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                    // Align the submenu's first item with the parent item it was
712                    // opened from. Items render at `dropdown_rect.y + 1 + idx`
713                    // (the `+ 1` skips the top border), so the submenu's top
714                    // border sits one row above the parent item's row.
715                    current_y = dropdown_rect.y.saturating_add(submenu_idx as u16);
716
717                    // Adjust if submenu would go off screen to the right - flip to left side
718                    let next_width = Self::calculate_dropdown_width(items);
719                    if current_x.saturating_add(next_width as u16) > terminal_width {
720                        current_x = dropdown_rect
721                            .x
722                            .saturating_sub(next_width as u16)
723                            .saturating_add(1);
724                    }
725                } else {
726                    break;
727                }
728            }
729        }
730    }
731
732    /// Calculate the width needed for a dropdown containing the given items
733    fn calculate_dropdown_width(items: &[MenuItem]) -> usize {
734        items
735            .iter()
736            .map(|item| match item {
737                MenuItem::Action { label, .. } => str_width(label) + 20,
738                MenuItem::Submenu { label, .. } => str_width(label) + 20,
739                MenuItem::DynamicSubmenu { label, .. } => str_width(label) + 20,
740                MenuItem::Separator { .. } => 20,
741                MenuItem::Label { info } => str_width(info) + 4,
742            })
743            .max()
744            .unwrap_or(20)
745            .min(40)
746    }
747
748    /// Render a single dropdown level and return its bounding Rect
749    #[allow(clippy::too_many_arguments)]
750    fn render_dropdown_level(
751        frame: &mut Frame,
752        items: &[MenuItem],
753        highlighted_item: Option<usize>,
754        x: u16,
755        y: u16,
756        terminal_width: u16,
757        terminal_height: u16,
758        depth: usize,
759        submenu_path: &[usize],
760        menu_index: usize,
761        keybindings: &crate::input::keybindings::KeybindingResolver,
762        theme: &Theme,
763        hover_target: Option<&crate::app::HoverTarget>,
764        context: &MenuContext,
765        layout: &mut MenuLayout,
766    ) -> Rect {
767        let max_width = Self::calculate_dropdown_width(items);
768        let dropdown_height = items.len() + 2; // +2 for borders
769
770        let desired_width = max_width as u16;
771        let desired_height = dropdown_height as u16;
772
773        // Bounds check: ensure dropdown fits within the visible area
774        let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
775            terminal_width.saturating_sub(desired_width)
776        } else {
777            x
778        };
779
780        let available_height = terminal_height.saturating_sub(y);
781        let height = desired_height.min(available_height);
782
783        let available_width = terminal_width.saturating_sub(adjusted_x);
784        let width = desired_width.min(available_width);
785
786        // Only render if we have at least minimal space
787        if width < 10 || height < 3 {
788            return Rect {
789                x: adjusted_x,
790                y,
791                width,
792                height,
793            };
794        }
795
796        let dropdown_area = Rect {
797            x: adjusted_x,
798            y,
799            width,
800            height,
801        };
802
803        // Build dropdown content
804        let mut lines = Vec::new();
805        let max_items = (height.saturating_sub(2)) as usize;
806        let items_to_show = items.len().min(max_items);
807        let content_width = (width as usize).saturating_sub(2);
808
809        for (idx, item) in items.iter().enumerate().take(items_to_show) {
810            let is_highlighted = highlighted_item == Some(idx);
811            // Check if this item is in the submenu path (has an open child submenu)
812            let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
813
814            // For hover target matching at submenu levels
815            let is_hovered = if depth == 0 {
816                matches!(
817                    hover_target,
818                    Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
819                )
820            } else {
821                matches!(
822                    hover_target,
823                    Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
824                )
825            };
826            let enabled = is_menu_item_enabled(item, context);
827
828            // Track item area for hit testing
829            // Item position: inside border (x+1, y+1+idx), full content width
830            let item_area = Rect::new(adjusted_x + 1, y + 1 + idx as u16, content_width as u16, 1);
831            if depth == 0 {
832                layout.item_areas.push((idx, item_area));
833            } else {
834                layout.submenu_areas.push((depth, idx, item_area));
835            }
836
837            let line = match item {
838                MenuItem::Action {
839                    label,
840                    action,
841                    checkbox,
842                    ..
843                } => {
844                    let style = if !enabled {
845                        Style::default()
846                            .fg(theme.menu_disabled_fg)
847                            .bg(theme.menu_disabled_bg)
848                    } else if is_highlighted {
849                        Style::default()
850                            .fg(theme.menu_highlight_fg)
851                            .bg(theme.menu_highlight_bg)
852                    } else if is_hovered {
853                        Style::default()
854                            .fg(theme.menu_hover_fg)
855                            .bg(theme.menu_hover_bg)
856                    } else {
857                        Style::default()
858                            .fg(theme.menu_dropdown_fg)
859                            .bg(theme.menu_dropdown_bg)
860                    };
861
862                    let keybinding = keybindings
863                        .find_keybinding_for_action(
864                            action,
865                            crate::input::keybindings::KeyContext::Normal,
866                        )
867                        .unwrap_or_default();
868
869                    let checkbox_icon = if checkbox.is_some() {
870                        if is_checkbox_checked(checkbox, context) {
871                            "☑ "
872                        } else {
873                            "☐ "
874                        }
875                    } else {
876                        ""
877                    };
878
879                    let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
880                    let label_display_width = str_width(label);
881                    let keybinding_display_width = str_width(&keybinding);
882
883                    let text = if keybinding.is_empty() {
884                        let padding_needed =
885                            content_width.saturating_sub(checkbox_width + label_display_width + 1);
886                        format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
887                    } else {
888                        let padding_needed = content_width.saturating_sub(
889                            checkbox_width + label_display_width + keybinding_display_width + 2,
890                        );
891                        format!(
892                            " {}{}{} {}",
893                            checkbox_icon,
894                            label,
895                            " ".repeat(padding_needed),
896                            keybinding
897                        )
898                    };
899
900                    Line::from(vec![Span::styled(text, style)])
901                }
902                MenuItem::Separator { .. } => {
903                    let separator = "─".repeat(content_width);
904                    Line::from(vec![Span::styled(
905                        format!(" {separator}"),
906                        Style::default()
907                            .fg(theme.menu_separator_fg)
908                            .bg(theme.menu_dropdown_bg),
909                    )])
910                }
911                MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
912                    // Highlight submenu items that have an open child
913                    let style = if is_highlighted || has_open_submenu {
914                        Style::default()
915                            .fg(theme.menu_highlight_fg)
916                            .bg(theme.menu_highlight_bg)
917                    } else if is_hovered {
918                        Style::default()
919                            .fg(theme.menu_hover_fg)
920                            .bg(theme.menu_hover_bg)
921                    } else {
922                        Style::default()
923                            .fg(theme.menu_dropdown_fg)
924                            .bg(theme.menu_dropdown_bg)
925                    };
926
927                    // Format: " Label        > " - label left-aligned, arrow near the end with padding
928                    // content_width minus: leading space (1) + space before arrow (1) + arrow (1) + trailing space (2)
929                    let label_display_width = str_width(label);
930                    let padding_needed = content_width.saturating_sub(label_display_width + 5);
931                    Line::from(vec![Span::styled(
932                        format!(" {}{} >  ", label, " ".repeat(padding_needed)),
933                        style,
934                    )])
935                }
936                MenuItem::Label { info } => {
937                    // Disabled info label - always shown in disabled style
938                    let style = Style::default()
939                        .fg(theme.menu_disabled_fg)
940                        .bg(theme.menu_dropdown_bg);
941                    let info_display_width = str_width(info);
942                    let padding_needed = content_width.saturating_sub(info_display_width);
943                    Line::from(vec![Span::styled(
944                        format!(" {}{}", info, " ".repeat(padding_needed)),
945                        style,
946                    )])
947                }
948            };
949
950            lines.push(line);
951        }
952
953        let block = Block::default()
954            .borders(Borders::ALL)
955            .border_style(Style::default().fg(theme.menu_border_fg))
956            .style(Style::reset().bg(theme.menu_dropdown_bg));
957
958        let paragraph = Paragraph::new(lines).block(block);
959        frame.render_widget(paragraph, dropdown_area);
960
961        dropdown_area
962    }
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968    use std::collections::HashMap;
969
970    fn create_test_menus() -> Vec<Menu> {
971        vec![
972            Menu {
973                id: None,
974                label: "File".to_string(),
975                items: vec![
976                    MenuItem::Action {
977                        label: "New".to_string(),
978                        action: "new_file".to_string(),
979                        args: HashMap::new(),
980                        when: None,
981                        checkbox: None,
982                    },
983                    MenuItem::Separator { separator: true },
984                    MenuItem::Action {
985                        label: "Save".to_string(),
986                        action: "save".to_string(),
987                        args: HashMap::new(),
988                        when: None,
989                        checkbox: None,
990                    },
991                    MenuItem::Action {
992                        label: "Quit".to_string(),
993                        action: "quit".to_string(),
994                        args: HashMap::new(),
995                        when: None,
996                        checkbox: None,
997                    },
998                ],
999                when: None,
1000            },
1001            Menu {
1002                id: None,
1003                label: "Edit".to_string(),
1004                items: vec![
1005                    MenuItem::Action {
1006                        label: "Undo".to_string(),
1007                        action: "undo".to_string(),
1008                        args: HashMap::new(),
1009                        when: None,
1010                        checkbox: None,
1011                    },
1012                    MenuItem::Action {
1013                        label: "Redo".to_string(),
1014                        action: "redo".to_string(),
1015                        args: HashMap::new(),
1016                        when: None,
1017                        checkbox: None,
1018                    },
1019                ],
1020                when: None,
1021            },
1022            Menu {
1023                id: None,
1024                label: "View".to_string(),
1025                items: vec![MenuItem::Action {
1026                    label: "Toggle Explorer".to_string(),
1027                    action: "toggle_file_explorer".to_string(),
1028                    args: HashMap::new(),
1029                    when: None,
1030                    checkbox: None,
1031                }],
1032                when: None,
1033            },
1034        ]
1035    }
1036
1037    #[test]
1038    fn test_menu_state_default() {
1039        let state = MenuState::for_testing();
1040        assert_eq!(state.active_menu, None);
1041        assert_eq!(state.highlighted_item, None);
1042        assert!(state.plugin_menus.is_empty());
1043    }
1044
1045    #[test]
1046    fn test_menu_state_open_menu() {
1047        let mut state = MenuState::for_testing();
1048        state.open_menu(2);
1049        assert_eq!(state.active_menu, Some(2));
1050        assert_eq!(state.highlighted_item, Some(0));
1051    }
1052
1053    #[test]
1054    fn test_menu_state_close_menu() {
1055        let mut state = MenuState::for_testing();
1056        state.open_menu(1);
1057        state.close_menu();
1058        assert_eq!(state.active_menu, None);
1059        assert_eq!(state.highlighted_item, None);
1060    }
1061
1062    #[test]
1063    fn test_menu_state_next_menu() {
1064        let mut state = MenuState::for_testing();
1065        let menus = create_test_menus();
1066        state.open_menu(0);
1067
1068        state.next_menu(&menus);
1069        assert_eq!(state.active_menu, Some(1));
1070
1071        state.next_menu(&menus);
1072        assert_eq!(state.active_menu, Some(2));
1073
1074        // Wrap around
1075        state.next_menu(&menus);
1076        assert_eq!(state.active_menu, Some(0));
1077    }
1078
1079    #[test]
1080    fn test_menu_state_prev_menu() {
1081        let mut state = MenuState::for_testing();
1082        let menus = create_test_menus();
1083        state.open_menu(0);
1084
1085        // Wrap around backwards
1086        state.prev_menu(&menus);
1087        assert_eq!(state.active_menu, Some(2));
1088
1089        state.prev_menu(&menus);
1090        assert_eq!(state.active_menu, Some(1));
1091
1092        state.prev_menu(&menus);
1093        assert_eq!(state.active_menu, Some(0));
1094    }
1095
1096    #[test]
1097    fn test_menu_state_next_item_skips_separator() {
1098        let mut state = MenuState::for_testing();
1099        let menus = create_test_menus();
1100        state.open_menu(0);
1101
1102        // highlighted_item starts at 0 (New)
1103        assert_eq!(state.highlighted_item, Some(0));
1104
1105        // Next should skip separator and go to Save (index 2)
1106        state.next_item(&menus[0]);
1107        assert_eq!(state.highlighted_item, Some(2));
1108
1109        // Next goes to Quit (index 3)
1110        state.next_item(&menus[0]);
1111        assert_eq!(state.highlighted_item, Some(3));
1112
1113        // Wrap around to New (index 0)
1114        state.next_item(&menus[0]);
1115        assert_eq!(state.highlighted_item, Some(0));
1116    }
1117
1118    #[test]
1119    fn test_menu_state_prev_item_skips_separator() {
1120        let mut state = MenuState::for_testing();
1121        let menus = create_test_menus();
1122        state.open_menu(0);
1123        state.highlighted_item = Some(2); // Start at Save
1124
1125        // Prev should skip separator and go to New (index 0)
1126        state.prev_item(&menus[0]);
1127        assert_eq!(state.highlighted_item, Some(0));
1128
1129        // Wrap around backwards to Quit (index 3)
1130        state.prev_item(&menus[0]);
1131        assert_eq!(state.highlighted_item, Some(3));
1132    }
1133
1134    #[test]
1135    fn test_get_highlighted_action() {
1136        let mut state = MenuState::for_testing();
1137        let menus = create_test_menus();
1138        state.open_menu(0);
1139        state.highlighted_item = Some(2); // Save action
1140
1141        let action = state.get_highlighted_action(&menus);
1142        assert!(action.is_some());
1143        let (action_name, _args) = action.unwrap();
1144        assert_eq!(action_name, "save");
1145    }
1146
1147    #[test]
1148    fn test_menu_item_when_requires_selection() {
1149        let mut state = MenuState::for_testing();
1150        let select_menu = Menu {
1151            id: None,
1152            label: "Edit".to_string(),
1153            items: vec![MenuItem::Action {
1154                label: "Find in Selection".to_string(),
1155                action: "find_in_selection".to_string(),
1156                args: HashMap::new(),
1157                when: Some(context_keys::HAS_SELECTION.to_string()),
1158                checkbox: None,
1159            }],
1160            when: None,
1161        };
1162        state.open_menu(0);
1163        state.highlighted_item = Some(0);
1164
1165        // Without has_selection set, action should be disabled
1166        assert!(state
1167            .get_highlighted_action(std::slice::from_ref(&select_menu))
1168            .is_none());
1169
1170        // With has_selection set to true, action should be enabled
1171        state.context.set(context_keys::HAS_SELECTION, true);
1172        assert!(state.get_highlighted_action(&[select_menu]).is_some());
1173    }
1174
1175    #[test]
1176    fn test_get_highlighted_action_none_when_closed() {
1177        let state = MenuState::for_testing();
1178        let menus = create_test_menus();
1179        assert!(state.get_highlighted_action(&menus).is_none());
1180    }
1181
1182    #[test]
1183    fn test_get_highlighted_action_none_for_separator() {
1184        let mut state = MenuState::for_testing();
1185        let menus = create_test_menus();
1186        state.open_menu(0);
1187        state.highlighted_item = Some(1); // Separator
1188
1189        assert!(state.get_highlighted_action(&menus).is_none());
1190    }
1191
1192    #[test]
1193    fn test_menu_layout_menu_at() {
1194        // Build a layout manually simulating what render would produce
1195        let bar_area = Rect::new(0, 0, 80, 1);
1196        let mut layout = MenuLayout::new(bar_area);
1197
1198        // " File " at x=0, width=6
1199        layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1200        // " Edit " at x=7, width=6
1201        layout.menu_areas.push((1, Rect::new(7, 0, 6, 1)));
1202        // " View " at x=14, width=6
1203        layout.menu_areas.push((2, Rect::new(14, 0, 6, 1)));
1204
1205        // File: x=0-5, y=0
1206        assert_eq!(layout.menu_at(0, 0), Some(0));
1207        assert_eq!(layout.menu_at(3, 0), Some(0));
1208        assert_eq!(layout.menu_at(5, 0), Some(0));
1209
1210        // Space between: x=6
1211        assert_eq!(layout.menu_at(6, 0), None);
1212
1213        // Edit: x=7-12
1214        assert_eq!(layout.menu_at(7, 0), Some(1));
1215        assert_eq!(layout.menu_at(10, 0), Some(1));
1216        assert_eq!(layout.menu_at(12, 0), Some(1));
1217
1218        // Space between: x=13
1219        assert_eq!(layout.menu_at(13, 0), None);
1220
1221        // View: x=14-19
1222        assert_eq!(layout.menu_at(14, 0), Some(2));
1223        assert_eq!(layout.menu_at(17, 0), Some(2));
1224        assert_eq!(layout.menu_at(19, 0), Some(2));
1225
1226        // After View
1227        assert_eq!(layout.menu_at(20, 0), None);
1228        assert_eq!(layout.menu_at(100, 0), None);
1229
1230        // Wrong row returns None
1231        assert_eq!(layout.menu_at(3, 1), None);
1232    }
1233
1234    #[test]
1235    fn test_menu_layout_item_at() {
1236        // Build a layout manually simulating what render would produce
1237        let bar_area = Rect::new(0, 0, 80, 1);
1238        let mut layout = MenuLayout::new(bar_area);
1239
1240        // Dropdown items for File menu at x=1 (inside border), y=2,3,4,5 (inside border)
1241        // Item 0 (New) at y=2
1242        layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1243        // Item 1 (Separator) at y=3
1244        layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1245        // Item 2 (Save) at y=4
1246        layout.item_areas.push((2, Rect::new(1, 4, 20, 1)));
1247        // Item 3 (Quit) at y=5
1248        layout.item_areas.push((3, Rect::new(1, 5, 20, 1)));
1249
1250        // Menu bar row returns None
1251        assert_eq!(layout.item_at(5, 0), None);
1252        // Border row returns None
1253        assert_eq!(layout.item_at(5, 1), None);
1254
1255        // y=2: New (index 0)
1256        assert_eq!(layout.item_at(5, 2), Some(0));
1257
1258        // y=3: Separator (index 1) - note: layout includes all items, filtering happens elsewhere
1259        assert_eq!(layout.item_at(5, 3), Some(1));
1260
1261        // y=4: Save (index 2)
1262        assert_eq!(layout.item_at(5, 4), Some(2));
1263
1264        // y=5: Quit (index 3)
1265        assert_eq!(layout.item_at(5, 5), Some(3));
1266
1267        // Beyond items
1268        assert_eq!(layout.item_at(5, 6), None);
1269        assert_eq!(layout.item_at(5, 100), None);
1270    }
1271
1272    #[test]
1273    fn test_menu_layout_hit_test() {
1274        let bar_area = Rect::new(0, 0, 80, 1);
1275        let mut layout = MenuLayout::new(bar_area);
1276
1277        // Menu labels
1278        layout.menu_areas.push((0, Rect::new(0, 0, 6, 1)));
1279
1280        // Dropdown items
1281        layout.item_areas.push((0, Rect::new(1, 2, 20, 1)));
1282        layout.item_areas.push((1, Rect::new(1, 3, 20, 1)));
1283
1284        // Submenu items at depth 1
1285        layout.submenu_areas.push((1, 0, Rect::new(22, 3, 15, 1)));
1286
1287        // Hit test menu label
1288        assert_eq!(layout.hit_test(3, 0), Some(MenuHit::MenuLabel(0)));
1289
1290        // Hit test dropdown item
1291        assert_eq!(layout.hit_test(5, 2), Some(MenuHit::DropdownItem(0)));
1292
1293        // Hit test submenu item (should have priority)
1294        assert_eq!(
1295            layout.hit_test(25, 3),
1296            Some(MenuHit::SubmenuItem { depth: 1, index: 0 })
1297        );
1298
1299        // Hit test bar background (inside bar area but not on menu)
1300        assert_eq!(layout.hit_test(50, 0), Some(MenuHit::BarBackground));
1301
1302        // Hit test outside everything
1303        assert_eq!(layout.hit_test(50, 10), None);
1304    }
1305
1306    #[test]
1307    fn test_menu_config_json_parsing() {
1308        let json = r#"{
1309            "menus": [
1310                {
1311                    "label": "File",
1312                    "items": [
1313                        { "label": "New", "action": "new_file" },
1314                        { "separator": true },
1315                        { "label": "Save", "action": "save" }
1316                    ]
1317                }
1318            ]
1319        }"#;
1320
1321        let config: MenuConfig = serde_json::from_str(json).unwrap();
1322        assert_eq!(config.menus.len(), 1);
1323        assert_eq!(config.menus[0].label, "File");
1324        assert_eq!(config.menus[0].items.len(), 3);
1325
1326        match &config.menus[0].items[0] {
1327            MenuItem::Action { label, action, .. } => {
1328                assert_eq!(label, "New");
1329                assert_eq!(action, "new_file");
1330            }
1331            _ => panic!("Expected Action"),
1332        }
1333
1334        assert!(matches!(
1335            config.menus[0].items[1],
1336            MenuItem::Separator { .. }
1337        ));
1338
1339        match &config.menus[0].items[2] {
1340            MenuItem::Action { label, action, .. } => {
1341                assert_eq!(label, "Save");
1342                assert_eq!(action, "save");
1343            }
1344            _ => panic!("Expected Action"),
1345        }
1346    }
1347
1348    #[test]
1349    fn test_menu_item_with_args() {
1350        let json = r#"{
1351            "label": "Go to Line",
1352            "action": "goto_line",
1353            "args": { "line": 42 }
1354        }"#;
1355
1356        let item: MenuItem = serde_json::from_str(json).unwrap();
1357        match item {
1358            MenuItem::Action {
1359                label,
1360                action,
1361                args,
1362                ..
1363            } => {
1364                assert_eq!(label, "Go to Line");
1365                assert_eq!(action, "goto_line");
1366                assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1367            }
1368            _ => panic!("Expected Action with args"),
1369        }
1370    }
1371
1372    #[test]
1373    fn test_empty_menu_config() {
1374        let json = r#"{ "menus": [] }"#;
1375        let config: MenuConfig = serde_json::from_str(json).unwrap();
1376        assert!(config.menus.is_empty());
1377    }
1378
1379    #[test]
1380    fn test_menu_mnemonic_lookup() {
1381        use crate::config::Config;
1382        use crate::input::keybindings::KeybindingResolver;
1383
1384        let config = Config::default();
1385        let resolver = KeybindingResolver::new(&config);
1386
1387        // Check that default Alt+letter bindings are configured
1388        assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1389        assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1390        assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1391        assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1392        assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1393        assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1394
1395        // Case-insensitive matching
1396        assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1397        assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1398
1399        // Non-existent menu should return None
1400        assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1401    }
1402
1403    fn create_menu_with_submenus() -> Vec<Menu> {
1404        vec![Menu {
1405            id: None,
1406            label: "View".to_string(),
1407            items: vec![
1408                MenuItem::Action {
1409                    label: "Toggle Explorer".to_string(),
1410                    action: "toggle_file_explorer".to_string(),
1411                    args: HashMap::new(),
1412                    when: None,
1413                    checkbox: None,
1414                },
1415                MenuItem::Submenu {
1416                    label: "Terminal".to_string(),
1417                    items: vec![
1418                        MenuItem::Action {
1419                            label: "Open Terminal".to_string(),
1420                            action: "open_terminal".to_string(),
1421                            args: HashMap::new(),
1422                            when: None,
1423                            checkbox: None,
1424                        },
1425                        MenuItem::Action {
1426                            label: "Close Terminal".to_string(),
1427                            action: "close_terminal".to_string(),
1428                            args: HashMap::new(),
1429                            when: None,
1430                            checkbox: None,
1431                        },
1432                        MenuItem::Submenu {
1433                            label: "Terminal Settings".to_string(),
1434                            items: vec![MenuItem::Action {
1435                                label: "Font Size".to_string(),
1436                                action: "terminal_font_size".to_string(),
1437                                args: HashMap::new(),
1438                                when: None,
1439                                checkbox: None,
1440                            }],
1441                        },
1442                    ],
1443                },
1444                MenuItem::Separator { separator: true },
1445                MenuItem::Action {
1446                    label: "Zoom In".to_string(),
1447                    action: "zoom_in".to_string(),
1448                    args: HashMap::new(),
1449                    when: None,
1450                    checkbox: None,
1451                },
1452            ],
1453            when: None,
1454        }]
1455    }
1456
1457    #[test]
1458    fn test_submenu_open_and_close() {
1459        let mut state = MenuState::for_testing();
1460        let menus = create_menu_with_submenus();
1461
1462        state.open_menu(0);
1463        assert!(state.submenu_path.is_empty());
1464        assert!(!state.in_submenu());
1465
1466        // Move to Terminal submenu item (index 1)
1467        state.highlighted_item = Some(1);
1468
1469        // Open the submenu
1470        assert!(state.open_submenu(&menus));
1471        assert_eq!(state.submenu_path, vec![1]);
1472        assert!(state.in_submenu());
1473        assert_eq!(state.submenu_depth(), 1);
1474        assert_eq!(state.highlighted_item, Some(0)); // Reset to first item
1475
1476        // Close the submenu
1477        assert!(state.close_submenu());
1478        assert!(state.submenu_path.is_empty());
1479        assert!(!state.in_submenu());
1480        assert_eq!(state.highlighted_item, Some(1)); // Restored to parent item
1481    }
1482
1483    #[test]
1484    fn test_nested_submenu() {
1485        let mut state = MenuState::for_testing();
1486        let menus = create_menu_with_submenus();
1487
1488        state.open_menu(0);
1489        state.highlighted_item = Some(1); // Terminal submenu
1490
1491        // Open first level submenu
1492        assert!(state.open_submenu(&menus));
1493        assert_eq!(state.submenu_depth(), 1);
1494
1495        // Move to Terminal Settings (nested submenu at index 2)
1496        state.highlighted_item = Some(2);
1497
1498        // Open second level submenu
1499        assert!(state.open_submenu(&menus));
1500        assert_eq!(state.submenu_path, vec![1, 2]);
1501        assert_eq!(state.submenu_depth(), 2);
1502        assert_eq!(state.highlighted_item, Some(0));
1503
1504        // Close back to first level
1505        assert!(state.close_submenu());
1506        assert_eq!(state.submenu_path, vec![1]);
1507        assert_eq!(state.highlighted_item, Some(2));
1508
1509        // Close back to main menu
1510        assert!(state.close_submenu());
1511        assert!(state.submenu_path.is_empty());
1512        assert_eq!(state.highlighted_item, Some(1));
1513
1514        // Can't close further
1515        assert!(!state.close_submenu());
1516    }
1517
1518    #[test]
1519    fn test_get_highlighted_action_in_submenu() {
1520        let mut state = MenuState::for_testing();
1521        let menus = create_menu_with_submenus();
1522
1523        state.open_menu(0);
1524        state.highlighted_item = Some(1); // Terminal submenu
1525
1526        // On a submenu item, get_highlighted_action should return None
1527        assert!(state.get_highlighted_action(&menus).is_none());
1528
1529        // Open the submenu
1530        state.open_submenu(&menus);
1531        // Now highlighted_item is 0 which is "Open Terminal"
1532        let action = state.get_highlighted_action(&menus);
1533        assert!(action.is_some());
1534        let (action_name, _) = action.unwrap();
1535        assert_eq!(action_name, "open_terminal");
1536
1537        // Navigate to second item
1538        state.highlighted_item = Some(1);
1539        let action = state.get_highlighted_action(&menus);
1540        assert!(action.is_some());
1541        let (action_name, _) = action.unwrap();
1542        assert_eq!(action_name, "close_terminal");
1543    }
1544
1545    #[test]
1546    fn test_get_current_items_at_different_depths() {
1547        let mut state = MenuState::for_testing();
1548        let menus = create_menu_with_submenus();
1549
1550        state.open_menu(0);
1551
1552        // At top level, should get main menu items
1553        let items = state.get_current_items(&menus, 0).unwrap();
1554        assert_eq!(items.len(), 4); // Action, Submenu, Separator, Action
1555
1556        // Open Terminal submenu
1557        state.highlighted_item = Some(1);
1558        state.open_submenu(&menus);
1559
1560        // Now should get Terminal submenu items
1561        let items = state.get_current_items(&menus, 0).unwrap();
1562        assert_eq!(items.len(), 3); // Open, Close, Settings submenu
1563
1564        // Open nested Terminal Settings submenu
1565        state.highlighted_item = Some(2);
1566        state.open_submenu(&menus);
1567
1568        // Now should get Terminal Settings submenu items
1569        let items = state.get_current_items(&menus, 0).unwrap();
1570        assert_eq!(items.len(), 1); // Font Size
1571    }
1572
1573    #[test]
1574    fn test_is_highlighted_submenu() {
1575        let mut state = MenuState::for_testing();
1576        let menus = create_menu_with_submenus();
1577
1578        state.open_menu(0);
1579        state.highlighted_item = Some(0); // Toggle Explorer (action)
1580        assert!(!state.is_highlighted_submenu(&menus));
1581
1582        state.highlighted_item = Some(1); // Terminal (submenu)
1583        assert!(state.is_highlighted_submenu(&menus));
1584
1585        state.highlighted_item = Some(2); // Separator
1586        assert!(!state.is_highlighted_submenu(&menus));
1587
1588        state.highlighted_item = Some(3); // Zoom In (action)
1589        assert!(!state.is_highlighted_submenu(&menus));
1590    }
1591
1592    #[test]
1593    fn test_open_menu_clears_submenu_path() {
1594        let mut state = MenuState::for_testing();
1595        let menus = create_menu_with_submenus();
1596
1597        state.open_menu(0);
1598        state.highlighted_item = Some(1);
1599        state.open_submenu(&menus);
1600        assert!(!state.submenu_path.is_empty());
1601
1602        // Opening a new menu should clear the submenu path
1603        state.open_menu(0);
1604        assert!(state.submenu_path.is_empty());
1605    }
1606
1607    #[test]
1608    fn test_next_prev_menu_clears_submenu_path() {
1609        let mut state = MenuState::for_testing();
1610        let menus = create_menu_with_submenus();
1611
1612        state.open_menu(0);
1613        state.highlighted_item = Some(1);
1614        state.open_submenu(&menus);
1615        assert!(!state.submenu_path.is_empty());
1616
1617        // next_menu should clear submenu path
1618        state.next_menu(&menus);
1619        assert!(state.submenu_path.is_empty());
1620
1621        // Re-open submenu
1622        state.open_menu(0);
1623        state.highlighted_item = Some(1);
1624        state.open_submenu(&menus);
1625
1626        // prev_menu should clear submenu path
1627        state.prev_menu(&menus);
1628        assert!(state.submenu_path.is_empty());
1629    }
1630
1631    #[test]
1632    fn test_navigation_in_submenu() {
1633        let mut state = MenuState::for_testing();
1634        let menus = create_menu_with_submenus();
1635
1636        state.open_menu(0);
1637        state.highlighted_item = Some(1);
1638        state.open_submenu(&menus);
1639
1640        // In Terminal submenu, start at index 0
1641        assert_eq!(state.highlighted_item, Some(0));
1642
1643        // Navigate down
1644        state.next_item(&menus[0]);
1645        assert_eq!(state.highlighted_item, Some(1));
1646
1647        // Navigate down again
1648        state.next_item(&menus[0]);
1649        assert_eq!(state.highlighted_item, Some(2));
1650
1651        // Navigate down wraps to start
1652        state.next_item(&menus[0]);
1653        assert_eq!(state.highlighted_item, Some(0));
1654
1655        // Navigate up wraps to end
1656        state.prev_item(&menus[0]);
1657        assert_eq!(state.highlighted_item, Some(2));
1658    }
1659
1660    /// Helper function to calculate dropdown x offset (mirrors the logic in render_dropdown_chain)
1661    fn calculate_dropdown_x_offset(
1662        all_menus: &[Menu],
1663        menu_index: usize,
1664        context: &MenuContext,
1665    ) -> usize {
1666        let mut x_offset = 0usize;
1667        for (idx, m) in all_menus.iter().enumerate() {
1668            if idx == menu_index {
1669                break;
1670            }
1671            // Only count visible menus
1672            let is_visible = match &m.when {
1673                Some(condition) => context.get(condition),
1674                None => true,
1675            };
1676            if is_visible {
1677                x_offset += str_width(&m.label) + 3; // label + spaces
1678            }
1679        }
1680        x_offset
1681    }
1682
1683    #[test]
1684    fn test_dropdown_position_skips_hidden_menus() {
1685        // Create menus: File (always visible), Explorer (conditional), Help (always visible)
1686        let menus = vec![
1687            Menu {
1688                id: None,
1689                label: "File".to_string(), // width 4, total 7 with padding
1690                items: vec![],
1691                when: None,
1692            },
1693            Menu {
1694                id: None,
1695                label: "Explorer".to_string(), // width 8, total 11 with padding
1696                items: vec![],
1697                when: Some("file_explorer_focused".to_string()),
1698            },
1699            Menu {
1700                id: None,
1701                label: "Help".to_string(), // width 4, total 7 with padding
1702                items: vec![],
1703                when: None,
1704            },
1705        ];
1706
1707        // When Explorer is hidden, Help dropdown should be at File's width only
1708        let context_hidden = MenuContext::new().with("file_explorer_focused", false);
1709        let x_help_hidden = calculate_dropdown_x_offset(&menus, 2, &context_hidden);
1710        // "File" = 4 chars + 3 spaces = 7
1711        assert_eq!(
1712            x_help_hidden, 7,
1713            "Help dropdown should be at x=7 when Explorer is hidden"
1714        );
1715
1716        // When Explorer is visible, Help dropdown should be at File + Explorer width
1717        let context_visible = MenuContext::new().with("file_explorer_focused", true);
1718        let x_help_visible = calculate_dropdown_x_offset(&menus, 2, &context_visible);
1719        // "File" = 4 chars + 3 spaces = 7, "Explorer" = 8 chars + 3 spaces = 11, total = 18
1720        assert_eq!(
1721            x_help_visible, 18,
1722            "Help dropdown should be at x=18 when Explorer is visible"
1723        );
1724    }
1725
1726    #[test]
1727    fn test_dropdown_position_with_multiple_hidden_menus() {
1728        let menus = vec![
1729            Menu {
1730                id: None,
1731                label: "A".to_string(), // width 1, total 4
1732                items: vec![],
1733                when: None,
1734            },
1735            Menu {
1736                id: None,
1737                label: "B".to_string(), // width 1, total 4
1738                items: vec![],
1739                when: Some("show_b".to_string()),
1740            },
1741            Menu {
1742                id: None,
1743                label: "C".to_string(), // width 1, total 4
1744                items: vec![],
1745                when: Some("show_c".to_string()),
1746            },
1747            Menu {
1748                id: None,
1749                label: "D".to_string(),
1750                items: vec![],
1751                when: None,
1752            },
1753        ];
1754
1755        // No conditional menus visible: D should be right after A
1756        let context_none = MenuContext::new();
1757        assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_none), 4);
1758
1759        // Only B visible: D should be after A + B
1760        let context_b = MenuContext::new().with("show_b", true);
1761        assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_b), 8);
1762
1763        // Both B and C visible: D should be after A + B + C
1764        let context_both = MenuContext::new().with("show_b", true).with("show_c", true);
1765        assert_eq!(calculate_dropdown_x_offset(&menus, 3, &context_both), 12);
1766    }
1767}