1use alloc::collections::BTreeMap;
37
38use azul_core::{
39 callbacks::{TimerCallbackReturn, Update},
40 dom::DomId,
41 geom::LogicalPosition,
42 refany::RefAny,
43 styled_dom::NodeHierarchyItemId,
44 task::TerminateTimer,
45};
46
47use crate::{
48 managers::scroll_state::{ScrollInput, ScrollInputQueue, ScrollInputSource, ScrollNodeInfo},
49 timer::TimerCallbackInfo,
50};
51
52use azul_css::props::style::scrollbar::{ScrollPhysics, OverflowScrolling, OverscrollBehavior};
53
54const MAX_SCROLL_EVENTS_PER_TICK: usize = 100;
58
59const ASSUMED_FPS: f32 = 60.0;
62
63#[derive(Debug)]
68pub struct ScrollPhysicsState {
69 pub input_queue: ScrollInputQueue,
71 pub node_velocities: BTreeMap<(DomId, NodeId), NodeScrollPhysics>,
73 pub pending_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
75 pub pending_trackpad_positions: BTreeMap<(DomId, NodeId), LogicalPosition>,
77 pub scroll_physics: ScrollPhysics,
79}
80
81use azul_core::id::NodeId;
83
84#[derive(Copy, Debug, Clone, Default)]
86pub struct NodeScrollPhysics {
87 pub velocity: LogicalPosition,
89 pub is_rubber_banding: bool,
91}
92
93impl ScrollPhysicsState {
94 #[must_use] pub const fn new(input_queue: ScrollInputQueue, scroll_physics: ScrollPhysics) -> Self {
96 Self {
97 input_queue,
98 node_velocities: BTreeMap::new(),
99 pending_positions: BTreeMap::new(),
100 pending_trackpad_positions: BTreeMap::new(),
101 scroll_physics,
102 }
103 }
104
105 fn is_active(&self) -> bool {
107 let threshold = self.scroll_physics.min_velocity_threshold;
108 self.input_queue.has_pending()
109 || self.node_velocities.values().any(|v| {
110 v.velocity.x.abs() > threshold
111 || v.velocity.y.abs() > threshold
112 || v.is_rubber_banding
113 })
114 || !self.pending_positions.is_empty()
115 || !self.pending_trackpad_positions.is_empty()
116 }
117}
118
119#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] pub extern "C" fn scroll_physics_timer_callback(
136 mut data: RefAny,
137 mut timer_info: TimerCallbackInfo,
138) -> TimerCallbackReturn {
139 let Some(mut physics) = data.downcast_mut::<ScrollPhysicsState>() else {
141 return TimerCallbackReturn::terminate_unchanged();
142 };
143
144 let sp = &physics.scroll_physics;
146 let dt = sp.timer_interval_ms.max(1) as f32 / 1000.0;
147 let friction_rate = friction_from_deceleration(sp.deceleration_rate);
148 let velocity_threshold = sp.min_velocity_threshold;
149 let wheel_multiplier = sp.wheel_multiplier;
150 let max_velocity = sp.max_velocity.max(0.0);
155 let overscroll_elasticity = sp.overscroll_elasticity;
156 let max_overscroll_distance = sp.max_overscroll_distance;
157 let bounce_back_duration_ms = sp.bounce_back_duration_ms;
158
159 let inputs = physics.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
161
162 for input in inputs {
163 let key = (input.dom_id, input.node_id);
164 match input.source {
165 ScrollInputSource::TrackpadContinuous => {
166 let current = timer_info
168 .get_scroll_node_info(input.dom_id, input.node_id)
169 .map(|info| info.current_offset)
170 .unwrap_or_default();
171
172 let new_pos = LogicalPosition {
173 x: current.x + input.delta.x,
174 y: current.y + input.delta.y,
175 };
176 physics.pending_trackpad_positions.insert(key, new_pos);
177
178 physics.node_velocities.remove(&key);
180 }
181 ScrollInputSource::WheelDiscrete => {
182 let node_physics = physics
184 .node_velocities
185 .entry(key)
186 .or_insert_with(NodeScrollPhysics::default);
187
188 node_physics.velocity.x += input.delta.x * wheel_multiplier * ASSUMED_FPS;
190 node_physics.velocity.y += input.delta.y * wheel_multiplier * ASSUMED_FPS;
191
192 node_physics.velocity.x = node_physics.velocity.x.clamp(-max_velocity, max_velocity);
194 node_physics.velocity.y = node_physics.velocity.y.clamp(-max_velocity, max_velocity);
195 }
196 ScrollInputSource::Programmatic => {
197 let current = timer_info
199 .get_scroll_node_info(input.dom_id, input.node_id)
200 .map(|info| info.current_offset)
201 .unwrap_or_default();
202
203 let new_pos = LogicalPosition {
204 x: current.x + input.delta.x,
205 y: current.y + input.delta.y,
206 };
207 physics.pending_positions.insert(key, new_pos);
208 }
209 ScrollInputSource::TrackpadEnd => {
210 let pos = physics.pending_positions.remove(&key)
214 .or_else(|| timer_info.get_scroll_node_info(input.dom_id, input.node_id)
215 .map(|info| info.current_offset));
216
217 if let Some(pos) = pos {
218 if let Some(info) = timer_info.get_scroll_node_info(input.dom_id, input.node_id) {
219 let overshoot_x = calculate_overshoot(pos.x, 0.0, info.max_scroll_x);
220 let overshoot_y = calculate_overshoot(pos.y, 0.0, info.max_scroll_y);
221
222 if overshoot_x.abs() > 0.01 || overshoot_y.abs() > 0.01 {
223 let node_phys = physics.node_velocities
224 .entry(key)
225 .or_insert_with(NodeScrollPhysics::default);
226 node_phys.velocity = LogicalPosition::zero();
230 node_phys.is_rubber_banding = true;
231 }
232
233 let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(input.node_id));
236 timer_info.scroll_to_unclamped(input.dom_id, hierarchy_id, pos);
237 }
238 }
239 }
240 }
241 }
242
243 let mut velocity_updates: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
245 let mut momentum_handoffs: Vec<((DomId, NodeId), LogicalPosition)> = Vec::new();
249
250 for ((dom_id, node_id), node_physics) in &mut physics.node_velocities {
251 let Some(info) = timer_info.get_scroll_node_info(*dom_id, *node_id) else {
253 continue;
254 };
255
256 let rubber_band_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, overscroll_elasticity);
258 let rubber_band_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, overscroll_elasticity);
259
260 let overshoot_x = calculate_overshoot(info.current_offset.x, 0.0, info.max_scroll_x);
262 let overshoot_y = calculate_overshoot(info.current_offset.y, 0.0, info.max_scroll_y);
263
264 let is_overshooting_x = overshoot_x.abs() > 0.01;
265 let is_overshooting_y = overshoot_y.abs() > 0.01;
266
267 if is_overshooting_x && rubber_band_x {
270 let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
271 let damping = 2.0 * spring_k.sqrt(); let spring_force_x = -spring_k * overshoot_x - damping * node_physics.velocity.x;
273 node_physics.velocity.x += spring_force_x * dt;
274 node_physics.is_rubber_banding = true;
275 }
276 if is_overshooting_y && rubber_band_y {
277 let spring_k = spring_constant_from_bounce_duration(bounce_back_duration_ms);
278 let damping = 2.0 * spring_k.sqrt(); let spring_force_y = -spring_k * overshoot_y - damping * node_physics.velocity.y;
280 node_physics.velocity.y += spring_force_y * dt;
281 node_physics.is_rubber_banding = true;
282 }
283
284 if !node_physics.is_rubber_banding
286 && node_physics.velocity.x.abs() < velocity_threshold
287 && node_physics.velocity.y.abs() < velocity_threshold
288 {
289 node_physics.velocity = LogicalPosition::zero();
290 continue;
291 }
292
293 let displacement = LogicalPosition {
295 x: node_physics.velocity.x * dt,
296 y: node_physics.velocity.y * dt,
297 };
298
299 let raw_new_x = info.current_offset.x + displacement.x;
300 let raw_new_y = info.current_offset.y + displacement.y;
301
302 let new_x = if rubber_band_x && max_overscroll_distance > 0.0 {
304 rubber_band_clamp(raw_new_x, 0.0, info.max_scroll_x, max_overscroll_distance, overscroll_elasticity)
306 } else {
307 raw_new_x.clamp(0.0, info.max_scroll_x)
308 };
309
310 let new_y = if rubber_band_y && max_overscroll_distance > 0.0 {
311 rubber_band_clamp(raw_new_y, 0.0, info.max_scroll_y, max_overscroll_distance, overscroll_elasticity)
312 } else {
313 raw_new_y.clamp(0.0, info.max_scroll_y)
314 };
315
316 let new_pos = LogicalPosition { x: new_x, y: new_y };
317
318 let decay = (-friction_rate * dt * ASSUMED_FPS).exp();
320 node_physics.velocity.x *= decay;
321 node_physics.velocity.y *= decay;
322
323 if !rubber_band_x && (new_pos.x <= 0.0 || new_pos.x >= info.max_scroll_x) {
330 let into_edge = (new_pos.x <= 0.0 && node_physics.velocity.x < 0.0)
331 || (new_pos.x >= info.max_scroll_x && node_physics.velocity.x > 0.0);
332 if into_edge
333 && info.overscroll_behavior_x == OverscrollBehavior::Auto
334 && node_physics.velocity.x.abs() > velocity_threshold
335 {
336 momentum_handoffs.push((
337 (*dom_id, *node_id),
338 LogicalPosition { x: node_physics.velocity.x, y: 0.0 },
339 ));
340 }
341 node_physics.velocity.x = 0.0;
342 }
343 if !rubber_band_y && (new_pos.y <= 0.0 || new_pos.y >= info.max_scroll_y) {
344 let into_edge = (new_pos.y <= 0.0 && node_physics.velocity.y < 0.0)
345 || (new_pos.y >= info.max_scroll_y && node_physics.velocity.y > 0.0);
346 if into_edge
347 && info.overscroll_behavior_y == OverscrollBehavior::Auto
348 && node_physics.velocity.y.abs() > velocity_threshold
349 {
350 momentum_handoffs.push((
351 (*dom_id, *node_id),
352 LogicalPosition { x: 0.0, y: node_physics.velocity.y },
353 ));
354 }
355 node_physics.velocity.y = 0.0;
356 }
357
358 let new_overshoot_x = calculate_overshoot(new_pos.x, 0.0, info.max_scroll_x);
360 let new_overshoot_y = calculate_overshoot(new_pos.y, 0.0, info.max_scroll_y);
361 if new_overshoot_x.abs() < 0.5 && new_overshoot_y.abs() < 0.5 {
362 node_physics.is_rubber_banding = false;
363 }
364
365 if node_physics.velocity.x.abs() < velocity_threshold {
367 node_physics.velocity.x = 0.0;
368 }
369 if node_physics.velocity.y.abs() < velocity_threshold {
370 node_physics.velocity.y = 0.0;
371 }
372
373 velocity_updates.push(((*dom_id, *node_id), new_pos));
374 }
375
376 physics
378 .node_velocities
379 .retain(|_, v| v.velocity.x.abs() > 0.0 || v.velocity.y.abs() > 0.0 || v.is_rubber_banding);
380
381 for ((dom_id, node_id), vel) in momentum_handoffs {
387 let mut cur = node_id;
388 for _ in 0..64 {
389 let Some(parent) = timer_info.find_scroll_parent(dom_id, cur) else {
390 break;
391 };
392 let Some(pinfo) = timer_info.get_scroll_node_info(dom_id, parent) else {
393 break;
394 };
395 let can_x = vel.x != 0.0
396 && ((vel.x > 0.0 && pinfo.current_offset.x < pinfo.max_scroll_x - 0.5)
397 || (vel.x < 0.0 && pinfo.current_offset.x > 0.5));
398 let can_y = vel.y != 0.0
399 && ((vel.y > 0.0 && pinfo.current_offset.y < pinfo.max_scroll_y - 0.5)
400 || (vel.y < 0.0 && pinfo.current_offset.y > 0.5));
401 if can_x || can_y {
402 let entry = physics
403 .node_velocities
404 .entry((dom_id, parent))
405 .or_insert_with(NodeScrollPhysics::default);
406 if can_x {
407 entry.velocity.x += vel.x;
408 }
409 if can_y {
410 entry.velocity.y += vel.y;
411 }
412 break;
413 }
414 let stop_x = vel.x != 0.0 && pinfo.overscroll_behavior_x != OverscrollBehavior::Auto;
417 let stop_y = vel.y != 0.0 && pinfo.overscroll_behavior_y != OverscrollBehavior::Auto;
418 if stop_x || stop_y {
419 break;
420 }
421 cur = parent;
422 }
423 }
424
425 let mut any_changes = false;
427
428 let direct_positions: Vec<_> = physics.pending_positions.iter().map(|(k, v)| (*k, *v)).collect();
430 physics.pending_positions.clear();
431 for ((dom_id, node_id), position) in direct_positions {
432 let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| LogicalPosition {
433 x: position.x.clamp(0.0, info.max_scroll_x),
434 y: position.y.clamp(0.0, info.max_scroll_y),
435 });
436 let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
437 timer_info.scroll_to(dom_id, hierarchy_id, clamped);
438 any_changes = true;
439 }
440
441 let trackpad_positions: Vec<_> = physics.pending_trackpad_positions.iter().map(|(k, v)| (*k, *v)).collect();
444 physics.pending_trackpad_positions.clear();
445 for ((dom_id, node_id), position) in trackpad_positions {
446 let clamped = timer_info.get_scroll_node_info(dom_id, node_id).map_or(position, |info| {
447 let rubber_x = node_allows_rubber_band(info.max_scroll_x, info.overscroll_behavior_x, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
448 let rubber_y = node_allows_rubber_band(info.max_scroll_y, info.overscroll_behavior_y, info.overflow_scrolling, physics.scroll_physics.overscroll_elasticity);
449 let max_over = physics.scroll_physics.max_overscroll_distance;
450 let elasticity = physics.scroll_physics.overscroll_elasticity;
451 LogicalPosition {
452 x: if rubber_x {
453 rubber_band_clamp(position.x, 0.0, info.max_scroll_x, max_over, elasticity)
454 } else {
455 position.x.clamp(0.0, info.max_scroll_x)
456 },
457 y: if rubber_y {
458 rubber_band_clamp(position.y, 0.0, info.max_scroll_y, max_over, elasticity)
459 } else {
460 position.y.clamp(0.0, info.max_scroll_y)
461 },
462 }
463 });
464 let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
465 timer_info.scroll_to_unclamped(dom_id, hierarchy_id, clamped);
466 any_changes = true;
467 }
468
469 for ((dom_id, node_id), position) in velocity_updates {
471 let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
472 timer_info.scroll_to_unclamped(dom_id, hierarchy_id, position);
473 any_changes = true;
474 }
475
476 if physics.is_active() || any_changes {
478 TimerCallbackReturn {
479 should_update: Update::DoNothing, should_terminate: TerminateTimer::Continue,
481 }
482 } else {
483 TimerCallbackReturn::terminate_unchanged()
485 }
486}
487
488fn node_allows_rubber_band(
498 max_scroll: f32,
499 overscroll_behavior: OverscrollBehavior,
500 overflow_scrolling: OverflowScrolling,
501 global_elasticity: f32,
502) -> bool {
503 if max_scroll <= 0.0 {
504 return false;
505 }
506 if overscroll_behavior == OverscrollBehavior::None {
507 return false;
508 }
509 if overflow_scrolling == OverflowScrolling::Touch {
510 return true;
511 }
512 global_elasticity > 0.0
513}
514
515fn calculate_overshoot(pos: f32, min: f32, max: f32) -> f32 {
518 if pos < min {
519 pos - min } else if pos > max {
521 pos - max } else {
523 0.0
524 }
525}
526
527fn rubber_band_clamp(
532 raw_pos: f32,
533 min: f32,
534 max: f32,
535 max_overscroll: f32,
536 elasticity: f32,
537) -> f32 {
538 if raw_pos >= min && raw_pos <= max {
539 return raw_pos;
540 }
541
542 let (boundary, overshoot) = if raw_pos < min {
543 (min, min - raw_pos) } else {
545 (max, raw_pos - max)
546 };
547
548 let clamped_overscroll = if max_overscroll > 0.0 {
551 max_overscroll * (1.0 - (-elasticity * overshoot / max_overscroll).exp())
552 } else {
553 0.0
554 };
555
556 if raw_pos < min {
557 boundary - clamped_overscroll
558 } else {
559 boundary + clamped_overscroll
560 }
561}
562
563fn friction_from_deceleration(deceleration_rate: f32) -> f32 {
566 (1.0 - deceleration_rate.clamp(0.0, 0.999)).max(0.001)
569}
570
571#[allow(clippy::cast_precision_loss)] #[allow(clippy::similar_names)] fn spring_constant_from_bounce_duration(duration_ms: u32) -> f32 {
576 let duration_s = duration_ms.max(50) as f32 / 1000.0;
577 let omega = core::f32::consts::TAU / duration_s;
578 omega * omega
579}
580
581#[cfg(all(test, feature = "std"))]
586#[allow(
587 clippy::float_cmp,
588 clippy::cast_precision_loss,
589 clippy::too_many_lines,
590 clippy::unreadable_literal
591)]
592mod autotest_generated {
593 use std::sync::{Arc, Mutex};
594
595 use azul_core::{
596 dom::{DomNodeId, OptionDomNodeId},
597 geom::{LogicalRect, LogicalSize, OptionLogicalPosition},
598 gl::OptionGlContextPtr,
599 hit_test::ScrollPosition,
600 refany::OptionRefAny,
601 resources::RendererResources,
602 task::Instant,
603 window::{MonitorVec, RawWindowHandle},
604 };
605 use azul_css::system::SystemStyle;
606 use rust_fontconfig::FcFontCache;
607
608 use super::*;
609 #[cfg(feature = "icu")]
610 use crate::icu::IcuLocalizerHandle;
611 use crate::{
612 callbacks::{CallbackChange, CallbackInfo, CallbackInfoRefData, ExternalSystemCallbacks},
613 window::LayoutWindow,
614 window_state::FullWindowState,
615 };
616
617 struct Env<'a> {
626 ref_data: &'a CallbackInfoRefData<'a>,
627 changes: &'a Arc<Mutex<Vec<CallbackChange>>>,
628 }
629
630 impl Env<'_> {
631 fn tick(&mut self, data: &RefAny) -> TimerCallbackReturn {
633 let info = CallbackInfo::new(
634 self.ref_data,
635 self.changes,
636 DomNodeId {
637 dom: DomId::ROOT_ID,
638 node: NodeHierarchyItemId::NONE,
639 },
640 OptionLogicalPosition::None,
641 OptionLogicalPosition::None,
642 );
643 let timer_info =
644 TimerCallbackInfo::create(info, OptionDomNodeId::None, Instant::now(), 0, false);
645 scroll_physics_timer_callback(data.clone(), timer_info)
646 }
647
648 fn take_changes(&self) -> Vec<CallbackChange> {
650 self.changes
651 .lock()
652 .map(|mut c| core::mem::take(&mut *c))
653 .unwrap_or_default()
654 }
655
656 fn take_scroll_tos(&self) -> Vec<(usize, LogicalPosition, bool)> {
659 self.take_changes()
660 .iter()
661 .map(|change| {
662 let CallbackChange::ScrollTo {
663 node_id,
664 position,
665 unclamped,
666 ..
667 } = change
668 else {
669 panic!("expected only ScrollTo changes, got {change:?}");
670 };
671 let idx = node_id
672 .into_crate_internal()
673 .expect("ScrollTo must name a concrete node")
674 .index();
675 (idx, *position, *unclamped)
676 })
677 .collect()
678 }
679 }
680
681 fn with_env<R>(setup: impl FnOnce(&mut LayoutWindow), f: impl FnOnce(&mut Env<'_>) -> R) -> R {
684 let mut layout_window =
685 LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
686 setup(&mut layout_window);
687
688 let renderer_resources = RendererResources::default();
689 let previous_window_state: Option<FullWindowState> = None;
690 let current_window_state = FullWindowState::default();
691 let gl_context = OptionGlContextPtr::None;
692 let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
693 BTreeMap::new();
694 let window_handle = RawWindowHandle::Unsupported;
695 let system_callbacks = ExternalSystemCallbacks::rust_internal();
696
697 let ref_data = CallbackInfoRefData {
698 layout_window: &layout_window,
699 renderer_resources: &renderer_resources,
700 previous_window_state: &previous_window_state,
701 current_window_state: ¤t_window_state,
702 gl_context: &gl_context,
703 current_scroll_manager: &scroll_states,
704 current_window_handle: &window_handle,
705 system_callbacks: &system_callbacks,
706 system_style: Arc::new(SystemStyle::default()),
707 monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
708 #[cfg(feature = "icu")]
709 icu_localizer: IcuLocalizerHandle::default(),
710 ctx: OptionRefAny::None,
711 };
712
713 let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
714 let mut env = Env {
715 ref_data: &ref_data,
716 changes: &changes,
717 };
718 f(&mut env)
719 }
720
721 fn register_node(
725 window: &mut LayoutWindow,
726 idx: usize,
727 container: (f32, f32),
728 content: (f32, f32),
729 ) {
730 window.scroll_manager.register_or_update_scroll_node(
731 DomId::ROOT_ID,
732 NodeId::new(idx),
733 LogicalRect::new(
734 LogicalPosition::zero(),
735 LogicalSize::new(container.0, container.1),
736 ),
737 LogicalSize::new(content.0, content.1),
738 Instant::now(),
739 0.0,
740 0.0,
741 false,
742 false,
743 );
744 }
745
746 fn input(idx: usize, delta: (f32, f32), source: ScrollInputSource) -> ScrollInput {
748 ScrollInput {
749 dom_id: DomId::ROOT_ID,
750 node_id: NodeId::new(idx),
751 delta: LogicalPosition::new(delta.0, delta.1),
752 timestamp: Instant::now(),
753 source,
754 }
755 }
756
757 fn state_with(physics: ScrollPhysics) -> (RefAny, ScrollInputQueue) {
759 let queue = ScrollInputQueue::new();
760 let state = ScrollPhysicsState::new(queue.clone(), physics);
761 (RefAny::new(state), queue)
762 }
763
764 fn key(idx: usize) -> (DomId, NodeId) {
765 (DomId::ROOT_ID, NodeId::new(idx))
766 }
767
768 fn with_state<R>(data: &mut RefAny, f: impl FnOnce(&ScrollPhysicsState) -> R) -> R {
770 let state = data
771 .downcast_ref::<ScrollPhysicsState>()
772 .expect("RefAny must still hold a ScrollPhysicsState");
773 f(&state)
774 }
775
776 fn nan_physics() -> ScrollPhysics {
780 ScrollPhysics {
781 smooth_scroll_duration_ms: 0,
782 deceleration_rate: f32::NAN,
783 min_velocity_threshold: f32::NAN,
784 max_velocity: 0.0,
785 wheel_multiplier: f32::NAN,
786 invert_direction: false,
787 overscroll_elasticity: f32::NAN,
788 max_overscroll_distance: f32::NAN,
789 bounce_back_duration_ms: 0,
790 timer_interval_ms: 0,
791 }
792 }
793
794 #[test]
799 fn calculate_overshoot_returns_zero_inside_the_range_and_on_both_boundaries() {
800 assert_eq!(calculate_overshoot(0.0, 0.0, 100.0), 0.0);
801 assert_eq!(calculate_overshoot(100.0, 0.0, 100.0), 0.0);
802 assert_eq!(calculate_overshoot(50.0, 0.0, 100.0), 0.0);
803 assert_eq!(calculate_overshoot(0.0, 0.0, 0.0), 0.0);
805 assert_eq!(calculate_overshoot(-0.0, 0.0, 100.0), 0.0);
807 }
808
809 #[test]
810 fn calculate_overshoot_is_signed_by_which_boundary_was_crossed() {
811 assert_eq!(calculate_overshoot(-10.0, 0.0, 100.0), -10.0);
812 assert_eq!(calculate_overshoot(110.0, 0.0, 100.0), 10.0);
813 assert_eq!(calculate_overshoot(-30.0, -20.0, -10.0), -10.0);
815 assert_eq!(calculate_overshoot(0.0, -20.0, -10.0), 10.0);
816 }
817
818 #[test]
819 fn calculate_overshoot_nan_position_reports_no_overshoot() {
820 let out = calculate_overshoot(f32::NAN, 0.0, 100.0);
824 assert!(!out.is_nan(), "NaN must not leak out of calculate_overshoot");
825 assert_eq!(out, 0.0);
826 assert_eq!(calculate_overshoot(1e9, 0.0, f32::NAN), 0.0);
828 assert_eq!(calculate_overshoot(-1e9, f32::NAN, 100.0), 0.0);
829 }
830
831 #[test]
832 fn calculate_overshoot_infinite_position_saturates_without_panicking() {
833 assert_eq!(calculate_overshoot(f32::INFINITY, 0.0, 100.0), f32::INFINITY);
834 assert_eq!(
835 calculate_overshoot(f32::NEG_INFINITY, 0.0, 100.0),
836 f32::NEG_INFINITY
837 );
838 assert_eq!(
840 calculate_overshoot(f32::INFINITY, f32::NEG_INFINITY, f32::INFINITY),
841 0.0
842 );
843 }
844
845 #[test]
846 fn calculate_overshoot_extreme_finite_range_overflows_to_infinity_not_a_panic() {
847 let out = calculate_overshoot(f32::MAX, f32::MIN, f32::MIN);
849 assert!(out.is_infinite() && out.is_sign_positive());
850 let out = calculate_overshoot(f32::MIN, f32::MAX, f32::MAX);
851 assert!(out.is_infinite() && out.is_sign_negative());
852 }
853
854 #[test]
855 fn calculate_overshoot_inverted_range_is_deterministic() {
856 assert_eq!(calculate_overshoot(5.0, 10.0, 0.0), -5.0);
859 assert_eq!(calculate_overshoot(20.0, 10.0, 0.0), 20.0);
860 }
861
862 #[test]
867 fn rubber_band_clamp_is_the_identity_inside_the_range() {
868 assert_eq!(rubber_band_clamp(0.0, 0.0, 100.0, 50.0, 0.5), 0.0);
869 assert_eq!(rubber_band_clamp(50.0, 0.0, 100.0, 50.0, 0.5), 50.0);
870 assert_eq!(rubber_band_clamp(100.0, 0.0, 100.0, 50.0, 0.5), 100.0);
871 }
872
873 #[test]
874 fn rubber_band_clamp_with_zero_max_overscroll_hard_clamps_to_the_boundary() {
875 assert_eq!(rubber_band_clamp(1000.0, 0.0, 100.0, 0.0, 0.5), 100.0);
876 assert_eq!(rubber_band_clamp(-1000.0, 0.0, 100.0, 0.0, 0.5), 0.0);
877 assert_eq!(rubber_band_clamp(1000.0, 0.0, 100.0, -50.0, 0.5), 100.0);
879 assert_eq!(rubber_band_clamp(-1000.0, 0.0, 100.0, -50.0, 0.5), 0.0);
880 }
881
882 #[test]
883 fn rubber_band_clamp_with_zero_elasticity_pins_to_the_boundary() {
884 assert_eq!(rubber_band_clamp(1000.0, 0.0, 100.0, 120.0, 0.0), 100.0);
886 assert_eq!(rubber_band_clamp(-1000.0, 0.0, 100.0, 120.0, 0.0), 0.0);
887 }
888
889 #[test]
890 fn rubber_band_clamp_never_exceeds_max_overscroll_even_for_absurd_input() {
891 let (min, max, max_over, elast) = (0.0, 100.0, 120.0, 0.5);
892 for raw in [101.0_f32, 500.0, 1e6, 1e30, f32::MAX, f32::INFINITY] {
893 let out = rubber_band_clamp(raw, min, max, max_over, elast);
894 assert!(out.is_finite(), "raw={raw} produced {out}");
895 assert!(
896 out >= max && out <= max + max_over,
897 "raw={raw} escaped the overscroll band: {out}"
898 );
899 }
900 for raw in [-1.0_f32, -500.0, -1e6, -1e30, f32::MIN, f32::NEG_INFINITY] {
901 let out = rubber_band_clamp(raw, min, max, max_over, elast);
902 assert!(out.is_finite(), "raw={raw} produced {out}");
903 assert!(
904 out <= min && out >= min - max_over,
905 "raw={raw} escaped the overscroll band: {out}"
906 );
907 }
908 assert_eq!(
910 rubber_band_clamp(f32::INFINITY, min, max, max_over, elast),
911 max + max_over
912 );
913 assert_eq!(
914 rubber_band_clamp(f32::NEG_INFINITY, min, max, max_over, elast),
915 min - max_over
916 );
917 }
918
919 #[test]
920 fn rubber_band_clamp_has_diminishing_returns_and_stays_monotonic() {
921 let (min, max, max_over, elast) = (0.0, 100.0, 100.0, 0.5);
922 let mut previous = max;
923 for raw in [110.0_f32, 120.0, 200.0, 400.0, 800.0] {
924 let out = rubber_band_clamp(raw, min, max, max_over, elast);
925 assert!(out > previous, "not monotonic at raw={raw}: {out} <= {previous}");
927 assert!(
929 out < raw,
930 "raw={raw} was not resisted at all (got {out})"
931 );
932 previous = out;
933 }
934 }
935
936 #[test]
937 fn rubber_band_clamp_nan_inputs_are_defined_and_do_not_panic() {
938 assert!(rubber_band_clamp(f32::NAN, 0.0, 100.0, 120.0, 0.5).is_nan());
942 assert!(rubber_band_clamp(500.0, 0.0, 100.0, 120.0, f32::NAN).is_nan());
944 assert_eq!(rubber_band_clamp(500.0, 0.0, 100.0, f32::NAN, 0.5), 100.0);
946 }
947
948 #[test]
949 fn rubber_band_clamp_negative_elasticity_stays_non_nan() {
950 for raw in [110.0_f32, 1e6, f32::MAX] {
954 let out = rubber_band_clamp(raw, 0.0, 100.0, 100.0, -1.0);
955 assert!(!out.is_nan(), "raw={raw} produced NaN");
956 }
957 let out = rubber_band_clamp(110.0, 0.0, 100.0, 100.0, -1.0);
959 assert!(out.is_finite());
960 assert!(out < 100.0, "negative elasticity flips the sign: {out}");
961 }
962
963 #[test]
964 fn rubber_band_clamp_degenerate_range_still_returns_a_boundary() {
965 assert_eq!(rubber_band_clamp(0.0, 0.0, 0.0, 100.0, 0.5), 0.0);
967 let out = rubber_band_clamp(10.0, 0.0, 0.0, 100.0, 0.5);
968 assert!(out > 0.0 && out <= 100.0, "{out}");
969 let out = rubber_band_clamp(-10.0, 0.0, 0.0, 100.0, 0.5);
970 assert!((-100.0..0.0).contains(&out), "{out}");
971 }
972
973 #[test]
978 fn friction_from_deceleration_matches_the_documented_values() {
979 assert!((friction_from_deceleration(0.95) - 0.05).abs() < 1e-6);
980 assert!((friction_from_deceleration(0.998) - 0.002).abs() < 1e-6);
981 assert!((friction_from_deceleration(0.0) - 1.0).abs() < 1e-6);
982 }
983
984 #[test]
985 fn friction_from_deceleration_clamps_both_ends_and_never_returns_zero() {
986 assert_eq!(friction_from_deceleration(1.0), 0.001);
990 assert_eq!(friction_from_deceleration(0.999), 0.001);
991 assert_eq!(friction_from_deceleration(f32::MAX), 0.001);
992 assert_eq!(friction_from_deceleration(f32::INFINITY), 0.001);
993 assert_eq!(friction_from_deceleration(-0.0), 1.0);
995 assert_eq!(friction_from_deceleration(-5.0), 1.0);
996 assert_eq!(friction_from_deceleration(f32::MIN), 1.0);
997 assert_eq!(friction_from_deceleration(f32::NEG_INFINITY), 1.0);
998 }
999
1000 #[test]
1001 fn friction_from_deceleration_nan_falls_back_to_the_floor() {
1002 let out = friction_from_deceleration(f32::NAN);
1005 assert!(!out.is_nan(), "NaN deceleration must not poison friction");
1006 assert_eq!(out, 0.001);
1007 }
1008
1009 #[test]
1010 fn friction_from_deceleration_always_yields_a_usable_decay_factor() {
1011 let dt = 16.0 / 1000.0;
1012 for rate in [
1013 0.0_f32,
1014 0.5,
1015 0.9,
1016 0.95,
1017 0.996,
1018 0.998,
1019 0.999,
1020 1.0,
1021 -1.0,
1022 f32::NAN,
1023 f32::INFINITY,
1024 f32::NEG_INFINITY,
1025 f32::MAX,
1026 f32::MIN,
1027 f32::MIN_POSITIVE,
1028 ] {
1029 let friction = friction_from_deceleration(rate);
1030 assert!(friction.is_finite(), "rate={rate} -> {friction}");
1031 assert!(
1032 (0.001..=1.0).contains(&friction),
1033 "rate={rate} -> friction {friction} outside [0.001, 1.0]"
1034 );
1035 let decay = (-friction * dt * ASSUMED_FPS).exp();
1037 assert!(decay.is_finite() && decay > 0.0 && decay < 1.0, "rate={rate} -> decay {decay}");
1038 }
1039 }
1040
1041 #[test]
1046 fn spring_constant_clamps_short_durations_to_50ms() {
1047 let floor = spring_constant_from_bounce_duration(50);
1048 assert_eq!(spring_constant_from_bounce_duration(0), floor);
1049 assert_eq!(spring_constant_from_bounce_duration(1), floor);
1050 assert_eq!(spring_constant_from_bounce_duration(49), floor);
1051 assert!((floor - 15791.37).abs() < 1.0, "{floor}");
1053 }
1054
1055 #[test]
1056 fn spring_constant_decreases_monotonically_with_duration() {
1057 let mut previous = f32::INFINITY;
1058 for ms in [50_u32, 100, 200, 300, 400, 500, 1000, 10_000] {
1059 let k = spring_constant_from_bounce_duration(ms);
1060 assert!(k.is_finite() && k > 0.0, "ms={ms} -> {k}");
1061 assert!(k < previous, "ms={ms}: {k} did not decrease below {previous}");
1062 previous = k;
1063 }
1064 }
1065
1066 #[test]
1067 fn spring_constant_stays_finite_and_positive_at_u32_max() {
1068 let k = spring_constant_from_bounce_duration(u32::MAX);
1071 assert!(k.is_finite(), "{k}");
1072 assert!(k > 0.0, "{k}");
1073 assert!(k < 1e-6, "{k}");
1074 }
1075
1076 #[test]
1077 fn spring_constant_damping_term_is_always_finite() {
1078 for ms in [0_u32, 1, 50, 400, 1000, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
1081 let k = spring_constant_from_bounce_duration(ms);
1082 let damping = 2.0 * k.sqrt();
1083 assert!(damping.is_finite() && damping > 0.0, "ms={ms} -> damping {damping}");
1084 }
1085 }
1086
1087 #[test]
1092 fn node_allows_rubber_band_requires_actual_overflow_on_the_axis() {
1093 for max_scroll in [0.0_f32, -0.0, -1.0, -1e9, f32::MIN, f32::NEG_INFINITY] {
1095 assert!(
1096 !node_allows_rubber_band(
1097 max_scroll,
1098 OverscrollBehavior::Auto,
1099 OverflowScrolling::Touch,
1100 1.0
1101 ),
1102 "max_scroll={max_scroll} must not rubber-band"
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn node_allows_rubber_band_is_vetoed_by_overscroll_behavior_none() {
1109 assert!(!node_allows_rubber_band(
1112 400.0,
1113 OverscrollBehavior::None,
1114 OverflowScrolling::Touch,
1115 1.0
1116 ));
1117 assert!(!node_allows_rubber_band(
1118 f32::MAX,
1119 OverscrollBehavior::None,
1120 OverflowScrolling::Auto,
1121 1.0
1122 ));
1123 }
1124
1125 #[test]
1126 fn node_allows_rubber_band_touch_overrides_a_zero_global_elasticity() {
1127 assert!(node_allows_rubber_band(
1130 400.0,
1131 OverscrollBehavior::Auto,
1132 OverflowScrolling::Touch,
1133 0.0
1134 ));
1135 assert!(node_allows_rubber_band(
1137 400.0,
1138 OverscrollBehavior::Contain,
1139 OverflowScrolling::Touch,
1140 0.0
1141 ));
1142 }
1143
1144 #[test]
1145 fn node_allows_rubber_band_otherwise_follows_the_global_elasticity() {
1146 let ask = |elasticity: f32| {
1147 node_allows_rubber_band(
1148 400.0,
1149 OverscrollBehavior::Auto,
1150 OverflowScrolling::Auto,
1151 elasticity,
1152 )
1153 };
1154 assert!(!ask(0.0));
1155 assert!(!ask(-0.0));
1156 assert!(!ask(-1.0));
1157 assert!(!ask(f32::NEG_INFINITY));
1158 assert!(!ask(f32::NAN));
1160 assert!(ask(f32::MIN_POSITIVE));
1161 assert!(ask(0.3));
1162 assert!(ask(f32::INFINITY));
1163 }
1164
1165 #[test]
1166 fn node_allows_rubber_band_contain_still_bounces_locally() {
1167 assert!(node_allows_rubber_band(
1169 400.0,
1170 OverscrollBehavior::Contain,
1171 OverflowScrolling::Auto,
1172 0.5
1173 ));
1174 assert!(!node_allows_rubber_band(
1175 400.0,
1176 OverscrollBehavior::Contain,
1177 OverflowScrolling::Auto,
1178 0.0
1179 ));
1180 }
1181
1182 #[test]
1183 fn node_allows_rubber_band_nan_max_scroll_is_treated_as_overflowing() {
1184 assert!(node_allows_rubber_band(
1190 f32::NAN,
1191 OverscrollBehavior::Auto,
1192 OverflowScrolling::Auto,
1193 0.5
1194 ));
1195 assert!(node_allows_rubber_band(
1196 f32::NAN,
1197 OverscrollBehavior::Auto,
1198 OverflowScrolling::Touch,
1199 0.0
1200 ));
1201 assert!(!node_allows_rubber_band(
1203 f32::NAN,
1204 OverscrollBehavior::None,
1205 OverflowScrolling::Touch,
1206 1.0
1207 ));
1208 }
1209
1210 #[test]
1215 fn new_starts_empty_and_keeps_the_config_verbatim() {
1216 for physics in [
1217 ScrollPhysics::default(),
1218 ScrollPhysics::ios(),
1219 ScrollPhysics::macos(),
1220 ScrollPhysics::windows(),
1221 ScrollPhysics::android(),
1222 nan_physics(),
1223 ] {
1224 let state = ScrollPhysicsState::new(ScrollInputQueue::new(), physics);
1225 assert!(state.node_velocities.is_empty());
1226 assert!(state.pending_positions.is_empty());
1227 assert!(state.pending_trackpad_positions.is_empty());
1228 assert!(!state.input_queue.has_pending());
1229 assert_eq!(
1231 state.scroll_physics.timer_interval_ms,
1232 physics.timer_interval_ms
1233 );
1234 assert_eq!(state.scroll_physics.max_velocity, physics.max_velocity);
1235 }
1236 }
1237
1238 #[test]
1239 fn new_shares_the_input_queue_rather_than_copying_it() {
1240 let queue = ScrollInputQueue::new();
1243 let state = ScrollPhysicsState::new(queue.clone(), ScrollPhysics::default());
1244 assert!(!state.input_queue.has_pending());
1245
1246 queue.push(input(0, (0.0, 10.0), ScrollInputSource::WheelDiscrete));
1247 assert!(
1248 state.input_queue.has_pending(),
1249 "the queue must be shared (Arc), not deep-copied"
1250 );
1251
1252 let taken = state.input_queue.take_recent(MAX_SCROLL_EVENTS_PER_TICK);
1253 assert_eq!(taken.len(), 1);
1254 assert!(!queue.has_pending(), "draining the timer side drains both");
1255 }
1256
1257 #[test]
1262 fn is_active_is_false_for_a_fresh_state() {
1263 let state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1264 assert!(!state.is_active());
1265 }
1266
1267 #[test]
1268 fn is_active_is_true_while_inputs_are_pending() {
1269 let queue = ScrollInputQueue::new();
1270 let state = ScrollPhysicsState::new(queue.clone(), ScrollPhysics::default());
1271 queue.push(input(0, (0.0, 1.0), ScrollInputSource::WheelDiscrete));
1272 assert!(state.is_active());
1273 }
1274
1275 #[test]
1276 fn is_active_uses_a_strict_greater_than_against_the_threshold() {
1277 let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1278 let threshold = state.scroll_physics.min_velocity_threshold; let at = |velocity: LogicalPosition| NodeScrollPhysics {
1280 velocity,
1281 is_rubber_banding: false,
1282 };
1283
1284 state
1286 .node_velocities
1287 .insert(key(0), at(LogicalPosition::new(0.0, threshold)));
1288 assert!(!state.is_active(), "velocity == threshold must not be active");
1289
1290 state
1292 .node_velocities
1293 .insert(key(0), at(LogicalPosition::new(0.0, threshold * 1.0001)));
1294 assert!(state.is_active());
1295
1296 state
1298 .node_velocities
1299 .insert(key(0), at(LogicalPosition::new(-threshold * 2.0, 0.0)));
1300 assert!(state.is_active(), "|velocity| is what counts, not the sign");
1301 }
1302
1303 #[test]
1304 fn is_active_is_true_while_rubber_banding_even_at_zero_velocity() {
1305 let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1306 state.node_velocities.insert(
1307 key(0),
1308 NodeScrollPhysics {
1309 velocity: LogicalPosition::zero(),
1310 is_rubber_banding: true,
1311 },
1312 );
1313 assert!(
1314 state.is_active(),
1315 "the spring-back animation must keep the timer alive"
1316 );
1317 }
1318
1319 #[test]
1320 fn is_active_is_true_while_positions_are_pending() {
1321 let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1322 state
1323 .pending_positions
1324 .insert(key(0), LogicalPosition::zero());
1325 assert!(state.is_active());
1326
1327 let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1328 state
1329 .pending_trackpad_positions
1330 .insert(key(0), LogicalPosition::zero());
1331 assert!(state.is_active());
1332 }
1333
1334 #[test]
1335 fn is_active_treats_nan_velocity_as_inactive_without_panicking() {
1336 let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1339 state.node_velocities.insert(
1340 key(0),
1341 NodeScrollPhysics {
1342 velocity: LogicalPosition::new(f32::NAN, f32::NAN),
1343 is_rubber_banding: false,
1344 },
1345 );
1346 assert!(!state.is_active());
1347
1348 let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), nan_physics());
1350 state.node_velocities.insert(
1351 key(0),
1352 NodeScrollPhysics {
1353 velocity: LogicalPosition::new(1e9, 1e9),
1354 is_rubber_banding: false,
1355 },
1356 );
1357 assert!(!state.is_active());
1358 }
1359
1360 #[test]
1361 fn is_active_with_infinite_velocity_is_true() {
1362 let mut state = ScrollPhysicsState::new(ScrollInputQueue::new(), ScrollPhysics::default());
1363 state.node_velocities.insert(
1364 key(0),
1365 NodeScrollPhysics {
1366 velocity: LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
1367 is_rubber_banding: false,
1368 },
1369 );
1370 assert!(state.is_active());
1371 }
1372
1373 #[test]
1378 fn callback_with_a_foreign_refany_terminates_instead_of_panicking() {
1379 let data = RefAny::new(42_u32);
1380 with_env(|_| {}, |env| {
1381 let ret = env.tick(&data);
1382 assert_eq!(ret.should_terminate, TerminateTimer::Terminate);
1383 assert_eq!(ret.should_update, Update::DoNothing);
1384 assert!(env.take_changes().is_empty());
1385 });
1386 }
1387
1388 #[test]
1389 fn callback_with_nothing_to_do_terminates_the_timer() {
1390 let (data, _queue) = state_with(ScrollPhysics::default());
1391 with_env(|_| {}, |env| {
1392 let ret = env.tick(&data);
1393 assert_eq!(
1394 ret.should_terminate,
1395 TerminateTimer::Terminate,
1396 "an idle physics timer must not keep spinning"
1397 );
1398 assert!(env.take_changes().is_empty());
1399 });
1400 }
1401
1402 #[test]
1403 fn callback_programmatic_input_pushes_a_hard_clamped_scroll_to() {
1404 let (data, queue) = state_with(ScrollPhysics::default());
1405 queue.push(input(3, (0.0, 10_000.0), ScrollInputSource::Programmatic));
1407
1408 with_env(
1409 |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1410 |env| {
1411 let ret = env.tick(&data);
1412 assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1413 assert_eq!(ret.should_update, Update::DoNothing);
1415
1416 let scrolls = env.take_scroll_tos();
1417 assert_eq!(scrolls.len(), 1);
1418 let (idx, pos, unclamped) = scrolls[0];
1419 assert_eq!(idx, 3);
1420 assert!(!unclamped, "programmatic scroll must be hard-clamped");
1421 assert_eq!(pos.x, 0.0);
1422 assert_eq!(pos.y, 400.0, "a 10000px jump must clamp to max_scroll_y");
1423 },
1424 );
1425 }
1426
1427 #[test]
1428 fn callback_programmatic_negative_input_clamps_to_zero() {
1429 let (data, queue) = state_with(ScrollPhysics::default());
1430 queue.push(input(3, (-1e9, -1e9), ScrollInputSource::Programmatic));
1431
1432 with_env(
1433 |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1434 |env| {
1435 let _ = env.tick(&data);
1436 let scrolls = env.take_scroll_tos();
1437 assert_eq!(scrolls.len(), 1);
1438 assert_eq!(scrolls[0].1, LogicalPosition::zero());
1439 },
1440 );
1441 }
1442
1443 #[test]
1444 fn callback_trackpad_overshoot_is_bounded_by_max_overscroll_distance() {
1445 let physics = ScrollPhysics::ios();
1447 let (data, queue) = state_with(physics);
1448 queue.push(input(3, (0.0, 1e9), ScrollInputSource::TrackpadContinuous));
1449
1450 with_env(
1451 |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1452 |env| {
1453 let ret = env.tick(&data);
1454 assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1455
1456 let scrolls = env.take_scroll_tos();
1457 assert_eq!(scrolls.len(), 1);
1458 let (idx, pos, unclamped) = scrolls[0];
1459 assert_eq!(idx, 3);
1460 assert!(unclamped, "the timer does its own rubber-band clamping");
1461 assert!(pos.y.is_finite());
1462 assert!(
1464 pos.y > 400.0 && pos.y <= 400.0 + physics.max_overscroll_distance + 1e-3,
1465 "a 1e9 px flick escaped the overscroll band: {}",
1466 pos.y
1467 );
1468 assert_eq!(pos.x, 0.0);
1470 },
1471 );
1472 }
1473
1474 #[test]
1475 fn callback_trackpad_without_elasticity_hard_clamps() {
1476 let (data, queue) = state_with(ScrollPhysics::windows());
1478 queue.push(input(3, (0.0, 1e9), ScrollInputSource::TrackpadContinuous));
1479
1480 with_env(
1481 |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1482 |env| {
1483 let _ = env.tick(&data);
1484 let scrolls = env.take_scroll_tos();
1485 assert_eq!(scrolls.len(), 1);
1486 assert_eq!(scrolls[0].1.y, 400.0, "no bounce -> pinned to max_scroll_y");
1487 },
1488 );
1489 }
1490
1491 #[test]
1492 fn callback_wheel_impulse_is_clamped_to_max_velocity() {
1493 let physics = ScrollPhysics::default(); let (mut data, queue) = state_with(physics);
1495 queue.push(input(0, (1e9, -1e9), ScrollInputSource::WheelDiscrete));
1497 queue.push(input(1, (f32::INFINITY, f32::NEG_INFINITY), ScrollInputSource::WheelDiscrete));
1498
1499 with_env(|_| {}, |env| {
1500 let ret = env.tick(&data);
1501 assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1502 });
1503
1504 with_state(&mut data, |state| {
1505 for idx in [0_usize, 1] {
1506 let node = state
1507 .node_velocities
1508 .get(&key(idx))
1509 .unwrap_or_else(|| panic!("node {idx} lost its velocity"));
1510 assert_eq!(node.velocity.x, physics.max_velocity, "node {idx}");
1511 assert_eq!(node.velocity.y, -physics.max_velocity, "node {idx}");
1512 }
1513 });
1514 }
1515
1516 #[test]
1517 fn callback_wheel_momentum_decays_and_never_leaves_the_scroll_bounds() {
1518 let (mut data, queue) = state_with(ScrollPhysics::default());
1519 queue.push(input(3, (0.0, 100.0), ScrollInputSource::WheelDiscrete));
1520
1521 with_env(
1522 |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1523 |env| {
1524 let mut ticks = 0;
1525 loop {
1528 let ret = env.tick(&data);
1529 for (_, pos, _) in env.take_scroll_tos() {
1530 assert!(pos.x.is_finite() && pos.y.is_finite());
1531 assert!(
1532 (0.0..=400.0).contains(&pos.y),
1533 "tick {ticks}: y={} left [0, max_scroll_y]",
1534 pos.y
1535 );
1536 assert_eq!(pos.x, 0.0);
1537 }
1538 ticks += 1;
1539 if ret.should_terminate == TerminateTimer::Terminate {
1540 break;
1541 }
1542 assert!(
1543 ticks < 1000,
1544 "momentum never decayed below the velocity threshold"
1545 );
1546 }
1547 assert!(ticks > 1, "the fling should survive at least one tick");
1548 },
1549 );
1550
1551 with_state(&mut data, |state| {
1552 assert!(
1553 state.node_velocities.is_empty(),
1554 "a terminated timer must not leave live velocities behind"
1555 );
1556 });
1557 }
1558
1559 #[test]
1560 fn callback_caps_the_events_processed_per_tick() {
1561 let (data, queue) = state_with(ScrollPhysics::default());
1562 let total = MAX_SCROLL_EVENTS_PER_TICK * 5;
1564 for i in 0..total {
1565 queue.push(input(i, (0.0, 1.0), ScrollInputSource::Programmatic));
1566 }
1567
1568 with_env(|_| {}, |env| {
1569 let ret = env.tick(&data);
1570 assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1571
1572 let scrolls = env.take_scroll_tos();
1573 assert_eq!(
1574 scrolls.len(),
1575 MAX_SCROLL_EVENTS_PER_TICK,
1576 "the per-tick event budget must be enforced"
1577 );
1578 for (idx, _, _) in &scrolls {
1581 assert!(
1582 *idx >= total - MAX_SCROLL_EVENTS_PER_TICK,
1583 "node {idx} is a stale event that should have been dropped"
1584 );
1585 }
1586 });
1587
1588 assert!(
1589 !queue.has_pending(),
1590 "the backlog must be drained, not left to grow unboundedly"
1591 );
1592 }
1593
1594 #[test]
1595 fn callback_nan_delta_does_not_panic() {
1596 let (data, queue) = state_with(ScrollPhysics::default());
1599 queue.push(input(0, (f32::NAN, f32::NAN), ScrollInputSource::Programmatic));
1600
1601 with_env(|_| {}, |env| {
1602 let ret = env.tick(&data);
1603 assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1604 let scrolls = env.take_scroll_tos();
1605 assert_eq!(scrolls.len(), 1);
1606 assert!(scrolls[0].1.x.is_nan() && scrolls[0].1.y.is_nan());
1607 });
1608 }
1609
1610 #[test]
1611 fn callback_nan_wheel_delta_drops_the_node_instead_of_spinning_forever() {
1612 let (mut data, queue) = state_with(ScrollPhysics::default());
1616 queue.push(input(0, (f32::NAN, f32::NAN), ScrollInputSource::WheelDiscrete));
1617
1618 with_env(|_| {}, |env| {
1619 let ret = env.tick(&data);
1620 assert_eq!(ret.should_terminate, TerminateTimer::Terminate);
1621 assert!(env.take_changes().is_empty());
1622 });
1623
1624 with_state(&mut data, |state| {
1625 assert!(state.node_velocities.is_empty());
1626 });
1627 }
1628
1629 #[test]
1630 fn callback_trackpad_end_on_an_unknown_node_is_a_no_op() {
1631 let (data, queue) = state_with(ScrollPhysics::ios());
1632 queue.push(input(7, (0.0, 0.0), ScrollInputSource::TrackpadEnd));
1633
1634 with_env(|_| {}, |env| {
1635 let ret = env.tick(&data);
1636 assert_eq!(ret.should_terminate, TerminateTimer::Terminate);
1637 assert!(env.take_changes().is_empty());
1638 });
1639 }
1640
1641 #[test]
1642 fn callback_trackpad_end_inside_the_bounds_does_not_start_a_spring_back() {
1643 let (mut data, queue) = state_with(ScrollPhysics::ios());
1644 queue.push(input(3, (0.0, 0.0), ScrollInputSource::TrackpadEnd));
1645
1646 with_env(
1647 |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1648 |env| {
1649 let _ = env.tick(&data);
1651 let scrolls = env.take_scroll_tos();
1652 assert_eq!(scrolls.len(), 1, "the position is re-pushed unclamped");
1653 assert!(scrolls[0].2, "TrackpadEnd re-pushes the raw position");
1654 assert_eq!(scrolls[0].1, LogicalPosition::zero());
1655 },
1656 );
1657
1658 with_state(&mut data, |state| {
1659 assert!(
1660 state.node_velocities.is_empty(),
1661 "no overshoot must not arm the spring"
1662 );
1663 });
1664 }
1665
1666 #[test]
1667 fn callback_degenerate_physics_config_does_not_panic() {
1668 let (data, queue) = state_with(nan_physics());
1671 queue.push(input(0, (10.0, 10.0), ScrollInputSource::WheelDiscrete));
1672 queue.push(input(1, (10.0, 10.0), ScrollInputSource::TrackpadContinuous));
1673 queue.push(input(2, (10.0, 10.0), ScrollInputSource::Programmatic));
1674 queue.push(input(3, (10.0, 10.0), ScrollInputSource::TrackpadEnd));
1675
1676 with_env(
1677 |w| {
1678 register_node(w, 0, (100.0, 100.0), (100.0, 500.0));
1679 register_node(w, 1, (100.0, 100.0), (100.0, 500.0));
1680 register_node(w, 2, (100.0, 100.0), (100.0, 500.0));
1681 register_node(w, 3, (100.0, 100.0), (100.0, 500.0));
1682 },
1683 |env| {
1684 let ret = env.tick(&data);
1685 assert!(matches!(
1687 ret.should_terminate,
1688 TerminateTimer::Continue | TerminateTimer::Terminate
1689 ));
1690 let _ = env.tick(&data);
1692 },
1693 );
1694 }
1695
1696 #[test]
1697 fn callback_zero_timer_interval_still_advances_time() {
1698 let physics = ScrollPhysics {
1701 timer_interval_ms: 0,
1702 ..ScrollPhysics::default()
1703 };
1704 let (mut data, queue) = state_with(physics);
1705 queue.push(input(3, (0.0, 100.0), ScrollInputSource::WheelDiscrete));
1706
1707 with_env(
1708 |w| register_node(w, 3, (100.0, 100.0), (100.0, 500.0)),
1709 |env| {
1710 let ret = env.tick(&data);
1711 assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1712 let scrolls = env.take_scroll_tos();
1713 assert_eq!(scrolls.len(), 1);
1714 let y = scrolls[0].1.y;
1715 assert!(y.is_finite() && y > 0.0 && y <= 400.0, "y={y}");
1716 },
1717 );
1718
1719 with_state(&mut data, |state| {
1720 let node = state.node_velocities.get(&key(3)).expect("velocity kept");
1721 assert!(node.velocity.y.is_finite());
1722 });
1723 }
1724
1725 #[test]
1726 fn callback_survives_a_huge_backlog_on_a_single_node() {
1727 let physics = ScrollPhysics::default();
1730 let (mut data, queue) = state_with(physics);
1731 for _ in 0..(MAX_SCROLL_EVENTS_PER_TICK * 10) {
1732 queue.push(input(0, (0.0, 1e6), ScrollInputSource::WheelDiscrete));
1733 }
1734
1735 with_env(|_| {}, |env| {
1736 let ret = env.tick(&data);
1737 assert_eq!(ret.should_terminate, TerminateTimer::Continue);
1738 });
1739
1740 assert!(!queue.has_pending());
1741 with_state(&mut data, |state| {
1742 let node = state.node_velocities.get(&key(0)).expect("velocity kept");
1743 assert_eq!(
1744 node.velocity.y, physics.max_velocity,
1745 "1000 stacked impulses must not exceed max_velocity"
1746 );
1747 });
1748 }
1749
1750 #[test]
1760 fn nan_max_velocity_is_sanitized_to_a_safe_clamp() {
1761 let max_velocity = ScrollPhysics {
1762 max_velocity: f32::NAN,
1763 ..ScrollPhysics::default()
1764 }
1765 .max_velocity
1766 .max(0.0);
1767 assert_eq!(max_velocity, 0.0);
1768 assert_eq!((600.0_f32).clamp(-max_velocity, max_velocity), 0.0);
1769 }
1770
1771 #[test]
1772 fn negative_max_velocity_is_sanitized_to_a_safe_clamp() {
1773 let max_velocity = ScrollPhysics {
1774 max_velocity: -1.0,
1775 ..ScrollPhysics::default()
1776 }
1777 .max_velocity
1778 .max(0.0);
1779 assert_eq!(max_velocity, 0.0);
1780 assert_eq!((600.0_f32).clamp(-max_velocity, max_velocity), 0.0);
1781 }
1782
1783 #[test]
1784 fn zero_max_velocity_is_the_only_safe_degenerate_config() {
1785 assert_eq!((600.0_f32).clamp(-0.0, 0.0), 0.0);
1788 assert_eq!((-600.0_f32).clamp(-0.0, 0.0), -0.0);
1789 assert!(f32::NAN.clamp(-8000.0, 8000.0).is_nan());
1791 }
1792}