Skip to main content

teksilo_core/
animation_builder.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `AnimationSpec` — fluent ergonomic façade over `Signal::animate_to`
5//! / `animate_looping` / `try_animate_with_options`.
6//!
7//! Captures duration, easing, looping mode, frame-interval throttle,
8//! pixel-stable epsilon, and the platform reduced-motion preference at
9//! build time. Cloned into event-handler closures so a tween fires in
10//! one call:
11//!
12//! ```ignore
13//! let knob_anim = ctx.animate().fast().standard();
14//! handlers = handlers.on_tap(move |_, _| {
15//!     knob_anim.to_or_snap(&knob_position, target);
16//! });
17//! ```
18//!
19//! `looping()` quietly enables sub-perceptual quantization
20//! (epsilon = 1/255) and a 60 Hz frame interval by default — the two
21//! settings every continuous loop should have but that the bare
22//! `Signal::animate_looping` API makes opt-in.
23
24use std::time::Duration;
25
26use teksilo_tokens::{Easing, MotionTokens};
27
28use crate::animation::AnimationRequest;
29use crate::signal::Signal;
30
31/// Frame interval used when `looping()` is enabled and no explicit
32/// override is set. 60 Hz (16.667 ms), matches the scheduler default
33/// and the most common display refresh rate so a continuous loop
34/// advances once per vsync on a 60 Hz panel and every other frame on
35/// 120 Hz. Slower loops where the eye can't resolve sub-30-Hz detail
36/// (e.g. `ProgressBar::indeterminate` at 15 Hz) override via
37/// `frame_interval(d)` to halve wgpu submits.
38const DEFAULT_LOOP_FRAME_INTERVAL: Duration = Duration::from_micros(16_667);
39
40/// Sub-perceptual epsilon for looping color/opacity/position
41/// animations. 1/255 ≈ one 8-bit channel step — below this, the
42/// scheduler skips the `Signal::set` call and the bound widgets
43/// don't get a spurious repaint.
44const LOOP_DEFAULT_EPSILON: f32 = 1.0 / 255.0;
45
46/// A fluent specification for animating a `Signal<f32>`.
47///
48/// Cheap to clone (one `MotionTokens`, a few primitives). Built via
49/// [`BuildContext::animate`](crate::build_context::BuildContext::animate)
50/// at widget build time, then captured into event-handler closures
51/// that drive animations.
52#[derive(Debug, Clone)]
53pub struct AnimationSpec {
54    motion: MotionTokens,
55    duration: Duration,
56    easing: Easing,
57    looping: bool,
58    frame_interval: Option<Duration>,
59    epsilon: f32,
60    reduced_motion: bool,
61}
62
63impl AnimationSpec {
64    /// Build a default spec (`duration_normal` + `easing_standard`).
65    /// Callers normally use `BuildContext::animate` instead, which
66    /// wires in the platform reduced-motion preference.
67    pub fn from_motion(motion: MotionTokens, reduced_motion: bool) -> Self {
68        let duration = motion.duration_normal;
69        let easing = motion.easing_standard;
70        Self {
71            motion,
72            duration,
73            easing,
74            looping: false,
75            frame_interval: None,
76            epsilon: 0.0,
77            reduced_motion,
78        }
79    }
80
81    // -- duration presets (read from MotionTokens) ----------------------------
82
83    /// `MotionTokens::duration_instant` (default 0 ms).
84    pub fn instant(mut self) -> Self {
85        self.duration = self.motion.duration_instant;
86        self
87    }
88
89    /// `MotionTokens::duration_fast` (default 120 ms — tooltip fade,
90    /// interactive feedback).
91    pub fn fast(mut self) -> Self {
92        self.duration = self.motion.duration_fast;
93        self
94    }
95
96    /// `MotionTokens::duration_normal` (default 200 ms — notification
97    /// slides, generic transitions).
98    pub fn normal(mut self) -> Self {
99        self.duration = self.motion.duration_normal;
100        self
101    }
102
103    /// `MotionTokens::duration_slow` (default 300 ms — dialog
104    /// appearance).
105    pub fn slow(mut self) -> Self {
106        self.duration = self.motion.duration_slow;
107        self
108    }
109
110    /// `MotionTokens::duration_collapse` (default 200 ms — accordion /
111    /// disclosure expand-collapse).
112    pub fn collapse(mut self) -> Self {
113        self.duration = self.motion.duration_collapse;
114        self
115    }
116
117    /// `MotionTokens::duration_indeterminate_sweep` (default 900 ms —
118    /// indeterminate progress sweep, spinner period). Implies
119    /// `looping()`.
120    pub fn sweep(mut self) -> Self {
121        self.duration = self.motion.duration_indeterminate_sweep;
122        self.set_looping_defaults()
123    }
124
125    /// Set the duration explicitly. Prefer the named presets when one
126    /// fits — they keep the design system honest.
127    pub fn duration(mut self, duration: Duration) -> Self {
128        self.duration = duration;
129        self
130    }
131
132    // -- easing ---------------------------------------------------------------
133
134    /// `MotionTokens::easing_standard` (default `EaseOut`). The Int-UI
135    /// "single mild ease-out everywhere" curve.
136    pub fn standard(mut self) -> Self {
137        self.easing = self.motion.easing_standard;
138        self
139    }
140
141    pub fn linear(mut self) -> Self {
142        self.easing = Easing::Linear;
143        self
144    }
145
146    pub fn ease_in(mut self) -> Self {
147        self.easing = Easing::EaseIn;
148        self
149    }
150
151    pub fn ease_out(mut self) -> Self {
152        self.easing = Easing::EaseOut;
153        self
154    }
155
156    pub fn ease_in_out(mut self) -> Self {
157        self.easing = Easing::EaseInOut;
158        self
159    }
160
161    pub fn easing(mut self, easing: Easing) -> Self {
162        self.easing = easing;
163        self
164    }
165
166    /// A CSS `cubic-bezier(x1, y1, x2, y2)` curve — the general easing
167    /// escape hatch for design-language motion specs (Material 3, Fluent)
168    /// that don't reduce to the named curves.
169    pub fn cubic_bezier(mut self, x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
170        self.easing = Easing::CubicBezier { x1, y1, x2, y2 };
171        self
172    }
173
174    /// Material 3 "emphasized" curve — `cubic-bezier(0.2, 0.0, 0.0, 1.0)`.
175    /// The standard M3 enter/exit feel.
176    pub fn m3_emphasized(self) -> Self {
177        self.cubic_bezier(0.2, 0.0, 0.0, 1.0)
178    }
179
180    // -- loop / frame interval / quantization ---------------------------------
181
182    /// Switch to looping mode. Sets a sub-perceptual epsilon (1/255)
183    /// and a 60 Hz frame interval **only if not already overridden**,
184    /// so a continuous bar / spinner advances once per vsync on a
185    /// 60 Hz panel without forcing higher-refresh displays into
186    /// extra `Signal::set` calls (`==`-equal values short-circuit).
187    pub fn looping(self) -> Self {
188        self.set_looping_defaults()
189    }
190
191    fn set_looping_defaults(mut self) -> Self {
192        self.looping = true;
193        if self.frame_interval.is_none() {
194            self.frame_interval = Some(DEFAULT_LOOP_FRAME_INTERVAL);
195        }
196        if self.epsilon == 0.0 {
197            self.epsilon = LOOP_DEFAULT_EPSILON;
198        }
199        self
200    }
201
202    /// Throttle scheduler ticks to at most one per `interval`. Use to
203    /// drop a 60 Hz signal animation to 15-30 Hz when the eye can't
204    /// resolve the difference and every doubled frame costs a wgpu
205    /// submit (e.g. `ProgressBar::indeterminate`'s wide sweep, set to
206    /// 15 Hz via `Duration::from_millis(66)`).
207    pub fn frame_interval(mut self, interval: Duration) -> Self {
208        self.frame_interval = Some(interval);
209        self
210    }
211
212    /// Per-tick quantization. Skip `Signal::set(value)` when the new
213    /// value differs from the last set value by less than `epsilon`.
214    /// `0.0` (the one-shot default) means "set every tick".
215    pub fn epsilon(mut self, epsilon: f32) -> Self {
216        self.epsilon = epsilon;
217        self
218    }
219
220    // -- application ----------------------------------------------------------
221
222    /// Animate `signal` to `target` using this spec. Returns
223    /// immediately; the scheduler drives the tween.
224    ///
225    /// Does NOT honor `prefers_reduced_motion`; use
226    /// [`to_or_snap`](Self::to_or_snap) for that.
227    pub fn to(&self, signal: &Signal<f32>, target: f32) {
228        let _ = signal.try_animate_with_options(self.into_request(target));
229    }
230
231    /// Animate to `target`, but if the user prefers reduced motion
232    /// (snapshot at build time), snap directly to `target` instead of
233    /// tweening. This is the right default for one-shot UI tweens
234    /// (toggle knob, accordion collapse, fade).
235    ///
236    /// For continuous looping animations, prefer gating the call site
237    /// (don't start the loop at all under reduced motion) — snapping
238    /// a loop to its end value just stops it on the wrong frame.
239    pub fn to_or_snap(&self, signal: &Signal<f32>, target: f32) {
240        if self.reduced_motion {
241            signal.set(target);
242        } else {
243            self.to(signal, target);
244        }
245    }
246
247    /// Whether the captured platform preference is for reduced motion.
248    /// Use as a gate before kicking off a continuous looping animation
249    /// (`if !spec.reduced_motion() { spec.to(&signal, 1.0); }`).
250    pub fn reduced_motion(&self) -> bool {
251        self.reduced_motion
252    }
253
254    #[allow(clippy::wrong_self_convention)]
255    fn into_request(&self, target: f32) -> AnimationRequest {
256        AnimationRequest {
257            target,
258            duration: self.duration,
259            easing: self.easing,
260            frame_interval: self.frame_interval,
261            looping: self.looping,
262            epsilon: self.epsilon,
263            max_duration: None,
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn motion() -> MotionTokens {
273        MotionTokens::default()
274    }
275
276    #[test]
277    fn presets_pull_from_motion_tokens() {
278        let m = motion();
279        let s = AnimationSpec::from_motion(m.clone(), false);
280        assert_eq!(s.clone().fast().into_request(0.0).duration, m.duration_fast);
281        assert_eq!(
282            s.clone().normal().into_request(0.0).duration,
283            m.duration_normal
284        );
285        assert_eq!(s.clone().slow().into_request(0.0).duration, m.duration_slow);
286        assert_eq!(
287            s.clone().collapse().into_request(0.0).duration,
288            m.duration_collapse
289        );
290        assert_eq!(
291            s.clone().sweep().into_request(0.0).duration,
292            m.duration_indeterminate_sweep
293        );
294    }
295
296    #[test]
297    fn looping_sets_subperceptual_epsilon_and_frame_interval() {
298        let s = AnimationSpec::from_motion(motion(), false).looping();
299        let r = s.into_request(1.0);
300        assert!(r.looping);
301        assert_eq!(r.epsilon, LOOP_DEFAULT_EPSILON);
302        assert_eq!(r.frame_interval, Some(DEFAULT_LOOP_FRAME_INTERVAL));
303    }
304
305    #[test]
306    fn looping_preserves_explicit_frame_interval() {
307        let custom = Duration::from_millis(66);
308        let s = AnimationSpec::from_motion(motion(), false)
309            .frame_interval(custom)
310            .looping();
311        assert_eq!(s.into_request(1.0).frame_interval, Some(custom));
312    }
313
314    #[test]
315    fn looping_preserves_explicit_epsilon() {
316        let s = AnimationSpec::from_motion(motion(), false)
317            .epsilon(0.5)
318            .looping();
319        assert_eq!(s.into_request(1.0).epsilon, 0.5);
320    }
321
322    #[test]
323    fn sweep_implies_looping() {
324        let r = AnimationSpec::from_motion(motion(), false)
325            .sweep()
326            .into_request(1.0);
327        assert!(r.looping);
328        assert_eq!(r.epsilon, LOOP_DEFAULT_EPSILON);
329    }
330
331    #[test]
332    fn standard_resets_easing_to_token() {
333        let m = motion();
334        let r = AnimationSpec::from_motion(m.clone(), false)
335            .ease_in_out()
336            .standard()
337            .into_request(0.0);
338        assert_eq!(r.easing, m.easing_standard);
339    }
340
341    #[test]
342    fn to_or_snap_under_reduced_motion_sets_directly() {
343        use crate::signal::Signal;
344        let signal = Signal::new_animated(0.0);
345        let s = AnimationSpec::from_motion(motion(), true).fast();
346        s.to_or_snap(&signal, 0.75);
347        // Direct set, no pending animation request.
348        assert!(!signal.has_pending_animation());
349        assert_eq!(signal.get(), 0.75);
350    }
351
352    #[test]
353    fn to_or_snap_without_reduced_motion_queues_request() {
354        use crate::signal::Signal;
355        let signal = Signal::new_animated(0.0);
356        let s = AnimationSpec::from_motion(motion(), false).fast();
357        s.to_or_snap(&signal, 0.75);
358        assert!(signal.has_pending_animation());
359        assert_eq!(signal.get(), 0.0); // hasn't moved yet
360    }
361}