Refactor ruin_app, add some README files, minor usability in reactivity

This commit is contained in:
2026-03-25 22:32:11 -04:00
parent 5f67c06083
commit 341d07d82f
28 changed files with 2782 additions and 2118 deletions

152
lib/reactivity/README.md Normal file
View File

@@ -0,0 +1,152 @@
# RUIN Reactivity - Fine-Grained Reactor
Automatic thread-stack-based reactivity for RUIN-apps.
RUIN Reactivity provides an implementation of fine-grained reactivity primitives
for dependency tracking and incremental computation. Those primitives are:
- `Cell<T>`: a tracked-state value cell, primitively observable (sometimes
called a "signal" in other reactor implementations).
- `effect`: a primitive observer that runs once, observes its dependencies, and
runs again whenever its dependencies change.
- `Thunk<T>`: a tracked-state recomputable value defined by a closure.
Invalidated if any of its dependencies change. A `Thunk` is both an observer
and an observable.
- `Event<T>`: a push-style source of events of type `T`. Supports subscription
and cancellation of interest in events.
RUIN reactivity requires the RUIN runtime to function, and cannot be used on
threads not managed by the RUIN runtime.
RUIN reactivity does not function across thread boundaries. It tracks
dependencies between entities on the same thread only.
## Examples
### Observe a cell using an effect
```rs
use std::time::Duration;
use ruin_reactivity::{cell, effect};
use ruin_runtime::{main, set_timeout};
#[main]
fn main() {
// Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set`
// will trigger updates to any dependent observers.
let v = cell(5);
// Creates an observer that prints the value of `v` whenever it changes.
// Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically
// disposing when dropped.
effect({
let v = v.clone();
move || {
println!("v is: {}", v.get());
}
})
.leak();
// Queue a future to wait 5 seconds and then update `v`. This will trigger
// the effect to run again and print the new value.
set_timeout(Duration::from_secs(5), {
let v = v.clone();
move || {
v.set(v.get() + 20);
}
});
}
```
### Observe a thunk using an effect
```rs
use std::time::Duration;
use ruin_reactivity::{cell, effect, thunk};
use ruin_runtime::{clear_interval, main, set_interval, set_timeout};
#[main]
fn main() {
// Two primitive observable values.
let x = cell(5);
let y = cell(10);
// A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the
// result will be cached until then.
let z = thunk({
let x = x.clone();
let y = y.clone();
move || {
println!("calculating z...");
x.get() + y.get()
}
});
// The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will
// run whenever `x` or `y` change.
effect({
let z = z.clone();
move || {
println!("z is: {}", z.get());
}
})
.leak();
// Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`.
let interval = set_interval(Duration::from_secs(1), {
let x = x.clone();
let y = y.clone();
move || {
x.update(|value| *value += 1);
y.update(|value| *value += 2);
}
});
// After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will
// empty and the program will exit since there are no more pending tasks.
set_timeout(Duration::from_secs(10), move || {
println!("clearing interval...");
clear_interval(&interval);
});
}
```
### Use an event to handle intra-thread messaging
```rs
use std::{cell::Cell, rc::Rc, time::Duration};
use ruin_reactivity::event;
use ruin_runtime::{main, queue_future, set_interval, time::sleep};
#[main]
fn main() {
let my_event = event::<String>();
my_event.subscribe(|message| {
println!("got event with message: {message}");
});
// Emit an event every 250ms with an incrementing count.
let interval = set_interval(Duration::from_millis(250), {
let counter = Rc::new(Cell::new(0));
move || {
let count = counter.get();
my_event.emit(format!("the count is {}", count));
counter.set(count + 1);
}
});
// After 5 seconds, clear the interval to stop emitting events.
queue_future(async move {
sleep(Duration::from_secs(5)).await;
interval.clear();
});
}
```
## License
Licensed under the [MIT License](../../LICENSE.txt).

View File

@@ -0,0 +1,31 @@
use std::time::Duration;
use ruin_reactivity::{cell, effect};
use ruin_runtime::{main, set_timeout};
#[main]
fn main() {
// Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set`
// will trigger updates to any dependent observers.
let v = cell(5);
// Creates an observer that prints the value of `v` whenever it changes.
// Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically
// disposing when dropped.
effect({
let v = v.clone();
move || {
println!("v is: {}", v.get());
}
})
.leak();
// Queue a future to wait 5 seconds and then update `v`. This will trigger
// the effect to run again and print the new value.
set_timeout(Duration::from_secs(5), {
let v = v.clone();
move || {
v.set(v.get() + 20);
}
});
}

View File

