1use crate::config::{generate_dynamic_items, Menu, MenuConfig, MenuExt, MenuItem, MenuItemExt};
4use crate::primitives::display_width::str_width;
5use crate::view::theme::Theme;
6use ratatui::layout::Rect;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Paragraph};
10use ratatui::Frame;
11
12pub use crate::types::context_keys;
14
15#[derive(Debug, Clone, Default)]
18pub struct MenuContext {
19 states: std::collections::HashMap<String, bool>,
20}
21
22impl MenuContext {
23 pub fn new() -> Self {
24 Self {
25 states: std::collections::HashMap::new(),
26 }
27 }
28
29 pub fn set(&mut self, name: impl Into<String>, value: bool) -> &mut Self {
31 self.states.insert(name.into(), value);
32 self
33 }
34
35 pub fn get(&self, name: &str) -> bool {
37 self.states.get(name).copied().unwrap_or(false)
38 }
39
40 pub fn with(mut self, name: impl Into<String>, value: bool) -> Self {
42 self.set(name, value);
43 self
44 }
45}
46
47fn is_menu_item_enabled(item: &MenuItem, context: &MenuContext) -> bool {
48 match item {
49 MenuItem::Action { when, .. } => {
50 match when.as_deref() {
51 Some(condition) => context.get(condition),
52 None => true, }
54 }
55 _ => true,
56 }
57}
58
59fn is_checkbox_checked(checkbox: &Option<String>, context: &MenuContext) -> bool {
60 match checkbox.as_deref() {
61 Some(name) => context.get(name),
62 None => false,
63 }
64}
65
66#[derive(Debug, Clone, Default)]
68pub struct MenuState {
69 pub active_menu: Option<usize>,
71 pub highlighted_item: Option<usize>,
73 pub submenu_path: Vec<usize>,
76 pub plugin_menus: Vec<Menu>,
78 pub context: MenuContext,
80}
81
82impl MenuState {
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn open_menu(&mut self, index: usize) {
89 self.active_menu = Some(index);
90 self.highlighted_item = Some(0);
91 self.submenu_path.clear();
92 }
93
94 pub fn close_menu(&mut self) {
96 self.active_menu = None;
97 self.highlighted_item = None;
98 self.submenu_path.clear();
99 }
100
101 pub fn next_menu(&mut self, total_menus: usize) {
103 if let Some(active) = self.active_menu {
104 self.active_menu = Some((active + 1) % total_menus);
105 self.highlighted_item = Some(0);
106 self.submenu_path.clear();
107 }
108 }
109
110 pub fn prev_menu(&mut self, total_menus: usize) {
112 if let Some(active) = self.active_menu {
113 self.active_menu = Some((active + total_menus - 1) % total_menus);
114 self.highlighted_item = Some(0);
115 self.submenu_path.clear();
116 }
117 }
118
119 pub fn in_submenu(&self) -> bool {
121 !self.submenu_path.is_empty()
122 }
123
124 pub fn submenu_depth(&self) -> usize {
126 self.submenu_path.len()
127 }
128
129 pub fn open_submenu(&mut self, menus: &[Menu]) -> bool {
132 let Some(active_idx) = self.active_menu else {
133 return false;
134 };
135 let Some(highlighted) = self.highlighted_item else {
136 return false;
137 };
138
139 let Some(menu) = menus.get(active_idx) else {
141 return false;
142 };
143 let Some(items) = self.get_current_items_cloned(menu) else {
144 return false;
145 };
146
147 if let Some(item) = items.get(highlighted) {
149 match item {
150 MenuItem::Submenu {
151 items: submenu_items,
152 ..
153 } if !submenu_items.is_empty() => {
154 self.submenu_path.push(highlighted);
155 self.highlighted_item = Some(0);
156 return true;
157 }
158 MenuItem::DynamicSubmenu { source, .. } => {
159 let generated = generate_dynamic_items(source);
161 if !generated.is_empty() {
162 self.submenu_path.push(highlighted);
163 self.highlighted_item = Some(0);
164 return true;
165 }
166 }
167 _ => {}
168 }
169 }
170 false
171 }
172
173 pub fn close_submenu(&mut self) -> bool {
176 if let Some(parent_idx) = self.submenu_path.pop() {
177 self.highlighted_item = Some(parent_idx);
178 true
179 } else {
180 false
181 }
182 }
183
184 pub fn get_current_items<'a>(
186 &self,
187 menus: &'a [Menu],
188 active_idx: usize,
189 ) -> Option<&'a [MenuItem]> {
190 let menu = menus.get(active_idx)?;
191 let mut items: &[MenuItem] = &menu.items;
192
193 for &idx in &self.submenu_path {
194 match items.get(idx)? {
195 MenuItem::Submenu {
196 items: submenu_items,
197 ..
198 } => {
199 items = submenu_items;
200 }
201 _ => return None,
202 }
203 }
204
205 Some(items)
206 }
207
208 pub fn get_current_items_cloned(&self, menu: &Menu) -> Option<Vec<MenuItem>> {
211 let mut items: Vec<MenuItem> = menu.items.iter().map(|i| i.expand_dynamic()).collect();
213
214 for &idx in &self.submenu_path {
215 match items.get(idx)?.expand_dynamic() {
216 MenuItem::Submenu {
217 items: submenu_items,
218 ..
219 } => {
220 items = submenu_items;
221 }
222 _ => return None,
223 }
224 }
225
226 Some(items)
227 }
228
229 pub fn next_item(&mut self, menu: &Menu) {
231 let Some(idx) = self.highlighted_item else {
232 return;
233 };
234
235 let Some(items) = self.get_current_items_cloned(menu) else {
237 return;
238 };
239
240 if items.is_empty() {
241 return;
242 }
243
244 let mut next = (idx + 1) % items.len();
246 while next != idx && self.should_skip_item(&items[next]) {
247 next = (next + 1) % items.len();
248 }
249 self.highlighted_item = Some(next);
250 }
251
252 pub fn prev_item(&mut self, menu: &Menu) {
254 let Some(idx) = self.highlighted_item else {
255 return;
256 };
257
258 let Some(items) = self.get_current_items_cloned(menu) else {
260 return;
261 };
262
263 if items.is_empty() {
264 return;
265 }
266
267 let total = items.len();
269 let mut prev = (idx + total - 1) % total;
270 while prev != idx && self.should_skip_item(&items[prev]) {
271 prev = (prev + total - 1) % total;
272 }
273 self.highlighted_item = Some(prev);
274 }
275
276 fn should_skip_item(&self, item: &MenuItem) -> bool {
278 match item {
279 MenuItem::Separator { .. } => true,
280 MenuItem::Action { when, .. } => {
281 match when.as_deref() {
283 Some(condition) => !self.context.get(condition),
284 None => false, }
286 }
287 _ => false,
288 }
289 }
290
291 pub fn get_highlighted_action(
294 &self,
295 menus: &[Menu],
296 ) -> Option<(String, std::collections::HashMap<String, serde_json::Value>)> {
297 let active_menu = self.active_menu?;
298 let highlighted_item = self.highlighted_item?;
299
300 let menu = menus.get(active_menu)?;
302 let items = self.get_current_items_cloned(menu)?;
303 let item = items.get(highlighted_item)?;
304
305 match item {
306 MenuItem::Action { action, args, .. } => {
307 if is_menu_item_enabled(item, &self.context) {
308 Some((action.clone(), args.clone()))
309 } else {
310 None
311 }
312 }
313 _ => None,
314 }
315 }
316
317 pub fn is_highlighted_submenu(&self, menus: &[Menu]) -> bool {
319 let Some(active_menu) = self.active_menu else {
320 return false;
321 };
322 let Some(highlighted_item) = self.highlighted_item else {
323 return false;
324 };
325
326 let Some(menu) = menus.get(active_menu) else {
328 return false;
329 };
330 let Some(items) = self.get_current_items_cloned(menu) else {
331 return false;
332 };
333
334 matches!(
335 items.get(highlighted_item),
336 Some(MenuItem::Submenu { .. } | MenuItem::DynamicSubmenu { .. })
337 )
338 }
339
340 pub fn get_menu_at_position(&self, menus: &[Menu], x: u16) -> Option<usize> {
343 let mut current_x = 0u16;
344
345 for (idx, menu) in menus.iter().enumerate() {
346 let label_width = str_width(&menu.label) as u16 + 2; let total_width = label_width + 1; if x >= current_x && x < current_x + label_width {
350 return Some(idx);
351 }
352
353 current_x += total_width;
354 }
355
356 None
357 }
358
359 pub fn get_item_at_position(&self, menu: &Menu, y: u16) -> Option<usize> {
362 if y < 2 {
364 return None;
365 }
366
367 let item_index = (y - 2) as usize;
368 if item_index < menu.items.len() {
369 if matches!(menu.items[item_index], MenuItem::Separator { .. }) {
371 None
372 } else {
373 Some(item_index)
374 }
375 } else {
376 None
377 }
378 }
379}
380
381pub struct MenuRenderer;
383
384impl MenuRenderer {
385 pub fn render(
396 frame: &mut Frame,
397 area: Rect,
398 menu_config: &MenuConfig,
399 menu_state: &MenuState,
400 keybindings: &crate::input::keybindings::KeybindingResolver,
401 theme: &Theme,
402 hover_target: Option<&crate::app::HoverTarget>,
403 ) {
404 let all_menus: Vec<Menu> = menu_config
406 .menus
407 .iter()
408 .chain(menu_state.plugin_menus.iter())
409 .cloned()
410 .map(|mut menu| {
411 menu.expand_dynamic_items();
412 menu
413 })
414 .collect();
415
416 let mut spans = Vec::new();
418
419 for (idx, menu) in all_menus.iter().enumerate() {
420 let is_active = menu_state.active_menu == Some(idx);
421 let is_hovered =
422 matches!(hover_target, Some(crate::app::HoverTarget::MenuBarItem(i)) if *i == idx);
423
424 let base_style = if is_active {
425 Style::default()
426 .fg(theme.menu_active_fg)
427 .bg(theme.menu_active_bg)
428 .add_modifier(Modifier::BOLD)
429 } else if is_hovered {
430 Style::default()
431 .fg(theme.menu_hover_fg)
432 .bg(theme.menu_hover_bg)
433 } else {
434 Style::default().fg(theme.menu_fg).bg(theme.menu_bg)
435 };
436
437 let mnemonic = keybindings.find_menu_mnemonic(&menu.label);
439
440 spans.push(Span::styled(" ", base_style));
442
443 if let Some(mnemonic_char) = mnemonic {
444 let mut found = false;
446 for c in menu.label.chars() {
447 if !found && c.to_ascii_lowercase() == mnemonic_char {
448 spans.push(Span::styled(
450 c.to_string(),
451 base_style.add_modifier(Modifier::UNDERLINED),
452 ));
453 found = true;
454 } else {
455 spans.push(Span::styled(c.to_string(), base_style));
456 }
457 }
458 } else {
459 spans.push(Span::styled(menu.label.clone(), base_style));
461 }
462
463 spans.push(Span::styled(" ", base_style));
464 spans.push(Span::raw(" "));
465 }
466
467 let line = Line::from(spans);
468 let paragraph = Paragraph::new(line).style(Style::default().bg(theme.menu_bg));
469 frame.render_widget(paragraph, area);
470
471 if let Some(active_idx) = menu_state.active_menu {
473 if let Some(menu) = all_menus.get(active_idx) {
474 Self::render_dropdown_chain(
475 frame,
476 area,
477 menu,
478 menu_state,
479 active_idx,
480 &all_menus,
481 keybindings,
482 theme,
483 hover_target,
484 );
485 }
486 }
487 }
488
489 #[allow(clippy::too_many_arguments)]
491 fn render_dropdown_chain(
492 frame: &mut Frame,
493 menu_bar_area: Rect,
494 menu: &Menu,
495 menu_state: &MenuState,
496 menu_index: usize,
497 all_menus: &[Menu],
498 keybindings: &crate::input::keybindings::KeybindingResolver,
499 theme: &Theme,
500 hover_target: Option<&crate::app::HoverTarget>,
501 ) {
502 let mut x_offset = 0usize;
504 for (idx, m) in all_menus.iter().enumerate() {
505 if idx == menu_index {
506 break;
507 }
508 x_offset += str_width(&m.label) + 3; }
510
511 let terminal_width = frame.area().width;
512 let terminal_height = frame.area().height;
513
514 let mut current_items: &[MenuItem] = &menu.items;
516 let mut current_x = menu_bar_area.x.saturating_add(x_offset as u16);
517 let mut current_y = menu_bar_area.y.saturating_add(1);
518
519 for depth in 0..=menu_state.submenu_path.len() {
522 let is_deepest = depth == menu_state.submenu_path.len();
523 let highlighted_item = if is_deepest {
524 menu_state.highlighted_item
525 } else {
526 Some(menu_state.submenu_path[depth])
527 };
528
529 let dropdown_rect = Self::render_dropdown_level(
531 frame,
532 current_items,
533 highlighted_item,
534 current_x,
535 current_y,
536 terminal_width,
537 terminal_height,
538 depth,
539 &menu_state.submenu_path,
540 menu_index,
541 keybindings,
542 theme,
543 hover_target,
544 &menu_state.context,
545 );
546
547 if !is_deepest {
549 let submenu_idx = menu_state.submenu_path[depth];
550 let submenu_items = match current_items.get(submenu_idx) {
552 Some(MenuItem::Submenu { items, .. }) => Some(items.as_slice()),
553 Some(MenuItem::DynamicSubmenu { .. }) => {
554 None
557 }
558 _ => None,
559 };
560 if let Some(items) = submenu_items {
561 current_items = items;
562 current_x = dropdown_rect
564 .x
565 .saturating_add(dropdown_rect.width.saturating_sub(1));
566 current_y = dropdown_rect.y.saturating_add(submenu_idx as u16 + 1); let next_width = Self::calculate_dropdown_width(items);
570 if current_x.saturating_add(next_width as u16) > terminal_width {
571 current_x = dropdown_rect
572 .x
573 .saturating_sub(next_width as u16)
574 .saturating_add(1);
575 }
576 } else {
577 break;
578 }
579 }
580 }
581 }
582
583 fn calculate_dropdown_width(items: &[MenuItem]) -> usize {
585 items
586 .iter()
587 .map(|item| match item {
588 MenuItem::Action { label, .. } => str_width(label) + 20,
589 MenuItem::Submenu { label, .. } => str_width(label) + 20,
590 MenuItem::DynamicSubmenu { label, .. } => str_width(label) + 20,
591 MenuItem::Separator { .. } => 20,
592 MenuItem::Label { info } => str_width(info) + 4,
593 })
594 .max()
595 .unwrap_or(20)
596 .min(40)
597 }
598
599 #[allow(clippy::too_many_arguments)]
601 fn render_dropdown_level(
602 frame: &mut Frame,
603 items: &[MenuItem],
604 highlighted_item: Option<usize>,
605 x: u16,
606 y: u16,
607 terminal_width: u16,
608 terminal_height: u16,
609 depth: usize,
610 submenu_path: &[usize],
611 menu_index: usize,
612 keybindings: &crate::input::keybindings::KeybindingResolver,
613 theme: &Theme,
614 hover_target: Option<&crate::app::HoverTarget>,
615 context: &MenuContext,
616 ) -> Rect {
617 let max_width = Self::calculate_dropdown_width(items);
618 let dropdown_height = items.len() + 2; let desired_width = max_width as u16;
621 let desired_height = dropdown_height as u16;
622
623 let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
625 terminal_width.saturating_sub(desired_width)
626 } else {
627 x
628 };
629
630 let available_height = terminal_height.saturating_sub(y);
631 let height = desired_height.min(available_height);
632
633 let available_width = terminal_width.saturating_sub(adjusted_x);
634 let width = desired_width.min(available_width);
635
636 if width < 10 || height < 3 {
638 return Rect {
639 x: adjusted_x,
640 y,
641 width,
642 height,
643 };
644 }
645
646 let dropdown_area = Rect {
647 x: adjusted_x,
648 y,
649 width,
650 height,
651 };
652
653 let mut lines = Vec::new();
655 let max_items = (height.saturating_sub(2)) as usize;
656 let items_to_show = items.len().min(max_items);
657 let content_width = (width as usize).saturating_sub(2);
658
659 for (idx, item) in items.iter().enumerate().take(items_to_show) {
660 let is_highlighted = highlighted_item == Some(idx);
661 let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
663
664 let is_hovered = if depth == 0 {
666 matches!(
667 hover_target,
668 Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
669 )
670 } else {
671 matches!(
672 hover_target,
673 Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
674 )
675 };
676 let enabled = is_menu_item_enabled(item, context);
677
678 let line = match item {
679 MenuItem::Action {
680 label,
681 action,
682 checkbox,
683 ..
684 } => {
685 let style = if !enabled {
686 Style::default()
687 .fg(theme.menu_disabled_fg)
688 .bg(theme.menu_disabled_bg)
689 } else if is_highlighted {
690 Style::default()
691 .fg(theme.menu_highlight_fg)
692 .bg(theme.menu_highlight_bg)
693 } else if is_hovered {
694 Style::default()
695 .fg(theme.menu_hover_fg)
696 .bg(theme.menu_hover_bg)
697 } else {
698 Style::default()
699 .fg(theme.menu_dropdown_fg)
700 .bg(theme.menu_dropdown_bg)
701 };
702
703 let keybinding = keybindings
704 .find_keybinding_for_action(
705 action,
706 crate::input::keybindings::KeyContext::Normal,
707 )
708 .unwrap_or_default();
709
710 let checkbox_icon = if checkbox.is_some() {
711 if is_checkbox_checked(checkbox, context) {
712 "☑ "
713 } else {
714 "☐ "
715 }
716 } else {
717 ""
718 };
719
720 let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
721 let label_display_width = str_width(label);
722 let keybinding_display_width = str_width(&keybinding);
723
724 let text = if keybinding.is_empty() {
725 let padding_needed =
726 content_width.saturating_sub(checkbox_width + label_display_width + 1);
727 format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
728 } else {
729 let padding_needed = content_width.saturating_sub(
730 checkbox_width + label_display_width + keybinding_display_width + 2,
731 );
732 format!(
733 " {}{}{} {}",
734 checkbox_icon,
735 label,
736 " ".repeat(padding_needed),
737 keybinding
738 )
739 };
740
741 Line::from(vec![Span::styled(text, style)])
742 }
743 MenuItem::Separator { .. } => {
744 let separator = "─".repeat(content_width);
745 Line::from(vec![Span::styled(
746 format!(" {separator}"),
747 Style::default()
748 .fg(theme.menu_separator_fg)
749 .bg(theme.menu_dropdown_bg),
750 )])
751 }
752 MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
753 let style = if is_highlighted || has_open_submenu {
755 Style::default()
756 .fg(theme.menu_highlight_fg)
757 .bg(theme.menu_highlight_bg)
758 } else if is_hovered {
759 Style::default()
760 .fg(theme.menu_hover_fg)
761 .bg(theme.menu_hover_bg)
762 } else {
763 Style::default()
764 .fg(theme.menu_dropdown_fg)
765 .bg(theme.menu_dropdown_bg)
766 };
767
768 let label_display_width = str_width(label);
771 let padding_needed = content_width.saturating_sub(label_display_width + 5);
772 Line::from(vec![Span::styled(
773 format!(" {}{} > ", label, " ".repeat(padding_needed)),
774 style,
775 )])
776 }
777 MenuItem::Label { info } => {
778 let style = Style::default()
780 .fg(theme.menu_disabled_fg)
781 .bg(theme.menu_dropdown_bg);
782 let info_display_width = str_width(info);
783 let padding_needed = content_width.saturating_sub(info_display_width);
784 Line::from(vec![Span::styled(
785 format!(" {}{}", info, " ".repeat(padding_needed)),
786 style,
787 )])
788 }
789 };
790
791 lines.push(line);
792 }
793
794 let block = Block::default()
795 .borders(Borders::ALL)
796 .border_style(Style::default().fg(theme.menu_border_fg))
797 .style(Style::default().bg(theme.menu_dropdown_bg));
798
799 let paragraph = Paragraph::new(lines).block(block);
800 frame.render_widget(paragraph, dropdown_area);
801
802 dropdown_area
803 }
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809 use std::collections::HashMap;
810
811 fn create_test_menus() -> Vec<Menu> {
812 vec![
813 Menu {
814 id: None,
815 label: "File".to_string(),
816 items: vec![
817 MenuItem::Action {
818 label: "New".to_string(),
819 action: "new_file".to_string(),
820 args: HashMap::new(),
821 when: None,
822 checkbox: None,
823 },
824 MenuItem::Separator { separator: true },
825 MenuItem::Action {
826 label: "Save".to_string(),
827 action: "save".to_string(),
828 args: HashMap::new(),
829 when: None,
830 checkbox: None,
831 },
832 MenuItem::Action {
833 label: "Quit".to_string(),
834 action: "quit".to_string(),
835 args: HashMap::new(),
836 when: None,
837 checkbox: None,
838 },
839 ],
840 },
841 Menu {
842 id: None,
843 label: "Edit".to_string(),
844 items: vec![
845 MenuItem::Action {
846 label: "Undo".to_string(),
847 action: "undo".to_string(),
848 args: HashMap::new(),
849 when: None,
850 checkbox: None,
851 },
852 MenuItem::Action {
853 label: "Redo".to_string(),
854 action: "redo".to_string(),
855 args: HashMap::new(),
856 when: None,
857 checkbox: None,
858 },
859 ],
860 },
861 Menu {
862 id: None,
863 label: "View".to_string(),
864 items: vec![MenuItem::Action {
865 label: "Toggle Explorer".to_string(),
866 action: "toggle_file_explorer".to_string(),
867 args: HashMap::new(),
868 when: None,
869 checkbox: None,
870 }],
871 },
872 ]
873 }
874
875 #[test]
876 fn test_menu_state_default() {
877 let state = MenuState::new();
878 assert_eq!(state.active_menu, None);
879 assert_eq!(state.highlighted_item, None);
880 assert!(state.plugin_menus.is_empty());
881 }
882
883 #[test]
884 fn test_menu_state_open_menu() {
885 let mut state = MenuState::new();
886 state.open_menu(2);
887 assert_eq!(state.active_menu, Some(2));
888 assert_eq!(state.highlighted_item, Some(0));
889 }
890
891 #[test]
892 fn test_menu_state_close_menu() {
893 let mut state = MenuState::new();
894 state.open_menu(1);
895 state.close_menu();
896 assert_eq!(state.active_menu, None);
897 assert_eq!(state.highlighted_item, None);
898 }
899
900 #[test]
901 fn test_menu_state_next_menu() {
902 let mut state = MenuState::new();
903 state.open_menu(0);
904
905 state.next_menu(3);
906 assert_eq!(state.active_menu, Some(1));
907
908 state.next_menu(3);
909 assert_eq!(state.active_menu, Some(2));
910
911 state.next_menu(3);
913 assert_eq!(state.active_menu, Some(0));
914 }
915
916 #[test]
917 fn test_menu_state_prev_menu() {
918 let mut state = MenuState::new();
919 state.open_menu(0);
920
921 state.prev_menu(3);
923 assert_eq!(state.active_menu, Some(2));
924
925 state.prev_menu(3);
926 assert_eq!(state.active_menu, Some(1));
927
928 state.prev_menu(3);
929 assert_eq!(state.active_menu, Some(0));
930 }
931
932 #[test]
933 fn test_menu_state_next_item_skips_separator() {
934 let mut state = MenuState::new();
935 let menus = create_test_menus();
936 state.open_menu(0);
937
938 assert_eq!(state.highlighted_item, Some(0));
940
941 state.next_item(&menus[0]);
943 assert_eq!(state.highlighted_item, Some(2));
944
945 state.next_item(&menus[0]);
947 assert_eq!(state.highlighted_item, Some(3));
948
949 state.next_item(&menus[0]);
951 assert_eq!(state.highlighted_item, Some(0));
952 }
953
954 #[test]
955 fn test_menu_state_prev_item_skips_separator() {
956 let mut state = MenuState::new();
957 let menus = create_test_menus();
958 state.open_menu(0);
959 state.highlighted_item = Some(2); state.prev_item(&menus[0]);
963 assert_eq!(state.highlighted_item, Some(0));
964
965 state.prev_item(&menus[0]);
967 assert_eq!(state.highlighted_item, Some(3));
968 }
969
970 #[test]
971 fn test_get_highlighted_action() {
972 let mut state = MenuState::new();
973 let menus = create_test_menus();
974 state.open_menu(0);
975 state.highlighted_item = Some(2); let action = state.get_highlighted_action(&menus);
978 assert!(action.is_some());
979 let (action_name, _args) = action.unwrap();
980 assert_eq!(action_name, "save");
981 }
982
983 #[test]
984 fn test_menu_item_when_requires_selection() {
985 let mut state = MenuState::new();
986 let select_menu = Menu {
987 id: None,
988 label: "Edit".to_string(),
989 items: vec![MenuItem::Action {
990 label: "Find in Selection".to_string(),
991 action: "find_in_selection".to_string(),
992 args: HashMap::new(),
993 when: Some(context_keys::HAS_SELECTION.to_string()),
994 checkbox: None,
995 }],
996 };
997 state.open_menu(0);
998 state.highlighted_item = Some(0);
999
1000 assert!(state
1002 .get_highlighted_action(std::slice::from_ref(&select_menu))
1003 .is_none());
1004
1005 state.context.set(context_keys::HAS_SELECTION, true);
1007 assert!(state.get_highlighted_action(&[select_menu]).is_some());
1008 }
1009
1010 #[test]
1011 fn test_get_highlighted_action_none_when_closed() {
1012 let state = MenuState::new();
1013 let menus = create_test_menus();
1014 assert!(state.get_highlighted_action(&menus).is_none());
1015 }
1016
1017 #[test]
1018 fn test_get_highlighted_action_none_for_separator() {
1019 let mut state = MenuState::new();
1020 let menus = create_test_menus();
1021 state.open_menu(0);
1022 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1025 }
1026
1027 #[test]
1028 fn test_get_menu_at_position() {
1029 let state = MenuState::new();
1030 let menus = create_test_menus();
1031
1032 assert_eq!(state.get_menu_at_position(&menus, 0), Some(0));
1035 assert_eq!(state.get_menu_at_position(&menus, 3), Some(0));
1036 assert_eq!(state.get_menu_at_position(&menus, 5), Some(0));
1037
1038 assert_eq!(state.get_menu_at_position(&menus, 6), None);
1040
1041 assert_eq!(state.get_menu_at_position(&menus, 7), Some(1));
1043 assert_eq!(state.get_menu_at_position(&menus, 10), Some(1));
1044 assert_eq!(state.get_menu_at_position(&menus, 12), Some(1));
1045
1046 assert_eq!(state.get_menu_at_position(&menus, 13), None);
1048
1049 assert_eq!(state.get_menu_at_position(&menus, 14), Some(2));
1051 assert_eq!(state.get_menu_at_position(&menus, 17), Some(2));
1052 assert_eq!(state.get_menu_at_position(&menus, 19), Some(2));
1053
1054 assert_eq!(state.get_menu_at_position(&menus, 20), None);
1056 assert_eq!(state.get_menu_at_position(&menus, 100), None);
1057 }
1058
1059 #[test]
1060 fn test_get_item_at_position() {
1061 let state = MenuState::new();
1062 let menus = create_test_menus();
1063
1064 assert_eq!(state.get_item_at_position(&menus[0], 0), None);
1075 assert_eq!(state.get_item_at_position(&menus[0], 1), None);
1076
1077 assert_eq!(state.get_item_at_position(&menus[0], 2), Some(0));
1079
1080 assert_eq!(state.get_item_at_position(&menus[0], 3), None);
1082
1083 assert_eq!(state.get_item_at_position(&menus[0], 4), Some(2));
1085
1086 assert_eq!(state.get_item_at_position(&menus[0], 5), Some(3));
1088
1089 assert_eq!(state.get_item_at_position(&menus[0], 6), None);
1091 assert_eq!(state.get_item_at_position(&menus[0], 100), None);
1092 }
1093
1094 #[test]
1095 fn test_menu_config_json_parsing() {
1096 let json = r#"{
1097 "menus": [
1098 {
1099 "label": "File",
1100 "items": [
1101 { "label": "New", "action": "new_file" },
1102 { "separator": true },
1103 { "label": "Save", "action": "save" }
1104 ]
1105 }
1106 ]
1107 }"#;
1108
1109 let config: MenuConfig = serde_json::from_str(json).unwrap();
1110 assert_eq!(config.menus.len(), 1);
1111 assert_eq!(config.menus[0].label, "File");
1112 assert_eq!(config.menus[0].items.len(), 3);
1113
1114 match &config.menus[0].items[0] {
1115 MenuItem::Action { label, action, .. } => {
1116 assert_eq!(label, "New");
1117 assert_eq!(action, "new_file");
1118 }
1119 _ => panic!("Expected Action"),
1120 }
1121
1122 assert!(matches!(
1123 config.menus[0].items[1],
1124 MenuItem::Separator { .. }
1125 ));
1126
1127 match &config.menus[0].items[2] {
1128 MenuItem::Action { label, action, .. } => {
1129 assert_eq!(label, "Save");
1130 assert_eq!(action, "save");
1131 }
1132 _ => panic!("Expected Action"),
1133 }
1134 }
1135
1136 #[test]
1137 fn test_menu_item_with_args() {
1138 let json = r#"{
1139 "label": "Go to Line",
1140 "action": "goto_line",
1141 "args": { "line": 42 }
1142 }"#;
1143
1144 let item: MenuItem = serde_json::from_str(json).unwrap();
1145 match item {
1146 MenuItem::Action {
1147 label,
1148 action,
1149 args,
1150 ..
1151 } => {
1152 assert_eq!(label, "Go to Line");
1153 assert_eq!(action, "goto_line");
1154 assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1155 }
1156 _ => panic!("Expected Action with args"),
1157 }
1158 }
1159
1160 #[test]
1161 fn test_empty_menu_config() {
1162 let json = r#"{ "menus": [] }"#;
1163 let config: MenuConfig = serde_json::from_str(json).unwrap();
1164 assert!(config.menus.is_empty());
1165 }
1166
1167 #[test]
1168 fn test_menu_mnemonic_lookup() {
1169 use crate::config::Config;
1170 use crate::input::keybindings::KeybindingResolver;
1171
1172 let config = Config::default();
1173 let resolver = KeybindingResolver::new(&config);
1174
1175 assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1177 assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1178 assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1179 assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1180 assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1181 assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1182
1183 assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1185 assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1186
1187 assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1189 }
1190
1191 fn create_menu_with_submenus() -> Vec<Menu> {
1192 vec![Menu {
1193 id: None,
1194 label: "View".to_string(),
1195 items: vec![
1196 MenuItem::Action {
1197 label: "Toggle Explorer".to_string(),
1198 action: "toggle_file_explorer".to_string(),
1199 args: HashMap::new(),
1200 when: None,
1201 checkbox: None,
1202 },
1203 MenuItem::Submenu {
1204 label: "Terminal".to_string(),
1205 items: vec![
1206 MenuItem::Action {
1207 label: "Open Terminal".to_string(),
1208 action: "open_terminal".to_string(),
1209 args: HashMap::new(),
1210 when: None,
1211 checkbox: None,
1212 },
1213 MenuItem::Action {
1214 label: "Close Terminal".to_string(),
1215 action: "close_terminal".to_string(),
1216 args: HashMap::new(),
1217 when: None,
1218 checkbox: None,
1219 },
1220 MenuItem::Submenu {
1221 label: "Terminal Settings".to_string(),
1222 items: vec![MenuItem::Action {
1223 label: "Font Size".to_string(),
1224 action: "terminal_font_size".to_string(),
1225 args: HashMap::new(),
1226 when: None,
1227 checkbox: None,
1228 }],
1229 },
1230 ],
1231 },
1232 MenuItem::Separator { separator: true },
1233 MenuItem::Action {
1234 label: "Zoom In".to_string(),
1235 action: "zoom_in".to_string(),
1236 args: HashMap::new(),
1237 when: None,
1238 checkbox: None,
1239 },
1240 ],
1241 }]
1242 }
1243
1244 #[test]
1245 fn test_submenu_open_and_close() {
1246 let mut state = MenuState::new();
1247 let menus = create_menu_with_submenus();
1248
1249 state.open_menu(0);
1250 assert!(state.submenu_path.is_empty());
1251 assert!(!state.in_submenu());
1252
1253 state.highlighted_item = Some(1);
1255
1256 assert!(state.open_submenu(&menus));
1258 assert_eq!(state.submenu_path, vec![1]);
1259 assert!(state.in_submenu());
1260 assert_eq!(state.submenu_depth(), 1);
1261 assert_eq!(state.highlighted_item, Some(0)); assert!(state.close_submenu());
1265 assert!(state.submenu_path.is_empty());
1266 assert!(!state.in_submenu());
1267 assert_eq!(state.highlighted_item, Some(1)); }
1269
1270 #[test]
1271 fn test_nested_submenu() {
1272 let mut state = MenuState::new();
1273 let menus = create_menu_with_submenus();
1274
1275 state.open_menu(0);
1276 state.highlighted_item = Some(1); assert!(state.open_submenu(&menus));
1280 assert_eq!(state.submenu_depth(), 1);
1281
1282 state.highlighted_item = Some(2);
1284
1285 assert!(state.open_submenu(&menus));
1287 assert_eq!(state.submenu_path, vec![1, 2]);
1288 assert_eq!(state.submenu_depth(), 2);
1289 assert_eq!(state.highlighted_item, Some(0));
1290
1291 assert!(state.close_submenu());
1293 assert_eq!(state.submenu_path, vec![1]);
1294 assert_eq!(state.highlighted_item, Some(2));
1295
1296 assert!(state.close_submenu());
1298 assert!(state.submenu_path.is_empty());
1299 assert_eq!(state.highlighted_item, Some(1));
1300
1301 assert!(!state.close_submenu());
1303 }
1304
1305 #[test]
1306 fn test_get_highlighted_action_in_submenu() {
1307 let mut state = MenuState::new();
1308 let menus = create_menu_with_submenus();
1309
1310 state.open_menu(0);
1311 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1315
1316 state.open_submenu(&menus);
1318 let action = state.get_highlighted_action(&menus);
1320 assert!(action.is_some());
1321 let (action_name, _) = action.unwrap();
1322 assert_eq!(action_name, "open_terminal");
1323
1324 state.highlighted_item = Some(1);
1326 let action = state.get_highlighted_action(&menus);
1327 assert!(action.is_some());
1328 let (action_name, _) = action.unwrap();
1329 assert_eq!(action_name, "close_terminal");
1330 }
1331
1332 #[test]
1333 fn test_get_current_items_at_different_depths() {
1334 let mut state = MenuState::new();
1335 let menus = create_menu_with_submenus();
1336
1337 state.open_menu(0);
1338
1339 let items = state.get_current_items(&menus, 0).unwrap();
1341 assert_eq!(items.len(), 4); state.highlighted_item = Some(1);
1345 state.open_submenu(&menus);
1346
1347 let items = state.get_current_items(&menus, 0).unwrap();
1349 assert_eq!(items.len(), 3); state.highlighted_item = Some(2);
1353 state.open_submenu(&menus);
1354
1355 let items = state.get_current_items(&menus, 0).unwrap();
1357 assert_eq!(items.len(), 1); }
1359
1360 #[test]
1361 fn test_is_highlighted_submenu() {
1362 let mut state = MenuState::new();
1363 let menus = create_menu_with_submenus();
1364
1365 state.open_menu(0);
1366 state.highlighted_item = Some(0); assert!(!state.is_highlighted_submenu(&menus));
1368
1369 state.highlighted_item = Some(1); assert!(state.is_highlighted_submenu(&menus));
1371
1372 state.highlighted_item = Some(2); assert!(!state.is_highlighted_submenu(&menus));
1374
1375 state.highlighted_item = Some(3); assert!(!state.is_highlighted_submenu(&menus));
1377 }
1378
1379 #[test]
1380 fn test_open_menu_clears_submenu_path() {
1381 let mut state = MenuState::new();
1382 let menus = create_menu_with_submenus();
1383
1384 state.open_menu(0);
1385 state.highlighted_item = Some(1);
1386 state.open_submenu(&menus);
1387 assert!(!state.submenu_path.is_empty());
1388
1389 state.open_menu(0);
1391 assert!(state.submenu_path.is_empty());
1392 }
1393
1394 #[test]
1395 fn test_next_prev_menu_clears_submenu_path() {
1396 let mut state = MenuState::new();
1397 let menus = create_menu_with_submenus();
1398
1399 state.open_menu(0);
1400 state.highlighted_item = Some(1);
1401 state.open_submenu(&menus);
1402 assert!(!state.submenu_path.is_empty());
1403
1404 state.next_menu(1);
1406 assert!(state.submenu_path.is_empty());
1407
1408 state.open_menu(0);
1410 state.highlighted_item = Some(1);
1411 state.open_submenu(&menus);
1412
1413 state.prev_menu(1);
1415 assert!(state.submenu_path.is_empty());
1416 }
1417
1418 #[test]
1419 fn test_navigation_in_submenu() {
1420 let mut state = MenuState::new();
1421 let menus = create_menu_with_submenus();
1422
1423 state.open_menu(0);
1424 state.highlighted_item = Some(1);
1425 state.open_submenu(&menus);
1426
1427 assert_eq!(state.highlighted_item, Some(0));
1429
1430 state.next_item(&menus[0]);
1432 assert_eq!(state.highlighted_item, Some(1));
1433
1434 state.next_item(&menus[0]);
1436 assert_eq!(state.highlighted_item, Some(2));
1437
1438 state.next_item(&menus[0]);
1440 assert_eq!(state.highlighted_item, Some(0));
1441
1442 state.prev_item(&menus[0]);
1444 assert_eq!(state.highlighted_item, Some(2));
1445 }
1446}