diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-03 11:56:54 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-03 11:56:54 +0200 |
| commit | afd20c9527fa77ba60901707c7bc73b2af926a67 (patch) | |
| tree | 60acbb399c791befb1746b0f28b362e5f17605a4 | |
| parent | 89b7add4d116be057b8fff2c751dc88df71baedf (diff) | |
| download | qtmaildir-afd20c9527fa77ba60901707c7bc73b2af926a67.tar.gz qtmaildir-afd20c9527fa77ba60901707c7bc73b2af926a67.zip | |
docs: add post-0.1.0 usability backlog and app icon
Collects the items found while actually using 0.1.0. Two clusters dominate:
state that does not survive restart (splitter, zoom, account selection) and
actions reachable only by memorized keys.
The plan is open by design rather than a fixed release scope. Numbering is
stable so notes referring to an item keep meaning the same thing.
Decisions recorded while triaging:
- UI state goes to its own file, not qtmaildir.conf. That file is hand-edited
and rewriting it on exit would eat comments QSettings does not preserve.
- Auto-mark-read stays off the undo stack. An explicit toggle_unread action
already exists, so Ctrl+Z need not undo an action the user never took.
- Zoom is Chromium's, not ours. No setZoomFactor call exists in src/, so
persisting it means taking ownership of zoom first.
Icon is a tag rather than an envelope, since tagging is the core interaction
and an envelope would not distinguish it from any other mail client. Verified
legible at 16x16, which is where it is mostly seen.
| -rw-r--r-- | assets/icons/qtmaildir.svg | 5 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 349 |
2 files changed, 354 insertions, 0 deletions
diff --git a/assets/icons/qtmaildir.svg b/assets/icons/qtmaildir.svg new file mode 100644 index 0000000..8980511 --- /dev/null +++ b/assets/icons/qtmaildir.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> + <rect x="8" y="8" width="240" height="240" rx="56" fill="#101e2d"></rect> + <path d="M100,70 L176,70 A14,14 0 0 1 190,84 L190,172 A14,14 0 0 1 176,186 L100,186 L40,128 Z" fill="#a855f7" stroke="#7c3aed" stroke-width="6" stroke-linejoin="round"></path> + <circle cx="100" cy="128" r="15" fill="#101e2d"></circle> +</svg>
\ No newline at end of file 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. |
