aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/messageview.cpp18
-rw-r--r--src/messageview.h9
-rw-r--r--src/tagchip.cpp126
-rw-r--r--src/tagchip.h62
-rw-r--r--src/tagcolors.cpp171
-rw-r--r--src/tagcolors.h92
-rw-r--r--src/tagstrip.cpp136
-rw-r--r--src/tagstrip.h63
-rw-r--r--src/threadlistmodel.cpp23
-rw-r--r--src/threadlistmodel.h27
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_tagcolors.cpp251
-rw-r--r--tests/test_threadlistmodel.cpp88
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()