From 055132af18cc0a4f66e01eb77a94941cb66c0020 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 17:17:25 +0200 Subject: 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 --- tests/test_mainwindow.cpp | 44 ++++++++++++++++++++++++++++++++++++++++++ tests/test_threadlistmodel.cpp | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) (limited to 'tests') 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 #include +#include + #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(); + 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 5a72c64..a9cdb73 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" -- cgit v1.2.3