1
//! Software menu-bar widget — the Linux fallback when there is no native global
2
//! menu (GNOME/KDE export their own; Windows uses `HMENU`, macOS the app menu).
3
//!
4
//! Renders the window's [`Menu`] (declared by the user via
5
//! `Dom::with_menu_bar(menu)`) as a horizontal bar of top-level items. Clicking a
6
//! top-level item opens its children as a dropdown popup positioned directly
7
//! below the item via [`CallbackInfo::open_menu_for_hit_node`] — which looks up
8
//! the clicked node's on-screen rect and opens a child window at its bottom-left
9
//! (the unified [`WindowPosition::RelativeToParentWindow`] path).
10
//!
11
//! ## Styling
12
//!
13
//! All styling is inline via `with_css(&str)` using the `system:` color namespace
14
//! (`system:window-background`, `system:text`, `system:selection-background`, …)
15
//! and the `system:ui` font, so the bar matches the OS theme without threading a
16
//! `SystemStyle` through. The bar MUST be injected at the *Dom* level (before
17
//! `StyledDom::create_from_dom`) so `scope_inline_css` scopes these rules in the
18
//! same flatten pass as the rest of the window — see
19
//! `shell2::common::layout::regenerate_layout`.
20
//!
21
//! ## Backreference pattern
22
//!
23
//! The dropdown's items are the user's own [`MenuItem`]s, each carrying the
24
//! `(RefAny, Callback)` backreference the user attached with
25
//! `StringMenuItem::with_callback(data, cb)`. Clicking a leaf fires that callback
26
//! with the user's data — so the bar is a thin shell and behaviour stays in user
27
//! code (see `doc/guide/en/architecture.md`, "the backreference pattern").
28
//!
29
//! [`WindowPosition::RelativeToParentWindow`]: azul_core::window::WindowPosition::RelativeToParentWindow
30

            
31
use azul_core::{
32
    callbacks::{CoreCallback, CoreCallbackData},
33
    dom::{Dom, EventFilter, HoverEventFilter, IdOrClass::Class, IdOrClassVec},
34
    menu::{Menu, MenuItem, MenuItemVec, StringMenuItem},
35
    refany::{OptionRefAny, RefAny},
36
};
37

            
38
/// Class on the injected bar root — also the detection marker the renderer / tests
39
/// use to recognise an injected software menu bar.
40
pub const MENUBAR_CLASS: &str = "__azul-native-menubar";
41
/// Class on each top-level bar item.
42
pub const MENUBAR_ITEM_CLASS: &str = "azul-menubar-item";
43

            
44
/// Inline CSS for the bar root: a full-width horizontal flex row themed from the
45
/// OS (`system:` colors + `system:ui` font). Bare declarations, so the rule is
46
/// scoped node-only at flatten time.
47
const MENUBAR_CSS: &str = "display: flex; \
48
     flex-direction: row; \
49
     align-items: stretch; \
50
     width: 100%; \
51
     height: 26px; \
52
     background: system:window-background; \
53
     color: system:text; \
54
     font-family: system:ui; \
55
     font-size: 14px; \
56
     padding-left: 2px;";
57

            
58
/// Inline CSS for a top-level item: vertically-centered click target with hover
59
/// feedback (the `:hover` block nests via CSS nesting in `parse_inline`).
60
const MENUBAR_ITEM_CSS: &str = "display: flex; \
61
     flex-direction: row; \
62
     align-items: center; \
63
     padding-left: 10px; \
64
     padding-right: 10px; \
65
     color: system:text; \
66
     cursor: pointer; \
67
     :hover { background: system:selection-background; color: system:selection-text; }";
68

            
69
/// Build the software menu-bar DOM from a [`Menu`].
70
///
71
/// The bar is a flex row of one item per top-level `MenuItem::String`
72
/// (separators / break-lines are not rendered in the bar). Inject the returned
73
/// `Dom` at the Dom level so its `with_css` rules are scoped in the main flatten.
74
72
#[must_use] pub fn build_menubar_dom(menu: &Menu) -> Dom {
75
72
    let mut bar = Dom::create_div()
76
72
        .with_ids_and_classes(IdOrClassVec::from_vec(vec![
77
72
            Class(MENUBAR_CLASS.into()),
78
72
            Class("azul-menubar".into()),
79
        ]))
80
72
        .with_css(MENUBAR_CSS);
81

            
82
1526
    for item in menu.items.as_slice() {
83
1526
        if let MenuItem::String(s) = item {
84
1513
            bar = bar.with_child(build_menubar_item(s));
85
1513
        }
86
    }
87

            
88
72
    bar
89
72
}
90

            
91
/// One clickable top-level bar item. Its `MouseUp` callback opens the item's
92
/// submenu (its children, or — for a top-level leaf — a one-item menu of itself
93
/// so the leaf's own callback still fires) below the item.
94
1525
fn build_menubar_item(item: &StringMenuItem) -> Dom {
95
    // The submenu carried (by value) into the click callback as its RefAny.
96
1525
    let submenu = if item.children.as_slice().is_empty() {
97
1514
        Menu::create(MenuItemVec::from_vec(vec![MenuItem::String(item.clone())]))
98
    } else {
99
11
        Menu::create(item.children.clone())
100
    };
101

            
102
1525
    Dom::create_div()
103
1525
        .with_ids_and_classes(IdOrClassVec::from_vec(vec![Class(MENUBAR_ITEM_CLASS.into())]))
104
1525
        .with_css(MENUBAR_ITEM_CSS)
105
1525
        .with_child(Dom::create_p_with_text(item.label.clone()))
106
1525
        .with_callbacks(
107
1525
            vec![CoreCallbackData {
108
1525
                event: EventFilter::Hover(HoverEventFilter::MouseUp),
109
1525
                callback: CoreCallback {
110
1525
                    cb: callbacks::menubar_item_click as usize,
111
1525
                    ctx: OptionRefAny::None,
112
1525
                },
113
1525
                refany: RefAny::new(submenu),
114
1525
            }]
115
1525
            .into(),
116
        )
117
1525
}
118

            
119
pub(crate) mod callbacks {
120
    use azul_core::{callbacks::Update, menu::Menu, refany::RefAny};
121

            
122
    use crate::callbacks::CallbackInfo;
123

            
124
    /// Top-level bar item clicked → open its submenu under the item. The submenu
125
    /// `Menu` is the callback's `data` (a backreference set at build time); its
126
    /// items carry the user's own callbacks, fired by the menu system on click.
127
27
    pub(super) extern "C" fn menubar_item_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
128
27
        if let Some(menu) = data.downcast_ref::<Menu>() {
129
24
            info.open_menu_for_hit_node(menu.clone());
130
24
        }
131
27
        Update::DoNothing
132
27
    }
