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}