325 lines
12 KiB
Markdown
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
|