133
}
134

            
135
#[cfg(test)]
136
mod autotest_generated {
137
    use std::{
138
        collections::{BTreeMap, HashMap},
139
        sync::{Arc, Mutex},
140
    };
141

            
142
    use azul_core::{
143
        callbacks::Update,
144
        dom::{DomId, DomNodeId, IdOrClass, NodeId, NodeType},
145
        geom::{LogicalPosition, LogicalRect, LogicalSize, OptionLogicalPosition},
146
        gl::OptionGlContextPtr,
147
        hit_test::ScrollPosition,
148
        menu::{CoreMenuCallback, MenuItemIcon, MenuItemState},
149
        resources::RendererResources,
150
        styled_dom::{NodeHierarchyItemId, StyledDom},
151
        window::{
152
            MonitorVec, RawWindowHandle, VirtualKeyCode, VirtualKeyCodeCombo, VirtualKeyCodeVec,
153
        },
154
    };
155
    use azul_css::AzString;
156
    use rust_fontconfig::FcFontCache;
157

            
158
    use super::callbacks::menubar_item_click;
159
    use super::*;
160
    #[cfg(feature = "icu")]
161
    use crate::icu::IcuLocalizerHandle;
162
    use crate::{
163
        callbacks::{CallbackChange, CallbackInfo, CallbackInfoRefData, ExternalSystemCallbacks},
164
        solver3::{
165
            display_list::{DisplayList, DisplayListItem, WindowLogicalRect},
166
            layout_tree::LayoutTree,
167
        },
168
        window::{DomLayoutResult, LayoutWindow},
169
        window_state::FullWindowState,
170
    };
171

            
172
    // ------------------------------------------------------------------
173
    // Menu fixtures
174
    // ------------------------------------------------------------------
175

            
176
    fn leaf(label: &str) -> StringMenuItem {
177
        StringMenuItem::create(AzString::from(label))
178
    }
179

            
180
    fn string_item(label: &str) -> MenuItem {
181
        MenuItem::String(leaf(label))
182
    }
183

            
184
    fn menu_of(items: Vec<MenuItem>) -> Menu {
185
        Menu::create(MenuItemVec::from_vec(items))
186
    }
187

            
188
    /// A leaf whose every optional field is populated — so "the leaf is wrapped
189
    /// verbatim" is a claim about *all* of `StringMenuItem`, not just its label.
190
    fn decorated_leaf(label: &str) -> StringMenuItem {
191
        let mut it = leaf(label);
192
        it.accelerator = Some(VirtualKeyCodeCombo {
193
            keys: VirtualKeyCodeVec::from_vec(vec![VirtualKeyCode::Q]),
194
        })
195
        .into();
196
        it.callback = Some(CoreMenuCallback {
197
            refany: RefAny::new(0xDEAD_BEEF_u64),
198
            callback: CoreCallback {
199
                cb: 0x1234_usize,
200
                ctx: OptionRefAny::None,
201
            },
202
        })
203
        .into();
204
        it.menu_item_state = MenuItemState::Greyed;
205
        it.icon = Some(MenuItemIcon::Checkbox(true)).into();
206
        it
207
    }
208

            
209
    /// `depth` levels of single-child nesting under one top-level item.
210
    /// Kept modest on purpose: the builders clone/drop recursively, and a test
211
    /// that blows the stack aborts the whole harness instead of failing.
212
    fn nested(depth: usize) -> StringMenuItem {
213
        let mut cur = leaf("bottom");
214
        for i in 0..depth {
215
            cur = leaf(&format!("lvl{i}")).with_child(MenuItem::String(cur));
216
        }
217
        cur
218
    }
219

            
220
    /// Labels/markers of a menu's items, in menu order.
221
    fn labels_of(menu: &Menu) -> Vec<String> {
222
        menu.items
223
            .as_slice()
224
            .iter()
225
            .map(|i| match i {
226
                MenuItem::String(s) => s.label.as_str().to_string(),
227
                MenuItem::Separator => "<sep>".to_string(),
228
                MenuItem::BreakLine => "<br>".to_string(),
229
            })
230
            .collect()
231
    }
232

            
233
    // ------------------------------------------------------------------
234
    // Dom inspection
235
    // ------------------------------------------------------------------
236

            
237
    fn text_of(node: &Dom) -> Option<&str> {
238
        match node.root.get_node_type() {
239
            NodeType::Text(t) => Some(t.as_ref().as_str()),
240
            _ => None,
241
        }
242
    }
243

            
244
    fn classes_of(node: &Dom) -> Vec<String> {
245
        node.root
246
            .get_ids_and_classes()
247
            .as_ref()
248
            .iter()
249
            .filter_map(|c| match c {
250
                Class(s) => Some(s.as_str().to_string()),
251
                IdOrClass::Id(_) => None,
252
            })
253
            .collect()
254
    }
255

            
256
    /// The label rendered by a bar item: a `<p>` block wrapping the text.
257
    ///
258
    /// The bar item used to hold a BARE text node. A text leaf owns no box, so
259
    /// in a flex/grid parent it competes as an item with no line box of its own
260
    /// — browsers wrap such runs in an anonymous block, azul does not. Every
261
    /// widget label is a `<p>` now, so this walks one level deeper.
262
    fn rendered_label(item_dom: &Dom) -> &str {
263
        let children = item_dom.children.as_ref();
264
        assert_eq!(children.len(), 1, "a bar item renders exactly one label block");
265
        let label = &children[0];
266
        assert!(
267
            matches!(label.root.get_node_type(), NodeType::P),
268
            "a bar item's label must be wrapped in a <p>, not a bare text node"
269
        );
270
        let inner = label.children.as_ref();
271
        assert_eq!(inner.len(), 1, "a label <p> wraps exactly one text node");
272
        text_of(&inner[0]).expect("the <p>'s only child must be a text node")
273
    }
274

            
275
    /// The true recursive descendant count — what `estimated_total_children`
276
    /// caches, and what `convert_dom_into_compact_dom` sizes its arenas from.
277
    fn recursive_descendants(node: &Dom) -> usize {
278
        node.children
279
            .as_ref()
280
            .iter()
281
            .map(|c| 1 + recursive_descendants(c))
282
            .sum()
283
    }
284

            
285
    fn assert_children_count_is_in_sync(dom: &Dom) {
286
        assert_eq!(
287
            dom.estimated_total_children,
288
            recursive_descendants(dom),
289
            "estimated_total_children must equal the real descendant count — a \
290
             too-small value makes convert_dom_into_compact_dom under-allocate",
291
        );
292
    }
293

            
294
    /// The `RefAny` a bar item carries on its click callback.
295
    fn refany_of(item_dom: &Dom) -> RefAny {
296
        let cbs = item_dom.root.callbacks.as_ref();
297
        assert_eq!(cbs.len(), 1, "a bar item registers exactly one callback");
298
        cbs[0].refany.clone()
299
    }
300

            
301
    /// Decodes a bar item's backreference back into the `Menu` it will open.
302
    fn submenu_of(item_dom: &Dom) -> Menu {
303
        let mut refany = refany_of(item_dom);
304
        let guard = refany
305
            .downcast_ref::<Menu>()
306
            .expect("a bar item's backreference must be a Menu");
307
        guard.clone()
308
    }
309

            
310
    /// DOM node ids of the bar items inside a flattened bar, in document order.
311
    fn item_node_ids(styled: &StyledDom) -> Vec<NodeId> {
312
        styled
313
            .node_data
314
            .as_ref()
315
            .iter()
316
            .enumerate()
317
            .filter(|(_, nd)| {
318
                nd.get_ids_and_classes().as_ref().iter().any(
319
                    |c| matches!(c, Class(s) if s.as_str() == MENUBAR_ITEM_CLASS),
320
                )
321
            })
322
            .map(|(i, _)| NodeId::new(i))
323
            .collect()
324
    }
325

            
326
    // ------------------------------------------------------------------
327
    // CallbackInfo harness (mirrors the one in `drop_down.rs`)
328
    // ------------------------------------------------------------------
329

            
330
    struct Env<'a> {
331
        ref_data: &'a CallbackInfoRefData<'a>,
332
        changes: &'a Arc<Mutex<Vec<CallbackChange>>>,
333
        default_hit: DomNodeId,
334
    }
