From 5e769b073062a8913d2c4250d079aeb2d7d7b0e8 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 26 Aug 2026 15:50:33 +0200 Subject: feat: reserve a card's avatar gutter --- src/cardlayout.cpp | 10 +++++++ src/cardlayout.h | 10 +++++++ tests/test_cardlayout.cpp | 73 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp index d32935b..a9bbe52 100644 --- a/src/cardlayout.cpp +++ b/src/cardlayout.cpp @@ -175,6 +175,16 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect, const int indent = depth * kIndentStep; out.contentLeft = textLeft + kPaddingX + indent; + // The avatar, square, in the gutter between the indent and the text. + // Sized from the card's HEIGHT rather than from a pixel constant, so it + // follows the desktop's font exactly as markSide() does. + const int avatarSide = qMax(0, rect.height() - kPaddingY * 2); + out.avatarRect = QRect(out.contentLeft, rect.top() + kPaddingY, + avatarSide, avatarSide); + // Everything after it starts past the squircle. This is what the item's + // cost is: a deep reply loses the gutter on top of its indent. + out.contentLeft = out.avatarRect.right() + 1 + kAvatarGap; + // One spine per level actually indented, each running the card's full // height so an expansion reads as one continuous block. for (int level = 0; level < depth; ++level) { diff --git a/src/cardlayout.h b/src/cardlayout.h index 92edd96..cd4eb76 100644 --- a/src/cardlayout.h +++ b/src/cardlayout.h @@ -142,6 +142,16 @@ struct CardLayout /// without ever drawing two lines. Empty on a reply. QRect accentRect; + /// The sender's avatar squircle, in its own gutter before the text. + /// + /// On EVERY row, thread and reply alike: a reply is where the sender + /// actually changes, so it is the row whose author is most worth seeing. + /// Square, and inset vertically so it does not touch the card's edges. + QRect avatarRect; + + /// Space between the avatar and the text that follows it. + static constexpr int kAvatarGap = 8; + /// One full-height vertical line per depth level, outermost first. QVector spines; diff --git a/tests/test_cardlayout.cpp b/tests/test_cardlayout.cpp index f5f40ab..c81296f 100644 --- a/tests/test_cardlayout.cpp +++ b/tests/test_cardlayout.cpp @@ -45,6 +45,10 @@ private slots: void theDateFitsWhenTheCardIsBold(); void theDateFollowsTheSystemLocale(); void aConfiguredDateFormatIsUsedAndReservedFor(); + void everyRowCarriesAnAvatar(); + void theAvatarPushesTheContentRight(); + void theAvatarFollowsTheIndent(); + void theAvatarIsSquareAndFitsTheCard(); }; namespace { @@ -410,8 +414,14 @@ void TestCardLayout::marksDoNotCollideWithEachOtherOrTheExpander() // The subject survives at a usable width rather than being squeezed to // nothing by four marks: they are small and fixed, it is the elastic part. - QVERIFY2(card.subjectRect.width() > 100, - "four marks left the subject with almost no room on a 400px card"); + // Relative rather than absolute: the avatar gutter shifts every text rect + // right, so a fixed pixel floor like 100 fails on a card that gained a + // gutter and would pass on one that had not. Comparing against another + // fixed element of the same card keeps the real invariant: the marks must + // not leave the subject narrower than the avatar gutter beside it. + QVERIFY2(card.subjectRect.width() > card.avatarRect.width(), + "four marks left the subject narrower than the avatar gutter on a " + "400px card"); } void TestCardLayout::dateIsFlushRight() @@ -580,5 +590,64 @@ void TestCardLayout::theDateFitsWhenTheCardIsBold() .arg(boldWidth))); } +void TestCardLayout::everyRowCarriesAnAvatar() +{ + const QFont font; + const QRect rect(0, 0, 600, CardLayout::heightFor(font)); + + CardLayout::Input thread; + const CardLayout rootCard = CardLayout::compute(thread, rect, font); + QVERIFY(!rootCard.avatarRect.isEmpty()); + + // A reply gets one too: it is the row where the sender actually changes. + CardLayout::Input reply; + reply.isMessage = true; + reply.depth = 1; + const CardLayout replyCard = CardLayout::compute(reply, rect, font); + QVERIFY(!replyCard.avatarRect.isEmpty()); +} + +void TestCardLayout::theAvatarPushesTheContentRight() +{ + const QFont font; + const QRect rect(0, 0, 600, CardLayout::heightFor(font)); + const CardLayout card = CardLayout::compute(CardLayout::Input(), rect, font); + + // The text starts after the squircle, never on it. + QVERIFY(card.contentLeft >= card.avatarRect.right() + 1); +} + +void TestCardLayout::theAvatarFollowsTheIndent() +{ + const QFont font; + const QRect rect(0, 0, 600, CardLayout::heightFor(font)); + + CardLayout::Input shallow; + shallow.isMessage = true; + shallow.depth = 1; + CardLayout::Input deep; + deep.isMessage = true; + deep.depth = 3; + + const CardLayout shallowCard = CardLayout::compute(shallow, rect, font); + const CardLayout deepCard = CardLayout::compute(deep, rect, font); + + // The squircle sits inside the card's own rect and moves with the nesting, + // which is the same reason contentLeft does. Asserting on the RECT here is + // safe precisely because it is CardLayout's own output, not a visualRect. + QVERIFY(deepCard.avatarRect.left() > shallowCard.avatarRect.left()); +} + +void TestCardLayout::theAvatarIsSquareAndFitsTheCard() +{ + const QFont font; + const QRect rect(0, 0, 600, CardLayout::heightFor(font)); + const CardLayout card = CardLayout::compute(CardLayout::Input(), rect, font); + + QCOMPARE(card.avatarRect.width(), card.avatarRect.height()); + QVERIFY(card.avatarRect.top() >= rect.top()); + QVERIFY(card.avatarRect.bottom() <= rect.bottom()); +} + QTEST_MAIN(TestCardLayout) #include "test_cardlayout.moc" -- cgit v1.2.3