163 lines
6.2 KiB
Markdown
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.
|