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