summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 08:56:33 +0200
committerDanilo M. <danix@danix.xyz>2026-08-03 08:56:33 +0200
commitc6c1011222361b446fb3cdee1ad324fe54931abc (patch)
tree89aa8a114b327feeb10d83ffec566ec61f24feb3
parent9564794135c3a90fc3988e42233adfd819c5881d (diff)
downloadqtmaildir-c6c1011222361b446fb3cdee1ad324fe54931abc.tar.gz
qtmaildir-c6c1011222361b446fb3cdee1ad324fe54931abc.zip
feat: add ThreadListModel with batch append
QAbstractTableModel over query results, appended in batches so a large query paints its first screenful immediately. Tag changes apply locally for optimistic UI; reverting a failed write means calling applyTagChange again with added and removed swapped, which the round-trip test pins. Two additions to the drafted version: - A ThreadIdRole, so a view's QModelIndex maps back to the thread id the worker speaks without every caller reaching around the model. - data() checks its own row and column bounds. Qt will not hand out an out-of-range index and invalidates persistent ones on reset, so this is unreachable defence rather than a live path; the test says so instead of pretending to cover it. Verified by mutation that the empty-batch guard, the ThreadIdRole, and the full-row dataChanged range each fail exactly one test when removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/threadlistmodel.cpp124
-rw-r--r--src/threadlistmodel.h50
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_threadlistmodel.cpp282
5 files changed, 458 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 5198429..5f01128 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -6,6 +6,7 @@ add_library(qtmaildir_lib STATIC
htmlbuilder.cpp
cidschemehandler.cpp
notmuchworker.cpp
+ threadlistmodel.cpp
)
target_include_directories(qtmaildir_lib
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
new file mode 100644
index 0000000..5acfe0e
--- /dev/null
+++ b/src/threadlistmodel.cpp
@@ -0,0 +1,124 @@
+#include "threadlistmodel.h"
+
+#include <QFont>
+
+ThreadListModel::ThreadListModel(QObject *parent)
+ : QAbstractTableModel(parent)
+{
+}
+
+int ThreadListModel::rowCount(const QModelIndex &parent) const
+{
+ return parent.isValid() ? 0 : m_threads.size();
+}
+
+int ThreadListModel::columnCount(const QModelIndex &parent) const
+{
+ return parent.isValid() ? 0 : ColumnCount;
+}
+
+QVariant ThreadListModel::data(const QModelIndex &index, int role) const
+{
+ // A stale index from a view that has not caught up with a clear() can carry
+ // any row or column, so both bounds are checked rather than trusted.
+ if (!index.isValid() || index.row() < 0 || index.row() >= m_threads.size()
+ || index.column() < 0 || index.column() >= ColumnCount) {
+ return {};
+ }
+
+ const ThreadSummary &thread = m_threads.at(index.row());
+
+ if (role == ThreadIdRole)
+ return thread.threadId;
+
+ if (role == Qt::DisplayRole) {
+ switch (index.column()) {
+ case DateColumn:
+ return thread.date.toString(QStringLiteral("yyyy-MM-dd hh:mm"));
+ case AuthorsColumn:
+ return thread.authors;
+ case SubjectColumn:
+ return thread.totalCount > 1
+ ? QStringLiteral("%1 (%2)").arg(thread.subject)
+ .arg(thread.totalCount)
+ : thread.subject;
+ case TagsColumn:
+ return thread.tags.join(QLatin1Char(' '));
+ default:
+ return {};
+ }
+ }
+
+ if (role == Qt::FontRole && thread.isUnread()) {
+ QFont font;
+ font.setBold(true);
+ return font;
+ }
+
+ return {};
+}
+
+QVariant ThreadListModel::headerData(int section, Qt::Orientation orientation,
+ int role) const
+{
+ if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
+ return {};
+
+ switch (section) {
+ case DateColumn: return QStringLiteral("Date");
+ case AuthorsColumn: return QStringLiteral("From");
+ case SubjectColumn: return QStringLiteral("Subject");
+ case TagsColumn: return QStringLiteral("Tags");
+ default: return {};
+ }
+}
+
+void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch)
+{
+ // beginInsertRows with an empty range violates Qt's contract, so the guard
+ // has to come before the signal, not inside it.
+ if (batch.isEmpty())
+ return;
+
+ const int first = m_threads.size();
+ beginInsertRows({}, first, first + batch.size() - 1);
+ m_threads.append(batch);
+ endInsertRows();
+}
+
+void ThreadListModel::clear()
+{
+ beginResetModel();
+ m_threads.clear();
+ endResetModel();
+}
+
+ThreadSummary ThreadListModel::threadAt(int row) const
+{
+ if (row < 0 || row >= m_threads.size())
+ return {};
+ return m_threads.at(row);
+}
+
+void ThreadListModel::applyTagChange(const QString &threadId,
+ const QStringList &added,
+ const QStringList &removed)
+{
+ for (int row = 0; row < m_threads.size(); ++row) {
+ if (m_threads.at(row).threadId != threadId)
+ continue;
+
+ QStringList &tags = m_threads[row].tags;
+ for (const QString &tag : removed)
+ tags.removeAll(tag);
+ for (const QString &tag : added) {
+ if (!tags.contains(tag))
+ tags.append(tag);
+ }
+
+ // The whole row repaints: unread state drives the font of every column,
+ // not just the tags one.
+ emit dataChanged(index(row, 0), index(row, ColumnCount - 1));
+ return;
+ }
+}
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
new file mode 100644
index 0000000..2cf8d2e
--- /dev/null
+++ b/src/threadlistmodel.h
@@ -0,0 +1,50 @@
+#pragma once
+
+#include <QAbstractTableModel>
+#include <QVector>
+
+#include "types.h"
+
+/// Table model over query results, filled in batches so a large query paints
+/// its first screenful immediately.
+class ThreadListModel : public QAbstractTableModel
+{
+ Q_OBJECT
+public:
+ enum Column {
+ DateColumn = 0,
+ AuthorsColumn,
+ SubjectColumn,
+ TagsColumn,
+ 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
+ /// rather than in every caller.
+ ThreadIdRole = Qt::UserRole + 1,
+ };
+
+ explicit ThreadListModel(QObject *parent = nullptr);
+
+ int rowCount(const QModelIndex &parent = {}) const override;
+ int columnCount(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;
+
+ /// 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:
+ QVector<ThreadSummary> m_threads;
+};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index a24a60b..f970385 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -13,3 +13,4 @@ target_compile_definitions(test_mimeparser PRIVATE
add_qtmaildir_test(interceptor)
add_qtmaildir_test(htmlbuilder)
add_qtmaildir_test(notmuchworker)
+add_qtmaildir_test(threadlistmodel)
diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp
new file mode 100644
index 0000000..f69147c
--- /dev/null
+++ b/tests/test_threadlistmodel.cpp
@@ -0,0 +1,282 @@
+#include <QAbstractItemModelTester>
+#include <QSignalSpy>
+#include <QtTest>
+
+#include "threadlistmodel.h"
+
+class TestThreadListModel : public QObject
+{
+ Q_OBJECT
+private slots:
+ void startsEmpty();
+ void appendsBatches();
+ void appendingEmptyBatchIsNoOp();
+ void clearResetsModel();
+ void reportsSubjectAndAuthors();
+ void subjectShowsMessageCountOnlyForRealThreads();
+ void unreadThreadsRenderBold();
+ void threadIdIsReachableFromAnIndex();
+ void invalidIndexesReturnNothing();
+ void threadAtOutOfRangeIsSafe();
+ void updatesTagsForMessage();
+ void tagChangeIsIdempotent();
+ void tagChangeSignalsExactlyTheChangedRow();
+ void tagChangeForUnknownThreadIsIgnored();
+ void tagChangeRoundTripsForRevert();
+ void modelPassesQtTester();
+};
+
+static ThreadSummary makeThread(const QString &id, const QString &subject)
+{
+ ThreadSummary t;
+ t.threadId = id;
+ t.subject = subject;
+ t.authors = QStringLiteral("Alice");
+ t.date = QDateTime::fromSecsSinceEpoch(1750000000);
+ t.totalCount = 2;
+ t.matchedCount = 1;
+ t.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread") };
+ return t;
+}
+
+void TestThreadListModel::startsEmpty()
+{
+ ThreadListModel model;
+ QCOMPARE(model.rowCount(), 0);
+ QCOMPARE(model.columnCount(), ThreadListModel::ColumnCount);
+}
+
+void TestThreadListModel::appendsBatches()
+{
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+ QCOMPARE(model.rowCount(), 1);
+
+ model.appendBatch({ makeThread(QStringLiteral("t2"), QStringLiteral("two")),
+ makeThread(QStringLiteral("t3"), QStringLiteral("three")) });
+ QCOMPARE(model.rowCount(), 3);
+ QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t3"));
+}
+
+void TestThreadListModel::appendingEmptyBatchIsNoOp()
+{
+ ThreadListModel model;
+ QSignalSpy inserted(&model, &QAbstractItemModel::rowsInserted);
+
+ model.appendBatch({});
+
+ QCOMPARE(model.rowCount(), 0);
+ // An empty beginInsertRows(first, first - 1) range is a Qt contract
+ // violation, so the guard must come before the signal.
+ QVERIFY(inserted.isEmpty());
+}
+
+void TestThreadListModel::clearResetsModel()
+{
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ QSignalSpy spy(&model, &QAbstractItemModel::modelReset);
+ model.clear();
+
+ QCOMPARE(model.rowCount(), 0);
+ QCOMPARE(spy.count(), 1);
+}
+
+void TestThreadListModel::reportsSubjectAndAuthors()
+{
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("hello")) });
+
+ const QModelIndex authors = model.index(0, ThreadListModel::AuthorsColumn);
+ QCOMPARE(model.data(authors, Qt::DisplayRole).toString(),
+ QStringLiteral("Alice"));
+
+ const QModelIndex date = model.index(0, ThreadListModel::DateColumn);
+ QVERIFY(!model.data(date, Qt::DisplayRole).toString().isEmpty());
+
+ const QModelIndex tags = model.index(0, ThreadListModel::TagsColumn);
+ QCOMPARE(model.data(tags, Qt::DisplayRole).toString(),
+ QStringLiteral("inbox unread"));
+}
+
+void TestThreadListModel::subjectShowsMessageCountOnlyForRealThreads()
+{
+ ThreadListModel model;
+
+ ThreadSummary single = makeThread(QStringLiteral("t1"), QStringLiteral("alone"));
+ single.totalCount = 1;
+ ThreadSummary multi = makeThread(QStringLiteral("t2"), QStringLiteral("group"));
+ multi.totalCount = 4;
+ model.appendBatch({ single, multi });
+
+ QCOMPARE(model.data(model.index(0, ThreadListModel::SubjectColumn),
+ Qt::DisplayRole).toString(),
+ QStringLiteral("alone"));
+ QCOMPARE(model.data(model.index(1, ThreadListModel::SubjectColumn),
+ Qt::DisplayRole).toString(),
+ QStringLiteral("group (4)"));
+}
+
+void TestThreadListModel::unreadThreadsRenderBold()
+{
+ ThreadListModel model;
+ ThreadSummary read = makeThread(QStringLiteral("t1"), QStringLiteral("read"));
+ read.tags = QStringList{ QStringLiteral("inbox") };
+ model.appendBatch({ read, makeThread(QStringLiteral("t2"), QStringLiteral("unread")) });
+
+ const QVariant readFont =
+ model.data(model.index(0, ThreadListModel::SubjectColumn), Qt::FontRole);
+ QVERIFY(!readFont.isValid());
+
+ const QVariant unreadFont =
+ model.data(model.index(1, ThreadListModel::SubjectColumn), Qt::FontRole);
+ QVERIFY(unreadFont.isValid());
+ QVERIFY(unreadFont.value<QFont>().bold());
+}
+
+void TestThreadListModel::threadIdIsReachableFromAnIndex()
+{
+ // The view hands MainWindow a QModelIndex; the worker needs a thread id.
+ // Without a role for it, every caller has to reach around the model.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")),
+ makeThread(QStringLiteral("t2"), QStringLiteral("two")) });
+
+ const QModelIndex index = model.index(1, ThreadListModel::SubjectColumn);
+ QCOMPARE(model.data(index, ThreadListModel::ThreadIdRole).toString(),
+ QStringLiteral("t2"));
+}
+
+void TestThreadListModel::invalidIndexesReturnNothing()
+{
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ QVERIFY(!model.data(QModelIndex(), Qt::DisplayRole).isValid());
+
+ // Qt refuses to hand out an out-of-range index at all, so data() cannot be
+ // reached with one through the public API. Verified empirically: index()
+ // returns invalid for these, and a QPersistentModelIndex is invalidated by
+ // the reset in clear() before data() ever sees it. data() still checks its
+ // own bounds, but that guard is unreachable defence, not something these
+ // assertions can falsify.
+ QVERIFY(!model.index(0, ThreadListModel::ColumnCount).isValid());
+ QVERIFY(!model.index(5, 0).isValid());
+ QVERIFY(!model.index(-1, 0).isValid());
+
+ // A child index must yield nothing: this is a table, not a tree.
+ const QModelIndex child = model.index(0, 0, model.index(0, 0));
+ QVERIFY(!child.isValid());
+ QVERIFY(!model.data(child, Qt::DisplayRole).isValid());
+}
+
+void TestThreadListModel::threadAtOutOfRangeIsSafe()
+{
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ QVERIFY(model.threadAt(-1).threadId.isEmpty());
+ QVERIFY(model.threadAt(99).threadId.isEmpty());
+ QCOMPARE(model.threadAt(0).threadId, QStringLiteral("t1"));
+}
+
+void TestThreadListModel::updatesTagsForMessage()
+{
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+ QVERIFY(model.threadAt(0).isUnread());
+
+ // Optimistic UI: the model changes before the worker confirms.
+ model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("unread") });
+ QVERIFY(!model.threadAt(0).isUnread());
+
+ model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("flagged") }, {});
+ QVERIFY(model.threadAt(0).isFlagged());
+}
+
+void TestThreadListModel::tagChangeIsIdempotent()
+{
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ // Adding a tag twice must not duplicate it: the tag list is shown joined,
+ // and "inbox inbox" is visible garbage.
+ model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("inbox") }, {});
+ QCOMPARE(model.threadAt(0).tags.count(QStringLiteral("inbox")), 1);
+
+ // Removing an absent tag is equally harmless.
+ model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("nosuchtag") });
+ QCOMPARE(model.threadAt(0).tags.count(QStringLiteral("inbox")), 1);
+}
+
+void TestThreadListModel::tagChangeSignalsExactlyTheChangedRow()
+{
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")),
+ makeThread(QStringLiteral("t2"), QStringLiteral("two")),
+ makeThread(QStringLiteral("t3"), QStringLiteral("three")) });
+
+ QSignalSpy changed(&model, &QAbstractItemModel::dataChanged);
+ model.applyTagChange(QStringLiteral("t2"), {}, { QStringLiteral("unread") });
+
+ QCOMPARE(changed.size(), 1);
+ const QModelIndex topLeft = changed.first().at(0).value<QModelIndex>();
+ const QModelIndex bottomRight = changed.first().at(1).value<QModelIndex>();
+ QCOMPARE(topLeft.row(), 1);
+ QCOMPARE(bottomRight.row(), 1);
+ QCOMPARE(topLeft.column(), 0);
+ // The whole row repaints: unread state changes the font of every column.
+ QCOMPARE(bottomRight.column(), ThreadListModel::ColumnCount - 1);
+}
+
+void TestThreadListModel::tagChangeForUnknownThreadIsIgnored()
+{
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+
+ QSignalSpy changed(&model, &QAbstractItemModel::dataChanged);
+ model.applyTagChange(QStringLiteral("nosuchthread"), { QStringLiteral("x") }, {});
+
+ QVERIFY(changed.isEmpty());
+ QCOMPARE(model.threadAt(0).tags, (QStringList{ QStringLiteral("inbox"),
+ QStringLiteral("unread") }));
+}
+
+void TestThreadListModel::tagChangeRoundTripsForRevert()
+{
+ // MainWindow reverts a failed write by re-applying the change with add and
+ // remove swapped. That only restores the original state if the round trip
+ // is exact.
+ ThreadListModel model;
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) });
+ const QStringList before = model.threadAt(0).tags;
+
+ const QStringList add{ QStringLiteral("flagged") };
+ const QStringList remove{ QStringLiteral("inbox") };
+
+ model.applyTagChange(QStringLiteral("t1"), add, remove);
+ QVERIFY(model.threadAt(0).tags != before);
+
+ model.applyTagChange(QStringLiteral("t1"), remove, add);
+ const QStringList after = model.threadAt(0).tags;
+ QCOMPARE(after.size(), before.size());
+ for (const QString &tag : before)
+ QVERIFY(after.contains(tag));
+}
+
+void TestThreadListModel::modelPassesQtTester()
+{
+ ThreadListModel model;
+ // Catches signal/rowCount contract violations that hand-written tests miss.
+ QAbstractItemModelTester tester(&model,
+ QAbstractItemModelTester::FailureReportingMode::QtTest);
+
+ model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")),
+ makeThread(QStringLiteral("t2"), QStringLiteral("two")) });
+ model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("unread") });
+ model.clear();
+}
+
+QTEST_MAIN(TestThreadListModel)
+#include "test_threadlistmodel.moc"