1use crate::config::{generate_dynamic_items, Menu, MenuConfig, MenuExt, MenuItem, MenuItemExt};
4use crate::primitives::display_width::str_width;
5use crate::view::theme::Theme;
6use crate::view::ui::layout::point_in_rect;
7use ratatui::layout::Rect;
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Borders, Paragraph};
11use ratatui::Frame;
12
13pub use crate::types::context_keys;
15
16#[derive(Debug, Clone, Default)]
21pub struct MenuLayout {
22 pub menu_areas: Vec<(usize, Rect)>,
24 pub item_areas: Vec<(usize, Rect)>,
27 pub submenu_areas: Vec<(usize, usize, Rect)>,
29 pub bar_area: Rect,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum MenuHit {
36 MenuLabel(usize),
38 DropdownItem(usize),
40 SubmenuItem { depth: usize, index: usize },
42 BarBackground,
44}
45
46impl MenuLayout {
47 pub fn new(bar_area: Rect) -> Self {
49 Self {
50 menu_areas: Vec::new(),
51 item_areas: Vec::new(),
52 submenu_areas: Vec::new(),
53 bar_area,
54 }
55 }
56
57 pub fn menu_at(&self, x: u16, y: u16) -> Option<usize> {
59 for (idx, area) in &self.menu_areas {
60 if point_in_rect(*area, x, y) {
61 return Some(*idx);
62 }
63 }
64 None
65 }
66
67 pub fn item_at(&self, x: u16, y: u16) -> Option<usize> {
69 for (idx, area) in &self.item_areas {
70 if point_in_rect(*area, x, y) {
71 return Some(*idx);
72 }
73 }
74 None
75 }
76
77 pub fn submenu_item_at(&self, x: u16, y: u16) -> Option<(usize, usize)> {
79 for (depth, idx, area) in &self.submenu_areas {
80 if point_in_rect(*area, x, y) {
81 return Some((*depth, *idx));
82 }
83 }
84 None
85 }
86
87 pub fn hit_test(&self, x: u16, y: u16) -> Option<MenuHit> {
89 if let Some((depth, idx)) = self.submenu_item_at(x, y) {
91 return Some(MenuHit::SubmenuItem { depth, index: idx });
92 }
93
94 if let Some(idx) = self.item_at(x, y) {
96 return Some(MenuHit::DropdownItem(idx));
97 }
98
99 if let Some(idx) = self.menu_at(x, y) {
101 return Some(MenuHit::MenuLabel(idx));
102 }
103
104 if point_in_rect(self.bar_area, x, y) {
106 return Some(MenuHit::BarBackground);
107 }
108
109 None
110 }
111}
112
113pub use fresh_core::menu::MenuContext;
115
116fn is_menu_item_enabled(item: &MenuItem, context: &MenuContext) -> bool {
117 match item {
118 MenuItem::Action { when, .. } => {
119 match when.as_deref() {
120 Some(condition) => context.get(condition),
121 None => true, }
123 }
124 _ => true,
125 }
126}
127
128fn is_checkbox_checked(checkbox: &Option<String>, context: &MenuContext) -> bool {
129 match checkbox.as_deref() {
130 Some(name) => context.get(name),
131 None => false,
132 }
133}
134
135#[derive(Debug, Clone)]
146pub struct MenuState {
147 pub active_menu: Option<usize>,
149 pub highlighted_item: Option<usize>,
151 pub submenu_path: Vec<usize>,
154 pub plugin_menus: Vec<Menu>,
156 pub context: MenuContext,
158 pub themes_dir: std::path::PathBuf,
161}
162
163impl MenuState {
164 pub fn new(themes_dir: std::path::PathBuf) -> Self {
165 Self {
166 active_menu: None,
167 highlighted_item: None,
168 submenu_path: Vec::new(),
169 plugin_menus: Vec::new(),
170 context: MenuContext::default(),
171 themes_dir,
172 }
173 }
174
175 #[cfg(test)]
177 pub fn for_testing() -> Self {
178 Self::new(std::path::PathBuf::new())
179 }
180
181 pub fn open_menu(&mut self, index: usize) {
183 self.active_menu = Some(index);
184 self.highlighted_item = Some(0);
185 self.submenu_path.clear();
186 }
187
188 pub fn close_menu(&mut self) {
190 self.active_menu = None;
191 self.highlighted_item = None;
192 self.submenu_path.clear();
193 }
194
195 pub fn next_menu(&mut self, menus: &[Menu]) {
198 let Some(active) = self.active_menu else {
199 return;
200 };
201 let total = menus.len();
202 if total == 0 {
203 return;
204 }
205
206 for i in 1..=total {
208 let next_idx = (active + i) % total;
209 if self.is_menu_visible(&menus[next_idx]) {
210 self.active_menu = Some(next_idx);
211 self.highlighted_item = Some(0);
212 self.submenu_path.clear();
213 return;
214 }
215 }
216 }
218
219 pub fn prev_menu(&mut self, menus: &[Menu]) {
222 let Some(active) = self.active_menu else {
223 return;
224 };
225 let total = menus.len();
226 if total == 0 {
227 return;
228 }
229
230 for i in 1..=total {
232 let prev_idx = (active + total - i) % total;
233 if self.is_menu_visible(&menus[prev_idx]) {
234 self.active_menu = Some(prev_idx);
235 self.highlighted_item = Some(0);
236 self.submenu_path.clear();
237 return;
238 }
239 }
240 }
242
243 fn is_menu_visible(&self, menu: &Menu) -> bool {
245 match &menu.when {
246 Some(condition) => self.context.get(condition),
247 None => true, }
249 }
250
251 pub fn in_submenu(&self) -> bool {
253 !self.submenu_path.is_empty()
254 }
255
256 pub fn submenu_depth(&self) -> usize {
258 self.submenu_path.len()
259 }
260
261 pub fn open_submenu(&mut self, menus: &[Menu]) -> bool {
264 let Some(active_idx) = self.active_menu else {
265 return false;
266 };
267 let Some(highlighted) = self.highlighted_item else {
268 return false;
269 };
270
271 let Some(menu) = menus.get(active_idx) else {
273 return false;
274 };
275 let Some(items) = self.get_current_items_cloned(menu) else {
276 return false;
277 };
278
279 if let Some(item) = items.get(highlighted) {
281 match item {
282 MenuItem::Submenu {
283 items: submenu_items,
284 ..
285 } if !submenu_items.is_empty() => {
286 self.submenu_path.push(highlighted);
287 self.highlighted_item = Some(0);
288 return true;
289 }
290 MenuItem::DynamicSubmenu { source, .. } => {
291 let generated = generate_dynamic_items(source, &self.themes_dir);
293 if !generated.is_empty() {
294 self.submenu_path.push(highlighted);
295 self.highlighted_item = Some(0);
296 return true;
297 }
298 }
299 _ => {}
300 }
301 }
302 false
303 }
304
305 pub fn close_submenu(&mut self) -> bool {
308 if let Some(parent_idx) = self.submenu_path.pop() {
309 self.highlighted_item = Some(parent_idx);
310 true
311 } else {
312 false
313 }
314 }
315
316 pub fn get_current_items<'a>(
318 &self,
319 menus: &'a [Menu],
320 active_idx: usize,
321 ) -> Option<&'a [MenuItem]> {
322 let menu = menus.get(active_idx)?;
323 let mut items: &[MenuItem] = &menu.items;
324
325 for &idx in &self.submenu_path {
326 match items.get(idx)? {
327 MenuItem::Submenu {
328 items: submenu_items,
329 ..
330 } => {
331 items = submenu_items;
332 }
333 _ => return None,
334 }
335 }
336
337 Some(items)
338 }
339
340 pub fn get_current_items_cloned(&self, menu: &Menu) -> Option<Vec<MenuItem>> {
343 let mut items: Vec<MenuItem> = menu
345 .items
346 .iter()
347 .map(|i| i.expand_dynamic(&self.themes_dir))
348 .collect();
349
350 for &idx in &self.submenu_path {
351 match items.get(idx)?.expand_dynamic(&self.themes_dir) {
352 MenuItem::Submenu {
353 items: submenu_items,
354 ..
355 } => {
356 items = submenu_items;
357 }
358 _ => return None,
359 }
360 }
361
362 Some(items)
363 }
364
365 pub fn next_item(&mut self, menu: &Menu) {
367 let Some(idx) = self.highlighted_item else {
368 return;
369 };
370
371 let Some(items) = self.get_current_items_cloned(menu) else {
373 return;
374 };
375
376 if items.is_empty() {
377 return;
378 }
379
380 let mut next = (idx + 1) % items.len();
382 while next != idx && self.should_skip_item(&items[next]) {
383 next = (next + 1) % items.len();
384 }
385 self.highlighted_item = Some(next);
386 }
387
388 pub fn prev_item(&mut self, menu: &Menu) {
390 let Some(idx) = self.highlighted_item else {
391 return;
392 };
393
394 let Some(items) = self.get_current_items_cloned(menu) else {
396 return;
397 };
398
399 if items.is_empty() {
400 return;
401 }
402
403 let total = items.len();
405 let mut prev = (idx + total - 1) % total;
406 while prev != idx && self.should_skip_item(&items[prev]) {
407 prev = (prev + total - 1) % total;
408 }
409 self.highlighted_item = Some(prev);
410 }
411
412 fn should_skip_item(&self, item: &MenuItem) -> bool {
414 match item {
415 MenuItem::Separator { .. } => true,
416 MenuItem::Action { when, .. } => {
417 match when.as_deref() {
419 Some(condition) => !self.context.get(condition),
420 None => false, }
422 }
423 _ => false,
424 }
425 }
426
427 pub fn get_highlighted_action(
430 &self,
431 menus: &[Menu],
432 ) -> Option<(String, std::collections::HashMap<String, serde_json::Value>)> {
433 let active_menu = self.active_menu?;
434 let highlighted_item = self.highlighted_item?;
435
436 let menu = menus.get(active_menu)?;
438 let items = self.get_current_items_cloned(menu)?;
439 let item = items.get(highlighted_item)?;
440
441 match item {
442 MenuItem::Action { action, args, .. } => {
443 if is_menu_item_enabled(item, &self.context) {
444 Some((action.clone(), args.clone()))
445 } else {
446 None
447 }
448 }
449 _ => None,
450 }
451 }
452
453 pub fn is_highlighted_submenu(&self, menus: &[Menu]) -> bool {
455 let Some(active_menu) = self.active_menu else {
456 return false;
457 };
458 let Some(highlighted_item) = self.highlighted_item else {
459 return false;
460 };
461
462 let Some(menu) = menus.get(active_menu) else {
464 return false;
465 };
466 let Some(items) = self.get_current_items_cloned(menu) else {
467 return false;
468 };
469
470 matches!(
471 items.get(highlighted_item),
472 Some(MenuItem::Submenu { .. } | MenuItem::DynamicSubmenu { .. })
473 )
474 }
475}
476
477pub struct MenuRenderer;
479
480impl MenuRenderer {
481 #[allow(clippy::too_many_arguments)]
495 pub fn render(
496 frame: &mut Frame,
497 area: Rect,
498 menu_config: &MenuConfig,
499 menu_state: &MenuState,
500 keybindings: &crate::input::keybindings::KeybindingResolver,
501 theme: &Theme,
502 hover_target: Option<&crate::app::HoverTarget>,
503 mnemonics_enabled: bool,
504 ) -> MenuLayout {
505 let mut layout = MenuLayout::new(area);
506 let all_menus: Vec<Menu> = menu_config
508 .menus
509 .iter()
510 .chain(menu_state.plugin_menus.iter())
511 .cloned()
512 .map(|mut menu| {
513 menu.expand_dynamic_items(&menu_state.themes_dir);
514 menu
515 })
516 .collect();
517
518 let menu_visible: Vec<bool> = all_menus
520 .iter()
521 .map(|menu| match &menu.when {
522 Some(condition) => menu_state.context.get(condition),
523 None => true, })
525 .collect();
526
527 let mut spans = Vec::new();
529 let mut current_x = area.x;
530
531 for (idx, menu) in all_menus.iter().enumerate() {
532 if !menu_visible[idx] {
534 continue;
535 }
536
537 let is_active = menu_state.active_menu == Some(idx);
538 let is_hovered =
539 matches!(hover_target, Some(crate::app::HoverTarget::MenuBarItem(i)) if *i == idx);
540
541 let base_style = if is_active {
542 Style::default()
543 .fg(theme.menu_active_fg)
544 .bg(theme.menu_active_bg)
545 .add_modifier(Modifier::BOLD)
546 } else if is_hovered {
547 Style::default()
548 .fg(theme.menu_hover_fg)
549 .bg(theme.menu_hover_bg)
550 } else {
551 Style::default().fg(theme.menu_fg).bg(theme.menu_bg)
552 };
553
554 let label_width = str_width(&menu.label) as u16 + 2;
556
557 layout
559 .menu_areas
560 .push((idx, Rect::new(current_x, area.y, label_width, 1)));
561
562 let mnemonic = if mnemonics_enabled {
564 keybindings.find_menu_mnemonic(&menu.label)
565 } else {
566 None
567 };
568
569 spans.push(Span::styled(" ", base_style));
571
572 if let Some(mnemonic_char) = mnemonic {
573 let mut found = false;
575 for c in menu.label.chars() {
576 if !found && c.to_ascii_lowercase() == mnemonic_char {
577 spans.push(Span::styled(
579 c.to_string(),
580 base_style.add_modifier(Modifier::UNDERLINED),
581 ));
582 found = true;
583 } else {
584 spans.push(Span::styled(c.to_string(), base_style));
585 }
586 }
587 } else {
588 spans.push(Span::styled(menu.label.clone(), base_style));
590 }
591
592 spans.push(Span::styled(" ", base_style));
593 spans.push(Span::raw(" "));
594
595 current_x += label_width + 1;
597 }
598
599 let line = Line::from(spans);
600 let paragraph = Paragraph::new(line).style(Style::default().bg(theme.menu_bg));
601 frame.render_widget(paragraph, area);
602
603 if let Some(active_idx) = menu_state.active_menu {
605 if let Some(menu) = all_menus.get(active_idx) {
606 Self::render_dropdown_chain(
607 frame,
608 area,
609 menu,
610 menu_state,
611 active_idx,
612 &all_menus,
613 keybindings,
614 theme,
615 hover_target,
616 &mut layout,
617 );
618 }
619 }
620
621 layout
622 }
623
624 #[allow(clippy::too_many_arguments)]
626 fn render_dropdown_chain(
627 frame: &mut Frame,
628 menu_bar_area: Rect,
629 menu: &Menu,
630 menu_state: &MenuState,
631 menu_index: usize,
632 all_menus: &[Menu],
633 keybindings: &crate::input::keybindings::KeybindingResolver,
634 theme: &Theme,
635 hover_target: Option<&crate::app::HoverTarget>,
636 layout: &mut MenuLayout,
637 ) {
638 let mut x_offset = 0usize;
641 for (idx, m) in all_menus.iter().enumerate() {
642 if idx == menu_index {
643 break;
644 }
645 let is_visible = match &m.when {
647 Some(condition) => menu_state.context.get(condition),
648 None => true,
649 };
650 if is_visible {
651 x_offset += str_width(&m.label) + 3; }
653 }
654
655 let terminal_width = frame.area().width;
656 let terminal_height = frame.area().height;
657
658 let mut current_items: &[MenuItem] = &menu.items;
660 let mut current_x = menu_bar_area.x.saturating_add(x_offset as u16);
661 let mut current_y = menu_bar_area.y.saturating_add(1);
662
663 for depth in 0..=menu_state.submenu_path.len() {
666 let is_deepest = depth == menu_state.submenu_path.len();
667 let highlighted_item = if is_deepest {
668 menu_state.highlighted_item
669 } else {
670 Some(menu_state.submenu_path[depth])
671 };
672
673 let dropdown_rect = Self::render_dropdown_level(
675 frame,
676 current_items,
677 highlighted_item,
678 current_x,
679 current_y,
680 terminal_width,
681 terminal_height,
682 depth,
683 &menu_state.submenu_path,
684 menu_index,
685 keybindings,
686 theme,
687 hover_target,
688 &menu_state.context,
689 layout,
690 );
691
692 if !is_deepest {
694 let submenu_idx = menu_state.submenu_path[depth];
695 let submenu_items = match current_items.get(submenu_idx) {
697 Some(MenuItem::Submenu { items, .. }) => Some(items.as_slice()),
698 Some(MenuItem::DynamicSubmenu { .. }) => {
699 None
702 }
703 _ => None,
704 };
705 if let Some(items) = submenu_items {
706 current_items = items;
707 current_x = dropdown_rect
709 .x
710 .saturating_add(dropdown_rect.width.saturating_sub(1));
711 current_y = dropdown_rect.y.saturating_add(submenu_idx as u16 + 1); let next_width = Self::calculate_dropdown_width(items);
715 if current_x.saturating_add(next_width as u16) > terminal_width {
716 current_x = dropdown_rect
717 .x
718 .saturating_sub(next_width as u16)
719 .saturating_add(1);
720 }
721 } else {
722 break;
723 }
724 }
725 }
726 }
727
728 fn calculate_dropdown_width(items: &[MenuItem]) -> usize {
730 items
731 .iter()
732 .map(|item| match item {
733 MenuItem::Action { label, .. } => str_width(label) + 20,
734 MenuItem::Submenu { label, .. } => str_width(label) + 20,
735 MenuItem::DynamicSubmenu { label, .. } => str_width(label) + 20,
736 MenuItem::Separator { .. } => 20,
737 MenuItem::Label { info } => str_width(info) + 4,
738 })
739 .max()
740 .unwrap_or(20)
741 .min(40)
742 }
743
744 #[allow(clippy::too_many_arguments)]
746 fn render_dropdown_level(
747 frame: &mut Frame,
748 items: &[MenuItem],
749 highlighted_item: Option<usize>,
750 x: u16,
751 y: u16,
752 terminal_width: u16,
753 terminal_height: u16,
754 depth: usize,
755 submenu_path: &[usize],
756 menu_index: usize,
757 keybindings: &crate::input::keybindings::KeybindingResolver,
758 theme: &Theme,
759 hover_target: Option<&crate::app::HoverTarget>,
760 context: &MenuContext,
761 layout: &mut MenuLayout,
762 ) -> Rect {
763 let max_width = Self::calculate_dropdown_width(items);
764 let dropdown_height = items.len() + 2; let desired_width = max_width as u16;
767 let desired_height = dropdown_height as u16;
768
769 let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
771 terminal_width.saturating_sub(desired_width)
772 } else {
773 x
774 };
775
776 let available_height = terminal_height.saturating_sub(y);
777 let height = desired_height.min(available_height);
778
779 let available_width = terminal_width.saturating_sub(adjusted_x);
780 let width = desired_width.min(available_width);
781
782 if width < 10 || height < 3 {
784 return Rect {
785 x: adjusted_x,
786 y,
787 width,
788 height,
789 };
790 }
791
792 let dropdown_area = Rect {
793 x: adjusted_x,
794 y,
795 width,
796 height,
797 };
798
799 let mut lines = Vec::new();
801 let max_items = (height.saturating_sub(2)) as usize;
802 let items_to_show = items.len().min(max_items);
803 let content_width = (width as usize).saturating_sub(2);
804
805 for (idx, item) in items.iter().enumerate().take(items_to_show) {
806 let is_highlighted = highlighted_item == Some(idx);
807 let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
809
810 let is_hovered = if depth == 0 {
812 matches!(
813 hover_target,
814 Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
815 )
816 } else {
817 matches!(
818 hover_target,
819 Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
820 )
821 };
822 let enabled = is_menu_item_enabled(item, context);
823
824 let item_area = Rect::new(adjusted_x + 1, y + 1 + idx as u16, content_width as u16, 1);
827 if depth == 0 {
828 layout.item_areas.push((idx, item_area));
829 } else {
830 layout.submenu_areas.push((depth, idx, item_area));
831 }
832
833 let line = match item {
834 MenuItem::Action {
835 label,
836 action,
837 checkbox,
838 ..
839 } => {
840 let style = if !enabled {
841 Style::default()
842 .fg(theme.menu_disabled_fg)
843 .bg(theme.menu_disabled_bg)
844 } else if is_highlighted {
845 Style::default()
846 .fg(theme.menu_highlight_fg)
847 .bg(theme.menu_highlight_bg)
848 } else if is_hovered {
849 Style::default()
850 .fg(theme.menu_hover_fg)
851 .bg(theme.menu_hover_bg)
852 } else {
853 Style::default()
854 .fg(theme.menu_dropdown_fg)
855 .bg(theme.menu_dropdown_bg)
856 };
857
858 let keybinding = keybindings
859 .find_keybinding_for_action(
860 action,
861 crate::input::keybindings::KeyContext::Normal,
862 )
863 .unwrap_or_default();
864
865 let checkbox_icon = if checkbox.is_some() {
866 if is_checkbox_checked(checkbox, context) {
867 "☑ "
868 } else {
869 "☐ "
870 }
871 } else {
872 ""
873 };
874
875 let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
876 let label_display_width = str_width(label);
877 let keybinding_display_width = str_width(&keybinding);
878
879 let text = if keybinding.is_empty() {
880 let padding_needed =
881 content_width.saturating_sub(checkbox_width + label_display_width + 1);
882 format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
883 } else {
884 let padding_needed = content_width.saturating_sub(
885 checkbox_width + label_display_width + keybinding_display_width + 2,
886 );
887 format!(
888 " {}{}{} {}",
889 checkbox_icon,
890 label,
891 " ".repeat(padding_needed),
892 keybinding
893 )
894 };
895
896 Line::from(vec![Span::styled(text, style)])
897 }
898 MenuItem::Separator { .. } => {
899 let separator = "─".repeat(content_width);
900 Line::from(vec![Span::styled(
901 format!(" {separator}"),
902 Style::default()
903 .fg(theme.menu_separator_fg)
904 .bg(theme.menu_dropdown_bg),
905 )])
906 }
907 MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
908 let style = if is_highlighted || has_open_submenu {
910 Style::default()
911 .fg(theme.menu_highlight_fg)
912 .bg(theme.menu_highlight_bg)
913 } else if is_hovered {
914 Style::default()
915 .fg(theme.menu_hover_fg)
916 .bg(theme.menu_hover_bg)
917 } else {
918 Style::default()
919 .fg(theme.menu_dropdown_fg)
920 .bg(theme.menu_dropdown_bg)
921 };
922
923 let label_display_width = str_width(label);
926 let padding_needed = content_width.saturating_sub(label_display_width + 5);
927 Line::from(vec![Span::styled(
928 format!(" {}{} > ", label, " ".repeat(padding_needed)),
929 style,
930 )])
931 }
932 MenuItem::Label { info } => {
933 let style = Style::default()
935 .fg(theme.menu_disabled_fg)
936 .bg(theme.menu_dropdown_bg);
937 let info_display_width = str_width(info);
938 let padding_needed = content_width.saturating_sub(info_display_width);
939 Line::from(vec![Span::styled(
940 format!(" {}{}", info, " ".repeat(padding_needed)),
941 style,
942 )])
943 }
944 };
945
946 lines.push(line);
947 }
948
949 let block = Block::default()
950 .borders(Borders::ALL)
951 .border_style(Style::default().fg(theme.menu_border_fg))
952 .style(Style::reset().bg(theme.menu_dropdown_bg));
953
954 let paragraph = Paragraph::new(lines).block(block);
955 frame.render_widget(paragraph, dropdown_area);
956
957 dropdown_area
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964 use std::collections::HashMap;
965
966 fn create_test_menus() -> Vec<Menu> {
967 vec![
968 Menu {
969 id: None,
970 label: "File".to_string(),
971 items: vec![
972 MenuItem::Action {
973 label: "New".to_string(),
974 action: "new_file".to_string(),
975 args: HashMap::new(),
976 when: None,
977 checkbox: None,
978 },
979 MenuItem::Separator { separator: true },
980 MenuItem::Action {
981 label: "Save".to_string(),
982 action: "save".to_string(),
983 args: HashMap::new(),
984 when: None,
985 checkbox: None,
986 },
987 MenuItem::Action {
988 label: "Quit".to_string(),
989 action: "quit".to_string(),
990 args: HashMap::new(),
991 when: None,
992 checkbox: None,
993 },
994 ],
995 when: None,
996 },
997 Menu {
998 id: None,
999 label: "Edit".to_string(),
1000 items: vec![
1001 MenuItem::Action {
1002 label: "Undo".to_string(),
1003 action: "undo".to_string(),
1004 args: HashMap::new(),
1005 when: None,
1006 checkbox: None,
1007 },
1008 MenuItem::Action {
1009 label: "Redo".to_string(),
1010 action: "redo".to_string(),
1011 args: HashMap::new(),
1012 when: None,
1013 checkbox: None,
1014 },
1015 ],
1016 when: None,
1017 },
1018 Menu {
1019 id: None,
1020 label: "View".to_string(),
1021 items: vec![MenuItem::Action {
1022 label: "Toggle Explorer".to_string(),
1023 action: "toggle_file_explorer".to_string(),
1024 args: HashMap::new(),
1025 when: None,
1026 checkbox: None,
1027 }],
1028 when: None,
1029 },
1030 ]
1031 }
1032
1033 #[test]
1034 fn test_menu_state_default() {
1035 let state = MenuState::for_testing();
1036 assert_eq!(state.active_menu, None);
1037 assert_eq!(state.highlighted_item, None);
1038 assert!(state.plugin_menus.is_empty());
1039 }
1040
1041 #[test]
1042 fn test_menu_state_open_menu() {
1043 let mut state = MenuState::for_testing();
1044 state.open_menu(2);
1045 assert_eq!(state.active_menu, Some(2));
1046 assert_eq!(state.highlighted_item, Some(0));
1047 }
1048
1049 #[test]
1050 fn test_menu_state_close_menu() {
1051 let mut state = MenuState::for_testing();
1052 state.open_menu(1);
1053 state.close_menu();
1054 assert_eq!(state.active_menu, None);
1055 assert_eq!(state.highlighted_item, None);
1056 }
1057
1058 #[test]
1059 fn test_menu_state_next_menu() {
1060 let mut state = MenuState::for_testing();
1061 let menus = create_test_menus();
1062 state.open_menu(0);
1063
1064 state.next_menu(&menus);
1065 assert_eq!(state.active_menu, Some(1));
1066
1067 state.next_menu(&menus);
1068 assert_eq!(state.active_menu, Some(2));
1069
1070 state.next_menu(&menus);
1072 assert_eq!(state.active_menu, Some(0));
1073 }
1074
1075 #[test]
1076 fn test_menu_state_prev_menu() {
1077 let mut state = MenuState::for_testing();
1078 let menus = create_test_menus();
1079 state.open_menu(0);
1080
1081 state.prev_menu(&menus);
1083 assert_eq!(state.active_menu, Some(2));
1084
1085 state.prev_menu(&menus);
1086 assert_eq!(state.active_menu, Some(1));
1087
1088 state.prev_menu(&menus);
1089 assert_eq!(state.active_menu, Some(0));
1090 }
1091
1092 #[test]
1093 fn test_menu_state_next_item_skips_separator() {
1094 let mut state = MenuState::for_testing();
1095 let menus = create_test_menus();
1096 state.open_menu(0);
1097
1098 assert_eq!(state.highlighted_item, Some(0));
1100
1101 state.next_item(&menus[0]);
1103 assert_eq!(state.highlighted_item, Some(2));
1104
1105 state.next_item(&menus[0]);
1107 assert_eq!(state.highlighted_item, Some(3));
1108
1109 state.next_item(&menus[0]);
1111 assert_eq!(state.highlighted_item, Some(0));
1112 }
1113
1114 #[test]
1115 fn test_menu_state_prev_item_skips_separator() {
1116 let mut state = MenuState::for_testing();
1117 let menus = create_test_menus();
1118 state.open_menu(0);
1119 state.highlighted_item = Some(2); state.prev_item(&menus[0]);
1123 assert_eq!(state.highlighted_item, Some(0));
1124
1125 state.prev_item(&menus[0]);
1127 assert_eq!(state.highlighted_item, Some(3));
1128 }
1129
1130 #[test]
1131 fn test_get_highlighted_action() {
1132 let mut state = MenuState::for_testing();
1133 let menus = create_test_menus();
1134 state.open_menu(0);
1135 state.highlighted_item = Some(2); let action = state.get_highlighted_action(&menus);
1138 assert!(action.is_some());
1139 let (action_name, _args) = action.unwrap();
1140 assert_eq!(action_name, "save");
1141 }
1142
1143 #[test]
1144 fn test_menu_item_when_requires_selection() {
1145 let mut state = MenuState::for_testing();
1146 let select_menu = Menu {
1147 id: None,
1148 label: "Edit".to_string(),
1149 items: vec![MenuItem::Action {
1150 label: "Find in Selection".to_string(),
1151 action: "find_in_selection".to_string(),
1152 args: HashMap::new(),
1153 when: Some(context_keys::HAS_SELECTION.to_string()),
1154 checkbox: None,
1155 }],
1156 when: None,
1157 };
1158 state.open_menu(0);
1159 state.highlighted_item = Some(0);
1160
1161 assert!(state
1163 .get_highlighted_action(std::slice::from_ref(&select_menu))
1164 .is_none());
1165
1166 state.context.set(context_keys::HAS_SELECTION, true);
1168 assert!(state.get_highlighted_action(&[select_menu]).is_some());
1169 }
1170
1171 #[test]
1172 fn test_get_highlighted_action_none_when_closed() {
1173 let state = MenuState::for_testing();
1174 let menus = create_test_menus();
1175 assert!(state.get_highlighted_action(&menus).is_none());
1176 }
1177
1178 #[test]
1179 fn test_get_highlighted_action_none_for_separator() {
1180 let mut state = MenuState::for_testing();
1181 let menus = create_test_menus();
1182 state.open_menu(0);
1183 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1186 }
1187
1188 #[test]
1189 fn test_menu_layout_menu_at() {
1190 let bar_area = Rect::new(0, 0, 80, 1);
1192 let mut layout = MenuLayout::new(bar_area);
1193
1194 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1196 layout.menu_areas.push((1, Rect::new(7, 0, 6, 1)));
1198 layout.menu_areas.push((2, Rect::new(14, 0, 6, 1)));
1200
1201 assert_eq!(layout.menu_at(0, 0), Some(0));
1203 assert_eq!(layout.menu_at(3, 0), Some(0));
1204 assert_eq!(layout.menu_at(5, 0), Some(0));
1205
1206 assert_eq!(layout.menu_at(6, 0), None);
1208
1209 assert_eq!(layout.menu_at(7, 0), Some(1));
1211 assert_eq!(layout.menu_at(10, 0), Some(1));
1212 assert_eq!(layout.menu_at(12, 0), Some(1));
1213
1214 assert_eq!(layout.menu_at(13, 0), None);
1216
1217 assert_eq!(layout.menu_at(14, 0), Some(2));
1219 assert_eq!(layout.menu_at(17, 0), Some(2));
1220 assert_eq!(layout.menu_at(19, 0), Some(2));
1221
1222 assert_eq!(layout.menu_at(20, 0), None);
1224 assert_eq!(layout.menu_at(100, 0), None);
1225
1226 assert_eq!(layout.menu_at(3, 1), None);
1228 }
1229
1230 #[test]
1231 fn test_menu_layout_item_at() {
1232 let bar_area = Rect::new(0, 0, 80, 1);
1234 let mut layout = MenuLayout::new(bar_area);
1235
1236 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1239 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1241 layout.item_areas.push((2, Rect::new(1, 4, 20, 1)));
1243 layout.item_areas.push((3, Rect::new(1, 5, 20, 1)));
1245
1246 assert_eq!(layout.item_at(5, 0), None);
1248 assert_eq!(layout.item_at(5, 1), None);
1250
1251 assert_eq!(layout.item_at(5, 2), Some(0));
1253
1254 assert_eq!(layout.item_at(5, 3), Some(1));
1256
1257 assert_eq!(layout.item_at(5, 4), Some(2));
1259
1260 assert_eq!(layout.item_at(5, 5), Some(3));
1262
1263 assert_eq!(layout.item_at(5, 6), None);
1265 assert_eq!(layout.item_at(5, 100), None);
1266 }
1267
1268 #[test]
1269 fn test_menu_layout_hit_test() {
1270 let bar_area = Rect::new(0, 0, 80, 1);
1271 let mut layout = MenuLayout::new(bar_area);
1272
1273 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1275
1276 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1278 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1279
1280 layout.submenu_areas.push((1, 0, Rect::new(22, 3, 15, 1)));
1282
1283 assert_eq!(layout.hit_test(3, 0), Some(MenuHit::MenuLabel(0)));
1285
1286 assert_eq!(layout.hit_test(5, 2), Some(MenuHit::DropdownItem(0)));
1288
1289 assert_eq!(
1291 layout.hit_test(25, 3),
1292 Some(MenuHit::SubmenuItem { depth: 1, index: 0 })
1293 );
1294
1295 assert_eq!(layout.hit_test(50, 0), Some(MenuHit::BarBackground));
1297
1298 assert_eq!(layout.hit_test(50, 10), None);
1300 }
1301
1302 #[test]
1303 fn test_menu_config_json_parsing() {
1304 let json = r#"{
1305 "menus": [
1306 {
1307 "label": "File",
1308 "items": [
1309 { "label": "New", "action": "new_file" },
1310 { "separator": true },
1311 { "label": "Save", "action": "save" }
1312 ]
1313 }
1314 ]
1315 }"#;
1316
1317 let config: MenuConfig = serde_json::from_str(json).unwrap();
1318 assert_eq!(config.menus.len(), 1);
1319 assert_eq!(config.menus[0].label, "File");
1320 assert_eq!(config.menus[0].items.len(), 3);
1321
1322 match &config.menus[0].items[0] {
1323 MenuItem::Action { label, action, .. } => {
1324 assert_eq!(label, "New");
1325 assert_eq!(action, "new_file");
1326 }
1327 _ => panic!("Expected Action"),
1328 }
1329
1330 assert!(matches!(
1331 config.menus[0].items[1],
1332 MenuItem::Separator { .. }
1333 ));
1334
1335 match &config.menus[0].items[2] {
1336 MenuItem::Action { label, action, .. } => {
1337 assert_eq!(label, "Save");
1338 assert_eq!(action, "save");
1339 }
1340 _ => panic!("Expected Action"),
1341 }
1342 }
1343
1344 #[test]
1345 fn test_menu_item_with_args() {
1346 let json = r#"{
1347 "label": "Go to Line",
1348 "action": "goto_line",
1349 "args": { "line": 42 }
1350 }"#;
1351
1352 let item: MenuItem = serde_json::from_str(json).unwrap();
1353 match item {
1354 MenuItem::Action {
1355 label,
1356 action,
1357 args,
1358 ..
1359 } => {
1360 assert_eq!(label, "Go to Line");
1361 assert_eq!(action, "goto_line");
1362 assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1363 }
1364 _ => panic!("Expected Action with args"),
1365 }
1366 }
1367
1368 #[test]
1369 fn test_empty_menu_config() {
1370 let json = r#"{ "menus": [] }"#;
1371 let config: MenuConfig = serde_json::from_str(json).unwrap();
1372 assert!(config.menus.is_empty());
1373 }
1374
1375 #[test]
1376 fn test_menu_mnemonic_lookup() {
1377 use crate::config::Config;
1378 use crate::input::keybindings::KeybindingResolver;
1379
1380 let config = Config::default();
1381 let resolver = KeybindingResolver::new(&config);
1382
1383 assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1385 assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1386 assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1387 assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1388 assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1389 assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1390
1391 assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1393 assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1394
1395 assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1397 }
1398
1399 fn create_menu_with_submenus() -> Vec<Menu> {
1400 vec![Menu {
1401 id: None,
1402 label: "View".to_string(),
1403 items: vec![
1404 MenuItem::Action {
1405 label: "Toggle Explorer".to_string(),
1406 action: "toggle_file_explorer".to_string(),
1407 args: HashMap::new(),
1408 when: None,
1409 checkbox: None,
1410 },
1411 MenuItem::Submenu {
1412 label: "Terminal".to_string(),
1413 items: vec![
1414 MenuItem::Action {
1415 label: "Open Terminal".to_string(),
1416 action: "open_terminal".to_string(),
1417 args: HashMap::new(),
1418 when: None,
1419 checkbox: None,
1420 },
1421 MenuItem::Action {
1422 label: "Close Terminal".to_string(),
1423 action: "close_terminal".to_string(),
1424 args: HashMap::new(),
1425 when: None,
1426 checkbox: None,
1427 },
1428 MenuItem::Submenu {
1429 label: "Terminal Settings".to_string(),
1430 items: vec![MenuItem::Action {
1431 label: "Font Size".to_string(),
1432 action: "terminal_font_size".to_string(),
1433 args: HashMap::new(),
1434 when: None,
1435 checkbox: None,
1436 }],
1437 },
1438 ],
1439 },
1440 MenuItem::Separator { separator: true },
1441 MenuItem::Action {
1442 label: "Zoom In".to_string(),
1443 action: "zoom_in".to_string(),
1444 args: HashMap::new(),
1445 when: None,
1446 checkbox: None,
1447 },
1448 ],
1449 when: None,
1450 }]
1451 }
1452
1453 #[test]
1454 fn test_submenu_open_and_close() {
1455 let mut state = MenuState::for_testing();
1456 let menus = create_menu_with_submenus();
1457
1458 state.open_menu(0);
1459 assert!(state.submenu_path.is_empty());
1460 assert!(!state.in_submenu());
1461
1462 state.highlighted_item = Some(1);
1464
1465 assert!(state.open_submenu(&menus));
1467 assert_eq!(state.submenu_path, vec![1]);
1468 assert!(state.in_submenu());
1469 assert_eq!(state.submenu_depth(), 1);
1470 assert_eq!(state.highlighted_item, Some(0)); assert!(state.close_submenu());
1474 assert!(state.submenu_path.is_empty());
1475 assert!(!state.in_submenu());
1476 assert_eq!(state.highlighted_item, Some(1)); }
1478
1479 #[test]
1480 fn test_nested_submenu() {
1481 let mut state = MenuState::for_testing();
1482 let menus = create_menu_with_submenus();
1483
1484 state.open_menu(0);
1485 state.highlighted_item = Some(1); assert!(state.open_submenu(&menus));
1489 assert_eq!(state.submenu_depth(), 1);
1490
1491 state.highlighted_item = Some(2);
1493
1494 assert!(state.open_submenu(&menus));
1496 assert_eq!(state.submenu_path, vec![1, 2]);
1497 assert_eq!(state.submenu_depth(), 2);
1498 assert_eq!(state.highlighted_item, Some(0));
1499
1500 assert!(state.close_submenu());
1502 assert_eq!(state.submenu_path, vec![1]);
1503 assert_eq!(state.highlighted_item, Some(2));
1504
1505 assert!(state.close_submenu());
1507 assert!(state.submenu_path.is_empty());
1508 assert_eq!(state.highlighted_item, Some(1));
1509
1510 assert!(!state.close_submenu());
1512 }
1513
1514 #[test]
1515 fn test_get_highlighted_action_in_submenu() {
1516 let mut state = MenuState::for_testing();
1517 let menus = create_menu_with_submenus();
1518
1519 state.open_menu(0);
1520 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1524
1525 state.open_submenu(&menus);
1527 let action = state.get_highlighted_action(&menus);
1529 assert!(action.is_some());
1530 let (action_name, _) = action.unwrap();
1531 assert_eq!(action_name, "open_terminal");
1532
1533 state.highlighted_item = Some(1);
1535 let action = state.get_highlighted_action(&menus);
1536 assert!(action.is_some());
1537 let (action_name, _) = action.unwrap();
1538 assert_eq!(action_name, "close_terminal");
1539 }
1540
1541 #[test]
1542 fn test_get_current_items_at_different_depths() {
1543 let mut state = MenuState::for_testing();
1544 let menus = create_menu_with_submenus();
1545
1546 state.open_menu(0);
1547
1548 let items = state.get_current_items(&menus, 0).unwrap();
1550 assert_eq!(items.len(), 4); state.highlighted_item = Some(1);
1554 state.open_submenu(&menus);
1555
1556 let items = state.get_current_items(&menus, 0).unwrap();
1558 assert_eq!(items.len(), 3); state.highlighted_item = Some(2);
1562 state.open_submenu(&menus);
1563
1564 let items = state.get_current_items(&menus, 0).unwrap();
1566 assert_eq!(items.len(), 1); }
1568
1569 #[test]
1570 fn test_is_highlighted_submenu() {
1571 let mut state = MenuState::for_testing();
1572 let menus = create_menu_with_submenus();
1573
1574 state.open_menu(0);
1575 state.highlighted_item = Some(0); assert!(!state.is_highlighted_submenu(&menus));
1577
1578 state.highlighted_item = Some(1); assert!(state.is_highlighted_submenu(&menus));
1580
1581 state.highlighted_item = Some(2); assert!(!state.is_highlighted_submenu(&menus));
1583
1584 state.highlighted_item = Some(3); assert!(!state.is_highlighted_submenu(&menus));
1586 }
1587
1588 #[test]
1589 fn test_open_menu_clears_submenu_path() {
1590 let mut state = MenuState::for_testing();
1591 let menus = create_menu_with_submenus();
1592
1593 state.open_menu(0);
1594 state.highlighted_item = Some(1);
1595 state.open_submenu(&menus);
1596 assert!(!state.submenu_path.is_empty());
1597
1598 state.open_menu(0);
1600 assert!(state.submenu_path.is_empty());
1601 }
1602
1603 #[test]
1604 fn test_next_prev_menu_clears_submenu_path() {
1605 let mut state = MenuState::for_testing();
1606 let menus = create_menu_with_submenus();
1607
1608 state.open_menu(0);
1609 state.highlighted_item = Some(1);
1610 state.open_submenu(&menus);
1611 assert!(!state.submenu_path.is_empty());
1612
1613 state.next_menu(&menus);
1615 assert!(state.submenu_path.is_empty());
1616
1617 state.open_menu(0);
1619 state.highlighted_item = Some(1);
1620 state.open_submenu(&menus);
1621
1622 state.prev_menu(&menus);
1624 assert!(state.submenu_path.is_empty());
1625 }
1626
1627 #[test]
1628 fn test_navigation_in_submenu() {
1629 let mut state = MenuState::for_testing();
1630 let menus = create_menu_with_submenus();
1631
1632 state.open_menu(0);
1633 state.highlighted_item = Some(1);
1634 state.open_submenu(&menus);
1635
1636 assert_eq!(state.highlighted_item, Some(0));
1638
1639 state.next_item(&menus[0]);
1641 assert_eq!(state.highlighted_item, Some(1));
1642
1643 state.next_item(&menus[0]);
1645 assert_eq!(state.highlighted_item, Some(2));
1646
1647 state.next_item(&menus[0]);
1649 assert_eq!(state.highlighted_item, Some(0));
1650
1651 state.prev_item(&menus[0]);
1653 assert_eq!(state.highlighted_item, Some(2));
1654 }
1655
1656 fn calculate_dropdown_x_offset(
1658 all_menus: &[Menu],
1659 menu_index: usize,
1660 context: &MenuContext,
1661 ) -> usize {
1662 let mut x_offset = 0usize;
1663 for (idx, m) in all_menus.iter().enumerate() {
1664 if idx == menu_index {
1665 break;
1666 }
1667 let is_visible = match &m.when {
1669 Some(condition) => context.get(condition),
1670 None => true,
1671 };
1672 if is_visible {
1673 x_offset += str_width(&m.label) + 3; }
1675 }
1676 x_offset
1677 }
1678
1679 #[test]
1680 fn test_dropdown_position_skips_hidden_menus() {
1681 let menus = vec![
1683 Menu {
1684 id: None,
1685 label: "File".to_string(), items: vec![],
1687 when: None,
1688 },
1689 Menu {
1690 id: None,
1691 label: "Explorer".to_string(), items: vec![],
1693 when: Some("file_explorer_focused".to_string()),
1694 },
1695 Menu {
1696 id: None,
1697 label: "Help".to_string(), items: vec![],
1699 when: None,
1700 },
1701 ];
1702
1703 let context_hidden = MenuContext::new().with("file_explorer_focused", false);
1705 let x_help_hidden = calculate_dropdown_x_offset(&menus, 2, &context_hidden);
1706 assert_eq!(
1708 x_help_hidden, 7,
1709 "Help dropdown should be at x=7 when Explorer is hidden"
1710 );
1711
1712 let context_visible = MenuContext::new().with("file_explorer_focused", true);
1714 let x_help_visible = calculate_dropdown_x_offset(&menus, 2, &context_visible);
1715 assert_eq!(
1717 x_help_visible, 18,
1718 "Help dropdown should be at x=18 when Explorer is visible"
1719 );
1720 }
1721
1722 #[test]
1723 fn test_dropdown_position_with_multiple_hidden_menus() {
1724 let menus = vec![
1725 Menu {
1726 id: None,
1727 label: "A".to_string(), items: vec![],
1729 when: None,
1730 },
1731 Menu {
1732 id: None,
1733 label: "B".to_string(), items: vec![],
1735 when: Some("show_b".to_string()),
1736 },
1737 Menu {
1738 id: None,
1739 label: "C".to_string(), items: vec![],
1741 when: Some("show_c".to_string()),
1742 },
1743 Menu {
1744 id: None,
1745 label: "D".to_string(),
1746 items: vec![],
1747 when: None,
1748 },
1749 ];
1750
1751 let context_none = MenuContext::new();
1753 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_none), 4);
1754
1755 let context_b = MenuContext::new().with("show_b", true);
1757 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_b), 8);
1758
1759 let context_both = MenuContext::new().with("show_b", true).with("show_c", true);
1761 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_both), 12);
1762 }
1763}