aboutsummaryrefslogtreecommitdiffstats
path: root/src/threadlistmodel.h
diff options
context:
space:
mode:
Diffstat (limited to 'src/threadlistmodel.h')
-rw-r--r--src/threadlistmodel.h185
1 files changed, 152 insertions, 33 deletions
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 2eaa88e..f381ffa 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -18,39 +18,26 @@
#pragma once
-#include <QAbstractTableModel>
+#include <QAbstractItemModel>
#include <QColor>
#include <QVector>
#include "tagcolors.h"
#include "types.h"
-/// Table model over query results, filled in batches so a large query paints
+/// Tree model over query results, filled in batches so a large query paints
/// its first screenful immediately.
-class ThreadListModel : public QAbstractTableModel
+///
+/// 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:
- /// No tags column: spelling out a dozen tags per row cost most of the
- /// list's width and was unreadable. Functional tags moved to a chip strip
- /// under the message pane, and the account tag renders as a chip in front
- /// of the subject.
- enum Column {
- /// A paperclip when the thread has an attachment, so it is visible
- /// without opening the thread. Icon only and deliberately narrow;
- /// it carries no text.
- AttachmentColumn = 0,
-
- /// A star when the thread carries the flagged tag. Beside the
- /// paperclip and the same shape: icon only, narrow, no text.
- FlagColumn,
-
- DateColumn,
- AuthorsColumn,
- SubjectColumn,
- ColumnCount,
- };
-
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
@@ -77,23 +64,86 @@ public:
/// 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.
};
- /// 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.
- /// The character shown in AttachmentColumn for a thread that has one.
- /// A paperclip when the system font can draw it, "*" otherwise.
+ /// 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 character shown in FlagColumn for a flagged thread.
+ /// 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,
@@ -109,17 +159,59 @@ public:
/// Without one, chips fall back to a colour generated from the tag name.
void setTagColors(const TagColors *colours) { m_tagColors = colours; }
+ 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;
- QVariant headerData(int section, Qt::Orientation orientation,
- int role) const override;
void appendBatch(const QVector<ThreadSummary> &batch);
void clear();
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.
///
@@ -137,6 +229,33 @@ public:
const QStringList &removed);
private:
- QVector<ThreadSummary> m_threads;
+ /// 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;
};