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 max_width = Self::calculate_dropdown_width(items);
819 let dropdown_height = items.len() + 2; let desired_width = max_width as u16;
822 let desired_height = dropdown_height as u16;
823
824 let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
826 terminal_width.saturating_sub(desired_width)
827 } else {
828 x
829 };
830
831 let available_height = terminal_height.saturating_sub(y);
832 let height = desired_height.min(available_height);
833
834 let available_width = terminal_width.saturating_sub(adjusted_x);
835 let width = desired_width.min(available_width);
836
837 if width < 10 || height < 3 {
839 return Rect {
840 x: adjusted_x,
841 y,
842 width,
843 height,
844 };
845 }
846
847 let dropdown_area = Rect {
848 x: adjusted_x,
849 y,
850 width,
851 height,
852 };
853
854 if let Some(r) = rec.as_deref_mut() {
857 for row in dropdown_area.y..dropdown_area.y + dropdown_area.height {
858 r.run(
859 dropdown_area.x,
860 row,
861 dropdown_area.width,
862 Some("ui.menu_border_fg"),
863 Some("ui.menu_dropdown_bg"),
864 "Menu Dropdown",
865 );
866 }
867 }
868
869 let mut lines = Vec::new();
871 let max_items = (height.saturating_sub(2)) as usize;
872 let items_to_show = items.len().min(max_items);
873 let content_width = (width as usize).saturating_sub(2);
874
875 for (idx, item) in items.iter().enumerate().take(items_to_show) {
876 let is_highlighted = highlighted_item == Some(idx);
877 let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
879
880 let is_hovered = if depth == 0 {
882 matches!(
883 hover_target,
884 Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
885 )
886 } else {
887 matches!(
888 hover_target,
889 Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
890 )
891 };
892 let enabled = is_menu_item_enabled(item, context);
893
894 let item_area = Rect::new(adjusted_x + 1, y + 1 + idx as u16, content_width as u16, 1);
897 if depth == 0 {
898 layout.item_areas.push((idx, item_area));
899 } else {
900 layout.submenu_areas.push((depth, idx, item_area));
901 }
902
903 if let Some(r) = rec.as_deref_mut() {
905 let (fg, bg) = match item {
906 MenuItem::Separator { .. } => ("ui.menu_separator_fg", "ui.menu_dropdown_bg"),
907 MenuItem::Label { .. } => ("ui.menu_disabled_fg", "ui.menu_dropdown_bg"),
908 _ if !enabled => ("ui.menu_disabled_fg", "ui.menu_disabled_bg"),
909 _ if is_highlighted || has_open_submenu => {
910 ("ui.menu_highlight_fg", "ui.menu_highlight_bg")
911 }
912 _ => ("ui.menu_dropdown_fg", "ui.menu_dropdown_bg"),
913 };
914 r.run(
915 item_area.x,
916 item_area.y,
917 item_area.width,
918 Some(fg),
919 Some(bg),
920 "Menu Dropdown",
921 );
922 }
923
924 let line = match item {
925 MenuItem::Action {
926 label,
927 action,
928 checkbox,
929 ..
930 } => {
931 let style = if !enabled {
932 Style::default()
933 .fg(theme.menu_disabled_fg)
934 .bg(theme.menu_disabled_bg)
935 } else if is_highlighted {
936 Style::default()
937 .fg(theme.menu_highlight_fg)
938 .bg(theme.menu_highlight_bg)
939 } else if is_hovered {
940 Style::default()
941 .fg(theme.menu_hover_fg)
942 .bg(theme.menu_hover_bg)
943 } else {
944 Style::default()
945 .fg(theme.menu_dropdown_fg)
946 .bg(theme.menu_dropdown_bg)
947 };
948
949 let keybinding = keybindings
950 .find_keybinding_for_action(
951 action,
952 crate::input::keybindings::KeyContext::Normal,
953 )
954 .unwrap_or_default();
955
956 let checkbox_icon = if checkbox.is_some() {
957 if is_checkbox_checked(checkbox, context) {
958 "☑ "
959 } else {
960 "☐ "
961 }
962 } else {
963 ""
964 };
965
966 let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
967 let label_display_width = str_width(label);
968 let keybinding_display_width = str_width(&keybinding);
969
970 let text = if keybinding.is_empty() {
971 let padding_needed =
972 content_width.saturating_sub(checkbox_width + label_display_width + 1);
973 format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
974 } else {
975 let padding_needed = content_width.saturating_sub(
976 checkbox_width + label_display_width + keybinding_display_width + 2,
977 );
978 format!(
979 " {}{}{} {}",
980 checkbox_icon,
981 label,
982 " ".repeat(padding_needed),
983 keybinding
984 )
985 };
986
987 Line::from(vec![Span::styled(text, style)])
988 }
989 MenuItem::Separator { .. } => {
990 let separator = "─".repeat(content_width);
991 Line::from(vec![Span::styled(
992 format!(" {separator}"),
993 Style::default()
994 .fg(theme.menu_separator_fg)
995 .bg(theme.menu_dropdown_bg),
996 )])
997 }
998 MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
999 let style = if is_highlighted || has_open_submenu {
1001 Style::default()
1002 .fg(theme.menu_highlight_fg)
1003 .bg(theme.menu_highlight_bg)
1004 } else if is_hovered {
1005 Style::default()
1006 .fg(theme.menu_hover_fg)
1007 .bg(theme.menu_hover_bg)
1008 } else {
1009 Style::default()
1010 .fg(theme.menu_dropdown_fg)
1011 .bg(theme.menu_dropdown_bg)
1012 };
1013
1014 let label_display_width = str_width(label);
1017 let padding_needed = content_width.saturating_sub(label_display_width + 5);
1018 Line::from(vec![Span::styled(
1019 format!(" {}{} > ", label, " ".repeat(padding_needed)),
1020 style,
1021 )])
1022 }
1023 MenuItem::Label { info } => {
1024 let style = Style::default()
1026 .fg(theme.menu_disabled_fg)
1027 .bg(theme.menu_dropdown_bg);
1028 let info_display_width = str_width(info);
1029 let padding_needed = content_width.saturating_sub(info_display_width);
1030 Line::from(vec![Span::styled(
1031 format!(" {}{}", info, " ".repeat(padding_needed)),
1032 style,
1033 )])
1034 }
1035 };
1036
1037 lines.push(line);
1038 }
1039
1040 let block = Block::default()
1041 .borders(Borders::ALL)
1042 .border_style(Style::default().fg(theme.menu_border_fg))
1043 .style(Style::reset().bg(theme.menu_dropdown_bg));
1044
1045 if draw {
1046 let paragraph = Paragraph::new(lines).block(block);
1047 frame.render_widget(paragraph, dropdown_area);
1048 }
1049
1050 dropdown_area
1051 }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057 use crate::config::MenuConfig;
1058 use std::collections::HashMap;
1059
1060 fn create_test_menus() -> Vec<Menu> {
1061 vec![
1062 Menu {
1063 id: None,
1064 label: "File".to_string(),
1065 items: vec![
1066 MenuItem::Action {
1067 label: "New".to_string(),
1068 action: "new_file".to_string(),
1069 args: HashMap::new(),
1070 when: None,
1071 checkbox: None,
1072 },
1073 MenuItem::Separator { separator: true },
1074 MenuItem::Action {
1075 label: "Save".to_string(),
1076 action: "save".to_string(),
1077 args: HashMap::new(),
1078 when: None,
1079 checkbox: None,
1080 },
1081 MenuItem::Action {
1082 label: "Quit".to_string(),
1083 action: "quit".to_string(),
1084 args: HashMap::new(),
1085 when: None,
1086 checkbox: None,
1087 },
1088 ],
1089 when: None,
1090 },
1091 Menu {
1092 id: None,
1093 label: "Edit".to_string(),
1094 items: vec![
1095 MenuItem::Action {
1096 label: "Undo".to_string(),
1097 action: "undo".to_string(),
1098 args: HashMap::new(),
1099 when: None,
1100 checkbox: None,
1101 },
1102 MenuItem::Action {
1103 label: "Redo".to_string(),
1104 action: "redo".to_string(),
1105 args: HashMap::new(),
1106 when: None,
1107 checkbox: None,
1108 },
1109 ],
1110 when: None,
1111 },
1112 Menu {
1113 id: None,
1114 label: "View".to_string(),
1115 items: vec![MenuItem::Action {
1116 label: "Toggle Explorer".to_string(),
1117 action: "toggle_file_explorer".to_string(),
1118 args: HashMap::new(),
1119 when: None,
1120 checkbox: None,
1121 }],
1122 when: None,
1123 },
1124 ]
1125 }
1126
1127 #[test]
1128 fn test_menu_state_default() {
1129 let state = MenuState::for_testing();
1130 assert_eq!(state.active_menu, None);
1131 assert_eq!(state.highlighted_item, None);
1132 assert!(state.plugin_menus.is_empty());
1133 }
1134
1135 #[test]
1136 fn test_menu_state_open_menu() {
1137 let mut state = MenuState::for_testing();
1138 state.open_menu(2);
1139 assert_eq!(state.active_menu, Some(2));
1140 assert_eq!(state.highlighted_item, Some(0));
1141 }
1142
1143 #[test]
1144 fn test_menu_state_close_menu() {
1145 let mut state = MenuState::for_testing();
1146 state.open_menu(1);
1147 state.close_menu();
1148 assert_eq!(state.active_menu, None);
1149 assert_eq!(state.highlighted_item, None);
1150 }
1151
1152 #[test]
1153 fn test_menu_state_next_menu() {
1154 let mut state = MenuState::for_testing();
1155 let menus = create_test_menus();
1156 state.open_menu(0);
1157
1158 state.next_menu(&menus);
1159 assert_eq!(state.active_menu, Some(1));
1160
1161 state.next_menu(&menus);
1162 assert_eq!(state.active_menu, Some(2));
1163
1164 state.next_menu(&menus);
1166 assert_eq!(state.active_menu, Some(0));
1167 }
1168
1169 #[test]
1170 fn test_menu_state_prev_menu() {
1171 let mut state = MenuState::for_testing();
1172 let menus = create_test_menus();
1173 state.open_menu(0);
1174
1175 state.prev_menu(&menus);
1177 assert_eq!(state.active_menu, Some(2));
1178
1179 state.prev_menu(&menus);
1180 assert_eq!(state.active_menu, Some(1));
1181
1182 state.prev_menu(&menus);
1183 assert_eq!(state.active_menu, Some(0));
1184 }
1185
1186 #[test]
1187 fn test_menu_state_next_item_skips_separator() {
1188 let mut state = MenuState::for_testing();
1189 let menus = create_test_menus();
1190 state.open_menu(0);
1191
1192 assert_eq!(state.highlighted_item, Some(0));
1194
1195 state.next_item(&menus[0]);
1197 assert_eq!(state.highlighted_item, Some(2));
1198
1199 state.next_item(&menus[0]);
1201 assert_eq!(state.highlighted_item, Some(3));
1202
1203 state.next_item(&menus[0]);
1205 assert_eq!(state.highlighted_item, Some(0));
1206 }
1207
1208 #[test]
1209 fn test_menu_state_prev_item_skips_separator() {
1210 let mut state = MenuState::for_testing();
1211 let menus = create_test_menus();
1212 state.open_menu(0);
1213 state.highlighted_item = Some(2); state.prev_item(&menus[0]);
1217 assert_eq!(state.highlighted_item, Some(0));
1218
1219 state.prev_item(&menus[0]);
1221 assert_eq!(state.highlighted_item, Some(3));
1222 }
1223
1224 #[test]
1225 fn test_get_highlighted_action() {
1226 let mut state = MenuState::for_testing();
1227 let menus = create_test_menus();
1228 state.open_menu(0);
1229 state.highlighted_item = Some(2); let action = state.get_highlighted_action(&menus);
1232 assert!(action.is_some());
1233 let (action_name, _args) = action.unwrap();
1234 assert_eq!(action_name, "save");
1235 }
1236
1237 #[test]
1238 fn test_menu_item_when_requires_selection() {
1239 let mut state = MenuState::for_testing();
1240 let select_menu = Menu {
1241 id: None,
1242 label: "Edit".to_string(),
1243 items: vec![MenuItem::Action {
1244 label: "Find in Selection".to_string(),
1245 action: "find_in_selection".to_string(),
1246 args: HashMap::new(),
1247 when: Some(context_keys::HAS_SELECTION.to_string()),
1248 checkbox: None,
1249 }],
1250 when: None,
1251 };
1252 state.open_menu(0);
1253 state.highlighted_item = Some(0);
1254
1255 assert!(state
1257 .get_highlighted_action(std::slice::from_ref(&select_menu))
1258 .is_none());
1259
1260 state.context.set(context_keys::HAS_SELECTION, true);
1262 assert!(state.get_highlighted_action(&[select_menu]).is_some());
1263 }
1264
1265 #[test]
1266 fn test_get_highlighted_action_none_when_closed() {
1267 let state = MenuState::for_testing();
1268 let menus = create_test_menus();
1269 assert!(state.get_highlighted_action(&menus).is_none());
1270 }
1271
1272 #[test]
1273 fn test_get_highlighted_action_none_for_separator() {
1274 let mut state = MenuState::for_testing();
1275 let menus = create_test_menus();
1276 state.open_menu(0);
1277 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1280 }
1281
1282 #[test]
1283 fn test_menu_layout_menu_at() {
1284 let bar_area = Rect::new(0, 0, 80, 1);
1286 let mut layout = MenuLayout::new(bar_area);
1287
1288 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1290 layout.menu_areas.push((1, Rect::new(7, 0, 6, 1)));
1292 layout.menu_areas.push((2, Rect::new(14, 0, 6, 1)));
1294
1295 assert_eq!(layout.menu_at(0, 0), Some(0));
1297 assert_eq!(layout.menu_at(3, 0), Some(0));
1298 assert_eq!(layout.menu_at(5, 0), Some(0));
1299
1300 assert_eq!(layout.menu_at(6, 0), None);
1302
1303 assert_eq!(layout.menu_at(7, 0), Some(1));
1305 assert_eq!(layout.menu_at(10, 0), Some(1));
1306 assert_eq!(layout.menu_at(12, 0), Some(1));
1307
1308 assert_eq!(layout.menu_at(13, 0), None);
1310
1311 assert_eq!(layout.menu_at(14, 0), Some(2));
1313 assert_eq!(layout.menu_at(17, 0), Some(2));
1314 assert_eq!(layout.menu_at(19, 0), Some(2));
1315
1316 assert_eq!(layout.menu_at(20, 0), None);
1318 assert_eq!(layout.menu_at(100, 0), None);
1319
1320 assert_eq!(layout.menu_at(3, 1), None);
1322 }
1323
1324 #[test]
1325 fn test_menu_layout_item_at() {
1326 let bar_area = Rect::new(0, 0, 80, 1);
1328 let mut layout = MenuLayout::new(bar_area);
1329
1330 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1333 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1335 layout.item_areas.push((2, Rect::new(1, 4, 20, 1)));
1337 layout.item_areas.push((3, Rect::new(1, 5, 20, 1)));
1339
1340 assert_eq!(layout.item_at(5, 0), None);
1342 assert_eq!(layout.item_at(5, 1), None);
1344
1345 assert_eq!(layout.item_at(5, 2), Some(0));
1347
1348 assert_eq!(layout.item_at(5, 3), Some(1));
1350
1351 assert_eq!(layout.item_at(5, 4), Some(2));
1353
1354 assert_eq!(layout.item_at(5, 5), Some(3));
1356
1357 assert_eq!(layout.item_at(5, 6), None);
1359 assert_eq!(layout.item_at(5, 100), None);
1360 }
1361
1362 #[test]
1363 fn test_menu_layout_hit_test() {
1364 let bar_area = Rect::new(0, 0, 80, 1);
1365 let mut layout = MenuLayout::new(bar_area);
1366
1367 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1369
1370 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1372 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1373
1374 layout.submenu_areas.push((1, 0, Rect::new(22, 3, 15, 1)));
1376
1377 assert_eq!(layout.hit_test(3, 0), Some(MenuHit::MenuLabel(0)));
1379
1380 assert_eq!(layout.hit_test(5, 2), Some(MenuHit::DropdownItem(0)));
1382
1383 assert_eq!(
1385 layout.hit_test(25, 3),
1386 Some(MenuHit::SubmenuItem { depth: 1, index: 0 })
1387 );
1388
1389 assert_eq!(layout.hit_test(50, 0), Some(MenuHit::BarBackground));
1391
1392 assert_eq!(layout.hit_test(50, 10), None);
1394 }
1395
1396 #[test]
1397 fn test_menu_config_json_parsing() {
1398 let json = r#"{
1399 "menus": [
1400 {
1401 "label": "File",
1402 "items": [
1403 { "label": "New", "action": "new_file" },
1404 { "separator": true },
1405 { "label": "Save", "action": "save" }
1406 ]
1407 }
1408 ]
1409 }"#;
1410
1411 let config: MenuConfig = serde_json::from_str(json).unwrap();
1412 assert_eq!(config.menus.len(), 1);
1413 assert_eq!(config.menus[0].label, "File");
1414 assert_eq!(config.menus[0].items.len(), 3);
1415
1416 match &config.menus[0].items[0] {
1417 MenuItem::Action { label, action, .. } => {
1418 assert_eq!(label, "New");
1419 assert_eq!(action, "new_file");
1420 }
1421 _ => panic!("Expected Action"),
1422 }
1423
1424 assert!(matches!(
1425 config.menus[0].items[1],
1426 MenuItem::Separator { .. }
1427 ));
1428
1429 match &config.menus[0].items[2] {
1430 MenuItem::Action { label, action, .. } => {
1431 assert_eq!(label, "Save");
1432 assert_eq!(action, "save");
1433 }
1434 _ => panic!("Expected Action"),
1435 }
1436 }
1437
1438 #[test]
1439 fn test_menu_item_with_args() {
1440 let json = r#"{
1441 "label": "Go to Line",
1442 "action": "goto_line",
1443 "args": { "line": 42 }
1444 }"#;
1445
1446 let item: MenuItem = serde_json::from_str(json).unwrap();
1447 match item {
1448 MenuItem::Action {
1449 label,
1450 action,
1451 args,
1452 ..
1453 } => {
1454 assert_eq!(label, "Go to Line");
1455 assert_eq!(action, "goto_line");
1456 assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1457 }
1458 _ => panic!("Expected Action with args"),
1459 }
1460 }
1461
1462 #[test]
1463 fn test_empty_menu_config() {
1464 let json = r#"{ "menus": [] }"#;
1465 let config: MenuConfig = serde_json::from_str(json).unwrap();
1466 assert!(config.menus.is_empty());
1467 }
1468
1469 #[test]
1470 fn test_menu_mnemonic_lookup() {
1471 use crate::config::Config;
1472 use crate::input::keybindings::KeybindingResolver;
1473
1474 let config = Config::default();
1475 let resolver = KeybindingResolver::new(&config);
1476
1477 assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1479 assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1480 assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1481 assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1482 assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1483 assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1484
1485 assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1487 assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1488
1489 assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1491 }
1492
1493 fn create_menu_with_submenus() -> Vec<Menu> {
1494 vec![Menu {
1495 id: None,
1496 label: "View".to_string(),
1497 items: vec![
1498 MenuItem::Action {
1499 label: "Toggle Explorer".to_string(),
1500 action: "toggle_file_explorer".to_string(),
1501 args: HashMap::new(),
1502 when: None,
1503 checkbox: None,
1504 },
1505 MenuItem::Submenu {
1506 label: "Terminal".to_string(),
1507 items: vec![
1508 MenuItem::Action {
1509 label: "Open Terminal".to_string(),
1510 action: "open_terminal".to_string(),
1511 args: HashMap::new(),
1512 when: None,
1513 checkbox: None,
1514 },
1515 MenuItem::Action {
1516 label: "Close Terminal".to_string(),
1517 action: "close_terminal".to_string(),
1518 args: HashMap::new(),
1519 when: None,
1520 checkbox: None,
1521 },
1522 MenuItem::Submenu {
1523 label: "Terminal Settings".to_string(),
1524 items: vec![MenuItem::Action {
1525 label: "Font Size".to_string(),
1526 action: "terminal_font_size".to_string(),
1527 args: HashMap::new(),
1528 when: None,
1529 checkbox: None,
1530 }],
1531 },
1532 ],
1533 },
1534 MenuItem::Separator { separator: true },
1535 MenuItem::Action {
1536 label: "Zoom In".to_string(),
1537 action: "zoom_in".to_string(),
1538 args: HashMap::new(),
1539 when: None,
1540 checkbox: None,
1541 },
1542 ],
1543 when: None,
1544 }]
1545 }
1546
1547 #[test]
1548 fn test_submenu_open_and_close() {
1549 let mut state = MenuState::for_testing();
1550 let menus = create_menu_with_submenus();
1551
1552 state.open_menu(0);
1553 assert!(state.submenu_path.is_empty());
1554 assert!(!state.in_submenu());
1555
1556 state.highlighted_item = Some(1);
1558
1559 assert!(state.open_submenu(&menus));
1561 assert_eq!(state.submenu_path, vec![1]);
1562 assert!(state.in_submenu());
1563 assert_eq!(state.submenu_depth(), 1);
1564 assert_eq!(state.highlighted_item, Some(0)); assert!(state.close_submenu());
1568 assert!(state.submenu_path.is_empty());
1569 assert!(!state.in_submenu());
1570 assert_eq!(state.highlighted_item, Some(1)); }
1572
1573 #[test]
1574 fn test_nested_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(1); assert!(state.open_submenu(&menus));
1583 assert_eq!(state.submenu_depth(), 1);
1584
1585 state.highlighted_item = Some(2);
1587
1588 assert!(state.open_submenu(&menus));
1590 assert_eq!(state.submenu_path, vec![1, 2]);
1591 assert_eq!(state.submenu_depth(), 2);
1592 assert_eq!(state.highlighted_item, Some(0));
1593
1594 assert!(state.close_submenu());
1596 assert_eq!(state.submenu_path, vec![1]);
1597 assert_eq!(state.highlighted_item, Some(2));
1598
1599 assert!(state.close_submenu());
1601 assert!(state.submenu_path.is_empty());
1602 assert_eq!(state.highlighted_item, Some(1));
1603
1604 assert!(!state.close_submenu());
1606 }
1607
1608 #[test]
1609 fn test_get_highlighted_action_in_submenu() {
1610 let mut state = MenuState::for_testing();
1611 let menus = create_menu_with_submenus();
1612
1613 state.open_menu(0);
1614 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1618
1619 state.open_submenu(&menus);
1621 let action = state.get_highlighted_action(&menus);
1623 assert!(action.is_some());
1624 let (action_name, _) = action.unwrap();
1625 assert_eq!(action_name, "open_terminal");
1626
1627 state.highlighted_item = Some(1);
1629 let action = state.get_highlighted_action(&menus);
1630 assert!(action.is_some());
1631 let (action_name, _) = action.unwrap();
1632 assert_eq!(action_name, "close_terminal");
1633 }
1634
1635 #[test]
1636 fn test_get_current_items_at_different_depths() {
1637 let mut state = MenuState::for_testing();
1638 let menus = create_menu_with_submenus();
1639
1640 state.open_menu(0);
1641
1642 let items = state.get_current_items(&menus, 0).unwrap();
1644 assert_eq!(items.len(), 4); state.highlighted_item = Some(1);
1648 state.open_submenu(&menus);
1649
1650 let items = state.get_current_items(&menus, 0).unwrap();
1652 assert_eq!(items.len(), 3); state.highlighted_item = Some(2);
1656 state.open_submenu(&menus);
1657
1658 let items = state.get_current_items(&menus, 0).unwrap();
1660 assert_eq!(items.len(), 1); }
1662
1663 #[test]
1664 fn test_is_highlighted_submenu() {
1665 let mut state = MenuState::for_testing();
1666 let menus = create_menu_with_submenus();
1667
1668 state.open_menu(0);
1669 state.highlighted_item = Some(0); assert!(!state.is_highlighted_submenu(&menus));
1671
1672 state.highlighted_item = Some(1); assert!(state.is_highlighted_submenu(&menus));
1674
1675 state.highlighted_item = Some(2); assert!(!state.is_highlighted_submenu(&menus));
1677
1678 state.highlighted_item = Some(3); assert!(!state.is_highlighted_submenu(&menus));
1680 }
1681
1682 #[test]
1683 fn test_open_menu_clears_submenu_path() {
1684 let mut state = MenuState::for_testing();
1685 let menus = create_menu_with_submenus();
1686
1687 state.open_menu(0);
1688 state.highlighted_item = Some(1);
1689 state.open_submenu(&menus);
1690 assert!(!state.submenu_path.is_empty());
1691
1692 state.open_menu(0);
1694 assert!(state.submenu_path.is_empty());
1695 }
1696
1697 #[test]
1698 fn test_next_prev_menu_clears_submenu_path() {
1699 let mut state = MenuState::for_testing();
1700 let menus = create_menu_with_submenus();
1701
1702 state.open_menu(0);
1703 state.highlighted_item = Some(1);
1704 state.open_submenu(&menus);
1705 assert!(!state.submenu_path.is_empty());
1706
1707 state.next_menu(&menus);
1709 assert!(state.submenu_path.is_empty());
1710
1711 state.open_menu(0);
1713 state.highlighted_item = Some(1);
1714 state.open_submenu(&menus);
1715
1716 state.prev_menu(&menus);
1718 assert!(state.submenu_path.is_empty());
1719 }
1720
1721 #[test]
1722 fn test_navigation_in_submenu() {
1723 let mut state = MenuState::for_testing();
1724 let menus = create_menu_with_submenus();
1725
1726 state.open_menu(0);
1727 state.highlighted_item = Some(1);
1728 state.open_submenu(&menus);
1729
1730 assert_eq!(state.highlighted_item, Some(0));
1732
1733 state.next_item(&menus[0]);
1735 assert_eq!(state.highlighted_item, Some(1));
1736
1737 state.next_item(&menus[0]);
1739 assert_eq!(state.highlighted_item, Some(2));
1740
1741 state.next_item(&menus[0]);
1743 assert_eq!(state.highlighted_item, Some(0));
1744
1745 state.prev_item(&menus[0]);
1747 assert_eq!(state.highlighted_item, Some(2));
1748 }
1749
1750 fn calculate_dropdown_x_offset(
1752 all_menus: &[Menu],
1753 menu_index: usize,
1754 context: &MenuContext,
1755 ) -> usize {
1756 let mut x_offset = 0usize;
1757 for (idx, m) in all_menus.iter().enumerate() {
1758 if idx == menu_index {
1759 break;
1760 }
1761 let is_visible = match &m.when {
1763 Some(condition) => context.get(condition),
1764 None => true,
1765 };
1766 if is_visible {
1767 x_offset += str_width(&m.label) + 3; }
1769 }
1770 x_offset
1771 }
1772
1773 #[test]
1774 fn test_dropdown_position_skips_hidden_menus() {
1775 let menus = vec![
1777 Menu {
1778 id: None,
1779 label: "File".to_string(), items: vec![],
1781 when: None,
1782 },
1783 Menu {
1784 id: None,
1785 label: "Explorer".to_string(), items: vec![],
1787 when: Some("file_explorer_focused".to_string()),
1788 },
1789 Menu {
1790 id: None,
1791 label: "Help".to_string(), items: vec![],
1793 when: None,
1794 },
1795 ];
1796
1797 let context_hidden = MenuContext::new().with("file_explorer_focused", false);
1799 let x_help_hidden = calculate_dropdown_x_offset(&menus, 2, &context_hidden);
1800 assert_eq!(
1802 x_help_hidden, 7,
1803 "Help dropdown should be at x=7 when Explorer is hidden"
1804 );
1805
1806 let context_visible = MenuContext::new().with("file_explorer_focused", true);
1808 let x_help_visible = calculate_dropdown_x_offset(&menus, 2, &context_visible);
1809 assert_eq!(
1811 x_help_visible, 18,
1812 "Help dropdown should be at x=18 when Explorer is visible"
1813 );
1814 }
1815
1816 #[test]
1817 fn test_dropdown_position_with_multiple_hidden_menus() {
1818 let menus = vec![
1819 Menu {
1820 id: None,
1821 label: "A".to_string(), items: vec![],
1823 when: None,
1824 },
1825 Menu {
1826 id: None,
1827 label: "B".to_string(), items: vec![],
1829 when: Some("show_b".to_string()),
1830 },
1831 Menu {
1832 id: None,
1833 label: "C".to_string(), items: vec![],
1835 when: Some("show_c".to_string()),
1836 },
1837 Menu {
1838 id: None,
1839 label: "D".to_string(),
1840 items: vec![],
1841 when: None,
1842 },
1843 ];
1844
1845 let context_none = MenuContext::new();
1847 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_none), 4);
1848
1849 let context_b = MenuContext::new().with("show_b", true);
1851 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_b), 8);
1852
1853 let context_both = MenuContext::new().with("show_b", true).with("show_c", true);
1855 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_both), 12);
1856 }
1857}