335

            
336
    impl Env<'_> {
337
        fn info(&self) -> CallbackInfo {
338
            self.info_at(self.default_hit)
339
        }
340

            
341
        fn info_at(&self, hit: DomNodeId) -> CallbackInfo {
342
            CallbackInfo::new(
343
                self.ref_data,
344
                self.changes,
345
                hit,
346
                OptionLogicalPosition::None,
347
                OptionLogicalPosition::None,
348
            )
349
        }
350

            
351
        fn take_changes(&self) -> Vec<CallbackChange> {
352
            self.changes
353
                .lock()
354
                .map(|mut c| core::mem::take(&mut *c))
355
                .unwrap_or_default()
356
        }
357

            
358
        fn take_one(&self) -> CallbackChange {
359
            let mut changes = self.take_changes();
360
            assert_eq!(changes.len(), 1, "expected exactly one change: {changes:?}");
361
            changes.remove(0)
362
        }
363
    }
364

            
365
    fn dom_node(node: NodeId) -> DomNodeId {
366
        DomNodeId {
367
            dom: DomId::ROOT_ID,
368
            node: NodeHierarchyItemId::from_crate_internal(Some(node)),
369
        }
370
    }
371

            
372
    fn no_hit_node() -> DomNodeId {
373
        DomNodeId {
374
            dom: DomId::ROOT_ID,
375
            node: NodeHierarchyItemId::NONE,
376
        }
377
    }
378

            
379
    /// The tag the hit-tester would use for `node`. `open_menu_for_node` resolves
380
    /// the anchor rect through this mapping, so a forged hit-test area must reuse
381
    /// the id the styling pass actually assigned.
382
    fn tag_of(styled_dom: &StyledDom, node: NodeId) -> u64 {
383
        let nid = NodeHierarchyItemId::from_crate_internal(Some(node));
384
        styled_dom
385
            .tag_ids_to_node_ids
386
            .iter()
387
            .find(|m| m.node_id == nid)
388
            .expect("a menubar item must be hit-testable")
389
            .tag_id
390
            .inner
391
    }
392

            
393
    /// A `DomLayoutResult` carrying only a `styled_dom` plus forged hit-test
394
    /// areas. `menubar_item_click` reaches exactly one geometry query
395
    /// (`get_node_hit_test_bounds`), which reads the display list only — so no
396
    /// real layout (and no font) is needed.
