aboutsummaryrefslogtreecommitdiffstats
path: root/src/carddelegate.cpp
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/carddelegate.cpp
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/carddelegate.cpp')
-rw-r--r--src/carddelegate.cpp85
1 files changed, 76 insertions, 9 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();