teksilo_platform/native_menu.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Native (OS) menu service.
5//!
6//! Mirrors a logical menu tree (the `teksilo-widgets` `MenuModel`) into the
7//! platform's *native* menu surface — the global menu bar at the top of the
8//! screen on macOS (`NSApplication.mainMenu`), and, in the future, an `HMENU`
9//! on Windows or a DBus app-menu on Linux. A serious desktop app is expected to
10//! present its menus this way on macOS; an in-window menu strip alone reads as
11//! non-native.
12//!
13//! Three concerns are separated, mirroring [`crate::file_dialog`] and
14//! [`crate::external_dnd`]:
15//!
16//! - **Boundary data** — [`NativeMenuSnapshot`] is a plain, already-resolved
17//! description of the whole tree (display strings, key equivalents, enabled /
18//! check state, stable [`MenuItemId`]s). It carries no widgets, signals, or
19//! localized strings — the widget layer resolves all of that before handing a
20//! snapshot down, so `teksilo-platform` never depends on `teksilo-widgets`.
21//! - **Trait surface** — [`NativeMenuBackend`] is the swappable platform
22//! abstraction (macOS `NSMenu`; [`NoopNativeMenuBackend`] elsewhere).
23//! - **Handle** — [`NativeMenuHandle`] is the per-app service registered in
24//! app-state. It owns the backend and, per window, the map from
25//! [`MenuItemId`] to the action to run when that item is chosen.
26//!
27//! # Activation routing
28//!
29//! When the user picks a native menu item, the backend posts a
30//! [`NativeMenuEventPayload`] through [`teksilo_core::AppEventPoster::post_external`].
31//! `teksilo-app` picks it up in its `AppEvent::External` arm and looks the
32//! [`MenuItemId`] up in the [`NativeMenuHandle`]. It runs the item's action
33//! in the window whose menu it is (in the application tree for an item of the
34//! application menu), and sends the item's intent from the focused widget of
35//! the window with the OS focus, as the item's shortcut would be; see
36//! `teksilo-app`'s `native_menu_route`.
37//!
38//! # The application menu and the windows' menus
39//!
40//! On macOS there is exactly one global menu bar. It shows the
41//! **application menu** ([`NativeMenuHandle::set_app_menu`]), which
42//! `teksilo-app` installs from the application tree, whenever the key window
43//! has no menu of its own and while no window is open. A window with a menu
44//! of its own ([`NativeMenuHandle::set_window_menu`], from a `MenuBar` built
45//! with `MenuBar::from_model`) replaces it while that window is key.
46//! `teksilo-app` calls [`NativeMenuHandle::activate_window`] on every focus
47//! change, which picks between the two.
48
49use std::cell::RefCell;
50use std::collections::HashMap;
51use std::rc::Rc;
52use std::sync::Arc;
53
54use teksilo_core::AppEventPoster;
55use teksilo_core::MenuItemId;
56use teksilo_core::widget::EventContext;
57use teksilo_core::window::TeksiloWindowId;
58
59#[cfg(target_os = "macos")]
60mod macos;
61
62// ============================================================
63// Snapshot data (the platform boundary type)
64// ============================================================
65
66/// On/off/mixed state for a checkable native menu item.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum NativeCheck {
69 /// Not a checkable item — no check-mark column behaviour.
70 #[default]
71 None,
72 /// Checkable, currently unchecked.
73 Off,
74 /// Checkable, currently checked.
75 On,
76 /// Checkable, currently mixed/indeterminate (tri-state parents).
77 Mixed,
78}
79
80/// A platform-neutral key equivalent for a native menu item. Already resolved
81/// from the app's `ShortcutRegistry` by the widget layer. `key` is the base
82/// character the OS menu expects (e.g. `"s"`, `"\r"`); the booleans are the
83/// modifier flags. An item with an empty `key` displays no shortcut.
84#[derive(Debug, Clone, Default, PartialEq, Eq)]
85pub struct NativeKeyEquivalent {
86 /// The base key as the single string the native menu expects.
87 pub key: String,
88 /// Command (⌘ on macOS) / the platform's primary accelerator modifier.
89 pub command: bool,
90 /// Shift (⇧).
91 pub shift: bool,
92 /// Alt / Option (⌥).
93 pub alt: bool,
94 /// Control (⌃).
95 pub control: bool,
96}
97
98/// Standard, platform-defined menus with required placement/behaviour (the
99/// macOS App / Window / Help menus, with their About / Services / Hide /
100/// Hide Others / Show All / Quit and window-management items wired to system
101/// selectors). The backend supplies the
102/// native structure; the in-window `MenuBar` ignores these.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum StandardMenuRole {
105 /// The application menu (About, Settings, Services, Hide, Hide Others,
106 /// Show All, Quit). The macOS backend shows
107 /// it first in the bar wherever the snapshot declares it, since AppKit
108 /// shows the bar's first menu as the application menu.
109 App,
110 /// The Window menu (Minimize / Zoom / window list).
111 Window,
112 /// The Help menu.
113 Help,
114}
115
116/// Display strings for a [`StandardMenuRole`], **already localized** by the
117/// widget layer. The platform layer never hardcodes user-visible menu text — it
118/// applies whatever the snapshot carries — so a standard menu honours the app's
119/// locale (e.g. "Quitter" / "Masquer" on a French system) instead of leaking
120/// English literals onto the most visible native surface.
121#[derive(Debug, Clone, Default)]
122pub struct StandardLabels {
123 /// Submenu title (Window / Help; the App submenu typically uses the app name).
124 pub title: String,
125 /// "About …" (App).
126 pub about: String,
127 /// "Settings…" / "Preferences…" (App). Only rendered when the snapshot
128 /// also carries a `settings_item` — the platform has no default action
129 /// for it, unlike About, Hide and the rest.
130 pub settings: String,
131 /// "Services" (App): the submenu AppKit fills with the services that
132 /// apply to the current selection.
133 pub services: String,
134 /// "Hide …" (App).
135 pub hide: String,
136 /// "Hide Others" (App).
137 pub hide_others: String,
138 /// "Show All" (App).
139 pub show_all: String,
140 /// "Quit …" (App).
141 pub quit: String,
142 /// "Minimize" (Window).
143 pub minimize: String,
144 /// "Zoom" (Window).
145 pub zoom: String,
146}
147
148/// A row inside a platform-standard menu that the app routes rather than the
149/// platform selects — Quit and Settings today.
150///
151/// Carries the key equivalent alongside the id because the widget layer is the
152/// only place that knows it. Every other item's chord comes from the
153/// `ShortcutRegistry` there, resolved through the primary-accelerator
154/// convention; if the platform layer picked one for these two it would be the
155/// one surface in the app advertising a chord nobody registered — live even
156/// after the user rebound the command, and immune to the rewriting the registry
157/// does for everything else. `None` means no key equivalent at all.
158#[derive(Debug, Clone)]
159pub struct StandardRoutedItem {
160 /// Correlates the native item back to the logical one on activation.
161 pub id: MenuItemId,
162 /// Key equivalent to advertise, already resolved.
163 pub key_equiv: Option<NativeKeyEquivalent>,
164}
165
166/// One node of a native menu tree.
167// A standard menu's labels make it the large variant. A snapshot is built
168// once per install and holds a handful of standard menus, so boxing them
169// would cost every construction for no measurable saving.
170#[allow(clippy::large_enum_variant)]
171#[derive(Debug, Clone)]
172pub enum NativeMenuNode {
173 /// A leaf command.
174 Item {
175 /// Correlates the native item back to the logical one on activation.
176 id: MenuItemId,
177 /// Display text (mnemonics already stripped, locale already resolved).
178 title: String,
179 /// Key equivalent, if any.
180 key_equiv: Option<NativeKeyEquivalent>,
181 /// Whether the item is enabled.
182 enabled: bool,
183 /// Whether the item is shown. A hidden item keeps its place, so a
184 /// [`MenuItemDelta`] can show it again without rebuilding the menu.
185 visible: bool,
186 /// Check-mark state.
187 check: NativeCheck,
188 },
189 /// A submenu with its own children.
190 Submenu {
191 /// Submenu title.
192 title: String,
193 /// Child nodes.
194 children: Vec<NativeMenuNode>,
195 },
196 /// A separator line.
197 Separator,
198 /// A platform-standard menu the backend fills in, with localized chrome.
199 Standard {
200 /// Which standard menu.
201 role: StandardMenuRole,
202 /// Localized display strings (supplied by the widget layer).
203 labels: StandardLabels,
204 /// App menu only: build **Quit** as an ordinary routed item under this
205 /// id (the activation recorded for it, and the key equivalent the
206 /// widget layer resolved), instead of the platform's own terminate
207 /// selector.
208 ///
209 /// The widget layer always routes it: the activation sends the app's
210 /// quit intent, and asks the application to quit through every
211 /// window's close guard when nothing takes the intent or there is none
212 /// ([`NativeMenuActivation::quits`]). A main-menu key equivalent is
213 /// dispatched by the platform before the responder chain, so an
214 /// app-side ⌘Q never sees the keystroke; and the platform's own item
215 /// (`terminate:`, which asks the quit through `os_quit` when
216 /// `teksilo-app` runs the loop) could not send the app's quit intent.
217 ///
218 /// `None` binds the item to the platform's terminate selector
219 /// (`terminate:` on macOS), for a snapshot built by other means.
220 quit_item: Option<StandardRoutedItem>,
221 /// App menu only: build a **Settings…** item under this id, placed where
222 /// the platform expects it (on macOS: after About), with the key
223 /// equivalent the widget layer resolved.
224 ///
225 /// Unlike Quit there is no `None` fallback that still does something —
226 /// no platform ships a default action for opening an app's settings —
227 /// so `None` simply omits the item. An app that has a settings window
228 /// routes it; one that has none leaves the slot empty rather than
229 /// showing a row that does nothing.
230 settings_item: Option<StandardRoutedItem>,
231 },
232}
233
234/// A complete, resolved description of one window's menu tree.
235#[derive(Debug, Clone, Default)]
236pub struct NativeMenuSnapshot {
237 /// The top-level menus (each typically a [`NativeMenuNode::Submenu`] or a
238 /// [`NativeMenuNode::Standard`]).
239 pub roots: Vec<NativeMenuNode>,
240}
241
242/// A reactive change to a single already-installed native item, applied without
243/// rebuilding the whole menu. Each `Some` field replaces that property.
244#[derive(Debug, Clone, Default)]
245pub struct MenuItemDelta {
246 /// New enabled state.
247 pub enabled: Option<bool>,
248 /// New shown state.
249 pub visible: Option<bool>,
250 /// New check state.
251 pub check: Option<NativeCheck>,
252 /// New display title.
253 pub title: Option<String>,
254 /// New key equivalent (`Some(None)` clears it; `None` leaves it unchanged).
255 pub key_equiv: Option<Option<NativeKeyEquivalent>>,
256}
257
258// ============================================================
259// Activation (kept on the app side of the boundary)
260// ============================================================
261
262/// What to do when a native menu item is chosen. Cloneable (the action is an
263/// `Rc`), so the router can pull a copy out of the handle and run it.
264pub type MenuActionFn = Rc<dyn Fn(&mut EventContext)>;
265
266/// What choosing a native menu item does: an intent, an action, or both.
267///
268/// `teksilo-app` runs `action` where the state it captured lives (the window
269/// whose menu the item is in, or the application tree for an item of the
270/// application menu), then sends `intent` from the focused widget of the
271/// window with the OS focus, which may be another one (a dialog with no menu
272/// of its own), or in the application tree when no window has the focus.
273#[derive(Clone, Default)]
274pub struct NativeMenuActivation {
275 /// Fire this intent by name through the `Action`/`Intent` pipeline, from
276 /// the focused widget of the window with the OS focus. Dispatched after
277 /// `action` has run.
278 pub intent: Option<&'static str>,
279 /// Or run this closure directly (the escape hatch), in the window whose
280 /// menu the item is in, anchored at that window's root, or in the
281 /// application tree for an item of the application menu.
282 pub action: Option<MenuActionFn>,
283 /// The App menu's Quit row: when nothing on the route takes `intent`, or
284 /// there is none, `teksilo-app` asks the application to quit, through
285 /// every window's close guard, instead.
286 pub quits: bool,
287}
288
289impl std::fmt::Debug for NativeMenuActivation {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 f.debug_struct("NativeMenuActivation")
292 .field("intent", &self.intent)
293 .field("action", &self.action.as_ref().map(|_| "<closure>"))
294 .field("quits", &self.quits)
295 .finish()
296 }
297}
298
299// ============================================================
300// Event payload
301// ============================================================
302
303/// Boxed inside `AppEvent::External` when the user picks a native menu item.
304/// `teksilo-app` downcasts to this, runs the item's action in the window whose
305/// menu it is, and sends its intent from the window with the OS focus (see
306/// the [module](self) doc).
307#[derive(Debug, Clone)]
308pub struct NativeMenuEventPayload {
309 /// The window whose menu was active when the item was chosen.
310 pub window_id_owner: TeksiloWindowId,
311 /// The chosen item.
312 pub item_id: MenuItemId,
313}
314
315/// Boxed inside `AppEvent::External` when the user picks an item of the
316/// [application menu](NativeMenuBackend::set_app_menu), the menu shown while
317/// no window's own menu is. `teksilo-app` runs the item's action in the
318/// application tree and sends its intent from the focus of the window with
319/// the OS focus, or in the application tree when none has it.
320#[derive(Debug, Clone)]
321pub struct NativeAppMenuEventPayload {
322 /// The chosen item.
323 pub item_id: MenuItemId,
324}
325
326// ============================================================
327// Backend trait
328// ============================================================
329
330/// Swappable native-menu backend. One instance serves the whole app.
331pub trait NativeMenuBackend {
332 /// Build (or replace) the native menu of `window_id`, a window with a
333 /// menu of its own, from `menu`. For every item the user later chooses,
334 /// the backend MUST post a [`NativeMenuEventPayload`], with
335 /// `window_id_owner == window_id`, through `poster`. If `window_id` is
336 /// the key window (the last one [`activate_window`](Self::activate_window)
337 /// named), or no window has been activated yet, the backend shows this
338 /// menu.
339 fn set_window_menu(
340 &mut self,
341 window_id: TeksiloWindowId,
342 menu: NativeMenuSnapshot,
343 poster: Arc<dyn AppEventPoster>,
344 );
345
346 /// `window_id` became the key window (focus follows window): show the
347 /// bar [`key_window_bar`] picks, its own menu if it set one, else the
348 /// menu of the window it belongs to (see
349 /// [`set_window_owner`](Self::set_window_owner)), else the
350 /// [application menu](Self::set_app_menu), or, when the application
351 /// declared none, the bar shown before.
352 fn activate_window(&mut self, window_id: TeksiloWindowId);
353
354 /// `window_id` belongs to `owner` (a modal dialog to the window it
355 /// blocks), or to no window (`None`). While `window_id` is key and has
356 /// no menu of its own, the bar shows its owner's, as AppKit keeps the
357 /// menu bar for a panel or a sheet. `teksilo-app` calls it as it opens a
358 /// modal window; the relation goes with
359 /// [`clear_window`](Self::clear_window). The default ignores it, for a
360 /// backend whose menus belong to windows alone.
361 #[doc(hidden)]
362 fn set_window_owner(&mut self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
363 let _ = (window_id, owner);
364 }
365
366 /// Forget `window_id`'s menu (the window closed, or no longer has a menu
367 /// of its own). If it was showing, show the
368 /// [application menu](Self::set_app_menu) until another window becomes
369 /// key.
370 fn clear_window(&mut self, window_id: TeksiloWindowId);
371
372 /// Apply a reactive delta to a single already-installed item, in every
373 /// menu that carries it, the application menu included.
374 fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta);
375
376 /// Whether this backend shows a menu bar the user can open. `false` for
377 /// one that renders nothing ([`NoopNativeMenuBackend`]): `teksilo-app`
378 /// then keeps no row's availability up to date.
379 #[doc(hidden)]
380 fn shows_bar(&self) -> bool {
381 true
382 }
383
384 /// Set the application menu: the menu shown whenever the key window has
385 /// no menu of its own nor an owner with one, and while no window is
386 /// open. `teksilo-app` sets it from the application tree at startup and
387 /// again whenever it changes; it replaces any earlier one, and the bar
388 /// is chosen again for the key window, as
389 /// [`activate_window`](Self::activate_window) chooses it: an
390 /// application menu declared while a window
391 /// with no menu of its own is key shows at once. `declared`
392 /// says whether the application declared it: `false` for the default
393 /// App menu `teksilo-app` supplies when it declared none, which a key
394 /// window without a menu does not bring up (see [`key_window_bar`]).
395 /// For every item the user later chooses in it, the backend MUST post a
396 /// [`NativeAppMenuEventPayload`] through `poster`. The default does
397 /// nothing, for a backend whose menus belong to windows alone.
398 fn set_app_menu(
399 &mut self,
400 menu: NativeMenuSnapshot,
401 poster: Arc<dyn AppEventPoster>,
402 declared: bool,
403 ) {
404 let _ = (menu, poster, declared);
405 }
406}
407
408/// Forward through a boxed backend so `NativeMenuHandle::new(default_backend())`
409/// type-checks.
410impl NativeMenuBackend for Box<dyn NativeMenuBackend> {
411 fn set_window_menu(
412 &mut self,
413 window_id: TeksiloWindowId,
414 menu: NativeMenuSnapshot,
415 poster: Arc<dyn AppEventPoster>,
416 ) {
417 (**self).set_window_menu(window_id, menu, poster)
418 }
419 fn activate_window(&mut self, window_id: TeksiloWindowId) {
420 (**self).activate_window(window_id)
421 }
422 fn set_window_owner(&mut self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
423 (**self).set_window_owner(window_id, owner)
424 }
425 fn clear_window(&mut self, window_id: TeksiloWindowId) {
426 (**self).clear_window(window_id)
427 }
428 fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
429 (**self).update_item(id, delta)
430 }
431 fn shows_bar(&self) -> bool {
432 (**self).shows_bar()
433 }
434 fn set_app_menu(
435 &mut self,
436 menu: NativeMenuSnapshot,
437 poster: Arc<dyn AppEventPoster>,
438 declared: bool,
439 ) {
440 (**self).set_app_menu(menu, poster, declared)
441 }
442}
443
444// ============================================================
445// NativeMenuHandle
446// ============================================================
447
448/// Per-window map from item id to its activation.
449type WindowActivations = HashMap<MenuItemId, NativeMenuActivation>;
450
451/// A row whose only effect is its intent, enabled only while something on
452/// the intent's route takes it. See [`NativeMenuHandle::refresh_availability`].
453struct Gate {
454 intent: &'static str,
455 /// The row's own enabled state (its `enabled` signal).
456 own: bool,
457 /// Whether the route took the intent at the last refresh.
458 handled: bool,
459 /// What the backend was last told.
460 shown: bool,
461}
462
463struct NativeMenuState {
464 backend: RefCell<Box<dyn NativeMenuBackend>>,
465 /// Per-window: item id → what to do when chosen.
466 activations: RefCell<HashMap<TeksiloWindowId, WindowActivations>>,
467 /// The application menu's items → what to do when chosen.
468 app_activations: RefCell<WindowActivations>,
469 /// The rows that grey out when nothing takes their intent, by id.
470 gates: RefCell<HashMap<MenuItemId, Gate>>,
471 /// The intents taken at the last refresh, `None` once a menu installed
472 /// since has gates the refresh has not evaluated: while it holds the
473 /// same answer, a refresh has nothing to do.
474 refreshed: RefCell<Option<Vec<&'static str>>>,
475 /// How many rows the refreshes evaluated, for the tests.
476 #[cfg(test)]
477 evaluations: std::cell::Cell<usize>,
478}
479
480/// Per-app native-menu service. Registered in app-state by
481/// `TeksiloAppBuilder::install_native_menu` (or `.app_state(NativeMenuHandle::new(..))`
482/// for a custom backend). Cloneable; clones share one backend + activation map.
483#[derive(Clone)]
484pub struct NativeMenuHandle {
485 inner: Rc<NativeMenuState>,
486}
487
488impl NativeMenuHandle {
489 /// Build a handle wrapping the given backend.
490 pub fn new<B: NativeMenuBackend + 'static>(backend: B) -> Self {
491 Self {
492 inner: Rc::new(NativeMenuState {
493 backend: RefCell::new(Box::new(backend)),
494 activations: RefCell::new(HashMap::new()),
495 app_activations: RefCell::new(HashMap::new()),
496 gates: RefCell::new(HashMap::new()),
497 refreshed: RefCell::new(None),
498 #[cfg(test)]
499 evaluations: std::cell::Cell::new(0),
500 }),
501 }
502 }
503
504 /// Install `window_id`'s menu, recording the per-item activations so a later
505 /// click can be routed. Replaces any prior menu for that window.
506 pub fn set_window_menu(
507 &self,
508 window_id: TeksiloWindowId,
509 menu: NativeMenuSnapshot,
510 activations: HashMap<MenuItemId, NativeMenuActivation>,
511 poster: Arc<dyn AppEventPoster>,
512 ) {
513 self.gate_rows(&menu, &activations);
514 self.inner
515 .activations
516 .borrow_mut()
517 .insert(window_id, activations);
518 self.inner
519 .backend
520 .borrow_mut()
521 .set_window_menu(window_id, menu, poster);
522 }
523
524 /// `window_id` became the key window: show the bar
525 /// [`key_window_bar`] picks for it.
526 pub fn activate_window(&self, window_id: TeksiloWindowId) {
527 self.inner.backend.borrow_mut().activate_window(window_id);
528 }
529
530 /// `window_id` belongs to `owner` (a modal dialog to the window it
531 /// blocks): see [`NativeMenuBackend::set_window_owner`].
532 #[doc(hidden)]
533 pub fn set_window_owner(&self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
534 self.inner
535 .backend
536 .borrow_mut()
537 .set_window_owner(window_id, owner);
538 }
539
540 /// Forget a window's menu + activations (window closed).
541 pub fn clear_window(&self, window_id: TeksiloWindowId) {
542 self.inner.activations.borrow_mut().remove(&window_id);
543 self.forget_dead_gates();
544 self.inner.backend.borrow_mut().clear_window(window_id);
545 }
546
547 /// Apply a reactive delta to one installed item. For a row that greys
548 /// out when nothing takes its intent, a new `enabled` is the row's own
549 /// state, and what reaches the backend is that state and whether the
550 /// intent was taken at the last
551 /// [`refresh_availability`](Self::refresh_availability).
552 pub fn update_item(&self, id: MenuItemId, mut delta: MenuItemDelta) {
553 if let Some(own) = delta.enabled
554 && let Some(gate) = self.inner.gates.borrow_mut().get_mut(&id)
555 {
556 gate.own = own;
557 gate.shown = own && gate.handled;
558 delta.enabled = Some(gate.shown);
559 }
560 self.inner.backend.borrow_mut().update_item(id, delta);
561 }
562
563 /// Grey out or enable again every installed row whose only effect is
564 /// its intent (an item with an intent, no action, and not the App menu's
565 /// Quit), from `handled`, the sorted intent names something on the route
566 /// takes now, combined with the row's own enabled state. Only a row
567 /// whose state changes reaches the backend, through
568 /// [`NativeMenuBackend::update_item`].
569 ///
570 /// `teksilo-app` calls it once per event-loop turn, with what the route
571 /// a chosen row's intent takes answers (the focused window's
572 /// `WidgetTree::handled_intents_kept_in_modal_from` its live focus, where
573 /// the row's intent starts and which a dialog open in the window keeps,
574 /// else the application tree's), so the
575 /// menu is current whenever the user opens it: focus, the key window and
576 /// the actions only change while the loop runs. With the same `handled`
577 /// as the last call and no menu installed since, nothing can have
578 /// changed (a row's own state reaches the backend through
579 /// [`update_item`](Self::update_item)), and no row is evaluated.
580 #[doc(hidden)]
581 pub fn refresh_availability(&self, handled: &[&'static str]) {
582 if self
583 .inner
584 .refreshed
585 .borrow()
586 .as_deref()
587 .is_some_and(|last| last == handled)
588 {
589 return;
590 }
591 *self.inner.refreshed.borrow_mut() = Some(handled.to_vec());
592 let mut changed = Vec::new();
593 for (id, gate) in self.inner.gates.borrow_mut().iter_mut() {
594 #[cfg(test)]
595 self.inner.evaluations.set(self.inner.evaluations.get() + 1);
596 gate.handled = handled.binary_search(&gate.intent).is_ok();
597 let shown = gate.own && gate.handled;
598 if shown != gate.shown {
599 gate.shown = shown;
600 changed.push((*id, shown));
601 }
602 }
603 let mut backend = self.inner.backend.borrow_mut();
604 for (id, enabled) in changed {
605 backend.update_item(
606 id,
607 MenuItemDelta {
608 enabled: Some(enabled),
609 ..Default::default()
610 },
611 );
612 }
613 }
614
615 /// Whether the backend shows a menu bar at all
616 /// ([`NativeMenuBackend::shows_bar`]): `teksilo-app` refreshes the rows'
617 /// availability only then.
618 #[doc(hidden)]
619 pub fn shows_bar(&self) -> bool {
620 self.inner.backend.borrow().shows_bar()
621 }
622
623 /// How many rows the availability refreshes evaluated.
624 #[cfg(test)]
625 fn evaluations(&self) -> usize {
626 self.inner.evaluations.get()
627 }
628
629 /// The enabled state the backend was last given for a row that greys
630 /// out when nothing takes its intent; `None` for any other item.
631 #[doc(hidden)]
632 pub fn gated_enabled(&self, id: MenuItemId) -> Option<bool> {
633 self.inner.gates.borrow().get(&id).map(|gate| gate.shown)
634 }
635
636 /// Record the rows of `menu` that grey out when nothing takes their
637 /// intent, enabled as `menu` installs them until the next refresh.
638 fn gate_rows(&self, menu: &NativeMenuSnapshot, activations: &WindowActivations) {
639 fn own_enabled(nodes: &[NativeMenuNode], id: MenuItemId) -> Option<bool> {
640 nodes.iter().find_map(|node| match node {
641 NativeMenuNode::Item {
642 id: item, enabled, ..
643 } if *item == id => Some(*enabled),
644 NativeMenuNode::Submenu { children, .. } => own_enabled(children, id),
645 _ => None,
646 })
647 }
648 let mut gates = self.inner.gates.borrow_mut();
649 // The new rows start enabled: the next refresh evaluates them,
650 // whatever it is told.
651 *self.inner.refreshed.borrow_mut() = None;
652 for (id, activation) in activations {
653 let (Some(intent), None, false) =
654 (activation.intent, &activation.action, activation.quits)
655 else {
656 continue;
657 };
658 let own = own_enabled(&menu.roots, *id).unwrap_or(true);
659 gates.insert(
660 *id,
661 Gate {
662 intent,
663 own,
664 handled: true,
665 shown: own,
666 },
667 );
668 }
669 }
670
671 /// Forget the gates of rows no installed menu holds any more.
672 fn forget_dead_gates(&self) {
673 let windows = self.inner.activations.borrow();
674 let app = self.inner.app_activations.borrow();
675 self.inner.gates.borrow_mut().retain(|id, _| {
676 app.contains_key(id) || windows.values().any(|menu| menu.contains_key(id))
677 });
678 }
679
680 /// Set the application menu, shown whenever the key window has no menu
681 /// of its own and while no window is open (see
682 /// [`NativeMenuBackend::set_app_menu`]), recording its activations.
683 /// Replaces any earlier one. `teksilo-app` calls it from the application
684 /// tree's declaration; a window's menu never sets it.
685 pub fn set_app_menu(
686 &self,
687 menu: NativeMenuSnapshot,
688 activations: HashMap<MenuItemId, NativeMenuActivation>,
689 poster: Arc<dyn AppEventPoster>,
690 declared: bool,
691 ) {
692 self.gate_rows(&menu, &activations);
693 *self.inner.app_activations.borrow_mut() = activations;
694 self.forget_dead_gates();
695 self.inner
696 .backend
697 .borrow_mut()
698 .set_app_menu(menu, poster, declared);
699 }
700
701 /// The activation for an item chosen in the application menu.
702 pub fn app_activation(&self, id: MenuItemId) -> Option<NativeMenuActivation> {
703 self.inner.app_activations.borrow().get(&id).cloned()
704 }
705
706 /// Look up (and clone) the activation for a chosen item, for the router.
707 pub fn activation(
708 &self,
709 window_id: TeksiloWindowId,
710 id: MenuItemId,
711 ) -> Option<NativeMenuActivation> {
712 self.inner
713 .activations
714 .borrow()
715 .get(&window_id)
716 .and_then(|m| m.get(&id).cloned())
717 }
718}
719
720impl std::fmt::Debug for NativeMenuHandle {
721 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
722 f.debug_struct("NativeMenuHandle")
723 .field("windows", &self.inner.activations.borrow().len())
724 .finish_non_exhaustive()
725 }
726}
727
728// ============================================================
729// Which bar a key window shows (pure, so every OS tests it)
730// ============================================================
731
732/// The bar to show while `key` is the key window: `Some(window)` for that
733/// window's own menu, `None` for the application menu.
734///
735/// The key window's own menu if it has one (`has_menu`); else the menu of
736/// the window it belongs to, following `owner` (a modal dialog's owner, then
737/// that window's owner); else the application menu when the application
738/// declared one (`app_declared`), or `shown`, the bar shown before, when it
739/// did not: AppKit keeps `mainMenu` up while a panel or a sheet is key, so
740/// an app that declared no application menu keeps its window's File and
741/// Edit while a dialog or a secondary window is key.
742#[doc(hidden)]
743pub fn key_window_bar(
744 key: TeksiloWindowId,
745 has_menu: impl Fn(TeksiloWindowId) -> bool,
746 owner: impl Fn(TeksiloWindowId) -> Option<TeksiloWindowId>,
747 app_declared: bool,
748 shown: Option<TeksiloWindowId>,
749) -> Option<TeksiloWindowId> {
750 let mut window = key;
751 let mut visited = vec![window];
752 loop {
753 if has_menu(window) {
754 return Some(window);
755 }
756 match owner(window) {
757 Some(next) if !visited.contains(&next) => {
758 visited.push(next);
759 window = next;
760 }
761 _ => break,
762 }
763 }
764 if app_declared { None } else { shown }
765}
766
767/// What a backend shows once the application menu has been set or replaced
768/// ([`NativeMenuBackend::set_app_menu`]): `Some(bar)` to show `bar` (as
769/// [`key_window_bar`] names it, `None` for the application menu), `None`
770/// to leave the bar up as it is.
771///
772/// `key_bar` is what [`key_window_bar`] picks for the key window now, `None`
773/// before any window was activated; `shown` is the bar up. The key window's
774/// pick is shown when it differs from the bar up (an application menu
775/// declared while a window with no menu of its own is key replaces the
776/// window bar kept for it), and whenever it is the application menu, which
777/// is a new menu now. With no key window, only an application menu already
778/// up is replaced.
779pub(crate) fn bar_after_app_menu(
780 key_bar: Option<Option<TeksiloWindowId>>,
781 shown: Option<TeksiloWindowId>,
782) -> Option<Option<TeksiloWindowId>> {
783 match key_bar {
784 Some(bar) => (bar.is_none() || bar != shown).then_some(bar),
785 None => shown.is_none().then_some(None),
786 }
787}
788
789/// What a backend shows once it has forgotten `cleared`'s menu
790/// ([`NativeMenuBackend::clear_window`]): `Some(bar)` to show `bar` (as
791/// [`key_window_bar`] names it, `None` for the application menu), `None` to
792/// leave the bar up as it is.
793///
794/// Only a bar that was `cleared`'s own (`shown`) is replaced, by the key
795/// window's pick (`key`, with `has_menu`, `owner` and `app_declared` as for
796/// [`key_window_bar`]) when another window is key, else by the application
797/// menu. The pick is made as if `cleared` were already gone and no bar were
798/// up, so it is never `cleared`'s menu, declared application menu or not,
799/// even when the key window is `cleared` itself or a dialog it owned.
800pub(crate) fn bar_after_clear(
801 cleared: TeksiloWindowId,
802 key: Option<TeksiloWindowId>,
803 shown: Option<TeksiloWindowId>,
804 has_menu: impl Fn(TeksiloWindowId) -> bool,
805 owner: impl Fn(TeksiloWindowId) -> Option<TeksiloWindowId>,
806 app_declared: bool,
807) -> Option<Option<TeksiloWindowId>> {
808 if shown != Some(cleared) {
809 return None;
810 }
811 Some(key.filter(|key| *key != cleared).and_then(|key| {
812 key_window_bar(
813 key,
814 |window| window != cleared && has_menu(window),
815 |window| owner(window).filter(|owner| *owner != cleared),
816 app_declared,
817 None,
818 )
819 }))
820}
821
822// ============================================================
823// NoopNativeMenuBackend
824// ============================================================
825
826/// Backend that renders nothing. Used on platforms without a native-menu
827/// implementation (everything except macOS today) so cross-platform code that
828/// installs a native menu compiles and runs — the in-window `MenuBar` remains
829/// the menu surface there.
830#[derive(Default)]
831pub struct NoopNativeMenuBackend;
832
833impl NoopNativeMenuBackend {
834 /// Build the no-op backend.
835 pub fn new() -> Self {
836 Self
837 }
838}
839
840impl NativeMenuBackend for NoopNativeMenuBackend {
841 fn set_window_menu(
842 &mut self,
843 _window_id: TeksiloWindowId,
844 _menu: NativeMenuSnapshot,
845 _poster: Arc<dyn AppEventPoster>,
846 ) {
847 }
848 fn activate_window(&mut self, _window_id: TeksiloWindowId) {}
849 fn clear_window(&mut self, _window_id: TeksiloWindowId) {}
850 fn update_item(&mut self, _id: MenuItemId, _delta: MenuItemDelta) {}
851 fn shows_bar(&self) -> bool {
852 false
853 }
854}
855
856// ============================================================
857// The macOS bar's layout (pure, so every OS tests it)
858// ============================================================
859
860/// The top-level menus of a snapshot as the macOS menu bar shows them.
861#[cfg(any(target_os = "macos", test))]
862#[derive(Debug)]
863pub(crate) struct MacBarPlan<'a> {
864 /// The menus in bar order: the first App-role menu, then every other
865 /// submenu and standard menu in the order the snapshot declares them.
866 pub(crate) menus: Vec<&'a NativeMenuNode>,
867 /// The index in `menus` of the menu AppKit keeps the window list in
868 /// (`NSApp.windowsMenu`): the first Window-role menu. `None`: the bar
869 /// declares none, and the backend adds a hidden one, so the Dock icon's
870 /// menu still lists the windows.
871 pub(crate) windows_menu: Option<usize>,
872 /// The index in `menus` of the menu registered as `NSApp.helpMenu`: the
873 /// first Help-role menu.
874 pub(crate) help_menu: Option<usize>,
875 /// For each entry of `menus`, the rows of the app's own menus folded into
876 /// it: a menu the app titles like a standard Window or Help menu in the
877 /// same snapshot, which the bar would otherwise show twice. Empty for
878 /// every other menu.
879 pub(crate) folded: Vec<Vec<&'a NativeMenuNode>>,
880}
881
882/// Lay out `snapshot` for the macOS menu bar.
883///
884/// AppKit shows the bar's first menu as the bold application menu, under
885/// the application's name, whatever that menu holds. So the App-role menu
886/// goes first wherever the model declared it: declared after File, File's
887/// rows would sit under the application's name and About, Hide and Quit in
888/// a second, ordinary menu. A later App-role menu is left out, since a bar
889/// has one application menu. A leaf or separator at the top level is left
890/// out too: the bar holds menus only.
891///
892/// A menu of the app's own titled like a standard Window or Help menu of the
893/// same snapshot is not shown beside it: its rows go into the first standard
894/// menu with that title, where a Mac app keeps them (its Window commands
895/// under Minimize and Zoom). A model that gives the in-window bar a Window
896/// menu and macOS its standard one shows one Window menu on macOS, with
897/// both.
898#[cfg(any(target_os = "macos", test))]
899pub(crate) fn mac_bar_plan(snapshot: &NativeMenuSnapshot) -> MacBarPlan<'_> {
900 let is_app = |node: &NativeMenuNode| {
901 matches!(
902 node,
903 NativeMenuNode::Standard {
904 role: StandardMenuRole::App,
905 ..
906 }
907 )
908 };
909 let app = snapshot.roots.iter().find(|node| is_app(node));
910 let rest = snapshot.roots.iter().filter(|node| {
911 !is_app(node)
912 && matches!(
913 node,
914 NativeMenuNode::Submenu { .. } | NativeMenuNode::Standard { .. }
915 )
916 });
917 let candidates: Vec<&NativeMenuNode> = app.into_iter().chain(rest).collect();
918 // The first Window- or Help-role menu with this title, which an app menu
919 // of the same title folds into.
920 let host_of = |wanted: &str| {
921 candidates.iter().position(|node| {
922 matches!(
923 node,
924 NativeMenuNode::Standard { role: StandardMenuRole::Window | StandardMenuRole::Help, labels, .. }
925 if labels.title == wanted
926 )
927 })
928 };
929 let mut menus = Vec::with_capacity(candidates.len());
930 let mut folded: Vec<Vec<&NativeMenuNode>> = Vec::with_capacity(candidates.len());
931 // `host_index[i]`: where `candidates[i]` landed in `menus`.
932 let mut host_index = vec![None; candidates.len()];
933 let mut pending: Vec<(usize, &[NativeMenuNode])> = Vec::new();
934 for (i, node) in candidates.iter().copied().enumerate() {
935 if let NativeMenuNode::Submenu { title, children } = node
936 && let Some(host) = host_of(title)
937 {
938 pending.push((host, children));
939 continue;
940 }
941 host_index[i] = Some(menus.len());
942 menus.push(node);
943 folded.push(Vec::new());
944 }
945 for (host, children) in pending {
946 if let Some(at) = host_index[host] {
947 folded[at].extend(children);
948 }
949 }
950 let first = |wanted: StandardMenuRole| {
951 menus.iter().position(
952 |node| matches!(node, NativeMenuNode::Standard { role, .. } if *role == wanted),
953 )
954 };
955 MacBarPlan {
956 windows_menu: first(StandardMenuRole::Window),
957 help_menu: first(StandardMenuRole::Help),
958 menus,
959 folded,
960 }
961}
962
963/// Whether the macOS backend evaluates a window again for the Window menu
964/// it registers, by flipping its `excludedFromWindowsMenu`: a window that
965/// is not excluded (as a dialog is) and that AppKit lists, on screen
966/// (`visible`) or in the Dock (`miniaturized`). AppKit adds a row for a
967/// window it evaluates with the flag off, so flipping a hidden window's
968/// would list a window the app hid.
969#[cfg(any(target_os = "macos", test))]
970pub(crate) fn mac_relists_window(excluded: bool, visible: bool, miniaturized: bool) -> bool {
971 !excluded && (visible || miniaturized)
972}
973
974/// A row of the macOS App menu. See [`mac_app_menu_rows`].
975#[cfg(any(target_os = "macos", test))]
976#[derive(Debug, Clone, Copy, PartialEq, Eq)]
977pub(crate) enum MacAppMenuRow {
978 /// About, AppKit's `orderFrontStandardAboutPanel:`.
979 About,
980 /// Settings, routed to the app.
981 Settings,
982 /// The Services submenu, registered as `NSApp.servicesMenu`.
983 Services,
984 /// Hide <App>, `hide:`, ⌘H.
985 Hide,
986 /// Hide Others, `hideOtherApplications:`, ⌥⌘H.
987 HideOthers,
988 /// Show All, `unhideAllApplications:`.
989 ShowAll,
990 /// Quit, routed to the app.
991 Quit,
992 /// A separator.
993 Separator,
994}
995
996/// The rows of the macOS App menu, in the order AppKit's own application
997/// menu has them: About; Settings in a group of its own when the app routes
998/// it; Services; Hide, Hide Others and Show All; Quit.
999#[cfg(any(target_os = "macos", test))]
1000pub(crate) fn mac_app_menu_rows(settings: bool) -> Vec<MacAppMenuRow> {
1001 use MacAppMenuRow::*;
1002 let mut rows = vec![About, Separator];
1003 if settings {
1004 rows.extend([Settings, Separator]);
1005 }
1006 rows.extend([
1007 Services, Separator, Hide, HideOthers, ShowAll, Separator, Quit,
1008 ]);
1009 rows
1010}
1011
1012// ============================================================
1013// Default backend factory
1014// ============================================================
1015
1016/// The default native-menu backend for the current target: macOS gets the real
1017/// `NSMenu` backend, every other target gets [`NoopNativeMenuBackend`].
1018pub fn default_backend() -> Box<dyn NativeMenuBackend> {
1019 #[cfg(target_os = "macos")]
1020 {
1021 Box::new(macos::MacOsNativeMenuBackend::new())
1022 }
1023 #[cfg(not(target_os = "macos"))]
1024 {
1025 Box::new(NoopNativeMenuBackend::new())
1026 }
1027}
1028
1029// ============================================================
1030// MemoryNativeMenuBackend (test backend)
1031// ============================================================
1032
1033/// Recording backend for headless tests. Captures the snapshot set per window,
1034/// which window is active, item deltas, and cleared windows. Cloneable; clones
1035/// share the recording so a test can keep a clone after handing one to
1036/// [`NativeMenuHandle::new`].
1037#[derive(Clone, Default)]
1038pub struct MemoryNativeMenuBackend {
1039 inner: Rc<RefCell<MemoryRecording>>,
1040}
1041
1042#[derive(Default)]
1043struct MemoryRecording {
1044 menus: HashMap<TeksiloWindowId, NativeMenuSnapshot>,
1045 app_menu: Option<NativeMenuSnapshot>,
1046 /// The last window `activate_window` named.
1047 key: Option<TeksiloWindowId>,
1048 /// The window whose menu shows; `None` while the application menu does.
1049 active: Option<TeksiloWindowId>,
1050 /// The window each window belongs to (`set_window_owner`).
1051 owners: HashMap<TeksiloWindowId, TeksiloWindowId>,
1052 /// Whether the application menu set last was declared by the app.
1053 app_declared: bool,
1054 deltas: Vec<(MenuItemId, MenuItemDelta)>,
1055 cleared: Vec<TeksiloWindowId>,
1056}
1057
1058impl MemoryRecording {
1059 /// The bar shown while `key` is key, by [`key_window_bar`].
1060 fn bar_for(&self, key: TeksiloWindowId) -> Option<TeksiloWindowId> {
1061 key_window_bar(
1062 key,
1063 |window| self.menus.contains_key(&window),
1064 |window| self.owners.get(&window).copied(),
1065 self.app_declared,
1066 self.active,
1067 )
1068 }
1069}
1070
1071impl MemoryNativeMenuBackend {
1072 /// Build a new empty recording backend.
1073 pub fn new() -> Self {
1074 Self::default()
1075 }
1076
1077 /// The snapshot currently set for `window_id`, if any.
1078 pub fn menu_for(&self, window_id: TeksiloWindowId) -> Option<NativeMenuSnapshot> {
1079 self.inner.borrow().menus.get(&window_id).cloned()
1080 }
1081
1082 /// The window whose own menu shows, as on macOS: the one
1083 /// [`key_window_bar`] picks for the key window (the last
1084 /// `activate_window`), or a window that set one before any was
1085 /// activated. `None` while the application menu shows.
1086 pub fn active_window(&self) -> Option<TeksiloWindowId> {
1087 self.inner.borrow().active
1088 }
1089
1090 /// Whether the application menu is the one showing: no window's own
1091 /// menu is ([`active_window`](Self::active_window) is `None`).
1092 pub fn shows_app_menu(&self) -> bool {
1093 self.inner.borrow().active.is_none()
1094 }
1095
1096 /// All item deltas applied so far, in order.
1097 pub fn deltas(&self) -> Vec<(MenuItemId, MenuItemDelta)> {
1098 self.inner.borrow().deltas.clone()
1099 }
1100
1101 /// Windows whose menus were cleared, in order.
1102 pub fn cleared(&self) -> Vec<TeksiloWindowId> {
1103 self.inner.borrow().cleared.clone()
1104 }
1105
1106 /// The application menu last set, if any.
1107 pub fn app_menu(&self) -> Option<NativeMenuSnapshot> {
1108 self.inner.borrow().app_menu.clone()
1109 }
1110}
1111
1112impl NativeMenuBackend for MemoryNativeMenuBackend {
1113 fn set_window_menu(
1114 &mut self,
1115 window_id: TeksiloWindowId,
1116 menu: NativeMenuSnapshot,
1117 _poster: Arc<dyn AppEventPoster>,
1118 ) {
1119 let mut rec = self.inner.borrow_mut();
1120 rec.menus.insert(window_id, menu);
1121 // As the macOS backend: the bar the key window shows, and a
1122 // window's menu set before any window was activated.
1123 rec.active = match rec.key {
1124 Some(key) => rec.bar_for(key),
1125 None => Some(window_id),
1126 };
1127 }
1128 fn activate_window(&mut self, window_id: TeksiloWindowId) {
1129 let mut rec = self.inner.borrow_mut();
1130 rec.key = Some(window_id);
1131 rec.active = rec.bar_for(window_id);
1132 }
1133 fn set_window_owner(&mut self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
1134 let mut rec = self.inner.borrow_mut();
1135 match owner {
1136 Some(owner) => rec.owners.insert(window_id, owner),
1137 None => rec.owners.remove(&window_id),
1138 };
1139 }
1140 fn clear_window(&mut self, window_id: TeksiloWindowId) {
1141 let mut rec = self.inner.borrow_mut();
1142 rec.menus.remove(&window_id);
1143 rec.owners.remove(&window_id);
1144 rec.cleared.push(window_id);
1145 // As the macOS backend: never the dead window's menu.
1146 let bar = bar_after_clear(
1147 window_id,
1148 rec.key,
1149 rec.active,
1150 |window| rec.menus.contains_key(&window),
1151 |window| rec.owners.get(&window).copied(),
1152 rec.app_declared,
1153 );
1154 if let Some(bar) = bar {
1155 rec.active = bar;
1156 }
1157 }
1158 fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
1159 self.inner.borrow_mut().deltas.push((id, delta));
1160 }
1161 fn set_app_menu(
1162 &mut self,
1163 menu: NativeMenuSnapshot,
1164 _poster: Arc<dyn AppEventPoster>,
1165 declared: bool,
1166 ) {
1167 let mut rec = self.inner.borrow_mut();
1168 rec.app_menu = Some(menu);
1169 rec.app_declared = declared;
1170 // As the macOS backend: the key window's bar again, which the new
1171 // application menu may be.
1172 let key_bar = rec.key.map(|key| rec.bar_for(key));
1173 if let Some(bar) = bar_after_app_menu(key_bar, rec.active) {
1174 rec.active = bar;
1175 }
1176 }
1177}
1178
1179// ============================================================
1180// Tests
1181// ============================================================
1182
1183#[cfg(test)]
1184mod tests {
1185 use super::*;
1186 use std::sync::Mutex;
1187 use teksilo_core::SubscriptionId;
1188
1189 struct NullPoster;
1190 impl AppEventPoster for NullPoster {
1191 fn post_subscription_event(
1192 &self,
1193 _sub_id: SubscriptionId,
1194 _event: Box<dyn std::any::Any + Send>,
1195 ) {
1196 }
1197 fn post_external(&self, _payload: Box<dyn std::any::Any + Send>) {}
1198 }
1199
1200 fn poster() -> Arc<dyn AppEventPoster> {
1201 Arc::new(NullPoster)
1202 }
1203
1204 fn win(n: u64) -> TeksiloWindowId {
1205 TeksiloWindowId::new(n)
1206 }
1207
1208 fn sample_snapshot(id: MenuItemId) -> NativeMenuSnapshot {
1209 NativeMenuSnapshot {
1210 roots: vec![NativeMenuNode::Submenu {
1211 title: "File".into(),
1212 children: vec![NativeMenuNode::Item {
1213 id,
1214 title: "New".into(),
1215 key_equiv: None,
1216 enabled: true,
1217 visible: true,
1218 check: NativeCheck::None,
1219 }],
1220 }],
1221 }
1222 }
1223
1224 #[test]
1225 fn set_menu_records_snapshot_and_activations() {
1226 let backend = MemoryNativeMenuBackend::new();
1227 let handle = NativeMenuHandle::new(backend.clone());
1228 let id = MenuItemId::next();
1229
1230 let fired = Arc::new(Mutex::new(false));
1231 let fired2 = fired.clone();
1232 let mut acts = HashMap::new();
1233 acts.insert(
1234 id,
1235 NativeMenuActivation {
1236 intent: Some("app.new"),
1237 action: Some(Rc::new(move |_ctx: &mut EventContext| {
1238 *fired2.lock().unwrap() = true;
1239 })),
1240 quits: false,
1241 },
1242 );
1243
1244 handle.set_window_menu(win(1), sample_snapshot(id), acts, poster());
1245
1246 assert!(backend.menu_for(win(1)).is_some());
1247 assert_eq!(backend.active_window(), Some(win(1)));
1248 let act = handle.activation(win(1), id).expect("activation recorded");
1249 assert_eq!(act.intent, Some("app.new"));
1250 assert!(act.action.is_some());
1251 }
1252
1253 #[test]
1254 fn activate_and_clear_window() {
1255 let backend = MemoryNativeMenuBackend::new();
1256 let handle = NativeMenuHandle::new(backend.clone());
1257 let id = MenuItemId::next();
1258 handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
1259 handle.set_window_menu(
1260 win(2),
1261 sample_snapshot(MenuItemId::next()),
1262 HashMap::new(),
1263 poster(),
1264 );
1265
1266 handle.activate_window(win(2));
1267 assert_eq!(backend.active_window(), Some(win(2)));
1268
1269 handle.clear_window(win(2));
1270 assert_eq!(backend.cleared(), vec![win(2)]);
1271 assert!(handle.activation(win(2), id).is_none());
1272 assert!(backend.menu_for(win(2)).is_none());
1273 }
1274
1275 /// A window with no menu of its own, nor an owner with one, shows the
1276 /// application menu the app declared when it becomes key, as the macOS
1277 /// backend does: the previous window's menu used to stay up, its items
1278 /// acting on a window the user had left.
1279 #[test]
1280 fn activating_a_window_without_a_menu_shows_the_application_menu() {
1281 let backend = MemoryNativeMenuBackend::new();
1282 let handle = NativeMenuHandle::new(backend.clone());
1283 handle.set_app_menu(
1284 sample_snapshot(MenuItemId::next()),
1285 HashMap::new(),
1286 poster(),
1287 true,
1288 );
1289 handle.set_window_menu(
1290 win(1),
1291 sample_snapshot(MenuItemId::next()),
1292 HashMap::new(),
1293 poster(),
1294 );
1295 handle.activate_window(win(1));
1296 assert_eq!(backend.active_window(), Some(win(1)));
1297 handle.activate_window(win(2));
1298 assert_eq!(backend.active_window(), None);
1299 assert!(backend.shows_app_menu());
1300 handle.activate_window(win(1));
1301 assert_eq!(backend.active_window(), Some(win(1)), "and back");
1302 }
1303
1304 /// A window that installs its menu while another is key does not take
1305 /// the bar; the key window that closes leaves the application menu.
1306 #[test]
1307 fn the_key_window_decides_and_its_close_leaves_the_application_menu() {
1308 let backend = MemoryNativeMenuBackend::new();
1309 let handle = NativeMenuHandle::new(backend.clone());
1310 handle.activate_window(win(1));
1311 handle.set_window_menu(
1312 win(2),
1313 sample_snapshot(MenuItemId::next()),
1314 HashMap::new(),
1315 poster(),
1316 );
1317 assert!(backend.shows_app_menu(), "window 1 is key and has none");
1318 handle.set_window_menu(
1319 win(1),
1320 sample_snapshot(MenuItemId::next()),
1321 HashMap::new(),
1322 poster(),
1323 );
1324 assert_eq!(backend.active_window(), Some(win(1)));
1325 handle.clear_window(win(1));
1326 assert!(backend.shows_app_menu());
1327 }
1328
1329 /// A row whose only effect is its intent greys out while nothing takes
1330 /// the intent, its own enabled state still counting; a row with an
1331 /// action, and the App menu's Quit, never do.
1332 #[test]
1333 fn an_intent_only_row_greys_out_while_nothing_takes_its_intent() {
1334 let backend = MemoryNativeMenuBackend::new();
1335 let handle = NativeMenuHandle::new(backend.clone());
1336 let (plain, with_action, quit) =
1337 (MenuItemId::next(), MenuItemId::next(), MenuItemId::next());
1338 let activation = |action: bool, quits: bool| NativeMenuActivation {
1339 intent: Some("app.print"),
1340 action: action.then(|| -> MenuActionFn { Rc::new(|_| {}) }),
1341 quits,
1342 };
1343 handle.set_window_menu(
1344 win(1),
1345 sample_snapshot(plain),
1346 [
1347 (plain, activation(false, false)),
1348 (with_action, activation(true, false)),
1349 (quit, activation(false, true)),
1350 ]
1351 .into_iter()
1352 .collect(),
1353 poster(),
1354 );
1355 let enabled_deltas = |backend: &MemoryNativeMenuBackend| {
1356 backend
1357 .deltas()
1358 .into_iter()
1359 .filter_map(|(id, delta)| delta.enabled.map(|enabled| (id, enabled)))
1360 .collect::<Vec<_>>()
1361 };
1362 handle.refresh_availability(&["app.print"]);
1363 assert!(enabled_deltas(&backend).is_empty(), "nothing changed");
1364 handle.refresh_availability(&[]);
1365 assert_eq!(enabled_deltas(&backend), [(plain, false)]);
1366 assert_eq!(handle.gated_enabled(plain), Some(false));
1367 assert_eq!(handle.gated_enabled(with_action), None);
1368 assert_eq!(handle.gated_enabled(quit), None);
1369
1370 // Its own state turning true does not enable it while nothing takes
1371 // the intent; the intent taken again does.
1372 handle.update_item(
1373 plain,
1374 MenuItemDelta {
1375 enabled: Some(true),
1376 ..Default::default()
1377 },
1378 );
1379 assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, false)));
1380 handle.refresh_availability(&["app.print"]);
1381 assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, true)));
1382
1383 // Its own state turning false greys it out whatever the route says.
1384 handle.update_item(
1385 plain,
1386 MenuItemDelta {
1387 enabled: Some(false),
1388 ..Default::default()
1389 },
1390 );
1391 handle.refresh_availability(&["app.print"]);
1392 assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, false)));
1393
1394 handle.clear_window(win(1));
1395 assert_eq!(handle.gated_enabled(plain), None, "forgotten with its menu");
1396 }
1397
1398 fn standard(role: StandardMenuRole, title: &str) -> NativeMenuNode {
1399 NativeMenuNode::Standard {
1400 role,
1401 labels: StandardLabels {
1402 title: title.into(),
1403 ..Default::default()
1404 },
1405 quit_item: None,
1406 settings_item: None,
1407 }
1408 }
1409
1410 fn bar_titles(plan: &MacBarPlan<'_>) -> Vec<String> {
1411 plan.menus
1412 .iter()
1413 .map(|node| match node {
1414 NativeMenuNode::Submenu { title, .. } => title.clone(),
1415 NativeMenuNode::Standard { labels, .. } => labels.title.clone(),
1416 other => panic!("the bar holds menus only, got {other:?}"),
1417 })
1418 .collect()
1419 }
1420
1421 /// AppKit shows the bar's first menu as the application menu, so the
1422 /// App-role menu goes first wherever the model declares it. Declared
1423 /// after File, File's rows sat under the application's name and About,
1424 /// Hide and Quit in a second, ordinary menu (macOS findings, item 2).
1425 #[test]
1426 fn the_app_menu_is_first_in_the_mac_bar_wherever_it_is_declared() {
1427 let mut file = sample_snapshot(MenuItemId::next()).roots;
1428 let snapshot = NativeMenuSnapshot {
1429 roots: vec![
1430 file.remove(0),
1431 NativeMenuNode::Separator,
1432 standard(StandardMenuRole::Window, "Window"),
1433 standard(StandardMenuRole::App, "Probe"),
1434 standard(StandardMenuRole::Help, "Help"),
1435 standard(StandardMenuRole::App, "Second"),
1436 ],
1437 };
1438 assert_eq!(
1439 bar_titles(&mac_bar_plan(&snapshot)),
1440 ["Probe", "File", "Window", "Help"],
1441 "App first, the rest in declared order, one App menu, no top-level separator"
1442 );
1443
1444 let declared_first = NativeMenuSnapshot {
1445 roots: vec![
1446 standard(StandardMenuRole::App, "Probe"),
1447 sample_snapshot(MenuItemId::next()).roots.remove(0),
1448 ],
1449 };
1450 assert_eq!(
1451 bar_titles(&mac_bar_plan(&declared_first)),
1452 ["Probe", "File"]
1453 );
1454 }
1455
1456 /// A window registered again in the Window menu is one AppKit lists:
1457 /// not excluded, and on screen or in the Dock. A hidden window (ordered
1458 /// out, not miniaturized) is left alone: flipping its flag lists it,
1459 /// since AppKit keeps a row for every window it evaluates with the flag
1460 /// off, and a window the app hid would show in the Window and Dock
1461 /// menus.
1462 #[test]
1463 fn the_mac_window_menu_relists_only_shown_or_miniaturized_windows() {
1464 assert!(mac_relists_window(false, true, false), "on screen");
1465 assert!(mac_relists_window(false, false, true), "in the Dock");
1466 assert!(!mac_relists_window(false, false, false), "hidden");
1467 assert!(
1468 !mac_relists_window(true, true, false),
1469 "excluded (a dialog)"
1470 );
1471 }
1472
1473 /// The App menu's rows in AppKit's order: About; Settings in a group of
1474 /// its own; Services; Hide, Hide Others and Show All; Quit.
1475 #[test]
1476 fn the_mac_app_menu_has_appkits_rows_in_appkits_order() {
1477 use MacAppMenuRow::*;
1478 assert_eq!(
1479 mac_app_menu_rows(true),
1480 [
1481 About, Separator, Settings, Separator, Services, Separator, Hide, HideOthers,
1482 ShowAll, Separator, Quit,
1483 ]
1484 );
1485 assert_eq!(
1486 mac_app_menu_rows(false),
1487 [
1488 About, Separator, Services, Separator, Hide, HideOthers, ShowAll, Separator, Quit,
1489 ],
1490 "no Settings row, and no empty group for it"
1491 );
1492 }
1493
1494 /// AppKit keeps the window list in the menu registered as `windowsMenu`,
1495 /// and the Dock icon's menu lists the windows from it: the bar's first
1496 /// Window-role menu when it declares one, else a hidden one the backend
1497 /// adds (Qt's `insertWindowMenu`), so the Dock still lists the windows
1498 /// (macOS findings, item 3).
1499 #[test]
1500 fn the_mac_bar_keeps_the_window_list_in_its_window_menu_or_a_hidden_one() {
1501 let snapshot = NativeMenuSnapshot {
1502 roots: vec![
1503 sample_snapshot(MenuItemId::next()).roots.remove(0),
1504 standard(StandardMenuRole::Window, "Window"),
1505 standard(StandardMenuRole::App, "Probe"),
1506 standard(StandardMenuRole::Window, "Second window menu"),
1507 standard(StandardMenuRole::Help, "Help"),
1508 ],
1509 };
1510 let plan = mac_bar_plan(&snapshot);
1511 assert_eq!(
1512 plan.windows_menu,
1513 Some(2),
1514 "the first Window menu, in bar order"
1515 );
1516 assert_eq!(plan.help_menu, Some(4));
1517
1518 let no_window_menu = NativeMenuSnapshot {
1519 roots: vec![
1520 sample_snapshot(MenuItemId::next()).roots.remove(0),
1521 standard(StandardMenuRole::App, "Probe"),
1522 ],
1523 };
1524 let plan = mac_bar_plan(&no_window_menu);
1525 assert_eq!(plan.windows_menu, None, "a hidden one is added");
1526 assert_eq!(plan.help_menu, None);
1527 }
1528
1529 /// An app menu titled like the snapshot's standard Window or Help menu
1530 /// is not shown beside it: its rows go into the standard menu. The
1531 /// `native-menu` example gave the in-window bar a Window menu (Inspector)
1532 /// and macOS its standard one, and the macOS bar showed Window twice.
1533 #[test]
1534 fn an_app_menu_titled_like_a_standard_one_folds_into_it_on_the_mac_bar() {
1535 let inspector = MenuItemId::next();
1536 let own = |title: &str, id: MenuItemId| NativeMenuNode::Submenu {
1537 title: title.into(),
1538 children: vec![NativeMenuNode::Item {
1539 id,
1540 title: "Row".into(),
1541 key_equiv: None,
1542 enabled: true,
1543 visible: true,
1544 check: NativeCheck::None,
1545 }],
1546 };
1547 let manual = MenuItemId::next();
1548 let snapshot = NativeMenuSnapshot {
1549 roots: vec![
1550 standard(StandardMenuRole::App, "Probe"),
1551 own("Window", inspector),
1552 own("Help", manual),
1553 standard(StandardMenuRole::Window, "Window"),
1554 standard(StandardMenuRole::Help, "Help"),
1555 own("Tools", MenuItemId::next()),
1556 ],
1557 };
1558 let plan = mac_bar_plan(&snapshot);
1559 assert_eq!(
1560 bar_titles(&plan),
1561 ["Probe", "Window", "Help", "Tools"],
1562 "one Window menu, one Help menu"
1563 );
1564 let ids = |rows: &[&NativeMenuNode]| -> Vec<MenuItemId> {
1565 rows.iter()
1566 .map(|node| match node {
1567 NativeMenuNode::Item { id, .. } => *id,
1568 other => panic!("expected a row, got {other:?}"),
1569 })
1570 .collect()
1571 };
1572 assert_eq!(
1573 ids(&plan.folded[1]),
1574 [inspector],
1575 "into the standard Window menu"
1576 );
1577 assert_eq!(
1578 ids(&plan.folded[2]),
1579 [manual],
1580 "into the standard Help menu"
1581 );
1582 assert!(plan.folded[0].is_empty() && plan.folded[3].is_empty());
1583 assert_eq!((plan.windows_menu, plan.help_menu), (Some(1), Some(2)));
1584
1585 // With no standard Window menu, the app's own stays as it is.
1586 let alone = NativeMenuSnapshot {
1587 roots: vec![
1588 standard(StandardMenuRole::App, "Probe"),
1589 own("Window", MenuItemId::next()),
1590 ],
1591 };
1592 let plan = mac_bar_plan(&alone);
1593 assert_eq!(bar_titles(&plan), ["Probe", "Window"]);
1594 assert_eq!(plan.windows_menu, None);
1595 }
1596
1597 /// The bar a key window shows: its own menu; else the menu of the window
1598 /// it belongs to (a modal dialog's owner, up the chain of owners); else
1599 /// the application menu, or, when the application declared none, the bar
1600 /// shown before, as AppKit keeps `mainMenu` up for its panels and
1601 /// sheets. A dialog used to show the application menu, so an app with
1602 /// no application menu lost File and Edit whenever one was key.
1603 #[test]
1604 fn a_key_window_without_a_menu_shows_its_owners_or_the_applications() {
1605 let menus = [win(1)];
1606 let has_menu = |w: TeksiloWindowId| menus.contains(&w);
1607 // 2 is a dialog of 1, 3 a dialog of 2; 4 is a secondary window.
1608 let owner = |w: TeksiloWindowId| match w.raw() {
1609 2 => Some(win(1)),
1610 3 => Some(win(2)),
1611 _ => None,
1612 };
1613 let bar = |key, declared, shown| key_window_bar(key, has_menu, owner, declared, shown);
1614 assert_eq!(bar(win(1), true, None), Some(win(1)), "its own");
1615 assert_eq!(bar(win(2), true, None), Some(win(1)), "its owner's");
1616 assert_eq!(bar(win(3), true, None), Some(win(1)), "up the chain");
1617 assert_eq!(bar(win(4), true, Some(win(1))), None, "the application's");
1618 assert_eq!(
1619 bar(win(4), false, Some(win(1))),
1620 Some(win(1)),
1621 "none declared: the bar shown before stays"
1622 );
1623 assert_eq!(bar(win(4), false, None), None);
1624
1625 // An owner relation that loops ends the walk rather than the loop.
1626 let looping = |w: TeksiloWindowId| Some(if w == win(5) { win(6) } else { win(5) });
1627 assert_eq!(key_window_bar(win(5), has_menu, looping, true, None), None);
1628 }
1629
1630 /// `MemoryNativeMenuBackend` follows the same rule as the macOS one.
1631 #[test]
1632 fn the_memory_backend_shows_a_dialogs_owners_menu() {
1633 let backend = MemoryNativeMenuBackend::new();
1634 let handle = NativeMenuHandle::new(backend.clone());
1635 handle.set_app_menu(
1636 sample_snapshot(MenuItemId::next()),
1637 HashMap::new(),
1638 poster(),
1639 false,
1640 );
1641 handle.set_window_menu(
1642 win(1),
1643 sample_snapshot(MenuItemId::next()),
1644 HashMap::new(),
1645 poster(),
1646 );
1647 handle.set_window_owner(win(2), Some(win(1)));
1648 handle.activate_window(win(1));
1649 handle.activate_window(win(2));
1650 assert_eq!(
1651 backend.active_window(),
1652 Some(win(1)),
1653 "the dialog's owner's"
1654 );
1655 handle.activate_window(win(3));
1656 assert_eq!(
1657 backend.active_window(),
1658 Some(win(1)),
1659 "no application menu declared: a secondary window keeps the bar"
1660 );
1661 handle.set_app_menu(
1662 sample_snapshot(MenuItemId::next()),
1663 HashMap::new(),
1664 poster(),
1665 true,
1666 );
1667 handle.activate_window(win(3));
1668 assert!(backend.shows_app_menu(), "declared: the application's");
1669 handle.activate_window(win(2));
1670 assert_eq!(backend.active_window(), Some(win(1)));
1671 handle.clear_window(win(1));
1672 assert!(backend.shows_app_menu(), "the owner's menu went with it");
1673 }
1674
1675 /// Declaring the application menu while a window with no menu of its own
1676 /// is key shows it at once, without waiting for focus to move: the bar
1677 /// left up was another window's, kept only because no application menu
1678 /// was declared. Declaring it while the key window shows its own menu
1679 /// leaves that one up.
1680 #[test]
1681 fn declaring_the_application_menu_shows_it_for_a_menu_less_key_window() {
1682 let backend = MemoryNativeMenuBackend::new();
1683 let handle = NativeMenuHandle::new(backend.clone());
1684 let app_menu = |declared| {
1685 handle.set_app_menu(
1686 sample_snapshot(MenuItemId::next()),
1687 HashMap::new(),
1688 poster(),
1689 declared,
1690 )
1691 };
1692 app_menu(false);
1693 handle.set_window_menu(
1694 win(1),
1695 sample_snapshot(MenuItemId::next()),
1696 HashMap::new(),
1697 poster(),
1698 );
1699 handle.activate_window(win(1));
1700 handle.activate_window(win(2));
1701 assert_eq!(backend.active_window(), Some(win(1)), "precondition");
1702 app_menu(true);
1703 assert!(backend.shows_app_menu(), "window 2 is key and has none");
1704
1705 handle.activate_window(win(1));
1706 app_menu(true);
1707 assert_eq!(
1708 backend.active_window(),
1709 Some(win(1)),
1710 "a key window's own menu stays"
1711 );
1712 }
1713
1714 /// After a window's menu is forgotten the bar is never that window's,
1715 /// whoever is key: the window itself, a dialog it owned, another window
1716 /// with or without a menu, none; with an application menu declared or
1717 /// not. A bar that was not the forgotten window's stays.
1718 #[test]
1719 fn a_cleared_windows_bar_never_comes_back() {
1720 let (closed, dialog, other, plain) = (win(1), win(2), win(3), win(4));
1721 // `has_menu` and `owner` still answer for the closed window, as a
1722 // backend that had not forgotten it yet would.
1723 let has_menu = |w: TeksiloWindowId| w == closed || w == other;
1724 let owner = |w: TeksiloWindowId| (w == dialog).then_some(closed);
1725 for declared in [true, false] {
1726 for key in [Some(closed), Some(dialog), Some(other), Some(plain), None] {
1727 let bar = bar_after_clear(closed, key, Some(closed), has_menu, owner, declared)
1728 .expect("its bar was up: replaced");
1729 assert_ne!(bar, Some(closed), "key {key:?}, declared {declared}");
1730 if key == Some(other) {
1731 assert_eq!(bar, Some(other), "the key window's own menu");
1732 }
1733 }
1734 assert_eq!(
1735 bar_after_clear(closed, Some(plain), Some(other), has_menu, owner, declared),
1736 None,
1737 "another window's bar stays"
1738 );
1739 }
1740 }
1741
1742 /// The rule both backends follow when the application menu is set:
1743 /// show what [`key_window_bar`] picks for the key window when it differs
1744 /// from the bar up, and the new application menu whenever that is the
1745 /// pick; with no window key yet, replace only an application menu
1746 /// already showing.
1747 #[test]
1748 fn what_setting_the_application_menu_shows() {
1749 let app = None;
1750 assert_eq!(bar_after_app_menu(Some(app), Some(win(1))), Some(app));
1751 assert_eq!(bar_after_app_menu(Some(app), app), Some(app), "rebuilt");
1752 assert_eq!(bar_after_app_menu(Some(Some(win(1))), Some(win(1))), None);
1753 assert_eq!(
1754 bar_after_app_menu(Some(Some(win(1))), app),
1755 Some(Some(win(1)))
1756 );
1757 assert_eq!(bar_after_app_menu(None, app), Some(app));
1758 assert_eq!(bar_after_app_menu(None, Some(win(1))), None);
1759 }
1760
1761 /// A refresh whose inputs are the last refresh's (the same intents
1762 /// taken, no menu installed since) evaluates no row: the loop asks every
1763 /// turn, and most turns change nothing. A menu installed since, or
1764 /// another answer, is evaluated again.
1765 #[test]
1766 fn an_unchanged_refresh_evaluates_nothing() {
1767 let backend = MemoryNativeMenuBackend::new();
1768 let handle = NativeMenuHandle::new(backend.clone());
1769 let row = MenuItemId::next();
1770 let intent_only = || NativeMenuActivation {
1771 intent: Some("app.print"),
1772 action: None,
1773 quits: false,
1774 };
1775 handle.set_window_menu(
1776 win(1),
1777 sample_snapshot(row),
1778 [(row, intent_only())].into_iter().collect(),
1779 poster(),
1780 );
1781 handle.refresh_availability(&["app.print"]);
1782 assert_eq!(handle.evaluations(), 1);
1783 handle.refresh_availability(&["app.print"]);
1784 assert_eq!(handle.evaluations(), 1, "nothing changed: no row evaluated");
1785
1786 let other = MenuItemId::next();
1787 handle.set_window_menu(
1788 win(2),
1789 sample_snapshot(other),
1790 [(other, intent_only())].into_iter().collect(),
1791 poster(),
1792 );
1793 handle.refresh_availability(&["app.print"]);
1794 assert_eq!(handle.evaluations(), 3, "a menu installed since: both rows");
1795 handle.refresh_availability(&[]);
1796 assert_eq!(handle.evaluations(), 5, "another answer");
1797 assert_eq!(handle.gated_enabled(other), Some(false));
1798 }
1799
1800 /// The no-op backend shows no menu bar, so the loop skips the
1801 /// availability refresh altogether where it is the backend.
1802 #[test]
1803 fn only_a_backend_with_a_menu_bar_says_it_shows_one() {
1804 assert!(!NativeMenuHandle::new(NoopNativeMenuBackend::new()).shows_bar());
1805 assert!(NativeMenuHandle::new(MemoryNativeMenuBackend::new()).shows_bar());
1806 }
1807
1808 #[test]
1809 fn update_item_records_delta() {
1810 let backend = MemoryNativeMenuBackend::new();
1811 let handle = NativeMenuHandle::new(backend.clone());
1812 let id = MenuItemId::next();
1813 handle.update_item(
1814 id,
1815 MenuItemDelta {
1816 enabled: Some(false),
1817 check: Some(NativeCheck::On),
1818 ..Default::default()
1819 },
1820 );
1821 let deltas = backend.deltas();
1822 assert_eq!(deltas.len(), 1);
1823 assert_eq!(deltas[0].0, id);
1824 assert_eq!(deltas[0].1.enabled, Some(false));
1825 assert_eq!(deltas[0].1.check, Some(NativeCheck::On));
1826 }
1827
1828 #[test]
1829 fn noop_backend_is_inert() {
1830 let handle = NativeMenuHandle::new(NoopNativeMenuBackend::new());
1831 let id = MenuItemId::next();
1832 handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
1833 handle.activate_window(win(1));
1834 handle.update_item(id, MenuItemDelta::default());
1835 handle.clear_window(win(1));
1836 // No activation was recorded for noop set? Activations live in the
1837 // handle, not the backend, so they ARE recorded then cleared.
1838 assert!(handle.activation(win(1), id).is_none());
1839 }
1840}