From eb114fc614352b1035eb0b7dc59a5749589a67c4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 10 May 2026 10:59:53 +0200 Subject: [PATCH] docs: add evil-keys crate implementation plan Comprehensive design document covering architecture, API surface, dispatch algorithm, integration steps, testing strategy with 106 test cases, and design decisions (shift normalization, conflict detection, count overflow). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent) Co-authored-by: Sisyphus --- .sisyphus/plans/evil-keys-crate.md | 1127 ++++++++++++++++++++++++++++ 1 file changed, 1127 insertions(+) create mode 100644 .sisyphus/plans/evil-keys-crate.md 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) +} +```