diff options
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/avatar.cpp | 78 | ||||
| -rw-r--r-- | src/avatar.h | 77 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_avatar.cpp | 95 |
5 files changed, 252 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4d9dbaa..d4f1cb7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(qtmaildir_lib STATIC htmlbuilder.cpp cidschemehandler.cpp cardlayout.cpp + avatar.cpp busyindicator.cpp marks.cpp carddelegate.cpp diff --git a/src/avatar.cpp b/src/avatar.cpp new file mode 100644 index 0000000..4cedae2 --- /dev/null +++ b/src/avatar.cpp @@ -0,0 +1,78 @@ +/* + * 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 "avatar.h" + +#include <QCryptographicHash> +#include <QPainter> +#include <QPainterPath> + +namespace { + +QString twoFrom(const QString &text) +{ + const QString trimmed = text.trimmed(); + if (trimmed.size() >= 2) + return trimmed.left(2).toUpper(); + if (trimmed.size() == 1) + return (trimmed + trimmed).toUpper(); + return QString(); +} + +} // namespace + +namespace Avatar { + +QString initialsFor(const QString &displayName, const QString &address, + const QString &accountLabel) +{ + const QStringList words = displayName.split(QLatin1Char(' '), + Qt::SkipEmptyParts); + if (words.size() >= 2) { + return (words.at(0).left(1) + words.at(1).left(1)).toUpper(); + } + if (words.size() == 1) { + const QString one = twoFrom(words.at(0)); + if (!one.isEmpty()) + return one; + } + + // No usable name. The local part and the domain each give one letter, + // which never degrades to a single letter the way the local part alone + // would, and never reads as a truncated word. + const int at = address.indexOf(QLatin1Char('@')); + if (at > 0) { + const QString local = address.left(at).trimmed(); + const QString domain = address.mid(at + 1).trimmed(); + if (!local.isEmpty() && !domain.isEmpty()) + return (local.left(1) + domain.left(1)).toUpper(); + } + // An address with no `@` is still something to show. + const QString bare = twoFrom(address); + if (!bare.isEmpty()) + return bare; + + const QString account = twoFrom(accountLabel); + if (!account.isEmpty()) + return account; + + // Nothing at all. Two characters regardless, so the shape never breaks. + return QStringLiteral("??"); +} + +} // namespace Avatar diff --git a/src/avatar.h b/src/avatar.h new file mode 100644 index 0000000..8415022 --- /dev/null +++ b/src/avatar.h @@ -0,0 +1,77 @@ +/* + * 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 <QFont> +#include <QPixmap> +#include <QString> + +/// A card's sender avatar: which letters it carries and what fills it. +/// +/// A NAMESPACE of free functions over values, deliberately, for the reason +/// CardLayout is a struct with no painter: the letters and the fill choice are +/// decisions with right answers, and they must be assertable without a widget, +/// a model or an exposed view. Only pixmapFor() touches a QPainter, and it +/// paints into an image it owns rather than onto a widget. +namespace Avatar +{ + +/// Which of the two generated fills a sender gets. +enum class Fill +{ + /// A 5x5 symmetric grid from the hash bits, under a darkening veil. + Identicon, + /// Two related hues from the hash, split at an angle, initials on a large + /// flat field. + TwoTone, +}; + +/// Always exactly two characters, upper-cased. +/// +/// In order: a display name of two or more words gives one letter from each of +/// the first two; a one-word name gives its own first two; a bare address +/// gives the first of the local part and the first of the domain; and with +/// nothing usable, the account's label. The uniform length is the point, so +/// every squircle reads as the same shape. +QString initialsFor(const QString &displayName, const QString &address, + const QString &accountLabel); + +/// Which fill, given whether the list claims this address as a business one. +/// +/// The list wins first, then the presence of a display name. That order is +/// what lets `Ian Farrell <notifications@github.com>` read as a person while +/// a listed address stays a business whatever name it presents. +Fill fillFor(const QString &displayName, bool isBusinessSender); + +/// A stable colour for an address. Same input, same colour, always. +/// +/// Generated at a FIXED saturation and lightness so the initials keep their +/// contrast in both themes, exactly as TagColors::colourFor() does for a tag +/// with nothing configured. +QColor colourFor(const QString &address); + +/// The finished squircle, `side` pixels a side, ready to draw. +/// +/// `seed` is what the fill is generated from, normally the sender's address +/// and the account's own address when there is no sender. +QPixmap pixmapFor(const QString &seed, const QString &initials, Fill fill, + int side, const QFont &font); + +} // namespace Avatar diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 830e2b2..2b6f846 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,6 +52,7 @@ add_qtmaildir_test(htmlbuilder) add_qtmaildir_test(notmuchworker) add_qtmaildir_test(tagcolors) add_qtmaildir_test(cardlayout) +add_qtmaildir_test(avatar) add_qtmaildir_test(marks) add_qtmaildir_test(carddelegate) add_qtmaildir_test(threadlistmodel) diff --git a/tests/test_avatar.cpp b/tests/test_avatar.cpp new file mode 100644 index 0000000..225b4f1 --- /dev/null +++ b/tests/test_avatar.cpp @@ -0,0 +1,95 @@ +/* + * 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 <QTest> + +#include "avatar.h" + +class TestAvatar : public QObject +{ + Q_OBJECT + +private slots: + void twoWordNameTakesOneLetterFromEach(); + void oneWordNameTakesItsFirstTwoLetters(); + void bareAddressTakesLocalAndDomain(); + void nothingUsableFallsBackToTheAccountLabel(); + void initialsAreAlwaysTwoLetters(); +}; + +void TestAvatar::twoWordNameTakesOneLetterFromEach() +{ + QCOMPARE(Avatar::initialsFor(QStringLiteral("John Doe"), + QStringLiteral("john@example.org"), + QStringLiteral("Work")), + QStringLiteral("JD")); + // Three words still take the FIRST two, not the first and last. + QCOMPARE(Avatar::initialsFor(QStringLiteral("Maria Grazia Rossi"), + QStringLiteral("maria@example.org"), + QStringLiteral("Work")), + QStringLiteral("MG")); +} + +void TestAvatar::oneWordNameTakesItsFirstTwoLetters() +{ + QCOMPARE(Avatar::initialsFor(QStringLiteral("Cofidis"), + QStringLiteral("noreply@cofidis.it"), + QStringLiteral("Work")), + QStringLiteral("CO")); +} + +void TestAvatar::bareAddressTakesLocalAndDomain() +{ + QCOMPARE(Avatar::initialsFor(QString(), + QStringLiteral("noreply@cofidis.it"), + QStringLiteral("Work")), + QStringLiteral("NC")); +} + +void TestAvatar::nothingUsableFallsBackToTheAccountLabel() +{ + // No name and no address at all: the account's label is the last resort, + // so a card always carries a squircle rather than a hole. + QCOMPARE(Avatar::initialsFor(QString(), QString(), + QStringLiteral("Work")), + QStringLiteral("WO")); + // And with nothing whatsoever, still two characters rather than empty. + QCOMPARE(Avatar::initialsFor(QString(), QString(), QString()).size(), 2); +} + +void TestAvatar::initialsAreAlwaysTwoLetters() +{ + // The shape is the point: every squircle reads the same. An address with + // no domain, a one-letter local part and a name of one letter all still + // produce two characters. + const QStringList names { QString(), QStringLiteral("X"), + QStringLiteral("A B") }; + const QStringList addresses { QStringLiteral("a@b.org"), + QStringLiteral("malformed"), + QString() }; + for (const QString &name : names) { + for (const QString &address : addresses) { + const QString initials = + Avatar::initialsFor(name, address, QStringLiteral("Acct")); + QCOMPARE(initials.size(), 2); + } + } +} + +QTEST_MAIN(TestAvatar) +#include "test_avatar.moc" |
