use std::cell::RefCell;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use reactive_graph::signal::RwSignal;
use reactive_graph::traits::Update;
use crate::ReactiveRuntime;
use crate::executor::is_ui_thread;
#[derive(Clone)]
pub struct BackPresses {
pub count: RwSignal<u64>,
}
static COUNTER: OnceLock<RwSignal<u64>> = OnceLock::new();
static HANDLES_BACK: AtomicBool = AtomicBool::new(false);
thread_local! {
static CAN_POP_PROVIDER: RefCell<Option<RegisteredProvider>> =
const { RefCell::new(None) };
static CAN_POP_NEXT_ID: std::cell::Cell<u64> = const { std::cell::Cell::new(1) };
}
type RegisteredProvider = (u64, Box<dyn Fn() -> bool>);
#[must_use = "dropping the registration token without storing it makes the provider impossible to unregister scoped-safely"]
#[derive(Debug)]
pub struct CanPopRegistration(u64);
impl CanPopRegistration {
pub fn unregister(self) {
CAN_POP_PROVIDER.with(|slot| {
let mut slot = slot.borrow_mut();
if slot.as_ref().is_some_and(|(id, _)| *id == self.0) {
*slot = None;
}
});
}
}
fn counter() -> RwSignal<u64> {
*COUNTER.get_or_init(|| {
let rt = ReactiveRuntime::get().expect(
"frust-reactive: back_presses() was called before ReactiveRuntime::init — an app \
must run under the Frust facade's entry point (which initializes the reactive \
runtime) before reading back presses",
);
rt.with_owner(|| RwSignal::new(0))
})
}
pub fn push_back_press() {
if !is_ui_thread() {
panic!(
"frust-reactive: push_back_press was called off the UI thread. Back presses can \
only be pushed from the UI thread (the one `ReactiveRuntime::init` ran on) — this \
is a wiring bug: route the platform delivery through the UI thread before pushing, \
the same contract `push_deep_link`/`Executor::spawn_local` enforce."
);
}
if ReactiveRuntime::get().is_none() {
eprintln!(
"frust-reactive: push_back_press() dropped — ReactiveRuntime::init has not run \
yet. A back press before the runtime exists indicates an odd init-ordering race, \
not normal operation (the shell wires the back callback only after init)."
);
return;
}
counter().update(|c| *c += 1);
}
pub fn back_presses() -> BackPresses {
BackPresses { count: counter() }
}
pub fn set_handles_back(handles: bool) {
HANDLES_BACK.store(handles, Ordering::Relaxed);
}
pub fn set_can_pop_provider(provider: Box<dyn Fn() -> bool>) -> CanPopRegistration {
if !is_ui_thread() {
panic!(
"frust-reactive: set_can_pop_provider was called off the UI thread. The can-pop \
provider captures an Rc-backed NavigatorController (!Send) and lives in UI-thread \
storage — this is a wiring bug: register it from the UI thread (the one \
`ReactiveRuntime::init` ran on), the same contract `push_back_press`/\
`Executor::spawn_local` enforce."
);
}
let id = CAN_POP_NEXT_ID.with(|next| {
let id = next.get();
next.set(id + 1);
id
});
CAN_POP_PROVIDER.with(|slot| *slot.borrow_mut() = Some((id, provider)));
CanPopRegistration(id)
}
pub fn clear_can_pop_provider() {
CAN_POP_PROVIDER.with(|slot| *slot.borrow_mut() = None);
}
pub fn handles_back() -> bool {
if let Some(answer) = CAN_POP_PROVIDER.with(|slot| slot.borrow().as_ref().map(|(_, f)| f())) {
return answer;
}
HANDLES_BACK.load(Ordering::Relaxed)
}
#[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 as AtomicOrdering};
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, AtomicOrdering::SeqCst);
});
(waker, seen)
}
#[test]
fn back_press_push_dedupe_and_handles_flag() {
let _guard = crate::WAKER_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (waker, wakes) = recording_waker();
let _rt = ReactiveRuntime::init(waker);
assert!(!handles_back(), "handles_back defaults to false");
set_handles_back(true);
assert!(handles_back(), "set_handles_back(true) is observable");
set_handles_back(false);
assert!(!handles_back(), "set_handles_back(false) is observable");
let start = back_presses().count.get_untracked();
let scope = TrackedScope::new();
let seen = scope.track(|| back_presses().count.get());
assert_eq!(seen, start, "a fresh track observes the current count");
assert!(!scope.is_dirty(), "a fresh track starts clean");
let before = wakes.load(AtomicOrdering::SeqCst);
push_back_press();
assert!(
scope.is_dirty(),
"push_back_press must dirty a scope tracking `count`"
);
assert_eq!(
wakes.load(AtomicOrdering::SeqCst) - before,
1,
"a push must fire the waker exactly once"
);
assert_eq!(
back_presses().count.get_untracked(),
start + 1,
"each push increments the counter by one"
);
let mut consumed = back_presses().count.get_untracked();
assert_eq!(
back_presses().count.get_untracked(),
consumed,
"no push -> the count is unchanged, so a dedup'd consumer no-ops"
);
push_back_press();
push_back_press();
let now = back_presses().count.get_untracked();
assert_eq!(now, consumed + 2, "two pushes advance the counter by two");
assert_ne!(now, consumed, "there is unconsumed back-press progress");
consumed = now;
assert_eq!(back_presses().count.get_untracked(), consumed);
}
#[test]
fn can_pop_provider_wins_and_unregisters() {
use std::cell::Cell;
use std::rc::Rc;
let _guard = crate::WAKER_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _rt = ReactiveRuntime::init(Arc::new(|| {}));
clear_can_pop_provider();
set_handles_back(false);
assert!(!handles_back(), "no provider + flag false -> false");
set_handles_back(true);
assert!(
handles_back(),
"no provider -> the polled flag is the answer"
);
let live = Rc::new(Cell::new(true));
let probe = live.clone();
let reg = set_can_pop_provider(Box::new(move || probe.get()));
set_handles_back(false);
assert!(
handles_back(),
"a registered provider wins over the (opposite) polled flag"
);
live.set(false);
set_handles_back(true);
assert!(
!handles_back(),
"the provider is re-queried live, not cached, and still wins"
);
reg.unregister();
assert!(handles_back(), "after clear, the flag (true) answers again");
set_handles_back(false);
assert!(!handles_back(), "fallback tracks the flag once more");
clear_can_pop_provider();
assert!(!handles_back());
}
#[test]
fn stale_unregister_never_clears_a_newer_registration() {
let _guard = crate::WAKER_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _rt = ReactiveRuntime::init(Arc::new(|| {}));
clear_can_pop_provider();
set_handles_back(false);
let reg1 = set_can_pop_provider(Box::new(|| false));
assert!(!handles_back(), "first provider answers false");
let reg2 = set_can_pop_provider(Box::new(|| true));
assert!(handles_back(), "second provider replaced the first");
reg1.unregister();
assert!(
handles_back(),
"stale unregister must not clear the newer registration"
);
reg2.unregister();
assert!(!handles_back(), "live unregister restores the polled flag");
}
#[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_back_press)
.join()
.unwrap_or_else(|e| std::panic::resume_unwind(e));
}
#[test]
#[should_panic(expected = "wiring bug")]
fn set_provider_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(|| {
let _ = set_can_pop_provider(Box::new(|| true));
})
.join()
.unwrap_or_else(|e| std::panic::resume_unwind(e));
}
}