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}