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-07-27 17:53:44 Functions: 100.0 % 28 28
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 56.4 % 1051 593

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

Generated by: LCOV version 2.0-1