tui_vision/menus/
events.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3use super::{MenuBar, MenuItem};
4
5/// Direction for submenu navigation.
6#[derive(Debug, Clone, Copy)]
7enum SubmenuNavDirection {
8    Up,
9    Down,
10}
11
12/// Result of handling a key event in the menu system.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum MenuEventResult {
15    /// Event was not handled by the menu system.
16    NotHandled,
17    /// Event was handled but no specific action occurred.
18    Handled,
19    /// A menu was opened.
20    MenuOpened { menu_index: usize },
21    /// A menu was closed.
22    MenuClosed,
23    /// Navigation occurred within the menu.
24    NavigationChanged,
25    /// A menu item was selected.
26    ItemSelected { command: String },
27    /// A submenu was opened.
28    SubmenuOpened { submenu_label: String },
29    /// A submenu was closed.
30    SubmenuClosed { submenu_label: String },
31}
32
33impl MenuBar {
34    /// Handles a keyboard event for the menu system.
35    ///
36    /// Returns a `MenuEventResult` indicating what happened as a result of the key press.
37    /// The caller can use this to update status messages, handle commands, etc.
38    pub fn handle_key_event(&mut self, key: KeyEvent) -> MenuEventResult {
39        match key.code {
40            // Menu activation with Alt/Ctrl key combinations
41            KeyCode::Char(c)
42                if key.modifiers.contains(KeyModifiers::ALT)
43                    || key.modifiers.contains(KeyModifiers::CONTROL) =>
44            {
45                self.handle_menu_hotkey(c)
46            }
47
48            // Arrow key navigation
49            KeyCode::Left => self.handle_left_arrow(),
50            KeyCode::Right => self.handle_right_arrow(),
51            KeyCode::Down => self.handle_down_arrow(),
52            KeyCode::Up => self.handle_up_arrow(),
53
54            // Enter to select item or open submenu
55            KeyCode::Enter => self.handle_enter(),
56
57            // Escape to close menu
58            KeyCode::Esc => self.handle_escape(),
59
60            // Tab navigation
61            KeyCode::Tab => self.handle_tab(key.modifiers.contains(KeyModifiers::SHIFT)),
62
63            // Space bar to activate menu system
64            KeyCode::Char(' ') => self.handle_space(),
65
66            // Direct hotkey access (without modifiers)
67            KeyCode::Char(c) => self.handle_item_hotkey(c),
68
69            _ => MenuEventResult::NotHandled,
70        }
71    }
72
73    /// Helper to get the currently focused submenu if any.
74    fn get_focused_submenu_mut(&mut self) -> Option<&mut super::SubMenuItem> {
75        let menu = self.opened_menu_mut()?;
76        let focused_index = menu.focused_item?;
77        match menu.items.get_mut(focused_index)? {
78            MenuItem::SubMenu(submenu) => Some(submenu),
79            _ => None,
80        }
81    }
82
83    /// Helper to check if we're currently in an open submenu.
84    fn is_in_open_submenu(&self) -> bool {
85        if let Some(menu) = self.opened_menu() {
86            if let Some(focused_index) = menu.focused_item {
87                if let Some(MenuItem::SubMenu(submenu)) = menu.items.get(focused_index) {
88                    return submenu.is_open;
89                }
90            }
91        }
92        false
93    }
94
95    /// Helper to navigate within a submenu.
96    fn navigate_submenu(&mut self, direction: SubmenuNavDirection) -> MenuEventResult {
97        let submenu = match self.get_focused_submenu_mut() {
98            Some(submenu) if submenu.is_open => submenu,
99            _ => return MenuEventResult::NotHandled,
100        };
101
102        match direction {
103            SubmenuNavDirection::Down => {
104                if let Some(current) = submenu.focused_item {
105                    let next =
106                        submenu
107                            .items
108                            .iter()
109                            .enumerate()
110                            .skip(current + 1)
111                            .find_map(|(i, item)| {
112                                if !matches!(item, MenuItem::Separator(_)) {
113                                    Some(i)
114                                } else {
115                                    None
116                                }
117                            });
118                    submenu.focused_item = next.or(submenu.focused_item);
119                } else {
120                    submenu.focused_item = submenu
121                        .items
122                        .iter()
123                        .position(|item| !matches!(item, MenuItem::Separator(_)));
124                }
125            }
126            SubmenuNavDirection::Up => {
127                if let Some(current) = submenu.focused_item {
128                    if current > 0 {
129                        let prev = submenu
130                            .items
131                            .iter()
132                            .enumerate()
133                            .take(current)
134                            .rev()
135                            .find_map(|(i, item)| {
136                                if !matches!(item, MenuItem::Separator(_)) {
137                                    Some(i)
138                                } else {
139                                    None
140                                }
141                            });
142                        submenu.focused_item = prev.or(submenu.focused_item);
143                    }
144                }
145            }
146        }
147        MenuEventResult::NavigationChanged
148    }
149
150    /// Helper to handle submenu item selection.
151    fn handle_submenu_item_selection(&mut self) -> Option<MenuEventResult> {
152        let submenu = self.get_focused_submenu_mut()?;
153        if !submenu.is_open {
154            return None;
155        }
156
157        let submenu_focused = submenu.focused_item?;
158        let submenu_item = submenu.items.get(submenu_focused)?;
159
160        match submenu_item {
161            MenuItem::Action(action) => {
162                let command = action.command.to_string();
163                self.close_menu();
164                Some(MenuEventResult::ItemSelected { command })
165            }
166            _ => Some(MenuEventResult::NotHandled),
167        }
168    }
169
170    fn handle_menu_hotkey(&mut self, hotkey: char) -> MenuEventResult {
171        // Check for menu hotkeys (case insensitive)
172        for (index, menu) in self.menus.iter().enumerate() {
173            if let Some(menu_hotkey) = menu.hotkey {
174                if menu_hotkey.to_ascii_lowercase() == hotkey.to_ascii_lowercase() {
175                    self.open_menu(index);
176                    return MenuEventResult::MenuOpened { menu_index: index };
177                }
178            }
179        }
180        MenuEventResult::NotHandled
181    }
182
183    fn handle_left_arrow(&mut self) -> MenuEventResult {
184        // Check if we're in a submenu and should close it
185        if let Some(submenu) = self.get_focused_submenu_mut() {
186            if submenu.is_open {
187                submenu.is_open = false;
188                submenu.focused_item = None;
189                return MenuEventResult::SubmenuClosed {
190                    submenu_label: submenu.label.clone(),
191                };
192            }
193        }
194
195        // Move to previous menu if we have an open menu
196        if self.has_open_menu() {
197            self.open_previous_menu();
198            MenuEventResult::NavigationChanged
199        } else {
200            MenuEventResult::NotHandled
201        }
202    }
203
204    fn handle_right_arrow(&mut self) -> MenuEventResult {
205        // Check if current focused item is a submenu
206        if let Some(submenu) = self.get_focused_submenu_mut() {
207            if !submenu.is_open {
208                // Open the submenu
209                submenu.is_open = true;
210                submenu.focused_item = submenu
211                    .items
212                    .iter()
213                    .position(|item| !matches!(item, MenuItem::Separator(_)));
214
215                return MenuEventResult::SubmenuOpened {
216                    submenu_label: submenu.label.clone(),
217                };
218            }
219        }
220
221        // Move to next menu if we have an open menu
222        if self.has_open_menu() {
223            self.open_next_menu();
224            MenuEventResult::NavigationChanged
225        } else {
226            MenuEventResult::NotHandled
227        }
228    }
229
230    fn handle_down_arrow(&mut self) -> MenuEventResult {
231        // Try to navigate within submenu first
232        if self.is_in_open_submenu() {
233            return self.navigate_submenu(SubmenuNavDirection::Down);
234        }
235
236        // Regular menu navigation
237        if let Some(menu) = self.opened_menu_mut() {
238            menu.focus_next_item();
239            MenuEventResult::NavigationChanged
240        } else {
241            MenuEventResult::NotHandled
242        }
243    }
244
245    fn handle_up_arrow(&mut self) -> MenuEventResult {
246        // Try to navigate within submenu first
247        if self.is_in_open_submenu() {
248            return self.navigate_submenu(SubmenuNavDirection::Up);
249        }
250
251        // Regular menu navigation
252        if let Some(menu) = self.opened_menu_mut() {
253            menu.focus_previous_item();
254            MenuEventResult::NavigationChanged
255        } else {
256            MenuEventResult::NotHandled
257        }
258    }
259
260    fn handle_enter(&mut self) -> MenuEventResult {
261        // First check if we're selecting a submenu item
262        if let Some(result) = self.handle_submenu_item_selection() {
263            return result;
264        }
265
266        // Handle main menu items
267        let menu = match self.opened_menu_mut() {
268            Some(menu) => menu,
269            None => return MenuEventResult::NotHandled,
270        };
271
272        let focused_index = match menu.focused_item {
273            Some(index) => index,
274            None => return MenuEventResult::NotHandled,
275        };
276
277        let item = match menu.items.get_mut(focused_index) {
278            Some(item) => item,
279            None => return MenuEventResult::NotHandled,
280        };
281
282        match item {
283            MenuItem::Action(action) => {
284                let command = action.command.to_string();
285                self.close_menu();
286                MenuEventResult::ItemSelected { command }
287            }
288            MenuItem::SubMenu(submenu) => {
289                submenu.is_open = !submenu.is_open;
290                if submenu.is_open {
291                    submenu.focused_item = submenu
292                        .items
293                        .iter()
294                        .position(|item| !matches!(item, MenuItem::Separator(_)));
295                    MenuEventResult::SubmenuOpened {
296                        submenu_label: submenu.label.clone(),
297                    }
298                } else {
299                    submenu.focused_item = None;
300                    MenuEventResult::SubmenuClosed {
301                        submenu_label: submenu.label.clone(),
302                    }
303                }
304            }
305            MenuItem::Separator(_) => MenuEventResult::NotHandled,
306        }
307    }
308
309    fn handle_escape(&mut self) -> MenuEventResult {
310        if self.has_open_menu() {
311            self.close_menu();
312            MenuEventResult::MenuClosed
313        } else {
314            MenuEventResult::NotHandled
315        }
316    }
317
318    fn handle_tab(&mut self, shift_pressed: bool) -> MenuEventResult {
319        if shift_pressed {
320            self.open_previous_menu();
321        } else {
322            self.open_next_menu();
323        }
324        MenuEventResult::NavigationChanged
325    }
326
327    fn handle_space(&mut self) -> MenuEventResult {
328        if !self.has_open_menu() {
329            self.open_menu(0);
330            MenuEventResult::MenuOpened { menu_index: 0 }
331        } else {
332            MenuEventResult::NotHandled
333        }
334    }
335
336    fn handle_item_hotkey(&mut self, hotkey: char) -> MenuEventResult {
337        if let Some(menu) = self.opened_menu_mut() {
338            // Check for item hotkeys within open menu (case insensitive)
339            if let Some(index) = find_item_by_hotkey(menu, hotkey) {
340                menu.focused_item = Some(index);
341                if let Some(item) = menu.get_focused_item() {
342                    match item {
343                        MenuItem::Action(action) => {
344                            let command = action.command.to_string();
345                            self.close_menu();
346                            MenuEventResult::ItemSelected { command }
347                        }
348                        MenuItem::SubMenu(_) => MenuEventResult::NavigationChanged,
349                        _ => MenuEventResult::NotHandled,
350                    }
351                } else {
352                    MenuEventResult::NotHandled
353                }
354            } else {
355                MenuEventResult::NotHandled
356            }
357        } else {
358            // Check for menu hotkeys (case insensitive)
359            for (index, menu) in self.menus.iter().enumerate() {
360                if let Some(menu_hotkey) = menu.hotkey {
361                    if menu_hotkey.to_ascii_lowercase() == hotkey.to_ascii_lowercase() {
362                        self.open_menu(index);
363                        return MenuEventResult::MenuOpened { menu_index: index };
364                    }
365                }
366            }
367            MenuEventResult::NotHandled
368        }
369    }
370}
371
372/// Finds a menu item by its hotkey character (case insensitive).
373fn find_item_by_hotkey(menu: &super::Menu, hotkey: char) -> Option<usize> {
374    menu.items.iter().position(|item| {
375        if let Some(item_hotkey) = item.hotkey() {
376            item_hotkey.to_ascii_lowercase() == hotkey.to_ascii_lowercase()
377        } else {
378            false
379        }
380    })
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::{item, menu, menu_bar};
387
388    fn create_test_menu_bar() -> MenuBar {
389        menu_bar![
390            menu![
391                "File",
392                'F',
393                item![action: "New", command: "file.new", hotkey: 'N'],
394                item![action: "Open", command: "file.open", hotkey: 'O'],
395                item![submenu: "Export", items: [
396                    item![action: "PDF", command: "file.export.pdf", hotkey: 'P'],
397                    item![action: "HTML", command: "file.export.html", hotkey: 'H']
398                ], hotkey: 'E'],
399            ],
400            menu![
401                "Edit",
402                'E',
403                item![action: "Undo", command: "edit.undo", hotkey: 'U'],
404                item![action: "Redo", command: "edit.redo", hotkey: 'R'],
405            ]
406        ]
407    }
408
409    #[test]
410    fn menu_hotkey_with_alt_opens_menu() {
411        let mut menu_bar = create_test_menu_bar();
412        let key = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT);
413
414        let result = menu_bar.handle_key_event(key);
415
416        assert_eq!(result, MenuEventResult::MenuOpened { menu_index: 0 });
417        assert!(menu_bar.has_open_menu());
418        assert_eq!(menu_bar.opened_menu, Some(0));
419    }
420
421    #[test]
422    fn item_hotkey_selects_action() {
423        let mut menu_bar = create_test_menu_bar();
424        menu_bar.open_menu(0); // Open File menu
425
426        let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
427        let result = menu_bar.handle_key_event(key);
428
429        assert_eq!(
430            result,
431            MenuEventResult::ItemSelected {
432                command: "file.new".to_string()
433            }
434        );
435        assert!(!menu_bar.has_open_menu());
436    }
437
438    #[test]
439    fn arrow_keys_navigate_menus() {
440        let mut menu_bar = create_test_menu_bar();
441        menu_bar.open_menu(0); // Open File menu
442
443        let result = menu_bar.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
444        assert_eq!(result, MenuEventResult::NavigationChanged);
445        assert_eq!(menu_bar.opened_menu, Some(1)); // Should move to Edit menu
446    }
447
448    #[test]
449    fn escape_closes_menu() {
450        let mut menu_bar = create_test_menu_bar();
451        menu_bar.open_menu(0);
452
453        let result = menu_bar.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
454        assert_eq!(result, MenuEventResult::MenuClosed);
455        assert!(!menu_bar.has_open_menu());
456    }
457
458    #[test]
459    fn enter_opens_submenu() {
460        let mut menu_bar = create_test_menu_bar();
461        menu_bar.open_menu(0);
462
463        // Focus the Export submenu (index 2)
464        if let Some(menu) = menu_bar.opened_menu_mut() {
465            menu.focused_item = Some(2);
466        }
467
468        let result = menu_bar.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
469        assert_eq!(
470            result,
471            MenuEventResult::SubmenuOpened {
472                submenu_label: "Export".to_string()
473            }
474        );
475
476        // Check that submenu is open
477        if let Some(menu) = menu_bar.opened_menu() {
478            if let Some(MenuItem::SubMenu(submenu)) = menu.items.get(2) {
479                assert!(submenu.is_open);
480            } else {
481                panic!("Expected submenu at index 2");
482            }
483        }
484    }
485
486    #[test]
487    fn space_activates_menu_system() {
488        let mut menu_bar = create_test_menu_bar();
489
490        let result =
491            menu_bar.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
492        assert_eq!(result, MenuEventResult::MenuOpened { menu_index: 0 });
493        assert!(menu_bar.has_open_menu());
494    }
495
496    #[test]
497    fn submenu_item_selection_triggers_item_selected_event() {
498        use crate::{item, menu, menu_bar};
499
500        let mut menu_bar = menu_bar![menu![
501            "View",
502            'V',
503            item![submenu: "Theme", items: [
504                item![action: "Dark Theme", command: "view.theme.dark", hotkey: 'D'],
505                item![action: "Light Theme", command: "view.theme.light", hotkey: 'L']
506            ], hotkey: 'T'],
507        ]];
508
509        // Open the View menu
510        menu_bar.open_menu(0);
511
512        // The submenu should be focused by default, press Enter to open it
513        let result = menu_bar.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
514
515        // Debug: let's check what we actually got
516        match &result {
517            MenuEventResult::SubmenuOpened { submenu_label } => {
518                assert_eq!(submenu_label, "Theme");
519            }
520            other => {
521                panic!("Expected SubmenuOpened, got: {other:?}");
522            }
523        }
524
525        // Navigate down to the first theme option (should be focused already)
526        let menu = menu_bar.opened_menu().unwrap();
527        let submenu = match &menu.items[0] {
528            MenuItem::SubMenu(submenu) => submenu,
529            _ => panic!("Expected submenu"),
530        };
531        assert!(submenu.is_open);
532        assert_eq!(submenu.focused_item, Some(0)); // Should focus "Dark Theme"
533
534        // Press Enter to select the Dark Theme option
535        let result = menu_bar.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
536        match &result {
537            MenuEventResult::ItemSelected { command } => {
538                assert_eq!(command, "view.theme.dark");
539            }
540            other => {
541                panic!("Expected ItemSelected, got: {other:?}");
542            }
543        }
544
545        // Menu should be closed after selection
546        assert!(!menu_bar.has_open_menu());
547    }
548}