Skip to main content

gpui/elements/
animation.rs

1use scheduler::Instant;
2use std::{cell::Cell, rc::Rc, time::Duration};
3
4use crate::{
5    AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement,
6    ParentElement, SpringAnimation, SpringConfig, SpringPlayback, SpringState, SpringTarget,
7    Window,
8};
9
10pub use easing::*;
11use smallvec::SmallVec;
12
13/// An animation that can be applied to an element.
14#[derive(Clone)]
15pub struct Animation {
16    /// The amount of time for which this animation should run
17    pub duration: Duration,
18    /// Whether to repeat this animation when it finishes
19    pub oneshot: bool,
20    /// Whether to derive the phase from a shared clock. See [`Animation::repeat_synced`].
21    pub synced: bool,
22    /// A function that maps normalized time to an animated value.
23    /// The result may exceed 0..1 for easing functions that overshoot.
24    pub easing: Rc<dyn Fn(f32) -> f32>,
25    /// The maximum number of times per second this animation re-renders.
26    /// When `None`, the animation re-renders on every frame.
27    pub max_fps: Option<f32>,
28}
29
30impl Animation {
31    /// Create a new animation with the given duration.
32    /// By default the animation will only run once and will use a linear easing function.
33    pub fn new(duration: Duration) -> Self {
34        Self {
35            duration,
36            oneshot: true,
37            synced: false,
38            easing: Rc::new(linear),
39            max_fps: None,
40        }
41    }
42
43    /// Set the animation to loop when it finishes.
44    pub fn repeat(mut self) -> Self {
45        self.oneshot = false;
46        self
47    }
48
49    /// Set the animation to loop when it finishes, phase-locked to a clock shared by the whole [`App`].
50    pub fn repeat_synced(mut self) -> Self {
51        self.oneshot = false;
52        self.synced = true;
53        self
54    }
55
56    /// Sets the easing function used to map normalized time to an animated value.
57    ///
58    /// The output is not clamped, allowing physical easing functions such as
59    /// springs to overshoot.
60    pub fn with_easing(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self {
61        self.easing = Rc::new(easing);
62        self
63    }
64
65    /// Limit how often this animation re-renders. Instead of re-rendering on
66    /// every frame, the animation schedules its next render `1 / max_fps`
67    /// seconds after the current one. Values that are not finite and positive
68    /// are ignored.
69    pub fn with_max_fps(mut self, max_fps: f32) -> Self {
70        self.max_fps = Some(max_fps);
71        self
72    }
73}
74
75/// An extension trait for adding the animation wrapper to both Elements and Components
76///
77/// Animations rendered through this trait automatically respect
78/// [`App::reduce_motion`](crate::App::reduce_motion): when it is set,
79/// the element is rendered in a static state (the end state for oneshot
80/// animations, the start state for repeating ones) and no animation frames are
81/// scheduled.
82pub trait AnimationExt {
83    /// Render this component or element with an animation
84    fn with_animation(
85        self,
86        id: impl Into<ElementId>,
87        animation: Animation,
88        animator: impl Fn(Self, f32) -> Self + 'static,
89    ) -> AnimationElement<Self>
90    where
91        Self: Sized,
92    {
93        AnimationElement {
94            id: id.into(),
95            element: Some(self),
96            animator: Box::new(move |this, _, value| animator(this, value)),
97            animations: smallvec::smallvec![animation],
98        }
99    }
100
101    /// Render this component or element with a chain of animations
102    fn with_animations(
103        self,
104        id: impl Into<ElementId>,
105        animations: Vec<Animation>,
106        animator: impl Fn(Self, usize, f32) -> Self + 'static,
107    ) -> AnimationElement<Self>
108    where
109        Self: Sized,
110    {
111        AnimationElement {
112            id: id.into(),
113            element: Some(self),
114            animator: Box::new(animator),
115            animations: animations.into(),
116        }
117    }
118
119    /// Renders this component or element at the value produced by a spring.
120    ///
121    /// The element ID preserves position and velocity across target changes.
122    /// A newly mounted spring starts at its target unless configured with
123    /// [`SpringAnimation::from`].
124    fn with_spring<T>(
125        self,
126        id: impl Into<ElementId>,
127        animation: SpringAnimation<T>,
128        animator: impl FnOnce(Self, T::Output) -> Self + 'static,
129    ) -> SpringAnimationElement<Self>
130    where
131        Self: Sized,
132        T: SpringTarget,
133        T::Output: 'static,
134    {
135        let SpringAnimation {
136            config,
137            target,
138            epsilon,
139            initial,
140            playback,
141        } = animation;
142        let scalar_target = target.target();
143        SpringAnimationElement {
144            id: id.into(),
145            element: Some(self),
146            config,
147            target: scalar_target,
148            epsilon,
149            initial,
150            playback,
151            animator: Some(Box::new(move |this, value| {
152                animator(this, target.resolve(value))
153            })),
154        }
155    }
156}
157
158impl<E: IntoElement + 'static> AnimationExt for E {}
159
160/// A GPUI element that applies an animation to another element
161pub struct AnimationElement<E> {
162    id: ElementId,
163    element: Option<E>,
164    animations: SmallVec<[Animation; 1]>,
165    animator: Box<dyn Fn(E, usize, f32) -> E + 'static>,
166}
167
168/// A GPUI element driven by a stateful spring.
169pub struct SpringAnimationElement<E> {
170    id: ElementId,
171    element: Option<E>,
172    config: SpringConfig,
173    target: f32,
174    epsilon: f32,
175    initial: Option<f32>,
176    playback: SpringPlayback,
177    animator: Option<Box<dyn FnOnce(E, f32) -> E + 'static>>,
178}
179
180impl<E: ParentElement> ParentElement for SpringAnimationElement<E> {
181    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
182        let Some(element) = &mut self.element else {
183            return;
184        };
185
186        element.extend(elements);
187    }
188}
189
190impl<E> SpringAnimationElement<E> {
191    /// Returns a new [`SpringAnimationElement<E>`] after applying the given function
192    /// to the element being animated.
193    pub fn map_element(mut self, f: impl FnOnce(E) -> E) -> SpringAnimationElement<E> {
194        self.element = self.element.map(f);
195        self
196    }
197}
198
199impl<E: IntoElement + 'static> IntoElement for SpringAnimationElement<E> {
200    type Element = SpringAnimationElement<E>;
201
202    fn into_element(self) -> Self::Element {
203        self
204    }
205}
206
207impl<E: ParentElement> ParentElement for AnimationElement<E> {
208    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
209        let Some(element) = &mut self.element else {
210            return;
211        };
212
213        element.extend(elements);
214    }
215}
216
217impl<E> AnimationElement<E> {
218    /// Returns a new [`AnimationElement<E>`] after applying the given function
219    /// to the element being animated.
220    pub fn map_element(mut self, f: impl FnOnce(E) -> E) -> AnimationElement<E> {
221        self.element = self.element.map(f);
222        self
223    }
224}
225
226impl<E: IntoElement + 'static> IntoElement for AnimationElement<E> {
227    type Element = AnimationElement<E>;
228
229    fn into_element(self) -> Self::Element {
230        self
231    }
232}
233
234struct AnimationState {
235    start: Instant,
236    animation_ix: usize,
237    /// Whether a throttled re-render (see [`Animation::with_max_fps`]) is
238    /// already scheduled, so overlapping renders don't stack extra timers.
239    delayed_frame_pending: Rc<Cell<bool>>,
240}
241
242struct SpringElementState {
243    spring: SpringState,
244    target: f32,
245    config: SpringConfig,
246    initial: f32,
247    playback: SpringPlayback,
248    updated_at: Instant,
249}
250
251impl<E: IntoElement + 'static> Element for SpringAnimationElement<E> {
252    type RequestLayoutState = AnyElement;
253    type PrepaintState = ();
254
255    fn id(&self) -> Option<ElementId> {
256        Some(self.id.clone())
257    }
258
259    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
260        None
261    }
262
263    fn request_layout(
264        &mut self,
265        global_id: Option<&GlobalElementId>,
266        _inspector_id: Option<&InspectorElementId>,
267        window: &mut Window,
268        cx: &mut App,
269    ) -> (crate::LayoutId, Self::RequestLayoutState) {
270        window.with_element_state(global_id.unwrap(), |state, window| {
271            let now = Instant::now();
272            let initial = self.initial.unwrap_or(self.target);
273            let mut state = state.unwrap_or_else(|| SpringElementState {
274                spring: SpringState {
275                    position: initial,
276                    velocity: 0.0,
277                },
278                target: self.target,
279                config: self.config,
280                initial,
281                playback: self.playback,
282                updated_at: now,
283            });
284
285            let elapsed = now.duration_since(state.updated_at).as_secs_f32();
286            match state.playback {
287                SpringPlayback::Running => {
288                    state.spring = state.config.step(state.spring, state.target, elapsed);
289                }
290                SpringPlayback::Paused
291                | SpringPlayback::Stopped
292                | SpringPlayback::Completed
293                | SpringPlayback::Cancelled => {}
294            }
295
296            state.config = self.config;
297            state.target = self.target;
298
299            let done = match self.playback {
300                SpringPlayback::Running => {
301                    if cx.reduce_motion() {
302                        state.spring = SpringState {
303                            position: state.target,
304                            velocity: 0.0,
305                        };
306                        true
307                    } else {
308                        let done =
309                            state
310                                .config
311                                .is_settled(state.spring, state.target, self.epsilon);
312                        if done {
313                            state.spring = SpringState {
314                                position: state.target,
315                                velocity: 0.0,
316                            };
317                        }
318                        done
319                    }
320                }
321                SpringPlayback::Paused => true,
322                SpringPlayback::Stopped => {
323                    state.spring.velocity = 0.0;
324                    true
325                }
326                SpringPlayback::Completed => {
327                    state.spring = SpringState {
328                        position: state.target,
329                        velocity: 0.0,
330                    };
331                    true
332                }
333                SpringPlayback::Cancelled => {
334                    state.spring = SpringState {
335                        position: state.initial,
336                        velocity: 0.0,
337                    };
338                    true
339                }
340            };
341            state.playback = self.playback;
342            state.updated_at = now;
343
344            let element = self.element.take().expect("should only be called once");
345            let animator = self.animator.take().expect("should only be called once");
346            let mut element = animator(element, state.spring.position).into_any_element();
347
348            if !done {
349                window.request_animation_frame();
350            }
351
352            ((element.request_layout(window, cx), element), state)
353        })
354    }
355
356    fn prepaint(
357        &mut self,
358        _id: Option<&GlobalElementId>,
359        _inspector_id: Option<&InspectorElementId>,
360        _bounds: crate::Bounds<crate::Pixels>,
361        element: &mut Self::RequestLayoutState,
362        window: &mut Window,
363        cx: &mut App,
364    ) -> Self::PrepaintState {
365        element.prepaint(window, cx);
366    }
367
368    fn paint(
369        &mut self,
370        _id: Option<&GlobalElementId>,
371        _inspector_id: Option<&InspectorElementId>,
372        _bounds: crate::Bounds<crate::Pixels>,
373        element: &mut Self::RequestLayoutState,
374        _: &mut Self::PrepaintState,
375        window: &mut Window,
376        cx: &mut App,
377    ) {
378        element.paint(window, cx);
379    }
380}
381
382impl<E: IntoElement + 'static> Element for AnimationElement<E> {
383    type RequestLayoutState = AnyElement;
384    type PrepaintState = ();
385
386    fn id(&self) -> Option<ElementId> {
387        Some(self.id.clone())
388    }
389
390    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
391        None
392    }
393
394    fn request_layout(
395        &mut self,
396        global_id: Option<&GlobalElementId>,
397        _inspector_id: Option<&InspectorElementId>,
398        window: &mut Window,
399        cx: &mut App,
400    ) -> (crate::LayoutId, Self::RequestLayoutState) {
401        window.with_element_state(global_id.unwrap(), |state, window| {
402            let mut state = state.unwrap_or_else(|| AnimationState {
403                start: Instant::now(),
404                animation_ix: 0,
405                delayed_frame_pending: Rc::new(Cell::new(false)),
406            });
407            let (animation_ix, delta, done) = if cx.reduce_motion() {
408                let animation_ix = self.animations.len() - 1;
409                let delta = if self.animations[animation_ix].oneshot {
410                    1.0
411                } else {
412                    0.0
413                };
414                (animation_ix, delta, true)
415            } else {
416                let animation_ix = state.animation_ix;
417                let duration = self.animations[animation_ix].duration;
418
419                let elapsed = if self.animations[animation_ix].synced && !duration.is_zero() {
420                    let elapsed = cx.background_executor().now() - cx.synced_animation_epoch;
421                    // Reduce modulo the duration before f32 conversion, which loses sub-second precision at scale.
422                    Duration::from_nanos((elapsed.as_nanos() % duration.as_nanos()) as u64)
423                } else {
424                    state.start.elapsed()
425                };
426                let mut delta = elapsed.as_secs_f32() / duration.as_secs_f32();
427
428                let mut done = false;
429                if delta > 1.0 {
430                    if self.animations[animation_ix].oneshot {
431                        if animation_ix >= self.animations.len() - 1 {
432                            done = true;
433                        } else {
434                            state.start = Instant::now();
435                            state.animation_ix += 1;
436                        }
437                        delta = 1.0;
438                    } else {
439                        delta %= 1.0;
440                    }
441                }
442                (animation_ix, delta, done)
443            };
444            let delta = (self.animations[animation_ix].easing)(delta);
445
446            debug_assert!(delta.is_finite(), "animated value should be finite");
447
448            let element = self.element.take().expect("should only be called once");
449            let mut element = (self.animator)(element, animation_ix, delta).into_any_element();
450
451            if !done {
452                match self.animations[animation_ix].max_fps {
453                    Some(max_fps) if max_fps.is_finite() && max_fps > 0.0 => {
454                        if !state.delayed_frame_pending.get() {
455                            state.delayed_frame_pending.set(true);
456                            let delayed_frame_pending = state.delayed_frame_pending.clone();
457                            let view = window.current_view();
458                            let interval = Duration::from_secs_f32(1.0 / max_fps);
459                            window
460                                .spawn(cx, async move |cx| {
461                                    cx.background_executor().timer(interval).await;
462                                    delayed_frame_pending.set(false);
463                                    cx.update(move |_, cx| cx.notify(view)).ok();
464                                })
465                                .detach();
466                        }
467                    }
468                    _ => window.request_animation_frame(),
469                }
470            }
471
472            ((element.request_layout(window, cx), element), state)
473        })
474    }
475
476    fn prepaint(
477        &mut self,
478        _id: Option<&GlobalElementId>,
479        _inspector_id: Option<&InspectorElementId>,
480        _bounds: crate::Bounds<crate::Pixels>,
481        element: &mut Self::RequestLayoutState,
482        window: &mut Window,
483        cx: &mut App,
484    ) -> Self::PrepaintState {
485        element.prepaint(window, cx);
486    }
487
488    fn paint(
489        &mut self,
490        _id: Option<&GlobalElementId>,
491        _inspector_id: Option<&InspectorElementId>,
492        _bounds: crate::Bounds<crate::Pixels>,
493        element: &mut Self::RequestLayoutState,
494        _: &mut Self::PrepaintState,
495        window: &mut Window,
496        cx: &mut App,
497    ) {
498        element.paint(window, cx);
499    }
500}
501
502mod easing {
503    use std::f32::consts::PI;
504
505    /// The linear easing function, or delta itself
506    pub fn linear(delta: f32) -> f32 {
507        delta
508    }
509
510    /// The quadratic easing function, delta * delta
511    pub fn quadratic(delta: f32) -> f32 {
512        delta * delta
513    }
514
515    /// The quadratic ease-in-out function, which starts and ends slowly but speeds up in the middle
516    pub fn ease_in_out(delta: f32) -> f32 {
517        if delta < 0.5 {
518            2.0 * delta * delta
519        } else {
520            let x = -2.0 * delta + 2.0;
521            1.0 - x * x / 2.0
522        }
523    }
524
525    /// The Quint ease-out function, which starts quickly and decelerates to a stop
526    pub fn ease_out_quint() -> impl Fn(f32) -> f32 {
527        move |delta| 1.0 - (1.0 - delta).powi(5)
528    }
529
530    /// Apply the given easing function, first in the forward direction and then in the reverse direction
531    pub fn bounce(easing: impl Fn(f32) -> f32) -> impl Fn(f32) -> f32 {
532        move |delta| {
533            if delta < 0.5 {
534                easing(delta * 2.0)
535            } else {
536                easing((1.0 - delta) * 2.0)
537            }
538        }
539    }
540
541    /// A custom easing function for pulsating alpha that slows down as it approaches 0.1
542    pub fn pulsating_between(min: f32, max: f32) -> impl Fn(f32) -> f32 {
543        let range = max - min;
544
545        move |delta| {
546            // Use a combination of sine and cubic functions for a more natural breathing rhythm
547            let t = (delta * 2.0 * PI).sin();
548            let breath = (t * t * t + t) / 2.0;
549
550            // Map the breath to our desired alpha range
551            let normalized_alpha = (breath + 1.0) / 2.0;
552
553            min + (normalized_alpha * range)
554        }
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use std::{cell::RefCell, rc::Rc, time::Duration};
561
562    use crate::{
563        Animation, Context, InteractiveElement, Pixels, Render, SpringAnimation, SpringConfig,
564        TestAppContext, WindowHandle, div, prelude::*, px, size,
565    };
566
567    use super::*;
568
569    struct AnimationTestView {
570        rendered_deltas: Rc<RefCell<Vec<f32>>>,
571        max_fps: Option<f32>,
572    }
573
574    struct SyncedAnimationTestView {
575        show_second: bool,
576        first_deltas: Rc<RefCell<Vec<f32>>>,
577        second_deltas: Rc<RefCell<Vec<f32>>>,
578    }
579
580    struct SpringAnimationTestView {
581        target: Pixels,
582        initial: Option<Pixels>,
583        playback: SpringPlayback,
584        rendered_values: Rc<RefCell<Vec<Pixels>>>,
585    }
586
587    impl Render for SpringAnimationTestView {
588        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
589            let rendered_values = self.rendered_values.clone();
590            let mut animation = SpringAnimation::new(SpringConfig::new(100.0, 2.0, 1.0))
591                .to(self.target)
592                .with_epsilon(0.01)
593                .playback(self.playback);
594            if let Some(initial) = self.initial {
595                animation = animation.from(initial);
596            }
597            div().with_spring("spring-animation", animation, move |this, value| {
598                rendered_values.borrow_mut().push(value);
599                this.left(value)
600            })
601        }
602    }
603
604    impl Render for SyncedAnimationTestView {
605        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
606            let record_deltas = |deltas: Rc<RefCell<Vec<f32>>>| {
607                move |this, delta| {
608                    deltas.borrow_mut().push(delta);
609                    this
610                }
611            };
612            div()
613                .size_full()
614                .child(div().with_animation(
615                    "first-synced-animation",
616                    Animation::new(Duration::from_secs(1)).repeat_synced(),
617                    record_deltas(self.first_deltas.clone()),
618                ))
619                .when(self.show_second, |this| {
620                    this.child(div().with_animation(
621                        "second-synced-animation",
622                        Animation::new(Duration::from_secs(1)).repeat_synced(),
623                        record_deltas(self.second_deltas.clone()),
624                    ))
625                })
626        }
627    }
628
629    impl Render for AnimationTestView {
630        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
631            let rendered_deltas = self.rendered_deltas.clone();
632            // The throttled variant syncs to the shared clock so the deltas
633            // follow the test scheduler's clock rather than wall time.
634            let mut animation = Animation::new(Duration::from_secs(1));
635            if let Some(max_fps) = self.max_fps {
636                animation = animation.repeat_synced().with_max_fps(max_fps);
637            } else {
638                animation = animation.repeat();
639            }
640            div().size_full().child(div().with_animation(
641                "repeating-animation",
642                animation,
643                move |this, delta| {
644                    rendered_deltas.borrow_mut().push(delta);
645                    this
646                },
647            ))
648        }
649    }
650
651    fn open_test_window(
652        cx: &mut TestAppContext,
653    ) -> (Rc<RefCell<Vec<f32>>>, WindowHandle<AnimationTestView>) {
654        open_test_window_with_max_fps(cx, None)
655    }
656
657    fn open_test_window_with_max_fps(
658        cx: &mut TestAppContext,
659        max_fps: Option<f32>,
660    ) -> (Rc<RefCell<Vec<f32>>>, WindowHandle<AnimationTestView>) {
661        let rendered_deltas = Rc::new(RefCell::new(Vec::new()));
662        let window = cx.open_window(size(px(100.), px(100.)), {
663            let rendered_deltas = rendered_deltas.clone();
664            move |_, _| AnimationTestView {
665                rendered_deltas,
666                max_fps,
667            }
668        });
669        cx.run_until_parked();
670        (rendered_deltas, window)
671    }
672
673    fn simulate_next_frame<V: Render>(window: &WindowHandle<V>, cx: &mut TestAppContext) -> usize {
674        let callback_count = window
675            .update(cx, |_, window, cx| window.simulate_next_frame(cx))
676            .unwrap();
677        cx.run_until_parked();
678        callback_count
679    }
680    // Before parent-animation-element, using .with_animation
681    // would not allow chaining .parent after. This is just a
682    // build check that we can call div().id().with_animation().child()
683    #[test]
684    fn test_animation_parent() {
685        div()
686            .id("id")
687            //
688            .with_animation(
689                "animation",
690                Animation::new(Duration::from_secs(1)),
691                |el, _t| {
692                    //
693                    el
694                },
695            )
696            .child(
697                //
698                div(),
699            );
700    }
701
702    #[test]
703    fn test_spring_animation_parent() {
704        div()
705            .id("id")
706            .with_spring(
707                "spring-animation",
708                SpringAnimation::new(SpringConfig::new(100.0, 10.0, 1.0))
709                    .to(px(10.0))
710                    .from(px(0.0)),
711                |element, value| element.left(value),
712            )
713            .child(div());
714    }
715
716    #[gpui::test]
717    fn test_spring_animation_preserves_velocity_when_retargeted(cx: &mut TestAppContext) {
718        let rendered_values = Rc::new(RefCell::new(Vec::new()));
719        let window = cx.open_window(size(px(100.0), px(100.0)), {
720            let rendered_values = rendered_values.clone();
721            move |_, _| SpringAnimationTestView {
722                target: px(0.0),
723                initial: None,
724                playback: SpringPlayback::Running,
725                rendered_values,
726            }
727        });
728        cx.run_until_parked();
729        assert_eq!(*rendered_values.borrow(), vec![px(0.0)]);
730
731        window
732            .update(cx, |view, _, cx| {
733                view.target = px(100.0);
734                cx.notify();
735            })
736            .unwrap();
737        cx.run_until_parked();
738
739        cx.executor().advance_clock(Duration::from_millis(50));
740        assert!(simulate_next_frame(&window, cx) > 0);
741        let value_before_retargeting = *rendered_values.borrow().last().unwrap();
742        assert!(value_before_retargeting > px(0.0));
743        assert!(value_before_retargeting < px(100.0));
744
745        window
746            .update(cx, |view, _, cx| {
747                view.target = px(0.0);
748                cx.notify();
749            })
750            .unwrap();
751        cx.run_until_parked();
752
753        cx.executor().advance_clock(Duration::from_millis(5));
754        assert!(simulate_next_frame(&window, cx) > 0);
755        let value_after_retargeting = *rendered_values.borrow().last().unwrap();
756        assert!(value_after_retargeting > value_before_retargeting);
757    }
758
759    #[gpui::test]
760    fn test_paused_spring_resumes_with_its_velocity(cx: &mut TestAppContext) {
761        let rendered_values = Rc::new(RefCell::new(Vec::new()));
762        let window = cx.open_window(size(px(100.0), px(100.0)), {
763            let rendered_values = rendered_values.clone();
764            move |_, _| SpringAnimationTestView {
765                target: px(0.0),
766                initial: None,
767                playback: SpringPlayback::Running,
768                rendered_values,
769            }
770        });
771        cx.run_until_parked();
772
773        window
774            .update(cx, |view, _, cx| {
775                view.target = px(100.0);
776                cx.notify();
777            })
778            .unwrap();
779        cx.run_until_parked();
780        cx.executor().advance_clock(Duration::from_millis(50));
781        assert!(simulate_next_frame(&window, cx) > 0);
782
783        window
784            .update(cx, |view, _, cx| {
785                view.target = px(0.0);
786                view.playback = SpringPlayback::Paused;
787                cx.notify();
788            })
789            .unwrap();
790        cx.run_until_parked();
791        let paused_value = *rendered_values.borrow().last().unwrap();
792
793        cx.executor().advance_clock(Duration::from_millis(500));
794        assert!(simulate_next_frame(&window, cx) > 0);
795        assert_eq!(*rendered_values.borrow().last().unwrap(), paused_value);
796        assert_eq!(simulate_next_frame(&window, cx), 0);
797
798        window
799            .update(cx, |view, _, cx| {
800                view.playback = SpringPlayback::Running;
801                cx.notify();
802            })
803            .unwrap();
804        cx.run_until_parked();
805        cx.executor().advance_clock(Duration::from_millis(5));
806        assert!(simulate_next_frame(&window, cx) > 0);
807        assert!(*rendered_values.borrow().last().unwrap() > paused_value);
808    }
809
810    #[gpui::test]
811    fn test_stopped_spring_resumes_without_velocity(cx: &mut TestAppContext) {
812        let rendered_values = Rc::new(RefCell::new(Vec::new()));
813        let window = cx.open_window(size(px(100.0), px(100.0)), {
814            let rendered_values = rendered_values.clone();
815            move |_, _| SpringAnimationTestView {
816                target: px(0.0),
817                initial: None,
818                playback: SpringPlayback::Running,
819                rendered_values,
820            }
821        });
822        cx.run_until_parked();
823
824        window
825            .update(cx, |view, _, cx| {
826                view.target = px(1_000_000.0);
827                cx.notify();
828            })
829            .unwrap();
830        cx.run_until_parked();
831        cx.executor().advance_clock(Duration::from_millis(50));
832        assert!(simulate_next_frame(&window, cx) > 0);
833
834        window
835            .update(cx, |view, _, cx| {
836                view.target = px(0.0);
837                view.playback = SpringPlayback::Stopped;
838                cx.notify();
839            })
840            .unwrap();
841        cx.run_until_parked();
842        let stopped_value = *rendered_values.borrow().last().unwrap();
843
844        cx.executor().advance_clock(Duration::from_millis(500));
845        assert!(simulate_next_frame(&window, cx) > 0);
846        assert_eq!(*rendered_values.borrow().last().unwrap(), stopped_value);
847        assert_eq!(simulate_next_frame(&window, cx), 0);
848
849        window
850            .update(cx, |view, _, cx| {
851                view.target = stopped_value;
852                view.playback = SpringPlayback::Running;
853                cx.notify();
854            })
855            .unwrap();
856        cx.run_until_parked();
857        assert_eq!(*rendered_values.borrow().last().unwrap(), stopped_value);
858        assert_eq!(simulate_next_frame(&window, cx), 0);
859    }
860
861    #[gpui::test]
862    fn test_cancelled_and_completed_springs_resolve_their_endpoints(cx: &mut TestAppContext) {
863        let rendered_values = Rc::new(RefCell::new(Vec::new()));
864        let window = cx.open_window(size(px(100.0), px(100.0)), {
865            let rendered_values = rendered_values.clone();
866            move |_, _| SpringAnimationTestView {
867                target: px(100.0),
868                initial: Some(px(20.0)),
869                playback: SpringPlayback::Running,
870                rendered_values,
871            }
872        });
873        cx.run_until_parked();
874        assert_eq!(*rendered_values.borrow(), vec![px(20.0)]);
875
876        cx.executor().advance_clock(Duration::from_millis(50));
877        assert!(simulate_next_frame(&window, cx) > 0);
878        assert!(*rendered_values.borrow().last().unwrap() > px(20.0));
879
880        window
881            .update(cx, |view, _, cx| {
882                view.playback = SpringPlayback::Cancelled;
883                cx.notify();
884            })
885            .unwrap();
886        cx.run_until_parked();
887        assert_eq!(*rendered_values.borrow().last().unwrap(), px(20.0));
888        assert!(simulate_next_frame(&window, cx) > 0);
889        assert_eq!(simulate_next_frame(&window, cx), 0);
890
891        window
892            .update(cx, |view, _, cx| {
893                view.playback = SpringPlayback::Completed;
894                cx.notify();
895            })
896            .unwrap();
897        cx.run_until_parked();
898        assert_eq!(*rendered_values.borrow().last().unwrap(), px(100.0));
899        assert_eq!(simulate_next_frame(&window, cx), 0);
900    }
901
902    #[gpui::test]
903    fn test_spring_animation_respects_reduced_motion(cx: &mut TestAppContext) {
904        cx.update(|cx| cx.set_reduce_motion(true));
905        let rendered_values = Rc::new(RefCell::new(Vec::new()));
906        let window = cx.open_window(size(px(100.0), px(100.0)), {
907            let rendered_values = rendered_values.clone();
908            move |_, _| SpringAnimationTestView {
909                target: px(100.0),
910                initial: None,
911                playback: SpringPlayback::Running,
912                rendered_values,
913            }
914        });
915        cx.run_until_parked();
916
917        assert_eq!(*rendered_values.borrow(), vec![px(100.0)]);
918        assert_eq!(simulate_next_frame(&window, cx), 0);
919    }
920
921    #[gpui::test]
922    fn test_repeating_animation_schedules_animation_frames(cx: &mut TestAppContext) {
923        let (rendered_deltas, window) = open_test_window(cx);
924
925        assert_eq!(rendered_deltas.borrow().len(), 1);
926
927        for expected_frames in 2..=3 {
928            assert_eq!(simulate_next_frame(&window, cx), 1);
929            assert_eq!(rendered_deltas.borrow().len(), expected_frames);
930        }
931    }
932
933    #[gpui::test]
934    fn test_max_fps_schedules_timer_driven_frames(cx: &mut TestAppContext) {
935        let (rendered_deltas, window) = open_test_window_with_max_fps(cx, Some(10.0));
936
937        // The test scheduler's clock jitters forward slightly on each poll,
938        // so compare against expectations loosely.
939        let assert_deltas_approx_eq = |expected: &[f32]| {
940            let actual = rendered_deltas.borrow();
941            assert_eq!(actual.len(), expected.len(), "deltas: {actual:?}");
942            for (actual, expected) in actual.iter().zip(expected) {
943                assert!(
944                    (actual - expected).abs() < 1e-2,
945                    "expected {expected}, got {actual}"
946                );
947            }
948        };
949
950        assert_deltas_approx_eq(&[0.0]);
951
952        // No per-frame callback is scheduled; re-renders are timer-driven.
953        assert_eq!(simulate_next_frame(&window, cx), 0);
954        assert_deltas_approx_eq(&[0.0]);
955
956        cx.executor().advance_clock(Duration::from_millis(105));
957        cx.run_until_parked();
958        assert_deltas_approx_eq(&[0.0, 0.105]);
959
960        cx.executor().advance_clock(Duration::from_millis(105));
961        cx.run_until_parked();
962        assert_deltas_approx_eq(&[0.0, 0.105, 0.21]);
963    }
964
965    #[gpui::test]
966    fn test_synced_animations_share_phase_across_elements(cx: &mut TestAppContext) {
967        let first_deltas = Rc::new(RefCell::new(Vec::new()));
968        let second_deltas = Rc::new(RefCell::new(Vec::new()));
969        let window = cx.open_window(size(px(100.), px(100.)), {
970            let first_deltas = first_deltas.clone();
971            let second_deltas = second_deltas.clone();
972            move |_, _| SyncedAnimationTestView {
973                show_second: false,
974                first_deltas,
975                second_deltas,
976            }
977        });
978        cx.run_until_parked();
979
980        assert_eq!(*first_deltas.borrow(), vec![0.0]);
981
982        cx.executor().advance_clock(Duration::from_millis(250));
983        simulate_next_frame(&window, cx);
984        assert_eq!(*first_deltas.borrow(), vec![0.0, 0.25]);
985
986        // The second element mounts a quarter through the cycle, yet renders
987        // the shared phase rather than starting at zero.
988        window
989            .update(cx, |view, _, cx| {
990                view.show_second = true;
991                cx.notify();
992            })
993            .unwrap();
994        cx.run_until_parked();
995        cx.executor().advance_clock(Duration::from_millis(250));
996        simulate_next_frame(&window, cx);
997
998        assert_eq!(*second_deltas.borrow().last().unwrap(), 0.5);
999        assert_eq!(
1000            *first_deltas.borrow().last().unwrap(),
1001            *second_deltas.borrow().last().unwrap()
1002        );
1003        assert!(second_deltas.borrow().iter().all(|delta| *delta > 0.0));
1004
1005        // The phase wraps around each full cycle.
1006        cx.executor().advance_clock(Duration::from_millis(2250));
1007        simulate_next_frame(&window, cx);
1008        assert_eq!(*first_deltas.borrow().last().unwrap(), 0.75);
1009
1010        // Sub-second precision survives months of uptime: converting the raw
1011        // elapsed time to f32 would round 0.25 away entirely.
1012        cx.executor()
1013            .advance_clock(Duration::from_secs(300 * 24 * 60 * 60) + Duration::from_millis(500));
1014        simulate_next_frame(&window, cx);
1015        assert_eq!(*first_deltas.borrow().last().unwrap(), 0.25);
1016    }
1017
1018    #[gpui::test]
1019    fn test_reduce_motion_renders_single_static_frame(cx: &mut TestAppContext) {
1020        cx.update(|cx| cx.set_reduce_motion(true));
1021        let (rendered_deltas, window) = open_test_window(cx);
1022
1023        assert_eq!(*rendered_deltas.borrow(), vec![0.0]);
1024
1025        assert_eq!(simulate_next_frame(&window, cx), 0);
1026        assert_eq!(*rendered_deltas.borrow(), vec![0.0]);
1027    }
1028}