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    /// `z<x>` — bare z-prefix chord in Normal / Visual mode. The app sets this
25    /// after intercepting `z`; `step` routes the next `Key::Char(ch)` to
26    /// `EngineCmd::AfterZChord { ch, count }`. `Key::Esc` cancels; any
27    /// non-char key also cancels (mirrors the `AfterG` arm).
28    AfterZ {
29        count: usize,
30    },
31    // 2c–2e variants land later.
32}
33
34/// One step of the reducer.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Outcome {
37    /// Need more keys — keep accumulating with new state.
38    Wait(PendingState),
39    /// Run this engine command, then clear pending.
40    Commit(crate::cmd::EngineCmd),
41    /// Cancel pending (Esc, invalid char, etc.). No engine call.
42    Cancel,
43    /// Pending state didn't consume this key — host should route it
44    /// normally (e.g. modifier-only key). Pending state stays alive.
45    Forward,
46}
47
48/// `Key` is intentionally minimal — hjkl-vim should not depend on
49/// crossterm. Hosts translate their native keys into this shape.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Key {
52    Char(char),
53    Esc,
54    Enter,
55    Backspace,
56    Tab,
57    // Add more variants only as later chunks require them.
58}
59
60pub fn step(state: PendingState, key: Key) -> Outcome {
61    match state {
62        PendingState::Replace { count } => match key {
63            Key::Esc => Outcome::Cancel,
64            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch, count }),
65            Key::Enter => Outcome::Commit(crate::cmd::EngineCmd::ReplaceChar { ch: '\n', count }),
66            _ => Outcome::Cancel,
67        },
68        PendingState::Find {
69            count,
70            forward,
71            till,
72        } => match key {
73            Key::Esc => Outcome::Cancel,
74            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::FindChar {
75                ch,
76                forward,
77                till,
78                count,
79            }),
80            // Any non-char key cancels (vim cancels f<non-char>).
81            _ => Outcome::Cancel,
82        },
83        PendingState::AfterG { count } => match key {
84            Key::Esc => Outcome::Cancel,
85            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterGChord { ch, count }),
86            // Any non-char key cancels (mirrors Find arm).
87            _ => Outcome::Cancel,
88        },
89        PendingState::AfterZ { count } => match key {
90            Key::Esc => Outcome::Cancel,
91            Key::Char(ch) => Outcome::Commit(crate::cmd::EngineCmd::AfterZChord { ch, count }),
92            // Any non-char key cancels (mirrors AfterG arm).
93            _ => Outcome::Cancel,
94        },
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::cmd::EngineCmd;
102
103    // ── AfterG reducer unit tests ────────────────────────────────────────────
104
105    #[test]
106    fn after_g_gg_commits() {
107        let state = PendingState::AfterG { count: 1 };
108        assert_eq!(
109            step(state, Key::Char('g')),
110            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 1 })
111        );
112    }
113
114    #[test]
115    fn after_g_gv_commits() {
116        let state = PendingState::AfterG { count: 1 };
117        assert_eq!(
118            step(state, Key::Char('v')),
119            Outcome::Commit(EngineCmd::AfterGChord { ch: 'v', count: 1 })
120        );
121    }
122
123    #[test]
124    fn after_g_gu_operator_commits() {
125        // gU still produces AfterGChord; the engine handles the Pending::Op transition.
126        let state = PendingState::AfterG { count: 1 };
127        assert_eq!(
128            step(state, Key::Char('U')),
129            Outcome::Commit(EngineCmd::AfterGChord { ch: 'U', count: 1 })
130        );
131    }
132
133    #[test]
134    fn after_g_gi_commits() {
135        let state = PendingState::AfterG { count: 1 };
136        assert_eq!(
137            step(state, Key::Char('i')),
138            Outcome::Commit(EngineCmd::AfterGChord { ch: 'i', count: 1 })
139        );
140    }
141
142    #[test]
143    fn after_g_esc_cancels() {
144        let state = PendingState::AfterG { count: 1 };
145        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
146    }
147
148    #[test]
149    fn after_g_count_carry_through() {
150        // 5gg enters with count=5 — AfterGChord carries it through.
151        let state = PendingState::AfterG { count: 5 };
152        assert_eq!(
153            step(state, Key::Char('g')),
154            Outcome::Commit(EngineCmd::AfterGChord { ch: 'g', count: 5 })
155        );
156    }
157
158    #[test]
159    fn after_g_non_char_cancels() {
160        // Non-char, non-Esc key (e.g. Enter) cancels.
161        let state = PendingState::AfterG { count: 1 };
162        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
163    }
164
165    // ── AfterZ reducer unit tests ────────────────────────────────────────────
166
167    #[test]
168    fn after_z_zz_commits() {
169        let state = PendingState::AfterZ { count: 1 };
170        assert_eq!(
171            step(state, Key::Char('z')),
172            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 1 })
173        );
174    }
175
176    #[test]
177    fn after_z_zf_commits() {
178        let state = PendingState::AfterZ { count: 1 };
179        assert_eq!(
180            step(state, Key::Char('f')),
181            Outcome::Commit(EngineCmd::AfterZChord { ch: 'f', count: 1 })
182        );
183    }
184
185    #[test]
186    fn after_z_esc_cancels() {
187        let state = PendingState::AfterZ { count: 1 };
188        assert_eq!(step(state, Key::Esc), Outcome::Cancel);
189    }
190
191    #[test]
192    fn after_z_count_carry_through() {
193        // 3zz enters with count=3 — AfterZChord carries it through.
194        let state = PendingState::AfterZ { count: 3 };
195        assert_eq!(
196            step(state, Key::Char('z')),
197            Outcome::Commit(EngineCmd::AfterZChord { ch: 'z', count: 3 })
198        );
199    }
200
201    #[test]
202    fn after_z_non_char_cancels() {
203        // Non-char, non-Esc key (e.g. Enter) cancels.
204        let state = PendingState::AfterZ { count: 1 };
205        assert_eq!(step(state, Key::Enter), Outcome::Cancel);
206    }
207}