Skip to main content

ftui_runtime/reactive/
binding.rs

1#![forbid(unsafe_code)]
2
3//! Ergonomic binding utilities for connecting [`Observable`] values to UI state.
4//!
5//! A [`Binding<T>`] encapsulates an observable source plus an optional transform,
6//! making it easy to derive display values from reactive state. The [`bind!`] and
7//! [`bind_map!`] macros provide syntactic sugar.
8//!
9//! # Usage
10//!
11//! ```ignore
12//! use ftui_runtime::reactive::{Observable, Binding, bind, bind_map};
13//!
14//! let count = Observable::new(0);
15//!
16//! // Direct binding — get() returns the observable's value.
17//! let b = bind!(count);
18//! assert_eq!(b.get(), 0);
19//!
20//! // Mapped binding — get() returns the transformed value.
21//! let label = bind_map!(count, |c| format!("Count: {c}"));
22//! assert_eq!(label.get(), "Count: 0");
23//!
24//! count.set(5);
25//! assert_eq!(b.get(), 5);
26//! assert_eq!(label.get(), "Count: 5");
27//! ```
28//!
29//! # Two-Way Bindings
30//!
31//! [`TwoWayBinding<T>`] connects two `Observable`s so changes to either
32//! propagate to the other, with cycle prevention.
33//!
34//! ```ignore
35//! let source = Observable::new(42);
36//! let target = Observable::new(0);
37//! let _binding = TwoWayBinding::new(&source, &target);
38//!
39//! source.set(10);
40//! assert_eq!(target.get(), 10);
41//!
42//! target.set(20);
43//! assert_eq!(source.get(), 20);
44//! ```
45//!
46//! # Invariants
47//!
48//! 1. `Binding::get()` always returns the current (not stale) value.
49//! 2. A binding's transform is applied on every `get()` call (no caching).
50//!    Use [`Computed`] when memoization is needed.
51//! 3. `TwoWayBinding` prevents infinite cycles via a re-entrancy guard.
52//! 4. Dropping a `TwoWayBinding` cleanly unsubscribes both directions.
53//! 5. Bindings are `Clone` when the source `Observable` is (shared state).
54//!
55//! # Failure Modes
56//!
57//! - Transform panic: propagates to caller of `get()`.
58//! - Source dropped while binding alive: binding still works (Rc keeps inner alive).
59//!
60//! [`Computed`]: super::Computed
61
62use std::cell::Cell;
63use std::rc::Rc;
64
65use super::observable::{Observable, Subscription};
66
67// ---------------------------------------------------------------------------
68// Binding<T> — one-way read binding
69// ---------------------------------------------------------------------------
70
71/// A read-only binding to an [`Observable`] value with an optional transform.
72///
73/// Evaluates lazily on each `get()` call. For memoized transforms, prefer
74/// [`Computed`](super::Computed).
75pub struct Binding<T> {
76    eval: Rc<dyn Fn() -> T>,
77}
78
79impl<T> Clone for Binding<T> {
80    fn clone(&self) -> Self {
81        Self {
82            eval: Rc::clone(&self.eval),
83        }
84    }
85}
86
87impl<T: std::fmt::Debug + 'static> std::fmt::Debug for Binding<T> {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("Binding")
90            .field("value", &self.get())
91            .finish()
92    }
93}
94
95impl<T: 'static> Binding<T> {
96    /// Create a binding that evaluates `f` on each `get()` call.
97    pub fn new(f: impl Fn() -> T + 'static) -> Self {
98        Self { eval: Rc::new(f) }
99    }
100
101    /// Get the current bound value.
102    #[must_use]
103    pub fn get(&self) -> T {
104        (self.eval)()
105    }
106
107    /// Apply a further transform, returning a new `Binding`.
108    pub fn then<U: 'static>(self, f: impl Fn(T) -> U + 'static) -> Binding<U> {
109        Binding {
110            eval: Rc::new(move || f((self.eval)())),
111        }
112    }
113}
114
115/// Create a direct binding to an observable (identity transform).
116pub fn bind_observable<T: Clone + PartialEq + 'static>(source: &Observable<T>) -> Binding<T> {
117    let src = source.clone();
118    Binding {
119        eval: Rc::new(move || src.get()),
120    }
121}
122
123/// Create a mapped binding: `source` value transformed by `map`.
124pub fn bind_mapped<S: Clone + PartialEq + 'static, T: 'static>(
125    source: &Observable<S>,
126    map: impl Fn(&S) -> T + 'static,
127) -> Binding<T> {
128    let src = source.clone();
129    Binding {
130        eval: Rc::new(move || src.with(|v| map(v))),
131    }
132}
133
134/// Create a binding from two observables combined by `map`.
135pub fn bind_mapped2<
136    S1: Clone + PartialEq + 'static,
137    S2: Clone + PartialEq + 'static,
138    T: 'static,
139>(
140    s1: &Observable<S1>,
141    s2: &Observable<S2>,
142    map: impl Fn(&S1, &S2) -> T + 'static,
143) -> Binding<T> {
144    let src1 = s1.clone();
145    let src2 = s2.clone();
146    Binding {
147        eval: Rc::new(move || src1.with(|v1| src2.with(|v2| map(v1, v2)))),
148    }
149}
150
151// ---------------------------------------------------------------------------
152// TwoWayBinding<T> — bidirectional sync
153// ---------------------------------------------------------------------------
154
155/// Bidirectional binding between two [`Observable`]s of the same type.
156///
157/// Changes to either observable propagate to the other. A re-entrancy guard
158/// prevents infinite update cycles.
159///
160/// Drop the `TwoWayBinding` to disconnect both directions.
161pub struct TwoWayBinding<T: Clone + PartialEq + 'static> {
162    _sub_a_to_b: Subscription,
163    _sub_b_to_a: Subscription,
164    _guard: Rc<Cell<bool>>,
165    _phantom: std::marker::PhantomData<T>,
166}
167
168impl<T: Clone + PartialEq + 'static> TwoWayBinding<T> {
169    /// Create a two-way binding between `a` and `b`.
170    ///
171    /// Initially syncs `b` to `a`'s current value. Subsequent changes to
172    /// either side propagate to the other.
173    pub fn new(a: &Observable<T>, b: &Observable<T>) -> Self {
174        // Sync initial value: b takes a's value.
175        b.set(a.get());
176
177        let syncing = Rc::new(Cell::new(false));
178
179        struct ReentrancyGuard<'a>(&'a Cell<bool>);
180        impl<'a> Drop for ReentrancyGuard<'a> {
181            fn drop(&mut self) {
182                self.0.set(false);
183            }
184        }
185
186        // a → b
187        let b_clone = b.clone();
188        let guard_ab = Rc::clone(&syncing);
189        let sub_ab = a.subscribe(move |val| {
190            if !guard_ab.get() {
191                guard_ab.set(true);
192                let _guard = ReentrancyGuard(&guard_ab);
193                b_clone.set(val.clone());
194            }
195        });
196
197        // b → a
198        let a_clone = a.clone();
199        let guard_ba = Rc::clone(&syncing);
200        let sub_ba = b.subscribe(move |val| {
201            if !guard_ba.get() {
202                guard_ba.set(true);
203                let _guard = ReentrancyGuard(&guard_ba);
204                a_clone.set(val.clone());
205            }
206        });
207
208        Self {
209            _sub_a_to_b: sub_ab,
210            _sub_b_to_a: sub_ba,
211            _guard: syncing,
212            _phantom: std::marker::PhantomData,
213        }
214    }
215}
216
217impl<T: Clone + PartialEq + 'static> std::fmt::Debug for TwoWayBinding<T> {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.debug_struct("TwoWayBinding").finish()
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Macros
225// ---------------------------------------------------------------------------
226
227/// Create a direct [`Binding`] to an observable.
228///
229/// # Examples
230///
231/// ```ignore
232/// let count = Observable::new(0);
233/// let b = bind!(count);
234/// assert_eq!(b.get(), 0);
235/// ```
236#[macro_export]
237macro_rules! bind {
238    ($obs:expr) => {
239        $crate::reactive::binding::bind_observable(&$obs)
240    };
241}
242
243/// Create a mapped [`Binding`] from an observable with a transform function.
244///
245/// # Examples
246///
247/// ```ignore
248/// let count = Observable::new(0);
249/// let label = bind_map!(count, |c| format!("Count: {c}"));
250/// assert_eq!(label.get(), "Count: 0");
251/// ```
252#[macro_export]
253macro_rules! bind_map {
254    ($obs:expr, $f:expr) => {
255        $crate::reactive::binding::bind_mapped(&$obs, $f)
256    };
257}
258
259/// Create a mapped [`Binding`] from two observables.
260///
261/// # Examples
262///
263/// ```ignore
264/// let width = Observable::new(10);
265/// let height = Observable::new(20);
266/// let area = bind_map2!(width, height, |w, h| w * h);
267/// assert_eq!(area.get(), 200);
268/// ```
269#[macro_export]
270macro_rules! bind_map2 {
271    ($s1:expr, $s2:expr, $f:expr) => {
272        $crate::reactive::binding::bind_mapped2(&$s1, &$s2, $f)
273    };
274}
275
276// ---------------------------------------------------------------------------
277// BindingScope — lifecycle management
278// ---------------------------------------------------------------------------
279
280/// Collects subscriptions and bindings for a logical scope (e.g., a widget).
281///
282/// When the scope is dropped, all held subscriptions are released, cleanly
283/// disconnecting all reactive bindings associated with that scope.
284///
285/// # Usage
286///
287/// ```ignore
288/// let mut scope = BindingScope::new();
289///
290/// let obs = Observable::new(42);
291/// scope.subscribe(&obs, |v| println!("value: {v}"));
292/// scope.bind(&obs, |v| format!("display: {v}"));
293///
294/// // When scope drops, all subscriptions are released.
295/// ```
296///
297/// # Invariants
298///
299/// 1. Subscriptions are released in reverse registration order on drop.
300/// 2. After drop, no callbacks from this scope will fire.
301/// 3. `clear()` releases all subscriptions immediately (reusable scope).
302/// 4. Binding count is always accurate.
303pub struct BindingScope {
304    subscriptions: Vec<Subscription>,
305}
306
307impl BindingScope {
308    /// Create an empty binding scope.
309    #[must_use]
310    pub fn new() -> Self {
311        Self {
312            subscriptions: Vec::new(),
313        }
314    }
315
316    /// Add a subscription to this scope. The subscription will be held alive
317    /// until the scope is dropped or `clear()` is called.
318    pub fn hold(&mut self, sub: Subscription) {
319        self.subscriptions.push(sub);
320    }
321
322    /// Subscribe to an observable within this scope.
323    ///
324    /// Returns a reference to the scope for chaining.
325    pub fn subscribe<T: Clone + PartialEq + 'static>(
326        &mut self,
327        source: &Observable<T>,
328        callback: impl Fn(&T) + 'static,
329    ) -> &mut Self {
330        let sub = source.subscribe(callback);
331        self.subscriptions.push(sub);
332        self
333    }
334
335    /// Create a one-way binding within this scope.
336    ///
337    /// The binding's underlying subscription is held by the scope.
338    /// Returns the `Binding<T>` for reading the value.
339    pub fn bind<T: Clone + PartialEq + 'static>(&mut self, source: &Observable<T>) -> Binding<T> {
340        bind_observable(source)
341    }
342
343    /// Create a mapped binding within this scope.
344    pub fn bind_map<S: Clone + PartialEq + 'static, T: 'static>(
345        &mut self,
346        source: &Observable<S>,
347        map: impl Fn(&S) -> T + 'static,
348    ) -> Binding<T> {
349        bind_mapped(source, map)
350    }
351
352    /// Number of active subscriptions/bindings in this scope.
353    #[must_use]
354    pub fn binding_count(&self) -> usize {
355        self.subscriptions.len()
356    }
357
358    /// Whether the scope has no active bindings.
359    #[must_use]
360    pub fn is_empty(&self) -> bool {
361        self.subscriptions.is_empty()
362    }
363
364    /// Release all subscriptions immediately (scope becomes empty but reusable).
365    pub fn clear(&mut self) {
366        self.subscriptions.clear();
367    }
368}
369
370impl Default for BindingScope {
371    fn default() -> Self {
372        Self::new()
373    }
374}
375
376impl std::fmt::Debug for BindingScope {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        f.debug_struct("BindingScope")
379            .field("binding_count", &self.subscriptions.len())
380            .finish()
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Tests
386// ---------------------------------------------------------------------------
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn binding_from_observable() {
394        let obs = Observable::new(42);
395        let b = bind_observable(&obs);
396        assert_eq!(b.get(), 42);
397
398        obs.set(100);
399        assert_eq!(b.get(), 100);
400    }
401
402    #[test]
403    fn binding_map() {
404        let count = Observable::new(3);
405        let label = bind_mapped(&count, |c| format!("items: {c}"));
406        assert_eq!(label.get(), "items: 3");
407
408        count.set(7);
409        assert_eq!(label.get(), "items: 7");
410    }
411
412    #[test]
413    fn binding_map2() {
414        let w = Observable::new(10);
415        let h = Observable::new(20);
416        let area = bind_mapped2(&w, &h, |a, b| a * b);
417        assert_eq!(area.get(), 200);
418
419        w.set(5);
420        assert_eq!(area.get(), 100);
421    }
422
423    #[test]
424    fn binding_then_chain() {
425        let obs = Observable::new(5);
426        let doubled = bind_observable(&obs).then(|v| v * 2);
427        assert_eq!(doubled.get(), 10);
428
429        obs.set(3);
430        assert_eq!(doubled.get(), 6);
431    }
432
433    #[test]
434    fn binding_clone_shares_source() {
435        let obs = Observable::new(1);
436        let b1 = bind_observable(&obs);
437        let b2 = b1.clone();
438
439        obs.set(99);
440        assert_eq!(b1.get(), 99);
441        assert_eq!(b2.get(), 99);
442    }
443
444    #[test]
445    fn binding_new_custom() {
446        let counter = Rc::new(Cell::new(0));
447        let c = Rc::clone(&counter);
448        let b = Binding::new(move || {
449            c.set(c.get() + 1);
450            c.get()
451        });
452        assert_eq!(b.get(), 1);
453        assert_eq!(b.get(), 2);
454    }
455
456    #[test]
457    fn bind_macro() {
458        let obs = Observable::new(42);
459        let b = bind!(obs);
460        assert_eq!(b.get(), 42);
461    }
462
463    #[test]
464    fn bind_map_macro() {
465        let obs = Observable::new(5);
466        let b = bind_map!(obs, |v| v * 10);
467        assert_eq!(b.get(), 50);
468    }
469
470    #[test]
471    fn bind_map2_macro() {
472        let a = Observable::new(3);
473        let b = Observable::new(4);
474        let sum = bind_map2!(a, b, |x, y| x + y);
475        assert_eq!(sum.get(), 7);
476    }
477
478    // ---- Two-way binding tests ----
479
480    #[test]
481    fn two_way_initial_sync() {
482        let a = Observable::new(10);
483        let b = Observable::new(0);
484        let _binding = TwoWayBinding::new(&a, &b);
485        assert_eq!(b.get(), 10, "b should sync to a's initial value");
486    }
487
488    #[test]
489    fn two_way_a_to_b() {
490        let a = Observable::new(1);
491        let b = Observable::new(0);
492        let _binding = TwoWayBinding::new(&a, &b);
493
494        a.set(42);
495        assert_eq!(b.get(), 42);
496    }
497
498    #[test]
499    fn two_way_b_to_a() {
500        let a = Observable::new(1);
501        let b = Observable::new(0);
502        let _binding = TwoWayBinding::new(&a, &b);
503
504        b.set(99);
505        assert_eq!(a.get(), 99);
506    }
507
508    #[test]
509    fn two_way_no_cycle() {
510        let a = Observable::new(0);
511        let b = Observable::new(0);
512        let _binding = TwoWayBinding::new(&a, &b);
513
514        // Set a → should propagate to b but not cycle back.
515        a.set(5);
516        assert_eq!(a.get(), 5);
517        assert_eq!(b.get(), 5);
518
519        b.set(10);
520        assert_eq!(a.get(), 10);
521        assert_eq!(b.get(), 10);
522    }
523
524    #[test]
525    fn two_way_drop_disconnects() {
526        let a = Observable::new(1);
527        let b = Observable::new(0);
528        {
529            let _binding = TwoWayBinding::new(&a, &b);
530            a.set(5);
531            assert_eq!(b.get(), 5);
532        }
533        // After drop, changes should not propagate.
534        a.set(100);
535        assert_eq!(b.get(), 5, "b should not update after binding dropped");
536    }
537
538    #[test]
539    fn two_way_with_strings() {
540        let a = Observable::new(String::from("hello"));
541        let b = Observable::new(String::new());
542        let _binding = TwoWayBinding::new(&a, &b);
543
544        assert_eq!(b.get(), "hello");
545        b.set("world".to_string());
546        assert_eq!(a.get(), "world");
547    }
548
549    #[test]
550    fn multiple_bindings_same_source() {
551        let source = Observable::new(0);
552        let b1 = bind_observable(&source);
553        let b2 = bind_mapped(&source, |v| v * 2);
554        let b3 = bind_mapped(&source, |v| format!("{v}"));
555
556        source.set(5);
557        assert_eq!(b1.get(), 5);
558        assert_eq!(b2.get(), 10);
559        assert_eq!(b3.get(), "5");
560    }
561
562    #[test]
563    fn binding_survives_source_clone() {
564        let source = Observable::new(42);
565        let b = bind_observable(&source);
566
567        let source2 = source.clone();
568        source2.set(99);
569        assert_eq!(
570            b.get(),
571            99,
572            "binding should see changes through cloned observable"
573        );
574    }
575
576    // ---- BindingScope tests ----
577
578    #[test]
579    fn scope_holds_subscriptions() {
580        let obs = Observable::new(0);
581        let seen = Rc::new(Cell::new(0));
582
583        let mut scope = BindingScope::new();
584        let s = Rc::clone(&seen);
585        scope.subscribe(&obs, move |v| s.set(*v));
586        assert_eq!(scope.binding_count(), 1);
587
588        obs.set(42);
589        assert_eq!(seen.get(), 42);
590    }
591
592    #[test]
593    fn scope_drop_releases_subscriptions() {
594        let obs = Observable::new(0);
595        let seen = Rc::new(Cell::new(0));
596
597        {
598            let mut scope = BindingScope::new();
599            let s = Rc::clone(&seen);
600            scope.subscribe(&obs, move |v| s.set(*v));
601            obs.set(1);
602            assert_eq!(seen.get(), 1);
603        }
604
605        // After scope dropped, subscription should be gone.
606        obs.set(99);
607        assert_eq!(
608            seen.get(),
609            1,
610            "callback should not fire after scope dropped"
611        );
612    }
613
614    #[test]
615    fn scope_clear_releases() {
616        let obs = Observable::new(0);
617        let seen = Rc::new(Cell::new(0));
618
619        let mut scope = BindingScope::new();
620        let s = Rc::clone(&seen);
621        scope.subscribe(&obs, move |v| s.set(*v));
622        assert_eq!(scope.binding_count(), 1);
623
624        scope.clear();
625        assert_eq!(scope.binding_count(), 0);
626        assert!(scope.is_empty());
627
628        obs.set(42);
629        assert_eq!(seen.get(), 0, "callback should not fire after clear");
630    }
631
632    #[test]
633    fn scope_multiple_subscriptions() {
634        let obs = Observable::new(0);
635        let count = Rc::new(Cell::new(0));
636
637        let mut scope = BindingScope::new();
638        for _ in 0..5 {
639            let c = Rc::clone(&count);
640            scope.subscribe(&obs, move |_| c.set(c.get() + 1));
641        }
642        assert_eq!(scope.binding_count(), 5);
643
644        obs.set(1);
645        assert_eq!(count.get(), 5, "all 5 callbacks should fire");
646    }
647
648    #[test]
649    fn scope_bind_returns_binding() {
650        let obs = Observable::new(42);
651        let mut scope = BindingScope::new();
652        let b = scope.bind(&obs);
653        assert_eq!(b.get(), 42);
654
655        obs.set(7);
656        assert_eq!(b.get(), 7);
657    }
658
659    #[test]
660    fn scope_bind_map() {
661        let obs = Observable::new(3);
662        let mut scope = BindingScope::new();
663        let b = scope.bind_map(&obs, |v| v * 10);
664        assert_eq!(b.get(), 30);
665    }
666
667    #[test]
668    fn scope_reusable_after_clear() {
669        let obs = Observable::new(0);
670        let mut scope = BindingScope::new();
671
672        let seen1 = Rc::new(Cell::new(false));
673        let s1 = Rc::clone(&seen1);
674        scope.subscribe(&obs, move |_| s1.set(true));
675        scope.clear();
676
677        let seen2 = Rc::new(Cell::new(false));
678        let s2 = Rc::clone(&seen2);
679        scope.subscribe(&obs, move |_| s2.set(true));
680
681        obs.set(1);
682        assert!(!seen1.get(), "first subscription should be gone");
683        assert!(seen2.get(), "second subscription should be active");
684    }
685
686    #[test]
687    fn scope_hold_external_subscription() {
688        let obs = Observable::new(0);
689        let seen = Rc::new(Cell::new(0));
690
691        let mut scope = BindingScope::new();
692        let s = Rc::clone(&seen);
693        let sub = obs.subscribe(move |v| s.set(*v));
694        scope.hold(sub);
695
696        obs.set(5);
697        assert_eq!(seen.get(), 5);
698
699        drop(scope);
700        obs.set(99);
701        assert_eq!(
702            seen.get(),
703            5,
704            "held subscription should be released on scope drop"
705        );
706    }
707
708    #[test]
709    fn scope_debug_format() {
710        let mut scope = BindingScope::new();
711        let obs = Observable::new(0);
712        scope.subscribe(&obs, |_| {});
713        scope.subscribe(&obs, |_| {});
714        let debug = format!("{scope:?}");
715        assert!(debug.contains("binding_count: 2"));
716    }
717}