aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-26 17:50:18 +0200
committerDanilo M. <danix@danix.xyz>2026-08-26 17:50:18 +0200
commit9ae43f94f5d822efe582e79b47b2f5407161a38b (patch)
treed9b956fb6bb0034da6ca8c923017235b89bb5926 /src
parent8dec28ff4f1176b485d9de722756c677be3a4f1c (diff)
downloadqtmaildir-9ae43f94f5d822efe582e79b47b2f5407161a38b.tar.gz
qtmaildir-9ae43f94f5d822efe582e79b47b2f5407161a38b.zip
fix: correct the avatar initials, the two-tone fill and the fade
Hand-testing item 169 found four defects, three of them visible on every card. The initials were taken from whatever the card's first line held, which is the raw From header on a reply row and notmuch's comma-joined author summary on a thread row. A naive space split therefore gave `T<` for `tsujan <notifications@github.com>` and one letter each from two different people for `Standreas, tsujan`, and a separator counted as a word, so `INE - Expert IT Training` drew `I-`. Avatar::initialsFor() now normalises first: the angle-addr and any quoting go, a comma takes the first entry unless the name is quoted, a bare address is not a name, and a word has to carry a letter or a digit. Avatar::fillFor() uses the same normalisation, so an address in the name's place no longer reads as a person. The two-tone fill built its gradient axis as a radius from the centre, so the 0.5 colour stop landed on the squircle's edge and one hue filled almost the whole face. The axis spans the diameter now. The fade ran left to right, which put its hard stop at 60% of the card and read as a slab rather than a wash. It runs right to left: opaque at the card's right edge, where the only hard stop is the card's own boundary, and gone before it reaches the accent bar that already states the account. And the flat views hashed the user's own address on every row, so every Sent and Drafts card shared one pattern. ThreadSummary::firstMessageRecipient rides the recipient fold, which already parses the To header, and SenderAddressRole prefers it, falling back to the sender when there is no usable To. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
Diffstat (limited to 'src')
-rw-r--r--src/avatar.cpp69
-rw-r--r--src/carddelegate.cpp26
-rw-r--r--src/carddelegate.h12
-rw-r--r--src/notmuchworker.cpp91
-rw-r--r--src/threadlistmodel.cpp7
-rw-r--r--src/types.h12
6 files changed, 166 insertions, 51 deletions
diff --git a/src/avatar.cpp b/src/avatar.cpp
index 7aaa6eb..9b292a2 100644
--- a/src/avatar.cpp
+++ b/src/avatar.cpp
@@ -26,6 +26,42 @@
namespace {
+/// The bare display name from whatever the card's first line holds.
+///
+/// That string is the RAW header for a reply row (`Name <addr>`) and a
+/// comma-joined author summary for a thread row, so a naive space split gives
+/// `T<` for one and one letter from each of two different people for the
+/// other. Take the first entry, drop the angle-addr and any quoting, and
+/// report nothing when what remains is itself an address: the caller's address
+/// branch reads that far better than the local part would.
+QString displayNameOf(const QString &raw)
+{
+ QString name = raw.trimmed();
+
+ // `Name <addr>`: everything before the bracket is the name. When there is
+ // nothing before it, the address itself is not a name.
+ const int bracket = name.indexOf(QLatin1Char('<'));
+ if (bracket >= 0)
+ name = name.left(bracket).trimmed();
+
+ // A comma joins either two authors or a `"Rossi, Mario"` quoted name. The
+ // quotes tell them apart, so strip them only after splitting.
+ if (!name.startsWith(QLatin1Char('"'))) {
+ const int comma = name.indexOf(QLatin1Char(','));
+ if (comma >= 0)
+ name = name.left(comma).trimmed();
+ }
+ if (name.size() >= 2 && name.startsWith(QLatin1Char('"'))
+ && name.endsWith(QLatin1Char('"'))) {
+ name = name.mid(1, name.size() - 2).trimmed();
+ }
+
+ // A bare address left standing is not a name.
+ if (name.contains(QLatin1Char('@')))
+ return QString();
+ return name;
+}
+
QString twoFrom(const QString &text)
{
const QString trimmed = text.trimmed();
@@ -43,8 +79,19 @@ namespace Avatar {
QString initialsFor(const QString &displayName, const QString &address,
const QString &accountLabel)
{
- const QStringList words = displayName.split(QLatin1Char(' '),
- Qt::SkipEmptyParts);
+ // Words, and a separator is not one. `INE - Expert IT Training` split on
+ // spaces alone gave `I-`, because the dash counted as the second word.
+ QStringList words;
+ const QStringList parts = displayNameOf(displayName)
+ .split(QLatin1Char(' '), Qt::SkipEmptyParts);
+ for (const QString &part : parts) {
+ // Trim leading punctuation so `(Team)` still contributes its `T`.
+ int at = 0;
+ while (at < part.size() && !part.at(at).isLetterOrNumber())
+ ++at;
+ if (at < part.size())
+ words.append(part.mid(at));
+ }
if (words.size() >= 2) {
return (words.at(0).left(1) + words.at(1).left(1)).toUpper();
}
@@ -83,7 +130,10 @@ Fill fillFor(const QString &displayName, bool isBusinessSender)
// heuristic, or a listed sender could never be pinned.
if (isBusinessSender)
return Fill::TwoTone;
- return displayName.trimmed().isEmpty() ? Fill::TwoTone : Fill::Identicon;
+ // The same normalisation initialsFor() uses: a raw `<addr>` header or a
+ // bare address is not a display name, so it must not read as a person.
+ return displayNameOf(displayName).isEmpty() ? Fill::TwoTone
+ : Fill::Identicon;
}
QColor colourFor(const QString &address)
@@ -150,9 +200,18 @@ QPixmap pixmapFor(const QString &seed, const QString &initials, Fill fill,
// behind the letters stays large and flat, which is the whole reason
// this fill exists beside the identicon.
const int angle = static_cast<quint8>(digest.at(1)) * 360 / 256;
- QLineF axis = QLineF::fromPolar(side, angle);
+ // The axis must span the DIAMETER through the centre, not a radius
+ // from it. fromPolar() starts at the origin, so translating by half
+ // the side put p1 at the centre and the 0.5 colour stop on the
+ // squircle's edge: one hue filled almost the whole face and the fill
+ // read as flat. The diagonal, so the split still crosses the shape at
+ // any angle.
+ const qreal reach = side * 0.71;
+ QLineF axis = QLineF::fromPolar(reach, angle);
axis.translate(side / 2.0, side / 2.0);
- QLinearGradient gradient(axis.p2(), axis.p1());
+ QLineF back = QLineF::fromPolar(reach, angle + 180.0);
+ back.translate(side / 2.0, side / 2.0);
+ QLinearGradient gradient(back.p2(), axis.p2());
gradient.setColorAt(0.0, base);
gradient.setColorAt(0.499, base);
gradient.setColorAt(0.5, base.darker(135));
diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp
index 89c3ed0..2dd29a2 100644
--- a/src/carddelegate.cpp
+++ b/src/carddelegate.cpp
@@ -107,12 +107,20 @@ QRect CardDelegate::fadeRectFor(const QRect &card, const QRect &innermostSpine)
{
// The EXCLUSIVE right edge, then a rect built from it: QRect::right() is
// inclusive, which is the trap CardLayout already documents.
- const int end = card.left() + int(card.width() * kFadeFraction);
- const int start = innermostSpine.isEmpty() ? card.left()
- : innermostSpine.left();
- if (end <= start)
+ // Right to left: the wash is opaque at the card's RIGHT edge, where a
+ // hard stop is the card's own boundary, and fades out before reaching the
+ // accent bar, which already states the account. Drawing it the other way
+ // put the hard stop mid-card and read as a slab.
+ //
+ // A reply's left limit is its own spine rather than the card's edge, so
+ // the wash steps right with the nesting and stays shorter.
+ const int left = innermostSpine.isEmpty() ? card.left()
+ : innermostSpine.left();
+ const int start = card.right() + 1 - int(card.width() * kFadeFraction);
+ if (card.right() + 1 <= qMax(start, left))
return QRect();
- return QRect(start, card.top(), end - start, card.height());
+ return QRect(qMax(start, left), card.top(),
+ card.right() + 1 - qMax(start, left), card.height());
}
QColor CardDelegate::accentLineColour(const QColor &accountColour)
@@ -219,7 +227,13 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
// A reply's wash is weaker than its root's, so an expanded thread
// reads as one block with the root leading it.
from.setAlphaF(card.accentRect.isEmpty() ? 0.14 : 0.30);
- QLinearGradient gradient(fade.topLeft(), fade.topRight());
+ // Right to left: opaque at the fade's far end, transparent where the
+ // accent bar already carries the colour. Drawing it the other way put
+ // the strongest wash under the bar, which is the one place the account
+ // is already stated.
+ QLinearGradient gradient(fade.topRight(), fade.topLeft());
+ // Opaque at the right edge, gone at the left, so the only hard stop
+ // is the card's own boundary.
gradient.setColorAt(0.0, from);
from.setAlphaF(0.0);
gradient.setColorAt(1.0, from);
diff --git a/src/carddelegate.h b/src/carddelegate.h
index 18c735f..cfbd23e 100644
--- a/src/carddelegate.h
+++ b/src/carddelegate.h
@@ -81,16 +81,18 @@ public:
/// Where the account's fade runs, given the card and the row's innermost
/// spine (an empty rect for a thread root, which has none).
///
- /// A root's fade starts at the card's left edge; a reply's starts at its
- /// own spine, which IS its coloured left border, so the wash steps right
- /// with the nesting. Both end at 60% of the card's width, so a deeper
- /// reply's wash is shorter as well as further right.
+ /// Runs RIGHT to left: opaque at the card's right edge and gone 60% of the
+ /// width in, so the only hard stop is the card's own boundary and the
+ /// accent bar is left to state the account on its own. A reply is clamped
+ /// at its own spine, which IS its coloured left border, so its wash is
+ /// shorter as well as further right.
///
/// Static and rect-in, rect-out so the geometry is assertable without a
/// painter, for the same reason CardLayout is.
static QRect fadeRectFor(const QRect &card, const QRect &innermostSpine);
- /// How far across the card the account's colour reaches.
+ /// How far back across the card, from its right edge, the account's
+ /// colour reaches.
static constexpr qreal kFadeFraction = 0.60;
/// A tag chip's colour, drained for the SIBLING tier (item 111).
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 3de79e1..e78d8c8 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -81,6 +81,51 @@ QStringList tagsOf(notmuch_thread_t *thread)
return result;
}
+/// The first mailbox address in a raw address header, display names dropped.
+///
+/// Parsed with GMime rather than split: the header is untrusted and a display
+/// name may legally contain an `@`, so "Ian <a@b>" split on `@` yields
+/// nonsense.
+QString firstMailboxOf(const QString &rawHeader)
+{
+ // GMime must be initialised once per process before any parse, or the
+ // first internet_address_list_parse() call dereferences an uninitialised
+ // type registry and SEGVs. Function-local static, exactly as the other
+ // gmime-using units do, so this file does not lean on libnotmuch having
+ // initialised it as a side effect (measured 2026-08-26: it currently
+ // does, but that is not documented anywhere).
+ static const bool initialised = [] {
+ g_mime_init();
+ return true;
+ }();
+ Q_UNUSED(initialised);
+
+ const QByteArray utf8 = rawHeader.trimmed().toUtf8();
+ if (utf8.isEmpty())
+ return QString();
+
+ InternetAddressList *list =
+ internet_address_list_parse(nullptr, utf8.constData());
+ if (!list)
+ return QString();
+
+ QString address;
+ const int count = internet_address_list_length(list);
+ for (int i = 0; i < count; ++i) {
+ InternetAddress *entry = internet_address_list_get_address(list, i);
+ if (!entry || !INTERNET_ADDRESS_IS_MAILBOX(entry))
+ continue;
+ const char *addr =
+ internet_address_mailbox_get_addr(INTERNET_ADDRESS_MAILBOX(entry));
+ if (addr && *addr) {
+ address = QString::fromUtf8(addr);
+ break;
+ }
+ }
+ g_object_unref(list);
+ return address;
+}
+
/// Who a thread's messages were addressed to, summarised for one line.
///
/// EXPENSIVE, and only called when a query asks. "To" is not served from
@@ -94,7 +139,7 @@ QStringList tagsOf(notmuch_thread_t *thread)
/// frees again, and the walk finishes before the caller drops the thread. This
/// is the same rule walkReplies follows, and getting it wrong is a double-free
/// rather than a leak.
-QString recipientsOf(notmuch_thread_t *thread)
+QString recipientsOf(notmuch_thread_t *thread, QString *firstAddress)
{
// The first message with a usable To wins. A thread is one conversation,
// and the alternative, folding every message's recipients together, is the
@@ -114,8 +159,14 @@ QString recipientsOf(notmuch_thread_t *thread)
continue;
const QString summary = recipientSummary(QString::fromUtf8(to));
- if (!summary.isEmpty())
+ if (!summary.isEmpty()) {
+ // The same header, for the avatar's hash (item 169). Parsed rather
+ // than split for the reason senderAddressOf() documents: a display
+ // name may legally contain an `@`.
+ if (firstAddress)
+ *firstAddress = firstMailboxOf(QString::fromUtf8(to));
return summary;
+ }
}
return QString();
}
@@ -129,41 +180,10 @@ QString recipientsOf(notmuch_thread_t *thread)
/// may legally contain an `@`, and "Ian <a@b>" split on `@` yields nonsense.
QString senderAddressOf(notmuch_message_t *message)
{
- // GMime must be initialised once per process before any parse, or the
- // first internet_address_list_parse() call dereferences an uninitialised
- // type registry and SEGVs. Function-local static, exactly as the other
- // gmime-using units do, so this file does not lean on libnotmuch having
- // initialised it as a side effect (measured 2026-08-26: it currently
- // does, but that is not documented anywhere).
- static const bool initialised = [] {
- g_mime_init();
- return true;
- }();
- Q_UNUSED(initialised);
-
const char *from = notmuch_message_get_header(message, "From");
if (!from || !*from)
return QString();
-
- InternetAddressList *list = internet_address_list_parse(nullptr, from);
- if (!list)
- return QString();
-
- QString address;
- const int count = internet_address_list_length(list);
- for (int i = 0; i < count; ++i) {
- InternetAddress *entry = internet_address_list_get_address(list, i);
- if (!entry || !INTERNET_ADDRESS_IS_MAILBOX(entry))
- continue;
- const char *addr =
- internet_address_mailbox_get_addr(INTERNET_ADDRESS_MAILBOX(entry));
- if (addr && *addr) {
- address = QString::fromUtf8(addr);
- break;
- }
- }
- g_object_unref(list);
- return address;
+ return firstMailboxOf(QString::fromUtf8(from));
}
/// Collects the message ids a query matches. Returns false if the query could
@@ -455,7 +475,8 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
summary.matchedCount = notmuch_thread_get_matched_messages(thread.get());
summary.tags = tagsOf(thread.get());
if (withRecipients)
- summary.recipients = recipientsOf(thread.get());
+ summary.recipients =
+ recipientsOf(thread.get(), &summary.firstMessageRecipient);
// The message the row's card stands for. Raw pointers on purpose:
// messages reached through a thread are owned by the THREAD and freed
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index a6fada3..f085b79 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -689,6 +689,13 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// The bare address of the message this card stands for, carried from
// the query. This is what the avatar's hash and the business-senders
// lookup consume; the display name below is what the card draws.
+ //
+ // The recipient first in a flat view, matching SendersRole below and
+ // for the same reason: the sender there is the user on every row, so
+ // every card would hash to one pattern. Falls back to the sender when
+ // the fold found no usable To.
+ if (!thread.firstMessageRecipient.isEmpty())
+ return thread.firstMessageRecipient;
return thread.firstMessageSender;
case SenderNameRole:
// The same string the card's first line shows: recipients in a flat
diff --git a/src/types.h b/src/types.h
index a1689ab..a0f772b 100644
--- a/src/types.h
+++ b/src/types.h
@@ -113,6 +113,18 @@ struct ThreadSummary
/// row and carries nothing.
QString recipients;
+ /// The FIRST recipient's bare address, for the avatar in a flat view.
+ ///
+ /// Filled by the same walk as `recipients` and under the same flag, so it
+ /// costs nothing extra: the To header is already parsed there.
+ ///
+ /// A Sent or Drafts card's `firstMessageSender` is the user on every row,
+ /// so every card would carry one pattern and only the initials would vary.
+ /// The avatar answers "who is this row about", and there that is the
+ /// recipient. `firstMessageSender` stays the fallback for a row with no
+ /// usable To.
+ QString firstMessageRecipient;
+
bool isUnread() const { return tags.contains(QStringLiteral("unread")); }
bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); }
/// True when this message was forwarded. The Maildir "P" (passed) flag,