397
    fn layout_result(styled_dom: StyledDom, anchors: &[(NodeId, LogicalRect)]) -> DomLayoutResult {
398
        let mut display_list = DisplayList::default();
399
        for (node, rect) in anchors {
400
            let tag = tag_of(&styled_dom, *node);
401
            display_list.items.push(DisplayListItem::HitTestArea {
402
                bounds: WindowLogicalRect::new(rect.origin, rect.size),
403
                // The tag TYPE matters: `get_node_hit_test_bounds` looks for
404
                // a DOM-node area specifically (text-run cursor areas share
405
                // the `tag.0` numbering space), so a forged area must carry
406
                // the same type the display list builder stamps.
407
                tag: (tag, azul_core::hit_test::TAG_TYPE_DOM_NODE),
408
            });
409
        }
410

            
411
        DomLayoutResult {
412
            styled_dom,
413
            layout_tree: LayoutTree {
414
                nodes: Vec::new(),
415
                warm: Vec::new(),
416
                cold: Vec::new(),
417
                root: 0,
418
                dom_to_layout: BTreeMap::new(),
419
                children_arena: Vec::new(),
420
                children_offsets: Vec::new(),
421
                subtree_needs_intrinsic: Vec::new(),
422
            },
423
            calculated_positions: Vec::new(),
424
            viewport: LogicalRect::zero(),
425
            display_list: Arc::new(display_list),
426
            scroll_ids: HashMap::new(),
427
            scroll_id_to_node_id: HashMap::new(),
428
        }
429
    }
430

            
431
    /// Empty `LayoutWindow`, no hit node — the "nothing to anchor to" case.
