1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub enum MenuItem {
16 Action {
18 label: String,
20 action: String,
22 disabled: bool,
24 shortcut: Option<String>,
26 },
27 Checkbox {
29 label: String,
31 action: String,
33 checked: bool,
35 disabled: bool,
37 },
38 Separator,
40 Submenu {
42 label: String,
44 items: Vec<MenuItem>,
46 disabled: bool,
48 },
49}
50
51impl MenuItem {
52 #[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 #[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 #[must_use]
76 pub const fn separator() -> Self {
77 Self::Separator
78 }
79
80 #[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 #[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 #[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 #[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 #[must_use]
124 pub const fn height(&self) -> f32 {
125 match self {
126 Self::Separator => 9.0, _ => 32.0,
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
134pub enum MenuTrigger {
135 #[default]
137 Click,
138 Hover,
140 ContextMenu,
142}
143
144#[derive(Serialize, Deserialize)]
146pub struct Menu {
147 pub items: Vec<MenuItem>,
149 pub open: bool,
151 pub trigger: MenuTrigger,
153 pub width: f32,
155 pub background_color: Color,
157 pub hover_color: Color,
159 pub text_color: Color,
161 pub disabled_color: Color,
163 test_id_value: Option<String>,
165 #[serde(skip)]
167 bounds: Rect,
168 #[serde(skip)]
170 panel_bounds: Rect,
171 #[serde(skip)]
173 highlighted_index: Option<usize>,
174 #[serde(skip)]
176 open_submenu: Option<usize>,
177 #[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 #[must_use]
206 pub fn new() -> Self {
207 Self::default()
208 }
209
210 #[must_use]
212 pub fn items(mut self, items: Vec<MenuItem>) -> Self {
213 self.items = items;
214 self
215 }
216
217 #[must_use]
219 pub fn item(mut self, item: MenuItem) -> Self {
220 self.items.push(item);
221 self
222 }
223
224 #[must_use]
226 pub const fn trigger(mut self, trigger: MenuTrigger) -> Self {
227 self.trigger = trigger;
228 self
229 }
230
231 #[must_use]
233 pub const fn width(mut self, width: f32) -> Self {
234 self.width = width;
235 self
236 }
237
238 #[must_use]
240 pub const fn background_color(mut self, color: Color) -> Self {
241 self.background_color = color;
242 self
243 }
244
245 #[must_use]
247 pub const fn hover_color(mut self, color: Color) -> Self {
248 self.hover_color = color;
249 self
250 }
251
252 #[must_use]
254 pub const fn text_color(mut self, color: Color) -> Self {
255 self.text_color = color;
256 self
257 }
258
259 pub fn trigger_widget(mut self, widget: impl Widget + 'static) -> Self {
261 self.trigger_widget = Some(Box::new(widget));
262 self
263 }
264
265 #[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 pub fn show(&mut self) {
274 self.open = true;
275 self.highlighted_index = None;
276 }
277
278 pub fn hide(&mut self) {
280 self.open = false;
281 self.highlighted_index = None;
282 self.open_submenu = None;
283 }
284
285 pub fn toggle(&mut self) {
287 if self.open {
288 self.hide();
289 } else {
290 self.show();
291 }
292 }
293
294 #[must_use]
296 pub const fn is_open(&self) -> bool {
297 self.open
298 }
299
300 #[must_use]
302 pub const fn highlighted_index(&self) -> Option<usize> {
303 self.highlighted_index
304 }
305
306 fn calculate_menu_height(&self) -> f32 {
308 let padding = 8.0; let items_height: f32 = self.items.iter().map(MenuItem::height).sum();
310 items_height + padding * 2.0
311 }
312
313 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 fn item_at_position(&self, y: f32) -> Option<usize> {
361 let relative_y = y - self.panel_bounds.y - 8.0; 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 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 if let Some(ref mut trigger) = self.trigger_widget {
398 trigger.layout(bounds);
399 }
400
401 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 if let Some(ref trigger) = self.trigger_widget {
417 trigger.paint(canvas);
418 }
419
420 if !self.open {
421 return;
422 }
423
424 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 let mut y = self.panel_bounds.y + 8.0; 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
722pub struct MenuToggled {
723 pub open: bool,
725}
726
727#[derive(Debug, Clone)]
729pub struct MenuItemSelected {
730 pub action: String,
732}
733
734#[derive(Debug, Clone)]
736pub struct MenuCheckboxToggled {
737 pub action: String,
739 pub checked: bool,
741}
742
743#[derive(Debug, Clone)]
745pub struct MenuClosed;
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 #[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 #[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 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); }
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 assert_eq!(menu.next_selectable(None, true), Some(0));
978
979 assert_eq!(menu.next_selectable(Some(0), true), Some(2)); assert_eq!(menu.next_selectable(Some(2), false), Some(0)); }
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 #[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 #[test]
1049 fn test_menu_shortcut_on_non_action() {
1050 let item = MenuItem::checkbox("Show", "show", false).shortcut("Ctrl+S");
1052 match item {
1053 MenuItem::Checkbox { .. } => {} _ => 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 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 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 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 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 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()); }
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}