Files
ui-agregator/.sisyphus/plans/evil-keys-crate.md
T
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

35 KiB

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

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

[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

/// 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

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

#[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

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

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

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

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

# 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

// 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

// 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

// 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

// 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)

// Just add a line
t.bind_desc("C-r", AppAction::Refresh, "refresh").unwrap();

Adding new action

// 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

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

// 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

[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

// === 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

// 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

// === 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

// === 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

// === 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

// === 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)

// === 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

// === 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)
}