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