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;