Skip to main content

azul_core/
menu.rs

1//! Menu system for context menus, dropdown menus, and application menus.
2//!
3//! This module provides a cross-platform menu abstraction modeled after the Windows API,
4//! supporting hierarchical menus with separators, icons, keyboard accelerators, and callbacks.
5//!
6//! # Core vs Layout Types
7//!
8//! This module uses `CoreMenuCallback` with `usize` placeholders instead of function pointers
9//! to avoid circular dependencies between `azul-core` and `azul-layout`. The actual function
10//! pointers are stored in `azul-layout` and converted via unsafe code with identical memory
11//! layout.
12
13extern 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/// Represents a menu (context menu, dropdown menu, or application menu).
28///
29/// A menu consists of a list of items that can be displayed as a popup or
30/// attached to a window's menu bar. Modeled after the Windows API for
31/// cross-platform consistency.
32///
33/// # Fields
34///
35/// * `items` - The menu items to display
36/// * `position` - Where the menu should appear (for popups)
37/// * `context_mouse_btn` - Which mouse button triggers the context menu
38#[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    /// Creates a new menu with the given items.
55    ///
56    /// Uses default position (`AutoCursor`) and right mouse button for context menus.
57    #[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    /// Builder method to set the popup position.
67    #[must_use]
68    pub const fn with_position(mut self, position: MenuPopupPosition) -> Self {
69        self.position = position;
70        self
71    }
72
73    /// Computes a 64-bit hash of this menu using the `HighwayHash` algorithm.
74    ///
75    /// This is used to detect changes in menu structure for caching and optimization.
76    #[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/// Specifies where a popup menu should appear relative to the cursor or clicked element.
86///
87/// This positioning information is ignored for application-level menus (menu bars)
88/// and only applies to context menus and dropdowns.
89#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
90#[repr(C)]
91pub enum MenuPopupPosition {
92    /// Position menu below and to the left of the cursor
93    BottomLeftOfCursor,
94    /// Position menu below and to the right of the cursor
95    BottomRightOfCursor,
96    /// Position menu above and to the left of the cursor
97    TopLeftOfCursor,
98    /// Position menu above and to the right of the cursor
99    TopRightOfCursor,
100    /// Position menu below the rectangle that was clicked
101    BottomOfHitRect,
102    /// Position menu to the left of the rectangle that was clicked
103    LeftOfHitRect,
104    /// Position menu above the rectangle that was clicked
105    TopOfHitRect,
106    /// Position menu to the right of the rectangle that was clicked
107    RightOfHitRect,
108    /// Automatically calculate position based on available screen space near cursor
109    AutoCursor,
110    /// Automatically calculate position based on available screen space near clicked rect
111    AutoHitRect,
112}
113
114impl Default for MenuPopupPosition {
115    fn default() -> Self {
116        Self::AutoCursor
117    }
118}
119
120/// Describes the interactive state of a menu item.
121///
122/// Menu items can be in different states that affect their appearance and behavior:
123///
124/// - Normal items are clickable and render normally
125/// - Greyed items are visually disabled (greyed out) and non-clickable
126/// - Disabled items are non-clickable but retain normal appearance
127#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
128#[repr(C)]
129pub enum MenuItemState {
130    /// Normal menu item (default)
131    Normal,
132    /// Menu item is greyed out and clicking it does nothing
133    Greyed,
134    /// Menu item is disabled, but NOT greyed out
135    Disabled,
136}
137#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
138/// Represents a single item in a menu.
139///
140/// Menu items can be regular text items with labels and callbacks,
141/// visual separators, or line breaks for horizontal menu layouts.
142#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
143#[repr(C, u8)]
144#[allow(clippy::large_enum_variant)] // #[repr(C,u8)] FFI enum: boxing a variant changes the C ABI/api.json
145pub enum MenuItem {
146    /// A regular menu item with a label, optional icon, callback, and sub-items
147    String(StringMenuItem),
148    /// A visual separator line (only rendered in vertical layouts)
149    Separator,
150    /// Forces a line break when the menu is laid out horizontally
151    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/// A menu item with a text label and optional features.
171///
172/// `StringMenuItem` represents a clickable menu entry that can have:
173///
174/// - A text label
175/// - An optional keyboard accelerator (e.g., Ctrl+C)
176/// - An optional callback function
177/// - An optional icon (checkbox or image)
178/// - A state (normal, greyed, or disabled)
179/// - Child menu items (for sub-menus)
180#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
181#[repr(C)]
182pub struct StringMenuItem {
183    /// Label of the menu
184    /// (ex. "File", "Edit", "View")
185    pub label: AzString,
186    /// Optional accelerator combination
187    /// (ex. "CTRL + X" = [`VirtualKeyCode::Ctrl`, `VirtualKeyCode::X`]) for keyboard shortcut
188    pub accelerator: OptionVirtualKeyCodeCombo,
189    /// Optional callback to call
190    pub callback: OptionCoreMenuCallback,
191    /// State (normal, greyed, disabled)
192    pub menu_item_state: MenuItemState,
193    /// Optional icon for the menu entry
194    pub icon: OptionMenuItemIcon,
195    /// Sub-menus of this item (separators and line-breaks can't have sub-menus)
196    pub children: MenuItemVec,
197}
198
199impl StringMenuItem {
200    /// Creates a new menu item with the given label.
201    /// All optional fields default to `None` / `Normal`.
202    #[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    /// Sets the child menu items for this item, creating a sub-menu.
215    #[must_use]
216    pub fn with_children(mut self, children: MenuItemVec) -> Self {
217        self.children = children;
218        self
219    }
220
221    /// Adds a single child menu item to this item.
222    #[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    /// Attaches a callback function to this menu item.
231    ///
232    /// # Parameters
233    ///
234    /// * `data` - User data passed to the callback
235    /// * `callback` - Function pointer (as usize) to invoke when item is clicked
236    ///
237    /// # Note
238    ///
239    /// This uses `CoreCallbackType` (usize) instead of a real function pointer
240    /// to avoid circular dependencies. The conversion happens in azul-layout.
241    #[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/// Optional icon displayed next to a menu item.
253///
254/// Icons can be either:
255/// - A checkbox (checked or unchecked)
256/// - A custom image (typically 16x16 pixels)
257#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
258#[repr(C, u8)]
259pub enum MenuItemIcon {
260    /// Displays a checkbox, with `true` = checked, `false` = unchecked
261    Checkbox(bool),
262    /// Displays a custom image (typically 16x16 format)
263    Image(ImageRef),
264}
265
266impl_option!(
267    MenuItemIcon,
268    OptionMenuItemIcon,
269    copy = false,
270    [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
271);
272
273// Core menu callback types (usize-based placeholders)
274//
275// Similar to CoreCallback, these use usize instead of function pointers
276// to avoid circular dependencies. Will be converted to real function
277// pointers in azul-layout.
278//
279// IMPORTANT: Memory layout must be identical to the real callback types!
280// Tests for this are in azul-layout/src/callbacks.rs
281
282/// Menu callback using usize placeholder for function pointer.
283///
284/// This type is used in `azul-core` to represent menu item callbacks without
285/// creating circular dependencies with `azul-layout`. The actual function pointer
286/// is stored as a `usize` and converted via unsafe code in `azul-layout`.
287#[derive(Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord)]
288#[repr(C)]
289pub struct CoreMenuCallback {
290    /// User data passed to the callback when the menu item is clicked
291    pub refany: RefAny,
292    /// Callback function pointer stored as usize (converted to real fn pointer in azul-layout)
293    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    // ---- Menu::create ---------------------------------------------------
353
354    #[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        // `create` documents AutoCursor + Right, which must agree with the derived Default.
388        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        // The &'static (NoDestructor) backing must decode to an owned, equal Vec as well.
402        assert!(MenuItemVec::from_const_slice(&[])
403            .into_library_owned_vec()
404            .is_empty());
405    }
406
407    // ---- Menu::with_position --------------------------------------------
408
409    #[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    // ---- Menu::get_hash --------------------------------------------------
430
431    #[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        // Eq/Hash contract: the two empties compare equal, so they must hash equal.
452        // A hash that mixed in `ptr`/`cap`/destructor would break here.
453        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        // [a[b]] and [a, b] contain the same labels; a length-prefixed hash must separate them.
484        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        // `None` vs `Some(<empty combo>)` are different states and must not collide.
524        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}",  // combining acute
546            "\u{e9}",    // precomposed é — different bytes, must hash apart
547            "\u{202e}x", // RTL override
548            "\u{1F469}\u{200D}\u{1F4BB}", // ZWJ emoji sequence
549            "\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        // A single flipped label anywhere in a 10k menu must change the hash.
577        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        // Hash + drop glue both recurse once per level. Depth is bounded deliberately:
591        // an unbounded depth would abort the whole test binary on stack exhaustion
592        // rather than fail one test.
593        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    /// FIXED (was: "KNOWN HAZARD — pinned, not weakened"). `RefAny::clone()` still mints a
613    /// fresh `instance_id`, but `instance_id` is no longer part of `RefAny`'s `Hash`/`Eq` —
614    /// those now key on `sharing_info` alone (see the comment on `RefAny` in
615    /// `core/src/refany.rs`). A menu carrying a callback is therefore equal to its own
616    /// clone, and `get_hash()` — whose documented job is change detection for caching — no
617    /// longer reports "changed" for a structurally identical menu.
618    ///
619    /// The old test asserted the broken behaviour on purpose, "so that fixing `RefAny`
620    /// flips it loudly instead of silently". It did exactly that; this is the flip.
621    #[test]
622    fn menu_get_hash_is_stable_across_clone_with_callback() {
623        let menu = Menu::create(MenuItemVec::from_item(MenuItem::String(
624            StringMenuItem::create("Save".into()).with_callback(RefAny::new(1u32), 0xDEAD_usize),
625        )));
626        let cloned = menu.clone();
627        assert_eq!(menu, cloned, "a clone shares the same RefAny data, so it must compare equal");
628        assert_eq!(menu.get_hash(), cloned.get_hash());
629    }
630
631    // ---- StringMenuItem::create ------------------------------------------
632
633    #[test]
634    fn string_menu_item_create_defaults() {
635        let it = StringMenuItem::create("File".into());
636        assert_eq!(it.label.as_str(), "File");
637        assert!(it.accelerator.is_none());
638        assert!(it.callback.is_none());
639        assert_eq!(it.menu_item_state, MenuItemState::Normal);
640        assert!(it.icon.is_none());
641        assert!(it.children.is_empty());
642        assert_eq!(it.children.len(), 0);
643    }
644
645    #[test]
646    fn string_menu_item_create_preserves_extreme_labels() {
647        let huge = "\u{1F600}".repeat(256 * 1024); // ~1 MiB of 4-byte chars
648        for label in [
649            String::new(),
650            String::from("a\u{0}b"),
651            String::from("\u{202e}\u{200d}\u{feff}"),
652            "x".repeat(1024 * 1024),
653            huge,
654        ] {
655            let it = StringMenuItem::create(label.as_str().into());
656            assert_eq!(it.label.as_str(), label.as_str(), "label must survive byte-for-byte");
657            assert_eq!(it.label.as_str().len(), label.len());
658            assert!(it.children.is_empty());
659        }
660    }
661
662    // ---- StringMenuItem::with_children -----------------------------------
663
664    #[test]
665    fn with_children_sets_replaces_and_clears() {
666        let it = StringMenuItem::create("File".into()).with_children(item_vec(&["New", "Open"]));
667        assert_eq!(it.children.len(), 2);
668        assert_eq!(labels_of(&it.children), ["New", "Open"]);
669
670        // Last call wins — it is a setter, not an appender.
671        let it = it.with_children(item_vec(&["Only"]));
672        assert_eq!(it.children.len(), 1);
673        assert_eq!(labels_of(&it.children), ["Only"]);
674
675        let it = it.with_children(MenuItemVec::new());
676        assert!(it.children.is_empty());
677        assert_eq!(it.children.get(0), None);
678    }
679
680    #[test]
681    fn with_children_keeps_other_fields_and_accepts_large_input() {
682        let big: MenuItemVec = (0..5_000)
683            .map(|i| item(format!("c{i}").as_str()))
684            .collect::<Vec<_>>()
685            .into();
686        let it = StringMenuItem::create("root".into()).with_children(big);
687        assert_eq!(it.label.as_str(), "root");
688        assert_eq!(it.children.len(), 5_000);
689        assert_eq!(it.menu_item_state, MenuItemState::Normal);
690        assert!(it.callback.is_none());
691        match it.children.get(4_999) {
692            Some(MenuItem::String(s)) => assert_eq!(s.label.as_str(), "c4999"),
693            other => panic!("unexpected tail item: {other:?}"),
694        }
695        assert_eq!(it.children.get(5_000), None, "index at len must be None");
696    }
697
698    // ---- StringMenuItem::with_child --------------------------------------
699
700    #[test]
701    fn with_child_copies_out_of_the_static_backing() {
702        // `create` seeds `children` with a NoDestructor (&'static) vec; the first push must
703        // copy out of it rather than write through the static pointer.
704        let it = StringMenuItem::create("File".into()).with_child(item("New"));
705        assert_eq!(it.children.len(), 1);
706        assert_eq!(labels_of(&it.children), ["New"]);
707        assert!(it.children.capacity() >= 1);
708
709        // A freshly created sibling must still see an empty child list.
710        assert!(StringMenuItem::create("Edit".into()).children.is_empty());
711    }
712
713    #[test]
714    fn with_child_appends_in_order_and_after_with_children() {
715        let it = StringMenuItem::create("File".into())
716            .with_child(item("a"))
717            .with_child(MenuItem::Separator)
718            .with_child(item("b"));
719        assert_eq!(labels_of(&it.children), ["a", "<sep>", "b"]);
720
721        // with_child appends onto whatever with_children installed.
722        let it = StringMenuItem::create("File".into())
723            .with_children(item_vec(&["x"]))
724            .with_child(item("y"));
725        assert_eq!(labels_of(&it.children), ["x", "y"]);
726    }
727
728    #[test]
729    fn with_child_does_not_alias_a_clone() {
730        let base = StringMenuItem::create("File".into()).with_child(item("shared"));
731        let extended = base.clone().with_child(item("extra"));
732        assert_eq!(base.children.len(), 1, "clone must not write back into the original");
733        assert_eq!(extended.children.len(), 2);
734        assert_eq!(labels_of(&extended.children), ["shared", "extra"]);
735    }
736
737    #[test]
738    fn with_child_repeated_many_times_keeps_contents() {
739        // Every call round-trips the vec through into_library_owned_vec(); a mishandled
740        // destructor tag here would surface as corruption/UAF (also exercised under Miri).
741        const N: usize = 2_000;
742        let mut it = StringMenuItem::create("root".into());
743        for i in 0..N {
744            it = it.with_child(item(format!("c{i}").as_str()));
745        }
746        assert_eq!(it.children.len(), N);
747        assert_eq!(labels_of(&it.children)[0], "c0");
748        assert_eq!(labels_of(&it.children)[N - 1], "c1999");
749        assert_eq!(it.label.as_str(), "root");
750    }
751
752    // ---- StringMenuItem::with_callback -----------------------------------
753
754    #[test]
755    fn with_callback_stores_pointer_value_and_data() {
756        for cb in [0_usize, 1, usize::MAX, usize::MAX - 1] {
757            let mut it = StringMenuItem::create("Save".into()).with_callback(RefAny::new(42u64), cb);
758            assert!(it.callback.is_some());
759            {
760                let stored = it.callback.as_ref().expect("callback set");
761                assert_eq!(stored.callback.cb, cb, "raw fn-pointer usize must survive verbatim");
762                assert!(
763                    stored.callback.ctx.is_none(),
764                    "native Rust callbacks carry no FFI ctx"
765                );
766            }
767            let data = it.callback.as_mut().expect("callback set");
768            let value = data.refany.downcast_ref::<u64>().expect("payload is u64");
769            assert_eq!(*value, 42u64);
770        }
771    }
772
773    #[test]
774    fn with_callback_keeps_other_fields_and_last_call_wins() {
775        let it = StringMenuItem::create("Save".into())
776            .with_children(item_vec(&["kid"]))
777            .with_callback(RefAny::new(1u8), 111_usize)
778            .with_callback(RefAny::new(2u8), 222_usize);
779
780        let stored = it.callback.as_ref().expect("callback set");
781        assert_eq!(stored.callback.cb, 222_usize);
782        assert_eq!(it.label.as_str(), "Save");
783        assert_eq!(it.children.len(), 1, "callback must not disturb children");
784        assert_eq!(it.menu_item_state, MenuItemState::Normal);
785    }
786
787    #[test]
788    fn with_callback_changes_menu_hash() {
789        let plain = Menu::create(MenuItemVec::from_item(MenuItem::String(
790            StringMenuItem::create("Save".into()),
791        )));
792        let with_cb = Menu::create(MenuItemVec::from_item(MenuItem::String(
793            StringMenuItem::create("Save".into()).with_callback(RefAny::new(1u32), 7_usize),
794        )));
795        assert_ne!(plain.get_hash(), with_cb.get_hash());
796    }
797}