From 1f2eddff6afcbf4c24f06e982e9169219429a2ed Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 14:17:09 +0200 Subject: feat: add menus, a toolbar and a shortcut reference Actions were a QHash of std::function dispatched by an event filter, which nothing could put in a menu. They are QActions now, bound from KeyMap so a [keys] override reaches the menus as well as the keyboard. Menu bar covers every action; the toolbar carries only Sync, Archive, Delete and Undo. Help > Keyboard shortcuts is generated from the actions, so it shows what the keys really do rather than a copy that drifts. spam and load_remote gained defaults, having been unreachable without a hand-written binding. The event filter is gone. Probing showed QAction shortcuts are dispatched before the focused widget sees the key, so they beat QAbstractItemView's type-to-search without one, and Qt already suppresses plain-letter shortcuts while an editable widget has focus. Dropping the filter's blanket guard also lets Ctrl+Q work while the query bar has focus. registeredActionNames() is derived from the actions rather than hand-maintained, so the two drift tests it needed are replaced by checks that a configured binding reaches its action. No confirmation dialogs: tag mutations still answer to undo. --- CHANGELOG.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) (limited to 'CHANGELOG.md') diff --git a/CHANGELOG.md b/CHANGELOG.md index 90853ce..ce2b744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,37 @@ point at which they are stable. ## [Unreleased] -Nothing yet. +### Added + +- Menu bar covering every action: File, Edit, Message, View and Help. +- Toolbar with the frequent subset, Sync, Archive, Delete and Undo. +- **Help > Keyboard shortcuts**, listing the current bindings. Generated from + the actions themselves, so it shows configured overrides rather than a + hand-written copy of the defaults. +- **Help > About**. +- Default bindings for `spam` and `load_remote`, which previously had none + and were unreachable until bound by hand. + +### Fixed + +- Three default bindings never fired. Typing a capital sends `Shift`+the key, + but `N`, `F` and `G` were stored as the unshifted key, which no keystroke + produces, leaving `toggle_unread`, `flag` and `sync` dead. A bare capital in + `[keys]` is now read as `Shift`+that letter. As a side effect `y` and `Y` + are two distinct keys rather than a collision that silently dropped one. +- Modifier shortcuts such as `Ctrl+Q` now work while the query bar has focus. + The old event filter suppressed every binding there, not only the plain + letters that would have interfered with typing. + +### Changed + +- Default bindings moved to modifier shortcuts (`Ctrl+E` archive, `Ctrl+D` + delete, and so on). Existing `[keys]` entries are unaffected, and single + letters are still safe to bind. See "Upgrading from 0.1.0" in the README. +- Actions are `QAction`s dispatched by shortcut rather than a hash of + callbacks behind an event filter, which is what lets them appear in menus. + The hand-maintained list of registered action names is now derived from the + actions, so it can no longer drift from them. ## [0.1.0] - 2026-08-03 -- cgit v1.2.3 From 1371a3901857a8d2ac4fcf235fb29e7caaf63cae Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 14:57:11 +0200 Subject: feat: show that a tag action landed Selecting a thread and hitting Delete changed nothing on screen, so there was no way to tell the action had stuck. The tag was always applied: applyTagChange() emitted dataChanged across the row, and the Tags column did update. But Subject was set to stretch while Tags came after it, so Subject took all free width and pushed Tags out of view. The feedback lived in the one column that could not be seen. Columns are now Tags, Date, From, Subject, with Subject stretching last so nothing can be pushed off the right edge. A thread tagged deleted or spam fills its whole row, muted red or orange with white struck-through text, through the background, foreground and font roles, so no cue depends on one column remaining visible. Strike-through accompanies the fill on purpose: it survives a theme that overrides backgrounds and reads without colour. Bold for unread still composes with it. Archive adds no tag, so an archived row is left unstyled for now. --- CHANGELOG.md | 7 ++ .../plans/2026-08-03-post-0.1.0-usability.md | 38 ++++++++ src/mainwindow.cpp | 8 ++ src/threadlistmodel.cpp | 46 +++++++++- src/threadlistmodel.h | 15 ++- src/types.h | 7 ++ tests/test_threadlistmodel.cpp | 102 +++++++++++++++++++++ 7 files changed, 218 insertions(+), 5 deletions(-) (limited to 'CHANGELOG.md') diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2b744..e0ff08b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ point at which they are stable. ### Fixed +- Acting on a thread now visibly changes its row. A thread tagged `deleted` + or `spam` is filled dark red or orange, in white struck-through text, across + every column. The tag change was already applied, but `Tags` sat after the + stretching `Subject` column and was pushed off-screen, so Delete looked like + it had done nothing. +- Thread list columns reordered to Tags, Date, From, Subject. Subject stretches + and is now last, so no column can be pushed out of view. - Three default bindings never fired. Typing a capital sends `Shift`+the key, but `N`, `F` and `G` were stored as the unshifted key, which no keystroke produces, leaving `toggle_unread`, `flag` and `sync` dead. A bare capital in diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 99e26e0..1e08bb1 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -45,6 +45,7 @@ taking that too literally. | 9 | No in-app view of configured shortcuts | discoverability | S | **done** | | 10 | Reaching an account's inbox takes two steps | workflow | S | open | | 11 | Icon, `.desktop` file, SlackBuild | packaging | M | open | +| 13 | No visual feedback that an action stuck | feedback | S | **done** | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -360,6 +361,43 @@ Packaging, independent of everything above, and can proceed in parallel. --- +## 13. No visual feedback that an action stuck + +**Observed:** selecting a thread and hitting Delete changed nothing on screen. +No way to tell whether the thread was really going to be deleted on the next +sync, which is bad UX for every tag action, not only delete. + +**Cause:** not a missing update. `ThreadListModel::applyTagChange()` already +added the tag and emitted `dataChanged` across the whole row, so the Tags +column did change. But `SubjectColumn` was set to `QHeaderView::Stretch` while +`TagsColumn` came after it, so Subject absorbed all free width and pushed Tags +out of view. The feedback existed in the one column that could not be seen. + +**Approach:** two changes, since the cause was two things. + +- Column order is now Tags, Date, From, Subject. Subject stretches and is + last, so nothing sits to its right to be pushed out. The other three size + to their contents. +- A thread tagged `deleted` or `spam` styles its entire row: muted dark red + (`#8b2c2c`) or orange (`#a85c18`) fill, white text, struck through. Applied + through `Qt::BackgroundRole`, `Qt::ForegroundRole` and `Qt::FontRole` for + every column, so no cue depends on a single column staying visible. + +Strike-through rides along with the fill deliberately: it survives a theme +that overrides background colours, a colourblind reader, and a screenshot. +Bold for unread still composes with it. + +**Decisions:** no status-bar or toast changes, the existing `tagSelected()` +message stays as it is. Archive removes `inbox` and adds nothing, so an +archived thread gets no row styling; whether it should disappear from an inbox +query is deliberately left open rather than guessed at. + +**Verification:** four model tests covering the colours, the strike-through, +that styling spans every column, and that undo restores a plain row. Rendered +and inspected: normal, unread, deleted, spam, and deleted-plus-unread rows. + +--- + ## Deferred, unsized, or split out Items noted while triaging but not part of the original list. Same numbering diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index eaf98b8..915c1ae 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -169,8 +169,16 @@ void MainWindow::buildUi() m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection); m_threadView->verticalHeader()->hide(); m_threadView->horizontalHeader()->setStretchLastSection(false); + // Subject is the last column and takes the leftover width; the three + // fixed-width ones size to their contents. Nothing sits to the right of + // the stretching column, so no column can be pushed out of view. m_threadView->horizontalHeader()->setSectionResizeMode( ThreadListModel::SubjectColumn, QHeaderView::Stretch); + for (int column : { ThreadListModel::TagsColumn, ThreadListModel::DateColumn, + ThreadListModel::AuthorsColumn }) { + m_threadView->horizontalHeader()->setSectionResizeMode( + column, QHeaderView::ResizeToContents); + } connect(m_threadView->selectionModel(), &QItemSelectionModel::currentRowChanged, diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp index c129be1..a083145 100644 --- a/src/threadlistmodel.cpp +++ b/src/threadlistmodel.cpp @@ -18,8 +18,24 @@ #include "threadlistmodel.h" +#include #include +QColor ThreadListModel::deletedColour() +{ + // Desaturated crimson: legible under white text on a dark theme, and calm + // enough that deleting fifty threads does not repaint the list as a + // warning banner. + return QColor(0x8b, 0x2c, 0x2c); +} + +QColor ThreadListModel::spamColour() +{ + // Distinct hue rather than a lighter red, so spam and deleted are told + // apart by colour and not by shade. + return QColor(0xa8, 0x5c, 0x18); +} + ThreadListModel::ThreadListModel(QObject *parent) : QAbstractTableModel(parent) { @@ -67,10 +83,34 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const } } - if (role == Qt::FontRole && thread.isUnread()) { + // A thread tagged deleted or spam is on its way out, and the user needs to + // see that the moment they act. Every one of these roles applies to the + // whole row: a cue on a single column disappears as soon as that column + // scrolls out of view, which is exactly how the tag change used to go + // unnoticed. + if (thread.isDoomed()) { + if (role == Qt::BackgroundRole) + return QBrush(thread.isDeleted() ? deletedColour() : spamColour()); + if (role == Qt::ForegroundRole) + return QBrush(QColor(Qt::white)); + } + + if (role == Qt::FontRole) { QFont font; - font.setBold(true); - return font; + bool styled = false; + if (thread.isUnread()) { + font.setBold(true); + styled = true; + } + // Struck through as well as filled, so the state survives a + // screenshot, a colourblind reader, and a theme that overrides the + // background. + if (thread.isDoomed()) { + font.setStrikeOut(true); + styled = true; + } + if (styled) + return font; } return {}; diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h index eb1a7ff..01fd241 100644 --- a/src/threadlistmodel.h +++ b/src/threadlistmodel.h @@ -19,6 +19,7 @@ #pragma once #include +#include #include #include "types.h" @@ -29,11 +30,14 @@ class ThreadListModel : public QAbstractTableModel { Q_OBJECT public: + /// Subject stretches to fill the view, so it must come last: anything + /// after it is pushed out of sight. Tags leads, being the column that + /// changes when the user acts on a thread. enum Column { - DateColumn = 0, + TagsColumn = 0, + DateColumn, AuthorsColumn, SubjectColumn, - TagsColumn, ColumnCount, }; @@ -44,6 +48,13 @@ public: ThreadIdRole = Qt::UserRole + 1, }; + /// Row fill for a thread tagged `deleted`, and for one tagged `spam`. + /// 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. + static QColor deletedColour(); + static QColor spamColour(); + explicit ThreadListModel(QObject *parent = nullptr); int rowCount(const QModelIndex &parent = {}) const override; diff --git a/src/types.h b/src/types.h index 2de6129..e25c3a9 100644 --- a/src/types.h +++ b/src/types.h @@ -35,6 +35,13 @@ struct ThreadSummary bool isUnread() const { return tags.contains(QStringLiteral("unread")); } bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); } + bool isDeleted() const { return tags.contains(QStringLiteral("deleted")); } + bool isSpam() const { return tags.contains(QStringLiteral("spam")); } + + /// 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. + bool isDoomed() const { return isDeleted() || isSpam(); } }; struct MessageRef diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp index 24ba9e2..98c477c 100644 --- a/tests/test_threadlistmodel.cpp +++ b/tests/test_threadlistmodel.cpp @@ -33,6 +33,11 @@ private slots: void reportsSubjectAndAuthors(); void subjectShowsMessageCountOnlyForRealThreads(); void unreadThreadsRenderBold(); + void tagsAreTheFirstColumnAndSubjectTheLast(); + void deletedThreadsAreRedAndStruckThrough(); + void spamThreadsAreOrangeAndStruckThrough(); + void doomedStylingCoversEveryColumn(); + void ordinaryThreadsCarryNoRowColour(); void threadIdIsReachableFromAnIndex(); void invalidIndexesReturnNothing(); void threadAtOutOfRangeIsSafe(); @@ -153,6 +158,103 @@ void TestThreadListModel::unreadThreadsRenderBold() QVERIFY(unreadFont.value().bold()); } +void TestThreadListModel::tagsAreTheFirstColumnAndSubjectTheLast() +{ + // Subject stretches to fill the view, so whatever sits after it is pushed + // off-screen. Tags used to be there, which is why acting on a thread + // looked like it did nothing: the only column that changed was invisible. + QCOMPARE(ThreadListModel::TagsColumn, 0); + QCOMPARE(ThreadListModel::SubjectColumn, ThreadListModel::ColumnCount - 1); + + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("hello")) }); + QCOMPARE(model.headerData(ThreadListModel::TagsColumn, Qt::Horizontal, + Qt::DisplayRole).toString(), + QStringLiteral("Tags")); + QCOMPARE(model.headerData(ThreadListModel::SubjectColumn, Qt::Horizontal, + Qt::DisplayRole).toString(), + QStringLiteral("Subject")); +} + +void TestThreadListModel::deletedThreadsAreRedAndStruckThrough() +{ + ThreadListModel model; + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("doomed")); + thread.tags = QStringList{ QStringLiteral("inbox") }; + model.appendBatch({ thread }); + + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QVERIFY(!model.data(subject, Qt::BackgroundRole).isValid()); + + model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); + + const QVariant background = model.data(subject, Qt::BackgroundRole); + QVERIFY(background.isValid()); + QCOMPARE(background.value().color(), ThreadListModel::deletedColour()); + + // White text on the fill, and struck through so the state reads even in a + // screenshot with the colours stripped. + QCOMPARE(model.data(subject, Qt::ForegroundRole).value().color(), + QColor(Qt::white)); + QVERIFY(model.data(subject, Qt::FontRole).value().strikeOut()); +} + +void TestThreadListModel::spamThreadsAreOrangeAndStruckThrough() +{ + ThreadListModel model; + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("junk")); + thread.tags = QStringList{ QStringLiteral("inbox") }; + model.appendBatch({ thread }); + + model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("spam") }, {}); + + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QCOMPARE(model.data(subject, Qt::BackgroundRole).value().color(), + ThreadListModel::spamColour()); + QVERIFY(model.data(subject, Qt::FontRole).value().strikeOut()); + + // Spam and deleted must be distinguishable, not two shades of one colour. + QVERIFY(ThreadListModel::spamColour() != ThreadListModel::deletedColour()); +} + +void TestThreadListModel::doomedStylingCoversEveryColumn() +{ + // A cue on one column would vanish the moment that column scrolled out of + // view, which is the bug this whole change exists to fix. + ThreadListModel model; + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("doomed")); + thread.tags = QStringList{ QStringLiteral("inbox") }; + model.appendBatch({ thread }); + + model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); + + for (int column = 0; column < ThreadListModel::ColumnCount; ++column) { + const QModelIndex index = model.index(0, column); + QVERIFY2(model.data(index, Qt::BackgroundRole).isValid(), + qPrintable(QStringLiteral("column %1 has no background").arg(column))); + QVERIFY2(model.data(index, Qt::FontRole).value().strikeOut(), + qPrintable(QStringLiteral("column %1 is not struck through").arg(column))); + } +} + +void TestThreadListModel::ordinaryThreadsCarryNoRowColour() +{ + // Undo has to restore the plain look, not merely drop the tag. + ThreadListModel model; + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("normal")); + thread.tags = QStringList{ QStringLiteral("inbox") }; + model.appendBatch({ thread }); + + model.applyTagChange(QStringLiteral("t1"), { QStringLiteral("deleted") }, {}); + model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("deleted") }); + + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QVERIFY(!model.data(subject, Qt::BackgroundRole).isValid()); + QVERIFY(!model.data(subject, Qt::ForegroundRole).isValid()); + const QVariant font = model.data(subject, Qt::FontRole); + QVERIFY(!font.isValid() || !font.value().strikeOut()); +} + void TestThreadListModel::threadIdIsReachableFromAnIndex() { // The view hands MainWindow a QModelIndex; the worker needs a thread id. -- cgit v1.2.3 From f62ced3c2c85675e746bff7ef8aca5c75c9737e0 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 15:41:56 +0200 Subject: feat: use the application icon The icon was committed in a previous session and referenced nowhere: no qrc, no .desktop entry, no setWindowIcon. It is wired up now, as a window icon, a desktop entry, and install rules placing both into hicolor and share/applications. resources.qrc belongs to the executable rather than to qtmaildir_lib. A qrc compiled into a static library registers itself from a global initialiser, and the linker discards that object because nothing references it: the build succeeded, qInitResources_resources() was present in the .a, and QFile::exists(":/icons/qtmaildir.svg") still returned false at runtime. Verified loading at 16, 32 and 64 pixels after the move. Toolbar and menu actions take icons from the system theme by their standard names, so they match the rest of the desktop rather than shipping bespoke art. A theme lacking one leaves that action as text, which still works. --- CHANGELOG.md | 26 ++++++++++++-- CMakeLists.txt | 1 + README.md | 35 +++++++++++++++++++ assets/qtmaildir.desktop | 13 +++++++ .../plans/2026-08-03-post-0.1.0-usability.md | 40 +++++++++++++++++++++- src/CMakeLists.txt | 16 ++++++++- src/main.cpp | 8 +++++ src/resources.qrc | 6 ++++ 8 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 assets/qtmaildir.desktop create mode 100644 src/resources.qrc (limited to 'CHANGELOG.md') diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ff08b..c78cb94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,22 @@ point at which they are stable. ### Added +- Tags render as coloured chips instead of text in a column. The account tag + sits in front of the subject in the thread list, and the functional tags fill + a single row under the message pane, with anything that does not fit + collapsing into a `+N` chip whose tooltip names the rest. +- `[tagcolors]` config group. Colours resolve by exact tag first, then by + top-level prefix, so one `shopping` entry covers `shopping/amazon` and + `shopping/nike` while `shopping/amazon` can still override its own. Built-in + defaults cover the usual state tags; anything unconfigured gets a stable + colour derived from its name. +- `color` and `label` keys in an account stanza, setting the account chip's + fill and its text. `label` shortens a long key for display only and renames + nothing in notmuch; unset falls back to the key. +- The application icon is now used: window icon, a `.desktop` entry, and + install rules placing both into `hicolor` and `share/applications`. +- Toolbar and menu actions carry icons from the system theme, falling back to + text where a theme lacks one. - Menu bar covering every action: File, Edit, Message, View and Help. - Toolbar with the frequent subset, Sync, Archive, Delete and Undo. - **Help > Keyboard shortcuts**, listing the current bindings. Generated from @@ -29,8 +45,14 @@ point at which they are stable. every column. The tag change was already applied, but `Tags` sat after the stretching `Subject` column and was pushed off-screen, so Delete looked like it had done nothing. -- Thread list columns reordered to Tags, Date, From, Subject. Subject stretches - and is now last, so no column can be pushed out of view. +- Thread list columns are Date, From and Subject, all resizable. The tags + column is gone: spelling out a dozen tags per row consumed most of the list's + width. Widening past the viewport scrolls horizontally rather than squeezing + the other columns. +- Hierarchical tags in `[tagcolors]` were silently ignored. QSettings treats + `/` in a key as a group separator, so `shopping/amazon` becomes a nested key + that `childKeys()` never returns, and every tag containing a `/` fell through + to its prefix. - Three default bindings never fired. Typing a capital sends `Shift`+the key, but `N`, `F` and `G` were stored as the unshifted key, which no keystroke produces, leaving `toggle_unread`, `flag` and `sync` dead. A bare capital in diff --git a/CMakeLists.txt b/CMakeLists.txt index ed9db9c..7b73a7f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,7 @@ project(qtmaildir VERSION 0.1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) find_package(Qt6 6.5 REQUIRED COMPONENTS Widgets WebEngineWidgets Test) diff --git a/README.md b/README.md index f7e2444..37ea3c9 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ name = Your Name address = you@example.org maildir = work-mail ; relative to notmuch's database.path drafts = Drafts ; recorded for v2; unused today +label = W ; optional chip text; defaults to the key +color = #2f6fa8 ; optional chip colour; generated when unset [account.personal] name = Your Name @@ -98,6 +100,13 @@ address = you@example.net maildir = personal drafts = Drafts +[tagcolors] +; Optional. Colours resolve by exact tag first, then by top-level prefix, so +; one entry covers a whole hierarchy. +shopping = #3366cc ; also colours shopping/amazon, shopping/nike, ... +shopping/amazon = #ff9900 ; ... unless the exact tag overrides it +work = #cc4444 + [queries] Inbox = tag:inbox Unread = tag:unread @@ -114,6 +123,32 @@ Saved-query buttons appear in alphabetical order rather than file order: QSettings returns keys sorted, and preserving file order would mean hand-rolling an INI parser. +## Tags + +Tags render as coloured chips, and fall into two kinds. + +**Account tags** (`account-`, matching an `[account.]` stanza) say +which mailbox a thread arrived in. They appear as a chip in front of the +subject in the thread list, coloured by that account's `color` key and labelled +by its `label` key. `label` changes the chip text only; the notmuch tag is +never renamed, so queries and external tagging are unaffected. + +**Functional tags** say what state a thread is in. They fill one row under the +message pane, sorted, with whatever does not fit collapsing into a `+N` chip +whose tooltip lists the rest. Colours come from `[tagcolors]`, falling back to +built-in defaults for the usual state tags (`flagged`, `unread`, `deleted`, +`spam`, `attachment`, `replied`, and others), and finally to a colour derived +from the tag name so no chip is ever unstyled. + +Lookup is exact tag first, then top-level prefix. One `shopping` entry +therefore covers `shopping/amazon` and `shopping/nike`, while a +`shopping/amazon` entry still overrides its own. + +Note that a `/` in an INI key is a group separator to QSettings, so +`shopping/amazon = #ff9900` is stored as a nested key and written to the file +as `shopping\amazon`. It is read back correctly; the escaping is QSettings' +own. + ## Keybindings Defaults, all rebindable through `[keys]`: diff --git a/assets/qtmaildir.desktop b/assets/qtmaildir.desktop new file mode 100644 index 0000000..98e435b --- /dev/null +++ b/assets/qtmaildir.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=qtmaildir +GenericName=Mail Reader +Comment=Read and organize a local notmuch-indexed Maildir +Exec=qtmaildir +Icon=qtmaildir +Terminal=false +Categories=Network;Email;Qt; +Keywords=mail;email;notmuch;maildir; +StartupNotify=true +StartupWMClass=qtmaildir diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 1e08bb1..915c8cb 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -44,8 +44,9 @@ taking that too literally. | 8 | No buttons or menu entries for archive, undo, etc | discoverability | M | **done** | | 9 | No in-app view of configured shortcuts | discoverability | S | **done** | | 10 | Reaching an account's inbox takes two steps | workflow | S | open | -| 11 | Icon, `.desktop` file, SlackBuild | packaging | M | open | +| 11 | Icon, `.desktop` file, SlackBuild | packaging | M | **partly done**: icon and `.desktop` landed, SlackBuild open | | 13 | No visual feedback that an action stuck | feedback | S | **done** | +| 14 | Tag column unreadable, tags need another home | presentation | M | **done** | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -398,6 +399,43 @@ and inspected: normal, unread, deleted, spam, and deleted-plus-unread rows. --- +## 14. Tag column unreadable + +**Observed:** with tags spelled out per row the column ran to 500 pixels of +mostly repeated text ("account-privateemail-danilo.macri attachment flagged +inbox passed replied"), dominated by the account prefix, and consumed most of +the list's width. + +**Cause:** presentation, not data. 96 tags in this database, many hierarchical +(`shopping/amazon`, `mailing-list/SBo`), rendered as a joined string. + +**Approach:** the column is gone. Tags now render as coloured chips in two +places, split by taxonomy: + +- The **account tag** says which mailbox a thread came from. It draws as a chip + in front of the subject, coloured and labelled from its own `[account.]` + stanza via new `color` and `label` keys. `label` is display-only; the notmuch + tag is never renamed. +- **Functional tags** say what state a thread is in. They fill a single row + under the message pane, with overflow collapsing into a `+N` chip whose + tooltip lists the hidden ones. A single row keeps the message area from + shifting between threads with different tag counts. + +Colours resolve exact tag first, then top-level prefix, so one `shopping` entry +covers the hierarchy without listing all 96. Unconfigured tags fall back to a +hash of the name, stable so a chip never changes colour as the list scrolls. + +**Defect found while building:** QSettings treats `/` in a key as a group +separator, so `shopping/amazon` becomes a nested key that `childKeys()` never +returns. Reading `[tagcolors]` with `childKeys()` silently dropped every +hierarchical tag, and each fell through to its prefix colour. Fixed by reading +`allKeys()`, with a regression test. The same gotcha is already documented in +`CLAUDE.md` for `[account.work]` section names. + +**Deferred:** clicking a chip to search that tag. Display only for now. + +--- + ## Deferred, unsized, or split out Items noted while triaging but not part of the original list. Same numbering diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7e4cea8..26cb37c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -6,6 +6,9 @@ add_library(qtmaildir_lib STATIC htmlbuilder.cpp cidschemehandler.cpp notmuchworker.cpp + tagchip.cpp + tagcolors.cpp + tagstrip.cpp threadlistmodel.cpp mailsync.cpp threadcidmap.cpp @@ -20,7 +23,18 @@ target_include_directories(qtmaildir_lib target_link_libraries(qtmaildir_lib PUBLIC Qt6::Widgets Qt6::WebEngineWidgets PkgConfig::GMIME ${NOTMUCH_LIBRARY}) -add_executable(qtmaildir main.cpp) +# resources.qrc belongs to the executable, not to the static library. A qrc +# compiled into a .a registers itself from a global initialiser, and the linker +# drops that object because nothing references it, so the resource silently +# fails to exist at runtime. +add_executable(qtmaildir main.cpp resources.qrc) target_link_libraries(qtmaildir PRIVATE qtmaildir_lib) install(TARGETS qtmaildir RUNTIME DESTINATION bin) + +# The icon goes into the hicolor theme under its scalable directory, which is +# where a desktop environment looks for the Icon= name in the .desktop entry. +install(FILES ${CMAKE_SOURCE_DIR}/assets/icons/qtmaildir.svg + DESTINATION share/icons/hicolor/scalable/apps) +install(FILES ${CMAKE_SOURCE_DIR}/assets/qtmaildir.desktop + DESTINATION share/applications) diff --git a/src/main.cpp b/src/main.cpp index 4d3ed0a..231594f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,6 +17,7 @@ */ #include +#include #include #include @@ -76,6 +77,13 @@ int main(int argc, char *argv[]) app.setOrganizationName(QStringLiteral("qtmaildir")); app.setApplicationVersion(QStringLiteral(QTMAILDIR_VERSION)); + // Compiled in rather than read from disk, so the icon is there whether or + // not the app was installed. setDesktopFileName() is what lets a Wayland + // compositor match the window to its .desktop entry, which is where the + // taskbar icon really comes from there. + app.setWindowIcon(QIcon(QStringLiteral(":/icons/qtmaildir.svg"))); + app.setDesktopFileName(QStringLiteral("qtmaildir")); + // Fail loudly on an ABI mismatch rather than crashing later. if (LIBNOTMUCH_MAJOR_VERSION < 5) { QMessageBox::critical(nullptr, QObject::tr("qtmaildir"), diff --git a/src/resources.qrc b/src/resources.qrc new file mode 100644 index 0000000..7bdb592 --- /dev/null +++ b/src/resources.qrc @@ -0,0 +1,6 @@ + + + + ../assets/icons/qtmaildir.svg + + -- cgit v1.2.3