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

            
13
extern crate alloc;
14

            
15
use alloc::vec::Vec;
16
use core::hash::Hash;
17

            
18
use azul_css::AzString;
19

            
20
use 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)]
40
pub struct Menu {
41
    pub items: MenuItemVec,
42
    pub position: MenuPopupPosition,
43
    pub context_mouse_btn: ContextMenuMouseButton,
44
}
45

            
46
impl_option!(
47
    Menu,
48
    OptionMenu,
49
    copy = false,
50
    [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
51
);
52

            
53
impl 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
14383
    pub const fn create(items: MenuItemVec) -> Self {
59
14383
        Self {
60
14383
            items,
61
14383
            position: MenuPopupPosition::AutoCursor,
62
14383
            context_mouse_btn: ContextMenuMouseButton::Right,
63
14383
        }
64
14383
    }
65

            
66
    /// Builder method to set the popup position.
67
    #[must_use]
68
26
    pub const fn with_position(mut self, position: MenuPopupPosition) -> Self {
69
26
        self.position = position;
70
26
        self
71
26
    }
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
81
    pub fn get_hash(&self) -> u64 {
78
        use core::hash::Hasher;
79
81
        let mut hasher = crate::hash::DefaultHasher::new();
80
81
        self.hash(&mut hasher);
81
81
        hasher.finish()
82
81
    }
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)]
91
pub 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

            
114
impl Default for MenuPopupPosition {
115
3
    fn default() -> Self {
116
3
        Self::AutoCursor
117
3
    }
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)]
129
pub 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
145
pub 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

            
154
impl_option!(
155
    MenuItem,
156
    OptionMenuItem,
157
    copy = false,
158
    [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
159
);
160

            
161
impl_vec!(MenuItem, MenuItemVec, MenuItemVecDestructor, MenuItemVecDestructorType, MenuItemVecSlice, OptionMenuItem);
162
impl_vec_clone!(MenuItem, MenuItemVec, MenuItemVecDestructor);
163
impl_vec_debug!(MenuItem, MenuItemVec);
164
impl_vec_partialeq!(MenuItem, MenuItemVec);
165
impl_vec_partialord!(MenuItem, MenuItemVec);
166
impl_vec_hash!(MenuItem, MenuItemVec);
167
impl_vec_eq!(MenuItem, MenuItemVec);
168
impl_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)]
182
pub 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

            
199
impl StringMenuItem {
200
    /// Creates a new menu item with the given label.
201
    /// All optional fields default to `None` / `Normal`.
202
    #[must_use]
203
47281
    pub const fn create(label: AzString) -> Self {
204
47281
        Self {
205
47281
            label,
206
47281
            accelerator: OptionVirtualKeyCodeCombo::None,
207
47281
            callback: OptionCoreMenuCallback::None,
208
47281
            menu_item_state: MenuItemState::Normal,
209
47281
            icon: OptionMenuItemIcon::None,
210
47281
            children: MenuItemVec::from_const_slice(&[]),
211
47281
        }
212
47281
    }
213

            
214
    /// Sets the child menu items for this item, creating a sub-menu.
215
    #[must_use]
216
237
    pub fn with_children(mut self, children: MenuItemVec) -> Self {
217
237
        self.children = children;
218
237
        self
219
237
    }
220

            
221
    /// Adds a single child menu item to this item.
222
    #[must_use]
223
2967
    pub fn with_child(mut self, child: MenuItem) -> Self {
224
2967
        let mut children = self.children.into_library_owned_vec();
225
2967
        children.push(child);
226
2967
        self.children = children.into();
227
2967
        self
228
2967
    }
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
56
    pub fn with_callback<I: Into<CoreCallback>>(mut self, data: RefAny, callback: I) -> Self {
243
56
        self.callback = Some(CoreMenuCallback {
244
56
            refany: data,
245
56
            callback: callback.into(),
246
56
        })
247
56
        .into();
248
56
        self
249
56
    }
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)]
259
pub 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

            
266
impl_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)]
289
pub 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

            
296
impl_option!(
297
    CoreMenuCallback,
298
    OptionCoreMenuCallback,
299
    copy = false,
300
    [Debug, Clone, PartialEq, PartialOrd, Hash, Eq, Ord]
301
);
302

            
303
#[cfg(test)]
304
mod 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
}