diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-09 12:43:58 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-09 12:43:58 +0200 |
| commit | 23d07f07562adbb349f7fe8b97e4f4e9ea6c6ebd (patch) | |
| tree | bd160e61cbd78f96c1a06e73754ec945d3d4ae59 /docs | |
| parent | d990f7c6775b4b921c6d6d8d539c7018279346be (diff) | |
| download | qtmaildir-23d07f07562adbb349f7fe8b97e4f4e9ea6c6ebd.tar.gz qtmaildir-23d07f07562adbb349f7fe8b97e4f4e9ea6c6ebd.zip | |
docs: make the account colour the card's accent, not a chip
The plan left the account chip unspecified, because where it sits on a card was
never decided and inventing a place would have been a guess. The user's answer
replaces it rather than placing it: a coloured bar down the card's left edge,
the reply spines inheriting that colour, and matching swatches in the account
dropdown.
It is a net simplification. The chip ate a third of line 2 on every card to
repeat a name the user already knows, which is the table-of-records texture item
53 is about; the bar says the same thing in a few pixels and leaves line 2 to
the subject.
Three details that are easy to get wrong and are specified rather than left to
the implementer:
- The raw account colour is never drawn as a line. It is chosen to be a
chip's FILL, with text drawn on top in whatever stays legible against it. A
thin line on the pane's own background has a different job: followable down
a long expansion without competing with the senders beside it. The accent
blends toward QPalette::Base by the same 0.35 weight threadLineColour()
already uses, keeping the hue and dropping the shout. The dropdown swatch
does use the raw colour, being a filled patch rather than a line.
- A reply resolves its THREAD's colour by walking to the root.
AccountColourRole is empty on a message row, so a spine reading its own
index would fall back to the neutral line under an accented root and break
the continuous edge the design is built on.
- Reply cards carry no bar of their own. Two vertical lines a few pixels
apart in one gutter is what option B looked like, and the spine already
carries the accent.
colourFor() never failing is kept deliberately: an account with no colour= key
gets a stable colour derived from its tag name, so adding an account and
forgetting to colour it degrades to something usable rather than to nothing.
kAccentWidth ships at 3px as a starting value. Whether five accounts are
tellable apart at that width, on this user's screen and theme, is not decidable
from a mockup or a test, so Task 10 gains a step that settles it against real
cards, in both themes, with the guidance to widen the bar before touching the
user's own colour choices.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-08-09-card-list.md | 240 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-08-09-card-list-design.md | 54 |
2 files changed, 276 insertions, 18 deletions
diff --git a/docs/superpowers/plans/2026-08-09-card-list.md b/docs/superpowers/plans/2026-08-09-card-list.md index 582d8b9..4ea9a4b 100644 --- a/docs/superpowers/plans/2026-08-09-card-list.md +++ b/docs/superpowers/plans/2026-08-09-card-list.md @@ -679,6 +679,8 @@ private slots: void expanderSitsOnTheSecondLine(); void expanderIsEmptyWithoutReplies(); void dateIsFlushRight(); + void threadCardCarriesAnAccentBar(); + void replyCardCarriesNoAccentBar(); }; namespace { @@ -840,6 +842,37 @@ void TestCardLayout::dateIsFlushRight() QVERIFY(card.senderRect.right() <= card.dateRect.left()); } +void TestCardLayout::threadCardCarriesAnAccentBar() +{ + const QFont font; + const int h = CardLayout::heightFor(font); + const QRect rect(0, 0, 400, h); + const CardLayout card = CardLayout::compute(threadInput(), rect, font); + + QCOMPARE(card.accentRect.left(), rect.left()); + QCOMPARE(card.accentRect.width(), CardLayout::kAccentWidth); + // Full height, so a run of cards from one account reads as a continuous + // edge rather than as dashes. + QCOMPARE(card.accentRect.top(), rect.top()); + QCOMPARE(card.accentRect.bottom(), rect.bottom()); + + // Nothing may be drawn on top of the colour. + QVERIFY(card.contentLeft >= card.accentRect.right()); +} + +void TestCardLayout::replyCardCarriesNoAccentBar() +{ + const QFont font; + const int h = CardLayout::heightFor(font); + const CardLayout reply = + CardLayout::compute(replyInput(1), QRect(0, 0, 400, h), font); + + // A reply's account is its thread's, stated once at the head. The spine + // carries the accent instead, so the gutter never holds two lines. + QVERIFY(reply.accentRect.isEmpty()); + QCOMPARE(reply.spines.size(), 1); +} + QTEST_MAIN(TestCardLayout) #include "test_cardlayout.moc" ``` @@ -889,7 +922,16 @@ struct CardLayout int replyCount = 0; ///< 0 means no expander. }; - /// Horizontal breathing room at the card's edges. + /// Width of the account accent bar down a thread card's left edge. + /// + /// A starting value, not a settled one. Five accounts is enough that two + /// colours distinct as chips can read alike as thin stripes, and that can + /// only be judged against real cards on the user's own screen and theme + /// (Task 10). Widen it there if the accounts are not tellable apart. + static constexpr int kAccentWidth = 3; + + /// Horizontal breathing room at the card's edges, measured from the accent + /// bar rather than from the card, so text does not sit on the colour. static constexpr int kPaddingX = 8; /// Vertical breathing room above the first line and below the last. @@ -916,6 +958,15 @@ struct CardLayout /// Empty when the row has no replies. QRect expanderRect; + /// The account accent bar down the card's left edge. + /// + /// Thread cards only. A reply's account is its thread's, stated once at the + /// head of the conversation, and a second vertical line in a reply's gutter + /// would sit a few pixels from the spine and compete with it. The spine + /// carries the accent instead, so an expansion is bounded by one colour + /// without ever drawing two lines. Empty on a reply. + QRect accentRect; + /// One full-height vertical line per depth level, outermost first. QVector<QRect> spines; @@ -979,16 +1030,24 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect, out.totalHeight = rect.height(); + // The accent bar sits flush against the card's left edge, on thread cards + // only, and everything else starts after it so no text sits on the colour. + if (!input.isMessage) { + out.accentRect = + QRect(rect.left(), rect.top(), kAccentWidth, rect.height()); + } + const int textLeft = rect.left() + kAccentWidth; + // Indent, capped. qMin rather than a branch so depth 5 and depth 50 land // in exactly the same place. const int depth = qMin(input.depth, kMaxDepth); const int indent = depth * kIndentStep; - out.contentLeft = rect.left() + kPaddingX + indent; + out.contentLeft = textLeft + kPaddingX + indent; // 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) { - const int x = rect.left() + kPaddingX + level * kIndentStep + const int x = textLeft + kPaddingX + level * kIndentStep + kIndentStep / 2; out.spines.append(QRect(x, rect.top(), 2, rect.height())); } @@ -1127,6 +1186,20 @@ public: /// click, because a delegate gets no click of its own without an editor. static QRect expanderRectFor(const QStyleOptionViewItem &option, const QModelIndex &index); + + /// An account's colour as a thin LINE rather than as a chip's fill. + /// + /// Never use the raw account colour for the accent bar or the spine. That + /// colour is chosen to be a background with legible text drawn on top + /// (TagColors::textColourOn picks black or white against it). The same + /// colour as a few pixels of line on the pane's own background is a + /// different problem: it has to be followable down a long expansion + /// WITHOUT competing with the senders beside it, which is the constraint + /// threadLineColour() states and meets by blending 0.35 toward the + /// palette's text. This blends the account colour toward the palette's + /// Base by the same weight, keeping the hue that identifies the account + /// and dropping the saturation that would shout. + static QColor accentLineColour(const QColor &accountColour); }; ``` @@ -1165,6 +1238,22 @@ QRect CardDelegate::expanderRectFor(const QStyleOptionViewItem &option, .expanderRect; } +QColor CardDelegate::accentLineColour(const QColor &accountColour) +{ + if (!accountColour.isValid()) + return ThreadListModel::threadLineColour(); + + // The same 0.35 weight threadLineColour() uses, toward Base rather than + // toward Text, so the two kinds of line sit at the same visual strength. + const QColor base = QGuiApplication::palette().color(QPalette::Base); + constexpr qreal kWeight = 0.35; + const qreal inverse = 1.0 - kWeight; + return QColor::fromRgbF( + accountColour.redF() * kWeight + base.redF() * inverse, + accountColour.greenF() * kWeight + base.greenF() * inverse, + accountColour.blueF() * kWeight + base.blueF() * inverse); +} + QSize CardDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { @@ -1191,9 +1280,30 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, painter->save(); - // Spines, under everything else. + // The account's colour, for both the accent bar and the spines. + // + // A reply must resolve its THREAD's colour, not its own: AccountColourRole + // is empty on a message row, and a spine that fell back to the neutral + // line under an accented root would break the one continuous edge this + // design is built on. index.parent() is the thread for a depth-1 reply and + // the containing subtree for a deeper one, so walk to the root. + QModelIndex root = index; + while (root.parent().isValid()) + root = root.parent(); + const QColor accountColour = + root.data(ThreadListModel::AccountColourRole).value<QColor>(); + const QColor lineColour = accentLineColour(accountColour); + + // The accent bar, thread cards only. Drawn after the chrome so the + // selection highlight cannot cover it: which account a card belongs to + // must stay readable on the row the user is looking at. + if (!card.accentRect.isEmpty()) + painter->fillRect(card.accentRect, lineColour); + + // Spines, under everything else, in the same accent so an expanded thread + // is bounded by one colour from its root to its last reply. for (const QRect &spine : card.spines) - painter->fillRect(spine, ThreadListModel::threadLineColour()); + painter->fillRect(spine, lineColour); // Selection outranks the model's foreground, and the order matters: a read // card carries a dimmed colour blended against the UNSELECTED background, @@ -1288,7 +1398,8 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, } ``` -Add `#include <QRegularExpression>` to the includes. +Add `#include <QRegularExpression>` and `#include <QGuiApplication>` to the +includes. - [ ] **Step 4: Delete `SubjectDelegate`** @@ -1326,6 +1437,16 @@ A reply's Re: prefix is stripped here. Every reply repeating the thread's subject is the visual signature of a table of records, which is the thing item 53 is about. +The account chip becomes a bar down the card's left edge, and the reply spines +inherit its colour, so an expanded thread is bounded by one accent from its root +to its last reply without a second line in the gutter. Neither uses the raw +account colour: that colour is chosen to be a chip's fill with legible text on +top, and the same value as a thin line has to be followable down an expansion +without competing with the senders, so it is blended toward the palette's Base +by the weight threadLineColour() already uses. A reply resolves its THREAD's +colour by walking to the root, since AccountColourRole is empty on a message +row and a neutral spine under an accented root would break the continuous edge. + The build is red at this commit; the view and window still name the old delegate." ``` @@ -1791,7 +1912,62 @@ cmake --build build && ./build/tests/test_mainwindow Expected: PASS. -- [ ] **Step 7: Commit** +- [ ] **Step 7: Colour the account dropdown's entries** + +The accent bar on a card says nothing until something maps a colour to an +account name, and the dropdown is where the user already goes to think about +accounts. In `src/mainwindow.cpp:399`, the loop that fills `m_accountBox`: + +```cpp + m_accountBox->addItem(tr("All accounts"), QString()); + for (const Account &account : m_config.accounts()) { + m_accountBox->addItem(account.key, account.key); + // The RAW account colour here, not the blended line colour: a swatch + // is a filled patch like a chip, not a thin line, so it wants the + // colour the account was actually given. Qt renders a DecorationRole + // colour as a swatch itself, with no delegate. + m_accountBox->setItemData( + m_accountBox->count() - 1, + m_tagColors.colourFor(TagColors::tagForAccountKey(account.key)), + Qt::DecorationRole); + } +``` + +`colourFor` never fails: an account with no `color=` key gets a stable colour +derived from its tag name, which is deliberate. Adding an account and +forgetting to colour it degrades to something usable rather than to nothing. + +Check the member's name first: `grep -n "TagColors m_\|m_tagColors" +src/mainwindow.h`. + +- [ ] **Step 8: Test it** + +```cpp +void TestMainWindow::accountEntriesCarryTheirColour() +{ + MainWindow window; + auto *box = window.findChild<QComboBox *>(QStringLiteral("accountBox")); + QVERIFY(box); + + // "All accounts" is not an account and carries no swatch. + QVERIFY(!box->itemData(0, Qt::DecorationRole).isValid()); + + // Every real account does. Skipped rather than failed when the test + // environment has no accounts configured, since this reads real config. + if (box->count() < 2) + QSKIP("no accounts configured in this environment"); + for (int i = 1; i < box->count(); ++i) { + const QVariant swatch = box->itemData(i, Qt::DecorationRole); + QVERIFY(swatch.isValid()); + QVERIFY(swatch.value<QColor>().isValid()); + } +} +``` + +`m_accountBox` needs `setObjectName(QStringLiteral("accountBox"))` if it does +not have one. + +- [ ] **Step 9: Commit** ```bash git add src/mainwindow.cpp src/mainwindow.h tests/test_mainwindow.cpp @@ -1802,7 +1978,13 @@ the column header was decorative and nothing implemented click-to-sort, so removing the header with the grid lost nothing. Stored in uistate.conf, never in the hand-edited config, and range-guarded on -read: a stale file can hold anything, which is the lesson item 58 recorded." +read: a stale file can hold anything, which is the lesson item 58 recorded. + +The account dropdown's entries now carry their account's colour as a swatch, +which is what makes the accent bar on a card mean anything: a colour down a +card's edge says nothing until something maps it to a name. Raw colour here +rather than the blended line colour, since a swatch is a filled patch like a +chip rather than a thin line." ``` --- @@ -1856,6 +2038,10 @@ Under `## [Unreleased]`, in `### Changed`: carrying only the tags the thread itself does not have. - Threads can be listed newest or oldest first, from a new control beside the query bar. The choice is remembered. +- An account's colour now runs down the left edge of its threads, and down the + spine of their replies, replacing the account chip that used to sit in front + of every subject. The account dropdown shows the same colours, so which + colour means which account is readable in one place. ``` In `### Fixed`: @@ -1922,6 +2108,24 @@ the original bug was the desktop's own font being configured Bold in qt6ct. - The sort dropdown reverses the list, and the choice survives a restart. - The list does not scroll sideways on any click, at any window width. +- [ ] **Step 2b: Settle the accent width, which cannot be settled from a mockup** + +`kAccentWidth` ships at 3px as a starting value. On a query spanning all five +accounts, check: + +- **Are the five tellable apart?** Two colours distinct as chips can read alike + as thin stripes. If not, widen `kAccentWidth` before reaching for different + colours: the config's colours are the user's own choice and changing them is + their call, not the delegate's. +- **Does the spine still read as structure rather than as decoration?** It is + now accent-coloured, and the blend was chosen to match `threadLineColour()`'s + strength. If a bright account makes its expansion shout, raise the blend + weight rather than special-casing that colour. +- **Does the accent survive selection?** It is painted after the chrome + deliberately, so it should stay visible on the highlighted row. Confirm. +- **Check both themes.** The blend goes toward `QPalette::Base`, so it inverts + with the theme; a value that looks right on dark can vanish on light. + - [ ] **Step 3: Check the theme you do not use** Switch the desktop between light and dark and confirm the spine, the chips and @@ -1943,13 +2147,19 @@ reply line 3 (1), the spine and indent cap (4), the expander (5, 6), sorting green), the new roles (1, 2), testing (each task), keyboard navigation (7), and returning to a whole thread (nothing to build, it is the root card). -**Known gap, deliberately left.** The spec says `CardDelegate` should draw the -account chip, and no task does. It is an addition to Task 5's `paint`, on line -1 in front of the sender, using `AccountLabelRole` and `AccountColourRole` -exactly as `SubjectDelegate` did. It is left out because the layout for it was -not specified and inventing one here would be a guess; add it as Task 5b after -seeing the cards on screen, or leave the chip out if the account is already -clear from the sender. +**The account chip gap is closed.** An earlier draft of this plan left the +account chip unhandled, since its placement on a card was never specified. The +user's answer was better than a chip: a coloured bar down the card's left edge, +with the reply spines inheriting the same colour, and matching swatches in the +account dropdown. That is in Task 4 (geometry), Task 5 (painting and the blend) +and Task 8 (the dropdown). The chip itself is gone rather than relocated, which +is a net simplification: it used to eat a third of line 2 to repeat a name the +user already knows. + +**One judgement is deliberately deferred to Task 10.** `kAccentWidth` ships at +3px, and whether five accounts are tellable apart at that width on the user's +own screen and theme cannot be decided from a mockup or a test. Step 2b of Task +10 is where it gets settled. **Ordering risk.** Tasks 2, 5 and 6 leave the build red between commits. That is deliberate, since splitting a column removal across three files cannot be diff --git a/docs/superpowers/specs/2026-08-09-card-list-design.md b/docs/superpowers/specs/2026-08-09-card-list-design.md index f231577..de13132 100644 --- a/docs/superpowers/specs/2026-08-09-card-list-design.md +++ b/docs/superpowers/specs/2026-08-09-card-list-design.md @@ -73,10 +73,56 @@ same places and lights up exactly where the user put a tag deliberately. Computed in the model from data it already holds: `MessageNode::tags` against the parent `ThreadSummary::tags`. **No worker change.** +## The account accent + +**A thread card carries a vertical bar of its account's colour down its left +edge.** A few pixels wide; the exact width is a judgement to make against real +cards on the user's own screen and theme, not from a mockup, since five accounts +is enough that two colours distinct as chips may read alike as thin stripes. + +**Reply cards carry no bar.** The account belongs to the conversation and is +stated once at its head, and a second vertical line in a reply's gutter would +sit a few pixels from the spine and compete with it. + +**Instead the spine inherits the account's colour**, so an expanded thread is +bounded by one accent from its root to its last reply without drawing two lines +anywhere. + +This **replaces the account chip** rather than joining it. The chip ate a third +of line 2 on every card to repeat a name the user already knows, which is +exactly the texture item 53 is about. + +**The colour is blended toward the background, never used raw.** An account +colour is chosen to be a chip's fill, with text drawn on top in whatever stays +legible against it (`TagColors::textColourOn`). The same colour as a thin line +on the pane's own background is a different problem: it has to be followable +down a long expansion without competing with the senders beside it, which is the +constraint `threadLineColour()` already states and meets with a 0.35 weight +toward the palette's text. The accent spine blends the account colour toward +`QPalette::Base` by that same weight, so it keeps the hue that identifies the +account and loses the saturation that would shout. + +**An account with no configured colour still gets one**, derived from the tag +name by `TagColors::colourFor`, which never fails. That fallback is deliberate +and is kept: a stable arbitrary colour is more useful than no accent, and it +means adding an account to the config and forgetting to colour it degrades to +something usable rather than to nothing. + +**The account dropdown carries the same colours.** `m_accountBox` +(`mainwindow.cpp:399`) is filled in a plain loop over `m_config.accounts()`; +each entry gets its account's colour as `Qt::DecorationRole`, which Qt renders +as a swatch with no delegate. That is what makes the accent legible at all: a +bar down a card means nothing until something says which account it is, and the +dropdown is where the user already goes to think about accounts. Use the raw +colour here, not the blended one: a swatch is a filled patch like a chip, not a +thin line. + ## The spine Replies are indented by depth with a **continuous vertical line per depth -level**, drawn the full height of each reply card, in `threadLineColour()`. +level**, drawn the full height of each reply card, in the thread's blended +account accent (above), falling back to `threadLineColour()` when there is no +account tag at all. No elbows, no horizontal tick into the card, and no different glyph on the last child. The alternatives were shown and this one chosen: elbows would require the @@ -172,8 +218,10 @@ painting the whole card, neither is reachable. - Action scope by row kind, and the status-bar scope naming before and after an action. No confirmation dialogs, per the standing rule. - Undo through `TagChange::inverted()`. -- The account chip, the `deletedColour()` / `spamColour()` row fills, and the - unread/read weight and colour cues. +- The `deletedColour()` / `spamColour()` row fills, and the unread/read weight + and colour cues. +- `AccountLabelRole` and `AccountColourRole`, though what they feed changes: the + chip becomes the left accent bar and the dropdown's swatches. - `setUniformRowHeights(true)`. ## New |
