1use 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#[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 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 response: Duration::from_micros(502_655),
50 max_stretch: Some(36.),
51 }
52 }
53 }
54}
55
56impl ScrollBounceMotion {
57 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 pub fn with_response(mut self, response: Duration) -> Self {
83 self.response = response;
84 self
85 }
86
87 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 pub fn tracking(&self) -> f32 {
105 self.tracking
106 }
107
108 pub fn response(&self) -> Duration {
110 self.response
111 }
112
113 pub fn max_stretch(&self) -> Option<f32> {
116 self.max_stretch
117 }
118
119 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
126pub 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 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 pub fn enabled(mut self, enabled: bool) -> Self {
182 self.enabled = enabled;
183 self
184 }
185
186 pub fn scroll_wheel(mut self, enabled: bool) -> Self {
194 self.scroll_wheel = enabled;
195 self
196 }
197
198 pub fn motion(mut self, motion: ScrollBounceMotion) -> Self {
200 self.motion = motion;
201 self
202 }
203
204 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 suppressed_direction: Option<f32>,
224}
225
226impl State {
227 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
238const CATCH_DRAG_SLOP: f32 = 8.;
242
243const MOMENTUM_GAP: Duration = Duration::from_millis(250);
248
249#[derive(Default)]
256struct ScrollOwner(Option<HitboxId>);
257
258impl Global for ScrollOwner {}
259
260pub(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
272fn 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 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 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 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 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 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 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 if self.scroll_wheel {
551 let bounces = self.enabled && !cx.reduce_motion();
552 let state = prepaint.state.clone();
553 let hitbox = prepaint.hitbox.id;
554 let handle = self.handle.clone();
555 let view = window.current_view();
556 let on_scroll = self.on_scroll.clone();
557 window.on_mouse_event(move |event: &ScrollEvent, phase, window, cx| {
558 if phase != DispatchPhase::Capture
559 || !hitbox.should_handle_scroll(window)
560 || !owns_scroll(hitbox, cx)
561 {
562 return;
563 }
564 let mut delta = event.delta.pixel_delta(window.line_height());
565 let mut state = state.borrow_mut();
566 state
567 .ongoing_scroll
568 .lock_axis(&mut delta, event.touch_phase);
569 let max = max_scroll_extent(handle.as_ref());
570 if max <= px(0.) {
571 state.overscroll.reset();
572 return;
573 }
574 if delta.x.abs() > delta.y.abs() {
575 state
576 .overscroll
577 .touch_phase(event.touch_phase, cx.background_executor().now());
578 return;
579 }
580 let before = handle.offset();
581 let changed = if !bounces || matches!(event.delta, ScrollDelta::Lines(_)) {
582 let displaced = state.overscroll.offset() != 0.;
583 state.overscroll.reset();
584 let mut offset = before;
585 offset.y = (before.y + delta.y).clamp(-max, px(0.));
586 handle.set_offset(offset);
587 displaced
588 } else {
589 state.overscroll.packet(
590 delta.y.as_f32(),
591 event.touch_phase,
592 bounds.size.height.as_f32(),
593 cx.background_executor().now(),
594 |dy| {
595 let before = handle.offset();
596 let mut offset = before;
597 let requested = before.y + px(dy);
598 offset.y = requested.clamp(-max, px(0.));
599 handle.set_offset(offset);
600 if offset.y == requested {
603 dy
604 } else {
605 (offset.y - before.y).as_f32()
606 }
607 },
608 )
609 };
610 let scrolled = handle.offset() != before;
611 drop(state);
612 cx.stop_propagation();
613 if changed || scrolled {
614 cx.notify(view);
615 }
616 if scrolled && let Some(handler) = &on_scroll {
617 handler(window, cx);
618 }
619 });
620 }
621 }
622}
623
624fn limit_throw(position: f32, speed: f32, omega: f32, max: f32) -> f32 {
631 let peak = |speed: f32| {
632 if speed <= omega * position {
633 position
634 } else {
635 speed / omega * (omega * position / speed - 1.).exp()
636 }
637 };
638 if peak(speed) <= max {
639 return speed;
640 }
641 let (mut low, mut high) = (0., speed);
642 for _ in 0..24 {
643 let mid = (low + high) / 2.;
644 if peak(mid) <= max {
645 low = mid;
646 } else {
647 high = mid;
648 }
649 }
650 low
651}
652
653#[derive(Default)]
656pub(crate) struct Physics {
657 position: f32,
658 velocity: f32,
659 dragging: bool,
660 suppress_momentum: bool,
661 extent: f32,
662 motion: ScrollBounceMotion,
663}
664
665impl Physics {
666 pub(crate) fn set_motion(&mut self, motion: ScrollBounceMotion) {
667 self.motion = motion;
668 }
669
670 pub(crate) fn reset(&mut self) {
671 *self = Self {
672 motion: self.motion,
673 ..Self::default()
674 };
675 }
676 pub(crate) fn is_dragging(&self) -> bool {
677 self.dragging
678 }
679
680 pub(crate) fn is_moving(&self) -> bool {
682 !self.dragging && (self.position != 0. || self.velocity != 0.)
683 }
684
685 pub(crate) fn offset(&self) -> f32 {
686 if self.dragging {
687 let d = self.extent.max(1.);
688 let tracking = self.motion.tracking;
689 let stretch = self.position * tracking / (1. + tracking * self.position.abs() / d);
690 match self.motion.max_stretch {
691 Some(max) => max * (stretch / max).tanh(),
693 None => stretch,
694 }
695 } else {
696 self.position
697 }
698 }
699
700 pub(crate) fn begin(&mut self, extent: f32) {
701 let offset = self.offset();
702 if offset == 0. || self.extent <= 0. {
707 self.extent = extent.max(1.);
708 }
709 let stretch = match self.motion.max_stretch {
711 Some(max) => max * (offset / max).clamp(-0.999, 0.999).atanh(),
712 None => offset,
713 };
714 let tracking = self.motion.tracking;
715 self.position = stretch / (tracking * (1. - stretch.abs() / self.extent).max(0.01));
716 self.velocity = 0.;
717 self.dragging = true;
718 self.suppress_momentum = false;
719 }
720
721 pub(crate) fn pull(&mut self, delta: f32) -> f32 {
723 let previous = self.position;
724 let next = previous + delta;
725 if previous != 0. && previous.signum() != next.signum() {
726 self.position = 0.;
727 next
728 } else {
729 self.position = next;
730 0.
731 }
732 }
733
734 pub(crate) fn release(&mut self) {
735 self.position = self.offset();
736 self.dragging = false;
737 if self.position != 0. {
738 self.suppress_momentum = true;
739 }
740 }
741
742 pub(crate) fn let_go(&mut self, velocity: f32) {
749 self.position = self.offset();
750 self.dragging = false;
751 let Some(omega) = self.motion.omega() else {
752 self.position = 0.;
753 self.velocity = 0.;
754 return;
755 };
756 let mut outward = if self.position * velocity >= 0. {
757 velocity
758 } else {
759 0.
760 };
761 if let Some(max) = self.motion.max_stretch {
762 outward =
763 outward.signum() * limit_throw(self.position.abs(), outward.abs(), omega, max);
764 }
765 self.velocity = outward - omega * self.position;
766 }
767
768 pub(crate) fn step(&mut self, seconds: f32) -> bool {
770 if self.dragging || (self.position == 0. && self.velocity == 0.) {
771 return false;
772 }
773 let Some(omega) = self.motion.omega() else {
774 self.position = 0.;
775 self.velocity = 0.;
776 return false;
777 };
778 let decay = (-omega * seconds).exp();
779 let c = self.velocity + omega * self.position;
780 self.position = (self.position + c * seconds) * decay;
781 self.velocity = (self.velocity - omega * c * seconds) * decay;
782 if self.position.abs() < 0.1 && self.velocity.abs() < 1. {
783 self.position = 0.;
784 self.velocity = 0.;
785 false
786 } else {
787 true
788 }
789 }
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795 use gpui::{
796 Context, InteractiveElement as _, ParentElement as _, Render, ScrollHandle,
797 StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, div,
798 };
799
800 struct ScrollTest {
801 handle: ScrollHandle,
802 enabled: bool,
803 }
804
805 impl Render for ScrollTest {
806 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
807 div().p(px(20.)).child(
808 ScrollBounce::new(
809 "bounce",
810 &self.handle,
811 div()
812 .id("viewport")
813 .w(px(200.))
814 .h(px(200.))
815 .overflow_y_scroll()
816 .track_scroll(&self.handle)
817 .child(div().h(px(600.)).w_full()),
818 )
819 .enabled(self.enabled)
820 .scroll_wheel(false),
821 )
822 }
823 }
824
825 fn draw(cx: &mut VisualTestContext) {
826 cx.update(|window, cx| window.draw(cx).clear(cx));
827 }
828
829 fn scroll(cx: &mut VisualTestContext, delta: f32, phase: TouchPhase) {
830 cx.simulate_event(ScrollEvent {
831 position: point(px(100.), px(100.)),
832 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
833 touch_phase: phase,
834 ..Default::default()
835 });
836 }
837
838 struct WheelViewport(ScrollHandle, Pixels);
839
840 impl Render for WheelViewport {
841 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
842 ScrollBounce::new(
843 "wheel-bounce",
844 &self.0,
845 div()
846 .id("wheel-viewport")
847 .w(px(200.))
848 .h(px(200.))
849 .overflow_y_scroll()
850 .track_scroll(&self.0)
851 .child(div().h(self.1).w_full()),
852 )
853 .enabled(true)
854 .scroll_wheel(true)
855 }
856 }
857
858 #[gpui::test]
859 fn fractional_wheel_input_keeps_scrolling_deep_in_a_long_list(cx: &mut TestAppContext) {
860 for delta in [-0.09_f32, 0.09] {
861 let mut app = cx.new_app();
862 let handle = ScrollHandle::new();
863 let (_, cx) = app.add_window_view({
864 let handle = handle.clone();
865 move |_, _| WheelViewport(handle, px(2_000_000.))
866 });
867 draw(cx);
868 handle.set_offset(point(px(0.), px(-1_000_000.)));
869 draw(cx);
870 let origin = handle.bounds().origin;
871 scroll(cx, delta, TouchPhase::Started);
872 scroll(cx, delta.signum() * 20., TouchPhase::Moved);
873 scroll(cx, 0., TouchPhase::Ended);
874 cx.executor().advance_clock(Duration::from_millis(16));
875 draw(cx);
876 scroll(cx, delta, TouchPhase::Moved);
877 scroll(cx, 0., TouchPhase::Started);
878 scroll(cx, delta.signum() * 20., TouchPhase::Moved);
879 draw(cx);
880 let expected = -1_000_000. + delta.signum() * 40.125;
882 assert!((handle.offset().y.as_f32() - expected).abs() < 0.001);
883 assert_eq!(handle.bounds().origin, origin);
884 }
885 }
886
887 #[gpui::test]
888 fn ordinary_mouse_wheel_keeps_the_shared_viewport_undisplaced(cx: &mut TestAppContext) {
889 let handle = ScrollHandle::new();
890 let (_, cx) = cx.add_window_view({
891 let handle = handle.clone();
892 move |_, _| WheelViewport(handle, px(600.))
893 });
894 draw(cx);
895 let origin = handle.bounds().origin;
896 cx.simulate_event(ScrollEvent {
897 position: point(px(100.), px(100.)),
898 delta: ScrollDelta::Lines(point(0., -100.)),
899 ..Default::default()
900 });
901 draw(cx);
902 cx.executor().advance_clock(Duration::from_millis(16));
903 draw(cx);
904 assert_eq!(handle.offset().y, -handle.max_offset().y);
905 assert_eq!(handle.bounds().origin, origin);
906 }
907
908 #[gpui::test]
909 fn wheel_scrolling_interrupts_the_shared_return(cx: &mut TestAppContext) {
910 let handle = ScrollHandle::new();
911 let (_, cx) = cx.add_window_view({
912 let handle = handle.clone();
913 move |_, _| WheelViewport(handle, px(600.))
914 });
915 draw(cx);
916 let wheel = |cx: &mut VisualTestContext, dy| {
917 cx.simulate_event(ScrollEvent {
918 position: point(px(100.), px(100.)),
919 delta: ScrollDelta::Lines(point(0., dy)),
920 ..Default::default()
921 });
922 };
923 scroll(cx, -1000., TouchPhase::Started);
924 scroll(cx, 0., TouchPhase::Ended);
925 draw(cx);
926 assert_eq!(handle.offset().y, -handle.max_offset().y);
927 cx.executor().advance_clock(Duration::from_millis(200));
928 draw(cx);
929 let end = handle.offset().y;
930 wheel(cx, 2.);
931 assert!(
932 handle.offset().y > end,
933 "the new wheel input scrolls before the next frame"
934 );
935 draw(cx);
936 }
937
938 struct Nested {
940 outer: ScrollHandle,
941 inner: ScrollHandle,
942 inner_enabled: bool,
943 }
944
945 impl Render for Nested {
946 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
947 ScrollBounce::new(
948 "outer",
949 &self.outer,
950 div()
951 .id("outer-viewport")
952 .w(px(200.))
953 .h(px(300.))
954 .overflow_y_scroll()
955 .track_scroll(&self.outer)
956 .child(div().h(px(100.)).w_full())
957 .child(
958 ScrollBounce::new(
959 "inner",
960 &self.inner,
961 div()
962 .id("inner-viewport")
963 .w(px(200.))
964 .h(px(100.))
965 .overflow_y_scroll()
966 .track_scroll(&self.inner)
967 .child(div().h(px(400.)).w_full()),
968 )
969 .enabled(self.inner_enabled),
970 )
971 .child(div().h(px(600.)).w_full()),
972 )
973 .enabled(true)
974 }
975 }
976
977 fn nested(cx: &mut TestAppContext, inner_enabled: bool) -> (Nested, &mut VisualTestContext) {
978 let (outer, inner) = (ScrollHandle::new(), ScrollHandle::new());
979 let (_, cx) = cx.add_window_view({
980 let (outer, inner) = (outer.clone(), inner.clone());
981 move |_, _| Nested {
982 outer,
983 inner,
984 inner_enabled,
985 }
986 });
987 draw(cx);
988 (
989 Nested {
990 outer,
991 inner,
992 inner_enabled,
993 },
994 cx,
995 )
996 }
997
998 fn scroll_at(cx: &mut VisualTestContext, y: f32, delta: f32, phase: TouchPhase) {
999 cx.simulate_event(ScrollEvent {
1000 position: point(px(100.), px(y)),
1001 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
1002 touch_phase: phase,
1003 ..Default::default()
1004 });
1005 draw(cx);
1006 }
1007
1008 #[gpui::test]
1009 fn the_innermost_area_takes_the_whole_gesture(cx: &mut TestAppContext) {
1010 let (areas, cx) = nested(cx, true);
1011 let inner_origin = areas.inner.bounds().origin.y;
1012 scroll_at(cx, 150., 0., TouchPhase::Started);
1013 scroll_at(cx, 150., -200., TouchPhase::Moved);
1014 assert_eq!(areas.inner.offset().y, px(-200.), "the inner area scrolls");
1015 assert_eq!(areas.outer.offset().y, px(0.), "the outer one does not");
1016 scroll_at(cx, 150., -200., TouchPhase::Moved);
1018 assert_eq!(areas.inner.offset().y, px(-300.));
1019 assert_eq!(areas.outer.offset().y, px(0.));
1020 assert!(areas.inner.bounds().origin.y < inner_origin);
1021 scroll_at(cx, 150., 0., TouchPhase::Ended);
1023 scroll_at(cx, 20., -50., TouchPhase::Moved);
1024 assert_eq!(areas.outer.offset().y, px(-50.));
1025 }
1026
1027 #[gpui::test]
1028 fn a_disabled_inner_area_still_keeps_its_scroll(cx: &mut TestAppContext) {
1029 let (areas, cx) = nested(cx, false);
1030 let inner_origin = areas.inner.bounds().origin.y;
1031 scroll_at(cx, 150., -50., TouchPhase::Moved);
1032 assert_eq!(areas.inner.offset().y, px(-50.), "the inner area scrolls");
1033 assert_eq!(areas.outer.offset().y, px(0.), "the outer one does not");
1034 scroll_at(cx, 150., -500., TouchPhase::Moved);
1036 assert_eq!(areas.inner.offset().y, px(-300.));
1037 assert_eq!(areas.inner.bounds().origin.y, inner_origin);
1038 assert_eq!(areas.outer.offset().y, px(0.));
1039 }
1040
1041 struct ListTest(gpui::ListState);
1042
1043 impl Render for ListTest {
1044 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1045 ScrollBounce::new(
1046 "bounce-list",
1047 &self.0,
1048 gpui::list(self.0.clone(), |_, _, _| {
1049 div().h(px(40.)).into_any_element()
1050 })
1051 .w(px(200.))
1052 .h(px(200.)),
1053 )
1054 .enabled(true)
1055 .scroll_wheel(false)
1056 }
1057 }
1058
1059 #[gpui::test]
1060 fn list_reverse_drag_preserves_events_before_the_next_frame(cx: &mut TestAppContext) {
1061 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1062 let (_, cx) = cx.add_window_view({
1063 let handle = handle.clone();
1064 move |_, _| ListTest(handle)
1065 });
1066 draw(cx);
1067 scroll(cx, 100., TouchPhase::Started);
1068 scroll(cx, -140., TouchPhase::Moved);
1069 assert_eq!(handle.offset().y, px(-40.));
1070 scroll(cx, -10., TouchPhase::Moved);
1071 draw(cx);
1072 assert_eq!(handle.offset().y, px(-50.));
1073 }
1074
1075 #[gpui::test]
1076 fn trackpad_release_between_frames_does_not_bounce_in_the_middle(cx: &mut TestAppContext) {
1077 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1078 let (_, cx) = cx.add_window_view({
1079 let handle = handle.clone();
1080 move |_, _| ListTest(handle)
1081 });
1082 draw(cx);
1083 handle.set_offset(point(px(0.), px(-400.)));
1084 draw(cx);
1085 let origin = handle.viewport_bounds().origin.y;
1086 cx.update(|window, cx| {
1089 for (delta, phase) in [(-30., TouchPhase::Started), (0., TouchPhase::Ended)] {
1090 window.dispatch_event(
1091 gpui::PlatformInput::Scroll(ScrollEvent {
1092 position: point(px(100.), px(100.)),
1093 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
1094 touch_phase: phase,
1095 ..Default::default()
1096 }),
1097 cx,
1098 );
1099 }
1100 });
1101 draw(cx);
1102 assert_eq!(handle.viewport_bounds().origin.y, origin);
1103 assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
1104 }
1105
1106 #[gpui::test]
1107 fn trackpad_direction_change_between_frames_does_not_bounce_in_the_middle(
1108 cx: &mut TestAppContext,
1109 ) {
1110 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1111 let (_, cx) = cx.add_window_view({
1112 let handle = handle.clone();
1113 move |_, _| ListTest(handle)
1114 });
1115 draw(cx);
1116 handle.set_offset(point(px(0.), px(-400.)));
1117 draw(cx);
1118 let origin = handle.viewport_bounds().origin.y;
1119 cx.update(|window, cx| {
1120 for (delta, phase) in [(-30., TouchPhase::Started), (5., TouchPhase::Moved)] {
1121 window.dispatch_event(
1122 gpui::PlatformInput::Scroll(ScrollEvent {
1123 position: point(px(100.), px(100.)),
1124 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
1125 touch_phase: phase,
1126 ..Default::default()
1127 }),
1128 cx,
1129 );
1130 }
1131 });
1132 draw(cx);
1133 assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
1134 assert_eq!(handle.viewport_bounds().origin.y, origin);
1135 }
1136
1137 #[gpui::test]
1138 fn list_stretches_only_the_distance_past_either_edge(cx: &mut TestAppContext) {
1139 for (start, delta, end) in [(-30., 50., 0.), (-970., -50., -1000.)] {
1140 let mut app = cx.new_app();
1141 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1142 let (_, cx) = app.add_window_view({
1143 let handle = handle.clone();
1144 move |_, _| ListTest(handle)
1145 });
1146 draw(cx);
1147 handle.set_offset(point(px(0.), px(start)));
1148 draw(cx);
1149 let origin = handle.viewport_bounds().origin.y;
1150 scroll(cx, delta, TouchPhase::Started);
1151 draw(cx);
1152 assert_eq!(handle.offset().y, px(end));
1153 let stretch = (handle.viewport_bounds().origin.y - origin).as_f32();
1154 assert_eq!(stretch.signum(), delta.signum());
1155 assert!(stretch.abs() > 0. && stretch.abs() < 20.);
1158 }
1159 }
1160
1161 #[gpui::test]
1162 fn reverse_drag_consumes_stretch_before_scrolling_content(cx: &mut TestAppContext) {
1163 let handle = ScrollHandle::new();
1164 let (_, cx) = cx.add_window_view({
1165 let handle = handle.clone();
1166 move |_, _| ScrollTest {
1167 handle,
1168 enabled: true,
1169 }
1170 });
1171 draw(cx);
1172 let origin = handle.bounds().origin.y;
1173 assert_eq!(origin, px(20.));
1174 scroll(cx, 100., TouchPhase::Started);
1175 draw(cx);
1176 assert_eq!(handle.offset().y, px(0.));
1177 assert!(handle.bounds().origin.y > origin);
1178 scroll(cx, -140., TouchPhase::Moved);
1179 draw(cx);
1180 assert_eq!(handle.offset().y, px(-40.));
1181 assert_eq!(handle.bounds().origin.y, origin);
1182 }
1183
1184 #[gpui::test]
1185 fn touch_release_ignores_momentum_until_a_new_touch_takes_over(cx: &mut TestAppContext) {
1186 let handle = ScrollHandle::new();
1187 let (_, cx) = cx.add_window_view({
1188 let handle = handle.clone();
1189 move |_, _| ScrollTest {
1190 handle,
1191 enabled: true,
1192 }
1193 });
1194 draw(cx);
1195 let origin = handle.bounds().origin.y;
1196 scroll(cx, 100., TouchPhase::Started);
1197 draw(cx);
1198 assert!(handle.bounds().origin.y > origin);
1199 scroll(cx, 0., TouchPhase::Ended);
1200 draw(cx);
1201 let released = handle.bounds().origin.y;
1202 scroll(cx, -400., TouchPhase::Moved);
1206 draw(cx);
1207 assert_eq!(handle.offset().y, px(0.));
1208 assert!(handle.bounds().origin.y <= released);
1209 scroll(cx, 0., TouchPhase::Started);
1211 scroll(cx, -250., TouchPhase::Moved);
1212 draw(cx);
1213 assert!(handle.offset().y < px(0.));
1214 assert_eq!(handle.bounds().origin.y, origin);
1215 }
1216
1217 #[gpui::test]
1218 fn phaseless_wheel_scrolls_back_right_after_bouncing(cx: &mut TestAppContext) {
1219 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
1220 let (_, cx) = cx.add_window_view({
1221 let handle = handle.clone();
1222 move |_, _| ListTest(handle)
1223 });
1224 draw(cx);
1225 handle.set_offset(point(px(0.), px(-1000.)));
1226 draw(cx);
1227 let origin = handle.viewport_bounds().origin.y;
1228 scroll(cx, -50., TouchPhase::Moved);
1230 draw(cx);
1231 assert!(handle.viewport_bounds().origin.y < origin);
1232 scroll(cx, 200., TouchPhase::Moved);
1233 draw(cx);
1234 assert!(handle.offset().y > px(-1000.));
1235 assert_eq!(handle.viewport_bounds().origin.y, origin);
1236 }
1237
1238 #[gpui::test]
1239 fn phaseless_wheel_bounces_again_only_after_a_pause(cx: &mut TestAppContext) {
1240 let handle = ScrollHandle::new();
1241 let (_, cx) = cx.add_window_view({
1242 let handle = handle.clone();
1243 move |_, _| ScrollTest {
1244 handle,
1245 enabled: true,
1246 }
1247 });
1248 draw(cx);
1249 scroll(cx, 50., TouchPhase::Moved);
1250 draw(cx);
1251 let bounced = handle.bounds().origin.y;
1252 scroll(cx, 50., TouchPhase::Moved);
1255 draw(cx);
1256 assert!(handle.bounds().origin.y <= bounced);
1257 cx.executor().advance_clock(MOMENTUM_GAP);
1258 scroll(cx, 50., TouchPhase::Moved);
1259 draw(cx);
1260 assert!(handle.bounds().origin.y > bounced);
1261 assert_eq!(handle.offset().y, px(0.));
1262 }
1263
1264 #[gpui::test]
1265 fn tiny_drag_catching_momentum_does_not_start_a_reverse_fling(cx: &mut TestAppContext) {
1266 let handle = ScrollHandle::new();
1267 let (_, cx) = cx.add_window_view({
1268 let handle = handle.clone();
1269 move |_, _| ScrollTest {
1270 handle,
1271 enabled: true,
1272 }
1273 });
1274 draw(cx);
1275 handle.set_offset(point(px(0.), px(-200.)));
1276 draw(cx);
1277
1278 scroll(cx, -60., TouchPhase::Started);
1281 scroll(cx, 0., TouchPhase::Ended);
1282 draw(cx);
1283 scroll(cx, -100., TouchPhase::Moved);
1284 draw(cx);
1285 scroll(cx, 0., TouchPhase::Ended);
1286 draw(cx);
1287 let before_catch = handle.offset().y;
1288 assert!(before_catch < px(0.));
1289 scroll(cx, 0., TouchPhase::Started);
1290 scroll(cx, 8., TouchPhase::Moved);
1291 scroll(cx, 0., TouchPhase::Ended);
1292 draw(cx);
1293 let stopped = handle.offset().y;
1294 assert_eq!(stopped, before_catch + px(8.));
1295
1296 scroll(cx, 100., TouchPhase::Moved);
1299 draw(cx);
1300 assert_eq!(handle.offset().y, stopped);
1301
1302 scroll(cx, -20., TouchPhase::Started);
1304 scroll(cx, 0., TouchPhase::Ended);
1305 scroll(cx, -10., TouchPhase::Moved);
1306 draw(cx);
1307 assert_eq!(handle.offset().y, stopped - px(30.));
1308 }
1309
1310 #[gpui::test]
1311 fn deliberate_drag_after_catching_momentum_can_fling(cx: &mut TestAppContext) {
1312 let handle = ScrollHandle::new();
1313 let (_, cx) = cx.add_window_view({
1314 let handle = handle.clone();
1315 move |_, _| ScrollTest {
1316 handle,
1317 enabled: true,
1318 }
1319 });
1320 draw(cx);
1321 handle.set_offset(point(px(0.), px(-200.)));
1322 draw(cx);
1323
1324 scroll(cx, 0., TouchPhase::Started);
1325 scroll(cx, 24., TouchPhase::Moved);
1326 scroll(cx, 0., TouchPhase::Ended);
1327 draw(cx);
1328 assert_eq!(handle.offset().y, px(-176.));
1329 scroll(cx, 40., TouchPhase::Moved);
1330 draw(cx);
1331 assert_eq!(handle.offset().y, px(-136.));
1332 }
1333
1334 #[gpui::test]
1335 fn short_catch_release_still_stretches_past_the_edge(cx: &mut TestAppContext) {
1336 let handle = ScrollHandle::new();
1337 let (_, cx) = cx.add_window_view({
1338 let handle = handle.clone();
1339 move |_, _| ScrollTest {
1340 handle,
1341 enabled: true,
1342 }
1343 });
1344 draw(cx);
1345 handle.set_offset(point(px(0.), px(-4.)));
1346 draw(cx);
1347 let origin = handle.bounds().origin.y;
1348
1349 scroll(cx, 0., TouchPhase::Started);
1350 scroll(cx, 8., TouchPhase::Ended);
1351 draw(cx);
1352 assert_eq!(handle.offset().y, px(0.));
1353 assert!(handle.bounds().origin.y > origin);
1354 }
1355
1356 #[gpui::test]
1357 fn disabled_and_reduced_motion_leave_the_viewport_fixed(cx: &mut TestAppContext) {
1358 for enabled in [false, true] {
1359 let mut app = cx.new_app();
1360 if enabled {
1361 app.update(|cx| cx.set_reduce_motion(true));
1362 }
1363 let handle = ScrollHandle::new();
1364 let (_, cx) = app.add_window_view({
1365 let handle = handle.clone();
1366 move |_, _| ScrollTest { handle, enabled }
1367 });
1368 draw(cx);
1369 let origin = handle.bounds().origin.y;
1370 scroll(cx, 100., TouchPhase::Started);
1371 draw(cx);
1372 assert_eq!(handle.bounds().origin.y, origin);
1373 scroll(cx, -40., TouchPhase::Moved);
1374 draw(cx);
1375 assert_eq!(handle.offset().y, px(-40.));
1376 }
1377 }
1378
1379 #[test]
1380 fn resistance_and_reverse_preserve_unconsumed_distance() {
1381 let mut scroll = Physics::default();
1382 scroll.begin(600.);
1383 assert_eq!(scroll.pull(100.), 0.);
1384 assert!(scroll.offset() > 0. && scroll.offset() < 55.);
1385 assert_eq!(scroll.pull(-130.), -30.);
1386 assert_eq!(scroll.offset(), 0.);
1387 }
1388
1389 #[gpui::test]
1390 fn diagonal_wobble_stays_with_the_stretch(cx: &mut TestAppContext) {
1391 let handle = ScrollHandle::new();
1392 let (_, cx) = cx.add_window_view({
1393 let handle = handle.clone();
1394 move |_, _| ScrollTest {
1395 handle,
1396 enabled: true,
1397 }
1398 });
1399 draw(cx);
1400 let origin = handle.bounds().origin.y;
1401 scroll(cx, 100., TouchPhase::Started);
1402 draw(cx);
1403 let stretched = handle.bounds().origin.y;
1404 assert!(stretched > origin);
1405 cx.update(|window, cx| {
1409 for delta in [point(px(30.), px(-20.)), point(px(0.), px(-20.))] {
1410 window.dispatch_event(
1411 gpui::PlatformInput::Scroll(ScrollEvent {
1412 position: point(px(100.), px(100.)),
1413 delta: ScrollDelta::Pixels(delta),
1414 touch_phase: TouchPhase::Moved,
1415 ..Default::default()
1416 }),
1417 cx,
1418 );
1419 }
1420 });
1421 draw(cx);
1422 assert!(handle.bounds().origin.y < stretched);
1424 assert!(handle.bounds().origin.y > origin);
1425 assert_eq!(handle.offset(), point(px(0.), px(0.)));
1426 }
1427
1428 #[test]
1429 fn motion_builder_configures_tracking_and_response() {
1430 let motion = ScrollBounceMotion::default()
1431 .with_tracking(0.4)
1432 .with_response(Duration::from_millis(300))
1433 .with_max_stretch(24.);
1434 assert_eq!(motion.tracking(), 0.4);
1435 assert_eq!(motion.response(), Duration::from_millis(300));
1436 assert_eq!(motion.max_stretch(), Some(24.));
1437 }
1438
1439 #[test]
1440 fn tracking_scales_the_first_stretch() {
1441 let stretch = |tracking: f32| {
1442 let mut scroll = Physics {
1443 motion: ScrollBounceMotion::default().with_tracking(tracking),
1444 ..Physics::default()
1445 };
1446 scroll.begin(600.);
1447 scroll.pull(100.);
1448 scroll.offset()
1449 };
1450 assert!(stretch(0.3) < stretch(0.55));
1451 assert!(stretch(0.55) < stretch(0.8));
1452 }
1453
1454 #[test]
1455 fn response_scales_the_return_and_zero_snaps() {
1456 let remaining = |response: Duration| {
1457 let mut scroll = Physics {
1458 motion: ScrollBounceMotion::default().with_response(response),
1459 ..Physics::default()
1460 };
1461 scroll.begin(600.);
1462 scroll.pull(180.);
1463 scroll.release();
1464 scroll.step(0.25);
1465 scroll.offset()
1466 };
1467 assert!(remaining(Duration::from_secs(1)) > remaining(Duration::from_millis(524)));
1468 assert!(remaining(Duration::from_millis(524)) > remaining(Duration::from_millis(200)));
1469 assert_eq!(remaining(Duration::ZERO), 0.);
1470 }
1471
1472 #[test]
1473 fn regrabbing_a_displaced_edge_keeps_its_extent() {
1474 let mut scroll = Physics::default();
1475 scroll.begin(600.);
1476 scroll.pull(-150.);
1477 scroll.release();
1478 scroll.step(0.08);
1479 let before = scroll.offset();
1480 scroll.begin(40.);
1483 assert!((scroll.offset() - before).abs() < 0.001);
1484 scroll.pull(10.);
1486 assert!(scroll.offset() > before);
1487 assert!(scroll.offset() < before + 10.);
1488 }
1489
1490 #[test]
1491 fn a_gesture_from_rest_adopts_the_current_extent() {
1492 let mut scroll = Physics::default();
1493 scroll.begin(600.);
1494 scroll.begin(40.);
1495 scroll.pull(-100.);
1496 assert!(scroll.offset() > -40.);
1498 }
1499
1500 #[test]
1501 fn grabbing_the_spring_does_not_jump() {
1502 let mut scroll = Physics::default();
1503 scroll.begin(600.);
1504 scroll.pull(-150.);
1505 scroll.release();
1506 scroll.step(0.08);
1507 let before = scroll.offset();
1508 scroll.begin(600.);
1509 assert!((scroll.offset() - before).abs() < 0.001);
1510 let held = scroll.offset();
1511 assert!(!scroll.step(0.1));
1512 assert_eq!(scroll.offset(), held);
1513 }
1514
1515 #[test]
1516 fn spring_has_the_same_trajectory_at_60_and_120_hz() {
1517 let at = |hz: usize| {
1518 let mut scroll = Physics::default();
1519 scroll.begin(600.);
1520 scroll.pull(180.);
1521 scroll.release();
1522 for _ in 0..hz / 4 {
1523 scroll.step(1. / hz as f32);
1524 }
1525 scroll.offset()
1526 };
1527 assert!((at(60) - at(120)).abs() < 0.001);
1528 }
1529
1530 #[test]
1531 fn return_keeps_a_visible_tail_after_a_quarter_second() {
1532 let mut scroll = Physics {
1533 position: 100.,
1534 ..Physics::default()
1535 };
1536 assert!(scroll.step(0.25));
1537 assert!(scroll.offset() > 10. && scroll.offset() < 30.);
1540 assert!(scroll.velocity < 0.);
1541 }
1542
1543 #[test]
1544 fn spring_settles_without_crossing_the_boundary() {
1545 let mut scroll = Physics::default();
1546 scroll.begin(600.);
1547 scroll.pull(-200.);
1548 scroll.release();
1549 let mut previous = scroll.offset();
1550 for _ in 0..120 {
1551 scroll.step(1. / 120.);
1552 assert!(scroll.offset() >= previous && scroll.offset() <= 0.);
1553 previous = scroll.offset();
1554 }
1555 assert_eq!(scroll.offset(), 0.);
1556 assert!(scroll.suppress_momentum);
1557 scroll.begin(600.);
1558 assert!(!scroll.suppress_momentum);
1559 }
1560}