Skip to main content

fresh/view/ui/
menu.rs

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