summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 15:41:16 +0200
committerDanilo M. <danix@danix.xyz>2026-08-03 15:41:16 +0200
commitcd0be6dc5e4741c2ad180d6bf6a275f000ab943a (patch)
treec2c704065c398ba33d9549bfb025d30dbb0a46c4 /src
parentab16342f6181f69e5cb1ef528f1dd0eb433bb74b (diff)
downloadqtmaildir-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.
Diffstat (limited to 'src')
-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
10 files changed, 719 insertions, 8 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;
};