1use crate::{OngoingScrollExt as _, ScrollbarHandle};
4use gpui::{
5 AnyElement, App, Bounds, ContentMask, DispatchPhase, Element, ElementId, GlobalElementId,
6 Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, OngoingScroll, Pixels,
7 ScrollDelta, ScrollWheelEvent, TouchPhase, Window, point, px,
8};
9#[cfg(not(target_family = "wasm"))]
10use std::time::Instant;
11use std::{cell::RefCell, rc::Rc, time::Duration};
12#[cfg(target_family = "wasm")]
13use web_time::Instant;
14
15#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct ScrollBounceMotion {
22 tracking: f32,
23 response: Duration,
24}
25
26impl Default for ScrollBounceMotion {
27 fn default() -> Self {
29 Self {
30 tracking: 0.55,
31 response: Duration::from_millis(524),
32 }
33 }
34}
35
36impl ScrollBounceMotion {
37 pub fn with_tracking(mut self, tracking: f32) -> Self {
47 assert!(
48 tracking.is_finite() && tracking > 0.,
49 "scroll bounce tracking must be finite and positive"
50 );
51 self.tracking = tracking;
52 self
53 }
54
55 pub fn with_response(mut self, response: Duration) -> Self {
63 self.response = response;
64 self
65 }
66
67 pub fn tracking(&self) -> f32 {
69 self.tracking
70 }
71
72 pub fn response(&self) -> Duration {
74 self.response
75 }
76
77 fn omega(&self) -> Option<f32> {
79 let seconds = self.response.as_secs_f32();
80 (seconds > 0.).then(|| std::f32::consts::TAU / seconds)
81 }
82}
83
84pub struct ScrollBounce {
96 id: ElementId,
97 handle: Rc<dyn ScrollbarHandle>,
98 child: AnyElement,
99 enabled: bool,
100 motion: ScrollBounceMotion,
101 on_scroll: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
102}
103
104impl ScrollBounce {
105 pub fn new<H: ScrollbarHandle + Clone>(
106 id: impl Into<ElementId>,
107 handle: &H,
108 child: impl IntoElement,
109 ) -> Self {
110 Self {
111 id: id.into(),
112 handle: Rc::new(handle.clone()),
113 child: child.into_any_element(),
114 enabled: cfg!(any(target_os = "ios", target_os = "android")),
115 motion: ScrollBounceMotion::default(),
116 on_scroll: None,
117 }
118 }
119
120 pub fn enabled(mut self, enabled: bool) -> Self {
122 self.enabled = enabled;
123 self
124 }
125
126 pub fn motion(mut self, motion: ScrollBounceMotion) -> Self {
128 self.motion = motion;
129 self
130 }
131
132 pub fn on_scroll(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
136 self.on_scroll = Some(Rc::new(handler));
137 self
138 }
139}
140
141#[derive(Default)]
142struct State {
143 physics: Physics,
144 sampled_at: Option<Instant>,
145 ongoing_scroll: OngoingScroll,
146 short_drag_distance: Option<f32>,
147 last_wheel_at: Option<Instant>,
148 suppressed_direction: Option<f32>,
151}
152
153impl State {
154 fn release(&mut self, from_rest: bool) {
159 self.physics.release();
160 self.suppressed_direction =
161 (from_rest && self.physics.suppress_momentum).then(|| self.physics.offset().signum());
162 }
163}
164
165const CATCH_DRAG_SLOP: f32 = 8.;
169
170const MOMENTUM_GAP: Duration = Duration::from_millis(250);
175
176fn max_scroll_extent(handle: &dyn ScrollbarHandle) -> Pixels {
180 (handle.content_size().height - handle.viewport_bounds().size.height).max(px(0.))
181}
182
183#[doc(hidden)]
184pub struct ScrollBouncePrepaintState {
185 state: Rc<RefCell<State>>,
186 hitbox: Hitbox,
187}
188
189impl IntoElement for ScrollBounce {
190 type Element = Self;
191 fn into_element(self) -> Self {
192 self
193 }
194}
195
196impl Element for ScrollBounce {
197 type RequestLayoutState = ();
198 type PrepaintState = ScrollBouncePrepaintState;
199
200 fn id(&self) -> Option<ElementId> {
201 Some(self.id.clone())
202 }
203 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
204 None
205 }
206
207 fn request_layout(
208 &mut self,
209 _: Option<&GlobalElementId>,
210 _: Option<&InspectorElementId>,
211 window: &mut Window,
212 cx: &mut App,
213 ) -> (LayoutId, ()) {
214 (self.child.request_layout(window, cx), ())
215 }
216
217 fn prepaint(
218 &mut self,
219 id: Option<&GlobalElementId>,
220 _: Option<&InspectorElementId>,
221 bounds: Bounds<Pixels>,
222 _: &mut (),
223 window: &mut Window,
224 cx: &mut App,
225 ) -> Self::PrepaintState {
226 let state = window.with_element_state(
227 id.expect("ScrollBounce has an id"),
228 |state: Option<Rc<RefCell<State>>>, _| {
229 let state = state.unwrap_or_default();
230 (state.clone(), state)
231 },
232 );
233 let offset = {
234 let mut state = state.borrow_mut();
235 if !self.enabled || cx.reduce_motion() {
236 *state = State::default();
237 }
238 state.physics.motion = self.motion;
239 let now = Instant::now();
240 let elapsed = state
241 .sampled_at
242 .replace(now)
243 .map_or(0., |at| now.duration_since(at).as_secs_f32());
244 if state.physics.step(elapsed) {
245 window.request_animation_frame();
246 }
247 state.physics.offset()
248 };
249 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
250 window.with_content_mask(Some(ContentMask { bounds }), |window| {
251 window.with_element_offset(point(px(0.), px(offset)), |window| {
252 self.child.prepaint(window, cx);
253 });
254 });
255 ScrollBouncePrepaintState { state, hitbox }
256 }
257
258 fn paint(
259 &mut self,
260 _: Option<&GlobalElementId>,
261 _: Option<&InspectorElementId>,
262 bounds: Bounds<Pixels>,
263 _: &mut (),
264 prepaint: &mut Self::PrepaintState,
265 window: &mut Window,
266 cx: &mut App,
267 ) {
268 if self.enabled && !cx.reduce_motion() {
269 let state = prepaint.state.clone();
270 let hitbox = prepaint.hitbox.id;
271 let handle = self.handle.clone();
272 let view = window.current_view();
273 let on_scroll = self.on_scroll.clone();
274 let mut before = 0.;
275 let mut allow_end_bounce = false;
276 window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
277 let ScrollDelta::Pixels(mut delta) = event.delta else {
278 return;
279 };
280 if !hitbox.should_handle_scroll(window) {
281 return;
282 }
283 let mut state = state.borrow_mut();
284 state
289 .ongoing_scroll
290 .lock_axis(&mut delta, event.touch_phase);
291 if delta.x.abs() > delta.y.abs() {
292 return;
293 }
294 let ended = matches!(event.touch_phase, TouchPhase::Ended | TouchPhase::Cancelled);
295 let mut scrolled = false;
296 let mut changed = false;
297 if phase == DispatchPhase::Capture {
298 before = handle.offset().y.as_f32();
299 if event.touch_phase == TouchPhase::Started {
300 state.short_drag_distance = (delta.y == px(0.)).then_some(0.);
301 state.physics.begin(bounds.size.height.as_f32());
302 } else if let Some(distance) = state.short_drag_distance.as_mut() {
303 *distance += delta.y.as_f32().abs();
304 if *distance > CATCH_DRAG_SLOP {
305 state.short_drag_distance = None;
306 }
307 }
308 let suppress_short_drag_momentum = if ended {
309 state.short_drag_distance.take().is_some()
310 && event.touch_phase == TouchPhase::Ended
311 } else {
312 false
313 };
314 allow_end_bounce = suppress_short_drag_momentum;
317 let now = cx.background_executor().now();
318 let paused = state
319 .last_wheel_at
320 .replace(now)
321 .is_some_and(|at| now.saturating_duration_since(at) >= MOMENTUM_GAP);
322 let reversed = state.suppressed_direction.is_some_and(|direction| {
323 delta.y != px(0.) && delta.y.as_f32().signum() != direction
324 });
325 if paused || reversed {
326 state.physics.suppress_momentum = false;
327 }
328 if state.physics.suppress_momentum {
329 cx.stop_propagation();
330 return;
331 }
332 if state.physics.offset() != 0. {
333 let from_rest = !state.physics.dragging;
337 if from_rest {
338 state.physics.begin(bounds.size.height.as_f32());
339 }
340 let remainder = state.physics.pull(delta.y.as_f32());
341 if remainder != 0. {
342 let max = max_scroll_extent(handle.as_ref());
343 let mut offset = handle.offset();
344 offset.y = px(before + remainder).clamp(-max, px(0.));
345 handle.set_offset(offset);
346 scrolled = true;
347 }
348 if ended || from_rest {
349 state.release(from_rest);
350 }
351 changed = true;
352 cx.stop_propagation();
353 } else if ended {
354 state.physics.release();
355 }
356 if suppress_short_drag_momentum {
357 state.physics.suppress_momentum = true;
358 state.suppressed_direction = None;
359 }
360 } else {
361 let mut offset = handle.offset();
365 let max = max_scroll_extent(handle.as_ref());
366 let clamped = offset.y.clamp(-max, px(0.));
367 if clamped != offset.y {
368 offset.y = clamped;
369 handle.set_offset(offset);
370 }
371 let after = offset.y.as_f32();
372 let requested = delta.y.as_f32();
373 let at_outward_edge = (requested > 0. && offset.y == px(0.))
378 || (requested < 0. && offset.y == -max);
379 let residual =
380 (requested - (after - before)).clamp(requested.min(0.), requested.max(0.));
381 if at_outward_edge
382 && residual.abs() > 0.01
383 && (!state.physics.suppress_momentum || allow_end_bounce)
384 {
385 let dragging = state.physics.dragging;
386 if !dragging {
387 state.physics.begin(bounds.size.height.as_f32());
388 }
389 state.physics.pull(residual);
390 if !dragging || ended {
391 state.release(!dragging);
392 }
393 changed = true;
394 }
395 }
396 if changed {
397 state.sampled_at = Some(Instant::now());
398 }
399 drop(state);
400 if changed {
401 cx.notify(view);
402 }
403 if scrolled && let Some(handler) = &on_scroll {
404 handler(window, cx);
405 }
406 });
407 }
408 window.with_content_mask(Some(ContentMask { bounds }), |window| {
409 self.child.paint(window, cx)
410 });
411 }
412}
413
414#[derive(Default)]
415struct Physics {
416 position: f32,
417 velocity: f32,
418 dragging: bool,
419 suppress_momentum: bool,
420 extent: f32,
421 motion: ScrollBounceMotion,
422}
423
424impl Physics {
425 fn offset(&self) -> f32 {
426 if self.dragging {
427 let d = self.extent.max(1.);
428 let tracking = self.motion.tracking;
429 self.position * tracking / (1. + tracking * self.position.abs() / d)
430 } else {
431 self.position
432 }
433 }
434
435 fn begin(&mut self, extent: f32) {
436 let offset = self.offset();
437 if offset == 0. {
442 self.extent = extent.max(1.);
443 }
444 let tracking = self.motion.tracking;
446 self.position = offset / (tracking * (1. - offset.abs() / self.extent).max(0.01));
447 self.velocity = 0.;
448 self.dragging = true;
449 self.suppress_momentum = false;
450 }
451
452 fn pull(&mut self, delta: f32) -> f32 {
454 let previous = self.position;
455 let next = previous + delta;
456 if previous != 0. && previous.signum() != next.signum() {
457 self.position = 0.;
458 next
459 } else {
460 self.position = next;
461 0.
462 }
463 }
464
465 fn release(&mut self) {
466 self.position = self.offset();
467 self.dragging = false;
468 if self.position != 0. {
469 self.suppress_momentum = true;
470 }
471 }
472
473 fn step(&mut self, seconds: f32) -> bool {
475 if self.dragging || self.position == 0. {
476 return false;
477 }
478 let Some(omega) = self.motion.omega() else {
479 self.position = 0.;
480 self.velocity = 0.;
481 return false;
482 };
483 let decay = (-omega * seconds).exp();
484 let c = self.velocity + omega * self.position;
485 self.position = (self.position + c * seconds) * decay;
486 self.velocity = (self.velocity - omega * c * seconds) * decay;
487 if self.position.abs() < 0.1 && self.velocity.abs() < 1. {
488 self.position = 0.;
489 self.velocity = 0.;
490 false
491 } else {
492 true
493 }
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use gpui::{
501 Context, InteractiveElement as _, ParentElement as _, Render, ScrollHandle,
502 StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, div,
503 };
504
505 struct ScrollTest {
506 handle: ScrollHandle,
507 enabled: bool,
508 }
509
510 impl Render for ScrollTest {
511 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
512 div().p(px(20.)).child(
513 ScrollBounce::new(
514 "bounce",
515 &self.handle,
516 div()
517 .id("viewport")
518 .w(px(200.))
519 .h(px(200.))
520 .overflow_y_scroll()
521 .track_scroll(&self.handle)
522 .child(div().h(px(600.)).w_full()),
523 )
524 .enabled(self.enabled),
525 )
526 }
527 }
528
529 fn draw(cx: &mut VisualTestContext) {
530 cx.update(|window, cx| window.draw(cx).clear(cx));
531 }
532
533 fn scroll(cx: &mut VisualTestContext, delta: f32, phase: TouchPhase) {
534 cx.simulate_event(ScrollWheelEvent {
535 position: point(px(100.), px(100.)),
536 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
537 touch_phase: phase,
538 ..Default::default()
539 });
540 }
541
542 struct ListTest(gpui::ListState);
543
544 impl Render for ListTest {
545 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
546 ScrollBounce::new(
547 "bounce-list",
548 &self.0,
549 gpui::list(self.0.clone(), |_, _, _| {
550 div().h(px(40.)).into_any_element()
551 })
552 .w(px(200.))
553 .h(px(200.)),
554 )
555 .enabled(true)
556 }
557 }
558
559 #[gpui::test]
560 fn list_reverse_drag_preserves_events_before_the_next_frame(cx: &mut TestAppContext) {
561 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
562 let (_, cx) = cx.add_window_view({
563 let handle = handle.clone();
564 move |_, _| ListTest(handle)
565 });
566 draw(cx);
567 scroll(cx, 100., TouchPhase::Started);
568 scroll(cx, -140., TouchPhase::Moved);
569 assert_eq!(handle.offset().y, px(-40.));
570 scroll(cx, -10., TouchPhase::Moved);
571 draw(cx);
572 assert_eq!(handle.offset().y, px(-50.));
573 }
574
575 #[gpui::test]
576 fn trackpad_release_between_frames_does_not_bounce_in_the_middle(cx: &mut TestAppContext) {
577 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
578 let (_, cx) = cx.add_window_view({
579 let handle = handle.clone();
580 move |_, _| ListTest(handle)
581 });
582 draw(cx);
583 handle.set_offset(point(px(0.), px(-400.)));
584 draw(cx);
585 let origin = handle.viewport_bounds().origin.y;
586 cx.update(|window, cx| {
589 for (delta, phase) in [(-30., TouchPhase::Started), (0., TouchPhase::Ended)] {
590 window.dispatch_event(
591 gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
592 position: point(px(100.), px(100.)),
593 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
594 touch_phase: phase,
595 ..Default::default()
596 }),
597 cx,
598 );
599 }
600 });
601 draw(cx);
602 assert_eq!(handle.viewport_bounds().origin.y, origin);
603 assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
604 }
605
606 #[gpui::test]
607 fn trackpad_direction_change_between_frames_does_not_bounce_in_the_middle(
608 cx: &mut TestAppContext,
609 ) {
610 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
611 let (_, cx) = cx.add_window_view({
612 let handle = handle.clone();
613 move |_, _| ListTest(handle)
614 });
615 draw(cx);
616 handle.set_offset(point(px(0.), px(-400.)));
617 draw(cx);
618 let origin = handle.viewport_bounds().origin.y;
619 cx.update(|window, cx| {
620 for (delta, phase) in [(-30., TouchPhase::Started), (5., TouchPhase::Moved)] {
621 window.dispatch_event(
622 gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
623 position: point(px(100.), px(100.)),
624 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
625 touch_phase: phase,
626 ..Default::default()
627 }),
628 cx,
629 );
630 }
631 });
632 draw(cx);
633 assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
634 assert_eq!(handle.viewport_bounds().origin.y, origin);
635 }
636
637 #[gpui::test]
638 fn list_stretches_only_the_distance_past_either_edge(cx: &mut TestAppContext) {
639 for (start, delta, end) in [(-30., 50., 0.), (-970., -50., -1000.)] {
640 let mut app = cx.new_app();
641 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
642 let (_, cx) = app.add_window_view({
643 let handle = handle.clone();
644 move |_, _| ListTest(handle)
645 });
646 draw(cx);
647 handle.set_offset(point(px(0.), px(start)));
648 draw(cx);
649 let origin = handle.viewport_bounds().origin.y;
650 scroll(cx, delta, TouchPhase::Started);
651 draw(cx);
652 assert_eq!(handle.offset().y, px(end));
653 let stretch = (handle.viewport_bounds().origin.y - origin).as_f32();
654 assert_eq!(stretch.signum(), delta.signum());
655 assert!(stretch.abs() > 0. && stretch.abs() < 20.);
658 }
659 }
660
661 #[gpui::test]
662 fn reverse_drag_consumes_stretch_before_scrolling_content(cx: &mut TestAppContext) {
663 let handle = ScrollHandle::new();
664 let (_, cx) = cx.add_window_view({
665 let handle = handle.clone();
666 move |_, _| ScrollTest {
667 handle,
668 enabled: true,
669 }
670 });
671 draw(cx);
672 let origin = handle.bounds().origin.y;
673 assert_eq!(origin, px(20.));
674 scroll(cx, 100., TouchPhase::Started);
675 draw(cx);
676 assert_eq!(handle.offset().y, px(0.));
677 assert!(handle.bounds().origin.y > origin);
678 scroll(cx, -140., TouchPhase::Moved);
679 draw(cx);
680 assert_eq!(handle.offset().y, px(-40.));
681 assert_eq!(handle.bounds().origin.y, origin);
682 }
683
684 #[gpui::test]
685 fn touch_release_ignores_momentum_until_a_new_touch_takes_over(cx: &mut TestAppContext) {
686 let handle = ScrollHandle::new();
687 let (_, cx) = cx.add_window_view({
688 let handle = handle.clone();
689 move |_, _| ScrollTest {
690 handle,
691 enabled: true,
692 }
693 });
694 draw(cx);
695 let origin = handle.bounds().origin.y;
696 scroll(cx, 100., TouchPhase::Started);
697 draw(cx);
698 assert!(handle.bounds().origin.y > origin);
699 scroll(cx, 0., TouchPhase::Ended);
700 draw(cx);
701 let released = handle.bounds().origin.y;
702 scroll(cx, -400., TouchPhase::Moved);
706 draw(cx);
707 assert_eq!(handle.offset().y, px(0.));
708 assert!(handle.bounds().origin.y <= released);
709 scroll(cx, 0., TouchPhase::Started);
711 scroll(cx, -250., TouchPhase::Moved);
712 draw(cx);
713 assert!(handle.offset().y < px(0.));
714 assert_eq!(handle.bounds().origin.y, origin);
715 }
716
717 #[gpui::test]
718 fn phaseless_wheel_scrolls_back_right_after_bouncing(cx: &mut TestAppContext) {
719 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
720 let (_, cx) = cx.add_window_view({
721 let handle = handle.clone();
722 move |_, _| ListTest(handle)
723 });
724 draw(cx);
725 handle.set_offset(point(px(0.), px(-1000.)));
726 draw(cx);
727 let origin = handle.viewport_bounds().origin.y;
728 scroll(cx, -50., TouchPhase::Moved);
730 draw(cx);
731 assert!(handle.viewport_bounds().origin.y < origin);
732 scroll(cx, 200., TouchPhase::Moved);
733 draw(cx);
734 assert!(handle.offset().y > px(-1000.));
735 assert_eq!(handle.viewport_bounds().origin.y, origin);
736 }
737
738 #[gpui::test]
739 fn phaseless_wheel_bounces_again_only_after_a_pause(cx: &mut TestAppContext) {
740 let handle = ScrollHandle::new();
741 let (_, cx) = cx.add_window_view({
742 let handle = handle.clone();
743 move |_, _| ScrollTest {
744 handle,
745 enabled: true,
746 }
747 });
748 draw(cx);
749 scroll(cx, 50., TouchPhase::Moved);
750 draw(cx);
751 let bounced = handle.bounds().origin.y;
752 scroll(cx, 50., TouchPhase::Moved);
755 draw(cx);
756 assert!(handle.bounds().origin.y <= bounced);
757 cx.executor().advance_clock(MOMENTUM_GAP);
758 scroll(cx, 50., TouchPhase::Moved);
759 draw(cx);
760 assert!(handle.bounds().origin.y > bounced);
761 assert_eq!(handle.offset().y, px(0.));
762 }
763
764 #[gpui::test]
765 fn tiny_drag_catching_momentum_does_not_start_a_reverse_fling(cx: &mut TestAppContext) {
766 let handle = ScrollHandle::new();
767 let (_, cx) = cx.add_window_view({
768 let handle = handle.clone();
769 move |_, _| ScrollTest {
770 handle,
771 enabled: true,
772 }
773 });
774 draw(cx);
775 handle.set_offset(point(px(0.), px(-200.)));
776 draw(cx);
777
778 scroll(cx, -60., TouchPhase::Started);
781 scroll(cx, 0., TouchPhase::Ended);
782 draw(cx);
783 scroll(cx, -100., TouchPhase::Moved);
784 draw(cx);
785 scroll(cx, 0., TouchPhase::Ended);
786 draw(cx);
787 let before_catch = handle.offset().y;
788 assert!(before_catch < px(0.));
789 scroll(cx, 0., TouchPhase::Started);
790 scroll(cx, 8., TouchPhase::Moved);
791 scroll(cx, 0., TouchPhase::Ended);
792 draw(cx);
793 let stopped = handle.offset().y;
794 assert_eq!(stopped, before_catch + px(8.));
795
796 scroll(cx, 100., TouchPhase::Moved);
799 draw(cx);
800 assert_eq!(handle.offset().y, stopped);
801
802 scroll(cx, -20., TouchPhase::Started);
804 scroll(cx, 0., TouchPhase::Ended);
805 scroll(cx, -10., TouchPhase::Moved);
806 draw(cx);
807 assert_eq!(handle.offset().y, stopped - px(30.));
808 }
809
810 #[gpui::test]
811 fn deliberate_drag_after_catching_momentum_can_fling(cx: &mut TestAppContext) {
812 let handle = ScrollHandle::new();
813 let (_, cx) = cx.add_window_view({
814 let handle = handle.clone();
815 move |_, _| ScrollTest {
816 handle,
817 enabled: true,
818 }
819 });
820 draw(cx);
821 handle.set_offset(point(px(0.), px(-200.)));
822 draw(cx);
823
824 scroll(cx, 0., TouchPhase::Started);
825 scroll(cx, 24., TouchPhase::Moved);
826 scroll(cx, 0., TouchPhase::Ended);
827 draw(cx);
828 assert_eq!(handle.offset().y, px(-176.));
829 scroll(cx, 40., TouchPhase::Moved);
830 draw(cx);
831 assert_eq!(handle.offset().y, px(-136.));
832 }
833
834 #[gpui::test]
835 fn short_catch_release_still_stretches_past_the_edge(cx: &mut TestAppContext) {
836 let handle = ScrollHandle::new();
837 let (_, cx) = cx.add_window_view({
838 let handle = handle.clone();
839 move |_, _| ScrollTest {
840 handle,
841 enabled: true,
842 }
843 });
844 draw(cx);
845 handle.set_offset(point(px(0.), px(-4.)));
846 draw(cx);
847 let origin = handle.bounds().origin.y;
848
849 scroll(cx, 0., TouchPhase::Started);
850 scroll(cx, 8., TouchPhase::Ended);
851 draw(cx);
852 assert_eq!(handle.offset().y, px(0.));
853 assert!(handle.bounds().origin.y > origin);
854 }
855
856 #[gpui::test]
857 fn disabled_and_reduced_motion_leave_the_viewport_fixed(cx: &mut TestAppContext) {
858 for enabled in [false, true] {
859 let mut app = cx.new_app();
860 if enabled {
861 app.update(|cx| cx.set_reduce_motion(true));
862 }
863 let handle = ScrollHandle::new();
864 let (_, cx) = app.add_window_view({
865 let handle = handle.clone();
866 move |_, _| ScrollTest { handle, enabled }
867 });
868 draw(cx);
869 let origin = handle.bounds().origin.y;
870 scroll(cx, 100., TouchPhase::Started);
871 draw(cx);
872 assert_eq!(handle.bounds().origin.y, origin);
873 scroll(cx, -40., TouchPhase::Moved);
874 draw(cx);
875 assert_eq!(handle.offset().y, px(-40.));
876 }
877 }
878
879 #[test]
880 fn resistance_and_reverse_preserve_unconsumed_distance() {
881 let mut scroll = Physics::default();
882 scroll.begin(600.);
883 assert_eq!(scroll.pull(100.), 0.);
884 assert!(scroll.offset() > 0. && scroll.offset() < 55.);
885 assert_eq!(scroll.pull(-130.), -30.);
886 assert_eq!(scroll.offset(), 0.);
887 }
888
889 #[gpui::test]
890 fn diagonal_wobble_stays_with_the_stretch(cx: &mut TestAppContext) {
891 let handle = ScrollHandle::new();
892 let (_, cx) = cx.add_window_view({
893 let handle = handle.clone();
894 move |_, _| ScrollTest {
895 handle,
896 enabled: true,
897 }
898 });
899 draw(cx);
900 let origin = handle.bounds().origin.y;
901 scroll(cx, 100., TouchPhase::Started);
902 draw(cx);
903 let stretched = handle.bounds().origin.y;
904 assert!(stretched > origin);
905 cx.update(|window, cx| {
909 for delta in [point(px(30.), px(-20.)), point(px(0.), px(-20.))] {
910 window.dispatch_event(
911 gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
912 position: point(px(100.), px(100.)),
913 delta: ScrollDelta::Pixels(delta),
914 touch_phase: TouchPhase::Moved,
915 ..Default::default()
916 }),
917 cx,
918 );
919 }
920 });
921 draw(cx);
922 assert!(handle.bounds().origin.y < stretched);
924 assert!(handle.bounds().origin.y > origin);
925 assert_eq!(handle.offset(), point(px(0.), px(0.)));
926 }
927
928 #[test]
929 fn motion_builder_configures_tracking_and_response() {
930 let motion = ScrollBounceMotion::default()
931 .with_tracking(0.4)
932 .with_response(Duration::from_millis(300));
933 assert_eq!(motion.tracking(), 0.4);
934 assert_eq!(motion.response(), Duration::from_millis(300));
935 }
936
937 #[test]
938 fn tracking_scales_the_first_stretch() {
939 let stretch = |tracking: f32| {
940 let mut scroll = Physics {
941 motion: ScrollBounceMotion::default().with_tracking(tracking),
942 ..Physics::default()
943 };
944 scroll.begin(600.);
945 scroll.pull(100.);
946 scroll.offset()
947 };
948 assert!(stretch(0.3) < stretch(0.55));
949 assert!(stretch(0.55) < stretch(0.8));
950 }
951
952 #[test]
953 fn response_scales_the_return_and_zero_snaps() {
954 let remaining = |response: Duration| {
955 let mut scroll = Physics {
956 motion: ScrollBounceMotion::default().with_response(response),
957 ..Physics::default()
958 };
959 scroll.begin(600.);
960 scroll.pull(180.);
961 scroll.release();
962 scroll.step(0.25);
963 scroll.offset()
964 };
965 assert!(remaining(Duration::from_secs(1)) > remaining(Duration::from_millis(524)));
966 assert!(remaining(Duration::from_millis(524)) > remaining(Duration::from_millis(200)));
967 assert_eq!(remaining(Duration::ZERO), 0.);
968 }
969
970 #[test]
971 fn regrabbing_a_displaced_edge_keeps_its_extent() {
972 let mut scroll = Physics::default();
973 scroll.begin(600.);
974 scroll.pull(-150.);
975 scroll.release();
976 scroll.step(0.08);
977 let before = scroll.offset();
978 scroll.begin(40.);
981 assert!((scroll.offset() - before).abs() < 0.001);
982 scroll.pull(10.);
984 assert!(scroll.offset() > before);
985 assert!(scroll.offset() < before + 10.);
986 }
987
988 #[test]
989 fn a_gesture_from_rest_adopts_the_current_extent() {
990 let mut scroll = Physics::default();
991 scroll.begin(600.);
992 scroll.begin(40.);
993 scroll.pull(-100.);
994 assert!(scroll.offset() > -40.);
996 }
997
998 #[test]
999 fn grabbing_the_spring_does_not_jump() {
1000 let mut scroll = Physics::default();
1001 scroll.begin(600.);
1002 scroll.pull(-150.);
1003 scroll.release();
1004 scroll.step(0.08);
1005 let before = scroll.offset();
1006 scroll.begin(600.);
1007 assert!((scroll.offset() - before).abs() < 0.001);
1008 let held = scroll.offset();
1009 assert!(!scroll.step(0.1));
1010 assert_eq!(scroll.offset(), held);
1011 }
1012
1013 #[test]
1014 fn spring_has_the_same_trajectory_at_60_and_120_hz() {
1015 let at = |hz: usize| {
1016 let mut scroll = Physics::default();
1017 scroll.begin(600.);
1018 scroll.pull(180.);
1019 scroll.release();
1020 for _ in 0..hz / 4 {
1021 scroll.step(1. / hz as f32);
1022 }
1023 scroll.offset()
1024 };
1025 assert!((at(60) - at(120)).abs() < 0.001);
1026 }
1027
1028 #[test]
1029 fn return_keeps_a_visible_tail_after_a_quarter_second() {
1030 let mut scroll = Physics {
1031 position: 100.,
1032 ..Physics::default()
1033 };
1034 assert!(scroll.step(0.25));
1035 assert!(scroll.offset() > 10. && scroll.offset() < 30.);
1038 assert!(scroll.velocity < 0.);
1039 }
1040
1041 #[test]
1042 fn spring_settles_without_crossing_the_boundary() {
1043 let mut scroll = Physics::default();
1044 scroll.begin(600.);
1045 scroll.pull(-200.);
1046 scroll.release();
1047 let mut previous = scroll.offset();
1048 for _ in 0..120 {
1049 scroll.step(1. / 120.);
1050 assert!(scroll.offset() >= previous && scroll.offset() <= 0.);
1051 previous = scroll.offset();
1052 }
1053 assert_eq!(scroll.offset(), 0.);
1054 assert!(scroll.suppress_momentum);
1055 scroll.begin(600.);
1056 assert!(!scroll.suppress_momentum);
1057 }
1058}