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// Re-export MenuContext from fresh-core so existing editor code keeps compiling.
114pub use fresh_core::menu::MenuContext;
115
116fn is_menu_item_enabled(item: &MenuItem, context: &MenuContext) -> bool {
117    match item {
118        MenuItem::Action { when, .. } => {
119            match when.as_deref() {
120                Some(condition) => context.get(condition),
121                None => true, // No condition means always enabled
122            }
123        }
124        _ => true,
125    }
126}
127
128fn is_checkbox_checked(checkbox: &Option<String>, context: &MenuContext) -> bool {
129    match checkbox.as_deref() {
130        Some(name) => context.get(name),
131        None => false,
132    }
133}
134
135/// Menu bar state (tracks which menu is open and which item is highlighted)
136///
137/// TODO: The menu system design could be improved to handle dynamic items better.
138/// Currently, `themes_dir` is stored here to support `DynamicSubmenu` expansion.
139/// A cleaner approach might be:
140/// 1. Accept a pure data value representing the entire expanded menu system
141/// 2. Have the "dynamic" item expansion done externally by the caller
142/// 3. Allow updating the menu data by re-setting with a new expanded value
143///
144/// This would decouple menu rendering/navigation from theme loading concerns.
145#[derive(Debug, Clone)]
146pub struct MenuState {
147    /// Index of the currently open menu (None if menu bar is closed)
148    pub active_menu: Option<usize>,
149    /// Index of the highlighted item within the active menu or current submenu
150    pub highlighted_item: Option<usize>,
151    /// Path of indices into nested submenus (empty = at top level menu)
152    /// Each element is the index of the submenu item that was opened
153    pub submenu_path: Vec<usize>,
154    /// Runtime menu additions from plugins
155    pub plugin_menus: Vec<Menu>,
156    /// Context containing named boolean states for conditions and checkboxes
157    pub context: MenuContext,
158    /// Path to the themes directory for expanding DynamicSubmenu items.
159    /// See TODO above for potential design improvement.
160    pub themes_dir: std::path::PathBuf,
161}
162
163impl MenuState {
164    pub fn new(themes_dir: std::path::PathBuf) -> Self {
165        Self {
166            active_menu: None,
167            highlighted_item: None,
168            submenu_path: Vec::new(),
169            plugin_menus: Vec::new(),
170            context: MenuContext::default(),
171            themes_dir,
172        }
173    }
174
175    /// Create a MenuState for testing with an empty themes directory.
176    #[cfg(test)]
177    pub fn for_testing() -> Self {
178        Self::new(std::path::PathBuf::new())
179    }
180
181    /// Open a menu by index
182    pub fn open_menu(&mut self, index: usize) {
183        self.active_menu = Some(index);
184        self.highlighted_item = Some(0);
185        self.submenu_path.clear();
186    }
187
188    /// Close the currently open menu (and all submenus)
189    pub fn close_menu(&mut self) {
190        self.active_menu = None;
191        self.highlighted_item = None;
192        self.submenu_path.clear();
193    }
194
195    /// Navigate to the next menu (right) - only at top level
196    /// Skips menus that are hidden (where `when` condition evaluates to false)
197    pub fn next_menu(&mut self, menus: &[Menu]) {
198        let Some(active) = self.active_menu else {
199            return;
200        };
201        let total = menus.len();
202        if total == 0 {
203            return;
204        }
205
206        // Find the next visible menu, wrapping around
207        for i in 1..=total {
208            let next_idx = (active + i) % total;
209            if self.is_menu_visible(&menus[next_idx]) {
210                self.active_menu = Some(next_idx);
211                self.highlighted_item = Some(0);
212                self.submenu_path.clear();
213                return;
214            }
215        }
216        // No visible menu found, stay on current
217    }
218
219    /// Navigate to the previous menu (left) - only at top level
220    /// Skips menus that are hidden (where `when` condition evaluates to false)
221    pub fn prev_menu(&mut self, menus: &[Menu]) {
222        let Some(active) = self.active_menu else {
223            return;
224        };
225        let total = menus.len();
226        if total == 0 {
227            return;
228        }
229
230        // Find the previous visible menu, wrapping around
231        for i in 1..=total {
232            let prev_idx = (active + total - i) % total;
233            if self.is_menu_visible(&menus[prev_idx]) {
234                self.active_menu = Some(prev_idx);
235                self.highlighted_item = Some(0);
236                self.submenu_path.clear();
237                return;
238            }
239        }
240        // No visible menu found, stay on current
241    }
242
243    /// Check if a menu is visible based on its `when` condition
244    fn is_menu_visible(&self, menu: &Menu) -> bool {
245        match &menu.when {
246            Some(condition) => self.context.get(condition),
247            None => true, // No condition = always visible
248        }
249    }
250
251    /// Check if we're currently in a submenu
252    pub fn in_submenu(&self) -> bool {
253        !self.submenu_path.is_empty()
254    }
255
256    /// Get the current submenu depth (0 = top level menu)
257    pub fn submenu_depth(&self) -> usize {
258        self.submenu_path.len()
259    }
260
261    /// Open a submenu at the current highlighted item
262    /// Returns true if a submenu was opened, false if the item wasn't a submenu
263    pub fn open_submenu(&mut self, menus: &[Menu]) -> bool {
264        let Some(active_idx) = self.active_menu else {
265            return false;
266        };
267        let Some(highlighted) = self.highlighted_item else {
268            return false;
269        };
270
271        // Get the current menu items
272        let Some(menu) = menus.get(active_idx) else {
273            return false;
274        };
275        let Some(items) = self.get_current_items_cloned(menu) else {
276            return false;
277        };
278
279        // Check if highlighted item is a submenu (including DynamicSubmenu which was expanded)
280        if let Some(item) = items.get(highlighted) {
281            match item {
282                MenuItem::Submenu {
283                    items: submenu_items,
284                    ..
285                } if !submenu_items.is_empty() => {
286                    self.submenu_path.push(highlighted);
287                    self.highlighted_item = Some(0);
288                    return true;
289                }
290                MenuItem::DynamicSubmenu { source, .. } => {
291                    // Generate items to check if non-empty
292                    let generated = generate_dynamic_items(source, &self.themes_dir);
293                    if !generated.is_empty() {
294                        self.submenu_path.push(highlighted);
295                        self.highlighted_item = Some(0);
296                        return true;
297                    }
298                }
299                _ => {}
300            }
301        }
302        false
303    }
304
305    /// Close the current submenu and go back to parent
306    /// Returns true if a submenu was closed, false if already at top level
307    pub fn close_submenu(&mut self) -> bool {
308        if let Some(parent_idx) = self.submenu_path.pop() {
309            self.highlighted_item = Some(parent_idx);
310            true
311        } else {
312            false
313        }
314    }
315
316    /// Get the menu items at the current submenu level
317    pub fn get_current_items<'a>(
318        &self,
319        menus: &'a [Menu],
320        active_idx: usize,
321    ) -> Option<&'a [MenuItem]> {
322        let menu = menus.get(active_idx)?;
323        let mut items: &[MenuItem] = &menu.items;
324
325        for &idx in &self.submenu_path {
326            match items.get(idx)? {
327                MenuItem::Submenu {
328                    items: submenu_items,
329                    ..
330                } => {
331                    items = submenu_items;
332                }
333                _ => return None,
334            }
335        }
336
337        Some(items)
338    }
339
340    /// Get owned vec of current items (for use when Menu is cloned)
341    /// DynamicSubmenus are expanded to regular Submenus
342    pub fn get_current_items_cloned(&self, menu: &Menu) -> Option<Vec<MenuItem>> {
343        // Expand all items (handles DynamicSubmenu -> Submenu)
344        let mut items: Vec<MenuItem> = menu
345            .items
346            .iter()
347            .map(|i| i.expand_dynamic(&self.themes_dir))
348            .collect();
349
350        for &idx in &self.submenu_path {
351            match items.get(idx)?.expand_dynamic(&self.themes_dir) {
352                MenuItem::Submenu {
353                    items: submenu_items,
354                    ..
355                } => {
356                    items = submenu_items;
357                }
358                _ => return None,
359            }
360        }
361
362        Some(items)
363    }
364
365    /// 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    #[allow(clippy::too_many_arguments)]
495    pub fn render(
496        frame: &mut Frame,
497        area: Rect,
498        menu_config: &MenuConfig,
499        menu_state: &MenuState,
500        keybindings: &crate::input::keybindings::KeybindingResolver,
501        theme: &Theme,
502        hover_target: Option<&crate::app::HoverTarget>,
503        mnemonics_enabled: bool,
504    ) -> MenuLayout {
505        let mut layout = MenuLayout::new(area);
506        // Combine config menus with plugin menus, expanding any DynamicSubmenus
507        let all_menus: Vec<Menu> = menu_config
508            .menus
509            .iter()
510            .chain(menu_state.plugin_menus.iter())
511            .cloned()
512            .map(|mut menu| {
513                menu.expand_dynamic_items(&menu_state.themes_dir);
514                menu
515            })
516            .collect();
517
518        // Track which menus are visible (based on their `when` condition)
519        let menu_visible: Vec<bool> = all_menus
520            .iter()
521            .map(|menu| match &menu.when {
522                Some(condition) => menu_state.context.get(condition),
523                None => true, // No condition = always visible
524            })
525            .collect();
526
527        // Build spans for each menu label and track their areas
528        let mut spans = Vec::new();
529        let mut current_x = area.x;
530
531        for (idx, menu) in all_menus.iter().enumerate() {
532            // Skip hidden menus
533            if !menu_visible[idx] {
534                continue;
535            }
536
537            let is_active = menu_state.active_menu == Some(idx);
538            let is_hovered =
539                matches!(hover_target, Some(crate::app::HoverTarget::MenuBarItem(i)) if *i == idx);
540
541            let base_style = if is_active {
542                Style::default()
543                    .fg(theme.menu_active_fg)
544                    .bg(theme.menu_active_bg)
545                    .add_modifier(Modifier::BOLD)
546            } else if is_hovered {
547                Style::default()
548                    .fg(theme.menu_hover_fg)
549                    .bg(theme.menu_hover_bg)
550            } else {
551                Style::default().fg(theme.menu_fg).bg(theme.menu_bg)
552            };
553
554            // Calculate label width: " Label " = 1 + label_width + 1
555            let label_width = str_width(&menu.label) as u16 + 2;
556
557            // Track the menu label area for hit testing
558            layout
559                .menu_areas
560                .push((idx, Rect::new(current_x, area.y, label_width, 1)));
561
562            // Check for mnemonic character (Alt+letter keybinding)
563            let mnemonic = if mnemonics_enabled {
564                keybindings.find_menu_mnemonic(&menu.label)
565            } else {
566                None
567            };
568
569            // Build the label with underlined mnemonic
570            spans.push(Span::styled(" ", base_style));
571
572            if let Some(mnemonic_char) = mnemonic {
573                // Find the first occurrence of the mnemonic character in the label
574                let mut found = false;
575                for c in menu.label.chars() {
576                    if !found && c.to_ascii_lowercase() == mnemonic_char {
577                        // Underline this character
578                        spans.push(Span::styled(
579                            c.to_string(),
580                            base_style.add_modifier(Modifier::UNDERLINED),
581                        ));
582                        found = true;
583                    } else {
584                        spans.push(Span::styled(c.to_string(), base_style));
585                    }
586                }
587            } else {
588                // No mnemonic, just render the label normally
589                spans.push(Span::styled(menu.label.clone(), base_style));
590            }
591
592            spans.push(Span::styled(" ", base_style));
593            spans.push(Span::raw(" "));
594
595            // Move to next position: label_width + 1 for trailing space
596            current_x += label_width + 1;
597        }
598
599        let line = Line::from(spans);
600        let paragraph = Paragraph::new(line).style(Style::default().bg(theme.menu_bg));
601        frame.render_widget(paragraph, area);
602
603        // Render dropdown if a menu is active
604        if let Some(active_idx) = menu_state.active_menu {
605            if let Some(menu) = all_menus.get(active_idx) {
606                Self::render_dropdown_chain(
607                    frame,
608                    area,
609                    menu,
610                    menu_state,
611                    active_idx,
612                    &all_menus,
613                    keybindings,
614                    theme,
615                    hover_target,
616                    &mut layout,
617                );
618            }
619        }
620
621        layout
622    }
623
624    /// Render a dropdown menu and all its open submenus
625    #[allow(clippy::too_many_arguments)]
626    fn render_dropdown_chain(
627        frame: &mut Frame,
628        menu_bar_area: Rect,
629        menu: &Menu,
630        menu_state: &MenuState,
631        menu_index: usize,
632        all_menus: &[Menu],
633        keybindings: &crate::input::keybindings::KeybindingResolver,
634        theme: &Theme,
635        hover_target: Option<&crate::app::HoverTarget>,
636        layout: &mut MenuLayout,
637    ) {
638        // Calculate the x position of the top-level dropdown based on menu index
639        // Skip hidden menus (those with `when` conditions that evaluate to false)
640        let mut x_offset = 0usize;
641        for (idx, m) in all_menus.iter().enumerate() {
642            if idx == menu_index {
643                break;
644            }
645            // Only count visible menus
646            let is_visible = match &m.when {
647                Some(condition) => menu_state.context.get(condition),
648                None => true,
649            };
650            if is_visible {
651                x_offset += str_width(&m.label) + 3; // label + spaces
652            }
653        }
654
655        let terminal_width = frame.area().width;
656        let terminal_height = frame.area().height;
657
658        // Track dropdown positions for rendering submenus
659        let mut current_items: &[MenuItem] = &menu.items;
660        let mut current_x = menu_bar_area.x.saturating_add(x_offset as u16);
661        let mut current_y = menu_bar_area.y.saturating_add(1);
662
663        // Render the main dropdown and collect submenu rendering info
664        // We'll render depth 0, then 1, etc.
665        for depth in 0..=menu_state.submenu_path.len() {
666            let is_deepest = depth == menu_state.submenu_path.len();
667            let highlighted_item = if is_deepest {
668                menu_state.highlighted_item
669            } else {
670                Some(menu_state.submenu_path[depth])
671            };
672
673            // Render this dropdown level
674            let dropdown_rect = Self::render_dropdown_level(
675                frame,
676                current_items,
677                highlighted_item,
678                current_x,
679                current_y,
680                terminal_width,
681                terminal_height,
682                depth,
683                &menu_state.submenu_path,
684                menu_index,
685                keybindings,
686                theme,
687                hover_target,
688                &menu_state.context,
689                layout,
690            );
691
692            // If not at the deepest level, navigate into the submenu for next iteration
693            if !is_deepest {
694                let submenu_idx = menu_state.submenu_path[depth];
695                // Handle both Submenu and DynamicSubmenu
696                let submenu_items = match current_items.get(submenu_idx) {
697                    Some(MenuItem::Submenu { items, .. }) => Some(items.as_slice()),
698                    Some(MenuItem::DynamicSubmenu { .. }) => {
699                        // DynamicSubmenu items will be generated and stored temporarily
700                        // This case shouldn't happen in normal flow since we expand before entering
701                        None
702                    }
703                    _ => None,
704                };
705                if let Some(items) = submenu_items {
706                    current_items = items;
707                    // Position submenu to the right of parent, aligned with the highlighted item
708                    current_x = dropdown_rect
709                        .x
710                        .saturating_add(dropdown_rect.width.saturating_sub(1));
711                    current_y = dropdown_rect.y.saturating_add(submenu_idx as u16 + 1); // +1 for border
712
713                    // Adjust if submenu would go off screen to the right - flip to left side
714                    let next_width = Self::calculate_dropdown_width(items);
715                    if current_x.saturating_add(next_width as u16) > terminal_width {
716                        current_x = dropdown_rect
717                            .x
718                            .saturating_sub(next_width as u16)
719                            .saturating_add(1);
720                    }
721                } else {
722                    break;
723                }
724            }
725        }
726    }
727
728    /// Calculate the width needed for a dropdown containing the given items
729    fn calculate_dropdown_width(items: &[MenuItem]) -> usize {
730        items
731            .iter()
732            .map(|item| match item {
733                MenuItem::Action { label, .. } => str_width(label) + 20,
734                MenuItem::Submenu { label, .. } => str_width(label) + 20,
735                MenuItem::DynamicSubmenu { label, .. } => str_width(label) + 20,
736                MenuItem::Separator { .. } => 20,
737                MenuItem::Label { info } => str_width(info) + 4,
738            })
739            .max()
740            .unwrap_or(20)
741            .min(40)
742    }
743
744    /// Render a single dropdown level and return its bounding Rect
745    #[allow(clippy::too_many_arguments)]
746    fn render_dropdown_level(
747        frame: &mut Frame,
748        items: &[MenuItem],
749        highlighted_item: Option<usize>,
750        x: u16,
751        y: u16,
752        terminal_width: u16,
753        terminal_height: u16,
754        depth: usize,
755        submenu_path: &[usize],
756        menu_index: usize,
757        keybindings: &crate::input::keybindings::KeybindingResolver,
758        theme: &Theme,
759        hover_target: Option<&crate::app::HoverTarget>,
760        context: &MenuContext,
761        layout: &mut MenuLayout,
762    ) -> Rect {
763        let max_width = Self::calculate_dropdown_width(items);
764        let dropdown_height = items.len() + 2; // +2 for borders
765
766        let desired_width = max_width as u16;
767        let desired_height = dropdown_height as u16;
768
769        // Bounds check: ensure dropdown fits within the visible area
770        let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
771            terminal_width.saturating_sub(desired_width)
772        } else {
773            x
774        };
775
776        let available_height = terminal_height.saturating_sub(y);
777        let height = desired_height.min(available_height);
778
779        let available_width = terminal_width.saturating_sub(adjusted_x);
780        let width = desired_width.min(available_width);
781
782        // Only render if we have at least minimal space
783        if width < 10 || height < 3 {
784            return Rect {
785                x: adjusted_x,
786                y,
787                width,
788                height,
789            };
790        }
791
792        let dropdown_area = Rect {
793            x: adjusted_x,
794            y,
795            width,
796            height,
797        };
798
799        // Build dropdown content
800        let mut lines = Vec::new();
801        let max_items = (height.saturating_sub(2)) as usize;
802        let items_to_show = items.len().min(max_items);
803        let content_width = (width as usize).saturating_sub(2);
804
805        for (idx, item) in items.iter().enumerate().take(items_to_show) {
806            let is_highlighted = highlighted_item == Some(idx);
807            // Check if this item is in the submenu path (has an open child submenu)
808            let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
809
810            // For hover target matching at submenu levels
811            let is_hovered = if depth == 0 {
812                matches!(
813                    hover_target,
814                    Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
815                )
816            } else {
817                matches!(
818                    hover_target,
819                    Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
820                )
821            };
822            let enabled = is_menu_item_enabled(item, context);
823
824            // Track item area for hit testing
825            // Item position: inside border (x+1, y+1+idx), full content width
826            let item_area = Rect::new(adjusted_x + 1, y + 1 + idx as u16, content_width as u16, 1);
827            if depth == 0 {
828                layout.item_areas.push((idx, item_area));
829            } else {
830                layout.submenu_areas.push((depth, idx, item_area));
831            }
832
833            let line = match item {
834                MenuItem::Action {
835                    label,
836                    action,
837                    checkbox,
838                    ..
839                } => {
840                    let style = if !enabled {
841                        Style::default()
842                            .fg(theme.menu_disabled_fg)
843                            .bg(theme.menu_disabled_bg)
844                    } else if is_highlighted {
845                        Style::default()
846                            .fg(theme.menu_highlight_fg)
847                            .bg(theme.menu_highlight_bg)
848                    } else if is_hovered {
849                        Style::default()
850                            .fg(theme.menu_hover_fg)
851                            .bg(theme.menu_hover_bg)
852                    } else {
853                        Style::default()
854                            .fg(theme.menu_dropdown_fg)
855                            .bg(theme.menu_dropdown_bg)
856                    };
857
858                    let keybinding = keybindings
859                        .find_keybinding_for_action(
860                            action,
861                            crate::input::keybindings::KeyContext::Normal,
862                        )
863                        .unwrap_or_default();
864
865                    let checkbox_icon = if checkbox.is_some() {
866                        if is_checkbox_checked(checkbox, context) {
867                            "☑ "
868                        } else {
869                            "☐ "
870                        }
871                    } else {
872                        ""
873                    };
874
875                    let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
876                    let label_display_width = str_width(label);
877                    let keybinding_display_width = str_width(&keybinding);
878
879                    let text = if keybinding.is_empty() {
880                        let padding_needed =
881                            content_width.saturating_sub(checkbox_width + label_display_width + 1);
882                        format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
883                    } else {
884                        let padding_needed = content_width.saturating_sub(
885                            checkbox_width + label_display_width + keybinding_display_width + 2,
886                        );
887                        format!(
888                            " {}{}{} {}",
889                            checkbox_icon,
890                            label,
891                            " ".repeat(padding_needed),
892                            keybinding
893                        )
894                    };
895
896                    Line::from(vec![Span::styled(text, style)])
897                }
898                MenuItem::Separator { .. } => {
899                    let separator = "─".repeat(content_width);
900                    Line::from(vec![Span::styled(
901                        format!(" {separator}"),
902                        Style::default()
903                            .fg(theme.menu_separator_fg)
904                            .bg(theme.menu_dropdown_bg),
905                    )])
906                }
907                MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
908                    // Highlight submenu items that have an open child
909                    let style = if is_highlighted || has_open_submenu {
910                        Style::default()
911                            .fg(theme.menu_highlight_fg)
912                            .bg(theme.menu_highlight_bg)
913                    } else if is_hovered {
914                        Style::default()
915                            .fg(theme.menu_hover_fg)
916                            .bg(theme.menu_hover_bg)
917                    } else {
918                        Style::default()
919                            .fg(theme.menu_dropdown_fg)
920                            .bg(theme.menu_dropdown_bg)
921                    };
922
923                    // Format: " Label        > " - label left-aligned, arrow near the end with padding
924                    // content_width minus: leading space (1) + space before arrow (1) + arrow (1) + trailing space (2)
925                    let label_display_width = str_width(label);
926                    let padding_needed = content_width.saturating_sub(label_display_width + 5);
927                    Line::from(vec![Span::styled(
928                        format!(" {}{} >  ", label, " ".repeat(padding_needed)),
929                        style,
930                    )])
931                }
932                MenuItem::Label { info } => {
933                    // Disabled info label - always shown in disabled style
934                    let style = Style::default()
935                        .fg(theme.menu_disabled_fg)
936                        .bg(theme.menu_dropdown_bg);
937                    let info_display_width = str_width(info);
938                    let padding_needed = content_width.saturating_sub(info_display_width);
939                    Line::from(vec![Span::styled(
940                        format!(" {}{}", info, " ".repeat(padding_needed)),
941                        style,
942                    )])
943                }
944            };
945
946            lines.push(line);
947        }
948
949        let block = Block::default()
950            .borders(Borders::ALL)
951            .border_style(Style::default().fg(theme.menu_border_fg))
952            .style(Style::reset().bg(theme.menu_dropdown_bg));
953
954        let paragraph = Paragraph::new(lines).block(block);
955        frame.render_widget(paragraph, dropdown_area);
956
957        dropdown_area
958    }
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964    use std::collections::HashMap;
965
966    fn create_test_menus() -> Vec<Menu> {
967        vec![
968            Menu {
969                id: None,
970                label: "File".to_string(),
971                items: vec![
972                    MenuItem::Action {
973                        label: "New".to_string(),
974                        action: "new_file".to_string(),
975                        args: HashMap::new(),
976                        when: None,
977                        checkbox: None,
978                    },
979                    MenuItem::Separator { separator: true },
980                    MenuItem::Action {
981                        label: "Save".to_string(),
982                        action: "save".to_string(),
983                        args: HashMap::new(),
984                        when: None,
985                        checkbox: None,
986                    },
987                    MenuItem::Action {
988                        label: "Quit".to_string(),
989                        action: "quit".to_string(),
990                        args: HashMap::new(),
991                        when: None,
992                        checkbox: None,
993                    },
994                ],
995                when: None,
996            },
997            Menu {
998                id: None,
999                label: "Edit".to_string(),
1000                items: vec![
1001                    MenuItem::Action {
1002                        label: "Undo".to_string(),
1003                        action: "undo".to_string(),
1004                        args: HashMap::new(),
1005                        when: None,
1006                        checkbox: None,
1007                    },
1008                    MenuItem::Action {
1009                        label: "Redo".to_string(),
1010                        action: "redo".to_string(),
1011                        args: HashMap::new(),
1012                        when: None,
1013                        checkbox: None,
1014                    },
1015                ],
1016                when: None,
1017            },
1018            Menu {
1019                id: None,
1020                label: "View".to_string(),
1021                items: vec![MenuItem::Action {
1022                    label: "Toggle Explorer".to_string(),
1023                    action: "toggle_file_explorer".to_string(),
1024                    args: HashMap::new(),
1025                    when: None,
1026                    checkbox: None,
1027                }],
1028                when: None,
1029            },
1030        ]
1031    }
1032
1033    #[test]
1034    fn test_menu_state_default() {
1035        let state = MenuState::for_testing();
1036        assert_eq!(state.active_menu, None);
1037        assert_eq!(state.highlighted_item, None);
1038        assert!(state.plugin_menus.is_empty());
1039    }
1040
1041    #[test]
1042    fn test_menu_state_open_menu() {
1043        let mut state = MenuState::for_testing();
1044        state.open_menu(2);
1045        assert_eq!(state.active_menu, Some(2));
1046        assert_eq!(state.highlighted_item, Some(0));
1047    }
1048
1049    #[test]
1050    fn test_menu_state_close_menu() {
1051        let mut state = MenuState::for_testing();
1052        state.open_menu(1);
1053        state.close_menu();
1054        assert_eq!(state.active_menu, None);
1055        assert_eq!(state.highlighted_item, None);
1056    }
1057
1058    #[test]
1059    fn test_menu_state_next_menu() {
1060        let mut state = MenuState::for_testing();
1061        let menus = create_test_menus();
1062        state.open_menu(0);
1063
1064        state.next_menu(&menus);
1065        assert_eq!(state.active_menu, Some(1));
1066
1067        state.next_menu(&menus);
1068        assert_eq!(state.active_menu, Some(2));
1069
1070        // Wrap around
1071        state.next_menu(&menus);
1072        assert_eq!(state.active_menu, Some(0));
1073    }
1074
1075    #[test]
1076    fn test_menu_state_prev_menu() {
1077        let mut state = MenuState::for_testing();
1078        let menus = create_test_menus();
1079        state.open_menu(0);
1080
1081        // Wrap around backwards
1082        state.prev_menu(&menus);
1083        assert_eq!(state.active_menu, Some(2));
1084
1085        state.prev_menu(&menus);
1086        assert_eq!(state.active_menu, Some(1));
1087
1088        state.prev_menu(&menus);
1089        assert_eq!(state.active_menu, Some(0));
1090    }
1091
1092    #[test]
1093    fn test_menu_state_next_item_skips_separator() {
1094        let mut state = MenuState::for_testing();
1095        let menus = create_test_menus();
1096        state.open_menu(0);
1097
1098        // highlighted_item starts at 0 (New)
1099        assert_eq!(state.highlighted_item, Some(0));
1100
1101        // Next should skip separator and go to Save (index 2)
1102        state.next_item(&menus[0]);
1103        assert_eq!(state.highlighted_item, Some(2));
1104
1105        // Next goes to Quit (index 3)
1106        state.next_item(&menus[0]);
1107        assert_eq!(state.highlighted_item, Some(3));
1108
1109        // Wrap around to New (index 0)
1110        state.next_item(&menus[0]);
1111        assert_eq!(state.highlighted_item, Some(0));
1112    }
1113
1114    #[test]
1115    fn test_menu_state_prev_item_skips_separator() {
1116        let mut state = MenuState::for_testing();
1117        let menus = create_test_menus();
1118        state.open_menu(0);
1119        state.highlighted_item = Some(2); // Start at Save
1120
1121        // Prev should skip separator and go to New (index 0)
1122        state.prev_item(&menus[0]);
1123        assert_eq!(state.highlighted_item, Some(0));
1124
1125        // Wrap around backwards to Quit (index 3)
1126        state.prev_item(&menus[0]);
1127        assert_eq!(state.highlighted_item, Some(3));
1128    }
1129
1130    #[test]
1131    fn test_get_highlighted_action() {
1132        let mut state = MenuState::for_testing();
1133        let menus = create_test_menus();
1134        state.open_menu(0);
1135        state.highlighted_item = Some(2); // Save action
1136
1137        let action = state.get_highlighted_action(&menus);
1138        assert!(action.is_some());
1139        let (action_name, _args) = action.unwrap();
1140        assert_eq!(action_name, "save");
1141    }
1142
1143    #[test]
1144    fn test_menu_item_when_requires_selection() {
1145        let mut state = MenuState::for_testing();
1146        let select_menu = Menu {
1147            id: None,
1148            label: "Edit".to_string(),
1149            items: vec![MenuItem::Action {
1150                label: "Find in Selection".to_string(),
1151                action: "find_in_selection".to_string(),
1152                args: HashMap::new(),
1153                when: Some(context_keys::HAS_SELECTION.to_string()),
1154                checkbox: None,
1155            }],
1156            when: None,
1157        };
1158        state.open_menu(0);
1159        state.highlighted_item = Some(0);
1160
1161        // Without has_selection set, action should be disabled
1162        assert!(state
1163            .get_highlighted_action(std::slice::from_ref(&select_menu))
1164            .is_none());
1165
1166        // With has_selection set to true, action should be enabled
1167        state.context.set(context_keys::HAS_SELECTION, true);
1168        assert!(state.get_highlighted_action(&[select_menu]).is_some());
1169    }
1170
1171    #[test]
1172    fn test_get_highlighted_action_none_when_closed() {
1173        let state = MenuState::for_testing();
1174        let menus = create_test_menus();
1175        assert!(state.get_highlighted_action(&menus).is_none());
1176    }
1177
1178    #[test]
1179    fn test_get_highlighted_action_none_for_separator() {
1180        let mut state = MenuState::for_testing();
1181        let menus = create_test_menus();
1182        state.open_menu(0);
1183        state.highlighted_item = Some(1); // Separator
1184
1185        assert!(state.get_highlighted_action(&menus).is_none());
1186    }
1187
1188    #[test]
1189    fn test_menu_layout_menu_at() {
1190        // Build a layout manually simulating what render would produce
1191        let bar_area = Rect::new(0, 0, 80, 1);
1192        let mut layout = MenuLayout::new(bar_area);
1193
1194        // " File " at x=0, width=6
1195        layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1196        // " Edit " at x=7, width=6
1197        layout.menu_areas.push((1, Rect::new(7, 0, 6, 1)));
1198        // " View " at x=14, width=6
1199        layout.menu_areas.push((2, Rect::new(14, 0, 6, 1)));
1200
1201        // File: x=0-5, y=0
1202        assert_eq!(layout.menu_at(0, 0), Some(0));
1203        assert_eq!(layout.menu_at(3, 0), Some(0));
1204        assert_eq!(layout.menu_at(5, 0), Some(0));
1205
1206        // Space between: x=6
1207        assert_eq!(layout.menu_at(6, 0), None);
1208
1209        // Edit: x=7-12
1210        assert_eq!(layout.menu_at(7, 0), Some(1));
1211        assert_eq!(layout.menu_at(10, 0), Some(1));
1212        assert_eq!(layout.menu_at(12, 0), Some(1));
1213
1214        // Space between: x=13
1215        assert_eq!(layout.menu_at(13, 0), None);
1216
1217        // View: x=14-19
1218        assert_eq!(layout.menu_at(14, 0), Some(2));
1219        assert_eq!(layout.menu_at(17, 0), Some(2));
1220        assert_eq!(layout.menu_at(19, 0), Some(2));
1221
1222        // After View
1223        assert_eq!(layout.menu_at(20, 0), None);
1224        assert_eq!(layout.menu_at(100, 0), None);
1225
1226        // Wrong row returns None
1227        assert_eq!(layout.menu_at(3, 1), None);
1228    }
1229
1230    #[test]
1231    fn test_menu_layout_item_at() {
1232        // Build a layout manually simulating what render would produce
1233        let bar_area = Rect::new(0, 0, 80, 1);
1234        let mut layout = MenuLayout::new(bar_area);
1235
1236        // Dropdown items for File menu at x=1 (inside border), y=2,3,4,5 (inside border)
1237        // Item 0 (New) at y=2
1238        layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1239        // Item 1 (Separator) at y=3
1240        layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1241        // Item 2 (Save) at y=4
1242        layout.item_areas.push((2, Rect::new(1, 4, 20, 1)));
1243        // Item 3 (Quit) at y=5
1244        layout.item_areas.push((3, Rect::new(1, 5, 20, 1)));
1245
1246        // Menu bar row returns None
1247        assert_eq!(layout.item_at(5, 0), None);
1248        // Border row returns None
1249        assert_eq!(layout.item_at(5, 1), None);
1250
1251        // y=2: New (index 0)
1252        assert_eq!(layout.item_at(5, 2), Some(0));
1253
1254        // y=3: Separator (index 1) - note: layout includes all items, filtering happens elsewhere
1255        assert_eq!(layout.item_at(5, 3), Some(1));
1256
1257        // y=4: Save (index 2)
1258        assert_eq!(layout.item_at(5, 4), Some(2));
1259
1260        // y=5: Quit (index 3)
1261        assert_eq!(layout.item_at(5, 5), Some(3));
1262
1263        // Beyond items
1264        assert_eq!(layout.item_at(5, 6), None);
1265        assert_eq!(layout.item_at(5, 100), None);
1266    }
1267
1268    #[test]
1269    fn test_menu_layout_hit_test() {
1270        let bar_area = Rect::new(0, 0, 80, 1);
1271        let mut layout = MenuLayout::new(bar_area);
1272
1273        // Menu labels
1274        layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1275
1276        // Dropdown items
1277        layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1278        layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1279
1280        // Submenu items at depth 1
1281        layout.submenu_areas.push((1, 0, Rect::new(22, 3, 15, 1)));
1282
1283        // Hit test menu label
1284        assert_eq!(layout.hit_test(3, 0), Some(MenuHit::MenuLabel(0)));
1285
1286        // Hit test dropdown item
1287        assert_eq!(layout.hit_test(5, 2), Some(MenuHit::DropdownItem(0)));
1288
1289        // Hit test submenu item (should have priority)
1290        assert_eq!(
1291            layout.hit_test(25, 3),
1292            Some(MenuHit::SubmenuItem { depth: 1, index: 0 })
1293        );
1294
1295        // Hit test bar background (inside bar area but not on menu)
1296        assert_eq!(layout.hit_test(50, 0), Some(MenuHit::BarBackground));
1297
1298        // Hit test outside everything
1299        assert_eq!(layout.hit_test(50, 10), None);
1300    }
1301
1302    #[test]
1303    fn test_menu_config_json_parsing() {
1304        let json = r#"{
1305            "menus": [
1306                {
1307                    "label": "File",
1308                    "items": [
1309                        { "label": "New", "action": "new_file" },
1310                        { "separator": true },
1311                        { "label": "Save", "action": "save" }
1312                    ]
1313                }
1314            ]
1315        }"#;
1316
1317        let config: MenuConfig = serde_json::from_str(json).unwrap();
1318        assert_eq!(config.menus.len(), 1);
1319        assert_eq!(config.menus[0].label, "File");
1320        assert_eq!(config.menus[0].items.len(), 3);
1321
1322        match &config.menus[0].items[0] {
1323            MenuItem::Action { label, action, .. } => {
1324                assert_eq!(label, "New");
1325                assert_eq!(action, "new_file");
1326            }
1327            _ => panic!("Expected Action"),
1328        }
1329
1330        assert!(matches!(
1331            config.menus[0].items[1],
1332            MenuItem::Separator { .. }
1333        ));
1334
1335        match &config.menus[0].items[2] {
1336            MenuItem::Action { label, action, .. } => {
1337                assert_eq!(label, "Save");
1338                assert_eq!(action, "save");
1339            }
1340            _ => panic!("Expected Action"),
1341        }
1342    }
1343
1344    #[test]
1345    fn test_menu_item_with_args() {
1346        let json = r#"{
1347            "label": "Go to Line",
1348            "action": "goto_line",
1349            "args": { "line": 42 }
1350        }"#;
1351
1352        let item: MenuItem = serde_json::from_str(json).unwrap();
1353        match item {
1354            MenuItem::Action {
1355                label,
1356                action,
1357                args,
1358                ..
1359            } => {
1360                assert_eq!(label, "Go to Line");
1361                assert_eq!(action, "goto_line");
1362                assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1363            }
1364            _ => panic!("Expected Action with args"),
1365        }
1366    }
1367
1368    #[test]
1369    fn test_empty_menu_config() {
1370        let json = r#"{ "menus": [] }"#;
1371        let config: MenuConfig = serde_json::from_str(json).unwrap();
1372        assert!(config.menus.is_empty());
1373    }
1374
1375    #[test]
1376    fn test_menu_mnemonic_lookup() {
1377        use crate::config::Config;
1378        use crate::input::keybindings::KeybindingResolver;
1379
1380        let config = Config::default();
1381        let resolver = KeybindingResolver::new(&config);
1382
1383        // Check that default Alt+letter bindings are configured
1384        assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1385        assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1386        assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1387        assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1388        assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1389        assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1390
1391        // Case-insensitive matching
1392        assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1393        assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1394
1395        // Non-existent menu should return None
1396        assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1397    }
1398
1399    fn create_menu_with_submenus() -> Vec<Menu> {
1400        vec![Menu {
1401            id: None,
1402            label: "View".to_string(),
1403            items: vec![
1404                MenuItem::Action {
1405                    label: "Toggle Explorer".to_string(),
1406                    action: "toggle_file_explorer".to_string(),
1407                    args: HashMap::new(),
1408                    when: None,
1409                    checkbox: None,
1410                },
1411                MenuItem::Submenu {
1412                    label: "Terminal".to_string(),
1413                    items: vec![
1414                        MenuItem::Action {
1415                            label: "Open Terminal".to_string(),
1416                            action: "open_terminal".to_string(),
1417                            args: HashMap::new(),
1418                            when: None,
1419                            checkbox: None,
1420                        },
1421                        MenuItem::Action {
1422                            label: "Close Terminal".to_string(),
1423                            action: "close_terminal".to_string(),
1424                            args: HashMap::new(),
1425                            when: None,
1426                            checkbox: None,
1427                        },
1428                        MenuItem::Submenu {
1429                            label: "Terminal Settings".to_string(),
1430                            items: vec![MenuItem::Action {
1431                                label: "Font Size".to_string(),
1432                                action: "terminal_font_size".to_string(),
1433                                args: HashMap::new(),
1434                                when: None,
1435                                checkbox: None,
1436                            }],
1437                        },
1438                    ],
1439                },
1440                MenuItem::Separator { separator: true },
1441                MenuItem::Action {
1442                    label: "Zoom In".to_string(),
1443                    action: "zoom_in".to_string(),
1444                    args: HashMap::new(),
1445                    when: None,
1446                    checkbox: None,
1447                },
1448            ],
1449            when: None,
1450        }]
1451    }
1452
1453    #[test]
1454    fn test_submenu_open_and_close() {
1455        let mut state = MenuState::for_testing();
1456        let menus = create_menu_with_submenus();
1457
1458        state.open_menu(0);
1459        assert!(state.submenu_path.is_empty());
1460        assert!(!state.in_submenu());
1461
1462        // Move to Terminal submenu item (index 1)
1463        state.highlighted_item = Some(1);
1464
1465        // Open the submenu
1466        assert!(state.open_submenu(&menus));
1467        assert_eq!(state.submenu_path, vec![1]);
1468        assert!(state.in_submenu());
1469        assert_eq!(state.submenu_depth(), 1);
1470        assert_eq!(state.highlighted_item, Some(0)); // Reset to first item
1471
1472        // Close the submenu
1473        assert!(state.close_submenu());
1474        assert!(state.submenu_path.is_empty());
1475        assert!(!state.in_submenu());
1476        assert_eq!(state.highlighted_item, Some(1)); // Restored to parent item
1477    }
1478
1479    #[test]
1480    fn test_nested_submenu() {
1481        let mut state = MenuState::for_testing();
1482        let menus = create_menu_with_submenus();
1483
1484        state.open_menu(0);
1485        state.highlighted_item = Some(1); // Terminal submenu
1486
1487        // Open first level submenu
1488        assert!(state.open_submenu(&menus));
1489        assert_eq!(state.submenu_depth(), 1);
1490
1491        // Move to Terminal Settings (nested submenu at index 2)
1492        state.highlighted_item = Some(2);
1493
1494        // Open second level submenu
1495        assert!(state.open_submenu(&menus));
1496        assert_eq!(state.submenu_path, vec![1, 2]);
1497        assert_eq!(state.submenu_depth(), 2);
1498        assert_eq!(state.highlighted_item, Some(0));
1499
1500        // Close back to first level
1501        assert!(state.close_submenu());
1502        assert_eq!(state.submenu_path, vec![1]);
1503        assert_eq!(state.highlighted_item, Some(2));
1504
1505        // Close back to main menu
1506        assert!(state.close_submenu());
1507        assert!(state.submenu_path.is_empty());
1508        assert_eq!(state.highlighted_item, Some(1));
1509
1510        // Can't close further
1511        assert!(!state.close_submenu());
1512    }
1513
1514    #[test]
1515    fn test_get_highlighted_action_in_submenu() {
1516        let mut state = MenuState::for_testing();
1517        let menus = create_menu_with_submenus();
1518
1519        state.open_menu(0);
1520        state.highlighted_item = Some(1); // Terminal submenu
1521
1522        // On a submenu item, get_highlighted_action should return None
1523        assert!(state.get_highlighted_action(&menus).is_none());
1524
1525        // Open the submenu
1526        state.open_submenu(&menus);
1527        // Now highlighted_item is 0 which is "Open Terminal"
1528        let action = state.get_highlighted_action(&menus);
1529        assert!(action.is_some());
1530        let (action_name, _) = action.unwrap();
1531        assert_eq!(action_name, "open_terminal");
1532
1533        // Navigate to second item
1534        state.highlighted_item = Some(1);
1535        let action = state.get_highlighted_action(&menus);
1536        assert!(action.is_some());
1537        let (action_name, _) = action.unwrap();
1538        assert_eq!(action_name, "close_terminal");
1539    }
1540
1541    #[test]
1542    fn test_get_current_items_at_different_depths() {
1543        let mut state = MenuState::for_testing();
1544        let menus = create_menu_with_submenus();
1545
1546        state.open_menu(0);
1547
1548        // At top level, should get main menu items
1549        let items = state.get_current_items(&menus, 0).unwrap();
1550        assert_eq!(items.len(), 4); // Action, Submenu, Separator, Action
1551
1552        // Open Terminal submenu
1553        state.highlighted_item = Some(1);
1554        state.open_submenu(&menus);
1555
1556        // Now should get Terminal submenu items
1557        let items = state.get_current_items(&menus, 0).unwrap();
1558        assert_eq!(items.len(), 3); // Open, Close, Settings submenu
1559
1560        // Open nested Terminal Settings submenu
1561        state.highlighted_item = Some(2);
1562        state.open_submenu(&menus);
1563
1564        // Now should get Terminal Settings submenu items
1565        let items = state.get_current_items(&menus, 0).unwrap();
1566        assert_eq!(items.len(), 1); // Font Size
1567    }
1568
1569    #[test]
1570    fn test_is_highlighted_submenu() {
1571        let mut state = MenuState::for_testing();
1572        let menus = create_menu_with_submenus();
1573
1574        state.open_menu(0);
1575        state.highlighted_item = Some(0); // Toggle Explorer (action)
1576        assert!(!state.is_highlighted_submenu(&menus));
1577
1578        state.highlighted_item = Some(1); // Terminal (submenu)
1579        assert!(state.is_highlighted_submenu(&menus));
1580
1581        state.highlighted_item = Some(2); // Separator
1582        assert!(!state.is_highlighted_submenu(&menus));
1583
1584        state.highlighted_item = Some(3); // Zoom In (action)
1585        assert!(!state.is_highlighted_submenu(&menus));
1586    }
1587
1588    #[test]
1589    fn test_open_menu_clears_submenu_path() {
1590        let mut state = MenuState::for_testing();
1591        let menus = create_menu_with_submenus();
1592
1593        state.open_menu(0);
1594        state.highlighted_item = Some(1);
1595        state.open_submenu(&menus);
1596        assert!(!state.submenu_path.is_empty());
1597
1598        // Opening a new menu should clear the submenu path
1599        state.open_menu(0);
1600        assert!(state.submenu_path.is_empty());
1601    }
1602
1603    #[test]
1604    fn test_next_prev_menu_clears_submenu_path() {
1605        let mut state = MenuState::for_testing();
1606        let menus = create_menu_with_submenus();
1607
1608        state.open_menu(0);
1609        state.highlighted_item = Some(1);
1610        state.open_submenu(&menus);
1611        assert!(!state.submenu_path.is_empty());
1612
1613        // next_menu should clear submenu path
1614        state.next_menu(&menus);
1615        assert!(state.submenu_path.is_empty());
1616
1617        // Re-open submenu
1618        state.open_menu(0);
1619        state.highlighted_item = Some(1);
1620        state.open_submenu(&menus);
1621
1622        // prev_menu should clear submenu path
1623        state.prev_menu(&menus);
1624        assert!(state.submenu_path.is_empty());
1625    }
1626
1627    #[test]
1628    fn test_navigation_in_submenu() {
1629        let mut state = MenuState::for_testing();
1630        let menus = create_menu_with_submenus();
1631
1632        state.open_menu(0);
1633        state.highlighted_item = Some(1);
1634        state.open_submenu(&menus);
1635
1636        // In Terminal submenu, start at index 0
1637        assert_eq!(state.highlighted_item, Some(0));
1638
1639        // Navigate down
1640        state.next_item(&menus[0]);
1641        assert_eq!(state.highlighted_item, Some(1));
1642
1643        // Navigate down again
1644        state.next_item(&menus[0]);
1645        assert_eq!(state.highlighted_item, Some(2));
1646
1647        // Navigate down wraps to start
1648        state.next_item(&menus[0]);
1649        assert_eq!(state.highlighted_item, Some(0));
1650
1651        // Navigate up wraps to end
1652        state.prev_item(&menus[0]);
1653        assert_eq!(state.highlighted_item, Some(2));
1654    }
1655
1656    /// Helper function to calculate dropdown x offset (mirrors the logic in render_dropdown_chain)
1657    fn calculate_dropdown_x_offset(
1658        all_menus: &[Menu],
1659        menu_index: usize,
1660        context: &MenuContext,
1661    ) -> usize {
1662        let mut x_offset = 0usize;
1663        for (idx, m) in all_menus.iter().enumerate() {
1664            if idx == menu_index {
1665                break;
1666            }
1667            // Only count visible menus
1668            let is_visible = match &m.when {
1669                Some(condition) => context.get(condition),
1670                None => true,
1671            };
1672            if is_visible {
1673                x_offset += str_width(&m.label) + 3; // label + spaces
1674            }
1675        }
1676        x_offset
1677    }
1678
1679    #[test]
1680    fn test_dropdown_position_skips_hidden_menus() {
1681        // Create menus: File (always visible), Explorer (conditional), Help (always visible)
1682        let menus = vec![
1683            Menu {
1684                id: None,
1685                label: "File".to_string(), // width 4, total 7 with padding
1686                items: vec![],
1687                when: None,
1688            },
1689            Menu {
1690                id: None,
1691                label: "Explorer".to_string(), // width 8, total 11 with padding
1692                items: vec![],
1693                when: Some("file_explorer_focused".to_string()),
1694            },
1695            Menu {
1696                id: None,
1697                label: "Help".to_string(), // width 4, total 7 with padding
1698                items: vec![],
1699                when: None,
1700            },
1701        ];
1702
1703        // When Explorer is hidden, Help dropdown should be at File's width only
1704        let context_hidden = MenuContext::new().with("file_explorer_focused", false);
1705        let x_help_hidden = calculate_dropdown_x_offset(&menus, 2, &context_hidden);
1706        // "File" = 4 chars + 3 spaces = 7
1707        assert_eq!(
1708            x_help_hidden, 7,
1709            "Help dropdown should be at x=7 when Explorer is hidden"
1710        );
1711
1712        // When Explorer is visible, Help dropdown should be at File + Explorer width
1713        let context_visible = MenuContext::new().with("file_explorer_focused", true);
1714        let x_help_visible = calculate_dropdown_x_offset(&menus, 2, &context_visible);
1715        // "File" = 4 chars + 3 spaces = 7, "Explorer" = 8 chars + 3 spaces = 11, total = 18
1716        assert_eq!(
1717            x_help_visible, 18,
1718            "Help dropdown should be at x=18 when Explorer is visible"
1719        );
1720    }
1721
1722    #[test]
1723    fn test_dropdown_position_with_multiple_hidden_menus() {
1724        let menus = vec![
1725            Menu {
1726                id: None,
1727                label: "A".to_string(), // width 1, total 4
1728                items: vec![],
1729                when: None,
1730            },
1731            Menu {
1732                id: None,
1733                label: "B".to_string(), // width 1, total 4
1734                items: vec![],
1735                when: Some("show_b".to_string()),
1736            },
1737            Menu {
1738                id: None,
1739                label: "C".to_string(), // width 1, total 4
1740                items: vec![],
1741                when: Some("show_c".to_string()),
1742            },
1743            Menu {
1744                id: None,
1745                label: "D".to_string(),
1746                items: vec![],
1747                when: None,
1748            },
1749        ];
1750
1751        // No conditional menus visible: D should be right after A
1752        let context_none = MenuContext::new();
1753        assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_none), 4);
1754
1755        // Only B visible: D should be after A + B
1756        let context_b = MenuContext::new().with("show_b", true);
1757        assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_b), 8);
1758
1759        // Both B and C visible: D should be after A + B + C
1760        let context_both = MenuContext::new().with("show_b", true).with("show_c", true);
1761        assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_both), 12);
1762    }
1763}