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}
148
149const CATCH_DRAG_SLOP: f32 = 8.;
153
154fn max_scroll_extent(handle: &dyn ScrollbarHandle) -> Pixels {
158 (handle.content_size().height - handle.viewport_bounds().size.height).max(px(0.))
159}
160
161#[doc(hidden)]
162pub struct ScrollBouncePrepaintState {
163 state: Rc<RefCell<State>>,
164 hitbox: Hitbox,
165}
166
167impl IntoElement for ScrollBounce {
168 type Element = Self;
169 fn into_element(self) -> Self {
170 self
171 }
172}
173
174impl Element for ScrollBounce {
175 type RequestLayoutState = ();
176 type PrepaintState = ScrollBouncePrepaintState;
177
178 fn id(&self) -> Option<ElementId> {
179 Some(self.id.clone())
180 }
181 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
182 None
183 }
184
185 fn request_layout(
186 &mut self,
187 _: Option<&GlobalElementId>,
188 _: Option<&InspectorElementId>,
189 window: &mut Window,
190 cx: &mut App,
191 ) -> (LayoutId, ()) {
192 (self.child.request_layout(window, cx), ())
193 }
194
195 fn prepaint(
196 &mut self,
197 id: Option<&GlobalElementId>,
198 _: Option<&InspectorElementId>,
199 bounds: Bounds<Pixels>,
200 _: &mut (),
201 window: &mut Window,
202 cx: &mut App,
203 ) -> Self::PrepaintState {
204 let state = window.with_element_state(
205 id.expect("ScrollBounce has an id"),
206 |state: Option<Rc<RefCell<State>>>, _| {
207 let state = state.unwrap_or_default();
208 (state.clone(), state)
209 },
210 );
211 let offset = {
212 let mut state = state.borrow_mut();
213 if !self.enabled || cx.reduce_motion() {
214 *state = State::default();
215 }
216 state.physics.motion = self.motion;
217 let now = Instant::now();
218 let elapsed = state
219 .sampled_at
220 .replace(now)
221 .map_or(0., |at| now.duration_since(at).as_secs_f32());
222 if state.physics.step(elapsed) {
223 window.request_animation_frame();
224 }
225 state.physics.offset()
226 };
227 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
228 window.with_content_mask(Some(ContentMask { bounds }), |window| {
229 window.with_element_offset(point(px(0.), px(offset)), |window| {
230 self.child.prepaint(window, cx);
231 });
232 });
233 ScrollBouncePrepaintState { state, hitbox }
234 }
235
236 fn paint(
237 &mut self,
238 _: Option<&GlobalElementId>,
239 _: Option<&InspectorElementId>,
240 bounds: Bounds<Pixels>,
241 _: &mut (),
242 prepaint: &mut Self::PrepaintState,
243 window: &mut Window,
244 cx: &mut App,
245 ) {
246 if self.enabled && !cx.reduce_motion() {
247 let state = prepaint.state.clone();
248 let hitbox = prepaint.hitbox.id;
249 let handle = self.handle.clone();
250 let view = window.current_view();
251 let on_scroll = self.on_scroll.clone();
252 let mut before = 0.;
253 let mut allow_end_bounce = false;
254 window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
255 let ScrollDelta::Pixels(mut delta) = event.delta else {
256 return;
257 };
258 if !hitbox.should_handle_scroll(window) {
259 return;
260 }
261 let mut state = state.borrow_mut();
262 state
267 .ongoing_scroll
268 .lock_axis(&mut delta, event.touch_phase);
269 if delta.x.abs() > delta.y.abs() {
270 return;
271 }
272 let ended = matches!(event.touch_phase, TouchPhase::Ended | TouchPhase::Cancelled);
273 let mut scrolled = false;
274 let mut changed = false;
275 if phase == DispatchPhase::Capture {
276 before = handle.offset().y.as_f32();
277 if event.touch_phase == TouchPhase::Started {
278 state.short_drag_distance = (delta.y == px(0.)).then_some(0.);
279 state.physics.begin(bounds.size.height.as_f32());
280 } else if let Some(distance) = state.short_drag_distance.as_mut() {
281 *distance += delta.y.as_f32().abs();
282 if *distance > CATCH_DRAG_SLOP {
283 state.short_drag_distance = None;
284 }
285 }
286 let suppress_short_drag_momentum = if ended {
287 state.short_drag_distance.take().is_some()
288 && event.touch_phase == TouchPhase::Ended
289 } else {
290 false
291 };
292 allow_end_bounce = suppress_short_drag_momentum;
295 if state.physics.suppress_momentum {
296 cx.stop_propagation();
297 return;
298 }
299 if state.physics.offset() != 0. {
300 let remainder = state.physics.pull(delta.y.as_f32());
301 if remainder != 0. {
302 let max = max_scroll_extent(handle.as_ref());
303 let mut offset = handle.offset();
304 offset.y = px(before + remainder).clamp(-max, px(0.));
305 handle.set_offset(offset);
306 scrolled = true;
307 }
308 if ended {
309 state.physics.release();
310 }
311 changed = true;
312 cx.stop_propagation();
313 } else if ended {
314 state.physics.release();
315 }
316 if suppress_short_drag_momentum {
317 state.physics.suppress_momentum = true;
318 }
319 } else {
320 let mut offset = handle.offset();
324 let max = max_scroll_extent(handle.as_ref());
325 let clamped = offset.y.clamp(-max, px(0.));
326 if clamped != offset.y {
327 offset.y = clamped;
328 handle.set_offset(offset);
329 }
330 let after = offset.y.as_f32();
331 let requested = delta.y.as_f32();
332 let at_outward_edge = (requested > 0. && offset.y == px(0.))
337 || (requested < 0. && offset.y == -max);
338 let residual =
339 (requested - (after - before)).clamp(requested.min(0.), requested.max(0.));
340 if at_outward_edge
341 && residual.abs() > 0.01
342 && (!state.physics.suppress_momentum || allow_end_bounce)
343 {
344 let dragging = state.physics.dragging;
345 if !dragging {
346 state.physics.begin(bounds.size.height.as_f32());
347 }
348 state.physics.pull(residual);
349 if !dragging || ended {
350 state.physics.release();
351 }
352 changed = true;
353 }
354 }
355 if changed {
356 state.sampled_at = Some(Instant::now());
357 }
358 drop(state);
359 if changed {
360 cx.notify(view);
361 }
362 if scrolled && let Some(handler) = &on_scroll {
363 handler(window, cx);
364 }
365 });
366 }
367 window.with_content_mask(Some(ContentMask { bounds }), |window| {
368 self.child.paint(window, cx)
369 });
370 }
371}
372
373#[derive(Default)]
374struct Physics {
375 position: f32,
376 velocity: f32,
377 dragging: bool,
378 suppress_momentum: bool,
379 extent: f32,
380 motion: ScrollBounceMotion,
381}
382
383impl Physics {
384 fn offset(&self) -> f32 {
385 if self.dragging {
386 let d = self.extent.max(1.);
387 let tracking = self.motion.tracking;
388 self.position * tracking / (1. + tracking * self.position.abs() / d)
389 } else {
390 self.position
391 }
392 }
393
394 fn begin(&mut self, extent: f32) {
395 let offset = self.offset();
396 if offset == 0. {
401 self.extent = extent.max(1.);
402 }
403 let tracking = self.motion.tracking;
405 self.position = offset / (tracking * (1. - offset.abs() / self.extent).max(0.01));
406 self.velocity = 0.;
407 self.dragging = true;
408 self.suppress_momentum = false;
409 }
410
411 fn pull(&mut self, delta: f32) -> f32 {
413 let previous = self.position;
414 let next = previous + delta;
415 if previous != 0. && previous.signum() != next.signum() {
416 self.position = 0.;
417 next
418 } else {
419 self.position = next;
420 0.
421 }
422 }
423
424 fn release(&mut self) {
425 self.position = self.offset();
426 self.dragging = false;
427 if self.position != 0. {
428 self.suppress_momentum = true;
429 }
430 }
431
432 fn step(&mut self, seconds: f32) -> bool {
434 if self.dragging || self.position == 0. {
435 return false;
436 }
437 let Some(omega) = self.motion.omega() else {
438 self.position = 0.;
439 self.velocity = 0.;
440 return false;
441 };
442 let decay = (-omega * seconds).exp();
443 let c = self.velocity + omega * self.position;
444 self.position = (self.position + c * seconds) * decay;
445 self.velocity = (self.velocity - omega * c * seconds) * decay;
446 if self.position.abs() < 0.1 && self.velocity.abs() < 1. {
447 self.position = 0.;
448 self.velocity = 0.;
449 false
450 } else {
451 true
452 }
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use gpui::{
460 Context, InteractiveElement as _, ParentElement as _, Render, ScrollHandle,
461 StatefulInteractiveElement as _, Styled as _, TestAppContext, VisualTestContext, div,
462 };
463
464 struct ScrollTest {
465 handle: ScrollHandle,
466 enabled: bool,
467 }
468
469 impl Render for ScrollTest {
470 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
471 div().p(px(20.)).child(
472 ScrollBounce::new(
473 "bounce",
474 &self.handle,
475 div()
476 .id("viewport")
477 .w(px(200.))
478 .h(px(200.))
479 .overflow_y_scroll()
480 .track_scroll(&self.handle)
481 .child(div().h(px(600.)).w_full()),
482 )
483 .enabled(self.enabled),
484 )
485 }
486 }
487
488 fn draw(cx: &mut VisualTestContext) {
489 cx.update(|window, cx| window.draw(cx).clear(cx));
490 }
491
492 fn scroll(cx: &mut VisualTestContext, delta: f32, phase: TouchPhase) {
493 cx.simulate_event(ScrollWheelEvent {
494 position: point(px(100.), px(100.)),
495 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
496 touch_phase: phase,
497 ..Default::default()
498 });
499 }
500
501 struct ListTest(gpui::ListState);
502
503 impl Render for ListTest {
504 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
505 ScrollBounce::new(
506 "bounce-list",
507 &self.0,
508 gpui::list(self.0.clone(), |_, _, _| {
509 div().h(px(40.)).into_any_element()
510 })
511 .w(px(200.))
512 .h(px(200.)),
513 )
514 .enabled(true)
515 }
516 }
517
518 #[gpui::test]
519 fn list_reverse_drag_preserves_events_before_the_next_frame(cx: &mut TestAppContext) {
520 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
521 let (_, cx) = cx.add_window_view({
522 let handle = handle.clone();
523 move |_, _| ListTest(handle)
524 });
525 draw(cx);
526 scroll(cx, 100., TouchPhase::Started);
527 scroll(cx, -140., TouchPhase::Moved);
528 assert_eq!(handle.offset().y, px(-40.));
529 scroll(cx, -10., TouchPhase::Moved);
530 draw(cx);
531 assert_eq!(handle.offset().y, px(-50.));
532 }
533
534 #[gpui::test]
535 fn trackpad_release_between_frames_does_not_bounce_in_the_middle(cx: &mut TestAppContext) {
536 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
537 let (_, cx) = cx.add_window_view({
538 let handle = handle.clone();
539 move |_, _| ListTest(handle)
540 });
541 draw(cx);
542 handle.set_offset(point(px(0.), px(-400.)));
543 draw(cx);
544 let origin = handle.viewport_bounds().origin.y;
545 cx.update(|window, cx| {
548 for (delta, phase) in [(-30., TouchPhase::Started), (0., TouchPhase::Ended)] {
549 window.dispatch_event(
550 gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
551 position: point(px(100.), px(100.)),
552 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
553 touch_phase: phase,
554 ..Default::default()
555 }),
556 cx,
557 );
558 }
559 });
560 draw(cx);
561 assert_eq!(handle.viewport_bounds().origin.y, origin);
562 assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
563 }
564
565 #[gpui::test]
566 fn trackpad_direction_change_between_frames_does_not_bounce_in_the_middle(
567 cx: &mut TestAppContext,
568 ) {
569 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
570 let (_, cx) = cx.add_window_view({
571 let handle = handle.clone();
572 move |_, _| ListTest(handle)
573 });
574 draw(cx);
575 handle.set_offset(point(px(0.), px(-400.)));
576 draw(cx);
577 let origin = handle.viewport_bounds().origin.y;
578 cx.update(|window, cx| {
579 for (delta, phase) in [(-30., TouchPhase::Started), (5., TouchPhase::Moved)] {
580 window.dispatch_event(
581 gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
582 position: point(px(100.), px(100.)),
583 delta: ScrollDelta::Pixels(point(px(0.), px(delta))),
584 touch_phase: phase,
585 ..Default::default()
586 }),
587 cx,
588 );
589 }
590 });
591 draw(cx);
592 assert!(handle.offset().y < px(-300.) && handle.offset().y > px(-500.));
593 assert_eq!(handle.viewport_bounds().origin.y, origin);
594 }
595
596 #[gpui::test]
597 fn list_stretches_only_the_distance_past_either_edge(cx: &mut TestAppContext) {
598 for (start, delta, end) in [(-30., 50., 0.), (-970., -50., -1000.)] {
599 let mut app = cx.new_app();
600 let handle = gpui::ListState::new(30, gpui::ListAlignment::Top, px(0.)).measure_all();
601 let (_, cx) = app.add_window_view({
602 let handle = handle.clone();
603 move |_, _| ListTest(handle)
604 });
605 draw(cx);
606 handle.set_offset(point(px(0.), px(start)));
607 draw(cx);
608 let origin = handle.viewport_bounds().origin.y;
609 scroll(cx, delta, TouchPhase::Started);
610 draw(cx);
611 assert_eq!(handle.offset().y, px(end));
612 let stretch = (handle.viewport_bounds().origin.y - origin).as_f32();
613 assert_eq!(stretch.signum(), delta.signum());
614 assert!(stretch.abs() > 0. && stretch.abs() < 20.);
617 }
618 }
619
620 #[gpui::test]
621 fn reverse_drag_consumes_stretch_before_scrolling_content(cx: &mut TestAppContext) {
622 let handle = ScrollHandle::new();
623 let (_, cx) = cx.add_window_view({
624 let handle = handle.clone();
625 move |_, _| ScrollTest {
626 handle,
627 enabled: true,
628 }
629 });
630 draw(cx);
631 let origin = handle.bounds().origin.y;
632 assert_eq!(origin, px(20.));
633 scroll(cx, 100., TouchPhase::Started);
634 draw(cx);
635 assert_eq!(handle.offset().y, px(0.));
636 assert!(handle.bounds().origin.y > origin);
637 scroll(cx, -140., TouchPhase::Moved);
638 draw(cx);
639 assert_eq!(handle.offset().y, px(-40.));
640 assert_eq!(handle.bounds().origin.y, origin);
641 }
642
643 #[gpui::test]
644 fn touch_release_ignores_momentum_until_a_new_touch_takes_over(cx: &mut TestAppContext) {
645 let handle = ScrollHandle::new();
646 let (_, cx) = cx.add_window_view({
647 let handle = handle.clone();
648 move |_, _| ScrollTest {
649 handle,
650 enabled: true,
651 }
652 });
653 draw(cx);
654 let origin = handle.bounds().origin.y;
655 scroll(cx, 100., TouchPhase::Started);
656 draw(cx);
657 assert!(handle.bounds().origin.y > origin);
658 scroll(cx, 0., TouchPhase::Ended);
659 draw(cx);
660 let released = handle.bounds().origin.y;
661 scroll(cx, -400., TouchPhase::Moved);
665 draw(cx);
666 assert_eq!(handle.offset().y, px(0.));
667 assert!(handle.bounds().origin.y <= released);
668 scroll(cx, 0., TouchPhase::Started);
670 scroll(cx, -250., TouchPhase::Moved);
671 draw(cx);
672 assert!(handle.offset().y < px(0.));
673 assert_eq!(handle.bounds().origin.y, origin);
674 }
675
676 #[gpui::test]
677 fn tiny_drag_catching_momentum_does_not_start_a_reverse_fling(cx: &mut TestAppContext) {
678 let handle = ScrollHandle::new();
679 let (_, cx) = cx.add_window_view({
680 let handle = handle.clone();
681 move |_, _| ScrollTest {
682 handle,
683 enabled: true,
684 }
685 });
686 draw(cx);
687 handle.set_offset(point(px(0.), px(-200.)));
688 draw(cx);
689
690 scroll(cx, -60., TouchPhase::Started);
693 scroll(cx, 0., TouchPhase::Ended);
694 draw(cx);
695 scroll(cx, -100., TouchPhase::Moved);
696 draw(cx);
697 scroll(cx, 0., TouchPhase::Ended);
698 draw(cx);
699 let before_catch = handle.offset().y;
700 assert!(before_catch < px(0.));
701 scroll(cx, 0., TouchPhase::Started);
702 scroll(cx, 8., TouchPhase::Moved);
703 scroll(cx, 0., TouchPhase::Ended);
704 draw(cx);
705 let stopped = handle.offset().y;
706 assert_eq!(stopped, before_catch + px(8.));
707
708 scroll(cx, 100., TouchPhase::Moved);
711 draw(cx);
712 assert_eq!(handle.offset().y, stopped);
713
714 scroll(cx, -20., TouchPhase::Started);
716 scroll(cx, 0., TouchPhase::Ended);
717 scroll(cx, -10., TouchPhase::Moved);
718 draw(cx);
719 assert_eq!(handle.offset().y, stopped - px(30.));
720 }
721
722 #[gpui::test]
723 fn deliberate_drag_after_catching_momentum_can_fling(cx: &mut TestAppContext) {
724 let handle = ScrollHandle::new();
725 let (_, cx) = cx.add_window_view({
726 let handle = handle.clone();
727 move |_, _| ScrollTest {
728 handle,
729 enabled: true,
730 }
731 });
732 draw(cx);
733 handle.set_offset(point(px(0.), px(-200.)));
734 draw(cx);
735
736 scroll(cx, 0., TouchPhase::Started);
737 scroll(cx, 24., TouchPhase::Moved);
738 scroll(cx, 0., TouchPhase::Ended);
739 draw(cx);
740 assert_eq!(handle.offset().y, px(-176.));
741 scroll(cx, 40., TouchPhase::Moved);
742 draw(cx);
743 assert_eq!(handle.offset().y, px(-136.));
744 }
745
746 #[gpui::test]
747 fn short_catch_release_still_stretches_past_the_edge(cx: &mut TestAppContext) {
748 let handle = ScrollHandle::new();
749 let (_, cx) = cx.add_window_view({
750 let handle = handle.clone();
751 move |_, _| ScrollTest {
752 handle,
753 enabled: true,
754 }
755 });
756 draw(cx);
757 handle.set_offset(point(px(0.), px(-4.)));
758 draw(cx);
759 let origin = handle.bounds().origin.y;
760
761 scroll(cx, 0., TouchPhase::Started);
762 scroll(cx, 8., TouchPhase::Ended);
763 draw(cx);
764 assert_eq!(handle.offset().y, px(0.));
765 assert!(handle.bounds().origin.y > origin);
766 }
767
768 #[gpui::test]
769 fn disabled_and_reduced_motion_leave_the_viewport_fixed(cx: &mut TestAppContext) {
770 for enabled in [false, true] {
771 let mut app = cx.new_app();
772 if enabled {
773 app.update(|cx| cx.set_reduce_motion(true));
774 }
775 let handle = ScrollHandle::new();
776 let (_, cx) = app.add_window_view({
777 let handle = handle.clone();
778 move |_, _| ScrollTest { handle, enabled }
779 });
780 draw(cx);
781 let origin = handle.bounds().origin.y;
782 scroll(cx, 100., TouchPhase::Started);
783 draw(cx);
784 assert_eq!(handle.bounds().origin.y, origin);
785 scroll(cx, -40., TouchPhase::Moved);
786 draw(cx);
787 assert_eq!(handle.offset().y, px(-40.));
788 }
789 }
790
791 #[test]
792 fn resistance_and_reverse_preserve_unconsumed_distance() {
793 let mut scroll = Physics::default();
794 scroll.begin(600.);
795 assert_eq!(scroll.pull(100.), 0.);
796 assert!(scroll.offset() > 0. && scroll.offset() < 55.);
797 assert_eq!(scroll.pull(-130.), -30.);
798 assert_eq!(scroll.offset(), 0.);
799 }
800
801 #[gpui::test]
802 fn diagonal_wobble_stays_with_the_stretch(cx: &mut TestAppContext) {
803 let handle = ScrollHandle::new();
804 let (_, cx) = cx.add_window_view({
805 let handle = handle.clone();
806 move |_, _| ScrollTest {
807 handle,
808 enabled: true,
809 }
810 });
811 draw(cx);
812 let origin = handle.bounds().origin.y;
813 scroll(cx, 100., TouchPhase::Started);
814 draw(cx);
815 let stretched = handle.bounds().origin.y;
816 assert!(stretched > origin);
817 cx.update(|window, cx| {
821 for delta in [point(px(30.), px(-20.)), point(px(0.), px(-20.))] {
822 window.dispatch_event(
823 gpui::PlatformInput::ScrollWheel(ScrollWheelEvent {
824 position: point(px(100.), px(100.)),
825 delta: ScrollDelta::Pixels(delta),
826 touch_phase: TouchPhase::Moved,
827 ..Default::default()
828 }),
829 cx,
830 );
831 }
832 });
833 draw(cx);
834 assert!(handle.bounds().origin.y < stretched);
836 assert!(handle.bounds().origin.y > origin);
837 assert_eq!(handle.offset(), point(px(0.), px(0.)));
838 }
839
840 #[test]
841 fn motion_builder_configures_tracking_and_response() {
842 let motion = ScrollBounceMotion::default()
843 .with_tracking(0.4)
844 .with_response(Duration::from_millis(300));
845 assert_eq!(motion.tracking(), 0.4);
846 assert_eq!(motion.response(), Duration::from_millis(300));
847 }
848
849 #[test]
850 fn tracking_scales_the_first_stretch() {
851 let stretch = |tracking: f32| {
852 let mut scroll = Physics {
853 motion: ScrollBounceMotion::default().with_tracking(tracking),
854 ..Physics::default()
855 };
856 scroll.begin(600.);
857 scroll.pull(100.);
858 scroll.offset()
859 };
860 assert!(stretch(0.3) < stretch(0.55));
861 assert!(stretch(0.55) < stretch(0.8));
862 }
863
864 #[test]
865 fn response_scales_the_return_and_zero_snaps() {
866 let remaining = |response: Duration| {
867 let mut scroll = Physics {
868 motion: ScrollBounceMotion::default().with_response(response),
869 ..Physics::default()
870 };
871 scroll.begin(600.);
872 scroll.pull(180.);
873 scroll.release();
874 scroll.step(0.25);
875 scroll.offset()
876 };
877 assert!(remaining(Duration::from_secs(1)) > remaining(Duration::from_millis(524)));
878 assert!(remaining(Duration::from_millis(524)) > remaining(Duration::from_millis(200)));
879 assert_eq!(remaining(Duration::ZERO), 0.);
880 }
881
882 #[test]
883 fn regrabbing_a_displaced_edge_keeps_its_extent() {
884 let mut scroll = Physics::default();
885 scroll.begin(600.);
886 scroll.pull(-150.);
887 scroll.release();
888 scroll.step(0.08);
889 let before = scroll.offset();
890 scroll.begin(40.);
893 assert!((scroll.offset() - before).abs() < 0.001);
894 scroll.pull(10.);
896 assert!(scroll.offset() > before);
897 assert!(scroll.offset() < before + 10.);
898 }
899
900 #[test]
901 fn a_gesture_from_rest_adopts_the_current_extent() {
902 let mut scroll = Physics::default();
903 scroll.begin(600.);
904 scroll.begin(40.);
905 scroll.pull(-100.);
906 assert!(scroll.offset() > -40.);
908 }
909
910 #[test]
911 fn grabbing_the_spring_does_not_jump() {
912 let mut scroll = Physics::default();
913 scroll.begin(600.);
914 scroll.pull(-150.);
915 scroll.release();
916 scroll.step(0.08);
917 let before = scroll.offset();
918 scroll.begin(600.);
919 assert!((scroll.offset() - before).abs() < 0.001);
920 let held = scroll.offset();
921 assert!(!scroll.step(0.1));
922 assert_eq!(scroll.offset(), held);
923 }
924
925 #[test]
926 fn spring_has_the_same_trajectory_at_60_and_120_hz() {
927 let at = |hz: usize| {
928 let mut scroll = Physics::default();
929 scroll.begin(600.);
930 scroll.pull(180.);
931 scroll.release();
932 for _ in 0..hz / 4 {
933 scroll.step(1. / hz as f32);
934 }
935 scroll.offset()
936 };
937 assert!((at(60) - at(120)).abs() < 0.001);
938 }
939
940 #[test]
941 fn return_keeps_a_visible_tail_after_a_quarter_second() {
942 let mut scroll = Physics {
943 position: 100.,
944 ..Physics::default()
945 };
946 assert!(scroll.step(0.25));
947 assert!(scroll.offset() > 10. && scroll.offset() < 30.);
950 assert!(scroll.velocity < 0.);
951 }
952
953 #[test]
954 fn spring_settles_without_crossing_the_boundary() {
955 let mut scroll = Physics::default();
956 scroll.begin(600.);
957 scroll.pull(-200.);
958 scroll.release();
959 let mut previous = scroll.offset();
960 for _ in 0..120 {
961 scroll.step(1. / 120.);
962 assert!(scroll.offset() >= previous && scroll.offset() <= 0.);
963 previous = scroll.offset();
964 }
965 assert_eq!(scroll.offset(), 0.);
966 assert!(scroll.suppress_momentum);
967 scroll.begin(600.);
968 assert!(!scroll.suppress_momentum);
969 }
970}