fresh/view/ui/
menu.rs

1//! Menu bar rendering
2
3use crate::config::{generate_dynamic_items, Menu, MenuConfig, MenuExt, MenuItem, MenuItemExt};
4use crate::primitives::display_width::str_width;
5use crate::view::theme::Theme;
6use ratatui::layout::Rect;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Paragraph};
10use ratatui::Frame;
11
12// Re-export context_keys from the shared types module
13pub use crate::types::context_keys;
14
15/// Menu state context - provides named boolean states for menu item conditions
16/// Both `when` conditions and `checkbox` states look up values here
17#[derive(Debug, Clone, Default)]
18pub struct MenuContext {
19    states: std::collections::HashMap<String, bool>,
20}
21
22impl MenuContext {
23    pub fn new() -> Self {
24        Self {
25            states: std::collections::HashMap::new(),
26        }
27    }
28
29    /// Set a named boolean state
30    pub fn set(&mut self, name: impl Into<String>, value: bool) -> &mut Self {
31        self.states.insert(name.into(), value);
32        self
33    }
34
35    /// Get a named boolean state (defaults to false if not set)
36    pub fn get(&self, name: &str) -> bool {
37        self.states.get(name).copied().unwrap_or(false)
38    }
39
40    /// Builder-style setter
41    pub fn with(mut self, name: impl Into<String>, value: bool) -> Self {
42        self.set(name, value);
43        self
44    }
45}
46
47fn is_menu_item_enabled(item: &MenuItem, context: &MenuContext) -> bool {
48    match item {
49        MenuItem::Action { when, .. } => {
50            match when.as_deref() {
51                Some(condition) => context.get(condition),
52                None => true, // No condition means always enabled
53            }
54        }
55        _ => true,
56    }
57}
58
59fn is_checkbox_checked(checkbox: &Option<String>, context: &MenuContext) -> bool {
60    match checkbox.as_deref() {
61        Some(name) => context.get(name),
62        None => false,
63    }
64}
65
66/// Menu bar state (tracks which menu is open and which item is highlighted)
67#[derive(Debug, Clone, Default)]
68pub struct MenuState {
69    /// Index of the currently open menu (None if menu bar is closed)
70    pub active_menu: Option<usize>,
71    /// Index of the highlighted item within the active menu or current submenu
72    pub highlighted_item: Option<usize>,
73    /// Path of indices into nested submenus (empty = at top level menu)
74    /// Each element is the index of the submenu item that was opened
75    pub submenu_path: Vec<usize>,
76    /// Runtime menu additions from plugins
77    pub plugin_menus: Vec<Menu>,
78    /// Context containing named boolean states for conditions and checkboxes
79    pub context: MenuContext,
80}
81
82impl MenuState {
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Open a menu by index
88    pub fn open_menu(&mut self, index: usize) {
89        self.active_menu = Some(index);
90        self.highlighted_item = Some(0);
91        self.submenu_path.clear();
92    }
93
94    /// Close the currently open menu (and all submenus)
95    pub fn close_menu(&mut self) {
96        self.active_menu = None;
97        self.highlighted_item = None;
98        self.submenu_path.clear();
99    }
100
101    /// Navigate to the next menu (right) - only at top level
102    pub fn next_menu(&mut self, total_menus: usize) {
103        if let Some(active) = self.active_menu {
104            self.active_menu = Some((active + 1) % total_menus);
105            self.highlighted_item = Some(0);
106            self.submenu_path.clear();
107        }
108    }
109
110    /// Navigate to the previous menu (left) - only at top level
111    pub fn prev_menu(&mut self, total_menus: usize) {
112        if let Some(active) = self.active_menu {
113            self.active_menu = Some((active + total_menus - 1) % total_menus);
114            self.highlighted_item = Some(0);
115            self.submenu_path.clear();
116        }
117    }
118
119    /// Check if we're currently in a submenu
120    pub fn in_submenu(&self) -> bool {
121        !self.submenu_path.is_empty()
122    }
123
124    /// Get the current submenu depth (0 = top level menu)
125    pub fn submenu_depth(&self) -> usize {
126        self.submenu_path.len()
127    }
128
129    /// Open a submenu at the current highlighted item
130    /// Returns true if a submenu was opened, false if the item wasn't a submenu
131    pub fn open_submenu(&mut self, menus: &[Menu]) -> bool {
132        let Some(active_idx) = self.active_menu else {
133            return false;
134        };
135        let Some(highlighted) = self.highlighted_item else {
136            return false;
137        };
138
139        // Get the current menu items
140        let Some(menu) = menus.get(active_idx) else {
141            return false;
142        };
143        let Some(items) = self.get_current_items_cloned(menu) else {
144            return false;
145        };
146
147        // Check if highlighted item is a submenu (including DynamicSubmenu which was expanded)
148        if let Some(item) = items.get(highlighted) {
149            match item {
150                MenuItem::Submenu {
151                    items: submenu_items,
152                    ..
153                } if !submenu_items.is_empty() => {
154                    self.submenu_path.push(highlighted);
155                    self.highlighted_item = Some(0);
156                    return true;
157                }
158                MenuItem::DynamicSubmenu { source, .. } => {
159                    // Generate items to check if non-empty
160                    let generated = generate_dynamic_items(source);
161                    if !generated.is_empty() {
162                        self.submenu_path.push(highlighted);
163                        self.highlighted_item = Some(0);
164                        return true;
165                    }
166                }
167                _ => {}
168            }
169        }
170        false
171    }
172
173    /// Close the current submenu and go back to parent
174    /// Returns true if a submenu was closed, false if already at top level
175    pub fn close_submenu(&mut self) -> bool {
176        if let Some(parent_idx) = self.submenu_path.pop() {
177            self.highlighted_item = Some(parent_idx);
178            true
179        } else {
180            false
181        }
182    }
183
184    /// Get the menu items at the current submenu level
185    pub fn get_current_items<'a>(
186        &self,
187        menus: &'a [Menu],
188        active_idx: usize,
189    ) -> Option<&'a [MenuItem]> {
190        let menu = menus.get(active_idx)?;
191        let mut items: &[MenuItem] = &menu.items;
192
193        for &idx in &self.submenu_path {
194            match items.get(idx)? {
195                MenuItem::Submenu {
196                    items: submenu_items,
197                    ..
198                } => {
199                    items = submenu_items;
200                }
201                _ => return None,
202            }
203        }
204
205        Some(items)
206    }
207
208    /// Get owned vec of current items (for use when Menu is cloned)
209    /// DynamicSubmenus are expanded to regular Submenus
210    pub fn get_current_items_cloned(&self, menu: &Menu) -> Option<Vec<MenuItem>> {
211        // Expand all items (handles DynamicSubmenu -> Submenu)
212        let mut items: Vec<MenuItem> = menu.items.iter().map(|i| i.expand_dynamic()).collect();
213
214        for &idx in &self.submenu_path {
215            match items.get(idx)?.expand_dynamic() {
216                MenuItem::Submenu {
217                    items: submenu_items,
218                    ..
219                } => {
220                    items = submenu_items;
221                }
222                _ => return None,
223            }
224        }
225
226        Some(items)
227    }
228
229    /// Navigate to the next item in the current menu/submenu (down)
230    pub fn next_item(&mut self, menu: &Menu) {
231        let Some(idx) = self.highlighted_item else {
232            return;
233        };
234
235        // Get current items (may be in a submenu)
236        let Some(items) = self.get_current_items_cloned(menu) else {
237            return;
238        };
239
240        if items.is_empty() {
241            return;
242        }
243
244        // Skip separators and disabled items
245        let mut next = (idx + 1) % items.len();
246        while next != idx && self.should_skip_item(&items[next]) {
247            next = (next + 1) % items.len();
248        }
249        self.highlighted_item = Some(next);
250    }
251
252    /// Navigate to the previous item in the current menu/submenu (up)
253    pub fn prev_item(&mut self, menu: &Menu) {
254        let Some(idx) = self.highlighted_item else {
255            return;
256        };
257
258        // Get current items (may be in a submenu)
259        let Some(items) = self.get_current_items_cloned(menu) else {
260            return;
261        };
262
263        if items.is_empty() {
264            return;
265        }
266
267        // Skip separators and disabled items
268        let total = items.len();
269        let mut prev = (idx + total - 1) % total;
270        while prev != idx && self.should_skip_item(&items[prev]) {
271            prev = (prev + total - 1) % total;
272        }
273        self.highlighted_item = Some(prev);
274    }
275
276    /// Check if a menu item should be skipped during navigation
277    fn should_skip_item(&self, item: &MenuItem) -> bool {
278        match item {
279            MenuItem::Separator { .. } => true,
280            MenuItem::Action { when, .. } => {
281                // Skip disabled items (when condition evaluates to false)
282                match when.as_deref() {
283                    Some(condition) => !self.context.get(condition),
284                    None => false, // No condition means enabled, don't skip
285                }
286            }
287            _ => false,
288        }
289    }
290
291    /// Get the currently highlighted action (if any)
292    /// This navigates through the submenu path to find the currently highlighted item
293    pub fn get_highlighted_action(
294        &self,
295        menus: &[Menu],
296    ) -> Option<(String, std::collections::HashMap<String, serde_json::Value>)> {
297        let active_menu = self.active_menu?;
298        let highlighted_item = self.highlighted_item?;
299
300        // Get the items at the current submenu level, handling DynamicSubmenu
301        let menu = menus.get(active_menu)?;
302        let items = self.get_current_items_cloned(menu)?;
303        let item = items.get(highlighted_item)?;
304
305        match item {
306            MenuItem::Action { action, args, .. } => {
307                if is_menu_item_enabled(item, &self.context) {
308                    Some((action.clone(), args.clone()))
309                } else {
310                    None
311                }
312            }
313            _ => None,
314        }
315    }
316
317    /// Check if the currently highlighted item is a submenu
318    pub fn is_highlighted_submenu(&self, menus: &[Menu]) -> bool {
319        let Some(active_menu) = self.active_menu else {
320            return false;
321        };
322        let Some(highlighted_item) = self.highlighted_item else {
323            return false;
324        };
325
326        // Use get_current_items_cloned to handle DynamicSubmenu
327        let Some(menu) = menus.get(active_menu) else {
328            return false;
329        };
330        let Some(items) = self.get_current_items_cloned(menu) else {
331            return false;
332        };
333
334        matches!(
335            items.get(highlighted_item),
336            Some(MenuItem::Submenu { .. } | MenuItem::DynamicSubmenu { .. })
337        )
338    }
339
340    /// Get the menu index at a given x position in the menu bar
341    /// Returns the menu index if the click is on a menu label
342    pub fn get_menu_at_position(&self, menus: &[Menu], x: u16) -> Option<usize> {
343        let mut current_x = 0u16;
344
345        for (idx, menu) in menus.iter().enumerate() {
346            let label_width = str_width(&menu.label) as u16 + 2; // " Label "
347            let total_width = label_width + 1; // Plus trailing space
348
349            if x >= current_x && x < current_x + label_width {
350                return Some(idx);
351            }
352
353            current_x += total_width;
354        }
355
356        None
357    }
358
359    /// Get the item index at a given y position in the dropdown
360    /// y is relative to the menu bar (so y=1 is the first item in dropdown)
361    pub fn get_item_at_position(&self, menu: &Menu, y: u16) -> Option<usize> {
362        // y=0 is menu bar, y=1 is top border, y=2 is first item
363        if y < 2 {
364            return None;
365        }
366
367        let item_index = (y - 2) as usize;
368        if item_index < menu.items.len() {
369            // Don't return separator indices
370            if matches!(menu.items[item_index], MenuItem::Separator { .. }) {
371                None
372            } else {
373                Some(item_index)
374            }
375        } else {
376            None
377        }
378    }
379}
380
381/// Renders the menu bar
382pub struct MenuRenderer;
383
384impl MenuRenderer {
385    /// Render the menu bar at the top of the screen
386    ///
387    /// # Arguments
388    /// * `frame` - The ratatui frame to render to
389    /// * `area` - The rectangular area to render the menu bar in
390    /// * `menu_config` - The menu configuration
391    /// * `menu_state` - Current menu state (which menu/item is active, and context)
392    /// * `keybindings` - Keybinding resolver for displaying shortcuts
393    /// * `theme` - The active theme for colors
394    /// * `hover_target` - The currently hovered UI element (if any)
395    pub fn render(
396        frame: &mut Frame,
397        area: Rect,
398        menu_config: &MenuConfig,
399        menu_state: &MenuState,
400        keybindings: &crate::input::keybindings::KeybindingResolver,
401        theme: &Theme,
402        hover_target: Option<&crate::app::HoverTarget>,
403    ) {
404        // Combine config menus with plugin menus, expanding any DynamicSubmenus
405        let all_menus: Vec<Menu> = menu_config
406            .menus
407            .iter()
408            .chain(menu_state.plugin_menus.iter())
409            .cloned()
410            .map(|mut menu| {
411                menu.expand_dynamic_items();
412                menu
413            })
414            .collect();
415
416        // Build spans for each menu label
417        let mut spans = Vec::new();
418
419        for (idx, menu) in all_menus.iter().enumerate() {
420            let is_active = menu_state.active_menu == Some(idx);
421            let is_hovered =
422                matches!(hover_target, Some(crate::app::HoverTarget::MenuBarItem(i)) if *i == idx);
423
424            let base_style = if is_active {
425                Style::default()
426                    .fg(theme.menu_active_fg)
427                    .bg(theme.menu_active_bg)
428                    .add_modifier(Modifier::BOLD)
429            } else if is_hovered {
430                Style::default()
431                    .fg(theme.menu_hover_fg)
432                    .bg(theme.menu_hover_bg)
433            } else {
434                Style::default().fg(theme.menu_fg).bg(theme.menu_bg)
435            };
436
437            // Check for mnemonic character (Alt+letter keybinding)
438            let mnemonic = keybindings.find_menu_mnemonic(&menu.label);
439
440            // Build the label with underlined mnemonic
441            spans.push(Span::styled(" ", base_style));
442
443            if let Some(mnemonic_char) = mnemonic {
444                // Find the first occurrence of the mnemonic character in the label
445                let mut found = false;
446                for c in menu.label.chars() {
447                    if !found && c.to_ascii_lowercase() == mnemonic_char {
448                        // Underline this character
449                        spans.push(Span::styled(
450                            c.to_string(),
451                            base_style.add_modifier(Modifier::UNDERLINED),
452                        ));
453                        found = true;
454                    } else {
455                        spans.push(Span::styled(c.to_string(), base_style));
456                    }
457                }
458            } else {
459                // No mnemonic, just render the label normally
460                spans.push(Span::styled(menu.label.clone(), base_style));
461            }
462
463            spans.push(Span::styled(" ", base_style));
464            spans.push(Span::raw(" "));
465        }
466
467        let line = Line::from(spans);
468        let paragraph = Paragraph::new(line).style(Style::default().bg(theme.menu_bg));
469        frame.render_widget(paragraph, area);
470
471        // Render dropdown if a menu is active
472        if let Some(active_idx) = menu_state.active_menu {
473            if let Some(menu) = all_menus.get(active_idx) {
474                Self::render_dropdown_chain(
475                    frame,
476                    area,
477                    menu,
478                    menu_state,
479                    active_idx,
480                    &all_menus,
481                    keybindings,
482                    theme,
483                    hover_target,
484                );
485            }
486        }
487    }
488
489    /// Render a dropdown menu and all its open submenus
490    #[allow(clippy::too_many_arguments)]
491    fn render_dropdown_chain(
492        frame: &mut Frame,
493        menu_bar_area: Rect,
494        menu: &Menu,
495        menu_state: &MenuState,
496        menu_index: usize,
497        all_menus: &[Menu],
498        keybindings: &crate::input::keybindings::KeybindingResolver,
499        theme: &Theme,
500        hover_target: Option<&crate::app::HoverTarget>,
501    ) {
502        // Calculate the x position of the top-level dropdown based on menu index
503        let mut x_offset = 0usize;
504        for (idx, m) in all_menus.iter().enumerate() {
505            if idx == menu_index {
506                break;
507            }
508            x_offset += str_width(&m.label) + 3; // label + spaces
509        }
510
511        let terminal_width = frame.area().width;
512        let terminal_height = frame.area().height;
513
514        // Track dropdown positions for rendering submenus
515        let mut current_items: &[MenuItem] = &menu.items;
516        let mut current_x = menu_bar_area.x.saturating_add(x_offset as u16);
517        let mut current_y = menu_bar_area.y.saturating_add(1);
518
519        // Render the main dropdown and collect submenu rendering info
520        // We'll render depth 0, then 1, etc.
521        for depth in 0..=menu_state.submenu_path.len() {
522            let is_deepest = depth == menu_state.submenu_path.len();
523            let highlighted_item = if is_deepest {
524                menu_state.highlighted_item
525            } else {
526                Some(menu_state.submenu_path[depth])
527            };
528
529            // Render this dropdown level
530            let dropdown_rect = Self::render_dropdown_level(
531                frame,
532                current_items,
533                highlighted_item,
534                current_x,
535                current_y,
536                terminal_width,
537                terminal_height,
538                depth,
539                &menu_state.submenu_path,
540                menu_index,
541                keybindings,
542                theme,
543                hover_target,
544                &menu_state.context,
545            );
546
547            // If not at the deepest level, navigate into the submenu for next iteration
548            if !is_deepest {
549                let submenu_idx = menu_state.submenu_path[depth];
550                // Handle both Submenu and DynamicSubmenu
551                let submenu_items = match current_items.get(submenu_idx) {
552                    Some(MenuItem::Submenu { items, .. }) => Some(items.as_slice()),
553                    Some(MenuItem::DynamicSubmenu { .. }) => {
554                        // DynamicSubmenu items will be generated and stored temporarily
555                        // This case shouldn't happen in normal flow since we expand before entering
556                        None
557                    }
558                    _ => None,
559                };
560                if let Some(items) = submenu_items {
561                    current_items = items;
562                    // Position submenu to the right of parent, aligned with the highlighted item
563                    current_x = dropdown_rect
564                        .x
565                        .saturating_add(dropdown_rect.width.saturating_sub(1));
566                    current_y = dropdown_rect.y.saturating_add(submenu_idx as u16 + 1); // +1 for border
567
568                    // Adjust if submenu would go off screen to the right - flip to left side
569                    let next_width = Self::calculate_dropdown_width(items);
570                    if current_x.saturating_add(next_width as u16) > terminal_width {
571                        current_x = dropdown_rect
572                            .x
573                            .saturating_sub(next_width as u16)
574                            .saturating_add(1);
575                    }
576                } else {
577                    break;
578                }
579            }
580        }
581    }
582
583    /// Calculate the width needed for a dropdown containing the given items
584    fn calculate_dropdown_width(items: &[MenuItem]) -> usize {
585        items
586            .iter()
587            .map(|item| match item {
588                MenuItem::Action { label, .. } => str_width(label) + 20,
589                MenuItem::Submenu { label, .. } => str_width(label) + 20,
590                MenuItem::DynamicSubmenu { label, .. } => str_width(label) + 20,
591                MenuItem::Separator { .. } => 20,
592                MenuItem::Label { info } => str_width(info) + 4,
593            })
594            .max()
595            .unwrap_or(20)
596            .min(40)
597    }
598
599    /// Render a single dropdown level and return its bounding Rect
600    #[allow(clippy::too_many_arguments)]
601    fn render_dropdown_level(
602        frame: &mut Frame,
603        items: &[MenuItem],
604        highlighted_item: Option<usize>,
605        x: u16,
606        y: u16,
607        terminal_width: u16,
608        terminal_height: u16,
609        depth: usize,
610        submenu_path: &[usize],
611        menu_index: usize,
612        keybindings: &crate::input::keybindings::KeybindingResolver,
613        theme: &Theme,
614        hover_target: Option<&crate::app::HoverTarget>,
615        context: &MenuContext,
616    ) -> Rect {
617        let max_width = Self::calculate_dropdown_width(items);
618        let dropdown_height = items.len() + 2; // +2 for borders
619
620        let desired_width = max_width as u16;
621        let desired_height = dropdown_height as u16;
622
623        // Bounds check: ensure dropdown fits within the visible area
624        let adjusted_x = if x.saturating_add(desired_width) > terminal_width {
625            terminal_width.saturating_sub(desired_width)
626        } else {
627            x
628        };
629
630        let available_height = terminal_height.saturating_sub(y);
631        let height = desired_height.min(available_height);
632
633        let available_width = terminal_width.saturating_sub(adjusted_x);
634        let width = desired_width.min(available_width);
635
636        // Only render if we have at least minimal space
637        if width < 10 || height < 3 {
638            return Rect {
639                x: adjusted_x,
640                y,
641                width,
642                height,
643            };
644        }
645
646        let dropdown_area = Rect {
647            x: adjusted_x,
648            y,
649            width,
650            height,
651        };
652
653        // Build dropdown content
654        let mut lines = Vec::new();
655        let max_items = (height.saturating_sub(2)) as usize;
656        let items_to_show = items.len().min(max_items);
657        let content_width = (width as usize).saturating_sub(2);
658
659        for (idx, item) in items.iter().enumerate().take(items_to_show) {
660            let is_highlighted = highlighted_item == Some(idx);
661            // Check if this item is in the submenu path (has an open child submenu)
662            let has_open_submenu = depth < submenu_path.len() && submenu_path[depth] == idx;
663
664            // For hover target matching at submenu levels
665            let is_hovered = if depth == 0 {
666                matches!(
667                    hover_target,
668                    Some(crate::app::HoverTarget::MenuDropdownItem(mi, ii)) if *mi == menu_index && *ii == idx
669                )
670            } else {
671                matches!(
672                    hover_target,
673                    Some(crate::app::HoverTarget::SubmenuItem(d, ii)) if *d == depth && *ii == idx
674                )
675            };
676            let enabled = is_menu_item_enabled(item, context);
677
678            let line = match item {
679                MenuItem::Action {
680                    label,
681                    action,
682                    checkbox,
683                    ..
684                } => {
685                    let style = if !enabled {
686                        Style::default()
687                            .fg(theme.menu_disabled_fg)
688                            .bg(theme.menu_disabled_bg)
689                    } else if is_highlighted {
690                        Style::default()
691                            .fg(theme.menu_highlight_fg)
692                            .bg(theme.menu_highlight_bg)
693                    } else if is_hovered {
694                        Style::default()
695                            .fg(theme.menu_hover_fg)
696                            .bg(theme.menu_hover_bg)
697                    } else {
698                        Style::default()
699                            .fg(theme.menu_dropdown_fg)
700                            .bg(theme.menu_dropdown_bg)
701                    };
702
703                    let keybinding = keybindings
704                        .find_keybinding_for_action(
705                            action,
706                            crate::input::keybindings::KeyContext::Normal,
707                        )
708                        .unwrap_or_default();
709
710                    let checkbox_icon = if checkbox.is_some() {
711                        if is_checkbox_checked(checkbox, context) {
712                            "☑ "
713                        } else {
714                            "☐ "
715                        }
716                    } else {
717                        ""
718                    };
719
720                    let checkbox_width = if checkbox.is_some() { 2 } else { 0 };
721                    let label_display_width = str_width(label);
722                    let keybinding_display_width = str_width(&keybinding);
723
724                    let text = if keybinding.is_empty() {
725                        let padding_needed =
726                            content_width.saturating_sub(checkbox_width + label_display_width + 1);
727                        format!(" {}{}{}", checkbox_icon, label, " ".repeat(padding_needed))
728                    } else {
729                        let padding_needed = content_width.saturating_sub(
730                            checkbox_width + label_display_width + keybinding_display_width + 2,
731                        );
732                        format!(
733                            " {}{}{} {}",
734                            checkbox_icon,
735                            label,
736                            " ".repeat(padding_needed),
737                            keybinding
738                        )
739                    };
740
741                    Line::from(vec![Span::styled(text, style)])
742                }
743                MenuItem::Separator { .. } => {
744                    let separator = "─".repeat(content_width);
745                    Line::from(vec![Span::styled(
746                        format!(" {separator}"),
747                        Style::default()
748                            .fg(theme.menu_separator_fg)
749                            .bg(theme.menu_dropdown_bg),
750                    )])
751                }
752                MenuItem::Submenu { label, .. } | MenuItem::DynamicSubmenu { label, .. } => {
753                    // Highlight submenu items that have an open child
754                    let style = if is_highlighted || has_open_submenu {
755                        Style::default()
756                            .fg(theme.menu_highlight_fg)
757                            .bg(theme.menu_highlight_bg)
758                    } else if is_hovered {
759                        Style::default()
760                            .fg(theme.menu_hover_fg)
761                            .bg(theme.menu_hover_bg)
762                    } else {
763                        Style::default()
764                            .fg(theme.menu_dropdown_fg)
765                            .bg(theme.menu_dropdown_bg)
766                    };
767
768                    // Format: " Label        > " - label left-aligned, arrow near the end with padding
769                    // content_width minus: leading space (1) + space before arrow (1) + arrow (1) + trailing space (2)
770                    let label_display_width = str_width(label);
771                    let padding_needed = content_width.saturating_sub(label_display_width + 5);
772                    Line::from(vec![Span::styled(
773                        format!(" {}{} >  ", label, " ".repeat(padding_needed)),
774                        style,
775                    )])
776                }
777                MenuItem::Label { info } => {
778                    // Disabled info label - always shown in disabled style
779                    let style = Style::default()
780                        .fg(theme.menu_disabled_fg)
781                        .bg(theme.menu_dropdown_bg);
782                    let info_display_width = str_width(info);
783                    let padding_needed = content_width.saturating_sub(info_display_width);
784                    Line::from(vec![Span::styled(
785                        format!(" {}{}", info, " ".repeat(padding_needed)),
786                        style,
787                    )])
788                }
789            };
790
791            lines.push(line);
792        }
793
794        let block = Block::default()
795            .borders(Borders::ALL)
796            .border_style(Style::default().fg(theme.menu_border_fg))
797            .style(Style::default().bg(theme.menu_dropdown_bg));
798
799        let paragraph = Paragraph::new(lines).block(block);
800        frame.render_widget(paragraph, dropdown_area);
801
802        dropdown_area
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use std::collections::HashMap;
810
811    fn create_test_menus() -> Vec<Menu> {
812        vec![
813            Menu {
814                id: None,
815                label: "File".to_string(),
816                items: vec![
817                    MenuItem::Action {
818                        label: "New".to_string(),
819                        action: "new_file".to_string(),
820                        args: HashMap::new(),
821                        when: None,
822                        checkbox: None,
823                    },
824                    MenuItem::Separator { separator: true },
825                    MenuItem::Action {
826                        label: "Save".to_string(),
827                        action: "save".to_string(),
828                        args: HashMap::new(),
829                        when: None,
830                        checkbox: None,
831                    },
832                    MenuItem::Action {
833                        label: "Quit".to_string(),
834                        action: "quit".to_string(),
835                        args: HashMap::new(),
836                        when: None,
837                        checkbox: None,
838                    },
839                ],
840            },
841            Menu {
842                id: None,
843                label: "Edit".to_string(),
844                items: vec![
845                    MenuItem::Action {
846                        label: "Undo".to_string(),
847                        action: "undo".to_string(),
848                        args: HashMap::new(),
849                        when: None,
850                        checkbox: None,
851                    },
852                    MenuItem::Action {
853                        label: "Redo".to_string(),
854                        action: "redo".to_string(),
855                        args: HashMap::new(),
856                        when: None,
857                        checkbox: None,
858                    },
859                ],
860            },
861            Menu {
862                id: None,
863                label: "View".to_string(),
864                items: vec![MenuItem::Action {
865                    label: "Toggle Explorer".to_string(),
866                    action: "toggle_file_explorer".to_string(),
867                    args: HashMap::new(),
868                    when: None,
869                    checkbox: None,
870                }],
871            },
872        ]
873    }
874
875    #[test]
876    fn test_menu_state_default() {
877        let state = MenuState::new();
878        assert_eq!(state.active_menu, None);
879        assert_eq!(state.highlighted_item, None);
880        assert!(state.plugin_menus.is_empty());
881    }
882
883    #[test]
884    fn test_menu_state_open_menu() {
885        let mut state = MenuState::new();
886        state.open_menu(2);
887        assert_eq!(state.active_menu, Some(2));
888        assert_eq!(state.highlighted_item, Some(0));
889    }
890
891    #[test]
892    fn test_menu_state_close_menu() {
893        let mut state = MenuState::new();
894        state.open_menu(1);
895        state.close_menu();
896        assert_eq!(state.active_menu, None);
897        assert_eq!(state.highlighted_item, None);
898    }
899
900    #[test]
901    fn test_menu_state_next_menu() {
902        let mut state = MenuState::new();
903        state.open_menu(0);
904
905        state.next_menu(3);
906        assert_eq!(state.active_menu, Some(1));
907
908        state.next_menu(3);
909        assert_eq!(state.active_menu, Some(2));
910
911        // Wrap around
912        state.next_menu(3);
913        assert_eq!(state.active_menu, Some(0));
914    }
915
916    #[test]
917    fn test_menu_state_prev_menu() {
918        let mut state = MenuState::new();
919        state.open_menu(0);
920
921        // Wrap around backwards
922        state.prev_menu(3);
923        assert_eq!(state.active_menu, Some(2));
924
925        state.prev_menu(3);
926        assert_eq!(state.active_menu, Some(1));
927
928        state.prev_menu(3);
929        assert_eq!(state.active_menu, Some(0));
930    }
931
932    #[test]
933    fn test_menu_state_next_item_skips_separator() {
934        let mut state = MenuState::new();
935        let menus = create_test_menus();
936        state.open_menu(0);
937
938        // highlighted_item starts at 0 (New)
939        assert_eq!(state.highlighted_item, Some(0));
940
941        // Next should skip separator and go to Save (index 2)
942        state.next_item(&menus[0]);
943        assert_eq!(state.highlighted_item, Some(2));
944
945        // Next goes to Quit (index 3)
946        state.next_item(&menus[0]);
947        assert_eq!(state.highlighted_item, Some(3));
948
949        // Wrap around to New (index 0)
950        state.next_item(&menus[0]);
951        assert_eq!(state.highlighted_item, Some(0));
952    }
953
954    #[test]
955    fn test_menu_state_prev_item_skips_separator() {
956        let mut state = MenuState::new();
957        let menus = create_test_menus();
958        state.open_menu(0);
959        state.highlighted_item = Some(2); // Start at Save
960
961        // Prev should skip separator and go to New (index 0)
962        state.prev_item(&menus[0]);
963        assert_eq!(state.highlighted_item, Some(0));
964
965        // Wrap around backwards to Quit (index 3)
966        state.prev_item(&menus[0]);
967        assert_eq!(state.highlighted_item, Some(3));
968    }
969
970    #[test]
971    fn test_get_highlighted_action() {
972        let mut state = MenuState::new();
973        let menus = create_test_menus();
974        state.open_menu(0);
975        state.highlighted_item = Some(2); // Save action
976
977        let action = state.get_highlighted_action(&menus);
978        assert!(action.is_some());
979        let (action_name, _args) = action.unwrap();
980        assert_eq!(action_name, "save");
981    }
982
983    #[test]
984    fn test_menu_item_when_requires_selection() {
985        let mut state = MenuState::new();
986        let select_menu = Menu {
987            id: None,
988            label: "Edit".to_string(),
989            items: vec![MenuItem::Action {
990                label: "Find in Selection".to_string(),
991                action: "find_in_selection".to_string(),
992                args: HashMap::new(),
993                when: Some(context_keys::HAS_SELECTION.to_string()),
994                checkbox: None,
995            }],
996        };
997        state.open_menu(0);
998        state.highlighted_item = Some(0);
999
1000        // Without has_selection set, action should be disabled
1001        assert!(state
1002            .get_highlighted_action(std::slice::from_ref(&select_menu))
1003            .is_none());
1004
1005        // With has_selection set to true, action should be enabled
1006        state.context.set(context_keys::HAS_SELECTION, true);
1007        assert!(state.get_highlighted_action(&[select_menu]).is_some());
1008    }
1009
1010    #[test]
1011    fn test_get_highlighted_action_none_when_closed() {
1012        let state = MenuState::new();
1013        let menus = create_test_menus();
1014        assert!(state.get_highlighted_action(&menus).is_none());
1015    }
1016
1017    #[test]
1018    fn test_get_highlighted_action_none_for_separator() {
1019        let mut state = MenuState::new();
1020        let menus = create_test_menus();
1021        state.open_menu(0);
1022        state.highlighted_item = Some(1); // Separator
1023
1024        assert!(state.get_highlighted_action(&menus).is_none());
1025    }
1026
1027    #[test]
1028    fn test_get_menu_at_position() {
1029        let state = MenuState::new();
1030        let menus = create_test_menus();
1031
1032        // Menu positions: " File " (6 chars) + " " = 7, " Edit " (6 chars) + " " = 7, " View " (6 chars)
1033        // File: x=0-5
1034        assert_eq!(state.get_menu_at_position(&menus, 0), Some(0));
1035        assert_eq!(state.get_menu_at_position(&menus, 3), Some(0));
1036        assert_eq!(state.get_menu_at_position(&menus, 5), Some(0));
1037
1038        // Space between: x=6
1039        assert_eq!(state.get_menu_at_position(&menus, 6), None);
1040
1041        // Edit: x=7-12
1042        assert_eq!(state.get_menu_at_position(&menus, 7), Some(1));
1043        assert_eq!(state.get_menu_at_position(&menus, 10), Some(1));
1044        assert_eq!(state.get_menu_at_position(&menus, 12), Some(1));
1045
1046        // Space between: x=13
1047        assert_eq!(state.get_menu_at_position(&menus, 13), None);
1048
1049        // View: x=14-19
1050        assert_eq!(state.get_menu_at_position(&menus, 14), Some(2));
1051        assert_eq!(state.get_menu_at_position(&menus, 17), Some(2));
1052        assert_eq!(state.get_menu_at_position(&menus, 19), Some(2));
1053
1054        // After View
1055        assert_eq!(state.get_menu_at_position(&menus, 20), None);
1056        assert_eq!(state.get_menu_at_position(&menus, 100), None);
1057    }
1058
1059    #[test]
1060    fn test_get_item_at_position() {
1061        let state = MenuState::new();
1062        let menus = create_test_menus();
1063
1064        // File menu has: New (0), Separator (1), Save (2), Quit (3)
1065        // y=0: menu bar
1066        // y=1: top border
1067        // y=2: first item (New)
1068        // y=3: second item (Separator)
1069        // y=4: third item (Save)
1070        // y=5: fourth item (Quit)
1071        // y=6: bottom border
1072
1073        // y < 2 returns None
1074        assert_eq!(state.get_item_at_position(&menus[0], 0), None);
1075        assert_eq!(state.get_item_at_position(&menus[0], 1), None);
1076
1077        // y=2: New (index 0)
1078        assert_eq!(state.get_item_at_position(&menus[0], 2), Some(0));
1079
1080        // y=3: Separator returns None
1081        assert_eq!(state.get_item_at_position(&menus[0], 3), None);
1082
1083        // y=4: Save (index 2)
1084        assert_eq!(state.get_item_at_position(&menus[0], 4), Some(2));
1085
1086        // y=5: Quit (index 3)
1087        assert_eq!(state.get_item_at_position(&menus[0], 5), Some(3));
1088
1089        // Beyond items
1090        assert_eq!(state.get_item_at_position(&menus[0], 6), None);
1091        assert_eq!(state.get_item_at_position(&menus[0], 100), None);
1092    }
1093
1094    #[test]
1095    fn test_menu_config_json_parsing() {
1096        let json = r#"{
1097            "menus": [
1098                {
1099                    "label": "File",
1100                    "items": [
1101                        { "label": "New", "action": "new_file" },
1102                        { "separator": true },
1103                        { "label": "Save", "action": "save" }
1104                    ]
1105                }
1106            ]
1107        }"#;
1108
1109        let config: MenuConfig = serde_json::from_str(json).unwrap();
1110        assert_eq!(config.menus.len(), 1);
1111        assert_eq!(config.menus[0].label, "File");
1112        assert_eq!(config.menus[0].items.len(), 3);
1113
1114        match &config.menus[0].items[0] {
1115            MenuItem::Action { label, action, .. } => {
1116                assert_eq!(label, "New");
1117                assert_eq!(action, "new_file");
1118            }
1119            _ => panic!("Expected Action"),
1120        }
1121
1122        assert!(matches!(
1123            config.menus[0].items[1],
1124            MenuItem::Separator { .. }
1125        ));
1126
1127        match &config.menus[0].items[2] {
1128            MenuItem::Action { label, action, .. } => {
1129                assert_eq!(label, "Save");
1130                assert_eq!(action, "save");
1131            }
1132            _ => panic!("Expected Action"),
1133        }
1134    }
1135
1136    #[test]
1137    fn test_menu_item_with_args() {
1138        let json = r#"{
1139            "label": "Go to Line",
1140            "action": "goto_line",
1141            "args": { "line": 42 }
1142        }"#;
1143
1144        let item: MenuItem = serde_json::from_str(json).unwrap();
1145        match item {
1146            MenuItem::Action {
1147                label,
1148                action,
1149                args,
1150                ..
1151            } => {
1152                assert_eq!(label, "Go to Line");
1153                assert_eq!(action, "goto_line");
1154                assert_eq!(args.get("line").unwrap().as_i64(), Some(42));
1155            }
1156            _ => panic!("Expected Action with args"),
1157        }
1158    }
1159
1160    #[test]
1161    fn test_empty_menu_config() {
1162        let json = r#"{ "menus": [] }"#;
1163        let config: MenuConfig = serde_json::from_str(json).unwrap();
1164        assert!(config.menus.is_empty());
1165    }
1166
1167    #[test]
1168    fn test_menu_mnemonic_lookup() {
1169        use crate::config::Config;
1170        use crate::input::keybindings::KeybindingResolver;
1171
1172        let config = Config::default();
1173        let resolver = KeybindingResolver::new(&config);
1174
1175        // Check that default Alt+letter bindings are configured
1176        assert_eq!(resolver.find_menu_mnemonic("File"), Some('f'));
1177        assert_eq!(resolver.find_menu_mnemonic("Edit"), Some('e'));
1178        assert_eq!(resolver.find_menu_mnemonic("View"), Some('v'));
1179        assert_eq!(resolver.find_menu_mnemonic("Selection"), Some('s'));
1180        assert_eq!(resolver.find_menu_mnemonic("Go"), Some('g'));
1181        assert_eq!(resolver.find_menu_mnemonic("Help"), Some('h'));
1182
1183        // Case-insensitive matching
1184        assert_eq!(resolver.find_menu_mnemonic("file"), Some('f'));
1185        assert_eq!(resolver.find_menu_mnemonic("FILE"), Some('f'));
1186
1187        // Non-existent menu should return None
1188        assert_eq!(resolver.find_menu_mnemonic("NonExistent"), None);
1189    }
1190
1191    fn create_menu_with_submenus() -> Vec<Menu> {
1192        vec![Menu {
1193            id: None,
1194            label: "View".to_string(),
1195            items: vec![
1196                MenuItem::Action {
1197                    label: "Toggle Explorer".to_string(),
1198                    action: "toggle_file_explorer".to_string(),
1199                    args: HashMap::new(),
1200                    when: None,
1201                    checkbox: None,
1202                },
1203                MenuItem::Submenu {
1204                    label: "Terminal".to_string(),
1205                    items: vec![
1206                        MenuItem::Action {
1207                            label: "Open Terminal".to_string(),
1208                            action: "open_terminal".to_string(),
1209                            args: HashMap::new(),
1210                            when: None,
1211                            checkbox: None,
1212                        },
1213                        MenuItem::Action {
1214                            label: "Close Terminal".to_string(),
1215                            action: "close_terminal".to_string(),
1216                            args: HashMap::new(),
1217                            when: None,
1218                            checkbox: None,
1219                        },
1220                        MenuItem::Submenu {
1221                            label: "Terminal Settings".to_string(),
1222                            items: vec![MenuItem::Action {
1223                                label: "Font Size".to_string(),
1224                                action: "terminal_font_size".to_string(),
1225                                args: HashMap::new(),
1226                                when: None,
1227                                checkbox: None,
1228                            }],
1229                        },
1230                    ],
1231                },
1232                MenuItem::Separator { separator: true },
1233                MenuItem::Action {
1234                    label: "Zoom In".to_string(),
1235                    action: "zoom_in".to_string(),
1236                    args: HashMap::new(),
1237                    when: None,
1238                    checkbox: None,
1239                },
1240            ],
1241        }]
1242    }
1243
1244    #[test]
1245    fn test_submenu_open_and_close() {
1246        let mut state = MenuState::new();
1247        let menus = create_menu_with_submenus();
1248
1249        state.open_menu(0);
1250        assert!(state.submenu_path.is_empty());
1251        assert!(!state.in_submenu());
1252
1253        // Move to Terminal submenu item (index 1)
1254        state.highlighted_item = Some(1);
1255
1256        // Open the submenu
1257        assert!(state.open_submenu(&menus));
1258        assert_eq!(state.submenu_path, vec![1]);
1259        assert!(state.in_submenu());
1260        assert_eq!(state.submenu_depth(), 1);
1261        assert_eq!(state.highlighted_item, Some(0)); // Reset to first item
1262
1263        // Close the submenu
1264        assert!(state.close_submenu());
1265        assert!(state.submenu_path.is_empty());
1266        assert!(!state.in_submenu());
1267        assert_eq!(state.highlighted_item, Some(1)); // Restored to parent item
1268    }
1269
1270    #[test]
1271    fn test_nested_submenu() {
1272        let mut state = MenuState::new();
1273        let menus = create_menu_with_submenus();
1274
1275        state.open_menu(0);
1276        state.highlighted_item = Some(1); // Terminal submenu
1277
1278        // Open first level submenu
1279        assert!(state.open_submenu(&menus));
1280        assert_eq!(state.submenu_depth(), 1);
1281
1282        // Move to Terminal Settings (nested submenu at index 2)
1283        state.highlighted_item = Some(2);
1284
1285        // Open second level submenu
1286        assert!(state.open_submenu(&menus));
1287        assert_eq!(state.submenu_path, vec![1, 2]);
1288        assert_eq!(state.submenu_depth(), 2);
1289        assert_eq!(state.highlighted_item, Some(0));
1290
1291        // Close back to first level
1292        assert!(state.close_submenu());
1293        assert_eq!(state.submenu_path, vec![1]);
1294        assert_eq!(state.highlighted_item, Some(2));
1295
1296        // Close back to main menu
1297        assert!(state.close_submenu());
1298        assert!(state.submenu_path.is_empty());
1299        assert_eq!(state.highlighted_item, Some(1));
1300
1301        // Can't close further
1302        assert!(!state.close_submenu());
1303    }
1304
1305    #[test]
1306    fn test_get_highlighted_action_in_submenu() {
1307        let mut state = MenuState::new();
1308        let menus = create_menu_with_submenus();
1309
1310        state.open_menu(0);
1311        state.highlighted_item = Some(1); // Terminal submenu
1312
1313        // On a submenu item, get_highlighted_action should return None
1314        assert!(state.get_highlighted_action(&menus).is_none());
1315
1316        // Open the submenu
1317        state.open_submenu(&menus);
1318        // Now highlighted_item is 0 which is "Open Terminal"
1319        let action = state.get_highlighted_action(&menus);
1320        assert!(action.is_some());
1321        let (action_name, _) = action.unwrap();
1322        assert_eq!(action_name, "open_terminal");
1323
1324        // Navigate to second item
1325        state.highlighted_item = Some(1);
1326        let action = state.get_highlighted_action(&menus);
1327        assert!(action.is_some());
1328        let (action_name, _) = action.unwrap();
1329        assert_eq!(action_name, "close_terminal");
1330    }
1331
1332    #[test]
1333    fn test_get_current_items_at_different_depths() {
1334        let mut state = MenuState::new();
1335        let menus = create_menu_with_submenus();
1336
1337        state.open_menu(0);
1338
1339        // At top level, should get main menu items
1340        let items = state.get_current_items(&menus, 0).unwrap();
1341        assert_eq!(items.len(), 4); // Action, Submenu, Separator, Action
1342
1343        // Open Terminal submenu
1344        state.highlighted_item = Some(1);
1345        state.open_submenu(&menus);
1346
1347        // Now should get Terminal submenu items
1348        let items = state.get_current_items(&menus, 0).unwrap();
1349        assert_eq!(items.len(), 3); // Open, Close, Settings submenu
1350
1351        // Open nested Terminal Settings submenu
1352        state.highlighted_item = Some(2);
1353        state.open_submenu(&menus);
1354
1355        // Now should get Terminal Settings submenu items
1356        let items = state.get_current_items(&menus, 0).unwrap();
1357        assert_eq!(items.len(), 1); // Font Size
1358    }
1359
1360    #[test]
1361    fn test_is_highlighted_submenu() {
1362        let mut state = MenuState::new();
1363        let menus = create_menu_with_submenus();
1364
1365        state.open_menu(0);
1366        state.highlighted_item = Some(0); // Toggle Explorer (action)
1367        assert!(!state.is_highlighted_submenu(&menus));
1368
1369        state.highlighted_item = Some(1); // Terminal (submenu)
1370        assert!(state.is_highlighted_submenu(&menus));
1371
1372        state.highlighted_item = Some(2); // Separator
1373        assert!(!state.is_highlighted_submenu(&menus));
1374
1375        state.highlighted_item = Some(3); // Zoom In (action)
1376        assert!(!state.is_highlighted_submenu(&menus));
1377    }
1378
1379    #[test]
1380    fn test_open_menu_clears_submenu_path() {
1381        let mut state = MenuState::new();
1382        let menus = create_menu_with_submenus();
1383
1384        state.open_menu(0);
1385        state.highlighted_item = Some(1);
1386        state.open_submenu(&menus);
1387        assert!(!state.submenu_path.is_empty());
1388
1389        // Opening a new menu should clear the submenu path
1390        state.open_menu(0);
1391        assert!(state.submenu_path.is_empty());
1392    }
1393
1394    #[test]
1395    fn test_next_prev_menu_clears_submenu_path() {
1396        let mut state = MenuState::new();
1397        let menus = create_menu_with_submenus();
1398
1399        state.open_menu(0);
1400        state.highlighted_item = Some(1);
1401        state.open_submenu(&menus);
1402        assert!(!state.submenu_path.is_empty());
1403
1404        // next_menu should clear submenu path
1405        state.next_menu(1);
1406        assert!(state.submenu_path.is_empty());
1407
1408        // Re-open submenu
1409        state.open_menu(0);
1410        state.highlighted_item = Some(1);
1411        state.open_submenu(&menus);
1412
1413        // prev_menu should clear submenu path
1414        state.prev_menu(1);
1415        assert!(state.submenu_path.is_empty());
1416    }
1417
1418    #[test]
1419    fn test_navigation_in_submenu() {
1420        let mut state = MenuState::new();
1421        let menus = create_menu_with_submenus();
1422
1423        state.open_menu(0);
1424        state.highlighted_item = Some(1);
1425        state.open_submenu(&menus);
1426
1427        // In Terminal submenu, start at index 0
1428        assert_eq!(state.highlighted_item, Some(0));
1429
1430        // Navigate down
1431        state.next_item(&menus[0]);
1432        assert_eq!(state.highlighted_item, Some(1));
1433
1434        // Navigate down again
1435        state.next_item(&menus[0]);
1436        assert_eq!(state.highlighted_item, Some(2));
1437
1438        // Navigate down wraps to start
1439        state.next_item(&menus[0]);
1440        assert_eq!(state.highlighted_item, Some(0));
1441
1442        // Navigate up wraps to end
1443        state.prev_item(&menus[0]);
1444        assert_eq!(state.highlighted_item, Some(2));
1445    }
1446}