summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-10 09:33:44 +0200
committerDanilo M. <danix@danix.xyz>2026-08-10 09:33:44 +0200
commit01419de209c2b5e2ae7b996e6b5ff1baa2efb3da (patch)
tree25718fd36e87eac721f41b02cca46b6fb07e94d9 /src
parentf72dba9f6c463c6823d85701e51d8be38dd22a62 (diff)
parente1dba2987a9a1e87b92801959df9c9d4f1375d2f (diff)
downloadqtmaildir-01419de209c2b5e2ae7b996e6b5ff1baa2efb3da.tar.gz
qtmaildir-01419de209c2b5e2ae7b996e6b5ff1baa2efb3da.zip
Merge branch 'card-list': the thread pane as a list of cards
Replaces the five-column grid with a single column of three-line cards. Item 53 recorded that the columns, not the cues drawn inside them, were what made the list read as a table of records; item 20 had already shipped finished, tested and green and been rejected on sight for exactly that reason. A card is sender and date, subject with the flag, attachment and reply-count marks, and tags, at one uniform height. Replies indent under a continuous spine and show only the tags their thread does not carry. The account colour runs down the card's left edge, replacing the chip that used to eat a third of every subject line, with matching swatches in the account dropdown. Sorting newest or oldest first is new and remembered. Closes items 20, 51, 53 and 60. The four defects that mattered were all found by rendering cards to an image and looking at them, with the suite green through every one: a date clipped on unread cards because bold is wider than the font the layout measured, an accent bar painted in a colour identical to the background, an expander pill in a palette role a theme had made equal to Base, and three separate faults from trusting notmuch's reply depth to mean structure when it only means how notmuch happened to thread the mail.
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt2
-rw-r--r--src/carddelegate.cpp261
-rw-r--r--src/carddelegate.h72
-rw-r--r--src/cardlayout.cpp186
-rw-r--r--src/cardlayout.h150
-rw-r--r--src/keymap.cpp36
-rw-r--r--src/keymap.h9
-rw-r--r--src/mainwindow.cpp507
-rw-r--r--src/mainwindow.h98
-rw-r--r--src/notmuchworker.cpp164
-rw-r--r--src/notmuchworker.h41
-rw-r--r--src/tagchip.cpp176
-rw-r--r--src/tagchip.h42
-rw-r--r--src/threadlistmodel.cpp499
-rw-r--r--src/threadlistmodel.h185
-rw-r--r--src/threadlistview.cpp141
-rw-r--r--src/threadlistview.h39
-rw-r--r--src/types.h58
18 files changed, 2103 insertions, 563 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index cae3bd4..fdac2c1 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -5,6 +5,8 @@ add_library(qtmaildir_lib STATIC
requestinterceptor.cpp
htmlbuilder.cpp
cidschemehandler.cpp
+ cardlayout.cpp
+ carddelegate.cpp
notmuchworker.cpp
tagchip.cpp
tagcolors.cpp
diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp
new file mode 100644
index 0000000..f13205e
--- /dev/null
+++ b/src/carddelegate.cpp
@@ -0,0 +1,261 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "carddelegate.h"
+
+#include "cardlayout.h"
+#include "threadlistmodel.h"
+
+#include <QApplication>
+#include <QDateTime>
+#include <QGuiApplication>
+#include <QPainter>
+#include <QRegularExpression>
+#include <QStyle>
+
+namespace {
+
+CardLayout::Input inputFor(const QModelIndex &index)
+{
+ CardLayout::Input in;
+ in.isMessage = index.data(ThreadListModel::IsMessageRole).toBool();
+ in.depth = index.data(ThreadListModel::MessageDepthRole).toInt();
+ in.replyCount = index.data(ThreadListModel::ReplyCountRole).toInt();
+ return in;
+}
+
+} // namespace
+
+QRect CardDelegate::expanderRectFor(const QStyleOptionViewItem &option,
+ const QModelIndex &index)
+{
+ return CardLayout::compute(inputFor(index), option.rect, option.font)
+ .expanderRect;
+}
+
+QColor CardDelegate::accentLineColour(const QColor &accountColour)
+{
+ if (!accountColour.isValid())
+ return ThreadListModel::threadLineColour();
+
+ // 0.35 toward Base was the first attempt and produced an INVISIBLE bar on
+ // a dark theme: rendered against a Base of (0.169, 0.169, 0.169) it landed
+ // at (0.18, 0.22, 0.26), which is the background. The weight is a fraction
+ // OF THE ACCOUNT COLOUR, so a low one keeps the background, not the hue.
+ //
+ // The bar is the account's colour, undiluted. Blending it toward Base at
+ // all was the mistake: a chip's colour is chosen to carry text on top and
+ // is therefore already muted, and three pixels of a muted colour on a dark
+ // background is nothing at all. There is no text on this bar, so nothing
+ // needs the contrast a chip's fill was picked for.
+ //
+ // What DOES step back is the spine, below: a line running the height of a
+ // whole expansion has to be followable without competing with the senders
+ // beside it, which is a different problem from a 3px edge marker.
+ return accountColour;
+}
+
+QSize CardDelegate::sizeHint(const QStyleOptionViewItem &option,
+ const QModelIndex &index) const
+{
+ Q_UNUSED(index);
+ // One height for every row, thread and reply alike. Asserted directly in
+ // test_cardlayout rather than left to two cards happening to agree.
+ return QSize(option.rect.width(), CardLayout::heightFor(option.font));
+}
+
+void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
+ const QModelIndex &index) const
+{
+ // Background, selection and any model fill first, through the style, so a
+ // selected or doomed card looks right before anything is drawn on top.
+ QStyleOptionViewItem chrome = option;
+ initStyleOption(&chrome, index);
+ chrome.text.clear();
+ const QWidget *widget = option.widget;
+ QStyle *style = widget ? widget->style() : QApplication::style();
+ style->drawControl(QStyle::CE_ItemViewItem, &chrome, painter, widget);
+
+ const CardLayout card =
+ CardLayout::compute(inputFor(index), option.rect, option.font);
+
+ painter->save();
+
+ // The account's colour, for both the accent bar and the spines.
+ //
+ // A reply must resolve its THREAD's colour, not its own: AccountColourRole
+ // is empty on a message row, and a spine that fell back to the neutral
+ // line under an accented root would break the one continuous edge this
+ // design is built on. index.parent() is the thread for a depth-1 reply and
+ // the containing subtree for a deeper one, so walk to the root.
+ QModelIndex root = index;
+ while (root.parent().isValid())
+ root = root.parent();
+ const QColor accountColour =
+ root.data(ThreadListModel::AccountColourRole).value<QColor>();
+ const QColor lineColour = accentLineColour(accountColour);
+
+ // The accent bar, thread cards only. Drawn after the chrome so the
+ // selection highlight cannot cover it: which account a card belongs to
+ // must stay readable on the row the user is looking at.
+ if (!card.accentRect.isEmpty())
+ painter->fillRect(card.accentRect, lineColour);
+
+ // Spines, under everything else, in the account's hue so an expanded thread
+ // is bounded by one colour from its root to its last reply. Muted against
+ // the pane's own background, unlike the accent bar: this line runs the full
+ // height of every reply and at full strength it shouts.
+ if (!card.spines.isEmpty()) {
+ const QColor base =
+ QGuiApplication::palette().color(QPalette::Base);
+ constexpr qreal kSpineWeight = 0.55;
+ const qreal inverse = 1.0 - kSpineWeight;
+ const QColor spineColour = QColor::fromRgbF(
+ lineColour.redF() * kSpineWeight + base.redF() * inverse,
+ lineColour.greenF() * kSpineWeight + base.greenF() * inverse,
+ lineColour.blueF() * kSpineWeight + base.blueF() * inverse);
+ for (const QRect &spine : card.spines)
+ painter->fillRect(spine, spineColour);
+ }
+
+ // Selection outranks the model's foreground, and the order matters: a read
+ // card carries a dimmed colour blended against the UNSELECTED background,
+ // so over the highlight it lands grey-on-highlight and close to unreadable.
+ const QVariant foreground = index.data(Qt::ForegroundRole);
+ if (option.state & QStyle::State_Selected)
+ painter->setPen(option.palette.highlightedText().color());
+ else if (foreground.isValid())
+ painter->setPen(foreground.value<QBrush>().color());
+ else
+ painter->setPen(option.palette.text().color());
+
+ // The model's font carries bold for unread and strike-out for deleted;
+ // initStyleOption resolved it into chrome.font.
+ painter->setFont(chrome.font);
+ const QFontMetrics metrics(chrome.font);
+
+ // Line 1: sender, then the date flush right.
+ painter->drawText(card.senderRect, Qt::AlignVCenter | Qt::AlignLeft,
+ metrics.elidedText(
+ index.data(ThreadListModel::SendersRole).toString(),
+ Qt::ElideRight, card.senderRect.width()));
+ const QDateTime date =
+ index.data(ThreadListModel::DateRole).toDateTime();
+ painter->drawText(card.dateRect, Qt::AlignVCenter | Qt::AlignRight,
+ CardLayout::formatDate(date));
+
+ // Line 2: the flag mark, the subject, the attachment mark.
+ QString subject = index.data(ThreadListModel::SubjectRole).toString();
+ if (index.data(ThreadListModel::IsMessageRole).toBool()) {
+ // Every reply repeating "Re: <the thread's subject>" is the visual
+ // signature of a table of records, which is what item 53 is about.
+ static const QRegularExpression re(
+ QStringLiteral("^\\s*(?:[Rr][Ee]\\s*:\\s*)+"));
+ subject.remove(re);
+ }
+ QString line2;
+ if (index.data(ThreadListModel::IsFlaggedRole).toBool())
+ line2 += ThreadListModel::flagGlyph() + QLatin1Char(' ');
+ line2 += subject;
+ if (index.data(ThreadListModel::HasAttachmentRole).toBool())
+ line2 += QLatin1Char(' ') + ThreadListModel::attachmentGlyph();
+ painter->drawText(card.subjectRect, Qt::AlignVCenter | Qt::AlignLeft,
+ metrics.elidedText(line2, Qt::ElideRight,
+ card.subjectRect.width()));
+
+ // 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()) {
+ const int count = index.data(ThreadListModel::ReplyCountRole).toInt();
+ 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();
+
+ // Line 3: the chips. A thread card draws its own tags; a reply draws only
+ // the tags its thread does not already carry, so the thread's chips are
+ // not repeated down the whole expansion.
+ const bool isMessage =
+ index.data(ThreadListModel::IsMessageRole).toBool();
+ const QStringList tags =
+ index.data(isMessage ? ThreadListModel::MessageOwnTagsRole
+ : ThreadListModel::PillTagsRole)
+ .toStringList();
+ const QVariantList colours =
+ index.data(isMessage ? ThreadListModel::MessageOwnColoursRole
+ : ThreadListModel::PillColoursRole)
+ .toList();
+
+ const QFont chipFont = CardLayout::smallFont(chrome.font);
+ const QFontMetrics chipMetrics(chipFont);
+ 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));
+ 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);
+ x += size.width() + TagChip::kSpacing;
+ }
+ painter->restore();
+}
diff --git a/src/carddelegate.h b/src/carddelegate.h
new file mode 100644
index 0000000..7012eaa
--- /dev/null
+++ b/src/carddelegate.h
@@ -0,0 +1,72 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include "tagchip.h"
+
+/// Paints a whole card: three lines, all of it, including the tag chips.
+///
+/// It replaces both SubjectDelegate and ThreadListView::paintEvent. The view
+/// used to paint the tag strip because a delegate cannot paint outside its
+/// column and the strip spanned all five; with one column there is nothing to
+/// span, so the strip comes home to the delegate and the view stops painting
+/// entirely. That removes the two failure modes CLAUDE.md records for the
+/// strip, a deleted row cut in half and every other row showing a bare stripe,
+/// both of which existed because the view had to re-honour alternating
+/// colours, the selection and BackgroundRole across cells it did not own.
+///
+/// Inherits RowStyleDelegate for its one job, which still matters: Qt resolves
+/// Qt::ForegroundRole into the palette's Text roles and then prefers those over
+/// HighlightedText, so the read/unread dimming would otherwise win on a
+/// selected row and land as grey on the highlight colour.
+class CardDelegate : public RowStyleDelegate
+{
+ Q_OBJECT
+public:
+ using RowStyleDelegate::RowStyleDelegate;
+
+ void paint(QPainter *painter, const QStyleOptionViewItem &option,
+ const QModelIndex &index) const override;
+ QSize sizeHint(const QStyleOptionViewItem &option,
+ const QModelIndex &index) const override;
+
+ /// The expander's rect for a row, so the VIEW can hit-test a click without
+ /// duplicating the layout. The delegate draws it and the view owns the
+ /// click, because a delegate gets no click of its own without an editor.
+ static QRect expanderRectFor(const QStyleOptionViewItem &option,
+ const QModelIndex &index);
+
+ /// The colour the accent bar is painted in: the account's own, undiluted.
+ ///
+ /// Blending it toward the palette's Base was tried first, at the 0.35
+ /// weight threadLineColour() uses, and produced an INVISIBLE bar on a dark
+ /// theme: against a Base of (0.169, 0.169, 0.169) it landed at (0.18, 0.22,
+ /// 0.26), which is the background. The weight is a fraction OF THE ACCOUNT
+ /// COLOUR, so a low one keeps the background rather than the hue.
+ ///
+ /// An account colour is already chosen to be a chip's fill with legible
+ /// text on top, so it is muted to begin with; three pixels of a muted
+ /// colour is nothing. Nothing is drawn on this bar, so it needs none of the
+ /// contrast that choice was made for. The SPINE is where the muting belongs
+ /// and is blended in paint(): it runs the full height of every reply in an
+ /// expansion and has to be followable without competing with the senders.
+ ///
+ /// Falls back to threadLineColour() for a thread with no account tag.
+ static QColor accentLineColour(const QColor &accountColour);
+};
diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp
new file mode 100644
index 0000000..f542df0
--- /dev/null
+++ b/src/cardlayout.cpp
@@ -0,0 +1,186 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#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)
+{
+ QFont small = cardFont;
+ // Derived from the card's font rather than fixed, so it follows the
+ // desktop's font size instead of shrinking to nothing on a large one.
+ //
+ // pointSizeF() returns -1 for a font set in PIXELS, which qt6ct and some
+ // styles do. Subtracting from -1 would ask for an invalid size and Qt
+ // would silently keep the original, making the small font the same size as
+ // the card's; the pixel branch avoids that.
+ if (small.pointSizeF() > 0.0)
+ small.setPointSizeF(qMax(6.0, cardFont.pointSizeF() - 1.0));
+ else if (small.pixelSize() > 0)
+ small.setPixelSize(qMax(8, cardFont.pixelSize() - 1));
+ return small;
+}
+
+int CardLayout::heightFor(const QFont &font)
+{
+ const QFontMetrics metrics(font);
+ const QFontMetrics smallMetrics(smallFont(font));
+ // Two lines at the card's font, one at the small one, plus the padding
+ // above the first and below the last.
+ return kPaddingY * 2 + metrics.height() * 2 + smallMetrics.height();
+}
+
+CardLayout CardLayout::compute(const Input &input, const QRect &rect,
+ const QFont &font)
+{
+ CardLayout out;
+ const QFontMetrics metrics(font);
+ const QFontMetrics smallMetrics(smallFont(font));
+
+ out.totalHeight = rect.height();
+
+ // The accent bar sits flush against the card's left edge, on thread cards
+ // only, and everything else starts after it so no text sits on the colour.
+ if (!input.isMessage) {
+ out.accentRect =
+ QRect(rect.left(), rect.top(), kAccentWidth, rect.height());
+ }
+ const int textLeft = rect.left() + kAccentWidth;
+
+ // Indent, capped. qMin rather than a branch so depth 5 and depth 50 land
+ // in exactly the same place.
+ //
+ // A MESSAGE row is nested at least one level whatever depth it reports.
+ // notmuch numbers every message of a thread with no usable In-Reply-To as
+ // depth 0, so a flat thread's replies arrived here claiming no nesting and
+ // drew flush against their own thread with no spine, while a nested
+ // thread's replies indented normally: two different shapes on screen for
+ // the same relationship. Being a child row IS the nesting; the depth only
+ // says how much further to go.
+ const int effectiveDepth =
+ input.isMessage ? qMax(1, input.depth) : input.depth;
+ const int depth = qMin(effectiveDepth, kMaxDepth);
+ const int indent = depth * kIndentStep;
+ out.contentLeft = textLeft + kPaddingX + indent;
+
+ // One spine per level actually indented, each running the card's full
+ // height so an expansion reads as one continuous block.
+ for (int level = 0; level < depth; ++level) {
+ const int x = textLeft + kPaddingX + level * kIndentStep
+ + kIndentStep / 2;
+ out.spines.append(QRect(x, rect.top(), 2, rect.height()));
+ }
+
+ // The EXCLUSIVE right edge: one past the last pixel a card may draw on.
+ // QRect::right() is inclusive (left + width - 1), so building widths from
+ // it directly lands everything one pixel short of the intended padding.
+ const int right = rect.right() + 1 - kPaddingX;
+ const int lineOneTop = rect.top() + kPaddingY;
+ const int lineTwoTop = lineOneTop + metrics.height();
+ const int lineThreeTop = lineTwoTop + metrics.height();
+
+ // The date is measured first and the sender gets what is left, so a long
+ // sender is elided rather than painting over the date.
+ //
+ // Measured BOLD whatever font this is handed. An unread card draws bold and
+ // the delegate computes its layout from the view's regular font, so a rect
+ // sized regular clips a bold date: 154px reserved against 170px needed, one
+ // digit of the year gone from every unread card. Reserving the wider of the
+ // two costs a few pixels on a read card and cannot disagree with what is
+ // painted.
+ QFont dateFont = font;
+ dateFont.setBold(true);
+ const int dateWidth =
+ QFontMetrics(dateFont).horizontalAdvance(widestDateSample());
+ out.dateRect = QRect(right - dateWidth, lineOneTop, dateWidth,
+ metrics.height());
+ out.senderRect = QRect(out.contentLeft, lineOneTop,
+ qMax(0, out.dateRect.left() - out.contentLeft
+ - kPaddingX),
+ metrics.height());
+
+ // 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 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());
+ }
+
+ const int subjectRight = out.expanderRect.isEmpty()
+ ? right
+ : out.expanderRect.left() - kPaddingX;
+ out.subjectRect = QRect(out.contentLeft, lineTwoTop,
+ qMax(0, subjectRight - out.contentLeft),
+ metrics.height());
+
+ out.tagRect = QRect(out.contentLeft, lineThreeTop,
+ qMax(0, right - out.contentLeft),
+ smallMetrics.height());
+
+ return out;
+}
diff --git a/src/cardlayout.h b/src/cardlayout.h
new file mode 100644
index 0000000..ef6a563
--- /dev/null
+++ b/src/cardlayout.h
@@ -0,0 +1,150 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QDateTime>
+#include <QFont>
+#include <QRect>
+#include <QVector>
+
+/// Where everything on a card goes, with no painting and no widget.
+///
+/// Split out from CardDelegate on purpose. A delegate needs a live QPainter and
+/// an exposed view before it draws anything, which is what makes delegate tests
+/// fragile: CLAUDE.md records that viewport()->render() returns a blank image in
+/// several ordinary situations, and that a probe reporting "no ink anywhere" is
+/// far more likely broken than the code it is testing. Every geometric claim
+/// about a card is therefore made here, where a test is a function call.
+///
+/// The card is three lines, always:
+///
+/// sender ................................ date <- senderRect/dateRect
+/// * subject @ v 3 replies <- subjectRect/expanderRect
+/// [tag] [tag] <- tagRect
+struct CardLayout
+{
+ /// What the model says about the row. Deliberately plain data: the layout
+ /// must be computable in a test without a model or a view.
+ struct Input
+ {
+ bool isMessage = false;
+ int depth = 0; ///< 0 for a thread root, 1 for a direct reply.
+ int replyCount = 0; ///< 0 means no expander.
+ };
+
+ /// Width of the account accent bar down a thread card's left edge.
+ ///
+ /// A starting value, not a settled one. Five accounts is enough that two
+ /// colours distinct as chips can read alike as thin stripes, and that can
+ /// only be judged against real cards on the user's own screen and theme
+ /// (Task 10). Widen it there if the accounts are not tellable apart.
+ static constexpr int kAccentWidth = 3;
+
+ /// Horizontal breathing room at the card's edges, measured from the accent
+ /// bar rather than from the card, so text does not sit on the colour.
+ static constexpr int kPaddingX = 8;
+
+ /// Vertical breathing room above the first line and below the last.
+ static constexpr int kPaddingY = 4;
+
+ /// How far one level of reply nesting indents.
+ static constexpr int kIndentStep = 18;
+
+ /// The depth past which nothing indents further.
+ ///
+ /// A mailing-list chain can nest a dozen deep, and without a cap the
+ /// sender is eventually pushed off the right edge. Item 20 accepted that
+ /// deep chains must be capped in the VIEW rather than flattened in the
+ /// model, and this is that cap. Rows past it draw at this depth's indent
+ /// with no marker saying so.
+ static constexpr int kMaxDepth = 4;
+
+ QRect senderRect;
+ QRect dateRect;
+ QRect subjectRect;
+ QRect tagRect;
+
+ /// The reply count's rect, and the click target that toggles the thread.
+ /// Empty when the row has no replies.
+ QRect expanderRect;
+
+ /// The account accent bar down the card's left edge.
+ ///
+ /// Thread cards only. A reply's account is its thread's, stated once at the
+ /// head of the conversation, and a second vertical line in a reply's gutter
+ /// would sit a few pixels from the spine and compete with it. The spine
+ /// carries the accent instead, so an expansion is bounded by one colour
+ /// without ever drawing two lines. Empty on a reply.
+ QRect accentRect;
+
+ /// One full-height vertical line per depth level, outermost first.
+ QVector<QRect> spines;
+
+ /// Where the card's text starts, after any indent.
+ int contentLeft = 0;
+
+ int totalHeight = 0;
+
+ /// The height EVERY row gets, thread and reply alike.
+ ///
+ /// Uniform by design: it keeps setUniformRowHeights(true), which is the
+ /// single cheapest property of this layout, since no scrolling or
+ /// hit-testing arithmetic has to account for rows of differing size. The
+ /// cost is a blank third line on a card with no tags, which was accepted
+ /// explicitly.
+ static int heightFor(const QFont &font);
+
+ /// The font the tag chips and the reply count are drawn in: a size down
+ /// from the card's own, so they read as annotation rather than as a third
+ /// column of content.
+ static QFont smallFont(const QFont &cardFont);
+
+ 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/keymap.cpp b/src/keymap.cpp
index 29a5f71..cdd26a1 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -18,6 +18,8 @@
#include "keymap.h"
+#include <algorithm>
+
#include <QSettings>
QStringList KeyMap::knownActions()
@@ -64,6 +66,18 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
return {
{ QStringLiteral("Ctrl+J"), QStringLiteral("next_thread") },
{ QStringLiteral("Ctrl+K"), QStringLiteral("prev_thread") },
+ // Alt, because Shift+Up/Down is QTreeView's built-in extend-selection,
+ // which multi-row tagging depends on, and plain Up/Down is the view's
+ // own navigation, which already steps INTO an expanded thread's
+ // replies and is what gives message-to-message movement for free.
+ //
+ // These must stay chords. Every action is a QAction with
+ // WindowShortcut, dispatched before the focused widget sees the key,
+ // and Qt withholds only plain LETTERS from editable widgets: a bare
+ // Up bound here would break the arrow keys in the query bar, the tag
+ // dialog and the web view at once, exactly as Return did.
+ { QStringLiteral("Alt+Down"), QStringLiteral("next_thread") },
+ { QStringLiteral("Alt+Up"), QStringLiteral("prev_thread") },
{ QStringLiteral("Return"), QStringLiteral("open_thread") },
{ QStringLiteral("Ctrl+E"), QStringLiteral("archive") },
{ QStringLiteral("Ctrl+D"), QStringLiteral("delete") },
@@ -153,6 +167,28 @@ void KeyMap::loadDefaults()
m_bindings.insert(normalizeSequence(binding.first), binding.second);
}
+QList<QKeySequence> KeyMap::sequencesFor(const QString &action) const
+{
+ const QKeySequence primary = sequenceFor(action);
+ if (primary.isEmpty())
+ return {};
+
+ QList<QKeySequence> all{ primary };
+ QList<QKeySequence> rest;
+ for (auto it = m_bindings.cbegin(); it != m_bindings.cend(); ++it) {
+ if (it.value() == action && it.key() != primary)
+ rest.append(it.key());
+ }
+ // QHash iteration order is unspecified, so the tail is sorted rather than
+ // left to chance: an action's shortcut list must not reorder between runs.
+ std::sort(rest.begin(), rest.end(),
+ [](const QKeySequence &a, const QKeySequence &b) {
+ return a.toString() < b.toString();
+ });
+ all += rest;
+ return all;
+}
+
QKeySequence KeyMap::sequenceFor(const QString &action) const
{
// Several sequences can reach one action: the built-in default, which
diff --git a/src/keymap.h b/src/keymap.h
index 1c7df5f..f0addf5 100644
--- a/src/keymap.h
+++ b/src/keymap.h
@@ -55,6 +55,15 @@ public:
/// text, so the menu shows a stable choice rather than a hash-order one.
QKeySequence sequenceFor(const QString &action) const;
+ /// EVERY sequence bound to an action, with sequenceFor()'s choice first.
+ ///
+ /// An action can have more than one binding, and setShortcut() keeps only
+ /// the last: next_thread ships with both Ctrl+J and Alt+Down, and with the
+ /// singular setter whichever arrived second was silently unreachable.
+ /// Ordered rather than hash-ordered, so the menu still advertises the same
+ /// binding sequenceFor() chose.
+ QList<QKeySequence> sequencesFor(const QString &action) const;
+
/// The built-in sequence for an action, ignoring any user override.
static QKeySequence defaultSequenceFor(const QString &action);
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 11de1d4..b7efec5 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -42,7 +42,7 @@
#include <QSplitter>
#include <QStandardPaths>
#include <QStatusBar>
-#include <QTableView>
+#include <QScrollBar>
#include <QTimer>
#include <QToolBar>
#include <QVBoxLayout>
@@ -52,6 +52,8 @@
#include "mimeparser.h"
#include "notmuchworker.h"
#include "querycompleter.h"
+#include "carddelegate.h"
+#include "cardlayout.h"
#include "tagchip.h"
#include "tagdialog.h"
#include "threadlistmodel.h"
@@ -99,6 +101,40 @@ QString MainWindow::locksPath()
return g_locksPath;
}
+/// The thread row containing an index: the index itself when it is already a
+/// thread row, its parent when it is a message row.
+///
+/// Replaces the arithmetic on row numbers that a table permitted. In a tree a
+/// row number only identifies a row within one parent, so "current.row() + 1"
+/// means the next SIBLING, which under an expanded thread is the next reply.
+QModelIndex MainWindow::threadRowOf(const QModelIndex &index) const
+{
+ if (!index.isValid())
+ return {};
+ return index.parent().isValid() ? index.parent() : index;
+}
+
+/// Selects a whole row, the way QTableView::selectRow did.
+///
+/// QTreeView has no selectRow, and SelectRows on the selection model is not a
+/// substitute: it governs what a click extends to, not what a programmatic
+/// select() covers.
+void MainWindow::selectRowAt(const QModelIndex &index)
+{
+ if (!index.isValid())
+ return;
+
+ m_threadView->selectionModel()->select(
+ index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+ m_threadView->setCurrentIndex(index);
+}
+
+/// Selects the top-level thread row at `row`.
+void MainWindow::selectThreadRow(int row)
+{
+ selectRowAt(m_model->index(row, 0, QModelIndex()));
+}
+
void MainWindow::restoreUiState()
{
QSettings state(uiStatePath(), QSettings::IniFormat);
@@ -123,20 +159,15 @@ void MainWindow::restoreUiState()
m_splitter->restoreState(splitter);
}
- // A header blob saved against a different set of columns must be
- // discarded, not restored. QHeaderView::restoreState() returns TRUE for a
- // blob with fewer sections than the model and applies the old widths to
- // the wrong columns: adding the attachment column in front shifted every
- // saved width one place right, silently mangling the layout with no error
- // to detect it by (verified on Qt 6.11). The column count is stored
- // alongside and the blob is only used when it still matches.
- const QByteArray header = state.value(QStringLiteral("threadlist/header"))
- .toByteArray();
- const int savedColumns =
- state.value(QStringLiteral("threadlist/columns")).toInt();
- if (!header.isEmpty() && savedColumns == ThreadListModel::ColumnCount) {
- m_threadView->horizontalHeader()->restoreState(header);
- }
+ // No thread-list header state is read. The pane is one column drawn whole
+ // by CardDelegate, so there are no widths to restore; a blob saved by an
+ // older version is simply ignored (item 53's Upgrading note).
+
+ // Range-guarded on read: a stale or hand-edited file can hold anything,
+ // and setCurrentIndex() on a value with no row silently selects nothing.
+ const int sort =
+ state.value(QStringLiteral("threadlist/sortOrder"), 0).toInt();
+ m_sortOrder->setCurrentIndex(sort == 1 ? 1 : 0);
// The config value is the starting point for a profile that has never
// zoomed; once the user does, the state file is what they last had.
@@ -153,11 +184,8 @@ void MainWindow::saveUiState() const
state.setValue(QStringLiteral("window/geometry"), saveGeometry());
state.setValue(QStringLiteral("window/state"), saveState());
state.setValue(QStringLiteral("window/splitter"), m_splitter->saveState());
- state.setValue(QStringLiteral("threadlist/header"),
- m_threadView->horizontalHeader()->saveState());
- // Guards the blob above: see restoreUiState().
- state.setValue(QStringLiteral("threadlist/columns"),
- int(ThreadListModel::ColumnCount));
+ state.setValue(QStringLiteral("threadlist/sortOrder"),
+ m_sortOrder->currentIndex());
state.setValue(QStringLiteral("message/zoom"), m_messageView->zoomFactor());
}
@@ -289,8 +317,10 @@ MainWindow::MainWindow(const Config &config, QWidget *parent)
qRegisterMetaType<MessageRef>();
qRegisterMetaType<TagChange>();
qRegisterMetaType<DatabaseStats>();
+ qRegisterMetaType<MessageNode>();
qRegisterMetaType<QVector<ThreadSummary>>();
qRegisterMetaType<QVector<MessageRef>>();
+ qRegisterMetaType<QVector<MessageNode>>();
m_keyMap.loadDefaults();
{
@@ -397,9 +427,34 @@ void MainWindow::buildUi()
// Query row.
auto *queryRow = new QHBoxLayout;
m_accountBox = new QComboBox(central);
+ m_accountBox->setObjectName(QStringLiteral("accountBox"));
m_accountBox->addItem(tr("All accounts"), QString());
- for (const Account &account : m_config.accounts())
+ for (const Account &account : m_config.accounts()) {
m_accountBox->addItem(account.key, account.key);
+ // The RAW account colour here, not CardDelegate's blended line colour:
+ // a swatch is a filled patch like a chip, not a thin line, so it wants
+ // the colour the account was actually given. Qt renders a
+ // DecorationRole colour as a swatch itself, with no delegate.
+ //
+ // This is what makes the accent bar on a card mean anything: a colour
+ // down a card's edge says nothing until something maps it to a name.
+ m_accountBox->setItemData(
+ m_accountBox->count() - 1,
+ m_tagColors.colourFor(TagColors::tagForAccountKey(account.key)),
+ Qt::DecorationRole);
+ }
+
+ // Sort order. Two entries, straight to notmuch: this ADDS a feature rather
+ // than replacing one, since the old column header was decorative and
+ // nothing implemented click-to-sort.
+ m_sortOrder = new QComboBox(central);
+ m_sortOrder->setObjectName(QStringLiteral("sortOrder"));
+ // Order matters: the index is what uistate.conf stores.
+ m_sortOrder->addItem(tr("Newest first"));
+ m_sortOrder->addItem(tr("Oldest first"));
+ m_sortOrder->setToolTip(tr("The order threads are listed in"));
+ connect(m_sortOrder, &QComboBox::currentIndexChanged,
+ this, &MainWindow::runCurrentQuery);
m_queryEdit = new QLineEdit(central);
m_queryEdit->setPlaceholderText(tr("notmuch query, e.g. tag:inbox"));
@@ -488,6 +543,7 @@ void MainWindow::buildUi()
// would squeeze the field, but three is the real-world case today. Item 23
// already specifies buttons-plus-menu and is where that belongs.
queryRow->addWidget(m_accountBox);
+ queryRow->addWidget(m_sortOrder);
queryRow->addWidget(m_queryEdit, 1);
for (const SavedQuery &saved : m_config.savedQueries()) {
auto *button = new QPushButton(saved.name, central);
@@ -507,58 +563,48 @@ void MainWindow::buildUi()
// delegate is confined to one column's rectangle.
m_threadView = new ThreadListView(central);
m_threadView->setModel(m_model);
+ m_threadView->setItemDelegate(new CardDelegate(this));
+ m_threadView->setHeaderHidden(true);
m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows);
m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection);
- m_threadView->verticalHeader()->hide();
- m_threadView->horizontalHeader()->setStretchLastSection(false);
- // Every column Interactive, Subject included: Stretch and ResizeToContents
- // both compute a width and discard the user's drag. Nothing absorbs spare
- // width as a result, so the columns end where they end.
- for (int column = 0; column < ThreadListModel::ColumnCount; ++column) {
- m_threadView->horizontalHeader()->setSectionResizeMode(
- column, QHeaderView::Interactive);
- }
- // Two delegates, and the split is not cosmetic. RowStyleDelegate carries
- // only the selection fix every column needs: the read/unread dimming
- // arrives as a Qt::ForegroundRole, which Qt's painting prefers over the
- // highlight, leaving a selected read row grey on the selection colour.
- //
- // SubjectDelegate adds the account chip and the tag pills, and must go on
- // the subject column ALONE. It reads AccountLabelRole, a property of the
- // row rather than of a cell, so installed view-wide it draws the chip into
- // every column: tried once, and the list came out with a chip repeated
- // four times per row.
- m_threadView->setItemDelegate(new RowStyleDelegate(this));
- m_threadView->setItemDelegateForColumn(ThreadListModel::SubjectColumn,
- new SubjectDelegate(this));
-
- // One height for every row, set here rather than left to a column's
- // sizeHint: a QTableView takes a single height per row, so a hint from the
- // subject column alone would only apply if the view happened to ask it.
- m_threadView->verticalHeader()->setDefaultSectionSize(
- SubjectDelegate::rowHeightFor(m_threadView->font()));
- // Widening a column past the viewport scrolls rather than squeezing the
- // others. Per-pixel so the scroll does not jump a whole column at a time.
- // Banding, so the eye can follow a row across four columns and a pill
- // strip without losing it. The colour comes from the palette's
- // AlternateBase, so it follows the desktop theme.
+ // No style-drawn branch decoration. CardDelegate draws the expander itself,
+ // because drawBranches runs BEFORE the row's cells and the delegate's own
+ // background paints straight over anything put there: a 60-pixel triangle
+ // once survived as 8. Leaving both enabled would draw the theme's dot
+ // underneath the delegate's glyph.
+ m_threadView->setRootIsDecorated(false);
+
+ // Zero, because CardLayout draws the indent itself. Qt's own indentation
+ // would shift the card's rect, and every rect on the card is measured from
+ // that rect's left edge, so the two would compound.
+ m_threadView->setIndentation(0);
+
+ // One height for every row. A QTreeView has no vertical header to carry a
+ // default section size, so the height comes from uniformRowHeights plus
+ // CardDelegate::sizeHint.
+ m_threadView->setUniformRowHeights(true);
+
+ // Banding, so the eye can follow a card across the pane. The colour comes
+ // from the palette's AlternateBase, so it follows the desktop theme.
m_threadView->setAlternatingRowColors(true);
- m_threadView->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
- m_threadView->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);
-
- // Starting widths only; a drag overrides them, and they are what the
- // saved-widths item will persist.
- // Without this the attachment column cannot be narrow at all: the default
- // minimum section size is 58px on this platform, and setColumnWidth()
- // clamps to it silently rather than reporting the smaller value back.
- m_threadView->horizontalHeader()->setMinimumSectionSize(24);
- m_threadView->setColumnWidth(ThreadListModel::AttachmentColumn, 28);
- m_threadView->setColumnWidth(ThreadListModel::FlagColumn, 28);
- m_threadView->setColumnWidth(ThreadListModel::DateColumn, 130);
- m_threadView->setColumnWidth(ThreadListModel::AuthorsColumn, 180);
- m_threadView->setColumnWidth(ThreadListModel::SubjectColumn, 520);
+ // A card is exactly viewport width, so there is nothing to scroll to
+ // sideways. Turning the bar off is what closes item 51: a click used to
+ // scroll the list horizontally, because the subject column was wider than
+ // the viewport and auto-scroll brought the clicked index fully into view.
+ m_threadView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+
+ // Scrolling a whole card at a time rather than a fraction of one, so a
+ // card is never left half above the top edge.
+ m_threadView->verticalScrollBar()->setSingleStep(
+ CardLayout::heightFor(m_threadView->font()));
+
+ // Replies are loaded when a thread is expanded, not with the query.
+ // Walking the reply tree of every thread in a 10k-thread result would cost
+ // far more than the query itself and almost none of it would be looked at.
+ connect(m_threadView, &QTreeView::expanded,
+ this, &MainWindow::onThreadExpanded);
connect(m_threadView->selectionModel(),
&QItemSelectionModel::currentRowChanged,
@@ -617,9 +663,11 @@ QAction *MainWindow::addAction(const QString &name, const QString &text,
// The binding comes from KeyMap, so a [keys] override reaches the menus
// and the shortcut reference as well as the keyboard.
- const QKeySequence sequence = m_keyMap.sequenceFor(name);
- if (!sequence.isEmpty())
- action->setShortcut(sequence);
+ // Plural: an action can carry more than one binding, and setShortcut()
+ // keeps only the last one given. next_thread has both Ctrl+J and Alt+Down.
+ const QList<QKeySequence> sequences = m_keyMap.sequencesFor(name);
+ if (!sequences.isEmpty())
+ action->setShortcuts(sequences);
// Shortcuts must work while focus is in the thread list or the message
// view, not only on the window itself.
@@ -643,16 +691,32 @@ void MainWindow::registerActions()
});
addAction(QStringLiteral("next_thread"), tr("&Next thread"),
tr("Select the next thread"), [this]() {
- const QModelIndex current = m_threadView->currentIndex();
- const int row = current.isValid() ? current.row() + 1 : 0;
- if (row < m_model->rowCount())
- m_threadView->selectRow(row);
+ // Walked by INDEX, never by row number. A tree numbers rows per
+ // parent, so current.row() + 1 names a SIBLING: from the last reply of
+ // an expanded thread it asks for a row that does not exist, and from a
+ // thread row it counts top-level threads only by accident (item 60).
+ //
+ // The skip loop is what keeps this meaning thread-to-thread while the
+ // view's own Up/Down still steps message-to-message.
+ QModelIndex index = m_threadView->indexBelow(
+ m_threadView->currentIndex());
+ while (index.isValid()
+ && index.data(ThreadListModel::IsMessageRole).toBool()) {
+ index = m_threadView->indexBelow(index);
+ }
+ if (index.isValid())
+ selectRowAt(index);
});
addAction(QStringLiteral("prev_thread"), tr("&Previous thread"),
tr("Select the previous thread"), [this]() {
- const QModelIndex current = m_threadView->currentIndex();
- if (current.isValid() && current.row() > 0)
- m_threadView->selectRow(current.row() - 1);
+ QModelIndex index = m_threadView->indexAbove(
+ m_threadView->currentIndex());
+ while (index.isValid()
+ && index.data(ThreadListModel::IsMessageRole).toBool()) {
+ index = m_threadView->indexAbove(index);
+ }
+ if (index.isValid())
+ selectRowAt(index);
});
addAction(QStringLiteral("open_thread"), tr("&Open thread"),
tr("Focus the thread list"), [this]() {
@@ -802,6 +866,7 @@ void MainWindow::registerActions()
// thread straight back, which is the queued-reply race documented in
// CLAUDE.md.
m_currentThreadId.clear();
+ m_currentMessageId.clear();
m_messageView->clear();
showPlaceholderPane();
m_markReadTimer->stop();
@@ -835,6 +900,7 @@ void MainWindow::registerActions()
m_threadView->setCurrentIndex(QModelIndex());
m_currentThreadId.clear();
+ m_currentMessageId.clear();
m_messageView->clear();
showPlaceholderPane();
m_markReadTimer->stop();
@@ -985,7 +1051,7 @@ void MainWindow::buildMenus()
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("select_all")));
m_threadView->setContextMenuPolicy(Qt::CustomContextMenu);
- connect(m_threadView, &QTableView::customContextMenuRequested,
+ connect(m_threadView, &QWidget::customContextMenuRequested,
this, &MainWindow::showThreadContextMenu);
// The frequent subset only. A toolbar holding every action is as
@@ -1245,6 +1311,10 @@ void MainWindow::wireWorker()
this, &MainWindow::onThreadsReady);
connect(m_worker, &NotmuchWorker::queryFinished,
this, &MainWindow::onQueryFinished);
+ connect(m_worker, &NotmuchWorker::threadTreeLoaded,
+ this, &MainWindow::onThreadTreeLoaded);
+ connect(m_worker, &NotmuchWorker::messageLoaded,
+ this, &MainWindow::onMessageLoaded);
connect(m_worker, &NotmuchWorker::threadLoaded,
this, &MainWindow::onThreadLoaded);
connect(m_worker, &NotmuchWorker::errorOccurred,
@@ -1429,9 +1499,13 @@ void MainWindow::runCurrentQuery()
m_queryComplete = false;
updateViewWideActions();
+ const auto sort = m_sortOrder->currentIndex() == 1
+ ? NotmuchWorker::OldestFirst
+ : NotmuchWorker::NewestFirst;
QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection,
Q_ARG(QString, query),
- Q_ARG(quint64, m_generation));
+ Q_ARG(quint64, m_generation),
+ Q_ARG(NotmuchWorker::SortOrder, sort));
}
void MainWindow::onThreadsReady(const QVector<ThreadSummary> &threads,
@@ -1519,41 +1593,90 @@ void MainWindow::showThreadContextMenu(const QPoint &pos)
// collapsing to the clicked row here would silently narrow a deliberate
// multi-row selection to one. Right-clicking outside it selects that row
// instead, which is what every other list does.
- if (!m_threadView->selectionModel()->isRowSelected(index.row()))
- m_threadView->selectRow(index.row());
+ if (!m_threadView->selectionModel()->isSelected(index))
+ selectRowAt(index);
m_threadContextMenu->popup(m_threadView->viewport()->mapToGlobal(pos));
}
void MainWindow::onSelectionChanged()
{
- const int selected = m_threadView->selectionModel()->selectedRows().size();
- if (selected <= 1) {
- // Clearing the count here would wipe whatever the last action reported
- // ("Archive: 3 threads"), which is the more useful message once the
- // selection is gone. Only a count this function wrote is taken back.
- if (m_statusLabel->text() == m_selectionMessage)
- m_statusLabel->clear();
- m_selectionMessage.clear();
+ const QModelIndexList rows = m_threadView->selectionModel()->selectedRows();
+ const int selected = rows.size();
+ if (selected == 1) {
+ // One row selected. With two kinds of row this is exactly where the
+ // scope became ambiguous: a thread root stands for every message in it,
+ // a message row for one, and the keypress looks identical. Naming it
+ // here is what this project does instead of a confirmation dialog,
+ // which CLAUDE.md rules out for tag mutations.
+ const ActionScope scope = m_model->scopeFor(rows);
+
+ if (scope.wholeThread) {
+ m_selectionMessage =
+ tr("1 thread selected (%n message(s))", "", scope.messageCount);
+ m_statusLabel->setText(m_selectionMessage);
+ m_statusTimer->stop();
+ m_transientMessage.clear();
+ } else {
+ // Reading one message is not a bulk action and gets no count.
+ if (m_statusLabel->text() == m_selectionMessage)
+ m_statusLabel->clear();
+ m_selectionMessage.clear();
+ }
- // Collapsing a multi-row selection back to one row has to load that
- // row here, and cannot be left to onThreadSelected. currentRowChanged
- // is emitted BEFORE the selection model is updated (verified against
- // Qt 6.11), so when a click collapses three rows to one, that handler
- // still sees three selected, takes the multi-select branch and returns
- // without loading anything. Only this signal sees the real count.
+ // Collapsing a multi-row selection back to one row has to load that row
+ // here, and cannot be left to onThreadSelected: currentRowChanged is
+ // emitted BEFORE the selection model is updated (verified against
+ // 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.
const QModelIndex current = m_threadView->currentIndex();
- if (current.isValid()
- && m_model->threadAt(current.row()).threadId != m_currentThreadId) {
- onThreadSelected(current, QModelIndex());
+ 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;
+ if (changed)
+ onThreadSelected(current, QModelIndex());
}
return;
}
+ if (selected < 1) {
+ // Nothing selected. Clearing unconditionally would wipe whatever the
+ // last action reported ("Archive: 3 threads"), which is the more useful
+ // message once the selection is gone, so only a count this function
+ // wrote is taken back.
+ if (m_statusLabel->text() == m_selectionMessage)
+ m_statusLabel->clear();
+ m_selectionMessage.clear();
+ return;
+ }
+
// The count is the part that actually teaches multi-select: it acknowledges
// the selection while it is being built, rather than only after an action
// has already been applied to it.
- m_selectionMessage = tr("%n thread(s) selected", "", selected);
+ //
+ // Reported per row kind rather than as a bare row count, so a mixed
+ // selection says what it will really touch instead of calling three replies
+ // "3 threads".
+ const ActionScope scope = m_model->scopeFor(rows);
+ if (!scope.threadIds.isEmpty() && scope.messageIds.isEmpty()) {
+ m_selectionMessage =
+ tr("%n thread(s) selected (%1 messages)", "", scope.threadIds.size())
+ .arg(scope.messageCount);
+ } else if (scope.threadIds.isEmpty()) {
+ m_selectionMessage =
+ tr("%n message(s) selected", "", scope.messageIds.size());
+ } else {
+ m_selectionMessage =
+ tr("%n thread(s) and %1 message(s) selected", "",
+ scope.threadIds.size()).arg(scope.messageIds.size());
+ }
m_statusLabel->setText(m_selectionMessage);
// State, not an event: it must persist while the selection does. Cancel any
@@ -1568,6 +1691,7 @@ void MainWindow::onSelectionChanged()
m_markReadTimer->stop();
m_markReadThreadId.clear();
m_currentThreadId.clear();
+ m_currentMessageId.clear();
m_messageView->clear();
showPlaceholderPane();
}
@@ -1599,21 +1723,121 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
m_markReadTimer->stop();
m_markReadThreadId.clear();
m_currentThreadId.clear();
+ m_currentMessageId.clear();
m_messageView->clear();
showPlaceholderPane();
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.
+ 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.
+ m_markReadTimer->stop();
+ m_markReadThreadId.clear();
+
+ m_currentThreadId.clear();
+ m_currentMessageId = node.messageId;
+ m_messageView->setTags(node.tags);
+ 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());
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),
Q_ARG(quint64, m_generation));
}
+void MainWindow::onMessageLoaded(const QVector<MessageRef> &messages,
+ quint64 generation)
+{
+ // The same two guards onThreadLoaded carries. A stale generation means the
+ // query moved on, and a reply landing after the selection grew past one row
+ // would paint a message back over a deliberately blanked pane.
+ if (generation != m_generation || messages.isEmpty())
+ return;
+ if (m_threadView->selectionModel()->selectedRows().size() > 1)
+ return;
+
+ // A third guard this one needs and onThreadLoaded does not: a reply that
+ // lands after the selection moved to a THREAD row would render one message
+ // where the whole conversation belongs.
+ if (m_currentMessageId.isEmpty())
+ return;
+
+ onThreadLoaded(messages, generation);
+}
+
+void MainWindow::onThreadExpanded(const QModelIndex &index)
+{
+ if (!index.isValid() || m_model->isMessageRow(index))
+ return;
+
+ const QString threadId =
+ m_model->data(index, ThreadListModel::ThreadIdRole).toString();
+ if (threadId.isEmpty())
+ return;
+
+ QMetaObject::invokeMethod(m_worker, "loadThreadTree", Qt::QueuedConnection,
+ Q_ARG(QString, threadId),
+ Q_ARG(QString, m_lastQuery),
+ Q_ARG(quint64, m_generation));
+}
+
+void MainWindow::onThreadTreeLoaded(const QVector<MessageNode> &nodes,
+ quint64 generation)
+{
+ // The same generation guard every other worker reply carries: an expansion
+ // whose query has since been replaced must not insert rows into the new
+ // result, where that thread may not even appear.
+ if (generation != m_generation || nodes.isEmpty())
+ return;
+
+ // Every node in one reply belongs to one thread, so the first one names it.
+ // Read from the node rather than remembered from the request: two
+ // expansions can be in flight at once, and pairing them by order would
+ // attach one thread's replies to the other.
+ m_model->setThreadMessages(nodes.first().threadId, nodes);
+}
+
void MainWindow::onThreadLoaded(const QVector<MessageRef> &messages,
quint64 generation)
{
@@ -2297,20 +2521,83 @@ void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
if (rows.isEmpty())
return;
- QStringList threadIds;
- threadIds.reserve(rows.size());
- for (const QModelIndex &index : rows)
- threadIds.append(m_model->threadAt(index.row()).threadId);
+ // Resolved through the model rather than by mapping rows to threads here.
+ // 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);
+ if (scope.isEmpty())
+ return;
- sendThreadTagChange(threadIds, add, remove, description);
+ if (!scope.threadIds.isEmpty()) {
+ sendThreadTagChange(scope.threadIds, add, remove, description);
- // Pushed for undo. The inverse re-resolves the same threads, so it works
- // whether or not those rows are still selected.
- m_undoStack.push(new ThreadTagCommand(this, threadIds, add, remove,
- description));
+ // Pushed for undo. The inverse re-resolves the same threads, so it
+ // works whether or not those rows are still selected.
+ m_undoStack.push(new ThreadTagCommand(this, scope.threadIds, add,
+ remove, description));
+ }
+
+ if (!scope.messageIds.isEmpty()) {
+ sendMessageTagChange(scope.messageIds, add, remove, description);
+ m_undoStack.push(new MessageTagCommand(this, scope.messageIds, add,
+ remove, description));
+ }
+ // The scope named after the fact, since the selection may well be gone by
+ // the time the user reads it. This is what stands in for the confirmation
+ // dialog CLAUDE.md rules out: undo is the safety net, and undo is only
+ // usable if the user can tell that something larger than they meant has
+ // just happened.
showTransientStatus(
- tr("%1: %n thread(s)", "", threadIds.size()).arg(description));
+ scope.wholeThread
+ ? tr("%1: %n message(s) (whole thread)", "", scope.messageCount)
+ .arg(description)
+ : tr("%1: %n message(s)", "", scope.messageCount).arg(description));
+}
+
+void MainWindow::sendMessageTagChange(const QStringList &messageIds,
+ const QStringList &add,
+ const QStringList &remove,
+ const QString &description)
+{
+ 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.
+
+ // The accounts this touches, resolved through the containing threads: the
+ // account is a property of the thread, and the sync needs the channel
+ // whether one message moved or seven.
+ for (const QString &messageId : messageIds) {
+ const QString threadId = m_model->threadIdForMessage(messageId);
+ if (threadId.isEmpty())
+ continue;
+ for (const QString &key : m_model->accountKeysForThread(threadId))
+ m_editedAccounts.insert(key);
+ }
+
+ // Held during a sync for exactly the reason the thread path is: the
+ // worker's read-write open BLOCKS on notmuch's exclusive lock rather than
+ // failing, so sending now would freeze the worker for the rest of the run.
+ if (aSyncHoldsTheWriteLock()) {
+ m_heldEdits.append(HeldEdit{
+ {}, TagChange{ messageIds, add, remove, description } });
+ m_statusLabel->setText(
+ tr("A sync is running; your change will be applied when it "
+ "finishes."));
+ updatePendingIndicator();
+ return;
+ }
+
+ m_pendingThreadIds.clear();
+ m_pendingChange = TagChange{ messageIds, add, remove, description };
+
+ QMetaObject::invokeMethod(m_worker, "applyTags", Qt::QueuedConnection,
+ Q_ARG(TagChange, m_pendingChange));
}
void MainWindow::sendThreadTagChange(const QStringList &threadIds,
diff --git a/src/mainwindow.h b/src/mainwindow.h
index d8401a3..ccac5ad 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -41,7 +41,7 @@
class QAction;
class QLineEdit;
class QMenu;
-class QTableView;
+class ThreadListView;
class QLabel;
class QPushButton;
class QComboBox;
@@ -129,6 +129,19 @@ public:
/// command was pushed, which is what "this did nothing" has to assert.
int undoDepthForTesting() const { return m_undoStack.count(); }
+ /// The ids the last tag change was sent for, and whether they were thread
+ /// ids or message ids.
+ ///
+ /// Exposed because the difference is invisible from outside otherwise: a
+ /// message row routed down the thread path produces the same undo depth and
+ /// the same status text while tagging every sibling in the thread. A
+ /// mutation that made exactly that change passed the whole suite.
+ QStringList pendingThreadIdsForTesting() const { return m_pendingThreadIds; }
+ QStringList pendingMessageIdsForTesting() const
+ {
+ return m_pendingChange.messageIds;
+ }
+
/// The generation a worker reply must carry to be accepted.
///
/// A test seam: onQueryFinished() discards a reply whose generation is
@@ -164,6 +177,17 @@ private slots:
/// the click lands inside.
void showThreadContextMenu(const QPoint &pos);
void onThreadLoaded(const QVector<MessageRef> &messages, quint64 generation);
+
+ /// Asks the worker for a thread's reply tree when its row is expanded.
+ void onThreadExpanded(const QModelIndex &index);
+
+ /// Fills in the expanded thread's message rows.
+ void onThreadTreeLoaded(const QVector<MessageNode> &nodes,
+ quint64 generation);
+
+ /// Renders the single message a message row asked for.
+ void onMessageLoaded(const QVector<MessageRef> &messages,
+ quint64 generation);
void onWorkerError(const QString &message);
void onSyncFinished(bool success, int exitCode);
@@ -212,6 +236,15 @@ private:
/// A missing or rejected blob leaves the buildUi() defaults in place.
void restoreUiState();
+ /// The thread row containing an index: itself for a thread row, its parent
+ /// for a message row.
+ QModelIndex threadRowOf(const QModelIndex &index) const;
+
+ /// Selects a whole row. QTreeView has no selectRow of its own.
+ void selectRowAt(const QModelIndex &index);
+
+ /// Selects the top-level thread row at `row`.
+ void selectThreadRow(int row);
void saveUiState() const;
void registerActions();
@@ -335,6 +368,13 @@ private:
const QStringList &remove,
const QString &description);
+ /// The same for individual MESSAGES, without touching the undo stack.
+ /// Both tagSelected() and MessageTagCommand route through this.
+ void sendMessageTagChange(const QStringList &messageIds,
+ const QStringList &add,
+ const QStringList &remove,
+ const QString &description);
+
/// Undoes the optimistic model update for a write the worker rejected.
void revertPendingTagChange();
@@ -368,6 +408,7 @@ private:
QVector<HeldEdit> m_heldEdits;
friend class ThreadTagCommand;
+ friend class MessageTagCommand;
Config m_config;
KeyMap m_keyMap;
@@ -410,7 +451,10 @@ private:
QLineEdit *m_queryEdit = nullptr;
QueryCompleter *m_queryCompleter = nullptr;
- QTableView *m_threadView = nullptr;
+ /// Its own type, not the QTreeView base. The strip painting and the
+ /// expander column are ThreadListView's, and holding the base here only
+ /// hid that from every reader.
+ ThreadListView *m_threadView = nullptr;
/// Right-click menu for the thread list, holding the same QActions the
/// menu bar does.
@@ -424,6 +468,7 @@ private:
/// placeholder's own text wraps every couple of words.
static constexpr int kMinMessagePaneWidth = 300;
QComboBox *m_accountBox = nullptr;
+ QComboBox *m_sortOrder = nullptr;
QLabel *m_statusLabel = nullptr;
/// Expires a transient status message. See showTransientStatus().
@@ -495,6 +540,11 @@ private:
QString m_lastQuery;
QString m_currentThreadId;
+ /// The message a MESSAGE row is showing, empty whenever the pane holds a
+ /// whole thread. The two are mutually exclusive and each clears the other,
+ /// so a late reply can tell which kind of selection it belongs to.
+ QString m_currentMessageId;
+
/// The selection count last written to the status bar, so it can be taken
/// back without clobbering a message some other action put there.
QString m_selectionMessage;
@@ -601,3 +651,47 @@ private:
QString m_description;
bool m_firstRedo = true;
};
+
+/// Undo entry for a tag change over individual MESSAGES.
+///
+/// Stores message ids, unlike ThreadTagCommand, and that difference is the
+/// point rather than an inconsistency: a message row acts on one message, so
+/// re-resolving its thread on undo would restore tags across every sibling the
+/// action never touched.
+class MessageTagCommand : public QUndoCommand
+{
+public:
+ MessageTagCommand(MainWindow *window, const QStringList &messageIds,
+ const QStringList &add, const QStringList &remove,
+ const QString &description)
+ : QUndoCommand(description), m_window(window),
+ m_messageIds(messageIds), m_add(add), m_remove(remove),
+ m_description(description) {}
+
+ /// The stack calls redo() when the command is pushed, by which point the
+ /// change has already been sent, so the first call is skipped.
+ void redo() override
+ {
+ if (m_firstRedo) {
+ m_firstRedo = false;
+ return;
+ }
+ m_window->sendMessageTagChange(m_messageIds, m_add, m_remove,
+ m_description);
+ }
+
+ void undo() override
+ {
+ m_window->sendMessageTagChange(
+ m_messageIds, m_remove, m_add,
+ QStringLiteral("Undo %1").arg(m_description));
+ }
+
+private:
+ MainWindow *m_window;
+ QStringList m_messageIds;
+ QStringList m_add;
+ QStringList m_remove;
+ QString m_description;
+ bool m_firstRedo = true;
+};
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 752a52c..d34c032 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -69,11 +69,75 @@ bool collectMessageIds(notmuch_database_t *db, const QString &query,
return true;
}
+/// Walks a thread's reply structure depth-first, appending each message with
+/// its depth.
+///
+/// Takes RAW notmuch_message_t*, deliberately, against the rule that every
+/// handle in this file is RAII-owned. Messages reached through a thread belong
+/// to that thread and are freed with it (notmuch.h:1637), so wrapping one in
+/// NmMessage would call notmuch_message_destroy on memory the thread frees
+/// again. The NmThread in the caller is what keeps every pointer here alive,
+/// and this must not outlive it.
+///
+/// No match-set argument, unlike loadThread. A row is drawn for every message
+/// in the thread regardless of the query: the list is where the user goes to
+/// SEE the thread's shape, and hiding replies that did not match would make the
+/// reply count disagree with the rows beneath it.
+void walkReplies(notmuch_messages_t *messages, int depth,
+ QVector<MessageNode> *out)
+{
+ for (; notmuch_messages_valid(messages);
+ notmuch_messages_move_to_next(messages)) {
+
+ notmuch_message_t *message = notmuch_messages_get(messages);
+ if (!message)
+ continue;
+
+ MessageNode node;
+ node.messageId =
+ QString::fromUtf8(notmuch_message_get_message_id(message));
+ node.threadId =
+ QString::fromUtf8(notmuch_message_get_thread_id(message));
+ node.filePath =
+ QString::fromUtf8(notmuch_message_get_filename(message));
+ node.from =
+ QString::fromUtf8(notmuch_message_get_header(message, "from"));
+ node.subject =
+ QString::fromUtf8(notmuch_message_get_header(message, "subject"));
+ node.date =
+ QDateTime::fromSecsSinceEpoch(notmuch_message_get_date(message));
+ node.tags = tagsOf(message);
+ node.depth = depth;
+ out->append(node);
+
+ // NULL is a legitimate "no replies" here: notmuch_messages_valid
+ // accepts it and returns FALSE (notmuch.h:1630), so a leaf needs no
+ // guard of its own.
+ walkReplies(notmuch_message_get_replies(message), depth + 1, out);
+ }
+}
+
} // namespace
+/// Registers SortOrder for queued calls, once, before main() runs.
+///
+/// Q_ENUM alone is NOT enough for a queued Q_ARG: it gives the enum a
+/// meta-object entry, not a metatype registered under the name invokeMethod
+/// resolves, so MainWindow's queued runQuery would drop its sort argument at
+/// runtime with a warning and every query would silently run newest-first.
+///
+/// Here rather than in MainWindow's constructor, because the registration
+/// belongs to the type rather than to one consumer: a caller that never
+/// constructs a MainWindow (a test, or a future headless mode) needs it too,
+/// and that is exactly how the first attempt at this passed by accident and
+/// failed under test.
+static const int kSortOrderMetaType =
+ qRegisterMetaType<NotmuchWorker::SortOrder>("NotmuchWorker::SortOrder");
+
NotmuchWorker::NotmuchWorker(const QString &notmuchConfigPath, QObject *parent)
: QObject(parent), m_configPath(notmuchConfigPath)
{
+ Q_UNUSED(kSortOrderMetaType);
}
NotmuchWorker::~NotmuchWorker()
@@ -122,7 +186,8 @@ void NotmuchWorker::close()
}
}
-void NotmuchWorker::runQuery(const QString &query, quint64 generation)
+void NotmuchWorker::runQuery(const QString &query, quint64 generation,
+ SortOrder sort)
{
if (!openReadOnly())
return;
@@ -132,7 +197,9 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation)
emit errorOccurred(QStringLiteral("Invalid query: %1").arg(query));
return;
}
- notmuch_query_set_sort(nmQuery.get(), NOTMUCH_SORT_NEWEST_FIRST);
+ notmuch_query_set_sort(nmQuery.get(),
+ sort == OldestFirst ? NOTMUCH_SORT_OLDEST_FIRST
+ : NOTMUCH_SORT_NEWEST_FIRST);
notmuch_threads_t *rawThreads = nullptr;
const notmuch_status_t status =
@@ -242,6 +309,99 @@ void NotmuchWorker::loadThread(const QString &threadId,
emit threadLoaded(result, generation);
}
+void NotmuchWorker::loadThreadTree(const QString &threadId,
+ const QString &matchQuery,
+ quint64 generation)
+{
+ // Accepted for signature symmetry with loadThread, and unused on purpose:
+ // see walkReplies on why every message in the thread gets a row.
+ Q_UNUSED(matchQuery);
+
+ if (!openReadOnly())
+ return;
+
+ const QString query = QStringLiteral("thread:%1").arg(threadId);
+ NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData()));
+ if (!nmQuery) {
+ emit errorOccurred(
+ QStringLiteral("Cannot load thread %1").arg(threadId));
+ return;
+ }
+
+ // search_threads, not search_messages. The messages have to come from a
+ // notmuch_thread_t or notmuch_message_get_replies returns NULL for every
+ // one of them and the walk below produces a flat list at depth 0.
+ notmuch_threads_t *rawThreads = nullptr;
+ if (notmuch_query_search_threads(nmQuery.get(), &rawThreads)
+ != NOTMUCH_STATUS_SUCCESS) {
+ emit errorOccurred(
+ QStringLiteral("Cannot search thread %1").arg(threadId));
+ return;
+ }
+ NmThreads threads(rawThreads);
+
+ QVector<MessageNode> nodes;
+ if (notmuch_threads_valid(threads.get())) {
+ // Held for the whole walk: every message pointer inside belongs to this
+ // thread and dies with it.
+ NmThread thread(notmuch_threads_get(threads.get()));
+ if (thread) {
+ walkReplies(notmuch_thread_get_toplevel_messages(thread.get()), 0,
+ &nodes);
+ }
+ }
+
+ emit threadTreeLoaded(nodes, generation);
+}
+
+void NotmuchWorker::loadMessage(const QString &messageId, quint64 generation)
+{
+ if (!openReadOnly())
+ return;
+
+ // id: is an exact-match prefix, and the id is quoted because a message id
+ // can legitimately contain characters notmuch's parser would otherwise read
+ // as query syntax.
+ const QString query = QStringLiteral("id:\"%1\"").arg(messageId);
+ NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData()));
+ if (!nmQuery) {
+ emit errorOccurred(
+ QStringLiteral("Cannot load message %1").arg(messageId));
+ return;
+ }
+
+ notmuch_messages_t *rawMessages = nullptr;
+ if (notmuch_query_search_messages(nmQuery.get(), &rawMessages)
+ != NOTMUCH_STATUS_SUCCESS) {
+ emit errorOccurred(
+ QStringLiteral("Cannot search message %1").arg(messageId));
+ return;
+ }
+ NmMessages messages(rawMessages);
+
+ QVector<MessageRef> result;
+ if (notmuch_messages_valid(messages.get())) {
+ NmMessage message(notmuch_messages_get(messages.get()));
+ if (message) {
+ MessageRef ref;
+ ref.messageId = QString::fromUtf8(
+ notmuch_message_get_message_id(message.get()));
+ ref.filePath = QString::fromUtf8(
+ notmuch_message_get_filename(message.get()));
+ ref.tags = tagsOf(message.get());
+
+ // Always matched: the user asked for this message by clicking its
+ // row, so rendering it as a stub would answer the wrong question.
+ ref.matched = true;
+ result.append(ref);
+ }
+ }
+
+ // Emitted even when empty, so the UI's handler runs and can decide what to
+ // do rather than waiting for a reply that never comes.
+ emit messageLoaded(result, generation);
+}
+
void NotmuchWorker::applyTagsToThreads(const QStringList &threadIds,
const QStringList &add,
const QStringList &remove,
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 7d6a587..1d8c8c0 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -44,10 +44,24 @@ public:
/// Threads emitted per threadsReady() signal.
static constexpr int kBatchSize = 200;
+ /// The sort orders offered to the user.
+ ///
+ /// Two, not four. notmuch also has NOTMUCH_SORT_MESSAGE_ID and
+ /// NOTMUCH_SORT_UNSORTED, and neither is an order a human wants. Sorting
+ /// by sender or subject is deliberately absent: notmuch cannot do it, so
+ /// the model would have to sort after results arrive, which fights the
+ /// batching that makes a 10k-thread query paint immediately.
+ enum SortOrder {
+ NewestFirst,
+ OldestFirst,
+ };
+ Q_ENUM(SortOrder)
+
public slots:
/// Runs a query. generation lets the UI discard results from a superseded
/// query without the worker needing to know about cancellation.
- void runQuery(const QString &query, quint64 generation);
+ void runQuery(const QString &query, quint64 generation,
+ SortOrder sort = NewestFirst);
/// Loads the messages of one thread, oldest first. matchQuery is the
/// user's current query; messages matching it render expanded, the rest
@@ -55,6 +69,28 @@ public slots:
void loadThread(const QString &threadId, const QString &matchQuery,
quint64 generation);
+ /// Loads a thread as a reply TREE, for the message rows in the list.
+ ///
+ /// Separate from loadThread rather than replacing it, for a reason that is
+ /// not stylistic: loadThread walks notmuch_query_search_messages, and a
+ /// message obtained that way returns NULL from
+ /// notmuch_message_get_replies (notmuch.h:1617-1628), so that walk cannot
+ /// produce reply depth at all. The tree has to come from
+ /// notmuch_thread_get_toplevel_messages instead. The message pane still
+ /// wants the flat list; only the list wants the tree.
+ ///
+ /// matchQuery is accepted for signature symmetry with loadThread and is
+ /// deliberately unused: see the comment on the walk in the .cpp.
+ void loadThreadTree(const QString &threadId, const QString &matchQuery,
+ quint64 generation);
+
+ /// Loads ONE message, for a message row selected in the list.
+ ///
+ /// Emits messageLoaded with an empty vector when the id is unknown, which
+ /// is an ordinary race after a reindex rather than an error worth
+ /// reporting.
+ void loadMessage(const QString &messageId, quint64 generation);
+
/// Applies tag changes. Opens the database read-write, applies, and closes
/// immediately: notmuch's write lock is exclusive process-wide, so holding
/// it would block the user's cron `notmuch new`.
@@ -100,6 +136,9 @@ signals:
void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
void queryFinished(int totalThreads, quint64 generation);
void threadLoaded(const QVector<MessageRef> &messages, quint64 generation);
+ void threadTreeLoaded(const QVector<MessageNode> &nodes,
+ quint64 generation);
+ void messageLoaded(const QVector<MessageRef> &messages, quint64 generation);
void tagsApplied(const TagChange &change);
void allTagsReady(const QStringList &tags, quint64 generation);
diff --git a/src/tagchip.cpp b/src/tagchip.cpp
index 1f4ad79..d770a56 100644
--- a/src/tagchip.cpp
+++ b/src/tagchip.cpp
@@ -55,42 +55,6 @@ void paint(QPainter *painter, const QRect &rect, const QString &text,
} // namespace TagChip
-int SubjectDelegate::subjectBandHeight(const QStyleOptionViewItem &option)
-{
- return QFontMetrics(option.font).height();
-}
-
-QFont SubjectDelegate::pillFont(const QFont &rowFont)
-{
- QFont font = rowFont;
-
- // Two points down, floored. One point was measured to change nothing at a
- // 12pt desktop font: 12 and 11 both render 17px tall, so the pills came
- // out the same size as the subject and read as competing content rather
- // than as annotation.
- //
- // pointSize() is -1 when the font was specified in pixels, which
- // subtracting from would be nonsense, hence the two branches.
- if (rowFont.pointSize() > 0)
- font.setPointSize(qMax(6, rowFont.pointSize() - 2));
- else if (rowFont.pixelSize() > 0)
- font.setPixelSize(qMax(8, rowFont.pixelSize() - 3));
-
- return font;
-}
-
-int SubjectDelegate::rowHeightFor(const QFont &rowFont)
-{
- // The text band uses the ROW's font and the strip its own smaller one.
- // Measuring both with one font is what put the pills over the date text.
- const QFontMetrics rowMetrics(rowFont);
- const QFontMetrics pillMetrics(pillFont(rowFont));
-
- return rowMetrics.height()
- + TagChip::sizeFor(pillMetrics, QStringLiteral("x")).height()
- + kRowPadding * 2 + TagChip::kSpacing;
-}
-
void RowStyleDelegate::initStyleOption(QStyleOptionViewItem *option,
const QModelIndex &index) const
{
@@ -115,143 +79,9 @@ void RowStyleDelegate::initStyleOption(QStyleOptionViewItem *option,
option->palette.setColor(QPalette::WindowText, highlighted);
}
- // Top-aligned and on one line, matching the subject beside them.
- //
- // The row is tall enough for a pill strip under the text, and Qt centres a
- // cell's text in the whole rectangle by default: date and sender floated
- // into the middle while the subject sat at the top, so the three did not
- // share a baseline. Confining the rectangle to the text band puts them all
- // on one.
- //
- // Wrapping matters more than it looks. A long sender ran to a second line,
- // which reached down into the strip's band and collided with the pills; a
- // cell cannot know they are there, since the view paints them afterwards.
- // Eliding keeps every row's text inside its own band whatever it holds.
- // Top of the row rather than centre of it, so the alignment is expressed
- // without shrinking the rectangle: the rect is also what the background
- // and selection fill are drawn into, and clipping it to the text band
- // would leave the highlight covering only the upper part of the row.
+ // One line, elided. A card draws its own text through CardDelegate, but
+ // this still governs whatever Qt draws for the item itself, and a wrapped
+ // string would run past the card's own three lines.
option->features &= ~QStyleOptionViewItem::WrapText;
option->textElideMode = Qt::ElideRight;
-
- // The marker columns keep their centring. Their glyphs are the row's
- // symbols rather than its text, so aligning them with the subject's
- // baseline would strand them at the top of a tall row with the pill strip
- // empty beneath; centred, they read as marking the whole row.
- const bool marker = index.column() == ThreadListModel::AttachmentColumn
- || index.column() == ThreadListModel::FlagColumn;
- option->displayAlignment = marker
- ? Qt::AlignCenter
- : (Qt::AlignLeft | Qt::AlignTop);
-}
-
-void SubjectDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
- const QModelIndex &index) const
-{
- // AccountLabelRole is a property of the ROW, not of a cell, so this
- // delegate must only ever be installed on the subject column. Installed
- // view-wide it draws the account chip into every column, which is exactly
- // what happened when that was tried.
- Q_ASSERT(index.column() == ThreadListModel::SubjectColumn);
-
- const QString account =
- index.data(ThreadListModel::AccountLabelRole).toString();
- if (account.isEmpty()) {
- // No chip to draw, so the base class renders the text, confined to the
- // upper band: the lower one belongs to the row-wide pill strip that
- // ThreadListView paints after every cell.
- QStyleOptionViewItem chrome = option;
- initStyleOption(&chrome, index);
- chrome.rect.setHeight(subjectBandHeight(option));
- QStyledItemDelegate::paint(painter, chrome, index);
-
- return;
- }
-
- // Draw the row's own background and selection first, then the chip and the
- // subject on top, so a selected or struck-through row still looks right.
- QStyleOptionViewItem chrome = option;
- initStyleOption(&chrome, index);
- chrome.text.clear();
- const QWidget *widget = option.widget;
- QStyle *style = widget ? widget->style() : QApplication::style();
- style->drawControl(QStyle::CE_ItemViewItem, &chrome, painter, widget);
-
- const QFontMetrics metrics(option.font);
- const QSize chipSize = TagChip::sizeFor(metrics, account);
-
- // The subject and its chip occupy the upper band; ThreadListView paints
- // the pill strip across the lower one. Centring the chip in the whole row
- // would leave it floating beside that gap rather than beside its text.
- const int textBandHeight = subjectBandHeight(option);
- const int textTop = option.rect.top() + kRowPadding;
-
- const QRect chipRect(option.rect.left() + TagChip::kSpacing,
- textTop + (textBandHeight - chipSize.height()) / 2,
- chipSize.width(), chipSize.height());
-
- const QColor colour =
- index.data(ThreadListModel::AccountColourRole).value<QColor>();
- TagChip::paint(painter, chipRect, account,
- colour.isValid() ? colour : QColor(0x55, 0x55, 0x5f));
-
- // The subject follows the chip, elided so a long one cannot overflow.
- QRect textRect = option.rect;
- textRect.setLeft(chipRect.right() + TagChip::kSpacing * 2);
- textRect.setTop(textTop);
- textRect.setHeight(textBandHeight);
- if (textRect.width() <= 0)
- return;
-
- painter->save();
- // Selection outranks the model's colour, and that order matters. A read
- // thread carries a dimmed foreground blended against the UNSELECTED
- // background, so painting it over the highlight leaves grey-on-purple,
- // which is close to unreadable. The highlight already carries the "this
- // row" signal, so the read/unread distinction can yield to it for as long
- // as the row is selected.
- //
- // A doomed thread is the exception that proves the rule: its white is not
- // a dimming but a contrast requirement against its own fill, and the fill
- // is drawn under the selection too.
- const QVariant foreground = index.data(Qt::ForegroundRole);
- if (option.state & QStyle::State_Selected)
- painter->setPen(option.palette.highlightedText().color());
- else if (foreground.isValid())
- painter->setPen(foreground.value<QBrush>().color());
- else
- painter->setPen(option.palette.text().color());
-
- // The model's font carries bold for unread and strike-out for deleted.
- // initStyleOption() already resolved it into chrome.font; using it rather
- // than option.font is what keeps those cues on a delegate-drawn subject.
- const QVariant fontData = index.data(Qt::FontRole);
- const QFont rowFont = fontData.isValid() ? fontData.value<QFont>()
- : chrome.font;
- painter->setFont(rowFont);
- const QFontMetrics rowMetrics(rowFont);
- painter->drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft,
- rowMetrics.elidedText(index.data(Qt::DisplayRole).toString(),
- Qt::ElideRight, textRect.width()));
- painter->restore();
-}
-
-QSize SubjectDelegate::sizeHint(const QStyleOptionViewItem &option,
- const QModelIndex &index) const
-{
- QSize size = QStyledItemDelegate::sizeHint(option, index);
- const QString account =
- index.data(ThreadListModel::AccountLabelRole).toString();
- if (!account.isEmpty()) {
- const QFontMetrics metrics(option.font);
- size.setWidth(size.width() + TagChip::sizeFor(metrics, account).width()
- + TagChip::kSpacing * 3);
- }
-
- // Height comes from rowHeightFor(), applied by the view to every row at
- // once. A QTableView takes ONE height per row, so a hint returned here
- // would only win if the view happened to ask this column, and this
- // delegate is on the subject column alone.
- size.setHeight(rowHeightFor(option.font));
- return size;
}
diff --git a/src/tagchip.h b/src/tagchip.h
index cc3b5de..5514cae 100644
--- a/src/tagchip.h
+++ b/src/tagchip.h
@@ -59,8 +59,8 @@ void paint(QPainter *painter, const QRect &rect, const QString &text,
/// the selection highlight it lands as grey on the highlight colour, close to
/// unreadable.
///
-/// Applied to the columns that have no delegate of their own; SubjectDelegate
-/// inherits it for the subject column.
+/// Inherited by CardDelegate, which is the only delegate the thread list
+/// installs.
class RowStyleDelegate : public QStyledItemDelegate
{
Q_OBJECT
@@ -72,41 +72,3 @@ protected:
const QModelIndex &index) const override;
};
-/// Item delegate for the subject column: draws the account chip in front of
-/// the subject text, so which mailbox a thread came from reads at a glance
-/// without a tags column spelling it out.
-/// **Install on the subject column only.** It reads AccountLabelRole, which is
-/// a property of the row rather than of a cell, so as a view-wide delegate it
-/// draws the account chip into every column.
-class SubjectDelegate : public RowStyleDelegate
-{
- Q_OBJECT
-public:
- using RowStyleDelegate::RowStyleDelegate;
-
- void paint(QPainter *painter, const QStyleOptionViewItem &option,
- const QModelIndex &index) const override;
- QSize sizeHint(const QStyleOptionViewItem &option,
- const QModelIndex &index) const override;
-
- /// Vertical breathing room above the subject and below the pill row.
- static constexpr int kRowPadding = 4;
-
- /// The font the pill strip is drawn in: a size down from the row's own.
- ///
- /// At the same size the pills read as a second row of content competing
- /// with the subject, rather than as annotation beneath it. Derived from
- /// the row font rather than fixed, so it follows the desktop's font size.
- static QFont pillFont(const QFont &rowFont);
-
- /// The height every row gets, tall enough for the subject and a pill strip
- /// beneath it. The view applies this itself: a QTableView takes one height
- /// for the whole row, so leaving it to a single column's sizeHint would
- /// let whichever column the view happens to ask decide.
- static int rowHeightFor(const QFont &rowFont);
-
-protected:
- /// The height of the band the subject text occupies. Everything below it
- /// belongs to ThreadListView's row-wide pill strip.
- static int subjectBandHeight(const QStyleOptionViewItem &option);
-};
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 4bafca8..bdc7e96 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -64,7 +64,7 @@ QString ThreadListModel::flagGlyph()
// U+2605 BLACK STAR, with the same fallback reasoning as the paperclip: an
// unrenderable codepoint shows as tofu, which reads as breakage rather
// than as "flagged". The solid star, not the outlined U+2606, since it has
- // to register at column width beside a paperclip.
+ // to register at small size beside a paperclip.
static const QString glyph = [] {
const char32_t star = 0x2605;
const QString preferred = QString::fromUcs4(&star, 1);
@@ -75,6 +75,46 @@ QString ThreadListModel::flagGlyph()
return glyph;
}
+QColor ThreadListModel::replyBackground()
+{
+ // Mixed from the palette rather than fixed, for the same reason as
+ // readColour: a tint that reads as "grouped" on a light theme is either
+ // invisible or muddy on a dark one.
+ //
+ // Toward Text rather than toward a hue, so it darkens on a light theme and
+ // lightens on a dark one without picking a colour that means something
+ // else. 0.07 is deliberately near the threshold of noticing: it is a
+ // grouping cue sitting beside the deleted and spam fills, which carry
+ // actual meaning and must stay the loudest thing in the list.
+ const QPalette palette = QGuiApplication::palette();
+ const QColor base = palette.color(QPalette::Base);
+ const QColor text = palette.color(QPalette::Text);
+
+ constexpr qreal kWeight = 0.07;
+ const qreal inverse = 1.0 - kWeight;
+ return QColor::fromRgbF(
+ text.redF() * kWeight + base.redF() * inverse,
+ text.greenF() * kWeight + base.greenF() * inverse,
+ text.blueF() * kWeight + base.blueF() * inverse);
+}
+
+QColor ThreadListModel::threadLineColour()
+{
+ // Stronger than the tint, weaker than the text: the line is structure, so
+ // it has to be followable down a long expansion without competing with the
+ // senders beside it.
+ const QPalette palette = QGuiApplication::palette();
+ const QColor base = palette.color(QPalette::Base);
+ const QColor text = palette.color(QPalette::Text);
+
+ constexpr qreal kWeight = 0.35;
+ const qreal inverse = 1.0 - kWeight;
+ return QColor::fromRgbF(
+ text.redF() * kWeight + base.redF() * inverse,
+ text.greenF() * kWeight + base.greenF() * inverse,
+ text.blueF() * kWeight + base.blueF() * inverse);
+}
+
QColor ThreadListModel::readColour()
{
// Derived from the palette, never hardcoded: a fixed grey that reads as
@@ -99,37 +139,255 @@ QColor ThreadListModel::readColour()
}
ThreadListModel::ThreadListModel(QObject *parent)
- : QAbstractTableModel(parent)
+ : QAbstractItemModel(parent)
+{
+}
+
+QModelIndex ThreadListModel::index(int row, int column,
+ const QModelIndex &parent) const
{
+ if (!hasIndex(row, column, parent))
+ return {};
+
+ // A root row. -1 as the internal id marks it, so parent() can tell the two
+ // kinds apart without storing a node pointer per index.
+ if (!parent.isValid())
+ return createIndex(row, column, static_cast<quintptr>(-1));
+
+ // A child row: the internal id is its parent's row, which is all parent()
+ // needs to rebuild the thread index.
+ return createIndex(row, column, static_cast<quintptr>(parent.row()));
+}
+
+QModelIndex ThreadListModel::parent(const QModelIndex &child) const
+{
+ if (!child.isValid())
+ return {};
+
+ const quintptr id = child.internalId();
+ if (id == static_cast<quintptr>(-1))
+ return {};
+
+ // Column 0, always. Qt requires a parent index in the first column, and
+ // returning the child's own column instead breaks selection and the
+ // expander, silently and only for the other columns.
+ return createIndex(static_cast<int>(id), 0, static_cast<quintptr>(-1));
}
int ThreadListModel::rowCount(const QModelIndex &parent) const
{
- return parent.isValid() ? 0 : m_threads.size();
+ if (!parent.isValid())
+ return m_threads.size();
+
+ // Only a thread row has children, and only in its first column. A tree
+ // takes one set of children per row; offering them under every column makes
+ // the view draw an expander in each one.
+ if (parent.parent().isValid() || parent.column() != 0)
+ return 0;
+
+ if (parent.row() < 0 || parent.row() >= m_threads.size())
+ return 0;
+
+ return m_threads.at(parent.row()).children.size();
+}
+
+bool ThreadListModel::hasChildren(const QModelIndex &parent) const
+{
+ if (!parent.isValid())
+ return !m_threads.isEmpty();
+
+ // A message row is always a leaf. Reply depth is drawn from the node's own
+ // depth, not from further nesting, so nothing hangs under a reply.
+ if (parent.parent().isValid())
+ return false;
+
+ if (parent.column() != 0)
+ return false;
+
+ if (parent.row() < 0 || parent.row() >= m_threads.size())
+ return false;
+
+ const ThreadNode &node = m_threads.at(parent.row());
+
+ // Once loaded the children are the truth, including "there are none", which
+ // is how a thread whose totalCount counted duplicates stops offering an
+ // expander that opens onto nothing.
+ if (node.loaded)
+ return !node.children.isEmpty();
+
+ // Before loading, the summary's count is all there is. A thread of one
+ // message has no replies and must not offer an expander.
+ return node.summary.totalCount > 1;
}
int ThreadListModel::columnCount(const QModelIndex &parent) const
{
- return parent.isValid() ? 0 : ColumnCount;
+ // One column: the card is drawn whole by CardDelegate. The five-column
+ // grid is what item 53 removed.
+ //
+ // Answered for a valid parent too. Returning 0 there, as the table version
+ // did, would give message rows no columns at all and render them blank.
+ Q_UNUSED(parent);
+ return 1;
}
QVariant ThreadListModel::data(const QModelIndex &index, int role) const
{
// A stale index from a view that has not caught up with a clear() can carry
// any row or column, so both bounds are checked rather than trusted.
- if (!index.isValid() || index.row() < 0 || index.row() >= m_threads.size()
- || index.column() < 0 || index.column() >= ColumnCount) {
+ if (!index.isValid() || index.row() < 0
+ || index.column() != 0) {
return {};
}
- const ThreadSummary &thread = m_threads.at(index.row());
+ // A message row. Handled before the bounds check below, since a child row's
+ // number indexes its siblings, not m_threads.
+ if (isMessageRow(index)) {
+ const MessageNode node = messageAt(index);
+ if (node.messageId.isEmpty())
+ return {};
+
+ switch (role) {
+ case IsMessageRole:
+ return true;
+ case MessageIdRole:
+ return node.messageId;
+ case MessageDepthRole:
+ return node.depth;
+ case HasRepliesRole:
+ // A reply never has its own expander: nesting past the first level
+ // is drawn from depth, not from further parent-child structure.
+ return false;
+ case ThreadIdRole:
+ // A message row still belongs to a thread, and a caller that only
+ // needs the containing thread must not have to walk up itself.
+ return node.threadId;
+ case TagsRole:
+ case PillTagsRole:
+ // No strip under a child row: the strip is a ROW-wide band carrying
+ // the thread's tags, and one under every reply would stripe the
+ // list and repeat the same tags down the whole expansion.
+ return QStringList();
+ case PillColoursRole:
+ return QVariantList();
+ case MessageOwnTagsRole: {
+ // Set difference against the parent THREAD, not against a global
+ // list: "own" means "not already said by the card above this one".
+ // The parent row indexes m_threads directly, which is the same
+ // mapping messageAt() uses to reach this node.
+ const int threadRow = index.parent().row();
+ const QStringList threadTags =
+ (threadRow >= 0 && threadRow < m_threads.size())
+ ? m_threads.at(threadRow).summary.tags
+ : QStringList();
+ QStringList own;
+ for (const QString &tag : node.tags) {
+ if (!threadTags.contains(tag))
+ own.append(tag);
+ }
+ // Sorted, so a reply does not reshuffle its own chips between
+ // repaints, matching what PillTagsRole already guarantees.
+ own.sort();
+ return own;
+ }
+ case MessageOwnColoursRole: {
+ const QStringList own =
+ data(index, MessageOwnTagsRole).toStringList();
+ QVariantList colours;
+ colours.reserve(own.size());
+ for (const QString &tag : own) {
+ colours.append(m_tagColors ? m_tagColors->colourFor(tag)
+ : TagColors().colourFor(tag));
+ }
+ return colours;
+ }
+ case AccountLabelRole:
+ return QString();
+ case Qt::DisplayRole:
+ case SubjectRole:
+ return node.subject;
+ case SendersRole:
+ // The REPLY's sender, not the thread's author summary. Reading the
+ // thread's fields here would look almost right, since the first
+ // sender usually appears in both.
+ return node.from;
+ case DateRole:
+ return node.date;
+ case HasAttachmentRole:
+ return node.hasAttachment();
+ case IsFlaggedRole:
+ return node.isFlagged();
+ case ReplyCountRole:
+ // A reply never offers an expander: nesting past the first level is
+ // drawn from depth, not from further parent-child structure.
+ return 0;
+ case Qt::BackgroundRole:
+ // 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
+ // half tinted.
+ 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.
+ 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));
+ return font;
+ }
+ case Qt::ForegroundRole:
+ // 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.
+ return node.isUnread() ? QVariant() : QVariant(readColour());
+ default:
+ return {};
+ }
+ }
+
+ if (index.row() >= m_threads.size())
+ return {};
+
+ const ThreadSummary &thread = m_threads.at(index.row()).summary;
if (role == ThreadIdRole)
return thread.threadId;
+ // Answered rather than left to fall through as an invalid QVariant. An
+ // invalid one converts to false and an empty string anyway, so the
+ // behaviour is the same, but a role the model never mentions is a latent
+ // bug the next reader has to prove is harmless.
+ if (role == IsMessageRole)
+ return false;
+
+ 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;
+
+ if (role == HasRepliesRole)
+ return hasChildren(index.siblingAtColumn(0));
+
if (role == TagsRole)
return thread.tags;
+ if (role == MessageOwnTagsRole)
+ return QStringList();
+
+ if (role == MessageOwnColoursRole)
+ return QVariantList();
+
if (role == PillTagsRole || role == PillColoursRole) {
// Everything the row already says another way is dropped: the account
// is the chip in the subject cell, flagged is the star column,
@@ -186,46 +444,42 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return {};
}
- if (role == Qt::ToolTipRole && index.column() == AttachmentColumn)
- return thread.hasAttachment() ? tr("Has an attachment") : QVariant();
-
- // "Important", matching the action's own wording (item 57). The underlying
- // tag is still `flagged` and isFlagged() still tests for it; only what the
- // user reads changed.
- if (role == Qt::ToolTipRole && index.column() == FlagColumn)
- return thread.isFlagged() ? tr("Important") : QVariant();
-
- // Both marker columns: a glyph reads as a marker only when it sits in the
- // middle of its column rather than against the text beside it.
- if (role == Qt::TextAlignmentRole
- && (index.column() == AttachmentColumn || index.column() == FlagColumn)) {
- return QVariant::fromValue(Qt::AlignCenter);
+ if (role == Qt::ToolTipRole) {
+ // One tooltip for the whole card, since the marks no longer have
+ // columns of their own to be hovered separately. "Important" matches
+ // the action's own wording (item 57); the underlying tag is still
+ // `flagged` and isFlagged() still tests for it.
+ QStringList marks;
+ if (thread.isFlagged())
+ marks.append(tr("Important"));
+ if (thread.hasAttachment())
+ marks.append(tr("Has an attachment"));
+ return marks.isEmpty() ? QVariant() : marks.join(QStringLiteral(", "));
}
- if (role == Qt::DisplayRole) {
- switch (index.column()) {
- case AttachmentColumn:
- // A glyph rather than an icon resource: no new asset to ship, and
- // it inherits the row's font, so it strikes through with a doomed
- // thread like every other cell.
- return thread.hasAttachment() ? attachmentGlyph() : QString();
- case FlagColumn:
- // A glyph rather than an icon, for the same reasons as the
- // paperclip: no asset to ship, and it inherits the row's font so
- // it strikes through with a doomed thread.
- return thread.isFlagged() ? flagGlyph() : QString();
- case DateColumn:
- return thread.date.toString(QStringLiteral("yyyy-MM-dd hh:mm"));
- case AuthorsColumn:
- return thread.authors;
- case SubjectColumn:
- return thread.totalCount > 1
- ? QStringLiteral("%1 (%2)").arg(thread.subject)
- .arg(thread.totalCount)
- : thread.subject;
- default:
- return {};
- }
+ switch (role) {
+ case Qt::DisplayRole:
+ case SubjectRole:
+ // Bare, with no "(3)" message-count suffix. The count is drawn on the
+ // card's second line as the expander, so a suffix here would state it
+ // twice on the same card.
+ return thread.subject;
+ case SendersRole:
+ return thread.authors;
+ case DateRole:
+ // The QDateTime itself. Formatting belongs to the delegate now: the
+ // card decides how much of a date it has room for, and a pre-formatted
+ // string takes that decision away from it.
+ return thread.date;
+ case HasAttachmentRole:
+ return thread.hasAttachment();
+ case IsFlaggedRole:
+ return thread.isFlagged();
+ case ReplyCountRole:
+ // totalCount includes the root message, which is the card itself.
+ return qMax(0, thread.totalCount - 1);
+ default:
+ break;
}
// A thread tagged deleted or spam is on its way out, and the user needs to
@@ -283,24 +537,6 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return {};
}
-QVariant ThreadListModel::headerData(int section, Qt::Orientation orientation,
- int role) const
-{
- if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
- return {};
-
- switch (section) {
- // No label: any text would set a minimum width far wider than the icon,
- // which defeats the point of a narrow column.
- case AttachmentColumn: return QString();
- case FlagColumn: return QString();
- case DateColumn: return tr("Date");
- case AuthorsColumn: return tr("From");
- case SubjectColumn: return tr("Subject");
- default: return {};
- }
-}
-
void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch)
{
// beginInsertRows with an empty range violates Qt's contract, so the guard
@@ -310,7 +546,8 @@ void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch)
const int first = m_threads.size();
beginInsertRows({}, first, first + batch.size() - 1);
- m_threads.append(batch);
+ for (const ThreadSummary &summary : batch)
+ m_threads.append(ThreadNode{ summary, {}, {}, false });
endInsertRows();
}
@@ -321,17 +558,137 @@ void ThreadListModel::clear()
endResetModel();
}
+void ThreadListModel::setThreadMessages(const QString &threadId,
+ const QVector<MessageNode> &nodes)
+{
+ for (int row = 0; row < m_threads.size(); ++row) {
+ if (m_threads.at(row).summary.threadId != threadId)
+ continue;
+
+ const QModelIndex parent = index(row, 0, QModelIndex());
+
+ // Replace, not append. A thread reloaded after a sync would otherwise
+ // list every reply twice.
+ if (!m_threads.at(row).children.isEmpty()) {
+ beginRemoveRows(parent, 0, m_threads.at(row).children.size() - 1);
+ m_threads[row].children.clear();
+ endRemoveRows();
+ }
+
+ // 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);
+ m_threads[row].children = children;
+ endInsertRows();
+ }
+
+ // Set even when there are no replies: that is the difference between a
+ // single-message thread and one whose replies were never fetched.
+ m_threads[row].loaded = true;
+ return;
+ }
+}
+
+bool ThreadListModel::isMessageRow(const QModelIndex &index) const
+{
+ return index.isValid() && index.parent().isValid();
+}
+
+MessageNode ThreadListModel::messageAt(const QModelIndex &index) const
+{
+ if (!isMessageRow(index))
+ return {};
+
+ const int threadRow = index.parent().row();
+ if (threadRow < 0 || threadRow >= m_threads.size())
+ return {};
+
+ const QVector<MessageNode> &children = m_threads.at(threadRow).children;
+ if (index.row() < 0 || index.row() >= children.size())
+ return {};
+
+ return children.at(index.row());
+}
+
+QString ThreadListModel::threadIdForMessage(const QString &messageId) const
+{
+ for (const ThreadNode &node : m_threads) {
+ for (const MessageNode &child : node.children) {
+ if (child.messageId == messageId)
+ return node.summary.threadId;
+ }
+ }
+ return {};
+}
+
+ActionScope ThreadListModel::scopeFor(const QModelIndexList &selection) const
+{
+ ActionScope scope;
+
+ for (const QModelIndex &index : selection) {
+ if (isMessageRow(index)) {
+ const MessageNode node = messageAt(index);
+ if (node.messageId.isEmpty()
+ || scope.messageIds.contains(node.messageId))
+ continue;
+ scope.messageIds.append(node.messageId);
+ scope.messageCount += 1;
+ continue;
+ }
+
+ if (index.row() < 0 || index.row() >= m_threads.size())
+ continue;
+
+ const ThreadSummary &summary = m_threads.at(index.row()).summary;
+ if (scope.threadIds.contains(summary.threadId))
+ continue;
+
+ scope.threadIds.append(summary.threadId);
+
+ // totalCount, not the loaded children: a thread that was never expanded
+ // still has all of its messages, and counting only what happens to be
+ // on screen would understate what the action does. Floored at 1, since
+ // a summary with no count still stands for at least the message that
+ // produced it.
+ scope.messageCount += qMax(1, summary.totalCount);
+ scope.wholeThread = true;
+ }
+
+ return scope;
+}
+
ThreadSummary ThreadListModel::threadAt(int row) const
{
if (row < 0 || row >= m_threads.size())
return {};
- return m_threads.at(row);
+ return m_threads.at(row).summary;
}
QStringList ThreadListModel::accountKeysForThread(const QString &threadId) const
{
QStringList keys;
- for (const ThreadSummary &thread : m_threads) {
+ for (const ThreadNode &node : m_threads) {
+ const ThreadSummary &thread = node.summary;
if (thread.threadId != threadId)
continue;
for (const QString &tag : thread.tags) {
@@ -351,10 +708,10 @@ void ThreadListModel::applyTagChange(const QString &threadId,
const QStringList &removed)
{
for (int row = 0; row < m_threads.size(); ++row) {
- if (m_threads.at(row).threadId != threadId)
+ if (m_threads.at(row).summary.threadId != threadId)
continue;
- QStringList &tags = m_threads[row].tags;
+ QStringList &tags = m_threads[row].summary.tags;
for (const QString &tag : removed)
tags.removeAll(tag);
for (const QString &tag : added) {
@@ -362,9 +719,9 @@ void ThreadListModel::applyTagChange(const QString &threadId,
tags.append(tag);
}
- // The whole row repaints: unread state drives the font of every column,
- // not just the tags one.
- emit dataChanged(index(row, 0), index(row, ColumnCount - 1));
+ // 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));
return;
}
}
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 2eaa88e..f381ffa 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -18,39 +18,26 @@
#pragma once
-#include <QAbstractTableModel>
+#include <QAbstractItemModel>
#include <QColor>
#include <QVector>
#include "tagcolors.h"
#include "types.h"
-/// Table model over query results, filled in batches so a large query paints
+/// Tree model over query results, filled in batches so a large query paints
/// its first screenful immediately.
-class ThreadListModel : public QAbstractTableModel
+///
+/// A tree rather than a table since item 20: a thread's replies are child rows
+/// under it. The tree is at most two levels deep in the MODEL (a thread, then
+/// its messages) even though the messages carry a reply depth of their own; the
+/// visual nesting beyond the first level comes from that depth, not from
+/// further parent-child structure. A deeper model would buy nothing and make
+/// every index calculation recursive.
+class ThreadListModel : public QAbstractItemModel
{
Q_OBJECT
public:
- /// No tags column: spelling out a dozen tags per row cost most of the
- /// list's width and was unreadable. Functional tags moved to a chip strip
- /// under the message pane, and the account tag renders as a chip in front
- /// of the subject.
- enum Column {
- /// A paperclip when the thread has an attachment, so it is visible
- /// without opening the thread. Icon only and deliberately narrow;
- /// it carries no text.
- AttachmentColumn = 0,
-
- /// A star when the thread carries the flagged tag. Beside the
- /// paperclip and the same shape: icon only, narrow, no text.
- FlagColumn,
-
- DateColumn,
- AuthorsColumn,
- SubjectColumn,
- ColumnCount,
- };
-
enum Role {
/// The thread id behind a row. Views hand out QModelIndexes, but the
/// worker speaks thread ids, so the mapping belongs on the model
@@ -77,23 +64,86 @@ public:
/// model because it owns the TagColors instance; a delegate reading
/// config itself would be a second source of truth.
PillColoursRole,
+
+ /// 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.
+ IsMessageRole,
+
+ /// The message id behind a message row. Empty on a thread root.
+ MessageIdRole,
+
+ /// The message's reply depth, for the view's indentation. 1 for a
+ /// direct reply, since depth 0 is the root row itself.
+ MessageDepthRole,
+
+ /// True when the row is a thread that has replies to show.
+ ///
+ /// Read by SubjectDelegate, which draws the expander itself: the
+ /// delegate cannot call hasChildren without the model, and the same
+ /// answer has to reach the cell that reserves room for the glyph.
+ HasRepliesRole,
+
+ /// The tags this MESSAGE carries that its thread does not.
+ ///
+ /// A reply card shows these and nothing else. Showing a reply's full
+ /// tag set instead was measured against the user's own database and
+ /// rejected: of 48691 messages, 7 carry `unread` and 75 carry
+ /// `flagged`, and both are already drawn another way (the sender's
+ /// weight, and the mark on line 2). Every other tag is applied to a
+ /// whole thread and is identical on all its messages, so full sets
+ /// would repeat the thread's own chips down the entire expansion,
+ /// which is the striping the old row-wide strip existed to avoid.
+ ///
+ /// Empty on a thread row, which has no thread to differ from.
+ MessageOwnTagsRole,
+
+ /// The colours for MessageOwnTagsRole, in the same order. Supplied by
+ /// the model for the same reason as PillColoursRole: it owns the
+ /// TagColors instance, and a delegate reading config itself would be a
+ /// second source of truth.
+ MessageOwnColoursRole,
+
+ /// The card's own fields, by role rather than by column.
+ ///
+ /// Five columns used to answer these through Qt::DisplayRole. One
+ /// column cannot, and a card needs all five values at once, so each
+ /// gets a role and Qt::DisplayRole answers the subject alone (which is
+ /// what keyboard search and accessibility read).
+ SubjectRole,
+ SendersRole,
+ DateRole, ///< A QDateTime. The delegate formats it.
+ HasAttachmentRole, ///< bool
+ IsFlaggedRole, ///< bool
+ ReplyCountRole, ///< int; 0 when a thread has no replies.
};
- /// Row fill for a thread tagged `deleted`, and for one tagged `spam`.
- /// Muted rather than saturated: a bulk delete paints every selected row,
- /// and a wall of pure red is harder to read than the list it replaces.
- /// Exposed so a test names the same colour the model uses.
- /// The character shown in AttachmentColumn for a thread that has one.
- /// A paperclip when the system font can draw it, "*" otherwise.
+ /// The mark drawn on a card's second line when the message has an
+ /// attachment. A paperclip when the system font can draw it, "*" otherwise.
static QString attachmentGlyph();
- /// The character shown in FlagColumn for a flagged thread.
+ /// The mark drawn on a card's second line when the message is flagged.
/// A star when the system font can draw it, "*" otherwise.
static QString flagGlyph();
+ /// Row fill for a thread tagged `deleted`, and for one tagged `spam`.
+ /// Muted rather than saturated: a bulk delete paints every selected row,
+ /// and a wall of pure red is harder to read than the list it replaces.
+ /// Exposed so a test names the same colour the model uses.
static QColor deletedColour();
static QColor spamColour();
+ /// Background for a reply row, so an expanded thread reads as one block
+ /// rather than as more table rows.
+ ///
+ /// Derived from the palette and deliberately subtle: it marks a grouping,
+ /// and a tint strong enough to notice on its own would compete with the
+ /// deleted and spam row colours, which carry real meaning.
+ static QColor replyBackground();
+
+ /// The line drawn down the left of an expanded thread's replies.
+ static QColor threadLineColour();
+
/// The dimmed text colour a READ thread carries.
///
/// Unread rows are left at the palette's own colour and read ones recede,
@@ -109,17 +159,59 @@ public:
/// Without one, chips fall back to a colour generated from the tag name.
void setTagColors(const TagColors *colours) { m_tagColors = colours; }
+ QModelIndex index(int row, int column,
+ const QModelIndex &parent = {}) const override;
+ QModelIndex parent(const QModelIndex &child) const override;
+
int rowCount(const QModelIndex &parent = {}) const override;
int columnCount(const QModelIndex &parent = {}) const override;
+
+ /// Whether a thread row should offer an expander.
+ ///
+ /// Answered from totalCount rather than from the loaded children, and that
+ /// is what makes lazy loading possible at all: rowCount is 0 until the
+ /// worker has walked the thread, so a view left to infer this from rowCount
+ /// alone draws no expander, the user can never expand, and the replies are
+ /// never asked for. The count is already in the summary, so this costs
+ /// nothing.
+ bool hasChildren(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
- QVariant headerData(int section, Qt::Orientation orientation,
- int role) const override;
void appendBatch(const QVector<ThreadSummary> &batch);
void clear();
ThreadSummary threadAt(int row) 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
+ /// ROOT row already stands for it. Keeping it would show a thread of seven
+ /// as one root and seven children, contradicting the reply count the row
+ /// advertises. Calling again replaces the rows rather than appending, so a
+ /// thread reloaded after a sync does not list its replies twice.
+ void setThreadMessages(const QString &threadId,
+ const QVector<MessageNode> &nodes);
+
+ /// True when the index is a message row rather than a thread root.
+ bool isMessageRow(const QModelIndex &index) const;
+
+ /// The message row's node, or a default-constructed one for any index that
+ /// is not a message row.
+ MessageNode messageAt(const QModelIndex &index) const;
+
+ /// The thread a loaded message row belongs to, or empty when no expanded
+ /// thread holds it. Only expanded threads have message rows at all, so a
+ /// message the user could select is always findable here.
+ QString threadIdForMessage(const QString &messageId) const;
+
+ /// Resolves a selection into what an action should touch.
+ ///
+ /// 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.
+ ActionScope scopeFor(const QModelIndexList &selection) const;
+
/// The account keys behind a thread's account tags, for item 49's
/// per-account sync.
///
@@ -137,6 +229,33 @@ public:
const QStringList &removed);
private:
- QVector<ThreadSummary> m_threads;
+ /// One thread root and the message rows expanded under it.
+ ///
+ /// Children live beside the summary rather than in a separate map keyed by
+ /// thread id, so a row and its expansion are appended, cleared and
+ /// destroyed together. The model is rebuilt wholesale on every query, so
+ /// nothing here has to survive a reset.
+ struct ThreadNode
+ {
+ 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
+ /// messages.
+ bool loaded = false;
+ };
+
+ QVector<ThreadNode> m_threads;
const TagColors *m_tagColors = nullptr;
};
diff --git a/src/threadlistview.cpp b/src/threadlistview.cpp
index ef80e09..5a3ce77 100644
--- a/src/threadlistview.cpp
+++ b/src/threadlistview.cpp
@@ -18,123 +18,38 @@
#include "threadlistview.h"
-#include "tagchip.h"
+#include "carddelegate.h"
#include "threadlistmodel.h"
-#include <QPaintEvent>
-#include <QPainter>
-#include <QScrollBar>
+#include <QMouseEvent>
-void ThreadListView::paintEvent(QPaintEvent *event)
+void ThreadListView::mousePressEvent(QMouseEvent *event)
{
- QTableView::paintEvent(event);
-
- if (!model())
- return;
-
- QPainter painter(viewport());
-
- // Two fonts, deliberately. The row's own font fixes where the text band
- // ends, and the pills are drawn a size smaller: at the same size they read
- // as a second row of content competing with the subject, rather than as
- // annotation beneath it.
- const QFontMetrics rowMetrics(font());
- const QFont pillFont = SubjectDelegate::pillFont(font());
- const QFontMetrics metrics(pillFont);
- painter.setFont(pillFont);
-
- // Only the rows actually on screen. Walking the whole model would paint
- // thousands of strips outside the viewport on a large query.
- const int first = rowAt(0);
- const int last = rowAt(viewport()->height() - 1);
- const int lastRow = last >= 0 ? last : model()->rowCount() - 1;
-
- for (int row = qMax(0, first); row <= lastRow; ++row) {
- const QModelIndex index =
- model()->index(row, ThreadListModel::SubjectColumn);
-
- const int rowTop = rowViewportPosition(row);
- const int height = rowHeight(row);
- if (height <= 0)
- continue;
-
- // The strip's band, filled to match the row before anything is drawn
- // on it.
- //
- // A QTableView paints alternating colours and the selection PER CELL,
- // so nothing paints the width to the right of the last column, and
- // nothing paints the band at all where a column does not reach. Left
- // unfilled, an alternate-coloured or selected row shows the viewport
- // background in a strip across its lower half. Filled for every
- // visible row, not only tagged ones, since an untagged row has the
- // same band to account for.
- // Starting at the date column, NOT at the viewport edge. The two
- // leading columns hold the attachment and flag glyphs, centred in the
- // full row height, so a band drawn over them cuts those glyphs in half.
- const int bandLeft =
- columnViewportPosition(ThreadListModel::DateColumn);
- const QRect band(bandLeft, rowTop + SubjectDelegate::kRowPadding
- + rowMetrics.height(),
- viewport()->width() - bandLeft,
- height - SubjectDelegate::kRowPadding
- - rowMetrics.height());
-
- // The model's own row colour wins where it has one: a deleted or spam
- // thread fills its cells with crimson or orange, and painting the base
- // colour across the band beneath them would cut the row in half.
- const QVariant background = index.data(Qt::BackgroundRole);
-
- if (background.isValid())
- painter.fillRect(band, background.value<QBrush>());
- else if (selectionModel() && selectionModel()->isRowSelected(row))
- painter.fillRect(band, palette().brush(QPalette::Highlight));
- else if (alternatingRowColors() && (row % 2))
- painter.fillRect(band, palette().brush(QPalette::AlternateBase));
- else
- painter.fillRect(band, palette().brush(QPalette::Base));
-
- const QStringList tags =
- index.data(ThreadListModel::PillTagsRole).toStringList();
- if (tags.isEmpty())
- continue;
-
- const QVariantList colours =
- index.data(ThreadListModel::PillColoursRole).toList();
-
- // The band the cells leave free, below the text they draw in the
- // upper one. Measured from SubjectDelegate by both sides, so neither
- // can drift into the other's half. The row's own font metrics set the
- // text band; the strip's smaller font must not be used for it, or the
- // pills ride up over the date and sender.
- const int top = rowTop + SubjectDelegate::kRowPadding
- + rowMetrics.height() + TagChip::kSpacing;
-
- // Aligned with the first text column rather than the viewport edge:
- // the two leading columns are narrow markers for the attachment and
- // flag glyphs, and a strip starting at x=0 paints straight over them.
- // Indented past the date column's own left edge rather than flush with
- // it: a chip starting exactly where the column does reads as part of
- // the column rather than as a strip laid under the row.
- int x = columnViewportPosition(ThreadListModel::DateColumn)
- + TagChip::kSpacing * 2;
- const int available = viewport()->width() - TagChip::kSpacing;
-
- for (int i = 0; i < tags.size(); ++i) {
- const QSize size = TagChip::sizeFor(metrics, tags.at(i));
-
- // Stop rather than wrap or elide. A row that grew to fit its tags
- // would break the uniform height the list depends on, and half a
- // chip reads as a rendering fault.
- if (x + size.width() > available)
- break;
-
- const QColor colour = i < colours.size()
- ? colours.at(i).value<QColor>()
- : QColor(0x55, 0x55, 0x5f);
-
- TagChip::paint(&painter, QRect(x, top, size.width(), size.height()),
- tags.at(i), colour);
- x += size.width() + TagChip::kSpacing;
+ const QModelIndex index = indexAt(event->pos());
+
+ // The reply count IS the expander. Anything outside its rect selects the
+ // card and opens it, which is what the rest of the card is for.
+ if (event->button() == Qt::LeftButton && index.isValid()
+ && index.data(ThreadListModel::ReplyCountRole).toInt() > 0) {
+
+ QStyleOptionViewItem option;
+ initViewItemOption(&option);
+ option.rect = visualRect(index);
+ // State_Open decides which way the glyph points, and the rect is the
+ // same either way, but pass it so the layout sees the true state.
+ if (isExpanded(index))
+ option.state |= QStyle::State_Open;
+
+ if (CardDelegate::expanderRectFor(option, index)
+ .contains(event->pos())) {
+ setExpanded(index, !isExpanded(index));
+ // Swallowed, so expanding does not also load the thread into the
+ // message pane: it is a request to see the thread's shape, not to
+ // read it.
+ event->accept();
+ return;
}
}
+
+ QTreeView::mousePressEvent(event);
}
diff --git a/src/threadlistview.h b/src/threadlistview.h
index 0b4eafc..3215153 100644
--- a/src/threadlistview.h
+++ b/src/threadlistview.h
@@ -18,30 +18,33 @@
#pragma once
-#include <QTableView>
+#include <QTreeView>
-/// The thread list, with a row-wide strip of tag chips under each row's cells.
+/// The thread list.
///
-/// The strip is painted by the VIEW rather than by a delegate, and that is the
-/// whole reason this class exists. A delegate is handed one cell's rectangle
-/// and cannot paint outside its column, so pills drawn from the subject
-/// column's delegate stop at that column's edge, losing the last tags of a
-/// well-tagged thread, and start at that column's left edge, which puts them
-/// under the subject instead of under the row. Painting after the cells lets
-/// the strip run the full width, which is what the layout asks for:
+/// It exists for ONE reason now: the expander is drawn by CardDelegate, and a
+/// delegate gets no click of its own without an editor, so the view has to own
+/// the hit-test. Everything else it used to do is gone.
///
-/// [ date ][ from ][ subject ...................... ]
-/// [ pill ][ pill ][ pill ]
-///
-/// The cells confine themselves to the upper band so the lower one is free;
-/// SubjectDelegate::kRowPadding and rowHeightFor() are the shared measurements
-/// that keep the two halves agreeing.
-class ThreadListView : public QTableView
+/// Until item 53 this class also painted a row-wide strip of tag chips after
+/// the cells, because a delegate cannot paint outside its column and the strip
+/// spanned all five. With one column and one delegate painting the whole card,
+/// that reason is gone and so is the paintEvent, along with the two faults it
+/// kept producing: a deleted row cut in half, and every other row showing a
+/// bare stripe, both from the view having to re-honour alternating colours,
+/// the selection and BackgroundRole across cells it did not own.
+class ThreadListView : public QTreeView
{
Q_OBJECT
public:
- using QTableView::QTableView;
+ using QTreeView::QTreeView;
protected:
- void paintEvent(QPaintEvent *event) override;
+ /// Toggles a thread when its reply count is clicked.
+ ///
+ /// Being VISIBLE and being CLICKABLE are separate properties:
+ /// setRootIsDecorated(false), needed to stop the style drawing its own
+ /// indicator underneath, also removed the style's hit area, so an expander
+ /// once painted correctly and did nothing at all.
+ void mousePressEvent(QMouseEvent *event) override;
};
diff --git a/src/types.h b/src/types.h
index 999d3f3..d04670e 100644
--- a/src/types.h
+++ b/src/types.h
@@ -63,6 +63,63 @@ struct MessageRef
bool matched = true;
};
+/// One message as a row in the thread list.
+///
+/// Separate from MessageRef, which exists for RENDERING a thread and carries
+/// only what the message pane needs. A row has to be drawn without opening the
+/// message at all, so the display facts live here.
+struct MessageNode
+{
+ QString messageId;
+ QString threadId; ///< The thread this message belongs to.
+ QString from;
+ QString subject;
+ QDateTime date;
+ QStringList tags;
+ QString filePath;
+
+ /// Reply depth within the thread. 0 is the thread's first message, which
+ /// occupies the ROOT row rather than a child row: the user's model is
+ /// "N replies", so a thread of 7 shows 1 root and 6 descendants.
+ int depth = 0;
+
+ bool isUnread() const { return tags.contains(QStringLiteral("unread")); }
+ bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); }
+
+ /// notmuch applies "attachment" while indexing, so this needs no MIME
+ /// parsing, exactly as on ThreadSummary.
+ bool hasAttachment() const
+ {
+ return tags.contains(QStringLiteral("attachment"));
+ }
+};
+
+/// What an action is about to touch, resolved from the selection.
+///
+/// Exists because the thread list holds two kinds of row since item 20, so a
+/// keypress alone no longer says whether it hit one message or seven. Actions
+/// take one of these rather than a bare list of thread ids, and the status bar
+/// reports it: this project's answer to that ambiguity is to make the scope
+/// visible, not to add a confirmation dialog. See CLAUDE.md on why.
+struct ActionScope
+{
+ QStringList threadIds; ///< Whole threads to act on.
+ QStringList messageIds; ///< Individual messages to act on.
+
+ /// Messages the action will touch in total, for the status bar. A whole
+ /// thread contributes all of its messages, a message row contributes one.
+ int messageCount = 0;
+
+ /// True when any whole thread is in scope, which drives the
+ /// "(whole thread)" suffix in the status bar.
+ bool wholeThread = false;
+
+ bool isEmpty() const
+ {
+ return threadIds.isEmpty() && messageIds.isEmpty();
+ }
+};
+
/// One tag mutation, kept so it can be inverted for undo.
struct TagChange
{
@@ -93,5 +150,6 @@ struct DatabaseStats
Q_DECLARE_METATYPE(ThreadSummary)
Q_DECLARE_METATYPE(MessageRef)
+Q_DECLARE_METATYPE(MessageNode)
Q_DECLARE_METATYPE(TagChange)
Q_DECLARE_METATYPE(DatabaseStats)