teksilo_widgets/menu_item/widget_impl.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementation for [`MenuItem`]: build (row
5//! assembly, hover / press / submenu pointer wiring, the safe-triangle gate),
6//! layout, placement and accessibility — plus the small state-to-role helpers
7//! only that build uses.
8
9use super::*;
10fn resolve_text_role(state: MenuItemState) -> TextRole {
11 match state {
12 MenuItemState::Disabled => TextRole::Disabled,
13 _ => TextRole::Primary,
14 }
15}
16
17fn resolve_shortcut_role(state: MenuItemState) -> TextRole {
18 match state {
19 MenuItemState::Disabled => TextRole::Disabled,
20 _ => TextRole::TooltipShortcut,
21 }
22}
23
24/// Whether a state is the row's *highlighted* one — the state a
25/// [`MenuItemStyle::highlighted_label_role`](teksilo_core::styles::MenuItemStyle::highlighted_label_role)
26/// applies to. Hover and the
27/// keyboard-arrow highlight share `Hovered`; a pressed row is still
28/// highlighted underneath the press.
29fn is_highlight(state: MenuItemState) -> bool {
30 matches!(state, MenuItemState::Hovered | MenuItemState::Pressed)
31}
32
33/// The roles a style asks a row's foregrounds to take in its interaction
34/// states, read once per build.
35#[derive(Clone, Copy)]
36pub(super) struct StateRoles {
37 /// [`MenuItemStyle::highlighted_label_role`](teksilo_core::styles::MenuItemStyle::highlighted_label_role).
38 pub(super) highlighted: Option<TextRole>,
39 /// [`MenuItemStyle::pressed_label_role`](teksilo_core::styles::MenuItemStyle::pressed_label_role).
40 pub(super) pressed: Option<TextRole>,
41}
42
43impl StateRoles {
44 /// The role a foreground reads in `state`: the pressed role while
45 /// pressed, else the highlighted role while highlighted (a pressed row
46 /// included), else `rest`, the row's own mapping for the state.
47 pub(super) fn resolve(self, state: MenuItemState, rest: TextRole) -> TextRole {
48 self.pressed
49 .filter(|_| state == MenuItemState::Pressed)
50 .or_else(|| self.highlighted.filter(|_| is_highlight(state)))
51 .unwrap_or(rest)
52 }
53}
54
55impl Widget for MenuItem {
56 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
57 let self_id = ctx.self_id();
58 self.self_id = Some(self_id);
59 // Forward enabled (static or signal-bound) into the arena. A bound
60 // signal makes enable/disable reactive — the framework's
61 // effective_enabled drives paint / AT (and event gating).
62 ctx.enabled_when(self_id, self.enabled.clone());
63 let effective_enabled = ctx.effective_enabled_signal(self_id);
64
65 // Interaction seeds to Idle; the framework's effective_enabled
66 // drives the Disabled visual via the recipe and through the
67 // leaves' role substitution.
68 let interaction = ctx.signal(MenuItemState::Idle);
69 self.interaction = interaction.clone();
70 // The row's pressed state, driven by the router's press record while
71 // a pointer holds the row. It was previously written by the keyboard
72 // path alone (and by `on_tap`, which runs on the release and resets
73 // to `Idle` in the same handler), so a style's pressed chrome and its
74 // `pressed_label_role` were unreachable for a mouse and for a finger.
75 // `hovered` is where the hover truth lives while `interaction` holds
76 // `Pressed`, so a press that ends without activating (it slid off)
77 // returns the row to where the pointer is.
78 let hovered = Rc::new(std::cell::Cell::new(false));
79 {
80 let hovered = hovered.clone();
81 crate::common::interaction::bind_press_state(
82 ctx,
83 interaction.clone(),
84 MenuItemState::Pressed,
85 move || {
86 if hovered.get() {
87 MenuItemState::Hovered
88 } else {
89 MenuItemState::Idle
90 }
91 },
92 );
93 }
94
95 // Resolved here rather than at `make_body` because the label is
96 // built long before the chrome, and a style whose highlight is a
97 // *solid* fill (macOS's accent row) has to say so in time to
98 // recolour it. Per-call override > theme slot > shipped recipe.
99 let style: SharedMenuItemStyle = self
100 .style_override
101 .clone()
102 .or_else(|| ctx.theme().style_slots.menu_item.clone())
103 .unwrap_or_else(|| {
104 Rc::new(crate::styles::RecipeMenuItemStyle::for_tokens(
105 &ctx.theme().input,
106 ))
107 });
108 let state_roles = StateRoles {
109 highlighted: style.highlighted_label_role(),
110 pressed: style.pressed_label_role(),
111 };
112 // The slot sizes the active style owns. `MenuItem` builds the leading
113 // icon/check column and the trailing chevron column itself — they are
114 // slot *contents*, handed to `make_body` already sized — so they have
115 // to be read here rather than inside the style. Hardcoding the IntUI
116 // constants instead is what made `MenuItemRecipe::icon_column_width` a
117 // no-op: macOS declared a 14 dp check column and got 16.
118 let metrics = style.metrics();
119
120 // Combine interaction + effective_enabled so `text_role`
121 // resolves to Disabled when disabled. Keeps the icon and label
122 // muted on hover-while-disabled too (defense in depth — the
123 // leaves' `ColorProp::resolve(theme, ctx.effective_enabled)`
124 // would substitute Disabled anyway).
125 let text_role = interaction.zip(&effective_enabled).map(move |(s, on)| {
126 if !*on {
127 TextRole::Disabled
128 } else {
129 state_roles.resolve(*s, resolve_text_role(*s))
130 }
131 });
132
133 // Build the three slots fed to the active `MenuItemStyle`.
134 // The style decides the row layout (and chrome); the widget
135 // owns the slot contents.
136 //
137 // Leading: icon column — always reserved at `icon_column_width`,
138 // even when the item has no icon, so labels line up vertically
139 // between icon'd and icon-less items.
140 //
141 // For Check / Radio modes the slot becomes a `Switcher`
142 // driven by the bound state signal, swapping between the
143 // glyph and a `Spacer`. The framework's binding system
144 // re-paints the leaf when the signal flips — no rebuild.
145 //
146 // Icon + Check/Radio are mutually exclusive (Windows
147 // convention). If both are set, `debug_assert!` fires and the
148 // check/radio mode wins in release.
149 let leading = {
150 let icon_child_id = match &self.mode {
151 MenuItemMode::Plain => match self.icon.take() {
152 // The caller's own colour stands — see `icon_keeps_color`.
153 Some(icon) if self.icon_keeps_color => ctx.add(icon),
154 Some(icon) => ctx.add(icon.color(text_role.clone())),
155 None => ctx.add(Spacer::new()),
156 },
157 MenuItemMode::Check(CheckKind::TwoState(s)) => {
158 debug_assert!(
159 self.icon.is_none(),
160 "MenuItem: .icon() is mutually exclusive with a checkmark (checked / reflect_checked)"
161 );
162 self.icon = None;
163 // 0 = checkmark, 1 = spacer.
164 let idx = s.map(|b| if *b { 0_usize } else { 1 });
165 ctx.add(
166 Switcher::new(idx)
167 .child(
168 IconWidget::checkmark(MENU_INDICATOR_GLYPH_SIZE)
169 .color(text_role.clone()),
170 )
171 .child(Spacer::new()),
172 )
173 }
174 MenuItemMode::Check(CheckKind::Reflect(s)) => {
175 debug_assert!(
176 self.icon.is_none(),
177 "MenuItem: .icon() is mutually exclusive with a checkmark (checked / reflect_checked)"
178 );
179 self.icon = None;
180 // 0 = checkmark, 1 = spacer.
181 let idx = s.as_signal().map(|b| if *b { 0_usize } else { 1 });
182 ctx.add(
183 Switcher::new(idx)
184 .child(
185 IconWidget::checkmark(MENU_INDICATOR_GLYPH_SIZE)
186 .color(text_role.clone()),
187 )
188 .child(Spacer::new()),
189 )
190 }
191 MenuItemMode::Check(CheckKind::TriState(s)) => {
192 debug_assert!(
193 self.icon.is_none(),
194 "MenuItem: .icon() is mutually exclusive with .check_state()"
195 );
196 self.icon = None;
197 // 0 = checkmark (Checked), 1 = dash (Indeterminate), 2 = spacer (Unchecked).
198 let idx = s.map(|cs| match cs {
199 CheckState::Checked => 0_usize,
200 CheckState::Indeterminate => 1,
201 CheckState::Unchecked => 2,
202 });
203 ctx.add(
204 Switcher::new(idx)
205 .child(
206 IconWidget::checkmark(MENU_INDICATOR_GLYPH_SIZE)
207 .color(text_role.clone()),
208 )
209 .child(
210 IconWidget::dash(MENU_INDICATOR_GLYPH_SIZE)
211 .color(text_role.clone()),
212 )
213 .child(Spacer::new()),
214 )
215 }
216 MenuItemMode::Radio { value, selected } => {
217 debug_assert!(
218 self.icon.is_none(),
219 "MenuItem: .icon() is mutually exclusive with .radio()"
220 );
221 self.icon = None;
222 let v = *value;
223 // 0 = filled dot (selected == value), 1 = spacer.
224 let idx = selected.map(move |sel| if *sel == v { 0_usize } else { 1 });
225 ctx.add(
226 Switcher::new(idx)
227 .child(
228 IconWidget::radio_dot(MENU_INDICATOR_GLYPH_SIZE)
229 .color(text_role.clone()),
230 )
231 .child(Spacer::new()),
232 )
233 }
234 };
235 ctx.add(
236 crate::primitives::FixedSize::new()
237 .width(metrics.icon_column_width)
238 .height(metrics.icon_column_width)
239 .child(icon_child_id),
240 )
241 };
242
243 // Label. Uses `MenuLabel` (not `TextWidget`) so a single `&`
244 // in the label is parsed as a mnemonic marker — stripped from
245 // the visible text and underlined when `alt_down` is held.
246 // The parsed form is cached so the enclosing MenuList can
247 // read it for type-ahead and in-menu mnemonic activation.
248 let parsed = parse_mnemonic(&self.label.resolve_now());
249 self.parsed_mnemonic = Some(parsed.clone());
250 let alt_down = ctx
251 .window()
252 .map(|w| w.alt_down().clone())
253 .unwrap_or_else(|| Signal::new(false));
254 let label_source: teksilo_core::signal::Prop<String> = self.label.clone().into();
255 let label_color: teksilo_core::color_prop::ColorProp = self
256 .text_role_override
257 .clone()
258 .unwrap_or_else(|| text_role.clone().into());
259 let label_style: teksilo_core::color_prop::TextStyleProp = self
260 .label_style
261 .clone()
262 .unwrap_or_else(|| TextStyleRole::Body.into());
263 let label = ctx.add(MenuLabel::new(
264 label_source,
265 alt_down,
266 label_color,
267 label_style,
268 ));
269
270 // Resolve the trailing accelerator *reactively*. A manual
271 // `shortcut_label` is a static string; a `shortcut_id` binds a
272 // per-id registry signal (built into the trailing slot below), so
273 // a rebind of *that* id refreshes the chord in place. Crucially we
274 // do NOT observe the coarse global `shortcut_version` at `Rebuild`
275 // here — doing so tore the whole item (its gesture arena) down on
276 // *any* shortcut-registry activity anywhere, dropping the click on
277 // menu items that show a shortcut. The item is now never rebuilt
278 // for shortcut changes; only its trailing label repaints.
279 self.shortcut_signal = self.shortcut_id.map(|id| ctx.effective_shortcut_signal(id));
280
281 // Pre-create submenu content if this is a submenu trigger. Kept
282 // dormant until hover opens the overlay.
283 let submenu_content_id = if let Some(factory) = self.submenu_factory.take() {
284 let mut submenu_widget = factory();
285 // A reader entering the submenu hears it named after this row
286 // ("Recent menu").
287 if let Some(menu) = submenu_widget
288 .as_any_mut()
289 .and_then(|a| a.downcast_mut::<crate::menu_list::MenuList>())
290 {
291 menu.set_opener(self_id);
292 }
293 // Detached (a submenu opens in an overlay beside the item, never
294 // inline) but owned, so it dies with the item instead of outliving
295 // every menu the user ever opened.
296 // Built the first time the submenu is actually wanted. A menu of
297 // twenty items with submenus used to build all twenty submenus —
298 // and their submenus — the moment the menu was mounted.
299 let id = ctx.add_detached_deferred_boxed(self.submenu_needed.clone(), submenu_widget);
300 ctx.set_dormant(id);
301 self.submenu_content_id = Some(id);
302 Some(id)
303 } else {
304 None
305 };
306
307 // Trailing slot — combines (optional shortcut + fixed gap +
308 // optional chevron column). The chevron column is always
309 // reserved at the style's `trailing_column_width` so submenu
310 // and regular items share the same trailing edge.
311 let trailing = {
312 let mut trailing_row = HStack::new().spacing(0.0);
313 // Trailing accelerator. Present whenever this item references a
314 // shortcut (manual `shortcut_label`, or a `shortcut_id`). For an
315 // id it binds the per-id signal reactively (empty ⇒ zero-width,
316 // so a shortcut appearing/disappearing needs no rebuild); for a
317 // manual label it's a static string.
318 let shortcut: Option<TextWidget> = if let Some(label) = self.shortcut_label.clone() {
319 Some(TextWidget::new(lit!(label)))
320 } else {
321 self.shortcut_signal.clone().map(|sig| {
322 TextWidget::new(lit!(""))
323 .text(sig.map(|ks| (*ks).map(format_keystroke).unwrap_or_default()))
324 })
325 };
326 let has_shortcut = shortcut.is_some();
327 if let Some(shortcut) = shortcut {
328 let shortcut_role =
329 interaction.map(move |s| state_roles.resolve(*s, resolve_shortcut_role(*s)));
330 trailing_row = trailing_row.child(
331 shortcut
332 .style(TextStyleRole::Body)
333 .color(shortcut_role)
334 .single_line()
335 .a11y_hidden(),
336 );
337 }
338 // Trailing descriptive hint. Unlike the accelerator above this is
339 // built straight from the `LocalizedString`, so `TextWidget`'s own
340 // `Prop<String>` conversion binds it to the locale signal and it
341 // re-resolves in place on a language switch. It is `a11y_hidden`
342 // because it is announced as the item's *description* instead (see
343 // `accessibility`), never as a keyboard shortcut.
344 if let Some(hint) = self.trailing_hint.clone() {
345 if has_shortcut {
346 // Both set (rare) — keep the chord and the phrase apart.
347 trailing_row = trailing_row.child(
348 crate::primitives::FixedSize::new().width(metrics.trailing_column_width),
349 );
350 }
351 let hint_role =
352 interaction.map(move |s| state_roles.resolve(*s, resolve_shortcut_role(*s)));
353 trailing_row = trailing_row.child(
354 TextWidget::new(hint)
355 .style(TextStyleRole::Body)
356 .color(hint_role)
357 .single_line()
358 .a11y_hidden(),
359 );
360 }
361 // Chevron column. Always reserved (Spacer when no submenu)
362 // so the row's right edge sits at exactly the same X
363 // regardless of submenu-ness.
364 //
365 // The submenu opens on the trailing edge
366 // (`OverlayPlacement::TrailingEdge`) — right under LTR, left
367 // under RTL — so the chevron must point the same way. Drive a
368 // `Switcher` off the locale's direction signal so it flips
369 // live on a locale change (0 = LTR → ▶, 1 = RTL → ◀). With no
370 // i18n manager installed there's no RTL, so fall back to the
371 // plain right-pointing chevron.
372 let chevron_child_id = if submenu_content_id.is_some() {
373 match teksilo_i18n::current_direction() {
374 Some(direction) => {
375 let idx = direction.map(|d| {
376 if *d == teksilo_core::environment::LayoutDirection::RightToLeft {
377 1_usize
378 } else {
379 0
380 }
381 });
382 ctx.add(
383 Switcher::new(idx)
384 .child(IconWidget::chevron_right(12.0).color(text_role.clone()))
385 .child(IconWidget::chevron_left(12.0).color(text_role.clone())),
386 )
387 }
388 None => ctx.add(IconWidget::chevron_right(12.0).color(text_role.clone())),
389 }
390 } else {
391 ctx.add(Spacer::new())
392 };
393 let chevron_column = ctx.add(
394 crate::primitives::FixedSize::new()
395 .width(metrics.trailing_column_width)
396 .height(metrics.icon_column_width)
397 .child(chevron_child_id),
398 );
399 trailing_row = trailing_row.child(chevron_column);
400 ctx.add(trailing_row)
401 };
402
403 // Derive the four boolean signals the trait wants.
404 let is_hovered = interaction.map(|s| matches!(s, MenuItemState::Hovered));
405 let is_pressed = interaction.map(|s| matches!(s, MenuItemState::Pressed));
406 let is_disabled = interaction.map(|s| matches!(s, MenuItemState::Disabled));
407
408 // MenuItem doesn't track focus/highlight separately today —
409 // hovered already covers the keyboard-arrow case in the
410 // existing dispatcher. Wire is_focused to a constant false
411 // signal.
412 let is_focused = ctx.signal(false);
413 // A submenu trigger stays highlighted for as long as its
414 // submenu is on screen, not just while the pointer is on the
415 // row — the pointer spends that whole time somewhere else (the
416 // diagonal, then the submenu itself), and every desktop menu
417 // keeps the parent row lit to show where the open panel came
418 // from. Plain rows have no submenu, so this is `is_hovered`.
419 let is_highlighted = if submenu_content_id.is_some() {
420 is_hovered.or(&self.submenu_open)
421 } else {
422 is_hovered.clone()
423 };
424
425 let cfg = MenuItemStyleConfig {
426 label,
427 leading: Some(leading),
428 trailing: Some(trailing),
429 is_hovered,
430 is_pressed,
431 is_focused,
432 is_disabled,
433 is_highlighted,
434 };
435 let root_id = style.make_body(&cfg, ctx);
436
437 self.root_child_id = Some(root_id);
438
439 // Attach tooltip if configured. The three setters
440 // (`tooltip`, `rich_tooltip*`, `composite_tooltip`) are
441 // mutually exclusive — setters clear the other two so at most
442 // one branch runs. A `MenuItem` only ever lives in a vertical
443 // `MenuList`, so the tooltip opens to the trailing `Side` — a
444 // `Below` tooltip would cover the next item down.
445 use crate::tooltip::TooltipPlacement;
446 if let Some(content) = self.composite_tooltip_content.take() {
447 let delay = ctx.theme().motion.tooltip_delay_heavy;
448 crate::tooltip::attach_composite_tooltip_boxed_with_placement(
449 ctx,
450 root_id,
451 content,
452 delay,
453 TooltipPlacement::Side,
454 );
455 } else if let Some(source) = self.rich_tooltip_source.clone() {
456 // Cloned, not taken: `build()` re-runs on every rebuild, and an item
457 // that consumed its source attached a tooltip once and then silently
458 // lost it — the surviving entry pointed at the previous build's body,
459 // which the rebuild had just destroyed. (`composite_tooltip_content`
460 // above is a `Box<dyn Widget>` with no way to clone, so it keeps the
461 // take and its one-shot behaviour.)
462 let delay = ctx.theme().motion.tooltip_delay;
463 crate::tooltip::attach_rich_tooltip_source_with_placement(
464 ctx,
465 root_id,
466 source,
467 delay,
468 TooltipPlacement::Side,
469 );
470 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
471 let delay = ctx.theme().motion.tooltip_delay;
472 crate::tooltip::attach_plain_tooltip_with_placement(
473 ctx,
474 root_id,
475 tooltip_text,
476 delay,
477 TooltipPlacement::Side,
478 );
479 }
480
481 // --- Handlers ---
482 let action = self.action.take();
483 let action_rc: std::rc::Rc<Option<CommandFactory>> = std::rc::Rc::new(action);
484 let action_for_key = action_rc.clone();
485
486 // Shared closure that performs the bound-state mutation on
487 // activation — flips the check signal, cycles the tristate
488 // signal, or writes the radio value. Captured by both the
489 // tap and key handlers so click and Enter/Space have
490 // identical semantics. `None` for `Plain` and for submenu
491 // triggers (which never carry a bound state).
492 type ActivateFn = std::rc::Rc<dyn Fn()>;
493 let mode_activate: Option<ActivateFn> = match &self.mode {
494 MenuItemMode::Plain => None,
495 MenuItemMode::Check(CheckKind::TwoState(s)) => {
496 let s = s.clone();
497 Some(std::rc::Rc::new(move || s.set(!s.get())))
498 }
499 // Reflect-only: no built-in write — the on_activate / intent owns
500 // the state change; the checkmark follows `state` reactively.
501 MenuItemMode::Check(CheckKind::Reflect(_)) => None,
502 MenuItemMode::Check(CheckKind::TriState(s)) => {
503 let s = s.clone();
504 // Click toggles Unchecked <-> Checked. Indeterminate
505 // (driven by external aggregation models) promotes
506 // to Checked. Mirrors `Checkbox::toggle`.
507 Some(std::rc::Rc::new(move || match s.get() {
508 CheckState::Unchecked => s.set(CheckState::Checked),
509 CheckState::Checked => s.set(CheckState::Unchecked),
510 CheckState::Indeterminate => s.set(CheckState::Checked),
511 }))
512 }
513 MenuItemMode::Radio { value, selected } => {
514 let v = *value;
515 let selected = selected.clone();
516 Some(std::rc::Rc::new(move || selected.set(v)))
517 }
518 };
519 let mode_activate_for_tap = mode_activate.clone();
520 let mode_activate_for_key = mode_activate.clone();
521
522 let int_hover = interaction.clone();
523 let self_id = ctx.self_id();
524 let is_submenu = submenu_content_id.is_some();
525
526 // Shared dismiss callback for the submenu overlay. Flipped
527 // to `false` by the overlay manager when the submenu is
528 // dismissed by any path (pointer leave, cascade, Escape,
529 // click outside) so `accessibility()` can report accurate
530 // `set_expanded` without needing to track the overlay state
531 // from inside the MenuItem's own handlers.
532 //
533 // Also retracts the safe-triangle publication when the overlay
534 // actually closes — it is deliberately kept alive across
535 // hover-leave (that is when the diagonal starts), so it MUST
536 // be cleared here once the submenu is finally gone.
537 let submenu_open_signal = self.submenu_open.clone();
538 let submenu_needed_signal = self.submenu_needed.clone();
539 let submenu_content_id_for_dismiss = submenu_content_id;
540 let safe_triangle_for_dismiss = self.safe_triangle.clone();
541 let submenu_dismiss_callback: teksilo_core::overlay::OverlayDismissCallback = {
542 let open = submenu_open_signal.clone();
543 std::rc::Rc::new(move |_, _| {
544 open.set(false);
545 if let (Some(sub_id), Some(state_rc)) = (
546 submenu_content_id_for_dismiss,
547 safe_triangle_for_dismiss.as_ref(),
548 ) {
549 let mut state = state_rc.borrow_mut();
550 if state.submenu_content_id == Some(sub_id) {
551 state.submenu_content_id = None;
552 }
553 }
554 })
555 };
556
557 // Shared activation for assistive-tech / automation (AccessKit `Click`).
558 // Mirrors the Enter/Space `on_key` path exactly: a regular item flips its
559 // bound mode, runs the user action, and dismisses the chain; a submenu
560 // trigger opens its nested overlay. The item already advertises
561 // `Action::Click` in `accessibility()`, but without a handler that
562 // advertised action is inert — this makes it activatable.
563 let activate_item: std::rc::Rc<dyn Fn(&mut EventContext)> = {
564 let mode_activate = mode_activate.clone();
565 let action = action_rc.clone();
566 let sub_id = submenu_content_id;
567 let open = submenu_open_signal.clone();
568 let needed = submenu_needed_signal.clone();
569 let dismiss = submenu_dismiss_callback.clone();
570 std::rc::Rc::new(move |ctx: &mut EventContext| {
571 if let Some(ref activate) = mode_activate {
572 activate();
573 }
574 if let Some(ref action) = *action {
575 action(ctx);
576 ctx.dismiss_self_overlay_chain();
577 } else if mode_activate.is_some() {
578 ctx.dismiss_self_overlay_chain();
579 } else if let Some(sub_id) = sub_id {
580 ctx.dismiss_child_overlays_except(sub_id);
581 // Build the submenu if this is the first time it is wanted, before
582 // the overlay below is measured against it.
583 needed.set(true);
584 ctx.materialize_now(sub_id);
585 ctx.activate(sub_id);
586 open.set(true);
587 ctx.show_overlay(
588 OverlayRequest::new(
589 sub_id,
590 self_id,
591 OverlayPlacement::TrailingEdge,
592 SubmenuOpenRoute::KeyboardOrAt.dismiss(),
593 )
594 .on_dismiss(dismiss.clone()),
595 );
596 ctx.request_focus(sub_id);
597 }
598 })
599 };
600
601 // The enclosing `MenuList` keeps focus while its rows are highlighted,
602 // so it activates this row itself on Enter, Space, the inline-forward
603 // arrow and a mnemonic, and it does so here: the keyboard route, not a
604 // synthesised click. A click is a mouse's, and a submenu a mouse
605 // opens is dismissed 150 ms after the pointer leaves it, so a
606 // keyboard-opened submenu closed itself wherever the mouse happened
607 // to rest. The framework gates a click on a disabled row; this gate
608 // is that one.
609 if let Some(slot) = self.keyboard_activation.as_ref() {
610 let activate = activate_item.clone();
611 let enabled = effective_enabled.clone();
612 *slot.borrow_mut() = Some(std::rc::Rc::new(move |ctx: &mut EventContext| {
613 if enabled.get() {
614 activate(ctx);
615 }
616 }));
617 }
618
619 let mut handler_set = HandlerSet::new();
620
621 if is_submenu {
622 // --- Submenu trigger: timer-based delayed open ---
623 // On hover enter: request a delayed overlay via the widget tree's
624 // timer system (like tooltips). On hover leave: cancel the pending
625 // request. The widget tree checks pending overlays during layout()
626 // and opens them once the delay elapses.
627 let sub_id = submenu_content_id.expect("is_submenu implies submenu_content_id is Some");
628 let open_delay = self.submenu_open_delay;
629
630 let open_for_tap = submenu_open_signal.clone();
631 let needed_for_tap = submenu_needed_signal.clone();
632 let dismiss_for_tap = submenu_dismiss_callback.clone();
633 let open_for_hover = submenu_open_signal.clone();
634 let needed_for_hover = submenu_needed_signal.clone();
635 let dismiss_for_hover = submenu_dismiss_callback.clone();
636 // Capture the safe-triangle shared state so the hover
637 // handler can publish / retract "this is the submenu the
638 // pointer is travelling to" for its siblings.
639 let safe_triangle_hover = self.safe_triangle.clone();
640 // Framework gates events on `arena.is_enabled(self_id)`.
641 handler_set = handler_set
642 .on_tap({
643 move |_pos, ctx: &mut EventContext| {
644 // Click on submenu trigger opens it immediately
645 ctx.dismiss_child_overlays_except(sub_id);
646 // Build the submenu if this is the first time it is wanted, before
647 // the overlay below is measured against it.
648 needed_for_tap.set(true);
649 ctx.materialize_now(sub_id);
650 ctx.activate(sub_id);
651 open_for_tap.set(true);
652 ctx.show_overlay(
653 OverlayRequest::new(
654 sub_id,
655 self_id,
656 OverlayPlacement::TrailingEdge,
657 SubmenuOpenRoute::Tap(ctx.pointer_kind()).dismiss(),
658 )
659 .on_dismiss(dismiss_for_tap.clone()),
660 );
661 ctx.request_focus(sub_id);
662 }
663 })
664 .on_hover({
665 let int_hover = int_hover.clone();
666 let hovered = hovered.clone();
667 move |entered: bool, ctx: &mut EventContext| {
668 hovered.set(entered);
669 if entered {
670 int_hover.set(MenuItemState::Hovered);
671 open_for_hover.set(true);
672 // Build the submenu if this is the first time it is wanted, before
673 // the overlay below is measured against it.
674 needed_for_hover.set(true);
675 ctx.materialize_now(sub_id);
676 // Sibling submenus are dismissed when this
677 // one *opens*, not now: a pointer merely
678 // crossing this row on its way to the
679 // submenu already open two rows up must not
680 // take that submenu down with it. See
681 // `show_overlay_after_replacing_siblings`.
682 ctx.show_overlay_after_replacing_siblings(
683 OverlayRequest::new(
684 sub_id,
685 self_id,
686 OverlayPlacement::TrailingEdge,
687 SubmenuOpenRoute::Hover.dismiss(),
688 )
689 .on_dismiss(dismiss_for_hover.clone()),
690 open_delay,
691 sub_id,
692 );
693 } else {
694 int_hover.set(MenuItemState::Idle);
695 ctx.cancel_delayed_overlay(sub_id);
696 if ctx.overlay_bounds_for_content(sub_id).is_some() {
697 // The submenu is up and the pointer has
698 // just left this row — which is exactly
699 // where the diagonal toward it begins.
700 // Arm the safe triangle from here: the
701 // framework holds off the overlay's
702 // pointer-leave grace, and siblings
703 // consult the same region before they
704 // switch. `submenu_open` deliberately
705 // stays `true` — the panel is still on
706 // screen, so the row stays highlighted
707 // and `set_expanded` stays honest until
708 // the dismiss callback fires.
709 ctx.arm_overlay_safe_region(sub_id);
710 if let Some(state_rc) = safe_triangle_hover.as_ref() {
711 state_rc.borrow_mut().submenu_content_id = Some(sub_id);
712 }
713 } else {
714 // Never opened — the hover-open delay was
715 // cancelled while still pending, so the
716 // dismiss callback that would reset the
717 // flag will never fire. Do it here.
718 open_for_hover.set(false);
719 if let Some(state_rc) = safe_triangle_hover.as_ref() {
720 let mut state = state_rc.borrow_mut();
721 if state.submenu_content_id == Some(sub_id) {
722 state.submenu_content_id = None;
723 }
724 }
725 }
726 }
727 }
728 });
729 } else {
730 // --- Regular menu item: tap to activate ---
731 let action_for_tap = action_rc.clone();
732 let int_tap = interaction.clone();
733
734 handler_set = handler_set
735 .on_tap({
736 move |_pos, ctx: &mut EventContext| {
737 int_tap.set(MenuItemState::Pressed);
738 // 1. Flip the bound state first (Check / Radio),
739 // so the user-supplied action sees the
740 // post-activation value.
741 if let Some(ref activate) = mode_activate_for_tap {
742 activate();
743 }
744 // 2. Invoke the user action if any.
745 if let Some(ref action) = *action_for_tap {
746 action(ctx);
747 }
748 // 3. Dismiss the chain when EITHER an action
749 // fired OR a mode flip happened. Plain items
750 // without an action used to no-op the click;
751 // Check/Radio items without an action still
752 // dismiss because the visible state changed.
753 if action_for_tap.is_some() || mode_activate_for_tap.is_some() {
754 ctx.dismiss_self_overlay_chain();
755 }
756 // Reset to Idle after dispatching — the
757 // overlay dismissal swallows the trailing
758 // PointerUp that would normally clear Pressed,
759 // and the dormant content widgets keep their
760 // last-painted state. Without this the
761 // previously-clicked item reads as Pressed
762 // (highlighted) the next time the menu opens,
763 // until a hover transition overwrites it.
764 int_tap.set(MenuItemState::Idle);
765 }
766 })
767 .on_hover({
768 let safe_triangle_sibling = self.safe_triangle.clone();
769 let hovered = hovered.clone();
770 move |entered: bool, ctx: &mut EventContext| {
771 hovered.set(entered);
772 if entered {
773 // Safe-triangle gate: while a traversal
774 // toward an open submenu in this list is
775 // live, skip the dismiss — the user may be
776 // crossing this row on the way there rather
777 // than choosing it — and let the overlay's
778 // own pointer-leave grace decide. That grace
779 // tests the cone on every sample and closes
780 // the submenu one close-delay after the
781 // pointer stops heading there, which is the
782 // decision this handler cannot make: it
783 // fires once, at the instant the pointer
784 // crosses onto the row, a pixel or two from
785 // the apex where the cone is still a needle.
786 // Asking whether *that* sample was inside
787 // the cone let one quantized step settle it,
788 // and a submenu died the moment the pointer
789 // left the trigger row for any departure
790 // steeper than the cone.
791 let traversal_live = safe_triangle_sibling
792 .as_ref()
793 .and_then(|state_rc| state_rc.borrow().submenu_content_id)
794 .is_some_and(|sub| ctx.overlay_safe_region_armed(sub));
795 if !traversal_live {
796 ctx.dismiss_child_overlays();
797 }
798 int_hover.set(MenuItemState::Hovered);
799 } else {
800 int_hover.set(MenuItemState::Idle);
801 }
802 }
803 });
804 }
805
806 // Keyboard handler shared by both submenu and regular items
807 handler_set = handler_set.on_key({
808 let interaction = interaction.clone();
809 let sub_id = submenu_content_id;
810 let open_for_key = submenu_open_signal.clone();
811 let needed_for_key = submenu_needed_signal.clone();
812 let dismiss_for_key = submenu_dismiss_callback.clone();
813 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
814 // The "open submenu / go deeper" key is inline-forward:
815 // ArrowRight under LTR, ArrowLeft under RTL (submenus open
816 // on the trailing edge, which mirrors). The inline-back
817 // key (ArrowLeft under LTR, ArrowRight under RTL) is left
818 // to bubble / to the framework's nested-overlay dismissal.
819 let open_submenu_key = if ctx.is_rtl() {
820 Key::ArrowLeft
821 } else {
822 Key::ArrowRight
823 };
824 match event {
825 WidgetEvent::KeyDown {
826 key: Key::Enter | Key::Space,
827 ..
828 } => {
829 // Mirror the tap activation order: bound-state
830 // mutation first, then user action, then chain
831 // dismissal. Submenu triggers fall through to
832 // the existing open path (they never carry a
833 // bound mode signal).
834 if let Some(ref activate) = mode_activate_for_key {
835 activate();
836 }
837 if let Some(ref action) = *action_for_key {
838 action(ctx);
839 ctx.dismiss_self_overlay_chain();
840 } else if mode_activate_for_key.is_some() {
841 // Check/Radio with no user action — still dismiss.
842 ctx.dismiss_self_overlay_chain();
843 } else if let Some(sub_id) = sub_id {
844 ctx.dismiss_child_overlays_except(sub_id);
845 // Build the submenu if this is the first time it is wanted, before
846 // the overlay below is measured against it.
847 needed_for_key.set(true);
848 ctx.materialize_now(sub_id);
849 ctx.activate(sub_id);
850 open_for_key.set(true);
851 ctx.show_overlay(
852 OverlayRequest::new(
853 sub_id,
854 self_id,
855 OverlayPlacement::TrailingEdge,
856 SubmenuOpenRoute::KeyboardOrAt.dismiss(),
857 )
858 .on_dismiss(dismiss_for_key.clone()),
859 );
860 ctx.request_focus(sub_id);
861 }
862 interaction.set(MenuItemState::Pressed);
863 EventResponse::Handled
864 }
865 // Inline-forward arrow opens submenu (ignored on
866 // regular items). RTL-flipped via `open_submenu_key`.
867 WidgetEvent::KeyDown { key, .. } if *key == open_submenu_key => {
868 if let Some(sub_id) = sub_id {
869 ctx.dismiss_child_overlays_except(sub_id);
870 // Build the submenu if this is the first time it is wanted, before
871 // the overlay below is measured against it.
872 needed_for_key.set(true);
873 ctx.materialize_now(sub_id);
874 ctx.activate(sub_id);
875 open_for_key.set(true);
876 ctx.show_overlay(
877 OverlayRequest::new(
878 sub_id,
879 self_id,
880 OverlayPlacement::TrailingEdge,
881 SubmenuOpenRoute::KeyboardOrAt.dismiss(),
882 )
883 .on_dismiss(dismiss_for_key.clone()),
884 );
885 ctx.request_focus(sub_id);
886 EventResponse::Handled
887 } else {
888 EventResponse::Ignored
889 }
890 }
891 _ => EventResponse::Ignored,
892 }
893 }
894 });
895
896 // Assistive-tech / automation activation. Click (the default action)
897 // and Expand (submenu triggers) both run the shared activation.
898 handler_set = handler_set.on_access_action({
899 let activate = activate_item.clone();
900 move |action, ctx: &mut EventContext| -> EventResponse {
901 use teksilo_core::accesskit::Action;
902 if matches!(action, Action::Click | Action::Expand) {
903 activate(ctx);
904 EventResponse::Handled
905 } else {
906 EventResponse::Ignored
907 }
908 }
909 });
910
911 // Cursor is always Pointer. `HandlerSet::cursor` stores a *static*
912 // `CursorIcon` on the node — there is no reactive form — so reading
913 // `effective_enabled.get()` here only snapshots the value at build
914 // time. Menu-bar dropdowns materialise their items while dormant
915 // (often with every enablement signal still `false`), so that
916 // snapshot permanently stuck rows on `NotAllowed` even after the
917 // signal later went true and clicks started working. The framework
918 // also gates *all* events — including `PointerEnter`, the path that
919 // applies `node_cursor` — on `arena.is_enabled`, so a `NotAllowed`
920 // icon could never show for a truly-disabled item either. Match
921 // `Button` / `IconButton`: Pointer while interactive; greyed paint
922 // + gated events while disabled.
923 handler_set = handler_set.cursor(CursorIcon::Pointer);
924
925 ctx.apply_self_handlers(handler_set);
926
927 vec![root_id]
928 }
929
930 fn layout_response(
931 &self,
932 proposal: SizeProposal,
933 ctx: &LayoutContext,
934 ) -> teksilo_core::widget::LayoutResponse {
935 match self.root_child_id {
936 Some(id) => {
937 let size = ctx
938 .child_size(id, proposal)
939 .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
940 // Claim the full proposed width when the parent offers one.
941 // This is what makes menu items stretch to the popup width:
942 // MenuList sizes its VStack to the widest item, then the
943 // VStack proposes that width to each child. Without this
944 // line, each MenuItem would report only its own content
945 // width and the row's internal Spacer would have no room
946 // to stretch — so the shortcut would sit flush against
947 // the label instead of pushing to the trailing edge.
948 let width = proposal.width.unwrap_or(size.width);
949 Size::new(width, size.height)
950 }
951 None => proposal.resolve(120.0, 24.0),
952 }
953 .into()
954 }
955
956 fn place_children(
957 &self,
958 bounds: Rect,
959 _proposal: SizeProposal,
960 children: &mut [WidgetPlacement],
961 _ctx: &LayoutContext,
962 ) {
963 for child in children.iter_mut() {
964 child.origin = bounds.origin();
965 child.size = bounds.size();
966 }
967 }
968
969 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
970 use teksilo_core::accesskit::{HasPopup, Role, Toggled};
971
972 // Role reflects the mode: Plain → MenuItem, Check → MenuItemCheckBox,
973 // Radio → MenuItemRadio. Submenu triggers always render as
974 // Role::MenuItem (independent of mode — submenu+checkable is
975 // not a supported combination).
976 let role = match &self.mode {
977 MenuItemMode::Plain => Role::MenuItem,
978 MenuItemMode::Check(_) => Role::MenuItemCheckBox,
979 MenuItemMode::Radio { .. } => Role::MenuItemRadio,
980 };
981 builder.set_role(role);
982 // Use the stripped form for the announced name — screen readers
983 // say "Save", not "ampersand-Save". Re-parse from a fresh
984 // `resolve_now()` every walk rather than reading the build-time
985 // would otherwise announce the stale-locale name. The cached
986 // mnemonic index is still used for the access key below, and by
987 // `MenuList` for type-ahead and in-menu mnemonic activation —
988 // `MenuLabel` re-parses its own source for the underline.
989 let parsed_name = parse_mnemonic(&self.label.resolve_now()).stripped;
990 builder.set_name(parsed_name);
991
992 // Where this row stands among its menu's shown rows ("3 of 7"). The
993 // menu carries the count, since AccessKit reads an item's set size
994 // from its container.
995 if let (Some(rows), Some(id)) = (self.menu_rows.as_ref(), self.self_id)
996 && let Some(position) = rows.borrow().position_of(id)
997 {
998 builder.set_position_in_set(position);
999 }
1000
1001 // Toggle state for Check / Radio. Mirrors `Checkbox`:
1002 // `set_toggled(bool)` for binary, `inner_mut().set_toggled(Toggled::Mixed)`
1003 // for tri-state Indeterminate.
1004 match &self.mode {
1005 MenuItemMode::Plain => {}
1006 MenuItemMode::Check(CheckKind::TwoState(s)) => {
1007 builder.set_toggled(s.get());
1008 }
1009 MenuItemMode::Check(CheckKind::Reflect(s)) => {
1010 builder.set_toggled(s.get());
1011 }
1012 MenuItemMode::Check(CheckKind::TriState(s)) => match s.get() {
1013 CheckState::Unchecked => builder.set_toggled(false),
1014 CheckState::Checked => builder.set_toggled(true),
1015 CheckState::Indeterminate => {
1016 builder.inner_mut().set_toggled(Toggled::Mixed);
1017 }
1018 },
1019 MenuItemMode::Radio { value, selected } => {
1020 builder.set_toggled(selected.get() == *value);
1021 }
1022 }
1023
1024 // Radio "2 of N" — push every group member id (including self)
1025 // into the AT node so assistive tech can announce
1026 // position-in-set. Only emitted for Radio items where the
1027 // enclosing MenuList wired up the group buffer. Mirrors
1028 // [`RadioButton::accessibility`] exactly.
1029 if let (MenuItemMode::Radio { .. }, Some(buf)) = (&self.mode, self.radio_group_ids.as_ref())
1030 {
1031 for sibling in buf.borrow().iter().copied() {
1032 builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(
1033 sibling,
1034 ));
1035 }
1036 }
1037
1038 // A submenu trigger exposes `has_popup(Menu)` so screen
1039 // readers announce the item as leading into a nested menu,
1040 // and `set_expanded` reflects whether the submenu is
1041 // currently visible. We check `submenu_content_id` rather
1042 // than `submenu_factory`: the factory is moved out during
1043 // `build()` via `take()`, so by the time the framework
1044 // queries accessibility the factory is always `None`,
1045 // but the content id survives.
1046 if self.submenu_content_id.is_some() {
1047 builder.set_has_popup(HasPopup::Menu);
1048 let open = self.submenu_open.get();
1049 builder.set_expanded(open);
1050 // State-appropriate Expand/Collapse (Click, advertised below, opens
1051 // it too). Handled by the `on_access_action` handler in `build()`.
1052 if open {
1053 builder.add_action(teksilo_core::accesskit::Action::Collapse);
1054 } else {
1055 builder.add_action(teksilo_core::accesskit::Action::Expand);
1056 }
1057 }
1058 // Framework a11y walker sets `set_disabled` from arena state.
1059 builder.add_action(teksilo_core::accesskit::Action::Click);
1060 // Announce the current chord *live*: a manual label, else the
1061 // per-id signal's present value — so AT reflects a rebind even
1062 // though the item itself is never rebuilt for shortcut changes.
1063 let accel = self.shortcut_label.clone().or_else(|| {
1064 self.shortcut_signal
1065 .as_ref()
1066 .and_then(|sig| sig.get().map(format_keystroke))
1067 });
1068 if let Some(accel) = accel {
1069 builder.set_keyboard_shortcut(accel);
1070 }
1071 // A trailing hint is prose, not a chord — it belongs in the
1072 // description so AT reads "Scene, inside" rather than announcing
1073 // "inside" as a key to press. Resolved here rather than at build
1074 // time so the a11y tree follows a live locale change too.
1075 if let Some(hint) = self.trailing_hint.as_ref() {
1076 builder.set_description(hint.resolve_now());
1077 }
1078
1079 // Mnemonic — populates AccessKit's `access_key` field, which
1080 // Windows Narrator announces as "Access key: F" on items
1081 // carrying a single-character menu accelerator. Distinct from
1082 // the (rebindable) `keyboard_shortcut` field above, which
1083 // carries Ctrl+S-style accelerators. Empty / non-mnemonic
1084 // labels emit nothing.
1085 if let Some(parsed) = self.parsed_mnemonic.as_ref()
1086 && let Some(k) = parsed.key_lower
1087 {
1088 builder
1089 .inner_mut()
1090 .set_access_key(k.to_ascii_uppercase().to_string());
1091 }
1092 }
1093
1094 fn children(&self) -> Vec<WidgetId> {
1095 match self.root_child_id {
1096 Some(id) => vec![id],
1097 None => Vec::new(),
1098 }
1099 }
1100
1101 /// Opt into reflection so [`MenuList::build`](crate::menu_list::MenuList::build)
1102 /// can downcast a pending boxed item and install its radio group
1103 /// buffer before the item is added to the arena.
1104 fn as_any(&self) -> Option<&dyn std::any::Any> {
1105 Some(self)
1106 }
1107
1108 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1109 Some(self)
1110 }
1111}