Skip to main content

rgx/input/
vim.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3use super::{key_to_action, Action};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum VimMode {
7    Normal,
8    Insert,
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum PendingKey {
13    None,
14    G,
15    D,
16    C,
17}
18
19#[derive(Debug, Clone)]
20pub struct VimState {
21    pub mode: VimMode,
22    pending: PendingKey,
23}
24
25impl VimState {
26    pub fn new() -> Self {
27        Self {
28            mode: VimMode::Normal,
29            pending: PendingKey::None,
30        }
31    }
32
33    /// Revert to Normal mode. Used when an Insert-triggering action (e.g. o/O)
34    /// is not applicable to the current panel.
35    pub fn cancel_insert(&mut self) {
36        self.mode = VimMode::Normal;
37    }
38}
39
40impl Default for VimState {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46/// Returns true if this key should bypass vim processing.
47fn is_global_shortcut(key: &KeyEvent) -> bool {
48    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
49    let alt = key.modifiers.contains(KeyModifiers::ALT);
50
51    if ctrl {
52        return matches!(
53            key.code,
54            KeyCode::Char('d')
55                | KeyCode::Char('e')
56                | KeyCode::Char('z')
57                | KeyCode::Char('Z')
58                | KeyCode::Char('y')
59                | KeyCode::Char('w')
60                | KeyCode::Char('o')
61                | KeyCode::Char('s')
62                | KeyCode::Char('r')
63                | KeyCode::Char('b')
64                | KeyCode::Char('u')
65                | KeyCode::Char('g')
66                | KeyCode::Char('c')
67                | KeyCode::Char('x')
68                | KeyCode::Char('q')
69                | KeyCode::Left
70                | KeyCode::Right
71        );
72    }
73    if alt {
74        return matches!(
75            key.code,
76            KeyCode::Char('i')
77                | KeyCode::Char('m')
78                | KeyCode::Char('s')
79                | KeyCode::Char('u')
80                | KeyCode::Char('x')
81                | KeyCode::Up
82                | KeyCode::Down
83        );
84    }
85    matches!(key.code, KeyCode::F(1) | KeyCode::Tab | KeyCode::BackTab)
86}
87
88/// Process a key event through the vim state machine.
89pub fn vim_key_to_action(key: KeyEvent, state: &mut VimState) -> Action {
90    if is_global_shortcut(&key) {
91        state.pending = PendingKey::None;
92        return key_to_action(key);
93    }
94
95    match state.mode {
96        VimMode::Insert => vim_insert_action(key, state),
97        VimMode::Normal => vim_normal_action(key, state),
98    }
99}
100
101fn vim_insert_action(key: KeyEvent, state: &mut VimState) -> Action {
102    if key.code == KeyCode::Esc {
103        state.mode = VimMode::Normal;
104        return Action::EnterNormalMode;
105    }
106    key_to_action(key)
107}
108
109fn vim_normal_action(key: KeyEvent, state: &mut VimState) -> Action {
110    // Handle pending keys first
111    match state.pending {
112        PendingKey::G => {
113            state.pending = PendingKey::None;
114            return match key.code {
115                KeyCode::Char('g') => Action::MoveToFirstLine,
116                _ => Action::None,
117            };
118        }
119        PendingKey::D => {
120            state.pending = PendingKey::None;
121            return match key.code {
122                KeyCode::Char('d') => Action::DeleteLine,
123                _ => Action::None,
124            };
125        }
126        PendingKey::C => {
127            state.pending = PendingKey::None;
128            return match key.code {
129                KeyCode::Char('c') => {
130                    state.mode = VimMode::Insert;
131                    Action::ChangeLine
132                }
133                _ => Action::None,
134            };
135        }
136        PendingKey::None => {}
137    }
138
139    match key.code {
140        // Mode transitions
141        KeyCode::Char('i') => {
142            state.mode = VimMode::Insert;
143            Action::EnterInsertMode
144        }
145        KeyCode::Char('a') => {
146            state.mode = VimMode::Insert;
147            Action::EnterInsertModeAppend
148        }
149        KeyCode::Char('I') => {
150            state.mode = VimMode::Insert;
151            Action::EnterInsertModeLineStart
152        }
153        KeyCode::Char('A') => {
154            state.mode = VimMode::Insert;
155            Action::EnterInsertModeLineEnd
156        }
157        KeyCode::Char('o') => {
158            state.mode = VimMode::Insert;
159            Action::OpenLineBelow
160        }
161        KeyCode::Char('O') => {
162            state.mode = VimMode::Insert;
163            Action::OpenLineAbove
164        }
165
166        // Motions
167        KeyCode::Char('h') | KeyCode::Left => Action::MoveCursorLeft,
168        KeyCode::Char('l') | KeyCode::Right => Action::MoveCursorRight,
169        KeyCode::Char('j') | KeyCode::Down => Action::ScrollDown,
170        KeyCode::Char('k') | KeyCode::Up => Action::ScrollUp,
171        KeyCode::Char('w') => Action::MoveCursorWordRight,
172        KeyCode::Char('b') => Action::MoveCursorWordLeft,
173        KeyCode::Char('e') => Action::MoveCursorWordForwardEnd,
174        KeyCode::Char('0') => Action::MoveCursorHome,
175        KeyCode::Char('^') => Action::MoveToFirstNonBlank,
176        KeyCode::Char('$') => Action::MoveCursorEnd,
177        KeyCode::Char('G') => Action::MoveToLastLine,
178        KeyCode::Char('g') => {
179            state.pending = PendingKey::G;
180            Action::None
181        }
182        KeyCode::Home => Action::MoveCursorHome,
183        KeyCode::End => Action::MoveCursorEnd,
184
185        // Editing (dd/cc require double-tap)
186        KeyCode::Char('x') => Action::DeleteCharAtCursor,
187        KeyCode::Char('d') => {
188            state.pending = PendingKey::D;
189            Action::None
190        }
191        KeyCode::Char('c') => {
192            state.pending = PendingKey::C;
193            Action::None
194        }
195        KeyCode::Char('u') => Action::Undo,
196        KeyCode::Char('p') => Action::PasteClipboard,
197
198        // Quit from normal mode
199        KeyCode::Esc => Action::Quit,
200
201        _ => Action::None,
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
209
210    fn key(code: KeyCode) -> KeyEvent {
211        KeyEvent {
212            code,
213            modifiers: KeyModifiers::NONE,
214            kind: KeyEventKind::Press,
215            state: KeyEventState::NONE,
216        }
217    }
218
219    fn key_ctrl(code: KeyCode) -> KeyEvent {
220        KeyEvent {
221            code,
222            modifiers: KeyModifiers::CONTROL,
223            kind: KeyEventKind::Press,
224            state: KeyEventState::NONE,
225        }
226    }
227
228    #[test]
229    fn test_starts_in_normal_mode() {
230        let state = VimState::new();
231        assert_eq!(state.mode, VimMode::Normal);
232    }
233
234    #[test]
235    fn test_i_enters_insert_mode() {
236        let mut state = VimState::new();
237        let action = vim_key_to_action(key(KeyCode::Char('i')), &mut state);
238        assert_eq!(action, Action::EnterInsertMode);
239        assert_eq!(state.mode, VimMode::Insert);
240    }
241
242    #[test]
243    fn test_esc_in_insert_returns_to_normal() {
244        let mut state = VimState::new();
245        state.mode = VimMode::Insert;
246        let action = vim_key_to_action(key(KeyCode::Esc), &mut state);
247        assert_eq!(action, Action::EnterNormalMode);
248        assert_eq!(state.mode, VimMode::Normal);
249    }
250
251    #[test]
252    fn test_esc_in_normal_quits() {
253        let mut state = VimState::new();
254        let action = vim_key_to_action(key(KeyCode::Esc), &mut state);
255        assert_eq!(action, Action::Quit);
256    }
257
258    #[test]
259    fn test_hjkl_motions() {
260        let mut state = VimState::new();
261        assert_eq!(
262            vim_key_to_action(key(KeyCode::Char('h')), &mut state),
263            Action::MoveCursorLeft
264        );
265        assert_eq!(
266            vim_key_to_action(key(KeyCode::Char('j')), &mut state),
267            Action::ScrollDown
268        );
269        assert_eq!(
270            vim_key_to_action(key(KeyCode::Char('k')), &mut state),
271            Action::ScrollUp
272        );
273        assert_eq!(
274            vim_key_to_action(key(KeyCode::Char('l')), &mut state),
275            Action::MoveCursorRight
276        );
277    }
278
279    #[test]
280    fn test_word_motions() {
281        let mut state = VimState::new();
282        assert_eq!(
283            vim_key_to_action(key(KeyCode::Char('w')), &mut state),
284            Action::MoveCursorWordRight
285        );
286        assert_eq!(
287            vim_key_to_action(key(KeyCode::Char('b')), &mut state),
288            Action::MoveCursorWordLeft
289        );
290        assert_eq!(
291            vim_key_to_action(key(KeyCode::Char('e')), &mut state),
292            Action::MoveCursorWordForwardEnd
293        );
294    }
295
296    #[test]
297    fn test_gg_goes_to_first_line() {
298        let mut state = VimState::new();
299        let a1 = vim_key_to_action(key(KeyCode::Char('g')), &mut state);
300        assert_eq!(a1, Action::None);
301        let a2 = vim_key_to_action(key(KeyCode::Char('g')), &mut state);
302        assert_eq!(a2, Action::MoveToFirstLine);
303    }
304
305    #[test]
306    fn test_g_then_non_g_cancels() {
307        let mut state = VimState::new();
308        vim_key_to_action(key(KeyCode::Char('g')), &mut state);
309        let action = vim_key_to_action(key(KeyCode::Char('x')), &mut state);
310        assert_eq!(action, Action::None);
311    }
312
313    #[test]
314    fn test_dd_deletes_line() {
315        let mut state = VimState::new();
316        let a1 = vim_key_to_action(key(KeyCode::Char('d')), &mut state);
317        assert_eq!(a1, Action::None);
318        let a2 = vim_key_to_action(key(KeyCode::Char('d')), &mut state);
319        assert_eq!(a2, Action::DeleteLine);
320    }
321
322    #[test]
323    fn test_d_then_non_d_cancels() {
324        let mut state = VimState::new();
325        vim_key_to_action(key(KeyCode::Char('d')), &mut state);
326        let action = vim_key_to_action(key(KeyCode::Char('j')), &mut state);
327        assert_eq!(action, Action::None);
328    }
329
330    #[test]
331    fn test_cc_changes_line() {
332        let mut state = VimState::new();
333        let a1 = vim_key_to_action(key(KeyCode::Char('c')), &mut state);
334        assert_eq!(a1, Action::None);
335        let a2 = vim_key_to_action(key(KeyCode::Char('c')), &mut state);
336        assert_eq!(a2, Action::ChangeLine);
337        assert_eq!(state.mode, VimMode::Insert);
338    }
339
340    #[test]
341    fn test_x_deletes_char() {
342        let mut state = VimState::new();
343        assert_eq!(
344            vim_key_to_action(key(KeyCode::Char('x')), &mut state),
345            Action::DeleteCharAtCursor
346        );
347    }
348
349    #[test]
350    fn test_ctrl_d_is_global_shortcut() {
351        let mut state = VimState::new();
352        let action = vim_key_to_action(key_ctrl(KeyCode::Char('d')), &mut state);
353        assert_eq!(action, Action::ToggleDebugger);
354    }
355
356    #[test]
357    fn test_global_shortcuts_bypass_vim() {
358        let mut state = VimState::new();
359        let action = vim_key_to_action(key_ctrl(KeyCode::Char('e')), &mut state);
360        assert_eq!(action, Action::SwitchEngine);
361        assert_eq!(state.mode, VimMode::Normal);
362    }
363
364    #[test]
365    fn test_global_shortcut_clears_pending() {
366        let mut state = VimState::new();
367        vim_key_to_action(key(KeyCode::Char('d')), &mut state);
368        let action = vim_key_to_action(key_ctrl(KeyCode::Char('e')), &mut state);
369        assert_eq!(action, Action::SwitchEngine);
370    }
371
372    #[test]
373    fn test_insert_mode_types_chars() {
374        let mut state = VimState::new();
375        state.mode = VimMode::Insert;
376        let action = vim_key_to_action(key(KeyCode::Char('h')), &mut state);
377        assert_eq!(action, Action::InsertChar('h'));
378    }
379
380    #[test]
381    fn test_a_enters_insert_append() {
382        let mut state = VimState::new();
383        let action = vim_key_to_action(key(KeyCode::Char('a')), &mut state);
384        assert_eq!(action, Action::EnterInsertModeAppend);
385        assert_eq!(state.mode, VimMode::Insert);
386    }
387
388    #[test]
389    fn test_tab_bypasses_vim() {
390        let mut state = VimState::new();
391        let action = vim_key_to_action(key(KeyCode::Tab), &mut state);
392        assert_eq!(action, Action::SwitchPanel);
393    }
394
395    #[test]
396    fn test_u_is_undo_in_normal() {
397        let mut state = VimState::new();
398        assert_eq!(
399            vim_key_to_action(key(KeyCode::Char('u')), &mut state),
400            Action::Undo
401        );
402    }
403
404    #[test]
405    fn test_o_opens_line_and_enters_insert() {
406        let mut state = VimState::new();
407        let action = vim_key_to_action(key(KeyCode::Char('o')), &mut state);
408        assert_eq!(action, Action::OpenLineBelow);
409        assert_eq!(state.mode, VimMode::Insert);
410    }
411}