Skip to main content

fresh/view/ui/
menu.rs

1//! Menu bar rendering
2
3use 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
13// Re-export context_keys from the shared types module
14pub use crate::types::context_keys;
15
16/// Layout information for hit testing menu interactions
17///
18/// Returned by `MenuRenderer::render()` to enable mouse hit testing
19/// without duplicating position calculations.
20#[derive(Debug, Clone, Default)]
21pub struct MenuLayout {
22    /// Areas for top-level menu labels: (menu_index, area)
23    pub menu_areas: Vec<(usize, Rect)>,
24    /// Areas for dropdown items: (item_index, area)
25    /// Only populated when a menu is open
26    pub item_areas: Vec<(usize, Rect)>,
27    /// Areas for submenu items at each depth: (depth, item_index, area)
28    pub submenu_areas: Vec<(usize, usize, Rect)>,
29    /// The full menu bar area
30    pub bar_area: Rect,
31}
32
33/// Hit test result for menu interactions
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum MenuHit {
36    /// Hit a top-level menu label
37    MenuLabel(usize),
38    /// Hit a dropdown item (in the main dropdown)
39    DropdownItem(usize),
40    /// Hit a submenu item at a given depth
41    SubmenuItem { depth: usize, index: usize },
42    /// Hit the menu bar background
43    BarBackground,
44}
45
46impl MenuLayout {
47    /// Create a new empty layout
48    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    /// Get the menu index at a given position
58    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    /// Get the dropdown item index at a given position
68    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    /// Get the submenu item at a given position
78    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    /// Perform a complete hit test
88    pub fn hit_test(&self, x: u16, y: u16) -> Option<MenuHit> {
89        // Check submenu items first (they're on top)
90        if let Some((depth, idx)) = self.submenu_item_at(x, y) {
91            return Some(MenuHit::SubmenuItem { depth, index: idx });
92        }
93
94        // Check dropdown items
95        if let Some(idx) = self.item_at(x, y) {
96            return Some(MenuHit::DropdownItem(idx));
97        }
98
99        // Check menu labels
100        if let Some(idx) = self.menu_at(x, y) {
101            return Some(MenuHit::MenuLabel(idx));
102        }
103
104        // Check bar background
105        if point_in_rect(self.bar_area, x, y) {
106            return Some(MenuHit::BarBackground);
107        }
108
109        None
110    }
111}
112
113/// Menu state context - provides named boolean states for menu item conditions
114/// Both `when` conditions and `checkbox` states look up values here
115#[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    /// Set a named boolean state
128    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    /// Get a named boolean state (defaults to false if not set)
134    pub fn get(&self, name: &str) -> bool {
135        self.states.get(name).copied().unwrap_or(false)
136    }
137
138    /// Builder-style setter
139    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, // No condition means always enabled
151            }
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/// Menu bar state (tracks which menu is open and which item is highlighted)
165#[derive(Debug, Clone, Default)]
166pub struct MenuState {
167    /// Index of the currently open menu (None if menu bar is closed)
168    pub active_menu: Option<usize>,
169    /// Index of the highlighted item within the active menu or current submenu
170    pub highlighted_item: Option<usize>,
171    /// Path of indices into nested submenus (empty = at top level menu)
172    /// Each element is the index of the submenu item that was opened
173    pub submenu_path: Vec<usize>,
174    /// Runtime menu additions from plugins
175    pub plugin_menus: Vec<Menu>,
176    /// Context containing named boolean states for conditions and checkboxes
177    pub context: MenuContext,
178}
179
180impl MenuState {
181    pub fn new() -> Self {
182        Self::default()
183    }
184
185    /// Open a menu by index
186    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    /// Close the currently open menu (and all submenus)
193    pub fn close_menu(&mut self) {
194        self.active_menu = None;
195        self.highlighted_item = None;
196        self.submenu_path.clear();
197    }
198
199    /// Navigate to the next menu (right) - only at top level
200    /// Skips menus that are hidden (where `when` condition evaluates to false)
201    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        // Find the next visible menu, wrapping around
211        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        // No visible menu found, stay on current
221    }
222
223    /// Navigate to the previous menu (left) - only at top level
224    /// Skips menus that are hidden (where `when` condition evaluates to false)
225    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        // Find the previous visible menu, wrapping around
235        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        // No visible menu found, stay on current
245    }
246
247    /// Check if a menu is visible based on its `when` condition
248    fn is_menu_visible(&self, menu: &Menu) -> bool {
249        match &menu.when {
250            Some(condition) => self.context.get(condition),
251            None => true, // No condition = always visible
252        }
253    }
254
255    /// Check if we're currently in a submenu
256    pub fn in_submenu(&self) -> bool {
257        !self.submenu_path.is_empty()
258    }
259
260    /// Get the current submenu depth (0 = top level menu)
261    pub fn submenu_depth(&self) -> usize {
262        self.submenu_path.len()
263    }
264
265    /// Open a submenu at the current highlighted item
266    /// Returns true if a submenu was opened, false if the item wasn't a submenu
267    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        // Get the current menu items
276        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        // Check if highlighted item is a submenu (including DynamicSubmenu which was expanded)
284        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                    // Generate items to check if non-empty
296                    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    /// Close the current submenu and go back to parent
310    /// Returns true if a submenu was closed, false if already at top level
311    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    /// Get the menu items at the current submenu level
321    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    /// Get owned vec of current items (for use when Menu is cloned)
345    /// DynamicSubmenus are expanded to regular Submenus
346    pub fn get_current_items_cloned(&self, menu: &Menu) -> Option<Vec<MenuItem>> {
347        // Expand all items (handles DynamicSubmenu -> Submenu)
348        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    /// Navigate to the next item in the current menu/submenu (down)
366    pub fn next_item(&mut self, menu: &Menu) {
367        let Some(idx) = self.highlighted_item else {
368            return;
369        };
370
371        // Get current items (may be in a submenu)
372        let Some(items) = self.get_current_items_cloned(menu) else {
373            return;
374        };
375
376        if items.is_empty() {
377            return;
378        }
379
380        // Skip separators and disabled items
381        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    /// Navigate to the previous item in the current menu/submenu (up)
389    pub fn prev_item(&mut self, menu: &Menu) {
390        let Some(idx) = self.highlighted_item else {
391            return;
392        };
393
394        // Get current items (may be in a submenu)
395        let Some(items) = self.get_current_items_cloned(menu) else {
396            return;
397        };
398
399        if items.is_empty() {
400            return;
401        }
402
403        // Skip separators and disabled items
404        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    /// Check if a menu item should be skipped during navigation
413    fn should_skip_item(&self, item: &MenuItem) -> bool {
414        match item {
415            MenuItem::Separator { .. } => true,
416            MenuItem::Action { when, .. } => {
417                // Skip disabled items (when condition evaluates to false)
418                match when.as_deref() {
419                    Some(condition) => !self.context.get(condition),
420                    None => false, // No condition means enabled, don't skip
421                }
422            }
423            _ => false,
424        }
425    }
426
427    /// Get the currently highlighted action (if any)
428    /// This navigates through the submenu path to find the currently highlighted item
429    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        // Get the items at the current submenu level, handling DynamicSubmenu
437        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    /// Check if the currently highlighted item is a submenu
454    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        // Use get_current_items_cloned to handle DynamicSubmenu
463        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
477/// Renders the menu bar
478pub struct MenuRenderer;
479
480impl MenuRenderer {
481    /// Render the menu bar at the top of the screen
482    ///
483    /// # Arguments
484    /// * `frame` - The ratatui frame to render to
485    /// * `area` - The rectangular area to render the menu bar in
486    /// * `menu_config` - The menu configuration
487    /// * `menu_state` - Current menu state (which menu/item is active, and context)
488    /// * `keybindings` - Keybinding resolver for displaying shortcuts
489    /// * `theme` - The active theme for colors
490    /// * `hover_target` - The currently hovered UI element (if any)
491    ///
492    /// # Returns
493    /// `MenuLayout` containing hit areas for mouse interaction
494    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        // Combine config menus with plugin menus, expanding any DynamicSubmenus
505        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        // Track which menus are visible (based on their `when` condition)
517        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, // No condition = always visible
522            })
523            .collect();
524
525        // Build spans for each menu label and track their areas
526        let mut spans = Vec::new();
527        let mut current_x = area.x;
528
529        for (idx, menu) in all_menus.iter().enumerate() {
530            // Skip hidden menus
531            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            // Calculate label width: " Label " = 1 + label_width + 1
553            let label_width = str_width(&menu.label) as u16 + 2;
554
555            // Track the menu label area for hit testing
556            layout
557                .menu_areas
558                .push((idx, Rect::new(current_x, area.y, label_width, 1)));
559
560            // Check for mnemonic character (Alt+letter keybinding)
561            let mnemonic = keybindings.find_menu_mnemonic(&menu.label);
562
563            // Build the label with underlined mnemonic
564            spans.push(Span::styled(" ", base_style));
565
566            if let Some(mnemonic_char) = mnemonic {
567                // Find the first occurrence of the mnemonic character in the label
568                let mut found = false;
569                for c in menu.label.chars() {
570                    if !found && c.to_ascii_lowercase() == mnemonic_char {
571                        // Underline this character
572                        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                // No mnemonic, just render the label normally
583                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            // Move to next position: label_width + 1 for trailing space
590            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        // Render dropdown if a menu is active
598        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    /// Render a dropdown menu and all its open submenus
619    #[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        // Calculate the x position of the top-level dropdown based on menu index
633        // Skip hidden menus (those with `when` conditions that evaluate to false)
634        let mut x_offset = 0usize;
635        for (idx, m) in all_menus.iter().enumerate() {
636            if idx == menu_index {
637                break;
638            }
639            // Only count visible menus
640            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; // label + spaces
646            }
647        }
648
649        let terminal_width = frame.area().width;
650        let terminal_height = frame.area().height;
651
652        // Track dropdown positions for rendering submenus
653        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        // Render the main dropdown and collect submenu rendering info
658        // We'll render depth 0, then 1, etc.
659        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            // Render this dropdown level
668            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 not at the deepest level, navigate into the submenu for next iteration
687            if !is_deepest {
688                let submenu_idx = menu_state.submenu_path[depth];
689                // Handle both Submenu and DynamicSubmenu
690                let submenu_items = match current_items.get(submenu_idx) {
691                    Some(MenuItem::Submenu { items, .. }) => Some(items.as_slice()),
692                    Some(MenuItem::DynamicSubmenu { .. }) => {
693                        // DynamicSubmenu items will be generated and stored temporarily
694                        // This case shouldn't happen in normal flow since we expand before entering
695                        None
696                    }
697                    _ => None,
698                };
699                if let Some(items) = submenu_items {
700                    current_items = items;
701                    // Position submenu to the right of parent, aligned with the highlighted item
702                    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); // +1 for border
706
707                    // Adjust if submenu would go off screen to the right - flip to left side
708                    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    /// Calculate the width needed for a dropdown containing the given items
723    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    /// Render a single dropdown level and return its bounding Rect
739    #[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; // +2 for borders
759
760        let desired_width = max_width as u16;
761        let desired_height = dropdown_height as u16;
762
763        // Bounds check: ensure dropdown fits within the visible area
764        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        // Only render if we have at least minimal space
777        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        // Build dropdown content
794        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            // Check if this item is in the submenu path (has an open child submenu)
802            let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
803
804            // For hover target matching at submenu levels
805            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            // Track item area for hit testing
819            // Item position: inside border (x+1, y+1+idx), full content width
820            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                    // Highlight submenu items that have an open child
903                    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                    // Format: " Label        > " - label left-aligned, arrow near the end with padding
918                    // content_width minus: leading space (1) + space before arrow (1) + arrow (1) + trailing space (2)
919                    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                    // Disabled info label - always shown in disabled style
928                    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        // Wrap around
1065        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        // Wrap around backwards
1076        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        // highlighted_item starts at 0 (New)
1093        assert_eq!(state.highlighted_item, Some(0));
1094
1095        // Next should skip separator and go to Save (index 2)
1096        state.next_item(&menus[0]);
1097        assert_eq!(state.highlighted_item, Some(2));
1098
1099        // Next goes to Quit (index 3)
1100        state.next_item(&menus[0]);
1101        assert_eq!(state.highlighted_item, Some(3));
1102
1103        // Wrap around to New (index 0)
1104        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); // Start at Save
1114
1115        // Prev should skip separator and go to New (index 0)
1116        state.prev_item(&menus[0]);
1117        assert_eq!(state.highlighted_item, Some(0));
1118
1119        // Wrap around backwards to Quit (index 3)
1120        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); // Save action
1130
1131        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        // Without has_selection set, action should be disabled
1156        assert!(state
1157            .get_highlighted_action(std::slice::from_ref(&select_menu))
1158            .is_none());
1159
1160        // With has_selection set to true, action should be enabled
1161        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); // Separator
1178
1179        assert!(state.get_highlighted_action(&menus).is_none());
1180    }
1181
1182    #[test]
1183    fn test_menu_layout_menu_at() {
1184        // Build a layout manually simulating what render would produce
1185        let bar_area = Rect::new(0, 0, 80, 1);
1186        let mut layout = MenuLayout::new(bar_area);
1187
1188        // " File " at x=0, width=6
1189        layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1190        // " Edit " at x=7, width=6
1191        layout.menu_areas.push((1, Rect::new(7, 0, 6, 1)));
1192        // " View " at x=14, width=6
1193        layout.menu_areas.push((2, Rect::new(14, 0, 6, 1)));
1194
1195        // File: x=0-5, y=0
1196        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        // Space between: x=6
1201        assert_eq!(layout.menu_at(6, 0), None);
1202
1203        // Edit: x=7-12
1204        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        // Space between: x=13
1209        assert_eq!(layout.menu_at(13, 0), None);
1210
1211        // View: x=14-19
1212        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        // After View
1217        assert_eq!(layout.menu_at(20, 0), None);
1218        assert_eq!(layout.menu_at(100, 0), None);
1219
1220        // Wrong row returns None
1221        assert_eq!(layout.menu_at(3, 1), None);
1222    }
1223
1224    #[test]
1225    fn test_menu_layout_item_at() {
1226        // Build a layout manually simulating what render would produce
1227        let bar_area = Rect::new(0, 0, 80, 1);
1228        let mut layout = MenuLayout::new(bar_area);
1229
1230        // Dropdown items for File menu at x=1 (inside border), y=2,3,4,5 (inside border)
1231        // Item 0 (New) at y=2
1232        layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1233        // Item 1 (Separator) at y=3
1234        layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1235        // Item 2 (Save) at y=4
1236        layout.item_areas.push((2, Rect::new(1, 4, 20, 1)));
1237        // Item 3 (Quit) at y=5
1238        layout.item_areas.push((3, Rect::new(1, 5, 20, 1)));
1239
1240        // Menu bar row returns None
1241        assert_eq!(layout.item_at(5, 0), None);
1242        // Border row returns None
1243        assert_eq!(layout.item_at(5, 1), None);
1244
1245        // y=2: New (index 0)
1246        assert_eq!(layout.item_at(5, 2), Some(0));
1247
1248        // y=3: Separator (index 1) - note: layout includes all items, filtering happens elsewhere
1249        assert_eq!(layout.item_at(5, 3), Some(1));
1250
1251        // y=4: Save (index 2)
1252        assert_eq!(layout.item_at(5, 4), Some(2));
1253
1254        // y=5: Quit (index 3)
1255        assert_eq!(layout.item_at(5, 5), Some(3));
1256
1257        // Beyond items
1258        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        // Menu labels
1268        layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1269
1270        // Dropdown items
1271        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        // Submenu items at depth 1
1275        layout.submenu_areas.push((1, 0, Rect::new(22, 3, 15, 1)));
1276
1277        // Hit test menu label
1278        assert_eq!(layout.hit_test(3, 0), Some(MenuHit::MenuLabel(0)));
1279
1280        // Hit test dropdown item
1281        assert_eq!(layout.hit_test(5, 2), Some(MenuHit::DropdownItem(0)));
1282
1283        // Hit test submenu item (should have priority)
1284        assert_eq!(
1285            layout.hit_test(25, 3),
1286            Some(MenuHit::SubmenuItem { depth: 1, index: 0 })
1287        );
1288
1289        // Hit test bar background (inside bar area but not on menu)
1290        assert_eq!(layout.hit_test(50, 0), Some(MenuHit::BarBackground));
1291
1292        // Hit test outside everything
1293        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        // Check that default Alt+letter bindings are configured
1378        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        // Case-insensitive matching
1386        assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1387        assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1388
1389        // Non-existent menu should return None
1390        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        // Move to Terminal submenu item (index 1)
1457        state.highlighted_item = Some(1);
1458
1459        // Open the submenu
1460        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)); // Reset to first item
1465
1466        // Close the submenu
1467        assert!(state.close_submenu());
1468        assert!(state.submenu_path.is_empty());
1469        assert!(!state.in_submenu());
1470        assert_eq!(state.highlighted_item, Some(1)); // Restored to parent item
1471    }
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); // Terminal submenu
1480
1481        // Open first level submenu
1482        assert!(state.open_submenu(&menus));
1483        assert_eq!(state.submenu_depth(), 1);
1484
1485        // Move to Terminal Settings (nested submenu at index 2)
1486        state.highlighted_item = Some(2);
1487
1488        // Open second level submenu
1489        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        // Close back to first level
1495        assert!(state.close_submenu());
1496        assert_eq!(state.submenu_path, vec![1]);
1497        assert_eq!(state.highlighted_item, Some(2));
1498
1499        // Close back to main menu
1500        assert!(state.close_submenu());
1501        assert!(state.submenu_path.is_empty());
1502        assert_eq!(state.highlighted_item, Some(1));
1503
1504        // Can't close further
1505        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); // Terminal submenu
1515
1516        // On a submenu item, get_highlighted_action should return None
1517        assert!(state.get_highlighted_action(&menus).is_none());
1518
1519        // Open the submenu
1520        state.open_submenu(&menus);
1521        // Now highlighted_item is 0 which is "Open Terminal"
1522        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        // Navigate to second item
1528        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        // At top level, should get main menu items
1543        let items = state.get_current_items(&menus, 0).unwrap();
1544        assert_eq!(items.len(), 4); // Action, Submenu, Separator, Action
1545
1546        // Open Terminal submenu
1547        state.highlighted_item = Some(1);
1548        state.open_submenu(&menus);
1549
1550        // Now should get Terminal submenu items
1551        let items = state.get_current_items(&menus, 0).unwrap();
1552        assert_eq!(items.len(), 3); // Open, Close, Settings submenu
1553
1554        // Open nested Terminal Settings submenu
1555        state.highlighted_item = Some(2);
1556        state.open_submenu(&menus);
1557
1558        // Now should get Terminal Settings submenu items
1559        let items = state.get_current_items(&menus, 0).unwrap();
1560        assert_eq!(items.len(), 1); // Font Size
1561    }
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); // Toggle Explorer (action)
1570        assert!(!state.is_highlighted_submenu(&menus));
1571
1572        state.highlighted_item = Some(1); // Terminal (submenu)
1573        assert!(state.is_highlighted_submenu(&menus));
1574
1575        state.highlighted_item = Some(2); // Separator
1576        assert!(!state.is_highlighted_submenu(&menus));
1577
1578        state.highlighted_item = Some(3); // Zoom In (action)
1579        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        // Opening a new menu should clear the submenu path
1593        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        // next_menu should clear submenu path
1608        state.next_menu(&menus);
1609        assert!(state.submenu_path.is_empty());
1610
1611        // Re-open submenu
1612        state.open_menu(0);
1613        state.highlighted_item = Some(1);
1614        state.open_submenu(&menus);
1615
1616        // prev_menu should clear submenu path
1617        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        // In Terminal submenu, start at index 0
1631        assert_eq!(state.highlighted_item, Some(0));
1632
1633        // Navigate down
1634        state.next_item(&menus[0]);
1635        assert_eq!(state.highlighted_item, Some(1));
1636
1637        // Navigate down again
1638        state.next_item(&menus[0]);
1639        assert_eq!(state.highlighted_item, Some(2));
1640
1641        // Navigate down wraps to start
1642        state.next_item(&menus[0]);
1643        assert_eq!(state.highlighted_item, Some(0));
1644
1645        // Navigate up wraps to end
1646        state.prev_item(&menus[0]);
1647        assert_eq!(state.highlighted_item, Some(2));
1648    }
1649
1650    /// Helper function to calculate dropdown x offset (mirrors the logic in render_dropdown_chain)
1651    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            // Only count visible menus
1662            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; // label + spaces
1668            }
1669        }
1670        x_offset
1671    }
1672
1673    #[test]
1674    fn test_dropdown_position_skips_hidden_menus() {
1675        // Create menus: File (always visible), Explorer (conditional), Help (always visible)
1676        let menus = vec![
1677            Menu {
1678                id: None,
1679                label: "File".to_string(), // width 4, total 7 with padding
1680                items: vec![],
1681                when: None,
1682            },
1683            Menu {
1684                id: None,
1685                label: "Explorer".to_string(), // width 8, total 11 with padding
1686                items: vec![],
1687                when: Some("file_explorer_focused".to_string()),
1688            },
1689            Menu {
1690                id: None,
1691                label: "Help".to_string(), // width 4, total 7 with padding
1692                items: vec![],
1693                when: None,
1694            },
1695        ];
1696
1697        // When Explorer is hidden, Help dropdown should be at File's width only
1698        let context_hidden = MenuContext::new().with("file_explorer_focused", false);
1699        let x_help_hidden = calculate_dropdown_x_offset(&menus, 2, &context_hidden);
1700        // "File" = 4 chars + 3 spaces = 7
1701        assert_eq!(
1702            x_help_hidden, 7,
1703            "Help dropdown should be at x=7 when Explorer is hidden"
1704        );
1705
1706        // When Explorer is visible, Help dropdown should be at File + Explorer width
1707        let context_visible = MenuContext::new().with("file_explorer_focused", true);
1708        let x_help_visible = calculate_dropdown_x_offset(&menus, 2, &context_visible);
1709        // "File" = 4 chars + 3 spaces = 7, "Explorer" = 8 chars + 3 spaces = 11, total = 18
1710        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(), // width 1, total 4
1722                items: vec![],
1723                when: None,
1724            },
1725            Menu {
1726                id: None,
1727                label: "B".to_string(), // width 1, total 4
1728                items: vec![],
1729                when: Some("show_b".to_string()),
1730            },
1731            Menu {
1732                id: None,
1733                label: "C".to_string(), // width 1, total 4
1734                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        // No conditional menus visible: D should be right after A
1746        let context_none = MenuContext::new();
1747        assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_none), 4);
1748
1749        // Only B visible: D should be after A + B
1750        let context_b = MenuContext::new().with("show_b", true);
1751        assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_b), 8);
1752
1753        // Both B and C visible: D should be after A + B + C
1754        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}