Skip to main content

gpui_kit/motion/
flip.rs

1//! Sliding an element from where it was to where it now is.
2//!
3//! FLIP is the standard trick for animating a layout change: read where the
4//! element was (First), let layout put it where it belongs (Last), Invert that
5//! difference into a visual offset, and Play the offset back to zero. Nothing
6//! about the layout changes — a reordered row lands in its new slot on the
7//! frame the caller reorders it, and only the pixels take their time.
8//!
9//! ```no_run
10//! # use gpui::{App, Window, div, prelude::*};
11//! # use gpui_kit::motion::{Flipping, flip};
12//! # fn row(id: &'static str, window: &mut Window, cx: &mut App) -> impl IntoElement {
13//! let handle = flip(id, cx);
14//! div().child("Row").flip(&handle, window, cx)
15//! # }
16//! ```
17//!
18//! The offset is applied during prepaint through [`gpui::Window::with_element_offset`],
19//! after layout has already run, so it cannot move a sibling. The origin the
20//! element is measured against excludes the ambient element offset, so
21//! scrolling a list — which offsets every row at once — is not mistaken for a
22//! reorder.
23//!
24//! # Position and size are not the same promise
25//!
26//! [`Flipping::flip`] animates position only, and that is free: an offset
27//! applied after layout has no box to push, so nothing beside the element can
28//! move.
29//!
30//! [`Flipping::flip_size`] additionally animates size, and that is **not**
31//! free. The pinned GPUI revision has no transform for an element subtree —
32//! `TransformationMatrix` reaches sprites alone — so a size animation cannot
33//! be faked with a scale the way a browser does it. The element really is a
34//! different size on every frame of the animation, which means it really does
35//! move its siblings, and it owns a layout node of its own to do it. Ask for
36//! it deliberately, on an element whose neighbours can stand being pushed.
37
38use std::cell::RefCell;
39use std::panic::Location;
40use std::rc::Rc;
41use std::time::Duration;
42
43use gpui::{
44    App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, InspectorElementId,
45    IntoElement, LayoutId, Pixels, Point, SharedString, Size, Style, Window, px, size,
46};
47use gpui_kit_theme::{ActiveTheme, SpringPreset};
48use web_time::Instant;
49
50use super::keyed;
51use super::{Interpolate, MotionSpec, Spring, Transition};
52
53/// How far an origin or an edge has to move before it counts as a move.
54///
55/// Sub-pixel drift would otherwise start an animation on every frame forever.
56const EPSILON: f32 = 0.5;
57
58/// How long a recorded rectangle is worth inverting from.
59///
60/// A shared element handed from one tree to another needs its rectangle to
61/// outlive the gap where neither tree renders it. A rectangle older than this
62/// is not a handoff, it is a memory, and flying in from where something stood
63/// half a minute ago is worse than not animating at all.
64const MEMORY: Duration = Duration::from_millis(500);
65
66/// How many frames a shared id survives while nothing renders it.
67///
68/// Wall clock and frames bound the same gap from two directions, because an
69/// idle window advances one and not the other; whichever runs out first ends
70/// the handoff.
71const HANDOFF_GRACE: u64 = 30;
72
73#[derive(Default)]
74struct FlipState {
75    /// Where layout last put the element, with the ambient offset removed.
76    origin: Option<Point<Pixels>>,
77    /// The offset the current slide started from.
78    from: Point<Pixels>,
79    /// What is on screen right now.
80    current: Point<Pixels>,
81    elapsed: Duration,
82    last_frame: Option<Instant>,
83    /// The size the element is being drawn at, animating toward whatever size
84    /// it would naturally take. `None` until an element opts into size.
85    size: Option<Transition<Size<Pixels>>>,
86    /// The size layout would give the element if nothing were animating.
87    natural: Option<Size<Pixels>>,
88    /// The space the parent last offered, so the natural size is measured
89    /// against the same constraints the element would really be laid out in.
90    available: Option<Size<AvailableSpace>>,
91    /// Whether this frame's offer has been taken already.
92    ///
93    /// Layout asks a measured node its size several times, narrowing toward
94    /// the size the node itself last reported. Only the first question of a
95    /// frame is the parent saying what it has to give; taking a later one
96    /// would measure the natural size against the animated size and stop the
97    /// animation dead where it stood.
98    offered: bool,
99    /// When the rectangle was last recorded, for bounding a handoff.
100    recorded_at: Option<Instant>,
101    /// When the size transition was last advanced.
102    size_frame: Option<Instant>,
103    /// The frame an element last claimed this id on.
104    seen_frame: Option<u64>,
105    /// The last frame on which two elements were sharing this id, plus one.
106    contested_through: Option<u64>,
107}
108
109impl FlipState {
110    fn advance(&mut self, now: Instant) {
111        if let Some(last) = self.last_frame {
112            self.elapsed += now.saturating_duration_since(last);
113        }
114        self.last_frame = Some(now);
115    }
116
117    fn sample(&self, spring: Spring, settle: Duration) -> Point<Pixels> {
118        if self.elapsed >= settle {
119            return Point::default();
120        }
121        self.from.lerp(Point::default(), spring.value(self.elapsed))
122    }
123
124    /// Records where layout put the element, and inverts a move into an offset.
125    ///
126    /// A move that arrives mid-slide continues from the offset on screen
127    /// rather than restarting, so a list reordered twice in quick succession
128    /// does not jump.
129    fn record(&mut self, origin: Point<Pixels>, residual: Point<Pixels>) {
130        if let Some(previous) = self.origin
131            && (moved(previous.x, origin.x) || moved(previous.y, origin.y))
132        {
133            self.from = previous - origin + residual;
134            self.elapsed = Duration::ZERO;
135        }
136        self.origin = Some(origin);
137    }
138
139    /// Records the size layout would give the element, and returns the size to
140    /// draw it at.
141    ///
142    /// The target keeps being measured while the animation forces a different
143    /// size, so a target that changes mid-flight is noticed on the frame it
144    /// changes. A change past [`EPSILON`] retargets the transition, which
145    /// continues from the size on screen and carries the speed it already had.
146    fn record_size(
147        &mut self,
148        natural: Size<Pixels>,
149        spec: MotionSpec,
150        now: Instant,
151    ) -> Size<Pixels> {
152        self.natural = Some(natural);
153        let mut transition = self
154            .size
155            .unwrap_or_else(|| Transition::new(natural, spec))
156            .spec(spec);
157        if let Some(last) = self.size_frame {
158            transition.advance(now.saturating_duration_since(last));
159        }
160        self.size_frame = Some(now);
161        let target = transition.target();
162        if moved(target.width, natural.width) || moved(target.height, natural.height) {
163            transition.set(natural);
164        }
165        self.size = Some(transition);
166        transition.value()
167    }
168
169    /// Puts the element at its natural size with nothing in flight.
170    fn settle_size(&mut self, natural: Size<Pixels>, spec: MotionSpec, now: Instant) {
171        self.natural = Some(natural);
172        let mut transition = self
173            .size
174            .unwrap_or_else(|| Transition::new(natural, spec))
175            .spec(spec);
176        transition.snap(natural);
177        self.size = Some(transition);
178        self.size_frame = Some(now);
179    }
180
181    /// Puts the element where layout put it with nothing in flight.
182    fn settle(&mut self, origin: Point<Pixels>, settle: Duration) {
183        self.origin = Some(origin);
184        self.from = Point::default();
185        self.current = Point::default();
186        self.elapsed = settle;
187        self.last_frame = None;
188    }
189
190    /// Drops a rectangle too old to invert from, so the next measurement is
191    /// read as a first one.
192    fn forget_if_stale(&mut self, now: Instant) {
193        let stale = self
194            .recorded_at
195            .is_some_and(|at| now.saturating_duration_since(at) > MEMORY);
196        if stale {
197            self.origin = None;
198            self.from = Point::default();
199            self.current = Point::default();
200            self.elapsed = Duration::ZERO;
201            self.last_frame = None;
202            self.size = None;
203            self.natural = None;
204            self.size_frame = None;
205        }
206        self.recorded_at = Some(now);
207    }
208
209    /// Claims the id for this frame, reporting whether another element already
210    /// claimed it.
211    ///
212    /// Two elements sharing one slot would each read the other's rectangle as
213    /// its own previous one and throw the other across the window, every
214    /// frame, forever. Neither of them animates instead. The refusal outlasts
215    /// the collision by a frame, because the frame after a contested one is
216    /// the first that can record a rectangle nothing else is writing to, and
217    /// inverting from a rectangle written by the loser of a fight is the same
218    /// jump wearing a different shape.
219    fn claim(&mut self, frame: Option<u64>) -> bool {
220        let Some(frame) = frame else {
221            return false;
222        };
223        if self.seen_frame == Some(frame) {
224            self.contested_through = Some(frame + 1);
225        }
226        self.seen_frame = Some(frame);
227        self.contested_through
228            .is_some_and(|through| frame <= through)
229    }
230}
231
232fn moved(a: Pixels, b: Pixels) -> bool {
233    (f32::from(a) - f32::from(b)).abs() > EPSILON
234}
235
236/// A handle to one element's slide, keyed by semantic id.
237///
238/// Cheap to clone and to rebuild: the state lives in an application global, so
239/// both a `RenderOnce` builder and a `Render` view can ask for the same handle
240/// on every frame.
241#[derive(Clone)]
242pub struct Flip {
243    id: SharedString,
244    state: Rc<RefCell<FlipState>>,
245}
246
247impl std::fmt::Debug for Flip {
248    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        formatter
250            .debug_struct("Flip")
251            .field("id", &self.id)
252            .field("offset", &self.offset())
253            .field("size", &self.size())
254            .finish()
255    }
256}
257
258impl Flip {
259    pub fn id(&self) -> &SharedString {
260        &self.id
261    }
262
263    /// The offset currently painted, in pixels.
264    ///
265    /// This is where the element is drawn, not where it is: the layout, the
266    /// hit target and the semantic tree all report the settled position. It
267    /// exists so a test or an inspector can watch a slide without treating a
268    /// value in flight as a fact about the interface.
269    pub fn offset(&self) -> Point<Pixels> {
270        self.state.borrow().current
271    }
272
273    /// The size currently painted, once an element has opted into animating
274    /// its size with [`Flipping::flip_size`].
275    ///
276    /// Unlike [`Flip::offset`], this one *is* what the layout and the semantic
277    /// tree report, because a size animation is a real layout change.
278    pub fn size(&self) -> Option<Size<Pixels>> {
279        self.state.borrow().size.map(|size| size.value())
280    }
281
282    /// The size layout would give the element if nothing were animating.
283    pub fn target_size(&self) -> Option<Size<Pixels>> {
284        self.state.borrow().natural
285    }
286
287    /// Whether two elements are currently sharing this id.
288    ///
289    /// A contested id does not animate at all.
290    pub fn is_contended(&self) -> bool {
291        let state = self.state.borrow();
292        match (state.seen_frame, state.contested_through) {
293            (Some(frame), Some(through)) => frame <= through,
294            _ => false,
295        }
296    }
297
298    pub fn is_animating(&self) -> bool {
299        let offset = self.offset();
300        let sliding = offset.x.abs() > px(EPSILON) || offset.y.abs() > px(EPSILON);
301        sliding || self.state.borrow().size.is_some_and(|s| s.is_animating())
302    }
303}
304
305/// The slide handle for `id`.
306pub fn flip(id: impl Into<SharedString>, cx: &mut App) -> Flip {
307    let id = id.into();
308    let state = keyed::slot::<FlipState>(&id, cx);
309    Flip { id, state }
310}
311
312/// The slide handle for `id`, kept alive across a handoff between two element
313/// trees.
314///
315/// A row that opens into a detail panel is two elements in two trees with one
316/// identity. Because the state is keyed by id rather than by element, the
317/// panel inverts from the rectangle the row last recorded and travels there
318/// instead of cutting. What this adds over [`flip`] is only patience: the
319/// rectangle survives the frames in which neither tree renders the id, up to
320/// 30 frames and 500 ms of wall clock, whichever ends first. Past that the
321/// arriving element is simply already in place.
322///
323/// Both trees rendering the id at once is a collision, and a collision does
324/// not animate; see [`Flip::is_contended`].
325pub fn shared_flip(id: impl Into<SharedString>, cx: &mut App) -> Flip {
326    let id = id.into();
327    let state = keyed::slot_retained::<FlipState>(&id, HANDOFF_GRACE, cx);
328    Flip { id, state }
329}
330
331/// The ids the flip global currently retains.
332///
333/// An id that stops rendering is dropped within two frames — or within 30
334/// when it was last asked for through [`shared_flip`] — so this is the set
335/// of elements on screen rather than every element ever flipped.
336pub fn tracked_ids(cx: &App) -> Vec<SharedString> {
337    keyed::ids::<FlipState>(cx)
338}
339
340/// Slides an element from where it was to where it is.
341pub trait Flipping: IntoElement + Sized {
342    /// Wraps the element so a change in its position is played back as a
343    /// slide.
344    ///
345    /// The wrapper contributes no box of its own and the offset is applied
346    /// after layout, so this cannot move anything beside the element.
347    ///
348    /// Under reduced motion the element is simply at its new place from the
349    /// first frame.
350    fn flip(self, flip: &Flip, window: &mut Window, cx: &mut App) -> Flipped {
351        flipped(self, flip, false, window, cx)
352    }
353
354    /// The same, and a change in the element's size is played back too.
355    ///
356    /// This costs what [`Flipping::flip`] does not. The element takes a layout
357    /// node of its own, is measured twice a frame while it animates, and is
358    /// genuinely the animated size rather than a scaled picture of the settled
359    /// one — so its siblings move with it, and the size the semantic tree
360    /// publishes is the size in flight. Position and size run independently,
361    /// so an element that both moves and grows does both.
362    ///
363    /// Under reduced motion the element is at its new size from the first
364    /// frame.
365    fn flip_size(self, flip: &Flip, window: &mut Window, cx: &mut App) -> Flipped {
366        flipped(self, flip, true, window, cx)
367    }
368}
369
370fn flipped<E: IntoElement>(
371    element: E,
372    flip: &Flip,
373    sized: bool,
374    window: &mut Window,
375    cx: &mut App,
376) -> Flipped {
377    let spring = Spring::preset(cx.theme(), SpringPreset::Grab);
378    let element = Flipped {
379        element: element.into_any_element(),
380        state: Rc::clone(&flip.state),
381        spring,
382        settle: spring.settle_time(),
383        sized,
384        measuring: false,
385        measured_against: None,
386        reduce_motion: cx.reduce_motion(),
387        frame: keyed::frame_counter(cx),
388    };
389    // A slide that is still running needs the next frame even when nothing
390    // else on the window asks for one.
391    if flip.is_animating() {
392        window.request_animation_frame();
393    }
394    element
395}
396
397impl<E: IntoElement> Flipping for E {}
398
399/// An element painted at an offset from where layout put it, and optionally at
400/// a size on its way to the size layout would give it.
401pub struct Flipped {
402    element: gpui::AnyElement,
403    state: Rc<RefCell<FlipState>>,
404    spring: Spring,
405    settle: Duration,
406    sized: bool,
407    /// Whether this frame is one the element owns its layout node on. The
408    /// first frame an id is ever seen on is not: see
409    /// [`Element::request_layout`].
410    measuring: bool,
411    /// The constraints this frame's natural size was measured against, so
412    /// prepaint can tell whether layout has since offered different ones.
413    measured_against: Option<Size<AvailableSpace>>,
414    reduce_motion: bool,
415    frame: Option<u64>,
416}
417
418impl std::fmt::Debug for Flipped {
419    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        formatter
421            .debug_struct("Flipped")
422            .field("offset", &self.state.borrow().current)
423            .field("sized", &self.sized)
424            .field("reduce_motion", &self.reduce_motion)
425            .finish()
426    }
427}
428
429impl Flipped {
430    fn spec(&self) -> MotionSpec {
431        MotionSpec::sprung(self.spring)
432    }
433}
434
435impl IntoElement for Flipped {
436    type Element = Self;
437
438    fn into_element(self) -> Self::Element {
439        self
440    }
441}
442
443impl Element for Flipped {
444    type RequestLayoutState = ();
445    type PrepaintState = ();
446
447    fn id(&self) -> Option<ElementId> {
448        None
449    }
450
451    fn source_location(&self) -> Option<&'static Location<'static>> {
452        None
453    }
454
455    fn request_layout(
456        &mut self,
457        _id: Option<&GlobalElementId>,
458        _inspector_id: Option<&InspectorElementId>,
459        window: &mut Window,
460        cx: &mut App,
461    ) -> (LayoutId, ()) {
462        // A rectangle too old to invert from is dropped before anything is
463        // measured against it, so the measurement that follows is read as a
464        // first one rather than as the far end of a very long journey.
465        let now = cx.background_executor().now();
466        self.state.borrow_mut().forget_if_stale(now);
467
468        if !self.sized {
469            // The wrapper contributes no box of its own: it hands layout the
470            // same node the wrapped element asked for.
471            return (self.element.request_layout(window, cx), ());
472        }
473
474        // Measuring against the space the parent offered last frame needs a
475        // frame in which the parent laid the element out for itself. Until
476        // then the element is passed straight through, which is both the
477        // correct first frame and where the constraints are learned: guessing
478        // them instead would draw an element sized as a fraction of its
479        // container at nothing at all.
480        let Some(available) = self.state.borrow().available else {
481            self.measuring = false;
482            return (self.element.request_layout(window, cx), ());
483        };
484        self.measuring = true;
485        self.measured_against = Some(available);
486
487        // Measuring the natural size here rather than in prepaint is what lets
488        // a size change be seen on the frame it happens: the node this element
489        // hands to layout is already the first frame of the animation.
490        let natural = self.element.layout_as_root(available, window, cx);
491        let spec = self.spec();
492
493        let drawn = {
494            let mut state = self.state.borrow_mut();
495            if self.reduce_motion {
496                state.settle_size(natural, spec, now);
497                natural
498            } else {
499                state.record_size(natural, spec, now)
500            }
501        };
502
503        self.state.borrow_mut().offered = false;
504        let state = Rc::clone(&self.state);
505        let layout_id = window.request_measured_layout(
506            Style::default(),
507            move |known, available, _window, _cx| {
508                let mut state = state.borrow_mut();
509                if !state.offered {
510                    state.offered = true;
511                    state.available = Some(available);
512                }
513                drop(state);
514                size(
515                    known.width.unwrap_or(drawn.width),
516                    known.height.unwrap_or(drawn.height),
517                )
518            },
519        );
520        (layout_id, ())
521    }
522
523    fn prepaint(
524        &mut self,
525        _id: Option<&GlobalElementId>,
526        _inspector_id: Option<&InspectorElementId>,
527        bounds: Bounds<Pixels>,
528        _request_layout: &mut (),
529        window: &mut Window,
530        cx: &mut App,
531    ) {
532        let origin = bounds.origin - window.element_offset();
533        let now = cx.background_executor().now();
534        let offset = {
535            let mut state = self.state.borrow_mut();
536            let contested = state.claim(self.frame);
537            if self.sized && !self.measuring {
538                // The frame the parent laid this element out for itself: what
539                // it came out as is both the size to animate from next time
540                // and the constraints to measure the next one against.
541                state.available = Some(size(
542                    AvailableSpace::Definite(bounds.size.width),
543                    AvailableSpace::Definite(bounds.size.height),
544                ));
545                state.settle_size(bounds.size, self.spec(), now);
546            }
547            if self.reduce_motion || contested {
548                state.settle(origin, self.settle);
549                if self.sized
550                    && let Some(natural) = state.natural
551                {
552                    state.settle_size(natural, self.spec(), now);
553                }
554            } else {
555                state.advance(now);
556                let residual = state.sample(self.spring, self.settle);
557                state.record(origin, residual);
558                state.current = state.sample(self.spring, self.settle);
559                if state.elapsed >= self.settle {
560                    state.last_frame = None;
561                }
562            }
563            state.current
564        };
565
566        if self.measuring {
567            // The element owns its layout node, so it also owns where its
568            // child sits: the slide is added to the origin rather than pushed
569            // through the ambient element offset.
570            self.element.prepaint_as_root(
571                bounds.origin + offset,
572                size(
573                    AvailableSpace::Definite(bounds.size.width),
574                    AvailableSpace::Definite(bounds.size.height),
575                ),
576                window,
577                cx,
578            );
579        } else {
580            window.with_element_offset(offset, |window| {
581                self.element.prepaint(window, cx);
582            });
583        }
584
585        let state = self.state.borrow();
586        let growing = state.size.is_some_and(|size| size.is_animating());
587        // Layout offered constraints this element has not been measured
588        // against yet, which only the next frame can do. Nothing else on the
589        // window knows that, so an element sized from a container that just
590        // resized would otherwise wait for an unrelated frame to notice.
591        let told_something_new = self.measuring && state.available != self.measured_against;
592        drop(state);
593        if offset != Point::default() || growing || told_something_new {
594            window.request_animation_frame();
595        }
596    }
597
598    fn paint(
599        &mut self,
600        _id: Option<&GlobalElementId>,
601        _inspector_id: Option<&InspectorElementId>,
602        _bounds: Bounds<Pixels>,
603        _request_layout: &mut (),
604        _prepaint: &mut (),
605        window: &mut Window,
606        cx: &mut App,
607    ) {
608        self.element.paint(window, cx);
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use gpui::{point, size};
616    use gpui_kit_theme::Theme;
617
618    fn grab() -> Spring {
619        Spring::preset(&Theme::studio_dark(), SpringPreset::Grab)
620    }
621
622    fn spec() -> MotionSpec {
623        MotionSpec::sprung(grab())
624    }
625
626    /// A clock a test advances by hand, so a size animation is driven the way
627    /// a window would drive it.
628    struct Frames(Instant);
629
630    impl Frames {
631        fn new() -> Self {
632            Self(Instant::now())
633        }
634
635        fn step(&mut self) -> Instant {
636            self.0 += Duration::from_millis(8);
637            self.0
638        }
639    }
640
641    /// Keeps measuring the same size until the animation comes to rest.
642    fn run_to_rest(
643        state: &mut FlipState,
644        natural: Size<Pixels>,
645        frames: &mut Frames,
646    ) -> Size<Pixels> {
647        let mut drawn = natural;
648        for _ in 0..600 {
649            if !state.size.is_some_and(|size| size.is_animating()) {
650                break;
651            }
652            drawn = state.record_size(natural, spec(), frames.step());
653        }
654        drawn
655    }
656
657    #[test]
658    fn the_grab_spring_settles_sooner_than_the_snappy_one() {
659        let snappy = Spring::preset(&Theme::studio_dark(), SpringPreset::Snappy);
660        assert!(grab().settle_time() < snappy.settle_time());
661    }
662
663    #[test]
664    fn a_first_measurement_produces_no_offset() {
665        let mut state = FlipState::default();
666        state.record(point(px(10.0), px(20.0)), Point::default());
667        assert_eq!(state.sample(grab(), grab().settle_time()), Point::default());
668    }
669
670    #[test]
671    fn a_move_inverts_into_the_distance_travelled() {
672        let spring = grab();
673        let settle = spring.settle_time();
674        let mut state = FlipState::default();
675        state.record(point(px(0.0), px(0.0)), Point::default());
676        state.record(point(px(0.0), px(40.0)), Point::default());
677        assert_eq!(state.sample(spring, settle), point(px(0.0), px(-40.0)));
678
679        state.elapsed = settle;
680        assert_eq!(state.sample(spring, settle), Point::default());
681    }
682
683    #[test]
684    fn a_move_mid_slide_continues_from_what_is_on_screen() {
685        let spring = grab();
686        let settle = spring.settle_time();
687        let mut state = FlipState::default();
688        state.record(point(px(0.0), px(0.0)), Point::default());
689        state.record(point(px(0.0), px(40.0)), Point::default());
690
691        state.elapsed = settle / 2;
692        let residual = state.sample(spring, settle);
693        assert!(residual.y > px(-40.0) && residual.y < px(0.0));
694
695        state.record(point(px(0.0), px(60.0)), residual);
696        assert_eq!(
697            state.sample(spring, settle),
698            residual - point(px(0.0), px(20.0))
699        );
700    }
701
702    #[test]
703    fn sub_pixel_drift_does_not_start_a_slide() {
704        let spring = grab();
705        let settle = spring.settle_time();
706        let mut state = FlipState::default();
707        state.record(point(px(0.0), px(0.0)), Point::default());
708        state.record(point(px(0.2), px(0.3)), Point::default());
709        assert_eq!(state.sample(spring, settle), Point::default());
710    }
711
712    #[test]
713    fn a_first_size_is_drawn_at_once() {
714        let mut frames = Frames::new();
715        let mut state = FlipState::default();
716        let first = size(px(100.0), px(40.0));
717        assert_eq!(state.record_size(first, spec(), frames.step()), first);
718        assert!(!state.size.expect("recorded").is_animating());
719    }
720
721    #[test]
722    fn a_size_change_starts_at_the_old_size_and_lands_on_the_new_one() {
723        let mut frames = Frames::new();
724        let mut state = FlipState::default();
725        state.record_size(size(px(100.0), px(40.0)), spec(), frames.step());
726        let grown = size(px(200.0), px(80.0));
727        let drawn = state.record_size(grown, spec(), frames.step());
728        assert_eq!(
729            drawn,
730            size(px(100.0), px(40.0)),
731            "the first frame of a resize is the size it had"
732        );
733        assert_eq!(run_to_rest(&mut state, grown, &mut frames), grown);
734    }
735
736    #[test]
737    fn a_size_change_mid_animation_continues_from_the_size_on_screen() {
738        let mut frames = Frames::new();
739        let mut state = FlipState::default();
740        state.record_size(size(px(100.0), px(40.0)), spec(), frames.step());
741        let wider = size(px(200.0), px(40.0));
742        state.record_size(wider, spec(), frames.step());
743
744        let mut interrupted = size(px(100.0), px(40.0));
745        let mut caught = frames.step();
746        for _ in 0..6 {
747            caught = frames.step();
748            interrupted = state.record_size(wider, spec(), caught);
749        }
750        assert!(
751            interrupted.width > px(100.0) && interrupted.width < px(200.0),
752            "the animation has to be in flight for the claim to mean anything: {interrupted:?}"
753        );
754
755        // The same instant, so the only thing that could move the drawn size
756        // is the retarget itself.
757        let widest = size(px(300.0), px(40.0));
758        let drawn = state.record_size(widest, spec(), caught);
759        assert_eq!(
760            drawn, interrupted,
761            "a retarget starts from what is on screen rather than from the old size"
762        );
763        assert_eq!(run_to_rest(&mut state, widest, &mut frames), widest);
764    }
765
766    #[test]
767    fn sub_pixel_size_churn_starts_nothing() {
768        let mut frames = Frames::new();
769        let mut state = FlipState::default();
770        state.record_size(size(px(100.0), px(40.0)), spec(), frames.step());
771        let drawn = state.record_size(size(px(100.3), px(40.2)), spec(), frames.step());
772        assert_eq!(drawn, size(px(100.0), px(40.0)));
773        assert!(!state.size.expect("recorded").is_animating());
774    }
775
776    #[test]
777    fn a_settled_size_is_the_new_size_with_nothing_in_flight() {
778        let mut frames = Frames::new();
779        let mut state = FlipState::default();
780        state.record_size(size(px(100.0), px(40.0)), spec(), frames.step());
781        state.settle_size(size(px(200.0), px(80.0)), spec(), frames.step());
782        let transition = state.size.expect("recorded");
783        assert_eq!(transition.value(), size(px(200.0), px(80.0)));
784        assert!(!transition.is_animating());
785    }
786
787    #[test]
788    fn position_and_size_run_independently() {
789        let mut frames = Frames::new();
790        let spring = grab();
791        let settle = spring.settle_time();
792        let mut state = FlipState::default();
793        state.record(point(px(0.0), px(0.0)), Point::default());
794        state.record_size(size(px(100.0), px(40.0)), spec(), frames.step());
795
796        state.record(point(px(0.0), px(40.0)), Point::default());
797        let taller = size(px(100.0), px(90.0));
798        let drawn = state.record_size(taller, spec(), frames.step());
799        assert_eq!(state.sample(spring, settle), point(px(0.0), px(-40.0)));
800        assert_eq!(drawn, size(px(100.0), px(40.0)));
801        assert_eq!(run_to_rest(&mut state, taller, &mut frames), taller);
802    }
803
804    #[test]
805    fn a_rectangle_older_than_the_handoff_window_is_not_inverted_from() {
806        let start = Instant::now();
807        let mut state = FlipState::default();
808        state.forget_if_stale(start);
809        state.record(point(px(0.0), px(0.0)), Point::default());
810        state.record_size(size(px(100.0), px(40.0)), spec(), start);
811
812        state.forget_if_stale(start + MEMORY / 2);
813        state.record(point(px(0.0), px(300.0)), Point::default());
814        assert_ne!(
815            state.sample(grab(), grab().settle_time()),
816            Point::default(),
817            "a gap inside the window is a handoff and travels"
818        );
819
820        state.forget_if_stale(start + MEMORY / 2 + MEMORY * 2);
821        assert_eq!(state.origin, None, "a stale rectangle is forgotten");
822        assert_eq!(state.size, None);
823        state.record(point(px(0.0), px(600.0)), Point::default());
824        assert_eq!(
825            state.sample(grab(), grab().settle_time()),
826            Point::default(),
827            "an element with no recent rectangle is simply already in place"
828        );
829    }
830
831    #[test]
832    fn two_elements_sharing_an_id_in_one_frame_contest_it() {
833        let mut state = FlipState::default();
834        assert!(
835            !state.claim(Some(7)),
836            "one element per frame is no collision"
837        );
838        assert!(
839            state.claim(Some(7)),
840            "the second element in a frame collides"
841        );
842        assert!(
843            state.claim(Some(8)),
844            "the frame after a collision is still refused"
845        );
846        assert!(
847            !state.claim(Some(9)),
848            "a single renderer resumes once it has a rectangle of its own"
849        );
850    }
851
852    #[test]
853    fn a_host_without_a_frame_counter_never_reports_a_collision() {
854        let mut state = FlipState::default();
855        assert!(!state.claim(None));
856        assert!(!state.claim(None));
857    }
858}