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