1use std::time::Duration;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum MotionPreference {
8 Animated,
10 Reduced,
12}
13
14impl MotionPreference {
15 pub const fn is_immediate(self) -> bool {
17 matches!(self, Self::Reduced)
18 }
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum MotionDuration {
24 Immediate,
26 Short,
28 Medium,
30 Long,
32 Custom(Duration),
34}
35
36impl MotionDuration {
37 pub const fn as_duration(self) -> Duration {
39 match self {
40 Self::Immediate => Duration::from_millis(0),
41 Self::Short => Duration::from_millis(120),
42 Self::Medium => Duration::from_millis(180),
43 Self::Long => Duration::from_millis(260),
44 Self::Custom(duration) => duration,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum MotionEasing {
52 Linear,
54 EaseOut,
56 EaseInOut,
58 EaseOutStrong,
60 EaseInOutStrong,
62}
63
64impl MotionEasing {
65 pub fn sample(self, progress: f32) -> f32 {
67 let progress = progress.clamp(0.0, 1.0);
68 match self {
69 Self::Linear => progress,
70 Self::EaseOut => 1.0 - (1.0 - progress).powi(3),
71 Self::EaseInOut => {
72 if progress < 0.5 {
73 4.0 * progress.powi(3)
74 } else {
75 1.0 - (-2.0 * progress + 2.0).powi(3) / 2.0
76 }
77 }
78 Self::EaseOutStrong => 1.0 - (1.0 - progress).powi(4),
79 Self::EaseInOutStrong => {
80 if progress < 0.5 {
81 8.0 * progress.powi(4)
82 } else {
83 1.0 - (-2.0 * progress + 2.0).powi(4) / 2.0
84 }
85 }
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct MotionSpec {
93 preference: MotionPreference,
94 duration: MotionDuration,
95 easing: MotionEasing,
96}
97
98impl MotionSpec {
99 pub const fn new(
101 preference: MotionPreference,
102 duration: MotionDuration,
103 easing: MotionEasing,
104 ) -> Self {
105 Self {
106 preference,
107 duration,
108 easing,
109 }
110 }
111
112 pub const fn layout(preference: MotionPreference) -> Self {
114 Self::new(preference, MotionDuration::Medium, MotionEasing::EaseOut)
115 }
116
117 pub const fn affordance(preference: MotionPreference) -> Self {
119 Self::new(preference, MotionDuration::Short, MotionEasing::EaseOut)
120 }
121
122 pub const fn committed_layout(preference: MotionPreference) -> Self {
124 Self::new(
125 preference,
126 MotionDuration::Medium,
127 MotionEasing::EaseOutStrong,
128 )
129 }
130
131 pub const fn continuity(preference: MotionPreference) -> Self {
133 Self::new(
134 preference,
135 MotionDuration::Long,
136 MotionEasing::EaseInOutStrong,
137 )
138 }
139
140 pub const fn immediate() -> Self {
142 Self::new(
143 MotionPreference::Reduced,
144 MotionDuration::Immediate,
145 MotionEasing::Linear,
146 )
147 }
148
149 pub const fn preference(self) -> MotionPreference {
151 self.preference
152 }
153
154 pub const fn duration(self) -> MotionDuration {
156 if self.preference.is_immediate() {
157 MotionDuration::Immediate
158 } else {
159 self.duration
160 }
161 }
162
163 pub const fn easing(self) -> MotionEasing {
165 self.easing
166 }
167
168 pub const fn is_immediate(self) -> bool {
170 self.preference.is_immediate() || matches!(self.duration(), MotionDuration::Immediate)
171 }
172
173 pub const fn allows_spatial_motion(self) -> bool {
175 !self.is_immediate()
176 }
177
178 pub fn progress_at(self, elapsed: Duration) -> f32 {
180 if self.is_immediate() {
181 return 1.0;
182 }
183 let duration = self.duration().as_duration();
184 if duration.is_zero() {
185 return 1.0;
186 }
187 let raw = (elapsed.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0);
188 self.easing().sample(raw)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn reduced_motion_forces_immediate_duration() {
198 let spec = MotionSpec::layout(MotionPreference::Reduced);
199
200 assert!(spec.is_immediate());
201 assert_eq!(spec.duration(), MotionDuration::Immediate);
202 assert_eq!(spec.duration().as_duration(), Duration::from_millis(0));
203 }
204
205 #[test]
206 fn animated_motion_preserves_duration_and_easing() {
207 let spec = MotionSpec::new(
208 MotionPreference::Animated,
209 MotionDuration::Long,
210 MotionEasing::EaseInOut,
211 );
212
213 assert!(!spec.is_immediate());
214 assert_eq!(spec.duration(), MotionDuration::Long);
215 assert_eq!(spec.easing(), MotionEasing::EaseInOut);
216 assert_eq!(spec.duration().as_duration(), Duration::from_millis(260));
217 }
218
219 #[test]
220 fn progress_at_samples_duration_and_curve() {
221 let spec = MotionSpec::new(
222 MotionPreference::Animated,
223 MotionDuration::Custom(Duration::from_millis(200)),
224 MotionEasing::Linear,
225 );
226
227 assert_eq!(spec.progress_at(Duration::from_millis(0)), 0.0);
228 assert_eq!(spec.progress_at(Duration::from_millis(100)), 0.5);
229 assert_eq!(spec.progress_at(Duration::from_millis(250)), 1.0);
230 }
231
232 #[test]
233 fn named_layout_specs_use_stronger_curves_without_changing_layout_default() {
234 assert_eq!(
235 MotionSpec::layout(MotionPreference::Animated).easing(),
236 MotionEasing::EaseOut
237 );
238 assert_eq!(
239 MotionSpec::affordance(MotionPreference::Animated).duration(),
240 MotionDuration::Short
241 );
242 assert_eq!(
243 MotionSpec::committed_layout(MotionPreference::Animated).easing(),
244 MotionEasing::EaseOutStrong
245 );
246 assert_eq!(
247 MotionSpec::continuity(MotionPreference::Animated).easing(),
248 MotionEasing::EaseInOutStrong
249 );
250 }
251
252 #[test]
253 fn reduced_motion_disables_spatial_motion() {
254 assert!(!MotionSpec::layout(MotionPreference::Reduced).allows_spatial_motion());
255 assert!(MotionSpec::layout(MotionPreference::Animated).allows_spatial_motion());
256 }
257
258 #[test]
259 fn strong_motion_curves_are_monotonic_and_complete() {
260 for easing in [MotionEasing::EaseOutStrong, MotionEasing::EaseInOutStrong] {
261 let samples = [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0]
262 .into_iter()
263 .map(|progress| easing.sample(progress))
264 .collect::<Vec<_>>();
265
266 assert_eq!(samples.first().copied(), Some(0.0));
267 assert_eq!(samples.last().copied(), Some(1.0));
268 assert!(
269 samples.windows(2).all(|window| window[0] <= window[1]),
270 "{easing:?} should be monotonic: {samples:?}"
271 );
272 }
273 }
274}