Skip to main content

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}