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