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);
716
717 let next_width = Self::calculate_dropdown_width(items);
719 if current_x.saturating_add(next_width as u16) > terminal_width {
720 current_x = dropdown_rect
721 .x
722 .saturating_sub(next_width as u16)
723 .saturating_add(1);
724 }
725 } else {
726 break;
727 }
728 }
729 }
730 }
731
732 fn calculate_dropdown_width(items: &[MenuItem]) -> usize {
734 items
735 .iter()
736 .map(|item| match item {
737 MenuItem::Action { label, .. } => str_width(label) + 20,
738 MenuItem::Submenu { label, .. } => str_width(label) + 20,
739 MenuItem::DynamicSubmenu { label, .. } => str_width(label) + 20,
740 MenuItem::Separator { .. } => 20,
741 MenuItem::Label { info } => str_width(info) + 4,
742 })
743 .max()
744 .unwrap_or(20)
745 .min(40)
746 }
747
748 #[allow(clippy::too_many_arguments)]
750 fn render_dropdown_level(
751 frame: &mut Frame,
752 items: &[MenuItem],
753 highlighted_item: Option<usize>,
754 x: u16,
755 y: u16,
756 terminal_width: u16,
757 terminal_height: u16,
758 depth: usize,
759 submenu_path: &[usize],
760 menu_index: usize,
761 keybindings: &crate::input::keybindings::KeybindingResolver,
762 theme: &Theme,
763 hover_target: Option<&crate::app::HoverTarget>,
764 context: &MenuContext,
765 layout: &mut MenuLayout,
766 ) -> Rect {
767 let max_width = Self::calculate_dropdown_width(items);
768 let dropdown_height = items.len() + 2; let desired_width = max_width as u16;
771 let desired_height = dropdown_height as u16;
772
773 let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
775 terminal_width.saturating_sub(desired_width)
776 } else {
777 x
778 };
779
780 let available_height = terminal_height.saturating_sub(y);
781 let height = desired_height.min(available_height);
782
783 let available_width = terminal_width.saturating_sub(adjusted_x);
784 let width = desired_width.min(available_width);
785
786 if width < 10 || height < 3 {
788 return Rect {
789 x: adjusted_x,
790 y,
791 width,
792 height,
793 };
794 }
795
796 let dropdown_area = Rect {
797 x: adjusted_x,
798 y,
799 width,
800 height,
801 };
802
803 let mut lines = Vec::new();
805 let max_items = (height.saturating_sub(2)) as usize;
806 let items_to_show = items.len().min(max_items);
807 let content_width = (width as usize).saturating_sub(2);
808
809 for (idx, item) in items.iter().enumerate().take(items_to_show) {
810 let is_highlighted = highlighted_item == Some(idx);
811 let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
813
814 let is_hovered = if depth == 0 {
816 matches!(
817 hover_target,
818 Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
819 )
820 } else {
821 matches!(
822 hover_target,
823 Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
824 )
825 };
826 let enabled = is_menu_item_enabled(item, context);
827
828 let item_area = Rect::new(adjusted_x + 1, y + 1 + idx as u16, content_width as u16, 1);
831 if depth == 0 {
832 layout.item_areas.push((idx, item_area));
833 } else {
834 layout.submenu_areas.push((depth, idx, item_area));
835 }
836
837 let line = match item {
838 MenuItem::Action {
839 label,
840 action,
841 checkbox,
842 ..
843 } => {
844 let style = if !enabled {
845 Style::default()
846 .fg(theme.menu_disabled_fg)
847 .bg(theme.menu_disabled_bg)
848 } else if is_highlighted {
849 Style::default()
850 .fg(theme.menu_highlight_fg)
851 .bg(theme.menu_highlight_bg)
852 } else if is_hovered {
853 Style::default()
854 .fg(theme.menu_hover_fg)
855 .bg(theme.menu_hover_bg)
856 } else {
857 Style::default()
858 .fg(theme.menu_dropdown_fg)
859 .bg(theme.menu_dropdown_bg)
860 };
861
862 let keybinding = keybindings
863 .find_keybinding_for_action(
864 action,
865 crate::input::keybindings::KeyContext::Normal,
866 )
867 .unwrap_or_default();
868
869 let checkbox_icon = if checkbox.is_some() {
870 if is_checkbox_checked(checkbox, context) {
871 "☑ "
872 } else {
873 "☐ "
874 }
875 } else {
876 ""
877 };
878
879 let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
880 let label_display_width = str_width(label);
881 let keybinding_display_width = str_width(&keybinding);
882
883 let text = if keybinding.is_empty() {
884 let padding_needed =
885 content_width.saturating_sub(checkbox_width + label_display_width + 1);
886 format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
887 } else {
888 let padding_needed = content_width.saturating_sub(
889 checkbox_width + label_display_width + keybinding_display_width + 2,
890 );
891 format!(
892 " {}{}{} {}",
893 checkbox_icon,
894 label,
895 " ".repeat(padding_needed),
896 keybinding
897 )
898 };
899
900 Line::from(vec![Span::styled(text, style)])
901 }
902 MenuItem::Separator { .. } => {
903 let separator = "─".repeat(content_width);
904 Line::from(vec![Span::styled(
905 format!(" {separator}"),
906 Style::default()
907 .fg(theme.menu_separator_fg)
908 .bg(theme.menu_dropdown_bg),
909 )])
910 }
911 MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
912 let style = if is_highlighted || has_open_submenu {
914 Style::default()
915 .fg(theme.menu_highlight_fg)
916 .bg(theme.menu_highlight_bg)
917 } else if is_hovered {
918 Style::default()
919 .fg(theme.menu_hover_fg)
920 .bg(theme.menu_hover_bg)
921 } else {
922 Style::default()
923 .fg(theme.menu_dropdown_fg)
924 .bg(theme.menu_dropdown_bg)
925 };
926
927 let label_display_width = str_width(label);
930 let padding_needed = content_width.saturating_sub(label_display_width + 5);
931 Line::from(vec![Span::styled(
932 format!(" {}{} > ", label, " ".repeat(padding_needed)),
933 style,
934 )])
935 }
936 MenuItem::Label { info } => {
937 let style = Style::default()
939 .fg(theme.menu_disabled_fg)
940 .bg(theme.menu_dropdown_bg);
941 let info_display_width = str_width(info);
942 let padding_needed = content_width.saturating_sub(info_display_width);
943 Line::from(vec![Span::styled(
944 format!(" {}{}", info, " ".repeat(padding_needed)),
945 style,
946 )])
947 }
948 };
949
950 lines.push(line);
951 }
952
953 let block = Block::default()
954 .borders(Borders::ALL)
955 .border_style(Style::default().fg(theme.menu_border_fg))
956 .style(Style::reset().bg(theme.menu_dropdown_bg));
957
958 let paragraph = Paragraph::new(lines).block(block);
959 frame.render_widget(paragraph, dropdown_area);
960
961 dropdown_area
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use std::collections::HashMap;
969
970 fn create_test_menus() -> Vec<Menu> {
971 vec![
972 Menu {
973 id: None,
974 label: "File".to_string(),
975 items: vec![
976 MenuItem::Action {
977 label: "New".to_string(),
978 action: "new_file".to_string(),
979 args: HashMap::new(),
980 when: None,
981 checkbox: None,
982 },
983 MenuItem::Separator { separator: true },
984 MenuItem::Action {
985 label: "Save".to_string(),
986 action: "save".to_string(),
987 args: HashMap::new(),
988 when: None,
989 checkbox: None,
990 },
991 MenuItem::Action {
992 label: "Quit".to_string(),
993 action: "quit".to_string(),
994 args: HashMap::new(),
995 when: None,
996 checkbox: None,
997 },
998 ],
999 when: None,
1000 },
1001 Menu {
1002 id: None,
1003 label: "Edit".to_string(),
1004 items: vec![
1005 MenuItem::Action {
1006 label: "Undo".to_string(),
1007 action: "undo".to_string(),
1008 args: HashMap::new(),
1009 when: None,
1010 checkbox: None,
1011 },
1012 MenuItem::Action {
1013 label: "Redo".to_string(),
1014 action: "redo".to_string(),
1015 args: HashMap::new(),
1016 when: None,
1017 checkbox: None,
1018 },
1019 ],
1020 when: None,
1021 },
1022 Menu {
1023 id: None,
1024 label: "View".to_string(),
1025 items: vec![MenuItem::Action {
1026 label: "Toggle Explorer".to_string(),
1027 action: "toggle_file_explorer".to_string(),
1028 args: HashMap::new(),
1029 when: None,
1030 checkbox: None,
1031 }],
1032 when: None,
1033 },
1034 ]
1035 }
1036
1037 #[test]
1038 fn test_menu_state_default() {
1039 let state = MenuState::for_testing();
1040 assert_eq!(state.active_menu, None);
1041 assert_eq!(state.highlighted_item, None);
1042 assert!(state.plugin_menus.is_empty());
1043 }
1044
1045 #[test]
1046 fn test_menu_state_open_menu() {
1047 let mut state = MenuState::for_testing();
1048 state.open_menu(2);
1049 assert_eq!(state.active_menu, Some(2));
1050 assert_eq!(state.highlighted_item, Some(0));
1051 }
1052
1053 #[test]
1054 fn test_menu_state_close_menu() {
1055 let mut state = MenuState::for_testing();
1056 state.open_menu(1);
1057 state.close_menu();
1058 assert_eq!(state.active_menu, None);
1059 assert_eq!(state.highlighted_item, None);
1060 }
1061
1062 #[test]
1063 fn test_menu_state_next_menu() {
1064 let mut state = MenuState::for_testing();
1065 let menus = create_test_menus();
1066 state.open_menu(0);
1067
1068 state.next_menu(&menus);
1069 assert_eq!(state.active_menu, Some(1));
1070
1071 state.next_menu(&menus);
1072 assert_eq!(state.active_menu, Some(2));
1073
1074 state.next_menu(&menus);
1076 assert_eq!(state.active_menu, Some(0));
1077 }
1078
1079 #[test]
1080 fn test_menu_state_prev_menu() {
1081 let mut state = MenuState::for_testing();
1082 let menus = create_test_menus();
1083 state.open_menu(0);
1084
1085 state.prev_menu(&menus);
1087 assert_eq!(state.active_menu, Some(2));
1088
1089 state.prev_menu(&menus);
1090 assert_eq!(state.active_menu, Some(1));
1091
1092 state.prev_menu(&menus);
1093 assert_eq!(state.active_menu, Some(0));
1094 }
1095
1096 #[test]
1097 fn test_menu_state_next_item_skips_separator() {
1098 let mut state = MenuState::for_testing();
1099 let menus = create_test_menus();
1100 state.open_menu(0);
1101
1102 assert_eq!(state.highlighted_item, Some(0));
1104
1105 state.next_item(&menus[0]);
1107 assert_eq!(state.highlighted_item, Some(2));
1108
1109 state.next_item(&menus[0]);
1111 assert_eq!(state.highlighted_item, Some(3));
1112
1113 state.next_item(&menus[0]);
1115 assert_eq!(state.highlighted_item, Some(0));
1116 }
1117
1118 #[test]
1119 fn test_menu_state_prev_item_skips_separator() {
1120 let mut state = MenuState::for_testing();
1121 let menus = create_test_menus();
1122 state.open_menu(0);
1123 state.highlighted_item = Some(2); state.prev_item(&menus[0]);
1127 assert_eq!(state.highlighted_item, Some(0));
1128
1129 state.prev_item(&menus[0]);
1131 assert_eq!(state.highlighted_item, Some(3));
1132 }
1133
1134 #[test]
1135 fn test_get_highlighted_action() {
1136 let mut state = MenuState::for_testing();
1137 let menus = create_test_menus();
1138 state.open_menu(0);
1139 state.highlighted_item = Some(2); let action = state.get_highlighted_action(&menus);
1142 assert!(action.is_some());
1143 let (action_name, _args) = action.unwrap();
1144 assert_eq!(action_name, "save");
1145 }
1146
1147 #[test]
1148 fn test_menu_item_when_requires_selection() {
1149 let mut state = MenuState::for_testing();
1150 let select_menu = Menu {
1151 id: None,
1152 label: "Edit".to_string(),
1153 items: vec![MenuItem::Action {
1154 label: "Find in Selection".to_string(),
1155 action: "find_in_selection".to_string(),
1156 args: HashMap::new(),
1157 when: Some(context_keys::HAS_SELECTION.to_string()),
1158 checkbox: None,
1159 }],
1160 when: None,
1161 };
1162 state.open_menu(0);
1163 state.highlighted_item = Some(0);
1164
1165 assert!(state
1167 .get_highlighted_action(std::slice::from_ref(&select_menu))
1168 .is_none());
1169
1170 state.context.set(context_keys::HAS_SELECTION, true);
1172 assert!(state.get_highlighted_action(&[select_menu]).is_some());
1173 }
1174
1175 #[test]
1176 fn test_get_highlighted_action_none_when_closed() {
1177 let state = MenuState::for_testing();
1178 let menus = create_test_menus();
1179 assert!(state.get_highlighted_action(&menus).is_none());
1180 }
1181
1182 #[test]
1183 fn test_get_highlighted_action_none_for_separator() {
1184 let mut state = MenuState::for_testing();
1185 let menus = create_test_menus();
1186 state.open_menu(0);
1187 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1190 }
1191
1192 #[test]
1193 fn test_menu_layout_menu_at() {
1194 let bar_area = Rect::new(0, 0, 80, 1);
1196 let mut layout = MenuLayout::new(bar_area);
1197
1198 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1200 layout.menu_areas.push((1, Rect::new(7, 0, 6, 1)));
1202 layout.menu_areas.push((2, Rect::new(14, 0, 6, 1)));
1204
1205 assert_eq!(layout.menu_at(0, 0), Some(0));
1207 assert_eq!(layout.menu_at(3, 0), Some(0));
1208 assert_eq!(layout.menu_at(5, 0), Some(0));
1209
1210 assert_eq!(layout.menu_at(6, 0), None);
1212
1213 assert_eq!(layout.menu_at(7, 0), Some(1));
1215 assert_eq!(layout.menu_at(10, 0), Some(1));
1216 assert_eq!(layout.menu_at(12, 0), Some(1));
1217
1218 assert_eq!(layout.menu_at(13, 0), None);
1220
1221 assert_eq!(layout.menu_at(14, 0), Some(2));
1223 assert_eq!(layout.menu_at(17, 0), Some(2));
1224 assert_eq!(layout.menu_at(19, 0), Some(2));
1225
1226 assert_eq!(layout.menu_at(20, 0), None);
1228 assert_eq!(layout.menu_at(100, 0), None);
1229
1230 assert_eq!(layout.menu_at(3, 1), None);
1232 }
1233
1234 #[test]
1235 fn test_menu_layout_item_at() {
1236 let bar_area = Rect::new(0, 0, 80, 1);
1238 let mut layout = MenuLayout::new(bar_area);
1239
1240 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1243 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1245 layout.item_areas.push((2, Rect::new(1, 4, 20, 1)));
1247 layout.item_areas.push((3, Rect::new(1, 5, 20, 1)));
1249
1250 assert_eq!(layout.item_at(5, 0), None);
1252 assert_eq!(layout.item_at(5, 1), None);
1254
1255 assert_eq!(layout.item_at(5, 2), Some(0));
1257
1258 assert_eq!(layout.item_at(5, 3), Some(1));
1260
1261 assert_eq!(layout.item_at(5, 4), Some(2));
1263
1264 assert_eq!(layout.item_at(5, 5), Some(3));
1266
1267 assert_eq!(layout.item_at(5, 6), None);
1269 assert_eq!(layout.item_at(5, 100), None);
1270 }
1271
1272 #[test]
1273 fn test_menu_layout_hit_test() {
1274 let bar_area = Rect::new(0, 0, 80, 1);
1275 let mut layout = MenuLayout::new(bar_area);
1276
1277 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1279
1280 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1282 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1283
1284 layout.submenu_areas.push((1, 0, Rect::new(22, 3, 15, 1)));
1286
1287 assert_eq!(layout.hit_test(3, 0), Some(MenuHit::MenuLabel(0)));
1289
1290 assert_eq!(layout.hit_test(5, 2), Some(MenuHit::DropdownItem(0)));
1292
1293 assert_eq!(
1295 layout.hit_test(25, 3),
1296 Some(MenuHit::SubmenuItem { depth: 1, index: 0 })
1297 );
1298
1299 assert_eq!(layout.hit_test(50, 0), Some(MenuHit::BarBackground));
1301
1302 assert_eq!(layout.hit_test(50, 10), None);
1304 }
1305
1306 #[test]
1307 fn test_menu_config_json_parsing() {
1308 let json = r#"{
1309 "menus": [
1310 {
1311 "label": "File",
1312 "items": [
1313 { "label": "New", "action": "new_file" },
1314 { "separator": true },
1315 { "label": "Save", "action": "save" }
1316 ]
1317 }
1318 ]
1319 }"#;
1320
1321 let config: MenuConfig = serde_json::from_str(json).unwrap();
1322 assert_eq!(config.menus.len(), 1);
1323 assert_eq!(config.menus[0].label, "File");
1324 assert_eq!(config.menus[0].items.len(), 3);
1325
1326 match &config.menus[0].items[0] {
1327 MenuItem::Action { label, action, .. } => {
1328 assert_eq!(label, "New");
1329 assert_eq!(action, "new_file");
1330 }
1331 _ => panic!("Expected Action"),
1332 }
1333
1334 assert!(matches!(
1335 config.menus[0].items[1],
1336 MenuItem::Separator { .. }
1337 ));
1338
1339 match &config.menus[0].items[2] {
1340 MenuItem::Action { label, action, .. } => {
1341 assert_eq!(label, "Save");
1342 assert_eq!(action, "save");
1343 }
1344 _ => panic!("Expected Action"),
1345 }
1346 }
1347
1348 #[test]
1349 fn test_menu_item_with_args() {
1350 let json = r#"{
1351 "label": "Go to Line",
1352 "action": "goto_line",
1353 "args": { "line": 42 }
1354 }"#;
1355
1356 let item: MenuItem = serde_json::from_str(json).unwrap();
1357 match item {
1358 MenuItem::Action {
1359 label,
1360 action,
1361 args,
1362 ..
1363 } => {
1364 assert_eq!(label, "Go to Line");
1365 assert_eq!(action, "goto_line");
1366 assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1367 }
1368 _ => panic!("Expected Action with args"),
1369 }
1370 }
1371
1372 #[test]
1373 fn test_empty_menu_config() {
1374 let json = r#"{ "menus": [] }"#;
1375 let config: MenuConfig = serde_json::from_str(json).unwrap();
1376 assert!(config.menus.is_empty());
1377 }
1378
1379 #[test]
1380 fn test_menu_mnemonic_lookup() {
1381 use crate::config::Config;
1382 use crate::input::keybindings::KeybindingResolver;
1383
1384 let config = Config::default();
1385 let resolver = KeybindingResolver::new(&config);
1386
1387 assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1389 assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1390 assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1391 assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1392 assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1393 assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1394
1395 assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1397 assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1398
1399 assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1401 }
1402
1403 fn create_menu_with_submenus() -> Vec<Menu> {
1404 vec![Menu {
1405 id: None,
1406 label: "View".to_string(),
1407 items: vec![
1408 MenuItem::Action {
1409 label: "Toggle Explorer".to_string(),
1410 action: "toggle_file_explorer".to_string(),
1411 args: HashMap::new(),
1412 when: None,
1413 checkbox: None,
1414 },
1415 MenuItem::Submenu {
1416 label: "Terminal".to_string(),
1417 items: vec![
1418 MenuItem::Action {
1419 label: "Open Terminal".to_string(),
1420 action: "open_terminal".to_string(),
1421 args: HashMap::new(),
1422 when: None,
1423 checkbox: None,
1424 },
1425 MenuItem::Action {
1426 label: "Close Terminal".to_string(),
1427 action: "close_terminal".to_string(),
1428 args: HashMap::new(),
1429 when: None,
1430 checkbox: None,
1431 },
1432 MenuItem::Submenu {
1433 label: "Terminal Settings".to_string(),
1434 items: vec![MenuItem::Action {
1435 label: "Font Size".to_string(),
1436 action: "terminal_font_size".to_string(),
1437 args: HashMap::new(),
1438 when: None,
1439 checkbox: None,
1440 }],
1441 },
1442 ],
1443 },
1444 MenuItem::Separator { separator: true },
1445 MenuItem::Action {
1446 label: "Zoom In".to_string(),
1447 action: "zoom_in".to_string(),
1448 args: HashMap::new(),
1449 when: None,
1450 checkbox: None,
1451 },
1452 ],
1453 when: None,
1454 }]
1455 }
1456
1457 #[test]
1458 fn test_submenu_open_and_close() {
1459 let mut state = MenuState::for_testing();
1460 let menus = create_menu_with_submenus();
1461
1462 state.open_menu(0);
1463 assert!(state.submenu_path.is_empty());
1464 assert!(!state.in_submenu());
1465
1466 state.highlighted_item = Some(1);
1468
1469 assert!(state.open_submenu(&menus));
1471 assert_eq!(state.submenu_path, vec![1]);
1472 assert!(state.in_submenu());
1473 assert_eq!(state.submenu_depth(), 1);
1474 assert_eq!(state.highlighted_item, Some(0)); assert!(state.close_submenu());
1478 assert!(state.submenu_path.is_empty());
1479 assert!(!state.in_submenu());
1480 assert_eq!(state.highlighted_item, Some(1)); }
1482
1483 #[test]
1484 fn test_nested_submenu() {
1485 let mut state = MenuState::for_testing();
1486 let menus = create_menu_with_submenus();
1487
1488 state.open_menu(0);
1489 state.highlighted_item = Some(1); assert!(state.open_submenu(&menus));
1493 assert_eq!(state.submenu_depth(), 1);
1494
1495 state.highlighted_item = Some(2);
1497
1498 assert!(state.open_submenu(&menus));
1500 assert_eq!(state.submenu_path, vec![1, 2]);
1501 assert_eq!(state.submenu_depth(), 2);
1502 assert_eq!(state.highlighted_item, Some(0));
1503
1504 assert!(state.close_submenu());
1506 assert_eq!(state.submenu_path, vec![1]);
1507 assert_eq!(state.highlighted_item, Some(2));
1508
1509 assert!(state.close_submenu());
1511 assert!(state.submenu_path.is_empty());
1512 assert_eq!(state.highlighted_item, Some(1));
1513
1514 assert!(!state.close_submenu());
1516 }
1517
1518 #[test]
1519 fn test_get_highlighted_action_in_submenu() {
1520 let mut state = MenuState::for_testing();
1521 let menus = create_menu_with_submenus();
1522
1523 state.open_menu(0);
1524 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1528
1529 state.open_submenu(&menus);
1531 let action = state.get_highlighted_action(&menus);
1533 assert!(action.is_some());
1534 let (action_name, _) = action.unwrap();
1535 assert_eq!(action_name, "open_terminal");
1536
1537 state.highlighted_item = Some(1);
1539 let action = state.get_highlighted_action(&menus);
1540 assert!(action.is_some());
1541 let (action_name, _) = action.unwrap();
1542 assert_eq!(action_name, "close_terminal");
1543 }
1544
1545 #[test]
1546 fn test_get_current_items_at_different_depths() {
1547 let mut state = MenuState::for_testing();
1548 let menus = create_menu_with_submenus();
1549
1550 state.open_menu(0);
1551
1552 let items = state.get_current_items(&menus, 0).unwrap();
1554 assert_eq!(items.len(), 4); state.highlighted_item = Some(1);
1558 state.open_submenu(&menus);
1559
1560 let items = state.get_current_items(&menus, 0).unwrap();
1562 assert_eq!(items.len(), 3); state.highlighted_item = Some(2);
1566 state.open_submenu(&menus);
1567
1568 let items = state.get_current_items(&menus, 0).unwrap();
1570 assert_eq!(items.len(), 1); }
1572
1573 #[test]
1574 fn test_is_highlighted_submenu() {
1575 let mut state = MenuState::for_testing();
1576 let menus = create_menu_with_submenus();
1577
1578 state.open_menu(0);
1579 state.highlighted_item = Some(0); assert!(!state.is_highlighted_submenu(&menus));
1581
1582 state.highlighted_item = Some(1); assert!(state.is_highlighted_submenu(&menus));
1584
1585 state.highlighted_item = Some(2); assert!(!state.is_highlighted_submenu(&menus));
1587
1588 state.highlighted_item = Some(3); assert!(!state.is_highlighted_submenu(&menus));
1590 }
1591
1592 #[test]
1593 fn test_open_menu_clears_submenu_path() {
1594 let mut state = MenuState::for_testing();
1595 let menus = create_menu_with_submenus();
1596
1597 state.open_menu(0);
1598 state.highlighted_item = Some(1);
1599 state.open_submenu(&menus);
1600 assert!(!state.submenu_path.is_empty());
1601
1602 state.open_menu(0);
1604 assert!(state.submenu_path.is_empty());
1605 }
1606
1607 #[test]
1608 fn test_next_prev_menu_clears_submenu_path() {
1609 let mut state = MenuState::for_testing();
1610 let menus = create_menu_with_submenus();
1611
1612 state.open_menu(0);
1613 state.highlighted_item = Some(1);
1614 state.open_submenu(&menus);
1615 assert!(!state.submenu_path.is_empty());
1616
1617 state.next_menu(&menus);
1619 assert!(state.submenu_path.is_empty());
1620
1621 state.open_menu(0);
1623 state.highlighted_item = Some(1);
1624 state.open_submenu(&menus);
1625
1626 state.prev_menu(&menus);
1628 assert!(state.submenu_path.is_empty());
1629 }
1630
1631 #[test]
1632 fn test_navigation_in_submenu() {
1633 let mut state = MenuState::for_testing();
1634 let menus = create_menu_with_submenus();
1635
1636 state.open_menu(0);
1637 state.highlighted_item = Some(1);
1638 state.open_submenu(&menus);
1639
1640 assert_eq!(state.highlighted_item, Some(0));
1642
1643 state.next_item(&menus[0]);
1645 assert_eq!(state.highlighted_item, Some(1));
1646
1647 state.next_item(&menus[0]);
1649 assert_eq!(state.highlighted_item, Some(2));
1650
1651 state.next_item(&menus[0]);
1653 assert_eq!(state.highlighted_item, Some(0));
1654
1655 state.prev_item(&menus[0]);
1657 assert_eq!(state.highlighted_item, Some(2));
1658 }
1659
1660 fn calculate_dropdown_x_offset(
1662 all_menus: &[Menu],
1663 menu_index: usize,
1664 context: &MenuContext,
1665 ) -> usize {
1666 let mut x_offset = 0usize;
1667 for (idx, m) in all_menus.iter().enumerate() {
1668 if idx == menu_index {
1669 break;
1670 }
1671 let is_visible = match &m.when {
1673 Some(condition) => context.get(condition),
1674 None => true,
1675 };
1676 if is_visible {
1677 x_offset += str_width(&m.label) + 3; }
1679 }
1680 x_offset
1681 }
1682
1683 #[test]
1684 fn test_dropdown_position_skips_hidden_menus() {
1685 let menus = vec![
1687 Menu {
1688 id: None,
1689 label: "File".to_string(), items: vec![],
1691 when: None,
1692 },
1693 Menu {
1694 id: None,
1695 label: "Explorer".to_string(), items: vec![],
1697 when: Some("file_explorer_focused".to_string()),
1698 },
1699 Menu {
1700 id: None,
1701 label: "Help".to_string(), items: vec![],
1703 when: None,
1704 },
1705 ];
1706
1707 let context_hidden = MenuContext::new().with("file_explorer_focused", false);
1709 let x_help_hidden = calculate_dropdown_x_offset(&menus, 2, &context_hidden);
1710 assert_eq!(
1712 x_help_hidden, 7,
1713 "Help dropdown should be at x=7 when Explorer is hidden"
1714 );
1715
1716 let context_visible = MenuContext::new().with("file_explorer_focused", true);
1718 let x_help_visible = calculate_dropdown_x_offset(&menus, 2, &context_visible);
1719 assert_eq!(
1721 x_help_visible, 18,
1722 "Help dropdown should be at x=18 when Explorer is visible"
1723 );
1724 }
1725
1726 #[test]
1727 fn test_dropdown_position_with_multiple_hidden_menus() {
1728 let menus = vec![
1729 Menu {
1730 id: None,
1731 label: "A".to_string(), items: vec![],
1733 when: None,
1734 },
1735 Menu {
1736 id: None,
1737 label: "B".to_string(), items: vec![],
1739 when: Some("show_b".to_string()),
1740 },
1741 Menu {
1742 id: None,
1743 label: "C".to_string(), items: vec![],
1745 when: Some("show_c".to_string()),
1746 },
1747 Menu {
1748 id: None,
1749 label: "D".to_string(),
1750 items: vec![],
1751 when: None,
1752 },
1753 ];
1754
1755 let context_none = MenuContext::new();
1757 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_none), 4);
1758
1759 let context_b = MenuContext::new().with("show_b", true);
1761 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_b), 8);
1762
1763 let context_both = MenuContext::new().with("show_b", true).with("show_c", true);
1765 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_both), 12);
1766 }
1767}