Skip to main content

ratatui_interact/components/
context_menu.rs

1//! Context Menu component - Right-click popup menu
2//!
3//! A context menu component that displays a popup menu at a specified position
4//! with support for actions, separators, and nested submenus.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ratatui_interact::components::{
10//!     ContextMenu, ContextMenuState, ContextMenuStyle, ContextMenuItem,
11//!     handle_context_menu_key, handle_context_menu_mouse,
12//! };
13//! use ratatui::layout::Rect;
14//!
15//! // Create menu items
16//! let items = vec![
17//!     ContextMenuItem::action("copy", "Copy").shortcut("Ctrl+C"),
18//!     ContextMenuItem::action("paste", "Paste").shortcut("Ctrl+V"),
19//!     ContextMenuItem::separator(),
20//!     ContextMenuItem::action("delete", "Delete").icon("🗑"),
21//! ];
22//!
23//! // Create state and open at position
24//! let mut state = ContextMenuState::new();
25//! state.open_at(10, 5);
26//!
27//! // Create context menu widget
28//! let menu = ContextMenu::new(&items, &state);
29//!
30//! // Render and handle events (see handle_context_menu_key, handle_context_menu_mouse)
31//! ```
32
33use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
34use ratatui::{
35    Frame,
36    layout::Rect,
37    style::{Color, Style},
38    text::{Line, Span},
39    widgets::{Block, Borders, Clear, Paragraph},
40};
41
42use crate::traits::ClickRegion;
43
44/// Actions a context menu can emit.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ContextMenuAction {
47    /// Menu was opened.
48    Open,
49    /// Menu was closed.
50    Close,
51    /// An action item was selected (item ID).
52    Select(String),
53    /// A submenu was opened (parent index).
54    SubmenuOpen(usize),
55    /// A submenu was closed.
56    SubmenuClose,
57    /// Highlight changed (new index).
58    HighlightChange(usize),
59}
60
61/// A single item in a context menu.
62#[derive(Debug, Clone)]
63pub enum ContextMenuItem {
64    /// A clickable action item.
65    Action {
66        /// Unique identifier for this action.
67        id: String,
68        /// Display label.
69        label: String,
70        /// Optional icon (emoji or character).
71        icon: Option<String>,
72        /// Optional keyboard shortcut display.
73        shortcut: Option<String>,
74        /// Whether the item is enabled.
75        enabled: bool,
76    },
77    /// A visual separator line.
78    Separator,
79    /// A submenu that opens additional items.
80    Submenu {
81        /// Display label.
82        label: String,
83        /// Optional icon.
84        icon: Option<String>,
85        /// Child menu items.
86        items: Vec<ContextMenuItem>,
87        /// Whether the submenu is enabled.
88        enabled: bool,
89    },
90}
91
92impl ContextMenuItem {
93    /// Create a new action item.
94    pub fn action(id: impl Into<String>, label: impl Into<String>) -> Self {
95        Self::Action {
96            id: id.into(),
97            label: label.into(),
98            icon: None,
99            shortcut: None,
100            enabled: true,
101        }
102    }
103
104    /// Create a separator.
105    pub fn separator() -> Self {
106        Self::Separator
107    }
108
109    /// Create a submenu.
110    pub fn submenu(label: impl Into<String>, items: Vec<ContextMenuItem>) -> Self {
111        Self::Submenu {
112            label: label.into(),
113            icon: None,
114            items,
115            enabled: true,
116        }
117    }
118
119    /// Add an icon to this item.
120    pub fn icon(mut self, icon: impl Into<String>) -> Self {
121        match &mut self {
122            Self::Action { icon: i, .. } => *i = Some(icon.into()),
123            Self::Submenu { icon: i, .. } => *i = Some(icon.into()),
124            Self::Separator => {}
125        }
126        self
127    }
128
129    /// Add a shortcut display to this item.
130    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
131        if let Self::Action { shortcut: s, .. } = &mut self {
132            *s = Some(shortcut.into());
133        }
134        self
135    }
136
137    /// Set whether this item is enabled.
138    pub fn enabled(mut self, enabled: bool) -> Self {
139        match &mut self {
140            Self::Action { enabled: e, .. } => *e = enabled,
141            Self::Submenu { enabled: e, .. } => *e = enabled,
142            Self::Separator => {}
143        }
144        self
145    }
146
147    /// Check if this item is selectable (not a separator and enabled).
148    pub fn is_selectable(&self) -> bool {
149        match self {
150            Self::Action { enabled, .. } => *enabled,
151            Self::Separator => false,
152            Self::Submenu { enabled, .. } => *enabled,
153        }
154    }
155
156    /// Check if this item has a submenu.
157    pub fn has_submenu(&self) -> bool {
158        matches!(self, Self::Submenu { .. })
159    }
160
161    /// Get the ID if this is an action item.
162    pub fn id(&self) -> Option<&str> {
163        if let Self::Action { id, .. } = self {
164            Some(id)
165        } else {
166            None
167        }
168    }
169
170    /// Get the label for this item.
171    pub fn label(&self) -> Option<&str> {
172        match self {
173            Self::Action { label, .. } => Some(label),
174            Self::Submenu { label, .. } => Some(label),
175            Self::Separator => None,
176        }
177    }
178
179    /// Get the icon for this item.
180    pub fn get_icon(&self) -> Option<&str> {
181        match self {
182            Self::Action { icon, .. } => icon.as_deref(),
183            Self::Submenu { icon, .. } => icon.as_deref(),
184            Self::Separator => None,
185        }
186    }
187
188    /// Get the shortcut for this item.
189    pub fn get_shortcut(&self) -> Option<&str> {
190        if let Self::Action { shortcut, .. } = self {
191            shortcut.as_deref()
192        } else {
193            None
194        }
195    }
196
197    /// Check if this item is enabled.
198    pub fn is_enabled(&self) -> bool {
199        match self {
200            Self::Action { enabled, .. } => *enabled,
201            Self::Separator => false,
202            Self::Submenu { enabled, .. } => *enabled,
203        }
204    }
205
206    /// Get submenu items if this is a submenu.
207    pub fn submenu_items(&self) -> Option<&[ContextMenuItem]> {
208        if let Self::Submenu { items, .. } = self {
209            Some(items)
210        } else {
211            None
212        }
213    }
214}
215
216/// State for a context menu.
217#[derive(Debug, Clone)]
218pub struct ContextMenuState {
219    /// Whether the menu is currently open.
220    pub is_open: bool,
221    /// Anchor position (x, y) where menu appears.
222    pub anchor_position: (u16, u16),
223    /// Currently highlighted item index.
224    pub highlighted_index: usize,
225    /// Scroll offset for long menus.
226    pub scroll_offset: u16,
227    /// Index of active submenu (if any).
228    pub active_submenu: Option<usize>,
229    /// State for active submenu (boxed to avoid infinite size).
230    pub submenu_state: Option<Box<ContextMenuState>>,
231}
232
233impl Default for ContextMenuState {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239impl ContextMenuState {
240    /// Create a new context menu state.
241    pub fn new() -> Self {
242        Self {
243            is_open: false,
244            anchor_position: (0, 0),
245            highlighted_index: 0,
246            scroll_offset: 0,
247            active_submenu: None,
248            submenu_state: None,
249        }
250    }
251
252    /// Open the menu at the specified position.
253    pub fn open_at(&mut self, x: u16, y: u16) {
254        self.is_open = true;
255        self.anchor_position = (x, y);
256        self.highlighted_index = 0;
257        self.scroll_offset = 0;
258        self.close_submenu();
259    }
260
261    /// Close the menu.
262    pub fn close(&mut self) {
263        self.is_open = false;
264        self.close_submenu();
265    }
266
267    /// Move highlight to previous selectable item.
268    pub fn highlight_prev(&mut self, items: &[ContextMenuItem]) {
269        if items.is_empty() {
270            return;
271        }
272
273        let mut new_index = self.highlighted_index;
274        loop {
275            if new_index == 0 {
276                break;
277            }
278            new_index -= 1;
279            if items.get(new_index).is_some_and(|i| i.is_selectable()) {
280                self.highlighted_index = new_index;
281                break;
282            }
283        }
284    }
285
286    /// Move highlight to next selectable item.
287    pub fn highlight_next(&mut self, items: &[ContextMenuItem]) {
288        if items.is_empty() {
289            return;
290        }
291
292        let mut new_index = self.highlighted_index;
293        loop {
294            new_index += 1;
295            if new_index >= items.len() {
296                break;
297            }
298            if items.get(new_index).is_some_and(|i| i.is_selectable()) {
299                self.highlighted_index = new_index;
300                break;
301            }
302        }
303    }
304
305    /// Move to first selectable item.
306    pub fn highlight_first(&mut self, items: &[ContextMenuItem]) {
307        for (i, item) in items.iter().enumerate() {
308            if item.is_selectable() {
309                self.highlighted_index = i;
310                self.scroll_offset = 0;
311                break;
312            }
313        }
314    }
315
316    /// Move to last selectable item.
317    pub fn highlight_last(&mut self, items: &[ContextMenuItem]) {
318        for (i, item) in items.iter().enumerate().rev() {
319            if item.is_selectable() {
320                self.highlighted_index = i;
321                break;
322            }
323        }
324    }
325
326    /// Open submenu at the highlighted index.
327    pub fn open_submenu(&mut self) {
328        self.active_submenu = Some(self.highlighted_index);
329        let mut submenu_state = ContextMenuState::new();
330        submenu_state.is_open = true;
331        self.submenu_state = Some(Box::new(submenu_state));
332    }
333
334    /// Close any open submenu.
335    pub fn close_submenu(&mut self) {
336        self.active_submenu = None;
337        self.submenu_state = None;
338    }
339
340    /// Check if a submenu is open.
341    pub fn has_open_submenu(&self) -> bool {
342        self.active_submenu.is_some()
343    }
344
345    /// Ensure highlighted item is visible in viewport.
346    pub fn ensure_visible(&mut self, viewport_height: usize) {
347        if viewport_height == 0 {
348            return;
349        }
350        if self.highlighted_index < self.scroll_offset as usize {
351            self.scroll_offset = self.highlighted_index as u16;
352        } else if self.highlighted_index >= self.scroll_offset as usize + viewport_height {
353            self.scroll_offset = (self.highlighted_index - viewport_height + 1) as u16;
354        }
355    }
356}
357
358/// Style configuration for context menu.
359#[derive(Debug, Clone)]
360pub struct ContextMenuStyle {
361    /// Background color for the menu.
362    pub background: Color,
363    /// Border color.
364    pub border: Color,
365    /// Normal item foreground color.
366    pub normal_fg: Color,
367    /// Highlighted item background.
368    pub highlight_bg: Color,
369    /// Highlighted item foreground.
370    pub highlight_fg: Color,
371    /// Disabled item foreground.
372    pub disabled_fg: Color,
373    /// Shortcut text color.
374    pub shortcut_fg: Color,
375    /// Separator color.
376    pub separator_fg: Color,
377    /// Minimum menu width.
378    pub min_width: u16,
379    /// Maximum menu width.
380    pub max_width: u16,
381    /// Maximum visible items before scrolling.
382    pub max_visible_items: u16,
383    /// Horizontal padding inside menu.
384    pub padding: u16,
385    /// Submenu indicator (e.g., "â–¶").
386    pub submenu_indicator: &'static str,
387    /// Separator character.
388    pub separator_char: char,
389}
390
391impl Default for ContextMenuStyle {
392    fn default() -> Self {
393        Self {
394            background: Color::Rgb(40, 40, 40),
395            border: Color::Rgb(80, 80, 80),
396            normal_fg: Color::White,
397            highlight_bg: Color::Rgb(60, 100, 180),
398            highlight_fg: Color::White,
399            disabled_fg: Color::DarkGray,
400            shortcut_fg: Color::Rgb(140, 140, 140),
401            separator_fg: Color::Rgb(80, 80, 80),
402            min_width: 15,
403            max_width: 50,
404            max_visible_items: 15,
405            padding: 1,
406            submenu_indicator: "â–¶",
407            separator_char: '─',
408        }
409    }
410}
411
412impl From<&crate::theme::Theme> for ContextMenuStyle {
413    fn from(theme: &crate::theme::Theme) -> Self {
414        let p = &theme.palette;
415        Self {
416            background: p.surface,
417            border: p.separator,
418            normal_fg: p.text,
419            highlight_bg: p.menu_highlight_bg,
420            highlight_fg: p.menu_highlight_fg,
421            disabled_fg: p.text_disabled,
422            shortcut_fg: p.text_muted,
423            separator_fg: p.separator,
424            min_width: 15,
425            max_width: 50,
426            max_visible_items: 15,
427            padding: 1,
428            submenu_indicator: "â–¶",
429            separator_char: '─',
430        }
431    }
432}
433
434impl ContextMenuStyle {
435    /// Create a light theme style.
436    pub fn light() -> Self {
437        Self {
438            background: Color::Rgb(250, 250, 250),
439            border: Color::Rgb(180, 180, 180),
440            normal_fg: Color::Rgb(30, 30, 30),
441            highlight_bg: Color::Rgb(0, 120, 215),
442            highlight_fg: Color::White,
443            disabled_fg: Color::Rgb(160, 160, 160),
444            shortcut_fg: Color::Rgb(100, 100, 100),
445            separator_fg: Color::Rgb(200, 200, 200),
446            ..Default::default()
447        }
448    }
449
450    /// Create a minimal style.
451    pub fn minimal() -> Self {
452        Self {
453            background: Color::Reset,
454            border: Color::Gray,
455            normal_fg: Color::White,
456            highlight_bg: Color::Blue,
457            highlight_fg: Color::White,
458            disabled_fg: Color::DarkGray,
459            shortcut_fg: Color::Gray,
460            separator_fg: Color::DarkGray,
461            ..Default::default()
462        }
463    }
464
465    /// Set minimum width.
466    pub fn min_width(mut self, width: u16) -> Self {
467        self.min_width = width;
468        self
469    }
470
471    /// Set maximum width.
472    pub fn max_width(mut self, width: u16) -> Self {
473        self.max_width = width;
474        self
475    }
476
477    /// Set maximum visible items.
478    pub fn max_visible_items(mut self, count: u16) -> Self {
479        self.max_visible_items = count;
480        self
481    }
482
483    /// Set the submenu indicator.
484    pub fn submenu_indicator(mut self, indicator: &'static str) -> Self {
485        self.submenu_indicator = indicator;
486        self
487    }
488
489    /// Set the highlight colors.
490    pub fn highlight(mut self, fg: Color, bg: Color) -> Self {
491        self.highlight_fg = fg;
492        self.highlight_bg = bg;
493        self
494    }
495}
496
497/// Context menu widget.
498///
499/// A popup menu that appears at a specified position, typically triggered
500/// by a right-click event.
501pub struct ContextMenu<'a> {
502    items: &'a [ContextMenuItem],
503    state: &'a ContextMenuState,
504    style: ContextMenuStyle,
505}
506
507impl<'a> ContextMenu<'a> {
508    /// Create a new context menu.
509    pub fn new(items: &'a [ContextMenuItem], state: &'a ContextMenuState) -> Self {
510        Self {
511            items,
512            state,
513            style: ContextMenuStyle::default(),
514        }
515    }
516
517    /// Set the style.
518    pub fn style(mut self, style: ContextMenuStyle) -> Self {
519        self.style = style;
520        self
521    }
522
523    /// Apply a theme to derive the style.
524    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
525        self.style(ContextMenuStyle::from(theme))
526    }
527
528    /// Calculate the required width for the menu.
529    fn calculate_width(&self) -> u16 {
530        let mut max_label_width = 0u16;
531        let mut max_shortcut_width = 0u16;
532
533        for item in self.items {
534            match item {
535                ContextMenuItem::Action {
536                    label,
537                    icon,
538                    shortcut,
539                    ..
540                } => {
541                    let icon_width = icon.as_ref().map(|i| i.chars().count() + 1).unwrap_or(0);
542                    let label_width = label.chars().count() + icon_width;
543                    max_label_width = max_label_width.max(label_width as u16);
544                    if let Some(s) = shortcut {
545                        max_shortcut_width = max_shortcut_width.max(s.chars().count() as u16);
546                    }
547                }
548                ContextMenuItem::Submenu { label, icon, .. } => {
549                    let icon_width = icon.as_ref().map(|i| i.chars().count() + 1).unwrap_or(0);
550                    // +2 for submenu indicator
551                    let label_width = label.chars().count() + icon_width + 2;
552                    max_label_width = max_label_width.max(label_width as u16);
553                }
554                ContextMenuItem::Separator => {}
555            }
556        }
557
558        // Total width: padding + label + gap + shortcut + padding + borders
559        let content_width = self.style.padding
560            + max_label_width
561            + if max_shortcut_width > 0 {
562                2 + max_shortcut_width
563            } else {
564                0
565            }
566            + self.style.padding;
567
568        // Clamp to min/max
569        (content_width + 2) // +2 for borders
570            .max(self.style.min_width)
571            .min(self.style.max_width)
572    }
573
574    /// Calculate the required height for the menu.
575    fn calculate_height(&self) -> u16 {
576        let item_count = self.items.len() as u16;
577        let visible = item_count.min(self.style.max_visible_items);
578        visible + 2 // +2 for borders
579    }
580
581    /// Calculate the menu area based on anchor and screen bounds.
582    fn calculate_menu_area(&self, screen: Rect) -> Rect {
583        let (anchor_x, anchor_y) = self.state.anchor_position;
584        let width = self.calculate_width();
585        let height = self.calculate_height();
586
587        // Prefer right-down positioning, flip if needed
588        let x = if anchor_x + width <= screen.x + screen.width {
589            anchor_x
590        } else {
591            anchor_x.saturating_sub(width)
592        };
593
594        let y = if anchor_y + height <= screen.y + screen.height {
595            anchor_y
596        } else {
597            anchor_y.saturating_sub(height)
598        };
599
600        // Ensure we stay within screen bounds
601        let final_width = width.min(screen.width.saturating_sub(x.saturating_sub(screen.x)));
602        let final_height = height.min(screen.height.saturating_sub(y.saturating_sub(screen.y)));
603
604        Rect::new(x, y, final_width, final_height)
605    }
606
607    /// Render the context menu and return click regions for items.
608    ///
609    /// Returns a tuple of (menu_area, item_click_regions).
610    pub fn render_stateful(
611        &self,
612        frame: &mut Frame,
613        screen: Rect,
614    ) -> (Rect, Vec<ClickRegion<ContextMenuAction>>) {
615        let mut regions = Vec::new();
616
617        if !self.state.is_open || self.items.is_empty() {
618            return (Rect::default(), regions);
619        }
620
621        let menu_area = self.calculate_menu_area(screen);
622
623        // Clear background (overlay)
624        frame.render_widget(Clear, menu_area);
625
626        // Render border
627        let block = Block::default()
628            .borders(Borders::ALL)
629            .border_style(Style::default().fg(self.style.border))
630            .style(Style::default().bg(self.style.background));
631
632        let inner = block.inner(menu_area);
633        frame.render_widget(block, menu_area);
634
635        // Render items
636        let visible_count = inner.height as usize;
637        let scroll = self.state.scroll_offset as usize;
638
639        for (display_idx, (item_idx, item)) in self
640            .items
641            .iter()
642            .enumerate()
643            .skip(scroll)
644            .take(visible_count)
645            .enumerate()
646        {
647            let y = inner.y + display_idx as u16;
648            let item_area = Rect::new(inner.x, y, inner.width, 1);
649
650            let is_highlighted = item_idx == self.state.highlighted_index;
651
652            match item {
653                ContextMenuItem::Separator => {
654                    // Render separator line
655                    let sep_line: String =
656                        std::iter::repeat_n(self.style.separator_char, inner.width as usize)
657                            .collect();
658                    let para = Paragraph::new(Span::styled(
659                        sep_line,
660                        Style::default().fg(self.style.separator_fg),
661                    ));
662                    frame.render_widget(para, item_area);
663                }
664                ContextMenuItem::Action {
665                    label,
666                    icon,
667                    shortcut,
668                    enabled,
669                    id,
670                } => {
671                    let (fg, bg) = if !enabled {
672                        (self.style.disabled_fg, self.style.background)
673                    } else if is_highlighted {
674                        (self.style.highlight_fg, self.style.highlight_bg)
675                    } else {
676                        (self.style.normal_fg, self.style.background)
677                    };
678
679                    let style = Style::default().fg(fg).bg(bg);
680                    let shortcut_style = Style::default()
681                        .fg(if *enabled {
682                            self.style.shortcut_fg
683                        } else {
684                            self.style.disabled_fg
685                        })
686                        .bg(bg);
687
688                    let mut spans = Vec::new();
689
690                    // Padding
691                    spans.push(Span::styled(" ".repeat(self.style.padding as usize), style));
692
693                    // Icon
694                    if let Some(ic) = icon {
695                        spans.push(Span::styled(format!("{} ", ic), style));
696                    }
697
698                    // Label
699                    spans.push(Span::styled(label.clone(), style));
700
701                    // Fill space before shortcut
702                    let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
703                    let shortcut_len = shortcut.as_ref().map(|s| s.chars().count()).unwrap_or(0);
704                    let fill_len = (inner.width as usize)
705                        .saturating_sub(current_len)
706                        .saturating_sub(shortcut_len)
707                        .saturating_sub(self.style.padding as usize);
708
709                    if fill_len > 0 {
710                        spans.push(Span::styled(" ".repeat(fill_len), style));
711                    }
712
713                    // Shortcut
714                    if let Some(sc) = shortcut {
715                        spans.push(Span::styled(sc.clone(), shortcut_style));
716                    }
717
718                    // Right padding
719                    spans.push(Span::styled(" ".repeat(self.style.padding as usize), style));
720
721                    let para = Paragraph::new(Line::from(spans));
722                    frame.render_widget(para, item_area);
723
724                    // Register click region
725                    if *enabled {
726                        regions.push(ClickRegion::new(
727                            item_area,
728                            ContextMenuAction::Select(id.clone()),
729                        ));
730                    }
731                }
732                ContextMenuItem::Submenu {
733                    label,
734                    icon,
735                    enabled,
736                    ..
737                } => {
738                    let (fg, bg) = if !enabled {
739                        (self.style.disabled_fg, self.style.background)
740                    } else if is_highlighted {
741                        (self.style.highlight_fg, self.style.highlight_bg)
742                    } else {
743                        (self.style.normal_fg, self.style.background)
744                    };
745
746                    let style = Style::default().fg(fg).bg(bg);
747
748                    let mut spans = Vec::new();
749
750                    // Padding
751                    spans.push(Span::styled(" ".repeat(self.style.padding as usize), style));
752
753                    // Icon
754                    if let Some(ic) = icon {
755                        spans.push(Span::styled(format!("{} ", ic), style));
756                    }
757
758                    // Label
759                    spans.push(Span::styled(label.clone(), style));
760
761                    // Fill and submenu indicator
762                    let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
763                    let indicator_len = self.style.submenu_indicator.chars().count();
764                    let fill_len = (inner.width as usize)
765                        .saturating_sub(current_len)
766                        .saturating_sub(indicator_len)
767                        .saturating_sub(self.style.padding as usize);
768
769                    if fill_len > 0 {
770                        spans.push(Span::styled(" ".repeat(fill_len), style));
771                    }
772
773                    spans.push(Span::styled(self.style.submenu_indicator, style));
774
775                    // Right padding
776                    spans.push(Span::styled(" ".repeat(self.style.padding as usize), style));
777
778                    let para = Paragraph::new(Line::from(spans));
779                    frame.render_widget(para, item_area);
780
781                    // Register click region for submenu
782                    if *enabled {
783                        regions.push(ClickRegion::new(
784                            item_area,
785                            ContextMenuAction::SubmenuOpen(item_idx),
786                        ));
787                    }
788                }
789            }
790        }
791
792        // Render submenu if open
793        if let (Some(submenu_idx), Some(submenu_state)) =
794            (self.state.active_submenu, &self.state.submenu_state)
795        {
796            if let Some(ContextMenuItem::Submenu { items, .. }) = self.items.get(submenu_idx) {
797                // Position submenu to the right of the parent item
798                let submenu_anchor_x = menu_area.x + menu_area.width;
799                let submenu_anchor_y =
800                    menu_area.y + 1 + (submenu_idx as u16).saturating_sub(self.state.scroll_offset);
801
802                let mut adjusted_state = (**submenu_state).clone();
803                adjusted_state.anchor_position = (submenu_anchor_x, submenu_anchor_y);
804
805                let adjusted_submenu =
806                    ContextMenu::new(items, &adjusted_state).style(self.style.clone());
807
808                let (_, submenu_regions) = adjusted_submenu.render_stateful(frame, screen);
809                regions.extend(submenu_regions);
810            }
811        }
812
813        (menu_area, regions)
814    }
815}
816
817/// Handle keyboard events for context menu.
818///
819/// Returns `Some(ContextMenuAction)` if an action was triggered, `None` otherwise.
820///
821/// # Key Bindings
822///
823/// - `Esc` - Close menu
824/// - `Up` - Move highlight up
825/// - `Down` - Move highlight down
826/// - `Enter`, `Space` - Select highlighted item
827/// - `Right` - Open submenu (if item has one)
828/// - `Left` - Close submenu (if one is open)
829/// - `Home` - Move to first item
830/// - `End` - Move to last item
831pub fn handle_context_menu_key(
832    key: &KeyEvent,
833    state: &mut ContextMenuState,
834    items: &[ContextMenuItem],
835) -> Option<ContextMenuAction> {
836    if !state.is_open {
837        return None;
838    }
839
840    // If submenu is open, delegate to it first
841    if let (Some(submenu_idx), Some(submenu_state)) =
842        (state.active_submenu, &mut state.submenu_state)
843    {
844        if let Some(ContextMenuItem::Submenu {
845            items: sub_items, ..
846        }) = items.get(submenu_idx)
847        {
848            match key.code {
849                KeyCode::Left | KeyCode::Esc => {
850                    state.close_submenu();
851                    return Some(ContextMenuAction::SubmenuClose);
852                }
853                _ => {
854                    if let Some(action) =
855                        handle_context_menu_key(key, submenu_state.as_mut(), sub_items)
856                    {
857                        return Some(action);
858                    }
859                }
860            }
861            return None;
862        }
863    }
864
865    match key.code {
866        KeyCode::Esc => {
867            state.close();
868            Some(ContextMenuAction::Close)
869        }
870        KeyCode::Up => {
871            state.highlight_prev(items);
872            state.ensure_visible(8);
873            Some(ContextMenuAction::HighlightChange(state.highlighted_index))
874        }
875        KeyCode::Down => {
876            state.highlight_next(items);
877            state.ensure_visible(8);
878            Some(ContextMenuAction::HighlightChange(state.highlighted_index))
879        }
880        KeyCode::Home => {
881            state.highlight_first(items);
882            Some(ContextMenuAction::HighlightChange(state.highlighted_index))
883        }
884        KeyCode::End => {
885            state.highlight_last(items);
886            state.ensure_visible(items.len());
887            Some(ContextMenuAction::HighlightChange(state.highlighted_index))
888        }
889        KeyCode::Enter | KeyCode::Char(' ') => {
890            if let Some(item) = items.get(state.highlighted_index) {
891                match item {
892                    ContextMenuItem::Action { id, enabled, .. } if *enabled => {
893                        let action_id = id.clone();
894                        state.close();
895                        Some(ContextMenuAction::Select(action_id))
896                    }
897                    ContextMenuItem::Submenu { enabled, .. } if *enabled => {
898                        state.open_submenu();
899                        Some(ContextMenuAction::SubmenuOpen(state.highlighted_index))
900                    }
901                    _ => None,
902                }
903            } else {
904                None
905            }
906        }
907        KeyCode::Right => {
908            if let Some(item) = items.get(state.highlighted_index) {
909                if item.has_submenu() && item.is_enabled() {
910                    state.open_submenu();
911                    return Some(ContextMenuAction::SubmenuOpen(state.highlighted_index));
912                }
913            }
914            None
915        }
916        KeyCode::Left => {
917            // Close current menu level (handled by parent)
918            None
919        }
920        _ => None,
921    }
922}
923
924/// Handle mouse events for context menu.
925///
926/// Returns `Some(ContextMenuAction)` if an action was triggered, `None` otherwise.
927///
928/// # Arguments
929///
930/// * `mouse` - The mouse event
931/// * `state` - Mutable reference to context menu state
932/// * `menu_area` - The rendered menu area
933/// * `item_regions` - Click regions from `render_stateful`
934pub fn handle_context_menu_mouse(
935    mouse: &MouseEvent,
936    state: &mut ContextMenuState,
937    menu_area: Rect,
938    item_regions: &[ClickRegion<ContextMenuAction>],
939) -> Option<ContextMenuAction> {
940    if !state.is_open {
941        return None;
942    }
943
944    let col = mouse.column;
945    let row = mouse.row;
946
947    match mouse.kind {
948        MouseEventKind::Down(MouseButton::Left) => {
949            // Check if clicked on an item
950            for region in item_regions {
951                if region.contains(col, row) {
952                    match &region.data {
953                        ContextMenuAction::Select(id) => {
954                            let action_id = id.clone();
955                            state.close();
956                            return Some(ContextMenuAction::Select(action_id));
957                        }
958                        ContextMenuAction::SubmenuOpen(idx) => {
959                            state.highlighted_index = *idx;
960                            state.open_submenu();
961                            return Some(ContextMenuAction::SubmenuOpen(*idx));
962                        }
963                        _ => {}
964                    }
965                }
966            }
967
968            // Check if clicked outside menu
969            if !menu_area.intersects(Rect::new(col, row, 1, 1)) {
970                state.close();
971                return Some(ContextMenuAction::Close);
972            }
973            None
974        }
975        MouseEventKind::Moved => {
976            // Update highlight on hover
977            for region in item_regions.iter() {
978                if region.contains(col, row) {
979                    // Find the actual item index from the region
980                    if let ContextMenuAction::Select(_) | ContextMenuAction::SubmenuOpen(_) =
981                        &region.data
982                    {
983                        // The item_regions index may not match the items index due to separators
984                        // We need to find the corresponding item
985                        let inner_start_y = menu_area.y + 1; // +1 for border
986                        let item_idx =
987                            (row - inner_start_y) as usize + state.scroll_offset as usize;
988
989                        if item_idx < item_regions.len() + state.scroll_offset as usize
990                            && state.highlighted_index != item_idx
991                        {
992                            state.highlighted_index = item_idx;
993                            return Some(ContextMenuAction::HighlightChange(item_idx));
994                        }
995                    }
996                    break;
997                }
998            }
999            None
1000        }
1001        _ => None,
1002    }
1003}
1004
1005/// Check if a mouse event is a context menu trigger (right-click).
1006pub fn is_context_menu_trigger(mouse: &MouseEvent) -> bool {
1007    matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1008}
1009
1010/// Calculate the height needed for a context menu.
1011pub fn calculate_menu_height(item_count: usize, max_visible: u16) -> u16 {
1012    let visible = (item_count as u16).min(max_visible);
1013    visible + 2 // +2 for borders
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019
1020    #[test]
1021    fn test_context_menu_item_action() {
1022        let item = ContextMenuItem::action("copy", "Copy")
1023            .icon("📋")
1024            .shortcut("Ctrl+C");
1025
1026        assert!(item.is_selectable());
1027        assert!(!item.has_submenu());
1028        assert_eq!(item.id(), Some("copy"));
1029        assert_eq!(item.label(), Some("Copy"));
1030        assert_eq!(item.get_icon(), Some("📋"));
1031        assert_eq!(item.get_shortcut(), Some("Ctrl+C"));
1032    }
1033
1034    #[test]
1035    fn test_context_menu_item_separator() {
1036        let item = ContextMenuItem::separator();
1037
1038        assert!(!item.is_selectable());
1039        assert!(!item.has_submenu());
1040        assert_eq!(item.label(), None);
1041    }
1042
1043    #[test]
1044    fn test_context_menu_item_submenu() {
1045        let items = vec![ContextMenuItem::action("sub1", "Sub Item 1")];
1046        let item = ContextMenuItem::submenu("More", items).icon("â–¶");
1047
1048        assert!(item.is_selectable());
1049        assert!(item.has_submenu());
1050        assert_eq!(item.label(), Some("More"));
1051        assert!(item.submenu_items().is_some());
1052    }
1053
1054    #[test]
1055    fn test_context_menu_item_disabled() {
1056        let item = ContextMenuItem::action("delete", "Delete").enabled(false);
1057
1058        assert!(!item.is_selectable());
1059        assert!(!item.is_enabled());
1060    }
1061
1062    #[test]
1063    fn test_context_menu_state_open_close() {
1064        let mut state = ContextMenuState::new();
1065
1066        assert!(!state.is_open);
1067
1068        state.open_at(10, 5);
1069        assert!(state.is_open);
1070        assert_eq!(state.anchor_position, (10, 5));
1071        assert_eq!(state.highlighted_index, 0);
1072
1073        state.close();
1074        assert!(!state.is_open);
1075    }
1076
1077    #[test]
1078    fn test_context_menu_state_navigation() {
1079        let mut state = ContextMenuState::new();
1080        state.open_at(0, 0);
1081
1082        let items = vec![
1083            ContextMenuItem::action("a", "A"),
1084            ContextMenuItem::separator(),
1085            ContextMenuItem::action("b", "B"),
1086            ContextMenuItem::action("c", "C"),
1087        ];
1088
1089        // Start at index 0
1090        assert_eq!(state.highlighted_index, 0);
1091
1092        // Move down (should skip separator)
1093        state.highlight_next(&items);
1094        assert_eq!(state.highlighted_index, 2); // Skipped separator at 1
1095
1096        // Move down again
1097        state.highlight_next(&items);
1098        assert_eq!(state.highlighted_index, 3);
1099
1100        // Move up
1101        state.highlight_prev(&items);
1102        assert_eq!(state.highlighted_index, 2);
1103
1104        // Move up again (should skip separator)
1105        state.highlight_prev(&items);
1106        assert_eq!(state.highlighted_index, 0);
1107    }
1108
1109    #[test]
1110    fn test_context_menu_state_submenu() {
1111        let mut state = ContextMenuState::new();
1112        state.open_at(0, 0);
1113        state.highlighted_index = 2;
1114
1115        assert!(!state.has_open_submenu());
1116
1117        state.open_submenu();
1118        assert!(state.has_open_submenu());
1119        assert_eq!(state.active_submenu, Some(2));
1120        assert!(state.submenu_state.is_some());
1121
1122        state.close_submenu();
1123        assert!(!state.has_open_submenu());
1124        assert!(state.submenu_state.is_none());
1125    }
1126
1127    #[test]
1128    fn test_context_menu_style_default() {
1129        let style = ContextMenuStyle::default();
1130        assert_eq!(style.min_width, 15);
1131        assert_eq!(style.max_width, 50);
1132        assert_eq!(style.max_visible_items, 15);
1133        assert_eq!(style.submenu_indicator, "â–¶");
1134    }
1135
1136    #[test]
1137    fn test_context_menu_style_builders() {
1138        let style = ContextMenuStyle::default()
1139            .min_width(20)
1140            .max_width(60)
1141            .max_visible_items(10)
1142            .submenu_indicator("→");
1143
1144        assert_eq!(style.min_width, 20);
1145        assert_eq!(style.max_width, 60);
1146        assert_eq!(style.max_visible_items, 10);
1147        assert_eq!(style.submenu_indicator, "→");
1148    }
1149
1150    #[test]
1151    fn test_context_menu_style_presets() {
1152        let light = ContextMenuStyle::light();
1153        assert_eq!(light.background, Color::Rgb(250, 250, 250));
1154
1155        let minimal = ContextMenuStyle::minimal();
1156        assert_eq!(minimal.background, Color::Reset);
1157    }
1158
1159    #[test]
1160    fn test_handle_key_escape() {
1161        let mut state = ContextMenuState::new();
1162        state.open_at(0, 0);
1163
1164        let items = vec![ContextMenuItem::action("a", "A")];
1165        let key = KeyEvent::from(KeyCode::Esc);
1166        let action = handle_context_menu_key(&key, &mut state, &items);
1167
1168        assert_eq!(action, Some(ContextMenuAction::Close));
1169        assert!(!state.is_open);
1170    }
1171
1172    #[test]
1173    fn test_handle_key_navigation() {
1174        let mut state = ContextMenuState::new();
1175        state.open_at(0, 0);
1176
1177        let items = vec![
1178            ContextMenuItem::action("a", "A"),
1179            ContextMenuItem::action("b", "B"),
1180            ContextMenuItem::action("c", "C"),
1181        ];
1182
1183        // Down
1184        let key = KeyEvent::from(KeyCode::Down);
1185        let action = handle_context_menu_key(&key, &mut state, &items);
1186        assert_eq!(action, Some(ContextMenuAction::HighlightChange(1)));
1187        assert_eq!(state.highlighted_index, 1);
1188
1189        // Up
1190        let key = KeyEvent::from(KeyCode::Up);
1191        let action = handle_context_menu_key(&key, &mut state, &items);
1192        assert_eq!(action, Some(ContextMenuAction::HighlightChange(0)));
1193        assert_eq!(state.highlighted_index, 0);
1194    }
1195
1196    #[test]
1197    fn test_handle_key_select() {
1198        let mut state = ContextMenuState::new();
1199        state.open_at(0, 0);
1200        state.highlighted_index = 1;
1201
1202        let items = vec![
1203            ContextMenuItem::action("a", "A"),
1204            ContextMenuItem::action("b", "B"),
1205        ];
1206
1207        let key = KeyEvent::from(KeyCode::Enter);
1208        let action = handle_context_menu_key(&key, &mut state, &items);
1209
1210        assert_eq!(action, Some(ContextMenuAction::Select("b".to_string())));
1211        assert!(!state.is_open);
1212    }
1213
1214    #[test]
1215    fn test_is_context_menu_trigger() {
1216        use crossterm::event::KeyModifiers;
1217
1218        let right_click = MouseEvent {
1219            kind: MouseEventKind::Down(MouseButton::Right),
1220            column: 10,
1221            row: 5,
1222            modifiers: KeyModifiers::NONE,
1223        };
1224        assert!(is_context_menu_trigger(&right_click));
1225
1226        let left_click = MouseEvent {
1227            kind: MouseEventKind::Down(MouseButton::Left),
1228            column: 10,
1229            row: 5,
1230            modifiers: KeyModifiers::NONE,
1231        };
1232        assert!(!is_context_menu_trigger(&left_click));
1233    }
1234
1235    #[test]
1236    fn test_calculate_menu_height() {
1237        assert_eq!(calculate_menu_height(5, 15), 7); // 5 + 2
1238        assert_eq!(calculate_menu_height(20, 15), 17); // 15 + 2 (clamped)
1239        assert_eq!(calculate_menu_height(0, 15), 2); // 0 + 2
1240    }
1241
1242    // Additional comprehensive tests
1243
1244    #[test]
1245    fn test_context_menu_item_icon_on_separator() {
1246        // Icon should not affect separators
1247        let item = ContextMenuItem::separator().icon("x");
1248        assert_eq!(item.get_icon(), None);
1249    }
1250
1251    #[test]
1252    fn test_context_menu_item_shortcut_on_submenu() {
1253        // Shortcut should not affect submenus
1254        let item = ContextMenuItem::submenu("Menu", vec![]).shortcut("Ctrl+X");
1255        assert_eq!(item.get_shortcut(), None);
1256    }
1257
1258    #[test]
1259    fn test_context_menu_item_enabled_on_separator() {
1260        // Enabled should not affect separators (always false)
1261        let item = ContextMenuItem::separator().enabled(true);
1262        assert!(!item.is_enabled());
1263    }
1264
1265    #[test]
1266    fn test_context_menu_item_submenu_items() {
1267        let sub_items = vec![
1268            ContextMenuItem::action("a", "A"),
1269            ContextMenuItem::action("b", "B"),
1270        ];
1271        let item = ContextMenuItem::submenu("Menu", sub_items);
1272        let items = item.submenu_items().unwrap();
1273        assert_eq!(items.len(), 2);
1274    }
1275
1276    #[test]
1277    fn test_context_menu_item_action_no_submenu_items() {
1278        let item = ContextMenuItem::action("test", "Test");
1279        assert!(item.submenu_items().is_none());
1280    }
1281
1282    #[test]
1283    fn test_context_menu_state_default() {
1284        let state = ContextMenuState::default();
1285        assert!(!state.is_open);
1286        assert_eq!(state.anchor_position, (0, 0));
1287        assert_eq!(state.highlighted_index, 0);
1288        assert_eq!(state.scroll_offset, 0);
1289        assert!(state.active_submenu.is_none());
1290        assert!(state.submenu_state.is_none());
1291    }
1292
1293    #[test]
1294    fn test_context_menu_state_open_resets_state() {
1295        let mut state = ContextMenuState::new();
1296        state.highlighted_index = 5;
1297        state.scroll_offset = 10;
1298        state.open_submenu();
1299
1300        state.open_at(20, 30);
1301
1302        assert!(state.is_open);
1303        assert_eq!(state.anchor_position, (20, 30));
1304        assert_eq!(state.highlighted_index, 0);
1305        assert_eq!(state.scroll_offset, 0);
1306        assert!(!state.has_open_submenu());
1307    }
1308
1309    #[test]
1310    fn test_context_menu_state_highlight_first_last() {
1311        let mut state = ContextMenuState::new();
1312        state.open_at(0, 0);
1313
1314        let items = vec![
1315            ContextMenuItem::separator(),      // index 0 - not selectable
1316            ContextMenuItem::action("a", "A"), // index 1
1317            ContextMenuItem::action("b", "B"), // index 2
1318            ContextMenuItem::separator(),      // index 3 - not selectable
1319            ContextMenuItem::action("c", "C"), // index 4
1320        ];
1321
1322        state.highlight_first(&items);
1323        assert_eq!(state.highlighted_index, 1); // First selectable
1324
1325        state.highlight_last(&items);
1326        assert_eq!(state.highlighted_index, 4); // Last selectable
1327    }
1328
1329    #[test]
1330    fn test_context_menu_state_navigation_bounds() {
1331        let mut state = ContextMenuState::new();
1332        state.open_at(0, 0);
1333        state.highlighted_index = 0;
1334
1335        let items = vec![
1336            ContextMenuItem::action("a", "A"),
1337            ContextMenuItem::action("b", "B"),
1338        ];
1339
1340        // Try to go before first
1341        state.highlight_prev(&items);
1342        assert_eq!(state.highlighted_index, 0);
1343
1344        // Go to last
1345        state.highlighted_index = 1;
1346        // Try to go past last
1347        state.highlight_next(&items);
1348        assert_eq!(state.highlighted_index, 1);
1349    }
1350
1351    #[test]
1352    fn test_context_menu_state_navigation_empty_items() {
1353        let mut state = ContextMenuState::new();
1354        state.open_at(0, 0);
1355        state.highlighted_index = 5;
1356
1357        let items: Vec<ContextMenuItem> = vec![];
1358
1359        state.highlight_next(&items);
1360        assert_eq!(state.highlighted_index, 5); // Unchanged
1361
1362        state.highlight_prev(&items);
1363        assert_eq!(state.highlighted_index, 5); // Unchanged
1364    }
1365
1366    #[test]
1367    fn test_context_menu_state_ensure_visible() {
1368        let mut state = ContextMenuState::new();
1369        state.highlighted_index = 15;
1370        state.scroll_offset = 0;
1371
1372        state.ensure_visible(10);
1373        // 15 - 10 + 1 = 6
1374        assert!(state.scroll_offset >= 6);
1375
1376        // Scroll back up
1377        state.highlighted_index = 3;
1378        state.ensure_visible(10);
1379        assert!(state.scroll_offset <= 3);
1380    }
1381
1382    #[test]
1383    fn test_context_menu_state_ensure_visible_zero_viewport() {
1384        let mut state = ContextMenuState::new();
1385        state.highlighted_index = 10;
1386        state.scroll_offset = 5;
1387
1388        // Zero viewport should not change anything
1389        state.ensure_visible(0);
1390        assert_eq!(state.scroll_offset, 5);
1391    }
1392
1393    #[test]
1394    fn test_context_menu_style_highlight() {
1395        let style = ContextMenuStyle::default().highlight(Color::Red, Color::Blue);
1396
1397        assert_eq!(style.highlight_fg, Color::Red);
1398        assert_eq!(style.highlight_bg, Color::Blue);
1399    }
1400
1401    #[test]
1402    fn test_handle_key_when_closed() {
1403        let mut state = ContextMenuState::new();
1404        assert!(!state.is_open);
1405
1406        let items = vec![ContextMenuItem::action("a", "A")];
1407        let key = KeyEvent::from(KeyCode::Down);
1408        let action = handle_context_menu_key(&key, &mut state, &items);
1409
1410        assert!(action.is_none());
1411    }
1412
1413    #[test]
1414    fn test_handle_key_space_select() {
1415        let mut state = ContextMenuState::new();
1416        state.open_at(0, 0);
1417
1418        let items = vec![ContextMenuItem::action("a", "Action A")];
1419
1420        let key = KeyEvent::from(KeyCode::Char(' '));
1421        let action = handle_context_menu_key(&key, &mut state, &items);
1422
1423        assert_eq!(action, Some(ContextMenuAction::Select("a".to_string())));
1424        assert!(!state.is_open);
1425    }
1426
1427    #[test]
1428    fn test_handle_key_home_end() {
1429        let mut state = ContextMenuState::new();
1430        state.open_at(0, 0);
1431
1432        let items = vec![
1433            ContextMenuItem::action("a", "A"),
1434            ContextMenuItem::action("b", "B"),
1435            ContextMenuItem::action("c", "C"),
1436            ContextMenuItem::action("d", "D"),
1437        ];
1438
1439        // End
1440        let key = KeyEvent::from(KeyCode::End);
1441        let action = handle_context_menu_key(&key, &mut state, &items);
1442        assert_eq!(action, Some(ContextMenuAction::HighlightChange(3)));
1443        assert_eq!(state.highlighted_index, 3);
1444
1445        // Home
1446        let key = KeyEvent::from(KeyCode::Home);
1447        let action = handle_context_menu_key(&key, &mut state, &items);
1448        assert_eq!(action, Some(ContextMenuAction::HighlightChange(0)));
1449        assert_eq!(state.highlighted_index, 0);
1450    }
1451
1452    #[test]
1453    fn test_handle_key_select_disabled_item() {
1454        let mut state = ContextMenuState::new();
1455        state.open_at(0, 0);
1456
1457        let items = vec![ContextMenuItem::action("a", "A").enabled(false)];
1458
1459        let key = KeyEvent::from(KeyCode::Enter);
1460        let action = handle_context_menu_key(&key, &mut state, &items);
1461
1462        // Should not select disabled item
1463        assert!(action.is_none());
1464        assert!(state.is_open); // Still open
1465    }
1466
1467    #[test]
1468    fn test_handle_key_open_submenu() {
1469        let mut state = ContextMenuState::new();
1470        state.open_at(0, 0);
1471
1472        let items = vec![ContextMenuItem::submenu(
1473            "More",
1474            vec![ContextMenuItem::action("sub", "Sub Action")],
1475        )];
1476
1477        // Enter to open submenu
1478        let key = KeyEvent::from(KeyCode::Enter);
1479        let action = handle_context_menu_key(&key, &mut state, &items);
1480
1481        assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1482        assert!(state.has_open_submenu());
1483    }
1484
1485    #[test]
1486    fn test_handle_key_right_arrow_submenu() {
1487        let mut state = ContextMenuState::new();
1488        state.open_at(0, 0);
1489
1490        let items = vec![ContextMenuItem::submenu(
1491            "More",
1492            vec![ContextMenuItem::action("sub", "Sub Action")],
1493        )];
1494
1495        // Right arrow to open submenu
1496        let key = KeyEvent::from(KeyCode::Right);
1497        let action = handle_context_menu_key(&key, &mut state, &items);
1498
1499        assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1500        assert!(state.has_open_submenu());
1501    }
1502
1503    #[test]
1504    fn test_handle_key_right_arrow_no_submenu() {
1505        let mut state = ContextMenuState::new();
1506        state.open_at(0, 0);
1507
1508        let items = vec![ContextMenuItem::action("a", "A")];
1509
1510        // Right arrow on non-submenu item
1511        let key = KeyEvent::from(KeyCode::Right);
1512        let action = handle_context_menu_key(&key, &mut state, &items);
1513
1514        assert!(action.is_none());
1515        assert!(!state.has_open_submenu());
1516    }
1517
1518    #[test]
1519    fn test_handle_key_left_arrow() {
1520        let mut state = ContextMenuState::new();
1521        state.open_at(0, 0);
1522
1523        let items = vec![ContextMenuItem::action("a", "A")];
1524
1525        // Left arrow (no effect at top level)
1526        let key = KeyEvent::from(KeyCode::Left);
1527        let action = handle_context_menu_key(&key, &mut state, &items);
1528
1529        assert!(action.is_none());
1530    }
1531
1532    #[test]
1533    fn test_handle_key_unknown_key() {
1534        let mut state = ContextMenuState::new();
1535        state.open_at(0, 0);
1536
1537        let items = vec![ContextMenuItem::action("a", "A")];
1538
1539        // Unknown key should be ignored
1540        let key = KeyEvent::from(KeyCode::Char('x'));
1541        let action = handle_context_menu_key(&key, &mut state, &items);
1542
1543        assert!(action.is_none());
1544        assert!(state.is_open);
1545    }
1546
1547    #[test]
1548    fn test_handle_mouse_when_closed() {
1549        use crossterm::event::KeyModifiers;
1550
1551        let mut state = ContextMenuState::new();
1552        assert!(!state.is_open);
1553
1554        let mouse = MouseEvent {
1555            kind: MouseEventKind::Down(MouseButton::Left),
1556            column: 10,
1557            row: 5,
1558            modifiers: KeyModifiers::NONE,
1559        };
1560
1561        let action = handle_context_menu_mouse(&mouse, &mut state, Rect::default(), &[]);
1562
1563        assert!(action.is_none());
1564    }
1565
1566    #[test]
1567    fn test_handle_mouse_click_outside() {
1568        use crossterm::event::KeyModifiers;
1569
1570        let mut state = ContextMenuState::new();
1571        state.open_at(10, 10);
1572
1573        let menu_area = Rect::new(10, 10, 20, 10);
1574
1575        // Click outside menu
1576        let mouse = MouseEvent {
1577            kind: MouseEventKind::Down(MouseButton::Left),
1578            column: 5,
1579            row: 5,
1580            modifiers: KeyModifiers::NONE,
1581        };
1582
1583        let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, &[]);
1584
1585        assert_eq!(action, Some(ContextMenuAction::Close));
1586        assert!(!state.is_open);
1587    }
1588
1589    #[test]
1590    fn test_handle_mouse_click_item() {
1591        use crate::traits::ClickRegion;
1592        use crossterm::event::KeyModifiers;
1593
1594        let mut state = ContextMenuState::new();
1595        state.open_at(10, 10);
1596
1597        let menu_area = Rect::new(10, 10, 20, 10);
1598        let item_area = Rect::new(11, 11, 18, 1);
1599        let regions = vec![ClickRegion::new(
1600            item_area,
1601            ContextMenuAction::Select("test".to_string()),
1602        )];
1603
1604        // Click on item
1605        let mouse = MouseEvent {
1606            kind: MouseEventKind::Down(MouseButton::Left),
1607            column: 15,
1608            row: 11,
1609            modifiers: KeyModifiers::NONE,
1610        };
1611
1612        let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, &regions);
1613
1614        assert_eq!(action, Some(ContextMenuAction::Select("test".to_string())));
1615        assert!(!state.is_open);
1616    }
1617
1618    #[test]
1619    fn test_handle_mouse_click_submenu_item() {
1620        use crate::traits::ClickRegion;
1621        use crossterm::event::KeyModifiers;
1622
1623        let mut state = ContextMenuState::new();
1624        state.open_at(10, 10);
1625
1626        let menu_area = Rect::new(10, 10, 20, 10);
1627        let item_area = Rect::new(11, 11, 18, 1);
1628        let regions = vec![ClickRegion::new(
1629            item_area,
1630            ContextMenuAction::SubmenuOpen(0),
1631        )];
1632
1633        // Click on submenu item
1634        let mouse = MouseEvent {
1635            kind: MouseEventKind::Down(MouseButton::Left),
1636            column: 15,
1637            row: 11,
1638            modifiers: KeyModifiers::NONE,
1639        };
1640
1641        let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, &regions);
1642
1643        assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1644        assert!(state.has_open_submenu());
1645    }
1646
1647    #[test]
1648    fn test_context_menu_action_equality() {
1649        assert_eq!(ContextMenuAction::Open, ContextMenuAction::Open);
1650        assert_eq!(ContextMenuAction::Close, ContextMenuAction::Close);
1651        assert_eq!(
1652            ContextMenuAction::Select("a".to_string()),
1653            ContextMenuAction::Select("a".to_string())
1654        );
1655        assert_ne!(
1656            ContextMenuAction::Select("a".to_string()),
1657            ContextMenuAction::Select("b".to_string())
1658        );
1659        assert_eq!(
1660            ContextMenuAction::SubmenuOpen(1),
1661            ContextMenuAction::SubmenuOpen(1)
1662        );
1663        assert_eq!(
1664            ContextMenuAction::SubmenuClose,
1665            ContextMenuAction::SubmenuClose
1666        );
1667        assert_eq!(
1668            ContextMenuAction::HighlightChange(5),
1669            ContextMenuAction::HighlightChange(5)
1670        );
1671    }
1672
1673    #[test]
1674    fn test_context_menu_item_all_disabled() {
1675        let items = vec![
1676            ContextMenuItem::separator(),
1677            ContextMenuItem::action("a", "A").enabled(false),
1678            ContextMenuItem::separator(),
1679        ];
1680
1681        let mut state = ContextMenuState::new();
1682        state.open_at(0, 0);
1683        state.highlighted_index = 1;
1684
1685        // Navigation should not move to any item since none are selectable
1686        state.highlight_next(&items);
1687        assert_eq!(state.highlighted_index, 1); // Unchanged
1688
1689        state.highlight_prev(&items);
1690        assert_eq!(state.highlighted_index, 1); // Unchanged
1691    }
1692
1693    #[test]
1694    fn test_context_menu_widget_new() {
1695        let items = vec![ContextMenuItem::action("test", "Test")];
1696        let state = ContextMenuState::new();
1697        let _menu = ContextMenu::new(&items, &state);
1698
1699        // Verify menu is created (we can't easily test rendering without Frame)
1700        assert!(!state.is_open);
1701    }
1702
1703    #[test]
1704    fn test_context_menu_widget_style() {
1705        let items = vec![ContextMenuItem::action("test", "Test")];
1706        let state = ContextMenuState::new();
1707        let style = ContextMenuStyle::light();
1708        let _menu = ContextMenu::new(&items, &state).style(style);
1709    }
1710
1711    #[test]
1712    fn test_is_context_menu_trigger_other_events() {
1713        use crossterm::event::KeyModifiers;
1714
1715        // Mouse move
1716        let mouse_move = MouseEvent {
1717            kind: MouseEventKind::Moved,
1718            column: 10,
1719            row: 5,
1720            modifiers: KeyModifiers::NONE,
1721        };
1722        assert!(!is_context_menu_trigger(&mouse_move));
1723
1724        // Mouse up
1725        let mouse_up = MouseEvent {
1726            kind: MouseEventKind::Up(MouseButton::Right),
1727            column: 10,
1728            row: 5,
1729            modifiers: KeyModifiers::NONE,
1730        };
1731        assert!(!is_context_menu_trigger(&mouse_up));
1732
1733        // Middle click
1734        let middle_click = MouseEvent {
1735            kind: MouseEventKind::Down(MouseButton::Middle),
1736            column: 10,
1737            row: 5,
1738            modifiers: KeyModifiers::NONE,
1739        };
1740        assert!(!is_context_menu_trigger(&middle_click));
1741
1742        // Scroll
1743        let scroll = MouseEvent {
1744            kind: MouseEventKind::ScrollUp,
1745            column: 10,
1746            row: 5,
1747            modifiers: KeyModifiers::NONE,
1748        };
1749        assert!(!is_context_menu_trigger(&scroll));
1750    }
1751
1752    #[test]
1753    fn test_context_menu_submenu_disabled() {
1754        let mut state = ContextMenuState::new();
1755        state.open_at(0, 0);
1756
1757        let items = vec![
1758            ContextMenuItem::submenu("More", vec![ContextMenuItem::action("sub", "Sub")])
1759                .enabled(false),
1760        ];
1761
1762        // Right arrow on disabled submenu should not open it
1763        let key = KeyEvent::from(KeyCode::Right);
1764        let action = handle_context_menu_key(&key, &mut state, &items);
1765
1766        assert!(action.is_none());
1767        assert!(!state.has_open_submenu());
1768
1769        // Enter on disabled submenu should not open it
1770        let key = KeyEvent::from(KeyCode::Enter);
1771        let action = handle_context_menu_key(&key, &mut state, &items);
1772
1773        assert!(action.is_none());
1774        assert!(!state.has_open_submenu());
1775    }
1776}