Skip to main content

ferroui_themes_material/
motion.rs

1// Copyright 2024 The Android Open Source Project (motion tokens).
2// Copyright 2026 FerroUI contributors (analytical evaluator and runtime integration).
3// SPDX-License-Identifier: Apache-2.0
4//! Material 3 Expressive motion, connected to FerroUI's transition and compositor APIs.
5//!
6//! The spring constants follow AndroidX Material3 1.5.0-beta02, commit
7//! `e4d933c98df7baa7ea2b83eea6e086915961e0ff`, `ExpressiveMotionTokens.kt` and
8//! `StandardMotionTokens.kt` (Copyright The Android Open Source Project, Apache-2.0).
9//! The analytical evaluator and the FerroUI adapters are native Rust implementations.
10//!
11//! Spatial motion may overshoot. Effects motion is critically damped: use it for colors,
12//! opacity and the button's pressed shape. In particular, the upstream button intentionally
13//! uses `DefaultEffects`, while the switch thumb uses `FastSpatial`.
14
15use 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/// Shared per-theme cancellation state. The low bit is the current accessibility setting;
23/// the remaining bits count entries into reduced motion. An easing retains its creation
24/// generation, so a canceled transition cannot resume part-way through if motion is enabled
25/// again before FerroUI's original duration expires. The same state is safe on render threads.
26#[derive(Clone, Debug, Default)]
27pub(crate) struct MaterialMotionPolicy {
28    state: Arc<AtomicU64>,
29}
30
31impl MaterialMotionPolicy {
32    // fetch_update is the name available on the workspace Rust 1.89 baseline.
33    #[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/// The two motion schemes published by Material Design.
55#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub enum MaterialMotionScheme {
57    /// Subtle motion for utilitarian interfaces.
58    Standard,
59    /// The Material 3 Expressive spring scheme.
60    #[default]
61    Expressive,
62}
63
64ferroui_base::ferro_markup_enum!(
65    MaterialMotionScheme { Standard, Expressive },
66    { namespace: "FerroUI.Themes.Material" }
67);
68
69/// A semantic motion role. Spatial and effects roles must not be interchanged.
70#[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    /// All the semantic roles, in their resource-installation order.
82    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    /// The role's suffix in `MaterialMotion{Role}Duration` and `MaterialMotion{Role}Easing`.
92    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    /// The exact unit-mass spring parameters of the upstream motion role.
106    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    /// The finite duration used to host this spring in FerroUI's duration-based transitions.
123    ///
124    /// This is a settling-time adapter, not a Material duration token: the easing evaluates
125    /// the source spring in real seconds and the duration only bounds its imperceptible tail.
126    /// Install this value with the matching [`Self::easing`] resource.
127    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    /// A real FerroUI easing for `DoubleTransition`, `BrushTransition`, shape transitions,
136    /// keyframe animations and compositor animations.
137    ///
138    /// The adapter additionally reads `IPlatformSettings::animations_enabled` on the UI
139    /// thread. Its render-thread snapshot is immutable and contains no thread-local access.
140    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    /// Theme resources share a live accessibility policy even after a running transition has
149    /// copied its easing. Merely replacing a DynamicResource cannot cancel that old copy.
150    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/// The position and velocity of a spring. Velocity is measured in value units per second.
165#[derive(Clone, Copy, Debug, Default, PartialEq)]
166pub struct MaterialSpringSample {
167    pub value: f64,
168    pub velocity: f64,
169}
170
171/// Parameters of a unit-mass damped spring: `x'' + 2 ζ √k x' + k (x-target) = 0`.
172#[derive(Clone, Copy, Debug, PartialEq)]
173pub struct MaterialSpringSpec {
174    damping_ratio: f64,
175    stiffness: f64,
176}
177
178impl MaterialSpringSpec {
179    /// Creates a spring. Stiffness must be positive and finite; damping must be finite and
180    /// nonnegative. A zero-damping spring oscillates forever and has no settling duration.
181    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    /// Evaluates both position and velocity analytically, independent of display refresh rate.
205    ///
206    /// All three damping regimes are supported. The overdamped case uses a stable slow root
207    /// and `expm1` to avoid subtracting nearly equal exponentials near critical damping.
208    /// The underdamped case uses `sinc`, so its limit at critical damping is also well behaved.
209    /// A negative/NaN elapsed time leaves the initial state unchanged; positive infinity is
210    /// treated as the settled state. Initial and target values must be finite.
211    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            // A finite elapsed time can overflow the phase of an undamped spring. Reduce
251            // time by its period in that case, so a suspended clock cannot create NaNs.
252            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            // (zeta + sqrt(zeta^2-1))/zeta, without overflowing either zeta^2 or
287            // zeta + sqrt(zeta^2-1). The reciprocal slow root avoids cancellation.
288            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            // Divide by the fast root first; even a fast root beyond f64's range
292            // then has the correct finite limiting coefficient.
293            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    /// A conservative finite tail for the unit step, with position tolerance `0.001` and
310    /// velocity tolerance `0.001 * sqrt(stiffness)`. Returns infinity for an undamped spring.
311    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        // For the nonoscillatory unit step, displacement decreases monotonically. Once its
323        // displacement is this small, velocity is also on its decreasing branch. Bracket then
324        // bisect rather than advancing an Euler simulation with a refresh-rate-dependent step.
325        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    /// A conservative settling time for an arbitrary interrupted spring, in seconds.
350    ///
351    /// Both the position and velocity stay below their respective tolerances after this
352    /// time. Unlike the unit-step adapter, this accounts for momentum when the new target
353    /// is very close to the current position. Bounds are analytical exponential envelopes;
354    /// this does not advance a simulation or mistake an oscillation's zero crossing for rest.
355    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        // Use fixed-size coefficients: this calculation allocates nothing when a control
383        // changes target, and no duration search is performed on animation frames.
384        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/// A velocity-continuous spring for interactive controls whose target may change mid-flight.
433///
434/// Call [`Self::advance`] with frame time, then [`Self::retarget`] on an input change. Retargeting
435/// preserves both the current position and its physical velocity, including when the target
436/// reverses direction. No allocations or numerical integration are involved.
437#[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    /// Changes the motion scheme without discontinuously resetting the live state.
486    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/// Bridges a physical spring to FerroUI's normalized-progress `IEasing` interface.
530///
531/// Use the matching duration; the same spring with an unrelated duration changes its physical
532/// speed. This adapter starts with zero velocity because `IEasing` has no velocity channel.
533/// Controls needing interruption continuity use [`MaterialSpringState`] instead.
534#[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    /// Parameters consumed by the velocity-preserving Material property transitions.
556    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/// Reusable platform-accessibility fixture for the theme's motion and indicator tests.
611#[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}