diff options
| -rw-r--r-- | src/avatar.cpp | 78 | ||||
| -rw-r--r-- | tests/test_avatar.cpp | 30 |
2 files changed, 108 insertions, 0 deletions
diff --git a/src/avatar.cpp b/src/avatar.cpp index 0e2b25f..7aaa6eb 100644 --- a/src/avatar.cpp +++ b/src/avatar.cpp @@ -19,6 +19,8 @@ #include "avatar.h" #include <QCryptographicHash> +#include <QLinearGradient> +#include <QLineF> #include <QPainter> #include <QPainterPath> @@ -97,4 +99,80 @@ QColor colourFor(const QString &address) return QColor::fromHsl(hue, 110, 95); } +QPixmap pixmapFor(const QString &seed, const QString &initials, Fill fill, + int side, const QFont &font) +{ + QPixmap pixmap(side, side); + pixmap.fill(Qt::transparent); + + const QByteArray digest = + QCryptographicHash::hash(seed.toUtf8(), QCryptographicHash::Md5); + const QColor base = colourFor(seed); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + + // The squircle. A rounded rect at ~30% of the side reads as one without + // needing a superellipse, and clipping to it means neither fill has to + // know the shape. + QPainterPath squircle; + squircle.addRoundedRect(QRectF(0, 0, side, side), side * 0.3, side * 0.3); + painter.setClipPath(squircle); + + if (fill == Fill::Identicon) { + // A 5x5 grid, mirrored about the vertical axis, so only the left + // three columns come from the hash: 15 cells, one bit each, which is + // two bytes of the digest. Symmetry is what makes the shape read as a + // deliberate mark rather than as noise. + painter.fillRect(QRect(0, 0, side, side), base.darker(220)); + const qreal cell = qreal(side) / 5.0; + for (int col = 0; col < 3; ++col) { + for (int row = 0; row < 5; ++row) { + const int bit = col * 5 + row; + const bool on = + (static_cast<quint8>(digest.at(bit / 8)) >> (bit % 8)) & 1; + if (!on) + continue; + painter.fillRect(QRectF(col * cell, row * cell, cell, cell), + base); + const int mirrored = 4 - col; + painter.fillRect( + QRectF(mirrored * cell, row * cell, cell, cell), base); + } + } + // The veil. Without it the initials sit on whatever the pattern + // happens to do behind them, which is the classic legibility failure + // this fill invites. Tune the opacity against the real font before + // calling it done. + painter.fillRect(QRect(0, 0, side, side), QColor(0, 0, 0, 77)); + } else { + // Two related hues split at an angle, both from the hash. The field + // behind the letters stays large and flat, which is the whole reason + // this fill exists beside the identicon. + const int angle = static_cast<quint8>(digest.at(1)) * 360 / 256; + QLineF axis = QLineF::fromPolar(side, angle); + axis.translate(side / 2.0, side / 2.0); + QLinearGradient gradient(axis.p2(), axis.p1()); + gradient.setColorAt(0.0, base); + gradient.setColorAt(0.499, base); + gradient.setColorAt(0.5, base.darker(135)); + gradient.setColorAt(1.0, base.darker(135)); + painter.fillRect(QRect(0, 0, side, side), gradient); + } + + // The letters. White with a soft shadow rather than a computed contrast + // colour: the fills are generated at a fixed lightness precisely so one + // choice works for all of them. + QFont letters = font; + letters.setBold(true); + letters.setPixelSize(qMax(8, int(side * 0.36))); + painter.setFont(letters); + painter.setPen(QColor(0, 0, 0, 120)); + painter.drawText(QRect(1, 1, side, side), Qt::AlignCenter, initials); + painter.setPen(Qt::white); + painter.drawText(QRect(0, 0, side, side), Qt::AlignCenter, initials); + + return pixmap; +} + } // namespace Avatar diff --git a/tests/test_avatar.cpp b/tests/test_avatar.cpp index b53dfc1..72e5941 100644 --- a/tests/test_avatar.cpp +++ b/tests/test_avatar.cpp @@ -33,6 +33,7 @@ private slots: void aDisplayNameMeansAPerson(); void theListOverridesADisplayName(); void aColourIsStablePerAddress(); + void aPixmapIsStableAndDiffersPerSeed(); }; void TestAvatar::twoWordNameTakesOneLetterFromEach() @@ -119,5 +120,34 @@ void TestAvatar::aColourIsStablePerAddress() QVERIFY(Avatar::colourFor(QStringLiteral("b@example.org")) != first); } +void TestAvatar::aPixmapIsStableAndDiffersPerSeed() +{ + const QFont font; + const QPixmap first = Avatar::pixmapFor(QStringLiteral("a@example.org"), + QStringLiteral("AE"), + Avatar::Fill::Identicon, 44, font); + QCOMPARE(first.size(), QSize(44, 44)); + QVERIFY(!first.isNull()); + + const QPixmap again = Avatar::pixmapFor(QStringLiteral("a@example.org"), + QStringLiteral("AE"), + Avatar::Fill::Identicon, 44, font); + // Same seed, same image, byte for byte: the identity must not drift + // between repaints. + QCOMPARE(first.toImage(), again.toImage()); + + const QPixmap other = Avatar::pixmapFor(QStringLiteral("b@example.org"), + QStringLiteral("AE"), + Avatar::Fill::Identicon, 44, font); + // Different sender, different image, even with identical initials. + QVERIFY(first.toImage() != other.toImage()); + + const QPixmap twoTone = Avatar::pixmapFor(QStringLiteral("a@example.org"), + QStringLiteral("AE"), + Avatar::Fill::TwoTone, 44, font); + // The two fills are actually different renderings, not one with a flag. + QVERIFY(first.toImage() != twoTone.toImage()); +} + QTEST_MAIN(TestAvatar) #include "test_avatar.moc" |
