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