diff --git a/docs/evil-keys-plan.md b/docs/evil-keys-plan.md new file mode 100644 index 0000000..dac87b5 --- /dev/null +++ b/docs/evil-keys-plan.md @@ -0,0 +1,324 @@ +# `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