12 KiB
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 keymapsLeaderNodetree — hardcoded, disconnected from vim keybindings- Massive
matchblocks inapp.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:
gis bound to a sparse keymap containingg->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/leaderwhich is adefine-prefix-commandpointing todoom-leader-map. The rest is standard keymap dispatch. - Operator-pending is a real state with its own keymap. When you press
d:evil-deletesetsevil-this-operatorto the current commandevil-operator-rangecallsevil-change-state 'operator- Creates temporary keymap binding
dd->evil-line-or-visual-line - Calls
evil-read-motionwhich usesread-key-sequencefor the next key - Motion returns range (BEG END TYPE)
- Exits operator-pending, returns to normal
- Operator body executes with the range
- Count prefix: digits
1-9bound todigit-argumentin motion keymap. Evil'sevil-keypress-parseraccumulates 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 hooksevil-macros.el—evil-define-operator,evil-define-motion,evil-operator-rangeevil-common.el—evil-keypress-parser,evil-read-motionevil-maps.el— default keymaps, digit bindings, g/z prefix keysevil-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.ghasNV_NCH_ALWflag (always read next char) and dispatches tonv_g_cmd()— a giant switch oncap->nchar. - Operator-pending: NOT a separate state machine. It's a flag (
finish_op) + struct (oparg_Twithop_type,start,end,motion_type). The operator setsoap->startandoap->op_type, the nextnormal_execute()call runs the motion which moves the cursor, thendo_pending_operator()usesoap->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-> finalcount0 = 6. - Timeout:
timeoutlen(default 1000ms) for ambiguous multi-key mappings.handle_mapping()ingetchar.ctracks 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 executionsrc/nvim/normal_defs.h—oparg_Tstruct,cmdarg_Tstructsrc/nvim/getchar.c—handle_mapping(), partial match + timeout logicsrc/nvim/mapping.c—mapblock_T,MAP_HASHmacro, 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()andvim.api.nvim_buf_get_keymap(), then merges with user-defined mappings. - Builds a trie:
Nodewith_children: table<string, Node>,keymap,mapping,path. - Respects
timeoutlen: checksvim.o.timeoutandvim.o.timeoutlen. - Creates trigger mappings: for each prefix key (leader, g, z), creates a temporary
vim.keymap.set()that callsstate.start()to intercept and show the popup. <leader>is string substitution: at mapping definition time,<leader>is replaced withvim.g.mapleader(e.g., space). It's NOT a real prefix key in Neovim.
Key source files:
lua/which-key/tree.lua— trie constructionlua/which-key/node.lua— node structure with children hashlua/which-key/state.lua—check()with timeout logic, nowait handlinglua/which-key/triggers.lua— temporary keymap creation for prefix interceptionlua/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, infoboxhelix-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
// --- 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
// 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)
- Helix
keymap.rs— trie structure, search(), infobox(), sticky nodes - Evil-mode — everything is a prefix key, states have keymaps, layered resolution
- Neovim
normal.c— count accumulation loop, NV_NCH_ALW for g/z prefixes - which-key.nvim — Node structure, find() with create flag, timeout handling
- keybinds-rs — simple public API, crossterm integration, timeout