summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 15:41:16 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:53:28 +0200
commit0dacb5c4c61818088efb5bf513f15d2b715491e3 (patch)
tree2f9994867f07b62d80e01a8256d3ee2acb5d62a9 /tests
parent702568b65a9e8322b646ce7ae03303f85076d47c (diff)
downloadqtmaildir-0dacb5c4c61818088efb5bf513f15d2b715491e3.tar.gz
qtmaildir-0dacb5c4c61818088efb5bf513f15d2b715491e3.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 'tests')
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_tagcolors.cpp251
-rw-r--r--tests/test_threadlistmodel.cpp88
3 files changed, 333 insertions, 7 deletions
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..1d5efdc
--- /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-webmail-personal")));
+ QVERIFY(!TagColors::isAccountTag(QStringLiteral("flagged")));
+ QVERIFY(!TagColors::isAccountTag(QStringLiteral("shopping/amazon")));
+
+ // The INI key for [account.webmail-personal] is what follows "account-".
+ QCOMPARE(TagColors::accountKeyForTag(QStringLiteral("account-webmail-personal")),
+ QStringLiteral("webmail-personal"));
+ QVERIFY(TagColors::accountKeyForTag(QStringLiteral("flagged")).isEmpty());
+
+ // Round trip, since the mapping is derived rather than configured.
+ QCOMPARE(TagColors::tagForAccountKey(QStringLiteral("webmail-personal")),
+ QStringLiteral("account-webmail-personal"));
+}
+
+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("webmail-personal"),
+ QColor(QStringLiteral("#cc0000")));
+
+ QCOMPARE(colours.colourFor(QStringLiteral("account-webmail-personal")),
+ 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-webmail-personal")),
+ QStringLiteral("webmail-personal"));
+
+ // Not an account tag: nothing to label.
+ QVERIFY(colours.labelForAccountTag(QStringLiteral("flagged")).isEmpty());
+}
+
+void TestTagColors::accountLabelCanBeOverridden()
+{
+ // "account-provider-work" is 33 characters of chip for what is
+ // really one bit of information, so the label is configurable.
+ TagColors colours;
+ colours.setAccountLabel(QStringLiteral("webmail-personal"),
+ QStringLiteral("WM-personal"));
+ colours.setAccountLabel(QStringLiteral("provider-personal"),
+ QStringLiteral("PR-personal"));
+
+ QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-webmail-personal")),
+ QStringLiteral("WM-personal"));
+ QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-provider-personal")),
+ QStringLiteral("PR-personal"));
+
+ // 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("webmail-personal"), QString());
+ QCOMPARE(colours.labelForAccountTag(QStringLiteral("account-webmail-personal")),
+ QStringLiteral("WM-personal"));
+}
+
+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..5a72c64 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-webmail-personal") };
+ model.appendBatch({ thread });
+
+ const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn);
+ QCOMPARE(model.data(subject, ThreadListModel::AccountLabelRole).toString(),
+ QStringLiteral("webmail-personal"));
+ 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-webmail-personal") };
+ 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("webmail-personal"),
+ QColor(QStringLiteral("#cc0000")));
+
+ ThreadListModel model;
+ model.setTagColors(&colours);
+ ThreadSummary thread = makeThread(QStringLiteral("t1"), QStringLiteral("hello"));
+ thread.tags = QStringList{ QStringLiteral("account-webmail-personal") };
+ model.appendBatch({ thread });
+
+ QCOMPARE(model.data(model.index(0, ThreadListModel::SubjectColumn),
+ ThreadListModel::AccountColourRole).value<QColor>(),
+ QColor(QStringLiteral("#cc0000")));
}
void TestThreadListModel::deletedThreadsAreRedAndStruckThrough()