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, ScrollWheelEvent, 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 |_: &ScrollWheelEvent, 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: &ScrollWheelEvent, 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: &ScrollWheelEvent, 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                    return;
576                }
577                let before = handle.offset();
578                let changed = if !bounces || matches!(event.delta, ScrollDelta::Lines(_)) {
579                    let displaced = state.overscroll.offset() != 0.;
580                    state.overscroll.reset();
581                    let mut offset = before;
582                    offset.y = (before.y + delta.y).clamp(-max, px(0.));
583                    handle.set_offset(offset);
584                    displaced
585                } else {
586                    state.overscroll.packet(
587                        delta.y.as_f32(),
588                        event.touch_phase,
589                        bounds.size.height.as_f32(),
590                        cx.background_executor().now(),
591                        |dy| {
592                            let before = handle.offset();
593                            let mut offset = before;
594                            offset.y = (before.y + px(dy)).clamp(-max, px(0.));
595                            handle.set_offset(offset);
596                            (offset.y - before.y).as_f32()
597                        },
598                    )
599                };
600                let scrolled = handle.offset() != before;
601                drop(state);
602                cx.stop_propagation();
603                if changed || scrolled {
604                    cx.notify(view);
605                }
606                if scrolled && let Some(handler) = &on_scroll {
607                    handler(window, cx);
608                }
609            });
610        }
611    }
612}
613
614/// The largest outward speed, at most `speed`, that lets an edge at
615/// `position` go no farther out than `max`.
616///
617/// The edge then follows `(position + speed * t) * e^(-ωt)`, which peaks at
618/// `speed / ω * e^(ω * position / speed - 1)` when the speed is enough to
619/// carry it outward at all.
620fn limit_throw(position: f32, speed: f32, omega: f32, max: f32) -> f32 {
621    let peak = |speed: f32| {
622        if speed <= omega * position {
623            position
624        } else {
625            speed / omega * (omega * position / speed - 1.).exp()
626        }
627    };
628    if peak(speed) <= max {
629        return speed;
630    }
631    let (mut low, mut high) = (0., speed);
632    for _ in 0..24 {
633        let mid = (low + high) / 2.;
634        if peak(mid) <= max {
635            low = mid;
636        } else {
637            high = mid;
638        }
639    }
640    low
641}
642
643/// The stretch past an edge and its return, shared with the input's own
644/// scrolling.
645#[derive(Default)]
646pub(crate) struct Physics {
647    position: f32,
648    velocity: f32,
649    dragging: bool,
650    suppress_momentum: bool,
651    extent: f32,
652    motion: ScrollBounceMotion,
653}
654
655impl Physics {
656    pub(crate) fn set_motion(&mut self, motion: ScrollBounceMotion) {
657        self.motion = motion;
658    }
659
660    pub(crate) fn reset(&mut self) {
661        *self = Self {
662            motion: self.motion,
663            ..Self::default()
664        };
665    }
666    pub(crate) fn is_dragging(&self) -> bool {
667        self.dragging
668    }
669
670    /// Whether a released edge is still on its way.
671    pub(crate) fn is_moving(&self) -> bool {
672        !self.dragging && (self.position != 0. || self.velocity != 0.)
673    }
674
675    pub(crate) fn offset(&self) -> f32 {
676        if self.dragging {
677            let d = self.extent.max(1.);
678            let tracking = self.motion.tracking;
679            let stretch = self.position * tracking / (1. + tracking * self.position.abs() / d);
680            match self.motion.max_stretch {
681                // Follows the stretch closely at first, then eases into the limit.
682                Some(max) => max * (stretch / max).tanh(),
683                None => stretch,
684            }
685        } else {
686            self.position
687        }
688    }
689
690    pub(crate) fn begin(&mut self, extent: f32) {
691        let offset = self.offset();
692        // A displaced edge keeps the extent it was stretched under. The
693        // rubber-band curve saturates at the extent, so re-reading a viewport
694        // that shrank mid-return (rotation, keyboard) could not place the
695        // finger where the edge is: it would snap, then need a long pull back.
696        if offset == 0. || self.extent <= 0. {
697            self.extent = extent.max(1.);
698        }
699        // Invert the curves so grabbing a returning edge is continuous.
700        let stretch = match self.motion.max_stretch {
701            Some(max) => max * (offset / max).clamp(-0.999, 0.999).atanh(),
702            None => offset,
703        };
704        let tracking = self.motion.tracking;
705        self.position = stretch / (tracking * (1. - stretch.abs() / self.extent).max(0.01));
706        self.velocity = 0.;
707        self.dragging = true;
708        self.suppress_momentum = false;
709    }
710
711    /// Apply finger displacement. Return the part that crosses back into the list.
712    pub(crate) fn pull(&mut self, delta: f32) -> f32 {
713        let previous = self.position;
714        let next = previous + delta;
715        if previous != 0. && previous.signum() != next.signum() {
716            self.position = 0.;
717            next
718        } else {
719            self.position = next;
720            0.
721        }
722    }
723
724    pub(crate) fn release(&mut self) {
725        self.position = self.offset();
726        self.dragging = false;
727        if self.position != 0. {
728            self.suppress_momentum = true;
729        }
730    }
731
732    /// Let go of the edge the way AppKit does: from wherever it is, it
733    /// returns at full speed, and it carries `velocity` outward on the way.
734    /// Its path is `(x + velocity * t) * e^(-ωt)`, so an edge at rest is
735    /// thrown out and back, and an edge let go while still being pushed
736    /// returns a little later. It never crosses the boundary: velocity
737    /// pointing back inward is dropped.
738    pub(crate) fn let_go(&mut self, velocity: f32) {
739        self.position = self.offset();
740        self.dragging = false;
741        let Some(omega) = self.motion.omega() else {
742            self.position = 0.;
743            self.velocity = 0.;
744            return;
745        };
746        let mut outward = if self.position * velocity >= 0. {
747            velocity
748        } else {
749            0.
750        };
751        if let Some(max) = self.motion.max_stretch {
752            outward =
753                outward.signum() * limit_throw(self.position.abs(), outward.abs(), omega, max);
754        }
755        self.velocity = outward - omega * self.position;
756    }
757
758    /// Exact critically damped spring integration, independent of refresh rate.
759    pub(crate) fn step(&mut self, seconds: f32) -> bool {
760        if self.dragging || (self.position == 0. && self.velocity == 0.) {
761            return false;
762        }
763        let Some(omega) = self.motion.omega() else {
764            self.position = 0.;
765            self.velocity = 0.;
766            return false;
767        };
768        let decay = (-omega * seconds).exp();
769        let c = self.velocity + omega * self.position;
770        self.position = (self.position + c * seconds) * decay;
771        self.velocity = (self.velocity - omega * c * seconds) * decay;
772        if self.position.abs() < 0.1 && self.velocity.abs() < 1. {
773            self.position = 0.;
774            self.velocity = 0.;
775            false
776        } else {
777            true
778        }
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use gpui::{
786        Context, InteractiveElement as _, ParentElement as _, Render, ScrollHandle,
787        StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, div,
788    };
789
790    struct ScrollTest {
791        handle: ScrollHandle,
792        enabled: bool,
793    }
794
795    impl Render for ScrollTest {
796        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
797            div().p(px(20.)).child(
798                ScrollBounce::new(
799                    "bounce",
800                    &self.handle,
801                    div()
802                        .id("viewport")
803                        .w(px(200.))
804                        .h(px(200.))
805                        .overflow_y_scroll()
806                        .track_scroll(&self.handle)
807                        .child(div().h(px(600.)).w_full()),
808                )
809                .enabled(self.enabled)
810                .scroll_wheel(false),
811            )
812        }
813    }
814
815    fn draw(cx: &mut VisualTestContext) {
816        cx.update(|window, cx| window.draw(cx).clear(cx));
817    }
818
819    fn scroll(cx: &mut VisualTestContext, delta: f32, phase: TouchPhase) {
820        cx.simulate_event(ScrollWheelEvent {
821            position: point(px(100.), px(100.)),
822            delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
823            touch_phase: phase,
824            ..Default::default()
825        });
826    }
827
828    struct WheelViewport(ScrollHandle);
829
830    impl Render for WheelViewport {
831        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
832            ScrollBounce::new(
833                "wheel-bounce",
834                &self.0,
835                div()
836                    .id("wheel-viewport")
837                    .w(px(200.))
838                    .h(px(200.))
839                    .overflow_y_scroll()
840                    .track_scroll(&self.0)
841                    .child(div().h(px(600.)).w_full()),
842            )
843            .enabled(true)
844            .scroll_wheel(true)
845        }
846    }
847
848    #[gpui::test]
849    fn ordinary_mouse_wheel_keeps_the_shared_viewport_undisplaced(cx: &mut TestAppContext) {
850        let handle = ScrollHandle::new();
851        let (_, cx) = cx.add_window_view({
852            let handle = handle.clone();
853            move |_, _| WheelViewport(handle)
854        });
855        draw(cx);
856        let origin = handle.bounds().origin;
857        cx.simulate_event(ScrollWheelEvent {
858            position: point(px(100.), px(100.)),
859            delta: ScrollDelta::Lines(point(0., -100.)),
860            ..Default::default()
861        });
862        draw(cx);
863        cx.executor().advance_clock(Duration::from_millis(16));
864        draw(cx);
865        assert_eq!(handle.offset().y, -handle.max_offset().y);
866        assert_eq!(handle.bounds().origin, origin);
867    }
868
869    #[gpui::test]
870    fn wheel_scrolling_interrupts_the_shared_return(cx: &mut TestAppContext) {
871        let handle = ScrollHandle::new();
872        let (_, cx) = cx.add_window_view({
873            let handle = handle.clone();
874            move |_, _| WheelViewport(handle)
875        });
876        draw(cx);
877        let wheel = |cx: &mut VisualTestContext, dy| {
878            cx.simulate_event(ScrollWheelEvent {
879                position: point(px(100.), px(100.)),
880                delta: ScrollDelta::Lines(point(0., dy)),
881                ..Default::default()
882            });
883        };
884        scroll(cx, -1000., TouchPhase::Started);
885        scroll(cx, 0., TouchPhase::Ended);
886        draw(cx);
887        assert_eq!(handle.offset().y, -handle.max_offset().y);
888        cx.executor().advance_clock(Duration::from_millis(200));
889        draw(cx);
890        let end = handle.offset().y;
891        wheel(cx, 2.);
892        assert!(
893            handle.offset().y > end,
894            "the new wheel input scrolls before the next frame"
895        );
896        draw(cx);
897    }
898
899    /// A scroll area with a shorter one inside it, 100 px down.
900    struct Nested {
901        outer: ScrollHandle,
902        inner: ScrollHandle,
903        inner_enabled: bool,
904    }
905
906    impl Render for Nested {
907        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
908            ScrollBounce::new(
909                "outer",
910                &self.outer,
911                div()
912                    .id("outer-viewport")
913                    .w(px(200.))
914                    .h(px(300.))
915                    .overflow_y_scroll()
916                    .track_scroll(&self.outer)
917                    .child(div().h(px(100.)).w_full())
918                    .child(
919                        ScrollBounce::new(
920                            "inner",
921                            &self.inner,
922                            div()
923                                .id("inner-viewport")
924                                .w(px(200.))
925                                .h(px(100.))
926                                .overflow_y_scroll()
927                                .track_scroll(&self.inner)
928                                .child(div().h(px(400.)).w_full()),
929                        )
930                        .enabled(self.inner_enabled),
931                    )
932                    .child(div().h(px(600.)).w_full()),
933            )
934            .enabled(true)
935        }
936    }
937
938    fn nested(cx: &mut TestAppContext, inner_enabled: bool) -> (Nested, &mut VisualTestContext) {
939        let (outer, inner) = (ScrollHandle::new(), ScrollHandle::new());
940        let (_, cx) = cx.add_window_view({
941            let (outer, inner) = (outer.clone(), inner.clone());
942            move |_, _| Nested {
943                outer,
944                inner,
945                inner_enabled,
946            }
947        });
948        draw(cx);
949        (
950            Nested {
951                outer,
952                inner,
953                inner_enabled,
954            },
955            cx,
956        )
957    }
958
959    fn scroll_at(cx: &mut VisualTestContext, y: f32, delta: f32, phase: TouchPhase) {
960        cx.simulate_event(ScrollWheelEvent {
961            position: point(px(100.), px(y)),
962            delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
963            touch_phase: phase,
964            ..Default::default()
965        });
966        draw(cx);
967    }
968
969    #[gpui::test]
970    fn the_innermost_area_takes_the_whole_gesture(cx: &mut TestAppContext) {
971        let (areas, cx) = nested(cx, true);
972        let inner_origin = areas.inner.bounds().origin.y;
973        scroll_at(cx, 150., 0., TouchPhase::Started);
974        scroll_at(cx, 150., -200., TouchPhase::Moved);
975        assert_eq!(areas.inner.offset().y, px(-200.), "the inner area scrolls");
976        assert_eq!(areas.outer.offset().y, px(0.), "the outer one does not");
977        // At its end the inner area stretches instead of handing over.
978        scroll_at(cx, 150., -200., TouchPhase::Moved);
979        assert_eq!(areas.inner.offset().y, px(-300.));
980        assert_eq!(areas.outer.offset().y, px(0.));
981        assert!(areas.inner.bounds().origin.y < inner_origin);
982        // Outside the inner area, the outer one scrolls.
983        scroll_at(cx, 150., 0., TouchPhase::Ended);
984        scroll_at(cx, 20., -50., TouchPhase::Moved);
985        assert_eq!(areas.outer.offset().y, px(-50.));
986    }
987
988    #[gpui::test]
989    fn a_disabled_inner_area_still_keeps_its_scroll(cx: &mut TestAppContext) {
990        let (areas, cx) = nested(cx, false);
991        let inner_origin = areas.inner.bounds().origin.y;
992        scroll_at(cx, 150., -50., TouchPhase::Moved);
993        assert_eq!(areas.inner.offset().y, px(-50.), "the inner area scrolls");
994        assert_eq!(areas.outer.offset().y, px(0.), "the outer one does not");
995        // At its end it stops without stretching.
996        scroll_at(cx, 150., -500., TouchPhase::Moved);
997        assert_eq!(areas.inner.offset().y, px(-300.));
998        assert_eq!(areas.inner.bounds().origin.y, inner_origin);
999        assert_eq!(areas.outer.offset().y, px(0.));
1000    }
1001
1002    struct ListTest(gpui::ListState);
1003
1004    impl Render for ListTest {
1005        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1006            ScrollBounce::new(
1007                "bounce-list",
1008                &self.0,
1009                gpui::list(self.0.clone(), |_, _, _| {
1010                    div().h(px(40.)).into_any_element()
1011                })
1012                .w(px(200.))
1013                .h(px(200.)),
1014            )
1015            .enabled(true)
1016            .scroll_wheel(false)
1017        }
1018    }
1019
1020    #[gpui::test]
1021    fn list_reverse_drag_preserves_events_before_the_next_frame(cx: &mut TestAppContext) {
1022        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1023        let (_, cx) = cx.add_window_view({
1024            let handle = handle.clone();
1025            move |_, _| ListTest(handle)
1026        });
1027        draw(cx);
1028        scroll(cx, 100., TouchPhase::Started);
1029        scroll(cx, -140., TouchPhase::Moved);
1030        assert_eq!(handle.offset().y, px(-40.));
1031        scroll(cx, -10., TouchPhase::Moved);
1032        draw(cx);
1033        assert_eq!(handle.offset().y, px(-50.));
1034    }
1035
1036    #[gpui::test]
1037    fn trackpad_release_between_frames_does_not_bounce_in_the_middle(cx: &mut TestAppContext) {
1038        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1039        let (_, cx) = cx.add_window_view({
1040            let handle = handle.clone();
1041            move |_, _| ListTest(handle)
1042        });
1043        draw(cx);
1044        handle.set_offset(point(px(0.), px(-400.)));
1045        draw(cx);
1046        let origin = handle.viewport_bounds().origin.y;
1047        // Unlike simulate_event (which may draw after each event), dispatch
1048        // both packets within one update to exercise native input coalescing.
1049        cx.update(|window, cx| {
1050            for (delta, phase) in [(-30., TouchPhase::Started), (0., TouchPhase::Ended)] {
1051                window.dispatch_event(
1052                    gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
1053                        position: point(px(100.), px(100.)),
1054                        delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
1055                        touch_phase: phase,
1056                        ..Default::default()
1057                    }),
1058                    cx,
1059                );
1060            }
1061        });
1062        draw(cx);
1063        assert_eq!(handle.viewport_bounds().origin.y, origin);
1064        assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
1065    }
1066
1067    #[gpui::test]
1068    fn trackpad_direction_change_between_frames_does_not_bounce_in_the_middle(
1069        cx: &mut TestAppContext,
1070    ) {
1071        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1072        let (_, cx) = cx.add_window_view({
1073            let handle = handle.clone();
1074            move |_, _| ListTest(handle)
1075        });
1076        draw(cx);
1077        handle.set_offset(point(px(0.), px(-400.)));
1078        draw(cx);
1079        let origin = handle.viewport_bounds().origin.y;
1080        cx.update(|window, cx| {
1081            for (delta, phase) in [(-30., TouchPhase::Started), (5., TouchPhase::Moved)] {
1082                window.dispatch_event(
1083                    gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
1084                        position: point(px(100.), px(100.)),
1085                        delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
1086                        touch_phase: phase,
1087                        ..Default::default()
1088                    }),
1089                    cx,
1090                );
1091            }
1092        });
1093        draw(cx);
1094        assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
1095        assert_eq!(handle.viewport_bounds().origin.y, origin);
1096    }
1097
1098    #[gpui::test]
1099    fn list_stretches_only_the_distance_past_either_edge(cx: &mut TestAppContext) {
1100        for (start, delta, end) in [(-30., 50., 0.), (-970., -50., -1000.)] {
1101            let mut app = cx.new_app();
1102            let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1103            let (_, cx) = app.add_window_view({
1104                let handle = handle.clone();
1105                move |_, _| ListTest(handle)
1106            });
1107            draw(cx);
1108            handle.set_offset(point(px(0.), px(start)));
1109            draw(cx);
1110            let origin = handle.viewport_bounds().origin.y;
1111            scroll(cx, delta, TouchPhase::Started);
1112            draw(cx);
1113            assert_eq!(handle.offset().y, px(end));
1114            let stretch = (handle.viewport_bounds().origin.y - origin).as_f32();
1115            assert_eq!(stretch.signum(), delta.signum());
1116            // Of the 50 px input, 30 px is ordinary scrolling. Only the
1117            // remaining 20 px may be rubber-banded (resistance reduces it).
1118            assert!(stretch.abs() > 0. && stretch.abs() < 20.);
1119        }
1120    }
1121
1122    #[gpui::test]
1123    fn reverse_drag_consumes_stretch_before_scrolling_content(cx: &mut TestAppContext) {
1124        let handle = ScrollHandle::new();
1125        let (_, cx) = cx.add_window_view({
1126            let handle = handle.clone();
1127            move |_, _| ScrollTest {
1128                handle,
1129                enabled: true,
1130            }
1131        });
1132        draw(cx);
1133        let origin = handle.bounds().origin.y;
1134        assert_eq!(origin, px(20.));
1135        scroll(cx, 100., TouchPhase::Started);
1136        draw(cx);
1137        assert_eq!(handle.offset().y, px(0.));
1138        assert!(handle.bounds().origin.y > origin);
1139        scroll(cx, -140., TouchPhase::Moved);
1140        draw(cx);
1141        assert_eq!(handle.offset().y, px(-40.));
1142        assert_eq!(handle.bounds().origin.y, origin);
1143    }
1144
1145    #[gpui::test]
1146    fn touch_release_ignores_momentum_until_a_new_touch_takes_over(cx: &mut TestAppContext) {
1147        let handle = ScrollHandle::new();
1148        let (_, cx) = cx.add_window_view({
1149            let handle = handle.clone();
1150            move |_, _| ScrollTest {
1151                handle,
1152                enabled: true,
1153            }
1154        });
1155        draw(cx);
1156        let origin = handle.bounds().origin.y;
1157        scroll(cx, 100., TouchPhase::Started);
1158        draw(cx);
1159        assert!(handle.bounds().origin.y > origin);
1160        scroll(cx, 0., TouchPhase::Ended);
1161        draw(cx);
1162        let released = handle.bounds().origin.y;
1163        // The iOS backend emits Moved packets for momentum after finger-up.
1164        // Even a large inward packet must not move the logical list while
1165        // the returning edge owns this gesture.
1166        scroll(cx, -400., TouchPhase::Moved);
1167        draw(cx);
1168        assert_eq!(handle.offset().y, px(0.));
1169        assert!(handle.bounds().origin.y <= released);
1170        // A new finger-down must end suppression and take over immediately.
1171        scroll(cx, 0., TouchPhase::Started);
1172        scroll(cx, -250., TouchPhase::Moved);
1173        draw(cx);
1174        assert!(handle.offset().y < px(0.));
1175        assert_eq!(handle.bounds().origin.y, origin);
1176    }
1177
1178    #[gpui::test]
1179    fn phaseless_wheel_scrolls_back_right_after_bouncing(cx: &mut TestAppContext) {
1180        let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1181        let (_, cx) = cx.add_window_view({
1182            let handle = handle.clone();
1183            move |_, _| ListTest(handle)
1184        });
1185        draw(cx);
1186        handle.set_offset(point(px(0.), px(-1000.)));
1187        draw(cx);
1188        let origin = handle.viewport_bounds().origin.y;
1189        // Smooth-scrolling mouse drivers on macOS: precise deltas, no phase.
1190        scroll(cx, -50., TouchPhase::Moved);
1191        draw(cx);
1192        assert!(handle.viewport_bounds().origin.y < origin);
1193        scroll(cx, 200., TouchPhase::Moved);
1194        draw(cx);
1195        assert!(handle.offset().y > px(-1000.));
1196        assert_eq!(handle.viewport_bounds().origin.y, origin);
1197    }
1198
1199    #[gpui::test]
1200    fn phaseless_wheel_bounces_again_only_after_a_pause(cx: &mut TestAppContext) {
1201        let handle = ScrollHandle::new();
1202        let (_, cx) = cx.add_window_view({
1203            let handle = handle.clone();
1204            move |_, _| ScrollTest {
1205                handle,
1206                enabled: true,
1207            }
1208        });
1209        draw(cx);
1210        scroll(cx, 50., TouchPhase::Moved);
1211        draw(cx);
1212        let bounced = handle.bounds().origin.y;
1213        // Momentum after an edge hit keeps pushing outward; it must not
1214        // stretch further.
1215        scroll(cx, 50., TouchPhase::Moved);
1216        draw(cx);
1217        assert!(handle.bounds().origin.y <= bounced);
1218        cx.executor().advance_clock(MOMENTUM_GAP);
1219        scroll(cx, 50., TouchPhase::Moved);
1220        draw(cx);
1221        assert!(handle.bounds().origin.y > bounced);
1222        assert_eq!(handle.offset().y, px(0.));
1223    }
1224
1225    #[gpui::test]
1226    fn tiny_drag_catching_momentum_does_not_start_a_reverse_fling(cx: &mut TestAppContext) {
1227        let handle = ScrollHandle::new();
1228        let (_, cx) = cx.add_window_view({
1229            let handle = handle.clone();
1230            move |_, _| ScrollTest {
1231                handle,
1232                enabled: true,
1233            }
1234        });
1235        draw(cx);
1236        handle.set_offset(point(px(0.), px(-200.)));
1237        draw(cx);
1238
1239        // GPUI ends the old momentum stream, then starts a drag at zero
1240        // displacement when a finger catches the moving content.
1241        scroll(cx, -60., TouchPhase::Started);
1242        scroll(cx, 0., TouchPhase::Ended);
1243        draw(cx);
1244        scroll(cx, -100., TouchPhase::Moved);
1245        draw(cx);
1246        scroll(cx, 0., TouchPhase::Ended);
1247        draw(cx);
1248        let before_catch = handle.offset().y;
1249        assert!(before_catch < px(0.));
1250        scroll(cx, 0., TouchPhase::Started);
1251        scroll(cx, 8., TouchPhase::Moved);
1252        scroll(cx, 0., TouchPhase::Ended);
1253        draw(cx);
1254        let stopped = handle.offset().y;
1255        assert_eq!(stopped, before_catch + px(8.));
1256
1257        // The recognizer can synthesize a large reverse momentum packet from
1258        // that 8 px movement. It must not move the logical viewport.
1259        scroll(cx, 100., TouchPhase::Moved);
1260        draw(cx);
1261        assert_eq!(handle.offset().y, stopped);
1262
1263        // A fresh gesture restores ordinary scrolling and momentum.
1264        scroll(cx, -20., TouchPhase::Started);
1265        scroll(cx, 0., TouchPhase::Ended);
1266        scroll(cx, -10., TouchPhase::Moved);
1267        draw(cx);
1268        assert_eq!(handle.offset().y, stopped - px(30.));
1269    }
1270
1271    #[gpui::test]
1272    fn deliberate_drag_after_catching_momentum_can_fling(cx: &mut TestAppContext) {
1273        let handle = ScrollHandle::new();
1274        let (_, cx) = cx.add_window_view({
1275            let handle = handle.clone();
1276            move |_, _| ScrollTest {
1277                handle,
1278                enabled: true,
1279            }
1280        });
1281        draw(cx);
1282        handle.set_offset(point(px(0.), px(-200.)));
1283        draw(cx);
1284
1285        scroll(cx, 0., TouchPhase::Started);
1286        scroll(cx, 24., TouchPhase::Moved);
1287        scroll(cx, 0., TouchPhase::Ended);
1288        draw(cx);
1289        assert_eq!(handle.offset().y, px(-176.));
1290        scroll(cx, 40., TouchPhase::Moved);
1291        draw(cx);
1292        assert_eq!(handle.offset().y, px(-136.));
1293    }
1294
1295    #[gpui::test]
1296    fn short_catch_release_still_stretches_past_the_edge(cx: &mut TestAppContext) {
1297        let handle = ScrollHandle::new();
1298        let (_, cx) = cx.add_window_view({
1299            let handle = handle.clone();
1300            move |_, _| ScrollTest {
1301                handle,
1302                enabled: true,
1303            }
1304        });
1305        draw(cx);
1306        handle.set_offset(point(px(0.), px(-4.)));
1307        draw(cx);
1308        let origin = handle.bounds().origin.y;
1309
1310        scroll(cx, 0., TouchPhase::Started);
1311        scroll(cx, 8., TouchPhase::Ended);
1312        draw(cx);
1313        assert_eq!(handle.offset().y, px(0.));
1314        assert!(handle.bounds().origin.y > origin);
1315    }
1316
1317    #[gpui::test]
1318    fn disabled_and_reduced_motion_leave_the_viewport_fixed(cx: &mut TestAppContext) {
1319        for enabled in [false, true] {
1320            let mut app = cx.new_app();
1321            if enabled {
1322                app.update(|cx| cx.set_reduce_motion(true));
1323            }
1324            let handle = ScrollHandle::new();
1325            let (_, cx) = app.add_window_view({
1326                let handle = handle.clone();
1327                move |_, _| ScrollTest { handle, enabled }
1328            });
1329            draw(cx);
1330            let origin = handle.bounds().origin.y;
1331            scroll(cx, 100., TouchPhase::Started);
1332            draw(cx);
1333            assert_eq!(handle.bounds().origin.y, origin);
1334            scroll(cx, -40., TouchPhase::Moved);
1335            draw(cx);
1336            assert_eq!(handle.offset().y, px(-40.));
1337        }
1338    }
1339
1340    #[test]
1341    fn resistance_and_reverse_preserve_unconsumed_distance() {
1342        let mut scroll = Physics::default();
1343        scroll.begin(600.);
1344        assert_eq!(scroll.pull(100.), 0.);
1345        assert!(scroll.offset() > 0. && scroll.offset() < 55.);
1346        assert_eq!(scroll.pull(-130.), -30.);
1347        assert_eq!(scroll.offset(), 0.);
1348    }
1349
1350    #[gpui::test]
1351    fn diagonal_wobble_stays_with_the_stretch(cx: &mut TestAppContext) {
1352        let handle = ScrollHandle::new();
1353        let (_, cx) = cx.add_window_view({
1354            let handle = handle.clone();
1355            move |_, _| ScrollTest {
1356                handle,
1357                enabled: true,
1358            }
1359        });
1360        draw(cx);
1361        let origin = handle.bounds().origin.y;
1362        scroll(cx, 100., TouchPhase::Started);
1363        draw(cx);
1364        let stretched = handle.bounds().origin.y;
1365        assert!(stretched > origin);
1366        // A trackpad swipe that started vertical wobbles horizontal-dominant
1367        // for a packet. Dispatch within one update so the packets stay within
1368        // the axis lock's gesture separation.
1369        cx.update(|window, cx| {
1370            for delta in [point(px(30.), px(-20.)), point(px(0.), px(-20.))] {
1371                window.dispatch_event(
1372                    gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
1373                        position: point(px(100.), px(100.)),
1374                        delta: ScrollDelta::Pixels(delta),
1375                        touch_phase: TouchPhase::Moved,
1376                        ..Default::default()
1377                    }),
1378                    cx,
1379                );
1380            }
1381        });
1382        draw(cx);
1383        // Both packets shrink the stretch; neither scrolls the list.
1384        assert!(handle.bounds().origin.y < stretched);
1385        assert!(handle.bounds().origin.y > origin);
1386        assert_eq!(handle.offset(), point(px(0.), px(0.)));
1387    }
1388
1389    #[test]
1390    fn motion_builder_configures_tracking_and_response() {
1391        let motion = ScrollBounceMotion::default()
1392            .with_tracking(0.4)
1393            .with_response(Duration::from_millis(300))
1394            .with_max_stretch(24.);
1395        assert_eq!(motion.tracking(), 0.4);
1396        assert_eq!(motion.response(), Duration::from_millis(300));
1397        assert_eq!(motion.max_stretch(), Some(24.));
1398    }
1399
1400    #[test]
1401    fn tracking_scales_the_first_stretch() {
1402        let stretch = |tracking: f32| {
1403            let mut scroll = Physics {
1404                motion: ScrollBounceMotion::default().with_tracking(tracking),
1405                ..Physics::default()
1406            };
1407            scroll.begin(600.);
1408            scroll.pull(100.);
1409            scroll.offset()
1410        };
1411        assert!(stretch(0.3) < stretch(0.55));
1412        assert!(stretch(0.55) < stretch(0.8));
1413    }
1414
1415    #[test]
1416    fn response_scales_the_return_and_zero_snaps() {
1417        let remaining = |response: Duration| {
1418            let mut scroll = Physics {
1419                motion: ScrollBounceMotion::default().with_response(response),
1420                ..Physics::default()
1421            };
1422            scroll.begin(600.);
1423            scroll.pull(180.);
1424            scroll.release();
1425            scroll.step(0.25);
1426            scroll.offset()
1427        };
1428        assert!(remaining(Duration::from_secs(1)) > remaining(Duration::from_millis(524)));
1429        assert!(remaining(Duration::from_millis(524)) > remaining(Duration::from_millis(200)));
1430        assert_eq!(remaining(Duration::ZERO), 0.);
1431    }
1432
1433    #[test]
1434    fn regrabbing_a_displaced_edge_keeps_its_extent() {
1435        let mut scroll = Physics::default();
1436        scroll.begin(600.);
1437        scroll.pull(-150.);
1438        scroll.release();
1439        scroll.step(0.08);
1440        let before = scroll.offset();
1441        // The viewport shrank below the displacement while the edge was
1442        // returning. The finger still lands on the edge where it is.
1443        scroll.begin(40.);
1444        assert!((scroll.offset() - before).abs() < 0.001);
1445        // And a short pull inward moves the edge right away.
1446        scroll.pull(10.);
1447        assert!(scroll.offset() > before);
1448        assert!(scroll.offset() < before + 10.);
1449    }
1450
1451    #[test]
1452    fn a_gesture_from_rest_adopts_the_current_extent() {
1453        let mut scroll = Physics::default();
1454        scroll.begin(600.);
1455        scroll.begin(40.);
1456        scroll.pull(-100.);
1457        // The stretch saturates below the 40 px viewport, not the 600 px one.
1458        assert!(scroll.offset() > -40.);
1459    }
1460
1461    #[test]
1462    fn grabbing_the_spring_does_not_jump() {
1463        let mut scroll = Physics::default();
1464        scroll.begin(600.);
1465        scroll.pull(-150.);
1466        scroll.release();
1467        scroll.step(0.08);
1468        let before = scroll.offset();
1469        scroll.begin(600.);
1470        assert!((scroll.offset() - before).abs() < 0.001);
1471        let held = scroll.offset();
1472        assert!(!scroll.step(0.1));
1473        assert_eq!(scroll.offset(), held);
1474    }
1475
1476    #[test]
1477    fn spring_has_the_same_trajectory_at_60_and_120_hz() {
1478        let at = |hz: usize| {
1479            let mut scroll = Physics::default();
1480            scroll.begin(600.);
1481            scroll.pull(180.);
1482            scroll.release();
1483            for _ in 0..hz / 4 {
1484                scroll.step(1. / hz as f32);
1485            }
1486            scroll.offset()
1487        };
1488        assert!((at(60) - at(120)).abs() < 0.001);
1489    }
1490
1491    #[test]
1492    fn return_keeps_a_visible_tail_after_a_quarter_second() {
1493        let mut scroll = Physics {
1494            position: 100.,
1495            ..Physics::default()
1496        };
1497        assert!(scroll.step(0.25));
1498        // A 100 px release should still have a visible, decelerating tail
1499        // after 250 ms instead of snapping almost completely back by then.
1500        assert!(scroll.offset() > 10. && scroll.offset() < 30.);
1501        assert!(scroll.velocity < 0.);
1502    }
1503
1504    #[test]
1505    fn spring_settles_without_crossing_the_boundary() {
1506        let mut scroll = Physics::default();
1507        scroll.begin(600.);
1508        scroll.pull(-200.);
1509        scroll.release();
1510        let mut previous = scroll.offset();
1511        for _ in 0..120 {
1512            scroll.step(1. / 120.);
1513            assert!(scroll.offset() >= previous && scroll.offset() <= 0.);
1514            previous = scroll.offset();
1515        }
1516        assert_eq!(scroll.offset(), 0.);
1517        assert!(scroll.suppress_momentum);
1518        scroll.begin(600.);
1519        assert!(!scroll.suppress_momentum);
1520    }
1521}