1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3use super::{MenuBar, MenuItem};
4
5#[derive(Debug, Clone, Copy)]
7enum SubmenuNavDirection {
8 Up,
9 Down,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum MenuEventResult {
15 NotHandled,
17 Handled,
19 MenuOpened { menu_index: usize },
21 MenuClosed,
23 NavigationChanged,
25 ItemSelected { command: String },
27 SubmenuOpened { submenu_label: String },
29 SubmenuClosed { submenu_label: String },
31}
32
33impl MenuBar {
34 pub fn handle_key_event(&mut self, key: KeyEvent) -> MenuEventResult {
39 match key.code {
40 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 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 KeyCode::Enter => self.handle_enter(),
56
57 KeyCode::Esc => self.handle_escape(),
59
60 KeyCode::Tab => self.handle_tab(key.modifiers.contains(KeyModifiers::SHIFT)),
62
63 KeyCode::Char(' ') => self.handle_space(),
65
66 KeyCode::Char(c) => self.handle_item_hotkey(c),
68
69 _ => MenuEventResult::NotHandled,
70 }
71 }
72
73 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 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 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 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 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 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 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 if let Some(submenu) = self.get_focused_submenu_mut() {
207 if !submenu.is_open {
208 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 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 if self.is_in_open_submenu() {
233 return self.navigate_submenu(SubmenuNavDirection::Down);
234 }
235
236 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 if self.is_in_open_submenu() {
248 return self.navigate_submenu(SubmenuNavDirection::Up);
249 }
250
251 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 if let Some(result) = self.handle_submenu_item_selection() {
263 return result;
264 }
265
266 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 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 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
372fn 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); 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); 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)); }
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 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 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 menu_bar.open_menu(0);
511
512 let result = menu_bar.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
514
515 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 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)); 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 assert!(!menu_bar.has_open_menu());
547 }
548}