Skip to main content

gpui_base/
scroll_bounce.rs

1//! Boundary displacement only: the list keeps its clamped logical position.
2
3use crate::{OngoingScrollExt as _, ScrollbarHandle};
4use gpui::{
5    AnyElement, App, Bounds, ContentMask, DispatchPhase, Element, ElementId, GlobalElementId,
6    Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, OngoingScroll, Pixels,
7    ScrollDelta, ScrollWheelEvent, TouchPhase, Window, point, px,
8};
9#[cfg(not(target_family = "wasm"))]
10use std::time::Instant;
11use std::{cell::RefCell, rc::Rc, time::Duration};
12#[cfg(target_family = "wasm")]
13use web_time::Instant;
14
15/// Motion tokens for [`ScrollBounce`]: how far a drag stretches the viewport
16/// past an edge, and how quickly a released edge returns.
17///
18/// Base plays the stretch and the return; the feel belongs to the caller.
19/// The default is tuned to feel like a `UIScrollView` bounce.
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct ScrollBounceMotion {
22    tracking: f32,
23    response: Duration,
24}
25
26impl Default for ScrollBounceMotion {
27    /// Tuned to feel like a `UIScrollView` bounce; these are not UIKit constants.
28    fn default() -> Self {
29        Self {
30            tracking: 0.55,
31            response: Duration::from_millis(524),
32        }
33    }
34}
35
36impl ScrollBounceMotion {
37    /// Fraction of finger travel the stretched edge follows at first.
38    ///
39    /// The edge follows less and less as it approaches the viewport height,
40    /// which it never reaches. Tracking is independent of the return, so
41    /// slowing the return does not change how the finger feels.
42    ///
43    /// # Panics
44    ///
45    /// Panics when `tracking` is not finite or not positive.
46    pub fn with_tracking(mut self, tracking: f32) -> Self {
47        assert!(
48            tracking.is_finite() && tracking > 0.,
49            "scroll bounce tracking must be finite and positive"
50        );
51        self.tracking = tracking;
52        self
53    }
54
55    /// Time scale of the return once the finger lifts.
56    ///
57    /// Read the way [`crate::Spring::new`] reads its response: the period one
58    /// full oscillation would take without damping, which is the scale the
59    /// return is felt at rather than the moment it stops. The return is
60    /// critically damped, so it never crosses the edge. A zero response snaps
61    /// the edge back on the spot.
62    pub fn with_response(mut self, response: Duration) -> Self {
63        self.response = response;
64        self
65    }
66
67    /// Fraction of finger travel the stretched edge follows at first.
68    pub fn tracking(&self) -> f32 {
69        self.tracking
70    }
71
72    /// Time scale of the return once the finger lifts.
73    pub fn response(&self) -> Duration {
74        self.response
75    }
76
77    /// Undamped angular frequency of the return, or `None` when it snaps.
78    fn omega(&self) -> Option<f32> {
79        let seconds = self.response.as_secs_f32();
80        (seconds > 0.).then(|| std::f32::consts::TAU / seconds)
81    }
82}
83
84/// Adds vertical touch overscroll to an existing scroll viewport.
85///
86/// The child owns layout, content, and ordinary scrolling; `handle` must be the
87/// child's scroll handle. Only unused vertical deltas stretch the viewport.
88/// The stable `id` owns gesture and spring state. Change it when replacing the
89/// document. Put fixed chrome (scrollbars, toolbars) outside this wrapper.
90///
91/// Enabled by default on iOS and Android. Other platforms pass through unless
92/// explicitly enabled; their input must emit `Ended` at finger release, before momentum.
93/// Reduced motion disables displacement. Keyboard, focus, and line-wheel input
94/// remain owned by the child. No colors, padding, or dimensions are imposed.
95pub struct ScrollBounce {
96    id: ElementId,
97    handle: Rc<dyn ScrollbarHandle>,
98    child: AnyElement,
99    enabled: bool,
100    motion: ScrollBounceMotion,
101    on_scroll: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
102}
103
104impl ScrollBounce {
105    pub fn new<H: ScrollbarHandle + Clone>(
106        id: impl Into<ElementId>,
107        handle: &H,
108        child: impl IntoElement,
109    ) -> Self {
110        Self {
111            id: id.into(),
112            handle: Rc::new(handle.clone()),
113            child: child.into_any_element(),
114            enabled: cfg!(any(target_os = "ios", target_os = "android")),
115            motion: ScrollBounceMotion::default(),
116            on_scroll: None,
117        }
118    }
119
120    /// Opt in on a platform with compatible touch phase semantics.
121    pub fn enabled(mut self, enabled: bool) -> Self {
122        self.enabled = enabled;
123        self
124    }
125
126    /// Set how far a drag stretches past an edge and how the edge returns.
127    pub fn motion(mut self, motion: ScrollBounceMotion) -> Self {
128        self.motion = motion;
129        self
130    }
131
132    /// Observe a logical scroll performed when a reverse drag leaves the stretched
133    /// region. Runs after the handle update, with no internal state borrowed.
134    /// Ordinary child scrolling continues to use the child's own notifications.
135    pub fn on_scroll(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
136        self.on_scroll = Some(Rc::new(handler));
137        self
138    }
139}
140
141#[derive(Default)]
142struct State {
143    physics: Physics,
144    sampled_at: Option<Instant>,
145    ongoing_scroll: OngoingScroll,
146}
147
148/// `ScrollbarHandle` has no `max_offset`; recover it from the definition
149/// `content_size = viewport + max_offset`. Both dispatch phases clamp against
150/// this bound and must agree on it.
151fn max_scroll_extent(handle: &dyn ScrollbarHandle) -> Pixels {
152    (handle.content_size().height - handle.viewport_bounds().size.height).max(px(0.))
153}
154
155#[doc(hidden)]
156pub struct ScrollBouncePrepaintState {
157    state: Rc<RefCell<State>>,
158    hitbox: Hitbox,
159}
160
161impl IntoElement for ScrollBounce {
162    type Element = Self;
163    fn into_element(self) -> Self {
164        self
165    }
166}
167
168impl Element for ScrollBounce {
169    type RequestLayoutState = ();
170    type PrepaintState = ScrollBouncePrepaintState;
171
172    fn id(&self) -> Option<ElementId> {
173        Some(self.id.clone())
174    }
175    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
176        None
177    }
178
179    fn request_layout(
180        &mut self,
181        _: Option<&GlobalElementId>,
182        _: Option<&InspectorElementId>,
183        window: &mut Window,
184        cx: &mut App,
185    ) -> (LayoutId, ()) {
186        (self.child.request_layout(window, cx), ())
187    }
188
189    fn prepaint(
190        &mut self,
191        id: Option<&GlobalElementId>,
192        _: Option<&InspectorElementId>,
193        bounds: Bounds<Pixels>,
194        _: &mut (),
195        window: &mut Window,
196        cx: &mut App,
197    ) -> Self::PrepaintState {
198        let state = window.with_element_state(
199            id.expect("ScrollBounce has an id"),
200            |state: Option<Rc<RefCell<State>>>, _| {
201                let state = state.unwrap_or_default();
202                (state.clone(), state)
203            },
204        );
205        let offset = {
206            let mut state = state.borrow_mut();
207            if !self.enabled || cx.reduce_motion() {
208                *state = State::default();
209            }
210            state.physics.motion = self.motion;
211            let now = Instant::now();
212            let elapsed = state
213                .sampled_at
214                .replace(now)
215                .map_or(0., |at| now.duration_since(at).as_secs_f32());
216            if state.physics.step(elapsed) {
217                window.request_animation_frame();
218            }
219            state.physics.offset()
220        };
221        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
222        window.with_content_mask(Some(ContentMask { bounds }), |window| {
223            window.with_element_offset(point(px(0.), px(offset)), |window| {
224                self.child.prepaint(window, cx);
225            });
226        });
227        ScrollBouncePrepaintState { state, hitbox }
228    }
229
230    fn paint(
231        &mut self,
232        _: Option<&GlobalElementId>,
233        _: Option<&InspectorElementId>,
234        bounds: Bounds<Pixels>,
235        _: &mut (),
236        prepaint: &mut Self::PrepaintState,
237        window: &mut Window,
238        cx: &mut App,
239    ) {
240        if self.enabled && !cx.reduce_motion() {
241            let state = prepaint.state.clone();
242            let hitbox = prepaint.hitbox.id;
243            let handle = self.handle.clone();
244            let view = window.current_view();
245            let on_scroll = self.on_scroll.clone();
246            let mut before = 0.;
247            window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
248                let ScrollDelta::Pixels(mut delta) = event.delta else {
249                    return;
250                };
251                if !hitbox.should_handle_scroll(window) {
252                    return;
253                }
254                let mut state = state.borrow_mut();
255                // Lock the gesture to the axis it started on, so a diagonal
256                // swipe cannot wobble out of the stretch from one packet to
257                // the next. Both dispatch phases see the same packet, and the
258                // lock gives both the same answer.
259                state
260                    .ongoing_scroll
261                    .lock_axis(&mut delta, event.touch_phase);
262                if delta.x.abs() > delta.y.abs() {
263                    return;
264                }
265                let ended = matches!(event.touch_phase, TouchPhase::Ended | TouchPhase::Cancelled);
266                let mut scrolled = false;
267                let mut changed = false;
268                if phase == DispatchPhase::Capture {
269                    before = handle.offset().y.as_f32();
270                    if event.touch_phase == TouchPhase::Started {
271                        state.physics.begin(bounds.size.height.as_f32());
272                    }
273                    if state.physics.suppress_momentum {
274                        cx.stop_propagation();
275                        return;
276                    }
277                    if state.physics.offset() != 0. {
278                        let remainder = state.physics.pull(delta.y.as_f32());
279                        if remainder != 0. {
280                            let max = max_scroll_extent(handle.as_ref());
281                            let mut offset = handle.offset();
282                            offset.y = px(before + remainder).clamp(-max, px(0.));
283                            handle.set_offset(offset);
284                            scrolled = true;
285                        }
286                        if ended {
287                            state.physics.release();
288                        }
289                        changed = true;
290                        cx.stop_propagation();
291                    } else if ended {
292                        state.physics.release();
293                    }
294                } else {
295                    // Div applies deltas immediately but clamps during its next
296                    // prepaint. Clamp here so that boundary deltas are not
297                    // mistaken for consumed scrolling (ListState clamps eagerly).
298                    let mut offset = handle.offset();
299                    let max = max_scroll_extent(handle.as_ref());
300                    let clamped = offset.y.clamp(-max, px(0.));
301                    if clamped != offset.y {
302                        offset.y = clamped;
303                        handle.set_offset(offset);
304                    }
305                    let after = offset.y.as_f32();
306                    let requested = delta.y.as_f32();
307                    // A List can coalesce several packets against one painted
308                    // scroll position. Their offset difference alone does not
309                    // prove overscroll, especially after direction changes or
310                    // a zero-delta Ended packet from a trackpad.
311                    let at_outward_edge = (requested > 0. && offset.y == px(0.))
312                        || (requested < 0. && offset.y == -max);
313                    let residual =
314                        (requested - (after - before)).clamp(requested.min(0.), requested.max(0.));
315                    if at_outward_edge && residual.abs() > 0.01 && !state.physics.suppress_momentum
316                    {
317                        let dragging = state.physics.dragging;
318                        if !dragging {
319                            state.physics.begin(bounds.size.height.as_f32());
320                        }
321                        state.physics.pull(residual);
322                        if !dragging || ended {
323                            state.physics.release();
324                        }
325                        changed = true;
326                    }
327                }
328                if changed {
329                    state.sampled_at = Some(Instant::now());
330                }
331                drop(state);
332                if changed {
333                    cx.notify(view);
334                }
335                if scrolled && let Some(handler) = &on_scroll {
336                    handler(window, cx);
337                }
338            });
339        }
340        window.with_content_mask(Some(ContentMask { bounds }), |window| {
341            self.child.paint(window, cx)
342        });
343    }
344}
345
346#[derive(Default)]
347struct Physics {
348    position: f32,
349    velocity: f32,
350    dragging: bool,
351    suppress_momentum: bool,
352    extent: f32,
353    motion: ScrollBounceMotion,
354}
355
356impl Physics {
357    fn offset(&self) -> f32 {
358        if self.dragging {
359            let d = self.extent.max(1.);
360            let tracking = self.motion.tracking;
361            self.position * tracking / (1. + tracking * self.position.abs() / d)
362        } else {
363            self.position
364        }
365    }
366
367    fn begin(&mut self, extent: f32) {
368        let offset = self.offset();
369        // A displaced edge keeps the extent it was stretched under. The
370        // rubber-band curve saturates at the extent, so re-reading a viewport
371        // that shrank mid-return (rotation, keyboard) could not place the
372        // finger where the edge is: it would snap, then need a long pull back.
373        if offset == 0. {
374            self.extent = extent.max(1.);
375        }
376        // Invert the rubber-band curve so grabbing a returning edge is continuous.
377        let tracking = self.motion.tracking;
378        self.position = offset / (tracking * (1. - offset.abs() / self.extent).max(0.01));
379        self.velocity = 0.;
380        self.dragging = true;
381        self.suppress_momentum = false;
382    }
383
384    /// Apply finger displacement. Return the part that crosses back into the list.
385    fn pull(&mut self, delta: f32) -> f32 {
386        let previous = self.position;
387        let next = previous + delta;
388        if previous != 0. && previous.signum() != next.signum() {
389            self.position = 0.;
390            next
391        } else {
392            self.position = next;
393            0.
394        }
395    }
396
397    fn release(&mut self) {
398        self.position = self.offset();
399        self.dragging = false;
400        if self.position != 0. {
401            self.suppress_momentum = true;
402        }
403    }
404
405    /// Exact critically damped spring integration, independent of refresh rate.
406    fn step(&mut self, seconds: f32) -> bool {
407        if self.dragging || self.position == 0. {
408            return false;
409        }
410        let Some(omega) = self.motion.omega() else {
411            self.position = 0.;
412            self.velocity = 0.;
413            return false;
414        };
415        let decay = (-omega * seconds).exp();
416        let c = self.velocity + omega * self.position;
417        self.position = (self.position + c * seconds) * decay;
418        self.velocity = (self.velocity - omega * c * seconds) * decay;
419        if self.position.abs() < 0.1 && self.velocity.abs() < 1. {
420            self.position = 0.;
421            self.velocity = 0.;
422            false
423        } else {
424            true
425        }
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use gpui::{
433        Context, InteractiveElement as _, ParentElement as _, Render, ScrollHandle,
434        StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, div,
435    };
436
437    struct ScrollTest {
438        handle: ScrollHandle,
439        enabled: bool,
440    }
441
442    impl Render for ScrollTest {
443        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
444            div().p(px(20.)).child(
445                ScrollBounce::new(
446                    "bounce",
447                    &self.handle,
448                    div()
449                        .id("viewport")
450                        .w(px(200.))
451                        .h(px(200.))
452                        .overflow_y_scroll()
453                        .track_scroll(&self.handle)
454                        .child(div().h(px(600.)).w_full()),
455                )
456                .enabled(self.enabled),
457            )
458        }
459    }
460
461    fn draw(cx: &mut VisualTestContext) {
462        cx.update(|window, cx| window.draw(cx).clear(cx));
463    }
464
465    fn scroll(cx: &mut VisualTestContext, delta: f32, phase: TouchPhase) {
466        cx.simulate_event(ScrollWheelEvent {
467            position: point(px(100.), px(100.)),
468            delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
469            touch_phase: phase,
470            ..Default::default()
471        });
472    }
473
474    struct ListTest(gpui::ListState);
475
476    impl Render for ListTest {
477        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
478            ScrollBounce::new(
479                "bounce-list",
480                &self.0,
481                gpui::list(self.0.clone(), |_, _, _| {
482                    div().h(px(40.)).into_any_element()
483                })
484                .w(px(200.))
485                .h(px(200.)),
486            )
487            .enabled(true)
488        }
489    }
490
491    #[gpui::test]
492    fn list_reverse_drag_preserves_events_before_the_next_frame(cx: &mut TestAppContext) {
493        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
494        let (_, cx) = cx.add_window_view({
495            let handle = handle.clone();
496            move |_, _| ListTest(handle)
497        });
498        draw(cx);
499        scroll(cx, 100., TouchPhase::Started);
500        scroll(cx, -140., TouchPhase::Moved);
501        assert_eq!(handle.offset().y, px(-40.));
502        scroll(cx, -10., TouchPhase::Moved);
503        draw(cx);
504        assert_eq!(handle.offset().y, px(-50.));
505    }
506
507    #[gpui::test]
508    fn trackpad_release_between_frames_does_not_bounce_in_the_middle(cx: &mut TestAppContext) {
509        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
510        let (_, cx) = cx.add_window_view({
511            let handle = handle.clone();
512            move |_, _| ListTest(handle)
513        });
514        draw(cx);
515        handle.set_offset(point(px(0.), px(-400.)));
516        draw(cx);
517        let origin = handle.viewport_bounds().origin.y;
518        // Unlike simulate_event (which may draw after each event), dispatch
519        // both packets within one update to exercise native input coalescing.
520        cx.update(|window, cx| {
521            for (delta, phase) in [(-30., TouchPhase::Started), (0., TouchPhase::Ended)] {
522                window.dispatch_event(
523                    gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
524                        position: point(px(100.), px(100.)),
525                        delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
526                        touch_phase: phase,
527                        ..Default::default()
528                    }),
529                    cx,
530                );
531            }
532        });
533        draw(cx);
534        assert_eq!(handle.viewport_bounds().origin.y, origin);
535        assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
536    }
537
538    #[gpui::test]
539    fn trackpad_direction_change_between_frames_does_not_bounce_in_the_middle(
540        cx: &mut TestAppContext,
541    ) {
542        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
543        let (_, cx) = cx.add_window_view({
544            let handle = handle.clone();
545            move |_, _| ListTest(handle)
546        });
547        draw(cx);
548        handle.set_offset(point(px(0.), px(-400.)));
549        draw(cx);
550        let origin = handle.viewport_bounds().origin.y;
551        cx.update(|window, cx| {
552            for (delta, phase) in [(-30., TouchPhase::Started), (5., TouchPhase::Moved)] {
553                window.dispatch_event(
554                    gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
555                        position: point(px(100.), px(100.)),
556                        delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
557                        touch_phase: phase,
558                        ..Default::default()
559                    }),
560                    cx,
561                );
562            }
563        });
564        draw(cx);
565        assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
566        assert_eq!(handle.viewport_bounds().origin.y, origin);
567    }
568
569    #[gpui::test]
570    fn list_stretches_only_the_distance_past_either_edge(cx: &mut TestAppContext) {
571        for (start, delta, end) in [(-30., 50., 0.), (-970., -50., -1000.)] {
572            let mut app = cx.new_app();
573            let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
574            let (_, cx) = app.add_window_view({
575                let handle = handle.clone();
576                move |_, _| ListTest(handle)
577            });
578            draw(cx);
579            handle.set_offset(point(px(0.), px(start)));
580            draw(cx);
581            let origin = handle.viewport_bounds().origin.y;
582            scroll(cx, delta, TouchPhase::Started);
583            draw(cx);
584            assert_eq!(handle.offset().y, px(end));
585            let stretch = (handle.viewport_bounds().origin.y - origin).as_f32();
586            assert_eq!(stretch.signum(), delta.signum());
587            // Of the 50 px input, 30 px is ordinary scrolling. Only the
588            // remaining 20 px may be rubber-banded (resistance reduces it).
589            assert!(stretch.abs() > 0. && stretch.abs() < 20.);
590        }
591    }
592
593    #[gpui::test]
594    fn reverse_drag_consumes_stretch_before_scrolling_content(cx: &mut TestAppContext) {
595        let handle = ScrollHandle::new();
596        let (_, cx) = cx.add_window_view({
597            let handle = handle.clone();
598            move |_, _| ScrollTest {
599                handle,
600                enabled: true,
601            }
602        });
603        draw(cx);
604        let origin = handle.bounds().origin.y;
605        assert_eq!(origin, px(20.));
606        scroll(cx, 100., TouchPhase::Started);
607        draw(cx);
608        assert_eq!(handle.offset().y, px(0.));
609        assert!(handle.bounds().origin.y > origin);
610        scroll(cx, -140., TouchPhase::Moved);
611        draw(cx);
612        assert_eq!(handle.offset().y, px(-40.));
613        assert_eq!(handle.bounds().origin.y, origin);
614    }
615
616    #[gpui::test]
617    fn touch_release_ignores_momentum_until_a_new_touch_takes_over(cx: &mut TestAppContext) {
618        let handle = ScrollHandle::new();
619        let (_, cx) = cx.add_window_view({
620            let handle = handle.clone();
621            move |_, _| ScrollTest {
622                handle,
623                enabled: true,
624            }
625        });
626        draw(cx);
627        let origin = handle.bounds().origin.y;
628        scroll(cx, 100., TouchPhase::Started);
629        draw(cx);
630        assert!(handle.bounds().origin.y > origin);
631        scroll(cx, 0., TouchPhase::Ended);
632        draw(cx);
633        let released = handle.bounds().origin.y;
634        // The iOS backend emits Moved packets for momentum after finger-up.
635        // Even a large inward packet must not move the logical list while
636        // the returning edge owns this gesture.
637        scroll(cx, -400., TouchPhase::Moved);
638        draw(cx);
639        assert_eq!(handle.offset().y, px(0.));
640        assert!(handle.bounds().origin.y <= released);
641        // A new finger-down must end suppression and take over immediately.
642        scroll(cx, 0., TouchPhase::Started);
643        scroll(cx, -250., TouchPhase::Moved);
644        draw(cx);
645        assert!(handle.offset().y < px(0.));
646        assert_eq!(handle.bounds().origin.y, origin);
647    }
648
649    #[gpui::test]
650    fn disabled_and_reduced_motion_leave_the_viewport_fixed(cx: &mut TestAppContext) {
651        for enabled in [false, true] {
652            let mut app = cx.new_app();
653            if enabled {
654                app.update(|cx| cx.set_reduce_motion(true));
655            }
656            let handle = ScrollHandle::new();
657            let (_, cx) = app.add_window_view({
658                let handle = handle.clone();
659                move |_, _| ScrollTest { handle, enabled }
660            });
661            draw(cx);
662            let origin = handle.bounds().origin.y;
663            scroll(cx, 100., TouchPhase::Started);
664            draw(cx);
665            assert_eq!(handle.bounds().origin.y, origin);
666            scroll(cx, -40., TouchPhase::Moved);
667            draw(cx);
668            assert_eq!(handle.offset().y, px(-40.));
669        }
670    }
671
672    #[test]
673    fn resistance_and_reverse_preserve_unconsumed_distance() {
674        let mut scroll = Physics::default();
675        scroll.begin(600.);
676        assert_eq!(scroll.pull(100.), 0.);
677        assert!(scroll.offset() > 0. && scroll.offset() < 55.);
678        assert_eq!(scroll.pull(-130.), -30.);
679        assert_eq!(scroll.offset(), 0.);
680    }
681
682    #[gpui::test]
683    fn diagonal_wobble_stays_with_the_stretch(cx: &mut TestAppContext) {
684        let handle = ScrollHandle::new();
685        let (_, cx) = cx.add_window_view({
686            let handle = handle.clone();
687            move |_, _| ScrollTest {
688                handle,
689                enabled: true,
690            }
691        });
692        draw(cx);
693        let origin = handle.bounds().origin.y;
694        scroll(cx, 100., TouchPhase::Started);
695        draw(cx);
696        let stretched = handle.bounds().origin.y;
697        assert!(stretched > origin);
698        // A trackpad swipe that started vertical wobbles horizontal-dominant
699        // for a packet. Dispatch within one update so the packets stay within
700        // the axis lock's gesture separation.
701        cx.update(|window, cx| {
702            for delta in [point(px(30.), px(-20.)), point(px(0.), px(-20.))] {
703                window.dispatch_event(
704                    gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
705                        position: point(px(100.), px(100.)),
706                        delta: ScrollDelta::Pixels(delta),
707                        touch_phase: TouchPhase::Moved,
708                        ..Default::default()
709                    }),
710                    cx,
711                );
712            }
713        });
714        draw(cx);
715        // Both packets shrink the stretch; neither scrolls the list.
716        assert!(handle.bounds().origin.y < stretched);
717        assert!(handle.bounds().origin.y > origin);
718        assert_eq!(handle.offset(), point(px(0.), px(0.)));
719    }
720
721    #[test]
722    fn motion_builder_configures_tracking_and_response() {
723        let motion = ScrollBounceMotion::default()
724            .with_tracking(0.4)
725            .with_response(Duration::from_millis(300));
726        assert_eq!(motion.tracking(), 0.4);
727        assert_eq!(motion.response(), Duration::from_millis(300));
728    }
729
730    #[test]
731    fn tracking_scales_the_first_stretch() {
732        let stretch = |tracking: f32| {
733            let mut scroll = Physics {
734                motion: ScrollBounceMotion::default().with_tracking(tracking),
735                ..Physics::default()
736            };
737            scroll.begin(600.);
738            scroll.pull(100.);
739            scroll.offset()
740        };
741        assert!(stretch(0.3) < stretch(0.55));
742        assert!(stretch(0.55) < stretch(0.8));
743    }
744
745    #[test]
746    fn response_scales_the_return_and_zero_snaps() {
747        let remaining = |response: Duration| {
748            let mut scroll = Physics {
749                motion: ScrollBounceMotion::default().with_response(response),
750                ..Physics::default()
751            };
752            scroll.begin(600.);
753            scroll.pull(180.);
754            scroll.release();
755            scroll.step(0.25);
756            scroll.offset()
757        };
758        assert!(remaining(Duration::from_secs(1)) > remaining(Duration::from_millis(524)));
759        assert!(remaining(Duration::from_millis(524)) > remaining(Duration::from_millis(200)));
760        assert_eq!(remaining(Duration::ZERO), 0.);
761    }
762
763    #[test]
764    fn regrabbing_a_displaced_edge_keeps_its_extent() {
765        let mut scroll = Physics::default();
766        scroll.begin(600.);
767        scroll.pull(-150.);
768        scroll.release();
769        scroll.step(0.08);
770        let before = scroll.offset();
771        // The viewport shrank below the displacement while the edge was
772        // returning. The finger still lands on the edge where it is.
773        scroll.begin(40.);
774        assert!((scroll.offset() - before).abs() < 0.001);
775        // And a short pull inward moves the edge right away.
776        scroll.pull(10.);
777        assert!(scroll.offset() > before);
778        assert!(scroll.offset() < before + 10.);
779    }
780
781    #[test]
782    fn a_gesture_from_rest_adopts_the_current_extent() {
783        let mut scroll = Physics::default();
784        scroll.begin(600.);
785        scroll.begin(40.);
786        scroll.pull(-100.);
787        // The stretch saturates below the 40 px viewport, not the 600 px one.
788        assert!(scroll.offset() > -40.);
789    }
790
791    #[test]
792    fn grabbing_the_spring_does_not_jump() {
793        let mut scroll = Physics::default();
794        scroll.begin(600.);
795        scroll.pull(-150.);
796        scroll.release();
797        scroll.step(0.08);
798        let before = scroll.offset();
799        scroll.begin(600.);
800        assert!((scroll.offset() - before).abs() < 0.001);
801        let held = scroll.offset();
802        assert!(!scroll.step(0.1));
803        assert_eq!(scroll.offset(), held);
804    }
805
806    #[test]
807    fn spring_has_the_same_trajectory_at_60_and_120_hz() {
808        let at = |hz: usize| {
809            let mut scroll = Physics::default();
810            scroll.begin(600.);
811            scroll.pull(180.);
812            scroll.release();
813            for _ in 0..hz / 4 {
814                scroll.step(1. / hz as f32);
815            }
816            scroll.offset()
817        };
818        assert!((at(60) - at(120)).abs() < 0.001);
819    }
820
821    #[test]
822    fn return_keeps_a_visible_tail_after_a_quarter_second() {
823        let mut scroll = Physics {
824            position: 100.,
825            ..Physics::default()
826        };
827        assert!(scroll.step(0.25));
828        // A 100 px release should still have a visible, decelerating tail
829        // after 250 ms instead of snapping almost completely back by then.
830        assert!(scroll.offset() > 10. && scroll.offset() < 30.);
831        assert!(scroll.velocity < 0.);
832    }
833
834    #[test]
835    fn spring_settles_without_crossing_the_boundary() {
836        let mut scroll = Physics::default();
837        scroll.begin(600.);
838        scroll.pull(-200.);
839        scroll.release();
840        let mut previous = scroll.offset();
841        for _ in 0..120 {
842            scroll.step(1. / 120.);
843            assert!(scroll.offset() >= previous && scroll.offset() <= 0.);
844            previous = scroll.offset();
845        }
846        assert_eq!(scroll.offset(), 0.);
847        assert!(scroll.suppress_momentum);
848        scroll.begin(600.);
849        assert!(!scroll.suppress_momentum);
850    }
851}