diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 39 | ||||
| -rw-r--r-- | src/tagchip.cpp | 110 | ||||
| -rw-r--r-- | src/tagchip.h | 62 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 97 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 27 | ||||
| -rw-r--r-- | src/threadlistview.cpp | 140 | ||||
| -rw-r--r-- | src/threadlistview.h | 47 |
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; +}; |
