Files
ui-agregator/docs/evil-keys-plan.md
T

325 lines
12 KiB
Markdown

# `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<char> }` — ad-hoc flag for g/z/m/'/[/]
- `LeaderState { path: Vec<char>, 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<string, Node>`, `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.
- **`<leader>` is string substitution**: at mapping definition time, `<leader>` 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<KeyEvent, KeyTrie>, name: String, is_sticky: bool }`
- **Trie enum**: `KeyTrie = MappableCommand | Sequence(Vec<MappableCommand>) | Node(KeyTrieNode)`
- **Dispatch state**: `Keymaps { map: HashMap<Mode, KeyTrie>, state: Vec<KeyEvent>, sticky: Option<KeyTrieNode> }`
- **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<Keybind>` with linear search (not a trie)
- Timeout support with `Instant`-based tracking
- Simple API: `dispatch(key) -> Option<Action>`
- 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<KeyEvent, KeyTrie>`) | 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", "<SPC> b l")
├── trie.rs — KeyTrie<A>, KeyTrieNode<A>, insert/search/merge/iterate
├── mode.rs — Mode trait or enum, ModeMap (HashMap<Mode, KeyTrie<A>>)
├── dispatch.rs — Dispatcher<A>: 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<A> {
Leaf(A),
Sequence(Vec<A>),
Node(KeyTrieNode<A>),
}
pub struct KeyTrieNode<A> {
pub name: String,
pub map: IndexMap<KeyEvent, KeyTrie<A>>,
pub sticky: bool,
}
// --- Dispatch ---
pub struct Dispatcher<A: Clone> {
modes: HashMap<Mode, KeyTrie<A>>,
current_mode: Mode,
pending: Vec<KeyEvent>,
count: CountState,
timeout: Duration,
last_key: Option<Instant>,
}
pub enum DispatchResult<'a, A> {
Matched { action: A, count: usize },
Pending { node: &'a KeyTrieNode<A> },
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("<SPC>", 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<WhichKeyEntry> = 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