summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
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)