Skip to main content

oxicode/tui_vt/git_tui/
keys.rs

1//! Keymap for the git TUI overlay.
2//!
3//! Maps a [`crossterm::event::KeyEvent`] to a [`GitKeyAction`]. Pure — no
4//! terminal I/O, no rendering. Consumed by the overlay's event loop.
5
6use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7
8/// High-level action dispatched by the git TUI keymap.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum GitKeyAction {
11    /// Move cursor down by one visible line / entry.
12    Down,
13    /// Move cursor up by one visible line / entry.
14    Up,
15    /// Collapse / step left in the file tree.
16    Left,
17    /// Expand / step right in the file tree.
18    Right,
19    /// Jump to the top of the current list.
20    GotoTop,
21    /// Jump to the bottom of the current list.
22    GotoBottom,
23    /// Jump to the next hunk.
24    HunkNext,
25    /// Jump to the previous hunk.
26    HunkPrev,
27    /// Jump to the next file.
28    FileNext,
29    /// Jump to the previous file.
30    FilePrev,
31    /// Switch view mode: `1` = Split, `2` = Inline, `3` = Hunks, `4` = Files.
32    /// The byte is the digit (`'1'..='4'`).
33    ViewMode(u8),
34    /// Toggle the file-tree sidebar.
35    ToggleSidebar,
36    /// Cycle whitespace mode: Off → IgnoreWhitespace → IgnoreFormatting.
37    CycleWhitespace,
38    /// Toggle soft-wrap of long lines.
39    ToggleWrap,
40    /// Stage the current hunk / file (`git add`).
41    Stage,
42    /// Unstage the current hunk / file (`git reset`).
43    Unstage,
44    /// Open the commit message composer.
45    Commit,
46    /// Re-run `git status` + `git diff` and refresh the view.
47    Refresh,
48    /// Close the git overlay.
49    Close,
50}
51
52/// Map a key event to a [`GitKeyAction`]. Returns `None` for keys the
53/// overlay does not handle (the caller decides whether to forward or drop).
54pub fn match_git_key(key: &KeyEvent) -> Option<GitKeyAction> {
55    let alt = key.modifiers.contains(KeyModifiers::ALT);
56
57    // Number keys for view modes — handled before the catch-all Char arm.
58    if let KeyCode::Char(c) = key.code
59        && !alt
60        && !key.modifiers.contains(KeyModifiers::CONTROL)
61    {
62        match c {
63            '1' => return Some(GitKeyAction::ViewMode(1)),
64            '2' => return Some(GitKeyAction::ViewMode(2)),
65            '3' => return Some(GitKeyAction::ViewMode(3)),
66            '4' => return Some(GitKeyAction::ViewMode(4)),
67            _ => {}
68        }
69    }
70
71    // Alt-modified cursor keys for hunk navigation.
72    if alt {
73        return match key.code {
74            KeyCode::Down => Some(GitKeyAction::HunkNext),
75            KeyCode::Up => Some(GitKeyAction::HunkPrev),
76            _ => None,
77        };
78    }
79
80    // Guard against Ctrl-modified keys hijacking the unmapped-char arms
81    // below (Ctrl+C would otherwise trigger Commit, Ctrl+S → Stage, etc.).
82    // Alt+down/up already short-circuits above for hunk nav.
83    if key.modifiers.contains(KeyModifiers::CONTROL) {
84        return None;
85    }
86
87    match key.code {
88        KeyCode::Char('j') => Some(GitKeyAction::Down),
89        KeyCode::Char('k') => Some(GitKeyAction::Up),
90        KeyCode::Char('h') => Some(GitKeyAction::Left),
91        KeyCode::Char('l') => Some(GitKeyAction::Right),
92        KeyCode::Char('g') => Some(GitKeyAction::GotoTop),
93        KeyCode::Char('G') => Some(GitKeyAction::GotoBottom),
94        KeyCode::Char(']') => Some(GitKeyAction::FileNext),
95        KeyCode::Char('[') => Some(GitKeyAction::FilePrev),
96        KeyCode::Char('v') => Some(GitKeyAction::ToggleSidebar),
97        KeyCode::Char('b') => Some(GitKeyAction::CycleWhitespace),
98        KeyCode::Char('w') => Some(GitKeyAction::ToggleWrap),
99        KeyCode::Char('s') => Some(GitKeyAction::Stage),
100        KeyCode::Char('u') => Some(GitKeyAction::Unstage),
101        KeyCode::Char('c') => Some(GitKeyAction::Commit),
102        KeyCode::Char('r') => Some(GitKeyAction::Refresh),
103        KeyCode::Char('q') => Some(GitKeyAction::Close),
104        KeyCode::Esc => Some(GitKeyAction::Close),
105        _ => None,
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn k(code: KeyCode) -> KeyEvent {
114        KeyEvent::new(code, KeyModifiers::NONE)
115    }
116
117    fn alt(code: KeyCode) -> KeyEvent {
118        KeyEvent::new(code, KeyModifiers::ALT)
119    }
120
121    #[test]
122    fn vim_motions_map() {
123        assert_eq!(
124            match_git_key(&k(KeyCode::Char('j'))),
125            Some(GitKeyAction::Down)
126        );
127        assert_eq!(
128            match_git_key(&k(KeyCode::Char('k'))),
129            Some(GitKeyAction::Up)
130        );
131        assert_eq!(
132            match_git_key(&k(KeyCode::Char('h'))),
133            Some(GitKeyAction::Left)
134        );
135        assert_eq!(
136            match_git_key(&k(KeyCode::Char('l'))),
137            Some(GitKeyAction::Right)
138        );
139        assert_eq!(
140            match_git_key(&k(KeyCode::Char('g'))),
141            Some(GitKeyAction::GotoTop)
142        );
143        assert_eq!(
144            match_git_key(&k(KeyCode::Char('G'))),
145            Some(GitKeyAction::GotoBottom)
146        );
147
148        // Alt+down / Alt+Up → hunk nav.
149        assert_eq!(
150            match_git_key(&alt(KeyCode::Down)),
151            Some(GitKeyAction::HunkNext)
152        );
153        assert_eq!(
154            match_git_key(&alt(KeyCode::Up)),
155            Some(GitKeyAction::HunkPrev)
156        );
157
158        // ] / [ → file nav.
159        assert_eq!(
160            match_git_key(&k(KeyCode::Char(']'))),
161            Some(GitKeyAction::FileNext)
162        );
163        assert_eq!(
164            match_git_key(&k(KeyCode::Char('['))),
165            Some(GitKeyAction::FilePrev)
166        );
167    }
168
169    #[test]
170    fn number_keys_select_view() {
171        assert_eq!(
172            match_git_key(&k(KeyCode::Char('1'))),
173            Some(GitKeyAction::ViewMode(1))
174        );
175        assert_eq!(
176            match_git_key(&k(KeyCode::Char('2'))),
177            Some(GitKeyAction::ViewMode(2))
178        );
179        assert_eq!(
180            match_git_key(&k(KeyCode::Char('3'))),
181            Some(GitKeyAction::ViewMode(3))
182        );
183        assert_eq!(
184            match_git_key(&k(KeyCode::Char('4'))),
185            Some(GitKeyAction::ViewMode(4))
186        );
187        // Out-of-range digits return None.
188        assert_eq!(match_git_key(&k(KeyCode::Char('5'))), None);
189        assert_eq!(match_git_key(&k(KeyCode::Char('0'))), None);
190    }
191
192    #[test]
193    fn whitespace_and_wrap_toggles() {
194        assert_eq!(
195            match_git_key(&k(KeyCode::Char('b'))),
196            Some(GitKeyAction::CycleWhitespace)
197        );
198        assert_eq!(
199            match_git_key(&k(KeyCode::Char('w'))),
200            Some(GitKeyAction::ToggleWrap)
201        );
202        // Stage / Unstage / Commit / Refresh.
203        assert_eq!(
204            match_git_key(&k(KeyCode::Char('s'))),
205            Some(GitKeyAction::Stage)
206        );
207        assert_eq!(
208            match_git_key(&k(KeyCode::Char('u'))),
209            Some(GitKeyAction::Unstage)
210        );
211        assert_eq!(
212            match_git_key(&k(KeyCode::Char('c'))),
213            Some(GitKeyAction::Commit)
214        );
215        assert_eq!(
216            match_git_key(&k(KeyCode::Char('r'))),
217            Some(GitKeyAction::Refresh)
218        );
219        // Sidebar toggle.
220        assert_eq!(
221            match_git_key(&k(KeyCode::Char('v'))),
222            Some(GitKeyAction::ToggleSidebar)
223        );
224    }
225
226    #[test]
227    fn close_keys() {
228        assert_eq!(
229            match_git_key(&k(KeyCode::Char('q'))),
230            Some(GitKeyAction::Close)
231        );
232        assert_eq!(match_git_key(&k(KeyCode::Esc)), Some(GitKeyAction::Close));
233    }
234
235    fn ctrl(code: KeyCode) -> KeyEvent {
236        KeyEvent::new(code, KeyModifiers::CONTROL)
237    }
238
239    #[test]
240    fn ctrl_c_does_not_commit() {
241        // Without the guard, Ctrl+C (a control-modifier key whose letter
242        // happens to be 'c') would fall into the Char('c') arm and fire
243        // Commit — hijacking the host cancel convention.
244        assert_eq!(match_git_key(&ctrl(KeyCode::Char('c'))), None);
245        // Plain 'c' still commits.
246        assert_eq!(
247            match_git_key(&k(KeyCode::Char('c'))),
248            Some(GitKeyAction::Commit)
249        );
250    }
251
252    #[test]
253    fn ctrl_s_does_not_stage() {
254        assert_eq!(match_git_key(&ctrl(KeyCode::Char('s'))), None);
255        assert_eq!(
256            match_git_key(&k(KeyCode::Char('s'))),
257            Some(GitKeyAction::Stage)
258        );
259    }
260
261    #[test]
262    fn ctrl_r_and_ctrl_q_do_not_hijack() {
263        // Ctrl+R is the host's redraw; Ctrl+Q is the host's quit. Both
264        // must NOT be swallowed by the git overlay.
265        assert_eq!(match_git_key(&ctrl(KeyCode::Char('r'))), None);
266        assert_eq!(match_git_key(&ctrl(KeyCode::Char('q'))), None);
267        assert_eq!(
268            match_git_key(&k(KeyCode::Char('r'))),
269            Some(GitKeyAction::Refresh)
270        );
271        assert_eq!(
272            match_git_key(&k(KeyCode::Char('q'))),
273            Some(GitKeyAction::Close)
274        );
275    }
276}