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    short_drag_distance: Option<f32>,
147}
148
149// GPUI starts a normal touch pan only after its 8 px touch slop, but a touch
150// catching a fling starts at zero displacement. A short catch should stop the
151// old fling rather than turn a few fast pixels into a new one.
152const CATCH_DRAG_SLOP: f32 = 8.;
153
154/// `ScrollbarHandle` has no `max_offset`; recover it from the definition
155/// `content_size = viewport + max_offset`. Both dispatch phases clamp against
156/// this bound and must agree on it.
157fn max_scroll_extent(handle: &dyn ScrollbarHandle) -> Pixels {
158    (handle.content_size().height - handle.viewport_bounds().size.height).max(px(0.))
159}
160
161#[doc(hidden)]
162pub struct ScrollBouncePrepaintState {
163    state: Rc<RefCell<State>>,
164    hitbox: Hitbox,
165}
166
167impl IntoElement for ScrollBounce {
168    type Element = Self;
169    fn into_element(self) -> Self {
170        self
171    }
172}
173
174impl Element for ScrollBounce {
175    type RequestLayoutState = ();
176    type PrepaintState = ScrollBouncePrepaintState;
177
178    fn id(&self) -> Option<ElementId> {
179        Some(self.id.clone())
180    }
181    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
182        None
183    }
184
185    fn request_layout(
186        &mut self,
187        _: Option<&GlobalElementId>,
188        _: Option<&InspectorElementId>,
189        window: &mut Window,
190        cx: &mut App,
191    ) -> (LayoutId, ()) {
192        (self.child.request_layout(window, cx), ())
193    }
194
195    fn prepaint(
196        &mut self,
197        id: Option<&GlobalElementId>,
198        _: Option<&InspectorElementId>,
199        bounds: Bounds<Pixels>,
200        _: &mut (),
201        window: &mut Window,
202        cx: &mut App,
203    ) -> Self::PrepaintState {
204        let state = window.with_element_state(
205            id.expect("ScrollBounce has an id"),
206            |state: Option<Rc<RefCell<State>>>, _| {
207                let state = state.unwrap_or_default();
208                (state.clone(), state)
209            },
210        );
211        let offset = {
212            let mut state = state.borrow_mut();
213            if !self.enabled || cx.reduce_motion() {
214                *state = State::default();
215            }
216            state.physics.motion = self.motion;
217            let now = Instant::now();
218            let elapsed = state
219                .sampled_at
220                .replace(now)
221                .map_or(0., |at| now.duration_since(at).as_secs_f32());
222            if state.physics.step(elapsed) {
223                window.request_animation_frame();
224            }
225            state.physics.offset()
226        };
227        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
228        window.with_content_mask(Some(ContentMask { bounds }), |window| {
229            window.with_element_offset(point(px(0.), px(offset)), |window| {
230                self.child.prepaint(window, cx);
231            });
232        });
233        ScrollBouncePrepaintState { state, hitbox }
234    }
235
236    fn paint(
237        &mut self,
238        _: Option<&GlobalElementId>,
239        _: Option<&InspectorElementId>,
240        bounds: Bounds<Pixels>,
241        _: &mut (),
242        prepaint: &mut Self::PrepaintState,
243        window: &mut Window,
244        cx: &mut App,
245    ) {
246        if self.enabled && !cx.reduce_motion() {
247            let state = prepaint.state.clone();
248            let hitbox = prepaint.hitbox.id;
249            let handle = self.handle.clone();
250            let view = window.current_view();
251            let on_scroll = self.on_scroll.clone();
252            let mut before = 0.;
253            let mut allow_end_bounce = false;
254            window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
255                let ScrollDelta::Pixels(mut delta) = event.delta else {
256                    return;
257                };
258                if !hitbox.should_handle_scroll(window) {
259                    return;
260                }
261                let mut state = state.borrow_mut();
262                // Lock the gesture to the axis it started on, so a diagonal
263                // swipe cannot wobble out of the stretch from one packet to
264                // the next. Both dispatch phases see the same packet, and the
265                // lock gives both the same answer.
266                state
267                    .ongoing_scroll
268                    .lock_axis(&mut delta, event.touch_phase);
269                if delta.x.abs() > delta.y.abs() {
270                    return;
271                }
272                let ended = matches!(event.touch_phase, TouchPhase::Ended | TouchPhase::Cancelled);
273                let mut scrolled = false;
274                let mut changed = false;
275                if phase == DispatchPhase::Capture {
276                    before = handle.offset().y.as_f32();
277                    if event.touch_phase == TouchPhase::Started {
278                        state.short_drag_distance = (delta.y == px(0.)).then_some(0.);
279                        state.physics.begin(bounds.size.height.as_f32());
280                    } else if let Some(distance) = state.short_drag_distance.as_mut() {
281                        *distance += delta.y.as_f32().abs();
282                        if *distance > CATCH_DRAG_SLOP {
283                            state.short_drag_distance = None;
284                        }
285                    }
286                    let suppress_short_drag_momentum = if ended {
287                        state.short_drag_distance.take().is_some()
288                            && event.touch_phase == TouchPhase::Ended
289                    } else {
290                        false
291                    };
292                    // The current Ended packet may still cross an edge; only
293                    // momentum packets after it should be suppressed.
294                    allow_end_bounce = suppress_short_drag_momentum;
295                    if state.physics.suppress_momentum {
296                        cx.stop_propagation();
297                        return;
298                    }
299                    if state.physics.offset() != 0. {
300                        let remainder = state.physics.pull(delta.y.as_f32());
301                        if remainder != 0. {
302                            let max = max_scroll_extent(handle.as_ref());
303                            let mut offset = handle.offset();
304                            offset.y = px(before + remainder).clamp(-max, px(0.));
305                            handle.set_offset(offset);
306                            scrolled = true;
307                        }
308                        if ended {
309                            state.physics.release();
310                        }
311                        changed = true;
312                        cx.stop_propagation();
313                    } else if ended {
314                        state.physics.release();
315                    }
316                    if suppress_short_drag_momentum {
317                        state.physics.suppress_momentum = true;
318                    }
319                } else {
320                    // Div applies deltas immediately but clamps during its next
321                    // prepaint. Clamp here so that boundary deltas are not
322                    // mistaken for consumed scrolling (ListState clamps eagerly).
323                    let mut offset = handle.offset();
324                    let max = max_scroll_extent(handle.as_ref());
325                    let clamped = offset.y.clamp(-max, px(0.));
326                    if clamped != offset.y {
327                        offset.y = clamped;
328                        handle.set_offset(offset);
329                    }
330                    let after = offset.y.as_f32();
331                    let requested = delta.y.as_f32();
332                    // A List can coalesce several packets against one painted
333                    // scroll position. Their offset difference alone does not
334                    // prove overscroll, especially after direction changes or
335                    // a zero-delta Ended packet from a trackpad.
336                    let at_outward_edge = (requested > 0. && offset.y == px(0.))
337                        || (requested < 0. && offset.y == -max);
338                    let residual =
339                        (requested - (after - before)).clamp(requested.min(0.), requested.max(0.));
340                    if at_outward_edge
341                        && residual.abs() > 0.01
342                        && (!state.physics.suppress_momentum || allow_end_bounce)
343                    {
344                        let dragging = state.physics.dragging;
345                        if !dragging {
346                            state.physics.begin(bounds.size.height.as_f32());
347                        }
348                        state.physics.pull(residual);
349                        if !dragging || ended {
350                            state.physics.release();
351                        }
352                        changed = true;
353                    }
354                }
355                if changed {
356                    state.sampled_at = Some(Instant::now());
357                }
358                drop(state);
359                if changed {
360                    cx.notify(view);
361                }
362                if scrolled && let Some(handler) = &on_scroll {
363                    handler(window, cx);
364                }
365            });
366        }
367        window.with_content_mask(Some(ContentMask { bounds }), |window| {
368            self.child.paint(window, cx)
369        });
370    }
371}
372
373#[derive(Default)]
374struct Physics {
375    position: f32,
376    velocity: f32,
377    dragging: bool,
378    suppress_momentum: bool,
379    extent: f32,
380    motion: ScrollBounceMotion,
381}
382
383impl Physics {
384    fn offset(&self) -> f32 {
385        if self.dragging {
386            let d = self.extent.max(1.);
387            let tracking = self.motion.tracking;
388            self.position * tracking / (1. + tracking * self.position.abs() / d)
389        } else {
390            self.position
391        }
392    }
393
394    fn begin(&mut self, extent: f32) {
395        let offset = self.offset();
396        // A displaced edge keeps the extent it was stretched under. The
397        // rubber-band curve saturates at the extent, so re-reading a viewport
398        // that shrank mid-return (rotation, keyboard) could not place the
399        // finger where the edge is: it would snap, then need a long pull back.
400        if offset == 0. {
401            self.extent = extent.max(1.);
402        }
403        // Invert the rubber-band curve so grabbing a returning edge is continuous.
404        let tracking = self.motion.tracking;
405        self.position = offset / (tracking * (1. - offset.abs() / self.extent).max(0.01));
406        self.velocity = 0.;
407        self.dragging = true;
408        self.suppress_momentum = false;
409    }
410
411    /// Apply finger displacement. Return the part that crosses back into the list.
412    fn pull(&mut self, delta: f32) -> f32 {
413        let previous = self.position;
414        let next = previous + delta;
415        if previous != 0. && previous.signum() != next.signum() {
416            self.position = 0.;
417            next
418        } else {
419            self.position = next;
420            0.
421        }
422    }
423
424    fn release(&mut self) {
425        self.position = self.offset();
426        self.dragging = false;
427        if self.position != 0. {
428            self.suppress_momentum = true;
429        }
430    }
431
432    /// Exact critically damped spring integration, independent of refresh rate.
433    fn step(&mut self, seconds: f32) -> bool {
434        if self.dragging || self.position == 0. {
435            return false;
436        }
437        let Some(omega) = self.motion.omega() else {
438            self.position = 0.;
439            self.velocity = 0.;
440            return false;
441        };
442        let decay = (-omega * seconds).exp();
443        let c = self.velocity + omega * self.position;
444        self.position = (self.position + c * seconds) * decay;
445        self.velocity = (self.velocity - omega * c * seconds) * decay;
446        if self.position.abs() < 0.1 && self.velocity.abs() < 1. {
447            self.position = 0.;
448            self.velocity = 0.;
449            false
450        } else {
451            true
452        }
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use gpui::{
460        Context, InteractiveElement as _, ParentElement as _, Render, ScrollHandle,
461        StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, div,
462    };
463
464    struct ScrollTest {
465        handle: ScrollHandle,
466        enabled: bool,
467    }
468
469    impl Render for ScrollTest {
470        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
471            div().p(px(20.)).child(
472                ScrollBounce::new(
473                    "bounce",
474                    &self.handle,
475                    div()
476                        .id("viewport")
477                        .w(px(200.))
478                        .h(px(200.))
479                        .overflow_y_scroll()
480                        .track_scroll(&self.handle)
481                        .child(div().h(px(600.)).w_full()),
482                )
483                .enabled(self.enabled),
484            )
485        }
486    }
487
488    fn draw(cx: &mut VisualTestContext) {
489        cx.update(|window, cx| window.draw(cx).clear(cx));
490    }
491
492    fn scroll(cx: &mut VisualTestContext, delta: f32, phase: TouchPhase) {
493        cx.simulate_event(ScrollWheelEvent {
494            position: point(px(100.), px(100.)),
495            delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
496            touch_phase: phase,
497            ..Default::default()
498        });
499    }
500
501    struct ListTest(gpui::ListState);
502
503    impl Render for ListTest {
504        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
505            ScrollBounce::new(
506                "bounce-list",
507                &self.0,
508                gpui::list(self.0.clone(), |_, _, _| {
509                    div().h(px(40.)).into_any_element()
510                })
511                .w(px(200.))
512                .h(px(200.)),
513            )
514            .enabled(true)
515        }
516    }
517
518    #[gpui::test]
519    fn list_reverse_drag_preserves_events_before_the_next_frame(cx: &mut TestAppContext) {
520        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
521        let (_, cx) = cx.add_window_view({
522            let handle = handle.clone();
523            move |_, _| ListTest(handle)
524        });
525        draw(cx);
526        scroll(cx, 100., TouchPhase::Started);
527        scroll(cx, -140., TouchPhase::Moved);
528        assert_eq!(handle.offset().y, px(-40.));
529        scroll(cx, -10., TouchPhase::Moved);
530        draw(cx);
531        assert_eq!(handle.offset().y, px(-50.));
532    }
533
534    #[gpui::test]
535    fn trackpad_release_between_frames_does_not_bounce_in_the_middle(cx: &mut TestAppContext) {
536        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
537        let (_, cx) = cx.add_window_view({
538            let handle = handle.clone();
539            move |_, _| ListTest(handle)
540        });
541        draw(cx);
542        handle.set_offset(point(px(0.), px(-400.)));
543        draw(cx);
544        let origin = handle.viewport_bounds().origin.y;
545        // Unlike simulate_event (which may draw after each event), dispatch
546        // both packets within one update to exercise native input coalescing.
547        cx.update(|window, cx| {
548            for (delta, phase) in [(-30., TouchPhase::Started), (0., TouchPhase::Ended)] {
549                window.dispatch_event(
550                    gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
551                        position: point(px(100.), px(100.)),
552                        delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
553                        touch_phase: phase,
554                        ..Default::default()
555                    }),
556                    cx,
557                );
558            }
559        });
560        draw(cx);
561        assert_eq!(handle.viewport_bounds().origin.y, origin);
562        assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
563    }
564
565    #[gpui::test]
566    fn trackpad_direction_change_between_frames_does_not_bounce_in_the_middle(
567        cx: &mut TestAppContext,
568    ) {
569        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
570        let (_, cx) = cx.add_window_view({
571            let handle = handle.clone();
572            move |_, _| ListTest(handle)
573        });
574        draw(cx);
575        handle.set_offset(point(px(0.), px(-400.)));
576        draw(cx);
577        let origin = handle.viewport_bounds().origin.y;
578        cx.update(|window, cx| {
579            for (delta, phase) in [(-30., TouchPhase::Started), (5., TouchPhase::Moved)] {
580                window.dispatch_event(
581                    gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
582                        position: point(px(100.), px(100.)),
583                        delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
584                        touch_phase: phase,
585                        ..Default::default()
586                    }),
587                    cx,
588                );
589            }
590        });
591        draw(cx);
592        assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
593        assert_eq!(handle.viewport_bounds().origin.y, origin);
594    }
595
596    #[gpui::test]
597    fn list_stretches_only_the_distance_past_either_edge(cx: &mut TestAppContext) {
598        for (start, delta, end) in [(-30., 50., 0.), (-970., -50., -1000.)] {
599            let mut app = cx.new_app();
600            let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
601            let (_, cx) = app.add_window_view({
602                let handle = handle.clone();
603                move |_, _| ListTest(handle)
604            });
605            draw(cx);
606            handle.set_offset(point(px(0.), px(start)));
607            draw(cx);
608            let origin = handle.viewport_bounds().origin.y;
609            scroll(cx, delta, TouchPhase::Started);
610            draw(cx);
611            assert_eq!(handle.offset().y, px(end));
612            let stretch = (handle.viewport_bounds().origin.y - origin).as_f32();
613            assert_eq!(stretch.signum(), delta.signum());
614            // Of the 50 px input, 30 px is ordinary scrolling. Only the
615            // remaining 20 px may be rubber-banded (resistance reduces it).
616            assert!(stretch.abs() > 0. && stretch.abs() < 20.);
617        }
618    }
619
620    #[gpui::test]
621    fn reverse_drag_consumes_stretch_before_scrolling_content(cx: &mut TestAppContext) {
622        let handle = ScrollHandle::new();
623        let (_, cx) = cx.add_window_view({
624            let handle = handle.clone();
625            move |_, _| ScrollTest {
626                handle,
627                enabled: true,
628            }
629        });
630        draw(cx);
631        let origin = handle.bounds().origin.y;
632        assert_eq!(origin, px(20.));
633        scroll(cx, 100., TouchPhase::Started);
634        draw(cx);
635        assert_eq!(handle.offset().y, px(0.));
636        assert!(handle.bounds().origin.y > origin);
637        scroll(cx, -140., TouchPhase::Moved);
638        draw(cx);
639        assert_eq!(handle.offset().y, px(-40.));
640        assert_eq!(handle.bounds().origin.y, origin);
641    }
642
643    #[gpui::test]
644    fn touch_release_ignores_momentum_until_a_new_touch_takes_over(cx: &mut TestAppContext) {
645        let handle = ScrollHandle::new();
646        let (_, cx) = cx.add_window_view({
647            let handle = handle.clone();
648            move |_, _| ScrollTest {
649                handle,
650                enabled: true,
651            }
652        });
653        draw(cx);
654        let origin = handle.bounds().origin.y;
655        scroll(cx, 100., TouchPhase::Started);
656        draw(cx);
657        assert!(handle.bounds().origin.y > origin);
658        scroll(cx, 0., TouchPhase::Ended);
659        draw(cx);
660        let released = handle.bounds().origin.y;
661        // The iOS backend emits Moved packets for momentum after finger-up.
662        // Even a large inward packet must not move the logical list while
663        // the returning edge owns this gesture.
664        scroll(cx, -400., TouchPhase::Moved);
665        draw(cx);
666        assert_eq!(handle.offset().y, px(0.));
667        assert!(handle.bounds().origin.y <= released);
668        // A new finger-down must end suppression and take over immediately.
669        scroll(cx, 0., TouchPhase::Started);
670        scroll(cx, -250., TouchPhase::Moved);
671        draw(cx);
672        assert!(handle.offset().y < px(0.));
673        assert_eq!(handle.bounds().origin.y, origin);
674    }
675
676    #[gpui::test]
677    fn tiny_drag_catching_momentum_does_not_start_a_reverse_fling(cx: &mut TestAppContext) {
678        let handle = ScrollHandle::new();
679        let (_, cx) = cx.add_window_view({
680            let handle = handle.clone();
681            move |_, _| ScrollTest {
682                handle,
683                enabled: true,
684            }
685        });
686        draw(cx);
687        handle.set_offset(point(px(0.), px(-200.)));
688        draw(cx);
689
690        // GPUI ends the old momentum stream, then starts a drag at zero
691        // displacement when a finger catches the moving content.
692        scroll(cx, -60., TouchPhase::Started);
693        scroll(cx, 0., TouchPhase::Ended);
694        draw(cx);
695        scroll(cx, -100., TouchPhase::Moved);
696        draw(cx);
697        scroll(cx, 0., TouchPhase::Ended);
698        draw(cx);
699        let before_catch = handle.offset().y;
700        assert!(before_catch < px(0.));
701        scroll(cx, 0., TouchPhase::Started);
702        scroll(cx, 8., TouchPhase::Moved);
703        scroll(cx, 0., TouchPhase::Ended);
704        draw(cx);
705        let stopped = handle.offset().y;
706        assert_eq!(stopped, before_catch + px(8.));
707
708        // The recognizer can synthesize a large reverse momentum packet from
709        // that 8 px movement. It must not move the logical viewport.
710        scroll(cx, 100., TouchPhase::Moved);
711        draw(cx);
712        assert_eq!(handle.offset().y, stopped);
713
714        // A fresh gesture restores ordinary scrolling and momentum.
715        scroll(cx, -20., TouchPhase::Started);
716        scroll(cx, 0., TouchPhase::Ended);
717        scroll(cx, -10., TouchPhase::Moved);
718        draw(cx);
719        assert_eq!(handle.offset().y, stopped - px(30.));
720    }
721
722    #[gpui::test]
723    fn deliberate_drag_after_catching_momentum_can_fling(cx: &mut TestAppContext) {
724        let handle = ScrollHandle::new();
725        let (_, cx) = cx.add_window_view({
726            let handle = handle.clone();
727            move |_, _| ScrollTest {
728                handle,
729                enabled: true,
730            }
731        });
732        draw(cx);
733        handle.set_offset(point(px(0.), px(-200.)));
734        draw(cx);
735
736        scroll(cx, 0., TouchPhase::Started);
737        scroll(cx, 24., TouchPhase::Moved);
738        scroll(cx, 0., TouchPhase::Ended);
739        draw(cx);
740        assert_eq!(handle.offset().y, px(-176.));
741        scroll(cx, 40., TouchPhase::Moved);
742        draw(cx);
743        assert_eq!(handle.offset().y, px(-136.));
744    }
745
746    #[gpui::test]
747    fn short_catch_release_still_stretches_past_the_edge(cx: &mut TestAppContext) {
748        let handle = ScrollHandle::new();
749        let (_, cx) = cx.add_window_view({
750            let handle = handle.clone();
751            move |_, _| ScrollTest {
752                handle,
753                enabled: true,
754            }
755        });
756        draw(cx);
757        handle.set_offset(point(px(0.), px(-4.)));
758        draw(cx);
759        let origin = handle.bounds().origin.y;
760
761        scroll(cx, 0., TouchPhase::Started);
762        scroll(cx, 8., TouchPhase::Ended);
763        draw(cx);
764        assert_eq!(handle.offset().y, px(0.));
765        assert!(handle.bounds().origin.y > origin);
766    }
767
768    #[gpui::test]
769    fn disabled_and_reduced_motion_leave_the_viewport_fixed(cx: &mut TestAppContext) {
770        for enabled in [false, true] {
771            let mut app = cx.new_app();
772            if enabled {
773                app.update(|cx| cx.set_reduce_motion(true));
774            }
775            let handle = ScrollHandle::new();
776            let (_, cx) = app.add_window_view({
777                let handle = handle.clone();
778                move |_, _| ScrollTest { handle, enabled }
779            });
780            draw(cx);
781            let origin = handle.bounds().origin.y;
782            scroll(cx, 100., TouchPhase::Started);
783            draw(cx);
784            assert_eq!(handle.bounds().origin.y, origin);
785            scroll(cx, -40., TouchPhase::Moved);
786            draw(cx);
787            assert_eq!(handle.offset().y, px(-40.));
788        }
789    }
790
791    #[test]
792    fn resistance_and_reverse_preserve_unconsumed_distance() {
793        let mut scroll = Physics::default();
794        scroll.begin(600.);
795        assert_eq!(scroll.pull(100.), 0.);
796        assert!(scroll.offset() > 0. && scroll.offset() < 55.);
797        assert_eq!(scroll.pull(-130.), -30.);
798        assert_eq!(scroll.offset(), 0.);
799    }
800
801    #[gpui::test]
802    fn diagonal_wobble_stays_with_the_stretch(cx: &mut TestAppContext) {
803        let handle = ScrollHandle::new();
804        let (_, cx) = cx.add_window_view({
805            let handle = handle.clone();
806            move |_, _| ScrollTest {
807                handle,
808                enabled: true,
809            }
810        });
811        draw(cx);
812        let origin = handle.bounds().origin.y;
813        scroll(cx, 100., TouchPhase::Started);
814        draw(cx);
815        let stretched = handle.bounds().origin.y;
816        assert!(stretched > origin);
817        // A trackpad swipe that started vertical wobbles horizontal-dominant
818        // for a packet. Dispatch within one update so the packets stay within
819        // the axis lock's gesture separation.
820        cx.update(|window, cx| {
821            for delta in [point(px(30.), px(-20.)), point(px(0.), px(-20.))] {
822                window.dispatch_event(
823                    gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
824                        position: point(px(100.), px(100.)),
825                        delta: ScrollDelta::Pixels(delta),
826                        touch_phase: TouchPhase::Moved,
827                        ..Default::default()
828                    }),
829                    cx,
830                );
831            }
832        });
833        draw(cx);
834        // Both packets shrink the stretch; neither scrolls the list.
835        assert!(handle.bounds().origin.y < stretched);
836        assert!(handle.bounds().origin.y > origin);
837        assert_eq!(handle.offset(), point(px(0.), px(0.)));
838    }
839
840    #[test]
841    fn motion_builder_configures_tracking_and_response() {
842        let motion = ScrollBounceMotion::default()
843            .with_tracking(0.4)
844            .with_response(Duration::from_millis(300));
845        assert_eq!(motion.tracking(), 0.4);
846        assert_eq!(motion.response(), Duration::from_millis(300));
847    }
848
849    #[test]
850    fn tracking_scales_the_first_stretch() {
851        let stretch = |tracking: f32| {
852            let mut scroll = Physics {
853                motion: ScrollBounceMotion::default().with_tracking(tracking),
854                ..Physics::default()
855            };
856            scroll.begin(600.);
857            scroll.pull(100.);
858            scroll.offset()
859        };
860        assert!(stretch(0.3) < stretch(0.55));
861        assert!(stretch(0.55) < stretch(0.8));
862    }
863
864    #[test]
865    fn response_scales_the_return_and_zero_snaps() {
866        let remaining = |response: Duration| {
867            let mut scroll = Physics {
868                motion: ScrollBounceMotion::default().with_response(response),
869                ..Physics::default()
870            };
871            scroll.begin(600.);
872            scroll.pull(180.);
873            scroll.release();
874            scroll.step(0.25);
875            scroll.offset()
876        };
877        assert!(remaining(Duration::from_secs(1)) > remaining(Duration::from_millis(524)));
878        assert!(remaining(Duration::from_millis(524)) > remaining(Duration::from_millis(200)));
879        assert_eq!(remaining(Duration::ZERO), 0.);
880    }
881
882    #[test]
883    fn regrabbing_a_displaced_edge_keeps_its_extent() {
884        let mut scroll = Physics::default();
885        scroll.begin(600.);
886        scroll.pull(-150.);
887        scroll.release();
888        scroll.step(0.08);
889        let before = scroll.offset();
890        // The viewport shrank below the displacement while the edge was
891        // returning. The finger still lands on the edge where it is.
892        scroll.begin(40.);
893        assert!((scroll.offset() - before).abs() < 0.001);
894        // And a short pull inward moves the edge right away.
895        scroll.pull(10.);
896        assert!(scroll.offset() > before);
897        assert!(scroll.offset() < before + 10.);
898    }
899
900    #[test]
901    fn a_gesture_from_rest_adopts_the_current_extent() {
902        let mut scroll = Physics::default();
903        scroll.begin(600.);
904        scroll.begin(40.);
905        scroll.pull(-100.);
906        // The stretch saturates below the 40 px viewport, not the 600 px one.
907        assert!(scroll.offset() > -40.);
908    }
909
910    #[test]
911    fn grabbing_the_spring_does_not_jump() {
912        let mut scroll = Physics::default();
913        scroll.begin(600.);
914        scroll.pull(-150.);
915        scroll.release();
916        scroll.step(0.08);
917        let before = scroll.offset();
918        scroll.begin(600.);
919        assert!((scroll.offset() - before).abs() < 0.001);
920        let held = scroll.offset();
921        assert!(!scroll.step(0.1));
922        assert_eq!(scroll.offset(), held);
923    }
924
925    #[test]
926    fn spring_has_the_same_trajectory_at_60_and_120_hz() {
927        let at = |hz: usize| {
928            let mut scroll = Physics::default();
929            scroll.begin(600.);
930            scroll.pull(180.);
931            scroll.release();
932            for _ in 0..hz / 4 {
933                scroll.step(1. / hz as f32);
934            }
935            scroll.offset()
936        };
937        assert!((at(60) - at(120)).abs() < 0.001);
938    }
939
940    #[test]
941    fn return_keeps_a_visible_tail_after_a_quarter_second() {
942        let mut scroll = Physics {
943            position: 100.,
944            ..Physics::default()
945        };
946        assert!(scroll.step(0.25));
947        // A 100 px release should still have a visible, decelerating tail
948        // after 250 ms instead of snapping almost completely back by then.
949        assert!(scroll.offset() > 10. && scroll.offset() < 30.);
950        assert!(scroll.velocity < 0.);
951    }
952
953    #[test]
954    fn spring_settles_without_crossing_the_boundary() {
955        let mut scroll = Physics::default();
956        scroll.begin(600.);
957        scroll.pull(-200.);
958        scroll.release();
959        let mut previous = scroll.offset();
960        for _ in 0..120 {
961            scroll.step(1. / 120.);
962            assert!(scroll.offset() >= previous && scroll.offset() <= 0.);
963            previous = scroll.offset();
964        }
965        assert_eq!(scroll.offset(), 0.);
966        assert!(scroll.suppress_momentum);
967        scroll.begin(600.);
968        assert!(!scroll.suppress_momentum);
969    }
970}