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            .is_some_and(|t| t.remove(&chord.0));
197        Ok(removed)
198    }
199
200    // ── Query API ─────────────────────────────────────────────────────────
201
202    /// Return the direct-child terminal bindings reachable from `prefix` in
203    /// `mode`. Used by which-key to list available completions.
204    pub fn children(&self, mode: M, prefix: &Chord) -> Vec<(KeyEvent, Binding<A>)> {
205        let Some(tree) = self.trees.get(&mode) else {
206            return vec![];
207        };
208        tree.children_of(&prefix.0)
209            .into_iter()
210            .map(|(k, b)| (*k, b.clone()))
211            .collect()
212    }
213
214    /// Return **all** direct children reachable from `prefix` in `mode` —
215    /// both terminal bindings and pure-prefix (submenu) entries.
216    ///
217    /// Terminal entries carry `Some(Binding)`; prefix-only entries carry `None`.
218    /// Callers (e.g. which-key) should render prefix-only entries with a
219    /// synthetic description such as `"…"`.
220    pub fn children_all(&self, mode: M, prefix: &Chord) -> Vec<(KeyEvent, Option<Binding<A>>)> {
221        let Some(tree) = self.trees.get(&mode) else {
222            return vec![];
223        };
224        tree.all_children_of(&prefix.0)
225            .into_iter()
226            .map(|(k, b)| (*k, b.cloned()))
227            .collect()
228    }
229
230    // ── Stateful feed ─────────────────────────────────────────────────────
231
232    /// Feed a single key event for `mode` and return what happened.
233    ///
234    /// `now` is used to drive timeout logic — pass `Instant::now()` in
235    /// production; use a fake `Instant` in tests if needed.
236    pub fn feed(&mut self, mode: M, ev: KeyEvent, _now: Instant) -> KeyResolve<A> {
237        let state = self.state.entry(mode).or_default();
238        state.buffer.push(ev);
239        let buf = state.buffer.clone();
240
241        let Some(tree) = self.trees.get(&mode) else {
242            // No bindings for this mode at all — unbound.
243            let drained: Vec<KeyEvent> = self
244                .state
245                .entry(mode)
246                .or_default()
247                .buffer
248                .drain(..)
249                .collect();
250            return KeyResolve::Unbound(drained);
251        };
252
253        let exact = tree.lookup(&buf);
254        let has_longer = tree.has_prefix(&buf);
255
256        match (exact, has_longer) {
257            (Some(_binding), true) => {
258                // Ambiguous: exact match exists AND deeper bindings exist.
259                KeyResolve::Ambiguous
260            }
261            (Some(binding), false) => {
262                // Clean terminal match.
263                let binding = binding.clone();
264                self.state.entry(mode).or_default().buffer.clear();
265                KeyResolve::Match(binding)
266            }
267            (None, true) => {
268                // Prefix only — wait for more keys.
269                KeyResolve::Pending
270            }
271            (None, false) => {
272                // Dead end — no match, no prefix.
273                let drained: Vec<KeyEvent> = self
274                    .state
275                    .entry(mode)
276                    .or_default()
277                    .buffer
278                    .drain(..)
279                    .collect();
280                KeyResolve::Unbound(drained)
281            }
282        }
283    }
284
285    /// Force-resolve any pending chord state (called when the timeout fires).
286    ///
287    /// Three outcomes:
288    ///
289    /// * View matches a terminal binding → `Match(binding)` and the buffer
290    ///   is drained. This is the Ambiguous resolution case (e.g. both `g` and
291    ///   `gd` bound: pressing `g` and waiting fires the `g` binding).
292    /// * View is a pure prefix (no terminal at this depth but deeper
293    ///   bindings exist) → `Unbound(vec![])` and the buffer is **left in
294    ///   place**. The user is mid-chord; the timeout fired for which-key
295    ///   purposes but no chord-level action is required.
296    /// * View is a dead-end (no terminal, no descendants) → `Unbound(buf)`
297    ///   with the drained events. This shouldn't normally occur given that
298    ///   `feed` only buffers keys that extend a valid prefix.
299    pub fn timeout_resolve(&mut self, mode: M) -> KeyResolve<A> {
300        let buf = match self.state.get(&mode) {
301            Some(s) if !s.buffer.is_empty() => s.buffer.clone(),
302            _ => return KeyResolve::Unbound(vec![]),
303        };
304
305        let Some(tree) = self.trees.get(&mode) else {
306            let drained: Vec<KeyEvent> = self
307                .state
308                .entry(mode)
309                .or_default()
310                .buffer
311                .drain(..)
312                .collect();
313            return KeyResolve::Unbound(drained);
314        };
315
316        if let Some(binding) = tree.lookup(&buf) {
317            let binding = binding.clone();
318            self.state.entry(mode).or_default().buffer.clear();
319            KeyResolve::Match(binding)
320        } else if tree.has_prefix(&buf) {
321            // Pure-Pending: user is mid-chord. Keep the buffer alive.
322            KeyResolve::Unbound(vec![])
323        } else {
324            let drained: Vec<KeyEvent> = self
325                .state
326                .entry(mode)
327                .or_default()
328                .buffer
329                .drain(..)
330                .collect();
331            KeyResolve::Unbound(drained)
332        }
333    }
334
335    /// Return a snapshot of the currently pending chord buffer for `mode`.
336    /// Empty when no chord is in progress.
337    pub fn pending(&self, mode: M) -> &[KeyEvent] {
338        self.state
339            .get(&mode)
340            .map_or(&[][..], |s| s.buffer.as_slice())
341    }
342
343    /// Reset the pending buffer for `mode` (e.g. on mode switch).
344    pub fn reset(&mut self, mode: M) {
345        if let Some(state) = self.state.get_mut(&mode) {
346            state.buffer.clear();
347        }
348    }
349
350    /// Pop the last key from the pending buffer for `mode`.
351    /// Returns the removed key, or `None` if the buffer was empty.
352    ///
353    /// Used by callers (e.g. which-key popup) to implement Backspace-as-navigate:
354    /// the user backs out of a chord prefix one key at a time.
355    pub fn pop(&mut self, mode: M) -> Option<KeyEvent> {
356        self.state.get_mut(&mode)?.buffer.pop()
357    }
358}