frust_reactive/menu.rs
1//! Process-wide **menu-activation** source: a desktop shell delivers a native
2//! menu item's activation (an NSApp menu bar item on macOS, a `TranslateAccelerator`
3//! -dispatched HMENU item on Windows) via [`push_menu_event`]; app code reads the
4//! current state through [`menu_events`]/[`MenuEvents`] — the `frust` facade
5//! re-exports both as `frust::menu_events()`/`frust::MenuEvents`, so app code
6//! never names this crate directly.
7//!
8//! This mirrors the deep-link source next door (`frust-reactive::deep_link`) in
9//! both shape and layering: one process-wide slot holding a lazily-created
10//! [`RwSignal`], a shell-only push entry point, and an app-facing read surface —
11//! and, like it, `frust-reactive` stays a leaf: the *menu vocabulary* (which
12//! items exist, their labels/accelerators/roles) lives in the desktop shell tier
13//! that owns the platform menu, never here. All that crosses this seam is the
14//! activated item's id.
15//!
16//! # Why this is a reactive source, not a `theme_override`-style polled slot
17//!
18//! `frust-shell-common::theme_override`'s module docs draw the line: a value
19//! every shell polls once per frame and pushes into non-reactive delivery paths
20//! belongs there, while a source app code *subscribes* to belongs here. A menu
21//! activation is the second kind — an app reacts to "Preferences… was chosen" by
22//! writing its own state from a tracked read, exactly as it reacts to a deep
23//! link, so the write must wake the shell through the tracked-signal/[`FrameWaker`]
24//! machinery. (The per-OS shell still *drains* its platform menu queue once per
25//! frame; that pump is the producer side, and it pushes here.)
26//!
27//! [`FrameWaker`]: crate::FrameWaker
28//!
29//! # A sequence number, not a bare id
30//!
31//! Each activation is an *event*, and unlike a deep link the same payload
32//! repeats constantly — "Zoom In" chosen three times is three activations of one
33//! id. So [`MenuEvent`] carries a monotonically increasing [`sequence`](MenuEvent::sequence)
34//! beside the id, and glue dedupes by the last sequence it consumed (the
35//! `RouterDeepLinks` consumed-marker pattern, and the same reasoning that makes
36//! the back-press source a counter — see `crate::back`). Without it, a second
37//! activation of an already-consumed id would be indistinguishable from the
38//! first for any consumer that compares values rather than tracking writes.
39//!
40//! # Thread contract
41//!
42//! [`push_menu_event`] must be called on the UI thread — the one
43//! [`ReactiveRuntime::init`] ran on — mirroring [`push_deep_link`](crate::push_deep_link)'s
44//! contract: a desktop shell drains its platform menu queue from inside the
45//! winit event loop, i.e. always on the UI thread, so an off-thread call is a
46//! wiring bug, not a runtime-data condition, and panics with the same message
47//! convention `Executor::spawn_local` uses.
48//!
49//! A push that races ahead of [`ReactiveRuntime::init`] (the shell installs the
50//! menu only after `run_desktop` has initialized the runtime, so this should not
51//! happen through it) is **dropped with a logged warning** rather than panicking
52//! or buffering indefinitely — the same rationale the deep-link source records:
53//! buffering would need a bound and a flush point for a path that isn't expected
54//! to be exercised.
55
56use std::sync::OnceLock;
57
58use reactive_graph::signal::RwSignal;
59use reactive_graph::traits::Update;
60
61use crate::ReactiveRuntime;
62use crate::executor::is_ui_thread;
63
64/// One delivered menu activation: the id the app gave the item in its
65/// `MenuSpec` (e.g. `"file.open"`), plus the process-wide sequence number of
66/// this activation (see the module docs). The id is passed through verbatim —
67/// mapping it to an action is app code's job, not this crate's.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct MenuEvent {
70 /// The activated item's app-chosen id, exactly as it appeared in the
71 /// `MenuSpec` the shell was configured with.
72 pub id: String,
73 /// This activation's position in the process-wide activation sequence,
74 /// starting at `1` for the first push. Strictly increasing, so glue can
75 /// dedupe by "the last sequence I consumed" and tell a repeat activation of
76 /// the same id apart from a re-read of the previous one.
77 pub sequence: u64,
78}
79
80impl MenuEvent {
81 /// Pair an item id with its activation sequence number.
82 ///
83 /// Public for glue and tests that need to construct the value the signal
84 /// carries; production pushes go through [`push_menu_event`], which assigns
85 /// the sequence itself.
86 pub fn new(id: impl Into<String>, sequence: u64) -> Self {
87 Self {
88 id: id.into(),
89 sequence,
90 }
91 }
92}
93
94/// The app-facing menu-activation read surface. Obtained via [`menu_events`]
95/// (`frust::menu_events()` at the facade).
96#[derive(Clone)]
97pub struct MenuEvents {
98 /// The most recently activated item, or `None` when nothing has been
99 /// activated in this process yet. Read/track it with the `Get`/`Track`
100 /// traits (`frust::{Get, Track}`) the same way any other `RwSignal` is read.
101 ///
102 /// There is deliberately no `initial`-style snapshot beside it (the
103 /// deep-link source's cold-start half): a menu cannot be activated before
104 /// the app it belongs to is running, so "the first activation ever" carries
105 /// no precedence meaning worth recording.
106 pub latest: RwSignal<Option<MenuEvent>>,
107}
108
109/// The process-wide menu-activation slot. Lazily created (mirroring
110/// [`ReactiveRuntime`]'s own process-wide, lazily-installed static, and the
111/// deep-link slot next door) on first access once [`ReactiveRuntime`] exists,
112/// without needing to modify `ReactiveRuntime::init` itself.
113///
114/// The sequence counter needs no state of its own: each push derives the next
115/// number from the value already in the signal, so the signal *is* the whole
116/// slot.
117static SLOT: OnceLock<RwSignal<Option<MenuEvent>>> = OnceLock::new();
118
119/// Returns the process-wide signal, creating it (under the reactive root
120/// [`Owner`](reactive_graph::owner::Owner)) on first access.
121///
122/// # Panics
123///
124/// Panics if [`ReactiveRuntime::init`] has not run yet — reading a menu event
125/// before the reactive runtime exists (`push_menu_event`'s pre-init case is
126/// handled separately, before this is ever called) is a genuine wiring bug: the
127/// caller must initialize the runtime first.
128fn slot() -> &'static RwSignal<Option<MenuEvent>> {
129 SLOT.get_or_init(|| {
130 let rt = ReactiveRuntime::get().expect(
131 "frust-reactive: menu_events() was called before ReactiveRuntime::init — an app \
132 must run under the Frust facade's entry point (which initializes the reactive \
133 runtime) before reading menu events",
134 );
135 rt.with_owner(|| RwSignal::new(None))
136 })
137}
138
139/// Deliver a native menu activation into the process-wide source. Called by a
140/// desktop shell's per-frame menu pump on the UI thread; app code never calls
141/// this directly.
142///
143/// The activation is stamped with the next sequence number (derived from the
144/// one already in the signal, starting at `1`) and written to
145/// [`MenuEvents::latest`], so a tracked reader is woken even when the same item
146/// is chosen twice in a row (see the module docs).
147///
148/// # Panics
149///
150/// Panics if called off the UI thread (see the module docs' thread contract). A
151/// call before [`ReactiveRuntime::init`] does **not** panic — it is dropped with
152/// a logged warning (see the module docs).
153pub fn push_menu_event(id: impl Into<String>) {
154 let id = id.into();
155
156 if !is_ui_thread() {
157 panic!(
158 "frust-reactive: push_menu_event was called off the UI thread. Menu events can \
159 only be pushed from the UI thread (the one `ReactiveRuntime::init` ran on) — this \
160 is a wiring bug: drain the platform menu queue from the shell's own event loop, \
161 the same contract `Executor::spawn_local` enforces."
162 );
163 }
164
165 if ReactiveRuntime::get().is_none() {
166 eprintln!(
167 "frust-reactive: push_menu_event(\"{id}\") dropped — ReactiveRuntime::init has \
168 not run yet. A shell installs its platform menu only after the runtime exists; \
169 reaching this indicates an odd init-ordering race, not normal operation."
170 );
171 return;
172 }
173
174 slot().update(|latest| {
175 let sequence = latest.as_ref().map_or(1, |event| event.sequence + 1);
176 *latest = Some(MenuEvent::new(id, sequence));
177 });
178}
179
180/// The current menu-activation read surface: the live [`MenuEvents::latest`]
181/// signal. Call from a tracked context (e.g. inside `Component::build`) to
182/// observe subsequent activations as they arrive.
183pub fn menu_events() -> MenuEvents {
184 MenuEvents { latest: *slot() }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::{FrameWaker, TrackedScope};
191 use reactive_graph::traits::{Get, GetUntracked};
192 use std::sync::Arc;
193 use std::sync::atomic::{AtomicUsize, Ordering};
194
195 /// A recording waker: an `Arc<AtomicUsize>` bumped once per `wake()`.
196 fn recording_waker() -> (FrameWaker, Arc<AtomicUsize>) {
197 let counter = Arc::new(AtomicUsize::new(0));
198 let seen = counter.clone();
199 let waker: FrameWaker = Arc::new(move || {
200 counter.fetch_add(1, Ordering::SeqCst);
201 });
202 (waker, seen)
203 }
204
205 /// Every acceptance criterion in one `#[test]`, serialized on the shared
206 /// waker lock (`ReactiveRuntime::init` here swaps the process-wide waker,
207 /// which would otherwise race the other waker-asserting tests in this
208 /// crate — see `WAKER_TEST_LOCK`'s doc comment in `lib.rs`). The slot is
209 /// process-wide with no reset, so every assertion below is relative to the
210 /// sequence observed at the start rather than an absolute number.
211 #[test]
212 fn menu_push_and_wake_bridge() {
213 let _guard = crate::WAKER_TEST_LOCK
214 .lock()
215 .unwrap_or_else(|e| e.into_inner());
216
217 let (waker, wakes) = recording_waker();
218 let _rt = ReactiveRuntime::init(waker);
219
220 // Criterion: push before any tracked read — a late subscriber sees it
221 // immediately through the live signal.
222 push_menu_event("file.open");
223 let first = menu_events()
224 .latest
225 .get_untracked()
226 .expect("a push must leave the latest activation readable");
227 assert_eq!(first.id, "file.open");
228
229 // A tracked scope reading `latest` after the push observes the
230 // already-pushed activation with no wake needed — an ordinary read.
231 let scope = TrackedScope::new();
232 let seen = scope.track(|| menu_events().latest.get());
233 assert_eq!(seen, Some(first.clone()));
234 assert!(!scope.is_dirty(), "a fresh track starts clean");
235
236 // Criterion: push after a tracked read fires the signal-write -> wake
237 // contract — the tracked scope re-dirties and the waker fires exactly
238 // once (coalesced).
239 let before = wakes.load(Ordering::SeqCst);
240 push_menu_event("file.save");
241 assert!(
242 scope.is_dirty(),
243 "push_menu_event must dirty a scope tracking `latest`"
244 );
245 assert_eq!(
246 wakes.load(Ordering::SeqCst) - before,
247 1,
248 "a push must fire the waker exactly once"
249 );
250
251 let second = menu_events()
252 .latest
253 .get_untracked()
254 .expect("the second push is readable too");
255 assert_eq!(second.id, "file.save");
256 assert_eq!(
257 second.sequence,
258 first.sequence + 1,
259 "each activation takes the next sequence number"
260 );
261
262 // Criterion: the same id activated twice is still two distinguishable
263 // events — the reason the sequence number exists at all (see the module
264 // docs).
265 push_menu_event("file.save");
266 let third = menu_events()
267 .latest
268 .get_untracked()
269 .expect("the repeat activation is readable");
270 assert_eq!(third.id, second.id);
271 assert_ne!(
272 third, second,
273 "a repeat activation of the same id must not compare equal to the previous one"
274 );
275 assert_eq!(third.sequence, second.sequence + 1);
276 }
277
278 /// Criterion: the UI-thread contract is enforced (mirrors
279 /// `push_deep_link`'s off-thread panic). Also serializes on the waker lock
280 /// since `ReactiveRuntime::init` swaps the process-wide waker.
281 #[test]
282 #[should_panic(expected = "wiring bug")]
283 fn push_off_ui_thread_panics() {
284 let _guard = crate::WAKER_TEST_LOCK
285 .lock()
286 .unwrap_or_else(|e| e.into_inner());
287 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
288
289 std::thread::spawn(|| {
290 push_menu_event("file.open");
291 })
292 .join()
293 .unwrap_or_else(|e| std::panic::resume_unwind(e));
294 }
295}