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
30const DEFAULT_SPRING_EPSILON: f32 = 0.001;
32
33pub 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#[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#[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#[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
243pub 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
351pub 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#[derive(Clone, Copy, Debug)]
404pub struct Spring {
405 response: Duration,
406 damping: f32,
407 epsilon: f32,
408 travel: bool,
409}
410
411#[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 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 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 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 pub const fn with_travel(mut self, travel: bool) -> Self {
498 self.travel = travel;
499 self
500 }
501
502 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 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 pub const fn epsilon(self) -> f32 {
531 self.epsilon
532 }
533
534 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
553pub 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 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 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 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 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}