1use crate::app::types::CellThemeRecorder;
4use crate::config::{generate_dynamic_items, Menu, MenuItem, MenuItemExt};
5use crate::primitives::display_width::str_width;
6use crate::view::theme::Theme;
7use crate::view::ui::layout::point_in_rect;
8use ratatui::layout::Rect;
9use ratatui::style::{Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Borders, Paragraph};
12use ratatui::Frame;
13
14pub use crate::types::context_keys;
16
17#[derive(Debug, Clone, Default)]
22pub struct MenuLayout {
23 pub menu_areas: Vec<(usize, Rect)>,
25 pub item_areas: Vec<(usize, Rect)>,
28 pub submenu_areas: Vec<(usize, usize, Rect)>,
30 pub bar_area: Rect,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum MenuHit {
37 MenuLabel(usize),
39 DropdownItem(usize),
41 SubmenuItem { depth: usize, index: usize },
43 BarBackground,
45}
46
47impl MenuLayout {
48 pub fn new(bar_area: Rect) -> Self {
50 Self {
51 menu_areas: Vec::new(),
52 item_areas: Vec::new(),
53 submenu_areas: Vec::new(),
54 bar_area,
55 }
56 }
57
58 pub fn menu_at(&self, x: u16, y: u16) -> Option<usize> {
60 for (idx, area) in &self.menu_areas {
61 if point_in_rect(*area, x, y) {
62 return Some(*idx);
63 }
64 }
65 None
66 }
67
68 pub fn item_at(&self, x: u16, y: u16) -> Option<usize> {
70 for (idx, area) in &self.item_areas {
71 if point_in_rect(*area, x, y) {
72 return Some(*idx);
73 }
74 }
75 None
76 }
77
78 pub fn submenu_item_at(&self, x: u16, y: u16) -> Option<(usize, usize)> {
80 for (depth, idx, area) in &self.submenu_areas {
81 if point_in_rect(*area, x, y) {
82 return Some((*depth, *idx));
83 }
84 }
85 None
86 }
87
88 pub fn hit_test(&self, x: u16, y: u16) -> Option<MenuHit> {
90 if let Some((depth, idx)) = self.submenu_item_at(x, y) {
92 return Some(MenuHit::SubmenuItem { depth, index: idx });
93 }
94
95 if let Some(idx) = self.item_at(x, y) {
97 return Some(MenuHit::DropdownItem(idx));
98 }
99
100 if let Some(idx) = self.menu_at(x, y) {
102 return Some(MenuHit::MenuLabel(idx));
103 }
104
105 if point_in_rect(self.bar_area, x, y) {
107 return Some(MenuHit::BarBackground);
108 }
109
110 None
111 }
112}
113
114pub use fresh_core::menu::MenuContext;
116
117pub(crate) fn is_menu_item_enabled(item: &MenuItem, context: &MenuContext) -> bool {
121 match item {
122 MenuItem::Action { when, .. } => {
123 match when.as_deref() {
124 Some(condition) => context.get(condition),
125 None => true, }
127 }
128 _ => true,
129 }
130}
131
132pub(crate) fn is_checkbox_checked(checkbox: &Option<String>, context: &MenuContext) -> bool {
135 match checkbox.as_deref() {
136 Some(name) => context.get(name),
137 None => false,
138 }
139}
140
141pub(crate) fn is_menu_visible(menu: &Menu, context: &MenuContext) -> bool {
145 match &menu.when {
146 Some(condition) => context.get(condition),
147 None => true, }
149}
150
151#[derive(Debug, Clone)]
162pub struct MenuState {
163 pub active_menu: Option<usize>,
165 pub highlighted_item: Option<usize>,
167 pub submenu_path: Vec<usize>,
170 pub plugin_menus: Vec<Menu>,
172 pub context: MenuContext,
174 pub themes_dir: std::path::PathBuf,
177}
178
179impl MenuState {
180 pub fn new(themes_dir: std::path::PathBuf) -> Self {
181 Self {
182 active_menu: None,
183 highlighted_item: None,
184 submenu_path: Vec::new(),
185 plugin_menus: Vec::new(),
186 context: MenuContext::default(),
187 themes_dir,
188 }
189 }
190
191 #[cfg(test)]
193 pub fn for_testing() -> Self {
194 Self::new(std::path::PathBuf::new())
195 }
196
197 pub fn open_menu(&mut self, index: usize) {
199 self.active_menu = Some(index);
200 self.highlighted_item = Some(0);
201 self.submenu_path.clear();
202 }
203
204 pub fn close_menu(&mut self) {
206 self.active_menu = None;
207 self.highlighted_item = None;
208 self.submenu_path.clear();
209 }
210
211 pub fn next_menu(&mut self, menus: &[Menu]) {
214 let Some(active) = self.active_menu else {
215 return;
216 };
217 let total = menus.len();
218 if total == 0 {
219 return;
220 }
221
222 for i in 1..=total {
224 let next_idx = (active + i) % total;
225 if self.is_menu_visible(&menus[next_idx]) {
226 self.active_menu = Some(next_idx);
227 self.highlighted_item = Some(0);
228 self.submenu_path.clear();
229 return;
230 }
231 }
232 }
234
235 pub fn prev_menu(&mut self, menus: &[Menu]) {
238 let Some(active) = self.active_menu else {
239 return;
240 };
241 let total = menus.len();
242 if total == 0 {
243 return;
244 }
245
246 for i in 1..=total {
248 let prev_idx = (active + total - i) % total;
249 if self.is_menu_visible(&menus[prev_idx]) {
250 self.active_menu = Some(prev_idx);
251 self.highlighted_item = Some(0);
252 self.submenu_path.clear();
253 return;
254 }
255 }
256 }
258
259 fn is_menu_visible(&self, menu: &Menu) -> bool {
262 is_menu_visible(menu, &self.context)
263 }
264
265 pub fn in_submenu(&self) -> bool {
267 !self.submenu_path.is_empty()
268 }
269
270 pub fn submenu_depth(&self) -> usize {
272 self.submenu_path.len()
273 }
274
275 pub fn open_submenu(&mut self, menus: &[Menu]) -> bool {
278 let Some(active_idx) = self.active_menu else {
279 return false;
280 };
281 let Some(highlighted) = self.highlighted_item else {
282 return false;
283 };
284
285 let Some(menu) = menus.get(active_idx) else {
287 return false;
288 };
289 let Some(items) = self.get_current_items_cloned(menu) else {
290 return false;
291 };
292
293 if let Some(item) = items.get(highlighted) {
295 match item {
296 MenuItem::Submenu {
297 items: submenu_items,
298 ..
299 } if !submenu_items.is_empty() => {
300 self.submenu_path.push(highlighted);
301 self.highlighted_item = Some(0);
302 return true;
303 }
304 MenuItem::DynamicSubmenu { source, .. } => {
305 let generated = generate_dynamic_items(source, &self.themes_dir);
307 if !generated.is_empty() {
308 self.submenu_path.push(highlighted);
309 self.highlighted_item = Some(0);
310 return true;
311 }
312 }
313 _ => {}
314 }
315 }
316 false
317 }
318
319 pub fn close_submenu(&mut self) -> bool {
322 if let Some(parent_idx) = self.submenu_path.pop() {
323 self.highlighted_item = Some(parent_idx);
324 true
325 } else {
326 false
327 }
328 }
329
330 pub fn get_current_items<'a>(
332 &self,
333 menus: &'a [Menu],
334 active_idx: usize,
335 ) -> Option<&'a [MenuItem]> {
336 let menu = menus.get(active_idx)?;
337 let mut items: &[MenuItem] = &menu.items;
338
339 for &idx in &self.submenu_path {
340 match items.get(idx)? {
341 MenuItem::Submenu {
342 items: submenu_items,
343 ..
344 } => {
345 items = submenu_items;
346 }
347 _ => return None,
348 }
349 }
350
351 Some(items)
352 }
353
354 pub fn get_current_items_cloned(&self, menu: &Menu) -> Option<Vec<MenuItem>> {
357 let mut items: Vec<MenuItem> = menu
359 .items
360 .iter()
361 .map(|i| i.expand_dynamic(&self.themes_dir))
362 .collect();
363
364 for &idx in &self.submenu_path {
365 match items.get(idx)?.expand_dynamic(&self.themes_dir) {
366 MenuItem::Submenu {
367 items: submenu_items,
368 ..
369 } => {
370 items = submenu_items;
371 }
372 _ => return None,
373 }
374 }
375
376 Some(items)
377 }
378
379 pub fn next_item(&mut self, menu: &Menu) {
381 let Some(idx) = self.highlighted_item else {
382 return;
383 };
384
385 let Some(items) = self.get_current_items_cloned(menu) else {
387 return;
388 };
389
390 if items.is_empty() {
391 return;
392 }
393
394 let mut next = (idx + 1) % items.len();
396 while next != idx && self.should_skip_item(&items[next]) {
397 next = (next + 1) % items.len();
398 }
399 self.highlighted_item = Some(next);
400 }
401
402 pub fn prev_item(&mut self, menu: &Menu) {
404 let Some(idx) = self.highlighted_item else {
405 return;
406 };
407
408 let Some(items) = self.get_current_items_cloned(menu) else {
410 return;
411 };
412
413 if items.is_empty() {
414 return;
415 }
416
417 let total = items.len();
419 let mut prev = (idx + total - 1) % total;
420 while prev != idx && self.should_skip_item(&items[prev]) {
421 prev = (prev + total - 1) % total;
422 }
423 self.highlighted_item = Some(prev);
424 }
425
426 fn should_skip_item(&self, item: &MenuItem) -> bool {
428 match item {
429 MenuItem::Separator { .. } => true,
430 MenuItem::Action { when, .. } => {
431 match when.as_deref() {
433 Some(condition) => !self.context.get(condition),
434 None => false, }
436 }
437 _ => false,
438 }
439 }
440
441 pub fn get_highlighted_action(
444 &self,
445 menus: &[Menu],
446 ) -> Option<(String, std::collections::HashMap<String, serde_json::Value>)> {
447 let active_menu = self.active_menu?;
448 let highlighted_item = self.highlighted_item?;
449
450 let menu = menus.get(active_menu)?;
452 let items = self.get_current_items_cloned(menu)?;
453 let item = items.get(highlighted_item)?;
454
455 match item {
456 MenuItem::Action { action, args, .. } => {
457 if is_menu_item_enabled(item, &self.context) {
458 Some((action.clone(), args.clone()))
459 } else {
460 None
461 }
462 }
463 _ => None,
464 }
465 }
466
467 pub fn is_highlighted_submenu(&self, menus: &[Menu]) -> bool {
469 let Some(active_menu) = self.active_menu else {
470 return false;
471 };
472 let Some(highlighted_item) = self.highlighted_item else {
473 return false;
474 };
475
476 let Some(menu) = menus.get(active_menu) else {
478 return false;
479 };
480 let Some(items) = self.get_current_items_cloned(menu) else {
481 return false;
482 };
483
484 matches!(
485 items.get(highlighted_item),
486 Some(MenuItem::Submenu { .. } | MenuItem::DynamicSubmenu { .. })
487 )
488 }
489}
490
491pub struct MenuRenderer;
493
494impl MenuRenderer {
495 #[allow(clippy::too_many_arguments)]
509 pub fn render(
510 frame: &mut Frame,
511 area: Rect,
512 all_menus: &[Menu],
516 menu_state: &MenuState,
517 keybindings: &crate::input::keybindings::KeybindingResolver,
518 theme: &Theme,
519 hover_target: Option<&crate::app::HoverTarget>,
520 mnemonics_enabled: bool,
521 mut rec: Option<&mut CellThemeRecorder>,
522 draw: bool,
525 ) -> MenuLayout {
526 let mut layout = MenuLayout::new(area);
527 if let Some(r) = rec.as_deref_mut() {
530 r.run(
531 area.x,
532 area.y,
533 area.width,
534 Some("ui.menu_fg"),
535 Some("ui.menu_bg"),
536 "Menu Bar",
537 );
538 }
539 let menu_visible: Vec<bool> = all_menus
543 .iter()
544 .map(|menu| match &menu.when {
545 Some(condition) => menu_state.context.get(condition),
546 None => true, })
548 .collect();
549
550 let mut spans = Vec::new();
552 let mut current_x = area.x;
553
554 for (idx, menu) in all_menus.iter().enumerate() {
555 if !menu_visible[idx] {
557 continue;
558 }
559
560 let is_active = menu_state.active_menu == Some(idx);
561 let is_hovered =
562 matches!(hover_target, Some(crate::app::HoverTarget::MenuBarItem(i)) if *i == idx);
563
564 let base_style = if is_active {
565 Style::default()
566 .fg(theme.menu_active_fg)
567 .bg(theme.menu_active_bg)
568 .add_modifier(Modifier::BOLD)
569 } else if is_hovered {
570 Style::default()
571 .fg(theme.menu_hover_fg)
572 .bg(theme.menu_hover_bg)
573 } else {
574 Style::default().fg(theme.menu_fg).bg(theme.menu_bg)
575 };
576
577 let label_width = str_width(&menu.label) as u16 + 2;
579
580 layout
582 .menu_areas
583 .push((idx, Rect::new(current_x, area.y, label_width, 1)));
584
585 if let Some(r) = rec.as_deref_mut() {
588 let (fg, bg) = if is_active {
589 ("ui.menu_active_fg", "ui.menu_active_bg")
590 } else {
591 ("ui.menu_fg", "ui.menu_bg")
592 };
593 r.run(
594 current_x,
595 area.y,
596 label_width,
597 Some(fg),
598 Some(bg),
599 "Menu Bar",
600 );
601 }
602
603 let mnemonic = if mnemonics_enabled {
605 keybindings.find_menu_mnemonic(&menu.label)
606 } else {
607 None
608 };
609
610 spans.push(Span::styled(" ", base_style));
612
613 if let Some(mnemonic_char) = mnemonic {
614 let mut found = false;
616 for c in menu.label.chars() {
617 if !found && c.to_ascii_lowercase() == mnemonic_char {
618 spans.push(Span::styled(
620 c.to_string(),
621 base_style.add_modifier(Modifier::UNDERLINED),
622 ));
623 found = true;
624 } else {
625 spans.push(Span::styled(c.to_string(), base_style));
626 }
627 }
628 } else {
629 spans.push(Span::styled(menu.label.clone(), base_style));
631 }
632
633 spans.push(Span::styled(" ", base_style));
634 spans.push(Span::raw(" "));
635
636 current_x += label_width + 1;
638 }
639
640 if draw {
641 let line = Line::from(spans);
642 let paragraph = Paragraph::new(line).style(Style::default().bg(theme.menu_bg));
643 frame.render_widget(paragraph, area);
644 }
645
646 if let Some(active_idx) = menu_state.active_menu {
648 if let Some(menu) = all_menus.get(active_idx) {
649 Self::render_dropdown_chain(
650 frame,
651 area,
652 menu,
653 menu_state,
654 active_idx,
655 all_menus,
656 keybindings,
657 theme,
658 hover_target,
659 &mut layout,
660 rec,
661 draw,
662 );
663 }
664 }
665
666 layout
667 }
668
669 #[allow(clippy::too_many_arguments)]
671 fn render_dropdown_chain(
672 frame: &mut Frame,
673 menu_bar_area: Rect,
674 menu: &Menu,
675 menu_state: &MenuState,
676 menu_index: usize,
677 all_menus: &[Menu],
678 keybindings: &crate::input::keybindings::KeybindingResolver,
679 theme: &Theme,
680 hover_target: Option<&crate::app::HoverTarget>,
681 layout: &mut MenuLayout,
682 mut rec: Option<&mut CellThemeRecorder>,
683 draw: bool,
684 ) {
685 let mut x_offset = 0usize;
688 for (idx, m) in all_menus.iter().enumerate() {
689 if idx == menu_index {
690 break;
691 }
692 let is_visible = match &m.when {
694 Some(condition) => menu_state.context.get(condition),
695 None => true,
696 };
697 if is_visible {
698 x_offset += str_width(&m.label) + 3; }
700 }
701
702 let terminal_width = frame.area().width;
703 let terminal_height = frame.area().height;
704
705 let mut current_items: &[MenuItem] = &menu.items;
707 let mut current_x = menu_bar_area.x.saturating_add(x_offset as u16);
708 let mut current_y = menu_bar_area.y.saturating_add(1);
709
710 for depth in 0..=menu_state.submenu_path.len() {
713 let is_deepest = depth == menu_state.submenu_path.len();
714 let highlighted_item = if is_deepest {
715 menu_state.highlighted_item
716 } else {
717 Some(menu_state.submenu_path[depth])
718 };
719
720 let dropdown_rect = Self::render_dropdown_level(
722 frame,
723 current_items,
724 highlighted_item,
725 current_x,
726 current_y,
727 terminal_width,
728 terminal_height,
729 depth,
730 &menu_state.submenu_path,
731 menu_index,
732 keybindings,
733 theme,
734 hover_target,
735 &menu_state.context,
736 layout,
737 rec.as_deref_mut(),
738 draw,
739 );
740
741 if !is_deepest {
743 let submenu_idx = menu_state.submenu_path[depth];
744 let submenu_items = match current_items.get(submenu_idx) {
746 Some(MenuItem::Submenu { items, .. }) => Some(items.as_slice()),
747 Some(MenuItem::DynamicSubmenu { .. }) => {
748 None
751 }
752 _ => None,
753 };
754 if let Some(items) = submenu_items {
755 current_items = items;
756 current_x = dropdown_rect
758 .x
759 .saturating_add(dropdown_rect.width.saturating_sub(1));
760 current_y = dropdown_rect.y.saturating_add(submenu_idx as u16);
765
766 let next_width = Self::calculate_dropdown_width(items);
768 if current_x.saturating_add(next_width as u16) > terminal_width {
769 current_x = dropdown_rect
770 .x
771 .saturating_sub(next_width as u16)
772 .saturating_add(1);
773 }
774 } else {
775 break;
776 }
777 }
778 }
779 }
780
781 fn calculate_dropdown_width(items: &[MenuItem]) -> usize {
783 items
784 .iter()
785 .map(|item| match item {
786 MenuItem::Action { label, .. } => str_width(label) + 20,
787 MenuItem::Submenu { label, .. } => str_width(label) + 20,
788 MenuItem::DynamicSubmenu { label, .. } => str_width(label) + 20,
789 MenuItem::Separator { .. } => 20,
790 MenuItem::Label { info } => str_width(info) + 4,
791 })
792 .max()
793 .unwrap_or(20)
794 .min(40)
795 }
796
797 #[allow(clippy::too_many_arguments)]
799 fn render_dropdown_level(
800 frame: &mut Frame,
801 items: &[MenuItem],
802 highlighted_item: Option<usize>,
803 x: u16,
804 y: u16,
805 terminal_width: u16,
806 terminal_height: u16,
807 depth: usize,
808 submenu_path: &[usize],
809 menu_index: usize,
810 keybindings: &crate::input::keybindings::KeybindingResolver,
811 theme: &Theme,
812 hover_target: Option<&crate::app::HoverTarget>,
813 context: &MenuContext,
814 layout: &mut MenuLayout,
815 mut rec: Option<&mut CellThemeRecorder>,
816 draw: bool,
817 ) -> Rect {
818 let dropdown_area = Self::fit_dropdown_area(items, x, y, terminal_width, terminal_height);
819
820 if dropdown_area.width < 10 || dropdown_area.height < 3 {
822 return dropdown_area;
823 }
824 let adjusted_x = dropdown_area.x;
825
826 if let Some(r) = rec.as_deref_mut() {
829 for row in dropdown_area.y..dropdown_area.y + dropdown_area.height {
830 r.run(
831 dropdown_area.x,
832 row,
833 dropdown_area.width,
834 Some("ui.menu_border_fg"),
835 Some("ui.menu_dropdown_bg"),
836 "Menu Dropdown",
837 );
838 }
839 }
840
841 let mut lines = Vec::new();
843 let max_items = (dropdown_area.height.saturating_sub(2)) as usize;
844 let items_to_show = items.len().min(max_items);
845 let content_width = (dropdown_area.width as usize).saturating_sub(2);
846
847 for (idx, item) in items.iter().enumerate().take(items_to_show) {
848 let is_highlighted = highlighted_item == Some(idx);
849 let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
851
852 let is_hovered = if depth == 0 {
854 matches!(
855 hover_target,
856 Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
857 )
858 } else {
859 matches!(
860 hover_target,
861 Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
862 )
863 };
864 let enabled = is_menu_item_enabled(item, context);
865
866 let item_area = Rect::new(adjusted_x + 1, y + 1 + idx as u16, content_width as u16, 1);
869 if depth == 0 {
870 layout.item_areas.push((idx, item_area));
871 } else {
872 layout.submenu_areas.push((depth, idx, item_area));
873 }
874
875 if let Some(r) = rec.as_deref_mut() {
877 Self::record_dropdown_item_run(
878 r,
879 item,
880 item_area,
881 enabled,
882 is_highlighted,
883 has_open_submenu,
884 );
885 }
886
887 lines.push(Self::build_dropdown_item_line(
888 item,
889 content_width,
890 enabled,
891 is_highlighted,
892 is_hovered,
893 has_open_submenu,
894 keybindings,
895 theme,
896 context,
897 ));
898 }
899
900 let block = Block::default()
901 .borders(Borders::ALL)
902 .border_style(Style::default().fg(theme.menu_border_fg))
903 .style(Style::reset().bg(theme.menu_dropdown_bg));
904
905 if draw {
906 let paragraph = Paragraph::new(lines).block(block);
907 frame.render_widget(paragraph, dropdown_area);
908 }
909
910 dropdown_area
911 }
912
913 fn fit_dropdown_area(
918 items: &[MenuItem],
919 x: u16,
920 y: u16,
921 terminal_width: u16,
922 terminal_height: u16,
923 ) -> Rect {
924 let desired_width = Self::calculate_dropdown_width(items) as u16;
925 let desired_height = (items.len() + 2) as u16; let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
929 terminal_width.saturating_sub(desired_width)
930 } else {
931 x
932 };
933
934 let width = desired_width.min(terminal_width.saturating_sub(adjusted_x));
935 let height = desired_height.min(terminal_height.saturating_sub(y));
936
937 Rect {
938 x: adjusted_x,
939 y,
940 width,
941 height,
942 }
943 }
944
945 fn record_dropdown_item_run(
949 rec: &mut CellThemeRecorder,
950 item: &MenuItem,
951 item_area: Rect,
952 enabled: bool,
953 is_highlighted: bool,
954 has_open_submenu: bool,
955 ) {
956 let (fg, bg) = match item {
957 MenuItem::Separator { .. } => ("ui.menu_separator_fg", "ui.menu_dropdown_bg"),
958 MenuItem::Label { .. } => ("ui.menu_disabled_fg", "ui.menu_dropdown_bg"),
959 _ if !enabled => ("ui.menu_disabled_fg", "ui.menu_disabled_bg"),
960 _ if is_highlighted || has_open_submenu => {
961 ("ui.menu_highlight_fg", "ui.menu_highlight_bg")
962 }
963 _ => ("ui.menu_dropdown_fg", "ui.menu_dropdown_bg"),
964 };
965 rec.run(
966 item_area.x,
967 item_area.y,
968 item_area.width,
969 Some(fg),
970 Some(bg),
971 "Menu Dropdown",
972 );
973 }
974
975 #[allow(clippy::too_many_arguments)]
979 fn build_dropdown_item_line(
980 item: &MenuItem,
981 content_width: usize,
982 enabled: bool,
983 is_highlighted: bool,
984 is_hovered: bool,
985 has_open_submenu: bool,
986 keybindings: &crate::input::keybindings::KeybindingResolver,
987 theme: &Theme,
988 context: &MenuContext,
989 ) -> Line<'static> {
990 match item {
991 MenuItem::Action {
992 label,
993 action,
994 checkbox,
995 ..
996 } => {
997 let style = if !enabled {
998 Style::default()
999 .fg(theme.menu_disabled_fg)
1000 .bg(theme.menu_disabled_bg)
1001 } else if is_highlighted {
1002 Style::default()
1003 .fg(theme.menu_highlight_fg)
1004 .bg(theme.menu_highlight_bg)
1005 } else if is_hovered {
1006 Style::default()
1007 .fg(theme.menu_hover_fg)
1008 .bg(theme.menu_hover_bg)
1009 } else {
1010 Style::default()
1011 .fg(theme.menu_dropdown_fg)
1012 .bg(theme.menu_dropdown_bg)
1013 };
1014
1015 let keybinding = keybindings
1016 .find_keybinding_for_action(
1017 action,
1018 crate::input::keybindings::KeyContext::Normal,
1019 )
1020 .unwrap_or_default();
1021
1022 let checkbox_icon = if checkbox.is_some() {
1023 if is_checkbox_checked(checkbox, context) {
1024 "☑ "
1025 } else {
1026 "☐ "
1027 }
1028 } else {
1029 ""
1030 };
1031
1032 let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
1033 let label_display_width = str_width(label);
1034 let keybinding_display_width = str_width(&keybinding);
1035
1036 let text = if keybinding.is_empty() {
1037 let padding_needed =
1038 content_width.saturating_sub(checkbox_width + label_display_width + 1);
1039 format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
1040 } else {
1041 let padding_needed = content_width.saturating_sub(
1042 checkbox_width + label_display_width + keybinding_display_width + 2,
1043 );
1044 format!(
1045 " {}{}{} {}",
1046 checkbox_icon,
1047 label,
1048 " ".repeat(padding_needed),
1049 keybinding
1050 )
1051 };
1052
1053 Line::from(vec![Span::styled(text, style)])
1054 }
1055 MenuItem::Separator { .. } => {
1056 let separator = "─".repeat(content_width);
1057 Line::from(vec![Span::styled(
1058 format!(" {separator}"),
1059 Style::default()
1060 .fg(theme.menu_separator_fg)
1061 .bg(theme.menu_dropdown_bg),
1062 )])
1063 }
1064 MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
1065 let style = if is_highlighted || has_open_submenu {
1067 Style::default()
1068 .fg(theme.menu_highlight_fg)
1069 .bg(theme.menu_highlight_bg)
1070 } else if is_hovered {
1071 Style::default()
1072 .fg(theme.menu_hover_fg)
1073 .bg(theme.menu_hover_bg)
1074 } else {
1075 Style::default()
1076 .fg(theme.menu_dropdown_fg)
1077 .bg(theme.menu_dropdown_bg)
1078 };
1079
1080 let label_display_width = str_width(label);
1083 let padding_needed = content_width.saturating_sub(label_display_width + 5);
1084 Line::from(vec![Span::styled(
1085 format!(" {}{} > ", label, " ".repeat(padding_needed)),
1086 style,
1087 )])
1088 }
1089 MenuItem::Label { info } => {
1090 let style = Style::default()
1092 .fg(theme.menu_disabled_fg)
1093 .bg(theme.menu_dropdown_bg);
1094 let info_display_width = str_width(info);
1095 let padding_needed = content_width.saturating_sub(info_display_width);
1096 Line::from(vec![Span::styled(
1097 format!(" {}{}", info, " ".repeat(padding_needed)),
1098 style,
1099 )])
1100 }
1101 }
1102 }
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108 use crate::config::MenuConfig;
1109 use std::collections::HashMap;
1110
1111 fn create_test_menus() -> Vec<Menu> {
1112 vec![
1113 Menu {
1114 id: None,
1115 label: "File".to_string(),
1116 items: vec![
1117 MenuItem::Action {
1118 label: "New".to_string(),
1119 action: "new_file".to_string(),
1120 args: HashMap::new(),
1121 when: None,
1122 checkbox: None,
1123 },
1124 MenuItem::Separator { separator: true },
1125 MenuItem::Action {
1126 label: "Save".to_string(),
1127 action: "save".to_string(),
1128 args: HashMap::new(),
1129 when: None,
1130 checkbox: None,
1131 },
1132 MenuItem::Action {
1133 label: "Quit".to_string(),
1134 action: "quit".to_string(),
1135 args: HashMap::new(),
1136 when: None,
1137 checkbox: None,
1138 },
1139 ],
1140 when: None,
1141 },
1142 Menu {
1143 id: None,
1144 label: "Edit".to_string(),
1145 items: vec![
1146 MenuItem::Action {
1147 label: "Undo".to_string(),
1148 action: "undo".to_string(),
1149 args: HashMap::new(),
1150 when: None,
1151 checkbox: None,
1152 },
1153 MenuItem::Action {
1154 label: "Redo".to_string(),
1155 action: "redo".to_string(),
1156 args: HashMap::new(),
1157 when: None,
1158 checkbox: None,
1159 },
1160 ],
1161 when: None,
1162 },
1163 Menu {
1164 id: None,
1165 label: "View".to_string(),
1166 items: vec![MenuItem::Action {
1167 label: "Toggle Explorer".to_string(),
1168 action: "toggle_file_explorer".to_string(),
1169 args: HashMap::new(),
1170 when: None,
1171 checkbox: None,
1172 }],
1173 when: None,
1174 },
1175 ]
1176 }
1177
1178 #[test]
1179 fn test_menu_state_default() {
1180 let state = MenuState::for_testing();
1181 assert_eq!(state.active_menu, None);
1182 assert_eq!(state.highlighted_item, None);
1183 assert!(state.plugin_menus.is_empty());
1184 }
1185
1186 #[test]
1187 fn test_menu_state_open_menu() {
1188 let mut state = MenuState::for_testing();
1189 state.open_menu(2);
1190 assert_eq!(state.active_menu, Some(2));
1191 assert_eq!(state.highlighted_item, Some(0));
1192 }
1193
1194 #[test]
1195 fn test_menu_state_close_menu() {
1196 let mut state = MenuState::for_testing();
1197 state.open_menu(1);
1198 state.close_menu();
1199 assert_eq!(state.active_menu, None);
1200 assert_eq!(state.highlighted_item, None);
1201 }
1202
1203 #[test]
1204 fn test_menu_state_next_menu() {
1205 let mut state = MenuState::for_testing();
1206 let menus = create_test_menus();
1207 state.open_menu(0);
1208
1209 state.next_menu(&menus);
1210 assert_eq!(state.active_menu, Some(1));
1211
1212 state.next_menu(&menus);
1213 assert_eq!(state.active_menu, Some(2));
1214
1215 state.next_menu(&menus);
1217 assert_eq!(state.active_menu, Some(0));
1218 }
1219
1220 #[test]
1221 fn test_menu_state_prev_menu() {
1222 let mut state = MenuState::for_testing();
1223 let menus = create_test_menus();
1224 state.open_menu(0);
1225
1226 state.prev_menu(&menus);
1228 assert_eq!(state.active_menu, Some(2));
1229
1230 state.prev_menu(&menus);
1231 assert_eq!(state.active_menu, Some(1));
1232
1233 state.prev_menu(&menus);
1234 assert_eq!(state.active_menu, Some(0));
1235 }
1236
1237 #[test]
1238 fn test_menu_state_next_item_skips_separator() {
1239 let mut state = MenuState::for_testing();
1240 let menus = create_test_menus();
1241 state.open_menu(0);
1242
1243 assert_eq!(state.highlighted_item, Some(0));
1245
1246 state.next_item(&menus[0]);
1248 assert_eq!(state.highlighted_item, Some(2));
1249
1250 state.next_item(&menus[0]);
1252 assert_eq!(state.highlighted_item, Some(3));
1253
1254 state.next_item(&menus[0]);
1256 assert_eq!(state.highlighted_item, Some(0));
1257 }
1258
1259 #[test]
1260 fn test_menu_state_prev_item_skips_separator() {
1261 let mut state = MenuState::for_testing();
1262 let menus = create_test_menus();
1263 state.open_menu(0);
1264 state.highlighted_item = Some(2); state.prev_item(&menus[0]);
1268 assert_eq!(state.highlighted_item, Some(0));
1269
1270 state.prev_item(&menus[0]);
1272 assert_eq!(state.highlighted_item, Some(3));
1273 }
1274
1275 #[test]
1276 fn test_get_highlighted_action() {
1277 let mut state = MenuState::for_testing();
1278 let menus = create_test_menus();
1279 state.open_menu(0);
1280 state.highlighted_item = Some(2); let action = state.get_highlighted_action(&menus);
1283 assert!(action.is_some());
1284 let (action_name, _args) = action.unwrap();
1285 assert_eq!(action_name, "save");
1286 }
1287
1288 #[test]
1289 fn test_menu_item_when_requires_selection() {
1290 let mut state = MenuState::for_testing();
1291 let select_menu = Menu {
1292 id: None,
1293 label: "Edit".to_string(),
1294 items: vec![MenuItem::Action {
1295 label: "Find in Selection".to_string(),
1296 action: "find_in_selection".to_string(),
1297 args: HashMap::new(),
1298 when: Some(context_keys::HAS_SELECTION.to_string()),
1299 checkbox: None,
1300 }],
1301 when: None,
1302 };
1303 state.open_menu(0);
1304 state.highlighted_item = Some(0);
1305
1306 assert!(state
1308 .get_highlighted_action(std::slice::from_ref(&select_menu))
1309 .is_none());
1310
1311 state.context.set(context_keys::HAS_SELECTION, true);
1313 assert!(state.get_highlighted_action(&[select_menu]).is_some());
1314 }
1315
1316 #[test]
1317 fn test_get_highlighted_action_none_when_closed() {
1318 let state = MenuState::for_testing();
1319 let menus = create_test_menus();
1320 assert!(state.get_highlighted_action(&menus).is_none());
1321 }
1322
1323 #[test]
1324 fn test_get_highlighted_action_none_for_separator() {
1325 let mut state = MenuState::for_testing();
1326 let menus = create_test_menus();
1327 state.open_menu(0);
1328 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1331 }
1332
1333 #[test]
1334 fn test_menu_layout_menu_at() {
1335 let bar_area = Rect::new(0, 0, 80, 1);
1337 let mut layout = MenuLayout::new(bar_area);
1338
1339 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1341 layout.menu_areas.push((1, Rect::new(7, 0, 6, 1)));
1343 layout.menu_areas.push((2, Rect::new(14, 0, 6, 1)));
1345
1346 assert_eq!(layout.menu_at(0, 0), Some(0));
1348 assert_eq!(layout.menu_at(3, 0), Some(0));
1349 assert_eq!(layout.menu_at(5, 0), Some(0));
1350
1351 assert_eq!(layout.menu_at(6, 0), None);
1353
1354 assert_eq!(layout.menu_at(7, 0), Some(1));
1356 assert_eq!(layout.menu_at(10, 0), Some(1));
1357 assert_eq!(layout.menu_at(12, 0), Some(1));
1358
1359 assert_eq!(layout.menu_at(13, 0), None);
1361
1362 assert_eq!(layout.menu_at(14, 0), Some(2));
1364 assert_eq!(layout.menu_at(17, 0), Some(2));
1365 assert_eq!(layout.menu_at(19, 0), Some(2));
1366
1367 assert_eq!(layout.menu_at(20, 0), None);
1369 assert_eq!(layout.menu_at(100, 0), None);
1370
1371 assert_eq!(layout.menu_at(3, 1), None);
1373 }
1374
1375 #[test]
1376 fn test_menu_layout_item_at() {
1377 let bar_area = Rect::new(0, 0, 80, 1);
1379 let mut layout = MenuLayout::new(bar_area);
1380
1381 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1384 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1386 layout.item_areas.push((2, Rect::new(1, 4, 20, 1)));
1388 layout.item_areas.push((3, Rect::new(1, 5, 20, 1)));
1390
1391 assert_eq!(layout.item_at(5, 0), None);
1393 assert_eq!(layout.item_at(5, 1), None);
1395
1396 assert_eq!(layout.item_at(5, 2), Some(0));
1398
1399 assert_eq!(layout.item_at(5, 3), Some(1));
1401
1402 assert_eq!(layout.item_at(5, 4), Some(2));
1404
1405 assert_eq!(layout.item_at(5, 5), Some(3));
1407
1408 assert_eq!(layout.item_at(5, 6), None);
1410 assert_eq!(layout.item_at(5, 100), None);
1411 }
1412
1413 #[test]
1414 fn test_menu_layout_hit_test() {
1415 let bar_area = Rect::new(0, 0, 80, 1);
1416 let mut layout = MenuLayout::new(bar_area);
1417
1418 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1420
1421 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1423 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1424
1425 layout.submenu_areas.push((1, 0, Rect::new(22, 3, 15, 1)));
1427
1428 assert_eq!(layout.hit_test(3, 0), Some(MenuHit::MenuLabel(0)));
1430
1431 assert_eq!(layout.hit_test(5, 2), Some(MenuHit::DropdownItem(0)));
1433
1434 assert_eq!(
1436 layout.hit_test(25, 3),
1437 Some(MenuHit::SubmenuItem { depth: 1, index: 0 })
1438 );
1439
1440 assert_eq!(layout.hit_test(50, 0), Some(MenuHit::BarBackground));
1442
1443 assert_eq!(layout.hit_test(50, 10), None);
1445 }
1446
1447 #[test]
1448 fn test_menu_config_json_parsing() {
1449 let json = r#"{
1450 "menus": [
1451 {
1452 "label": "File",
1453 "items": [
1454 { "label": "New", "action": "new_file" },
1455 { "separator": true },
1456 { "label": "Save", "action": "save" }
1457 ]
1458 }
1459 ]
1460 }"#;
1461
1462 let config: MenuConfig = serde_json::from_str(json).unwrap();
1463 assert_eq!(config.menus.len(), 1);
1464 assert_eq!(config.menus[0].label, "File");
1465 assert_eq!(config.menus[0].items.len(), 3);
1466
1467 match &config.menus[0].items[0] {
1468 MenuItem::Action { label, action, .. } => {
1469 assert_eq!(label, "New");
1470 assert_eq!(action, "new_file");
1471 }
1472 _ => panic!("Expected Action"),
1473 }
1474
1475 assert!(matches!(
1476 config.menus[0].items[1],
1477 MenuItem::Separator { .. }
1478 ));
1479
1480 match &config.menus[0].items[2] {
1481 MenuItem::Action { label, action, .. } => {
1482 assert_eq!(label, "Save");
1483 assert_eq!(action, "save");
1484 }
1485 _ => panic!("Expected Action"),
1486 }
1487 }
1488
1489 #[test]
1490 fn test_menu_item_with_args() {
1491 let json = r#"{
1492 "label": "Go to Line",
1493 "action": "goto_line",
1494 "args": { "line": 42 }
1495 }"#;
1496
1497 let item: MenuItem = serde_json::from_str(json).unwrap();
1498 match item {
1499 MenuItem::Action {
1500 label,
1501 action,
1502 args,
1503 ..
1504 } => {
1505 assert_eq!(label, "Go to Line");
1506 assert_eq!(action, "goto_line");
1507 assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1508 }
1509 _ => panic!("Expected Action with args"),
1510 }
1511 }
1512
1513 #[test]
1514 fn test_empty_menu_config() {
1515 let json = r#"{ "menus": [] }"#;
1516 let config: MenuConfig = serde_json::from_str(json).unwrap();
1517 assert!(config.menus.is_empty());
1518 }
1519
1520 #[test]
1521 fn test_menu_mnemonic_lookup() {
1522 use crate::config::Config;
1523 use crate::input::keybindings::KeybindingResolver;
1524
1525 let config = Config::default();
1526 let resolver = KeybindingResolver::new(&config);
1527
1528 assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1530 assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1531 assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1532 assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1533 assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1534 assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1535
1536 assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1538 assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1539
1540 assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1542 }
1543
1544 fn create_menu_with_submenus() -> Vec<Menu> {
1545 vec![Menu {
1546 id: None,
1547 label: "View".to_string(),
1548 items: vec![
1549 MenuItem::Action {
1550 label: "Toggle Explorer".to_string(),
1551 action: "toggle_file_explorer".to_string(),
1552 args: HashMap::new(),
1553 when: None,
1554 checkbox: None,
1555 },
1556 MenuItem::Submenu {
1557 label: "Terminal".to_string(),
1558 items: vec![
1559 MenuItem::Action {
1560 label: "Open Terminal".to_string(),
1561 action: "open_terminal".to_string(),
1562 args: HashMap::new(),
1563 when: None,
1564 checkbox: None,
1565 },
1566 MenuItem::Action {
1567 label: "Close Terminal".to_string(),
1568 action: "close_terminal".to_string(),
1569 args: HashMap::new(),
1570 when: None,
1571 checkbox: None,
1572 },
1573 MenuItem::Submenu {
1574 label: "Terminal Settings".to_string(),
1575 items: vec![MenuItem::Action {
1576 label: "Font Size".to_string(),
1577 action: "terminal_font_size".to_string(),
1578 args: HashMap::new(),
1579 when: None,
1580 checkbox: None,
1581 }],
1582 },
1583 ],
1584 },
1585 MenuItem::Separator { separator: true },
1586 MenuItem::Action {
1587 label: "Zoom In".to_string(),
1588 action: "zoom_in".to_string(),
1589 args: HashMap::new(),
1590 when: None,
1591 checkbox: None,
1592 },
1593 ],
1594 when: None,
1595 }]
1596 }
1597
1598 #[test]
1599 fn test_submenu_open_and_close() {
1600 let mut state = MenuState::for_testing();
1601 let menus = create_menu_with_submenus();
1602
1603 state.open_menu(0);
1604 assert!(state.submenu_path.is_empty());
1605 assert!(!state.in_submenu());
1606
1607 state.highlighted_item = Some(1);
1609
1610 assert!(state.open_submenu(&menus));
1612 assert_eq!(state.submenu_path, vec![1]);
1613 assert!(state.in_submenu());
1614 assert_eq!(state.submenu_depth(), 1);
1615 assert_eq!(state.highlighted_item, Some(0)); assert!(state.close_submenu());
1619 assert!(state.submenu_path.is_empty());
1620 assert!(!state.in_submenu());
1621 assert_eq!(state.highlighted_item, Some(1)); }
1623
1624 #[test]
1625 fn test_nested_submenu() {
1626 let mut state = MenuState::for_testing();
1627 let menus = create_menu_with_submenus();
1628
1629 state.open_menu(0);
1630 state.highlighted_item = Some(1); assert!(state.open_submenu(&menus));
1634 assert_eq!(state.submenu_depth(), 1);
1635
1636 state.highlighted_item = Some(2);
1638
1639 assert!(state.open_submenu(&menus));
1641 assert_eq!(state.submenu_path, vec![1, 2]);
1642 assert_eq!(state.submenu_depth(), 2);
1643 assert_eq!(state.highlighted_item, Some(0));
1644
1645 assert!(state.close_submenu());
1647 assert_eq!(state.submenu_path, vec![1]);
1648 assert_eq!(state.highlighted_item, Some(2));
1649
1650 assert!(state.close_submenu());
1652 assert!(state.submenu_path.is_empty());
1653 assert_eq!(state.highlighted_item, Some(1));
1654
1655 assert!(!state.close_submenu());
1657 }
1658
1659 #[test]
1660 fn test_get_highlighted_action_in_submenu() {
1661 let mut state = MenuState::for_testing();
1662 let menus = create_menu_with_submenus();
1663
1664 state.open_menu(0);
1665 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1669
1670 state.open_submenu(&menus);
1672 let action = state.get_highlighted_action(&menus);
1674 assert!(action.is_some());
1675 let (action_name, _) = action.unwrap();
1676 assert_eq!(action_name, "open_terminal");
1677
1678 state.highlighted_item = Some(1);
1680 let action = state.get_highlighted_action(&menus);
1681 assert!(action.is_some());
1682 let (action_name, _) = action.unwrap();
1683 assert_eq!(action_name, "close_terminal");
1684 }
1685
1686 #[test]
1687 fn test_get_current_items_at_different_depths() {
1688 let mut state = MenuState::for_testing();
1689 let menus = create_menu_with_submenus();
1690
1691 state.open_menu(0);
1692
1693 let items = state.get_current_items(&menus, 0).unwrap();
1695 assert_eq!(items.len(), 4); state.highlighted_item = Some(1);
1699 state.open_submenu(&menus);
1700
1701 let items = state.get_current_items(&menus, 0).unwrap();
1703 assert_eq!(items.len(), 3); state.highlighted_item = Some(2);
1707 state.open_submenu(&menus);
1708
1709 let items = state.get_current_items(&menus, 0).unwrap();
1711 assert_eq!(items.len(), 1); }
1713
1714 #[test]
1715 fn test_is_highlighted_submenu() {
1716 let mut state = MenuState::for_testing();
1717 let menus = create_menu_with_submenus();
1718
1719 state.open_menu(0);
1720 state.highlighted_item = Some(0); assert!(!state.is_highlighted_submenu(&menus));
1722
1723 state.highlighted_item = Some(1); assert!(state.is_highlighted_submenu(&menus));
1725
1726 state.highlighted_item = Some(2); assert!(!state.is_highlighted_submenu(&menus));
1728
1729 state.highlighted_item = Some(3); assert!(!state.is_highlighted_submenu(&menus));
1731 }
1732
1733 #[test]
1734 fn test_open_menu_clears_submenu_path() {
1735 let mut state = MenuState::for_testing();
1736 let menus = create_menu_with_submenus();
1737
1738 state.open_menu(0);
1739 state.highlighted_item = Some(1);
1740 state.open_submenu(&menus);
1741 assert!(!state.submenu_path.is_empty());
1742
1743 state.open_menu(0);
1745 assert!(state.submenu_path.is_empty());
1746 }
1747
1748 #[test]
1749 fn test_next_prev_menu_clears_submenu_path() {
1750 let mut state = MenuState::for_testing();
1751 let menus = create_menu_with_submenus();
1752
1753 state.open_menu(0);
1754 state.highlighted_item = Some(1);
1755 state.open_submenu(&menus);
1756 assert!(!state.submenu_path.is_empty());
1757
1758 state.next_menu(&menus);
1760 assert!(state.submenu_path.is_empty());
1761
1762 state.open_menu(0);
1764 state.highlighted_item = Some(1);
1765 state.open_submenu(&menus);
1766
1767 state.prev_menu(&menus);
1769 assert!(state.submenu_path.is_empty());
1770 }
1771
1772 #[test]
1773 fn test_navigation_in_submenu() {
1774 let mut state = MenuState::for_testing();
1775 let menus = create_menu_with_submenus();
1776
1777 state.open_menu(0);
1778 state.highlighted_item = Some(1);
1779 state.open_submenu(&menus);
1780
1781 assert_eq!(state.highlighted_item, Some(0));
1783
1784 state.next_item(&menus[0]);
1786 assert_eq!(state.highlighted_item, Some(1));
1787
1788 state.next_item(&menus[0]);
1790 assert_eq!(state.highlighted_item, Some(2));
1791
1792 state.next_item(&menus[0]);
1794 assert_eq!(state.highlighted_item, Some(0));
1795
1796 state.prev_item(&menus[0]);
1798 assert_eq!(state.highlighted_item, Some(2));
1799 }
1800
1801 fn calculate_dropdown_x_offset(
1803 all_menus: &[Menu],
1804 menu_index: usize,
1805 context: &MenuContext,
1806 ) -> usize {
1807 let mut x_offset = 0usize;
1808 for (idx, m) in all_menus.iter().enumerate() {
1809 if idx == menu_index {
1810 break;
1811 }
1812 let is_visible = match &m.when {
1814 Some(condition) => context.get(condition),
1815 None => true,
1816 };
1817 if is_visible {
1818 x_offset += str_width(&m.label) + 3; }
1820 }
1821 x_offset
1822 }
1823
1824 #[test]
1825 fn test_dropdown_position_skips_hidden_menus() {
1826 let menus = vec![
1828 Menu {
1829 id: None,
1830 label: "File".to_string(), items: vec![],
1832 when: None,
1833 },
1834 Menu {
1835 id: None,
1836 label: "Explorer".to_string(), items: vec![],
1838 when: Some("file_explorer_focused".to_string()),
1839 },
1840 Menu {
1841 id: None,
1842 label: "Help".to_string(), items: vec![],
1844 when: None,
1845 },
1846 ];
1847
1848 let context_hidden = MenuContext::new().with("file_explorer_focused", false);
1850 let x_help_hidden = calculate_dropdown_x_offset(&menus, 2, &context_hidden);
1851 assert_eq!(
1853 x_help_hidden, 7,
1854 "Help dropdown should be at x=7 when Explorer is hidden"
1855 );
1856
1857 let context_visible = MenuContext::new().with("file_explorer_focused", true);
1859 let x_help_visible = calculate_dropdown_x_offset(&menus, 2, &context_visible);
1860 assert_eq!(
1862 x_help_visible, 18,
1863 "Help dropdown should be at x=18 when Explorer is visible"
1864 );
1865 }
1866
1867 #[test]
1868 fn test_dropdown_position_with_multiple_hidden_menus() {
1869 let menus = vec![
1870 Menu {
1871 id: None,
1872 label: "A".to_string(), items: vec![],
1874 when: None,
1875 },
1876 Menu {
1877 id: None,
1878 label: "B".to_string(), items: vec![],
1880 when: Some("show_b".to_string()),
1881 },
1882 Menu {
1883 id: None,
1884 label: "C".to_string(), items: vec![],
1886 when: Some("show_c".to_string()),
1887 },
1888 Menu {
1889 id: None,
1890 label: "D".to_string(),
1891 items: vec![],
1892 when: None,
1893 },
1894 ];
1895
1896 let context_none = MenuContext::new();
1898 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_none), 4);
1899
1900 let context_b = MenuContext::new().with("show_b", true);
1902 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_b), 8);
1903
1904 let context_both = MenuContext::new().with("show_b", true).with("show_c", true);
1906 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_both), 12);
1907 }
1908}