1extern crate alloc;
14
15use alloc::vec::Vec;
16use core::hash::Hash;
17
18use azul_css::AzString;
19
20use crate::{
21 callbacks::{CoreCallback, CoreCallbackType},
22 refany::RefAny,
23 resources::ImageRef,
24 window::{ContextMenuMouseButton, OptionVirtualKeyCodeCombo},
25};
26
27#[derive(Debug, Default, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
39#[repr(C)]
40pub struct Menu {
41 pub items: MenuItemVec,
42 pub position: MenuPopupPosition,
43 pub context_mouse_btn: ContextMenuMouseButton,
44}
45
46impl_option!(
47 Menu,
48 OptionMenu,
49 copy = false,
50 [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
51);
52
53impl Menu {
54 #[must_use]
58 pub const fn create(items: MenuItemVec) -> Self {
59 Self {
60 items,
61 position: MenuPopupPosition::AutoCursor,
62 context_mouse_btn: ContextMenuMouseButton::Right,
63 }
64 }
65
66 #[must_use]
68 pub const fn with_position(mut self, position: MenuPopupPosition) -> Self {
69 self.position = position;
70 self
71 }
72
73 #[must_use]
77 pub fn get_hash(&self) -> u64 {
78 use core::hash::Hasher;
79 let mut hasher = crate::hash::DefaultHasher::new();
80 self.hash(&mut hasher);
81 hasher.finish()
82 }
83}
84
85#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
90#[repr(C)]
91pub enum MenuPopupPosition {
92 BottomLeftOfCursor,
94 BottomRightOfCursor,
96 TopLeftOfCursor,
98 TopRightOfCursor,
100 BottomOfHitRect,
102 LeftOfHitRect,
104 TopOfHitRect,
106 RightOfHitRect,
108 AutoCursor,
110 AutoHitRect,
112}
113
114impl Default for MenuPopupPosition {
115 fn default() -> Self {
116 Self::AutoCursor
117 }
118}
119
120#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
128#[repr(C)]
129pub enum MenuItemState {
130 Normal,
132 Greyed,
134 Disabled,
136}
137#[allow(variant_size_differences)] #[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
143#[repr(C, u8)]
144#[allow(clippy::large_enum_variant)] pub enum MenuItem {
146 String(StringMenuItem),
148 Separator,
150 BreakLine,
152}
153
154impl_option!(
155 MenuItem,
156 OptionMenuItem,
157 copy = false,
158 [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
159);
160
161impl_vec!(MenuItem, MenuItemVec, MenuItemVecDestructor, MenuItemVecDestructorType, MenuItemVecSlice, OptionMenuItem);
162impl_vec_clone!(MenuItem, MenuItemVec, MenuItemVecDestructor);
163impl_vec_debug!(MenuItem, MenuItemVec);
164impl_vec_partialeq!(MenuItem, MenuItemVec);
165impl_vec_partialord!(MenuItem, MenuItemVec);
166impl_vec_hash!(MenuItem, MenuItemVec);
167impl_vec_eq!(MenuItem, MenuItemVec);
168impl_vec_ord!(MenuItem, MenuItemVec);
169
170#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
181#[repr(C)]
182pub struct StringMenuItem {
183 pub label: AzString,
186 pub accelerator: OptionVirtualKeyCodeCombo,
189 pub callback: OptionCoreMenuCallback,
191 pub menu_item_state: MenuItemState,
193 pub icon: OptionMenuItemIcon,
195 pub children: MenuItemVec,
197}
198
199impl StringMenuItem {
200 #[must_use]
203 pub const fn create(label: AzString) -> Self {
204 Self {
205 label,
206 accelerator: OptionVirtualKeyCodeCombo::None,
207 callback: OptionCoreMenuCallback::None,
208 menu_item_state: MenuItemState::Normal,
209 icon: OptionMenuItemIcon::None,
210 children: MenuItemVec::from_const_slice(&[]),
211 }
212 }
213
214 #[must_use]
216 pub fn with_children(mut self, children: MenuItemVec) -> Self {
217 self.children = children;
218 self
219 }
220
221 #[must_use]
223 pub fn with_child(mut self, child: MenuItem) -> Self {
224 let mut children = self.children.into_library_owned_vec();
225 children.push(child);
226 self.children = children.into();
227 self
228 }
229
230 #[must_use]
242 pub fn with_callback<I: Into<CoreCallback>>(mut self, data: RefAny, callback: I) -> Self {
243 self.callback = Some(CoreMenuCallback {
244 refany: data,
245 callback: callback.into(),
246 })
247 .into();
248 self
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
258#[repr(C, u8)]
259pub enum MenuItemIcon {
260 Checkbox(bool),
262 Image(ImageRef),
264}
265
266impl_option!(
267 MenuItemIcon,
268 OptionMenuItemIcon,
269 copy = false,
270 [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
271);
272
273#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
288#[repr(C)]
289pub struct CoreMenuCallback {
290 pub refany: RefAny,
292 pub callback: CoreCallback,
294}
295
296impl_option!(
297 CoreMenuCallback,
298 OptionCoreMenuCallback,
299 copy = false,
300 [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
301);
302
303#[cfg(test)]
304mod autotest_generated {
305 use alloc::{format, string::String, vec, vec::Vec};
306
307 use super::*;
308 use crate::{
309 refany::RefAny,
310 window::{ContextMenuMouseButton, VirtualKeyCodeCombo, VirtualKeyCodeVec},
311 };
312
313 const ALL_POSITIONS: [MenuPopupPosition; 10] = [
314 MenuPopupPosition::BottomLeftOfCursor,
315 MenuPopupPosition::BottomRightOfCursor,
316 MenuPopupPosition::TopLeftOfCursor,
317 MenuPopupPosition::TopRightOfCursor,
318 MenuPopupPosition::BottomOfHitRect,
319 MenuPopupPosition::LeftOfHitRect,
320 MenuPopupPosition::TopOfHitRect,
321 MenuPopupPosition::RightOfHitRect,
322 MenuPopupPosition::AutoCursor,
323 MenuPopupPosition::AutoHitRect,
324 ];
325
326 fn item(label: &str) -> MenuItem {
327 MenuItem::String(StringMenuItem::create(label.into()))
328 }
329
330 fn item_vec(labels: &[&str]) -> MenuItemVec {
331 labels.iter().map(|l| item(l)).collect::<Vec<_>>().into()
332 }
333
334 fn labels_of(v: &MenuItemVec) -> Vec<&str> {
335 v.as_slice()
336 .iter()
337 .map(|i| match i {
338 MenuItem::String(s) => s.label.as_str(),
339 MenuItem::Separator => "<sep>",
340 MenuItem::BreakLine => "<br>",
341 })
342 .collect()
343 }
344
345 fn all_distinct(hashes: &[u64]) -> bool {
346 let mut sorted = hashes.to_vec();
347 sorted.sort_unstable();
348 sorted.dedup();
349 sorted.len() == hashes.len()
350 }
351
352 #[test]
355 fn menu_create_defaults_and_extreme_inputs() {
356 for items in [
357 MenuItemVec::new(),
358 MenuItemVec::from_const_slice(&[]),
359 item_vec(&["a"]),
360 vec![MenuItem::Separator; 10_000].into(),
361 ] {
362 let expected_len = items.len();
363 let menu = Menu::create(items);
364 assert_eq!(menu.items.len(), expected_len);
365 assert_eq!(menu.position, MenuPopupPosition::AutoCursor);
366 assert_eq!(menu.context_mouse_btn, ContextMenuMouseButton::Right);
367 let _ = menu.get_hash();
368 }
369 }
370
371 #[test]
372 fn menu_create_preserves_item_order_and_contents() {
373 let source = vec![
374 item("File"),
375 MenuItem::Separator,
376 item("Edit"),
377 MenuItem::BreakLine,
378 item(""),
379 ];
380 let menu = Menu::create(source.clone().into());
381 assert_eq!(menu.items.as_slice(), source.as_slice());
382 assert_eq!(labels_of(&menu.items), ["File", "<sep>", "Edit", "<br>", ""]);
383 }
384
385 #[test]
386 fn menu_create_of_empty_vec_equals_default() {
387 assert_eq!(Menu::create(MenuItemVec::new()), Menu::default());
389 assert_eq!(
390 Menu::create(MenuItemVec::new()).get_hash(),
391 Menu::default().get_hash()
392 );
393 }
394
395 #[test]
396 fn menu_item_vec_roundtrip_through_library_owned_vec() {
397 let source = vec![item("a"), MenuItem::Separator, item("\u{1F600}")];
398 let decoded = MenuItemVec::from(source.clone()).into_library_owned_vec();
399 assert_eq!(decoded, source);
400
401 assert!(MenuItemVec::from_const_slice(&[])
403 .into_library_owned_vec()
404 .is_empty());
405 }
406
407 #[test]
410 fn menu_with_position_sets_field_and_keeps_items() {
411 for pos in ALL_POSITIONS {
412 let menu = Menu::create(item_vec(&["a", "b"])).with_position(pos);
413 assert_eq!(menu.position, pos);
414 assert_eq!(menu.items.len(), 2);
415 assert_eq!(labels_of(&menu.items), ["a", "b"]);
416 assert_eq!(menu.context_mouse_btn, ContextMenuMouseButton::Right);
417 }
418 }
419
420 #[test]
421 fn menu_with_position_last_call_wins() {
422 let menu = Menu::create(MenuItemVec::new())
423 .with_position(MenuPopupPosition::TopOfHitRect)
424 .with_position(MenuPopupPosition::LeftOfHitRect)
425 .with_position(MenuPopupPosition::AutoHitRect);
426 assert_eq!(menu.position, MenuPopupPosition::AutoHitRect);
427 }
428
429 #[test]
432 fn menu_get_hash_is_deterministic() {
433 let build = || {
434 Menu::create(item_vec(&["File", "Edit"])).with_position(MenuPopupPosition::TopOfHitRect)
435 };
436 let a = build();
437 let b = build();
438 assert_eq!(a.get_hash(), a.get_hash(), "repeated calls must agree");
439 assert_eq!(
440 a.get_hash(),
441 b.get_hash(),
442 "independently built equal menus must agree"
443 );
444 }
445
446 #[test]
447 fn menu_get_hash_on_empty_and_default_does_not_panic() {
448 let heap_empty = Menu::create(MenuItemVec::new());
449 let static_empty = Menu::create(MenuItemVec::from_const_slice(&[]));
450
451 assert_eq!(heap_empty, static_empty);
454 assert_eq!(heap_empty.get_hash(), static_empty.get_hash());
455 assert_eq!(Menu::default().get_hash(), heap_empty.get_hash());
456 }
457
458 #[test]
459 fn menu_get_hash_reacts_to_position_and_mouse_button() {
460 let hashes: Vec<u64> = ALL_POSITIONS
461 .iter()
462 .map(|p| Menu::create(item_vec(&["a"])).with_position(*p).get_hash())
463 .collect();
464 assert!(all_distinct(&hashes), "each popup position must hash apart");
465
466 let btn_hashes: Vec<u64> = [
467 ContextMenuMouseButton::Right,
468 ContextMenuMouseButton::Middle,
469 ContextMenuMouseButton::Left,
470 ]
471 .iter()
472 .map(|b| {
473 let mut menu = Menu::create(item_vec(&["a"]));
474 menu.context_mouse_btn = *b;
475 menu.get_hash()
476 })
477 .collect();
478 assert!(all_distinct(&btn_hashes));
479 }
480
481 #[test]
482 fn menu_get_hash_distinguishes_nesting_from_flattening() {
483 let nested = Menu::create(MenuItemVec::from_item(MenuItem::String(
485 StringMenuItem::create("a".into()).with_child(item("b")),
486 )));
487 let flat = Menu::create(item_vec(&["a", "b"]));
488 assert_ne!(nested, flat);
489 assert_ne!(nested.get_hash(), flat.get_hash());
490 }
491
492 #[test]
493 fn menu_get_hash_distinguishes_item_kinds_and_states() {
494 let sep = Menu::create(MenuItemVec::from_item(MenuItem::Separator));
495 let br = Menu::create(MenuItemVec::from_item(MenuItem::BreakLine));
496 assert_ne!(sep.get_hash(), br.get_hash());
497
498 let with_state = |state: MenuItemState| {
499 let mut s = StringMenuItem::create("x".into());
500 s.menu_item_state = state;
501 Menu::create(MenuItemVec::from_item(MenuItem::String(s))).get_hash()
502 };
503 assert!(all_distinct(&[
504 with_state(MenuItemState::Normal),
505 with_state(MenuItemState::Greyed),
506 with_state(MenuItemState::Disabled),
507 ]));
508
509 let with_icon = |icon: OptionMenuItemIcon| {
510 let mut s = StringMenuItem::create("x".into());
511 s.icon = icon;
512 Menu::create(MenuItemVec::from_item(MenuItem::String(s))).get_hash()
513 };
514 assert!(all_distinct(&[
515 with_icon(OptionMenuItemIcon::None),
516 with_icon(Some(MenuItemIcon::Checkbox(false)).into()),
517 with_icon(Some(MenuItemIcon::Checkbox(true)).into()),
518 ]));
519 }
520
521 #[test]
522 fn menu_get_hash_separates_absent_accelerator_from_empty_one() {
523 let mut with_empty_combo = StringMenuItem::create("x".into());
525 with_empty_combo.accelerator = Some(VirtualKeyCodeCombo {
526 keys: VirtualKeyCodeVec::new(),
527 })
528 .into();
529
530 let none = Menu::create(MenuItemVec::from_item(MenuItem::String(
531 StringMenuItem::create("x".into()),
532 )));
533 let empty = Menu::create(MenuItemVec::from_item(MenuItem::String(with_empty_combo)));
534 assert_ne!(none.get_hash(), empty.get_hash());
535 }
536
537 #[test]
538 fn menu_get_hash_reacts_to_unicode_and_nul_labels() {
539 let labels = [
540 "",
541 "a",
542 "a\u{0}b",
543 "a\u{0}",
544 "\u{0}a",
545 "e\u{301}", "\u{e9}", "\u{202e}x", "\u{1F469}\u{200D}\u{1F4BB}", "\u{10FFFF}",
550 ];
551 let hashes: Vec<u64> = labels
552 .iter()
553 .map(|l| Menu::create(item_vec(&[l])).get_hash())
554 .collect();
555 assert!(all_distinct(&hashes), "distinct labels must hash apart");
556
557 for l in labels {
558 let a = Menu::create(item_vec(&[l]));
559 let b = Menu::create(item_vec(&[l]));
560 assert_eq!(a.get_hash(), b.get_hash());
561 }
562 }
563
564 #[test]
565 fn menu_get_hash_handles_large_menus() {
566 let mut labels: Vec<String> = (0..10_000).map(|i| format!("item-{i}")).collect();
567 let big: MenuItemVec = labels
568 .iter()
569 .map(|l| item(l.as_str()))
570 .collect::<Vec<_>>()
571 .into();
572 let menu = Menu::create(big);
573 assert_eq!(menu.items.len(), 10_000);
574 assert_eq!(menu.get_hash(), menu.get_hash());
575
576 labels[9_999] = String::from("item-flipped");
578 let flipped = Menu::create(
579 labels
580 .iter()
581 .map(|l| item(l.as_str()))
582 .collect::<Vec<_>>()
583 .into(),
584 );
585 assert_ne!(menu.get_hash(), flipped.get_hash());
586 }
587
588 #[test]
589 fn menu_get_hash_survives_deeply_nested_submenus() {
590 const DEPTH: usize = 200;
594 let mut nested = StringMenuItem::create("leaf".into());
595 for i in 0..DEPTH {
596 nested = StringMenuItem::create(format!("lvl-{i}").as_str().into())
597 .with_child(MenuItem::String(nested));
598 }
599 let menu = Menu::create(MenuItemVec::from_item(MenuItem::String(nested)));
600 assert_eq!(menu.get_hash(), menu.get_hash());
601 }
602
603 #[test]
604 fn menu_get_hash_is_stable_across_clone_without_callbacks() {
605 let menu = Menu::create(item_vec(&["File", "Edit"]))
606 .with_position(MenuPopupPosition::BottomOfHitRect);
607 let cloned = menu.clone();
608 assert_eq!(menu, cloned);
609 assert_eq!(menu.get_hash(), cloned.get_hash());
610 }
611
612 #[test]
619 fn menu_get_hash_is_unstable_across_clone_with_callback_known_hazard() {
620 let menu = Menu::create(MenuItemVec::from_item(MenuItem::String(
621 StringMenuItem::create("Save".into()).with_callback(RefAny::new(1u32), 0xDEAD_usize),
622 )));
623 let cloned = menu.clone();
624 assert_ne!(menu, cloned, "clone identity leaks through RefAny::instance_id");
625 assert_ne!(menu.get_hash(), cloned.get_hash());
626 }
627
628 #[test]
631 fn string_menu_item_create_defaults() {
632 let it = StringMenuItem::create("File".into());
633 assert_eq!(it.label.as_str(), "File");
634 assert!(it.accelerator.is_none());
635 assert!(it.callback.is_none());
636 assert_eq!(it.menu_item_state, MenuItemState::Normal);
637 assert!(it.icon.is_none());
638 assert!(it.children.is_empty());
639 assert_eq!(it.children.len(), 0);
640 }
641
642 #[test]
643 fn string_menu_item_create_preserves_extreme_labels() {
644 let huge = "\u{1F600}".repeat(256 * 1024); for label in [
646 String::new(),
647 String::from("a\u{0}b"),
648 String::from("\u{202e}\u{200d}\u{feff}"),
649 "x".repeat(1024 * 1024),
650 huge,
651 ] {
652 let it = StringMenuItem::create(label.as_str().into());
653 assert_eq!(it.label.as_str(), label.as_str(), "label must survive byte-for-byte");
654 assert_eq!(it.label.as_str().len(), label.len());
655 assert!(it.children.is_empty());
656 }
657 }
658
659 #[test]
662 fn with_children_sets_replaces_and_clears() {
663 let it = StringMenuItem::create("File".into()).with_children(item_vec(&["New", "Open"]));
664 assert_eq!(it.children.len(), 2);
665 assert_eq!(labels_of(&it.children), ["New", "Open"]);
666
667 let it = it.with_children(item_vec(&["Only"]));
669 assert_eq!(it.children.len(), 1);
670 assert_eq!(labels_of(&it.children), ["Only"]);
671
672 let it = it.with_children(MenuItemVec::new());
673 assert!(it.children.is_empty());
674 assert_eq!(it.children.get(0), None);
675 }
676
677 #[test]
678 fn with_children_keeps_other_fields_and_accepts_large_input() {
679 let big: MenuItemVec = (0..5_000)
680 .map(|i| item(format!("c{i}").as_str()))
681 .collect::<Vec<_>>()
682 .into();
683 let it = StringMenuItem::create("root".into()).with_children(big);
684 assert_eq!(it.label.as_str(), "root");
685 assert_eq!(it.children.len(), 5_000);
686 assert_eq!(it.menu_item_state, MenuItemState::Normal);
687 assert!(it.callback.is_none());
688 match it.children.get(4_999) {
689 Some(MenuItem::String(s)) => assert_eq!(s.label.as_str(), "c4999"),
690 other => panic!("unexpected tail item: {other:?}"),
691 }
692 assert_eq!(it.children.get(5_000), None, "index at len must be None");
693 }
694
695 #[test]
698 fn with_child_copies_out_of_the_static_backing() {
699 let it = StringMenuItem::create("File".into()).with_child(item("New"));
702 assert_eq!(it.children.len(), 1);
703 assert_eq!(labels_of(&it.children), ["New"]);
704 assert!(it.children.capacity() >= 1);
705
706 assert!(StringMenuItem::create("Edit".into()).children.is_empty());
708 }
709
710 #[test]
711 fn with_child_appends_in_order_and_after_with_children() {
712 let it = StringMenuItem::create("File".into())
713 .with_child(item("a"))
714 .with_child(MenuItem::Separator)
715 .with_child(item("b"));
716 assert_eq!(labels_of(&it.children), ["a", "<sep>", "b"]);
717
718 let it = StringMenuItem::create("File".into())
720 .with_children(item_vec(&["x"]))
721 .with_child(item("y"));
722 assert_eq!(labels_of(&it.children), ["x", "y"]);
723 }
724
725 #[test]
726 fn with_child_does_not_alias_a_clone() {
727 let base = StringMenuItem::create("File".into()).with_child(item("shared"));
728 let extended = base.clone().with_child(item("extra"));
729 assert_eq!(base.children.len(), 1, "clone must not write back into the original");
730 assert_eq!(extended.children.len(), 2);
731 assert_eq!(labels_of(&extended.children), ["shared", "extra"]);
732 }
733
734 #[test]
735 fn with_child_repeated_many_times_keeps_contents() {
736 const N: usize = 2_000;
739 let mut it = StringMenuItem::create("root".into());
740 for i in 0..N {
741 it = it.with_child(item(format!("c{i}").as_str()));
742 }
743 assert_eq!(it.children.len(), N);
744 assert_eq!(labels_of(&it.children)[0], "c0");
745 assert_eq!(labels_of(&it.children)[N - 1], "c1999");
746 assert_eq!(it.label.as_str(), "root");
747 }
748
749 #[test]
752 fn with_callback_stores_pointer_value_and_data() {
753 for cb in [0_usize, 1, usize::MAX, usize::MAX - 1] {
754 let mut it = StringMenuItem::create("Save".into()).with_callback(RefAny::new(42u64), cb);
755 assert!(it.callback.is_some());
756 {
757 let stored = it.callback.as_ref().expect("callback set");
758 assert_eq!(stored.callback.cb, cb, "raw fn-pointer usize must survive verbatim");
759 assert!(
760 stored.callback.ctx.is_none(),
761 "native Rust callbacks carry no FFI ctx"
762 );
763 }
764 let data = it.callback.as_mut().expect("callback set");
765 let value = data.refany.downcast_ref::<u64>().expect("payload is u64");
766 assert_eq!(*value, 42u64);
767 }
768 }
769
770 #[test]
771 fn with_callback_keeps_other_fields_and_last_call_wins() {
772 let it = StringMenuItem::create("Save".into())
773 .with_children(item_vec(&["kid"]))
774 .with_callback(RefAny::new(1u8), 111_usize)
775 .with_callback(RefAny::new(2u8), 222_usize);
776
777 let stored = it.callback.as_ref().expect("callback set");
778 assert_eq!(stored.callback.cb, 222_usize);
779 assert_eq!(it.label.as_str(), "Save");
780 assert_eq!(it.children.len(), 1, "callback must not disturb children");
781 assert_eq!(it.menu_item_state, MenuItemState::Normal);
782 }
783
784 #[test]
785 fn with_callback_changes_menu_hash() {
786 let plain = Menu::create(MenuItemVec::from_item(MenuItem::String(
787 StringMenuItem::create("Save".into()),
788 )));
789 let with_cb = Menu::create(MenuItemVec::from_item(MenuItem::String(
790 StringMenuItem::create("Save".into()).with_callback(RefAny::new(1u32), 7_usize),
791 )));
792 assert_ne!(plain.get_hash(), with_cb.get_hash());
793 }
794}