hjkl_keymap/keymap.rs
1//! The public [`Keymap`] API that consumers use for chord dispatch.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use thiserror::Error;
8
9use crate::chord::{Chord, ChordParseError};
10use crate::key::KeyEvent;
11use crate::trie::{Binding, Predicate, TrieNode};
12
13/// Trait bound for editor-mode discriminators used as [`Keymap`] map keys.
14///
15/// Any `Copy + Eq + Hash + Debug` type satisfies this automatically via the
16/// blanket impl — consumers define their own concrete enum (e.g. `VimMode`,
17/// `HelixMode`) and no manual `impl Mode for T` is needed.
18pub trait Mode: Copy + Eq + std::hash::Hash + std::fmt::Debug {}
19impl<T: Copy + Eq + std::hash::Hash + std::fmt::Debug> Mode for T {}
20
21/// Error returned from [`Keymap`] operations.
22#[derive(Debug, Error)]
23pub enum KeymapError {
24 #[error("chord parse error: {0}")]
25 Parse(#[from] ChordParseError),
26 #[error("chord is empty")]
27 EmptyChord,
28}
29
30/// Result of feeding a key event into the keymap.
31#[derive(Debug)]
32pub enum KeyResolve<A> {
33 /// The key extends an incomplete chord — wait for more keys.
34 Pending,
35 /// A terminal chord was matched.
36 Match(Binding<A>),
37 /// An exact terminal match exists **and** longer chords also start
38 /// with this prefix. Caller waits for timeout to disambiguate.
39 Ambiguous,
40 /// No chord matches the buffered sequence. `Vec` contains the buffered
41 /// keys (including the last one) that should be replayed to the engine.
42 Unbound(Vec<KeyEvent>),
43}
44
45/// Per-mode pending-chord state.
46#[derive(Default)]
47struct ModeState {
48 /// Buffered key events since the last resolution.
49 buffer: Vec<KeyEvent>,
50}
51
52/// A modal keymap that maps chord sequences to user-defined actions.
53///
54/// Generic over both the action type `A` and the mode discriminator `M`.
55/// `M` can be any `Copy + Eq + Hash + Debug` type — typically a consumer-defined
56/// enum such as `VimMode` or `HelixMode`. Chords are stored per-`M` in
57/// separate tries. Call [`Keymap::feed`] once per key event; it manages an
58/// internal per-mode buffer and returns a [`KeyResolve`] indicating what happened.
59pub struct Keymap<A, M: Mode> {
60 trees: HashMap<M, TrieNode<A>>,
61 leader: char,
62 timeout: Duration,
63 /// Per-mode chord accumulation state.
64 state: HashMap<M, ModeState>,
65}
66
67impl<A: Clone, M: Mode> Keymap<A, M> {
68 /// Create a new keymap with the given leader character.
69 pub fn new(leader: char) -> Self {
70 Self {
71 trees: HashMap::new(),
72 leader,
73 timeout: Duration::from_millis(500),
74 state: HashMap::new(),
75 }
76 }
77
78 /// Update the leader character (re-parses are not needed; leader is
79 /// applied at `add`/`feed` time through `Chord::parse`).
80 pub fn set_leader(&mut self, c: char) {
81 self.leader = c;
82 }
83
84 /// Override the ambiguity-resolution timeout.
85 pub fn set_timeout(&mut self, t: Duration) {
86 self.timeout = t;
87 }
88
89 /// The current leader character.
90 pub fn leader(&self) -> char {
91 self.leader
92 }
93
94 /// The current timeout duration.
95 pub fn timeout_duration(&self) -> Duration {
96 self.timeout
97 }
98
99 // ── Binding registration ──────────────────────────────────────────────
100
101 /// Parse `chord_str` (vim notation, `<leader>` expanded) and register
102 /// `action` for `mode` unconditionally.
103 pub fn add(
104 &mut self,
105 mode: M,
106 chord_str: &str,
107 action: A,
108 desc: &str,
109 ) -> Result<(), KeymapError> {
110 let chord = Chord::parse(chord_str, self.leader)?;
111 if chord.is_empty() {
112 return Err(KeymapError::EmptyChord);
113 }
114 let binding = Binding {
115 action,
116 desc: desc.to_string(),
117 recursive: false,
118 condition: None,
119 };
120 self.add_chord(mode, chord, binding);
121 Ok(())
122 }
123
124 /// Parse `chord_str` and register `action` for `mode` gated behind a
125 /// runtime predicate.
126 ///
127 /// When the predicate returns `false` at resolve time the binding is
128 /// treated as if it does not exist: the key falls through to the next
129 /// dispatch layer (engine FSM, tmux fallback, etc.).
130 ///
131 /// `predicate` must be `Fn() -> bool + Send + Sync + 'static`. Capture
132 /// runtime state via `Arc<Mutex<…>>` or `Arc<AtomicBool>` as needed.
133 ///
134 /// # When to use this over an always-bound action
135 ///
136 /// Prefer this when the gate has **no fall-back behaviour** — the
137 /// binding should simply "not exist" when the predicate is false, and
138 /// the key should reach whatever handler runs after the keymap (engine,
139 /// tmux fallback, etc.). Action-variant gating (an always-bound action
140 /// that checks state at dispatch time and shows a toast on miss) is
141 /// fine when the user benefits from feedback; use `add_if` when silent
142 /// fall-through is the desired UX.
143 ///
144 /// Intentionally retained for future consumers — not yet called by
145 /// `apps/hjkl`. Concrete planned callers (issue #120 review):
146 ///
147 /// - **kryptic-sh/hjkl#39** — scripting (lua / vimscript) layer:
148 /// user-defined conditional bindings (`if filetype == 'rust' then
149 /// bind(...)`) need a host-side predicate primitive.
150 /// - **kryptic-sh/hjkl#113** — extension API: third-party plugins
151 /// registering bindings gated on runtime state (debugger attached,
152 /// project type detected, etc.).
153 /// - **kryptic-sh/hjkl#115** — git hunk actions: `<leader>hs` / `<leader>hr`
154 /// only meaningful inside a git repo; silent fall-through is cleaner
155 /// than a `not-in-git-repo` toast.
156 pub fn add_if(
157 &mut self,
158 mode: M,
159 chord_str: &str,
160 action: A,
161 desc: &str,
162 predicate: impl Fn() -> bool + Send + Sync + 'static,
163 ) -> Result<(), KeymapError> {
164 let chord = Chord::parse(chord_str, self.leader)?;
165 if chord.is_empty() {
166 return Err(KeymapError::EmptyChord);
167 }
168 let binding = Binding {
169 action,
170 desc: desc.to_string(),
171 recursive: false,
172 condition: Some(Arc::new(predicate) as Predicate),
173 };
174 self.add_chord(mode, chord, binding);
175 Ok(())
176 }
177
178 /// Register a pre-parsed chord + binding.
179 pub fn add_chord(&mut self, mode: M, chord: Chord, binding: Binding<A>) {
180 self.trees
181 .entry(mode)
182 .or_default()
183 .insert(&chord.0, binding);
184 }
185
186 /// Remove the binding for `chord_str` in `mode`. Returns `Ok(true)` if
187 /// something was actually removed.
188 pub fn remove(&mut self, mode: M, chord_str: &str) -> Result<bool, KeymapError> {
189 let chord = Chord::parse(chord_str, self.leader)?;
190 if chord.is_empty() {
191 return Err(KeymapError::EmptyChord);
192 }
193 let removed = self
194 .trees
195 .get_mut(&mode)
196 .map(|t| t.remove(&chord.0))
197 .unwrap_or(false);
198 Ok(removed)
199 }
200
201 // ── Query API ─────────────────────────────────────────────────────────
202
203 /// Return the direct-child terminal bindings reachable from `prefix` in
204 /// `mode`. Used by which-key to list available completions.
205 pub fn children(&self, mode: M, prefix: &Chord) -> Vec<(KeyEvent, Binding<A>)> {
206 let Some(tree) = self.trees.get(&mode) else {
207 return vec![];
208 };
209 tree.children_of(&prefix.0)
210 .into_iter()
211 .map(|(k, b)| (*k, b.clone()))
212 .collect()
213 }
214
215 /// Return **all** direct children reachable from `prefix` in `mode` —
216 /// both terminal bindings and pure-prefix (submenu) entries.
217 ///
218 /// Terminal entries carry `Some(Binding)`; prefix-only entries carry `None`.
219 /// Callers (e.g. which-key) should render prefix-only entries with a
220 /// synthetic description such as `"…"`.
221 pub fn children_all(&self, mode: M, prefix: &Chord) -> Vec<(KeyEvent, Option<Binding<A>>)> {
222 let Some(tree) = self.trees.get(&mode) else {
223 return vec![];
224 };
225 tree.all_children_of(&prefix.0)
226 .into_iter()
227 .map(|(k, b)| (*k, b.cloned()))
228 .collect()
229 }
230
231 // ── Stateful feed ─────────────────────────────────────────────────────
232
233 /// Feed a single key event for `mode` and return what happened.
234 ///
235 /// `now` is used to drive timeout logic — pass `Instant::now()` in
236 /// production; use a fake `Instant` in tests if needed.
237 pub fn feed(&mut self, mode: M, ev: KeyEvent, _now: Instant) -> KeyResolve<A> {
238 let state = self.state.entry(mode).or_default();
239 state.buffer.push(ev);
240 let buf = state.buffer.clone();
241
242 let Some(tree) = self.trees.get(&mode) else {
243 // No bindings for this mode at all — unbound.
244 let drained: Vec<KeyEvent> = self
245 .state
246 .entry(mode)
247 .or_default()
248 .buffer
249 .drain(..)
250 .collect();
251 return KeyResolve::Unbound(drained);
252 };
253
254 let exact = tree.lookup(&buf);
255 let has_longer = tree.has_prefix(&buf);
256
257 match (exact, has_longer) {
258 (Some(_binding), true) => {
259 // Ambiguous: exact match exists AND deeper bindings exist.
260 KeyResolve::Ambiguous
261 }
262 (Some(binding), false) => {
263 // Clean terminal match.
264 let binding = binding.clone();
265 self.state.entry(mode).or_default().buffer.clear();
266 KeyResolve::Match(binding)
267 }
268 (None, true) => {
269 // Prefix only — wait for more keys.
270 KeyResolve::Pending
271 }
272 (None, false) => {
273 // Dead end — no match, no prefix.
274 let drained: Vec<KeyEvent> = self
275 .state
276 .entry(mode)
277 .or_default()
278 .buffer
279 .drain(..)
280 .collect();
281 KeyResolve::Unbound(drained)
282 }
283 }
284 }
285
286 /// Force-resolve any pending chord state (called when the timeout fires).
287 ///
288 /// Three outcomes:
289 ///
290 /// * Buffer matches a terminal binding → `Match(binding)` and the buffer
291 /// is drained. This is the Ambiguous resolution case (e.g. both `g` and
292 /// `gd` bound: pressing `g` and waiting fires the `g` binding).
293 /// * Buffer is a pure prefix (no terminal at this depth but deeper
294 /// bindings exist) → `Unbound(vec![])` and the buffer is **left in
295 /// place**. The user is mid-chord; the timeout fired for which-key
296 /// purposes but no chord-level action is required.
297 /// * Buffer is a dead-end (no terminal, no descendants) → `Unbound(buf)`
298 /// with the drained events. This shouldn't normally occur given that
299 /// `feed` only buffers keys that extend a valid prefix.
300 pub fn timeout_resolve(&mut self, mode: M) -> KeyResolve<A> {
301 let buf = match self.state.get(&mode) {
302 Some(s) if !s.buffer.is_empty() => s.buffer.clone(),
303 _ => return KeyResolve::Unbound(vec![]),
304 };
305
306 let Some(tree) = self.trees.get(&mode) else {
307 let drained: Vec<KeyEvent> = self
308 .state
309 .entry(mode)
310 .or_default()
311 .buffer
312 .drain(..)
313 .collect();
314 return KeyResolve::Unbound(drained);
315 };
316
317 if let Some(binding) = tree.lookup(&buf) {
318 let binding = binding.clone();
319 self.state.entry(mode).or_default().buffer.clear();
320 KeyResolve::Match(binding)
321 } else if tree.has_prefix(&buf) {
322 // Pure-Pending: user is mid-chord. Keep the buffer alive.
323 KeyResolve::Unbound(vec![])
324 } else {
325 let drained: Vec<KeyEvent> = self
326 .state
327 .entry(mode)
328 .or_default()
329 .buffer
330 .drain(..)
331 .collect();
332 KeyResolve::Unbound(drained)
333 }
334 }
335
336 /// Return a snapshot of the currently pending chord buffer for `mode`.
337 /// Empty when no chord is in progress.
338 pub fn pending(&self, mode: M) -> &[KeyEvent] {
339 self.state
340 .get(&mode)
341 .map(|s| s.buffer.as_slice())
342 .unwrap_or(&[])
343 }
344
345 /// Reset the pending buffer for `mode` (e.g. on mode switch).
346 pub fn reset(&mut self, mode: M) {
347 if let Some(state) = self.state.get_mut(&mode) {
348 state.buffer.clear();
349 }
350 }
351
352 /// Pop the last key from the pending buffer for `mode`.
353 /// Returns the removed key, or `None` if the buffer was empty.
354 ///
355 /// Used by callers (e.g. which-key popup) to implement Backspace-as-navigate:
356 /// the user backs out of a chord prefix one key at a time.
357 pub fn pop(&mut self, mode: M) -> Option<KeyEvent> {
358 self.state.get_mut(&mode)?.buffer.pop()
359 }
360}