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, looks the
32//! [`MenuItemId`] up in the [`NativeMenuHandle`], and fires the item's intent /
33//! action inside the originating window's `EventContext` — the same
34//! `Action`/`Intent` pipeline an in-window `MenuItem` uses.
35//!
36//! # Multi-window
37//!
38//! On macOS there is exactly one global menu bar; it must reflect the *focused*
39//! window. Each window registers its snapshot via [`NativeMenuHandle::set_window_menu`];
40//! `teksilo-app` calls [`NativeMenuHandle::activate_window`] on focus change so
41//! the focused window's menu becomes `mainMenu`. Single-window apps work with
42//! set-on-build alone.
43
44use std::cell::RefCell;
45use std::collections::HashMap;
46use std::rc::Rc;
47use std::sync::Arc;
48
49use teksilo_core::AppEventPoster;
50use teksilo_core::MenuItemId;
51use teksilo_core::widget::EventContext;
52use teksilo_core::window::TeksiloWindowId;
53
54#[cfg(target_os = "macos")]
55mod macos;
56
57// ============================================================
58// Snapshot data (the platform boundary type)
59// ============================================================
60
61/// On/off/mixed state for a checkable native menu item.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum NativeCheck {
64    /// Not a checkable item — no check-mark column behaviour.
65    #[default]
66    None,
67    /// Checkable, currently unchecked.
68    Off,
69    /// Checkable, currently checked.
70    On,
71    /// Checkable, currently mixed/indeterminate (tri-state parents).
72    Mixed,
73}
74
75/// A platform-neutral key equivalent for a native menu item. Already resolved
76/// from the app's `ShortcutRegistry` by the widget layer. `key` is the base
77/// character the OS menu expects (e.g. `"s"`, `"\r"`); the booleans are the
78/// modifier flags. An item with an empty `key` displays no shortcut.
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
80pub struct NativeKeyEquivalent {
81    /// The base key as the single string the native menu expects.
82    pub key: String,
83    /// Command (⌘ on macOS) / the platform's primary accelerator modifier.
84    pub command: bool,
85    /// Shift (⇧).
86    pub shift: bool,
87    /// Alt / Option (⌥).
88    pub alt: bool,
89    /// Control (⌃).
90    pub control: bool,
91}
92
93/// Standard, platform-defined menus with required placement/behaviour (the
94/// macOS App / Window / Help menus, with their About / Hide / Quit /
95/// window-management items wired to system selectors). The backend supplies the
96/// native structure; the in-window `MenuBar` ignores these.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum StandardMenuRole {
99    /// The application menu (About / Hide / Quit). Must be first.
100    App,
101    /// The Window menu (Minimize / Zoom / window list).
102    Window,
103    /// The Help menu.
104    Help,
105}
106
107/// Display strings for a [`StandardMenuRole`], **already localized** by the
108/// widget layer. The platform layer never hardcodes user-visible menu text — it
109/// applies whatever the snapshot carries — so a standard menu honours the app's
110/// locale (e.g. "Quitter" / "Masquer" on a French system) instead of leaking
111/// English literals onto the most visible native surface.
112#[derive(Debug, Clone, Default)]
113pub struct StandardLabels {
114    /// Submenu title (Window / Help; the App submenu typically uses the app name).
115    pub title: String,
116    /// "About …" (App).
117    pub about: String,
118    /// "Settings…" / "Preferences…" (App). Only rendered when the snapshot
119    /// also carries a `settings_item` — the platform has no default action
120    /// for it, unlike About / Hide / Quit.
121    pub settings: String,
122    /// "Hide …" (App).
123    pub hide: String,
124    /// "Quit …" (App).
125    pub quit: String,
126    /// "Minimize" (Window).
127    pub minimize: String,
128    /// "Zoom" (Window).
129    pub zoom: String,
130}
131
132/// A row inside a platform-standard menu that the app routes rather than the
133/// platform selects — Quit and Settings today.
134///
135/// Carries the key equivalent alongside the id because the widget layer is the
136/// only place that knows it. Every other item's chord comes from the
137/// `ShortcutRegistry` there, resolved through the primary-accelerator
138/// convention; if the platform layer picked one for these two it would be the
139/// one surface in the app advertising a chord nobody registered — live even
140/// after the user rebound the command, and immune to the rewriting the registry
141/// does for everything else. `None` means no key equivalent at all.
142#[derive(Debug, Clone)]
143pub struct StandardRoutedItem {
144    /// Correlates the native item back to the logical one on activation.
145    pub id: MenuItemId,
146    /// Key equivalent to advertise, already resolved.
147    pub key_equiv: Option<NativeKeyEquivalent>,
148}
149
150/// One node of a native menu tree.
151#[derive(Debug, Clone)]
152pub enum NativeMenuNode {
153    /// A leaf command.
154    Item {
155        /// Correlates the native item back to the logical one on activation.
156        id: MenuItemId,
157        /// Display text (mnemonics already stripped, locale already resolved).
158        title: String,
159        /// Key equivalent, if any.
160        key_equiv: Option<NativeKeyEquivalent>,
161        /// Whether the item is enabled.
162        enabled: bool,
163        /// Check-mark state.
164        check: NativeCheck,
165    },
166    /// A submenu with its own children.
167    Submenu {
168        /// Submenu title.
169        title: String,
170        /// Child nodes.
171        children: Vec<NativeMenuNode>,
172    },
173    /// A separator line.
174    Separator,
175    /// A platform-standard menu the backend fills in, with localized chrome.
176    Standard {
177        /// Which standard menu.
178        role: StandardMenuRole,
179        /// Localized display strings (supplied by the widget layer).
180        labels: StandardLabels,
181        /// App menu only: route **Quit** back to the app under this item
182        /// instead of firing the platform's own terminate selector.
183        ///
184        /// `None` — the default — keeps the system behaviour: on macOS the item
185        /// is bound to `terminate:`, which works with no app wiring at all and
186        /// is why ⌘Q is live even for an app that declares no menus.
187        ///
188        /// `Some(..)` builds Quit as an ordinary routed item — same id → the
189        /// activation recorded for it, and the key equivalent the widget layer
190        /// resolved. **An app with anything to lose on exit must set this**: a
191        /// main-menu key equivalent is dispatched by the platform before the
192        /// responder chain, and `terminate:` does not run winit's exit path, so
193        /// an in-app quit shortcut is shadowed rather than merely duplicated.
194        /// Whatever the app routes to then owes the exit itself — nothing here
195        /// terminates.
196        quit_item: Option<StandardRoutedItem>,
197        /// App menu only: build a **Settings…** item under this id, placed where
198        /// the platform expects it (on macOS: after About), with the key
199        /// equivalent the widget layer resolved.
200        ///
201        /// Unlike Quit there is no `None` fallback that still does something —
202        /// no platform ships a default action for opening an app's settings —
203        /// so `None` simply omits the item. An app that has a settings window
204        /// routes it; one that has none leaves the slot empty rather than
205        /// showing a row that does nothing.
206        settings_item: Option<StandardRoutedItem>,
207    },
208}
209
210/// A complete, resolved description of one window's menu tree.
211#[derive(Debug, Clone, Default)]
212pub struct NativeMenuSnapshot {
213    /// The top-level menus (each typically a [`NativeMenuNode::Submenu`] or a
214    /// [`NativeMenuNode::Standard`]).
215    pub roots: Vec<NativeMenuNode>,
216}
217
218/// A reactive change to a single already-installed native item, applied without
219/// rebuilding the whole menu. Each `Some` field replaces that property.
220#[derive(Debug, Clone, Default)]
221pub struct MenuItemDelta {
222    /// New enabled state.
223    pub enabled: Option<bool>,
224    /// New check state.
225    pub check: Option<NativeCheck>,
226    /// New display title.
227    pub title: Option<String>,
228    /// New key equivalent (`Some(None)` clears it; `None` leaves it unchanged).
229    pub key_equiv: Option<Option<NativeKeyEquivalent>>,
230}
231
232// ============================================================
233// Activation (kept on the app side of the boundary)
234// ============================================================
235
236/// What to do when a native menu item is chosen. Cloneable (the action is an
237/// `Rc`), so the router can pull a copy out of the handle and run it.
238pub type MenuActionFn = Rc<dyn Fn(&mut EventContext)>;
239
240#[derive(Clone, Default)]
241pub struct NativeMenuActivation {
242    /// Fire this intent by name through the `Action`/`Intent` pipeline.
243    pub intent: Option<&'static str>,
244    /// Or run this closure directly (the escape hatch). Runs after `intent`.
245    pub action: Option<MenuActionFn>,
246}
247
248impl std::fmt::Debug for NativeMenuActivation {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        f.debug_struct("NativeMenuActivation")
251            .field("intent", &self.intent)
252            .field("action", &self.action.as_ref().map(|_| "<closure>"))
253            .finish()
254    }
255}
256
257// ============================================================
258// Event payload
259// ============================================================
260
261/// Boxed inside `AppEvent::External` when the user picks a native menu item.
262/// `teksilo-app` downcasts to this and routes the [`MenuItemId`] back to the
263/// originating window's tree.
264#[derive(Debug, Clone)]
265pub struct NativeMenuEventPayload {
266    /// The window whose menu was active when the item was chosen.
267    pub window_id_owner: TeksiloWindowId,
268    /// The chosen item.
269    pub item_id: MenuItemId,
270}
271
272// ============================================================
273// Backend trait
274// ============================================================
275
276/// Swappable native-menu backend. One instance serves the whole app.
277pub trait NativeMenuBackend {
278    /// Build (or replace) the native menu for `window_id` from `menu`. For
279    /// every item the user later chooses, the backend MUST post a
280    /// [`NativeMenuEventPayload`] — with `window_id_owner == window_id` —
281    /// through `poster`. If `window_id` is (or becomes) the active window, the
282    /// backend should also make this menu the visible one.
283    fn set_window_menu(
284        &mut self,
285        window_id: TeksiloWindowId,
286        menu: NativeMenuSnapshot,
287        poster: Arc<dyn AppEventPoster>,
288    );
289
290    /// Make `window_id`'s previously-set menu the active/visible one (focus
291    /// follows window). No-op if that window never set a menu.
292    fn activate_window(&mut self, window_id: TeksiloWindowId);
293
294    /// Forget `window_id`'s menu (window closed).
295    fn clear_window(&mut self, window_id: TeksiloWindowId);
296
297    /// Apply a reactive delta to a single already-installed item.
298    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta);
299}
300
301/// Forward through a boxed backend so `NativeMenuHandle::new(default_backend())`
302/// type-checks.
303impl NativeMenuBackend for Box<dyn NativeMenuBackend> {
304    fn set_window_menu(
305        &mut self,
306        window_id: TeksiloWindowId,
307        menu: NativeMenuSnapshot,
308        poster: Arc<dyn AppEventPoster>,
309    ) {
310        (**self).set_window_menu(window_id, menu, poster)
311    }
312    fn activate_window(&mut self, window_id: TeksiloWindowId) {
313        (**self).activate_window(window_id)
314    }
315    fn clear_window(&mut self, window_id: TeksiloWindowId) {
316        (**self).clear_window(window_id)
317    }
318    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
319        (**self).update_item(id, delta)
320    }
321}
322
323// ============================================================
324// NativeMenuHandle
325// ============================================================
326
327/// Per-window map from item id to its activation.
328type WindowActivations = HashMap<MenuItemId, NativeMenuActivation>;
329
330struct NativeMenuState {
331    backend: RefCell<Box<dyn NativeMenuBackend>>,
332    /// Per-window: item id → what to do when chosen.
333    activations: RefCell<HashMap<TeksiloWindowId, WindowActivations>>,
334}
335
336/// Per-app native-menu service. Registered in app-state by
337/// `TeksiloAppBuilder::install_native_menu` (or `.app_state(NativeMenuHandle::new(..))`
338/// for a custom backend). Cloneable; clones share one backend + activation map.
339#[derive(Clone)]
340pub struct NativeMenuHandle {
341    inner: Rc<NativeMenuState>,
342}
343
344impl NativeMenuHandle {
345    /// Build a handle wrapping the given backend.
346    pub fn new<B: NativeMenuBackend + 'static>(backend: B) -> Self {
347        Self {
348            inner: Rc::new(NativeMenuState {
349                backend: RefCell::new(Box::new(backend)),
350                activations: RefCell::new(HashMap::new()),
351            }),
352        }
353    }
354
355    /// Install `window_id`'s menu, recording the per-item activations so a later
356    /// click can be routed. Replaces any prior menu for that window.
357    pub fn set_window_menu(
358        &self,
359        window_id: TeksiloWindowId,
360        menu: NativeMenuSnapshot,
361        activations: HashMap<MenuItemId, NativeMenuActivation>,
362        poster: Arc<dyn AppEventPoster>,
363    ) {
364        self.inner
365            .activations
366            .borrow_mut()
367            .insert(window_id, activations);
368        self.inner
369            .backend
370            .borrow_mut()
371            .set_window_menu(window_id, menu, poster);
372    }
373
374    /// Make `window_id`'s menu the visible one (focus-follows-window).
375    pub fn activate_window(&self, window_id: TeksiloWindowId) {
376        self.inner.backend.borrow_mut().activate_window(window_id);
377    }
378
379    /// Forget a window's menu + activations (window closed).
380    pub fn clear_window(&self, window_id: TeksiloWindowId) {
381        self.inner.activations.borrow_mut().remove(&window_id);
382        self.inner.backend.borrow_mut().clear_window(window_id);
383    }
384
385    /// Apply a reactive delta to one installed item.
386    pub fn update_item(&self, id: MenuItemId, delta: MenuItemDelta) {
387        self.inner.backend.borrow_mut().update_item(id, delta);
388    }
389
390    /// Look up (and clone) the activation for a chosen item, for the router.
391    pub fn activation(
392        &self,
393        window_id: TeksiloWindowId,
394        id: MenuItemId,
395    ) -> Option<NativeMenuActivation> {
396        self.inner
397            .activations
398            .borrow()
399            .get(&window_id)
400            .and_then(|m| m.get(&id).cloned())
401    }
402}
403
404impl std::fmt::Debug for NativeMenuHandle {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        f.debug_struct("NativeMenuHandle")
407            .field("windows", &self.inner.activations.borrow().len())
408            .finish_non_exhaustive()
409    }
410}
411
412// ============================================================
413// NoopNativeMenuBackend
414// ============================================================
415
416/// Backend that renders nothing. Used on platforms without a native-menu
417/// implementation (everything except macOS today) so cross-platform code that
418/// installs a native menu compiles and runs — the in-window `MenuBar` remains
419/// the menu surface there.
420#[derive(Default)]
421pub struct NoopNativeMenuBackend;
422
423impl NoopNativeMenuBackend {
424    /// Build the no-op backend.
425    pub fn new() -> Self {
426        Self
427    }
428}
429
430impl NativeMenuBackend for NoopNativeMenuBackend {
431    fn set_window_menu(
432        &mut self,
433        _window_id: TeksiloWindowId,
434        _menu: NativeMenuSnapshot,
435        _poster: Arc<dyn AppEventPoster>,
436    ) {
437    }
438    fn activate_window(&mut self, _window_id: TeksiloWindowId) {}
439    fn clear_window(&mut self, _window_id: TeksiloWindowId) {}
440    fn update_item(&mut self, _id: MenuItemId, _delta: MenuItemDelta) {}
441}
442
443// ============================================================
444// Default backend factory
445// ============================================================
446
447/// The default native-menu backend for the current target: macOS gets the real
448/// `NSMenu` backend, every other target gets [`NoopNativeMenuBackend`].
449pub fn default_backend() -> Box<dyn NativeMenuBackend> {
450    #[cfg(target_os = "macos")]
451    {
452        Box::new(macos::MacOsNativeMenuBackend::new())
453    }
454    #[cfg(not(target_os = "macos"))]
455    {
456        Box::new(NoopNativeMenuBackend::new())
457    }
458}
459
460// ============================================================
461// MemoryNativeMenuBackend (test backend)
462// ============================================================
463
464/// Recording backend for headless tests. Captures the snapshot set per window,
465/// which window is active, item deltas, and cleared windows. Cloneable; clones
466/// share the recording so a test can keep a clone after handing one to
467/// [`NativeMenuHandle::new`].
468#[derive(Clone, Default)]
469pub struct MemoryNativeMenuBackend {
470    inner: Rc<RefCell<MemoryRecording>>,
471}
472
473#[derive(Default)]
474struct MemoryRecording {
475    menus: HashMap<TeksiloWindowId, NativeMenuSnapshot>,
476    active: Option<TeksiloWindowId>,
477    deltas: Vec<(MenuItemId, MenuItemDelta)>,
478    cleared: Vec<TeksiloWindowId>,
479}
480
481impl MemoryNativeMenuBackend {
482    /// Build a new empty recording backend.
483    pub fn new() -> Self {
484        Self::default()
485    }
486
487    /// The snapshot currently set for `window_id`, if any.
488    pub fn menu_for(&self, window_id: TeksiloWindowId) -> Option<NativeMenuSnapshot> {
489        self.inner.borrow().menus.get(&window_id).cloned()
490    }
491
492    /// The window whose menu is active (last `activate_window`, or the window
493    /// of the first `set_window_menu` if none was activated).
494    pub fn active_window(&self) -> Option<TeksiloWindowId> {
495        self.inner.borrow().active
496    }
497
498    /// All item deltas applied so far, in order.
499    pub fn deltas(&self) -> Vec<(MenuItemId, MenuItemDelta)> {
500        self.inner.borrow().deltas.clone()
501    }
502
503    /// Windows whose menus were cleared, in order.
504    pub fn cleared(&self) -> Vec<TeksiloWindowId> {
505        self.inner.borrow().cleared.clone()
506    }
507}
508
509impl NativeMenuBackend for MemoryNativeMenuBackend {
510    fn set_window_menu(
511        &mut self,
512        window_id: TeksiloWindowId,
513        menu: NativeMenuSnapshot,
514        _poster: Arc<dyn AppEventPoster>,
515    ) {
516        let mut rec = self.inner.borrow_mut();
517        rec.menus.insert(window_id, menu);
518        // First menu set becomes active by default (mirrors the real backend
519        // installing the first window's menu as mainMenu).
520        if rec.active.is_none() {
521            rec.active = Some(window_id);
522        }
523    }
524    fn activate_window(&mut self, window_id: TeksiloWindowId) {
525        self.inner.borrow_mut().active = Some(window_id);
526    }
527    fn clear_window(&mut self, window_id: TeksiloWindowId) {
528        let mut rec = self.inner.borrow_mut();
529        rec.menus.remove(&window_id);
530        rec.cleared.push(window_id);
531        if rec.active == Some(window_id) {
532            rec.active = None;
533        }
534    }
535    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
536        self.inner.borrow_mut().deltas.push((id, delta));
537    }
538}
539
540// ============================================================
541// Tests
542// ============================================================
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use std::sync::Mutex;
548    use teksilo_core::SubscriptionId;
549
550    struct NullPoster;
551    impl AppEventPoster for NullPoster {
552        fn post_subscription_event(
553            &self,
554            _sub_id: SubscriptionId,
555            _event: Box<dyn std::any::Any + Send>,
556        ) {
557        }
558        fn post_external(&self, _payload: Box<dyn std::any::Any + Send>) {}
559    }
560
561    fn poster() -> Arc<dyn AppEventPoster> {
562        Arc::new(NullPoster)
563    }
564
565    fn win(n: u64) -> TeksiloWindowId {
566        TeksiloWindowId::new(n)
567    }
568
569    fn sample_snapshot(id: MenuItemId) -> NativeMenuSnapshot {
570        NativeMenuSnapshot {
571            roots: vec![NativeMenuNode::Submenu {
572                title: "File".into(),
573                children: vec![NativeMenuNode::Item {
574                    id,
575                    title: "New".into(),
576                    key_equiv: None,
577                    enabled: true,
578                    check: NativeCheck::None,
579                }],
580            }],
581        }
582    }
583
584    #[test]
585    fn set_menu_records_snapshot_and_activations() {
586        let backend = MemoryNativeMenuBackend::new();
587        let handle = NativeMenuHandle::new(backend.clone());
588        let id = MenuItemId::next();
589
590        let fired = Arc::new(Mutex::new(false));
591        let fired2 = fired.clone();
592        let mut acts = HashMap::new();
593        acts.insert(
594            id,
595            NativeMenuActivation {
596                intent: Some("app.new"),
597                action: Some(Rc::new(move |_ctx: &mut EventContext| {
598                    *fired2.lock().unwrap() = true;
599                })),
600            },
601        );
602
603        handle.set_window_menu(win(1), sample_snapshot(id), acts, poster());
604
605        assert!(backend.menu_for(win(1)).is_some());
606        assert_eq!(backend.active_window(), Some(win(1)));
607        let act = handle.activation(win(1), id).expect("activation recorded");
608        assert_eq!(act.intent, Some("app.new"));
609        assert!(act.action.is_some());
610    }
611
612    #[test]
613    fn activate_and_clear_window() {
614        let backend = MemoryNativeMenuBackend::new();
615        let handle = NativeMenuHandle::new(backend.clone());
616        let id = MenuItemId::next();
617        handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
618        handle.set_window_menu(
619            win(2),
620            sample_snapshot(MenuItemId::next()),
621            HashMap::new(),
622            poster(),
623        );
624
625        handle.activate_window(win(2));
626        assert_eq!(backend.active_window(), Some(win(2)));
627
628        handle.clear_window(win(2));
629        assert_eq!(backend.cleared(), vec![win(2)]);
630        assert!(handle.activation(win(2), id).is_none());
631        assert!(backend.menu_for(win(2)).is_none());
632    }
633
634    #[test]
635    fn update_item_records_delta() {
636        let backend = MemoryNativeMenuBackend::new();
637        let handle = NativeMenuHandle::new(backend.clone());
638        let id = MenuItemId::next();
639        handle.update_item(
640            id,
641            MenuItemDelta {
642                enabled: Some(false),
643                check: Some(NativeCheck::On),
644                ..Default::default()
645            },
646        );
647        let deltas = backend.deltas();
648        assert_eq!(deltas.len(), 1);
649        assert_eq!(deltas[0].0, id);
650        assert_eq!(deltas[0].1.enabled, Some(false));
651        assert_eq!(deltas[0].1.check, Some(NativeCheck::On));
652    }
653
654    #[test]
655    fn noop_backend_is_inert() {
656        let handle = NativeMenuHandle::new(NoopNativeMenuBackend::new());
657        let id = MenuItemId::next();
658        handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
659        handle.activate_window(win(1));
660        handle.update_item(id, MenuItemDelta::default());
661        handle.clear_window(win(1));
662        // No activation was recorded for noop set? Activations live in the
663        // handle, not the backend, so they ARE recorded then cleared.
664        assert!(handle.activation(win(1), id).is_none());
665    }
666}