Skip to main content

frust_material/tokens/
motion.rs

1//! Material 3 Expressive motion tokens at reference fidelity: 16 named
2//! duration tokens, 7 cubic-easing curves ([`MaterialMotion`]), and 11 spring
3//! presets ([`MaterialSpring`]).
4//!
5//! Source: `paadevelopments/material_3_expressive` v1.0.8,
6//! `lib/foundations/m3e_motion.dart`'s `M3EMotion`/`M3ESpring` (retrieved
7//! 2026-08-19). [`super::motion_scheme`] builds the baseline
8//! [`frust::MotionScheme`]'s six spring slots plus its five-slot duration and
9//! three-slot easing vocabulary from a subset of these constants — see that
10//! function's own doc comment for the exact mapping. The remaining five spring
11//! presets ([`MaterialSpring::AOSP_SPATIAL`] and the four
12//! `EXPRESSIVE_SPATIAL_*` variants) have no `MotionScheme` slot to land in and
13//! stay reachable as plain crate constants instead — the same shape
14//! `button_group`'s local `PRESS_SPRING` and `loading_indicator`'s morph
15//! spring already use, just promoted to this shared table.
16//!
17//! Spring semantics: `stiffness`/`damping_ratio` map directly onto
18//! `SpringDescription.withDampingRatio(mass: 1, stiffness: stiffness, ratio:
19//! damping)` — `motor` 1.1.0's Flutter-compatible spring builder
20//! (`tmp/motor-1.1.0/lib/src/motion.dart`, e.g. its
21//! `MaterialSpringMotion.expressiveEffectsSlow`), the same construction M3E's
22//! own `M3ESpring::toDescription` uses. Mass is implicitly `1.0` for every
23//! preset here, matching [`frust::MotionSpring`]'s own convention
24//! (`crates/frust-theme/src/motion.rs`) — neither the Dart source nor this
25//! module has a per-preset mass to convert.
26//!
27//! # Dart → Rust naming map
28//!
29//! | `M3EMotion` (Dart, camelCase) | This module (Rust, `SCREAMING_SNAKE`) |
30//! |---|---|
31//! | `short1` | [`MaterialMotion::SHORT_1`] |
32//! | `short2` | [`MaterialMotion::SHORT_2`] |
33//! | `short3` | [`MaterialMotion::SHORT_3`] |
34//! | `short4` | [`MaterialMotion::SHORT_4`] |
35//! | `medium1` | [`MaterialMotion::MEDIUM_1`] |
36//! | `medium2` | [`MaterialMotion::MEDIUM_2`] |
37//! | `medium3` | [`MaterialMotion::MEDIUM_3`] |
38//! | `medium4` | [`MaterialMotion::MEDIUM_4`] |
39//! | `long1` | [`MaterialMotion::LONG_1`] |
40//! | `long2` | [`MaterialMotion::LONG_2`] |
41//! | `long3` | [`MaterialMotion::LONG_3`] |
42//! | `long4` | [`MaterialMotion::LONG_4`] |
43//! | `extraLong1` | [`MaterialMotion::EXTRA_LONG_1`] |
44//! | `extraLong2` | [`MaterialMotion::EXTRA_LONG_2`] |
45//! | `extraLong3` | [`MaterialMotion::EXTRA_LONG_3`] |
46//! | `extraLong4` | [`MaterialMotion::EXTRA_LONG_4`] |
47//! | `standard` | [`MaterialMotion::STANDARD`] |
48//! | `standardAccelerate` | [`MaterialMotion::STANDARD_ACCELERATE`] |
49//! | `standardDecelerate` | [`MaterialMotion::STANDARD_DECELERATE`] |
50//! | `emphasized` | [`MaterialMotion::EMPHASIZED`] |
51//! | `emphasizedAccelerate` | [`MaterialMotion::EMPHASIZED_ACCELERATE`] |
52//! | `emphasizedDecelerate` | [`MaterialMotion::EMPHASIZED_DECELERATE`] |
53//! | `linear` | [`MaterialMotion::LINEAR`] |
54//! | `spatialFast` | [`MaterialSpring::SPATIAL_FAST`] |
55//! | `spatialDefault` | [`MaterialSpring::SPATIAL_DEFAULT`] |
56//! | `spatialSlow` | [`MaterialSpring::SPATIAL_SLOW`] |
57//! | `aospSpatial` | [`MaterialSpring::AOSP_SPATIAL`] |
58//! | `expressiveSpatialFast` | [`MaterialSpring::EXPRESSIVE_SPATIAL_FAST`] |
59//! | `expressiveSpatialDefault` | [`MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT`] |
60//! | `expressiveSpatialPress` | [`MaterialSpring::EXPRESSIVE_SPATIAL_PRESS`] |
61//! | `expressiveSpatialSlow` | [`MaterialSpring::EXPRESSIVE_SPATIAL_SLOW`] |
62//! | `effectsFast` | [`MaterialSpring::EFFECTS_FAST`] |
63//! | `effectsDefault` | [`MaterialSpring::EFFECTS_DEFAULT`] |
64//! | `effectsSlow` | [`MaterialSpring::EFFECTS_SLOW`] |
65
66use std::time::Duration;
67
68use frust::Curve;
69
70/// The M3 Expressive duration/easing token namespace — an uninhabited type
71/// (no instance is ever constructed) carrying only associated constants,
72/// mirroring the Dart source's `abstract final class M3EMotion` static
73/// namespace. See the module docs for the full Dart → Rust naming map and
74/// source citation.
75#[derive(Debug)]
76pub enum MaterialMotion {}
77
78impl MaterialMotion {
79    // ---- Durations (16 tokens, four tiers of four) -----------------------
80
81    /// 50ms.
82    pub const SHORT_1: Duration = Duration::from_millis(50);
83    /// 100ms.
84    pub const SHORT_2: Duration = Duration::from_millis(100);
85    /// 150ms.
86    pub const SHORT_3: Duration = Duration::from_millis(150);
87    /// 200ms.
88    pub const SHORT_4: Duration = Duration::from_millis(200);
89    /// 250ms.
90    pub const MEDIUM_1: Duration = Duration::from_millis(250);
91    /// 300ms.
92    pub const MEDIUM_2: Duration = Duration::from_millis(300);
93    /// 350ms.
94    pub const MEDIUM_3: Duration = Duration::from_millis(350);
95    /// 400ms.
96    pub const MEDIUM_4: Duration = Duration::from_millis(400);
97    /// 450ms.
98    pub const LONG_1: Duration = Duration::from_millis(450);
99    /// 500ms.
100    pub const LONG_2: Duration = Duration::from_millis(500);
101    /// 550ms.
102    pub const LONG_3: Duration = Duration::from_millis(550);
103    /// 600ms.
104    pub const LONG_4: Duration = Duration::from_millis(600);
105    /// 700ms.
106    pub const EXTRA_LONG_1: Duration = Duration::from_millis(700);
107    /// 800ms.
108    pub const EXTRA_LONG_2: Duration = Duration::from_millis(800);
109    /// 900ms.
110    pub const EXTRA_LONG_3: Duration = Duration::from_millis(900);
111    /// 1000ms.
112    pub const EXTRA_LONG_4: Duration = Duration::from_millis(1000);
113
114    // ---- Easings (7 cubic-Bézier curves) ----------------------------------
115    //
116    // Every value below is a `Curve::Cubic(x1, y1, x2, y2)` — the same
117    // `cubic-bezier()` parameterization the Dart `Cubic` constructor uses,
118    // endpoints fixed at `(0, 0)`/`(1, 1)`. `standard` and `emphasized` carry
119    // identical control points in the Dart source; transcribed as-is rather
120    // than deduplicated.
121
122    /// Standard easing — `cubic-bezier(0.2, 0, 0, 1)`.
123    pub const STANDARD: Curve = Curve::Cubic(0.2, 0.0, 0.0, 1.0);
124    /// Standard-accelerate easing — `cubic-bezier(0.3, 0, 1, 1)`.
125    pub const STANDARD_ACCELERATE: Curve = Curve::Cubic(0.3, 0.0, 1.0, 1.0);
126    /// Standard-decelerate easing — `cubic-bezier(0, 0, 0, 1)`.
127    pub const STANDARD_DECELERATE: Curve = Curve::Cubic(0.0, 0.0, 0.0, 1.0);
128    /// Emphasized easing — `cubic-bezier(0.2, 0, 0, 1)` (identical control
129    /// points to [`STANDARD`](Self::STANDARD) in the Dart source).
130    pub const EMPHASIZED: Curve = Curve::Cubic(0.2, 0.0, 0.0, 1.0);
131    /// Emphasized-accelerate easing — `cubic-bezier(0.3, 0, 0.8, 0.15)`.
132    pub const EMPHASIZED_ACCELERATE: Curve = Curve::Cubic(0.3, 0.0, 0.8, 0.15);
133    /// Emphasized-decelerate easing — `cubic-bezier(0.05, 0.7, 0.1, 1)`.
134    pub const EMPHASIZED_DECELERATE: Curve = Curve::Cubic(0.05, 0.7, 0.1, 1.0);
135    /// Linear easing — `cubic-bezier(0, 0, 1, 1)`, transcribed as a `Cubic`
136    /// (not [`Curve::Linear`]) to match the Dart source's own
137    /// `Cubic(0, 0, 1, 1)` literal; the two are mathematically identical.
138    pub const LINEAR: Curve = Curve::Cubic(0.0, 0.0, 1.0, 1.0);
139}
140
141/// A Material 3 Expressive spring preset: `stiffness` plus `damping_ratio`
142/// (mass implicitly `1.0` — see the module docs). `1.0` is critically damped
143/// (no overshoot); values below `1.0` overshoot before settling.
144#[derive(Clone, Copy, Debug, PartialEq)]
145pub struct MaterialSpring {
146    pub stiffness: f64,
147    pub damping_ratio: f64,
148}
149
150impl MaterialSpring {
151    const fn new(stiffness: f64, damping_ratio: f64) -> Self {
152        Self {
153            stiffness,
154            damping_ratio,
155        }
156    }
157
158    // ---- Springs (11 presets) ---------------------------------------------
159    //
160    // Six of these (`SPATIAL_FAST`/`DEFAULT`/`SLOW`,
161    // `EFFECTS_FAST`/`DEFAULT`/`SLOW`) feed `motion_scheme`'s six
162    // `MotionScheme` slots directly; the remaining five have no slot and stay
163    // reachable only as these crate constants (see the module docs).
164
165    /// Fast spatial spring for size/position/shape morphs — feeds
166    /// `motion_scheme`'s `fast_spatial`.
167    pub const SPATIAL_FAST: Self = Self::new(1400.0, 0.9);
168    /// Default spatial spring for size/position/shape morphs — feeds
169    /// `motion_scheme`'s `default_spatial`.
170    pub const SPATIAL_DEFAULT: Self = Self::new(700.0, 0.9);
171    /// Slow spatial spring for size/position/shape morphs — feeds
172    /// `motion_scheme`'s `slow_spatial`.
173    pub const SPATIAL_SLOW: Self = Self::new(300.0, 0.9);
174    /// AOSP spatial spring (matches AOSP notification-list expansion — no
175    /// overshoot). No `MotionScheme` slot; a crate-reachable constant only.
176    pub const AOSP_SPATIAL: Self = Self::new(380.0, 1.0);
177    /// Fast expressive spatial spring with slight overshoot. No
178    /// `MotionScheme` slot; a crate-reachable constant only.
179    pub const EXPRESSIVE_SPATIAL_FAST: Self = Self::new(800.0, 0.6);
180    /// Default expressive spatial spring with slight overshoot. No
181    /// `MotionScheme` slot; a crate-reachable constant only.
182    pub const EXPRESSIVE_SPATIAL_DEFAULT: Self = Self::new(380.0, 0.8);
183    /// Interactive press-scale spring (floating toolbar / button morph
184    /// recipe). No `MotionScheme` slot; a crate-reachable constant only.
185    pub const EXPRESSIVE_SPATIAL_PRESS: Self = Self::new(380.0, 0.55);
186    /// Slow expressive spatial spring with slight overshoot. No
187    /// `MotionScheme` slot; a crate-reachable constant only.
188    pub const EXPRESSIVE_SPATIAL_SLOW: Self = Self::new(200.0, 0.8);
189    /// Fast effects spring for color/opacity (no overshoot) — feeds
190    /// `motion_scheme`'s `fast_effects`.
191    pub const EFFECTS_FAST: Self = Self::new(3800.0, 1.0);
192    /// Default effects spring for color/opacity (no overshoot) — feeds
193    /// `motion_scheme`'s `default_effects`.
194    pub const EFFECTS_DEFAULT: Self = Self::new(1600.0, 1.0);
195    /// Slow effects spring for color/opacity (no overshoot) — feeds
196    /// `motion_scheme`'s `slow_effects`.
197    pub const EFFECTS_SLOW: Self = Self::new(800.0, 1.0);
198}
199
200impl From<MaterialSpring> for frust::MotionSpring {
201    fn from(spring: MaterialSpring) -> Self {
202        frust::MotionSpring {
203            damping_ratio: spring.damping_ratio,
204            stiffness: spring.stiffness,
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    // ---- Durations: table test against the Dart source's literal ms -----
214
215    #[test]
216    fn durations_match_reference_table_exactly() {
217        let table: [(Duration, u64); 16] = [
218            (MaterialMotion::SHORT_1, 50),
219            (MaterialMotion::SHORT_2, 100),
220            (MaterialMotion::SHORT_3, 150),
221            (MaterialMotion::SHORT_4, 200),
222            (MaterialMotion::MEDIUM_1, 250),
223            (MaterialMotion::MEDIUM_2, 300),
224            (MaterialMotion::MEDIUM_3, 350),
225            (MaterialMotion::MEDIUM_4, 400),
226            (MaterialMotion::LONG_1, 450),
227            (MaterialMotion::LONG_2, 500),
228            (MaterialMotion::LONG_3, 550),
229            (MaterialMotion::LONG_4, 600),
230            (MaterialMotion::EXTRA_LONG_1, 700),
231            (MaterialMotion::EXTRA_LONG_2, 800),
232            (MaterialMotion::EXTRA_LONG_3, 900),
233            (MaterialMotion::EXTRA_LONG_4, 1000),
234        ];
235        for (actual, expected_ms) in table {
236            assert_eq!(actual, Duration::from_millis(expected_ms));
237        }
238    }
239
240    // ---- Easings: table test against the Dart source's literal control
241    // points, plus endpoint/monotonicity checks for every curve. ----------
242
243    /// One easing table row: `(name, curve, expected (x1, y1, x2, y2))`.
244    type EasingRow = (&'static str, Curve, (f64, f64, f64, f64));
245
246    fn easing_table() -> [EasingRow; 7] {
247        [
248            ("standard", MaterialMotion::STANDARD, (0.2, 0.0, 0.0, 1.0)),
249            (
250                "standard_accelerate",
251                MaterialMotion::STANDARD_ACCELERATE,
252                (0.3, 0.0, 1.0, 1.0),
253            ),
254            (
255                "standard_decelerate",
256                MaterialMotion::STANDARD_DECELERATE,
257                (0.0, 0.0, 0.0, 1.0),
258            ),
259            (
260                "emphasized",
261                MaterialMotion::EMPHASIZED,
262                (0.2, 0.0, 0.0, 1.0),
263            ),
264            (
265                "emphasized_accelerate",
266                MaterialMotion::EMPHASIZED_ACCELERATE,
267                (0.3, 0.0, 0.8, 0.15),
268            ),
269            (
270                "emphasized_decelerate",
271                MaterialMotion::EMPHASIZED_DECELERATE,
272                (0.05, 0.7, 0.1, 1.0),
273            ),
274            ("linear", MaterialMotion::LINEAR, (0.0, 0.0, 1.0, 1.0)),
275        ]
276    }
277
278    #[test]
279    fn easings_match_reference_control_points_exactly() {
280        for (name, curve, expected) in easing_table() {
281            match curve {
282                Curve::Cubic(x1, y1, x2, y2) => {
283                    assert_eq!((x1, y1, x2, y2), expected, "curve `{name}`");
284                }
285                other => panic!("curve `{name}` is not a Cubic: {other:?}"),
286            }
287        }
288    }
289
290    #[test]
291    fn easing_endpoints_are_exact() {
292        for (name, curve, _) in easing_table() {
293            assert!(
294                curve.transform(0.0).abs() < 1e-9,
295                "curve `{name}` at t=0 should be 0, got {}",
296                curve.transform(0.0)
297            );
298            assert!(
299                (curve.transform(1.0) - 1.0).abs() < 1e-9,
300                "curve `{name}` at t=1 should be 1, got {}",
301                curve.transform(1.0)
302            );
303        }
304    }
305
306    #[test]
307    fn easings_are_monotonic_in_t() {
308        for (name, curve, _) in easing_table() {
309            let mut prev = curve.transform(0.0);
310            for i in 1..=20 {
311                let t = i as f64 / 20.0;
312                let cur = curve.transform(t);
313                assert!(
314                    cur + 1e-9 >= prev,
315                    "curve `{name}` not monotonic at t={t}: {cur} < prev {prev}"
316                );
317                prev = cur;
318            }
319        }
320    }
321
322    // ---- Springs: table test against the Dart source's literal
323    // stiffness/damping. ----------------------------------------------------
324
325    #[test]
326    fn springs_match_reference_table_exactly() {
327        let table: [(MaterialSpring, f64, f64); 11] = [
328            (MaterialSpring::SPATIAL_FAST, 1400.0, 0.9),
329            (MaterialSpring::SPATIAL_DEFAULT, 700.0, 0.9),
330            (MaterialSpring::SPATIAL_SLOW, 300.0, 0.9),
331            (MaterialSpring::AOSP_SPATIAL, 380.0, 1.0),
332            (MaterialSpring::EXPRESSIVE_SPATIAL_FAST, 800.0, 0.6),
333            (MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT, 380.0, 0.8),
334            (MaterialSpring::EXPRESSIVE_SPATIAL_PRESS, 380.0, 0.55),
335            (MaterialSpring::EXPRESSIVE_SPATIAL_SLOW, 200.0, 0.8),
336            (MaterialSpring::EFFECTS_FAST, 3800.0, 1.0),
337            (MaterialSpring::EFFECTS_DEFAULT, 1600.0, 1.0),
338            (MaterialSpring::EFFECTS_SLOW, 800.0, 1.0),
339        ];
340        for (spring, stiffness, damping_ratio) in table {
341            assert_eq!(spring.stiffness, stiffness);
342            assert_eq!(spring.damping_ratio, damping_ratio);
343        }
344    }
345
346    #[test]
347    fn spring_into_motion_spring_preserves_fields() {
348        let converted: frust::MotionSpring = MaterialSpring::SPATIAL_FAST.into();
349        assert_eq!(converted.stiffness, 1400.0);
350        assert_eq!(converted.damping_ratio, 0.9);
351    }
352
353    #[test]
354    fn thirty_four_tokens_total() {
355        // 16 durations + 7 easings + 11 springs == 34 (task acceptance
356        // criterion 1's exact count).
357        let durations = 16;
358        let easings = easing_table().len();
359        let springs = 11;
360        assert_eq!(durations + easings + springs, 34);
361    }
362}