Skip to main content

teksilo_widgets/
menu_item.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! MenuItem — a single command row in a menu or context menu.
5//!
6//! Each item consists of an optional leading icon, a label, an optional
7//! trailing shortcut label, and an activation closure. `MenuItem` is
8//! non-generic: actions are type-erased closures identical to `Button`'s
9//! `on_activate_fn` model. Submenus are declared with `MenuItem::submenu`
10//! — the factory runs during `build()`, and the nested `MenuList` it
11//! returns stays a dormant, deferred subtree until first wanted.
12//!
13//! Every item operates in one of three **modes** selected by builder methods:
14//!
15//! | Builder | AT Role | Leading glyph |
16//! |---|---|---|
17//! | (default) | `Role::MenuItem` | icon or blank |
18//! | `.checked(signal)` | `Role::MenuItemCheckBox` | checkmark / blank |
19//! | `.check_state(signal)` | `Role::MenuItemCheckBox` | check / dash / blank |
20//! | `.reflect_checked(signal)` | `Role::MenuItemCheckBox` | checkmark (read-only) |
21//! | `.radio(value, selected)` | `Role::MenuItemRadio` | filled dot / blank |
22//!
23//! Check and radio modes are mutually exclusive with `.icon(...)` — the
24//! Windows convention reserves the leading slot for state glyphs on
25//! checkable items; a `debug_assert!` fires when both are set.
26//!
27//! ## An icon that keeps its own colour
28//!
29//! `.icon(...)` recolours whatever it is handed with the row's text role, so the
30//! glyph follows hover, press and disabled alongside the label. That is right for
31//! an icon that says the same thing as the label, and wrong for one whose colour
32//! *is* the content — a tag's swatch, a status light, a colour a person chose.
33//!
34//! `.icon_keeps_color()` leaves it alone. Two costs come with it: the icon no
35//! longer follows the highlight (on a style whose highlighted row is a solid
36//! accent fill, it has to carry its own contrast against that fill), and a
37//! *literal* colour does not dim in a disabled row — `ColorProp::Static` and
38//! `Bound` ignore the enabled state, while every role variant substitutes its
39//! disabled counterpart. An icon that should dim wants a role, and then it does
40//! not want this at all.
41//!
42//! ```rust
43//! # use teksilo_widgets::{MenuItem, primitives::IconWidget};
44//! # use teksilo_canvas::{Path, Point};
45//! # use teksilo_i18n::lit;
46//! # use teksilo_tokens::Color;
47//! let swatch = IconWidget::from_path(Path::circle(Point::new(5.0, 5.0), 4.5), 10.0)
48//!     .color(Color::from_hex("#e91e63"));
49//! let _w = MenuItem::new(lit!("Characters"))
50//!     .icon(swatch)
51//!     .icon_keeps_color();
52//! ```
53//!
54//! **Mnemonic markers** use the in-string `&` convention (`&Save` →
55//! underline 'S' when Alt is held; `&&` → literal `&`). The enclosing
56//! `MenuList` wires bare-letter in-menu activation automatically.
57//!
58//! ```rust
59//! # use teksilo_widgets::MenuItem;
60//! # use teksilo_i18n::lit;
61//! # use teksilo_core::Intent;
62//! let _w = MenuItem::new(lit!("&Save"))
63//!     .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.save")));
64//! ```
65
66use std::rc::Rc;
67use std::time::Duration;
68use teksilo_data::CheckState;
69use teksilo_i18n::lit;
70
71use teksilo_canvas::{Rect, Size, SizeProposal};
72use teksilo_core::accessibility::AccessNodeBuilder;
73use teksilo_core::build_context::BuildContext;
74use teksilo_core::event::{EventResponse, Key, WidgetEvent};
75use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
76use teksilo_core::shortcut::KeyStroke;
77use teksilo_core::signal::{Prop, Signal};
78use teksilo_core::styles::{MenuItemStyleConfig, SharedMenuItemStyle};
79use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
80use teksilo_core::widget_builder::HandlerSet;
81use teksilo_core::widget_id::WidgetId;
82use teksilo_tokens::{TextRole, TextStyleRole};
83
84use crate::keystroke_format::format_keystroke;
85use crate::primitives::{HStack, IconWidget, Spacer, Switcher, TextWidget};
86use teksilo_i18n::LocalizedString;
87
88mod menu_label;
89mod mnemonic;
90mod widget_impl;
91pub(crate) use menu_label::MenuLabel;
92pub(crate) use mnemonic::{ParsedMnemonic, parse_mnemonic};
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/// Which route opened a submenu, and therefore how it must be dismissed.
120///
121/// A `DismissBehavior::PointerLeave` grace asks "has the pointer left this
122/// panel and the row that opened it?" — a question only a pointer that hovers
123/// can answer. Every other route leaves it unanswerable, and an overlay whose
124/// dismissal never fires hangs on screen until something else takes it down.
125///
126/// So the behaviour is chosen per route rather than shared, and
127/// [`Self::dismiss`] is the one place it is chosen. The hover route keeps the
128/// grace; every other route ends the way a keyboard user ends things —
129/// `Escape`, or a press outside — which a finger produces too.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub(crate) enum SubmenuOpenRoute {
132    /// A hover dwell matured. The only route with a pointer that can leave.
133    Hover,
134    /// A tap or click on the trigger row.
135    Tap(teksilo_tokens::PointerKind),
136    /// `Enter` / `Space` / the inline-forward arrow, or an assistive
137    /// technology's `Click`.
138    KeyboardOrAt,
139}
140
141impl SubmenuOpenRoute {
142    /// The dismissal a submenu opened by this route must carry.
143    fn dismiss(self) -> DismissBehavior {
144        let leaves = match self {
145            Self::Hover => true,
146            // A pen taps and then genuinely hovers away, so its tap-opened
147            // submenu still has a pointer that can leave it. A finger does not:
148            // the contact is gone the instant it lifts.
149            Self::Tap(kind) => kind.hovers(),
150            Self::KeyboardOrAt => false,
151        };
152        if leaves {
153            DismissBehavior::PointerLeave {
154                delay: DEFAULT_SUBMENU_CLOSE_DELAY,
155            }
156        } else {
157            DismissBehavior::EscapeOrClickOutside
158        }
159    }
160}
161
162/// Glyph size for the check / dash / radio-dot rendered in the leading
163/// slot, whose width is the active style's
164/// [`MenuItemMetrics::icon_column_width`](teksilo_core::styles::MenuItemMetrics).
165/// 12dp matches the existing `chevron_right(12.0)` used for submenu
166/// triggers, and fits inside the smallest column any shipped preset asks
167/// for (macOS's 14dp).
168const MENU_INDICATOR_GLYPH_SIZE: f32 = 12.0;
169
170/// Internal selection mode of a `MenuItem`. `Plain` is the default
171/// and produces `Role::MenuItem`. `Check` swaps the leading-slot
172/// icon for a checkmark (binary) or check/dash/spacer (tri-state)
173/// and emits `Role::MenuItemCheckBox`. `Radio` swaps the leading
174/// slot for a filled dot when the radio group's `selected` signal
175/// matches `value` and emits `Role::MenuItemRadio`.
176///
177/// The state signals are kept here unboxed so `accessibility()`
178/// can read the current value cheaply via `Signal::get()`.
179enum MenuItemMode {
180    Plain,
181    Check(CheckKind),
182    Radio {
183        value: usize,
184        selected: Signal<usize>,
185    },
186}
187
188/// Internal dual-mode for checkable items — mirrors `Checkbox`'s
189/// internal `CheckKind` exactly so MenuItem and Checkbox behave
190/// identically when they share the same `Signal<bool>` /
191/// `Signal<CheckState>`.
192enum CheckKind {
193    TwoState(Signal<bool>),
194    TriState(Signal<CheckState>),
195    /// Reflect-only: the checkmark mirrors `state`, but activation does **not**
196    /// write it — the bound value's truth lives elsewhere (a model / method) and
197    /// the item's `on_activate`/intent is solely responsible for changing it.
198    /// The classic "View ▸ Sidebar / Full Screen" pattern, where the check
199    /// follows layout state the menu doesn't own. Renders identically to
200    /// `TwoState`; differs only in that clicking has no built-in toggle.
201    Reflect(Prop<bool>),
202}
203
204/// A single command row in a `MenuList` or context menu.
205///
206/// See the module documentation for the full mode table, mnemonic syntax, and
207/// submenu construction pattern.
208pub struct MenuItem {
209    label: LocalizedString,
210    icon: Option<IconWidget>,
211    /// Leave the icon's own colour alone instead of tinting it with the row's
212    /// text role — see [`MenuItem::icon_keeps_color`].
213    icon_keeps_color: bool,
214    shortcut_label: Option<String>,
215    /// A trailing *descriptive* phrase — not an accelerator. Unlike
216    /// `shortcut_label` this stays a [`LocalizedString`], so it re-resolves
217    /// on a live locale change, and it is announced as the item's
218    /// accessible *description* rather than its keyboard shortcut.
219    trailing_hint: Option<LocalizedString>,
220    /// Optional shortcut id. When set and `shortcut_label` is not, the
221    /// rendered trailing label is pulled from the tree's
222    /// [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry) and
223    /// tracks user rebindings automatically — reactively, via a *per-id*
224    /// signal (see `shortcut_signal`), so a rebind refreshes the chord in
225    /// place instead of rebuilding the whole item.
226    shortcut_id: Option<&'static str>,
227    tooltip_text: Option<LocalizedString>,
228    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
229    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
230    action: Option<CommandFactory>,
231    /// Enabled-state (static or signal-bound); forwarded to the arena at build
232    /// time via `enabled_when`, so a bound signal disables/enables the item
233    /// reactively (paint and AT follow). Cursor stays `Pointer` — see
234    /// the cursor assignment in `build` for why it is not derived from this.
235    enabled: Prop<bool>,
236    /// Plain / Check / Radio — see [`MenuItemMode`].
237    mode: MenuItemMode,
238    /// Sibling ids for radio-group AT announcement. Set by
239    /// [`MenuList::build`](crate::menu_list::MenuList::build) on
240    /// every radio-mode item that shares a `Signal<usize>` with
241    /// other items in the same list, via
242    /// `set_radio_group_ids(...)`. Used in `accessibility()` to
243    /// emit `push_to_radio_group(sibling_id)` so AT announces
244    /// "Theme Dark, 2 of 3". Empty for non-radio items and for
245    /// solitary radio items.
246    radio_group_ids: Option<Rc<std::cell::RefCell<Vec<WidgetId>>>>,
247    submenu_factory: Option<Box<dyn Fn() -> Box<dyn Widget>>>,
248    submenu_open_delay: Duration,
249    // Build state
250    interaction: Signal<MenuItemState>,
251    /// Whether this item's submenu overlay is currently visible.
252    /// Flipped to `true` by every open path (tap, hover, Enter,
253    /// ArrowRight) and flipped back to `false` by the overlay
254    /// manager's `on_dismiss` callback — regardless of dismiss
255    /// path. `accessibility()` reads this for `set_expanded`.
256    /// Only meaningful when `submenu_factory.is_some()`.
257    submenu_open: Signal<bool>,
258    /// "This submenu has been wanted at least once" — the reveal gate for its
259    /// deferred content. Distinct from `submenu_open`, which is the disclosure
260    /// state AT reads and the chevron follows: the hover path schedules a
261    /// *delayed* overlay and must have the content built before the delay
262    /// matures, while the item is not yet open.
263    submenu_needed: Signal<bool>,
264    /// Live per-id handle to the effective primary keystroke for
265    /// `shortcut_id`, obtained in `build()` from
266    /// [`BuildContext::effective_shortcut_signal`]. The trailing label
267    /// binds it (leaf-level, so a rebind repaints in place and the item
268    /// is never rebuilt on registry churn), and `accessibility()` reads
269    /// it live so screen readers announce the current chord. `None` for
270    /// items with a manual `shortcut_label` or no shortcut at all.
271    shortcut_signal: Option<Signal<Option<KeyStroke>>>,
272    /// Per-call override for the label's text style (font, size, weight).
273    /// `None` ⇒ the default `TextStyleRole::Body`.
274    label_style: Option<teksilo_core::color_prop::TextStyleProp>,
275    /// Per-call override for the label text color. `None` ⇒ the
276    /// interaction/enabled-derived cascade (hover / disabled). Setting
277    /// this replaces the cascade (loses the hover/disabled tint), so use
278    /// it only when a host enforces a fixed text role.
279    text_role_override: Option<teksilo_core::color_prop::ColorProp>,
280    /// Per-call style override. When `None`, falls back to the
281    /// theme-wide slot (`theme.style_slots.menu_item`) and finally to
282    /// the IntUI default `RecipeMenuItemStyle`.
283    style_override: Option<SharedMenuItemStyle>,
284    root_child_id: Option<WidgetId>,
285    submenu_content_id: Option<WidgetId>,
286    /// Parsed mnemonic from the label, captured during `build()`. The
287    /// enclosing [`MenuList`](crate::menu_list::MenuList) reads this
288    /// to wire in-menu mnemonic activation (bare-letter Alt
289    /// shortcut) and the keyboard-driven type-ahead.
290    parsed_mnemonic: Option<ParsedMnemonic>,
291    /// Shared safe-triangle state owned by the enclosing
292    /// [`MenuList`](crate::menu_list::MenuList). A submenu trigger
293    /// publishes its open submenu's content id here on hover-*leave*
294    /// (the apex itself lives in core, armed via
295    /// `ctx.arm_overlay_safe_region`); sibling items read that id
296    /// before firing their hover-switch so a diagonal
297    /// pointer trajectory toward the open submenu doesn't steal
298    /// focus. `None` for items that haven't been adopted by a
299    /// MenuList (e.g. solo menu items in tests).
300    safe_triangle: Option<crate::menu_list::SharedSafeTriangleState>,
301    /// The enclosing menu's rows, installed by
302    /// [`MenuList::build`](crate::menu_list::MenuList::build), so this item
303    /// can publish where it stands among them. `None` outside a `MenuList`.
304    menu_rows: Option<crate::menu_list::SharedMenuRows>,
305    /// Where this item leaves its keyboard activation for the enclosing
306    /// [`MenuList`](crate::menu_list::MenuList), which keeps focus while its
307    /// rows are highlighted and so activates them itself. Filled in by
308    /// `build()`. `None` outside a `MenuList`.
309    keyboard_activation: Option<crate::menu_list::KeyboardActivationSlot>,
310    /// This item's own node, captured in `build()`, to find itself among
311    /// `menu_rows`.
312    self_id: Option<WidgetId>,
313}
314
315impl MenuItem {
316    /// Create a plain menu item with the given label and no action yet.
317    pub fn new(label: impl Into<LocalizedString>) -> Self {
318        let ls: LocalizedString = label.into();
319        Self {
320            label: ls,
321            icon: None,
322            icon_keeps_color: false,
323            shortcut_label: None,
324            trailing_hint: None,
325            shortcut_id: None,
326            tooltip_text: None,
327            rich_tooltip_source: None,
328            composite_tooltip_content: None,
329            action: None,
330            enabled: Prop::Static(true),
331            mode: MenuItemMode::Plain,
332            radio_group_ids: None,
333            submenu_factory: None,
334            submenu_open_delay: DEFAULT_SUBMENU_OPEN_DELAY,
335            interaction: Signal::new(MenuItemState::Idle),
336            submenu_open: Signal::new(false),
337            submenu_needed: Signal::new(false),
338            shortcut_signal: None,
339            label_style: None,
340            text_role_override: None,
341            style_override: None,
342            root_child_id: None,
343            submenu_content_id: None,
344            parsed_mnemonic: None,
345            safe_triangle: None,
346            menu_rows: None,
347            keyboard_activation: None,
348            self_id: None,
349        }
350    }
351
352    /// Closure invoked on activation.
353    /// Note: shortcut label auto-lookup is not available with this variant
354    /// since there is no typed command to look up.
355    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
356        self.action = Some(Rc::new(f));
357        self
358    }
359
360    /// Read the item's display label. Exposed so SplitButton (and any other
361    /// compound widget that embeds a MenuItem) can mirror the label in its
362    /// own chrome.
363    pub fn label(&self) -> String {
364        self.label.resolve_now()
365    }
366
367    /// Like [`label`](Self::label) but returns the unresolved
368    /// [`LocalizedString`], so embedders can mirror the label *reactively*
369    /// (re-resolving on a locale switch) instead of freezing a snapshot.
370    pub fn label_localized(&self) -> LocalizedString {
371        self.label.clone()
372    }
373
374    /// Clone out a shared handle to the activation closure. Returns `None`
375    /// when this MenuItem has no action (e.g. it's a submenu trigger). The
376    /// returned `Rc` aliases MenuItem's own internal handle — invoking it
377    /// has the same effect as the user clicking this menu item (minus the
378    /// overlay dismissal that the tap handler also performs).
379    pub fn action(&self) -> Option<Rc<dyn Fn(&mut EventContext)>> {
380        self.action.clone()
381    }
382
383    /// Set a leading icon.
384    pub fn icon(mut self, icon: IconWidget) -> Self {
385        self.icon = Some(icon);
386        self
387    }
388
389    /// Keep the icon's **own** colour rather than tinting it with the row's.
390    ///
391    /// A menu icon normally says the same thing as the label beside it, so it takes
392    /// the row's text role and follows it through hover, press and disabled — which
393    /// is why [`icon`](Self::icon) recolours whatever it is handed. Some icons are
394    /// not that. A tag's swatch, a status light, a colour a person chose: there the
395    /// colour *is* the content, and tinting it to the menu's foreground deletes the
396    /// only thing the icon was there to say.
397    ///
398    /// Opt-in, because the default is right for nearly every row, and keeping a
399    /// colour has two costs the caller takes on:
400    ///
401    /// * **It does not follow the highlight.** On a style whose highlighted row is a
402    ///   solid accent fill (the macOS recipe), the icon has to carry its own contrast
403    ///   against that fill as well as against the menu's surface.
404    /// * **It does not dim when the row is disabled** — if it is a literal colour.
405    ///   That is [`ColorProp`](teksilo_core::ColorProp)'s own rule everywhere, not a
406    ///   special case here: `Static` and `Bound` ignore the enabled state, while every
407    ///   role variant substitutes its disabled counterpart. An icon that should dim
408    ///   should be given a role instead, and then it does not need this at all.
409    ///
410    /// Ignored in the check and radio modes, which draw an indicator glyph of the
411    /// framework's own rather than the caller's icon.
412    pub fn icon_keeps_color(mut self) -> Self {
413        self.icon_keeps_color = true;
414        self
415    }
416
417    /// Set a trailing shortcut label (e.g., "Ctrl+X"). Shortcut labels are
418    /// typically not translated (they're the key combination literal), so
419    /// this accepts a plain string.
420    pub fn shortcut_label(mut self, label: impl Into<String>) -> Self {
421        self.shortcut_label = Some(label.into());
422        self
423    }
424
425    /// Set a trailing *descriptive* hint (e.g. "inside", "after parent") —
426    /// a secondary phrase explaining what the item will do, rendered in the
427    /// same trailing slot as an accelerator but semantically unrelated to one.
428    ///
429    /// Prefer this over [`shortcut_label`](Self::shortcut_label) for any
430    /// trailing text that is not a key combination. It differs in two ways
431    /// that matter:
432    ///
433    /// * it takes a [`LocalizedString`], so a `tr!(...)` hint re-resolves on
434    ///   a live locale change instead of being frozen at build time;
435    /// * it is announced as the item's accessible **description**, not as
436    ///   `keyboard_shortcut` — a screen reader would otherwise read the
437    ///   phrase out as if it were a chord to press.
438    ///
439    /// Independent of the accelerator: an item may carry both, in which case
440    /// the chord renders first and the hint follows it.
441    pub fn trailing_hint(mut self, text: impl Into<LocalizedString>) -> Self {
442        self.trailing_hint = Some(text.into());
443        self
444    }
445
446    /// Bind the trailing shortcut label to a registered
447    /// [`Shortcut`](teksilo_core::shortcut::Shortcut) by its stable id.
448    /// The effective primary keystroke is bound *reactively*, via a
449    /// per-id signal, so a rebind performed through
450    /// [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry)
451    /// refreshes the chord in place — the item itself is never rebuilt
452    /// for shortcut changes.
453    ///
454    /// A manual [`shortcut_label`](Self::shortcut_label) takes
455    /// precedence when both are set.
456    pub fn for_shortcut(mut self, id: &'static str) -> Self {
457        self.shortcut_id = Some(id);
458        self
459    }
460
461    /// Set the enabled state — static or signal-bound. A bound `Signal<bool>`
462    /// enables/disables the item reactively (paint and AT follow), so
463    /// `MenuItem::new(...).enabled(can_save_signal)` greys out live without a
464    /// rebuild. Cursor is always `Pointer` (see `build`); disabled items are
465    /// gated by the arena before hover runs, so a `NotAllowed` cursor cannot
466    /// be applied from a build-time snapshot of this prop either.
467    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
468        self.enabled = enabled.into();
469        self
470    }
471
472    /// Per-call style override. Replaces the theme-wide default
473    /// `MenuItemStyle` for just this MenuItem instance.
474    pub fn style(mut self, style: impl teksilo_core::styles::MenuItemStyle) -> Self {
475        self.style_override = Some(Rc::new(style));
476        self
477    }
478
479    /// Override the label's text style (font, size, weight). Accepts a
480    /// `TextStyleRole`, a `TextStyle`, or a `Signal` of either. Default
481    /// (unset) is `TextStyleRole::Body`.
482    pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self {
483        self.label_style = Some(style.into());
484        self
485    }
486
487    /// Override the label text color. Accepts `Color`, a role, or a
488    /// `Signal` of either. Default (unset) is the interaction/enabled
489    /// cascade; setting this replaces that cascade (the hover / disabled
490    /// tint no longer applies), so reserve it for chrome that enforces a
491    /// fixed text role.
492    pub fn text_role(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
493        self.text_role_override = Some(color.into());
494        self
495    }
496
497    /// Attach a tooltip that appears after a hover delay, same mechanism
498    /// as [`Button::tooltip`](crate::button::Button::tooltip).
499    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
500        self.tooltip_text = Some(text.into());
501        self.rich_tooltip_source = None;
502        self.composite_tooltip_content = None;
503        self
504    }
505
506    /// Attach a rich tooltip resolved from the app-wide tooltip
507    /// registry. Body text supports inline markup
508    /// (`[label](url)`, `*italic*`, `**bold**`); the entry's shortcut
509    /// and long-form "more" fields are rendered automatically.
510    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
511        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
512        self.tooltip_text = None;
513        self.composite_tooltip_content = None;
514        self
515    }
516
517    /// Attach a rich tooltip driven by inline `TooltipContent`.
518    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
519        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
520        self.tooltip_text = None;
521        self.composite_tooltip_content = None;
522        self
523    }
524
525    /// Attach a composite tooltip — third tier, hosting an arbitrary
526    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
527    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
528        self.composite_tooltip_content = Some(Box::new(content));
529        self.tooltip_text = None;
530        self.rich_tooltip_source = None;
531        self
532    }
533
534    /// Create a submenu trigger item. The factory is invoked during `build()` to
535    /// pre-create the submenu content (typically a `MenuList`), which is kept
536    /// dormant until the hover delay elapses.
537    pub fn submenu(
538        label: impl Into<LocalizedString>,
539        factory: impl Fn() -> Box<dyn Widget> + 'static,
540    ) -> Self {
541        let ls: LocalizedString = label.into();
542        Self {
543            label: ls,
544            icon: None,
545            icon_keeps_color: false,
546            shortcut_label: None,
547            trailing_hint: None,
548            shortcut_id: None,
549            tooltip_text: None,
550            rich_tooltip_source: None,
551            composite_tooltip_content: None,
552            action: None,
553            enabled: Prop::Static(true),
554            mode: MenuItemMode::Plain,
555            radio_group_ids: None,
556            submenu_factory: Some(Box::new(factory)),
557            submenu_open_delay: DEFAULT_SUBMENU_OPEN_DELAY,
558            interaction: Signal::new(MenuItemState::Idle),
559            submenu_open: Signal::new(false),
560            submenu_needed: Signal::new(false),
561            shortcut_signal: None,
562            label_style: None,
563            text_role_override: None,
564            style_override: None,
565            root_child_id: None,
566            submenu_content_id: None,
567            parsed_mnemonic: None,
568            safe_triangle: None,
569            menu_rows: None,
570            keyboard_activation: None,
571            self_id: None,
572        }
573    }
574
575    /// Set a custom submenu open delay (default: 400 ms, IntelliJ's value;
576    /// see `DEFAULT_SUBMENU_OPEN_DELAY` for what that delay also buys).
577    pub fn submenu_delay(mut self, delay: Duration) -> Self {
578        self.submenu_open_delay = delay;
579        self
580    }
581
582    /// Whether this is a submenu trigger.
583    pub fn is_submenu(&self) -> bool {
584        self.submenu_factory.is_some()
585    }
586
587    /// Bind this item to a two-state `Signal<bool>`. The item renders
588    /// `Role::MenuItemCheckBox`; activation flips the signal. By
589    /// Windows convention, the leading icon slot becomes a checkmark
590    /// when the signal is `true`, blank otherwise.
591    ///
592    /// Mutually exclusive with [`check_state`](Self::check_state)
593    /// and [`radio`](Self::radio) — last call wins.
594    pub fn checked(mut self, state: Signal<bool>) -> Self {
595        self.mode = MenuItemMode::Check(CheckKind::TwoState(state));
596        self
597    }
598
599    /// Render `Role::MenuItemCheckBox` whose checkmark **reflects** `state`
600    /// read-only: activation does NOT write the signal — the truth lives
601    /// elsewhere (a model / method), and this item's `on_activate`/intent is
602    /// responsible for the change, after which `state` updates the checkmark
603    /// reactively. Use for "View ▸ Sidebar / Full Screen"-style commands that
604    /// mirror externally-owned state (e.g. `DockingModel::dock_open_signal`),
605    /// where two-way [`checked`](Self::checked) would fight the model.
606    ///
607    /// Mutually exclusive with the other check / radio binders — last call wins.
608    pub fn reflect_checked(mut self, state: impl Into<Prop<bool>>) -> Self {
609        self.mode = MenuItemMode::Check(CheckKind::Reflect(state.into()));
610        self
611    }
612
613    /// Bind this item to a tri-state `Signal<CheckState>`. The item
614    /// renders `Role::MenuItemCheckBox`; activation cycles
615    /// `Unchecked` ↔ `Checked` (per Windows / [`Checkbox`](crate::checkbox::Checkbox)
616    /// convention: `Indeterminate` is reserved for external sources
617    /// like `TreeCheckedModel`; clicking from `Indeterminate`
618    /// promotes to `Checked`).
619    ///
620    /// The leading-slot glyph is `checkmark` for `Checked`, `dash`
621    /// for `Indeterminate`, blank for `Unchecked` — matching the
622    /// Windows mixed-state convention.
623    ///
624    /// Mutually exclusive with [`checked`](Self::checked)
625    /// and [`radio`](Self::radio) — last call wins.
626    pub fn check_state(mut self, state: Signal<CheckState>) -> Self {
627        self.mode = MenuItemMode::Check(CheckKind::TriState(state));
628        self
629    }
630
631    /// Bind this item to a radio group via a shared `Signal<usize>`.
632    /// Activation writes `value` into `selected`; all radio items
633    /// sharing the same `selected` signal observe the change and
634    /// update their leading-slot dot accordingly. The item renders
635    /// `Role::MenuItemRadio`.
636    ///
637    /// For "2 of 3"-style AT announcement, the enclosing
638    /// [`MenuList`](crate::menu_list::MenuList) groups radio items
639    /// by selection-signal identity and emits `push_to_radio_group`
640    /// relationships automatically — no app-side wiring required.
641    ///
642    /// Mutually exclusive with [`checked`](Self::checked)
643    /// and [`check_state`](Self::check_state) — last call
644    /// wins.
645    pub fn radio(mut self, value: usize, selected: Signal<usize>) -> Self {
646        self.mode = MenuItemMode::Radio { value, selected };
647        self
648    }
649
650    /// Internal accessor for [`MenuList::build`](crate::menu_list::MenuList::build)
651    /// — read whether this item is a radio with a given group-id
652    /// (the `Rc`-identity of its `selected` signal).
653    pub(crate) fn radio_selection_handle(&self) -> Option<(usize, Signal<usize>)> {
654        match &self.mode {
655            MenuItemMode::Radio { value, selected } => Some((*value, selected.clone())),
656            _ => None,
657        }
658    }
659
660    /// Internal setter for [`MenuList::build`](crate::menu_list::MenuList::build)
661    /// — install the sibling id buffer so `accessibility()` can
662    /// announce "2 of N" via `push_to_radio_group`.
663    pub(crate) fn set_radio_group_ids(&mut self, ids: Rc<std::cell::RefCell<Vec<WidgetId>>>) {
664        self.radio_group_ids = Some(ids);
665    }
666
667    /// Read the parsed mnemonic for this item's label. Populated
668    /// inside `build()`. Returns `None` for items that haven't been
669    /// built yet, or whose label contains no un-escaped `&` marker.
670    ///
671    /// Used by [`MenuList`](crate::menu_list::MenuList) to wire
672    /// in-menu mnemonic activation (bare-letter activation of the
673    /// matching item) — the lookup runs on every `KeyDown` so a
674    /// fresh `parse_mnemonic` per keypress would be wasteful.
675    pub(crate) fn mnemonic(&self) -> Option<&ParsedMnemonic> {
676        self.parsed_mnemonic.as_ref()
677    }
678
679    /// Pre-parse the label so that
680    /// [`MenuList::build`](crate::menu_list::MenuList::build) can
681    /// read this item's mnemonic *before* the item is committed to
682    /// the arena. Idempotent — calls after the first one are no-ops.
683    pub(crate) fn ensure_mnemonic_parsed(&mut self) {
684        if self.parsed_mnemonic.is_none() {
685            self.parsed_mnemonic = Some(parse_mnemonic(&self.label.resolve_now()));
686        }
687    }
688
689    /// Install the enclosing
690    /// [`MenuList`](crate::menu_list::MenuList)'s shared
691    /// safe-triangle state. Called by `MenuList::build` for every
692    /// item before it reaches the arena. The handle lets:
693    ///
694    /// - a submenu trigger publish the open submenu's content id when
695    ///   the pointer leaves its row (the apex is armed separately, in
696    ///   core, via `ctx.arm_overlay_safe_region`);
697    /// - a sibling item read that submenu id on hover and skip its
698    ///   dismiss call while the region for that submenu is still
699    ///   armed.
700    pub(crate) fn set_safe_triangle_state(
701        &mut self,
702        state: crate::menu_list::SharedSafeTriangleState,
703    ) {
704        self.safe_triangle = Some(state);
705    }
706
707    /// Install the enclosing [`MenuList`](crate::menu_list::MenuList)'s rows,
708    /// so `accessibility()` can publish this item's position among them.
709    pub(crate) fn set_menu_rows(&mut self, rows: crate::menu_list::SharedMenuRows) {
710        self.menu_rows = Some(rows);
711    }
712
713    /// Install the slot the enclosing [`MenuList`](crate::menu_list::MenuList)
714    /// activates this item through from the keyboard. `build()` fills it.
715    pub(crate) fn set_keyboard_activation_slot(
716        &mut self,
717        slot: crate::menu_list::KeyboardActivationSlot,
718    ) {
719        self.keyboard_activation = Some(slot);
720    }
721}
722
723impl std::fmt::Debug for MenuItem {
724    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725        let mode = match &self.mode {
726            MenuItemMode::Plain => "Plain",
727            MenuItemMode::Check(CheckKind::TwoState(_)) => "Check(TwoState)",
728            MenuItemMode::Check(CheckKind::TriState(_)) => "Check(TriState)",
729            MenuItemMode::Check(CheckKind::Reflect(_)) => "Check(Reflect)",
730            MenuItemMode::Radio { .. } => "Radio",
731        };
732        f.debug_struct("MenuItem")
733            .field("label", &self.label)
734            .field("enabled", &self.enabled)
735            .field("is_submenu", &self.submenu_factory.is_some())
736            .field("mode", &mode)
737            .finish()
738    }
739}
740
741#[cfg(test)]
742mod tests;