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}