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