Skip to main content

gpui_authoring/elements/
animation.rs

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