Skip to main content

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}