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. --- src/mainwindow.cpp | 260 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 179 insertions(+), 81 deletions(-) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 2a484fb..be80d8a 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -18,13 +18,14 @@ #include "mainwindow.h" +#include #include -#include #include #include -#include #include #include +#include +#include #include #include #include @@ -32,6 +33,7 @@ #include #include #include +#include #include #include "mailsync.h" @@ -41,26 +43,13 @@ #include "threadlistmodel.h" #include "version.h" -QStringList MainWindow::registeredActionNames() +QStringList MainWindow::registeredActionNames() const { - // Keep in sync with registerActions(). Held against KeyMap::knownActions() - // by a test rather than by hope. - return { - QStringLiteral("next_thread"), - QStringLiteral("prev_thread"), - QStringLiteral("open_thread"), - QStringLiteral("archive"), - QStringLiteral("delete"), - QStringLiteral("spam"), - QStringLiteral("toggle_unread"), - QStringLiteral("flag"), - QStringLiteral("focus_query"), - QStringLiteral("toggle_html"), - QStringLiteral("load_remote"), - QStringLiteral("undo"), - QStringLiteral("sync"), - QStringLiteral("quit"), - }; + // Derived from the actions themselves, so it cannot drift from what + // registerActions() really installed. + QStringList names = m_actions.keys(); + names.sort(); + return names; } QString MainWindow::cidPrefixForIndex(int index) @@ -86,19 +75,16 @@ MainWindow::MainWindow(const Config &config, QWidget *parent) buildUi(); registerActions(); + buildMenus(); wireWorker(); showWarnings(); - installEventFilter(this); - - // The thread view needs its own filter, not just the window's. A filter on - // the window only sees key presses the focused child did not consume, and - // QAbstractItemView consumes plain letters for its type-to-search feature: - // with the list focused, 'h' jumped to the next thread whose subject began - // with "h" instead of toggling HTML, and every other single-letter binding - // (j, k, a, d, N, F, u, G) was swallowed the same way. Filtering the view - // itself puts the keymap ahead of that search. - m_threadView->installEventFilter(this); + // No event filter: QAction shortcuts are dispatched before the focused + // widget sees the key, so they beat QAbstractItemView's type-to-search + // without one. Qt also suppresses a plain-letter shortcut while an + // editable widget has focus, so typing in the query bar stays typing; + // modifier shortcuts such as Ctrl+Q still work there, which the old + // filter blocked. if (!m_config.savedQueries().isEmpty()) { m_queryEdit->setText(m_config.savedQueries().first().query); @@ -206,40 +192,77 @@ void MainWindow::buildUi() setWindowTitle(QStringLiteral("qtmaildir %1").arg(QTMAILDIR_VERSION)); } +QAction *MainWindow::addAction(const QString &name, const QString &text, + const QString &description, + const std::function &handler) +{ + auto *action = new QAction(text, this); + action->setObjectName(name); + action->setStatusTip(description); + m_actionDescriptions.insert(name, description); + + // The binding comes from KeyMap, so a [keys] override reaches the menus + // and the shortcut reference as well as the keyboard. + const QKeySequence sequence = m_keyMap.sequenceFor(name); + if (!sequence.isEmpty()) + action->setShortcut(sequence); + + // Shortcuts must work while focus is in the thread list or the message + // view, not only on the window itself. + action->setShortcutContext(Qt::WindowShortcut); + + connect(action, &QAction::triggered, this, handler); + + // Added to the window so the shortcut is live even before the action is + // put in a menu; the ones that never reach a menu depend on this. + QMainWindow::addAction(action); + m_actions.insert(name, action); + return action; +} + void MainWindow::registerActions() { - m_actions[QStringLiteral("focus_query")] = [this]() { + addAction(QStringLiteral("focus_query"), tr("&Find"), + tr("Focus and select the query bar"), [this]() { m_queryEdit->setFocus(); m_queryEdit->selectAll(); - }; - m_actions[QStringLiteral("next_thread")] = [this]() { + }); + addAction(QStringLiteral("next_thread"), tr("&Next thread"), + tr("Select the next thread"), [this]() { const QModelIndex current = m_threadView->currentIndex(); const int row = current.isValid() ? current.row() + 1 : 0; if (row < m_model->rowCount()) m_threadView->selectRow(row); - }; - m_actions[QStringLiteral("prev_thread")] = [this]() { + }); + addAction(QStringLiteral("prev_thread"), tr("&Previous thread"), + tr("Select the previous thread"), [this]() { const QModelIndex current = m_threadView->currentIndex(); if (current.isValid() && current.row() > 0) m_threadView->selectRow(current.row() - 1); - }; - m_actions[QStringLiteral("open_thread")] = [this]() { + }); + addAction(QStringLiteral("open_thread"), tr("&Open thread"), + tr("Focus the thread list"), [this]() { m_threadView->setFocus(); - }; - m_actions[QStringLiteral("archive")] = [this]() { + }); + addAction(QStringLiteral("archive"), tr("&Archive"), + tr("Remove inbox from every selected thread"), [this]() { tagSelected({}, { QStringLiteral("inbox") }, tr("Archive")); - }; - m_actions[QStringLiteral("delete")] = [this]() { + }); + addAction(QStringLiteral("delete"), tr("&Delete"), + tr("Add the deleted tag"), [this]() { tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete")); - }; - m_actions[QStringLiteral("spam")] = [this]() { + }); + addAction(QStringLiteral("spam"), tr("Mark &spam"), + tr("Add spam and remove inbox"), [this]() { tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") }, tr("Mark spam")); - }; - m_actions[QStringLiteral("flag")] = [this]() { + }); + addAction(QStringLiteral("flag"), tr("&Flag"), + tr("Add the flagged tag"), [this]() { tagSelected({ QStringLiteral("flagged") }, {}, tr("Flag")); - }; - m_actions[QStringLiteral("toggle_unread")] = [this]() { + }); + addAction(QStringLiteral("toggle_unread"), tr("Toggle &unread"), + tr("Toggle the unread tag"), [this]() { // The direction comes from the current row, but the change applies to // the whole selection, so a mixed selection lands in one consistent // state rather than each row flipping its own way. @@ -251,28 +274,123 @@ void MainWindow::registerActions() tagSelected({}, { QStringLiteral("unread") }, tr("Mark read")); else tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread")); - }; - m_actions[QStringLiteral("toggle_html")] = [this]() { + }); + addAction(QStringLiteral("toggle_html"), tr("Toggle &HTML"), + tr("Switch the thread between HTML and plain text"), [this]() { m_messageView->toggleHtml(); - }; - m_actions[QStringLiteral("load_remote")] = [this]() { + }); + addAction(QStringLiteral("load_remote"), tr("Load &remote content"), + tr("Load remote images for the current thread"), [this]() { m_messageView->loadRemoteContent(); - }; - m_actions[QStringLiteral("undo")] = [this]() { + }); + addAction(QStringLiteral("undo"), tr("&Undo"), + tr("Undo the last tag change"), [this]() { if (m_undoStack.canUndo()) m_undoStack.undo(); else m_statusLabel->setText(tr("Nothing to undo")); - }; - m_actions[QStringLiteral("sync")] = [this]() { + }); + addAction(QStringLiteral("sync"), tr("&Sync"), + tr("Run the configured sync command"), [this]() { if (m_sync->isAvailable()) m_sync->start(); - }; - m_actions[QStringLiteral("quit")] = [this]() { close(); }; + }); + addAction(QStringLiteral("quit"), tr("&Quit"), + tr("Quit qtmaildir"), [this]() { close(); }); - // The two lists are maintained by hand and a test pins them together; this - // catches the same drift in a debug run. - Q_ASSERT(m_actions.size() == registeredActionNames().size()); + // A binding the user wrote for an action that does not exist would be + // silently dead. KeyMap warns about unknown names, but only a check here + // catches the reverse: a known action nothing implements. + Q_ASSERT(m_actions.size() == KeyMap::knownActions().size()); +} + +void MainWindow::buildMenus() +{ + auto *fileMenu = menuBar()->addMenu(tr("&File")); + fileMenu->addAction(m_actions.value(QStringLiteral("sync"))); + fileMenu->addSeparator(); + fileMenu->addAction(m_actions.value(QStringLiteral("quit"))); + + auto *editMenu = menuBar()->addMenu(tr("&Edit")); + editMenu->addAction(m_actions.value(QStringLiteral("undo"))); + editMenu->addSeparator(); + editMenu->addAction(m_actions.value(QStringLiteral("focus_query"))); + + auto *messageMenu = menuBar()->addMenu(tr("&Message")); + messageMenu->addAction(m_actions.value(QStringLiteral("archive"))); + messageMenu->addAction(m_actions.value(QStringLiteral("delete"))); + messageMenu->addAction(m_actions.value(QStringLiteral("spam"))); + messageMenu->addSeparator(); + messageMenu->addAction(m_actions.value(QStringLiteral("toggle_unread"))); + messageMenu->addAction(m_actions.value(QStringLiteral("flag"))); + + auto *viewMenu = menuBar()->addMenu(tr("&View")); + viewMenu->addAction(m_actions.value(QStringLiteral("prev_thread"))); + viewMenu->addAction(m_actions.value(QStringLiteral("next_thread"))); + viewMenu->addSeparator(); + viewMenu->addAction(m_actions.value(QStringLiteral("toggle_html"))); + viewMenu->addAction(m_actions.value(QStringLiteral("load_remote"))); + + auto *helpMenu = menuBar()->addMenu(tr("&Help")); + auto *shortcuts = helpMenu->addAction(tr("&Keyboard shortcuts")); + connect(shortcuts, &QAction::triggered, + this, &MainWindow::showShortcutReference); + auto *about = helpMenu->addAction(tr("&About")); + connect(about, &QAction::triggered, this, &MainWindow::showAbout); + + // The frequent subset only. A toolbar holding every action is as + // unreadable as no toolbar. + auto *toolBar = addToolBar(tr("Main")); + toolBar->setObjectName(QStringLiteral("main_toolbar")); + toolBar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + toolBar->addAction(m_actions.value(QStringLiteral("sync"))); + toolBar->addSeparator(); + toolBar->addAction(m_actions.value(QStringLiteral("archive"))); + toolBar->addAction(m_actions.value(QStringLiteral("delete"))); + toolBar->addSeparator(); + toolBar->addAction(m_actions.value(QStringLiteral("undo"))); +} + +void MainWindow::showShortcutReference() +{ + // Generated from the actions, so it cannot disagree with what the keys + // really do. A hand-written list would drift the first time a binding + // changed. + QStringList rows; + for (const QString &name : registeredActionNames()) { + const QAction *action = m_actions.value(name); + if (!action) + continue; + const QString sequence = action->shortcut().toString(QKeySequence::NativeText); + rows.append(QStringLiteral("%1%2" + "%3") + .arg(sequence.isEmpty() ? tr("(unbound)") : sequence.toHtmlEscaped(), + m_actionDescriptions.value(name).toHtmlEscaped(), + name.toHtmlEscaped())); + } + + QMessageBox box(this); + box.setWindowTitle(tr("Keyboard shortcuts")); + box.setTextFormat(Qt::RichText); + box.setText(tr("

