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