Skip to main content

presentar_widgets/
menu.rs

1//! Menu and dropdown widgets for action lists.
2//!
3//! Provides hierarchical menus with keyboard navigation, submenus,
4//! and various item types (actions, checkable, separators).
5
6use presentar_core::{
7    widget::{LayoutResult, TextStyle},
8    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
9    Point, Rect, Size, TypeId, Widget,
10};
11use serde::{Deserialize, Serialize};
12use std::any::Any;
13use std::time::Duration;
14
15/// Menu item variant.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub enum MenuItem {
18    /// Action item with label
19    Action {
20        /// Item label
21        label: String,
22        /// Unique action ID
23        action: String,
24        /// Whether item is disabled
25        disabled: bool,
26        /// Optional keyboard shortcut display
27        shortcut: Option<String>,
28    },
29    /// Checkable item
30    Checkbox {
31        /// Item label
32        label: String,
33        /// Action ID
34        action: String,
35        /// Whether checked
36        checked: bool,
37        /// Whether disabled
38        disabled: bool,
39    },
40    /// Separator line
41    Separator,
42    /// Submenu
43    Submenu {
44        /// Submenu label
45        label: String,
46        /// Child items
47        items: Vec<Self>,
48        /// Whether disabled
49        disabled: bool,
50    },
51}
52
53impl MenuItem {
54    /// Create a new action item.
55    #[must_use]
56    pub fn action(label: impl Into<String>, action: impl Into<String>) -> Self {
57        Self::Action {
58            label: label.into(),
59            action: action.into(),
60            disabled: false,
61            shortcut: None,
62        }
63    }
64
65    /// Create a new checkbox item.
66    #[must_use]
67    pub fn checkbox(label: impl Into<String>, action: impl Into<String>, checked: bool) -> Self {
68        Self::Checkbox {
69            label: label.into(),
70            action: action.into(),
71            checked,
72            disabled: false,
73        }
74    }
75
76    /// Create a separator.
77    #[must_use]
78    pub const fn separator() -> Self {
79        Self::Separator
80    }
81
82    /// Create a submenu.
83    #[must_use]
84    pub fn submenu(label: impl Into<String>, items: Vec<Self>) -> Self {
85        Self::Submenu {
86            label: label.into(),
87            items,
88            disabled: false,
89        }
90    }
91
92    /// Set disabled state.
93    #[must_use]
94    pub fn disabled(mut self, disabled: bool) -> Self {
95        match &mut self {
96            Self::Action { disabled: d, .. }
97            | Self::Checkbox { disabled: d, .. }
98            | Self::Submenu { disabled: d, .. } => *d = disabled,
99            Self::Separator => {}
100        }
101        self
102    }
103
104    /// Set shortcut (for action items).
105    #[must_use]
106    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
107        if let Self::Action { shortcut: s, .. } = &mut self {
108            *s = Some(shortcut.into());
109        }
110        self
111    }
112
113    /// Check if this item is selectable (not separator, not disabled).
114    #[must_use]
115    pub fn is_selectable(&self) -> bool {
116        match self {
117            Self::Action { disabled, .. }
118            | Self::Checkbox { disabled, .. }
119            | Self::Submenu { disabled, .. } => !disabled,
120            Self::Separator => false,
121        }
122    }
123
124    /// Get item height.
125    #[must_use]
126    pub const fn height(&self) -> f32 {
127        match self {
128            Self::Separator => 9.0, // 1px line + 4px padding each side
129            _ => 32.0,
130        }
131    }
132}
133
134/// Menu trigger mode.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
136pub enum MenuTrigger {
137    /// Click to open
138    #[default]
139    Click,
140    /// Hover to open
141    Hover,
142    /// Right-click (context menu)
143    ContextMenu,
144}
145
146/// Menu widget for dropdown actions.
147#[derive(Serialize, Deserialize)]
148pub struct Menu {
149    /// Menu items
150    pub items: Vec<MenuItem>,
151    /// Whether menu is open
152    pub open: bool,
153    /// Trigger mode
154    pub trigger: MenuTrigger,
155    /// Menu width
156    pub width: f32,
157    /// Background color
158    pub background_color: Color,
159    /// Hover color
160    pub hover_color: Color,
161    /// Text color
162    pub text_color: Color,
163    /// Disabled text color
164    pub disabled_color: Color,
165    /// Test ID
166    test_id_value: Option<String>,
167    /// Cached bounds
168    #[serde(skip)]
169    bounds: Rect,
170    /// Panel bounds (dropdown area)
171    #[serde(skip)]
172    panel_bounds: Rect,
173    /// Currently highlighted index
174    #[serde(skip)]
175    highlighted_index: Option<usize>,
176    /// Open submenu index
177    #[serde(skip)]
178    open_submenu: Option<usize>,
179    /// Trigger widget (zero or one; a Vec so `children()` lists it)
180    #[serde(skip)]
181    trigger_widget: Vec<Box<dyn Widget>>,
182}
183
184impl Default for Menu {
185    fn default() -> Self {
186        Self {
187            items: Vec::new(),
188            open: false,
189            trigger: MenuTrigger::Click,
190            width: 200.0,
191            background_color: Color::WHITE,
192            hover_color: Color::rgba(0.0, 0.0, 0.0, 0.1),
193            text_color: Color::BLACK,
194            disabled_color: Color::rgb(0.6, 0.6, 0.6),
195            test_id_value: None,
196            bounds: Rect::default(),
197            panel_bounds: Rect::default(),
198            highlighted_index: None,
199            open_submenu: None,
200            trigger_widget: Vec::new(),
201        }
202    }
203}
204
205impl Menu {
206    /// Create a new menu.
207    #[must_use]
208    pub fn new() -> Self {
209        Self::default()
210    }
211
212    /// Set menu items.
213    #[must_use]
214    pub fn items(mut self, items: Vec<MenuItem>) -> Self {
215        self.items = items;
216        self
217    }
218
219    /// Add a single item.
220    #[must_use]
221    pub fn item(mut self, item: MenuItem) -> Self {
222        self.items.push(item);
223        self
224    }
225
226    /// Set trigger mode.
227    #[must_use]
228    pub const fn trigger(mut self, trigger: MenuTrigger) -> Self {
229        self.trigger = trigger;
230        self
231    }
232
233    /// Set menu width.
234    #[must_use]
235    pub const fn width(mut self, width: f32) -> Self {
236        self.width = width;
237        self
238    }
239
240    /// Set background color.
241    #[must_use]
242    pub const fn background_color(mut self, color: Color) -> Self {
243        self.background_color = color;
244        self
245    }
246
247    /// Set hover color.
248    #[must_use]
249    pub const fn hover_color(mut self, color: Color) -> Self {
250        self.hover_color = color;
251        self
252    }
253
254    /// Set text color.
255    #[must_use]
256    pub const fn text_color(mut self, color: Color) -> Self {
257        self.text_color = color;
258        self
259    }
260
261    /// Set the trigger widget.
262    pub fn trigger_widget(mut self, widget: impl Widget + 'static) -> Self {
263        self.trigger_widget = vec![Box::new(widget)];
264        self
265    }
266
267    /// Set test ID.
268    #[must_use]
269    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
270        self.test_id_value = Some(id.into());
271        self
272    }
273
274    /// Open the menu.
275    pub fn show(&mut self) {
276        self.open = true;
277        self.highlighted_index = None;
278    }
279
280    /// Close the menu.
281    pub fn hide(&mut self) {
282        self.open = false;
283        self.highlighted_index = None;
284        self.open_submenu = None;
285    }
286
287    /// Toggle the menu.
288    pub fn toggle(&mut self) {
289        if self.open {
290            self.hide();
291        } else {
292            self.show();
293        }
294    }
295
296    /// Check if menu is open.
297    #[must_use]
298    pub const fn is_open(&self) -> bool {
299        self.open
300    }
301
302    /// Get highlighted index.
303    #[must_use]
304    pub const fn highlighted_index(&self) -> Option<usize> {
305        self.highlighted_index
306    }
307
308    /// Calculate total menu height.
309    fn calculate_menu_height(&self) -> f32 {
310        let padding = 8.0; // Top and bottom padding
311        let items_height: f32 = self.items.iter().map(MenuItem::height).sum();
312        items_height + padding * 2.0
313    }
314
315    /// Find next selectable item.
316    fn next_selectable(&self, from: Option<usize>, forward: bool) -> Option<usize> {
317        if self.items.is_empty() {
318            return None;
319        }
320
321        let start = from.map_or_else(
322            || if forward { 0 } else { self.items.len() - 1 },
323            |i| {
324                if forward {
325                    if i + 1 >= self.items.len() {
326                        0
327                    } else {
328                        i + 1
329                    }
330                } else if i == 0 {
331                    self.items.len() - 1
332                } else {
333                    i - 1
334                }
335            },
336        );
337
338        let mut idx = start;
339        for _ in 0..self.items.len() {
340            if self.items[idx].is_selectable() {
341                return Some(idx);
342            }
343            if forward {
344                idx = if idx + 1 >= self.items.len() {
345                    0
346                } else {
347                    idx + 1
348                };
349            } else {
350                idx = if idx == 0 {
351                    self.items.len() - 1
352                } else {
353                    idx - 1
354                };
355            }
356        }
357
358        None
359    }
360
361    /// Get item at y position.
362    fn item_at_position(&self, y: f32) -> Option<usize> {
363        let relative_y = y - self.panel_bounds.y - 8.0; // Subtract padding
364        if relative_y < 0.0 {
365            return None;
366        }
367
368        let mut current_y = 0.0;
369        for (i, item) in self.items.iter().enumerate() {
370            let height = item.height();
371            if relative_y >= current_y && relative_y < current_y + height {
372                return Some(i);
373            }
374            current_y += height;
375        }
376
377        None
378    }
379
380    /// Whether `p` lies inside `r` (inclusive edges).
381    fn rect_contains(r: &Rect, p: &Point) -> bool {
382        p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height
383    }
384
385    /// Activate the item at `idx` (mouse click or Enter/Space).
386    ///
387    /// Submenu items open their submenu only when `allow_submenu` is set.
388    fn activate_item(&mut self, idx: usize, allow_submenu: bool) -> Option<Box<dyn Any + Send>> {
389        let item = self.items.get_mut(idx)?;
390        match item {
391            MenuItem::Action {
392                action, disabled, ..
393            } if !*disabled => {
394                let action_id = action.clone();
395                self.hide();
396                Some(Box::new(MenuItemSelected { action: action_id }))
397            }
398            MenuItem::Checkbox {
399                action,
400                checked,
401                disabled,
402                ..
403            } if !*disabled => {
404                *checked = !*checked;
405                let action_id = action.clone();
406                let is_checked = *checked;
407                Some(Box::new(MenuCheckboxToggled {
408                    action: action_id,
409                    checked: is_checked,
410                }))
411            }
412            MenuItem::Submenu { disabled, .. } if allow_submenu && !*disabled => {
413                self.open_submenu = Some(idx);
414                None
415            }
416            _ => None,
417        }
418    }
419
420    /// Handle a mouse press.
421    fn on_mouse_down(&mut self, position: &Point) -> Option<Box<dyn Any + Send>> {
422        if Self::rect_contains(&self.bounds, position) && self.trigger == MenuTrigger::Click {
423            self.toggle();
424            return Some(Box::new(MenuToggled { open: self.open }));
425        }
426        if !self.open {
427            return None;
428        }
429        if !Self::rect_contains(&self.panel_bounds, position) {
430            // Click outside menu - close
431            self.hide();
432            return Some(Box::new(MenuClosed));
433        }
434        let idx = self.item_at_position(position.y)?;
435        self.activate_item(idx, true)
436    }
437
438    /// Handle a mouse move.
439    fn on_mouse_move(&mut self, position: &Point) {
440        if !self.open {
441            return;
442        }
443        self.highlighted_index = if Self::rect_contains(&self.panel_bounds, position) {
444            self.item_at_position(position.y)
445        } else {
446            None
447        };
448    }
449
450    /// Handle a key press while the menu is open.
451    fn on_key_down(&mut self, key: &Key) -> Option<Box<dyn Any + Send>> {
452        match key {
453            Key::Escape => {
454                self.hide();
455                Some(Box::new(MenuClosed))
456            }
457            Key::Up => {
458                self.highlighted_index = self.next_selectable(self.highlighted_index, false);
459                None
460            }
461            Key::Down => {
462                self.highlighted_index = self.next_selectable(self.highlighted_index, true);
463                None
464            }
465            Key::Enter | Key::Space => {
466                let idx = self.highlighted_index?;
467                self.activate_item(idx, false)
468            }
469            _ => None,
470        }
471    }
472
473    /// Fill the hover background for row `i` when highlighted and enabled.
474    fn paint_hover(&self, canvas: &mut dyn Canvas, i: usize, y: f32, height: f32, disabled: bool) {
475        if self.highlighted_index == Some(i) && !disabled {
476            let hover_rect = Rect::new(self.panel_bounds.x, y, self.panel_bounds.width, height);
477            canvas.fill_rect(hover_rect, self.hover_color);
478        }
479    }
480
481    /// Paint one menu row.
482    fn paint_item(&self, canvas: &mut dyn Canvas, i: usize, item: &MenuItem, y: f32) {
483        let height = item.height();
484        let text_style = TextStyle {
485            size: 14.0,
486            color: self.text_color,
487            ..Default::default()
488        };
489        let disabled_style = TextStyle {
490            size: 14.0,
491            color: self.disabled_color,
492            ..Default::default()
493        };
494        match item {
495            MenuItem::Action {
496                label,
497                disabled,
498                shortcut,
499                ..
500            } => {
501                self.paint_hover(canvas, i, y, height, *disabled);
502                let style = if *disabled {
503                    &disabled_style
504                } else {
505                    &text_style
506                };
507                canvas.draw_text(
508                    label,
509                    Point::new(self.panel_bounds.x + 12.0, y + 20.0),
510                    style,
511                );
512                if let Some(shortcut) = shortcut {
513                    let shortcut_style = TextStyle {
514                        size: 12.0,
515                        color: self.disabled_color,
516                        ..Default::default()
517                    };
518                    canvas.draw_text(
519                        shortcut,
520                        Point::new(
521                            self.panel_bounds.x + self.panel_bounds.width - 60.0,
522                            y + 20.0,
523                        ),
524                        &shortcut_style,
525                    );
526                }
527            }
528            MenuItem::Checkbox {
529                label,
530                checked,
531                disabled,
532                ..
533            } => {
534                self.paint_hover(canvas, i, y, height, *disabled);
535                let check_text = if *checked { "✓" } else { " " };
536                let style = if *disabled {
537                    &disabled_style
538                } else {
539                    &text_style
540                };
541                canvas.draw_text(
542                    check_text,
543                    Point::new(self.panel_bounds.x + 12.0, y + 20.0),
544                    style,
545                );
546                canvas.draw_text(
547                    label,
548                    Point::new(self.panel_bounds.x + 32.0, y + 20.0),
549                    style,
550                );
551            }
552            MenuItem::Separator => {
553                let line_y = y + 4.0;
554                canvas.draw_line(
555                    Point::new(self.panel_bounds.x + 8.0, line_y),
556                    Point::new(self.panel_bounds.x + self.panel_bounds.width - 8.0, line_y),
557                    Color::rgb(0.9, 0.9, 0.9),
558                    1.0,
559                );
560            }
561            MenuItem::Submenu {
562                label, disabled, ..
563            } => {
564                self.paint_hover(canvas, i, y, height, *disabled);
565                let style = if *disabled {
566                    &disabled_style
567                } else {
568                    &text_style
569                };
570                canvas.draw_text(
571                    label,
572                    Point::new(self.panel_bounds.x + 12.0, y + 20.0),
573                    style,
574                );
575                canvas.draw_text(
576                    "›",
577                    Point::new(
578                        self.panel_bounds.x + self.panel_bounds.width - 20.0,
579                        y + 20.0,
580                    ),
581                    style,
582                );
583            }
584        }
585    }
586}
587
588impl Widget for Menu {
589    fn type_id(&self) -> TypeId {
590        TypeId::of::<Self>()
591    }
592
593    fn measure(&self, constraints: Constraints) -> Size {
594        // Measure just the trigger area
595        if let Some(trigger) = self.trigger_widget.first() {
596            trigger.measure(constraints)
597        } else {
598            Size::new(self.width.min(constraints.max_width), 32.0)
599        }
600    }
601
602    fn layout(&mut self, bounds: Rect) -> LayoutResult {
603        self.bounds = bounds;
604
605        // Layout trigger widget
606        if let Some(trigger) = self.trigger_widget.first_mut() {
607            trigger.layout(bounds);
608        }
609
610        // Calculate menu panel bounds (below trigger)
611        if self.open {
612            let menu_height = self.calculate_menu_height();
613            self.panel_bounds =
614                Rect::new(bounds.x, bounds.y + bounds.height, self.width, menu_height);
615        }
616
617        LayoutResult {
618            size: bounds.size(),
619        }
620    }
621
622    fn paint(&self, canvas: &mut dyn Canvas) {
623        // Paint trigger
624        if let Some(trigger) = self.trigger_widget.first() {
625            trigger.paint(canvas);
626        }
627
628        if !self.open {
629            return;
630        }
631
632        // Paint menu background with shadow
633        let shadow_bounds = Rect::new(
634            self.panel_bounds.x + 2.0,
635            self.panel_bounds.y + 2.0,
636            self.panel_bounds.width,
637            self.panel_bounds.height,
638        );
639        canvas.fill_rect(shadow_bounds, Color::rgba(0.0, 0.0, 0.0, 0.1));
640        canvas.fill_rect(self.panel_bounds, self.background_color);
641
642        // Paint items
643        let mut y = self.panel_bounds.y + 8.0; // Top padding
644        for (i, item) in self.items.iter().enumerate() {
645            self.paint_item(canvas, i, item, y);
646            y += item.height();
647        }
648    }
649
650    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
651        match event {
652            Event::MouseDown { position, .. } => self.on_mouse_down(position),
653            Event::MouseMove { position } => {
654                self.on_mouse_move(position);
655                None
656            }
657            Event::KeyDown { key, .. } if self.open => self.on_key_down(key),
658            _ => None,
659        }
660    }
661
662    fn children(&self) -> &[Box<dyn Widget>] {
663        &self.trigger_widget
664    }
665
666    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
667        &mut self.trigger_widget
668    }
669
670    fn is_focusable(&self) -> bool {
671        true
672    }
673
674    fn test_id(&self) -> Option<&str> {
675        self.test_id_value.as_deref()
676    }
677
678    fn bounds(&self) -> Rect {
679        self.bounds
680    }
681}
682
683// PROBAR-SPEC-009: Brick Architecture - Tests define interface
684impl Brick for Menu {
685    fn brick_name(&self) -> &'static str {
686        "Menu"
687    }
688
689    fn assertions(&self) -> &[BrickAssertion] {
690        &[BrickAssertion::MaxLatencyMs(16)]
691    }
692
693    fn budget(&self) -> BrickBudget {
694        BrickBudget::uniform(16)
695    }
696
697    fn verify(&self) -> BrickVerification {
698        BrickVerification {
699            passed: self.assertions().to_vec(),
700            failed: vec![],
701            verification_time: Duration::from_micros(10),
702        }
703    }
704
705    fn to_html(&self) -> String {
706        r#"<div class="brick-menu"></div>"#.to_string()
707    }
708
709    fn to_css(&self) -> String {
710        ".brick-menu { display: block; position: relative; }".to_string()
711    }
712
713    fn test_id(&self) -> Option<&str> {
714        self.test_id_value.as_deref()
715    }
716}
717
718/// Message when menu is toggled.
719#[derive(Debug, Clone)]
720pub struct MenuToggled {
721    /// Whether menu is now open
722    pub open: bool,
723}
724
725/// Message when menu item is selected.
726#[derive(Debug, Clone)]
727pub struct MenuItemSelected {
728    /// Action ID of selected item
729    pub action: String,
730}
731
732/// Message when menu checkbox is toggled.
733#[derive(Debug, Clone)]
734pub struct MenuCheckboxToggled {
735    /// Action ID
736    pub action: String,
737    /// New checked state
738    pub checked: bool,
739}
740
741/// Message when menu is closed.
742#[derive(Debug, Clone)]
743pub struct MenuClosed;
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    // =========================================================================
750    // MenuItem Tests
751    // =========================================================================
752
753    #[test]
754    fn test_menu_item_action() {
755        let item = MenuItem::action("Cut", "edit.cut");
756        match item {
757            MenuItem::Action {
758                label,
759                action,
760                disabled,
761                shortcut,
762            } => {
763                assert_eq!(label, "Cut");
764                assert_eq!(action, "edit.cut");
765                assert!(!disabled);
766                assert!(shortcut.is_none());
767            }
768            _ => panic!("Expected Action"),
769        }
770    }
771
772    #[test]
773    fn test_menu_item_action_with_shortcut() {
774        let item = MenuItem::action("Cut", "edit.cut").shortcut("Ctrl+X");
775        match item {
776            MenuItem::Action { shortcut, .. } => {
777                assert_eq!(shortcut, Some("Ctrl+X".to_string()));
778            }
779            _ => panic!("Expected Action"),
780        }
781    }
782
783    #[test]
784    fn test_menu_item_checkbox() {
785        let item = MenuItem::checkbox("Show Grid", "view.grid", true);
786        match item {
787            MenuItem::Checkbox {
788                label,
789                checked,
790                disabled,
791                ..
792            } => {
793                assert_eq!(label, "Show Grid");
794                assert!(checked);
795                assert!(!disabled);
796            }
797            _ => panic!("Expected Checkbox"),
798        }
799    }
800
801    #[test]
802    fn test_menu_item_separator() {
803        let item = MenuItem::separator();
804        assert!(matches!(item, MenuItem::Separator));
805    }
806
807    #[test]
808    fn test_menu_item_submenu() {
809        let items = vec![MenuItem::action("Sub 1", "sub.1")];
810        let item = MenuItem::submenu("More", items);
811        match item {
812            MenuItem::Submenu {
813                label,
814                items,
815                disabled,
816            } => {
817                assert_eq!(label, "More");
818                assert_eq!(items.len(), 1);
819                assert!(!disabled);
820            }
821            _ => panic!("Expected Submenu"),
822        }
823    }
824
825    #[test]
826    fn test_menu_item_disabled() {
827        let item = MenuItem::action("Cut", "edit.cut").disabled(true);
828        match item {
829            MenuItem::Action { disabled, .. } => assert!(disabled),
830            _ => panic!("Expected Action"),
831        }
832    }
833
834    #[test]
835    fn test_menu_item_is_selectable() {
836        assert!(MenuItem::action("Cut", "edit.cut").is_selectable());
837        assert!(!MenuItem::action("Cut", "edit.cut")
838            .disabled(true)
839            .is_selectable());
840        assert!(!MenuItem::separator().is_selectable());
841        assert!(MenuItem::checkbox("Show", "show", false).is_selectable());
842    }
843
844    #[test]
845    fn test_menu_item_height() {
846        assert_eq!(MenuItem::action("Cut", "edit.cut").height(), 32.0);
847        assert_eq!(MenuItem::separator().height(), 9.0);
848    }
849
850    // =========================================================================
851    // Menu Tests
852    // =========================================================================
853
854    #[test]
855    fn test_menu_new() {
856        let menu = Menu::new();
857        assert!(menu.items.is_empty());
858        assert!(!menu.open);
859        assert_eq!(menu.trigger, MenuTrigger::Click);
860    }
861
862    #[test]
863    fn test_menu_builder() {
864        let menu = Menu::new()
865            .items(vec![
866                MenuItem::action("Cut", "cut"),
867                MenuItem::separator(),
868                MenuItem::action("Paste", "paste"),
869            ])
870            .trigger(MenuTrigger::Hover)
871            .width(250.0);
872
873        assert_eq!(menu.items.len(), 3);
874        assert_eq!(menu.trigger, MenuTrigger::Hover);
875        assert_eq!(menu.width, 250.0);
876    }
877
878    #[test]
879    fn test_menu_add_item() {
880        let menu = Menu::new()
881            .item(MenuItem::action("Cut", "cut"))
882            .item(MenuItem::action("Copy", "copy"));
883        assert_eq!(menu.items.len(), 2);
884    }
885
886    #[test]
887    fn test_menu_show_hide() {
888        let mut menu = Menu::new();
889        assert!(!menu.is_open());
890
891        menu.show();
892        assert!(menu.is_open());
893
894        menu.hide();
895        assert!(!menu.is_open());
896    }
897
898    #[test]
899    fn test_menu_toggle() {
900        let mut menu = Menu::new();
901
902        menu.toggle();
903        assert!(menu.is_open());
904
905        menu.toggle();
906        assert!(!menu.is_open());
907    }
908
909    #[test]
910    fn test_menu_calculate_height() {
911        let menu = Menu::new().items(vec![
912            MenuItem::action("Cut", "cut"),
913            MenuItem::separator(),
914            MenuItem::action("Paste", "paste"),
915        ]);
916        // 2 actions (32px each) + 1 separator (9px) + padding (16px) = 89px
917        assert_eq!(menu.calculate_menu_height(), 89.0);
918    }
919
920    #[test]
921    fn test_menu_measure() {
922        let menu = Menu::new().width(200.0);
923        let size = menu.measure(Constraints::loose(Size::new(300.0, 400.0)));
924        assert_eq!(size.width, 200.0);
925    }
926
927    #[test]
928    fn test_menu_layout() {
929        let mut menu = Menu::new().width(200.0);
930        menu.open = true;
931        menu.items = vec![MenuItem::action("Cut", "cut")];
932
933        let result = menu.layout(Rect::new(10.0, 20.0, 100.0, 32.0));
934        assert_eq!(result.size, Size::new(100.0, 32.0));
935        assert_eq!(menu.panel_bounds.x, 10.0);
936        assert_eq!(menu.panel_bounds.y, 52.0); // Below trigger
937    }
938
939    #[test]
940    fn test_menu_type_id() {
941        let menu = Menu::new();
942        assert_eq!(Widget::type_id(&menu), TypeId::of::<Menu>());
943    }
944
945    #[test]
946    fn test_menu_is_focusable() {
947        let menu = Menu::new();
948        assert!(menu.is_focusable());
949    }
950
951    #[test]
952    fn test_menu_test_id() {
953        let menu = Menu::new().with_test_id("my-menu");
954        assert_eq!(Widget::test_id(&menu), Some("my-menu"));
955    }
956
957    #[test]
958    fn test_menu_highlighted_index() {
959        let mut menu = Menu::new();
960        assert!(menu.highlighted_index().is_none());
961
962        menu.highlighted_index = Some(2);
963        assert_eq!(menu.highlighted_index(), Some(2));
964    }
965
966    #[test]
967    fn test_menu_next_selectable() {
968        let menu = Menu::new().items(vec![
969            MenuItem::action("Cut", "cut"),
970            MenuItem::separator(),
971            MenuItem::action("Paste", "paste"),
972        ]);
973
974        // Forward from None
975        assert_eq!(menu.next_selectable(None, true), Some(0));
976
977        // Forward from 0
978        assert_eq!(menu.next_selectable(Some(0), true), Some(2)); // Skips separator
979
980        // Backward from 2
981        assert_eq!(menu.next_selectable(Some(2), false), Some(0)); // Skips separator
982    }
983
984    #[test]
985    fn test_menu_escape_closes() {
986        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
987        menu.show();
988
989        let result = menu.event(&Event::key_down(Key::Escape));
990        assert!(result.is_some());
991        assert!(!menu.is_open());
992    }
993
994    #[test]
995    fn test_menu_arrow_navigation() {
996        let mut menu = Menu::new().items(vec![
997            MenuItem::action("Cut", "cut"),
998            MenuItem::action("Copy", "copy"),
999        ]);
1000        menu.show();
1001
1002        menu.event(&Event::key_down(Key::Down));
1003        assert_eq!(menu.highlighted_index, Some(0));
1004
1005        menu.event(&Event::key_down(Key::Down));
1006        assert_eq!(menu.highlighted_index, Some(1));
1007    }
1008
1009    // =========================================================================
1010    // Message Tests
1011    // =========================================================================
1012
1013    #[test]
1014    fn test_menu_toggled_message() {
1015        let msg = MenuToggled { open: true };
1016        assert!(msg.open);
1017    }
1018
1019    #[test]
1020    fn test_menu_item_selected_message() {
1021        let msg = MenuItemSelected {
1022            action: "edit.cut".to_string(),
1023        };
1024        assert_eq!(msg.action, "edit.cut");
1025    }
1026
1027    #[test]
1028    fn test_menu_checkbox_toggled_message() {
1029        let msg = MenuCheckboxToggled {
1030            action: "view.grid".to_string(),
1031            checked: true,
1032        };
1033        assert_eq!(msg.action, "view.grid");
1034        assert!(msg.checked);
1035    }
1036
1037    #[test]
1038    fn test_menu_closed_message() {
1039        let msg = MenuClosed;
1040        assert_eq!(format!("{msg:?}"), "MenuClosed");
1041    }
1042
1043    // =========================================================================
1044    // Additional Coverage Tests
1045    // =========================================================================
1046
1047    #[test]
1048    fn test_menu_shortcut_on_non_action() {
1049        // shortcut() should do nothing on checkbox items
1050        let item = MenuItem::checkbox("Show", "show", false).shortcut("Ctrl+S");
1051        match item {
1052            MenuItem::Checkbox { .. } => {} // Still checkbox, shortcut not applied
1053            _ => panic!("Expected Checkbox"),
1054        }
1055    }
1056
1057    #[test]
1058    fn test_menu_disabled_checkbox() {
1059        let item = MenuItem::checkbox("Show", "show", true).disabled(true);
1060        match item {
1061            MenuItem::Checkbox { disabled, .. } => assert!(disabled),
1062            _ => panic!("Expected Checkbox"),
1063        }
1064    }
1065
1066    #[test]
1067    fn test_menu_disabled_submenu() {
1068        let item = MenuItem::submenu("More", vec![]).disabled(true);
1069        match item {
1070            MenuItem::Submenu { disabled, .. } => assert!(disabled),
1071            _ => panic!("Expected Submenu"),
1072        }
1073    }
1074
1075    #[test]
1076    fn test_menu_disabled_separator_no_op() {
1077        // Calling disabled on separator should be a no-op
1078        let item = MenuItem::separator().disabled(true);
1079        assert!(matches!(item, MenuItem::Separator));
1080    }
1081
1082    #[test]
1083    fn test_menu_submenu_not_selectable_when_disabled() {
1084        let item = MenuItem::submenu("More", vec![]).disabled(true);
1085        assert!(!item.is_selectable());
1086    }
1087
1088    #[test]
1089    fn test_menu_context_menu_trigger() {
1090        let menu = Menu::new().trigger(MenuTrigger::ContextMenu);
1091        assert_eq!(menu.trigger, MenuTrigger::ContextMenu);
1092    }
1093
1094    #[test]
1095    fn test_menu_hover_trigger() {
1096        let menu = Menu::new().trigger(MenuTrigger::Hover);
1097        assert_eq!(menu.trigger, MenuTrigger::Hover);
1098    }
1099
1100    #[test]
1101    fn test_menu_background_color() {
1102        let menu = Menu::new().background_color(Color::RED);
1103        assert_eq!(menu.background_color, Color::RED);
1104    }
1105
1106    #[test]
1107    fn test_menu_hover_color() {
1108        let menu = Menu::new().hover_color(Color::BLUE);
1109        assert_eq!(menu.hover_color, Color::BLUE);
1110    }
1111
1112    #[test]
1113    fn test_menu_text_color() {
1114        let menu = Menu::new().text_color(Color::GREEN);
1115        assert_eq!(menu.text_color, Color::GREEN);
1116    }
1117
1118    #[test]
1119    fn test_menu_next_selectable_empty() {
1120        let menu = Menu::new();
1121        assert!(menu.next_selectable(None, true).is_none());
1122        assert!(menu.next_selectable(None, false).is_none());
1123    }
1124
1125    #[test]
1126    fn test_menu_next_selectable_all_disabled() {
1127        let menu = Menu::new().items(vec![
1128            MenuItem::separator(),
1129            MenuItem::action("Cut", "cut").disabled(true),
1130            MenuItem::separator(),
1131        ]);
1132        assert!(menu.next_selectable(None, true).is_none());
1133    }
1134
1135    #[test]
1136    fn test_menu_next_selectable_wrap_forward() {
1137        let menu = Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1138        // From last item, should wrap to first
1139        assert_eq!(menu.next_selectable(Some(1), true), Some(0));
1140    }
1141
1142    #[test]
1143    fn test_menu_next_selectable_wrap_backward() {
1144        let menu = Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1145        // From first item, should wrap to last
1146        assert_eq!(menu.next_selectable(Some(0), false), Some(1));
1147    }
1148
1149    #[test]
1150    fn test_menu_children_empty() {
1151        let menu = Menu::new();
1152        assert!(menu.children().is_empty());
1153    }
1154
1155    #[test]
1156    fn test_menu_children_mut_empty() {
1157        let mut menu = Menu::new();
1158        assert!(menu.children_mut().is_empty());
1159    }
1160
1161    #[test]
1162    fn test_menu_bounds() {
1163        let mut menu = Menu::new();
1164        menu.layout(Rect::new(10.0, 20.0, 200.0, 32.0));
1165        assert_eq!(menu.bounds(), Rect::new(10.0, 20.0, 200.0, 32.0));
1166    }
1167
1168    #[test]
1169    fn test_menu_trigger_default() {
1170        assert_eq!(MenuTrigger::default(), MenuTrigger::Click);
1171    }
1172
1173    #[test]
1174    fn test_menu_event_closed_returns_none() {
1175        let mut menu = Menu::new();
1176        // Event on closed menu should return None
1177        let result = menu.event(&Event::key_down(Key::Down));
1178        assert!(result.is_none());
1179    }
1180
1181    #[test]
1182    fn test_menu_enter_selects_item() {
1183        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1184        menu.show();
1185        menu.highlighted_index = Some(0);
1186        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1187
1188        let result = menu.event(&Event::key_down(Key::Enter));
1189        assert!(result.is_some());
1190        assert!(!menu.is_open());
1191    }
1192
1193    #[test]
1194    fn test_menu_space_selects_item() {
1195        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1196        menu.show();
1197        menu.highlighted_index = Some(0);
1198        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1199
1200        let result = menu.event(&Event::key_down(Key::Space));
1201        assert!(result.is_some());
1202    }
1203
1204    #[test]
1205    fn test_menu_enter_on_checkbox_toggles() {
1206        let mut menu = Menu::new().items(vec![MenuItem::checkbox("Show", "show", false)]);
1207        menu.show();
1208        menu.highlighted_index = Some(0);
1209        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1210
1211        let result = menu.event(&Event::key_down(Key::Enter));
1212        assert!(result.is_some());
1213        // Menu stays open for checkbox
1214        assert!(menu.is_open());
1215    }
1216
1217    #[test]
1218    fn test_menu_enter_on_disabled_does_nothing() {
1219        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut").disabled(true)]);
1220        menu.show();
1221        menu.highlighted_index = Some(0);
1222        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1223
1224        let result = menu.event(&Event::key_down(Key::Enter));
1225        assert!(result.is_none());
1226        assert!(menu.is_open()); // Still open
1227    }
1228
1229    #[test]
1230    fn test_menu_up_arrow_navigation() {
1231        let mut menu =
1232            Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1233        menu.show();
1234        menu.highlighted_index = Some(1);
1235
1236        menu.event(&Event::key_down(Key::Up));
1237        assert_eq!(menu.highlighted_index, Some(0));
1238    }
1239
1240    // =========================================================================
1241    // Brick Trait Tests
1242    // =========================================================================
1243
1244    #[test]
1245    fn test_menu_brick_name() {
1246        let menu = Menu::new();
1247        assert_eq!(menu.brick_name(), "Menu");
1248    }
1249
1250    #[test]
1251    fn test_menu_brick_assertions() {
1252        let menu = Menu::new();
1253        let assertions = menu.assertions();
1254        assert!(!assertions.is_empty());
1255        assert!(matches!(assertions[0], BrickAssertion::MaxLatencyMs(16)));
1256    }
1257
1258    #[test]
1259    fn test_menu_brick_budget() {
1260        let menu = Menu::new();
1261        let budget = menu.budget();
1262        // Verify budget has reasonable values
1263        assert!(budget.layout_ms > 0);
1264        assert!(budget.paint_ms > 0);
1265    }
1266
1267    #[test]
1268    fn test_menu_brick_verify() {
1269        let menu = Menu::new();
1270        let verification = menu.verify();
1271        assert!(!verification.passed.is_empty());
1272        assert!(verification.failed.is_empty());
1273    }
1274
1275    #[test]
1276    fn test_menu_brick_to_html() {
1277        let menu = Menu::new();
1278        let html = menu.to_html();
1279        assert!(html.contains("brick-menu"));
1280    }
1281
1282    #[test]
1283    fn test_menu_brick_to_css() {
1284        let menu = Menu::new();
1285        let css = menu.to_css();
1286        assert!(css.contains(".brick-menu"));
1287        assert!(css.contains("display: block"));
1288        assert!(css.contains("position: relative"));
1289    }
1290
1291    #[test]
1292    fn test_menu_brick_test_id() {
1293        let menu = Menu::new().with_test_id("my-menu");
1294        assert_eq!(Brick::test_id(&menu), Some("my-menu"));
1295    }
1296
1297    #[test]
1298    fn test_menu_brick_test_id_none() {
1299        let menu = Menu::new();
1300        assert!(Brick::test_id(&menu).is_none());
1301    }
1302
1303    // =========================================================================
1304    // Item at Position Tests
1305    // =========================================================================
1306
1307    #[test]
1308    fn test_menu_item_at_position_valid() {
1309        let mut menu = Menu::new().items(vec![
1310            MenuItem::action("Cut", "cut"),
1311            MenuItem::action("Copy", "copy"),
1312            MenuItem::action("Paste", "paste"),
1313        ]);
1314        menu.open = true;
1315        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1316
1317        // Item 0 starts at y = panel_bounds.y + 8.0
1318        // Each action item is 32px tall
1319        let item = menu.item_at_position(menu.panel_bounds.y + 8.0 + 10.0);
1320        assert_eq!(item, Some(0));
1321
1322        let item = menu.item_at_position(menu.panel_bounds.y + 8.0 + 40.0);
1323        assert_eq!(item, Some(1));
1324
1325        let item = menu.item_at_position(menu.panel_bounds.y + 8.0 + 72.0);
1326        assert_eq!(item, Some(2));
1327    }
1328
1329    #[test]
1330    fn test_menu_item_at_position_above_menu() {
1331        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1332        menu.open = true;
1333        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1334
1335        // Y position above the menu
1336        let item = menu.item_at_position(menu.panel_bounds.y - 10.0);
1337        assert!(item.is_none());
1338    }
1339
1340    #[test]
1341    fn test_menu_item_at_position_below_items() {
1342        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1343        menu.open = true;
1344        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1345
1346        // Y position far below the item
1347        let item = menu.item_at_position(menu.panel_bounds.y + 500.0);
1348        assert!(item.is_none());
1349    }
1350
1351    // =========================================================================
1352    // Mouse Event Tests
1353    // =========================================================================
1354
1355    #[test]
1356    fn test_menu_click_on_trigger_opens() {
1357        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1358        menu.layout(Rect::new(10.0, 10.0, 200.0, 32.0));
1359
1360        // Click inside trigger area
1361        let result = menu.event(&Event::MouseDown {
1362            position: Point::new(50.0, 20.0),
1363            button: presentar_core::MouseButton::Left,
1364        });
1365
1366        assert!(result.is_some());
1367        assert!(menu.is_open());
1368    }
1369
1370    #[test]
1371    fn test_menu_click_outside_closes() {
1372        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1373        menu.show();
1374        menu.layout(Rect::new(10.0, 10.0, 200.0, 32.0));
1375
1376        // Click far outside menu area
1377        let result = menu.event(&Event::MouseDown {
1378            position: Point::new(500.0, 500.0),
1379            button: presentar_core::MouseButton::Left,
1380        });
1381
1382        assert!(result.is_some());
1383        assert!(!menu.is_open());
1384    }
1385
1386    #[test]
1387    fn test_menu_click_on_action_item() {
1388        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1389        menu.show();
1390        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1391
1392        // Click on the action item
1393        let click_y = menu.panel_bounds.y + 8.0 + 16.0; // Middle of first item
1394        let result = menu.event(&Event::MouseDown {
1395            position: Point::new(menu.panel_bounds.x + 50.0, click_y),
1396            button: presentar_core::MouseButton::Left,
1397        });
1398
1399        assert!(result.is_some());
1400        assert!(!menu.is_open()); // Menu closes after action
1401    }
1402
1403    #[test]
1404    fn test_menu_click_on_checkbox_item() {
1405        let mut menu = Menu::new().items(vec![MenuItem::checkbox("Show Grid", "show", false)]);
1406        menu.show();
1407        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1408
1409        // Click on the checkbox item
1410        let click_y = menu.panel_bounds.y + 8.0 + 16.0;
1411        let result = menu.event(&Event::MouseDown {
1412            position: Point::new(menu.panel_bounds.x + 50.0, click_y),
1413            button: presentar_core::MouseButton::Left,
1414        });
1415
1416        assert!(result.is_some());
1417        // Checkbox should be toggled
1418        if let MenuItem::Checkbox { checked, .. } = &menu.items[0] {
1419            assert!(*checked);
1420        } else {
1421            panic!("Expected Checkbox item");
1422        }
1423    }
1424
1425    #[test]
1426    fn test_menu_click_on_disabled_action() {
1427        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut").disabled(true)]);
1428        menu.show();
1429        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1430
1431        // Click on the disabled item
1432        let click_y = menu.panel_bounds.y + 8.0 + 16.0;
1433        let result = menu.event(&Event::MouseDown {
1434            position: Point::new(menu.panel_bounds.x + 50.0, click_y),
1435            button: presentar_core::MouseButton::Left,
1436        });
1437
1438        assert!(result.is_none()); // No action taken
1439        assert!(menu.is_open()); // Menu stays open
1440    }
1441
1442    #[test]
1443    fn test_menu_click_on_submenu_opens_it() {
1444        let mut menu = Menu::new().items(vec![MenuItem::submenu(
1445            "More",
1446            vec![MenuItem::action("Sub", "sub")],
1447        )]);
1448        menu.show();
1449        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1450
1451        // Click on the submenu item
1452        let click_y = menu.panel_bounds.y + 8.0 + 16.0;
1453        let result = menu.event(&Event::MouseDown {
1454            position: Point::new(menu.panel_bounds.x + 50.0, click_y),
1455            button: presentar_core::MouseButton::Left,
1456        });
1457
1458        assert!(result.is_none()); // No message, just opens submenu
1459        assert_eq!(menu.open_submenu, Some(0));
1460    }
1461
1462    #[test]
1463    fn test_menu_mouse_move_updates_highlight() {
1464        let mut menu = Menu::new().items(vec![
1465            MenuItem::action("Cut", "cut"),
1466            MenuItem::action("Copy", "copy"),
1467        ]);
1468        menu.show();
1469        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1470
1471        // Move over first item
1472        let y1 = menu.panel_bounds.y + 8.0 + 16.0;
1473        menu.event(&Event::MouseMove {
1474            position: Point::new(menu.panel_bounds.x + 50.0, y1),
1475        });
1476        assert_eq!(menu.highlighted_index, Some(0));
1477
1478        // Move over second item
1479        let y2 = menu.panel_bounds.y + 8.0 + 48.0;
1480        menu.event(&Event::MouseMove {
1481            position: Point::new(menu.panel_bounds.x + 50.0, y2),
1482        });
1483        assert_eq!(menu.highlighted_index, Some(1));
1484    }
1485
1486    #[test]
1487    fn test_menu_mouse_move_outside_clears_highlight() {
1488        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1489        menu.show();
1490        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1491        menu.highlighted_index = Some(0);
1492
1493        // Move outside menu
1494        menu.event(&Event::MouseMove {
1495            position: Point::new(500.0, 500.0),
1496        });
1497        assert!(menu.highlighted_index.is_none());
1498    }
1499
1500    // =========================================================================
1501    // Keyboard Navigation Edge Cases
1502    // =========================================================================
1503
1504    #[test]
1505    fn test_menu_up_from_none_selects_last() {
1506        let mut menu =
1507            Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1508        menu.show();
1509
1510        menu.event(&Event::key_down(Key::Up));
1511        assert_eq!(menu.highlighted_index, Some(1)); // Wraps to last
1512    }
1513
1514    #[test]
1515    fn test_menu_down_from_last_wraps_to_first() {
1516        let mut menu =
1517            Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1518        menu.show();
1519        menu.highlighted_index = Some(1);
1520
1521        menu.event(&Event::key_down(Key::Down));
1522        assert_eq!(menu.highlighted_index, Some(0)); // Wraps to first
1523    }
1524
1525    #[test]
1526    fn test_menu_up_skips_separator() {
1527        let mut menu = Menu::new().items(vec![
1528            MenuItem::action("A", "a"),
1529            MenuItem::separator(),
1530            MenuItem::action("B", "b"),
1531        ]);
1532        menu.show();
1533        menu.highlighted_index = Some(2);
1534
1535        menu.event(&Event::key_down(Key::Up));
1536        assert_eq!(menu.highlighted_index, Some(0)); // Skips separator
1537    }
1538
1539    #[test]
1540    fn test_menu_down_skips_disabled() {
1541        let mut menu = Menu::new().items(vec![
1542            MenuItem::action("A", "a"),
1543            MenuItem::action("B", "b").disabled(true),
1544            MenuItem::action("C", "c"),
1545        ]);
1546        menu.show();
1547        menu.highlighted_index = Some(0);
1548
1549        menu.event(&Event::key_down(Key::Down));
1550        assert_eq!(menu.highlighted_index, Some(2)); // Skips disabled
1551    }
1552
1553    #[test]
1554    fn test_menu_other_key_does_nothing() {
1555        let mut menu = Menu::new().items(vec![MenuItem::action("A", "a")]);
1556        menu.show();
1557        menu.highlighted_index = Some(0);
1558
1559        let result = menu.event(&Event::key_down(Key::Tab));
1560        assert!(result.is_none());
1561        assert_eq!(menu.highlighted_index, Some(0));
1562    }
1563
1564    #[test]
1565    fn test_menu_enter_on_separator_does_nothing() {
1566        let mut menu = Menu::new().items(vec![MenuItem::separator(), MenuItem::action("A", "a")]);
1567        menu.show();
1568        menu.highlighted_index = Some(0); // Manually set to separator (shouldn't happen in practice)
1569
1570        let result = menu.event(&Event::key_down(Key::Enter));
1571        assert!(result.is_none());
1572        assert!(menu.is_open());
1573    }
1574
1575    #[test]
1576    fn test_menu_enter_on_submenu_does_nothing() {
1577        let mut menu = Menu::new().items(vec![MenuItem::submenu(
1578            "More",
1579            vec![MenuItem::action("Sub", "sub")],
1580        )]);
1581        menu.show();
1582        menu.highlighted_index = Some(0);
1583
1584        let result = menu.event(&Event::key_down(Key::Enter));
1585        assert!(result.is_none());
1586        assert!(menu.is_open());
1587    }
1588
1589    #[test]
1590    fn test_menu_space_on_checkbox_toggles() {
1591        let mut menu = Menu::new().items(vec![MenuItem::checkbox("Show", "show", false)]);
1592        menu.show();
1593        menu.highlighted_index = Some(0);
1594        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1595
1596        let result = menu.event(&Event::key_down(Key::Space));
1597        assert!(result.is_some());
1598        // Checkbox should be toggled
1599        if let MenuItem::Checkbox { checked, .. } = &menu.items[0] {
1600            assert!(*checked);
1601        }
1602    }
1603
1604    // =========================================================================
1605    // MenuItem Methods Tests
1606    // =========================================================================
1607
1608    #[test]
1609    fn test_menu_item_height_action() {
1610        let item = MenuItem::action("Test", "test");
1611        assert_eq!(item.height(), 32.0);
1612    }
1613
1614    #[test]
1615    fn test_menu_item_height_checkbox() {
1616        let item = MenuItem::checkbox("Test", "test", false);
1617        assert_eq!(item.height(), 32.0);
1618    }
1619
1620    #[test]
1621    fn test_menu_item_height_submenu() {
1622        let item = MenuItem::submenu("More", vec![]);
1623        assert_eq!(item.height(), 32.0);
1624    }
1625
1626    #[test]
1627    fn test_menu_item_is_selectable_submenu() {
1628        let item = MenuItem::submenu("More", vec![]);
1629        assert!(item.is_selectable());
1630    }
1631
1632    #[test]
1633    fn test_menu_item_is_selectable_disabled_checkbox() {
1634        let item = MenuItem::checkbox("Test", "test", false).disabled(true);
1635        assert!(!item.is_selectable());
1636    }
1637
1638    // =========================================================================
1639    // Menu Trigger Tests
1640    // =========================================================================
1641
1642    #[test]
1643    fn test_menu_trigger_hover_no_click_open() {
1644        let mut menu = Menu::new()
1645            .trigger(MenuTrigger::Hover)
1646            .items(vec![MenuItem::action("Cut", "cut")]);
1647        menu.layout(Rect::new(10.0, 10.0, 200.0, 32.0));
1648
1649        // Click with hover trigger should not toggle
1650        let result = menu.event(&Event::MouseDown {
1651            position: Point::new(50.0, 20.0),
1652            button: presentar_core::MouseButton::Left,
1653        });
1654
1655        assert!(result.is_none());
1656        assert!(!menu.is_open());
1657    }
1658
1659    #[test]
1660    fn test_menu_trigger_context_menu_no_click_open() {
1661        let mut menu = Menu::new()
1662            .trigger(MenuTrigger::ContextMenu)
1663            .items(vec![MenuItem::action("Cut", "cut")]);
1664        menu.layout(Rect::new(10.0, 10.0, 200.0, 32.0));
1665
1666        // Regular click with context trigger should not toggle
1667        let result = menu.event(&Event::MouseDown {
1668            position: Point::new(50.0, 20.0),
1669            button: presentar_core::MouseButton::Left,
1670        });
1671
1672        assert!(result.is_none());
1673        assert!(!menu.is_open());
1674    }
1675
1676    // =========================================================================
1677    // Message Clone Tests
1678    // =========================================================================
1679
1680    #[test]
1681    fn test_menu_toggled_clone() {
1682        let msg = MenuToggled { open: true };
1683        let cloned = msg.clone();
1684        assert_eq!(cloned.open, msg.open);
1685    }
1686
1687    #[test]
1688    fn test_menu_item_selected_clone() {
1689        let msg = MenuItemSelected {
1690            action: "test".to_string(),
1691        };
1692        let cloned = msg.clone();
1693        assert_eq!(cloned.action, msg.action);
1694    }
1695
1696    #[test]
1697    fn test_menu_checkbox_toggled_clone() {
1698        let msg = MenuCheckboxToggled {
1699            action: "test".to_string(),
1700            checked: true,
1701        };
1702        let cloned = msg.clone();
1703        assert_eq!(cloned.action, msg.action);
1704        assert_eq!(cloned.checked, msg.checked);
1705    }
1706
1707    #[test]
1708    fn test_menu_closed_clone() {
1709        let msg = MenuClosed;
1710        let cloned = msg;
1711        assert_eq!(format!("{cloned:?}"), "MenuClosed");
1712    }
1713
1714    // =========================================================================
1715    // Default Trait Tests
1716    // =========================================================================
1717
1718    #[test]
1719    fn test_menu_default() {
1720        let menu = Menu::default();
1721        assert!(menu.items.is_empty());
1722        assert!(!menu.open);
1723        assert_eq!(menu.trigger, MenuTrigger::Click);
1724        assert_eq!(menu.width, 200.0);
1725    }
1726
1727    #[test]
1728    fn test_menu_trigger_eq() {
1729        assert_eq!(MenuTrigger::Click, MenuTrigger::Click);
1730        assert_ne!(MenuTrigger::Click, MenuTrigger::Hover);
1731        assert_ne!(MenuTrigger::Hover, MenuTrigger::ContextMenu);
1732    }
1733
1734    #[test]
1735    fn test_menu_hide_clears_submenu() {
1736        let mut menu = Menu::new().items(vec![MenuItem::submenu(
1737            "More",
1738            vec![MenuItem::action("Sub", "sub")],
1739        )]);
1740        menu.show();
1741        menu.open_submenu = Some(0);
1742
1743        menu.hide();
1744        assert!(!menu.is_open());
1745        assert!(menu.open_submenu.is_none());
1746        assert!(menu.highlighted_index.is_none());
1747    }
1748
1749    #[test]
1750    fn test_menu_debug() {
1751        let item = MenuItem::action("Test", "test");
1752        let debug_str = format!("{item:?}");
1753        assert!(debug_str.contains("Test"));
1754    }
1755
1756    #[test]
1757    fn test_menu_toggled_debug() {
1758        let msg = MenuToggled { open: true };
1759        let debug_str = format!("{msg:?}");
1760        assert!(debug_str.contains("true"));
1761    }
1762}