summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/carddelegate.cpp53
-rw-r--r--src/cardlayout.cpp58
-rw-r--r--src/cardlayout.h31
-rw-r--r--src/mainwindow.cpp23
-rw-r--r--src/threadlistmodel.cpp37
-rw-r--r--src/threadlistmodel.h9
6 files changed, 187 insertions, 24 deletions
diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp
index a3a846d..f13205e 100644
--- a/src/carddelegate.cpp
+++ b/src/carddelegate.cpp
@@ -157,7 +157,7 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
const QDateTime date =
index.data(ThreadListModel::DateRole).toDateTime();
painter->drawText(card.dateRect, Qt::AlignVCenter | Qt::AlignRight,
- date.toString(QStringLiteral("yyyy-MM-dd hh:mm")));
+ CardLayout::formatDate(date));
// Line 2: the flag mark, the subject, the attachment mark.
QString subject = index.data(ThreadListModel::SubjectRole).toString();
@@ -178,16 +178,51 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
metrics.elidedText(line2, Qt::ElideRight,
card.subjectRect.width()));
- // The reply count, which is also the expander.
+ // The reply count, which is also the expander, drawn as a PILL.
+ //
+ // A bare "3" on the card's own background read as an unexplained number
+ // beside the subject and gave no hint that it could be clicked. The chip
+ // shape says "this is a control", matching the tag chips on line 3, and the
+ // word says what the number counts.
if (!card.expanderRect.isEmpty()) {
- painter->setFont(CardLayout::smallFont(chrome.font));
const int count = index.data(ThreadListModel::ReplyCountRole).toInt();
- const QString glyph = (option.state & QStyle::State_Open)
- ? QStringLiteral("▾")
- : QStringLiteral("▸");
- painter->drawText(card.expanderRect, Qt::AlignVCenter | Qt::AlignRight,
- QStringLiteral("%1 %2").arg(glyph).arg(count));
- painter->setFont(chrome.font);
+ const QString label = CardLayout::expanderLabel(
+ count, option.state & QStyle::State_Open);
+
+ painter->save();
+ painter->setFont(CardLayout::smallFont(chrome.font));
+
+ // Blended from Text toward Base rather than taken from a palette ROLE.
+ // QPalette::Button is the role this obviously wants and it is
+ // #2b2b2b against a Base of #2b2b2b on the user's theme: byte
+ // identical, so the pill was invisible. A theme is free to make any two
+ // roles equal, and several do; a blend cannot collide with the surface
+ // it sits on because it is defined relative to it.
+ //
+ // Toward Text, so it darkens on a light theme and lightens on a dark
+ // one, the same trick replyBackground() and threadLineColour() use.
+ const QColor base = option.palette.color(QPalette::Base);
+ const QColor text = option.palette.color(QPalette::Text);
+ constexpr qreal kFillWeight = 0.18;
+ const QColor fill = QColor::fromRgbF(
+ text.redF() * kFillWeight + base.redF() * (1.0 - kFillWeight),
+ text.greenF() * kFillWeight + base.greenF() * (1.0 - kFillWeight),
+ text.blueF() * kFillWeight + base.blueF() * (1.0 - kFillWeight));
+ painter->setRenderHint(QPainter::Antialiasing, true);
+ painter->setPen(Qt::NoPen);
+ painter->setBrush(fill);
+ // Fully rounded ends, the same shape TagChip paints: the radius is half
+ // the height, so the pill cannot look like a rectangle with soft corners.
+ const qreal radius = card.expanderRect.height() / 2.0;
+ painter->drawRoundedRect(card.expanderRect, radius, radius);
+
+ // The pen is restored from the card's own text colour rather than
+ // ButtonText, which belongs to the role that just proved unreliable.
+ painter->setPen(option.state & QStyle::State_Selected
+ ? option.palette.highlightedText().color()
+ : text);
+ painter->drawText(card.expanderRect, Qt::AlignCenter, label);
+ painter->restore();
}
painter->restore();
diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp
index 1a79e3b..0e118ab 100644
--- a/src/cardlayout.cpp
+++ b/src/cardlayout.cpp
@@ -19,6 +19,44 @@
#include "cardlayout.h"
#include <QFontMetrics>
+#include <QLocale>
+
+QString CardLayout::formatDate(const QDateTime &date)
+{
+ // The system locale's own short format, not a hardcoded pattern: an
+ // Italian desktop writes 10/08/2025, not 2025-08-10, and a mail client
+ // that disagrees with every other application on screen is simply wrong.
+ return QLocale::system().toString(date, QLocale::ShortFormat);
+}
+
+QString CardLayout::expanderLabel(int replyCount, bool expanded)
+{
+ // "3 replies", not a bare "3". The count alone reads as an unexplained
+ // number beside the subject, and the word is what says the card opens.
+ //
+ // Not translated through tr() here because CardLayout is a plain struct
+ // rather than a QObject; the delegate is where a translated build would
+ // wrap this, and the string is deliberately kept in one place so there is
+ // exactly one thing to change.
+ const QString glyph = expanded ? QStringLiteral("\u25be")
+ : QStringLiteral("\u25b8");
+ const QString word = replyCount == 1 ? QStringLiteral("reply")
+ : QStringLiteral("replies");
+ return QStringLiteral("%1 %2 %3").arg(glyph).arg(replyCount).arg(word);
+}
+
+QString CardLayout::widestDateSample()
+{
+ // A real date run through the same formatter, with the wide digits and a
+ // two-digit day and month, so the reserved width matches what is drawn
+ // whatever the locale's pattern turns out to be. Guessing a pattern here
+ // would reintroduce the clipping this exists to prevent.
+ static const QString sample = [] {
+ const QDateTime wide(QDate(2028, 12, 28), QTime(22, 58));
+ return formatDate(wide);
+ }();
+ return sample;
+}
QFont CardLayout::smallFont(const QFont &cardFont)
{
@@ -96,8 +134,8 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect,
// painted.
QFont dateFont = font;
dateFont.setBold(true);
- const int dateWidth = QFontMetrics(dateFont).horizontalAdvance(
- QStringLiteral("8888-88-88 88:88"));
+ const int dateWidth =
+ QFontMetrics(dateFont).horizontalAdvance(widestDateSample());
out.dateRect = QRect(right - dateWidth, lineOneTop, dateWidth,
metrics.height());
out.senderRect = QRect(out.contentLeft, lineOneTop,
@@ -105,10 +143,20 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect,
- kPaddingX),
metrics.height());
- // The expander is the reply count, on line two and on the right.
+ // The expander is the reply count as a PILL, on line two and on the right.
+ //
+ // Sized from the label actually drawn rather than from a fixed sample, so
+ // the background and the text inside it cannot disagree. Both states of the
+ // glyph are measured because the rect must not change width when the card
+ // is expanded: a pill that resized on click would shift the subject's
+ // elision under the pointer.
if (input.replyCount > 0) {
- const int countWidth = smallMetrics.horizontalAdvance(
- QStringLiteral("▾ 8888 replies"));
+ const int collapsed = smallMetrics.horizontalAdvance(
+ expanderLabel(input.replyCount, false));
+ const int expanded = smallMetrics.horizontalAdvance(
+ expanderLabel(input.replyCount, true));
+ const int countWidth =
+ qMax(collapsed, expanded) + kPillPaddingX * 2;
out.expanderRect = QRect(right - countWidth, lineTwoTop, countWidth,
metrics.height());
}
diff --git a/src/cardlayout.h b/src/cardlayout.h
index d06ed92..ef6a563 100644
--- a/src/cardlayout.h
+++ b/src/cardlayout.h
@@ -18,6 +18,7 @@
#pragma once
+#include <QDateTime>
#include <QFont>
#include <QRect>
#include <QVector>
@@ -116,4 +117,34 @@ struct CardLayout
static CardLayout compute(const Input &input, const QRect &rect,
const QFont &font);
+
+ /// How a card writes a date, in the user's own locale.
+ ///
+ /// Never a hardcoded pattern. "yyyy-MM-dd hh:mm" is a US-looking format
+ /// that an Italian desktop does not use, and the whole point of asking the
+ /// system locale is that the user reads dates the way their desktop writes
+ /// them everywhere else.
+ ///
+ /// Shared with the layout so the width reserved for the date and the text
+ /// drawn into it come from one place: a locale whose short format is
+ /// longer than the reserved rect would clip, which is exactly the fault
+ /// bold text produced.
+ static QString formatDate(const QDateTime &date);
+
+ /// The widest string formatDate() can return, for reserving space.
+ static QString widestDateSample();
+
+ /// The expander's label: the reply count with its glyph, as drawn.
+ ///
+ /// Shared with the layout for the same reason as formatDate: the rect
+ /// reserved for the pill and the text put inside it must come from one
+ /// place, or a count wider than the sample the layout guessed at spills
+ /// out of its own background.
+ ///
+ /// `expanded` chooses which way the triangle points.
+ static QString expanderLabel(int replyCount, bool expanded);
+
+ /// Padding inside the expander pill, matching a tag chip's, so the two read
+ /// as the same kind of object on the card.
+ static constexpr int kPillPaddingX = 8;
};
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 5881fae..b7efec5 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1755,10 +1755,31 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
}
const ThreadSummary thread = m_model->threadAt(current.row());
- m_currentMessageId.clear();
m_currentThreadId = thread.threadId;
m_messageView->setTags(thread.tags);
scheduleMarkRead(thread);
+
+ // The root card IS the thread's first message, so selecting it renders
+ // that message rather than the whole conversation. Loading the thread here
+ // made the first message unreachable: the pane showed every message with
+ // only the last expanded, and no row in the list offered the first one,
+ // since the reply rows are messages two onward.
+ //
+ // Known only once the replies have been loaded, which happens when the
+ // thread is expanded. Until then the thread is the honest answer: it
+ // contains the first message, where a guess might not.
+ const QString firstId =
+ m_model->data(current, ThreadListModel::MessageIdRole).toString();
+ if (!firstId.isEmpty()) {
+ m_currentMessageId = firstId;
+ QMetaObject::invokeMethod(m_worker, "loadMessage",
+ Qt::QueuedConnection,
+ Q_ARG(QString, firstId),
+ Q_ARG(quint64, m_generation));
+ return;
+ }
+
+ m_currentMessageId.clear();
QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection,
Q_ARG(QString, m_currentThreadId),
Q_ARG(QString, m_lastQuery),
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index d8412f9..bdc7e96 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -365,8 +365,13 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
if (role == IsMessageRole)
return false;
- if (role == MessageIdRole)
- return QString();
+ if (role == MessageIdRole) {
+ // The thread's FIRST message, once known, because the root card is
+ // that message: selecting it renders one message rather than the whole
+ // conversation. Empty before the replies are loaded, which is the
+ // caller's signal to load the thread instead of guessing at a message.
+ return m_threads.at(index.row()).first.messageId;
+ }
if (role == MessageDepthRole)
return 0;
@@ -542,7 +547,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(ThreadNode{ summary, {}, {}, false });
endInsertRows();
}
@@ -570,12 +575,26 @@ void ThreadListModel::setThreadMessages(const QString &threadId,
endRemoveRows();
}
- QVector<MessageNode> children;
- children.reserve(nodes.size());
- for (const MessageNode &node : nodes) {
- if (node.depth > 0)
- children.append(node);
- }
+ // Every message EXCEPT the first, which is the root card itself.
+ //
+ // Selecting on depth > 0 instead was wrong, and wrong in a way that
+ // only showed on real mail: notmuch_thread_get_toplevel_messages
+ // returns every message at depth 0 when a thread carries no usable
+ // In-Reply-To, so a flat thread contributed no children at all. The
+ // card advertised "3 replies" and expanded onto nothing. Measured in
+ // the user's database: of 396 inbox threads, three are flat, one of
+ // them nine messages long, and every two-message thread of this kind
+ // was affected, which is why the fault looked like "the expander only
+ // works with more than one reply".
+ //
+ // Position also happens to be the right rule rather than a workaround.
+ // The root card IS the thread's first message, so the row under it is
+ // the second message whatever depth notmuch assigns it.
+ QVector<MessageNode> children = nodes.mid(1);
+
+ // Kept so the root card can render its own message. It is the card the
+ // user clicks to read the thread's opening message.
+ m_threads[row].first = nodes.isEmpty() ? MessageNode() : nodes.first();
if (!children.isEmpty()) {
beginInsertRows(parent, 0, children.size() - 1);
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 9cb774e..f381ffa 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -240,6 +240,15 @@ private:
ThreadSummary summary;
QVector<MessageNode> children; ///< Empty until the thread is expanded.
+ /// The thread's FIRST message, which the root card itself draws.
+ ///
+ /// Kept because the root card is that message: selecting it must
+ /// render one message rather than the whole conversation, and without
+ /// this the first message of every thread is unreachable, since the
+ /// only rows offering a message are the replies and it is not one of
+ /// them. Empty until the replies are loaded.
+ MessageNode first;
+
/// Distinguishes "this thread has no replies" from "its replies have
/// not been asked for yet". Without it an expander would be drawn over
/// every thread, including the ones that turn out to be single