Skip to main content

waterkit_core/
subscribed.rs

1//! Cross-thread reactive state.
2//!
3//! [`Subscribed<T>`] is a read-only view of a value that may be updated from
4//! any thread. It is a thin wrapper around `nami::Binding<T>`:
5//!
6//! - Readers on the binding's home thread observe via [`Subscribed::get`],
7//!   [`Subscribed::watch`], or [`Subscribed::stream`] — the type implements
8//!   `nami::Signal<Output = T>` directly, so any nami combinator (`map`,
9//!   `zip`, `distinct`, `cached`, …) composes natively.
10//! - Producers on any thread push values through [`SubscribedSink<T>`], a
11//!   thin wrapper over `nami::binding::BindingMailbox<T>`. The sink is
12//!   `Send + Sync + Clone`; calling [`SubscribedSink::set`] enqueues an
13//!   `FnOnce(&mut Binding<T>)` closure that the home-thread mailbox worker
14//!   drains and executes.
15//!
16//! ## Threading model
17//!
18//! The home thread is the thread that calls [`subscribed`]. A
19//! `LocalExecutor` (from `executor-core`) must be polling on that thread for
20//! the mailbox worker to drain pushes. Inside a waterui app, the UI thread
21//! satisfies this automatically. Headless tests can pass any
22//! `LocalExecutor` to [`subscribed_with_executor`].
23//!
24//! `Subscribed<T>` itself is `Clone + !Send` because the underlying
25//! `Binding<T>` is `Rc<…>`. To hand a sink to a background thread, use the
26//! `SubscribedSink<T>` returned alongside it.
27
28use alloc::sync::Arc;
29use core::fmt;
30use executor_core::LocalExecutor;
31use nami::binding::BindingMailbox;
32use nami::stream::SignalStream;
33use nami::watcher::Context as WatcherContext;
34use nami::{Binding, Signal, binding as nami_binding};
35
36extern crate alloc;
37
38/// Read-only reactive view of a value updated from any thread.
39///
40/// See the [module-level documentation](self) for usage.
41pub struct Subscribed<T: Clone + 'static> {
42    binding: Binding<T>,
43}
44
45impl<T: Clone + 'static> Subscribed<T> {
46    /// Constructs a [`Subscribed`] from an existing `nami::Binding<T>`.
47    ///
48    /// Useful when adapter code already holds a binding (e.g. inside a
49    /// view's local state) and wants to expose it as the `Subscribed` API.
50    /// The returned view shares the binding — `set` calls on the binding
51    /// notify watchers of this view, and vice versa.
52    #[must_use]
53    pub const fn from_binding(binding: Binding<T>) -> Self {
54        Self { binding }
55    }
56
57    /// Borrows the underlying binding.
58    ///
59    /// Most consumers do not need this; prefer the `Signal` impl. Provided
60    /// for adapters that need to plug a `Subscribed<T>` into APIs that
61    /// specifically take a `&Binding<T>`.
62    #[must_use]
63    pub const fn as_binding(&self) -> &Binding<T> {
64        &self.binding
65    }
66}
67
68impl<T: Clone + 'static> Clone for Subscribed<T> {
69    fn clone(&self) -> Self {
70        Self {
71            binding: self.binding.clone(),
72        }
73    }
74}
75
76impl<T: Clone + 'static> Subscribed<T> {
77    /// Synchronous snapshot of the current value.
78    ///
79    /// Equivalent to `<Self as nami::Signal>::get(&self)`.
80    #[must_use]
81    pub fn get(&self) -> T {
82        Signal::get(&self.binding)
83    }
84
85    /// Wraps as a `futures::Stream<Item = T>`.
86    ///
87    /// Each call returns a fresh stream; multiple stream consumers are
88    /// independent and each see every update after the call.
89    #[must_use]
90    pub fn stream(&self) -> SignalStream<Binding<T>> {
91        SignalStream::new(self.binding.clone())
92    }
93
94    /// Lazy map: derives a new `Subscribed<U>` whose value is `f(upstream)`.
95    ///
96    /// Zero spawn — uses `nami::Binding::mapping` directly. Composing
97    /// `a.map(f).map(g)` does not allocate forwarding tasks.
98    #[must_use]
99    pub fn map<U, F>(&self, f: F) -> Subscribed<U>
100    where
101        U: Clone + 'static,
102        F: Fn(T) -> U + Clone + 'static,
103    {
104        Subscribed {
105            binding: Binding::mapping(&self.binding, f, |_, _| {}),
106        }
107    }
108}
109
110impl<T: Clone + 'static> Signal for Subscribed<T> {
111    type Output = T;
112    type Guard = <Binding<T> as Signal>::Guard;
113
114    fn get(&self) -> T {
115        Signal::get(&self.binding)
116    }
117
118    fn watch(&self, watcher: impl Fn(WatcherContext<T>) + 'static) -> Self::Guard {
119        self.binding.watch(watcher)
120    }
121}
122
123impl<T: fmt::Debug + Clone + 'static> fmt::Debug for Subscribed<T> {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.debug_struct("Subscribed")
126            .field("value", &self.get())
127            .finish()
128    }
129}
130
131/// Cross-thread push handle for a [`Subscribed<T>`].
132///
133/// `Send + Sync + Clone`. Drop the sink (or all clones) when the producer
134/// stops; the binding's mailbox worker exits when the channel closes.
135///
136/// Internally an `Arc<BindingMailbox<T>>`; clones share one mailbox.
137pub struct SubscribedSink<T: 'static> {
138    mailbox: Arc<BindingMailbox<T>>,
139}
140
141impl<T: 'static> SubscribedSink<T> {
142    /// Pushes a new value. Returns immediately; the actual `binding.set`
143    /// runs asynchronously on the binding's home thread.
144    pub fn set(&self, value: T)
145    where
146        T: Send + 'static,
147    {
148        self.mailbox.handle(move |b| b.set(value));
149    }
150
151    /// Runs an arbitrary closure with mutable access to the binding on its
152    /// home thread. Useful for atomic read-modify-write patterns where
153    /// `set` of a clone would race with another producer.
154    pub fn handle(&self, job: impl FnOnce(&mut Binding<T>) + Send + 'static) {
155        self.mailbox.handle(job);
156    }
157}
158
159impl<T: 'static> Clone for SubscribedSink<T> {
160    fn clone(&self) -> Self {
161        Self {
162            mailbox: Arc::clone(&self.mailbox),
163        }
164    }
165}
166
167impl<T: 'static> fmt::Debug for SubscribedSink<T> {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.debug_struct("SubscribedSink").finish_non_exhaustive()
170    }
171}
172
173/// Constructs a fresh `Subscribed<T>` with an initial value, paired with a
174/// cross-thread [`SubscribedSink<T>`].
175///
176/// The binding lives on the calling thread; the mailbox worker is spawned
177/// on `executor_core::DefaultExecutor`.
178///
179/// # Precondition
180///
181/// A `LocalExecutor` must be polling on the calling thread for the mailbox
182/// worker to drain. Inside a waterui app this is guaranteed on the UI
183/// thread; for headless contexts use [`subscribed_with_executor`].
184#[must_use]
185pub fn subscribed<T: Clone + Send + 'static>(initial: T) -> (Subscribed<T>, SubscribedSink<T>) {
186    let binding = nami_binding(initial);
187    let mailbox = binding.mailbox();
188    (
189        Subscribed { binding },
190        SubscribedSink {
191            mailbox: Arc::new(mailbox),
192        },
193    )
194}
195
196/// Variant of [`subscribed`] that takes an explicit `LocalExecutor`.
197///
198/// Useful for tests or for crates that need to run the mailbox worker on
199/// a non-default executor (rare; consult the nami docs).
200#[must_use]
201pub fn subscribed_with_executor<T, E>(initial: T, executor: E) -> (Subscribed<T>, SubscribedSink<T>)
202where
203    T: Clone + Send + 'static,
204    E: LocalExecutor,
205{
206    let binding = nami_binding(initial);
207    let mailbox = binding.mailbox_with_executor(executor);
208    (
209        Subscribed { binding },
210        SubscribedSink {
211            mailbox: Arc::new(mailbox),
212        },
213    )
214}
215
216#[cfg(test)]
217mod tests {
218    //! Unit tests cover the bare reactive view (`Subscribed::from_binding`).
219    //! Mailbox-based pushing requires a `LocalExecutor`; that path is
220    //! exercised in integration tests where a runtime is available.
221
222    use super::*;
223    use core::cell::Cell;
224    use std::rc::Rc;
225
226    fn make<T: Clone + 'static>(value: T) -> Subscribed<T> {
227        let binding: Binding<T> = nami_binding(value);
228        Subscribed::from_binding(binding)
229    }
230
231    #[test]
232    fn snapshot_returns_initial_value() {
233        let sub = make(7_u32);
234        assert_eq!(sub.get(), 7);
235    }
236
237    #[test]
238    fn map_derives_value() {
239        let sub = make(3_u32);
240        let doubled = sub.map(|x: u32| x * 2);
241        assert_eq!(doubled.get(), 6);
242    }
243
244    #[test]
245    fn watch_fires_on_local_set() {
246        let sub = make(0_u32);
247        let captured = Rc::new(Cell::new(0_u32));
248        let captured_for_watcher = Rc::clone(&captured);
249        let _guard = sub.watch(move |ctx| {
250            captured_for_watcher.set(*ctx.value());
251        });
252        sub.as_binding().set(42);
253        assert_eq!(captured.get(), 42);
254        assert_eq!(sub.get(), 42);
255    }
256
257    #[test]
258    fn map_propagates_upstream_set() {
259        let sub = make(10_i32);
260        let plus_one = sub.map(|x: i32| x + 1);
261        assert_eq!(plus_one.get(), 11);
262        sub.as_binding().set(20);
263        assert_eq!(plus_one.get(), 21);
264    }
265}