Branch data Line data Source code
1 : : #include "CommandBar.h"
2 : : #include "../service/ImapResponseParser.h"
3 : :
4 : : #include <QApplication>
5 : : #include <QDateTime>
6 : : #include <QEvent>
7 : : #include <QHBoxLayout>
8 : : #include <QKeyEvent>
9 : : #include <QLabel>
10 : : #include <QLineEdit>
11 : : #include <QListWidget>
12 : : #include <QPropertyAnimation>
13 : : #include <QRegularExpression>
14 : : #include <QSignalBlocker>
15 : : #include <QVBoxLayout>
16 : :
17 [ + - ]: 100 : CommandBar::CommandBar(QWidget *parent) : QWidget(parent) {
18 [ + - ]: 100 : setupUi();
19 : 100 : }
20 : :
21 : 100 : void CommandBar::setupUi() {
22 : : // Sprint 75: Tridactyl-style overlay — the outer widget IS the panel.
23 : : // The panel background and top hairline are applied via QSS on this
24 : : // objectName (see main.qss). The suggestion list and input row sit
25 : : // directly inside it with 0 margins and 0 spacing so list + input
26 : : // read as one continuous surface, not two cards.
27 [ + - ]: 200 : setObjectName(QStringLiteral("commandBarOverlay"));
28 : : // Sprint 75 regression fix: a plain QWidget does NOT honor a QSS
29 : : // background-color rule unless WA_StyledBackground is set, so the
30 : : // panel was rendered transparent on screen and the text on top of
31 : : // it was unreadable. QListWidget (a QFrame) honors QSS backgrounds
32 : : // unconditionally — the pre-redesign styling lived on
33 : : // #commandSuggestions, which is why the transparency only surfaced
34 : : // after the background moved to this outer QWidget. Same pattern as
35 : : // FilterPopoverWidget / EventDetailPopup / EventEditDialog.
36 : 100 : setAttribute(Qt::WA_StyledBackground, true);
37 : :
38 : : // Main layout: suggestions above, input bar below — flush, no gap.
39 [ + - - + : 100 : auto *outerLayout = new QVBoxLayout(this);
- - ]
40 : 100 : outerLayout->setContentsMargins(0, 0, 0, 0);
41 : 100 : outerLayout->setSpacing(0);
42 : :
43 : : // Suggestion list (above the input bar, initially hidden).
44 [ + - - + : 100 : m_suggestionList = new QListWidget(this);
- - ]
45 [ + - ]: 200 : m_suggestionList->setObjectName(QStringLiteral("commandSuggestions"));
46 : : // Sprint 70: no fixed maximumHeight — computeTargetHeight() sizes the
47 : : // whole CommandBar dynamically based on the real rendered row height
48 : : // (sizeHintForRow(0)) plus a 10-item ceiling. The old fixed cap of
49 : : // 200px combined with the count*26+6 formula cut off entries whenever
50 : : // the actual row height exceeded the (too small) 26px assumption.
51 : : // Sprint 75: NoFrame so the list blends into the panel (Tridactyl
52 : : // style — no inner border between list and input row).
53 : 100 : m_suggestionList->setFrameShape(QFrame::NoFrame);
54 : 100 : m_suggestionList->setVisible(false);
55 : 100 : m_suggestionList->setFocusPolicy(Qt::NoFocus);
56 [ + - ]: 100 : outerLayout->addWidget(m_suggestionList);
57 : :
58 : : // Input bar container (transparent — the outer panel paints the bg).
59 [ + - - + : 100 : auto *barWidget = new QWidget(this);
- - ]
60 [ + - ]: 200 : barWidget->setObjectName(QStringLiteral("commandBarContainer"));
61 [ + - - + : 100 : auto *barLayout = new QHBoxLayout(barWidget);
- - ]
62 : : // Text padding only — the panel itself is flush with the host edges.
63 : 100 : barLayout->setContentsMargins(12, 4, 12, 4);
64 : 100 : barLayout->setSpacing(8);
65 : :
66 : : // Mode prefix label (e.g. ":", "/", "b:", "s→")
67 [ + - - + : 100 : m_prefixLabel = new QLabel(this);
- - ]
68 [ + - ]: 200 : m_prefixLabel->setObjectName(QStringLiteral("commandPrefix"));
69 [ + - ]: 100 : barLayout->addWidget(m_prefixLabel);
70 : :
71 : : // Text input
72 [ + - - + : 100 : m_input = new QLineEdit(this);
- - ]
73 [ + - ]: 200 : m_input->setObjectName(QStringLiteral("commandInput"));
74 : 100 : m_input->installEventFilter(this);
75 [ + - ]: 100 : barLayout->addWidget(m_input, 1);
76 : :
77 : 100 : barWidget->setFixedHeight(32);
78 [ + - ]: 100 : outerLayout->addWidget(barWidget);
79 : :
80 : : // Connect signals
81 [ + - ]: 100 : connect(m_input, &QLineEdit::textChanged, this, &CommandBar::onTextChanged);
82 : 100 : connect(m_input, &QLineEdit::returnPressed, this,
83 [ + - ]: 100 : &CommandBar::onReturnPressed);
84 : :
85 : : // Double-click on suggestion → accept it
86 : 100 : connect(m_suggestionList, &QListWidget::itemDoubleClicked, this,
87 [ + - ]: 101 : [this]() { acceptCurrentSuggestion(); });
88 : :
89 : : // Start hidden
90 : 100 : setVisible(false);
91 : 100 : setMaximumHeight(0);
92 : :
93 : : // Sprint 75: slide animation on `pos` (QPoint). The bar is now an
94 : : // overlay child of m_host (when set) and slides in from the bottom
95 : : // edge of the host. The previous "maximumHeight" animation only
96 : : // worked while the bar was a layout child — it now lives outside any
97 : : // layout so animating height would not move it. The host-less
98 : : // fallback path does not animate at all (see activate/deactivate).
99 [ + - + - : 100 : m_slideAnim = new QPropertyAnimation(this, "pos", this);
- + - - ]
100 : 100 : m_slideAnim->setDuration(120);
101 : 100 : }
102 : :
103 : : // Sprint 70: vertical extent is derived from real widget metrics, not a
104 : : // hardcoded constant. The previous `count * 26 + 6` formula assumed a
105 : : // 26px row height; the actual row (4px QSS padding + 13px font + line
106 : : // spacing ≈ 28-30px) was taller, so the outer CommandBar was sized too
107 : : // small and the QListWidget showed a scrollbar that cut off the last
108 : : // visible entry. Layout overhead (contentsMargins + spacing) was also
109 : : // missing from the old formula.
110 : : //
111 : : // Sprint 75: the Tridactyl-style redesign made the panel flush —
112 : : // outerLayout margins = 0, spacing = 0, suggestion list NoFrame — so
113 : : // the only overhead outside the rows themselves is the 32px input bar.
114 : 231 : int CommandBar::computeTargetHeight() const {
115 : : // Input bar is a fixed-height widget (32px, set in setupUi).
116 : 231 : const int barHeight = 32;
117 : :
118 : : // Vertical overhead *outside* the suggestion list: outer layout
119 : : // contentsMargins (top+bottom) + outer layout spacing between the
120 : : // list and the bar. Read from the live layout so changes to setupUi
121 : : // margins stay in sync automatically. With the Tridactyl redesign
122 : : // this is 0, but the formula is kept defensive.
123 : 231 : int overhead = 0;
124 [ + - + - : 231 : if (auto *box = qobject_cast<QVBoxLayout *>(layout())) {
+ - ]
125 [ + - + - ]: 231 : overhead += box->contentsMargins().top() + box->contentsMargins().bottom();
126 [ + - ]: 231 : overhead += box->spacing();
127 : : }
128 : : // Sprint 75: the suggestion list is NoFrame and borderless (merged
129 : : // into the panel), so there is no per-list frame overhead anymore.
130 : :
131 : : // No suggestions: just the bar plus overhead.
132 [ + - + - : 317 : if (!m_suggestionList || !m_suggestionList->isVisible() ||
+ + + + ]
133 [ + - - + ]: 86 : m_suggestionList->count() == 0)
134 : 145 : return barHeight + overhead;
135 : :
136 : : // Real rendered row height (includes the QSS `padding: 4px 12px` on
137 : : // QListWidget#commandSuggestions::item). Returns -1 before the first
138 : : // item is laid out — fall back to a sane default in that case.
139 [ + - ]: 86 : const int rowH = m_suggestionList->sizeHintForRow(0);
140 [ + - ]: 86 : const int effectiveRow = rowH > 0 ? rowH : 26;
141 : :
142 : : // Ceiling at 10 visible items so a 50-entry match does not eat the
143 : : // whole main window; the list itself scrolls internally beyond that.
144 : 86 : const int maxVisible = 10;
145 [ + - ]: 86 : const int visible = qMin(m_suggestionList->count(), maxVisible);
146 : :
147 : 86 : return barHeight + visible * effectiveRow + overhead;
148 : : }
149 : :
150 : 58 : QSize CommandBar::sizeHint() const {
151 : : // Width stays layout-driven; only the height is computed here.
152 [ + - + - ]: 58 : return {QWidget::sizeHint().width(), computeTargetHeight()};
153 : : }
154 : :
155 : 84 : void CommandBar::activate(Mode mode) {
156 : 84 : m_mode = mode;
157 : 84 : m_active = true;
158 : :
159 : : // Remember who had focus before
160 : 84 : m_previousFocus = QApplication::focusWidget();
161 : :
162 : : // Set prefix
163 [ + - + - ]: 84 : m_prefixLabel->setText(prefixForMode(mode));
164 : 84 : m_input->clear();
165 [ + - + - ]: 84 : m_input->setPlaceholderText(placeholderForMode(mode));
166 : :
167 : : // Pre-populate suggestions
168 : 84 : m_suggestionList->clear();
169 : 84 : m_showingFilterHelp = false;
170 : 84 : m_userNavigated = false;
171 [ + + + + ]: 84 : if (mode == FolderSwitch || mode == MoveToFolder) {
172 [ + - + - : 133 : for (const auto &f : m_folderPaths) {
+ + ]
173 [ + - ]: 99 : QString decoded = ImapResponseParser::decodeMailboxName(f);
174 [ + - + - : 99 : auto *item = new QListWidgetItem(decoded, m_suggestionList);
- + - - ]
175 [ + - ]: 99 : item->setData(Qt::UserRole, f); // raw IMAP path for operations
176 : 99 : }
177 : 34 : m_suggestionList->setVisible(!m_folderPaths.isEmpty());
178 [ + + ]: 84 : } else if (mode == Command) {
179 [ + - + - : 116 : for (const auto &c : m_commandNames) {
+ + ]
180 [ + - ]: 97 : m_suggestionList->addItem(c);
181 : : }
182 : 19 : m_suggestionList->setVisible(!m_commandNames.isEmpty());
183 [ + + ]: 31 : } else if (mode == Search) {
184 : : // Show initial filter help suggestions
185 [ + - ]: 11 : updateSuggestions({});
186 [ + + ]: 20 : } else if (mode == AddTask) {
187 : : // T-537: Show syntax help
188 [ + - + - ]: 11 : m_suggestionList->addItem(tr("/calendar Choose calendar"));
189 [ + - + - ]: 11 : m_suggestionList->addItem(tr("@tomorrow Due tomorrow"));
190 [ + - + - ]: 11 : m_suggestionList->addItem(tr("@2026-03-15 Due on date"));
191 [ + - + - ]: 11 : m_suggestionList->addItem(tr("!high High priority"));
192 [ + - + - ]: 11 : m_suggestionList->addItem(tr("!starred Starred"));
193 : 11 : m_showingFilterHelp = true;
194 : 11 : m_suggestionList->setVisible(true);
195 : : } else {
196 : 9 : m_suggestionList->setVisible(false);
197 : : }
198 : :
199 : : // Show and animate. Sprint 70: target height comes from
200 : : // computeTargetHeight() (real row metrics) instead of the old
201 : : // count*26+6 formula that was too small and cut off entries.
202 : : //
203 : : // Sprint 75: setVisible(true) is called BEFORE computeTargetHeight()
204 : : // so the suggestion list reports a correct isVisible() state. The
205 : : // overlay path pins the height via setFixedHeight(), so a stale
206 : : // "invisible" reading here would freeze the bar at bar+overhead
207 : : // even with N suggestions on screen.
208 : 84 : setVisible(true);
209 : :
210 : 84 : const int targetHeight = computeTargetHeight();
211 : :
212 [ + + ]: 84 : if (m_host) {
213 : : // Sprint 75 overlay path: position as a direct child of the host,
214 : : // docked at the bottom. Animate `pos` from just below the visible
215 : : // area up to the bottom-anchored target. The bar is outside any
216 : : // layout so the surrounding content (folder tree / mail list /
217 : : // mail view) is never displaced.
218 [ + - ]: 30 : setFixedHeight(targetHeight);
219 : 30 : const int hostW = m_host->width();
220 : 30 : const int hostH = m_host->height();
221 : 30 : const int w = qMax(0, hostW - 2 * m_overlayInset);
222 [ + - ]: 30 : resize(w, targetHeight);
223 : 30 : const QPoint startPos(m_overlayInset, hostH);
224 : : const QPoint endPos(m_overlayInset,
225 : 30 : qMax(0, hostH - m_overlayInset - targetHeight));
226 [ + - ]: 30 : move(startPos);
227 [ + - ]: 30 : raise();
228 [ + - ]: 30 : m_slideAnim->stop();
229 [ + - ]: 30 : m_slideAnim->setStartValue(startPos);
230 [ + - ]: 30 : m_slideAnim->setEndValue(endPos);
231 [ + - ]: 30 : m_slideAnim->start();
232 : : } else {
233 : : // Host-less fallback (existing direct unit tests): no animation,
234 : : // just size. setMaximumHeight() lifts the initial 0 limit so the
235 : : // bar is immediately visible at computeTargetHeight().
236 : 54 : setMaximumHeight(targetHeight);
237 : : }
238 : :
239 : 84 : m_input->setFocus();
240 : 84 : emit activeChanged(true);
241 : 84 : }
242 : :
243 : 62 : void CommandBar::deactivate() {
244 [ + + ]: 62 : if (!m_active)
245 : 3 : return;
246 : 59 : m_active = false;
247 : :
248 [ + + ]: 59 : if (m_host) {
249 : : // Sprint 75 overlay path: animate slide-out to just below the
250 : : // visible area, then hide. The snap target is hostH so the bar
251 : : // leaves the visible area cleanly.
252 : 21 : const int hostH = m_host->height();
253 [ + - ]: 21 : const QPoint startPos = pos();
254 : 21 : const QPoint endPos(m_overlayInset, hostH);
255 [ + - ]: 21 : m_slideAnim->stop();
256 [ + - ]: 21 : m_slideAnim->setStartValue(startPos);
257 [ + - ]: 21 : m_slideAnim->setEndValue(endPos);
258 : : // SingleShotConnection ensures the lambda runs once and detaches.
259 : 21 : connect(
260 : 21 : m_slideAnim, &QPropertyAnimation::finished, this,
261 [ + - ]: 21 : [this]() {
262 : 17 : setVisible(false);
263 : : // Clear the fixed-height constraint set during activate()
264 : : // so any later (host-less) sizing path is not pinned.
265 : 17 : setMinimumHeight(0);
266 : 17 : setMaximumHeight(16777215); // QWIDGETSIZE_MAX
267 : 17 : },
268 : : Qt::SingleShotConnection);
269 [ + - ]: 21 : m_slideAnim->start();
270 : : } else {
271 : : // Host-less fallback: stop animation, hide, restore height limit.
272 : 38 : m_slideAnim->stop();
273 : 38 : setVisible(false);
274 : 38 : setMaximumHeight(16777215); // QWIDGETSIZE_MAX
275 : : }
276 : :
277 : : // Restore focus
278 [ + + + + : 59 : if (m_previousFocus && m_previousFocus->isVisible()) {
+ + ]
279 : 8 : m_previousFocus->setFocus();
280 : : }
281 : 59 : m_previousFocus = nullptr;
282 : :
283 : 59 : emit activeChanged(false);
284 : : }
285 : :
286 : 83 : void CommandBar::setInputText(const QString &text) {
287 : : // Block m_input signals so this does not trigger onTextChanged (which would
288 : : // emit searchQueryChanged / run a live search). The mode is left untouched.
289 : 83 : const QSignalBlocker block(m_input);
290 [ + - ]: 83 : m_input->setText(text);
291 : 83 : }
292 : :
293 : 9 : QString CommandBar::inputText() const { return m_input->text(); }
294 : :
295 : 20 : void CommandBar::setFolderList(const QStringList &folders) {
296 : 20 : m_folderPaths = folders;
297 : 20 : }
298 : :
299 : 95 : void CommandBar::setCommandList(const QStringList &commands) {
300 : 95 : m_commandNames = commands;
301 : 95 : }
302 : :
303 : : // T-537: Calendar list for AddTask mode
304 : 6 : void CommandBar::setCalendarList(const QStringList &calendars) {
305 : 6 : m_calendarPaths = calendars;
306 : 6 : }
307 : :
308 : : // Sprint 75: host the bar as an overlay child of `host`. Reparents,
309 : : // installs a Resize event filter so window resize keeps the bar
310 : : // anchored at the bottom, and positions the (still hidden) bar below
311 : : // the visible area. Swapping the host detaches the previous filter.
312 : 62 : void CommandBar::setOverlayHost(QWidget *host) {
313 [ - + ]: 62 : if (m_host == host)
314 : 0 : return;
315 [ - + ]: 62 : if (m_host)
316 : 0 : m_host->removeEventFilter(this);
317 : 62 : m_host = host;
318 [ + - ]: 62 : if (m_host) {
319 : 62 : m_host->installEventFilter(this);
320 : 62 : setParent(m_host);
321 : : // Park below the visible area while hidden so the first slide-in
322 : : // has a clean start position.
323 [ + - ]: 62 : if (!m_active) {
324 : 62 : move(m_overlayInset, m_host->height());
325 : 62 : setVisible(false);
326 : : }
327 : : }
328 : : }
329 : :
330 : : // Sprint 75: re-anchor the bar inside m_host. Stop a still-running
331 : : // slide animation first so it cannot overwrite the recomputed geometry
332 : : // on its next tick (resize-during-animation guard, T-75.1 §7).
333 : 42 : void CommandBar::positionOverlay() {
334 [ - + ]: 42 : if (!m_host)
335 : 0 : return;
336 [ + + ]: 42 : if (m_slideAnim->state() == QAbstractAnimation::Running)
337 : 1 : m_slideAnim->stop();
338 : :
339 : 42 : const int hostW = m_host->width();
340 : 42 : const int hostH = m_host->height();
341 : 42 : const int w = qMax(0, hostW - 2 * m_overlayInset);
342 : 42 : const int h = height();
343 [ + + ]: 42 : if (m_active) {
344 : 31 : const int y = qMax(0, hostH - m_overlayInset - h);
345 : 31 : setGeometry(m_overlayInset, y, w, h);
346 : : }
347 : 42 : raise();
348 : : }
349 : :
350 : : // Sprint 75: apply computeTargetHeight() to the bar. With a host this
351 : : // pins the fixed height and repositions the overlay; without a host it
352 : : // lifts the maximumHeight limit (legacy layout-driven path).
353 : 89 : void CommandBar::adjustOverlayHeight() {
354 : 89 : const int targetHeight = computeTargetHeight();
355 [ + + ]: 89 : if (m_host) {
356 : 33 : setFixedHeight(targetHeight);
357 : 33 : positionOverlay();
358 : : } else {
359 : 56 : setMaximumHeight(targetHeight);
360 : : }
361 : 89 : }
362 : :
363 : : // T-180: Display search results in the suggestion list
364 : 18 : void CommandBar::setSearchResults(const QStringList &results) {
365 : : // Don't overwrite filter help suggestions (folder:, date:, etc.)
366 [ + + ]: 18 : if (m_showingFilterHelp)
367 : 1 : return;
368 : 17 : m_userNavigated = false;
369 : 17 : m_suggestionList->clear();
370 [ + + ]: 58 : for (const auto &r : results) {
371 [ + - ]: 41 : m_suggestionList->addItem(r);
372 : : }
373 : 17 : bool show = !results.isEmpty();
374 : 17 : m_suggestionList->setVisible(show);
375 [ + + ]: 17 : if (show) {
376 : 14 : m_suggestionList->setCurrentRow(0);
377 : : }
378 : : // Sprint 70/75: layout-driven sizing via computeTargetHeight(),
379 : : // routed through adjustOverlayHeight() so the overlay (if any)
380 : : // follows the new height.
381 : 17 : adjustOverlayHeight();
382 : : }
383 : :
384 : 210 : bool CommandBar::isActive() const { return m_active; }
385 : :
386 : 84 : QString CommandBar::prefixForMode(Mode mode) const {
387 [ + + + + : 84 : switch (mode) {
+ + - ]
388 : 19 : case Command:
389 : 19 : return QStringLiteral(":");
390 : 9 : case Filter:
391 : 9 : return QStringLiteral("/");
392 : 21 : case FolderSwitch:
393 : 21 : return QStringLiteral("b:");
394 : 13 : case MoveToFolder:
395 : 13 : return QStringLiteral("s\u2192");
396 : 11 : case Search:
397 : 11 : return QStringLiteral("\U0001F50D"); // 🔍
398 : 11 : case AddTask:
399 : 11 : return QStringLiteral("t\u2192"); // t→
400 : 0 : default:
401 : 0 : return {};
402 : : }
403 : : }
404 : :
405 : : // T-76.B3: placeholder text per mode. Centralised so activate() and
406 : : // retranslateUi() share the exact same source strings — a single edit
407 : : // point when a mode label changes.
408 : 170 : QString CommandBar::placeholderForMode(Mode mode) const {
409 [ + + + + : 170 : switch (mode) {
+ + ]
410 : 95 : case Command:
411 : 95 : return tr("Enter command...");
412 : 9 : case Filter:
413 : 9 : return tr("Filter...");
414 : 25 : case FolderSwitch:
415 : 25 : return tr("Switch folder...");
416 : 11 : case Search:
417 : 11 : return tr("Search...");
418 : 11 : case AddTask:
419 : 11 : return tr("New task: title /calendar @date !priority");
420 : 19 : case MoveToFolder:
421 : : default:
422 : 19 : return tr("Move to...");
423 : : }
424 : : }
425 : :
426 : : // T-76.B3: Runtime language switching
427 : 466 : void CommandBar::changeEvent(QEvent *event) {
428 [ + + ]: 466 : if (event->type() == QEvent::LanguageChange)
429 : 86 : retranslateUi();
430 : 466 : QWidget::changeEvent(event);
431 : 466 : }
432 : :
433 : 86 : void CommandBar::retranslateUi() {
434 : : // Re-apply the placeholder for the CURRENT mode. Suggestion-list
435 : : // entries are transient (rebuilt on activate()/onTextChanged()) so
436 : : // they pick up the new language naturally on the next refresh.
437 [ + - + - ]: 86 : m_input->setPlaceholderText(placeholderForMode(m_mode));
438 : 86 : }
439 : :
440 : 87 : void CommandBar::onTextChanged(const QString &text) {
441 [ + + ]: 87 : if (m_mode == Filter) {
442 : 26 : emit filterTextChanged(text);
443 [ + + ]: 61 : } else if (m_mode == Search) {
444 : : // Always update filter suggestions first
445 : 23 : updateSuggestions(text);
446 : 23 : m_userNavigated = false;
447 : : // Only emit searchQueryChanged if NOT showing filter help
448 : : // (i.e. there's actual FTS query text, not just filter prefixes)
449 [ + + ]: 23 : if (!m_showingFilterHelp) {
450 : 13 : emit searchQueryChanged(text);
451 : : }
452 : 23 : return;
453 : : }
454 : 64 : updateSuggestions(text);
455 : : }
456 : :
457 : 16 : void CommandBar::onReturnPressed() {
458 [ + - + - ]: 16 : QString text = m_input->text().trimmed();
459 : :
460 [ + + + + : 16 : switch (m_mode) {
+ - ]
461 : 1 : case Command: {
462 : : // Use highlighted suggestion if available, else typed text
463 [ + - ]: 1 : auto *current = m_suggestionList->currentItem();
464 [ + - + - ]: 1 : QString cmd = (current && m_suggestionList->isVisible())
465 [ + - ]: 1 : ? current->text()
466 [ + - ]: 1 : : text;
467 [ + - ]: 1 : if (!cmd.isEmpty())
468 [ + - ]: 1 : emit commandSubmitted(cmd);
469 [ + - ]: 1 : deactivate();
470 : 1 : break;
471 : 1 : }
472 : :
473 : 1 : case Filter:
474 : : // Enter in filter mode = accept current filter and close
475 [ + - ]: 1 : deactivate();
476 : 1 : break;
477 : :
478 : 3 : case FolderSwitch:
479 : : case MoveToFolder: {
480 : : // If a suggestion is selected, use that; otherwise use the typed text
481 : 3 : QString folder;
482 [ + - ]: 3 : auto *current = m_suggestionList->currentItem();
483 [ + + + - : 3 : if (current && m_suggestionList->isVisible()) {
+ - + + ]
484 : : // Use raw IMAP path from UserRole (display text is decoded)
485 [ + - + - ]: 2 : folder = current->data(Qt::UserRole).toString();
486 [ - + - - ]: 2 : if (folder.isEmpty()) folder = current->text();
487 [ + - ]: 1 : } else if (!text.isEmpty()) {
488 : : // Try exact match against raw paths and decoded names
489 [ + - + - : 2 : for (const auto &f : m_folderPaths) {
+ - ]
490 [ + - ]: 2 : QString decoded = ImapResponseParser::decodeMailboxName(f);
491 [ + + - + : 3 : if (f.compare(text, Qt::CaseInsensitive) == 0 ||
+ + ]
492 : 1 : decoded.compare(text, Qt::CaseInsensitive) == 0) {
493 : 1 : folder = f;
494 : 1 : break;
495 : : }
496 [ + + ]: 2 : }
497 : : // Fallback: first suggestion's raw path
498 [ - + - - : 1 : if (folder.isEmpty() && m_suggestionList->count() > 0) {
- - - + ]
499 [ # # # # : 0 : folder = m_suggestionList->item(0)->data(Qt::UserRole).toString();
# # ]
500 [ # # # # : 0 : if (folder.isEmpty()) folder = m_suggestionList->item(0)->text();
# # ]
501 : : }
502 : : }
503 [ + - ]: 3 : if (!folder.isEmpty()) {
504 [ + - ]: 3 : emit folderSelected(m_mode, folder);
505 : : }
506 [ + - ]: 3 : deactivate();
507 : 3 : break;
508 : 3 : }
509 : :
510 : 4 : case Search: {
511 [ + - ]: 4 : int row = m_suggestionList->currentRow();
512 : : // Only jump to a specific preview result when the user has explicitly
513 : : // navigated the list with the arrow keys. The first row is auto-selected
514 : : // for preview, so a plain Enter after typing must RUN the search (show the
515 : : // full result list) rather than open the first hit.
516 [ + - + - ]: 2 : if (m_userNavigated && m_suggestionList->isVisible()
517 [ + + + - : 6 : && row >= 0 && !m_showingFilterHelp) {
+ + + + ]
518 [ + - ]: 1 : emit searchResultSelected(row);
519 : : } else {
520 [ + - + - : 3 : emit searchSubmitted(m_input->text().trimmed());
+ - ]
521 : : }
522 [ + - ]: 4 : deactivate();
523 : 4 : break;
524 : : }
525 : :
526 : 7 : case AddTask: {
527 : : // T-537: Parse AddTask input
528 : : // Format: "Title /Calendar @date !priority"
529 [ + - + - ]: 7 : QString input = m_input->text().trimmed();
530 [ - + - - ]: 7 : if (input.isEmpty()) { deactivate(); break; }
531 : :
532 : 7 : QString title, calendarPath;
533 : 7 : QDateTime due;
534 : 7 : int priority = 0;
535 : :
536 : : // Extract tokens
537 [ + - ]: 7 : QStringList tokens = input.split(QLatin1Char(' '), Qt::SkipEmptyParts);
538 : 7 : QStringList titleParts;
539 [ + - + - : 22 : for (const QString &tok : tokens) {
+ + ]
540 [ + - + + ]: 15 : if (tok.startsWith(QLatin1Char('/'))) {
541 : : // Calendar selector
542 [ + - ]: 1 : QString calName = tok.mid(1);
543 [ + - + - : 2 : for (const auto &c : m_calendarPaths) {
+ - ]
544 [ + - + + ]: 2 : if (c.contains(calName, Qt::CaseInsensitive)) {
545 : 1 : calendarPath = c;
546 : 1 : break;
547 : : }
548 : : }
549 [ + - + + ]: 15 : } else if (tok.startsWith(QLatin1Char('@'))) {
550 : : // Date
551 [ + - + - ]: 2 : QString dateStr = tok.mid(1).toLower();
552 [ + + - + : 7 : if (dateStr == QStringLiteral("morgen") ||
+ - + + ]
553 [ + + + + : 3 : dateStr == QStringLiteral("tomorrow")) {
+ - ]
554 [ + - + - : 1 : due = QDateTime(QDate::currentDate().addDays(1), QTime(0, 0));
+ - + - ]
555 [ + - - + : 4 : } else if (dateStr == QStringLiteral("n\u00e4chstewoche") ||
+ - - + ]
556 [ + - + - : 2 : dateStr == QStringLiteral("nextweek")) {
+ - ]
557 [ # # # # : 0 : due = QDateTime(QDate::currentDate().addDays(7), QTime(0, 0));
# # # # ]
558 : : } else {
559 [ + - ]: 1 : QDate d = QDate::fromString(dateStr, Qt::ISODate);
560 [ + - + - ]: 1 : if (d.isValid())
561 [ + - + - ]: 1 : due = QDateTime(d, QTime(0, 0));
562 : : }
563 [ + - + + ]: 14 : } else if (tok.startsWith(QLatin1Char('!'))) {
564 : : // Priority
565 [ + - + - ]: 4 : QString priStr = tok.mid(1).toLower();
566 [ + + - + : 15 : if (priStr == QStringLiteral("hoch") ||
+ - + + ]
567 [ + + + + : 7 : priStr == QStringLiteral("high"))
+ - ]
568 : 1 : priority = 5;
569 [ + - + + : 12 : else if (priStr == QStringLiteral("dringend") ||
+ - + + ]
570 [ + - + - : 6 : priStr == QStringLiteral("urgent"))
+ - ]
571 : 1 : priority = 9;
572 [ + - + + : 8 : else if (priStr == QStringLiteral("starred") ||
+ - + + ]
573 [ + - + - : 4 : priStr == QStringLiteral("star"))
+ - ]
574 : 1 : priority = 1;
575 : 4 : } else {
576 [ + - ]: 8 : titleParts.append(tok);
577 : : }
578 : : }
579 [ + - ]: 7 : title = titleParts.join(QLatin1Char(' '));
580 [ + - ]: 7 : if (!title.isEmpty()) {
581 [ + - ]: 7 : emit taskSubmitted(title, calendarPath, due, priority);
582 : : }
583 [ + - ]: 7 : deactivate();
584 : 7 : break;
585 : 7 : }
586 : : }
587 : 16 : }
588 : :
589 : 98 : void CommandBar::updateSuggestions(const QString &text) {
590 [ + + ]: 98 : if (m_mode == Filter) {
591 : : // No internal suggestions in filter mode
592 [ + - ]: 26 : m_suggestionList->setVisible(false);
593 : 75 : return;
594 : : }
595 : :
596 [ + + ]: 72 : if (m_mode == Search) {
597 : : // T-193: Context-sensitive filter suggestions in Search mode
598 [ + - ]: 34 : m_suggestionList->clear();
599 : :
600 : 34 : bool isFilterHelp = false;
601 : :
602 [ + - + + ]: 34 : if (text.trimmed().isEmpty()) {
603 : : // Empty field → show available filter prefixes
604 [ + - + - ]: 15 : m_suggestionList->addItem(tr("folder: Restrict to folder"));
605 [ + - + - ]: 15 : m_suggestionList->addItem(tr("date: Filter by date range"));
606 [ + - + - ]: 15 : m_suggestionList->addItem(tr("from: Filter by sender"));
607 [ + - + - ]: 15 : m_suggestionList->addItem(tr("to: Filter by recipient"));
608 : 15 : isFilterHelp = true;
609 : : } else {
610 : : // Check last token for filter prefix being typed
611 [ + - ]: 19 : QStringList parts = text.split(QLatin1Char(' '), Qt::SkipEmptyParts);
612 [ - + + - ]: 19 : QString lastToken = parts.isEmpty() ? QString() : parts.last();
613 : :
614 [ + - + + ]: 19 : if (lastToken.startsWith(QStringLiteral("folder:"), Qt::CaseInsensitive)) {
615 : : // folder: → show matching folder names (decoded)
616 [ + - + - ]: 3 : QString partial = lastToken.mid(7).toLower();
617 [ + - + - : 12 : for (const QString &f : m_folderPaths) {
+ + ]
618 [ + - ]: 9 : QString decoded = ImapResponseParser::decodeMailboxName(f);
619 [ + - + - : 24 : if (partial.isEmpty() || decoded.toLower().contains(partial) ||
+ - + + +
+ - - ]
620 [ + - + - : 15 : f.toLower().contains(partial))
- + + + +
- - - ]
621 [ + - ]: 3 : m_suggestionList->addItem(decoded);
622 : 9 : }
623 : 3 : isFilterHelp = true;
624 [ + - + + ]: 19 : } else if (lastToken.startsWith(QStringLiteral("date:"), Qt::CaseInsensitive)) {
625 : : // date: → show presets
626 [ + - + - ]: 3 : QString partial = lastToken.mid(5).toLower();
627 : : QStringList presets = {
628 : 0 : QStringLiteral("heute Nur heute"),
629 : 3 : QStringLiteral("gestern Nur gestern"),
630 : 3 : QStringLiteral("woche Letzte 7 Tage"),
631 : 3 : QStringLiteral("monat Letzte 30 Tage"),
632 : 3 : QStringLiteral("jahr Letztes Jahr"),
633 [ + + - - ]: 18 : };
634 [ + - + - : 18 : for (const QString &p : presets) {
+ + ]
635 [ + + + - : 15 : if (partial.isEmpty() || p.toLower().startsWith(partial))
+ - + + +
+ + + -
- ]
636 [ + - ]: 11 : m_suggestionList->addItem(p);
637 : : }
638 : 3 : isFilterHelp = true;
639 : 3 : }
640 : : // No suggestions while typing from:/to: values or free text
641 : 19 : }
642 : :
643 : 34 : m_showingFilterHelp = isFilterHelp;
644 : :
645 [ + - ]: 34 : bool hasSuggestions = m_suggestionList->count() > 0;
646 [ + - ]: 34 : m_suggestionList->setVisible(hasSuggestions);
647 [ + + ]: 34 : if (hasSuggestions)
648 [ + - ]: 21 : m_suggestionList->setCurrentRow(0);
649 : :
650 : : // Sprint 70/75: layout-driven sizing via computeTargetHeight(),
651 : : // routed through adjustOverlayHeight().
652 [ + - ]: 34 : adjustOverlayHeight();
653 : 34 : return;
654 : : }
655 : :
656 : : // Sprint 56: AddTask mode — context-sensitive suggestions for /, @, !
657 [ + + ]: 38 : if (m_mode == AddTask) {
658 [ + - ]: 15 : m_suggestionList->clear();
659 [ + - ]: 15 : QStringList parts = text.split(QLatin1Char(' '), Qt::SkipEmptyParts);
660 [ + + + - ]: 15 : QString lastToken = parts.isEmpty() ? QString() : parts.last();
661 : 15 : bool isHelp = false;
662 : :
663 [ + - + + ]: 15 : if (text.trimmed().isEmpty()) {
664 : : // Initial help suggestions
665 [ + - + - ]: 9 : m_suggestionList->addItem(tr("/calendar Choose calendar"));
666 [ + - + - ]: 9 : m_suggestionList->addItem(tr("@tomorrow Due tomorrow"));
667 [ + - + - ]: 9 : m_suggestionList->addItem(tr("@2026-03-15 Due on date"));
668 [ + - + - ]: 9 : m_suggestionList->addItem(tr("!high High priority"));
669 [ + - + - ]: 9 : m_suggestionList->addItem(tr("!starred Starred"));
670 : 9 : isHelp = true;
671 [ + - + + ]: 6 : } else if (lastToken.startsWith(QLatin1Char('/'))) {
672 : : // Calendar suggestions from m_calendarPaths
673 [ + - + - ]: 3 : QString partial = lastToken.mid(1).toLower();
674 [ + - + - : 9 : for (const auto &c : m_calendarPaths) {
+ + ]
675 [ + - ]: 6 : QString name = c.section(QLatin1Char('/'), -2, -2);
676 [ + - + - : 6 : if (partial.isEmpty() || name.toLower().contains(partial))
+ - + + +
- + + -
- ]
677 [ + - ]: 3 : m_suggestionList->addItem(name);
678 : 6 : }
679 : 3 : isHelp = true;
680 [ + - + + ]: 6 : } else if (lastToken.startsWith(QLatin1Char('@'))) {
681 : : // Date suggestions
682 [ + - + - ]: 1 : QString partial = lastToken.mid(1).toLower();
683 : : QStringList presets = {
684 : 0 : QStringLiteral("morgen"),
685 : 1 : QStringLiteral("n\u00e4chsteWoche"),
686 [ + + - - ]: 3 : };
687 [ + - + - : 3 : for (const auto &p : presets) {
+ + ]
688 [ + - + - : 2 : if (partial.isEmpty() || p.toLower().startsWith(partial))
+ - + + +
- + + -
- ]
689 [ + - ]: 1 : m_suggestionList->addItem(p);
690 : : }
691 : 1 : isHelp = true;
692 [ + - + - ]: 3 : } else if (lastToken.startsWith(QLatin1Char('!'))) {
693 : : // Priority suggestions
694 [ + - + - ]: 2 : QString partial = lastToken.mid(1).toLower();
695 : : QStringList presets = {
696 : 0 : QStringLiteral("hoch"),
697 : 2 : QStringLiteral("dringend"),
698 : 2 : QStringLiteral("starred"),
699 [ + + - - ]: 8 : };
700 [ + - + - : 8 : for (const auto &p : presets) {
+ + ]
701 [ + - + - : 6 : if (partial.isEmpty() || p.toLower().startsWith(partial))
+ - + + +
- + + -
- ]
702 [ + - ]: 2 : m_suggestionList->addItem(p);
703 : : }
704 : 2 : isHelp = true;
705 : 2 : }
706 : :
707 : 15 : m_showingFilterHelp = isHelp;
708 [ + - ]: 15 : bool hasSugg = m_suggestionList->count() > 0;
709 [ + - ]: 15 : m_suggestionList->setVisible(hasSugg);
710 [ + - ]: 15 : if (hasSugg) {
711 [ + - ]: 15 : m_suggestionList->setCurrentRow(0);
712 : : }
713 : : // Sprint 70/75: layout-driven sizing via computeTargetHeight(),
714 : : // routed through adjustOverlayHeight().
715 [ + - ]: 15 : adjustOverlayHeight();
716 : 15 : return;
717 : 15 : }
718 : :
719 : 23 : const QStringList &source =
720 [ + + ]: 23 : (m_mode == Command) ? m_commandNames : m_folderPaths;
721 : :
722 [ + - ]: 23 : m_suggestionList->clear();
723 [ + - ]: 23 : QString lower = text.toLower();
724 : :
725 [ + + + + ]: 23 : bool isFolderMode = (m_mode == FolderSwitch || m_mode == MoveToFolder);
726 [ + + ]: 83 : for (const auto &item : source) {
727 : : QString decoded = isFolderMode
728 [ + + + - ]: 60 : ? ImapResponseParser::decodeMailboxName(item) : item;
729 [ + + + - : 149 : if (text.isEmpty() || decoded.toLower().contains(lower) ||
+ - + + +
+ - - ]
730 [ + - + - : 89 : item.toLower().contains(lower)) {
- + + + +
+ - - ]
731 [ + - + - : 31 : auto *listItem = new QListWidgetItem(decoded, m_suggestionList);
- + - - ]
732 [ + + ]: 31 : if (isFolderMode)
733 [ + - ]: 28 : listItem->setData(Qt::UserRole, item); // raw IMAP path
734 : : }
735 : 60 : }
736 : :
737 [ + - ]: 23 : bool hasSuggestions = m_suggestionList->count() > 0;
738 [ + - ]: 23 : m_suggestionList->setVisible(hasSuggestions);
739 : :
740 : : // Auto-select first item
741 [ + + ]: 23 : if (hasSuggestions) {
742 [ + - ]: 13 : m_suggestionList->setCurrentRow(0);
743 : : }
744 : :
745 : : // Sprint 70/75: layout-driven sizing via computeTargetHeight() —
746 : : // fixes the cutoff bug when filtering folders with few matches
747 : : // (e.g. only 2 entries were shown but the last one was clipped by a
748 : : // scrollbar because the old count*26+6 formula sized the bar too
749 : : // small). Routed through adjustOverlayHeight() so the overlay (if
750 : : // any) follows the new height.
751 [ + - ]: 23 : adjustOverlayHeight();
752 [ + - + - : 52 : }
+ - - - -
- - - - -
- - - - ]
753 : :
754 : 4 : void CommandBar::selectSuggestion(int delta) {
755 [ + - - + : 4 : if (!m_suggestionList->isVisible() || m_suggestionList->count() == 0)
- + ]
756 : 0 : return;
757 : :
758 : 4 : int current = m_suggestionList->currentRow();
759 : 4 : int next = current + delta;
760 : :
761 : : // Wrap around
762 [ - + ]: 4 : if (next < 0)
763 : 0 : next = m_suggestionList->count() - 1;
764 [ - + ]: 4 : if (next >= m_suggestionList->count())
765 : 0 : next = 0;
766 : :
767 : 4 : m_suggestionList->setCurrentRow(next);
768 : 4 : m_suggestionList->scrollToItem(m_suggestionList->currentItem());
769 : : }
770 : :
771 : 1 : void CommandBar::acceptCurrentSuggestion() {
772 : 1 : auto *current = m_suggestionList->currentItem();
773 [ - + ]: 1 : if (!current)
774 : 0 : return;
775 : :
776 [ - + ]: 1 : if (m_mode == Command) {
777 [ # # # # ]: 0 : m_input->setText(current->text());
778 : 0 : onReturnPressed();
779 : : } else {
780 : : // Folder modes: use raw IMAP path from UserRole
781 [ + - + - ]: 1 : QString path = current->data(Qt::UserRole).toString();
782 [ - + - - ]: 1 : if (path.isEmpty()) path = current->text();
783 [ + - ]: 1 : emit folderSelected(m_mode, path);
784 [ + - ]: 1 : deactivate();
785 : 1 : }
786 : : }
787 : :
788 : 5096 : bool CommandBar::eventFilter(QObject *obj, QEvent *event) {
789 : : // Sprint 75: keep the overlay anchored to the host on resize, even
790 : : // while the slide animation is still running (positionOverlay stops
791 : : // it and snaps to the recomputed geometry — see T-75.1 §7).
792 [ + + + + : 5096 : if (obj == m_host && event->type() == QEvent::Resize) {
+ + ]
793 : 9 : positionOverlay();
794 : 9 : return false; // do not eat the host's resize
795 : : }
796 [ + + + + : 5087 : if (obj == m_input && event->type() == QEvent::KeyPress) {
+ + ]
797 : 72 : auto *keyEvent = static_cast<QKeyEvent *>(event);
798 : :
799 [ + + - + : 72 : switch (keyEvent->key()) {
+ + ]
800 : 1 : case Qt::Key_Escape:
801 : 1 : emit cancelled();
802 : 1 : deactivate();
803 : 1 : return true;
804 : :
805 : 4 : case Qt::Key_Tab:
806 : : // Sprint 56: Tab completion for AddTask mode
807 [ + + + - : 4 : if (m_mode == AddTask && m_suggestionList->isVisible()) {
+ + ]
808 : 2 : auto *item = m_suggestionList->currentItem();
809 [ + - ]: 2 : if (item) {
810 [ + - + - ]: 6 : QString value = item->text().split(
811 [ + - + - : 6 : QRegularExpression(QStringLiteral("\\s{2,}"))).first().trimmed();
+ - ]
812 [ + - + - ]: 4 : QStringList parts = m_input->text().split(QLatin1Char(' '));
813 [ - + + - ]: 2 : QString last = parts.isEmpty() ? QString() : parts.last();
814 [ + - ]: 2 : if (last.startsWith(QLatin1Char('/')) ||
815 [ + + + - : 3 : last.startsWith(QLatin1Char('@')) ||
+ - ]
816 [ + - + - : 3 : last.startsWith(QLatin1Char('!'))) {
+ - ]
817 : 2 : parts.removeLast();
818 [ + - + - : 2 : parts.append(last.left(1) + value + QLatin1Char(' '));
+ - + - ]
819 [ + - + - ]: 2 : m_input->setText(parts.join(QLatin1Char(' ')));
820 [ # # # # : 0 : } else if (m_input->text().trimmed().isEmpty()) {
# # ]
821 [ # # ]: 0 : m_input->setText(value);
822 : : }
823 [ + - + - ]: 2 : m_input->setCursorPosition(m_input->text().length());
824 : 2 : }
825 : 2 : return true;
826 : : }
827 [ + - + - : 2 : if (m_mode == Search && m_suggestionList->isVisible()) {
+ - ]
828 : : // T-193: Insert selected suggestion into search text
829 : 2 : auto *item = m_suggestionList->currentItem();
830 [ + - ]: 2 : if (item) {
831 [ + - ]: 2 : QString suggestion = item->text();
832 [ + - ]: 2 : QString text = m_input->text();
833 : :
834 : : // Extract just the value part (before any whitespace description)
835 [ + - + - : 2 : QString value = suggestion.split(QRegularExpression(QStringLiteral("\\s{2,}"))).first().trimmed();
+ - + - ]
836 : :
837 [ + - ]: 2 : QStringList parts = text.split(QLatin1Char(' '));
838 [ - + + - : 2 : QString lastToken = parts.isEmpty() ? QString() : parts.last().trimmed();
+ - ]
839 : :
840 [ + - - + ]: 2 : if (text.trimmed().isEmpty()) {
841 : : // Empty field → insert filter prefix
842 [ # # ]: 0 : m_input->setText(value);
843 [ + - + + ]: 2 : } else if (lastToken.startsWith(QStringLiteral("folder:"), Qt::CaseInsensitive)) {
844 : : // Replace last token with folder:VALUE
845 : 1 : parts.removeLast();
846 [ + - + - : 2 : parts.append(QStringLiteral("folder:") + value + QLatin1Char(' '));
+ - ]
847 [ + - + - ]: 1 : m_input->setText(parts.join(QLatin1Char(' ')));
848 [ + - + - ]: 1 : } else if (lastToken.startsWith(QStringLiteral("date:"), Qt::CaseInsensitive)) {
849 : : // Replace last token with date:VALUE
850 : 1 : parts.removeLast();
851 [ + - + - : 2 : parts.append(QStringLiteral("date:") + value + QLatin1Char(' '));
+ - ]
852 [ + - + - ]: 1 : m_input->setText(parts.join(QLatin1Char(' ')));
853 : : }
854 [ + - + - ]: 2 : m_input->setCursorPosition(m_input->text().length());
855 : 2 : }
856 : : } else {
857 : 0 : selectSuggestion(+1);
858 : : }
859 : 2 : return true;
860 : :
861 : 0 : case Qt::Key_Backtab: // Shift+Tab
862 : 0 : selectSuggestion(-1);
863 : 0 : return true;
864 : :
865 : 3 : case Qt::Key_Down:
866 [ - + ]: 3 : if (m_mode == Filter) {
867 : 0 : emit navigateMailList(+1);
868 : 0 : return true;
869 : : }
870 : 3 : m_userNavigated = true;
871 : 3 : selectSuggestion(+1);
872 : 3 : return true;
873 : :
874 : 1 : case Qt::Key_Up:
875 [ - + ]: 1 : if (m_mode == Filter) {
876 : 0 : emit navigateMailList(-1);
877 : 0 : return true;
878 : : }
879 : 1 : m_userNavigated = true;
880 : 1 : selectSuggestion(-1);
881 : 1 : return true;
882 : :
883 : 63 : default:
884 : 63 : break;
885 : : }
886 : : }
887 : 5078 : return QWidget::eventFilter(obj, event);
888 : : }
|