diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 349 |
1 files changed, 349 insertions, 0 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 new file mode 100644 index 0000000..8847b9b --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -0,0 +1,349 @@ +# Post-0.1.0 usability backlog + +Status: **open, expandable by design.** This document is not a fixed release +plan. It collects items found by actually using qtmaildir after 0.1.0, and it +grows as more turn up. Nothing here is scheduled; picking what ships in a given +release is a separate decision. + +Source: usage notes taken while running the app, 2026-08-03. + +Numbering is stable. New items append with the next free number and never +renumber, so a note referring to "item 7" keeps meaning the same thing. An item +that is dropped stays in the table marked `dropped` with a one-line reason. + +## Theme + +0.1.0 was built to a spec written by someone who lives in neomutt. The result +is a keyboard-driven reader with almost no visible affordances. The notes below +are, with few exceptions, one complaint restated in several forms: **the app +does not tell the user what it can do, and it does not remember what the user +told it.** Two clusters follow from that: + +- **Persistence.** Splitter position, font size, window geometry, and the + account selection all reset on restart. Each is small on its own and + aggravating every single launch. +- **Discoverability.** Shortcuts are the only route to most actions, and there + is no menu bar, no toolbar, and no way to see the key bindings from inside + the app. + +Both clusters are cheap to fix. Neither was an oversight in design so much as a +consequence of specifying the app as "a GUI counterpart to neomutt" and then +taking that too literally. + +## Status table + +| # | Item | Cluster | Size | Status | +|---|------|---------|------|--------| +| 1 | Splitter/column widths do not survive restart | persistence | S | open | +| 2 | No way to see full message details (From/To/Cc/Subject) | information | M | open | +| 3 | Too few clickable affordances, shortcuts are the only route | discoverability | M | open | +| 4 | Message-pane font size does not survive restart | persistence | S | open | +| 5 | Thread list is cramped, poor readability | presentation | S | open | +| 6 | Opened message stays unread | behavior | S | open | +| 7 | HTML view should be default for HTML messages | behavior | XS | **verify first, may already be done** | +| 8 | No buttons or menu entries for archive, undo, etc | discoverability | M | open | +| 9 | No in-app view of configured shortcuts | discoverability | S | open | +| 10 | Reaching an account's inbox takes two steps | workflow | S | open | +| 11 | Icon, `.desktop` file, SlackBuild | packaging | M | open | + +Sizes are rough: XS under an hour, S a sitting, M a session. + +--- + +## 1. Splitter and column widths do not survive restart + +**Observed:** resizing the thread list pane, or a column inside it, is undone by +the next launch. + +**Cause:** `MainWindow::buildUi()` (`src/mainwindow.cpp:195`) builds the +`QSplitter` fresh every launch, sets a stretch factor, and never saves state. +`resize(1200, 800)` at `src/mainwindow.cpp:206` hardcodes window size too. There +is no `QSettings` window-state read or write anywhere in the class. + +**Approach:** one `saveState`/`restoreState` pair in +`MainWindow`, driven from a `QSettings` object separate from the hand-written +config file. + +- Save on `closeEvent`, restore at the end of `buildUi()`. +- Persist: `QMainWindow::saveGeometry()`, `QMainWindow::saveState()`, + `QSplitter::saveState()`, `QHeaderView::saveState()` for the thread list. +- Restore must be a no-op when the stored blob is absent or rejected, falling + back to the current hardcoded defaults. `restoreGeometry()` returns `false` + in that case; do not assume it succeeded. + +**Where the state file goes.** The hand-edited config lives at +`~/.config/qtmaildir/qtmaildir.conf` and is the user's to own. Machine-written +window blobs must not land in it: a base64 `QByteArray` appearing in a file the +user edits by hand is hostile, and rewriting that file on exit risks clobbering +comments and formatting QSettings does not preserve. Use a **separate** +`QSettings` instance for UI state, at +`~/.local/state/qtmaildir/uistate.conf` or the `QStandardPaths` equivalent, and +keep `Config` untouched. + +**Confirmed by the user, 2026-08-03.** This decision covers items 1, 4, and 10 +as well. Establish it once, in whichever lands first. + +**Verification:** manual. Resize both the window and the splitter, restart, +confirm both held. Then delete the state file and confirm the app still starts +with the 1200x800 default rather than a zero-size window. + +## 2. No way to see full message details + +**Observed:** From, To, Cc, Subject and the rest are not visible for the +selected message. + +**Cause:** partially true rather than wholly. `MessageView::updateHeader()` +(`src/messageview.cpp:219`) shows only the thread subject and a message count. +Per-message From and Date *are* rendered inside the HTML body as `.msg-header` +(`src/htmlbuilder.cpp:241`), styled at 9pt grey, which is easy to miss and does +not include To or Cc at all. + +**Check before building:** does `MimeParser` already extract To and Cc into the +message struct, or does `src/types.h` / `MimeParser`'s output need extending +first? If the fields are not parsed, that is the real first task and it is +larger than the UI work. + +**Approach, two parts:** + +- Widen the persistent header at the top of the message pane to show the + selected message's From, To, Cc, Date and Subject. This is the note's stated + preference ("should appear on top in right pane"). +- Add a shortcut and menu entry for a full raw-header dialog, for the cases the + summary omits (Message-Id, List-Id, Received chain). Read-only, selectable + text, no rendering. + +Both, not one: the header widget answers "who is this from" at a glance, the +dialog answers "what actually happened to this message". They are different +questions. + +**Constraint:** header values are untrusted input. The existing header label is +`Qt::RichText` (`src/messageview.cpp:104`), so every value must be +`toHtmlEscaped()` before interpolation, exactly as `updateHeader()` already +does. A `From` display name containing markup must never be able to inject into +the label. The raw-header dialog should use `Qt::PlainText` and sidestep the +question entirely. + +## 3, 8, 9. Discoverability: menu bar, toolbar, shortcut reference + +Grouped because they are one piece of work. Item 3 is the complaint, items 8 +and 9 are two of its symptoms. + +**Observed:** for a GUI app there is almost nothing to click; every action needs +a memorized key. Archive and undo have no buttons. There is no way to see the +configured bindings without opening a terminal. + +**Cause:** `MainWindow` has no `menuBar()` and no `QToolBar`. Actions are not +`QAction`s at all: `registerActions()` (`src/mainwindow.cpp:209`) fills a +`QHash<QString, std::function<void()>>` consumed by an `eventFilter`. Nothing in +that structure can appear in a menu, because a menu needs `QAction` objects. + +**Approach: convert the action registry to `QAction`s.** This is the core of the +work and everything else follows from it. + +- Each entry becomes a `QAction` with text, an object name matching the current + action key, and a shortcut set from `KeyMap`. +- `MainWindow::registeredActionNames()` and the `KeyMap::knownActions()` drift + test (already noted in `mainwindow.h` as hand-maintained) must keep working. + If the conversion lets both lists derive from one source, that test becomes + unnecessary, which is a real win. Check whether it can. +- The `eventFilter` route may become redundant once shortcuts live on the + `QAction`s. Removing it is the goal, but verify: the filter may be handling + focus cases (keys while the query line edit has focus) that `QAction` + shortcuts resolve differently. Do not delete it on the assumption that + `QAction` covers everything. +- Menu bar: File (Sync, Quit), Edit (Undo, Redo), Message (Archive, Delete, + Spam, Toggle unread, Toggle HTML, Load remote content), View (font size, see + item 4), Help (Shortcuts, About). +- Toolbar: the frequent subset only. Sync, Archive, Delete, Undo. A toolbar + holding every action is as unreadable as no toolbar. +- Shortcut reference (item 9): a dialog listing action, description, and current + binding, generated from the same `QAction` list. Generated, never hand-written + in parallel, or it drifts the way the two action lists already do. + +**Constraint:** undo must stay unconfirmed. `CLAUDE.md` is explicit that tag +mutations get undo instead of confirmation dialogs. Adding menu entries must not +smuggle in a "Are you sure?" for Delete. + +**Verification:** the existing keymap test must still pass unchanged, proving +user bindings survive the conversion. That is the load-bearing check here. + +## 4. Message-pane font size does not survive restart + +**Observed:** described as "very annoying", more so than item 1. + +**Confirmed by the user:** Ctrl+`+` / Ctrl+`-` do change the message pane font +size. It is only the persistence that is missing. + +**Cause:** qtmaildir does not implement that zoom. There is no `setZoomFactor`, +no zoom action, and no `Ctrl+=`/`Ctrl+-` binding anywhere in `src/`; grep finds +nothing. The behavior comes from `QWebEngineView`, which handles zoom keys +natively in Chromium. `htmlbuilder.cpp:26` separately hardcodes +`font-size: 10pt` as the document's base size, which the browser zoom then +scales. + +This changes the shape of the work. There is no application-side value to save, +because the application never learns the zoom changed: Chromium handles the key +and adjusts the factor without telling anyone. Persistence therefore requires +taking ownership of zoom first, rather than hooking a save onto something that +already exists. + +**Approach:** + +- Add explicit zoom in, zoom out and reset actions calling + `QWebEngineView::setZoomFactor()`, tracking the current factor in + `MessageView`. Use `setZoomFactor` rather than rewriting the CSS: it scales + the rendered document uniformly, needs no re-render, and does not disturb + `HtmlBuilder`'s output or its tests. +- Bind them to the same Ctrl+`+` / Ctrl+`-` the user already has in their + fingers, so the change is invisible except that it now sticks. Verify the + application binding actually takes precedence over Chromium's built-in + handling; if the web view swallows the key first, the action never fires and + the factor silently diverges from what is on screen. This is the one real + risk in the item and is worth checking before building the rest. +- Persist the factor to the UI state file from item 1, and reapply it on every + `setDocument()`. Do not assume zoom survives a load; `QWebEngineView` may + reset it on navigation. Verify empirically. +- Config entry as the user suggested: a `[general]` key for the starting zoom, + with the state file remembering runtime changes on top of it. The config value + is the default for a fresh profile, the state file is what the user last had. +- Clamp to a sane range. An accidental 0.1 or 25.0 leaves the pane unusable and + the user with no visible way back. Chromium's own limits are roughly 0.25 to + 5.0; match or tighten, never widen. +- Route the actions through item 3's `QAction` conversion so they appear in the + View menu, which also makes the reset discoverable. + +## 5. Thread list is cramped + +**Observed:** rows are tightly packed, everything is uniform, the UI reads as +"stuffed". + +**Approach:** presentation only, no model changes. + +- Row height: give the `QTableView` vertical breathing room. +- Alternating row colors, or a subtle separator. +- Make unread threads visually distinct (bold), which is the one distinction + that carries real information and currently does not exist. +- Consider dropping a column. Look at what `ThreadListModel` exposes and ask + whether every column earns its width. + +**Caution:** do not hardcode colors. The app should follow the desktop palette; +a hand-picked grey that looks right on a light theme is unreadable on a dark +one. Use `QPalette` roles. This applies to `HtmlBuilder`'s CSS too, which +currently hardcodes `#bbb`, `#555`, `#000`, `#666`, `#ddd` and no background, +and will look wrong under a dark theme. That is arguably its own item; see +item 12 below if it gets split out. + +## 6. Opened message stays unread + +**Observed:** opening a message leaves it tagged unread; the user expects it to +become read. + +**Decision (user, 2026-08-03): mark read after a 2 second delay, configurable.** + +**Approach:** + +- A `QTimer` started in `onThreadSelected()` (`src/mainwindow.cpp:373`), fired + once, removing the `unread` tag from the displayed thread. +- The timer **must** be restarted, not stacked, when the selection changes. + Arrowing quickly down a list must not mark ten threads read; only the one + still selected when the timer fires. +- **Decided (user, 2026-08-03): the automatic mark-read does NOT go on the undo + stack.** The tag change routes through `sendThreadTagChange()` directly, + bypassing the `ThreadTagCommand` push, exactly as an explicit non-undoable + mutation would. Rationale: a manual toggle-unread action already exists + (`toggle_unread`, `src/mainwindow.cpp:242`), so a user who wants the message + back as unread has a direct route and does not need undo for it. Leaving a + message read after undoing an unrelated archive is acceptable; hijacking + Ctrl+Z to undo an action the user never took is not. +- Consequence to keep in mind: `applyTags` is the funnel for all mutations per + `CLAUDE.md`, and that stays true. What changes is only whether the inverse is + pushed onto the `QUndoStack`, which is a `MainWindow` decision made above the + worker. Do not add a second write path to the worker for this. +- Config key in `[general]`, e.g. `mark_read_delay_ms`, default `2000`, with `0` + meaning "immediately" and a negative value meaning "never". Document all + three in the README. +- Interaction with item 3's toggle-unread action: if the user explicitly marks a + message unread, the timer must not immediately re-mark it read. Cancel the + pending timer on any manual unread toggle. + +**Verification:** unit-testable against the throwaway notmuch database the +`NotmuchWorker` tests already build, but the timer logic itself is UI-side and +easier to check by hand. At minimum, verify the rapid-arrow case manually. + +## 7. HTML view should be default for HTML messages + +**Verify before doing anything.** `MessageView::m_preferHtml` is already +initialized to `true` (`src/messageview.h`), and `clear()` resets it to `true` +(`src/messageview.cpp:164`). HTML should already be preferred where a message +offers it. + +Possible explanations for the observation: + +- The messages in question are `multipart/alternative` and `HtmlBuilder`'s + `PreferHtml` mode is not selecting the HTML part correctly. +- The HTML renders, but with remote content blocked it looks like plain text. +- `m_preferHtml` is being reset between messages by a `clear()` the user did not + intend to trigger. + +Reproduce first with a specific message, then decide. If it turns out to work +correctly, the item becomes a documentation gap rather than a bug, and the +user-preference key mentioned in the note (`prefer_html`, `[general]`) is still +worth adding for people who want plain text by default. + +**Do not**, in the course of this, relax anything in the web view security +section of `CLAUDE.md`. Preferring HTML is orthogonal to remote content, which +stays blocked and per-render. + +## 10. Reaching an account's inbox takes two steps + +**Observed:** select account from the dropdown, then click inbox or unread. + +**Approach:** cheapest useful fix first. + +- Persist the selected account across restarts (uses item 1's state file). If + the user reads one account 90% of the time, this alone removes most of the + friction. +- Then: per-account entries in a menu, or saved queries that carry their own + account scope, so one action gets there. `Account::scopedQuery()` already + exists in `Config`, so the composition is available; it is a UI question, not + a query question. + +Do not build a full account sidebar for this. Persisting the selection may +resolve the complaint entirely, and it is a fraction of the work. Reassess after. + +## 11. Icon, `.desktop` file, SlackBuild + +Packaging, independent of everything above, and can proceed in parallel. + +- **Icon:** an SVG plus rendered PNGs at the standard hicolor sizes. Needs a + design decision, not just code. +- **`.desktop` file:** `Categories=Network;Email;`, `Terminal=false`, + `MimeType=x-scheme-handler/mailto;` only if a `mailto:` handler is actually + implemented, which it is not in 0.1.0. Do not claim the MIME type until it + works; a desktop entry that registers as the mail handler and then does + nothing is worse than not registering. +- **CMake install rules:** icon into `share/icons/hicolor/<size>/apps/`, desktop + file into `share/applications/`. Neither exists yet. +- **SlackBuild:** per the global workflow, sources are authored here and the + user builds. Deliverables are `qtmaildir.SlackBuild`, `.info`, `README`, + `slack-desc`, plus an nvchecker stanza. Depends on a tagged release existing + to point `DOWNLOAD` at, so it follows a tag rather than leading it. + +--- + +## Deferred, unsized, or split out + +Items noted while triaging but not part of the original list. Same numbering +sequence, appended as they arise. + +| # | Item | Why here | +|---|------|----------| +| 12 | `HtmlBuilder` CSS is light-theme only | Split from item 5. Hardcoded greys and no background color; a dark desktop theme will render message bodies badly. Fix likely means passing palette-derived colors into the CSS, which affects `HtmlBuilder`'s tests. | + +## Adding to this document + +Append a row to the status table with the next free number, then a section using +the same shape: **Observed** (what the user saw), **Cause** (the code, with file +and line, verified not assumed), **Approach**, **Constraints**, and +**Verification** where it is not obvious. Do not renumber. Do not delete: mark +`dropped` with a reason. |
