use std::sync::OnceLock;
use reactive_graph::signal::RwSignal;
use reactive_graph::traits::Update;
use crate::ReactiveRuntime;
use crate::executor::is_ui_thread;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MenuEvent {
pub id: String,
pub sequence: u64,
}
impl MenuEvent {
pub fn new(id: impl Into<String>, sequence: u64) -> Self {
Self {
id: id.into(),
sequence,
}
}
}
#[derive(Clone)]
pub struct MenuEvents {
pub latest: RwSignal<Option<MenuEvent>>,
}
static SLOT: OnceLock<RwSignal<Option<MenuEvent>>> = OnceLock::new();
fn slot() -> &'static RwSignal<Option<MenuEvent>> {
SLOT.get_or_init(|| {
let rt = ReactiveRuntime::get().expect(
"frust-reactive: menu_events() was called before ReactiveRuntime::init — an app \
must run under the Frust facade's entry point (which initializes the reactive \
runtime) before reading menu events",
);
rt.with_owner(|| RwSignal::new(None))
})
}
pub fn push_menu_event(id: impl Into<String>) {
let id = id.into();
if !is_ui_thread() {
panic!(
"frust-reactive: push_menu_event was called off the UI thread. Menu events can \
only be pushed from the UI thread (the one `ReactiveRuntime::init` ran on) — this \
is a wiring bug: drain the platform menu queue from the shell's own event loop, \
the same contract `Executor::spawn_local` enforces."
);
}
if ReactiveRuntime::get().is_none() {
eprintln!(
"frust-reactive: push_menu_event(\"{id}\") dropped — ReactiveRuntime::init has \
not run yet. A shell installs its platform menu only after the runtime exists; \
reaching this indicates an odd init-ordering race, not normal operation."
);
return;
}
slot().update(|latest| {
let sequence = latest.as_ref().map_or(1, |event| event.sequence + 1);
*latest = Some(MenuEvent::new(id, sequence));
});
}
pub fn menu_events() -> MenuEvents {
MenuEvents { latest: *slot() }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FrameWaker, TrackedScope};
use reactive_graph::traits::{Get, GetUntracked};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn recording_waker() -> (FrameWaker, Arc<AtomicUsize>) {
let counter = Arc::new(AtomicUsize::new(0));
let seen = counter.clone();
let waker: FrameWaker = Arc::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
(waker, seen)
}
#[test]
fn menu_push_and_wake_bridge() {
let _guard = crate::WAKER_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (waker, wakes) = recording_waker();
let _rt = ReactiveRuntime::init(waker);
push_menu_event("file.open");
let first = menu_events()
.latest
.get_untracked()
.expect("a push must leave the latest activation readable");
assert_eq!(first.id, "file.open");
let scope = TrackedScope::new();
let seen = scope.track(|| menu_events().latest.get());
assert_eq!(seen, Some(first.clone()));
assert!(!scope.is_dirty(), "a fresh track starts clean");
let before = wakes.load(Ordering::SeqCst);
push_menu_event("file.save");
assert!(
scope.is_dirty(),
"push_menu_event must dirty a scope tracking `latest`"
);
assert_eq!(
wakes.load(Ordering::SeqCst) - before,
1,
"a push must fire the waker exactly once"
);
let second = menu_events()
.latest
.get_untracked()
.expect("the second push is readable too");
assert_eq!(second.id, "file.save");
assert_eq!(
second.sequence,
first.sequence + 1,
"each activation takes the next sequence number"
);
push_menu_event("file.save");
let third = menu_events()
.latest
.get_untracked()
.expect("the repeat activation is readable");
assert_eq!(third.id, second.id);
assert_ne!(
third, second,
"a repeat activation of the same id must not compare equal to the previous one"
);
assert_eq!(third.sequence, second.sequence + 1);
}
#[test]
#[should_panic(expected = "wiring bug")]
fn push_off_ui_thread_panics() {
let _guard = crate::WAKER_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _rt = ReactiveRuntime::init(Arc::new(|| {}));
std::thread::spawn(|| {
push_menu_event("file.open");
})
.join()
.unwrap_or_else(|e| std::panic::resume_unwind(e));
}
}