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
49mod trigger;
50mod widget_impl;
51
52use std::cell::{Cell, RefCell};
53use std::collections::HashMap;
54use std::rc::Rc;
55
56use teksilo_canvas::{Point, Rect, Size, SizeProposal};
57use teksilo_core::accessibility::AccessNodeBuilder;
58use teksilo_core::build_context::BuildContext;
59use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
60use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
61use teksilo_core::signal::Signal;
62use teksilo_core::widget::{
63    CursorIcon, EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement,
64};
65use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
66use teksilo_core::widget_id::WidgetId;
67use teksilo_core::window::{
68    MenubarAction, MenubarDispatcher, MenubarGuard, MenubarKeyEvent, MenubarReveal,
69};
70use teksilo_tokens::{SurfaceRole, TextStyleRole};
71
72use crate::animations::Unroll;
73use crate::icon_button::{IconButton, IconButtonSize};
74use crate::menu_context::MenuContext;
75use crate::menu_item::MenuLabel;
76use crate::menu_item::ParsedMnemonic;
77use crate::menu_item::parse_mnemonic;
78use crate::primitives::{HStack, Padding, RectWidget, Spacer, ZStack};
79use teksilo_i18n::LocalizedString;
80
81/// Controls when a collapsible [`MenuBar`] switches from the full inline bar
82/// to the hamburger `IconButton` representation.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum CollapsePolicy {
85    /// Collapse to a hamburger only when the bar's intrinsic width
86    /// exceeds the width it is allotted; otherwise show the full inline
87    /// bar. Mirrors the responsive `Toolbar` overflow behaviour.
88    #[default]
89    Responsive,
90    /// Always show the hamburger, regardless of available width. The
91    /// "force hamburger" / compact mode.
92    Always,
93}
94
95// ---------------------------------------------------------------------------
96// MenuBarEntry — pending menu definition
97// ---------------------------------------------------------------------------
98
99struct MenuBarEntry {
100    label: LocalizedString,
101    factory: Box<dyn Fn() -> Box<dyn Widget>>,
102}
103
104// ---------------------------------------------------------------------------
105// MenuBar — public widget
106// ---------------------------------------------------------------------------
107
108/// A horizontal application menu bar with labelled trigger buttons and dropdown menus.
109///
110/// Each top-level entry becomes a focusable trigger; activating it opens a
111/// floating `MenuList` overlay. See the module documentation for the full
112/// keyboard, mnemonic, and collapsible-mode details.
113pub struct MenuBar {
114    entries: Vec<MenuBarEntry>,
115    /// Pending leading/trailing slot content (the standard by-value slot
116    /// pattern, same as `Card` / `TextInput` / `StandardListItem`). Consumed
117    /// on the first build into `leading_slot_ids` / `trailing_slot_ids`, which
118    /// are re-attached on every later build. MenuBar is
119    /// [`preserves_children_on_rebuild`], so the reconciling rebuild keeps the
120    /// re-attached slot widgets alive — a stateful slot control (a search
121    /// field, a focused button) survives a theme / locale / model-version
122    /// rebuild with its state intact. The menu triggers, by contrast, are
123    /// re-derived fresh each build (the model may have changed) and the
124    /// reconcile reaps the superseded ones.
125    ///
126    /// [`preserves_children_on_rebuild`]: teksilo_core::widget::Widget::preserves_children_on_rebuild
127    leading_slot: Vec<PendingChild>,
128    trailing_slot: Vec<PendingChild>,
129    /// Memoized slot widget ids — populated from the pending content on the
130    /// first build, reused (re-attached) on every later build so the slot
131    /// widgets keep their identity and state across rebuilds.
132    leading_slot_ids: Vec<WidgetId>,
133    trailing_slot_ids: Vec<WidgetId>,
134    root_child_id: Option<WidgetId>,
135    /// Window-state guard for the per-window menubar key dispatcher
136    /// (F10, Alt+letter, bare-Alt-tap). Owned by the MenuBar so the
137    /// slot is cleared on rebuild / unmount.
138    menubar_guard: RefCell<Option<MenubarGuard>>,
139    /// When `true` (the default), `build()` installs a
140    /// [`MenubarDispatcher`] into the window-state slot so this
141    /// MenuBar receives F10 / Alt+letter / Alt-tap routing. Set to
142    /// `false` via [`MenuBar::no_dispatcher_install`] for showcase /
143    /// demo MenuBars that share a window with a primary one — the
144    /// window-state slot is single-occupancy and a second install
145    /// `debug_assert!`s otherwise.
146    install_dispatcher: bool,
147    /// When `Some`, the bar can collapse to a hamburger `IconButton`.
148    /// `None` (the default) is the classic always-inline MenuBar.
149    collapse_policy: Option<CollapsePolicy>,
150    /// `true` while collapsed (hamburger shown). Source of truth for
151    /// the visibility bindings. Driven by the responsive decision in
152    /// `place_children` (or pinned `true` for `CollapsePolicy::Always`).
153    collapsed: Signal<bool>,
154    /// `true` while the collapsed bar is shown as a floating overlay.
155    revealed: Signal<bool>,
156    /// Animated 0..1 reveal progress for the floating bar (0 = rolled up
157    /// into the hamburger, 1 = fully unrolled). The overlay's deferred
158    /// reveal/dismiss drives it; an [`Unroll`] wrapper binds the bar's
159    /// width to it so the bar unrolls out of the hamburger on open and
160    /// rolls back into it on close. Stays at `1.0` for the inline bar.
161    reveal_progress: Signal<f32>,
162    /// Idempotence guard for the responsive write (Toolbar pattern).
163    last_collapsed: Cell<bool>,
164    /// The bar root (ZStack) id, captured in `build()`. Used both as the
165    /// inline content and as the floating-overlay content when collapsed.
166    bar_id: Option<WidgetId>,
167    /// The hamburger `IconButton` id, captured in `build()`.
168    hamburger_id: Option<WidgetId>,
169    /// Size variant applied to the collapsed-mode hamburger `IconButton`.
170    /// Defaults to [`IconButtonSize::Default`] (matching a bare `IconButton`).
171    hamburger_size: IconButtonSize,
172    /// The declarative source model, when this bar was built via
173    /// [`from_model`](Self::from_model). Drives the native menu mirror.
174    model: Option<crate::menu::MenuModel>,
175    /// macOS native-menu behaviour (mirror to / suppress in-window).
176    native_mode: crate::menu::NativeMenuMode,
177    /// RAII binding keeping the native menu's reactive observers alive while
178    /// this bar is mounted.
179    native_binding: RefCell<Option<crate::menu::native::NativeMenuBinding>>,
180}
181
182impl MenuBar {
183    /// Create an empty menu bar with no menus, slots, or collapse policy.
184    pub fn new() -> Self {
185        Self {
186            entries: Vec::new(),
187            leading_slot: Vec::new(),
188            trailing_slot: Vec::new(),
189            leading_slot_ids: Vec::new(),
190            trailing_slot_ids: Vec::new(),
191            root_child_id: None,
192            menubar_guard: RefCell::new(None),
193            install_dispatcher: true,
194            collapse_policy: None,
195            collapsed: Signal::new(false),
196            revealed: Signal::new(false),
197            reveal_progress: Signal::new_animated(1.0),
198            last_collapsed: Cell::new(false),
199            bar_id: None,
200            hamburger_id: None,
201            hamburger_size: IconButtonSize::Default,
202            model: None,
203            native_mode: crate::menu::NativeMenuMode::Off,
204            native_binding: RefCell::new(None),
205        }
206    }
207
208    /// Build a menu bar from a declarative [`MenuModel`](crate::menu::MenuModel)
209    /// — the single source of truth shared with the native OS menu bar. Each
210    /// top-level menu in the model becomes an in-window dropdown; combine with
211    /// [`native_on_macos`](Self::native_on_macos) to also mirror it into the
212    /// macOS system menu bar.
213    pub fn from_model(model: crate::menu::MenuModel) -> Self {
214        let mut bar = Self::new();
215        // Entries are derived from the model on every `build()` (see
216        // `model_entries`), so runtime structural changes — `MenuModel::push_item`
217        // / `remove` / `push_menu` — re-render the in-window bar too (the bar
218        // binds `model.version()` at `Rebuild` level).
219        bar.model = Some(model);
220        bar
221    }
222
223    /// Derive the in-window menu entries from the model's top-level menus. Each
224    /// `Submenu` node becomes a dropdown whose factory builds a `MenuList` from
225    /// its children. `Standard` roles + bare items/separators at top level have
226    /// no in-window representation.
227    fn model_entries(model: &crate::menu::MenuModel) -> Vec<MenuBarEntry> {
228        model
229            .nodes()
230            .iter()
231            .filter_map(|node| match node {
232                crate::menu::MenuNode::Submenu {
233                    title, children, ..
234                } => {
235                    let children = children.clone();
236                    Some(MenuBarEntry {
237                        label: title.clone(),
238                        factory: Box::new(move || {
239                            Box::new(crate::menu::model::build_menu_list(&children))
240                        }),
241                    })
242                }
243                _ => None,
244            })
245            .collect()
246    }
247
248    /// Add an `HStack`'s worth of slot content to `row`, memoized.
249    ///
250    /// On the first build `pending` holds the by-value slot widgets: each is
251    /// inserted once and its id captured in `cache`. On every later build the
252    /// cached ids are re-attached unchanged — re-parenting the same slot
253    /// widgets into the fresh row. Because MenuBar is
254    /// `preserves_children_on_rebuild`, the reconciling rebuild keeps those
255    /// re-homed widgets (and their state) alive while reaping the superseded
256    /// menu triggers. Building each slot widget exactly once is what preserves
257    /// a stateful slot control across rebuilds.
258    fn add_slot(
259        ctx: &mut BuildContext,
260        mut row: HStack,
261        pending: &mut Vec<PendingChild>,
262        cache: &mut Vec<WidgetId>,
263    ) -> HStack {
264        if cache.is_empty() && !pending.is_empty() {
265            *cache = pending
266                .drain(..)
267                .map(|p| match p {
268                    PendingChild::Id(id) => id,
269                    PendingChild::Deferred(w) => ctx.add_boxed(w),
270                })
271                .collect();
272        }
273        for &id in cache.iter() {
274            row = row.child(id);
275        }
276        row
277    }
278
279    /// Choose how this bar behaves on macOS, where the convention is a global
280    /// menu bar at the top of the screen. Requires the bar to have been built
281    /// with [`from_model`](Self::from_model) and the app to have called
282    /// `install_native_menu()`. No effect on other platforms (the in-window bar
283    /// renders there regardless).
284    pub fn native_on_macos(mut self, mode: crate::menu::NativeMenuMode) -> Self {
285        self.native_mode = mode;
286        self
287    }
288
289    /// Enable the optional **hamburger** representation. When there
290    /// isn't room for the full inline bar, it collapses to a single
291    /// hamburger (☰) [`IconButton`]; activating it (click, `Alt`+
292    /// mnemonic, `F10`, or bare-`Alt`-tap) reveals the full bar as a
293    /// floating overlay over content. Clicking outside the bar or
294    /// pressing `Escape` hides it again.
295    ///
296    /// Uses [`CollapsePolicy::Responsive`]. Observe the collapsed state
297    /// via [`is_collapsed`](Self::is_collapsed), or bind your own signal
298    /// with [`collapsed_signal`](Self::collapsed_signal).
299    pub fn collapsible(mut self) -> Self {
300        self.collapse_policy
301            .get_or_insert(CollapsePolicy::Responsive);
302        self
303    }
304
305    /// Like [`collapsible`](Self::collapsible), but uses the supplied
306    /// signal as the collapsed-state source so the application can
307    /// observe (and react to) collapse transitions. The responsive
308    /// decision **writes** this signal (it is not a plain read-only
309    /// input) — kept as a `Signal<bool>` rather than `Prop<bool>` since a
310    /// static value would have nowhere to receive those writes.
311    pub fn collapsed_signal(mut self, collapsed: Signal<bool>) -> Self {
312        self.collapse_policy
313            .get_or_insert(CollapsePolicy::Responsive);
314        self.last_collapsed.set(collapsed.get());
315        self.collapsed = collapsed;
316        self
317    }
318
319    /// Set the collapse policy (and enable collapsible mode).
320    /// [`CollapsePolicy::Always`] forces the hamburger regardless of
321    /// available width — i.e. **collapsed by default**.
322    pub fn collapse_policy(mut self, policy: CollapsePolicy) -> Self {
323        self.collapse_policy = Some(policy);
324        // Start already-collapsed for `Always` so the first frame shows
325        // the hamburger (no one-frame inline flash before `place_children`
326        // sets the signal).
327        if policy == CollapsePolicy::Always {
328            self.collapsed.set(true);
329            self.last_collapsed.set(true);
330        }
331        self
332    }
333
334    /// Set the size variant of the collapsed-mode hamburger
335    /// [`IconButton`]. Mirrors [`IconButton::size`] — pick
336    /// [`IconButtonSize::Toolbar`], [`IconButtonSize::Large`],
337    /// [`IconButtonSize::Hero`], etc. so the hamburger matches the
338    /// surrounding chrome. Defaults to [`IconButtonSize::Default`].
339    pub fn hamburger_size(mut self, size: IconButtonSize) -> Self {
340        self.hamburger_size = size;
341        self
342    }
343
344    /// A clone of the collapsed-state signal (`true` while the
345    /// hamburger is shown). Call after [`collapsible`](Self::collapsible).
346    pub fn is_collapsed(&self) -> Signal<bool> {
347        self.collapsed.clone()
348    }
349
350    /// Skip the window-state dispatcher install. The MenuBar still
351    /// renders, intercepts mouse clicks, and supports keyboard
352    /// navigation when its triggers have focus — only F10 /
353    /// Alt+letter / Alt-tap routing through the window-level slot is
354    /// disabled. Use this for demo / showcase MenuBars that share a
355    /// window with a primary functional MenuBar — the slot is
356    /// single-occupancy and a second install would `debug_assert!`.
357    pub fn no_dispatcher_install(mut self) -> Self {
358        self.install_dispatcher = false;
359        self
360    }
361
362    /// Add a top-level menu entry. `label` is the trigger text (supports `&`
363    /// mnemonic markers, e.g. `"&File"`); `factory` is called each build to
364    /// produce the dropdown content — typically a `MenuList`.
365    pub fn menu(
366        mut self,
367        label: impl Into<LocalizedString>,
368        factory: impl Fn() -> Box<dyn Widget> + 'static,
369    ) -> Self {
370        let ls: LocalizedString = label.into();
371        self.entries.push(MenuBarEntry {
372            label: ls,
373            factory: Box::new(factory),
374        });
375        self
376    }
377
378    /// Add content before the menu buttons (e.g. an app icon). Call more than
379    /// once to stack several.
380    ///
381    /// Takes the widget by value, like every other widget's slot. MenuBar
382    /// builds it once and reuses it across rebuilds (it
383    /// [`preserves_children_on_rebuild`](teksilo_core::widget::Widget::preserves_children_on_rebuild)),
384    /// so the slot — and any state it holds — survives a theme / locale /
385    /// model-version rebuild.
386    pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
387        self.leading_slot
388            .push(teksilo_core::IntoTeksiChild::into_pending(widget));
389        self
390    }
391
392    /// Add several widgets before the menu buttons, in iterator order.
393    ///
394    /// The loop form of [`leading_slot`](Self::leading_slot), which already
395    /// stacks on repeat calls: this is the same thing in one call.
396    pub fn leading_slots(self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
397        iter.into_iter().fold(self, Self::leading_slot)
398    }
399
400    /// Add content after the menu buttons (e.g. a search box or avatar).
401    /// Like [`leading_slot`](Self::leading_slot), taken by value and preserved
402    /// across rebuilds.
403    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
404        self.trailing_slot
405            .push(teksilo_core::IntoTeksiChild::into_pending(widget));
406        self
407    }
408
409    /// Add several widgets after the menu buttons, in iterator order.
410    ///
411    /// The loop form of [`trailing_slot`](Self::trailing_slot), which already
412    /// stacks on repeat calls: this is the same thing in one call.
413    pub fn trailing_slots(self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
414        iter.into_iter().fold(self, Self::trailing_slot)
415    }
416
417    /// macOS `Suppress` path: a zero-chrome bar that renders only the
418    /// leading/trailing slots (the OS menu bar carries the menus). No triggers,
419    /// no F10/Alt dispatcher.
420    fn build_suppressed(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
421        let mut row = HStack::new().spacing(2.0);
422        row = Self::add_slot(ctx, row, &mut self.leading_slot, &mut self.leading_slot_ids);
423        row = row.child(Spacer::new());
424        row = Self::add_slot(
425            ctx,
426            row,
427            &mut self.trailing_slot,
428            &mut self.trailing_slot_ids,
429        );
430        let row_id = ctx.add(row);
431        self.root_child_id = Some(row_id);
432        self.bar_id = Some(row_id);
433        vec![row_id]
434    }
435}
436
437impl Default for MenuBar {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443// ---------------------------------------------------------------------------
444// MenuBarDispatcher — window-level F10 / Alt+letter / Alt-tap handler
445// ---------------------------------------------------------------------------
446
447/// `MenubarDispatcher` impl backed by the live trigger ids and
448/// mnemonic table from the most recent `MenuBar::build`.
449struct MenuBarDispatcher {
450    /// All top-level trigger ids, in declaration order.
451    trigger_ids: Vec<WidgetId>,
452    /// Lower-cased mnemonic char → trigger array index.
453    mnemonic_table: HashMap<char, usize>,
454}
455
456impl MenubarDispatcher for MenuBarDispatcher {
457    fn try_handle(&self, event: &MenubarKeyEvent) -> Option<MenubarAction> {
458        // F10 (no modifiers): focus the first trigger without
459        // opening any menu — matches Win32 / GTK F10 behaviour.
460        // Works on every platform (F10 is not transformed by any OS
461        // input layer the way Alt+letter is on macOS).
462        if event.modifiers == Modifiers::NONE && matches!(event.key, Key::F10) {
463            return self
464                .trigger_ids
465                .first()
466                .map(|&id| MenubarAction::FocusTrigger {
467                    trigger_id: id,
468                    reveal: None,
469                });
470        }
471        // Alt+<letter> mnemonics. On macOS, Option+letter is
472        // intercepted by the OS to compose accented characters
473        // (Option+E -> ´, Option+F -> ƒ, …) *before* winit sees the
474        // keystroke. The app receives the post-composition character
475        // (`ƒ`), not the typed letter (`F`), so the mnemonic table
476        // can never match. Worse, returning `Intercept` here would
477        // silently swallow legitimate accented text input. Skip the
478        // entire branch on macOS — F10 + Alt-tap + in-menu
479        // bare-letter activation cover the macOS menu-keyboard
480        // story instead.
481        #[cfg(not(target_os = "macos"))]
482        if event.modifiers == Modifiers::ALT {
483            // Strict per-OS contract — `Alt+letter` is reserved for
484            // menu mnemonics on Win32 / GTK and must be intercepted
485            // even when nothing matches, so the chord doesn't
486            // appear as garbled text input in a focused text field.
487            let lookup_char = match event.key {
488                Key::Character(c) => Some(c.to_ascii_lowercase()),
489                _ => {
490                    let c = event.key.to_char()?;
491                    Some(c.to_ascii_lowercase())
492                }
493            };
494            if let Some(c) = lookup_char {
495                if let Some(&idx) = self.mnemonic_table.get(&c) {
496                    if let Some(&tid) = self.trigger_ids.get(idx) {
497                        return Some(MenubarAction::OpenMenu {
498                            trigger_id: tid,
499                            reveal: None,
500                        });
501                    }
502                }
503                // Letter-with-Alt that doesn't match any mnemonic —
504                // intercept silently so the chord doesn't leak into
505                // focused text input as garbled chars.
506                return Some(MenubarAction::Intercept);
507            }
508        }
509        // Suppress an unused-warning on macOS where the Alt branch
510        // above is compiled out.
511        let _ = &self.mnemonic_table;
512        None
513    }
514
515    fn on_alt_tap(&self) -> Option<MenubarAction> {
516        // Bare-Alt-tap (no other key during the hold) → focus the
517        // first trigger in menubar-active mode (no menu opens until
518        // ArrowDown / Enter / Space).
519        self.trigger_ids
520            .first()
521            .map(|&id| MenubarAction::FocusTrigger {
522                trigger_id: id,
523                reveal: None,
524            })
525    }
526}
527
528// ---------------------------------------------------------------------------
529// CollapsibleMenuBarDispatcher — wraps MenuBarDispatcher for hamburger mode
530// ---------------------------------------------------------------------------
531
532/// Delegates to the inner [`MenuBarDispatcher`], and — when the bar is
533/// currently collapsed — attaches a `reveal` closure to the returned
534/// action so `teksilo-app` reveals the floating bar (and re-layouts)
535/// before focusing / opening. Preserves the inner dispatcher's
536/// platform-specific behaviour (macOS Alt+letter compile-out, F10,
537/// bare-Alt-tap) by pure delegation.
538struct CollapsibleMenuBarDispatcher {
539    inner: MenuBarDispatcher,
540    collapsed: Signal<bool>,
541    reveal: MenubarReveal,
542}
543
544impl CollapsibleMenuBarDispatcher {
545    fn with_reveal(&self, action: MenubarAction) -> MenubarAction {
546        if !self.collapsed.get() {
547            return action;
548        }
549        let reveal = Some(self.reveal.clone());
550        match action {
551            MenubarAction::OpenMenu { trigger_id, .. } => {
552                MenubarAction::OpenMenu { trigger_id, reveal }
553            }
554            MenubarAction::FocusTrigger { trigger_id, .. } => {
555                MenubarAction::FocusTrigger { trigger_id, reveal }
556            }
557            MenubarAction::Intercept => MenubarAction::Intercept,
558        }
559    }
560}
561
562impl MenubarDispatcher for CollapsibleMenuBarDispatcher {
563    fn try_handle(&self, event: &MenubarKeyEvent) -> Option<MenubarAction> {
564        self.inner.try_handle(event).map(|a| self.with_reveal(a))
565    }
566
567    fn on_alt_tap(&self) -> Option<MenubarAction> {
568        self.inner.on_alt_tap().map(|a| self.with_reveal(a))
569    }
570}
571
572impl std::fmt::Debug for MenuBar {
573    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574        f.debug_struct("MenuBar")
575            .field("entries", &self.entries.len())
576            .finish()
577    }
578}
579
580// ---------------------------------------------------------------------------
581// MenuBarTrigger — internal trigger label
582// ---------------------------------------------------------------------------
583
584#[derive(Debug)]
585struct MenuBarTrigger {
586    label: LocalizedString,
587    /// Mnemonic-stripped label name used for `AccessNodeBuilder::set_name`.
588    /// Captured from the parsed label so screen readers announce "File",
589    /// not "ampersand-File". Set in `build()`.
590    stripped_name: String,
591    /// Mnemonic letter (lowercase) for AT `set_access_key` annotation.
592    /// `None` for triggers whose label carries no un-escaped `&`.
593    mnemonic_key: Option<char>,
594    index: usize,
595    menu_ctx: MenuContext,
596    root_child_id: Option<WidgetId>,
597}
598
599// ---------------------------------------------------------------------------
600// MenuOverlayHost — wraps dropdown content, handles focus + cross-menu keys
601// ---------------------------------------------------------------------------
602
603/// Wraps dropdown menu content (typically a MenuList). Responsibilities:
604/// - Resets `open_index` when focus is lost (overlay dismissed)
605/// - Handles ArrowLeft/Right for cross-menu navigation (bubbles up from MenuList)
606#[derive(Debug)]
607struct MenuOverlayHost {
608    inner: Option<Box<dyn Widget>>,
609    menu_ctx: MenuContext,
610    menu_index: usize,
611    inner_id: Option<WidgetId>,
612}
613
614// ---------------------------------------------------------------------------
615// RevealHeightBox — match the floating bar's height to the hamburger
616// ---------------------------------------------------------------------------
617
618/// Wraps the collapsible bar's content. While the bar is shown as a
619/// floating overlay (`revealed == true`) it reports a height equal to the
620/// hamburger button's measured height, so the floating bar reads as a
621/// horizontal extension of the hamburger and the menu-trigger text centers
622/// vertically (the inner `HStack`'s default `VAlignment::Center`). When the
623/// bar is inline (`revealed == false`) it reports the child's natural size,
624/// leaving the normal in-window bar unchanged.
625#[derive(Debug)]
626struct RevealHeightBox {
627    child_id: Option<WidgetId>,
628    pending_child: Option<PendingChild>,
629    revealed: Signal<bool>,
630    /// The hamburger `IconButton` id, filled after it is built (the
631    /// `anchor_cell` pattern). Measuring it — rather than mapping the size
632    /// table — honours a custom `IconButtonSize` / style for free.
633    hamburger_id: Rc<Cell<Option<WidgetId>>>,
634}
635
636// ---------------------------------------------------------------------------
637// Tests
638// ---------------------------------------------------------------------------
639
640#[cfg(test)]
641mod tests;