Skip to main content

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}