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 ¤tFolder,
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 : }
|