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