diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-28 11:49:32 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-28 11:49:32 +0200 |
| commit | 26699b720c4135b57de6cfc84b35ab632de1c500 (patch) | |
| tree | c7f4831fdf4b2b8d5960d34681eec688574c824d | |
| parent | 5f31a037a37a928a8e21fdf0950568a689e29fb8 (diff) | |
| download | qtmaildir-26699b720c4135b57de6cfc84b35ab632de1c500.tar.gz qtmaildir-26699b720c4135b57de6cfc84b35ab632de1c500.zip | |
feat: resolve a selection's scope from what each row is
One resolver replacing the scopeFor/messageScopeFor pair. The caller no
longer chooses the scope, which is what let one gesture mean two things.
| -rw-r--r-- | src/threadlistmodel.cpp | 37 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 8 | ||||
| -rw-r--r-- | tests/test_threadlistmodel.cpp | 93 |
3 files changed, 138 insertions, 0 deletions
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp index 4a75d47..ddddf0b 100644 --- a/src/threadlistmodel.cpp +++ b/src/threadlistmodel.cpp @@ -1117,6 +1117,43 @@ ActionScope ThreadListModel::scopeFor(const QModelIndexList &selection) const return scope; } +ActionScope ThreadListModel::scopeForSelection( + const QModelIndexList &selection) const +{ + ActionScope scope; + + for (const QModelIndex &index : selection) { + if (isConversationRow(index)) { + 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: an unexpanded conversation + // still has all of its messages, and the count is what the status + // bar names after the fact. + scope.messageCount += summary.totalCount; + scope.wholeThread = true; + continue; + } + + const QString messageId = + isMessageRow(index) + ? messageAt(index).messageId + : (index.row() >= 0 && index.row() < m_threads.size() + ? m_threads.at(index.row()).summary.firstMessageId + : QString()); + + // Skipped rather than widened. Falling back to the thread would + // silently act on messages the row does not stand for. + if (messageId.isEmpty() || scope.messageIds.contains(messageId)) + continue; + scope.messageIds.append(messageId); + scope.messageCount += 1; + } + + 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 5cd1392..fcbb8f5 100644 --- a/src/threadlistmodel.h +++ b/src/threadlistmodel.h @@ -361,6 +361,14 @@ public: /// thread instead would be the silent escalation this exists to remove. ActionScope messageScopeFor(const QModelIndexList &selection) const; + /// What a selection means, resolved per row from what that row IS. + /// + /// Replaces the scopeFor()/messageScopeFor() pair, which made the caller + /// choose the scope and so let one gesture mean two things (item 177). A + /// conversation row contributes its thread, any other row its message, and + /// a mixed selection carries both. + ActionScope scopeForSelection(const QModelIndexList &selection) const; + /// The account keys behind a thread's account tags, for item 49's /// per-account sync. /// diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp index bd35c6b..edc84ee 100644 --- a/tests/test_threadlistmodel.cpp +++ b/tests/test_threadlistmodel.cpp @@ -107,6 +107,10 @@ private slots: void aSummaryWithRepliesIsAConversationRow(); void aLoadedThreadTrustsItsChildrenOverItsCount(); void aMessageRowIsNeverAConversationRow(); + void aConversationRowResolvesToItsThread(); + void aLoneMessageRowResolvesToItsMessage(); + void aReplyRowResolvesToItsMessage(); + void aMixedSelectionCarriesBothScopes(); }; static ThreadSummary makeThread(const QString &id, const QString &subject) @@ -2279,5 +2283,94 @@ void TestThreadListModel::aMessageRowIsNeverAConversationRow() "whole conversation"); } +void TestThreadListModel::aConversationRowResolvesToItsThread() +{ + ThreadListModel model; + ThreadSummary one = makeThread(QStringLiteral("t1"), QStringLiteral("Alone")); + one.totalCount = 1; + one.firstMessageId = QStringLiteral("m1"); + // SECOND, so a wrong answer is visible rather than accidentally right. + ThreadSummary many = makeThread(QStringLiteral("t2"), QStringLiteral("Talk")); + many.totalCount = 4; + many.firstMessageId = QStringLiteral("m2"); + model.appendBatch({ one, many }); + + const ActionScope scope = + model.scopeForSelection({ model.index(1, 0, QModelIndex()) }); + + QCOMPARE(scope.threadIds, QStringList{ QStringLiteral("t2") }); + QVERIFY2(scope.messageIds.isEmpty(), + "a conversation row named a message, so an action on it would " + "touch one message of the thread it claims to act on"); + QVERIFY(scope.wholeThread); +} + +void TestThreadListModel::aLoneMessageRowResolvesToItsMessage() +{ + ThreadListModel model; + ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Talk")); + many.totalCount = 4; + many.firstMessageId = QStringLiteral("m1"); + ThreadSummary one = makeThread(QStringLiteral("t2"), QStringLiteral("Alone")); + one.totalCount = 1; + one.firstMessageId = QStringLiteral("m2"); + model.appendBatch({ many, one }); + + const ActionScope scope = + model.scopeForSelection({ model.index(1, 0, QModelIndex()) }); + + QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m2") }); + QVERIFY(scope.threadIds.isEmpty()); + QVERIFY(!scope.wholeThread); +} + +void TestThreadListModel::aReplyRowResolvesToItsMessage() +{ + ThreadListModel model; + ThreadSummary first = makeThread(QStringLiteral("t1"), QStringLiteral("One")); + first.totalCount = 1; + first.firstMessageId = QStringLiteral("m0"); + ThreadSummary many = makeThread(QStringLiteral("t2"), QStringLiteral("Talk")); + many.totalCount = 2; + many.firstMessageId = QStringLiteral("m1"); + model.appendBatch({ first, many }); + + MessageNode root; + root.messageId = QStringLiteral("m1"); + root.threadId = QStringLiteral("t2"); + root.depth = 0; + MessageNode reply; + reply.messageId = QStringLiteral("m2"); + reply.threadId = QStringLiteral("t2"); + reply.depth = 1; + model.setThreadMessages(QStringLiteral("t2"), { root, reply }); + + const QModelIndex thread = model.index(1, 0, QModelIndex()); + const ActionScope scope = + model.scopeForSelection({ model.index(0, 0, thread) }); + + QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m2") }); + QVERIFY(scope.threadIds.isEmpty()); +} + +void TestThreadListModel::aMixedSelectionCarriesBothScopes() +{ + ThreadListModel model; + ThreadSummary one = makeThread(QStringLiteral("t1"), QStringLiteral("Alone")); + one.totalCount = 1; + one.firstMessageId = QStringLiteral("m1"); + ThreadSummary many = makeThread(QStringLiteral("t2"), QStringLiteral("Talk")); + many.totalCount = 4; + many.firstMessageId = QStringLiteral("m2"); + model.appendBatch({ one, many }); + + const ActionScope scope = + model.scopeForSelection({ model.index(0, 0, QModelIndex()), + model.index(1, 0, QModelIndex()) }); + + QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m1") }); + QCOMPARE(scope.threadIds, QStringList{ QStringLiteral("t2") }); +} + QTEST_MAIN(TestThreadListModel) #include "test_threadlistmodel.moc" |
