Files
Alexander eb114fc614 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 <clio-agent@sisyphuslabs.ai>
2026-05-10 10:59:53 +02:00

1128 lines
35 KiB
Markdown

# 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<A: Clone>
main.rs event loop: ├── KeyTrie<A> (sequence matching)
key_event ──> dispatcher.dispatch() ├── ModeMap (per-mode keymaps)
<── DispatchResult<A> ├── 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<A>, KeyTrieNode<A>, conflict detection
mode.rs ModeMap (validated mode storage)
dispatch.rs Dispatcher<A>, 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<crossterm::event::KeyEvent> 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<Key, ParseError>;
/// Parse sequence: "g g" -> Ok(vec![Key('g'), Key('g')])
/// Splits on whitespace, parses each token.
pub fn parse_sequence(s: &str) -> Result<Vec<Key>, 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<A> {
Leaf(LeafBinding<A>),
Node(KeyTrieNode<A>),
}
pub struct LeafBinding<A> {
pub action: A,
pub description: Option<String>,
}
pub struct KeyTrieNode<A> {
pub name: String,
pub map: IndexMap<Key, KeyTrie<A>>,
}
impl<A: Clone> KeyTrie<A> {
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<A>) -> Result<(), BindError>,
) -> Result<(), BindError>;
pub fn search(&self, keys: &[Key]) -> SearchResult<A>;
}
pub enum SearchResult<'a, A> {
Found(&'a LeafBinding<A>),
Prefix(&'a KeyTrieNode<A>), // 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<A: Clone> {
modes: HashMap<String, KeyTrie<A>>,
active_mode: String,
pending: Vec<Key>,
count: CountState,
timeout: Duration,
last_key_at: Option<Instant>,
}
/// No lifetime parameter — no references into the trie.
/// Which-key data accessed via separate which_key_entries() call.
pub enum DispatchResult<A> {
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<A: Clone> Dispatcher<A> {
pub fn new() -> Self;
pub fn add_mode(&mut self, name: &str, keymap: KeyTrie<A>) -> 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<A>;
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<Vec<WhichKeyEntry>>;
/// 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<A> KeyTrieNode<A> {
/// Generates sorted entries from this node's children.
/// Groups come first, then leaves. Alphabetical within each.
pub fn which_key_entries(&self) -> Vec<WhichKeyEntry>;
}
```
### 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<Instant>,
}
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<AppAction> {
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<GrpcRequest>) {
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::<KeyCode>(), any::<KeyModifiers>()) => {
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::<Key>(), 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::<KeyCode>() => {
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)
}
```