aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
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;
};