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