Skip to main content

teksilo_widgets/
menu_item.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! MenuItem — a single command row in a menu or context menu.
5//!
6//! Each item consists of an optional leading icon, a label, an optional
7//! trailing shortcut label, and an activation closure. `MenuItem` is
8//! non-generic: actions are type-erased closures identical to `Button`'s
9//! `on_activate_fn` model. Submenus are declared with `MenuItem::submenu`
10//! — the factory builds the nested `MenuList` lazily at hover time.
11//!
12//! Every item operates in one of three **modes** selected by builder methods:
13//!
14//! | Builder | AT Role | Leading glyph |
15//! |---|---|---|
16//! | (default) | `Role::MenuItem` | icon or blank |
17//! | `.checked(signal)` | `Role::MenuItemCheckBox` | checkmark / blank |
18//! | `.check_state(signal)` | `Role::MenuItemCheckBox` | check / dash / blank |
19//! | `.reflect_checked(signal)` | `Role::MenuItemCheckBox` | checkmark (read-only) |
20//! | `.radio(value, selected)` | `Role::MenuItemRadio` | filled dot / blank |
21//!
22//! Check and radio modes are mutually exclusive with `.icon(...)` — the
23//! Windows convention reserves the leading slot for state glyphs on
24//! checkable items; a `debug_assert!` fires when both are set.
25//!
26//! ## An icon that keeps its own colour
27//!
28//! `.icon(...)` recolours whatever it is handed with the row's text role, so the
29//! glyph follows hover, press and disabled alongside the label. That is right for
30//! an icon that says the same thing as the label, and wrong for one whose colour
31//! *is* the content — a tag's swatch, a status light, a colour a person chose.
32//!
33//! `.icon_keeps_color()` leaves it alone. Two costs come with it: the icon no
34//! longer follows the highlight (on a style whose highlighted row is a solid
35//! accent fill, it has to carry its own contrast against that fill), and a
36//! *literal* colour does not dim in a disabled row — `ColorProp::Static` and
37//! `Bound` ignore the enabled state, while every role variant substitutes its
38//! disabled counterpart. An icon that should dim wants a role, and then it does
39//! not want this at all.
40//!
41//! ```rust
42//! # use teksilo_widgets::{MenuItem, primitives::IconWidget};
43//! # use teksilo_canvas::{Path, Point};
44//! # use teksilo_i18n::lit;
45//! # use teksilo_tokens::Color;
46//! let swatch = IconWidget::from_path(Path::circle(Point::new(5.0, 5.0), 4.5), 10.0)
47//!     .color(Color::from_hex("#e91e63"));
48//! let _w = MenuItem::new(lit!("Characters"))
49//!     .icon(swatch)
50//!     .icon_keeps_color();
51//! ```
52//!
53//! **Mnemonic markers** use the in-string `&` convention (`&Save` →
54//! underline 'S' when Alt is held; `&&` → literal `&`). The enclosing
55//! `MenuList` wires bare-letter in-menu activation automatically.
56//!
57//! ```rust
58//! # use teksilo_widgets::MenuItem;
59//! # use teksilo_i18n::lit;
60//! # use teksilo_core::Intent;
61//! let _w = MenuItem::new(lit!("&Save"))
62//!     .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.save")));
63//! ```
64
65use std::rc::Rc;
66use std::time::Duration;
67use teksilo_data::CheckState;
68use teksilo_i18n::lit;
69
70use teksilo_canvas::{Rect, Size, SizeProposal};
71use teksilo_core::accessibility::AccessNodeBuilder;
72use teksilo_core::build_context::BuildContext;
73use teksilo_core::event::{EventResponse, Key, WidgetEvent};
74use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
75use teksilo_core::shortcut::KeyStroke;
76use teksilo_core::signal::{Prop, Signal};
77use teksilo_core::styles::{MenuItemStyleConfig, SharedMenuItemStyle};
78use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
79use teksilo_core::widget_builder::HandlerSet;
80use teksilo_core::widget_id::WidgetId;
81use teksilo_tokens::{TextRole, TextStyleRole};
82
83use crate::keystroke_format::format_keystroke;
84use crate::primitives::{HStack, IconWidget, Spacer, Switcher, TextWidget};
85use teksilo_i18n::LocalizedString;
86
87mod menu_label;
88mod mnemonic;
89mod safe_triangle;
90pub(crate) use menu_label::MenuLabel;
91pub(crate) use mnemonic::{ParsedMnemonic, parse_mnemonic};
92pub(crate) use safe_triangle::point_in_safe_triangle;
93
94/// Type-erased command factory. Stored as `Rc` (not `Box`) so the closure
95/// can be cloned and shared — in particular with SplitButton, which reads
96/// the action out of a MenuItem via `MenuItem::action()` and re-fires it
97/// from its main region without disturbing the MenuItem's own use of it.
98type CommandFactory = Rc<dyn Fn(&mut EventContext)>;
99
100/// Interaction state for a menu item.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum MenuItemState {
103    Idle,
104    Hovered,
105    Pressed,
106    Disabled,
107}
108
109/// Default delay before a submenu opens on hover (400 ms — IntelliJ's value).
110/// This delay also provides diagonal movement tolerance: when the pointer
111/// crosses other menu items while moving toward a submenu, those items
112/// don't open their submenus because the delay hasn't elapsed yet. 400 ms
113/// is long enough that a casual sweep past a submenu trigger doesn't
114/// accidentally open it, but short enough that a deliberate hover feels
115/// responsive.
116const DEFAULT_SUBMENU_OPEN_DELAY: Duration = Duration::from_millis(400);
117const DEFAULT_SUBMENU_CLOSE_DELAY: Duration = Duration::from_millis(150);
118
119/// Glyph size for the check / dash / radio-dot rendered in the
120/// 16dp `MENU_ICON_COLUMN_WIDTH` leading slot. 12dp matches the
121/// existing `chevron_right(12.0)` used for submenu triggers.
122const MENU_INDICATOR_GLYPH_SIZE: f32 = 12.0;
123
124/// Internal selection mode of a `MenuItem`. `Plain` is the default
125/// and produces `Role::MenuItem`. `Check` swaps the leading-slot
126/// icon for a checkmark (binary) or check/dash/spacer (tri-state)
127/// and emits `Role::MenuItemCheckBox`. `Radio` swaps the leading
128/// slot for a filled dot when the radio group's `selected` signal
129/// matches `value` and emits `Role::MenuItemRadio`.
130///
131/// The state signals are kept here unboxed so `accessibility()`
132/// can read the current value cheaply via `Signal::get()`.
133enum MenuItemMode {
134    Plain,
135    Check(CheckKind),
136    Radio {
137        value: usize,
138        selected: Signal<usize>,
139    },
140}
141
142/// Internal dual-mode for checkable items — mirrors `Checkbox`'s
143/// internal `CheckKind` exactly so MenuItem and Checkbox behave
144/// identically when they share the same `Signal<bool>` /
145/// `Signal<CheckState>`.
146enum CheckKind {
147    TwoState(Signal<bool>),
148    TriState(Signal<CheckState>),
149    /// Reflect-only: the checkmark mirrors `state`, but activation does **not**
150    /// write it — the bound value's truth lives elsewhere (a model / method) and
151    /// the item's `on_activate`/intent is solely responsible for changing it.
152    /// The classic "View ▸ Sidebar / Full Screen" pattern, where the check
153    /// follows layout state the menu doesn't own. Renders identically to
154    /// `TwoState`; differs only in that clicking has no built-in toggle.
155    Reflect(Prop<bool>),
156}
157
158/// A single command row in a `MenuList` or context menu.
159///
160/// See the module documentation for the full mode table, mnemonic syntax, and
161/// submenu construction pattern.
162pub struct MenuItem {
163    label: LocalizedString,
164    icon: Option<IconWidget>,
165    /// Leave the icon's own colour alone instead of tinting it with the row's
166    /// text role — see [`MenuItem::icon_keeps_color`].
167    icon_keeps_color: bool,
168    shortcut_label: Option<String>,
169    /// A trailing *descriptive* phrase — not an accelerator. Unlike
170    /// `shortcut_label` this stays a [`LocalizedString`], so it re-resolves
171    /// on a live locale change, and it is announced as the item's
172    /// accessible *description* rather than its keyboard shortcut.
173    trailing_hint: Option<LocalizedString>,
174    /// Optional shortcut id. When set and `shortcut_label` is not, the
175    /// rendered trailing label is pulled from the tree's
176    /// [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry) and
177    /// tracks user rebindings automatically — reactively, via a *per-id*
178    /// signal (see `shortcut_signal`), so a rebind refreshes the chord in
179    /// place instead of rebuilding the whole item.
180    shortcut_id: Option<&'static str>,
181    tooltip_text: Option<LocalizedString>,
182    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
183    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
184    action: Option<CommandFactory>,
185    /// Enabled-state (static or signal-bound); forwarded to the arena at build
186    /// time via `enabled_when`, so a bound signal disables/enables the item
187    /// reactively (paint and AT follow). Cursor stays `Pointer` — see
188    /// the cursor assignment in `build` for why it is not derived from this.
189    enabled: Prop<bool>,
190    /// Plain / Check / Radio — see [`MenuItemMode`].
191    mode: MenuItemMode,
192    /// Sibling ids for radio-group AT announcement. Set by
193    /// [`MenuList::build`](crate::menu_list::MenuList::build) on
194    /// every radio-mode item that shares a `Signal<usize>` with
195    /// other items in the same list, via
196    /// `set_radio_group_ids(...)`. Used in `accessibility()` to
197    /// emit `push_to_radio_group(sibling_id)` so AT announces
198    /// "Theme Dark, 2 of 3". Empty for non-radio items and for
199    /// solitary radio items.
200    radio_group_ids: Option<Rc<std::cell::RefCell<Vec<WidgetId>>>>,
201    submenu_factory: Option<Box<dyn Fn() -> Box<dyn Widget>>>,
202    submenu_open_delay: Duration,
203    // Build state
204    interaction: Signal<MenuItemState>,
205    /// Whether this item's submenu overlay is currently visible.
206    /// Flipped to `true` by every open path (tap, hover, Enter,
207    /// ArrowRight) and flipped back to `false` by the overlay
208    /// manager's `on_dismiss` callback — regardless of dismiss
209    /// path. `accessibility()` reads this for `set_expanded`.
210    /// Only meaningful when `submenu_factory.is_some()`.
211    submenu_open: Signal<bool>,
212    /// "This submenu has been wanted at least once" — the reveal gate for its
213    /// deferred content. Distinct from `submenu_open`, which is the disclosure
214    /// state AT reads and the chevron follows: the hover path schedules a
215    /// *delayed* overlay and must have the content built before the delay
216    /// matures, while the item is not yet open.
217    submenu_needed: Signal<bool>,
218    /// Live per-id handle to the effective primary keystroke for
219    /// `shortcut_id`, obtained in `build()` from
220    /// [`BuildContext::effective_shortcut_signal`]. The trailing label
221    /// binds it (leaf-level, so a rebind repaints in place and the item
222    /// is never rebuilt on registry churn), and `accessibility()` reads
223    /// it live so screen readers announce the current chord. `None` for
224    /// items with a manual `shortcut_label` or no shortcut at all.
225    shortcut_signal: Option<Signal<Option<KeyStroke>>>,
226    /// Per-call override for the label's text style (font, size, weight).
227    /// `None` ⇒ the default `TextStyleRole::Body`.
228    label_style: Option<teksilo_core::color_prop::TextStyleProp>,
229    /// Per-call override for the label text color. `None` ⇒ the
230    /// interaction/enabled-derived cascade (hover / disabled). Setting
231    /// this replaces the cascade (loses the hover/disabled tint), so use
232    /// it only when a host enforces a fixed text role.
233    text_role_override: Option<teksilo_core::color_prop::ColorProp>,
234    /// Per-call style override. When `None`, falls back to the
235    /// theme-wide slot (`theme.style_slots.menu_item`) and finally to
236    /// the IntUI default `RecipeMenuItemStyle`.
237    style_override: Option<SharedMenuItemStyle>,
238    root_child_id: Option<WidgetId>,
239    submenu_content_id: Option<WidgetId>,
240    /// Parsed mnemonic from the label, captured during `build()`. The
241    /// enclosing [`MenuList`](crate::menu_list::MenuList) reads this
242    /// to wire in-menu mnemonic activation (bare-letter Alt
243    /// shortcut) and the keyboard-driven type-ahead.
244    parsed_mnemonic: Option<ParsedMnemonic>,
245    /// Shared safe-triangle state owned by the enclosing
246    /// [`MenuList`](crate::menu_list::MenuList). Submenu triggers
247    /// write to it on hover-enter (stamp the anchor); sibling items
248    /// read it before firing their hover-switch so a diagonal
249    /// pointer trajectory toward the open submenu doesn't steal
250    /// focus. `None` for items that haven't been adopted by a
251    /// MenuList (e.g. solo menu items in tests).
252    safe_triangle: Option<crate::menu_list::SharedSafeTriangleState>,
253}
254
255impl MenuItem {
256    /// Create a plain menu item with the given label and no action yet.
257    pub fn new(label: impl Into<LocalizedString>) -> Self {
258        let ls: LocalizedString = label.into();
259        Self {
260            label: ls,
261            icon: None,
262            icon_keeps_color: false,
263            shortcut_label: None,
264            trailing_hint: None,
265            shortcut_id: None,
266            tooltip_text: None,
267            rich_tooltip_source: None,
268            composite_tooltip_content: None,
269            action: None,
270            enabled: Prop::Static(true),
271            mode: MenuItemMode::Plain,
272            radio_group_ids: None,
273            submenu_factory: None,
274            submenu_open_delay: DEFAULT_SUBMENU_OPEN_DELAY,
275            interaction: Signal::new(MenuItemState::Idle),
276            submenu_open: Signal::new(false),
277            submenu_needed: Signal::new(false),
278            shortcut_signal: None,
279            label_style: None,
280            text_role_override: None,
281            style_override: None,
282            root_child_id: None,
283            submenu_content_id: None,
284            parsed_mnemonic: None,
285            safe_triangle: None,
286        }
287    }
288
289    /// Closure invoked on activation.
290    /// Note: shortcut label auto-lookup is not available with this variant
291    /// since there is no typed command to look up.
292    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
293        self.action = Some(Rc::new(f));
294        self
295    }
296
297    /// Read the item's display label. Exposed so SplitButton (and any other
298    /// compound widget that embeds a MenuItem) can mirror the label in its
299    /// own chrome.
300    pub fn label(&self) -> String {
301        self.label.resolve_now()
302    }
303
304    /// Like [`label`](Self::label) but returns the unresolved
305    /// [`LocalizedString`], so embedders can mirror the label *reactively*
306    /// (re-resolving on a locale switch) instead of freezing a snapshot.
307    pub fn label_localized(&self) -> LocalizedString {
308        self.label.clone()
309    }
310
311    /// Clone out a shared handle to the activation closure. Returns `None`
312    /// when this MenuItem has no action (e.g. it's a submenu trigger). The
313    /// returned `Rc` aliases MenuItem's own internal handle — invoking it
314    /// has the same effect as the user clicking this menu item (minus the
315    /// overlay dismissal that the tap handler also performs).
316    pub fn action(&self) -> Option<Rc<dyn Fn(&mut EventContext)>> {
317        self.action.clone()
318    }
319
320    /// Set a leading icon.
321    pub fn icon(mut self, icon: IconWidget) -> Self {
322        self.icon = Some(icon);
323        self
324    }
325
326    /// Keep the icon's **own** colour rather than tinting it with the row's.
327    ///
328    /// A menu icon normally says the same thing as the label beside it, so it takes
329    /// the row's text role and follows it through hover, press and disabled — which
330    /// is why [`icon`](Self::icon) recolours whatever it is handed. Some icons are
331    /// not that. A tag's swatch, a status light, a colour a person chose: there the
332    /// colour *is* the content, and tinting it to the menu's foreground deletes the
333    /// only thing the icon was there to say.
334    ///
335    /// Opt-in, because the default is right for nearly every row, and keeping a
336    /// colour has two costs the caller takes on:
337    ///
338    /// * **It does not follow the highlight.** On a style whose highlighted row is a
339    ///   solid accent fill (the macOS recipe), the icon has to carry its own contrast
340    ///   against that fill as well as against the menu's surface.
341    /// * **It does not dim when the row is disabled** — if it is a literal colour.
342    ///   That is [`ColorProp`](teksilo_core::ColorProp)'s own rule everywhere, not a
343    ///   special case here: `Static` and `Bound` ignore the enabled state, while every
344    ///   role variant substitutes its disabled counterpart. An icon that should dim
345    ///   should be given a role instead, and then it does not need this at all.
346    ///
347    /// Ignored in the check and radio modes, which draw an indicator glyph of the
348    /// framework's own rather than the caller's icon.
349    pub fn icon_keeps_color(mut self) -> Self {
350        self.icon_keeps_color = true;
351        self
352    }
353
354    /// Set a trailing shortcut label (e.g., "Ctrl+X"). Shortcut labels are
355    /// typically not translated (they're the key combination literal), so
356    /// this accepts a plain string.
357    pub fn shortcut_label(mut self, label: impl Into<String>) -> Self {
358        self.shortcut_label = Some(label.into());
359        self
360    }
361
362    /// Set a trailing *descriptive* hint (e.g. "inside", "after parent") —
363    /// a secondary phrase explaining what the item will do, rendered in the
364    /// same trailing slot as an accelerator but semantically unrelated to one.
365    ///
366    /// Prefer this over [`shortcut_label`](Self::shortcut_label) for any
367    /// trailing text that is not a key combination. It differs in two ways
368    /// that matter:
369    ///
370    /// * it takes a [`LocalizedString`], so a `tr!(...)` hint re-resolves on
371    ///   a live locale change instead of being frozen at build time;
372    /// * it is announced as the item's accessible **description**, not as
373    ///   `keyboard_shortcut` — a screen reader would otherwise read the
374    ///   phrase out as if it were a chord to press.
375    ///
376    /// Independent of the accelerator: an item may carry both, in which case
377    /// the chord renders first and the hint follows it.
378    pub fn trailing_hint(mut self, text: impl Into<LocalizedString>) -> Self {
379        self.trailing_hint = Some(text.into());
380        self
381    }
382
383    /// Bind the trailing shortcut label to a registered
384    /// [`Shortcut`](teksilo_core::shortcut::Shortcut) by its stable id.
385    /// At build time the effective primary keystroke is rendered;
386    /// rebinds performed through
387    /// [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry)
388    /// rebuild this item automatically via the registry's version
389    /// signal.
390    ///
391    /// A manual [`shortcut_label`](Self::shortcut_label) takes
392    /// precedence when both are set.
393    pub fn for_shortcut(mut self, id: &'static str) -> Self {
394        self.shortcut_id = Some(id);
395        self
396    }
397
398    /// Set the enabled state — static or signal-bound. A bound `Signal<bool>`
399    /// enables/disables the item reactively (paint and AT follow), so
400    /// `MenuItem::new(...).enabled(can_save_signal)` greys out live without a
401    /// rebuild. Cursor is always `Pointer` (see `build`); disabled items are
402    /// gated by the arena before hover runs, so a `NotAllowed` cursor cannot
403    /// be applied from a build-time snapshot of this prop either.
404    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
405        self.enabled = enabled.into();
406        self
407    }
408
409    /// Per-call style override. Replaces the theme-wide default
410    /// `MenuItemStyle` for just this MenuItem instance.
411    pub fn style(mut self, style: impl teksilo_core::styles::MenuItemStyle) -> Self {
412        self.style_override = Some(Rc::new(style));
413        self
414    }
415
416    /// Override the label's text style (font, size, weight). Accepts a
417    /// `TextStyleRole`, a `TextStyle`, or a `Signal` of either. Default
418    /// (unset) is `TextStyleRole::Body`.
419    pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self {
420        self.label_style = Some(style.into());
421        self
422    }
423
424    /// Override the label text color. Accepts `Color`, a role, or a
425    /// `Signal` of either. Default (unset) is the interaction/enabled
426    /// cascade; setting this replaces that cascade (the hover / disabled
427    /// tint no longer applies), so reserve it for chrome that enforces a
428    /// fixed text role.
429    pub fn text_role(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
430        self.text_role_override = Some(color.into());
431        self
432    }
433
434    /// Attach a tooltip that appears after a hover delay, same mechanism
435    /// as [`Button::tooltip`](crate::button::Button::tooltip).
436    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
437        self.tooltip_text = Some(text.into());
438        self.rich_tooltip_source = None;
439        self.composite_tooltip_content = None;
440        self
441    }
442
443    /// Attach a rich tooltip resolved from the app-wide tooltip
444    /// registry. Body text supports inline markup
445    /// (`[label](url)`, `*italic*`, `**bold**`); the entry's shortcut
446    /// and long-form "more" fields are rendered automatically.
447    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
448        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
449        self.tooltip_text = None;
450        self.composite_tooltip_content = None;
451        self
452    }
453
454    /// Attach a rich tooltip driven by inline `TooltipContent`.
455    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
456        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
457        self.tooltip_text = None;
458        self.composite_tooltip_content = None;
459        self
460    }
461
462    /// Attach a composite tooltip — third tier, hosting an arbitrary
463    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
464    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
465        self.composite_tooltip_content = Some(Box::new(content));
466        self.tooltip_text = None;
467        self.rich_tooltip_source = None;
468        self
469    }
470
471    /// Create a submenu trigger item. The factory is invoked during `build()` to
472    /// pre-create the submenu content (typically a `MenuList`), which is kept
473    /// dormant until the hover delay elapses.
474    pub fn submenu(
475        label: impl Into<LocalizedString>,
476        factory: impl Fn() -> Box<dyn Widget> + 'static,
477    ) -> Self {
478        let ls: LocalizedString = label.into();
479        Self {
480            label: ls,
481            icon: None,
482            icon_keeps_color: false,
483            shortcut_label: None,
484            trailing_hint: None,
485            shortcut_id: None,
486            tooltip_text: None,
487            rich_tooltip_source: None,
488            composite_tooltip_content: None,
489            action: None,
490            enabled: Prop::Static(true),
491            mode: MenuItemMode::Plain,
492            radio_group_ids: None,
493            submenu_factory: Some(Box::new(factory)),
494            submenu_open_delay: DEFAULT_SUBMENU_OPEN_DELAY,
495            interaction: Signal::new(MenuItemState::Idle),
496            submenu_open: Signal::new(false),
497            submenu_needed: Signal::new(false),
498            shortcut_signal: None,
499            label_style: None,
500            text_role_override: None,
501            style_override: None,
502            root_child_id: None,
503            submenu_content_id: None,
504            parsed_mnemonic: None,
505            safe_triangle: None,
506        }
507    }
508
509    /// Set a custom submenu open delay (default: 400 ms, IntelliJ's value;
510    /// see `DEFAULT_SUBMENU_OPEN_DELAY` for what that delay also buys).
511    pub fn submenu_delay(mut self, delay: Duration) -> Self {
512        self.submenu_open_delay = delay;
513        self
514    }
515
516    /// Whether this is a submenu trigger.
517    pub fn is_submenu(&self) -> bool {
518        self.submenu_factory.is_some()
519    }
520
521    /// Bind this item to a two-state `Signal<bool>`. The item renders
522    /// `Role::MenuItemCheckBox`; activation flips the signal. By
523    /// Windows convention, the leading icon slot becomes a checkmark
524    /// when the signal is `true`, blank otherwise.
525    ///
526    /// Mutually exclusive with [`check_state`](Self::check_state)
527    /// and [`radio`](Self::radio) — last call wins.
528    pub fn checked(mut self, state: Signal<bool>) -> Self {
529        self.mode = MenuItemMode::Check(CheckKind::TwoState(state));
530        self
531    }
532
533    /// Render `Role::MenuItemCheckBox` whose checkmark **reflects** `state`
534    /// read-only: activation does NOT write the signal — the truth lives
535    /// elsewhere (a model / method), and this item's `on_activate`/intent is
536    /// responsible for the change, after which `state` updates the checkmark
537    /// reactively. Use for "View ▸ Sidebar / Full Screen"-style commands that
538    /// mirror externally-owned state (e.g. `DockingModel::dock_open_signal`),
539    /// where two-way [`checked`](Self::checked) would fight the model.
540    ///
541    /// Mutually exclusive with the other check / radio binders — last call wins.
542    pub fn reflect_checked(mut self, state: impl Into<Prop<bool>>) -> Self {
543        self.mode = MenuItemMode::Check(CheckKind::Reflect(state.into()));
544        self
545    }
546
547    /// Bind this item to a tri-state `Signal<CheckState>`. The item
548    /// renders `Role::MenuItemCheckBox`; activation cycles
549    /// `Unchecked` ↔ `Checked` (per Windows / [`Checkbox`](crate::checkbox::Checkbox)
550    /// convention: `Indeterminate` is reserved for external sources
551    /// like `TreeCheckedModel`; clicking from `Indeterminate`
552    /// promotes to `Checked`).
553    ///
554    /// The leading-slot glyph is `checkmark` for `Checked`, `dash`
555    /// for `Indeterminate`, blank for `Unchecked` — matching the
556    /// Windows mixed-state convention.
557    ///
558    /// Mutually exclusive with [`checked`](Self::checked)
559    /// and [`radio`](Self::radio) — last call wins.
560    pub fn check_state(mut self, state: Signal<CheckState>) -> Self {
561        self.mode = MenuItemMode::Check(CheckKind::TriState(state));
562        self
563    }
564
565    /// Bind this item to a radio group via a shared `Signal<usize>`.
566    /// Activation writes `value` into `selected`; all radio items
567    /// sharing the same `selected` signal observe the change and
568    /// update their leading-slot dot accordingly. The item renders
569    /// `Role::MenuItemRadio`.
570    ///
571    /// For "2 of 3"-style AT announcement, the enclosing
572    /// [`MenuList`](crate::menu_list::MenuList) groups radio items
573    /// by selection-signal identity and emits `push_to_radio_group`
574    /// relationships automatically — no app-side wiring required.
575    ///
576    /// Mutually exclusive with [`checked`](Self::checked)
577    /// and [`check_state`](Self::check_state) — last call
578    /// wins.
579    pub fn radio(mut self, value: usize, selected: Signal<usize>) -> Self {
580        self.mode = MenuItemMode::Radio { value, selected };
581        self
582    }
583
584    /// Internal accessor for [`MenuList::build`](crate::menu_list::MenuList::build)
585    /// — read whether this item is a radio with a given group-id
586    /// (the `Rc`-identity of its `selected` signal).
587    pub(crate) fn radio_selection_handle(&self) -> Option<(usize, Signal<usize>)> {
588        match &self.mode {
589            MenuItemMode::Radio { value, selected } => Some((*value, selected.clone())),
590            _ => None,
591        }
592    }
593
594    /// Internal setter for [`MenuList::build`](crate::menu_list::MenuList::build)
595    /// — install the sibling id buffer so `accessibility()` can
596    /// announce "2 of N" via `push_to_radio_group`.
597    pub(crate) fn set_radio_group_ids(&mut self, ids: Rc<std::cell::RefCell<Vec<WidgetId>>>) {
598        self.radio_group_ids = Some(ids);
599    }
600
601    /// Read the parsed mnemonic for this item's label. Populated
602    /// inside `build()`. Returns `None` for items that haven't been
603    /// built yet, or whose label contains no un-escaped `&` marker.
604    ///
605    /// Used by [`MenuList`](crate::menu_list::MenuList) to wire
606    /// in-menu mnemonic activation (bare-letter activation of the
607    /// matching item) — the lookup runs on every `KeyDown` so a
608    /// fresh `parse_mnemonic` per keypress would be wasteful.
609    pub(crate) fn mnemonic(&self) -> Option<&ParsedMnemonic> {
610        self.parsed_mnemonic.as_ref()
611    }
612
613    /// Pre-parse the label so that
614    /// [`MenuList::build`](crate::menu_list::MenuList::build) can
615    /// read this item's mnemonic *before* the item is committed to
616    /// the arena. Idempotent — calls after the first one are no-ops.
617    pub(crate) fn ensure_mnemonic_parsed(&mut self) {
618        if self.parsed_mnemonic.is_none() {
619            self.parsed_mnemonic = Some(parse_mnemonic(&self.label.resolve_now()));
620        }
621    }
622
623    /// Install the enclosing
624    /// [`MenuList`](crate::menu_list::MenuList)'s shared
625    /// safe-triangle state. Called by `MenuList::build` for every
626    /// item before it reaches the arena. The handle lets:
627    ///
628    /// - a submenu trigger stamp the anchor (pointer position at
629    ///   submenu-open time) and the open submenu's content id;
630    /// - a sibling item read the anchor + submenu id on hover and
631    ///   skip its dismiss / open call when the cursor is currently
632    ///   inside the safe triangle.
633    pub(crate) fn set_safe_triangle_state(
634        &mut self,
635        state: crate::menu_list::SharedSafeTriangleState,
636    ) {
637        self.safe_triangle = Some(state);
638    }
639}
640
641impl std::fmt::Debug for MenuItem {
642    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
643        let mode = match &self.mode {
644            MenuItemMode::Plain => "Plain",
645            MenuItemMode::Check(CheckKind::TwoState(_)) => "Check(TwoState)",
646            MenuItemMode::Check(CheckKind::TriState(_)) => "Check(TriState)",
647            MenuItemMode::Check(CheckKind::Reflect(_)) => "Check(Reflect)",
648            MenuItemMode::Radio { .. } => "Radio",
649        };
650        f.debug_struct("MenuItem")
651            .field("label", &self.label)
652            .field("enabled", &self.enabled)
653            .field("is_submenu", &self.submenu_factory.is_some())
654            .field("mode", &mode)
655            .finish()
656    }
657}
658
659fn resolve_text_role(state: MenuItemState) -> TextRole {
660    match state {
661        MenuItemState::Disabled => TextRole::Disabled,
662        _ => TextRole::Primary,
663    }
664}
665
666fn resolve_shortcut_role(state: MenuItemState) -> TextRole {
667    match state {
668        MenuItemState::Disabled => TextRole::Disabled,
669        _ => TextRole::TooltipShortcut,
670    }
671}
672
673/// Whether a state is the row's *highlighted* one — the state a
674/// [`MenuItemStyle::highlighted_label_role`](teksilo_core::styles::MenuItemStyle::highlighted_label_role)
675/// applies to. Hover and the
676/// keyboard-arrow highlight share `Hovered`; a pressed row is still
677/// highlighted underneath the press.
678fn is_highlight(state: MenuItemState) -> bool {
679    matches!(state, MenuItemState::Hovered | MenuItemState::Pressed)
680}
681
682impl Widget for MenuItem {
683    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
684        use crate::styles::recipe_menu_item_style as menu;
685        let self_id = ctx.self_id();
686        // Forward enabled (static or signal-bound) into the arena. A bound
687        // signal makes enable/disable reactive — the framework's
688        // effective_enabled drives paint / AT (and event gating).
689        ctx.enabled_when(self_id, self.enabled.clone());
690        let effective_enabled = ctx.effective_enabled_signal(self_id);
691
692        // Interaction seeds to Idle; the framework's effective_enabled
693        // drives the Disabled visual via the recipe and through the
694        // leaves' role substitution.
695        let interaction = ctx.signal(MenuItemState::Idle);
696        self.interaction = interaction.clone();
697
698        // Resolved here rather than at `make_body` because the label is
699        // built long before the chrome, and a style whose highlight is a
700        // *solid* fill (macOS's accent row) has to say so in time to
701        // recolour it. Per-call override > theme slot > shipped recipe.
702        let style: SharedMenuItemStyle = self
703            .style_override
704            .clone()
705            .or_else(|| ctx.theme().style_slots.menu_item.clone())
706            .unwrap_or_else(|| Rc::new(crate::styles::RecipeMenuItemStyle::default()));
707        let highlighted_role = style.highlighted_label_role();
708
709        // Combine interaction + effective_enabled so `text_role`
710        // resolves to Disabled when disabled. Keeps the icon and label
711        // muted on hover-while-disabled too (defense in depth — the
712        // leaves' `ColorProp::resolve(theme, ctx.effective_enabled)`
713        // would substitute Disabled anyway).
714        let text_role = interaction.zip(&effective_enabled).map(move |(s, on)| {
715            if !*on {
716                TextRole::Disabled
717            } else {
718                highlighted_role
719                    .filter(|_| is_highlight(*s))
720                    .unwrap_or_else(|| resolve_text_role(*s))
721            }
722        });
723
724        // Build the three slots fed to the active `MenuItemStyle`.
725        // The style decides the row layout (and chrome); the widget
726        // owns the slot contents.
727        //
728        // Leading: icon column — always reserved at `icon_column_width`,
729        // even when the item has no icon, so labels line up vertically
730        // between icon'd and icon-less items.
731        //
732        // For Check / Radio modes the slot becomes a `Switcher`
733        // driven by the bound state signal, swapping between the
734        // glyph and a `Spacer`. The framework's binding system
735        // re-paints the leaf when the signal flips — no rebuild.
736        //
737        // Icon + Check/Radio are mutually exclusive (Windows
738        // convention). If both are set, `debug_assert!` fires and the
739        // check/radio mode wins in release.
740        let leading = {
741            let icon_child_id = match &self.mode {
742                MenuItemMode::Plain => match self.icon.take() {
743                    // The caller's own colour stands — see `icon_keeps_color`.
744                    Some(icon) if self.icon_keeps_color => ctx.add(icon),
745                    Some(icon) => ctx.add(icon.color(text_role.clone())),
746                    None => ctx.add(Spacer::new()),
747                },
748                MenuItemMode::Check(CheckKind::TwoState(s)) => {
749                    debug_assert!(
750                        self.icon.is_none(),
751                        "MenuItem: .icon() is mutually exclusive with a checkmark (checked / reflect_checked)"
752                    );
753                    self.icon = None;
754                    // 0 = checkmark, 1 = spacer.
755                    let idx = s.map(|b| if *b { 0_usize } else { 1 });
756                    ctx.add(
757                        Switcher::new(idx)
758                            .child(
759                                IconWidget::checkmark(MENU_INDICATOR_GLYPH_SIZE)
760                                    .color(text_role.clone()),
761                            )
762                            .child(Spacer::new()),
763                    )
764                }
765                MenuItemMode::Check(CheckKind::Reflect(s)) => {
766                    debug_assert!(
767                        self.icon.is_none(),
768                        "MenuItem: .icon() is mutually exclusive with a checkmark (checked / reflect_checked)"
769                    );
770                    self.icon = None;
771                    // 0 = checkmark, 1 = spacer.
772                    let idx = s.as_signal().map(|b| if *b { 0_usize } else { 1 });
773                    ctx.add(
774                        Switcher::new(idx)
775                            .child(
776                                IconWidget::checkmark(MENU_INDICATOR_GLYPH_SIZE)
777                                    .color(text_role.clone()),
778                            )
779                            .child(Spacer::new()),
780                    )
781                }
782                MenuItemMode::Check(CheckKind::TriState(s)) => {
783                    debug_assert!(
784                        self.icon.is_none(),
785                        "MenuItem: .icon() is mutually exclusive with .check_state()"
786                    );
787                    self.icon = None;
788                    // 0 = checkmark (Checked), 1 = dash (Indeterminate), 2 = spacer (Unchecked).
789                    let idx = s.map(|cs| match cs {
790                        CheckState::Checked => 0_usize,
791                        CheckState::Indeterminate => 1,
792                        CheckState::Unchecked => 2,
793                    });
794                    ctx.add(
795                        Switcher::new(idx)
796                            .child(
797                                IconWidget::checkmark(MENU_INDICATOR_GLYPH_SIZE)
798                                    .color(text_role.clone()),
799                            )
800                            .child(
801                                IconWidget::dash(MENU_INDICATOR_GLYPH_SIZE)
802                                    .color(text_role.clone()),
803                            )
804                            .child(Spacer::new()),
805                    )
806                }
807                MenuItemMode::Radio { value, selected } => {
808                    debug_assert!(
809                        self.icon.is_none(),
810                        "MenuItem: .icon() is mutually exclusive with .radio()"
811                    );
812                    self.icon = None;
813                    let v = *value;
814                    // 0 = filled dot (selected == value), 1 = spacer.
815                    let idx = selected.map(move |sel| if *sel == v { 0_usize } else { 1 });
816                    ctx.add(
817                        Switcher::new(idx)
818                            .child(
819                                IconWidget::radio_dot(MENU_INDICATOR_GLYPH_SIZE)
820                                    .color(text_role.clone()),
821                            )
822                            .child(Spacer::new()),
823                    )
824                }
825            };
826            ctx.add(
827                crate::primitives::FixedSize::new()
828                    .width(menu::MENU_ICON_COLUMN_WIDTH)
829                    .height(menu::MENU_ICON_COLUMN_WIDTH)
830                    .child_id(icon_child_id),
831            )
832        };
833
834        // Label. Uses `MenuLabel` (not `TextWidget`) so a single `&`
835        // in the label is parsed as a mnemonic marker — stripped from
836        // the visible text and underlined when `alt_down` is held.
837        // The parsed form is cached so the enclosing MenuList can
838        // read it for type-ahead and in-menu mnemonic activation.
839        let parsed = parse_mnemonic(&self.label.resolve_now());
840        self.parsed_mnemonic = Some(parsed.clone());
841        let alt_down = ctx
842            .window()
843            .map(|w| w.alt_down().clone())
844            .unwrap_or_else(|| Signal::new(false));
845        let label_source: teksilo_core::signal::Prop<String> = self.label.clone().into();
846        let label_color: teksilo_core::color_prop::ColorProp = self
847            .text_role_override
848            .clone()
849            .unwrap_or_else(|| text_role.clone().into());
850        let label_style: teksilo_core::color_prop::TextStyleProp = self
851            .label_style
852            .clone()
853            .unwrap_or_else(|| TextStyleRole::Body.into());
854        let label = ctx.add(MenuLabel::new(
855            label_source,
856            alt_down,
857            label_color,
858            label_style,
859        ));
860
861        // Resolve the trailing accelerator *reactively*. A manual
862        // `shortcut_label` is a static string; a `shortcut_id` binds a
863        // per-id registry signal (built into the trailing slot below), so
864        // a rebind of *that* id refreshes the chord in place. Crucially we
865        // do NOT observe the coarse global `shortcut_version` at `Rebuild`
866        // here — doing so tore the whole item (its gesture arena) down on
867        // *any* shortcut-registry activity anywhere, dropping the click on
868        // menu items that show a shortcut. The item is now never rebuilt
869        // for shortcut changes; only its trailing label repaints.
870        self.shortcut_signal = self.shortcut_id.map(|id| ctx.effective_shortcut_signal(id));
871
872        // Pre-create submenu content if this is a submenu trigger. Kept
873        // dormant until hover opens the overlay.
874        let submenu_content_id = if let Some(factory) = self.submenu_factory.take() {
875            let submenu_widget = factory();
876            // Detached (a submenu opens in an overlay beside the item, never
877            // inline) but owned, so it dies with the item instead of outliving
878            // every menu the user ever opened.
879            // Built the first time the submenu is actually wanted. A menu of
880            // twenty items with submenus used to build all twenty submenus —
881            // and their submenus — the moment the menu was mounted.
882            let id = ctx.add_detached_deferred_boxed(self.submenu_needed.clone(), submenu_widget);
883            ctx.set_dormant(id);
884            self.submenu_content_id = Some(id);
885            Some(id)
886        } else {
887            None
888        };
889
890        // Trailing slot — combines (optional shortcut + fixed gap +
891        // optional chevron column). The chevron column is always
892        // reserved at `item_padding_horizontal` so submenu and
893        // regular items share the same trailing edge.
894        let trailing = {
895            let mut trailing_row = HStack::new().spacing(0.0);
896            // Trailing accelerator. Present whenever this item references a
897            // shortcut (manual `shortcut_label`, or a `shortcut_id`). For an
898            // id it binds the per-id signal reactively (empty ⇒ zero-width,
899            // so a shortcut appearing/disappearing needs no rebuild); for a
900            // manual label it's a static string.
901            let shortcut: Option<TextWidget> = if let Some(label) = self.shortcut_label.clone() {
902                Some(TextWidget::new(lit!(label)))
903            } else {
904                self.shortcut_signal.clone().map(|sig| {
905                    TextWidget::new(lit!(""))
906                        .text(sig.map(|ks| (*ks).map(format_keystroke).unwrap_or_default()))
907                })
908            };
909            let has_shortcut = shortcut.is_some();
910            if let Some(shortcut) = shortcut {
911                let shortcut_role = interaction.map(move |s| {
912                    highlighted_role
913                        .filter(|_| is_highlight(*s))
914                        .unwrap_or_else(|| resolve_shortcut_role(*s))
915                });
916                trailing_row = trailing_row.child(
917                    shortcut
918                        .style(TextStyleRole::Body)
919                        .color(shortcut_role)
920                        .single_line()
921                        .a11y_hidden(),
922                );
923            }
924            // Trailing descriptive hint. Unlike the accelerator above this is
925            // built straight from the `LocalizedString`, so `TextWidget`'s own
926            // `Prop<String>` conversion binds it to the locale signal and it
927            // re-resolves in place on a language switch. It is `a11y_hidden`
928            // because it is announced as the item's *description* instead (see
929            // `accessibility`), never as a keyboard shortcut.
930            if let Some(hint) = self.trailing_hint.clone() {
931                if has_shortcut {
932                    // Both set (rare) — keep the chord and the phrase apart.
933                    trailing_row = trailing_row.child(
934                        crate::primitives::FixedSize::new()
935                            .width(menu::MENU_ITEM_PADDING_HORIZONTAL),
936                    );
937                }
938                let hint_role = interaction.map(move |s| {
939                    highlighted_role
940                        .filter(|_| is_highlight(*s))
941                        .unwrap_or_else(|| resolve_shortcut_role(*s))
942                });
943                trailing_row = trailing_row.child(
944                    TextWidget::new(hint)
945                        .style(TextStyleRole::Body)
946                        .color(hint_role)
947                        .single_line()
948                        .a11y_hidden(),
949                );
950            }
951            // Chevron column. Always reserved (Spacer when no submenu)
952            // so the row's right edge sits at exactly the same X
953            // regardless of submenu-ness.
954            //
955            // The submenu opens on the trailing edge
956            // (`OverlayPlacement::TrailingEdge`) — right under LTR, left
957            // under RTL — so the chevron must point the same way. Drive a
958            // `Switcher` off the locale's direction signal so it flips
959            // live on a locale change (0 = LTR → ▶, 1 = RTL → ◀). With no
960            // i18n manager installed there's no RTL, so fall back to the
961            // plain right-pointing chevron.
962            let chevron_child_id = if submenu_content_id.is_some() {
963                match teksilo_i18n::current_direction() {
964                    Some(direction) => {
965                        let idx = direction.map(|d| {
966                            if *d == teksilo_core::environment::LayoutDirection::RightToLeft {
967                                1_usize
968                            } else {
969                                0
970                            }
971                        });
972                        ctx.add(
973                            Switcher::new(idx)
974                                .child(IconWidget::chevron_right(12.0).color(text_role.clone()))
975                                .child(IconWidget::chevron_left(12.0).color(text_role.clone())),
976                        )
977                    }
978                    None => ctx.add(IconWidget::chevron_right(12.0).color(text_role.clone())),
979                }
980            } else {
981                ctx.add(Spacer::new())
982            };
983            let chevron_column = ctx.add(
984                crate::primitives::FixedSize::new()
985                    .width(menu::MENU_ITEM_PADDING_HORIZONTAL)
986                    .height(menu::MENU_ICON_COLUMN_WIDTH)
987                    .child_id(chevron_child_id),
988            );
989            trailing_row = trailing_row.add_child(chevron_column);
990            ctx.add(trailing_row)
991        };
992
993        // Derive the four boolean signals the trait wants.
994        let is_hovered = interaction.map(|s| matches!(s, MenuItemState::Hovered));
995        let is_pressed = interaction.map(|s| matches!(s, MenuItemState::Pressed));
996        let is_disabled = interaction.map(|s| matches!(s, MenuItemState::Disabled));
997
998        // MenuItem doesn't track focus/highlight separately today —
999        // hovered already covers the keyboard-arrow case in the
1000        // existing dispatcher. Wire is_focused to a constant false
1001        // signal; is_highlighted reads the same as is_hovered for
1002        // the IntUI default (the recipe `or`s them anyway).
1003        let is_focused = ctx.signal(false);
1004        let is_highlighted = is_hovered.clone();
1005
1006        let cfg = MenuItemStyleConfig {
1007            label,
1008            leading: Some(leading),
1009            trailing: Some(trailing),
1010            is_hovered,
1011            is_pressed,
1012            is_focused,
1013            is_disabled,
1014            is_highlighted,
1015        };
1016        let root_id = style.make_body(&cfg, ctx);
1017
1018        self.root_child_id = Some(root_id);
1019
1020        // Attach tooltip if configured. The three setters
1021        // (`tooltip`, `rich_tooltip*`, `composite_tooltip`) are
1022        // mutually exclusive — setters clear the other two so at most
1023        // one branch runs. A `MenuItem` only ever lives in a vertical
1024        // `MenuList`, so the tooltip opens to the trailing `Side` — a
1025        // `Below` tooltip would cover the next item down.
1026        use crate::tooltip::TooltipPlacement;
1027        if let Some(content) = self.composite_tooltip_content.take() {
1028            let delay = ctx.theme().motion.tooltip_delay_heavy;
1029            crate::tooltip::attach_composite_tooltip_boxed_with_placement(
1030                ctx,
1031                root_id,
1032                content,
1033                delay,
1034                TooltipPlacement::Side,
1035            );
1036        } else if let Some(source) = self.rich_tooltip_source.clone() {
1037            // Cloned, not taken: `build()` re-runs on every rebuild, and an item
1038            // that consumed its source attached a tooltip once and then silently
1039            // lost it — the surviving entry pointed at the previous build's body,
1040            // which the rebuild had just destroyed. (`composite_tooltip_content`
1041            // above is a `Box<dyn Widget>` with no way to clone, so it keeps the
1042            // take and its one-shot behaviour.)
1043            let delay = ctx.theme().motion.tooltip_delay;
1044            crate::tooltip::attach_rich_tooltip_source_with_placement(
1045                ctx,
1046                root_id,
1047                source,
1048                delay,
1049                TooltipPlacement::Side,
1050            );
1051        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
1052            let delay = ctx.theme().motion.tooltip_delay;
1053            crate::tooltip::attach_plain_tooltip_with_placement(
1054                ctx,
1055                root_id,
1056                tooltip_text,
1057                delay,
1058                TooltipPlacement::Side,
1059            );
1060        }
1061
1062        // --- Handlers ---
1063        let action = self.action.take();
1064        let action_rc: std::rc::Rc<Option<CommandFactory>> = std::rc::Rc::new(action);
1065        let action_for_key = action_rc.clone();
1066
1067        // Shared closure that performs the bound-state mutation on
1068        // activation — flips the check signal, cycles the tristate
1069        // signal, or writes the radio value. Captured by both the
1070        // tap and key handlers so click and Enter/Space have
1071        // identical semantics. `None` for `Plain` and for submenu
1072        // triggers (which never carry a bound state).
1073        type ActivateFn = std::rc::Rc<dyn Fn()>;
1074        let mode_activate: Option<ActivateFn> = match &self.mode {
1075            MenuItemMode::Plain => None,
1076            MenuItemMode::Check(CheckKind::TwoState(s)) => {
1077                let s = s.clone();
1078                Some(std::rc::Rc::new(move || s.set(!s.get())))
1079            }
1080            // Reflect-only: no built-in write — the on_activate / intent owns
1081            // the state change; the checkmark follows `state` reactively.
1082            MenuItemMode::Check(CheckKind::Reflect(_)) => None,
1083            MenuItemMode::Check(CheckKind::TriState(s)) => {
1084                let s = s.clone();
1085                // Click toggles Unchecked <-> Checked. Indeterminate
1086                // (driven by external aggregation models) promotes
1087                // to Checked. Mirrors `Checkbox::toggle`.
1088                Some(std::rc::Rc::new(move || match s.get() {
1089                    CheckState::Unchecked => s.set(CheckState::Checked),
1090                    CheckState::Checked => s.set(CheckState::Unchecked),
1091                    CheckState::Indeterminate => s.set(CheckState::Checked),
1092                }))
1093            }
1094            MenuItemMode::Radio { value, selected } => {
1095                let v = *value;
1096                let selected = selected.clone();
1097                Some(std::rc::Rc::new(move || selected.set(v)))
1098            }
1099        };
1100        let mode_activate_for_tap = mode_activate.clone();
1101        let mode_activate_for_key = mode_activate.clone();
1102
1103        let int_hover = interaction.clone();
1104        let self_id = ctx.self_id();
1105        let is_submenu = submenu_content_id.is_some();
1106
1107        // Shared dismiss callback for the submenu overlay. Flipped
1108        // to `false` by the overlay manager when the submenu is
1109        // dismissed by any path (pointer leave, cascade, Escape,
1110        // click outside) so `accessibility()` can report accurate
1111        // `set_expanded` without needing to track the overlay state
1112        // from inside the MenuItem's own handlers.
1113        //
1114        // Also clears the safe-triangle anchor when the overlay
1115        // actually closes — keeping the anchor alive across
1116        // hover-leave (so sibling hovers heading toward the
1117        // submenu are properly gated) means we MUST clear it here
1118        // once the submenu is finally gone.
1119        let submenu_open_signal = self.submenu_open.clone();
1120        let submenu_needed_signal = self.submenu_needed.clone();
1121        let submenu_content_id_for_dismiss = submenu_content_id;
1122        let safe_triangle_for_dismiss = self.safe_triangle.clone();
1123        let submenu_dismiss_callback: teksilo_core::overlay::OverlayDismissCallback = {
1124            let open = submenu_open_signal.clone();
1125            std::rc::Rc::new(move || {
1126                open.set(false);
1127                if let (Some(sub_id), Some(state_rc)) = (
1128                    submenu_content_id_for_dismiss,
1129                    safe_triangle_for_dismiss.as_ref(),
1130                ) {
1131                    let mut state = state_rc.borrow_mut();
1132                    if state.submenu_content_id == Some(sub_id) {
1133                        state.submenu_content_id = None;
1134                        state.anchor = None;
1135                    }
1136                }
1137            })
1138        };
1139
1140        // Shared activation for assistive-tech / automation (AccessKit `Click`).
1141        // Mirrors the Enter/Space `on_key` path exactly: a regular item flips its
1142        // bound mode, runs the user action, and dismisses the chain; a submenu
1143        // trigger opens its nested overlay. The item already advertises
1144        // `Action::Click` in `accessibility()`, but without a handler that
1145        // advertised action is inert — this makes it activatable.
1146        let activate_item: std::rc::Rc<dyn Fn(&mut EventContext)> = {
1147            let mode_activate = mode_activate.clone();
1148            let action = action_rc.clone();
1149            let sub_id = submenu_content_id;
1150            let open = submenu_open_signal.clone();
1151            let needed = submenu_needed_signal.clone();
1152            let dismiss = submenu_dismiss_callback.clone();
1153            std::rc::Rc::new(move |ctx: &mut EventContext| {
1154                if let Some(ref activate) = mode_activate {
1155                    activate();
1156                }
1157                if let Some(ref action) = *action {
1158                    action(ctx);
1159                    ctx.dismiss_self_overlay_chain();
1160                } else if mode_activate.is_some() {
1161                    ctx.dismiss_self_overlay_chain();
1162                } else if let Some(sub_id) = sub_id {
1163                    ctx.dismiss_child_overlays_except(sub_id);
1164                    // Build the submenu if this is the first time it is wanted, before
1165                    // the overlay below is measured against it.
1166                    needed.set(true);
1167                    ctx.materialize_now(sub_id);
1168                    ctx.activate(sub_id);
1169                    open.set(true);
1170                    ctx.show_overlay(OverlayRequest {
1171                        content_id: sub_id,
1172                        anchor: self_id,
1173                        placement: OverlayPlacement::TrailingEdge,
1174                        dismiss: DismissBehavior::PointerLeave {
1175                            delay: DEFAULT_SUBMENU_CLOSE_DELAY,
1176                        },
1177                        layer: OverlayLayer::InTree,
1178                        parent_overlay: None,
1179                        on_dismiss: Some(dismiss.clone()),
1180                        fade_duration: None,
1181                    });
1182                    ctx.request_focus(sub_id);
1183                }
1184            })
1185        };
1186
1187        let mut handler_set = HandlerSet::new();
1188
1189        if is_submenu {
1190            // --- Submenu trigger: timer-based delayed open ---
1191            // On hover enter: request a delayed overlay via the widget tree's
1192            // timer system (like tooltips). On hover leave: cancel the pending
1193            // request. The widget tree checks pending overlays during layout()
1194            // and opens them once the delay elapses.
1195            let sub_id = submenu_content_id.expect("is_submenu implies submenu_content_id is Some");
1196            let open_delay = self.submenu_open_delay;
1197
1198            let open_for_tap = submenu_open_signal.clone();
1199            let needed_for_tap = submenu_needed_signal.clone();
1200            let dismiss_for_tap = submenu_dismiss_callback.clone();
1201            let open_for_hover = submenu_open_signal.clone();
1202            let needed_for_hover = submenu_needed_signal.clone();
1203            let dismiss_for_hover = submenu_dismiss_callback.clone();
1204            // Capture the safe-triangle shared state so we can stamp
1205            // / clear the anchor on submenu open / close.
1206            let safe_triangle_open = self.safe_triangle.clone();
1207            let safe_triangle_close = self.safe_triangle.clone();
1208            // Framework gates events on `arena.is_enabled(self_id)`.
1209            handler_set = handler_set
1210                .on_tap({
1211                    move |_pos, ctx: &mut EventContext| {
1212                        // Click on submenu trigger opens it immediately
1213                        ctx.dismiss_child_overlays_except(sub_id);
1214                        // Build the submenu if this is the first time it is wanted, before
1215                        // the overlay below is measured against it.
1216                        needed_for_tap.set(true);
1217                        ctx.materialize_now(sub_id);
1218                        ctx.activate(sub_id);
1219                        open_for_tap.set(true);
1220                        ctx.show_overlay(OverlayRequest {
1221                            content_id: sub_id,
1222                            anchor: self_id,
1223                            placement: OverlayPlacement::TrailingEdge,
1224                            dismiss: DismissBehavior::PointerLeave {
1225                                delay: DEFAULT_SUBMENU_CLOSE_DELAY,
1226                            },
1227                            layer: OverlayLayer::InTree,
1228                            parent_overlay: None,
1229                            on_dismiss: Some(dismiss_for_tap.clone()),
1230                            fade_duration: None,
1231                        });
1232                        ctx.request_focus(sub_id);
1233                    }
1234                })
1235                .on_hover({
1236                    let int_hover = int_hover.clone();
1237                    move |entered: bool, ctx: &mut EventContext| {
1238                        if entered {
1239                            int_hover.set(MenuItemState::Hovered);
1240                            ctx.dismiss_child_overlays_except(sub_id);
1241                            open_for_hover.set(true);
1242                            // Stamp the safe-triangle anchor so sibling
1243                            // hover-switches can suppress themselves
1244                            // while the cursor is travelling toward
1245                            // the open submenu. We use the current
1246                            // cursor position; if unavailable, the
1247                            // gate falls back to "no apex" (always
1248                            // false → no suppression).
1249                            if let Some(state_rc) = safe_triangle_open.as_ref() {
1250                                let mut state = state_rc.borrow_mut();
1251                                state.submenu_content_id = Some(sub_id);
1252                                state.anchor = ctx.tree_pointer_position();
1253                            }
1254                            // Build the submenu if this is the first time it is wanted, before
1255                            // the overlay below is measured against it.
1256                            needed_for_hover.set(true);
1257                            ctx.materialize_now(sub_id);
1258                            ctx.show_overlay_after_with_focus(
1259                                OverlayRequest {
1260                                    content_id: sub_id,
1261                                    anchor: self_id,
1262                                    placement: OverlayPlacement::TrailingEdge,
1263                                    dismiss: DismissBehavior::PointerLeave {
1264                                        delay: DEFAULT_SUBMENU_CLOSE_DELAY,
1265                                    },
1266                                    layer: OverlayLayer::InTree,
1267                                    parent_overlay: None,
1268                                    on_dismiss: Some(dismiss_for_hover.clone()),
1269                                    fade_duration: None,
1270                                },
1271                                open_delay,
1272                                sub_id,
1273                            );
1274                        } else {
1275                            int_hover.set(MenuItemState::Idle);
1276                            ctx.cancel_delayed_overlay(sub_id);
1277                            // If the overlay was still pending (delay
1278                            // not yet elapsed), its dismiss callback
1279                            // will never fire — we must reset the
1280                            // open flag ourselves. Idempotent if the
1281                            // overlay already showed: the framework
1282                            // dismiss callback will also set it false
1283                            // when the PointerLeave behavior tears
1284                            // the overlay down shortly afterward.
1285                            open_for_hover.set(false);
1286                            // Clear the safe-triangle anchor ONLY when
1287                            // the submenu never actually opened (the
1288                            // 400 ms delay was cancelled while still
1289                            // pending). When the overlay IS open, we
1290                            // leave the anchor in place — sibling
1291                            // hover handlers consult it during the
1292                            // user's diagonal travel toward the
1293                            // submenu, and the dismiss callback
1294                            // installed above clears it the moment
1295                            // the overlay actually closes. Clearing
1296                            // on every hover-leave would defeat the
1297                            // entire safe-triangle gate, because the
1298                            // trigger's hover-leave fires *before* a
1299                            // sibling's hover-enter.
1300                            if let Some(state_rc) = safe_triangle_close.as_ref()
1301                                && ctx.overlay_bounds_for_content(sub_id).is_none()
1302                            {
1303                                let mut state = state_rc.borrow_mut();
1304                                if state.submenu_content_id == Some(sub_id) {
1305                                    state.submenu_content_id = None;
1306                                    state.anchor = None;
1307                                }
1308                            }
1309                        }
1310                    }
1311                });
1312        } else {
1313            // --- Regular menu item: tap to activate ---
1314            let action_for_tap = action_rc.clone();
1315            let int_tap = interaction.clone();
1316
1317            handler_set = handler_set
1318                .on_tap({
1319                    move |_pos, ctx: &mut EventContext| {
1320                        int_tap.set(MenuItemState::Pressed);
1321                        // 1. Flip the bound state first (Check / Radio),
1322                        //    so the user-supplied action sees the
1323                        //    post-activation value.
1324                        if let Some(ref activate) = mode_activate_for_tap {
1325                            activate();
1326                        }
1327                        // 2. Invoke the user action if any.
1328                        if let Some(ref action) = *action_for_tap {
1329                            action(ctx);
1330                        }
1331                        // 3. Dismiss the chain when EITHER an action
1332                        //    fired OR a mode flip happened. Plain items
1333                        //    without an action used to no-op the click;
1334                        //    Check/Radio items without an action still
1335                        //    dismiss because the visible state changed.
1336                        if action_for_tap.is_some() || mode_activate_for_tap.is_some() {
1337                            ctx.dismiss_self_overlay_chain();
1338                        }
1339                        // Reset to Idle after dispatching — the
1340                        // overlay dismissal swallows the trailing
1341                        // PointerUp that would normally clear Pressed,
1342                        // and the dormant content widgets keep their
1343                        // last-painted state. Without this the
1344                        // previously-clicked item reads as Pressed
1345                        // (highlighted) the next time the menu opens,
1346                        // until a hover transition overwrites it.
1347                        int_tap.set(MenuItemState::Idle);
1348                    }
1349                })
1350                .on_hover({
1351                    let safe_triangle_sibling = self.safe_triangle.clone();
1352                    move |entered: bool, ctx: &mut EventContext| {
1353                        if entered {
1354                            // Safe-triangle gate: if another submenu is
1355                            // currently open AND the cursor is inside
1356                            // the triangle anchored at the
1357                            // submenu-open pointer position with its
1358                            // base on the open submenu's near edge,
1359                            // skip the dismiss — the user is en route
1360                            // to the submenu and we don't want to
1361                            // close it out from under them.
1362                            let suppress = safe_triangle_sibling
1363                                .as_ref()
1364                                .and_then(|state_rc| {
1365                                    let state = state_rc.borrow();
1366                                    let sub_content_id = state.submenu_content_id?;
1367                                    let anchor = state.anchor?;
1368                                    let pointer = ctx.tree_pointer_position()?;
1369                                    let bounds = ctx.overlay_bounds_for_content(sub_content_id)?;
1370                                    Some(point_in_safe_triangle(pointer, anchor, bounds))
1371                                })
1372                                .unwrap_or(false);
1373                            if !suppress {
1374                                ctx.dismiss_child_overlays();
1375                            }
1376                            int_hover.set(MenuItemState::Hovered);
1377                        } else {
1378                            int_hover.set(MenuItemState::Idle);
1379                        }
1380                    }
1381                });
1382        }
1383
1384        // Keyboard handler shared by both submenu and regular items
1385        handler_set = handler_set.on_key({
1386            let interaction = interaction.clone();
1387            let sub_id = submenu_content_id;
1388            let open_for_key = submenu_open_signal.clone();
1389            let needed_for_key = submenu_needed_signal.clone();
1390            let dismiss_for_key = submenu_dismiss_callback.clone();
1391            move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
1392                // The "open submenu / go deeper" key is inline-forward:
1393                // ArrowRight under LTR, ArrowLeft under RTL (submenus open
1394                // on the trailing edge, which mirrors). The inline-back
1395                // key (ArrowLeft under LTR, ArrowRight under RTL) is left
1396                // to bubble / to the framework's nested-overlay dismissal.
1397                let open_submenu_key = if ctx.is_rtl() {
1398                    Key::ArrowLeft
1399                } else {
1400                    Key::ArrowRight
1401                };
1402                match event {
1403                    WidgetEvent::KeyDown {
1404                        key: Key::Enter | Key::Space,
1405                        ..
1406                    } => {
1407                        // Mirror the tap activation order: bound-state
1408                        // mutation first, then user action, then chain
1409                        // dismissal. Submenu triggers fall through to
1410                        // the existing open path (they never carry a
1411                        // bound mode signal).
1412                        if let Some(ref activate) = mode_activate_for_key {
1413                            activate();
1414                        }
1415                        if let Some(ref action) = *action_for_key {
1416                            action(ctx);
1417                            ctx.dismiss_self_overlay_chain();
1418                        } else if mode_activate_for_key.is_some() {
1419                            // Check/Radio with no user action — still dismiss.
1420                            ctx.dismiss_self_overlay_chain();
1421                        } else if let Some(sub_id) = sub_id {
1422                            ctx.dismiss_child_overlays_except(sub_id);
1423                            // Build the submenu if this is the first time it is wanted, before
1424                            // the overlay below is measured against it.
1425                            needed_for_key.set(true);
1426                            ctx.materialize_now(sub_id);
1427                            ctx.activate(sub_id);
1428                            open_for_key.set(true);
1429                            ctx.show_overlay(OverlayRequest {
1430                                content_id: sub_id,
1431                                anchor: self_id,
1432                                placement: OverlayPlacement::TrailingEdge,
1433                                dismiss: DismissBehavior::PointerLeave {
1434                                    delay: DEFAULT_SUBMENU_CLOSE_DELAY,
1435                                },
1436                                layer: OverlayLayer::InTree,
1437                                parent_overlay: None,
1438                                on_dismiss: Some(dismiss_for_key.clone()),
1439                                fade_duration: None,
1440                            });
1441                            ctx.request_focus(sub_id);
1442                        }
1443                        interaction.set(MenuItemState::Pressed);
1444                        EventResponse::Handled
1445                    }
1446                    // Inline-forward arrow opens submenu (ignored on
1447                    // regular items). RTL-flipped via `open_submenu_key`.
1448                    WidgetEvent::KeyDown { key, .. } if *key == open_submenu_key => {
1449                        if let Some(sub_id) = sub_id {
1450                            ctx.dismiss_child_overlays_except(sub_id);
1451                            // Build the submenu if this is the first time it is wanted, before
1452                            // the overlay below is measured against it.
1453                            needed_for_key.set(true);
1454                            ctx.materialize_now(sub_id);
1455                            ctx.activate(sub_id);
1456                            open_for_key.set(true);
1457                            ctx.show_overlay(OverlayRequest {
1458                                content_id: sub_id,
1459                                anchor: self_id,
1460                                placement: OverlayPlacement::TrailingEdge,
1461                                dismiss: DismissBehavior::PointerLeave {
1462                                    delay: DEFAULT_SUBMENU_CLOSE_DELAY,
1463                                },
1464                                layer: OverlayLayer::InTree,
1465                                parent_overlay: None,
1466                                on_dismiss: Some(dismiss_for_key.clone()),
1467                                fade_duration: None,
1468                            });
1469                            ctx.request_focus(sub_id);
1470                            EventResponse::Handled
1471                        } else {
1472                            EventResponse::Ignored
1473                        }
1474                    }
1475                    _ => EventResponse::Ignored,
1476                }
1477            }
1478        });
1479
1480        // Assistive-tech / automation activation. Click (the default action)
1481        // and Expand (submenu triggers) both run the shared activation.
1482        handler_set = handler_set.on_access_action({
1483            let activate = activate_item.clone();
1484            move |action, ctx: &mut EventContext| -> EventResponse {
1485                use teksilo_core::accesskit::Action;
1486                if matches!(action, Action::Click | Action::Expand) {
1487                    activate(ctx);
1488                    EventResponse::Handled
1489                } else {
1490                    EventResponse::Ignored
1491                }
1492            }
1493        });
1494
1495        // Cursor is always Pointer. `HandlerSet::cursor` stores a *static*
1496        // `CursorIcon` on the node — there is no reactive form — so reading
1497        // `effective_enabled.get()` here only snapshots the value at build
1498        // time. Menu-bar dropdowns materialise their items while dormant
1499        // (often with every enablement signal still `false`), so that
1500        // snapshot permanently stuck rows on `NotAllowed` even after the
1501        // signal later went true and clicks started working. The framework
1502        // also gates *all* events — including `PointerEnter`, the path that
1503        // applies `node_cursor` — on `arena.is_enabled`, so a `NotAllowed`
1504        // icon could never show for a truly-disabled item either. Match
1505        // `Button` / `IconButton`: Pointer while interactive; greyed paint
1506        // + gated events while disabled.
1507        handler_set = handler_set.cursor(CursorIcon::Pointer);
1508
1509        ctx.apply_self_handlers(handler_set);
1510
1511        vec![root_id]
1512    }
1513
1514    fn layout_response(
1515        &self,
1516        proposal: SizeProposal,
1517        ctx: &LayoutContext,
1518    ) -> teksilo_core::widget::LayoutResponse {
1519        match self.root_child_id {
1520            Some(id) => {
1521                let size = ctx
1522                    .child_size(id, proposal)
1523                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1524                // Claim the full proposed width when the parent offers one.
1525                // This is what makes menu items stretch to the popup width:
1526                // MenuList sizes its VStack to the widest item, then the
1527                // VStack proposes that width to each child. Without this
1528                // line, each MenuItem would report only its own content
1529                // width and the row's internal Spacer would have no room
1530                // to stretch — so the shortcut would sit flush against
1531                // the label instead of pushing to the trailing edge.
1532                let width = proposal.width.unwrap_or(size.width);
1533                Size::new(width, size.height)
1534            }
1535            None => proposal.resolve(120.0, 24.0),
1536        }
1537        .into()
1538    }
1539
1540    fn place_children(
1541        &self,
1542        bounds: Rect,
1543        _proposal: SizeProposal,
1544        children: &mut [WidgetPlacement],
1545        _ctx: &LayoutContext,
1546    ) {
1547        for child in children.iter_mut() {
1548            child.origin = bounds.origin();
1549            child.size = bounds.size();
1550        }
1551    }
1552
1553    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1554        use teksilo_core::accesskit::{HasPopup, Role, Toggled};
1555
1556        // Role reflects the mode: Plain → MenuItem, Check → MenuItemCheckBox,
1557        // Radio → MenuItemRadio. Submenu triggers always render as
1558        // Role::MenuItem (independent of mode — submenu+checkable is
1559        // not a supported combination).
1560        let role = match &self.mode {
1561            MenuItemMode::Plain => Role::MenuItem,
1562            MenuItemMode::Check(_) => Role::MenuItemCheckBox,
1563            MenuItemMode::Radio { .. } => Role::MenuItemRadio,
1564        };
1565        builder.set_role(role);
1566        // Use the stripped form for the announced name — screen readers
1567        // say "Save", not "ampersand-Save". Re-parse from a fresh
1568        // `resolve_now()` every walk rather than reading the build-time
1569        // `parsed_mnemonic` cache: a locale switch marks the tree dirty
1570        // (re-walking AT) but does NOT rebuild the item, so the cache
1571        // would otherwise announce the stale-locale name. The cached
1572        // mnemonic index is still used for the underline in `paint`.
1573        let parsed_name = parse_mnemonic(&self.label.resolve_now()).stripped;
1574        builder.set_name(parsed_name);
1575
1576        // Toggle state for Check / Radio. Mirrors `Checkbox`:
1577        // `set_toggled(bool)` for binary, `inner_mut().set_toggled(Toggled::Mixed)`
1578        // for tri-state Indeterminate.
1579        match &self.mode {
1580            MenuItemMode::Plain => {}
1581            MenuItemMode::Check(CheckKind::TwoState(s)) => {
1582                builder.set_toggled(s.get());
1583            }
1584            MenuItemMode::Check(CheckKind::Reflect(s)) => {
1585                builder.set_toggled(s.get());
1586            }
1587            MenuItemMode::Check(CheckKind::TriState(s)) => match s.get() {
1588                CheckState::Unchecked => builder.set_toggled(false),
1589                CheckState::Checked => builder.set_toggled(true),
1590                CheckState::Indeterminate => {
1591                    builder.inner_mut().set_toggled(Toggled::Mixed);
1592                }
1593            },
1594            MenuItemMode::Radio { value, selected } => {
1595                builder.set_toggled(selected.get() == *value);
1596            }
1597        }
1598
1599        // Radio "2 of N" — push every group member id (including self)
1600        // into the AT node so assistive tech can announce
1601        // position-in-set. Only emitted for Radio items where the
1602        // enclosing MenuList wired up the group buffer. Mirrors
1603        // [`RadioButton::accessibility`] exactly.
1604        if let (MenuItemMode::Radio { .. }, Some(buf)) = (&self.mode, self.radio_group_ids.as_ref())
1605        {
1606            for sibling in buf.borrow().iter().copied() {
1607                builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(
1608                    sibling,
1609                ));
1610            }
1611        }
1612
1613        // A submenu trigger exposes `has_popup(Menu)` so screen
1614        // readers announce the item as leading into a nested menu,
1615        // and `set_expanded` reflects whether the submenu is
1616        // currently visible. We check `submenu_content_id` rather
1617        // than `submenu_factory`: the factory is moved out during
1618        // `build()` via `take()`, so by the time the framework
1619        // queries accessibility the factory is always `None`,
1620        // but the content id survives.
1621        if self.submenu_content_id.is_some() {
1622            builder.set_has_popup(HasPopup::Menu);
1623            let open = self.submenu_open.get();
1624            builder.set_expanded(open);
1625            // State-appropriate Expand/Collapse (Click, advertised below, opens
1626            // it too). Handled by the `on_access_action` handler in `build()`.
1627            if open {
1628                builder.add_action(teksilo_core::accesskit::Action::Collapse);
1629            } else {
1630                builder.add_action(teksilo_core::accesskit::Action::Expand);
1631            }
1632        }
1633        // Framework a11y walker sets `set_disabled` from arena state.
1634        builder.add_action(teksilo_core::accesskit::Action::Click);
1635        // Announce the current chord *live*: a manual label, else the
1636        // per-id signal's present value — so AT reflects a rebind even
1637        // though the item itself is never rebuilt for shortcut changes.
1638        let accel = self.shortcut_label.clone().or_else(|| {
1639            self.shortcut_signal
1640                .as_ref()
1641                .and_then(|sig| sig.get().map(format_keystroke))
1642        });
1643        if let Some(accel) = accel {
1644            builder.set_keyboard_shortcut(accel);
1645        }
1646        // A trailing hint is prose, not a chord — it belongs in the
1647        // description so AT reads "Scene, inside" rather than announcing
1648        // "inside" as a key to press. Resolved here rather than at build
1649        // time so the a11y tree follows a live locale change too.
1650        if let Some(hint) = self.trailing_hint.as_ref() {
1651            builder.set_description(hint.resolve_now());
1652        }
1653
1654        // Mnemonic — populates AccessKit's `access_key` field, which
1655        // Windows Narrator announces as "Access key: F" on items
1656        // carrying a single-character menu accelerator. Distinct from
1657        // the (rebindable) `keyboard_shortcut` field above, which
1658        // carries Ctrl+S-style accelerators. Empty / non-mnemonic
1659        // labels emit nothing.
1660        if let Some(parsed) = self.parsed_mnemonic.as_ref()
1661            && let Some(k) = parsed.key_lower
1662        {
1663            builder
1664                .inner_mut()
1665                .set_access_key(k.to_ascii_uppercase().to_string());
1666        }
1667    }
1668
1669    fn children(&self) -> Vec<WidgetId> {
1670        match self.root_child_id {
1671            Some(id) => vec![id],
1672            None => Vec::new(),
1673        }
1674    }
1675
1676    /// Opt into reflection so [`MenuList::build`](crate::menu_list::MenuList::build)
1677    /// can downcast a pending boxed item and install its radio group
1678    /// buffer before the item is added to the arena.
1679    fn as_any(&self) -> Option<&dyn std::any::Any> {
1680        Some(self)
1681    }
1682
1683    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1684        Some(self)
1685    }
1686}
1687
1688#[cfg(test)]
1689mod tests {
1690    use super::*;
1691    use crate::menu_list::MenuList;
1692    use teksilo_core::accesskit::Role;
1693    use teksilo_core::event::Modifiers;
1694    use teksilo_core::widget_tree::WidgetTree;
1695
1696    fn tree() -> WidgetTree {
1697        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
1698    }
1699
1700    fn layout(tree: &mut WidgetTree) {
1701        tree.layout(SizeProposal::exact(400.0, 300.0));
1702    }
1703
1704    // --- `MenuItemStyle::highlighted_label_role` ---
1705
1706    /// A style that fills a highlighted row with a saturated colour has to
1707    /// be able to recolour the label on top of it, and it cannot do that
1708    /// from `make_body` — `MenuItem` builds its label first.
1709    #[derive(Debug, Default, Clone, Copy)]
1710    struct OnAccentHighlightStyle;
1711
1712    impl teksilo_core::styles::MenuItemStyle for OnAccentHighlightStyle {
1713        fn make_body(
1714            &self,
1715            cfg: &MenuItemStyleConfig,
1716            ctx: &mut teksilo_core::build_context::BuildContext,
1717        ) -> WidgetId {
1718            crate::styles::RecipeMenuItemStyle::default().make_body(cfg, ctx)
1719        }
1720
1721        fn highlighted_label_role(&self) -> Option<TextRole> {
1722            Some(TextRole::OnAccent)
1723        }
1724    }
1725
1726    /// A theme whose `text_on_accent` differs from `text_primary`. IntUI's
1727    /// are both black — it pairs black labels with its teal accent — so
1728    /// the stock preset cannot tell a flipped label from an unflipped one.
1729    fn discriminating_theme() -> teksilo_core::Theme {
1730        let mut t = teksilo_core::presets::intui::light();
1731        t.colors.text_on_accent = teksilo_tokens::Color::WHITE;
1732        assert_ne!(t.colors.text_primary, t.colors.text_on_accent);
1733        t
1734    }
1735
1736    fn glyph_colors(tree: &mut WidgetTree) -> Vec<[u8; 4]> {
1737        tree.render()
1738            .glyphs
1739            .iter()
1740            .map(|g| {
1741                let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1742                [q(g.color[0]), q(g.color[1]), q(g.color[2]), q(g.color[3])]
1743            })
1744            .collect()
1745    }
1746
1747    fn rgba8(c: teksilo_tokens::Color) -> [u8; 4] {
1748        let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1749        [q(c.r()), q(c.g()), q(c.b()), q(c.a())]
1750    }
1751
1752    /// Build a menu row under `theme`, optionally hover it with a real
1753    /// pointer move, and report the glyph colours it paints.
1754    fn row_glyph_colors(theme: teksilo_core::Theme, hovered: bool, styled: bool) -> Vec<[u8; 4]> {
1755        let mut t = WidgetTree::new()
1756            .with_theme(theme)
1757            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1758                teksilo_canvas::MockTextBackend::new(),
1759            )));
1760        let mut item = MenuItem::new(lit!("Open"));
1761        if styled {
1762            item = item.style(OnAccentHighlightStyle);
1763        }
1764        let id = t.add(item);
1765        layout(&mut t);
1766        if hovered {
1767            // A real pointer move rather than poking the interaction
1768            // signal: it exercises the same path the running app takes,
1769            // and the signal is private to `build`.
1770            t.pointer_move(t.bounds(id).center());
1771            layout(&mut t);
1772        }
1773        glyph_colors(&mut t)
1774    }
1775
1776    /// The default is `None`, and a row under it keeps its own mapping
1777    /// however it is highlighted — the behaviour IntUI and Fluent rely on.
1778    #[test]
1779    fn a_style_without_the_hook_leaves_the_highlighted_label_alone() {
1780        let theme = discriminating_theme();
1781        let primary = rgba8(theme.colors.text_primary);
1782        let on_accent = rgba8(theme.colors.text_on_accent);
1783
1784        let colors = row_glyph_colors(theme, true, false);
1785        assert!(colors.contains(&primary));
1786        assert!(!colors.contains(&on_accent));
1787    }
1788
1789    /// …and a style that declares it flips the label while highlighted.
1790    #[test]
1791    fn the_hook_flips_the_label_of_a_highlighted_row() {
1792        let theme = discriminating_theme();
1793        let on_accent = rgba8(theme.colors.text_on_accent);
1794        assert!(row_glyph_colors(theme, true, true).contains(&on_accent));
1795    }
1796
1797    /// An idle row must keep its normal label even under a style that
1798    /// declares the hook, or every row in the menu would read as chosen.
1799    #[test]
1800    fn the_hook_does_not_touch_an_idle_row() {
1801        let theme = discriminating_theme();
1802        let primary = rgba8(theme.colors.text_primary);
1803        let on_accent = rgba8(theme.colors.text_on_accent);
1804
1805        let colors = row_glyph_colors(theme, false, true);
1806        assert!(colors.contains(&primary));
1807        assert!(!colors.contains(&on_accent));
1808    }
1809
1810    // --- Role coverage ---
1811
1812    fn a11y_node(
1813        update: &teksilo_core::accesskit::TreeUpdate,
1814        id: teksilo_core::widget_id::WidgetId,
1815    ) -> &teksilo_core::accesskit::Node {
1816        let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
1817        update
1818            .nodes
1819            .iter()
1820            .find(|(node_id, _)| *node_id == nid)
1821            .map(|(_, n)| n)
1822            .expect("widget present in the accessibility tree")
1823    }
1824
1825    // --- Trailing hint (descriptive phrase, not an accelerator) ---
1826
1827    /// The whole point of `trailing_hint` over `shortcut_label`: a phrase like
1828    /// "inside" must reach AT as a *description*. Routed through
1829    /// `keyboard_shortcut` (as `shortcut_label` does) a screen reader would
1830    /// announce it as a chord the user should press.
1831    #[test]
1832    fn trailing_hint_is_announced_as_a_description_not_a_chord() {
1833        let mut t = tree();
1834        let list_id =
1835            t.add(MenuList::new().item(MenuItem::new(lit!("Scene")).trailing_hint(lit!("inside"))));
1836        layout(&mut t);
1837        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItem);
1838        let update = t.sync_accessibility();
1839        let node = a11y_node(&update, item_id);
1840        assert_eq!(node.description(), Some("inside"));
1841        assert_eq!(
1842            node.keyboard_shortcut(),
1843            None,
1844            "a descriptive hint must never be announced as a keyboard shortcut"
1845        );
1846    }
1847
1848    /// The sibling guarantee — `shortcut_label` keeps its accelerator
1849    /// semantics, and does not leak into the description slot.
1850    #[test]
1851    fn shortcut_label_stays_a_chord_and_sets_no_description() {
1852        let mut t = tree();
1853        let list_id =
1854            t.add(MenuList::new().item(MenuItem::new(lit!("Save")).shortcut_label("Ctrl+S")));
1855        layout(&mut t);
1856        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItem);
1857        let update = t.sync_accessibility();
1858        let node = a11y_node(&update, item_id);
1859        assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
1860        assert_eq!(node.description(), None);
1861    }
1862
1863    /// Both may coexist: the chord and the phrase occupy the same trailing
1864    /// row but neither displaces the other, in the render or in AT.
1865    #[test]
1866    fn a_chord_and_a_hint_coexist_without_displacing_each_other() {
1867        let mut t = tree();
1868        let list_id = t.add(
1869            MenuList::new().item(
1870                MenuItem::new(lit!("Duplicate"))
1871                    .shortcut_label("Ctrl+D")
1872                    .trailing_hint(lit!("after")),
1873            ),
1874        );
1875        layout(&mut t);
1876        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItem);
1877        let update = t.sync_accessibility();
1878        let node = a11y_node(&update, item_id);
1879        assert_eq!(node.keyboard_shortcut(), Some("Ctrl+D"));
1880        assert_eq!(node.description(), Some("after"));
1881    }
1882
1883    #[test]
1884    fn plain_item_emits_role_menuitem() {
1885        let mut t = tree();
1886        let list_id = t.add(MenuList::new().item(MenuItem::new(lit!("Save"))));
1887        layout(&mut t);
1888        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItem);
1889        let info = t.accessibility_node(item_id);
1890        assert_eq!(info.role(), Role::MenuItem);
1891        assert_eq!(info.name(), Some("Save"));
1892    }
1893
1894    #[test]
1895    fn checked_emits_role_menuitemcheckbox() {
1896        let checked = Signal::new(false);
1897        let mut t = tree();
1898        let list_id =
1899            t.add(MenuList::new().item(MenuItem::new(lit!("Word Wrap")).checked(checked)));
1900        layout(&mut t);
1901        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItemCheckBox);
1902        let info = t.accessibility_node(item_id);
1903        assert_eq!(info.role(), Role::MenuItemCheckBox);
1904        assert_eq!(info.name(), Some("Word Wrap"));
1905        assert!(!info.is_toggled());
1906    }
1907
1908    #[test]
1909    fn check_state_emits_role_menuitemcheckbox() {
1910        let state = Signal::new(CheckState::Unchecked);
1911        let mut t = tree();
1912        let list_id =
1913            t.add(MenuList::new().item(MenuItem::new(lit!("Show Inspector")).check_state(state)));
1914        layout(&mut t);
1915        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItemCheckBox);
1916        let info = t.accessibility_node(item_id);
1917        assert_eq!(info.role(), Role::MenuItemCheckBox);
1918    }
1919
1920    #[test]
1921    fn radio_emits_role_menuitemradio() {
1922        let sel = Signal::new(0_usize);
1923        let mut t = tree();
1924        let list_id =
1925            t.add(MenuList::new().item(MenuItem::new(lit!("Light")).radio(0, sel.clone())));
1926        layout(&mut t);
1927        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItemRadio);
1928        let info = t.accessibility_node(item_id);
1929        assert_eq!(info.role(), Role::MenuItemRadio);
1930        assert!(info.is_toggled());
1931    }
1932
1933    // --- Activation: state mutation ---
1934
1935    #[test]
1936    fn checked_click_flips_signal() {
1937        let checked = Signal::new(false);
1938        let mut t = tree();
1939        let list_id =
1940            t.add(MenuList::new().item(MenuItem::new(lit!("Word Wrap")).checked(checked.clone())));
1941        layout(&mut t);
1942        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItemCheckBox);
1943        t.click(item_id);
1944        assert!(checked.get());
1945        // Re-add and click again to confirm round-trip — but the menu
1946        // already dismissed; rebuild a fresh tree to test the second flip.
1947        let mut t2 = tree();
1948        let checked2 = Signal::new(true);
1949        let list_id2 = t2
1950            .add(MenuList::new().item(MenuItem::new(lit!("Word Wrap")).checked(checked2.clone())));
1951        layout(&mut t2);
1952        let item_id2 = first_descendant_with_role(&t2, list_id2, Role::MenuItemCheckBox);
1953        t2.click(item_id2);
1954        assert!(!checked2.get());
1955    }
1956
1957    #[test]
1958    fn reflect_checked_emits_role_and_reflects_signal() {
1959        let visible = Signal::new(true);
1960        let mut t = tree();
1961        let list_id = t.add(
1962            MenuList::new().item(MenuItem::new(lit!("Show Outline")).reflect_checked(visible)),
1963        );
1964        layout(&mut t);
1965        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItemCheckBox);
1966        let info = t.accessibility_node(item_id);
1967        assert_eq!(info.role(), Role::MenuItemCheckBox);
1968        assert!(
1969            info.is_toggled(),
1970            "checkmark reflects the bound signal (true)"
1971        );
1972    }
1973
1974    #[test]
1975    fn reflect_checked_click_does_not_write_signal() {
1976        // The defining property: activation is reflect-only — the bound signal's
1977        // truth lives elsewhere, so clicking must NOT flip it (the on_activate /
1978        // intent owns the change).
1979        let visible = Signal::new(false);
1980        let mut t = tree();
1981        let list_id = t.add(
1982            MenuList::new()
1983                .item(MenuItem::new(lit!("Show Outline")).reflect_checked(visible.clone())),
1984        );
1985        layout(&mut t);
1986        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItemCheckBox);
1987        t.click(item_id);
1988        assert!(
1989            !visible.get(),
1990            "reflect_checked must not write the bound signal on click"
1991        );
1992    }
1993
1994    #[test]
1995    fn check_state_click_cycles_two_states_not_three() {
1996        // Mirror Checkbox: click toggles Unchecked <-> Checked only.
1997        // Indeterminate (external) promotes to Checked on click.
1998        let state = Signal::new(CheckState::Unchecked);
1999        let mut t = tree();
2000        let list_id = t
2001            .add(MenuList::new().item(MenuItem::new(lit!("Inspector")).check_state(state.clone())));
2002        layout(&mut t);
2003        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItemCheckBox);
2004        t.click(item_id);
2005        assert_eq!(state.get(), CheckState::Checked);
2006
2007        let state2 = Signal::new(CheckState::Checked);
2008        let mut t2 = tree();
2009        let list_id2 = t2.add(
2010            MenuList::new().item(MenuItem::new(lit!("Inspector")).check_state(state2.clone())),
2011        );
2012        layout(&mut t2);
2013        let item_id2 = first_descendant_with_role(&t2, list_id2, Role::MenuItemCheckBox);
2014        t2.click(item_id2);
2015        assert_eq!(state2.get(), CheckState::Unchecked);
2016
2017        let state3 = Signal::new(CheckState::Indeterminate);
2018        let mut t3 = tree();
2019        let list_id3 = t3.add(
2020            MenuList::new().item(MenuItem::new(lit!("Inspector")).check_state(state3.clone())),
2021        );
2022        layout(&mut t3);
2023        let item_id3 = first_descendant_with_role(&t3, list_id3, Role::MenuItemCheckBox);
2024        t3.click(item_id3);
2025        // Indeterminate -> Checked (promotion, not cycle to Unchecked).
2026        assert_eq!(state3.get(), CheckState::Checked);
2027    }
2028
2029    #[test]
2030    fn radio_click_writes_value_to_shared_signal() {
2031        let sel = Signal::new(0_usize);
2032        let mut t = tree();
2033        let _list_id = t.add(
2034            MenuList::new()
2035                .item(MenuItem::new(lit!("Light")).radio(0, sel.clone()))
2036                .item(MenuItem::new(lit!("Dark")).radio(1, sel.clone()))
2037                .item(MenuItem::new(lit!("System")).radio(2, sel.clone())),
2038        );
2039        layout(&mut t);
2040        // Find the "Dark" item by label.
2041        let dark_id = t
2042            .find_by_label("Dark")
2043            .expect("Dark menu item should exist");
2044        t.click(dark_id);
2045        assert_eq!(sel.get(), 1);
2046    }
2047
2048    #[test]
2049    fn checked_space_keypress_flips_signal() {
2050        let checked = Signal::new(false);
2051        let mut t = tree();
2052        let list_id =
2053            t.add(MenuList::new().item(MenuItem::new(lit!("Word Wrap")).checked(checked.clone())));
2054        layout(&mut t);
2055        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItemCheckBox);
2056        t.focus(item_id);
2057        t.press_key(Key::Space, Modifiers::NONE);
2058        assert!(checked.get());
2059    }
2060
2061    #[test]
2062    fn radio_external_signal_change_reflects_in_at() {
2063        // The bound `Signal<usize>` is the source of truth; clicking is
2064        // only one path. An external write must flip every item's
2065        // is_toggled() the next time the AT walker reads it.
2066        let sel = Signal::new(0_usize);
2067        let mut t = tree();
2068        let list_id = t.add(
2069            MenuList::new()
2070                .item(MenuItem::new(lit!("Light")).radio(0, sel.clone()))
2071                .item(MenuItem::new(lit!("Dark")).radio(1, sel.clone())),
2072        );
2073        layout(&mut t);
2074        let light_id = t.find_by_label("Light").expect("Light exists");
2075        let dark_id = t.find_by_label("Dark").expect("Dark exists");
2076
2077        assert!(t.accessibility_node(light_id).is_toggled());
2078        assert!(!t.accessibility_node(dark_id).is_toggled());
2079
2080        sel.set(1);
2081        let _ = list_id;
2082        assert!(!t.accessibility_node(light_id).is_toggled());
2083        assert!(t.accessibility_node(dark_id).is_toggled());
2084    }
2085
2086    // --- Reactive role state ---
2087
2088    #[test]
2089    fn checked_at_state_reflects_signal() {
2090        let checked = Signal::new(true);
2091        let mut t = tree();
2092        let list_id =
2093            t.add(MenuList::new().item(MenuItem::new(lit!("Word Wrap")).checked(checked.clone())));
2094        layout(&mut t);
2095        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItemCheckBox);
2096        assert!(t.accessibility_node(item_id).is_toggled());
2097        checked.set(false);
2098        assert!(!t.accessibility_node(item_id).is_toggled());
2099    }
2100
2101    // --- Mnemonic plumbing ---
2102
2103    #[test]
2104    fn ampersand_stripped_from_at_name() {
2105        // The `&` marker is parsed out of the label so screen readers
2106        // don't announce "ampersand Save" — they announce "Save".
2107        let mut t = tree();
2108        let list_id = t.add(MenuList::new().item(MenuItem::new(lit!("&Save"))));
2109        layout(&mut t);
2110        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItem);
2111        let info = t.accessibility_node(item_id);
2112        assert_eq!(info.name(), Some("Save"));
2113    }
2114
2115    #[test]
2116    fn mnemonic_parsed_from_label_when_builder_returns() {
2117        // Build the item, drop it back to inspect — the mnemonic
2118        // accessor should reflect the parse.
2119        let mut mi = MenuItem::new(lit!("&File"));
2120        mi.ensure_mnemonic_parsed();
2121        let m = mi.mnemonic().expect("mnemonic exists");
2122        assert_eq!(m.stripped, "File");
2123        assert_eq!(m.key_lower, Some('f'));
2124    }
2125
2126    // --- Plain item AT smoke ---
2127
2128    // --- Helpers ---
2129
2130    fn first_descendant_with_role(t: &WidgetTree, from: WidgetId, role: Role) -> WidgetId {
2131        // BFS through the tree starting at `from`.
2132        let mut queue = std::collections::VecDeque::new();
2133        queue.push_back(from);
2134        while let Some(id) = queue.pop_front() {
2135            if t.accessibility_node(id).role() == role {
2136                return id;
2137            }
2138            for child in t.children(id) {
2139                queue.push_back(child);
2140            }
2141        }
2142        panic!("no descendant of {from:?} has role {role:?}");
2143    }
2144
2145    // --- Regression: shortcut-registry churn must not rebuild a
2146    //     shortcut-bearing menu item (which would drop its click) ---
2147
2148    /// Every widget id in the subtree rooted at `from`, breadth-first.
2149    fn subtree(t: &WidgetTree, from: WidgetId) -> Vec<WidgetId> {
2150        let mut out = Vec::new();
2151        let mut queue = std::collections::VecDeque::new();
2152        queue.push_back(from);
2153        while let Some(id) = queue.pop_front() {
2154            out.push(id);
2155            for child in t.children(id) {
2156                queue.push_back(child);
2157            }
2158        }
2159        out
2160    }
2161
2162    /// Regression: a signal-bound `.enabled(...)` that starts `false` and
2163    /// later flips `true` must not leave the item on a permanent
2164    /// `NotAllowed` cursor. Menu-bar Format/Go rows hit this path — they
2165    /// are built dormant before any editor is attached, then enable when
2166    /// a scene has focus.
2167    #[test]
2168    fn menu_item_cursor_stays_pointer_after_enabled_signal_flips_true() {
2169        use teksilo_canvas::Point;
2170        use teksilo_core::widget::CursorIcon;
2171
2172        let enabled = Signal::new(false);
2173        let mut t = tree();
2174        let list_id =
2175            t.add(MenuList::new().item(MenuItem::new(lit!("Bold")).enabled(enabled.clone())));
2176        layout(&mut t);
2177        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItem);
2178        let bounds = t.bounds(item_id);
2179        let center = Point::new(
2180            bounds.origin().x + bounds.size().width / 2.0,
2181            bounds.origin().y + bounds.size().height / 2.0,
2182        );
2183
2184        // Still disabled at first hover: framework gates PointerEnter, so
2185        // the item never applies its node_cursor — cursor stays Default.
2186        t.pointer_move(center);
2187        // Flip enablement without rebuilding the item (the real menubar
2188        // path: signals update, paint/AT follow, handlers stay put).
2189        enabled.set(true);
2190        // Leave and re-enter so PointerEnter re-applies node_cursor under
2191        // the now-enabled gate.
2192        t.pointer_move(Point::new(0.0, 0.0));
2193        layout(&mut t); // flush effective_enabled + any dirty paint
2194        t.pointer_move(center);
2195        assert_eq!(
2196            t.current_cursor(),
2197            CursorIcon::Pointer,
2198            "enabled menu item must show Pointer, not a build-time NotAllowed snapshot"
2199        );
2200    }
2201
2202    #[test]
2203    fn menu_item_with_shortcut_not_rebuilt_on_unrelated_shortcut_churn() {
2204        use teksilo_core::event::Key;
2205        use teksilo_core::shortcut::Shortcut;
2206
2207        let mut t = tree();
2208        t.shortcut_registry_mut().register(
2209            Shortcut::new("test.cmd")
2210                .primary(KeyStroke::ctrl(Key::K))
2211                .build(),
2212        );
2213        let list_id =
2214            t.add(MenuList::new().item(MenuItem::new(lit!("New")).for_shortcut("test.cmd")));
2215        layout(&mut t);
2216        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItem);
2217
2218        // Snapshot the item's subtree identity. A rebuild re-creates the
2219        // item's children (label / accelerator / chevron) with fresh ids.
2220        let before = subtree(&t, item_id);
2221
2222        // Register an UNRELATED shortcut — exactly what a widget that
2223        // declares a scoped shortcut in build() does on every rebuild —
2224        // and flush pending rebuilds via layout. The old code bound the
2225        // GLOBAL shortcut version at `Rebuild` on every shortcut-bearing
2226        // item, so this bump rebuilt the item, tearing down its gesture
2227        // arena and dropping in-flight clicks (the reported regression).
2228        t.shortcut_registry_mut().register(
2229            Shortcut::new("unrelated.cmd")
2230                .primary(KeyStroke::ctrl(Key::J))
2231                .build(),
2232        );
2233        layout(&mut t);
2234
2235        let after = subtree(&t, item_id);
2236        assert_eq!(
2237            before, after,
2238            "a shortcut-bearing menu item must NOT rebuild when an unrelated \
2239             shortcut is registered; its accelerator now updates as a leaf"
2240        );
2241    }
2242
2243    // --- Regression: a rebuilt item must not leak its tooltip ---
2244
2245    /// Rebuilding a tooltip-bearing menu item must neither leak the old
2246    /// tooltip's widgets nor lose the tooltip.
2247    ///
2248    /// `build()` consumes the tooltip source (`.take()`), so a second build
2249    /// attaches nothing: the entry that survives points at the *previous*
2250    /// build's body, which the rebuild has just destroyed. Every later rebuild
2251    /// then strands one more content subtree — parentless by construction, so
2252    /// no teardown walk can ever reach it — in the arena for the process's
2253    /// lifetime.
2254    #[test]
2255    fn rebuilding_a_menu_item_neither_leaks_nor_loses_its_tooltip() {
2256        let mut t = tree();
2257        let list_id = t.add(MenuList::new().item(MenuItem::new(lit!("Bold")).tooltip(lit!("Tip"))));
2258        layout(&mut t);
2259        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItem);
2260
2261        let baseline = t.widget_count();
2262        for _ in 0..10 {
2263            t.arena_mark_needs_rebuild_for_testing(item_id);
2264            layout(&mut t);
2265            assert_eq!(
2266                t.tooltip_entry_count(),
2267                1,
2268                "the item must keep exactly one tooltip across rebuilds"
2269            );
2270        }
2271
2272        assert_eq!(
2273            t.widget_count(),
2274            baseline,
2275            "each rebuild stranded a tooltip content subtree in the arena"
2276        );
2277    }
2278
2279    /// **A swatch is not a glyph that repeats the label.**
2280    ///
2281    /// A menu icon normally means what the label means, so it takes the row's colour.
2282    /// An icon whose colour *is* the content — a tag's swatch, a status light — has
2283    /// nothing left to say once the row has tinted it to its own foreground. The
2284    /// opt-in leaves it alone; without it, the row wins, which is the default every
2285    /// other row wants.
2286    #[test]
2287    fn an_icon_that_keeps_its_color_is_not_tinted_by_the_row() {
2288        // A colour no theme role resolves to, so finding it among the painted glyphs
2289        // can only mean the icon's own was kept.
2290        let swatch = teksilo_tokens::Color::from_hex("#e91e63");
2291
2292        let painted = |keep: bool| {
2293            let mut t = WidgetTree::new()
2294                .with_theme(teksilo_core::presets::intui::light())
2295                .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
2296                    teksilo_canvas::MockTextBackend::new(),
2297                )));
2298            let mut item = MenuItem::new(lit!("Places"))
2299                .icon(IconWidget::checkmark(MENU_INDICATOR_GLYPH_SIZE).color(swatch));
2300            if keep {
2301                item = item.icon_keeps_color();
2302            }
2303            t.add(item);
2304            layout(&mut t);
2305            // The checkmark is vector artwork, so it lands in `paths` rather than
2306            // among the label's glyphs.
2307            t.render()
2308                .paths
2309                .iter()
2310                .map(|p| {
2311                    let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
2312                    [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
2313                })
2314                .collect::<Vec<_>>()
2315        };
2316
2317        assert!(
2318            painted(true).contains(&rgba8(swatch)),
2319            "the swatch must keep the colour it was given"
2320        );
2321        assert!(
2322            !painted(false).contains(&rgba8(swatch)),
2323            "and without the opt-in the row must still tint its icon, or every \
2324             existing menu icon would stop following hover and disabled"
2325        );
2326    }
2327
2328    /// The same contract for the rich (registry-keyed) tier, which carries a
2329    /// whole Accordion body — ~15 widgets per stranded copy.
2330    #[test]
2331    fn rebuilding_a_menu_item_neither_leaks_nor_loses_its_rich_tooltip() {
2332        let mut t = tree();
2333        let list_id =
2334            t.add(MenuList::new().item(MenuItem::new(lit!("Bold")).rich_tooltip("bold-details")));
2335        layout(&mut t);
2336        let item_id = first_descendant_with_role(&t, list_id, Role::MenuItem);
2337
2338        let baseline = t.widget_count();
2339        for _ in 0..10 {
2340            t.arena_mark_needs_rebuild_for_testing(item_id);
2341            layout(&mut t);
2342            assert_eq!(t.tooltip_entry_count(), 1);
2343        }
2344
2345        assert_eq!(
2346            t.widget_count(),
2347            baseline,
2348            "each rebuild stranded a rich-tooltip content subtree in the arena"
2349        );
2350    }
2351}