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