diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-03 15:41:16 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-03 15:41:16 +0200 |
| commit | cd0be6dc5e4741c2ad180d6bf6a275f000ab943a (patch) | |
| tree | c2c704065c398ba33d9549bfb025d30dbb0a46c4 | |
| parent | ab16342f6181f69e5cb1ef528f1dd0eb433bb74b (diff) | |
| download | qtmaildir-cd0be6dc5e4741c2ad180d6bf6a275f000ab943a.tar.gz qtmaildir-cd0be6dc5e4741c2ad180d6bf6a275f000ab943a.zip | |
feat: render tags as chips instead of a text column
Spelled out per row, tags ran to 500 pixels of largely repeated text and
took most of the thread list's width. The column is gone; tags render as
coloured chips split by what they actually mean.
An account tag says which mailbox a thread arrived in, and draws as a
chip in front of the subject. A functional tag says what state a thread
is in, and those fill one row under the message pane. One row keeps the
message area from shifting between threads with different tag counts, so
whatever does not fit collapses into a +N chip that names the rest in its
tooltip.
TagColors resolves a colour by exact tag first, then by top-level prefix,
so a single "shopping" entry covers shopping/amazon and shopping/nike
while shopping/amazon can still override its own. That matters at 96
tags. Built-in defaults cover the usual state tags, and anything left
unconfigured falls back to a hash of the name, stable so a chip does not
change colour as the list scrolls.
| -rw-r--r-- | src/messageview.cpp | 18 | ||||
| -rw-r--r-- | src/messageview.h | 9 | ||||
| -rw-r--r-- | src/tagchip.cpp | 126 | ||||
| -rw-r--r-- | src/tagchip.h | 62 | ||||
| -rw-r--r-- | src/tagcolors.cpp | 171 | ||||
| -rw-r--r-- | src/tagcolors.h | 92 | ||||
| -rw-r--r-- | src/tagstrip.cpp | 136 | ||||
| -rw-r--r-- | src/tagstrip.h | 63 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 23 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 27 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_tagcolors.cpp | 251 | ||||
| -rw-r--r-- | tests/test_threadlistmodel.cpp | 88 |
13 files changed, 1052 insertions, 15 deletions
diff --git a/src/messageview.cpp b/src/messageview.cpp index 3bb08a0..aebb81b 100644 --- a/src/messageview.cpp +++ b/src/messageview.cpp @@ -34,6 +34,7 @@ #include "cidschemehandler.h" #include "htmlbuilder.h" #include "requestinterceptor.h" +#include "tagstrip.h" #include "threadcidmap.h" namespace { @@ -118,17 +119,33 @@ MessageView::MessageView(QWidget *parent) m_attachmentBar = new QWidget(this); new QHBoxLayout(m_attachmentBar); + // Tags live under the message rather than in the thread list, where + // spelling them out cost most of the list's width. + m_tagStrip = new TagStrip(this); + m_tagStrip->hide(); + auto *layout = new QVBoxLayout(this); layout->addWidget(m_headerLabel); layout->addLayout(blockedRow); layout->addWidget(m_view, 1); layout->addWidget(m_attachmentBar); + layout->addWidget(m_tagStrip); clear(); } MessageView::~MessageView() = default; +void MessageView::setTagColors(const TagColors *colours) +{ + m_tagStrip->setTagColors(colours); +} + +void MessageView::setTags(const QStringList &tags) +{ + m_tagStrip->setTags(tags); +} + /// The single place that loads a document into the view. /// /// RequestInterceptor trusts exactly one qtmaildir: URL and denies every other @@ -145,6 +162,7 @@ void MessageView::setDocument(const QString &html) void MessageView::clear() { m_items.clear(); + m_tagStrip->setTags({}); // No thread is displayed, so nothing may be served or allowed. Without // this, the previous thread's parts would stay reachable. diff --git a/src/messageview.h b/src/messageview.h index f3bd96f..9570db5 100644 --- a/src/messageview.h +++ b/src/messageview.h @@ -30,6 +30,8 @@ class QPushButton; class QWebEngineView; class QWebEngineProfile; class CidSchemeHandler; +class TagColors; +class TagStrip; class RequestInterceptor; /// The message pane: thread header, body, attachment bar. @@ -57,6 +59,12 @@ public: void showError(const QString &text, const QString &filePath); void clear(); + /// Supplies the tag strip's colours. Not owned; must outlive the view. + void setTagColors(const TagColors *colours); + + /// Tags of the thread on display, shown as chips along the bottom. + void setTags(const QStringList &tags); + public slots: void toggleHtml(); void loadRemoteContent(); @@ -81,4 +89,5 @@ private: QLabel *m_blockedLabel = nullptr; QPushButton *m_loadRemoteButton = nullptr; QWidget *m_attachmentBar = nullptr; + TagStrip *m_tagStrip = nullptr; }; diff --git a/src/tagchip.cpp b/src/tagchip.cpp new file mode 100644 index 0000000..2e21419 --- /dev/null +++ b/src/tagchip.cpp @@ -0,0 +1,126 @@ +/* + * 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 "tagchip.h" + +#include <QApplication> +#include <QFontMetrics> +#include <QPainter> + +#include "tagcolors.h" +#include "threadlistmodel.h" + +namespace TagChip { + +QSize sizeFor(const QFontMetrics &metrics, const QString &text) +{ + return QSize(metrics.horizontalAdvance(text) + kPaddingX * 2, + metrics.height() + kPaddingY * 2); +} + +void paint(QPainter *painter, const QRect &rect, const QString &text, + const QColor &background) +{ + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(Qt::NoPen); + painter->setBrush(background); + painter->drawRoundedRect(rect, kRadius, kRadius); + + painter->setPen(TagColors::textColourOn(background)); + painter->drawText(rect, Qt::AlignCenter, text); + painter->restore(); +} + +} // namespace TagChip + +void SubjectDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + const QString account = + index.data(ThreadListModel::AccountLabelRole).toString(); + if (account.isEmpty()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + + // Draw the row's own background and selection first, then the chip and the + // subject on top, so a selected or struck-through row still looks right. + QStyleOptionViewItem chrome = option; + initStyleOption(&chrome, index); + chrome.text.clear(); + const QWidget *widget = option.widget; + QStyle *style = widget ? widget->style() : QApplication::style(); + style->drawControl(QStyle::CE_ItemViewItem, &chrome, painter, widget); + + const QFontMetrics metrics(option.font); + const QSize chipSize = TagChip::sizeFor(metrics, account); + const QRect chipRect(option.rect.left() + TagChip::kSpacing, + option.rect.top() + + (option.rect.height() - chipSize.height()) / 2, + chipSize.width(), chipSize.height()); + + const QColor colour = + index.data(ThreadListModel::AccountColourRole).value<QColor>(); + TagChip::paint(painter, chipRect, account, + colour.isValid() ? colour : QColor(0x55, 0x55, 0x5f)); + + // The subject follows the chip, elided so a long one cannot overflow. + QRect textRect = option.rect; + textRect.setLeft(chipRect.right() + TagChip::kSpacing * 2); + if (textRect.width() <= 0) + return; + + painter->save(); + // The model supplies the row's colours; honouring them keeps a deleted + // thread white-on-red here as everywhere else. + const QVariant foreground = index.data(Qt::ForegroundRole); + if (foreground.isValid()) + painter->setPen(foreground.value<QBrush>().color()); + else if (option.state & QStyle::State_Selected) + painter->setPen(option.palette.highlightedText().color()); + else + painter->setPen(option.palette.text().color()); + + // The model's font carries bold for unread and strike-out for deleted. + // initStyleOption() already resolved it into chrome.font; using it rather + // than option.font is what keeps those cues on a delegate-drawn subject. + const QVariant fontData = index.data(Qt::FontRole); + const QFont rowFont = fontData.isValid() ? fontData.value<QFont>() + : chrome.font; + painter->setFont(rowFont); + const QFontMetrics rowMetrics(rowFont); + painter->drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, + rowMetrics.elidedText(index.data(Qt::DisplayRole).toString(), + Qt::ElideRight, textRect.width())); + painter->restore(); +} + +QSize SubjectDelegate::sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + QSize size = QStyledItemDelegate::sizeHint(option, index); + const QString account = + index.data(ThreadListModel::AccountLabelRole).toString(); + if (!account.isEmpty()) { + const QFontMetrics metrics(option.font); + size.setWidth(size.width() + TagChip::sizeFor(metrics, account).width() + + TagChip::kSpacing * 3); + } + return size; +} diff --git a/src/tagchip.h b/src/tagchip.h new file mode 100644 index 0000000..9bd4e78 --- /dev/null +++ b/src/tagchip.h @@ -0,0 +1,62 @@ +/* + * 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 <QColor> +#include <QRect> +#include <QSize> +#include <QString> +#include <QStyledItemDelegate> + +class QPainter; +class QFontMetrics; + +/// Draws one rounded, filled tag chip. Shared so the account chip in the +/// thread list and the strip under the message pane cannot drift apart. +namespace TagChip { + +/// Padding inside a chip and the gap between two of them. +constexpr int kPaddingX = 6; +constexpr int kPaddingY = 1; +constexpr int kSpacing = 4; +constexpr int kRadius = 3; + +QSize sizeFor(const QFontMetrics &metrics, const QString &text); + +/// Paints the chip into `rect`, using `text` and `background`. The text colour +/// is derived from the fill so it stays legible. +void paint(QPainter *painter, const QRect &rect, const QString &text, + const QColor &background); + +} // namespace TagChip + +/// 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 +{ + Q_OBJECT +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const override; +}; diff --git a/src/tagcolors.cpp b/src/tagcolors.cpp new file mode 100644 index 0000000..88ca2ab --- /dev/null +++ b/src/tagcolors.cpp @@ -0,0 +1,171 @@ +/* + * 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 "tagcolors.h" + +#include <QCryptographicHash> +#include <QSettings> + +namespace { + +/// Colours for the tags every notmuch setup has. Chosen to stay legible on a +/// dark theme, which is where the message pane already sits. +QHash<QString, QColor> builtInColours() +{ + return { + { QStringLiteral("flagged"), QColor(0xd4, 0x9c, 0x1a) }, + { QStringLiteral("unread"), QColor(0x2f, 0x6f, 0xa8) }, + { QStringLiteral("deleted"), QColor(0x8b, 0x2c, 0x2c) }, + { QStringLiteral("spam"), QColor(0xa8, 0x5c, 0x18) }, + { QStringLiteral("attachment"), QColor(0x5a, 0x5a, 0x64) }, + { QStringLiteral("replied"), QColor(0x3d, 0x7a, 0x4a) }, + { QStringLiteral("passed"), QColor(0x3d, 0x7a, 0x62) }, + { QStringLiteral("draft"), QColor(0x77, 0x66, 0x33) }, + { QStringLiteral("encrypted"), QColor(0x6a, 0x4a, 0x8a) }, + { QStringLiteral("signed"), QColor(0x53, 0x4a, 0x8a) }, + { QStringLiteral("inbox"), QColor(0x44, 0x4a, 0x52) }, + { QStringLiteral("mailing-list"), QColor(0x36, 0x6a, 0x6a) }, + }; +} + +} // namespace + +bool TagColors::isAccountTag(const QString &tag) +{ + // The prefix alone, with nothing after it, names no account. + return tag.startsWith(accountTagPrefix()) + && tag.size() > accountTagPrefix().size(); +} + +QString TagColors::accountKeyForTag(const QString &tag) +{ + if (!isAccountTag(tag)) + return {}; + return tag.mid(accountTagPrefix().size()); +} + +QString TagColors::tagForAccountKey(const QString &key) +{ + return accountTagPrefix() + key; +} + +QColor TagColors::textColourOn(const QColor &background) +{ + // Perceived luminance: the eye weights green far above blue, so a plain + // average would call a saturated blue "light" and print black on it. + const double luminance = (0.299 * background.red() + + 0.587 * background.green() + + 0.114 * background.blue()) / 255.0; + return luminance > 0.55 ? QColor(Qt::black) : QColor(Qt::white); +} + +QString TagColors::topLevelPrefix(const QString &tag) +{ + const int slash = tag.indexOf(QLatin1Char('/')); + return slash < 0 ? tag : tag.left(slash); +} + +void TagColors::load(QSettings &settings) +{ + settings.beginGroup(QStringLiteral("tagcolors")); + // allKeys(), not childKeys(): QSettings treats '/' in a key as a group + // separator, so a hierarchical tag like shopping/amazon becomes a nested + // key that childKeys() does not return. allKeys() reports both, and the + // nested one comes back in the "shopping/amazon" form the tag already has. + // (In the INI file itself it is written as shopping\amazon.) + const QStringList keys = settings.allKeys(); + for (const QString &key : keys) { + const QString value = settings.value(key).toString(); + const QColor colour(value); + if (!colour.isValid()) { + m_warnings.append( + QStringLiteral("Unparseable colour '%1' for tag '%2' in " + "[tagcolors]").arg(value, key)); + continue; + } + m_colours.insert(key, colour); + } + settings.endGroup(); +} + +void TagColors::setAccountColour(const QString &accountKey, const QColor &colour) +{ + if (accountKey.isEmpty() || !colour.isValid()) + return; + m_accountColours.insert(accountKey, colour); +} + +void TagColors::setAccountLabel(const QString &accountKey, const QString &label) +{ + if (accountKey.isEmpty() || label.isEmpty()) + return; + m_accountLabels.insert(accountKey, label); +} + +QString TagColors::labelForAccountTag(const QString &tag) const +{ + const QString key = accountKeyForTag(tag); + if (key.isEmpty()) + return {}; + return m_accountLabels.value(key, key); +} + +bool TagColors::hasColour(const QString &tag) const +{ + if (isAccountTag(tag)) + return m_accountColours.contains(accountKeyForTag(tag)); + + const QHash<QString, QColor> builtIn = builtInColours(); + return m_colours.contains(tag) || builtIn.contains(tag) + || m_colours.contains(topLevelPrefix(tag)) + || builtIn.contains(topLevelPrefix(tag)); +} + +QColor TagColors::colourFor(const QString &tag) const +{ + // An account's colour lives in its own stanza, not in [tagcolors]. + if (isAccountTag(tag)) { + const QColor colour = m_accountColours.value(accountKeyForTag(tag)); + if (colour.isValid()) + return colour; + } + + const QHash<QString, QColor> builtIn = builtInColours(); + + // Most specific first: an exact entry must beat the prefix it falls under, + // or a single child tag could never be singled out. + if (m_colours.contains(tag)) + return m_colours.value(tag); + if (builtIn.contains(tag)) + return builtIn.value(tag); + + const QString prefix = topLevelPrefix(tag); + if (m_colours.contains(prefix)) + return m_colours.value(prefix); + if (builtIn.contains(prefix)) + return builtIn.value(prefix); + + // Nothing configured: derive a colour from the name so the chip is still + // readable and distinguishable. Hashing keeps it stable across calls, and + // the fixed saturation and lightness keep it in the same family as the + // built-ins rather than producing neon. + const QByteArray digest = + QCryptographicHash::hash(tag.toUtf8(), QCryptographicHash::Md5); + const int hue = static_cast<quint8>(digest.at(0)) * 360 / 256; + return QColor::fromHsl(hue, 90, 80); +} diff --git a/src/tagcolors.h b/src/tagcolors.h new file mode 100644 index 0000000..f9e1a95 --- /dev/null +++ b/src/tagcolors.h @@ -0,0 +1,92 @@ +/* + * 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 <QColor> +#include <QHash> +#include <QString> +#include <QStringList> + +class QSettings; + +/// Colours for tag chips. +/// +/// Tags fall into two taxonomies. A functional tag says what state a thread is +/// in (flagged, replied, shopping/amazon) and is coloured from built-in +/// defaults or the [tagcolors] config group. An account tag says which mailbox +/// it arrived in, is named account-<key> after the [account.<key>] stanza, and +/// takes its colour from that stanza instead. +/// +/// Lookup is by exact tag first, then by top-level prefix, so one entry can +/// colour a whole hierarchy: "shopping" covers shopping/amazon and +/// shopping/nike, while "shopping/amazon" still overrides its own. +class TagColors +{ +public: + /// The prefix marking a tag as naming an account rather than a state. + static QString accountTagPrefix() { return QStringLiteral("account-"); } + + static bool isAccountTag(const QString &tag); + + /// The [account.<key>] suffix behind an account tag, empty if not one. + static QString accountKeyForTag(const QString &tag); + + /// The tag notmuch carries for an account key. The mapping is derived, + /// never configured, so the two cannot drift. + static QString tagForAccountKey(const QString &key); + + /// Black or white, whichever stays legible on the given fill. + static QColor textColourOn(const QColor &background); + + /// Reads the [tagcolors] group. An unparseable colour is collected into + /// warnings() and the previous value kept, so one typo cannot leave a tag + /// unstyled. + void load(QSettings &settings); + + /// Registers an account's colour, taken from its own stanza. + void setAccountColour(const QString &accountKey, const QColor &colour); + + /// Registers the text shown on an account's chip. Empty is ignored: a + /// blank label would render an unreadable chip. The notmuch tag itself is + /// never renamed, only what the chip displays. + void setAccountLabel(const QString &accountKey, const QString &label); + + /// Chip text for an account tag, falling back to the account key. Empty + /// when the tag does not name an account. + QString labelForAccountTag(const QString &tag) const; + + /// True when this tag resolves to a colour that was chosen for it, as + /// opposed to the fallback every unknown tag receives. + bool hasColour(const QString &tag) const; + + /// Always valid: an unconfigured tag falls back to a colour derived from + /// its name, stable across calls so a chip never changes as you scroll. + QColor colourFor(const QString &tag) const; + + QStringList warnings() const { return m_warnings; } + +private: + /// The part before the first '/', which is the whole tag when it has none. + static QString topLevelPrefix(const QString &tag); + + QHash<QString, QColor> m_colours; ///< Exact tags and prefixes. + QHash<QString, QColor> m_accountColours; ///< Keyed by account key. + QHash<QString, QString> m_accountLabels; ///< Keyed by account key. + QStringList m_warnings; +}; diff --git a/src/tagstrip.cpp b/src/tagstrip.cpp new file mode 100644 index 0000000..bad116a --- /dev/null +++ b/src/tagstrip.cpp @@ -0,0 +1,136 @@ +/* + * 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 "tagstrip.h" + +#include <QFontMetrics> +#include <QPainter> + +#include "tagchip.h" +#include "tagcolors.h" + +namespace { + +/// Text of the chip standing in for tags that did not fit. +QString overflowText(int count) +{ + return QStringLiteral("+%1").arg(count); +} + +} // namespace + +TagStrip::TagStrip(QWidget *parent) + : QWidget(parent) +{ + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); +} + +void TagStrip::setTagColors(const TagColors *colours) +{ + m_tagColors = colours; + update(); +} + +void TagStrip::setTags(const QStringList &tags) +{ + m_tags.clear(); + for (const QString &tag : tags) { + // The account tag is shown as a chip in the thread list instead: it + // says which mailbox the thread came from, not what state it is in. + if (!TagColors::isAccountTag(tag)) + m_tags.append(tag); + } + m_tags.sort(); + + relayout(); + setVisible(!m_tags.isEmpty()); + update(); +} + +void TagStrip::relayout() +{ + m_visible.clear(); + m_hidden.clear(); + if (m_tags.isEmpty()) + return; + + const QFontMetrics metrics(font()); + // Reserve room for the overflow chip up front. Sizing it for the worst + // case avoids the loop having to back out a chip it already placed. + const int overflowWidth = + TagChip::sizeFor(metrics, overflowText(m_tags.size())).width() + + TagChip::kSpacing; + + int used = 0; + for (int i = 0; i < m_tags.size(); ++i) { + const int chipWidth = + TagChip::sizeFor(metrics, m_tags.at(i)).width() + TagChip::kSpacing; + const bool isLast = (i == m_tags.size() - 1); + // Every chip but the last must also leave room for the overflow chip, + // since anything after it will be hidden. + const int needed = used + chipWidth + (isLast ? 0 : overflowWidth); + if (needed > width() && !m_visible.isEmpty()) { + m_hidden = m_tags.mid(i); + break; + } + m_visible.append(m_tags.at(i)); + used += chipWidth; + } + + setToolTip(m_hidden.isEmpty() ? QString() + : m_hidden.join(QStringLiteral(", "))); +} + +QSize TagStrip::sizeHint() const +{ + const QFontMetrics metrics(font()); + return QSize(0, metrics.height() + TagChip::kPaddingY * 2 + + TagChip::kSpacing * 2); +} + +void TagStrip::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + relayout(); +} + +void TagStrip::paintEvent(QPaintEvent *) +{ + if (m_visible.isEmpty()) + return; + + QPainter painter(this); + const QFontMetrics metrics(font()); + const int top = (height() - (metrics.height() + TagChip::kPaddingY * 2)) / 2; + + int x = 0; + for (const QString &tag : m_visible) { + const QSize size = TagChip::sizeFor(metrics, tag); + const QColor colour = m_tagColors ? m_tagColors->colourFor(tag) + : TagColors().colourFor(tag); + TagChip::paint(&painter, QRect(QPoint(x, top), size), tag, colour); + x += size.width() + TagChip::kSpacing; + } + + if (!m_hidden.isEmpty()) { + const QString text = overflowText(m_hidden.size()); + const QSize size = TagChip::sizeFor(metrics, text); + TagChip::paint(&painter, QRect(QPoint(x, top), size), text, + QColor(0x44, 0x44, 0x4c)); + } +} diff --git a/src/tagstrip.h b/src/tagstrip.h new file mode 100644 index 0000000..4102bed --- /dev/null +++ b/src/tagstrip.h @@ -0,0 +1,63 @@ +/* + * 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 <QStringList> +#include <QWidget> + +class TagColors; + +/// One row of tag chips under the message pane. +/// +/// A single row by design: the message area must not shift as you move between +/// threads with different numbers of tags. Whatever does not fit collapses +/// into a trailing "+N" chip whose tooltip names the hidden tags. +class TagStrip : public QWidget +{ + Q_OBJECT +public: + explicit TagStrip(QWidget *parent = nullptr); + + /// Not owned; must outlive the strip. + void setTagColors(const TagColors *colours); + + /// Account tags are filtered out: they belong to the thread list chip, + /// being a different taxonomy from the functional tags shown here. + void setTags(const QStringList &tags); + + QSize sizeHint() const override; + + /// The tags actually drawn, in order. Exposed for testing the overflow + /// split without rendering. + QStringList visibleTags() const { return m_visible; } + QStringList hiddenTags() const { return m_hidden; } + +protected: + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private: + /// Recomputes the visible/hidden split for the current width. + void relayout(); + + QStringList m_tags; ///< Functional tags only, account ones removed. + QStringList m_visible; + QStringList m_hidden; + const TagColors *m_tagColors = nullptr; +}; diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp index a083145..2f2882e 100644 --- a/src/threadlistmodel.cpp +++ b/src/threadlistmodel.cpp @@ -65,6 +65,26 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const if (role == ThreadIdRole) return thread.threadId; + if (role == TagsRole) + return thread.tags; + + 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. + for (const QString &tag : thread.tags) { + if (!TagColors::isAccountTag(tag)) + continue; + if (role == AccountLabelRole) { + // The configured label when there is one, otherwise the key. + return m_tagColors ? m_tagColors->labelForAccountTag(tag) + : TagColors::accountKeyForTag(tag); + } + return m_tagColors ? m_tagColors->colourFor(tag) + : TagColors().colourFor(tag); + } + return {}; + } + if (role == Qt::DisplayRole) { switch (index.column()) { case DateColumn: @@ -76,8 +96,6 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const ? QStringLiteral("%1 (%2)").arg(thread.subject) .arg(thread.totalCount) : thread.subject; - case TagsColumn: - return thread.tags.join(QLatin1Char(' ')); default: return {}; } @@ -126,7 +144,6 @@ QVariant ThreadListModel::headerData(int section, Qt::Orientation orientation, case DateColumn: return QStringLiteral("Date"); case AuthorsColumn: return QStringLiteral("From"); case SubjectColumn: return QStringLiteral("Subject"); - case TagsColumn: return QStringLiteral("Tags"); default: return {}; } } diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h index 01fd241..7ed8fef 100644 --- a/src/threadlistmodel.h +++ b/src/threadlistmodel.h @@ -22,6 +22,7 @@ #include <QColor> #include <QVector> +#include "tagcolors.h" #include "types.h" /// Table model over query results, filled in batches so a large query paints @@ -30,12 +31,12 @@ class ThreadListModel : public QAbstractTableModel { Q_OBJECT public: - /// Subject stretches to fill the view, so it must come last: anything - /// after it is pushed out of sight. Tags leads, being the column that - /// changes when the user acts on a thread. + /// No tags column: spelling out a dozen tags per row cost most of the + /// list's width and was unreadable. Functional tags moved to a chip strip + /// under the message pane, and the account tag renders as a chip in front + /// of the subject. enum Column { - TagsColumn = 0, - DateColumn, + DateColumn = 0, AuthorsColumn, SubjectColumn, ColumnCount, @@ -46,6 +47,17 @@ public: /// worker speaks thread ids, so the mapping belongs on the model /// rather than in every caller. ThreadIdRole = Qt::UserRole + 1, + + /// The account tag on this thread without its "account-" prefix, for + /// the chip drawn in front of the subject. Empty when the thread + /// carries none. + AccountLabelRole, + + /// Fill colour for that chip. + AccountColourRole, + + /// Every tag on the thread, for the strip under the message pane. + TagsRole, }; /// Row fill for a thread tagged `deleted`, and for one tagged `spam`. @@ -57,6 +69,10 @@ public: explicit ThreadListModel(QObject *parent = nullptr); + /// Supplies the account chip colours. Not owned; must outlive the model. + /// Without one, chips fall back to a colour generated from the tag name. + void setTagColors(const TagColors *colours) { m_tagColors = colours; } + int rowCount(const QModelIndex &parent = {}) const override; int columnCount(const QModelIndex &parent = {}) const override; QVariant data(const QModelIndex &index, int role) const override; @@ -76,4 +92,5 @@ public: private: QVector<ThreadSummary> m_threads; + const TagColors *m_tagColors = nullptr; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1833f29..e761cb6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,7 @@ target_compile_definitions(test_mimeparser PRIVATE add_qtmaildir_test(interceptor) add_qtmaildir_test(htmlbuilder) add_qtmaildir_test(notmuchworker) +add_qtmaildir_test(tagcolors) add_qtmaildir_test(threadlistmodel) add_qtmaildir_test(mailsync) add_qtmaildir_test(threadcidmap) diff --git a/tests/test_tagcolors.cpp b/tests/test_tagcolors.cpp new file mode 100644 index 0000000..53c0210 --- /dev/null +++ b/tests/test_tagcolors.cpp @@ -0,0 +1,251 @@ +/* + * 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 <QSettings> +#include <QTemporaryDir> +#include <QtTest> + +#include "tagcolors.h" + +class TestTagColors : public QObject +{ + Q_OBJECT +private slots: + void builtInDefaultsExist(); + void prefixColoursWholeHierarchy(); + void exactTagBeatsItsPrefix(); + void configOverridesABuiltIn(); + void unknownTagStillGetsAColour(); + void accountTagsAreRecognised(); + void accountColourComesFromTheAccount(); + void accountLabelDefaultsToTheKey(); + void accountLabelCanBeOverridden(); + void malformedColourIsReported(); + void textContrastsWithItsBackground(); +}; + +void TestTagColors::builtInDefaultsExist() +{ + // The common state tags must be styled out of the box: a user who never + // writes a [tagcolors] section still needs flagged to stand out. + TagColors colours; + const QStringList expected = { QStringLiteral("flagged"), + QStringLiteral("unread"), + QStringLiteral("deleted"), + QStringLiteral("spam"), + QStringLiteral("attachment"), + QStringLiteral("replied") }; + for (const QString &tag : expected) { + QVERIFY2(colours.hasColour(tag), + qPrintable(QStringLiteral("no built-in colour for '%1'").arg(tag))); + } +} + +void TestTagColors::prefixColoursWholeHierarchy() +{ + // 96 tags, many of them shopping/foo and mailing-list/bar. Colouring by + // top-level prefix is what keeps the config from listing every one. + TagColors colours; + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("tagcolors")); + s.setValue(QStringLiteral("shopping"), QStringLiteral("#3366cc")); + s.endGroup(); + } + QSettings s(path, QSettings::IniFormat); + colours.load(s); + + QCOMPARE(colours.colourFor(QStringLiteral("shopping/amazon")), + QColor(QStringLiteral("#3366cc"))); + QCOMPARE(colours.colourFor(QStringLiteral("shopping/nike")), + QColor(QStringLiteral("#3366cc"))); + // The bare prefix itself is a tag too. + QCOMPARE(colours.colourFor(QStringLiteral("shopping")), + QColor(QStringLiteral("#3366cc"))); + // A different hierarchy is unaffected. + QVERIFY(colours.colourFor(QStringLiteral("mailing-list/SBo")) + != QColor(QStringLiteral("#3366cc"))); +} + +void TestTagColors::exactTagBeatsItsPrefix() +{ + // Specific beats general, or you could never single out one child tag. + TagColors colours; + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("tagcolors")); + s.setValue(QStringLiteral("shopping"), QStringLiteral("#3366cc")); + s.setValue(QStringLiteral("shopping/amazon"), QStringLiteral("#ff9900")); + s.endGroup(); + } + QSettings s(path, QSettings::IniFormat); + colours.load(s); + + QCOMPARE(colours.colourFor(QStringLiteral("shopping/amazon")), + QColor(QStringLiteral("#ff9900"))); + QCOMPARE(colours.colourFor(QStringLiteral("shopping/nike")), + QColor(QStringLiteral("#3366cc"))); + + // Regression: QSettings treats '/' as a group separator, so a + // hierarchical tag is a nested key that childKeys() never returns. Reading + // the group with childKeys() silently dropped every tag with a '/' in it, + // which is most of this user's, and they all fell through to their prefix. + QVERIFY(colours.hasColour(QStringLiteral("shopping/amazon"))); +} + +void TestTagColors::configOverridesABuiltIn() +{ + TagColors colours; + const QColor original = colours.colourFor(QStringLiteral("flagged")); + + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("tagcolors")); + s.setValue(QStringLiteral("flagged"), QStringLiteral("#00ff00")); + s.endGroup(); + } + QSettings s(path, QSettings::IniFormat); + colours.load(s); + + QCOMPARE(colours.colourFor(QStringLiteral("flagged")), + QColor(QStringLiteral("#00ff00"))); + QVERIFY(colours.colourFor(QStringLiteral("flagged")) != original); +} + +void TestTagColors::unknownTagStillGetsAColour() +{ + // A chip with no colour would render as an invisible blank, so every tag + // resolves to something even when nothing is configured for it. + TagColors colours; + const QColor colour = colours.colourFor(QStringLiteral("no-such-tag-anywhere")); + QVERIFY(colour.isValid()); + + // Stable across calls: a tag must not change colour as you scroll. + QCOMPARE(colours.colourFor(QStringLiteral("no-such-tag-anywhere")), colour); +} + +void TestTagColors::accountTagsAreRecognised() +{ + // Account tags are a different taxonomy from functional tags: which + // mailbox a thread came from, not what state it is in. They are shown + // separately, so they have to be identifiable. + QVERIFY(TagColors::isAccountTag(QStringLiteral("account-gmail-danixland"))); + QVERIFY(!TagColors::isAccountTag(QStringLiteral("flagged"))); + QVERIFY(!TagColors::isAccountTag(QStringLiteral("shopping/amazon"))); + + // The INI key for [account.gmail-danixland] is what follows "account-". + QCOMPARE(TagColors::accountKeyForTag(QStringLiteral("account-gmail-danixland")), + QStringLiteral("gmail-danixland")); + QVERIFY(TagColors::accountKeyForTag(QStringLiteral("flagged")).isEmpty()); + + // Round trip, since the mapping is derived rather than configured. + QCOMPARE(TagColors::tagForAccountKey(QStringLiteral("gmail-danixland")), + QStringLiteral("account-gmail-danixland")); +} + +void TestTagColors::accountColourComesFromTheAccount() +{ + // Per the account stanza, not [tagcolors]: the colour belongs to the + // account, and the tag name is derived from its key. + TagColors colours; + colours.setAccountColour(QStringLiteral("gmail-danixland"), + QColor(QStringLiteral("#cc0000"))); + + QCOMPARE(colours.colourFor(QStringLiteral("account-gmail-danixland")), + QColor(QStringLiteral("#cc0000"))); +} + +void TestTagColors::accountLabelDefaultsToTheKey() +{ + // Without a configured label the chip shows the account key, which is what + // it did before labels existed. + TagColors colours; + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-gmail-danixland")), + QStringLiteral("gmail-danixland")); + + // Not an account tag: nothing to label. + QVERIFY(colours.labelForAccountTag(QStringLiteral("flagged")).isEmpty()); +} + +void TestTagColors::accountLabelCanBeOverridden() +{ + // "account-privateemail-danilo.macri" is 33 characters of chip for what is + // really one bit of information, so the label is configurable. + TagColors colours; + colours.setAccountLabel(QStringLiteral("gmail-danixland"), + QStringLiteral("GM-danixland")); + colours.setAccountLabel(QStringLiteral("privateemail-danix"), + QStringLiteral("PE-danix")); + + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-gmail-danixland")), + QStringLiteral("GM-danixland")); + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-privateemail-danix")), + QStringLiteral("PE-danix")); + + // An account left unlabelled still falls back to its key. + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-work")), + QStringLiteral("work")); + + // An empty label is not an override: it would render a blank chip. + colours.setAccountLabel(QStringLiteral("gmail-danixland"), QString()); + QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-gmail-danixland")), + QStringLiteral("GM-danixland")); +} + +void TestTagColors::malformedColourIsReported() +{ + // A typo must be visible rather than silently ignored, matching how the + // rest of the config reports its problems. + TagColors colours; + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("tagcolors")); + s.setValue(QStringLiteral("flagged"), QStringLiteral("not-a-colour")); + s.endGroup(); + } + QSettings s(path, QSettings::IniFormat); + colours.load(s); + + QCOMPARE(colours.warnings().size(), 1); + QVERIFY(colours.warnings().first().contains(QStringLiteral("flagged"))); + // The built-in survives, so one bad line does not leave the tag unstyled. + QVERIFY(colours.colourFor(QStringLiteral("flagged")).isValid()); +} + +void TestTagColors::textContrastsWithItsBackground() +{ + // A chip is coloured text on a coloured fill, so the pair has to stay + // legible whatever colour the user picks. + QCOMPARE(TagColors::textColourOn(QColor(Qt::black)), QColor(Qt::white)); + QCOMPARE(TagColors::textColourOn(QColor(Qt::white)), QColor(Qt::black)); + QCOMPARE(TagColors::textColourOn(QColor(QStringLiteral("#8b2c2c"))), + QColor(Qt::white)); + QCOMPARE(TagColors::textColourOn(QColor(QStringLiteral("#ffee88"))), + QColor(Qt::black)); +} + +QTEST_MAIN(TestTagColors) +#include "test_tagcolors.moc" diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp index 98c477c..e8a5fa8 100644 --- a/tests/test_threadlistmodel.cpp +++ b/tests/test_threadlistmodel.cpp @@ -34,6 +34,9 @@ private slots: void subjectShowsMessageCountOnlyForRealThreads(); void unreadThreadsRenderBold(); void tagsAreTheFirstColumnAndSubjectTheLast(); + void accountTagBecomesAChipLabel(); + void unreadStylingSurvivesAnAccountChip(); + void accountChipUsesTheConfiguredColour(); void deletedThreadsAreRedAndStruckThrough(); void spamThreadsAreOrangeAndStruckThrough(); void doomedStylingCoversEveryColumn(); @@ -118,9 +121,11 @@ void TestThreadListModel::reportsSubjectAndAuthors() const QModelIndex date = model.index(0, ThreadListModel::DateColumn); QVERIFY(!model.data(date, Qt::DisplayRole).toString().isEmpty()); - const QModelIndex tags = model.index(0, ThreadListModel::TagsColumn); - QCOMPARE(model.data(tags, Qt::DisplayRole).toString(), - QStringLiteral("inbox unread")); + // Tags are no longer a column; they reach the strip under the message + // pane through a role instead. + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QCOMPARE(model.data(subject, ThreadListModel::TagsRole).toStringList(), + QStringList({ QStringLiteral("inbox"), QStringLiteral("unread") })); } void TestThreadListModel::subjectShowsMessageCountOnlyForRealThreads() @@ -163,17 +168,86 @@ void TestThreadListModel::tagsAreTheFirstColumnAndSubjectTheLast() // Subject stretches to fill the view, so whatever sits after it is pushed // off-screen. Tags used to be there, which is why acting on a thread // looked like it did nothing: the only column that changed was invisible. - QCOMPARE(ThreadListModel::TagsColumn, 0); QCOMPARE(ThreadListModel::SubjectColumn, ThreadListModel::ColumnCount - 1); ThreadListModel model; model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("hello")) }); - QCOMPARE(model.headerData(ThreadListModel::TagsColumn, Qt::Horizontal, - Qt::DisplayRole).toString(), - QStringLiteral("Tags")); QCOMPARE(model.headerData(ThreadListModel::SubjectColumn, Qt::Horizontal, Qt::DisplayRole).toString(), QStringLiteral("Subject")); + + // No tags column at all: spelling out a dozen tags per row consumed most + // of the list's width and was unreadable. + for (int column = 0; column < ThreadListModel::ColumnCount; ++column) { + QVERIFY(model.headerData(column, Qt::Horizontal, Qt::DisplayRole) + .toString() != QStringLiteral("Tags")); + } +} + +void TestThreadListModel::accountTagBecomesAChipLabel() +{ + // The account tag is a different taxonomy from a functional one: which + // mailbox the thread arrived in. It renders as a chip in front of the + // subject, so the model exposes its label and colour separately. + ThreadListModel model; + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); + thread.tags = QStringList{ QStringLiteral("inbox"), + QStringLiteral("account-gmail-danixland") }; + model.appendBatch({ thread }); + + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QCOMPARE(model.data(subject, ThreadListModel::AccountLabelRole).toString(), + QStringLiteral("gmail-danixland")); + QVERIFY(model.data(subject, ThreadListModel::AccountColourRole) + .value<QColor>().isValid()); + + // A thread with no account tag gets no chip rather than an empty one. + ThreadListModel plain; + ThreadSummary untagged = makeThread(QStringLiteral("t2"), QStringLiteral("hi")); + untagged.tags = QStringList{ QStringLiteral("inbox") }; + plain.appendBatch({ untagged }); + QVERIFY(plain.data(plain.index(0, ThreadListModel::SubjectColumn), + ThreadListModel::AccountLabelRole).toString().isEmpty()); +} + +void TestThreadListModel::unreadStylingSurvivesAnAccountChip() +{ + // The subject cell is drawn by a delegate when the thread has an account + // chip. The delegate paints the text itself, so it has to keep honouring + // the model's font: otherwise an unread thread stops rendering bold for + // exactly those threads that carry an account tag, which is all of them. + ThreadListModel model; + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); + thread.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread"), + QStringLiteral("account-gmail-danixland") }; + model.appendBatch({ thread }); + + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QVERIFY(!model.data(subject, ThreadListModel::AccountLabelRole) + .toString().isEmpty()); + + const QVariant font = model.data(subject, Qt::FontRole); + QVERIFY2(font.isValid(), "unread thread with an account tag has no font"); + QVERIFY2(font.value<QFont>().bold(), "unread thread is not bold"); +} + +void TestThreadListModel::accountChipUsesTheConfiguredColour() +{ + // The colour comes from the account's own stanza, so a configured one must + // reach the chip rather than the generated fallback. + TagColors colours; + colours.setAccountColour(QStringLiteral("gmail-danixland"), + QColor(QStringLiteral("#cc0000"))); + + ThreadListModel model; + model.setTagColors(&colours); + ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello")); + thread.tags = QStringList{ QStringLiteral("account-gmail-danixland") }; + model.appendBatch({ thread }); + + QCOMPARE(model.data(model.index(0, ThreadListModel::SubjectColumn), + ThreadListModel::AccountColourRole).value<QColor>(), + QColor(QStringLiteral("#cc0000"))); } void TestThreadListModel::deletedThreadsAreRedAndStruckThrough() |
