Skip to main content

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}