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