aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/threadlistmodel.cpp36
-rw-r--r--src/threadlistmodel.h8
-rw-r--r--src/types.h26
-rw-r--r--tests/test_threadlistmodel.cpp83
4 files changed, 153 insertions, 0 deletions
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 6fc2177..19e1153 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -498,6 +498,42 @@ MessageNode ThreadListModel::messageAt(const QModelIndex &index) const
return children.at(index.row());
}
+ActionScope ThreadListModel::scopeFor(const QModelIndexList &selection) const
+{
+ ActionScope scope;
+
+ for (const QModelIndex &index : selection) {
+ if (isMessageRow(index)) {
+ const MessageNode node = messageAt(index);
+ if (node.messageId.isEmpty()
+ || scope.messageIds.contains(node.messageId))
+ continue;
+ scope.messageIds.append(node.messageId);
+ scope.messageCount += 1;
+ continue;
+ }
+
+ if (index.row() < 0 || index.row() >= m_threads.size())
+ continue;
+
+ const ThreadSummary &summary = m_threads.at(index.row()).summary;
+ if (scope.threadIds.contains(summary.threadId))
+ continue;
+
+ scope.threadIds.append(summary.threadId);
+
+ // totalCount, not the loaded children: a thread that was never expanded
+ // still has all of its messages, and counting only what happens to be
+ // on screen would understate what the action does. Floored at 1, since
+ // a summary with no count still stands for at least the message that
+ // produced it.
+ scope.messageCount += qMax(1, summary.totalCount);
+ scope.wholeThread = true;
+ }
+
+ return scope;
+}
+
ThreadSummary ThreadListModel::threadAt(int row) const
{
if (row < 0 || row >= m_threads.size())
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index a7ce5d3..bc9fcbf 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -160,6 +160,14 @@ public:
/// is not a message row.
MessageNode messageAt(const QModelIndex &index) 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.
///
diff --git a/src/types.h b/src/types.h
index ef822b9..d04670e 100644
--- a/src/types.h
+++ b/src/types.h
@@ -94,6 +94,32 @@ struct MessageNode
}
};
+/// What an action is about to touch, resolved from the selection.
+///
+/// Exists because the thread list holds two kinds of row since item 20, so a
+/// keypress alone no longer says whether it hit one message or seven. Actions
+/// take one of these rather than a bare list of thread ids, and the status bar
+/// reports it: this project's answer to that ambiguity is to make the scope
+/// visible, not to add a confirmation dialog. See CLAUDE.md on why.
+struct ActionScope
+{
+ QStringList threadIds; ///< Whole threads to act on.
+ QStringList messageIds; ///< Individual messages to act on.
+
+ /// Messages the action will touch in total, for the status bar. A whole
+ /// thread contributes all of its messages, a message row contributes one.
+ int messageCount = 0;
+
+ /// True when any whole thread is in scope, which drives the
+ /// "(whole thread)" suffix in the status bar.
+ bool wholeThread = false;
+
+ bool isEmpty() const
+ {
+ return threadIds.isEmpty() && messageIds.isEmpty();
+ }
+};
+
/// One tag mutation, kept so it can be inverted for undo.
struct TagChange
{
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
index b4be527..74b8dd1 100644
--- a/tests/test_threadlistmodel.cpp
+++ b/tests/test_threadlistmodel.cpp
@@ -32,6 +32,9 @@ private slots:
void repliesBecomeChildRowsUnderTheirThread();
void messageRowsShowTheirOwnSenderAndSubject();
void reloadingAThreadReplacesItsRepliesRatherThanRepeatingThem();
+ void scopeFollowsTheSelectedRowKind();
+ void scopeCountsEveryMessageOfAnUnexpandedThread();
+ void scopeHonoursAMixedSelectionWithoutEscalating();
void startsEmpty();
void accountKeysComeFromTheAccountTags();
void accountKeysCoverAThreadSpanningTwoAccounts();
@@ -193,6 +196,86 @@ void TestThreadListModel::reloadingAThreadReplacesItsRepliesRatherThanRepeatingT
Q_UNUSED(tester);
}
+void TestThreadListModel::scopeFollowsTheSelectedRowKind()
+{
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ QStringLiteral("A subject"));
+ t.totalCount = 3;
+ model.appendBatch({ t });
+ model.setThreadMessages(QStringLiteral("t1"),
+ { makeNode(QStringLiteral("m0@example.org"), 0),
+ makeNode(QStringLiteral("m1@example.org"), 1) });
+
+ const QModelIndex root = model.index(0, 0, QModelIndex());
+ const QModelIndex child = model.index(0, 0, root);
+
+ // A thread root acts on the whole thread, and reports every message it
+ // stands for so the status bar can say so.
+ const ActionScope threadScope = model.scopeFor({ root });
+ QCOMPARE(threadScope.threadIds, QStringList{ QStringLiteral("t1") });
+ QVERIFY(threadScope.messageIds.isEmpty());
+ QCOMPARE(threadScope.messageCount, 3);
+ QVERIFY(threadScope.wholeThread);
+
+ // A message row acts on that message alone.
+ const ActionScope messageScope = model.scopeFor({ child });
+ QVERIFY(messageScope.threadIds.isEmpty());
+ QCOMPARE(messageScope.messageIds,
+ QStringList{ QStringLiteral("m1@example.org") });
+ QCOMPARE(messageScope.messageCount, 1);
+ QVERIFY(!messageScope.wholeThread);
+}
+
+void TestThreadListModel::scopeCountsEveryMessageOfAnUnexpandedThread()
+{
+ // totalCount, not the loaded children. A thread that was never expanded
+ // still has all of its messages, and counting only what happens to be on
+ // screen would understate what the action is about to do.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"),
+ QStringLiteral("A subject"));
+ t.totalCount = 7;
+ model.appendBatch({ t });
+
+ const QModelIndex root = model.index(0, 0, QModelIndex());
+ QCOMPARE(model.rowCount(root), 0); // guard: nothing expanded
+
+ const ActionScope scope = model.scopeFor({ root });
+ QCOMPARE(scope.messageCount, 7);
+}
+
+void TestThreadListModel::scopeHonoursAMixedSelectionWithoutEscalating()
+{
+ // Selecting a thread root and an unrelated reply acts on that whole thread
+ // AND that one message. Nothing is escalated to thread scope or narrowed to
+ // message scope silently, which is the point of the scope being visible.
+ ThreadListModel model;
+ ThreadSummary t1 = makeThread(QStringLiteral("t1"), QStringLiteral("One"));
+ t1.totalCount = 2;
+ ThreadSummary t2 = makeThread(QStringLiteral("t2"), QStringLiteral("Two"));
+ t2.totalCount = 5;
+ model.appendBatch({ t1, t2 });
+
+ MessageNode reply = makeNode(QStringLiteral("m1@example.org"), 1);
+ reply.threadId = QStringLiteral("t2");
+ model.setThreadMessages(QStringLiteral("t2"),
+ { makeNode(QStringLiteral("m0@example.org"), 0),
+ reply });
+
+ const QModelIndex firstRoot = model.index(0, 0, QModelIndex());
+ const QModelIndex secondRoot = model.index(1, 0, QModelIndex());
+ const QModelIndex reply1 = model.index(0, 0, secondRoot);
+
+ const ActionScope scope = model.scopeFor({ firstRoot, reply1 });
+ QCOMPARE(scope.threadIds, QStringList{ QStringLiteral("t1") });
+ QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m1@example.org") });
+
+ // 2 from the whole thread plus 1 for the lone message.
+ QCOMPARE(scope.messageCount, 3);
+ QVERIFY(scope.wholeThread);
+}
+
void TestThreadListModel::accountKeysComeFromTheAccountTags()
{
// Item 49 reads this to decide which mbsync channels a sync needs. Only