aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
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;
+};