summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-16 21:58:18 +0200
committerDanilo M. <danix@danix.xyz>2026-08-16 21:58:18 +0200
commit019117aa8e52ce39cab58f77b57a9a67f510696f (patch)
treed96c71e0e43a777dcdbce05cb7f0e58f135139b1 /src
parentb405e3288bf625bd478204a065572e41a93fb4c3 (diff)
downloadqtmaildir-019117aa8e52ce39cab58f77b57a9a67f510696f.tar.gz
qtmaildir-019117aa8e52ce39cab58f77b57a9a67f510696f.zip
feat(ui): act on the message a row displays, not its whole thread
A thread's card has rendered one message since item 66, but every tag action still acted on the entire conversation. Delete, Archive, Important, Mark spam and Toggle unread now act on the message the card shows; the whole-thread versions move to a "Whole thread" submenu in the Message menu and the thread list's context menu, on Ctrl+Alt+<key>. Closes items 87, 88, 105, 106, 107, 108, 109, 110 and 111. The defects fixed along the way, several found by reading rather than by report: - threadAt(current.row()) answered about the wrong thread for a reply row, because a tree numbers rows per parent. The audit found four live sites, not the one reported: Delete and Toggle unread each chose their DIRECTION from an unrelated thread, and the tag dialog counted the wrong thread's tags. threadFor(index) replaces them. - A message-scoped write made no optimistic model update and no reply row carried a doomed cue, so acting on a reply moved the pending-edit count and changed nothing on screen. - Both toggles read the state of a reply's THREAD, which a message-scoped write never changes, so they were one-way: the second press re-sent a tag the message already had. - flushHeldEdits() re-sent only thread-scoped edits, so a tag change made on one message during a sync was applied to the row, counted as unsynced, and then dropped without ever being written. - applyTagChange() updated a thread's summary but not its loaded replies, leaving an expanded thread's rows describing a state the database no longer held. - A thread's first message is not among its children, so both message-scoped lookups missed it: acting on a root card repainted nothing and emptied the message pane's chip row. - ThreadSummary::tags is notmuch's union over the thread, so a card standing for one message drew tags belonging to its siblings. The worker now reads that message's own tags in the walk that already finds its id, so the split is known before a row is ever opened. The card shows both tiers: its own message's tags at full size, the rest of the conversation's smaller and muted, so nothing appears to vanish when a row is selected. Auto mark-read is message-scoped as a result, and now arms for a reply, which it never did. With maildir.synchronize_flags on, the old thread-wide write reached the server for mail that had never been displayed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/carddelegate.cpp85
-rw-r--r--src/carddelegate.h18
-rw-r--r--src/cardlayout.cpp26
-rw-r--r--src/cardlayout.h13
-rw-r--r--src/keymap.cpp29
-rw-r--r--src/mainwindow.cpp460
-rw-r--r--src/mainwindow.h101
-rw-r--r--src/notmuchworker.cpp6
-rw-r--r--src/tagchip.cpp14
-rw-r--r--src/tagchip.h13
-rw-r--r--src/threadlistmodel.cpp383
-rw-r--r--src/threadlistmodel.h120
-rw-r--r--src/types.h23
13 files changed, 1154 insertions, 137 deletions
diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp
index d7d06aa..4e27e6e 100644
--- a/src/carddelegate.cpp
+++ b/src/carddelegate.cpp
@@ -20,6 +20,7 @@
#include "cardlayout.h"
#include "marks.h"
+#include "tagchip.h"
#include "threadlistmodel.h"
#include <QApplication>
@@ -31,6 +32,12 @@
namespace {
+/// How much of a full-size chip's padding a SIBLING chip keeps.
+///
+/// Matched to CardLayout::siblingFont()'s own scale, so the chip shrinks as a
+/// whole rather than keeping full-size margins around smaller letters.
+constexpr qreal kSiblingPaddingScale = 0.70;
+
CardLayout::Input inputFor(const QModelIndex &index)
{
CardLayout::Input in;
@@ -58,6 +65,39 @@ QRect CardDelegate::expanderRectFor(const QStyleOptionViewItem &option,
.expanderRect;
}
+QSize CardDelegate::chipSize(const QFontMetrics &metrics, const QString &text,
+ bool own)
+{
+ // The padding shrinks with the font for a sibling chip. Left fixed it is
+ // 18px around roughly 30px of text, so the chip stays wide while its
+ // letters shrink and the tier reads as "same chip, smaller text".
+ return own ? TagChip::sizeFor(metrics, text)
+ : TagChip::sizeFor(metrics, text, kSiblingPaddingScale);
+}
+
+QColor CardDelegate::mutedChipColour(const QColor &chipColour)
+{
+ if (!chipColour.isValid())
+ return chipColour;
+
+ // Saturation only, and NOT a blend toward the background. The accent bar
+ // above records what blending toward Base costs: on a dark theme it lands
+ // on the background and the thing disappears. A chip is worse, because its
+ // fill also has to carry legible text on top of it.
+ //
+ // Hue is untouched, so a muted `signed` is still recognisably the same
+ // colour as a full-size `signed` elsewhere in the list. Lightness is
+ // untouched too, which is what keeps TagColors::textColourOn() picking the
+ // same text colour: draining saturation alone moves the fill toward grey
+ // without moving it toward either black or white, so contrast is preserved
+ // by construction rather than by hoping.
+ constexpr float kSaturationScale = 0.45f;
+
+ float h = 0, s = 0, l = 0, a = 0;
+ chipColour.getHslF(&h, &s, &l, &a);
+ return QColor::fromHslF(h, s * kSaturationScale, l, a);
+}
+
QColor CardDelegate::accentLineColour(const QColor &accountColour)
{
if (!accountColour.isValid())
@@ -304,20 +344,47 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
: ThreadListModel::PillColoursRole)
.toList();
- const QFont chipFont = CardLayout::smallFont(chrome.font);
- const QFontMetrics chipMetrics(chipFont);
+ // A thread card draws its own tags at full size and the rest of the
+ // conversation's smaller and muted (item 111). The count is where the two
+ // tiers meet; a message row has no such split and reports its whole list.
+ //
+ // Shown rather than dropped, at the user's request: a card sits above a
+ // conversation, so what its siblings carry is worth seeing, just not at
+ // the same weight. Before the row has been opened everything is in the own
+ // tier, so a chip SHRINKS when the split becomes known and none vanishes.
+ const int ownCount =
+ isMessage ? tags.size()
+ : index.data(ThreadListModel::PillOwnCountRole).toInt();
+
+ const QFont ownFont = CardLayout::smallFont(chrome.font);
+ const QFont siblingFont = CardLayout::siblingFont(chrome.font);
+ const QFontMetrics ownMetrics(ownFont);
+ const QFontMetrics siblingMetrics(siblingFont);
+
painter->save();
- painter->setFont(chipFont);
int x = card.tagRect.left();
for (int i = 0; i < tags.size(); ++i) {
- const QSize size = TagChip::sizeFor(chipMetrics, tags.at(i));
+ const bool own = i < ownCount;
+ const QFontMetrics &metrics = own ? ownMetrics : siblingMetrics;
+
+ const QSize size = chipSize(metrics, tags.at(i), own);
if (x + size.width() > card.tagRect.right())
break; // Out of room; a clipped chip reads as a rendering fault.
- const QColor colour = i < colours.size()
- ? colours.at(i).value<QColor>()
- : QColor(0x55, 0x55, 0x5f);
- TagChip::paint(painter, QRect(QPoint(x, card.tagRect.top()), size),
- tags.at(i), colour);
+
+ QColor colour = i < colours.size() ? colours.at(i).value<QColor>()
+ : QColor(0x55, 0x55, 0x5f);
+ if (!own)
+ colour = mutedChipColour(colour);
+
+ // Bottom-aligned, so a smaller chip sits on the same baseline as its
+ // neighbours rather than floating in the middle of the row. Top
+ // alignment would step the tier down and read as a layout fault.
+ const int top = card.tagRect.top()
+ + (ownMetrics.height() - metrics.height());
+
+ painter->setFont(own ? ownFont : siblingFont);
+ TagChip::paint(painter, QRect(QPoint(x, top), size), tags.at(i),
+ colour);
x += size.width() + TagChip::kSpacing;
}
painter->restore();
diff --git a/src/carddelegate.h b/src/carddelegate.h
index 74dee8e..1846359 100644
--- a/src/carddelegate.h
+++ b/src/carddelegate.h
@@ -76,4 +76,22 @@ public:
///
/// Falls back to threadLineColour() for a thread with no account tag.
static QColor accentLineColour(const QColor &accountColour);
+
+ /// A tag chip's colour, drained for the SIBLING tier (item 111).
+ ///
+ /// Saturation only: hue stays so the tag is still recognisable, and
+ /// lightness stays so `TagColors::textColourOn()` keeps choosing the same
+ /// text colour and the chip cannot become unreadable. Exposed for a test,
+ /// since "muted" has to be asserted on rather than eyeballed.
+ static QColor mutedChipColour(const QColor &chipColour);
+
+ /// The size of one tag chip on a card, for either tier.
+ ///
+ /// The delegate's own arithmetic rather than a duplicate of it: a test
+ /// calling `TagChip::sizeFor` directly proves what that function does and
+ /// nothing about what the delegate ASKS for, which is where the padding
+ /// scale is chosen. A mutation dropping the scale at the call site
+ /// survived exactly that kind of test.
+ static QSize chipSize(const QFontMetrics &metrics, const QString &text,
+ bool own);
};
diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp
index 0febb4b..3719591 100644
--- a/src/cardlayout.cpp
+++ b/src/cardlayout.cpp
@@ -107,6 +107,32 @@ QFont CardLayout::smallFont(const QFont &cardFont)
return small;
}
+QFont CardLayout::siblingFont(const QFont &cardFont)
+{
+ // A FRACTION of the card's font, not a fixed number of points off it.
+ //
+ // Subtracting one point was the first attempt and the user reported the
+ // tiers as indistinguishable. The reason is arithmetic: their desktop is
+ // 14pt, so the two chip tiers were 13 and 12, a 7% step. Subtraction gives
+ // a step whose size depends on the desktop font, which is exactly backwards
+ // — it is largest where the text is already small enough to be fragile.
+ //
+ // 0.70 of the card font, against smallFont()'s one point off, so on a 14pt
+ // desktop the tiers are 13 and 10. Chosen with the user against rendered
+ // sizes rather than picked.
+ constexpr qreal kSiblingScale = 0.70;
+
+ QFont small = cardFont;
+ // pointSizeF() returns -1 for a font set in PIXELS, which qt6ct does, and
+ // scaling -1 asks for an invalid size that Qt silently ignores, leaving
+ // both tiers identical. Same split as smallFont(), same reason.
+ if (small.pointSizeF() > 0.0)
+ small.setPointSizeF(qMax(6.0, cardFont.pointSizeF() * kSiblingScale));
+ else if (small.pixelSize() > 0)
+ small.setPixelSize(qMax(8, qRound(cardFont.pixelSize() * kSiblingScale)));
+ return small;
+}
+
int CardLayout::heightFor(const QFont &font)
{
const QFontMetrics metrics(font);
diff --git a/src/cardlayout.h b/src/cardlayout.h
index 3512242..de4c41f 100644
--- a/src/cardlayout.h
+++ b/src/cardlayout.h
@@ -160,6 +160,19 @@ struct CardLayout
/// column of content.
static QFont smallFont(const QFont &cardFont);
+ /// The font a SIBLING's tag chip is drawn in: a size down again from
+ /// smallFont().
+ ///
+ /// A card stands for one message but sits above a conversation, and shows
+ /// both tiers (item 111). Size is what says which is which, so the two
+ /// must be visibly different; taking one more step from the same base
+ /// keeps it following the desktop's font rather than being fixed.
+ ///
+ /// Floored like smallFont(), and the floor really is reachable: a desktop
+ /// at the minimum size gives both tiers the same size, which is a legible
+ /// degradation rather than an illegible chip.
+ static QFont siblingFont(const QFont &cardFont);
+
static CardLayout compute(const Input &input, const QRect &rect,
const QFont &font);
diff --git a/src/keymap.cpp b/src/keymap.cpp
index f6ef6a4..c731bbb 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -37,6 +37,21 @@ QStringList KeyMap::knownActions()
QStringLiteral("edit_tags"),
QStringLiteral("tag_rules"),
QStringLiteral("flag"),
+ // The whole-thread counterparts (item 108). The names above act on the
+ // message a row displays; these act on its entire thread. Separate
+ // names rather than a scope flag, because a name is what a user writes
+ // in [keys]: giving `delete` new semantics would silently change an
+ // existing config, and renaming it would break one that mentions it.
+ //
+ // Unbound by default. They are reached through the "Whole thread"
+ // submenu, and inventing five more default chords for actions most
+ // users will rarely want is worse than leaving them to bind what they
+ // use.
+ QStringLiteral("archive_thread"),
+ QStringLiteral("delete_thread"),
+ QStringLiteral("spam_thread"),
+ QStringLiteral("toggle_unread_thread"),
+ QStringLiteral("flag_thread"),
QStringLiteral("focus_query"),
QStringLiteral("complete_query"),
QStringLiteral("save_query"),
@@ -90,6 +105,20 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
// action takes the harder chord rather than the easier one.
{ QStringLiteral("Ctrl+Shift+U"), QStringLiteral("mark_all_read") },
{ QStringLiteral("Ctrl+I"), QStringLiteral("flag") },
+ // The whole-thread tier (item 108), one modifier out from each
+ // message-scoped twin: Ctrl+D deletes the message a row displays,
+ // Ctrl+Alt+D deletes its conversation.
+ //
+ // Ctrl+ALT, not Ctrl+Shift. The obvious pairing is taken twice over:
+ // Ctrl+Shift+S is `spam` and Ctrl+Shift+U is `mark_all_read`, both
+ // shipped and both in users' fingers. Reusing either would silently
+ // change what an existing key does, which is the same objection that
+ // made these separate action names rather than a flag on the old ones.
+ { QStringLiteral("Ctrl+Alt+E"), QStringLiteral("archive_thread") },
+ { QStringLiteral("Ctrl+Alt+D"), QStringLiteral("delete_thread") },
+ { QStringLiteral("Ctrl+Alt+S"), QStringLiteral("spam_thread") },
+ { QStringLiteral("Ctrl+Alt+U"), QStringLiteral("toggle_unread_thread") },
+ { QStringLiteral("Ctrl+Alt+I"), QStringLiteral("flag_thread") },
{ QStringLiteral("Ctrl+T"), QStringLiteral("edit_tags") },
// Shifted against Ctrl+T for the same reason Ctrl+Shift+U is shifted
// against Ctrl+U: this is the standing version of tagging, applied to
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index fcf97df..ee883b0 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -832,15 +832,14 @@ void MainWindow::registerActions()
// independently would leave one keystroke with the selection in two
// states, which is worse than either outcome, so undelete only when
// every selected thread is already deleted.
- const QModelIndexList rows =
- m_threadView->selectionModel()->selectedRows();
- bool allDeleted = !rows.isEmpty();
- for (const QModelIndex &index : rows) {
- if (!m_model->threadAt(index.row()).isDeleted()) {
- allDeleted = false;
- break;
- }
- }
+ //
+ // Each row's own state, message or thread: a reply row is asked about
+ // the MESSAGE it stands for. Asking its thread made Delete one-way on
+ // a reply, since a message-scoped write never changes the thread's
+ // tags and the answer therefore stayed "not deleted" however many
+ // times it was pressed. Item 88 fixed which thread was read here; this
+ // is about reading a message at all.
+ const bool allDeleted = everySelectedRowHasTag(QStringLiteral("deleted"));
if (allDeleted)
tagSelected({}, { QStringLiteral("deleted") }, tr("Undelete"));
@@ -866,21 +865,26 @@ void MainWindow::registerActions()
});
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.
- const QModelIndex current = m_threadView->currentIndex();
- if (!current.isValid())
- return;
- const ThreadSummary thread = m_model->threadAt(current.row());
+ // The state of whatever the rows STAND FOR, which for a reply is the
+ // message and not its thread. See everySelectedRowHasTag(): reading
+ // the thread here made the key dead on a reply.
+ //
+ // Item 88 fixed WHICH thread this read. That was necessary and not
+ // sufficient: a reply needs a message read, not a better thread.
+ //
+ // Per selection rather than per current row, matching Delete. The old
+ // comment said the direction came from the current row while the
+ // change applied to the whole selection, which is the same split that
+ // makes a mixed selection land in two states.
+ const bool unread = everySelectedRowHasTag(QStringLiteral("unread"));
// An explicit toggle overrides the automatic one. Without this, marking
// a thread unread by hand would be undone a moment later by a timer
// armed when it was opened, and the key would look broken.
m_markReadTimer->stop();
- m_markReadThreadId.clear();
+ m_markReadMessageId.clear();
- if (thread.isUnread())
+ if (unread)
tagSelected({}, { QStringLiteral("unread") }, tr("Mark read"));
else
tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread"));
@@ -894,6 +898,58 @@ void MainWindow::registerActions()
tr("Add or remove any tag on the selected threads"), [this]() {
editTagsOnSelection();
});
+
+ // The whole-thread counterparts (item 108). Separate action NAMES, because
+ // a name is what a user writes in [keys]: reusing `delete` with new
+ // semantics would silently change what an existing config does, and
+ // renaming it would break one that mentions it. These are unbound by
+ // default; the submenu is how they are reached.
+ //
+ // Each one is its message-scoped twin with TagScope::Thread, so the two
+ // cannot drift in what they write, only in what they write it to.
+ addAction(QStringLiteral("archive_thread"), tr("&Archive thread"),
+ tr("Remove inbox from every message of the selected threads"),
+ [this]() {
+ tagSelected({}, { QStringLiteral("inbox") }, tr("Archive thread"),
+ TagScope::Thread);
+ });
+ addAction(QStringLiteral("delete_thread"), tr("&Delete thread"),
+ tr("Add or remove the deleted tag on whole threads"), [this]() {
+ if (everySelectedRowHasTag(QStringLiteral("deleted"), TagScope::Thread)) {
+ tagSelected({}, { QStringLiteral("deleted") },
+ tr("Undelete thread"), TagScope::Thread);
+ } else {
+ tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete thread"),
+ TagScope::Thread);
+ }
+ });
+ addAction(QStringLiteral("spam_thread"), tr("Mark thread as &spam"),
+ tr("Add spam and remove inbox on whole threads"), [this]() {
+ tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") },
+ tr("Mark thread spam"), TagScope::Thread);
+ });
+ addAction(QStringLiteral("toggle_unread_thread"), tr("Toggle &unread"),
+ tr("Toggle the unread tag on whole threads"), [this]() {
+ // Cancels the automatic mark-read for the same reason its
+ // message-scoped twin does: a thread marked unread by hand must not be
+ // undone a moment later by a timer armed when it was opened.
+ m_markReadTimer->stop();
+ m_markReadMessageId.clear();
+
+ if (everySelectedRowHasTag(QStringLiteral("unread"), TagScope::Thread)) {
+ tagSelected({}, { QStringLiteral("unread") },
+ tr("Mark thread read"), TagScope::Thread);
+ } else {
+ tagSelected({ QStringLiteral("unread") }, {},
+ tr("Mark thread unread"), TagScope::Thread);
+ }
+ });
+ addAction(QStringLiteral("flag_thread"), tr("&Important"),
+ tr("Mark every message of the selected threads as important"),
+ [this]() {
+ tagSelected({ QStringLiteral("flagged") }, {},
+ tr("Mark thread important"), TagScope::Thread);
+ });
addAction(QStringLiteral("tag_rules"), tr("Tagging &rules..."),
tr("Edit the rules that tag mail as it arrives"), [this]() {
showTagRulesDialog();
@@ -975,7 +1031,7 @@ void MainWindow::registerActions()
m_messageView->clear();
showPlaceholderPane();
m_markReadTimer->stop();
- m_markReadThreadId.clear();
+ m_markReadMessageId.clear();
});
addAction(QStringLiteral("clear_selection"), tr("Clear &selection"),
tr("Blank the message pane and deselect every thread"),
@@ -1010,7 +1066,7 @@ void MainWindow::registerActions()
m_messageView->clear();
showPlaceholderPane();
m_markReadTimer->stop();
- m_markReadThreadId.clear();
+ m_markReadMessageId.clear();
});
addAction(QStringLiteral("select_all"), tr("Select &all threads"),
tr("Select every thread in the current result list"), [this]() {
@@ -1061,6 +1117,8 @@ void MainWindow::buildMenus()
messageMenu->addAction(m_actions.value(QStringLiteral("mark_all_read")));
messageMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
messageMenu->addAction(m_actions.value(QStringLiteral("flag")));
+ messageMenu->addSeparator();
+ messageMenu->addMenu(buildThreadActionsMenu(messageMenu));
// Separated from the entries above: those act on the selection, this edits
// a rule store shared with mailctl and changes nothing that is on screen.
messageMenu->addSeparator();
@@ -1141,6 +1199,20 @@ void MainWindow::buildMenus()
{ QStringLiteral("zoom_in"), QStringLiteral("zoom-in") },
{ QStringLiteral("zoom_out"), QStringLiteral("zoom-out") },
{ QStringLiteral("zoom_reset"), QStringLiteral("zoom-original") },
+
+ // The whole-thread tier (item 108) deliberately SHARES each icon with
+ // its message-scoped twin. The no-duplicates rule exists because the
+ // toolbar can be icon-only, where the icon is the entire control;
+ // these five never reach the toolbar. They live in a submenu whose
+ // entries always carry text, and "Delete thread" beside the delete
+ // icon is the honest pairing: the same operation, a wider scope, with
+ // the words saying which. Inventing five different shapes for the same
+ // five operations would be less clear, not more.
+ { QStringLiteral("archive_thread"), QStringLiteral("mail-archive") },
+ { QStringLiteral("delete_thread"), QStringLiteral("edit-delete") },
+ { QStringLiteral("spam_thread"), QStringLiteral("mail-mark-junk") },
+ { QStringLiteral("toggle_unread_thread"), QStringLiteral("mail-mark-unread") },
+ { QStringLiteral("flag_thread"), QStringLiteral("mail-mark-important") },
};
for (auto it = themeIcons.cbegin(); it != themeIcons.cend(); ++it) {
QAction *action = m_actions.value(it.key());
@@ -1168,6 +1240,8 @@ void MainWindow::buildMenus()
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("flag")));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
m_threadContextMenu->addSeparator();
+ m_threadContextMenu->addMenu(buildThreadActionsMenu(m_threadContextMenu));
+ m_threadContextMenu->addSeparator();
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("select_all")));
m_threadView->setContextMenuPolicy(Qt::CustomContextMenu);
@@ -2384,7 +2458,7 @@ void MainWindow::markAllRead()
// An automatic mark-read armed for the open thread would fire after this
// and push a second, redundant command onto the stack.
m_markReadTimer->stop();
- m_markReadThreadId.clear();
+ m_markReadMessageId.clear();
const QString description = tr("Mark all read");
sendThreadTagChange(threadIds, {}, { QStringLiteral("unread") },
@@ -2448,16 +2522,16 @@ void MainWindow::onSelectionChanged()
// Qt 6.11), so that handler still sees the old count and returns
// without loading anything.
//
- // Compared per row kind. A message row's row number indexes its
- // siblings, so threadAt() on one answers about an unrelated thread and
- // the comparison below would be against the wrong id.
+ // Compared per row kind: a message row is identified by its message id
+ // and a thread row by its thread id, which are different questions.
+ // threadFor() resolves the thread either way, so the row-number trap
+ // (item 88) cannot be re-entered here even if this branch changes.
const QModelIndex current = m_threadView->currentIndex();
if (current.isValid()) {
const bool changed =
m_model->isMessageRow(current)
? m_model->messageAt(current).messageId != m_currentMessageId
- : m_model->threadAt(current.row()).threadId
- != m_currentThreadId;
+ : m_model->threadFor(current).threadId != m_currentThreadId;
if (changed)
onThreadSelected(current, QModelIndex());
}
@@ -2507,7 +2581,7 @@ void MainWindow::onSelectionChanged()
// current, so onThreadSelected never runs and its guard never fires. The
// pane and the pending timer have to be dealt with here as well.
m_markReadTimer->stop();
- m_markReadThreadId.clear();
+ m_markReadMessageId.clear();
m_currentThreadId.clear();
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
@@ -2563,7 +2637,7 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
// pane that no longer shows the thread.
if (m_threadView->selectionModel()->selectedRows().size() > 1) {
m_markReadTimer->stop();
- m_markReadThreadId.clear();
+ m_markReadMessageId.clear();
m_currentThreadId.clear();
m_currentMessageId.clear();
m_currentMessageThreadId.clear();
@@ -2572,21 +2646,22 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
return;
}
- // A message row renders that message ALONE. Checked before threadAt(),
- // which takes a top-level row number: a child's row number indexes its
- // siblings, so passing it here would silently load whichever thread happens
- // to sit at that position in the list.
+ // A message row renders that message ALONE, so the kind of row still has
+ // to be checked here: this is a different render path, not a different way
+ // of naming the same thread.
if (m_model->isMessageRow(current)) {
const MessageNode node = m_model->messageAt(current);
if (node.messageId.isEmpty())
return;
- // No mark-read timer for a message row in this pass. Marking one
- // message of a thread read is a per-message tag write, and the
- // pending-edit map is keyed by thread; item 28 is the record of what
- // happens when that count goes wrong.
+ // Armed for a reply too, since item 87. It deliberately was not
+ // before, because the write was thread-scoped and reading one reply
+ // would have marked the whole conversation read. With the write scoped
+ // to one message that objection is gone, and leaving it unarmed would
+ // make the message the user is actually reading the one kind that
+ // never gets marked read.
m_markReadTimer->stop();
- m_markReadThreadId.clear();
+ m_markReadMessageId.clear();
m_currentThreadId.clear();
m_currentMessageId = node.messageId;
@@ -2594,16 +2669,27 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
// thread it came from is what the refreshed list is checked against.
m_currentMessageThreadId = node.threadId;
m_messageView->setTags(node.tags);
+
+ // After m_currentMessageId is set: the handler compares against it to
+ // tell "still showing this" from "the selection moved on".
+ scheduleMarkRead(node.messageId, node.isUnread());
+
QMetaObject::invokeMethod(m_worker, "loadMessage", Qt::QueuedConnection,
Q_ARG(QString, node.messageId),
Q_ARG(quint64, m_generation));
return;
}
- const ThreadSummary thread = m_model->threadAt(current.row());
+ const ThreadSummary thread = m_model->threadFor(current);
m_currentThreadId = thread.threadId;
m_messageView->setTags(thread.tags);
- scheduleMarkRead(thread);
+
+ // The message the card displays, not the thread. The summary's `unread` is
+ // a union over the conversation, so this can arm for a thread whose first
+ // message is already read; the write is scoped to that message either way,
+ // so the cost is a no-op rather than a wrong write. Narrowing it properly
+ // needs per-message state in ThreadSummary, which nothing carries yet.
+ scheduleMarkRead(thread.firstMessageId, thread.isUnread());
// The root card IS the thread's first message, so selecting it renders
// that message. Never the whole conversation: that path is gone (item 66).
@@ -2654,6 +2740,25 @@ void MainWindow::onMessageLoaded(const QVector<MessageRef> &messages,
if (m_currentMessageId.isEmpty())
return;
+ // The worker's answer is the authority on what THIS message carries, and
+ // it is the only place that truth arrives. Until it does, a thread row can
+ // only offer ThreadSummary::tags, which is notmuch's union over the
+ // conversation: a four-message thread whose third message is signed makes
+ // the root card and the pane both claim `signed` for a message that is not
+ // (item 110). Recording it here corrects the card and gives a
+ // message-scoped write something to update, which is why marking a root
+ // message read left the row bold before.
+ //
+ // A reply already has its own node from the thread tree, and
+ // setRootMessageTags ignores anything that is not a root.
+ for (const MessageRef &ref : messages)
+ m_model->setRootMessageTags(ref.messageId, ref.tags);
+
+ // The pane follows the same correction. setTags() at selection time can
+ // only have used the union.
+ if (messages.size() == 1)
+ m_messageView->setTags(messages.first().tags);
+
renderMessages(messages);
}
@@ -2741,7 +2846,12 @@ void MainWindow::renderMessages(const QVector<MessageRef> &messages)
void MainWindow::revertPendingTagChange()
{
- if (m_pendingThreadIds.isEmpty())
+ // Either scope can be in flight: a thread-scoped write names threads, a
+ // message-scoped one names messages, and both are now applied
+ // optimistically. Checking only the thread ids left a failed message write
+ // showing its optimistic state for good, with nothing to correct it until
+ // the next query.
+ if (m_pendingThreadIds.isEmpty() && m_pendingChange.messageIds.isEmpty())
return;
// Put the rows back the way they were. Only the model is touched: the
@@ -2750,6 +2860,10 @@ void MainWindow::revertPendingTagChange()
m_model->applyTagChange(threadId, m_pendingChange.removed,
m_pendingChange.added);
}
+ for (const QString &messageId : m_pendingChange.messageIds) {
+ m_model->applyMessageTagChange(messageId, m_pendingChange.removed,
+ m_pendingChange.added);
+ }
// The undo entry describes a change that never landed, so it would apply a
// spurious inverse if the user pressed undo.
@@ -2805,18 +2919,37 @@ void MainWindow::flushHeldEdits()
m_flushGeneration = m_generation;
for (const HeldEdit &edit : edits) {
- // Take the optimistic update back before sending, because
- // sendThreadTagChange() applies it again. applyTagChange() is
- // idempotent per tag so the rows do not visibly flicker; without this
- // the change is applied twice and a later revert undoes only one of
- // them, leaving a row showing a tag the database never got.
+ // Take the optimistic update back before sending, because the send
+ // applies it again. Both apply functions are idempotent per tag so the
+ // rows do not visibly flicker; without this the change is applied
+ // twice and a later revert undoes only one of them, leaving a row
+ // showing a tag the database never got.
for (const QString &threadId : edit.threadIds) {
m_model->applyTagChange(threadId, edit.change.removed,
edit.change.added);
}
+ for (const QString &messageId : edit.change.messageIds) {
+ m_model->applyMessageTagChange(messageId, edit.change.removed,
+ edit.change.added);
+ }
- sendThreadTagChange(edit.threadIds, edit.change.added,
- edit.change.removed, edit.change.description);
+ // By SCOPE. A held edit is one or the other, never both: a
+ // message-scoped edit carries no thread ids, so sending it through
+ // sendThreadTagChange() sent an empty list, which returns immediately.
+ // The edit was applied to the row, counted as unsynced and then
+ // dropped without ever being written, which is data loss with a
+ // pending count claiming the opposite.
+ //
+ // Escalating it to its thread instead would be worse: Delete on one
+ // reply would delete every message in the conversation.
+ if (!edit.threadIds.isEmpty()) {
+ sendThreadTagChange(edit.threadIds, edit.change.added,
+ edit.change.removed, edit.change.description);
+ }
+ if (!edit.change.messageIds.isEmpty()) {
+ sendMessageTagChange(edit.change.messageIds, edit.change.added,
+ edit.change.removed, edit.change.description);
+ }
}
// Held edits stop counting as held; what counts now is whatever
@@ -3149,7 +3282,7 @@ void MainWindow::onRowDoubleClicked(const QModelIndex &index)
// the recovery selects. What is cancelled is the arming for a row the user
// is leaving.
m_markReadTimer->stop();
- m_markReadThreadId.clear();
+ m_markReadMessageId.clear();
// Reuses the stale-thread recovery outright, which already runs thread:<id>,
// expands the thread when the row arrives, selects the target message once
@@ -3604,27 +3737,32 @@ void MainWindow::updatePendingIndicator()
m_pendingLabel->show();
}
-void MainWindow::scheduleMarkRead(const ThreadSummary &thread)
+void MainWindow::scheduleMarkRead(const QString &messageId, bool unread)
{
- // Any pending timer belongs to a thread that is no longer on screen.
+ // Any pending timer belongs to a message that is no longer on screen.
// Stopping unconditionally is what makes this a restart rather than a
- // stack: arrowing down ten threads must mark only the one still selected
+ // stack: arrowing down ten rows must mark only the one still selected
// when the timer finally fires.
m_markReadTimer->stop();
- m_markReadThreadId.clear();
+ m_markReadMessageId.clear();
// Negative disables the behaviour entirely, per the config key.
const int delay = m_config.markReadDelayMs();
if (delay < 0)
return;
- // Nothing to do for a thread that is already read. Checked here rather
- // than in the handler so no timer is even armed, which keeps a read thread
- // from arming one that would fire into a no-op write.
- if (!thread.tags.contains(QStringLiteral("unread")))
+ // A row the model cannot name a message for. Marking its thread instead
+ // would be the escalation item 108 removed.
+ if (messageId.isEmpty())
+ return;
+
+ // Nothing to do for a message that is already read. Checked here rather
+ // than in the handler so no timer is even armed, which keeps a read
+ // message from arming one that would fire into a no-op write.
+ if (!unread)
return;
- m_markReadThreadId = thread.threadId;
+ m_markReadMessageId = messageId;
// Zero means immediately, and a zero-interval timer still fires through
// the event loop rather than reentering the selection handler.
@@ -3633,62 +3771,167 @@ void MainWindow::scheduleMarkRead(const ThreadSummary &thread)
void MainWindow::markCurrentThreadRead()
{
- if (m_markReadThreadId.isEmpty())
+ if (m_markReadMessageId.isEmpty())
return;
// The selection can have moved on between the timer being armed and it
- // firing, and the thread can have been marked read by hand in that window.
- // Both mean this timer has nothing left to do.
- if (m_markReadThreadId != m_currentThreadId) {
- m_markReadThreadId.clear();
- return;
- }
-
- const QModelIndex current = m_threadView->currentIndex();
- if (!current.isValid()) {
- m_markReadThreadId.clear();
- return;
- }
-
- const ThreadSummary thread = m_model->threadAt(current.row());
- if (thread.threadId != m_markReadThreadId
- || !thread.tags.contains(QStringLiteral("unread"))) {
- m_markReadThreadId.clear();
+ // firing, and the message can have been marked read by hand in that
+ // window. Both mean this timer has nothing left to do.
+ //
+ // Compared against what the PANE is showing rather than against the
+ // selection: those are the same thing for both kinds of row, and the pane
+ // is what "the message the user is reading" means.
+ const QString showing = m_currentMessageId.isEmpty()
+ ? currentThreadFirstMessageId()
+ : m_currentMessageId;
+ if (m_markReadMessageId != showing) {
+ m_markReadMessageId.clear();
return;
}
- const QStringList threadIds = { m_markReadThreadId };
- m_markReadThreadId.clear();
+ const QStringList messageIds = { m_markReadMessageId };
+ m_markReadMessageId.clear();
- // sendThreadTagChange, NOT tagSelected: this deliberately does not go on
+ // sendMessageTagChange, NOT tagSelected: this deliberately does not go on
// the undo stack. The user never took this action, so hijacking Ctrl+Z to
// reverse it would undo something they did not do, and toggle_unread
// already gives them a direct way to put it back. Decided 2026-08-03.
//
+ // MESSAGE-scoped since item 87. The thread-wide write was coherent while a
+ // root card rendered the whole conversation; item 66 made it render one
+ // message and left the write alone, so reading one message marked replies
+ // read that had never been displayed. maildir.synchronize_flags is on, so
+ // that reached the server and nothing here could put it back.
+ //
// It still funnels through the one applyTags path, per CLAUDE.md; what
// differs is only whether the inverse is pushed, which is a window-level
// decision above the worker.
- sendThreadTagChange(threadIds, {}, { QStringLiteral("unread") },
- tr("Mark read"));
+ sendMessageTagChange(messageIds, {}, { QStringLiteral("unread") },
+ tr("Mark read"));
}
-void MainWindow::editTagsOnSelection()
+QString MainWindow::currentThreadFirstMessageId() const
+{
+ // The message a selected THREAD row displays. m_currentThreadId is what
+ // the pane was opened from, so this resolves through the model rather than
+ // through the selection, which can have moved.
+ if (m_currentThreadId.isEmpty())
+ return {};
+
+ for (int row = 0; row < m_model->rowCount(QModelIndex()); ++row) {
+ const ThreadSummary thread = m_model->threadAt(row);
+ if (thread.threadId == m_currentThreadId)
+ return thread.firstMessageId;
+ }
+ return {};
+}
+
+bool MainWindow::everySelectedRowHasTag(const QString &tag,
+ TagScope scope) const
{
+ // What a toggle asks before choosing its direction, for both Delete and
+ // Toggle unread.
+ //
+ // Per ROW, and each row is asked about what it stands for: a reply row
+ // reports the message's tags, a thread row the thread's. Asking a reply's
+ // THREAD is the trap both toggles fell into. The write is message-scoped,
+ // so it never changes the thread's tags; the thread's answer therefore
+ // never moves however many times the key is pressed, and the toggle
+ // becomes one-way. On the second press it re-sends a tag the message
+ // already has, which is a no-op, and a no-op repaints nothing.
+ //
+ // One direction for the WHOLE selection, which is the rule Delete
+ // established: toggling each row independently would leave one keystroke
+ // with the selection in two states, which is worse than either outcome.
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
- if (rows.isEmpty()) {
- showTransientStatus(tr("Select a thread first"));
- return;
+ if (rows.isEmpty())
+ return false;
+
+ for (const QModelIndex &index : rows) {
+ QStringList tags;
+ if (scope == TagScope::Thread) {
+ tags = m_model->threadFor(index).tags;
+ } else if (m_model->isMessageRow(index)) {
+ tags = m_model->messageAt(index).tags;
+ } else {
+ // The thread's summary, and this is a KNOWN approximation rather
+ // than an oversight. A thread row acts on the message its card
+ // displays, but that message's own tags are never in the model:
+ // setThreadMessages drops depth 0 because the root row stands for
+ // it, so there is no node to read and messageById() cannot find
+ // one. The summary is a union over the thread, so it answers
+ // "unread" while ANY message is.
+ //
+ // The consequence is bounded and only affects the DIRECTION a
+ // toggle picks, never what it writes: on a thread whose first
+ // message is read while a later one is not, Toggle unread reads
+ // the thread as unread and marks the first message read again, a
+ // no-op. Fixing it properly needs per-message state in
+ // ThreadSummary, which is the same thing item 87 needs; leave it
+ // for that item rather than guessing here.
+ tags = m_model->threadFor(index).tags;
+ }
+ if (!tags.contains(tag))
+ return false;
}
+ return true;
+}
+
+ThreadSummary MainWindow::threadForCurrentRowForTesting() const
+{
+ return m_model->threadFor(m_threadView->currentIndex());
+}
- // How many of the selected threads carry each tag, which is what tells a
- // tag that is on all of them from one that is on some.
+QMenu *MainWindow::buildThreadActionsMenu(QWidget *parent)
+{
+ // Built per call rather than shared. A QMenu belongs to one place in one
+ // menu tree, and adding the same instance to both the menu bar and the
+ // context menu gives whichever added it last the object. The ACTIONS are
+ // shared, which is what has to stay consistent; the menu holding them is
+ // just a container.
+ auto *menu = new QMenu(tr("&Whole thread"), parent);
+ menu->setObjectName(QStringLiteral("threadActionsMenu"));
+ menu->addAction(m_actions.value(QStringLiteral("archive_thread")));
+ menu->addAction(m_actions.value(QStringLiteral("delete_thread")));
+ menu->addAction(m_actions.value(QStringLiteral("spam_thread")));
+ menu->addSeparator();
+ menu->addAction(m_actions.value(QStringLiteral("toggle_unread_thread")));
+ menu->addAction(m_actions.value(QStringLiteral("flag_thread")));
+ return menu;
+}
+
+QHash<QString, int> MainWindow::selectionTagCounts() const
+{
+ // How many of the selected rows carry each tag, which is what tells a tag
+ // that is on all of them from one that is on some. The dialog's tri-state
+ // checkboxes are built from this, so a wrong count offers to remove a tag
+ // the selection does not have.
+ //
+ // threadFor(index), NOT threadAt(index.row()): a reply's row number named
+ // an unrelated thread, so selecting one counted the tags of whichever
+ // thread sat at that position in the list (item 88).
QHash<QString, int> counts;
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
for (const QModelIndex &index : rows) {
- const ThreadSummary thread = m_model->threadAt(index.row());
+ const ThreadSummary thread = m_model->threadFor(index);
for (const QString &tag : thread.tags)
counts[tag] += 1;
}
+ return counts;
+}
+
+void MainWindow::editTagsOnSelection()
+{
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ if (rows.isEmpty()) {
+ showTransientStatus(tr("Select a thread first"));
+ return;
+ }
+
+ const QHash<QString, int> counts = selectionTagCounts();
// m_knownTags is the same list the query completer uses, so the dialog
// offers every tag in the database without a round trip.
@@ -3708,7 +3951,7 @@ void MainWindow::editTagsOnSelection()
}
void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
- const QString &description)
+ const QString &description, TagScope tagScope)
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
@@ -3719,7 +3962,13 @@ void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
// A message row's row number indexes its siblings, so the old
// threadAt(index.row()) mapping silently acted on whichever thread sat at
// that position in the list.
- const ActionScope scope = m_model->scopeFor(rows);
+ //
+ // Message scope by default since item 108: a thread row displays one
+ // message, so acting on it acts on that message. Thread scope is what the
+ // "Whole thread" actions ask for explicitly.
+ const ActionScope scope = tagScope == TagScope::Thread
+ ? m_model->scopeFor(rows)
+ : m_model->messageScopeFor(rows);
if (scope.isEmpty())
return;
@@ -3758,10 +4007,33 @@ void MainWindow::sendMessageTagChange(const QStringList &messageIds,
if (messageIds.isEmpty())
return;
- // No optimistic model update. applyTagChange is keyed by THREAD and would
- // repaint the whole row as though every message in it had changed, which
- // for a one-message edit is a lie the user would see and then watch
- // silently correct itself on the next query.
+ // Optimistically applied to each MESSAGE's own row. applyTagChange is
+ // keyed by thread and would repaint the whole card as though every message
+ // in it had changed, which for a one-message edit is a lie; that is why
+ // this path had no optimistic update at all, and the cost was that Delete
+ // and Toggle unread on a reply moved the pending count and changed nothing
+ // the user could see. The reply's own row is where the feedback belongs.
+ for (const QString &messageId : messageIds)
+ m_model->applyMessageTagChange(messageId, add, remove);
+
+ // The strip shows the tags of the message ON DISPLAY, so it has to follow
+ // an edit to that message rather than waiting for the next selection. The
+ // thread path has carried this since the strip existed; without it here, a
+ // message-scoped edit repainted the list row and left the pane's chips
+ // describing the message as it was, until the user selected away and back.
+ //
+ // Keyed on m_currentMessageId, which is set only for a message row, so a
+ // write to some other reply cannot repaint the open one with its tags.
+ //
+ // Read by ID, not from currentIndex(): the two agree today, and a guard
+ // that depends on them agreeing would put the WRONG message's tags in the
+ // pane on the day they do not. The id is what the pane is actually
+ // showing.
+ if (!m_currentMessageId.isEmpty()
+ && messageIds.contains(m_currentMessageId)) {
+ m_messageView->setTags(
+ m_model->messageById(m_currentMessageId).tags);
+ }
// The accounts this touches, resolved through the containing threads: the
// account is a property of the thread, and the sync needs the channel
@@ -3821,7 +4093,7 @@ void MainWindow::sendThreadTagChange(const QStringList &threadIds,
if (threadIds.contains(m_currentThreadId)) {
const QModelIndex current = m_threadView->currentIndex();
if (current.isValid())
- m_messageView->setTags(m_model->threadAt(current.row()).tags);
+ m_messageView->setTags(m_model->threadFor(current).tags);
}
// A sync holds notmuch's exclusive write lock, and the worker's read-write
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 614430f..e741416 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -167,6 +167,37 @@ public:
/// command was pushed, which is what "this did nothing" has to assert.
int undoDepthForTesting() const { return m_undoStack.count(); }
+ /// The text of the command on top of the undo stack.
+ ///
+ /// A test seam for the DIRECTION a toggle chose. Delete and Undelete both
+ /// push one command and touch the same rows, so a depth or an id says
+ /// nothing about which way the toggle went, which is exactly what item 88
+ /// got wrong.
+ QString undoTextForTesting() const { return m_undoStack.undoText(); }
+
+ /// The tag counts the tag dialog would be built from, for the current
+ /// selection. A test seam: the dialog is modal, so the counts cannot be
+ /// observed through it.
+ QHash<QString, int> selectionTagCountsForTesting() const
+ {
+ return selectionTagCounts();
+ }
+
+ /// The thread the current row belongs to. A test seam for item 88's
+ /// resolution itself, reachable when the write it guards is not.
+ ThreadSummary threadForCurrentRowForTesting() const;
+
+ /// Sends a message-scoped tag change directly. A test seam for the cases
+ /// where driving the action would move the selection, which is sometimes
+ /// the very thing under test.
+ void sendMessageTagChangeForTesting(const QStringList &messageIds,
+ const QStringList &add,
+ const QStringList &remove,
+ const QString &description)
+ {
+ sendMessageTagChange(messageIds, add, remove, description);
+ }
+
/// The ids the last tag change was sent for, and whether they were thread
/// ids or message ids.
///
@@ -180,6 +211,11 @@ public:
return m_pendingChange.messageIds;
}
+ /// The whole change last sent, for tests about WHAT was written rather
+ /// than what it was written to. The tags are the same under either scope,
+ /// so a test about a tag name should read this instead of a model row.
+ TagChange pendingChangeForTesting() const { return m_pendingChange; }
+
/// The generation a worker reply must carry to be accepted.
///
/// A test seam: onQueryFinished() discards a reply whose generation is
@@ -563,13 +599,32 @@ private:
const QString &description,
const std::function<void()> &handler);
+ /// What a tag action acts on.
+ ///
+ /// Since item 108 a thread ROW means the one message its card displays, so
+ /// Message is the default and Thread is the explicit choice the user makes
+ /// through the "Whole thread" submenu. Before that there was no choice:
+ /// a thread row always meant the conversation.
+ enum class TagScope {
+ Message, ///< The message each selected row displays.
+ Thread, ///< Every message of each selected row's thread.
+ };
+
void tagSelected(const QStringList &add, const QStringList &remove,
- const QString &description);
+ const QString &description,
+ TagScope scope = TagScope::Message);
/// Starts, restarts or cancels the mark-read timer for a newly opened
- /// thread. Cancels outright for a thread that is not unread, so an already
- /// read thread never schedules a write that would change nothing.
- void scheduleMarkRead(const ThreadSummary &thread);
+ /// MESSAGE. Cancels outright for one that is not unread, so an already read
+ /// message never schedules a write that would change nothing.
+ ///
+ /// Takes the id and the state separately because the two come from
+ /// different places: a reply row has a MessageNode, and a thread row has
+ /// only its summary, whose `unread` is a union over the conversation.
+ void scheduleMarkRead(const QString &messageId, bool unread);
+
+ /// The message id of the thread the pane was opened from, or empty.
+ QString currentThreadFirstMessageId() const;
/// Removes `unread` from the thread the timer was armed for, if it is still
/// the one on screen.
@@ -631,6 +686,29 @@ private:
///
/// The only route to an arbitrary tag: every other tag action writes a
/// hardcoded name.
+ /// The "Whole thread" submenu, built fresh for each parent that needs one.
+ ///
+ /// A QMenu lives in one menu tree, so the menu bar and the context menu get
+ /// their own instance. The actions inside are shared, which is what has to
+ /// stay consistent between them.
+ QMenu *buildThreadActionsMenu(QWidget *parent);
+
+ /// Per-tag counts across the selected rows, for the tag dialog.
+ QHash<QString, int> selectionTagCounts() const;
+
+ /// True when every selected row already carries \p tag, which is what a
+ /// toggle asks before choosing its direction.
+ ///
+ /// Under Message scope each row answers about what it STANDS FOR: a reply
+ /// row about its message, a thread row about the message its card
+ /// displays. Asking a reply's thread makes a toggle one-way, since the
+ /// message-scoped write never changes the thread's tags.
+ ///
+ /// Under Thread scope a row answers about its whole thread, so the
+ /// question matches the write the thread actions are about to make.
+ bool everySelectedRowHasTag(const QString &tag,
+ TagScope scope = TagScope::Message) const;
+
void editTagsOnSelection();
/// Set once the user has answered the exit prompt, or once a sync started
@@ -981,10 +1059,17 @@ private:
/// fires, not each one passed through.
QTimer *m_markReadTimer = nullptr;
- /// The thread m_markReadTimer will mark read. Compared against the current
- /// selection when it fires, so a timer that outlives its thread does
- /// nothing rather than marking the wrong one.
- QString m_markReadThreadId;
+ /// The MESSAGE m_markReadTimer will mark read. Compared against what the
+ /// pane is showing when it fires, so a timer that outlives its message
+ /// does nothing rather than marking the wrong one.
+ ///
+ /// A message id, not a thread id, since item 87. The timer used to mark
+ /// the whole thread, which was coherent while a root card rendered the
+ /// whole conversation and stopped being so when item 66 made it render
+ /// one message: reading one message marked replies read that had never
+ /// been displayed, and with maildir.synchronize_flags on that reaches the
+ /// server.
+ QString m_markReadMessageId;
/// Debounces the automatic sync that follows a tag edit (item 71).
///
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index a3a2fd5..b152830 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -314,6 +314,9 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
if (matched) {
summary.firstMessageId = QString::fromUtf8(
notmuch_message_get_message_id(message));
+ // The card's own tags, beside the thread's union above.
+ // Same walk, same index read, no extra query.
+ summary.firstMessageTags = tagsOf(message);
break;
}
}
@@ -323,6 +326,9 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
if (notmuch_message_t *first = notmuch_messages_get(top)) {
summary.firstMessageId = QString::fromUtf8(
notmuch_message_get_message_id(first));
+ // The card's own tags, beside the thread's union above.
+ // Same walk, same index read, no extra query.
+ summary.firstMessageTags = tagsOf(first);
}
}
}
diff --git a/src/tagchip.cpp b/src/tagchip.cpp
index d770a56..ec83641 100644
--- a/src/tagchip.cpp
+++ b/src/tagchip.cpp
@@ -29,8 +29,18 @@ namespace TagChip {
QSize sizeFor(const QFontMetrics &metrics, const QString &text)
{
- return QSize(metrics.horizontalAdvance(text) + kPaddingX * 2,
- metrics.height() + kPaddingY * 2);
+ return sizeFor(metrics, text, 1.0);
+}
+
+QSize sizeFor(const QFontMetrics &metrics, const QString &text, qreal scale)
+{
+ // Floored at 2 a side: the corner radius is half the chip's height, so the
+ // leftmost and rightmost pixels of the fill are curve rather than usable
+ // width, and text set flush against it touches the round end.
+ const int padX = qMax(2, qRound(kPaddingX * scale));
+ const int padY = qMax(0, qRound(kPaddingY * scale));
+ return QSize(metrics.horizontalAdvance(text) + padX * 2,
+ metrics.height() + padY * 2);
}
void paint(QPainter *painter, const QRect &rect, const QString &text,
diff --git a/src/tagchip.h b/src/tagchip.h
index 5514cae..68e1e55 100644
--- a/src/tagchip.h
+++ b/src/tagchip.h
@@ -43,6 +43,19 @@ constexpr int kSpacing = 4;
QSize sizeFor(const QFontMetrics &metrics, const QString &text);
+/// The same, with the padding scaled by \p scale.
+///
+/// The padding is a fixed pixel count, which is right for one chip size and
+/// wrong the moment there are two: at 1.0 it is 18px around roughly 30px of
+/// text on a sibling chip, so the chip stays wide while its text shrinks and
+/// the tier reads as "same size, smaller letters" rather than as a smaller
+/// chip. Scaling it with the font is what makes the second tier actually look
+/// smaller (item 111).
+///
+/// Floored at 2px a side, because the corner radius is half the height and a
+/// chip with no horizontal padding has its text touching the curve.
+QSize sizeFor(const QFontMetrics &metrics, const QString &text, qreal scale);
+
/// Paints the chip into `rect`, using `text` and `background`. The text colour
/// is derived from the fill so it stays legible.
void paint(QPainter *painter, const QRect &rect, const QString &text,
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 3e079ed..6ddd85c 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -347,6 +347,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
case DateFormatRole:
return m_dateFormat;
case Qt::BackgroundRole:
+ // Doomed first: a reply tagged deleted or spam is on its way out
+ // and the user has to see that the moment they act, exactly as a
+ // thread row does. Without this branch a message-scoped Delete
+ // repainted a reply identically to an undeleted one, so the
+ // pending count moved and nothing on screen did.
+ if (node.isDoomed())
+ return QBrush(node.isDeleted() ? deletedColour()
+ : spamColour());
+
// Tinted, so an expanded thread reads as one block rather than as
// more table rows. Applied per cell here; ThreadListView fills the
// same colour across the strip's band so the row does not end up
@@ -354,18 +363,47 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return replyBackground();
case Qt::FontRole: {
// A size down from the thread rows, so a thread reads as the
- // heading and its replies as the contents. Never bold: an unread
- // reply is still subordinate to the thread it belongs to, and the
- // thread row above already carries the unread cue for the whole
- // conversation.
+ // heading and its replies as the contents. The size is what keeps
+ // a reply subordinate; bold on top of it is the unread cue, at the
+ // user's request on 2026-08-16.
+ //
+ // Replies were unbolded deliberately at first, on the reasoning
+ // that the thread row above already says the conversation has
+ // unread mail. That is true of the THREAD and useless for the
+ // reply: once a thread is expanded, the row telling the user which
+ // messages in it are unread is the only one that can, and dimming
+ // alone left the user unable to see a read/unread change at all.
QFont font = QGuiApplication::font();
if (font.pointSize() > 0)
font.setPointSize(qMax(6, font.pointSize() - 1));
else if (font.pixelSize() > 0)
font.setPixelSize(qMax(8, font.pixelSize() - 2));
+
+ // Bold combines with the dimming rather than replacing it: two
+ // cues for one state, which is what the thread row has had since
+ // 2026-08-07 and for the same reason. If the desktop's own font is
+ // configured Bold, setBold() changes nothing and the dimming is
+ // the whole cue, which CLAUDE.md records as a real configuration
+ // on this user's machine.
+ if (node.isUnread())
+ font.setBold(true);
+
+ // Struck through when doomed, for the same reason the thread row
+ // is: the state then survives a screenshot, a colourblind reader,
+ // and a theme that overrides the background. A reply had neither
+ // this nor the fill, so a message-scoped Delete was invisible.
+ if (node.isDoomed())
+ font.setStrikeOut(true);
return font;
}
case Qt::ForegroundRole:
+ // White over the doomed fill, matching the thread row. The dimmed
+ // read colour is mixed toward the BACKGROUND, so leaving it here
+ // would compute a grey against the pane's base and then paint it
+ // over red.
+ if (node.isDoomed())
+ return QBrush(QColor(Qt::white));
+
// Dimmed whether read or not, for the same reason as the font: a
// reply is subordinate content. An unread one is left undimmed so
// it can still be found.
@@ -378,7 +416,26 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
if (index.row() >= m_threads.size())
return {};
- const ThreadSummary &thread = m_threads.at(index.row()).summary;
+ const ThreadNode &rowNode = m_threads.at(index.row());
+
+ // A card stands for ONE message since item 108, so it must draw that
+ // message's tags and not the thread's. `ThreadSummary::tags` is notmuch's
+ // UNION over the conversation: a four-message thread whose third message
+ // is signed reads as signed, and the card said so about a message that was
+ // not (item 110).
+ //
+ // Only the tags are substituted. Everything else on the card, the subject,
+ // the authors, the date and the reply count, describes the THREAD and is
+ // correct as it stands; only the tags were ever the union that lied.
+ //
+ // `first.tags` is populated when the message is loaded, which is when the
+ // user selects the row. Before that the union is the only answer available
+ // and is what the card shows, which is why an unopened row can still
+ // display a sibling's mark. Narrowing that further needs per-message state
+ // in the query itself.
+ ThreadSummary thread = rowNode.summary;
+ if (!rowNode.first.messageId.isEmpty())
+ thread.tags = rowNode.first.tags;
if (role == ThreadIdRole)
return thread.threadId;
@@ -424,7 +481,8 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
if (role == MessageOwnColoursRole)
return QVariantList();
- if (role == PillTagsRole || role == PillColoursRole) {
+ if (role == PillTagsRole || role == PillColoursRole
+ || role == PillOwnCountRole) {
// Everything the row already says another way is dropped: the account
// is the chip in the subject cell, flagged is the star column,
// attachment is the paperclip, unread is the row not being dimmed, and
@@ -443,16 +501,43 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
QStringLiteral("unread"),
};
- QStringList pills;
- for (const QString &tag : thread.tags) {
- if (hidden.contains(tag) || isDrawnAsAMark(tag)
- || TagColors::isAccountTag(tag))
- continue;
- pills.append(tag);
+ const auto pillsFrom = [&](const QStringList &tags) {
+ QStringList pills;
+ for (const QString &tag : tags) {
+ if (hidden.contains(tag) || isDrawnAsAMark(tag)
+ || TagColors::isAccountTag(tag))
+ continue;
+ pills.append(tag);
+ }
+ // Sorted rather than in notmuch's order, which is not guaranteed
+ // stable: a row whose pills reordered between repaints would
+ // flicker.
+ pills.sort();
+ return pills;
+ };
+
+ // `thread.tags` is the displayed message's own tags once the row has
+ // been opened, and the thread's union before that (see the
+ // substitution above). The union is always the full set, so the
+ // difference is what belongs only to siblings.
+ QStringList pills = pillsFrom(thread.tags);
+ const int ownCount = pills.size();
+
+ // The sibling tier, appended after the message's own. Shown rather
+ // than dropped at the user's request: a card sits above a
+ // conversation, so what the rest of it carries is worth seeing, just
+ // not at the same weight. The delegate draws these smaller and muted.
+ //
+ // Empty until the row has been opened, because before that
+ // `thread.tags` IS the union and the difference is nothing. That is
+ // what makes a chip shrink rather than appear.
+ for (const QString &tag : pillsFrom(rowNode.summary.tags)) {
+ if (!pills.contains(tag))
+ pills.append(tag);
}
- // Sorted rather than in notmuch's order, which is not guaranteed
- // stable: a row whose pills reordered between repaints would flicker.
- pills.sort();
+
+ if (role == PillOwnCountRole)
+ return ownCount;
if (role == PillTagsRole)
return pills;
@@ -598,6 +683,23 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return {};
}
+ThreadListModel::ThreadNode
+ThreadListModel::nodeFor(const ThreadSummary &summary)
+{
+ ThreadNode node{ summary, {}, {}, false };
+
+ // Only when the query actually supplied them. An empty list here would be
+ // indistinguishable from "this message carries nothing", which would put
+ // every chip in the sibling tier and mute the whole card.
+ if (!summary.firstMessageId.isEmpty()
+ && !summary.firstMessageTags.isEmpty()) {
+ node.first.messageId = summary.firstMessageId;
+ node.first.threadId = summary.threadId;
+ node.first.tags = summary.firstMessageTags;
+ }
+ return node;
+}
+
void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch)
{
// beginInsertRows with an empty range violates Qt's contract, so the guard
@@ -608,7 +710,7 @@ void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch)
const int first = m_threads.size();
beginInsertRows({}, first, first + batch.size() - 1);
for (const ThreadSummary &summary : batch)
- m_threads.append(ThreadNode{ summary, {}, {}, false });
+ m_threads.append(nodeFor(summary));
endInsertRows();
}
@@ -657,7 +759,7 @@ void ThreadListModel::reconcile(const QVector<ThreadSummary> &threads)
if (it == present.constEnd()) {
const int at = qMin(target, m_threads.size());
beginInsertRows({}, at, at);
- m_threads.insert(at, ThreadNode{ summary, {}, {}, false });
+ m_threads.insert(at, nodeFor(summary));
endInsertRows();
// Every later row shifted by one, and the map is read again on the
@@ -707,8 +809,26 @@ void ThreadListModel::reconcile(const QVector<ThreadSummary> &threads)
|| m_threads.at(row).summary.authors != summary.authors
|| m_threads.at(row).summary.date != summary.date
|| m_threads.at(row).summary.totalCount != summary.totalCount
- || m_threads.at(row).summary.matchedCount != summary.matchedCount) {
+ || m_threads.at(row).summary.matchedCount != summary.matchedCount
+ // The card's OWN message, which can move while the thread's union
+ // does not: a root read elsewhere leaves the thread unread as long
+ // as any reply is. Without this the card kept the tags it was
+ // first given, and the sibling tier with them.
+ || m_threads.at(row).summary.firstMessageTags
+ != summary.firstMessageTags) {
m_threads[row].summary = summary;
+
+ // The node too, since the card draws its tags from there. Only the
+ // tags: the node's children and loaded flag are the expansion
+ // state this whole method exists to preserve, and `first` carries
+ // no children.
+ if (!summary.firstMessageId.isEmpty()
+ && !summary.firstMessageTags.isEmpty()) {
+ m_threads[row].first.messageId = summary.firstMessageId;
+ m_threads[row].first.threadId = summary.threadId;
+ m_threads[row].first.tags = summary.firstMessageTags;
+ }
+
emit dataChanged(index(row, 0), index(row, 0));
}
}
@@ -804,6 +924,108 @@ QString ThreadListModel::threadIdForMessage(const QString &messageId) const
return {};
}
+void ThreadListModel::setRootMessageTags(const QString &messageId,
+ const QStringList &tags)
+{
+ if (messageId.isEmpty())
+ return;
+
+ for (int row = 0; row < m_threads.size(); ++row) {
+ ThreadNode &node = m_threads[row];
+ if (node.summary.firstMessageId != messageId
+ && node.first.messageId != messageId) {
+ continue;
+ }
+
+ if (node.first.tags == tags && !node.first.messageId.isEmpty())
+ return; // Nothing changed; do not churn the view.
+
+ // Enough of a node for the card to draw from. The rest of the display
+ // still comes from the summary, which is correct for it: the subject,
+ // the authors and the date describe the thread, and only the TAGS were
+ // ever the union that lied about this message.
+ node.first.messageId = messageId;
+ node.first.threadId = node.summary.threadId;
+ node.first.tags = tags;
+
+ const QModelIndex threadIndex = index(row, 0, QModelIndex());
+ emit dataChanged(threadIndex, threadIndex);
+ return;
+ }
+}
+
+MessageNode ThreadListModel::messageById(const QString &messageId) const
+{
+ if (messageId.isEmpty())
+ return {};
+
+ for (const ThreadNode &node : m_threads) {
+ // The root's own message first, and it is not among the children:
+ // setThreadMessages drops depth 0 because the root row stands for it.
+ // Searching only the children returned a default-constructed node for
+ // every root message, and a caller that trusted it set the message
+ // pane's tag strip to that empty tag list, wiping a strip that had
+ // been correct.
+ if (!node.first.messageId.isEmpty()
+ && node.first.messageId == messageId) {
+ return node.first;
+ }
+
+ // Before expansion there is no node, so the answer is assembled from
+ // the summary: for a thread of one, its tags ARE this message's, since
+ // a thread's tags are a union over its messages. For a longer thread
+ // they are a union over messages this one is only part of, which is
+ // wider than the truth but is also exactly what the card shows, so a
+ // caller repainting from it stays consistent with the row beside it.
+ if (node.summary.firstMessageId == messageId) {
+ MessageNode root;
+ root.messageId = node.summary.firstMessageId;
+ root.threadId = node.summary.threadId;
+ root.subject = node.summary.subject;
+ root.date = node.summary.date;
+ root.tags = node.summary.tags;
+ return root;
+ }
+
+ for (const MessageNode &child : node.children) {
+ if (child.messageId == messageId)
+ return child;
+ }
+ }
+ return {};
+}
+
+ActionScope ThreadListModel::messageScopeFor(
+ const QModelIndexList &selection) const
+{
+ ActionScope scope;
+
+ for (const QModelIndex &index : selection) {
+ QString messageId;
+ if (isMessageRow(index)) {
+ messageId = messageAt(index).messageId;
+ } else {
+ if (index.row() < 0 || index.row() >= m_threads.size())
+ continue;
+ // The message the CARD displays, which the query already named.
+ // Not the loaded children: a thread the user never expanded still
+ // shows its first message, and this must work without one.
+ messageId = m_threads.at(index.row()).summary.firstMessageId;
+ }
+
+ // Skipped rather than widened. Falling back to the thread here would
+ // silently act on messages the row does not display, which is the
+ // behaviour item 108 removed.
+ if (messageId.isEmpty() || scope.messageIds.contains(messageId))
+ continue;
+
+ scope.messageIds.append(messageId);
+ scope.messageCount += 1;
+ }
+
+ return scope;
+}
+
ActionScope ThreadListModel::scopeFor(const QModelIndexList &selection) const
{
ActionScope scope;
@@ -847,6 +1069,19 @@ ThreadSummary ThreadListModel::threadAt(int row) const
return m_threads.at(row).summary;
}
+ThreadSummary ThreadListModel::threadFor(const QModelIndex &index) const
+{
+ if (!index.isValid())
+ return {};
+
+ // The parent's row for a message, its own for a thread. Both are top-level
+ // numbers by the time threadAt() sees them, which is the whole point: the
+ // conversion happens once, here, instead of at every call site that has to
+ // remember which kind of row it is holding.
+ const QModelIndex threadIndex = isMessageRow(index) ? index.parent() : index;
+ return threadAt(threadIndex.row());
+}
+
QStringList ThreadListModel::accountKeysForThread(const QString &threadId) const
{
QStringList keys;
@@ -884,7 +1119,117 @@ void ThreadListModel::applyTagChange(const QString &threadId,
// The whole card repaints: unread state drives its font, and the tags
// it draws on line 3 have just changed.
- emit dataChanged(index(row, 0), index(row, 0));
+ const QModelIndex threadIndex = index(row, 0);
+ emit dataChanged(threadIndex, threadIndex);
+
+ // And every LOADED reply, because a thread-scoped write reaches every
+ // message in the thread. Updating only the summary left an expanded
+ // thread showing replies that still carried the old tags: marking a
+ // thread read repainted the card and left its replies bold and
+ // undimmed, describing a state the database no longer held. They
+ // corrected themselves on the next query, which is what made it look
+ // like a repaint bug rather than a stale model.
+ //
+ // Only the loaded ones exist to update. An unexpanded thread has no
+ // child rows, and the replies it does not hold are the database's
+ // business, not this model's.
+ QVector<MessageNode> &children = m_threads[row].children;
+ if (children.isEmpty())
+ return;
+
+ for (MessageNode &child : children) {
+ for (const QString &tag : removed)
+ child.tags.removeAll(tag);
+ for (const QString &tag : added) {
+ if (!child.tags.contains(tag))
+ child.tags.append(tag);
+ }
+ }
+
+ // One span for the whole expansion rather than a signal per reply: the
+ // rows are contiguous under this parent and a view coalesces them
+ // anyway.
+ emit dataChanged(index(0, 0, threadIndex),
+ index(children.size() - 1, 0, threadIndex));
+ return;
+ }
+}
+
+void ThreadListModel::applyMessageTagChange(const QString &messageId,
+ const QStringList &added,
+ const QStringList &removed)
+{
+ if (messageId.isEmpty())
return;
+
+ const auto retag = [&](QStringList &tags) {
+ for (const QString &tag : removed)
+ tags.removeAll(tag);
+ for (const QString &tag : added) {
+ if (!tags.contains(tag))
+ tags.append(tag);
+ }
+ };
+
+ for (int row = 0; row < m_threads.size(); ++row) {
+ ThreadNode &node = m_threads[row];
+ const QModelIndex threadIndex = index(row, 0, QModelIndex());
+
+ // The ROOT card's own message, which is not among the children:
+ // setThreadMessages drops depth 0 because the root row stands for it.
+ // Searching only the children meant a write to the message a root card
+ // displays found nothing and repainted nothing, and item 108 made that
+ // the ordinary gesture rather than an edge case.
+ //
+ // Matched on the summary's id as well as the loaded node's, because the
+ // node is empty until the thread has been expanded and the user acts on
+ // unexpanded threads constantly.
+ const bool isRoot =
+ node.summary.firstMessageId == messageId
+ || (!node.first.messageId.isEmpty()
+ && node.first.messageId == messageId);
+ if (isRoot) {
+ // The root's own node, which is what the card draws its tags from
+ // once the message has been loaded. Seeded from the summary when
+ // the message has never been loaded, so an edit made before the
+ // row was ever opened still has somewhere to land; the summary is
+ // the union, which is the widest honest starting point.
+ if (node.first.messageId.isEmpty()) {
+ node.first.messageId = node.summary.firstMessageId;
+ node.first.threadId = node.summary.threadId;
+ node.first.tags = node.summary.tags;
+ }
+ retag(node.first.tags);
+
+ // The SUMMARY only for a single-message thread. A thread's tags are
+ // a UNION over its messages: for a thread of one that union IS this
+ // message, so keeping the two in step is exact; for a longer
+ // thread, deleting one message does not delete the conversation,
+ // and the summary must keep describing the conversation because
+ // that is what the thread-scoped actions and the query read.
+ //
+ // The CARD does not depend on this either way: since item 110 it
+ // draws its tags from first.tags, which was just updated. This
+ // keeps the summary honest for everything else that reads it.
+ if (node.summary.totalCount <= 1)
+ retag(node.summary.tags);
+
+ emit dataChanged(threadIndex, threadIndex);
+ return;
+ }
+
+ QVector<MessageNode> &children = node.children;
+ for (int child = 0; child < children.size(); ++child) {
+ if (children.at(child).messageId != messageId)
+ continue;
+
+ retag(children[child].tags);
+
+ // The reply's own row, and only that row. Its chips, its marks,
+ // its dimming and its doomed fill all read the node's tags.
+ const QModelIndex replyIndex = index(child, 0, threadIndex);
+ emit dataChanged(replyIndex, replyIndex);
+ return;
+ }
}
}
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 2b8d2b0..717537c 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -65,6 +65,21 @@ public:
/// config itself would be a second source of truth.
PillColoursRole,
+ /// How many of PillTagsRole's entries belong to the message the card
+ /// DISPLAYS, the rest belonging only to its siblings.
+ ///
+ /// The card stands for one message but sits above a conversation, so
+ /// it shows both: the message's own tags first at full size, then the
+ /// thread's other tags smaller and muted. Without the split a card
+ /// either claimed a sibling's tag as its own (item 110) or dropped it
+ /// and looked like it had lost information.
+ ///
+ /// Equals the whole list until the row has been opened, since the
+ /// per-message tags arrive with the message load and before that the
+ /// union is the only answer there is. Chips therefore SHRINK when the
+ /// split becomes known; none ever disappears.
+ PillOwnCountRole,
+
/// True when the row is a MESSAGE row rather than a thread root.
/// Drives both the action scope and whether the view paints a tag
/// strip under the row.
@@ -225,8 +240,29 @@ public:
/// contradict the sort the user selected.
void reconcile(const QVector<ThreadSummary> &threads);
+ /// The thread at a TOP-LEVEL row.
+ ///
+ /// **Wrong for any index that might be a reply**, and that is item 88. A
+ /// tree numbers rows per parent, so a reply's row() indexes its siblings:
+ /// threadAt(0) on the first reply of any thread returns the FIRST THREAD IN
+ /// THE LIST, and the caller acts on unrelated mail while every id it
+ /// compares looks right. Safe only for a row number that came from a loop
+ /// over rowCount(), never from an index the user selected.
+ ///
+ /// Prefer threadFor(index), which cannot be handed the wrong number.
ThreadSummary threadAt(int row) const;
+ /// The thread an index belongs to, whichever kind of row it is.
+ ///
+ /// A thread row resolves to itself; a message row resolves through its
+ /// PARENT rather than through its own row number. This is the accessor
+ /// every caller holding a QModelIndex wants, and it exists because the
+ /// row-taking one above silently answers about unrelated mail for a reply.
+ ///
+ /// An invalid or unknown index gives a default-constructed summary, whose
+ /// empty threadId every caller here already treats as "nothing to do".
+ ThreadSummary threadFor(const QModelIndex &index) const;
+
/// Fills in a thread's message rows once the worker has walked its tree.
///
/// The depth-0 message is dropped: it is the thread's first message and the
@@ -249,14 +285,58 @@ public:
/// message the user could select is always findable here.
QString threadIdForMessage(const QString &messageId) const;
- /// Resolves a selection into what an action should touch.
+ /// Records the tags a MESSAGE really carries, as the worker reported them.
+ ///
+ /// Exists because `ThreadSummary::tags` is notmuch's UNION over the
+ /// thread, which is right for a card standing for a conversation and wrong
+ /// for one standing for a message. A four-message thread whose third
+ /// message is signed makes the whole thread read as signed, so the root
+ /// card and the message pane both claimed a tag the displayed message did
+ /// not have.
+ ///
+ /// Only the ROOT needs this: reply rows already carry their own nodes from
+ /// setThreadMessages. Calling it for anything else is a no-op.
+ ///
+ /// The thread's summary is deliberately NOT rewritten. It describes the
+ /// conversation, and three unread siblings do not stop being unread
+ /// because this message was read.
+ void setRootMessageTags(const QString &messageId, const QStringList &tags);
+
+ /// A loaded message row's node, found by id rather than by position.
///
- /// Mixed selections are honoured as given: a thread root and an unrelated
- /// reply act on that whole thread and that one message. Nothing is
- /// escalated or narrowed silently, which is the point of the scope being
- /// visible in the first place.
+ /// For callers that know WHICH message they mean and must not depend on it
+ /// being the row the user has selected. Default-constructed when no
+ /// expanded thread holds it.
+ MessageNode messageById(const QString &messageId) const;
+
+ /// Resolves a selection into whole THREADS, for the thread-scoped actions.
+ ///
+ /// A thread row contributes its thread; a message row still contributes
+ /// only itself, since a reply's own row cannot be widened into its
+ /// conversation without escalating silently. Mixed selections are honoured
+ /// as given: a thread root and an unrelated reply act on that whole thread
+ /// and that one message.
+ ///
+ /// **Not the default any more.** Since item 108 the ordinary actions use
+ /// messageScopeFor(); this is what the explicit "whole thread" submenu
+ /// resolves through.
ActionScope scopeFor(const QModelIndexList &selection) const;
+ /// Resolves a selection into individual MESSAGES, which is what the
+ /// ordinary tag actions act on since item 108.
+ ///
+ /// A thread row contributes the ONE message its card displays, not its
+ /// whole conversation. That is `ThreadSummary::firstMessageId`, carried
+ /// from the query, so this needs no expansion and no worker round trip.
+ /// In the Sent view that field is the first MATCHED message rather than
+ /// the thread's opening one, which is right here for the same reason it is
+ /// right on the card: both answer "the message this row shows".
+ ///
+ /// A thread row whose `firstMessageId` is empty contributes nothing. That
+ /// is a row the model cannot name a message for, and acting on the whole
+ /// thread instead would be the silent escalation this exists to remove.
+ ActionScope messageScopeFor(const QModelIndexList &selection) const;
+
/// The account keys behind a thread's account tags, for item 49's
/// per-account sync.
///
@@ -273,6 +353,21 @@ public:
void applyTagChange(const QString &threadId, const QStringList &added,
const QStringList &removed);
+ /// The same, for a change scoped to ONE message.
+ ///
+ /// Repaints that message's own row and leaves the thread alone. The thread
+ /// row deliberately does not follow: it stands for the whole conversation,
+ /// so redrawing it for a one-message edit would claim every message in it
+ /// had changed. That reasoning is why no optimistic update existed here at
+ /// all, which left Delete and Toggle unread on a reply moving the pending
+ /// count and changing nothing on screen.
+ ///
+ /// A message id that no expanded thread holds is a no-op: only expanded
+ /// threads have message rows, so there is nothing to repaint.
+ void applyMessageTagChange(const QString &messageId,
+ const QStringList &added,
+ const QStringList &removed);
+
private:
/// One thread root and the message rows expanded under it.
///
@@ -301,6 +396,21 @@ private:
bool loaded = false;
};
+ /// A newly arrived thread, with its card's own message seeded from the
+ /// query.
+ ///
+ /// `ThreadSummary::tags` is notmuch's union over the conversation, and the
+ /// card stands for ONE message. The worker reads that message's own tags
+ /// in the same walk that finds its id, so the two tiers are known from the
+ /// first paint (item 111). Deriving them from the message LOAD instead
+ /// left every unopened row drawing one tier and correcting itself when the
+ /// user selected it, which is most of the list.
+ ///
+ /// `first` is left empty when the query carried no per-message tags, so
+ /// anything that supplies only a summary keeps the old behaviour rather
+ /// than claiming the union as one message's.
+ static ThreadNode nodeFor(const ThreadSummary &summary);
+
QVector<ThreadNode> m_threads;
const TagColors *m_tagColors = nullptr;
QString m_dateFormat;
diff --git a/src/types.h b/src/types.h
index f4eaeba..409ce79 100644
--- a/src/types.h
+++ b/src/types.h
@@ -48,6 +48,20 @@ struct ThreadSummary
/// with recipients; the two have nothing in common but their position here.
QString firstMessageId;
+ /// The tags of that ONE message, as opposed to `tags` above, which is
+ /// notmuch's union over the whole thread.
+ ///
+ /// A card stands for one message but sits above a conversation, and shows
+ /// both: its own tags at full size, the thread's others smaller (item
+ /// 111). Without this the split is unknown until the row is opened and the
+ /// message loads, so every chip renders as the card's own and then shrinks
+ /// on selection, which is what the user reported.
+ ///
+ /// Free, for the same reason `firstMessageId` is: the walk that finds that
+ /// message is already happening and this reads the INDEX, not the message
+ /// file. Do not move it behind a flag by analogy with `recipients`.
+ QStringList firstMessageTags;
+
/// Who the thread's messages were sent TO, summarised for one line.
///
/// Empty unless the query asked for it, and that is a performance
@@ -140,6 +154,15 @@ struct MessageNode
{
return tags.contains(QStringLiteral("attachment"));
}
+
+ bool isDeleted() const { return tags.contains(QStringLiteral("deleted")); }
+ bool isSpam() const { return tags.contains(QStringLiteral("spam")); }
+
+ /// True while the message is tagged for removal, exactly as the thread
+ /// predicate of the same name. A reply carries its own fate: a
+ /// message-scoped Delete tags one message, and the reply's row is the only
+ /// place the user can see that happen.
+ bool isDoomed() const { return isDeleted() || isSpam(); }
};
/// What an action is about to touch, resolved from the selection.