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('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
202                Some(Action::MeasureLatency)
203            }
204            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
205            KeyCode::Backspace => Some(Action::FilterBackspace),
206            KeyCode::Char(c) => Some(Action::FilterInput(c)),
207            _ => None,
208        },
209        Mode::CalendarPicker => match key.code {
210            KeyCode::Esc => Some(Action::CloseCalendar),
211            KeyCode::Left => Some(Action::CalendarPrevDay),
212            KeyCode::Down => Some(Action::CalendarNextWeek),
213            KeyCode::Up => Some(Action::CalendarPrevWeek),
214            KeyCode::Right => Some(Action::CalendarNextDay),
215            KeyCode::Char('n') | KeyCode::Tab => Some(Action::CalendarNextMonth),
216            KeyCode::Char('p') | KeyCode::BackTab => Some(Action::CalendarPrevMonth),
217            KeyCode::Enter => Some(Action::CalendarSelect),
218            _ => None,
219        },
220        Mode::TabPicker => match key.code {
221            KeyCode::Esc => Some(Action::CloseMenu),
222            KeyCode::Down => Some(Action::NextItem),
223            KeyCode::Up => Some(Action::PrevItem),
224            KeyCode::Enter => Some(Action::Select),
225            KeyCode::Backspace => Some(Action::FilterBackspace),
226            KeyCode::Char(c) => Some(Action::FilterInput(c)),
227            _ => None,
228        },
229        Mode::SessionPicker => match key.code {
230            KeyCode::Esc => Some(Action::ExitFilterMode),
231            KeyCode::Char('i') => Some(Action::EnterFilterMode),
232            KeyCode::Down => Some(Action::NextItem),
233            KeyCode::Up => Some(Action::PrevItem),
234            KeyCode::Enter => Some(Action::LoadSession),
235            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
236            KeyCode::Backspace => Some(Action::FilterBackspace),
237            KeyCode::Char(c) => Some(Action::FilterInput(c)),
238            _ => None,
239        },
240        Mode::ProfilePicker => match key.code {
241            KeyCode::Esc => Some(Action::ExitFilterMode),
242            KeyCode::Char('i') => Some(Action::EnterFilterMode),
243            KeyCode::Down => Some(Action::NextItem),
244            KeyCode::Up => Some(Action::PrevItem),
245            KeyCode::Enter => Some(Action::Select),
246            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
247            KeyCode::Backspace => Some(Action::FilterBackspace),
248            KeyCode::Char(c) => Some(Action::FilterInput(c)),
249            _ => None,
250        },
251    }
252}
253
254#[derive(Debug, Clone, PartialEq)]
255pub enum Action {
256    Quit,
257    CloseService,
258    NextItem,
259    PrevItem,
260    NextPane,
261    PrevPane,
262    CollapseRow,
263    ExpandRow,
264    Select,
265    OpenSpaceMenu,
266    CloseMenu,
267    OpenServicePicker,
268    OpenCloudWatch,
269    OpenCloudWatchSplit,
270    OpenCloudWatchAlarms,
271    FilterInput(char),
272    FilterBackspace,
273    DeleteWord,
274    WordLeft,
275    WordRight,
276    OpenColumnSelector,
277    ToggleColumn,
278    NextPreferences,
279    PrevPreferences,
280    CloseColumnSelector,
281    StartFilter,
282    StartEventFilter,
283    ApplyFilter,
284    ToggleExactMatch,
285    ToggleShowExpired,
286    GoBack,
287    NextFilterFocus,
288    PrevFilterFocus,
289    ToggleFilterCheckbox,
290    CycleSortColumn,
291    ToggleSortDirection,
292    ScrollUp,
293    ScrollDown,
294    PageUp,
295    PageDown,
296    Refresh,
297    MeasureLatency,
298    RetryLoad,
299    Yank,
300    OpenInConsole,
301    OpenInBrowser,
302    ShowHelp,
303    OpenRegionPicker,
304    OpenCalendar,
305    CloseCalendar,
306    CalendarPrevDay,
307    CalendarNextDay,
308    CalendarPrevWeek,
309    CalendarNextWeek,
310    CalendarPrevMonth,
311    CalendarNextMonth,
312    CalendarSelect,
313    NextTab,
314    PrevTab,
315    NextDetailTab,
316    PrevDetailTab,
317    CloseTab,
318    OpenTabPicker,
319    OpenSessionPicker,
320    OpenProfilePicker,
321    LoadSession,
322    SaveSession,
323    CopyToClipboard,
324    EnterFilterMode,
325    ExitFilterMode,
326    Noop,
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_space_o_opens_service_menu() {
335        let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
336        let action = handle_key(key, Mode::SpaceMenu);
337        assert_eq!(action, Some(Action::OpenServicePicker));
338    }
339
340    #[test]
341    fn test_insights_input_accepts_chars() {
342        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
343        let action = handle_key(key, Mode::InsightsInput);
344        assert_eq!(action, Some(Action::FilterInput('a')));
345
346        let key2 = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE);
347        let action2 = handle_key(key2, Mode::InsightsInput);
348        assert_eq!(action2, Some(Action::FilterInput('1')));
349    }
350
351    #[test]
352    fn test_insights_input_esc_closes() {
353        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
354        let action = handle_key(key, Mode::InsightsInput);
355        assert_eq!(action, Some(Action::CloseMenu));
356    }
357
358    #[test]
359    fn test_service_menu_accepts_input() {
360        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
361        let action = handle_key(key, Mode::ServicePicker);
362        assert_eq!(action, Some(Action::FilterInput('c')));
363    }
364
365    #[test]
366    fn test_service_menu_navigation() {
367        let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
368        let action_down = handle_key(key_down, Mode::ServicePicker);
369        assert_eq!(action_down, Some(Action::NextItem));
370
371        let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
372        let action_up = handle_key(key_up, Mode::ServicePicker);
373        assert_eq!(action_up, Some(Action::PrevItem));
374    }
375
376    #[test]
377    fn test_service_menu_backspace() {
378        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
379        let action = handle_key(key, Mode::ServicePicker);
380        assert_eq!(action, Some(Action::FilterBackspace));
381    }
382
383    #[test]
384    fn test_ctrl_shift_n_next_tab() {
385        let key = KeyEvent::new(
386            KeyCode::Char('N'),
387            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
388        );
389        let action = handle_key(key, Mode::Normal);
390        assert_eq!(action, Some(Action::NextTab));
391    }
392
393    #[test]
394    fn test_ctrl_shift_p_prev_tab() {
395        let key = KeyEvent::new(
396            KeyCode::Char('P'),
397            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
398        );
399        let action = handle_key(key, Mode::Normal);
400        assert_eq!(action, Some(Action::PrevTab));
401    }
402
403    #[test]
404    fn test_space_c_close_tab() {
405        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
406        let action = handle_key(key, Mode::SpaceMenu);
407        assert_eq!(action, Some(Action::CloseService));
408    }
409
410    #[test]
411    fn test_space_b_window_picker() {
412        let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
413        let action = handle_key(key, Mode::SpaceMenu);
414        assert_eq!(action, Some(Action::OpenTabPicker));
415    }
416
417    #[test]
418    fn test_window_picker_navigation() {
419        let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
420        let action = handle_key(key_down, Mode::TabPicker);
421        assert_eq!(action, Some(Action::NextItem));
422
423        let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
424        let action_up = handle_key(key_up, Mode::TabPicker);
425        assert_eq!(action_up, Some(Action::PrevItem));
426    }
427
428    #[test]
429    fn test_window_picker_select() {
430        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
431        let action = handle_key(key, Mode::TabPicker);
432        assert_eq!(action, Some(Action::Select));
433    }
434
435    #[test]
436    fn test_space_opens_space_menu_in_normal_mode() {
437        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
438        let action = handle_key(key, Mode::Normal);
439        assert_eq!(action, Some(Action::OpenSpaceMenu));
440    }
441
442    #[test]
443    fn test_space_menu_o_opens_service_menu() {
444        let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
445        let action = handle_key(key, Mode::SpaceMenu);
446        assert_eq!(action, Some(Action::OpenServicePicker));
447    }
448
449    #[test]
450    fn test_ctrl_r_refreshes_profile_picker() {
451        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
452        let action = handle_key(key, Mode::ProfilePicker);
453        assert_eq!(action, Some(Action::Refresh));
454    }
455
456    #[test]
457    fn test_ctrl_r_refreshes_region_picker() {
458        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
459        let action = handle_key(key, Mode::RegionPicker);
460        assert_eq!(action, Some(Action::Refresh));
461    }
462
463    #[test]
464    fn test_ctrl_r_refreshes_session_picker() {
465        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
466        let action = handle_key(key, Mode::SessionPicker);
467        assert_eq!(action, Some(Action::Refresh));
468    }
469
470    #[test]
471    fn test_p_opens_column_selector() {
472        let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
473        let action = handle_key(key, Mode::Normal);
474        assert_eq!(
475            action,
476            Some(Action::OpenColumnSelector),
477            "p should open column selector (preferences)"
478        );
479    }
480
481    #[test]
482    fn test_ctrl_p_copies_to_clipboard() {
483        let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
484        let action = handle_key(key, Mode::Normal);
485        assert_eq!(
486            action,
487            Some(Action::CopyToClipboard),
488            "Ctrl+P should copy screen to clipboard (print)"
489        );
490    }
491
492    #[test]
493    fn test_y_yanks_selected_item() {
494        let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
495        let action = handle_key(key, Mode::Normal);
496        assert_eq!(
497            action,
498            Some(Action::Yank),
499            "y should yank (copy) selected item"
500        );
501    }
502
503    #[test]
504    fn test_space_toggles_checkbox_in_filter_input() {
505        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
506        let action = handle_key(key, Mode::FilterInput);
507        assert_eq!(
508            action,
509            Some(Action::ToggleFilterCheckbox),
510            "Space should toggle checkbox in FilterInput mode"
511        );
512    }
513
514    #[test]
515    fn test_space_not_added_to_filter_text() {
516        // Space should toggle checkbox, not be added to filter text
517        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
518        let action = handle_key(key, Mode::FilterInput);
519        assert_ne!(
520            action,
521            Some(Action::FilterInput(' ')),
522            "Space should not be added to filter text"
523        );
524    }
525}