Skip to main content

liora_components/
motion.rs

1use gpui::{Animation, AnimationElement, AnimationExt, ElementId, IntoElement, Styled, radians};
2use liora_icons::Icon;
3use std::{f32::consts::TAU, time::Duration};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum MotionDuration {
7    Fast,
8    Normal,
9    Slow,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum MotionEasing {
14    Linear,
15    EaseInOut,
16    EaseOut,
17    Elastic,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum MotionPreset {
22    FadeIn,
23    FadeOut,
24    PopIn,
25    Pulse,
26    Spin,
27    ElasticSlide,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum MotionCurve {
32    Linear,
33    EaseInOut,
34    EaseOut,
35    ElasticSnap,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum FadeDirection {
40    In,
41    Out,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct Interpolator {
46    from: f32,
47    to: f32,
48}
49
50impl MotionDuration {
51    pub fn as_duration(self) -> Duration {
52        match self {
53            Self::Fast => Duration::from_millis(220),
54            Self::Normal => Duration::from_millis(320),
55            Self::Slow => Duration::from_millis(900),
56        }
57    }
58}
59
60impl Interpolator {
61    pub fn new(from: f32, to: f32) -> Self {
62        Self { from, to }
63    }
64
65    pub fn from(&self) -> f32 {
66        self.from
67    }
68
69    pub fn to(&self) -> f32 {
70        self.to
71    }
72
73    pub fn sample(&self, delta: f32) -> f32 {
74        self.sample_with(delta, MotionCurve::Linear)
75    }
76
77    pub fn sample_with(&self, delta: f32, curve: MotionCurve) -> f32 {
78        self.from + (self.to - self.from) * curve_progress(delta, curve)
79    }
80
81    pub fn map(&self, delta: f32, mapper: impl FnOnce(f32) -> f32) -> f32 {
82        self.from + (self.to - self.from) * mapper(delta.clamp(0.0, 1.0))
83    }
84}
85
86pub fn motion_animation(duration: MotionDuration, easing: MotionEasing) -> Animation {
87    Animation::new(duration.as_duration()).with_easing(move |delta| ease(delta, easing))
88}
89
90pub fn repeating_motion_animation(duration: MotionDuration, easing: MotionEasing) -> Animation {
91    motion_animation(duration, easing).repeat()
92}
93
94pub fn fade<E>(
95    id: impl Into<ElementId>,
96    direction: FadeDirection,
97    element: E,
98) -> AnimationElement<E>
99where
100    E: Styled + IntoElement + 'static,
101{
102    element.with_animation(
103        ElementId::from(id.into()),
104        motion_animation(MotionDuration::Fast, MotionEasing::EaseOut),
105        move |element, delta| {
106            let opacity = match direction {
107                FadeDirection::In => delta,
108                FadeDirection::Out => 1.0 - delta,
109            };
110            element.opacity(opacity)
111        },
112    )
113}
114
115pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
116where
117    E: Styled + IntoElement + 'static,
118{
119    fade(id, FadeDirection::In, element)
120}
121
122pub fn fade_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
123where
124    E: Styled + IntoElement + 'static,
125{
126    fade(id, FadeDirection::Out, element)
127}
128
129pub fn pop_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
130where
131    E: Styled + IntoElement + 'static,
132{
133    element.with_animation(
134        ElementId::from(id.into()),
135        motion_animation(MotionDuration::Normal, MotionEasing::EaseOut),
136        |element, delta| element.opacity(0.86 + delta * 0.14),
137    )
138}
139
140pub fn pulse<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
141where
142    E: Styled + IntoElement + 'static,
143{
144    element.with_animation(
145        ElementId::from(id.into()),
146        repeating_motion_animation(MotionDuration::Slow, MotionEasing::EaseInOut),
147        |element, delta| element.opacity(0.62 + pulse_alpha(delta) * 0.38),
148    )
149}
150
151pub fn spin_icon(id: impl Into<ElementId>, icon: Icon) -> AnimationElement<Icon> {
152    icon.with_animation(
153        ElementId::from(id.into()),
154        repeating_motion_animation(MotionDuration::Slow, MotionEasing::Linear),
155        |icon, delta| icon.rotation(radians(delta * TAU)),
156    )
157}
158
159pub fn elastic_slide(delta: f32) -> f32 {
160    let t = delta.clamp(0.0, 1.0);
161    let c1 = 1.35;
162    let c3 = c1 + 1.0;
163    1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
164}
165
166pub fn elastic_snap(delta: f32) -> f32 {
167    let eased = gpui::ease_in_out(delta.clamp(0.0, 1.0));
168    let snap_start = 0.62;
169
170    if eased <= snap_start {
171        eased
172    } else {
173        let local = (eased - snap_start) / (1.0 - snap_start);
174        snap_start + (1.0 - snap_start) * elastic_slide(local)
175    }
176}
177
178pub fn slide_snap(from: f32, to: f32, delta: f32) -> f32 {
179    Interpolator::new(from, to).sample_with(delta, MotionCurve::ElasticSnap)
180}
181
182fn curve_progress(delta: f32, curve: MotionCurve) -> f32 {
183    match curve {
184        MotionCurve::Linear => gpui::linear(delta.clamp(0.0, 1.0)),
185        MotionCurve::EaseInOut => gpui::ease_in_out(delta.clamp(0.0, 1.0)),
186        MotionCurve::EaseOut => gpui::ease_out_quint()(delta.clamp(0.0, 1.0)),
187        MotionCurve::ElasticSnap => elastic_snap(delta),
188    }
189}
190
191fn ease(delta: f32, easing: MotionEasing) -> f32 {
192    match easing {
193        MotionEasing::Linear => gpui::linear(delta),
194        MotionEasing::EaseInOut => gpui::ease_in_out(delta),
195        MotionEasing::EaseOut => gpui::ease_out_quint()(delta),
196        MotionEasing::Elastic => elastic_slide(delta).clamp(0.0, 1.0),
197    }
198}
199
200fn pulse_alpha(delta: f32) -> f32 {
201    gpui::pulsating_between(0.0, 1.0)(delta)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn motion_duration_tokens_track_liora_defaults() {
210        assert_eq!(
211            MotionDuration::Fast.as_duration(),
212            Duration::from_millis(220)
213        );
214        assert_eq!(
215            MotionDuration::Normal.as_duration(),
216            Duration::from_millis(320)
217        );
218        assert_eq!(
219            MotionDuration::Slow.as_duration(),
220            Duration::from_millis(900)
221        );
222    }
223
224    #[test]
225    fn elastic_slide_overshoots_then_settles() {
226        assert!(elastic_slide(0.0).abs() < 0.000_01);
227        assert_eq!(elastic_slide(1.0), 1.0);
228        assert!(elastic_slide(0.7) > 1.0);
229    }
230
231    #[test]
232    fn interpolator_samples_common_curves() {
233        let interpolator = Interpolator::new(10.0, 20.0);
234
235        assert_eq!(interpolator.from(), 10.0);
236        assert_eq!(interpolator.to(), 20.0);
237        assert_eq!(interpolator.sample(0.5), 15.0);
238        assert!(interpolator.sample_with(0.25, MotionCurve::EaseInOut) < 12.5);
239        assert_eq!(interpolator.map(0.5, |delta| delta * delta), 12.5);
240    }
241
242    #[test]
243    fn elastic_snap_accelerates_decelerates_and_snaps() {
244        assert!(elastic_snap(0.25) < 0.25);
245        assert!((elastic_snap(1.0) - 1.0).abs() < 0.000_01);
246        assert!(elastic_snap(0.75) > 1.0);
247    }
248
249    #[test]
250    fn slide_snap_overshoots_toward_target() {
251        assert!(slide_snap(3.0, 21.0, 0.25) < 3.0 + (21.0 - 3.0) * 0.25);
252        assert!(slide_snap(3.0, 21.0, 0.75) > 21.0);
253        assert!(slide_snap(21.0, 3.0, 0.75) < 3.0);
254        assert_eq!(slide_snap(3.0, 21.0, 1.0), 21.0);
255    }
256
257    #[test]
258    fn elastic_easing_is_bounded_for_gpui_animation() {
259        let animation = motion_animation(MotionDuration::Normal, MotionEasing::Elastic);
260        let eased = (animation.easing)(0.7);
261
262        assert!((0.0..=1.0).contains(&eased));
263    }
264
265    #[test]
266    fn motion_presets_cover_requested_component_behaviors() {
267        let presets = [
268            MotionPreset::FadeIn,
269            MotionPreset::FadeOut,
270            MotionPreset::PopIn,
271            MotionPreset::Pulse,
272            MotionPreset::Spin,
273            MotionPreset::ElasticSlide,
274        ];
275
276        assert_eq!(presets.len(), 6);
277    }
278}