windjammer_ui/
reactivity.rs

1//! Fine-grained reactivity system for Windjammer UI
2//!
3//! This module provides automatic dependency tracking and fine-grained updates.
4//! Inspired by Solid.js, Vue 3, and Leptos.
5//!
6//! # Example
7//!
8//! ```rust
9//! use windjammer_ui::reactivity::*;
10//!
11//! let count = Signal::new(0);
12//! let doubled = Computed::new({
13//!     let count = count.clone();
14//!     move || count.get() * 2
15//! });
16//!
17//! // Effect runs immediately and re-runs when count changes
18//! create_effect({
19//!     let doubled = doubled.clone();
20//!     move || {
21//!         println!("Doubled: {}", doubled.get());
22//!     }
23//! });
24//!
25//! count.set(5); // Prints: "Doubled: 10"
26//! ```
27
28use std::cell::RefCell;
29use std::collections::{HashMap, HashSet};
30use std::rc::Rc;
31use std::sync::atomic::{AtomicUsize, Ordering};
32
33/// Unique identifier for signals
34pub type SignalId = usize;
35
36/// Unique identifier for effects
37pub type EffectId = usize;
38
39// Global reactive context (thread-local for single-threaded WASM)
40thread_local! {
41    static REACTIVE_CONTEXT: RefCell<ReactiveContext> = RefCell::new(ReactiveContext::new());
42    static EFFECT_REGISTRY: RefCell<HashMap<EffectId, EffectHandle>> = RefCell::new(HashMap::new());
43}
44
45/// Reactive context tracks the current effect being executed
46struct ReactiveContext {
47    /// The effect currently being executed (for dependency tracking)
48    current_effect: Option<EffectId>,
49    /// Cleanup functions to run when effects are disposed
50    cleanups: HashMap<EffectId, Vec<Box<dyn Fn()>>>,
51}
52
53impl ReactiveContext {
54    fn new() -> Self {
55        Self {
56            current_effect: None,
57            cleanups: HashMap::new(),
58        }
59    }
60}
61
62/// Handle to an effect for execution
63struct EffectHandle {
64    f: Rc<dyn Fn()>,
65}
66
67/// Core reactive primitive - a value that notifies subscribers when it changes
68#[derive(Clone)]
69pub struct Signal<T: Clone> {
70    id: SignalId,
71    value: Rc<RefCell<T>>,
72    subscribers: Rc<RefCell<HashSet<EffectId>>>,
73}
74
75impl<T: Clone> Signal<T> {
76    /// Create a new signal with an initial value
77    pub fn new(value: T) -> Self {
78        static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
79        Self {
80            id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
81            value: Rc::new(RefCell::new(value)),
82            subscribers: Rc::new(RefCell::new(HashSet::new())),
83        }
84    }
85
86    /// Get the current value (tracks dependency in reactive context)
87    pub fn get(&self) -> T {
88        // Track this read in the current reactive context
89        REACTIVE_CONTEXT.with(|ctx| {
90            let ctx = ctx.borrow();
91            if let Some(effect_id) = ctx.current_effect {
92                self.subscribers.borrow_mut().insert(effect_id);
93            }
94        });
95
96        self.value.borrow().clone()
97    }
98
99    /// Get the current value without tracking (for debugging)
100    pub fn get_untracked(&self) -> T {
101        self.value.borrow().clone()
102    }
103
104    /// Set a new value and notify subscribers
105    pub fn set(&self, value: T) {
106        *self.value.borrow_mut() = value;
107        self.notify();
108    }
109
110    /// Update the value using a function and notify subscribers
111    pub fn update<F>(&self, f: F)
112    where
113        F: FnOnce(&mut T),
114    {
115        f(&mut self.value.borrow_mut());
116        self.notify();
117    }
118
119    /// Notify all subscribers that the value has changed
120    fn notify(&self) {
121        let subscribers = self.subscribers.borrow().clone();
122        for effect_id in subscribers {
123            EFFECT_REGISTRY.with(|registry| {
124                if let Some(effect) = registry.borrow().get(&effect_id) {
125                    (effect.f)();
126                }
127            });
128        }
129
130        // Trigger UI re-render if we're in a WASM context
131        #[cfg(target_arch = "wasm32")]
132        {
133            crate::app_reactive::trigger_rerender();
134        }
135
136        // Trigger UI re-render for desktop apps
137        #[cfg(all(not(target_arch = "wasm32"), feature = "desktop"))]
138        {
139            crate::desktop_app_context::trigger_repaint();
140        }
141    }
142
143    /// Get the signal's unique ID
144    pub fn id(&self) -> SignalId {
145        self.id
146    }
147}
148
149impl<T: Clone + std::fmt::Debug> std::fmt::Debug for Signal<T> {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("Signal")
152            .field("id", &self.id)
153            .field("value", &self.get_untracked())
154            .finish()
155    }
156}
157
158/// Computed signal - a derived value that automatically updates when dependencies change
159#[derive(Clone)]
160pub struct Computed<T: Clone> {
161    signal: Signal<T>,
162    _effect_id: EffectId,
163}
164
165impl<T: Clone + 'static> Computed<T> {
166    /// Create a new computed value
167    pub fn new<F>(compute: F) -> Self
168    where
169        F: Fn() -> T + 'static,
170    {
171        let compute = Rc::new(compute);
172
173        // Compute initial value
174        let initial_value = compute();
175        let signal = Signal::new(initial_value);
176
177        // Create effect that updates signal when dependencies change
178        let signal_clone = signal.clone();
179        let compute_clone = compute.clone();
180        let effect_id = Effect::new(move || {
181            let new_value = compute_clone();
182            // Use direct assignment to avoid infinite loops
183            *signal_clone.value.borrow_mut() = new_value;
184        });
185
186        Self {
187            signal,
188            _effect_id: effect_id,
189        }
190    }
191
192    /// Get the current computed value (tracks dependency)
193    pub fn get(&self) -> T {
194        self.signal.get()
195    }
196
197    /// Get the current computed value without tracking
198    pub fn get_untracked(&self) -> T {
199        self.signal.get_untracked()
200    }
201}
202
203impl<T: Clone + std::fmt::Debug + 'static> std::fmt::Debug for Computed<T> {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.debug_struct("Computed")
206            .field("value", &self.get_untracked())
207            .finish()
208    }
209}
210
211/// Effect - a side effect that runs when its dependencies change
212pub struct Effect {
213    _id: EffectId,
214}
215
216impl Effect {
217    /// Create a new effect
218    #[allow(clippy::new_ret_no_self)]
219    pub fn new<F>(f: F) -> EffectId
220    where
221        F: Fn() + 'static,
222    {
223        static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
224        let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
225        let f: Rc<dyn Fn()> = Rc::new(f);
226
227        // Register effect
228        EFFECT_REGISTRY.with(|registry| {
229            registry
230                .borrow_mut()
231                .insert(id, EffectHandle { f: f.clone() });
232        });
233
234        // Run effect once to establish dependencies
235        Self::run_effect(id, f.clone());
236
237        id
238    }
239
240    /// Run an effect with the given ID as the current reactive context
241    fn run_effect(id: EffectId, f: Rc<dyn Fn()>) {
242        REACTIVE_CONTEXT.with(|ctx| {
243            // Save previous effect
244            let prev_effect = ctx.borrow().current_effect;
245
246            // Set current effect
247            ctx.borrow_mut().current_effect = Some(id);
248
249            // Run the effect
250            f();
251
252            // Restore previous effect
253            ctx.borrow_mut().current_effect = prev_effect;
254        });
255    }
256
257    /// Dispose of an effect (stop tracking and run cleanup)
258    pub fn dispose(id: EffectId) {
259        // Run cleanup functions
260        REACTIVE_CONTEXT.with(|ctx| {
261            if let Some(cleanups) = ctx.borrow_mut().cleanups.remove(&id) {
262                for cleanup in cleanups {
263                    cleanup();
264                }
265            }
266        });
267
268        // Remove from registry
269        EFFECT_REGISTRY.with(|registry| {
270            registry.borrow_mut().remove(&id);
271        });
272    }
273}
274
275/// Register a cleanup function to run when the current effect is disposed
276pub fn on_cleanup<F>(cleanup: F)
277where
278    F: Fn() + 'static,
279{
280    REACTIVE_CONTEXT.with(|ctx| {
281        let mut ctx = ctx.borrow_mut();
282        if let Some(effect_id) = ctx.current_effect {
283            ctx.cleanups
284                .entry(effect_id)
285                .or_insert_with(Vec::new)
286                .push(Box::new(cleanup));
287        }
288    });
289}
290
291/// Create a reactive scope that tracks dependencies
292pub fn create_effect<F>(f: F) -> EffectId
293where
294    F: Fn() + 'static,
295{
296    Effect::new(f)
297}
298
299/// Create a computed value
300pub fn create_computed<T, F>(compute: F) -> Computed<T>
301where
302    T: Clone + 'static,
303    F: Fn() -> T + 'static,
304{
305    Computed::new(compute)
306}
307
308/// Create a signal
309pub fn create_signal<T: Clone>(value: T) -> Signal<T> {
310    Signal::new(value)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_signal_basic() {
319        let count = Signal::new(0);
320        assert_eq!(count.get(), 0);
321
322        count.set(5);
323        assert_eq!(count.get(), 5);
324
325        count.update(|c| *c += 10);
326        assert_eq!(count.get(), 15);
327    }
328
329    #[test]
330    fn test_signal_effect() {
331        let count = Signal::new(0);
332        let result = Rc::new(RefCell::new(0));
333
334        let result_clone = result.clone();
335        let count_clone = count.clone();
336        Effect::new(move || {
337            *result_clone.borrow_mut() = count_clone.get() * 2;
338        });
339
340        assert_eq!(*result.borrow(), 0);
341
342        count.set(5);
343        assert_eq!(*result.borrow(), 10);
344
345        count.set(10);
346        assert_eq!(*result.borrow(), 20);
347    }
348
349    #[test]
350    fn test_computed() {
351        let count = Signal::new(5);
352        let count_clone = count.clone();
353        let doubled = Computed::new(move || count_clone.get() * 2);
354
355        assert_eq!(doubled.get(), 10);
356
357        count.set(10);
358        assert_eq!(doubled.get(), 20);
359
360        count.set(7);
361        assert_eq!(doubled.get(), 14);
362    }
363
364    #[test]
365    fn test_multiple_dependencies() {
366        let a = Signal::new(2);
367        let b = Signal::new(3);
368
369        let a_clone = a.clone();
370        let b_clone = b.clone();
371        let sum = Computed::new(move || a_clone.get() + b_clone.get());
372
373        assert_eq!(sum.get(), 5);
374
375        a.set(10);
376        assert_eq!(sum.get(), 13);
377
378        b.set(7);
379        assert_eq!(sum.get(), 17);
380    }
381
382    #[test]
383    fn test_effect_runs_immediately() {
384        let ran = Rc::new(RefCell::new(false));
385        let ran_clone = ran.clone();
386
387        Effect::new(move || {
388            *ran_clone.borrow_mut() = true;
389        });
390
391        assert!(*ran.borrow());
392    }
393
394    #[test]
395    fn test_untracked_read() {
396        let count = Signal::new(0);
397        let effect_count = Rc::new(RefCell::new(0));
398
399        let effect_count_clone = effect_count.clone();
400        let count_clone = count.clone();
401        Effect::new(move || {
402            // This should not trigger on untracked changes
403            let _ = count_clone.get_untracked();
404            *effect_count_clone.borrow_mut() += 1;
405        });
406
407        assert_eq!(*effect_count.borrow(), 1); // Initial run
408
409        count.set(5);
410        assert_eq!(*effect_count.borrow(), 1); // Should not re-run
411    }
412
413    #[test]
414    fn test_nested_effects() {
415        let count = Signal::new(0);
416        let doubled = Rc::new(RefCell::new(0));
417        let quadrupled = Rc::new(RefCell::new(0));
418
419        // First effect: doubled = count * 2
420        let doubled_clone = doubled.clone();
421        let count_clone = count.clone();
422        Effect::new(move || {
423            *doubled_clone.borrow_mut() = count_clone.get() * 2;
424        });
425
426        // Second effect: quadrupled = doubled * 2
427        let quadrupled_clone = quadrupled.clone();
428        let doubled_clone2 = doubled.clone();
429        Effect::new(move || {
430            *quadrupled_clone.borrow_mut() = *doubled_clone2.borrow() * 2;
431        });
432
433        assert_eq!(*doubled.borrow(), 0);
434        assert_eq!(*quadrupled.borrow(), 0);
435
436        count.set(5);
437        assert_eq!(*doubled.borrow(), 10);
438        // Note: quadrupled won't update automatically because it doesn't depend on a signal
439        // This is expected behavior - effects only track signal reads
440    }
441}