@@ -0,0 +1,49 @@
use std::time::Duration;
use ruin_reactivity::{cell, effect, thunk};
use ruin_runtime::{clear_interval, main, set_interval, set_timeout};
#[main]
fn main() {
// Two primitive observable values.
let x = cell(5);
let y = cell(10);
// A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the
// result will be cached until then.
let z = thunk({
let x = x.clone();
let y = y.clone();
move || {
println!("calculating z...");
x.get() + y.get()
}
});
// The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will
// run whenever `x` or `y` change.
effect({
let z = z.clone();
move || {
println!("z is: {}", z.get());
}
})
.leak();
// Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`.
let interval = set_interval(Duration::from_secs(1), {
let x = x.clone();
let y = y.clone();
move || {
x.update(|value| *value += 1);
y.update(|value| *value += 2);
}
});
// After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will
// empty and the program will exit since there are no more pending tasks.
set_timeout(Duration::from_secs(10), move || {
println!("clearing interval...");
clear_interval(&interval);
});
}

View File

@@ -0,0 +1,29 @@
use std::{cell::Cell, rc::Rc, time::Duration};
use ruin_reactivity::event;
use ruin_runtime::{main, queue_future, set_interval, time::sleep};
#[main]
fn main() {
let my_event = event::<String>();
my_event.subscribe(|message| {
println!("got event with message: {message}");
});
// Emit an event every 250ms with an incrementing count.
let interval = set_interval(Duration::from_millis(250), {
let counter = Rc::new(Cell::new(0));
move || {
let count = counter.get();
my_event.emit(format!("the count is {}", count));
counter.set(count + 1);
}
});
// After 5 seconds, clear the interval to stop emitting events.
queue_future(async move {
sleep(Duration::from_secs(5)).await;
interval.clear();
});
}

View File

@@ -5,6 +5,9 @@ use crate::reactor::ObserverHook;
use crate::{NodeId, Reactor, current, trace_targets};
/// Creates an effect in the current thread's default reactor.
///
/// The effect is scheduled immediately and then re-scheduled whenever one of its dependencies
/// changes.
pub fn effect(f: impl Fn() + 'static) -> EffectHandle {
current().effect(f)
}
@@ -16,6 +19,7 @@ pub fn effect_in(reactor: &Reactor, f: impl Fn() + 'static) -> EffectHandle {
/// Disposable handle for a reactive effect.
#[derive(Clone)]
#[must_use = "effects are automatically disposed when dropped, so you must use the handle or explicitly leak it"]
pub struct EffectHandle {
inner: Rc<EffectInner>,
}
@@ -55,6 +59,14 @@ impl EffectHandle {
Self { inner }
}
/// Consumes the effect and leaks it, allowing it to run for the remainder of the program without automatically
/// disposing when dropped. Use this for effects that you want to run for the lifetime of the program. You CANNOT
/// recover an EffectHandle after calling this method, so be sure to call it on an EffectHandle that you don't need
/// to later dispose of.
pub fn leak(self) {
std::mem::forget(self);
}
/// Disposes the effect immediately.
pub fn dispose(&self) {
self.inner.dispose();

View File

@@ -1,5 +1,5 @@
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::rc::Rc;
use crate::{NodeId, Reactor, current, trace_targets};
@@ -17,11 +17,13 @@ pub fn event_in<T: 'static>(reactor: &Reactor) -> Event<T> {
}
/// Creates a reactive draining subscription for `event` in the current reactor.
#[must_use = "subscriptions created with `on` must be used to stay active"]
pub fn on<T: Clone + 'static>(event: &Event<T>, handler: impl Fn(&T) + 'static) -> Subscription {
current().on(event, handler)
}
/// Creates a reactive draining subscription for `event` associated with `reactor`.
#[must_use = "subscriptions created with `on_in` must be used to stay active"]
pub fn on_in<T: Clone + 'static>(
reactor: &Reactor,
event: &Event<T>,
@@ -49,6 +51,7 @@ impl Reactor {
}
/// Creates a reactive draining subscription for `event`.
#[must_use = "subscriptions created with `on` must be used to stay active"]
pub fn on<T: Clone + 'static>(
&self,
event: &Event<T>,
@@ -103,7 +106,7 @@ impl<T: 'static> Event<T> {
reactor,
id,
next_subscriber: Cell::new(1),
subscribers: RefCell::new(BTreeMap::new()),
subscribers: RefCell::new(Default::default()),
}),
}
}
@@ -204,7 +207,7 @@ struct EventInner<T> {
reactor: Reactor,
id: NodeId,
next_subscriber: Cell<usize>,
subscribers: RefCell<BTreeMap<usize, Rc<SubscriberFn<T>>>>,
subscribers: RefCell<HashMap<usize, Rc<SubscriberFn<T>>>>,
}
struct SubscriptionInner {
@@ -227,11 +230,12 @@ impl SubscriptionInner {
}
}
impl Drop for SubscriptionInner {
fn drop(&mut self) {
self.unsubscribe();
}
}
// TODO: it's actually kind of hard to use events if subcscriptions have to be manually managed.
// impl Drop for SubscriptionInner {
// fn drop(&mut self) {
// self.unsubscribe();
// }
// }
#[cfg(test)]
mod tests {