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    QuitConfirm,
20}
21
22pub fn handle_key(key: KeyEvent, mode: Mode) -> Option<Action> {
23    match mode {
24        Mode::Normal => match key.code {
25            KeyCode::Char('q') => Some(Action::Quit),
26            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
27                Some(Action::ConfirmQuit)
28            }
29            KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
30                Some(Action::CloseService)
31            }
32            KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
33                Some(Action::OpenInConsole)
34            }
35            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
36            KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
37                Some(Action::PageUp)
38            }
39            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
40                Some(Action::PageDown)
41            }
42            KeyCode::Esc => Some(Action::GoBack),
43            KeyCode::Char('i') => Some(Action::StartFilter),
44            KeyCode::Char('c') => Some(Action::OpenCalendar),
45            KeyCode::Down => Some(Action::NextItem),
46            KeyCode::Up => Some(Action::PrevItem),
47            KeyCode::Right => Some(Action::ExpandRow),
48            KeyCode::Left => Some(Action::CollapseRow),
49            KeyCode::Tab => Some(Action::NextDetailTab),
50            KeyCode::BackTab => Some(Action::PrevDetailTab),
51            KeyCode::Enter => Some(Action::Select),
52            KeyCode::Char(' ') => Some(Action::OpenSpaceMenu),
53            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
54                Some(Action::CopyToClipboard)
55            }
56            KeyCode::Char('p') => Some(Action::OpenColumnSelector),
57            KeyCode::Char('e') => Some(Action::ToggleExactMatch),
58            KeyCode::Char('x') => Some(Action::ToggleShowExpired),
59            KeyCode::Char('s') => Some(Action::CycleSortColumn),
60            KeyCode::Char('o') => Some(Action::ToggleSortDirection),
61            KeyCode::Char('y') => Some(Action::Yank),
62            KeyCode::Char('[') => Some(Action::PrevTab),
63            KeyCode::Char(']') => Some(Action::NextTab),
64            KeyCode::Char('?') => Some(Action::ShowHelp),
65            KeyCode::Char('N')
66                if key.modifiers.contains(KeyModifiers::CONTROL)
67                    && key.modifiers.contains(KeyModifiers::SHIFT) =>
68            {
69                Some(Action::NextTab)
70            }
71            KeyCode::Char('P')
72                if key.modifiers.contains(KeyModifiers::CONTROL)
73                    && key.modifiers.contains(KeyModifiers::SHIFT) =>
74            {
75                Some(Action::PrevTab)
76            }
77            KeyCode::Char(c) if c.is_ascii_digit() => Some(Action::FilterInput(c)),
78            KeyCode::Char('P') => Some(Action::ApplyFilter),
79            _ => None,
80        },
81        Mode::SpaceMenu => match key.code {
82            KeyCode::Esc => Some(Action::CloseMenu),
83            KeyCode::Char('o') => Some(Action::OpenServicePicker),
84            KeyCode::Char('r') => Some(Action::OpenRegionPicker),
85            KeyCode::Char('p') => Some(Action::OpenProfilePicker),
86            KeyCode::Char('a') => Some(Action::OpenCloudWatchAlarms),
87            KeyCode::Char('c') => Some(Action::CloseService),
88            KeyCode::Char('b') | KeyCode::Char('t') => Some(Action::OpenTabPicker),
89            KeyCode::Char('s') => Some(Action::OpenSessionPicker),
90            KeyCode::Char('h') => Some(Action::ShowHelp),
91            _ => None,
92        },
93        Mode::ServicePicker => match key.code {
94            KeyCode::Esc => Some(Action::ExitFilterMode),
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) => 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::QuitConfirm => match key.code {
196            KeyCode::Char('y') | KeyCode::Enter => Some(Action::ConfirmQuit),
197            KeyCode::Char('n') | KeyCode::Esc | KeyCode::Char('q') => Some(Action::CancelQuit),
198            _ => None,
199        },
200        Mode::RegionPicker => match key.code {
201            KeyCode::Esc => Some(Action::ExitFilterMode),
202            KeyCode::Char('i') => Some(Action::EnterFilterMode),
203            KeyCode::Char('j') | KeyCode::Down => Some(Action::NextItem),
204            KeyCode::Char('k') | KeyCode::Up => Some(Action::PrevItem),
205            KeyCode::Enter => Some(Action::Select),
206            KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
207                Some(Action::MeasureLatency)
208            }
209            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
210            KeyCode::Backspace => Some(Action::FilterBackspace),
211            KeyCode::Char(c) => Some(Action::FilterInput(c)),
212            _ => None,
213        },
214        Mode::CalendarPicker => match key.code {
215            KeyCode::Esc => Some(Action::CloseCalendar),
216            KeyCode::Left => Some(Action::CalendarPrevDay),
217            KeyCode::Down => Some(Action::CalendarNextWeek),
218            KeyCode::Up => Some(Action::CalendarPrevWeek),
219            KeyCode::Right => Some(Action::CalendarNextDay),
220            KeyCode::Char('n') | KeyCode::Tab => Some(Action::CalendarNextMonth),
221            KeyCode::Char('p') | KeyCode::BackTab => Some(Action::CalendarPrevMonth),
222            KeyCode::Enter => Some(Action::CalendarSelect),
223            _ => None,
224        },
225        Mode::TabPicker => match key.code {
226            KeyCode::Esc => Some(Action::CloseMenu),
227            KeyCode::Down => Some(Action::NextItem),
228            KeyCode::Up => Some(Action::PrevItem),
229            KeyCode::Enter => Some(Action::Select),
230            KeyCode::Backspace => Some(Action::FilterBackspace),
231            KeyCode::Char(c) => Some(Action::FilterInput(c)),
232            _ => None,
233        },
234        Mode::SessionPicker => match key.code {
235            KeyCode::Esc => Some(Action::ExitFilterMode),
236            KeyCode::Char('i') => Some(Action::EnterFilterMode),
237            KeyCode::Down => Some(Action::NextItem),
238            KeyCode::Up => Some(Action::PrevItem),
239            KeyCode::Enter => Some(Action::LoadSession),
240            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
241            KeyCode::Backspace => Some(Action::FilterBackspace),
242            KeyCode::Char(c) => Some(Action::FilterInput(c)),
243            _ => None,
244        },
245        Mode::ProfilePicker => match key.code {
246            KeyCode::Esc => Some(Action::ExitFilterMode),
247            KeyCode::Char('i') => Some(Action::EnterFilterMode),
248            KeyCode::Down => Some(Action::NextItem),
249            KeyCode::Up => Some(Action::PrevItem),
250            KeyCode::Enter => Some(Action::Select),
251            KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => Some(Action::Refresh),
252            KeyCode::Backspace => Some(Action::FilterBackspace),
253            KeyCode::Char(c) => Some(Action::FilterInput(c)),
254            _ => None,
255        },
256    }
257}
258
259#[derive(Debug, Clone, PartialEq)]
260pub enum Action {
261    Quit,
262    ConfirmQuit,
263    CancelQuit,
264    CloseService,
265    NextItem,
266    PrevItem,
267    NextPane,
268    PrevPane,
269    CollapseRow,
270    ExpandRow,
271    Select,
272    OpenSpaceMenu,
273    CloseMenu,
274    OpenServicePicker,
275    OpenCloudWatch,
276    OpenCloudWatchSplit,
277    OpenCloudWatchAlarms,
278    FilterInput(char),
279    FilterBackspace,
280    DeleteWord,
281    WordLeft,
282    WordRight,
283    OpenColumnSelector,
284    ToggleColumn,
285    NextPreferences,
286    PrevPreferences,
287    CloseColumnSelector,
288    StartFilter,
289    StartEventFilter,
290    ApplyFilter,
291    ToggleExactMatch,
292    ToggleShowExpired,
293    GoBack,
294    NextFilterFocus,
295    PrevFilterFocus,
296    ToggleFilterCheckbox,
297    CycleSortColumn,
298    ToggleSortDirection,
299    ScrollUp,
300    ScrollDown,
301    PageUp,
302    PageDown,
303    Refresh,
304    MeasureLatency,
305    RetryLoad,
306    Yank,
307    OpenInConsole,
308    OpenInBrowser,
309    ShowHelp,
310    OpenRegionPicker,
311    OpenCalendar,
312    CloseCalendar,
313    CalendarPrevDay,
314    CalendarNextDay,
315    CalendarPrevWeek,
316    CalendarNextWeek,
317    CalendarPrevMonth,
318    CalendarNextMonth,
319    CalendarSelect,
320    NextTab,
321    PrevTab,
322    NextDetailTab,
323    PrevDetailTab,
324    CloseTab,
325    OpenTabPicker,
326    OpenSessionPicker,
327    OpenProfilePicker,
328    LoadSession,
329    SaveSession,
330    CopyToClipboard,
331    EnterFilterMode,
332    ExitFilterMode,
333    Noop,
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_space_o_opens_service_menu() {
342        let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
343        let action = handle_key(key, Mode::SpaceMenu);
344        assert_eq!(action, Some(Action::OpenServicePicker));
345    }
346
347    #[test]
348    fn test_insights_input_accepts_chars() {
349        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
350        let action = handle_key(key, Mode::InsightsInput);
351        assert_eq!(action, Some(Action::FilterInput('a')));
352
353        let key2 = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE);
354        let action2 = handle_key(key2, Mode::InsightsInput);
355        assert_eq!(action2, Some(Action::FilterInput('1')));
356    }
357
358    #[test]
359    fn test_insights_input_esc_closes() {
360        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
361        let action = handle_key(key, Mode::InsightsInput);
362        assert_eq!(action, Some(Action::CloseMenu));
363    }
364
365    #[test]
366    fn test_service_menu_accepts_input() {
367        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
368        let action = handle_key(key, Mode::ServicePicker);
369        assert_eq!(action, Some(Action::FilterInput('c')));
370    }
371
372    #[test]
373    fn test_service_menu_navigation() {
374        let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
375        let action_down = handle_key(key_down, Mode::ServicePicker);
376        assert_eq!(action_down, Some(Action::NextItem));
377
378        let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
379        let action_up = handle_key(key_up, Mode::ServicePicker);
380        assert_eq!(action_up, Some(Action::PrevItem));
381    }
382
383    #[test]
384    fn test_service_menu_backspace() {
385        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
386        let action = handle_key(key, Mode::ServicePicker);
387        assert_eq!(action, Some(Action::FilterBackspace));
388    }
389
390    #[test]
391    fn test_ctrl_shift_n_next_tab() {
392        let key = KeyEvent::new(
393            KeyCode::Char('N'),
394            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
395        );
396        let action = handle_key(key, Mode::Normal);
397        assert_eq!(action, Some(Action::NextTab));
398    }
399
400    #[test]
401    fn test_ctrl_shift_p_prev_tab() {
402        let key = KeyEvent::new(
403            KeyCode::Char('P'),
404            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
405        );
406        let action = handle_key(key, Mode::Normal);
407        assert_eq!(action, Some(Action::PrevTab));
408    }
409
410    #[test]
411    fn test_space_c_close_tab() {
412        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
413        let action = handle_key(key, Mode::SpaceMenu);
414        assert_eq!(action, Some(Action::CloseService));
415    }
416
417    #[test]
418    fn test_space_b_window_picker() {
419        let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
420        let action = handle_key(key, Mode::SpaceMenu);
421        assert_eq!(action, Some(Action::OpenTabPicker));
422    }
423
424    #[test]
425    fn test_window_picker_navigation() {
426        let key_down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
427        let action = handle_key(key_down, Mode::TabPicker);
428        assert_eq!(action, Some(Action::NextItem));
429
430        let key_up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
431        let action_up = handle_key(key_up, Mode::TabPicker);
432        assert_eq!(action_up, Some(Action::PrevItem));
433    }
434
435    #[test]
436    fn test_window_picker_select() {
437        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
438        let action = handle_key(key, Mode::TabPicker);
439        assert_eq!(action, Some(Action::Select));
440    }
441
442    #[test]
443    fn test_space_opens_space_menu_in_normal_mode() {
444        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
445        let action = handle_key(key, Mode::Normal);
446        assert_eq!(action, Some(Action::OpenSpaceMenu));
447    }
448
449    #[test]
450    fn test_space_menu_o_opens_service_menu() {
451        let key = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
452        let action = handle_key(key, Mode::SpaceMenu);
453        assert_eq!(action, Some(Action::OpenServicePicker));
454    }
455
456    #[test]
457    fn test_ctrl_r_refreshes_profile_picker() {
458        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
459        let action = handle_key(key, Mode::ProfilePicker);
460        assert_eq!(action, Some(Action::Refresh));
461    }
462
463    #[test]
464    fn test_ctrl_r_refreshes_region_picker() {
465        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
466        let action = handle_key(key, Mode::RegionPicker);
467        assert_eq!(action, Some(Action::Refresh));
468    }
469
470    #[test]
471    fn test_ctrl_r_refreshes_session_picker() {
472        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
473        let action = handle_key(key, Mode::SessionPicker);
474        assert_eq!(action, Some(Action::Refresh));
475    }
476
477    #[test]
478    fn test_p_opens_column_selector() {
479        let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
480        let action = handle_key(key, Mode::Normal);
481        assert_eq!(
482            action,
483            Some(Action::OpenColumnSelector),
484            "p should open column selector (preferences)"
485        );
486    }
487
488    #[test]
489    fn test_ctrl_p_copies_to_clipboard() {
490        let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
491        let action = handle_key(key, Mode::Normal);
492        assert_eq!(
493            action,
494            Some(Action::CopyToClipboard),
495            "Ctrl+P should copy screen to clipboard (print)"
496        );
497    }
498
499    #[test]
500    fn test_y_yanks_selected_item() {
501        let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
502        let action = handle_key(key, Mode::Normal);
503        assert_eq!(
504            action,
505            Some(Action::Yank),
506            "y should yank (copy) selected item"
507        );
508    }
509
510    #[test]
511    fn test_space_toggles_checkbox_in_filter_input() {
512        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
513        let action = handle_key(key, Mode::FilterInput);
514        assert_eq!(
515            action,
516            Some(Action::ToggleFilterCheckbox),
517            "Space should toggle checkbox in FilterInput mode"
518        );
519    }
520
521    #[test]
522    fn test_space_not_added_to_filter_text() {
523        // Space should toggle checkbox, not be added to filter text
524        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
525        let action = handle_key(key, Mode::FilterInput);
526        assert_ne!(
527            action,
528            Some(Action::FilterInput(' ')),
529            "Space should not be added to filter text"
530        );
531    }
532
533    #[test]
534    fn test_q_in_normal_mode_shows_quit_confirmation() {
535        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
536        let action = handle_key(key, Mode::Normal);
537        assert_eq!(
538            action,
539            Some(Action::Quit),
540            "q in Normal mode must trigger Quit (shows confirmation dialog)"
541        );
542    }
543
544    #[test]
545    fn test_ctrl_c_quits_immediately_without_confirmation() {
546        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
547        let action = handle_key(key, Mode::Normal);
548        assert_eq!(
549            action,
550            Some(Action::ConfirmQuit),
551            "Ctrl+C must quit immediately (ConfirmQuit, not Quit)"
552        );
553    }
554
555    #[test]
556    fn test_q_in_service_picker_shows_quit_confirmation() {
557        // 'q' in ServicePicker now dispatches FilterInput('q') so it can type 'q'
558        // when filter is active (e.g. typing "sqs"). When filter is NOT active,
559        // app.rs handles FilterInput('q') as QuitConfirm.
560        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
561        let action = handle_key(key, Mode::ServicePicker);
562        assert_eq!(
563            action,
564            Some(Action::FilterInput('q')),
565            "q in ServicePicker must dispatch FilterInput so it can type 'sqs' in filter"
566        );
567    }
568
569    #[test]
570    fn test_q_in_service_picker_not_filtering_triggers_quit() {
571        // When ServicePicker filter is NOT active, FilterInput('q') triggers QuitConfirm
572        use crate::app::App;
573        let mut app = App::new_without_client("default".to_string(), None);
574        app.mode = Mode::ServicePicker;
575        app.service_picker.filter_active = false; // not filtering
576
577        app.handle_action(Action::FilterInput('q'));
578
579        assert_eq!(
580            app.mode,
581            Mode::QuitConfirm,
582            "q when not filtering in ServicePicker must show quit confirmation"
583        );
584    }
585
586    #[test]
587    fn test_q_in_service_picker_while_filtering_types_q() {
588        // When ServicePicker filter IS active (user is typing), 'q' types into filter
589        use crate::app::App;
590        let mut app = App::new_without_client("default".to_string(), None);
591        app.mode = Mode::ServicePicker;
592        app.service_picker.filter_active = true; // filter is active (user pressed 'i')
593
594        app.handle_action(Action::FilterInput('q'));
595
596        assert_eq!(
597            app.mode,
598            Mode::ServicePicker,
599            "q while filtering must NOT quit"
600        );
601        assert_eq!(
602            app.service_picker.filter, "q",
603            "q while filtering must type 'q' into filter"
604        );
605    }
606
607    #[test]
608    fn test_i_in_service_picker_dispatches_filter_input() {
609        let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
610        let action = handle_key(key, Mode::ServicePicker);
611        assert_eq!(
612            action,
613            Some(Action::FilterInput('i')),
614            "i in ServicePicker must dispatch FilterInput('i')"
615        );
616    }
617
618    #[test]
619    fn test_i_in_service_picker_not_filtering_activates_filter() {
620        use crate::app::App;
621        let mut app = App::new_without_client("default".to_string(), None);
622        app.mode = Mode::ServicePicker;
623        app.service_picker.filter_active = false;
624
625        app.handle_action(Action::FilterInput('i'));
626
627        assert!(app.service_picker.filter_active);
628        assert_eq!(
629            app.service_picker.filter, "",
630            "i activates filter but must not type itself"
631        );
632    }
633
634    #[test]
635    fn test_i_in_service_picker_while_filtering_types_i() {
636        use crate::app::App;
637        let mut app = App::new_without_client("default".to_string(), None);
638        app.mode = Mode::ServicePicker;
639        app.service_picker.filter_active = true;
640        app.service_picker.filter = "sqs".to_string();
641
642        app.handle_action(Action::FilterInput('i'));
643
644        assert_eq!(
645            app.service_picker.filter, "sqsi",
646            "i while filtering must type 'i' into filter"
647        );
648        assert_eq!(
649            app.mode,
650            Mode::ServicePicker,
651            "mode must stay ServicePicker"
652        );
653    }
654
655    #[test]
656    fn test_service_filter_types_all_chars_in_sqs() {
657        use crate::app::App;
658        let mut app = App::new_without_client("default".to_string(), None);
659        app.mode = Mode::ServicePicker;
660        app.service_picker.filter_active = true;
661
662        for c in ['s', 'q', 's'] {
663            app.handle_action(Action::FilterInput(c));
664        }
665
666        assert_eq!(app.service_picker.filter, "sqs");
667        assert_eq!(app.mode, Mode::ServicePicker);
668    }
669
670    #[test]
671    fn test_any_char_auto_activates_filter_in_service_picker() {
672        // Typing any non-q char when not filtering auto-activates and types it
673        use crate::app::App;
674        let mut app = App::new_without_client("default".to_string(), None);
675        app.mode = Mode::ServicePicker;
676        app.service_picker.filter_active = false;
677
678        app.handle_action(Action::FilterInput('s'));
679
680        assert!(app.service_picker.filter_active);
681        assert_eq!(app.service_picker.filter, "s");
682    }
683
684    #[test]
685    fn test_quit_confirm_mode_y_confirms() {
686        let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
687        let action = handle_key(key, Mode::QuitConfirm);
688        assert_eq!(action, Some(Action::ConfirmQuit));
689    }
690
691    #[test]
692    fn test_quit_confirm_mode_enter_confirms() {
693        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
694        let action = handle_key(key, Mode::QuitConfirm);
695        assert_eq!(action, Some(Action::ConfirmQuit));
696    }
697
698    #[test]
699    fn test_quit_confirm_mode_n_cancels() {
700        let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
701        let action = handle_key(key, Mode::QuitConfirm);
702        assert_eq!(action, Some(Action::CancelQuit));
703    }
704
705    #[test]
706    fn test_quit_confirm_mode_esc_cancels() {
707        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
708        let action = handle_key(key, Mode::QuitConfirm);
709        assert_eq!(action, Some(Action::CancelQuit));
710    }
711
712    #[test]
713    fn test_quit_shows_confirmation_dialog_not_immediate_quit() {
714        use crate::app::{App, Service};
715        let mut app = App::new_without_client("default".to_string(), None);
716        app.current_service = Service::CloudWatchLogGroups;
717        app.service_selected = true;
718        app.mode = Mode::Normal;
719
720        app.handle_action(Action::Quit);
721
722        assert_eq!(
723            app.mode,
724            Mode::QuitConfirm,
725            "q must show confirmation dialog"
726        );
727        assert!(
728            app.running,
729            "app must still be running after q (before confirmation)"
730        );
731    }
732
733    #[test]
734    fn test_confirm_quit_actually_quits() {
735        use crate::app::App;
736        let mut app = App::new_without_client("default".to_string(), None);
737        app.mode = Mode::QuitConfirm;
738
739        app.handle_action(Action::ConfirmQuit);
740
741        assert!(!app.running, "ConfirmQuit must set running=false");
742    }
743
744    #[test]
745    fn test_cancel_quit_returns_to_normal() {
746        use crate::app::App;
747        let mut app = App::new_without_client("default".to_string(), None);
748        app.mode = Mode::QuitConfirm;
749
750        app.handle_action(Action::CancelQuit);
751
752        assert_eq!(
753            app.mode,
754            Mode::Normal,
755            "CancelQuit must return to Normal mode"
756        );
757        assert!(app.running, "app must still be running after cancel");
758    }
759}