Skip to main content

uzor_core/widgets/
context_menu.rs

1//! Context menu system for uzor
2//!
3//! Provides right-click context menus with keyboard shortcuts, icons,
4//! separators, and enabled/disabled states.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use uzor::input::context_menu::{ContextMenuState, ContextMenuItem};
10//! use uzor::input::shortcuts::KeyboardShortcut;
11//! use uzor::input::events::KeyCode;
12//!
13//! let mut menu = ContextMenuState::new();
14//!
15//! // Create menu items
16//! let items = vec![
17//!     ContextMenuItem::new("copy", "Copy")
18//!         .with_shortcut(KeyboardShortcut::command(KeyCode::C)),
19//!     ContextMenuItem::new("paste", "Paste")
20//!         .with_shortcut(KeyboardShortcut::command(KeyCode::V)),
21//!     ContextMenuItem::separator(),
22//!     ContextMenuItem::new("delete", "Delete").disabled(),
23//! ];
24//!
25//! // Open menu at cursor position
26//! menu.open((100.0, 200.0), items);
27//!
28//! // Handle click on item
29//! if let Some(item_id) = menu.handle_click(0) {
30//!     println!("Clicked: {}", item_id);
31//! }
32//! ```
33
34use crate::input::shortcuts::KeyboardShortcut;
35use crate::input::widget_state::WidgetId;
36
37// =============================================================================
38// ContextMenuItem
39// =============================================================================
40
41/// A single item in a context menu
42///
43/// Represents a menu item with optional shortcuts, icons, and visual separators.
44/// Items can be enabled or disabled to control user interaction.
45#[derive(Clone, Debug)]
46pub struct ContextMenuItem {
47    /// Unique identifier for the item
48    pub id: String,
49    /// Display label
50    pub label: String,
51    /// Optional keyboard shortcut hint
52    pub shortcut: Option<KeyboardShortcut>,
53    /// Whether the item is enabled
54    pub enabled: bool,
55    /// Whether to show a separator after this item
56    pub separator_after: bool,
57    /// Optional icon identifier
58    pub icon: Option<String>,
59}
60
61impl ContextMenuItem {
62    /// Create a new context menu item
63    ///
64    /// # Example
65    ///
66    /// ```ignore
67    /// let item = ContextMenuItem::new("copy", "Copy");
68    /// ```
69    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
70        Self {
71            id: id.into(),
72            label: label.into(),
73            shortcut: None,
74            enabled: true,
75            separator_after: false,
76            icon: None,
77        }
78    }
79
80    /// Add a keyboard shortcut to this item
81    ///
82    /// The shortcut will be displayed as a hint next to the item label.
83    ///
84    /// # Example
85    ///
86    /// ```ignore
87    /// let item = ContextMenuItem::new("copy", "Copy")
88    ///     .with_shortcut(KeyboardShortcut::command(KeyCode::C));
89    /// ```
90    pub fn with_shortcut(mut self, shortcut: KeyboardShortcut) -> Self {
91        self.shortcut = Some(shortcut);
92        self
93    }
94
95    /// Mark this item as disabled
96    ///
97    /// Disabled items will be displayed but cannot be clicked.
98    ///
99    /// # Example
100    ///
101    /// ```ignore
102    /// let item = ContextMenuItem::new("paste", "Paste").disabled();
103    /// ```
104    pub fn disabled(mut self) -> Self {
105        self.enabled = false;
106        self
107    }
108
109    /// Add a separator after this item
110    ///
111    /// A visual separator line will be drawn after this item in the menu.
112    ///
113    /// # Example
114    ///
115    /// ```ignore
116    /// let item = ContextMenuItem::new("copy", "Copy").with_separator();
117    /// ```
118    pub fn with_separator(mut self) -> Self {
119        self.separator_after = true;
120        self
121    }
122
123    /// Add an icon to this item
124    ///
125    /// The icon identifier can be used by the renderer to display an icon
126    /// next to the menu item.
127    ///
128    /// # Example
129    ///
130    /// ```ignore
131    /// let item = ContextMenuItem::new("open", "Open")
132    ///     .with_icon("folder-open");
133    /// ```
134    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
135        self.icon = Some(icon.into());
136        self
137    }
138
139    /// Create a separator-only item
140    ///
141    /// This creates a non-interactive item that only displays a separator.
142    /// The item will have an empty label and be disabled.
143    ///
144    /// # Example
145    ///
146    /// ```ignore
147    /// let items = vec![
148    ///     ContextMenuItem::new("copy", "Copy"),
149    ///     ContextMenuItem::separator(),
150    ///     ContextMenuItem::new("paste", "Paste"),
151    /// ];
152    /// ```
153    pub fn separator() -> Self {
154        Self {
155            id: String::new(),
156            label: String::new(),
157            shortcut: None,
158            enabled: false,
159            separator_after: true,
160            icon: None,
161        }
162    }
163
164    /// Check if this is a separator-only item
165    pub fn is_separator(&self) -> bool {
166        self.id.is_empty() && self.label.is_empty()
167    }
168}
169
170// =============================================================================
171// ContextMenuRequest
172// =============================================================================
173
174/// Request to show a context menu
175///
176/// Contains the menu items and position information. Optionally tracks
177/// the source widget that triggered the menu.
178#[derive(Clone, Debug)]
179pub struct ContextMenuRequest {
180    /// Position to show menu (usually at cursor)
181    pub position: (f64, f64),
182    /// Menu items
183    pub items: Vec<ContextMenuItem>,
184    /// Widget that triggered the menu (optional)
185    pub source_widget: Option<WidgetId>,
186}
187
188impl ContextMenuRequest {
189    /// Create a new context menu request
190    pub fn new(position: (f64, f64), items: Vec<ContextMenuItem>) -> Self {
191        Self {
192            position,
193            items,
194            source_widget: None,
195        }
196    }
197
198    /// Create a context menu request with a source widget
199    pub fn with_source(
200        position: (f64, f64),
201        items: Vec<ContextMenuItem>,
202        source: WidgetId,
203    ) -> Self {
204        Self {
205            position,
206            items,
207            source_widget: Some(source),
208        }
209    }
210}
211
212// =============================================================================
213// ContextMenuState
214// =============================================================================
215
216/// Manages context menu state
217///
218/// Tracks the currently active menu, hover state, and handles click detection.
219/// Only one context menu can be active at a time.
220#[derive(Clone, Debug, Default)]
221pub struct ContextMenuState {
222    /// Active menu request
223    active: Option<ContextMenuRequest>,
224    /// Currently hovered item index
225    hovered_item: Option<usize>,
226    /// Menu rect for hit testing (set after layout)
227    /// (x, y, width, height)
228    menu_rect: Option<(f64, f64, f64, f64)>,
229}
230
231impl ContextMenuState {
232    /// Create a new context menu state
233    ///
234    /// # Example
235    ///
236    /// ```ignore
237    /// let mut menu = ContextMenuState::new();
238    /// ```
239    pub fn new() -> Self {
240        Self::default()
241    }
242
243    /// Open a context menu at the specified position
244    ///
245    /// If a menu is already open, it will be replaced with the new menu.
246    ///
247    /// # Example
248    ///
249    /// ```ignore
250    /// let items = vec![
251    ///     ContextMenuItem::new("copy", "Copy"),
252    ///     ContextMenuItem::new("paste", "Paste"),
253    /// ];
254    /// menu.open((100.0, 200.0), items);
255    /// ```
256    pub fn open(&mut self, position: (f64, f64), items: Vec<ContextMenuItem>) {
257        self.active = Some(ContextMenuRequest::new(position, items));
258        self.hovered_item = None;
259        self.menu_rect = None;
260    }
261
262    /// Open a context menu with a source widget
263    ///
264    /// This allows tracking which widget triggered the menu, which can be
265    /// useful for context-specific actions.
266    ///
267    /// # Example
268    ///
269    /// ```ignore
270    /// let widget_id = WidgetId::new("my_widget");
271    /// menu.open_for_widget((100.0, 200.0), items, widget_id);
272    /// ```
273    pub fn open_for_widget(
274        &mut self,
275        position: (f64, f64),
276        items: Vec<ContextMenuItem>,
277        widget: WidgetId,
278    ) {
279        self.active = Some(ContextMenuRequest::with_source(position, items, widget));
280        self.hovered_item = None;
281        self.menu_rect = None;
282    }
283
284    /// Close the context menu
285    ///
286    /// Clears the active menu and all associated state.
287    ///
288    /// # Example
289    ///
290    /// ```ignore
291    /// menu.close();
292    /// ```
293    pub fn close(&mut self) {
294        self.active = None;
295        self.hovered_item = None;
296        self.menu_rect = None;
297    }
298
299    /// Check if a context menu is currently open
300    ///
301    /// # Example
302    ///
303    /// ```ignore
304    /// if menu.is_open() {
305    ///     // Render menu
306    /// }
307    /// ```
308    pub fn is_open(&self) -> bool {
309        self.active.is_some()
310    }
311
312    /// Get the active menu request
313    ///
314    /// Returns None if no menu is currently open.
315    ///
316    /// # Example
317    ///
318    /// ```ignore
319    /// if let Some(request) = menu.get_active() {
320    ///     for item in &request.items {
321    ///         println!("{}", item.label);
322    ///     }
323    /// }
324    /// ```
325    pub fn get_active(&self) -> Option<&ContextMenuRequest> {
326        self.active.as_ref()
327    }
328
329    /// Set the menu rectangle for hit testing
330    ///
331    /// This should be called after layout to enable proper click detection.
332    /// The rectangle is in screen coordinates (x, y, width, height).
333    ///
334    /// # Example
335    ///
336    /// ```ignore
337    /// menu.set_menu_rect((100.0, 200.0, 150.0, 100.0));
338    /// ```
339    pub fn set_menu_rect(&mut self, rect: (f64, f64, f64, f64)) {
340        self.menu_rect = Some(rect);
341    }
342
343    /// Get the current menu rectangle
344    pub fn get_menu_rect(&self) -> Option<(f64, f64, f64, f64)> {
345        self.menu_rect
346    }
347
348    /// Set the currently hovered item
349    ///
350    /// Pass None to clear hover state.
351    ///
352    /// # Example
353    ///
354    /// ```ignore
355    /// menu.set_hovered(Some(2)); // Hover third item
356    /// menu.set_hovered(None);     // Clear hover
357    /// ```
358    pub fn set_hovered(&mut self, index: Option<usize>) {
359        self.hovered_item = index;
360    }
361
362    /// Get the currently hovered item index
363    ///
364    /// Returns None if no item is hovered.
365    ///
366    /// # Example
367    ///
368    /// ```ignore
369    /// if let Some(index) = menu.get_hovered() {
370    ///     println!("Hovering item {}", index);
371    /// }
372    /// ```
373    pub fn get_hovered(&self) -> Option<usize> {
374        self.hovered_item
375    }
376
377    /// Handle a click on a menu item
378    ///
379    /// Returns the item ID if the item is enabled and was clicked successfully.
380    /// Returns None if the item is disabled or out of bounds.
381    ///
382    /// # Example
383    ///
384    /// ```ignore
385    /// if let Some(item_id) = menu.handle_click(0) {
386    ///     match item_id.as_str() {
387    ///         "copy" => copy_to_clipboard(),
388    ///         "paste" => paste_from_clipboard(),
389    ///         _ => {}
390    ///     }
391    ///     menu.close();
392    /// }
393    /// ```
394    pub fn handle_click(&mut self, index: usize) -> Option<String> {
395        if let Some(ref request) = self.active {
396            if let Some(item) = request.items.get(index) {
397                if item.enabled && !item.is_separator() {
398                    return Some(item.id.clone());
399                }
400            }
401        }
402        None
403    }
404
405    /// Get the number of items in the active menu
406    ///
407    /// Returns 0 if no menu is open.
408    ///
409    /// # Example
410    ///
411    /// ```ignore
412    /// let count = menu.item_count();
413    /// ```
414    pub fn item_count(&self) -> usize {
415        self.active
416            .as_ref()
417            .map(|req| req.items.len())
418            .unwrap_or(0)
419    }
420
421    /// Get a menu item by index
422    ///
423    /// Returns None if the index is out of bounds or no menu is open.
424    ///
425    /// # Example
426    ///
427    /// ```ignore
428    /// if let Some(item) = menu.get_item(0) {
429    ///     println!("First item: {}", item.label);
430    /// }
431    /// ```
432    pub fn get_item(&self, index: usize) -> Option<&ContextMenuItem> {
433        self.active
434            .as_ref()
435            .and_then(|req| req.items.get(index))
436    }
437}
438
439// =============================================================================
440// ContextMenuResult
441// =============================================================================
442
443/// Result of context menu input handling
444///
445/// Contains information about what happened during input handling,
446/// allowing the application to respond appropriately.
447#[derive(Clone, Debug, Default)]
448pub struct ContextMenuResult {
449    /// Whether menu should close
450    pub should_close: bool,
451    /// Item that was clicked (id)
452    pub clicked_item: Option<String>,
453    /// Currently hovered item index
454    pub hovered_index: Option<usize>,
455}
456
457impl ContextMenuResult {
458    /// Create a new empty result
459    pub fn new() -> Self {
460        Self::default()
461    }
462
463    /// Create a result indicating the menu should close
464    pub fn close() -> Self {
465        Self {
466            should_close: true,
467            ..Default::default()
468        }
469    }
470
471    /// Create a result with a clicked item
472    pub fn clicked(item_id: String) -> Self {
473        Self {
474            should_close: true,
475            clicked_item: Some(item_id),
476            hovered_index: None,
477        }
478    }
479
480    /// Create a result with hover state
481    pub fn hovered(index: usize) -> Self {
482        Self {
483            should_close: false,
484            clicked_item: None,
485            hovered_index: Some(index),
486        }
487    }
488}
489
490// =============================================================================
491// Input Handling
492// =============================================================================
493
494/// Handle input for context menu
495///
496/// This is a helper function that processes mouse input for context menus.
497/// It handles hover detection, clicks, and outside clicks.
498///
499/// # Arguments
500///
501/// * `state` - The context menu state to update
502/// * `item_rects` - List of (index, rect) pairs for each menu item
503/// * `cursor_pos` - Current cursor position (None if not available)
504/// * `clicked` - Whether a mouse click occurred this frame
505/// * `clicked_outside` - Whether a click occurred outside the menu
506///
507/// # Returns
508///
509/// A ContextMenuResult indicating what happened
510///
511/// # Example
512///
513/// ```ignore
514/// let item_rects = vec![
515///     (0, (100.0, 200.0, 150.0, 20.0)),
516///     (1, (100.0, 220.0, 150.0, 20.0)),
517/// ];
518///
519/// let result = handle_context_menu_input(
520///     &mut menu,
521///     &item_rects,
522///     Some((125.0, 210.0)),
523///     true,
524///     false,
525/// );
526///
527/// if result.should_close {
528///     menu.close();
529/// }
530/// ```
531#[allow(clippy::type_complexity)]
532pub fn handle_context_menu_input(
533    state: &mut ContextMenuState,
534    item_rects: &[(usize, (f64, f64, f64, f64))],
535    cursor_pos: Option<(f64, f64)>,
536    clicked: bool,
537    clicked_outside: bool,
538) -> ContextMenuResult {
539    // If clicked outside, close menu
540    if clicked_outside {
541        return ContextMenuResult::close();
542    }
543
544    // Check cursor position against items
545    if let Some((cx, cy)) = cursor_pos {
546        let mut hovered_index = None;
547
548        for &(index, (x, y, w, h)) in item_rects {
549            if cx >= x && cx < x + w && cy >= y && cy < y + h {
550                hovered_index = Some(index);
551                break;
552            }
553        }
554
555        // Update hover state
556        state.set_hovered(hovered_index);
557
558        // Handle click
559        if clicked {
560            if let Some(index) = hovered_index {
561                if let Some(item_id) = state.handle_click(index) {
562                    return ContextMenuResult::clicked(item_id);
563                }
564            }
565        }
566
567        // Return hover state
568        if let Some(index) = hovered_index {
569            return ContextMenuResult::hovered(index);
570        }
571    }
572
573    ContextMenuResult::new()
574}
575
576// =============================================================================
577// Tests
578// =============================================================================
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::input::events::KeyCode;
584
585    #[test]
586    fn test_menu_item_creation() {
587        let item = ContextMenuItem::new("copy", "Copy");
588        assert_eq!(item.id, "copy");
589        assert_eq!(item.label, "Copy");
590        assert!(item.enabled);
591        assert!(!item.separator_after);
592        assert!(item.shortcut.is_none());
593        assert!(item.icon.is_none());
594    }
595
596    #[test]
597    fn test_menu_item_builder() {
598        let item = ContextMenuItem::new("copy", "Copy")
599            .with_shortcut(KeyboardShortcut::command(KeyCode::C))
600            .with_icon("clipboard")
601            .with_separator();
602
603        assert!(item.shortcut.is_some());
604        assert_eq!(item.icon, Some("clipboard".to_string()));
605        assert!(item.separator_after);
606        assert!(item.enabled);
607    }
608
609    #[test]
610    fn test_menu_item_disabled() {
611        let item = ContextMenuItem::new("paste", "Paste").disabled();
612        assert!(!item.enabled);
613    }
614
615    #[test]
616    fn test_separator_item() {
617        let sep = ContextMenuItem::separator();
618        assert!(sep.is_separator());
619        assert!(!sep.enabled);
620        assert!(sep.separator_after);
621        assert_eq!(sep.id, "");
622        assert_eq!(sep.label, "");
623    }
624
625    #[test]
626    fn test_menu_state_open_close() {
627        let mut menu = ContextMenuState::new();
628        assert!(!menu.is_open());
629
630        let items = vec![
631            ContextMenuItem::new("copy", "Copy"),
632            ContextMenuItem::new("paste", "Paste"),
633        ];
634
635        menu.open((100.0, 200.0), items);
636        assert!(menu.is_open());
637        assert_eq!(menu.item_count(), 2);
638
639        menu.close();
640        assert!(!menu.is_open());
641        assert_eq!(menu.item_count(), 0);
642    }
643
644    #[test]
645    fn test_menu_state_with_widget() {
646        let mut menu = ContextMenuState::new();
647        let widget_id = WidgetId::new("my_widget");
648
649        let items = vec![ContextMenuItem::new("action", "Action")];
650
651        menu.open_for_widget((100.0, 200.0), items, widget_id.clone());
652
653        assert!(menu.is_open());
654        let request = menu.get_active().unwrap();
655        assert_eq!(request.source_widget, Some(widget_id));
656    }
657
658    #[test]
659    fn test_hover_tracking() {
660        let mut menu = ContextMenuState::new();
661        let items = vec![
662            ContextMenuItem::new("item1", "Item 1"),
663            ContextMenuItem::new("item2", "Item 2"),
664        ];
665
666        menu.open((100.0, 200.0), items);
667
668        assert_eq!(menu.get_hovered(), None);
669
670        menu.set_hovered(Some(0));
671        assert_eq!(menu.get_hovered(), Some(0));
672
673        menu.set_hovered(Some(1));
674        assert_eq!(menu.get_hovered(), Some(1));
675
676        menu.set_hovered(None);
677        assert_eq!(menu.get_hovered(), None);
678    }
679
680    #[test]
681    fn test_click_handling_enabled() {
682        let mut menu = ContextMenuState::new();
683        let items = vec![
684            ContextMenuItem::new("copy", "Copy"),
685            ContextMenuItem::new("paste", "Paste"),
686        ];
687
688        menu.open((100.0, 200.0), items);
689
690        let result = menu.handle_click(0);
691        assert_eq!(result, Some("copy".to_string()));
692
693        let result = menu.handle_click(1);
694        assert_eq!(result, Some("paste".to_string()));
695    }
696
697    #[test]
698    fn test_click_handling_disabled() {
699        let mut menu = ContextMenuState::new();
700        let items = vec![
701            ContextMenuItem::new("copy", "Copy"),
702            ContextMenuItem::new("paste", "Paste").disabled(),
703        ];
704
705        menu.open((100.0, 200.0), items);
706
707        // Enabled item should work
708        let result = menu.handle_click(0);
709        assert_eq!(result, Some("copy".to_string()));
710
711        // Disabled item should not work
712        let result = menu.handle_click(1);
713        assert_eq!(result, None);
714    }
715
716    #[test]
717    fn test_click_handling_separator() {
718        let mut menu = ContextMenuState::new();
719        let items = vec![
720            ContextMenuItem::new("copy", "Copy"),
721            ContextMenuItem::separator(),
722            ContextMenuItem::new("paste", "Paste"),
723        ];
724
725        menu.open((100.0, 200.0), items);
726
727        // Separator should not be clickable
728        let result = menu.handle_click(1);
729        assert_eq!(result, None);
730
731        // Regular items should work
732        let result = menu.handle_click(0);
733        assert_eq!(result, Some("copy".to_string()));
734
735        let result = menu.handle_click(2);
736        assert_eq!(result, Some("paste".to_string()));
737    }
738
739    #[test]
740    fn test_click_out_of_bounds() {
741        let mut menu = ContextMenuState::new();
742        let items = vec![ContextMenuItem::new("copy", "Copy")];
743
744        menu.open((100.0, 200.0), items);
745
746        let result = menu.handle_click(5);
747        assert_eq!(result, None);
748    }
749
750    #[test]
751    fn test_get_item() {
752        let mut menu = ContextMenuState::new();
753        let items = vec![
754            ContextMenuItem::new("copy", "Copy"),
755            ContextMenuItem::new("paste", "Paste"),
756        ];
757
758        menu.open((100.0, 200.0), items);
759
760        let item = menu.get_item(0).unwrap();
761        assert_eq!(item.id, "copy");
762
763        let item = menu.get_item(1).unwrap();
764        assert_eq!(item.id, "paste");
765
766        assert!(menu.get_item(2).is_none());
767    }
768
769    #[test]
770    fn test_menu_rect() {
771        let mut menu = ContextMenuState::new();
772        assert_eq!(menu.get_menu_rect(), None);
773
774        menu.set_menu_rect((100.0, 200.0, 150.0, 80.0));
775        assert_eq!(menu.get_menu_rect(), Some((100.0, 200.0, 150.0, 80.0)));
776    }
777
778    #[test]
779    fn test_handle_input_hover() {
780        let mut menu = ContextMenuState::new();
781        let items = vec![
782            ContextMenuItem::new("item1", "Item 1"),
783            ContextMenuItem::new("item2", "Item 2"),
784        ];
785        menu.open((100.0, 200.0), items);
786
787        let item_rects = vec![
788            (0, (100.0, 200.0, 150.0, 20.0)),
789            (1, (100.0, 220.0, 150.0, 20.0)),
790        ];
791
792        // Hover over first item
793        let result = handle_context_menu_input(
794            &mut menu,
795            &item_rects,
796            Some((125.0, 210.0)),
797            false,
798            false,
799        );
800
801        assert!(!result.should_close);
802        assert_eq!(result.hovered_index, Some(0));
803        assert_eq!(menu.get_hovered(), Some(0));
804
805        // Hover over second item
806        let result = handle_context_menu_input(
807            &mut menu,
808            &item_rects,
809            Some((125.0, 230.0)),
810            false,
811            false,
812        );
813
814        assert_eq!(result.hovered_index, Some(1));
815        assert_eq!(menu.get_hovered(), Some(1));
816    }
817
818    #[test]
819    fn test_handle_input_click() {
820        let mut menu = ContextMenuState::new();
821        let items = vec![
822            ContextMenuItem::new("copy", "Copy"),
823            ContextMenuItem::new("paste", "Paste"),
824        ];
825        menu.open((100.0, 200.0), items);
826
827        let item_rects = vec![
828            (0, (100.0, 200.0, 150.0, 20.0)),
829            (1, (100.0, 220.0, 150.0, 20.0)),
830        ];
831
832        // Click first item
833        let result = handle_context_menu_input(
834            &mut menu,
835            &item_rects,
836            Some((125.0, 210.0)),
837            true,
838            false,
839        );
840
841        assert!(result.should_close);
842        assert_eq!(result.clicked_item, Some("copy".to_string()));
843    }
844
845    #[test]
846    fn test_handle_input_click_outside() {
847        let mut menu = ContextMenuState::new();
848        let items = vec![ContextMenuItem::new("copy", "Copy")];
849        menu.open((100.0, 200.0), items);
850
851        let item_rects = vec![(0, (100.0, 200.0, 150.0, 20.0))];
852
853        // Click outside
854        let result = handle_context_menu_input(
855            &mut menu,
856            &item_rects,
857            Some((50.0, 50.0)),
858            true,
859            true,
860        );
861
862        assert!(result.should_close);
863        assert_eq!(result.clicked_item, None);
864    }
865
866    #[test]
867    fn test_handle_input_disabled_item() {
868        let mut menu = ContextMenuState::new();
869        let items = vec![
870            ContextMenuItem::new("copy", "Copy"),
871            ContextMenuItem::new("paste", "Paste").disabled(),
872        ];
873        menu.open((100.0, 200.0), items);
874
875        let item_rects = vec![
876            (0, (100.0, 200.0, 150.0, 20.0)),
877            (1, (100.0, 220.0, 150.0, 20.0)),
878        ];
879
880        // Click disabled item
881        let result = handle_context_menu_input(
882            &mut menu,
883            &item_rects,
884            Some((125.0, 230.0)),
885            true,
886            false,
887        );
888
889        // Should not trigger click
890        assert!(!result.should_close);
891        assert_eq!(result.clicked_item, None);
892    }
893
894    #[test]
895    fn test_context_menu_result() {
896        let result = ContextMenuResult::new();
897        assert!(!result.should_close);
898        assert_eq!(result.clicked_item, None);
899        assert_eq!(result.hovered_index, None);
900
901        let result = ContextMenuResult::close();
902        assert!(result.should_close);
903
904        let result = ContextMenuResult::clicked("copy".to_string());
905        assert!(result.should_close);
906        assert_eq!(result.clicked_item, Some("copy".to_string()));
907
908        let result = ContextMenuResult::hovered(2);
909        assert!(!result.should_close);
910        assert_eq!(result.hovered_index, Some(2));
911    }
912}