1use ferroui_base::animation::easings::{Easing, IEasing, SharedEasing};
16use ferroui_base::animation::TimeSpan;
17use ferroui_base::platform::IPlatformSettings;
18use ferroui_base::{FerroLocator, LocatorExtensions};
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::sync::Arc;
21
22#[derive(Clone, Debug, Default)]
27pub(crate) struct MaterialMotionPolicy {
28 state: Arc<AtomicU64>,
29}
30
31impl MaterialMotionPolicy {
32 #[allow(deprecated)]
34 pub(crate) fn set_reduced_motion(&self, reduced: bool) {
35 let _ = self
36 .state
37 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| {
38 if (state & 1 != 0) == reduced {
39 None
40 } else if reduced {
41 Some(state.wrapping_add(2) | 1)
42 } else {
43 Some(state & !1)
44 }
45 });
46 }
47
48 fn interrupted_since(&self, generation: u64) -> bool {
49 let state = self.state.load(Ordering::Acquire);
50 state & 1 != 0 || state >> 1 != generation
51 }
52}
53
54#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub enum MaterialMotionScheme {
57 Standard,
59 #[default]
61 Expressive,
62}
63
64ferroui_base::ferro_markup_enum!(
65 MaterialMotionScheme { Standard, Expressive },
66 { namespace: "FerroUI.Themes.Material" }
67);
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub enum MaterialMotionRole {
72 FastSpatial,
73 DefaultSpatial,
74 SlowSpatial,
75 FastEffects,
76 DefaultEffects,
77 SlowEffects,
78}
79
80impl MaterialMotionRole {
81 pub const ALL: [Self; 6] = [
83 Self::FastSpatial,
84 Self::DefaultSpatial,
85 Self::SlowSpatial,
86 Self::FastEffects,
87 Self::DefaultEffects,
88 Self::SlowEffects,
89 ];
90
91 pub const fn resource_name(self) -> &'static str {
93 match self {
94 Self::FastSpatial => "FastSpatial",
95 Self::DefaultSpatial => "DefaultSpatial",
96 Self::SlowSpatial => "SlowSpatial",
97 Self::FastEffects => "FastEffects",
98 Self::DefaultEffects => "DefaultEffects",
99 Self::SlowEffects => "SlowEffects",
100 }
101 }
102}
103
104impl MaterialMotionScheme {
105 pub const fn spring(self, role: MaterialMotionRole) -> MaterialSpringSpec {
107 use MaterialMotionRole::*;
108 let (damping, stiffness) = match (self, role) {
109 (Self::Expressive, FastSpatial) => (0.6, 800.0),
110 (Self::Expressive, DefaultSpatial) => (0.8, 380.0),
111 (Self::Expressive, SlowSpatial) => (0.8, 200.0),
112 (Self::Standard, FastSpatial) => (0.9, 1400.0),
113 (Self::Standard, DefaultSpatial) => (0.9, 700.0),
114 (Self::Standard, SlowSpatial) => (0.9, 300.0),
115 (_, FastEffects) => (1.0, 3800.0),
116 (_, DefaultEffects) => (1.0, 1600.0),
117 (_, SlowEffects) => (1.0, 800.0),
118 };
119 MaterialSpringSpec::new(damping, stiffness)
120 }
121
122 pub fn duration(self, role: MaterialMotionRole, reduced_motion: bool) -> TimeSpan {
128 if reduced_motion {
129 TimeSpan::ZERO
130 } else {
131 TimeSpan::from_seconds(self.spring(role).settling_duration())
132 }
133 }
134
135 pub fn easing(self, role: MaterialMotionRole, reduced_motion: bool) -> Easing {
141 Easing::new(MaterialSpringEasing::new(
142 self.spring(role),
143 self.duration(role, reduced_motion),
144 reduced_motion,
145 ))
146 }
147
148 pub(crate) fn easing_with_policy(
151 self,
152 role: MaterialMotionRole,
153 policy: &MaterialMotionPolicy,
154 ) -> Easing {
155 let state = policy.state.load(Ordering::Acquire);
156 let reduced = state & 1 != 0;
157 let mut easing =
158 MaterialSpringEasing::new(self.spring(role), self.duration(role, reduced), reduced);
159 easing.policy = Some((policy.clone(), state >> 1));
160 Easing::new(easing)
161 }
162}
163
164#[derive(Clone, Copy, Debug, Default, PartialEq)]
166pub struct MaterialSpringSample {
167 pub value: f64,
168 pub velocity: f64,
169}
170
171#[derive(Clone, Copy, Debug, PartialEq)]
173pub struct MaterialSpringSpec {
174 damping_ratio: f64,
175 stiffness: f64,
176}
177
178impl MaterialSpringSpec {
179 pub const fn new(damping_ratio: f64, stiffness: f64) -> Self {
182 assert!(
183 damping_ratio.is_finite() && damping_ratio >= 0.0,
184 "invalid spring damping ratio"
185 );
186 assert!(
187 stiffness.is_finite() && stiffness > 0.0,
188 "invalid spring stiffness"
189 );
190 Self {
191 damping_ratio,
192 stiffness,
193 }
194 }
195
196 pub const fn damping_ratio(self) -> f64 {
197 self.damping_ratio
198 }
199
200 pub const fn stiffness(self) -> f64 {
201 self.stiffness
202 }
203
204 pub fn sample(
212 self,
213 elapsed: f64,
214 from: f64,
215 target: f64,
216 initial_velocity: f64,
217 ) -> MaterialSpringSample {
218 assert!(
219 from.is_finite() && target.is_finite() && initial_velocity.is_finite(),
220 "non-finite spring state"
221 );
222 if elapsed.is_nan() || elapsed <= 0.0 {
223 return MaterialSpringSample {
224 value: from,
225 velocity: initial_velocity,
226 };
227 }
228 if !elapsed.is_finite() {
229 return MaterialSpringSample {
230 value: target,
231 velocity: 0.0,
232 };
233 }
234
235 let displacement = from - target;
236 let omega = self.stiffness.sqrt();
237 let zeta = self.damping_ratio;
238 let alpha = zeta * omega;
239
240 let (position, velocity) = if zeta < 1.0 {
241 let decay = (-alpha * elapsed).exp();
242 if decay == 0.0 {
243 return MaterialSpringSample {
244 value: target,
245 velocity: 0.0,
246 };
247 }
248 let frequency = omega * ((1.0 - zeta) * (1.0 + zeta)).sqrt();
249 let raw_angle = frequency * elapsed;
250 let angle = if raw_angle.is_finite() {
253 raw_angle
254 } else {
255 (elapsed % (std::f64::consts::TAU / frequency)) * frequency
256 };
257 let cosine = angle.cos();
258 let sine_over_frequency = if raw_angle.abs() < 1.0e-4 {
259 elapsed * sinc(angle)
260 } else {
261 angle.sin() / frequency
262 };
263 (
264 decay
265 * (displacement * cosine
266 + (initial_velocity + alpha * displacement) * sine_over_frequency),
267 decay
268 * (initial_velocity * cosine
269 - (zeta * initial_velocity + omega * displacement)
270 * (omega * sine_over_frequency)),
271 )
272 } else if zeta == 1.0 {
273 let decay = (-omega * elapsed).exp();
274 if decay == 0.0 {
275 return MaterialSpringSample {
276 value: target,
277 velocity: 0.0,
278 };
279 }
280 let coefficient = initial_velocity + omega * displacement;
281 (
282 (displacement + coefficient * elapsed) * decay,
283 (initial_velocity - omega * coefficient * elapsed) * decay,
284 )
285 } else {
286 let factor = 1.0 + (((zeta - 1.0) / zeta) * (1.0 + zeta.recip())).sqrt();
289 let slow = -omega / zeta / factor;
290 let fast = -omega * zeta * factor;
291 let coefficient = (displacement - initial_velocity / fast) / (1.0 - slow / fast);
294 let slow_decay = (slow * elapsed).exp();
295 let fast_decay = (fast * elapsed).exp();
296 let difference = slow_decay * -((fast - slow) * elapsed).exp_m1();
297 (
298 displacement * fast_decay + coefficient * difference,
299 initial_velocity * fast_decay + slow * coefficient * difference,
300 )
301 };
302
303 MaterialSpringSample {
304 value: target + position,
305 velocity,
306 }
307 }
308
309 pub fn settling_duration(self) -> f64 {
312 const TOLERANCE: f64 = 0.001;
313 let omega = self.stiffness.sqrt();
314 if self.damping_ratio == 0.0 {
315 return f64::INFINITY;
316 }
317 if self.damping_ratio < 1.0 {
318 let amplitude = 1.0 / ((1.0 - self.damping_ratio) * (1.0 + self.damping_ratio)).sqrt();
319 return (amplitude / TOLERANCE).ln() / (self.damping_ratio * omega);
320 }
321
322 let settled = |t| {
326 let sample = self.sample(t, 0.0, 1.0, 0.0);
327 (1.0 - sample.value).abs() <= TOLERANCE && sample.velocity.abs() <= TOLERANCE * omega
328 };
329 let mut lower = 0.0;
330 let mut upper = 1.0 / omega;
331 while !settled(upper) {
332 lower = upper;
333 upper *= 2.0;
334 if !upper.is_finite() {
335 return f64::INFINITY;
336 }
337 }
338 for _ in 0..48 {
339 let middle = (lower + upper) * 0.5;
340 if settled(middle) {
341 upper = middle;
342 } else {
343 lower = middle;
344 }
345 }
346 upper
347 }
348
349 pub fn settling_duration_from(
356 self,
357 displacement: f64,
358 velocity: f64,
359 position_threshold: f64,
360 velocity_threshold: f64,
361 ) -> f64 {
362 assert!(displacement.is_finite() && velocity.is_finite(), "non-finite spring state");
363 assert!(position_threshold.is_finite() && position_threshold > 0.0, "invalid position threshold");
364 assert!(velocity_threshold.is_finite() && velocity_threshold > 0.0, "invalid velocity threshold");
365 if displacement == 0.0 && velocity == 0.0 {
366 return 0.0;
367 }
368 let omega = self.stiffness.sqrt();
369 let zeta = self.damping_ratio;
370 if zeta == 0.0 {
371 return f64::INFINITY;
372 }
373 if zeta < 1.0 {
374 let alpha = zeta * omega;
375 let frequency = omega * ((1.0 - zeta) * (1.0 + zeta)).sqrt();
376 let position_envelope = displacement.hypot((velocity + alpha * displacement) / frequency);
377 let velocity_envelope = velocity.hypot((alpha * velocity + self.stiffness * displacement) / frequency);
378 return ((position_envelope / position_threshold).ln()
379 .max((velocity_envelope / velocity_threshold).ln()) / alpha).max(0.0);
380 }
381
382 let coefficient = (velocity + omega * displacement).abs();
385 let factor = 1.0 + (((zeta - 1.0) / zeta) * (1.0 + zeta.recip())).sqrt();
386 let slow = -omega / zeta / factor;
387 let fast = -omega * zeta * factor;
388 let a = if zeta == 1.0 { 0.0 } else { (displacement - velocity / fast) / (1.0 - slow / fast) };
389 let b = displacement - a;
390 let mut lower = if zeta == 1.0 { omega.recip() } else { 0.0 };
391 let settled = |t: f64| {
392 let (position, speed) = if zeta == 1.0 {
393 let decay = (-omega * t).exp();
394 ((displacement.abs() + coefficient * t) * decay,
395 (velocity.abs() + omega * coefficient * t) * decay)
396 } else {
397 let slow_decay = (slow * t).exp();
398 let fast_decay = (fast * t).exp();
399 (a.abs() * slow_decay + b.abs() * fast_decay,
400 (a * slow).abs() * slow_decay + (b * fast).abs() * fast_decay)
401 };
402 position <= position_threshold && speed <= velocity_threshold
403 };
404 if settled(lower) {
405 return lower;
406 }
407 let mut upper = lower.max(omega.recip());
408 while !settled(upper) {
409 lower = upper;
410 upper *= 2.0;
411 if !upper.is_finite() {
412 return f64::INFINITY;
413 }
414 }
415 for _ in 0..48 {
416 let middle = (lower + upper) * 0.5;
417 if settled(middle) { upper = middle; } else { lower = middle; }
418 }
419 upper
420 }
421}
422
423fn sinc(value: f64) -> f64 {
424 if value.abs() < 1.0e-4 {
425 let squared = value * value;
426 1.0 - squared / 6.0 + squared * squared / 120.0
427 } else {
428 value.sin() / value
429 }
430}
431
432#[derive(Clone, Copy, Debug)]
438pub struct MaterialSpringState {
439 spec: MaterialSpringSpec,
440 origin: MaterialSpringSample,
441 current: MaterialSpringSample,
442 target: f64,
443 elapsed: f64,
444 reduced_motion: bool,
445}
446
447impl MaterialSpringState {
448 pub fn new(spec: MaterialSpringSpec, value: f64) -> Self {
449 assert!(value.is_finite(), "non-finite spring value");
450 let initial = MaterialSpringSample {
451 value,
452 velocity: 0.0,
453 };
454 Self {
455 spec,
456 origin: initial,
457 current: initial,
458 target: value,
459 elapsed: 0.0,
460 reduced_motion: false,
461 }
462 }
463
464 pub fn current(&self) -> MaterialSpringSample {
465 self.current
466 }
467
468 pub fn target(&self) -> f64 {
469 self.target
470 }
471
472 pub fn retarget(&mut self, target: f64) {
473 assert!(target.is_finite(), "non-finite spring target");
474 if self.target == target {
475 return;
476 }
477 self.origin = self.current;
478 self.target = target;
479 self.elapsed = 0.0;
480 if self.reduced_motion {
481 self.finish();
482 }
483 }
484
485 pub fn set_spec(&mut self, spec: MaterialSpringSpec) {
487 self.origin = self.current;
488 self.spec = spec;
489 self.elapsed = 0.0;
490 }
491
492 pub fn set_reduced_motion(&mut self, reduced_motion: bool) {
493 self.reduced_motion = reduced_motion;
494 if reduced_motion {
495 self.finish();
496 }
497 }
498
499 pub fn advance(&mut self, seconds: f64) -> MaterialSpringSample {
500 if self.reduced_motion {
501 self.finish();
502 } else if !seconds.is_nan() && seconds > 0.0 {
503 self.elapsed += seconds;
504 self.current = self.spec.sample(
505 self.elapsed,
506 self.origin.value,
507 self.target,
508 self.origin.velocity,
509 );
510 }
511 self.current
512 }
513
514 pub fn is_at_rest(&self, position_threshold: f64, velocity_threshold: f64) -> bool {
515 (self.current.value - self.target).abs() <= position_threshold.max(0.0)
516 && self.current.velocity.abs() <= velocity_threshold.max(0.0)
517 }
518
519 pub fn finish(&mut self) {
520 self.current = MaterialSpringSample {
521 value: self.target,
522 velocity: 0.0,
523 };
524 self.origin = self.current;
525 self.elapsed = 0.0;
526 }
527}
528
529#[derive(Clone, Debug)]
535pub struct MaterialSpringEasing {
536 spec: MaterialSpringSpec,
537 seconds: f64,
538 reduced_motion: bool,
539 check_platform: bool,
540 policy: Option<(MaterialMotionPolicy, u64)>,
541}
542
543impl MaterialSpringEasing {
544 pub fn new(spec: MaterialSpringSpec, duration: TimeSpan, reduced_motion: bool) -> Self {
545 assert!(duration >= TimeSpan::ZERO, "negative spring duration");
546 Self {
547 spec,
548 seconds: duration.total_seconds(),
549 reduced_motion,
550 check_platform: true,
551 policy: None,
552 }
553 }
554
555 pub fn spring_spec(&self) -> MaterialSpringSpec {
557 self.spec
558 }
559
560 pub(crate) fn motion_disabled(&self) -> bool {
561 self.reduced_motion || (self.check_platform && !animations_enabled(false))
562 || self.policy.as_ref().is_some_and(|(policy, generation)| policy.interrupted_since(*generation))
563 }
564
565 fn evaluate(&self, progress: f64, reduced_motion: bool) -> f64 {
566 if reduced_motion
567 || self.seconds == 0.0
568 || self
569 .policy
570 .as_ref()
571 .is_some_and(|(policy, generation)| policy.interrupted_since(*generation))
572 {
573 return 1.0;
574 }
575 if progress.is_nan() || progress <= 0.0 {
576 return 0.0;
577 }
578 if progress >= 1.0 {
579 return 1.0;
580 }
581 self.spec
582 .sample(progress * self.seconds, 0.0, 1.0, 0.0)
583 .value
584 }
585}
586
587impl IEasing for MaterialSpringEasing {
588 fn ease(&self, progress: f64) -> f64 {
589 let reduced = self.reduced_motion || (self.check_platform && !animations_enabled(false));
590 self.evaluate(progress, reduced)
591 }
592
593 fn to_shared(&self) -> Arc<SharedEasing> {
594 Arc::new(Self {
595 reduced_motion: self.reduced_motion
596 || (self.check_platform && !animations_enabled(false)),
597 check_platform: false,
598 ..self.clone()
599 })
600 }
601}
602
603pub(crate) fn animations_enabled(reduced_motion: bool) -> bool {
604 !reduced_motion
605 && FerroLocator::current()
606 .get_service::<dyn IPlatformSettings>()
607 .is_none_or(|settings| settings.animations_enabled())
608}
609
610#[cfg(test)]
612pub(crate) struct MotionTestSettings {
613 base: ferroui_base::platform::DefaultPlatformSettings,
614 pub animations_enabled: std::cell::Cell<bool>,
615}
616
617#[cfg(test)]
618impl MotionTestSettings {
619 pub fn new() -> std::rc::Rc<Self> {
620 std::rc::Rc::new(Self {
621 base: ferroui_base::platform::DefaultPlatformSettings::new(),
622 animations_enabled: std::cell::Cell::new(true),
623 })
624 }
625}
626
627#[cfg(test)]
628impl IPlatformSettings for MotionTestSettings {
629 fn get_tap_size(&self, kind: ferroui_base::input::PointerType) -> ferroui_base::Size {
630 self.base.get_tap_size(kind)
631 }
632 fn get_double_tap_size(&self, kind: ferroui_base::input::PointerType) -> ferroui_base::Size {
633 self.base.get_double_tap_size(kind)
634 }
635 fn get_double_tap_time(&self, kind: ferroui_base::input::PointerType) -> std::time::Duration {
636 self.base.get_double_tap_time(kind)
637 }
638 fn hold_wait_duration(&self) -> std::time::Duration {
639 self.base.hold_wait_duration()
640 }
641 fn hotkey_configuration(
642 &self,
643 ) -> std::rc::Rc<ferroui_base::input::platform::PlatformHotkeyConfiguration> {
644 self.base.hotkey_configuration()
645 }
646 fn preferred_application_language(&self) -> String {
647 self.base.preferred_application_language()
648 }
649 fn animations_enabled(&self) -> bool {
650 self.animations_enabled.get()
651 }
652 fn get_color_values(&self) -> ferroui_base::platform::PlatformColorValues {
653 self.base.get_color_values()
654 }
655 fn color_values_changed(
656 &self,
657 handler: std::rc::Rc<dyn Fn(&ferroui_base::platform::PlatformColorValues)>,
658 ) -> std::rc::Rc<dyn ferroui_base::reactive::IDisposable> {
659 self.base.color_values_changed(handler)
660 }
661 fn preferred_application_language_changed(
662 &self,
663 handler: std::rc::Rc<dyn Fn()>,
664 ) -> std::rc::Rc<dyn ferroui_base::reactive::IDisposable> {
665 self.base.preferred_application_language_changed(handler)
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 fn close(expected: f64, actual: f64, epsilon: f64) {
674 assert!(
675 (expected - actual).abs() <= epsilon,
676 "expected {expected}, got {actual}"
677 );
678 }
679
680 #[test]
681 fn source_motion_tokens_are_preserved() {
682 let expressive = MaterialMotionScheme::Expressive;
683 assert_eq!(
684 MaterialSpringSpec::new(0.6, 800.0),
685 expressive.spring(MaterialMotionRole::FastSpatial)
686 );
687 assert_eq!(
688 MaterialSpringSpec::new(0.8, 380.0),
689 expressive.spring(MaterialMotionRole::DefaultSpatial)
690 );
691 assert_eq!(
692 MaterialSpringSpec::new(0.8, 200.0),
693 expressive.spring(MaterialMotionRole::SlowSpatial)
694 );
695 for role in [
696 MaterialMotionRole::FastEffects,
697 MaterialMotionRole::DefaultEffects,
698 MaterialMotionRole::SlowEffects,
699 ] {
700 assert_eq!(
701 expressive.spring(role),
702 MaterialMotionScheme::Standard.spring(role)
703 );
704 assert_eq!(1.0, expressive.spring(role).damping_ratio());
705 }
706 }
707
708 #[test]
709 fn critical_and_undamped_solutions_match_closed_form_reference() {
710 let critical = MaterialSpringSpec::new(1.0, 1600.0).sample(0.05, 0.0, 1.0, 0.0);
711 close(0.5939941502901619, critical.value, 1e-12);
712 close(10.826822658929016, critical.velocity, 1e-12);
713 let undamped = MaterialSpringSpec::new(0.0, 100.0).sample(0.1, 0.0, 1.0, 0.0);
714 close(0.45969769413186023, undamped.value, 1e-12);
715 close(8.414709848078965, undamped.velocity, 1e-12);
716 }
717
718 #[test]
719 fn position_and_velocity_are_continuous_across_critical_damping() {
720 let center = MaterialSpringSpec::new(1.0, 380.0);
721 for time in [0.00001, 0.01, 0.1, 1.0] {
722 let expected = center.sample(time, 2.0, -4.0, 17.0);
723 for damping in [1.0 - 1e-9, 1.0 + 1e-9] {
724 let sample = MaterialSpringSpec::new(damping, 380.0).sample(time, 2.0, -4.0, 17.0);
725 close(expected.value, sample.value, 1e-7);
726 close(expected.velocity, sample.velocity, 1e-6);
727 }
728 }
729 }
730
731 #[test]
732 fn velocity_is_the_derivative_in_every_damping_regime() {
733 for damping in [0.0, 0.6, 0.999999, 1.0, 1.000001, 2.0, 20.0] {
734 let spring = MaterialSpringSpec::new(damping, 380.0);
735 let step = 1e-6;
736 let left = spring.sample(0.15 - step, 12.0, -3.0, 5.0);
737 let right = spring.sample(0.15 + step, 12.0, -3.0, 5.0);
738 let center = spring.sample(0.15, 12.0, -3.0, 5.0);
739 close(
740 center.velocity,
741 (right.value - left.value) / (2.0 * step),
742 1e-5,
743 );
744 }
745 }
746
747 #[test]
748 fn retargeting_preserves_incoming_velocity_and_is_refresh_rate_independent() {
749 let spec = MaterialMotionScheme::Expressive.spring(MaterialMotionRole::FastSpatial);
750 let mut spring = MaterialSpringState::new(spec, 0.0);
751 spring.retarget(100.0);
752 let before = spring.advance(0.08);
753 spring.retarget(-20.0);
754 assert_eq!(before, spring.current());
755 assert_eq!(before, spring.advance(0.0));
756 let mut single_step = spring;
757 for _ in 0..30 {
758 spring.advance(1.0 / 120.0);
759 }
760 let expected = single_step.advance(0.25);
761 close(expected.value, spring.current().value, 1e-10);
762 close(expected.velocity, spring.current().velocity, 1e-10);
763 }
764
765 #[test]
766 fn spatial_motion_overshoots_but_effects_do_not() {
767 let scheme = MaterialMotionScheme::Expressive;
768 let spatial = scheme.spring(MaterialMotionRole::FastSpatial);
769 assert!((1..100).any(|i| spatial.sample(i as f64 / 100.0, 0.0, 1.0, 0.0).value > 1.01));
770 for role in [
771 MaterialMotionRole::FastEffects,
772 MaterialMotionRole::DefaultEffects,
773 MaterialMotionRole::SlowEffects,
774 ] {
775 let effect = scheme.spring(role);
776 let mut previous = 0.0;
777 for step in 0..100 {
778 let value = effect.sample(step as f64 / 100.0, 0.0, 1.0, 0.0).value;
779 assert!((previous..=1.0).contains(&value));
780 previous = value;
781 }
782 }
783 }
784
785 #[test]
786 fn transition_duration_preserves_physical_time_and_settles() {
787 for scheme in [
788 MaterialMotionScheme::Standard,
789 MaterialMotionScheme::Expressive,
790 ] {
791 for role in MaterialMotionRole::ALL {
792 let spec = scheme.spring(role);
793 let duration = scheme.duration(role, false);
794 let easing = MaterialSpringEasing::new(spec, duration, false);
795 let expected = spec.sample(0.05, 0.0, 1.0, 0.0).value;
796 close(
797 expected,
798 easing.ease(0.05 / duration.total_seconds()),
799 1e-12,
800 );
801 close(easing.ease(0.5), easing.to_shared().ease(0.5), 1e-12);
802 close(
803 1.0,
804 spec.sample(duration.total_seconds(), 0.0, 1.0, 0.0).value,
805 0.00101,
806 );
807 assert_eq!(0.0, easing.ease(0.0));
808 assert_eq!(1.0, easing.ease(1.0));
809 }
810 }
811 }
812
813 #[test]
814 fn interrupted_settling_bound_covers_high_momentum_and_every_damping_regime() {
815 for damping in [0.6, 0.8, 0.999999, 1.0, 1.000001, 2.0, 20.0] {
816 let spec = MaterialSpringSpec::new(damping, 380.0);
817 for (displacement, velocity) in [(0.0, 4000.0), (100.0, -3000.0), (-0.01, 1000.0), (0.0, 0.0)] {
818 let time = spec.settling_duration_from(displacement, velocity, 0.001, 0.01);
819 assert!(time.is_finite(), "damping={damping}, displacement={displacement}, velocity={velocity}");
820 for step in 0..50 {
821 let sample = spec.sample(time + step as f64 * 0.01, displacement, 0.0, velocity);
822 assert!(sample.value.abs() <= 0.00100001, "late displacement {sample:?} at {time}");
823 assert!(sample.velocity.abs() <= 0.0100001, "late velocity {sample:?} at {time}");
824 }
825 }
826 }
827 assert!(MaterialSpringSpec::new(0.0, 380.0).settling_duration_from(1.0, 0.0, 0.001, 0.01).is_infinite());
828 }
829
830 #[test]
831 fn reduced_motion_snaps_existing_and_new_targets() {
832 let scheme = MaterialMotionScheme::Expressive;
833 let role = MaterialMotionRole::DefaultSpatial;
834 assert_eq!(TimeSpan::ZERO, scheme.duration(role, true));
835 assert_eq!(1.0, scheme.easing(role, true).ease(0.0));
836 let mut state = MaterialSpringState::new(scheme.spring(role), 0.0);
837 state.retarget(40.0);
838 state.advance(0.01);
839 state.set_reduced_motion(true);
840 assert_eq!(
841 MaterialSpringSample {
842 value: 40.0,
843 velocity: 0.0
844 },
845 state.current()
846 );
847 state.retarget(90.0);
848 assert_eq!(
849 MaterialSpringSample {
850 value: 90.0,
851 velocity: 0.0
852 },
853 state.current()
854 );
855 }
856
857 #[test]
858 fn reducing_motion_cancels_captured_ui_and_render_easings_without_resuming_old_motion() {
859 let scheme = MaterialMotionScheme::Expressive;
860 let role = MaterialMotionRole::DefaultSpatial;
861 let policy = MaterialMotionPolicy::default();
862 let easing = scheme.easing_with_policy(role, &policy);
863 let shared = easing.to_shared();
864 assert!(easing.ease(0.1) < 1.0);
865 assert!(shared.ease(0.1) < 1.0);
866 policy.set_reduced_motion(true);
867 assert_eq!(1.0, easing.ease(0.2));
868 assert_eq!(1.0, shared.ease(0.2));
869 policy.set_reduced_motion(false);
870 assert_eq!(
871 1.0,
872 easing.ease(0.3),
873 "a canceled transition must not jump backwards"
874 );
875 assert_eq!(1.0, shared.ease(0.3));
876 let fresh = scheme.easing_with_policy(role, &policy);
877 assert!(
878 fresh.ease(0.1) < 1.0,
879 "new interactions animate after motion is reenabled"
880 );
881 assert!(fresh.to_shared().ease(0.1) < 1.0);
882 }
883
884 #[test]
885 fn platform_motion_preference_is_observed_during_a_running_ui_easing() {
886 use ferroui_controls::testing::{TestServices, UnitTestApplication};
887 let settings = MotionTestSettings::new();
888 let _application = UnitTestApplication::start(
889 TestServices::default().with_platform_settings(settings.clone()),
890 );
891 let easing =
892 MaterialMotionScheme::Expressive.easing(MaterialMotionRole::DefaultSpatial, false);
893 assert!(easing.ease(0.1) < 1.0);
894 settings.animations_enabled.set(false);
895 assert_eq!(1.0, easing.ease(0.2));
896 assert_eq!(1.0, easing.to_shared().ease(0.2));
897 }
898
899 #[test]
900 fn invalid_elapsed_time_does_not_poison_a_live_spring() {
901 let mut state = MaterialSpringState::new(MaterialSpringSpec::new(0.8, 380.0), 10.0);
902 state.retarget(20.0);
903 let initial = state.current();
904 assert_eq!(initial, state.advance(f64::NAN));
905 assert_eq!(initial, state.advance(-1.0));
906 assert_eq!(
907 MaterialSpringSample {
908 value: 20.0,
909 velocity: 0.0
910 },
911 state.advance(f64::INFINITY)
912 );
913 }
914
915 #[test]
916 fn extreme_finite_clock_values_do_not_overflow_spring_phases() {
917 for damping in [0.6, 1.0, 2.0] {
918 let sample = MaterialSpringSpec::new(damping, 380.0).sample(f64::MAX, 12.0, -3.0, 5.0);
919 assert_eq!(
920 MaterialSpringSample {
921 value: -3.0,
922 velocity: 0.0
923 },
924 sample
925 );
926 }
927 let undamped = MaterialSpringSpec::new(0.0, 1.0e308).sample(f64::MAX, 12.0, -3.0, 5.0);
928 assert!(undamped.value.is_finite());
929 assert!(undamped.velocity.is_finite());
930 let overdamped = MaterialSpringSpec::new(1.0e308, 380.0).sample(0.25, 12.0, -3.0, 5.0);
931 assert!(overdamped.value.is_finite());
932 assert!(overdamped.velocity.is_finite());
933 close(12.0, overdamped.value, 1e-12);
934 }
935
936 #[test]
937 #[should_panic(expected = "invalid spring stiffness")]
938 fn rejects_invalid_stiffness() {
939 MaterialSpringSpec::new(0.8, f64::NAN);
940 }
941}