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}