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
113#[derive(Debug, Clone, Default)]
116pub struct MenuContext {
117 states: std::collections::HashMap<String, bool>,
118}
119
120impl MenuContext {
121 pub fn new() -> Self {
122 Self {
123 states: std::collections::HashMap::new(),
124 }
125 }
126
127 pub fn set(&mut self, name: impl Into<String>, value: bool) -> &mut Self {
129 self.states.insert(name.into(), value);
130 self
131 }
132
133 pub fn get(&self, name: &str) -> bool {
135 self.states.get(name).copied().unwrap_or(false)
136 }
137
138 pub fn with(mut self, name: impl Into<String>, value: bool) -> Self {
140 self.set(name, value);
141 self
142 }
143}
144
145fn is_menu_item_enabled(item: &MenuItem, context: &MenuContext) -> bool {
146 match item {
147 MenuItem::Action { when, .. } => {
148 match when.as_deref() {
149 Some(condition) => context.get(condition),
150 None => true, }
152 }
153 _ => true,
154 }
155}
156
157fn is_checkbox_checked(checkbox: &Option<String>, context: &MenuContext) -> bool {
158 match checkbox.as_deref() {
159 Some(name) => context.get(name),
160 None => false,
161 }
162}
163
164#[derive(Debug, Clone, Default)]
166pub struct MenuState {
167 pub active_menu: Option<usize>,
169 pub highlighted_item: Option<usize>,
171 pub submenu_path: Vec<usize>,
174 pub plugin_menus: Vec<Menu>,
176 pub context: MenuContext,
178}
179
180impl MenuState {
181 pub fn new() -> Self {
182 Self::default()
183 }
184
185 pub fn open_menu(&mut self, index: usize) {
187 self.active_menu = Some(index);
188 self.highlighted_item = Some(0);
189 self.submenu_path.clear();
190 }
191
192 pub fn close_menu(&mut self) {
194 self.active_menu = None;
195 self.highlighted_item = None;
196 self.submenu_path.clear();
197 }
198
199 pub fn next_menu(&mut self, menus: &[Menu]) {
202 let Some(active) = self.active_menu else {
203 return;
204 };
205 let total = menus.len();
206 if total == 0 {
207 return;
208 }
209
210 for i in 1..=total {
212 let next_idx = (active + i) % total;
213 if self.is_menu_visible(&menus[next_idx]) {
214 self.active_menu = Some(next_idx);
215 self.highlighted_item = Some(0);
216 self.submenu_path.clear();
217 return;
218 }
219 }
220 }
222
223 pub fn prev_menu(&mut self, menus: &[Menu]) {
226 let Some(active) = self.active_menu else {
227 return;
228 };
229 let total = menus.len();
230 if total == 0 {
231 return;
232 }
233
234 for i in 1..=total {
236 let prev_idx = (active + total - i) % total;
237 if self.is_menu_visible(&menus[prev_idx]) {
238 self.active_menu = Some(prev_idx);
239 self.highlighted_item = Some(0);
240 self.submenu_path.clear();
241 return;
242 }
243 }
244 }
246
247 fn is_menu_visible(&self, menu: &Menu) -> bool {
249 match &menu.when {
250 Some(condition) => self.context.get(condition),
251 None => true, }
253 }
254
255 pub fn in_submenu(&self) -> bool {
257 !self.submenu_path.is_empty()
258 }
259
260 pub fn submenu_depth(&self) -> usize {
262 self.submenu_path.len()
263 }
264
265 pub fn open_submenu(&mut self, menus: &[Menu]) -> bool {
268 let Some(active_idx) = self.active_menu else {
269 return false;
270 };
271 let Some(highlighted) = self.highlighted_item else {
272 return false;
273 };
274
275 let Some(menu) = menus.get(active_idx) else {
277 return false;
278 };
279 let Some(items) = self.get_current_items_cloned(menu) else {
280 return false;
281 };
282
283 if let Some(item) = items.get(highlighted) {
285 match item {
286 MenuItem::Submenu {
287 items: submenu_items,
288 ..
289 } if !submenu_items.is_empty() => {
290 self.submenu_path.push(highlighted);
291 self.highlighted_item = Some(0);
292 return true;
293 }
294 MenuItem::DynamicSubmenu { source, .. } => {
295 let generated = generate_dynamic_items(source);
297 if !generated.is_empty() {
298 self.submenu_path.push(highlighted);
299 self.highlighted_item = Some(0);
300 return true;
301 }
302 }
303 _ => {}
304 }
305 }
306 false
307 }
308
309 pub fn close_submenu(&mut self) -> bool {
312 if let Some(parent_idx) = self.submenu_path.pop() {
313 self.highlighted_item = Some(parent_idx);
314 true
315 } else {
316 false
317 }
318 }
319
320 pub fn get_current_items<'a>(
322 &self,
323 menus: &'a [Menu],
324 active_idx: usize,
325 ) -> Option<&'a [MenuItem]> {
326 let menu = menus.get(active_idx)?;
327 let mut items: &[MenuItem] = &menu.items;
328
329 for &idx in &self.submenu_path {
330 match items.get(idx)? {
331 MenuItem::Submenu {
332 items: submenu_items,
333 ..
334 } => {
335 items = submenu_items;
336 }
337 _ => return None,
338 }
339 }
340
341 Some(items)
342 }
343
344 pub fn get_current_items_cloned(&self, menu: &Menu) -> Option<Vec<MenuItem>> {
347 let mut items: Vec<MenuItem> = menu.items.iter().map(|i| i.expand_dynamic()).collect();
349
350 for &idx in &self.submenu_path {
351 match items.get(idx)?.expand_dynamic() {
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 pub fn render(
495 frame: &mut Frame,
496 area: Rect,
497 menu_config: &MenuConfig,
498 menu_state: &MenuState,
499 keybindings: &crate::input::keybindings::KeybindingResolver,
500 theme: &Theme,
501 hover_target: Option<&crate::app::HoverTarget>,
502 ) -> MenuLayout {
503 let mut layout = MenuLayout::new(area);
504 let all_menus: Vec<Menu> = menu_config
506 .menus
507 .iter()
508 .chain(menu_state.plugin_menus.iter())
509 .cloned()
510 .map(|mut menu| {
511 menu.expand_dynamic_items();
512 menu
513 })
514 .collect();
515
516 let menu_visible: Vec<bool> = all_menus
518 .iter()
519 .map(|menu| match &menu.when {
520 Some(condition) => menu_state.context.get(condition),
521 None => true, })
523 .collect();
524
525 let mut spans = Vec::new();
527 let mut current_x = area.x;
528
529 for (idx, menu) in all_menus.iter().enumerate() {
530 if !menu_visible[idx] {
532 continue;
533 }
534
535 let is_active = menu_state.active_menu == Some(idx);
536 let is_hovered =
537 matches!(hover_target, Some(crate::app::HoverTarget::MenuBarItem(i)) if *i == idx);
538
539 let base_style = if is_active {
540 Style::default()
541 .fg(theme.menu_active_fg)
542 .bg(theme.menu_active_bg)
543 .add_modifier(Modifier::BOLD)
544 } else if is_hovered {
545 Style::default()
546 .fg(theme.menu_hover_fg)
547 .bg(theme.menu_hover_bg)
548 } else {
549 Style::default().fg(theme.menu_fg).bg(theme.menu_bg)
550 };
551
552 let label_width = str_width(&menu.label) as u16 + 2;
554
555 layout
557 .menu_areas
558 .push((idx, Rect::new(current_x, area.y, label_width, 1)));
559
560 let mnemonic = keybindings.find_menu_mnemonic(&menu.label);
562
563 spans.push(Span::styled(" ", base_style));
565
566 if let Some(mnemonic_char) = mnemonic {
567 let mut found = false;
569 for c in menu.label.chars() {
570 if !found && c.to_ascii_lowercase() == mnemonic_char {
571 spans.push(Span::styled(
573 c.to_string(),
574 base_style.add_modifier(Modifier::UNDERLINED),
575 ));
576 found = true;
577 } else {
578 spans.push(Span::styled(c.to_string(), base_style));
579 }
580 }
581 } else {
582 spans.push(Span::styled(menu.label.clone(), base_style));
584 }
585
586 spans.push(Span::styled(" ", base_style));
587 spans.push(Span::raw(" "));
588
589 current_x += label_width + 1;
591 }
592
593 let line = Line::from(spans);
594 let paragraph = Paragraph::new(line).style(Style::default().bg(theme.menu_bg));
595 frame.render_widget(paragraph, area);
596
597 if let Some(active_idx) = menu_state.active_menu {
599 if let Some(menu) = all_menus.get(active_idx) {
600 Self::render_dropdown_chain(
601 frame,
602 area,
603 menu,
604 menu_state,
605 active_idx,
606 &all_menus,
607 keybindings,
608 theme,
609 hover_target,
610 &mut layout,
611 );
612 }
613 }
614
615 layout
616 }
617
618 #[allow(clippy::too_many_arguments)]
620 fn render_dropdown_chain(
621 frame: &mut Frame,
622 menu_bar_area: Rect,
623 menu: &Menu,
624 menu_state: &MenuState,
625 menu_index: usize,
626 all_menus: &[Menu],
627 keybindings: &crate::input::keybindings::KeybindingResolver,
628 theme: &Theme,
629 hover_target: Option<&crate::app::HoverTarget>,
630 layout: &mut MenuLayout,
631 ) {
632 let mut x_offset = 0usize;
635 for (idx, m) in all_menus.iter().enumerate() {
636 if idx == menu_index {
637 break;
638 }
639 let is_visible = match &m.when {
641 Some(condition) => menu_state.context.get(condition),
642 None => true,
643 };
644 if is_visible {
645 x_offset += str_width(&m.label) + 3; }
647 }
648
649 let terminal_width = frame.area().width;
650 let terminal_height = frame.area().height;
651
652 let mut current_items: &[MenuItem] = &menu.items;
654 let mut current_x = menu_bar_area.x.saturating_add(x_offset as u16);
655 let mut current_y = menu_bar_area.y.saturating_add(1);
656
657 for depth in 0..=menu_state.submenu_path.len() {
660 let is_deepest = depth == menu_state.submenu_path.len();
661 let highlighted_item = if is_deepest {
662 menu_state.highlighted_item
663 } else {
664 Some(menu_state.submenu_path[depth])
665 };
666
667 let dropdown_rect = Self::render_dropdown_level(
669 frame,
670 current_items,
671 highlighted_item,
672 current_x,
673 current_y,
674 terminal_width,
675 terminal_height,
676 depth,
677 &menu_state.submenu_path,
678 menu_index,
679 keybindings,
680 theme,
681 hover_target,
682 &menu_state.context,
683 layout,
684 );
685
686 if !is_deepest {
688 let submenu_idx = menu_state.submenu_path[depth];
689 let submenu_items = match current_items.get(submenu_idx) {
691 Some(MenuItem::Submenu { items, .. }) => Some(items.as_slice()),
692 Some(MenuItem::DynamicSubmenu { .. }) => {
693 None
696 }
697 _ => None,
698 };
699 if let Some(items) = submenu_items {
700 current_items = items;
701 current_x = dropdown_rect
703 .x
704 .saturating_add(dropdown_rect.width.saturating_sub(1));
705 current_y = dropdown_rect.y.saturating_add(submenu_idx as u16 + 1); let next_width = Self::calculate_dropdown_width(items);
709 if current_x.saturating_add(next_width as u16) > terminal_width {
710 current_x = dropdown_rect
711 .x
712 .saturating_sub(next_width as u16)
713 .saturating_add(1);
714 }
715 } else {
716 break;
717 }
718 }
719 }
720 }
721
722 fn calculate_dropdown_width(items: &[MenuItem]) -> usize {
724 items
725 .iter()
726 .map(|item| match item {
727 MenuItem::Action { label, .. } => str_width(label) + 20,
728 MenuItem::Submenu { label, .. } => str_width(label) + 20,
729 MenuItem::DynamicSubmenu { label, .. } => str_width(label) + 20,
730 MenuItem::Separator { .. } => 20,
731 MenuItem::Label { info } => str_width(info) + 4,
732 })
733 .max()
734 .unwrap_or(20)
735 .min(40)
736 }
737
738 #[allow(clippy::too_many_arguments)]
740 fn render_dropdown_level(
741 frame: &mut Frame,
742 items: &[MenuItem],
743 highlighted_item: Option<usize>,
744 x: u16,
745 y: u16,
746 terminal_width: u16,
747 terminal_height: u16,
748 depth: usize,
749 submenu_path: &[usize],
750 menu_index: usize,
751 keybindings: &crate::input::keybindings::KeybindingResolver,
752 theme: &Theme,
753 hover_target: Option<&crate::app::HoverTarget>,
754 context: &MenuContext,
755 layout: &mut MenuLayout,
756 ) -> Rect {
757 let max_width = Self::calculate_dropdown_width(items);
758 let dropdown_height = items.len() + 2; let desired_width = max_width as u16;
761 let desired_height = dropdown_height as u16;
762
763 let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
765 terminal_width.saturating_sub(desired_width)
766 } else {
767 x
768 };
769
770 let available_height = terminal_height.saturating_sub(y);
771 let height = desired_height.min(available_height);
772
773 let available_width = terminal_width.saturating_sub(adjusted_x);
774 let width = desired_width.min(available_width);
775
776 if width < 10 || height < 3 {
778 return Rect {
779 x: adjusted_x,
780 y,
781 width,
782 height,
783 };
784 }
785
786 let dropdown_area = Rect {
787 x: adjusted_x,
788 y,
789 width,
790 height,
791 };
792
793 let mut lines = Vec::new();
795 let max_items = (height.saturating_sub(2)) as usize;
796 let items_to_show = items.len().min(max_items);
797 let content_width = (width as usize).saturating_sub(2);
798
799 for (idx, item) in items.iter().enumerate().take(items_to_show) {
800 let is_highlighted = highlighted_item == Some(idx);
801 let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
803
804 let is_hovered = if depth == 0 {
806 matches!(
807 hover_target,
808 Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
809 )
810 } else {
811 matches!(
812 hover_target,
813 Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
814 )
815 };
816 let enabled = is_menu_item_enabled(item, context);
817
818 let item_area = Rect::new(adjusted_x + 1, y + 1 + idx as u16, content_width as u16, 1);
821 if depth == 0 {
822 layout.item_areas.push((idx, item_area));
823 } else {
824 layout.submenu_areas.push((depth, idx, item_area));
825 }
826
827 let line = match item {
828 MenuItem::Action {
829 label,
830 action,
831 checkbox,
832 ..
833 } => {
834 let style = if !enabled {
835 Style::default()
836 .fg(theme.menu_disabled_fg)
837 .bg(theme.menu_disabled_bg)
838 } else if is_highlighted {
839 Style::default()
840 .fg(theme.menu_highlight_fg)
841 .bg(theme.menu_highlight_bg)
842 } else if is_hovered {
843 Style::default()
844 .fg(theme.menu_hover_fg)
845 .bg(theme.menu_hover_bg)
846 } else {
847 Style::default()
848 .fg(theme.menu_dropdown_fg)
849 .bg(theme.menu_dropdown_bg)
850 };
851
852 let keybinding = keybindings
853 .find_keybinding_for_action(
854 action,
855 crate::input::keybindings::KeyContext::Normal,
856 )
857 .unwrap_or_default();
858
859 let checkbox_icon = if checkbox.is_some() {
860 if is_checkbox_checked(checkbox, context) {
861 "☑ "
862 } else {
863 "☐ "
864 }
865 } else {
866 ""
867 };
868
869 let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
870 let label_display_width = str_width(label);
871 let keybinding_display_width = str_width(&keybinding);
872
873 let text = if keybinding.is_empty() {
874 let padding_needed =
875 content_width.saturating_sub(checkbox_width + label_display_width + 1);
876 format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
877 } else {
878 let padding_needed = content_width.saturating_sub(
879 checkbox_width + label_display_width + keybinding_display_width + 2,
880 );
881 format!(
882 " {}{}{} {}",
883 checkbox_icon,
884 label,
885 " ".repeat(padding_needed),
886 keybinding
887 )
888 };
889
890 Line::from(vec![Span::styled(text, style)])
891 }
892 MenuItem::Separator { .. } => {
893 let separator = "─".repeat(content_width);
894 Line::from(vec![Span::styled(
895 format!(" {separator}"),
896 Style::default()
897 .fg(theme.menu_separator_fg)
898 .bg(theme.menu_dropdown_bg),
899 )])
900 }
901 MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
902 let style = if is_highlighted || has_open_submenu {
904 Style::default()
905 .fg(theme.menu_highlight_fg)
906 .bg(theme.menu_highlight_bg)
907 } else if is_hovered {
908 Style::default()
909 .fg(theme.menu_hover_fg)
910 .bg(theme.menu_hover_bg)
911 } else {
912 Style::default()
913 .fg(theme.menu_dropdown_fg)
914 .bg(theme.menu_dropdown_bg)
915 };
916
917 let label_display_width = str_width(label);
920 let padding_needed = content_width.saturating_sub(label_display_width + 5);
921 Line::from(vec![Span::styled(
922 format!(" {}{} > ", label, " ".repeat(padding_needed)),
923 style,
924 )])
925 }
926 MenuItem::Label { info } => {
927 let style = Style::default()
929 .fg(theme.menu_disabled_fg)
930 .bg(theme.menu_dropdown_bg);
931 let info_display_width = str_width(info);
932 let padding_needed = content_width.saturating_sub(info_display_width);
933 Line::from(vec![Span::styled(
934 format!(" {}{}", info, " ".repeat(padding_needed)),
935 style,
936 )])
937 }
938 };
939
940 lines.push(line);
941 }
942
943 let block = Block::default()
944 .borders(Borders::ALL)
945 .border_style(Style::default().fg(theme.menu_border_fg))
946 .style(Style::default().bg(theme.menu_dropdown_bg));
947
948 let paragraph = Paragraph::new(lines).block(block);
949 frame.render_widget(paragraph, dropdown_area);
950
951 dropdown_area
952 }
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958 use std::collections::HashMap;
959
960 fn create_test_menus() -> Vec<Menu> {
961 vec![
962 Menu {
963 id: None,
964 label: "File".to_string(),
965 items: vec![
966 MenuItem::Action {
967 label: "New".to_string(),
968 action: "new_file".to_string(),
969 args: HashMap::new(),
970 when: None,
971 checkbox: None,
972 },
973 MenuItem::Separator { separator: true },
974 MenuItem::Action {
975 label: "Save".to_string(),
976 action: "save".to_string(),
977 args: HashMap::new(),
978 when: None,
979 checkbox: None,
980 },
981 MenuItem::Action {
982 label: "Quit".to_string(),
983 action: "quit".to_string(),
984 args: HashMap::new(),
985 when: None,
986 checkbox: None,
987 },
988 ],
989 when: None,
990 },
991 Menu {
992 id: None,
993 label: "Edit".to_string(),
994 items: vec![
995 MenuItem::Action {
996 label: "Undo".to_string(),
997 action: "undo".to_string(),
998 args: HashMap::new(),
999 when: None,
1000 checkbox: None,
1001 },
1002 MenuItem::Action {
1003 label: "Redo".to_string(),
1004 action: "redo".to_string(),
1005 args: HashMap::new(),
1006 when: None,
1007 checkbox: None,
1008 },
1009 ],
1010 when: None,
1011 },
1012 Menu {
1013 id: None,
1014 label: "View".to_string(),
1015 items: vec![MenuItem::Action {
1016 label: "Toggle Explorer".to_string(),
1017 action: "toggle_file_explorer".to_string(),
1018 args: HashMap::new(),
1019 when: None,
1020 checkbox: None,
1021 }],
1022 when: None,
1023 },
1024 ]
1025 }
1026
1027 #[test]
1028 fn test_menu_state_default() {
1029 let state = MenuState::new();
1030 assert_eq!(state.active_menu, None);
1031 assert_eq!(state.highlighted_item, None);
1032 assert!(state.plugin_menus.is_empty());
1033 }
1034
1035 #[test]
1036 fn test_menu_state_open_menu() {
1037 let mut state = MenuState::new();
1038 state.open_menu(2);
1039 assert_eq!(state.active_menu, Some(2));
1040 assert_eq!(state.highlighted_item, Some(0));
1041 }
1042
1043 #[test]
1044 fn test_menu_state_close_menu() {
1045 let mut state = MenuState::new();
1046 state.open_menu(1);
1047 state.close_menu();
1048 assert_eq!(state.active_menu, None);
1049 assert_eq!(state.highlighted_item, None);
1050 }
1051
1052 #[test]
1053 fn test_menu_state_next_menu() {
1054 let mut state = MenuState::new();
1055 let menus = create_test_menus();
1056 state.open_menu(0);
1057
1058 state.next_menu(&menus);
1059 assert_eq!(state.active_menu, Some(1));
1060
1061 state.next_menu(&menus);
1062 assert_eq!(state.active_menu, Some(2));
1063
1064 state.next_menu(&menus);
1066 assert_eq!(state.active_menu, Some(0));
1067 }
1068
1069 #[test]
1070 fn test_menu_state_prev_menu() {
1071 let mut state = MenuState::new();
1072 let menus = create_test_menus();
1073 state.open_menu(0);
1074
1075 state.prev_menu(&menus);
1077 assert_eq!(state.active_menu, Some(2));
1078
1079 state.prev_menu(&menus);
1080 assert_eq!(state.active_menu, Some(1));
1081
1082 state.prev_menu(&menus);
1083 assert_eq!(state.active_menu, Some(0));
1084 }
1085
1086 #[test]
1087 fn test_menu_state_next_item_skips_separator() {
1088 let mut state = MenuState::new();
1089 let menus = create_test_menus();
1090 state.open_menu(0);
1091
1092 assert_eq!(state.highlighted_item, Some(0));
1094
1095 state.next_item(&menus[0]);
1097 assert_eq!(state.highlighted_item, Some(2));
1098
1099 state.next_item(&menus[0]);
1101 assert_eq!(state.highlighted_item, Some(3));
1102
1103 state.next_item(&menus[0]);
1105 assert_eq!(state.highlighted_item, Some(0));
1106 }
1107
1108 #[test]
1109 fn test_menu_state_prev_item_skips_separator() {
1110 let mut state = MenuState::new();
1111 let menus = create_test_menus();
1112 state.open_menu(0);
1113 state.highlighted_item = Some(2); state.prev_item(&menus[0]);
1117 assert_eq!(state.highlighted_item, Some(0));
1118
1119 state.prev_item(&menus[0]);
1121 assert_eq!(state.highlighted_item, Some(3));
1122 }
1123
1124 #[test]
1125 fn test_get_highlighted_action() {
1126 let mut state = MenuState::new();
1127 let menus = create_test_menus();
1128 state.open_menu(0);
1129 state.highlighted_item = Some(2); let action = state.get_highlighted_action(&menus);
1132 assert!(action.is_some());
1133 let (action_name, _args) = action.unwrap();
1134 assert_eq!(action_name, "save");
1135 }
1136
1137 #[test]
1138 fn test_menu_item_when_requires_selection() {
1139 let mut state = MenuState::new();
1140 let select_menu = Menu {
1141 id: None,
1142 label: "Edit".to_string(),
1143 items: vec![MenuItem::Action {
1144 label: "Find in Selection".to_string(),
1145 action: "find_in_selection".to_string(),
1146 args: HashMap::new(),
1147 when: Some(context_keys::HAS_SELECTION.to_string()),
1148 checkbox: None,
1149 }],
1150 when: None,
1151 };
1152 state.open_menu(0);
1153 state.highlighted_item = Some(0);
1154
1155 assert!(state
1157 .get_highlighted_action(std::slice::from_ref(&select_menu))
1158 .is_none());
1159
1160 state.context.set(context_keys::HAS_SELECTION, true);
1162 assert!(state.get_highlighted_action(&[select_menu]).is_some());
1163 }
1164
1165 #[test]
1166 fn test_get_highlighted_action_none_when_closed() {
1167 let state = MenuState::new();
1168 let menus = create_test_menus();
1169 assert!(state.get_highlighted_action(&menus).is_none());
1170 }
1171
1172 #[test]
1173 fn test_get_highlighted_action_none_for_separator() {
1174 let mut state = MenuState::new();
1175 let menus = create_test_menus();
1176 state.open_menu(0);
1177 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1180 }
1181
1182 #[test]
1183 fn test_menu_layout_menu_at() {
1184 let bar_area = Rect::new(0, 0, 80, 1);
1186 let mut layout = MenuLayout::new(bar_area);
1187
1188 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1190 layout.menu_areas.push((1, Rect::new(7, 0, 6, 1)));
1192 layout.menu_areas.push((2, Rect::new(14, 0, 6, 1)));
1194
1195 assert_eq!(layout.menu_at(0, 0), Some(0));
1197 assert_eq!(layout.menu_at(3, 0), Some(0));
1198 assert_eq!(layout.menu_at(5, 0), Some(0));
1199
1200 assert_eq!(layout.menu_at(6, 0), None);
1202
1203 assert_eq!(layout.menu_at(7, 0), Some(1));
1205 assert_eq!(layout.menu_at(10, 0), Some(1));
1206 assert_eq!(layout.menu_at(12, 0), Some(1));
1207
1208 assert_eq!(layout.menu_at(13, 0), None);
1210
1211 assert_eq!(layout.menu_at(14, 0), Some(2));
1213 assert_eq!(layout.menu_at(17, 0), Some(2));
1214 assert_eq!(layout.menu_at(19, 0), Some(2));
1215
1216 assert_eq!(layout.menu_at(20, 0), None);
1218 assert_eq!(layout.menu_at(100, 0), None);
1219
1220 assert_eq!(layout.menu_at(3, 1), None);
1222 }
1223
1224 #[test]
1225 fn test_menu_layout_item_at() {
1226 let bar_area = Rect::new(0, 0, 80, 1);
1228 let mut layout = MenuLayout::new(bar_area);
1229
1230 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1233 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1235 layout.item_areas.push((2, Rect::new(1, 4, 20, 1)));
1237 layout.item_areas.push((3, Rect::new(1, 5, 20, 1)));
1239
1240 assert_eq!(layout.item_at(5, 0), None);
1242 assert_eq!(layout.item_at(5, 1), None);
1244
1245 assert_eq!(layout.item_at(5, 2), Some(0));
1247
1248 assert_eq!(layout.item_at(5, 3), Some(1));
1250
1251 assert_eq!(layout.item_at(5, 4), Some(2));
1253
1254 assert_eq!(layout.item_at(5, 5), Some(3));
1256
1257 assert_eq!(layout.item_at(5, 6), None);
1259 assert_eq!(layout.item_at(5, 100), None);
1260 }
1261
1262 #[test]
1263 fn test_menu_layout_hit_test() {
1264 let bar_area = Rect::new(0, 0, 80, 1);
1265 let mut layout = MenuLayout::new(bar_area);
1266
1267 layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1269
1270 layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1272 layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1273
1274 layout.submenu_areas.push((1, 0, Rect::new(22, 3, 15, 1)));
1276
1277 assert_eq!(layout.hit_test(3, 0), Some(MenuHit::MenuLabel(0)));
1279
1280 assert_eq!(layout.hit_test(5, 2), Some(MenuHit::DropdownItem(0)));
1282
1283 assert_eq!(
1285 layout.hit_test(25, 3),
1286 Some(MenuHit::SubmenuItem { depth: 1, index: 0 })
1287 );
1288
1289 assert_eq!(layout.hit_test(50, 0), Some(MenuHit::BarBackground));
1291
1292 assert_eq!(layout.hit_test(50, 10), None);
1294 }
1295
1296 #[test]
1297 fn test_menu_config_json_parsing() {
1298 let json = r#"{
1299 "menus": [
1300 {
1301 "label": "File",
1302 "items": [
1303 { "label": "New", "action": "new_file" },
1304 { "separator": true },
1305 { "label": "Save", "action": "save" }
1306 ]
1307 }
1308 ]
1309 }"#;
1310
1311 let config: MenuConfig = serde_json::from_str(json).unwrap();
1312 assert_eq!(config.menus.len(), 1);
1313 assert_eq!(config.menus[0].label, "File");
1314 assert_eq!(config.menus[0].items.len(), 3);
1315
1316 match &config.menus[0].items[0] {
1317 MenuItem::Action { label, action, .. } => {
1318 assert_eq!(label, "New");
1319 assert_eq!(action, "new_file");
1320 }
1321 _ => panic!("Expected Action"),
1322 }
1323
1324 assert!(matches!(
1325 config.menus[0].items[1],
1326 MenuItem::Separator { .. }
1327 ));
1328
1329 match &config.menus[0].items[2] {
1330 MenuItem::Action { label, action, .. } => {
1331 assert_eq!(label, "Save");
1332 assert_eq!(action, "save");
1333 }
1334 _ => panic!("Expected Action"),
1335 }
1336 }
1337
1338 #[test]
1339 fn test_menu_item_with_args() {
1340 let json = r#"{
1341 "label": "Go to Line",
1342 "action": "goto_line",
1343 "args": { "line": 42 }
1344 }"#;
1345
1346 let item: MenuItem = serde_json::from_str(json).unwrap();
1347 match item {
1348 MenuItem::Action {
1349 label,
1350 action,
1351 args,
1352 ..
1353 } => {
1354 assert_eq!(label, "Go to Line");
1355 assert_eq!(action, "goto_line");
1356 assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1357 }
1358 _ => panic!("Expected Action with args"),
1359 }
1360 }
1361
1362 #[test]
1363 fn test_empty_menu_config() {
1364 let json = r#"{ "menus": [] }"#;
1365 let config: MenuConfig = serde_json::from_str(json).unwrap();
1366 assert!(config.menus.is_empty());
1367 }
1368
1369 #[test]
1370 fn test_menu_mnemonic_lookup() {
1371 use crate::config::Config;
1372 use crate::input::keybindings::KeybindingResolver;
1373
1374 let config = Config::default();
1375 let resolver = KeybindingResolver::new(&config);
1376
1377 assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1379 assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1380 assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1381 assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1382 assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1383 assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1384
1385 assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1387 assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1388
1389 assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1391 }
1392
1393 fn create_menu_with_submenus() -> Vec<Menu> {
1394 vec![Menu {
1395 id: None,
1396 label: "View".to_string(),
1397 items: vec![
1398 MenuItem::Action {
1399 label: "Toggle Explorer".to_string(),
1400 action: "toggle_file_explorer".to_string(),
1401 args: HashMap::new(),
1402 when: None,
1403 checkbox: None,
1404 },
1405 MenuItem::Submenu {
1406 label: "Terminal".to_string(),
1407 items: vec![
1408 MenuItem::Action {
1409 label: "Open Terminal".to_string(),
1410 action: "open_terminal".to_string(),
1411 args: HashMap::new(),
1412 when: None,
1413 checkbox: None,
1414 },
1415 MenuItem::Action {
1416 label: "Close Terminal".to_string(),
1417 action: "close_terminal".to_string(),
1418 args: HashMap::new(),
1419 when: None,
1420 checkbox: None,
1421 },
1422 MenuItem::Submenu {
1423 label: "Terminal Settings".to_string(),
1424 items: vec![MenuItem::Action {
1425 label: "Font Size".to_string(),
1426 action: "terminal_font_size".to_string(),
1427 args: HashMap::new(),
1428 when: None,
1429 checkbox: None,
1430 }],
1431 },
1432 ],
1433 },
1434 MenuItem::Separator { separator: true },
1435 MenuItem::Action {
1436 label: "Zoom In".to_string(),
1437 action: "zoom_in".to_string(),
1438 args: HashMap::new(),
1439 when: None,
1440 checkbox: None,
1441 },
1442 ],
1443 when: None,
1444 }]
1445 }
1446
1447 #[test]
1448 fn test_submenu_open_and_close() {
1449 let mut state = MenuState::new();
1450 let menus = create_menu_with_submenus();
1451
1452 state.open_menu(0);
1453 assert!(state.submenu_path.is_empty());
1454 assert!(!state.in_submenu());
1455
1456 state.highlighted_item = Some(1);
1458
1459 assert!(state.open_submenu(&menus));
1461 assert_eq!(state.submenu_path, vec![1]);
1462 assert!(state.in_submenu());
1463 assert_eq!(state.submenu_depth(), 1);
1464 assert_eq!(state.highlighted_item, Some(0)); assert!(state.close_submenu());
1468 assert!(state.submenu_path.is_empty());
1469 assert!(!state.in_submenu());
1470 assert_eq!(state.highlighted_item, Some(1)); }
1472
1473 #[test]
1474 fn test_nested_submenu() {
1475 let mut state = MenuState::new();
1476 let menus = create_menu_with_submenus();
1477
1478 state.open_menu(0);
1479 state.highlighted_item = Some(1); assert!(state.open_submenu(&menus));
1483 assert_eq!(state.submenu_depth(), 1);
1484
1485 state.highlighted_item = Some(2);
1487
1488 assert!(state.open_submenu(&menus));
1490 assert_eq!(state.submenu_path, vec![1, 2]);
1491 assert_eq!(state.submenu_depth(), 2);
1492 assert_eq!(state.highlighted_item, Some(0));
1493
1494 assert!(state.close_submenu());
1496 assert_eq!(state.submenu_path, vec![1]);
1497 assert_eq!(state.highlighted_item, Some(2));
1498
1499 assert!(state.close_submenu());
1501 assert!(state.submenu_path.is_empty());
1502 assert_eq!(state.highlighted_item, Some(1));
1503
1504 assert!(!state.close_submenu());
1506 }
1507
1508 #[test]
1509 fn test_get_highlighted_action_in_submenu() {
1510 let mut state = MenuState::new();
1511 let menus = create_menu_with_submenus();
1512
1513 state.open_menu(0);
1514 state.highlighted_item = Some(1); assert!(state.get_highlighted_action(&menus).is_none());
1518
1519 state.open_submenu(&menus);
1521 let action = state.get_highlighted_action(&menus);
1523 assert!(action.is_some());
1524 let (action_name, _) = action.unwrap();
1525 assert_eq!(action_name, "open_terminal");
1526
1527 state.highlighted_item = Some(1);
1529 let action = state.get_highlighted_action(&menus);
1530 assert!(action.is_some());
1531 let (action_name, _) = action.unwrap();
1532 assert_eq!(action_name, "close_terminal");
1533 }
1534
1535 #[test]
1536 fn test_get_current_items_at_different_depths() {
1537 let mut state = MenuState::new();
1538 let menus = create_menu_with_submenus();
1539
1540 state.open_menu(0);
1541
1542 let items = state.get_current_items(&menus, 0).unwrap();
1544 assert_eq!(items.len(), 4); state.highlighted_item = Some(1);
1548 state.open_submenu(&menus);
1549
1550 let items = state.get_current_items(&menus, 0).unwrap();
1552 assert_eq!(items.len(), 3); state.highlighted_item = Some(2);
1556 state.open_submenu(&menus);
1557
1558 let items = state.get_current_items(&menus, 0).unwrap();
1560 assert_eq!(items.len(), 1); }
1562
1563 #[test]
1564 fn test_is_highlighted_submenu() {
1565 let mut state = MenuState::new();
1566 let menus = create_menu_with_submenus();
1567
1568 state.open_menu(0);
1569 state.highlighted_item = Some(0); assert!(!state.is_highlighted_submenu(&menus));
1571
1572 state.highlighted_item = Some(1); assert!(state.is_highlighted_submenu(&menus));
1574
1575 state.highlighted_item = Some(2); assert!(!state.is_highlighted_submenu(&menus));
1577
1578 state.highlighted_item = Some(3); assert!(!state.is_highlighted_submenu(&menus));
1580 }
1581
1582 #[test]
1583 fn test_open_menu_clears_submenu_path() {
1584 let mut state = MenuState::new();
1585 let menus = create_menu_with_submenus();
1586
1587 state.open_menu(0);
1588 state.highlighted_item = Some(1);
1589 state.open_submenu(&menus);
1590 assert!(!state.submenu_path.is_empty());
1591
1592 state.open_menu(0);
1594 assert!(state.submenu_path.is_empty());
1595 }
1596
1597 #[test]
1598 fn test_next_prev_menu_clears_submenu_path() {
1599 let mut state = MenuState::new();
1600 let menus = create_menu_with_submenus();
1601
1602 state.open_menu(0);
1603 state.highlighted_item = Some(1);
1604 state.open_submenu(&menus);
1605 assert!(!state.submenu_path.is_empty());
1606
1607 state.next_menu(&menus);
1609 assert!(state.submenu_path.is_empty());
1610
1611 state.open_menu(0);
1613 state.highlighted_item = Some(1);
1614 state.open_submenu(&menus);
1615
1616 state.prev_menu(&menus);
1618 assert!(state.submenu_path.is_empty());
1619 }
1620
1621 #[test]
1622 fn test_navigation_in_submenu() {
1623 let mut state = MenuState::new();
1624 let menus = create_menu_with_submenus();
1625
1626 state.open_menu(0);
1627 state.highlighted_item = Some(1);
1628 state.open_submenu(&menus);
1629
1630 assert_eq!(state.highlighted_item, Some(0));
1632
1633 state.next_item(&menus[0]);
1635 assert_eq!(state.highlighted_item, Some(1));
1636
1637 state.next_item(&menus[0]);
1639 assert_eq!(state.highlighted_item, Some(2));
1640
1641 state.next_item(&menus[0]);
1643 assert_eq!(state.highlighted_item, Some(0));
1644
1645 state.prev_item(&menus[0]);
1647 assert_eq!(state.highlighted_item, Some(2));
1648 }
1649
1650 fn calculate_dropdown_x_offset(
1652 all_menus: &[Menu],
1653 menu_index: usize,
1654 context: &MenuContext,
1655 ) -> usize {
1656 let mut x_offset = 0usize;
1657 for (idx, m) in all_menus.iter().enumerate() {
1658 if idx == menu_index {
1659 break;
1660 }
1661 let is_visible = match &m.when {
1663 Some(condition) => context.get(condition),
1664 None => true,
1665 };
1666 if is_visible {
1667 x_offset += str_width(&m.label) + 3; }
1669 }
1670 x_offset
1671 }
1672
1673 #[test]
1674 fn test_dropdown_position_skips_hidden_menus() {
1675 let menus = vec![
1677 Menu {
1678 id: None,
1679 label: "File".to_string(), items: vec![],
1681 when: None,
1682 },
1683 Menu {
1684 id: None,
1685 label: "Explorer".to_string(), items: vec![],
1687 when: Some("file_explorer_focused".to_string()),
1688 },
1689 Menu {
1690 id: None,
1691 label: "Help".to_string(), items: vec![],
1693 when: None,
1694 },
1695 ];
1696
1697 let context_hidden = MenuContext::new().with("file_explorer_focused", false);
1699 let x_help_hidden = calculate_dropdown_x_offset(&menus, 2, &context_hidden);
1700 assert_eq!(
1702 x_help_hidden, 7,
1703 "Help dropdown should be at x=7 when Explorer is hidden"
1704 );
1705
1706 let context_visible = MenuContext::new().with("file_explorer_focused", true);
1708 let x_help_visible = calculate_dropdown_x_offset(&menus, 2, &context_visible);
1709 assert_eq!(
1711 x_help_visible, 18,
1712 "Help dropdown should be at x=18 when Explorer is visible"
1713 );
1714 }
1715
1716 #[test]
1717 fn test_dropdown_position_with_multiple_hidden_menus() {
1718 let menus = vec![
1719 Menu {
1720 id: None,
1721 label: "A".to_string(), items: vec![],
1723 when: None,
1724 },
1725 Menu {
1726 id: None,
1727 label: "B".to_string(), items: vec![],
1729 when: Some("show_b".to_string()),
1730 },
1731 Menu {
1732 id: None,
1733 label: "C".to_string(), items: vec![],
1735 when: Some("show_c".to_string()),
1736 },
1737 Menu {
1738 id: None,
1739 label: "D".to_string(),
1740 items: vec![],
1741 when: None,
1742 },
1743 ];
1744
1745 let context_none = MenuContext::new();
1747 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_none), 4);
1748
1749 let context_b = MenuContext::new().with("show_b", true);
1751 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_b), 8);
1752
1753 let context_both = MenuContext::new().with("show_b", true).with("show_c", true);
1755 assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_both), 12);
1756 }
1757}