eb114fc614
Comprehensive design document covering architecture, API surface, dispatch algorithm, integration steps, testing strategy with 106 test cases, and design decisions (shift normalization, conflict detection, count overflow). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1128 lines
35 KiB
Markdown
1128 lines
35 KiB
Markdown
# evil-keys: Plug'n'Play Keybinding Crate
|
|
|
|
## Goal
|
|
|
|
Workspace member crate (`crates/evil-keys/`) providing Emacs/Evil-style modal keybinding dispatch for any Rust TUI app. Consumer defines `Action` enum, builds keymaps, calls `dispatch()` in their event loop. Zero coupling to ratatui or application domain.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Consumer (ui-agregator) evil-keys crate
|
|
======================== ================
|
|
|
|
enum AppAction { ... } ──────────> Dispatcher<A: Clone>
|
|
│
|
|
main.rs event loop: ├── KeyTrie<A> (sequence matching)
|
|
key_event ──> dispatcher.dispatch() ├── ModeMap (per-mode keymaps)
|
|
<── DispatchResult<A> ├── CountState (5j, 12G)
|
|
├── Timeout (1000ms default)
|
|
app.handle_action(action, count) └── WhichKey (introspection, separate call)
|
|
```
|
|
|
|
---
|
|
|
|
## Public API Surface
|
|
|
|
```rust
|
|
use evil_keys::{Dispatcher, DispatchResult, KeyTrie};
|
|
|
|
// 1. Define your actions (consumer-side)
|
|
#[derive(Clone, Debug)]
|
|
enum Action {
|
|
MoveDown,
|
|
GotoFirst,
|
|
GotoTab(usize),
|
|
ShowHelp,
|
|
}
|
|
|
|
// 2. Build keymaps — bind() for leaves, group() for subtrees
|
|
let mut normal = KeyTrie::new("normal");
|
|
normal.bind("j", Action::MoveDown)?;
|
|
normal.bind("g g", Action::GotoFirst)?;
|
|
normal.bind("G", Action::GotoLast)?;
|
|
normal.group("SPC", "+leader", |g| {
|
|
g.group("b", "+buffer", |b| {
|
|
b.bind("l", Action::GotoTab(0))?;
|
|
b.bind("w", Action::GotoTab(1))?;
|
|
Ok(())
|
|
})?;
|
|
g.bind("h", Action::ShowHelp)?;
|
|
Ok(())
|
|
})?;
|
|
|
|
// 3. Create dispatcher
|
|
let mut dispatcher = Dispatcher::new();
|
|
dispatcher.add_mode("normal", normal)?;
|
|
dispatcher.add_mode("insert", insert)?;
|
|
dispatcher.set_active("normal")?; // panics/errors on unknown mode
|
|
dispatcher.set_timeout(Duration::from_millis(1000));
|
|
|
|
// 4. In event loop (plug'n'play)
|
|
match dispatcher.dispatch(key_event) {
|
|
DispatchResult::Matched { action, count } => app.handle(action, count),
|
|
DispatchResult::Pending => { /* which-key after timeout */ }
|
|
DispatchResult::Cancelled => { /* Esc mid-sequence */ }
|
|
DispatchResult::CountAccumulated => { /* show "5" in status */ }
|
|
DispatchResult::Ignored => { /* key release/repeat filtered */ }
|
|
DispatchResult::NotFound => { /* no binding */ }
|
|
}
|
|
|
|
// 5. Which-key introspection (separate call — no lifetime issue)
|
|
if dispatcher.pending_elapsed() > Duration::from_secs(1) {
|
|
if let Some(entries) = dispatcher.which_key_entries() {
|
|
// entries: [WhichKeyEntry { key: "l", description: "library", is_group: false }]
|
|
render_which_key(entries);
|
|
}
|
|
}
|
|
|
|
// 6. Pending keys display (for status bar: "SPC b _")
|
|
let pending = dispatcher.pending_display(); // -> "SPC b"
|
|
|
|
// 7. Timeout check (on tick)
|
|
dispatcher.check_timeout();
|
|
|
|
// 8. Mode switching
|
|
dispatcher.set_active("insert")?;
|
|
dispatcher.set_active("normal")?;
|
|
```
|
|
|
|
---
|
|
|
|
## Crate Structure
|
|
|
|
```
|
|
crates/evil-keys/
|
|
Cargo.toml
|
|
src/
|
|
lib.rs Public re-exports
|
|
key.rs Key struct (code + modifiers), parser, Display impl
|
|
trie.rs KeyTrie<A>, KeyTrieNode<A>, conflict detection
|
|
mode.rs ModeMap (validated mode storage)
|
|
dispatch.rs Dispatcher<A>, DispatchResult
|
|
count.rs CountState (digit accumulation)
|
|
timeout.rs Instant-based timeout tracking
|
|
which_key.rs WhichKeyEntry generation from trie node
|
|
error.rs BindError, ParseError, ModeError
|
|
```
|
|
|
|
### Dependencies
|
|
|
|
```toml
|
|
[package]
|
|
name = "evil-keys"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
|
|
[dependencies]
|
|
crossterm = "0.28" # KeyEvent, KeyCode, KeyModifiers
|
|
indexmap = "2" # Ordered map for trie nodes (stable iteration for which-key)
|
|
```
|
|
|
|
No ratatui. No tokio. No serde. Pure input logic.
|
|
|
|
---
|
|
|
|
## Core Types
|
|
|
|
### key.rs
|
|
|
|
```rust
|
|
/// Only code + modifiers matter for matching.
|
|
/// KeyEventKind (Press/Release/Repeat) and KeyEventState (caps lock etc.)
|
|
/// are filtered at the dispatch boundary, not stored here.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct Key {
|
|
pub code: KeyCode,
|
|
pub modifiers: KeyModifiers,
|
|
}
|
|
|
|
impl From<crossterm::event::KeyEvent> for Key {
|
|
fn from(e: crossterm::event::KeyEvent) -> Self {
|
|
Key { code: e.code, modifiers: e.modifiers }
|
|
}
|
|
}
|
|
|
|
/// Display: Key -> human-readable notation.
|
|
/// Key { code: Char('d'), modifiers: CONTROL } -> "C-d"
|
|
/// Key { code: Char(' '), modifiers: NONE } -> "SPC"
|
|
/// Key { code: Char('j'), modifiers: NONE } -> "j"
|
|
/// Key { code: Esc, modifiers: NONE } -> "Esc"
|
|
impl fmt::Display for Key { ... }
|
|
|
|
/// Parse human-readable key notation (reverse of Display).
|
|
/// "j" -> Ok(Key { code: Char('j'), modifiers: NONE })
|
|
/// "C-d" -> Ok(Key { code: Char('d'), modifiers: CONTROL })
|
|
/// "SPC" -> Ok(Key { code: Char(' '), modifiers: NONE })
|
|
/// "Esc" -> Ok(Key { code: Esc, modifiers: NONE })
|
|
/// "" -> Err(ParseError::EmptyInput)
|
|
/// "???" -> Err(ParseError::UnknownKey("???"))
|
|
pub fn parse_key(s: &str) -> Result<Key, ParseError>;
|
|
|
|
/// Parse sequence: "g g" -> Ok(vec![Key('g'), Key('g')])
|
|
/// Splits on whitespace, parses each token.
|
|
pub fn parse_sequence(s: &str) -> Result<Vec<Key>, ParseError>;
|
|
```
|
|
|
|
#### Shift normalization
|
|
|
|
Terminals often send `Shift+j` as `KeyCode::Char('J')` with `modifiers: NONE`
|
|
(the shift is baked into the uppercase char). This crate does NOT normalize —
|
|
`'J'` and `Shift+'j'` are treated as different keys. Consumers should bind
|
|
uppercase chars directly: `bind("J", ...)` not `bind("S-j", ...)`.
|
|
|
|
### trie.rs
|
|
|
|
```rust
|
|
pub enum KeyTrie<A> {
|
|
Leaf(LeafBinding<A>),
|
|
Node(KeyTrieNode<A>),
|
|
}
|
|
|
|
pub struct LeafBinding<A> {
|
|
pub action: A,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
pub struct KeyTrieNode<A> {
|
|
pub name: String,
|
|
pub map: IndexMap<Key, KeyTrie<A>>,
|
|
}
|
|
|
|
impl<A: Clone> KeyTrie<A> {
|
|
pub fn new(name: &str) -> Self;
|
|
|
|
/// Bind a key sequence to an action.
|
|
/// Returns Err(BindError::ConflictWithPrefix) if sequence prefix is already a leaf.
|
|
/// Returns Err(BindError::ConflictWithLeaf) if sequence is already a prefix.
|
|
/// Returns Err(BindError::EmptySequence) if keys is empty.
|
|
pub fn bind(&mut self, keys: &str, action: A) -> Result<(), BindError>;
|
|
pub fn bind_desc(&mut self, keys: &str, action: A, desc: &str) -> Result<(), BindError>;
|
|
|
|
/// Create a subtree for prefix key sequences (leader groups).
|
|
/// Separate from bind() — explicit intent.
|
|
pub fn group(
|
|
&mut self,
|
|
key: &str,
|
|
name: &str,
|
|
f: impl FnOnce(&mut KeyTrieNode<A>) -> Result<(), BindError>,
|
|
) -> Result<(), BindError>;
|
|
|
|
pub fn search(&self, keys: &[Key]) -> SearchResult<A>;
|
|
}
|
|
|
|
pub enum SearchResult<'a, A> {
|
|
Found(&'a LeafBinding<A>),
|
|
Prefix(&'a KeyTrieNode<A>), // More keys needed
|
|
NotFound,
|
|
}
|
|
```
|
|
|
|
### error.rs
|
|
|
|
```rust
|
|
#[derive(Debug)]
|
|
pub enum ParseError {
|
|
EmptyInput,
|
|
UnknownKey(String),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum BindError {
|
|
EmptySequence,
|
|
ConflictWithLeaf { existing_keys: String }, // "g" is already a leaf, can't add "g g"
|
|
ConflictWithPrefix { existing_keys: String }, // "g g" exists, can't bind "g" as leaf
|
|
ParseError(ParseError),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ModeError {
|
|
UnknownMode(String),
|
|
DuplicateMode(String),
|
|
}
|
|
```
|
|
|
|
### dispatch.rs
|
|
|
|
```rust
|
|
pub struct Dispatcher<A: Clone> {
|
|
modes: HashMap<String, KeyTrie<A>>,
|
|
active_mode: String,
|
|
pending: Vec<Key>,
|
|
count: CountState,
|
|
timeout: Duration,
|
|
last_key_at: Option<Instant>,
|
|
}
|
|
|
|
/// No lifetime parameter — no references into the trie.
|
|
/// Which-key data accessed via separate which_key_entries() call.
|
|
pub enum DispatchResult<A> {
|
|
Matched { action: A, count: usize },
|
|
Pending, // Partial match, more keys needed
|
|
Cancelled, // Esc pressed mid-sequence, state cleared
|
|
CountAccumulated, // Digit consumed into count register
|
|
Ignored, // Non-Press event filtered (release, repeat)
|
|
NotFound, // No binding matches
|
|
}
|
|
|
|
impl<A: Clone> Dispatcher<A> {
|
|
pub fn new() -> Self;
|
|
pub fn add_mode(&mut self, name: &str, keymap: KeyTrie<A>) -> Result<(), ModeError>;
|
|
pub fn set_active(&mut self, mode: &str) -> Result<(), ModeError>;
|
|
pub fn active_mode(&self) -> &str;
|
|
|
|
/// Main dispatch entry point. Filters non-Press events automatically.
|
|
pub fn dispatch(&mut self, event: crossterm::event::KeyEvent) -> DispatchResult<A>;
|
|
|
|
pub fn check_timeout(&mut self) -> bool;
|
|
pub fn pending_keys(&self) -> &[Key];
|
|
pub fn pending_display(&self) -> String; // "SPC b" for status bar
|
|
pub fn pending_elapsed(&self) -> Duration;
|
|
pub fn clear_pending(&mut self);
|
|
|
|
/// Which-key introspection. Returns None if no pending prefix.
|
|
/// Separate from dispatch() to avoid lifetime issues.
|
|
pub fn which_key_entries(&self) -> Option<Vec<WhichKeyEntry>>;
|
|
|
|
/// Current count display. Returns None if no count accumulated.
|
|
pub fn count_display(&self) -> Option<&str>;
|
|
}
|
|
```
|
|
|
|
### which_key.rs
|
|
|
|
```rust
|
|
pub struct WhichKeyEntry {
|
|
pub key: String, // "l"
|
|
pub description: String, // "library"
|
|
pub is_group: bool, // true if has children
|
|
}
|
|
|
|
impl<A> KeyTrieNode<A> {
|
|
/// Generates sorted entries from this node's children.
|
|
/// Groups come first, then leaves. Alphabetical within each.
|
|
pub fn which_key_entries(&self) -> Vec<WhichKeyEntry>;
|
|
}
|
|
```
|
|
|
|
### count.rs
|
|
|
|
```rust
|
|
pub struct CountState {
|
|
digits: String, // "12" from pressing 1 then 2
|
|
}
|
|
|
|
impl CountState {
|
|
pub fn push_digit(&mut self, d: char);
|
|
pub fn take(&mut self) -> usize; // Returns count, resets. Default 1.
|
|
pub fn is_active(&self) -> bool;
|
|
pub fn display(&self) -> &str;
|
|
pub fn reset(&mut self);
|
|
}
|
|
```
|
|
|
|
### timeout.rs
|
|
|
|
```rust
|
|
pub struct TimeoutTracker {
|
|
timeout: Duration,
|
|
started_at: Option<Instant>,
|
|
}
|
|
|
|
impl TimeoutTracker {
|
|
pub fn start(&mut self);
|
|
pub fn check(&mut self) -> bool; // true if expired, clears started_at
|
|
pub fn elapsed(&self) -> Duration;
|
|
pub fn reset(&mut self);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Dispatch Algorithm
|
|
|
|
```
|
|
dispatch(key_event):
|
|
0. If key_event.kind != Press:
|
|
return Ignored
|
|
|
|
1. Convert crossterm KeyEvent -> Key (code + modifiers only)
|
|
|
|
2. If key is Escape AND (pending is non-empty OR count is active):
|
|
clear pending, reset count, reset timeout
|
|
return Cancelled
|
|
|
|
3. If digit 1-9 (or 0 when count active) AND no pending keys:
|
|
count.push_digit(digit)
|
|
return CountAccumulated
|
|
|
|
4. Push key to pending buffer
|
|
|
|
5. Get active mode's KeyTrie
|
|
|
|
6. Search trie with pending keys:
|
|
Found(leaf):
|
|
action = leaf.action.clone()
|
|
count = count.take() // consume accumulated count, default 1
|
|
clear pending, reset timeout
|
|
return Matched { action, count }
|
|
Prefix(node):
|
|
start/reset timeout
|
|
return Pending
|
|
NotFound:
|
|
clear pending, reset count, reset timeout
|
|
return NotFound
|
|
|
|
Notes:
|
|
- Count persists through prefix sequences. 5 SPC b l -> Matched { GotoTab(0), count: 5 }.
|
|
Consumer decides whether to use count for non-motion actions.
|
|
- Timeout expiry (checked via check_timeout on tick) clears pending + count silently.
|
|
```
|
|
|
|
---
|
|
|
|
## Integration into ui-agregator
|
|
|
|
### Step 1: Convert to workspace
|
|
|
|
```toml
|
|
# Root Cargo.toml
|
|
[workspace]
|
|
members = ["crates/evil-keys"]
|
|
|
|
[package]
|
|
name = "ui-agregator"
|
|
# ... existing config ...
|
|
|
|
[dependencies]
|
|
evil-keys = { path = "crates/evil-keys" }
|
|
```
|
|
|
|
### Step 2: Define AppAction
|
|
|
|
```rust
|
|
// src/input/action.rs
|
|
#[derive(Clone, Debug)]
|
|
pub enum AppAction {
|
|
// Movement
|
|
MoveUp, MoveDown, FocusLeft, FocusRight, CycleFocus,
|
|
GotoFirst, GotoLast,
|
|
HalfPageDown, HalfPageUp,
|
|
|
|
// Tabs
|
|
NextTab, PrevTab, GotoTab(usize),
|
|
|
|
// System
|
|
Quit, Refresh, ShowHelp, ToggleNotifications,
|
|
Escape,
|
|
}
|
|
```
|
|
|
|
### Step 3: Build keymap
|
|
|
|
```rust
|
|
// src/input/keymap.rs
|
|
pub fn build_normal_keymap() -> KeyTrie<AppAction> {
|
|
let mut t = KeyTrie::new("normal");
|
|
|
|
// Motion — bind() for leaf actions
|
|
t.bind_desc("j", AppAction::MoveDown, "down").unwrap();
|
|
t.bind_desc("k", AppAction::MoveUp, "up").unwrap();
|
|
t.bind_desc("h", AppAction::FocusLeft, "focus left").unwrap();
|
|
t.bind_desc("l", AppAction::FocusRight, "focus right").unwrap();
|
|
t.bind_desc("Tab", AppAction::CycleFocus, "cycle focus").unwrap();
|
|
t.bind_desc("G", AppAction::GotoLast, "last item").unwrap();
|
|
t.bind_desc("C-d", AppAction::HalfPageDown, "half page down").unwrap();
|
|
t.bind_desc("C-u", AppAction::HalfPageUp, "half page up").unwrap();
|
|
|
|
// g-prefix — group() for subtrees
|
|
t.group("g", "+goto", |g| {
|
|
g.bind_desc("g", AppAction::GotoFirst, "first item")?;
|
|
g.bind_desc("t", AppAction::NextTab, "next tab")?;
|
|
g.bind_desc("T", AppAction::PrevTab, "prev tab")?;
|
|
Ok(())
|
|
}).unwrap();
|
|
|
|
// Tabs
|
|
for i in 1..=6 {
|
|
t.bind(&format!("{}", i), AppAction::GotoTab(i - 1)).unwrap();
|
|
}
|
|
|
|
// Leader — group() for SPC prefix
|
|
t.group("SPC", "+leader", |g| {
|
|
g.group("b", "+buffer", |b| {
|
|
b.bind_desc("l", AppAction::GotoTab(0), "library")?;
|
|
b.bind_desc("w", AppAction::GotoTab(1), "wanted")?;
|
|
b.bind_desc("q", AppAction::GotoTab(2), "queue")?;
|
|
b.bind_desc("h", AppAction::GotoTab(3), "history")?;
|
|
b.bind_desc("n", AppAction::NextTab, "next")?;
|
|
b.bind_desc("p", AppAction::PrevTab, "prev")?;
|
|
Ok(())
|
|
})?;
|
|
g.bind_desc("h", AppAction::ShowHelp, "help")?;
|
|
g.bind_desc("q", AppAction::Quit, "quit")?;
|
|
g.bind_desc("n", AppAction::ToggleNotifications, "notifications")?;
|
|
Ok(())
|
|
}).unwrap();
|
|
|
|
// Direct keys
|
|
t.bind("Esc", AppAction::Escape).unwrap();
|
|
t.bind("?", AppAction::ShowHelp).unwrap();
|
|
|
|
t
|
|
}
|
|
```
|
|
|
|
### Step 4: Wire into main.rs
|
|
|
|
```rust
|
|
// Replace lines 81-94
|
|
use evil_keys::{Dispatcher, DispatchResult};
|
|
|
|
let mut dispatcher = Dispatcher::new();
|
|
dispatcher.add_mode("normal", build_normal_keymap()).unwrap();
|
|
dispatcher.add_mode("insert", build_insert_keymap()).unwrap();
|
|
dispatcher.set_active("normal").unwrap();
|
|
|
|
// In event loop:
|
|
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
|
match dispatcher.dispatch(key) {
|
|
DispatchResult::Matched { action, count } => {
|
|
app.handle_action(action, count, &grpc_tx);
|
|
}
|
|
DispatchResult::Pending => {}
|
|
DispatchResult::Cancelled => {}
|
|
DispatchResult::CountAccumulated => {}
|
|
DispatchResult::Ignored => {}
|
|
DispatchResult::NotFound => {}
|
|
}
|
|
}
|
|
|
|
// In tick handler:
|
|
if dispatcher.check_timeout() {
|
|
// Pending keys cleared — optionally hide which-key popup
|
|
}
|
|
```
|
|
|
|
### Step 5: handle_action in app
|
|
|
|
```rust
|
|
// src/application/handlers.rs
|
|
impl App {
|
|
pub fn handle_action(&mut self, action: AppAction, count: usize, tx: &Sender<GrpcRequest>) {
|
|
match action {
|
|
AppAction::MoveDown => {
|
|
for _ in 0..count { self.library.move_down(); }
|
|
}
|
|
AppAction::MoveUp => {
|
|
for _ in 0..count { self.library.move_up(); }
|
|
}
|
|
AppAction::FocusLeft => self.library.focus_left(),
|
|
AppAction::FocusRight => self.library.focus_right(),
|
|
AppAction::CycleFocus => self.library.cycle_focus(),
|
|
AppAction::GotoFirst => { /* select index 0 */ }
|
|
AppAction::GotoLast => { /* select last index */ }
|
|
AppAction::GotoTab(i) => {
|
|
if let Some(tab) = Tab::ALL.get(i) { self.tab = *tab; }
|
|
}
|
|
AppAction::NextTab => { /* cycle tab forward */ }
|
|
AppAction::PrevTab => { /* cycle tab backward */ }
|
|
AppAction::Quit => self.running = false,
|
|
AppAction::Refresh => {
|
|
self.library.clear_cache();
|
|
let _ = tx.try_send(GrpcRequest::GetArtists);
|
|
}
|
|
AppAction::ShowHelp => self.modal = Some(ModalKind::Help),
|
|
AppAction::ToggleNotifications => {
|
|
self.notifications_open = !self.notifications_open;
|
|
}
|
|
AppAction::Escape => self.handle_escape(),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Implementation Phases
|
|
|
|
### Phase 1: Core crate + wire (MVP)
|
|
Files: `key.rs`, `trie.rs`, `dispatch.rs`, `error.rs`, `lib.rs`
|
|
Skip: count, timeout, which-key
|
|
Result: `j/k/h/l`, `gg/G`, `gt/gT`, `SPC b l`, `1-6` tabs, `?`, `Esc` all work
|
|
Test:
|
|
- `parse_key("C-d")` -> `Key { Char('d'), CONTROL }`
|
|
- `parse_key("")` -> `Err(EmptyInput)`
|
|
- `trie.bind("g g", X)` then `trie.bind("g", Y)` -> `Err(ConflictWithPrefix)`
|
|
- `trie.search([g, g])` -> `Found(GotoFirst)`
|
|
- `trie.search([g])` -> `Prefix`
|
|
- `dispatch(KeyRelease)` -> `Ignored`
|
|
- `dispatch(Esc)` mid-sequence -> `Cancelled`
|
|
- Full sequence: `dispatch(g) -> Pending`, `dispatch(g) -> Matched(GotoFirst)`
|
|
Effort: 4-6h
|
|
|
|
### Phase 2: Count prefix + timeout
|
|
Files: `count.rs`, `timeout.rs`, update `dispatch.rs`
|
|
Result: `5j` moves 5 items, `12G` goes to line 12, pending keys clear after 1s
|
|
Test:
|
|
- `dispatch('5')` -> `CountAccumulated`, `dispatch('j')` -> `Matched { MoveDown, count: 5 }`
|
|
- `dispatch('0')` with no count active -> `NotFound` (not a count digit)
|
|
- `dispatch('1')` then `dispatch('0')` -> count is 10
|
|
- Timeout fires after 1s -> pending cleared
|
|
- `count.take()` returns 1 when no digits accumulated
|
|
Effort: 1-2h
|
|
|
|
### Phase 3: Which-key + status bar display
|
|
Files: `which_key.rs`, update `dispatch.rs`
|
|
Result: `which_key_entries()` returns entries after `Pending`, `pending_display()` shows `"SPC b"`
|
|
Test:
|
|
- After `dispatch(SPC)` -> `Pending`, `which_key_entries()` returns `[b:+buffer, h:help, q:quit, n:notifications]`
|
|
- `pending_display()` -> `"SPC"`
|
|
- After `dispatch(SPC)` then `dispatch(b)` -> `pending_display()` -> `"SPC b"`
|
|
- Entries sorted: groups first, then leaves, alphabetical within
|
|
Effort: 2-3h
|
|
|
|
### Phase 4: Extend keymap + insert mode
|
|
Add remaining bindings from help modal: `C-d/C-u`, `H/M/L`, `{/}`, `[[/]]`
|
|
Add insert mode for search filter (`/` enters insert, `Esc` returns to normal)
|
|
Effort: 1-2h
|
|
|
|
---
|
|
|
|
## Extensibility Model
|
|
|
|
### Adding new bindings (consumer-side)
|
|
|
|
```rust
|
|
// Just add a line
|
|
t.bind_desc("C-r", AppAction::Refresh, "refresh").unwrap();
|
|
```
|
|
|
|
### Adding new action
|
|
|
|
```rust
|
|
// 1. Add variant to AppAction
|
|
enum AppAction { ..., ToggleTheme }
|
|
|
|
// 2. Bind it
|
|
t.bind_desc("SPC t t", AppAction::ToggleTheme, "toggle theme").unwrap();
|
|
|
|
// 3. Handle it
|
|
AppAction::ToggleTheme => self.toggle_theme(),
|
|
```
|
|
|
|
### Adding new mode
|
|
|
|
```rust
|
|
let mut visual = KeyTrie::new("visual");
|
|
visual.bind("Esc", AppAction::ExitVisual).unwrap();
|
|
visual.bind("j", AppAction::ExtendSelectionDown).unwrap();
|
|
dispatcher.add_mode("visual", visual).unwrap();
|
|
```
|
|
|
|
### Swapping entire keymap
|
|
|
|
```rust
|
|
// Emacs-style bindings instead of Vim
|
|
let emacs = build_emacs_keymap(); // C-n/C-p/C-f/C-b instead of hjkl
|
|
dispatcher.add_mode("normal", emacs).unwrap();
|
|
```
|
|
|
|
---
|
|
|
|
## What NOT to Build
|
|
|
|
- Text editing (delete/yank/change ranges)
|
|
- Registers
|
|
- Macro recording
|
|
- Operator-pending state
|
|
- Config file parsing (keymaps built in code)
|
|
- Async runtime
|
|
- ratatui dependency (consumer renders which-key)
|
|
- Shift normalization (consumer binds uppercase chars directly: `"J"` not `"S-j"`)
|
|
- Trait abstraction over crossterm (YAGNI — refactor if crate goes public)
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Dependencies
|
|
|
|
```toml
|
|
[dev-dependencies]
|
|
proptest = "1.4"
|
|
```
|
|
|
|
### Test Structure
|
|
|
|
```
|
|
crates/evil-keys/
|
|
src/
|
|
key.rs # #[cfg(test)] mod tests — parsing/display unit tests
|
|
trie.rs # #[cfg(test)] mod tests — bind/search/conflict unit tests
|
|
dispatch.rs # #[cfg(test)] mod tests — state machine unit tests
|
|
count.rs # #[cfg(test)] mod tests — digit accumulation unit tests
|
|
timeout.rs # #[cfg(test)] mod tests — expiry unit tests
|
|
tests/
|
|
key_parsing.rs # Exhaustive parse/display edge cases
|
|
trie_operations.rs # Conflict detection, deep/wide structures
|
|
dispatch_sequences.rs # Full pipeline integration scenarios
|
|
proptest_invariants.rs # Property-based invariant tests
|
|
```
|
|
|
|
### Design Decisions to Lock Before Testing
|
|
|
|
These ambiguities MUST be resolved — tests encode the answers:
|
|
|
|
| Question | Decision | Rationale |
|
|
|----------|----------|-----------|
|
|
| `"S-g"` vs `"G"` | Distinct. `"G"` = `Char('G')` no modifiers. `"S-g"` = `Char('g')` + SHIFT. | Terminals send uppercase as `Char('G')` with NONE modifiers |
|
|
| `"S-G"` (shift + uppercase) | `Err(ParseError)` — redundant/ambiguous | Prevent confusion |
|
|
| `"S-C-d"` vs `"C-S-d"` | Both accepted, normalize to `C-S-` internally | Canonical modifier order: Ctrl, Shift, Alt |
|
|
| `"c-d"` lowercase modifier | `Err(ParseError)` — strict | Prevent typos |
|
|
| `"spc"` / `"ESC"` | `Err(ParseError)` — case-sensitive aliases | `SPC`, `Esc`, `Tab`, `Enter` only |
|
|
| `"C-"` dangling modifier | `Err(ParseError::DanglingModifier)` | Obviously invalid |
|
|
| `" "` (space char) | `Err(ParseError)` — must use `"SPC"` | Unambiguous |
|
|
| Rebind same key | Overwrite silently (last write wins) | Common pattern for extending keymaps |
|
|
| `0` as first key | Dispatched as binding, not count | Vim's `0` = start of line |
|
|
| `0` after digit | Accumulated as count (`10`, `20`, etc.) | Vim's count behavior |
|
|
| Count overflow | Saturate at `usize::MAX` | Never panic or wrap |
|
|
| `count.take()` default | Returns `1` when no digits | "Do it once" |
|
|
| Mode switch mid-sequence | Clears pending + count | Clean slate |
|
|
|
|
---
|
|
|
|
### 1. Key Parsing — Edge Cases
|
|
|
|
```rust
|
|
// === Empty / Whitespace ===
|
|
parse_key("") -> Err(EmptyInput)
|
|
parse_key(" ") -> Err(EmptyInput)
|
|
parse_key(" j") -> Err(UnknownKey)
|
|
parse_key("j ") -> Err(UnknownKey)
|
|
|
|
// === Modifier Edge Cases ===
|
|
parse_key("C-") -> Err(DanglingModifier)
|
|
parse_key("S-") -> Err(DanglingModifier)
|
|
parse_key("C-S-") -> Err(DanglingModifier)
|
|
parse_key("C-C-d") -> Err(DuplicateModifier)
|
|
parse_key("c-d") -> Err(UnknownKey) // lowercase modifier rejected
|
|
parse_key("C-S-d") -> Ok(Key { Char('d'), CTRL|SHIFT })
|
|
parse_key("S-C-d") -> Ok(Key { Char('d'), CTRL|SHIFT }) // normalized
|
|
|
|
// === Single Char That Looks Like Modifier ===
|
|
parse_key("C") -> Ok(Key { Char('C'), NONE }) // letter C, not Ctrl
|
|
parse_key("S") -> Ok(Key { Char('S'), NONE }) // letter S, not Shift
|
|
|
|
// === Uppercase / Shift Ambiguity ===
|
|
parse_key("G") -> Ok(Key { Char('G'), NONE }) // uppercase char, no SHIFT
|
|
parse_key("S-g") -> Ok(Key { Char('g'), SHIFT }) // distinct from "G"
|
|
parse_key("S-G") -> Err(RedundantShift) // ambiguous
|
|
|
|
// === Special Key Aliases (case-sensitive) ===
|
|
parse_key("SPC") -> Ok(Key { Char(' '), NONE })
|
|
parse_key("spc") -> Err(UnknownKey)
|
|
parse_key("Esc") -> Ok(Key { Esc, NONE })
|
|
parse_key("ESC") -> Err(UnknownKey)
|
|
parse_key("Tab") -> Ok(Key { Tab, NONE })
|
|
parse_key("Enter") -> Ok(Key { Enter, NONE })
|
|
parse_key("Backspace") -> Ok(Key { Backspace, NONE })
|
|
|
|
// === Tab vs t ===
|
|
parse_key("Tab") ≠ parse_key("t")
|
|
|
|
// === Function Keys ===
|
|
parse_key("F1") -> Ok(Key { F(1), NONE })
|
|
parse_key("F12") -> Ok(Key { F(12), NONE })
|
|
parse_key("F0") -> Err(UnknownKey)
|
|
parse_key("F13") -> Err(UnknownKey) // unsupported
|
|
parse_key("F") -> Ok(Key { Char('F'), NONE }) // just letter F
|
|
parse_key("C-F1") -> Ok(Key { F(1), CTRL })
|
|
|
|
// === Symbols / Punctuation ===
|
|
parse_key("-") -> Ok(Key { Char('-'), NONE })
|
|
parse_key("C--") -> Ok(Key { Char('-'), CTRL }) // Ctrl+hyphen
|
|
parse_key("[") -> Ok(Key { Char('['), NONE })
|
|
parse_key("]") -> Ok(Key { Char(']'), NONE })
|
|
parse_key("/") -> Ok(Key { Char('/'), NONE })
|
|
parse_key("?") -> Ok(Key { Char('?'), NONE })
|
|
|
|
// === Numbers ===
|
|
parse_key("0")..="9" -> Ok(Key { Char('0'..'9'), NONE })
|
|
parse_key("C-1") -> Ok(Key { Char('1'), CTRL })
|
|
|
|
// === Invalid Input ===
|
|
parse_key("é") -> Err(UnknownKey) // non-ASCII
|
|
parse_key("🔥") -> Err(UnknownKey)
|
|
parse_key("\0") -> Err(UnknownKey)
|
|
parse_key("\x1b") -> Err(UnknownKey) // raw escape byte
|
|
parse_key("g g") -> Err(UnknownKey) // sequence, not single key
|
|
|
|
// === Sequence Parsing ===
|
|
parse_sequence("g g") -> Ok([Key('g'), Key('g')])
|
|
parse_sequence("SPC b l") -> Ok([Key(' '), Key('b'), Key('l')])
|
|
parse_sequence("C-x C-s") -> Ok([Key(Ctrl+'x'), Key(Ctrl+'s')])
|
|
parse_sequence("") -> Err(EmptyInput)
|
|
parse_sequence(" ") -> Err(EmptyInput)
|
|
```
|
|
|
|
### 2. Key Display — Roundtrip
|
|
|
|
```rust
|
|
// Every parsed key must roundtrip through Display
|
|
for input in ["j", "G", "C-d", "C-S-d", "SPC", "Esc", "Tab", "Enter", "F1", "-", "C--", "["] {
|
|
let key = parse_key(input).unwrap();
|
|
let displayed = key.to_string();
|
|
let reparsed = parse_key(&displayed).unwrap();
|
|
assert_eq!(key, reparsed); // roundtrip identity
|
|
}
|
|
|
|
// Display always uses canonical modifier order
|
|
Key { Char('d'), CTRL|SHIFT }.to_string() == "C-S-d" // not "S-C-d"
|
|
|
|
// Space displays as SPC, not " "
|
|
Key { Char(' '), NONE }.to_string() == "SPC"
|
|
```
|
|
|
|
### 3. Trie Operations — Conflicts & Structure
|
|
|
|
```rust
|
|
// === Conflict Detection ===
|
|
bind("g", X) then bind("g g", Y) -> Err(ConflictWithLeaf("g"))
|
|
bind("g g", X) then bind("g", Y) -> Err(ConflictWithPrefix("g"))
|
|
bind("g g", X) then bind("g h", Y) -> Ok (siblings, no conflict)
|
|
bind("g", X) then bind("g", Y) -> Ok (overwrite leaf)
|
|
bind("SPC", group) then bind("SPC", Z) -> Err(ConflictWithPrefix("SPC"))
|
|
group("g") then bind("g", X) -> Err(ConflictWithPrefix("g"))
|
|
|
|
// === Empty / Degenerate ===
|
|
bind("", X) -> Err(EmptySequence)
|
|
search([]) on any trie -> NotFound
|
|
search on empty trie -> NotFound
|
|
|
|
// === Deep Nesting ===
|
|
bind("a b c d e f g h", X) -> Ok
|
|
search([a,b,c,d,e,f,g,h]) -> Found(X)
|
|
search([a,b,c]) -> Prefix
|
|
|
|
// === Wide Single Level ===
|
|
bind 26 single chars a-z -> all Ok
|
|
search any single char -> Found
|
|
|
|
// === Group Edge Cases ===
|
|
group("SPC", "+leader", |_| {}) -> Ok (empty group = valid prefix with no leaves)
|
|
search([SPC]) -> Prefix (node with no children)
|
|
which_key_entries on empty group -> [] (empty vec)
|
|
|
|
// === Search Beyond Leaf ===
|
|
bind("g", X)
|
|
search([g, h]) -> NotFound (g is leaf, h doesn't exist beyond it)
|
|
|
|
// === Partial Prefix ===
|
|
bind("g g", X)
|
|
search([g]) -> Prefix
|
|
search([g, g]) -> Found(X)
|
|
search([g, h]) -> NotFound
|
|
search([h]) -> NotFound
|
|
```
|
|
|
|
### 4. Dispatcher — State Machine
|
|
|
|
```rust
|
|
// === Event Filtering ===
|
|
dispatch(KeyRelease('j')) -> Ignored
|
|
dispatch(KeyRepeat('j')) -> Ignored
|
|
dispatch(KeyPress('j')) -> Matched/Pending/NotFound (real dispatch)
|
|
|
|
// === Escape Behavior ===
|
|
// nothing pending, Esc bound to action:
|
|
dispatch(Esc) -> Matched(EscAction, 1)
|
|
// pending keys only:
|
|
dispatch('g') -> Pending
|
|
dispatch(Esc) -> Cancelled // pending cleared
|
|
// count only:
|
|
dispatch('5') -> CountAccumulated
|
|
dispatch(Esc) -> Cancelled // count cleared
|
|
// count + pending:
|
|
dispatch('3') -> CountAccumulated
|
|
dispatch('g') -> Pending
|
|
dispatch(Esc) -> Cancelled // both cleared
|
|
// after cancel, fresh dispatch works:
|
|
dispatch('j') -> Matched(MoveDown, 1)
|
|
|
|
// === Count: 0 Key Dual Role ===
|
|
dispatch('0') -> Matched(GoToStart, 1) // 0 is a binding, not count
|
|
dispatch('1') -> CountAccumulated
|
|
dispatch('0') -> CountAccumulated // now count = 10
|
|
dispatch('j') -> Matched(MoveDown, 10)
|
|
|
|
// === Count: Accumulation ===
|
|
dispatch('1') -> CountAccumulated
|
|
dispatch('2') -> CountAccumulated
|
|
dispatch('3') -> CountAccumulated
|
|
dispatch('j') -> Matched(MoveDown, 123)
|
|
|
|
// === Count: Overflow Saturation ===
|
|
dispatch('9' x 20 times) // 20 nines
|
|
dispatch('j') -> Matched(MoveDown, usize::MAX) // saturated, not panicked
|
|
|
|
// === Count: take() Semantics ===
|
|
CountState::new().take() == 1 // default
|
|
count.push('5'); count.take() == 5
|
|
count.take() == 1 // reset after take
|
|
|
|
// === Count: Persists Through Prefix ===
|
|
dispatch('5') -> CountAccumulated
|
|
dispatch('g') -> Pending
|
|
dispatch('g') -> Matched(GotoFirst, 5) // count carried through
|
|
|
|
// === Count: Digits NOT Accumulated During Pending ===
|
|
bind("g 3", SomeAction)
|
|
dispatch('g') -> Pending
|
|
dispatch('3') -> Matched(SomeAction, 1) // '3' is sequence key, not count
|
|
|
|
// === Mode Switching ===
|
|
dispatch('g') -> Pending // normal mode
|
|
set_active("insert") // switch mode clears state
|
|
pending_keys() == [] // cleared
|
|
dispatch('j') in insert mode // uses insert keymap
|
|
|
|
// === Invalid Mode ===
|
|
set_active("nonexistent") -> Err(UnknownMode)
|
|
|
|
// === No Modes Registered ===
|
|
Dispatcher::new().dispatch('j') -> NotFound // no panic
|
|
|
|
// === Wrong Key Mid-Sequence ===
|
|
bind("g g", X)
|
|
dispatch('g') -> Pending
|
|
dispatch('h') -> NotFound // "g h" doesn't exist, pending cleared
|
|
dispatch('g') -> Pending // fresh start
|
|
|
|
// === Full Sequence ===
|
|
bind("SPC b l", GotoLibrary)
|
|
dispatch(SPC) -> Pending
|
|
dispatch('b') -> Pending
|
|
dispatch('l') -> Matched(GotoLibrary, 1)
|
|
|
|
// === Rapid Same Key ===
|
|
bind("j", MoveDown)
|
|
dispatch('j') x 1000 // all Matched, no state leak
|
|
```
|
|
|
|
### 5. Timeout
|
|
|
|
```rust
|
|
// === Basic Expiry ===
|
|
timeout = 100ms
|
|
dispatch('g') -> Pending
|
|
sleep(150ms)
|
|
check_timeout() -> true // expired, pending cleared
|
|
dispatch('g') -> Pending // fresh start, not "g g"
|
|
|
|
// === Reset On New Key ===
|
|
timeout = 100ms
|
|
dispatch('g') -> Pending
|
|
sleep(50ms)
|
|
dispatch('g') -> Matched // completed before timeout
|
|
|
|
// === No Timeout Active ===
|
|
check_timeout() -> false // nothing pending
|
|
|
|
// === Zero Timeout ===
|
|
timeout = 0ms
|
|
dispatch('g') -> Pending
|
|
check_timeout() -> true // immediately expired
|
|
|
|
// === Timeout Clears Count Too ===
|
|
dispatch('5') -> CountAccumulated
|
|
dispatch('g') -> Pending
|
|
sleep(timeout)
|
|
check_timeout() -> true
|
|
dispatch('j') -> Matched(MoveDown, 1) // count was cleared, not 5
|
|
```
|
|
|
|
### 6. Which-Key Introspection
|
|
|
|
```rust
|
|
// === After Prefix ===
|
|
bind SPC -> group(+leader: b->+buffer, h->help, q->quit, n->notifications)
|
|
dispatch(SPC) -> Pending
|
|
which_key_entries() -> Some([
|
|
WhichKeyEntry { key: "b", description: "+buffer", is_group: true },
|
|
WhichKeyEntry { key: "h", description: "help", is_group: false },
|
|
WhichKeyEntry { key: "n", description: "notifications", is_group: false },
|
|
WhichKeyEntry { key: "q", description: "quit", is_group: false },
|
|
])
|
|
|
|
// === Deeper Nesting ===
|
|
dispatch(SPC) -> Pending
|
|
dispatch('b') -> Pending
|
|
which_key_entries() -> Some([
|
|
WhichKeyEntry { key: "h", description: "history", is_group: false },
|
|
WhichKeyEntry { key: "l", description: "library", is_group: false },
|
|
WhichKeyEntry { key: "n", description: "next", is_group: false },
|
|
...
|
|
])
|
|
|
|
// === No Pending ===
|
|
which_key_entries() -> None
|
|
|
|
// === Pending On Leaf (no children) ===
|
|
bind("j", MoveDown)
|
|
dispatch('j') -> Matched
|
|
which_key_entries() -> None // already resolved
|
|
|
|
// === Empty Group ===
|
|
group("SPC", "+leader", |_| {})
|
|
dispatch(SPC) -> Pending
|
|
which_key_entries() -> Some([]) // empty vec, valid but nothing to show
|
|
|
|
// === Sorted Output ===
|
|
// Groups first, then leaves. Alphabetical within each category.
|
|
entries[0].is_group == true // groups come first
|
|
// Within groups: alphabetical by key
|
|
// Within leaves: alphabetical by key
|
|
|
|
// === pending_display() ===
|
|
dispatch(SPC) -> "SPC"
|
|
dispatch(SPC, b) -> "SPC b"
|
|
nothing pending -> ""
|
|
```
|
|
|
|
### 7. Property-Based Tests (proptest)
|
|
|
|
```rust
|
|
// === Key Invariants ===
|
|
|
|
// Any successfully parsed key roundtrips through Display
|
|
proptest!(key in valid_key_strategy() => {
|
|
let displayed = key.to_string();
|
|
let reparsed = parse_key(&displayed).unwrap();
|
|
assert_eq!(key, reparsed);
|
|
});
|
|
|
|
// parse_key never panics on arbitrary strings
|
|
proptest!(s in ".*" => {
|
|
let _ = parse_key(&s); // may error, must not panic
|
|
});
|
|
|
|
// Display never panics on any Key
|
|
proptest!((code, mods) in (any::<KeyCode>(), any::<KeyModifiers>()) => {
|
|
let key = Key { code, modifiers: mods };
|
|
let _ = key.to_string(); // must not panic
|
|
});
|
|
|
|
// === Trie Invariants ===
|
|
|
|
// Any successfully bound sequence is always found
|
|
proptest!(bindings in vec(valid_sequence_and_action(), 1..20) => {
|
|
let mut trie = KeyTrie::new("test");
|
|
let mut ok_seqs = vec![];
|
|
for (seq, action) in &bindings {
|
|
if trie.bind(seq, action.clone()).is_ok() {
|
|
ok_seqs.push(seq);
|
|
}
|
|
}
|
|
for seq in ok_seqs {
|
|
assert!(matches!(trie.search(&parse_sequence(seq).unwrap()), SearchResult::Found(_)));
|
|
}
|
|
});
|
|
|
|
// Search never panics on arbitrary key vectors
|
|
proptest!(keys in vec(any::<Key>(), 0..50) => {
|
|
let trie: KeyTrie<()> = KeyTrie::new("test");
|
|
let _ = trie.search(&keys);
|
|
});
|
|
|
|
// === Dispatcher Invariants ===
|
|
|
|
// Non-Press events always return Ignored
|
|
proptest!(kind in [Release, Repeat], code in any::<KeyCode>() => {
|
|
let mut d = test_dispatcher();
|
|
let event = KeyEvent { kind, code, modifiers: NONE, state: NONE };
|
|
assert_eq!(d.dispatch(event), DispatchResult::Ignored);
|
|
});
|
|
|
|
// count.take() always returns >= 1
|
|
proptest!(digits in vec('0'..='9', 0..10) => {
|
|
let mut count = CountState::new();
|
|
let mut first = true;
|
|
for d in digits {
|
|
if first && d == '0' { continue; }
|
|
count.push_digit(d);
|
|
first = false;
|
|
}
|
|
assert!(count.take() >= 1);
|
|
});
|
|
|
|
// Escape always leaves dispatcher in clean state
|
|
proptest!(keys_before in vec(valid_key_press(), 0..10) => {
|
|
let mut d = test_dispatcher();
|
|
for k in keys_before { let _ = d.dispatch(k); }
|
|
let _ = d.dispatch(esc_press());
|
|
assert!(d.pending_keys().is_empty());
|
|
});
|
|
```
|
|
|
|
### 8. Integration Scenarios
|
|
|
|
```rust
|
|
// === Full Vim Workflow: 5gg ===
|
|
dispatch('5') -> CountAccumulated
|
|
dispatch('g') -> Pending
|
|
dispatch('g') -> Matched { action: GotoFirst, count: 5 }
|
|
|
|
// === Doom Leader: SPC f f ===
|
|
dispatch(SPC) -> Pending
|
|
dispatch('f') -> Pending
|
|
dispatch('f') -> Matched { action: FindFile, count: 1 }
|
|
|
|
// === Escape Progression ===
|
|
dispatch('5') -> CountAccumulated
|
|
dispatch(Esc) -> Cancelled
|
|
dispatch('g') -> Pending
|
|
dispatch(Esc) -> Cancelled
|
|
dispatch('3') -> CountAccumulated
|
|
dispatch('g') -> Pending
|
|
dispatch(Esc) -> Cancelled
|
|
dispatch('j') -> Matched { action: MoveDown, count: 1 } // fully clean
|
|
|
|
// === Timeout Resets Sequence ===
|
|
timeout = 100ms
|
|
dispatch('g') -> Pending
|
|
sleep(150ms)
|
|
check_timeout() -> true
|
|
dispatch('g') -> Pending // fresh "g", not "g g"
|
|
dispatch('g') -> Matched(GotoFirst) // now completes
|
|
|
|
// === Mode Switch Mid-Sequence ===
|
|
dispatch('3') -> CountAccumulated
|
|
dispatch('g') -> Pending
|
|
set_active("insert") // clears everything
|
|
set_active("normal")
|
|
dispatch('j') -> Matched(MoveDown, 1) // clean state
|
|
|
|
// === Which-Key After Leader ===
|
|
dispatch(SPC) -> Pending
|
|
which_key_entries() -> Some([b:+buffer, h:help, q:quit, n:notifications])
|
|
dispatch('b') -> Pending
|
|
which_key_entries() -> Some([l:library, w:wanted, q:queue, h:history, n:next, p:prev])
|
|
dispatch('l') -> Matched(GotoTab(0))
|
|
which_key_entries() -> None
|
|
|
|
// === Stress: Rapid Fire ===
|
|
for _ in 0..1000 { dispatch('j') -> Matched(MoveDown, 1) }
|
|
// No memory leak, no state corruption, pending always empty after match
|
|
|
|
// === Stress: Alternating Sequences ===
|
|
for _ in 0..100 {
|
|
dispatch('g') -> Pending
|
|
dispatch('g') -> Matched(GotoFirst, 1)
|
|
dispatch('g') -> Pending
|
|
dispatch('t') -> Matched(NextTab, 1)
|
|
}
|
|
```
|