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 ContextMenuStyle {
413    /// Create a light theme style.
414    pub fn light() -> Self {
415        Self {
416            background: Color::Rgb(250, 250, 250),
417            border: Color::Rgb(180, 180, 180),
418            normal_fg: Color::Rgb(30, 30, 30),
419            highlight_bg: Color::Rgb(0, 120, 215),
420            highlight_fg: Color::White,
421            disabled_fg: Color::Rgb(160, 160, 160),
422            shortcut_fg: Color::Rgb(100, 100, 100),
423            separator_fg: Color::Rgb(200, 200, 200),
424            ..Default::default()
425        }
426    }
427
428    /// Create a minimal style.
429    pub fn minimal() -> Self {
430        Self {
431            background: Color::Reset,
432            border: Color::Gray,
433            normal_fg: Color::White,
434            highlight_bg: Color::Blue,
435            highlight_fg: Color::White,
436            disabled_fg: Color::DarkGray,
437            shortcut_fg: Color::Gray,
438            separator_fg: Color::DarkGray,
439            ..Default::default()
440        }
441    }
442
443    /// Set minimum width.
444    pub fn min_width(mut self, width: u16) -> Self {
445        self.min_width = width;
446        self
447    }
448
449    /// Set maximum width.
450    pub fn max_width(mut self, width: u16) -> Self {
451        self.max_width = width;
452        self
453    }
454
455    /// Set maximum visible items.
456    pub fn max_visible_items(mut self, count: u16) -> Self {
457        self.max_visible_items = count;
458        self
459    }
460
461    /// Set the submenu indicator.
462    pub fn submenu_indicator(mut self, indicator: &'static str) -> Self {
463        self.submenu_indicator = indicator;
464        self
465    }
466
467    /// Set the highlight colors.
468    pub fn highlight(mut self, fg: Color, bg: Color) -> Self {
469        self.highlight_fg = fg;
470        self.highlight_bg = bg;
471        self
472    }
473}
474
475/// Context menu widget.
476///
477/// A popup menu that appears at a specified position, typically triggered
478/// by a right-click event.
479pub struct ContextMenu<'a> {
480    items: &'a [ContextMenuItem],
481    state: &'a ContextMenuState,
482    style: ContextMenuStyle,
483}
484
485impl<'a> ContextMenu<'a> {
486    /// Create a new context menu.
487    pub fn new(items: &'a [ContextMenuItem], state: &'a ContextMenuState) -> Self {
488        Self {
489            items,
490            state,
491            style: ContextMenuStyle::default(),
492        }
493    }
494
495    /// Set the style.
496    pub fn style(mut self, style: ContextMenuStyle) -> Self {
497        self.style = style;
498        self
499    }
500
501    /// Calculate the required width for the menu.
502    fn calculate_width(&self) -> u16 {
503        let mut max_label_width = 0u16;
504        let mut max_shortcut_width = 0u16;
505
506        for item in self.items {
507            match item {
508                ContextMenuItem::Action {
509                    label,
510                    icon,
511                    shortcut,
512                    ..
513                } => {
514                    let icon_width = icon.as_ref().map(|i| i.chars().count() + 1).unwrap_or(0);
515                    let label_width = label.chars().count() + icon_width;
516                    max_label_width = max_label_width.max(label_width as u16);
517                    if let Some(s) = shortcut {
518                        max_shortcut_width = max_shortcut_width.max(s.chars().count() as u16);
519                    }
520                }
521                ContextMenuItem::Submenu { label, icon, .. } => {
522                    let icon_width = icon.as_ref().map(|i| i.chars().count() + 1).unwrap_or(0);
523                    // +2 for submenu indicator
524                    let label_width = label.chars().count() + icon_width + 2;
525                    max_label_width = max_label_width.max(label_width as u16);
526                }
527                ContextMenuItem::Separator => {}
528            }
529        }
530
531        // Total width: padding + label + gap + shortcut + padding + borders
532        let content_width = self.style.padding
533            + max_label_width
534            + if max_shortcut_width > 0 {
535                2 + max_shortcut_width
536            } else {
537                0
538            }
539            + self.style.padding;
540
541        // Clamp to min/max
542        (content_width + 2) // +2 for borders
543            .max(self.style.min_width)
544            .min(self.style.max_width)
545    }
546
547    /// Calculate the required height for the menu.
548    fn calculate_height(&self) -> u16 {
549        let item_count = self.items.len() as u16;
550        let visible = item_count.min(self.style.max_visible_items);
551        visible + 2 // +2 for borders
552    }
553
554    /// Calculate the menu area based on anchor and screen bounds.
555    fn calculate_menu_area(&self, screen: Rect) -> Rect {
556        let (anchor_x, anchor_y) = self.state.anchor_position;
557        let width = self.calculate_width();
558        let height = self.calculate_height();
559
560        // Prefer right-down positioning, flip if needed
561        let x = if anchor_x + width <= screen.x + screen.width {
562            anchor_x
563        } else {
564            anchor_x.saturating_sub(width)
565        };
566
567        let y = if anchor_y + height <= screen.y + screen.height {
568            anchor_y
569        } else {
570            anchor_y.saturating_sub(height)
571        };
572
573        // Ensure we stay within screen bounds
574        let final_width = width.min(screen.width.saturating_sub(x.saturating_sub(screen.x)));
575        let final_height = height.min(screen.height.saturating_sub(y.saturating_sub(screen.y)));
576
577        Rect::new(x, y, final_width, final_height)
578    }
579
580    /// Render the context menu and return click regions for items.
581    ///
582    /// Returns a tuple of (menu_area, item_click_regions).
583    pub fn render_stateful(
584        &self,
585        frame: &mut Frame,
586        screen: Rect,
587    ) -> (Rect, Vec<ClickRegion<ContextMenuAction>>) {
588        let mut regions = Vec::new();
589
590        if !self.state.is_open || self.items.is_empty() {
591            return (Rect::default(), regions);
592        }
593
594        let menu_area = self.calculate_menu_area(screen);
595
596        // Clear background (overlay)
597        frame.render_widget(Clear, menu_area);
598
599        // Render border
600        let block = Block::default()
601            .borders(Borders::ALL)
602            .border_style(Style::default().fg(self.style.border))
603            .style(Style::default().bg(self.style.background));
604
605        let inner = block.inner(menu_area);
606        frame.render_widget(block, menu_area);
607
608        // Render items
609        let visible_count = inner.height as usize;
610        let scroll = self.state.scroll_offset as usize;
611
612        for (display_idx, (item_idx, item)) in self
613            .items
614            .iter()
615            .enumerate()
616            .skip(scroll)
617            .take(visible_count)
618            .enumerate()
619        {
620            let y = inner.y + display_idx as u16;
621            let item_area = Rect::new(inner.x, y, inner.width, 1);
622
623            let is_highlighted = item_idx == self.state.highlighted_index;
624
625            match item {
626                ContextMenuItem::Separator => {
627                    // Render separator line
628                    let sep_line: String =
629                        std::iter::repeat_n(self.style.separator_char, inner.width as usize)
630                            .collect();
631                    let para = Paragraph::new(Span::styled(
632                        sep_line,
633                        Style::default().fg(self.style.separator_fg),
634                    ));
635                    frame.render_widget(para, item_area);
636                }
637                ContextMenuItem::Action {
638                    label,
639                    icon,
640                    shortcut,
641                    enabled,
642                    id,
643                } => {
644                    let (fg, bg) = if !enabled {
645                        (self.style.disabled_fg, self.style.background)
646                    } else if is_highlighted {
647                        (self.style.highlight_fg, self.style.highlight_bg)
648                    } else {
649                        (self.style.normal_fg, self.style.background)
650                    };
651
652                    let style = Style::default().fg(fg).bg(bg);
653                    let shortcut_style = Style::default()
654                        .fg(if *enabled {
655                            self.style.shortcut_fg
656                        } else {
657                            self.style.disabled_fg
658                        })
659                        .bg(bg);
660
661                    let mut spans = Vec::new();
662
663                    // Padding
664                    spans.push(Span::styled(
665                        " ".repeat(self.style.padding as usize),
666                        style,
667                    ));
668
669                    // Icon
670                    if let Some(ic) = icon {
671                        spans.push(Span::styled(format!("{} ", ic), style));
672                    }
673
674                    // Label
675                    spans.push(Span::styled(label.clone(), style));
676
677                    // Fill space before shortcut
678                    let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
679                    let shortcut_len = shortcut.as_ref().map(|s| s.chars().count()).unwrap_or(0);
680                    let fill_len = (inner.width as usize)
681                        .saturating_sub(current_len)
682                        .saturating_sub(shortcut_len)
683                        .saturating_sub(self.style.padding as usize);
684
685                    if fill_len > 0 {
686                        spans.push(Span::styled(" ".repeat(fill_len), style));
687                    }
688
689                    // Shortcut
690                    if let Some(sc) = shortcut {
691                        spans.push(Span::styled(sc.clone(), shortcut_style));
692                    }
693
694                    // Right padding
695                    spans.push(Span::styled(
696                        " ".repeat(self.style.padding as usize),
697                        style,
698                    ));
699
700                    let para = Paragraph::new(Line::from(spans));
701                    frame.render_widget(para, item_area);
702
703                    // Register click region
704                    if *enabled {
705                        regions.push(ClickRegion::new(
706                            item_area,
707                            ContextMenuAction::Select(id.clone()),
708                        ));
709                    }
710                }
711                ContextMenuItem::Submenu {
712                    label,
713                    icon,
714                    enabled,
715                    ..
716                } => {
717                    let (fg, bg) = if !enabled {
718                        (self.style.disabled_fg, self.style.background)
719                    } else if is_highlighted {
720                        (self.style.highlight_fg, self.style.highlight_bg)
721                    } else {
722                        (self.style.normal_fg, self.style.background)
723                    };
724
725                    let style = Style::default().fg(fg).bg(bg);
726
727                    let mut spans = Vec::new();
728
729                    // Padding
730                    spans.push(Span::styled(
731                        " ".repeat(self.style.padding as usize),
732                        style,
733                    ));
734
735                    // Icon
736                    if let Some(ic) = icon {
737                        spans.push(Span::styled(format!("{} ", ic), style));
738                    }
739
740                    // Label
741                    spans.push(Span::styled(label.clone(), style));
742
743                    // Fill and submenu indicator
744                    let current_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
745                    let indicator_len = self.style.submenu_indicator.chars().count();
746                    let fill_len = (inner.width as usize)
747                        .saturating_sub(current_len)
748                        .saturating_sub(indicator_len)
749                        .saturating_sub(self.style.padding as usize);
750
751                    if fill_len > 0 {
752                        spans.push(Span::styled(" ".repeat(fill_len), style));
753                    }
754
755                    spans.push(Span::styled(self.style.submenu_indicator, style));
756
757                    // Right padding
758                    spans.push(Span::styled(
759                        " ".repeat(self.style.padding as usize),
760                        style,
761                    ));
762
763                    let para = Paragraph::new(Line::from(spans));
764                    frame.render_widget(para, item_area);
765
766                    // Register click region for submenu
767                    if *enabled {
768                        regions.push(ClickRegion::new(
769                            item_area,
770                            ContextMenuAction::SubmenuOpen(item_idx),
771                        ));
772                    }
773                }
774            }
775        }
776
777        // Render submenu if open
778        if let (Some(submenu_idx), Some(submenu_state)) =
779            (self.state.active_submenu, &self.state.submenu_state)
780        {
781            if let Some(ContextMenuItem::Submenu { items, .. }) = self.items.get(submenu_idx) {
782                // Position submenu to the right of the parent item
783                let submenu_anchor_x = menu_area.x + menu_area.width;
784                let submenu_anchor_y =
785                    menu_area.y + 1 + (submenu_idx as u16).saturating_sub(self.state.scroll_offset);
786
787                let mut adjusted_state = (**submenu_state).clone();
788                adjusted_state.anchor_position = (submenu_anchor_x, submenu_anchor_y);
789
790                let adjusted_submenu =
791                    ContextMenu::new(items, &adjusted_state).style(self.style.clone());
792
793                let (_, submenu_regions) = adjusted_submenu.render_stateful(frame, screen);
794                regions.extend(submenu_regions);
795            }
796        }
797
798        (menu_area, regions)
799    }
800}
801
802/// Handle keyboard events for context menu.
803///
804/// Returns `Some(ContextMenuAction)` if an action was triggered, `None` otherwise.
805///
806/// # Key Bindings
807///
808/// - `Esc` - Close menu
809/// - `Up` - Move highlight up
810/// - `Down` - Move highlight down
811/// - `Enter`, `Space` - Select highlighted item
812/// - `Right` - Open submenu (if item has one)
813/// - `Left` - Close submenu (if one is open)
814/// - `Home` - Move to first item
815/// - `End` - Move to last item
816pub fn handle_context_menu_key(
817    key: &KeyEvent,
818    state: &mut ContextMenuState,
819    items: &[ContextMenuItem],
820) -> Option<ContextMenuAction> {
821    if !state.is_open {
822        return None;
823    }
824
825    // If submenu is open, delegate to it first
826    if let (Some(submenu_idx), Some(submenu_state)) =
827        (state.active_submenu, &mut state.submenu_state)
828    {
829        if let Some(ContextMenuItem::Submenu { items: sub_items, .. }) = items.get(submenu_idx) {
830            match key.code {
831                KeyCode::Left | KeyCode::Esc => {
832                    state.close_submenu();
833                    return Some(ContextMenuAction::SubmenuClose);
834                }
835                _ => {
836                    if let Some(action) =
837                        handle_context_menu_key(key, submenu_state.as_mut(), sub_items)
838                    {
839                        return Some(action);
840                    }
841                }
842            }
843            return None;
844        }
845    }
846
847    match key.code {
848        KeyCode::Esc => {
849            state.close();
850            Some(ContextMenuAction::Close)
851        }
852        KeyCode::Up => {
853            state.highlight_prev(items);
854            state.ensure_visible(8);
855            Some(ContextMenuAction::HighlightChange(state.highlighted_index))
856        }
857        KeyCode::Down => {
858            state.highlight_next(items);
859            state.ensure_visible(8);
860            Some(ContextMenuAction::HighlightChange(state.highlighted_index))
861        }
862        KeyCode::Home => {
863            state.highlight_first(items);
864            Some(ContextMenuAction::HighlightChange(state.highlighted_index))
865        }
866        KeyCode::End => {
867            state.highlight_last(items);
868            state.ensure_visible(items.len());
869            Some(ContextMenuAction::HighlightChange(state.highlighted_index))
870        }
871        KeyCode::Enter | KeyCode::Char(' ') => {
872            if let Some(item) = items.get(state.highlighted_index) {
873                match item {
874                    ContextMenuItem::Action { id, enabled, .. } if *enabled => {
875                        let action_id = id.clone();
876                        state.close();
877                        Some(ContextMenuAction::Select(action_id))
878                    }
879                    ContextMenuItem::Submenu { enabled, .. } if *enabled => {
880                        state.open_submenu();
881                        Some(ContextMenuAction::SubmenuOpen(state.highlighted_index))
882                    }
883                    _ => None,
884                }
885            } else {
886                None
887            }
888        }
889        KeyCode::Right => {
890            if let Some(item) = items.get(state.highlighted_index) {
891                if item.has_submenu() && item.is_enabled() {
892                    state.open_submenu();
893                    return Some(ContextMenuAction::SubmenuOpen(state.highlighted_index));
894                }
895            }
896            None
897        }
898        KeyCode::Left => {
899            // Close current menu level (handled by parent)
900            None
901        }
902        _ => None,
903    }
904}
905
906/// Handle mouse events for context menu.
907///
908/// Returns `Some(ContextMenuAction)` if an action was triggered, `None` otherwise.
909///
910/// # Arguments
911///
912/// * `mouse` - The mouse event
913/// * `state` - Mutable reference to context menu state
914/// * `menu_area` - The rendered menu area
915/// * `item_regions` - Click regions from `render_stateful`
916pub fn handle_context_menu_mouse(
917    mouse: &MouseEvent,
918    state: &mut ContextMenuState,
919    menu_area: Rect,
920    item_regions: &[ClickRegion<ContextMenuAction>],
921) -> Option<ContextMenuAction> {
922    if !state.is_open {
923        return None;
924    }
925
926    let col = mouse.column;
927    let row = mouse.row;
928
929    match mouse.kind {
930        MouseEventKind::Down(MouseButton::Left) => {
931            // Check if clicked on an item
932            for region in item_regions {
933                if region.contains(col, row) {
934                    match &region.data {
935                        ContextMenuAction::Select(id) => {
936                            let action_id = id.clone();
937                            state.close();
938                            return Some(ContextMenuAction::Select(action_id));
939                        }
940                        ContextMenuAction::SubmenuOpen(idx) => {
941                            state.highlighted_index = *idx;
942                            state.open_submenu();
943                            return Some(ContextMenuAction::SubmenuOpen(*idx));
944                        }
945                        _ => {}
946                    }
947                }
948            }
949
950            // Check if clicked outside menu
951            if !menu_area.intersects(Rect::new(col, row, 1, 1)) {
952                state.close();
953                return Some(ContextMenuAction::Close);
954            }
955            None
956        }
957        MouseEventKind::Moved => {
958            // Update highlight on hover
959            for region in item_regions.iter() {
960                if region.contains(col, row) {
961                    // Find the actual item index from the region
962                    if let ContextMenuAction::Select(_) | ContextMenuAction::SubmenuOpen(_) =
963                        &region.data
964                    {
965                        // The item_regions index may not match the items index due to separators
966                        // We need to find the corresponding item
967                        let inner_start_y = menu_area.y + 1; // +1 for border
968                        let item_idx = (row - inner_start_y) as usize + state.scroll_offset as usize;
969
970                        if item_idx < item_regions.len() + state.scroll_offset as usize
971                            && state.highlighted_index != item_idx
972                        {
973                            state.highlighted_index = item_idx;
974                            return Some(ContextMenuAction::HighlightChange(item_idx));
975                        }
976                    }
977                    break;
978                }
979            }
980            None
981        }
982        _ => None,
983    }
984}
985
986/// Check if a mouse event is a context menu trigger (right-click).
987pub fn is_context_menu_trigger(mouse: &MouseEvent) -> bool {
988    matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
989}
990
991/// Calculate the height needed for a context menu.
992pub fn calculate_menu_height(item_count: usize, max_visible: u16) -> u16 {
993    let visible = (item_count as u16).min(max_visible);
994    visible + 2 // +2 for borders
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    #[test]
1002    fn test_context_menu_item_action() {
1003        let item = ContextMenuItem::action("copy", "Copy")
1004            .icon("📋")
1005            .shortcut("Ctrl+C");
1006
1007        assert!(item.is_selectable());
1008        assert!(!item.has_submenu());
1009        assert_eq!(item.id(), Some("copy"));
1010        assert_eq!(item.label(), Some("Copy"));
1011        assert_eq!(item.get_icon(), Some("📋"));
1012        assert_eq!(item.get_shortcut(), Some("Ctrl+C"));
1013    }
1014
1015    #[test]
1016    fn test_context_menu_item_separator() {
1017        let item = ContextMenuItem::separator();
1018
1019        assert!(!item.is_selectable());
1020        assert!(!item.has_submenu());
1021        assert_eq!(item.label(), None);
1022    }
1023
1024    #[test]
1025    fn test_context_menu_item_submenu() {
1026        let items = vec![ContextMenuItem::action("sub1", "Sub Item 1")];
1027        let item = ContextMenuItem::submenu("More", items).icon("â–¶");
1028
1029        assert!(item.is_selectable());
1030        assert!(item.has_submenu());
1031        assert_eq!(item.label(), Some("More"));
1032        assert!(item.submenu_items().is_some());
1033    }
1034
1035    #[test]
1036    fn test_context_menu_item_disabled() {
1037        let item = ContextMenuItem::action("delete", "Delete").enabled(false);
1038
1039        assert!(!item.is_selectable());
1040        assert!(!item.is_enabled());
1041    }
1042
1043    #[test]
1044    fn test_context_menu_state_open_close() {
1045        let mut state = ContextMenuState::new();
1046
1047        assert!(!state.is_open);
1048
1049        state.open_at(10, 5);
1050        assert!(state.is_open);
1051        assert_eq!(state.anchor_position, (10, 5));
1052        assert_eq!(state.highlighted_index, 0);
1053
1054        state.close();
1055        assert!(!state.is_open);
1056    }
1057
1058    #[test]
1059    fn test_context_menu_state_navigation() {
1060        let mut state = ContextMenuState::new();
1061        state.open_at(0, 0);
1062
1063        let items = vec![
1064            ContextMenuItem::action("a", "A"),
1065            ContextMenuItem::separator(),
1066            ContextMenuItem::action("b", "B"),
1067            ContextMenuItem::action("c", "C"),
1068        ];
1069
1070        // Start at index 0
1071        assert_eq!(state.highlighted_index, 0);
1072
1073        // Move down (should skip separator)
1074        state.highlight_next(&items);
1075        assert_eq!(state.highlighted_index, 2); // Skipped separator at 1
1076
1077        // Move down again
1078        state.highlight_next(&items);
1079        assert_eq!(state.highlighted_index, 3);
1080
1081        // Move up
1082        state.highlight_prev(&items);
1083        assert_eq!(state.highlighted_index, 2);
1084
1085        // Move up again (should skip separator)
1086        state.highlight_prev(&items);
1087        assert_eq!(state.highlighted_index, 0);
1088    }
1089
1090    #[test]
1091    fn test_context_menu_state_submenu() {
1092        let mut state = ContextMenuState::new();
1093        state.open_at(0, 0);
1094        state.highlighted_index = 2;
1095
1096        assert!(!state.has_open_submenu());
1097
1098        state.open_submenu();
1099        assert!(state.has_open_submenu());
1100        assert_eq!(state.active_submenu, Some(2));
1101        assert!(state.submenu_state.is_some());
1102
1103        state.close_submenu();
1104        assert!(!state.has_open_submenu());
1105        assert!(state.submenu_state.is_none());
1106    }
1107
1108    #[test]
1109    fn test_context_menu_style_default() {
1110        let style = ContextMenuStyle::default();
1111        assert_eq!(style.min_width, 15);
1112        assert_eq!(style.max_width, 50);
1113        assert_eq!(style.max_visible_items, 15);
1114        assert_eq!(style.submenu_indicator, "â–¶");
1115    }
1116
1117    #[test]
1118    fn test_context_menu_style_builders() {
1119        let style = ContextMenuStyle::default()
1120            .min_width(20)
1121            .max_width(60)
1122            .max_visible_items(10)
1123            .submenu_indicator("→");
1124
1125        assert_eq!(style.min_width, 20);
1126        assert_eq!(style.max_width, 60);
1127        assert_eq!(style.max_visible_items, 10);
1128        assert_eq!(style.submenu_indicator, "→");
1129    }
1130
1131    #[test]
1132    fn test_context_menu_style_presets() {
1133        let light = ContextMenuStyle::light();
1134        assert_eq!(light.background, Color::Rgb(250, 250, 250));
1135
1136        let minimal = ContextMenuStyle::minimal();
1137        assert_eq!(minimal.background, Color::Reset);
1138    }
1139
1140    #[test]
1141    fn test_handle_key_escape() {
1142        let mut state = ContextMenuState::new();
1143        state.open_at(0, 0);
1144
1145        let items = vec![ContextMenuItem::action("a", "A")];
1146        let key = KeyEvent::from(KeyCode::Esc);
1147        let action = handle_context_menu_key(&key, &mut state, &items);
1148
1149        assert_eq!(action, Some(ContextMenuAction::Close));
1150        assert!(!state.is_open);
1151    }
1152
1153    #[test]
1154    fn test_handle_key_navigation() {
1155        let mut state = ContextMenuState::new();
1156        state.open_at(0, 0);
1157
1158        let items = vec![
1159            ContextMenuItem::action("a", "A"),
1160            ContextMenuItem::action("b", "B"),
1161            ContextMenuItem::action("c", "C"),
1162        ];
1163
1164        // Down
1165        let key = KeyEvent::from(KeyCode::Down);
1166        let action = handle_context_menu_key(&key, &mut state, &items);
1167        assert_eq!(action, Some(ContextMenuAction::HighlightChange(1)));
1168        assert_eq!(state.highlighted_index, 1);
1169
1170        // Up
1171        let key = KeyEvent::from(KeyCode::Up);
1172        let action = handle_context_menu_key(&key, &mut state, &items);
1173        assert_eq!(action, Some(ContextMenuAction::HighlightChange(0)));
1174        assert_eq!(state.highlighted_index, 0);
1175    }
1176
1177    #[test]
1178    fn test_handle_key_select() {
1179        let mut state = ContextMenuState::new();
1180        state.open_at(0, 0);
1181        state.highlighted_index = 1;
1182
1183        let items = vec![
1184            ContextMenuItem::action("a", "A"),
1185            ContextMenuItem::action("b", "B"),
1186        ];
1187
1188        let key = KeyEvent::from(KeyCode::Enter);
1189        let action = handle_context_menu_key(&key, &mut state, &items);
1190
1191        assert_eq!(action, Some(ContextMenuAction::Select("b".to_string())));
1192        assert!(!state.is_open);
1193    }
1194
1195    #[test]
1196    fn test_is_context_menu_trigger() {
1197        use crossterm::event::KeyModifiers;
1198
1199        let right_click = MouseEvent {
1200            kind: MouseEventKind::Down(MouseButton::Right),
1201            column: 10,
1202            row: 5,
1203            modifiers: KeyModifiers::NONE,
1204        };
1205        assert!(is_context_menu_trigger(&right_click));
1206
1207        let left_click = MouseEvent {
1208            kind: MouseEventKind::Down(MouseButton::Left),
1209            column: 10,
1210            row: 5,
1211            modifiers: KeyModifiers::NONE,
1212        };
1213        assert!(!is_context_menu_trigger(&left_click));
1214    }
1215
1216    #[test]
1217    fn test_calculate_menu_height() {
1218        assert_eq!(calculate_menu_height(5, 15), 7); // 5 + 2
1219        assert_eq!(calculate_menu_height(20, 15), 17); // 15 + 2 (clamped)
1220        assert_eq!(calculate_menu_height(0, 15), 2); // 0 + 2
1221    }
1222
1223    // Additional comprehensive tests
1224
1225    #[test]
1226    fn test_context_menu_item_icon_on_separator() {
1227        // Icon should not affect separators
1228        let item = ContextMenuItem::separator().icon("x");
1229        assert_eq!(item.get_icon(), None);
1230    }
1231
1232    #[test]
1233    fn test_context_menu_item_shortcut_on_submenu() {
1234        // Shortcut should not affect submenus
1235        let item = ContextMenuItem::submenu("Menu", vec![]).shortcut("Ctrl+X");
1236        assert_eq!(item.get_shortcut(), None);
1237    }
1238
1239    #[test]
1240    fn test_context_menu_item_enabled_on_separator() {
1241        // Enabled should not affect separators (always false)
1242        let item = ContextMenuItem::separator().enabled(true);
1243        assert!(!item.is_enabled());
1244    }
1245
1246    #[test]
1247    fn test_context_menu_item_submenu_items() {
1248        let sub_items = vec![
1249            ContextMenuItem::action("a", "A"),
1250            ContextMenuItem::action("b", "B"),
1251        ];
1252        let item = ContextMenuItem::submenu("Menu", sub_items);
1253        let items = item.submenu_items().unwrap();
1254        assert_eq!(items.len(), 2);
1255    }
1256
1257    #[test]
1258    fn test_context_menu_item_action_no_submenu_items() {
1259        let item = ContextMenuItem::action("test", "Test");
1260        assert!(item.submenu_items().is_none());
1261    }
1262
1263    #[test]
1264    fn test_context_menu_state_default() {
1265        let state = ContextMenuState::default();
1266        assert!(!state.is_open);
1267        assert_eq!(state.anchor_position, (0, 0));
1268        assert_eq!(state.highlighted_index, 0);
1269        assert_eq!(state.scroll_offset, 0);
1270        assert!(state.active_submenu.is_none());
1271        assert!(state.submenu_state.is_none());
1272    }
1273
1274    #[test]
1275    fn test_context_menu_state_open_resets_state() {
1276        let mut state = ContextMenuState::new();
1277        state.highlighted_index = 5;
1278        state.scroll_offset = 10;
1279        state.open_submenu();
1280
1281        state.open_at(20, 30);
1282
1283        assert!(state.is_open);
1284        assert_eq!(state.anchor_position, (20, 30));
1285        assert_eq!(state.highlighted_index, 0);
1286        assert_eq!(state.scroll_offset, 0);
1287        assert!(!state.has_open_submenu());
1288    }
1289
1290    #[test]
1291    fn test_context_menu_state_highlight_first_last() {
1292        let mut state = ContextMenuState::new();
1293        state.open_at(0, 0);
1294
1295        let items = vec![
1296            ContextMenuItem::separator(), // index 0 - not selectable
1297            ContextMenuItem::action("a", "A"), // index 1
1298            ContextMenuItem::action("b", "B"), // index 2
1299            ContextMenuItem::separator(), // index 3 - not selectable
1300            ContextMenuItem::action("c", "C"), // index 4
1301        ];
1302
1303        state.highlight_first(&items);
1304        assert_eq!(state.highlighted_index, 1); // First selectable
1305
1306        state.highlight_last(&items);
1307        assert_eq!(state.highlighted_index, 4); // Last selectable
1308    }
1309
1310    #[test]
1311    fn test_context_menu_state_navigation_bounds() {
1312        let mut state = ContextMenuState::new();
1313        state.open_at(0, 0);
1314        state.highlighted_index = 0;
1315
1316        let items = vec![
1317            ContextMenuItem::action("a", "A"),
1318            ContextMenuItem::action("b", "B"),
1319        ];
1320
1321        // Try to go before first
1322        state.highlight_prev(&items);
1323        assert_eq!(state.highlighted_index, 0);
1324
1325        // Go to last
1326        state.highlighted_index = 1;
1327        // Try to go past last
1328        state.highlight_next(&items);
1329        assert_eq!(state.highlighted_index, 1);
1330    }
1331
1332    #[test]
1333    fn test_context_menu_state_navigation_empty_items() {
1334        let mut state = ContextMenuState::new();
1335        state.open_at(0, 0);
1336        state.highlighted_index = 5;
1337
1338        let items: Vec<ContextMenuItem> = vec![];
1339
1340        state.highlight_next(&items);
1341        assert_eq!(state.highlighted_index, 5); // Unchanged
1342
1343        state.highlight_prev(&items);
1344        assert_eq!(state.highlighted_index, 5); // Unchanged
1345    }
1346
1347    #[test]
1348    fn test_context_menu_state_ensure_visible() {
1349        let mut state = ContextMenuState::new();
1350        state.highlighted_index = 15;
1351        state.scroll_offset = 0;
1352
1353        state.ensure_visible(10);
1354        // 15 - 10 + 1 = 6
1355        assert!(state.scroll_offset >= 6);
1356
1357        // Scroll back up
1358        state.highlighted_index = 3;
1359        state.ensure_visible(10);
1360        assert!(state.scroll_offset <= 3);
1361    }
1362
1363    #[test]
1364    fn test_context_menu_state_ensure_visible_zero_viewport() {
1365        let mut state = ContextMenuState::new();
1366        state.highlighted_index = 10;
1367        state.scroll_offset = 5;
1368
1369        // Zero viewport should not change anything
1370        state.ensure_visible(0);
1371        assert_eq!(state.scroll_offset, 5);
1372    }
1373
1374    #[test]
1375    fn test_context_menu_style_highlight() {
1376        let style = ContextMenuStyle::default()
1377            .highlight(Color::Red, Color::Blue);
1378
1379        assert_eq!(style.highlight_fg, Color::Red);
1380        assert_eq!(style.highlight_bg, Color::Blue);
1381    }
1382
1383    #[test]
1384    fn test_handle_key_when_closed() {
1385        let mut state = ContextMenuState::new();
1386        assert!(!state.is_open);
1387
1388        let items = vec![ContextMenuItem::action("a", "A")];
1389        let key = KeyEvent::from(KeyCode::Down);
1390        let action = handle_context_menu_key(&key, &mut state, &items);
1391
1392        assert!(action.is_none());
1393    }
1394
1395    #[test]
1396    fn test_handle_key_space_select() {
1397        let mut state = ContextMenuState::new();
1398        state.open_at(0, 0);
1399
1400        let items = vec![ContextMenuItem::action("a", "Action A")];
1401
1402        let key = KeyEvent::from(KeyCode::Char(' '));
1403        let action = handle_context_menu_key(&key, &mut state, &items);
1404
1405        assert_eq!(action, Some(ContextMenuAction::Select("a".to_string())));
1406        assert!(!state.is_open);
1407    }
1408
1409    #[test]
1410    fn test_handle_key_home_end() {
1411        let mut state = ContextMenuState::new();
1412        state.open_at(0, 0);
1413
1414        let items = vec![
1415            ContextMenuItem::action("a", "A"),
1416            ContextMenuItem::action("b", "B"),
1417            ContextMenuItem::action("c", "C"),
1418            ContextMenuItem::action("d", "D"),
1419        ];
1420
1421        // End
1422        let key = KeyEvent::from(KeyCode::End);
1423        let action = handle_context_menu_key(&key, &mut state, &items);
1424        assert_eq!(action, Some(ContextMenuAction::HighlightChange(3)));
1425        assert_eq!(state.highlighted_index, 3);
1426
1427        // Home
1428        let key = KeyEvent::from(KeyCode::Home);
1429        let action = handle_context_menu_key(&key, &mut state, &items);
1430        assert_eq!(action, Some(ContextMenuAction::HighlightChange(0)));
1431        assert_eq!(state.highlighted_index, 0);
1432    }
1433
1434    #[test]
1435    fn test_handle_key_select_disabled_item() {
1436        let mut state = ContextMenuState::new();
1437        state.open_at(0, 0);
1438
1439        let items = vec![ContextMenuItem::action("a", "A").enabled(false)];
1440
1441        let key = KeyEvent::from(KeyCode::Enter);
1442        let action = handle_context_menu_key(&key, &mut state, &items);
1443
1444        // Should not select disabled item
1445        assert!(action.is_none());
1446        assert!(state.is_open); // Still open
1447    }
1448
1449    #[test]
1450    fn test_handle_key_open_submenu() {
1451        let mut state = ContextMenuState::new();
1452        state.open_at(0, 0);
1453
1454        let items = vec![ContextMenuItem::submenu(
1455            "More",
1456            vec![ContextMenuItem::action("sub", "Sub Action")],
1457        )];
1458
1459        // Enter to open submenu
1460        let key = KeyEvent::from(KeyCode::Enter);
1461        let action = handle_context_menu_key(&key, &mut state, &items);
1462
1463        assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1464        assert!(state.has_open_submenu());
1465    }
1466
1467    #[test]
1468    fn test_handle_key_right_arrow_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        // Right arrow to open submenu
1478        let key = KeyEvent::from(KeyCode::Right);
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_no_submenu() {
1487        let mut state = ContextMenuState::new();
1488        state.open_at(0, 0);
1489
1490        let items = vec![ContextMenuItem::action("a", "A")];
1491
1492        // Right arrow on non-submenu item
1493        let key = KeyEvent::from(KeyCode::Right);
1494        let action = handle_context_menu_key(&key, &mut state, &items);
1495
1496        assert!(action.is_none());
1497        assert!(!state.has_open_submenu());
1498    }
1499
1500    #[test]
1501    fn test_handle_key_left_arrow() {
1502        let mut state = ContextMenuState::new();
1503        state.open_at(0, 0);
1504
1505        let items = vec![ContextMenuItem::action("a", "A")];
1506
1507        // Left arrow (no effect at top level)
1508        let key = KeyEvent::from(KeyCode::Left);
1509        let action = handle_context_menu_key(&key, &mut state, &items);
1510
1511        assert!(action.is_none());
1512    }
1513
1514    #[test]
1515    fn test_handle_key_unknown_key() {
1516        let mut state = ContextMenuState::new();
1517        state.open_at(0, 0);
1518
1519        let items = vec![ContextMenuItem::action("a", "A")];
1520
1521        // Unknown key should be ignored
1522        let key = KeyEvent::from(KeyCode::Char('x'));
1523        let action = handle_context_menu_key(&key, &mut state, &items);
1524
1525        assert!(action.is_none());
1526        assert!(state.is_open);
1527    }
1528
1529    #[test]
1530    fn test_handle_mouse_when_closed() {
1531        use crossterm::event::KeyModifiers;
1532
1533        let mut state = ContextMenuState::new();
1534        assert!(!state.is_open);
1535
1536        let mouse = MouseEvent {
1537            kind: MouseEventKind::Down(MouseButton::Left),
1538            column: 10,
1539            row: 5,
1540            modifiers: KeyModifiers::NONE,
1541        };
1542
1543        let action =
1544            handle_context_menu_mouse(&mouse, &mut state, Rect::default(), &[]);
1545
1546        assert!(action.is_none());
1547    }
1548
1549    #[test]
1550    fn test_handle_mouse_click_outside() {
1551        use crossterm::event::KeyModifiers;
1552
1553        let mut state = ContextMenuState::new();
1554        state.open_at(10, 10);
1555
1556        let menu_area = Rect::new(10, 10, 20, 10);
1557
1558        // Click outside menu
1559        let mouse = MouseEvent {
1560            kind: MouseEventKind::Down(MouseButton::Left),
1561            column: 5,
1562            row: 5,
1563            modifiers: KeyModifiers::NONE,
1564        };
1565
1566        let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, &[]);
1567
1568        assert_eq!(action, Some(ContextMenuAction::Close));
1569        assert!(!state.is_open);
1570    }
1571
1572    #[test]
1573    fn test_handle_mouse_click_item() {
1574        use crossterm::event::KeyModifiers;
1575        use crate::traits::ClickRegion;
1576
1577        let mut state = ContextMenuState::new();
1578        state.open_at(10, 10);
1579
1580        let menu_area = Rect::new(10, 10, 20, 10);
1581        let item_area = Rect::new(11, 11, 18, 1);
1582        let regions = vec![ClickRegion::new(
1583            item_area,
1584            ContextMenuAction::Select("test".to_string()),
1585        )];
1586
1587        // Click on item
1588        let mouse = MouseEvent {
1589            kind: MouseEventKind::Down(MouseButton::Left),
1590            column: 15,
1591            row: 11,
1592            modifiers: KeyModifiers::NONE,
1593        };
1594
1595        let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, &regions);
1596
1597        assert_eq!(action, Some(ContextMenuAction::Select("test".to_string())));
1598        assert!(!state.is_open);
1599    }
1600
1601    #[test]
1602    fn test_handle_mouse_click_submenu_item() {
1603        use crossterm::event::KeyModifiers;
1604        use crate::traits::ClickRegion;
1605
1606        let mut state = ContextMenuState::new();
1607        state.open_at(10, 10);
1608
1609        let menu_area = Rect::new(10, 10, 20, 10);
1610        let item_area = Rect::new(11, 11, 18, 1);
1611        let regions = vec![ClickRegion::new(
1612            item_area,
1613            ContextMenuAction::SubmenuOpen(0),
1614        )];
1615
1616        // Click on submenu item
1617        let mouse = MouseEvent {
1618            kind: MouseEventKind::Down(MouseButton::Left),
1619            column: 15,
1620            row: 11,
1621            modifiers: KeyModifiers::NONE,
1622        };
1623
1624        let action = handle_context_menu_mouse(&mouse, &mut state, menu_area, &regions);
1625
1626        assert_eq!(action, Some(ContextMenuAction::SubmenuOpen(0)));
1627        assert!(state.has_open_submenu());
1628    }
1629
1630    #[test]
1631    fn test_context_menu_action_equality() {
1632        assert_eq!(ContextMenuAction::Open, ContextMenuAction::Open);
1633        assert_eq!(ContextMenuAction::Close, ContextMenuAction::Close);
1634        assert_eq!(
1635            ContextMenuAction::Select("a".to_string()),
1636            ContextMenuAction::Select("a".to_string())
1637        );
1638        assert_ne!(
1639            ContextMenuAction::Select("a".to_string()),
1640            ContextMenuAction::Select("b".to_string())
1641        );
1642        assert_eq!(
1643            ContextMenuAction::SubmenuOpen(1),
1644            ContextMenuAction::SubmenuOpen(1)
1645        );
1646        assert_eq!(ContextMenuAction::SubmenuClose, ContextMenuAction::SubmenuClose);
1647        assert_eq!(
1648            ContextMenuAction::HighlightChange(5),
1649            ContextMenuAction::HighlightChange(5)
1650        );
1651    }
1652
1653    #[test]
1654    fn test_context_menu_item_all_disabled() {
1655        let items = vec![
1656            ContextMenuItem::separator(),
1657            ContextMenuItem::action("a", "A").enabled(false),
1658            ContextMenuItem::separator(),
1659        ];
1660
1661        let mut state = ContextMenuState::new();
1662        state.open_at(0, 0);
1663        state.highlighted_index = 1;
1664
1665        // Navigation should not move to any item since none are selectable
1666        state.highlight_next(&items);
1667        assert_eq!(state.highlighted_index, 1); // Unchanged
1668
1669        state.highlight_prev(&items);
1670        assert_eq!(state.highlighted_index, 1); // Unchanged
1671    }
1672
1673    #[test]
1674    fn test_context_menu_widget_new() {
1675        let items = vec![ContextMenuItem::action("test", "Test")];
1676        let state = ContextMenuState::new();
1677        let menu = ContextMenu::new(&items, &state);
1678
1679        // Verify menu is created (we can't easily test rendering without Frame)
1680        assert!(!state.is_open);
1681    }
1682
1683    #[test]
1684    fn test_context_menu_widget_style() {
1685        let items = vec![ContextMenuItem::action("test", "Test")];
1686        let state = ContextMenuState::new();
1687        let style = ContextMenuStyle::light();
1688        let _menu = ContextMenu::new(&items, &state).style(style);
1689    }
1690
1691    #[test]
1692    fn test_is_context_menu_trigger_other_events() {
1693        use crossterm::event::KeyModifiers;
1694
1695        // Mouse move
1696        let mouse_move = MouseEvent {
1697            kind: MouseEventKind::Moved,
1698            column: 10,
1699            row: 5,
1700            modifiers: KeyModifiers::NONE,
1701        };
1702        assert!(!is_context_menu_trigger(&mouse_move));
1703
1704        // Mouse up
1705        let mouse_up = MouseEvent {
1706            kind: MouseEventKind::Up(MouseButton::Right),
1707            column: 10,
1708            row: 5,
1709            modifiers: KeyModifiers::NONE,
1710        };
1711        assert!(!is_context_menu_trigger(&mouse_up));
1712
1713        // Middle click
1714        let middle_click = MouseEvent {
1715            kind: MouseEventKind::Down(MouseButton::Middle),
1716            column: 10,
1717            row: 5,
1718            modifiers: KeyModifiers::NONE,
1719        };
1720        assert!(!is_context_menu_trigger(&middle_click));
1721
1722        // Scroll
1723        let scroll = MouseEvent {
1724            kind: MouseEventKind::ScrollUp,
1725            column: 10,
1726            row: 5,
1727            modifiers: KeyModifiers::NONE,
1728        };
1729        assert!(!is_context_menu_trigger(&scroll));
1730    }
1731
1732    #[test]
1733    fn test_context_menu_submenu_disabled() {
1734        let mut state = ContextMenuState::new();
1735        state.open_at(0, 0);
1736
1737        let items = vec![ContextMenuItem::submenu(
1738            "More",
1739            vec![ContextMenuItem::action("sub", "Sub")],
1740        )
1741        .enabled(false)];
1742
1743        // Right arrow on disabled submenu should not open it
1744        let key = KeyEvent::from(KeyCode::Right);
1745        let action = handle_context_menu_key(&key, &mut state, &items);
1746
1747        assert!(action.is_none());
1748        assert!(!state.has_open_submenu());
1749
1750        // Enter on disabled submenu should not open it
1751        let key = KeyEvent::from(KeyCode::Enter);
1752        let action = handle_context_menu_key(&key, &mut state, &items);
1753
1754        assert!(action.is_none());
1755        assert!(!state.has_open_submenu());
1756    }
1757}