diff --git a/.sisyphus/plans/evil-keys-crate.md b/.sisyphus/plans/evil-keys-crate.md new file mode 100644 index 0000000..a939dfd --- /dev/null +++ b/.sisyphus/plans/evil-keys-crate.md @@ -0,0 +1,1127 @@ +# evil-keys: Plug'n'Play Keybinding Crate + +## Goal + +Workspace member crate (`crates/evil-keys/`) providing Emacs/Evil-style modal keybinding dispatch for any Rust TUI app. Consumer defines `Action` enum, builds keymaps, calls `dispatch()` in their event loop. Zero coupling to ratatui or application domain. + +--- + +## Architecture + +``` +Consumer (ui-agregator) evil-keys crate +======================== ================ + +enum AppAction { ... } ──────────> Dispatcher + │ +main.rs event loop: ├── KeyTrie (sequence matching) + key_event ──> dispatcher.dispatch() ├── ModeMap (per-mode keymaps) + <── DispatchResult ├── CountState (5j, 12G) + ├── Timeout (1000ms default) + app.handle_action(action, count) └── WhichKey (introspection, separate call) +``` + +--- + +## Public API Surface + +```rust +use evil_keys::{Dispatcher, DispatchResult, KeyTrie}; + +// 1. Define your actions (consumer-side) +#[derive(Clone, Debug)] +enum Action { + MoveDown, + GotoFirst, + GotoTab(usize), + ShowHelp, +} + +// 2. Build keymaps — bind() for leaves, group() for subtrees +let mut normal = KeyTrie::new("normal"); +normal.bind("j", Action::MoveDown)?; +normal.bind("g g", Action::GotoFirst)?; +normal.bind("G", Action::GotoLast)?; +normal.group("SPC", "+leader", |g| { + g.group("b", "+buffer", |b| { + b.bind("l", Action::GotoTab(0))?; + b.bind("w", Action::GotoTab(1))?; + Ok(()) + })?; + g.bind("h", Action::ShowHelp)?; + Ok(()) +})?; + +// 3. Create dispatcher +let mut dispatcher = Dispatcher::new(); +dispatcher.add_mode("normal", normal)?; +dispatcher.add_mode("insert", insert)?; +dispatcher.set_active("normal")?; // panics/errors on unknown mode +dispatcher.set_timeout(Duration::from_millis(1000)); + +// 4. In event loop (plug'n'play) +match dispatcher.dispatch(key_event) { + DispatchResult::Matched { action, count } => app.handle(action, count), + DispatchResult::Pending => { /* which-key after timeout */ } + DispatchResult::Cancelled => { /* Esc mid-sequence */ } + DispatchResult::CountAccumulated => { /* show "5" in status */ } + DispatchResult::Ignored => { /* key release/repeat filtered */ } + DispatchResult::NotFound => { /* no binding */ } +} + +// 5. Which-key introspection (separate call — no lifetime issue) +if dispatcher.pending_elapsed() > Duration::from_secs(1) { + if let Some(entries) = dispatcher.which_key_entries() { + // entries: [WhichKeyEntry { key: "l", description: "library", is_group: false }] + render_which_key(entries); + } +} + +// 6. Pending keys display (for status bar: "SPC b _") +let pending = dispatcher.pending_display(); // -> "SPC b" + +// 7. Timeout check (on tick) +dispatcher.check_timeout(); + +// 8. Mode switching +dispatcher.set_active("insert")?; +dispatcher.set_active("normal")?; +``` + +--- + +## Crate Structure + +``` +crates/evil-keys/ + Cargo.toml + src/ + lib.rs Public re-exports + key.rs Key struct (code + modifiers), parser, Display impl + trie.rs KeyTrie, KeyTrieNode, conflict detection + mode.rs ModeMap (validated mode storage) + dispatch.rs Dispatcher, DispatchResult + count.rs CountState (digit accumulation) + timeout.rs Instant-based timeout tracking + which_key.rs WhichKeyEntry generation from trie node + error.rs BindError, ParseError, ModeError +``` + +### Dependencies + +```toml +[package] +name = "evil-keys" +version = "0.1.0" +edition = "2021" + +[dependencies] +crossterm = "0.28" # KeyEvent, KeyCode, KeyModifiers +indexmap = "2" # Ordered map for trie nodes (stable iteration for which-key) +``` + +No ratatui. No tokio. No serde. Pure input logic. + +--- + +## Core Types + +### key.rs + +```rust +/// Only code + modifiers matter for matching. +/// KeyEventKind (Press/Release/Repeat) and KeyEventState (caps lock etc.) +/// are filtered at the dispatch boundary, not stored here. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct Key { + pub code: KeyCode, + pub modifiers: KeyModifiers, +} + +impl From for Key { + fn from(e: crossterm::event::KeyEvent) -> Self { + Key { code: e.code, modifiers: e.modifiers } + } +} + +/// Display: Key -> human-readable notation. +/// Key { code: Char('d'), modifiers: CONTROL } -> "C-d" +/// Key { code: Char(' '), modifiers: NONE } -> "SPC" +/// Key { code: Char('j'), modifiers: NONE } -> "j" +/// Key { code: Esc, modifiers: NONE } -> "Esc" +impl fmt::Display for Key { ... } + +/// Parse human-readable key notation (reverse of Display). +/// "j" -> Ok(Key { code: Char('j'), modifiers: NONE }) +/// "C-d" -> Ok(Key { code: Char('d'), modifiers: CONTROL }) +/// "SPC" -> Ok(Key { code: Char(' '), modifiers: NONE }) +/// "Esc" -> Ok(Key { code: Esc, modifiers: NONE }) +/// "" -> Err(ParseError::EmptyInput) +/// "???" -> Err(ParseError::UnknownKey("???")) +pub fn parse_key(s: &str) -> Result; + +/// Parse sequence: "g g" -> Ok(vec![Key('g'), Key('g')]) +/// Splits on whitespace, parses each token. +pub fn parse_sequence(s: &str) -> Result, ParseError>; +``` + +#### Shift normalization + +Terminals often send `Shift+j` as `KeyCode::Char('J')` with `modifiers: NONE` +(the shift is baked into the uppercase char). This crate does NOT normalize — +`'J'` and `Shift+'j'` are treated as different keys. Consumers should bind +uppercase chars directly: `bind("J", ...)` not `bind("S-j", ...)`. + +### trie.rs + +```rust +pub enum KeyTrie { + Leaf(LeafBinding), + Node(KeyTrieNode), +} + +pub struct LeafBinding { + pub action: A, + pub description: Option, +} + +pub struct KeyTrieNode { + pub name: String, + pub map: IndexMap>, +} + +impl KeyTrie { + pub fn new(name: &str) -> Self; + + /// Bind a key sequence to an action. + /// Returns Err(BindError::ConflictWithPrefix) if sequence prefix is already a leaf. + /// Returns Err(BindError::ConflictWithLeaf) if sequence is already a prefix. + /// Returns Err(BindError::EmptySequence) if keys is empty. + pub fn bind(&mut self, keys: &str, action: A) -> Result<(), BindError>; + pub fn bind_desc(&mut self, keys: &str, action: A, desc: &str) -> Result<(), BindError>; + + /// Create a subtree for prefix key sequences (leader groups). + /// Separate from bind() — explicit intent. + pub fn group( + &mut self, + key: &str, + name: &str, + f: impl FnOnce(&mut KeyTrieNode) -> Result<(), BindError>, + ) -> Result<(), BindError>; + + pub fn search(&self, keys: &[Key]) -> SearchResult; +} + +pub enum SearchResult<'a, A> { + Found(&'a LeafBinding), + Prefix(&'a KeyTrieNode), // More keys needed + NotFound, +} +``` + +### error.rs + +```rust +#[derive(Debug)] +pub enum ParseError { + EmptyInput, + UnknownKey(String), +} + +#[derive(Debug)] +pub enum BindError { + EmptySequence, + ConflictWithLeaf { existing_keys: String }, // "g" is already a leaf, can't add "g g" + ConflictWithPrefix { existing_keys: String }, // "g g" exists, can't bind "g" as leaf + ParseError(ParseError), +} + +#[derive(Debug)] +pub enum ModeError { + UnknownMode(String), + DuplicateMode(String), +} +``` + +### dispatch.rs + +```rust +pub struct Dispatcher { + modes: HashMap>, + active_mode: String, + pending: Vec, + count: CountState, + timeout: Duration, + last_key_at: Option, +} + +/// No lifetime parameter — no references into the trie. +/// Which-key data accessed via separate which_key_entries() call. +pub enum DispatchResult { + Matched { action: A, count: usize }, + Pending, // Partial match, more keys needed + Cancelled, // Esc pressed mid-sequence, state cleared + CountAccumulated, // Digit consumed into count register + Ignored, // Non-Press event filtered (release, repeat) + NotFound, // No binding matches +} + +impl Dispatcher { + pub fn new() -> Self; + pub fn add_mode(&mut self, name: &str, keymap: KeyTrie) -> Result<(), ModeError>; + pub fn set_active(&mut self, mode: &str) -> Result<(), ModeError>; + pub fn active_mode(&self) -> &str; + + /// Main dispatch entry point. Filters non-Press events automatically. + pub fn dispatch(&mut self, event: crossterm::event::KeyEvent) -> DispatchResult; + + pub fn check_timeout(&mut self) -> bool; + pub fn pending_keys(&self) -> &[Key]; + pub fn pending_display(&self) -> String; // "SPC b" for status bar + pub fn pending_elapsed(&self) -> Duration; + pub fn clear_pending(&mut self); + + /// Which-key introspection. Returns None if no pending prefix. + /// Separate from dispatch() to avoid lifetime issues. + pub fn which_key_entries(&self) -> Option>; + + /// Current count display. Returns None if no count accumulated. + pub fn count_display(&self) -> Option<&str>; +} +``` + +### which_key.rs + +```rust +pub struct WhichKeyEntry { + pub key: String, // "l" + pub description: String, // "library" + pub is_group: bool, // true if has children +} + +impl KeyTrieNode { + /// Generates sorted entries from this node's children. + /// Groups come first, then leaves. Alphabetical within each. + pub fn which_key_entries(&self) -> Vec; +} +``` + +### count.rs + +```rust +pub struct CountState { + digits: String, // "12" from pressing 1 then 2 +} + +impl CountState { + pub fn push_digit(&mut self, d: char); + pub fn take(&mut self) -> usize; // Returns count, resets. Default 1. + pub fn is_active(&self) -> bool; + pub fn display(&self) -> &str; + pub fn reset(&mut self); +} +``` + +### timeout.rs + +```rust +pub struct TimeoutTracker { + timeout: Duration, + started_at: Option, +} + +impl TimeoutTracker { + pub fn start(&mut self); + pub fn check(&mut self) -> bool; // true if expired, clears started_at + pub fn elapsed(&self) -> Duration; + pub fn reset(&mut self); +} +``` + +--- + +## Dispatch Algorithm + +``` +dispatch(key_event): + 0. If key_event.kind != Press: + return Ignored + + 1. Convert crossterm KeyEvent -> Key (code + modifiers only) + + 2. If key is Escape AND (pending is non-empty OR count is active): + clear pending, reset count, reset timeout + return Cancelled + + 3. If digit 1-9 (or 0 when count active) AND no pending keys: + count.push_digit(digit) + return CountAccumulated + + 4. Push key to pending buffer + + 5. Get active mode's KeyTrie + + 6. Search trie with pending keys: + Found(leaf): + action = leaf.action.clone() + count = count.take() // consume accumulated count, default 1 + clear pending, reset timeout + return Matched { action, count } + Prefix(node): + start/reset timeout + return Pending + NotFound: + clear pending, reset count, reset timeout + return NotFound + +Notes: + - Count persists through prefix sequences. 5 SPC b l -> Matched { GotoTab(0), count: 5 }. + Consumer decides whether to use count for non-motion actions. + - Timeout expiry (checked via check_timeout on tick) clears pending + count silently. +``` + +--- + +## Integration into ui-agregator + +### Step 1: Convert to workspace + +```toml +# Root Cargo.toml +[workspace] +members = ["crates/evil-keys"] + +[package] +name = "ui-agregator" +# ... existing config ... + +[dependencies] +evil-keys = { path = "crates/evil-keys" } +``` + +### Step 2: Define AppAction + +```rust +// src/input/action.rs +#[derive(Clone, Debug)] +pub enum AppAction { + // Movement + MoveUp, MoveDown, FocusLeft, FocusRight, CycleFocus, + GotoFirst, GotoLast, + HalfPageDown, HalfPageUp, + + // Tabs + NextTab, PrevTab, GotoTab(usize), + + // System + Quit, Refresh, ShowHelp, ToggleNotifications, + Escape, +} +``` + +### Step 3: Build keymap + +```rust +// src/input/keymap.rs +pub fn build_normal_keymap() -> KeyTrie { + let mut t = KeyTrie::new("normal"); + + // Motion — bind() for leaf actions + t.bind_desc("j", AppAction::MoveDown, "down").unwrap(); + t.bind_desc("k", AppAction::MoveUp, "up").unwrap(); + t.bind_desc("h", AppAction::FocusLeft, "focus left").unwrap(); + t.bind_desc("l", AppAction::FocusRight, "focus right").unwrap(); + t.bind_desc("Tab", AppAction::CycleFocus, "cycle focus").unwrap(); + t.bind_desc("G", AppAction::GotoLast, "last item").unwrap(); + t.bind_desc("C-d", AppAction::HalfPageDown, "half page down").unwrap(); + t.bind_desc("C-u", AppAction::HalfPageUp, "half page up").unwrap(); + + // g-prefix — group() for subtrees + t.group("g", "+goto", |g| { + g.bind_desc("g", AppAction::GotoFirst, "first item")?; + g.bind_desc("t", AppAction::NextTab, "next tab")?; + g.bind_desc("T", AppAction::PrevTab, "prev tab")?; + Ok(()) + }).unwrap(); + + // Tabs + for i in 1..=6 { + t.bind(&format!("{}", i), AppAction::GotoTab(i - 1)).unwrap(); + } + + // Leader — group() for SPC prefix + t.group("SPC", "+leader", |g| { + g.group("b", "+buffer", |b| { + b.bind_desc("l", AppAction::GotoTab(0), "library")?; + b.bind_desc("w", AppAction::GotoTab(1), "wanted")?; + b.bind_desc("q", AppAction::GotoTab(2), "queue")?; + b.bind_desc("h", AppAction::GotoTab(3), "history")?; + b.bind_desc("n", AppAction::NextTab, "next")?; + b.bind_desc("p", AppAction::PrevTab, "prev")?; + Ok(()) + })?; + g.bind_desc("h", AppAction::ShowHelp, "help")?; + g.bind_desc("q", AppAction::Quit, "quit")?; + g.bind_desc("n", AppAction::ToggleNotifications, "notifications")?; + Ok(()) + }).unwrap(); + + // Direct keys + t.bind("Esc", AppAction::Escape).unwrap(); + t.bind("?", AppAction::ShowHelp).unwrap(); + + t +} +``` + +### Step 4: Wire into main.rs + +```rust +// Replace lines 81-94 +use evil_keys::{Dispatcher, DispatchResult}; + +let mut dispatcher = Dispatcher::new(); +dispatcher.add_mode("normal", build_normal_keymap()).unwrap(); +dispatcher.add_mode("insert", build_insert_keymap()).unwrap(); +dispatcher.set_active("normal").unwrap(); + +// In event loop: +Event::Key(key) if key.kind == KeyEventKind::Press => { + match dispatcher.dispatch(key) { + DispatchResult::Matched { action, count } => { + app.handle_action(action, count, &grpc_tx); + } + DispatchResult::Pending => {} + DispatchResult::Cancelled => {} + DispatchResult::CountAccumulated => {} + DispatchResult::Ignored => {} + DispatchResult::NotFound => {} + } +} + +// In tick handler: +if dispatcher.check_timeout() { + // Pending keys cleared — optionally hide which-key popup +} +``` + +### Step 5: handle_action in app + +```rust +// src/application/handlers.rs +impl App { + pub fn handle_action(&mut self, action: AppAction, count: usize, tx: &Sender) { + match action { + AppAction::MoveDown => { + for _ in 0..count { self.library.move_down(); } + } + AppAction::MoveUp => { + for _ in 0..count { self.library.move_up(); } + } + AppAction::FocusLeft => self.library.focus_left(), + AppAction::FocusRight => self.library.focus_right(), + AppAction::CycleFocus => self.library.cycle_focus(), + AppAction::GotoFirst => { /* select index 0 */ } + AppAction::GotoLast => { /* select last index */ } + AppAction::GotoTab(i) => { + if let Some(tab) = Tab::ALL.get(i) { self.tab = *tab; } + } + AppAction::NextTab => { /* cycle tab forward */ } + AppAction::PrevTab => { /* cycle tab backward */ } + AppAction::Quit => self.running = false, + AppAction::Refresh => { + self.library.clear_cache(); + let _ = tx.try_send(GrpcRequest::GetArtists); + } + AppAction::ShowHelp => self.modal = Some(ModalKind::Help), + AppAction::ToggleNotifications => { + self.notifications_open = !self.notifications_open; + } + AppAction::Escape => self.handle_escape(), + _ => {} + } + } +} +``` + +--- + +## Implementation Phases + +### Phase 1: Core crate + wire (MVP) +Files: `key.rs`, `trie.rs`, `dispatch.rs`, `error.rs`, `lib.rs` +Skip: count, timeout, which-key +Result: `j/k/h/l`, `gg/G`, `gt/gT`, `SPC b l`, `1-6` tabs, `?`, `Esc` all work +Test: + - `parse_key("C-d")` -> `Key { Char('d'), CONTROL }` + - `parse_key("")` -> `Err(EmptyInput)` + - `trie.bind("g g", X)` then `trie.bind("g", Y)` -> `Err(ConflictWithPrefix)` + - `trie.search([g, g])` -> `Found(GotoFirst)` + - `trie.search([g])` -> `Prefix` + - `dispatch(KeyRelease)` -> `Ignored` + - `dispatch(Esc)` mid-sequence -> `Cancelled` + - Full sequence: `dispatch(g) -> Pending`, `dispatch(g) -> Matched(GotoFirst)` +Effort: 4-6h + +### Phase 2: Count prefix + timeout +Files: `count.rs`, `timeout.rs`, update `dispatch.rs` +Result: `5j` moves 5 items, `12G` goes to line 12, pending keys clear after 1s +Test: + - `dispatch('5')` -> `CountAccumulated`, `dispatch('j')` -> `Matched { MoveDown, count: 5 }` + - `dispatch('0')` with no count active -> `NotFound` (not a count digit) + - `dispatch('1')` then `dispatch('0')` -> count is 10 + - Timeout fires after 1s -> pending cleared + - `count.take()` returns 1 when no digits accumulated +Effort: 1-2h + +### Phase 3: Which-key + status bar display +Files: `which_key.rs`, update `dispatch.rs` +Result: `which_key_entries()` returns entries after `Pending`, `pending_display()` shows `"SPC b"` +Test: + - After `dispatch(SPC)` -> `Pending`, `which_key_entries()` returns `[b:+buffer, h:help, q:quit, n:notifications]` + - `pending_display()` -> `"SPC"` + - After `dispatch(SPC)` then `dispatch(b)` -> `pending_display()` -> `"SPC b"` + - Entries sorted: groups first, then leaves, alphabetical within +Effort: 2-3h + +### Phase 4: Extend keymap + insert mode +Add remaining bindings from help modal: `C-d/C-u`, `H/M/L`, `{/}`, `[[/]]` +Add insert mode for search filter (`/` enters insert, `Esc` returns to normal) +Effort: 1-2h + +--- + +## Extensibility Model + +### Adding new bindings (consumer-side) + +```rust +// Just add a line +t.bind_desc("C-r", AppAction::Refresh, "refresh").unwrap(); +``` + +### Adding new action + +```rust +// 1. Add variant to AppAction +enum AppAction { ..., ToggleTheme } + +// 2. Bind it +t.bind_desc("SPC t t", AppAction::ToggleTheme, "toggle theme").unwrap(); + +// 3. Handle it +AppAction::ToggleTheme => self.toggle_theme(), +``` + +### Adding new mode + +```rust +let mut visual = KeyTrie::new("visual"); +visual.bind("Esc", AppAction::ExitVisual).unwrap(); +visual.bind("j", AppAction::ExtendSelectionDown).unwrap(); +dispatcher.add_mode("visual", visual).unwrap(); +``` + +### Swapping entire keymap + +```rust +// Emacs-style bindings instead of Vim +let emacs = build_emacs_keymap(); // C-n/C-p/C-f/C-b instead of hjkl +dispatcher.add_mode("normal", emacs).unwrap(); +``` + +--- + +## What NOT to Build + +- Text editing (delete/yank/change ranges) +- Registers +- Macro recording +- Operator-pending state +- Config file parsing (keymaps built in code) +- Async runtime +- ratatui dependency (consumer renders which-key) +- Shift normalization (consumer binds uppercase chars directly: `"J"` not `"S-j"`) +- Trait abstraction over crossterm (YAGNI — refactor if crate goes public) + +--- + +## Testing Strategy + +### Dependencies + +```toml +[dev-dependencies] +proptest = "1.4" +``` + +### Test Structure + +``` +crates/evil-keys/ + src/ + key.rs # #[cfg(test)] mod tests — parsing/display unit tests + trie.rs # #[cfg(test)] mod tests — bind/search/conflict unit tests + dispatch.rs # #[cfg(test)] mod tests — state machine unit tests + count.rs # #[cfg(test)] mod tests — digit accumulation unit tests + timeout.rs # #[cfg(test)] mod tests — expiry unit tests + tests/ + key_parsing.rs # Exhaustive parse/display edge cases + trie_operations.rs # Conflict detection, deep/wide structures + dispatch_sequences.rs # Full pipeline integration scenarios + proptest_invariants.rs # Property-based invariant tests +``` + +### Design Decisions to Lock Before Testing + +These ambiguities MUST be resolved — tests encode the answers: + +| Question | Decision | Rationale | +|----------|----------|-----------| +| `"S-g"` vs `"G"` | Distinct. `"G"` = `Char('G')` no modifiers. `"S-g"` = `Char('g')` + SHIFT. | Terminals send uppercase as `Char('G')` with NONE modifiers | +| `"S-G"` (shift + uppercase) | `Err(ParseError)` — redundant/ambiguous | Prevent confusion | +| `"S-C-d"` vs `"C-S-d"` | Both accepted, normalize to `C-S-` internally | Canonical modifier order: Ctrl, Shift, Alt | +| `"c-d"` lowercase modifier | `Err(ParseError)` — strict | Prevent typos | +| `"spc"` / `"ESC"` | `Err(ParseError)` — case-sensitive aliases | `SPC`, `Esc`, `Tab`, `Enter` only | +| `"C-"` dangling modifier | `Err(ParseError::DanglingModifier)` | Obviously invalid | +| `" "` (space char) | `Err(ParseError)` — must use `"SPC"` | Unambiguous | +| Rebind same key | Overwrite silently (last write wins) | Common pattern for extending keymaps | +| `0` as first key | Dispatched as binding, not count | Vim's `0` = start of line | +| `0` after digit | Accumulated as count (`10`, `20`, etc.) | Vim's count behavior | +| Count overflow | Saturate at `usize::MAX` | Never panic or wrap | +| `count.take()` default | Returns `1` when no digits | "Do it once" | +| Mode switch mid-sequence | Clears pending + count | Clean slate | + +--- + +### 1. Key Parsing — Edge Cases + +```rust +// === Empty / Whitespace === +parse_key("") -> Err(EmptyInput) +parse_key(" ") -> Err(EmptyInput) +parse_key(" j") -> Err(UnknownKey) +parse_key("j ") -> Err(UnknownKey) + +// === Modifier Edge Cases === +parse_key("C-") -> Err(DanglingModifier) +parse_key("S-") -> Err(DanglingModifier) +parse_key("C-S-") -> Err(DanglingModifier) +parse_key("C-C-d") -> Err(DuplicateModifier) +parse_key("c-d") -> Err(UnknownKey) // lowercase modifier rejected +parse_key("C-S-d") -> Ok(Key { Char('d'), CTRL|SHIFT }) +parse_key("S-C-d") -> Ok(Key { Char('d'), CTRL|SHIFT }) // normalized + +// === Single Char That Looks Like Modifier === +parse_key("C") -> Ok(Key { Char('C'), NONE }) // letter C, not Ctrl +parse_key("S") -> Ok(Key { Char('S'), NONE }) // letter S, not Shift + +// === Uppercase / Shift Ambiguity === +parse_key("G") -> Ok(Key { Char('G'), NONE }) // uppercase char, no SHIFT +parse_key("S-g") -> Ok(Key { Char('g'), SHIFT }) // distinct from "G" +parse_key("S-G") -> Err(RedundantShift) // ambiguous + +// === Special Key Aliases (case-sensitive) === +parse_key("SPC") -> Ok(Key { Char(' '), NONE }) +parse_key("spc") -> Err(UnknownKey) +parse_key("Esc") -> Ok(Key { Esc, NONE }) +parse_key("ESC") -> Err(UnknownKey) +parse_key("Tab") -> Ok(Key { Tab, NONE }) +parse_key("Enter") -> Ok(Key { Enter, NONE }) +parse_key("Backspace") -> Ok(Key { Backspace, NONE }) + +// === Tab vs t === +parse_key("Tab") ≠ parse_key("t") + +// === Function Keys === +parse_key("F1") -> Ok(Key { F(1), NONE }) +parse_key("F12") -> Ok(Key { F(12), NONE }) +parse_key("F0") -> Err(UnknownKey) +parse_key("F13") -> Err(UnknownKey) // unsupported +parse_key("F") -> Ok(Key { Char('F'), NONE }) // just letter F +parse_key("C-F1") -> Ok(Key { F(1), CTRL }) + +// === Symbols / Punctuation === +parse_key("-") -> Ok(Key { Char('-'), NONE }) +parse_key("C--") -> Ok(Key { Char('-'), CTRL }) // Ctrl+hyphen +parse_key("[") -> Ok(Key { Char('['), NONE }) +parse_key("]") -> Ok(Key { Char(']'), NONE }) +parse_key("/") -> Ok(Key { Char('/'), NONE }) +parse_key("?") -> Ok(Key { Char('?'), NONE }) + +// === Numbers === +parse_key("0")..="9" -> Ok(Key { Char('0'..'9'), NONE }) +parse_key("C-1") -> Ok(Key { Char('1'), CTRL }) + +// === Invalid Input === +parse_key("é") -> Err(UnknownKey) // non-ASCII +parse_key("🔥") -> Err(UnknownKey) +parse_key("\0") -> Err(UnknownKey) +parse_key("\x1b") -> Err(UnknownKey) // raw escape byte +parse_key("g g") -> Err(UnknownKey) // sequence, not single key + +// === Sequence Parsing === +parse_sequence("g g") -> Ok([Key('g'), Key('g')]) +parse_sequence("SPC b l") -> Ok([Key(' '), Key('b'), Key('l')]) +parse_sequence("C-x C-s") -> Ok([Key(Ctrl+'x'), Key(Ctrl+'s')]) +parse_sequence("") -> Err(EmptyInput) +parse_sequence(" ") -> Err(EmptyInput) +``` + +### 2. Key Display — Roundtrip + +```rust +// Every parsed key must roundtrip through Display +for input in ["j", "G", "C-d", "C-S-d", "SPC", "Esc", "Tab", "Enter", "F1", "-", "C--", "["] { + let key = parse_key(input).unwrap(); + let displayed = key.to_string(); + let reparsed = parse_key(&displayed).unwrap(); + assert_eq!(key, reparsed); // roundtrip identity +} + +// Display always uses canonical modifier order +Key { Char('d'), CTRL|SHIFT }.to_string() == "C-S-d" // not "S-C-d" + +// Space displays as SPC, not " " +Key { Char(' '), NONE }.to_string() == "SPC" +``` + +### 3. Trie Operations — Conflicts & Structure + +```rust +// === Conflict Detection === +bind("g", X) then bind("g g", Y) -> Err(ConflictWithLeaf("g")) +bind("g g", X) then bind("g", Y) -> Err(ConflictWithPrefix("g")) +bind("g g", X) then bind("g h", Y) -> Ok (siblings, no conflict) +bind("g", X) then bind("g", Y) -> Ok (overwrite leaf) +bind("SPC", group) then bind("SPC", Z) -> Err(ConflictWithPrefix("SPC")) +group("g") then bind("g", X) -> Err(ConflictWithPrefix("g")) + +// === Empty / Degenerate === +bind("", X) -> Err(EmptySequence) +search([]) on any trie -> NotFound +search on empty trie -> NotFound + +// === Deep Nesting === +bind("a b c d e f g h", X) -> Ok +search([a,b,c,d,e,f,g,h]) -> Found(X) +search([a,b,c]) -> Prefix + +// === Wide Single Level === +bind 26 single chars a-z -> all Ok +search any single char -> Found + +// === Group Edge Cases === +group("SPC", "+leader", |_| {}) -> Ok (empty group = valid prefix with no leaves) +search([SPC]) -> Prefix (node with no children) +which_key_entries on empty group -> [] (empty vec) + +// === Search Beyond Leaf === +bind("g", X) +search([g, h]) -> NotFound (g is leaf, h doesn't exist beyond it) + +// === Partial Prefix === +bind("g g", X) +search([g]) -> Prefix +search([g, g]) -> Found(X) +search([g, h]) -> NotFound +search([h]) -> NotFound +``` + +### 4. Dispatcher — State Machine + +```rust +// === Event Filtering === +dispatch(KeyRelease('j')) -> Ignored +dispatch(KeyRepeat('j')) -> Ignored +dispatch(KeyPress('j')) -> Matched/Pending/NotFound (real dispatch) + +// === Escape Behavior === +// nothing pending, Esc bound to action: +dispatch(Esc) -> Matched(EscAction, 1) +// pending keys only: +dispatch('g') -> Pending +dispatch(Esc) -> Cancelled // pending cleared +// count only: +dispatch('5') -> CountAccumulated +dispatch(Esc) -> Cancelled // count cleared +// count + pending: +dispatch('3') -> CountAccumulated +dispatch('g') -> Pending +dispatch(Esc) -> Cancelled // both cleared +// after cancel, fresh dispatch works: +dispatch('j') -> Matched(MoveDown, 1) + +// === Count: 0 Key Dual Role === +dispatch('0') -> Matched(GoToStart, 1) // 0 is a binding, not count +dispatch('1') -> CountAccumulated +dispatch('0') -> CountAccumulated // now count = 10 +dispatch('j') -> Matched(MoveDown, 10) + +// === Count: Accumulation === +dispatch('1') -> CountAccumulated +dispatch('2') -> CountAccumulated +dispatch('3') -> CountAccumulated +dispatch('j') -> Matched(MoveDown, 123) + +// === Count: Overflow Saturation === +dispatch('9' x 20 times) // 20 nines +dispatch('j') -> Matched(MoveDown, usize::MAX) // saturated, not panicked + +// === Count: take() Semantics === +CountState::new().take() == 1 // default +count.push('5'); count.take() == 5 +count.take() == 1 // reset after take + +// === Count: Persists Through Prefix === +dispatch('5') -> CountAccumulated +dispatch('g') -> Pending +dispatch('g') -> Matched(GotoFirst, 5) // count carried through + +// === Count: Digits NOT Accumulated During Pending === +bind("g 3", SomeAction) +dispatch('g') -> Pending +dispatch('3') -> Matched(SomeAction, 1) // '3' is sequence key, not count + +// === Mode Switching === +dispatch('g') -> Pending // normal mode +set_active("insert") // switch mode clears state +pending_keys() == [] // cleared +dispatch('j') in insert mode // uses insert keymap + +// === Invalid Mode === +set_active("nonexistent") -> Err(UnknownMode) + +// === No Modes Registered === +Dispatcher::new().dispatch('j') -> NotFound // no panic + +// === Wrong Key Mid-Sequence === +bind("g g", X) +dispatch('g') -> Pending +dispatch('h') -> NotFound // "g h" doesn't exist, pending cleared +dispatch('g') -> Pending // fresh start + +// === Full Sequence === +bind("SPC b l", GotoLibrary) +dispatch(SPC) -> Pending +dispatch('b') -> Pending +dispatch('l') -> Matched(GotoLibrary, 1) + +// === Rapid Same Key === +bind("j", MoveDown) +dispatch('j') x 1000 // all Matched, no state leak +``` + +### 5. Timeout + +```rust +// === Basic Expiry === +timeout = 100ms +dispatch('g') -> Pending +sleep(150ms) +check_timeout() -> true // expired, pending cleared +dispatch('g') -> Pending // fresh start, not "g g" + +// === Reset On New Key === +timeout = 100ms +dispatch('g') -> Pending +sleep(50ms) +dispatch('g') -> Matched // completed before timeout + +// === No Timeout Active === +check_timeout() -> false // nothing pending + +// === Zero Timeout === +timeout = 0ms +dispatch('g') -> Pending +check_timeout() -> true // immediately expired + +// === Timeout Clears Count Too === +dispatch('5') -> CountAccumulated +dispatch('g') -> Pending +sleep(timeout) +check_timeout() -> true +dispatch('j') -> Matched(MoveDown, 1) // count was cleared, not 5 +``` + +### 6. Which-Key Introspection + +```rust +// === After Prefix === +bind SPC -> group(+leader: b->+buffer, h->help, q->quit, n->notifications) +dispatch(SPC) -> Pending +which_key_entries() -> Some([ + WhichKeyEntry { key: "b", description: "+buffer", is_group: true }, + WhichKeyEntry { key: "h", description: "help", is_group: false }, + WhichKeyEntry { key: "n", description: "notifications", is_group: false }, + WhichKeyEntry { key: "q", description: "quit", is_group: false }, +]) + +// === Deeper Nesting === +dispatch(SPC) -> Pending +dispatch('b') -> Pending +which_key_entries() -> Some([ + WhichKeyEntry { key: "h", description: "history", is_group: false }, + WhichKeyEntry { key: "l", description: "library", is_group: false }, + WhichKeyEntry { key: "n", description: "next", is_group: false }, + ... +]) + +// === No Pending === +which_key_entries() -> None + +// === Pending On Leaf (no children) === +bind("j", MoveDown) +dispatch('j') -> Matched +which_key_entries() -> None // already resolved + +// === Empty Group === +group("SPC", "+leader", |_| {}) +dispatch(SPC) -> Pending +which_key_entries() -> Some([]) // empty vec, valid but nothing to show + +// === Sorted Output === +// Groups first, then leaves. Alphabetical within each category. +entries[0].is_group == true // groups come first +// Within groups: alphabetical by key +// Within leaves: alphabetical by key + +// === pending_display() === +dispatch(SPC) -> "SPC" +dispatch(SPC, b) -> "SPC b" +nothing pending -> "" +``` + +### 7. Property-Based Tests (proptest) + +```rust +// === Key Invariants === + +// Any successfully parsed key roundtrips through Display +proptest!(key in valid_key_strategy() => { + let displayed = key.to_string(); + let reparsed = parse_key(&displayed).unwrap(); + assert_eq!(key, reparsed); +}); + +// parse_key never panics on arbitrary strings +proptest!(s in ".*" => { + let _ = parse_key(&s); // may error, must not panic +}); + +// Display never panics on any Key +proptest!((code, mods) in (any::(), any::()) => { + let key = Key { code, modifiers: mods }; + let _ = key.to_string(); // must not panic +}); + +// === Trie Invariants === + +// Any successfully bound sequence is always found +proptest!(bindings in vec(valid_sequence_and_action(), 1..20) => { + let mut trie = KeyTrie::new("test"); + let mut ok_seqs = vec![]; + for (seq, action) in &bindings { + if trie.bind(seq, action.clone()).is_ok() { + ok_seqs.push(seq); + } + } + for seq in ok_seqs { + assert!(matches!(trie.search(&parse_sequence(seq).unwrap()), SearchResult::Found(_))); + } +}); + +// Search never panics on arbitrary key vectors +proptest!(keys in vec(any::(), 0..50) => { + let trie: KeyTrie<()> = KeyTrie::new("test"); + let _ = trie.search(&keys); +}); + +// === Dispatcher Invariants === + +// Non-Press events always return Ignored +proptest!(kind in [Release, Repeat], code in any::() => { + let mut d = test_dispatcher(); + let event = KeyEvent { kind, code, modifiers: NONE, state: NONE }; + assert_eq!(d.dispatch(event), DispatchResult::Ignored); +}); + +// count.take() always returns >= 1 +proptest!(digits in vec('0'..='9', 0..10) => { + let mut count = CountState::new(); + let mut first = true; + for d in digits { + if first && d == '0' { continue; } + count.push_digit(d); + first = false; + } + assert!(count.take() >= 1); +}); + +// Escape always leaves dispatcher in clean state +proptest!(keys_before in vec(valid_key_press(), 0..10) => { + let mut d = test_dispatcher(); + for k in keys_before { let _ = d.dispatch(k); } + let _ = d.dispatch(esc_press()); + assert!(d.pending_keys().is_empty()); +}); +``` + +### 8. Integration Scenarios + +```rust +// === Full Vim Workflow: 5gg === +dispatch('5') -> CountAccumulated +dispatch('g') -> Pending +dispatch('g') -> Matched { action: GotoFirst, count: 5 } + +// === Doom Leader: SPC f f === +dispatch(SPC) -> Pending +dispatch('f') -> Pending +dispatch('f') -> Matched { action: FindFile, count: 1 } + +// === Escape Progression === +dispatch('5') -> CountAccumulated +dispatch(Esc) -> Cancelled +dispatch('g') -> Pending +dispatch(Esc) -> Cancelled +dispatch('3') -> CountAccumulated +dispatch('g') -> Pending +dispatch(Esc) -> Cancelled +dispatch('j') -> Matched { action: MoveDown, count: 1 } // fully clean + +// === Timeout Resets Sequence === +timeout = 100ms +dispatch('g') -> Pending +sleep(150ms) +check_timeout() -> true +dispatch('g') -> Pending // fresh "g", not "g g" +dispatch('g') -> Matched(GotoFirst) // now completes + +// === Mode Switch Mid-Sequence === +dispatch('3') -> CountAccumulated +dispatch('g') -> Pending +set_active("insert") // clears everything +set_active("normal") +dispatch('j') -> Matched(MoveDown, 1) // clean state + +// === Which-Key After Leader === +dispatch(SPC) -> Pending +which_key_entries() -> Some([b:+buffer, h:help, q:quit, n:notifications]) +dispatch('b') -> Pending +which_key_entries() -> Some([l:library, w:wanted, q:queue, h:history, n:next, p:prev]) +dispatch('l') -> Matched(GotoTab(0)) +which_key_entries() -> None + +// === Stress: Rapid Fire === +for _ in 0..1000 { dispatch('j') -> Matched(MoveDown, 1) } +// No memory leak, no state corruption, pending always empty after match + +// === Stress: Alternating Sequences === +for _ in 0..100 { + dispatch('g') -> Pending + dispatch('g') -> Matched(GotoFirst, 1) + dispatch('g') -> Pending + dispatch('t') -> Matched(NextTab, 1) +} +```