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