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).
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/// A menu item's direct activation closure.
238pub type MenuActionFn = Rc<dyn Fn(&mut EventContext)>;
239
240/// `Rc`), so the router can pull a copy out of the handle and run it.
241#[derive(Clone, Default)]
242pub struct NativeMenuActivation {
243    /// Fire this intent by name through the `Action`/`Intent` pipeline.
244    pub intent: Option<&'static str>,
245    /// Or run this closure directly (the escape hatch). Runs after `intent`.
246    pub action: Option<MenuActionFn>,
247}
248
249impl std::fmt::Debug for NativeMenuActivation {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.debug_struct("NativeMenuActivation")
252            .field("intent", &self.intent)
253            .field("action", &self.action.as_ref().map(|_| "<closure>"))
254            .finish()
255    }
256}
257
258// ============================================================
259// Event payload
260// ============================================================
261
262/// Boxed inside `AppEvent::External` when the user picks a native menu item.
263/// `teksilo-app` downcasts to this and routes the [`MenuItemId`] back to the
264/// originating window's tree.
265#[derive(Debug, Clone)]
266pub struct NativeMenuEventPayload {
267    /// The window whose menu was active when the item was chosen.
268    pub window_id_owner: TeksiloWindowId,
269    /// The chosen item.
270    pub item_id: MenuItemId,
271}
272
273// ============================================================
274// Backend trait
275// ============================================================
276
277/// Swappable native-menu backend. One instance serves the whole app.
278pub trait NativeMenuBackend {
279    /// Build (or replace) the native menu for `window_id` from `menu`. For
280    /// every item the user later chooses, the backend MUST post a
281    /// [`NativeMenuEventPayload`] — with `window_id_owner == window_id` —
282    /// through `poster`. If `window_id` is (or becomes) the active window, the
283    /// backend should also make this menu the visible one.
284    fn set_window_menu(
285        &mut self,
286        window_id: TeksiloWindowId,
287        menu: NativeMenuSnapshot,
288        poster: Arc<dyn AppEventPoster>,
289    );
290
291    /// Make `window_id`'s previously-set menu the active/visible one (focus
292    /// follows window). No-op if that window never set a menu.
293    fn activate_window(&mut self, window_id: TeksiloWindowId);
294
295    /// Forget `window_id`'s menu (window closed).
296    fn clear_window(&mut self, window_id: TeksiloWindowId);
297
298    /// Apply a reactive delta to a single already-installed item.
299    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta);
300}
301
302/// Forward through a boxed backend so `NativeMenuHandle::new(default_backend())`
303/// type-checks.
304impl NativeMenuBackend for Box<dyn NativeMenuBackend> {
305    fn set_window_menu(
306        &mut self,
307        window_id: TeksiloWindowId,
308        menu: NativeMenuSnapshot,
309        poster: Arc<dyn AppEventPoster>,
310    ) {
311        (**self).set_window_menu(window_id, menu, poster)
312    }
313    fn activate_window(&mut self, window_id: TeksiloWindowId) {
314        (**self).activate_window(window_id)
315    }
316    fn clear_window(&mut self, window_id: TeksiloWindowId) {
317        (**self).clear_window(window_id)
318    }
319    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
320        (**self).update_item(id, delta)
321    }
322}
323
324// ============================================================
325// NativeMenuHandle
326// ============================================================
327
328/// Per-window map from item id to its activation.
329type WindowActivations = HashMap<MenuItemId, NativeMenuActivation>;
330
331struct NativeMenuState {
332    backend: RefCell<Box<dyn NativeMenuBackend>>,
333    /// Per-window: item id → what to do when chosen.
334    activations: RefCell<HashMap<TeksiloWindowId, WindowActivations>>,
335}
336
337/// Per-app native-menu service. Registered in app-state by
338/// `TeksiloAppBuilder::install_native_menu` (or `.app_state(NativeMenuHandle::new(..))`
339/// for a custom backend). Cloneable; clones share one backend + activation map.
340#[derive(Clone)]
341pub struct NativeMenuHandle {
342    inner: Rc<NativeMenuState>,
343}
344
345impl NativeMenuHandle {
346    /// Build a handle wrapping the given backend.
347    pub fn new<B: NativeMenuBackend + 'static>(backend: B) -> Self {
348        Self {
349            inner: Rc::new(NativeMenuState {
350                backend: RefCell::new(Box::new(backend)),
351                activations: RefCell::new(HashMap::new()),
352            }),
353        }
354    }
355
356    /// Install `window_id`'s menu, recording the per-item activations so a later
357    /// click can be routed. Replaces any prior menu for that window.
358    pub fn set_window_menu(
359        &self,
360        window_id: TeksiloWindowId,
361        menu: NativeMenuSnapshot,
362        activations: HashMap<MenuItemId, NativeMenuActivation>,
363        poster: Arc<dyn AppEventPoster>,
364    ) {
365        self.inner
366            .activations
367            .borrow_mut()
368            .insert(window_id, activations);
369        self.inner
370            .backend
371            .borrow_mut()
372            .set_window_menu(window_id, menu, poster);
373    }
374
375    /// Make `window_id`'s menu the visible one (focus-follows-window).
376    pub fn activate_window(&self, window_id: TeksiloWindowId) {
377        self.inner.backend.borrow_mut().activate_window(window_id);
378    }
379
380    /// Forget a window's menu + activations (window closed).
381    pub fn clear_window(&self, window_id: TeksiloWindowId) {
382        self.inner.activations.borrow_mut().remove(&window_id);
383        self.inner.backend.borrow_mut().clear_window(window_id);
384    }
385
386    /// Apply a reactive delta to one installed item.
387    pub fn update_item(&self, id: MenuItemId, delta: MenuItemDelta) {
388        self.inner.backend.borrow_mut().update_item(id, delta);
389    }
390
391    /// Look up (and clone) the activation for a chosen item, for the router.
392    pub fn activation(
393        &self,
394        window_id: TeksiloWindowId,
395        id: MenuItemId,
396    ) -> Option<NativeMenuActivation> {
397        self.inner
398            .activations
399            .borrow()
400            .get(&window_id)
401            .and_then(|m| m.get(&id).cloned())
402    }
403}
404
405impl std::fmt::Debug for NativeMenuHandle {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        f.debug_struct("NativeMenuHandle")
408            .field("windows", &self.inner.activations.borrow().len())
409            .finish_non_exhaustive()
410    }
411}
412
413// ============================================================
414// NoopNativeMenuBackend
415// ============================================================
416
417/// Backend that renders nothing. Used on platforms without a native-menu
418/// implementation (everything except macOS today) so cross-platform code that
419/// installs a native menu compiles and runs — the in-window `MenuBar` remains
420/// the menu surface there.
421#[derive(Default)]
422pub struct NoopNativeMenuBackend;
423
424impl NoopNativeMenuBackend {
425    /// Build the no-op backend.
426    pub fn new() -> Self {
427        Self
428    }
429}
430
431impl NativeMenuBackend for NoopNativeMenuBackend {
432    fn set_window_menu(
433        &mut self,
434        _window_id: TeksiloWindowId,
435        _menu: NativeMenuSnapshot,
436        _poster: Arc<dyn AppEventPoster>,
437    ) {
438    }
439    fn activate_window(&mut self, _window_id: TeksiloWindowId) {}
440    fn clear_window(&mut self, _window_id: TeksiloWindowId) {}
441    fn update_item(&mut self, _id: MenuItemId, _delta: MenuItemDelta) {}
442}
443
444// ============================================================
445// Default backend factory
446// ============================================================
447
448/// The default native-menu backend for the current target: macOS gets the real
449/// `NSMenu` backend, every other target gets [`NoopNativeMenuBackend`].
450pub fn default_backend() -> Box<dyn NativeMenuBackend> {
451    #[cfg(target_os = "macos")]
452    {
453        Box::new(macos::MacOsNativeMenuBackend::new())
454    }
455    #[cfg(not(target_os = "macos"))]
456    {
457        Box::new(NoopNativeMenuBackend::new())
458    }
459}
460
461// ============================================================
462// MemoryNativeMenuBackend (test backend)
463// ============================================================
464
465/// Recording backend for headless tests. Captures the snapshot set per window,
466/// which window is active, item deltas, and cleared windows. Cloneable; clones
467/// share the recording so a test can keep a clone after handing one to
468/// [`NativeMenuHandle::new`].
469#[derive(Clone, Default)]
470pub struct MemoryNativeMenuBackend {
471    inner: Rc<RefCell<MemoryRecording>>,
472}
473
474#[derive(Default)]
475struct MemoryRecording {
476    menus: HashMap<TeksiloWindowId, NativeMenuSnapshot>,
477    active: Option<TeksiloWindowId>,
478    deltas: Vec<(MenuItemId, MenuItemDelta)>,
479    cleared: Vec<TeksiloWindowId>,
480}
481
482impl MemoryNativeMenuBackend {
483    /// Build a new empty recording backend.
484    pub fn new() -> Self {
485        Self::default()
486    }
487
488    /// The snapshot currently set for `window_id`, if any.
489    pub fn menu_for(&self, window_id: TeksiloWindowId) -> Option<NativeMenuSnapshot> {
490        self.inner.borrow().menus.get(&window_id).cloned()
491    }
492
493    /// The window whose menu is active (last `activate_window`, or the window
494    /// of the most recent `set_window_menu` if none was activated).
495    pub fn active_window(&self) -> Option<TeksiloWindowId> {
496        self.inner.borrow().active
497    }
498
499    /// All item deltas applied so far, in order.
500    pub fn deltas(&self) -> Vec<(MenuItemId, MenuItemDelta)> {
501        self.inner.borrow().deltas.clone()
502    }
503
504    /// Windows whose menus were cleared, in order.
505    pub fn cleared(&self) -> Vec<TeksiloWindowId> {
506        self.inner.borrow().cleared.clone()
507    }
508}
509
510impl NativeMenuBackend for MemoryNativeMenuBackend {
511    fn set_window_menu(
512        &mut self,
513        window_id: TeksiloWindowId,
514        menu: NativeMenuSnapshot,
515        _poster: Arc<dyn AppEventPoster>,
516    ) {
517        let mut rec = self.inner.borrow_mut();
518        rec.menus.insert(window_id, menu);
519        // First menu set becomes active by default (mirrors the real backend
520        // installing the first window's menu as mainMenu).
521        if rec.active.is_none() {
522            rec.active = Some(window_id);
523        }
524    }
525    fn activate_window(&mut self, window_id: TeksiloWindowId) {
526        self.inner.borrow_mut().active = Some(window_id);
527    }
528    fn clear_window(&mut self, window_id: TeksiloWindowId) {
529        let mut rec = self.inner.borrow_mut();
530        rec.menus.remove(&window_id);
531        rec.cleared.push(window_id);
532        if rec.active == Some(window_id) {
533            rec.active = None;
534        }
535    }
536    fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
537        self.inner.borrow_mut().deltas.push((id, delta));
538    }
539}
540
541// ============================================================
542// Tests
543// ============================================================
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use std::sync::Mutex;
549    use teksilo_core::SubscriptionId;
550
551    struct NullPoster;
552    impl AppEventPoster for NullPoster {
553        fn post_subscription_event(
554            &self,
555            _sub_id: SubscriptionId,
556            _event: Box<dyn std::any::Any + Send>,
557        ) {
558        }
559        fn post_external(&self, _payload: Box<dyn std::any::Any + Send>) {}
560    }
561
562    fn poster() -> Arc<dyn AppEventPoster> {
563        Arc::new(NullPoster)
564    }
565
566    fn win(n: u64) -> TeksiloWindowId {
567        TeksiloWindowId::new(n)
568    }
569
570    fn sample_snapshot(id: MenuItemId) -> NativeMenuSnapshot {
571        NativeMenuSnapshot {
572            roots: vec![NativeMenuNode::Submenu {
573                title: "File".into(),
574                children: vec![NativeMenuNode::Item {
575                    id,
576                    title: "New".into(),
577                    key_equiv: None,
578                    enabled: true,
579                    check: NativeCheck::None,
580                }],
581            }],
582        }
583    }
584
585    #[test]
586    fn set_menu_records_snapshot_and_activations() {
587        let backend = MemoryNativeMenuBackend::new();
588        let handle = NativeMenuHandle::new(backend.clone());
589        let id = MenuItemId::next();
590
591        let fired = Arc::new(Mutex::new(false));
592        let fired2 = fired.clone();
593        let mut acts = HashMap::new();
594        acts.insert(
595            id,
596            NativeMenuActivation {
597                intent: Some("app.new"),
598                action: Some(Rc::new(move |_ctx: &mut EventContext| {
599                    *fired2.lock().unwrap() = true;
600                })),
601            },
602        );
603
604        handle.set_window_menu(win(1), sample_snapshot(id), acts, poster());
605
606        assert!(backend.menu_for(win(1)).is_some());
607        assert_eq!(backend.active_window(), Some(win(1)));
608        let act = handle.activation(win(1), id).expect("activation recorded");
609        assert_eq!(act.intent, Some("app.new"));
610        assert!(act.action.is_some());
611    }
612
613    #[test]
614    fn activate_and_clear_window() {
615        let backend = MemoryNativeMenuBackend::new();
616        let handle = NativeMenuHandle::new(backend.clone());
617        let id = MenuItemId::next();
618        handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
619        handle.set_window_menu(
620            win(2),
621            sample_snapshot(MenuItemId::next()),
622            HashMap::new(),
623            poster(),
624        );
625
626        handle.activate_window(win(2));
627        assert_eq!(backend.active_window(), Some(win(2)));
628
629        handle.clear_window(win(2));
630        assert_eq!(backend.cleared(), vec![win(2)]);
631        assert!(handle.activation(win(2), id).is_none());
632        assert!(backend.menu_for(win(2)).is_none());
633    }
634
635    #[test]
636    fn update_item_records_delta() {
637        let backend = MemoryNativeMenuBackend::new();
638        let handle = NativeMenuHandle::new(backend.clone());
639        let id = MenuItemId::next();
640        handle.update_item(
641            id,
642            MenuItemDelta {
643                enabled: Some(false),
644                check: Some(NativeCheck::On),
645                ..Default::default()
646            },
647        );
648        let deltas = backend.deltas();
649        assert_eq!(deltas.len(), 1);
650        assert_eq!(deltas[0].0, id);
651        assert_eq!(deltas[0].1.enabled, Some(false));
652        assert_eq!(deltas[0].1.check, Some(NativeCheck::On));
653    }
654
655    #[test]
656    fn noop_backend_is_inert() {
657        let handle = NativeMenuHandle::new(NoopNativeMenuBackend::new());
658        let id = MenuItemId::next();
659        handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
660        handle.activate_window(win(1));
661        handle.update_item(id, MenuItemDelta::default());
662        handle.clear_window(win(1));
663        // No activation was recorded for noop set? Activations live in the
664        // handle, not the backend, so they ARE recorded then cleared.
665        assert!(handle.activation(win(1), id).is_none());
666    }
667}