MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - ui - CommandBar.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 94.8 % 539 511
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 28 28
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 56.2 % 1057 594

             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                 :             : }
        

Generated by: LCOV version 2.0-1