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}