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}