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    Canvas, Color, Constraints, Event, Key, Point, Rect, Size, TypeId, Widget,
9};
10use serde::{Deserialize, Serialize};
11use std::any::Any;
12
13/// Menu item variant.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub enum MenuItem {
16    /// Action item with label
17    Action {
18        /// Item label
19        label: String,
20        /// Unique action ID
21        action: String,
22        /// Whether item is disabled
23        disabled: bool,
24        /// Optional keyboard shortcut display
25        shortcut: Option<String>,
26    },
27    /// Checkable item
28    Checkbox {
29        /// Item label
30        label: String,
31        /// Action ID
32        action: String,
33        /// Whether checked
34        checked: bool,
35        /// Whether disabled
36        disabled: bool,
37    },
38    /// Separator line
39    Separator,
40    /// Submenu
41    Submenu {
42        /// Submenu label
43        label: String,
44        /// Child items
45        items: Vec<MenuItem>,
46        /// Whether disabled
47        disabled: bool,
48    },
49}
50
51impl MenuItem {
52    /// Create a new action item.
53    #[must_use]
54    pub fn action(label: impl Into<String>, action: impl Into<String>) -> Self {
55        Self::Action {
56            label: label.into(),
57            action: action.into(),
58            disabled: false,
59            shortcut: None,
60        }
61    }
62
63    /// Create a new checkbox item.
64    #[must_use]
65    pub fn checkbox(label: impl Into<String>, action: impl Into<String>, checked: bool) -> Self {
66        Self::Checkbox {
67            label: label.into(),
68            action: action.into(),
69            checked,
70            disabled: false,
71        }
72    }
73
74    /// Create a separator.
75    #[must_use]
76    pub const fn separator() -> Self {
77        Self::Separator
78    }
79
80    /// Create a submenu.
81    #[must_use]
82    pub fn submenu(label: impl Into<String>, items: Vec<Self>) -> Self {
83        Self::Submenu {
84            label: label.into(),
85            items,
86            disabled: false,
87        }
88    }
89
90    /// Set disabled state.
91    #[must_use]
92    pub fn disabled(mut self, disabled: bool) -> Self {
93        match &mut self {
94            Self::Action { disabled: d, .. }
95            | Self::Checkbox { disabled: d, .. }
96            | Self::Submenu { disabled: d, .. } => *d = disabled,
97            Self::Separator => {}
98        }
99        self
100    }
101
102    /// Set shortcut (for action items).
103    #[must_use]
104    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
105        if let Self::Action { shortcut: s, .. } = &mut self {
106            *s = Some(shortcut.into());
107        }
108        self
109    }
110
111    /// Check if this item is selectable (not separator, not disabled).
112    #[must_use]
113    pub fn is_selectable(&self) -> bool {
114        match self {
115            Self::Action { disabled, .. }
116            | Self::Checkbox { disabled, .. }
117            | Self::Submenu { disabled, .. } => !disabled,
118            Self::Separator => false,
119        }
120    }
121
122    /// Get item height.
123    #[must_use]
124    pub const fn height(&self) -> f32 {
125        match self {
126            Self::Separator => 9.0, // 1px line + 4px padding each side
127            _ => 32.0,
128        }
129    }
130}
131
132/// Menu trigger mode.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
134pub enum MenuTrigger {
135    /// Click to open
136    #[default]
137    Click,
138    /// Hover to open
139    Hover,
140    /// Right-click (context menu)
141    ContextMenu,
142}
143
144/// Menu widget for dropdown actions.
145#[derive(Serialize, Deserialize)]
146pub struct Menu {
147    /// Menu items
148    pub items: Vec<MenuItem>,
149    /// Whether menu is open
150    pub open: bool,
151    /// Trigger mode
152    pub trigger: MenuTrigger,
153    /// Menu width
154    pub width: f32,
155    /// Background color
156    pub background_color: Color,
157    /// Hover color
158    pub hover_color: Color,
159    /// Text color
160    pub text_color: Color,
161    /// Disabled text color
162    pub disabled_color: Color,
163    /// Test ID
164    test_id_value: Option<String>,
165    /// Cached bounds
166    #[serde(skip)]
167    bounds: Rect,
168    /// Panel bounds (dropdown area)
169    #[serde(skip)]
170    panel_bounds: Rect,
171    /// Currently highlighted index
172    #[serde(skip)]
173    highlighted_index: Option<usize>,
174    /// Open submenu index
175    #[serde(skip)]
176    open_submenu: Option<usize>,
177    /// Trigger widget
178    #[serde(skip)]
179    trigger_widget: Option<Box<dyn Widget>>,
180}
181
182impl Default for Menu {
183    fn default() -> Self {
184        Self {
185            items: Vec::new(),
186            open: false,
187            trigger: MenuTrigger::Click,
188            width: 200.0,
189            background_color: Color::WHITE,
190            hover_color: Color::rgba(0.0, 0.0, 0.0, 0.1),
191            text_color: Color::BLACK,
192            disabled_color: Color::rgb(0.6, 0.6, 0.6),
193            test_id_value: None,
194            bounds: Rect::default(),
195            panel_bounds: Rect::default(),
196            highlighted_index: None,
197            open_submenu: None,
198            trigger_widget: None,
199        }
200    }
201}
202
203impl Menu {
204    /// Create a new menu.
205    #[must_use]
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// Set menu items.
211    #[must_use]
212    pub fn items(mut self, items: Vec<MenuItem>) -> Self {
213        self.items = items;
214        self
215    }
216
217    /// Add a single item.
218    #[must_use]
219    pub fn item(mut self, item: MenuItem) -> Self {
220        self.items.push(item);
221        self
222    }
223
224    /// Set trigger mode.
225    #[must_use]
226    pub const fn trigger(mut self, trigger: MenuTrigger) -> Self {
227        self.trigger = trigger;
228        self
229    }
230
231    /// Set menu width.
232    #[must_use]
233    pub const fn width(mut self, width: f32) -> Self {
234        self.width = width;
235        self
236    }
237
238    /// Set background color.
239    #[must_use]
240    pub const fn background_color(mut self, color: Color) -> Self {
241        self.background_color = color;
242        self
243    }
244
245    /// Set hover color.
246    #[must_use]
247    pub const fn hover_color(mut self, color: Color) -> Self {
248        self.hover_color = color;
249        self
250    }
251
252    /// Set text color.
253    #[must_use]
254    pub const fn text_color(mut self, color: Color) -> Self {
255        self.text_color = color;
256        self
257    }
258
259    /// Set the trigger widget.
260    pub fn trigger_widget(mut self, widget: impl Widget + 'static) -> Self {
261        self.trigger_widget = Some(Box::new(widget));
262        self
263    }
264
265    /// Set test ID.
266    #[must_use]
267    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
268        self.test_id_value = Some(id.into());
269        self
270    }
271
272    /// Open the menu.
273    pub fn show(&mut self) {
274        self.open = true;
275        self.highlighted_index = None;
276    }
277
278    /// Close the menu.
279    pub fn hide(&mut self) {
280        self.open = false;
281        self.highlighted_index = None;
282        self.open_submenu = None;
283    }
284
285    /// Toggle the menu.
286    pub fn toggle(&mut self) {
287        if self.open {
288            self.hide();
289        } else {
290            self.show();
291        }
292    }
293
294    /// Check if menu is open.
295    #[must_use]
296    pub const fn is_open(&self) -> bool {
297        self.open
298    }
299
300    /// Get highlighted index.
301    #[must_use]
302    pub const fn highlighted_index(&self) -> Option<usize> {
303        self.highlighted_index
304    }
305
306    /// Calculate total menu height.
307    fn calculate_menu_height(&self) -> f32 {
308        let padding = 8.0; // Top and bottom padding
309        let items_height: f32 = self.items.iter().map(MenuItem::height).sum();
310        items_height + padding * 2.0
311    }
312
313    /// Find next selectable item.
314    fn next_selectable(&self, from: Option<usize>, forward: bool) -> Option<usize> {
315        if self.items.is_empty() {
316            return None;
317        }
318
319        let start = from.map_or_else(
320            || if forward { 0 } else { self.items.len() - 1 },
321            |i| {
322                if forward {
323                    if i + 1 >= self.items.len() {
324                        0
325                    } else {
326                        i + 1
327                    }
328                } else if i == 0 {
329                    self.items.len() - 1
330                } else {
331                    i - 1
332                }
333            },
334        );
335
336        let mut idx = start;
337        for _ in 0..self.items.len() {
338            if self.items[idx].is_selectable() {
339                return Some(idx);
340            }
341            if forward {
342                idx = if idx + 1 >= self.items.len() {
343                    0
344                } else {
345                    idx + 1
346                };
347            } else {
348                idx = if idx == 0 {
349                    self.items.len() - 1
350                } else {
351                    idx - 1
352                };
353            }
354        }
355
356        None
357    }
358
359    /// Get item at y position.
360    fn item_at_position(&self, y: f32) -> Option<usize> {
361        let relative_y = y - self.panel_bounds.y - 8.0; // Subtract padding
362        if relative_y < 0.0 {
363            return None;
364        }
365
366        let mut current_y = 0.0;
367        for (i, item) in self.items.iter().enumerate() {
368            let height = item.height();
369            if relative_y >= current_y && relative_y < current_y + height {
370                return Some(i);
371            }
372            current_y += height;
373        }
374
375        None
376    }
377}
378
379impl Widget for Menu {
380    fn type_id(&self) -> TypeId {
381        TypeId::of::<Self>()
382    }
383
384    fn measure(&self, constraints: Constraints) -> Size {
385        // Measure just the trigger area
386        if let Some(ref trigger) = self.trigger_widget {
387            trigger.measure(constraints)
388        } else {
389            Size::new(self.width.min(constraints.max_width), 32.0)
390        }
391    }
392
393    fn layout(&mut self, bounds: Rect) -> LayoutResult {
394        self.bounds = bounds;
395
396        // Layout trigger widget
397        if let Some(ref mut trigger) = self.trigger_widget {
398            trigger.layout(bounds);
399        }
400
401        // Calculate menu panel bounds (below trigger)
402        if self.open {
403            let menu_height = self.calculate_menu_height();
404            self.panel_bounds =
405                Rect::new(bounds.x, bounds.y + bounds.height, self.width, menu_height);
406        }
407
408        LayoutResult {
409            size: bounds.size(),
410        }
411    }
412
413    #[allow(clippy::too_many_lines)]
414    fn paint(&self, canvas: &mut dyn Canvas) {
415        // Paint trigger
416        if let Some(ref trigger) = self.trigger_widget {
417            trigger.paint(canvas);
418        }
419
420        if !self.open {
421            return;
422        }
423
424        // Paint menu background with shadow
425        let shadow_bounds = Rect::new(
426            self.panel_bounds.x + 2.0,
427            self.panel_bounds.y + 2.0,
428            self.panel_bounds.width,
429            self.panel_bounds.height,
430        );
431        canvas.fill_rect(shadow_bounds, Color::rgba(0.0, 0.0, 0.0, 0.1));
432        canvas.fill_rect(self.panel_bounds, self.background_color);
433
434        // Paint items
435        let mut y = self.panel_bounds.y + 8.0; // Top padding
436        let text_style = TextStyle {
437            size: 14.0,
438            color: self.text_color,
439            ..Default::default()
440        };
441        let disabled_style = TextStyle {
442            size: 14.0,
443            color: self.disabled_color,
444            ..Default::default()
445        };
446
447        for (i, item) in self.items.iter().enumerate() {
448            let height = item.height();
449
450            match item {
451                MenuItem::Action {
452                    label,
453                    disabled,
454                    shortcut,
455                    ..
456                } => {
457                    // Hover background
458                    if self.highlighted_index == Some(i) && !disabled {
459                        let hover_rect =
460                            Rect::new(self.panel_bounds.x, y, self.panel_bounds.width, height);
461                        canvas.fill_rect(hover_rect, self.hover_color);
462                    }
463
464                    // Label
465                    let style = if *disabled {
466                        &disabled_style
467                    } else {
468                        &text_style
469                    };
470                    canvas.draw_text(
471                        label,
472                        Point::new(self.panel_bounds.x + 12.0, y + 20.0),
473                        style,
474                    );
475
476                    // Shortcut
477                    if let Some(ref shortcut) = shortcut {
478                        let shortcut_style = TextStyle {
479                            size: 12.0,
480                            color: self.disabled_color,
481                            ..Default::default()
482                        };
483                        canvas.draw_text(
484                            shortcut,
485                            Point::new(
486                                self.panel_bounds.x + self.panel_bounds.width - 60.0,
487                                y + 20.0,
488                            ),
489                            &shortcut_style,
490                        );
491                    }
492                }
493                MenuItem::Checkbox {
494                    label,
495                    checked,
496                    disabled,
497                    ..
498                } => {
499                    // Hover background
500                    if self.highlighted_index == Some(i) && !disabled {
501                        let hover_rect =
502                            Rect::new(self.panel_bounds.x, y, self.panel_bounds.width, height);
503                        canvas.fill_rect(hover_rect, self.hover_color);
504                    }
505
506                    // Checkbox indicator
507                    let check_text = if *checked { "✓" } else { " " };
508                    let style = if *disabled {
509                        &disabled_style
510                    } else {
511                        &text_style
512                    };
513                    canvas.draw_text(
514                        check_text,
515                        Point::new(self.panel_bounds.x + 12.0, y + 20.0),
516                        style,
517                    );
518
519                    // Label
520                    canvas.draw_text(
521                        label,
522                        Point::new(self.panel_bounds.x + 32.0, y + 20.0),
523                        style,
524                    );
525                }
526                MenuItem::Separator => {
527                    let line_y = y + 4.0;
528                    canvas.draw_line(
529                        Point::new(self.panel_bounds.x + 8.0, line_y),
530                        Point::new(self.panel_bounds.x + self.panel_bounds.width - 8.0, line_y),
531                        Color::rgb(0.9, 0.9, 0.9),
532                        1.0,
533                    );
534                }
535                MenuItem::Submenu {
536                    label, disabled, ..
537                } => {
538                    // Hover background
539                    if self.highlighted_index == Some(i) && !disabled {
540                        let hover_rect =
541                            Rect::new(self.panel_bounds.x, y, self.panel_bounds.width, height);
542                        canvas.fill_rect(hover_rect, self.hover_color);
543                    }
544
545                    // Label
546                    let style = if *disabled {
547                        &disabled_style
548                    } else {
549                        &text_style
550                    };
551                    canvas.draw_text(
552                        label,
553                        Point::new(self.panel_bounds.x + 12.0, y + 20.0),
554                        style,
555                    );
556
557                    // Arrow indicator
558                    canvas.draw_text(
559                        "›",
560                        Point::new(
561                            self.panel_bounds.x + self.panel_bounds.width - 20.0,
562                            y + 20.0,
563                        ),
564                        style,
565                    );
566                }
567            }
568
569            y += height;
570        }
571    }
572
573    #[allow(clippy::too_many_lines)]
574    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
575        match event {
576            Event::MouseDown { position, .. } => {
577                // Check if click is on trigger
578                let on_trigger = position.x >= self.bounds.x
579                    && position.x <= self.bounds.x + self.bounds.width
580                    && position.y >= self.bounds.y
581                    && position.y <= self.bounds.y + self.bounds.height;
582
583                if on_trigger && self.trigger == MenuTrigger::Click {
584                    self.toggle();
585                    return Some(Box::new(MenuToggled { open: self.open }));
586                }
587
588                // Check if click is on menu item
589                if self.open {
590                    let on_menu = position.x >= self.panel_bounds.x
591                        && position.x <= self.panel_bounds.x + self.panel_bounds.width
592                        && position.y >= self.panel_bounds.y
593                        && position.y <= self.panel_bounds.y + self.panel_bounds.height;
594
595                    if on_menu {
596                        if let Some(idx) = self.item_at_position(position.y) {
597                            if let Some(item) = self.items.get_mut(idx) {
598                                match item {
599                                    MenuItem::Action {
600                                        action, disabled, ..
601                                    } if !*disabled => {
602                                        let action_id = action.clone();
603                                        self.hide();
604                                        return Some(Box::new(MenuItemSelected {
605                                            action: action_id,
606                                        }));
607                                    }
608                                    MenuItem::Checkbox {
609                                        action,
610                                        checked,
611                                        disabled,
612                                        ..
613                                    } if !*disabled => {
614                                        *checked = !*checked;
615                                        let action_id = action.clone();
616                                        let is_checked = *checked;
617                                        return Some(Box::new(MenuCheckboxToggled {
618                                            action: action_id,
619                                            checked: is_checked,
620                                        }));
621                                    }
622                                    MenuItem::Submenu { disabled, .. } if !*disabled => {
623                                        self.open_submenu = Some(idx);
624                                    }
625                                    _ => {}
626                                }
627                            }
628                        }
629                    } else {
630                        // Click outside menu - close
631                        self.hide();
632                        return Some(Box::new(MenuClosed));
633                    }
634                }
635            }
636            Event::MouseMove { position } => {
637                if self.open {
638                    let on_menu = position.x >= self.panel_bounds.x
639                        && position.x <= self.panel_bounds.x + self.panel_bounds.width
640                        && position.y >= self.panel_bounds.y
641                        && position.y <= self.panel_bounds.y + self.panel_bounds.height;
642
643                    if on_menu {
644                        self.highlighted_index = self.item_at_position(position.y);
645                    } else {
646                        self.highlighted_index = None;
647                    }
648                }
649            }
650            Event::KeyDown { key } if self.open => match key {
651                Key::Escape => {
652                    self.hide();
653                    return Some(Box::new(MenuClosed));
654                }
655                Key::Up => {
656                    self.highlighted_index = self.next_selectable(self.highlighted_index, false);
657                }
658                Key::Down => {
659                    self.highlighted_index = self.next_selectable(self.highlighted_index, true);
660                }
661                Key::Enter | Key::Space => {
662                    if let Some(idx) = self.highlighted_index {
663                        if let Some(item) = self.items.get_mut(idx) {
664                            match item {
665                                MenuItem::Action {
666                                    action, disabled, ..
667                                } if !*disabled => {
668                                    let action_id = action.clone();
669                                    self.hide();
670                                    return Some(Box::new(MenuItemSelected { action: action_id }));
671                                }
672                                MenuItem::Checkbox {
673                                    action,
674                                    checked,
675                                    disabled,
676                                    ..
677                                } if !*disabled => {
678                                    *checked = !*checked;
679                                    let action_id = action.clone();
680                                    let is_checked = *checked;
681                                    return Some(Box::new(MenuCheckboxToggled {
682                                        action: action_id,
683                                        checked: is_checked,
684                                    }));
685                                }
686                                _ => {}
687                            }
688                        }
689                    }
690                }
691                _ => {}
692            },
693            _ => {}
694        }
695
696        None
697    }
698
699    fn children(&self) -> &[Box<dyn Widget>] {
700        &[]
701    }
702
703    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
704        &mut []
705    }
706
707    fn is_focusable(&self) -> bool {
708        true
709    }
710
711    fn test_id(&self) -> Option<&str> {
712        self.test_id_value.as_deref()
713    }
714
715    fn bounds(&self) -> Rect {
716        self.bounds
717    }
718}
719
720/// Message when menu is toggled.
721#[derive(Debug, Clone)]
722pub struct MenuToggled {
723    /// Whether menu is now open
724    pub open: bool,
725}
726
727/// Message when menu item is selected.
728#[derive(Debug, Clone)]
729pub struct MenuItemSelected {
730    /// Action ID of selected item
731    pub action: String,
732}
733
734/// Message when menu checkbox is toggled.
735#[derive(Debug, Clone)]
736pub struct MenuCheckboxToggled {
737    /// Action ID
738    pub action: String,
739    /// New checked state
740    pub checked: bool,
741}
742
743/// Message when menu is closed.
744#[derive(Debug, Clone)]
745pub struct MenuClosed;
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    // =========================================================================
752    // MenuItem Tests
753    // =========================================================================
754
755    #[test]
756    fn test_menu_item_action() {
757        let item = MenuItem::action("Cut", "edit.cut");
758        match item {
759            MenuItem::Action {
760                label,
761                action,
762                disabled,
763                shortcut,
764            } => {
765                assert_eq!(label, "Cut");
766                assert_eq!(action, "edit.cut");
767                assert!(!disabled);
768                assert!(shortcut.is_none());
769            }
770            _ => panic!("Expected Action"),
771        }
772    }
773
774    #[test]
775    fn test_menu_item_action_with_shortcut() {
776        let item = MenuItem::action("Cut", "edit.cut").shortcut("Ctrl+X");
777        match item {
778            MenuItem::Action { shortcut, .. } => {
779                assert_eq!(shortcut, Some("Ctrl+X".to_string()));
780            }
781            _ => panic!("Expected Action"),
782        }
783    }
784
785    #[test]
786    fn test_menu_item_checkbox() {
787        let item = MenuItem::checkbox("Show Grid", "view.grid", true);
788        match item {
789            MenuItem::Checkbox {
790                label,
791                checked,
792                disabled,
793                ..
794            } => {
795                assert_eq!(label, "Show Grid");
796                assert!(checked);
797                assert!(!disabled);
798            }
799            _ => panic!("Expected Checkbox"),
800        }
801    }
802
803    #[test]
804    fn test_menu_item_separator() {
805        let item = MenuItem::separator();
806        assert!(matches!(item, MenuItem::Separator));
807    }
808
809    #[test]
810    fn test_menu_item_submenu() {
811        let items = vec![MenuItem::action("Sub 1", "sub.1")];
812        let item = MenuItem::submenu("More", items);
813        match item {
814            MenuItem::Submenu {
815                label,
816                items,
817                disabled,
818            } => {
819                assert_eq!(label, "More");
820                assert_eq!(items.len(), 1);
821                assert!(!disabled);
822            }
823            _ => panic!("Expected Submenu"),
824        }
825    }
826
827    #[test]
828    fn test_menu_item_disabled() {
829        let item = MenuItem::action("Cut", "edit.cut").disabled(true);
830        match item {
831            MenuItem::Action { disabled, .. } => assert!(disabled),
832            _ => panic!("Expected Action"),
833        }
834    }
835
836    #[test]
837    fn test_menu_item_is_selectable() {
838        assert!(MenuItem::action("Cut", "edit.cut").is_selectable());
839        assert!(!MenuItem::action("Cut", "edit.cut")
840            .disabled(true)
841            .is_selectable());
842        assert!(!MenuItem::separator().is_selectable());
843        assert!(MenuItem::checkbox("Show", "show", false).is_selectable());
844    }
845
846    #[test]
847    fn test_menu_item_height() {
848        assert_eq!(MenuItem::action("Cut", "edit.cut").height(), 32.0);
849        assert_eq!(MenuItem::separator().height(), 9.0);
850    }
851
852    // =========================================================================
853    // Menu Tests
854    // =========================================================================
855
856    #[test]
857    fn test_menu_new() {
858        let menu = Menu::new();
859        assert!(menu.items.is_empty());
860        assert!(!menu.open);
861        assert_eq!(menu.trigger, MenuTrigger::Click);
862    }
863
864    #[test]
865    fn test_menu_builder() {
866        let menu = Menu::new()
867            .items(vec![
868                MenuItem::action("Cut", "cut"),
869                MenuItem::separator(),
870                MenuItem::action("Paste", "paste"),
871            ])
872            .trigger(MenuTrigger::Hover)
873            .width(250.0);
874
875        assert_eq!(menu.items.len(), 3);
876        assert_eq!(menu.trigger, MenuTrigger::Hover);
877        assert_eq!(menu.width, 250.0);
878    }
879
880    #[test]
881    fn test_menu_add_item() {
882        let menu = Menu::new()
883            .item(MenuItem::action("Cut", "cut"))
884            .item(MenuItem::action("Copy", "copy"));
885        assert_eq!(menu.items.len(), 2);
886    }
887
888    #[test]
889    fn test_menu_show_hide() {
890        let mut menu = Menu::new();
891        assert!(!menu.is_open());
892
893        menu.show();
894        assert!(menu.is_open());
895
896        menu.hide();
897        assert!(!menu.is_open());
898    }
899
900    #[test]
901    fn test_menu_toggle() {
902        let mut menu = Menu::new();
903
904        menu.toggle();
905        assert!(menu.is_open());
906
907        menu.toggle();
908        assert!(!menu.is_open());
909    }
910
911    #[test]
912    fn test_menu_calculate_height() {
913        let menu = Menu::new().items(vec![
914            MenuItem::action("Cut", "cut"),
915            MenuItem::separator(),
916            MenuItem::action("Paste", "paste"),
917        ]);
918        // 2 actions (32px each) + 1 separator (9px) + padding (16px) = 89px
919        assert_eq!(menu.calculate_menu_height(), 89.0);
920    }
921
922    #[test]
923    fn test_menu_measure() {
924        let menu = Menu::new().width(200.0);
925        let size = menu.measure(Constraints::loose(Size::new(300.0, 400.0)));
926        assert_eq!(size.width, 200.0);
927    }
928
929    #[test]
930    fn test_menu_layout() {
931        let mut menu = Menu::new().width(200.0);
932        menu.open = true;
933        menu.items = vec![MenuItem::action("Cut", "cut")];
934
935        let result = menu.layout(Rect::new(10.0, 20.0, 100.0, 32.0));
936        assert_eq!(result.size, Size::new(100.0, 32.0));
937        assert_eq!(menu.panel_bounds.x, 10.0);
938        assert_eq!(menu.panel_bounds.y, 52.0); // Below trigger
939    }
940
941    #[test]
942    fn test_menu_type_id() {
943        let menu = Menu::new();
944        assert_eq!(Widget::type_id(&menu), TypeId::of::<Menu>());
945    }
946
947    #[test]
948    fn test_menu_is_focusable() {
949        let menu = Menu::new();
950        assert!(menu.is_focusable());
951    }
952
953    #[test]
954    fn test_menu_test_id() {
955        let menu = Menu::new().with_test_id("my-menu");
956        assert_eq!(menu.test_id(), Some("my-menu"));
957    }
958
959    #[test]
960    fn test_menu_highlighted_index() {
961        let mut menu = Menu::new();
962        assert!(menu.highlighted_index().is_none());
963
964        menu.highlighted_index = Some(2);
965        assert_eq!(menu.highlighted_index(), Some(2));
966    }
967
968    #[test]
969    fn test_menu_next_selectable() {
970        let menu = Menu::new().items(vec![
971            MenuItem::action("Cut", "cut"),
972            MenuItem::separator(),
973            MenuItem::action("Paste", "paste"),
974        ]);
975
976        // Forward from None
977        assert_eq!(menu.next_selectable(None, true), Some(0));
978
979        // Forward from 0
980        assert_eq!(menu.next_selectable(Some(0), true), Some(2)); // Skips separator
981
982        // Backward from 2
983        assert_eq!(menu.next_selectable(Some(2), false), Some(0)); // Skips separator
984    }
985
986    #[test]
987    fn test_menu_escape_closes() {
988        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
989        menu.show();
990
991        let result = menu.event(&Event::KeyDown { key: Key::Escape });
992        assert!(result.is_some());
993        assert!(!menu.is_open());
994    }
995
996    #[test]
997    fn test_menu_arrow_navigation() {
998        let mut menu = Menu::new().items(vec![
999            MenuItem::action("Cut", "cut"),
1000            MenuItem::action("Copy", "copy"),
1001        ]);
1002        menu.show();
1003
1004        menu.event(&Event::KeyDown { key: Key::Down });
1005        assert_eq!(menu.highlighted_index, Some(0));
1006
1007        menu.event(&Event::KeyDown { key: Key::Down });
1008        assert_eq!(menu.highlighted_index, Some(1));
1009    }
1010
1011    // =========================================================================
1012    // Message Tests
1013    // =========================================================================
1014
1015    #[test]
1016    fn test_menu_toggled_message() {
1017        let msg = MenuToggled { open: true };
1018        assert!(msg.open);
1019    }
1020
1021    #[test]
1022    fn test_menu_item_selected_message() {
1023        let msg = MenuItemSelected {
1024            action: "edit.cut".to_string(),
1025        };
1026        assert_eq!(msg.action, "edit.cut");
1027    }
1028
1029    #[test]
1030    fn test_menu_checkbox_toggled_message() {
1031        let msg = MenuCheckboxToggled {
1032            action: "view.grid".to_string(),
1033            checked: true,
1034        };
1035        assert_eq!(msg.action, "view.grid");
1036        assert!(msg.checked);
1037    }
1038
1039    #[test]
1040    fn test_menu_closed_message() {
1041        let _msg = MenuClosed;
1042    }
1043
1044    // =========================================================================
1045    // Additional Coverage Tests
1046    // =========================================================================
1047
1048    #[test]
1049    fn test_menu_shortcut_on_non_action() {
1050        // shortcut() should do nothing on checkbox items
1051        let item = MenuItem::checkbox("Show", "show", false).shortcut("Ctrl+S");
1052        match item {
1053            MenuItem::Checkbox { .. } => {} // Still checkbox, shortcut not applied
1054            _ => panic!("Expected Checkbox"),
1055        }
1056    }
1057
1058    #[test]
1059    fn test_menu_disabled_checkbox() {
1060        let item = MenuItem::checkbox("Show", "show", true).disabled(true);
1061        match item {
1062            MenuItem::Checkbox { disabled, .. } => assert!(disabled),
1063            _ => panic!("Expected Checkbox"),
1064        }
1065    }
1066
1067    #[test]
1068    fn test_menu_disabled_submenu() {
1069        let item = MenuItem::submenu("More", vec![]).disabled(true);
1070        match item {
1071            MenuItem::Submenu { disabled, .. } => assert!(disabled),
1072            _ => panic!("Expected Submenu"),
1073        }
1074    }
1075
1076    #[test]
1077    fn test_menu_disabled_separator_no_op() {
1078        // Calling disabled on separator should be a no-op
1079        let item = MenuItem::separator().disabled(true);
1080        assert!(matches!(item, MenuItem::Separator));
1081    }
1082
1083    #[test]
1084    fn test_menu_submenu_not_selectable_when_disabled() {
1085        let item = MenuItem::submenu("More", vec![]).disabled(true);
1086        assert!(!item.is_selectable());
1087    }
1088
1089    #[test]
1090    fn test_menu_context_menu_trigger() {
1091        let menu = Menu::new().trigger(MenuTrigger::ContextMenu);
1092        assert_eq!(menu.trigger, MenuTrigger::ContextMenu);
1093    }
1094
1095    #[test]
1096    fn test_menu_hover_trigger() {
1097        let menu = Menu::new().trigger(MenuTrigger::Hover);
1098        assert_eq!(menu.trigger, MenuTrigger::Hover);
1099    }
1100
1101    #[test]
1102    fn test_menu_background_color() {
1103        let menu = Menu::new().background_color(Color::RED);
1104        assert_eq!(menu.background_color, Color::RED);
1105    }
1106
1107    #[test]
1108    fn test_menu_hover_color() {
1109        let menu = Menu::new().hover_color(Color::BLUE);
1110        assert_eq!(menu.hover_color, Color::BLUE);
1111    }
1112
1113    #[test]
1114    fn test_menu_text_color() {
1115        let menu = Menu::new().text_color(Color::GREEN);
1116        assert_eq!(menu.text_color, Color::GREEN);
1117    }
1118
1119    #[test]
1120    fn test_menu_next_selectable_empty() {
1121        let menu = Menu::new();
1122        assert!(menu.next_selectable(None, true).is_none());
1123        assert!(menu.next_selectable(None, false).is_none());
1124    }
1125
1126    #[test]
1127    fn test_menu_next_selectable_all_disabled() {
1128        let menu = Menu::new().items(vec![
1129            MenuItem::separator(),
1130            MenuItem::action("Cut", "cut").disabled(true),
1131            MenuItem::separator(),
1132        ]);
1133        assert!(menu.next_selectable(None, true).is_none());
1134    }
1135
1136    #[test]
1137    fn test_menu_next_selectable_wrap_forward() {
1138        let menu = Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1139        // From last item, should wrap to first
1140        assert_eq!(menu.next_selectable(Some(1), true), Some(0));
1141    }
1142
1143    #[test]
1144    fn test_menu_next_selectable_wrap_backward() {
1145        let menu = Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1146        // From first item, should wrap to last
1147        assert_eq!(menu.next_selectable(Some(0), false), Some(1));
1148    }
1149
1150    #[test]
1151    fn test_menu_children_empty() {
1152        let menu = Menu::new();
1153        assert!(menu.children().is_empty());
1154    }
1155
1156    #[test]
1157    fn test_menu_children_mut_empty() {
1158        let mut menu = Menu::new();
1159        assert!(menu.children_mut().is_empty());
1160    }
1161
1162    #[test]
1163    fn test_menu_bounds() {
1164        let mut menu = Menu::new();
1165        menu.layout(Rect::new(10.0, 20.0, 200.0, 32.0));
1166        assert_eq!(menu.bounds(), Rect::new(10.0, 20.0, 200.0, 32.0));
1167    }
1168
1169    #[test]
1170    fn test_menu_trigger_default() {
1171        assert_eq!(MenuTrigger::default(), MenuTrigger::Click);
1172    }
1173
1174    #[test]
1175    fn test_menu_event_closed_returns_none() {
1176        let mut menu = Menu::new();
1177        // Event on closed menu should return None
1178        let result = menu.event(&Event::KeyDown { key: Key::Down });
1179        assert!(result.is_none());
1180    }
1181
1182    #[test]
1183    fn test_menu_enter_selects_item() {
1184        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1185        menu.show();
1186        menu.highlighted_index = Some(0);
1187        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1188
1189        let result = menu.event(&Event::KeyDown { key: Key::Enter });
1190        assert!(result.is_some());
1191        assert!(!menu.is_open());
1192    }
1193
1194    #[test]
1195    fn test_menu_space_selects_item() {
1196        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut")]);
1197        menu.show();
1198        menu.highlighted_index = Some(0);
1199        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1200
1201        let result = menu.event(&Event::KeyDown { key: Key::Space });
1202        assert!(result.is_some());
1203    }
1204
1205    #[test]
1206    fn test_menu_enter_on_checkbox_toggles() {
1207        let mut menu = Menu::new().items(vec![MenuItem::checkbox("Show", "show", false)]);
1208        menu.show();
1209        menu.highlighted_index = Some(0);
1210        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1211
1212        let result = menu.event(&Event::KeyDown { key: Key::Enter });
1213        assert!(result.is_some());
1214        // Menu stays open for checkbox
1215        assert!(menu.is_open());
1216    }
1217
1218    #[test]
1219    fn test_menu_enter_on_disabled_does_nothing() {
1220        let mut menu = Menu::new().items(vec![MenuItem::action("Cut", "cut").disabled(true)]);
1221        menu.show();
1222        menu.highlighted_index = Some(0);
1223        menu.layout(Rect::new(0.0, 0.0, 200.0, 32.0));
1224
1225        let result = menu.event(&Event::KeyDown { key: Key::Enter });
1226        assert!(result.is_none());
1227        assert!(menu.is_open()); // Still open
1228    }
1229
1230    #[test]
1231    fn test_menu_up_arrow_navigation() {
1232        let mut menu =
1233            Menu::new().items(vec![MenuItem::action("A", "a"), MenuItem::action("B", "b")]);
1234        menu.show();
1235        menu.highlighted_index = Some(1);
1236
1237        menu.event(&Event::KeyDown { key: Key::Up });
1238        assert_eq!(menu.highlighted_index, Some(0));
1239    }
1240}