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        // a → b
180        let b_clone = b.clone();
181        let guard_ab = Rc::clone(&syncing);
182        let sub_ab = a.subscribe(move |val| {
183            if !guard_ab.get() {
184                guard_ab.set(true);
185                b_clone.set(val.clone());
186                guard_ab.set(false);
187            }
188        });
189
190        // b → a
191        let a_clone = a.clone();
192        let guard_ba = Rc::clone(&syncing);
193        let sub_ba = b.subscribe(move |val| {
194            if !guard_ba.get() {
195                guard_ba.set(true);
196                a_clone.set(val.clone());
197                guard_ba.set(false);
198            }
199        });
200
201        Self {
202            _sub_a_to_b: sub_ab,
203            _sub_b_to_a: sub_ba,
204            _guard: syncing,
205            _phantom: std::marker::PhantomData,
206        }
207    }
208}
209
210impl<T: Clone + PartialEq + 'static> std::fmt::Debug for TwoWayBinding<T> {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        f.debug_struct("TwoWayBinding").finish()
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Macros
218// ---------------------------------------------------------------------------
219
220/// Create a direct [`Binding`] to an observable.
221///
222/// # Examples
223///
224/// ```ignore
225/// let count = Observable::new(0);
226/// let b = bind!(count);
227/// assert_eq!(b.get(), 0);
228/// ```
229#[macro_export]
230macro_rules! bind {
231    ($obs:expr) => {
232        $crate::reactive::binding::bind_observable(&$obs)
233    };
234}
235
236/// Create a mapped [`Binding`] from an observable with a transform function.
237///
238/// # Examples
239///
240/// ```ignore
241/// let count = Observable::new(0);
242/// let label = bind_map!(count, |c| format!("Count: {c}"));
243/// assert_eq!(label.get(), "Count: 0");
244/// ```
245#[macro_export]
246macro_rules! bind_map {
247    ($obs:expr, $f:expr) => {
248        $crate::reactive::binding::bind_mapped(&$obs, $f)
249    };
250}
251
252/// Create a mapped [`Binding`] from two observables.
253///
254/// # Examples
255///
256/// ```ignore
257/// let width = Observable::new(10);
258/// let height = Observable::new(20);
259/// let area = bind_map2!(width, height, |w, h| w * h);
260/// assert_eq!(area.get(), 200);
261/// ```
262#[macro_export]
263macro_rules! bind_map2 {
264    ($s1:expr, $s2:expr, $f:expr) => {
265        $crate::reactive::binding::bind_mapped2(&$s1, &$s2, $f)
266    };
267}
268
269// ---------------------------------------------------------------------------
270// BindingScope — lifecycle management
271// ---------------------------------------------------------------------------
272
273/// Collects subscriptions and bindings for a logical scope (e.g., a widget).
274///
275/// When the scope is dropped, all held subscriptions are released, cleanly
276/// disconnecting all reactive bindings associated with that scope.
277///
278/// # Usage
279///
280/// ```ignore
281/// let mut scope = BindingScope::new();
282///
283/// let obs = Observable::new(42);
284/// scope.subscribe(&obs, |v| println!("value: {v}"));
285/// scope.bind(&obs, |v| format!("display: {v}"));
286///
287/// // When scope drops, all subscriptions are released.
288/// ```
289///
290/// # Invariants
291///
292/// 1. Subscriptions are released in reverse registration order on drop.
293/// 2. After drop, no callbacks from this scope will fire.
294/// 3. `clear()` releases all subscriptions immediately (reusable scope).
295/// 4. Binding count is always accurate.
296pub struct BindingScope {
297    subscriptions: Vec<Subscription>,
298}
299
300impl BindingScope {
301    /// Create an empty binding scope.
302    #[must_use]
303    pub fn new() -> Self {
304        Self {
305            subscriptions: Vec::new(),
306        }
307    }
308
309    /// Add a subscription to this scope. The subscription will be held alive
310    /// until the scope is dropped or `clear()` is called.
311    pub fn hold(&mut self, sub: Subscription) {
312        self.subscriptions.push(sub);
313    }
314
315    /// Subscribe to an observable within this scope.
316    ///
317    /// Returns a reference to the scope for chaining.
318    pub fn subscribe<T: Clone + PartialEq + 'static>(
319        &mut self,
320        source: &Observable<T>,
321        callback: impl Fn(&T) + 'static,
322    ) -> &mut Self {
323        let sub = source.subscribe(callback);
324        self.subscriptions.push(sub);
325        self
326    }
327
328    /// Create a one-way binding within this scope.
329    ///
330    /// The binding's underlying subscription is held by the scope.
331    /// Returns the `Binding<T>` for reading the value.
332    pub fn bind<T: Clone + PartialEq + 'static>(&mut self, source: &Observable<T>) -> Binding<T> {
333        bind_observable(source)
334    }
335
336    /// Create a mapped binding within this scope.
337    pub fn bind_map<S: Clone + PartialEq + 'static, T: 'static>(
338        &mut self,
339        source: &Observable<S>,
340        map: impl Fn(&S) -> T + 'static,
341    ) -> Binding<T> {
342        bind_mapped(source, map)
343    }
344
345    /// Number of active subscriptions/bindings in this scope.
346    #[must_use]
347    pub fn binding_count(&self) -> usize {
348        self.subscriptions.len()
349    }
350
351    /// Whether the scope has no active bindings.
352    #[must_use]
353    pub fn is_empty(&self) -> bool {
354        self.subscriptions.is_empty()
355    }
356
357    /// Release all subscriptions immediately (scope becomes empty but reusable).
358    pub fn clear(&mut self) {
359        self.subscriptions.clear();
360    }
361}
362
363impl Default for BindingScope {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369impl std::fmt::Debug for BindingScope {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        f.debug_struct("BindingScope")
372            .field("binding_count", &self.subscriptions.len())
373            .finish()
374    }
375}
376
377// ---------------------------------------------------------------------------
378// Tests
379// ---------------------------------------------------------------------------
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn binding_from_observable() {
387        let obs = Observable::new(42);
388        let b = bind_observable(&obs);
389        assert_eq!(b.get(), 42);
390
391        obs.set(100);
392        assert_eq!(b.get(), 100);
393    }
394
395    #[test]
396    fn binding_map() {
397        let count = Observable::new(3);
398        let label = bind_mapped(&count, |c| format!("items: {c}"));
399        assert_eq!(label.get(), "items: 3");
400
401        count.set(7);
402        assert_eq!(label.get(), "items: 7");
403    }
404
405    #[test]
406    fn binding_map2() {
407        let w = Observable::new(10);
408        let h = Observable::new(20);
409        let area = bind_mapped2(&w, &h, |a, b| a * b);
410        assert_eq!(area.get(), 200);
411
412        w.set(5);
413        assert_eq!(area.get(), 100);
414    }
415
416    #[test]
417    fn binding_then_chain() {
418        let obs = Observable::new(5);
419        let doubled = bind_observable(&obs).then(|v| v * 2);
420        assert_eq!(doubled.get(), 10);
421
422        obs.set(3);
423        assert_eq!(doubled.get(), 6);
424    }
425
426    #[test]
427    fn binding_clone_shares_source() {
428        let obs = Observable::new(1);
429        let b1 = bind_observable(&obs);
430        let b2 = b1.clone();
431
432        obs.set(99);
433        assert_eq!(b1.get(), 99);
434        assert_eq!(b2.get(), 99);
435    }
436
437    #[test]
438    fn binding_new_custom() {
439        let counter = Rc::new(Cell::new(0));
440        let c = Rc::clone(&counter);
441        let b = Binding::new(move || {
442            c.set(c.get() + 1);
443            c.get()
444        });
445        assert_eq!(b.get(), 1);
446        assert_eq!(b.get(), 2);
447    }
448
449    #[test]
450    fn bind_macro() {
451        let obs = Observable::new(42);
452        let b = bind!(obs);
453        assert_eq!(b.get(), 42);
454    }
455
456    #[test]
457    fn bind_map_macro() {
458        let obs = Observable::new(5);
459        let b = bind_map!(obs, |v| v * 10);
460        assert_eq!(b.get(), 50);
461    }
462
463    #[test]
464    fn bind_map2_macro() {
465        let a = Observable::new(3);
466        let b = Observable::new(4);
467        let sum = bind_map2!(a, b, |x, y| x + y);
468        assert_eq!(sum.get(), 7);
469    }
470
471    // ---- Two-way binding tests ----
472
473    #[test]
474    fn two_way_initial_sync() {
475        let a = Observable::new(10);
476        let b = Observable::new(0);
477        let _binding = TwoWayBinding::new(&a, &b);
478        assert_eq!(b.get(), 10, "b should sync to a's initial value");
479    }
480
481    #[test]
482    fn two_way_a_to_b() {
483        let a = Observable::new(1);
484        let b = Observable::new(0);
485        let _binding = TwoWayBinding::new(&a, &b);
486
487        a.set(42);
488        assert_eq!(b.get(), 42);
489    }
490
491    #[test]
492    fn two_way_b_to_a() {
493        let a = Observable::new(1);
494        let b = Observable::new(0);
495        let _binding = TwoWayBinding::new(&a, &b);
496
497        b.set(99);
498        assert_eq!(a.get(), 99);
499    }
500
501    #[test]
502    fn two_way_no_cycle() {
503        let a = Observable::new(0);
504        let b = Observable::new(0);
505        let _binding = TwoWayBinding::new(&a, &b);
506
507        // Set a → should propagate to b but not cycle back.
508        a.set(5);
509        assert_eq!(a.get(), 5);
510        assert_eq!(b.get(), 5);
511
512        b.set(10);
513        assert_eq!(a.get(), 10);
514        assert_eq!(b.get(), 10);
515    }
516
517    #[test]
518    fn two_way_drop_disconnects() {
519        let a = Observable::new(1);
520        let b = Observable::new(0);
521        {
522            let _binding = TwoWayBinding::new(&a, &b);
523            a.set(5);
524            assert_eq!(b.get(), 5);
525        }
526        // After drop, changes should not propagate.
527        a.set(100);
528        assert_eq!(b.get(), 5, "b should not update after binding dropped");
529    }
530
531    #[test]
532    fn two_way_with_strings() {
533        let a = Observable::new(String::from("hello"));
534        let b = Observable::new(String::new());
535        let _binding = TwoWayBinding::new(&a, &b);
536
537        assert_eq!(b.get(), "hello");
538        b.set("world".to_string());
539        assert_eq!(a.get(), "world");
540    }
541
542    #[test]
543    fn multiple_bindings_same_source() {
544        let source = Observable::new(0);
545        let b1 = bind_observable(&source);
546        let b2 = bind_mapped(&source, |v| v * 2);
547        let b3 = bind_mapped(&source, |v| format!("{v}"));
548
549        source.set(5);
550        assert_eq!(b1.get(), 5);
551        assert_eq!(b2.get(), 10);
552        assert_eq!(b3.get(), "5");
553    }
554
555    #[test]
556    fn binding_survives_source_clone() {
557        let source = Observable::new(42);
558        let b = bind_observable(&source);
559
560        let source2 = source.clone();
561        source2.set(99);
562        assert_eq!(
563            b.get(),
564            99,
565            "binding should see changes through cloned observable"
566        );
567    }
568
569    // ---- BindingScope tests ----
570
571    #[test]
572    fn scope_holds_subscriptions() {
573        let obs = Observable::new(0);
574        let seen = Rc::new(Cell::new(0));
575
576        let mut scope = BindingScope::new();
577        let s = Rc::clone(&seen);
578        scope.subscribe(&obs, move |v| s.set(*v));
579        assert_eq!(scope.binding_count(), 1);
580
581        obs.set(42);
582        assert_eq!(seen.get(), 42);
583    }
584
585    #[test]
586    fn scope_drop_releases_subscriptions() {
587        let obs = Observable::new(0);
588        let seen = Rc::new(Cell::new(0));
589
590        {
591            let mut scope = BindingScope::new();
592            let s = Rc::clone(&seen);
593            scope.subscribe(&obs, move |v| s.set(*v));
594            obs.set(1);
595            assert_eq!(seen.get(), 1);
596        }
597
598        // After scope dropped, subscription should be gone.
599        obs.set(99);
600        assert_eq!(
601            seen.get(),
602            1,
603            "callback should not fire after scope dropped"
604        );
605    }
606
607    #[test]
608    fn scope_clear_releases() {
609        let obs = Observable::new(0);
610        let seen = Rc::new(Cell::new(0));
611
612        let mut scope = BindingScope::new();
613        let s = Rc::clone(&seen);
614        scope.subscribe(&obs, move |v| s.set(*v));
615        assert_eq!(scope.binding_count(), 1);
616
617        scope.clear();
618        assert_eq!(scope.binding_count(), 0);
619        assert!(scope.is_empty());
620
621        obs.set(42);
622        assert_eq!(seen.get(), 0, "callback should not fire after clear");
623    }
624
625    #[test]
626    fn scope_multiple_subscriptions() {
627        let obs = Observable::new(0);
628        let count = Rc::new(Cell::new(0));
629
630        let mut scope = BindingScope::new();
631        for _ in 0..5 {
632            let c = Rc::clone(&count);
633            scope.subscribe(&obs, move |_| c.set(c.get() + 1));
634        }
635        assert_eq!(scope.binding_count(), 5);
636
637        obs.set(1);
638        assert_eq!(count.get(), 5, "all 5 callbacks should fire");
639    }
640
641    #[test]
642    fn scope_bind_returns_binding() {
643        let obs = Observable::new(42);
644        let mut scope = BindingScope::new();
645        let b = scope.bind(&obs);
646        assert_eq!(b.get(), 42);
647
648        obs.set(7);
649        assert_eq!(b.get(), 7);
650    }
651
652    #[test]
653    fn scope_bind_map() {
654        let obs = Observable::new(3);
655        let mut scope = BindingScope::new();
656        let b = scope.bind_map(&obs, |v| v * 10);
657        assert_eq!(b.get(), 30);
658    }
659
660    #[test]
661    fn scope_reusable_after_clear() {
662        let obs = Observable::new(0);
663        let mut scope = BindingScope::new();
664
665        let seen1 = Rc::new(Cell::new(false));
666        let s1 = Rc::clone(&seen1);
667        scope.subscribe(&obs, move |_| s1.set(true));
668        scope.clear();
669
670        let seen2 = Rc::new(Cell::new(false));
671        let s2 = Rc::clone(&seen2);
672        scope.subscribe(&obs, move |_| s2.set(true));
673
674        obs.set(1);
675        assert!(!seen1.get(), "first subscription should be gone");
676        assert!(seen2.get(), "second subscription should be active");
677    }
678
679    #[test]
680    fn scope_hold_external_subscription() {
681        let obs = Observable::new(0);
682        let seen = Rc::new(Cell::new(0));
683
684        let mut scope = BindingScope::new();
685        let s = Rc::clone(&seen);
686        let sub = obs.subscribe(move |v| s.set(*v));
687        scope.hold(sub);
688
689        obs.set(5);
690        assert_eq!(seen.get(), 5);
691
692        drop(scope);
693        obs.set(99);
694        assert_eq!(
695            seen.get(),
696            5,
697            "held subscription should be released on scope drop"
698        );
699    }
700
701    #[test]
702    fn scope_debug_format() {
703        let mut scope = BindingScope::new();
704        let obs = Observable::new(0);
705        scope.subscribe(&obs, |_| {});
706        scope.subscribe(&obs, |_| {});
707        let debug = format!("{scope:?}");
708        assert!(debug.contains("binding_count: 2"));
709    }
710}