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}