diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-11 20:24:05 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-11 20:24:05 +0200 |
| commit | 1faf94eb35e8270a659f215d260db73bcaa3f8d7 (patch) | |
| tree | 6305f6532cf1abc000eb261ce85c3ce12bc0a8d3 /src/cardlayout.cpp | |
| parent | 64d3138ba923071069da6c9bc458a25a9cc7d27f (diff) | |
| download | qtmaildir-1faf94eb35e8270a659f215d260db73bcaa3f8d7.tar.gz qtmaildir-1faf94eb35e8270a659f215d260db73bcaa3f8d7.zip | |
feat(panes): draw the pane marks from shipped SVGs, not font glyphs
Items 70 and 69, the second folded into the first as item 70's own size note
predicted it should be.
The panes drew their state marks as font glyphs: U+1F4CE for an attachment and
U+2605 for a flagged thread, each with a fallback for a font that cannot render
it. Both fell back to "*", so on such a font a flagged thread and one carrying
an attachment were indistinguishable, which is a defect the fallback introduced
rather than prevented. What a mark looks like was also the desktop's decision
rather than this application's, and the panes are exactly where it should not
be: the user asked for the toolbar and menus to keep following their icon theme
while the panes stop.
Six marks now ship in assets/icons/marks/: flagged, attachment, passed, replied
and the two expander triangles. QIcon::fromTheme still resolves every toolbar
and menu icon and was not touched.
Licensing chose the shapes. The look came from a GPL3 icon theme, and this
project is GPLv2-only, which are incompatible: GPLv2's "no further
restrictions" clause bars shipping GPL3 assets in a v2-only work. The six were
drawn fresh in the same idiom instead, with no path data copied. The idiom is
generic: solid single-path silhouettes at 16x16 with no strokes.
They are compiled in as string literals rather than loaded from a .qrc.
src/CMakeLists.txt already records why resources belong to the executable: a
qrc in the static library registers itself from a global initialiser the linker
drops. The tests link the library, so a resource-based mark would be missing
exactly where it needs asserting. assets/icons/marks/ stays the editable
source.
One asset serves both palettes. Every payload paints with fill="currentColor",
which QSvgRenderer renders black rather than resolving, so Marks::pixmap
composites the wanted colour with CompositionMode_SourceIn. A mark then takes
the card's own pen colour and follows selection and the read/unread dimming
without a second variant to keep in step.
CardLayout reserves a rect per mark and CardDelegate paints into it. The marks
were glyphs inside the subject STRING, so their width came free from the text
metrics; as icons the geometry has to know they are there or the subject runs
underneath them. The expander pill had the same trap, its triangle being a
glyph in expanderLabel(), and now reserves that width explicitly.
Item 69's part: passed and replied were words in the tag strip and are marks
beside the subject now. The message pane's header carries the flagged and
attachment marks next to the subject, per the user's decision that the right
pane needs those two and only outside the message area.
A duplicate that no test caught is worth recording. Every geometry assertion
passed while a card showed passed as BOTH an arrow and a green tag chip: the
chip filter had no reason to know a mark had appeared. It was found by
rendering real cards to an image and looking at them. isDrawnAsAMark() is now
one list consulted by both PillTagsRole and MessageOwnTagsRole, since two
copies drifting apart is how a tag ends up drawn twice on one row and not at
all on another.
Fourteen tests: nine in test_marks, four in test_cardlayout, one in
test_threadlistmodel. Mutation-checked at four points, each failing a test: the
subject ignoring the marks, the flag not indenting the subject, the pill
forgetting the triangle's width, and the recolour composite removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/cardlayout.cpp')
| -rw-r--r-- | src/cardlayout.cpp | 85 |
1 files changed, 76 insertions, 9 deletions
diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp index d60bff9..0febb4b 100644 --- a/src/cardlayout.cpp +++ b/src/cardlayout.cpp @@ -33,20 +33,46 @@ QString CardLayout::formatDate(const QDateTime &date, const QString &format) return QLocale::system().toString(date, format); } +int CardLayout::markSide(const QFont &font) +{ + // Derived from the font's ascent rather than fixed, so the marks grow with + // the user's text size. A pixel count settled on one desktop is wrong on + // the next one, and qt6ct sets fonts in PIXELS, where pointSizeF() returns + // -1 (see the note in this file's header) which is why the metric and not + // the size is the thing measured. + // + // Ascent rather than height: height includes the descent, which no mark + // occupies, and marks sized from it read noticeably larger than the text + // beside them. + const int side = QFontMetrics(font).ascent(); + + // Floor of 8: below that the shapes stop being tellable apart, which is the + // defect item 70 exists to fix rather than one to reintroduce at a small + // font size. + return qMax(8, side); +} + QString CardLayout::expanderLabel(int replyCount, bool expanded) { // "3 replies", not a bare "3". The count alone reads as an unexplained // number beside the subject, and the word is what says the card opens. // + // The triangle is NO LONGER part of this string. Item 70 made it a drawn + // mark, so the pill reserves a rect for it and the delegate paints it; a + // glyph left here would be a second triangle beside the drawn one. The + // `expanded` parameter therefore no longer changes the label, and is kept + // because both states are still measured: the pill must not change width + // when the card opens, and a caller that stopped passing the state would + // hide that requirement rather than satisfy it. + // // Not translated through tr() here because CardLayout is a plain struct // rather than a QObject; the delegate is where a translated build would // wrap this, and the string is deliberately kept in one place so there is // exactly one thing to change. - const QString glyph = expanded ? QStringLiteral("\u25be") - : QStringLiteral("\u25b8"); + Q_UNUSED(expanded); const QString word = replyCount == 1 ? QStringLiteral("reply") : QStringLiteral("replies"); - return QStringLiteral("%1 %2 %3").arg(glyph).arg(replyCount).arg(word); + return QStringLiteral("%1 %2").arg(replyCount).arg(word); } QString CardLayout::widestDateSample(const QString &format) @@ -172,17 +198,58 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect, expanderLabel(input.replyCount, false)); const int expanded = smallMetrics.horizontalAdvance( expanderLabel(input.replyCount, true)); + // The triangle is a drawn mark since item 70, so the pill has to + // reserve its width explicitly. It came free from the text metrics + // while it was a glyph in the label, which is exactly the kind of + // width that disappears silently when the glyph does. + const int triangle = smallMetrics.ascent() + kMarkGap; const int countWidth = - qMax(collapsed, expanded) + kPillPaddingX * 2; + qMax(collapsed, expanded) + triangle + kPillPaddingX * 2; out.expanderRect = QRect(right - countWidth, lineTwoTop, countWidth, metrics.height()); } - const int subjectRight = out.expanderRect.isEmpty() - ? right - : out.expanderRect.left() - kPaddingX; - out.subjectRect = QRect(out.contentLeft, lineTwoTop, - qMax(0, subjectRight - out.contentLeft), + // Item 70's marks. Until it they were glyphs inside the subject string, so + // the text metrics reserved their width without anyone arranging it; as + // icons they need rects, and the subject needs to end before them or it + // runs underneath. + // + // Laid out from the RIGHT, inwards: the expander is already placed, and + // each mark present takes the next slot to its left. The subject then gets + // whatever is left, which is what keeps a card with four marks from eliding + // its subject to nothing on a narrow window: the marks are small and fixed, + // the subject is the elastic part. + const int side = markSide(font); + const int markTop = lineTwoTop + (metrics.height() - side) / 2; + int markRight = out.expanderRect.isEmpty() + ? right + : out.expanderRect.left() - kPaddingX; + + // Order matters and is the drawing order reversed: placing right to left + // here puts attachment nearest the subject and replied furthest right, + // which is the order the delegate then paints them in. + const auto placeMark = [&](bool present, QRect &target) { + if (!present) + return; + target = QRect(markRight - side, markTop, side, side); + markRight = target.left() - kMarkGap; + }; + placeMark(input.replied, out.repliedRect); + placeMark(input.passed, out.passedRect); + placeMark(input.hasAttachment, out.attachmentRect); + + // The flag sits at the START of line two, where the glyph did, so a flagged + // card still reads flagged from the left edge. It indents the subject + // rather than overlapping it. + int subjectLeft = out.contentLeft; + if (input.flagged) { + out.flagRect = QRect(out.contentLeft, markTop, side, side); + subjectLeft = out.flagRect.right() + 1 + kMarkGap; + } + + const int subjectRight = markRight; + out.subjectRect = QRect(subjectLeft, lineTwoTop, + qMax(0, subjectRight - subjectLeft), metrics.height()); out.tagRect = QRect(out.contentLeft, lineThreeTop, |
