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

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 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.elevil-define-operator, evil-define-motion, evil-operator-range
  • evil-common.elevil-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.cnv_cmds[], normal_execute(), normal_get_command_count(), nv_g_cmd()
  • src/nvim/ops.cdo_pending_operator(), operator execution
  • src/nvim/normal_defs.hoparg_T struct, cmdarg_T struct
  • src/nvim/getchar.chandle_mapping(), partial match + timeout logic
  • src/nvim/mapping.cmapblock_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.luacheck() 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

// --- 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)

  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