Skip to main content

teksilo_widgets/menu_bar/
widget_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementations for [`MenuBar`] and the two private
5//! wrappers it builds — `MenuOverlayHost` (the open dropdown's frame, which
6//! resets the bar's open index on dismissal and routes ArrowLeft / ArrowRight
7//! between sibling menus) and `RevealHeightBox` (which matches the floating
8//! collapsed bar's height to its hamburger).
9
10use super::*;
11
12// ---------------------------------------------------------------------------
13// MenuBar Widget impl
14// ---------------------------------------------------------------------------
15
16impl Widget for MenuBar {
17    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
18        // Mirror the model into the native OS menu bar (macOS) when requested.
19        // The bridge is a no-op without a `NativeMenuHandle` in app-state.
20        if self.native_mode.installs_native()
21            && cfg!(target_os = "macos")
22            && let Some(model) = &self.model
23        {
24            *self.native_binding.borrow_mut() = crate::menu::native::install(model, ctx);
25        }
26
27        // A runtime structural change (`MenuModel::push_item`/`remove`/…) bumps
28        // the model version; rebuild so the in-window dropdowns AND the native
29        // menu re-derive from the new structure.
30        if let Some(model) = &self.model {
31            model.version().bind_to(
32                ctx.self_id(),
33                ctx.binding_registry(),
34                teksilo_core::BindingLevel::Rebuild,
35            );
36        }
37
38        // On macOS with `Suppress`, the global menu bar IS the menu — render
39        // only the optional leading/trailing slots in-window (no triggers, no
40        // F10/Alt dispatcher).
41        if self.native_mode.suppresses_in_window() {
42            return self.build_suppressed(ctx);
43        }
44
45        let theme_signal = ctx.theme_signal();
46
47        let open_index: Signal<Option<usize>> = ctx.signal(None);
48        let menu_ctx = MenuContext::new(open_index);
49
50        // Build the full row: [leading_slot | triggers... | Spacer | trailing_slot]
51        let mut row = HStack::new().spacing(2.0);
52
53        // Leading slot (memoized — the same widgets survive each rebuild)
54        row = Self::add_slot(ctx, row, &mut self.leading_slot, &mut self.leading_slot_ids);
55
56        // Menu triggers + content
57        let mut trigger_ids = Vec::new();
58        let mut content_ids = Vec::new();
59        // Mnemonic table built alongside triggers: `lowercase char →
60        // trigger array index`. Drives the window-level dispatcher
61        // for Alt+letter activation.
62        let mut mnemonic_table: HashMap<char, usize> = HashMap::new();
63
64        // Both bar flavours re-derive their entries every build and re-run
65        // the (Fn) factories, so neither consumes the state it needs to
66        // rebuild: model-built bars re-derive from the (possibly mutated)
67        // model, classic `.menu()` bars iterate their retained entries by
68        // reference. Consuming `self.entries` here (the old `mem::take`)
69        // left the bar empty on the next theme / locale rebuild.
70        let model_entries = self.model.as_ref().map(Self::model_entries);
71        let entries: &[MenuBarEntry] = match &model_entries {
72            Some(derived) => derived,
73            None => &self.entries,
74        };
75        for (i, entry) in entries.iter().enumerate() {
76            let parsed: ParsedMnemonic = parse_mnemonic(&entry.label.resolve_now());
77
78            // Wrap factory output in MenuOverlayHost for focus/key handling
79            let host = MenuOverlayHost {
80                inner: Some((entry.factory)()),
81                menu_ctx: menu_ctx.clone(),
82                menu_index: i,
83                inner_id: None,
84            };
85            // Detached: a menu's content is shown through an overlay, never
86            // inline under the bar. Owned all the same, so a rebuilt menubar
87            // reaps the menus it replaced instead of stranding one host — and
88            // its whole `MenuList` — per rebuild.
89            // Built the first time *this* menu is opened. A menu bar used to
90            // build every menu's whole `MenuList` — and every submenu under it —
91            // on each rebuild of the bar, which a locale or shortcut change
92            // triggers. See `teksilo_core::deferred_subtree::DeferredSubtree`.
93            let opened_here = menu_ctx.open_index.map(move |open| *open == Some(i));
94            let content_id = ctx.add_detached_deferred(opened_here, host);
95            ctx.set_dormant(content_id);
96
97            let trigger = MenuBarTrigger {
98                label: entry.label.clone(),
99                stripped_name: parsed.stripped.clone(),
100                mnemonic_key: parsed.key_lower,
101                index: i,
102                menu_ctx: menu_ctx.clone(),
103                root_child_id: None,
104            };
105            let trigger_id = ctx.add(trigger);
106            row = row.child(trigger_id);
107
108            if let Some(k) = parsed.key_lower {
109                if let Some(prev) = mnemonic_table.insert(k, i) {
110                    debug_assert!(
111                        false,
112                        "MenuBar: duplicate mnemonic {:?} (triggers {} and {})",
113                        k, prev, i
114                    );
115                }
116            }
117
118            trigger_ids.push(trigger_id);
119            content_ids.push(content_id);
120        }
121
122        // Register all trigger/content IDs in the context.
123        // focus_id is initially content_id; MenuOverlayHost::build() will
124        // overwrite it with the actual inner MenuList ID.
125        for (i, (&tid, &cid)) in trigger_ids.iter().zip(content_ids.iter()).enumerate() {
126            menu_ctx.register(i, tid, cid, cid);
127        }
128
129        // Spacer pushes triggers left, trailing slot right
130        row = row.child(Spacer::new());
131
132        // Trailing slot (memoized — the same widgets survive each rebuild)
133        row = Self::add_slot(
134            ctx,
135            row,
136            &mut self.trailing_slot,
137            &mut self.trailing_slot_ids,
138        );
139
140        let row_id = ctx.add(row);
141
142        let bg = RectWidget::new()
143            .background(SurfaceRole::Main)
144            .border_color(theme_signal.map(|t| t.colors.border.with_alpha(0.2)))
145            .border_width(0.0_f32);
146        let bg_id = ctx.add(bg);
147
148        let padding = Padding::symmetric(0.0, 2.0).child(row_id);
149        let padding_id = ctx.add(padding);
150
151        let zstack_id = ctx.add(ZStack::new().child(bg_id).child(padding_id));
152        // Shared cell holding the hamburger id once it's built below — the
153        // `RevealHeightBox` measures it to size the floating bar (filled at
154        // `anchor_cell.set(...)`, the same pattern as the overlay anchor).
155        let ham_cell: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
156        // In collapsible mode the `Role::MenuBar` landmark lives on the
157        // bar content node (not the composing widget) so it travels into
158        // the floating overlay AND so `overlay_is_host_surface` treats
159        // the revealed bar as a host (menu-open dismissal spares it). The
160        // content is wrapped in a `RevealHeightBox` so the *floating* bar's
161        // height matches the hamburger button — the triggers center
162        // vertically (the inner `HStack`'s default `VAlignment::Center`);
163        // the inline bar keeps its natural height. That, in turn, is
164        // wrapped in an `Unroll` so the floating bar unrolls out of the
165        // hamburger on open and rolls back into it on close (driven by
166        // `reveal_progress`; the overlay owns the tween + dismissal
167        // deferral — see the reveal closure below). `reveal_progress`
168        // stays at `1.0` for the inline bar, so `Unroll` is a no-op there.
169        let root_id = if self.collapse_policy.is_some() {
170            let height_box = ctx.add(RevealHeightBox {
171                child_id: None,
172                pending_child: Some(PendingChild::Id(zstack_id)),
173                revealed: self.revealed.clone(),
174                hamburger_id: ham_cell.clone(),
175            });
176            // Unrolls trailing-ward from the hamburger's edge (RTL flip is
177            // a follow-up, matching the docking handle-direction caveat).
178            ctx.add(
179                Unroll::from_progress(self.reveal_progress.clone())
180                    .child(height_box)
181                    .access_role(teksilo_core::accesskit::Role::MenuBar),
182            )
183        } else {
184            zstack_id
185        };
186        self.root_child_id = Some(root_id);
187        self.bar_id = Some(root_id);
188
189        // Collapsible (hamburger) mode: build the hamburger button and
190        // the reveal closure that floats the bar as an overlay; gate
191        // inline visibility on `collapsed` / `revealed`.
192        let mut children = vec![root_id];
193        let collapsible_reveal: Option<MenubarReveal> = if self.collapse_policy.is_some() {
194            let bar_id = root_id;
195            let revealed = self.revealed.clone();
196            let collapsed = self.collapsed.clone();
197            // Captured at build (EventContext can't reach motion / pref):
198            // the unroll tween duration and whether to snap. A theme /
199            // reduced-motion change rebuilds the bar, refreshing both.
200            let reveal_progress = self.reveal_progress.clone();
201            let reveal_duration = ctx.theme().motion.duration_collapse;
202            let reduced_motion = ctx.prefers_reduced_motion();
203
204            // The bar overlay trails the hamburger (the developer is
205            // responsible for placing the hamburger). The anchor cell is
206            // filled after the button is added, since the reveal closure
207            // is created before the button id is known.
208            let anchor_cell: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
209            // First trigger, focused on reveal so the bar is immediately
210            // keyboard-navigable (arrows move between menus, Enter opens).
211            let first_trigger = trigger_ids.first().copied();
212
213            let reveal: MenubarReveal = {
214                let revealed = revealed.clone();
215                let anchor_cell = anchor_cell.clone();
216                let reveal_progress = reveal_progress.clone();
217                Rc::new(move |ctx: &mut EventContext| {
218                    if revealed.get() {
219                        return; // idempotent — already revealed
220                    }
221                    revealed.set(true);
222                    ctx.activate(bar_id);
223                    let anchor = anchor_cell.get().unwrap_or(bar_id);
224                    let on_dismiss: teksilo_core::overlay::OverlayDismissCallback = {
225                        let revealed = revealed.clone();
226                        Rc::new(move |_, _| revealed.set(false))
227                    };
228                    let request = OverlayRequest::new(
229                        bar_id,
230                        anchor,
231                        OverlayPlacement::TrailingEdge,
232                        DismissBehavior::EscapeOrClickOutside,
233                    )
234                    .on_dismiss(on_dismiss);
235                    if reduced_motion {
236                        // No tween: show fully unrolled; dismissal is immediate.
237                        reveal_progress.set(1.0);
238                        ctx.show_overlay(request);
239                    } else {
240                        // Start rolled up, then the overlay tweens 0 → 1 on
241                        // show and 1 → 0 on close (deferring teardown until
242                        // the roll-back completes).
243                        reveal_progress.set(0.0);
244                        ctx.show_overlay_with_reveal(
245                            request,
246                            reveal_progress.clone(),
247                            reveal_duration,
248                        );
249                    }
250                    if let Some(trigger) = first_trigger {
251                        ctx.request_focus(trigger);
252                    }
253                })
254            };
255
256            // `IconButton::menu()` already advertises `HasPopup::Menu` and
257            // an accessible name ("Menu"). Binding `expanded_when(revealed)`
258            // completes the ARIA disclosure pattern: the button reports
259            // `expanded=true` while the bar is shown, `false` while collapsed.
260            let hamburger = IconButton::menu()
261                .size(self.hamburger_size)
262                .expanded_when(revealed.clone())
263                .on_activate_fn({
264                    let reveal = reveal.clone();
265                    move |ctx| reveal(ctx)
266                });
267            let hamburger_id = ctx.add(hamburger);
268            anchor_cell.set(Some(hamburger_id));
269            // Let the bar's `RevealHeightBox` measure the hamburger so the
270            // floating overlay's height matches the button.
271            ham_cell.set(Some(hamburger_id));
272            self.hamburger_id = Some(hamburger_id);
273
274            // Hamburger visible only while collapsed.
275            ctx.visible_when(hamburger_id, collapsed.clone());
276            // Bar active when shown inline (`!collapsed`) OR as the
277            // floating overlay (`revealed`). Keeping it active while
278            // revealed prevents the visibility binding from fighting the
279            // overlay activation.
280            let bar_active = collapsed.zip(&revealed).map(|(c, r)| !*c || *r);
281            ctx.visible_when(bar_id, bar_active);
282
283            children.push(hamburger_id);
284            Some(reveal)
285        } else {
286            None
287        };
288
289        // Window-level menubar key dispatcher (F10 / Alt+letter /
290        // Alt-tap). Installed on every platform — `MenuBar` is an
291        // in-window widget menu, not the OS system menu, so the
292        // dispatcher's job is to wire framework menus to keyboard
293        // accelerators regardless of host OS.
294        //
295        // **macOS**: the dispatcher's `Alt+letter` branch is compiled
296        // out (see `MenuBarDispatcher::try_handle`) because the OS
297        // rewrites Option+letter for accented character composition
298        // before the app sees the keystroke. F10 and bare-Alt-tap
299        // continue to fire on macOS through this same dispatcher.
300        //
301        // Drop the previous guard BEFORE installing the new one so
302        // the slot is empty when `install_menubar_dispatcher` runs
303        // its `debug_assert!(slot.is_none())`. Otherwise a rebuild
304        // of `MenuBar` (e.g. when a composing ancestor rebuilds)
305        // trips the assert in debug builds and would over-write the
306        // slot under another live guard in release.
307        if self.install_dispatcher
308            && let Some(window) = ctx.window()
309        {
310            *self.menubar_guard.borrow_mut() = None;
311            let inner = MenuBarDispatcher {
312                trigger_ids: trigger_ids.clone(),
313                mnemonic_table,
314            };
315            let dispatcher: Rc<dyn MenubarDispatcher> = match collapsible_reveal {
316                Some(reveal) => Rc::new(CollapsibleMenuBarDispatcher {
317                    inner,
318                    collapsed: self.collapsed.clone(),
319                    reveal,
320                }),
321                None => Rc::new(inner),
322            };
323            let guard = window.install_menubar_dispatcher(dispatcher);
324            *self.menubar_guard.borrow_mut() = Some(guard);
325        }
326
327        children
328    }
329
330    fn layout_response(
331        &self,
332        proposal: SizeProposal,
333        ctx: &LayoutContext,
334    ) -> teksilo_core::widget::LayoutResponse {
335        // Collapsed: size to the hamburger's natural size (a small box),
336        // don't stretch to the full allotted width.
337        if self.collapse_policy.is_some() && self.collapsed.get() {
338            return match self.hamburger_id {
339                Some(id) => ctx
340                    .child_size(id, SizeProposal::unspecified())
341                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
342                None => proposal.resolve(0.0, 0.0),
343            }
344            .into();
345        }
346        match self.root_child_id {
347            Some(id) => {
348                let content_proposal = SizeProposal {
349                    width: proposal.width,
350                    height: None,
351                };
352                let size = ctx
353                    .child_size(id, content_proposal)
354                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
355                Size::new(proposal.width.unwrap_or(size.width), size.height)
356            }
357            None => proposal.resolve(0.0, 0.0),
358        }
359        .into()
360    }
361
362    fn place_children(
363        &self,
364        bounds: Rect,
365        proposal: SizeProposal,
366        children: &mut [WidgetPlacement],
367        ctx: &LayoutContext,
368    ) {
369        // Responsive collapse decision (Toolbar pattern): compare the
370        // bar's intrinsic width against the allotted width and toggle
371        // `collapsed`, idempotently (the guard avoids relayout churn).
372        if let Some(policy) = self.collapse_policy {
373            let should_collapse = match policy {
374                CollapsePolicy::Always => true,
375                CollapsePolicy::Responsive => {
376                    if self.revealed.get() {
377                        // Don't un-collapse while the overlay is up — it
378                        // would make the bar both inline and floating.
379                        self.collapsed.get()
380                    } else if let (Some(bar_id), Some(avail)) = (self.bar_id, proposal.width) {
381                        ctx.measure_intrinsic(bar_id, SizeProposal::unspecified())
382                            .map(|s| s.width)
383                            .unwrap_or(0.0)
384                            > avail + 0.5
385                    } else {
386                        // Unbounded width (or no bar) → never collapse.
387                        false
388                    }
389                }
390            };
391            if self.last_collapsed.get() != should_collapse {
392                self.last_collapsed.set(should_collapse);
393                self.collapsed.set(should_collapse);
394            }
395        }
396
397        // The hamburger keeps a constant width: place it at its intrinsic
398        // size, leading-aligned, so a stretching parent can't widen it.
399        // Everything else (the bar, inline or as the re-laid overlay) fills
400        // the bounds; dormant children are skipped by the layout pass.
401        let collapsed = self.collapse_policy.is_some() && self.collapsed.get();
402        for child in children.iter_mut() {
403            if collapsed && Some(child.id) == self.hamburger_id {
404                let size = ctx
405                    .measure_intrinsic(child.id, SizeProposal::unspecified())
406                    .unwrap_or_else(|| bounds.size());
407                let x = if ctx.is_rtl() {
408                    bounds.right() - size.width
409                } else {
410                    bounds.x
411                };
412                child.origin = Point::new(x, bounds.y);
413                child.size = size;
414            } else {
415                child.origin = bounds.origin();
416                child.size = bounds.size();
417            }
418        }
419    }
420
421    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
422        // In collapsible mode the `Role::MenuBar` landmark lives on the
423        // bar content node so it travels into the floating overlay; the
424        // composing widget node stays a generic container.
425        if self.collapse_policy.is_none() {
426            builder.set_role(teksilo_core::accesskit::Role::MenuBar);
427        }
428    }
429
430    fn children(&self) -> Vec<WidgetId> {
431        let mut v: Vec<WidgetId> = self.root_child_id.into_iter().collect();
432        if let Some(h) = self.hamburger_id {
433            v.push(h);
434        }
435        v
436    }
437
438    /// Reconcile on rebuild. The menu triggers are re-derived fresh each build
439    /// (the model may have changed) and the reconcile reaps the superseded
440    /// ones; the memoized leading/trailing slot widgets (see `add_slot`) are
441    /// re-attached by id and kept alive, so a stateful slot control — a search
442    /// field, a focused button, an avatar with hover state — survives a
443    /// model-version / theme / locale rebuild instead of being rebuilt from
444    /// scratch.
445    fn preserves_children_on_rebuild(&self) -> bool {
446        true
447    }
448}
449impl Widget for RevealHeightBox {
450    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
451        if let Some(pending) = self.pending_child.take() {
452            self.child_id = Some(match pending {
453                PendingChild::Id(id) => id,
454                PendingChild::Deferred(w) => ctx.add_boxed(w),
455            });
456        }
457        // Re-layout when the bar reveals / hides so the height switches
458        // between hamburger-matched (floating) and natural (inline).
459        self.revealed.bind_to(
460            ctx.self_id(),
461            ctx.binding_registry(),
462            teksilo_core::binding::BindingLevel::Relayout,
463        );
464        self.child_id.into_iter().collect()
465    }
466
467    fn layout_response(
468        &self,
469        proposal: SizeProposal,
470        ctx: &LayoutContext,
471    ) -> teksilo_core::widget::LayoutResponse {
472        let child = self.child_id;
473        if self.revealed.get() {
474            if let Some(ham) = self.hamburger_id.get() {
475                if let Some(h) = ctx
476                    .measure_intrinsic(ham, SizeProposal::unspecified())
477                    .map(|s| s.height)
478                {
479                    let child_w = child
480                        .and_then(|id| {
481                            ctx.child_size(
482                                id,
483                                SizeProposal {
484                                    width: proposal.width,
485                                    height: Some(h),
486                                },
487                            )
488                        })
489                        .map(|s| s.width)
490                        .unwrap_or(0.0);
491                    let w = proposal.width.unwrap_or(child_w);
492                    return Size::new(w, h).into();
493                }
494            }
495        }
496        child
497            .and_then(|id| ctx.child_size(id, proposal))
498            .unwrap_or(Size::ZERO)
499            .into()
500    }
501
502    fn place_children(
503        &self,
504        bounds: Rect,
505        _proposal: SizeProposal,
506        children: &mut [WidgetPlacement],
507        _ctx: &LayoutContext,
508    ) {
509        for child in children.iter_mut() {
510            child.origin = bounds.origin();
511            child.size = bounds.size();
512        }
513    }
514
515    fn children(&self) -> Vec<WidgetId> {
516        self.child_id.into_iter().collect()
517    }
518}
519impl Widget for MenuOverlayHost {
520    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
521        let mut inner_widget = self.inner.take().expect("MenuOverlayHost built twice");
522        // A reader entering the menu hears it named after its trigger ("File
523        // menu").
524        if let Some(trigger) = self.menu_ctx.trigger_id(self.menu_index)
525            && let Some(menu) = inner_widget
526                .as_any_mut()
527                .and_then(|a| a.downcast_mut::<crate::menu_list::MenuList>())
528        {
529            menu.set_opener(trigger);
530        }
531        let id = ctx.add_boxed(inner_widget);
532        self.inner_id = Some(id);
533
534        // Register inner widget as the focus target for this menu index
535        self.menu_ctx.set_focus_id(self.menu_index, id);
536
537        // No focus handler: the menu stops being the open one when its overlay
538        // is dismissed (`MenuContext::open_at`'s `on_dismiss`), whatever closed
539        // it, and the framework's focus-out rule
540        // (`dismiss_overlays_left_by_focus`) is one such route. Clearing
541        // `open_index` on `FocusLost` instead was wrong both ways: reopening the
542        // open menu hands focus through the trigger, which cleared the state
543        // the reopen had just set, and choosing an item in a submenu closed the
544        // menus with it still set.
545        let menu_ctx = self.menu_ctx.clone();
546        let handler_set = HandlerSet::new().on_key({
547            let menu_ctx = menu_ctx.clone();
548            move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
549                // These keys bubble up from the inner MenuList when it
550                // returns Ignored. Under RTL the bar is laid out
551                // right-to-left, so the previous/next arrows swap.
552                let (left_delta, right_delta) = if ctx.is_rtl() { (1, -1) } else { (-1, 1) };
553                match event {
554                    WidgetEvent::KeyDown {
555                        key: Key::ArrowLeft,
556                        ..
557                    } => {
558                        menu_ctx.navigate(left_delta, ctx);
559                        EventResponse::Handled
560                    }
561                    WidgetEvent::KeyDown {
562                        key: Key::ArrowRight,
563                        ..
564                    } => {
565                        menu_ctx.navigate(right_delta, ctx);
566                        EventResponse::Handled
567                    }
568                    WidgetEvent::KeyDown {
569                        key: Key::Escape, ..
570                    } => {
571                        menu_ctx.close(ctx);
572                        EventResponse::Handled
573                    }
574                    _ => EventResponse::Ignored,
575                }
576            }
577        });
578        // NOT focusable — the inner MenuList receives focus directly.
579        // ArrowLeft/Right and Escape bubble from MenuList through here.
580        ctx.apply_self_handlers(handler_set);
581
582        vec![id]
583    }
584
585    fn layout_response(
586        &self,
587        proposal: SizeProposal,
588        ctx: &LayoutContext,
589    ) -> teksilo_core::widget::LayoutResponse {
590        self.inner_id
591            .and_then(|id| ctx.child_size(id, proposal))
592            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
593            .into()
594    }
595
596    fn place_children(
597        &self,
598        bounds: Rect,
599        _proposal: SizeProposal,
600        children: &mut [WidgetPlacement],
601        _ctx: &LayoutContext,
602    ) {
603        for child in children.iter_mut() {
604            child.origin = bounds.origin();
605            child.size = bounds.size();
606        }
607    }
608
609    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
610        // The inner widget (typically `MenuList`) owns the `Role::Menu`
611        // semantics. A second Menu role here would nest two Menu nodes
612        // per dropdown, confusing screen readers that look for a single
613        // Menu per popup. `GenericContainer` is the ARIA `none`/`presentation`
614        // equivalent: the host is kept in the tree for focus/key routing
615        // but is ignored by assistive tech.
616        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
617    }
618
619    fn children(&self) -> Vec<WidgetId> {
620        self.inner_id.into_iter().collect()
621    }
622}