Skip to main content

hjkl_vim/
pending.rs

1/// Pending-state machine for second-key chords. The umbrella stores
2/// `Option<PendingState>`; when `Some`, it routes keys through `step`
3/// instead of the keymap trie.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum PendingState {
6    Replace {
7        count: usize,
8    },
9    /// `f<x>` / `F<x>` / `t<x>` / `T<x>` — find single char on current line.
10    /// `forward` = direction (true for f/t, false for F/T).
11    /// `till` = stop one char before target (true for t/T, false for f/F).
12    Find {
13        count: usize,
14        forward: bool,
15        till: bool,
16    },
17    /// `g<x>` — bare g-prefix chord in Normal / Visual mode. The app sets this
18    /// after intercepting `g`; `step` routes the next `Key::Char(ch)` to
19    /// `EngineCmd::AfterGChord { ch, count }`. `Key::Esc` cancels; any
20    /// non-char key also cancels (mirrors the `Find` arm).
21    AfterG {
22        count: usize,
23    },
24    // 2c–2e variants land later.
25}
26
27/// One step of the reducer.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Outcome {
30    /// Need more keys — keep accumulating with new state.
31    Wait(PendingState),
32    /// Run this engine command, then clear pending.
33    Commit(crate::cmd::EngineCmd),
34    /// Cancel pending (Esc, invalid char, etc.). No engine call.
35    Cancel,
36    /// Pending state didn't consume this key — host should route it
37    /// normally (e.g. modifier-only key). Pending state stays alive.
38    Forward,
39}
40
41/// `Key` is intentionally minimal — hjkl-vim should not depend on
42/// crossterm. Hosts translate their native keys into this shape.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Key {
45    Char(char),
46    Esc,
47    Enter,
48    Backspace,
49    Tab,
50    // Add more variants only as later chunks require them.
51}
52
53pub fn step(state: PendingState, key: Key) -> Outcome {
54    match state {
55        PendingState::Replace { count } => match key {
56            Key::Esc => Outcome::Cancel,
57            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch, count }),
58            Key::Enter => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch: '\n', count }),
59            _ => Outcome::Cancel,
60        },
61        PendingState::Find {
62            count,
63            forward,
64            till,
65        } => match key {
66            Key::Esc => Outcome::Cancel,
67            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::FindChar {
68                ch,
69                forward,
70                till,
71                count,
72            }),
73            // Any non-char key cancels (vim cancels f<non-char>).
74            _ => Outcome::Cancel,
75        },
76        PendingState::AfterG { count } => match key {
77            Key::Esc => Outcome::Cancel,
78            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterGChord { ch, count }),
79            // Any non-char key cancels (mirrors Find arm).
80            _ => Outcome::Cancel,
81        },
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::cmd::EngineCmd;
89
90    // ── AfterG reducer unit tests ────────────────────────────────────────────
91
92    #[test]
93    fn after_g_gg_commits() {
94        let state = PendingState::AfterG { count: 1 };
95        assert_eq!(
96            step(state, Key::Char('g')),
97            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 1 })
98        );
99    }
100
101    #[test]
102    fn after_g_gv_commits() {
103        let state = PendingState::AfterG { count: 1 };
104        assert_eq!(
105            step(state, Key::Char('v')),
106            Outcome::Commit(EngineCmd::AfterGChord { ch: 'v', count: 1 })
107        );
108    }
109
110    #[test]
111    fn after_g_gu_operator_commits() {
112        // gU still produces AfterGChord; the engine handles the Pending::Op transition.
113        let state = PendingState::AfterG { count: 1 };
114        assert_eq!(
115            step(state, Key::Char('U')),
116            Outcome::Commit(EngineCmd::AfterGChord { ch: 'U', count: 1 })
117        );
118    }
119
120    #[test]
121    fn after_g_gi_commits() {
122        let state = PendingState::AfterG { count: 1 };
123        assert_eq!(
124            step(state, Key::Char('i')),
125            Outcome::Commit(EngineCmd::AfterGChord { ch: 'i', count: 1 })
126        );
127    }
128
129    #[test]
130    fn after_g_esc_cancels() {
131        let state = PendingState::AfterG { count: 1 };
132        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
133    }
134
135    #[test]
136    fn after_g_count_carry_through() {
137        // 5gg enters with count=5 — AfterGChord carries it through.
138        let state = PendingState::AfterG { count: 5 };
139        assert_eq!(
140            step(state, Key::Char('g')),
141            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 5 })
142        );
143    }
144
145    #[test]
146    fn after_g_non_char_cancels() {
147        // Non-char, non-Esc key (e.g. Enter) cancels.
148        let state = PendingState::AfterG { count: 1 };
149        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
150    }
151}