# 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` — 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` (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" 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`, 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.