Skip to main content

gpui_base/
scroll_bounce.rs

1//! Boundary displacement only: the list keeps its clamped logical position.
2
3use crate::overscroll::Overscroll;
4use crate::{OngoingScrollExt as _, ScrollbarHandle};
5use gpui::{
6    AnyElement, App, Bounds, ContentMask, DispatchPhase, Element, ElementId, Global,
7    GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, LayoutId,
8    OngoingScroll, Pixels, ScrollDelta, ScrollEvent, TouchPhase, Window, point, px,
9};
10#[cfg(not(target_family = "wasm"))]
11use std::time::Instant;
12use std::{cell::RefCell, rc::Rc, time::Duration};
13#[cfg(target_family = "wasm")]
14use web_time::Instant;
15
16/// Motion tokens for [`ScrollBounce`]: how far a drag stretches the viewport
17/// past an edge, and how quickly a released edge returns.
18///
19/// Base plays the stretch and the return; the feel belongs to the caller.
20/// The default is tuned to feel like a `UIScrollView` bounce on iOS and
21/// Android, and like an `NSScrollView` bounce elsewhere.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct ScrollBounceMotion {
24    tracking: f32,
25    response: Duration,
26    max_stretch: Option<f32>,
27}
28
29impl Default for ScrollBounceMotion {
30    /// Tuned to feel like a `UIScrollView` bounce on touch platforms; these
31    /// are not UIKit constants. Elsewhere the input is a trackpad, whose
32    /// accelerated deltas run far past a finger's travel, so the default is
33    /// fitted to an `NSScrollView` measured on macOS: the stretch follows
34    /// about 0.075 of the travel, the return decays at 12.5 per second, and
35    /// the edge stops 36 px out. AppKit stretches by the unaccelerated
36    /// travel, which GPUI does not report; the limit keeps a hard swipe as
37    /// short as Finder's.
38    fn default() -> Self {
39        if cfg!(any(target_os = "ios", target_os = "android")) {
40            Self {
41                tracking: 0.55,
42                response: Duration::from_millis(524),
43                max_stretch: None,
44            }
45        } else {
46            Self {
47                tracking: 0.075,
48                // 2π / 12.5
49                response: Duration::from_micros(502_655),
50                max_stretch: Some(36.),
51            }
52        }
53    }
54}
55
56impl ScrollBounceMotion {
57    /// Fraction of finger travel the stretched edge follows at first.
58    ///
59    /// The edge follows less and less as it approaches the viewport height,
60    /// which it never reaches. Tracking is independent of the return, so
61    /// slowing the return does not change how the finger feels.
62    ///
63    /// # Panics
64    ///
65    /// Panics when `tracking` is not finite or not positive.
66    pub fn with_tracking(mut self, tracking: f32) -> Self {
67        assert!(
68            tracking.is_finite() && tracking > 0.,
69            "scroll bounce tracking must be finite and positive"
70        );
71        self.tracking = tracking;
72        self
73    }
74
75    /// Time scale of the return once the finger lifts.
76    ///
77    /// Read the way [`crate::Spring::new`] reads its response: the period one
78    /// full oscillation would take without damping, which is the scale the
79    /// return is felt at rather than the moment it stops. The return is
80    /// critically damped, so it never crosses the edge. A zero response snaps
81    /// the edge back on the spot.
82    pub fn with_response(mut self, response: Duration) -> Self {
83        self.response = response;
84        self
85    }
86
87    /// The farthest the edge goes past its end, in pixels, whether stretched
88    /// or thrown by a fling. The stretch eases into it rather than stopping
89    /// at it.
90    ///
91    /// # Panics
92    ///
93    /// Panics when `max_stretch` is not finite or not positive.
94    pub fn with_max_stretch(mut self, max_stretch: f32) -> Self {
95        assert!(
96            max_stretch.is_finite() && max_stretch > 0.,
97            "scroll bounce max stretch must be finite and positive"
98        );
99        self.max_stretch = Some(max_stretch);
100        self
101    }
102
103    /// Fraction of finger travel the stretched edge follows at first.
104    pub fn tracking(&self) -> f32 {
105        self.tracking
106    }
107
108    /// Time scale of the return once the finger lifts.
109    pub fn response(&self) -> Duration {
110        self.response
111    }
112
113    /// The farthest the edge goes past its end, or `None` when only the
114    /// viewport height bounds it.
115    pub fn max_stretch(&self) -> Option<f32> {
116        self.max_stretch
117    }
118
119    /// Undamped angular frequency of the return, or `None` when it snaps.
120    fn omega(&self) -> Option<f32> {
121        let seconds = self.response.as_secs_f32();
122        (seconds > 0.).then(|| std::f32::consts::TAU / seconds)
123    }
124}
125
126/// Adds vertical touch overscroll to an existing scroll viewport.
127///
128/// The child owns layout, content, and ordinary scrolling; `handle` must be the
129/// child's scroll handle. Only unused vertical deltas stretch the viewport.
130/// The stable `id` owns gesture and spring state. Change it when replacing the
131/// document. Put fixed chrome (scrollbars, toolbars) outside this wrapper.
132///
133/// Enabled by default where the platform's own scroll views bounce: iOS,
134/// Android and macOS. Reduced motion disables displacement. Keyboard and
135/// focus remain owned by the child. No colors, padding, or dimensions are
136/// imposed.
137///
138/// Nested scroll areas follow the platform: the innermost one under the
139/// pointer takes the whole gesture, at its ends too, and bounces there
140/// rather than handing the rest to the area around it. A disabled
141/// `ScrollBounce` still scrolls its child and keeps the gesture from the
142/// areas around it; it only stops at the ends instead of stretching.
143pub struct ScrollBounce {
144    id: ElementId,
145    handle: Rc<dyn ScrollbarHandle>,
146    child: AnyElement,
147    enabled: bool,
148    scroll_wheel: bool,
149    motion: ScrollBounceMotion,
150    on_scroll: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
151}
152
153impl ScrollBounce {
154    pub fn new<H: ScrollbarHandle + Clone>(
155        id: impl Into<ElementId>,
156        handle: &H,
157        child: impl IntoElement,
158    ) -> Self {
159        Self {
160            id: id.into(),
161            handle: Rc::new(handle.clone()),
162            child: child.into_any_element(),
163            enabled: Self::enabled_by_default(),
164            scroll_wheel: true,
165            motion: ScrollBounceMotion::default(),
166            on_scroll: None,
167        }
168    }
169
170    /// Whether scroll areas bounce on this platform unless told otherwise:
171    /// on iOS, Android and macOS, whose own scroll views do.
172    pub const fn enabled_by_default() -> bool {
173        cfg!(any(
174            target_os = "ios",
175            target_os = "android",
176            target_os = "macos"
177        ))
178    }
179
180    /// Turn the bounce on or off. Defaults to [`Self::enabled_by_default`].
181    pub fn enabled(mut self, enabled: bool) -> Self {
182        self.enabled = enabled;
183        self
184    }
185
186    /// Own vertical scrolling using the shared input overscroll engine.
187    /// Precise pixel input can bounce; ordinary line-wheel input only scrolls
188    /// and cancels any existing displacement. Keep fixed chrome outside it.
189    ///
190    /// On by default. Turned off, the child scrolls itself and only its
191    /// unused deltas stretch the edge; that needs input that emits `Ended`
192    /// at finger release, before momentum, as touch does.
193    pub fn scroll_wheel(mut self, enabled: bool) -> Self {
194        self.scroll_wheel = enabled;
195        self
196    }
197
198    /// Set how far a drag stretches past an edge and how the edge returns.
199    pub fn motion(mut self, motion: ScrollBounceMotion) -> Self {
200        self.motion = motion;
201        self
202    }
203
204    /// Observe a logical scroll performed when a reverse drag leaves the stretched
205    /// region. Runs after the handle update, with no internal state borrowed.
206    /// Ordinary child scrolling continues to use the child's own notifications.
207    pub fn on_scroll(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
208        self.on_scroll = Some(Rc::new(handler));
209        self
210    }
211}
212
213#[derive(Default)]
214struct State {
215    physics: Physics,
216    overscroll: Overscroll,
217    sampled_at: Option<Instant>,
218    ongoing_scroll: OngoingScroll,
219    short_drag_distance: Option<f32>,
220    last_wheel_at: Option<Instant>,
221    /// Sign of the stream being suppressed, when it is known to be momentum
222    /// that cannot reverse. `None` suppresses both directions.
223    suppressed_direction: Option<f32>,
224}
225
226impl State {
227    /// Release the edge. A stretch made outside a gesture is momentum (or a
228    /// phaseless wheel) hitting the edge; that stream only pushes outward,
229    /// so an inward packet is a new scroll and ends the suppression. After a
230    /// gesture's own release the suppressed momentum may point either way.
231    fn release(&mut self, from_rest: bool) {
232        self.physics.release();
233        self.suppressed_direction =
234            (from_rest && self.physics.suppress_momentum).then(|| self.physics.offset().signum());
235    }
236}
237
238// GPUI starts a normal touch pan only after its 8 px touch slop, but a touch
239// catching a fling starts at zero displacement. A short catch should stop the
240// old fling rather than turn a few fast pixels into a new one.
241const CATCH_DRAG_SLOP: f32 = 8.;
242
243// Momentum arrives once per frame until it stops, so a longer silence means
244// the suppressed stream has ended. Smooth-scrolling mouse drivers on macOS
245// send precise deltas with no phase: they never send the `Started` that
246// otherwise ends suppression, and would stay locked after one bounce.
247const MOMENTUM_GAP: Duration = Duration::from_millis(250);
248
249/// Which scroll area under the pointer takes the current wheel event.
250///
251/// Capture runs from the outside in, so every area that could scroll writes
252/// itself here before its child paints, and the innermost one is the last
253/// to write. Each then acts, from the inside out, only if it is still the
254/// one written.
255#[derive(Default)]
256struct ScrollOwner(Option<HitboxId>);
257
258impl Global for ScrollOwner {}
259
260/// Take the current wheel event from the scroll areas around `hitbox`.
261/// Call it in the capture phase, from a listener registered before the
262/// children paint, whenever the area would scroll for the event.
263pub(crate) fn claim_scroll(hitbox: HitboxId, cx: &mut App) {
264    cx.default_global::<ScrollOwner>().0 = Some(hitbox);
265}
266
267fn owns_scroll(hitbox: HitboxId, cx: &App) -> bool {
268    cx.try_global::<ScrollOwner>()
269        .is_some_and(|owner| owner.0 == Some(hitbox))
270}
271
272/// `ScrollbarHandle` has no `max_offset`; recover it from the definition
273/// `content_size = viewport + max_offset`. Both dispatch phases clamp against
274/// this bound and must agree on it.
275fn max_scroll_extent(handle: &dyn ScrollbarHandle) -> Pixels {
276    (handle.content_size().height - handle.viewport_bounds().size.height).max(px(0.))
277}
278
279#[doc(hidden)]
280pub struct ScrollBouncePrepaintState {
281    state: Rc<RefCell<State>>,
282    hitbox: Hitbox,
283}
284
285impl IntoElement for ScrollBounce {
286    type Element = Self;
287    fn into_element(self) -> Self {
288        self
289    }
290}
291
292impl Element for ScrollBounce {
293    type RequestLayoutState = ();
294    type PrepaintState = ScrollBouncePrepaintState;
295
296    fn id(&self) -> Option<ElementId> {
297        Some(self.id.clone())
298    }
299    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
300        None
301    }
302
303    fn request_layout(
304        &mut self,
305        _: Option<&GlobalElementId>,
306        _: Option<&InspectorElementId>,
307        window: &mut Window,
308        cx: &mut App,
309    ) -> (LayoutId, ()) {
310        (self.child.request_layout(window, cx), ())
311    }
312
313    fn prepaint(
314        &mut self,
315        id: Option<&GlobalElementId>,
316        _: Option<&InspectorElementId>,
317        bounds: Bounds<Pixels>,
318        _: &mut (),
319        window: &mut Window,
320        cx: &mut App,
321    ) -> Self::PrepaintState {
322        let state = window.with_element_state(
323            id.expect("ScrollBounce has an id"),
324            |state: Option<Rc<RefCell<State>>>, _| {
325                let state = state.unwrap_or_default();
326                (state.clone(), state)
327            },
328        );
329        let offset = {
330            let mut state = state.borrow_mut();
331            if !self.enabled || cx.reduce_motion() {
332                *state = State::default();
333            }
334            if self.scroll_wheel {
335                state.overscroll.set_motion(self.motion);
336                if max_scroll_extent(self.handle.as_ref()) <= px(0.) {
337                    state.overscroll.reset();
338                }
339                if state.overscroll.step(cx.background_executor().now()) {
340                    window.request_animation_frame();
341                }
342                state.overscroll.offset()
343            } else {
344                state.physics.motion = self.motion;
345                let now = Instant::now();
346                let elapsed = state
347                    .sampled_at
348                    .replace(now)
349                    .map_or(0., |at| now.duration_since(at).as_secs_f32());
350                if state.physics.step(elapsed) {
351                    window.request_animation_frame();
352                }
353                state.physics.offset()
354            }
355        };
356        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
357        window.with_content_mask(Some(ContentMask { bounds }), |window| {
358            window.with_element_offset(point(px(0.), px(offset)), |window| {
359                self.child.prepaint(window, cx);
360            });
361        });
362        // The child's fresh metrics can remove overflow during a drag.
363        // Reset against those metrics as well, and repaint any displaced frame.
364        if self.scroll_wheel && max_scroll_extent(self.handle.as_ref()) <= px(0.) {
365            let mut state = state.borrow_mut();
366            let displaced = state.overscroll.offset() != 0.;
367            state.overscroll.reset();
368            if displaced {
369                window.request_animation_frame();
370            }
371        }
372        ScrollBouncePrepaintState { state, hitbox }
373    }
374
375    fn paint(
376        &mut self,
377        _: Option<&GlobalElementId>,
378        _: Option<&InspectorElementId>,
379        bounds: Bounds<Pixels>,
380        _: &mut (),
381        prepaint: &mut Self::PrepaintState,
382        window: &mut Window,
383        cx: &mut App,
384    ) {
385        if self.scroll_wheel {
386            let hitbox = prepaint.hitbox.id;
387            let handle = self.handle.clone();
388            window.on_mouse_event(move |_: &ScrollEvent, phase, window, cx| {
389                if phase == DispatchPhase::Capture
390                    && hitbox.should_handle_scroll(window)
391                    && max_scroll_extent(handle.as_ref()) > px(0.)
392                {
393                    claim_scroll(hitbox, cx);
394                }
395            });
396        }
397        if self.enabled && !self.scroll_wheel && !cx.reduce_motion() {
398            let state = prepaint.state.clone();
399            let hitbox = prepaint.hitbox.id;
400            let handle = self.handle.clone();
401            let view = window.current_view();
402            let on_scroll = self.on_scroll.clone();
403            let mut before = 0.;
404            let mut allow_end_bounce = false;
405            window.on_mouse_event(move |event: &ScrollEvent, phase, window, cx| {
406                let ScrollDelta::Pixels(mut delta) = event.delta else {
407                    if phase == DispatchPhase::Capture && hitbox.should_handle_scroll(window) {
408                        let mut state = state.borrow_mut();
409                        let displaced = state.physics.offset() != 0.;
410                        *state = State::default();
411                        drop(state);
412                        if displaced {
413                            cx.notify(view);
414                        }
415                    }
416                    return;
417                };
418                if !hitbox.should_handle_scroll(window) {
419                    return;
420                }
421                let mut state = state.borrow_mut();
422                // Lock the gesture to the axis it started on, so a diagonal
423                // swipe cannot wobble out of the stretch from one packet to
424                // the next. Both dispatch phases see the same packet, and the
425                // lock gives both the same answer.
426                state
427                    .ongoing_scroll
428                    .lock_axis(&mut delta, event.touch_phase);
429                if delta.x.abs() > delta.y.abs() {
430                    return;
431                }
432                let ended = matches!(event.touch_phase, TouchPhase::Ended | TouchPhase::Cancelled);
433                let mut scrolled = false;
434                let mut changed = false;
435                if phase == DispatchPhase::Capture {
436                    before = handle.offset().y.as_f32();
437                    if event.touch_phase == TouchPhase::Started {
438                        state.short_drag_distance = (delta.y == px(0.)).then_some(0.);
439                        state.physics.begin(bounds.size.height.as_f32());
440                    } else if let Some(distance) = state.short_drag_distance.as_mut() {
441                        *distance += delta.y.as_f32().abs();
442                        if *distance > CATCH_DRAG_SLOP {
443                            state.short_drag_distance = None;
444                        }
445                    }
446                    let suppress_short_drag_momentum = if ended {
447                        state.short_drag_distance.take().is_some()
448                            && event.touch_phase == TouchPhase::Ended
449                    } else {
450                        false
451                    };
452                    // The current Ended packet may still cross an edge; only
453                    // momentum packets after it should be suppressed.
454                    allow_end_bounce = suppress_short_drag_momentum;
455                    let now = cx.background_executor().now();
456                    let paused = state
457                        .last_wheel_at
458                        .replace(now)
459                        .is_some_and(|at| now.saturating_duration_since(at) >= MOMENTUM_GAP);
460                    let reversed = state.suppressed_direction.is_some_and(|direction| {
461                        delta.y != px(0.) && delta.y.as_f32().signum() != direction
462                    });
463                    if paused || reversed {
464                        state.physics.suppress_momentum = false;
465                    }
466                    if state.physics.suppress_momentum {
467                        cx.stop_propagation();
468                        return;
469                    }
470                    if state.physics.offset() != 0. {
471                        // Outside a gesture (a phaseless wheel once suppression
472                        // lifts) a packet grabs the returning edge and lets it
473                        // go again, as it would at rest.
474                        let from_rest = !state.physics.dragging;
475                        if from_rest {
476                            state.physics.begin(bounds.size.height.as_f32());
477                        }
478                        let remainder = state.physics.pull(delta.y.as_f32());
479                        if remainder != 0. {
480                            let max = max_scroll_extent(handle.as_ref());
481                            let mut offset = handle.offset();
482                            offset.y = px(before + remainder).clamp(-max, px(0.));
483                            handle.set_offset(offset);
484                            scrolled = true;
485                        }
486                        if ended || from_rest {
487                            state.release(from_rest);
488                        }
489                        changed = true;
490                        cx.stop_propagation();
491                    } else if ended {
492                        state.physics.release();
493                    }
494                    if suppress_short_drag_momentum {
495                        state.physics.suppress_momentum = true;
496                        state.suppressed_direction = None;
497                    }
498                } else {
499                    // Div applies deltas immediately but clamps during its next
500                    // prepaint. Clamp here so that boundary deltas are not
501                    // mistaken for consumed scrolling (ListState clamps eagerly).
502                    let mut offset = handle.offset();
503                    let max = max_scroll_extent(handle.as_ref());
504                    let clamped = offset.y.clamp(-max, px(0.));
505                    if clamped != offset.y {
506                        offset.y = clamped;
507                        handle.set_offset(offset);
508                    }
509                    let after = offset.y.as_f32();
510                    let requested = delta.y.as_f32();
511                    // A List can coalesce several packets against one painted
512                    // scroll position. Their offset difference alone does not
513                    // prove overscroll, especially after direction changes or
514                    // a zero-delta Ended packet from a trackpad.
515                    let at_outward_edge = (requested > 0. && offset.y == px(0.))
516                        || (requested < 0. && offset.y == -max);
517                    let residual =
518                        (requested - (after - before)).clamp(requested.min(0.), requested.max(0.));
519                    if at_outward_edge
520                        && residual.abs() > 0.01
521                        && (!state.physics.suppress_momentum || allow_end_bounce)
522                    {
523                        let dragging = state.physics.dragging;
524                        if !dragging {
525                            state.physics.begin(bounds.size.height.as_f32());
526                        }
527                        state.physics.pull(residual);
528                        if !dragging || ended {
529                            state.release(!dragging);
530                        }
531                        changed = true;
532                    }
533                }
534                if changed {
535                    state.sampled_at = Some(Instant::now());
536                }
537                drop(state);
538                if changed {
539                    cx.notify(view);
540                }
541                if scrolled && let Some(handler) = &on_scroll {
542                    handler(window, cx);
543                }
544            });
545        }
546        window.with_content_mask(Some(ContentMask { bounds }), |window| {
547            self.child.paint(window, cx)
548        });
549        // After the children, so that the areas inside act first.
550        if self.scroll_wheel {
551            let bounces = self.enabled && !cx.reduce_motion();
552            let state = prepaint.state.clone();
553            let hitbox = prepaint.hitbox.id;
554            let handle = self.handle.clone();
555            let view = window.current_view();
556            let on_scroll = self.on_scroll.clone();
557            window.on_mouse_event(move |event: &ScrollEvent, phase, window, cx| {
558                if phase != DispatchPhase::Capture
559                    || !hitbox.should_handle_scroll(window)
560                    || !owns_scroll(hitbox, cx)
561                {
562                    return;
563                }
564                let mut delta = event.delta.pixel_delta(window.line_height());
565                let mut state = state.borrow_mut();
566                state
567                    .ongoing_scroll
568                    .lock_axis(&mut delta, event.touch_phase);
569                let max = max_scroll_extent(handle.as_ref());
570                if max <= px(0.) {
571                    state.overscroll.reset();
572                    return;
573                }
574                if delta.x.abs() > delta.y.abs() {
575                    state
576                        .overscroll
577                        .touch_phase(event.touch_phase, cx.background_executor().now());
578                    return;
579                }
580                let before = handle.offset();
581                let changed = if !bounces || matches!(event.delta, ScrollDelta::Lines(_)) {
582                    let displaced = state.overscroll.offset() != 0.;
583                    state.overscroll.reset();
584                    let mut offset = before;
585                    offset.y = (before.y + delta.y).clamp(-max, px(0.));
586                    handle.set_offset(offset);
587                    displaced
588                } else {
589                    state.overscroll.packet(
590                        delta.y.as_f32(),
591                        event.touch_phase,
592                        bounds.size.height.as_f32(),
593                        cx.background_executor().now(),
594                        |dy| {
595                            let before = handle.offset();
596                            let mut offset = before;
597                            let requested = before.y + px(dy);
598                            offset.y = requested.clamp(-max, px(0.));
599                            handle.set_offset(offset);
600                            // Only clamping leaves unused input; subtraction
601                            // can leave a rounding residue inside the list.
602                            if offset.y == requested {
603                                dy
604                            } else {
605                                (offset.y - before.y).as_f32()
606                            }
607                        },
608                    )
609                };
610                let scrolled = handle.offset() != before;
611                drop(state);
612                cx.stop_propagation();
613                if changed || scrolled {
614                    cx.notify(view);
615                }
616                if scrolled && let Some(handler) = &on_scroll {
617                    handler(window, cx);
618                }
619            });
620        }
621    }
622}
623
624/// The largest outward speed, at most `speed`, that lets an edge at
625/// `position` go no farther out than `max`.
626///
627/// The edge then follows `(position + speed * t) * e^(-ωt)`, which peaks at
628/// `speed / ω * e^(ω * position / speed - 1)` when the speed is enough to
629/// carry it outward at all.
630fn limit_throw(position: f32, speed: f32, omega: f32, max: f32) -> f32 {
631    let peak = |speed: f32| {
632        if speed <= omega * position {
633            position
634        } else {
635            speed / omega * (omega * position / speed - 1.).exp()
636        }
637    };
638    if peak(speed) <= max {
639        return speed;
640    }
641    let (mut low, mut high) = (0., speed);
642    for _ in 0..24 {
643        let mid = (low + high) / 2.;
644        if peak(mid) <= max {
645            low = mid;
646        } else {
647            high = mid;
648        }
649    }
650    low
651}
652
653/// The stretch past an edge and its return, shared with the input's own
654/// scrolling.
655#[derive(Default)]
656pub(crate) struct Physics {
657    position: f32,
658    velocity: f32,
659    dragging: bool,
660    suppress_momentum: bool,
661    extent: f32,
662    motion: ScrollBounceMotion,
663}
664
665impl Physics {
666    pub(crate) fn set_motion(&mut self, motion: ScrollBounceMotion) {
667        self.motion = motion;
668    }
669
670    pub(crate) fn reset(&mut self) {
671        *self = Self {
672            motion: self.motion,
673            ..Self::default()
674        };
675    }
676    pub(crate) fn is_dragging(&self) -> bool {
677        self.dragging
678    }
679
680    /// Whether a released edge is still on its way.
681    pub(crate) fn is_moving(&self) -> bool {
682        !self.dragging && (self.position != 0. || self.velocity != 0.)
683    }
684
685    pub(crate) fn offset(&self) -> f32 {
686        if self.dragging {
687            let d = self.extent.max(1.);
688            let tracking = self.motion.tracking;
689            let stretch = self.position * tracking / (1. + tracking * self.position.abs() / d);
690            match self.motion.max_stretch {
691                // Follows the stretch closely at first, then eases into the limit.
692                Some(max) => max * (stretch / max).tanh(),
693                None => stretch,
694            }
695        } else {
696            self.position
697        }
698    }
699
700    pub(crate) fn begin(&mut self, extent: f32) {
701        let offset = self.offset();
702        // A displaced edge keeps the extent it was stretched under. The
703        // rubber-band curve saturates at the extent, so re-reading a viewport
704        // that shrank mid-return (rotation, keyboard) could not place the
705        // finger where the edge is: it would snap, then need a long pull back.
706        if offset == 0. || self.extent <= 0. {
707            self.extent = extent.max(1.);
708        }
709        // Invert the curves so grabbing a returning edge is continuous.
710        let stretch = match self.motion.max_stretch {
711            Some(max) => max * (offset / max).clamp(-0.999, 0.999).atanh(),
712            None => offset,
713        };
714        let tracking = self.motion.tracking;
715        self.position = stretch / (tracking * (1. - stretch.abs() / self.extent).max(0.01));
716        self.velocity = 0.;
717        self.dragging = true;
718        self.suppress_momentum = false;
719    }
720
721    /// Apply finger displacement. Return the part that crosses back into the list.
722    pub(crate) fn pull(&mut self, delta: f32) -> f32 {
723        let previous = self.position;
724        let next = previous + delta;
725        if previous != 0. && previous.signum() != next.signum() {
726            self.position = 0.;
727            next
728        } else {
729            self.position = next;
730            0.
731        }
732    }
733
734    pub(crate) fn release(&mut self) {
735        self.position = self.offset();
736        self.dragging = false;
737        if self.position != 0. {
738            self.suppress_momentum = true;
739        }
740    }
741
742    /// Let go of the edge the way AppKit does: from wherever it is, it
743    /// returns at full speed, and it carries `velocity` outward on the way.
744    /// Its path is `(x + velocity * t) * e^(-ωt)`, so an edge at rest is
745    /// thrown out and back, and an edge let go while still being pushed
746    /// returns a little later. It never crosses the boundary: velocity
747    /// pointing back inward is dropped.
748    pub(crate) fn let_go(&mut self, velocity: f32) {
749        self.position = self.offset();
750        self.dragging = false;
751        let Some(omega) = self.motion.omega() else {
752            self.position = 0.;
753            self.velocity = 0.;
754            return;
755        };
756        let mut outward = if self.position * velocity >= 0. {
757            velocity
758        } else {
759            0.
760        };
761        if let Some(max) = self.motion.max_stretch {
762            outward =
763                outward.signum() * limit_throw(self.position.abs(), outward.abs(), omega, max);
764        }
765        self.velocity = outward - omega * self.position;
766    }
767
768    /// Exact critically damped spring integration, independent of refresh rate.
769    pub(crate) fn step(&mut self, seconds: f32) -> bool {
770        if self.dragging || (self.position == 0. && self.velocity == 0.) {
771            return false;
772        }
773        let Some(omega) = self.motion.omega() else {
774            self.position = 0.;
775            self.velocity = 0.;
776            return false;
777        };
778        let decay = (-omega * seconds).exp();
779        let c = self.velocity + omega * self.position;
780        self.position = (self.position + c * seconds) * decay;
781        self.velocity = (self.velocity - omega * c * seconds) * decay;
782        if self.position.abs() < 0.1 && self.velocity.abs() < 1. {
783            self.position = 0.;
784            self.velocity = 0.;
785            false
786        } else {
787            true
788        }
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use gpui::{
796        Context, InteractiveElement as _, ParentElement as _, Render, ScrollHandle,
797        StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, div,
798    };
799
800    struct ScrollTest {
801        handle: ScrollHandle,
802        enabled: bool,
803    }
804
805    impl Render for ScrollTest {
806        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
807            div().p(px(20.)).child(
808                ScrollBounce::new(
809                    "bounce",
810                    &self.handle,
811                    div()
812                        .id("viewport")
813                        .w(px(200.))
814                        .h(px(200.))
815                        .overflow_y_scroll()
816                        .track_scroll(&self.handle)
817                        .child(div().h(px(600.)).w_full()),
818                )
819                .enabled(self.enabled)
820                .scroll_wheel(false),
821            )
822        }
823    }
824
825    fn draw(cx: &mut VisualTestContext) {
826        cx.update(|window, cx| window.draw(cx).clear(cx));
827    }
828
829    fn scroll(cx: &mut VisualTestContext, delta: f32, phase: TouchPhase) {
830        cx.simulate_event(ScrollEvent {
831            position: point(px(100.), px(100.)),
832            delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
833            touch_phase: phase,
834            ..Default::default()
835        });
836    }
837
838    struct WheelViewport(ScrollHandle, Pixels);
839
840    impl Render for WheelViewport {
841        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
842            ScrollBounce::new(
843                "wheel-bounce",
844                &self.0,
845                div()
846                    .id("wheel-viewport")
847                    .w(px(200.))
848                    .h(px(200.))
849                    .overflow_y_scroll()
850                    .track_scroll(&self.0)
851                    .child(div().h(self.1).w_full()),
852            )
853            .enabled(true)
854            .scroll_wheel(true)
855        }
856    }
857
858    #[gpui::test]
859    fn fractional_wheel_input_keeps_scrolling_deep_in_a_long_list(cx: &mut TestAppContext) {
860        for delta in [-0.09_f32, 0.09] {
861            let mut app = cx.new_app();
862            let handle = ScrollHandle::new();
863            let (_, cx) = app.add_window_view({
864                let handle = handle.clone();
865                move |_, _| WheelViewport(handle, px(2_000_000.))
866            });
867            draw(cx);
868            handle.set_offset(point(px(0.), px(-1_000_000.)));
869            draw(cx);
870            let origin = handle.bounds().origin;
871            scroll(cx, delta, TouchPhase::Started);
872            scroll(cx, delta.signum() * 20., TouchPhase::Moved);
873            scroll(cx, 0., TouchPhase::Ended);
874            cx.executor().advance_clock(Duration::from_millis(16));
875            draw(cx);
876            scroll(cx, delta, TouchPhase::Moved);
877            scroll(cx, 0., TouchPhase::Started);
878            scroll(cx, delta.signum() * 20., TouchPhase::Moved);
879            draw(cx);
880            // Here each 0.09px delta rounds to 0.0625px in the stored offset.
881            let expected = -1_000_000. + delta.signum() * 40.125;
882            assert!((handle.offset().y.as_f32() - expected).abs() < 0.001);
883            assert_eq!(handle.bounds().origin, origin);
884        }
885    }
886
887    #[gpui::test]
888    fn ordinary_mouse_wheel_keeps_the_shared_viewport_undisplaced(cx: &mut TestAppContext) {
889        let handle = ScrollHandle::new();
890        let (_, cx) = cx.add_window_view({
891            let handle = handle.clone();
892            move |_, _| WheelViewport(handle, px(600.))
893        });
894        draw(cx);
895        let origin = handle.bounds().origin;
896        cx.simulate_event(ScrollEvent {
897            position: point(px(100.), px(100.)),
898            delta: ScrollDelta::Lines(point(0., -100.)),
899            ..Default::default()
900        });
901        draw(cx);
902        cx.executor().advance_clock(Duration::from_millis(16));
903        draw(cx);
904        assert_eq!(handle.offset().y, -handle.max_offset().y);
905        assert_eq!(handle.bounds().origin, origin);
906    }
907
908    #[gpui::test]
909    fn wheel_scrolling_interrupts_the_shared_return(cx: &mut TestAppContext) {
910        let handle = ScrollHandle::new();
911        let (_, cx) = cx.add_window_view({
912            let handle = handle.clone();
913            move |_, _| WheelViewport(handle, px(600.))
914        });
915        draw(cx);
916        let wheel = |cx: &mut VisualTestContext, dy| {
917            cx.simulate_event(ScrollEvent {
918                position: point(px(100.), px(100.)),
919                delta: ScrollDelta::Lines(point(0., dy)),
920                ..Default::default()
921            });
922        };
923        scroll(cx, -1000., TouchPhase::Started);
924        scroll(cx, 0., TouchPhase::Ended);
925        draw(cx);
926        assert_eq!(handle.offset().y, -handle.max_offset().y);
927        cx.executor().advance_clock(Duration::from_millis(200));
928        draw(cx);
929        let end = handle.offset().y;
930        wheel(cx, 2.);
931        assert!(
932            handle.offset().y > end,
933            "the new wheel input scrolls before the next frame"
934        );
935        draw(cx);
936    }
937
938    /// A scroll area with a shorter one inside it, 100 px down.
939    struct Nested {
940        outer: ScrollHandle,
941        inner: ScrollHandle,
942        inner_enabled: bool,
943    }
944
945    impl Render for Nested {
946        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
947            ScrollBounce::new(
948                "outer",
949                &self.outer,
950                div()
951                    .id("outer-viewport")
952                    .w(px(200.))
953                    .h(px(300.))
954                    .overflow_y_scroll()
955                    .track_scroll(&self.outer)
956                    .child(div().h(px(100.)).w_full())
957                    .child(
958                        ScrollBounce::new(
959                            "inner",
960                            &self.inner,
961                            div()
962                                .id("inner-viewport")
963                                .w(px(200.))
964                                .h(px(100.))
965                                .overflow_y_scroll()
966                                .track_scroll(&self.inner)
967                                .child(div().h(px(400.)).w_full()),
968                        )
969                        .enabled(self.inner_enabled),
970                    )
971                    .child(div().h(px(600.)).w_full()),
972            )
973            .enabled(true)
974        }
975    }
976
977    fn nested(cx: &mut TestAppContext, inner_enabled: bool) -> (Nested, &mut VisualTestContext) {
978        let (outer, inner) = (ScrollHandle::new(), ScrollHandle::new());
979        let (_, cx) = cx.add_window_view({
980            let (outer, inner) = (outer.clone(), inner.clone());
981            move |_, _| Nested {
982                outer,
983                inner,
984                inner_enabled,
985            }
986        });
987        draw(cx);
988        (
989            Nested {
990                outer,
991                inner,
992                inner_enabled,
993            },
994            cx,
995        )
996    }
997
998    fn scroll_at(cx: &mut VisualTestContext, y: f32, delta: f32, phase: TouchPhase) {
999        cx.simulate_event(ScrollEvent {
1000            position: point(px(100.), px(y)),
1001            delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
1002            touch_phase: phase,
1003            ..Default::default()
1004        });
1005        draw(cx);
1006    }
1007
1008    #[gpui::test]
1009    fn the_innermost_area_takes_the_whole_gesture(cx: &mut TestAppContext) {
1010        let (areas, cx) = nested(cx, true);
1011        let inner_origin = areas.inner.bounds().origin.y;
1012        scroll_at(cx, 150., 0., TouchPhase::Started);
1013        scroll_at(cx, 150., -200., TouchPhase::Moved);
1014        assert_eq!(areas.inner.offset().y, px(-200.), "the inner area scrolls");
1015        assert_eq!(areas.outer.offset().y, px(0.), "the outer one does not");
1016        // At its end the inner area stretches instead of handing over.
1017        scroll_at(cx, 150., -200., TouchPhase::Moved);
1018        assert_eq!(areas.inner.offset().y, px(-300.));
1019        assert_eq!(areas.outer.offset().y, px(0.));
1020        assert!(areas.inner.bounds().origin.y < inner_origin);
1021        // Outside the inner area, the outer one scrolls.
1022        scroll_at(cx, 150., 0., TouchPhase::Ended);
1023        scroll_at(cx, 20., -50., TouchPhase::Moved);
1024        assert_eq!(areas.outer.offset().y, px(-50.));
1025    }
1026
1027    #[gpui::test]
1028    fn a_disabled_inner_area_still_keeps_its_scroll(cx: &mut TestAppContext) {
1029        let (areas, cx) = nested(cx, false);
1030        let inner_origin = areas.inner.bounds().origin.y;
1031        scroll_at(cx, 150., -50., TouchPhase::Moved);
1032        assert_eq!(areas.inner.offset().y, px(-50.), "the inner area scrolls");
1033        assert_eq!(areas.outer.offset().y, px(0.), "the outer one does not");
1034        // At its end it stops without stretching.
1035        scroll_at(cx, 150., -500., TouchPhase::Moved);
1036        assert_eq!(areas.inner.offset().y, px(-300.));
1037        assert_eq!(areas.inner.bounds().origin.y, inner_origin);
1038        assert_eq!(areas.outer.offset().y, px(0.));
1039    }
1040
1041    struct ListTest(gpui::ListState);
1042
1043    impl Render for ListTest {
1044        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1045            ScrollBounce::new(
1046                "bounce-list",
1047                &self.0,
1048                gpui::list(self.0.clone(), |_, _, _| {
1049                    div().h(px(40.)).into_any_element()
1050                })
1051                .w(px(200.))
1052                .h(px(200.)),
1053            )
1054            .enabled(true)
1055            .scroll_wheel(false)
1056        }
1057    }
1058
1059    #[gpui::test]
1060    fn list_reverse_drag_preserves_events_before_the_next_frame(cx: &mut TestAppContext) {
1061        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1062        let (_, cx) = cx.add_window_view({
1063            let handle = handle.clone();
1064            move |_, _| ListTest(handle)
1065        });
1066        draw(cx);
1067        scroll(cx, 100., TouchPhase::Started);
1068        scroll(cx, -140., TouchPhase::Moved);
1069        assert_eq!(handle.offset().y, px(-40.));
1070        scroll(cx, -10., TouchPhase::Moved);
1071        draw(cx);
1072        assert_eq!(handle.offset().y, px(-50.));
1073    }
1074
1075    #[gpui::test]
1076    fn trackpad_release_between_frames_does_not_bounce_in_the_middle(cx: &mut TestAppContext) {
1077        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1078        let (_, cx) = cx.add_window_view({
1079            let handle = handle.clone();
1080            move |_, _| ListTest(handle)
1081        });
1082        draw(cx);
1083        handle.set_offset(point(px(0.), px(-400.)));
1084        draw(cx);
1085        let origin = handle.viewport_bounds().origin.y;
1086        // Unlike simulate_event (which may draw after each event), dispatch
1087        // both packets within one update to exercise native input coalescing.
1088        cx.update(|window, cx| {
1089            for (delta, phase) in [(-30., TouchPhase::Started), (0., TouchPhase::Ended)] {
1090                window.dispatch_event(
1091                    gpui::PlatformInput::Scroll(ScrollEvent {
1092                        position: point(px(100.), px(100.)),
1093                        delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
1094                        touch_phase: phase,
1095                        ..Default::default()
1096                    }),
1097                    cx,
1098                );
1099            }
1100        });
1101        draw(cx);
1102        assert_eq!(handle.viewport_bounds().origin.y, origin);
1103        assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
1104    }
1105
1106    #[gpui::test]
1107    fn trackpad_direction_change_between_frames_does_not_bounce_in_the_middle(
1108        cx: &mut TestAppContext,
1109    ) {
1110        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1111        let (_, cx) = cx.add_window_view({
1112            let handle = handle.clone();
1113            move |_, _| ListTest(handle)
1114        });
1115        draw(cx);
1116        handle.set_offset(point(px(0.), px(-400.)));
1117        draw(cx);
1118        let origin = handle.viewport_bounds().origin.y;
1119        cx.update(|window, cx| {
1120            for (delta, phase) in [(-30., TouchPhase::Started), (5., TouchPhase::Moved)] {
1121                window.dispatch_event(
1122                    gpui::PlatformInput::Scroll(ScrollEvent {
1123                        position: point(px(100.), px(100.)),
1124                        delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
1125                        touch_phase: phase,
1126                        ..Default::default()
1127                    }),
1128                    cx,
1129                );
1130            }
1131        });
1132        draw(cx);
1133        assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
1134        assert_eq!(handle.viewport_bounds().origin.y, origin);
1135    }
1136
1137    #[gpui::test]
1138    fn list_stretches_only_the_distance_past_either_edge(cx: &mut TestAppContext) {
1139        for (start, delta, end) in [(-30., 50., 0.), (-970., -50., -1000.)] {
1140            let mut app = cx.new_app();
1141            let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1142            let (_, cx) = app.add_window_view({
1143                let handle = handle.clone();
1144                move |_, _| ListTest(handle)
1145            });
1146            draw(cx);
1147            handle.set_offset(point(px(0.), px(start)));
1148            draw(cx);
1149            let origin = handle.viewport_bounds().origin.y;
1150            scroll(cx, delta, TouchPhase::Started);
1151            draw(cx);
1152            assert_eq!(handle.offset().y, px(end));
1153            let stretch = (handle.viewport_bounds().origin.y - origin).as_f32();
1154            assert_eq!(stretch.signum(), delta.signum());
1155            // Of the 50 px input, 30 px is ordinary scrolling. Only the
1156            // remaining 20 px may be rubber-banded (resistance reduces it).
1157            assert!(stretch.abs() > 0. && stretch.abs() < 20.);
1158        }
1159    }
1160
1161    #[gpui::test]
1162    fn reverse_drag_consumes_stretch_before_scrolling_content(cx: &mut TestAppContext) {
1163        let handle = ScrollHandle::new();
1164        let (_, cx) = cx.add_window_view({
1165            let handle = handle.clone();
1166            move |_, _| ScrollTest {
1167                handle,
1168                enabled: true,
1169            }
1170        });
1171        draw(cx);
1172        let origin = handle.bounds().origin.y;
1173        assert_eq!(origin, px(20.));
1174        scroll(cx, 100., TouchPhase::Started);
1175        draw(cx);
1176        assert_eq!(handle.offset().y, px(0.));
1177        assert!(handle.bounds().origin.y > origin);
1178        scroll(cx, -140., TouchPhase::Moved);
1179        draw(cx);
1180        assert_eq!(handle.offset().y, px(-40.));
1181        assert_eq!(handle.bounds().origin.y, origin);
1182    }
1183
1184    #[gpui::test]
1185    fn touch_release_ignores_momentum_until_a_new_touch_takes_over(cx: &mut TestAppContext) {
1186        let handle = ScrollHandle::new();
1187        let (_, cx) = cx.add_window_view({
1188            let handle = handle.clone();
1189            move |_, _| ScrollTest {
1190                handle,
1191                enabled: true,
1192            }
1193        });
1194        draw(cx);
1195        let origin = handle.bounds().origin.y;
1196        scroll(cx, 100., TouchPhase::Started);
1197        draw(cx);
1198        assert!(handle.bounds().origin.y > origin);
1199        scroll(cx, 0., TouchPhase::Ended);
1200        draw(cx);
1201        let released = handle.bounds().origin.y;
1202        // The iOS backend emits Moved packets for momentum after finger-up.
1203        // Even a large inward packet must not move the logical list while
1204        // the returning edge owns this gesture.
1205        scroll(cx, -400., TouchPhase::Moved);
1206        draw(cx);
1207        assert_eq!(handle.offset().y, px(0.));
1208        assert!(handle.bounds().origin.y <= released);
1209        // A new finger-down must end suppression and take over immediately.
1210        scroll(cx, 0., TouchPhase::Started);
1211        scroll(cx, -250., TouchPhase::Moved);
1212        draw(cx);
1213        assert!(handle.offset().y < px(0.));
1214        assert_eq!(handle.bounds().origin.y, origin);
1215    }
1216
1217    #[gpui::test]
1218    fn phaseless_wheel_scrolls_back_right_after_bouncing(cx: &mut TestAppContext) {
1219        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1220        let (_, cx) = cx.add_window_view({
1221            let handle = handle.clone();
1222            move |_, _| ListTest(handle)
1223        });
1224        draw(cx);
1225        handle.set_offset(point(px(0.), px(-1000.)));
1226        draw(cx);
1227        let origin = handle.viewport_bounds().origin.y;
1228        // Smooth-scrolling mouse drivers on macOS: precise deltas, no phase.
1229        scroll(cx, -50., TouchPhase::Moved);
1230        draw(cx);
1231        assert!(handle.viewport_bounds().origin.y < origin);
1232        scroll(cx, 200., TouchPhase::Moved);
1233        draw(cx);
1234        assert!(handle.offset().y > px(-1000.));
1235        assert_eq!(handle.viewport_bounds().origin.y, origin);
1236    }
1237
1238    #[gpui::test]
1239    fn phaseless_wheel_bounces_again_only_after_a_pause(cx: &mut TestAppContext) {
1240        let handle = ScrollHandle::new();
1241        let (_, cx) = cx.add_window_view({
1242            let handle = handle.clone();
1243            move |_, _| ScrollTest {
1244                handle,
1245                enabled: true,
1246            }
1247        });
1248        draw(cx);
1249        scroll(cx, 50., TouchPhase::Moved);
1250        draw(cx);
1251        let bounced = handle.bounds().origin.y;
1252        // Momentum after an edge hit keeps pushing outward; it must not
1253        // stretch further.
1254        scroll(cx, 50., TouchPhase::Moved);
1255        draw(cx);
1256        assert!(handle.bounds().origin.y <= bounced);
1257        cx.executor().advance_clock(MOMENTUM_GAP);
1258        scroll(cx, 50., TouchPhase::Moved);
1259        draw(cx);
1260        assert!(handle.bounds().origin.y > bounced);
1261        assert_eq!(handle.offset().y, px(0.));
1262    }
1263
1264    #[gpui::test]
1265    fn tiny_drag_catching_momentum_does_not_start_a_reverse_fling(cx: &mut TestAppContext) {
1266        let handle = ScrollHandle::new();
1267        let (_, cx) = cx.add_window_view({
1268            let handle = handle.clone();
1269            move |_, _| ScrollTest {
1270                handle,
1271                enabled: true,
1272            }
1273        });
1274        draw(cx);
1275        handle.set_offset(point(px(0.), px(-200.)));
1276        draw(cx);
1277
1278        // GPUI ends the old momentum stream, then starts a drag at zero
1279        // displacement when a finger catches the moving content.
1280        scroll(cx, -60., TouchPhase::Started);
1281        scroll(cx, 0., TouchPhase::Ended);
1282        draw(cx);
1283        scroll(cx, -100., TouchPhase::Moved);
1284        draw(cx);
1285        scroll(cx, 0., TouchPhase::Ended);
1286        draw(cx);
1287        let before_catch = handle.offset().y;
1288        assert!(before_catch < px(0.));
1289        scroll(cx, 0., TouchPhase::Started);
1290        scroll(cx, 8., TouchPhase::Moved);
1291        scroll(cx, 0., TouchPhase::Ended);
1292        draw(cx);
1293        let stopped = handle.offset().y;
1294        assert_eq!(stopped, before_catch + px(8.));
1295
1296        // The recognizer can synthesize a large reverse momentum packet from
1297        // that 8 px movement. It must not move the logical viewport.
1298        scroll(cx, 100., TouchPhase::Moved);
1299        draw(cx);
1300        assert_eq!(handle.offset().y, stopped);
1301
1302        // A fresh gesture restores ordinary scrolling and momentum.
1303        scroll(cx, -20., TouchPhase::Started);
1304        scroll(cx, 0., TouchPhase::Ended);
1305        scroll(cx, -10., TouchPhase::Moved);
1306        draw(cx);
1307        assert_eq!(handle.offset().y, stopped - px(30.));
1308    }
1309
1310    #[gpui::test]
1311    fn deliberate_drag_after_catching_momentum_can_fling(cx: &mut TestAppContext) {
1312        let handle = ScrollHandle::new();
1313        let (_, cx) = cx.add_window_view({
1314            let handle = handle.clone();
1315            move |_, _| ScrollTest {
1316                handle,
1317                enabled: true,
1318            }
1319        });
1320        draw(cx);
1321        handle.set_offset(point(px(0.), px(-200.)));
1322        draw(cx);
1323
1324        scroll(cx, 0., TouchPhase::Started);
1325        scroll(cx, 24., TouchPhase::Moved);
1326        scroll(cx, 0., TouchPhase::Ended);
1327        draw(cx);
1328        assert_eq!(handle.offset().y, px(-176.));
1329        scroll(cx, 40., TouchPhase::Moved);
1330        draw(cx);
1331        assert_eq!(handle.offset().y, px(-136.));
1332    }
1333
1334    #[gpui::test]
1335    fn short_catch_release_still_stretches_past_the_edge(cx: &mut TestAppContext) {
1336        let handle = ScrollHandle::new();
1337        let (_, cx) = cx.add_window_view({
1338            let handle = handle.clone();
1339            move |_, _| ScrollTest {
1340                handle,
1341                enabled: true,
1342            }
1343        });
1344        draw(cx);
1345        handle.set_offset(point(px(0.), px(-4.)));
1346        draw(cx);
1347        let origin = handle.bounds().origin.y;
1348
1349        scroll(cx, 0., TouchPhase::Started);
1350        scroll(cx, 8., TouchPhase::Ended);
1351        draw(cx);
1352        assert_eq!(handle.offset().y, px(0.));
1353        assert!(handle.bounds().origin.y > origin);
1354    }
1355
1356    #[gpui::test]
1357    fn disabled_and_reduced_motion_leave_the_viewport_fixed(cx: &mut TestAppContext) {
1358        for enabled in [false, true] {
1359            let mut app = cx.new_app();
1360            if enabled {
1361                app.update(|cx| cx.set_reduce_motion(true));
1362            }
1363            let handle = ScrollHandle::new();
1364            let (_, cx) = app.add_window_view({
1365                let handle = handle.clone();
1366                move |_, _| ScrollTest { handle, enabled }
1367            });
1368            draw(cx);
1369            let origin = handle.bounds().origin.y;
1370            scroll(cx, 100., TouchPhase::Started);
1371            draw(cx);
1372            assert_eq!(handle.bounds().origin.y, origin);
1373            scroll(cx, -40., TouchPhase::Moved);
1374            draw(cx);
1375            assert_eq!(handle.offset().y, px(-40.));
1376        }
1377    }
1378
1379    #[test]
1380    fn resistance_and_reverse_preserve_unconsumed_distance() {
1381        let mut scroll = Physics::default();
1382        scroll.begin(600.);
1383        assert_eq!(scroll.pull(100.), 0.);
1384        assert!(scroll.offset() > 0. && scroll.offset() < 55.);
1385        assert_eq!(scroll.pull(-130.), -30.);
1386        assert_eq!(scroll.offset(), 0.);
1387    }
1388
1389    #[gpui::test]
1390    fn diagonal_wobble_stays_with_the_stretch(cx: &mut TestAppContext) {
1391        let handle = ScrollHandle::new();
1392        let (_, cx) = cx.add_window_view({
1393            let handle = handle.clone();
1394            move |_, _| ScrollTest {
1395                handle,
1396                enabled: true,
1397            }
1398        });
1399        draw(cx);
1400        let origin = handle.bounds().origin.y;
1401        scroll(cx, 100., TouchPhase::Started);
1402        draw(cx);
1403        let stretched = handle.bounds().origin.y;
1404        assert!(stretched > origin);
1405        // A trackpad swipe that started vertical wobbles horizontal-dominant
1406        // for a packet. Dispatch within one update so the packets stay within
1407        // the axis lock's gesture separation.
1408        cx.update(|window, cx| {
1409            for delta in [point(px(30.), px(-20.)), point(px(0.), px(-20.))] {
1410                window.dispatch_event(
1411                    gpui::PlatformInput::Scroll(ScrollEvent {
1412                        position: point(px(100.), px(100.)),
1413                        delta: ScrollDelta::Pixels(delta),
1414                        touch_phase: TouchPhase::Moved,
1415                        ..Default::default()
1416                    }),
1417                    cx,
1418                );
1419            }
1420        });
1421        draw(cx);
1422        // Both packets shrink the stretch; neither scrolls the list.
1423        assert!(handle.bounds().origin.y < stretched);
1424        assert!(handle.bounds().origin.y > origin);
1425        assert_eq!(handle.offset(), point(px(0.), px(0.)));
1426    }
1427
1428    #[test]
1429    fn motion_builder_configures_tracking_and_response() {
1430        let motion = ScrollBounceMotion::default()
1431            .with_tracking(0.4)
1432            .with_response(Duration::from_millis(300))
1433            .with_max_stretch(24.);
1434        assert_eq!(motion.tracking(), 0.4);
1435        assert_eq!(motion.response(), Duration::from_millis(300));
1436        assert_eq!(motion.max_stretch(), Some(24.));
1437    }
1438
1439    #[test]
1440    fn tracking_scales_the_first_stretch() {
1441        let stretch = |tracking: f32| {
1442            let mut scroll = Physics {
1443                motion: ScrollBounceMotion::default().with_tracking(tracking),
1444                ..Physics::default()
1445            };
1446            scroll.begin(600.);
1447            scroll.pull(100.);
1448            scroll.offset()
1449        };
1450        assert!(stretch(0.3) < stretch(0.55));
1451        assert!(stretch(0.55) < stretch(0.8));
1452    }
1453
1454    #[test]
1455    fn response_scales_the_return_and_zero_snaps() {
1456        let remaining = |response: Duration| {
1457            let mut scroll = Physics {
1458                motion: ScrollBounceMotion::default().with_response(response),
1459                ..Physics::default()
1460            };
1461            scroll.begin(600.);
1462            scroll.pull(180.);
1463            scroll.release();
1464            scroll.step(0.25);
1465            scroll.offset()
1466        };
1467        assert!(remaining(Duration::from_secs(1)) > remaining(Duration::from_millis(524)));
1468        assert!(remaining(Duration::from_millis(524)) > remaining(Duration::from_millis(200)));
1469        assert_eq!(remaining(Duration::ZERO), 0.);
1470    }
1471
1472    #[test]
1473    fn regrabbing_a_displaced_edge_keeps_its_extent() {
1474        let mut scroll = Physics::default();
1475        scroll.begin(600.);
1476        scroll.pull(-150.);
1477        scroll.release();
1478        scroll.step(0.08);
1479        let before = scroll.offset();
1480        // The viewport shrank below the displacement while the edge was
1481        // returning. The finger still lands on the edge where it is.
1482        scroll.begin(40.);
1483        assert!((scroll.offset() - before).abs() < 0.001);
1484        // And a short pull inward moves the edge right away.
1485        scroll.pull(10.);
1486        assert!(scroll.offset() > before);
1487        assert!(scroll.offset() < before + 10.);
1488    }
1489
1490    #[test]
1491    fn a_gesture_from_rest_adopts_the_current_extent() {
1492        let mut scroll = Physics::default();
1493        scroll.begin(600.);
1494        scroll.begin(40.);
1495        scroll.pull(-100.);
1496        // The stretch saturates below the 40 px viewport, not the 600 px one.
1497        assert!(scroll.offset() > -40.);
1498    }
1499
1500    #[test]
1501    fn grabbing_the_spring_does_not_jump() {
1502        let mut scroll = Physics::default();
1503        scroll.begin(600.);
1504        scroll.pull(-150.);
1505        scroll.release();
1506        scroll.step(0.08);
1507        let before = scroll.offset();
1508        scroll.begin(600.);
1509        assert!((scroll.offset() - before).abs() < 0.001);
1510        let held = scroll.offset();
1511        assert!(!scroll.step(0.1));
1512        assert_eq!(scroll.offset(), held);
1513    }
1514
1515    #[test]
1516    fn spring_has_the_same_trajectory_at_60_and_120_hz() {
1517        let at = |hz: usize| {
1518            let mut scroll = Physics::default();
1519            scroll.begin(600.);
1520            scroll.pull(180.);
1521            scroll.release();
1522            for _ in 0..hz / 4 {
1523                scroll.step(1. / hz as f32);
1524            }
1525            scroll.offset()
1526        };
1527        assert!((at(60) - at(120)).abs() < 0.001);
1528    }
1529
1530    #[test]
1531    fn return_keeps_a_visible_tail_after_a_quarter_second() {
1532        let mut scroll = Physics {
1533            position: 100.,
1534            ..Physics::default()
1535        };
1536        assert!(scroll.step(0.25));
1537        // A 100 px release should still have a visible, decelerating tail
1538        // after 250 ms instead of snapping almost completely back by then.
1539        assert!(scroll.offset() > 10. && scroll.offset() < 30.);
1540        assert!(scroll.velocity < 0.);
1541    }
1542
1543    #[test]
1544    fn spring_settles_without_crossing_the_boundary() {
1545        let mut scroll = Physics::default();
1546        scroll.begin(600.);
1547        scroll.pull(-200.);
1548        scroll.release();
1549        let mut previous = scroll.offset();
1550        for _ in 0..120 {
1551            scroll.step(1. / 120.);
1552            assert!(scroll.offset() >= previous && scroll.offset() <= 0.);
1553            previous = scroll.offset();
1554        }
1555        assert_eq!(scroll.offset(), 0.);
1556        assert!(scroll.suppress_momentum);
1557        scroll.begin(600.);
1558        assert!(!scroll.suppress_momentum);
1559    }
1560}