crabidy/architecture/help-modal.md

163 lines
6.2 KiB
Markdown

# Help modal for cbd-tui
## Context and problem statement
`cbd-tui` is the ratatui/crossterm terminal client for crabidy. All keyboard
handling lives in a single `match (app.focus, key.modifiers, key.code)` in
`main.rs` (`run_ui`), covering global bindings plus per-pane bindings for the
two focusable panes (`UiFocus::Library`, `UiFocus::Queue`). None of this is
discoverable from inside the app: a new user has to read the source to learn
that `Tab` cycles panes or that `a` appends the selected library node to the
queue.
Goal: pressing `?` opens a help modal that explains basic usage (panes, focus
cycling) and lists all keyboard shortcuts; a key press closes it again.
## Assumptions (confirmed)
- The help content must not be able to drift from the real bindings — the
binding table becomes the single source of truth for both dispatch and help
rendering (confirmed with user; see Options).
- The modal is read-only and modal in the strict sense: while it is open, all
other bindings are inert. `?`, `Esc`, and `q` close it (`q` therefore does
**not** quit the app while help is open).
- Bindings stay hardcoded for now. User-configurable keymaps are out of scope,
but the table design must not preclude them later.
- The modal shows **all** scopes (Global, Library, Queue) grouped, not just the
bindings of the currently focused pane — the point is discovery.
- No new dependencies; ratatui's `Clear` widget plus a centered `Rect` is
enough for the overlay.
## Options considered
### Option A — static help text, dispatch untouched
A display-only `const HELP: &[(&str, &str, &str)]` table rendered by the
modal; the existing `match` in `main.rs` stays as-is.
- **Pros**: smallest diff; zero refactor risk.
- **Cons**: two parallel encodings of the same facts; every binding change now
has a silently skippable second edit site. Historically this is exactly the
kind of table that rots.
### Option B — declarative binding table (chosen)
Introduce `app/bindings.rs`:
- `Scope``Global | Library | Queue`, mirroring `UiFocus` plus a global tier.
- `Action` — one variant per user-visible operation (`Quit`, `TogglePlay`,
`VolumeUp`, `LibraryDown`, `QueueRemoveTrack`, …).
- `Binding { scope, mods, code, action, description }` with
`const BINDINGS: &[Binding]`.
- `lookup(focus: UiFocus, key: KeyEvent) -> Option<Action>` — scope-aware
table scan (global entries match in any focus; pane entries only when that
pane is focused).
- A `key_label(mods, code) -> String` formatter so the help modal derives the
displayed key from the same data dispatch uses (no hand-written "Ctrl+d"
strings).
The event loop shrinks to: translate `KeyEvent``Action` via `lookup`, then
one `match action` executes it (`App::dispatch`). The help modal renders
`BINDINGS` grouped by `Scope`.
- **Pros**: single source of truth; help cannot drift; the loop's 30-arm match
becomes data; natural seam for configurable keymaps later.
- **Cons**: moderate refactor of `run_ui`; `Action` execution needs access to
both `&mut App` and the `Sender<MessageFromUi>` (solved by giving `App` its
own `tx`, which it already receives in `App::new`).
**Decision: Option B**, confirmed with the user.
## Structure
```d2
direction: right
main: main.rs run_ui loop {
poll: crossterm event poll
}
app: app module {
bindings: bindings.rs {
table: "BINDINGS: &[Binding]"
lookup: "lookup(focus, key) -> Option<Action>"
label: "key_label(mods, code)"
}
state: App {
focus: "focus: UiFocus"
help: "show_help: bool"
dispatch: "dispatch(action)"
}
help_modal: help.rs {
render: "render_help(frame)"
}
}
server: crabidy-server (gRPC)
main.poll -> app.bindings.lookup: KeyEvent
app.bindings.lookup -> app.state.dispatch: Action
app.bindings.table -> app.bindings.lookup: dispatch reads
app.bindings.table -> app.help_modal.render: help reads same table
app.state.dispatch -> server: MessageFromUi via tx
app.state.help -> app.help_modal.render: gates overlay
```
## Key-press flow
```d2
shape: sequence_diagram
user: User
loop: run_ui loop
bindings: bindings::lookup
app: App
user -> loop: presses "?"
loop -> bindings: lookup(focus, key)
bindings -> loop: "Some(Action::ToggleHelp)"
loop -> app: "dispatch(ToggleHelp)"
app -> app: "show_help = true"
loop -> app: render()
app -> app: draw panes, then help overlay (Clear + centered popup)
user -> loop: presses any bound key while help open
loop -> bindings: lookup sees help-open state
bindings -> loop: "only Close actions match (?, Esc, q)"
```
## Boundaries and interfaces
- **`app/bindings.rs`** owns the vocabulary: `Scope`, `Action`, `Binding`,
`BINDINGS`, `lookup`, `key_label`. Pure data + pure functions; no I/O, no
ratatui types — unit-testable without a terminal.
- **`App`** gains `show_help: bool`, a stored `tx: Sender<MessageFromUi>`, and
`dispatch(&mut self, action: Action)`. `run_ui` keeps ownership of the loop
and terminal; quitting stays a loop-level concern (`dispatch` returns a
signal or `Action::Quit` is handled in the loop — decided in api-design).
- **`app/help.rs`** renders the overlay: short usage paragraph (panes, `Tab`
to switch focus) followed by the binding table grouped by scope. Reads
`BINDINGS` only.
- Modal gating lives in one place: when `show_help` is true, `lookup` (or the
loop) only admits close actions. No other component needs to know the modal
exists.
## Risks
- **`?` and modifier reporting**: terminals differ on whether `?` arrives with
`SHIFT` set. Match `KeyCode::Char('?')` regardless of the shift modifier
(as the existing `J`/`K`/`G` arms already do for shifted letters).
- **Small terminals**: the full binding list may not fit. Initial version
clamps the popup to the frame and truncates; scrolling is an explicit
non-goal for now (open question below).
- **Refactor regressions**: converting ~30 match arms to table entries risks
transposition mistakes. Mitigated by unit tests asserting `lookup` results
for every current binding (quality-gates stage).
## Open questions
- Should the help modal scroll when the terminal is too small, or is
truncation with a "…" indicator acceptable? (Default: truncate.)
- Mouse support is enabled (`EnableMouseCapture`) but unused; clicking outside
the modal to close it is a possible later nicety, not in scope.