From 11946725ae4fee988d9cea38359693d4ecb46470 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 08:56:33 +0200 Subject: 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 --- src/CMakeLists.txt | 1 + src/threadlistmodel.cpp | 124 ++++++++++++++++++++++++++++++++++++++++++++++++ src/threadlistmodel.h | 50 +++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 src/threadlistmodel.cpp create mode 100644 src/threadlistmodel.h (limited to 'src') 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 + +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 &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 +#include + +#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 &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 m_threads; +}; -- cgit v1.2.3