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}