diff options
| -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() |
