Skip to main content

gpui/
transition.rs

1use std::{
2    cell::{Ref, RefCell},
3    time::Instant,
4};
5
6use crate::{Animated, App, Entity, EntityId, Motion, Progress, Window, lerp::Lerp};
7
8#[derive(Clone)]
9struct TransitionCache<T> {
10    value: Option<T>,
11    progress: Progress,
12    is_active: bool,
13}
14
15impl<T> TransitionCache<T> {
16    fn empty() -> Self {
17        Self {
18            value: None,
19            progress: Progress::END,
20            is_active: false,
21        }
22    }
23
24    fn clear(&mut self) {
25        self.value = None;
26    }
27}
28
29/// A value that moves toward a goal over time and changes course when that goal changes.
30///
31/// A `Transition` pairs an entity that holds a [`TransitionState`] with a [`Motion`]. Read the
32/// value for the current frame with [`evaluate`](Self::evaluate) during render. Change the goal
33/// with [`update`](Self::update) from an event handler, then call `cx.notify()` so the view
34/// redraws. `T` must implement [`Lerp`], [`Clone`] and [`PartialEq`].
35///
36/// Clones share the goal and motion state, but each has its own per-frame cache. Read the value
37/// through the clone that is rendering it.
38///
39/// Create one during render with [`Window::use_keyed_transition`]. Its key keeps the state across
40/// frames. [`Window::use_transition`] keys state by call site instead, so that state lasts only
41/// while the element renders at that call site in consecutive frames.
42///
43/// # Continuous and non-continuous mode
44///
45/// Transitions are continuous by default. When the goal changes:
46///
47/// - **Continuous** (the default): the value continues from where it is now toward the new goal.
48/// - **Non-continuous** ([`continuous(false)`](Self::continuous)): the value restarts from the
49///   initial value and moves toward the new goal.
50///
51/// # Examples
52///
53/// The goal can change from an event handler, and the value follows it. This example uses an
54/// easing of `|_| 0.5`, so the value sits halfway between its start and its goal, and the
55/// assertions are exact.
56///
57/// ```
58/// use gpui::{
59///     AppContext, Context, IntoElement, Render, TestApp, Transition, TransitionState, Window,
60///     div, prelude::*, secs,
61/// };
62///
63/// struct Fade {
64///     transition: Transition<f32>,
65/// }
66///
67/// impl Render for Fade {
68///     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
69///         let opacity = *self.transition.evaluate(window, cx);
70///         div().opacity(opacity)
71///     }
72/// }
73///
74/// let mut app = TestApp::new();
75/// let mut window = app.open_window(|_, cx| {
76///     let state = cx.new(|_| TransitionState::new(0.0_f32, secs(10)));
77///     Fade {
78///         transition: Transition::new(state, secs(10)).with_easing(|_| 0.5),
79///     }
80/// });
81///
82/// window.update(|view, _, cx| {
83///     view.transition.update(cx, |goal, cx| {
84///         *goal = 1.0;
85///         cx.notify();
86///     });
87/// });
88/// window.draw();
89///
90/// window.update(|view, _, cx| {
91///     assert_eq!(*view.transition.read_cache(), Some(0.5));
92///     assert_eq!(*view.transition.read_goal(cx), 1.0);
93/// });
94/// ```
95///
96/// See the [animation guide](crate::_documentation::guides::animation#transition-a-value-that-changes)
97/// for the keyed hook.
98#[derive(Clone)]
99pub struct Transition<T: Lerp + Clone + PartialEq + 'static> {
100    motion: Motion,
101
102    state: Entity<TransitionState<T>>,
103
104    /// The transition sample cached for this render.
105    cache: RefCell<TransitionCache<T>>,
106
107    /// Whether to continue the transition from the current value when the goal changes.
108    /// If true, transitions smoothly from current animated value to new goal.
109    /// If false, restarts from the original start value.
110    continuous: bool,
111}
112
113impl<T: Lerp + Clone + PartialEq + 'static> Transition<T> {
114    /// Creates a transition that stores its state in `state` and animates with `motion`.
115    ///
116    /// Inside render, prefer [`Window::use_keyed_transition`], which creates the state for you.
117    pub fn new(state: Entity<TransitionState<T>>, motion: impl Into<Motion>) -> Self {
118        Self {
119            motion: motion.into(),
120            state,
121            cache: RefCell::new(TransitionCache::empty()),
122            continuous: true,
123        }
124    }
125
126    /// Sets the easing of the forward pass. See [`Motion::with_easing`].
127    ///
128    /// The easing maps normalized pass time to presentation progress. It may be non-monotonic, and
129    /// it may overshoot zero through one.
130    pub fn with_easing(mut self, easing: impl Fn(f32) -> f32 + 'static) -> Self {
131        self.motion = self.motion.with_easing(easing);
132        self.clear_cache();
133        self
134    }
135
136    /// Sets whether a new goal continues from the current value.
137    ///
138    /// Continuous is the default. Pass `false` to restart from the initial value on every goal
139    /// change instead.
140    pub fn continuous(mut self, continuous: bool) -> Self {
141        self.continuous = continuous;
142        self
143    }
144
145    fn sample(&self, cx: &mut App) -> Ref<'_, TransitionCache<T>> {
146        if self.cache.borrow().value.is_none() {
147            // Sampling updates presentation bookkeeping, not the transition's logical goal.
148            // Avoid the mutable-borrow guard here because dropping it notifies every view that
149            // read this state, which would invalidate the frame that just sampled it.
150            let sample = self
151                .state
152                .update(cx, |state, _| state.sample(Instant::now()));
153
154            *self.cache.borrow_mut() = TransitionCache {
155                value: Some(sample.value),
156                progress: sample.progress,
157                is_active: sample.is_active,
158            };
159        }
160
161        self.cache.borrow()
162    }
163
164    fn clear_cache(&self) {
165        self.cache.borrow_mut().clear();
166    }
167
168    /// Returns the value for the current frame, and requests another frame while it moves.
169    ///
170    /// The value depends on the time since the last goal change, and on the easing. The first call
171    /// samples the clock and caches the result. Later calls return the cached value until the goal
172    /// changes or the cache is cleared, so a `Transition` kept across frames returns the same value.
173    /// [`Window::use_keyed_transition`] returns a new `Transition` on every render, which avoids
174    /// this. Call this during render or paint.
175    pub fn evaluate(&self, window: &mut Window, cx: &mut App) -> Ref<'_, T> {
176        let sample = self.sample(cx);
177        if sample.is_active {
178            window.request_animation_frame();
179        }
180
181        Ref::map(sample, |sample| sample.value.as_ref().unwrap())
182    }
183
184    /// Returns the goal: the value most recently set by [`update`](Self::update).
185    ///
186    /// This isn't the value on screen while the transition moves. Use [`evaluate`](Self::evaluate)
187    /// for that.
188    pub fn read_goal<'b>(&'b self, cx: &'b mut App) -> &'b T {
189        self.state.read(cx).value()
190    }
191
192    /// Returns the value cached by the last [`evaluate`](Self::evaluate), or `None` if the cache
193    /// was cleared.
194    ///
195    /// The cache is cleared when the goal changes, and by [`jump_to`](Self::jump_to),
196    /// [`scale_by`](Self::scale_by), [`reset`](Self::reset) and [`with_easing`](Self::with_easing).
197    pub fn read_cache(&self) -> Ref<'_, Option<T>> {
198        Ref::map(self.cache.borrow(), |cache| &cache.value)
199    }
200
201    /// Returns the eased progress for the current frame, without requesting another frame.
202    ///
203    /// Progress may overshoot zero through one. It doesn't mean the run has finished: restarting
204    /// iterations begin again, and alternating iterations run in reverse.
205    pub fn evaluate_delta(&self, cx: &App) -> f32 {
206        if self.cache.borrow().value.is_some() {
207            return self.cache.borrow().progress.get();
208        }
209
210        self.state.read(cx).progress_at(Instant::now()).get()
211    }
212
213    /// Changes the goal, and starts a run toward the new goal if it changed.
214    ///
215    /// `update` passes the current goal to the closure, which can change it. If the new goal
216    /// differs from the old one, the value moves toward it. In continuous mode the run starts from
217    /// the current value. Otherwise it starts from the initial value. The goal isn't read during
218    /// render, so change it from an event handler.
219    ///
220    /// Returns `true` if the goal changed, and `false` if it stayed the same.
221    ///
222    /// This doesn't notify GPUI. Call `cx.notify()` inside the closure to redraw the view.
223    pub fn update<R>(
224        &self,
225        cx: &mut App,
226        update: impl FnOnce(&mut T, &mut crate::Context<TransitionState<T>>) -> R,
227    ) -> bool {
228        let mut was_updated = false;
229
230        self.state.update(cx, |state, cx| {
231            let mut value = state.value().clone();
232            update(&mut value, cx);
233            was_updated = if self.continuous {
234                state.set(value, &self.motion, Instant::now())
235            } else {
236                state.restart(value, &self.motion, Instant::now())
237            };
238        });
239
240        if was_updated {
241            self.clear_cache();
242        }
243
244        was_updated
245    }
246
247    /// Sets the value and the goal to `target`, with no animation.
248    ///
249    /// Later calls to [`evaluate`](Self::evaluate) return `target` until the goal changes again.
250    /// Use this when the next animation should start from a different value.
251    pub fn jump_to(&self, target: T, cx: &mut App) {
252        self.state.update(cx, |state, _cx| state.jump_to(target));
253        self.clear_cache();
254    }
255
256    /// Multiplies the start value and the goal by `ratio`.
257    ///
258    /// An in-flight transition keeps its progress when the coordinate space changes, such as a
259    /// window resize. Pass the ratio of the new scale to the old one.
260    pub fn scale_by(&self, ratio: f32, cx: &mut App)
261    where
262        T: std::ops::Mul<f32, Output = T>,
263    {
264        self.state.update(cx, |state, _cx| state.scale_by(ratio));
265        self.clear_cache();
266    }
267
268    /// Returns the ID of the entity that holds this transition's state.
269    ///
270    /// Use it to tell two transitions apart.
271    pub fn entity_id(&self) -> EntityId {
272        self.state.entity_id()
273    }
274
275    /// Returns the transition to its initial value, with no animation.
276    ///
277    /// This clears any progress and sets the goal back to the value the transition was created
278    /// with. The cache is cleared too.
279    pub fn reset(&self, cx: &mut App) {
280        self.state.update(cx, |state, _cx| state.reset());
281        self.clear_cache();
282    }
283}
284
285/// The state behind a [`Transition`]: an [`Animated`] that reads the clock from [`Instant`].
286///
287/// Create one with `cx.new(|_| TransitionState::new(initial, motion))` and pass it to
288/// [`Transition::new`]. [`Window::use_keyed_transition`] does that for you.
289pub type TransitionState<T> = Animated<T, Instant>;
290
291#[cfg(all(test, feature = "test-support"))]
292mod tests {
293    use std::cell::RefCell;
294    use std::rc::Rc;
295    use std::time::Duration;
296
297    use crate::{AppContext, Context, IntoElement, Render, canvas, px, size};
298
299    use super::*;
300    use gpui::TestAppContext;
301
302    /// Helper to create a Transition directly without using window hooks.
303    /// This bypasses the render-phase restriction of use_transition/use_keyed_transition.
304    fn create_transition<T: Lerp + Clone + PartialEq + 'static>(
305        cx: &mut App,
306        motion: impl Into<Motion>,
307        initial: T,
308    ) -> Transition<T> {
309        let motion = motion.into();
310        let state = cx.new(|_| TransitionState::new(initial, motion.clone()));
311        Transition::new(state, motion)
312    }
313
314    #[gpui::test]
315    fn unkeyed_transitions_have_distinct_state_that_persists(cx: &mut TestAppContext) {
316        struct Hooks {
317            seen: Rc<RefCell<Vec<[EntityId; 2]>>>,
318        }
319
320        impl Render for Hooks {
321            fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
322                let first = window.use_transition(cx, Duration::from_secs(1), |_, _| 2.0_f32);
323                let second = window.use_transition(cx, Duration::from_secs(1), |_, _| 7.0_f32);
324                assert_eq!(*first.read_goal(cx), 2.0);
325                assert_eq!(*second.read_goal(cx), 7.0);
326                self.seen
327                    .borrow_mut()
328                    .push([first.entity_id(), second.entity_id()]);
329                crate::Empty
330            }
331        }
332
333        let seen = Rc::new(RefCell::new(Vec::new()));
334        let window = cx.open_window(size(px(100.), px(100.)), {
335            let seen = seen.clone();
336            move |_, _| Hooks { seen }
337        });
338        cx.run_until_parked();
339        window.update(cx, |_, _, cx| cx.notify()).unwrap();
340        cx.run_until_parked();
341
342        let seen = seen.borrow();
343        assert!(seen.len() >= 2);
344        assert_ne!(seen[0][0], seen[0][1]);
345        assert!(seen.iter().all(|ids| *ids == seen[0]));
346    }
347
348    struct TransitionTestView {
349        transition: Transition<f32>,
350        rendered_values: Rc<RefCell<Vec<f32>>>,
351    }
352
353    impl Render for TransitionTestView {
354        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
355            let transition = self.transition.clone();
356            let rendered_values = self.rendered_values.clone();
357            canvas(
358                move |_, window, cx| {
359                    rendered_values
360                        .borrow_mut()
361                        .push(*transition.evaluate(window, cx));
362                },
363                |_, _, _, _| {},
364            )
365        }
366    }
367
368    #[gpui::test]
369    fn transition_exercises_state_updates_and_cache(cx: &mut TestAppContext) {
370        cx.update(|cx| {
371            let transition =
372                create_transition(cx, Duration::from_secs(1), 0.0_f32).with_easing(|_| 0.5);
373
374            assert!(transition.read_cache().is_none());
375            assert!(transition.update(cx, |value, _| *value = 100.0));
376            assert_eq!(*transition.read_goal(cx), 100.0);
377            let entity_id = transition.entity_id();
378            assert_eq!(entity_id, transition.entity_id());
379
380            let value1 = *transition.sample(cx).value.as_ref().unwrap();
381            let value2 = *transition.sample(cx).value.as_ref().unwrap();
382            assert_eq!((value1, value2), (50.0, 50.0));
383            assert_eq!(transition.evaluate_delta(cx), 0.5);
384
385            assert!(!transition.update(cx, |value, _| *value = 100.0));
386            assert!(transition.read_cache().is_some());
387            assert!(transition.update(cx, |value, _| *value = 200.0));
388            assert!(transition.read_cache().is_none());
389
390            let transition = transition.with_easing(|_| 0.75);
391            assert!(transition.read_cache().is_none());
392            assert_eq!(*transition.sample(cx).value.as_ref().unwrap(), 125.0);
393            assert_eq!(transition.evaluate_delta(cx), 0.5);
394
395            let motion = Motion::new(Duration::from_secs(1)).with_easing(|_| 0.5);
396            let continuous = create_transition(cx, motion.clone(), 0.0_f32);
397            let restarting = create_transition(cx, motion, 0.0_f32).continuous(false);
398
399            for transition in [&continuous, &restarting] {
400                assert!(transition.update(cx, |value, _| *value = 100.0));
401                assert_eq!(*transition.sample(cx).value.as_ref().unwrap(), 50.0);
402            }
403
404            assert!(continuous.update(cx, |value, _| *value = 200.0));
405            assert!(restarting.update(cx, |value, _| *value = 200.0));
406
407            assert_eq!(*continuous.sample(cx).value.as_ref().unwrap(), 125.0);
408            assert_eq!(*restarting.sample(cx).value.as_ref().unwrap(), 100.0);
409
410            let motion = Motion::new(Duration::from_secs(1)).with_easing(|_| 0.5);
411            let mutators = create_transition(cx, motion, 2.0_f32);
412
413            assert!(mutators.update(cx, |value, _| *value = 10.0));
414            assert_eq!(*mutators.sample(cx).value.as_ref().unwrap(), 6.0);
415
416            mutators.scale_by(2.0, cx);
417            assert_eq!(*mutators.read_goal(cx), 20.0);
418            assert_eq!(*mutators.sample(cx).value.as_ref().unwrap(), 12.0);
419
420            mutators.jump_to(7.0, cx);
421            assert_eq!(*mutators.read_goal(cx), 7.0);
422            assert_eq!(*mutators.sample(cx).value.as_ref().unwrap(), 7.0);
423
424            mutators.reset(cx);
425            assert_eq!(*mutators.read_goal(cx), 2.0);
426            assert!(mutators.read_cache().is_none());
427
428            let alternating = create_transition(
429                cx,
430                Motion::new(Duration::ZERO).iterations(2).alternate(),
431                0.0_f32,
432            );
433            assert!(alternating.update(cx, |value, _| *value = 100.0));
434            let sample = alternating.sample(cx);
435            assert_eq!(sample.value, Some(0.0));
436            assert_eq!(sample.progress, Progress::START);
437            assert!(!sample.is_active);
438            drop(sample);
439            assert_eq!(alternating.evaluate_delta(cx), 0.0);
440        });
441    }
442
443    #[gpui::test]
444    fn evaluate_uses_samples_and_requests_frames_while_active(cx: &mut TestAppContext) {
445        let transition = cx.update(|cx| {
446            create_transition(
447                cx,
448                Motion::new(Duration::from_secs(1)).with_easing(|_| 0.5),
449                0.0_f32,
450            )
451        });
452        cx.update(|cx| {
453            assert!(transition.update(cx, |value, _| *value = 100.0));
454        });
455        let rendered_values = Rc::new(RefCell::new(Vec::new()));
456        let window = cx.open_window(size(px(100.0), px(100.0)), {
457            let transition = transition.clone();
458            let rendered_values = rendered_values.clone();
459            move |_, _| TransitionTestView {
460                transition,
461                rendered_values,
462            }
463        });
464        cx.run_until_parked();
465
466        assert_eq!(*rendered_values.borrow(), vec![50.0]);
467        cx.update(|cx| transition.jump_to(100.0, cx));
468        assert_eq!(
469            window
470                .update(cx, |_, window, cx| window.simulate_next_frame(cx))
471                .unwrap(),
472            1
473        );
474        cx.run_until_parked();
475
476        assert_eq!(*rendered_values.borrow(), vec![50.0, 100.0]);
477        assert_eq!(
478            window
479                .update(cx, |_, window, cx| window.simulate_next_frame(cx))
480                .unwrap(),
481            0
482        );
483    }
484}