Skip to main content

putzen_cli/caches/tui/
keys.rs

1//! Map crossterm KeyEvents to TUI Msg.
2
3use super::Msg;
4use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5
6#[derive(Copy, Clone, Debug, Eq, PartialEq)]
7pub enum ModalKind {
8    None,
9    DeleteConfirm,
10    ActiveMark,
11    FilterEdit,
12}
13
14pub fn key_to_msg(k: KeyEvent, modal: ModalKind, focus_right: bool) -> Option<Msg> {
15    use KeyCode::*;
16    match modal {
17        ModalKind::DeleteConfirm => match (k.code, k.modifiers) {
18            (Char('y'), KeyModifiers::NONE) | (Enter, _) => Some(Msg::ConfirmDelete),
19            (Char('n'), KeyModifiers::NONE) | (Esc, _) => Some(Msg::CancelDelete),
20            _ => None,
21        },
22        ModalKind::ActiveMark => match (k.code, k.modifiers) {
23            (Char('y'), KeyModifiers::NONE) | (Enter, _) => Some(Msg::ConfirmActiveMark),
24            (Char('n'), KeyModifiers::NONE) | (Esc, _) => Some(Msg::CancelActiveMark),
25            _ => None,
26        },
27        ModalKind::FilterEdit => match (k.code, k.modifiers) {
28            (Enter, _) => Some(Msg::FilterApply),
29            (Esc, _) => Some(Msg::FilterCancel),
30            (Backspace, _) => Some(Msg::FilterBackspace),
31            // Accept printable chars with NONE or SHIFT modifiers (uppercase).
32            (Char(c), m)
33                if (m == KeyModifiers::NONE || m == KeyModifiers::SHIFT) && !c.is_control() =>
34            {
35                Some(Msg::FilterChar(c))
36            }
37            _ => None,
38        },
39        ModalKind::None if focus_right => match (k.code, k.modifiers) {
40            // Right pane focus: only the file-list scrollers + focus toggle
41            // + quit are live. Mark/sort/drill/delete are out of scope here.
42            (Up, _) | (Char('k'), KeyModifiers::NONE) => Some(Msg::MoveUp),
43            (Down, _) | (Char('j'), KeyModifiers::NONE) => Some(Msg::MoveDown),
44            (Tab, _) | (BackTab, _) | (Esc, _) => Some(Msg::ToggleFocus),
45            (Char('q'), KeyModifiers::NONE) => Some(Msg::RequestQuit),
46            _ => None,
47        },
48        ModalKind::None => match (k.code, k.modifiers) {
49            (Up, _) | (Char('k'), KeyModifiers::NONE) => Some(Msg::MoveUp),
50            (Down, _) | (Char('j'), KeyModifiers::NONE) => Some(Msg::MoveDown),
51            (Right, _) | (Char('l'), KeyModifiers::NONE) | (Enter, _) => Some(Msg::DrillIn),
52            (Left, _) | (Char('h'), KeyModifiers::NONE) | (Esc, _) | (Backspace, _) => {
53                Some(Msg::DrillOut)
54            }
55            (Char(' '), _) => Some(Msg::ToggleMark),
56            (Char('m'), KeyModifiers::NONE) => Some(Msg::MarkDownToCursor),
57            (Char('s'), KeyModifiers::NONE) => Some(Msg::CycleSort),
58            (Char('d'), KeyModifiers::NONE) => Some(Msg::DeletePressed),
59            (Char('/'), KeyModifiers::NONE) => Some(Msg::FilterStart),
60            (Char('*'), _) => Some(Msg::MarkAllVisible),
61            (Tab, _) | (BackTab, _) => Some(Msg::ToggleFocus),
62            (Char('q'), KeyModifiers::NONE) => Some(Msg::RequestQuit),
63            _ => None,
64        },
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
72
73    fn k(code: KeyCode) -> KeyEvent {
74        KeyEvent::new(code, KeyModifiers::NONE)
75    }
76
77    #[test]
78    fn arrows_map_to_movement() {
79        assert!(matches!(
80            key_to_msg(k(KeyCode::Up), ModalKind::None, false),
81            Some(Msg::MoveUp)
82        ));
83        assert!(matches!(
84            key_to_msg(k(KeyCode::Down), ModalKind::None, false),
85            Some(Msg::MoveDown)
86        ));
87    }
88    #[test]
89    fn vim_keys_map_to_movement() {
90        assert!(matches!(
91            key_to_msg(k(KeyCode::Char('j')), ModalKind::None, false),
92            Some(Msg::MoveDown)
93        ));
94        assert!(matches!(
95            key_to_msg(k(KeyCode::Char('k')), ModalKind::None, false),
96            Some(Msg::MoveUp)
97        ));
98        assert!(matches!(
99            key_to_msg(k(KeyCode::Char('l')), ModalKind::None, false),
100            Some(Msg::DrillIn)
101        ));
102        assert!(matches!(
103            key_to_msg(k(KeyCode::Char('h')), ModalKind::None, false),
104            Some(Msg::DrillOut)
105        ));
106    }
107    #[test]
108    fn enter_is_drill_in() {
109        assert!(matches!(
110            key_to_msg(k(KeyCode::Enter), ModalKind::None, false),
111            Some(Msg::DrillIn)
112        ));
113    }
114    #[test]
115    fn esc_is_drill_out() {
116        assert!(matches!(
117            key_to_msg(k(KeyCode::Esc), ModalKind::None, false),
118            Some(Msg::DrillOut)
119        ));
120    }
121    #[test]
122    fn backspace_is_drill_out() {
123        assert!(matches!(
124            key_to_msg(k(KeyCode::Backspace), ModalKind::None, false),
125            Some(Msg::DrillOut)
126        ));
127    }
128    #[test]
129    fn q_requests_quit() {
130        assert!(matches!(
131            key_to_msg(k(KeyCode::Char('q')), ModalKind::None, false),
132            Some(Msg::RequestQuit)
133        ));
134    }
135    #[test]
136    fn d_in_normal_mode_requests_delete() {
137        assert!(matches!(
138            key_to_msg(k(KeyCode::Char('d')), ModalKind::None, false),
139            Some(Msg::DeletePressed)
140        ));
141    }
142    #[test]
143    fn y_in_modal_confirms_delete() {
144        assert!(matches!(
145            key_to_msg(k(KeyCode::Char('y')), ModalKind::DeleteConfirm, false),
146            Some(Msg::ConfirmDelete)
147        ));
148    }
149    #[test]
150    fn n_in_modal_cancels_delete() {
151        assert!(matches!(
152            key_to_msg(k(KeyCode::Char('n')), ModalKind::DeleteConfirm, false),
153            Some(Msg::CancelDelete)
154        ));
155    }
156    #[test]
157    fn y_in_active_modal_confirms_active_mark() {
158        assert!(matches!(
159            key_to_msg(k(KeyCode::Char('y')), ModalKind::ActiveMark, false),
160            Some(Msg::ConfirmActiveMark)
161        ));
162    }
163    #[test]
164    fn n_in_active_modal_cancels_active_mark() {
165        assert!(matches!(
166            key_to_msg(k(KeyCode::Char('n')), ModalKind::ActiveMark, false),
167            Some(Msg::CancelActiveMark)
168        ));
169    }
170
171    #[test]
172    fn right_focus_swallows_mark_sort_delete() {
173        // While the right pane is focused, those left-pane-only actions are
174        // ignored so the user doesn't accidentally mark or delete from a
175        // file-list scroll session.
176        assert!(key_to_msg(k(KeyCode::Char(' ')), ModalKind::None, true).is_none());
177        assert!(key_to_msg(k(KeyCode::Char('m')), ModalKind::None, true).is_none());
178        assert!(key_to_msg(k(KeyCode::Char('s')), ModalKind::None, true).is_none());
179        assert!(key_to_msg(k(KeyCode::Char('d')), ModalKind::None, true).is_none());
180    }
181
182    #[test]
183    fn slash_starts_filter() {
184        assert!(matches!(
185            key_to_msg(k(KeyCode::Char('/')), ModalKind::None, false),
186            Some(Msg::FilterStart)
187        ));
188    }
189
190    #[test]
191    fn star_marks_all_visible() {
192        assert!(matches!(
193            key_to_msg(k(KeyCode::Char('*')), ModalKind::None, false),
194            Some(Msg::MarkAllVisible)
195        ));
196    }
197
198    #[test]
199    fn filter_edit_routes_text_input() {
200        assert!(matches!(
201            key_to_msg(k(KeyCode::Char('a')), ModalKind::FilterEdit, false),
202            Some(Msg::FilterChar('a'))
203        ));
204        assert!(matches!(
205            key_to_msg(k(KeyCode::Backspace), ModalKind::FilterEdit, false),
206            Some(Msg::FilterBackspace)
207        ));
208        assert!(matches!(
209            key_to_msg(k(KeyCode::Enter), ModalKind::FilterEdit, false),
210            Some(Msg::FilterApply)
211        ));
212        assert!(matches!(
213            key_to_msg(k(KeyCode::Esc), ModalKind::FilterEdit, false),
214            Some(Msg::FilterCancel)
215        ));
216    }
217
218    #[test]
219    fn right_focus_keeps_movement_focus_quit() {
220        assert!(matches!(
221            key_to_msg(k(KeyCode::Up), ModalKind::None, true),
222            Some(Msg::MoveUp)
223        ));
224        assert!(matches!(
225            key_to_msg(k(KeyCode::Down), ModalKind::None, true),
226            Some(Msg::MoveDown)
227        ));
228        assert!(matches!(
229            key_to_msg(k(KeyCode::Tab), ModalKind::None, true),
230            Some(Msg::ToggleFocus)
231        ));
232        assert!(matches!(
233            key_to_msg(k(KeyCode::BackTab), ModalKind::None, true),
234            Some(Msg::ToggleFocus)
235        ));
236        assert!(matches!(
237            key_to_msg(k(KeyCode::Esc), ModalKind::None, true),
238            Some(Msg::ToggleFocus)
239        ));
240        assert!(matches!(
241            key_to_msg(k(KeyCode::Char('q')), ModalKind::None, true),
242            Some(Msg::RequestQuit)
243        ));
244    }
245}