# `evil-keys` — Vim/Evil-mode Keybinding Library Plan ## Status: Planned (not yet implemented) The current input handling in `src/input/` uses an ad-hoc approach with separate `VimState`, `LeaderState`, and inline `match` blocks. This document describes a proper architecture based on research into Neovim, Emacs evil-mode, and existing Rust implementations. --- ## Problem The current implementation treats each input mechanism as a separate system: - `VimState { count: String, pending: Option }` — ad-hoc flag for g/z/m/'/[/] - `LeaderState { path: Vec, active: bool }` — completely separate from keymaps - `LeaderNode` tree — hardcoded, disconnected from vim keybindings - Massive `match` blocks in `app.rs::handle_key()` for every single key In Emacs and Neovim, **everything is a keymap trie**. There is no distinction between `g` waiting for `g` (two-char sequence), `SPC` waiting for `b` (leader), `d` waiting for `w` (operator + motion), or `m` waiting for `a` (mark). They all work the same way: a key is bound to a nested keymap, the system waits for the next key, looks it up, repeats. --- ## Research Findings ### Emacs evil-mode **Source**: `emacs-evil/evil` on GitHub - **States are explicit**: Normal, Insert, Visual, Operator-pending, Motion, Replace, Emacs. Each state has its own keymap. States can inherit (Normal enables Motion keymaps). - **Two-char sequences are prefix keys**: `g` is bound to a sparse keymap containing `g`->goto-first-line, `t`->next-tab, etc. Emacs waits indefinitely for the next key. No timeout needed. - **SPC leader is just a prefix key**: Doom Emacs binds SPC to `doom/leader` which is a `define-prefix-command` pointing to `doom-leader-map`. The rest is standard keymap dispatch. - **Operator-pending is a real state** with its own keymap. When you press `d`: 1. `evil-delete` sets `evil-this-operator` to the current command 2. `evil-operator-range` calls `evil-change-state 'operator` 3. Creates temporary keymap binding `dd` -> `evil-line-or-visual-line` 4. Calls `evil-read-motion` which uses `read-key-sequence` for the next key 5. Motion returns range (BEG END TYPE) 6. Exits operator-pending, returns to normal 7. Operator body executes with the range - **Count prefix**: digits `1-9` bound to `digit-argument` in motion keymap. Evil's `evil-keypress-parser` accumulates digits during keymap lookup, passes count to command. - **which-key.el is timer-based**: idle timer (default 1.0s). On tick, checks if `(key-binding current-keys)` returns a keymap. If yes, enumerates bindings, shows popup. Doom Emacs does NOT change the 1.0s default. **Key source files**: - `evil-states.el` — state definitions, post-command hooks - `evil-macros.el` — `evil-define-operator`, `evil-define-motion`, `evil-operator-range` - `evil-common.el` — `evil-keypress-parser`, `evil-read-motion` - `evil-maps.el` — default keymaps, digit bindings, g/z prefix keys - `evil-core.el` — keymap hierarchy documentation, `evil-change-state` **Key insight from evil-core.el**: ``` Emacs keymap hierarchy (highest priority first): * Overriding keymaps/overlay keymaps * Emulation mode keymaps - Evil keymaps: * Intercept keymaps * Local state keymap * Minor-mode keymaps * Auxiliary keymaps * Overriding keymaps * Global state keymap * Keymaps for other states * Minor mode keymaps * Local keymap * Global keymap ``` ### Neovim (C source) **Source**: `neovim/neovim` on GitHub - **Keymap storage**: hash table of linked lists (`maphash[256]`), NOT a trie. Hash by `(mode, first_char)`, walk linked list comparing full sequences. - **Command dispatch**: `nv_cmds[]` — static sorted array of `{char, function_ptr, flags, arg}`. Binary search by command char. `g` has `NV_NCH_ALW` flag (always read next char) and dispatches to `nv_g_cmd()` — a giant switch on `cap->nchar`. - **Operator-pending**: NOT a separate state machine. It's a flag (`finish_op`) + struct (`oparg_T` with `op_type`, `start`, `end`, `motion_type`). The operator sets `oap->start` and `oap->op_type`, the next `normal_execute()` call runs the motion which moves the cursor, then `do_pending_operator()` uses `oap->start` + cursor as the range. - **Count accumulation**: loop in `normal_get_command_count()`: `count = count * 10 + (c - '0')`. Capped at 999999999. Count is multiplied: `3d2w` -> `opcount=3, count0=2` -> final `count0 = 6`. - **Timeout**: `timeoutlen` (default 1000ms) for ambiguous multi-key mappings. `handle_mapping()` in `getchar.c` tracks partial matches and waits. **Key source files**: - `src/nvim/normal.c` — `nv_cmds[]`, `normal_execute()`, `normal_get_command_count()`, `nv_g_cmd()` - `src/nvim/ops.c` — `do_pending_operator()`, operator execution - `src/nvim/normal_defs.h` — `oparg_T` struct, `cmdarg_T` struct - `src/nvim/getchar.c` — `handle_mapping()`, partial match + timeout logic - `src/nvim/mapping.c` — `mapblock_T`, `MAP_HASH` macro, mapping storage ### Neovim which-key.nvim (Lua) **Source**: `folke/which-key.nvim` on GitHub - **Reads from Neovim's native keymaps**: calls `vim.api.nvim_get_keymap()` and `vim.api.nvim_buf_get_keymap()`, then merges with user-defined mappings. - **Builds a trie**: `Node` with `_children: table`, `keymap`, `mapping`, `path`. - **Respects `timeoutlen`**: checks `vim.o.timeout` and `vim.o.timeoutlen`. - **Creates trigger mappings**: for each prefix key (leader, g, z), creates a temporary `vim.keymap.set()` that calls `state.start()` to intercept and show the popup. - **`` is string substitution**: at mapping definition time, `` is replaced with `vim.g.mapleader` (e.g., space). It's NOT a real prefix key in Neovim. **Key source files**: - `lua/which-key/tree.lua` — trie construction - `lua/which-key/node.lua` — node structure with children hash - `lua/which-key/state.lua` — `check()` with timeout logic, nowait handling - `lua/which-key/triggers.lua` — temporary keymap creation for prefix interception - `lua/which-key/buf.lua` — reads keymaps from Neovim API ### Existing Rust Implementations #### Helix editor (`helix-editor/helix`) — Best reference - **Trie structure**: `KeyTrieNode { map: IndexMap, name: String, is_sticky: bool }` - **Trie enum**: `KeyTrie = MappableCommand | Sequence(Vec) | Node(KeyTrieNode)` - **Dispatch state**: `Keymaps { map: HashMap, state: Vec, sticky: Option }` - **Search**: `search(&self, keys: &[KeyEvent]) -> Option<&KeyTrie>` — recursive descent - **Which-key**: `infobox(&self) -> Info` — iterates current node, groups keys by description - **Modes**: `Normal, Select, Insert` (simpler than vim) - **No operator-pending, no count prefix** (Helix doesn't do traditional vim operators) **Key source files**: - `helix-term/src/keymap.rs` — trie, dispatch, infobox - `helix-view/src/input.rs` — KeyEvent struct #### keybinds-rs (`rhysd/keybinds-rs`) - Flat `Vec` with linear search (not a trie) - Timeout support with `Instant`-based tracking - Simple API: `dispatch(key) -> Option` - Crossterm integration via feature flag - Key sequence parsing: `"Ctrl+x Ctrl+s"`, `"g g"` #### modalkit crate - Full vim keybinding engine with `ModalMachine` - Edge-based state transitions (graph, not trie) - Built-in vim bindings including operators, motions, text objects, counts - Most complete but also most complex #### hjkl crate - Modular: `hjkl-engine` (pure vim FSM), `hjkl-ratatui` (adapter) - Cleanest separation of concerns - Form-level vim editing --- ## Proposed Architecture ### Positioning Workspace member crate at `crates/evil-keys/`. Purpose-built for ui-agregator but generic enough to extract later. Consumer defines their own `Action` enum. ### Design Choice: Helix Trie + Evil-mode Concepts | Aspect | Decision | Rationale | |---|---|---| | Core structure | Helix-style trie (`IndexMap`) | Proven in Rust, O(k) lookup, natural which-key generation | | State model | Evil-mode concept (per-state keymaps, states inherit) | Cleaner than Neovim's single map with mode flags | | Operator-pending | Neovim's flag approach (simpler) | TUI app doesn't need full text operator semantics | | Count prefix | Integrated into dispatch loop (like Neovim) | Digits checked before trie lookup | | Timeout | Configurable, default 1000ms | Matching Neovim's timeoutlen | | Event loop | Library does NOT own it | Consumer calls `dispatch()` synchronously | | Which-key | Generate data from current trie node | Like Helix's `infobox()` | ### Module Plan ``` crates/evil-keys/ ├── Cargo.toml └── src/ ├── lib.rs — Public API re-exports ├── key.rs — KeyEvent wrapper, KeySeq parser ("g g", "C-d", " b l") ├── trie.rs — KeyTrie, KeyTrieNode, insert/search/merge/iterate ├── mode.rs — Mode trait or enum, ModeMap (HashMap>) ├── dispatch.rs — Dispatcher: main input handler ├── count.rs — Count prefix accumulator ├── which_key.rs — Generate popup entries from current trie node └── timeout.rs — Instant-based timeout tracking ``` ### Core Types ```rust // --- Trie --- pub enum KeyTrie { Leaf(A), Sequence(Vec), Node(KeyTrieNode), } pub struct KeyTrieNode { pub name: String, pub map: IndexMap>, pub sticky: bool, } // --- Dispatch --- pub struct Dispatcher { modes: HashMap>, current_mode: Mode, pending: Vec, count: CountState, timeout: Duration, last_key: Option, } pub enum DispatchResult<'a, A> { Matched { action: A, count: usize }, Pending { node: &'a KeyTrieNode }, CountAccumulated, NotFound, } // --- Which-key --- pub struct WhichKeyEntry { pub key: String, pub description: String, pub is_group: bool, } ``` ### Public API ```rust // Building keymaps let mut normal = KeyTrie::new("normal"); normal.bind("j", Action::MoveDown); normal.bind("g g", Action::GotoFirst); normal.bind("g t", Action::NextTab); normal.bind("", KeyTrie::group("+leader", |t| { t.bind("b", KeyTrie::group("+buffer", |t| { t.bind("l", Action::GotoLibrary); t.bind("n", Action::NextTab); })); t.bind("/", Action::Search); })); // Dispatching (in event loop) let mut dispatcher = Dispatcher::new(); dispatcher.set_mode_map(Mode::Normal, normal); dispatcher.set_timeout(Duration::from_millis(1000)); match dispatcher.dispatch(crossterm_key_event) { DispatchResult::Matched { action, count } => handle(action, count), DispatchResult::Pending { node } => show_which_key(node), DispatchResult::CountAccumulated => update_status_bar(), DispatchResult::NotFound => beep(), } // Which-key popup data if let Some(node) = dispatcher.pending_node() { let entries: Vec = node.which_key_entries(); // entries: [("l", "library", false), ("n", "next tab", false), ...] } // Timeout check (on tick) if dispatcher.check_timeout() { /* pending keys cleared */ } ``` ### Implementation Phases **Phase 1**: Core trie + basic dispatch - `key.rs`, `trie.rs`, `mode.rs`, `dispatch.rs` - Tests for trie insertion, search, prefix resolution **Phase 2**: Count prefix + timeout - `count.rs`, `timeout.rs` - Integrate into dispatcher - Tests for 5j, 12G, bare-0 exclusion **Phase 3**: Which-key generation - `which_key.rs` - Breadcrumb generation from pending path - Tests **Phase 4**: Wildcard keys + special patterns - `{any}` wildcard in trie (for m{a-z}, '{a-z}) - Range constraints - Sticky nodes **Phase 5**: Integration with ui-agregator - Replace `VimState`, `LeaderState`, `LeaderNode` - Replace inline match blocks with trie dispatch - Wire which-key popup to dispatcher ### What NOT to Build - Text editing operations (delete/yank/change on text ranges) - Registers - Macro recording/playback - Async runtime - Custom event loop (consumer owns the loop) - Config file serialization (keymaps built in code) ### Inspiration Sources (by relevance) 1. **Helix `keymap.rs`** — trie structure, search(), infobox(), sticky nodes 2. **Evil-mode** — everything is a prefix key, states have keymaps, layered resolution 3. **Neovim `normal.c`** — count accumulation loop, NV_NCH_ALW for g/z prefixes 4. **which-key.nvim** — Node structure, find() with create flag, timeout handling 5. **keybinds-rs** — simple public API, crossterm integration, timeout