Skip to main content

guise/anim/
mod.rs

1//! Animation: easing curves, springs, keyframed motion, and the clocks that
2//! run them.
3//!
4//! Two layers, and the split is the point.
5//!
6//! The **description** is pure. A [`Motion`] is tracks of [`Keyframe`]s over
7//! a duration; a [`Sequence`] places motions on one clock; a [`Stagger`] maps
8//! an index to a delay. `sample(t)` turns any of them into a [`Frame`] — the
9//! properties that have a value at that millisecond — with no state, no
10//! window, and nothing to tick. That is what makes the whole model unit
11//! testable and a paused animation free.
12//!
13//! The **clock** is a thin shell over it. [`Animated`] plays a clip once when
14//! its element mounts (gpui's `with_animation` supplies the time);
15//! [`Animator`] is an entity that owns a playhead you can play, pause,
16//! reverse, scrub and re-speed. [`Presence`] is the special case worth its
17//! own type: it latches an element through an *exit* animation before
18//! unmounting, which a stateless conditional cannot do.
19//!
20//! [`Transition`](crate::Transition) and [`Collapse`](crate::Collapse) are the
21//! older, narrower wrappers over the same curves and still the shortest path
22//! to a fade or a reveal.
23//!
24//! ```ignore
25//! Animated::new("card")
26//!     .motion(
27//!         Motion::new()
28//!             .duration(420.0)
29//!             .ease(Easing::Out(Curve::Back))
30//!             .tween(Prop::Opacity, 0.0, 1.0)
31//!             .tween(Prop::Y, 12.0, 0.0),
32//!     )
33//!     .child(card)
34//! ```
35
36pub mod ease;
37
38mod animated;
39mod animator;
40mod clip;
41mod frame;
42mod macros;
43mod motion;
44mod motioned;
45mod presence;
46mod prop;
47mod sequence;
48mod spring;
49mod stagger;
50mod value;
51
52pub use animated::Animated;
53pub use animator::{Animator, AnimatorEvent};
54pub use clip::Clip;
55pub use ease::Curve;
56pub use frame::Frame;
57pub use motion::{IntoKeyframe, Keyframe, Loop, Motion, Track, SLIDE_DISTANCE};
58pub use motioned::Motioned;
59pub use presence::{Presence, PresenceEvent};
60pub use prop::Prop;
61pub use sequence::{At, Sequence};
62pub use spring::Spring;
63pub use stagger::{Stagger, StaggerAxis, StaggerFrom};
64pub use value::AnimValue;
65
66use std::time::Duration;
67
68use gpui::Animation;
69
70/// A named easing curve, storable on builders (`Copy`). `apply` maps
71/// normalized time; `animation` builds a ready gpui [`Animation`].
72#[derive(Debug, Clone, Copy, PartialEq, Default)]
73pub enum Easing {
74  Linear,
75  EaseIn,
76  #[default]
77  EaseOut,
78  EaseInOut,
79  EaseInCubic,
80  EaseOutCubic,
81  EaseInOutCubic,
82  EaseOutQuint,
83  EaseOutExpo,
84  EaseOutBack,
85  EaseOutElastic,
86  EaseOutBounce,
87  /// A curve accelerating out of rest: `In(Curve::Quad)` is anime.js's
88  /// `inQuad`.
89  In(Curve),
90  /// A curve decelerating into rest — the one most UI motion wants.
91  Out(Curve),
92  /// Accelerate, then decelerate.
93  InOut(Curve),
94  /// `n` equal jumps instead of a smooth ramp (CSS `steps(n, end)`).
95  Steps(u32),
96  /// CSS `cubic-bezier(x1, y1, x2, y2)`.
97  CubicBezier(f32, f32, f32, f32),
98  /// Physical spring; its duration comes from the spring itself.
99  Spring(Spring),
100}
101
102impl Easing {
103  pub fn apply(self, t: f32) -> f32 {
104    match self {
105      Easing::Linear => ease::linear(t),
106      Easing::EaseIn => ease::ease_in(t),
107      Easing::EaseOut => ease::ease_out(t),
108      Easing::EaseInOut => ease::ease_in_out(t),
109      Easing::EaseInCubic => ease::ease_in_cubic(t),
110      Easing::EaseOutCubic => ease::ease_out_cubic(t),
111      Easing::EaseInOutCubic => ease::ease_in_out_cubic(t),
112      Easing::EaseOutQuint => ease::ease_out_quint(t),
113      Easing::EaseOutExpo => ease::ease_out_expo(t),
114      Easing::EaseOutBack => ease::ease_out_back(t),
115      Easing::EaseOutElastic => ease::ease_out_elastic(t),
116      Easing::EaseOutBounce => ease::ease_out_bounce(t),
117      Easing::In(curve) => ease::curve_in(curve, t),
118      Easing::Out(curve) => ease::curve_out(curve, t),
119      Easing::InOut(curve) => ease::curve_in_out(curve, t),
120      Easing::Steps(count) => ease::steps(count, t),
121      Easing::CubicBezier(x1, y1, x2, y2) => ease::cubic_bezier(x1, y1, x2, y2, t),
122      Easing::Spring(spring) => spring.easing()(t),
123    }
124  }
125
126  /// A gpui [`Animation`] running this curve, **clamped** into `0..=1`.
127  /// `duration_ms` is ignored for springs — they settle on their own clock.
128  ///
129  /// gpui debug-asserts that an animation's easing output stays within
130  /// `0..=1`, which overshooting curves (`Spring`, `EaseOutBack`,
131  /// `EaseOutElastic`, wide cubic-beziers) violate by design — unclamped
132  /// they abort any debug build. The clamp flattens the overshoot peaks;
133  /// to keep them, run [`clock`](Self::clock) and apply the curve inside
134  /// the animator closure, where any value is legal:
135  ///
136  /// ```ignore
137  /// el.with_animation(id, easing.clock(200), move |el, t| {
138  ///     let delta = easing.apply(t); // may overshoot past 1.0
139  ///     el.ml(px((1.0 - delta) * 8.0))
140  /// })
141  /// ```
142  pub fn animation(self, duration_ms: u64) -> Animation {
143    self
144      .clock(duration_ms)
145      .with_easing(move |t| self.apply(t).clamp(0.0, 1.0))
146  }
147
148  /// The un-eased gpui [`Animation`] for this curve: a linear clock sized
149  /// for it (springs use their settle time). Pair with
150  /// [`apply`](Self::apply) in the animator closure — see
151  /// [`animation`](Self::animation) for why overshooting curves must run
152  /// animator-side.
153  pub fn clock(self, duration_ms: u64) -> Animation {
154    let duration = match self {
155      Easing::Spring(spring) => Duration::from_secs_f32(spring.settle_seconds()),
156      _ => Duration::from_millis(duration_ms),
157    };
158    Animation::new(duration)
159  }
160}
161
162#[cfg(test)]
163mod tests {
164  use super::*;
165
166  #[test]
167  fn every_variant_hits_the_endpoints() {
168    let variants = [
169      Easing::Linear,
170      Easing::EaseIn,
171      Easing::EaseOut,
172      Easing::EaseInOut,
173      Easing::EaseInCubic,
174      Easing::EaseOutCubic,
175      Easing::EaseInOutCubic,
176      Easing::EaseOutQuint,
177      Easing::EaseOutExpo,
178      Easing::EaseOutBack,
179      Easing::EaseOutElastic,
180      Easing::EaseOutBounce,
181      Easing::CubicBezier(0.25, 0.1, 0.25, 1.0),
182      Easing::Spring(Spring::default()),
183      Easing::Steps(4),
184    ];
185    let variants = variants.into_iter().chain(
186      Curve::ALL
187        .iter()
188        .flat_map(|c| [Easing::In(*c), Easing::Out(*c), Easing::InOut(*c)]),
189    );
190    for easing in variants {
191      assert!(easing.apply(0.0).abs() < 1e-3, "{easing:?} at 0");
192      assert!((easing.apply(1.0) - 1.0).abs() < 1e-3, "{easing:?} at 1");
193    }
194  }
195
196  /// Overshoot is a feature of these curves — and exactly why the entity
197  /// animators run them via `clock()` + `apply()` instead of gpui's easing
198  /// slot, which debug-asserts its output into `0..=1`.
199  #[test]
200  fn overshooting_curves_really_overshoot() {
201    let overshooters = [
202      Easing::EaseOutBack,
203      Easing::EaseOutElastic,
204      Easing::Spring(Spring::default()),
205      Easing::Out(Curve::Back),
206      Easing::Out(Curve::Elastic),
207    ];
208    for easing in overshooters {
209      let peak = (1..100)
210        .map(|i| easing.apply(i as f32 / 100.0))
211        .fold(f32::MIN, f32::max);
212      assert!(peak > 1.0, "{easing:?} never exceeded 1.0 (peak {peak})");
213    }
214  }
215}