aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-07 16:26:22 +0200
committerDanilo M. <danix@danix.xyz>2026-08-07 16:26:22 +0200
commit39cbde74a560407e24b05e171df883421aa2153e (patch)
treea7390b036f57507b17aa4291511ce33d3f297f81 /src
parentde884b036689b253d10ff48daa3a05cca20ba61d (diff)
downloadqtmaildir-39cbde74a560407e24b05e171df883421aa2153e.tar.gz
qtmaildir-39cbde74a560407e24b05e171df883421aa2153e.zip
feat(ui): show each thread's tags under its row
The thread list was uniform and cramped: every row one line tall, with nothing to say what a thread was about before opening it. Rows are now roughly double height, carrying a strip of tag chips beneath the text, with alternating row colours and a star column for flagged threads beside the existing paperclip. The strip is painted by the VIEW rather than by a delegate, which is why ThreadListView exists. A delegate is handed one cell's rectangle and cannot paint outside its column, so a strip drawn from the subject column stops at that column's edge, losing the last tags of a well-tagged thread, and starts at its left edge, putting the chips under the subject instead of under the row. Tags the row already shows another way are left out: inbox as structure, unread as the dimming, flagged as the star, attachment as the paperclip, and the account as the chip in the subject cell. Sorted, since notmuch's order is not guaranteed stable and a row whose chips reordered between repaints would flicker. Six defects were introduced and fixed on the way here, all of them one consequence: a QTableView paints per cell, and a row-wide strip is not a cell. SubjectDelegate installed view-wide drew the account chip into every column, since AccountLabelRole belongs to the row; it is split into RowStyleDelegate for every column and SubjectDelegate for the subject alone, with a Q_ASSERT guarding that. Row height returned from sizeHint did nothing, because a table takes one height per row. The strip painted from x=0 over the marker columns, via a protected viewportMargins() that returns 0. Measuring the text band and the strip with one font put the pills over the date. Alternating colours and the selection are per-cell too, so the band showed bare viewport background until the view filled it, honouring the model's own BackgroundRole first so a deleted row is not cut in half. And that fill spanned the full width, cutting the centred marker glyphs at their midpoint. Closes item 5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/mainwindow.cpp39
-rw-r--r--src/tagchip.cpp110
-rw-r--r--src/tagchip.h62
-rw-r--r--src/threadlistmodel.cpp97
-rw-r--r--src/threadlistmodel.h27
-rw-r--r--src/threadlistview.cpp140
-rw-r--r--src/threadlistview.h47
8 files changed, 480 insertions, 43 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a71a6f1..cae3bd4 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -11,6 +11,7 @@ add_library(qtmaildir_lib STATIC
tagdialog.cpp
tagstrip.cpp
threadlistmodel.cpp
+ threadlistview.cpp
mailsync.cpp
syncmonitor.cpp
threadcidmap.cpp
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 30de89c..77322bc 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -55,6 +55,7 @@
#include "tagchip.h"
#include "tagdialog.h"
#include "threadlistmodel.h"
+#include "threadlistview.h"
#include "version.h"
QStringList MainWindow::registeredActionNames() const
@@ -500,7 +501,10 @@ void MainWindow::buildUi()
// Thread list and message pane.
m_model = new ThreadListModel(this);
m_model->setTagColors(&m_tagColors);
- m_threadView = new QTableView(central);
+ // ThreadListView, not a plain QTableView: it paints the row-wide tag
+ // strip under each row's cells, which no delegate can do because a
+ // delegate is confined to one column's rectangle.
+ m_threadView = new ThreadListView(central);
m_threadView->setModel(m_model);
m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows);
m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection);
@@ -514,16 +518,32 @@ void MainWindow::buildUi()
column, QHeaderView::Interactive);
}
- // The subject cell carries the account chip in front of its text, and
- // every cell needs the delegate's selection handling: the read/unread
- // dimming arrives as a Qt::ForegroundRole, which Qt's default painting
- // prefers over the highlight, leaving a selected read row grey on the
- // selection colour. SubjectDelegate::initStyleOption reverses that, and
- // its paint() falls through to the base class wherever there is no chip,
- // so the other columns keep their ordinary rendering.
- m_threadView->setItemDelegate(new SubjectDelegate(this));
+ // 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.
+ m_threadView->setAlternatingRowColors(true);
+
m_threadView->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_threadView->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);
@@ -534,6 +554,7 @@ void MainWindow::buildUi()
// 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);
diff --git a/src/tagchip.cpp b/src/tagchip.cpp
index 1ac7623..1f4ad79 100644
--- a/src/tagchip.cpp
+++ b/src/tagchip.cpp
@@ -40,7 +40,13 @@ void paint(QPainter *painter, const QRect &rect, const QString &text,
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(Qt::NoPen);
painter->setBrush(background);
- painter->drawRoundedRect(rect, kRadius, kRadius);
+ // Radius from the chip's own height rather than the fixed kRadius: a 3px
+ // corner on a 17px chip reads as a slightly-softened rectangle, which is
+ // hard to tell from the square cells of the columns behind it. Half the
+ // height gives fully rounded ends, so a chip reads as an object sitting on
+ // the row instead of as another compartment of it.
+ const qreal radius = rect.height() / 2.0;
+ painter->drawRoundedRect(rect, radius, radius);
painter->setPen(TagColors::textColourOn(background));
painter->drawText(rect, Qt::AlignCenter, text);
@@ -49,8 +55,44 @@ void paint(QPainter *painter, const QRect &rect, const QString &text,
} // namespace TagChip
-void SubjectDelegate::initStyleOption(QStyleOptionViewItem *option,
- const QModelIndex &index) const
+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
{
QStyledItemDelegate::initStyleOption(option, index);
@@ -72,15 +114,57 @@ void SubjectDelegate::initStyleOption(QStyleOptionViewItem *option,
option->palette.setColor(QPalette::Text, highlighted);
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.
+ 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()) {
- QStyledItemDelegate::paint(painter, option, index);
+ // 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;
}
@@ -95,9 +179,15 @@ void SubjectDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
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,
- option.rect.top()
- + (option.rect.height() - chipSize.height()) / 2,
+ textTop + (textBandHeight - chipSize.height()) / 2,
chipSize.width(), chipSize.height());
const QColor colour =
@@ -108,6 +198,8 @@ void SubjectDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
// 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;
@@ -155,5 +247,11 @@ QSize SubjectDelegate::sizeHint(const QStyleOptionViewItem &option,
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 f43e3ff..cc3b5de 100644
--- a/src/tagchip.h
+++ b/src/tagchip.h
@@ -19,6 +19,7 @@
#pragma once
#include <QColor>
+#include <QFont>
#include <QRect>
#include <QSize>
#include <QString>
@@ -32,10 +33,13 @@ class QFontMetrics;
namespace TagChip {
/// Padding inside a chip and the gap between two of them.
-constexpr int kPaddingX = 6;
+///
+/// kPaddingX allows for the rounded ends: the corner radius is half the chip's
+/// height, so the leftmost and rightmost few pixels of the fill are curve
+/// rather than usable width, and text set closer would touch it.
+constexpr int kPaddingX = 9;
constexpr int kPaddingY = 1;
constexpr int kSpacing = 4;
-constexpr int kRadius = 3;
QSize sizeFor(const QFontMetrics &metrics, const QString &text);
@@ -46,25 +50,63 @@ void paint(QPainter *painter, const QRect &rect, const QString &text,
} // namespace TagChip
+/// Makes the selection highlight outrank a model-supplied foreground colour.
+///
+/// Qt resolves Qt::ForegroundRole into the palette's Text roles and its
+/// painting then prefers those over HighlightedText, so a model that supplies
+/// a foreground wins even on a selected row. That is wrong for the read/unread
+/// dimming, whose colour is blended against the UNSELECTED background: over
+/// 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.
+class RowStyleDelegate : public QStyledItemDelegate
+{
+ Q_OBJECT
+public:
+ using QStyledItemDelegate::QStyledItemDelegate;
+
+protected:
+ void initStyleOption(QStyleOptionViewItem *option,
+ 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.
-class SubjectDelegate : public QStyledItemDelegate
+/// **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 QStyledItemDelegate::QStyledItemDelegate;
+ 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;
-protected:
- /// Makes the selection highlight outrank a model-supplied foreground.
+ /// 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.
///
- /// Qt's own resolution does the opposite, which leaves a dimmed read
- /// thread painting grey over the selection colour.
- void initStyleOption(QStyleOptionViewItem *option,
- const QModelIndex &index) const override;
+ /// 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 7e0477d..4794d8c 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -59,6 +59,22 @@ QColor ThreadListModel::spamColour()
return QColor(0xa8, 0x5c, 0x18);
}
+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.
+ static const QString glyph = [] {
+ const char32_t star = 0x2605;
+ const QString preferred = QString::fromUcs4(&star, 1);
+ const QFontMetrics metrics{QFontDatabase::systemFont(
+ QFontDatabase::GeneralFont)};
+ return metrics.inFontUcs4(star) ? preferred : QStringLiteral("*");
+ }();
+ return glyph;
+}
+
QColor ThreadListModel::readColour()
{
// Derived from the palette, never hardcoded: a fixed grey that reads as
@@ -114,6 +130,45 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
if (role == TagsRole)
return thread.tags;
+ 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,
+ // attachment is the paperclip, unread is the row not being dimmed, and
+ // inbox is structural rather than informative. Spending the pill row on
+ // any of those would repeat what is already on screen.
+ //
+ // deleted and spam are kept: they repaint the whole row, so a pill is
+ // redundant there too, but a doomed thread is rare and worth naming.
+ static const QStringList hidden = {
+ QStringLiteral("inbox"),
+ QStringLiteral("unread"),
+ QStringLiteral("flagged"),
+ QStringLiteral("attachment"),
+ };
+
+ QStringList pills;
+ for (const QString &tag : thread.tags) {
+ if (hidden.contains(tag) || TagColors::isAccountTag(tag))
+ continue;
+ pills.append(tag);
+ }
+ // Sorted rather than in notmuch's order, which is not guaranteed
+ // stable: a row whose pills reordered between repaints would flicker.
+ pills.sort();
+
+ if (role == PillTagsRole)
+ return pills;
+
+ // Same order as the names, so the delegate can walk the two together.
+ QVariantList colours;
+ colours.reserve(pills.size());
+ for (const QString &tag : pills) {
+ colours.append(m_tagColors ? m_tagColors->colourFor(tag)
+ : TagColors().colourFor(tag));
+ }
+ return colours;
+ }
+
if (role == AccountLabelRole || role == AccountColourRole) {
// At most one account tag per thread in practice, but a thread whose
// messages landed in two mailboxes carries both; the first is shown.
@@ -134,8 +189,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
if (role == Qt::ToolTipRole && index.column() == AttachmentColumn)
return thread.hasAttachment() ? tr("Has an attachment") : QVariant();
- if (role == Qt::TextAlignmentRole && index.column() == AttachmentColumn)
+ if (role == Qt::ToolTipRole && index.column() == FlagColumn)
+ return thread.isFlagged() ? tr("Flagged") : 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::DisplayRole) {
switch (index.column()) {
@@ -144,6 +206,11 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
// 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:
@@ -170,19 +237,20 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const
return QBrush(QColor(Qt::white));
}
- // Unread's cue, and it deliberately does NOT rely on the bold below.
+ // Unread's second cue, independent of the bold below.
//
- // Bold was the only cue until 2026-08-07, when it turned out to render
- // identically to regular on the user's system: confirmed with a bare
- // QTableView and a plain QStandardItemModel, so the fault is in Qt or
- // fontconfig, below this application, and nothing here can reach it.
+ // Bold alone was the only distinction until 2026-08-07, which leaves
+ // nothing to see when the desktop's own font is configured bold: every row
+ // renders bold and setBold() changes nothing. That is a font setting
+ // rather than a defect here, but a cue with a single point of failure is
+ // worth reinforcing.
//
- // So the emphasis is inverted instead. Unread rows are left at the
+ // So the emphasis is inverted as well. Unread rows are left at the
// palette's own text colour, and READ rows are dimmed toward the
- // background. That way the cue rides on ForegroundRole, which the delegate
- // already honours, and it costs no column. It also suits the real ratio:
- // with a few dozen unread among thousands read, dimming the bulk is calmer
- // than highlighting it.
+ // background. The cue rides on ForegroundRole, which the delegate already
+ // honours, and costs no column. It also suits the real ratio: with a few
+ // dozen unread among thousands read, dimming the bulk is calmer than
+ // highlighting it.
//
// BELOW the doomed branch on purpose, and that ordering is the whole
// protection: a deleted or spam thread has already returned white text for
@@ -222,9 +290,10 @@ QVariant ThreadListModel::headerData(int section, Qt::Orientation orientation,
// 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 DateColumn: return QStringLiteral("Date");
- case AuthorsColumn: return QStringLiteral("From");
- case SubjectColumn: return QStringLiteral("Subject");
+ case FlagColumn: return QString();
+ case DateColumn: return tr("Date");
+ case AuthorsColumn: return tr("From");
+ case SubjectColumn: return tr("Subject");
default: return {};
}
}
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index 152730f..461e467 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -40,6 +40,11 @@ public:
/// 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,
@@ -62,6 +67,16 @@ public:
/// Every tag on the thread, for the strip under the message pane.
TagsRole,
+
+ /// The tags worth drawing as pills under the subject: every tag except
+ /// the ones the row already shows another way. Sorted, so a row does
+ /// not reshuffle its own pills between repaints.
+ PillTagsRole,
+
+ /// The colours for PillTagsRole, in the same order. Supplied by the
+ /// model because it owns the TagColors instance; a delegate reading
+ /// config itself would be a second source of truth.
+ PillColoursRole,
};
/// Row fill for a thread tagged `deleted`, and for one tagged `spam`.
@@ -72,16 +87,20 @@ public:
/// A paperclip when the system font can draw it, "*" otherwise.
static QString attachmentGlyph();
+ /// The character shown in FlagColumn for a flagged thread.
+ /// A star when the system font can draw it, "*" otherwise.
+ static QString flagGlyph();
+
static QColor deletedColour();
static QColor spamColour();
/// The dimmed text colour a READ thread carries.
///
/// Unread rows are left at the palette's own colour and read ones recede,
- /// rather than unread being emphasised. Bold used to be the only cue and
- /// cannot be relied on: on at least one system it renders identically to
- /// regular, which is a Qt or fontconfig matter this application cannot
- /// reach. Derived from the palette, never hardcoded.
+ /// rather than unread being emphasised. Bold alone used to be the only
+ /// cue, which leaves nothing to see when the desktop font is itself
+ /// configured bold; colour is a second cue that survives that. Derived
+ /// from the palette, never hardcoded.
static QColor readColour();
explicit ThreadListModel(QObject *parent = nullptr);
diff --git a/src/threadlistview.cpp b/src/threadlistview.cpp
new file mode 100644
index 0000000..ef80e09
--- /dev/null
+++ b/src/threadlistview.cpp
@@ -0,0 +1,140 @@
+/*
+ * 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 "threadlistview.h"
+
+#include "tagchip.h"
+#include "threadlistmodel.h"
+
+#include <QPaintEvent>
+#include <QPainter>
+#include <QScrollBar>
+
+void ThreadListView::paintEvent(QPaintEvent *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;
+ }
+ }
+}
diff --git a/src/threadlistview.h b/src/threadlistview.h
new file mode 100644
index 0000000..0b4eafc
--- /dev/null
+++ b/src/threadlistview.h
@@ -0,0 +1,47 @@
+/*
+ * 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 <QTableView>
+
+/// The thread list, with a row-wide strip of tag chips under each row's cells.
+///
+/// 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:
+///
+/// [ 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
+{
+ Q_OBJECT
+public:
+ using QTableView::QTableView;
+
+protected:
+ void paintEvent(QPaintEvent *event) override;
+};