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)]
145pub struct MenuState {
146 pub active_menu: Option<usize>,
148 pub highlighted_item: Option<usize>,
150 pub submenu_path: Vec<usize>,
153 pub plugin_menus: Vec<Menu>,
155 pub context: MenuContext,
157 pub themes_dir: std::path::PathBuf,
160}
161
162impl MenuState {
163 pub fn new(themes_dir: std::path::PathBuf) -> Self {
164 Self {
165 active_menu: None,
166 highlighted_item: None,
167 submenu_path: Vec::new(),
168 plugin_menus: Vec::new(),
169 context: MenuContext::default(),
170 themes_dir,
171 }
172 }
173
174 #[cfg(test)]
176 pub fn for_testing() -> Self {
177 Self::new(std::path::PathBuf::new())
178 }
179
180 pub fn open_menu(&mut self, index: usize) {
182 self.active_menu = Some(index);
183 self.highlighted_item = Some(0);
184 self.submenu_path.clear();
185 }
186
187 pub fn close_menu(&mut self) {
189 self.active_menu = None;
190 self.highlighted_item = None;
191 self.submenu_path.clear();
192 }
193
194 pub fn next_menu(&mut self, menus: &[Menu]) {
197 let Some(active) = self.active_menu else {
198 return;
199 };
200 let total = menus.len();
201 if total == 0 {
202 return;
203 }
204
205 for i in 1..=total {
207 let next_idx = (active + i) % total;
208 if self.is_menu_visible(&menus[next_idx]) {
209 self.active_menu = Some(next_idx);
210 self.highlighted_item = Some(0);
211 self.submenu_path.clear();
212 return;
213 }
214 }
215 }
217
218 pub fn prev_menu(&mut self, menus: &[Menu]) {
221 let Some(active) = self.active_menu else {
222 return;
223 };
224 let total = menus.len();
225 if total == 0 {
226 return;
227 }
228
229 for i in 1..=total {
231 let prev_idx = (active + total - i) % total;
232 if self.is_menu_visible(&menus[prev_idx]) {
233 self.active_menu = Some(prev_idx);
234 self.highlighted_item = Some(0);
235 self.submenu_path.clear();
236 return;
237 }
238 }
239 }
241
242 fn is_menu_visible(&self, menu: &Menu) -> bool {
244 match &menu.when {
245 Some(condition) => self.context.get(condition),
246 None => true, }
248 }
249
250 pub fn in_submenu(&self) -> bool {
252 !self.submenu_path.is_empty()
253 }
254
255 pub fn submenu_depth(&self) -> usize {
257 self.submenu_path.len()
258 }
259
260 pub fn open_submenu(&mut self, menus: &[Menu]) -> bool {
263 let Some(active_idx) = self.active_menu else {
264 return false;
265 };
266 let Some(highlighted) = self.highlighted_item else {
267 return false;
268 };
269
270 let Some(menu) = menus.get(active_idx) else {
272 return false;
273 };
274 let Some(items) = self.get_current_items_cloned(menu) else {
275 return false;
276 };
277
278 if let Some(item) = items.get(highlighted) {
280 match item {
281 MenuItem::Submenu {
282 items: submenu_items,
283 ..
284 } if !submenu_items.is_empty() => {
285 self.submenu_path.push(highlighted);
286 self.highlighted_item = Some(0);
287 return true;
288 }
289 MenuItem::DynamicSubmenu { source, .. } => {
290 let generated = generate_dynamic_items(source, &self.themes_dir);
292 if !generated.is_empty() {
293 self.submenu_path.push(highlighted);
294 self.highlighted_item = Some(0);
295 return true;
296 }
297 }
298 _ => {}
299 }
300 }
301 false
302 }
303
304 pub fn close_submenu(&mut self) -> bool {
307 if let Some(parent_idx) = self.submenu_path.pop() {
308 self.highlighted_item = Some(parent_idx);
309 true
310 } else {
311 false
312 }
313 }
314
315 pub fn get_current_items<'a>(
317 &self,
318 menus: &'a [Menu],
319 active_idx: usize,
320 ) -> Option<&'a [MenuItem]> {
321 let menu = menus.get(active_idx)?;
322 let mut items: &[MenuItem] = &menu.items;
323
324 for &idx in &self.submenu_path {
325 match items.get(idx)? {
326 MenuItem::Submenu {
327 items: submenu_items,
328 ..
329 } => {
330 items = submenu_items;
331 }
332 _ => return None,
333 }
334 }
335
336 Some(items)
337 }
338
339 pub fn get_current_items_cloned(&self, menu: &Menu) -> Option<Vec<MenuItem>> {
342 let mut items: Vec<MenuItem> = menu
344 .items
345 .iter()
346 .map(|i| i.expand_dynamic(&self.themes_dir))
347 .collect();
348
349 for &idx in &self.submenu_path {
350 match items.get(idx)?.expand_dynamic(&self.themes_dir) {
351 MenuItem::Submenu {
352 items: submenu_items,
353 ..
354 } => {
355 items = submenu_items;
356 }
357 _ => return None,
358 }
359 }
360
361 Some(items)
362 }
363
364 pub fn next_item(&mut self, menu: &Menu) {
366 let Some(idx) = self.highlighted_item else {
367 return;
368 };
369
370 let Some(items) = self.get_current_items_cloned(menu) else {
372 return;
373 };
374
375 if items.is_empty() {
376 return;
377 }
378
379 let mut next = (idx + 1) % items.len();
381 while next != idx && self.should_skip_item(&items[next]) {
382 next = (next + 1) % items.len();
383 }
384 self.highlighted_item = Some(next);
385 }
386
387 pub fn prev_item(&mut self, menu: &Menu) {
389 let Some(idx) = self.highlighted_item else {
390 return;
391 };
392
393 let Some(items) = self.get_current_items_cloned(menu) else {
395 return;
396 };
397
398 if items.is_empty() {
399 return;
400 }
401
402 let total = items.len();
404 let mut prev = (idx + total - 1) % total;
405 while prev != idx && self.should_skip_item(&items[prev]) {
406 prev = (prev + total - 1) % total;
407 }
408 self.highlighted_item = Some(prev);
409 }
410
411 fn should_skip_item(&self, item: &MenuItem) -> bool {
413 match item {
414 MenuItem::Separator { .. } => true,
415 MenuItem::Action { when, .. } => {
416 match when.as_deref() {
418 Some(condition) => !self.context.get(condition),
419 None => false, }
421 }
422 _ => false,
423 }
424 }
425
426 pub fn get_highlighted_action(
429 &self,
430 menus: &[Menu],
431 ) -> Option<(String, std::collections::HashMap<String, serde_json::Value>)> {
432 let active_menu = self.active_menu?;
433 let highlighted_item = self.highlighted_item?;
434
435 let menu = menus.get(active_menu)?;
437 let items = self.get_current_items_cloned(menu)?;
438 let item = items.get(highlighted_item)?;
439
440 match item {
441 MenuItem::Action { action, args, .. } => {
442 if is_menu_item_enabled(item, &self.context) {
443 Some((action.clone(), args.clone()))
444 } else {
445 None
446 }
447 }
448 _ => None,
449 }
450 }
451
452 pub fn is_highlighted_submenu(&self, menus: &[Menu]) -> bool {
454 let Some(active_menu) = self.active_menu else {
455 return false;
456 };
457 let Some(highlighted_item) = self.highlighted_item else {
458 return false;
459 };
460
461 let Some(menu) = menus.get(active_menu) else {
463 return false;
464 };
465 let Some(items) = self.get_current_items_cloned(menu) else {
466 return false;
467 };
468
469 matches!(
470 items.get(highlighted_item),
471 Some(MenuItem::Submenu { .. } | MenuItem::DynamicSubmenu { .. })
472 )
473 }
474}
475
476pub struct MenuRenderer;
478
479impl MenuRenderer {
480 pub fn render(
494 frame: &mut Frame,
495 area: Rect,
496 menu_config: &MenuConfig,
497 menu_state: &MenuState,
498 keybindings: &crate::input::keybindings::KeybindingResolver,
499 theme: &Theme,
500 hover_target: Option<&crate::app::HoverTarget>,
501 ) -> MenuLayout {
502 let mut layout = MenuLayout::new(area);
503 let all_menus: Vec<Menu> = menu_config
505 .menus
506 .iter()
507 .chain(menu_state.plugin_menus.iter())
508 .cloned()
509 .map(|mut menu| {
510 menu.expand_dynamic_items(&menu_state.themes_dir);
511 menu
512 })
513 .collect();
514
515 let menu_visible: Vec<bool> = all_menus
517 .iter()
518 .map(|menu| match &menu.when {
519 Some(condition) => menu_state.context.get(condition),
520 None => true, })
522 .collect();
523
524 let mut spans = Vec::new();
526 let mut current_x = area.x;
527
528 for (idx, menu) in all_menus.iter().enumerate() {
529 if !menu_visible[idx] {
531 continue;
532 }
533
534 let is_active = menu_state.active_menu == Some(idx);
535 let is_hovered =
536 matches!(hover_target, Some(crate::app::HoverTarget::MenuBarItem(i)) if *i == idx);
537
538 let base_style = if is_active {
539 Style::default()
540 .fg(theme.menu_active_fg)
541 .bg(theme.menu_active_bg)
542 .add_modifier(Modifier::BOLD)
543 } else if is_hovered {
544 Style::default()
545 .fg(theme.menu_hover_fg)
546 .bg(theme.menu_hover_bg)
547 } else {
548 Style::default().fg(theme.menu_fg).bg(theme.menu_bg)
549 };
550
551 let label_width = str_width(&menu.label) as u16 + 2;
553
554 layout
556 .menu_areas
557 .push((idx, Rect::new(current_x, area.y, label_width, 1)));
558
559 let mnemonic = keybindings.find_menu_mnemonic(&menu.label);
561
562 spans.push(Span::styled(" ", base_style));
564
565 if let Some(mnemonic_char) = mnemonic {
566 let mut found = false;
568 for c in menu.label.chars() {
569 if !found && c.to_ascii_lowercase() == mnemonic_char {
570 spans.push(Span::styled(
572 c.to_string(),
573 base_style.add_modifier(Modifier::UNDERLINED),
574 ));
575 found = true;
576 } else {
577 spans.push(Span::styled(c.to_string(), base_style));
578 }
579 }
580 } else {
581 spans.push(Span::styled(menu.label.clone(), base_style));
583 }
584
585 spans.push(Span::styled(" ", base_style));
586 spans.push(Span::raw(" "));
587
588 current_x += label_width + 1;
590 }
591
592 let line = Line::from(spans);
593 let paragraph = Paragraph::new(line).style(Style::default().bg(theme.menu_bg));
594 frame.render_widget(paragraph, area);
595
596 if let Some(active_idx) = menu_state.active_menu {
598 if let Some(menu) = all_menus.get(active_idx) {
599 Self::render_dropdown_chain(
600 frame,
601 area,
602 menu,
603 menu_state,
604 active_idx,
605 &all_menus,
606 keybindings,
607 theme,
608 hover_target,
609 &mut layout,
610 );
611 }
612 }
613
614 layout
615 }
616
617 #[allow(clippy::too_many_arguments)]
619 fn render_dropdown_chain(
620 frame: &mut Frame,
621 menu_bar_area: Rect,
622 menu: &Menu,
623 menu_state: &MenuState,
624 menu_index: usize,
625 all_menus: &[Menu],
626 keybindings: &crate::input::keybindings::KeybindingResolver,
627 theme: &Theme,
628 hover_target: Option<&crate::app::HoverTarget>,
629 layout: &mut MenuLayout,
630 ) {
631 let mut x_offset = 0usize;
634 for (idx, m) in all_menus.iter().enumerate() {
635 if idx == menu_index {
636 break;
637 }
638 let is_visible = match &m.when {
640 Some(condition) => menu_state.context.get(condition),
641 None => true,
642 };
643 if is_visible {
644 x_offset += str_width(&m.label) + 3; }
646 }
647
648 let terminal_width = frame.area().width;
649 let terminal_height = frame.area().height;
650
651 let mut current_items: &[MenuItem] = &menu.items;
653 let mut current_x = menu_bar_area.x.saturating_add(x_offset as u16);
654 let mut current_y = menu_bar_area.y.saturating_add(1);
655
656 for depth in 0..=menu_state.submenu_path.len() {
659 let is_deepest = depth == menu_state.submenu_path.len();
660 let highlighted_item = if is_deepest {
661 menu_state.highlighted_item
662 } else {
663 Some(menu_state.submenu_path[depth])
664 };
665
666 let dropdown_rect = Self::render_dropdown_level(
668 frame,
669 current_items,
670 highlighted_item,
671 current_x,
672 current_y,
673 terminal_width,
674 terminal_height,
675 depth,
676 &menu_state.submenu_path,
677 menu_index,
678 keybindings,
679 theme,
680 hover_target,
681 &menu_state.context,
682 layout,
683 );
684
685 if !is_deepest {
687 let submenu_idx = menu_state.submenu_path[depth];
688 let submenu_items = match current_items.get(submenu_idx) {
690 Some(MenuItem::Submenu { items, .. }) => Some(items.as_slice()),
691 Some(MenuItem::DynamicSubmenu { .. }) => {
692 None
695 }
696 _ => None,
697 };
698 if let Some(items) = submenu_items {
699 current_items = items;
700 current_x = dropdown_rect
702 .x
703 .saturating_add(dropdown_rect.width.saturating_sub(1));
704 current_y = dropdown_rect.y.saturating_add(submenu_idx as u16 + 1); let next_width = Self::calculate_dropdown_width(items);
708 if current_x.saturating_add(next_width as u16) > terminal_width {
709 current_x = dropdown_rect
710 .x
711 .saturating_sub(next_width as u16)
712 .saturating_add(1);
713 }
714 } else {
715 break;
716 }
717 }
718 }
719 }
720
721 fn calculate_dropdown_width(items: &[MenuItem]) -> usize {
723 items
724 .iter()
725 .map(|item| match item {
726 MenuItem::Action { label, .. } => str_width(label) + 20,
727 MenuItem::Submenu { label, .. } => str_width(label) + 20,
728 MenuItem::DynamicSubmenu { label, .. } => str_width(label) + 20,
729 MenuItem::Separator { .. } => 20,
730 MenuItem::Label { info } => str_width(info) + 4,
731 })
732 .max()
733 .unwrap_or(20)
734 .min(40)
735 }
736
737 #[allow(clippy::too_many_arguments)]
739 fn render_dropdown_level(
740 frame: &mut Frame,
741 items: &[MenuItem],
742 highlighted_item: Option<usize>,
743 x: u16,
744 y: u16,
745 terminal_width: u16,
746 terminal_height: u16,
747 depth: usize,
748 submenu_path: &[usize],
749 menu_index: usize,
750 keybindings: &crate::input::keybindings::KeybindingResolver,
751 theme: &Theme,
752 hover_target: Option<&crate::app::HoverTarget>,
753 context: &MenuContext,
754 layout: &mut MenuLayout,
755 ) -> Rect {
756 let max_width = Self::calculate_dropdown_width(items);
757 let dropdown_height = items.len() + 2; let desired_width = max_width as u16;
760 let desired_height = dropdown_height as u16;
761
762 let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
764 terminal_width.saturating_sub(desired_width)
765 } else {
766 x
767 };
768
769 let available_height = terminal_height.saturating_sub(y);
770 let height = desired_height.min(available_height);
771
772 let available_width = terminal_width.saturating_sub(adjusted_x);
773 let width = desired_width.min(available_width);
774
775 if width < 10 || height < 3 {
777 return Rect {
778 x: adjusted_x,
779 y,
780 width,
781 height,
782 };
783 }
784
785 let dropdown_area = Rect {
786 x: adjusted_x,
787 y,
788 width,
789 height,
790 };
791
792 let mut lines = Vec::new();
794 let max_items = (height.saturating_sub(2)) as usize;
795 let items_to_show = items.len().min(max_items);
796 let content_width = (width as usize).saturating_sub(2);
797
798 for (idx, item) in items.iter().enumerate().take(items_to_show) {
799 let is_highlighted = highlighted_item == Some(idx);
800 let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
802
803 let is_hovered = if depth == 0 {
805 matches!(
806 hover_target,
807 Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
808 )
809 } else {
810 matches!(
811 hover_target,
812 Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
813 )
814 };
815 let enabled = is_menu_item_enabled(item, context);
816
817 let item_area = Rect::new(adjusted_x + 1, y + 1 + idx as u16, content_width as u16, 1);
820 if depth == 0 {
821 layout.item_areas.push((idx, item_area));
822 } else {
823 layout.submenu_areas.push((depth, idx, item_area));
824 }
825
826 let line = match item {
827 MenuItem::Action {
828 label,
829 action,
830 checkbox,
831 ..
832 } => {
833 let style = if !enabled {
834 Style::default()
835 .fg(theme.menu_disabled_fg)
836 .bg(theme.menu_disabled_bg)
837 } else if is_highlighted {
838 Style::default()
839 .fg(theme.menu_highlight_fg)
840 .bg(theme.menu_highlight_bg)
841 } else if is_hovered {
842 Style::default()
843 .fg(theme.menu_hover_fg)
844 .bg(theme.menu_hover_bg)
845 } else {
846 Style::default()
847 .fg(theme.menu_dropdown_fg)
848 .bg(theme.menu_dropdown_bg)
849 };
850
851 let keybinding = keybindings
852 .find_keybinding_for_action(
853 action,
854 crate::input::keybindings::KeyContext::Normal,
855 )
856 .unwrap_or_default();
857
858 let checkbox_icon = if checkbox.is_some() {
859 if is_checkbox_checked(checkbox, context) {
860 "☑ "
861 } else {
862 "☐ "
863 }
864 } else {
865 ""
866 };
867
868 let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
869 let label_display_width = str_width(label);
870 let keybinding_display_width = str_width(&keybinding);
871
872 let text = if keybinding.is_empty() {
873 let padding_needed =
874 content_width.saturating_sub(checkbox_width + label_display_width + 1);
875 format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
876 } else {
877 let padding_needed = content_width.saturating_sub(
878 checkbox_width + label_display_width + keybinding_display_width + 2,
879 );
880 format!(
881 " {}{}{} {}",
882 checkbox_icon,
883 label,
884 " ".repeat(padding_needed),
885 keybinding
886 )
887 };
888
889 Line::from(vec![Span::styled(text, style)])
890 }
891 MenuItem::Separator { .. } => {
892 let separator = "─".repeat(content_width);
893 Line::from(vec![Span::styled(
894 format!(" {separator}"),
895 Style::default()
896 .fg(theme.menu_separator_fg)
897 .bg(theme.menu_dropdown_bg),
898 )])
899 }
900 MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
901 let style = if is_highlighted || has_open_submenu {
903 Style::default()
904 .fg(theme.menu_highlight_fg)
905 .bg(theme.menu_highlight_bg)
906 } else if is_hovered {
907 Style::default()
908 .fg(theme.menu_hover_fg)
909 .bg(theme.menu_hover_bg)
910 } else {
911 Style::default()
912 .fg(theme.menu_dropdown_fg)
913 .bg(theme.menu_dropdown_bg)
914 };
915
916 let label_display_width = str_width(label);
919 let padding_needed = content_width.saturating_sub(label_display_width + 5);
920 Line::from(vec![Span::styled(
921 format!(" {}{} > ", label, " ".repeat(padding_needed)),
922 style,
923 )])
924 }
925 MenuItem::Label { info } => {
926 let style = Style::default()
928 .fg(theme.menu_disabled_fg)
929 .bg(theme.menu_dropdown_bg);
930 let info_display_width = str_width(info);
931 let padding_needed = content_width.saturating_sub(info_display_width);
932 Line::from(vec![Span::styled(
933 format!(" {}{}", info, " ".repeat(padding_needed)),
934 style,
935 )])
936 }
937 };
938
939 lines.push(line);
940 }
941
942 let block = Block::default()
943 .borders(Borders::ALL)
944 .border_style(Style::default().fg(theme.menu_border_fg))
945 .style(Style::reset().bg(theme.menu_dropdown_bg));
946
947 let paragraph = Paragraph::new(lines).block(block);
948 frame.render_widget(paragraph, dropdown_area);
949
950 dropdown_area
951 }
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957 use std::collections::HashMap;
958
959 fn create_test_menus() -> Vec<Menu> {
960 vec![
961 Menu {
962 id: None,
963 label: "File".to_string(),
964 items: vec![
965 MenuItem::Action {
966 label: "New".to_string(),
967 action: "new_file".to_string(),
968 args: HashMap::new(),
969 when: None,
970 checkbox: None,
971 },
972 MenuItem::Separator { separator: true },
973 MenuItem::Action {
974 label: "Save".to_string(),
975 action: "save".to_string(),
976 args: HashMap::new(),
977 when: None,
978 checkbox: None,
979 },
980 MenuItem::Action {
981 label: "Quit".to_string(),
982 action: "quit".to_string(),
983 args: HashMap::new(),
984 when: None,
985 checkbox: None,
986 },
987 ],
988 when: None,
989 },
990 Menu {
991 id: None,
992 label: "Edit".to_string(),
993 items: vec![
994 MenuItem::Action {
995 label: "Undo".to_string(),
996 action: "undo".to_string(),
997 args: HashMap::new(),
998 when: None,
999 checkbox: None,
1000 },
1001 MenuItem::Action {
1002 label: "Redo".to_string(),
1003 action: "redo".to_string(),
1004 args: HashMap::new(),
1005 when: None,
1006 checkbox: None,
1007 },
1008 ],
1009 when: None,
1010 },
1011 Menu {
1012 id: None,
1013 label: "View".to_string(),
1014 items: vec![MenuItem::Action {
1015 label: "Toggle Explorer".to_string(),
1016 action: "toggle_file_explorer".to_string(),
1017 args: HashMap::new(),
1018 when: None,
1019 checkbox: None,
1020 }],
1021 when: None,
1022 },
1023 ]
1024 }
1025
1026 #[test]
1027 fn test_menu_state_default() {
1028 let state = MenuState::for_testing();
1029 assert_eq!(state.active_menu, None);
1030 assert_eq!(state.highlighted_item, None);
1031 assert!(state.plugin_menus.is_empty());
1032 }
1033
1034 #[test]
1035 fn test_menu_state_open_menu() {
1036 let mut state = MenuState::for_testing();
1037 state.open_menu(2);
1038 assert_eq!(state.active_menu, Some(2));
1039 assert_eq!(state.highlighted_item, Some(0));
1040 }
1041
1042 #[test]
1043 fn test_menu_state_close_menu() {
1044 let mut state = MenuState::for_testing();
1045 state.open_menu(1);
1046 state.close_menu();
1047 assert_eq!(state.active_menu, None);
1048 assert_eq!(state.highlighted_item, None);
1049 }
1050
1051 #[test]
1052 fn test_menu_state_next_menu() {
1053 let mut state = MenuState::for_testing();
1054 let menus = create_test_menus();
1055 state.open_menu(0);
1056
1057 state.next_menu(&menus);
1058 assert_eq!(state.active_menu, Some(1));
1059
1060 state.next_menu(&menus);
1061 assert_eq!(state.active_menu, Some(2));
1062
1063 state.next_menu(&menus);
1065 assert_eq!(state.active_menu, Some(0));
1066 }
1067
1068 #[test]
1069 fn test_menu_state_prev_menu() {
1070 let mut state = MenuState::for_testing();
1071 let menus = create_test_menus();
1072 state.open_menu(0);
1073
1074 state.prev_menu(&menus);
1076 assert_eq!(state.active_menu, Some(2));
1077
1078 state.prev_menu(&menus);
1079 assert_eq!(state.active_menu, Some(1));
1080
1081 state.prev_menu(&menus);
1082 assert_eq!(state.active_menu, Some(0));
1083 }
1084
1085 #[test]
1086 fn test_menu_state_next_item_skips_separator() {
1087 let mut state = MenuState::for_testing();
1088 let menus = create_test_menus();
1089 state.open_menu(0);
1090
1091 assert_eq!(state.highlighted_item, Some(0));
1093
1094 state.next_item(&menus[0]);
1096 assert_eq!(state.highlighted_item, Some(2));
1097
1098 state.next_item(&menus[0]);
1100 assert_eq!(state.highlighted_item, Some(3));
1101
1102 state.next_item(&menus[0]);
1104 assert_eq!(state.highlighted_item, Some(0));
1105 }
1106
1107 #[test]
1108 fn test_menu_state_prev_item_skips_separator() {
1109 let mut state = MenuState::for_testing();
1110 let menus = create_test_menus();
1111 state.open_menu(0);
1112 state.highlighted_item = Some(2); state.prev_item(&menus[0]);
1116 assert_eq!(state.highlighted_item, Some(0));
1117
1118 state.prev_item(&menus[0]);
1120 assert_eq!(state.highlighted_item, Some(3));
1121 }
1122
1123 #[test]
1124 fn test_get_highlighted_action() {
1125 let mut state = MenuState::for_testing();
1126 let menus = create_test_menus();
1127 state.open_menu(0);
1128 state.highlighted_item = Some(2); let action = state.get_highlighted_action(&menus);
1131 assert!(action.is_some());
1132 let (action_name, _args) = action.unwrap();
1133 assert_eq!(action_name, "save");
1134 }
1135
1136 #[test]
1137 fn test_menu_item_when_requires_selection() {
1138 let mut state = MenuState::for_testing();
1139 let select_menu = Menu {
1140 id: None,
1141 label: "Edit".to_string(),
1142 items: vec![MenuItem::Action {
1143 label: "Find in Selection".to_string(),
1144 action: "find_in_selection".to_string(),
1145 args: HashMap::new(),
1146 when: Some(context_keys::HAS_SELECTION.to_string()),
1147 checkbox: None,
1148 }],
1149 when: None,
1150 };
1151 state.open_menu(0);
1152 state.highlighted_item = Some(0);
1153
1154 assert!(state
1156 .get_highlighted_action(std::slice::from_ref(&select_menu))
1157 .is_none());
1158
1159 state.context.set(context_keys::HAS_SELECTION, true);
1161 assert!(state.get_highlighted_action(&[select_menu]).is_some());
1162 }
1163
1164 #[test]
1165 fn test_get_highlighted_action_none_when_closed() {
1166 let state = MenuState::for_testing();
1167 let menus = create_test_menus();
1168 assert!(state.get_highlighted_action(&menus).is_none());
1169 }
1170
1171 #[test]
1172 fn test_get_highlighted_action_none_for_separator() {
1173 let mut state = MenuState::for_testing();
1174 let menus = create_test_menus();
1175 state.open_menu(0);
1176 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1179 }
1180
1181 #[test]
1182 fn test_menu_layout_menu_at() {
1183 let bar_area = Rect::new(0, 0, 80, 1);
1185 let mut layout = MenuLayout::new(bar_area);
1186
1187 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1189 layout.menu_areas.push((1, Rect::new(7, 0, 6, 1)));
1191 layout.menu_areas.push((2, Rect::new(14, 0, 6, 1)));
1193
1194 assert_eq!(layout.menu_at(0, 0), Some(0));
1196 assert_eq!(layout.menu_at(3, 0), Some(0));
1197 assert_eq!(layout.menu_at(5, 0), Some(0));
1198
1199 assert_eq!(layout.menu_at(6, 0), None);
1201
1202 assert_eq!(layout.menu_at(7, 0), Some(1));
1204 assert_eq!(layout.menu_at(10, 0), Some(1));
1205 assert_eq!(layout.menu_at(12, 0), Some(1));
1206
1207 assert_eq!(layout.menu_at(13, 0), None);
1209
1210 assert_eq!(layout.menu_at(14, 0), Some(2));
1212 assert_eq!(layout.menu_at(17, 0), Some(2));
1213 assert_eq!(layout.menu_at(19, 0), Some(2));
1214
1215 assert_eq!(layout.menu_at(20, 0), None);
1217 assert_eq!(layout.menu_at(100, 0), None);
1218
1219 assert_eq!(layout.menu_at(3, 1), None);
1221 }
1222
1223 #[test]
1224 fn test_menu_layout_item_at() {
1225 let bar_area = Rect::new(0, 0, 80, 1);
1227 let mut layout = MenuLayout::new(bar_area);
1228
1229 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1232 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1234 layout.item_areas.push((2, Rect::new(1, 4, 20, 1)));
1236 layout.item_areas.push((3, Rect::new(1, 5, 20, 1)));
1238
1239 assert_eq!(layout.item_at(5, 0), None);
1241 assert_eq!(layout.item_at(5, 1), None);
1243
1244 assert_eq!(layout.item_at(5, 2), Some(0));
1246
1247 assert_eq!(layout.item_at(5, 3), Some(1));
1249
1250 assert_eq!(layout.item_at(5, 4), Some(2));
1252
1253 assert_eq!(layout.item_at(5, 5), Some(3));
1255
1256 assert_eq!(layout.item_at(5, 6), None);
1258 assert_eq!(layout.item_at(5, 100), None);
1259 }
1260
1261 #[test]
1262 fn test_menu_layout_hit_test() {
1263 let bar_area = Rect::new(0, 0, 80, 1);
1264 let mut layout = MenuLayout::new(bar_area);
1265
1266 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1268
1269 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1271 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1272
1273 layout.submenu_areas.push((1, 0, Rect::new(22, 3, 15, 1)));
1275
1276 assert_eq!(layout.hit_test(3, 0), Some(MenuHit::MenuLabel(0)));
1278
1279 assert_eq!(layout.hit_test(5, 2), Some(MenuHit::DropdownItem(0)));
1281
1282 assert_eq!(
1284 layout.hit_test(25, 3),
1285 Some(MenuHit::SubmenuItem { depth: 1, index: 0 })
1286 );
1287
1288 assert_eq!(layout.hit_test(50, 0), Some(MenuHit::BarBackground));
1290
1291 assert_eq!(layout.hit_test(50, 10), None);
1293 }
1294
1295 #[test]
1296 fn test_menu_config_json_parsing() {
1297 let json = r#"{
1298 "menus": [
1299 {
1300 "label": "File",
1301 "items": [
1302 { "label": "New", "action": "new_file" },
1303 { "separator": true },
1304 { "label": "Save", "action": "save" }
1305 ]
1306 }
1307 ]
1308 }"#;
1309
1310 let config: MenuConfig = serde_json::from_str(json).unwrap();
1311 assert_eq!(config.menus.len(), 1);
1312 assert_eq!(config.menus[0].label, "File");
1313 assert_eq!(config.menus[0].items.len(), 3);
1314
1315 match &config.menus[0].items[0] {
1316 MenuItem::Action { label, action, .. } => {
1317 assert_eq!(label, "New");
1318 assert_eq!(action, "new_file");
1319 }
1320 _ => panic!("Expected Action"),
1321 }
1322
1323 assert!(matches!(
1324 config.menus[0].items[1],
1325 MenuItem::Separator { .. }
1326 ));
1327
1328 match &config.menus[0].items[2] {
1329 MenuItem::Action { label, action, .. } => {
1330 assert_eq!(label, "Save");
1331 assert_eq!(action, "save");
1332 }
1333 _ => panic!("Expected Action"),
1334 }
1335 }
1336
1337 #[test]
1338 fn test_menu_item_with_args() {
1339 let json = r#"{
1340 "label": "Go to Line",
1341 "action": "goto_line",
1342 "args": { "line": 42 }
1343 }"#;
1344
1345 let item: MenuItem = serde_json::from_str(json).unwrap();
1346 match item {
1347 MenuItem::Action {
1348 label,
1349 action,
1350 args,
1351 ..
1352 } => {
1353 assert_eq!(label, "Go to Line");
1354 assert_eq!(action, "goto_line");
1355 assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1356 }
1357 _ => panic!("Expected Action with args"),
1358 }
1359 }
1360
1361 #[test]
1362 fn test_empty_menu_config() {
1363 let json = r#"{ "menus": [] }"#;
1364 let config: MenuConfig = serde_json::from_str(json).unwrap();
1365 assert!(config.menus.is_empty());
1366 }
1367
1368 #[test]
1369 fn test_menu_mnemonic_lookup() {
1370 use crate::config::Config;
1371 use crate::input::keybindings::KeybindingResolver;
1372
1373 let config = Config::default();
1374 let resolver = KeybindingResolver::new(&config);
1375
1376 assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1378 assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1379 assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1380 assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1381 assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1382 assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1383
1384 assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1386 assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1387
1388 assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1390 }
1391
1392 fn create_menu_with_submenus() -> Vec<Menu> {
1393 vec![Menu {
1394 id: None,
1395 label: "View".to_string(),
1396 items: vec![
1397 MenuItem::Action {
1398 label: "Toggle Explorer".to_string(),
1399 action: "toggle_file_explorer".to_string(),
1400 args: HashMap::new(),
1401 when: None,
1402 checkbox: None,
1403 },
1404 MenuItem::Submenu {
1405 label: "Terminal".to_string(),
1406 items: vec![
1407 MenuItem::Action {
1408 label: "Open Terminal".to_string(),
1409 action: "open_terminal".to_string(),
1410 args: HashMap::new(),
1411 when: None,
1412 checkbox: None,
1413 },
1414 MenuItem::Action {
1415 label: "Close Terminal".to_string(),
1416 action: "close_terminal".to_string(),
1417 args: HashMap::new(),
1418 when: None,
1419 checkbox: None,
1420 },
1421 MenuItem::Submenu {
1422 label: "Terminal Settings".to_string(),
1423 items: vec![MenuItem::Action {
1424 label: "Font Size".to_string(),
1425 action: "terminal_font_size".to_string(),
1426 args: HashMap::new(),
1427 when: None,
1428 checkbox: None,
1429 }],
1430 },
1431 ],
1432 },
1433 MenuItem::Separator { separator: true },
1434 MenuItem::Action {
1435 label: "Zoom In".to_string(),
1436 action: "zoom_in".to_string(),
1437 args: HashMap::new(),
1438 when: None,
1439 checkbox: None,
1440 },
1441 ],
1442 when: None,
1443 }]
1444 }
1445
1446 #[test]
1447 fn test_submenu_open_and_close() {
1448 let mut state = MenuState::for_testing();
1449 let menus = create_menu_with_submenus();
1450
1451 state.open_menu(0);
1452 assert!(state.submenu_path.is_empty());
1453 assert!(!state.in_submenu());
1454
1455 state.highlighted_item = Some(1);
1457
1458 assert!(state.open_submenu(&menus));
1460 assert_eq!(state.submenu_path, vec![1]);
1461 assert!(state.in_submenu());
1462 assert_eq!(state.submenu_depth(), 1);
1463 assert_eq!(state.highlighted_item, Some(0)); assert!(state.close_submenu());
1467 assert!(state.submenu_path.is_empty());
1468 assert!(!state.in_submenu());
1469 assert_eq!(state.highlighted_item, Some(1)); }
1471
1472 #[test]
1473 fn test_nested_submenu() {
1474 let mut state = MenuState::for_testing();
1475 let menus = create_menu_with_submenus();
1476
1477 state.open_menu(0);
1478 state.highlighted_item = Some(1); assert!(state.open_submenu(&menus));
1482 assert_eq!(state.submenu_depth(), 1);
1483
1484 state.highlighted_item = Some(2);
1486
1487 assert!(state.open_submenu(&menus));
1489 assert_eq!(state.submenu_path, vec![1, 2]);
1490 assert_eq!(state.submenu_depth(), 2);
1491 assert_eq!(state.highlighted_item, Some(0));
1492
1493 assert!(state.close_submenu());
1495 assert_eq!(state.submenu_path, vec![1]);
1496 assert_eq!(state.highlighted_item, Some(2));
1497
1498 assert!(state.close_submenu());
1500 assert!(state.submenu_path.is_empty());
1501 assert_eq!(state.highlighted_item, Some(1));
1502
1503 assert!(!state.close_submenu());
1505 }
1506
1507 #[test]
1508 fn test_get_highlighted_action_in_submenu() {
1509 let mut state = MenuState::for_testing();
1510 let menus = create_menu_with_submenus();
1511
1512 state.open_menu(0);
1513 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1517
1518 state.open_submenu(&menus);
1520 let action = state.get_highlighted_action(&menus);
1522 assert!(action.is_some());
1523 let (action_name, _) = action.unwrap();
1524 assert_eq!(action_name, "open_terminal");
1525
1526 state.highlighted_item = Some(1);
1528 let action = state.get_highlighted_action(&menus);
1529 assert!(action.is_some());
1530 let (action_name, _) = action.unwrap();
1531 assert_eq!(action_name, "close_terminal");
1532 }
1533
1534 #[test]
1535 fn test_get_current_items_at_different_depths() {
1536 let mut state = MenuState::for_testing();
1537 let menus = create_menu_with_submenus();
1538
1539 state.open_menu(0);
1540
1541 let items = state.get_current_items(&menus, 0).unwrap();
1543 assert_eq!(items.len(), 4); state.highlighted_item = Some(1);
1547 state.open_submenu(&menus);
1548
1549 let items = state.get_current_items(&menus, 0).unwrap();
1551 assert_eq!(items.len(), 3); state.highlighted_item = Some(2);
1555 state.open_submenu(&menus);
1556
1557 let items = state.get_current_items(&menus, 0).unwrap();
1559 assert_eq!(items.len(), 1); }
1561
1562 #[test]
1563 fn test_is_highlighted_submenu() {
1564 let mut state = MenuState::for_testing();
1565 let menus = create_menu_with_submenus();
1566
1567 state.open_menu(0);
1568 state.highlighted_item = Some(0); assert!(!state.is_highlighted_submenu(&menus));
1570
1571 state.highlighted_item = Some(1); assert!(state.is_highlighted_submenu(&menus));
1573
1574 state.highlighted_item = Some(2); assert!(!state.is_highlighted_submenu(&menus));
1576
1577 state.highlighted_item = Some(3); assert!(!state.is_highlighted_submenu(&menus));
1579 }
1580
1581 #[test]
1582 fn test_open_menu_clears_submenu_path() {
1583 let mut state = MenuState::for_testing();
1584 let menus = create_menu_with_submenus();
1585
1586 state.open_menu(0);
1587 state.highlighted_item = Some(1);
1588 state.open_submenu(&menus);
1589 assert!(!state.submenu_path.is_empty());
1590
1591 state.open_menu(0);
1593 assert!(state.submenu_path.is_empty());
1594 }
1595
1596 #[test]
1597 fn test_next_prev_menu_clears_submenu_path() {
1598 let mut state = MenuState::for_testing();
1599 let menus = create_menu_with_submenus();
1600
1601 state.open_menu(0);
1602 state.highlighted_item = Some(1);
1603 state.open_submenu(&menus);
1604 assert!(!state.submenu_path.is_empty());
1605
1606 state.next_menu(&menus);
1608 assert!(state.submenu_path.is_empty());
1609
1610 state.open_menu(0);
1612 state.highlighted_item = Some(1);
1613 state.open_submenu(&menus);
1614
1615 state.prev_menu(&menus);
1617 assert!(state.submenu_path.is_empty());
1618 }
1619
1620 #[test]
1621 fn test_navigation_in_submenu() {
1622 let mut state = MenuState::for_testing();
1623 let menus = create_menu_with_submenus();
1624
1625 state.open_menu(0);
1626 state.highlighted_item = Some(1);
1627 state.open_submenu(&menus);
1628
1629 assert_eq!(state.highlighted_item, Some(0));
1631
1632 state.next_item(&menus[0]);
1634 assert_eq!(state.highlighted_item, Some(1));
1635
1636 state.next_item(&menus[0]);
1638 assert_eq!(state.highlighted_item, Some(2));
1639
1640 state.next_item(&menus[0]);
1642 assert_eq!(state.highlighted_item, Some(0));
1643
1644 state.prev_item(&menus[0]);
1646 assert_eq!(state.highlighted_item, Some(2));
1647 }
1648
1649 fn calculate_dropdown_x_offset(
1651 all_menus: &[Menu],
1652 menu_index: usize,
1653 context: &MenuContext,
1654 ) -> usize {
1655 let mut x_offset = 0usize;
1656 for (idx, m) in all_menus.iter().enumerate() {
1657 if idx == menu_index {
1658 break;
1659 }
1660 let is_visible = match &m.when {
1662 Some(condition) => context.get(condition),
1663 None => true,
1664 };
1665 if is_visible {
1666 x_offset += str_width(&m.label) + 3; }
1668 }
1669 x_offset
1670 }
1671
1672 #[test]
1673 fn test_dropdown_position_skips_hidden_menus() {
1674 let menus = vec![
1676 Menu {
1677 id: None,
1678 label: "File".to_string(), items: vec![],
1680 when: None,
1681 },
1682 Menu {
1683 id: None,
1684 label: "Explorer".to_string(), items: vec![],
1686 when: Some("file_explorer_focused".to_string()),
1687 },
1688 Menu {
1689 id: None,
1690 label: "Help".to_string(), items: vec![],
1692 when: None,
1693 },
1694 ];
1695
1696 let context_hidden = MenuContext::new().with("file_explorer_focused", false);
1698 let x_help_hidden = calculate_dropdown_x_offset(&menus, 2, &context_hidden);
1699 assert_eq!(
1701 x_help_hidden, 7,
1702 "Help dropdown should be at x=7 when Explorer is hidden"
1703 );
1704
1705 let context_visible = MenuContext::new().with("file_explorer_focused", true);
1707 let x_help_visible = calculate_dropdown_x_offset(&menus, 2, &context_visible);
1708 assert_eq!(
1710 x_help_visible, 18,
1711 "Help dropdown should be at x=18 when Explorer is visible"
1712 );
1713 }
1714
1715 #[test]
1716 fn test_dropdown_position_with_multiple_hidden_menus() {
1717 let menus = vec![
1718 Menu {
1719 id: None,
1720 label: "A".to_string(), items: vec![],
1722 when: None,
1723 },
1724 Menu {
1725 id: None,
1726 label: "B".to_string(), items: vec![],
1728 when: Some("show_b".to_string()),
1729 },
1730 Menu {
1731 id: None,
1732 label: "C".to_string(), items: vec![],
1734 when: Some("show_c".to_string()),
1735 },
1736 Menu {
1737 id: None,
1738 label: "D".to_string(),
1739 items: vec![],
1740 when: None,
1741 },
1742 ];
1743
1744 let context_none = MenuContext::new();
1746 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_none), 4);
1747
1748 let context_b = MenuContext::new().with("show_b", true);
1750 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_b), 8);
1751
1752 let context_both = MenuContext::new().with("show_b", true).with("show_c", true);
1754 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_both), 12);
1755 }
1756}