Skip to main content

gpui_base/
motion.rs

1#[cfg(not(target_family = "wasm"))]
2use std::time::Instant;
3use std::{rc::Rc, time::Duration};
4#[cfg(target_family = "wasm")]
5use web_time::Instant;
6
7use gpui::{
8    App, Bounds, ElementId, Pixels, SharedString, Size, SpringConfig, SpringState, SpringTarget,
9    Window,
10};
11
12use crate::animation::{Lerp, ease_out_cubic};
13
14mod easing;
15mod keyframes;
16mod presence;
17mod reveal;
18mod stagger;
19mod timing;
20
21pub use easing::{Easing, EasingError, LinearStop, StepPosition};
22pub use keyframes::{Discrete, DiscreteError, Keyframe, KeyframeError, Keyframes};
23pub use presence::{Presence, PresencePhase, PresenceSample};
24pub use reveal::MotionReveal;
25pub use stagger::{Stagger, StaggerOrigin};
26pub use timing::{
27    IterationCount, MotionPhase, PlaybackDirection, SignedDuration, Timing, TimingSample,
28};
29
30/// Matches GPUI's own default spring settling tolerance.
31const DEFAULT_SPRING_EPSILON: f32 = 0.001;
32
33/// A value that can be interpolated between two application-owned targets.
34pub trait Interpolate: Clone {
35    fn interpolate(&self, target: &Self, progress: f32) -> Self;
36}
37
38impl<T: Lerp> Interpolate for T {
39    fn interpolate(&self, target: &Self, progress: f32) -> Self {
40        self.lerp(target, progress)
41    }
42}
43
44impl Interpolate for Size<Pixels> {
45    fn interpolate(&self, target: &Self, progress: f32) -> Self {
46        Size::new(
47            self.width.lerp(&target.width, progress),
48            self.height.lerp(&target.height, progress),
49        )
50    }
51}
52
53impl Interpolate for Bounds<Pixels> {
54    fn interpolate(&self, target: &Self, progress: f32) -> Self {
55        Bounds::new(
56            self.origin.lerp(&target.origin, progress),
57            self.size.interpolate(&target.size, progress),
58        )
59    }
60}
61
62/// A presentation-neutral bundle for coordinated paint transforms.
63#[derive(Clone, Copy, Debug, PartialEq)]
64pub struct MotionTransform {
65    pub translation: gpui::Point<Pixels>,
66    pub scale: gpui::Point<f32>,
67    pub rotation_radians: f32,
68    pub opacity: f32,
69}
70
71impl MotionTransform {
72    pub fn identity() -> Self {
73        Self {
74            translation: gpui::point(gpui::px(0.0), gpui::px(0.0)),
75            scale: gpui::point(1.0, 1.0),
76            rotation_radians: 0.0,
77            opacity: 1.0,
78        }
79    }
80}
81
82impl Default for MotionTransform {
83    fn default() -> Self {
84        Self::identity()
85    }
86}
87
88impl Interpolate for MotionTransform {
89    fn interpolate(&self, target: &Self, progress: f32) -> Self {
90        Self {
91            translation: self.translation.lerp(&target.translation, progress),
92            scale: gpui::point(
93                self.scale.x.lerp(&target.scale.x, progress),
94                self.scale.y.lerp(&target.scale.y, progress),
95            ),
96            rotation_radians: self
97                .rotation_radians
98                .lerp(&target.rotation_radians, progress),
99            opacity: self.opacity.lerp(&target.opacity, progress),
100        }
101    }
102}
103
104/// CSS-like timing policy for a target-value transition.
105///
106/// This type is intentionally separate from [`crate::animation::Transition`],
107/// whose legacy interface applies concrete fade, slide, and size effects to an
108/// element. A value transition never chooses a visual property for the caller.
109#[derive(Clone)]
110pub struct Transition {
111    duration: Duration,
112    delay: SignedDuration,
113    easing: Easing,
114}
115
116impl Transition {
117    pub fn new(duration: Duration) -> Self {
118        Self {
119            duration,
120            delay: SignedDuration::ZERO,
121            easing: Easing::Custom(Rc::new(ease_out_cubic)),
122        }
123    }
124
125    pub fn delay(mut self, delay: impl Into<SignedDuration>) -> Self {
126        self.delay = delay.into();
127        self
128    }
129
130    pub fn ease(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self {
131        self.easing = Easing::Custom(Rc::new(easing));
132        self
133    }
134
135    pub fn easing(mut self, easing: Easing) -> Self {
136        self.easing = easing;
137        self
138    }
139
140    fn sample(&self, progress: f32) -> f32 {
141        self.easing.sample(progress)
142    }
143
144    fn progress(&self, elapsed: Duration, duration: Duration) -> (f32, MotionStatus) {
145        let Some(active_elapsed) = self.delay.active_elapsed(elapsed) else {
146            return (0.0, MotionStatus::Delayed);
147        };
148        if duration.is_zero() || active_elapsed >= duration {
149            return (1.0, MotionStatus::Finished);
150        }
151        (
152            active_elapsed.as_secs_f32() / duration.as_secs_f32(),
153            MotionStatus::Running,
154        )
155    }
156}
157
158impl From<Duration> for SignedDuration {
159    fn from(duration: Duration) -> Self {
160        Self::positive(duration)
161    }
162}
163
164/// Identifies one independently transitioning value.
165#[derive(Clone, Debug, Eq, Hash, PartialEq)]
166pub struct TransitionId(ElementId);
167
168impl From<ElementId> for TransitionId {
169    fn from(id: ElementId) -> Self {
170        Self(id)
171    }
172}
173
174impl From<&'static str> for TransitionId {
175    fn from(id: &'static str) -> Self {
176        Self(id.into())
177    }
178}
179
180impl From<String> for TransitionId {
181    fn from(id: String) -> Self {
182        Self(id.into())
183    }
184}
185
186impl From<SharedString> for TransitionId {
187    fn from(id: SharedString) -> Self {
188        Self(id.into())
189    }
190}
191
192impl From<usize> for TransitionId {
193    fn from(id: usize) -> Self {
194        Self(id.into())
195    }
196}
197
198impl From<i32> for TransitionId {
199    fn from(id: i32) -> Self {
200        Self(id.into())
201    }
202}
203
204impl From<TransitionId> for ElementId {
205    fn from(id: TransitionId) -> Self {
206        ElementId::NamedChild(id.0.into(), "__base-transition-state".into())
207    }
208}
209
210impl<I, C> From<(I, C)> for TransitionId
211where
212    I: Into<ElementId>,
213    C: Into<SharedString>,
214{
215    fn from((id, channel): (I, C)) -> Self {
216        Self(ElementId::NamedChild(id.into().into(), channel.into()))
217    }
218}
219
220#[derive(Clone)]
221struct ValueTransition<T> {
222    from: T,
223    target: T,
224    started_at: Instant,
225    reversing_factor: f32,
226    duration: Duration,
227}
228
229#[derive(Clone, Copy, Debug, Eq, PartialEq)]
230pub enum MotionStatus {
231    Idle,
232    Delayed,
233    Running,
234    Finished,
235}
236
237#[derive(Clone, Copy, Debug, PartialEq)]
238pub struct MotionValue<T> {
239    pub value: T,
240    pub status: MotionStatus,
241}
242
243/// Returns the current value for a CSS-like transition toward `target`.
244///
245/// State is keyed by `id`. The first value is adopted immediately; later target
246/// changes transition from the value sampled at that instant. Components opt
247/// into this function explicitly—base components do not install default motion.
248///
249/// Call this while rendering an element, where GPUI keyed element state is
250/// available. A channel id must identify one value type within that element.
251pub fn transition<T>(
252    id: impl Into<TransitionId>,
253    target: T,
254    policy: Transition,
255    window: &mut Window,
256    cx: &mut App,
257) -> T
258where
259    T: Interpolate + PartialEq + 'static,
260{
261    transition_with_status(id, target, policy, window, cx).value
262}
263
264pub fn transition_with_status<T>(
265    id: impl Into<TransitionId>,
266    target: T,
267    policy: Transition,
268    window: &mut Window,
269    cx: &mut App,
270) -> MotionValue<T>
271where
272    T: Interpolate + PartialEq + 'static,
273{
274    let id: ElementId = id.into().into();
275    let now = cx.background_executor().now();
276    let state = window.use_keyed_state(id, cx, |_, _| ValueTransition {
277        from: target.clone(),
278        target: target.clone(),
279        started_at: now,
280        reversing_factor: 1.0,
281        duration: policy.duration,
282    });
283
284    let snapshot = state.read(cx).clone();
285
286    if cx.reduce_motion() || policy.duration.is_zero() {
287        if snapshot.from != target || snapshot.target != target {
288            state.update(cx, |state, _| {
289                state.from = target.clone();
290                state.target = target.clone();
291                state.started_at = now;
292                state.reversing_factor = 1.0;
293                state.duration = policy.duration;
294            });
295        }
296        return MotionValue {
297            value: target,
298            status: MotionStatus::Finished,
299        };
300    }
301
302    let elapsed = now.saturating_duration_since(snapshot.started_at);
303    let (progress, status) = policy.progress(elapsed, snapshot.duration);
304    let sampled = snapshot
305        .from
306        .interpolate(&snapshot.target, policy.sample(progress));
307
308    let (value, status) = if snapshot.target != target {
309        let reversing = target == snapshot.from;
310        let reversing_factor = if reversing {
311            (policy.sample(progress) * snapshot.reversing_factor
312                + (1.0 - snapshot.reversing_factor))
313                .clamp(0.0, 1.0)
314        } else {
315            1.0
316        };
317        let duration = policy.duration.mul_f32(reversing_factor);
318        state.update(cx, |state, _| {
319            state.from = sampled.clone();
320            state.target = target.clone();
321            state.started_at = now;
322            state.reversing_factor = reversing_factor;
323            state.duration = duration;
324        });
325        let (initial_progress, initial_status) = policy.progress(Duration::ZERO, duration);
326        (
327            sampled.interpolate(&target, policy.sample(initial_progress)),
328            initial_status,
329        )
330    } else {
331        (
332            sampled,
333            if snapshot.from == snapshot.target {
334                MotionStatus::Idle
335            } else {
336                status
337            },
338        )
339    };
340    if matches!(status, MotionStatus::Delayed | MotionStatus::Running) {
341        window.request_animation_frame();
342    }
343    MotionValue { value, status }
344}
345
346#[derive(Clone, Copy)]
347struct KeyframePlayback {
348    started_at: Instant,
349}
350
351/// Samples a keyed keyframe playback and requests frames while it is active.
352///
353/// The stable `id` owns the playback's start time. Re-rendering with the same
354/// ID continues that playback; it does not restart when `keyframes` or `timing`
355/// is reconstructed. To replay a sequence, include an application-owned
356/// generation in the ID, for example `("notification-enter", generation)`.
357pub fn animate_keyframes<T>(
358    id: impl Into<TransitionId>,
359    keyframes: &Keyframes<T>,
360    timing: Timing,
361    window: &mut Window,
362    cx: &mut App,
363) -> MotionValue<T>
364where
365    T: Interpolate + 'static,
366{
367    let id: TransitionId = id.into();
368    let id = ElementId::NamedChild(ElementId::from(id).into(), "__keyframes".into());
369    let now = cx.background_executor().now();
370    let state = window.use_keyed_state(id, cx, |_, _| KeyframePlayback { started_at: now });
371    let started_at = state.read(cx).started_at;
372
373    if cx.reduce_motion() {
374        return MotionValue {
375            value: keyframes.sample(1.0),
376            status: MotionStatus::Finished,
377        };
378    }
379
380    let sample = timing.sample(now.saturating_duration_since(started_at));
381    let status = match sample.phase {
382        MotionPhase::Before => MotionStatus::Delayed,
383        MotionPhase::Active => MotionStatus::Running,
384        MotionPhase::After => MotionStatus::Finished,
385    };
386    if matches!(status, MotionStatus::Delayed | MotionStatus::Running) {
387        window.request_animation_frame();
388    }
389    MotionValue {
390        value: keyframes.sample(sample.directed_progress),
391        status,
392    }
393}
394
395/// A physical spring policy for [`spring`].
396///
397/// A spring is the counterpart to [`Transition`] for values that can be
398/// retargeted while they are still moving. A duration-based transition restarts
399/// its easing from the value sampled at that instant, which is continuous in
400/// position but not in velocity. A spring carries velocity across the retarget,
401/// so a value reversed mid-flight decelerates and turns around instead of
402/// snapping to a new curve's initial speed.
403#[derive(Clone, Copy, Debug)]
404pub struct Spring {
405    response: Duration,
406    damping: f32,
407    epsilon: f32,
408    travel: bool,
409}
410
411/// Invalid physical or settling parameters for a [`Spring`].
412#[derive(Clone, Copy, Debug, Eq, PartialEq)]
413pub enum SpringError {
414    InvalidDamping,
415    InvalidEpsilon,
416}
417
418impl std::fmt::Display for SpringError {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        match self {
421            Self::InvalidDamping => f.write_str("spring damping must be finite and non-negative"),
422            Self::InvalidEpsilon => {
423                f.write_str("spring epsilon must be finite and greater than zero")
424            }
425        }
426    }
427}
428
429impl std::error::Error for SpringError {}
430
431impl Spring {
432    /// Builds a spring that reaches its target in about `response` without
433    /// overshooting it.
434    ///
435    /// `response` is not a duration in the sense [`Transition::new`] means one.
436    /// A spring has no end to schedule: this is the period one full oscillation
437    /// would take without damping, which is the scale the motion is felt at
438    /// rather than the moment it stops. The remaining fraction of a percent
439    /// keeps settling past it, until it is within the tolerance
440    /// [`Self::with_epsilon`] sets.
441    ///
442    /// A zero response adopts the target on the spot, as a zero duration does
443    /// for a transition. Say that with [`Self::with_travel`] where it is what
444    /// you mean; a zero here is the degenerate case, defined so an infinitely
445    /// stiff spring resolves rather than dividing by its own period.
446    pub const fn new(response: Duration) -> Self {
447        Self {
448            response,
449            damping: 1.0,
450            epsilon: DEFAULT_SPRING_EPSILON,
451            travel: true,
452        }
453    }
454
455    /// Sets the damping ratio, which is `1.0` — no overshoot — by default.
456    ///
457    /// Below `1.0` the spring passes its target and comes back; above `1.0` it
458    /// approaches slowly. Overshoot suits a value with room to pass its target
459    /// and nothing to collide with. A height, an opacity, or anything bounded by
460    /// the geometry around it should stay at the default.
461    ///
462    /// This is $\zeta$, not GPUI's `SpringConfig::damping`, which is the
463    /// coefficient $c = 2 \zeta \omega_0$.
464    ///
465    /// # Panics
466    ///
467    /// Panics when `ratio` is negative or non-finite. Use
468    /// [`Self::try_with_damping`] when the value is not a trusted constant.
469    pub const fn with_damping(self, ratio: f32) -> Self {
470        match self.try_with_damping(ratio) {
471            Ok(spring) => spring,
472            Err(_) => panic!("spring damping must be finite and non-negative"),
473        }
474    }
475
476    /// Checked form of [`Self::with_damping`].
477    pub const fn try_with_damping(mut self, ratio: f32) -> Result<Self, SpringError> {
478        if !ratio.is_finite() || ratio < 0.0 {
479            return Err(SpringError::InvalidDamping);
480        }
481        self.damping = ratio;
482        Ok(self)
483    }
484
485    /// Sets whether the spring travels to its target or adopts it on the spot.
486    ///
487    /// A value the pointer is already moving — a panel being dragged by its
488    /// resize handle — must not lag behind the pointer, so the spring stops
489    /// travelling for as long as the drag lasts. Retained state stays pinned to
490    /// the target meanwhile, so travel resumes from the value the drag released
491    /// rather than from wherever the spring was when it began.
492    ///
493    /// This says at the call that the motion is suspended, and it says it
494    /// without disturbing the response, damping or tolerance the spring is
495    /// configured with — which a policy swapped out for the length of the drag
496    /// would have to restate or discard.
497    pub const fn with_travel(mut self, travel: bool) -> Self {
498        self.travel = travel;
499        self
500    }
501
502    /// Sets the settling tolerance, expressed in the target's own units.
503    ///
504    /// The default suits targets that move within a normalized `0..1` range. A
505    /// spring over pixels settles perceptibly sooner with a coarser tolerance,
506    /// which also ends the animation frames that the remaining sub-pixel motion
507    /// would otherwise request.
508    ///
509    /// # Panics
510    ///
511    /// Panics when `epsilon` is zero, negative, or non-finite. Use
512    /// [`Self::try_with_epsilon`] when the value is not a trusted constant.
513    pub const fn with_epsilon(self, epsilon: f32) -> Self {
514        match self.try_with_epsilon(epsilon) {
515            Ok(spring) => spring,
516            Err(_) => panic!("spring epsilon must be finite and greater than zero"),
517        }
518    }
519
520    /// Checked form of [`Self::with_epsilon`].
521    pub const fn try_with_epsilon(mut self, epsilon: f32) -> Result<Self, SpringError> {
522        if !epsilon.is_finite() || epsilon <= 0.0 {
523            return Err(SpringError::InvalidEpsilon);
524        }
525        self.epsilon = epsilon;
526        Ok(self)
527    }
528
529    /// Returns the settling tolerance in the target's own units.
530    pub const fn epsilon(self) -> f32 {
531        self.epsilon
532    }
533
534    /// The physical parameters GPUI integrates. The response must be non-zero;
535    /// [`spring`] adopts the target before reaching here when it is not.
536    ///
537    /// Derived on use rather than stored, so the builders stay `const`: neither
538    /// `Duration::as_secs_f32` nor the square root that recovers a damping ratio
539    /// from a built config can be called from a `const fn`.
540    fn config(&self) -> SpringConfig {
541        let frequency = std::f32::consts::TAU / self.response.as_secs_f32();
542        SpringConfig::new(frequency * frequency, 2.0 * self.damping * frequency, 1.0)
543    }
544}
545
546#[derive(Clone, Copy)]
547struct SpringTransition {
548    state: SpringState,
549    target: f32,
550    updated_at: Instant,
551}
552
553/// Returns the current value for a spring travelling toward `target`.
554///
555/// State is keyed by `id` exactly as [`transition`] keys its own. The first
556/// value is adopted immediately; later target changes preserve both the current
557/// position and the current velocity, so an interrupted spring is redirected
558/// rather than restarted.
559///
560/// Call this while rendering an element, where GPUI keyed element state is
561/// available. A channel id must identify one value within that element.
562pub fn spring<T>(
563    id: impl Into<TransitionId>,
564    target: T,
565    policy: Spring,
566    window: &mut Window,
567    cx: &mut App,
568) -> T::Output
569where
570    T: SpringTarget,
571{
572    let id: ElementId = id.into().into();
573    let now = cx.background_executor().now();
574    let target_position = target.target();
575    let state = window.use_keyed_state(id, cx, |_, _| SpringTransition {
576        state: SpringState {
577            position: target_position,
578            velocity: 0.0,
579        },
580        target: target_position,
581        updated_at: now,
582    });
583
584    let snapshot = *state.read(cx);
585    let at_rest_on_target =
586        snapshot.state.position == target_position && snapshot.state.velocity == 0.0;
587
588    // The overwhelmingly common case: a spring nothing is currently moving. It
589    // has no state to advance and no frame to ask for, so it never builds a
590    // config or steps one — a settled spring costs a read and two comparisons.
591    // Every branch below would return this same value and write nothing.
592    //
593    // Resting writes nothing, so `updated_at` goes stale for as long as the rest
594    // lasts. The next retarget then steps a zero displacement at zero velocity
595    // over that whole gap, which any elapsed time leaves where it is, so the
596    // stale clock cannot move the value — it only has to not produce a NaN, and
597    // every term the propagator scales is finite.
598    if at_rest_on_target {
599        return target.resolve(target_position);
600    }
601
602    let settle = |state: &mut SpringTransition| {
603        state.state = SpringState {
604            position: target_position,
605            velocity: 0.0,
606        };
607        state.target = target_position;
608        state.updated_at = now;
609    };
610
611    if cx.reduce_motion() || !policy.travel || policy.response.is_zero() {
612        state.update(cx, |state, _| settle(state));
613        return target.resolve(target_position);
614    }
615
616    // Advance over the frame that just elapsed, which the previous target
617    // governed, before adopting the new one for the frame to come.
618    let elapsed = now
619        .saturating_duration_since(snapshot.updated_at)
620        .as_secs_f32();
621    let config = policy.config();
622    let stepped = config.step(snapshot.state, snapshot.target, elapsed);
623
624    if config.is_settled(stepped, target_position, policy.epsilon) {
625        state.update(cx, |state, _| settle(state));
626        return target.resolve(target_position);
627    }
628
629    state.update(cx, |state, _| {
630        state.state = stepped;
631        state.target = target_position;
632        state.updated_at = now;
633    });
634    window.request_animation_frame();
635    target.resolve(stepped.position)
636}
637
638#[cfg(test)]
639mod css_timing_tests {
640    use super::{
641        Easing, IterationCount, LinearStop, MotionPhase, PlaybackDirection, SignedDuration,
642        StepPosition, Timing,
643    };
644    use std::time::Duration;
645
646    #[test]
647    fn css_keyword_easing_matches_published_reference_samples() {
648        for (easing, samples) in [
649            (Easing::Ease, [(0.2, 0.295), (0.5, 0.802), (0.8, 0.976)]),
650            (Easing::EaseIn, [(0.2, 0.062), (0.5, 0.315), (0.8, 0.692)]),
651            (Easing::EaseOut, [(0.2, 0.308), (0.5, 0.685), (0.8, 0.938)]),
652            (Easing::EaseInOut, [(0.2, 0.082), (0.5, 0.5), (0.8, 0.918)]),
653        ] {
654            for (progress, expected) in samples {
655                let actual = easing.sample(progress);
656                assert!(
657                    (actual - expected).abs() < 0.002,
658                    "{easing:?}({progress}) = {actual}, expected {expected}"
659                );
660            }
661        }
662    }
663
664    #[test]
665    fn step_easing_observes_css_jump_positions() {
666        let start = Easing::steps(4, StepPosition::JumpStart).unwrap();
667        let end = Easing::steps(4, StepPosition::JumpEnd).unwrap();
668
669        assert_eq!(start.sample(0.0), 0.25);
670        assert_eq!(start.sample(0.24), 0.25);
671        assert_eq!(start.sample(0.25), 0.5);
672        assert_eq!(end.sample(0.0), 0.0);
673        assert_eq!(end.sample(0.24), 0.0);
674        assert_eq!(end.sample(0.25), 0.25);
675        assert!(Easing::steps(0, StepPosition::JumpEnd).is_err());
676
677        let none = Easing::steps(4, StepPosition::JumpNone).unwrap();
678        let both = Easing::steps(4, StepPosition::JumpBoth).unwrap();
679        assert_eq!(none.sample(0.0), 0.0);
680        assert!((none.sample(0.5) - 2.0 / 3.0).abs() < f32::EPSILON);
681        assert_eq!(none.sample(1.0), 1.0);
682        assert_eq!(both.sample(0.0), 0.2);
683        assert_eq!(both.sample(1.0), 1.0);
684        assert!(Easing::steps(1, StepPosition::JumpNone).is_err());
685    }
686
687    #[test]
688    fn linear_stops_fill_omitted_positions_before_sampling() {
689        let easing = Easing::linear_stops([
690            LinearStop::at(0.0, 0.0),
691            LinearStop::new(0.2),
692            LinearStop::new(0.8),
693            LinearStop::at(1.0, 1.0),
694        ])
695        .unwrap();
696
697        assert!((easing.sample(1.0 / 3.0) - 0.2).abs() < 1e-6);
698        assert!((easing.sample(0.5) - 0.5).abs() < 1e-6);
699        assert!(
700            Easing::linear_stops([LinearStop::at(0.0, 0.8), LinearStop::at(1.0, 0.2)]).is_err()
701        );
702    }
703
704    #[test]
705    fn negative_delay_starts_inside_the_active_interval() {
706        let timing = Timing::new(Duration::from_millis(100))
707            .delay(SignedDuration::negative(Duration::from_millis(25)));
708        let sample = timing.sample(Duration::ZERO);
709
710        assert_eq!(sample.phase, MotionPhase::Active);
711        assert!((sample.directed_progress - 0.25).abs() < f32::EPSILON);
712        assert!(sample.active);
713        assert!(!sample.finished);
714    }
715
716    #[test]
717    fn alternate_direction_reverses_odd_iterations() {
718        let timing = Timing::new(Duration::from_millis(100))
719            .iterations(IterationCount::Finite(2))
720            .direction(PlaybackDirection::Alternate)
721            .ease(Easing::Linear);
722
723        let first = timing.sample(Duration::from_millis(25));
724        let second = timing.sample(Duration::from_millis(125));
725        let finished = timing.sample(Duration::from_millis(200));
726
727        assert_eq!(first.iteration, 0);
728        assert_eq!(first.directed_progress, 0.25);
729        assert_eq!(second.iteration, 1);
730        assert_eq!(second.directed_progress, 0.75);
731        assert_eq!(finished.phase, MotionPhase::After);
732        assert_eq!(finished.directed_progress, 0.0);
733        assert!(finished.finished);
734    }
735}
736
737#[cfg(test)]
738mod motion_track_tests {
739    use super::{
740        Discrete, Easing, Interpolate as _, Keyframe, KeyframeError, Keyframes, MotionTransform,
741        Stagger, StaggerOrigin,
742    };
743    use gpui::{Bounds, Point, Size, point, px, size};
744    use std::time::Duration;
745
746    #[test]
747    fn keyframes_validate_offsets_and_sample_each_segments_easing() {
748        assert!(matches!(
749            Keyframes::try_new([Keyframe::new(0.2, 0.0_f32), Keyframe::new(1.0, 1.0_f32),]),
750            Err(KeyframeError::MissingEndpoint)
751        ));
752        assert!(matches!(
753            Keyframes::try_new([
754                Keyframe::new(0.0, 0.0_f32),
755                Keyframe::new(0.8, 1.0_f32),
756                Keyframe::new(0.7, 2.0_f32),
757                Keyframe::new(1.0, 3.0_f32),
758            ]),
759            Err(KeyframeError::OffsetsNotMonotonic)
760        ));
761
762        let track = Keyframes::try_new([
763            Keyframe::new(0.0, 0.0_f32)
764                .ease(Easing::steps(2, super::StepPosition::JumpEnd).unwrap()),
765            Keyframe::new(0.5, 10.0_f32).ease(Easing::Linear),
766            Keyframe::new(1.0, 20.0_f32),
767        ])
768        .unwrap();
769
770        assert_eq!(track.sample(0.2), 0.0);
771        assert_eq!(track.sample(0.3), 5.0);
772        assert_eq!(track.sample(0.75), 15.0);
773        assert_eq!(track.sample(1.0), 20.0);
774    }
775
776    #[test]
777    fn discrete_values_switch_only_at_the_requested_progress() {
778        let value = Discrete::new("old", "new").switch_at(0.75).unwrap();
779        assert_eq!(value.sample(0.749), "old");
780        assert_eq!(value.sample(0.75), "new");
781        assert!(Discrete::new(0, 1).switch_at(f32::NAN).is_err());
782    }
783
784    #[test]
785    fn stagger_origins_produce_stable_delays_without_allocating_a_schedule() {
786        let interval = Duration::from_millis(20);
787        let first = Stagger::new(interval, StaggerOrigin::First);
788        let last = Stagger::new(interval, StaggerOrigin::Last);
789        let center = Stagger::new(interval, StaggerOrigin::Center);
790
791        assert_eq!(first.delay(3, 5), Duration::from_millis(60));
792        assert_eq!(last.delay(3, 5), Duration::from_millis(20));
793        assert_eq!(center.delay(2, 5), Duration::ZERO);
794        assert_eq!(center.delay(0, 5), Duration::from_millis(40));
795        assert_eq!(first.delay(7, 0), Duration::ZERO);
796    }
797
798    #[test]
799    fn common_gpui_geometry_interpolates_channel_by_channel() {
800        let from_size = size(px(10.0), px(20.0));
801        let to_size = size(px(30.0), px(60.0));
802        assert_eq!(
803            from_size.interpolate(&to_size, 0.25),
804            size(px(15.0), px(30.0))
805        );
806
807        let from = Bounds::new(point(px(0.0), px(10.0)), from_size);
808        let to = Bounds::new(point(px(40.0), px(50.0)), to_size);
809        assert_eq!(
810            from.interpolate(&to, 0.5),
811            Bounds::new(point(px(20.0), px(30.0)), size(px(20.0), px(40.0)))
812        );
813
814        let _: Point<gpui::Pixels> = from.origin;
815        let _: Size<gpui::Pixels> = from.size;
816
817        let transform = MotionTransform::identity().interpolate(
818            &MotionTransform {
819                translation: point(px(20.0), px(40.0)),
820                scale: point(2.0, 0.5),
821                rotation_radians: std::f32::consts::PI,
822                opacity: 0.0,
823            },
824            0.5,
825        );
826        assert_eq!(transform.translation, point(px(10.0), px(20.0)));
827        assert_eq!(transform.scale, point(1.5, 0.75));
828        assert_eq!(transform.rotation_radians, std::f32::consts::FRAC_PI_2);
829        assert_eq!(transform.opacity, 0.5);
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use std::{
836        cell::{Cell, RefCell},
837        rc::Rc,
838        time::Duration,
839    };
840
841    use gpui::{Empty, IntoElement, Render, TestAppContext, WindowHandle, px, size};
842
843    use super::*;
844
845    struct StatusView {
846        target: Rc<Cell<f32>>,
847        policy: Transition,
848        samples: Rc<RefCell<Vec<MotionValue<f32>>>>,
849    }
850
851    impl Render for StatusView {
852        fn render(
853            &mut self,
854            window: &mut Window,
855            cx: &mut gpui::Context<Self>,
856        ) -> impl IntoElement {
857            self.samples.borrow_mut().push(transition_with_status(
858                ("status-test", "value"),
859                self.target.get(),
860                self.policy.clone(),
861                window,
862                cx,
863            ));
864            Empty
865        }
866    }
867
868    struct StatusFixture {
869        window: WindowHandle<StatusView>,
870        target: Rc<Cell<f32>>,
871        samples: Rc<RefCell<Vec<MotionValue<f32>>>>,
872    }
873
874    impl StatusFixture {
875        fn open(cx: &mut TestAppContext, policy: Transition) -> Self {
876            let target = Rc::new(Cell::new(0.0));
877            let samples = Rc::new(RefCell::new(Vec::new()));
878            let window = cx.open_window(size(px(100.), px(100.)), {
879                let target = target.clone();
880                let samples = samples.clone();
881                move |_, _| StatusView {
882                    target,
883                    policy,
884                    samples,
885                }
886            });
887            cx.run_until_parked();
888            Self {
889                window,
890                target,
891                samples,
892            }
893        }
894
895        fn render(&self, cx: &mut TestAppContext, target: f32) -> MotionValue<f32> {
896            self.target.set(target);
897            self.window
898                .update(cx, |_, window, _| window.refresh())
899                .unwrap();
900            cx.run_until_parked();
901            *self.samples.borrow().last().unwrap()
902        }
903    }
904
905    #[gpui::test]
906    fn status_transition_reports_delay_running_and_finished(cx: &mut TestAppContext) {
907        let fixture = StatusFixture::open(
908            cx,
909            Transition::new(Duration::from_millis(100)).delay(Duration::from_millis(20)),
910        );
911        assert_eq!(fixture.render(cx, 1.0).status, MotionStatus::Delayed);
912
913        cx.executor().advance_clock(Duration::from_millis(20));
914        assert_eq!(fixture.render(cx, 1.0).status, MotionStatus::Running);
915        cx.executor().advance_clock(Duration::from_millis(100));
916        assert_eq!(fixture.render(cx, 1.0).status, MotionStatus::Finished);
917    }
918
919    #[gpui::test]
920    fn negative_delay_samples_a_target_change_inside_its_interval(cx: &mut TestAppContext) {
921        let fixture = StatusFixture::open(
922            cx,
923            Transition::new(Duration::from_millis(100))
924                .delay(SignedDuration::negative(Duration::from_millis(25)))
925                .ease(|t| t),
926        );
927        let sample = fixture.render(cx, 1.0);
928        assert_eq!(sample.status, MotionStatus::Running);
929        assert_eq!(sample.value, 0.25);
930    }
931
932    #[gpui::test]
933    fn a_direct_reversal_shortens_the_return_transition(cx: &mut TestAppContext) {
934        let fixture =
935            StatusFixture::open(cx, Transition::new(Duration::from_millis(100)).ease(|t| t));
936        assert_eq!(fixture.render(cx, 1.0).value, 0.0);
937        cx.executor().advance_clock(Duration::from_millis(50));
938        assert_eq!(fixture.render(cx, 0.0).value, 0.5);
939        cx.executor().advance_clock(Duration::from_millis(25));
940        assert_eq!(fixture.render(cx, 0.0).value, 0.25);
941    }
942
943    struct KeyframeView {
944        track: Keyframes<f32>,
945        timing: Timing,
946        samples: Rc<RefCell<Vec<MotionValue<f32>>>>,
947    }
948
949    impl Render for KeyframeView {
950        fn render(
951            &mut self,
952            window: &mut Window,
953            cx: &mut gpui::Context<Self>,
954        ) -> impl IntoElement {
955            self.samples.borrow_mut().push(animate_keyframes(
956                "keyframe-test",
957                &self.track,
958                self.timing.clone(),
959                window,
960                cx,
961            ));
962            Empty
963        }
964    }
965
966    #[gpui::test]
967    fn keyed_keyframes_follow_timing_and_stop_after_completion(cx: &mut TestAppContext) {
968        let samples = Rc::new(RefCell::new(Vec::new()));
969        let window = cx.open_window(size(px(100.), px(100.)), {
970            let samples = samples.clone();
971            move |_, _| KeyframeView {
972                track: Keyframes::try_new([Keyframe::new(0.0, 0.0), Keyframe::new(1.0, 10.0)])
973                    .unwrap(),
974                timing: Timing::new(Duration::from_millis(100)),
975                samples,
976            }
977        });
978        cx.run_until_parked();
979        assert_eq!(samples.borrow().last().unwrap().value, 0.0);
980        assert_eq!(
981            samples.borrow().last().unwrap().status,
982            MotionStatus::Running
983        );
984
985        cx.executor().advance_clock(Duration::from_millis(50));
986        assert_eq!(
987            window
988                .update(cx, |_, window, cx| window.simulate_next_frame(cx))
989                .unwrap(),
990            1
991        );
992        cx.run_until_parked();
993        assert_eq!(samples.borrow().last().unwrap().value, 5.0);
994
995        cx.executor().advance_clock(Duration::from_millis(50));
996        window.update(cx, |_, window, _| window.refresh()).unwrap();
997        cx.run_until_parked();
998        assert_eq!(
999            samples.borrow().last().unwrap().status,
1000            MotionStatus::Finished
1001        );
1002        window
1003            .update(cx, |_, window, cx| window.simulate_next_frame(cx))
1004            .unwrap();
1005        cx.run_until_parked();
1006        assert_eq!(
1007            window
1008                .update(cx, |_, window, cx| window.simulate_next_frame(cx))
1009                .unwrap(),
1010            0
1011        );
1012    }
1013
1014    struct PresenceView {
1015        present: Rc<Cell<bool>>,
1016        samples: Rc<RefCell<Vec<PresenceSample>>>,
1017    }
1018
1019    impl Render for PresenceView {
1020        fn render(
1021            &mut self,
1022            window: &mut Window,
1023            cx: &mut gpui::Context<Self>,
1024        ) -> impl IntoElement {
1025            self.samples.borrow_mut().push(
1026                Presence::new("presence-test", self.present.get())
1027                    .transition(Transition::new(Duration::from_millis(100)).ease(|t| t))
1028                    .sample(window, cx),
1029            );
1030            Empty
1031        }
1032    }
1033
1034    struct PresenceFixture {
1035        window: WindowHandle<PresenceView>,
1036        present: Rc<Cell<bool>>,
1037        samples: Rc<RefCell<Vec<PresenceSample>>>,
1038    }
1039
1040    impl PresenceFixture {
1041        fn open(cx: &mut TestAppContext, initially_present: bool) -> Self {
1042            let present = Rc::new(Cell::new(initially_present));
1043            let samples = Rc::new(RefCell::new(Vec::new()));
1044            let window = cx.open_window(size(px(100.), px(100.)), {
1045                let present = present.clone();
1046                let samples = samples.clone();
1047                move |_, _| PresenceView { present, samples }
1048            });
1049            cx.run_until_parked();
1050            Self {
1051                window,
1052                present,
1053                samples,
1054            }
1055        }
1056
1057        fn render(&self, cx: &mut TestAppContext, present: bool) -> PresenceSample {
1058            self.present.set(present);
1059            self.window
1060                .update(cx, |_, window, _| window.refresh())
1061                .unwrap();
1062            cx.run_until_parked();
1063            *self.samples.borrow().last().unwrap()
1064        }
1065    }
1066
1067    #[gpui::test]
1068    fn presence_enters_exits_and_only_unmounts_after_exit(cx: &mut TestAppContext) {
1069        let fixture = PresenceFixture::open(cx, true);
1070        let entering = *fixture.samples.borrow().last().unwrap();
1071        assert_eq!(entering.phase, PresencePhase::Entering);
1072        assert_eq!(entering.progress, 0.0);
1073        assert!(entering.should_render());
1074
1075        cx.executor().advance_clock(Duration::from_millis(100));
1076        let present = fixture.render(cx, true);
1077        assert_eq!(present.phase, PresencePhase::Present);
1078        assert_eq!(present.progress, 1.0);
1079
1080        let exiting = fixture.render(cx, false);
1081        assert_eq!(exiting.phase, PresencePhase::Exiting);
1082        assert_eq!(exiting.progress, 1.0);
1083        assert!(exiting.should_render());
1084
1085        cx.executor().advance_clock(Duration::from_millis(100));
1086        let absent = fixture.render(cx, false);
1087        assert_eq!(absent.phase, PresencePhase::Absent);
1088        assert_eq!(absent.progress, 0.0);
1089        assert!(!absent.should_render());
1090    }
1091
1092    #[gpui::test]
1093    fn presence_reentry_reverses_from_the_exit_sample(cx: &mut TestAppContext) {
1094        let fixture = PresenceFixture::open(cx, true);
1095        cx.executor().advance_clock(Duration::from_millis(100));
1096        fixture.render(cx, true);
1097        fixture.render(cx, false);
1098        cx.executor().advance_clock(Duration::from_millis(40));
1099        let reentering = fixture.render(cx, true);
1100
1101        assert_eq!(reentering.phase, PresencePhase::Entering);
1102        assert_eq!(reentering.progress, 0.6);
1103    }
1104
1105    #[gpui::test]
1106    fn reduced_motion_resolves_presence_without_a_pending_frame(cx: &mut TestAppContext) {
1107        cx.update(|cx| cx.set_reduce_motion(true));
1108        let fixture = PresenceFixture::open(cx, true);
1109        assert_eq!(
1110            fixture.samples.borrow().last().unwrap().phase,
1111            PresencePhase::Present
1112        );
1113        assert_eq!(
1114            fixture
1115                .window
1116                .update(cx, |_, window, cx| window.simulate_next_frame(cx))
1117                .unwrap(),
1118            0
1119        );
1120        assert_eq!(fixture.render(cx, false).phase, PresencePhase::Absent);
1121    }
1122
1123    #[test]
1124    fn transition_ids_accept_element_like_scalars_and_named_channels() {
1125        assert_eq!(
1126            TransitionId::from("opacity"),
1127            TransitionId::from(ElementId::from("opacity"))
1128        );
1129        assert_ne!(
1130            TransitionId::from(("terms", "fill")),
1131            TransitionId::from(("terms", "mark-opacity"))
1132        );
1133        let _: TransitionId = 7usize.into();
1134        let _: TransitionId = 7i32.into();
1135    }
1136
1137    struct TestView {
1138        target: Rc<Cell<f32>>,
1139        duration: Duration,
1140        samples: Rc<RefCell<Vec<f32>>>,
1141    }
1142
1143    impl Render for TestView {
1144        fn render(
1145            &mut self,
1146            window: &mut Window,
1147            cx: &mut gpui::Context<Self>,
1148        ) -> impl IntoElement {
1149            self.samples.borrow_mut().push(transition(
1150                ("test", "value"),
1151                self.target.get(),
1152                Transition::new(self.duration).ease(|t| t),
1153                window,
1154                cx,
1155            ));
1156            Empty
1157        }
1158    }
1159
1160    struct DelayedView {
1161        target: Rc<Cell<f32>>,
1162        samples: Rc<RefCell<Vec<f32>>>,
1163    }
1164
1165    impl Render for DelayedView {
1166        fn render(
1167            &mut self,
1168            window: &mut Window,
1169            cx: &mut gpui::Context<Self>,
1170        ) -> impl IntoElement {
1171            self.samples.borrow_mut().push(transition(
1172                ("delayed-test", "value"),
1173                self.target.get(),
1174                Transition::new(Duration::from_millis(100))
1175                    .delay(Duration::from_millis(50))
1176                    .ease(|t| t),
1177                window,
1178                cx,
1179            ));
1180            Empty
1181        }
1182    }
1183
1184    struct Fixture {
1185        window: WindowHandle<TestView>,
1186        target: Rc<Cell<f32>>,
1187        samples: Rc<RefCell<Vec<f32>>>,
1188    }
1189
1190    impl Fixture {
1191        fn open(cx: &mut TestAppContext, duration: Duration) -> Self {
1192            let target = Rc::new(Cell::new(0.0));
1193            let samples = Rc::new(RefCell::new(Vec::new()));
1194            let window = cx.open_window(size(px(100.), px(100.)), {
1195                let target = target.clone();
1196                let samples = samples.clone();
1197                move |_, _| TestView {
1198                    target,
1199                    duration,
1200                    samples,
1201                }
1202            });
1203            cx.run_until_parked();
1204            Self {
1205                window,
1206                target,
1207                samples,
1208            }
1209        }
1210
1211        fn render(&self, cx: &mut TestAppContext, target: f32) -> f32 {
1212            self.target.set(target);
1213            self.window
1214                .update(cx, |_, window, _| window.refresh())
1215                .unwrap();
1216            cx.run_until_parked();
1217            *self.samples.borrow().last().unwrap()
1218        }
1219
1220        fn pending_frame(&self, cx: &mut TestAppContext) -> usize {
1221            self.window
1222                .update(cx, |_, window, cx| window.simulate_next_frame(cx))
1223                .unwrap()
1224        }
1225    }
1226
1227    #[gpui::test]
1228    fn a_zero_duration_target_change_is_immediate(cx: &mut TestAppContext) {
1229        let fixture = Fixture::open(cx, Duration::ZERO);
1230        assert_eq!(fixture.render(cx, 1.0), 1.0);
1231    }
1232
1233    #[gpui::test]
1234    fn a_changed_target_transitions_over_time(cx: &mut TestAppContext) {
1235        let duration = Duration::from_millis(100);
1236        let fixture = Fixture::open(cx, duration);
1237        assert_eq!(fixture.render(cx, 10.0), 0.0);
1238
1239        cx.executor().advance_clock(Duration::from_millis(50));
1240        assert_eq!(fixture.render(cx, 10.0), 5.0);
1241    }
1242
1243    #[gpui::test]
1244    fn requested_animation_frames_resample_without_manual_refresh(cx: &mut TestAppContext) {
1245        let duration = Duration::from_millis(100);
1246        let fixture = Fixture::open(cx, duration);
1247        assert_eq!(fixture.render(cx, 10.0), 0.0);
1248
1249        cx.executor().advance_clock(Duration::from_millis(50));
1250        assert_eq!(fixture.pending_frame(cx), 1);
1251        cx.run_until_parked();
1252
1253        assert_eq!(*fixture.samples.borrow().last().unwrap(), 5.0);
1254    }
1255
1256    #[gpui::test]
1257    fn reversing_uses_the_current_sample_and_shortens_the_return(cx: &mut TestAppContext) {
1258        let duration = Duration::from_millis(100);
1259        let fixture = Fixture::open(cx, duration);
1260        assert_eq!(fixture.render(cx, 10.0), 0.0);
1261
1262        cx.executor().advance_clock(Duration::from_millis(50));
1263        assert_eq!(fixture.render(cx, 0.0), 5.0);
1264        cx.executor().advance_clock(Duration::from_millis(25));
1265        assert_eq!(fixture.render(cx, 0.0), 2.5);
1266    }
1267
1268    #[gpui::test]
1269    fn delay_holds_the_previous_value_before_interpolation(cx: &mut TestAppContext) {
1270        let target = Rc::new(Cell::new(0.0));
1271        let samples = Rc::new(RefCell::new(Vec::new()));
1272        let window = cx.open_window(size(px(100.), px(100.)), {
1273            let target = target.clone();
1274            let samples = samples.clone();
1275            move |_, _| DelayedView { target, samples }
1276        });
1277        cx.run_until_parked();
1278
1279        target.set(10.0);
1280        window.update(cx, |_, window, _| window.refresh()).unwrap();
1281        cx.run_until_parked();
1282        assert_eq!(*samples.borrow().last().unwrap(), 0.0);
1283
1284        cx.executor().advance_clock(Duration::from_millis(50));
1285        window.update(cx, |_, window, _| window.refresh()).unwrap();
1286        cx.run_until_parked();
1287        assert_eq!(*samples.borrow().last().unwrap(), 0.0);
1288
1289        cx.executor().advance_clock(Duration::from_millis(50));
1290        window.update(cx, |_, window, _| window.refresh()).unwrap();
1291        cx.run_until_parked();
1292        assert_eq!(*samples.borrow().last().unwrap(), 5.0);
1293    }
1294
1295    #[gpui::test]
1296    fn a_completed_transition_stops_requesting_frames(cx: &mut TestAppContext) {
1297        let duration = Duration::from_millis(100);
1298        let fixture = Fixture::open(cx, duration);
1299        fixture.render(cx, 1.0);
1300        assert_eq!(fixture.pending_frame(cx), 1);
1301
1302        cx.executor().advance_clock(duration);
1303        assert_eq!(fixture.render(cx, 1.0), 1.0);
1304        fixture.pending_frame(cx);
1305        cx.run_until_parked();
1306        assert_eq!(fixture.pending_frame(cx), 0);
1307    }
1308
1309    #[gpui::test]
1310    fn reduced_motion_adopts_the_target_without_requesting_a_frame(cx: &mut TestAppContext) {
1311        cx.update(|cx| cx.set_reduce_motion(true));
1312        let duration = Duration::from_millis(100);
1313        let fixture = Fixture::open(cx, duration);
1314        assert_eq!(fixture.render(cx, 1.0), 1.0);
1315        assert_eq!(fixture.pending_frame(cx), 0);
1316    }
1317
1318    struct SpringView {
1319        target: Rc<Cell<f32>>,
1320        policy: Rc<Cell<Spring>>,
1321        samples: Rc<RefCell<Vec<f32>>>,
1322    }
1323
1324    impl Render for SpringView {
1325        fn render(
1326            &mut self,
1327            window: &mut Window,
1328            cx: &mut gpui::Context<Self>,
1329        ) -> impl IntoElement {
1330            self.samples.borrow_mut().push(spring(
1331                ("spring-test", "value"),
1332                self.target.get(),
1333                self.policy.get(),
1334                window,
1335                cx,
1336            ));
1337            Empty
1338        }
1339    }
1340
1341    struct SpringFixture {
1342        window: WindowHandle<SpringView>,
1343        target: Rc<Cell<f32>>,
1344        policy: Rc<Cell<Spring>>,
1345        samples: Rc<RefCell<Vec<f32>>>,
1346    }
1347
1348    impl SpringFixture {
1349        fn open(cx: &mut TestAppContext, policy: Spring) -> Self {
1350            let target = Rc::new(Cell::new(0.0));
1351            let policy = Rc::new(Cell::new(policy));
1352            let samples = Rc::new(RefCell::new(Vec::new()));
1353            let window = cx.open_window(size(px(100.), px(100.)), {
1354                let target = target.clone();
1355                let policy = policy.clone();
1356                let samples = samples.clone();
1357                move |_, _| SpringView {
1358                    target,
1359                    policy,
1360                    samples,
1361                }
1362            });
1363            cx.run_until_parked();
1364            Self {
1365                window,
1366                target,
1367                policy,
1368                samples,
1369            }
1370        }
1371
1372        fn render(&self, cx: &mut TestAppContext, target: f32) -> f32 {
1373            self.target.set(target);
1374            self.window
1375                .update(cx, |_, window, _| window.refresh())
1376                .unwrap();
1377            cx.run_until_parked();
1378            *self.samples.borrow().last().unwrap()
1379        }
1380
1381        fn advance(&self, cx: &mut TestAppContext, millis: u64, target: f32) -> f32 {
1382            cx.executor().advance_clock(Duration::from_millis(millis));
1383            self.render(cx, target)
1384        }
1385
1386        fn pending_frame(&self, cx: &mut TestAppContext) -> usize {
1387            self.window
1388                .update(cx, |_, window, cx| window.simulate_next_frame(cx))
1389                .unwrap()
1390        }
1391    }
1392
1393    #[gpui::test]
1394    fn a_spring_adopts_its_first_target_immediately(cx: &mut TestAppContext) {
1395        let fixture = SpringFixture::open(cx, Spring::new(Duration::from_millis(300)));
1396        assert_eq!(*fixture.samples.borrow().first().unwrap(), 0.0);
1397    }
1398
1399    #[gpui::test]
1400    fn a_spring_travels_toward_its_target_over_time(cx: &mut TestAppContext) {
1401        let fixture = SpringFixture::open(cx, Spring::new(Duration::from_millis(300)));
1402        assert_eq!(fixture.render(cx, 1.0), 0.0);
1403
1404        let early = fixture.advance(cx, 50, 1.0);
1405        let late = fixture.advance(cx, 50, 1.0);
1406        assert!(
1407            0.0 < early && early < late && late < 1.0,
1408            "expected monotonic approach, got {early} then {late}"
1409        );
1410    }
1411
1412    #[gpui::test]
1413    fn a_reversed_spring_keeps_its_momentum_before_turning_around(cx: &mut TestAppContext) {
1414        let fixture = SpringFixture::open(cx, Spring::new(Duration::from_millis(300)));
1415        fixture.render(cx, 1.0);
1416        let reversed_at = fixture.advance(cx, 100, 1.0);
1417
1418        // Retarget mid-flight. A duration-based transition restarts its easing
1419        // here and moves away from 1.0 on the very next frame.
1420        assert_eq!(fixture.render(cx, 0.0), reversed_at);
1421
1422        let next = fixture.advance(cx, 16, 0.0);
1423        assert!(
1424            next > reversed_at,
1425            "expected the spring to carry its velocity past {reversed_at}, got {next}"
1426        );
1427
1428        assert_eq!(fixture.advance(cx, 1_000, 0.0), 0.0);
1429    }
1430
1431    #[gpui::test]
1432    fn a_bouncy_spring_overshoots_its_target(cx: &mut TestAppContext) {
1433        let fixture = SpringFixture::open(
1434            cx,
1435            Spring::new(Duration::from_millis(350)).with_damping(0.7),
1436        );
1437        fixture.render(cx, 1.0);
1438        for _ in 0..30 {
1439            fixture.advance(cx, 16, 1.0);
1440        }
1441
1442        let peak = fixture
1443            .samples
1444            .borrow()
1445            .iter()
1446            .copied()
1447            .fold(f32::MIN, f32::max);
1448        assert!(peak > 1.0, "expected an overshoot past 1.0, got {peak}");
1449    }
1450
1451    #[gpui::test]
1452    fn a_settled_spring_stops_requesting_frames(cx: &mut TestAppContext) {
1453        let fixture = SpringFixture::open(cx, Spring::new(Duration::from_millis(300)));
1454        fixture.render(cx, 1.0);
1455        assert_eq!(fixture.pending_frame(cx), 1);
1456
1457        assert_eq!(fixture.advance(cx, 2_000, 1.0), 1.0);
1458        fixture.pending_frame(cx);
1459        cx.run_until_parked();
1460        assert_eq!(fixture.pending_frame(cx), 0);
1461    }
1462
1463    #[gpui::test]
1464    fn a_spring_that_is_not_travelling_adopts_its_target_on_the_spot(cx: &mut TestAppContext) {
1465        let travelling = Spring::new(Duration::from_millis(300));
1466        let fixture = SpringFixture::open(cx, travelling.with_travel(false));
1467
1468        assert_eq!(fixture.render(cx, 1.0), 1.0);
1469        assert_eq!(fixture.pending_frame(cx), 0);
1470        assert_eq!(fixture.advance(cx, 100, 5.0), 5.0);
1471
1472        // Travel resumes from the value the suspension left behind. A spring
1473        // that had kept the state it held beforehand would jump back to it here.
1474        fixture.policy.set(travelling);
1475        assert_eq!(fixture.render(cx, 6.0), 5.0);
1476        let next = fixture.advance(cx, 50, 6.0);
1477        assert!(
1478            5.0 < next && next < 6.0,
1479            "expected travel to resume from 5.0, got {next}"
1480        );
1481    }
1482
1483    #[gpui::test]
1484    fn a_zero_response_spring_resolves_instead_of_dividing_by_its_period(cx: &mut TestAppContext) {
1485        let fixture = SpringFixture::open(cx, Spring::new(Duration::ZERO));
1486        assert_eq!(fixture.render(cx, 1.0), 1.0);
1487        assert_eq!(fixture.pending_frame(cx), 0);
1488    }
1489
1490    #[test]
1491    fn spring_rejects_non_finite_or_negative_physical_parameters() {
1492        let spring = Spring::new(Duration::from_millis(300));
1493
1494        assert_eq!(
1495            spring.try_with_damping(f32::NAN).unwrap_err(),
1496            SpringError::InvalidDamping
1497        );
1498        assert_eq!(
1499            spring.try_with_damping(-0.1).unwrap_err(),
1500            SpringError::InvalidDamping
1501        );
1502        assert_eq!(
1503            spring.try_with_epsilon(f32::INFINITY).unwrap_err(),
1504            SpringError::InvalidEpsilon
1505        );
1506        assert_eq!(
1507            spring.try_with_epsilon(-0.1).unwrap_err(),
1508            SpringError::InvalidEpsilon
1509        );
1510    }
1511
1512    #[test]
1513    fn spring_reports_its_unit_specific_settling_tolerance() {
1514        let normalized = Spring::new(Duration::from_millis(180));
1515        let pixels = Spring::new(Duration::from_millis(180)).with_epsilon(0.1);
1516
1517        assert!(normalized.epsilon() < 0.01);
1518        assert_eq!(pixels.epsilon(), 0.1);
1519    }
1520
1521    #[gpui::test]
1522    fn reduced_motion_adopts_the_spring_target_without_requesting_a_frame(cx: &mut TestAppContext) {
1523        cx.update(|cx| cx.set_reduce_motion(true));
1524        let fixture = SpringFixture::open(
1525            cx,
1526            Spring::new(Duration::from_millis(350)).with_damping(0.7),
1527        );
1528        assert_eq!(fixture.render(cx, 1.0), 1.0);
1529        assert_eq!(fixture.pending_frame(cx), 0);
1530    }
1531}