Keyboard shortcuts

" + "" + "" + "%1
KeyDoesAction name
" + "

Rebind any of these in the [keys] section of " + "qtmaildir.conf, using the action name.

") + .arg(rows.join(QString()))); + box.exec(); +} + +void MainWindow::showAbout() +{ + QMessageBox::about( + this, tr("About qtmaildir"), + tr("

qtmaildir %1

" + "

A Qt6 mail client for notmuch-indexed Maildirs.

" + "

Reads and organizes local mail. Fetching and sending are " + "handled by external scripts.

") + .arg(QStringLiteral(QTMAILDIR_VERSION))); } void MainWindow::wireWorker() @@ -511,23 +629,3 @@ void MainWindow::sendThreadTagChange(const QStringList &threadIds, Q_ARG(QString, description)); } -bool MainWindow::eventFilter(QObject *watched, QEvent *event) -{ - if (event->type() != QEvent::KeyPress) - return QMainWindow::eventFilter(watched, event); - - // The query bar must receive ordinary typing, so single-key bindings are - // suppressed while it has focus. - if (m_queryEdit->hasFocus()) - return QMainWindow::eventFilter(watched, event); - - auto *keyEvent = static_cast(event); - const QKeySequence sequence(keyEvent->keyCombination()); - - const QString action = m_keyMap.actionFor(sequence); - if (action.isEmpty() || !m_actions.contains(action)) - return QMainWindow::eventFilter(watched, event); - - m_actions.value(action)(); - return true; -} -- cgit v1.2.3 From 95d0d19a4c1323a4839c1544f3bc1c0323f85dd9 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 14:29:06 +0200 Subject: fix: keep the shortcut reference inside the screen Fourteen actions in one table made a dialog taller than the display, which pushed its own title bar off the top. The rows are split into two columns of seven, with the closing note spanning both. QMessageBox is replaced by a plain QDialog. The message box wraps its text at a narrow fixed width, which broke every description into a column of single words and was most of the height: 719x1084 before, 1426x366 after. --- src/mainwindow.cpp | 57 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 12 deletions(-) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index be80d8a..eaf98b8 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -20,6 +20,8 @@ #include #include +#include +#include #include #include #include @@ -362,24 +364,55 @@ void MainWindow::showShortcutReference() if (!action) continue; const QString sequence = action->shortcut().toString(QKeySequence::NativeText); - rows.append(QStringLiteral("%1%2" + rows.append(QStringLiteral("%1  " + "%2  " "%3") .arg(sequence.isEmpty() ? tr("(unbound)") : sequence.toHtmlEscaped(), m_actionDescriptions.value(name).toHtmlEscaped(), name.toHtmlEscaped())); } - QMessageBox box(this); - box.setWindowTitle(tr("Keyboard shortcuts")); - box.setTextFormat(Qt::RichText); - box.setText(tr("

