feat(tui): configurable key bindings system #50

Merged
charles merged 1 commit from tui/keybinds-19 into main 2026-04-11 20:41:27 +00:00
Owner

Summary

Third PR in the loom-tui stack. Adds a real key-binding layer so the spec §3.1 defaults come from data instead of hard-coded matches, and future config loading can layer overrides.

Stacks on #49. Closes charles/loom#19.

What's in

  • KeyCombo — single modified key press with round-trippable Display/FromStr. Handles Ctrl+Shift+K, Esc, ?, F5, Space, lowercase modifiers, shift normalisation for printable chars
  • BindingSingle(KeyCombo) or Chord(KeyCombo, KeyCombo); also round-trippable ("g g")
  • KeyMap — scope-aware lookup (Scope::Global vs Scope::Screen(ScreenKind)), per-screen shadows global, apply_overrides(toml::Table, known_action_ids) with OverrideWarnings (UnknownAction, BadValue, ParseError)
  • ChordTracker — state machine that walks keystrokes and returns Action(id) / Pending / Unbound. Prefers holding when a key is a chord prefix in the active scope so chords are always reachable
  • actions module — APP_* and NAV_* action id constants. Screen-local action ids get added alongside their screens
  • defaults() — compiles the spec §3.1 global map in
  • App refactor — the old hard-coded match block is gone. handle_global_key routes through ChordTracker::stepinvoke_action(ActionId). Chord state resets on navigation.

Tests (16 total, all passing)

  • Combo parse/display roundtrip across representative combos
  • Lowercase modifiers accepted
  • Unknown modifier rejected with UnknownModifier
  • Chord binding parse ("g g"Chord)
  • Defaults have nav mnemonics
  • Chord tracker holds on g when a gg chord is scoped to the current screen, then fires chord on second g
  • Chord tracker fires single binding immediately when no chord prefix matches in scope
  • Overrides replace defaults and emit UnknownAction warning for bogus ids

Notes

  • Binding::single / chord, KeyMap::unbind, apply_overrides, known_action_ids are #[allow(dead_code)] — they're the consumer surface for #43 (config) and #39 (settings editor). Marked explicitly so accidental dead code elsewhere still warns
  • The APP_TOGGLE_SIDEBAR / APP_FOCUS_NEXT / APP_FOCUS_PREV / APP_PALETTE actions are parsed into the defaults but invoke_action only handles quit/help/cancel/nav today. The rest come online in #17 (sidebar/focus), #18 (palette)
  • Chord ambiguity rule: when a key is both a single binding and a chord prefix in scope, the tracker holds. Without a timeout mechanism this is the only way to keep chords usable. Vim-style timeouts can be added later without breaking the API
## Summary Third PR in the loom-tui stack. Adds a real key-binding layer so the spec §3.1 defaults come from data instead of hard-coded matches, and future config loading can layer overrides. Stacks on #49. Closes charles/loom#19. ## What's in - **`KeyCombo`** — single modified key press with round-trippable `Display`/`FromStr`. Handles `Ctrl+Shift+K`, `Esc`, `?`, `F5`, `Space`, lowercase modifiers, shift normalisation for printable chars - **`Binding`** — `Single(KeyCombo)` or `Chord(KeyCombo, KeyCombo)`; also round-trippable (`"g g"`) - **`KeyMap`** — scope-aware lookup (`Scope::Global` vs `Scope::Screen(ScreenKind)`), per-screen shadows global, `apply_overrides(toml::Table, known_action_ids)` with `OverrideWarning`s (`UnknownAction`, `BadValue`, `ParseError`) - **`ChordTracker`** — state machine that walks keystrokes and returns `Action(id)` / `Pending` / `Unbound`. Prefers holding when a key is a chord prefix in the active scope so chords are always reachable - **`actions`** module — `APP_*` and `NAV_*` action id constants. Screen-local action ids get added alongside their screens - **`defaults()`** — compiles the spec §3.1 global map in - **`App` refactor** — the old hard-coded match block is gone. `handle_global_key` routes through `ChordTracker::step` → `invoke_action(ActionId)`. Chord state resets on navigation. ## Tests (16 total, all passing) - Combo parse/display roundtrip across representative combos - Lowercase modifiers accepted - Unknown modifier rejected with `UnknownModifier` - Chord binding parse (`"g g"` → `Chord`) - Defaults have nav mnemonics - Chord tracker holds on `g` when a `gg` chord is scoped to the current screen, then fires chord on second `g` - Chord tracker fires single binding immediately when no chord prefix matches in scope - Overrides replace defaults and emit `UnknownAction` warning for bogus ids ## Notes - `Binding::single / chord`, `KeyMap::unbind`, `apply_overrides`, `known_action_ids` are `#[allow(dead_code)]` — they're the consumer surface for #43 (config) and #39 (settings editor). Marked explicitly so accidental dead code elsewhere still warns - The `APP_TOGGLE_SIDEBAR` / `APP_FOCUS_NEXT` / `APP_FOCUS_PREV` / `APP_PALETTE` actions are parsed into the defaults but `invoke_action` only handles quit/help/cancel/nav today. The rest come online in #17 (sidebar/focus), #18 (palette) - Chord ambiguity rule: when a key is both a single binding and a chord prefix in scope, the tracker holds. Without a timeout mechanism this is the only way to keep chords usable. Vim-style timeouts can be added later without breaking the API
Introduces a KeyCombo / Binding / KeyMap trio with round-trippable
string parsing, scope-aware lookup (global or per-screen), and a
ChordTracker state machine for two-key chord sequences like `gg` or
`dd`. Default map matches spec §3.1: `q`/`Ctrl+C` quit, `?` help,
`:`/`Ctrl+P` palette, `Ctrl+B` sidebar, `Tab`/`BackTab` focus,
`Esc` cancel, and `g n m e p s` for screen navigation. A KeyMap
override loader parses a `[tui.keybinds]` table and emits warnings
for unknown action ids or unparseable combos so the future config
loader (#43) can surface them on startup.

App now routes global keys through the map + chord tracker rather
than hard-coding matches. Chord state is reset on screen navigation
so a half-typed chord never leaks across screens. When a key is both
a single binding (global `g` → gallery) AND the prefix of a chord
in the active scope (gallery-local `gg` → top), the tracker prefers
holding for the chord since otherwise the chord would be unreachable.

Closes charles/loom#19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
charles changed target branch from tui/event-loop-16 to main 2026-04-11 20:41:20 +00:00
charles deleted branch tui/keybinds-19 2026-04-11 20:41:27 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/loom!50
No description provided.