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;