432
    fn with_env<R>(f: impl FnOnce(&Env<'_>) -> R) -> R {
433
        with_env_cfg(None, &[], None, f)
434
    }
435

            
436
    fn with_anchored_env<R>(
437
        styled_dom: StyledDom,
438
        anchors: &[(NodeId, LogicalRect)],
439
        hit: NodeId,
440
        f: impl FnOnce(&Env<'_>) -> R,
441
    ) -> R {
442
        with_env_cfg(Some(styled_dom), anchors, Some(hit), f)
443
    }
444

            
445
    fn with_env_cfg<R>(
446
        styled: Option<StyledDom>,
447
        anchors: &[(NodeId, LogicalRect)],
448
        hit: Option<NodeId>,
449
        f: impl FnOnce(&Env<'_>) -> R,
450
    ) -> R {
451
        let mut layout_window =
452
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
453
        if let Some(sd) = styled {
454
            layout_window
455
                .layout_results
456
                .insert(DomId::ROOT_ID, layout_result(sd, anchors));
457
        }
458

            
459
        let renderer_resources = RendererResources::default();
460
        let previous_window_state: Option<FullWindowState> = None;
461
        let current_window_state = FullWindowState::default();
462
        let gl_context = OptionGlContextPtr::None;
463
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
464
            BTreeMap::new();
465
        let window_handle = RawWindowHandle::Unsupported;
466
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
467

            
468
        let ref_data = CallbackInfoRefData {
469
            layout_window: &layout_window,
470
            renderer_resources: &renderer_resources,
471
            previous_window_state: &previous_window_state,
472
            current_window_state: &current_window_state,
473
            gl_context: &gl_context,
474
            current_scroll_manager: &scroll_states,
475
            current_window_handle: &window_handle,
476
            system_callbacks: &system_callbacks,
477
            system_style: Arc::new(azul_css::system::SystemStyle::default()),
478
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
479
            #[cfg(feature = "icu")]
480
            icu_localizer: IcuLocalizerHandle::default(),
481
            ctx: OptionRefAny::None,
482
        };
483

            
484
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
485
        let env = Env {
486
            ref_data: &ref_data,
487
            changes: &changes,
488
            default_hit: hit.map_or_else(no_hit_node, dom_node),
489
        };
490
        f(&env)
491
    }
492

            
493
    // ==================================================================
494
    // build_menubar_dom
495
    // ==================================================================
496

            
497
    #[test]
498
    fn build_menubar_dom_on_an_empty_menu_yields_a_childless_bar() {
499
        let bar = build_menubar_dom(&menu_of(Vec::new()));
500

            
501
        assert!(
502
            bar.children.as_ref().is_empty(),
503
            "an empty menu must produce no bar items — not a placeholder"
504
        );
505
        assert_eq!(bar.estimated_total_children, 0);
506
        assert_eq!(
507
            classes_of(&bar),
508
            vec![MENUBAR_CLASS.to_string(), "azul-menubar".to_string()],
509
            "the detection marker class must be present even on an empty bar"
510
        );
511
        assert!(
512
            bar.root.callbacks.as_ref().is_empty(),
513
            "the bar root itself is inert — only its items are clickable"
514
        );
515
    }
516

            
517
    #[test]
518
    fn build_menubar_dom_attaches_the_bar_css_exactly_once() {
519
        let bar = build_menubar_dom(&menu_of(vec![string_item("File")]));
520

            
521
        let css = bar.css.as_ref();
522
        assert_eq!(css.len(), 1, "with_css must push exactly one component stylesheet");
523
        assert!(
524
            !css[0].rules.as_ref().is_empty(),
525
            "MENUBAR_CSS must not silently parse to nothing"
526
        );
527

            
528
        let item_css = bar.children.as_ref()[0].css.as_ref();
529
        assert_eq!(item_css.len(), 1, "each item carries its own stylesheet");
530
        assert!(
531
            !item_css[0].rules.as_ref().is_empty(),
532
            "MENUBAR_ITEM_CSS (incl. its nested :hover block) must not parse to nothing"
533
        );
534
    }
535

            
536
    #[test]
537
    fn build_menubar_dom_renders_only_string_items() {
538
        // Separators / break-lines are documented as *not* rendered in the bar,
539
        // and — the real trap — must not shift the item↦child mapping.
540
        let m = menu_of(vec![
541
            MenuItem::Separator,
542
            string_item("File"),
543
            MenuItem::BreakLine,
544
            MenuItem::Separator,
545
            string_item("Edit"),
546
            MenuItem::BreakLine,
547
        ]);
548
        let bar = build_menubar_dom(&m);
549

            
550
        let labels: Vec<&str> = bar.children.as_ref().iter().map(rendered_label).collect();
551
        assert_eq!(labels, vec!["File", "Edit"], "order must survive the skipped items");
552

            
553
        for child in bar.children.as_ref() {
554
            assert_eq!(classes_of(child), vec![MENUBAR_ITEM_CLASS.to_string()]);
555
        }
556
    }
557

            
558
    #[test]
559
    fn build_menubar_dom_on_only_non_string_items_yields_a_childless_bar() {
560
        for items in [
561
            vec![MenuItem::Separator],
562
            vec![MenuItem::BreakLine],
563
            vec![MenuItem::Separator, MenuItem::BreakLine, MenuItem::Separator],
564
        ] {
565
            let bar = build_menubar_dom(&menu_of(items));
566
            assert!(
567
                bar.children.as_ref().is_empty(),
568
                "separators/break-lines are never rendered in the bar"
569
            );
570
            assert_children_count_is_in_sync(&bar);
571
        }
572
    }
573

            
574
    #[test]
575
    fn build_menubar_dom_preserves_pathological_labels_byte_for_byte() {
576
        let huge = "x".repeat(100_000);
577
        let cases = vec![
578
            "",
579
            " ",
580
            "\t\n\r",
581
            "a\u{0}b",                 // interior NUL
582
            "\u{0}",                   // lone NUL
583
            "👨‍👩‍👧‍👦",                      // ZWJ sequence
584
            "مرحبا",                   // RTL
585
            "e\u{301}\u{301}\u{301}",  // stacked combining marks
586
            "\u{200b}\u{feff}",        // zero-width / BOM
587
            "\u{1f600}\u{fe0f}",       // astral + variation selector
588
            "&<>\"'",                  // markup metacharacters
589
            "{ color: red }",          // CSS-looking label
590
            huge.as_str(),
591
        ];
592
        let m = menu_of(cases.iter().map(|c| string_item(c)).collect::<Vec<_>>());
593
        let bar = build_menubar_dom(&m);
594

            
595
        let labels: Vec<&str> = bar.children.as_ref().iter().map(rendered_label).collect();
596
        assert_eq!(labels, cases, "labels must round-trip into the DOM verbatim");
597
        assert_eq!(
598
            labels[cases.len() - 1].len(),
599
            100_000,
600
            "a 100k-char label must not be truncated"
601
        );
602
        assert_children_count_is_in_sync(&bar);
603
    }
604

            
605
    #[test]
606
    fn build_menubar_dom_keeps_estimated_total_children_in_sync() {
607
        for n in [0_usize, 1, 2, 17, 256] {
608
            let m = menu_of((0..n).map(|i| string_item(&format!("m{i}"))).collect());
609
            let bar = build_menubar_dom(&m);
610

            
611
            assert_eq!(bar.children.as_ref().len(), n);
612
            assert_eq!(
613
                bar.estimated_total_children,
614
                3 * n,
615
                "each item contributes itself + its label <p> + the text node"
616
            );
617
            assert_children_count_is_in_sync(&bar);
618
        }
619
    }
620

            
621
    #[test]
622
    fn build_menubar_dom_scales_to_a_wide_menu() {
623
        const N: usize = 1_000;
624
        let m = menu_of((0..N).map(|i| string_item(&format!("item{i}"))).collect());
625
        let bar = build_menubar_dom(&m);
626

            
627
        assert_eq!(bar.children.as_ref().len(), N);
628
        for (i, child) in bar.children.as_ref().iter().enumerate() {
629
            assert_eq!(rendered_label(child), format!("item{i}"), "item {i} is misplaced");
630
        }
631
        assert_children_count_is_in_sync(&bar);
632
    }
633

            
634
    #[test]
635
    fn build_menubar_dom_round_trips_every_item_into_its_own_submenu() {
636
        // Duplicate labels + interleaved separators: if the builder ever indexed
637
        // `menu.items` instead of iterating the *rendered* items, this mapping
638
        // would slip by one.
639
        let with_kids = leaf("File").with_children(MenuItemVec::from_vec(vec![
640
            string_item("New"),
641
            MenuItem::Separator,
642
            string_item("Open"),
643
        ]));
644
        let dup_a = leaf("Same").with_child(string_item("a"));
645
        let dup_b = leaf("Same").with_child(string_item("b"));
646
        let bare = leaf("Help");
647

            
648
        let m = menu_of(vec![
649
            MenuItem::Separator,
650
            MenuItem::String(with_kids.clone()),
651
            MenuItem::String(dup_a.clone()),
652
            MenuItem::BreakLine,
653
            MenuItem::String(dup_b.clone()),
654
            MenuItem::String(bare.clone()),
655
        ]);
656
        let bar = build_menubar_dom(&m);
657
        let children = bar.children.as_ref();
658
        assert_eq!(children.len(), 4);
659

            
660
        assert_eq!(labels_of(&submenu_of(&children[0])), vec!["New", "<sep>", "Open"]);
661
        assert_eq!(labels_of(&submenu_of(&children[1])), vec!["a"]);
662
        assert_eq!(labels_of(&submenu_of(&children[2])), vec!["b"]);
663
        // A top-level leaf opens a one-item menu of *itself* so its own callback
664
        // still fires.
665
        assert_eq!(
666
            submenu_of(&children[3]),
667
            Menu::create(MenuItemVec::from_vec(vec![MenuItem::String(bare)])),
668
        );
669
    }
670

            
671
    #[test]
672
    fn build_menubar_dom_gives_identical_items_distinct_backreferences() {
673
        let m = menu_of(vec![string_item("Same"), string_item("Same")]);
674
        let bar = build_menubar_dom(&m);
675

            
676
        let a = refany_of(&bar.children.as_ref()[0]);
677
        let b = refany_of(&bar.children.as_ref()[1]);
678
        assert!(
679
            a != b,
680
            "two bar items must not share one RefAny — dropping one would then \
681
             invalidate the other's submenu"
682
        );
683
        assert_eq!(a.get_type_id(), b.get_type_id(), "…while still both being Menus");
684
    }
685

            
686
    #[test]
687
    fn build_menubar_dom_only_renders_the_top_level_of_a_deep_menu() {
688
        let deep = nested(64);
689
        let bar = build_menubar_dom(&menu_of(vec![MenuItem::String(deep.clone())]));
690

            
691
        assert_eq!(bar.children.as_ref().len(), 1, "nesting depth is not bar width");
692
        assert_children_count_is_in_sync(&bar);
693

            
694
        // Only the *direct* children are carried into the popup.
695
        let sub = submenu_of(&bar.children.as_ref()[0]);
696
        assert_eq!(sub.items.as_slice().len(), 1);
697
        assert_eq!(labels_of(&sub), vec!["lvl62"]);
698
    }
699

            
700
    #[test]
701
    fn build_menubar_dom_flattens_into_a_styled_dom_with_hit_testable_items() {
702
        let m = menu_of(vec![string_item("File"), MenuItem::Separator, string_item("Edit")]);
703
        let styled = StyledDom::create_from_dom(build_menubar_dom(&m));
704

            
705
        let items = item_node_ids(&styled);
706
        assert_eq!(items.len(), 2, "one flat node per rendered bar item");
707
        for id in items {
708
            // Panics with a clear message if the item lost its tag.
709
            let _tag = tag_of(&styled, id);
710
        }
711
    }
712

            
713
    // ==================================================================
714
    // build_menubar_item
715
    // ==================================================================
716

            
717
    #[test]
718
    fn build_menubar_item_wraps_a_leaf_verbatim_including_every_optional_field() {
719
        let item = decorated_leaf("Quit");
720
        let dom = build_menubar_item(&item);
721

            
722
        let sub = submenu_of(&dom);
723
        assert_eq!(
724
            sub,
725
            Menu::create(MenuItemVec::from_vec(vec![MenuItem::String(item.clone())])),
726
            "a leaf must be wrapped byte-for-byte — accelerator, callback, state \
727
             and icon included — or its own callback can never fire",
728
        );
729

            
730
        let MenuItem::String(wrapped) = &sub.items.as_slice()[0] else {
731
            panic!("the wrapped item must still be a String item");
732
        };
733
        assert_eq!(wrapped.menu_item_state, MenuItemState::Greyed);
734
        assert!(wrapped.accelerator.is_some());
735
        assert!(wrapped.icon.is_some());
736
        assert!(wrapped.callback.is_some());
737
    }
738

            
739
    #[test]
740
    fn build_menubar_item_with_children_opens_exactly_those_children() {
741
        let children = vec![
742
            string_item("New"),
743
            MenuItem::Separator,
744
            string_item("Open"),
745
            MenuItem::BreakLine,
746
            MenuItem::String(decorated_leaf("Quit")),
747
        ];
748
        let item = leaf("File").with_children(MenuItemVec::from_vec(children.clone()));
749
        let dom = build_menubar_item(&item);
750

            
751
        let sub = submenu_of(&dom);
752
        assert_eq!(
753
            sub,
754
            Menu::create(MenuItemVec::from_vec(children)),
755
            "a parent must open its children verbatim, not itself",
756
        );
757
        assert!(
758
            !labels_of(&sub).contains(&"File".to_string()),
759
            "a parent must NOT wrap itself — that would duplicate the top-level entry",
760
        );
761
    }
762

            
763
    #[test]
764
    fn build_menubar_item_registers_exactly_one_mouseup_hover_callback() {
765
        let dom = build_menubar_item(&leaf("File"));
766
        let cbs = dom.root.callbacks.as_ref();
767

            
768
        assert_eq!(cbs.len(), 1);
769
        assert_eq!(cbs[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
770
        assert_eq!(
771
            cbs[0].callback.cb,
772
            menubar_item_click as usize,
773
            "the item must be wired to the menubar click handler",
774
        );
775
        assert!(
776
            matches!(cbs[0].callback.ctx, OptionRefAny::None),
777
            "a native Rust callback carries no FFI context",
778
        );
779
    }
780

            
781
    #[test]
782
    fn build_menubar_item_shape_is_one_class_one_text_child() {
783
        let huge = "z".repeat(100_000);
784
        for label in ["", "File", "👨‍👩‍👧‍👦", "a\u{0}b", huge.as_str()] {
785
            let dom = build_menubar_item(&leaf(label));
786

            
787
            assert_eq!(classes_of(&dom), vec![MENUBAR_ITEM_CLASS.to_string()]);
788
            assert_eq!(rendered_label(&dom), label, "the label must survive verbatim");
789
            // The item, its label <p>, and the text leaf inside it. This was 1
790
            // when the item held a BARE text node; every widget label is a <p>
791
            // now, because a text leaf owns no box and misbehaves as a
792
            // flex/grid item.
793
            assert_eq!(dom.estimated_total_children, 2);
794
            assert_children_count_is_in_sync(&dom);
795
        }
796
    }
797

            
798
    #[test]
799
    fn build_menubar_item_is_pure_and_repeatable() {
800
        let item = leaf("File").with_child(string_item("New"));
801

            
802
        let a = build_menubar_item(&item);
803
        let b = build_menubar_item(&item);
804

            
805
        assert_eq!(submenu_of(&a), submenu_of(&b), "same input ⇒ same submenu");
806
        assert!(
807
            refany_of(&a) != refany_of(&b),
808
            "each build must allocate its own backreference",
809
        );
810
        // The source item is untouched (it is only ever cloned).
811
        assert_eq!(item.label.as_str(), "File");
812
        assert_eq!(item.children.as_slice().len(), 1);
813
    }
814

            
815
    #[test]
816
    fn build_menubar_item_treats_an_empty_child_vec_as_a_leaf() {
817
        let empty = leaf("Help").with_children(MenuItemVec::from_vec(Vec::new()));
818
        let sub = submenu_of(&build_menubar_item(&empty));
819

            
820
        assert_eq!(
821
            sub.items.as_slice().len(),
822
            1,
823
            "an explicitly-empty child vec is still a leaf, so it self-wraps \
824
             rather than opening an empty popup",
825
        );
826
        assert_eq!(labels_of(&sub), vec!["Help"]);
827
    }
828

            
829
    // ==================================================================
830
    // menubar_item_click
831
    // ==================================================================
832

            
833
    #[test]
834
    fn menubar_item_click_without_a_hit_node_queues_nothing() {
835
        let data = refany_of(&build_menubar_item(&leaf("File")));
836

            
837
        with_env(|env| {
838
            assert_eq!(menubar_item_click(data.clone(), env.info()), Update::DoNothing);
839
            assert!(
840
                env.take_changes().is_empty(),
841
                "no anchor ⇒ no half-opened menu",
842
            );
843
        });
844
    }
845

            
846
    #[test]
847
    fn menubar_item_click_without_layout_geometry_queues_nothing() {
848
        let m = menu_of(vec![string_item("File")]);
849
        let bar = build_menubar_dom(&m);
850
        let data = refany_of(&bar.children.as_ref()[0]);
851
        let styled = StyledDom::create_from_dom(bar);
852
        let item = item_node_ids(&styled)[0];
853

            
854
        // Flattened DOM, but no hit-test area for the item: the geometry lookup
855
        // must fail closed.
856
        with_anchored_env(styled, &[], item, |env| {
857
            assert_eq!(menubar_item_click(data.clone(), env.info()), Update::DoNothing);
858
            assert!(env.take_changes().is_empty());
859
        });
860
    }
861

            
862
    #[test]
863
    fn menubar_item_click_on_a_foreign_payload_is_a_no_op() {
864
        for mut data in [
865
            RefAny::new(0_u32),
866
            RefAny::new(String::from("not a menu")),
867
            RefAny::new(Vec::<u8>::new()),
868
        ] {
869
            with_env(|env| {
870
                assert_eq!(
871
                    menubar_item_click(data.clone(), env.info()),
872
                    Update::DoNothing,
873
                    "a wrong-typed backreference must be ignored, not downcast blindly",
874
                );
875
                assert!(env.take_changes().is_empty());
876
            });
877
            assert!(
878
                data.downcast_ref::<Menu>().is_none(),
879
                "…and the payload is still not a Menu afterwards",
880
            );
881
        }
882
    }
883

            
884
    #[test]
885
    fn menubar_item_click_opens_the_submenu_at_the_bottom_left_of_the_item() {
886
        let m = menu_of(vec![
887
            MenuItem::String(leaf("File").with_children(MenuItemVec::from_vec(vec![
888
                string_item("New"),
889
                MenuItem::Separator,
890
                string_item("Open"),
891
            ]))),
892
        ]);
893
        let bar = build_menubar_dom(&m);
894
        let data = refany_of(&bar.children.as_ref()[0]);
895
        let expected = submenu_of(&bar.children.as_ref()[0]);
896
        let styled = StyledDom::create_from_dom(bar);
897
        let item = item_node_ids(&styled)[0];
898

            
899
        let rect = LogicalRect::new(LogicalPosition::new(10.0, 20.0), LogicalSize::new(100.0, 26.0));
900
        with_anchored_env(styled, &[(item, rect)], item, |env| {
901
            assert_eq!(menubar_item_click(data.clone(), env.info()), Update::DoNothing);
902

            
903
            let CallbackChange::OpenMenu { menu, position } = env.take_one() else {
904
                panic!("expected exactly one OpenMenu change");
905
            };
906
            assert_eq!(menu, expected, "the queued menu must be the item's backreference");
907

            
908
            let p = position.expect("the popup must be pinned under the bar item");
909
            assert_eq!((p.x, p.y), (10.0, 46.0), "bottom-left of the item rect");
910
        });
911
    }
912

            
913
    #[test]
914
    fn menubar_item_click_opens_each_bar_items_own_menu() {
915
        let m = menu_of(vec![
916
            MenuItem::String(leaf("File").with_child(string_item("New"))),
917
            MenuItem::Separator,
918
            MenuItem::String(leaf("Edit").with_child(string_item("Undo"))),
919
            MenuItem::String(leaf("Help")),
920
        ]);
921
        let bar = build_menubar_dom(&m);
922
        let payloads: Vec<RefAny> = bar.children.as_ref().iter().map(refany_of).collect();
923
        let styled = StyledDom::create_from_dom(bar);
924
        let items = item_node_ids(&styled);
925
        assert_eq!(items.len(), 3);
926

            
927
        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(40.0, 26.0));
928
        let anchors: Vec<(NodeId, LogicalRect)> = items.iter().map(|n| (*n, rect)).collect();
929
        let expected = [vec!["New"], vec!["Undo"], vec!["Help"]];
930

            
931
        with_anchored_env(styled, &anchors, items[0], |env| {
932
            for (i, node) in items.iter().enumerate() {
933
                assert_eq!(
934
                    menubar_item_click(payloads[i].clone(), env.info_at(dom_node(*node))),
935
                    Update::DoNothing,
936
                );
937
                let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
938
                    panic!("item {i}: expected an OpenMenu change");
939
                };
940
                assert_eq!(labels_of(&menu), expected[i], "item {i} opened the wrong menu");
941
            }
942
        });
943
    }
944

            
945
    #[test]
946
    fn menubar_item_click_is_repeatable_and_does_not_consume_the_payload() {
947
        let bar = build_menubar_dom(&menu_of(vec![string_item("File")]));
948
        let mut data = refany_of(&bar.children.as_ref()[0]);
949
        let styled = StyledDom::create_from_dom(bar);
950
        let item = item_node_ids(&styled)[0];
951

            
952
        let rect = LogicalRect::new(LogicalPosition::new(1.0, 2.0), LogicalSize::new(3.0, 4.0));
953
        with_anchored_env(styled, &[(item, rect)], item, |env| {
954
            for round in 0..5 {
955
                assert_eq!(menubar_item_click(data.clone(), env.info()), Update::DoNothing);
956
                let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
957
                    panic!("round {round}: expected an OpenMenu change");
958
                };
959
                assert_eq!(labels_of(&menu), vec!["File"]);
960
            }
961
        });
962

            
963
        assert!(
964
            data.downcast_ref::<Menu>().is_some(),
965
            "the backreference must survive repeated clicks",
966
        );
967
    }
968

            
969
    #[test]
970
    fn menubar_item_click_rejects_degenerate_anchor_rects() {
971
        // `get_node_hit_test_bounds` only accepts strictly positive extents, so
972
        // each of these must fail closed instead of queueing a menu at nowhere.
973
        let degenerate = [
974
            LogicalSize::new(0.0, 26.0),
975
            LogicalSize::new(100.0, 0.0),
976
            LogicalSize::new(0.0, 0.0),
977
            LogicalSize::new(-100.0, -26.0),
978
            LogicalSize::new(f32::NAN, 26.0),
979
            LogicalSize::new(100.0, f32::NAN),
980
            LogicalSize::new(f32::NAN, f32::NAN),
981
        ];
982

            
983
        for size in degenerate {
984
            let bar = build_menubar_dom(&menu_of(vec![string_item("File")]));
985
            let data = refany_of(&bar.children.as_ref()[0]);
986
            let styled = StyledDom::create_from_dom(bar);
987
            let item = item_node_ids(&styled)[0];
988
            let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), size);
989

            
990
            with_anchored_env(styled, &[(item, rect)], item, |env| {
991
                assert_eq!(menubar_item_click(data.clone(), env.info()), Update::DoNothing);
992
                assert!(
993
                    env.take_changes().is_empty(),
994
                    "a {size:?} anchor must not queue a menu",
995
                );
996
            });
997
        }
998
    }
