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    /// For each entry of `menus`, the rows of the app's own menus folded into
876    /// it: a menu the app titles like a standard Window or Help menu in the
877    /// same snapshot, which the bar would otherwise show twice. Empty for
878    /// every other menu.
879    pub(crate) folded: Vec<Vec<&'a NativeMenuNode>>,
880}
881
882/// Lay out `snapshot` for the macOS menu bar.
883///
884/// AppKit shows the bar's first menu as the bold application menu, under
885/// the application's name, whatever that menu holds. So the App-role menu
886/// goes first wherever the model declared it: declared after File, File's
887/// rows would sit under the application's name and About, Hide and Quit in
888/// a second, ordinary menu. A later App-role menu is left out, since a bar
889/// has one application menu. A leaf or separator at the top level is left
890/// out too: the bar holds menus only.
891///
892/// A menu of the app's own titled like a standard Window or Help menu of the
893/// same snapshot is not shown beside it: its rows go into the first standard
894/// menu with that title, where a Mac app keeps them (its Window commands
895/// under Minimize and Zoom). A model that gives the in-window bar a Window
896/// menu and macOS its standard one shows one Window menu on macOS, with
897/// both.
898#[cfg(any(target_os = "macos", test))]
899pub(crate) fn mac_bar_plan(snapshot: &NativeMenuSnapshot) -> MacBarPlan<'_> {
900    let is_app = |node: &NativeMenuNode| {
901        matches!(
902            node,
903            NativeMenuNode::Standard {
904                role: StandardMenuRole::App,
905                ..
906            }
907        )
908    };
909    let app = snapshot.roots.iter().find(|node| is_app(node));
910    let rest = snapshot.roots.iter().filter(|node| {
911        !is_app(node)
912            && matches!(
913                node,
914                NativeMenuNode::Submenu { .. } | NativeMenuNode::Standard { .. }
915            )
916    });
917    let candidates: Vec<&NativeMenuNode> = app.into_iter().chain(rest).collect();
918    // The first Window- or Help-role menu with this title, which an app menu
919    // of the same title folds into.
920    let host_of = |wanted: &str| {
921        candidates.iter().position(|node| {
922            matches!(
923                node,
924                NativeMenuNode::Standard { role: StandardMenuRole::Window | StandardMenuRole::Help, labels, .. }
925                    if labels.title == wanted
926            )
927        })
928    };
929    let mut menus = Vec::with_capacity(candidates.len());
930    let mut folded: Vec<Vec<&NativeMenuNode>> = Vec::with_capacity(candidates.len());
931    // `host_index[i]`: where `candidates[i]` landed in `menus`.
932    let mut host_index = vec![None; candidates.len()];
933    let mut pending: Vec<(usize, &[NativeMenuNode])> = Vec::new();
934    for (i, node) in candidates.iter().copied().enumerate() {
935        if let NativeMenuNode::Submenu { title, children } = node
936            && let Some(host) = host_of(title)
937        {
938            pending.push((host, children));
939            continue;
940        }
941        host_index[i] = Some(menus.len());
942        menus.push(node);
943        folded.push(Vec::new());
944    }
945    for (host, children) in pending {
946        if let Some(at) = host_index[host] {
947            folded[at].extend(children);
948        }
949    }
950    let first = |wanted: StandardMenuRole| {
951        menus.iter().position(
952            |node| matches!(node, NativeMenuNode::Standard { role, .. } if *role == wanted),
953        )
954    };
955    MacBarPlan {
956        windows_menu: first(StandardMenuRole::Window),
957        help_menu: first(StandardMenuRole::Help),
958        menus,
959        folded,
960    }
961}
962
963/// Whether the macOS backend evaluates a window again for the Window menu
964/// it registers, by flipping its `excludedFromWindowsMenu`: a window that
965/// is not excluded (as a dialog is) and that AppKit lists, on screen
966/// (`visible`) or in the Dock (`miniaturized`). AppKit adds a row for a
967/// window it evaluates with the flag off, so flipping a hidden window's
968/// would list a window the app hid.
969#[cfg(any(target_os = "macos", test))]
970pub(crate) fn mac_relists_window(excluded: bool, visible: bool, miniaturized: bool) -> bool {
971    !excluded && (visible || miniaturized)
972}
973
974/// A row of the macOS App menu. See [`mac_app_menu_rows`].
975#[cfg(any(target_os = "macos", test))]
976#[derive(Debug, Clone, Copy, PartialEq, Eq)]
977pub(crate) enum MacAppMenuRow {
978    /// About, AppKit's `orderFrontStandardAboutPanel:`.
979    About,
980    /// Settings, routed to the app.
981    Settings,
982    /// The Services submenu, registered as `NSApp.servicesMenu`.
983    Services,
984    /// Hide <App>, `hide:`, ⌘H.
985    Hide,
986    /// Hide Others, `hideOtherApplications:`, ⌥⌘H.
987    HideOthers,
988    /// Show All, `unhideAllApplications:`.
989    ShowAll,
990    /// Quit, routed to the app.
991    Quit,
992    /// A separator.
993    Separator,
994}
995
996/// The rows of the macOS App menu, in the order AppKit's own application
997/// menu has them: About; Settings in a group of its own when the app routes
998/// it; Services; Hide, Hide Others and Show All; Quit.
999#[cfg(any(target_os = "macos", test))]
1000pub(crate) fn mac_app_menu_rows(settings: bool) -> Vec<MacAppMenuRow> {
1001    use MacAppMenuRow::*;
1002    let mut rows = vec![About, Separator];
1003    if settings {
1004        rows.extend([Settings, Separator]);
1005    }
1006    rows.extend([
1007        Services, Separator, Hide, HideOthers, ShowAll, Separator, Quit,
1008    ]);
1009    rows
1010}
1011
1012// ============================================================
1013// Default backend factory
1014// ============================================================
1015
1016/// The default native-menu backend for the current target: macOS gets the real
1017/// `NSMenu` backend, every other target gets [`NoopNativeMenuBackend`].
1018pub fn default_backend() -> Box<dyn NativeMenuBackend> {
1019    #[cfg(target_os = "macos")]
1020    {
1021        Box::new(macos::MacOsNativeMenuBackend::new())
1022    }
1023    #[cfg(not(target_os = "macos"))]
1024    {
1025        Box::new(NoopNativeMenuBackend::new())
1026    }
1027}
1028
1029// ============================================================
1030// MemoryNativeMenuBackend (test backend)
1031// ============================================================
1032
1033/// Recording backend for headless tests. Captures the snapshot set per window,
1034/// which window is active, item deltas, and cleared windows. Cloneable; clones
1035/// share the recording so a test can keep a clone after handing one to
1036/// [`NativeMenuHandle::new`].
1037#[derive(Clone, Default)]
1038pub struct MemoryNativeMenuBackend {
1039    inner: Rc<RefCell<MemoryRecording>>,
1040}
1041
1042#[derive(Default)]
1043struct MemoryRecording {
1044    menus: HashMap<TeksiloWindowId, NativeMenuSnapshot>,
1045    app_menu: Option<NativeMenuSnapshot>,
1046    /// The last window `activate_window` named.
1047    key: Option<TeksiloWindowId>,
1048    /// The window whose menu shows; `None` while the application menu does.
1049    active: Option<TeksiloWindowId>,
1050    /// The window each window belongs to (`set_window_owner`).
1051    owners: HashMap<TeksiloWindowId, TeksiloWindowId>,
1052    /// Whether the application menu set last was declared by the app.
1053    app_declared: bool,
1054    deltas: Vec<(MenuItemId, MenuItemDelta)>,
1055    cleared: Vec<TeksiloWindowId>,
1056}
1057
1058impl MemoryRecording {
1059    /// The bar shown while `key` is key, by [`key_window_bar`].
1060    fn bar_for(&self, key: TeksiloWindowId) -> Option<TeksiloWindowId> {
1061        key_window_bar(
1062            key,
1063            |window| self.menus.contains_key(&window),
1064            |window| self.owners.get(&window).copied(),
1065            self.app_declared,
1066            self.active,
1067        )
1068    }
1069}
1070
1071impl MemoryNativeMenuBackend {
1072    /// Build a new empty recording backend.
1073    pub fn new() -> Self {
1074        Self::default()
1075    }
1076
1077    /// The snapshot currently set for `window_id`, if any.
1078    pub fn menu_for(&self, window_id: TeksiloWindowId) -> Option<NativeMenuSnapshot> {
1079        self.inner.borrow().menus.get(&window_id).cloned()
1080    }
1081
1082    /// The window whose own menu shows, as on macOS: the one
1083    /// [`key_window_bar`] picks for the key window (the last
1084    /// `activate_window`), or a window that set one before any was
1085    /// activated. `None` while the application menu shows.
1086    pub fn active_window(&self) -> Option<TeksiloWindowId> {
1087        self.inner.borrow().active
1088    }
1089
1090    /// Whether the application menu is the one showing: no window's own
1091    /// menu is ([`active_window`](Self::active_window) is `None`).
1092    pub fn shows_app_menu(&self) -> bool {
1093        self.inner.borrow().active.is_none()
1094    }
1095
1096    /// All item deltas applied so far, in order.
1097    pub fn deltas(&self) -> Vec<(MenuItemId, MenuItemDelta)> {
1098        self.inner.borrow().deltas.clone()
1099    }
1100
1101    /// Windows whose menus were cleared, in order.
1102    pub fn cleared(&self) -> Vec<TeksiloWindowId> {
1103        self.inner.borrow().cleared.clone()
1104    }
1105
1106    /// The application menu last set, if any.
1107    pub fn app_menu(&self) -> Option<NativeMenuSnapshot> {
1108        self.inner.borrow().app_menu.clone()
1109    }
1110}
1111
1112impl NativeMenuBackend for MemoryNativeMenuBackend {
1113    fn set_window_menu(
1114        &mut self,
1115        window_id: TeksiloWindowId,
1116        menu: NativeMenuSnapshot,
1117        _poster: Arc<dyn AppEventPoster>,
1118    ) {
1119        let mut rec = self.inner.borrow_mut();
1120        rec.menus.insert(window_id, menu);
1121        // As the macOS backend: the bar the key window shows, and a
1122        // window's menu set before any window was activated.
1123        rec.active = match rec.key {
1124            Some(key) => rec.bar_for(key),
1125            None => Some(window_id),
1126        };
1127    }
1128    fn activate_window(&mut self, window_id: TeksiloWindowId) {
1129        let mut rec = self.inner.borrow_mut();
1130        rec.key = Some(window_id);
1131        rec.active = rec.bar_for(window_id);
1132    }
1133    fn set_window_owner(&mut self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
1134        let mut rec = self.inner.borrow_mut();
1135        match owner {
1136            Some(owner) => rec.owners.insert(window_id, owner),
1137            None => rec.owners.remove(&window_id),
1138        };
1139    }
1140    fn clear_window(&mut self, window_id: TeksiloWindowId) {
1141        let mut rec = self.inner.borrow_mut();
1142        rec.menus.remove(&window_id);
1143        rec.owners.remove(&window_id);
1144        rec.cleared.push(window_id);
1145        // As the macOS backend: never the dead window's menu.
1146        let bar = bar_after_clear(
1147            window_id,
1148            rec.key,
1149            rec.active,
1150            |window| rec.menus.contains_key(&window),
1151            |window| rec.owners.get(&window).copied(),
1152            rec.app_declared,
1153        );
1154        if let Some(bar) = bar {
1155            rec.active = bar;
1156        }
1157    }
1158    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
1159        self.inner.borrow_mut().deltas.push((id, delta));
1160    }
1161    fn set_app_menu(
1162        &mut self,
1163        menu: NativeMenuSnapshot,
1164        _poster: Arc<dyn AppEventPoster>,
1165        declared: bool,
1166    ) {
1167        let mut rec = self.inner.borrow_mut();
1168        rec.app_menu = Some(menu);
1169        rec.app_declared = declared;
1170        // As the macOS backend: the key window's bar again, which the new
1171        // application menu may be.
1172        let key_bar = rec.key.map(|key| rec.bar_for(key));
1173        if let Some(bar) = bar_after_app_menu(key_bar, rec.active) {
1174            rec.active = bar;
1175        }
1176    }
1177}
1178
1179// ============================================================
1180// Tests
1181// ============================================================
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186    use std::sync::Mutex;
1187    use teksilo_core::SubscriptionId;
1188
1189    struct NullPoster;
1190    impl AppEventPoster for NullPoster {
1191        fn post_subscription_event(
1192            &self,
1193            _sub_id: SubscriptionId,
1194            _event: Box<dyn std::any::Any + Send>,
1195        ) {
1196        }
1197        fn post_external(&self, _payload: Box<dyn std::any::Any + Send>) {}
1198    }
1199
1200    fn poster() -> Arc<dyn AppEventPoster> {
1201        Arc::new(NullPoster)
1202    }
1203
1204    fn win(n: u64) -> TeksiloWindowId {
1205        TeksiloWindowId::new(n)
1206    }
1207
1208    fn sample_snapshot(id: MenuItemId) -> NativeMenuSnapshot {
1209        NativeMenuSnapshot {
1210            roots: vec![NativeMenuNode::Submenu {
1211                title: "File".into(),
1212                children: vec![NativeMenuNode::Item {
1213                    id,
1214                    title: "New".into(),
1215                    key_equiv: None,
1216                    enabled: true,
1217                    visible: true,
1218                    check: NativeCheck::None,
1219                }],
1220            }],
1221        }
1222    }
1223
1224    #[test]
1225    fn set_menu_records_snapshot_and_activations() {
1226        let backend = MemoryNativeMenuBackend::new();
1227        let handle = NativeMenuHandle::new(backend.clone());
1228        let id = MenuItemId::next();
1229
1230        let fired = Arc::new(Mutex::new(false));
1231        let fired2 = fired.clone();
1232        let mut acts = HashMap::new();
1233        acts.insert(
1234            id,
1235            NativeMenuActivation {
1236                intent: Some("app.new"),
1237                action: Some(Rc::new(move |_ctx: &mut EventContext| {
1238                    *fired2.lock().unwrap() = true;
1239                })),
1240                quits: false,
1241            },
1242        );
1243
1244        handle.set_window_menu(win(1), sample_snapshot(id), acts, poster());
1245
1246        assert!(backend.menu_for(win(1)).is_some());
1247        assert_eq!(backend.active_window(), Some(win(1)));
1248        let act = handle.activation(win(1), id).expect("activation recorded");
1249        assert_eq!(act.intent, Some("app.new"));
1250        assert!(act.action.is_some());
1251    }
1252
1253    #[test]
1254    fn activate_and_clear_window() {
1255        let backend = MemoryNativeMenuBackend::new();
1256        let handle = NativeMenuHandle::new(backend.clone());
1257        let id = MenuItemId::next();
1258        handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
1259        handle.set_window_menu(
1260            win(2),
1261            sample_snapshot(MenuItemId::next()),
1262            HashMap::new(),
1263            poster(),
1264        );
1265
1266        handle.activate_window(win(2));
1267        assert_eq!(backend.active_window(), Some(win(2)));
1268
1269        handle.clear_window(win(2));
1270        assert_eq!(backend.cleared(), vec![win(2)]);
1271        assert!(handle.activation(win(2), id).is_none());
1272        assert!(backend.menu_for(win(2)).is_none());
1273    }
1274
1275    /// A window with no menu of its own, nor an owner with one, shows the
1276    /// application menu the app declared when it becomes key, as the macOS
1277    /// backend does: the previous window's menu used to stay up, its items
1278    /// acting on a window the user had left.
1279    #[test]
1280    fn activating_a_window_without_a_menu_shows_the_application_menu() {
1281        let backend = MemoryNativeMenuBackend::new();
1282        let handle = NativeMenuHandle::new(backend.clone());
1283        handle.set_app_menu(
1284            sample_snapshot(MenuItemId::next()),
1285            HashMap::new(),
1286            poster(),
1287            true,
1288        );
1289        handle.set_window_menu(
1290            win(1),
1291            sample_snapshot(MenuItemId::next()),
1292            HashMap::new(),
1293            poster(),
1294        );
1295        handle.activate_window(win(1));
1296        assert_eq!(backend.active_window(), Some(win(1)));
1297        handle.activate_window(win(2));
1298        assert_eq!(backend.active_window(), None);
1299        assert!(backend.shows_app_menu());
1300        handle.activate_window(win(1));
1301        assert_eq!(backend.active_window(), Some(win(1)), "and back");
1302    }
1303
1304    /// A window that installs its menu while another is key does not take
1305    /// the bar; the key window that closes leaves the application menu.
1306    #[test]
1307    fn the_key_window_decides_and_its_close_leaves_the_application_menu() {
1308        let backend = MemoryNativeMenuBackend::new();
1309        let handle = NativeMenuHandle::new(backend.clone());
1310        handle.activate_window(win(1));
1311        handle.set_window_menu(
1312            win(2),
1313            sample_snapshot(MenuItemId::next()),
1314            HashMap::new(),
1315            poster(),
1316        );
1317        assert!(backend.shows_app_menu(), "window 1 is key and has none");
1318        handle.set_window_menu(
1319            win(1),
1320            sample_snapshot(MenuItemId::next()),
1321            HashMap::new(),
1322            poster(),
1323        );
1324        assert_eq!(backend.active_window(), Some(win(1)));
1325        handle.clear_window(win(1));
1326        assert!(backend.shows_app_menu());
1327    }
1328
1329    /// A row whose only effect is its intent greys out while nothing takes
1330    /// the intent, its own enabled state still counting; a row with an
1331    /// action, and the App menu's Quit, never do.
1332    #[test]
1333    fn an_intent_only_row_greys_out_while_nothing_takes_its_intent() {
1334        let backend = MemoryNativeMenuBackend::new();
1335        let handle = NativeMenuHandle::new(backend.clone());
1336        let (plain, with_action, quit) =
1337            (MenuItemId::next(), MenuItemId::next(), MenuItemId::next());
1338        let activation = |action: bool, quits: bool| NativeMenuActivation {
1339            intent: Some("app.print"),
1340            action: action.then(|| -> MenuActionFn { Rc::new(|_| {}) }),
1341            quits,
1342        };
1343        handle.set_window_menu(
1344            win(1),
1345            sample_snapshot(plain),
1346            [
1347                (plain, activation(false, false)),
1348                (with_action, activation(true, false)),
1349                (quit, activation(false, true)),
1350            ]
1351            .into_iter()
1352            .collect(),
1353            poster(),
1354        );
1355        let enabled_deltas = |backend: &MemoryNativeMenuBackend| {
1356            backend
1357                .deltas()
1358                .into_iter()
1359                .filter_map(|(id, delta)| delta.enabled.map(|enabled| (id, enabled)))
1360                .collect::<Vec<_>>()
1361        };
1362        handle.refresh_availability(&["app.print"]);
1363        assert!(enabled_deltas(&backend).is_empty(), "nothing changed");
1364        handle.refresh_availability(&[]);
1365        assert_eq!(enabled_deltas(&backend), [(plain, false)]);
1366        assert_eq!(handle.gated_enabled(plain), Some(false));
1367        assert_eq!(handle.gated_enabled(with_action), None);
1368        assert_eq!(handle.gated_enabled(quit), None);
1369
1370        // Its own state turning true does not enable it while nothing takes
1371        // the intent; the intent taken again does.
1372        handle.update_item(
1373            plain,
1374            MenuItemDelta {
1375                enabled: Some(true),
1376                ..Default::default()
1377            },
1378        );
1379        assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, false)));
1380        handle.refresh_availability(&["app.print"]);
1381        assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, true)));
1382
1383        // Its own state turning false greys it out whatever the route says.
1384        handle.update_item(
1385            plain,
1386            MenuItemDelta {
1387                enabled: Some(false),
1388                ..Default::default()
1389            },
1390        );
1391        handle.refresh_availability(&["app.print"]);
1392        assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, false)));
1393
1394        handle.clear_window(win(1));
1395        assert_eq!(handle.gated_enabled(plain), None, "forgotten with its menu");
1396    }
1397
1398    fn standard(role: StandardMenuRole, title: &str) -> NativeMenuNode {
1399        NativeMenuNode::Standard {
1400            role,
1401            labels: StandardLabels {
1402                title: title.into(),
1403                ..Default::default()
1404            },
1405            quit_item: None,
1406            settings_item: None,
1407        }
1408    }
1409
1410    fn bar_titles(plan: &MacBarPlan<'_>) -> Vec<String> {
1411        plan.menus
1412            .iter()
1413            .map(|node| match node {
1414                NativeMenuNode::Submenu { title, .. } => title.clone(),
1415                NativeMenuNode::Standard { labels, .. } => labels.title.clone(),
1416                other => panic!("the bar holds menus only, got {other:?}"),
1417            })
1418            .collect()
1419    }
1420
1421    /// AppKit shows the bar's first menu as the application menu, so the
1422    /// App-role menu goes first wherever the model declares it. Declared
1423    /// after File, File's rows sat under the application's name and About,
1424    /// Hide and Quit in a second, ordinary menu (macOS findings, item 2).
1425    #[test]
1426    fn the_app_menu_is_first_in_the_mac_bar_wherever_it_is_declared() {
1427        let mut file = sample_snapshot(MenuItemId::next()).roots;
1428        let snapshot = NativeMenuSnapshot {
1429            roots: vec![
1430                file.remove(0),
1431                NativeMenuNode::Separator,
1432                standard(StandardMenuRole::Window, "Window"),
1433                standard(StandardMenuRole::App, "Probe"),
1434                standard(StandardMenuRole::Help, "Help"),
1435                standard(StandardMenuRole::App, "Second"),
1436            ],
1437        };
1438        assert_eq!(
1439            bar_titles(&mac_bar_plan(&snapshot)),
1440            ["Probe", "File", "Window", "Help"],
1441            "App first, the rest in declared order, one App menu, no top-level separator"
1442        );
1443
1444        let declared_first = NativeMenuSnapshot {
1445            roots: vec![
1446                standard(StandardMenuRole::App, "Probe"),
1447                sample_snapshot(MenuItemId::next()).roots.remove(0),
1448            ],
1449        };
1450        assert_eq!(
1451            bar_titles(&mac_bar_plan(&declared_first)),
1452            ["Probe", "File"]
1453        );
1454    }
1455
1456    /// A window registered again in the Window menu is one AppKit lists:
1457    /// not excluded, and on screen or in the Dock. A hidden window (ordered
1458    /// out, not miniaturized) is left alone: flipping its flag lists it,
1459    /// since AppKit keeps a row for every window it evaluates with the flag
1460    /// off, and a window the app hid would show in the Window and Dock
1461    /// menus.
1462    #[test]
1463    fn the_mac_window_menu_relists_only_shown_or_miniaturized_windows() {
1464        assert!(mac_relists_window(false, true, false), "on screen");
1465        assert!(mac_relists_window(false, false, true), "in the Dock");
1466        assert!(!mac_relists_window(false, false, false), "hidden");
1467        assert!(
1468            !mac_relists_window(true, true, false),
1469            "excluded (a dialog)"
1470        );
1471    }
1472
1473    /// The App menu's rows in AppKit's order: About; Settings in a group of
1474    /// its own; Services; Hide, Hide Others and Show All; Quit.
1475    #[test]
1476    fn the_mac_app_menu_has_appkits_rows_in_appkits_order() {
1477        use MacAppMenuRow::*;
1478        assert_eq!(
1479            mac_app_menu_rows(true),
1480            [
1481                About, Separator, Settings, Separator, Services, Separator, Hide, HideOthers,
1482                ShowAll, Separator, Quit,
1483            ]
1484        );
1485        assert_eq!(
1486            mac_app_menu_rows(false),
1487            [
1488                About, Separator, Services, Separator, Hide, HideOthers, ShowAll, Separator, Quit,
1489            ],
1490            "no Settings row, and no empty group for it"
1491        );
1492    }
1493
1494    /// AppKit keeps the window list in the menu registered as `windowsMenu`,
1495    /// and the Dock icon's menu lists the windows from it: the bar's first
1496    /// Window-role menu when it declares one, else a hidden one the backend
1497    /// adds (Qt's `insertWindowMenu`), so the Dock still lists the windows
1498    /// (macOS findings, item 3).
1499    #[test]
1500    fn the_mac_bar_keeps_the_window_list_in_its_window_menu_or_a_hidden_one() {
1501        let snapshot = NativeMenuSnapshot {
1502            roots: vec![
1503                sample_snapshot(MenuItemId::next()).roots.remove(0),
1504                standard(StandardMenuRole::Window, "Window"),
1505                standard(StandardMenuRole::App, "Probe"),
1506                standard(StandardMenuRole::Window, "Second window menu"),
1507                standard(StandardMenuRole::Help, "Help"),
1508            ],
1509        };
1510        let plan = mac_bar_plan(&snapshot);
1511        assert_eq!(
1512            plan.windows_menu,
1513            Some(2),
1514            "the first Window menu, in bar order"
1515        );
1516        assert_eq!(plan.help_menu, Some(4));
1517
1518        let no_window_menu = NativeMenuSnapshot {
1519            roots: vec![
1520                sample_snapshot(MenuItemId::next()).roots.remove(0),
1521                standard(StandardMenuRole::App, "Probe"),
1522            ],
1523        };
1524        let plan = mac_bar_plan(&no_window_menu);
1525        assert_eq!(plan.windows_menu, None, "a hidden one is added");
1526        assert_eq!(plan.help_menu, None);
1527    }
1528
1529    /// An app menu titled like the snapshot's standard Window or Help menu
1530    /// is not shown beside it: its rows go into the standard menu. The
1531    /// `native-menu` example gave the in-window bar a Window menu (Inspector)
1532    /// and macOS its standard one, and the macOS bar showed Window twice.
1533    #[test]
1534    fn an_app_menu_titled_like_a_standard_one_folds_into_it_on_the_mac_bar() {
1535        let inspector = MenuItemId::next();
1536        let own = |title: &str, id: MenuItemId| NativeMenuNode::Submenu {
1537            title: title.into(),
1538            children: vec![NativeMenuNode::Item {
1539                id,
1540                title: "Row".into(),
1541                key_equiv: None,
1542                enabled: true,
1543                visible: true,
1544                check: NativeCheck::None,
1545            }],
1546        };
1547        let manual = MenuItemId::next();
1548        let snapshot = NativeMenuSnapshot {
1549            roots: vec![
1550                standard(StandardMenuRole::App, "Probe"),
1551                own("Window", inspector),
1552                own("Help", manual),
1553                standard(StandardMenuRole::Window, "Window"),
1554                standard(StandardMenuRole::Help, "Help"),
1555                own("Tools", MenuItemId::next()),
1556            ],
1557        };
1558        let plan = mac_bar_plan(&snapshot);
1559        assert_eq!(
1560            bar_titles(&plan),
1561            ["Probe", "Window", "Help", "Tools"],
1562            "one Window menu, one Help menu"
1563        );
1564        let ids = |rows: &[&NativeMenuNode]| -> Vec<MenuItemId> {
1565            rows.iter()
1566                .map(|node| match node {
1567                    NativeMenuNode::Item { id, .. } => *id,
1568                    other => panic!("expected a row, got {other:?}"),
1569                })
1570                .collect()
1571        };
1572        assert_eq!(
1573            ids(&plan.folded[1]),
1574            [inspector],
1575            "into the standard Window menu"
1576        );
1577        assert_eq!(
1578            ids(&plan.folded[2]),
1579            [manual],
1580            "into the standard Help menu"
1581        );
1582        assert!(plan.folded[0].is_empty() && plan.folded[3].is_empty());
1583        assert_eq!((plan.windows_menu, plan.help_menu), (Some(1), Some(2)));
1584
1585        // With no standard Window menu, the app's own stays as it is.
1586        let alone = NativeMenuSnapshot {
1587            roots: vec![
1588                standard(StandardMenuRole::App, "Probe"),
1589                own("Window", MenuItemId::next()),
1590            ],
1591        };
1592        let plan = mac_bar_plan(&alone);
1593        assert_eq!(bar_titles(&plan), ["Probe", "Window"]);
1594        assert_eq!(plan.windows_menu, None);
1595    }
1596
1597    /// The bar a key window shows: its own menu; else the menu of the window
1598    /// it belongs to (a modal dialog's owner, up the chain of owners); else
1599    /// the application menu, or, when the application declared none, the bar
1600    /// shown before, as AppKit keeps `mainMenu` up for its panels and
1601    /// sheets. A dialog used to show the application menu, so an app with
1602    /// no application menu lost File and Edit whenever one was key.
1603    #[test]
1604    fn a_key_window_without_a_menu_shows_its_owners_or_the_applications() {
1605        let menus = [win(1)];
1606        let has_menu = |w: TeksiloWindowId| menus.contains(&w);
1607        // 2 is a dialog of 1, 3 a dialog of 2; 4 is a secondary window.
1608        let owner = |w: TeksiloWindowId| match w.raw() {
1609            2 => Some(win(1)),
1610            3 => Some(win(2)),
1611            _ => None,
1612        };
1613        let bar = |key, declared, shown| key_window_bar(key, has_menu, owner, declared, shown);
1614        assert_eq!(bar(win(1), true, None), Some(win(1)), "its own");
1615        assert_eq!(bar(win(2), true, None), Some(win(1)), "its owner's");
1616        assert_eq!(bar(win(3), true, None), Some(win(1)), "up the chain");
1617        assert_eq!(bar(win(4), true, Some(win(1))), None, "the application's");
1618        assert_eq!(
1619            bar(win(4), false, Some(win(1))),
1620            Some(win(1)),
1621            "none declared: the bar shown before stays"
1622        );
1623        assert_eq!(bar(win(4), false, None), None);
1624
1625        // An owner relation that loops ends the walk rather than the loop.
1626        let looping = |w: TeksiloWindowId| Some(if w == win(5) { win(6) } else { win(5) });
1627        assert_eq!(key_window_bar(win(5), has_menu, looping, true, None), None);
1628    }
1629
1630    /// `MemoryNativeMenuBackend` follows the same rule as the macOS one.
1631    #[test]
1632    fn the_memory_backend_shows_a_dialogs_owners_menu() {
1633        let backend = MemoryNativeMenuBackend::new();
1634        let handle = NativeMenuHandle::new(backend.clone());
1635        handle.set_app_menu(
1636            sample_snapshot(MenuItemId::next()),
1637            HashMap::new(),
1638            poster(),
1639            false,
1640        );
1641        handle.set_window_menu(
1642            win(1),
1643            sample_snapshot(MenuItemId::next()),
1644            HashMap::new(),
1645            poster(),
1646        );
1647        handle.set_window_owner(win(2), Some(win(1)));
1648        handle.activate_window(win(1));
1649        handle.activate_window(win(2));
1650        assert_eq!(
1651            backend.active_window(),
1652            Some(win(1)),
1653            "the dialog's owner's"
1654        );
1655        handle.activate_window(win(3));
1656        assert_eq!(
1657            backend.active_window(),
1658            Some(win(1)),
1659            "no application menu declared: a secondary window keeps the bar"
1660        );
1661        handle.set_app_menu(
1662            sample_snapshot(MenuItemId::next()),
1663            HashMap::new(),
1664            poster(),
1665            true,
1666        );
1667        handle.activate_window(win(3));
1668        assert!(backend.shows_app_menu(), "declared: the application's");
1669        handle.activate_window(win(2));
1670        assert_eq!(backend.active_window(), Some(win(1)));
1671        handle.clear_window(win(1));
1672        assert!(backend.shows_app_menu(), "the owner's menu went with it");
1673    }
1674
1675    /// Declaring the application menu while a window with no menu of its own
1676    /// is key shows it at once, without waiting for focus to move: the bar
1677    /// left up was another window's, kept only because no application menu
1678    /// was declared. Declaring it while the key window shows its own menu
1679    /// leaves that one up.
1680    #[test]
1681    fn declaring_the_application_menu_shows_it_for_a_menu_less_key_window() {
1682        let backend = MemoryNativeMenuBackend::new();
1683        let handle = NativeMenuHandle::new(backend.clone());
1684        let app_menu = |declared| {
1685            handle.set_app_menu(
1686                sample_snapshot(MenuItemId::next()),
1687                HashMap::new(),
1688                poster(),
1689                declared,
1690            )
1691        };
1692        app_menu(false);
1693        handle.set_window_menu(
1694            win(1),
1695            sample_snapshot(MenuItemId::next()),
1696            HashMap::new(),
1697            poster(),
1698        );
1699        handle.activate_window(win(1));
1700        handle.activate_window(win(2));
1701        assert_eq!(backend.active_window(), Some(win(1)), "precondition");
1702        app_menu(true);
1703        assert!(backend.shows_app_menu(), "window 2 is key and has none");
1704
1705        handle.activate_window(win(1));
1706        app_menu(true);
1707        assert_eq!(
1708            backend.active_window(),
1709            Some(win(1)),
1710            "a key window's own menu stays"
1711        );
1712    }
1713
1714    /// After a window's menu is forgotten the bar is never that window's,
1715    /// whoever is key: the window itself, a dialog it owned, another window
1716    /// with or without a menu, none; with an application menu declared or
1717    /// not. A bar that was not the forgotten window's stays.
1718    #[test]
1719    fn a_cleared_windows_bar_never_comes_back() {
1720        let (closed, dialog, other, plain) = (win(1), win(2), win(3), win(4));
1721        // `has_menu` and `owner` still answer for the closed window, as a
1722        // backend that had not forgotten it yet would.
1723        let has_menu = |w: TeksiloWindowId| w == closed || w == other;
1724        let owner = |w: TeksiloWindowId| (w == dialog).then_some(closed);
1725        for declared in [true, false] {
1726            for key in [Some(closed), Some(dialog), Some(other), Some(plain), None] {
1727                let bar = bar_after_clear(closed, key, Some(closed), has_menu, owner, declared)
1728                    .expect("its bar was up: replaced");
1729                assert_ne!(bar, Some(closed), "key {key:?}, declared {declared}");
1730                if key == Some(other) {
1731                    assert_eq!(bar, Some(other), "the key window's own menu");
1732                }
1733            }
1734            assert_eq!(
1735                bar_after_clear(closed, Some(plain), Some(other), has_menu, owner, declared),
1736                None,
1737                "another window's bar stays"
1738            );
1739        }
1740    }
1741
1742    /// The rule both backends follow when the application menu is set:
1743    /// show what [`key_window_bar`] picks for the key window when it differs
1744    /// from the bar up, and the new application menu whenever that is the
1745    /// pick; with no window key yet, replace only an application menu
1746    /// already showing.
1747    #[test]
1748    fn what_setting_the_application_menu_shows() {
1749        let app = None;
1750        assert_eq!(bar_after_app_menu(Some(app), Some(win(1))), Some(app));
1751        assert_eq!(bar_after_app_menu(Some(app), app), Some(app), "rebuilt");
1752        assert_eq!(bar_after_app_menu(Some(Some(win(1))), Some(win(1))), None);
1753        assert_eq!(
1754            bar_after_app_menu(Some(Some(win(1))), app),
1755            Some(Some(win(1)))
1756        );
1757        assert_eq!(bar_after_app_menu(None, app), Some(app));
1758        assert_eq!(bar_after_app_menu(None, Some(win(1))), None);
1759    }
1760
1761    /// A refresh whose inputs are the last refresh's (the same intents
1762    /// taken, no menu installed since) evaluates no row: the loop asks every
1763    /// turn, and most turns change nothing. A menu installed since, or
1764    /// another answer, is evaluated again.
1765    #[test]
1766    fn an_unchanged_refresh_evaluates_nothing() {
1767        let backend = MemoryNativeMenuBackend::new();
1768        let handle = NativeMenuHandle::new(backend.clone());
1769        let row = MenuItemId::next();
1770        let intent_only = || NativeMenuActivation {
1771            intent: Some("app.print"),
1772            action: None,
1773            quits: false,
1774        };
1775        handle.set_window_menu(
1776            win(1),
1777            sample_snapshot(row),
1778            [(row, intent_only())].into_iter().collect(),
1779            poster(),
1780        );
1781        handle.refresh_availability(&["app.print"]);
1782        assert_eq!(handle.evaluations(), 1);
1783        handle.refresh_availability(&["app.print"]);
1784        assert_eq!(handle.evaluations(), 1, "nothing changed: no row evaluated");
1785
1786        let other = MenuItemId::next();
1787        handle.set_window_menu(
1788            win(2),
1789            sample_snapshot(other),
1790            [(other, intent_only())].into_iter().collect(),
1791            poster(),
1792        );
1793        handle.refresh_availability(&["app.print"]);
1794        assert_eq!(handle.evaluations(), 3, "a menu installed since: both rows");
1795        handle.refresh_availability(&[]);
1796        assert_eq!(handle.evaluations(), 5, "another answer");
1797        assert_eq!(handle.gated_enabled(other), Some(false));
1798    }
1799
1800    /// The no-op backend shows no menu bar, so the loop skips the
1801    /// availability refresh altogether where it is the backend.
1802    #[test]
1803    fn only_a_backend_with_a_menu_bar_says_it_shows_one() {
1804        assert!(!NativeMenuHandle::new(NoopNativeMenuBackend::new()).shows_bar());
1805        assert!(NativeMenuHandle::new(MemoryNativeMenuBackend::new()).shows_bar());
1806    }
1807
1808    #[test]
1809    fn update_item_records_delta() {
1810        let backend = MemoryNativeMenuBackend::new();
1811        let handle = NativeMenuHandle::new(backend.clone());
1812        let id = MenuItemId::next();
1813        handle.update_item(
1814            id,
1815            MenuItemDelta {
1816                enabled: Some(false),
1817                check: Some(NativeCheck::On),
1818                ..Default::default()
1819            },
1820        );
1821        let deltas = backend.deltas();
1822        assert_eq!(deltas.len(), 1);
1823        assert_eq!(deltas[0].0, id);
1824        assert_eq!(deltas[0].1.enabled, Some(false));
1825        assert_eq!(deltas[0].1.check, Some(NativeCheck::On));
1826    }
1827
1828    #[test]
1829    fn noop_backend_is_inert() {
1830        let handle = NativeMenuHandle::new(NoopNativeMenuBackend::new());
1831        let id = MenuItemId::next();
1832        handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
1833        handle.activate_window(win(1));
1834        handle.update_item(id, MenuItemDelta::default());
1835        handle.clear_window(win(1));
1836        // No activation was recorded for noop set? Activations live in the
1837        // handle, not the backend, so they ARE recorded then cleared.
1838        assert!(handle.activation(win(1), id).is_none());
1839    }
1840}