Skip to main content

teksilo_platform/
native_menu.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Native (OS) menu service.
5//!
6//! Mirrors a logical menu tree (the `teksilo-widgets` `MenuModel`) into the
7//! platform's *native* menu surface — the global menu bar at the top of the
8//! screen on macOS (`NSApplication.mainMenu`), and, in the future, an `HMENU`
9//! on Windows or a DBus app-menu on Linux. A serious desktop app is expected to
10//! present its menus this way on macOS; an in-window menu strip alone reads as
11//! non-native.
12//!
13//! Three concerns are separated, mirroring [`crate::file_dialog`] and
14//! [`crate::external_dnd`]:
15//!
16//! - **Boundary data** — [`NativeMenuSnapshot`] is a plain, already-resolved
17//!   description of the whole tree (display strings, key equivalents, enabled /
18//!   check state, stable [`MenuItemId`]s). It carries no widgets, signals, or
19//!   localized strings — the widget layer resolves all of that before handing a
20//!   snapshot down, so `teksilo-platform` never depends on `teksilo-widgets`.
21//! - **Trait surface** — [`NativeMenuBackend`] is the swappable platform
22//!   abstraction (macOS `NSMenu`; [`NoopNativeMenuBackend`] elsewhere).
23//! - **Handle** — [`NativeMenuHandle`] is the per-app service registered in
24//!   app-state. It owns the backend and, per window, the map from
25//!   [`MenuItemId`] to the action to run when that item is chosen.
26//!
27//! # Activation routing
28//!
29//! When the user picks a native menu item, the backend posts a
30//! [`NativeMenuEventPayload`] through [`teksilo_core::AppEventPoster::post_external`].
31//! `teksilo-app` picks it up in its `AppEvent::External` arm and looks the
32//! [`MenuItemId`] up in the [`NativeMenuHandle`]. It runs the item's action
33//! in the window whose menu it is (in the application tree for an item of the
34//! application menu), and sends the item's intent from the focused widget of
35//! the window with the OS focus, as the item's shortcut would be; see
36//! `teksilo-app`'s `native_menu_route`.
37//!
38//! # The application menu and the windows' menus
39//!
40//! On macOS there is exactly one global menu bar. It shows the
41//! **application menu** ([`NativeMenuHandle::set_app_menu`]), which
42//! `teksilo-app` installs from the application tree, whenever the key window
43//! has no menu of its own and while no window is open. A window with a menu
44//! of its own ([`NativeMenuHandle::set_window_menu`], from a `MenuBar` built
45//! with `MenuBar::from_model`) replaces it while that window is key.
46//! `teksilo-app` calls [`NativeMenuHandle::activate_window`] on every focus
47//! change, which picks between the two.
48
49use std::cell::RefCell;
50use std::collections::HashMap;
51use std::rc::Rc;
52use std::sync::Arc;
53
54use teksilo_core::AppEventPoster;
55use teksilo_core::MenuItemId;
56use teksilo_core::widget::EventContext;
57use teksilo_core::window::TeksiloWindowId;
58
59#[cfg(target_os = "macos")]
60mod macos;
61
62// ============================================================
63// Snapshot data (the platform boundary type)
64// ============================================================
65
66/// On/off/mixed state for a checkable native menu item.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum NativeCheck {
69    /// Not a checkable item — no check-mark column behaviour.
70    #[default]
71    None,
72    /// Checkable, currently unchecked.
73    Off,
74    /// Checkable, currently checked.
75    On,
76    /// Checkable, currently mixed/indeterminate (tri-state parents).
77    Mixed,
78}
79
80/// A platform-neutral key equivalent for a native menu item. Already resolved
81/// from the app's `ShortcutRegistry` by the widget layer. `key` is the base
82/// character the OS menu expects (e.g. `"s"`, `"\r"`); the booleans are the
83/// modifier flags. An item with an empty `key` displays no shortcut.
84#[derive(Debug, Clone, Default, PartialEq, Eq)]
85pub struct NativeKeyEquivalent {
86    /// The base key as the single string the native menu expects.
87    pub key: String,
88    /// Command (⌘ on macOS) / the platform's primary accelerator modifier.
89    pub command: bool,
90    /// Shift (⇧).
91    pub shift: bool,
92    /// Alt / Option (⌥).
93    pub alt: bool,
94    /// Control (⌃).
95    pub control: bool,
96}
97
98/// Standard, platform-defined menus with required placement/behaviour (the
99/// macOS App / Window / Help menus, with their About / Services / Hide /
100/// Hide Others / Show All / Quit and window-management items wired to system
101/// selectors). The backend supplies the
102/// native structure; the in-window `MenuBar` ignores these.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum StandardMenuRole {
105    /// The application menu (About, Settings, Services, Hide, Hide Others,
106    /// Show All, Quit). The macOS backend shows
107    /// it first in the bar wherever the snapshot declares it, since AppKit
108    /// shows the bar's first menu as the application menu.
109    App,
110    /// The Window menu (Minimize / Zoom / window list).
111    Window,
112    /// The Help menu.
113    Help,
114}
115
116/// Display strings for a [`StandardMenuRole`], **already localized** by the
117/// widget layer. The platform layer never hardcodes user-visible menu text — it
118/// applies whatever the snapshot carries — so a standard menu honours the app's
119/// locale (e.g. "Quitter" / "Masquer" on a French system) instead of leaking
120/// English literals onto the most visible native surface.
121#[derive(Debug, Clone, Default)]
122pub struct StandardLabels {
123    /// Submenu title (Window / Help; the App submenu typically uses the app name).
124    pub title: String,
125    /// "About …" (App).
126    pub about: String,
127    /// "Settings…" / "Preferences…" (App). Only rendered when the snapshot
128    /// also carries a `settings_item` — the platform has no default action
129    /// for it, unlike About, Hide and the rest.
130    pub settings: String,
131    /// "Services" (App): the submenu AppKit fills with the services that
132    /// apply to the current selection.
133    pub services: String,
134    /// "Hide …" (App).
135    pub hide: String,
136    /// "Hide Others" (App).
137    pub hide_others: String,
138    /// "Show All" (App).
139    pub show_all: String,
140    /// "Quit …" (App).
141    pub quit: String,
142    /// "Minimize" (Window).
143    pub minimize: String,
144    /// "Zoom" (Window).
145    pub zoom: String,
146}
147
148/// A row inside a platform-standard menu that the app routes rather than the
149/// platform selects — Quit and Settings today.
150///
151/// Carries the key equivalent alongside the id because the widget layer is the
152/// only place that knows it. Every other item's chord comes from the
153/// `ShortcutRegistry` there, resolved through the primary-accelerator
154/// convention; if the platform layer picked one for these two it would be the
155/// one surface in the app advertising a chord nobody registered — live even
156/// after the user rebound the command, and immune to the rewriting the registry
157/// does for everything else. `None` means no key equivalent at all.
158#[derive(Debug, Clone)]
159pub struct StandardRoutedItem {
160    /// Correlates the native item back to the logical one on activation.
161    pub id: MenuItemId,
162    /// Key equivalent to advertise, already resolved.
163    pub key_equiv: Option<NativeKeyEquivalent>,
164}
165
166/// One node of a native menu tree.
167// A standard menu's labels make it the large variant. A snapshot is built
168// once per install and holds a handful of standard menus, so boxing them
169// would cost every construction for no measurable saving.
170#[allow(clippy::large_enum_variant)]
171#[derive(Debug, Clone)]
172pub enum NativeMenuNode {
173    /// A leaf command.
174    Item {
175        /// Correlates the native item back to the logical one on activation.
176        id: MenuItemId,
177        /// Display text (mnemonics already stripped, locale already resolved).
178        title: String,
179        /// Key equivalent, if any.
180        key_equiv: Option<NativeKeyEquivalent>,
181        /// Whether the item is enabled.
182        enabled: bool,
183        /// Whether the item is shown. A hidden item keeps its place, so a
184        /// [`MenuItemDelta`] can show it again without rebuilding the menu.
185        visible: bool,
186        /// Check-mark state.
187        check: NativeCheck,
188    },
189    /// A submenu with its own children.
190    Submenu {
191        /// Submenu title.
192        title: String,
193        /// Child nodes.
194        children: Vec<NativeMenuNode>,
195    },
196    /// A separator line.
197    Separator,
198    /// A platform-standard menu the backend fills in, with localized chrome.
199    Standard {
200        /// Which standard menu.
201        role: StandardMenuRole,
202        /// Localized display strings (supplied by the widget layer).
203        labels: StandardLabels,
204        /// App menu only: build **Quit** as an ordinary routed item under this
205        /// id (the activation recorded for it, and the key equivalent the
206        /// widget layer resolved), instead of the platform's own terminate
207        /// selector.
208        ///
209        /// The widget layer always routes it: the activation sends the app's
210        /// quit intent, and asks the application to quit through every
211        /// window's close guard when nothing takes the intent or there is none
212        /// ([`NativeMenuActivation::quits`]). A main-menu key equivalent is
213        /// dispatched by the platform before the responder chain, so an
214        /// app-side ⌘Q never sees the keystroke; and the platform's own item
215        /// (`terminate:`, which asks the quit through `os_quit` when
216        /// `teksilo-app` runs the loop) could not send the app's quit intent.
217        ///
218        /// `None` binds the item to the platform's terminate selector
219        /// (`terminate:` on macOS), for a snapshot built by other means.
220        quit_item: Option<StandardRoutedItem>,
221        /// App menu only: build a **Settings…** item under this id, placed where
222        /// the platform expects it (on macOS: after About), with the key
223        /// equivalent the widget layer resolved.
224        ///
225        /// Unlike Quit there is no `None` fallback that still does something —
226        /// no platform ships a default action for opening an app's settings —
227        /// so `None` simply omits the item. An app that has a settings window
228        /// routes it; one that has none leaves the slot empty rather than
229        /// showing a row that does nothing.
230        settings_item: Option<StandardRoutedItem>,
231    },
232}
233
234/// A complete, resolved description of one window's menu tree.
235#[derive(Debug, Clone, Default)]
236pub struct NativeMenuSnapshot {
237    /// The top-level menus (each typically a [`NativeMenuNode::Submenu`] or a
238    /// [`NativeMenuNode::Standard`]).
239    pub roots: Vec<NativeMenuNode>,
240}
241
242/// A reactive change to a single already-installed native item, applied without
243/// rebuilding the whole menu. Each `Some` field replaces that property.
244#[derive(Debug, Clone, Default)]
245pub struct MenuItemDelta {
246    /// New enabled state.
247    pub enabled: Option<bool>,
248    /// New shown state.
249    pub visible: Option<bool>,
250    /// New check state.
251    pub check: Option<NativeCheck>,
252    /// New display title.
253    pub title: Option<String>,
254    /// New key equivalent (`Some(None)` clears it; `None` leaves it unchanged).
255    pub key_equiv: Option<Option<NativeKeyEquivalent>>,
256}
257
258// ============================================================
259// Activation (kept on the app side of the boundary)
260// ============================================================
261
262/// What to do when a native menu item is chosen. Cloneable (the action is an
263/// `Rc`), so the router can pull a copy out of the handle and run it.
264pub type MenuActionFn = Rc<dyn Fn(&mut EventContext)>;
265
266/// What choosing a native menu item does: an intent, an action, or both.
267///
268/// `teksilo-app` runs `action` where the state it captured lives (the window
269/// whose menu the item is in, or the application tree for an item of the
270/// application menu), then sends `intent` from the focused widget of the
271/// window with the OS focus, which may be another one (a dialog with no menu
272/// of its own), or in the application tree when no window has the focus.
273#[derive(Clone, Default)]
274pub struct NativeMenuActivation {
275    /// Fire this intent by name through the `Action`/`Intent` pipeline, from
276    /// the focused widget of the window with the OS focus. Dispatched after
277    /// `action` has run.
278    pub intent: Option<&'static str>,
279    /// Or run this closure directly (the escape hatch), in the window whose
280    /// menu the item is in, anchored at that window's root, or in the
281    /// application tree for an item of the application menu.
282    pub action: Option<MenuActionFn>,
283    /// The App menu's Quit row: when nothing on the route takes `intent`, or
284    /// there is none, `teksilo-app` asks the application to quit, through
285    /// every window's close guard, instead.
286    pub quits: bool,
287}
288
289impl std::fmt::Debug for NativeMenuActivation {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        f.debug_struct("NativeMenuActivation")
292            .field("intent", &self.intent)
293            .field("action", &self.action.as_ref().map(|_| "<closure>"))
294            .field("quits", &self.quits)
295            .finish()
296    }
297}
298
299// ============================================================
300// Event payload
301// ============================================================
302
303/// Boxed inside `AppEvent::External` when the user picks a native menu item.
304/// `teksilo-app` downcasts to this, runs the item's action in the window whose
305/// menu it is, and sends its intent from the window with the OS focus (see
306/// the [module](self) doc).
307#[derive(Debug, Clone)]
308pub struct NativeMenuEventPayload {
309    /// The window whose menu was active when the item was chosen.
310    pub window_id_owner: TeksiloWindowId,
311    /// The chosen item.
312    pub item_id: MenuItemId,
313}
314
315/// Boxed inside `AppEvent::External` when the user picks an item of the
316/// [application menu](NativeMenuBackend::set_app_menu), the menu shown while
317/// no window's own menu is. `teksilo-app` runs the item's action in the
318/// application tree and sends its intent from the focus of the window with
319/// the OS focus, or in the application tree when none has it.
320#[derive(Debug, Clone)]
321pub struct NativeAppMenuEventPayload {
322    /// The chosen item.
323    pub item_id: MenuItemId,
324}
325
326// ============================================================
327// Backend trait
328// ============================================================
329
330/// Swappable native-menu backend. One instance serves the whole app.
331pub trait NativeMenuBackend {
332    /// Build (or replace) the native menu of `window_id`, a window with a
333    /// menu of its own, from `menu`. For every item the user later chooses,
334    /// the backend MUST post a [`NativeMenuEventPayload`], with
335    /// `window_id_owner == window_id`, through `poster`. If `window_id` is
336    /// the key window (the last one [`activate_window`](Self::activate_window)
337    /// named), or no window has been activated yet, the backend shows this
338    /// menu.
339    fn set_window_menu(
340        &mut self,
341        window_id: TeksiloWindowId,
342        menu: NativeMenuSnapshot,
343        poster: Arc<dyn AppEventPoster>,
344    );
345
346    /// `window_id` became the key window (focus follows window): show the
347    /// bar [`key_window_bar`] picks, its own menu if it set one, else the
348    /// menu of the window it belongs to (see
349    /// [`set_window_owner`](Self::set_window_owner)), else the
350    /// [application menu](Self::set_app_menu), or, when the application
351    /// declared none, the bar shown before.
352    fn activate_window(&mut self, window_id: TeksiloWindowId);
353
354    /// `window_id` belongs to `owner` (a modal dialog to the window it
355    /// blocks), or to no window (`None`). While `window_id` is key and has
356    /// no menu of its own, the bar shows its owner's, as AppKit keeps the
357    /// menu bar for a panel or a sheet. `teksilo-app` calls it as it opens a
358    /// modal window; the relation goes with
359    /// [`clear_window`](Self::clear_window). The default ignores it, for a
360    /// backend whose menus belong to windows alone.
361    #[doc(hidden)]
362    fn set_window_owner(&mut self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
363        let _ = (window_id, owner);
364    }
365
366    /// Forget `window_id`'s menu (the window closed, or no longer has a menu
367    /// of its own). If it was showing, show the
368    /// [application menu](Self::set_app_menu) until another window becomes
369    /// key.
370    fn clear_window(&mut self, window_id: TeksiloWindowId);
371
372    /// Apply a reactive delta to a single already-installed item, in every
373    /// menu that carries it, the application menu included.
374    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta);
375
376    /// Whether this backend shows a menu bar the user can open. `false` for
377    /// one that renders nothing ([`NoopNativeMenuBackend`]): `teksilo-app`
378    /// then keeps no row's availability up to date.
379    #[doc(hidden)]
380    fn shows_bar(&self) -> bool {
381        true
382    }
383
384    /// Set the application menu: the menu shown whenever the key window has
385    /// no menu of its own nor an owner with one, and while no window is
386    /// open. `teksilo-app` sets it from the application tree at startup and
387    /// again whenever it changes; it replaces any earlier one, and the bar
388    /// is chosen again for the key window, as
389    /// [`activate_window`](Self::activate_window) chooses it: an
390    /// application menu declared while a window
391    /// with no menu of its own is key shows at once. `declared`
392    /// says whether the application declared it: `false` for the default
393    /// App menu `teksilo-app` supplies when it declared none, which a key
394    /// window without a menu does not bring up (see [`key_window_bar`]).
395    /// For every item the user later chooses in it, the backend MUST post a
396    /// [`NativeAppMenuEventPayload`] through `poster`. The default does
397    /// nothing, for a backend whose menus belong to windows alone.
398    fn set_app_menu(
399        &mut self,
400        menu: NativeMenuSnapshot,
401        poster: Arc<dyn AppEventPoster>,
402        declared: bool,
403    ) {
404        let _ = (menu, poster, declared);
405    }
406}
407
408/// Forward through a boxed backend so `NativeMenuHandle::new(default_backend())`
409/// type-checks.
410impl NativeMenuBackend for Box<dyn NativeMenuBackend> {
411    fn set_window_menu(
412        &mut self,
413        window_id: TeksiloWindowId,
414        menu: NativeMenuSnapshot,
415        poster: Arc<dyn AppEventPoster>,
416    ) {
417        (**self).set_window_menu(window_id, menu, poster)
418    }
419    fn activate_window(&mut self, window_id: TeksiloWindowId) {
420        (**self).activate_window(window_id)
421    }
422    fn set_window_owner(&mut self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
423        (**self).set_window_owner(window_id, owner)
424    }
425    fn clear_window(&mut self, window_id: TeksiloWindowId) {
426        (**self).clear_window(window_id)
427    }
428    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
429        (**self).update_item(id, delta)
430    }
431    fn shows_bar(&self) -> bool {
432        (**self).shows_bar()
433    }
434    fn set_app_menu(
435        &mut self,
436        menu: NativeMenuSnapshot,
437        poster: Arc<dyn AppEventPoster>,
438        declared: bool,
439    ) {
440        (**self).set_app_menu(menu, poster, declared)
441    }
442}
443
444// ============================================================
445// NativeMenuHandle
446// ============================================================
447
448/// Per-window map from item id to its activation.
449type WindowActivations = HashMap<MenuItemId, NativeMenuActivation>;
450
451/// A row whose only effect is its intent, enabled only while something on
452/// the intent's route takes it. See [`NativeMenuHandle::refresh_availability`].
453struct Gate {
454    intent: &'static str,
455    /// The row's own enabled state (its `enabled` signal).
456    own: bool,
457    /// Whether the route took the intent at the last refresh.
458    handled: bool,
459    /// What the backend was last told.
460    shown: bool,
461}
462
463struct NativeMenuState {
464    backend: RefCell<Box<dyn NativeMenuBackend>>,
465    /// Per-window: item id → what to do when chosen.
466    activations: RefCell<HashMap<TeksiloWindowId, WindowActivations>>,
467    /// The application menu's items → what to do when chosen.
468    app_activations: RefCell<WindowActivations>,
469    /// The rows that grey out when nothing takes their intent, by id.
470    gates: RefCell<HashMap<MenuItemId, Gate>>,
471    /// The intents taken at the last refresh, `None` once a menu installed
472    /// since has gates the refresh has not evaluated: while it holds the
473    /// same answer, a refresh has nothing to do.
474    refreshed: RefCell<Option<Vec<&'static str>>>,
475    /// How many rows the refreshes evaluated, for the tests.
476    #[cfg(test)]
477    evaluations: std::cell::Cell<usize>,
478}
479
480/// Per-app native-menu service. Registered in app-state by
481/// `TeksiloAppBuilder::install_native_menu` (or `.app_state(NativeMenuHandle::new(..))`
482/// for a custom backend). Cloneable; clones share one backend + activation map.
483#[derive(Clone)]
484pub struct NativeMenuHandle {
485    inner: Rc<NativeMenuState>,
486}
487
488impl NativeMenuHandle {
489    /// Build a handle wrapping the given backend.
490    pub fn new<B: NativeMenuBackend + 'static>(backend: B) -> Self {
491        Self {
492            inner: Rc::new(NativeMenuState {
493                backend: RefCell::new(Box::new(backend)),
494                activations: RefCell::new(HashMap::new()),
495                app_activations: RefCell::new(HashMap::new()),
496                gates: RefCell::new(HashMap::new()),
497                refreshed: RefCell::new(None),
498                #[cfg(test)]
499                evaluations: std::cell::Cell::new(0),
500            }),
501        }
502    }
503
504    /// Install `window_id`'s menu, recording the per-item activations so a later
505    /// click can be routed. Replaces any prior menu for that window.
506    pub fn set_window_menu(
507        &self,
508        window_id: TeksiloWindowId,
509        menu: NativeMenuSnapshot,
510        activations: HashMap<MenuItemId, NativeMenuActivation>,
511        poster: Arc<dyn AppEventPoster>,
512    ) {
513        self.gate_rows(&menu, &activations);
514        self.inner
515            .activations
516            .borrow_mut()
517            .insert(window_id, activations);
518        self.inner
519            .backend
520            .borrow_mut()
521            .set_window_menu(window_id, menu, poster);
522    }
523
524    /// `window_id` became the key window: show the bar
525    /// [`key_window_bar`] picks for it.
526    pub fn activate_window(&self, window_id: TeksiloWindowId) {
527        self.inner.backend.borrow_mut().activate_window(window_id);
528    }
529
530    /// `window_id` belongs to `owner` (a modal dialog to the window it
531    /// blocks): see [`NativeMenuBackend::set_window_owner`].
532    #[doc(hidden)]
533    pub fn set_window_owner(&self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
534        self.inner
535            .backend
536            .borrow_mut()
537            .set_window_owner(window_id, owner);
538    }
539
540    /// Forget a window's menu + activations (window closed).
541    pub fn clear_window(&self, window_id: TeksiloWindowId) {
542        self.inner.activations.borrow_mut().remove(&window_id);
543        self.forget_dead_gates();
544        self.inner.backend.borrow_mut().clear_window(window_id);
545    }
546
547    /// Apply a reactive delta to one installed item. For a row that greys
548    /// out when nothing takes its intent, a new `enabled` is the row's own
549    /// state, and what reaches the backend is that state and whether the
550    /// intent was taken at the last
551    /// [`refresh_availability`](Self::refresh_availability).
552    pub fn update_item(&self, id: MenuItemId, mut delta: MenuItemDelta) {
553        if let Some(own) = delta.enabled
554            && let Some(gate) = self.inner.gates.borrow_mut().get_mut(&id)
555        {
556            gate.own = own;
557            gate.shown = own && gate.handled;
558            delta.enabled = Some(gate.shown);
559        }
560        self.inner.backend.borrow_mut().update_item(id, delta);
561    }
562
563    /// Grey out or enable again every installed row whose only effect is
564    /// its intent (an item with an intent, no action, and not the App menu's
565    /// Quit), from `handled`, the sorted intent names something on the route
566    /// takes now, combined with the row's own enabled state. Only a row
567    /// whose state changes reaches the backend, through
568    /// [`NativeMenuBackend::update_item`].
569    ///
570    /// `teksilo-app` calls it once per event-loop turn, with what the route
571    /// a chosen row's intent takes answers (the focused window's
572    /// `WidgetTree::handled_intents_kept_in_modal_from` its live focus, where
573    /// the row's intent starts and which a dialog open in the window keeps,
574    /// else the application tree's), so the
575    /// menu is current whenever the user opens it: focus, the key window and
576    /// the actions only change while the loop runs. With the same `handled`
577    /// as the last call and no menu installed since, nothing can have
578    /// changed (a row's own state reaches the backend through
579    /// [`update_item`](Self::update_item)), and no row is evaluated.
580    #[doc(hidden)]
581    pub fn refresh_availability(&self, handled: &[&'static str]) {
582        if self
583            .inner
584            .refreshed
585            .borrow()
586            .as_deref()
587            .is_some_and(|last| last == handled)
588        {
589            return;
590        }
591        *self.inner.refreshed.borrow_mut() = Some(handled.to_vec());
592        let mut changed = Vec::new();
593        for (id, gate) in self.inner.gates.borrow_mut().iter_mut() {
594            #[cfg(test)]
595            self.inner.evaluations.set(self.inner.evaluations.get() + 1);
596            gate.handled = handled.binary_search(&gate.intent).is_ok();
597            let shown = gate.own && gate.handled;
598            if shown != gate.shown {
599                gate.shown = shown;
600                changed.push((*id, shown));
601            }
602        }
603        let mut backend = self.inner.backend.borrow_mut();
604        for (id, enabled) in changed {
605            backend.update_item(
606                id,
607                MenuItemDelta {
608                    enabled: Some(enabled),
609                    ..Default::default()
610                },
611            );
612        }
613    }
614
615    /// Whether the backend shows a menu bar at all
616    /// ([`NativeMenuBackend::shows_bar`]): `teksilo-app` refreshes the rows'
617    /// availability only then.
618    #[doc(hidden)]
619    pub fn shows_bar(&self) -> bool {
620        self.inner.backend.borrow().shows_bar()
621    }
622
623    /// How many rows the availability refreshes evaluated.
624    #[cfg(test)]
625    fn evaluations(&self) -> usize {
626        self.inner.evaluations.get()
627    }
628
629    /// The enabled state the backend was last given for a row that greys
630    /// out when nothing takes its intent; `None` for any other item.
631    #[doc(hidden)]
632    pub fn gated_enabled(&self, id: MenuItemId) -> Option<bool> {
633        self.inner.gates.borrow().get(&id).map(|gate| gate.shown)
634    }
635
636    /// Record the rows of `menu` that grey out when nothing takes their
637    /// intent, enabled as `menu` installs them until the next refresh.
638    fn gate_rows(&self, menu: &NativeMenuSnapshot, activations: &WindowActivations) {
639        fn own_enabled(nodes: &[NativeMenuNode], id: MenuItemId) -> Option<bool> {
640            nodes.iter().find_map(|node| match node {
641                NativeMenuNode::Item {
642                    id: item, enabled, ..
643                } if *item == id => Some(*enabled),
644                NativeMenuNode::Submenu { children, .. } => own_enabled(children, id),
645                _ => None,
646            })
647        }
648        let mut gates = self.inner.gates.borrow_mut();
649        // The new rows start enabled: the next refresh evaluates them,
650        // whatever it is told.
651        *self.inner.refreshed.borrow_mut() = None;
652        for (id, activation) in activations {
653            let (Some(intent), None, false) =
654                (activation.intent, &activation.action, activation.quits)
655            else {
656                continue;
657            };
658            let own = own_enabled(&menu.roots, *id).unwrap_or(true);
659            gates.insert(
660                *id,
661                Gate {
662                    intent,
663                    own,
664                    handled: true,
665                    shown: own,
666                },
667            );
668        }
669    }
670
671    /// Forget the gates of rows no installed menu holds any more.
672    fn forget_dead_gates(&self) {
673        let windows = self.inner.activations.borrow();
674        let app = self.inner.app_activations.borrow();
675        self.inner.gates.borrow_mut().retain(|id, _| {
676            app.contains_key(id) || windows.values().any(|menu| menu.contains_key(id))
677        });
678    }
679
680    /// Set the application menu, shown whenever the key window has no menu
681    /// of its own and while no window is open (see
682    /// [`NativeMenuBackend::set_app_menu`]), recording its activations.
683    /// Replaces any earlier one. `teksilo-app` calls it from the application
684    /// tree's declaration; a window's menu never sets it.
685    pub fn set_app_menu(
686        &self,
687        menu: NativeMenuSnapshot,
688        activations: HashMap<MenuItemId, NativeMenuActivation>,
689        poster: Arc<dyn AppEventPoster>,
690        declared: bool,
691    ) {
692        self.gate_rows(&menu, &activations);
693        *self.inner.app_activations.borrow_mut() = activations;
694        self.forget_dead_gates();
695        self.inner
696            .backend
697            .borrow_mut()
698            .set_app_menu(menu, poster, declared);
699    }
700
701    /// The activation for an item chosen in the application menu.
702    pub fn app_activation(&self, id: MenuItemId) -> Option<NativeMenuActivation> {
703        self.inner.app_activations.borrow().get(&id).cloned()
704    }
705
706    /// Look up (and clone) the activation for a chosen item, for the router.
707    pub fn activation(
708        &self,
709        window_id: TeksiloWindowId,
710        id: MenuItemId,
711    ) -> Option<NativeMenuActivation> {
712        self.inner
713            .activations
714            .borrow()
715            .get(&window_id)
716            .and_then(|m| m.get(&id).cloned())
717    }
718}
719
720impl std::fmt::Debug for NativeMenuHandle {
721    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
722        f.debug_struct("NativeMenuHandle")
723            .field("windows", &self.inner.activations.borrow().len())
724            .finish_non_exhaustive()
725    }
726}
727
728// ============================================================
729// Which bar a key window shows (pure, so every OS tests it)
730// ============================================================
731
732/// The bar to show while `key` is the key window: `Some(window)` for that
733/// window's own menu, `None` for the application menu.
734///
735/// The key window's own menu if it has one (`has_menu`); else the menu of
736/// the window it belongs to, following `owner` (a modal dialog's owner, then
737/// that window's owner); else the application menu when the application
738/// declared one (`app_declared`), or `shown`, the bar shown before, when it
739/// did not: AppKit keeps `mainMenu` up while a panel or a sheet is key, so
740/// an app that declared no application menu keeps its window's File and
741/// Edit while a dialog or a secondary window is key.
742#[doc(hidden)]
743pub fn key_window_bar(
744    key: TeksiloWindowId,
745    has_menu: impl Fn(TeksiloWindowId) -> bool,
746    owner: impl Fn(TeksiloWindowId) -> Option<TeksiloWindowId>,
747    app_declared: bool,
748    shown: Option<TeksiloWindowId>,
749) -> Option<TeksiloWindowId> {
750    let mut window = key;
751    let mut visited = vec![window];
752    loop {
753        if has_menu(window) {
754            return Some(window);
755        }
756        match owner(window) {
757            Some(next) if !visited.contains(&next) => {
758                visited.push(next);
759                window = next;
760            }
761            _ => break,
762        }
763    }
764    if app_declared { None } else { shown }
765}
766
767/// What a backend shows once the application menu has been set or replaced
768/// ([`NativeMenuBackend::set_app_menu`]): `Some(bar)` to show `bar` (as
769/// [`key_window_bar`] names it, `None` for the application menu), `None`
770/// to leave the bar up as it is.
771///
772/// `key_bar` is what [`key_window_bar`] picks for the key window now, `None`
773/// before any window was activated; `shown` is the bar up. The key window's
774/// pick is shown when it differs from the bar up (an application menu
775/// declared while a window with no menu of its own is key replaces the
776/// window bar kept for it), and whenever it is the application menu, which
777/// is a new menu now. With no key window, only an application menu already
778/// up is replaced.
779pub(crate) fn bar_after_app_menu(
780    key_bar: Option<Option<TeksiloWindowId>>,
781    shown: Option<TeksiloWindowId>,
782) -> Option<Option<TeksiloWindowId>> {
783    match key_bar {
784        Some(bar) => (bar.is_none() || bar != shown).then_some(bar),
785        None => shown.is_none().then_some(None),
786    }
787}
788
789/// What a backend shows once it has forgotten `cleared`'s menu
790/// ([`NativeMenuBackend::clear_window`]): `Some(bar)` to show `bar` (as
791/// [`key_window_bar`] names it, `None` for the application menu), `None` to
792/// leave the bar up as it is.
793///
794/// Only a bar that was `cleared`'s own (`shown`) is replaced, by the key
795/// window's pick (`key`, with `has_menu`, `owner` and `app_declared` as for
796/// [`key_window_bar`]) when another window is key, else by the application
797/// menu. The pick is made as if `cleared` were already gone and no bar were
798/// up, so it is never `cleared`'s menu, declared application menu or not,
799/// even when the key window is `cleared` itself or a dialog it owned.
800pub(crate) fn bar_after_clear(
801    cleared: TeksiloWindowId,
802    key: Option<TeksiloWindowId>,
803    shown: Option<TeksiloWindowId>,
804    has_menu: impl Fn(TeksiloWindowId) -> bool,
805    owner: impl Fn(TeksiloWindowId) -> Option<TeksiloWindowId>,
806    app_declared: bool,
807) -> Option<Option<TeksiloWindowId>> {
808    if shown != Some(cleared) {
809        return None;
810    }
811    Some(key.filter(|key| *key != cleared).and_then(|key| {
812        key_window_bar(
813            key,
814            |window| window != cleared && has_menu(window),
815            |window| owner(window).filter(|owner| *owner != cleared),
816            app_declared,
817            None,
818        )
819    }))
820}
821
822// ============================================================
823// NoopNativeMenuBackend
824// ============================================================
825
826/// Backend that renders nothing. Used on platforms without a native-menu
827/// implementation (everything except macOS today) so cross-platform code that
828/// installs a native menu compiles and runs — the in-window `MenuBar` remains
829/// the menu surface there.
830#[derive(Default)]
831pub struct NoopNativeMenuBackend;
832
833impl NoopNativeMenuBackend {
834    /// Build the no-op backend.
835    pub fn new() -> Self {
836        Self
837    }
838}
839
840impl NativeMenuBackend for NoopNativeMenuBackend {
841    fn set_window_menu(
842        &mut self,
843        _window_id: TeksiloWindowId,
844        _menu: NativeMenuSnapshot,
845        _poster: Arc<dyn AppEventPoster>,
846    ) {
847    }
848    fn activate_window(&mut self, _window_id: TeksiloWindowId) {}
849    fn clear_window(&mut self, _window_id: TeksiloWindowId) {}
850    fn update_item(&mut self, _id: MenuItemId, _delta: MenuItemDelta) {}
851    fn shows_bar(&self) -> bool {
852        false
853    }
854}
855
856// ============================================================
857// The macOS bar's layout (pure, so every OS tests it)
858// ============================================================
859
860/// The top-level menus of a snapshot as the macOS menu bar shows them.
861#[cfg(any(target_os = "macos", test))]
862#[derive(Debug)]
863pub(crate) struct MacBarPlan<'a> {
864    /// The menus in bar order: the first App-role menu, then every other
865    /// submenu and standard menu in the order the snapshot declares them.
866    pub(crate) menus: Vec<&'a NativeMenuNode>,
867    /// The index in `menus` of the menu AppKit keeps the window list in
868    /// (`NSApp.windowsMenu`): the first Window-role menu. `None`: the bar
869    /// declares none, and the backend adds a hidden one, so the Dock icon's
870    /// menu still lists the windows.
871    pub(crate) windows_menu: Option<usize>,
872    /// The index in `menus` of the menu registered as `NSApp.helpMenu`: the
873    /// first Help-role menu.
874    pub(crate) help_menu: Option<usize>,
875}
876
877/// Lay out `snapshot` for the macOS menu bar.
878///
879/// AppKit shows the bar's first menu as the bold application menu, under
880/// the application's name, whatever that menu holds. So the App-role menu
881/// goes first wherever the model declared it: declared after File, File's
882/// rows would sit under the application's name and About, Hide and Quit in
883/// a second, ordinary menu. A later App-role menu is left out, since a bar
884/// has one application menu. A leaf or separator at the top level is left
885/// out too: the bar holds menus only.
886#[cfg(any(target_os = "macos", test))]
887pub(crate) fn mac_bar_plan(snapshot: &NativeMenuSnapshot) -> MacBarPlan<'_> {
888    let is_app = |node: &NativeMenuNode| {
889        matches!(
890            node,
891            NativeMenuNode::Standard {
892                role: StandardMenuRole::App,
893                ..
894            }
895        )
896    };
897    let app = snapshot.roots.iter().find(|node| is_app(node));
898    let rest = snapshot.roots.iter().filter(|node| {
899        !is_app(node)
900            && matches!(
901                node,
902                NativeMenuNode::Submenu { .. } | NativeMenuNode::Standard { .. }
903            )
904    });
905    let menus: Vec<&NativeMenuNode> = app.into_iter().chain(rest).collect();
906    let first = |wanted: StandardMenuRole| {
907        menus.iter().position(
908            |node| matches!(node, NativeMenuNode::Standard { role, .. } if *role == wanted),
909        )
910    };
911    MacBarPlan {
912        windows_menu: first(StandardMenuRole::Window),
913        help_menu: first(StandardMenuRole::Help),
914        menus,
915    }
916}
917
918/// Whether the macOS backend evaluates a window again for the Window menu
919/// it registers, by flipping its `excludedFromWindowsMenu`: a window that
920/// is not excluded (as a dialog is) and that AppKit lists, on screen
921/// (`visible`) or in the Dock (`miniaturized`). AppKit adds a row for a
922/// window it evaluates with the flag off, so flipping a hidden window's
923/// would list a window the app hid.
924#[cfg(any(target_os = "macos", test))]
925pub(crate) fn mac_relists_window(excluded: bool, visible: bool, miniaturized: bool) -> bool {
926    !excluded && (visible || miniaturized)
927}
928
929/// A row of the macOS App menu. See [`mac_app_menu_rows`].
930#[cfg(any(target_os = "macos", test))]
931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
932pub(crate) enum MacAppMenuRow {
933    /// About, AppKit's `orderFrontStandardAboutPanel:`.
934    About,
935    /// Settings, routed to the app.
936    Settings,
937    /// The Services submenu, registered as `NSApp.servicesMenu`.
938    Services,
939    /// Hide <App>, `hide:`, ⌘H.
940    Hide,
941    /// Hide Others, `hideOtherApplications:`, ⌥⌘H.
942    HideOthers,
943    /// Show All, `unhideAllApplications:`.
944    ShowAll,
945    /// Quit, routed to the app.
946    Quit,
947    /// A separator.
948    Separator,
949}
950
951/// The rows of the macOS App menu, in the order AppKit's own application
952/// menu has them: About; Settings in a group of its own when the app routes
953/// it; Services; Hide, Hide Others and Show All; Quit.
954#[cfg(any(target_os = "macos", test))]
955pub(crate) fn mac_app_menu_rows(settings: bool) -> Vec<MacAppMenuRow> {
956    use MacAppMenuRow::*;
957    let mut rows = vec![About, Separator];
958    if settings {
959        rows.extend([Settings, Separator]);
960    }
961    rows.extend([
962        Services, Separator, Hide, HideOthers, ShowAll, Separator, Quit,
963    ]);
964    rows
965}
966
967// ============================================================
968// Default backend factory
969// ============================================================
970
971/// The default native-menu backend for the current target: macOS gets the real
972/// `NSMenu` backend, every other target gets [`NoopNativeMenuBackend`].
973pub fn default_backend() -> Box<dyn NativeMenuBackend> {
974    #[cfg(target_os = "macos")]
975    {
976        Box::new(macos::MacOsNativeMenuBackend::new())
977    }
978    #[cfg(not(target_os = "macos"))]
979    {
980        Box::new(NoopNativeMenuBackend::new())
981    }
982}
983
984// ============================================================
985// MemoryNativeMenuBackend (test backend)
986// ============================================================
987
988/// Recording backend for headless tests. Captures the snapshot set per window,
989/// which window is active, item deltas, and cleared windows. Cloneable; clones
990/// share the recording so a test can keep a clone after handing one to
991/// [`NativeMenuHandle::new`].
992#[derive(Clone, Default)]
993pub struct MemoryNativeMenuBackend {
994    inner: Rc<RefCell<MemoryRecording>>,
995}
996
997#[derive(Default)]
998struct MemoryRecording {
999    menus: HashMap<TeksiloWindowId, NativeMenuSnapshot>,
1000    app_menu: Option<NativeMenuSnapshot>,
1001    /// The last window `activate_window` named.
1002    key: Option<TeksiloWindowId>,
1003    /// The window whose menu shows; `None` while the application menu does.
1004    active: Option<TeksiloWindowId>,
1005    /// The window each window belongs to (`set_window_owner`).
1006    owners: HashMap<TeksiloWindowId, TeksiloWindowId>,
1007    /// Whether the application menu set last was declared by the app.
1008    app_declared: bool,
1009    deltas: Vec<(MenuItemId, MenuItemDelta)>,
1010    cleared: Vec<TeksiloWindowId>,
1011}
1012
1013impl MemoryRecording {
1014    /// The bar shown while `key` is key, by [`key_window_bar`].
1015    fn bar_for(&self, key: TeksiloWindowId) -> Option<TeksiloWindowId> {
1016        key_window_bar(
1017            key,
1018            |window| self.menus.contains_key(&window),
1019            |window| self.owners.get(&window).copied(),
1020            self.app_declared,
1021            self.active,
1022        )
1023    }
1024}
1025
1026impl MemoryNativeMenuBackend {
1027    /// Build a new empty recording backend.
1028    pub fn new() -> Self {
1029        Self::default()
1030    }
1031
1032    /// The snapshot currently set for `window_id`, if any.
1033    pub fn menu_for(&self, window_id: TeksiloWindowId) -> Option<NativeMenuSnapshot> {
1034        self.inner.borrow().menus.get(&window_id).cloned()
1035    }
1036
1037    /// The window whose own menu shows, as on macOS: the one
1038    /// [`key_window_bar`] picks for the key window (the last
1039    /// `activate_window`), or a window that set one before any was
1040    /// activated. `None` while the application menu shows.
1041    pub fn active_window(&self) -> Option<TeksiloWindowId> {
1042        self.inner.borrow().active
1043    }
1044
1045    /// Whether the application menu is the one showing: no window's own
1046    /// menu is ([`active_window`](Self::active_window) is `None`).
1047    pub fn shows_app_menu(&self) -> bool {
1048        self.inner.borrow().active.is_none()
1049    }
1050
1051    /// All item deltas applied so far, in order.
1052    pub fn deltas(&self) -> Vec<(MenuItemId, MenuItemDelta)> {
1053        self.inner.borrow().deltas.clone()
1054    }
1055
1056    /// Windows whose menus were cleared, in order.
1057    pub fn cleared(&self) -> Vec<TeksiloWindowId> {
1058        self.inner.borrow().cleared.clone()
1059    }
1060
1061    /// The application menu last set, if any.
1062    pub fn app_menu(&self) -> Option<NativeMenuSnapshot> {
1063        self.inner.borrow().app_menu.clone()
1064    }
1065}
1066
1067impl NativeMenuBackend for MemoryNativeMenuBackend {
1068    fn set_window_menu(
1069        &mut self,
1070        window_id: TeksiloWindowId,
1071        menu: NativeMenuSnapshot,
1072        _poster: Arc<dyn AppEventPoster>,
1073    ) {
1074        let mut rec = self.inner.borrow_mut();
1075        rec.menus.insert(window_id, menu);
1076        // As the macOS backend: the bar the key window shows, and a
1077        // window's menu set before any window was activated.
1078        rec.active = match rec.key {
1079            Some(key) => rec.bar_for(key),
1080            None => Some(window_id),
1081        };
1082    }
1083    fn activate_window(&mut self, window_id: TeksiloWindowId) {
1084        let mut rec = self.inner.borrow_mut();
1085        rec.key = Some(window_id);
1086        rec.active = rec.bar_for(window_id);
1087    }
1088    fn set_window_owner(&mut self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
1089        let mut rec = self.inner.borrow_mut();
1090        match owner {
1091            Some(owner) => rec.owners.insert(window_id, owner),
1092            None => rec.owners.remove(&window_id),
1093        };
1094    }
1095    fn clear_window(&mut self, window_id: TeksiloWindowId) {
1096        let mut rec = self.inner.borrow_mut();
1097        rec.menus.remove(&window_id);
1098        rec.owners.remove(&window_id);
1099        rec.cleared.push(window_id);
1100        // As the macOS backend: never the dead window's menu.
1101        let bar = bar_after_clear(
1102            window_id,
1103            rec.key,
1104            rec.active,
1105            |window| rec.menus.contains_key(&window),
1106            |window| rec.owners.get(&window).copied(),
1107            rec.app_declared,
1108        );
1109        if let Some(bar) = bar {
1110            rec.active = bar;
1111        }
1112    }
1113    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
1114        self.inner.borrow_mut().deltas.push((id, delta));
1115    }
1116    fn set_app_menu(
1117        &mut self,
1118        menu: NativeMenuSnapshot,
1119        _poster: Arc<dyn AppEventPoster>,
1120        declared: bool,
1121    ) {
1122        let mut rec = self.inner.borrow_mut();
1123        rec.app_menu = Some(menu);
1124        rec.app_declared = declared;
1125        // As the macOS backend: the key window's bar again, which the new
1126        // application menu may be.
1127        let key_bar = rec.key.map(|key| rec.bar_for(key));
1128        if let Some(bar) = bar_after_app_menu(key_bar, rec.active) {
1129            rec.active = bar;
1130        }
1131    }
1132}
1133
1134// ============================================================
1135// Tests
1136// ============================================================
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141    use std::sync::Mutex;
1142    use teksilo_core::SubscriptionId;
1143
1144    struct NullPoster;
1145    impl AppEventPoster for NullPoster {
1146        fn post_subscription_event(
1147            &self,
1148            _sub_id: SubscriptionId,
1149            _event: Box<dyn std::any::Any + Send>,
1150        ) {
1151        }
1152        fn post_external(&self, _payload: Box<dyn std::any::Any + Send>) {}
1153    }
1154
1155    fn poster() -> Arc<dyn AppEventPoster> {
1156        Arc::new(NullPoster)
1157    }
1158
1159    fn win(n: u64) -> TeksiloWindowId {
1160        TeksiloWindowId::new(n)
1161    }
1162
1163    fn sample_snapshot(id: MenuItemId) -> NativeMenuSnapshot {
1164        NativeMenuSnapshot {
1165            roots: vec![NativeMenuNode::Submenu {
1166                title: "File".into(),
1167                children: vec![NativeMenuNode::Item {
1168                    id,
1169                    title: "New".into(),
1170                    key_equiv: None,
1171                    enabled: true,
1172                    visible: true,
1173                    check: NativeCheck::None,
1174                }],
1175            }],
1176        }
1177    }
1178
1179    #[test]
1180    fn set_menu_records_snapshot_and_activations() {
1181        let backend = MemoryNativeMenuBackend::new();
1182        let handle = NativeMenuHandle::new(backend.clone());
1183        let id = MenuItemId::next();
1184
1185        let fired = Arc::new(Mutex::new(false));
1186        let fired2 = fired.clone();
1187        let mut acts = HashMap::new();
1188        acts.insert(
1189            id,
1190            NativeMenuActivation {
1191                intent: Some("app.new"),
1192                action: Some(Rc::new(move |_ctx: &mut EventContext| {
1193                    *fired2.lock().unwrap() = true;
1194                })),
1195                quits: false,
1196            },
1197        );
1198
1199        handle.set_window_menu(win(1), sample_snapshot(id), acts, poster());
1200
1201        assert!(backend.menu_for(win(1)).is_some());
1202        assert_eq!(backend.active_window(), Some(win(1)));
1203        let act = handle.activation(win(1), id).expect("activation recorded");
1204        assert_eq!(act.intent, Some("app.new"));
1205        assert!(act.action.is_some());
1206    }
1207
1208    #[test]
1209    fn activate_and_clear_window() {
1210        let backend = MemoryNativeMenuBackend::new();
1211        let handle = NativeMenuHandle::new(backend.clone());
1212        let id = MenuItemId::next();
1213        handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
1214        handle.set_window_menu(
1215            win(2),
1216            sample_snapshot(MenuItemId::next()),
1217            HashMap::new(),
1218            poster(),
1219        );
1220
1221        handle.activate_window(win(2));
1222        assert_eq!(backend.active_window(), Some(win(2)));
1223
1224        handle.clear_window(win(2));
1225        assert_eq!(backend.cleared(), vec![win(2)]);
1226        assert!(handle.activation(win(2), id).is_none());
1227        assert!(backend.menu_for(win(2)).is_none());
1228    }
1229
1230    /// A window with no menu of its own, nor an owner with one, shows the
1231    /// application menu the app declared when it becomes key, as the macOS
1232    /// backend does: the previous window's menu used to stay up, its items
1233    /// acting on a window the user had left.
1234    #[test]
1235    fn activating_a_window_without_a_menu_shows_the_application_menu() {
1236        let backend = MemoryNativeMenuBackend::new();
1237        let handle = NativeMenuHandle::new(backend.clone());
1238        handle.set_app_menu(
1239            sample_snapshot(MenuItemId::next()),
1240            HashMap::new(),
1241            poster(),
1242            true,
1243        );
1244        handle.set_window_menu(
1245            win(1),
1246            sample_snapshot(MenuItemId::next()),
1247            HashMap::new(),
1248            poster(),
1249        );
1250        handle.activate_window(win(1));
1251        assert_eq!(backend.active_window(), Some(win(1)));
1252        handle.activate_window(win(2));
1253        assert_eq!(backend.active_window(), None);
1254        assert!(backend.shows_app_menu());
1255        handle.activate_window(win(1));
1256        assert_eq!(backend.active_window(), Some(win(1)), "and back");
1257    }
1258
1259    /// A window that installs its menu while another is key does not take
1260    /// the bar; the key window that closes leaves the application menu.
1261    #[test]
1262    fn the_key_window_decides_and_its_close_leaves_the_application_menu() {
1263        let backend = MemoryNativeMenuBackend::new();
1264        let handle = NativeMenuHandle::new(backend.clone());
1265        handle.activate_window(win(1));
1266        handle.set_window_menu(
1267            win(2),
1268            sample_snapshot(MenuItemId::next()),
1269            HashMap::new(),
1270            poster(),
1271        );
1272        assert!(backend.shows_app_menu(), "window 1 is key and has none");
1273        handle.set_window_menu(
1274            win(1),
1275            sample_snapshot(MenuItemId::next()),
1276            HashMap::new(),
1277            poster(),
1278        );
1279        assert_eq!(backend.active_window(), Some(win(1)));
1280        handle.clear_window(win(1));
1281        assert!(backend.shows_app_menu());
1282    }
1283
1284    /// A row whose only effect is its intent greys out while nothing takes
1285    /// the intent, its own enabled state still counting; a row with an
1286    /// action, and the App menu's Quit, never do.
1287    #[test]
1288    fn an_intent_only_row_greys_out_while_nothing_takes_its_intent() {
1289        let backend = MemoryNativeMenuBackend::new();
1290        let handle = NativeMenuHandle::new(backend.clone());
1291        let (plain, with_action, quit) =
1292            (MenuItemId::next(), MenuItemId::next(), MenuItemId::next());
1293        let activation = |action: bool, quits: bool| NativeMenuActivation {
1294            intent: Some("app.print"),
1295            action: action.then(|| -> MenuActionFn { Rc::new(|_| {}) }),
1296            quits,
1297        };
1298        handle.set_window_menu(
1299            win(1),
1300            sample_snapshot(plain),
1301            [
1302                (plain, activation(false, false)),
1303                (with_action, activation(true, false)),
1304                (quit, activation(false, true)),
1305            ]
1306            .into_iter()
1307            .collect(),
1308            poster(),
1309        );
1310        let enabled_deltas = |backend: &MemoryNativeMenuBackend| {
1311            backend
1312                .deltas()
1313                .into_iter()
1314                .filter_map(|(id, delta)| delta.enabled.map(|enabled| (id, enabled)))
1315                .collect::<Vec<_>>()
1316        };
1317        handle.refresh_availability(&["app.print"]);
1318        assert!(enabled_deltas(&backend).is_empty(), "nothing changed");
1319        handle.refresh_availability(&[]);
1320        assert_eq!(enabled_deltas(&backend), [(plain, false)]);
1321        assert_eq!(handle.gated_enabled(plain), Some(false));
1322        assert_eq!(handle.gated_enabled(with_action), None);
1323        assert_eq!(handle.gated_enabled(quit), None);
1324
1325        // Its own state turning true does not enable it while nothing takes
1326        // the intent; the intent taken again does.
1327        handle.update_item(
1328            plain,
1329            MenuItemDelta {
1330                enabled: Some(true),
1331                ..Default::default()
1332            },
1333        );
1334        assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, false)));
1335        handle.refresh_availability(&["app.print"]);
1336        assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, true)));
1337
1338        // Its own state turning false greys it out whatever the route says.
1339        handle.update_item(
1340            plain,
1341            MenuItemDelta {
1342                enabled: Some(false),
1343                ..Default::default()
1344            },
1345        );
1346        handle.refresh_availability(&["app.print"]);
1347        assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, false)));
1348
1349        handle.clear_window(win(1));
1350        assert_eq!(handle.gated_enabled(plain), None, "forgotten with its menu");
1351    }
1352
1353    fn standard(role: StandardMenuRole, title: &str) -> NativeMenuNode {
1354        NativeMenuNode::Standard {
1355            role,
1356            labels: StandardLabels {
1357                title: title.into(),
1358                ..Default::default()
1359            },
1360            quit_item: None,
1361            settings_item: None,
1362        }
1363    }
1364
1365    fn bar_titles(plan: &MacBarPlan<'_>) -> Vec<String> {
1366        plan.menus
1367            .iter()
1368            .map(|node| match node {
1369                NativeMenuNode::Submenu { title, .. } => title.clone(),
1370                NativeMenuNode::Standard { labels, .. } => labels.title.clone(),
1371                other => panic!("the bar holds menus only, got {other:?}"),
1372            })
1373            .collect()
1374    }
1375
1376    /// AppKit shows the bar's first menu as the application menu, so the
1377    /// App-role menu goes first wherever the model declares it. Declared
1378    /// after File, File's rows sat under the application's name and About,
1379    /// Hide and Quit in a second, ordinary menu (macOS findings, item 2).
1380    #[test]
1381    fn the_app_menu_is_first_in_the_mac_bar_wherever_it_is_declared() {
1382        let mut file = sample_snapshot(MenuItemId::next()).roots;
1383        let snapshot = NativeMenuSnapshot {
1384            roots: vec![
1385                file.remove(0),
1386                NativeMenuNode::Separator,
1387                standard(StandardMenuRole::Window, "Window"),
1388                standard(StandardMenuRole::App, "Probe"),
1389                standard(StandardMenuRole::Help, "Help"),
1390                standard(StandardMenuRole::App, "Second"),
1391            ],
1392        };
1393        assert_eq!(
1394            bar_titles(&mac_bar_plan(&snapshot)),
1395            ["Probe", "File", "Window", "Help"],
1396            "App first, the rest in declared order, one App menu, no top-level separator"
1397        );
1398
1399        let declared_first = NativeMenuSnapshot {
1400            roots: vec![
1401                standard(StandardMenuRole::App, "Probe"),
1402                sample_snapshot(MenuItemId::next()).roots.remove(0),
1403            ],
1404        };
1405        assert_eq!(
1406            bar_titles(&mac_bar_plan(&declared_first)),
1407            ["Probe", "File"]
1408        );
1409    }
1410
1411    /// A window registered again in the Window menu is one AppKit lists:
1412    /// not excluded, and on screen or in the Dock. A hidden window (ordered
1413    /// out, not miniaturized) is left alone: flipping its flag lists it,
1414    /// since AppKit keeps a row for every window it evaluates with the flag
1415    /// off, and a window the app hid would show in the Window and Dock
1416    /// menus.
1417    #[test]
1418    fn the_mac_window_menu_relists_only_shown_or_miniaturized_windows() {
1419        assert!(mac_relists_window(false, true, false), "on screen");
1420        assert!(mac_relists_window(false, false, true), "in the Dock");
1421        assert!(!mac_relists_window(false, false, false), "hidden");
1422        assert!(
1423            !mac_relists_window(true, true, false),
1424            "excluded (a dialog)"
1425        );
1426    }
1427
1428    /// The App menu's rows in AppKit's order: About; Settings in a group of
1429    /// its own; Services; Hide, Hide Others and Show All; Quit.
1430    #[test]
1431    fn the_mac_app_menu_has_appkits_rows_in_appkits_order() {
1432        use MacAppMenuRow::*;
1433        assert_eq!(
1434            mac_app_menu_rows(true),
1435            [
1436                About, Separator, Settings, Separator, Services, Separator, Hide, HideOthers,
1437                ShowAll, Separator, Quit,
1438            ]
1439        );
1440        assert_eq!(
1441            mac_app_menu_rows(false),
1442            [
1443                About, Separator, Services, Separator, Hide, HideOthers, ShowAll, Separator, Quit,
1444            ],
1445            "no Settings row, and no empty group for it"
1446        );
1447    }
1448
1449    /// AppKit keeps the window list in the menu registered as `windowsMenu`,
1450    /// and the Dock icon's menu lists the windows from it: the bar's first
1451    /// Window-role menu when it declares one, else a hidden one the backend
1452    /// adds (Qt's `insertWindowMenu`), so the Dock still lists the windows
1453    /// (macOS findings, item 3).
1454    #[test]
1455    fn the_mac_bar_keeps_the_window_list_in_its_window_menu_or_a_hidden_one() {
1456        let snapshot = NativeMenuSnapshot {
1457            roots: vec![
1458                sample_snapshot(MenuItemId::next()).roots.remove(0),
1459                standard(StandardMenuRole::Window, "Window"),
1460                standard(StandardMenuRole::App, "Probe"),
1461                standard(StandardMenuRole::Window, "Second window menu"),
1462                standard(StandardMenuRole::Help, "Help"),
1463            ],
1464        };
1465        let plan = mac_bar_plan(&snapshot);
1466        assert_eq!(
1467            plan.windows_menu,
1468            Some(2),
1469            "the first Window menu, in bar order"
1470        );
1471        assert_eq!(plan.help_menu, Some(4));
1472
1473        let no_window_menu = NativeMenuSnapshot {
1474            roots: vec![
1475                sample_snapshot(MenuItemId::next()).roots.remove(0),
1476                standard(StandardMenuRole::App, "Probe"),
1477            ],
1478        };
1479        let plan = mac_bar_plan(&no_window_menu);
1480        assert_eq!(plan.windows_menu, None, "a hidden one is added");
1481        assert_eq!(plan.help_menu, None);
1482    }
1483
1484    /// The bar a key window shows: its own menu; else the menu of the window
1485    /// it belongs to (a modal dialog's owner, up the chain of owners); else
1486    /// the application menu, or, when the application declared none, the bar
1487    /// shown before, as AppKit keeps `mainMenu` up for its panels and
1488    /// sheets. A dialog used to show the application menu, so an app with
1489    /// no application menu lost File and Edit whenever one was key.
1490    #[test]
1491    fn a_key_window_without_a_menu_shows_its_owners_or_the_applications() {
1492        let menus = [win(1)];
1493        let has_menu = |w: TeksiloWindowId| menus.contains(&w);
1494        // 2 is a dialog of 1, 3 a dialog of 2; 4 is a secondary window.
1495        let owner = |w: TeksiloWindowId| match w.raw() {
1496            2 => Some(win(1)),
1497            3 => Some(win(2)),
1498            _ => None,
1499        };
1500        let bar = |key, declared, shown| key_window_bar(key, has_menu, owner, declared, shown);
1501        assert_eq!(bar(win(1), true, None), Some(win(1)), "its own");
1502        assert_eq!(bar(win(2), true, None), Some(win(1)), "its owner's");
1503        assert_eq!(bar(win(3), true, None), Some(win(1)), "up the chain");
1504        assert_eq!(bar(win(4), true, Some(win(1))), None, "the application's");
1505        assert_eq!(
1506            bar(win(4), false, Some(win(1))),
1507            Some(win(1)),
1508            "none declared: the bar shown before stays"
1509        );
1510        assert_eq!(bar(win(4), false, None), None);
1511
1512        // An owner relation that loops ends the walk rather than the loop.
1513        let looping = |w: TeksiloWindowId| Some(if w == win(5) { win(6) } else { win(5) });
1514        assert_eq!(key_window_bar(win(5), has_menu, looping, true, None), None);
1515    }
1516
1517    /// `MemoryNativeMenuBackend` follows the same rule as the macOS one.
1518    #[test]
1519    fn the_memory_backend_shows_a_dialogs_owners_menu() {
1520        let backend = MemoryNativeMenuBackend::new();
1521        let handle = NativeMenuHandle::new(backend.clone());
1522        handle.set_app_menu(
1523            sample_snapshot(MenuItemId::next()),
1524            HashMap::new(),
1525            poster(),
1526            false,
1527        );
1528        handle.set_window_menu(
1529            win(1),
1530            sample_snapshot(MenuItemId::next()),
1531            HashMap::new(),
1532            poster(),
1533        );
1534        handle.set_window_owner(win(2), Some(win(1)));
1535        handle.activate_window(win(1));
1536        handle.activate_window(win(2));
1537        assert_eq!(
1538            backend.active_window(),
1539            Some(win(1)),
1540            "the dialog's owner's"
1541        );
1542        handle.activate_window(win(3));
1543        assert_eq!(
1544            backend.active_window(),
1545            Some(win(1)),
1546            "no application menu declared: a secondary window keeps the bar"
1547        );
1548        handle.set_app_menu(
1549            sample_snapshot(MenuItemId::next()),
1550            HashMap::new(),
1551            poster(),
1552            true,
1553        );
1554        handle.activate_window(win(3));
1555        assert!(backend.shows_app_menu(), "declared: the application's");
1556        handle.activate_window(win(2));
1557        assert_eq!(backend.active_window(), Some(win(1)));
1558        handle.clear_window(win(1));
1559        assert!(backend.shows_app_menu(), "the owner's menu went with it");
1560    }
1561
1562    /// Declaring the application menu while a window with no menu of its own
1563    /// is key shows it at once, without waiting for focus to move: the bar
1564    /// left up was another window's, kept only because no application menu
1565    /// was declared. Declaring it while the key window shows its own menu
1566    /// leaves that one up.
1567    #[test]
1568    fn declaring_the_application_menu_shows_it_for_a_menu_less_key_window() {
1569        let backend = MemoryNativeMenuBackend::new();
1570        let handle = NativeMenuHandle::new(backend.clone());
1571        let app_menu = |declared| {
1572            handle.set_app_menu(
1573                sample_snapshot(MenuItemId::next()),
1574                HashMap::new(),
1575                poster(),
1576                declared,
1577            )
1578        };
1579        app_menu(false);
1580        handle.set_window_menu(
1581            win(1),
1582            sample_snapshot(MenuItemId::next()),
1583            HashMap::new(),
1584            poster(),
1585        );
1586        handle.activate_window(win(1));
1587        handle.activate_window(win(2));
1588        assert_eq!(backend.active_window(), Some(win(1)), "precondition");
1589        app_menu(true);
1590        assert!(backend.shows_app_menu(), "window 2 is key and has none");
1591
1592        handle.activate_window(win(1));
1593        app_menu(true);
1594        assert_eq!(
1595            backend.active_window(),
1596            Some(win(1)),
1597            "a key window's own menu stays"
1598        );
1599    }
1600
1601    /// After a window's menu is forgotten the bar is never that window's,
1602    /// whoever is key: the window itself, a dialog it owned, another window
1603    /// with or without a menu, none; with an application menu declared or
1604    /// not. A bar that was not the forgotten window's stays.
1605    #[test]
1606    fn a_cleared_windows_bar_never_comes_back() {
1607        let (closed, dialog, other, plain) = (win(1), win(2), win(3), win(4));
1608        // `has_menu` and `owner` still answer for the closed window, as a
1609        // backend that had not forgotten it yet would.
1610        let has_menu = |w: TeksiloWindowId| w == closed || w == other;
1611        let owner = |w: TeksiloWindowId| (w == dialog).then_some(closed);
1612        for declared in [true, false] {
1613            for key in [Some(closed), Some(dialog), Some(other), Some(plain), None] {
1614                let bar = bar_after_clear(closed, key, Some(closed), has_menu, owner, declared)
1615                    .expect("its bar was up: replaced");
1616                assert_ne!(bar, Some(closed), "key {key:?}, declared {declared}");
1617                if key == Some(other) {
1618                    assert_eq!(bar, Some(other), "the key window's own menu");
1619                }
1620            }
1621            assert_eq!(
1622                bar_after_clear(closed, Some(plain), Some(other), has_menu, owner, declared),
1623                None,
1624                "another window's bar stays"
1625            );
1626        }
1627    }
1628
1629    /// The rule both backends follow when the application menu is set:
1630    /// show what [`key_window_bar`] picks for the key window when it differs
1631    /// from the bar up, and the new application menu whenever that is the
1632    /// pick; with no window key yet, replace only an application menu
1633    /// already showing.
1634    #[test]
1635    fn what_setting_the_application_menu_shows() {
1636        let app = None;
1637        assert_eq!(bar_after_app_menu(Some(app), Some(win(1))), Some(app));
1638        assert_eq!(bar_after_app_menu(Some(app), app), Some(app), "rebuilt");
1639        assert_eq!(bar_after_app_menu(Some(Some(win(1))), Some(win(1))), None);
1640        assert_eq!(
1641            bar_after_app_menu(Some(Some(win(1))), app),
1642            Some(Some(win(1)))
1643        );
1644        assert_eq!(bar_after_app_menu(None, app), Some(app));
1645        assert_eq!(bar_after_app_menu(None, Some(win(1))), None);
1646    }
1647
1648    /// A refresh whose inputs are the last refresh's (the same intents
1649    /// taken, no menu installed since) evaluates no row: the loop asks every
1650    /// turn, and most turns change nothing. A menu installed since, or
1651    /// another answer, is evaluated again.
1652    #[test]
1653    fn an_unchanged_refresh_evaluates_nothing() {
1654        let backend = MemoryNativeMenuBackend::new();
1655        let handle = NativeMenuHandle::new(backend.clone());
1656        let row = MenuItemId::next();
1657        let intent_only = || NativeMenuActivation {
1658            intent: Some("app.print"),
1659            action: None,
1660            quits: false,
1661        };
1662        handle.set_window_menu(
1663            win(1),
1664            sample_snapshot(row),
1665            [(row, intent_only())].into_iter().collect(),
1666            poster(),
1667        );
1668        handle.refresh_availability(&["app.print"]);
1669        assert_eq!(handle.evaluations(), 1);
1670        handle.refresh_availability(&["app.print"]);
1671        assert_eq!(handle.evaluations(), 1, "nothing changed: no row evaluated");
1672
1673        let other = MenuItemId::next();
1674        handle.set_window_menu(
1675            win(2),
1676            sample_snapshot(other),
1677            [(other, intent_only())].into_iter().collect(),
1678            poster(),
1679        );
1680        handle.refresh_availability(&["app.print"]);
1681        assert_eq!(handle.evaluations(), 3, "a menu installed since: both rows");
1682        handle.refresh_availability(&[]);
1683        assert_eq!(handle.evaluations(), 5, "another answer");
1684        assert_eq!(handle.gated_enabled(other), Some(false));
1685    }
1686
1687    /// The no-op backend shows no menu bar, so the loop skips the
1688    /// availability refresh altogether where it is the backend.
1689    #[test]
1690    fn only_a_backend_with_a_menu_bar_says_it_shows_one() {
1691        assert!(!NativeMenuHandle::new(NoopNativeMenuBackend::new()).shows_bar());
1692        assert!(NativeMenuHandle::new(MemoryNativeMenuBackend::new()).shows_bar());
1693    }
1694
1695    #[test]
1696    fn update_item_records_delta() {
1697        let backend = MemoryNativeMenuBackend::new();
1698        let handle = NativeMenuHandle::new(backend.clone());
1699        let id = MenuItemId::next();
1700        handle.update_item(
1701            id,
1702            MenuItemDelta {
1703                enabled: Some(false),
1704                check: Some(NativeCheck::On),
1705                ..Default::default()
1706            },
1707        );
1708        let deltas = backend.deltas();
1709        assert_eq!(deltas.len(), 1);
1710        assert_eq!(deltas[0].0, id);
1711        assert_eq!(deltas[0].1.enabled, Some(false));
1712        assert_eq!(deltas[0].1.check, Some(NativeCheck::On));
1713    }
1714
1715    #[test]
1716    fn noop_backend_is_inert() {
1717        let handle = NativeMenuHandle::new(NoopNativeMenuBackend::new());
1718        let id = MenuItemId::next();
1719        handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
1720        handle.activate_window(win(1));
1721        handle.update_item(id, MenuItemDelta::default());
1722        handle.clear_window(win(1));
1723        // No activation was recorded for noop set? Activations live in the
1724        // handle, not the backend, so they ARE recorded then cleared.
1725        assert!(handle.activation(win(1), id).is_none());
1726    }
1727}