frust_theme/motion.rs
1//! [`MotionScheme::neutral`]: the design-language-free motion scheme — six
2//! spring presets plus a duration/easing token vocabulary, replacing a plain
3//! duration+easing-only model.
4//!
5//! This crate constructs no other `MotionScheme` — a design system builds
6//! its own from its own plugin crate (`frust-material`'s `tokens` module
7//! carries the M3 Expressive six-spring mapping, source cited there;
8//! `frust-cupertino`'s carries the community-documented iOS spring
9//! baseline). Unlike [`crate::shape`]/[`crate::elevation`]/[`crate::typography`],
10//! [`MotionScheme::neutral`]'s springs are **not** a reused M3 table — every
11//! spring here is critically damped (no bounce), a deliberately unbranded
12//! feel distinct from either design system's springier presets (see that
13//! constructor's own doc comment).
14//!
15//! `MotionSpring` is plain data defined here rather than reusing
16//! `frust-core`'s animation-core spring type: `frust-theme` already
17//! depends on `frust-core` (see `docs/ARCHITECTURE.md`), so
18//! [`impl From<MotionSpring> for SpringDesc`](struct.MotionSpring.html)
19//! below lives right here rather than in a separate facade conversion.
20
21use frust_core::anim::{Curve, SpringDesc};
22
23/// A physics spring's damping ratio and stiffness (mass is implicitly `1.0`
24/// for every preset; see module docs).
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub struct MotionSpring {
27 pub damping_ratio: f64,
28 pub stiffness: f64,
29}
30
31impl MotionSpring {
32 const fn new(damping_ratio: f64, stiffness: f64) -> Self {
33 Self {
34 damping_ratio,
35 stiffness,
36 }
37 }
38}
39
40/// Converts a [`MotionSpring`] token into the generic physics type
41/// [`AnimationController::fling`](frust_core::anim::AnimationController::fling)/
42/// [`Spring::new`](frust_core::anim::Spring::new) consume.
43///
44/// Mass is always `1.0` — every preset (this crate's and each design
45/// system's own) is defined purely in terms of damping ratio + stiffness, so
46/// `mass` has no token-level source to convert from.
47impl From<MotionSpring> for SpringDesc {
48 fn from(spring: MotionSpring) -> Self {
49 SpringDesc {
50 mass: 1.0,
51 stiffness: spring.stiffness,
52 damping_ratio: spring.damping_ratio,
53 }
54 }
55}
56
57/// Named duration tokens, in milliseconds.
58///
59/// Glyph's motion language is bezier/duration-authored (unlike the
60/// spring-authored presets above), so a [`MotionScheme`] carries this
61/// five-slot duration vocabulary alongside the six spring presets — see
62/// [`MotionScheme::neutral`] for this crate's own mapping, and
63/// `frust-material`/`frust-cupertino`'s own `tokens` modules for their
64/// per-baseline mappings and sources.
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct MotionDurations {
67 pub instant: f64,
68 pub fast: f64,
69 pub base: f64,
70 pub slow: f64,
71 pub deliberate: f64,
72}
73
74/// The `CosmeticLoop` tick-rate cap (Hz) — an app-tunable pacing token
75/// consumed by the frame gate's pacing: a
76/// purely cosmetic, indefinitely-looping animation (a shimmer/pulse with no
77/// user-visible endpoint) is capped to this rate rather than repainting
78/// every display frame. **Uncapped (0/`None`) is not an allowed value** —
79/// a cosmetic loop always paces to *some* ceiling, so this type clamps any
80/// constructed value up to [`CosmeticLoopRate::FLOOR_HZ`] (10Hz), a sane
81/// floor below which a "cosmetic" loop reads as visibly stuttering rather
82/// than paced.
83///
84/// This token only *declares* the cap; nothing in `frust-theme` reads a
85/// clock or paces a loop — the frame-gate consumer is what actually honors it.
86#[derive(Clone, Copy, Debug, PartialEq)]
87pub struct CosmeticLoopRate(f32);
88
89impl CosmeticLoopRate {
90 /// The floor every constructed rate clamps up to — below this a
91 /// "cosmetic" pace reads as stutter rather than a deliberate cap.
92 pub const FLOOR_HZ: f32 = 10.0;
93
94 /// Builds a rate, clamping `hz` up to [`FLOOR_HZ`](Self::FLOOR_HZ) (no
95 /// uncapped/zero value is representable). `const fn` so every
96 /// [`MotionScheme`] baseline constructor below can stay `const`.
97 ///
98 /// The clamp is **NaN-safe**: a plain `hz < FLOOR_HZ` test lets `NaN`
99 /// slip through (every ordered comparison with `NaN` is `false`), so `NaN`
100 /// is caught explicitly and clamped to the floor alongside any finite
101 /// below-floor value. This matters because the desktop shell derives its
102 /// per-frame paced interval as `Duration::from_secs_f32(1.0 / hz())`, and
103 /// `from_secs_f32` panics on a `NaN` argument — clamping here guarantees a
104 /// finite, `>= FLOOR_HZ` rate so that division can never produce one. (The
105 /// equivalent `!(hz >= FLOOR_HZ)` one-liner is clearer intent-wise but
106 /// trips clippy's `neg_cmp_op_on_partial_ord`; the explicit `is_nan()`
107 /// disjunction below is the lint-clean form of the same NaN-safe clamp.)
108 pub const fn new(hz: f32) -> Self {
109 if hz.is_nan() || hz < Self::FLOOR_HZ {
110 Self(Self::FLOOR_HZ)
111 } else {
112 Self(hz)
113 }
114 }
115
116 /// The clamped rate, in Hz.
117 pub const fn hz(self) -> f32 {
118 self.0
119 }
120}
121
122/// The three-easing vocabulary Glyph's bezier-authored motion patterns are
123/// built from: `spatial` (position/size changes — may overshoot), `effects`
124/// (opacity/color changes — never overshoots), and `exit` (elements leaving
125/// the screen — accelerates out). See [`MotionScheme::neutral`] for this
126/// crate's own curve values, and `frust-material`/`frust-cupertino`'s own
127/// `tokens` modules for their per-baseline curve values and sources.
128#[derive(Clone, Copy, Debug, PartialEq)]
129pub struct EasingSet {
130 pub spatial: Curve,
131 pub effects: Curve,
132 pub exit: Curve,
133}
134
135/// Six motion spring presets, plus the Glyph duration/easing token
136/// vocabulary mapped onto each baseline.
137#[derive(Clone, Copy, Debug, PartialEq)]
138pub struct MotionScheme {
139 pub fast_spatial: MotionSpring,
140 pub fast_effects: MotionSpring,
141 pub default_spatial: MotionSpring,
142 pub default_effects: MotionSpring,
143 pub slow_spatial: MotionSpring,
144 pub slow_effects: MotionSpring,
145 /// Named duration tokens (Glyph vocabulary; see [`MotionDurations`]).
146 pub durations: MotionDurations,
147 /// The three-easing vocabulary (Glyph vocabulary; see [`EasingSet`]).
148 pub easing: EasingSet,
149 /// Collapses patterned motion to a fast crossfade. Every baseline below
150 /// defaults this to `false` — the correct value absent an OS signal — and
151 /// a mobile shell raises it from the platform's own reduced-motion
152 /// accessibility preference:
153 ///
154 /// - **Android**: `Settings.Global.ANIMATOR_DURATION_SCALE`, reduced when
155 /// the scale is exactly `0` (the same signal `ValueAnimator::
156 /// areAnimatorsEnabled` consults). It is **not** a `Configuration`
157 /// field, so `onConfigurationChanged` never reports it — the embedding
158 /// registers a `ContentObserver` on that setting's URI and re-reads it
159 /// on every resume.
160 /// - **iOS**: `UIAccessibility.isReduceMotionEnabled`, observed through
161 /// `UIAccessibility.reduceMotionStatusDidChangeNotification`. It is
162 /// **not** a `UITraitCollection` trait, so `traitCollectionDidChange`
163 /// never fires for it.
164 /// - **Desktop**: no source. winit 0.30 exposes no reduced-motion (or any
165 /// other accessibility-preference) accessor and nothing else in the
166 /// desktop path reads one, so the desktop shell leaves this at whatever
167 /// the active theme authored — a deliberate, checked gap (2026-07-31),
168 /// not an oversight to re-investigate.
169 ///
170 /// Both mobile shells apply the OS report as a **floor**: the platform's
171 /// value is OR'd over the active theme's own authored token (each shell's
172 /// `effective_reduce_motion`), never assigned over it. So a theme built
173 /// with `reduce_motion: true` keeps it while the OS setting is off, and
174 /// the OS setting still wins whenever it is on.
175 pub reduce_motion: bool,
176 /// The `CosmeticLoop` tick-rate cap — see [`CosmeticLoopRate`]. Every
177 /// baseline below defaults this to 30Hz; override via
178 /// [`crate::builder::ThemeBuilder::map_motion`]. `reduce_motion`
179 /// interplay: a widget that honors `reduce_motion` collapses its loop
180 /// entirely (this cap never applies then) — this token only paces the
181 /// loops that still run when `reduce_motion` is off/unhonored by that
182 /// widget.
183 pub cosmetic_loop_rate: CosmeticLoopRate,
184}
185
186impl MotionScheme {
187 /// The neutral, design-language-free motion scheme.
188 ///
189 /// Every spring is **critically damped** (`damping_ratio: 1.0`, no
190 /// overshoot) — unlike `frust-material`'s under-damped `0.9` spatial
191 /// springs (a deliberate M3 Expressive personality trait) or
192 /// `frust-cupertino`'s community-documented ζ≈0.5753 iOS spring, both of
193 /// which bounce by design (see each plugin's own `tokens` module). A
194 /// neutral-themed transition settles without overshoot — a deliberately
195 /// unbranded feel, not a missing feature. Stiffness still varies by
196 /// speed tier (fast > default > slow), the same three-tier shape every
197 /// design system's own baseline uses.
198 ///
199 /// Duration/easing tokens are a plain, **Frust-authored round-number
200 /// scale** — not sourced from any published design system's table,
201 /// unlike the M3/Cupertino baselines each plugin crate carries — using
202 /// only the generic CSS-keyword [`Curve`] variants
203 /// (`EaseOut`/`EaseInOut`/`EaseIn`), never a design-language-specific
204 /// bezier.
205 pub const fn neutral() -> Self {
206 Self {
207 fast_spatial: MotionSpring::new(1.0, 700.0),
208 fast_effects: MotionSpring::new(1.0, 1800.0),
209 default_spatial: MotionSpring::new(1.0, 400.0),
210 default_effects: MotionSpring::new(1.0, 900.0),
211 slow_spatial: MotionSpring::new(1.0, 200.0),
212 slow_effects: MotionSpring::new(1.0, 450.0),
213 durations: MotionDurations {
214 instant: 100.0,
215 fast: 150.0,
216 base: 250.0,
217 slow: 400.0,
218 deliberate: 600.0,
219 },
220 easing: EasingSet {
221 spatial: Curve::EaseOut,
222 effects: Curve::EaseInOut,
223 exit: Curve::EaseIn,
224 },
225 reduce_motion: false,
226 cosmetic_loop_rate: CosmeticLoopRate::new(30.0),
227 }
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn spring_desc_round_trips_neutral_presets() {
237 let m = MotionScheme::neutral();
238 let presets = [
239 m.fast_spatial,
240 m.fast_effects,
241 m.default_spatial,
242 m.default_effects,
243 m.slow_spatial,
244 m.slow_effects,
245 ];
246 for preset in presets {
247 let desc: SpringDesc = preset.into();
248 assert_eq!(desc.mass, 1.0);
249 assert_eq!(desc.stiffness, preset.stiffness);
250 assert_eq!(desc.damping_ratio, preset.damping_ratio);
251 }
252 }
253
254 #[test]
255 fn cosmetic_loop_rate_clamps_to_the_floor() {
256 assert_eq!(CosmeticLoopRate::new(0.0).hz(), CosmeticLoopRate::FLOOR_HZ);
257 assert_eq!(CosmeticLoopRate::new(-5.0).hz(), CosmeticLoopRate::FLOOR_HZ);
258 assert_eq!(CosmeticLoopRate::new(5.0).hz(), CosmeticLoopRate::FLOOR_HZ);
259 assert_eq!(
260 CosmeticLoopRate::new(CosmeticLoopRate::FLOOR_HZ).hz(),
261 CosmeticLoopRate::FLOOR_HZ
262 );
263 }
264
265 #[test]
266 fn cosmetic_loop_rate_passes_through_above_the_floor() {
267 assert_eq!(CosmeticLoopRate::new(30.0).hz(), 30.0);
268 assert_eq!(CosmeticLoopRate::new(60.0).hz(), 60.0);
269 }
270
271 #[test]
272 fn cosmetic_loop_rate_clamps_nan_to_the_floor() {
273 // NaN-safe clamp: `NaN >= FLOOR` is false, so `new` clamps NaN up to
274 // the floor. The resulting rate must be finite so the desktop shell's
275 // `Duration::from_secs_f32(1.0 / hz())` per-frame interval cannot
276 // panic (`from_secs_f32` panics on a NaN argument).
277 let rate = CosmeticLoopRate::new(f32::NAN);
278 assert_eq!(rate.hz(), CosmeticLoopRate::FLOOR_HZ);
279 assert!(rate.hz().is_finite());
280 // And `1.0 / hz()` — the value that actually reaches `from_secs_f32` —
281 // is finite and positive, never NaN.
282 assert!((1.0 / rate.hz()).is_finite());
283 }
284
285 #[test]
286 fn cosmetic_loop_rate_new_is_const_fn() {
287 const RATE: CosmeticLoopRate = CosmeticLoopRate::new(45.0);
288 assert_eq!(RATE.hz(), 45.0);
289 }
290
291 #[test]
292 fn theme_builder_map_motion_overrides_the_cosmetic_loop_rate() {
293 // Task acceptance criterion 1: ThemeBuilder override test.
294 use crate::theme::Theme;
295
296 let base = Theme::neutral();
297 let theme = Theme::builder(base.clone())
298 .map_motion(|m| MotionScheme {
299 cosmetic_loop_rate: CosmeticLoopRate::new(15.0),
300 ..m
301 })
302 .build();
303
304 assert_eq!(theme.motion.cosmetic_loop_rate.hz(), 15.0);
305 // Nothing else in the motion group moved.
306 assert_eq!(theme.motion.fast_spatial, base.motion.fast_spatial);
307 assert_eq!(theme.motion.durations, base.motion.durations);
308 }
309
310 // ---- Neutral scheme -----------------------------------------------
311
312 #[test]
313 fn neutral_springs_are_all_critically_damped() {
314 // No overshoot anywhere — unlike frust-material's 0.9 spatial
315 // springs or frust-cupertino's ~0.5753 uniform spring.
316 let m = MotionScheme::neutral();
317 for s in [
318 m.fast_spatial,
319 m.fast_effects,
320 m.default_spatial,
321 m.default_effects,
322 m.slow_spatial,
323 m.slow_effects,
324 ] {
325 assert_eq!(s.damping_ratio, 1.0);
326 }
327 }
328
329 #[test]
330 fn neutral_stiffness_still_varies_by_speed_tier() {
331 let m = MotionScheme::neutral();
332 assert!(m.fast_spatial.stiffness > m.default_spatial.stiffness);
333 assert!(m.default_spatial.stiffness > m.slow_spatial.stiffness);
334 assert!(m.fast_effects.stiffness > m.default_effects.stiffness);
335 assert!(m.default_effects.stiffness > m.slow_effects.stiffness);
336 }
337
338 #[test]
339 fn neutral_easing_uses_only_generic_curves() {
340 let m = MotionScheme::neutral();
341 assert_eq!(m.easing.spatial, Curve::EaseOut);
342 assert_eq!(m.easing.effects, Curve::EaseInOut);
343 assert_eq!(m.easing.exit, Curve::EaseIn);
344 }
345
346 #[test]
347 fn neutral_cosmetic_loop_rate_defaults_to_30hz() {
348 assert_eq!(MotionScheme::neutral().cosmetic_loop_rate.hz(), 30.0);
349 }
350
351 #[test]
352 fn neutral_reduce_motion_defaults_to_false() {
353 assert!(!MotionScheme::neutral().reduce_motion);
354 }
355
356 const NEUTRAL_CONST: MotionScheme = MotionScheme::neutral();
357
358 #[test]
359 fn neutral_stays_const_fn() {
360 assert_eq!(NEUTRAL_CONST, MotionScheme::neutral());
361 }
362}