Skip to main content

teksilo_widgets/
menu_bar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! MenuBar — a horizontal application menu bar with keyboard-driven dropdowns.
5//!
6//! `MenuBar` renders a row of labelled trigger buttons; activating one opens a
7//! dropdown `MenuList` as an overlay. Menus can be added via the fluent
8//! `.menu(label, factory)` API or built from a declarative `MenuModel`
9//! (the single source of truth shared with the native macOS menu bar via
10//! `from_model` + `native_on_macos`). Leading and trailing slots accept
11//! arbitrary widget content (an app icon or a search field, for example).
12//!
13//! **Keyboard.** F10 and bare-Alt-tap focus the first trigger without opening
14//! a menu; Alt+letter opens the menu whose label carries a matching mnemonic
15//! marker (`&File` → Alt+F). On macOS the Alt+letter branch is suppressed
16//! because the OS rewrites Option+letter for accented character composition —
17//! F10 and bare-Alt-tap continue to work. Once a dropdown is open, ArrowLeft
18//! and ArrowRight cycle between top-level menus, and Escape closes the active
19//! one and returns focus to the trigger.
20//!
21//! **Hamburger / collapsible mode.** Call `.collapsible()` to let the bar
22//! collapse to a single hamburger `IconButton` when its intrinsic width
23//! exceeds the allotted space (`CollapsePolicy::Responsive`). `.collapse_policy(Always)`
24//! forces the hamburger regardless of width.
25//!
26//! ## Accessibility
27//!
28//! The bar carries `Role::MenuBar`; each trigger is `Role::MenuItem` with
29//! `set_has_popup(Menu)` and `set_expanded` tracking the open dropdown.
30//! Mnemonic letters are announced via `set_access_key` for Windows Narrator.
31//!
32//! ```rust
33//! # use teksilo_widgets::{MenuBar, MenuList, MenuItem};
34//! # use teksilo_i18n::lit;
35//! # use teksilo_core::Intent;
36//! let _w = MenuBar::new()
37//!     .menu(lit!("File"), || Box::new(
38//!         MenuList::new()
39//!             .item(MenuItem::new(lit!("New")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.new"))))
40//!             .separator()
41//!             .item(MenuItem::new(lit!("Quit")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.quit"))))
42//!     ))
43//!     .menu(lit!("Edit"), || Box::new(
44//!         MenuList::new()
45//!             .item(MenuItem::new(lit!("Cut")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.cut"))))
46//!     ));
47//! ```
48
49use std::cell::{Cell, RefCell};
50use std::collections::HashMap;
51use std::rc::Rc;
52
53use teksilo_canvas::{Point, Rect, Size, SizeProposal};
54use teksilo_core::accessibility::AccessNodeBuilder;
55use teksilo_core::build_context::BuildContext;
56use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
57use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
58use teksilo_core::signal::Signal;
59use teksilo_core::widget::{
60    CursorIcon, EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement,
61};
62use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
63use teksilo_core::widget_id::WidgetId;
64use teksilo_core::window::{
65    MenubarAction, MenubarDispatcher, MenubarGuard, MenubarKeyEvent, MenubarReveal,
66};
67use teksilo_tokens::{SurfaceRole, TextStyleRole};
68
69use crate::animations::Unroll;
70use crate::icon_button::{IconButton, IconButtonSize};
71use crate::menu_context::MenuContext;
72use crate::menu_item::MenuLabel;
73use crate::menu_item::ParsedMnemonic;
74use crate::menu_item::parse_mnemonic;
75use crate::primitives::{HStack, Padding, RectWidget, Spacer, ZStack};
76use teksilo_i18n::LocalizedString;
77
78/// Controls when a collapsible [`MenuBar`] switches from the full inline bar
79/// to the hamburger `IconButton` representation.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum CollapsePolicy {
82    /// Collapse to a hamburger only when the bar's intrinsic width
83    /// exceeds the width it is allotted; otherwise show the full inline
84    /// bar. Mirrors the responsive `Toolbar` overflow behaviour.
85    #[default]
86    Responsive,
87    /// Always show the hamburger, regardless of available width. The
88    /// "force hamburger" / compact mode.
89    Always,
90}
91
92// ---------------------------------------------------------------------------
93// MenuBarEntry — pending menu definition
94// ---------------------------------------------------------------------------
95
96struct MenuBarEntry {
97    label: LocalizedString,
98    factory: Box<dyn Fn() -> Box<dyn Widget>>,
99}
100
101// ---------------------------------------------------------------------------
102// MenuBar — public widget
103// ---------------------------------------------------------------------------
104
105/// A horizontal application menu bar with labelled trigger buttons and dropdown menus.
106///
107/// Each top-level entry becomes a focusable trigger; activating it opens a
108/// floating `MenuList` overlay. See the module documentation for the full
109/// keyboard, mnemonic, and collapsible-mode details.
110pub struct MenuBar {
111    entries: Vec<MenuBarEntry>,
112    /// Pending leading/trailing slot content (the standard by-value slot
113    /// pattern, same as `Card` / `TextInput` / `StandardListItem`). Consumed
114    /// on the first build into `leading_slot_ids` / `trailing_slot_ids`, which
115    /// are re-attached on every later build. MenuBar is
116    /// [`preserves_children_on_rebuild`], so the reconciling rebuild keeps the
117    /// re-attached slot widgets alive — a stateful slot control (a search
118    /// field, a focused button) survives a theme / locale / model-version
119    /// rebuild with its state intact. The menu triggers, by contrast, are
120    /// re-derived fresh each build (the model may have changed) and the
121    /// reconcile reaps the superseded ones.
122    ///
123    /// [`preserves_children_on_rebuild`]: teksilo_core::widget::Widget::preserves_children_on_rebuild
124    leading_slot: Vec<PendingChild>,
125    trailing_slot: Vec<PendingChild>,
126    /// Memoized slot widget ids — populated from the pending content on the
127    /// first build, reused (re-attached) on every later build so the slot
128    /// widgets keep their identity and state across rebuilds.
129    leading_slot_ids: Vec<WidgetId>,
130    trailing_slot_ids: Vec<WidgetId>,
131    root_child_id: Option<WidgetId>,
132    /// Window-state guard for the per-window menubar key dispatcher
133    /// (F10, Alt+letter, bare-Alt-tap). Owned by the MenuBar so the
134    /// slot is cleared on rebuild / unmount.
135    menubar_guard: RefCell<Option<MenubarGuard>>,
136    /// When `true` (the default), `build()` installs a
137    /// [`MenubarDispatcher`] into the window-state slot so this
138    /// MenuBar receives F10 / Alt+letter / Alt-tap routing. Set to
139    /// `false` via [`MenuBar::no_dispatcher_install`] for showcase /
140    /// demo MenuBars that share a window with a primary one — the
141    /// window-state slot is single-occupancy and a second install
142    /// `debug_assert!`s otherwise.
143    install_dispatcher: bool,
144    /// When `Some`, the bar can collapse to a hamburger `IconButton`.
145    /// `None` (the default) is the classic always-inline MenuBar.
146    collapse_policy: Option<CollapsePolicy>,
147    /// `true` while collapsed (hamburger shown). Source of truth for
148    /// the visibility bindings. Driven by the responsive decision in
149    /// `place_children` (or pinned `true` for `CollapsePolicy::Always`).
150    collapsed: Signal<bool>,
151    /// `true` while the collapsed bar is shown as a floating overlay.
152    revealed: Signal<bool>,
153    /// Animated 0..1 reveal progress for the floating bar (0 = rolled up
154    /// into the hamburger, 1 = fully unrolled). The overlay's deferred
155    /// reveal/dismiss drives it; an [`Unroll`] wrapper binds the bar's
156    /// width to it so the bar unrolls out of the hamburger on open and
157    /// rolls back into it on close. Stays at `1.0` for the inline bar.
158    reveal_progress: Signal<f32>,
159    /// Idempotence guard for the responsive write (Toolbar pattern).
160    last_collapsed: Cell<bool>,
161    /// The bar root (ZStack) id, captured in `build()`. Used both as the
162    /// inline content and as the floating-overlay content when collapsed.
163    bar_id: Option<WidgetId>,
164    /// The hamburger `IconButton` id, captured in `build()`.
165    hamburger_id: Option<WidgetId>,
166    /// Size variant applied to the collapsed-mode hamburger `IconButton`.
167    /// Defaults to [`IconButtonSize::Default`] (matching a bare `IconButton`).
168    hamburger_size: IconButtonSize,
169    /// The declarative source model, when this bar was built via
170    /// [`from_model`](Self::from_model). Drives the native menu mirror.
171    model: Option<crate::menu::MenuModel>,
172    /// macOS native-menu behaviour (mirror to / suppress in-window).
173    native_mode: crate::menu::NativeMenuMode,
174    /// RAII binding keeping the native menu's reactive observers alive while
175    /// this bar is mounted.
176    native_binding: RefCell<Option<crate::menu::native::NativeMenuBinding>>,
177}
178
179impl MenuBar {
180    /// Create an empty menu bar with no menus, slots, or collapse policy.
181    pub fn new() -> Self {
182        Self {
183            entries: Vec::new(),
184            leading_slot: Vec::new(),
185            trailing_slot: Vec::new(),
186            leading_slot_ids: Vec::new(),
187            trailing_slot_ids: Vec::new(),
188            root_child_id: None,
189            menubar_guard: RefCell::new(None),
190            install_dispatcher: true,
191            collapse_policy: None,
192            collapsed: Signal::new(false),
193            revealed: Signal::new(false),
194            reveal_progress: Signal::new_animated(1.0),
195            last_collapsed: Cell::new(false),
196            bar_id: None,
197            hamburger_id: None,
198            hamburger_size: IconButtonSize::Default,
199            model: None,
200            native_mode: crate::menu::NativeMenuMode::Off,
201            native_binding: RefCell::new(None),
202        }
203    }
204
205    /// Build a menu bar from a declarative [`MenuModel`](crate::menu::MenuModel)
206    /// — the single source of truth shared with the native OS menu bar. Each
207    /// top-level menu in the model becomes an in-window dropdown; combine with
208    /// [`native_on_macos`](Self::native_on_macos) to also mirror it into the
209    /// macOS system menu bar.
210    pub fn from_model(model: crate::menu::MenuModel) -> Self {
211        let mut bar = Self::new();
212        // Entries are derived from the model on every `build()` (see
213        // `model_entries`), so runtime structural changes — `MenuModel::push_item`
214        // / `remove` / `push_menu` — re-render the in-window bar too (the bar
215        // binds `model.version()` at `Rebuild` level).
216        bar.model = Some(model);
217        bar
218    }
219
220    /// Derive the in-window menu entries from the model's top-level menus. Each
221    /// `Submenu` node becomes a dropdown whose factory builds a `MenuList` from
222    /// its children. `Standard` roles + bare items/separators at top level have
223    /// no in-window representation.
224    fn model_entries(model: &crate::menu::MenuModel) -> Vec<MenuBarEntry> {
225        model
226            .nodes()
227            .iter()
228            .filter_map(|node| match node {
229                crate::menu::MenuNode::Submenu {
230                    title, children, ..
231                } => {
232                    let children = children.clone();
233                    Some(MenuBarEntry {
234                        label: title.clone(),
235                        factory: Box::new(move || {
236                            Box::new(crate::menu::model::build_menu_list(&children))
237                        }),
238                    })
239                }
240                _ => None,
241            })
242            .collect()
243    }
244
245    /// Add an `HStack`'s worth of slot content to `row`, memoized.
246    ///
247    /// On the first build `pending` holds the by-value slot widgets: each is
248    /// inserted once and its id captured in `cache`. On every later build the
249    /// cached ids are re-attached unchanged — re-parenting the same slot
250    /// widgets into the fresh row. Because MenuBar is
251    /// `preserves_children_on_rebuild`, the reconciling rebuild keeps those
252    /// re-homed widgets (and their state) alive while reaping the superseded
253    /// menu triggers. Building each slot widget exactly once is what preserves
254    /// a stateful slot control across rebuilds.
255    fn add_slot(
256        ctx: &mut BuildContext,
257        mut row: HStack,
258        pending: &mut Vec<PendingChild>,
259        cache: &mut Vec<WidgetId>,
260    ) -> HStack {
261        if cache.is_empty() && !pending.is_empty() {
262            *cache = pending
263                .drain(..)
264                .map(|p| match p {
265                    PendingChild::Id(id) => id,
266                    PendingChild::Deferred(w) => ctx.add_boxed(w),
267                })
268                .collect();
269        }
270        for &id in cache.iter() {
271            row = row.add_child(id);
272        }
273        row
274    }
275
276    /// Choose how this bar behaves on macOS, where the convention is a global
277    /// menu bar at the top of the screen. Requires the bar to have been built
278    /// with [`from_model`](Self::from_model) and the app to have called
279    /// `install_native_menu()`. No effect on other platforms (the in-window bar
280    /// renders there regardless).
281    pub fn native_on_macos(mut self, mode: crate::menu::NativeMenuMode) -> Self {
282        self.native_mode = mode;
283        self
284    }
285
286    /// Enable the optional **hamburger** representation. When there
287    /// isn't room for the full inline bar, it collapses to a single
288    /// hamburger (☰) [`IconButton`]; activating it (click, `Alt`+
289    /// mnemonic, `F10`, or bare-`Alt`-tap) reveals the full bar as a
290    /// floating overlay over content. Clicking outside the bar or
291    /// pressing `Escape` hides it again.
292    ///
293    /// Uses [`CollapsePolicy::Responsive`]. Observe the collapsed state
294    /// via [`is_collapsed`](Self::is_collapsed), or bind your own signal
295    /// with [`collapsed_signal`](Self::collapsed_signal).
296    pub fn collapsible(mut self) -> Self {
297        self.collapse_policy
298            .get_or_insert(CollapsePolicy::Responsive);
299        self
300    }
301
302    /// Like [`collapsible`](Self::collapsible), but uses the supplied
303    /// signal as the collapsed-state source so the application can
304    /// observe (and react to) collapse transitions. The responsive
305    /// decision **writes** this signal (it is not a plain read-only
306    /// input) — kept as a `Signal<bool>` rather than `Prop<bool>` since a
307    /// static value would have nowhere to receive those writes.
308    pub fn collapsed_signal(mut self, collapsed: Signal<bool>) -> Self {
309        self.collapse_policy
310            .get_or_insert(CollapsePolicy::Responsive);
311        self.last_collapsed.set(collapsed.get());
312        self.collapsed = collapsed;
313        self
314    }
315
316    /// Set the collapse policy (and enable collapsible mode).
317    /// [`CollapsePolicy::Always`] forces the hamburger regardless of
318    /// available width — i.e. **collapsed by default**.
319    pub fn collapse_policy(mut self, policy: CollapsePolicy) -> Self {
320        self.collapse_policy = Some(policy);
321        // Start already-collapsed for `Always` so the first frame shows
322        // the hamburger (no one-frame inline flash before `place_children`
323        // sets the signal).
324        if policy == CollapsePolicy::Always {
325            self.collapsed.set(true);
326            self.last_collapsed.set(true);
327        }
328        self
329    }
330
331    /// Set the size variant of the collapsed-mode hamburger
332    /// [`IconButton`]. Mirrors [`IconButton::size`] — pick
333    /// [`IconButtonSize::Toolbar`], [`IconButtonSize::Large`],
334    /// [`IconButtonSize::Hero`], etc. so the hamburger matches the
335    /// surrounding chrome. Defaults to [`IconButtonSize::Default`].
336    pub fn hamburger_size(mut self, size: IconButtonSize) -> Self {
337        self.hamburger_size = size;
338        self
339    }
340
341    /// A clone of the collapsed-state signal (`true` while the
342    /// hamburger is shown). Call after [`collapsible`](Self::collapsible).
343    pub fn is_collapsed(&self) -> Signal<bool> {
344        self.collapsed.clone()
345    }
346
347    /// Skip the window-state dispatcher install. The MenuBar still
348    /// renders, intercepts mouse clicks, and supports keyboard
349    /// navigation when its triggers have focus — only F10 /
350    /// Alt+letter / Alt-tap routing through the window-level slot is
351    /// disabled. Use this for demo / showcase MenuBars that share a
352    /// window with a primary functional MenuBar — the slot is
353    /// single-occupancy and a second install would `debug_assert!`.
354    pub fn no_dispatcher_install(mut self) -> Self {
355        self.install_dispatcher = false;
356        self
357    }
358
359    /// Add a top-level menu entry. `label` is the trigger text (supports `&`
360    /// mnemonic markers, e.g. `"&File"`); `factory` is called each build to
361    /// produce the dropdown content — typically a `MenuList`.
362    pub fn menu(
363        mut self,
364        label: impl Into<LocalizedString>,
365        factory: impl Fn() -> Box<dyn Widget> + 'static,
366    ) -> Self {
367        let ls: LocalizedString = label.into();
368        self.entries.push(MenuBarEntry {
369            label: ls,
370            factory: Box::new(factory),
371        });
372        self
373    }
374
375    /// Add content before the menu buttons (e.g. an app icon). Call more than
376    /// once to stack several.
377    ///
378    /// Takes the widget by value, like every other widget's slot. MenuBar
379    /// builds it once and reuses it across rebuilds (it
380    /// [`preserves_children_on_rebuild`](teksilo_core::widget::Widget::preserves_children_on_rebuild)),
381    /// so the slot — and any state it holds — survives a theme / locale /
382    /// model-version rebuild.
383    pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
384        self.leading_slot
385            .push(PendingChild::Deferred(Box::new(widget)));
386        self
387    }
388
389    /// Add content after the menu buttons (e.g. a search box or avatar).
390    /// Like [`leading_slot`](Self::leading_slot), taken by value and preserved
391    /// across rebuilds.
392    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
393        self.trailing_slot
394            .push(PendingChild::Deferred(Box::new(widget)));
395        self
396    }
397
398    /// macOS `Suppress` path: a zero-chrome bar that renders only the
399    /// leading/trailing slots (the OS menu bar carries the menus). No triggers,
400    /// no F10/Alt dispatcher.
401    fn build_suppressed(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
402        let mut row = HStack::new().spacing(2.0);
403        row = Self::add_slot(ctx, row, &mut self.leading_slot, &mut self.leading_slot_ids);
404        row = row.child(Spacer::new());
405        row = Self::add_slot(
406            ctx,
407            row,
408            &mut self.trailing_slot,
409            &mut self.trailing_slot_ids,
410        );
411        let row_id = ctx.add(row);
412        self.root_child_id = Some(row_id);
413        self.bar_id = Some(row_id);
414        vec![row_id]
415    }
416}
417
418impl Default for MenuBar {
419    fn default() -> Self {
420        Self::new()
421    }
422}
423
424// ---------------------------------------------------------------------------
425// MenuBarDispatcher — window-level F10 / Alt+letter / Alt-tap handler
426// ---------------------------------------------------------------------------
427
428/// `MenubarDispatcher` impl backed by the live trigger ids and
429/// mnemonic table from the most recent `MenuBar::build`.
430struct MenuBarDispatcher {
431    /// All top-level trigger ids, in declaration order.
432    trigger_ids: Vec<WidgetId>,
433    /// Lower-cased mnemonic char → trigger array index.
434    mnemonic_table: HashMap<char, usize>,
435}
436
437impl MenubarDispatcher for MenuBarDispatcher {
438    fn try_handle(&self, event: &MenubarKeyEvent) -> Option<MenubarAction> {
439        // F10 (no modifiers): focus the first trigger without
440        // opening any menu — matches Win32 / GTK F10 behaviour.
441        // Works on every platform (F10 is not transformed by any OS
442        // input layer the way Alt+letter is on macOS).
443        if event.modifiers == Modifiers::NONE && matches!(event.key, Key::F10) {
444            return self
445                .trigger_ids
446                .first()
447                .map(|&id| MenubarAction::FocusTrigger {
448                    trigger_id: id,
449                    reveal: None,
450                });
451        }
452        // Alt+<letter> mnemonics. On macOS, Option+letter is
453        // intercepted by the OS to compose accented characters
454        // (Option+E -> ´, Option+F -> ƒ, …) *before* winit sees the
455        // keystroke. The app receives the post-composition character
456        // (`ƒ`), not the typed letter (`F`), so the mnemonic table
457        // can never match. Worse, returning `Intercept` here would
458        // silently swallow legitimate accented text input. Skip the
459        // entire branch on macOS — F10 + Alt-tap + in-menu
460        // bare-letter activation cover the macOS menu-keyboard
461        // story instead.
462        #[cfg(not(target_os = "macos"))]
463        if event.modifiers == Modifiers::ALT {
464            // Strict per-OS contract — `Alt+letter` is reserved for
465            // menu mnemonics on Win32 / GTK and must be intercepted
466            // even when nothing matches, so the chord doesn't
467            // appear as garbled text input in a focused text field.
468            let lookup_char = match event.key {
469                Key::Character(c) => Some(c.to_ascii_lowercase()),
470                _ => {
471                    let c = event.key.to_char()?;
472                    Some(c.to_ascii_lowercase())
473                }
474            };
475            if let Some(c) = lookup_char {
476                if let Some(&idx) = self.mnemonic_table.get(&c) {
477                    if let Some(&tid) = self.trigger_ids.get(idx) {
478                        return Some(MenubarAction::OpenMenu {
479                            trigger_id: tid,
480                            reveal: None,
481                        });
482                    }
483                }
484                // Letter-with-Alt that doesn't match any mnemonic —
485                // intercept silently so the chord doesn't leak into
486                // focused text input as garbled chars.
487                return Some(MenubarAction::Intercept);
488            }
489        }
490        // Suppress an unused-warning on macOS where the Alt branch
491        // above is compiled out.
492        let _ = &self.mnemonic_table;
493        None
494    }
495
496    fn on_alt_tap(&self) -> Option<MenubarAction> {
497        // Bare-Alt-tap (no other key during the hold) → focus the
498        // first trigger in menubar-active mode (no menu opens until
499        // ArrowDown / Enter / Space).
500        self.trigger_ids
501            .first()
502            .map(|&id| MenubarAction::FocusTrigger {
503                trigger_id: id,
504                reveal: None,
505            })
506    }
507}
508
509// ---------------------------------------------------------------------------
510// CollapsibleMenuBarDispatcher — wraps MenuBarDispatcher for hamburger mode
511// ---------------------------------------------------------------------------
512
513/// Delegates to the inner [`MenuBarDispatcher`], and — when the bar is
514/// currently collapsed — attaches a `reveal` closure to the returned
515/// action so `teksilo-app` reveals the floating bar (and re-layouts)
516/// before focusing / opening. Preserves the inner dispatcher's
517/// platform-specific behaviour (macOS Alt+letter compile-out, F10,
518/// bare-Alt-tap) by pure delegation.
519struct CollapsibleMenuBarDispatcher {
520    inner: MenuBarDispatcher,
521    collapsed: Signal<bool>,
522    reveal: MenubarReveal,
523}
524
525impl CollapsibleMenuBarDispatcher {
526    fn with_reveal(&self, action: MenubarAction) -> MenubarAction {
527        if !self.collapsed.get() {
528            return action;
529        }
530        let reveal = Some(self.reveal.clone());
531        match action {
532            MenubarAction::OpenMenu { trigger_id, .. } => {
533                MenubarAction::OpenMenu { trigger_id, reveal }
534            }
535            MenubarAction::FocusTrigger { trigger_id, .. } => {
536                MenubarAction::FocusTrigger { trigger_id, reveal }
537            }
538            MenubarAction::Intercept => MenubarAction::Intercept,
539        }
540    }
541}
542
543impl MenubarDispatcher for CollapsibleMenuBarDispatcher {
544    fn try_handle(&self, event: &MenubarKeyEvent) -> Option<MenubarAction> {
545        self.inner.try_handle(event).map(|a| self.with_reveal(a))
546    }
547
548    fn on_alt_tap(&self) -> Option<MenubarAction> {
549        self.inner.on_alt_tap().map(|a| self.with_reveal(a))
550    }
551}
552
553impl std::fmt::Debug for MenuBar {
554    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555        f.debug_struct("MenuBar")
556            .field("entries", &self.entries.len())
557            .finish()
558    }
559}
560
561// ---------------------------------------------------------------------------
562// MenuBarTrigger — internal trigger label
563// ---------------------------------------------------------------------------
564
565#[derive(Debug)]
566struct MenuBarTrigger {
567    label: LocalizedString,
568    /// Mnemonic-stripped label name used for `AccessNodeBuilder::set_name`.
569    /// Captured from the parsed label so screen readers announce "File",
570    /// not "ampersand-File". Set in `build()`.
571    stripped_name: String,
572    /// Mnemonic letter (lowercase) for AT `set_access_key` annotation.
573    /// `None` for triggers whose label carries no un-escaped `&`.
574    mnemonic_key: Option<char>,
575    index: usize,
576    menu_ctx: MenuContext,
577    root_child_id: Option<WidgetId>,
578}
579
580impl Widget for MenuBarTrigger {
581    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
582        let theme = ctx.theme();
583        let radius_control = theme.shape.radius_control;
584        use crate::styles::recipe_menu_item_style as menu;
585        let index = self.index;
586        let menu_ctx = self.menu_ctx.clone();
587
588        // Background role: `AccentSubtle` when open (the Int UI token for
589        // highlighted menu-bar entries) or `Transparent` at rest. Replaces
590        // the previous hand-mixed `accent.with_alpha(0.12)` wash.
591        let bg_role = menu_ctx.open_index.map(move |open| {
592            if *open == Some(index) {
593                SurfaceRole::AccentSubtle
594            } else {
595                SurfaceRole::Transparent
596            }
597        });
598
599        // Text color can't collapse to a pure role: the at-rest state is
600        // `text_primary.with_alpha(0.8)` (dimmed primary — distinct from
601        // TextRole::Secondary, which is a different hue). Keep a direct
602        // `theme_signal` map for the blended case.
603        let theme_signal = ctx.theme_signal();
604        let text_color = menu_ctx
605            .open_index
606            .zip(&theme_signal)
607            .map(move |(open, t)| {
608                if *open == Some(index) {
609                    t.colors.text_primary
610                } else {
611                    t.colors.text_primary.with_alpha(0.8)
612                }
613            });
614
615        // Label. Uses `MenuLabel` so a single `&` in the trigger
616        // string acts as a mnemonic marker — stripped from the
617        // visible text and underlined when the window's `alt_down`
618        // signal is true.
619        let alt_down = ctx
620            .window()
621            .map(|w| w.alt_down().clone())
622            .unwrap_or_else(|| Signal::new(false));
623        let label_source: teksilo_core::signal::Prop<String> = self.label.clone().into();
624        let label_id = ctx.add(MenuLabel::new(
625            label_source,
626            alt_down,
627            text_color,
628            TextStyleRole::Small,
629        ));
630
631        let padding =
632            Padding::symmetric(4.0, menu::MENU_ITEM_PADDING_HORIZONTAL).child_id(label_id);
633        let padding_id = ctx.add(padding);
634
635        let bg = RectWidget::new()
636            .background(bg_role)
637            .corner_radius(teksilo_tokens::CornerRadius::uniform(radius_control));
638        let bg_id = ctx.add(bg);
639
640        let zstack = ZStack::new().add_child(bg_id).add_child(padding_id);
641        let root_id = ctx.add(zstack);
642        self.root_child_id = Some(root_id);
643
644        let handler_set = HandlerSet::new()
645            .on_tap({
646                let menu_ctx = menu_ctx.clone();
647                move |_pos, ctx: &mut EventContext| {
648                    if menu_ctx.open_index.get() == Some(index) {
649                        menu_ctx.close(ctx);
650                    } else {
651                        menu_ctx.open_at(index, ctx);
652                    }
653                }
654            })
655            .on_hover({
656                let menu_ctx = menu_ctx.clone();
657                move |entered: bool, ctx: &mut EventContext| {
658                    if entered {
659                        // If another menu is open, switch immediately (no delay)
660                        let current = menu_ctx.open_index.get();
661                        if current.is_some() && current != Some(index) {
662                            menu_ctx.open_at(index, ctx);
663                        }
664                    }
665                }
666            })
667            .on_key({
668                let menu_ctx = menu_ctx.clone();
669                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
670                    // The menu bar lays out right-to-left under RTL, so the
671                    // visual "previous/next menu" arrows swap: ArrowLeft moves
672                    // to the next (visually-left) menu and ArrowRight to the
673                    // previous one.
674                    let (left_delta, right_delta) = if ctx.is_rtl() { (1, -1) } else { (-1, 1) };
675                    match event {
676                        WidgetEvent::KeyDown {
677                            key: Key::ArrowDown | Key::Enter | Key::Space,
678                            ..
679                        } => {
680                            menu_ctx.open_at(index, ctx);
681                            EventResponse::Handled
682                        }
683                        WidgetEvent::KeyDown {
684                            key: Key::ArrowLeft,
685                            ..
686                        } => {
687                            menu_ctx.navigate(left_delta, ctx);
688                            EventResponse::Handled
689                        }
690                        WidgetEvent::KeyDown {
691                            key: Key::ArrowRight,
692                            ..
693                        } => {
694                            menu_ctx.navigate(right_delta, ctx);
695                            EventResponse::Handled
696                        }
697                        _ => EventResponse::Ignored,
698                    }
699                }
700            })
701            .on_access_action({
702                // Assistive-tech / automation activation. Click toggles the
703                // dropdown (matching `on_tap`); Expand opens it, Collapse closes
704                // it. Without this the trigger's advertised actions are inert.
705                let menu_ctx = menu_ctx.clone();
706                move |action, ctx: &mut EventContext| -> EventResponse {
707                    use teksilo_core::accesskit::Action;
708                    match action {
709                        Action::Click => {
710                            if menu_ctx.open_index.get() == Some(index) {
711                                menu_ctx.close(ctx);
712                            } else {
713                                menu_ctx.open_at(index, ctx);
714                            }
715                            EventResponse::Handled
716                        }
717                        Action::Expand => {
718                            menu_ctx.open_at(index, ctx);
719                            EventResponse::Handled
720                        }
721                        Action::Collapse => {
722                            menu_ctx.close(ctx);
723                            EventResponse::Handled
724                        }
725                        _ => EventResponse::Ignored,
726                    }
727                }
728            })
729            .focusable(true)
730            .cursor(CursorIcon::Pointer);
731
732        ctx.apply_self_handlers(handler_set);
733
734        // Re-query accessibility when this trigger's open/closed state flips so
735        // `set_expanded` stays in sync with the open menu index.
736        let self_id = ctx.self_id();
737        let registry = ctx.binding_registry();
738        self.menu_ctx.open_index.bind_to(
739            self_id,
740            registry,
741            teksilo_core::binding::BindingLevel::RepaintOnly,
742        );
743
744        vec![root_id]
745    }
746
747    fn layout_response(
748        &self,
749        proposal: SizeProposal,
750        ctx: &LayoutContext,
751    ) -> teksilo_core::widget::LayoutResponse {
752        match self.root_child_id {
753            Some(id) => ctx
754                .child_size(id, proposal)
755                .unwrap_or_else(|| proposal.resolve(0.0, 28.0)),
756            None => proposal.resolve(60.0, 28.0),
757        }
758        .into()
759    }
760
761    fn place_children(
762        &self,
763        bounds: Rect,
764        _proposal: SizeProposal,
765        children: &mut [WidgetPlacement],
766        _ctx: &LayoutContext,
767    ) {
768        for child in children.iter_mut() {
769            child.origin = bounds.origin();
770            child.size = bounds.size();
771        }
772    }
773
774    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
775        builder.set_role(teksilo_core::accesskit::Role::MenuItem);
776        // Stripped name — set in `build()` from the parsed mnemonic.
777        // Falls back to a fresh resolve if the trigger has not been
778        // built yet (rare; AT walks always happen post-build).
779        if !self.stripped_name.is_empty() {
780            builder.set_name(self.stripped_name.clone());
781        } else {
782            builder.set_name(parse_mnemonic(&self.label.resolve_now()).stripped);
783        }
784        // Every top-level menu bar entry opens a dropdown Menu.
785        builder.set_has_popup(teksilo_core::accesskit::HasPopup::Menu);
786        let is_open = self.menu_ctx.open_index.get() == Some(self.index);
787        builder.set_expanded(is_open);
788        // Advertise the default action (Click) plus the state-appropriate
789        // Expand/Collapse so assistive tech (and automation) can open/close the
790        // dropdown — the `on_access_action` handler in `build()` drives them.
791        // Without this a screen-reader user cannot open any menu.
792        builder.add_action(teksilo_core::accesskit::Action::Click);
793        if is_open {
794            builder.add_action(teksilo_core::accesskit::Action::Collapse);
795        } else {
796            builder.add_action(teksilo_core::accesskit::Action::Expand);
797        }
798        // Mnemonic — announced by Windows Narrator as "Access key: F".
799        if let Some(k) = self.mnemonic_key {
800            builder
801                .inner_mut()
802                .set_access_key(k.to_ascii_uppercase().to_string());
803        }
804    }
805
806    fn children(&self) -> Vec<WidgetId> {
807        self.root_child_id.into_iter().collect()
808    }
809}
810
811// ---------------------------------------------------------------------------
812// MenuOverlayHost — wraps dropdown content, handles focus + cross-menu keys
813// ---------------------------------------------------------------------------
814
815/// Wraps dropdown menu content (typically a MenuList). Responsibilities:
816/// - Resets `open_index` when focus is lost (overlay dismissed)
817/// - Handles ArrowLeft/Right for cross-menu navigation (bubbles up from MenuList)
818#[derive(Debug)]
819struct MenuOverlayHost {
820    inner: Option<Box<dyn Widget>>,
821    menu_ctx: MenuContext,
822    menu_index: usize,
823    inner_id: Option<WidgetId>,
824}
825
826impl Widget for MenuOverlayHost {
827    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
828        let inner_widget = self.inner.take().expect("MenuOverlayHost built twice");
829        let id = ctx.add_boxed(inner_widget);
830        self.inner_id = Some(id);
831
832        // Register inner widget as the focus target for this menu index
833        self.menu_ctx.set_focus_id(self.menu_index, id);
834
835        let menu_ctx = self.menu_ctx.clone();
836        let menu_index = self.menu_index;
837        let handler_set = HandlerSet::new()
838            .on_focus({
839                let menu_ctx = menu_ctx.clone();
840                move |gained: bool, _ctx: &mut EventContext| {
841                    // Focus left this menu, so it is on its way out — record
842                    // that, and nothing more. The dismissal itself belongs to
843                    // the framework's focus-out rule
844                    // (`dismiss_overlays_left_by_focus`), and the trigger gets
845                    // its focus back from the overlay's own `focus_restore`.
846                    //
847                    // Doing either of those *here* was a race: this handler
848                    // fires from inside the `FocusLost` dispatch, i.e. before
849                    // `focus_with_origin_ops` has installed the new target, so
850                    // the `request_focus(trigger)` it used to queue resolved
851                    // first and was then silently overwritten by the very
852                    // `set_focused` that was still in flight — a focus flash
853                    // onto the trigger that no `FocusLost` ever accounted for.
854                    // Keeping only the signal write leaves this side idempotent
855                    // and lets every dismissal path (Escape, click-outside,
856                    // Tab) converge on the same a11y state.
857                    if !gained && menu_ctx.open_index.get() == Some(menu_index) {
858                        menu_ctx.open_index.set(None);
859                    }
860                }
861            })
862            .on_key({
863                let menu_ctx = menu_ctx.clone();
864                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
865                    // These keys bubble up from the inner MenuList when it
866                    // returns Ignored. Under RTL the bar is laid out
867                    // right-to-left, so the previous/next arrows swap.
868                    let (left_delta, right_delta) = if ctx.is_rtl() { (1, -1) } else { (-1, 1) };
869                    match event {
870                        WidgetEvent::KeyDown {
871                            key: Key::ArrowLeft,
872                            ..
873                        } => {
874                            menu_ctx.navigate(left_delta, ctx);
875                            EventResponse::Handled
876                        }
877                        WidgetEvent::KeyDown {
878                            key: Key::ArrowRight,
879                            ..
880                        } => {
881                            menu_ctx.navigate(right_delta, ctx);
882                            EventResponse::Handled
883                        }
884                        WidgetEvent::KeyDown {
885                            key: Key::Escape, ..
886                        } => {
887                            menu_ctx.close(ctx);
888                            EventResponse::Handled
889                        }
890                        _ => EventResponse::Ignored,
891                    }
892                }
893            });
894        // NOT focusable — the inner MenuList receives focus directly.
895        // ArrowLeft/Right and FocusLost bubble from MenuList through here.
896        ctx.apply_self_handlers(handler_set);
897
898        vec![id]
899    }
900
901    fn layout_response(
902        &self,
903        proposal: SizeProposal,
904        ctx: &LayoutContext,
905    ) -> teksilo_core::widget::LayoutResponse {
906        self.inner_id
907            .and_then(|id| ctx.child_size(id, proposal))
908            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
909            .into()
910    }
911
912    fn place_children(
913        &self,
914        bounds: Rect,
915        _proposal: SizeProposal,
916        children: &mut [WidgetPlacement],
917        _ctx: &LayoutContext,
918    ) {
919        for child in children.iter_mut() {
920            child.origin = bounds.origin();
921            child.size = bounds.size();
922        }
923    }
924
925    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
926        // The inner widget (typically `MenuList`) owns the `Role::Menu`
927        // semantics. A second Menu role here would nest two Menu nodes
928        // per dropdown, confusing screen readers that look for a single
929        // Menu per popup. `GenericContainer` is the ARIA `none`/`presentation`
930        // equivalent: the host is kept in the tree for focus/key routing
931        // but is ignored by assistive tech.
932        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
933    }
934
935    fn children(&self) -> Vec<WidgetId> {
936        self.inner_id.into_iter().collect()
937    }
938}
939
940// ---------------------------------------------------------------------------
941// MenuBar Widget impl
942// ---------------------------------------------------------------------------
943
944impl Widget for MenuBar {
945    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
946        // Mirror the model into the native OS menu bar (macOS) when requested.
947        // The bridge is a no-op without a `NativeMenuHandle` in app-state.
948        if self.native_mode.installs_native()
949            && cfg!(target_os = "macos")
950            && let Some(model) = &self.model
951        {
952            *self.native_binding.borrow_mut() = crate::menu::native::install(model, ctx);
953        }
954
955        // A runtime structural change (`MenuModel::push_item`/`remove`/…) bumps
956        // the model version; rebuild so the in-window dropdowns AND the native
957        // menu re-derive from the new structure.
958        if let Some(model) = &self.model {
959            model.version().bind_to(
960                ctx.self_id(),
961                ctx.binding_registry(),
962                teksilo_core::BindingLevel::Rebuild,
963            );
964        }
965
966        // On macOS with `Suppress`, the global menu bar IS the menu — render
967        // only the optional leading/trailing slots in-window (no triggers, no
968        // F10/Alt dispatcher).
969        if self.native_mode.suppresses_in_window() {
970            return self.build_suppressed(ctx);
971        }
972
973        let theme_signal = ctx.theme_signal();
974
975        let open_index: Signal<Option<usize>> = ctx.signal(None);
976        let menu_ctx = MenuContext::new(open_index);
977
978        // Build the full row: [leading_slot | triggers... | Spacer | trailing_slot]
979        let mut row = HStack::new().spacing(2.0);
980
981        // Leading slot (memoized — the same widgets survive each rebuild)
982        row = Self::add_slot(ctx, row, &mut self.leading_slot, &mut self.leading_slot_ids);
983
984        // Menu triggers + content
985        let mut trigger_ids = Vec::new();
986        let mut content_ids = Vec::new();
987        // Mnemonic table built alongside triggers: `lowercase char →
988        // trigger array index`. Drives the window-level dispatcher
989        // for Alt+letter activation.
990        let mut mnemonic_table: HashMap<char, usize> = HashMap::new();
991
992        // Both bar flavours re-derive their entries every build and re-run
993        // the (Fn) factories, so neither consumes the state it needs to
994        // rebuild: model-built bars re-derive from the (possibly mutated)
995        // model, classic `.menu()` bars iterate their retained entries by
996        // reference. Consuming `self.entries` here (the old `mem::take`)
997        // left the bar empty on the next theme / locale rebuild.
998        let model_entries = self.model.as_ref().map(Self::model_entries);
999        let entries: &[MenuBarEntry] = match &model_entries {
1000            Some(derived) => derived,
1001            None => &self.entries,
1002        };
1003        for (i, entry) in entries.iter().enumerate() {
1004            let parsed: ParsedMnemonic = parse_mnemonic(&entry.label.resolve_now());
1005
1006            // Wrap factory output in MenuOverlayHost for focus/key handling
1007            let host = MenuOverlayHost {
1008                inner: Some((entry.factory)()),
1009                menu_ctx: menu_ctx.clone(),
1010                menu_index: i,
1011                inner_id: None,
1012            };
1013            // Detached: a menu's content is shown through an overlay, never
1014            // inline under the bar. Owned all the same, so a rebuilt menubar
1015            // reaps the menus it replaced instead of stranding one host — and
1016            // its whole `MenuList` — per rebuild.
1017            // Built the first time *this* menu is opened. A menu bar used to
1018            // build every menu's whole `MenuList` — and every submenu under it —
1019            // on each rebuild of the bar, which a locale or shortcut change
1020            // triggers. See `teksilo_core::deferred_subtree::DeferredSubtree`.
1021            let opened_here = menu_ctx.open_index.map(move |open| *open == Some(i));
1022            let content_id = ctx.add_detached_deferred(opened_here, host);
1023            ctx.set_dormant(content_id);
1024
1025            let trigger = MenuBarTrigger {
1026                label: entry.label.clone(),
1027                stripped_name: parsed.stripped.clone(),
1028                mnemonic_key: parsed.key_lower,
1029                index: i,
1030                menu_ctx: menu_ctx.clone(),
1031                root_child_id: None,
1032            };
1033            let trigger_id = ctx.add(trigger);
1034            row = row.add_child(trigger_id);
1035
1036            if let Some(k) = parsed.key_lower {
1037                if let Some(prev) = mnemonic_table.insert(k, i) {
1038                    debug_assert!(
1039                        false,
1040                        "MenuBar: duplicate mnemonic {:?} (triggers {} and {})",
1041                        k, prev, i
1042                    );
1043                }
1044            }
1045
1046            trigger_ids.push(trigger_id);
1047            content_ids.push(content_id);
1048        }
1049
1050        // Register all trigger/content IDs in the context.
1051        // focus_id is initially content_id; MenuOverlayHost::build() will
1052        // overwrite it with the actual inner MenuList ID.
1053        for (i, (&tid, &cid)) in trigger_ids.iter().zip(content_ids.iter()).enumerate() {
1054            menu_ctx.register(i, tid, cid, cid);
1055        }
1056
1057        // Spacer pushes triggers left, trailing slot right
1058        row = row.child(Spacer::new());
1059
1060        // Trailing slot (memoized — the same widgets survive each rebuild)
1061        row = Self::add_slot(
1062            ctx,
1063            row,
1064            &mut self.trailing_slot,
1065            &mut self.trailing_slot_ids,
1066        );
1067
1068        let row_id = ctx.add(row);
1069
1070        let bg = RectWidget::new()
1071            .background(SurfaceRole::Main)
1072            .border_color(theme_signal.map(|t| t.colors.border.with_alpha(0.2)))
1073            .border_width(0.0_f32);
1074        let bg_id = ctx.add(bg);
1075
1076        let padding = Padding::symmetric(0.0, 2.0).child_id(row_id);
1077        let padding_id = ctx.add(padding);
1078
1079        let zstack_id = ctx.add(ZStack::new().add_child(bg_id).add_child(padding_id));
1080        // Shared cell holding the hamburger id once it's built below — the
1081        // `RevealHeightBox` measures it to size the floating bar (filled at
1082        // `anchor_cell.set(...)`, the same pattern as the overlay anchor).
1083        let ham_cell: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
1084        // In collapsible mode the `Role::MenuBar` landmark lives on the
1085        // bar content node (not the composing widget) so it travels into
1086        // the floating overlay AND so `overlay_is_host_surface` treats
1087        // the revealed bar as a host (menu-open dismissal spares it). The
1088        // content is wrapped in a `RevealHeightBox` so the *floating* bar's
1089        // height matches the hamburger button — the triggers center
1090        // vertically (the inner `HStack`'s default `VAlignment::Center`);
1091        // the inline bar keeps its natural height. That, in turn, is
1092        // wrapped in an `Unroll` so the floating bar unrolls out of the
1093        // hamburger on open and rolls back into it on close (driven by
1094        // `reveal_progress`; the overlay owns the tween + dismissal
1095        // deferral — see the reveal closure below). `reveal_progress`
1096        // stays at `1.0` for the inline bar, so `Unroll` is a no-op there.
1097        let root_id = if self.collapse_policy.is_some() {
1098            let height_box = ctx.add(RevealHeightBox {
1099                child_id: None,
1100                pending_child: Some(PendingChild::Id(zstack_id)),
1101                revealed: self.revealed.clone(),
1102                hamburger_id: ham_cell.clone(),
1103            });
1104            // Unrolls trailing-ward from the hamburger's edge (RTL flip is
1105            // a follow-up, matching the docking handle-direction caveat).
1106            ctx.add(
1107                Unroll::from_progress(self.reveal_progress.clone())
1108                    .child_id(height_box)
1109                    .access_role(teksilo_core::accesskit::Role::MenuBar),
1110            )
1111        } else {
1112            zstack_id
1113        };
1114        self.root_child_id = Some(root_id);
1115        self.bar_id = Some(root_id);
1116
1117        // Collapsible (hamburger) mode: build the hamburger button and
1118        // the reveal closure that floats the bar as an overlay; gate
1119        // inline visibility on `collapsed` / `revealed`.
1120        let mut children = vec![root_id];
1121        let collapsible_reveal: Option<MenubarReveal> = if self.collapse_policy.is_some() {
1122            let bar_id = root_id;
1123            let revealed = self.revealed.clone();
1124            let collapsed = self.collapsed.clone();
1125            // Captured at build (EventContext can't reach motion / pref):
1126            // the unroll tween duration and whether to snap. A theme /
1127            // reduced-motion change rebuilds the bar, refreshing both.
1128            let reveal_progress = self.reveal_progress.clone();
1129            let reveal_duration = ctx.theme().motion.duration_collapse;
1130            let reduced_motion = ctx.prefers_reduced_motion();
1131
1132            // The bar overlay trails the hamburger (the developer is
1133            // responsible for placing the hamburger). The anchor cell is
1134            // filled after the button is added, since the reveal closure
1135            // is created before the button id is known.
1136            let anchor_cell: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
1137            // First trigger, focused on reveal so the bar is immediately
1138            // keyboard-navigable (arrows move between menus, Enter opens).
1139            let first_trigger = trigger_ids.first().copied();
1140
1141            let reveal: MenubarReveal = {
1142                let revealed = revealed.clone();
1143                let anchor_cell = anchor_cell.clone();
1144                let reveal_progress = reveal_progress.clone();
1145                Rc::new(move |ctx: &mut EventContext| {
1146                    if revealed.get() {
1147                        return; // idempotent — already revealed
1148                    }
1149                    revealed.set(true);
1150                    ctx.activate(bar_id);
1151                    let anchor = anchor_cell.get().unwrap_or(bar_id);
1152                    let on_dismiss: Rc<dyn Fn()> = {
1153                        let revealed = revealed.clone();
1154                        Rc::new(move || revealed.set(false))
1155                    };
1156                    let request = OverlayRequest {
1157                        content_id: bar_id,
1158                        anchor,
1159                        placement: OverlayPlacement::TrailingEdge,
1160                        dismiss: DismissBehavior::EscapeOrClickOutside,
1161                        layer: OverlayLayer::InTree,
1162                        parent_overlay: None,
1163                        on_dismiss: Some(on_dismiss),
1164                        fade_duration: None,
1165                    };
1166                    if reduced_motion {
1167                        // No tween: show fully unrolled; dismissal is immediate.
1168                        reveal_progress.set(1.0);
1169                        ctx.show_overlay(request);
1170                    } else {
1171                        // Start rolled up, then the overlay tweens 0 → 1 on
1172                        // show and 1 → 0 on close (deferring teardown until
1173                        // the roll-back completes).
1174                        reveal_progress.set(0.0);
1175                        ctx.show_overlay_with_reveal(
1176                            request,
1177                            reveal_progress.clone(),
1178                            reveal_duration,
1179                        );
1180                    }
1181                    if let Some(trigger) = first_trigger {
1182                        ctx.request_focus(trigger);
1183                    }
1184                })
1185            };
1186
1187            // `IconButton::menu()` already advertises `HasPopup::Menu` and
1188            // an accessible name ("Menu"). Binding `expanded_when(revealed)`
1189            // completes the ARIA disclosure pattern: the button reports
1190            // `expanded=true` while the bar is shown, `false` while collapsed.
1191            let hamburger = IconButton::menu()
1192                .size(self.hamburger_size)
1193                .expanded_when(revealed.clone())
1194                .on_activate_fn({
1195                    let reveal = reveal.clone();
1196                    move |ctx| reveal(ctx)
1197                });
1198            let hamburger_id = ctx.add(hamburger);
1199            anchor_cell.set(Some(hamburger_id));
1200            // Let the bar's `RevealHeightBox` measure the hamburger so the
1201            // floating overlay's height matches the button.
1202            ham_cell.set(Some(hamburger_id));
1203            self.hamburger_id = Some(hamburger_id);
1204
1205            // Hamburger visible only while collapsed.
1206            ctx.visible_when(hamburger_id, collapsed.clone());
1207            // Bar active when shown inline (`!collapsed`) OR as the
1208            // floating overlay (`revealed`). Keeping it active while
1209            // revealed prevents the visibility binding from fighting the
1210            // overlay activation.
1211            let bar_active = collapsed.zip(&revealed).map(|(c, r)| !*c || *r);
1212            ctx.visible_when(bar_id, bar_active);
1213
1214            children.push(hamburger_id);
1215            Some(reveal)
1216        } else {
1217            None
1218        };
1219
1220        // Window-level menubar key dispatcher (F10 / Alt+letter /
1221        // Alt-tap). Installed on every platform — `MenuBar` is an
1222        // in-window widget menu, not the OS system menu, so the
1223        // dispatcher's job is to wire framework menus to keyboard
1224        // accelerators regardless of host OS.
1225        //
1226        // **macOS**: the dispatcher's `Alt+letter` branch is compiled
1227        // out (see `MenuBarDispatcher::try_handle`) because the OS
1228        // rewrites Option+letter for accented character composition
1229        // before the app sees the keystroke. F10 and bare-Alt-tap
1230        // continue to fire on macOS through this same dispatcher.
1231        //
1232        // Drop the previous guard BEFORE installing the new one so
1233        // the slot is empty when `install_menubar_dispatcher` runs
1234        // its `debug_assert!(slot.is_none())`. Otherwise a rebuild
1235        // of `MenuBar` (e.g. when a composing ancestor rebuilds)
1236        // trips the assert in debug builds and would over-write the
1237        // slot under another live guard in release.
1238        if self.install_dispatcher
1239            && let Some(window) = ctx.window()
1240        {
1241            *self.menubar_guard.borrow_mut() = None;
1242            let inner = MenuBarDispatcher {
1243                trigger_ids: trigger_ids.clone(),
1244                mnemonic_table,
1245            };
1246            let dispatcher: Rc<dyn MenubarDispatcher> = match collapsible_reveal {
1247                Some(reveal) => Rc::new(CollapsibleMenuBarDispatcher {
1248                    inner,
1249                    collapsed: self.collapsed.clone(),
1250                    reveal,
1251                }),
1252                None => Rc::new(inner),
1253            };
1254            let guard = window.install_menubar_dispatcher(dispatcher);
1255            *self.menubar_guard.borrow_mut() = Some(guard);
1256        }
1257
1258        children
1259    }
1260
1261    fn layout_response(
1262        &self,
1263        proposal: SizeProposal,
1264        ctx: &LayoutContext,
1265    ) -> teksilo_core::widget::LayoutResponse {
1266        // Collapsed: size to the hamburger's natural size (a small box),
1267        // don't stretch to the full allotted width.
1268        if self.collapse_policy.is_some() && self.collapsed.get() {
1269            return match self.hamburger_id {
1270                Some(id) => ctx
1271                    .child_size(id, SizeProposal::unspecified())
1272                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
1273                None => proposal.resolve(0.0, 0.0),
1274            }
1275            .into();
1276        }
1277        match self.root_child_id {
1278            Some(id) => {
1279                let content_proposal = SizeProposal {
1280                    width: proposal.width,
1281                    height: None,
1282                };
1283                let size = ctx
1284                    .child_size(id, content_proposal)
1285                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1286                Size::new(proposal.width.unwrap_or(size.width), size.height)
1287            }
1288            None => proposal.resolve(0.0, 0.0),
1289        }
1290        .into()
1291    }
1292
1293    fn place_children(
1294        &self,
1295        bounds: Rect,
1296        proposal: SizeProposal,
1297        children: &mut [WidgetPlacement],
1298        ctx: &LayoutContext,
1299    ) {
1300        // Responsive collapse decision (Toolbar pattern): compare the
1301        // bar's intrinsic width against the allotted width and toggle
1302        // `collapsed`, idempotently (the guard avoids relayout churn).
1303        if let Some(policy) = self.collapse_policy {
1304            let should_collapse = match policy {
1305                CollapsePolicy::Always => true,
1306                CollapsePolicy::Responsive => {
1307                    if self.revealed.get() {
1308                        // Don't un-collapse while the overlay is up — it
1309                        // would make the bar both inline and floating.
1310                        self.collapsed.get()
1311                    } else if let (Some(bar_id), Some(avail)) = (self.bar_id, proposal.width) {
1312                        ctx.measure_intrinsic(bar_id, SizeProposal::unspecified())
1313                            .map(|s| s.width)
1314                            .unwrap_or(0.0)
1315                            > avail + 0.5
1316                    } else {
1317                        // Unbounded width (or no bar) → never collapse.
1318                        false
1319                    }
1320                }
1321            };
1322            if self.last_collapsed.get() != should_collapse {
1323                self.last_collapsed.set(should_collapse);
1324                self.collapsed.set(should_collapse);
1325            }
1326        }
1327
1328        // The hamburger keeps a constant width: place it at its intrinsic
1329        // size, leading-aligned, so a stretching parent can't widen it.
1330        // Everything else (the bar, inline or as the re-laid overlay) fills
1331        // the bounds; dormant children are skipped by the layout pass.
1332        let collapsed = self.collapse_policy.is_some() && self.collapsed.get();
1333        for child in children.iter_mut() {
1334            if collapsed && Some(child.id) == self.hamburger_id {
1335                let size = ctx
1336                    .measure_intrinsic(child.id, SizeProposal::unspecified())
1337                    .unwrap_or_else(|| bounds.size());
1338                let x = if ctx.is_rtl() {
1339                    bounds.right() - size.width
1340                } else {
1341                    bounds.x
1342                };
1343                child.origin = Point::new(x, bounds.y);
1344                child.size = size;
1345            } else {
1346                child.origin = bounds.origin();
1347                child.size = bounds.size();
1348            }
1349        }
1350    }
1351
1352    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1353        // In collapsible mode the `Role::MenuBar` landmark lives on the
1354        // bar content node so it travels into the floating overlay; the
1355        // composing widget node stays a generic container.
1356        if self.collapse_policy.is_none() {
1357            builder.set_role(teksilo_core::accesskit::Role::MenuBar);
1358        }
1359    }
1360
1361    fn children(&self) -> Vec<WidgetId> {
1362        let mut v: Vec<WidgetId> = self.root_child_id.into_iter().collect();
1363        if let Some(h) = self.hamburger_id {
1364            v.push(h);
1365        }
1366        v
1367    }
1368
1369    /// Reconcile on rebuild. The menu triggers are re-derived fresh each build
1370    /// (the model may have changed) and the reconcile reaps the superseded
1371    /// ones; the memoized leading/trailing slot widgets (see `add_slot`) are
1372    /// re-attached by id and kept alive, so a stateful slot control — a search
1373    /// field, a focused button, an avatar with hover state — survives a
1374    /// model-version / theme / locale rebuild instead of being rebuilt from
1375    /// scratch.
1376    fn preserves_children_on_rebuild(&self) -> bool {
1377        true
1378    }
1379}
1380
1381// ---------------------------------------------------------------------------
1382// RevealHeightBox — match the floating bar's height to the hamburger
1383// ---------------------------------------------------------------------------
1384
1385/// Wraps the collapsible bar's content. While the bar is shown as a
1386/// floating overlay (`revealed == true`) it reports a height equal to the
1387/// hamburger button's measured height, so the floating bar reads as a
1388/// horizontal extension of the hamburger and the menu-trigger text centers
1389/// vertically (the inner `HStack`'s default `VAlignment::Center`). When the
1390/// bar is inline (`revealed == false`) it reports the child's natural size,
1391/// leaving the normal in-window bar unchanged.
1392#[derive(Debug)]
1393struct RevealHeightBox {
1394    child_id: Option<WidgetId>,
1395    pending_child: Option<PendingChild>,
1396    revealed: Signal<bool>,
1397    /// The hamburger `IconButton` id, filled after it is built (the
1398    /// `anchor_cell` pattern). Measuring it — rather than mapping the size
1399    /// table — honours a custom `IconButtonSize` / style for free.
1400    hamburger_id: Rc<Cell<Option<WidgetId>>>,
1401}
1402
1403impl Widget for RevealHeightBox {
1404    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1405        if let Some(pending) = self.pending_child.take() {
1406            self.child_id = Some(match pending {
1407                PendingChild::Id(id) => id,
1408                PendingChild::Deferred(w) => ctx.add_boxed(w),
1409            });
1410        }
1411        // Re-layout when the bar reveals / hides so the height switches
1412        // between hamburger-matched (floating) and natural (inline).
1413        self.revealed.bind_to(
1414            ctx.self_id(),
1415            ctx.binding_registry(),
1416            teksilo_core::binding::BindingLevel::Relayout,
1417        );
1418        self.child_id.into_iter().collect()
1419    }
1420
1421    fn layout_response(
1422        &self,
1423        proposal: SizeProposal,
1424        ctx: &LayoutContext,
1425    ) -> teksilo_core::widget::LayoutResponse {
1426        let child = self.child_id;
1427        if self.revealed.get() {
1428            if let Some(ham) = self.hamburger_id.get() {
1429                if let Some(h) = ctx
1430                    .measure_intrinsic(ham, SizeProposal::unspecified())
1431                    .map(|s| s.height)
1432                {
1433                    let child_w = child
1434                        .and_then(|id| {
1435                            ctx.child_size(
1436                                id,
1437                                SizeProposal {
1438                                    width: proposal.width,
1439                                    height: Some(h),
1440                                },
1441                            )
1442                        })
1443                        .map(|s| s.width)
1444                        .unwrap_or(0.0);
1445                    let w = proposal.width.unwrap_or(child_w);
1446                    return Size::new(w, h).into();
1447                }
1448            }
1449        }
1450        child
1451            .and_then(|id| ctx.child_size(id, proposal))
1452            .unwrap_or(Size::ZERO)
1453            .into()
1454    }
1455
1456    fn place_children(
1457        &self,
1458        bounds: Rect,
1459        _proposal: SizeProposal,
1460        children: &mut [WidgetPlacement],
1461        _ctx: &LayoutContext,
1462    ) {
1463        for child in children.iter_mut() {
1464            child.origin = bounds.origin();
1465            child.size = bounds.size();
1466        }
1467    }
1468
1469    fn children(&self) -> Vec<WidgetId> {
1470        self.child_id.into_iter().collect()
1471    }
1472}
1473
1474// ---------------------------------------------------------------------------
1475// Tests
1476// ---------------------------------------------------------------------------
1477
1478#[cfg(test)]
1479mod tests {
1480    use super::*;
1481    use crate::MenuItem;
1482    use crate::menu_list::MenuList;
1483    use teksilo_core::accesskit::Role;
1484    use teksilo_core::widget_id::WidgetId;
1485    use teksilo_core::widget_tree::WidgetTree;
1486    use teksilo_core::window::state::WindowStateInit;
1487    use teksilo_core::window::{TeksiloWindowId, WindowPlacement, WindowState};
1488    use teksilo_i18n::lit;
1489
1490    fn tree_with_window() -> WidgetTree {
1491        let mut t = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1492        t.set_window_state(WindowState::new(WindowStateInit {
1493            id: TeksiloWindowId::new(1),
1494            string_id: Some("test".to_string()),
1495            placement: WindowPlacement::Floating,
1496            title: "Test".to_string(),
1497            size: (800, 600),
1498            position: (0, 0),
1499            focused: false,
1500            resizable: true,
1501            always_on_top: false,
1502        }));
1503        t
1504    }
1505
1506    /// Total active widgets whose concrete type name contains `needle`.
1507    fn count_by_type(t: &WidgetTree, needle: &str) -> u32 {
1508        t.widget_type_histogram()
1509            .iter()
1510            .filter(|(name, _)| name.contains(needle))
1511            .map(|(_, n)| *n)
1512            .sum()
1513    }
1514
1515    /// Distinctly-typed leaf used to prove a leading/trailing slot's
1516    /// content survives a rebuild (its type can't collide with the bar's
1517    /// own internal widgets).
1518    #[derive(Debug)]
1519    struct SlotMarker;
1520    impl Widget for SlotMarker {
1521        fn layout_response(
1522            &self,
1523            proposal: SizeProposal,
1524            _ctx: &LayoutContext,
1525        ) -> teksilo_core::widget::LayoutResponse {
1526            proposal.resolve(12.0, 12.0).into()
1527        }
1528    }
1529
1530    /// Slot leaf that records how many times it was built and its widget id —
1531    /// to prove a stateful slot is *preserved* (built once, same instance),
1532    /// not rebuilt, across a MenuBar rebuild.
1533    #[derive(Debug)]
1534    struct CountingSlot {
1535        builds: std::rc::Rc<std::cell::Cell<u32>>,
1536        id_out: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
1537    }
1538    impl Widget for CountingSlot {
1539        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1540            self.builds.set(self.builds.get() + 1);
1541            self.id_out.set(Some(ctx.self_id()));
1542            vec![]
1543        }
1544        fn layout_response(
1545            &self,
1546            proposal: SizeProposal,
1547            _ctx: &LayoutContext,
1548        ) -> teksilo_core::widget::LayoutResponse {
1549            proposal.resolve(12.0, 12.0).into()
1550        }
1551    }
1552
1553    fn first_descendant_with_role(t: &WidgetTree, from: WidgetId, role: Role) -> Option<WidgetId> {
1554        let mut queue = std::collections::VecDeque::new();
1555        queue.push_back(from);
1556        while let Some(id) = queue.pop_front() {
1557            if t.accessibility_node(id).role() == role {
1558                return Some(id);
1559            }
1560            for child in t.children(id) {
1561                queue.push_back(child);
1562            }
1563        }
1564        None
1565    }
1566
1567    fn collect_descendants_with_role(t: &WidgetTree, from: WidgetId, role: Role) -> Vec<WidgetId> {
1568        let mut queue = std::collections::VecDeque::new();
1569        let mut out = Vec::new();
1570        queue.push_back(from);
1571        while let Some(id) = queue.pop_front() {
1572            if t.accessibility_node(id).role() == role {
1573                out.push(id);
1574            }
1575            for child in t.children(id) {
1576                queue.push_back(child);
1577            }
1578        }
1579        out
1580    }
1581
1582    /// Collect the RGB (0..=255) of every glyph painted by a one-pass
1583    /// render of a light-themed MenuBar carrying `&File` / `&Edit`. In a
1584    /// bare tree the only text is the two trigger labels, so the returned
1585    /// colours ARE the trigger label colours. `use_model` switches between
1586    /// the direct `.menu()` builder and the `from_model` path.
1587    fn light_menubar_trigger_glyph_rgb(use_model: bool) -> Vec<[u32; 3]> {
1588        let mut t = WidgetTree::new()
1589            .with_theme(teksilo_core::presets::intui::light())
1590            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1591                teksilo_canvas::MockTextBackend::new(),
1592            )));
1593        t.set_window_state(WindowState::new(WindowStateInit {
1594            id: TeksiloWindowId::new(1),
1595            string_id: Some("test".to_string()),
1596            placement: WindowPlacement::Floating,
1597            title: "Test".to_string(),
1598            size: (800, 600),
1599            position: (0, 0),
1600            focused: false,
1601            resizable: true,
1602            always_on_top: false,
1603        }));
1604        if use_model {
1605            let model = crate::menu::MenuModel::new()
1606                .menu(lit!("&File"), |m| m)
1607                .menu(lit!("&Edit"), |m| m);
1608            t.add(MenuBar::from_model(model));
1609        } else {
1610            t.add(
1611                MenuBar::new()
1612                    .menu(lit!("&File"), || Box::new(MenuList::new()))
1613                    .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1614            );
1615        }
1616        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1617        let frame = t.render();
1618        frame
1619            .glyphs
1620            .iter()
1621            .map(|g| {
1622                [
1623                    (g.color[0] * 255.0).round() as u32,
1624                    (g.color[1] * 255.0).round() as u32,
1625                    (g.color[2] * 255.0).round() as u32,
1626                ]
1627            })
1628            .collect()
1629    }
1630
1631    /// Regression: the top-level trigger labels must paint in the ACTIVE
1632    /// theme's `text_primary`, never a stale constructor-default theme.
1633    ///
1634    /// Historical bug: a light-launched app rendered the "File" / "Edit"
1635    /// trigger labels in the *dark* theme's grey `text_primary` (#DFE1E5),
1636    /// invisible on a light bar, while the dropdowns rendered fine. Cause:
1637    /// the trigger colour is a `theme_signal.map(...)` derived signal, and
1638    /// `WidgetTree::with_theme` updated the cached `Theme` (seen by
1639    /// `ctx.theme()` / role resolution) but NOT `theme_signal`, which stayed
1640    /// at the constructor default. The first `set_theme` (e.g. a dark→light
1641    /// toggle) re-aligned the signal, which is why the bug self-healed on a
1642    /// theme switch. Fixed by keeping `theme` + `theme_signal` in lockstep
1643    /// and defaulting the constructor to light. Covers both trigger build
1644    /// paths.
1645    #[test]
1646    fn trigger_labels_paint_in_active_theme_color() {
1647        let rgb_of = |c: teksilo_tokens::Color| {
1648            let a = c.to_array();
1649            [
1650                (a[0] * 255.0).round() as u32,
1651                (a[1] * 255.0).round() as u32,
1652                (a[2] * 255.0).round() as u32,
1653            ]
1654        };
1655        let light_rgb = rgb_of(teksilo_core::presets::intui::light().colors.text_primary);
1656        let dark_rgb = rgb_of(teksilo_core::presets::intui::dark().colors.text_primary);
1657        assert_ne!(
1658            light_rgb, dark_rgb,
1659            "presets must differ for this test to mean anything"
1660        );
1661
1662        for use_model in [false, true] {
1663            let glyphs = light_menubar_trigger_glyph_rgb(use_model);
1664            assert!(
1665                !glyphs.is_empty(),
1666                "expected trigger label glyphs (use_model={use_model})"
1667            );
1668            for rgb in &glyphs {
1669                assert_eq!(
1670                    *rgb, light_rgb,
1671                    "trigger label glyph must use the active (light) theme's text_primary, \
1672                     not a stale constructor-default theme (use_model={use_model})"
1673                );
1674            }
1675        }
1676    }
1677
1678    /// A `MockTextBackend` wrapper that models the typesetter's glyph-cache
1679    /// eviction: while `evicted` is set, `ensure_glyphs` returns nothing
1680    /// (as the real bridge does once a cached layout's glyphs are dropped),
1681    /// and `layout_single_line` clears the flag (re-shaping repopulates the
1682    /// cache, mirroring the real bridge). Lets a headless test reproduce the
1683    /// "menu labels vanish under atlas pressure" bug deterministically.
1684    struct EvictingTextBackend {
1685        inner: teksilo_canvas::MockTextBackend,
1686        evicted: std::rc::Rc<std::cell::Cell<bool>>,
1687    }
1688
1689    impl teksilo_canvas::TextBackend for EvictingTextBackend {
1690        fn layout_single_line(
1691            &mut self,
1692            text: &str,
1693            style: &teksilo_tokens::TextStyle,
1694            max_width: Option<f32>,
1695        ) -> teksilo_canvas::TextLayout {
1696            // Re-shaping repopulates the glyph cache → no longer evicted.
1697            self.evicted.set(false);
1698            self.inner.layout_single_line(text, style, max_width)
1699        }
1700
1701        fn ensure_glyphs(
1702            &mut self,
1703            layout: &teksilo_canvas::TextLayout,
1704        ) -> Vec<teksilo_canvas::GlyphQuad> {
1705            if self.evicted.get() {
1706                Vec::new()
1707            } else {
1708                self.inner.ensure_glyphs(layout)
1709            }
1710        }
1711    }
1712
1713    /// Regression: a trigger label must keep rendering after the typesetter
1714    /// evicts its cached layout's glyphs. Under atlas pressure (a text-heavy
1715    /// window) the renderer's eviction-recovery path clears the bridge's
1716    /// glyph cache and re-paints WITHOUT re-laying-out, so `MenuLabel`'s
1717    /// retained `TextLayout` no longer resolves and `draw_text_layout` draws
1718    /// nothing — the labels silently vanished until the next relayout (a
1719    /// theme switch). The fix re-shapes through `draw_text` when the cached
1720    /// draw produces no glyphs.
1721    #[test]
1722    fn trigger_labels_survive_glyph_cache_eviction() {
1723        let evicted = std::rc::Rc::new(std::cell::Cell::new(false));
1724        let backend = EvictingTextBackend {
1725            inner: teksilo_canvas::MockTextBackend::new(),
1726            evicted: evicted.clone(),
1727        };
1728        let mut t = WidgetTree::new()
1729            .with_theme(teksilo_core::presets::intui::light())
1730            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
1731        t.set_window_state(WindowState::new(WindowStateInit {
1732            id: TeksiloWindowId::new(1),
1733            string_id: Some("test".to_string()),
1734            placement: WindowPlacement::Floating,
1735            title: "Test".to_string(),
1736            size: (800, 600),
1737            position: (0, 0),
1738            focused: false,
1739            resizable: true,
1740            always_on_top: false,
1741        }));
1742        t.add(
1743            MenuBar::new()
1744                .menu(lit!("&File"), || Box::new(MenuList::new()))
1745                .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1746        );
1747        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1748        let glyphs_initial = t.render().glyphs.len();
1749        assert!(glyphs_initial > 0, "trigger labels must render initially");
1750
1751        // Mimic the eviction-recovery path: the bridge's glyph cache is
1752        // cleared (so the retained layout's glyphs are gone) and the tree
1753        // is re-painted WITHOUT a relayout.
1754        evicted.set(true);
1755        t.invalidate_all_paints();
1756        let glyphs_after = t.render().glyphs.len();
1757        assert!(
1758            glyphs_after > 0,
1759            "trigger labels must survive glyph-cache eviction (re-shape fallback); \
1760             got {glyphs_after} glyphs after eviction"
1761        );
1762    }
1763
1764    #[test]
1765    fn menubar_emits_role_menubar() {
1766        let mut t = tree_with_window();
1767        let mb = t.add(
1768            MenuBar::new()
1769                .menu(lit!("&File"), || Box::new(MenuList::new()))
1770                .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1771        );
1772        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1773        assert_eq!(t.accessibility_node(mb).role(), Role::MenuBar);
1774    }
1775
1776    #[test]
1777    fn trigger_uses_stripped_name_in_at() {
1778        let mut t = tree_with_window();
1779        let mb = t.add(
1780            MenuBar::new()
1781                .menu(lit!("&File"), || Box::new(MenuList::new()))
1782                .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1783        );
1784        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1785        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
1786        assert_eq!(triggers.len(), 2);
1787        // The stripped name "File" / "Edit", NOT "&File" / "&Edit".
1788        let info0 = t.accessibility_node(triggers[0]);
1789        let info1 = t.accessibility_node(triggers[1]);
1790        assert_eq!(info0.name(), Some("File"));
1791        assert_eq!(info1.name(), Some("Edit"));
1792    }
1793
1794    #[test]
1795    fn trigger_arrow_navigation_ltr_right_goes_to_next() {
1796        let mut t = tree_with_window();
1797        let mb = t.add(
1798            MenuBar::new()
1799                .menu(lit!("&File"), || Box::new(MenuList::new()))
1800                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
1801                .menu(lit!("&View"), || Box::new(MenuList::new())),
1802        );
1803        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1804        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
1805        assert_eq!(triggers.len(), 3);
1806
1807        // From the File trigger, ArrowRight opens the next (Edit) menu in LTR.
1808        t.focus(triggers[0]);
1809        t.press_key(Key::ArrowRight, Modifiers::NONE);
1810        assert!(t.accessibility_node(triggers[1]).is_expanded());
1811        assert!(!t.accessibility_node(triggers[0]).is_expanded());
1812    }
1813
1814    #[test]
1815    fn trigger_arrow_navigation_rtl_right_goes_to_previous() {
1816        let mut t = tree_with_window();
1817        t.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
1818        let mb = t.add(
1819            MenuBar::new()
1820                .menu(lit!("&File"), || Box::new(MenuList::new()))
1821                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
1822                .menu(lit!("&View"), || Box::new(MenuList::new())),
1823        );
1824        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1825        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
1826        assert_eq!(triggers.len(), 3);
1827
1828        // Under RTL the bar runs right-to-left, so ArrowRight moves to the
1829        // *previous* menu — from File (index 0) that wraps to View (index 2).
1830        t.focus(triggers[0]);
1831        t.press_key(Key::ArrowRight, Modifiers::NONE);
1832        assert!(t.accessibility_node(triggers[2]).is_expanded());
1833        assert!(!t.accessibility_node(triggers[0]).is_expanded());
1834    }
1835
1836    #[test]
1837    fn dispatcher_installed_on_every_platform() {
1838        let mut t = tree_with_window();
1839        t.add(
1840            MenuBar::new()
1841                .menu(lit!("&File"), || Box::new(MenuList::new()))
1842                .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1843        );
1844        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1845        let window = t.window_state().expect("window state attached");
1846        assert!(
1847            window.menubar_dispatcher().is_some(),
1848            "MenuBar should install the window-level dispatcher on every \
1849             platform — framework menus aren't the OS system menu and need \
1850             keyboard accelerators wired regardless of host OS"
1851        );
1852    }
1853
1854    #[test]
1855    fn rebuilding_menubar_does_not_double_install_dispatcher() {
1856        // Regression: `install_menubar_dispatcher` debug_asserts that
1857        // the slot is empty before installing. The old `MenuBar::build`
1858        // implementation called install while the previous build's
1859        // `MenubarGuard` was still alive in `self.menubar_guard`,
1860        // which tripped the assert on every rebuild. Fixed by
1861        // dropping the old guard FIRST.
1862        let mut t = tree_with_window();
1863        let mb = t.add(
1864            MenuBar::new()
1865                .menu(lit!("&File"), || Box::new(MenuList::new()))
1866                .menu(lit!("&Edit"), || Box::new(MenuList::new())),
1867        );
1868        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1869        assert!(t.window_state().unwrap().menubar_dispatcher().is_some());
1870        // An empty bar still has a (now-empty) dispatcher, so assert the
1871        // menus themselves are present — the dispatcher check alone would
1872        // pass straight through a bar that self-emptied on rebuild.
1873        assert_eq!(
1874            count_by_type(&t, "MenuBarTrigger"),
1875            2,
1876            "two menus before rebuild"
1877        );
1878        // Force a rebuild and confirm the dispatcher install path
1879        // doesn't crash (debug builds) or silently overwrite a live
1880        // guard (release builds).
1881        t.arena_mark_needs_rebuild_for_testing(mb);
1882        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1883        assert!(
1884            t.window_state().unwrap().menubar_dispatcher().is_some(),
1885            "after rebuild the dispatcher slot must still point at \
1886             the most-recently-installed dispatcher"
1887        );
1888        assert_eq!(
1889            count_by_type(&t, "MenuBarTrigger"),
1890            2,
1891            "classic .menu() bar must keep its menus across a rebuild \
1892             (regression: build() used to mem::take the entries, leaving \
1893             an empty bar on the next theme/locale rebuild)"
1894        );
1895    }
1896
1897    #[test]
1898    fn menubar_slots_survive_rebuild() {
1899        // Regression: leading_slot / trailing_slot were drain(..)-ed on
1900        // every build, so a bar with an app icon (leading) or a search /
1901        // avatar (trailing) lost those slots on a theme / locale / model
1902        // rebuild — even for reactive model-based bars, which otherwise
1903        // re-derive their menus correctly.
1904        let mut t = tree_with_window();
1905        let mb = t.add(
1906            MenuBar::new()
1907                .menu(lit!("&File"), || Box::new(MenuList::new()))
1908                .leading_slot(SlotMarker)
1909                .trailing_slot(SlotMarker),
1910        );
1911        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1912        assert_eq!(
1913            count_by_type(&t, "SlotMarker"),
1914            2,
1915            "both slots before rebuild"
1916        );
1917        assert_eq!(count_by_type(&t, "MenuBarTrigger"), 1);
1918
1919        t.arena_mark_needs_rebuild_for_testing(mb);
1920        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1921        assert_eq!(
1922            count_by_type(&t, "SlotMarker"),
1923            2,
1924            "leading + trailing slots must survive a rebuild"
1925        );
1926        assert_eq!(count_by_type(&t, "MenuBarTrigger"), 1, "menu survives too");
1927    }
1928
1929    #[test]
1930    fn model_menubar_slots_survive_first_layout_self_rebuild() {
1931        // A `from_model` bar binds `model.version()` at `BindingLevel::Rebuild`,
1932        // so it re-runs build() once during the very first layout pass. With the
1933        // old drain(..) slots, that self-rebuild emptied them before the first
1934        // frame ever painted — a model bar's leading/trailing slots rendered for
1935        // zero frames. A single layout must leave both slots present.
1936        let file = teksilo_core::MenuItemId::next();
1937        let model = crate::menu::MenuModel::new().menu_with_id(file, lit!("File"), |m| {
1938            m.item(crate::menu::MenuEntry::new(lit!("New")))
1939        });
1940        let mut t = tree_with_window();
1941        let _mb = t.add(
1942            MenuBar::from_model(model.clone())
1943                .leading_slot(SlotMarker)
1944                .trailing_slot(SlotMarker),
1945        );
1946        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1947        assert_eq!(
1948            count_by_type(&t, "SlotMarker"),
1949            2,
1950            "model bar's slots must survive the self-rebuild on first layout"
1951        );
1952
1953        // And they survive a subsequent model mutation (another rebuild).
1954        model.push_item(file, crate::menu::MenuEntry::new(lit!("Open")));
1955        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1956        assert_eq!(
1957            count_by_type(&t, "SlotMarker"),
1958            2,
1959            "slots survive a model-mutation rebuild too"
1960        );
1961    }
1962
1963    #[test]
1964    fn model_menubar_preserves_stateful_slot_across_rebuild() {
1965        // The follow-on capability: a stateful slot control keeps its identity
1966        // (built once, same WidgetId) across a model-driven rebuild — the
1967        // memoized slot is re-attached, not reconstructed, so its internal
1968        // state (focus, caret, scroll) is preserved. Adding a second top-level
1969        // menu proves the bar genuinely rebuilt (trigger count 1 → 2) while the
1970        // slot's build count stays 1.
1971        let builds = std::rc::Rc::new(std::cell::Cell::new(0u32));
1972        let id_out = std::rc::Rc::new(std::cell::Cell::new(None));
1973        let file = teksilo_core::MenuItemId::next();
1974        let model = crate::menu::MenuModel::new().menu_with_id(file, lit!("File"), |m| {
1975            m.item(crate::menu::MenuEntry::new(lit!("New")))
1976        });
1977        let mut t = tree_with_window();
1978        t.add(
1979            MenuBar::from_model(model.clone()).leading_slot(CountingSlot {
1980                builds: builds.clone(),
1981                id_out: id_out.clone(),
1982            }),
1983        );
1984        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1985        let first_id = id_out.get().expect("slot built");
1986        assert_eq!(builds.get(), 1, "slot built exactly once initially");
1987        assert_eq!(count_by_type(&t, "MenuBarTrigger"), 1);
1988
1989        // Structural model change → MenuBar rebuild.
1990        model.push_menu(lit!("Edit"), |m| {
1991            m.item(crate::menu::MenuEntry::new(lit!("Undo")))
1992        });
1993        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
1994
1995        assert_eq!(
1996            count_by_type(&t, "MenuBarTrigger"),
1997            2,
1998            "the bar rebuilt (a second menu trigger appeared)"
1999        );
2000        assert_eq!(
2001            builds.get(),
2002            1,
2003            "the stateful slot was preserved, not rebuilt, across the rebuild"
2004        );
2005        assert_eq!(
2006            id_out.get(),
2007            Some(first_id),
2008            "the slot kept its identity (same widget instance)"
2009        );
2010    }
2011
2012    #[test]
2013    fn windowstate_dispatcher_slot_reinstall_after_guard_drop() {
2014        // Direct unit test of the WindowState slot lifecycle —
2015        // installing a second dispatcher after dropping the first
2016        // guard must succeed without a debug_assert.
2017        use teksilo_core::window::{MenubarAction, MenubarDispatcher, MenubarKeyEvent};
2018
2019        struct Noop;
2020        impl MenubarDispatcher for Noop {
2021            fn try_handle(&self, _ev: &MenubarKeyEvent) -> Option<MenubarAction> {
2022                None
2023            }
2024        }
2025
2026        let mut t = tree_with_window();
2027        let window = t.window_state().unwrap().clone();
2028        let guard_a = window.install_menubar_dispatcher(Rc::new(Noop));
2029        assert!(window.menubar_dispatcher().is_some());
2030        drop(guard_a);
2031        assert!(
2032            window.menubar_dispatcher().is_none(),
2033            "dropping the guard must clear the slot"
2034        );
2035        let _guard_b = window.install_menubar_dispatcher(Rc::new(Noop));
2036        assert!(
2037            window.menubar_dispatcher().is_some(),
2038            "second install after first guard's drop must succeed without an assert"
2039        );
2040        let _ = &mut t;
2041    }
2042
2043    // --- Pure-function dispatcher tests (platform-independent) ---
2044
2045    /// Fabricate a `WidgetId` from a numeric tag for tests that don't
2046    /// need a real arena. Mirrors the convention used across
2047    /// `teksilo-core`'s signal / overlay tests.
2048    fn fake_id(n: u64) -> WidgetId {
2049        slotmap::KeyData::from_ffi(n).into()
2050    }
2051
2052    fn make_dispatcher() -> MenuBarDispatcher {
2053        let mut mnemonic_table = HashMap::new();
2054        mnemonic_table.insert('f', 0);
2055        mnemonic_table.insert('e', 1);
2056        mnemonic_table.insert('v', 2);
2057        MenuBarDispatcher {
2058            trigger_ids: vec![fake_id(10), fake_id(11), fake_id(12)],
2059            mnemonic_table,
2060        }
2061    }
2062
2063    #[test]
2064    fn dispatcher_f10_focuses_first_trigger() {
2065        let d = make_dispatcher();
2066        let action = d.try_handle(&MenubarKeyEvent {
2067            key: Key::F10,
2068            modifiers: Modifiers::NONE,
2069        });
2070        assert!(matches!(
2071            action,
2072            Some(MenubarAction::FocusTrigger { trigger_id, .. }) if trigger_id == fake_id(10)
2073        ));
2074    }
2075
2076    #[test]
2077    fn dispatcher_f10_with_modifier_ignored() {
2078        let d = make_dispatcher();
2079        let action = d.try_handle(&MenubarKeyEvent {
2080            key: Key::F10,
2081            modifiers: Modifiers::CTRL,
2082        });
2083        assert!(action.is_none());
2084    }
2085
2086    // Alt+letter is intentionally unwired on macOS — the OS rewrites
2087    // Option+letter for accented input before the app sees the
2088    // keystroke, so the dispatcher's Alt branch is compiled out
2089    // there. These tests assert the Win32 / GTK semantic.
2090    #[cfg(not(target_os = "macos"))]
2091    #[test]
2092    fn dispatcher_alt_letter_opens_matching_menu() {
2093        let d = make_dispatcher();
2094        let action = d.try_handle(&MenubarKeyEvent {
2095            key: Key::F,
2096            modifiers: Modifiers::ALT,
2097        });
2098        assert!(matches!(
2099            action,
2100            Some(MenubarAction::OpenMenu { trigger_id, .. }) if trigger_id == fake_id(10)
2101        ));
2102    }
2103
2104    #[cfg(not(target_os = "macos"))]
2105    #[test]
2106    fn dispatcher_alt_letter_no_match_intercepts() {
2107        let d = make_dispatcher();
2108        let action = d.try_handle(&MenubarKeyEvent {
2109            key: Key::Q,
2110            modifiers: Modifiers::ALT,
2111        });
2112        assert!(matches!(action, Some(MenubarAction::Intercept)));
2113    }
2114
2115    #[test]
2116    fn dispatcher_alt_unrelated_key_ignored() {
2117        // Modifier != bare Alt → no menubar action. We use Modifiers::CTRL
2118        // here because constructing a multi-modifier value isn't part
2119        // of the public Modifiers API; the dispatcher relies on exact
2120        // equality with `Modifiers::ALT`.
2121        let d = make_dispatcher();
2122        let action = d.try_handle(&MenubarKeyEvent {
2123            key: Key::F,
2124            modifiers: Modifiers::CTRL,
2125        });
2126        assert!(action.is_none());
2127    }
2128
2129    #[cfg(not(target_os = "macos"))]
2130    #[test]
2131    fn dispatcher_case_insensitive_alt_letter() {
2132        let d = make_dispatcher();
2133        // Lowercase 'f' and uppercase 'F' both open the matching menu.
2134        let lower = d.try_handle(&MenubarKeyEvent {
2135            key: Key::Character('f'),
2136            modifiers: Modifiers::ALT,
2137        });
2138        let upper = d.try_handle(&MenubarKeyEvent {
2139            key: Key::Character('F'),
2140            modifiers: Modifiers::ALT,
2141        });
2142        assert!(matches!(lower, Some(MenubarAction::OpenMenu { .. })));
2143        assert!(matches!(upper, Some(MenubarAction::OpenMenu { .. })));
2144    }
2145
2146    #[cfg(target_os = "macos")]
2147    #[test]
2148    fn dispatcher_alt_letter_does_not_intercept_on_macos() {
2149        // macOS-specific: the dispatcher must NOT intercept Alt+letter
2150        // because the OS rewrites it for accented character input;
2151        // intercepting would silently break text input.
2152        let d = make_dispatcher();
2153        let action = d.try_handle(&MenubarKeyEvent {
2154            key: Key::F,
2155            modifiers: Modifiers::ALT,
2156        });
2157        assert!(
2158            action.is_none(),
2159            "macOS: Alt+letter must fall through to focus dispatch \
2160             so accented character input still works in text fields"
2161        );
2162    }
2163
2164    #[test]
2165    fn dispatcher_alt_tap_focuses_first_trigger() {
2166        let d = make_dispatcher();
2167        let action = d.on_alt_tap();
2168        assert!(matches!(
2169            action,
2170            Some(MenubarAction::FocusTrigger { trigger_id, .. }) if trigger_id == fake_id(10)
2171        ));
2172    }
2173
2174    #[test]
2175    fn dispatcher_alt_tap_with_no_triggers_is_none() {
2176        let d = MenuBarDispatcher {
2177            trigger_ids: Vec::new(),
2178            mnemonic_table: HashMap::new(),
2179        };
2180        assert!(d.on_alt_tap().is_none());
2181        assert!(
2182            d.try_handle(&MenubarKeyEvent {
2183                key: Key::F10,
2184                modifiers: Modifiers::NONE,
2185            })
2186            .is_none()
2187        );
2188    }
2189
2190    // ── Collapsible (hamburger) mode ─────────────────────────────────────
2191
2192    fn collapsible_tree() -> WidgetTree {
2193        let mut t = WidgetTree::new()
2194            .with_theme(teksilo_core::presets::intui::light())
2195            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
2196                teksilo_canvas::MockTextBackend::new(),
2197            )));
2198        t.set_window_state(WindowState::new(WindowStateInit {
2199            id: TeksiloWindowId::new(1),
2200            string_id: Some("test".to_string()),
2201            placement: WindowPlacement::Floating,
2202            title: "Test".to_string(),
2203            size: (800, 600),
2204            position: (0, 0),
2205            focused: false,
2206            resizable: true,
2207            always_on_top: false,
2208        }));
2209        t
2210    }
2211
2212    #[test]
2213    fn collapsible_always_shows_hamburger() {
2214        let mut t = collapsible_tree();
2215        let mb_widget = MenuBar::new()
2216            .menu(lit!("&File"), || Box::new(MenuList::new()))
2217            .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2218            .collapse_policy(CollapsePolicy::Always);
2219        let collapsed = mb_widget.is_collapsed();
2220        let mb = t.add(mb_widget);
2221        // Two passes: pass 1 sets `collapsed`, pass 2 settles visibility.
2222        t.layout(SizeProposal::exact(800.0, 100.0));
2223        t.layout(SizeProposal::exact(800.0, 100.0));
2224        assert!(collapsed.get(), "Always policy must collapse to hamburger");
2225        let children = t.children(mb);
2226        assert_eq!(children.len(), 2, "[bar, hamburger]");
2227        let (bar, hamburger) = (children[0], children[1]);
2228        assert!(t.is_active(hamburger), "hamburger active when collapsed");
2229        assert!(
2230            !t.is_active(bar),
2231            "bar dormant when collapsed and not revealed"
2232        );
2233    }
2234
2235    /// The hamburger keeps a constant (intrinsic) width even when a
2236    /// stretching parent hands the collapsed MenuBar a much wider slot.
2237    #[test]
2238    fn collapsible_hamburger_keeps_constant_width_in_wide_slot() {
2239        use crate::primitives::FixedSize;
2240        let mut t = collapsible_tree();
2241        let mb = t.add(
2242            MenuBar::new()
2243                .menu(lit!("&File"), || Box::new(MenuList::new()))
2244                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2245                .collapse_policy(CollapsePolicy::Always),
2246        );
2247        // FixedSize fills its child to 600px wide.
2248        let _slot = t.add(FixedSize::new().width(600.0_f32).child_id(mb));
2249        t.layout(SizeProposal::exact(800.0, 100.0));
2250        t.layout(SizeProposal::exact(800.0, 100.0));
2251
2252        let hamburger = t.children(mb)[1];
2253        let hw = t.bounds(hamburger).width;
2254        assert!(
2255            hw > 0.0 && hw < 200.0,
2256            "hamburger width {hw} must stay compact, not fill the 600px slot"
2257        );
2258    }
2259
2260    #[test]
2261    fn collapsible_responsive_collapses_when_narrow() {
2262        let mut t = collapsible_tree();
2263        let mb_widget = MenuBar::new()
2264            .menu(lit!("&File"), || Box::new(MenuList::new()))
2265            .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2266            .menu(lit!("&View"), || Box::new(MenuList::new()))
2267            .collapsible();
2268        let collapsed = mb_widget.is_collapsed();
2269        let _mb = t.add(mb_widget);
2270        t.layout(SizeProposal::exact(40.0, 100.0));
2271        t.layout(SizeProposal::exact(40.0, 100.0));
2272        assert!(collapsed.get(), "narrow width must collapse to hamburger");
2273    }
2274
2275    #[test]
2276    fn collapsible_responsive_expands_when_wide() {
2277        let mut t = collapsible_tree();
2278        let mb_widget = MenuBar::new()
2279            .menu(lit!("&File"), || Box::new(MenuList::new()))
2280            .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2281            .collapsible();
2282        let collapsed = mb_widget.is_collapsed();
2283        let mb = t.add(mb_widget);
2284        t.layout(SizeProposal::exact(800.0, 100.0));
2285        t.layout(SizeProposal::exact(800.0, 100.0));
2286        assert!(!collapsed.get(), "wide width must show the inline bar");
2287        let children = t.children(mb);
2288        assert!(t.is_active(children[0]), "bar active inline when wide");
2289        assert!(
2290            !t.is_active(children[1]),
2291            "hamburger dormant when bar is inline"
2292        );
2293    }
2294
2295    /// A collapsible MenuBar wide enough on its own becomes a hamburger
2296    /// once its allotted width drops below the bar's intrinsic width.
2297    #[test]
2298    fn collapsible_responsive_toggles_with_width() {
2299        let mut t = collapsible_tree();
2300        let mb_widget = MenuBar::new()
2301            .menu(lit!("&File"), || Box::new(MenuList::new()))
2302            .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2303            .menu(lit!("&View"), || Box::new(MenuList::new()))
2304            .collapsible();
2305        let collapsed = mb_widget.is_collapsed();
2306        let _mb = t.add(mb_widget);
2307
2308        t.layout(SizeProposal::exact(800.0, 100.0));
2309        t.layout(SizeProposal::exact(800.0, 100.0));
2310        assert!(!collapsed.get(), "wide → inline");
2311
2312        t.layout(SizeProposal::exact(30.0, 100.0));
2313        t.layout(SizeProposal::exact(30.0, 100.0));
2314        assert!(collapsed.get(), "narrow → hamburger");
2315
2316        t.layout(SizeProposal::exact(800.0, 100.0));
2317        t.layout(SizeProposal::exact(800.0, 100.0));
2318        assert!(!collapsed.get(), "wide again → inline");
2319    }
2320
2321    #[test]
2322    fn collapsible_click_hamburger_reveals_bar_overlay() {
2323        let mut t = collapsible_tree();
2324        let mb = t.add(
2325            MenuBar::new()
2326                .menu(lit!("&File"), || Box::new(MenuList::new()))
2327                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2328                .collapse_policy(CollapsePolicy::Always),
2329        );
2330        t.layout(SizeProposal::exact(800.0, 100.0));
2331        t.layout(SizeProposal::exact(800.0, 100.0));
2332        let children = t.children(mb);
2333        let (bar, hamburger) = (children[0], children[1]);
2334        assert!(!t.is_active(bar), "bar hidden before reveal");
2335
2336        t.click(hamburger);
2337        t.layout(SizeProposal::exact(800.0, 100.0));
2338        assert!(t.is_active(bar), "clicking the hamburger reveals the bar");
2339    }
2340
2341    #[test]
2342    fn collapsible_reveal_focuses_first_trigger() {
2343        let mut t = collapsible_tree();
2344        let mb = t.add(
2345            MenuBar::new()
2346                .menu(lit!("&File"), || Box::new(MenuList::new()))
2347                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2348                .collapse_policy(CollapsePolicy::Always),
2349        );
2350        t.layout(SizeProposal::exact(800.0, 100.0));
2351        t.layout(SizeProposal::exact(800.0, 100.0));
2352        let hamburger = t.children(mb)[1];
2353
2354        t.click(hamburger);
2355        t.layout(SizeProposal::exact(800.0, 100.0));
2356
2357        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2358        assert_eq!(triggers.len(), 2);
2359        assert_eq!(
2360            t.focused(),
2361            Some(triggers[0]),
2362            "revealing the bar focuses the first menu trigger"
2363        );
2364    }
2365
2366    /// Regression: ArrowLeft must navigate to the PREVIOUS top-level menu
2367    /// in the revealed bar, not close the current one. The bar is itself a
2368    /// host overlay, so the dispatch-level "overlay back" key (ArrowLeft in
2369    /// LTR) must not mistake an open top-level menu for a nested submenu.
2370    #[test]
2371    fn collapsible_revealed_bar_left_navigates_not_closes() {
2372        let menu = |label: &'static str| {
2373            move || -> Box<dyn Widget> {
2374                Box::new(MenuList::new().item(crate::menu_item::MenuItem::new(lit!(label))))
2375            }
2376        };
2377        let mut t = collapsible_tree();
2378        let mb = t.add(
2379            MenuBar::new()
2380                .menu(lit!("&File"), menu("New"))
2381                .menu(lit!("&Edit"), menu("Undo"))
2382                .menu(lit!("&View"), menu("Zoom"))
2383                .collapse_policy(CollapsePolicy::Always),
2384        );
2385        t.layout(SizeProposal::exact(800.0, 100.0));
2386        t.layout(SizeProposal::exact(800.0, 100.0));
2387        let hamburger = t.children(mb)[1];
2388        t.click(hamburger);
2389        t.layout(SizeProposal::exact(800.0, 100.0));
2390        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2391        let expanded = |t: &WidgetTree| -> Vec<bool> {
2392            triggers
2393                .iter()
2394                .map(|&id| t.accessibility_node(id).is_expanded())
2395                .collect()
2396        };
2397
2398        // Open File → Edit → View via ArrowRight.
2399        t.press_key(Key::ArrowRight, Modifiers::NONE);
2400        t.layout(SizeProposal::exact(800.0, 100.0));
2401        t.press_key(Key::ArrowRight, Modifiers::NONE);
2402        t.layout(SizeProposal::exact(800.0, 100.0));
2403        assert_eq!(expanded(&t), vec![false, false, true], "RIGHT reached View");
2404
2405        // ArrowLeft must move to Edit (the previous menu), NOT close View.
2406        t.press_key(Key::ArrowLeft, Modifiers::NONE);
2407        t.layout(SizeProposal::exact(800.0, 100.0));
2408        assert_eq!(
2409            expanded(&t),
2410            vec![false, true, false],
2411            "LEFT navigates to the previous menu (Edit), not closes"
2412        );
2413
2414        // And once more to File.
2415        t.press_key(Key::ArrowLeft, Modifiers::NONE);
2416        t.layout(SizeProposal::exact(800.0, 100.0));
2417        assert_eq!(
2418            expanded(&t),
2419            vec![true, false, false],
2420            "LEFT again reaches File"
2421        );
2422    }
2423
2424    /// Accessibility: the hamburger is a `Role::Button` whose `expanded`
2425    /// state tracks whether the bar is revealed (the ARIA disclosure
2426    /// pattern), and dismissing the bar restores focus to the hamburger.
2427    #[test]
2428    fn collapsible_hamburger_accessibility() {
2429        let mut t = collapsible_tree();
2430        let mb = t.add(
2431            MenuBar::new()
2432                .menu(lit!("&File"), || Box::new(MenuList::new()))
2433                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2434                .collapse_policy(CollapsePolicy::Always),
2435        );
2436        t.layout(SizeProposal::exact(800.0, 100.0));
2437        t.layout(SizeProposal::exact(800.0, 100.0));
2438        let hamburger = t.children(mb)[1];
2439
2440        // Collapsed: a button that is NOT expanded.
2441        let info = t.accessibility_node(hamburger);
2442        assert_eq!(info.role(), Role::Button);
2443        assert!(
2444            !info.is_expanded(),
2445            "collapsed hamburger reports expanded=false"
2446        );
2447
2448        // Revealed: expanded flips to true; the bar is a MenuBar landmark.
2449        t.click(hamburger);
2450        t.layout(SizeProposal::exact(800.0, 100.0));
2451        assert!(
2452            t.accessibility_node(hamburger).is_expanded(),
2453            "revealed hamburger reports expanded=true"
2454        );
2455        assert!(first_descendant_with_role(&t, mb, Role::MenuBar).is_some());
2456
2457        // Dismiss with Escape: expanded back to false, focus restored to
2458        // the hamburger (not lost in the now-hidden bar). The dismissal is
2459        // deferred for the roll-back tween, so advance past it first.
2460        t.press_key(Key::Escape, Modifiers::NONE);
2461        t.advance_time(std::time::Duration::from_secs(1));
2462        t.layout(SizeProposal::exact(800.0, 100.0));
2463        assert!(
2464            !t.accessibility_node(hamburger).is_expanded(),
2465            "collapsed again after Escape"
2466        );
2467        assert_eq!(
2468            t.focused(),
2469            Some(hamburger),
2470            "focus returns to the hamburger after the bar is dismissed"
2471        );
2472    }
2473
2474    /// Regression: arrow-navigating between menus must NOT tear down the
2475    /// revealed bar. `MenuContext::open_at` calls `dismiss_all_except_hosts`;
2476    /// the bar overlay is marked `Role::MenuBar` (a host) via an access-role
2477    /// override, so it must survive — and its triggers keep valid (non-zero)
2478    /// bounds so dropdowns anchor under them, not at the window origin.
2479    #[test]
2480    fn collapsible_revealed_bar_survives_arrow_navigation() {
2481        let mut t = collapsible_tree();
2482        let mb = t.add(
2483            MenuBar::new()
2484                .menu(lit!("&File"), || Box::new(MenuList::new()))
2485                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2486                .menu(lit!("&View"), || Box::new(MenuList::new()))
2487                .collapse_policy(CollapsePolicy::Always),
2488        );
2489        t.layout(SizeProposal::exact(800.0, 100.0));
2490        t.layout(SizeProposal::exact(800.0, 100.0));
2491        let (bar, hamburger) = (t.children(mb)[0], t.children(mb)[1]);
2492
2493        t.click(hamburger);
2494        t.layout(SizeProposal::exact(800.0, 100.0));
2495        assert!(t.is_active(bar), "bar revealed");
2496        // Let the unroll tween finish so the bar reaches full width and
2497        // its triggers settle at their on-screen positions.
2498        t.tick_animations(std::time::Duration::from_millis(500));
2499        t.layout(SizeProposal::exact(800.0, 100.0));
2500
2501        // Arrow-navigate to the next menu.
2502        t.press_key(Key::ArrowRight, Modifiers::NONE);
2503        t.layout(SizeProposal::exact(800.0, 100.0));
2504
2505        assert!(
2506            t.is_active(bar),
2507            "bar must stay visible while navigating between menus"
2508        );
2509        // Triggers remain laid out inside the floating bar (offset from the
2510        // origin), so the opened dropdown anchors under a trigger.
2511        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2512        let b = t.bounds(triggers[1]);
2513        assert!(
2514            b.width > 0.0 && (b.x > 0.0 || b.y > 0.0),
2515            "trigger stays laid out in the floating bar, not collapsed to the origin: {b:?}"
2516        );
2517    }
2518
2519    #[test]
2520    fn revealed_bar_height_matches_hamburger() {
2521        let mut t = collapsible_tree();
2522        let mb = t.add(
2523            MenuBar::new()
2524                .menu(lit!("&File"), || Box::new(MenuList::new()))
2525                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2526                .collapse_policy(CollapsePolicy::Always)
2527                .hamburger_size(IconButtonSize::Toolbar),
2528        );
2529        t.layout(SizeProposal::exact(800.0, 100.0));
2530        t.layout(SizeProposal::exact(800.0, 100.0));
2531        let (bar, hamburger) = (t.children(mb)[0], t.children(mb)[1]);
2532
2533        t.click(hamburger);
2534        t.layout(SizeProposal::exact(800.0, 100.0));
2535        assert!(t.is_active(bar), "bar revealed");
2536
2537        let ham_h = t.bounds(hamburger).height;
2538        let bar_h = t.bounds(bar).height;
2539        assert!(ham_h > 0.0, "hamburger laid out: {ham_h}");
2540        assert!(
2541            (bar_h - ham_h).abs() < 0.5,
2542            "floating bar height ({bar_h}) matches the hamburger ({ham_h})"
2543        );
2544    }
2545
2546    #[test]
2547    fn revealed_bar_unrolls_open_and_defers_close() {
2548        let mut t = collapsible_tree();
2549        let mb = t.add(
2550            MenuBar::new()
2551                .menu(lit!("&File"), || Box::new(MenuList::new()))
2552                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2553                .menu(lit!("&View"), || Box::new(MenuList::new()))
2554                .collapse_policy(CollapsePolicy::Always),
2555        );
2556        t.layout(SizeProposal::exact(800.0, 100.0));
2557        t.layout(SizeProposal::exact(800.0, 100.0));
2558        let (bar, hamburger) = (t.children(mb)[0], t.children(mb)[1]);
2559
2560        // Open: starts rolled up (~0 width), then unrolls to full width.
2561        t.click(hamburger);
2562        t.layout(SizeProposal::exact(800.0, 100.0));
2563        let just_opened = t.bounds(bar).width;
2564        t.tick_animations(std::time::Duration::from_millis(500));
2565        t.layout(SizeProposal::exact(800.0, 100.0));
2566        let unrolled = t.bounds(bar).width;
2567        assert!(
2568            unrolled > just_opened + 1.0,
2569            "bar unrolls wider after the tween: {just_opened} -> {unrolled}"
2570        );
2571
2572        // Close: the bar stays alive (rolling back) immediately after the
2573        // dismiss; it only goes dormant once the deferred tween completes.
2574        t.press_key(Key::Escape, Modifiers::NONE);
2575        t.layout(SizeProposal::exact(800.0, 100.0));
2576        assert!(
2577            t.is_active(bar),
2578            "bar stays active while rolling back on close"
2579        );
2580        t.advance_time(std::time::Duration::from_secs(1));
2581        t.layout(SizeProposal::exact(800.0, 100.0));
2582        assert!(
2583            !t.is_active(bar),
2584            "bar dormant after the roll-back finishes"
2585        );
2586    }
2587
2588    #[test]
2589    fn collapsible_escape_hides_revealed_bar() {
2590        let mut t = collapsible_tree();
2591        let mb = t.add(
2592            MenuBar::new()
2593                .menu(lit!("&File"), || Box::new(MenuList::new()))
2594                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2595                .collapse_policy(CollapsePolicy::Always),
2596        );
2597        t.layout(SizeProposal::exact(800.0, 100.0));
2598        t.layout(SizeProposal::exact(800.0, 100.0));
2599        let children = t.children(mb);
2600        let (bar, hamburger) = (children[0], children[1]);
2601
2602        t.click(hamburger);
2603        t.layout(SizeProposal::exact(800.0, 100.0));
2604        assert!(t.is_active(bar));
2605
2606        t.press_key(Key::Escape, Modifiers::NONE);
2607        // The close rolls the bar back into the hamburger before tearing
2608        // down; advance past the tween so the deferred dismissal fires.
2609        t.advance_time(std::time::Duration::from_secs(1));
2610        t.layout(SizeProposal::exact(800.0, 100.0));
2611        assert!(!t.is_active(bar), "Escape hides the revealed bar");
2612    }
2613
2614    #[test]
2615    fn collapsible_click_outside_hides_revealed_bar() {
2616        let mut t = collapsible_tree();
2617        let mb = t.add(
2618            MenuBar::new()
2619                .menu(lit!("&File"), || Box::new(MenuList::new()))
2620                .menu(lit!("&Edit"), || Box::new(MenuList::new()))
2621                .collapse_policy(CollapsePolicy::Always),
2622        );
2623        t.layout(SizeProposal::exact(800.0, 100.0));
2624        t.layout(SizeProposal::exact(800.0, 100.0));
2625        let children = t.children(mb);
2626        let (bar, hamburger) = (children[0], children[1]);
2627
2628        t.click(hamburger);
2629        t.layout(SizeProposal::exact(800.0, 100.0));
2630        assert!(t.is_active(bar));
2631
2632        // Click well below the top bar strip — outside the overlay.
2633        t.pointer_down_button(
2634            teksilo_canvas::Point::new(400.0, 400.0),
2635            teksilo_core::event::PointerButton::Primary,
2636        );
2637        // Advance past the roll-back tween so the deferred dismissal fires.
2638        t.advance_time(std::time::Duration::from_secs(1));
2639        t.layout(SizeProposal::exact(800.0, 100.0));
2640        assert!(!t.is_active(bar), "click outside hides the revealed bar");
2641    }
2642
2643    #[test]
2644    fn collapsible_bar_carries_menubar_role() {
2645        let mut t = collapsible_tree();
2646        let mb = t.add(
2647            MenuBar::new()
2648                .menu(lit!("&File"), || Box::new(MenuList::new()))
2649                .collapsible(),
2650        );
2651        t.layout(SizeProposal::exact(800.0, 100.0));
2652        t.layout(SizeProposal::exact(800.0, 100.0));
2653        // In collapsible mode the MenuBar landmark moves onto the bar
2654        // content node (so it travels into the floating overlay and is
2655        // treated as a host surface). It is still reachable as a descendant.
2656        assert!(
2657            first_descendant_with_role(&t, mb, Role::MenuBar).is_some(),
2658            "the bar content node carries Role::MenuBar"
2659        );
2660    }
2661
2662    #[test]
2663    fn collapsible_dispatcher_injects_reveal_only_when_collapsed() {
2664        let collapsed = Signal::new(true);
2665        let reveal: MenubarReveal = std::rc::Rc::new(|_| {});
2666        let d = CollapsibleMenuBarDispatcher {
2667            inner: MenuBarDispatcher {
2668                trigger_ids: vec![fake_id(10)],
2669                mnemonic_table: HashMap::new(),
2670            },
2671            collapsed: collapsed.clone(),
2672            reveal,
2673        };
2674
2675        let action = d.try_handle(&MenubarKeyEvent {
2676            key: Key::F10,
2677            modifiers: Modifiers::NONE,
2678        });
2679        assert!(
2680            matches!(
2681                action,
2682                Some(MenubarAction::FocusTrigger {
2683                    reveal: Some(_),
2684                    ..
2685                })
2686            ),
2687            "collapsed → reveal attached"
2688        );
2689
2690        collapsed.set(false);
2691        let action = d.try_handle(&MenubarKeyEvent {
2692            key: Key::F10,
2693            modifiers: Modifiers::NONE,
2694        });
2695        assert!(
2696            matches!(
2697                action,
2698                Some(MenubarAction::FocusTrigger { reveal: None, .. })
2699            ),
2700            "expanded → no reveal (classic inline behaviour)"
2701        );
2702    }
2703
2704    #[test]
2705    fn from_model_builds_in_window_triggers() {
2706        use crate::menu::{MenuEntry, MenuModel};
2707        let model = MenuModel::new()
2708            .menu(lit!("&File"), |m| {
2709                m.item(MenuEntry::new(lit!("&New")).intent("app.new"))
2710                    .separator()
2711                    .item(MenuEntry::new(lit!("&Quit")).intent("app.quit"))
2712            })
2713            .menu(lit!("&Edit"), |m| {
2714                m.item(MenuEntry::new(lit!("Cu&t")).intent("app.cut"))
2715            });
2716
2717        let mut t = tree_with_window();
2718        let mb = t.add(MenuBar::from_model(model));
2719        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2720
2721        // Two top-level menus → two triggers, names mnemonic-stripped.
2722        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2723        assert_eq!(triggers.len(), 2);
2724        assert_eq!(t.accessibility_node(triggers[0]).name(), Some("File"));
2725        assert_eq!(t.accessibility_node(triggers[1]).name(), Some("Edit"));
2726    }
2727
2728    #[test]
2729    fn runtime_model_mutation_rebuilds_in_window_bar() {
2730        use crate::menu::{MenuEntry, MenuModel};
2731        let model = MenuModel::new().menu(lit!("&File"), |m| m.item(MenuEntry::new(lit!("&New"))));
2732        let model_handle = model.clone();
2733
2734        let mut t = tree_with_window();
2735        let mb = t.add(MenuBar::from_model(model));
2736        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2737        assert_eq!(
2738            collect_descendants_with_role(&t, mb, Role::MenuItem).len(),
2739            1
2740        );
2741
2742        // Add a top-level menu at runtime → version bump → Rebuild binding →
2743        // the next layout re-derives the in-window triggers.
2744        model_handle.push_menu(lit!("&Edit"), |m| m.item(MenuEntry::new(lit!("Cu&t"))));
2745        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2746        assert_eq!(
2747            collect_descendants_with_role(&t, mb, Role::MenuItem).len(),
2748            2
2749        );
2750
2751        // Remove it again.
2752        let nodes_ids: Vec<_> = {
2753            model_handle
2754                .nodes()
2755                .iter()
2756                .filter_map(|n| match n {
2757                    crate::menu::MenuNode::Submenu { id, title, .. }
2758                        if title.resolve_now().contains("Edit") =>
2759                    {
2760                        Some(*id)
2761                    }
2762                    _ => None,
2763                })
2764                .collect()
2765        };
2766        assert!(model_handle.remove(nodes_ids[0]));
2767        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2768        assert_eq!(
2769            collect_descendants_with_role(&t, mb, Role::MenuItem).len(),
2770            1
2771        );
2772    }
2773
2774    #[test]
2775    fn native_suppress_hides_in_window_bar_on_macos() {
2776        use crate::menu::{MenuEntry, MenuModel, NativeMenuMode};
2777        let model = MenuModel::new().menu(lit!("&File"), |m| {
2778            m.item(MenuEntry::new(lit!("&New")).intent("app.new"))
2779        });
2780        let mut t = tree_with_window();
2781        let mb = t.add(MenuBar::from_model(model).native_on_macos(NativeMenuMode::Suppress));
2782        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2783
2784        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2785        if cfg!(target_os = "macos") {
2786            // Suppressed: the OS menu bar carries the menus, no in-window triggers.
2787            assert!(
2788                triggers.is_empty(),
2789                "macOS Suppress renders no in-window triggers"
2790            );
2791        } else {
2792            // Other platforms ignore the flag and render the in-window bar.
2793            assert_eq!(triggers.len(), 1);
2794        }
2795    }
2796
2797    /// End-to-end coverage of the model→native bridge (`menu::native::install`)
2798    /// via the recording `MemoryNativeMenuBackend` — the testable half of the
2799    /// native path (the `NSMenu` core itself needs a live AppKit loop). Verifies
2800    /// title stripping, check state, the auto-injected localized app menu, and
2801    /// that standard-menu labels go through i18n (no hardcoded English).
2802    #[cfg(target_os = "macos")]
2803    #[test]
2804    fn native_install_records_localized_snapshot() {
2805        use crate::menu::{MenuEntry, MenuModel, NativeMenuMode, StandardMenu};
2806        use std::any::{Any, TypeId};
2807        use std::collections::HashMap;
2808        use std::sync::Arc;
2809        use teksilo_core::AppEventPoster;
2810        use teksilo_platform::native_menu::{
2811            MemoryNativeMenuBackend, NativeCheck, NativeMenuHandle, NativeMenuNode,
2812            StandardMenuRole,
2813        };
2814
2815        struct NullPoster;
2816        impl AppEventPoster for NullPoster {
2817            fn post_subscription_event(
2818                &self,
2819                _: teksilo_core::SubscriptionId,
2820                _: Box<dyn Any + Send>,
2821            ) {
2822            }
2823            fn post_external(&self, _: Box<dyn Any + Send>) {}
2824        }
2825
2826        let grid = Signal::new(true);
2827        let model = MenuModel::new()
2828            // App menu with a localized Quit — must NOT be hardcoded English.
2829            .standard_menu(StandardMenu::app().quit(lit!("Quitter")))
2830            .menu(lit!("&File"), |m| {
2831                m.item(MenuEntry::new(lit!("&New")).intent("app.new"))
2832                    .item(MenuEntry::new(lit!("Show &Grid")).checkable(grid.clone()))
2833            });
2834
2835        let backend = MemoryNativeMenuBackend::new();
2836        let handle = NativeMenuHandle::new(backend.clone());
2837        let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
2838        app_state.insert(TypeId::of::<NativeMenuHandle>(), Box::new(handle));
2839        let poster: Arc<dyn AppEventPoster> = Arc::new(NullPoster);
2840
2841        let mut t = tree_with_window();
2842        t.set_app_context(Rc::new(
2843            teksilo_core::event_source::TreeAppContext::empty()
2844                .with_app_state(app_state)
2845                .with_poster(poster),
2846        ));
2847        let _mb = t.add(MenuBar::from_model(model).native_on_macos(NativeMenuMode::Coexist));
2848        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 100.0));
2849
2850        let snap = backend
2851            .menu_for(TeksiloWindowId::new(1))
2852            .expect("native snapshot recorded for the window");
2853
2854        // App menu is first, with the localized Quit label (not "Quit"), and an
2855        // unrouted Quit — this model set no quit intent, so the item stays on
2856        // the platform's `terminate:` selector.
2857        match &snap.roots[0] {
2858            NativeMenuNode::Standard {
2859                role: StandardMenuRole::App,
2860                labels,
2861                quit_item,
2862                ..
2863            } => {
2864                assert_eq!(labels.quit, "Quitter", "Quit label routes through i18n");
2865                assert_eq!(labels.about, "About", "default About label resolved");
2866                assert!(quit_item.is_none(), "no quit intent declared, no routing");
2867            }
2868            other => panic!("expected leading App menu, got {other:?}"),
2869        }
2870
2871        // File submenu: mnemonics stripped, checkable reflects the bound signal.
2872        let file = snap
2873            .roots
2874            .iter()
2875            .find_map(|n| match n {
2876                NativeMenuNode::Submenu { title, children } if title == "File" => Some(children),
2877                _ => None,
2878            })
2879            .expect("File submenu in snapshot");
2880        assert!(
2881            file.iter()
2882                .any(|n| matches!(n, NativeMenuNode::Item { title, .. } if title == "New")),
2883            "New item present, '&' stripped"
2884        );
2885        assert!(
2886            file.iter().any(|n| matches!(
2887                n,
2888                NativeMenuNode::Item { title, check: NativeCheck::On, .. } if title == "Show Grid"
2889            )),
2890            "checkable item reflects the bound signal (On) with stripped title"
2891        );
2892    }
2893
2894    /// A bare focusable leaf, so a bar has somewhere to Tab *to*. The menu
2895    /// tests need a destination outside the bar to tell "focus moved on" from
2896    /// "focus went nowhere".
2897    #[derive(Debug)]
2898    struct FocusableLeaf;
2899    impl Widget for FocusableLeaf {
2900        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2901            ctx.apply_self_handlers(HandlerSet::new().focusable(true));
2902            vec![]
2903        }
2904        fn layout_response(
2905            &self,
2906            proposal: SizeProposal,
2907            _ctx: &LayoutContext,
2908        ) -> teksilo_core::widget::LayoutResponse {
2909            proposal.resolve(12.0, 12.0).into()
2910        }
2911    }
2912
2913    /// Tab is an *exit* gesture for a menu, not a navigation one.
2914    ///
2915    /// ARIA APG's Menu pattern is unqualified about it: Tab "moves focus out
2916    /// of the menu or menubar, and closes all menus and submenus". Only the
2917    /// arrows navigate within. So one Tab must do two things — close the
2918    /// dropdown, and leave focus past the trigger it belongs to. Focus landing
2919    /// anywhere *behind* a still-open menu is WCAG 2.2 SC 2.4.11 (Focus Not
2920    /// Obscured), and focus landing on an arbitrary widget decided by arena
2921    /// insertion order is the same bug wearing a different hat.
2922    #[test]
2923    fn arrow_down_then_tab_closes_dropdown_and_lands_past_trigger() {
2924        let mut t = tree_with_window();
2925        let mb = t.add(
2926            MenuBar::new()
2927                .menu(lit!("&File"), || {
2928                    Box::new(
2929                        MenuList::new()
2930                            .item(MenuItem::new(lit!("New")))
2931                            .item(MenuItem::new(lit!("Open"))),
2932                    )
2933                })
2934                .menu(lit!("&Edit"), || {
2935                    Box::new(MenuList::new().item(MenuItem::new(lit!("Cut"))))
2936                }),
2937        );
2938        // A sibling root *after* the bar — the honest Tab destination.
2939        let after = t.add(FocusableLeaf);
2940        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
2941
2942        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
2943        assert_eq!(triggers.len(), 2, "two top-level triggers");
2944
2945        // ArrowDown on a focused trigger opens its dropdown.
2946        t.focus(triggers[0]);
2947        t.press_key(Key::ArrowDown, Modifiers::NONE);
2948        assert!(
2949            t.accessibility_node(triggers[0]).is_expanded(),
2950            "precondition: ArrowDown opens the File dropdown"
2951        );
2952        assert_ne!(
2953            t.focused(),
2954            Some(triggers[0]),
2955            "precondition: opening moves focus off the trigger, into the menu"
2956        );
2957
2958        t.press_key(Key::Tab, Modifiers::NONE);
2959
2960        // The load-bearing assertion. `is_expanded` only reflects
2961        // `MenuContext::open_index`, which `MenuOverlayHost`'s blur handler
2962        // clears on *any* FocusLost — so it goes false whether or not the
2963        // panel itself was actually taken down. Ask the overlay stack, or this
2964        // test passes with the focus-out rule entirely disabled and the
2965        // dropdown still on screen, which is precisely the WCAG 2.2 SC 2.4.11
2966        // failure it exists to catch.
2967        assert!(
2968            t.active_overlays().is_empty(),
2969            "the dropdown panel itself must not survive Tab"
2970        );
2971        assert!(
2972            !t.accessibility_node(triggers[0]).is_expanded(),
2973            "and the trigger must stop announcing itself as expanded"
2974        );
2975        assert_eq!(
2976            t.focused(),
2977            Some(after),
2978            "Tab must land on the first stop past the trigger"
2979        );
2980    }
2981
2982    /// Sideways bar navigation keeps exactly one menu up, with focus in it.
2983    ///
2984    /// `MenuContext::navigate` moves focus to the outgoing trigger and then
2985    /// opens the next menu, queueing two `request_focus` calls on one
2986    /// `EventContext` — only the last survives the drain — after an
2987    /// `EventContext::dismiss_all_except_hosts`. That ordering is why the
2988    /// focus-out rule cannot see this transition at all: the dismissal has
2989    /// already cleared focus by the time any focus move reaches
2990    /// `dismiss_overlays_left_by_focus`.
2991    ///
2992    /// So this is a **guard, not a probe** — it passes with the focus-out rule
2993    /// disabled, and is here to catch a future change that lets the rule reach
2994    /// this path and eat the menu `navigate` just opened. Stated plainly so
2995    /// nobody reads a green tick here as evidence the mechanism works; the
2996    /// tests that actually exercise it are in `focus_impl.rs`'s
2997    /// `tests_focus_out_dismissal` and `menu_list.rs`.
2998    #[test]
2999    fn sideways_navigate_does_not_orphan_focus() {
3000        let mut t = tree_with_window();
3001        let mb = t.add(
3002            MenuBar::new()
3003                .menu(lit!("&File"), || {
3004                    Box::new(MenuList::new().item(MenuItem::new(lit!("New"))))
3005                })
3006                .menu(lit!("&Edit"), || {
3007                    Box::new(MenuList::new().item(MenuItem::new(lit!("Cut"))))
3008                }),
3009        );
3010        t.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
3011        let triggers = collect_descendants_with_role(&t, mb, Role::MenuItem);
3012
3013        t.focus(triggers[0]);
3014        t.press_key(Key::ArrowDown, Modifiers::NONE);
3015        assert!(t.accessibility_node(triggers[0]).is_expanded());
3016
3017        t.press_key(Key::ArrowRight, Modifiers::NONE);
3018        assert!(
3019            t.accessibility_node(triggers[1]).is_expanded(),
3020            "ArrowRight must leave the Edit menu open, not dismissed by the focus-out rule"
3021        );
3022        assert!(
3023            !t.accessibility_node(triggers[0]).is_expanded(),
3024            "and must close the File menu it navigated away from"
3025        );
3026        assert_eq!(
3027            t.active_overlays().len(),
3028            1,
3029            "exactly one menu overlay stays up across sideways navigation"
3030        );
3031        // The orphan the name warns about: an open menu nobody is standing in.
3032        assert!(
3033            t.focused().is_some_and(|f| f != triggers[0]),
3034            "focus must land in the menu that was just opened, not be left behind"
3035        );
3036    }
3037}