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, ®istry, 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, ®istry, 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, ®istry, 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, ®istry, 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, ®istry, 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, ®, 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, ®, 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}