Skip to main content

teksilo_core/
signal.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Unified reactivity primitives for Teksilo.
5//!
6//! `Signal<T>` is the single reactive type. `Prop<T>` is the widget
7//! property type for static values and signal bindings. `ObserverHandle`
8//! is an RAII guard — dropping it removes the observer callback.
9
10use std::cell::{Cell, Ref, RefCell};
11use std::rc::{Rc, Weak};
12
13use crate::binding::{Binding, BindingLevel, BindingRegistry};
14use crate::widget_id::WidgetId;
15
16// ---------------------------------------------------------------------------
17// Feedback-loop guard (debug-only)
18// ---------------------------------------------------------------------------
19//
20// The snapshot-and-release notification model lets an observer freely
21// re-enter `set` on the same (or another) signal. That flexibility also means
22// an *accidental* feedback loop — signal A's observer writes B, B's observer
23// writes A, neither guarded by an equality check — recurses without bound and
24// blows the stack with no actionable diagnostic. In debug builds we track the
25// per-thread notification depth and panic with a pointer at the likely cause
26// once it crosses a limit set far above any legitimate synchronous cascade.
27// Release builds carry no counter and no check.
28#[cfg(debug_assertions)]
29const SIGNAL_NOTIFY_DEPTH_LIMIT: u32 = 256;
30
31#[cfg(debug_assertions)]
32thread_local! {
33    static SIGNAL_NOTIFY_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
34}
35
36/// RAII increment of the per-thread Signal-notification depth (debug only).
37#[cfg(debug_assertions)]
38struct NotifyDepthGuard;
39
40#[cfg(debug_assertions)]
41impl NotifyDepthGuard {
42    fn enter() -> Self {
43        SIGNAL_NOTIFY_DEPTH.with(|d| {
44            let next = d.get() + 1;
45            assert!(
46                next <= SIGNAL_NOTIFY_DEPTH_LIMIT,
47                "Signal notification nested {next} deep (limit {SIGNAL_NOTIFY_DEPTH_LIMIT}) — \
48                 almost certainly an unbounded feedback loop between observers (e.g. signal A's \
49                 observer sets B and B's observer sets A). Break the cycle: guard the write with \
50                 an equality check (`if sig.get() != v {{ sig.set(v) }}`), or drop one edge with \
51                 a WeakSignal."
52            );
53            d.set(next);
54        });
55        NotifyDepthGuard
56    }
57}
58
59#[cfg(debug_assertions)]
60impl Drop for NotifyDepthGuard {
61    fn drop(&mut self) {
62        SIGNAL_NOTIFY_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
63    }
64}
65
66// ---------------------------------------------------------------------------
67// ObserverHandle — RAII guard for observer cleanup
68// ---------------------------------------------------------------------------
69
70/// RAII guard for an observer callback. Dropping the handle removes the
71/// callback from the signal, preventing memory leaks.
72pub struct ObserverHandle {
73    /// Reference to the signal (keeps it alive while the handle exists).
74    _signal: Rc<dyn std::any::Any>,
75    observer_id: u64,
76    remover: Rc<dyn Fn(u64)>,
77}
78
79impl ObserverHandle {
80    /// Create a new observer handle.
81    ///
82    /// - `keeper`: an `Rc` that keeps the observed source alive while this handle exists.
83    /// - `observer_id`: the ID identifying this observer.
84    /// - `remover`: called with `observer_id` when the handle is dropped, to unregister the callback.
85    pub fn new(keeper: Rc<dyn std::any::Any>, observer_id: u64, remover: Rc<dyn Fn(u64)>) -> Self {
86        Self {
87            _signal: keeper,
88            observer_id,
89            remover,
90        }
91    }
92
93    /// Explicitly detach the observer without dropping the handle.
94    pub fn detach(self) {
95        // Drop runs automatically, which calls remover
96        drop(self);
97    }
98}
99
100impl Drop for ObserverHandle {
101    fn drop(&mut self) {
102        (self.remover)(self.observer_id);
103    }
104}
105
106impl std::fmt::Debug for ObserverHandle {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("ObserverHandle")
109            .field("observer_id", &self.observer_id)
110            .finish()
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Signal internals
116// ---------------------------------------------------------------------------
117
118struct ObserverEntry<T> {
119    id: u64,
120    callback: Rc<dyn Fn(&T)>,
121}
122
123/// Register `callback` on a mutable root and hand back the RAII guard that
124/// unregisters it.
125///
126/// Shared by [`Signal::try_observe`] and by the `subscribe` hook a mutable
127/// root publishes on its [`DerivedSource`], so a derived signal's observer
128/// attaches by exactly the same mechanism as a direct one — there is no
129/// second registration path that could drift from this one.
130fn observe_inner<T: 'static>(
131    inner: &Rc<RefCell<MutableInner<T>>>,
132    callback: Rc<dyn Fn(&T)>,
133) -> ObserverHandle {
134    let id = {
135        let mut guard = inner.borrow_mut();
136        let id = guard.next_observer_id;
137        guard.next_observer_id += 1;
138        guard.observers.push(ObserverEntry { id, callback });
139        id
140    };
141    ObserverHandle {
142        _signal: inner.clone(),
143        observer_id: id,
144        remover: {
145            let inner = inner.clone();
146            Rc::new(move |observer_id| {
147                inner.borrow_mut().observers.retain(|e| e.id != observer_id);
148            })
149        },
150    }
151}
152
153struct MutableInner<T> {
154    value: T,
155    /// Monotonic change counter, advanced by every write (`try_set`, and
156    /// arming an animation). **Never reset.**
157    ///
158    /// This replaced a plain `dirty: bool` because a boolean is
159    /// *consumer-shaped state stored on the producer*: one flag, but
160    /// potentially many independent consumers. Each open window owns its
161    /// own [`crate::binding::BindingRegistry`], and that registry's flush
162    /// pass both read AND cleared the flag — so with two windows bound to
163    /// one signal, whichever tree reconciled first consumed the flag and
164    /// every other window silently skipped its otherwise-correct binding.
165    /// Not a delayed rebuild: a permanently missed one, until an unrelated
166    /// later write raced a different window into observing it first (which
167    /// window lost was decided by `HashMap` iteration order).
168    ///
169    /// A counter moves the per-consumer half of that state to the
170    /// consumer: nothing here is consumed, and each registry remembers the
171    /// generation it last acted on (`BindingGroup::last_seen`). N readers
172    /// are then trivially independent.
173    generation: u64,
174    observers: Vec<ObserverEntry<T>>,
175    next_observer_id: u64,
176    /// Drop guards attached to this signal via `attach_keepalive`. Used
177    /// by adapters (e.g., `LocalizedString::to_signal`) that observe an
178    /// external source and need their `ObserverHandle` to live exactly
179    /// as long as the signal it updates — when the last `Signal<T>`
180    /// clone drops, this `Vec` drops, which drops every stored handle,
181    /// which detaches their callbacks from the source. Without this,
182    /// such adapters would have to `mem::forget` their handles and
183    /// leak both the observer entry on the source and the target
184    /// signal it kept alive through a strong `Rc` clone.
185    keepalive: Vec<Box<dyn std::any::Any>>,
186}
187
188/// Animation-specific state, only for `Signal<f32>`.
189struct AnimationState {
190    pending: Option<crate::animation::AnimationRequest>,
191    target: Option<f32>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
195pub enum SignalAccessError {
196    #[error("signal is read-only")]
197    ReadOnly,
198    #[error("signal does not support animation")]
199    AnimationUnsupported,
200}
201
202/// Weak reference to a mutable signal. Produced by `Signal::downgrade`.
203///
204/// Unlike a `Signal<T>` clone (which is an `Rc`), a `WeakSignal<T>`
205/// does not extend the lifetime of the underlying `MutableInner<T>`.
206/// Use this inside observer callbacks that should not keep the
207/// observed-target signal alive — otherwise the strong `Rc` captured
208/// by the closure forms a reference cycle with the inner that holds
209/// the observer, and neither gets freed.
210pub struct WeakSignal<T> {
211    inner: Weak<RefCell<MutableInner<T>>>,
212    animation: Option<Weak<RefCell<AnimationState>>>,
213}
214
215impl<T> Clone for WeakSignal<T> {
216    fn clone(&self) -> Self {
217        Self {
218            inner: self.inner.clone(),
219            animation: self.animation.clone(),
220        }
221    }
222}
223
224impl<T: 'static> WeakSignal<T> {
225    /// Try to upgrade the weak reference into a live `Signal<T>`.
226    /// Returns `None` if the target signal has already been dropped.
227    pub fn upgrade(&self) -> Option<Signal<T>> {
228        let inner = self.inner.upgrade()?;
229        // If the original was animated, preserve that — but if the
230        // animation state was freed independently we degrade to a
231        // non-animated signal rather than failing the upgrade.
232        let animation = self.animation.as_ref().and_then(|weak| weak.upgrade());
233        Some(Signal {
234            kind: SignalKind::Mutable { inner, animation },
235        })
236    }
237}
238
239pub(crate) struct WeakAnimatedSignal {
240    inner: Weak<RefCell<MutableInner<f32>>>,
241    animation: Weak<RefCell<AnimationState>>,
242}
243
244impl WeakAnimatedSignal {
245    pub(crate) fn upgrade(&self) -> Option<Signal<f32>> {
246        Some(Signal {
247            kind: SignalKind::Mutable {
248                inner: self.inner.upgrade()?,
249                animation: Some(self.animation.upgrade()?),
250            },
251        })
252    }
253
254    pub(crate) fn same_signal(&self, signal: &Signal<f32>) -> bool {
255        match &signal.kind {
256            SignalKind::Mutable { inner, .. } => self.inner.as_ptr() == Rc::as_ptr(inner),
257            SignalKind::Derived { .. } => false,
258        }
259    }
260}
261
262/// One upstream source a [`SignalKind::Derived`] signal depends on.
263///
264/// A single-source derived signal (the typical `map` case) carries one
265/// entry; multi-source derived signals (`zip`, `zip3`, `and`, `or`)
266/// carry one per observed mutable root. Change-tracking walks the whole
267/// vec — a consumer is stale if *any* entry's generation moved past what
268/// that consumer last acted on.
269///
270/// **Every `generation` closure in the system is monotonically
271/// non-decreasing**, whether it reads a mutable root's counter directly
272/// or is a composite built by [`coalesced_source`]. Callers rely on that:
273/// it is what lets [`Signal::generation`] fold a multi-source derived
274/// signal down to a single `u64` by summing, with no risk that one
275/// source's advance is cancelled by another's retreat.
276///
277/// Alongside the poll-based `generation`, a source may publish a
278/// **push**-based `subscribe`. Generation polling serves the frame loop,
279/// which asks every binding once a frame anyway; an observer has no frame
280/// loop behind it and has to be told. See [`Signal::try_observe`].
281#[derive(Clone)]
282struct DerivedSource {
283    /// Current generation of this upstream. Compare against a remembered
284    /// value to decide staleness; never "clear" it.
285    generation: Rc<dyn Fn() -> u64>,
286    /// Stable identity of the upstream mutable root — used by
287    /// [`BindingRegistry`] to dedup repeated `bind_to` calls.
288    source_id: usize,
289    /// Register a nullary change callback on this upstream, returning the
290    /// guard that unregisters it.
291    ///
292    /// `None` where the set of roots is chosen *dynamically* and so cannot
293    /// be subscribed to once and for all — [`Signal::flat_map`], whose
294    /// inner signal is re-selected on every poll. A derived signal with any
295    /// such source stays unobservable, and [`Signal::try_observe`] says so
296    /// with [`SignalAccessError::ReadOnly`] rather than attaching an
297    /// observer that would go quiet the moment the inner switched.
298    subscribe: Option<SubscribeHook>,
299}
300
301/// A [`DerivedSource`]'s push-side registration: takes the callback to run
302/// on every change of the upstream, returns the guard that detaches it.
303///
304/// Type-erased over the upstream's own `T` — a derived signal recomputes
305/// its own value rather than reading the root's, so the notification only
306/// has to say *that* something moved.
307type SubscribeHook = Rc<dyn Fn(Rc<dyn Fn()>) -> ObserverHandle>;
308
309/// Fold an arbitrary — and possibly *changing* — set of upstream
310/// generations into ONE monotone counter, presented as a single
311/// [`DerivedSource`].
312///
313/// `inputs` is polled on demand and returns the current generation of
314/// every upstream the composite currently depends on. Whenever that
315/// vector differs from the one seen at the previous poll (in length or
316/// in any element), the composite's own counter advances by one.
317///
318/// Summing the inputs would be enough for a *fixed* set of monotone
319/// upstreams, but not for [`Signal::flat_map`], whose selected inner
320/// signal is re-chosen on every poll: switching inners makes its
321/// generation term jump arbitrarily, including downwards, and a drop
322/// that exactly cancelled an increase elsewhere would hide a real
323/// change. Re-deriving an own counter from "did the input vector change
324/// at all" is immune to that, and keeps this source monotone for
325/// everyone downstream.
326///
327/// Crucially the memo is **never consumed**: the first registry to poll
328/// advances the counter, and every registry polling afterwards reads the
329/// same, already-advanced value and compares it against its OWN
330/// last-seen. That is what makes one composite safe to share between N
331/// independently-reconciled `WidgetTree`s — the property the whole
332/// generation scheme exists to provide.
333fn coalesced_source(
334    inputs: Rc<dyn Fn() -> Vec<u64>>,
335    subscribe: Option<SubscribeHook>,
336) -> DerivedSource {
337    // The token anchors a unique heap address used as `source_id`; the
338    // closure below owns it, so the address stays valid — and therefore
339    // unambiguous — for exactly as long as this source is reachable.
340    let token: Rc<()> = Rc::new(());
341    let source_id = Rc::as_ptr(&token) as usize;
342    let state: Rc<(Cell<u64>, RefCell<Option<Vec<u64>>>)> =
343        Rc::new((Cell::new(0), RefCell::new(None)));
344    DerivedSource {
345        generation: Rc::new(move || {
346            let _keep = &token;
347            let now = inputs();
348            let (own, seen) = &*state;
349            let mut seen = seen.borrow_mut();
350            if seen.as_deref() != Some(now.as_slice()) {
351                *seen = Some(now);
352                own.set(own.get().wrapping_add(1));
353            }
354            own.get()
355        }),
356        source_id,
357        subscribe,
358    }
359}
360
361/// Build one [`SubscribeHook`] that fans a single callback out to every
362/// source in `sources`, or `None` if any of them is poll-only.
363///
364/// All-or-nothing on purpose: a partial subscription is the worst outcome
365/// available here — it would report *some* changes and silently miss the
366/// rest, which reads as a stale UI with no error anywhere to explain it.
367fn fan_out_subscribe(sources: &[DerivedSource]) -> Option<SubscribeHook> {
368    let hooks: Option<Vec<SubscribeHook>> = sources.iter().map(|s| s.subscribe.clone()).collect();
369    let hooks = hooks?;
370    Some(Rc::new(move |notify: Rc<dyn Fn()>| {
371        let handles: Vec<ObserverHandle> = hooks.iter().map(|hook| hook(notify.clone())).collect();
372        // The keeper owns every per-source guard, so dropping the composite
373        // handle detaches all of them; there is no observer of its own to
374        // remove, hence the no-op remover.
375        ObserverHandle::new(Rc::new(handles), 0, Rc::new(|_| {}))
376    }))
377}
378
379enum SignalKind<T> {
380    Mutable {
381        inner: Rc<RefCell<MutableInner<T>>>,
382        animation: Option<Rc<RefCell<AnimationState>>>,
383    },
384    Derived {
385        compute: Rc<dyn Fn() -> T>,
386        /// The upstream mutable roots this derived signal depends on.
387        /// Typically one entry; `zip`/`zip3`/`and`/`or` produce many.
388        /// Deduped by `source_id` at construction.
389        sources: Vec<DerivedSource>,
390    },
391}
392
393// ---------------------------------------------------------------------------
394// Signal<T>
395// ---------------------------------------------------------------------------
396
397/// A reactive value. Created via `Signal::new(value)` for mutable signals
398/// or `signal.map(f)` for derived (read-only) signals.
399pub struct Signal<T> {
400    kind: SignalKind<T>,
401}
402
403impl<T: 'static> Signal<T> {
404    /// Create a mutable signal with an initial value.
405    pub fn new(value: T) -> Self {
406        Self {
407            kind: SignalKind::Mutable {
408                inner: Rc::new(RefCell::new(MutableInner {
409                    value,
410                    generation: 0,
411                    observers: Vec::new(),
412                    next_observer_id: 1,
413                    keepalive: Vec::new(),
414                })),
415                animation: None,
416            },
417        }
418    }
419
420    /// Attach an arbitrary drop guard that lives exactly as long as the
421    /// signal does — the guard is dropped when the last `Signal<T>`
422    /// clone is freed (i.e., when `MutableInner<T>` is freed). Intended
423    /// for adapters that observe an external source and want their
424    /// `ObserverHandle` to auto-unsubscribe when the signal they're
425    /// driving becomes unreachable.
426    ///
427    /// On a derived (read-only) signal this is a no-op; the adapter
428    /// pattern only makes sense for mutable signals.
429    pub fn attach_keepalive<G: 'static>(&self, guard: G) {
430        if let SignalKind::Mutable { inner, .. } = &self.kind {
431            inner.borrow_mut().keepalive.push(Box::new(guard));
432        }
433    }
434
435    /// Get a weak reference to this signal. Callbacks registered on an
436    /// external source should capture the `WeakSignal` instead of a
437    /// strong `Signal<T>` clone — otherwise the callback's strong `Rc`
438    /// keeps the inner alive indefinitely, creating a reference cycle.
439    ///
440    /// Returns `None` for derived (read-only) signals, which have no
441    /// shared inner to downgrade.
442    pub fn downgrade(&self) -> Option<WeakSignal<T>> {
443        match &self.kind {
444            SignalKind::Mutable { inner, animation } => Some(WeakSignal {
445                inner: Rc::downgrade(inner),
446                animation: animation.as_ref().map(Rc::downgrade),
447            }),
448            SignalKind::Derived { .. } => None,
449        }
450    }
451
452    /// Register an observer callback. Returns an `ObserverHandle` — dropping
453    /// the handle removes the callback.
454    ///
455    /// Works on a derived signal too; see [`try_observe`](Self::try_observe)
456    /// for the one shape that does not, and panics here.
457    pub fn observe(&self, f: impl Fn(&T) + 'static) -> ObserverHandle {
458        self.try_observe(f)
459            .expect("observe() needs a signal with fixed mutable roots")
460    }
461
462    /// Fallible [`observe`](Self::observe).
463    ///
464    /// A **derived** signal is observable as well as readable: it keeps the
465    /// mutable roots it was built from, so this registers on each of them and
466    /// recomputes on any change. That matters because `enabled(..)`,
467    /// `checked(..)` and every other `Prop` accept a derived signal
468    /// everywhere else — a consumer that pushes instead of polling (the
469    /// macOS native menu bridge is the one in the tree) would otherwise
470    /// reject exactly the bindings the rest of the framework invites.
471    ///
472    /// The callback is handed the derived signal's own recomputed value, not
473    /// the root's. It may fire more than once for a single logical change: a
474    /// derived signal over N roots has N registrations, and a caller writing
475    /// two of them notifies twice. Deltas applied by an observer should
476    /// therefore be idempotent — which is the same discipline
477    /// [`set`](Self::set) already imposes, since it fans out unconditionally.
478    ///
479    /// Returns [`SignalAccessError::ReadOnly`] only for a signal whose roots
480    /// are re-chosen as it is read — [`flat_map`](Self::flat_map) — where
481    /// there is nothing stable to attach to.
482    pub fn try_observe(
483        &self,
484        f: impl Fn(&T) + 'static,
485    ) -> Result<ObserverHandle, SignalAccessError> {
486        match &self.kind {
487            SignalKind::Mutable { inner, .. } => Ok(observe_inner(inner, Rc::new(f))),
488            SignalKind::Derived { compute, sources } => {
489                let subscribe = fan_out_subscribe(sources).ok_or(SignalAccessError::ReadOnly)?;
490                let compute = compute.clone();
491                let f = Rc::new(f);
492                Ok(subscribe(Rc::new(move || f(&compute()))))
493            }
494        }
495    }
496
497    /// Number of active observers on this signal. Derived signals always return 0.
498    pub fn observer_count(&self) -> usize {
499        match &self.kind {
500            SignalKind::Mutable { inner, .. } => inner.borrow().observers.len(),
501            SignalKind::Derived { .. } => 0,
502        }
503    }
504
505    /// Whether two Signal handles point to the same underlying value.
506    pub fn same(a: &Self, b: &Self) -> bool {
507        match (&a.kind, &b.kind) {
508            (SignalKind::Mutable { inner: a, .. }, SignalKind::Mutable { inner: b, .. }) => {
509                Rc::ptr_eq(a, b)
510            }
511            _ => false,
512        }
513    }
514}
515
516impl<T: Clone + 'static> Signal<T> {
517    /// Set a new value. Marks the signal as dirty and notifies observers.
518    /// Panics if called on a derived (read-only) signal.
519    ///
520    /// Observers may freely re-enter `set`/`try_set`/`observe`, or drop their
521    /// `ObserverHandle`, on this same signal from within their callback — see
522    /// [`try_set`](Self::try_set).
523    pub fn set(&self, value: T) {
524        self.try_set(value)
525            .expect("cannot set() on a derived Signal — it is read-only");
526    }
527
528    /// Set a new value only if it differs from the current one, returning
529    /// whether it changed.
530    ///
531    /// [`set`](Self::set) has no equality check by design — it writes and fans
532    /// out to every observer unconditionally. That is the right default for a
533    /// signal carrying a value whose identity matters, but it makes a
534    /// *republish* of an unchanged value cost a full observer walk. On a
535    /// per-frame path that is pure waste: the text editors' scroll-metric step
536    /// republishes four signals every tick and measured ~5% of frame CPU in
537    /// `set<f32>` before its call sites were guarded by hand.
538    ///
539    /// This is also exactly the guard the [`try_set`](Self::try_set) docs
540    /// prescribe for reactive writes that might cycle, so reach for this rather
541    /// than open-coding `if sig.get() != v { sig.set(v) }` — it is the same
542    /// thing, named, and it cannot be forgotten at one call site out of four.
543    ///
544    /// Equality is `PartialEq`, not an epsilon. For floats that is deliberate:
545    /// a tolerance like `f32::EPSILON` is the machine epsilon *near 1.0*, so
546    /// past a magnitude of about 1.0 the smallest representable step already
547    /// exceeds it and the comparison silently degrades into exact inequality
548    /// anyway — while near zero it would suppress writes that genuinely
549    /// changed. A caller that truly wants a tolerance wants a domain-specific
550    /// one, and should say so at its own call site.
551    ///
552    /// Panics on a derived (read-only) signal, like [`set`](Self::set).
553    pub fn set_if_changed(&self, value: T) -> bool
554    where
555        T: PartialEq,
556    {
557        if self.get() == value {
558            return false;
559        }
560        self.set(value);
561        true
562    }
563
564    /// Fallible [`set`](Self::set): returns [`SignalAccessError::ReadOnly`]
565    /// for a derived signal instead of panicking.
566    ///
567    /// The new value and the observer callbacks are snapshotted while the
568    /// inner `RefCell` is borrowed, then **all** borrows are released before
569    /// any callback runs. An observer is therefore free to re-enter
570    /// `set`/`try_set`/`observe`, or drop an `ObserverHandle`, on this same
571    /// signal without a `RefCell` borrow conflict — mirroring the
572    /// mutate-then-notify discipline used across `teksilo-data`. Each
573    /// notification delivers the value as written by *that* call; observer
574    /// additions or removals made during a callback take effect only on
575    /// subsequent notifications.
576    ///
577    /// # Feedback loops
578    ///
579    /// Re-entrancy is supported, but a write cascade that never settles — A's
580    /// observer writes B and B's observer writes A, with no equality guard — is
581    /// an unbounded recursion that will overflow the stack. Guard reactive
582    /// writes that may cycle (`if sig.get() != v { sig.set(v) }`) or break one
583    /// edge with a [`WeakSignal`]. In debug builds a depth guard turns a runaway
584    /// loop into a diagnostic panic instead of a silent stack overflow; release
585    /// builds carry no such check.
586    pub fn try_set(&self, value: T) -> Result<(), SignalAccessError> {
587        match &self.kind {
588            SignalKind::Mutable { inner, .. } => {
589                let (snapshot, callbacks) = {
590                    let mut guard = inner.borrow_mut();
591                    guard.value = value;
592                    guard.generation = guard.generation.wrapping_add(1);
593                    let callbacks: Vec<_> =
594                        guard.observers.iter().map(|e| e.callback.clone()).collect();
595                    (guard.value.clone(), callbacks)
596                };
597                // Debug-only: a re-entrant observer bumps this depth; an
598                // unbounded feedback loop trips the limit and panics with a
599                // diagnostic rather than overflowing the stack.
600                #[cfg(debug_assertions)]
601                let _depth = NotifyDepthGuard::enter();
602                for cb in &callbacks {
603                    cb(&snapshot);
604                }
605                Ok(())
606            }
607            SignalKind::Derived { .. } => Err(SignalAccessError::ReadOnly),
608        }
609    }
610
611    /// Read the current value (cloned).
612    pub fn get(&self) -> T {
613        match &self.kind {
614            SignalKind::Mutable { inner, .. } => inner.borrow().value.clone(),
615            SignalKind::Derived { compute, .. } => compute(),
616        }
617    }
618
619    /// Read the current value by reference (only for mutable signals).
620    /// Panics on derived signals.
621    pub fn get_ref(&self) -> Ref<'_, T> {
622        self.try_get_ref()
623            .expect("get_ref() is only supported on mutable signals")
624    }
625
626    pub fn try_get_ref(&self) -> Result<Ref<'_, T>, SignalAccessError> {
627        match &self.kind {
628            SignalKind::Mutable { inner, .. } => Ok(Ref::map(inner.borrow(), |guard| &guard.value)),
629            SignalKind::Derived { .. } => Err(SignalAccessError::ReadOnly),
630        }
631    }
632
633    /// Create a derived (read-only) signal whose value is computed from
634    /// this signal. The closure runs lazily when the derived signal is read.
635    pub fn map<U: Clone + 'static>(&self, f: impl Fn(&T) -> U + 'static) -> Signal<U> {
636        let compute = self.as_compute();
637        let sources = self.as_sources();
638        Signal {
639            kind: SignalKind::Derived {
640                compute: Rc::new(move || f(&compute())),
641                sources,
642            },
643        }
644    }
645
646    /// Zip this signal with another, producing a derived signal that
647    /// observes both upstream sources. The resulting signal is marked
648    /// dirty whenever *either* source flips, so widgets binding to it
649    /// correctly re-render on any input change.
650    ///
651    /// Combine with [`Signal::map`] for n-ary predicates:
652    ///
653    /// ```
654    /// # use teksilo_core::Signal;
655    /// # let focus = Signal::new(false);
656    /// # let readonly = Signal::new(false);
657    /// let composite = focus.zip(&readonly).map(|(f, r)| *f && !*r);
658    /// ```
659    pub fn zip<U: Clone + 'static>(&self, other: &Signal<U>) -> Signal<(T, U)> {
660        let a = self.as_compute();
661        let b = other.as_compute();
662        let mut sources = self.as_sources();
663        merge_sources(&mut sources, other.as_sources());
664        Signal {
665            kind: SignalKind::Derived {
666                compute: Rc::new(move || (a(), b())),
667                sources,
668            },
669        }
670    }
671
672    /// Zip three signals. See [`Signal::zip`].
673    pub fn zip3<U: Clone + 'static, V: Clone + 'static>(
674        &self,
675        b: &Signal<U>,
676        c: &Signal<V>,
677    ) -> Signal<(T, U, V)> {
678        let fa = self.as_compute();
679        let fb = b.as_compute();
680        let fc = c.as_compute();
681        let mut sources = self.as_sources();
682        merge_sources(&mut sources, b.as_sources());
683        merge_sources(&mut sources, c.as_sources());
684        Signal {
685            kind: SignalKind::Derived {
686                compute: Rc::new(move || (fa(), fb(), fc())),
687                sources,
688            },
689        }
690    }
691
692    /// Like [`map`](Self::map), but the resulting derived signal
693    /// presents a **single** combined `DerivedSource` to binding
694    /// registrations instead of one per upstream root.
695    ///
696    /// For animation-driven multi-axis signals — e.g. a
697    /// `pan_x.zip3(pan_y, zoom).zip(rotation)` view transform that
698    /// flips all four sources on every animation tick — this collapses
699    /// the per-tick binding work from O(N) to O(1) without changing
700    /// dirty-tracking semantics: the composite source is dirty when
701    /// any underlying source is, and clearing the composite clears
702    /// every underlying source.
703    ///
704    /// Use only when the derived signal's value depends on **all**
705    /// upstream sources being read together (a compose function);
706    /// when only one upstream changes per frame, [`map`](Self::map) is
707    /// equivalent and cheaper.
708    pub fn map_coalesced<U: Clone + 'static>(&self, f: impl Fn(&T) -> U + 'static) -> Signal<U> {
709        let compute = self.as_compute();
710        let underlying = self.as_sources();
711        if underlying.len() <= 1 {
712            // One source already — no coalescing benefit; delegate
713            // to plain map to avoid an extra indirection.
714            return self.map(f);
715        }
716        // The coalesced source stands in for a *fixed* set of roots, so it
717        // can still be subscribed to — one registration per root, behind the
718        // one hook. (`flat_map` below is the case that cannot.)
719        let subscribe = fan_out_subscribe(&underlying);
720        let coalesced = coalesced_source(
721            Rc::new(move || underlying.iter().map(|s| (s.generation)()).collect()),
722            subscribe,
723        );
724        Signal {
725            kind: SignalKind::Derived {
726                compute: Rc::new(move || f(&compute())),
727                sources: vec![coalesced],
728            },
729        }
730    }
731
732    /// Switch / bind: derive a signal whose value **and** dirty-tracking
733    /// follow the inner `Signal<U>` selected by `f` from this signal's
734    /// current value. When *this* signal changes, `f` re-selects a
735    /// (possibly different) inner signal and the result follows that one
736    /// instead — the classic reactive "switchLatest" combinator.
737    ///
738    /// Unlike [`map`](Self::map), the result depends on an inner source
739    /// that is chosen dynamically, so it exposes a **single composite
740    /// `DerivedSource`** whose dirty/clear evaluate the currently-selected
741    /// inner each time they are polled. Binding registration stays O(1)
742    /// regardless of how many distinct inner signals `f` may return.
743    ///
744    /// Typical use — track the *active* item's reactive flag out of a set:
745    ///
746    /// ```
747    /// # use teksilo_core::Signal;
748    /// # let current_step: Signal<usize> = Signal::new(0);
749    /// # let completion: Vec<Signal<bool>> = vec![Signal::new(true), Signal::new(false)];
750    /// // disable Next until the currently-shown step's gate is satisfied
751    /// let gate = current_step.flat_map(move |i| completion[*i].clone());
752    /// // ctx.enabled_when(next_id, gate);
753    /// # let _ = gate;
754    /// ```
755    pub fn flat_map<U: Clone + 'static>(&self, f: impl Fn(&T) -> Signal<U> + 'static) -> Signal<U> {
756        let f: Rc<dyn Fn(&T) -> Signal<U>> = Rc::new(f);
757        let outer_compute = self.as_compute();
758        let outer_sources = self.as_sources();
759
760        // compute: select the inner signal from the outer value, read it.
761        let compute: Rc<dyn Fn() -> U> = {
762            let f = f.clone();
763            let outer_compute = outer_compute.clone();
764            Rc::new(move || f(&outer_compute()).get())
765        };
766
767        // One composite source, whose inputs are the outer sources' own
768        // generations PLUS the generations of whichever inner signal is
769        // selected right now. The inner half is spliced in as its
770        // individual leaf generations rather than as one folded `u64`:
771        // the selected inner can change identity between polls, so the
772        // *shape* of the input vector is itself information — a switch
773        // from a 1-source inner to a 2-source one is a change even if
774        // the numbers happen to line up. `coalesced_source` turns the
775        // whole vector back into a monotone counter.
776        let composite = coalesced_source(
777            {
778                let f = f.clone();
779                let outer_compute = outer_compute.clone();
780                Rc::new(move || {
781                    let mut gens: Vec<u64> =
782                        outer_sources.iter().map(|s| (s.generation)()).collect();
783                    gens.extend(
784                        f(&outer_compute())
785                            .as_sources()
786                            .iter()
787                            .map(|s| (s.generation)()),
788                    );
789                    gens
790                })
791            },
792            // Poll-only: `f` re-selects the inner signal on every poll, so
793            // there is no stable set of roots to attach an observer to.
794            None,
795        );
796
797        Signal {
798            kind: SignalKind::Derived {
799                compute,
800                sources: vec![composite],
801            },
802        }
803    }
804
805    /// Borrow a compute closure that reads this signal's current value.
806    /// For mutable signals this clones the inner cell's value; for
807    /// derived signals it clones the parent compute `Rc`.
808    fn as_compute(&self) -> Rc<dyn Fn() -> T> {
809        match &self.kind {
810            SignalKind::Mutable { inner, .. } => {
811                let source = inner.clone();
812                Rc::new(move || source.borrow().value.clone())
813            }
814            SignalKind::Derived { compute, .. } => compute.clone(),
815        }
816    }
817
818    /// Bind this signal to a widget at the given dirty-tracking level.
819    ///
820    /// For a mutable or single-source derived signal this registers one
821    /// binding. For a multi-source derived signal (built via `zip` /
822    /// `zip3` / `and` / `or`) this registers one binding per observed
823    /// mutable root so dirty flips on *any* source correctly re-render
824    /// the widget.
825    ///
826    /// Idempotent per `(widget_id, source_id, bucket)` tuple: duplicate
827    /// calls collapse in the [`BindingRegistry`], promoting the level
828    /// if the incoming one has higher priority.
829    pub fn bind_to(&self, widget_id: WidgetId, registry: &BindingRegistry, level: BindingLevel) {
830        for src in self.as_sources() {
831            registry.register(Binding {
832                widget_id,
833                level,
834                generation: src.generation,
835                source_id: src.source_id,
836            });
837        }
838    }
839
840    /// Materialise this signal's upstream sources as a `Vec`. Mutable
841    /// signals yield one entry anchored on their inner `Rc`; derived
842    /// signals clone their existing sources vec.
843    fn as_sources(&self) -> Vec<DerivedSource> {
844        match &self.kind {
845            SignalKind::Mutable { inner, .. } => {
846                let gen_src = inner.clone();
847                let sub_src = inner.clone();
848                let source_id = Rc::as_ptr(inner) as *const () as usize;
849                vec![DerivedSource {
850                    // The closure owns an `Rc` clone of the inner, so the
851                    // address used as `source_id` cannot be recycled by a
852                    // different signal while this source is reachable.
853                    generation: Rc::new(move || gen_src.borrow().generation),
854                    source_id,
855                    // A mutable root can be pushed from, so it publishes the
856                    // hook. The callback discards the root's value: whoever
857                    // registered it is downstream of a `map`/`zip` and wants
858                    // its own recomputed value, not this one.
859                    subscribe: Some(Rc::new(move |notify: Rc<dyn Fn()>| {
860                        observe_inner(&sub_src, Rc::new(move |_: &T| notify()))
861                    })),
862                }]
863            }
864            SignalKind::Derived { sources, .. } => sources.clone(),
865        }
866    }
867}
868
869/// Extend `dst` with entries from `incoming`, deduping by `source_id`
870/// so the same mutable root is never registered twice in a combined
871/// derived signal (e.g. `a.zip(&a.map(...))`).
872fn merge_sources(dst: &mut Vec<DerivedSource>, incoming: Vec<DerivedSource>) {
873    for s in incoming {
874        if !dst.iter().any(|d| d.source_id == s.source_id) {
875            dst.push(s);
876        }
877    }
878}
879
880impl<T: 'static> Signal<T> {
881    pub fn is_mutable(&self) -> bool {
882        matches!(self.kind, SignalKind::Mutable { .. })
883    }
884
885    /// This signal's current change generation — a monotonically
886    /// non-decreasing counter advanced by every write. Two reads returning
887    /// the same value mean nothing changed in between; any difference
888    /// means something did.
889    ///
890    /// There is deliberately no way to *reset* it. Dirty tracking is
891    /// "compare against what I last acted on", and the remembered value
892    /// belongs to the consumer — see [`BindingRegistry`], which keeps
893    /// one per bound source. A resettable flag on the signal itself would
894    /// mean N consumers fighting over one slot, which is precisely the bug
895    /// this counter replaced (see `MutableInner::generation`).
896    ///
897    /// For a derived signal this folds every upstream into one number by
898    /// summing. That is exact rather than merely convenient: every
899    /// upstream generation is itself monotone, so a sum can only stay put
900    /// when all of them do.
901    pub fn generation(&self) -> u64 {
902        match &self.kind {
903            SignalKind::Mutable { inner, .. } => inner.borrow().generation,
904            SignalKind::Derived { sources, .. } => sources
905                .iter()
906                .map(|s| (s.generation)())
907                .fold(0u64, u64::wrapping_add),
908        }
909    }
910}
911
912// ---------------------------------------------------------------------------
913// Signal<bool> — boolean combinators for composite enabled_when predicates
914// ---------------------------------------------------------------------------
915
916impl Signal<bool> {
917    /// Logical AND of two boolean signals. The resulting derived signal
918    /// tracks both upstream sources and is marked dirty whenever either
919    /// changes (no short-circuit on the dirty side — semantic correctness
920    /// over micro-optimisation).
921    pub fn and(&self, other: &Signal<bool>) -> Signal<bool> {
922        self.zip(other).map(|(a, b)| *a && *b)
923    }
924
925    /// Logical OR of two boolean signals. Same dirty-tracking semantics
926    /// as [`Signal::and`].
927    pub fn or(&self, other: &Signal<bool>) -> Signal<bool> {
928        self.zip(other).map(|(a, b)| *a || *b)
929    }
930
931    /// Logical NOT of a boolean signal.
932    pub fn not(&self) -> Signal<bool> {
933        self.map(|b| !*b)
934    }
935}
936
937// ---------------------------------------------------------------------------
938// Signal<f32> — animation support
939// ---------------------------------------------------------------------------
940
941impl Signal<f32> {
942    /// Create a new `Signal<f32>` with animation support.
943    pub fn new_animated(value: f32) -> Self {
944        Self {
945            kind: SignalKind::Mutable {
946                inner: Rc::new(RefCell::new(MutableInner {
947                    value,
948                    generation: 0,
949                    observers: Vec::new(),
950                    next_observer_id: 1,
951                    keepalive: Vec::new(),
952                })),
953                animation: Some(Rc::new(RefCell::new(AnimationState {
954                    pending: None,
955                    target: None,
956                }))),
957            },
958        }
959    }
960
961    pub fn supports_animation(&self) -> bool {
962        matches!(
963            &self.kind,
964            SignalKind::Mutable {
965                animation: Some(_),
966                ..
967            }
968        )
969    }
970
971    pub(crate) fn weak_handle(&self) -> Option<WeakAnimatedSignal> {
972        match &self.kind {
973            SignalKind::Mutable {
974                inner,
975                animation: Some(animation),
976            } => Some(WeakAnimatedSignal {
977                inner: Rc::downgrade(inner),
978                animation: Rc::downgrade(animation),
979            }),
980            _ => None,
981        }
982    }
983
984    /// Animate to a target value over a duration with an easing curve.
985    pub fn animate_to(
986        &self,
987        target: f32,
988        duration: std::time::Duration,
989        easing: teksilo_tokens::Easing,
990    ) {
991        self.animate_to_with_frame_interval(target, duration, easing, None);
992    }
993
994    pub fn animate_to_with_frame_interval(
995        &self,
996        target: f32,
997        duration: std::time::Duration,
998        easing: teksilo_tokens::Easing,
999        frame_interval: Option<std::time::Duration>,
1000    ) {
1001        self.try_animate_to_with_frame_interval(target, duration, easing, frame_interval)
1002            .unwrap_or_else(|err| match err {
1003                SignalAccessError::ReadOnly => {
1004                    panic!("animate_to is not supported on derived signals")
1005                }
1006                SignalAccessError::AnimationUnsupported => {
1007                    panic!(
1008                        "animate_to called on Signal<f32> without animation support; use Signal::new_animated()"
1009                    )
1010                }
1011            });
1012    }
1013
1014    pub fn try_animate_to(
1015        &self,
1016        target: f32,
1017        duration: std::time::Duration,
1018        easing: teksilo_tokens::Easing,
1019    ) -> Result<(), SignalAccessError> {
1020        self.try_animate_to_with_frame_interval(target, duration, easing, None)
1021    }
1022
1023    pub fn try_animate_to_with_frame_interval(
1024        &self,
1025        target: f32,
1026        duration: std::time::Duration,
1027        easing: teksilo_tokens::Easing,
1028        frame_interval: Option<std::time::Duration>,
1029    ) -> Result<(), SignalAccessError> {
1030        self.try_animate_with_options(crate::animation::AnimationRequest {
1031            target,
1032            duration,
1033            easing,
1034            frame_interval,
1035            looping: false,
1036            epsilon: 0.0,
1037            max_duration: None,
1038        })
1039    }
1040
1041    /// Start a looping animation from the current value to `target`,
1042    /// repeating with the given period. Runs until cancelled.
1043    /// Frame updates are capped at `frame_interval` (default 60 Hz).
1044    pub fn animate_looping(
1045        &self,
1046        target: f32,
1047        period: std::time::Duration,
1048        easing: teksilo_tokens::Easing,
1049        frame_interval: Option<std::time::Duration>,
1050    ) {
1051        let _ = self.try_animate_with_options(crate::animation::AnimationRequest {
1052            target,
1053            duration: period,
1054            easing,
1055            frame_interval,
1056            looping: true,
1057            epsilon: 0.0,
1058            max_duration: None,
1059        });
1060    }
1061
1062    /// Generic entry point that accepts a fully-built `AnimationRequest`.
1063    /// Use this to set `epsilon` (pixel-stable quantization) or
1064    /// `max_duration` (opt-in wall-clock cap) without having to thread
1065    /// through every convenience wrapper.
1066    pub fn try_animate_with_options(
1067        &self,
1068        request: crate::animation::AnimationRequest,
1069    ) -> Result<(), SignalAccessError> {
1070        match &self.kind {
1071            SignalKind::Mutable {
1072                inner,
1073                animation: Some(animation),
1074            } => {
1075                let mut anim = animation.borrow_mut();
1076                anim.target = Some(request.target);
1077                anim.pending = Some(request);
1078                drop(anim);
1079                // Arming an animation is a change like any other write:
1080                // bound widgets must reconcile so the tree picks the
1081                // pending request up in `process_pending_animations`.
1082                let mut guard = inner.borrow_mut();
1083                guard.generation = guard.generation.wrapping_add(1);
1084                Ok(())
1085            }
1086            SignalKind::Mutable {
1087                animation: None, ..
1088            } => Err(SignalAccessError::AnimationUnsupported),
1089            SignalKind::Derived { .. } => Err(SignalAccessError::ReadOnly),
1090        }
1091    }
1092
1093    /// Returns the target value of the current or pending animation, if any.
1094    pub fn animation_target(&self) -> Option<f32> {
1095        match &self.kind {
1096            SignalKind::Mutable { animation, .. } => {
1097                animation.as_ref().and_then(|a| a.borrow().target)
1098            }
1099            _ => None,
1100        }
1101    }
1102
1103    /// Clear the animation target. Called by the animation scheduler when
1104    /// an animation completes.
1105    pub fn clear_animation_target(&self) {
1106        if let SignalKind::Mutable { animation, .. } = &self.kind
1107            && let Some(a) = animation
1108        {
1109            a.borrow_mut().target = None;
1110        }
1111    }
1112
1113    /// Take a pending animation request, if any.
1114    pub fn take_pending_animation(&self) -> Option<crate::animation::AnimationRequest> {
1115        match &self.kind {
1116            SignalKind::Mutable { animation, .. } => animation
1117                .as_ref()
1118                .and_then(|a| a.borrow_mut().pending.take()),
1119            _ => None,
1120        }
1121    }
1122
1123    /// Whether there is a pending animation request.
1124    pub fn has_pending_animation(&self) -> bool {
1125        match &self.kind {
1126            SignalKind::Mutable { animation, .. } => animation
1127                .as_ref()
1128                .is_some_and(|a| a.borrow().pending.is_some()),
1129            _ => false,
1130        }
1131    }
1132}
1133
1134// ---------------------------------------------------------------------------
1135// Clone, Debug
1136// ---------------------------------------------------------------------------
1137
1138impl<T> Clone for Signal<T> {
1139    fn clone(&self) -> Self {
1140        Self {
1141            kind: match &self.kind {
1142                SignalKind::Mutable { inner, animation } => SignalKind::Mutable {
1143                    inner: inner.clone(),
1144                    animation: animation.clone(),
1145                },
1146                SignalKind::Derived { compute, sources } => SignalKind::Derived {
1147                    compute: compute.clone(),
1148                    sources: sources.clone(),
1149                },
1150            },
1151        }
1152    }
1153}
1154
1155impl<T: std::fmt::Debug + 'static> std::fmt::Debug for Signal<T> {
1156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1157        match &self.kind {
1158            SignalKind::Mutable { inner, .. } => f
1159                .debug_struct("Signal::Mutable")
1160                .field("value", &inner.borrow().value)
1161                .field("generation", &inner.borrow().generation)
1162                .finish(),
1163            SignalKind::Derived { .. } => f.write_str("Signal::Derived(..)"),
1164        }
1165    }
1166}
1167
1168// ---------------------------------------------------------------------------
1169// Prop<T> — widget property type
1170// ---------------------------------------------------------------------------
1171
1172/// A property value that is either static or bound to a reactive signal.
1173/// Widget property methods accept `impl Into<Prop<T>>` for flexibility.
1174pub enum Prop<T: Clone + 'static> {
1175    /// A fixed value, set once.
1176    Static(T),
1177    /// Bound to a signal; value read lazily on each use.
1178    Bound(Signal<T>),
1179}
1180
1181impl<T: Clone + 'static> Prop<T> {
1182    /// Resolve the current value.
1183    pub fn get(&self) -> T {
1184        match self {
1185            Prop::Static(v) => v.clone(),
1186            Prop::Bound(signal) => signal.get(),
1187        }
1188    }
1189
1190    /// Register dirty tracking for this prop if it is bound.
1191    pub fn register_if_bound(
1192        &self,
1193        widget_id: WidgetId,
1194        registry: &BindingRegistry,
1195        level: BindingLevel,
1196    ) {
1197        if let Prop::Bound(signal) = self {
1198            signal.bind_to(widget_id, registry, level);
1199        }
1200    }
1201
1202    /// Return the underlying signal if bound, or wrap a static value in a
1203    /// fresh, unshared signal. Use when an existing code path needs a
1204    /// `Signal<T>` (e.g. `ctx.effect(&signal, ...)`) but the widget field
1205    /// was widened from `Signal<T>` to `Prop<T>` — the derived signal
1206    /// preserves reactivity for the `Bound` case with minimal churn.
1207    pub fn as_signal(&self) -> Signal<T> {
1208        match self {
1209            Prop::Static(v) => Signal::new(v.clone()),
1210            Prop::Bound(signal) => signal.clone(),
1211        }
1212    }
1213}
1214
1215impl<T: Clone + 'static> Clone for Prop<T> {
1216    fn clone(&self) -> Self {
1217        match self {
1218            Prop::Static(v) => Prop::Static(v.clone()),
1219            Prop::Bound(s) => Prop::Bound(s.clone()),
1220        }
1221    }
1222}
1223
1224impl<T: Clone + std::fmt::Debug + 'static> std::fmt::Debug for Prop<T> {
1225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1226        match self {
1227            Prop::Static(v) => write!(f, "Prop::Static({:?})", v),
1228            Prop::Bound(_) => f.write_str("Prop::Bound(..)"),
1229        }
1230    }
1231}
1232
1233impl<T: Clone + 'static> From<T> for Prop<T> {
1234    fn from(value: T) -> Self {
1235        Prop::Static(value)
1236    }
1237}
1238
1239impl<T: Clone + 'static> From<Signal<T>> for Prop<T> {
1240    fn from(signal: Signal<T>) -> Self {
1241        Prop::Bound(signal)
1242    }
1243}
1244
1245// String ergonomics: let `impl Into<Prop<String>>` setters accept a borrowed
1246// string literal / `&String` the same way the old `impl Into<String>` setters
1247// did (the blanket `From<T>` only covers an owned `String`). Keeps call sites
1248// like `.name("x")` / `.suffix("x")` compiling after the widening.
1249impl From<&str> for Prop<String> {
1250    fn from(s: &str) -> Self {
1251        Prop::Static(s.to_owned())
1252    }
1253}
1254
1255impl From<&String> for Prop<String> {
1256    fn from(s: &String) -> Self {
1257        Prop::Static(s.clone())
1258    }
1259}
1260
1261// ---------------------------------------------------------------------------
1262// Tests
1263// ---------------------------------------------------------------------------
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268
1269    #[test]
1270    fn signal_get_set() {
1271        let s = Signal::new(42);
1272        assert_eq!(s.get(), 42);
1273        s.set(99);
1274        assert_eq!(s.get(), 99);
1275    }
1276
1277    #[test]
1278    fn generation_advances_on_every_write_and_never_resets() {
1279        let s = Signal::new(0);
1280        let start = s.generation();
1281        assert_eq!(s.generation(), start, "reading is not a change");
1282
1283        s.set(1);
1284        let after_one = s.generation();
1285        assert_ne!(after_one, start, "a write advances the generation");
1286
1287        s.set(1);
1288        let after_republish = s.generation();
1289        assert_ne!(
1290            after_republish, after_one,
1291            "`set` is unconditional — a republish of the same value is still \
1292             a write, and callers who want the equality guard use \
1293             `set_if_changed`"
1294        );
1295
1296        assert!(!s.set_if_changed(1), "value is unchanged");
1297        assert_eq!(
1298            s.generation(),
1299            after_republish,
1300            "`set_if_changed` with an identical value writes nothing at all"
1301        );
1302    }
1303
1304    /// The property every consumer relies on: staleness is "the
1305    /// generation moved since I last looked", and looking is free of
1306    /// consequence — so any number of independent consumers can each
1307    /// track the same signal without interfering.
1308    #[test]
1309    fn observing_the_generation_does_not_consume_it() {
1310        let s = Signal::new(0);
1311        let (mut seen_a, mut seen_b) = (s.generation(), s.generation());
1312
1313        s.set(1);
1314        assert_ne!(s.generation(), seen_a);
1315        seen_a = s.generation();
1316        assert_ne!(
1317            s.generation(),
1318            seen_b,
1319            "consumer A catching up must leave consumer B behind, not clean"
1320        );
1321        seen_b = s.generation();
1322
1323        assert_eq!(s.generation(), seen_a);
1324        assert_eq!(s.generation(), seen_b);
1325    }
1326
1327    #[test]
1328    fn signal_clone_shares() {
1329        let a = Signal::new(10);
1330        let b = a.clone();
1331        a.set(20);
1332        assert_eq!(b.get(), 20);
1333        assert!(Signal::same(&a, &b));
1334    }
1335
1336    #[test]
1337    fn signal_map_derived() {
1338        let text = Signal::new(String::from("hello"));
1339        let len = text.map(|t| t.len());
1340        assert_eq!(len.get(), 5);
1341        text.set(String::from("hi"));
1342        assert_eq!(len.get(), 2);
1343    }
1344
1345    #[test]
1346    fn signal_map_chained() {
1347        let s = Signal::new(5);
1348        let doubled = s.map(|v| v * 2);
1349        let as_string = doubled.map(|v| format!("{}", v));
1350        assert_eq!(as_string.get(), "10");
1351        s.set(7);
1352        assert_eq!(as_string.get(), "14");
1353    }
1354
1355    #[test]
1356    fn signal_derived_generation_tracks_source() {
1357        let s = Signal::new(0);
1358        let derived = s.map(|v| v + 1);
1359        let seen = derived.generation();
1360        s.set(5);
1361        assert_ne!(
1362            derived.generation(),
1363            seen,
1364            "the source's write shows through"
1365        );
1366        let seen = derived.generation();
1367        assert_eq!(
1368            derived.generation(),
1369            seen,
1370            "and settles with no further write"
1371        );
1372    }
1373
1374    #[test]
1375    fn flat_map_follows_selected_inner_value() {
1376        let a = Signal::new(10);
1377        let b = Signal::new(20);
1378        let which = Signal::new(0usize);
1379        let (a2, b2) = (a.clone(), b.clone());
1380        let out = which.flat_map(move |i| if *i == 0 { a2.clone() } else { b2.clone() });
1381
1382        assert_eq!(out.get(), 10); // follows a
1383        a.set(11);
1384        assert_eq!(out.get(), 11); // tracks a's value
1385        which.set(1);
1386        assert_eq!(out.get(), 20); // switched to b
1387        b.set(21);
1388        assert_eq!(out.get(), 21);
1389        a.set(999); // a is no longer selected — ignored
1390        assert_eq!(out.get(), 21);
1391    }
1392
1393    #[test]
1394    fn flat_map_generation_tracks_outer_and_current_inner() {
1395        let a = Signal::new(0);
1396        let b = Signal::new(0);
1397        let which = Signal::new(0usize);
1398        let (a2, b2) = (a.clone(), b.clone());
1399        let out = which.flat_map(move |i| if *i == 0 { a2.clone() } else { b2.clone() });
1400        let mut seen = out.generation();
1401
1402        // The current inner (a) flipping advances the result.
1403        a.set(5);
1404        assert_ne!(out.generation(), seen);
1405        seen = out.generation();
1406        assert_eq!(out.generation(), seen);
1407
1408        // The non-selected inner (b) flipping does NOT.
1409        b.set(7);
1410        assert_eq!(out.generation(), seen, "b is not selected");
1411
1412        // The outer selector flipping does.
1413        which.set(1);
1414        assert_ne!(out.generation(), seen);
1415        seen = out.generation();
1416
1417        // Now b is selected, so b flipping is tracked and a is ignored.
1418        b.set(8);
1419        assert_ne!(out.generation(), seen);
1420        seen = out.generation();
1421        a.set(9);
1422        assert_eq!(out.generation(), seen, "a is no longer selected");
1423    }
1424
1425    /// `flat_map` is why the composite source memoises a counter rather
1426    /// than summing its inputs. Switching the selected inner makes that
1427    /// term jump arbitrarily — here it jumps *down*, from a heavily
1428    /// written signal to a fresh one, by exactly as much as the outer
1429    /// selector's own write advanced. A sum would land on the same total
1430    /// and report "nothing changed" for a switch that changed everything.
1431    #[test]
1432    fn flat_map_survives_an_inner_switch_that_would_cancel_out_in_a_sum() {
1433        let hot = Signal::new(0_i32);
1434        let cold = Signal::new(0_i32);
1435        // `which` reaches generation 1 on the switch below, so drive
1436        // `hot` exactly one generation ahead of `cold`: switching from
1437        // hot to cold then costs -1 while `which` contributes +1.
1438        hot.set(1);
1439        let (hot2, cold2) = (hot.clone(), cold.clone());
1440        let which = Signal::new(0usize);
1441        let out = which.flat_map(move |i| if *i == 0 { hot2.clone() } else { cold2.clone() });
1442
1443        assert_eq!(out.get(), 1, "starts on `hot`");
1444        let seen = out.generation();
1445
1446        which.set(1);
1447
1448        assert_eq!(out.get(), 0, "the value really did change");
1449        assert_ne!(
1450            out.generation(),
1451            seen,
1452            "and the generation says so — a plain sum of (outer + inner) \
1453             would have been unchanged here"
1454        );
1455    }
1456
1457    /// The cross-window property at the level of a composite source: a
1458    /// `flat_map`'s memo advances once and is then read, unchanged, by
1459    /// every consumer polling afterwards. If the memo were consumed by
1460    /// the first reader (the way the old `clear_dirty` consumed a shared
1461    /// flag) the second window would never rebuild.
1462    #[test]
1463    fn a_composite_sources_generation_is_readable_by_every_consumer() {
1464        let inner = Signal::new(0_i32);
1465        let inner2 = inner.clone();
1466        let which = Signal::new(0usize);
1467        let out = which.flat_map(move |_| inner2.clone());
1468
1469        let (window_a, window_b) = (out.generation(), out.generation());
1470        inner.set(1);
1471
1472        let a_now = out.generation();
1473        assert_ne!(a_now, window_a, "window A notices");
1474        assert_ne!(
1475            out.generation(),
1476            window_b,
1477            "and window B still notices, after A already looked"
1478        );
1479        assert_eq!(out.generation(), a_now, "both see the SAME new generation");
1480    }
1481
1482    #[test]
1483    fn flat_map_binding_rerenders_on_inner_and_outer_change() {
1484        use crate::binding::{BindingLevel, BindingRegistry};
1485        use slotmap::KeyData;
1486        let fake_id: WidgetId = KeyData::from_ffi(1).into();
1487        let inner = Signal::new(false);
1488        let which = Signal::new(0usize);
1489        let inner2 = inner.clone();
1490        let gate = which.flat_map(move |_| inner2.clone());
1491
1492        let registry = BindingRegistry::new();
1493        gate.bind_to(fake_id, &registry, BindingLevel::Relayout);
1494        assert!(registry.flush_dirty().is_empty());
1495
1496        // A change to the currently-selected inner must dirty the bound widget.
1497        inner.set(true);
1498        let dirty = registry.flush_dirty();
1499        assert_eq!(dirty.len(), 1, "selected-inner change must re-render");
1500        assert_eq!(dirty[0].0, fake_id);
1501
1502        // A change to the outer selector must also dirty it.
1503        which.set(0);
1504        let dirty = registry.flush_dirty();
1505        assert_eq!(dirty.len(), 1, "outer-selector change must re-render");
1506    }
1507
1508    #[test]
1509    fn observer_called_on_set() {
1510        use std::cell::Cell;
1511        let s = Signal::new(0);
1512        let called = Rc::new(Cell::new(false));
1513        let c = called.clone();
1514        let _handle = s.observe(move |val| {
1515            assert_eq!(*val, 42);
1516            c.set(true);
1517        });
1518        s.set(42);
1519        assert!(called.get());
1520    }
1521
1522    /// The whole reason `set_if_changed` exists: `set` fans out to every
1523    /// observer even when the value is identical, which on a per-frame
1524    /// republish path is pure waste.
1525    #[test]
1526    fn set_if_changed_does_not_notify_when_the_value_is_identical() {
1527        use std::cell::Cell;
1528        let s = Signal::new(7);
1529        let calls = Rc::new(Cell::new(0));
1530        let c = calls.clone();
1531        let _handle = s.observe(move |_| c.set(c.get() + 1));
1532
1533        assert!(
1534            !s.set_if_changed(7),
1535            "an identical write must report no change"
1536        );
1537        assert_eq!(calls.get(), 0, "an identical write must not walk observers");
1538
1539        assert!(
1540            s.set_if_changed(8),
1541            "a differing write must report a change"
1542        );
1543        assert_eq!(calls.get(), 1, "a differing write must notify");
1544        assert_eq!(s.get(), 8);
1545    }
1546
1547    /// Guarding a write is what breaks an A→B→A observer cycle, which the
1548    /// `try_set` docs prescribe and which callers previously hand-rolled.
1549    #[test]
1550    fn set_if_changed_settles_a_two_signal_feedback_loop() {
1551        let a = Signal::new(0);
1552        let b = Signal::new(0);
1553        let _ha = {
1554            let b = b.clone();
1555            a.observe(move |v| {
1556                b.set_if_changed(*v);
1557            })
1558        };
1559        let _hb = {
1560            let a = a.clone();
1561            b.observe(move |v| {
1562                a.set_if_changed(*v);
1563            })
1564        };
1565        // Without the equality guard this recurses until the depth guard trips.
1566        a.set(5);
1567        assert_eq!(b.get(), 5);
1568        assert_eq!(a.get(), 5);
1569    }
1570
1571    #[test]
1572    fn observer_removed_on_handle_drop() {
1573        use std::cell::Cell;
1574        let s = Signal::new(0);
1575        let count = Rc::new(Cell::new(0));
1576        let c = count.clone();
1577        let handle = s.observe(move |_| {
1578            c.set(c.get() + 1);
1579        });
1580        s.set(1);
1581        assert_eq!(count.get(), 1);
1582        drop(handle);
1583        s.set(2);
1584        assert_eq!(count.get(), 1); // Not called again
1585    }
1586
1587    #[test]
1588    fn multiple_observers() {
1589        use std::cell::Cell;
1590        let s = Signal::new(0);
1591        let count = Rc::new(Cell::new(0));
1592        let c1 = count.clone();
1593        let c2 = count.clone();
1594        let _h1 = s.observe(move |_| c1.set(c1.get() + 1));
1595        let _h2 = s.observe(move |_| c2.set(c2.get() + 1));
1596        s.set(10);
1597        assert_eq!(count.get(), 2);
1598    }
1599
1600    #[test]
1601    fn binding_registry_integration() {
1602        use slotmap::KeyData;
1603        let fake_id: WidgetId = KeyData::from_ffi(1).into();
1604        let registry = BindingRegistry::new();
1605        let s = Signal::new(0);
1606        s.bind_to(fake_id, &registry, BindingLevel::RepaintOnly);
1607
1608        assert!(registry.flush_dirty().is_empty());
1609        s.set(42);
1610        let dirty = registry.flush_dirty();
1611        assert_eq!(dirty.len(), 1);
1612        assert_eq!(dirty[0].0, fake_id);
1613        assert_eq!(dirty[0].1, BindingLevel::RepaintOnly);
1614        assert!(registry.flush_dirty().is_empty());
1615    }
1616
1617    #[test]
1618    fn derived_binding_registry() {
1619        use slotmap::KeyData;
1620        let fake_id: WidgetId = KeyData::from_ffi(1).into();
1621        let registry = BindingRegistry::new();
1622        let s = Signal::new(0);
1623        let doubled = s.map(|v| v * 2);
1624        doubled.bind_to(fake_id, &registry, BindingLevel::Relayout);
1625
1626        assert!(registry.flush_dirty().is_empty());
1627        s.set(5);
1628        let dirty = registry.flush_dirty();
1629        assert_eq!(dirty.len(), 1);
1630        assert_eq!(dirty[0].1, BindingLevel::Relayout);
1631    }
1632
1633    #[test]
1634    fn get_ref_works() {
1635        let s = Signal::new(String::from("hello"));
1636        {
1637            let r = s.get_ref();
1638            assert_eq!(&*r, "hello");
1639        }
1640    }
1641
1642    #[test]
1643    #[should_panic(expected = "cannot set() on a derived Signal")]
1644    fn set_on_derived_panics() {
1645        let s = Signal::new(0);
1646        let d = s.map(|v| v + 1);
1647        d.set(99);
1648    }
1649
1650    #[test]
1651    fn prop_static() {
1652        let p: Prop<i32> = 42.into();
1653        assert_eq!(p.get(), 42);
1654    }
1655
1656    #[test]
1657    fn prop_bound() {
1658        let s = Signal::new(10);
1659        let p: Prop<i32> = s.clone().into();
1660        assert_eq!(p.get(), 10);
1661        s.set(20);
1662        assert_eq!(p.get(), 20);
1663    }
1664
1665    #[test]
1666    fn prop_register_if_bound() {
1667        use slotmap::KeyData;
1668        let fake_id: WidgetId = KeyData::from_ffi(1).into();
1669        let registry = BindingRegistry::new();
1670
1671        let s = Signal::new(0);
1672        let p: Prop<i32> = s.clone().into();
1673        p.register_if_bound(fake_id, &registry, BindingLevel::RepaintOnly);
1674
1675        s.set(1);
1676        let dirty = registry.flush_dirty();
1677        assert_eq!(dirty.len(), 1);
1678
1679        // Static prop does not register
1680        let p2: Prop<i32> = 42.into();
1681        p2.register_if_bound(fake_id, &registry, BindingLevel::RepaintOnly);
1682        assert!(registry.flush_dirty().is_empty());
1683    }
1684
1685    // --- Multi-source derived signals (zip / zip3 / and / or / not) -------
1686
1687    #[test]
1688    fn zip_reads_both_sources() {
1689        let a = Signal::new(1_i32);
1690        let b = Signal::new("x".to_string());
1691        let z = a.zip(&b);
1692        assert_eq!(z.get(), (1, "x".to_string()));
1693        a.set(7);
1694        b.set("y".to_string());
1695        assert_eq!(z.get(), (7, "y".to_string()));
1696    }
1697
1698    #[test]
1699    fn zip_generation_advances_when_either_source_is_written() {
1700        let a = Signal::new(0_i32);
1701        let b = Signal::new(0_i32);
1702        let z = a.zip(&b);
1703        let mut seen = z.generation();
1704
1705        a.set(1);
1706        assert_ne!(z.generation(), seen, "a write to the first source shows");
1707        seen = z.generation();
1708
1709        b.set(2);
1710        assert_ne!(z.generation(), seen, "a write to the second source shows");
1711        seen = z.generation();
1712
1713        assert_eq!(z.generation(), seen, "and settles with no further write");
1714    }
1715
1716    /// A multi-source derived signal folds its upstreams into one number
1717    /// by summing, and that is only sound because every upstream
1718    /// generation is monotone: writes to *different* sources can never
1719    /// cancel each other out.
1720    #[test]
1721    fn zip_generation_reflects_writes_to_both_sources_independently() {
1722        let a = Signal::new(0_i32);
1723        let b = Signal::new(0_i32);
1724        let z = a.zip(&b);
1725
1726        let start = z.generation();
1727        a.set(1);
1728        let after_a = z.generation();
1729        b.set(1);
1730        let after_b = z.generation();
1731
1732        assert!(
1733            after_a > start && after_b > after_a,
1734            "monotone in both sources: {start} < {after_a} < {after_b}"
1735        );
1736    }
1737
1738    #[test]
1739    fn zip3_reads_three_sources() {
1740        let a = Signal::new(1_i32);
1741        let b = Signal::new(2_i32);
1742        let c = Signal::new(3_i32);
1743        let z = a.zip3(&b, &c);
1744        assert_eq!(z.get(), (1, 2, 3));
1745        c.set(30);
1746        assert_eq!(z.get(), (1, 2, 30));
1747    }
1748
1749    #[test]
1750    fn zip3_generation_advances_for_any_source() {
1751        let a = Signal::new(0_i32);
1752        let b = Signal::new(0_i32);
1753        let c = Signal::new(0_i32);
1754        let z = a.zip3(&b, &c);
1755        let mut seen = z.generation();
1756
1757        for write in [&c, &b, &a] {
1758            write.set(1);
1759            assert_ne!(z.generation(), seen);
1760            seen = z.generation();
1761        }
1762    }
1763
1764    #[test]
1765    fn and_reads_logical_and() {
1766        let a = Signal::new(true);
1767        let b = Signal::new(false);
1768        let anded = a.and(&b);
1769        assert!(!anded.get());
1770        b.set(true);
1771        assert!(anded.get());
1772        a.set(false);
1773        assert!(!anded.get());
1774    }
1775
1776    #[test]
1777    fn or_reads_logical_or() {
1778        let a = Signal::new(false);
1779        let b = Signal::new(false);
1780        let ored = a.or(&b);
1781        assert!(!ored.get());
1782        a.set(true);
1783        assert!(ored.get());
1784        a.set(false);
1785        b.set(true);
1786        assert!(ored.get());
1787    }
1788
1789    #[test]
1790    fn not_reads_logical_negation() {
1791        let a = Signal::new(true);
1792        let n = a.not();
1793        assert!(!n.get());
1794        a.set(false);
1795        assert!(n.get());
1796    }
1797
1798    #[test]
1799    fn combined_predicate_fires_binding_on_any_source() {
1800        use crate::binding::BindingRegistry;
1801        use slotmap::KeyData;
1802
1803        let reg = BindingRegistry::new();
1804        let id: WidgetId = KeyData::from_ffi(1).into();
1805
1806        let focus = Signal::new(false);
1807        let readonly = Signal::new(true);
1808        let in_editor = Signal::new(true);
1809
1810        // Composite: focus && !readonly && in_editor — built with
1811        // combinators, bound to a widget at Relayout level.
1812        let when = focus.and(&readonly.not()).and(&in_editor);
1813        when.bind_to(id, &reg, BindingLevel::Relayout);
1814        assert!(!when.get(), "all sources start producing false");
1815
1816        // Flip any source — the registry must see a dirty binding.
1817        focus.set(true);
1818        let dirty = reg.flush_dirty();
1819        assert_eq!(dirty.len(), 1, "focus change must fire the binding");
1820        assert_eq!(dirty[0].0, id);
1821
1822        // Flip a different source — same widget, same outcome.
1823        readonly.set(false);
1824        let dirty = reg.flush_dirty();
1825        assert_eq!(dirty.len(), 1, "readonly change must fire the binding");
1826        // And the predicate now reads true.
1827        assert!(when.get());
1828
1829        // Third source.
1830        in_editor.set(false);
1831        let dirty = reg.flush_dirty();
1832        assert_eq!(dirty.len(), 1, "in_editor change must fire the binding");
1833        assert!(!when.get());
1834    }
1835
1836    #[test]
1837    fn zip_dedups_identical_source() {
1838        // `a.zip(&a.map(|v| v + 1))` shares one upstream mutable root.
1839        // The derived should register exactly one binding per widget,
1840        // not two.
1841        use crate::binding::BindingRegistry;
1842        use slotmap::KeyData;
1843
1844        let reg = BindingRegistry::new();
1845        let id: WidgetId = KeyData::from_ffi(1).into();
1846        let a = Signal::new(0_i32);
1847        let derived = a.map(|v| v + 1);
1848        let z = a.zip(&derived);
1849        z.bind_to(id, &reg, BindingLevel::RepaintOnly);
1850        assert_eq!(
1851            reg.len(),
1852            1,
1853            "duplicate upstream root must register once, not twice"
1854        );
1855    }
1856
1857    #[test]
1858    fn signal_animated_f32() {
1859        let s = Signal::<f32>::new_animated(0.0);
1860        assert!(!s.has_pending_animation());
1861        s.animate_to(
1862            100.0,
1863            std::time::Duration::from_millis(200),
1864            teksilo_tokens::Easing::Linear,
1865        );
1866        assert!(s.has_pending_animation());
1867        assert_eq!(s.animation_target(), Some(100.0));
1868        let req = s.take_pending_animation().unwrap();
1869        assert_eq!(req.target, 100.0);
1870        assert!(!s.has_pending_animation());
1871    }
1872
1873    #[test]
1874    fn map_coalesced_collapses_multi_source_to_one_binding() {
1875        // A 4-source zip projects through map_coalesced; the
1876        // resulting derived signal exposes a single combined
1877        // DerivedSource. Verifies the source-count collapse.
1878        let a = Signal::new(1u32);
1879        let b = Signal::new(2u32);
1880        let c = Signal::new(3u32);
1881        let d = Signal::new(4u32);
1882        let composite = a
1883            .zip3(&b, &c)
1884            .zip(&d)
1885            .map_coalesced(|((x, y, z), w)| *x + *y + *z + *w);
1886        // Plain `map` would produce 4 sources; `map_coalesced` 1.
1887        assert_eq!(composite.as_sources().len(), 1);
1888        assert_eq!(composite.get(), 10);
1889        // Writing ANY underlying source advances the composite's single
1890        // generation, and it stays put in between.
1891        let mut seen = composite.generation();
1892        a.set(10);
1893        assert_ne!(composite.generation(), seen);
1894        seen = composite.generation();
1895        assert_eq!(composite.generation(), seen);
1896        c.set(30);
1897        assert_ne!(composite.generation(), seen);
1898    }
1899
1900    /// The coalesced composite is one memo shared by every consumer of
1901    /// the derived signal — including two different windows' binding
1902    /// registries. Reading it must not consume it.
1903    #[test]
1904    fn map_coalesced_generation_is_readable_by_every_consumer() {
1905        let a = Signal::new(1u32);
1906        let b = Signal::new(2u32);
1907        let composite = a.zip(&b).map_coalesced(|(x, y)| *x + *y);
1908
1909        let (window_a, window_b) = (composite.generation(), composite.generation());
1910        b.set(5);
1911
1912        let a_now = composite.generation();
1913        assert_ne!(a_now, window_a);
1914        assert_ne!(
1915            composite.generation(),
1916            window_b,
1917            "the first read must not have cleared anything"
1918        );
1919        assert_eq!(composite.generation(), a_now);
1920    }
1921
1922    #[test]
1923    fn map_coalesced_with_single_source_delegates_to_map() {
1924        // No coalescing benefit when there's only one source —
1925        // `map_coalesced` is equivalent to `map`.
1926        let a = Signal::new(7u32);
1927        let derived = a.map_coalesced(|v| *v * 2);
1928        assert_eq!(derived.get(), 14);
1929        assert_eq!(derived.as_sources().len(), 1);
1930    }
1931
1932    #[test]
1933    fn reentrant_set_in_observer_does_not_panic() {
1934        // An observer that writes the same signal must not trip the inner
1935        // RefCell: no borrow is held while callbacks run. (Before the fix,
1936        // try_set held a shared borrow across the callback loop, so the
1937        // nested borrow_mut panicked with BorrowMutError.)
1938        let s = Signal::new(0_i32);
1939        let s2 = s.clone();
1940        let _handle = s.observe(move |v| {
1941            // Recurse exactly once — only re-enter while the value is 1.
1942            if *v == 1 {
1943                s2.set(2);
1944            }
1945        });
1946        s.set(1);
1947        assert_eq!(s.get(), 2);
1948    }
1949
1950    #[cfg(debug_assertions)]
1951    #[test]
1952    #[should_panic(expected = "feedback loop")]
1953    fn unbounded_feedback_loop_panics_with_diagnostic() {
1954        // A's observer bumps B and B's observer bumps A, unconditionally, so
1955        // the cascade never settles. The debug depth guard must convert the
1956        // would-be stack overflow into an actionable panic.
1957        let a = Signal::new(0_i32);
1958        let b = Signal::new(0_i32);
1959        let b_for_a = b.clone();
1960        let _ha = a.observe(move |v| b_for_a.set(*v + 1));
1961        let a_for_b = a.clone();
1962        let _hb = b.observe(move |v| a_for_b.set(*v + 1));
1963        a.set(1);
1964    }
1965
1966    #[test]
1967    fn detaching_observer_during_notification_does_not_panic() {
1968        use std::cell::RefCell;
1969        // Observer A drops observer B's handle when fired; B's remover takes
1970        // borrow_mut on the same inner. Must not panic now that no borrow is
1971        // held during the callback loop.
1972        let s = Signal::new(0_i32);
1973        let b_slot: Rc<RefCell<Option<ObserverHandle>>> = Rc::new(RefCell::new(None));
1974        let b_slot_for_a = b_slot.clone();
1975        let _a = s.observe(move |_| {
1976            b_slot_for_a.borrow_mut().take();
1977        });
1978        let b = s.observe(|_| {});
1979        *b_slot.borrow_mut() = Some(b);
1980        s.set(1);
1981    }
1982
1983    /// The bug this exists to stop: `MenuEntry::enabled(..)` takes any
1984    /// `Prop<bool>`, so a caller reasonably passes `unsaved.and(&mode.not())`
1985    /// — and the macOS native-menu bridge, which pushes rather than polls,
1986    /// used to abort the process on it.
1987    #[test]
1988    fn observing_a_derived_signal_reports_every_root() {
1989        use std::cell::RefCell;
1990
1991        let unsaved = Signal::new(false);
1992        let backup_mode = Signal::new(false);
1993        let can_save = unsaved.and(&backup_mode.not());
1994
1995        let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
1996        let sink = seen.clone();
1997        let _h = can_save.observe(move |v| sink.borrow_mut().push(*v));
1998
1999        unsaved.set(true);
2000        assert_eq!(*seen.borrow(), vec![true], "the first root pushes through");
2001
2002        backup_mode.set(true);
2003        assert_eq!(
2004            *seen.borrow(),
2005            vec![true, false],
2006            "so does the second — a derived signal observes ALL its roots, \
2007             not just the one it was built from first"
2008        );
2009    }
2010
2011    #[test]
2012    fn dropping_a_derived_observer_detaches_from_every_root() {
2013        use std::cell::RefCell;
2014
2015        let a = Signal::new(0_i32);
2016        let b = Signal::new(0_i32);
2017        let sum = a.zip(&b).map(|(x, y)| *x + *y);
2018
2019        let count: Rc<RefCell<usize>> = Rc::new(RefCell::new(0));
2020        let sink = count.clone();
2021        let h = sum.observe(move |_| *sink.borrow_mut() += 1);
2022
2023        a.set(1);
2024        b.set(1);
2025        assert_eq!(*count.borrow(), 2);
2026        assert_eq!(a.observer_count(), 1);
2027        assert_eq!(b.observer_count(), 1);
2028
2029        drop(h);
2030        assert_eq!(a.observer_count(), 0, "no root keeps a stale observer");
2031        assert_eq!(b.observer_count(), 0);
2032
2033        a.set(2);
2034        b.set(2);
2035        assert_eq!(*count.borrow(), 2, "and none of them still fires");
2036    }
2037
2038    /// `flat_map` re-selects its inner signal as it is read, so there is no
2039    /// fixed root to attach to. It reports that rather than attaching an
2040    /// observer that would go quiet the moment the inner switched.
2041    #[test]
2042    fn observing_a_flat_mapped_signal_is_refused() {
2043        let which = Signal::new(0_usize);
2044        let inners = [Signal::new(1_i32), Signal::new(2_i32)];
2045        let picked = which.flat_map(move |i| inners[*i].clone());
2046
2047        assert!(matches!(
2048            picked.try_observe(|_| {}),
2049            Err(SignalAccessError::ReadOnly)
2050        ));
2051    }
2052
2053    /// `map_coalesced` folds a *fixed* set of roots into one source, so
2054    /// unlike `flat_map` it stays observable.
2055    #[test]
2056    fn observing_a_coalesced_signal_still_works() {
2057        use std::cell::RefCell;
2058
2059        let x = Signal::new(0_i32);
2060        let y = Signal::new(0_i32);
2061        let both = x.zip(&y).map_coalesced(|(a, b)| *a + *b);
2062
2063        let seen: Rc<RefCell<Vec<i32>>> = Rc::new(RefCell::new(Vec::new()));
2064        let sink = seen.clone();
2065        let _h = both.observe(move |v| sink.borrow_mut().push(*v));
2066
2067        x.set(3);
2068        y.set(4);
2069        assert_eq!(*seen.borrow(), vec![3, 7]);
2070    }
2071
2072    #[test]
2073    fn registering_observer_during_notification_does_not_panic() {
2074        use std::cell::RefCell;
2075        // Registering a new observer mid-callback takes borrow_mut on the
2076        // same inner (via try_observe). Must not panic.
2077        let s = Signal::new(0_i32);
2078        let s2 = s.clone();
2079        let extra: Rc<RefCell<Option<ObserverHandle>>> = Rc::new(RefCell::new(None));
2080        let extra2 = extra.clone();
2081        let _h = s.observe(move |_| {
2082            if extra2.borrow().is_none() {
2083                *extra2.borrow_mut() = Some(s2.observe(|_| {}));
2084            }
2085        });
2086        s.set(1);
2087    }
2088}