MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - ui - SuggestionOverlay.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 77.6 % 156 121
Test Date: 2026-06-21 21:10:19 Functions: 90.0 % 10 9
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 45.3 % 258 117

             Branch data     Line data    Source code
       1                 :             : #include "SuggestionOverlay.h"
       2                 :             : 
       3                 :             : #include <QAbstractItemModel>
       4                 :             : #include <QEvent>
       5                 :             : #include <QPainter>
       6                 :             : #include <QScrollBar>
       7                 :             : #include <QSortFilterProxyModel>
       8                 :             : #include <QTreeView>
       9                 :             : 
      10                 :             : #include "data/FolderPredictor.h"
      11                 :             : #include "data/Models.h"
      12                 :             : #include "service/ImapResponseParser.h"
      13                 :             : #include "ui/ThemeManager.h"
      14                 :             : 
      15                 :          14 : SuggestionOverlay::SuggestionOverlay(QTreeView *mailList, QWidget *parent)
      16         [ +  - ]:          14 :     : QWidget(parent), m_mailList(mailList) {
      17         [ +  - ]:          14 :   setAttribute(Qt::WA_TransparentForMouseEvents, true);
      18         [ +  - ]:          14 :   setAttribute(Qt::WA_NoSystemBackground, true);
      19         [ +  - ]:          14 :   hide();
      20                 :             : 
      21                 :             :   // Listen to viewport scroll/resize to repaint badges
      22   [ +  -  +  -  :          14 :   if (m_mailList && m_mailList->viewport()) {
             +  -  +  - ]
      23   [ +  -  +  - ]:          14 :     m_mailList->viewport()->installEventFilter(this);
      24                 :             :   }
      25                 :          14 : }
      26                 :             : 
      27                 :           7 : void SuggestionOverlay::activate(FolderPredictor *predictor,
      28                 :             :                                   const QList<MailHeader> &headers,
      29                 :             :                                   const QString &currentFolder,
      30                 :             :                                   const QString &junkFolder) {
      31                 :           7 :   m_suggestions.clear();
      32                 :           7 :   m_altSuggestions.clear();
      33                 :           7 :   m_showingAlternate.clear();
      34                 :           7 :   m_predicted.clear();
      35                 :           7 :   m_rowUids.clear();
      36                 :           7 :   m_headersByUid.clear();
      37                 :             : 
      38   [ +  -  +  -  :           7 :   if (!predictor || !m_mailList || !m_mailList->model())
             +  +  +  + ]
      39                 :           2 :     return;
      40                 :             : 
      41                 :             :   // Store references for lazy prediction
      42                 :           5 :   m_predictor = predictor;
      43                 :           5 :   m_currentFolder = currentFolder;
      44                 :           5 :   m_junkFolder = junkFolder;
      45                 :             : 
      46                 :             :   // Build UID mappings (fast — no SQL queries)
      47         [ +  + ]:          18 :   for (const auto &h : headers) {
      48         [ +  - ]:          13 :     m_rowUids.append(h.uid);
      49         [ +  - ]:          13 :     m_headersByUid[h.uid] = h;
      50                 :             :   }
      51                 :             : 
      52                 :           5 :   m_active = true;
      53                 :             :   // Resize to match viewport
      54         [ +  - ]:           5 :   if (m_mailList->viewport()) {
      55   [ +  -  +  - ]:           5 :     setGeometry(m_mailList->viewport()->rect());
      56                 :             :   }
      57                 :           5 :   show();
      58                 :           5 :   raise();
      59                 :             : }
      60                 :             : 
      61                 :           9 : void SuggestionOverlay::deactivate() {
      62                 :           9 :   m_active = false;
      63                 :           9 :   m_predictor = nullptr;
      64                 :           9 :   hide();
      65                 :           9 : }
      66                 :             : 
      67                 :         624 : void SuggestionOverlay::ensurePrediction(qint64 uid) {
      68   [ +  +  -  +  :         624 :   if (m_predicted.contains(uid) || !m_predictor)
                   +  + ]
      69                 :         612 :     return;
      70         [ +  - ]:          12 :   m_predicted.insert(uid);
      71                 :             : 
      72         [ +  - ]:          12 :   auto it = m_headersByUid.constFind(uid);
      73   [ +  -  -  + ]:          12 :   if (it == m_headersByUid.constEnd())
      74                 :           0 :     return;
      75                 :          12 :   const auto &h = it.value();
      76                 :             : 
      77         [ +  - ]:          12 :   auto topN = m_predictor->predictTop(h.from, h.subject, h.to, 2);
      78                 :             : 
      79                 :             :   // T-231: X-Spam override for primary suggestion
      80         [ +  + ]:          12 :   if (!topN.isEmpty()) {
      81         [ +  - ]:           3 :     auto &primary = topN[0];
      82   [ +  +  +  -  :           3 :     if (h.isSpam && !m_junkFolder.isEmpty()) {
                   +  + ]
      83   [ -  +  -  -  :           1 :       if (primary.second < 0.98 || primary.first == m_junkFolder) {
                   +  - ]
      84                 :           1 :         primary.first = m_junkFolder;
      85                 :           1 :         primary.second = qMax(primary.second, 0.95);
      86                 :             :       }
      87                 :             :     }
      88                 :             : 
      89                 :             :     // Don't suggest current folder
      90   [ +  +  +  -  :           3 :     if (primary.first != m_currentFolder && primary.second >= 0.4) {
                   +  + ]
      91                 :           2 :       SuggestionInfo info;
      92                 :           2 :       info.folderFull = primary.first;
      93         [ +  - ]:           2 :       info.folderShort = shortFolderName(primary.first);
      94                 :           2 :       info.confidence = primary.second;
      95                 :           2 :       info.isSpam = h.isSpam;
      96                 :           2 :       info.isAlternate = false;
      97         [ +  - ]:           2 :       m_suggestions[uid] = info;
      98                 :           2 :     }
      99                 :             :   }
     100                 :             : 
     101                 :             :   // Store secondary suggestion
     102         [ +  + ]:          12 :   if (topN.size() >= 2) {
     103         [ +  - ]:           3 :     auto &secondary = topN[1];
     104   [ +  -  +  +  :           3 :     if (secondary.first != m_currentFolder && secondary.second >= 0.01) {
                   +  + ]
     105                 :           1 :       SuggestionInfo altInfo;
     106                 :           1 :       altInfo.folderFull = secondary.first;
     107         [ +  - ]:           1 :       altInfo.folderShort = shortFolderName(secondary.first);
     108                 :           1 :       altInfo.confidence = secondary.second;
     109                 :           1 :       altInfo.isSpam = h.isSpam;
     110                 :           1 :       altInfo.isAlternate = true;
     111         [ +  - ]:           1 :       m_altSuggestions[uid] = altInfo;
     112                 :           1 :     }
     113                 :             :   }
     114                 :          12 : }
     115                 :             : 
     116                 :           7 : void SuggestionOverlay::toggleAlternate(qint64 uid) {
     117         [ -  + ]:           7 :   if (m_showingAlternate.contains(uid)) {
     118                 :           0 :     m_showingAlternate.remove(uid);
     119         [ -  + ]:           7 :   } else if (m_altSuggestions.contains(uid)) {
     120         [ #  # ]:           0 :     m_showingAlternate.insert(uid);
     121                 :             :   }
     122                 :           7 :   update();
     123                 :           7 : }
     124                 :             : 
     125                 :           9 : SuggestionInfo SuggestionOverlay::suggestionForUid(qint64 uid) const {
     126   [ -  +  -  -  :           9 :   if (m_showingAlternate.contains(uid) && m_altSuggestions.contains(uid)) {
                   -  + ]
     127                 :           0 :     return m_altSuggestions[uid];
     128                 :             :   }
     129         [ +  + ]:           9 :   if (m_suggestions.contains(uid)) {
     130                 :           2 :     return m_suggestions[uid];
     131                 :             :   }
     132                 :           7 :   return {};
     133                 :             : }
     134                 :             : 
     135                 :          70 : void SuggestionOverlay::paintEvent(QPaintEvent *) {
     136   [ +  -  +  -  :          70 :   if (!m_active || !m_mailList || !m_mailList->model())
          +  -  -  +  -  
                      + ]
     137                 :           0 :     return;
     138                 :             : 
     139         [ +  - ]:          70 :   QPainter p(this);
     140         [ +  - ]:          70 :   p.setRenderHint(QPainter::Antialiasing, true);
     141                 :             : 
     142         [ +  - ]:          70 :   auto *model = m_mailList->model();
     143   [ +  -  +  - ]:          70 :   QFont badgeFont("Sans", 9);
     144         [ +  - ]:          70 :   p.setFont(badgeFont);
     145         [ +  - ]:          70 :   QFontMetrics fm(badgeFont);
     146                 :             : 
     147                 :             :   // Subject column index
     148                 :          70 :   int subjectCol = 1;
     149                 :             : 
     150   [ +  -  +  + ]:         693 :   for (int row = 0; row < model->rowCount(); ++row) {
     151         [ +  - ]:         623 :     QModelIndex idx = model->index(row, subjectCol);
     152         [ +  - ]:         623 :     QRect visualRect = m_mailList->visualRect(idx);
     153                 :             : 
     154                 :             :     // Skip rows not visible in viewport
     155   [ +  +  +  -  :        1244 :     if (visualRect.isEmpty() || visualRect.bottom() < 0 ||
             -  +  +  + ]
     156                 :         621 :         visualRect.top() > height()) {
     157                 :         623 :       continue;
     158                 :             :     }
     159                 :             : 
     160                 :             :     // Map from proxy to source to get the correct header row
     161                 :         621 :     QModelIndex srcIdx = idx;
     162         [ +  - ]:         621 :     auto *proxy = qobject_cast<const QSortFilterProxyModel*>(model);
     163         [ +  - ]:         621 :     if (proxy) {
     164   [ +  -  +  - ]:         621 :       srcIdx = proxy->mapToSource(model->index(row, 0));
     165                 :             :     }
     166                 :         621 :     int srcRow = srcIdx.row();
     167   [ +  -  -  +  :         621 :     if (srcRow < 0 || srcRow >= m_rowUids.size())
                   -  + ]
     168                 :           0 :       continue;
     169         [ +  - ]:         621 :     qint64 uid = m_rowUids[srcRow];
     170                 :             : 
     171                 :             :     // Lazy prediction — only compute when row becomes visible
     172         [ +  - ]:         621 :     ensurePrediction(uid);
     173                 :             : 
     174                 :             :     // Look up suggestion (primary or alternate)
     175                 :         621 :     SuggestionInfo info;
     176   [ -  +  -  -  :         621 :     if (m_showingAlternate.contains(uid) && m_altSuggestions.contains(uid)) {
             -  -  -  + ]
     177         [ #  # ]:           0 :       info = m_altSuggestions[uid];
     178   [ +  -  -  + ]:         621 :     } else if (m_suggestions.contains(uid)) {
     179         [ #  # ]:           0 :       info = m_suggestions[uid];
     180                 :             :     } else {
     181                 :         621 :       continue;
     182                 :             :     }
     183                 :             : 
     184                 :             :     // Build badge text
     185                 :           0 :     int pct = static_cast<int>(info.confidence * 100);
     186                 :           0 :     QString badgeText;
     187         [ #  # ]:           0 :     if (info.isSpam) {
     188   [ #  #  #  # ]:           0 :       badgeText = QStringLiteral("🛡 %1 %2%").arg(info.folderShort).arg(pct);
     189                 :             :     } else {
     190   [ #  #  #  # ]:           0 :       badgeText = QStringLiteral("%1 %2%").arg(info.folderShort).arg(pct);
     191                 :             :     }
     192         [ #  # ]:           0 :     if (info.isAlternate) {
     193         [ #  # ]:           0 :       badgeText = QStringLiteral("↳ ") + badgeText;
     194                 :             :     }
     195                 :             : 
     196                 :             :     // Calculate badge dimensions
     197         [ #  # ]:           0 :     int textW = fm.horizontalAdvance(badgeText);
     198                 :           0 :     int badgeW = textW + 12;
     199         [ #  # ]:           0 :     int badgeH = fm.height() + 4;
     200                 :             : 
     201                 :             :     // Position: right side of subject column
     202                 :           0 :     int badgeX = visualRect.right() - badgeW - 4;
     203                 :           0 :     int badgeY = visualRect.top() + (visualRect.height() - badgeH) / 2;
     204                 :             : 
     205                 :             :     // Draw badge background
     206         [ #  # ]:           0 :     QColor bgColor = confidenceColor(info.confidence);
     207         [ #  # ]:           0 :     bgColor.setAlpha(35);
     208                 :           0 :     QRect badgeRect(badgeX, badgeY, badgeW, badgeH);
     209         [ #  # ]:           0 :     p.setPen(Qt::NoPen);
     210   [ #  #  #  # ]:           0 :     p.setBrush(bgColor);
     211         [ #  # ]:           0 :     p.drawRoundedRect(badgeRect, 4, 4);
     212                 :             : 
     213                 :             :     // Draw badge border
     214         [ #  # ]:           0 :     QColor borderColor = confidenceColor(info.confidence);
     215         [ #  # ]:           0 :     borderColor.setAlpha(120);
     216   [ #  #  #  #  :           0 :     p.setPen(QPen(borderColor, 1));
                   #  # ]
     217         [ #  # ]:           0 :     p.setBrush(Qt::NoBrush);
     218         [ #  # ]:           0 :     p.drawRoundedRect(badgeRect, 4, 4);
     219                 :             : 
     220                 :             :     // Draw badge text
     221   [ #  #  #  # ]:           0 :     p.setPen(confidenceColor(info.confidence));
     222         [ #  # ]:           0 :     p.drawText(badgeRect, Qt::AlignCenter, badgeText);
     223         [ -  + ]:         621 :   }
     224                 :          70 : }
     225                 :             : 
     226                 :          70 : bool SuggestionOverlay::eventFilter(QObject *obj, QEvent *event) {
     227   [ +  +  +  -  :          70 :   if (m_active && obj == m_mailList->viewport()) {
                   +  + ]
     228   [ +  -  +  -  :         138 :     if (event->type() == QEvent::Resize ||
                   +  - ]
     229                 :          69 :         event->type() == QEvent::Paint) {
     230                 :             :       // Resize overlay to match viewport and repaint
     231   [ +  -  +  - ]:          69 :       setGeometry(m_mailList->viewport()->rect());
     232                 :          69 :       update();
     233                 :             :     }
     234                 :             :   }
     235                 :          70 :   return QWidget::eventFilter(obj, event);
     236                 :             : }
     237                 :             : 
     238                 :           0 : QColor SuggestionOverlay::confidenceColor(double confidence) const {
     239                 :             :   // 67.B3: shared confidence palette lives in ThemeManager.
     240                 :           0 :   return ThemeManager::confidenceColor(confidence);
     241                 :             : }
     242                 :             : 
     243                 :           3 : QString SuggestionOverlay::shortFolderName(const QString &fullPath) {
     244                 :             :   // "INBOX.Projekte.Sprint24" → "Sprint24"
     245                 :             :   // "INBOX/Projekte/Sprint24" → "Sprint24"
     246                 :           3 :   int lastDot = fullPath.lastIndexOf(QLatin1Char('.'));
     247                 :           3 :   int lastSlash = fullPath.lastIndexOf(QLatin1Char('/'));
     248                 :           3 :   int lastSep = qMax(lastDot, lastSlash);
     249   [ +  +  +  - ]:           3 :   QString segment = (lastSep >= 0) ? fullPath.mid(lastSep + 1) : fullPath;
     250                 :             :   // T-420: Decode IMAP Modified UTF-7 (e.g. "Entw&APw-rfe" → "Entwürfe")
     251         [ +  - ]:           6 :   return ImapResponseParser::decodeMailboxName(segment);
     252                 :           3 : }
        

Generated by: LCOV version 2.0-1