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
180    #[serde(skip)]
181    trigger_widget: Option<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: None,
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 = Some(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
381impl Widget for Menu {
382    fn type_id(&self) -> TypeId {
383        TypeId::of::<Self>()
384    }
385
386    fn measure(&self, constraints: Constraints) -> Size {
387        // Measure just the trigger area
388        if let Some(ref trigger) = self.trigger_widget {
389            trigger.measure(constraints)
390        } else {
391            Size::new(self.width.min(constraints.max_width), 32.0)
392        }
393    }
394
395    fn layout(&mut self, bounds: Rect) -> LayoutResult {
396        self.bounds = bounds;
397
398        // Layout trigger widget
399        if let Some(ref mut trigger) = self.trigger_widget {
400            trigger.layout(bounds);
401        }
402
403        // Calculate menu panel bounds (below trigger)
404        if self.open {
405            let menu_height = self.calculate_menu_height();
406            self.panel_bounds =
407                Rect::new(bounds.x, bounds.y + bounds.height, self.width, menu_height);
408        }
409
410        LayoutResult {
411            size: bounds.size(),
412        }
413    }
414
415    #[allow(clippy::too_many_lines)]
416    fn paint(&self, canvas: &mut dyn Canvas) {
417        // Paint trigger
418        if let Some(ref trigger) = self.trigger_widget {
419            trigger.paint(canvas);
420        }
421
422        if !self.open {
423            return;
424        }
425
426        // Paint menu background with shadow
427        let shadow_bounds = Rect::new(
428            self.panel_bounds.x + 2.0,
429            self.panel_bounds.y + 2.0,
430            self.panel_bounds.width,
431            self.panel_bounds.height,
432        );
433        canvas.fill_rect(shadow_bounds, Color::rgba(0.0, 0.0, 0.0, 0.1));
434        canvas.fill_rect(self.panel_bounds, self.background_color);
435
436        // Paint items
437        let mut y = self.panel_bounds.y + 8.0; // Top padding
438        let text_style = TextStyle {
439            size: 14.0,
440            color: self.text_color,
441            ..Default::default()
442        };
443        let disabled_style = TextStyle {
444            size: 14.0,
445            color: self.disabled_color,
446            ..Default::default()
447        };
448
449        for (i, item) in self.items.iter().enumerate() {
450            let height = item.height();
451
452            match item {
453                MenuItem::Action {
454                    label,
455                    disabled,
456                    shortcut,
457                    ..
458                } => {
459                    // Hover background
460                    if self.highlighted_index == Some(i) && !disabled {
461                        let hover_rect =
462                            Rect::new(self.panel_bounds.x, y, self.panel_bounds.width, height);
463                        canvas.fill_rect(hover_rect, self.hover_color);
464                    }
465
466                    // Label
467                    let style = if *disabled {
468                        &disabled_style
469                    } else {
470                        &text_style
471                    };
472                    canvas.draw_text(
473                        label,
474                        Point::new(self.panel_bounds.x + 12.0, y + 20.0),
475                        style,
476                    );
477
478                    // Shortcut
479                    if let Some(ref shortcut) = shortcut {
480                        let shortcut_style = TextStyle {
481                            size: 12.0,
482                            color: self.disabled_color,
483                            ..Default::default()
484                        };
485                        canvas.draw_text(
486                            shortcut,
487                            Point::new(
488                                self.panel_bounds.x + self.panel_bounds.width - 60.0,
489                                y + 20.0,
490                            ),
491                            &shortcut_style,
492                        );
493                    }
494                }
495                MenuItem::Checkbox {
496                    label,
497                    checked,
498                    disabled,
499                    ..
500                } => {
501                    // Hover background
502                    if self.highlighted_index == Some(i) && !disabled {
503                        let hover_rect =
504                            Rect::new(self.panel_bounds.x, y, self.panel_bounds.width, height);
505                        canvas.fill_rect(hover_rect, self.hover_color);
506                    }
507
508                    // Checkbox indicator
509                    let check_text = if *checked { "✓" } else { " " };
510                    let style = if *disabled {
511                        &disabled_style
512                    } else {
513                        &text_style
514                    };
515                    canvas.draw_text(
516                        check_text,
517                        Point::new(self.panel_bounds.x + 12.0, y + 20.0),
518                        style,
519                    );
520
521                    // Label
522                    canvas.draw_text(
523                        label,
524                        Point::new(self.panel_bounds.x + 32.0, y + 20.0),
525                        style,
526                    );
527                }
528                MenuItem::Separator => {
529                    let line_y = y + 4.0;
530                    canvas.draw_line(
531                        Point::new(self.panel_bounds.x + 8.0, line_y),
532                        Point::new(self.panel_bounds.x + self.panel_bounds.width - 8.0, line_y),
533                        Color::rgb(0.9, 0.9, 0.9),
534                        1.0,
535                    );
536                }
537                MenuItem::Submenu {
538                    label, disabled, ..
539                } => {
540                    // Hover background
541                    if self.highlighted_index == Some(i) && !disabled {
542                        let hover_rect =
543                            Rect::new(self.panel_bounds.x, y, self.panel_bounds.width, height);
544                        canvas.fill_rect(hover_rect, self.hover_color);
545                    }
546
547                    // Label
548                    let style = if *disabled {
549                        &disabled_style
550                    } else {
551                        &text_style
552                    };
553                    canvas.draw_text(
554                        label,
555                        Point::new(self.panel_bounds.x + 12.0, y + 20.0),
556                        style,
557                    );
558
559                    // Arrow indicator
560                    canvas.draw_text(
561                        "›",
562                        Point::new(
563                            self.panel_bounds.x + self.panel_bounds.width - 20.0,
564                            y + 20.0,
565                        ),
566                        style,
567                    );
568                }
569            }
570
571            y += height;
572        }
573    }
574
575    #[allow(clippy::too_many_lines)]
576    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
577        match event {
578            Event::MouseDown { position, .. } => {
579                // Check if click is on trigger
580                let on_trigger = position.x >= self.bounds.x
581                    && position.x <= self.bounds.x + self.bounds.width
582                    && position.y >= self.bounds.y
583                    && position.y <= self.bounds.y + self.bounds.height;
584
585                if on_trigger && self.trigger == MenuTrigger::Click {
586                    self.toggle();
587                    return Some(Box::new(MenuToggled { open: self.open }));
588                }
589
590                // Check if click is on menu item
591                if self.open {
592                    let on_menu = position.x >= self.panel_bounds.x
593                        && position.x <= self.panel_bounds.x + self.panel_bounds.width
594                        && position.y >= self.panel_bounds.y
595                        && position.y <= self.panel_bounds.y + self.panel_bounds.height;
596
597                    if on_menu {
598                        if let Some(idx) = self.item_at_position(position.y) {
599                            if let Some(item) = self.items.get_mut(idx) {
600                                match item {
601                                    MenuItem::Action {
602                                        action, disabled, ..
603                                    } if !*disabled => {
604                                        let action_id = action.clone();
605                                        self.hide();
606                                        return Some(Box::new(MenuItemSelected {
607                                            action: action_id,
608                                        }));
609                                    }
610                                    MenuItem::Checkbox {
611                                        action,
612                                        checked,
613                                        disabled,
614                                        ..
615                                    } if !*disabled => {
616                                        *checked = !*checked;
617                                        let action_id = action.clone();
618                                        let is_checked = *checked;
619                                        return Some(Box::new(MenuCheckboxToggled {
620                                            action: action_id,
621                                            checked: is_checked,
622                                        }));
623                                    }
624                                    MenuItem::Submenu { disabled, .. } if !*disabled => {
625                                        self.open_submenu = Some(idx);
626                                    }
627                                    _ => {}
628                                }
629                            }
630                        }
631                    } else {
632                        // Click outside menu - close
633                        self.hide();
634                        return Some(Box::new(MenuClosed));
635                    }
636                }
637            }
638            Event::MouseMove { position } => {
639                if self.open {
640                    let on_menu = position.x >= self.panel_bounds.x
641                        && position.x <= self.panel_bounds.x + self.panel_bounds.width
642                        && position.y >= self.panel_bounds.y
643                        && position.y <= self.panel_bounds.y + self.panel_bounds.height;
644
645                    if on_menu {
646                        self.highlighted_index = self.item_at_position(position.y);
647                    } else {
648                        self.highlighted_index = None;
649                    }
650                }
651            }
652            Event::KeyDown { key, .. } if self.open => match key {
653                Key::Escape => {
654                    self.hide();
655                    return Some(Box::new(MenuClosed));
656                }
657                Key::Up => {
658                    self.highlighted_index = self.next_selectable(self.highlighted_index, false);
659                }
660                Key::Down => {
661                    self.highlighted_index = self.next_selectable(self.highlighted_index, true);
662                }
663                Key::Enter | Key::Space => {
664                    if let Some(idx) = self.highlighted_index {
665                        if let Some(item) = self.items.get_mut(idx) {
666                            match item {
667                                MenuItem::Action {
668                                    action, disabled, ..
669                                } if !*disabled => {
670                                    let action_id = action.clone();
671                                    self.hide();
672                                    return Some(Box::new(MenuItemSelected { action: action_id }));
673                                }
674                                MenuItem::Checkbox {
675                                    action,
676                                    checked,
677                                    disabled,
678                                    ..
679                                } if !*disabled => {
680                                    *checked = !*checked;
681                                    let action_id = action.clone();
682                                    let is_checked = *checked;
683                                    return Some(Box::new(MenuCheckboxToggled {
684                                        action: action_id,
685                                        checked: is_checked,
686                                    }));
687                                }
688                                _ => {}
689                            }
690                        }
691                    }
692                }
693                _ => {}
694            },
695            _ => {}
696        }
697
698        None
699    }
700
701    fn children(&self) -> &[Box<dyn Widget>] {
702        &[]
703    }
704
705    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
706        &mut []
707    }
708
709    fn is_focusable(&self) -> bool {
710        true
711    }
712
713    fn test_id(&self) -> Option<&str> {
714        self.test_id_value.as_deref()
715    }
716
717    fn bounds(&self) -> Rect {
718        self.bounds
719    }
720}
721
722// PROBAR-SPEC-009: Brick Architecture - Tests define interface
723impl Brick for Menu {
724    fn brick_name(&self) -> &'static str {
725        "Menu"
726    }
727
728    fn assertions(&self) -> &[BrickAssertion] {
729        &[BrickAssertion::MaxLatencyMs(16)]
730    }
731
732    fn budget(&self) -> BrickBudget {
733        BrickBudget::uniform(16)
734    }
735
736    fn verify(&self) -> BrickVerification {
737        BrickVerification {
738            passed: self.assertions().to_vec(),
739            failed: vec![],
740            verification_time: Duration::from_micros(10),
741        }
742    }
743
744    fn to_html(&self) -> String {
745        r#"<div class="brick-menu"></div>"#.to_string()
746    }
747
748    fn to_css(&self) -> String {
749        ".brick-menu { display: block; position: relative; }".to_string()
750    }
751
752    fn test_id(&self) -> Option<&str> {
753        self.test_id_value.as_deref()
754    }
755}
756
757/// Message when menu is toggled.
758#[derive(Debug, Clone)]
759pub struct MenuToggled {
760    /// Whether menu is now open
761    pub open: bool,
762}
763
764/// Message when menu item is selected.
765#[derive(Debug, Clone)]
766pub struct MenuItemSelected {
767    /// Action ID of selected item
768    pub action: String,
769}
770
771/// Message when menu checkbox is toggled.
772#[derive(Debug, Clone)]
773pub struct MenuCheckboxToggled {
774    /// Action ID
775    pub action: String,
776    /// New checked state
777    pub checked: bool,
778}
779
780/// Message when menu is closed.
781#[derive(Debug, Clone)]
782pub struct MenuClosed;
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    // =========================================================================
789    // MenuItem Tests
790    // =========================================================================
791
792    #[test]
793    fn test_menu_item_action() {
794        let item = MenuItem::action("Cut", "edit.cut");
795        match item {
796            MenuItem::Action {
797                label,
798                action,
799                disabled,
800                shortcut,
801            } => {
802                assert_eq!(label, "Cut");
803                assert_eq!(action, "edit.cut");
804                assert!(!disabled);
805                assert!(shortcut.is_none());
806            }
807            _ => panic!("Expected Action"),
808        }
809    }
810
811    #[test]
812    fn test_menu_item_action_with_shortcut() {
813        let item = MenuItem::action("Cut", "edit.cut").shortcut("Ctrl+X");
814        match item {
815            MenuItem::Action { shortcut, .. } => {
816                assert_eq!(shortcut, Some("Ctrl+X".to_string()));
817            }
818            _ => panic!("Expected Action"),
819        }
820    }
821
822    #[test]
823    fn test_menu_item_checkbox() {
824        let item = MenuItem::checkbox("Show Grid", "view.grid", true);
825        match item {
826            MenuItem::Checkbox {
827                label,
828                checked,
829                disabled,
830                ..
831            } => {
832                assert_eq!(label, "Show Grid");
833                assert!(checked);
834                assert!(!disabled);
835            }
836            _ => panic!("Expected Checkbox"),
837        }
838    }
839
840    #[test]
841    fn test_menu_item_separator() {
842        let item = MenuItem::separator();
843        assert!(matches!(item, MenuItem::Separator));
844    }
845
846    #[test]
847    fn test_menu_item_submenu() {
848        let items = vec![MenuItem::action("Sub 1", "sub.1")];
849        let item = MenuItem::submenu("More", items);
850        match item {
851            MenuItem::Submenu {
852                label,
853                items,
854                disabled,
855            } => {
856                assert_eq!(label, "More");
857                assert_eq!(items.len(), 1);
858                assert!(!disabled);
859            }
860            _ => panic!("Expected Submenu"),
861        }
862    }
863
864    #[test]
865    fn test_menu_item_disabled() {
866        let item = MenuItem::action("Cut", "edit.cut").disabled(true);
867        match item {
868            MenuItem::Action { disabled, .. } => assert!(disabled),
869            _ => panic!("Expected Action"),
870        }
871    }
872
873    #[test]
874    fn test_menu_item_is_selectable() {
875        assert!(MenuItem::action("Cut", "edit.cut").is_selectable());
876        assert!(!MenuItem::action("Cut", "edit.cut")
877            .disabled(true)
878            .is_selectable());
879        assert!(!MenuItem::separator().is_selectable());
880        assert!(MenuItem::checkbox("Show", "show", false).is_selectable());
881    }
882
883    #[test]
884    fn test_menu_item_height() {
885        assert_eq!(MenuItem::action("Cut", "edit.cut").height(), 32.0);
886        assert_eq!(MenuItem::separator().height(), 9.0);
887    }
888
889    // =========================================================================
890    // Menu Tests
891    // =========================================================================
892
893    #[test]
894    fn test_menu_new() {
895        let menu = Menu::new();
896        assert!(menu.items.is_empty());
897        assert!(!menu.open);
898        assert_eq!(menu.trigger, MenuTrigger::Click);
899    }
900
901    #[test]
902    fn test_menu_builder() {
903        let menu = Menu::new()
904            .items(vec![
905                MenuItem::action("Cut", "cut"),
906                MenuItem::separator(),
907                MenuItem::action("Paste", "paste"),
908            ])
909            .trigger(MenuTrigger::Hover)
910            .width(250.0);
911
912        assert_eq!(menu.items.len(), 3);
913        assert_eq!(menu.trigger, MenuTrigger::Hover);
914        assert_eq!(menu.width, 250.0);
915    }
916
917    #[test]
918    fn test_menu_add_item() {
919        let menu = Menu::new()
920            .item(MenuItem::action("Cut", "cut"))
921            .item(MenuItem::action("Copy", "copy"));
922        assert_eq!(menu.items.len(), 2);
923    }
924
925    #[test]
926    fn test_menu_show_hide() {
927        let mut menu = Menu::new();
928        assert!(!menu.is_open());
929
930        menu.show();
931        assert!(menu.is_open());
932
933        menu.hide();
934        assert!(!menu.is_open());
935    }
936
937    #[test]
938    fn test_menu_toggle() {
939        let mut menu = Menu::new();
940
941        menu.toggle();
942        assert!(menu.is_open());
943
944        menu.toggle();
945        assert!(!menu.is_open());
946    }
947
948    #[test]
949    fn test_menu_calculate_height() {
950        let menu = Menu::new().items(vec![
951            MenuItem::action("Cut", "cut"),
952            MenuItem::separator(),
953            MenuItem::action("Paste", "paste"),
954        ]);
955        // 2 actions (32px each) + 1 separator (9px) + padding (16px) = 89px
956        assert_eq!(menu.calculate_menu_height(), 89.0);
957    }
958
959    #[test]
960    fn test_menu_measure() {
961        let menu = Menu::new().width(200.0);
962        let size = menu.measure(Constraints::loose(Size::new(300.0, 400.0)));
963        assert_eq!(size.width, 200.0);
964    }
965
966    #[test]
967    fn test_menu_layout() {
968        let mut menu = Menu::new().width(200.0);
969        menu.open = true;
970        menu.items = vec![MenuItem::action("Cut", "cut")];
971
972        let result = menu.layout(Rect::new(10.0, 20.0, 100.0, 32.0));
973        assert_eq!(result.size, Size::new(100.0, 32.0));
974        assert_eq!(menu.panel_bounds.x, 10.0);
975        assert_eq!(menu.panel_bounds.y, 52.0); // Below trigger
976    }
977
978    #[test]
979    fn test_menu_type_id() {
980        let menu = Menu::new();
981        assert_eq!(Widget::type_id(&menu), TypeId::of::<Menu>());
982    }
983
984    #[test]
985    fn test_menu_is_focusable() {
986        let menu = Menu::new();
987        assert!(menu.is_focusable());
988    }
989
990    #[test]
991    fn test_menu_test_id() {
992        let menu = Menu::new().with_test_id("my-menu");
993        assert_eq!(Widget::test_id(&menu), Some("my-menu"));
994    }
995
996    #[test]
997    fn test_menu_highlighted_index() {
998        let mut menu = Menu::new();
999        assert!(menu.highlighted_index().is_none());
1000
1001        menu.highlighted_index = Some(2);
1002        assert_eq!(menu.highlighted_index(), Some(2));
1003    }
1004
1005    #[test]
1006    fn test_menu_next_selectable() {
1007        let menu = Menu::new().items(vec![
1008            MenuItem::action("Cut", "cut"),
1009            MenuItem::separator(),
1010            MenuItem::action("Paste", "paste"),
1011        ]);
1012
1013        // Forward from None
1014        assert_eq!(menu.next_selectable(None, true), Some(0));
1015
1016        // Forward from 0
1017        assert_eq!(menu.next_selectable(Some(0), true), Some(2)); // Skips separator
1018
1019        // Backward from 2
1020        assert_eq!(menu.next_selectable(Some(2), false), Some(0)); // Skips separator
1021    }
1022
1023    #[test]
1024    fn test_menu_escape_closes() {
1025        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1026        menu.show();
1027
1028        let result = menu.event(&Event::key_down(Key::Escape));
1029        assert!(result.is_some());
1030        assert!(!menu.is_open());
1031    }
1032
1033    #[test]
1034    fn test_menu_arrow_navigation() {
1035        let mut menu = Menu::new().items(vec![
1036            MenuItem::action("Cut", "cut"),
1037            MenuItem::action("Copy", "copy"),
1038        ]);
1039        menu.show();
1040
1041        menu.event(&Event::key_down(Key::Down));
1042        assert_eq!(menu.highlighted_index, Some(0));
1043
1044        menu.event(&Event::key_down(Key::Down));
1045        assert_eq!(menu.highlighted_index, Some(1));
1046    }
1047
1048    // =========================================================================
1049    // Message Tests
1050    // =========================================================================
1051
1052    #[test]
1053    fn test_menu_toggled_message() {
1054        let msg = MenuToggled { open: true };
1055        assert!(msg.open);
1056    }
1057
1058    #[test]
1059    fn test_menu_item_selected_message() {
1060        let msg = MenuItemSelected {
1061            action: "edit.cut".to_string(),
1062        };
1063        assert_eq!(msg.action, "edit.cut");
1064    }
1065
1066    #[test]
1067    fn test_menu_checkbox_toggled_message() {
1068        let msg = MenuCheckboxToggled {
1069            action: "view.grid".to_string(),
1070            checked: true,
1071        };
1072        assert_eq!(msg.action, "view.grid");
1073        assert!(msg.checked);
1074    }
1075
1076    #[test]
1077    fn test_menu_closed_message() {
1078        let msg = MenuClosed;
1079        assert_eq!(format!("{msg:?}"), "MenuClosed");
1080    }
1081
1082    // =========================================================================
1083    // Additional Coverage Tests
1084    // =========================================================================
1085
1086    #[test]
1087    fn test_menu_shortcut_on_non_action() {
1088        // shortcut() should do nothing on checkbox items
1089        let item = MenuItem::checkbox("Show", "show", false).shortcut("Ctrl+S");
1090        match item {
1091            MenuItem::Checkbox { .. } => {} // Still checkbox, shortcut not applied
1092            _ => panic!("Expected Checkbox"),
1093        }
1094    }
1095
1096    #[test]
1097    fn test_menu_disabled_checkbox() {
1098        let item = MenuItem::checkbox("Show", "show", true).disabled(true);
1099        match item {
1100            MenuItem::Checkbox { disabled, .. } => assert!(disabled),
1101            _ => panic!("Expected Checkbox"),
1102        }
1103    }
1104
1105    #[test]
1106    fn test_menu_disabled_submenu() {
1107        let item = MenuItem::submenu("More", vec![]).disabled(true);
1108        match item {
1109            MenuItem::Submenu { disabled, .. } => assert!(disabled),
1110            _ => panic!("Expected Submenu"),
1111        }
1112    }
1113
1114    #[test]
1115    fn test_menu_disabled_separator_no_op() {
1116        // Calling disabled on separator should be a no-op
1117        let item = MenuItem::separator().disabled(true);
1118        assert!(matches!(item, MenuItem::Separator));
1119    }
1120
1121    #[test]
1122    fn test_menu_submenu_not_selectable_when_disabled() {
1123        let item = MenuItem::submenu("More", vec![]).disabled(true);
1124        assert!(!item.is_selectable());
1125    }
1126
1127    #[test]
1128    fn test_menu_context_menu_trigger() {
1129        let menu = Menu::new().trigger(MenuTrigger::ContextMenu);
1130        assert_eq!(menu.trigger, MenuTrigger::ContextMenu);
1131    }
1132
1133    #[test]
1134    fn test_menu_hover_trigger() {
1135        let menu = Menu::new().trigger(MenuTrigger::Hover);
1136        assert_eq!(menu.trigger, MenuTrigger::Hover);
1137    }
1138
1139    #[test]
1140    fn test_menu_background_color() {
1141        let menu = Menu::new().background_color(Color::RED);
1142        assert_eq!(menu.background_color, Color::RED);
1143    }
1144
1145    #[test]
1146    fn test_menu_hover_color() {
1147        let menu = Menu::new().hover_color(Color::BLUE);
1148        assert_eq!(menu.hover_color, Color::BLUE);
1149    }
1150
1151    #[test]
1152    fn test_menu_text_color() {
1153        let menu = Menu::new().text_color(Color::GREEN);
1154        assert_eq!(menu.text_color, Color::GREEN);
1155    }
1156
1157    #[test]
1158    fn test_menu_next_selectable_empty() {
1159        let menu = Menu::new();
1160        assert!(menu.next_selectable(None, true).is_none());
1161        assert!(menu.next_selectable(None, false).is_none());
1162    }
1163
1164    #[test]
1165    fn test_menu_next_selectable_all_disabled() {
1166        let menu = Menu::new().items(vec![
1167            MenuItem::separator(),
1168            MenuItem::action("Cut", "cut").disabled(true),
1169            MenuItem::separator(),
1170        ]);
1171        assert!(menu.next_selectable(None, true).is_none());
1172    }
1173
1174    #[test]
1175    fn test_menu_next_selectable_wrap_forward() {
1176        let menu = Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1177        // From last item, should wrap to first
1178        assert_eq!(menu.next_selectable(Some(1), true), Some(0));
1179    }
1180
1181    #[test]
1182    fn test_menu_next_selectable_wrap_backward() {
1183        let menu = Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1184        // From first item, should wrap to last
1185        assert_eq!(menu.next_selectable(Some(0), false), Some(1));
1186    }
1187
1188    #[test]
1189    fn test_menu_children_empty() {
1190        let menu = Menu::new();
1191        assert!(menu.children().is_empty());
1192    }
1193
1194    #[test]
1195    fn test_menu_children_mut_empty() {
1196        let mut menu = Menu::new();
1197        assert!(menu.children_mut().is_empty());
1198    }
1199
1200    #[test]
1201    fn test_menu_bounds() {
1202        let mut menu = Menu::new();
1203        menu.layout(Rect::new(10.0, 20.0, 200.0, 32.0));
1204        assert_eq!(menu.bounds(), Rect::new(10.0, 20.0, 200.0, 32.0));
1205    }
1206
1207    #[test]
1208    fn test_menu_trigger_default() {
1209        assert_eq!(MenuTrigger::default(), MenuTrigger::Click);
1210    }
1211
1212    #[test]
1213    fn test_menu_event_closed_returns_none() {
1214        let mut menu = Menu::new();
1215        // Event on closed menu should return None
1216        let result = menu.event(&Event::key_down(Key::Down));
1217        assert!(result.is_none());
1218    }
1219
1220    #[test]
1221    fn test_menu_enter_selects_item() {
1222        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1223        menu.show();
1224        menu.highlighted_index = Some(0);
1225        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1226
1227        let result = menu.event(&Event::key_down(Key::Enter));
1228        assert!(result.is_some());
1229        assert!(!menu.is_open());
1230    }
1231
1232    #[test]
1233    fn test_menu_space_selects_item() {
1234        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1235        menu.show();
1236        menu.highlighted_index = Some(0);
1237        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1238
1239        let result = menu.event(&Event::key_down(Key::Space));
1240        assert!(result.is_some());
1241    }
1242
1243    #[test]
1244    fn test_menu_enter_on_checkbox_toggles() {
1245        let mut menu = Menu::new().items(vec![MenuItem::checkbox("Show", "show", false)]);
1246        menu.show();
1247        menu.highlighted_index = Some(0);
1248        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1249
1250        let result = menu.event(&Event::key_down(Key::Enter));
1251        assert!(result.is_some());
1252        // Menu stays open for checkbox
1253        assert!(menu.is_open());
1254    }
1255
1256    #[test]
1257    fn test_menu_enter_on_disabled_does_nothing() {
1258        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut").disabled(true)]);
1259        menu.show();
1260        menu.highlighted_index = Some(0);
1261        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1262
1263        let result = menu.event(&Event::key_down(Key::Enter));
1264        assert!(result.is_none());
1265        assert!(menu.is_open()); // Still open
1266    }
1267
1268    #[test]
1269    fn test_menu_up_arrow_navigation() {
1270        let mut menu =
1271            Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1272        menu.show();
1273        menu.highlighted_index = Some(1);
1274
1275        menu.event(&Event::key_down(Key::Up));
1276        assert_eq!(menu.highlighted_index, Some(0));
1277    }
1278
1279    // =========================================================================
1280    // Brick Trait Tests
1281    // =========================================================================
1282
1283    #[test]
1284    fn test_menu_brick_name() {
1285        let menu = Menu::new();
1286        assert_eq!(menu.brick_name(), "Menu");
1287    }
1288
1289    #[test]
1290    fn test_menu_brick_assertions() {
1291        let menu = Menu::new();
1292        let assertions = menu.assertions();
1293        assert!(!assertions.is_empty());
1294        assert!(matches!(assertions[0], BrickAssertion::MaxLatencyMs(16)));
1295    }
1296
1297    #[test]
1298    fn test_menu_brick_budget() {
1299        let menu = Menu::new();
1300        let budget = menu.budget();
1301        // Verify budget has reasonable values
1302        assert!(budget.layout_ms > 0);
1303        assert!(budget.paint_ms > 0);
1304    }
1305
1306    #[test]
1307    fn test_menu_brick_verify() {
1308        let menu = Menu::new();
1309        let verification = menu.verify();
1310        assert!(!verification.passed.is_empty());
1311        assert!(verification.failed.is_empty());
1312    }
1313
1314    #[test]
1315    fn test_menu_brick_to_html() {
1316        let menu = Menu::new();
1317        let html = menu.to_html();
1318        assert!(html.contains("brick-menu"));
1319    }
1320
1321    #[test]
1322    fn test_menu_brick_to_css() {
1323        let menu = Menu::new();
1324        let css = menu.to_css();
1325        assert!(css.contains(".brick-menu"));
1326        assert!(css.contains("display: block"));
1327        assert!(css.contains("position: relative"));
1328    }
1329
1330    #[test]
1331    fn test_menu_brick_test_id() {
1332        let menu = Menu::new().with_test_id("my-menu");
1333        assert_eq!(Brick::test_id(&menu), Some("my-menu"));
1334    }
1335
1336    #[test]
1337    fn test_menu_brick_test_id_none() {
1338        let menu = Menu::new();
1339        assert!(Brick::test_id(&menu).is_none());
1340    }
1341
1342    // =========================================================================
1343    // Item at Position Tests
1344    // =========================================================================
1345
1346    #[test]
1347    fn test_menu_item_at_position_valid() {
1348        let mut menu = Menu::new().items(vec![
1349            MenuItem::action("Cut", "cut"),
1350            MenuItem::action("Copy", "copy"),
1351            MenuItem::action("Paste", "paste"),
1352        ]);
1353        menu.open = true;
1354        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1355
1356        // Item 0 starts at y = panel_bounds.y + 8.0
1357        // Each action item is 32px tall
1358        let item = menu.item_at_position(menu.panel_bounds.y + 8.0 + 10.0);
1359        assert_eq!(item, Some(0));
1360
1361        let item = menu.item_at_position(menu.panel_bounds.y + 8.0 + 40.0);
1362        assert_eq!(item, Some(1));
1363
1364        let item = menu.item_at_position(menu.panel_bounds.y + 8.0 + 72.0);
1365        assert_eq!(item, Some(2));
1366    }
1367
1368    #[test]
1369    fn test_menu_item_at_position_above_menu() {
1370        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1371        menu.open = true;
1372        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1373
1374        // Y position above the menu
1375        let item = menu.item_at_position(menu.panel_bounds.y - 10.0);
1376        assert!(item.is_none());
1377    }
1378
1379    #[test]
1380    fn test_menu_item_at_position_below_items() {
1381        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1382        menu.open = true;
1383        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1384
1385        // Y position far below the item
1386        let item = menu.item_at_position(menu.panel_bounds.y + 500.0);
1387        assert!(item.is_none());
1388    }
1389
1390    // =========================================================================
1391    // Mouse Event Tests
1392    // =========================================================================
1393
1394    #[test]
1395    fn test_menu_click_on_trigger_opens() {
1396        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1397        menu.layout(Rect::new(10.0, 10.0, 200.0, 32.0));
1398
1399        // Click inside trigger area
1400        let result = menu.event(&Event::MouseDown {
1401            position: Point::new(50.0, 20.0),
1402            button: presentar_core::MouseButton::Left,
1403        });
1404
1405        assert!(result.is_some());
1406        assert!(menu.is_open());
1407    }
1408
1409    #[test]
1410    fn test_menu_click_outside_closes() {
1411        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1412        menu.show();
1413        menu.layout(Rect::new(10.0, 10.0, 200.0, 32.0));
1414
1415        // Click far outside menu area
1416        let result = menu.event(&Event::MouseDown {
1417            position: Point::new(500.0, 500.0),
1418            button: presentar_core::MouseButton::Left,
1419        });
1420
1421        assert!(result.is_some());
1422        assert!(!menu.is_open());
1423    }
1424
1425    #[test]
1426    fn test_menu_click_on_action_item() {
1427        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1428        menu.show();
1429        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1430
1431        // Click on the action item
1432        let click_y = menu.panel_bounds.y + 8.0 + 16.0; // Middle of first item
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_some());
1439        assert!(!menu.is_open()); // Menu closes after action
1440    }
1441
1442    #[test]
1443    fn test_menu_click_on_checkbox_item() {
1444        let mut menu = Menu::new().items(vec![MenuItem::checkbox("Show Grid", "show", false)]);
1445        menu.show();
1446        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1447
1448        // Click on the checkbox item
1449        let click_y = menu.panel_bounds.y + 8.0 + 16.0;
1450        let result = menu.event(&Event::MouseDown {
1451            position: Point::new(menu.panel_bounds.x + 50.0, click_y),
1452            button: presentar_core::MouseButton::Left,
1453        });
1454
1455        assert!(result.is_some());
1456        // Checkbox should be toggled
1457        if let MenuItem::Checkbox { checked, .. } = &menu.items[0] {
1458            assert!(*checked);
1459        } else {
1460            panic!("Expected Checkbox item");
1461        }
1462    }
1463
1464    #[test]
1465    fn test_menu_click_on_disabled_action() {
1466        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut").disabled(true)]);
1467        menu.show();
1468        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1469
1470        // Click on the disabled item
1471        let click_y = menu.panel_bounds.y + 8.0 + 16.0;
1472        let result = menu.event(&Event::MouseDown {
1473            position: Point::new(menu.panel_bounds.x + 50.0, click_y),
1474            button: presentar_core::MouseButton::Left,
1475        });
1476
1477        assert!(result.is_none()); // No action taken
1478        assert!(menu.is_open()); // Menu stays open
1479    }
1480
1481    #[test]
1482    fn test_menu_click_on_submenu_opens_it() {
1483        let mut menu = Menu::new().items(vec![MenuItem::submenu(
1484            "More",
1485            vec![MenuItem::action("Sub", "sub")],
1486        )]);
1487        menu.show();
1488        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1489
1490        // Click on the submenu item
1491        let click_y = menu.panel_bounds.y + 8.0 + 16.0;
1492        let result = menu.event(&Event::MouseDown {
1493            position: Point::new(menu.panel_bounds.x + 50.0, click_y),
1494            button: presentar_core::MouseButton::Left,
1495        });
1496
1497        assert!(result.is_none()); // No message, just opens submenu
1498        assert_eq!(menu.open_submenu, Some(0));
1499    }
1500
1501    #[test]
1502    fn test_menu_mouse_move_updates_highlight() {
1503        let mut menu = Menu::new().items(vec![
1504            MenuItem::action("Cut", "cut"),
1505            MenuItem::action("Copy", "copy"),
1506        ]);
1507        menu.show();
1508        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1509
1510        // Move over first item
1511        let y1 = menu.panel_bounds.y + 8.0 + 16.0;
1512        menu.event(&Event::MouseMove {
1513            position: Point::new(menu.panel_bounds.x + 50.0, y1),
1514        });
1515        assert_eq!(menu.highlighted_index, Some(0));
1516
1517        // Move over second item
1518        let y2 = menu.panel_bounds.y + 8.0 + 48.0;
1519        menu.event(&Event::MouseMove {
1520            position: Point::new(menu.panel_bounds.x + 50.0, y2),
1521        });
1522        assert_eq!(menu.highlighted_index, Some(1));
1523    }
1524
1525    #[test]
1526    fn test_menu_mouse_move_outside_clears_highlight() {
1527        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1528        menu.show();
1529        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1530        menu.highlighted_index = Some(0);
1531
1532        // Move outside menu
1533        menu.event(&Event::MouseMove {
1534            position: Point::new(500.0, 500.0),
1535        });
1536        assert!(menu.highlighted_index.is_none());
1537    }
1538
1539    // =========================================================================
1540    // Keyboard Navigation Edge Cases
1541    // =========================================================================
1542
1543    #[test]
1544    fn test_menu_up_from_none_selects_last() {
1545        let mut menu =
1546            Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1547        menu.show();
1548
1549        menu.event(&Event::key_down(Key::Up));
1550        assert_eq!(menu.highlighted_index, Some(1)); // Wraps to last
1551    }
1552
1553    #[test]
1554    fn test_menu_down_from_last_wraps_to_first() {
1555        let mut menu =
1556            Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1557        menu.show();
1558        menu.highlighted_index = Some(1);
1559
1560        menu.event(&Event::key_down(Key::Down));
1561        assert_eq!(menu.highlighted_index, Some(0)); // Wraps to first
1562    }
1563
1564    #[test]
1565    fn test_menu_up_skips_separator() {
1566        let mut menu = Menu::new().items(vec![
1567            MenuItem::action("A", "a"),
1568            MenuItem::separator(),
1569            MenuItem::action("B", "b"),
1570        ]);
1571        menu.show();
1572        menu.highlighted_index = Some(2);
1573
1574        menu.event(&Event::key_down(Key::Up));
1575        assert_eq!(menu.highlighted_index, Some(0)); // Skips separator
1576    }
1577
1578    #[test]
1579    fn test_menu_down_skips_disabled() {
1580        let mut menu = Menu::new().items(vec![
1581            MenuItem::action("A", "a"),
1582            MenuItem::action("B", "b").disabled(true),
1583            MenuItem::action("C", "c"),
1584        ]);
1585        menu.show();
1586        menu.highlighted_index = Some(0);
1587
1588        menu.event(&Event::key_down(Key::Down));
1589        assert_eq!(menu.highlighted_index, Some(2)); // Skips disabled
1590    }
1591
1592    #[test]
1593    fn test_menu_other_key_does_nothing() {
1594        let mut menu = Menu::new().items(vec![MenuItem::action("A", "a")]);
1595        menu.show();
1596        menu.highlighted_index = Some(0);
1597
1598        let result = menu.event(&Event::key_down(Key::Tab));
1599        assert!(result.is_none());
1600        assert_eq!(menu.highlighted_index, Some(0));
1601    }
1602
1603    #[test]
1604    fn test_menu_enter_on_separator_does_nothing() {
1605        let mut menu = Menu::new().items(vec![MenuItem::separator(), MenuItem::action("A", "a")]);
1606        menu.show();
1607        menu.highlighted_index = Some(0); // Manually set to separator (shouldn't happen in practice)
1608
1609        let result = menu.event(&Event::key_down(Key::Enter));
1610        assert!(result.is_none());
1611        assert!(menu.is_open());
1612    }
1613
1614    #[test]
1615    fn test_menu_enter_on_submenu_does_nothing() {
1616        let mut menu = Menu::new().items(vec![MenuItem::submenu(
1617            "More",
1618            vec![MenuItem::action("Sub", "sub")],
1619        )]);
1620        menu.show();
1621        menu.highlighted_index = Some(0);
1622
1623        let result = menu.event(&Event::key_down(Key::Enter));
1624        assert!(result.is_none());
1625        assert!(menu.is_open());
1626    }
1627
1628    #[test]
1629    fn test_menu_space_on_checkbox_toggles() {
1630        let mut menu = Menu::new().items(vec![MenuItem::checkbox("Show", "show", false)]);
1631        menu.show();
1632        menu.highlighted_index = Some(0);
1633        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1634
1635        let result = menu.event(&Event::key_down(Key::Space));
1636        assert!(result.is_some());
1637        // Checkbox should be toggled
1638        if let MenuItem::Checkbox { checked, .. } = &menu.items[0] {
1639            assert!(*checked);
1640        }
1641    }
1642
1643    // =========================================================================
1644    // MenuItem Methods Tests
1645    // =========================================================================
1646
1647    #[test]
1648    fn test_menu_item_height_action() {
1649        let item = MenuItem::action("Test", "test");
1650        assert_eq!(item.height(), 32.0);
1651    }
1652
1653    #[test]
1654    fn test_menu_item_height_checkbox() {
1655        let item = MenuItem::checkbox("Test", "test", false);
1656        assert_eq!(item.height(), 32.0);
1657    }
1658
1659    #[test]
1660    fn test_menu_item_height_submenu() {
1661        let item = MenuItem::submenu("More", vec![]);
1662        assert_eq!(item.height(), 32.0);
1663    }
1664
1665    #[test]
1666    fn test_menu_item_is_selectable_submenu() {
1667        let item = MenuItem::submenu("More", vec![]);
1668        assert!(item.is_selectable());
1669    }
1670
1671    #[test]
1672    fn test_menu_item_is_selectable_disabled_checkbox() {
1673        let item = MenuItem::checkbox("Test", "test", false).disabled(true);
1674        assert!(!item.is_selectable());
1675    }
1676
1677    // =========================================================================
1678    // Menu Trigger Tests
1679    // =========================================================================
1680
1681    #[test]
1682    fn test_menu_trigger_hover_no_click_open() {
1683        let mut menu = Menu::new()
1684            .trigger(MenuTrigger::Hover)
1685            .items(vec![MenuItem::action("Cut", "cut")]);
1686        menu.layout(Rect::new(10.0, 10.0, 200.0, 32.0));
1687
1688        // Click with hover trigger should not toggle
1689        let result = menu.event(&Event::MouseDown {
1690            position: Point::new(50.0, 20.0),
1691            button: presentar_core::MouseButton::Left,
1692        });
1693
1694        assert!(result.is_none());
1695        assert!(!menu.is_open());
1696    }
1697
1698    #[test]
1699    fn test_menu_trigger_context_menu_no_click_open() {
1700        let mut menu = Menu::new()
1701            .trigger(MenuTrigger::ContextMenu)
1702            .items(vec![MenuItem::action("Cut", "cut")]);
1703        menu.layout(Rect::new(10.0, 10.0, 200.0, 32.0));
1704
1705        // Regular click with context trigger should not toggle
1706        let result = menu.event(&Event::MouseDown {
1707            position: Point::new(50.0, 20.0),
1708            button: presentar_core::MouseButton::Left,
1709        });
1710
1711        assert!(result.is_none());
1712        assert!(!menu.is_open());
1713    }
1714
1715    // =========================================================================
1716    // Message Clone Tests
1717    // =========================================================================
1718
1719    #[test]
1720    fn test_menu_toggled_clone() {
1721        let msg = MenuToggled { open: true };
1722        let cloned = msg.clone();
1723        assert_eq!(cloned.open, msg.open);
1724    }
1725
1726    #[test]
1727    fn test_menu_item_selected_clone() {
1728        let msg = MenuItemSelected {
1729            action: "test".to_string(),
1730        };
1731        let cloned = msg.clone();
1732        assert_eq!(cloned.action, msg.action);
1733    }
1734
1735    #[test]
1736    fn test_menu_checkbox_toggled_clone() {
1737        let msg = MenuCheckboxToggled {
1738            action: "test".to_string(),
1739            checked: true,
1740        };
1741        let cloned = msg.clone();
1742        assert_eq!(cloned.action, msg.action);
1743        assert_eq!(cloned.checked, msg.checked);
1744    }
1745
1746    #[test]
1747    fn test_menu_closed_clone() {
1748        let msg = MenuClosed;
1749        let cloned = msg;
1750        assert_eq!(format!("{cloned:?}"), "MenuClosed");
1751    }
1752
1753    // =========================================================================
1754    // Default Trait Tests
1755    // =========================================================================
1756
1757    #[test]
1758    fn test_menu_default() {
1759        let menu = Menu::default();
1760        assert!(menu.items.is_empty());
1761        assert!(!menu.open);
1762        assert_eq!(menu.trigger, MenuTrigger::Click);
1763        assert_eq!(menu.width, 200.0);
1764    }
1765
1766    #[test]
1767    fn test_menu_trigger_eq() {
1768        assert_eq!(MenuTrigger::Click, MenuTrigger::Click);
1769        assert_ne!(MenuTrigger::Click, MenuTrigger::Hover);
1770        assert_ne!(MenuTrigger::Hover, MenuTrigger::ContextMenu);
1771    }
1772
1773    #[test]
1774    fn test_menu_hide_clears_submenu() {
1775        let mut menu = Menu::new().items(vec![MenuItem::submenu(
1776            "More",
1777            vec![MenuItem::action("Sub", "sub")],
1778        )]);
1779        menu.show();
1780        menu.open_submenu = Some(0);
1781
1782        menu.hide();
1783        assert!(!menu.is_open());
1784        assert!(menu.open_submenu.is_none());
1785        assert!(menu.highlighted_index.is_none());
1786    }
1787
1788    #[test]
1789    fn test_menu_debug() {
1790        let item = MenuItem::action("Test", "test");
1791        let debug_str = format!("{item:?}");
1792        assert!(debug_str.contains("Test"));
1793    }
1794
1795    #[test]
1796    fn test_menu_toggled_debug() {
1797        let msg = MenuToggled { open: true };
1798        let debug_str = format!("{msg:?}");
1799        assert!(debug_str.contains("true"));
1800    }
1801}