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, andqclose it (qtherefore 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
Clearwidget plus a centeredRectis 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, mirroringUiFocusplus a global tier.Action— one variant per user-visible operation (Quit,TogglePlay,VolumeUp,LibraryDown,QueueRemoveTrack, …).Binding { scope, mods, code, action, description }withconst 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) -> Stringformatter 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;Actionexecution needs access to both&mut Appand theSender<MessageFromUi>(solved by givingAppits owntx, which it already receives inApp::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.rsowns the vocabulary:Scope,Action,Binding,BINDINGS,lookup,key_label. Pure data + pure functions; no I/O, no ratatui types — unit-testable without a terminal.Appgainsshow_help: bool, a storedtx: Sender<MessageFromUi>, anddispatch(&mut self, action: Action).run_uikeeps ownership of the loop and terminal; quitting stays a loop-level concern (dispatchreturns a signal orAction::Quitis handled in the loop — decided in api-design).app/help.rsrenders the overlay: short usage paragraph (panes,Tabto switch focus) followed by the binding table grouped by scope. ReadsBINDINGSonly.- Modal gating lives in one place: when
show_helpis 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 withSHIFTset. MatchKeyCode::Char('?')regardless of the shift modifier (as the existingJ/K/Garms 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
lookupresults 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.