lib.rs
raw
//! Fine-grained signal runtime for guiduck: signals, memos, effects, scopes.
//!
//! All state lives in a thread-local runtime on the UI thread; handles are
//! `Copy` 8-byte keys, so closures capture them by value with no clone
//! ceremony and widgets store them without lifetimes. Writing a signal is
//! immediate, but effects run deferred: the frame scheduler calls
//! [`flush_effects`] once per frame, and dependent effects run at most once
//! per flush regardless of how many of their sources changed
//! (diamond-dependency glitch freedom).
//!
//! Reading a disposed node panics (use `try_get` to probe); writing one is a
//! defined no-op.
mod runtime;
pub use runtime::{flush_effects, has_pending_effects};
use std::any::Any;
use std::marker::PhantomData;
use runtime::{Kind, NodeKey, ScopeKey};
/// Marker making handle types `!Send`/`!Sync`: they are only meaningful on
/// the thread whose runtime created them.
type ThreadBound<T> = PhantomData<(fn() -> T, *mut ())>;
/// A writable reactive value.
pub struct Signal<T> {
key: NodeKey,
_marker: ThreadBound<T>,
}
impl<T> Copy for Signal<T> {}
impl<T> Clone for Signal<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T: 'static> Signal<T> {
pub fn new(value: T) -> Self {
Self {
key: runtime::create_node(Kind::Signal, Some(Box::new(value)), None),
_marker: PhantomData,
}
}
/// Read the value, registering the running effect or memo (if any) as a
/// dependent.
pub fn get(self) -> T
where
T: Clone,
{
self.with(T::clone)
}
/// Read without registering a dependency.
pub fn get_untracked(self) -> T
where
T: Clone,
{
self.with_untracked(T::clone)
}
/// Read by reference, tracking.
pub fn with<R>(self, f: impl FnOnce(&T) -> R) -> R {
read(self.key, true, f)
}
/// Read by reference, untracked.
pub fn with_untracked<R>(self, f: impl FnOnce(&T) -> R) -> R {
read(self.key, false, f)
}
/// Read the value if the signal is still alive.
pub fn try_get(self) -> Option<T>
where
T: Clone,
{
runtime::read(self.key, true, |v| {
v.and_then(|v| v.downcast_ref::<T>()).cloned()
})
}
/// Write a new value; dependents are notified only if it differs from
/// the current one. Writing to a disposed signal is a no-op.
pub fn set(self, value: T)
where
T: PartialEq,
{
let unchanged = runtime::read(self.key, false, |v| match v {
Some(v) => v.downcast_ref::<T>() == Some(&value),
// Disposed: swallow the write.
None => true,
});
if !unchanged {
runtime::write(self.key, Box::new(value));
}
}
/// Mutate the value in place. Dependents are always notified, since the
/// closure gives no way to detect "no change".
pub fn update(self, f: impl FnOnce(&mut T)) {
runtime::write_with(self.key, |v| {
if let Some(v) = v.downcast_mut::<T>() {
f(v);
}
});
}
/// Whether the signal is still alive.
pub fn is_alive(self) -> bool {
runtime::node_exists(self.key)
}
/// Dispose the signal explicitly (ahead of its owning scope). Dependents
/// stop tracking it; readers see it as dead (`try_get` → `None`).
pub fn dispose(self) {
runtime::dispose_node(self.key);
}
/// A read-only handle to the same value: whoever holds it can observe
/// but not write. Component props are the canonical use — the owner
/// drives the `Signal`, the component's logic sees a [`ReadSignal`].
pub fn read_only(self) -> ReadSignal<T> {
ReadSignal(self)
}
}
/// A read-only view of a [`Signal`]: same tracked reads, no `set`/`update`.
pub struct ReadSignal<T>(Signal<T>);
impl<T> Copy for ReadSignal<T> {}
impl<T> Clone for ReadSignal<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T: 'static> ReadSignal<T> {
/// Read the value, tracking.
pub fn get(self) -> T
where
T: Clone,
{
self.0.get()
}
/// Read without registering a dependency.
pub fn get_untracked(self) -> T
where
T: Clone,
{
self.0.get_untracked()
}
/// Read by reference, tracking.
pub fn with<R>(self, f: impl FnOnce(&T) -> R) -> R {
self.0.with(f)
}
/// Read the value if the signal is still alive.
pub fn try_get(self) -> Option<T>
where
T: Clone,
{
self.0.try_get()
}
}
/// A cached derived value, recomputed on demand when a dependency changed.
/// Downstream nodes re-run only if the recomputed value actually differs
/// (`PartialEq`).
pub struct Memo<T> {
key: NodeKey,
_marker: ThreadBound<T>,
}
impl<T> Copy for Memo<T> {}
impl<T> Clone for Memo<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T: PartialEq + 'static> Memo<T> {
pub fn new(mut f: impl FnMut() -> T + 'static) -> Self {
let key = runtime::create_and_run(
Kind::Memo,
Box::new(move |slot: &mut Option<Box<dyn Any>>| {
let new = f();
match slot.as_mut().and_then(|s| s.downcast_mut::<T>()) {
Some(old) if *old == new => false,
Some(old) => {
*old = new;
true
}
None => {
*slot = Some(Box::new(new));
true
}
}
}),
);
Self {
key,
_marker: PhantomData,
}
}
pub fn get(self) -> T
where
T: Clone,
{
self.with(T::clone)
}
pub fn with<R>(self, f: impl FnOnce(&T) -> R) -> R {
read(self.key, true, f)
}
}
fn read<T: 'static, R>(key: NodeKey, track: bool, f: impl FnOnce(&T) -> R) -> R {
runtime::read(key, track, |v| {
let v = v
.expect("read of a disposed reactive node")
.downcast_ref::<T>()
.expect("reactive node holds a different type");
f(v)
})
}
/// A reactive side effect: runs once at creation (establishing its
/// dependencies) and again during [`flush_effects`] whenever a dependency
/// changed.
pub struct Effect {
key: NodeKey,
_marker: ThreadBound<()>,
}
impl Effect {
pub fn new(mut f: impl FnMut() + 'static) -> Self {
let key = runtime::create_and_run(
Kind::Effect,
Box::new(move |_| {
f();
true
}),
);
Self {
key,
_marker: PhantomData,
}
}
/// Stop the effect permanently.
pub fn dispose(self) {
runtime::dispose_node(self.key);
}
}
/// An ownership scope for reactive nodes. Nodes created while a scope is
/// entered are disposed together when the scope is; component instances get
/// one scope each, so unmounting tears down all their reactive state.
pub struct Scope {
key: ScopeKey,
_marker: ThreadBound<()>,
}
impl Scope {
/// Create a child of the currently entered scope (or of the root).
pub fn new() -> Self {
Self {
key: runtime::create_scope(),
_marker: PhantomData,
}
}
/// Run `f` with this scope as the owner of any nodes it creates.
pub fn run<R>(&self, f: impl FnOnce() -> R) -> R {
let prev = runtime::enter_scope(self.key);
let result = f();
runtime::restore_scope(prev);
result
}
/// Dispose the scope, all nodes it owns, and all descendant scopes.
pub fn dispose(self) {
runtime::dispose_scope(self.key);
}
}
impl Default for Scope {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests;