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