teksilo_widgets/menu/model.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`MenuModel`] data type and its builders.
5
6use std::cell::{Ref, RefCell};
7use std::rc::Rc;
8
9use teksilo_core::signal::{Prop, Signal};
10use teksilo_core::widget::EventContext;
11use teksilo_core::{Intent, MenuItemId};
12use teksilo_data::CheckState;
13use teksilo_i18n::LocalizedString;
14use teksilo_platform::native_menu::StandardMenuRole;
15
16use crate::menu_item::MenuItem;
17use crate::menu_list::MenuList;
18
19/// Checkable / radio state for a menu item, mirroring the [`MenuItem`] modes.
20#[derive(Clone)]
21pub enum MenuItemState {
22 /// A plain command, no check column.
23 Plain,
24 /// Two-state checkbox bound to a `Signal<bool>`; activation flips it.
25 Check(Signal<bool>),
26 /// Reflect-only checkmark mirroring a `Signal<bool>`; activation does NOT
27 /// write it (the `intent`/`on_activate` owns the change). For commands that
28 /// mirror externally-owned state — "View ▸ Sidebar / Full Screen".
29 ReflectCheck(Signal<bool>),
30 /// Tri-state checkbox bound to a `Signal<CheckState>`.
31 TriCheck(Signal<CheckState>),
32 /// Radio item: selected iff `selected == value`.
33 Radio {
34 /// This item's value within the group.
35 value: usize,
36 /// The shared selection signal.
37 selected: Signal<usize>,
38 },
39}
40
41/// One leaf command in the menu tree. Both the builder and the stored spec.
42#[derive(Clone)]
43pub struct MenuEntry {
44 pub(crate) title: LocalizedString,
45 pub(crate) intent: Option<&'static str>,
46 pub(crate) action: Option<Rc<dyn Fn(&mut EventContext)>>,
47 pub(crate) shortcut_id: Option<&'static str>,
48 pub(crate) enabled: Prop<bool>,
49 pub(crate) visible: Prop<bool>,
50 pub(crate) state: MenuItemState,
51 pub(crate) id: MenuItemId,
52}
53
54impl MenuEntry {
55 /// Start a new leaf item with the given (possibly mnemonic-bearing,
56 /// localized) title. Allocates a process-unique [`MenuItemId`].
57 pub fn new(title: impl Into<LocalizedString>) -> Self {
58 Self {
59 title: title.into(),
60 intent: None,
61 action: None,
62 shortcut_id: None,
63 enabled: Prop::Static(true),
64 visible: Prop::Static(true),
65 state: MenuItemState::Plain,
66 id: MenuItemId::next(),
67 }
68 }
69
70 /// Fire this intent by name when the item is chosen (in-window or native).
71 pub fn intent(mut self, name: &'static str) -> Self {
72 self.intent = Some(name);
73 self
74 }
75
76 /// Run this closure when the item is chosen. Runs after `intent`, if both
77 /// are set. The escape hatch for behaviour that isn't a plain intent.
78 pub fn on_activate(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
79 self.action = Some(Rc::new(f));
80 self
81 }
82
83 /// Bind the displayed shortcut to a `ShortcutRegistry` entry by id. The
84 /// in-window item shows the resolved chord; the native item gets a key
85 /// equivalent (and the OS fires it directly).
86 pub fn shortcut(mut self, id: &'static str) -> Self {
87 self.shortcut_id = Some(id);
88 self
89 }
90
91 /// Enabled state (static or signal-bound).
92 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
93 self.enabled = enabled.into();
94 self
95 }
96
97 /// Visibility (static or signal-bound). A hidden item collapses to zero
98 /// height in the in-window menu (reactively); on the native menu it is
99 /// omitted from the snapshot at build time (toggling it settles on the next
100 /// menu rebuild — for fully-dynamic native menus prefer
101 /// [`MenuModel::remove`] / [`MenuModel::push_item`]).
102 pub fn visible(mut self, visible: impl Into<Prop<bool>>) -> Self {
103 self.visible = visible.into();
104 self
105 }
106
107 /// Make this a two-state checkbox item bound to `state`. Activation flips
108 /// `state` — use when the signal *is* the source of truth.
109 pub fn checkable(mut self, state: Signal<bool>) -> Self {
110 self.state = MenuItemState::Check(state);
111 self
112 }
113
114 /// Show a checkmark that **reflects** `state` read-only. Activation does not
115 /// write it — pair with [`intent`](Self::intent) / [`on_activate`](Self::on_activate)
116 /// that drive the change; the checkmark then follows `state` reactively. Use
117 /// when the truth is owned elsewhere (e.g. `DockingModel::dock_open_signal`),
118 /// where two-way [`checkable`](Self::checkable) would fight the model.
119 pub fn checked(mut self, state: Signal<bool>) -> Self {
120 self.state = MenuItemState::ReflectCheck(state);
121 self
122 }
123
124 /// Make this a tri-state checkbox item bound to `state`.
125 pub fn tri_checkable(mut self, state: Signal<CheckState>) -> Self {
126 self.state = MenuItemState::TriCheck(state);
127 self
128 }
129
130 /// Make this a radio item: selected iff `selected.get() == value`.
131 pub fn radio(mut self, value: usize, selected: Signal<usize>) -> Self {
132 self.state = MenuItemState::Radio { value, selected };
133 self
134 }
135
136 /// The stable id of this item.
137 pub fn id(&self) -> MenuItemId {
138 self.id
139 }
140
141 /// Build the live [`MenuItem`] widget for the in-window menu.
142 pub(crate) fn to_menu_item(&self) -> MenuItem {
143 // Pass the enabled `Prop` through (not its current value) so a bound
144 // signal greys the in-window item out reactively.
145 let mut mi = MenuItem::new(self.title.clone()).enabled(self.enabled.clone());
146 if let Some(id) = self.shortcut_id {
147 mi = mi.for_shortcut(id);
148 }
149 let intent = self.intent;
150 let action = self.action.clone();
151 mi = mi.on_activate_fn(move |ctx| {
152 if let Some(name) = intent {
153 ctx.send_intent(Intent::new(name));
154 }
155 if let Some(a) = &action {
156 a(ctx);
157 }
158 });
159 match &self.state {
160 MenuItemState::Plain => {}
161 MenuItemState::Check(s) => mi = mi.checked(s.clone()),
162 MenuItemState::ReflectCheck(s) => mi = mi.reflect_checked(s.clone()),
163 MenuItemState::TriCheck(s) => mi = mi.check_state(s.clone()),
164 MenuItemState::Radio { value, selected } => mi = mi.radio(*value, selected.clone()),
165 }
166 mi
167 }
168}
169
170/// One node of the menu tree.
171#[derive(Clone)]
172pub enum MenuNode {
173 /// A leaf command.
174 Item(MenuEntry),
175 /// A submenu.
176 Submenu {
177 /// Stable id, so the submenu can be addressed by runtime mutators
178 /// ([`MenuModel::push_item`], [`MenuModel::remove`]).
179 id: MenuItemId,
180 /// Submenu title.
181 title: LocalizedString,
182 /// Child nodes.
183 children: Vec<MenuNode>,
184 },
185 /// A separator line.
186 Separator,
187 /// A platform-standard menu (macOS App / Window / Help) with localized
188 /// chrome. Rendered by the native backend; ignored by the in-window bar.
189 Standard(StandardMenu),
190}
191
192/// A platform-standard menu (macOS App / Window / Help) with **localized**
193/// labels. The framework wires the system selectors (About / Hide / Quit,
194/// Minimize / Zoom); you supply the strings — defaults are English `lit!`s, so
195/// pass `tr!`-resolved [`LocalizedString`]s for a localized app menu. This keeps
196/// the OS menu bar inside the i18n net like every other widget.
197#[derive(Clone)]
198pub struct StandardMenu {
199 role: StandardMenuRole,
200 title: LocalizedString,
201 about: LocalizedString,
202 settings: LocalizedString,
203 hide: LocalizedString,
204 quit: LocalizedString,
205 minimize: LocalizedString,
206 zoom: LocalizedString,
207 /// Set by [`quit_intent`](Self::quit_intent): the intent to fire, paired
208 /// with the id the native item is built under. Minted once here rather than
209 /// per install, so the id an activation is recorded against survives every
210 /// rebuild of the model.
211 quit_route: Option<(&'static str, MenuItemId)>,
212 /// Set by [`settings_intent`](Self::settings_intent), same shape as
213 /// `quit_route`. `None` omits the item entirely — there is no platform
214 /// default to fall back on.
215 settings_route: Option<(&'static str, MenuItemId)>,
216 /// Shortcut ids for the two routed rows, if the app named one. Unset, the
217 /// row falls back to the platform's conventional chord — see
218 /// [`quit_shortcut`](Self::quit_shortcut).
219 quit_shortcut: Option<&'static str>,
220 settings_shortcut: Option<&'static str>,
221}
222
223impl StandardMenu {
224 /// The application menu (About / Hide / Quit). `title` is the bold app-name
225 /// submenu label — set it to your localized app name.
226 pub fn app() -> Self {
227 Self {
228 role: StandardMenuRole::App,
229 title: LocalizedString::literal("App"),
230 about: LocalizedString::literal("About"),
231 settings: LocalizedString::literal("Settings…"),
232 hide: LocalizedString::literal("Hide"),
233 quit: LocalizedString::literal("Quit"),
234 minimize: LocalizedString::literal(""),
235 zoom: LocalizedString::literal(""),
236 quit_route: None,
237 settings_route: None,
238 quit_shortcut: None,
239 settings_shortcut: None,
240 }
241 }
242
243 /// The Window menu (Minimize / Zoom + the live window list).
244 pub fn window() -> Self {
245 Self {
246 role: StandardMenuRole::Window,
247 title: LocalizedString::literal("Window"),
248 about: LocalizedString::literal(""),
249 settings: LocalizedString::literal(""),
250 hide: LocalizedString::literal(""),
251 quit: LocalizedString::literal(""),
252 minimize: LocalizedString::literal("Minimize"),
253 zoom: LocalizedString::literal("Zoom"),
254 quit_route: None,
255 settings_route: None,
256 quit_shortcut: None,
257 settings_shortcut: None,
258 }
259 }
260
261 /// The Help menu.
262 pub fn help() -> Self {
263 Self {
264 role: StandardMenuRole::Help,
265 title: LocalizedString::literal("Help"),
266 about: LocalizedString::literal(""),
267 settings: LocalizedString::literal(""),
268 hide: LocalizedString::literal(""),
269 quit: LocalizedString::literal(""),
270 minimize: LocalizedString::literal(""),
271 zoom: LocalizedString::literal(""),
272 quit_route: None,
273 settings_route: None,
274 quit_shortcut: None,
275 settings_shortcut: None,
276 }
277 }
278
279 /// Default standard menu for a role.
280 pub fn for_role(role: StandardMenuRole) -> Self {
281 match role {
282 StandardMenuRole::App => Self::app(),
283 StandardMenuRole::Window => Self::window(),
284 StandardMenuRole::Help => Self::help(),
285 }
286 }
287
288 /// This menu's role.
289 pub fn role(&self) -> StandardMenuRole {
290 self.role
291 }
292
293 /// Submenu title (the app name for `App`; the menu label for Window / Help).
294 pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
295 self.title = title.into();
296 self
297 }
298 /// "About …" label (App).
299 pub fn about(mut self, label: impl Into<LocalizedString>) -> Self {
300 self.about = label.into();
301 self
302 }
303 /// "Settings…" label (App). macOS 13+ says "Settings…"; older releases said
304 /// "Preferences…" — pass whichever your app targets, localized.
305 ///
306 /// The label alone does not create the item: pair it with
307 /// [`settings_intent`](Self::settings_intent).
308 pub fn settings(mut self, label: impl Into<LocalizedString>) -> Self {
309 self.settings = label.into();
310 self
311 }
312 /// "Hide …" label (App).
313 pub fn hide(mut self, label: impl Into<LocalizedString>) -> Self {
314 self.hide = label.into();
315 self
316 }
317 /// "Quit …" label (App).
318 pub fn quit(mut self, label: impl Into<LocalizedString>) -> Self {
319 self.quit = label.into();
320 self
321 }
322 /// "Minimize" label (Window).
323 pub fn minimize(mut self, label: impl Into<LocalizedString>) -> Self {
324 self.minimize = label.into();
325 self
326 }
327 /// "Zoom" label (Window).
328 pub fn zoom(mut self, label: impl Into<LocalizedString>) -> Self {
329 self.zoom = label.into();
330 self
331 }
332
333 /// Route the App menu's **Quit** through `intent` instead of the platform's
334 /// terminate selector, keeping its ⌘Q key equivalent.
335 ///
336 /// Set this whenever quitting has to pass through the app first — unsaved
337 /// work to confirm, a session to write out, a background job to stop. By
338 /// default the item is the platform's own (`terminate:` on macOS), which
339 /// exits immediately: it never reaches winit's exit path, so no
340 /// `LoopExiting` hook and nothing the app registered runs.
341 ///
342 /// An in-app ⌘Q shortcut is **not** a substitute. AppKit dispatches
343 /// main-menu key equivalents before the responder chain, so the App menu's
344 /// item wins and the app's own shortcut never sees the keystroke — the app
345 /// looks wired up and is not. Routing the item is the only place that
346 /// decision can be taken.
347 ///
348 /// Whatever `intent` resolves to now owns the exit — nothing terminates on
349 /// the app's behalf once this is set.
350 ///
351 /// ```ignore
352 /// StandardMenu::app()
353 /// .title(tr!(app_name()))
354 /// .quit(tr!(quit()))
355 /// .quit_intent("app.quit") // the guarded action, same as File ▸ Quit
356 /// ```
357 pub fn quit_intent(mut self, intent: &'static str) -> Self {
358 self.quit_route = Some((intent, MenuItemId::next()));
359 self
360 }
361
362 /// The intent [`quit_intent`](Self::quit_intent) installed, or `None` if
363 /// Quit is still the platform's own terminate selector.
364 ///
365 /// Public so an app can *test* that its Quit is guarded. Everything the
366 /// routing does happens on macOS, where a downstream test suite generally
367 /// does not run, so without a getter the difference between a guarded ⌘Q and
368 /// an unguarded one is invisible from the app's side — which is the same
369 /// blind spot that let the unrouted default ship in the first place.
370 pub fn quit_intent_name(&self) -> Option<&'static str> {
371 self.quit_route.map(|(intent, _)| intent)
372 }
373
374 /// The intent + item id [`quit_intent`](Self::quit_intent) installed, if any.
375 pub(crate) fn quit_route(&self) -> Option<(&'static str, MenuItemId)> {
376 self.quit_route
377 }
378
379 /// Put **Settings…** in the App menu, routed through `intent`, with the
380 /// platform's own placement and key equivalent (⌘, on macOS).
381 ///
382 /// macOS keeps app settings in the application menu, not in File or Edit,
383 /// and ⌘, is the only chord users try. Neither is reachable from a plain
384 /// `MenuEntry`: the App menu is filled in by the platform, so an entry the
385 /// model declares lands in some other menu instead.
386 ///
387 /// Unlike [`quit_intent`](Self::quit_intent) this is the *only* way to get
388 /// the item at all — no platform opens an app's settings on its own, so
389 /// leaving it unset omits the row rather than falling back to a system
390 /// behaviour. Route it to the same intent your in-window "Settings" command
391 /// fires, and the two stay one command.
392 ///
393 /// ```ignore
394 /// StandardMenu::app()
395 /// .title(tr!(app_name()))
396 /// .settings(tr!(settings()))
397 /// .settings_intent("app.settings")
398 /// ```
399 pub fn settings_intent(mut self, intent: &'static str) -> Self {
400 self.settings_route = Some((intent, MenuItemId::next()));
401 self
402 }
403
404 /// The intent [`settings_intent`](Self::settings_intent) installed, or
405 /// `None` if the App menu carries no Settings item.
406 ///
407 /// Public for the same reason as
408 /// [`quit_intent_name`](Self::quit_intent_name): the wiring only takes
409 /// effect on macOS, where an app's test suite generally does not run, so
410 /// without a getter a missing route is invisible from the app's side.
411 pub fn settings_intent_name(&self) -> Option<&'static str> {
412 self.settings_route.map(|(intent, _)| intent)
413 }
414
415 /// The intent + item id [`settings_intent`](Self::settings_intent)
416 /// installed, if any.
417 /// Advertise the registered shortcut `id` on the routed **Quit** row,
418 /// instead of the platform's conventional chord (⌘Q on macOS).
419 ///
420 /// Worth naming whenever the app registers a quit shortcut of its own —
421 /// which is to say whenever [`quit_intent`](Self::quit_intent) is set, since
422 /// the intent has to be reachable somehow. The chord then comes from the
423 /// `ShortcutRegistry` like every other menu row's: it follows the
424 /// primary-accelerator convention, and it follows a user's rebind. Left
425 /// unset, this row is the one place in the app advertising a chord nothing
426 /// registered — still live after the user moved the command elsewhere, and
427 /// shadowing the chord they moved it to, because the platform dispatches a
428 /// main-menu key equivalent before the responder chain.
429 pub fn quit_shortcut(mut self, id: &'static str) -> Self {
430 self.quit_shortcut = Some(id);
431 self
432 }
433
434 /// Advertise the registered shortcut `id` on the routed **Settings…** row,
435 /// instead of the platform's conventional chord (⌘, on macOS). Same
436 /// reasoning as [`quit_shortcut`](Self::quit_shortcut).
437 pub fn settings_shortcut(mut self, id: &'static str) -> Self {
438 self.settings_shortcut = Some(id);
439 self
440 }
441
442 /// The shortcut id named for the Quit row, if any.
443 pub fn quit_shortcut_id(&self) -> Option<&'static str> {
444 self.quit_shortcut
445 }
446
447 /// The shortcut id named for the Settings row, if any.
448 pub fn settings_shortcut_id(&self) -> Option<&'static str> {
449 self.settings_shortcut
450 }
451
452 pub(crate) fn settings_route(&self) -> Option<(&'static str, MenuItemId)> {
453 self.settings_route
454 }
455
456 /// Resolve to the platform's localized-label struct (widget-layer i18n
457 /// resolution happens here, so the platform never hardcodes English).
458 pub(crate) fn resolve_labels(&self) -> teksilo_platform::native_menu::StandardLabels {
459 teksilo_platform::native_menu::StandardLabels {
460 title: self.title.resolve_now(),
461 about: self.about.resolve_now(),
462 settings: self.settings.resolve_now(),
463 hide: self.hide.resolve_now(),
464 quit: self.quit.resolve_now(),
465 minimize: self.minimize.resolve_now(),
466 zoom: self.zoom.resolve_now(),
467 }
468 }
469}
470
471/// Builder for the contents of one (sub)menu — a sequence of items, separators,
472/// and nested submenus.
473#[derive(Clone, Default)]
474pub struct MenuItems {
475 pub(crate) nodes: Vec<MenuNode>,
476}
477
478impl MenuItems {
479 /// An empty contents builder.
480 pub fn new() -> Self {
481 Self::default()
482 }
483
484 /// Append a leaf command.
485 pub fn item(mut self, entry: MenuEntry) -> Self {
486 self.nodes.push(MenuNode::Item(entry));
487 self
488 }
489
490 /// Append a separator.
491 pub fn separator(mut self) -> Self {
492 self.nodes.push(MenuNode::Separator);
493 self
494 }
495
496 /// Append a nested submenu (auto-assigned id).
497 pub fn submenu(
498 self,
499 title: impl Into<LocalizedString>,
500 build: impl FnOnce(MenuItems) -> MenuItems,
501 ) -> Self {
502 self.submenu_with_id(MenuItemId::next(), title, build)
503 }
504
505 /// Append a nested submenu with a caller-supplied id, so it can be
506 /// addressed later by [`MenuModel::push_item`] / [`MenuModel::remove`].
507 pub fn submenu_with_id(
508 mut self,
509 id: MenuItemId,
510 title: impl Into<LocalizedString>,
511 build: impl FnOnce(MenuItems) -> MenuItems,
512 ) -> Self {
513 let children = build(MenuItems::new()).nodes;
514 self.nodes.push(MenuNode::Submenu {
515 id,
516 title: title.into(),
517 children,
518 });
519 self
520 }
521}
522
523/// A declarative menu tree shared by the in-window [`MenuBar`](crate::menu_bar::MenuBar)
524/// and the native OS menu bar. Cloneable by handle (`Rc` inside); a clone shares
525/// the same nodes and `version` signal, so mutating one updates every view.
526#[derive(Clone)]
527pub struct MenuModel {
528 nodes: Rc<RefCell<Vec<MenuNode>>>,
529 version: Signal<u64>,
530}
531
532impl Default for MenuModel {
533 fn default() -> Self {
534 Self::new()
535 }
536}
537
538impl MenuModel {
539 /// An empty model.
540 pub fn new() -> Self {
541 Self {
542 nodes: Rc::new(RefCell::new(Vec::new())),
543 version: Signal::new(0),
544 }
545 }
546
547 /// Append a top-level menu with the given title and contents (auto id).
548 pub fn menu(
549 self,
550 title: impl Into<LocalizedString>,
551 build: impl FnOnce(MenuItems) -> MenuItems,
552 ) -> Self {
553 self.menu_with_id(MenuItemId::next(), title, build)
554 }
555
556 /// Append a top-level menu with a caller-supplied id, so it can be addressed
557 /// later by [`push_item`](Self::push_item) / [`remove`](Self::remove).
558 pub fn menu_with_id(
559 self,
560 id: MenuItemId,
561 title: impl Into<LocalizedString>,
562 build: impl FnOnce(MenuItems) -> MenuItems,
563 ) -> Self {
564 let children = build(MenuItems::new()).nodes;
565 self.nodes.borrow_mut().push(MenuNode::Submenu {
566 id,
567 title: title.into(),
568 children,
569 });
570 self.bump();
571 self
572 }
573
574 /// Append a platform-standard top-level menu (macOS App / Window / Help)
575 /// with default (English) labels. Use [`standard_menu`](Self::standard_menu)
576 /// to supply localized labels.
577 pub fn standard(self, role: StandardMenuRole) -> Self {
578 self.standard_menu(StandardMenu::for_role(role))
579 }
580
581 /// Append a platform-standard top-level menu with localized labels.
582 pub fn standard_menu(self, menu: StandardMenu) -> Self {
583 self.nodes.borrow_mut().push(MenuNode::Standard(menu));
584 self.bump();
585 self
586 }
587
588 /// A `Signal<u64>` bumped whenever the tree's *structure* changes. The
589 /// native bridge re-installs the menu on a bump; per-item state changes go
590 /// through the finer-grained `update_item` path instead.
591 pub fn version(&self) -> Signal<u64> {
592 self.version.clone()
593 }
594
595 /// Borrow the top-level nodes.
596 pub fn nodes(&self) -> Ref<'_, Vec<MenuNode>> {
597 self.nodes.borrow()
598 }
599
600 // ── Runtime structural mutation ────────────────────────────────────────
601 //
602 // These `&self` mutators change the menu *structure* at runtime and bump
603 // `version`. A `MenuBar::from_model` bar binds `version` at `Rebuild` level,
604 // so a bump re-derives the in-window dropdowns AND re-installs the native
605 // menu. Per-item *state* (enabled / check / radio) does NOT need these —
606 // bind a `Signal` to the `MenuEntry` instead (reactive without a rebuild).
607
608 /// Mutate the node tree directly, then bump `version`. The escape hatch for
609 /// any structural change the typed helpers don't cover (reorder, retitle,
610 /// bulk edits). `MenuNode` / `MenuEntry` are public, so the closure can
611 /// build whatever it needs.
612 pub fn modify(&self, f: impl FnOnce(&mut Vec<MenuNode>)) {
613 f(&mut self.nodes.borrow_mut());
614 self.bump();
615 }
616
617 /// Append a top-level menu at runtime, returning its id. Mirrors
618 /// [`menu`](Self::menu) but takes `&self`.
619 pub fn push_menu(
620 &self,
621 title: impl Into<LocalizedString>,
622 build: impl FnOnce(MenuItems) -> MenuItems,
623 ) -> MenuItemId {
624 let id = MenuItemId::next();
625 let children = build(MenuItems::new()).nodes;
626 self.nodes.borrow_mut().push(MenuNode::Submenu {
627 id,
628 title: title.into(),
629 children,
630 });
631 self.bump();
632 id
633 }
634
635 /// Append `entry` to the submenu identified by `into` (a top-level menu or
636 /// nested submenu id). Returns `true` if the submenu was found.
637 pub fn push_item(&self, into: MenuItemId, entry: MenuEntry) -> bool {
638 let ok = {
639 let mut nodes = self.nodes.borrow_mut();
640 push_into_submenu(&mut nodes, into, MenuNode::Item(entry))
641 };
642 if ok {
643 self.bump();
644 }
645 ok
646 }
647
648 /// Append a separator to the submenu identified by `into`. Returns `true`
649 /// if the submenu was found.
650 pub fn push_separator(&self, into: MenuItemId) -> bool {
651 let ok = {
652 let mut nodes = self.nodes.borrow_mut();
653 push_into_submenu(&mut nodes, into, MenuNode::Separator)
654 };
655 if ok {
656 self.bump();
657 }
658 ok
659 }
660
661 /// Insert a top-level menu at `index`, under a caller-supplied id.
662 ///
663 /// [`push_menu`](Self::push_menu) appends, which puts a menu after Help —
664 /// fine for something added once at startup, wrong for a menu that comes and
665 /// goes, since a writer looking for it needs it in the same place every
666 /// time. The id is the caller's for the same reason: a menu that will be
667 /// removed again has to be nameable before it exists.
668 ///
669 /// `index` is clamped, so a model that has since grown or shrunk cannot
670 /// panic a caller holding a stale position.
671 pub fn insert_menu_at(
672 &self,
673 index: usize,
674 id: MenuItemId,
675 title: impl Into<LocalizedString>,
676 build: impl FnOnce(MenuItems) -> MenuItems,
677 ) {
678 let children = build(MenuItems::new()).nodes;
679 {
680 let mut nodes = self.nodes.borrow_mut();
681 let at = index.min(nodes.len());
682 nodes.insert(
683 at,
684 MenuNode::Submenu {
685 id,
686 title: title.into(),
687 children,
688 },
689 );
690 }
691 self.bump();
692 }
693
694 /// Whether a node with this id is anywhere in the tree.
695 ///
696 /// The companion to [`remove`](Self::remove) for callers that add and
697 /// remove the same node as state changes: without it, "is it already
698 /// there?" can only be answered by removing it and seeing what comes back,
699 /// which bumps the version and re-installs the native menu for nothing.
700 pub fn contains(&self, id: MenuItemId) -> bool {
701 fn find(nodes: &[MenuNode], id: MenuItemId) -> bool {
702 nodes.iter().any(|n| match n {
703 MenuNode::Item(entry) => entry.id == id,
704 MenuNode::Submenu {
705 id: sid, children, ..
706 } => *sid == id || find(children, id),
707 _ => false,
708 })
709 }
710 find(&self.nodes.borrow(), id)
711 }
712
713 /// Remove the item or submenu with the given id, anywhere in the tree.
714 /// Returns `true` if a node was removed.
715 pub fn remove(&self, id: MenuItemId) -> bool {
716 let removed = {
717 let mut nodes = self.nodes.borrow_mut();
718 remove_by_id(&mut nodes, id)
719 };
720 if removed {
721 self.bump();
722 }
723 removed
724 }
725
726 fn bump(&self) {
727 let v = self.version.get();
728 self.version.set(v.wrapping_add(1));
729 }
730}
731
732/// Append `node` to the children of the submenu with id `into` (searched
733/// recursively). Returns whether the submenu was found.
734fn push_into_submenu(nodes: &mut [MenuNode], into: MenuItemId, node: MenuNode) -> bool {
735 // Two-phase to avoid moving `node` into a non-matching branch: first locate.
736 fn find(nodes: &mut [MenuNode], into: MenuItemId) -> Option<&mut Vec<MenuNode>> {
737 for n in nodes {
738 if let MenuNode::Submenu { id, children, .. } = n {
739 if *id == into {
740 return Some(children);
741 }
742 if let Some(found) = find(children, into) {
743 return Some(found);
744 }
745 }
746 }
747 None
748 }
749 match find(nodes, into) {
750 Some(children) => {
751 children.push(node);
752 true
753 }
754 None => false,
755 }
756}
757
758/// Remove the first node whose id matches (item or submenu), recursively.
759fn remove_by_id(nodes: &mut Vec<MenuNode>, id: MenuItemId) -> bool {
760 if let Some(pos) = nodes.iter().position(|n| match n {
761 MenuNode::Item(e) => e.id == id,
762 MenuNode::Submenu { id: sid, .. } => *sid == id,
763 _ => false,
764 }) {
765 nodes.remove(pos);
766 return true;
767 }
768 for n in nodes.iter_mut() {
769 if let MenuNode::Submenu { children, .. } = n {
770 if remove_by_id(children, id) {
771 return true;
772 }
773 }
774 }
775 false
776}
777
778/// Build the in-window dropdown [`MenuList`] for a slice of nodes. Standard
779/// roles are skipped (they only exist in the native bar).
780pub(crate) fn build_menu_list(nodes: &[MenuNode]) -> MenuList {
781 let mut list = MenuList::new();
782 for node in nodes {
783 match node {
784 MenuNode::Item(entry) => {
785 // `item_when` gates visibility reactively (Static(true) ⇒ always
786 // shown, equivalent to `.item`).
787 list = list.item_when(entry.to_menu_item(), entry.visible.clone());
788 }
789 MenuNode::Separator => {
790 list = list.separator();
791 }
792 MenuNode::Submenu {
793 title, children, ..
794 } => {
795 let children = children.clone();
796 list = list.item(MenuItem::submenu(title.clone(), move || {
797 Box::new(build_menu_list(&children))
798 }));
799 }
800 MenuNode::Standard(_) => {}
801 }
802 }
803 list
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809 use teksilo_i18n::lit;
810
811 #[test]
812 fn menu_appends_nodes_and_bumps_version() {
813 let model = MenuModel::new();
814 let v0 = model.version().get();
815 let model = model
816 .menu(lit!("File"), |m| {
817 m.item(MenuEntry::new(lit!("New"))).separator()
818 })
819 .standard(StandardMenuRole::Window);
820 assert!(
821 model.version().get() > v0,
822 "structural change bumps version"
823 );
824 let nodes = model.nodes();
825 assert_eq!(nodes.len(), 2);
826 assert!(matches!(nodes[0], MenuNode::Submenu { .. }));
827 let MenuNode::Standard(sm) = &nodes[1] else {
828 panic!("expected standard menu");
829 };
830 assert_eq!(sm.role(), StandardMenuRole::Window);
831 }
832
833 #[test]
834 fn each_entry_gets_a_unique_id() {
835 let a = MenuEntry::new(lit!("A"));
836 let b = MenuEntry::new(lit!("B"));
837 assert_ne!(a.id(), b.id());
838 }
839
840 #[test]
841 fn push_item_into_submenu_by_id_and_remove() {
842 let recent = teksilo_core::MenuItemId::next();
843 let model = MenuModel::new().menu_with_id(recent, lit!("File"), |m| m);
844 let v0 = model.version().get();
845
846 // Add into the addressed submenu.
847 let doc = MenuEntry::new(lit!("doc.txt"));
848 let doc_id = doc.id();
849 assert!(model.push_item(recent, doc));
850 assert!(model.version().get() > v0, "push bumps version");
851
852 // It landed inside the File submenu.
853 {
854 let nodes = model.nodes();
855 let MenuNode::Submenu { children, .. } = &nodes[0] else {
856 panic!("expected submenu");
857 };
858 assert_eq!(children.len(), 1);
859 }
860
861 // Push to a non-existent submenu is a no-op (returns false).
862 assert!(!model.push_item(teksilo_core::MenuItemId::next(), MenuEntry::new(lit!("x"))));
863
864 // Remove the item by id.
865 assert!(model.remove(doc_id));
866 {
867 let nodes = model.nodes();
868 let MenuNode::Submenu { children, .. } = &nodes[0] else {
869 panic!("expected submenu");
870 };
871 assert!(children.is_empty());
872 }
873 assert!(!model.remove(doc_id), "second remove is a no-op");
874 }
875
876 #[test]
877 fn push_menu_and_modify_at_runtime() {
878 let model = MenuModel::new();
879 let id = model.push_menu(lit!("Edit"), |m| m.item(MenuEntry::new(lit!("Cut"))));
880 assert_eq!(model.nodes().len(), 1);
881
882 // Escape hatch: append a top-level separator-bearing menu via modify.
883 model.modify(|nodes| {
884 nodes.push(MenuNode::Separator);
885 });
886 assert_eq!(model.nodes().len(), 2);
887
888 // The pushed menu is addressable.
889 assert!(model.remove(id));
890 assert_eq!(model.nodes().len(), 1);
891 }
892
893 #[test]
894 fn submenu_nesting_is_preserved() {
895 let model = MenuModel::new().menu(lit!("File"), |m| {
896 m.submenu(lit!("Recent"), |s| s.item(MenuEntry::new(lit!("doc.txt"))))
897 });
898 let nodes = model.nodes();
899 let MenuNode::Submenu { children, .. } = &nodes[0] else {
900 panic!("expected submenu");
901 };
902 assert!(matches!(children[0], MenuNode::Submenu { .. }));
903 }
904}