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}
876
877/// Lay out `snapshot` for the macOS menu bar.
878///
879/// AppKit shows the bar's first menu as the bold application menu, under
880/// the application's name, whatever that menu holds. So the App-role menu
881/// goes first wherever the model declared it: declared after File, File's
882/// rows would sit under the application's name and About, Hide and Quit in
883/// a second, ordinary menu. A later App-role menu is left out, since a bar
884/// has one application menu. A leaf or separator at the top level is left
885/// out too: the bar holds menus only.
886#[cfg(any(target_os = "macos", test))]
887pub(crate) fn mac_bar_plan(snapshot: &NativeMenuSnapshot) -> MacBarPlan<'_> {
888 let is_app = |node: &NativeMenuNode| {
889 matches!(
890 node,
891 NativeMenuNode::Standard {
892 role: StandardMenuRole::App,
893 ..
894 }
895 )
896 };
897 let app = snapshot.roots.iter().find(|node| is_app(node));
898 let rest = snapshot.roots.iter().filter(|node| {
899 !is_app(node)
900 && matches!(
901 node,
902 NativeMenuNode::Submenu { .. } | NativeMenuNode::Standard { .. }
903 )
904 });
905 let menus: Vec<&NativeMenuNode> = app.into_iter().chain(rest).collect();
906 let first = |wanted: StandardMenuRole| {
907 menus.iter().position(
908 |node| matches!(node, NativeMenuNode::Standard { role, .. } if *role == wanted),
909 )
910 };
911 MacBarPlan {
912 windows_menu: first(StandardMenuRole::Window),
913 help_menu: first(StandardMenuRole::Help),
914 menus,
915 }
916}
917
918/// Whether the macOS backend evaluates a window again for the Window menu
919/// it registers, by flipping its `excludedFromWindowsMenu`: a window that
920/// is not excluded (as a dialog is) and that AppKit lists, on screen
921/// (`visible`) or in the Dock (`miniaturized`). AppKit adds a row for a
922/// window it evaluates with the flag off, so flipping a hidden window's
923/// would list a window the app hid.
924#[cfg(any(target_os = "macos", test))]
925pub(crate) fn mac_relists_window(excluded: bool, visible: bool, miniaturized: bool) -> bool {
926 !excluded && (visible || miniaturized)
927}
928
929/// A row of the macOS App menu. See [`mac_app_menu_rows`].
930#[cfg(any(target_os = "macos", test))]
931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
932pub(crate) enum MacAppMenuRow {
933 /// About, AppKit's `orderFrontStandardAboutPanel:`.
934 About,
935 /// Settings, routed to the app.
936 Settings,
937 /// The Services submenu, registered as `NSApp.servicesMenu`.
938 Services,
939 /// Hide <App>, `hide:`, ⌘H.
940 Hide,
941 /// Hide Others, `hideOtherApplications:`, ⌥⌘H.
942 HideOthers,
943 /// Show All, `unhideAllApplications:`.
944 ShowAll,
945 /// Quit, routed to the app.
946 Quit,
947 /// A separator.
948 Separator,
949}
950
951/// The rows of the macOS App menu, in the order AppKit's own application
952/// menu has them: About; Settings in a group of its own when the app routes
953/// it; Services; Hide, Hide Others and Show All; Quit.
954#[cfg(any(target_os = "macos", test))]
955pub(crate) fn mac_app_menu_rows(settings: bool) -> Vec<MacAppMenuRow> {
956 use MacAppMenuRow::*;
957 let mut rows = vec![About, Separator];
958 if settings {
959 rows.extend([Settings, Separator]);
960 }
961 rows.extend([
962 Services, Separator, Hide, HideOthers, ShowAll, Separator, Quit,
963 ]);
964 rows
965}
966
967// ============================================================
968// Default backend factory
969// ============================================================
970
971/// The default native-menu backend for the current target: macOS gets the real
972/// `NSMenu` backend, every other target gets [`NoopNativeMenuBackend`].
973pub fn default_backend() -> Box<dyn NativeMenuBackend> {
974 #[cfg(target_os = "macos")]
975 {
976 Box::new(macos::MacOsNativeMenuBackend::new())
977 }
978 #[cfg(not(target_os = "macos"))]
979 {
980 Box::new(NoopNativeMenuBackend::new())
981 }
982}
983
984// ============================================================
985// MemoryNativeMenuBackend (test backend)
986// ============================================================
987
988/// Recording backend for headless tests. Captures the snapshot set per window,
989/// which window is active, item deltas, and cleared windows. Cloneable; clones
990/// share the recording so a test can keep a clone after handing one to
991/// [`NativeMenuHandle::new`].
992#[derive(Clone, Default)]
993pub struct MemoryNativeMenuBackend {
994 inner: Rc<RefCell<MemoryRecording>>,
995}
996
997#[derive(Default)]
998struct MemoryRecording {
999 menus: HashMap<TeksiloWindowId, NativeMenuSnapshot>,
1000 app_menu: Option<NativeMenuSnapshot>,
1001 /// The last window `activate_window` named.
1002 key: Option<TeksiloWindowId>,
1003 /// The window whose menu shows; `None` while the application menu does.
1004 active: Option<TeksiloWindowId>,
1005 /// The window each window belongs to (`set_window_owner`).
1006 owners: HashMap<TeksiloWindowId, TeksiloWindowId>,
1007 /// Whether the application menu set last was declared by the app.
1008 app_declared: bool,
1009 deltas: Vec<(MenuItemId, MenuItemDelta)>,
1010 cleared: Vec<TeksiloWindowId>,
1011}
1012
1013impl MemoryRecording {
1014 /// The bar shown while `key` is key, by [`key_window_bar`].
1015 fn bar_for(&self, key: TeksiloWindowId) -> Option<TeksiloWindowId> {
1016 key_window_bar(
1017 key,
1018 |window| self.menus.contains_key(&window),
1019 |window| self.owners.get(&window).copied(),
1020 self.app_declared,
1021 self.active,
1022 )
1023 }
1024}
1025
1026impl MemoryNativeMenuBackend {
1027 /// Build a new empty recording backend.
1028 pub fn new() -> Self {
1029 Self::default()
1030 }
1031
1032 /// The snapshot currently set for `window_id`, if any.
1033 pub fn menu_for(&self, window_id: TeksiloWindowId) -> Option<NativeMenuSnapshot> {
1034 self.inner.borrow().menus.get(&window_id).cloned()
1035 }
1036
1037 /// The window whose own menu shows, as on macOS: the one
1038 /// [`key_window_bar`] picks for the key window (the last
1039 /// `activate_window`), or a window that set one before any was
1040 /// activated. `None` while the application menu shows.
1041 pub fn active_window(&self) -> Option<TeksiloWindowId> {
1042 self.inner.borrow().active
1043 }
1044
1045 /// Whether the application menu is the one showing: no window's own
1046 /// menu is ([`active_window`](Self::active_window) is `None`).
1047 pub fn shows_app_menu(&self) -> bool {
1048 self.inner.borrow().active.is_none()
1049 }
1050
1051 /// All item deltas applied so far, in order.
1052 pub fn deltas(&self) -> Vec<(MenuItemId, MenuItemDelta)> {
1053 self.inner.borrow().deltas.clone()
1054 }
1055
1056 /// Windows whose menus were cleared, in order.
1057 pub fn cleared(&self) -> Vec<TeksiloWindowId> {
1058 self.inner.borrow().cleared.clone()
1059 }
1060
1061 /// The application menu last set, if any.
1062 pub fn app_menu(&self) -> Option<NativeMenuSnapshot> {
1063 self.inner.borrow().app_menu.clone()
1064 }
1065}
1066
1067impl NativeMenuBackend for MemoryNativeMenuBackend {
1068 fn set_window_menu(
1069 &mut self,
1070 window_id: TeksiloWindowId,
1071 menu: NativeMenuSnapshot,
1072 _poster: Arc<dyn AppEventPoster>,
1073 ) {
1074 let mut rec = self.inner.borrow_mut();
1075 rec.menus.insert(window_id, menu);
1076 // As the macOS backend: the bar the key window shows, and a
1077 // window's menu set before any window was activated.
1078 rec.active = match rec.key {
1079 Some(key) => rec.bar_for(key),
1080 None => Some(window_id),
1081 };
1082 }
1083 fn activate_window(&mut self, window_id: TeksiloWindowId) {
1084 let mut rec = self.inner.borrow_mut();
1085 rec.key = Some(window_id);
1086 rec.active = rec.bar_for(window_id);
1087 }
1088 fn set_window_owner(&mut self, window_id: TeksiloWindowId, owner: Option<TeksiloWindowId>) {
1089 let mut rec = self.inner.borrow_mut();
1090 match owner {
1091 Some(owner) => rec.owners.insert(window_id, owner),
1092 None => rec.owners.remove(&window_id),
1093 };
1094 }
1095 fn clear_window(&mut self, window_id: TeksiloWindowId) {
1096 let mut rec = self.inner.borrow_mut();
1097 rec.menus.remove(&window_id);
1098 rec.owners.remove(&window_id);
1099 rec.cleared.push(window_id);
1100 // As the macOS backend: never the dead window's menu.
1101 let bar = bar_after_clear(
1102 window_id,
1103 rec.key,
1104 rec.active,
1105 |window| rec.menus.contains_key(&window),
1106 |window| rec.owners.get(&window).copied(),
1107 rec.app_declared,
1108 );
1109 if let Some(bar) = bar {
1110 rec.active = bar;
1111 }
1112 }
1113 fn update_item(&mut self, id: MenuItemId, delta: MenuItemDelta) {
1114 self.inner.borrow_mut().deltas.push((id, delta));
1115 }
1116 fn set_app_menu(
1117 &mut self,
1118 menu: NativeMenuSnapshot,
1119 _poster: Arc<dyn AppEventPoster>,
1120 declared: bool,
1121 ) {
1122 let mut rec = self.inner.borrow_mut();
1123 rec.app_menu = Some(menu);
1124 rec.app_declared = declared;
1125 // As the macOS backend: the key window's bar again, which the new
1126 // application menu may be.
1127 let key_bar = rec.key.map(|key| rec.bar_for(key));
1128 if let Some(bar) = bar_after_app_menu(key_bar, rec.active) {
1129 rec.active = bar;
1130 }
1131 }
1132}
1133
1134// ============================================================
1135// Tests
1136// ============================================================
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141 use std::sync::Mutex;
1142 use teksilo_core::SubscriptionId;
1143
1144 struct NullPoster;
1145 impl AppEventPoster for NullPoster {
1146 fn post_subscription_event(
1147 &self,
1148 _sub_id: SubscriptionId,
1149 _event: Box<dyn std::any::Any + Send>,
1150 ) {
1151 }
1152 fn post_external(&self, _payload: Box<dyn std::any::Any + Send>) {}
1153 }
1154
1155 fn poster() -> Arc<dyn AppEventPoster> {
1156 Arc::new(NullPoster)
1157 }
1158
1159 fn win(n: u64) -> TeksiloWindowId {
1160 TeksiloWindowId::new(n)
1161 }
1162
1163 fn sample_snapshot(id: MenuItemId) -> NativeMenuSnapshot {
1164 NativeMenuSnapshot {
1165 roots: vec![NativeMenuNode::Submenu {
1166 title: "File".into(),
1167 children: vec![NativeMenuNode::Item {
1168 id,
1169 title: "New".into(),
1170 key_equiv: None,
1171 enabled: true,
1172 visible: true,
1173 check: NativeCheck::None,
1174 }],
1175 }],
1176 }
1177 }
1178
1179 #[test]
1180 fn set_menu_records_snapshot_and_activations() {
1181 let backend = MemoryNativeMenuBackend::new();
1182 let handle = NativeMenuHandle::new(backend.clone());
1183 let id = MenuItemId::next();
1184
1185 let fired = Arc::new(Mutex::new(false));
1186 let fired2 = fired.clone();
1187 let mut acts = HashMap::new();
1188 acts.insert(
1189 id,
1190 NativeMenuActivation {
1191 intent: Some("app.new"),
1192 action: Some(Rc::new(move |_ctx: &mut EventContext| {
1193 *fired2.lock().unwrap() = true;
1194 })),
1195 quits: false,
1196 },
1197 );
1198
1199 handle.set_window_menu(win(1), sample_snapshot(id), acts, poster());
1200
1201 assert!(backend.menu_for(win(1)).is_some());
1202 assert_eq!(backend.active_window(), Some(win(1)));
1203 let act = handle.activation(win(1), id).expect("activation recorded");
1204 assert_eq!(act.intent, Some("app.new"));
1205 assert!(act.action.is_some());
1206 }
1207
1208 #[test]
1209 fn activate_and_clear_window() {
1210 let backend = MemoryNativeMenuBackend::new();
1211 let handle = NativeMenuHandle::new(backend.clone());
1212 let id = MenuItemId::next();
1213 handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
1214 handle.set_window_menu(
1215 win(2),
1216 sample_snapshot(MenuItemId::next()),
1217 HashMap::new(),
1218 poster(),
1219 );
1220
1221 handle.activate_window(win(2));
1222 assert_eq!(backend.active_window(), Some(win(2)));
1223
1224 handle.clear_window(win(2));
1225 assert_eq!(backend.cleared(), vec![win(2)]);
1226 assert!(handle.activation(win(2), id).is_none());
1227 assert!(backend.menu_for(win(2)).is_none());
1228 }
1229
1230 /// A window with no menu of its own, nor an owner with one, shows the
1231 /// application menu the app declared when it becomes key, as the macOS
1232 /// backend does: the previous window's menu used to stay up, its items
1233 /// acting on a window the user had left.
1234 #[test]
1235 fn activating_a_window_without_a_menu_shows_the_application_menu() {
1236 let backend = MemoryNativeMenuBackend::new();
1237 let handle = NativeMenuHandle::new(backend.clone());
1238 handle.set_app_menu(
1239 sample_snapshot(MenuItemId::next()),
1240 HashMap::new(),
1241 poster(),
1242 true,
1243 );
1244 handle.set_window_menu(
1245 win(1),
1246 sample_snapshot(MenuItemId::next()),
1247 HashMap::new(),
1248 poster(),
1249 );
1250 handle.activate_window(win(1));
1251 assert_eq!(backend.active_window(), Some(win(1)));
1252 handle.activate_window(win(2));
1253 assert_eq!(backend.active_window(), None);
1254 assert!(backend.shows_app_menu());
1255 handle.activate_window(win(1));
1256 assert_eq!(backend.active_window(), Some(win(1)), "and back");
1257 }
1258
1259 /// A window that installs its menu while another is key does not take
1260 /// the bar; the key window that closes leaves the application menu.
1261 #[test]
1262 fn the_key_window_decides_and_its_close_leaves_the_application_menu() {
1263 let backend = MemoryNativeMenuBackend::new();
1264 let handle = NativeMenuHandle::new(backend.clone());
1265 handle.activate_window(win(1));
1266 handle.set_window_menu(
1267 win(2),
1268 sample_snapshot(MenuItemId::next()),
1269 HashMap::new(),
1270 poster(),
1271 );
1272 assert!(backend.shows_app_menu(), "window 1 is key and has none");
1273 handle.set_window_menu(
1274 win(1),
1275 sample_snapshot(MenuItemId::next()),
1276 HashMap::new(),
1277 poster(),
1278 );
1279 assert_eq!(backend.active_window(), Some(win(1)));
1280 handle.clear_window(win(1));
1281 assert!(backend.shows_app_menu());
1282 }
1283
1284 /// A row whose only effect is its intent greys out while nothing takes
1285 /// the intent, its own enabled state still counting; a row with an
1286 /// action, and the App menu's Quit, never do.
1287 #[test]
1288 fn an_intent_only_row_greys_out_while_nothing_takes_its_intent() {
1289 let backend = MemoryNativeMenuBackend::new();
1290 let handle = NativeMenuHandle::new(backend.clone());
1291 let (plain, with_action, quit) =
1292 (MenuItemId::next(), MenuItemId::next(), MenuItemId::next());
1293 let activation = |action: bool, quits: bool| NativeMenuActivation {
1294 intent: Some("app.print"),
1295 action: action.then(|| -> MenuActionFn { Rc::new(|_| {}) }),
1296 quits,
1297 };
1298 handle.set_window_menu(
1299 win(1),
1300 sample_snapshot(plain),
1301 [
1302 (plain, activation(false, false)),
1303 (with_action, activation(true, false)),
1304 (quit, activation(false, true)),
1305 ]
1306 .into_iter()
1307 .collect(),
1308 poster(),
1309 );
1310 let enabled_deltas = |backend: &MemoryNativeMenuBackend| {
1311 backend
1312 .deltas()
1313 .into_iter()
1314 .filter_map(|(id, delta)| delta.enabled.map(|enabled| (id, enabled)))
1315 .collect::<Vec<_>>()
1316 };
1317 handle.refresh_availability(&["app.print"]);
1318 assert!(enabled_deltas(&backend).is_empty(), "nothing changed");
1319 handle.refresh_availability(&[]);
1320 assert_eq!(enabled_deltas(&backend), [(plain, false)]);
1321 assert_eq!(handle.gated_enabled(plain), Some(false));
1322 assert_eq!(handle.gated_enabled(with_action), None);
1323 assert_eq!(handle.gated_enabled(quit), None);
1324
1325 // Its own state turning true does not enable it while nothing takes
1326 // the intent; the intent taken again does.
1327 handle.update_item(
1328 plain,
1329 MenuItemDelta {
1330 enabled: Some(true),
1331 ..Default::default()
1332 },
1333 );
1334 assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, false)));
1335 handle.refresh_availability(&["app.print"]);
1336 assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, true)));
1337
1338 // Its own state turning false greys it out whatever the route says.
1339 handle.update_item(
1340 plain,
1341 MenuItemDelta {
1342 enabled: Some(false),
1343 ..Default::default()
1344 },
1345 );
1346 handle.refresh_availability(&["app.print"]);
1347 assert_eq!(enabled_deltas(&backend).last(), Some(&(plain, false)));
1348
1349 handle.clear_window(win(1));
1350 assert_eq!(handle.gated_enabled(plain), None, "forgotten with its menu");
1351 }
1352
1353 fn standard(role: StandardMenuRole, title: &str) -> NativeMenuNode {
1354 NativeMenuNode::Standard {
1355 role,
1356 labels: StandardLabels {
1357 title: title.into(),
1358 ..Default::default()
1359 },
1360 quit_item: None,
1361 settings_item: None,
1362 }
1363 }
1364
1365 fn bar_titles(plan: &MacBarPlan<'_>) -> Vec<String> {
1366 plan.menus
1367 .iter()
1368 .map(|node| match node {
1369 NativeMenuNode::Submenu { title, .. } => title.clone(),
1370 NativeMenuNode::Standard { labels, .. } => labels.title.clone(),
1371 other => panic!("the bar holds menus only, got {other:?}"),
1372 })
1373 .collect()
1374 }
1375
1376 /// AppKit shows the bar's first menu as the application menu, so the
1377 /// App-role menu goes first wherever the model declares it. Declared
1378 /// after File, File's rows sat under the application's name and About,
1379 /// Hide and Quit in a second, ordinary menu (macOS findings, item 2).
1380 #[test]
1381 fn the_app_menu_is_first_in_the_mac_bar_wherever_it_is_declared() {
1382 let mut file = sample_snapshot(MenuItemId::next()).roots;
1383 let snapshot = NativeMenuSnapshot {
1384 roots: vec![
1385 file.remove(0),
1386 NativeMenuNode::Separator,
1387 standard(StandardMenuRole::Window, "Window"),
1388 standard(StandardMenuRole::App, "Probe"),
1389 standard(StandardMenuRole::Help, "Help"),
1390 standard(StandardMenuRole::App, "Second"),
1391 ],
1392 };
1393 assert_eq!(
1394 bar_titles(&mac_bar_plan(&snapshot)),
1395 ["Probe", "File", "Window", "Help"],
1396 "App first, the rest in declared order, one App menu, no top-level separator"
1397 );
1398
1399 let declared_first = NativeMenuSnapshot {
1400 roots: vec![
1401 standard(StandardMenuRole::App, "Probe"),
1402 sample_snapshot(MenuItemId::next()).roots.remove(0),
1403 ],
1404 };
1405 assert_eq!(
1406 bar_titles(&mac_bar_plan(&declared_first)),
1407 ["Probe", "File"]
1408 );
1409 }
1410
1411 /// A window registered again in the Window menu is one AppKit lists:
1412 /// not excluded, and on screen or in the Dock. A hidden window (ordered
1413 /// out, not miniaturized) is left alone: flipping its flag lists it,
1414 /// since AppKit keeps a row for every window it evaluates with the flag
1415 /// off, and a window the app hid would show in the Window and Dock
1416 /// menus.
1417 #[test]
1418 fn the_mac_window_menu_relists_only_shown_or_miniaturized_windows() {
1419 assert!(mac_relists_window(false, true, false), "on screen");
1420 assert!(mac_relists_window(false, false, true), "in the Dock");
1421 assert!(!mac_relists_window(false, false, false), "hidden");
1422 assert!(
1423 !mac_relists_window(true, true, false),
1424 "excluded (a dialog)"
1425 );
1426 }
1427
1428 /// The App menu's rows in AppKit's order: About; Settings in a group of
1429 /// its own; Services; Hide, Hide Others and Show All; Quit.
1430 #[test]
1431 fn the_mac_app_menu_has_appkits_rows_in_appkits_order() {
1432 use MacAppMenuRow::*;
1433 assert_eq!(
1434 mac_app_menu_rows(true),
1435 [
1436 About, Separator, Settings, Separator, Services, Separator, Hide, HideOthers,
1437 ShowAll, Separator, Quit,
1438 ]
1439 );
1440 assert_eq!(
1441 mac_app_menu_rows(false),
1442 [
1443 About, Separator, Services, Separator, Hide, HideOthers, ShowAll, Separator, Quit,
1444 ],
1445 "no Settings row, and no empty group for it"
1446 );
1447 }
1448
1449 /// AppKit keeps the window list in the menu registered as `windowsMenu`,
1450 /// and the Dock icon's menu lists the windows from it: the bar's first
1451 /// Window-role menu when it declares one, else a hidden one the backend
1452 /// adds (Qt's `insertWindowMenu`), so the Dock still lists the windows
1453 /// (macOS findings, item 3).
1454 #[test]
1455 fn the_mac_bar_keeps_the_window_list_in_its_window_menu_or_a_hidden_one() {
1456 let snapshot = NativeMenuSnapshot {
1457 roots: vec![
1458 sample_snapshot(MenuItemId::next()).roots.remove(0),
1459 standard(StandardMenuRole::Window, "Window"),
1460 standard(StandardMenuRole::App, "Probe"),
1461 standard(StandardMenuRole::Window, "Second window menu"),
1462 standard(StandardMenuRole::Help, "Help"),
1463 ],
1464 };
1465 let plan = mac_bar_plan(&snapshot);
1466 assert_eq!(
1467 plan.windows_menu,
1468 Some(2),
1469 "the first Window menu, in bar order"
1470 );
1471 assert_eq!(plan.help_menu, Some(4));
1472
1473 let no_window_menu = NativeMenuSnapshot {
1474 roots: vec![
1475 sample_snapshot(MenuItemId::next()).roots.remove(0),
1476 standard(StandardMenuRole::App, "Probe"),
1477 ],
1478 };
1479 let plan = mac_bar_plan(&no_window_menu);
1480 assert_eq!(plan.windows_menu, None, "a hidden one is added");
1481 assert_eq!(plan.help_menu, None);
1482 }
1483
1484 /// The bar a key window shows: its own menu; else the menu of the window
1485 /// it belongs to (a modal dialog's owner, up the chain of owners); else
1486 /// the application menu, or, when the application declared none, the bar
1487 /// shown before, as AppKit keeps `mainMenu` up for its panels and
1488 /// sheets. A dialog used to show the application menu, so an app with
1489 /// no application menu lost File and Edit whenever one was key.
1490 #[test]
1491 fn a_key_window_without_a_menu_shows_its_owners_or_the_applications() {
1492 let menus = [win(1)];
1493 let has_menu = |w: TeksiloWindowId| menus.contains(&w);
1494 // 2 is a dialog of 1, 3 a dialog of 2; 4 is a secondary window.
1495 let owner = |w: TeksiloWindowId| match w.raw() {
1496 2 => Some(win(1)),
1497 3 => Some(win(2)),
1498 _ => None,
1499 };
1500 let bar = |key, declared, shown| key_window_bar(key, has_menu, owner, declared, shown);
1501 assert_eq!(bar(win(1), true, None), Some(win(1)), "its own");
1502 assert_eq!(bar(win(2), true, None), Some(win(1)), "its owner's");
1503 assert_eq!(bar(win(3), true, None), Some(win(1)), "up the chain");
1504 assert_eq!(bar(win(4), true, Some(win(1))), None, "the application's");
1505 assert_eq!(
1506 bar(win(4), false, Some(win(1))),
1507 Some(win(1)),
1508 "none declared: the bar shown before stays"
1509 );
1510 assert_eq!(bar(win(4), false, None), None);
1511
1512 // An owner relation that loops ends the walk rather than the loop.
1513 let looping = |w: TeksiloWindowId| Some(if w == win(5) { win(6) } else { win(5) });
1514 assert_eq!(key_window_bar(win(5), has_menu, looping, true, None), None);
1515 }
1516
1517 /// `MemoryNativeMenuBackend` follows the same rule as the macOS one.
1518 #[test]
1519 fn the_memory_backend_shows_a_dialogs_owners_menu() {
1520 let backend = MemoryNativeMenuBackend::new();
1521 let handle = NativeMenuHandle::new(backend.clone());
1522 handle.set_app_menu(
1523 sample_snapshot(MenuItemId::next()),
1524 HashMap::new(),
1525 poster(),
1526 false,
1527 );
1528 handle.set_window_menu(
1529 win(1),
1530 sample_snapshot(MenuItemId::next()),
1531 HashMap::new(),
1532 poster(),
1533 );
1534 handle.set_window_owner(win(2), Some(win(1)));
1535 handle.activate_window(win(1));
1536 handle.activate_window(win(2));
1537 assert_eq!(
1538 backend.active_window(),
1539 Some(win(1)),
1540 "the dialog's owner's"
1541 );
1542 handle.activate_window(win(3));
1543 assert_eq!(
1544 backend.active_window(),
1545 Some(win(1)),
1546 "no application menu declared: a secondary window keeps the bar"
1547 );
1548 handle.set_app_menu(
1549 sample_snapshot(MenuItemId::next()),
1550 HashMap::new(),
1551 poster(),
1552 true,
1553 );
1554 handle.activate_window(win(3));
1555 assert!(backend.shows_app_menu(), "declared: the application's");
1556 handle.activate_window(win(2));
1557 assert_eq!(backend.active_window(), Some(win(1)));
1558 handle.clear_window(win(1));
1559 assert!(backend.shows_app_menu(), "the owner's menu went with it");
1560 }
1561
1562 /// Declaring the application menu while a window with no menu of its own
1563 /// is key shows it at once, without waiting for focus to move: the bar
1564 /// left up was another window's, kept only because no application menu
1565 /// was declared. Declaring it while the key window shows its own menu
1566 /// leaves that one up.
1567 #[test]
1568 fn declaring_the_application_menu_shows_it_for_a_menu_less_key_window() {
1569 let backend = MemoryNativeMenuBackend::new();
1570 let handle = NativeMenuHandle::new(backend.clone());
1571 let app_menu = |declared| {
1572 handle.set_app_menu(
1573 sample_snapshot(MenuItemId::next()),
1574 HashMap::new(),
1575 poster(),
1576 declared,
1577 )
1578 };
1579 app_menu(false);
1580 handle.set_window_menu(
1581 win(1),
1582 sample_snapshot(MenuItemId::next()),
1583 HashMap::new(),
1584 poster(),
1585 );
1586 handle.activate_window(win(1));
1587 handle.activate_window(win(2));
1588 assert_eq!(backend.active_window(), Some(win(1)), "precondition");
1589 app_menu(true);
1590 assert!(backend.shows_app_menu(), "window 2 is key and has none");
1591
1592 handle.activate_window(win(1));
1593 app_menu(true);
1594 assert_eq!(
1595 backend.active_window(),
1596 Some(win(1)),
1597 "a key window's own menu stays"
1598 );
1599 }
1600
1601 /// After a window's menu is forgotten the bar is never that window's,
1602 /// whoever is key: the window itself, a dialog it owned, another window
1603 /// with or without a menu, none; with an application menu declared or
1604 /// not. A bar that was not the forgotten window's stays.
1605 #[test]
1606 fn a_cleared_windows_bar_never_comes_back() {
1607 let (closed, dialog, other, plain) = (win(1), win(2), win(3), win(4));
1608 // `has_menu` and `owner` still answer for the closed window, as a
1609 // backend that had not forgotten it yet would.
1610 let has_menu = |w: TeksiloWindowId| w == closed || w == other;
1611 let owner = |w: TeksiloWindowId| (w == dialog).then_some(closed);
1612 for declared in [true, false] {
1613 for key in [Some(closed), Some(dialog), Some(other), Some(plain), None] {
1614 let bar = bar_after_clear(closed, key, Some(closed), has_menu, owner, declared)
1615 .expect("its bar was up: replaced");
1616 assert_ne!(bar, Some(closed), "key {key:?}, declared {declared}");
1617 if key == Some(other) {
1618 assert_eq!(bar, Some(other), "the key window's own menu");
1619 }
1620 }
1621 assert_eq!(
1622 bar_after_clear(closed, Some(plain), Some(other), has_menu, owner, declared),
1623 None,
1624 "another window's bar stays"
1625 );
1626 }
1627 }
1628
1629 /// The rule both backends follow when the application menu is set:
1630 /// show what [`key_window_bar`] picks for the key window when it differs
1631 /// from the bar up, and the new application menu whenever that is the
1632 /// pick; with no window key yet, replace only an application menu
1633 /// already showing.
1634 #[test]
1635 fn what_setting_the_application_menu_shows() {
1636 let app = None;
1637 assert_eq!(bar_after_app_menu(Some(app), Some(win(1))), Some(app));
1638 assert_eq!(bar_after_app_menu(Some(app), app), Some(app), "rebuilt");
1639 assert_eq!(bar_after_app_menu(Some(Some(win(1))), Some(win(1))), None);
1640 assert_eq!(
1641 bar_after_app_menu(Some(Some(win(1))), app),
1642 Some(Some(win(1)))
1643 );
1644 assert_eq!(bar_after_app_menu(None, app), Some(app));
1645 assert_eq!(bar_after_app_menu(None, Some(win(1))), None);
1646 }
1647
1648 /// A refresh whose inputs are the last refresh's (the same intents
1649 /// taken, no menu installed since) evaluates no row: the loop asks every
1650 /// turn, and most turns change nothing. A menu installed since, or
1651 /// another answer, is evaluated again.
1652 #[test]
1653 fn an_unchanged_refresh_evaluates_nothing() {
1654 let backend = MemoryNativeMenuBackend::new();
1655 let handle = NativeMenuHandle::new(backend.clone());
1656 let row = MenuItemId::next();
1657 let intent_only = || NativeMenuActivation {
1658 intent: Some("app.print"),
1659 action: None,
1660 quits: false,
1661 };
1662 handle.set_window_menu(
1663 win(1),
1664 sample_snapshot(row),
1665 [(row, intent_only())].into_iter().collect(),
1666 poster(),
1667 );
1668 handle.refresh_availability(&["app.print"]);
1669 assert_eq!(handle.evaluations(), 1);
1670 handle.refresh_availability(&["app.print"]);
1671 assert_eq!(handle.evaluations(), 1, "nothing changed: no row evaluated");
1672
1673 let other = MenuItemId::next();
1674 handle.set_window_menu(
1675 win(2),
1676 sample_snapshot(other),
1677 [(other, intent_only())].into_iter().collect(),
1678 poster(),
1679 );
1680 handle.refresh_availability(&["app.print"]);
1681 assert_eq!(handle.evaluations(), 3, "a menu installed since: both rows");
1682 handle.refresh_availability(&[]);
1683 assert_eq!(handle.evaluations(), 5, "another answer");
1684 assert_eq!(handle.gated_enabled(other), Some(false));
1685 }
1686
1687 /// The no-op backend shows no menu bar, so the loop skips the
1688 /// availability refresh altogether where it is the backend.
1689 #[test]
1690 fn only_a_backend_with_a_menu_bar_says_it_shows_one() {
1691 assert!(!NativeMenuHandle::new(NoopNativeMenuBackend::new()).shows_bar());
1692 assert!(NativeMenuHandle::new(MemoryNativeMenuBackend::new()).shows_bar());
1693 }
1694
1695 #[test]
1696 fn update_item_records_delta() {
1697 let backend = MemoryNativeMenuBackend::new();
1698 let handle = NativeMenuHandle::new(backend.clone());
1699 let id = MenuItemId::next();
1700 handle.update_item(
1701 id,
1702 MenuItemDelta {
1703 enabled: Some(false),
1704 check: Some(NativeCheck::On),
1705 ..Default::default()
1706 },
1707 );
1708 let deltas = backend.deltas();
1709 assert_eq!(deltas.len(), 1);
1710 assert_eq!(deltas[0].0, id);
1711 assert_eq!(deltas[0].1.enabled, Some(false));
1712 assert_eq!(deltas[0].1.check, Some(NativeCheck::On));
1713 }
1714
1715 #[test]
1716 fn noop_backend_is_inert() {
1717 let handle = NativeMenuHandle::new(NoopNativeMenuBackend::new());
1718 let id = MenuItemId::next();
1719 handle.set_window_menu(win(1), sample_snapshot(id), HashMap::new(), poster());
1720 handle.activate_window(win(1));
1721 handle.update_item(id, MenuItemDelta::default());
1722 handle.clear_window(win(1));
1723 // No activation was recorded for noop set? Activations live in the
1724 // handle, not the backend, so they ARE recorded then cleared.
1725 assert!(handle.activation(win(1), id).is_none());
1726 }
1727}