diff options
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 22 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-08-09-card-list-design.md | 229 |
2 files changed, 247 insertions, 4 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 159643f..c799e23 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -108,9 +108,9 @@ taking that too literally. | 48 | Removing a tag suggests every tag, not the thread's own | workflow | XS | **done** | | 49 | Sync runs every account regardless of what changed | workflow | M | **done** | | 50 | Esc blanks the pane but leaves the row selected | workflow | XS | **done** | -| 51 | Clicking a subject scrolls the list sideways | presentation | XS | open | +| 51 | Clicking a subject scrolls the list sideways | presentation | XS | open; resolved as a side effect of 53, do not work separately | | 52 | `test_querycompleter` fails under Wayland, passes offscreen | testing | XS | **done** | -| 53 | Message rows still read as a table, not as a conversation | presentation | ? | open, unspecified | +| 53 | Message rows still read as a table, not as a conversation | presentation | M | open, specified 2026-08-09; see the card-list spec | | 54 | A cron sync carries the edits but the count still says pending | correctness | S | **done** | | 55 | In a narrow window the message pane is invisible | presentation | XS | **done** | | 56 | No action carries an icon, so the toolbar reserves space for nothing | presentation | S | **done** | @@ -3241,8 +3241,22 @@ exists, and its band arithmetic assumes a uniform row height and a known column layout. Anything that varies row height or removes columns for one row kind has to answer for the strip on thread rows, which must not change. -**Do not start any of these from this description.** It says what is and why, -not what was wanted. +**Specified 2026-08-09.** The user's answer was that the grid is wrong for the +WHOLE left pane, not only for reply rows, which is wider than any of the four +directions above. Threads and replies both become cards in a single column, with +no column grid at all. The design is at +`docs/superpowers/specs/2026-08-09-card-list-design.md`; read that rather than +this entry, which records only the finding. + +Two things settled here that the directions above got wrong. Direction 2's `Re:` +prefix removal is folded in rather than tried first, since the grid goes anyway. +And the constraint about the tag strip inverts: the strip is not something the +design has to answer for, it is deleted, because `ThreadListView` exists ONLY to +paint across columns that no longer exist. This item is a net removal of code. + +**Item 51 is resolved by this design as a side effect** and should not be worked +separately. Cards are exactly viewport width, so the pane has no horizontal +scroll range for a click to scroll into. ## 54. A cron sync carries the edits but the count still says pending diff --git a/docs/superpowers/specs/2026-08-09-card-list-design.md b/docs/superpowers/specs/2026-08-09-card-list-design.md new file mode 100644 index 0000000..3c717eb --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-card-list-design.md @@ -0,0 +1,229 @@ +# Card list: the thread pane without a column grid + +**Status:** specified 2026-08-09, not implemented. +**Supersedes:** the presentation half of item 20, built on `item-20-message-rows` +and rejected. Resolves backlog item 53. Retires item 51 as a side effect. + +## Why + +Item 20 shipped message rows exactly as its four decisions specified, and the +user's verdict on the finished result was that the table view does not fit the +use. Item 53 recorded the cause verified in code: a message row fills the same +five columns as a thread row, so replies land on the same rigid column +boundaries as the threads around them, and the eye reads columns before it reads +indentation or tint. + +The user's decision on 2026-08-09 is that the grid is wrong for the **whole** +left pane, not only for reply rows. Threads and replies both become cards. + +This is a presentation change. The model's data, the reply tree from notmuch, +the action scope, undo, and the worker are all kept. + +## The card + +One column. Every row is a card of exactly three lines, thread and reply alike. + +``` + sender ............................................ date + ★ subject @ ▾ 3 replies + [unread] [work] +``` + +- **Line 1:** sender, and the date flush right. +- **Line 2:** the flag mark, the subject, the attachment mark, and the reply + count. Flag and attachment become inline marks on this line rather than + columns of their own; `attachmentGlyph()` and `flagGlyph()` already answer + what character to draw and keep their font-fallback behaviour. +- **Line 3:** the thread's pill tags, from the existing `PillTagsRole` and + `PillColoursRole`. + +A **reply card** is the same three lines, indented, dimmed with `readColour()`, +tinted with `replyBackground()`, with the `Re: ` prefix stripped from line 2 and +no reply count. Its line 3 is specified below. + +**Every card is the same height**, including replies and including cards whose +line 3 is empty. This is the single cheapest property of the design: +`setUniformRowHeights(true)` stays, the delegate's `sizeHint` is one constant +computed from the font metrics, and no scrolling or hit-testing arithmetic has +to account for rows of differing size. The cost is a blank band under untagged +cards, which the user accepted explicitly. + +## Reply line 3: only what the thread does not already say + +A reply card's line 3 carries **the tags that message has and its thread does +not**. A reply tagged `todo` inside an untagged thread shows `[todo]`. A reply +carrying only the thread's own tags shows nothing. + +The rule exists so a tag applied to one message stays findable inside its +thread, without the thread's tags repeating identically down the whole +expansion. That repetition is the striping `ThreadListView`'s own header +comments give as the reason the current strip is painted on thread rows only. + +**Measured against the user's database, 2026-08-09**, because the alternative +(showing a reply's full tag set) was rejected on this evidence rather than on +taste: of 48691 messages, 7 carry `unread` and 75 carry `flagged`. Those are the +only two tags that vary within a thread in practice; the rest (`account-*`, +`lists`, and so on) are applied to whole threads and are identical on every +message in them. Both varying tags are already visible another way, `unread` as +the sender's weight and `flagged` as the mark on line 2. So a full per-message +tag set would render blank on essentially every reply and identical chips on the +rest, which is cost without payoff. The set difference degrades to blank in the +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 spine + +Replies are indented by depth with a **continuous vertical line per depth +level**, drawn the full height of each reply card, in `threadLineColour()`. + +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 +delegate to know whether a row is its parent's last child, and box-drawing +characters (`├─`, `└─`) depend on the UI font carrying glyphs a proportional +font often lacks or spaces badly, and do not scale with the row when the font +size changes. + +**Indent caps at depth 4.** A reply at depth 5 or deeper renders at depth 4's +indent, spines included, with **no marker** saying it was flattened. Item 20 +already accepted that deep chains must be capped in the view rather than +flattened in the model, and the cap belongs to the delegate. + +**No horizontal scrolling.** Uncapped indent with a horizontally scrollable pane +was considered and rejected: it reopens item 51 in a worse form. Today's +sideways scroll on click happens because the Subject column is wider than the +viewport, and `QAbstractItemView`'s auto-scroll brings the clicked index into +view. With cards there are no columns and a card is exactly viewport width, so +the horizontal scrollbar disappears and **item 51 is resolved for free**. +Restoring an over-wide row would put it back, and this time clicking any deep +reply would scroll the pane sideways. The card's right-aligned date is a second +casualty: it either scrolls out of sight or stops being right-aligned. + +## Expanding + +**The reply count on line 2 is the expander.** Clicking it toggles the thread; +clicking anywhere else on the card selects it and opens the message. + +No separate chevron in a left gutter. That would cost horizontal space on every +card including the ones with no replies, and the branch already hit the trap +that `setRootIsDecorated(false)`, needed to stop the style drawing its own +indicator, also removes the style's **hit area**, leaving a glyph that renders +and does nothing. + +Two consequences that must be honoured, both learned on the branch: + +- The delegate draws the expander, so the **view** must own the click, since a + delegate gets no click of its own without an editor. `ThreadListView` keeps + `mousePressEvent` for exactly this. +- `isExpanded` and `setExpanded` are keyed on **column 0**, which is now the + only column. + +## Sorting + +A **sort dropdown** in the query row, two entries: newest first (the default) +and oldest first. Passed to `notmuch_query_set_sort`, which today is hardcoded +to `NOTMUCH_SORT_NEWEST_FIRST` at `notmuchworker.cpp:135`. + +**This adds a feature rather than replacing one.** The current column header is +decorative: nothing in the codebase implements click-to-sort, so removing the +header loses nothing. + +Two entries and not four. notmuch offers `NOTMUCH_SORT_MESSAGE_ID` and +`NOTMUCH_SORT_UNSORTED` as well, and neither is a sort order a human wants. +Sorting by sender or subject was declined: notmuch cannot do it, so the model +would have to sort after results arrive, which fights the 200-at-a-time batching +that makes a 10k-thread query paint immediately. + +The chosen order persists in `~/.local/state/qtmaildir/uistate.conf` via +`MainWindow::uiStatePath()`, never in the hand-edited config. + +Changing the sort re-runs the current query, so it bumps the generation counter +like any other query. + +## What is deleted + +A net removal of code: + +- `ThreadListView::paintEvent` and the tag strip's band arithmetic. +- `SubjectDelegate` and `RowStyleDelegate`. +- The five `Column` enumerators (`AttachmentColumn`, `FlagColumn`, `DateColumn`, + `AuthorsColumn`, `SubjectColumn`), collapsing `ColumnCount` to 1. +- `headerData` and the view's header. +- The `HasRepliesRole` reservation logic that told the subject cell to leave + room for a glyph, now the delegate's own layout. + +**`ThreadListView` survives, with a much smaller job.** It keeps +`mousePressEvent` for the expander hit-test. It no longer paints anything. + +This retires two bug classes `CLAUDE.md` documents for the strip: a deleted row +cut in half, and every other row showing a bare stripe, both caused by the view +having to re-honour alternating colours, selection and `BackgroundRole` itself +because the strip spanned cells it did not own. With one column and one delegate +painting the whole card, neither is reachable. + +## What is kept + +- Every model role on the branch: `ThreadIdRole`, `AccountLabelRole`, + `AccountColourRole`, `TagsRole`, `PillTagsRole`, `PillColoursRole`, + `IsMessageRole`, `MessageIdRole`, `MessageDepthRole`, `HasRepliesRole`. +- `QAbstractItemModel` with the reply tree, lazy child loading, and + `hasChildren` answered from `totalCount` rather than from loaded children. +- 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. +- `setUniformRowHeights(true)`. + +## New + +- **`MessageOwnTagsRole`** and **`MessageOwnColoursRole`**, the set difference + described above and its chip colours in the same order. Model-side only, and + mirroring the existing `PillTagsRole` / `PillColoursRole` pair so the colours + keep coming from the model's `TagColors` rather than from a delegate reading + config as a second source of truth. +- **`CardDelegate`**, replacing `SubjectDelegate` and `RowStyleDelegate`. It + paints the whole card and owns every measurement: the three line baselines, + the indent per depth with its cap, the spine rects, the expander's rect, and + the chip run on line 3. +- The sort dropdown and its `uistate.conf` key. + +## Testing + +Per the rendering-probe warnings in `CLAUDE.md`, which were written after a +whole session was lost to probes that lied: + +- **Assert on the delegate's computed geometry, not on pixels.** `CardDelegate` + exposes its layout (line rects, indent width, expander rect, spine rects) as + a testable function of a row and a width. Those are the assertions. + `sizeHint` is asserted to be constant across thread rows, reply rows, tagged + and untagged. +- **Never count lit pixels.** It cannot tell bold from regular in either + direction. Where a rendered check is genuinely needed, use text width or a + strict pixel diff. +- **Every rendering test carries a mutation check** and a guard proving it can + fail: assert the geometry it depends on rather than assuming it. +- The set-difference rule gets a plain model test: a reply tagged with one tag + its thread lacks reports that tag alone; a reply carrying only thread tags + reports nothing. +- The indent cap gets a test at depths 3, 4, 5 and 9, asserting depth 5 and 9 + compute the same indent as 4. +- The expander hit-test gets a test that a click on the reply count toggles and + a click elsewhere on the card does not, since being visible and being + clickable are separate properties here. +- **Item 51 gets a regression test**: with cards, the view reports no horizontal + scroll range, and clicking a card does not change `horizontalScrollBar()`'s + value. + +Two constraints on writing these, from `CLAUDE.md`: nothing may be keyed on a +row **number**, because a tree numbers rows per parent; and the offscreen +platform chooses the window width itself and has been seen to choose +differently between runs, so a test must not depend on a particular width. + +## Open, deliberately not decided here + +- **Moving between messages in a thread without returning to the list.** Named + by the user during item 20 and deferred there. Still deferred. +- Whether the message pane's own presentation should change to match. Out of + scope: this spec is the left pane only. |
