Skip to main content

gpui_kit/motion/
stagger.rs

1//! Orchestration for motion that runs across a group of elements.
2
3use std::time::Duration;
4
5use super::MotionSpec;
6
7/// How far apart two neighbouring rows start, and how many rows the wave is
8/// allowed to span before it compresses instead of growing.
9const ROW_STEP_MS: u64 = 16;
10const ROW_WINDOW: usize = 8;
11
12/// The longest a row wave can last, whatever the row count.
13pub const ROW_STAGGER_CAP: Duration = Duration::from_millis(ROW_STEP_MS * (ROW_WINDOW as u64 - 1));
14
15/// Delays each item in a list so a group animates as a wave.
16///
17/// The total is capped so a long list stays responsive: past `max_items` the
18/// per-item delay shrinks instead of the sequence growing without bound.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Stagger {
21    step: Duration,
22    max_items: usize,
23    reversed: bool,
24}
25
26impl Stagger {
27    pub fn new(step: Duration, max_items: usize) -> Self {
28        Self {
29            step,
30            max_items: max_items.max(2),
31            reversed: false,
32        }
33    }
34
35    pub fn from_millis(step_ms: u64) -> Self {
36        Self::new(Duration::from_millis(step_ms), 12)
37    }
38
39    /// The wave a list of menu-shaped rows arrives on.
40    ///
41    /// Sixteen milliseconds a row across at most eight rows, so the last row
42    /// in a fifty-row menu starts 112ms after the first rather than a second
43    /// later: past eight rows the step shrinks to keep the window fixed.
44    pub fn rows() -> Self {
45        Self::new(Duration::from_millis(ROW_STEP_MS), ROW_WINDOW)
46    }
47
48    pub fn max_items(mut self, max_items: usize) -> Self {
49        self.max_items = max_items.max(2);
50        self
51    }
52
53    /// Runs the wave from the last item to the first.
54    ///
55    /// This is the order a list leaves on. A list that arrived from the top
56    /// down should depart from the bottom up, so the row the user is looking
57    /// at — the one they just acted on, at the top — is the last to go rather
58    /// than the first, and the group empties away from them instead of out
59    /// from under them.
60    pub fn reversed(mut self) -> Self {
61        self.reversed = true;
62        self
63    }
64
65    pub fn is_reversed(&self) -> bool {
66        self.reversed
67    }
68
69    /// How far apart two neighbours start, for a group of `count`.
70    ///
71    /// Past `max_items` the window is fixed, so the step shrinks to fit it.
72    fn step_for(&self, count: usize) -> Duration {
73        if count > self.max_items {
74            self.step
75                .mul_f32((self.max_items - 1) as f32 / (count - 1) as f32)
76        } else {
77            self.step
78        }
79    }
80
81    pub fn delay(&self, index: usize, count: usize) -> Duration {
82        if count <= 1 {
83            return Duration::ZERO;
84        }
85        let last = count - 1;
86        let place = index.min(last);
87        let place = if self.reversed { last - place } else { place };
88        self.step_for(count).mul_f32(place as f32)
89    }
90
91    /// The window the whole group occupies, including the last item's span.
92    ///
93    /// Reversing changes which item waits longest, not how long the group
94    /// takes, so the window is measured from the furthest place rather than
95    /// from the last index.
96    pub fn total(&self, count: usize, spec: MotionSpec) -> Duration {
97        let furthest = self.step_for(count).mul_f32(count.saturating_sub(1) as f32);
98        furthest + spec.total()
99    }
100
101    /// Applies the delay for one item to a spec.
102    pub fn spec(&self, index: usize, count: usize, spec: MotionSpec) -> MotionSpec {
103        spec.with_delay(spec.delay_ms + self.delay(index, count).as_millis() as u64)
104    }
105}
106
107/// The repeating phase for item `index` in a looping group animation, such as
108/// a chase or wave loader.
109pub fn staggered_phase(raw: f32, index: usize, stagger: f32) -> f32 {
110    (raw - index as f32 * stagger).rem_euclid(1.0)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::motion::CubicBezier;
117
118    fn spec() -> MotionSpec {
119        MotionSpec::new(100, CubicBezier::new(0.0, 0.0, 1.0, 1.0))
120    }
121
122    #[test]
123    fn the_first_item_never_waits() {
124        let stagger = Stagger::from_millis(30);
125        assert_eq!(stagger.delay(0, 5), Duration::ZERO);
126        assert_eq!(stagger.delay(0, 1), Duration::ZERO);
127    }
128
129    #[test]
130    fn delays_increase_with_position() {
131        let stagger = Stagger::from_millis(30);
132        assert_eq!(stagger.delay(1, 5), Duration::from_millis(30));
133        assert_eq!(stagger.delay(4, 5), Duration::from_millis(120));
134    }
135
136    #[test]
137    fn a_long_list_compresses_instead_of_growing_without_bound() {
138        let stagger = Stagger::from_millis(30).max_items(10);
139        let short = stagger.total(10, spec());
140        for count in [200, 2000] {
141            let long = stagger.total(count, spec());
142            assert!(
143                long.abs_diff(short) < Duration::from_millis(1),
144                "{count} items took {long:?} against {short:?} for ten"
145            );
146        }
147    }
148
149    #[test]
150    fn the_group_window_covers_the_last_items_span() {
151        let stagger = Stagger::from_millis(30);
152        assert_eq!(stagger.total(3, spec()), Duration::from_millis(160));
153    }
154
155    #[test]
156    fn a_row_wave_never_outlasts_its_cap() {
157        let stagger = Stagger::rows();
158        assert_eq!(stagger.delay(0, 50), Duration::ZERO);
159        for count in [2, 8, 50, 500] {
160            // Compared in whole milliseconds, which is the granularity the cap
161            // is stated in; the compressed step is a float division and lands
162            // a few tens of nanoseconds either side of it.
163            let waited = stagger.delay(count - 1, count);
164            assert!(
165                waited.as_millis() <= ROW_STAGGER_CAP.as_millis(),
166                "{count} rows waited {waited:?}"
167            );
168        }
169        assert_eq!(stagger.delay(7, 8), ROW_STAGGER_CAP);
170    }
171
172    #[test]
173    fn a_reversed_wave_starts_at_the_far_end() {
174        let stagger = Stagger::from_millis(30).reversed();
175        assert!(stagger.is_reversed());
176        assert_eq!(stagger.delay(4, 5), Duration::ZERO);
177        assert_eq!(stagger.delay(3, 5), Duration::from_millis(30));
178        assert_eq!(stagger.delay(0, 5), Duration::from_millis(120));
179    }
180
181    #[test]
182    fn reversing_does_not_change_how_long_the_group_takes() {
183        let forward = Stagger::from_millis(30);
184        for count in [1, 2, 5, 200] {
185            assert_eq!(
186                forward.total(count, spec()),
187                forward.reversed().total(count, spec()),
188                "{count} items"
189            );
190        }
191    }
192
193    #[test]
194    fn a_reversed_wave_compresses_the_same_way_a_forward_one_does() {
195        let stagger = Stagger::rows().reversed();
196        for count in [2, 8, 50, 500] {
197            let waited = stagger.delay(0, count);
198            assert!(
199                waited.as_millis() <= ROW_STAGGER_CAP.as_millis(),
200                "{count} rows waited {waited:?}"
201            );
202        }
203        assert_eq!(stagger.delay(7, 8), Duration::ZERO);
204        assert_eq!(stagger.delay(0, 8), ROW_STAGGER_CAP);
205    }
206
207    #[test]
208    fn a_staggered_phase_wraps_within_one_cycle() {
209        for index in 0..6 {
210            let phase = staggered_phase(0.2, index, 0.15);
211            assert!((0.0..1.0).contains(&phase));
212        }
213    }
214}