Skip to main content

open_gpui_motion/
spring.rs

1//! Renderer-neutral spring sampling for layout-like UI motion.
2
3use crate::{MotionPreference, MotionRunState, MotionSpec, MotionTimeline};
4use std::time::{Duration, Instant};
5
6const DEFAULT_MASS: f32 = 1.0;
7const DEFAULT_STIFFNESS: f32 = 260.0;
8const DEFAULT_DAMPING: f32 = 28.0;
9const DEFAULT_REST_DELTA: f32 = 0.001;
10const DEFAULT_REST_SPEED: f32 = 0.01;
11const MIN_POSITIVE: f32 = 0.000_001;
12const MAX_PHYSICS_VALUE: f32 = 1_000_000.0;
13
14/// Reviewable spring presets for layout-like UI motion.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum MotionSpringPreset {
17    /// Short, subtle affordance motion.
18    Affordance,
19    /// Committed layout motion such as collapse, expand, insert, or remove.
20    Layout,
21    /// Continuity motion for retargeted pane, divider, or zoom transitions.
22    Continuity,
23}
24
25/// Renderer-neutral spring physics parameters.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct MotionSpringPhysics {
28    mass: f32,
29    stiffness: f32,
30    damping: f32,
31    rest_delta: f32,
32    rest_speed: f32,
33    bounce: f32,
34    review_duration: Duration,
35}
36
37impl MotionSpringPhysics {
38    /// Maximum bounce value accepted by professional UI policy.
39    pub const MAX_REVIEWABLE_BOUNCE: f32 = 0.35;
40
41    /// Creates sanitized spring physics parameters.
42    pub fn new(mass: f32, stiffness: f32, damping: f32) -> Self {
43        Self {
44            mass: sanitize_positive(mass, DEFAULT_MASS),
45            stiffness: sanitize_positive(stiffness, DEFAULT_STIFFNESS),
46            damping: sanitize_non_negative(damping, DEFAULT_DAMPING),
47            rest_delta: DEFAULT_REST_DELTA,
48            rest_speed: DEFAULT_REST_SPEED,
49            bounce: 0.0,
50            review_duration: Duration::from_millis(180),
51        }
52    }
53
54    /// Returns the mass parameter.
55    pub const fn mass(self) -> f32 {
56        self.mass
57    }
58
59    /// Returns the stiffness parameter.
60    pub const fn stiffness(self) -> f32 {
61        self.stiffness
62    }
63
64    /// Returns the damping parameter.
65    pub const fn damping(self) -> f32 {
66        self.damping
67    }
68
69    /// Returns the position rest threshold.
70    pub const fn rest_delta(self) -> f32 {
71        self.rest_delta
72    }
73
74    /// Returns the velocity rest threshold.
75    pub const fn rest_speed(self) -> f32 {
76        self.rest_speed
77    }
78
79    /// Returns the review-facing bounce value.
80    pub const fn bounce(self) -> f32 {
81        self.bounce
82    }
83
84    /// Returns the expected review duration for policy checks.
85    pub const fn review_duration(self) -> Duration {
86        self.review_duration
87    }
88
89    /// Returns a copy with a sanitized position rest threshold.
90    pub fn with_rest_delta(mut self, rest_delta: f32) -> Self {
91        self.rest_delta = sanitize_positive(rest_delta, DEFAULT_REST_DELTA);
92        self
93    }
94
95    /// Returns a copy with a sanitized velocity rest threshold.
96    pub fn with_rest_speed(mut self, rest_speed: f32) -> Self {
97        self.rest_speed = sanitize_positive(rest_speed, DEFAULT_REST_SPEED);
98        self
99    }
100
101    /// Returns a copy with a review-facing bounce value clamped to policy range.
102    pub fn with_bounce(mut self, bounce: f32) -> Self {
103        self.bounce = if bounce.is_finite() {
104            bounce.clamp(0.0, Self::MAX_REVIEWABLE_BOUNCE)
105        } else {
106            0.0
107        };
108        self
109    }
110
111    /// Returns a copy with the expected review duration used by policy checks.
112    pub fn with_review_duration(mut self, duration: Duration) -> Self {
113        self.review_duration = duration;
114        self
115    }
116}
117
118/// Renderer-neutral spring motion specification.
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub struct MotionSpringSpec {
121    preference: MotionPreference,
122    preset: Option<MotionSpringPreset>,
123    physics: MotionSpringPhysics,
124}
125
126impl MotionSpringSpec {
127    /// Creates a spring spec from preference and explicit physics.
128    pub fn from_physics(preference: MotionPreference, physics: MotionSpringPhysics) -> Self {
129        Self {
130            preference,
131            preset: None,
132            physics,
133        }
134    }
135
136    /// Creates the default affordance spring spec for the given preference.
137    pub fn affordance(preference: MotionPreference) -> Self {
138        Self::from_preset(
139            preference,
140            MotionSpringPreset::Affordance,
141            MotionSpringPhysics::new(1.0, 320.0, 34.0)
142                .with_bounce(0.08)
143                .with_review_duration(Duration::from_millis(120)),
144        )
145    }
146
147    /// Creates the default committed layout spring spec for the given preference.
148    pub fn layout(preference: MotionPreference) -> Self {
149        Self::from_preset(
150            preference,
151            MotionSpringPreset::Layout,
152            MotionSpringPhysics::new(1.0, 260.0, 28.0)
153                .with_bounce(0.12)
154                .with_review_duration(Duration::from_millis(180)),
155        )
156    }
157
158    /// Creates the default continuity spring spec for the given preference.
159    pub fn continuity(preference: MotionPreference) -> Self {
160        Self::from_preset(
161            preference,
162            MotionSpringPreset::Continuity,
163            MotionSpringPhysics::new(1.0, 190.0, 23.0)
164                .with_bounce(0.10)
165                .with_review_duration(Duration::from_millis(260)),
166        )
167    }
168
169    fn from_preset(
170        preference: MotionPreference,
171        preset: MotionSpringPreset,
172        physics: MotionSpringPhysics,
173    ) -> Self {
174        Self {
175            preference,
176            preset: Some(preset),
177            physics,
178        }
179    }
180
181    /// Returns the motion preference.
182    pub const fn preference(self) -> MotionPreference {
183        self.preference
184    }
185
186    /// Returns the optional reviewable preset.
187    pub const fn preset(self) -> Option<MotionSpringPreset> {
188        self.preset
189    }
190
191    /// Returns the sanitized physics parameters.
192    pub const fn physics(self) -> MotionSpringPhysics {
193        self.physics
194    }
195
196    /// Returns whether this spring completes immediately.
197    pub const fn is_immediate(self) -> bool {
198        self.preference.is_immediate()
199    }
200
201    /// Returns a copy with review-facing bounce clamped to policy range.
202    pub fn with_bounce(mut self, bounce: f32) -> Self {
203        self.physics = self.physics.with_bounce(bounce);
204        self
205    }
206}
207
208/// Explicit preset that resolves to a concrete motion model.
209#[derive(Debug, Clone, Copy, PartialEq)]
210pub enum MotionPreset {
211    /// Immediate completion.
212    Immediate,
213    /// Duration/easing timeline motion.
214    Timeline(MotionSpec),
215    /// Explicit spring motion.
216    Spring(MotionSpringSpec),
217    /// Default committed layout spring.
218    CommittedLayout(MotionPreference),
219    /// Default continuity spring.
220    Continuity(MotionPreference),
221    /// Default affordance spring.
222    Affordance(MotionPreference),
223}
224
225impl MotionPreset {
226    /// Creates an immediate preset.
227    pub const fn immediate() -> Self {
228        Self::Immediate
229    }
230
231    /// Creates an explicit timeline preset.
232    pub const fn timeline(spec: MotionSpec) -> Self {
233        Self::Timeline(spec)
234    }
235
236    /// Creates an explicit spring preset.
237    pub const fn spring(spec: MotionSpringSpec) -> Self {
238        Self::Spring(spec)
239    }
240
241    /// Creates the default committed layout preset.
242    pub const fn committed_layout(preference: MotionPreference) -> Self {
243        Self::CommittedLayout(preference)
244    }
245
246    /// Creates the default continuity preset.
247    pub const fn continuity(preference: MotionPreference) -> Self {
248        Self::Continuity(preference)
249    }
250
251    /// Creates the default affordance preset.
252    pub const fn affordance(preference: MotionPreference) -> Self {
253        Self::Affordance(preference)
254    }
255
256    /// Resolves this preset to the concrete motion model that should execute.
257    pub fn resolve_model(self) -> MotionModel {
258        match self {
259            Self::Immediate => MotionModel::timeline(MotionSpec::immediate()),
260            Self::Timeline(spec) => MotionModel::timeline(spec),
261            Self::Spring(spec) => MotionModel::spring(spec),
262            Self::CommittedLayout(preference) => {
263                MotionModel::spring(MotionSpringSpec::layout(preference))
264            }
265            Self::Continuity(preference) => {
266                MotionModel::spring(MotionSpringSpec::continuity(preference))
267            }
268            Self::Affordance(preference) => {
269                MotionModel::spring(MotionSpringSpec::affordance(preference))
270            }
271        }
272    }
273}
274
275/// A sampled point on a scalar motion model.
276#[derive(Debug, Clone, Copy, PartialEq)]
277pub struct MotionScalarSample {
278    state: MotionRunState,
279    elapsed: Duration,
280    value: f32,
281    velocity: f32,
282    target: f32,
283}
284
285impl MotionScalarSample {
286    /// Creates a scalar motion sample from explicit values.
287    pub const fn new(
288        state: MotionRunState,
289        elapsed: Duration,
290        value: f32,
291        velocity: f32,
292        target: f32,
293    ) -> Self {
294        Self {
295            state,
296            elapsed,
297            value,
298            velocity,
299            target,
300        }
301    }
302
303    /// Returns the sampled state.
304    pub const fn state(self) -> MotionRunState {
305        self.state
306    }
307
308    /// Returns the elapsed duration used for this sample.
309    pub const fn elapsed(self) -> Duration {
310        self.elapsed
311    }
312
313    /// Returns the sampled scalar value.
314    pub const fn value(self) -> f32 {
315        self.value
316    }
317
318    /// Returns the sampled scalar velocity.
319    pub const fn velocity(self) -> f32 {
320        self.velocity
321    }
322
323    /// Returns the target scalar value.
324    pub const fn target(self) -> f32 {
325        self.target
326    }
327
328    /// Returns whether callers should continue requesting frames.
329    pub const fn is_active(self) -> bool {
330        self.state.is_active()
331    }
332
333    /// Returns whether the semantic final state has been reached.
334    pub const fn reached_final_state(self) -> bool {
335        self.state.reached_final_state()
336    }
337}
338
339/// A deterministic scalar spring transition.
340#[derive(Debug, Clone, Copy, PartialEq)]
341pub struct MotionSpring {
342    spec: MotionSpringSpec,
343    from: f32,
344    target: f32,
345    initial_velocity: f32,
346    started_at: Instant,
347    cancelled_at: Option<Instant>,
348}
349
350impl MotionSpring {
351    /// Creates a scalar spring transition.
352    pub fn new(
353        spec: MotionSpringSpec,
354        from: f32,
355        target: f32,
356        initial_velocity: f32,
357        started_at: Instant,
358    ) -> Self {
359        let from = sanitize_number(from, 0.0);
360        Self {
361            spec,
362            from,
363            target: sanitize_number(target, from),
364            initial_velocity: sanitize_number(initial_velocity, 0.0),
365            started_at,
366            cancelled_at: None,
367        }
368    }
369
370    /// Creates a new spring whose source and velocity come from an interrupted sample.
371    pub fn retarget_from_sample(
372        spec: MotionSpringSpec,
373        sample: MotionScalarSample,
374        target: f32,
375        started_at: Instant,
376    ) -> Self {
377        Self::new(spec, sample.value(), target, sample.velocity(), started_at)
378    }
379
380    /// Returns the spring specification.
381    pub const fn spec(self) -> MotionSpringSpec {
382        self.spec
383    }
384
385    /// Returns the source value.
386    pub const fn from(self) -> f32 {
387        self.from
388    }
389
390    /// Returns the target value.
391    pub const fn target(self) -> f32 {
392        self.target
393    }
394
395    /// Returns the initial velocity.
396    pub const fn initial_velocity(self) -> f32 {
397        self.initial_velocity
398    }
399
400    /// Returns the instant at which the spring started.
401    pub const fn started_at(self) -> Instant {
402        self.started_at
403    }
404
405    /// Returns the instant at which the spring was cancelled.
406    pub const fn cancelled_at(self) -> Option<Instant> {
407        self.cancelled_at
408    }
409
410    /// Marks the spring as cancelled at the provided instant.
411    pub fn cancel_at(&mut self, cancelled_at: Instant) {
412        self.cancelled_at = Some(cancelled_at);
413    }
414
415    /// Samples the spring at the provided instant.
416    pub fn sample(self, now: Instant) -> MotionScalarSample {
417        let effective_now = self.cancelled_at.unwrap_or(now);
418        let elapsed = effective_now.saturating_duration_since(self.started_at);
419        let mut sample = Self::sample_elapsed(
420            self.spec,
421            self.from,
422            self.target,
423            self.initial_velocity,
424            elapsed,
425        );
426        if self.cancelled_at.is_some() && !sample.reached_final_state() {
427            sample.state = MotionRunState::Cancelled;
428        }
429        sample
430    }
431
432    /// Samples a spring using an explicit elapsed duration.
433    pub fn sample_elapsed(
434        spec: MotionSpringSpec,
435        from: f32,
436        target: f32,
437        initial_velocity: f32,
438        elapsed: Duration,
439    ) -> MotionScalarSample {
440        let from = sanitize_number(from, 0.0);
441        let target = sanitize_number(target, from);
442        let initial_velocity = sanitize_number(initial_velocity, 0.0);
443
444        if spec.is_immediate() {
445            return MotionScalarSample::new(
446                MotionRunState::Immediate,
447                elapsed,
448                target,
449                0.0,
450                target,
451            );
452        }
453
454        let physics = spec.physics();
455        if elapsed.is_zero() {
456            let state = if at_rest(from, target, initial_velocity, physics) {
457                MotionRunState::Completed
458            } else {
459                MotionRunState::Active
460            };
461            let value = if state.reached_final_state() {
462                target
463            } else {
464                from
465            };
466            let velocity = if state.reached_final_state() {
467                0.0
468            } else {
469                initial_velocity
470            };
471            return MotionScalarSample::new(state, elapsed, value, velocity, target);
472        }
473
474        let (value, velocity) =
475            sample_spring_value(physics, from, target, initial_velocity, elapsed);
476        if at_rest(value, target, velocity, physics) {
477            MotionScalarSample::new(MotionRunState::Completed, elapsed, target, 0.0, target)
478        } else {
479            MotionScalarSample::new(MotionRunState::Active, elapsed, value, velocity, target)
480        }
481    }
482}
483
484/// Shared motion model wrapper for timeline and spring transitions.
485#[derive(Debug, Clone, Copy, PartialEq)]
486pub enum MotionModel {
487    /// Duration/easing timeline motion.
488    Timeline(MotionSpec),
489    /// Velocity-aware spring motion.
490    Spring(MotionSpringSpec),
491}
492
493impl MotionModel {
494    /// Creates a timeline motion model.
495    pub const fn timeline(spec: MotionSpec) -> Self {
496        Self::Timeline(spec)
497    }
498
499    /// Creates a spring motion model.
500    pub const fn spring(spec: MotionSpringSpec) -> Self {
501        Self::Spring(spec)
502    }
503
504    /// Returns the motion preference.
505    pub const fn preference(self) -> MotionPreference {
506        match self {
507            Self::Timeline(spec) => spec.preference(),
508            Self::Spring(spec) => spec.preference(),
509        }
510    }
511
512    /// Returns whether this model completes immediately.
513    pub const fn is_immediate(self) -> bool {
514        match self {
515            Self::Timeline(spec) => spec.is_immediate(),
516            Self::Spring(spec) => spec.is_immediate(),
517        }
518    }
519
520    /// Returns the duration used when this model participates in a sequence plan.
521    ///
522    /// Timeline models use their exact duration. Spring models use their review-duration hint,
523    /// because physical completion is still determined by sampled rest state.
524    pub const fn sequence_duration_hint(self) -> Duration {
525        match self {
526            Self::Timeline(spec) => spec.duration().as_duration(),
527            Self::Spring(spec) => spec.physics().review_duration(),
528        }
529    }
530
531    /// Samples this motion model as a scalar value.
532    pub fn sample_scalar_elapsed(
533        self,
534        from: f32,
535        target: f32,
536        initial_velocity: f32,
537        elapsed: Duration,
538    ) -> MotionScalarSample {
539        match self {
540            Self::Timeline(spec) => {
541                let sample = MotionTimeline::sample_elapsed(spec, elapsed);
542                let from = sanitize_number(from, 0.0);
543                let target = sanitize_number(target, from);
544                let value = from + (target - from) * sample.progress();
545                let duration = spec.duration().as_duration().as_secs_f32();
546                let velocity = if sample.reached_final_state() || duration <= 0.0 {
547                    0.0
548                } else {
549                    (target - from) / duration
550                };
551                MotionScalarSample::new(sample.state(), sample.elapsed(), value, velocity, target)
552            }
553            Self::Spring(spec) => {
554                MotionSpring::sample_elapsed(spec, from, target, initial_velocity, elapsed)
555            }
556        }
557    }
558}
559
560fn sample_spring_value(
561    physics: MotionSpringPhysics,
562    from: f32,
563    target: f32,
564    initial_velocity: f32,
565    elapsed: Duration,
566) -> (f32, f32) {
567    let t = elapsed.as_secs_f32().max(0.0);
568    let displacement = from - target;
569    let mass = physics.mass();
570    let stiffness = physics.stiffness();
571    let damping = physics.damping();
572    let angular_frequency = (stiffness / mass).sqrt();
573    let critical_damping = 2.0 * (stiffness * mass).sqrt();
574    let damping_ratio = if critical_damping > 0.0 {
575        damping / critical_damping
576    } else {
577        1.0
578    };
579
580    let (displacement, velocity) = if damping_ratio < 1.0 {
581        sample_underdamped(
582            displacement,
583            initial_velocity,
584            angular_frequency,
585            damping_ratio,
586            t,
587        )
588    } else if (damping_ratio - 1.0).abs() <= f32::EPSILON {
589        sample_critically_damped(displacement, initial_velocity, angular_frequency, t)
590    } else {
591        sample_overdamped(
592            displacement,
593            initial_velocity,
594            angular_frequency,
595            damping_ratio,
596            t,
597        )
598    };
599
600    let value = sanitize_number(target + displacement, target);
601    let velocity = sanitize_number(velocity, 0.0);
602    (value, velocity)
603}
604
605fn sample_underdamped(
606    displacement: f32,
607    velocity: f32,
608    angular_frequency: f32,
609    damping_ratio: f32,
610    time: f32,
611) -> (f32, f32) {
612    let damped_frequency = angular_frequency * (1.0 - damping_ratio * damping_ratio).sqrt();
613    if damped_frequency <= MIN_POSITIVE {
614        return sample_critically_damped(displacement, velocity, angular_frequency, time);
615    }
616
617    let decay = (-damping_ratio * angular_frequency * time).exp();
618    let sin = (damped_frequency * time).sin();
619    let cos = (damped_frequency * time).cos();
620    let a = displacement;
621    let b = (velocity + damping_ratio * angular_frequency * displacement) / damped_frequency;
622    let oscillation = a * cos + b * sin;
623    let sampled_displacement = decay * oscillation;
624    let sampled_velocity = decay
625        * ((-a * damped_frequency * sin + b * damped_frequency * cos)
626            - damping_ratio * angular_frequency * oscillation);
627    (sampled_displacement, sampled_velocity)
628}
629
630fn sample_critically_damped(
631    displacement: f32,
632    velocity: f32,
633    angular_frequency: f32,
634    time: f32,
635) -> (f32, f32) {
636    let decay = (-angular_frequency * time).exp();
637    let c = velocity + angular_frequency * displacement;
638    let displacement_term = displacement + c * time;
639    let sampled_displacement = decay * displacement_term;
640    let sampled_velocity = decay * (c - angular_frequency * displacement_term);
641    (sampled_displacement, sampled_velocity)
642}
643
644fn sample_overdamped(
645    displacement: f32,
646    velocity: f32,
647    angular_frequency: f32,
648    damping_ratio: f32,
649    time: f32,
650) -> (f32, f32) {
651    let ratio_delta = (damping_ratio * damping_ratio - 1.0).sqrt();
652    let r1 = -angular_frequency * (damping_ratio - ratio_delta);
653    let r2 = -angular_frequency * (damping_ratio + ratio_delta);
654    if (r1 - r2).abs() <= MIN_POSITIVE {
655        return sample_critically_damped(displacement, velocity, angular_frequency, time);
656    }
657    let c1 = (velocity - r2 * displacement) / (r1 - r2);
658    let c2 = displacement - c1;
659    let e1 = (r1 * time).exp();
660    let e2 = (r2 * time).exp();
661    let sampled_displacement = c1 * e1 + c2 * e2;
662    let sampled_velocity = c1 * r1 * e1 + c2 * r2 * e2;
663    (sampled_displacement, sampled_velocity)
664}
665
666fn at_rest(value: f32, target: f32, velocity: f32, physics: MotionSpringPhysics) -> bool {
667    (value - target).abs() <= physics.rest_delta() && velocity.abs() <= physics.rest_speed()
668}
669
670fn sanitize_number(value: f32, default: f32) -> f32 {
671    if value.is_finite() { value } else { default }
672}
673
674fn sanitize_positive(value: f32, default: f32) -> f32 {
675    if value.is_finite() && value > 0.0 {
676        value.clamp(MIN_POSITIVE, MAX_PHYSICS_VALUE)
677    } else {
678        default
679    }
680}
681
682fn sanitize_non_negative(value: f32, default: f32) -> f32 {
683    if value.is_finite() && value >= 0.0 {
684        value.clamp(0.0, MAX_PHYSICS_VALUE)
685    } else {
686        default
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use crate::MotionPreference;
694    use std::time::{Duration, Instant};
695
696    #[test]
697    fn layout_spring_samples_active_motion_and_reaches_exact_target_at_rest() {
698        let started_at = Instant::now();
699        let spring = MotionSpring::new(
700            MotionSpringSpec::layout(MotionPreference::Animated),
701            0.0,
702            1.0,
703            0.0,
704            started_at,
705        );
706
707        let start = spring.sample(started_at);
708        assert_eq!(start.state(), MotionRunState::Active);
709        assert_eq!(start.elapsed(), Duration::ZERO);
710        assert_eq!(start.value(), 0.0);
711        assert_eq!(start.target(), 1.0);
712
713        let midpoint = spring.sample(started_at + Duration::from_millis(90));
714        assert_eq!(midpoint.state(), MotionRunState::Active);
715        assert!(midpoint.value() > 0.0);
716        assert!(midpoint.value() < 1.08);
717        assert!(midpoint.velocity().is_finite());
718
719        let complete = spring.sample(started_at + Duration::from_secs(2));
720        assert_eq!(complete.state(), MotionRunState::Completed);
721        assert_eq!(complete.value(), 1.0);
722        assert_eq!(complete.velocity(), 0.0);
723        assert!(complete.reached_final_state());
724    }
725
726    #[test]
727    fn tiny_delta_uses_rest_thresholds_without_oscillating_forever() {
728        let sample = MotionSpring::sample_elapsed(
729            MotionSpringSpec::layout(MotionPreference::Animated),
730            10.0,
731            10.0001,
732            0.0,
733            Duration::from_millis(600),
734        );
735
736        assert_eq!(sample.state(), MotionRunState::Completed);
737        assert_eq!(sample.value(), 10.0001);
738    }
739
740    #[test]
741    fn spring_bounce_defaults_are_subtle_and_clamped() {
742        let layout = MotionSpringSpec::layout(MotionPreference::Animated);
743        assert!(layout.physics().bounce() <= 0.20);
744
745        let clamped = layout.with_bounce(2.0);
746        assert!(clamped.physics().bounce() <= MotionSpringPhysics::MAX_REVIEWABLE_BOUNCE);
747    }
748
749    #[test]
750    fn retargeted_spring_preserves_current_position_and_velocity() {
751        let started_at = Instant::now();
752        let spec = MotionSpringSpec::layout(MotionPreference::Animated);
753        let spring = MotionSpring::new(spec, 0.0, 1.0, 0.0, started_at);
754        let sampled_at = started_at + Duration::from_millis(80);
755        let sampled = spring.sample(sampled_at);
756
757        let retargeted = MotionSpring::retarget_from_sample(spec, sampled, 2.0, sampled_at);
758        let retarget_start = retargeted.sample(sampled_at);
759
760        assert_eq!(retarget_start.value(), sampled.value());
761        assert_eq!(retarget_start.velocity(), sampled.velocity());
762        assert_eq!(retarget_start.target(), 2.0);
763    }
764
765    #[test]
766    fn reduced_motion_spring_returns_final_semantic_sample() {
767        let sample = MotionSpring::sample_elapsed(
768            MotionSpringSpec::layout(MotionPreference::Reduced),
769            0.0,
770            1.0,
771            20.0,
772            Duration::from_millis(16),
773        );
774
775        assert_eq!(sample.state(), MotionRunState::Immediate);
776        assert_eq!(sample.value(), 1.0);
777        assert_eq!(sample.velocity(), 0.0);
778        assert!(sample.reached_final_state());
779    }
780
781    #[test]
782    fn invalid_physics_parameters_are_sanitized() {
783        let physics = MotionSpringPhysics::new(f32::NAN, -1.0, f32::INFINITY)
784            .with_rest_delta(f32::NAN)
785            .with_rest_speed(-20.0);
786        let sample = MotionSpring::sample_elapsed(
787            MotionSpringSpec::from_physics(MotionPreference::Animated, physics),
788            0.0,
789            1.0,
790            f32::NAN,
791            Duration::from_millis(80),
792        );
793
794        assert!(sample.value().is_finite());
795        assert!(sample.velocity().is_finite());
796    }
797
798    #[test]
799    fn motion_model_wraps_timeline_and_spring_specs() {
800        let timeline = MotionModel::timeline(crate::MotionSpec::layout(MotionPreference::Animated));
801        let spring = MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Animated));
802
803        assert!(!timeline.is_immediate());
804        assert!(!spring.is_immediate());
805        assert_eq!(spring.preference(), MotionPreference::Animated);
806    }
807
808    #[test]
809    fn motion_preset_resolves_default_springs_explicitly() {
810        let custom_timeline = MotionPreset::timeline(crate::MotionSpec::new(
811            MotionPreference::Animated,
812            crate::MotionDuration::Custom(Duration::from_millis(240)),
813            crate::MotionEasing::Linear,
814        ))
815        .resolve_model();
816        assert!(matches!(custom_timeline, MotionModel::Timeline(_)));
817
818        let committed = MotionPreset::committed_layout(MotionPreference::Animated).resolve_model();
819        assert!(matches!(
820            committed,
821            MotionModel::Spring(spec)
822                if spec.preset() == Some(MotionSpringPreset::Layout)
823        ));
824
825        let continuity = MotionPreset::continuity(MotionPreference::Animated).resolve_model();
826        assert!(matches!(
827            continuity,
828            MotionModel::Spring(spec)
829                if spec.preset() == Some(MotionSpringPreset::Continuity)
830        ));
831    }
832
833    #[test]
834    fn motion_model_samples_timeline_and_spring_as_scalar_values() {
835        let timeline = MotionModel::timeline(crate::MotionSpec::new(
836            MotionPreference::Animated,
837            crate::MotionDuration::Custom(Duration::from_millis(200)),
838            crate::MotionEasing::Linear,
839        ));
840        let timeline_sample =
841            timeline.sample_scalar_elapsed(0.0, 10.0, 0.0, Duration::from_millis(100));
842
843        assert_eq!(timeline_sample.state(), MotionRunState::Active);
844        assert_eq!(timeline_sample.value(), 5.0);
845        assert_eq!(timeline_sample.target(), 10.0);
846
847        let spring = MotionModel::spring(MotionSpringSpec::layout(MotionPreference::Animated));
848        let spring_sample =
849            spring.sample_scalar_elapsed(0.0, 10.0, 0.0, Duration::from_millis(100));
850
851        assert_eq!(spring_sample.state(), MotionRunState::Active);
852        assert!(spring_sample.value() > 0.0);
853        assert_eq!(spring_sample.target(), 10.0);
854        assert!(spring_sample.velocity().is_finite());
855    }
856}