1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
|
/*
* qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
* Copyright (C) 2026 Danilo M. <danix@danix.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
#pragma once
#include <QAbstractItemModel>
#include <QColor>
#include <QVector>
#include "tagcolors.h"
#include "types.h"
/// Tree model over query results, filled in batches so a large query paints
/// its first screenful immediately.
///
/// A tree rather than a table since item 20: a thread's replies are child rows
/// under it. The tree is at most two levels deep in the MODEL (a thread, then
/// its messages) even though the messages carry a reply depth of their own; the
/// visual nesting beyond the first level comes from that depth, not from
/// further parent-child structure. A deeper model would buy nothing and make
/// every index calculation recursive.
class ThreadListModel : public QAbstractItemModel
{
Q_OBJECT
public:
enum Role {
/// The thread id behind a row. Views hand out QModelIndexes, but the
/// worker speaks thread ids, so the mapping belongs on the model
/// rather than in every caller.
ThreadIdRole = Qt::UserRole + 1,
/// The account tag on this thread without its "account-" prefix, for
/// the chip drawn in front of the subject. Empty when the thread
/// carries none.
AccountLabelRole,
/// Fill colour for that chip.
AccountColourRole,
/// Every tag on the thread, for the strip under the message pane.
TagsRole,
/// The tags worth drawing as pills under the subject: every tag except
/// the ones the row already shows another way. Sorted, so a row does
/// not reshuffle its own pills between repaints.
PillTagsRole,
/// The colours for PillTagsRole, in the same order. Supplied by the
/// model because it owns the TagColors instance; a delegate reading
/// config itself would be a second source of truth.
PillColoursRole,
/// True when the row is a MESSAGE row rather than a thread root.
/// Drives both the action scope and whether the view paints a tag
/// strip under the row.
IsMessageRole,
/// The message id behind a message row. Empty on a thread root.
MessageIdRole,
/// The message's reply depth, for the view's indentation. 1 for a
/// direct reply, since depth 0 is the root row itself.
MessageDepthRole,
/// True when the row is a thread that has replies to show.
///
/// Read by SubjectDelegate, which draws the expander itself: the
/// delegate cannot call hasChildren without the model, and the same
/// answer has to reach the cell that reserves room for the glyph.
HasRepliesRole,
/// The tags this MESSAGE carries that its thread does not.
///
/// A reply card shows these and nothing else. Showing a reply's full
/// tag set instead was measured against the user's own database and
/// rejected: of 48691 messages, 7 carry `unread` and 75 carry
/// `flagged`, and both are already drawn another way (the sender's
/// weight, and the mark on line 2). Every other tag is applied to a
/// whole thread and is identical on all its messages, so full sets
/// would repeat the thread's own chips down the entire expansion,
/// which is the striping the old row-wide strip existed to avoid.
///
/// Empty on a thread row, which has no thread to differ from.
MessageOwnTagsRole,
/// The colours for MessageOwnTagsRole, in the same order. Supplied by
/// the model for the same reason as PillColoursRole: it owns the
/// TagColors instance, and a delegate reading config itself would be a
/// second source of truth.
MessageOwnColoursRole,
/// The card's own fields, by role rather than by column.
///
/// Five columns used to answer these through Qt::DisplayRole. One
/// column cannot, and a card needs all five values at once, so each
/// gets a role and Qt::DisplayRole answers the subject alone (which is
/// what keyboard search and accessibility read).
SubjectRole,
SendersRole,
DateRole, ///< A QDateTime. The delegate formats it.
HasAttachmentRole, ///< bool
IsFlaggedRole, ///< bool
ReplyCountRole, ///< int; 0 when a thread has no replies.
/// The [general] date_format pattern, or empty for the system's short
/// format. Same row value for every row.
///
/// Supplied by the model for the same reason as PillColoursRole: it is
/// the one thing here that holds config, and a delegate reading config
/// itself would be a second source of truth.
DateFormatRole,
};
/// The mark drawn on a card's second line when the message has an
/// attachment. A paperclip when the system font can draw it, "*" otherwise.
static QString attachmentGlyph();
/// The mark drawn on a card's second line when the message is flagged.
/// A star when the system font can draw it, "*" otherwise.
static QString flagGlyph();
/// Row fill for a thread tagged `deleted`, and for one tagged `spam`.
/// Muted rather than saturated: a bulk delete paints every selected row,
/// and a wall of pure red is harder to read than the list it replaces.
/// Exposed so a test names the same colour the model uses.
static QColor deletedColour();
static QColor spamColour();
/// Background for a reply row, so an expanded thread reads as one block
/// rather than as more table rows.
///
/// Derived from the palette and deliberately subtle: it marks a grouping,
/// and a tint strong enough to notice on its own would compete with the
/// deleted and spam row colours, which carry real meaning.
static QColor replyBackground();
/// The line drawn down the left of an expanded thread's replies.
static QColor threadLineColour();
/// The dimmed text colour a READ thread carries.
///
/// Unread rows are left at the palette's own colour and read ones recede,
/// rather than unread being emphasised. Bold alone used to be the only
/// cue, which leaves nothing to see when the desktop font is itself
/// configured bold; colour is a second cue that survives that. Derived
/// from the palette, never hardcoded.
static QColor readColour();
explicit ThreadListModel(QObject *parent = nullptr);
/// Supplies the account chip colours. Not owned; must outlive the model.
/// Without one, chips fall back to a colour generated from the tag name.
void setTagColors(const TagColors *colours) { m_tagColors = colours; }
/// The pattern DateFormatRole answers with. Empty means the system format.
void setDateFormat(const QString &format) { m_dateFormat = format; }
/// One row per thread, with no expander and no reply count.
///
/// For the Sent view, where a thread is the wrong unit: the user's model of
/// "what I sent" is a list, and a matching sent message otherwise drags in
/// the replies they RECEIVED, under a view that claims to be their outbox.
///
/// A flag on this model rather than a second model or a filtered query, and
/// that is what keeps it from leaking: it is off by default, only the Sent
/// button turns it on, and every other query turns it off again. The
/// expander already comes from hasChildren() and the card's count from
/// ReplyCountRole, so flat mode is those two answering differently and
/// nothing else changes.
///
/// The children are not discarded, only hidden. Leaving flat mode restores
/// the tree without reloading anything.
void setFlatMode(bool flat);
bool flatMode() const { return m_flatMode; }
QModelIndex index(int row, int column,
const QModelIndex &parent = {}) const override;
QModelIndex parent(const QModelIndex &child) const override;
int rowCount(const QModelIndex &parent = {}) const override;
int columnCount(const QModelIndex &parent = {}) const override;
/// Whether a thread row should offer an expander.
///
/// Answered from totalCount rather than from the loaded children, and that
/// is what makes lazy loading possible at all: rowCount is 0 until the
/// worker has walked the thread, so a view left to infer this from rowCount
/// alone draws no expander, the user can never expand, and the replies are
/// never asked for. The count is already in the summary, so this costs
/// nothing.
bool hasChildren(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
void appendBatch(const QVector<ThreadSummary> &batch);
void clear();
/// Brings the model to `threads` without resetting it.
///
/// Used by the automatic refresh after a sync, where clear() plus
/// appendBatch() is the wrong tool: a reset invalidates every index, so the
/// selection, the expanded threads and the message being read all go with
/// it. Rows are matched by thread id, so a surviving thread keeps its
/// identity, its persistent index and its loaded replies.
///
/// Order comes from `threads` and is never imposed here. The worker sorts
/// the query, so a new thread lands at the front under newest-first and at
/// the back under oldest-first; forcing new rows to the top would
/// contradict the sort the user selected.
void reconcile(const QVector<ThreadSummary> &threads);
ThreadSummary threadAt(int row) const;
/// Fills in a thread's message rows once the worker has walked its tree.
///
/// The depth-0 message is dropped: it is the thread's first message and the
/// ROOT row already stands for it. Keeping it would show a thread of seven
/// as one root and seven children, contradicting the reply count the row
/// advertises. Calling again replaces the rows rather than appending, so a
/// thread reloaded after a sync does not list its replies twice.
void setThreadMessages(const QString &threadId,
const QVector<MessageNode> &nodes);
/// True when the index is a message row rather than a thread root.
bool isMessageRow(const QModelIndex &index) const;
/// The message row's node, or a default-constructed one for any index that
/// is not a message row.
MessageNode messageAt(const QModelIndex &index) const;
/// The thread a loaded message row belongs to, or empty when no expanded
/// thread holds it. Only expanded threads have message rows at all, so a
/// message the user could select is always findable here.
QString threadIdForMessage(const QString &messageId) const;
/// Resolves a selection into what an action should touch.
///
/// Mixed selections are honoured as given: a thread root and an unrelated
/// reply act on that whole thread and that one message. Nothing is
/// escalated or narrowed silently, which is the point of the scope being
/// visible in the first place.
ActionScope scopeFor(const QModelIndexList &selection) const;
/// The account keys behind a thread's account tags, for item 49's
/// per-account sync.
///
/// Returns every one of them, not the first: the thread list shows only one
/// chip per row, but a thread whose messages landed in two mailboxes really
/// does span two accounts, and tagging it touches files under both. Syncing
/// only the one that happens to be shown would strand the other's edits.
/// Empty when the thread is unknown or carries no account tag.
QStringList accountKeysForThread(const QString &threadId) const;
/// Applies a tag change locally so the UI updates before the worker
/// confirms. To revert a failed write, call again with added and removed
/// swapped.
void applyTagChange(const QString &threadId, const QStringList &added,
const QStringList &removed);
private:
/// One thread root and the message rows expanded under it.
///
/// Children live beside the summary rather than in a separate map keyed by
/// thread id, so a row and its expansion are appended, cleared and
/// destroyed together. The model is rebuilt wholesale on every query, so
/// nothing here has to survive a reset.
struct ThreadNode
{
ThreadSummary summary;
QVector<MessageNode> children; ///< Empty until the thread is expanded.
/// The thread's FIRST message, which the root card itself draws.
///
/// Kept because the root card is that message: selecting it must
/// render one message rather than the whole conversation, and without
/// this the first message of every thread is unreachable, since the
/// only rows offering a message are the replies and it is not one of
/// them. Empty until the replies are loaded.
MessageNode first;
/// Distinguishes "this thread has no replies" from "its replies have
/// not been asked for yet". Without it an expander would be drawn over
/// every thread, including the ones that turn out to be single
/// messages.
bool loaded = false;
};
QVector<ThreadNode> m_threads;
const TagColors *m_tagColors = nullptr;
QString m_dateFormat;
bool m_flatMode = false;
};
|