diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-03 17:17:25 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-03 17:17:25 +0200 |
| commit | e45f68b04b5ee2400a7885d5f5b054a889061df5 (patch) | |
| tree | bcf0a90e65dd2a85665b83694fdf4a39bf97c565 | |
| parent | 7f505624b1f385a89c9ff32e15f4d4e68595e5b8 (diff) | |
| download | qtmaildir-e45f68b04b5ee2400a7885d5f5b054a889061df5.tar.gz qtmaildir-e45f68b04b5ee2400a7885d5f5b054a889061df5.zip | |
feat: show a paperclip for threads with attachments
An attachment was only discoverable by opening the thread. A narrow
leftmost column now marks the threads that carry one.
No new worker query is involved: notmuch applies the "attachment" tag
while indexing, so ThreadSummary already holds what this needs. The
marker is a glyph rather than an icon resource, which ships no new asset
and inherits the row font, so it strikes through with a doomed thread
like every other cell. It falls back to "*" where the system font cannot
draw U+1F4CE, since an unrenderable codepoint reads as breakage rather
than as a marker.
Two silent Qt behaviours had to be handled, both found by probe:
QHeaderView::restoreState() returns true for a blob saved against fewer
columns and applies the old widths shifted one place right. Adding a
column in front would therefore have mangled every existing saved
layout with no error to detect it by. The column count is now stored
beside the blob and a mismatch discards it, so the widths reset once on
upgrade instead of landing on the wrong columns.
QHeaderView's default minimumSectionSize is 58px on this platform, and
setColumnWidth() clamps to it without reporting the smaller value back,
so the column could not be narrow at all until it was lowered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | src/mainwindow.cpp | 19 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 35 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 10 | ||||
| -rw-r--r-- | src/types.h | 7 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 44 | ||||
| -rw-r--r-- | tests/test_threadlistmodel.cpp | 42 |
6 files changed, 155 insertions, 2 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 2a79988..1385f01 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -100,9 +100,18 @@ void MainWindow::restoreUiState() m_splitter->restoreState(splitter); } + // A header blob saved against a different set of columns must be + // discarded, not restored. QHeaderView::restoreState() returns TRUE for a + // blob with fewer sections than the model and applies the old widths to + // the wrong columns: adding the attachment column in front shifted every + // saved width one place right, silently mangling the layout with no error + // to detect it by (verified on Qt 6.11). The column count is stored + // alongside and the blob is only used when it still matches. const QByteArray header = state.value(QStringLiteral("threadlist/header")) .toByteArray(); - if (!header.isEmpty()) { + const int savedColumns = + state.value(QStringLiteral("threadlist/columns")).toInt(); + if (!header.isEmpty() && savedColumns == ThreadListModel::ColumnCount) { m_threadView->horizontalHeader()->restoreState(header); } @@ -123,6 +132,9 @@ void MainWindow::saveUiState() const state.setValue(QStringLiteral("window/splitter"), m_splitter->saveState()); state.setValue(QStringLiteral("threadlist/header"), m_threadView->horizontalHeader()->saveState()); + // Guards the blob above: see restoreUiState(). + state.setValue(QStringLiteral("threadlist/columns"), + int(ThreadListModel::ColumnCount)); state.setValue(QStringLiteral("message/zoom"), m_messageView->zoomFactor()); } @@ -275,6 +287,11 @@ void MainWindow::buildUi() // Starting widths only; a drag overrides them, and they are what the // saved-widths item will persist. + // Without this the attachment column cannot be narrow at all: the default + // minimum section size is 58px on this platform, and setColumnWidth() + // clamps to it silently rather than reporting the smaller value back. + m_threadView->horizontalHeader()->setMinimumSectionSize(24); + m_threadView->setColumnWidth(ThreadListModel::AttachmentColumn, 28); m_threadView->setColumnWidth(ThreadListModel::DateColumn, 130); m_threadView->setColumnWidth(ThreadListModel::AuthorsColumn, 180); m_threadView->setColumnWidth(ThreadListModel::SubjectColumn, 520); diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp index 2f2882e..2289e6c 100644 --- a/src/threadlistmodel.cpp +++ b/src/threadlistmodel.cpp @@ -20,6 +20,27 @@ #include <QBrush> #include <QFont> +#include <QFontDatabase> +#include <QFontMetrics> + +QString ThreadListModel::attachmentGlyph() +{ + // U+1F4CE PAPERCLIP, with a fallback for a system whose default font + // cannot draw it: an unrenderable codepoint shows as a tofu box, which + // reads as "something is broken" rather than "this has an attachment". + // Computed once; the font does not change under a running application. + static const QString glyph = [] { + const char32_t paperclip = 0x1F4CE; + const QString preferred = QString::fromUcs4(&paperclip, 1); + const QFontMetrics metrics{QFontDatabase::systemFont( + QFontDatabase::GeneralFont)}; + // "*" as the fallback: ASCII, present in every practical font, and + // unambiguous in a column that shows nothing else. + return metrics.inFontUcs4(paperclip) ? preferred + : QStringLiteral("*"); + }(); + return glyph; +} QColor ThreadListModel::deletedColour() { @@ -85,8 +106,19 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const return {}; } + if (role == Qt::ToolTipRole && index.column() == AttachmentColumn) + return thread.hasAttachment() ? tr("Has an attachment") : QVariant(); + + if (role == Qt::TextAlignmentRole && index.column() == AttachmentColumn) + return QVariant::fromValue(Qt::AlignCenter); + if (role == Qt::DisplayRole) { switch (index.column()) { + case AttachmentColumn: + // A glyph rather than an icon resource: no new asset to ship, and + // it inherits the row's font, so it strikes through with a doomed + // thread like every other cell. + return thread.hasAttachment() ? attachmentGlyph() : QString(); case DateColumn: return thread.date.toString(QStringLiteral("yyyy-MM-dd hh:mm")); case AuthorsColumn: @@ -141,6 +173,9 @@ QVariant ThreadListModel::headerData(int section, Qt::Orientation orientation, return {}; switch (section) { + // No label: any text would set a minimum width far wider than the icon, + // which defeats the point of a narrow column. + case AttachmentColumn: return QString(); case DateColumn: return QStringLiteral("Date"); case AuthorsColumn: return QStringLiteral("From"); case SubjectColumn: return QStringLiteral("Subject"); diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h index 7ed8fef..ab9378e 100644 --- a/src/threadlistmodel.h +++ b/src/threadlistmodel.h @@ -36,7 +36,11 @@ public: /// under the message pane, and the account tag renders as a chip in front /// of the subject. enum Column { - DateColumn = 0, + /// 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, + DateColumn, AuthorsColumn, SubjectColumn, ColumnCount, @@ -64,6 +68,10 @@ public: /// 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. + static QString attachmentGlyph(); + static QColor deletedColour(); static QColor spamColour(); diff --git a/src/types.h b/src/types.h index e25c3a9..59d1d06 100644 --- a/src/types.h +++ b/src/types.h @@ -38,6 +38,13 @@ struct ThreadSummary bool isDeleted() const { return tags.contains(QStringLiteral("deleted")); } bool isSpam() const { return tags.contains(QStringLiteral("spam")); } + /// notmuch applies "attachment" itself while indexing, so this needs no + /// MIME parsing and no extra worker query: the tag is already in tags. + bool hasAttachment() const + { + return tags.contains(QStringLiteral("attachment")); + } + /// True while the thread is tagged for removal. notmuch deletes nothing /// itself: the tag marks the thread for whatever the user's sync script /// does next, so the row has to show it is on its way out. diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index b4d9e4a..ab904f9 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -25,10 +25,13 @@ #include <QStandardPaths> #include <QTemporaryDir> +#include <QTableView> + #include "config.h" #include "keymap.h" #include "mainwindow.h" #include "messageview.h" +#include "threadlistmodel.h" /// MainWindow is mostly wiring, and the parts that need a real database are /// still verified manually. What is checked here is the action registry: the @@ -47,6 +50,7 @@ private slots: void uiStateIsNotWrittenIntoTheUserConfig(); void uiStateSurvivesARestart(); void missingUiStateLeavesTheDefaults(); + void headerStateFromADifferentColumnLayoutIsDiscarded(); }; void TestMainWindow::everyKnownActionIsRegistered() @@ -222,6 +226,46 @@ void TestMainWindow::missingUiStateLeavesTheDefaults() QStandardPaths::setTestModeEnabled(false); } +void TestMainWindow::headerStateFromADifferentColumnLayoutIsDiscarded() +{ + // The upgrade hazard: a 0.3.0 state file holds a three-column header blob, + // and 0.4.0 added the attachment column in front. QHeaderView:: + // restoreState() returns TRUE for a blob with fewer sections than the + // model and applies the old widths shifted one column right, mangling the + // layout with no error to detect it by (verified on Qt 6.11). The stored + // column count is what makes that detectable. + QStandardPaths::setTestModeEnabled(true); + QFile::remove(MainWindow::uiStatePath()); + + { + const Config config; + MainWindow window(config); + window.close(); + } + + // Forge a state file from an older layout: same blob, wrong column count. + { + QSettings state(MainWindow::uiStatePath(), QSettings::IniFormat); + state.setValue(QStringLiteral("threadlist/columns"), + int(ThreadListModel::ColumnCount) - 1); + state.setValue(QStringLiteral("threadlist/header"), + QByteArray("not a header this model could have saved")); + } + + // Constructing must not apply it, and must not crash on the garbage blob. + const Config config; + MainWindow reopened(config); + + auto *view = reopened.findChild<QTableView *>(); + QVERIFY(view); + QCOMPARE(view->columnWidth(ThreadListModel::AttachmentColumn), 28); + QCOMPARE(view->columnWidth(ThreadListModel::DateColumn), 130); + QCOMPARE(view->columnWidth(ThreadListModel::SubjectColumn), 520); + + QFile::remove(MainWindow::uiStatePath()); + QStandardPaths::setTestModeEnabled(false); +} + // Constructing a MainWindow needs a QApplication and a platform plugin. The // test has no display under ctest, so it runs offscreen unless the caller // asked for something else. diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp index e8a5fa8..97f7fde 100644 --- a/tests/test_threadlistmodel.cpp +++ b/tests/test_threadlistmodel.cpp @@ -38,6 +38,7 @@ private slots: void unreadStylingSurvivesAnAccountChip(); void accountChipUsesTheConfiguredColour(); void deletedThreadsAreRedAndStruckThrough(); + void attachmentColumnIsFirstAndMarksOnlyTaggedThreads(); void spamThreadsAreOrangeAndStruckThrough(); void doomedStylingCoversEveryColumn(); void ordinaryThreadsCarryNoRowColour(); @@ -472,5 +473,46 @@ void TestThreadListModel::modelPassesQtTester() model.clear(); } +void TestThreadListModel::attachmentColumnIsFirstAndMarksOnlyTaggedThreads() +{ + // Leftmost, and narrow: the point is to see an attachment without opening + // the thread, which only works if the column is never scrolled away. + QCOMPARE(ThreadListModel::AttachmentColumn, 0); + + ThreadSummary plain = makeThread(QStringLiteral("t1"), + QStringLiteral("no attachment")); + ThreadSummary withFile = makeThread(QStringLiteral("t2"), + QStringLiteral("has one")); + // notmuch applies this tag itself while indexing, so no MIME parsing and + // no extra worker query are involved. + withFile.tags.append(QStringLiteral("attachment")); + + ThreadListModel model; + model.appendBatch({ plain, withFile }); + + const QModelIndex plainCell = + model.index(0, ThreadListModel::AttachmentColumn); + const QModelIndex fileCell = + model.index(1, ThreadListModel::AttachmentColumn); + + QVERIFY(model.data(plainCell, Qt::DisplayRole).toString().isEmpty()); + QCOMPARE(model.data(fileCell, Qt::DisplayRole).toString(), + ThreadListModel::attachmentGlyph()); + + // The glyph must be something a font can draw. An unrenderable codepoint + // shows as a tofu box, which reads as breakage rather than as a marker. + QVERIFY(!ThreadListModel::attachmentGlyph().isEmpty()); + + // Only the marked thread gets a tooltip, or an empty cell would claim to + // have an attachment on hover. + QVERIFY(model.data(plainCell, Qt::ToolTipRole).toString().isEmpty()); + QVERIFY(!model.data(fileCell, Qt::ToolTipRole).toString().isEmpty()); + + // The header carries no text: a label would set a minimum width far wider + // than the icon and defeat the narrow column. + QVERIFY(model.headerData(ThreadListModel::AttachmentColumn, Qt::Horizontal, + Qt::DisplayRole).toString().isEmpty()); +} + QTEST_MAIN(TestThreadListModel) #include "test_threadlistmodel.moc" |
