Skip to main content

rusticity_term/
keymap.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3#[derive(Debug, Clone, Copy, PartialEq)]
4pub enum Mode {
5    Normal,
6    SpaceMenu,
7    ServicePicker,
8    ColumnSelector,
9    FilterInput,
10    EventFilterInput,
11    InsightsInput,
12    ErrorModal,
13    HelpModal,
14    RegionPicker,
15    ProfilePicker,
16    CalendarPicker,
17    TabPicker,
18    SessionPicker,
19}
20
21pub fn handle_key(key: KeyEvent, mode: Mode) -> Option<Action> {
22    match mode {
23        Mode::Normal => match key.code {
24            KeyCode::Char('q') => Some(Action::Quit),
25            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
26                Some(Action::Quit)
27            }
28            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
29                Some(Action::CloseService)
30            }
31            KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
32                Some(Action::OpenInConsole)
33            }
34            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
35            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
36                Some(Action::PageUp)
37            }
38            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
39                Some(Action::PageDown)
40            }
41            KeyCode::Esc => Some(Action::GoBack),
42            KeyCode::Char('i') => Some(Action::StartFilter),
43            KeyCode::Char('c') => Some(Action::OpenCalendar),
44            KeyCode::Down => Some(Action::NextItem),
45            KeyCode::Up => Some(Action::PrevItem),
46            KeyCode::Right => Some(Action::ExpandRow),
47            KeyCode::Left => Some(Action::CollapseRow),
48            KeyCode::Tab => Some(Action::NextDetailTab),
49            KeyCode::BackTab => Some(Action::PrevDetailTab),
50            KeyCode::Enter => Some(Action::Select),
51            KeyCode::Char(' ') => Some(Action::OpenSpaceMenu),
52            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
53                Some(Action::CopyToClipboard)
54            }
55            KeyCode::Char('p') => Some(Action::OpenColumnSelector),
56            KeyCode::Char('e') => Some(Action::ToggleExactMatch),
57            KeyCode::Char('x') => Some(Action::ToggleShowExpired),
58            KeyCode::Char('s') => Some(Action::CycleSortColumn),
59            KeyCode::Char('o') => Some(Action::ToggleSortDirection),
60            KeyCode::Char('y') => Some(Action::Yank),
61            KeyCode::Char('[') => Some(Action::PrevTab),
62            KeyCode::Char(']') => Some(Action::NextTab),
63            KeyCode::Char('?') => Some(Action::ShowHelp),
64            KeyCode::Char('N')
65                if key.modifiers.contains(KeyModifiers::CONTROL)
66                    && key.modifiers.contains(KeyModifiers::SHIFT) =>
67            {
68                Some(Action::NextTab)
69            }
70            KeyCode::Char('P')
71                if key.modifiers.contains(KeyModifiers::CONTROL)
72                    && key.modifiers.contains(KeyModifiers::SHIFT) =>
73            {
74                Some(Action::PrevTab)
75            }
76            KeyCode::Char(c) if c.is_ascii_digit() => Some(Action::FilterInput(c)),
77            KeyCode::Char('P') => Some(Action::ApplyFilter),
78            _ => None,
79        },
80        Mode::SpaceMenu => match key.code {
81            KeyCode::Esc => Some(Action::CloseMenu),
82            KeyCode::Char('o') => Some(Action::OpenServicePicker),
83            KeyCode::Char('r') => Some(Action::OpenRegionPicker),
84            KeyCode::Char('p') => Some(Action::OpenProfilePicker),
85            KeyCode::Char('a') => Some(Action::OpenCloudWatchAlarms),
86            KeyCode::Char('c') => Some(Action::CloseService),
87            KeyCode::Char('b') | KeyCode::Char('t') => Some(Action::OpenTabPicker),
88            KeyCode::Char('s') => Some(Action::OpenSessionPicker),
89            KeyCode::Char('h') => Some(Action::ShowHelp),
90            _ => None,
91        },
92        Mode::ServicePicker => match key.code {
93            KeyCode::Esc => Some(Action::ExitFilterMode),
94            KeyCode::Char('i') if key.modifiers.is_empty() => Some(Action::EnterFilterMode),
95            KeyCode::Down => Some(Action::NextItem),
96            KeyCode::Up => Some(Action::PrevItem),
97            KeyCode::Enter => Some(Action::Select),
98            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
99                Some(Action::DeleteWord)
100            }
101            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
102            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
103            KeyCode::Char(c) if c != 'i' => Some(Action::FilterInput(c)),
104            KeyCode::Backspace => Some(Action::FilterBackspace),
105            _ => None,
106        },
107        Mode::ColumnSelector => match key.code {
108            KeyCode::Esc => Some(Action::CloseColumnSelector),
109            KeyCode::Down => Some(Action::NextItem),
110            KeyCode::Up => Some(Action::PrevItem),
111            KeyCode::Char(' ') | KeyCode::Enter => Some(Action::ToggleColumn),
112            KeyCode::Tab => Some(Action::NextPreferences),
113            KeyCode::BackTab => Some(Action::PrevPreferences),
114            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
115                Some(Action::PageDown)
116            }
117            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
118                Some(Action::PageUp)
119            }
120            _ => None,
121        },
122        Mode::FilterInput => match key.code {
123            KeyCode::Esc => Some(Action::CloseMenu),
124            KeyCode::Enter => Some(Action::ApplyFilter),
125            KeyCode::BackTab => Some(Action::PrevFilterFocus),
126            KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
127                Some(Action::PrevFilterFocus)
128            }
129            KeyCode::Tab => Some(Action::NextFilterFocus),
130            KeyCode::Up => Some(Action::PrevItem),
131            KeyCode::Down => Some(Action::NextItem),
132            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
133            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
134            KeyCode::Left => Some(Action::PageUp),
135            KeyCode::Right => Some(Action::PageDown),
136            KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
137            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
138                Some(Action::DeleteWord)
139            }
140            KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
141            KeyCode::Backspace => Some(Action::FilterBackspace),
142            _ => None,
143        },
144        Mode::EventFilterInput => match key.code {
145            KeyCode::Esc => Some(Action::CloseMenu),
146            KeyCode::Enter => Some(Action::ApplyFilter),
147            KeyCode::BackTab => Some(Action::PrevFilterFocus),
148            KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
149                Some(Action::PrevFilterFocus)
150            }
151            KeyCode::Tab => Some(Action::NextFilterFocus),
152            KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
153            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
154                Some(Action::DeleteWord)
155            }
156            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
157            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
158            KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
159            KeyCode::Backspace => Some(Action::FilterBackspace),
160            _ => None,
161        },
162        Mode::InsightsInput => match key.code {
163            KeyCode::Esc => Some(Action::CloseMenu),
164            KeyCode::Enter => Some(Action::Select),
165            KeyCode::Tab => Some(Action::NextFilterFocus),
166            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
167                Some(Action::Refresh)
168            }
169            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
170                Some(Action::DeleteWord)
171            }
172            KeyCode::Left if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordLeft),
173            KeyCode::Right if key.modifiers.contains(KeyModifiers::ALT) => Some(Action::WordRight),
174            KeyCode::Down => Some(Action::NextItem),
175            KeyCode::Up => Some(Action::PrevItem),
176            KeyCode::Char(' ') => Some(Action::ToggleFilterCheckbox),
177            KeyCode::Char(c) if c != ' ' => Some(Action::FilterInput(c)),
178            KeyCode::Backspace => Some(Action::FilterBackspace),
179            _ => None,
180        },
181        Mode::ErrorModal => match key.code {
182            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
183                Some(Action::RetryLoad)
184            }
185            KeyCode::Char('y') => Some(Action::Yank),
186            KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit),
187            _ => None,
188        },
189        Mode::HelpModal => match key.code {
190            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?') => {
191                Some(Action::CloseMenu)
192            }
193            _ => None,
194        },
195        Mode::RegionPicker => match key.code {
196            KeyCode::Esc => Some(Action::ExitFilterMode),
197            KeyCode::Char('i') => Some(Action::EnterFilterMode),
198            KeyCode::Char('j') | KeyCode::Down => Some(Action::NextItem),
199            KeyCode::Char('k') | KeyCode::Up => Some(Action::PrevItem),
200            KeyCode::Enter => Some(Action::Select),
201            KeyCode::Char('s') => Some(Action::Refresh),
202            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
203            KeyCode::Backspace => Some(Action::FilterBackspace),
204            KeyCode::Char(c) => Some(Action::FilterInput(c)),
205            _ => None,
206        },
207        Mode::CalendarPicker => match key.code {
208            KeyCode::Esc => Some(Action::CloseCalendar),
209            KeyCode::Left => Some(Action::CalendarPrevDay),
210            KeyCode::Down => Some(Action::CalendarNextWeek),
211            KeyCode::Up => Some(Action::CalendarPrevWeek),
212            KeyCode::Right => Some(Action::CalendarNextDay),
213            KeyCode::Char('n') | KeyCode::Tab => Some(Action::CalendarNextMonth),
214            KeyCode::Char('p') | KeyCode::BackTab => Some(Action::CalendarPrevMonth),
215            KeyCode::Enter => Some(Action::CalendarSelect),
216            _ => None,
217        },
218        Mode::TabPicker => match key.code {
219            KeyCode::Esc => Some(Action::CloseMenu),
220            KeyCode::Down => Some(Action::NextItem),
221            KeyCode::Up => Some(Action::PrevItem),
222            KeyCode::Enter => Some(Action::Select),
223            KeyCode::Backspace => Some(Action::FilterBackspace),
224            KeyCode::Char(c) => Some(Action::FilterInput(c)),
225            _ => None,
226        },
227        Mode::SessionPicker => match key.code {
228            KeyCode::Esc => Some(Action::ExitFilterMode),
229            KeyCode::Char('i') => Some(Action::EnterFilterMode),
230            KeyCode::Down => Some(Action::NextItem),
231            KeyCode::Up => Some(Action::PrevItem),
232            KeyCode::Enter => Some(Action::LoadSession),
233            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
234            KeyCode::Backspace => Some(Action::FilterBackspace),
235            KeyCode::Char(c) => Some(Action::FilterInput(c)),
236            _ => None,
237        },
238        Mode::ProfilePicker => match key.code {
239            KeyCode::Esc => Some(Action::ExitFilterMode),
240            KeyCode::Char('i') => Some(Action::EnterFilterMode),
241            KeyCode::Down => Some(Action::NextItem),
242            KeyCode::Up => Some(Action::PrevItem),
243            KeyCode::Enter => Some(Action::Select),
244            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
245            KeyCode::Backspace => Some(Action::FilterBackspace),
246            KeyCode::Char(c) => Some(Action::FilterInput(c)),
247            _ => None,
248        },
249    }
250}
251
252#[derive(Debug, Clone, PartialEq)]
253pub enum Action {
254    Quit,
255    CloseService,
256    NextItem,
257    PrevItem,
258    NextPane,
259    PrevPane,
260    CollapseRow,
261    ExpandRow,
262    Select,
263    OpenSpaceMenu,
264    CloseMenu,
265    OpenServicePicker,
266    OpenCloudWatch,
267    OpenCloudWatchSplit,
268    OpenCloudWatchAlarms,
269    FilterInput(char),
270    FilterBackspace,
271    DeleteWord,
272    WordLeft,
273    WordRight,
274    OpenColumnSelector,
275    ToggleColumn,
276    NextPreferences,
277    PrevPreferences,
278    CloseColumnSelector,
279    StartFilter,
280    StartEventFilter,
281    ApplyFilter,
282    ToggleExactMatch,
283    ToggleShowExpired,
284    GoBack,
285    NextFilterFocus,
286    PrevFilterFocus,
287    ToggleFilterCheckbox,
288    CycleSortColumn,
289    ToggleSortDirection,
290    ScrollUp,
291    ScrollDown,
292    PageUp,
293    PageDown,
294    Refresh,
295    RetryLoad,
296    Yank,
297    OpenInConsole,
298    OpenInBrowser,
299    ShowHelp,
300    OpenRegionPicker,
301    OpenCalendar,
302    CloseCalendar,
303    CalendarPrevDay,
304    CalendarNextDay,
305    CalendarPrevWeek,
306    CalendarNextWeek,
307    CalendarPrevMonth,
308    CalendarNextMonth,
309    CalendarSelect,
310    NextTab,
311    PrevTab,
312    NextDetailTab,
313    PrevDetailTab,
314    CloseTab,
315    OpenTabPicker,
316    OpenSessionPicker,
317    OpenProfilePicker,
318    LoadSession,
319    SaveSession,
320    CopyToClipboard,
321    EnterFilterMode,
322    ExitFilterMode,
323    Noop,
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_space_o_opens_service_menu() {
332        let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
333        let action = handle_key(key, Mode::SpaceMenu);
334        assert_eq!(action, Some(Action::OpenServicePicker));
335    }
336
337    #[test]
338    fn test_insights_input_accepts_chars() {
339        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
340        let action = handle_key(key, Mode::InsightsInput);
341        assert_eq!(action, Some(Action::FilterInput('a')));
342
343        let key2 = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE);
344        let action2 = handle_key(key2, Mode::InsightsInput);
345        assert_eq!(action2, Some(Action::FilterInput('1')));
346    }
347
348    #[test]
349    fn test_insights_input_esc_closes() {
350        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
351        let action = handle_key(key, Mode::InsightsInput);
352        assert_eq!(action, Some(Action::CloseMenu));
353    }
354
355    #[test]
356    fn test_service_menu_accepts_input() {
357        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
358        let action = handle_key(key, Mode::ServicePicker);
359        assert_eq!(action, Some(Action::FilterInput('c')));
360    }
361
362    #[test]
363    fn test_service_menu_navigation() {
364        let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
365        let action_down = handle_key(key_down, Mode::ServicePicker);
366        assert_eq!(action_down, Some(Action::NextItem));
367
368        let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
369        let action_up = handle_key(key_up, Mode::ServicePicker);
370        assert_eq!(action_up, Some(Action::PrevItem));
371    }
372
373    #[test]
374    fn test_service_menu_backspace() {
375        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
376        let action = handle_key(key, Mode::ServicePicker);
377        assert_eq!(action, Some(Action::FilterBackspace));
378    }
379
380    #[test]
381    fn test_ctrl_shift_n_next_tab() {
382        let key = KeyEvent::new(
383            KeyCode::Char('N'),
384            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
385        );
386        let action = handle_key(key, Mode::Normal);
387        assert_eq!(action, Some(Action::NextTab));
388    }
389
390    #[test]
391    fn test_ctrl_shift_p_prev_tab() {
392        let key = KeyEvent::new(
393            KeyCode::Char('P'),
394            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
395        );
396        let action = handle_key(key, Mode::Normal);
397        assert_eq!(action, Some(Action::PrevTab));
398    }
399
400    #[test]
401    fn test_space_c_close_tab() {
402        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
403        let action = handle_key(key, Mode::SpaceMenu);
404        assert_eq!(action, Some(Action::CloseService));
405    }
406
407    #[test]
408    fn test_space_b_window_picker() {
409        let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
410        let action = handle_key(key, Mode::SpaceMenu);
411        assert_eq!(action, Some(Action::OpenTabPicker));
412    }
413
414    #[test]
415    fn test_window_picker_navigation() {
416        let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
417        let action = handle_key(key_down, Mode::TabPicker);
418        assert_eq!(action, Some(Action::NextItem));
419
420        let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
421        let action_up = handle_key(key_up, Mode::TabPicker);
422        assert_eq!(action_up, Some(Action::PrevItem));
423    }
424
425    #[test]
426    fn test_window_picker_select() {
427        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
428        let action = handle_key(key, Mode::TabPicker);
429        assert_eq!(action, Some(Action::Select));
430    }
431
432    #[test]
433    fn test_space_opens_space_menu_in_normal_mode() {
434        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
435        let action = handle_key(key, Mode::Normal);
436        assert_eq!(action, Some(Action::OpenSpaceMenu));
437    }
438
439    #[test]
440    fn test_space_menu_o_opens_service_menu() {
441        let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
442        let action = handle_key(key, Mode::SpaceMenu);
443        assert_eq!(action, Some(Action::OpenServicePicker));
444    }
445
446    #[test]
447    fn test_ctrl_r_refreshes_profile_picker() {
448        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
449        let action = handle_key(key, Mode::ProfilePicker);
450        assert_eq!(action, Some(Action::Refresh));
451    }
452
453    #[test]
454    fn test_ctrl_r_refreshes_region_picker() {
455        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
456        let action = handle_key(key, Mode::RegionPicker);
457        assert_eq!(action, Some(Action::Refresh));
458    }
459
460    #[test]
461    fn test_ctrl_r_refreshes_session_picker() {
462        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
463        let action = handle_key(key, Mode::SessionPicker);
464        assert_eq!(action, Some(Action::Refresh));
465    }
466
467    #[test]
468    fn test_p_opens_column_selector() {
469        let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
470        let action = handle_key(key, Mode::Normal);
471        assert_eq!(
472            action,
473            Some(Action::OpenColumnSelector),
474            "p should open column selector (preferences)"
475        );
476    }
477
478    #[test]
479    fn test_ctrl_p_copies_to_clipboard() {
480        let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
481        let action = handle_key(key, Mode::Normal);
482        assert_eq!(
483            action,
484            Some(Action::CopyToClipboard),
485            "Ctrl+P should copy screen to clipboard (print)"
486        );
487    }
488
489    #[test]
490    fn test_y_yanks_selected_item() {
491        let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
492        let action = handle_key(key, Mode::Normal);
493        assert_eq!(
494            action,
495            Some(Action::Yank),
496            "y should yank (copy) selected item"
497        );
498    }
499
500    #[test]
501    fn test_space_toggles_checkbox_in_filter_input() {
502        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
503        let action = handle_key(key, Mode::FilterInput);
504        assert_eq!(
505            action,
506            Some(Action::ToggleFilterCheckbox),
507            "Space should toggle checkbox in FilterInput mode"
508        );
509    }
510
511    #[test]
512    fn test_space_not_added_to_filter_text() {
513        // Space should toggle checkbox, not be added to filter text
514        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
515        let action = handle_key(key, Mode::FilterInput);
516        assert_ne!(
517            action,
518            Some(Action::FilterInput(' ')),
519            "Space should not be added to filter text"
520        );
521    }
522}