Skip to main content

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}