diff --git a/Cargo.lock b/Cargo.lock index 0eacc77..224d438 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "duplicate" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de78e66ac9061e030587b2a2e75cc88f22304913c907b11307bca737141230cb" +dependencies = [ + "heck", + "proc-macro-error", +] + [[package]] name = "dynatos" version = "0.1.0" @@ -9,3 +19,76 @@ version = "0.1.0" [[package]] name = "dynatos-reactive" version = "0.1.0" +dependencies = [ + "duplicate", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" diff --git a/Cargo.toml b/Cargo.toml index 83d45c8..0234401 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,5 @@ resolver = "2" # Workspace members dynatos = { path = "dynatos" } dynatos-reactive = { path = "dynatos-reactive" } + +duplicate = "1.0.0" diff --git a/dynatos-reactive/Cargo.toml b/dynatos-reactive/Cargo.toml index 336dec1..db243a7 100644 --- a/dynatos-reactive/Cargo.toml +++ b/dynatos-reactive/Cargo.toml @@ -4,3 +4,5 @@ version = "0.1.0" edition = "2021" [dependencies] + +duplicate = { workspace = true } diff --git a/dynatos-reactive/src/effect.rs b/dynatos-reactive/src/effect.rs new file mode 100644 index 0000000..357b579 --- /dev/null +++ b/dynatos-reactive/src/effect.rs @@ -0,0 +1,121 @@ +//! Effect +//! +//! An effect is a function that is re-run whenever +//! one of it's dependencies changes. + +// Imports +use std::{ + cell::RefCell, + hash::Hash, + rc::{Rc, Weak}, +}; + +thread_local! { + /// Effect stack + static EFFECT_STACK: RefCell> = RefCell::new(vec![]); +} + +/// Effect inner +struct Inner { + /// Effect runner + run: Box, +} + +/// Effect +#[derive(Clone)] +pub struct Effect { + /// Inner + inner: Rc>, +} + +impl Effect { + /// Creates a new computed effect. + /// + /// Runs the effect once to gather dependencies. + pub fn new(run: F) -> Self + where + F: Fn() + 'static, + { + // Create the effect + let inner = Inner { run: Box::new(run) }; + let effect = Self { + inner: Rc::new(RefCell::new(inner)), + }; + + // And run it once to gather dependencies. + effect.run(); + + effect + } + + /// Downgrades this effect + pub fn downgrade(&self) -> WeakEffect { + WeakEffect { + inner: Rc::downgrade(&self.inner), + } + } + + /// Returns the current running effect + pub fn running() -> Option { + EFFECT_STACK.with_borrow(|effects| effects.last().cloned()) + } + + /// Runs the effect + pub fn run(&self) { + // Push the effect, run the closure and pop it + EFFECT_STACK.with_borrow_mut(|effects| effects.push(self.clone())); + + // Then run it + let inner = self.inner.borrow(); + let run = inner.run.as_ref(); + run(); + + // And finally pop the effect from the stack + EFFECT_STACK + .with_borrow_mut(|effects| effects.pop()) + .expect("Missing added effect"); + } +} + +/// Weak effect +/// +/// Used to break ownership between a signal and it's subscribers +#[derive(Clone)] +pub struct WeakEffect { + /// Inner + inner: Weak>, +} + +impl WeakEffect { + /// Upgrades this effect + pub fn upgrade(&self) -> Option { + self.inner.upgrade().map(|inner| Effect { inner }) + } + + /// Runs this effect, if it exists. + /// + /// Returns if the effect still existed + pub fn try_run(&self) -> bool { + // Try to upgrade, else return that it was missing + let Some(effect) = self.upgrade() else { + return false; + }; + + effect.run(); + true + } +} + +impl PartialEq for WeakEffect { + fn eq(&self, other: &Self) -> bool { + Weak::ptr_eq(&self.inner, &other.inner) + } +} + +impl Eq for WeakEffect {} + +impl Hash for WeakEffect { + fn hash(&self, state: &mut H) { + self.inner.as_ptr().hash(state); + } +} diff --git a/dynatos-reactive/src/lib.rs b/dynatos-reactive/src/lib.rs index 61ecf5b..6ef7904 100644 --- a/dynatos-reactive/src/lib.rs +++ b/dynatos-reactive/src/lib.rs @@ -1 +1,11 @@ //! Reactivity for [`dynatos`] + +// Modules +pub mod effect; +pub mod signal; + +// Exports +pub use self::{ + effect::{Effect, WeakEffect}, + signal::Signal, +}; diff --git a/dynatos-reactive/src/signal.rs b/dynatos-reactive/src/signal.rs new file mode 100644 index 0000000..db9730b --- /dev/null +++ b/dynatos-reactive/src/signal.rs @@ -0,0 +1,147 @@ +//! Signal +//! +//! A read-write value that automatically updates +//! any subscribers when changed. + +// Imports +use { + crate::{Effect, WeakEffect}, + std::{cell::RefCell, collections::HashSet, mem, rc::Rc}, +}; + +/// Signal inner +struct Inner { + /// Value + value: T, + + /// Subscribers + subscribers: HashSet, +} + +/// Signal +pub struct Signal { + /// Inner + inner: Rc>>, +} + +impl Signal { + /// Creates a new signal + pub fn new(value: T) -> Self { + let inner = Inner { + value, + subscribers: HashSet::new(), + }; + Self { + inner: Rc::new(RefCell::new(inner)), + } + } + + /// Gets the inner value + #[track_caller] + pub fn get(&self) -> T + where + T: Copy, + { + if let Some(effect) = Effect::running() { + self.add_subscriber(effect); + } + + self.inner + .try_borrow() + .expect("Cannot get signal value while updating") + .value + } + + /// Calls `f` with the inner value + #[track_caller] + pub fn with(&self, f: F) -> O + where + F: FnOnce(&T) -> O, + { + let inner = self.inner.try_borrow().expect("Cannot use signal value while updating"); + f(&inner.value) + } + + /// Sets the inner value. + /// + /// Updates all subscribers. + /// + /// Returns the previous value + pub fn set(&self, new_value: T) -> T { + self.update(|value| mem::replace(value, new_value)) + } + + /// Updates the value in-place. + /// + /// Updates all subscribers + #[track_caller] + pub fn update(&self, f: F) -> O + where + F: FnOnce(&mut T) -> O, + { + // Update the value and get the output + let output = { + let mut inner = self + .inner + .try_borrow_mut() + .expect("Cannot update signal value while using it"); + f(&mut inner.value) + }; + + // Then update all subscribers, removing any stale ones. + // Note: Since running the effect will add subscribers, we can't keep + // the inner borrow active, so we gather all dependencies before-hand. + // However, we can remove subscribers in between running effects, so we + // don't need to wait for that. + let subscribers = self.inner.borrow().subscribers.iter().cloned().collect::>(); + for subscriber in subscribers { + if !subscriber.try_run() { + self.remove_subscriber(subscriber); + } + } + + output + } + + /// Explicitly adds a subscriber to this signal. + /// + /// Returns if the subscriber already existed. + pub fn add_subscriber(&self, subscriber: S) -> bool { + let mut inner = self.inner.borrow_mut(); + let new_effect = inner.subscribers.insert(subscriber.into_subscriber()); + !new_effect + } + + /// Removes a subscriber from this signal. + /// + /// Returns if the subscriber existed + pub fn remove_subscriber(&self, subscriber: S) -> bool { + let mut inner = self.inner.borrow_mut(); + inner.subscribers.remove(&subscriber.into_subscriber()) + } +} + +impl Clone for Signal { + fn clone(&self) -> Self { + Self { + inner: Rc::clone(&self.inner), + } + } +} + +/// Types that may be converted into a subscriber +pub trait IntoSubscriber { + fn into_subscriber(self) -> WeakEffect; +} + +#[duplicate::duplicate_item( + T body; + [ Effect ] [ self.downgrade() ]; + [ &'_ Effect ] [ self.downgrade() ]; + [ WeakEffect ] [ self ]; +)] +impl IntoSubscriber for T { + fn into_subscriber(self) -> WeakEffect { + body + } +}