# 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) } ```