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 /// Advertise the registered shortcut `id` on the routed **Quit** row,
416 /// instead of the platform's conventional chord (⌘Q on macOS).
417 /// The intent + item id [`settings_intent`](Self::settings_intent)
418 /// installed, if any.)
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 several leaf commands from an iterator, in order.
491 ///
492 /// The loop form of [`item`](Self::item), and the usual one for a menu whose
493 /// rows come from data (a recent-files list, a window list).
494 pub fn items(self, entries: impl IntoIterator<Item = MenuEntry>) -> Self {
495 entries.into_iter().fold(self, Self::item)
496 }
497
498 /// Append a separator.
499 pub fn separator(mut self) -> Self {
500 self.nodes.push(MenuNode::Separator);
501 self
502 }
503
504 /// Append a nested submenu (auto-assigned id).
505 pub fn submenu(
506 self,
507 title: impl Into<LocalizedString>,
508 build: impl FnOnce(MenuItems) -> MenuItems,
509 ) -> Self {
510 self.submenu_with_id(MenuItemId::next(), title, build)
511 }
512
513 /// Append a nested submenu with a caller-supplied id, so it can be
514 /// addressed later by [`MenuModel::push_item`] / [`MenuModel::remove`].
515 pub fn submenu_with_id(
516 mut self,
517 id: MenuItemId,
518 title: impl Into<LocalizedString>,
519 build: impl FnOnce(MenuItems) -> MenuItems,
520 ) -> Self {
521 let children = build(MenuItems::new()).nodes;
522 self.nodes.push(MenuNode::Submenu {
523 id,
524 title: title.into(),
525 children,
526 });
527 self
528 }
529}
530
531/// A declarative menu tree shared by the in-window [`MenuBar`](crate::menu_bar::MenuBar)
532/// and the native OS menu bar. Cloneable by handle (`Rc` inside); a clone shares
533/// the same nodes and `version` signal, so mutating one updates every view.
534#[derive(Clone)]
535pub struct MenuModel {
536 nodes: Rc<RefCell<Vec<MenuNode>>>,
537 version: Signal<u64>,
538}
539
540impl Default for MenuModel {
541 fn default() -> Self {
542 Self::new()
543 }
544}
545
546impl MenuModel {
547 /// An empty model.
548 pub fn new() -> Self {
549 Self {
550 nodes: Rc::new(RefCell::new(Vec::new())),
551 version: Signal::new(0),
552 }
553 }
554
555 /// Append a top-level menu with the given title and contents (auto id).
556 pub fn menu(
557 self,
558 title: impl Into<LocalizedString>,
559 build: impl FnOnce(MenuItems) -> MenuItems,
560 ) -> Self {
561 self.menu_with_id(MenuItemId::next(), title, build)
562 }
563
564 /// Append a top-level menu with a caller-supplied id, so it can be addressed
565 /// later by [`push_item`](Self::push_item) / [`remove`](Self::remove).
566 pub fn menu_with_id(
567 self,
568 id: MenuItemId,
569 title: impl Into<LocalizedString>,
570 build: impl FnOnce(MenuItems) -> MenuItems,
571 ) -> Self {
572 let children = build(MenuItems::new()).nodes;
573 self.nodes.borrow_mut().push(MenuNode::Submenu {
574 id,
575 title: title.into(),
576 children,
577 });
578 self.bump();
579 self
580 }
581
582 /// Append a platform-standard top-level menu (macOS App / Window / Help)
583 /// with default (English) labels. Use [`standard_menu`](Self::standard_menu)
584 /// to supply localized labels.
585 pub fn standard(self, role: StandardMenuRole) -> Self {
586 self.standard_menu(StandardMenu::for_role(role))
587 }
588
589 /// Append a platform-standard top-level menu with localized labels.
590 pub fn standard_menu(self, menu: StandardMenu) -> Self {
591 self.nodes.borrow_mut().push(MenuNode::Standard(menu));
592 self.bump();
593 self
594 }
595
596 /// Append several platform-standard top-level menus from an iterator.
597 ///
598 /// The loop form of [`standard_menu`](Self::standard_menu). Use it to place
599 /// the whole standard set in one call; build each entry with
600 /// [`StandardMenu::for_role`] when the labels stay at their defaults.
601 pub fn standard_menus(self, menus: impl IntoIterator<Item = StandardMenu>) -> Self {
602 menus.into_iter().fold(self, Self::standard_menu)
603 }
604
605 /// A `Signal<u64>` bumped whenever the tree's *structure* changes. The
606 /// native bridge re-installs the menu on a bump; per-item state changes go
607 /// through the finer-grained `update_item` path instead.
608 pub fn version(&self) -> Signal<u64> {
609 self.version.clone()
610 }
611
612 /// Borrow the top-level nodes.
613 pub fn nodes(&self) -> Ref<'_, Vec<MenuNode>> {
614 self.nodes.borrow()
615 }
616
617 // ── Runtime structural mutation ────────────────────────────────────────
618 //
619 // These `&self` mutators change the menu *structure* at runtime and bump
620 // `version`. A `MenuBar::from_model` bar binds `version` at `Rebuild` level,
621 // so a bump re-derives the in-window dropdowns AND re-installs the native
622 // menu. Per-item *state* (enabled / check / radio) does NOT need these —
623 // bind a `Signal` to the `MenuEntry` instead (reactive without a rebuild).
624
625 /// Mutate the node tree directly, then bump `version`. The escape hatch for
626 /// any structural change the typed helpers don't cover (reorder, retitle,
627 /// bulk edits). `MenuNode` / `MenuEntry` are public, so the closure can
628 /// build whatever it needs.
629 pub fn modify(&self, f: impl FnOnce(&mut Vec<MenuNode>)) {
630 f(&mut self.nodes.borrow_mut());
631 self.bump();
632 }
633
634 /// Append a top-level menu at runtime, returning its id. Mirrors
635 /// [`menu`](Self::menu) but takes `&self`.
636 pub fn push_menu(
637 &self,
638 title: impl Into<LocalizedString>,
639 build: impl FnOnce(MenuItems) -> MenuItems,
640 ) -> MenuItemId {
641 let id = MenuItemId::next();
642 let children = build(MenuItems::new()).nodes;
643 self.nodes.borrow_mut().push(MenuNode::Submenu {
644 id,
645 title: title.into(),
646 children,
647 });
648 self.bump();
649 id
650 }
651
652 /// Append `entry` to the submenu identified by `into` (a top-level menu or
653 /// nested submenu id). Returns `true` if the submenu was found.
654 pub fn push_item(&self, into: MenuItemId, entry: MenuEntry) -> bool {
655 let ok = {
656 let mut nodes = self.nodes.borrow_mut();
657 push_into_submenu(&mut nodes, into, MenuNode::Item(entry))
658 };
659 if ok {
660 self.bump();
661 }
662 ok
663 }
664
665 /// Append a separator to the submenu identified by `into`. Returns `true`
666 /// if the submenu was found.
667 pub fn push_separator(&self, into: MenuItemId) -> bool {
668 let ok = {
669 let mut nodes = self.nodes.borrow_mut();
670 push_into_submenu(&mut nodes, into, MenuNode::Separator)
671 };
672 if ok {
673 self.bump();
674 }
675 ok
676 }
677
678 /// Insert a top-level menu at `index`, under a caller-supplied id.
679 ///
680 /// [`push_menu`](Self::push_menu) appends, which puts a menu after Help —
681 /// fine for something added once at startup, wrong for a menu that comes and
682 /// goes, since a writer looking for it needs it in the same place every
683 /// time. The id is the caller's for the same reason: a menu that will be
684 /// removed again has to be nameable before it exists.
685 ///
686 /// `index` is clamped, so a model that has since grown or shrunk cannot
687 /// panic a caller holding a stale position.
688 pub fn insert_menu_at(
689 &self,
690 index: usize,
691 id: MenuItemId,
692 title: impl Into<LocalizedString>,
693 build: impl FnOnce(MenuItems) -> MenuItems,
694 ) {
695 let children = build(MenuItems::new()).nodes;
696 {
697 let mut nodes = self.nodes.borrow_mut();
698 let at = index.min(nodes.len());
699 nodes.insert(
700 at,
701 MenuNode::Submenu {
702 id,
703 title: title.into(),
704 children,
705 },
706 );
707 }
708 self.bump();
709 }
710
711 /// Whether a node with this id is anywhere in the tree.
712 ///
713 /// The companion to [`remove`](Self::remove) for callers that add and
714 /// remove the same node as state changes: without it, "is it already
715 /// there?" can only be answered by removing it and seeing what comes back,
716 /// which bumps the version and re-installs the native menu for nothing.
717 pub fn contains(&self, id: MenuItemId) -> bool {
718 fn find(nodes: &[MenuNode], id: MenuItemId) -> bool {
719 nodes.iter().any(|n| match n {
720 MenuNode::Item(entry) => entry.id == id,
721 MenuNode::Submenu {
722 id: sid, children, ..
723 } => *sid == id || find(children, id),
724 _ => false,
725 })
726 }
727 find(&self.nodes.borrow(), id)
728 }
729
730 /// Remove the item or submenu with the given id, anywhere in the tree.
731 /// Returns `true` if a node was removed.
732 pub fn remove(&self, id: MenuItemId) -> bool {
733 let removed = {
734 let mut nodes = self.nodes.borrow_mut();
735 remove_by_id(&mut nodes, id)
736 };
737 if removed {
738 self.bump();
739 }
740 removed
741 }
742
743 fn bump(&self) {
744 let v = self.version.get();
745 self.version.set(v.wrapping_add(1));
746 }
747}
748
749/// Append `node` to the children of the submenu with id `into` (searched
750/// recursively). Returns whether the submenu was found.
751fn push_into_submenu(nodes: &mut [MenuNode], into: MenuItemId, node: MenuNode) -> bool {
752 // Two-phase to avoid moving `node` into a non-matching branch: first locate.
753 fn find(nodes: &mut [MenuNode], into: MenuItemId) -> Option<&mut Vec<MenuNode>> {
754 for n in nodes {
755 if let MenuNode::Submenu { id, children, .. } = n {
756 if *id == into {
757 return Some(children);
758 }
759 if let Some(found) = find(children, into) {
760 return Some(found);
761 }
762 }
763 }
764 None
765 }
766 match find(nodes, into) {
767 Some(children) => {
768 children.push(node);
769 true
770 }
771 None => false,
772 }
773}
774
775/// Remove the first node whose id matches (item or submenu), recursively.
776fn remove_by_id(nodes: &mut Vec<MenuNode>, id: MenuItemId) -> bool {
777 if let Some(pos) = nodes.iter().position(|n| match n {
778 MenuNode::Item(e) => e.id == id,
779 MenuNode::Submenu { id: sid, .. } => *sid == id,
780 _ => false,
781 }) {
782 nodes.remove(pos);
783 return true;
784 }
785 for n in nodes.iter_mut() {
786 if let MenuNode::Submenu { children, .. } = n {
787 if remove_by_id(children, id) {
788 return true;
789 }
790 }
791 }
792 false
793}
794
795/// Build the in-window dropdown [`MenuList`] for a slice of nodes. Standard
796/// roles are skipped (they only exist in the native bar).
797pub(crate) fn build_menu_list(nodes: &[MenuNode]) -> MenuList {
798 let mut list = MenuList::new();
799 for node in nodes {
800 match node {
801 MenuNode::Item(entry) => {
802 // `item_when` gates visibility reactively (Static(true) ⇒ always
803 // shown, equivalent to `.item`).
804 list = list.item_when(entry.to_menu_item(), entry.visible.clone());
805 }
806 MenuNode::Separator => {
807 list = list.separator();
808 }
809 MenuNode::Submenu {
810 title, children, ..
811 } => {
812 let children = children.clone();
813 list = list.item(MenuItem::submenu(title.clone(), move || {
814 Box::new(build_menu_list(&children))
815 }));
816 }
817 MenuNode::Standard(_) => {}
818 }
819 }
820 list
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use teksilo_i18n::lit;
827
828 #[test]
829 fn menu_appends_nodes_and_bumps_version() {
830 let model = MenuModel::new();
831 let v0 = model.version().get();
832 let model = model
833 .menu(lit!("File"), |m| {
834 m.item(MenuEntry::new(lit!("New"))).separator()
835 })
836 .standard(StandardMenuRole::Window);
837 assert!(
838 model.version().get() > v0,
839 "structural change bumps version"
840 );
841 let nodes = model.nodes();
842 assert_eq!(nodes.len(), 2);
843 assert!(matches!(nodes[0], MenuNode::Submenu { .. }));
844 let MenuNode::Standard(sm) = &nodes[1] else {
845 panic!("expected standard menu");
846 };
847 assert_eq!(sm.role(), StandardMenuRole::Window);
848 }
849
850 #[test]
851 fn each_entry_gets_a_unique_id() {
852 let a = MenuEntry::new(lit!("A"));
853 let b = MenuEntry::new(lit!("B"));
854 assert_ne!(a.id(), b.id());
855 }
856
857 #[test]
858 fn push_item_into_submenu_by_id_and_remove() {
859 let recent = teksilo_core::MenuItemId::next();
860 let model = MenuModel::new().menu_with_id(recent, lit!("File"), |m| m);
861 let v0 = model.version().get();
862
863 // Add into the addressed submenu.
864 let doc = MenuEntry::new(lit!("doc.txt"));
865 let doc_id = doc.id();
866 assert!(model.push_item(recent, doc));
867 assert!(model.version().get() > v0, "push bumps version");
868
869 // It landed inside the File submenu.
870 {
871 let nodes = model.nodes();
872 let MenuNode::Submenu { children, .. } = &nodes[0] else {
873 panic!("expected submenu");
874 };
875 assert_eq!(children.len(), 1);
876 }
877
878 // Push to a non-existent submenu is a no-op (returns false).
879 assert!(!model.push_item(teksilo_core::MenuItemId::next(), MenuEntry::new(lit!("x"))));
880
881 // Remove the item by id.
882 assert!(model.remove(doc_id));
883 {
884 let nodes = model.nodes();
885 let MenuNode::Submenu { children, .. } = &nodes[0] else {
886 panic!("expected submenu");
887 };
888 assert!(children.is_empty());
889 }
890 assert!(!model.remove(doc_id), "second remove is a no-op");
891 }
892
893 #[test]
894 fn push_menu_and_modify_at_runtime() {
895 let model = MenuModel::new();
896 let id = model.push_menu(lit!("Edit"), |m| m.item(MenuEntry::new(lit!("Cut"))));
897 assert_eq!(model.nodes().len(), 1);
898
899 // Escape hatch: append a top-level separator-bearing menu via modify.
900 model.modify(|nodes| {
901 nodes.push(MenuNode::Separator);
902 });
903 assert_eq!(model.nodes().len(), 2);
904
905 // The pushed menu is addressable.
906 assert!(model.remove(id));
907 assert_eq!(model.nodes().len(), 1);
908 }
909
910 #[test]
911 fn submenu_nesting_is_preserved() {
912 let model = MenuModel::new().menu(lit!("File"), |m| {
913 m.submenu(lit!("Recent"), |s| s.item(MenuEntry::new(lit!("doc.txt"))))
914 });
915 let nodes = model.nodes();
916 let MenuNode::Submenu { children, .. } = &nodes[0] else {
917 panic!("expected submenu");
918 };
919 assert!(matches!(children[0], MenuNode::Submenu { .. }));
920 }
921}