Skip to main content

gpui_kit/motion/
presence.rs

1//! Enter and exit lifecycles for elements that must outlive their own removal.
2//!
3//! An element cannot animate out after it has been dropped from the tree, so a
4//! caller keeps rendering while [`Presence::is_rendered`] is true and drops the
5//! element only once the exit has finished.
6
7use std::time::Duration;
8
9use gpui::{App, Window};
10use web_time::Instant;
11
12use super::MotionSpec;
13
14/// Where an element is in its arrival or departure.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Phase {
17    Entering,
18    Present,
19    Exiting,
20    Gone,
21}
22
23/// An element's arrival and departure, either of which can be cancelled by the
24/// other while it is still running.
25///
26/// A cancelled phase is played backwards from where it had got to rather than
27/// restarted. The two phases are separate specifications with separate
28/// durations and separate curves, so "where it had got to" is a position and
29/// not a time: the visible progress is looked up in the other specification —
30/// [`MotionSpec::time_at`] — and the reversal starts from the point that
31/// produces it. An entrance cancelled at 30% therefore leaves from 30%,
32/// through the exit's own curve, in the part of the exit's time that is left
33/// once 70% of it is already behind.
34///
35/// This is deliberately not the velocity handover
36/// [`Transition`](super::Transition) performs on a retarget. A value aimed
37/// somewhere new is still going the way it was going; a phase that is
38/// cancelled has been told to go back, and carrying the speed across would
39/// mean an element on its way in overshooting past being present, which is not
40/// a state a lifecycle has.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct Presence {
43    phase: Phase,
44    elapsed: Duration,
45    enter: MotionSpec,
46    exit: MotionSpec,
47    last_frame: Option<Instant>,
48}
49
50impl Presence {
51    /// Starts hidden, so the first [`Presence::show`] animates in.
52    pub fn hidden(enter: MotionSpec, exit: MotionSpec) -> Self {
53        Self {
54            phase: Phase::Gone,
55            elapsed: Duration::ZERO,
56            enter,
57            exit,
58            last_frame: None,
59        }
60    }
61
62    /// Starts fully present, for content that exists before the first frame.
63    pub fn visible(enter: MotionSpec, exit: MotionSpec) -> Self {
64        Self {
65            phase: Phase::Present,
66            elapsed: Duration::ZERO,
67            enter,
68            exit,
69            last_frame: None,
70        }
71    }
72
73    pub fn phase(&self) -> Phase {
74        self.phase
75    }
76
77    /// True while the caller must keep the element in the tree, including for
78    /// the whole exit animation.
79    pub fn is_rendered(&self) -> bool {
80        self.phase != Phase::Gone
81    }
82
83    pub fn is_animating(&self) -> bool {
84        matches!(self.phase, Phase::Entering | Phase::Exiting)
85    }
86
87    /// 0 while absent, 1 while fully present.
88    pub fn progress(&self) -> f32 {
89        match self.phase {
90            Phase::Gone => 0.0,
91            Phase::Present => 1.0,
92            Phase::Entering => self.span_progress(self.enter),
93            Phase::Exiting => 1.0 - self.span_progress(self.exit),
94        }
95    }
96
97    fn span_progress(&self, spec: MotionSpec) -> f32 {
98        let total = spec.total().as_secs_f32();
99        if total <= 0.0 {
100            return 1.0;
101        }
102        spec.progress((self.elapsed.as_secs_f32() / total).clamp(0.0, 1.0))
103    }
104
105    /// Enters, or reverses an exit that is still in flight.
106    pub fn show(&mut self) {
107        match self.phase {
108            Phase::Present | Phase::Entering => {}
109            Phase::Gone => {
110                self.phase = Phase::Entering;
111                self.elapsed = Duration::ZERO;
112            }
113            Phase::Exiting => {
114                let visible = self.progress();
115                self.phase = Phase::Entering;
116                self.elapsed = self.enter.time_at(visible);
117            }
118        }
119    }
120
121    pub fn hide(&mut self) {
122        match self.phase {
123            Phase::Gone | Phase::Exiting => {}
124            Phase::Present => {
125                self.phase = Phase::Exiting;
126                self.elapsed = Duration::ZERO;
127            }
128            Phase::Entering => {
129                let visible = self.progress();
130                self.phase = Phase::Exiting;
131                // An exit runs from present to gone, so being `visible` at all
132                // means it has already covered the rest of its path.
133                self.elapsed = self.exit.time_at(1.0 - visible);
134            }
135        }
136    }
137
138    pub fn toggle(&mut self) {
139        if matches!(self.phase, Phase::Present | Phase::Entering) {
140            self.hide();
141        } else {
142            self.show();
143        }
144    }
145
146    /// Finishes the current phase instantly.
147    pub fn settle(&mut self) {
148        self.phase = match self.phase {
149            Phase::Entering | Phase::Present => Phase::Present,
150            Phase::Exiting | Phase::Gone => Phase::Gone,
151        };
152        self.elapsed = Duration::ZERO;
153    }
154
155    pub fn advance(&mut self, delta: Duration) {
156        if !self.is_animating() {
157            return;
158        }
159        self.elapsed += delta;
160        let span = match self.phase {
161            Phase::Entering => self.enter.total(),
162            _ => self.exit.total(),
163        };
164        if self.elapsed >= span {
165            self.settle();
166        }
167    }
168
169    /// Advances by the time since the previous frame and requests the next one
170    /// while a phase is running. Reduced motion skips straight to the end.
171    pub fn animate(&mut self, window: &mut Window, cx: &mut App) -> f32 {
172        if cx.reduce_motion() {
173            self.settle();
174            self.last_frame = None;
175            return self.progress();
176        }
177
178        let now = cx.background_executor().now();
179        if let Some(last) = self.last_frame {
180            self.advance(now.saturating_duration_since(last));
181        }
182        if self.is_animating() {
183            self.last_frame = Some(now);
184            window.request_animation_frame();
185        } else {
186            self.last_frame = None;
187        }
188        self.progress()
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::motion::{CubicBezier, MotionSpec};
196
197    fn presence() -> Presence {
198        let linear = |ms| MotionSpec::new(ms, CubicBezier::new(0.0, 0.0, 1.0, 1.0));
199        Presence::hidden(linear(200), linear(100))
200    }
201
202    #[test]
203    fn a_hidden_presence_renders_nothing() {
204        let presence = presence();
205        assert!(!presence.is_rendered());
206        assert_eq!(presence.progress(), 0.0);
207    }
208
209    #[test]
210    fn entering_becomes_present_only_after_the_full_span() {
211        let mut presence = presence();
212        presence.show();
213        assert_eq!(presence.phase(), Phase::Entering);
214        presence.advance(Duration::from_millis(100));
215        assert_eq!(presence.phase(), Phase::Entering);
216        assert!((presence.progress() - 0.5).abs() < 0.05);
217        presence.advance(Duration::from_millis(100));
218        assert_eq!(presence.phase(), Phase::Present);
219        assert_eq!(presence.progress(), 1.0);
220    }
221
222    #[test]
223    fn an_exiting_element_stays_rendered_until_the_exit_completes() {
224        let mut presence = presence();
225        presence.show();
226        presence.advance(Duration::from_millis(200));
227        presence.hide();
228
229        assert!(
230            presence.is_rendered(),
231            "the exit needs the element on screen"
232        );
233        presence.advance(Duration::from_millis(50));
234        assert!(presence.is_rendered());
235        assert!(presence.progress() < 1.0);
236        presence.advance(Duration::from_millis(50));
237        assert_eq!(presence.phase(), Phase::Gone);
238        assert!(!presence.is_rendered());
239    }
240
241    #[test]
242    fn reversing_an_exit_resumes_from_what_is_on_screen() {
243        let mut presence = presence();
244        presence.show();
245        presence.advance(Duration::from_millis(200));
246        presence.hide();
247        presence.advance(Duration::from_millis(50));
248        let interrupted = presence.progress();
249
250        presence.show();
251        assert_eq!(presence.phase(), Phase::Entering);
252        assert!((presence.progress() - interrupted).abs() < 0.05);
253    }
254
255    #[test]
256    fn reversing_an_entrance_resumes_from_what_is_on_screen() {
257        let mut presence = presence();
258        presence.show();
259        presence.advance(Duration::from_millis(100));
260        let interrupted = presence.progress();
261
262        presence.hide();
263        assert_eq!(presence.phase(), Phase::Exiting);
264        assert!((presence.progress() - interrupted).abs() < 0.05);
265    }
266
267    /// Enter and exit on a curve that is nowhere near linear, so a reversal
268    /// that assumed the two timelines were proportional would be caught.
269    fn curved() -> Presence {
270        let curve = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
271        Presence::hidden(MotionSpec::new(200, curve), MotionSpec::new(100, curve))
272    }
273
274    /// Runs the current phase out a millisecond at a time and reports how long
275    /// it took.
276    fn run_out(presence: &mut Presence) -> Duration {
277        let mut elapsed = Duration::ZERO;
278        while presence.is_animating() {
279            presence.advance(Duration::from_millis(1));
280            elapsed += Duration::from_millis(1);
281        }
282        elapsed
283    }
284
285    #[test]
286    fn a_cancelled_entrance_leaves_from_the_opacity_it_reached() {
287        let mut presence = curved();
288        presence.show();
289        presence.advance(Duration::from_millis(60));
290        let interrupted = presence.progress();
291        assert!(interrupted < 0.25, "the curve starts slowly: {interrupted}");
292
293        presence.hide();
294        assert!(
295            (presence.progress() - interrupted).abs() < 0.01,
296            "the element jumped from {interrupted} to {} on being cancelled",
297            presence.progress()
298        );
299        let took = run_out(&mut presence);
300        assert_eq!(presence.phase(), Phase::Gone);
301        assert!(
302            took < Duration::from_millis(40),
303            "leaving from {interrupted} took {took:?} of a 100ms exit"
304        );
305    }
306
307    #[test]
308    fn a_cancelled_exit_comes_back_the_way_it_went() {
309        let mut presence = curved();
310        presence.show();
311        presence.advance(Duration::from_millis(200));
312        presence.hide();
313        presence.advance(Duration::from_millis(40));
314        let interrupted = presence.progress();
315
316        presence.show();
317        assert!(
318            (presence.progress() - interrupted).abs() < 0.01,
319            "the element jumped from {interrupted} to {}",
320            presence.progress()
321        );
322        let took = run_out(&mut presence);
323        assert_eq!(presence.phase(), Phase::Present);
324        assert!(
325            took < Duration::from_millis(200),
326            "returning from {interrupted} took the whole {took:?} entrance"
327        );
328    }
329
330    #[test]
331    fn the_earlier_a_phase_is_cancelled_the_less_of_the_other_it_costs() {
332        let took = |after_ms| {
333            let mut presence = curved();
334            presence.show();
335            presence.advance(Duration::from_millis(after_ms));
336            presence.hide();
337            run_out(&mut presence)
338        };
339        assert!(
340            took(40) < took(100) && took(100) < took(180),
341            "{:?}, {:?}, {:?}",
342            took(40),
343            took(100),
344            took(180)
345        );
346    }
347
348    #[test]
349    fn a_delayed_entrance_reverses_from_what_is_on_screen() {
350        let linear = |ms| MotionSpec::new(ms, CubicBezier::new(0.0, 0.0, 1.0, 1.0));
351        let mut presence = Presence::hidden(linear(100).with_delay(100), linear(100));
352        presence.show();
353        presence.advance(Duration::from_millis(150));
354        let interrupted = presence.progress();
355        assert!((interrupted - 0.5).abs() < 0.02);
356
357        presence.hide();
358        assert!((presence.progress() - interrupted).abs() < 0.02);
359        let took = run_out(&mut presence);
360        assert!(
361            took.abs_diff(Duration::from_millis(50)) <= Duration::from_millis(2),
362            "half an exit is 50ms, not {took:?}"
363        );
364    }
365
366    #[test]
367    fn a_sprung_entrance_reverses_from_what_is_on_screen() {
368        let spring = MotionSpec::sprung(crate::motion::Spring::perceptual(
369            Duration::from_millis(300),
370            0.4,
371        ));
372        let mut presence = Presence::hidden(spring, spring);
373        presence.show();
374        presence.advance(Duration::from_millis(80));
375        let interrupted = presence.progress();
376
377        presence.hide();
378        assert!(
379            (presence.progress() - interrupted).abs() < 0.02,
380            "a sprung entrance jumped from {interrupted} to {}",
381            presence.progress()
382        );
383    }
384
385    #[test]
386    fn toggling_alternates_between_the_two_ends() {
387        let mut presence = presence();
388        presence.toggle();
389        assert_eq!(presence.phase(), Phase::Entering);
390        presence.settle();
391        presence.toggle();
392        assert_eq!(presence.phase(), Phase::Exiting);
393    }
394}