Skip to main content

buffr_modal/
engine.rs

1//! # Page-mode dispatcher engine.
2//!
3//! The engine wraps a [`Keymap`] with the runtime state every vim-ish
4//! dispatcher needs:
5//!
6//! - **pending chord buffer** — chords accumulate until the trie
7//!   reports `Match`, an ambiguity timeout fires, or a `NoMatch`
8//!   resets the buffer.
9//! - **count prefix** — leading digits 1-9 (and 0 if it's not a
10//!   binding by itself) accumulate into a u32 count attached to the
11//!   next count-bearing action. `5j` → `ScrollDown(5)`.
12//! - **register prefix** — `"<char>` selects a register and stashes
13//!   it on the engine. Phase 2 only captures the state; yank-to-
14//!   register wiring lands in Phase 5.
15//! - **mode** — current [`PageMode`]. Mode transitions arrive two
16//!   ways: implicit (specific actions like `OpenOmnibar` /
17//!   `EnterHintMode` / `EnterInsertMode` move the engine into the
18//!   matching mode after dispatch) and explicit
19//!   ([`PageAction::EnterMode`]). The user-config friendly catch-all
20//!   `EnterMode` co-exists with the legacy specific actions because
21//!   each carries a slightly different semantic for the host (the
22//!   omnibar action also opens the omnibar UI; a plain `EnterMode`
23//!   only changes mode). Both code paths converge on
24//!   [`Engine::set_mode`].
25//!
26//! # Design choice (mode transitions)
27//!
28//! Per the brief: "Pick whichever is cleaner; document the choice in
29//! a code comment at the top of the new file." Choice: **keep the
30//! specific actions, add `EnterMode(PageMode)` for raw transitions,
31//! and have the engine auto-transition for any of them**. Rationale:
32//! the specific actions carry host-side meaning beyond mode change
33//! (open the omnibar UI, paint the hint overlay), so they shouldn't
34//! collapse into `EnterMode`. The engine treats both as triggers.
35//!
36//! # Insert-mode stub
37//!
38//! When the trie returns `EnterInsertMode`, the engine sets `mode =
39//! Insert` and *stops processing keys via the trie*. Subsequent keys
40//! must go through [`Engine::feed_edit_mode_key`] which currently
41//! returns `EditModeStep::PassThrough(chord)`. Real wiring lands
42//! when `hjkl_engine::Host` extraction ships upstream.
43
44use crate::actions::{PageAction, PageMode};
45use crate::key::{Key, KeyChord, NamedKey};
46use crate::keymap::{Keymap, Lookup};
47use std::time::Duration;
48
49/// Default ambiguity timeout (vim's `&timeoutlen`).
50pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(1000);
51
52/// Result of feeding one chord to the engine.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Step {
55    /// Engine consumed the chord but doesn't have a match yet. Caller
56    /// should keep feeding.
57    Pending,
58    /// Engine has an ambiguous prefix — exact action present here,
59    /// but a longer binding could still match. If `tick(now)` is
60    /// called past `timeout_at` the shorter action fires.
61    Ambiguous { timeout_at: Duration },
62    /// An action resolved. Engine has reset its pending buffer and
63    /// any count/register state attached to it.
64    Resolved(PageAction),
65    /// Chord didn't extend any binding. Engine reset; caller may
66    /// forward the original chord(s) to the page if desired.
67    Reject,
68    /// Edit-mode is active; the trie was bypassed. Caller should
69    /// route subsequent chords through
70    /// [`Engine::feed_edit_mode_key`].
71    EditModeActive,
72}
73
74/// What `feed_edit_mode_key` returns.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum EditModeStep {
77    /// Engine has no real edit-mode wiring yet — pass the chord back
78    /// to the caller, who lets the page see it. Will be replaced
79    /// once `hjkl_engine::Host` lands.
80    PassThrough(KeyChord),
81    /// Edit-mode exited (Esc). Engine has flipped back to Normal.
82    Exited,
83}
84
85/// Page-mode dispatcher.
86#[derive(Debug)]
87pub struct Engine {
88    keymap: Keymap,
89    mode: PageMode,
90    /// Pre-edit-mode state — restored on Esc out of Edit. Today
91    /// always `Normal` since visual/hint don't enter edit-mode.
92    return_mode: PageMode,
93    pending: Vec<KeyChord>,
94    /// Wall-clock instant the first chord of the pending buffer was
95    /// fed. None when buffer empty.
96    pending_started: Option<Duration>,
97    /// Leading-digit count buffer. `0` means no count specified
98    /// (binding gets count 1 unless explicit).
99    count: u32,
100    /// `"<char>` register selector, set when user typed `"a` etc.
101    /// Phase 2 captures only — yank-to-register isn't wired.
102    register: Option<char>,
103    /// Set true while consuming the char *after* a `"`.
104    awaiting_register_char: bool,
105    timeout: Duration,
106}
107
108impl Engine {
109    pub fn new(keymap: Keymap) -> Self {
110        Self::with_timeout(keymap, DEFAULT_TIMEOUT)
111    }
112
113    pub fn with_timeout(keymap: Keymap, timeout: Duration) -> Self {
114        Self {
115            keymap,
116            mode: PageMode::Normal,
117            return_mode: PageMode::Normal,
118            pending: Vec::new(),
119            pending_started: None,
120            count: 0,
121            register: None,
122            awaiting_register_char: false,
123            timeout,
124        }
125    }
126
127    pub fn keymap(&self) -> &Keymap {
128        &self.keymap
129    }
130
131    pub fn keymap_mut(&mut self) -> &mut Keymap {
132        &mut self.keymap
133    }
134
135    /// Replace the live keymap and reset the pending chord buffer.
136    /// Used by hot-reload in `apps/buffr` so config edits swap bindings
137    /// without restarting CEF.
138    pub fn set_keymap(&mut self, keymap: Keymap) {
139        self.keymap = keymap;
140        self.pending.clear();
141        self.pending_started = None;
142        self.count = 0;
143        self.register = None;
144        self.awaiting_register_char = false;
145    }
146
147    pub fn mode(&self) -> PageMode {
148        self.mode
149    }
150
151    /// Force the mode. Resets the pending buffer.
152    pub fn set_mode(&mut self, mode: PageMode) {
153        self.mode = mode;
154        self.reset_pending();
155    }
156
157    /// Current pending chord buffer (for status-line rendering).
158    pub fn pending(&self) -> &[KeyChord] {
159        &self.pending
160    }
161
162    /// Currently captured register, if any.
163    pub fn register(&self) -> Option<char> {
164        self.register
165    }
166
167    /// Currently buffered count (0 = none).
168    pub fn count(&self) -> u32 {
169        self.count
170    }
171
172    /// Pending count buffer surfaced for the statusline. `None` when
173    /// no count has accumulated yet (the next count-bearing action
174    /// will get an implicit `1`); `Some(n)` once the user has typed
175    /// at least one digit.
176    ///
177    /// This is the chrome-friendly companion to [`Engine::count`] —
178    /// `count` returns the raw `u32` (0 for "none") which conflates
179    /// the absence of a count with the unrepresentable count of zero.
180    /// `count_buffer` does the obvious thing.
181    pub fn count_buffer(&self) -> Option<u32> {
182        if self.count == 0 {
183            None
184        } else {
185            Some(self.count)
186        }
187    }
188
189    /// Configured ambiguity timeout.
190    pub fn timeout(&self) -> Duration {
191        self.timeout
192    }
193
194    /// Feed one chord. The `now` argument is the current wall-clock
195    /// duration since some fixed epoch (the engine never reads the
196    /// clock itself; the host owns timekeeping).
197    pub fn feed(&mut self, chord: KeyChord, now: Duration) -> Step {
198        if matches!(self.mode, PageMode::Insert) {
199            return Step::EditModeActive;
200        }
201
202        // Register prefix consumes one chord at a time.
203        if self.awaiting_register_char {
204            self.awaiting_register_char = false;
205            if let Key::Char(c) = chord.key {
206                self.register = Some(c);
207                return Step::Pending;
208            }
209            // Non-char after `"` — abort register selection.
210            self.register = None;
211            return Step::Reject;
212        }
213
214        // Count and register prefixes only apply in Normal mode and
215        // only when no chords are pending.
216        if matches!(self.mode, PageMode::Normal | PageMode::Visual) && self.pending.is_empty() {
217            // `"` starts register selection.
218            if chord.modifiers.is_empty() && chord.key == Key::Char('"') {
219                self.awaiting_register_char = true;
220                return Step::Pending;
221            }
222            // Digits 1-9 always start a count. `0` only starts a
223            // count if a count is already in progress (vim
224            // convention: `0` alone is "go to col 0", which here
225            // means it's bindable; `10j` works because `1` started
226            // the count already).
227            if chord.modifiers.is_empty()
228                && let Key::Char(c) = chord.key
229                && c.is_ascii_digit()
230            {
231                let d = (c as u32) - ('0' as u32);
232                if self.count > 0 || d != 0 {
233                    self.count = self.count.saturating_mul(10).saturating_add(d);
234                    return Step::Pending;
235                }
236            }
237        }
238
239        // Trie path.
240        self.pending.push(chord);
241        if self.pending_started.is_none() {
242            self.pending_started = Some(now);
243        }
244        match self.keymap.lookup(self.mode, &self.pending) {
245            Lookup::Match(action) => {
246                let action = action.clone();
247                let resolved = self.finalise_action(action);
248                Step::Resolved(resolved)
249            }
250            Lookup::Pending => {
251                // Distinguish "pure prefix" (no action at this node)
252                // from "ambiguous" (action here, longer also
253                // available).
254                if self
255                    .keymap
256                    .resolve_timeout(self.mode, &self.pending)
257                    .is_some()
258                {
259                    Step::Ambiguous {
260                        timeout_at: self.pending_started.unwrap_or(now) + self.timeout,
261                    }
262                } else {
263                    Step::Pending
264                }
265            }
266            Lookup::NoMatch => {
267                self.reset_pending();
268                Step::Reject
269            }
270        }
271    }
272
273    /// Tick — fire the longest-prefix action when the ambiguity
274    /// timeout has elapsed. Returns `Some(action)` if an action
275    /// fired; the engine is reset.
276    pub fn tick(&mut self, now: Duration) -> Option<PageAction> {
277        let started = self.pending_started?;
278        if now < started + self.timeout {
279            return None;
280        }
281        let action = self
282            .keymap
283            .resolve_timeout(self.mode, &self.pending)
284            .cloned()?;
285        Some(self.finalise_action(action))
286    }
287
288    /// Insert-mode key path. Returns a stub today — once
289    /// `hjkl_engine::Host` lands upstream this routes through
290    /// `hjkl_editor::Editor`.
291    // TODO(phase-2-edit): once hjkl_engine::Host lands, route here
292    // through hjkl_editor::Editor.
293    pub fn feed_edit_mode_key(&mut self, chord: KeyChord) -> EditModeStep {
294        // Esc returns to the pre-insert mode.
295        if chord.modifiers.is_empty() && chord.key == Key::Named(NamedKey::Esc) {
296            self.mode = self.return_mode;
297            return EditModeStep::Exited;
298        }
299        EditModeStep::PassThrough(chord)
300    }
301
302    /// Reset pending buffer + count + register. Mode untouched.
303    fn reset_pending(&mut self) {
304        self.pending.clear();
305        self.pending_started = None;
306        self.count = 0;
307        self.register = None;
308        self.awaiting_register_char = false;
309    }
310
311    /// Bake the pending count into a count-bearing action and apply
312    /// any mode transition the action implies. Resets pending state.
313    fn finalise_action(&mut self, action: PageAction) -> PageAction {
314        let count = if self.count == 0 { 1 } else { self.count };
315        let action = apply_count(action, count);
316        self.apply_implicit_mode(&action);
317        self.reset_pending();
318        action
319    }
320
321    fn apply_implicit_mode(&mut self, action: &PageAction) {
322        let new_mode = match action {
323            PageAction::OpenOmnibar | PageAction::OpenCommandLine => Some(PageMode::Command),
324            PageAction::EnterHintMode | PageAction::EnterHintModeBackground => Some(PageMode::Hint),
325            PageAction::EnterInsertMode => {
326                self.return_mode = self.mode;
327                Some(PageMode::Insert)
328            }
329            PageAction::EnterMode(m) => Some(*m),
330            _ => None,
331        };
332        if let Some(m) = new_mode {
333            self.mode = m;
334        }
335    }
336}
337
338/// Replace count-bearing scroll actions' counts with `count`. Other
339/// actions are returned unchanged (count silently dropped).
340fn apply_count(action: PageAction, count: u32) -> PageAction {
341    match action {
342        PageAction::ScrollUp(_) => PageAction::ScrollUp(count),
343        PageAction::ScrollDown(_) => PageAction::ScrollDown(count),
344        PageAction::ScrollLeft(_) => PageAction::ScrollLeft(count),
345        PageAction::ScrollRight(_) => PageAction::ScrollRight(count),
346        other => other,
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::key::{parse_key, parse_keys};
354
355    fn engine_with(bindings: &[(PageMode, &str, PageAction)]) -> Engine {
356        let mut km = Keymap::new();
357        km.set_leader('\\');
358        for (mode, keys, action) in bindings {
359            km.bind(*mode, keys, action.clone()).unwrap();
360        }
361        Engine::new(km)
362    }
363
364    fn t(ms: u64) -> Duration {
365        Duration::from_millis(ms)
366    }
367
368    #[test]
369    fn single_chord_resolves_immediately() {
370        let mut e = engine_with(&[(PageMode::Normal, "j", PageAction::ScrollDown(1))]);
371        let r = e.feed(parse_key("j").unwrap(), t(0));
372        assert_eq!(r, Step::Resolved(PageAction::ScrollDown(1)));
373    }
374
375    #[test]
376    fn count_5j_scrolls_5() {
377        let mut e = engine_with(&[(PageMode::Normal, "j", PageAction::ScrollDown(1))]);
378        for c in parse_keys("5j").unwrap() {
379            let _ = e.feed(c, t(0));
380        }
381        // The final feed returns Resolved(ScrollDown(5)).
382        let mut e = engine_with(&[(PageMode::Normal, "j", PageAction::ScrollDown(1))]);
383        let chords = parse_keys("5j").unwrap();
384        let r1 = e.feed(chords[0], t(0));
385        assert_eq!(r1, Step::Pending); // `5` consumed as count.
386        let r2 = e.feed(chords[1], t(0));
387        assert_eq!(r2, Step::Resolved(PageAction::ScrollDown(5)));
388    }
389
390    #[test]
391    fn count_multidigit() {
392        let mut e = engine_with(&[(PageMode::Normal, "j", PageAction::ScrollDown(1))]);
393        for c in parse_keys("12").unwrap() {
394            let r = e.feed(c, t(0));
395            assert_eq!(r, Step::Pending);
396        }
397        let r = e.feed(parse_key("j").unwrap(), t(0));
398        assert_eq!(r, Step::Resolved(PageAction::ScrollDown(12)));
399    }
400
401    #[test]
402    fn no_count_means_one() {
403        let mut e = engine_with(&[(PageMode::Normal, "j", PageAction::ScrollDown(1))]);
404        let r = e.feed(parse_key("j").unwrap(), t(0));
405        assert_eq!(r, Step::Resolved(PageAction::ScrollDown(1)));
406    }
407
408    #[test]
409    fn zero_alone_is_a_binding_not_a_count() {
410        // Bind `0` to ScrollLeft(1) — vim convention for "go to col 0".
411        let mut e = engine_with(&[(PageMode::Normal, "0", PageAction::ScrollLeft(1))]);
412        let r = e.feed(parse_key("0").unwrap(), t(0));
413        assert_eq!(r, Step::Resolved(PageAction::ScrollLeft(1)));
414    }
415
416    #[test]
417    fn zero_after_digit_continues_count() {
418        let mut e = engine_with(&[(PageMode::Normal, "j", PageAction::ScrollDown(1))]);
419        let r1 = e.feed(parse_key("1").unwrap(), t(0));
420        assert_eq!(r1, Step::Pending);
421        let r2 = e.feed(parse_key("0").unwrap(), t(0));
422        assert_eq!(r2, Step::Pending);
423        let r3 = e.feed(parse_key("j").unwrap(), t(0));
424        assert_eq!(r3, Step::Resolved(PageAction::ScrollDown(10)));
425    }
426
427    #[test]
428    fn ambiguity_resolves_via_tick() {
429        let mut e = engine_with(&[
430            (PageMode::Normal, "g", PageAction::HistoryBack),
431            (PageMode::Normal, "gg", PageAction::ScrollTop),
432        ]);
433        let r = e.feed(parse_key("g").unwrap(), t(0));
434        assert!(matches!(r, Step::Ambiguous { .. }));
435        // Before timeout: nothing fires.
436        assert_eq!(e.tick(t(500)), None);
437        // After timeout: shorter action wins.
438        assert_eq!(e.tick(t(2000)), Some(PageAction::HistoryBack));
439        // Engine is reset.
440        assert!(e.pending().is_empty());
441    }
442
443    #[test]
444    fn ambiguity_extends_to_longer_match() {
445        let mut e = engine_with(&[
446            (PageMode::Normal, "g", PageAction::HistoryBack),
447            (PageMode::Normal, "gg", PageAction::ScrollTop),
448        ]);
449        let r1 = e.feed(parse_key("g").unwrap(), t(0));
450        assert!(matches!(r1, Step::Ambiguous { .. }));
451        // Second `g` within timeout window resolves the longer
452        // binding.
453        let r2 = e.feed(parse_key("g").unwrap(), t(100));
454        assert_eq!(r2, Step::Resolved(PageAction::ScrollTop));
455    }
456
457    #[test]
458    fn pure_prefix_returns_pending_not_ambiguous() {
459        // `<C-w>` is only a prefix (no action at that node), so the
460        // engine returns Pending, not Ambiguous.
461        let mut e = engine_with(&[(PageMode::Normal, "<C-w>c", PageAction::TabClose)]);
462        let r = e.feed(parse_key("<C-w>").unwrap(), t(0));
463        assert_eq!(r, Step::Pending);
464        let r2 = e.feed(parse_key("c").unwrap(), t(50));
465        assert_eq!(r2, Step::Resolved(PageAction::TabClose));
466    }
467
468    #[test]
469    fn no_match_rejects_and_resets() {
470        let mut e = engine_with(&[(PageMode::Normal, "j", PageAction::ScrollDown(1))]);
471        let r = e.feed(parse_key("z").unwrap(), t(0));
472        assert_eq!(r, Step::Reject);
473        assert!(e.pending().is_empty());
474        // Engine recovers; next chord works.
475        let r2 = e.feed(parse_key("j").unwrap(), t(10));
476        assert_eq!(r2, Step::Resolved(PageAction::ScrollDown(1)));
477    }
478
479    #[test]
480    fn register_quote_a_then_y_captures_state() {
481        // Bind `y` to YankUrl. Feed `"ay`: the engine captures
482        // register `a` and then resolves YankUrl. Phase 2 contract:
483        // register state observable on the engine after the action
484        // resolves — the action itself doesn't carry it yet.
485        let mut e = engine_with(&[(PageMode::Normal, "y", PageAction::YankUrl)]);
486        let r1 = e.feed(parse_key("\"").unwrap(), t(0));
487        assert_eq!(r1, Step::Pending);
488        let r2 = e.feed(parse_key("a").unwrap(), t(0));
489        assert_eq!(r2, Step::Pending);
490        // After `"a`, register captured.
491        assert_eq!(e.register(), Some('a'));
492        let r3 = e.feed(parse_key("y").unwrap(), t(0));
493        assert_eq!(r3, Step::Resolved(PageAction::YankUrl));
494        // Action resolved; register cleared with the rest of pending state.
495        assert_eq!(e.register(), None);
496    }
497
498    #[test]
499    fn omnibar_action_transitions_mode() {
500        let mut e = engine_with(&[(PageMode::Normal, "o", PageAction::OpenOmnibar)]);
501        assert_eq!(e.mode(), PageMode::Normal);
502        let r = e.feed(parse_key("o").unwrap(), t(0));
503        assert_eq!(r, Step::Resolved(PageAction::OpenOmnibar));
504        assert_eq!(e.mode(), PageMode::Command);
505    }
506
507    #[test]
508    fn enter_hint_action_transitions_mode() {
509        let mut e = engine_with(&[(PageMode::Normal, "f", PageAction::EnterHintMode)]);
510        let r = e.feed(parse_key("f").unwrap(), t(0));
511        assert_eq!(r, Step::Resolved(PageAction::EnterHintMode));
512        assert_eq!(e.mode(), PageMode::Hint);
513    }
514
515    #[test]
516    fn enter_mode_explicit_action() {
517        let mut e = engine_with(&[(
518            PageMode::Normal,
519            "v",
520            PageAction::EnterMode(PageMode::Visual),
521        )]);
522        let _ = e.feed(parse_key("v").unwrap(), t(0));
523        assert_eq!(e.mode(), PageMode::Visual);
524    }
525
526    #[test]
527    fn edit_mode_blocks_trie() {
528        let mut e = engine_with(&[
529            (PageMode::Normal, "i", PageAction::EnterInsertMode),
530            (PageMode::Normal, "j", PageAction::ScrollDown(1)),
531        ]);
532        let r = e.feed(parse_key("i").unwrap(), t(0));
533        assert_eq!(r, Step::Resolved(PageAction::EnterInsertMode));
534        assert_eq!(e.mode(), PageMode::Insert);
535        // After entering insert-mode the trie is bypassed.
536        let r2 = e.feed(parse_key("j").unwrap(), t(0));
537        assert_eq!(r2, Step::EditModeActive);
538    }
539
540    #[test]
541    fn edit_mode_passthrough_then_esc_exits() {
542        let mut e = engine_with(&[(PageMode::Normal, "i", PageAction::EnterInsertMode)]);
543        let _ = e.feed(parse_key("i").unwrap(), t(0));
544        assert_eq!(e.mode(), PageMode::Insert);
545        let chord = parse_key("a").unwrap();
546        assert_eq!(
547            e.feed_edit_mode_key(chord),
548            EditModeStep::PassThrough(chord)
549        );
550        assert_eq!(e.mode(), PageMode::Insert);
551        let exit = e.feed_edit_mode_key(parse_key("<Esc>").unwrap());
552        assert_eq!(exit, EditModeStep::Exited);
553        assert_eq!(e.mode(), PageMode::Normal);
554    }
555
556    #[test]
557    fn count_does_not_apply_to_non_count_actions() {
558        let mut e = engine_with(&[(PageMode::Normal, "r", PageAction::Reload)]);
559        let _ = e.feed(parse_key("5").unwrap(), t(0));
560        let r = e.feed(parse_key("r").unwrap(), t(0));
561        // Reload has no count slot — count is silently dropped.
562        assert_eq!(r, Step::Resolved(PageAction::Reload));
563        assert_eq!(e.count(), 0);
564    }
565
566    #[test]
567    fn tick_no_pending_returns_none() {
568        let mut e = engine_with(&[]);
569        assert_eq!(e.tick(t(5000)), None);
570    }
571
572    #[test]
573    fn count_buffer_none_until_digit() {
574        let mut e = engine_with(&[(PageMode::Normal, "j", PageAction::ScrollDown(1))]);
575        assert_eq!(e.count_buffer(), None);
576        let _ = e.feed(parse_key("1").unwrap(), t(0));
577        assert_eq!(e.count_buffer(), Some(1));
578        let _ = e.feed(parse_key("2").unwrap(), t(0));
579        assert_eq!(e.count_buffer(), Some(12));
580        let _ = e.feed(parse_key("j").unwrap(), t(0));
581        // Action resolved → buffer cleared.
582        assert_eq!(e.count_buffer(), None);
583    }
584}