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}