999

            
    #[test]
    fn menubar_item_click_survives_non_finite_anchor_origins() {
        // Origin/height arithmetic (`origin.y + size.height`) is unchecked — it
        // must saturate to an infinity or NaN rather than panic.
        let cases = [
            (LogicalPosition::new(f32::MAX, f32::MAX), LogicalSize::new(1.0, f32::MAX)),
            (LogicalPosition::new(f32::MIN, f32::MIN), LogicalSize::new(1.0, 1.0)),
            (LogicalPosition::new(f32::NAN, f32::NAN), LogicalSize::new(1.0, 1.0)),
            (
                LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
                LogicalSize::new(1.0, f32::INFINITY),
            ),
        ];
        for (origin, size) in cases {
            let bar = build_menubar_dom(&menu_of(vec![string_item("File")]));
            let data = refany_of(&bar.children.as_ref()[0]);
            let styled = StyledDom::create_from_dom(bar);
            let item = item_node_ids(&styled)[0];
            let rect = LogicalRect::new(origin, size);
            with_anchored_env(styled, &[(item, rect)], item, |env| {
                assert_eq!(menubar_item_click(data.clone(), env.info()), Update::DoNothing);
                let CallbackChange::OpenMenu { position, .. } = env.take_one() else {
                    panic!("expected an OpenMenu change for {origin:?}/{size:?}");
                };
                let p = position.expect("a positive-extent anchor still yields a position");
                let expected_y = origin.y + size.height;
                assert_eq!(p.x.is_nan(), origin.x.is_nan());
                if !p.x.is_nan() {
                    assert_eq!(p.x, origin.x);
                }
                assert_eq!(
                    p.y.is_nan(),
                    expected_y.is_nan(),
                    "NaN must propagate, not turn into a bogus finite coordinate",
                );
                if !p.y.is_nan() {
                    assert_eq!(p.y, expected_y, "y must saturate to origin.y + height");
                }
            });
        }
    }
    #[test]
    fn menubar_item_click_queues_an_empty_menu_for_an_empty_backreference() {
        // Not reachable through the builders (a leaf self-wraps), but the handler
        // must still not panic on a menu with no items.
        let bar = build_menubar_dom(&menu_of(vec![string_item("File")]));
        let data = RefAny::new(Menu::create(MenuItemVec::from_vec(Vec::new())));
        let styled = StyledDom::create_from_dom(bar);
        let item = item_node_ids(&styled)[0];
        let rect = LogicalRect::new(LogicalPosition::new(5.0, 5.0), LogicalSize::new(5.0, 5.0));
        with_anchored_env(styled, &[(item, rect)], item, |env| {
            assert_eq!(menubar_item_click(data.clone(), env.info()), Update::DoNothing);
            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
                panic!("expected an OpenMenu change");
            };
            assert!(menu.items.as_slice().is_empty(), "an empty menu is not a panic");
        });
    }
    #[test]
    fn menubar_item_click_queues_a_wide_menu_without_truncation() {
        const N: usize = 500;
        let children: Vec<MenuItem> = (0..N).map(|i| string_item(&format!("c{i}"))).collect();
        let m = menu_of(vec![MenuItem::String(
            leaf("File").with_children(MenuItemVec::from_vec(children)),
        )]);
        let bar = build_menubar_dom(&m);
        let data = refany_of(&bar.children.as_ref()[0]);
        let styled = StyledDom::create_from_dom(bar);
        let item = item_node_ids(&styled)[0];
        let rect = LogicalRect::new(LogicalPosition::new(0.0, 0.0), LogicalSize::new(1.0, 1.0));
        with_anchored_env(styled, &[(item, rect)], item, |env| {
            assert_eq!(menubar_item_click(data.clone(), env.info()), Update::DoNothing);
            let CallbackChange::OpenMenu { menu, .. } = env.take_one() else {
                panic!("expected an OpenMenu change");
            };
            assert_eq!(menu.items.as_slice().len(), N);
            assert_eq!(labels_of(&menu)[N - 1], format!("c{}", N - 1));
        });
    }
}