crabidy/architecture/help-modal.md

6.2 KiB

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:

  • ScopeGlobal | 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 KeyEventAction 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

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

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.