Skip to main content

teksilo_scene/
animation.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-item animation helpers for [`SceneItem`](crate::SceneItem)
5//! authors.
6//!
7//! ## The pattern
8//!
9//! Lightweight scene items don't have a `WidgetId` of their own —
10//! they're painted from inside the [`SceneView`](crate::SceneView)'s
11//! paint walk and don't enter the arena. To get framework-managed
12//! animations on an item-owned `Signal<f32>` (so the four idle
13//! gates apply: reduced-motion snapping, window-inactive pause,
14//! drop-cancel cleanup, paint-epoch visibility), the signal must
15//! register against the *SceneView's* `WidgetId`.
16//!
17//! That's exactly what `SceneItem::register_bindings` is for: it
18//! fires inside `SceneView::build()` and receives a `BuildContext`
19//! whose `self_id()` is the SceneView's id. Item authors call
20//! [`register_animated_item_signal`] on every animated signal they
21//! own; from then on, calling `Signal::animate_to` on it is
22//! framework-managed:
23//!
24//! ```ignore
25//! struct PulsingDot {
26//!     bounds: Rect,
27//!     opacity: Signal<f32>,
28//! }
29//!
30//! impl SceneItem for PulsingDot {
31//!     fn register_bindings(&self, ctx: &mut BuildContext, _view_id: WidgetId) {
32//!         // Hook the signal into the SceneView's animation scheduler.
33//!         register_animated_item_signal(ctx, &self.opacity);
34//!         // Also bind for repaint.
35//!         self.opacity.bind_to(ctx.self_id(), ctx.binding_registry(),
36//!             BindingLevel::RepaintOnly);
37//!     }
38//!     /* …bounds_in_scene / paint that reads self.opacity.get()… */
39//! }
40//! ```
41//!
42//! For one-shot tweens like a click-feedback flash, use the
43//! [`pulse_once`] helper.
44//!
45//! ## Caveat
46//!
47//! Looping animations on lightweight items register against the
48//! SceneView's id — they tick whenever the SceneView ticks, even
49//! if the specific item is currently culled by the spatial index.
50//! Apps that need ten-thousand pulsing background dots should
51//! prefer one shared `Signal<f32>` driving a parametric paint
52//! function instead of one signal per item.
53
54use std::time::Duration;
55
56use teksilo_core::build_context::BuildContext;
57use teksilo_core::signal::Signal;
58use teksilo_tokens::Easing;
59
60/// Register an item-owned `Signal<f32>` with the SceneView's
61/// animation scheduler. Call this from inside
62/// [`SceneItem::register_bindings`](crate::SceneItem::register_bindings)
63/// for every animated signal the item exposes.
64///
65/// Equivalent to `ctx.register_animated_signal(signal)` — the
66/// `register_bindings` callback is invoked while the framework's
67/// `self_id()` is the SceneView's `WidgetId`, so the signal ends
68/// up owned by the right widget for idle-gate tracking.
69///
70/// Provided as a named helper so item authors don't need to know
71/// the underlying `BuildContext` API surface — call this and your
72/// signal's `animate_to` calls are framework-managed.
73pub fn register_animated_item_signal(ctx: &mut BuildContext, signal: &Signal<f32>) {
74    ctx.register_animated_signal(signal);
75}
76
77/// One-shot ease-out tween from the signal's current value to
78/// `target` over `duration`. The standard "fire on click,
79/// dismiss" / "flash a highlight" pattern. The signal must
80/// already be registered with [`register_animated_item_signal`]
81/// (or directly via `ctx.register_animated_signal`) for the tween
82/// to participate in idle gating.
83///
84/// Doesn't reach into reduced-motion settings — apps wiring a
85/// flash that should suppress under reduced motion should query
86/// `BuildContext::prefers_reduced_motion()` at build time and
87/// skip the tween, or use the higher-level `ctx.animate()
88/// .to_or_snap()` API at the widget tier.
89pub fn pulse_once(signal: &Signal<f32>, target: f32, duration: Duration) {
90    signal.animate_to(target, duration, Easing::EaseOut);
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn pulse_once_kicks_off_animation() {
99        let signal = Signal::new_animated(0.0);
100        // Without a scheduler, animate_to still updates the
101        // internal target — but the signal value won't tick.
102        // We verify the call doesn't panic and the target lands.
103        pulse_once(&signal, 1.0, Duration::from_millis(100));
104        // The target should be queryable.
105        assert!(signal.animation_target().is_some());
106    }
107}