Keyboard shortcuts

" - "" - "" - "%1
KeyDoesAction name
" - "

Rebind any of these in the [keys] section of " - "qtmaildir.conf, using the action name.

") - .arg(rows.join(QString()))); - box.exec(); + // Two columns rather than one. Fourteen actions in a single table made a + // dialog taller than the screen, which cut off its own title bar. + const int half = (rows.size() + 1) / 2; + const QString header = + tr("KeyDoes" + "Action name"); + const QString left = header + rows.mid(0, half).join(QString()); + const QString right = header + rows.mid(half).join(QString()); + + // A QDialog rather than QMessageBox: the message box wraps its text at a + // narrow default width, which turned every description into a column of + // single words and made the dialog taller than the screen. + QDialog dialog(this); + dialog.setWindowTitle(tr("Keyboard shortcuts")); + + auto *label = new QLabel(&dialog); + label->setTextFormat(Qt::RichText); + label->setText(tr("" + "" + "" + "" + "
%1
%2
") + .arg(left, right)); + + auto *note = new QLabel( + tr("Rebind any of these in the [keys] section of " + "qtmaildir.conf, using the action name."), + &dialog); + note->setTextFormat(Qt::RichText); + note->setWordWrap(true); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok, &dialog); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + + auto *layout = new QVBoxLayout(&dialog); + layout->addWidget(label); + layout->addWidget(note); + layout->addStretch(); + layout->addWidget(buttons); + + dialog.exec(); } void MainWindow::showAbout() -- 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 'src/mainwindow.cpp') 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 df9b3a47f125e65c5610c62a14894c8c7514f954 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 14:58:45 +0200 Subject: fix: let the thread list columns be resized again Tags, Date and From were set to ResizeToContents while fixing the pushed-off-screen Tags column. That mode computes the width itself and discards a drag, so the columns stopped being resizable. Verified: with ResizeToContents a request for 250px yields 150, with Stretch 478, with Interactive 250. The reorder alone already fixed the original bug, since Subject stretches and is last, so nothing can be pushed past it. Locking the other three was unnecessary and also blocked the saved-column-widths item, which needs widths a user can actually set. They are Interactive again, with starting widths a drag overrides. --- src/mainwindow.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 915c1ae..bebe379 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -169,16 +169,21 @@ 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. + // Subject is last and takes the leftover width, so no column can be + // pushed off the right edge. The other three stay Interactive: both + // ResizeToContents and Stretch ignore a drag, and the user resizes these. m_threadView->horizontalHeader()->setSectionResizeMode( ThreadListModel::SubjectColumn, QHeaderView::Stretch); for (int column : { ThreadListModel::TagsColumn, ThreadListModel::DateColumn, ThreadListModel::AuthorsColumn }) { m_threadView->horizontalHeader()->setSectionResizeMode( - column, QHeaderView::ResizeToContents); + column, QHeaderView::Interactive); } + // Starting widths only; a drag overrides them, and they are what the + // saved-widths item will persist. + m_threadView->setColumnWidth(ThreadListModel::TagsColumn, 160); + m_threadView->setColumnWidth(ThreadListModel::DateColumn, 130); + m_threadView->setColumnWidth(ThreadListModel::AuthorsColumn, 180); connect(m_threadView->selectionModel(), &QItemSelectionModel::currentRowChanged, -- cgit v1.2.3 From ab16342f6181f69e5cb1ef528f1dd0eb433bb74b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 15:04:28 +0200 Subject: feat: make every thread list column resizable Subject was Stretch, which computes its own width and discards a drag, so it alone could not be resized. Every column is Interactive now. Nothing absorbs spare width as a consequence, so the view scrolls horizontally instead of squeezing columns when their total exceeds the viewport. Per-pixel, so scrolling does not jump a column at a time. --- src/mainwindow.cpp | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) (limited to 'src/mainwindow.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index bebe379..ba690b4 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -169,21 +169,24 @@ void MainWindow::buildUi() m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection); m_threadView->verticalHeader()->hide(); m_threadView->horizontalHeader()->setStretchLastSection(false); - // Subject is last and takes the leftover width, so no column can be - // pushed off the right edge. The other three stay Interactive: both - // ResizeToContents and Stretch ignore a drag, and the user resizes these. - m_threadView->horizontalHeader()->setSectionResizeMode( - ThreadListModel::SubjectColumn, QHeaderView::Stretch); - for (int column : { ThreadListModel::TagsColumn, ThreadListModel::DateColumn, - ThreadListModel::AuthorsColumn }) { + // Every column Interactive, Subject included: Stretch and ResizeToContents + // both compute a width and discard the user's drag. Nothing absorbs spare + // width as a result, so the columns end where they end. + for (int column = 0; column < ThreadListModel::ColumnCount; ++column) { m_threadView->horizontalHeader()->setSectionResizeMode( column, QHeaderView::Interactive); } + // Widening a column past the viewport scrolls rather than squeezing the + // others. Per-pixel so the scroll does not jump a whole column at a time. + m_threadView->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_threadView->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); + // Starting widths only; a drag overrides them, and they are what the // saved-widths item will persist. m_threadView->setColumnWidth(ThreadListModel::TagsColumn, 160); m_threadView->setColumnWidth(ThreadListModel::DateColumn, 130); m_threadView->setColumnWidth(ThreadListModel::AuthorsColumn, 180); + m_threadView->setColumnWidth(ThreadListModel::SubjectColumn, 520); connect(m_threadView->selectionModel(), &QItemSelectionModel::currentRowChanged, -- cgit v1.2.3 From db7bbb81f02b52c2a1b299fe140abffab7e74642 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 15:41:33 +0200 Subject: feat: let an account set its chip colour and label Two optional keys in an [account.] stanza. color fills the chip, label sets its text. Both belong to the account rather than to [tagcolors] because an account tag is a different taxonomy: which mailbox a thread arrived in, not what state it is in. label is display only. "account-privateemail-danilo.macri" is a lot of row for one bit of information, but the notmuch tag is never renamed, so existing queries and external tagging are unaffected. Unset falls back to the account key, and an empty label is ignored rather than rendering a blank chip. --- src/config.cpp | 17 +++++++++++++++++ src/config.h | 10 ++++++++++ src/mainwindow.cpp | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/mainwindow.h | 2 ++ 4 files changed, 77 insertions(+), 2 deletions(-) (limited to 'src/mainwindow.cpp') diff --git a/src/config.cpp b/src/config.cpp index de6a783..f21bba9 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -89,6 +89,23 @@ void Config::load(const QString &path) account.address = settings.value(QStringLiteral("address")).toString(); account.maildir = settings.value(QStringLiteral("maildir")).toString(); account.drafts = settings.value(QStringLiteral("drafts")).toString(); + + // Both optional, and both describe this account's chip in the thread + // list. An account tag is a different taxonomy from a functional one, + // saying which mailbox a thread arrived in rather than what state it + // is in, so these live here rather than in [tagcolors]. + account.label = settings.value(QStringLiteral("label")).toString(); + + const QString colour = settings.value(QStringLiteral("color")).toString(); + if (!colour.isEmpty()) { + account.color = QColor(colour); + if (!account.color.isValid()) { + addProblem( + QStringLiteral("Account '%1' has an unparseable color '%2'; " + "using a generated one.") + .arg(account.key, colour)); + } + } settings.endGroup(); if (!account.isValid()) { diff --git a/src/config.h b/src/config.h index 270b780..943cbd8 100644 --- a/src/config.h +++ b/src/config.h @@ -18,6 +18,7 @@ #pragma once +#include #include #include #include @@ -33,6 +34,15 @@ struct Account QString maildir; ///< Relative to notmuch's database.path. QString drafts; ///< Unused in v1; send is v2. + /// Chip colour in the thread list. Invalid when unset, in which case one + /// is generated from the account tag's name. + QColor color; + + /// Text shown on the chip. Empty falls back to the key, which can be long: + /// "privateemail-danilo.macri" is a lot of row for one bit of information. + /// This renames nothing in notmuch, only what the chip displays. + QString label; + bool isValid() const { return !key.isEmpty() && !maildir.isEmpty(); } /// Restricts a notmuch query to this account's subtree. diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index ba690b4..48b475c 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -42,6 +42,7 @@ #include "messageview.h" #include "mimeparser.h" #include "notmuchworker.h" +#include "tagchip.h" #include "threadlistmodel.h" #include "version.h" @@ -73,6 +74,14 @@ MainWindow::MainWindow(const Config &config, QWidget *parent) { QSettings settings(Config::defaultPath(), QSettings::IniFormat); m_keyMap.loadOverrides(settings); + m_tagColors.load(settings); + } + + // An account's chip colour comes from its own stanza, since an account tag + // is a different taxonomy from a functional one. + for (const Account &account : m_config.accounts()) { + m_tagColors.setAccountColour(account.key, account.color); + m_tagColors.setAccountLabel(account.key, account.label); } buildUi(); @@ -163,6 +172,7 @@ void MainWindow::buildUi() // Thread list and message pane. m_model = new ThreadListModel(this); + m_model->setTagColors(&m_tagColors); m_threadView = new QTableView(central); m_threadView->setModel(m_model); m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows); @@ -176,6 +186,10 @@ void MainWindow::buildUi() m_threadView->horizontalHeader()->setSectionResizeMode( column, QHeaderView::Interactive); } + + // The subject cell carries the account chip in front of its text. + m_threadView->setItemDelegateForColumn(ThreadListModel::SubjectColumn, + new SubjectDelegate(this)); // Widening a column past the viewport scrolls rather than squeezing the // others. Per-pixel so the scroll does not jump a whole column at a time. m_threadView->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); @@ -183,7 +197,6 @@ void MainWindow::buildUi() // Starting widths only; a drag overrides them, and they are what the // saved-widths item will persist. - m_threadView->setColumnWidth(ThreadListModel::TagsColumn, 160); m_threadView->setColumnWidth(ThreadListModel::DateColumn, 130); m_threadView->setColumnWidth(ThreadListModel::AuthorsColumn, 180); m_threadView->setColumnWidth(ThreadListModel::SubjectColumn, 520); @@ -193,6 +206,7 @@ void MainWindow::buildUi() this, &MainWindow::onThreadSelected); m_messageView = new MessageView(central); + m_messageView->setTagColors(&m_tagColors); connect(m_messageView, &MessageView::statusMessage, this, [this](const QString &text) { m_statusLabel->setText(text); }); @@ -356,6 +370,28 @@ void MainWindow::buildMenus() auto *about = helpMenu->addAction(tr("&About")); connect(about, &QAction::triggered, this, &MainWindow::showAbout); + // Standard names from the icon theme, so the buttons match the rest of the + // desktop rather than shipping bespoke art. A theme that lacks one leaves + // that action with text alone, which still works. + const QHash themeIcons = { + { QStringLiteral("sync"), QStringLiteral("mail-receive") }, + { QStringLiteral("archive"), QStringLiteral("mail-mark-read") }, + { QStringLiteral("delete"), QStringLiteral("edit-delete") }, + { QStringLiteral("undo"), QStringLiteral("edit-undo") }, + { QStringLiteral("spam"), QStringLiteral("mail-mark-junk") }, + { QStringLiteral("flag"), QStringLiteral("mail-mark-important") }, + { QStringLiteral("quit"), QStringLiteral("application-exit") }, + { QStringLiteral("focus_query"), QStringLiteral("edit-find") }, + }; + for (auto it = themeIcons.cbegin(); it != themeIcons.cend(); ++it) { + QAction *action = m_actions.value(it.key()); + if (!action) + continue; + const QIcon icon = QIcon::fromTheme(it.value()); + if (!icon.isNull()) + action->setIcon(icon); + } + // The frequent subset only. A toolbar holding every action is as // unreadable as no toolbar. auto *toolBar = addToolBar(tr("Main")); @@ -543,7 +579,9 @@ void MainWindow::onThreadSelected(const QModelIndex ¤t, if (!current.isValid()) return; - m_currentThreadId = m_model->threadAt(current.row()).threadId; + const ThreadSummary thread = m_model->threadAt(current.row()); + m_currentThreadId = thread.threadId; + m_messageView->setTags(thread.tags); QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection, Q_ARG(QString, m_currentThreadId), Q_ARG(QString, m_lastQuery), @@ -665,6 +703,14 @@ void MainWindow::sendThreadTagChange(const QStringList &threadIds, for (const QString &threadId : threadIds) m_model->applyTagChange(threadId, add, remove); + // The strip shows the open thread's tags, so it has to follow a change to + // that thread rather than waiting for the next selection. + if (threadIds.contains(m_currentThreadId)) { + const QModelIndex current = m_threadView->currentIndex(); + if (current.isValid()) + m_messageView->setTags(m_model->threadAt(current.row()).tags); + } + m_pendingThreadIds = threadIds; m_pendingChange = TagChange{ {}, add, remove, description }; diff --git a/src/mainwindow.h b/src/mainwindow.h index 30a128a..4445894 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -28,6 +28,7 @@ #include "config.h" #include "keymap.h" +#include "tagcolors.h" #include "types.h" class QAction; @@ -103,6 +104,7 @@ private: Config m_config; KeyMap m_keyMap; + TagColors m_tagColors; QThread m_workerThread; NotmuchWorker *m_worker = nullptr; -- cgit v1.2.3