1use std::time::Duration;
4
5use gpui::{Animation, AnimationElement, AnimationExt, ElementId, IntoElement, Styled, px};
6use gpui_kit_theme::{SpringPreset, Theme};
7
8use super::Spring;
9use super::easing::{CubicBezier, Easing};
10
11#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct MotionSpec {
21 pub duration_ms: u64,
22 pub delay_ms: u64,
23 pub curve: CubicBezier,
24 spring: Option<Spring>,
26}
27
28impl MotionSpec {
29 pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
30 Self {
31 duration_ms,
32 delay_ms: 0,
33 curve,
34 spring: None,
35 }
36 }
37
38 pub fn sprung(spring: Spring) -> Self {
44 let settle = spring.settle_time().as_millis() as u64;
45 Self {
46 duration_ms: settle.max(1),
47 delay_ms: 0,
48 curve: CubicBezier::new(0.0, 0.0, 1.0, 1.0),
49 spring: Some(spring),
50 }
51 }
52
53 pub const fn with_delay(mut self, delay_ms: u64) -> Self {
54 self.delay_ms = delay_ms;
55 self
56 }
57
58 pub fn is_sprung(self) -> bool {
59 self.spring.is_some()
60 }
61
62 pub fn spring(self) -> Option<Spring> {
65 self.spring
66 }
67
68 pub fn total(self) -> Duration {
69 Duration::from_millis(self.duration_ms + self.delay_ms)
70 }
71
72 pub fn progress(self, raw: f32) -> f32 {
73 let total = (self.duration_ms + self.delay_ms) as f32;
74 if total == 0.0 || self.duration_ms == 0 {
75 return 1.0;
76 }
77 let local = ((raw.clamp(0.0, 1.0) * total - self.delay_ms as f32)
78 / self.duration_ms as f32)
79 .clamp(0.0, 1.0);
80 match self.spring {
81 Some(spring) => {
82 if local >= 1.0 {
83 return 1.0;
84 }
85 spring.value(Duration::from_secs_f32(
86 local * self.duration_ms as f32 / 1000.0,
87 ))
88 }
89 None => self.curve.eval(local),
90 }
91 }
92
93 pub fn time_at(self, value: f32) -> Duration {
104 let total_ms = self.duration_ms + self.delay_ms;
105 if value <= 0.0 || total_ms == 0 {
106 return Duration::ZERO;
107 }
108 for ms in 0..=total_ms {
109 if self.progress(ms as f32 / total_ms as f32) >= value {
110 return Duration::from_millis(ms);
111 }
112 }
113 Duration::from_millis(total_ms)
114 }
115
116 pub fn after(mut self, previous: MotionSpec) -> Self {
123 self.delay_ms += previous.total().as_millis() as u64;
124 self
125 }
126
127 pub fn animation(self) -> Animation {
134 Animation::new(self.total()).with_easing(move |delta| self.progress(delta).clamp(0.0, 1.0))
135 }
136
137 pub fn repeating(self) -> Animation {
138 Animation::new(self.total()).repeat()
139 }
140}
141
142pub fn entrance(theme: &Theme) -> MotionSpec {
144 MotionSpec::new(theme.motion.entrance_ms, Easing::Settle.curve(theme))
145}
146
147pub fn menu(theme: &Theme) -> MotionSpec {
149 MotionSpec::new(theme.motion.menu_ms, Easing::Standard.curve(theme))
150}
151
152pub fn dialog(theme: &Theme) -> MotionSpec {
154 MotionSpec::new(theme.motion.dialog_ms, Easing::Standard.curve(theme))
155}
156
157pub fn dialog_arrival(theme: &Theme) -> MotionSpec {
160 MotionSpec::sprung(Spring::preset(theme, SpringPreset::Smooth))
161}
162
163pub fn state_change(theme: &Theme) -> MotionSpec {
166 MotionSpec::new(theme.motion.quick_ms, Easing::Standard.curve(theme))
167}
168
169pub fn resize(theme: &Theme) -> MotionSpec {
172 MotionSpec::new(theme.motion.resize_ms, Easing::Standard.curve(theme))
173}
174
175pub fn tracking(theme: &Theme) -> MotionSpec {
178 MotionSpec::sprung(Spring::preset(theme, SpringPreset::Grab))
179}
180
181pub fn fade_in<E>(id: impl Into<ElementId>, theme: &Theme, element: E) -> AnimationElement<E>
183where
184 E: Styled + IntoElement + 'static,
185{
186 element.with_animation(id, entrance(theme).animation(), |element, progress| {
187 element
188 .relative()
189 .opacity(progress)
190 .top(px(4.0 * (1.0 - progress)))
191 })
192}
193
194pub fn menu_in<E>(id: impl Into<ElementId>, theme: &Theme, element: E) -> AnimationElement<E>
196where
197 E: Styled + IntoElement + 'static,
198{
199 element.with_animation(id, menu(theme).animation(), |element, progress| {
200 element
201 .relative()
202 .opacity(0.3 + 0.7 * progress)
203 .top(px(-2.0 * (1.0 - progress)))
204 })
205}
206
207pub fn dialog_in<E>(id: impl Into<ElementId>, theme: &Theme, element: E) -> AnimationElement<E>
210where
211 E: Styled + IntoElement + 'static,
212{
213 let spec = dialog_arrival(theme);
214 element.with_animation(id, spec.animation(), |element, progress| {
215 element
216 .relative()
217 .opacity(progress)
218 .top(px(8.0 * (1.0 - progress)))
219 })
220}
221
222pub fn row_in<E>(
229 id: impl Into<ElementId>,
230 theme: &Theme,
231 index: usize,
232 count: usize,
233 element: E,
234) -> AnimationElement<E>
235where
236 E: Styled + IntoElement + 'static,
237{
238 let spec = super::Stagger::rows().spec(index, count, menu(theme));
239 element.with_animation(id, spec.animation(), |element, progress| {
240 element.opacity(progress)
241 })
242}
243
244pub fn content_in<E>(id: impl Into<ElementId>, theme: &Theme, element: E) -> AnimationElement<E>
249where
250 E: Styled + IntoElement + 'static,
251{
252 element.with_animation(id, entrance(theme).animation(), |element, progress| {
253 element
254 .relative()
255 .opacity(progress)
256 .top(px(6.0 * (1.0 - progress)))
257 })
258}
259
260pub fn shimmer_offset(phase: f32, band: f32) -> f32 {
266 phase.rem_euclid(1.0) * (1.0 + band) - band
267}
268
269pub fn pulse_wave(phase: f32) -> f32 {
271 0.5 - 0.5 * (phase * std::f32::consts::TAU).cos()
272}
273
274pub fn gradient_opacity(phase: f32, dim: f32) -> f32 {
276 let phase = phase.rem_euclid(1.0);
277 if phase < 0.45 {
278 1.0 + (dim - 1.0) * (phase / 0.45)
279 } else if phase < 0.92 {
280 dim
281 } else {
282 dim + (1.0 - dim) * ((phase - 0.92) / 0.08)
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn delayed_specs_hold_then_finish() {
292 let spec = MotionSpec::new(500, CubicBezier::new(0.0, 0.0, 1.0, 1.0)).with_delay(500);
293 assert_eq!(spec.progress(0.25), 0.0);
294 assert_eq!(spec.progress(1.0), 1.0);
295 }
296
297 #[test]
298 fn a_zero_duration_spec_is_already_complete() {
299 let spec = MotionSpec::new(0, CubicBezier::new(0.0, 0.0, 1.0, 1.0));
300 assert_eq!(spec.progress(0.0), 1.0);
301 }
302
303 #[test]
304 fn theme_presets_carry_their_token_durations() {
305 let theme = Theme::studio_dark();
306 assert_eq!(menu(&theme).duration_ms, theme.motion.menu_ms);
307 assert_eq!(dialog(&theme).duration_ms, theme.motion.dialog_ms);
308 assert_eq!(entrance(&theme).duration_ms, theme.motion.entrance_ms);
309 }
310
311 #[test]
312 fn a_time_inverts_the_progress_that_produced_it() {
313 let spec = MotionSpec::new(200, Easing::Standard.curve(&Theme::studio_dark()));
314 for step in 1..10 {
315 let value = step as f32 / 10.0;
316 let reached = spec.time_at(value);
317 let there = spec.progress(reached.as_secs_f32() / spec.total().as_secs_f32());
318 assert!(
319 (there - value).abs() < 0.02,
320 "{value} was found at {reached:?}, which is {there}"
321 );
322 }
323 }
324
325 #[test]
326 fn the_ends_of_a_run_are_where_they_belong() {
327 let spec = MotionSpec::new(200, CubicBezier::new(0.0, 0.0, 1.0, 1.0)).with_delay(100);
328 assert_eq!(spec.time_at(0.0), Duration::ZERO);
329 assert_eq!(spec.time_at(1.0), spec.total());
330 assert!(spec.time_at(0.001) > Duration::from_millis(100));
332 assert_eq!(spec.time_at(0.5), Duration::from_millis(200));
333 }
334
335 #[test]
336 fn a_sprung_time_finds_the_first_crossing_rather_than_the_last() {
337 let spec = MotionSpec::sprung(Spring::new(400.0, 28.0, 1.0));
338 let crossed = spec.time_at(1.0);
339 assert!(
340 crossed < spec.total(),
341 "an underdamped spring reaches its target before it settles on it"
342 );
343 }
344
345 #[test]
346 fn a_spec_that_follows_another_starts_when_it_ends() {
347 let first = MotionSpec::new(200, CubicBezier::new(0.0, 0.0, 1.0, 1.0));
348 let second = MotionSpec::new(100, CubicBezier::new(0.0, 0.0, 1.0, 1.0))
349 .with_delay(50)
350 .after(first);
351 assert_eq!(second.delay_ms, 250);
352 assert_eq!(second.total(), Duration::from_millis(350));
353 }
354
355 #[test]
356 fn gradient_pulse_stays_in_range() {
357 for step in 0..200 {
358 let opacity = gradient_opacity(step as f32 / 100.0, 0.1);
359 assert!((0.1..=1.0).contains(&opacity));
360 }
361 }
362}