1use alloc::collections::BTreeMap;
46#[cfg(feature = "std")]
47use alloc::vec::Vec;
48
49use azul_core::{
50 dom::{DomId, NodeId, ScrollbarOrientation},
51 events::EasingFunction,
52 geom::{LogicalPosition, LogicalRect, LogicalSize},
53 hit_test::ScrollPosition,
54 styled_dom::NodeHierarchyItemId,
55 task::{Duration, Instant},
56};
57
58#[cfg(feature = "std")]
59use std::sync::{Arc, Mutex};
60
61use crate::managers::hover::InputPointId;
62use crate::solver3::scrollbar::compute_scrollbar_geometry_with_button_size;
63
64const SCROLL_CHANGE_EPSILON: f32 = 0.01;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ScrollInputSource {
80 TrackpadContinuous,
83 TrackpadEnd,
88 WheelDiscrete,
91 Programmatic,
94}
95
96#[derive(Debug, Clone)]
102pub struct ScrollInput {
103 pub dom_id: DomId,
105 pub node_id: NodeId,
107 pub delta: LogicalPosition,
109 pub timestamp: Instant,
111 pub source: ScrollInputSource,
113}
114
115#[cfg(feature = "std")]
121#[derive(Debug, Clone, Default)]
122pub struct ScrollInputQueue {
123 inner: Arc<Mutex<Vec<ScrollInput>>>,
124}
125
126#[cfg(feature = "std")]
127impl ScrollInputQueue {
128 #[must_use] pub fn new() -> Self {
129 Self {
130 inner: Arc::new(Mutex::new(Vec::new())),
131 }
132 }
133
134 pub fn push(&self, input: ScrollInput) {
136 if let Ok(mut queue) = self.inner.lock() {
137 queue.push(input);
138 }
139 }
140
141 #[must_use] pub fn take_all(&self) -> Vec<ScrollInput> {
143 self.inner.lock().map_or_else(
144 |_| Vec::new(),
145 |mut queue| core::mem::take(&mut *queue),
146 )
147 }
148
149 #[must_use] pub fn take_recent(&self, max_events: usize) -> Vec<ScrollInput> {
153 self.inner.lock().map_or_else(
154 |_| Vec::new(),
155 |mut queue| {
156 let mut events = core::mem::take(&mut *queue);
157 if events.len() > max_events {
158 events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
160 events.drain(..events.len() - max_events);
161 }
162 events
163 },
164 )
165 }
166
167 #[must_use] pub fn has_pending(&self) -> bool {
169 self.inner
170 .lock()
171 .map(|q| !q.is_empty())
172 .unwrap_or(false)
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum ScrollbarComponent {
181 Track,
183 Thumb,
185 TopButton,
187 BottomButton,
189}
190
191#[derive(Copy, Debug, Clone)]
193pub struct ScrollbarState {
194 pub visible: bool,
196 pub orientation: ScrollbarOrientation,
198 pub base_size: f32,
200 pub scale: LogicalPosition, pub thumb_position_ratio: f32,
204 pub thumb_size_ratio: f32,
206 pub track_rect: LogicalRect,
208 pub button_size: f32,
210 pub usable_track_length: f32,
212 pub thumb_length: f32,
214 pub thumb_offset: f32,
216}
217
218impl ScrollbarState {
219 #[must_use] pub fn hit_test_component(&self, local_pos: LogicalPosition) -> ScrollbarComponent {
223 match self.orientation {
224 ScrollbarOrientation::Vertical => {
225 if local_pos.y < self.button_size {
227 return ScrollbarComponent::TopButton;
228 }
229
230 let track_height = self.track_rect.size.height;
232 if local_pos.y > track_height - self.button_size {
233 return ScrollbarComponent::BottomButton;
234 }
235
236 let thumb_y_start = self.button_size + self.thumb_offset;
238 let thumb_y_end = thumb_y_start + self.thumb_length;
239
240 if local_pos.y >= thumb_y_start && local_pos.y <= thumb_y_end {
241 ScrollbarComponent::Thumb
242 } else {
243 ScrollbarComponent::Track
244 }
245 }
246 ScrollbarOrientation::Horizontal => {
247 if local_pos.x < self.button_size {
249 return ScrollbarComponent::TopButton;
250 }
251
252 let track_width = self.track_rect.size.width;
254 if local_pos.x > track_width - self.button_size {
255 return ScrollbarComponent::BottomButton;
256 }
257
258 let thumb_x_start = self.button_size + self.thumb_offset;
260 let thumb_x_end = thumb_x_start + self.thumb_length;
261
262 if local_pos.x >= thumb_x_start && local_pos.x <= thumb_x_end {
263 ScrollbarComponent::Thumb
264 } else {
265 ScrollbarComponent::Track
266 }
267 }
268 }
269 }
270}
271
272#[derive(Debug, Clone, Copy)]
277pub struct ScrollbarHit {
278 pub dom_id: DomId,
280 pub node_id: NodeId,
282 pub orientation: ScrollbarOrientation,
284 pub component: ScrollbarComponent,
286 pub local_position: LogicalPosition,
288 pub global_position: LogicalPosition,
290}
291
292#[derive(Debug, Clone, Default)]
296pub struct ScrollManager {
297 states: BTreeMap<(DomId, NodeId), AnimatedScrollState>,
299 scrollbar_states: BTreeMap<(DomId, NodeId, ScrollbarOrientation), ScrollbarState>,
301 #[cfg(feature = "std")]
303 pub scroll_input_queue: ScrollInputQueue,
304 pub pending_wheel_event: Option<LogicalPosition>,
314 scroll_dirty: bool,
318 natural_scroll: bool,
331}
332
333#[derive(Debug, Clone)]
335pub struct AnimatedScrollState {
336 pub current_offset: LogicalPosition,
338 pub animation: Option<ScrollAnimation>,
340 pub last_activity: Instant,
342 pub container_rect: LogicalRect,
344 pub content_rect: LogicalRect,
346 pub virtual_scroll_size: Option<LogicalSize>,
349 pub virtual_scroll_offset: Option<LogicalPosition>,
351 pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
353 pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
355 pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
357 pub scrollbar_thickness: f32,
360 pub visual_width_px: f32,
363 pub has_horizontal_scrollbar: bool,
365 pub has_vertical_scrollbar: bool,
367}
368
369#[derive(Debug, Clone)]
376pub struct ScrollAnimation {
377 start_time: Instant,
379 duration: Duration,
381 start_offset: LogicalPosition,
383 target_offset: LogicalPosition,
385 easing: EasingFunction,
387}
388
389#[derive(Copy, Debug, Clone)]
394pub struct ScrollNodeInfo {
395 pub current_offset: LogicalPosition,
397 pub container_rect: LogicalRect,
399 pub content_rect: LogicalRect,
401 pub max_scroll_x: f32,
403 pub max_scroll_y: f32,
405 pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
407 pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
409 pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
411}
412
413#[derive(Debug, Default)]
415pub struct ScrollTickResult {
416 pub needs_repaint: bool,
418 pub updated_nodes: Vec<(DomId, NodeId)>,
420}
421
422impl ScrollManager {
425 #[must_use] pub fn new() -> Self {
427 let mut m = Self::default();
428 #[cfg(feature = "std")]
432 if let Some(v) = std::env::var_os("AZ_NATURAL_SCROLL") {
433 m.natural_scroll = matches!(v.to_str(), Some("1" | "true" | "TRUE"));
434 }
435 m
436 }
437
438 pub const fn set_natural_scroll(&mut self, natural: bool) {
443 self.natural_scroll = natural;
444 }
445
446 #[must_use] pub const fn is_natural_scroll(&self) -> bool {
448 self.natural_scroll
449 }
450
451 #[inline]
455 const fn scroll_sign(&self) -> f32 {
456 if self.natural_scroll {
457 1.0
458 } else {
459 -1.0
460 }
461 }
462
463 #[must_use] pub fn debug_counts(&self) -> (usize, usize) {
466 (self.states.len(), self.scrollbar_states.len())
467 }
468
469 pub(crate) const fn has_pending_scroll_changes(&self) -> bool {
472 self.scroll_dirty
473 }
474
475 #[must_use]
482 pub fn state_keys(&self) -> Vec<(DomId, NodeId)> {
483 self.states.keys().copied().collect()
484 }
485
486 #[must_use]
490 pub fn animating_keys(&self) -> Vec<(DomId, NodeId)> {
491 self.states
492 .iter()
493 .filter(|(_, s)| s.animation.is_some())
494 .map(|(k, _)| *k)
495 .collect()
496 }
497
498 pub const fn clear_scroll_dirty(&mut self) {
500 self.scroll_dirty = false;
501 }
502
503 #[must_use] pub fn build_scroll_offset_map(
511 &self,
512 dom_id: DomId,
513 scroll_ids: &std::collections::HashMap<usize, u64>,
514 ) -> std::collections::HashMap<u64, (f32, f32)> {
515 let mut map = std::collections::HashMap::new();
516 for ((d, node_id), state) in &self.states {
517 if *d != dom_id { continue; }
518 let node_idx = node_id.index();
522 if let Some(&scroll_id) = scroll_ids.get(&node_idx) {
523 map.insert(scroll_id, (state.current_offset.x, state.current_offset.y));
524 }
525 }
526 map
527 }
528
529 #[cfg(feature = "std")]
546 pub fn record_scroll_input(&mut self, mut input: ScrollInput) -> bool {
547 let sign = self.scroll_sign();
548 input.delta.x *= sign;
549 input.delta.y *= sign;
550 let was_empty = !self.scroll_input_queue.has_pending();
551 self.scroll_input_queue.push(input);
552 was_empty }
554
555 #[cfg(feature = "std")]
561 pub fn record_scroll_from_hit_test(
562 &mut self,
563 delta_x: f32,
564 delta_y: f32,
565 source: ScrollInputSource,
566 hover_manager: &crate::managers::hover::HoverManager,
567 input_point_id: &InputPointId,
568 now: Instant,
569 ) -> Option<(DomId, NodeId, bool)> {
570 self.pending_wheel_event = Some(LogicalPosition { x: delta_x, y: delta_y });
574
575 let hit_test = hover_manager.get_current(input_point_id)?;
576
577 let sign = self.scroll_sign();
588 let (eff_x, eff_y) = (delta_x * sign, delta_y * sign);
589 let target = self.select_scroll_target(
590 hit_test.hovered_nodes.iter().flat_map(|(dom_id, hit_node)| {
591 hit_node
592 .scroll_hit_test_nodes
593 .keys()
594 .rev()
595 .map(move |node_id| (*dom_id, *node_id))
596 }),
597 eff_x,
598 eff_y,
599 );
600 let (dom_id, node_id) = target?;
601 let input = ScrollInput {
602 dom_id,
603 node_id,
604 delta: LogicalPosition { x: delta_x, y: delta_y },
606 timestamp: now,
607 source,
608 };
609 let should_start_timer = self.record_scroll_input(input);
610 Some((dom_id, node_id, should_start_timer))
611 }
612
613 fn select_scroll_target<I>(
622 &self,
623 candidates: I,
624 eff_x: f32,
625 eff_y: f32,
626 ) -> Option<(DomId, NodeId)>
627 where
628 I: Iterator<Item = (DomId, NodeId)>,
629 {
630 let mut fallback = None;
631 for (dom_id, node_id) in candidates {
632 if !self.is_node_scrollable(dom_id, node_id) {
633 continue;
634 }
635 if fallback.is_none() {
636 fallback = Some((dom_id, node_id));
637 }
638 if self.can_consume_delta(dom_id, node_id, eff_x, eff_y) {
639 return Some((dom_id, node_id));
640 }
641 }
642 fallback
643 }
644
645 #[must_use] pub fn a11y_scroll_info(
650 &self,
651 dom_id: DomId,
652 node_id: NodeId,
653 ) -> Option<(LogicalPosition, f32, f32)> {
654 let state = self.states.get(&(dom_id, node_id))?;
655 let effective_width = state
656 .virtual_scroll_size
657 .map_or(state.content_rect.size.width, |s| s.width);
658 let effective_height = state
659 .virtual_scroll_size
660 .map_or(state.content_rect.size.height, |s| s.height);
661 let max_x = (effective_width - state.container_rect.size.width).max(0.0);
662 let max_y = (effective_height - state.container_rect.size.height).max(0.0);
663 if max_x <= 0.0 && max_y <= 0.0 {
664 return None;
665 }
666 Some((state.current_offset, max_x, max_y))
667 }
668
669 fn can_consume_delta(
673 &self,
674 dom_id: DomId,
675 node_id: NodeId,
676 eff_x: f32,
677 eff_y: f32,
678 ) -> bool {
679 const EPS: f32 = 0.5;
680 let Some(state) = self.states.get(&(dom_id, node_id)) else {
681 return false;
682 };
683 let effective_width = state
684 .virtual_scroll_size
685 .map_or(state.content_rect.size.width, |s| s.width);
686 let effective_height = state
687 .virtual_scroll_size
688 .map_or(state.content_rect.size.height, |s| s.height);
689 let max_x = (effective_width - state.container_rect.size.width).max(0.0);
690 let max_y = (effective_height - state.container_rect.size.height).max(0.0);
691 let off = state.current_offset;
692
693 let x_ok = if eff_x > EPS {
694 off.x < max_x - EPS
695 } else if eff_x < -EPS {
696 off.x > EPS
697 } else {
698 false
699 };
700 let y_ok = if eff_y > EPS {
701 off.y < max_y - EPS
702 } else if eff_y < -EPS {
703 off.y > EPS
704 } else {
705 false
706 };
707 x_ok || y_ok
708 }
709
710 #[cfg(feature = "std")]
715 #[must_use] pub fn get_input_queue(&self) -> ScrollInputQueue {
716 self.scroll_input_queue.clone()
717 }
718
719 #[allow(clippy::suboptimal_flops)] #[allow(clippy::needless_pass_by_value)]
723 pub fn tick(&mut self, now: Instant) -> ScrollTickResult {
724 let mut result = ScrollTickResult::default();
725 for ((dom_id, node_id), state) in &mut self.states {
726 if let Some(anim) = &state.animation {
727 let elapsed = now.duration_since(&anim.start_time);
728 let t = elapsed.div(&anim.duration).min(1.0);
729 let eased_t = apply_easing(t, anim.easing);
730
731 state.current_offset = LogicalPosition {
732 x: anim.start_offset.x + (anim.target_offset.x - anim.start_offset.x) * eased_t,
733 y: anim.start_offset.y + (anim.target_offset.y - anim.start_offset.y) * eased_t,
734 };
735 result.needs_repaint = true;
736 result.updated_nodes.push((*dom_id, *node_id));
737
738 if t >= 1.0 {
739 state.animation = None;
740 }
741 }
742 }
743 result
744 }
745
746 #[must_use] pub fn has_active_animations(&self) -> bool {
751 self.states.values().any(|s| s.animation.is_some())
752 }
753
754 #[must_use] pub fn find_scroll_parent(
760 &self,
761 dom_id: DomId,
762 node_id: NodeId,
763 node_hierarchy: &[azul_core::styled_dom::NodeHierarchyItem],
764 ) -> Option<NodeId> {
765 let mut current = Some(node_id);
766 while let Some(nid) = current {
767 if self.states.contains_key(&(dom_id, nid)) && nid != node_id {
768 return Some(nid);
769 }
770 current = node_hierarchy
771 .get(nid.index())
772 .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
773 }
774 None
775 }
776
777 fn is_node_scrollable(&self, dom_id: DomId, node_id: NodeId) -> bool {
783 let result = self.states.get(&(dom_id, node_id)).is_some_and(|state| {
784 let effective_width = state.virtual_scroll_size
785 .map_or(state.content_rect.size.width, |s| s.width);
786 let effective_height = state.virtual_scroll_size
787 .map_or(state.content_rect.size.height, |s| s.height);
788 let has_horizontal = effective_width > state.container_rect.size.width;
789 let has_vertical = effective_height > state.container_rect.size.height;
790 has_horizontal || has_vertical
791 });
792 result
793 }
794
795 pub fn set_scroll_position(
798 &mut self,
799 dom_id: DomId,
800 node_id: NodeId,
801 position: LogicalPosition,
802 now: Instant,
803 ) {
804 let state = self
805 .states
806 .entry((dom_id, node_id))
807 .or_insert_with(|| AnimatedScrollState::new(now.clone()));
808 let clamped = state.clamp(position);
809 if (clamped.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
810 || (clamped.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
811 {
812 self.scroll_dirty = true;
813 }
814 state.current_offset = clamped;
815 state.animation = None;
816 state.last_activity = now;
817 }
818
819 pub fn set_scroll_position_unclamped(
824 &mut self,
825 dom_id: DomId,
826 node_id: NodeId,
827 position: LogicalPosition,
828 now: Instant,
829 ) {
830 let state = self
831 .states
832 .entry((dom_id, node_id))
833 .or_insert_with(|| AnimatedScrollState::new(now.clone()));
834 if (position.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
835 || (position.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
836 {
837 self.scroll_dirty = true;
838 }
839 state.current_offset = position;
840 state.animation = None;
841 state.last_activity = now;
842 }
843
844 pub fn scroll_by(
846 &mut self,
847 dom_id: DomId,
848 node_id: NodeId,
849 delta: LogicalPosition,
850 duration: Duration,
851 easing: EasingFunction,
852 now: Instant,
853 ) {
854 let current = self.get_current_offset(dom_id, node_id).unwrap_or_default();
855 let target = LogicalPosition {
856 x: current.x + delta.x,
857 y: current.y + delta.y,
858 };
859 self.scroll_to(dom_id, node_id, target, duration, easing, now);
860 }
861
862 pub fn scroll_to(
866 &mut self,
867 dom_id: DomId,
868 node_id: NodeId,
869 target: LogicalPosition,
870 duration: Duration,
871 easing: EasingFunction,
872 now: Instant,
873 ) {
874 let is_zero = match &duration {
876 Duration::System(s) => s.secs == 0 && s.nanos == 0,
877 Duration::Tick(t) => t.tick_diff == 0,
878 };
879
880 if is_zero {
881 self.set_scroll_position(dom_id, node_id, target, now);
882 return;
883 }
884
885 let state = self
886 .states
887 .entry((dom_id, node_id))
888 .or_insert_with(|| AnimatedScrollState::new(now.clone()));
889 let clamped_target = state.clamp(target);
890 state.animation = Some(ScrollAnimation {
891 start_time: now.clone(),
892 duration,
893 start_offset: state.current_offset,
894 target_offset: clamped_target,
895 easing,
896 });
897 state.last_activity = now;
898 }
899
900 pub fn update_node_bounds(
902 &mut self,
903 dom_id: DomId,
904 node_id: NodeId,
905 container_rect: LogicalRect,
906 content_rect: LogicalRect,
907 now: Instant,
908 ) {
909 let state = self
910 .states
911 .entry((dom_id, node_id))
912 .or_insert_with(|| AnimatedScrollState::new(now));
913 state.container_rect = container_rect;
914 state.content_rect = content_rect;
915 state.current_offset = state.clamp(state.current_offset);
916 }
917
918 pub fn update_virtual_scroll_bounds(
927 &mut self,
928 dom_id: DomId,
929 node_id: NodeId,
930 virtual_scroll_size: LogicalSize,
931 virtual_scroll_offset: Option<LogicalPosition>,
932 ) {
933 let key = (dom_id, node_id);
934 let state = self.states.entry(key).or_insert_with(|| {
935 AnimatedScrollState::new(Instant::now())
939 });
940 state.virtual_scroll_size = Some(virtual_scroll_size);
941 state.virtual_scroll_offset = virtual_scroll_offset;
942 state.current_offset = state.clamp(state.current_offset);
944 }
945
946 #[must_use] pub fn get_current_offset(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
948 self.states
949 .get(&(dom_id, node_id))
950 .map(|s| s.current_offset)
951 }
952
953 #[must_use] pub fn get_last_activity_time(&self, dom_id: DomId, node_id: NodeId) -> Option<Instant> {
955 self.states
956 .get(&(dom_id, node_id))
957 .map(|s| s.last_activity.clone())
958 }
959
960 #[must_use] pub fn get_scroll_state(&self, dom_id: DomId, node_id: NodeId) -> Option<&AnimatedScrollState> {
962 self.states.get(&(dom_id, node_id))
963 }
964
965 #[must_use] pub fn get_scroll_node_info(
973 &self,
974 dom_id: DomId,
975 node_id: NodeId,
976 ) -> Option<ScrollNodeInfo> {
977 let state = self.states.get(&(dom_id, node_id))?;
978 let effective_content_width = state.virtual_scroll_size
979 .map_or(state.content_rect.size.width, |s| s.width);
980 let effective_content_height = state.virtual_scroll_size
981 .map_or(state.content_rect.size.height, |s| s.height);
982 let max_x = (effective_content_width - state.container_rect.size.width).max(0.0);
983 let max_y = (effective_content_height - state.container_rect.size.height).max(0.0);
984 Some(ScrollNodeInfo {
985 current_offset: state.current_offset,
986 container_rect: state.container_rect,
987 content_rect: state.content_rect,
988 max_scroll_x: max_x,
989 max_scroll_y: max_y,
990 overscroll_behavior_x: state.overscroll_behavior_x,
991 overscroll_behavior_y: state.overscroll_behavior_y,
992 overflow_scrolling: state.overflow_scrolling,
993 })
994 }
995
996 #[must_use] pub fn get_scroll_states_for_dom(&self, dom_id: DomId) -> BTreeMap<NodeId, ScrollPosition> {
998 if self.states.is_empty() {
1003 return BTreeMap::new();
1004 }
1005 self.states
1006 .iter()
1007 .filter(|((d, _), _)| *d == dom_id)
1008 .map(|((_, node_id), state)| {
1009 let effective_content_size = state.virtual_scroll_size
1012 .unwrap_or(state.content_rect.size);
1013 (
1014 *node_id,
1015 ScrollPosition {
1016 parent_rect: state.container_rect,
1017 children_rect: LogicalRect::new(
1018 state.current_offset,
1019 effective_content_size,
1020 ),
1021 },
1022 )
1023 })
1024 .collect()
1025 }
1026
1027 pub fn register_or_update_scroll_node(
1034 &mut self,
1035 dom_id: DomId,
1036 node_id: NodeId,
1037 container_rect: LogicalRect,
1038 content_size: LogicalSize,
1039 now: Instant,
1040 scrollbar_thickness: f32,
1041 visual_width_px: f32,
1042 has_horizontal_scrollbar: bool,
1043 has_vertical_scrollbar: bool,
1044 ) {
1045 let key = (dom_id, node_id);
1046
1047 let content_rect = LogicalRect {
1048 origin: LogicalPosition::zero(),
1049 size: content_size,
1050 };
1051
1052 if let Some(existing) = self.states.get_mut(&key) {
1053 existing.container_rect = container_rect;
1055 existing.content_rect = content_rect;
1056 existing.scrollbar_thickness = scrollbar_thickness;
1057 existing.visual_width_px = visual_width_px;
1058 existing.has_horizontal_scrollbar = has_horizontal_scrollbar;
1059 existing.has_vertical_scrollbar = has_vertical_scrollbar;
1060 existing.current_offset = existing.clamp(existing.current_offset);
1062 } else {
1063 self.states.insert(
1065 key,
1066 AnimatedScrollState {
1067 current_offset: LogicalPosition::zero(),
1068 animation: None,
1069 last_activity: now,
1070 container_rect,
1071 content_rect,
1072 virtual_scroll_size: None,
1073 virtual_scroll_offset: None,
1074 overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1075 overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1076 overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
1077 scrollbar_thickness,
1078 visual_width_px,
1079 has_horizontal_scrollbar,
1080 has_vertical_scrollbar,
1081 },
1082 );
1083 }
1084 }
1085
1086 pub fn calculate_scrollbar_states(&mut self) {
1092 self.scrollbar_states.clear();
1093
1094 for orientation in [ScrollbarOrientation::Vertical, ScrollbarOrientation::Horizontal] {
1097 let states: Vec<_> = self
1098 .states
1099 .iter()
1100 .filter(|(_, s)| {
1101 let (effective, container) = match orientation {
1102 ScrollbarOrientation::Vertical => (
1103 s.virtual_scroll_size.map_or(s.content_rect.size.height, |vs| vs.height),
1104 s.container_rect.size.height,
1105 ),
1106 ScrollbarOrientation::Horizontal => (
1107 s.virtual_scroll_size.map_or(s.content_rect.size.width, |vs| vs.width),
1108 s.container_rect.size.width,
1109 ),
1110 };
1111 effective > container
1112 })
1113 .map(|((dom_id, node_id), scroll_state)| {
1114 let state = Self::calculate_scrollbar_state_from_geometry(
1115 scroll_state,
1116 orientation,
1117 );
1118 ((*dom_id, *node_id, orientation), state)
1119 })
1120 .collect();
1121
1122 self.scrollbar_states.extend(states);
1123 }
1124 }
1125
1126 fn calculate_scrollbar_state_from_geometry(
1128 scroll_state: &AnimatedScrollState,
1129 orientation: ScrollbarOrientation,
1130 ) -> ScrollbarState {
1131 let scrollbar_thickness = if scroll_state.visual_width_px > 0.0 {
1132 scroll_state.visual_width_px
1133 } else if scroll_state.scrollbar_thickness > 0.0 {
1134 scroll_state.scrollbar_thickness
1135 } else {
1136 crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX
1137 };
1138
1139 let content_size = scroll_state.virtual_scroll_size
1140 .map_or(scroll_state.content_rect.size, |vs| vs);
1141
1142 let scroll_offset = match orientation {
1143 ScrollbarOrientation::Vertical => scroll_state.current_offset.y,
1144 ScrollbarOrientation::Horizontal => scroll_state.current_offset.x,
1145 };
1146
1147 let has_other_scrollbar = match orientation {
1148 ScrollbarOrientation::Vertical => scroll_state.has_horizontal_scrollbar,
1149 ScrollbarOrientation::Horizontal => scroll_state.has_vertical_scrollbar,
1150 };
1151
1152 let is_overlay = scroll_state.scrollbar_thickness == 0.0;
1154 let button_size = if is_overlay { 0.0 } else { scrollbar_thickness };
1155 let geom = compute_scrollbar_geometry_with_button_size(
1156 orientation,
1157 scroll_state.container_rect,
1158 content_size,
1159 scroll_offset,
1160 scrollbar_thickness,
1161 has_other_scrollbar,
1162 button_size,
1163 );
1164
1165 let scale = match orientation {
1167 ScrollbarOrientation::Vertical => {
1168 LogicalPosition::new(1.0, geom.track_rect.size.height / scrollbar_thickness)
1169 }
1170 ScrollbarOrientation::Horizontal => {
1171 LogicalPosition::new(geom.track_rect.size.width / scrollbar_thickness, 1.0)
1172 }
1173 };
1174
1175 ScrollbarState {
1176 visible: true,
1177 orientation,
1178 base_size: scrollbar_thickness,
1179 scale,
1180 thumb_position_ratio: geom.scroll_ratio,
1181 thumb_size_ratio: geom.thumb_size_ratio,
1182 track_rect: geom.track_rect,
1183 button_size: geom.button_size,
1184 usable_track_length: geom.usable_track_length,
1185 thumb_length: geom.thumb_length,
1186 thumb_offset: geom.thumb_offset,
1187 }
1188 }
1189
1190 #[must_use] pub fn get_scrollbar_state(
1192 &self,
1193 dom_id: DomId,
1194 node_id: NodeId,
1195 orientation: ScrollbarOrientation,
1196 ) -> Option<&ScrollbarState> {
1197 self.scrollbar_states.get(&(dom_id, node_id, orientation))
1198 }
1199
1200 pub(crate) fn iter_scrollbar_states(
1202 &self,
1203 ) -> impl Iterator<Item = ((DomId, NodeId, ScrollbarOrientation), &ScrollbarState)> + '_ {
1204 self.scrollbar_states.iter().map(|(k, v)| (*k, v))
1205 }
1206
1207 pub(crate) fn hit_test_scrollbar(
1212 &self,
1213 dom_id: DomId,
1214 node_id: NodeId,
1215 global_pos: LogicalPosition,
1216 ) -> Option<ScrollbarHit> {
1217 for orientation in [
1219 ScrollbarOrientation::Vertical,
1220 ScrollbarOrientation::Horizontal,
1221 ] {
1222 let Some(scrollbar_state) = self.scrollbar_states.get(&(dom_id, node_id, orientation)) else {
1223 continue;
1224 };
1225
1226 if !scrollbar_state.visible {
1227 continue;
1228 }
1229
1230 if !scrollbar_state.track_rect.contains(global_pos) {
1232 continue;
1233 }
1234
1235 let local_pos = LogicalPosition::new(
1237 global_pos.x - scrollbar_state.track_rect.origin.x,
1238 global_pos.y - scrollbar_state.track_rect.origin.y,
1239 );
1240
1241 let component = scrollbar_state.hit_test_component(local_pos);
1243
1244 return Some(ScrollbarHit {
1245 dom_id,
1246 node_id,
1247 orientation,
1248 component,
1249 local_position: local_pos,
1250 global_position: global_pos,
1251 });
1252 }
1253
1254 None
1255 }
1256
1257 #[must_use] pub fn hit_test_scrollbars(&self, global_pos: LogicalPosition) -> Option<ScrollbarHit> {
1265 for ((dom_id, node_id, orientation), scrollbar_state) in self.scrollbar_states.iter().rev()
1267 {
1268 if !scrollbar_state.visible {
1269 continue;
1270 }
1271
1272 if !scrollbar_state.track_rect.contains(global_pos) {
1274 continue;
1275 }
1276
1277 let local_pos = LogicalPosition::new(
1279 global_pos.x - scrollbar_state.track_rect.origin.x,
1280 global_pos.y - scrollbar_state.track_rect.origin.y,
1281 );
1282
1283 let component = scrollbar_state.hit_test_component(local_pos);
1285
1286 return Some(ScrollbarHit {
1287 dom_id: *dom_id,
1288 node_id: *node_id,
1289 orientation: *orientation,
1290 component,
1291 local_position: local_pos,
1292 global_position: global_pos,
1293 });
1294 }
1295
1296 None
1297 }
1298}
1299
1300impl AnimatedScrollState {
1303 pub(crate) const fn new(now: Instant) -> Self {
1306 Self {
1307 current_offset: LogicalPosition::zero(),
1308 animation: None,
1309 last_activity: now,
1310 container_rect: LogicalRect::zero(),
1311 content_rect: LogicalRect::zero(),
1312 virtual_scroll_size: None,
1313 virtual_scroll_offset: None,
1314 overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1315 overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1316 overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
1317 scrollbar_thickness: crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX,
1318 visual_width_px: 0.0,
1319 has_horizontal_scrollbar: false,
1320 has_vertical_scrollbar: false,
1321 }
1322 }
1323
1324 pub(crate) fn clamp(&self, position: LogicalPosition) -> LogicalPosition {
1329 let effective_width = self.virtual_scroll_size
1330 .map_or(self.content_rect.size.width, |s| s.width);
1331 let effective_height = self.virtual_scroll_size
1332 .map_or(self.content_rect.size.height, |s| s.height);
1333 let max_x = (effective_width - self.container_rect.size.width).max(0.0);
1334 let max_y = (effective_height - self.container_rect.size.height).max(0.0);
1335 LogicalPosition {
1336 x: position.x.max(0.0).min(max_x),
1337 y: position.y.max(0.0).min(max_y),
1338 }
1339 }
1340}
1341
1342#[allow(clippy::suboptimal_flops)] pub(crate) fn apply_easing(t: f32, easing: EasingFunction) -> f32 {
1348 match easing {
1349 EasingFunction::Linear => t,
1350 EasingFunction::EaseOut => 1.0 - (1.0 - t).powi(3),
1351 EasingFunction::EaseInOut => {
1352 if t < 0.5 {
1353 4.0 * t * t * t
1354 } else {
1355 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
1356 }
1357 }
1358 }
1359}
1360
1361impl crate::managers::NodeIdRemap for ScrollManager {
1362 fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
1371 crate::managers::remap_dom_keys(&mut self.states, dom, map);
1372
1373 let old = core::mem::take(&mut self.scrollbar_states);
1374 for ((d, old_node_id, orientation), state) in old {
1375 if d != dom {
1376 self.scrollbar_states
1377 .insert((d, old_node_id, orientation), state);
1378 } else if let Some(new_node_id) = map.resolve(old_node_id) {
1379 self.scrollbar_states
1380 .insert((d, new_node_id, orientation), state);
1381 }
1382 }
1383 }
1384}
1385
1386#[cfg(all(test, feature = "std"))]
1390mod natural_scroll_tests {
1391 use super::*;
1392 use azul_core::dom::{DomId, NodeId};
1393 use azul_core::geom::LogicalPosition;
1394 use azul_core::task::Instant;
1395
1396 fn raw_input(dx: f32, dy: f32) -> ScrollInput {
1397 ScrollInput {
1398 dom_id: DomId::ROOT_ID,
1399 node_id: NodeId::new(0),
1400 delta: LogicalPosition::new(dx, dy),
1401 timestamp: Instant::now(),
1402 source: ScrollInputSource::WheelDiscrete,
1403 }
1404 }
1405
1406 #[test]
1407 #[allow(clippy::float_cmp)] fn default_is_traditional_and_inverts_raw_delta() {
1409 let mut m = ScrollManager::new();
1413 assert!(!m.is_natural_scroll(), "default must be traditional");
1414 m.record_scroll_input(raw_input(3.0, 10.0));
1415 let q = m.get_input_queue().take_all();
1416 assert_eq!(q.len(), 1);
1417 assert_eq!(q[0].delta.x, -3.0, "x must be inverted by the default sign");
1418 assert_eq!(q[0].delta.y, -10.0, "y must be inverted by the default sign");
1419 }
1420
1421 #[test]
1422 #[allow(clippy::float_cmp)] fn natural_passes_raw_delta_through() {
1424 let mut m = ScrollManager::new();
1425 m.set_natural_scroll(true);
1426 assert!(m.is_natural_scroll());
1427 m.record_scroll_input(raw_input(3.0, 10.0));
1428 let q = m.get_input_queue().take_all();
1429 assert_eq!(q.len(), 1);
1430 assert_eq!(q[0].delta.x, 3.0, "natural mode must NOT invert x");
1431 assert_eq!(q[0].delta.y, 10.0, "natural mode must NOT invert y");
1432 }
1433
1434 #[test]
1435 #[allow(clippy::float_cmp)] fn toggling_flips_sign_for_subsequent_input() {
1437 let mut m = ScrollManager::new();
1440 m.record_scroll_input(raw_input(0.0, 5.0));
1441 m.set_natural_scroll(true);
1442 m.record_scroll_input(raw_input(0.0, 5.0));
1443 let q = m.get_input_queue().take_all();
1444 assert_eq!(q.len(), 2);
1445 assert_eq!(q[0].delta.y, -5.0, "traditional first");
1446 assert_eq!(q[1].delta.y, 5.0, "natural after toggle");
1447 }
1448
1449 fn nested_setup() -> (ScrollManager, DomId, NodeId, NodeId) {
1452 use azul_core::geom::{LogicalRect, LogicalSize};
1453
1454 let now = Instant::now();
1455 let mut m = ScrollManager::new();
1456 let dom = DomId::ROOT_ID;
1457 let outer = NodeId::from_usize(1).unwrap();
1459 let inner = NodeId::from_usize(9).unwrap();
1460 m.register_or_update_scroll_node(
1462 dom,
1463 outer,
1464 LogicalRect {
1465 origin: LogicalPosition::zero(),
1466 size: LogicalSize { width: 200.0, height: 200.0 },
1467 },
1468 LogicalSize { width: 200.0, height: 1000.0 },
1469 now.clone(),
1470 8.0,
1471 8.0,
1472 false,
1473 true,
1474 );
1475 m.register_or_update_scroll_node(
1477 dom,
1478 inner,
1479 LogicalRect {
1480 origin: LogicalPosition::zero(),
1481 size: LogicalSize { width: 100.0, height: 100.0 },
1482 },
1483 LogicalSize { width: 100.0, height: 300.0 },
1484 now,
1485 8.0,
1486 8.0,
1487 false,
1488 true,
1489 );
1490 (m, dom, outer, inner)
1491 }
1492
1493 #[test]
1494 fn nested_scroll_prefers_innermost_with_room() {
1495 let (m, dom, outer, inner) = nested_setup();
1496 let picked = m.select_scroll_target(
1498 [(dom, inner), (dom, outer)].into_iter(),
1499 0.0,
1500 1.0,
1501 );
1502 assert_eq!(picked, Some((dom, inner)), "inner has room → inner wins");
1503 }
1504
1505 #[test]
1506 fn nested_scroll_hands_off_to_ancestor_at_boundary() {
1507 let (mut m, dom, outer, inner) = nested_setup();
1508 m.states.get_mut(&(dom, inner)).unwrap().current_offset =
1510 LogicalPosition { x: 0.0, y: 200.0 };
1511
1512 let down = m.select_scroll_target(
1513 [(dom, inner), (dom, outer)].into_iter(),
1514 0.0,
1515 1.0,
1516 );
1517 assert_eq!(down, Some((dom, outer)), "inner pinned at bottom → handoff");
1518
1519 let up = m.select_scroll_target(
1520 [(dom, inner), (dom, outer)].into_iter(),
1521 0.0,
1522 -1.0,
1523 );
1524 assert_eq!(up, Some((dom, inner)), "inner has room upward → inner again");
1525 }
1526
1527 #[test]
1528 fn nested_scroll_falls_back_to_innermost_when_all_pinned() {
1529 let (mut m, dom, outer, inner) = nested_setup();
1530 m.states.get_mut(&(dom, inner)).unwrap().current_offset =
1531 LogicalPosition { x: 0.0, y: 200.0 };
1532 m.states.get_mut(&(dom, outer)).unwrap().current_offset =
1533 LogicalPosition { x: 0.0, y: 800.0 };
1534
1535 let picked = m.select_scroll_target(
1536 [(dom, inner), (dom, outer)].into_iter(),
1537 0.0,
1538 1.0,
1539 );
1540 assert_eq!(
1541 picked,
1542 Some((dom, inner)),
1543 "everything pinned → innermost fallback (gesture stays under pointer)"
1544 );
1545 }
1546}
1547
1548#[cfg(all(test, feature = "std"))]
1558mod autotest_generated {
1559 #![allow(clippy::float_cmp)] use std::collections::HashMap;
1562
1563 use azul_core::{
1564 dom::{DomId, NodeId, ScrollbarOrientation},
1565 events::EasingFunction,
1566 geom::{LogicalPosition, LogicalRect, LogicalSize},
1567 hit_test::{FullHitTest, HitTest, OverflowingScrollNode, ScrollHitTestItem},
1568 styled_dom::NodeHierarchyItem,
1569 task::{Duration, Instant, SystemTick, SystemTickDiff, SystemTimeDiff},
1570 };
1571
1572 use super::*;
1573 use crate::managers::hover::HoverManager;
1574
1575 const DOM: DomId = DomId::ROOT_ID;
1578 const DOM1: DomId = DomId { inner: 1 };
1579
1580 fn node(i: usize) -> NodeId {
1581 NodeId::new(i)
1582 }
1583
1584 fn at(t: u64) -> Instant {
1586 Instant::Tick(SystemTick::new(t))
1587 }
1588
1589 fn tick_dur(d: u64) -> Duration {
1590 Duration::Tick(SystemTickDiff { tick_diff: d })
1591 }
1592
1593 fn sys_dur(secs: u64, nanos: u32) -> Duration {
1594 Duration::System(SystemTimeDiff { secs, nanos })
1595 }
1596
1597 fn pos(x: f32, y: f32) -> LogicalPosition {
1598 LogicalPosition::new(x, y)
1599 }
1600
1601 fn size(w: f32, h: f32) -> LogicalSize {
1602 LogicalSize::new(w, h)
1603 }
1604
1605 fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
1606 LogicalRect::new(pos(x, y), size(w, h))
1607 }
1608
1609 fn mgr(container: LogicalSize, content: LogicalSize) -> ScrollManager {
1611 let mut m = ScrollManager::new();
1612 m.register_or_update_scroll_node(
1613 DOM,
1614 node(0),
1615 LogicalRect::new(LogicalPosition::zero(), container),
1616 content,
1617 at(0),
1618 16.0,
1619 16.0,
1620 false,
1621 true,
1622 );
1623 m
1624 }
1625
1626 fn state(container: LogicalSize, content: LogicalSize) -> AnimatedScrollState {
1628 let mut s = AnimatedScrollState::new(at(0));
1629 s.container_rect = LogicalRect::new(LogicalPosition::zero(), container);
1630 s.content_rect = LogicalRect::new(LogicalPosition::zero(), content);
1631 s
1632 }
1633
1634 fn input(dx: f32, dy: f32, ts: u64) -> ScrollInput {
1635 ScrollInput {
1636 dom_id: DOM,
1637 node_id: node(0),
1638 delta: pos(dx, dy),
1639 timestamp: at(ts),
1640 source: ScrollInputSource::WheelDiscrete,
1641 }
1642 }
1643
1644 fn scrollbar(
1645 orientation: ScrollbarOrientation,
1646 track: LogicalRect,
1647 button_size: f32,
1648 thumb_offset: f32,
1649 thumb_length: f32,
1650 ) -> ScrollbarState {
1651 ScrollbarState {
1652 visible: true,
1653 orientation,
1654 base_size: 16.0,
1655 scale: LogicalPosition::new(1.0, 1.0),
1656 thumb_position_ratio: 0.0,
1657 thumb_size_ratio: 0.5,
1658 track_rect: track,
1659 button_size,
1660 usable_track_length: 0.0,
1661 thumb_length,
1662 thumb_offset,
1663 }
1664 }
1665
1666 fn hover_over(nodes: &[usize]) -> HoverManager {
1670 let mut ht = HitTest::empty();
1671 for n in nodes {
1672 ht.scroll_hit_test_nodes.insert(
1673 node(*n),
1674 ScrollHitTestItem {
1675 point_in_viewport: LogicalPosition::zero(),
1676 point_relative_to_item: LogicalPosition::zero(),
1677 scroll_node: OverflowingScrollNode::default(),
1678 },
1679 );
1680 }
1681 let mut full = FullHitTest::empty(None);
1682 full.hovered_nodes.insert(DOM, ht);
1683 let mut hm = HoverManager::new();
1684 hm.push_hit_test(InputPointId::Mouse, full);
1685 hm
1686 }
1687
1688 #[test]
1692 fn apply_easing_endpoints_are_exact_for_every_curve() {
1693 for e in [
1696 EasingFunction::Linear,
1697 EasingFunction::EaseOut,
1698 EasingFunction::EaseInOut,
1699 ] {
1700 assert_eq!(apply_easing(0.0, e), 0.0, "f(0) must be 0 for {e:?}");
1701 assert_eq!(apply_easing(1.0, e), 1.0, "f(1) must be 1 for {e:?}");
1702 }
1703 }
1704
1705 #[test]
1706 fn apply_easing_is_monotonic_and_bounded_on_the_unit_interval() {
1707 for e in [
1708 EasingFunction::Linear,
1709 EasingFunction::EaseOut,
1710 EasingFunction::EaseInOut,
1711 ] {
1712 let mut prev = f32::NEG_INFINITY;
1713 for i in 0..=100 {
1714 let t = i as f32 / 100.0;
1715 let v = apply_easing(t, e);
1716 assert!(v.is_finite(), "{e:?}({t}) must be finite, got {v}");
1717 assert!(
1718 (-1e-6..=1.0 + 1e-6).contains(&v),
1719 "{e:?}({t}) = {v} escaped [0, 1]"
1720 );
1721 assert!(v >= prev - 1e-6, "{e:?} must not go backwards at t={t}");
1722 prev = v;
1723 }
1724 }
1725 }
1726
1727 #[test]
1728 fn apply_easing_nan_propagates_without_panicking() {
1729 for e in [
1731 EasingFunction::Linear,
1732 EasingFunction::EaseOut,
1733 EasingFunction::EaseInOut,
1734 ] {
1735 assert!(
1736 apply_easing(f32::NAN, e).is_nan(),
1737 "{e:?}(NaN) must be NaN, not a silently-wrong number"
1738 );
1739 }
1740 }
1741
1742 #[test]
1743 fn apply_easing_infinities_saturate_to_infinity_not_panic() {
1744 assert_eq!(apply_easing(f32::INFINITY, EasingFunction::Linear), f32::INFINITY);
1745 assert_eq!(
1746 apply_easing(f32::NEG_INFINITY, EasingFunction::Linear),
1747 f32::NEG_INFINITY
1748 );
1749 assert_eq!(apply_easing(f32::INFINITY, EasingFunction::EaseOut), f32::INFINITY);
1751 assert_eq!(
1752 apply_easing(f32::NEG_INFINITY, EasingFunction::EaseOut),
1753 f32::NEG_INFINITY
1754 );
1755 assert_eq!(
1757 apply_easing(f32::INFINITY, EasingFunction::EaseInOut),
1758 f32::INFINITY
1759 );
1760 assert_eq!(
1761 apply_easing(f32::NEG_INFINITY, EasingFunction::EaseInOut),
1762 f32::NEG_INFINITY
1763 );
1764 }
1765
1766 #[test]
1767 fn apply_easing_f32_extremes_do_not_panic() {
1768 for e in [
1771 EasingFunction::Linear,
1772 EasingFunction::EaseOut,
1773 EasingFunction::EaseInOut,
1774 ] {
1775 let hi = apply_easing(f32::MAX, e);
1776 let lo = apply_easing(f32::MIN, e);
1777 assert!(!hi.is_nan(), "{e:?}(f32::MAX) must not be NaN");
1778 assert!(!lo.is_nan(), "{e:?}(f32::MIN) must not be NaN");
1779 }
1780 assert!(apply_easing(f32::MIN_POSITIVE, EasingFunction::EaseInOut).abs() < 1e-30);
1782 }
1783
1784 #[test]
1785 fn apply_easing_negative_t_is_deterministic_extrapolation() {
1786 assert_eq!(apply_easing(-1.0, EasingFunction::Linear), -1.0);
1789 assert_eq!(apply_easing(-1.0, EasingFunction::EaseOut), -7.0);
1790 assert_eq!(apply_easing(-1.0, EasingFunction::EaseInOut), -4.0);
1791 }
1792
1793 #[test]
1794 fn apply_easing_ease_in_out_is_continuous_at_the_branch_boundary() {
1795 assert_eq!(apply_easing(0.5, EasingFunction::EaseInOut), 0.5);
1797 let just_below = apply_easing(0.499_999, EasingFunction::EaseInOut);
1798 assert!(
1799 (just_below - 0.5).abs() < 1e-4,
1800 "discontinuity at the 0.5 branch: {just_below}"
1801 );
1802 assert_eq!(apply_easing(0.5, EasingFunction::EaseOut), 0.875);
1803 }
1804
1805 #[test]
1809 fn animated_scroll_state_new_starts_at_scroll_origin() {
1810 let s = AnimatedScrollState::new(at(0));
1811 assert_eq!(s.current_offset, LogicalPosition::zero());
1812 assert!(s.animation.is_none());
1813 assert_eq!(s.container_rect, LogicalRect::zero());
1814 assert_eq!(s.content_rect, LogicalRect::zero());
1815 assert!(s.virtual_scroll_size.is_none());
1816 assert!(s.virtual_scroll_offset.is_none());
1817 assert!(!s.has_horizontal_scrollbar);
1818 assert!(!s.has_vertical_scrollbar);
1819 assert_eq!(s.clamp(pos(1e9, 1e9)), LogicalPosition::zero());
1821 }
1822
1823 #[test]
1827 fn clamp_pins_to_zero_and_max_travel() {
1828 let s = state(size(100.0, 100.0), size(100.0, 500.0));
1829 assert_eq!(s.clamp(pos(0.0, 0.0)), pos(0.0, 0.0));
1831 assert_eq!(s.clamp(pos(50.0, 250.0)), pos(0.0, 250.0));
1832 assert_eq!(s.clamp(pos(-1.0, -1.0)), pos(0.0, 0.0));
1833 assert_eq!(s.clamp(pos(9999.0, 9999.0)), pos(0.0, 400.0));
1834 }
1835
1836 #[test]
1837 fn clamp_never_produces_negative_max_when_content_is_smaller_than_container() {
1838 let s = state(size(500.0, 500.0), size(10.0, 10.0));
1840 assert_eq!(s.clamp(pos(100.0, 100.0)), LogicalPosition::zero());
1841 assert_eq!(s.clamp(pos(-100.0, -100.0)), LogicalPosition::zero());
1842 }
1843
1844 #[test]
1845 fn clamp_nan_position_collapses_to_origin_never_stores_nan() {
1846 let s = state(size(100.0, 100.0), size(100.0, 500.0));
1849 let c = s.clamp(pos(f32::NAN, f32::NAN));
1850 assert!(!c.x.is_nan() && !c.y.is_nan(), "clamp must not leak NaN");
1851 assert_eq!(c, LogicalPosition::zero());
1852 }
1853
1854 #[test]
1855 fn clamp_infinite_position_saturates_to_max_travel() {
1856 let s = state(size(100.0, 100.0), size(100.0, 500.0));
1857 assert_eq!(s.clamp(pos(f32::INFINITY, f32::INFINITY)), pos(0.0, 400.0));
1858 assert_eq!(
1859 s.clamp(pos(f32::NEG_INFINITY, f32::NEG_INFINITY)),
1860 LogicalPosition::zero()
1861 );
1862 assert_eq!(s.clamp(pos(f32::MAX, f32::MAX)), pos(0.0, 400.0));
1863 assert_eq!(s.clamp(pos(f32::MIN, f32::MIN)), LogicalPosition::zero());
1864 }
1865
1866 #[test]
1867 fn clamp_nan_geometry_degrades_to_zero_travel() {
1868 let s = state(size(100.0, 100.0), size(f32::NAN, f32::NAN));
1870 let c = s.clamp(pos(50.0, 50.0));
1871 assert!(!c.x.is_nan() && !c.y.is_nan());
1872 assert_eq!(c, LogicalPosition::zero());
1873 }
1874
1875 #[test]
1876 fn clamp_infinite_content_minus_infinite_container_is_zero_travel_not_nan() {
1877 let s = state(
1879 size(f32::INFINITY, f32::INFINITY),
1880 size(f32::INFINITY, f32::INFINITY),
1881 );
1882 let c = s.clamp(pos(10.0, 10.0));
1883 assert!(!c.x.is_nan() && !c.y.is_nan());
1884 assert_eq!(c, LogicalPosition::zero());
1885 }
1886
1887 #[test]
1888 fn clamp_prefers_virtual_scroll_size_over_content_rect() {
1889 let mut s = state(size(100.0, 100.0), size(100.0, 120.0));
1890 assert_eq!(s.clamp(pos(0.0, 1e9)), pos(0.0, 20.0), "content_rect bound");
1891 s.virtual_scroll_size = Some(size(100.0, 10_000.0));
1892 assert_eq!(
1893 s.clamp(pos(0.0, 1e9)),
1894 pos(0.0, 9900.0),
1895 "virtual size must override content_rect"
1896 );
1897 }
1898
1899 #[test]
1903 fn input_queue_new_is_empty_and_default_matches() {
1904 let q = ScrollInputQueue::new();
1905 assert!(!q.has_pending());
1906 assert!(q.take_all().is_empty());
1907 assert!(q.take_recent(10).is_empty());
1908 assert!(!ScrollInputQueue::default().has_pending());
1909 }
1910
1911 #[test]
1912 fn input_queue_take_all_drains_and_preserves_push_order() {
1913 let q = ScrollInputQueue::new();
1914 q.push(input(1.0, 1.0, 1));
1915 q.push(input(2.0, 2.0, 2));
1916 assert!(q.has_pending());
1917 let taken = q.take_all();
1918 assert_eq!(taken.len(), 2);
1919 assert_eq!(taken[0].delta.x, 1.0);
1920 assert_eq!(taken[1].delta.x, 2.0);
1921 assert!(!q.has_pending(), "take_all must drain the queue");
1922 assert!(q.take_all().is_empty(), "second take_all is empty, not stale");
1923 }
1924
1925 #[test]
1926 fn input_queue_take_recent_zero_discards_everything() {
1927 let q = ScrollInputQueue::new();
1931 q.push(input(1.0, 1.0, 1));
1932 q.push(input(2.0, 2.0, 2));
1933 let taken = q.take_recent(0);
1934 assert!(taken.is_empty(), "take_recent(0) must return nothing");
1935 assert!(!q.has_pending(), "take_recent(0) must still drain the queue");
1936 }
1937
1938 #[test]
1939 fn input_queue_take_recent_keeps_the_newest_events_sorted_oldest_first() {
1940 let q = ScrollInputQueue::new();
1941 q.push(input(0.0, 0.0, 5));
1943 q.push(input(0.0, 0.0, 1));
1944 q.push(input(0.0, 0.0, 3));
1945 q.push(input(0.0, 0.0, 9));
1946 let taken = q.take_recent(2);
1947 assert_eq!(taken.len(), 2, "backlog must be truncated to max_events");
1948 assert_eq!(taken[0].timestamp, at(5));
1949 assert_eq!(taken[1].timestamp, at(9), "newest event must be last");
1950 assert!(!q.has_pending());
1951 }
1952
1953 #[test]
1954 fn input_queue_take_recent_below_limit_returns_push_order_not_sorted() {
1955 let q = ScrollInputQueue::new();
1959 q.push(input(0.0, 0.0, 5));
1960 q.push(input(0.0, 0.0, 1));
1961 q.push(input(0.0, 0.0, 3));
1962 let taken = q.take_recent(3);
1963 assert_eq!(taken.len(), 3);
1964 let stamps: Vec<_> = taken.iter().map(|e| e.timestamp.clone()).collect();
1965 assert_eq!(stamps, vec![at(5), at(1), at(3)]);
1966 }
1967
1968 #[test]
1969 fn input_queue_take_recent_usize_max_does_not_overflow() {
1970 let q = ScrollInputQueue::new();
1973 q.push(input(1.0, 0.0, 1));
1974 q.push(input(2.0, 0.0, 2));
1975 let taken = q.take_recent(usize::MAX);
1976 assert_eq!(taken.len(), 2);
1977 assert!(!q.has_pending());
1978 assert!(q.take_recent(usize::MAX).is_empty());
1980 assert!(q.take_recent(0).is_empty());
1981 }
1982
1983 #[test]
1984 fn input_queue_clone_shares_one_backing_store() {
1985 let q = ScrollInputQueue::new();
1988 let c = q.clone();
1989 c.push(input(1.0, 2.0, 1));
1990 assert!(q.has_pending(), "clone must not deep-copy the queue");
1991 assert_eq!(q.take_all().len(), 1);
1992 assert!(!c.has_pending(), "draining one handle drains both");
1993 }
1994
1995 #[test]
1996 fn input_queue_accepts_non_finite_deltas_without_panicking() {
1997 let q = ScrollInputQueue::new();
1998 q.push(input(f32::NAN, f32::INFINITY, 1));
1999 q.push(input(f32::MAX, f32::MIN, 2));
2000 let taken = q.take_recent(usize::MAX);
2001 assert_eq!(taken.len(), 2);
2002 assert!(taken[0].delta.x.is_nan());
2003 assert_eq!(taken[0].delta.y, f32::INFINITY);
2004 }
2005
2006 #[test]
2010 fn hit_test_component_vertical_maps_each_region() {
2011 let sb = scrollbar(
2012 ScrollbarOrientation::Vertical,
2013 rect(0.0, 0.0, 16.0, 100.0),
2014 16.0, 10.0, 30.0, );
2018 assert_eq!(sb.hit_test_component(pos(8.0, 0.0)), ScrollbarComponent::TopButton);
2019 assert_eq!(sb.hit_test_component(pos(8.0, 15.9)), ScrollbarComponent::TopButton);
2020 assert_eq!(
2021 sb.hit_test_component(pos(8.0, 99.0)),
2022 ScrollbarComponent::BottomButton
2023 );
2024 assert_eq!(sb.hit_test_component(pos(8.0, 26.0)), ScrollbarComponent::Thumb);
2026 assert_eq!(sb.hit_test_component(pos(8.0, 56.0)), ScrollbarComponent::Thumb);
2027 assert_eq!(sb.hit_test_component(pos(8.0, 20.0)), ScrollbarComponent::Track);
2028 assert_eq!(sb.hit_test_component(pos(8.0, 60.0)), ScrollbarComponent::Track);
2029 }
2030
2031 #[test]
2032 fn hit_test_component_boundaries_are_exact() {
2033 let sb = scrollbar(
2034 ScrollbarOrientation::Vertical,
2035 rect(0.0, 0.0, 16.0, 100.0),
2036 16.0,
2037 0.0,
2038 30.0,
2039 );
2040 assert_eq!(sb.hit_test_component(pos(0.0, 16.0)), ScrollbarComponent::Thumb);
2042 assert_eq!(sb.hit_test_component(pos(0.0, 84.0)), ScrollbarComponent::Track);
2044 assert_eq!(
2045 sb.hit_test_component(pos(0.0, 84.001)),
2046 ScrollbarComponent::BottomButton
2047 );
2048 }
2049
2050 #[test]
2051 fn hit_test_component_overlay_zero_button_size_has_no_buttons() {
2052 let sb = scrollbar(
2055 ScrollbarOrientation::Vertical,
2056 rect(0.0, 0.0, 8.0, 100.0),
2057 0.0,
2058 0.0,
2059 50.0,
2060 );
2061 assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::Thumb);
2062 assert_eq!(sb.hit_test_component(pos(0.0, 50.0)), ScrollbarComponent::Thumb);
2063 assert_eq!(sb.hit_test_component(pos(0.0, 60.0)), ScrollbarComponent::Track);
2064 assert_eq!(sb.hit_test_component(pos(0.0, 100.0)), ScrollbarComponent::Track);
2066 }
2067
2068 #[test]
2069 fn hit_test_component_nan_position_falls_through_to_track() {
2070 let sb = scrollbar(
2073 ScrollbarOrientation::Vertical,
2074 rect(0.0, 0.0, 16.0, 100.0),
2075 16.0,
2076 10.0,
2077 30.0,
2078 );
2079 assert_eq!(
2080 sb.hit_test_component(pos(f32::NAN, f32::NAN)),
2081 ScrollbarComponent::Track
2082 );
2083 let hb = scrollbar(
2084 ScrollbarOrientation::Horizontal,
2085 rect(0.0, 0.0, 100.0, 16.0),
2086 16.0,
2087 10.0,
2088 30.0,
2089 );
2090 assert_eq!(
2091 hb.hit_test_component(pos(f32::NAN, f32::NAN)),
2092 ScrollbarComponent::Track
2093 );
2094 }
2095
2096 #[test]
2097 fn hit_test_component_infinite_position_picks_an_end_button() {
2098 let sb = scrollbar(
2099 ScrollbarOrientation::Vertical,
2100 rect(0.0, 0.0, 16.0, 100.0),
2101 16.0,
2102 10.0,
2103 30.0,
2104 );
2105 assert_eq!(
2106 sb.hit_test_component(pos(0.0, f32::NEG_INFINITY)),
2107 ScrollbarComponent::TopButton
2108 );
2109 assert_eq!(
2110 sb.hit_test_component(pos(0.0, f32::INFINITY)),
2111 ScrollbarComponent::BottomButton
2112 );
2113 assert_eq!(
2114 sb.hit_test_component(pos(0.0, f32::MIN)),
2115 ScrollbarComponent::TopButton
2116 );
2117 assert_eq!(
2118 sb.hit_test_component(pos(0.0, f32::MAX)),
2119 ScrollbarComponent::BottomButton
2120 );
2121 }
2122
2123 #[test]
2124 fn hit_test_component_ignores_the_cross_axis() {
2125 let v = scrollbar(
2128 ScrollbarOrientation::Vertical,
2129 rect(0.0, 0.0, 16.0, 100.0),
2130 16.0,
2131 10.0,
2132 30.0,
2133 );
2134 for x in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
2135 assert_eq!(v.hit_test_component(pos(x, 30.0)), ScrollbarComponent::Thumb);
2136 }
2137 let h = scrollbar(
2138 ScrollbarOrientation::Horizontal,
2139 rect(0.0, 0.0, 100.0, 16.0),
2140 16.0,
2141 10.0,
2142 30.0,
2143 );
2144 for y in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
2145 assert_eq!(h.hit_test_component(pos(30.0, y)), ScrollbarComponent::Thumb);
2146 }
2147 }
2148
2149 #[test]
2150 fn hit_test_component_degenerate_track_shorter_than_buttons_prefers_top() {
2151 let sb = scrollbar(
2154 ScrollbarOrientation::Vertical,
2155 rect(0.0, 0.0, 16.0, 4.0),
2156 16.0,
2157 0.0,
2158 0.0,
2159 );
2160 assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::TopButton);
2161 assert_eq!(sb.hit_test_component(pos(0.0, 3.0)), ScrollbarComponent::TopButton);
2162 }
2163
2164 #[test]
2168 fn manager_new_is_empty_and_traditional_by_default() {
2169 let m = ScrollManager::new();
2170 assert_eq!(m.debug_counts(), (0, 0));
2171 assert!(!m.has_active_animations());
2172 assert!(!m.has_pending_scroll_changes());
2173 assert!(!m.is_natural_scroll());
2174 assert_eq!(m.scroll_sign(), -1.0);
2175 assert!(m.pending_wheel_event.is_none());
2176 assert!(!m.get_input_queue().has_pending());
2177 assert!(m.get_current_offset(DOM, node(0)).is_none());
2179 assert!(m.get_last_activity_time(DOM, node(0)).is_none());
2180 assert!(m.get_scroll_state(DOM, node(0)).is_none());
2181 assert!(m.get_scroll_node_info(DOM, node(0)).is_none());
2182 assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
2183 assert!(m.get_scroll_states_for_dom(DOM).is_empty());
2184 assert!(m
2185 .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
2186 .is_none());
2187 assert!(m.hit_test_scrollbars(pos(0.0, 0.0)).is_none());
2188 assert_eq!(m.iter_scrollbar_states().count(), 0);
2189 assert!(!m.is_node_scrollable(DOM, node(0)));
2190 assert!(!m.can_consume_delta(DOM, node(0), 10.0, 10.0));
2191 }
2192
2193 #[test]
2194 fn scroll_sign_flips_with_the_preference() {
2195 let mut m = ScrollManager::new();
2196 assert_eq!(m.scroll_sign(), -1.0);
2197 m.set_natural_scroll(true);
2198 assert!(m.is_natural_scroll());
2199 assert_eq!(m.scroll_sign(), 1.0);
2200 m.set_natural_scroll(false);
2201 assert_eq!(m.scroll_sign(), -1.0);
2202 m.set_natural_scroll(false);
2204 assert_eq!(m.scroll_sign(), -1.0);
2205 }
2206
2207 #[test]
2211 fn scroll_dirty_is_set_only_on_a_real_move_and_cleared_on_demand() {
2212 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2213 assert!(!m.has_pending_scroll_changes());
2214
2215 m.set_scroll_position(DOM, node(0), pos(0.0, 0.005), at(1));
2218 assert!(!m.has_pending_scroll_changes(), "0.005px must not be 'moved'");
2219
2220 m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(2));
2221 assert!(m.has_pending_scroll_changes());
2222
2223 m.clear_scroll_dirty();
2224 assert!(!m.has_pending_scroll_changes());
2225 m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(3));
2227 assert!(!m.has_pending_scroll_changes());
2228 }
2229
2230 #[test]
2231 fn clear_scroll_dirty_on_a_clean_manager_is_a_noop() {
2232 let mut m = ScrollManager::new();
2233 m.clear_scroll_dirty();
2234 m.clear_scroll_dirty();
2235 assert!(!m.has_pending_scroll_changes());
2236 }
2237
2238 #[test]
2242 fn set_scroll_position_clamps_extremes_into_bounds() {
2243 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2244 m.set_scroll_position(DOM, node(0), pos(f32::MAX, f32::MAX), at(1));
2245 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2246
2247 m.set_scroll_position(DOM, node(0), pos(f32::MIN, f32::MIN), at(2));
2248 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 0.0)));
2249
2250 m.set_scroll_position(DOM, node(0), pos(f32::INFINITY, f32::INFINITY), at(3));
2251 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2252
2253 m.set_scroll_position(DOM, node(0), pos(f32::NAN, f32::NAN), at(4));
2254 let off = m.get_current_offset(DOM, node(0)).unwrap();
2255 assert!(!off.x.is_nan() && !off.y.is_nan(), "clamped path must kill NaN");
2256 assert_eq!(off, LogicalPosition::zero());
2257 }
2258
2259 #[test]
2260 fn set_scroll_position_cancels_a_running_animation() {
2261 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2262 m.scroll_to(DOM, node(0), pos(0.0, 300.0), tick_dur(100), EasingFunction::Linear, at(0));
2263 assert!(m.has_active_animations());
2264 m.set_scroll_position(DOM, node(0), pos(0.0, 10.0), at(1));
2265 assert!(!m.has_active_animations(), "an explicit set must win over easing");
2266 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 10.0)));
2267 }
2268
2269 #[test]
2270 fn set_scroll_position_on_an_unknown_node_creates_a_pinned_zero_state() {
2271 let mut m = ScrollManager::new();
2274 m.set_scroll_position(DOM, node(42), pos(500.0, 500.0), at(1));
2275 assert_eq!(m.get_current_offset(DOM, node(42)), Some(LogicalPosition::zero()));
2276 assert_eq!(m.debug_counts(), (1, 0));
2277 m.set_scroll_position(DOM, node(42), pos(600.0, 600.0), at(2));
2278 assert_eq!(m.debug_counts(), (1, 0), "repeat set must not grow the map");
2279 }
2280
2281 #[test]
2282 fn set_scroll_position_unclamped_keeps_overscroll_values_verbatim() {
2283 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2286 m.set_scroll_position_unclamped(DOM, node(0), pos(-50.0, -80.0), at(1));
2287 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(-50.0, -80.0)));
2288 m.set_scroll_position_unclamped(DOM, node(0), pos(0.0, 9999.0), at(2));
2289 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 9999.0)));
2290 assert!(m.has_pending_scroll_changes());
2291 }
2292
2293 #[test]
2294 fn set_scroll_position_unclamped_stores_non_finite_values_unfiltered() {
2295 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2299 m.set_scroll_position_unclamped(DOM, node(0), pos(f32::NAN, f32::NAN), at(1));
2300 let off = m.get_current_offset(DOM, node(0)).unwrap();
2301 assert!(off.x.is_nan() && off.y.is_nan(), "unclamped stores NaN as-is");
2302 assert!(
2303 !m.has_pending_scroll_changes(),
2304 "a NaN write does not trip the dirty flag (NaN comparisons are false)"
2305 );
2306 m.register_or_update_scroll_node(
2308 DOM,
2309 node(0),
2310 rect(0.0, 0.0, 100.0, 100.0),
2311 size(100.0, 500.0),
2312 at(2),
2313 16.0,
2314 16.0,
2315 false,
2316 true,
2317 );
2318 let off = m.get_current_offset(DOM, node(0)).unwrap();
2319 assert!(!off.x.is_nan() && !off.y.is_nan(), "re-clamp must sanitize NaN");
2320 }
2321
2322 #[test]
2326 fn scroll_to_zero_duration_is_immediate_for_both_clock_kinds() {
2327 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2328 m.scroll_to(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
2329 assert!(!m.has_active_animations(), "zero Tick duration must not animate");
2330 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
2331
2332 m.scroll_to(
2333 DOM,
2334 node(0),
2335 pos(0.0, 200.0),
2336 sys_dur(0, 0),
2337 EasingFunction::EaseOut,
2338 at(2),
2339 );
2340 assert!(!m.has_active_animations(), "zero System duration must not animate");
2341 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2342 }
2343
2344 #[test]
2345 fn scroll_to_clamps_the_animation_target_not_just_the_final_offset() {
2346 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2347 m.scroll_to(DOM, node(0), pos(0.0, 1e9), tick_dur(100), EasingFunction::Linear, at(0));
2348 let anim_target = m
2349 .get_scroll_state(DOM, node(0))
2350 .and_then(|s| s.animation.as_ref())
2351 .map(|a| a.target_offset)
2352 .unwrap();
2353 assert_eq!(anim_target, pos(0.0, 400.0), "target must be pre-clamped");
2354 let r = m.tick(at(100));
2356 assert!(r.needs_repaint);
2357 assert_eq!(r.updated_nodes, vec![(DOM, node(0))]);
2358 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2359 assert!(!m.has_active_animations(), "animation must clear at t >= 1");
2360 }
2361
2362 #[test]
2363 fn scroll_to_nan_target_animates_to_the_origin_never_to_nan() {
2364 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2365 m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(0));
2366 m.scroll_to(
2367 DOM,
2368 node(0),
2369 pos(f32::NAN, f32::NAN),
2370 tick_dur(10),
2371 EasingFunction::Linear,
2372 at(0),
2373 );
2374 m.tick(at(10));
2375 let off = m.get_current_offset(DOM, node(0)).unwrap();
2376 assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN target must be clamped away");
2377 assert_eq!(off, LogicalPosition::zero());
2378 }
2379
2380 #[test]
2381 fn scroll_by_accumulates_from_the_current_offset_and_saturates() {
2382 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2383 m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
2384 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
2385 m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(2));
2386 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2387 m.scroll_by(
2389 DOM,
2390 node(0),
2391 pos(f32::MAX, f32::MAX),
2392 tick_dur(0),
2393 EasingFunction::Linear,
2394 at(3),
2395 );
2396 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2397 m.scroll_by(
2399 DOM,
2400 node(0),
2401 pos(f32::MIN, f32::MIN),
2402 tick_dur(0),
2403 EasingFunction::Linear,
2404 at(4),
2405 );
2406 assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
2407 }
2408
2409 #[test]
2410 fn scroll_by_on_an_unknown_node_defaults_to_origin_and_stays_pinned() {
2411 let mut m = ScrollManager::new();
2412 m.scroll_by(
2413 DOM,
2414 node(7),
2415 pos(1e9, 1e9),
2416 tick_dur(0),
2417 EasingFunction::Linear,
2418 at(1),
2419 );
2420 assert_eq!(m.get_current_offset(DOM, node(7)), Some(LogicalPosition::zero()));
2422 }
2423
2424 #[test]
2425 fn scroll_by_nan_delta_does_not_poison_the_offset() {
2426 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2427 m.set_scroll_position(DOM, node(0), pos(0.0, 100.0), at(0));
2428 m.scroll_by(
2429 DOM,
2430 node(0),
2431 pos(f32::NAN, f32::NAN),
2432 tick_dur(0),
2433 EasingFunction::Linear,
2434 at(1),
2435 );
2436 let off = m.get_current_offset(DOM, node(0)).unwrap();
2437 assert!(!off.x.is_nan() && !off.y.is_nan());
2438 assert_eq!(off, LogicalPosition::zero(), "NaN target clamps to origin");
2439 }
2440
2441 #[test]
2445 fn tick_on_an_empty_manager_reports_no_work() {
2446 let mut m = ScrollManager::new();
2447 let r = m.tick(at(1));
2448 assert!(!r.needs_repaint);
2449 assert!(r.updated_nodes.is_empty());
2450 }
2451
2452 #[test]
2453 fn tick_interpolates_linearly_and_completes_exactly_once() {
2454 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2455 m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(0));
2456
2457 let r = m.tick(at(50));
2458 assert!(r.needs_repaint);
2459 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2460 assert!(m.has_active_animations(), "still mid-flight at t = 0.5");
2461
2462 let r = m.tick(at(100));
2463 assert!(r.needs_repaint);
2464 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2465 assert!(!m.has_active_animations());
2466
2467 let r = m.tick(at(500));
2469 assert!(!r.needs_repaint);
2470 assert!(r.updated_nodes.is_empty());
2471 }
2472
2473 #[test]
2474 fn tick_before_the_animation_start_time_saturates_to_zero_progress() {
2475 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2478 m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(0));
2479 m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(100));
2480 m.tick(at(0)); assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
2482 assert!(m.has_active_animations(), "no progress => still animating");
2483 }
2484
2485 #[test]
2486 fn tick_with_a_zero_duration_animation_completes_instead_of_producing_nan() {
2487 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2492 m.states.get_mut(&(DOM, node(0))).unwrap().animation = Some(ScrollAnimation {
2493 start_time: at(0),
2494 duration: tick_dur(0),
2495 start_offset: pos(0.0, 0.0),
2496 target_offset: pos(0.0, 300.0),
2497 easing: EasingFunction::Linear,
2498 });
2499 let r = m.tick(at(0));
2500 assert!(r.needs_repaint);
2501 let off = m.get_current_offset(DOM, node(0)).unwrap();
2502 assert!(!off.y.is_nan(), "0/0 must not leak NaN into the offset");
2503 assert_eq!(off, pos(0.0, 300.0));
2504 assert!(!m.has_active_animations());
2505 }
2506
2507 #[test]
2508 fn tick_with_a_mismatched_clock_kind_stalls_at_zero_instead_of_panicking() {
2509 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2520 m.set_scroll_position(DOM, node(0), pos(0.0, 25.0), at(0));
2521 m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
2522 let r = m.tick(Instant::now()); assert!(r.needs_repaint);
2524 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 25.0)));
2525 assert!(
2526 m.has_active_animations(),
2527 "mismatched clocks stall the animation (t stays 0) — it never completes"
2528 );
2529 }
2530
2531 #[test]
2532 fn tick_advances_every_animating_node_in_one_pass() {
2533 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2534 m.register_or_update_scroll_node(
2535 DOM,
2536 node(1),
2537 rect(0.0, 0.0, 100.0, 100.0),
2538 size(100.0, 300.0),
2539 at(0),
2540 16.0,
2541 16.0,
2542 false,
2543 true,
2544 );
2545 m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
2546 m.scroll_to(DOM, node(1), pos(0.0, 200.0), tick_dur(10), EasingFunction::Linear, at(0));
2547 let r = m.tick(at(10));
2548 assert_eq!(r.updated_nodes.len(), 2);
2549 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2550 assert_eq!(m.get_current_offset(DOM, node(1)), Some(pos(0.0, 200.0)));
2551 }
2552
2553 #[test]
2557 fn register_twice_updates_in_place_and_keeps_the_offset() {
2558 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2559 m.set_scroll_position(DOM, node(0), pos(0.0, 300.0), at(1));
2560 m.register_or_update_scroll_node(
2561 DOM,
2562 node(0),
2563 rect(0.0, 0.0, 100.0, 100.0),
2564 size(100.0, 500.0),
2565 at(2),
2566 16.0,
2567 16.0,
2568 false,
2569 true,
2570 );
2571 assert_eq!(m.debug_counts(), (1, 0), "re-register must not grow the map");
2572 assert_eq!(
2573 m.get_current_offset(DOM, node(0)),
2574 Some(pos(0.0, 300.0)),
2575 "an existing node keeps its scroll offset across relayout"
2576 );
2577 }
2578
2579 #[test]
2580 fn re_registering_with_shrunken_content_re_clamps_the_offset() {
2581 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2583 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2584 m.register_or_update_scroll_node(
2585 DOM,
2586 node(0),
2587 rect(0.0, 0.0, 100.0, 100.0),
2588 size(100.0, 150.0), at(2),
2590 16.0,
2591 16.0,
2592 false,
2593 true,
2594 );
2595 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
2596 }
2597
2598 #[test]
2599 fn register_with_non_finite_geometry_does_not_panic_or_leak_nan() {
2600 let mut m = ScrollManager::new();
2601 m.register_or_update_scroll_node(
2602 DOM,
2603 node(0),
2604 rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
2605 size(f32::NAN, f32::NAN),
2606 at(0),
2607 f32::NAN,
2608 f32::NAN,
2609 true,
2610 true,
2611 );
2612 let off = m.get_current_offset(DOM, node(0)).unwrap();
2613 assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN geometry must clamp to 0");
2614 assert_eq!(off, LogicalPosition::zero());
2615 assert!(!m.is_node_scrollable(DOM, node(0)), "NaN overflow check is false");
2616
2617 m.register_or_update_scroll_node(
2618 DOM,
2619 node(1),
2620 rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
2621 size(f32::INFINITY, f32::INFINITY),
2622 at(0),
2623 f32::MAX,
2624 f32::MAX,
2625 true,
2626 true,
2627 );
2628 let off = m.get_current_offset(DOM, node(1)).unwrap();
2629 assert!(!off.x.is_nan() && !off.y.is_nan());
2630 assert_eq!(m.debug_counts(), (2, 0));
2631 }
2632
2633 #[test]
2634 fn register_with_zero_sized_geometry_yields_a_non_scrollable_pinned_node() {
2635 let mut m = ScrollManager::new();
2636 m.register_or_update_scroll_node(
2637 DOM,
2638 node(0),
2639 LogicalRect::zero(),
2640 LogicalSize::zero(),
2641 at(0),
2642 0.0,
2643 0.0,
2644 false,
2645 false,
2646 );
2647 assert!(!m.is_node_scrollable(DOM, node(0)));
2648 assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
2649 let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
2650 assert_eq!(info.max_scroll_x, 0.0);
2651 assert_eq!(info.max_scroll_y, 0.0);
2652 }
2653
2654 #[test]
2658 fn is_node_scrollable_is_strict_overflow_not_equality() {
2659 let mut m = ScrollManager::new();
2660 m.register_or_update_scroll_node(
2662 DOM,
2663 node(0),
2664 rect(0.0, 0.0, 100.0, 100.0),
2665 size(100.0, 100.0),
2666 at(0),
2667 16.0,
2668 16.0,
2669 false,
2670 false,
2671 );
2672 assert!(!m.is_node_scrollable(DOM, node(0)));
2673 m.register_or_update_scroll_node(
2675 DOM,
2676 node(1),
2677 rect(0.0, 0.0, 100.0, 100.0),
2678 size(100.0, 100.1),
2679 at(0),
2680 16.0,
2681 16.0,
2682 false,
2683 true,
2684 );
2685 assert!(m.is_node_scrollable(DOM, node(1)));
2686 assert!(!m.is_node_scrollable(DOM, node(999)));
2688 assert!(!m.is_node_scrollable(DOM1, node(1)));
2689 }
2690
2691 #[test]
2692 fn is_node_scrollable_uses_the_virtual_size_when_present() {
2693 let mut m = ScrollManager::new();
2694 m.register_or_update_scroll_node(
2696 DOM,
2697 node(0),
2698 rect(0.0, 0.0, 100.0, 100.0),
2699 size(100.0, 50.0),
2700 at(0),
2701 16.0,
2702 16.0,
2703 false,
2704 true,
2705 );
2706 assert!(!m.is_node_scrollable(DOM, node(0)));
2707 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 100_000.0), None);
2708 assert!(
2709 m.is_node_scrollable(DOM, node(0)),
2710 "a VirtualView with a large virtual size must be scrollable"
2711 );
2712 }
2713
2714 #[test]
2718 fn can_consume_delta_respects_the_half_pixel_deadzone() {
2719 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0)); m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(1));
2721 assert!(!m.can_consume_delta(DOM, node(0), 0.0, 0.0));
2723 assert!(!m.can_consume_delta(DOM, node(0), 0.5, 0.5), "exactly EPS is a no-move");
2724 assert!(!m.can_consume_delta(DOM, node(0), -0.5, -0.5));
2725 assert!(m.can_consume_delta(DOM, node(0), 0.0, 0.51));
2726 assert!(m.can_consume_delta(DOM, node(0), 0.0, -0.51));
2727 assert!(!m.can_consume_delta(DOM, node(0), 100.0, 0.0));
2729 }
2730
2731 #[test]
2732 fn can_consume_delta_is_false_at_the_pinned_edges() {
2733 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2734 m.set_scroll_position(DOM, node(0), pos(0.0, 0.0), at(1));
2736 assert!(!m.can_consume_delta(DOM, node(0), 0.0, -10.0));
2737 assert!(m.can_consume_delta(DOM, node(0), 0.0, 10.0));
2738 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(2));
2740 assert!(!m.can_consume_delta(DOM, node(0), 0.0, 10.0));
2741 assert!(m.can_consume_delta(DOM, node(0), 0.0, -10.0));
2742 }
2743
2744 #[test]
2745 fn can_consume_delta_rejects_nan_and_accepts_infinite_deltas() {
2746 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2747 m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(1));
2748 assert!(
2749 !m.can_consume_delta(DOM, node(0), f32::NAN, f32::NAN),
2750 "a NaN delta consumes nothing (every comparison is false)"
2751 );
2752 assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::INFINITY));
2753 assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::NEG_INFINITY));
2754 assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::MAX));
2755 assert!(!m.can_consume_delta(DOM, node(999), 0.0, f32::MAX), "unknown node");
2756 }
2757
2758 #[test]
2762 fn select_scroll_target_on_no_candidates_is_none() {
2763 let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2764 assert!(m
2765 .select_scroll_target(core::iter::empty(), 0.0, 10.0)
2766 .is_none());
2767 assert!(m
2769 .select_scroll_target([(DOM, node(50)), (DOM1, node(0))].into_iter(), 0.0, 10.0)
2770 .is_none());
2771 }
2772
2773 #[test]
2774 fn select_scroll_target_with_zero_or_nan_delta_falls_back_to_the_innermost() {
2775 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2778 m.register_or_update_scroll_node(
2779 DOM,
2780 node(9),
2781 rect(0.0, 0.0, 50.0, 50.0),
2782 size(50.0, 200.0),
2783 at(0),
2784 16.0,
2785 16.0,
2786 false,
2787 true,
2788 );
2789 let inner_first = [(DOM, node(9)), (DOM, node(0))];
2790 assert_eq!(
2791 m.select_scroll_target(inner_first.into_iter(), 0.0, 0.0),
2792 Some((DOM, node(9)))
2793 );
2794 assert_eq!(
2795 m.select_scroll_target(inner_first.into_iter(), f32::NAN, f32::NAN),
2796 Some((DOM, node(9)))
2797 );
2798 assert_eq!(
2800 m.select_scroll_target(inner_first.into_iter(), 0.0, f32::INFINITY),
2801 Some((DOM, node(9)))
2802 );
2803 }
2804
2805 #[test]
2809 fn a11y_scroll_info_reports_travel_only_for_scrollable_nodes() {
2810 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2811 m.set_scroll_position(DOM, node(0), pos(0.0, 120.0), at(1));
2812 let (off, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
2813 assert_eq!(off, pos(0.0, 120.0));
2814 assert_eq!(max_x, 0.0);
2815 assert_eq!(max_y, 400.0);
2816
2817 m.register_or_update_scroll_node(
2819 DOM,
2820 node(1),
2821 rect(0.0, 0.0, 100.0, 100.0),
2822 size(10.0, 10.0),
2823 at(0),
2824 16.0,
2825 16.0,
2826 false,
2827 false,
2828 );
2829 assert!(m.a11y_scroll_info(DOM, node(1)).is_none());
2830 assert!(m.a11y_scroll_info(DOM, node(404)).is_none());
2831 assert!(m.a11y_scroll_info(DOM1, node(0)).is_none());
2832 }
2833
2834 #[test]
2835 fn a11y_scroll_info_uses_the_virtual_size() {
2836 let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
2837 assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
2838 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 1000.0), None);
2839 let (_, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
2840 assert_eq!(max_x, 0.0);
2841 assert_eq!(max_y, 900.0);
2842 }
2843
2844 #[test]
2848 fn get_scroll_node_info_max_scroll_is_never_negative() {
2849 let m = mgr(size(500.0, 500.0), size(10.0, 10.0));
2850 let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
2851 assert_eq!(info.max_scroll_x, 0.0, "underflow must clamp to 0, not go negative");
2852 assert_eq!(info.max_scroll_y, 0.0);
2853 assert_eq!(info.current_offset, LogicalPosition::zero());
2854 assert!(m.get_scroll_node_info(DOM, node(1)).is_none());
2855 }
2856
2857 #[test]
2858 fn get_scroll_node_info_prefers_the_virtual_size_for_max_travel() {
2859 let mut m = mgr(size(100.0, 100.0), size(100.0, 200.0));
2860 assert_eq!(m.get_scroll_node_info(DOM, node(0)).unwrap().max_scroll_y, 100.0);
2861 m.update_virtual_scroll_bounds(DOM, node(0), size(600.0, 5000.0), Some(pos(1.0, 2.0)));
2862 let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
2863 assert_eq!(info.max_scroll_x, 500.0);
2864 assert_eq!(info.max_scroll_y, 4900.0);
2865 assert_eq!(info.content_rect.size, size(100.0, 200.0));
2868 }
2869
2870 #[test]
2874 fn update_virtual_scroll_bounds_creates_a_state_for_an_unknown_node() {
2875 let mut m = ScrollManager::new();
2876 m.update_virtual_scroll_bounds(DOM, node(3), size(100.0, 9000.0), Some(pos(0.0, 4.0)));
2877 assert_eq!(m.debug_counts(), (1, 0));
2878 let s = m.get_scroll_state(DOM, node(3)).unwrap();
2879 assert_eq!(s.virtual_scroll_size, Some(size(100.0, 9000.0)));
2880 assert_eq!(s.virtual_scroll_offset, Some(pos(0.0, 4.0)));
2881 assert_eq!(s.current_offset, LogicalPosition::zero());
2882 assert!(m.is_node_scrollable(DOM, node(3)));
2884 }
2885
2886 #[test]
2887 fn update_virtual_scroll_bounds_re_clamps_a_shrinking_virtual_size() {
2888 let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
2889 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 5000.0), None);
2890 m.set_scroll_position(DOM, node(0), pos(0.0, 4900.0), at(1));
2891 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 4900.0)));
2892 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 300.0), None);
2894 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2895 }
2896
2897 #[test]
2898 fn update_virtual_scroll_bounds_with_non_finite_size_does_not_panic() {
2899 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2900 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2901 m.update_virtual_scroll_bounds(DOM, node(0), size(f32::NAN, f32::NAN), None);
2902 let off = m.get_current_offset(DOM, node(0)).unwrap();
2903 assert!(!off.x.is_nan() && !off.y.is_nan());
2904 assert_eq!(off, LogicalPosition::zero(), "NaN virtual size => zero travel");
2905 assert!(!m.is_node_scrollable(DOM, node(0)));
2906
2907 m.update_virtual_scroll_bounds(DOM, node(0), size(0.0, f32::INFINITY), None);
2908 let off = m.get_current_offset(DOM, node(0)).unwrap();
2909 assert!(!off.y.is_nan(), "infinite virtual height must not produce NaN");
2910 }
2911
2912 #[test]
2916 fn update_node_bounds_creates_the_state_and_re_clamps_a_shrinking_content() {
2917 let mut m = ScrollManager::new();
2918 m.update_node_bounds(
2920 DOM,
2921 node(0),
2922 rect(0.0, 0.0, 100.0, 100.0),
2923 rect(0.0, 0.0, 100.0, 500.0),
2924 at(0),
2925 );
2926 assert_eq!(m.debug_counts(), (1, 0));
2927 assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
2928
2929 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2930 m.clear_scroll_dirty();
2931
2932 m.update_node_bounds(
2934 DOM,
2935 node(0),
2936 rect(0.0, 0.0, 100.0, 100.0),
2937 rect(0.0, 0.0, 100.0, 150.0),
2938 at(2),
2939 );
2940 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
2941 assert!(!m.has_pending_scroll_changes());
2944 }
2945
2946 #[test]
2947 fn update_node_bounds_ignores_the_content_rect_origin() {
2948 let mut m = ScrollManager::new();
2951 m.update_node_bounds(
2952 DOM,
2953 node(0),
2954 rect(0.0, 0.0, 100.0, 100.0),
2955 rect(999.0, 999.0, 100.0, 500.0),
2956 at(0),
2957 );
2958 m.set_scroll_position(DOM, node(0), pos(1e9, 1e9), at(1));
2959 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2960 }
2961
2962 #[test]
2963 fn update_node_bounds_with_non_finite_rects_does_not_leak_nan() {
2964 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2965 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2966 m.update_node_bounds(
2967 DOM,
2968 node(0),
2969 rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
2970 rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
2971 at(2),
2972 );
2973 let off = m.get_current_offset(DOM, node(0)).unwrap();
2974 assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN bounds must clamp to 0");
2975 assert_eq!(off, LogicalPosition::zero());
2976
2977 m.update_node_bounds(
2979 DOM,
2980 node(0),
2981 rect(0.0, 0.0, 100.0, 100.0),
2982 rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
2983 at(3),
2984 );
2985 let off = m.get_current_offset(DOM, node(0)).unwrap();
2986 assert!(off.x.is_finite() && off.y.is_finite());
2987 }
2988
2989 #[test]
2994 fn get_scroll_states_for_dom_filters_by_dom_and_reports_the_live_offset() {
2995 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2996 m.set_scroll_position(DOM, node(0), pos(0.0, 42.0), at(1));
2997 m.register_or_update_scroll_node(
2998 DOM1,
2999 node(0),
3000 rect(0.0, 0.0, 10.0, 10.0),
3001 size(10.0, 100.0),
3002 at(0),
3003 16.0,
3004 16.0,
3005 false,
3006 true,
3007 );
3008
3009 let states = m.get_scroll_states_for_dom(DOM);
3010 assert_eq!(states.len(), 1, "other DOMs must not leak in");
3011 let sp = states.get(&node(0)).unwrap();
3012 assert_eq!(sp.parent_rect, rect(0.0, 0.0, 100.0, 100.0));
3013 assert_eq!(sp.children_rect.origin, pos(0.0, 42.0));
3014 assert_eq!(sp.children_rect.size, size(100.0, 500.0));
3015
3016 assert!(m.get_scroll_states_for_dom(DomId { inner: 99 }).is_empty());
3018 }
3019
3020 #[test]
3021 fn get_scroll_states_for_dom_uses_the_virtual_size_as_children_rect() {
3022 let mut m = mgr(size(100.0, 100.0), size(100.0, 120.0));
3023 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 8000.0), None);
3024 let states = m.get_scroll_states_for_dom(DOM);
3025 assert_eq!(states.get(&node(0)).unwrap().children_rect.size, size(100.0, 8000.0));
3026 }
3027
3028 #[test]
3029 fn build_scroll_offset_map_only_emits_nodes_present_in_scroll_ids() {
3030 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3031 m.set_scroll_position(DOM, node(0), pos(0.0, 25.0), at(1));
3032 m.register_or_update_scroll_node(
3033 DOM,
3034 node(4),
3035 rect(0.0, 0.0, 100.0, 100.0),
3036 size(100.0, 500.0),
3037 at(0),
3038 16.0,
3039 16.0,
3040 false,
3041 true,
3042 );
3043 m.register_or_update_scroll_node(
3044 DOM1,
3045 node(0),
3046 rect(0.0, 0.0, 100.0, 100.0),
3047 size(100.0, 500.0),
3048 at(0),
3049 16.0,
3050 16.0,
3051 false,
3052 true,
3053 );
3054
3055 assert!(m.build_scroll_offset_map(DOM, &HashMap::new()).is_empty());
3057
3058 let mut ids: HashMap<usize, u64> = HashMap::new();
3059 ids.insert(0, 100); ids.insert(7, 700); let map = m.build_scroll_offset_map(DOM, &ids);
3062 assert_eq!(map.len(), 1, "node 4 has no scroll_id; DOM1 is a different dom");
3063 assert_eq!(map.get(&100), Some(&(0.0, 25.0)));
3064 assert!(!map.contains_key(&700));
3065 }
3066
3067 #[test]
3071 fn find_scroll_parent_walks_up_to_the_nearest_registered_ancestor() {
3072 let hierarchy = [
3074 NodeHierarchyItem { parent: 0, previous_sibling: 0, next_sibling: 0, last_child: 2 },
3075 NodeHierarchyItem { parent: 1, previous_sibling: 0, next_sibling: 0, last_child: 3 },
3076 NodeHierarchyItem { parent: 2, previous_sibling: 0, next_sibling: 0, last_child: 0 },
3077 ];
3078 let m = mgr(size(100.0, 100.0), size(100.0, 500.0)); assert_eq!(
3080 m.find_scroll_parent(DOM, node(2), &hierarchy),
3081 Some(node(0)),
3082 "must skip the unregistered node 1 and find the root scroll container"
3083 );
3084 assert_eq!(m.find_scroll_parent(DOM, node(0), &hierarchy), None);
3086 assert_eq!(m.find_scroll_parent(DOM1, node(2), &hierarchy), None);
3088 }
3089
3090 #[test]
3091 fn find_scroll_parent_handles_empty_and_out_of_range_hierarchies() {
3092 let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3093 assert_eq!(m.find_scroll_parent(DOM, node(0), &[]), None);
3095 assert_eq!(m.find_scroll_parent(DOM, node(9999), &[]), None);
3096 let hierarchy = [NodeHierarchyItem::zeroed()];
3098 assert_eq!(m.find_scroll_parent(DOM, node(9999), &hierarchy), None);
3099 }
3100
3101 #[test]
3105 fn calculate_scrollbar_states_is_idempotent_and_only_for_overflowing_axes() {
3106 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3107 m.calculate_scrollbar_states();
3108 assert_eq!(m.debug_counts(), (1, 1), "only the vertical axis overflows");
3109 assert!(m
3110 .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
3111 .is_some());
3112 assert!(m
3113 .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Horizontal)
3114 .is_none());
3115
3116 for _ in 0..10 {
3118 m.calculate_scrollbar_states();
3119 }
3120 assert_eq!(m.debug_counts(), (1, 1), "per-frame recompute must not accumulate");
3121 assert_eq!(m.iter_scrollbar_states().count(), 1);
3122 }
3123
3124 #[test]
3125 fn calculate_scrollbar_states_drops_bars_once_the_content_fits() {
3126 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3127 m.calculate_scrollbar_states();
3128 assert_eq!(m.debug_counts().1, 1);
3129 m.register_or_update_scroll_node(
3131 DOM,
3132 node(0),
3133 rect(0.0, 0.0, 100.0, 100.0),
3134 size(100.0, 50.0),
3135 at(1),
3136 16.0,
3137 16.0,
3138 false,
3139 false,
3140 );
3141 m.calculate_scrollbar_states();
3142 assert_eq!(m.debug_counts().1, 0);
3143 assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
3144 }
3145
3146 #[test]
3147 fn calculate_scrollbar_states_produces_finite_geometry_for_both_axes() {
3148 let mut m = ScrollManager::new();
3149 m.register_or_update_scroll_node(
3150 DOM,
3151 node(0),
3152 rect(0.0, 0.0, 100.0, 100.0),
3153 size(1000.0, 1000.0),
3154 at(0),
3155 16.0,
3156 16.0,
3157 true,
3158 true,
3159 );
3160 m.calculate_scrollbar_states();
3161 assert_eq!(m.debug_counts(), (1, 2), "both axes overflow");
3162 for (_, sb) in m.iter_scrollbar_states() {
3163 assert!(sb.visible);
3164 assert!(sb.base_size.is_finite() && sb.base_size > 0.0);
3165 assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite());
3166 assert!(sb.thumb_length.is_finite());
3167 assert!(sb.thumb_offset.is_finite());
3168 assert!(sb.usable_track_length.is_finite());
3169 assert!(sb.track_rect.size.width.is_finite());
3170 assert!(sb.track_rect.size.height.is_finite());
3171 }
3172 }
3173
3174 #[test]
3175 fn calculate_scrollbar_states_zero_thickness_falls_back_to_the_default_width() {
3176 let mut m = ScrollManager::new();
3179 m.register_or_update_scroll_node(
3180 DOM,
3181 node(0),
3182 rect(0.0, 0.0, 100.0, 100.0),
3183 size(100.0, 400.0),
3184 at(0),
3185 0.0, 0.0, false,
3188 true,
3189 );
3190 m.calculate_scrollbar_states();
3191 let sb = m
3192 .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
3193 .unwrap();
3194 assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
3195 assert_eq!(sb.button_size, 0.0, "overlay scrollbars have no arrow buttons");
3196 assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite(), "no div-by-zero");
3197 }
3198
3199 #[test]
3200 fn calculate_scrollbar_state_from_geometry_survives_nan_input() {
3201 let mut s = state(size(f32::NAN, f32::NAN), size(f32::NAN, f32::NAN));
3202 s.scrollbar_thickness = f32::NAN;
3203 s.visual_width_px = f32::NAN;
3204 let sb = ScrollManager::calculate_scrollbar_state_from_geometry(
3207 &s,
3208 ScrollbarOrientation::Vertical,
3209 );
3210 assert!(sb.visible);
3211 assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
3212 assert_eq!(sb.usable_track_length, 0.0);
3215 assert_eq!(sb.thumb_length, 0.0);
3216 assert_eq!(sb.thumb_offset, 0.0);
3217 assert_eq!(sb.thumb_position_ratio, 0.0);
3218 assert!(
3221 sb.scale.y.is_nan(),
3222 "NaN container height still leaks into ScrollbarState::scale"
3223 );
3224 assert_eq!(
3226 sb.hit_test_component(pos(0.0, 5.0)),
3227 ScrollbarComponent::TopButton
3228 );
3229 }
3230
3231 #[test]
3235 fn hit_test_scrollbars_finds_the_vertical_bar_and_reports_local_coords() {
3236 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3237 m.calculate_scrollbar_states();
3238 let hit = m.hit_test_scrollbars(pos(90.0, 50.0)).expect("inside the track");
3240 assert_eq!(hit.dom_id, DOM);
3241 assert_eq!(hit.node_id, node(0));
3242 assert_eq!(hit.orientation, ScrollbarOrientation::Vertical);
3243 assert_eq!(hit.global_position, pos(90.0, 50.0));
3244 assert_eq!(hit.local_position, pos(6.0, 50.0), "local = global - track origin");
3245
3246 assert!(m.hit_test_scrollbars(pos(10.0, 50.0)).is_none());
3248 let hit2 = m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).unwrap();
3250 assert_eq!(hit2.local_position, hit.local_position);
3251 assert_eq!(hit2.component, hit.component);
3252 assert!(m.hit_test_scrollbar(DOM, node(1), pos(90.0, 50.0)).is_none());
3253 }
3254
3255 #[test]
3256 fn hit_test_scrollbars_rejects_non_finite_and_out_of_range_positions() {
3257 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3258 m.calculate_scrollbar_states();
3259 for p in [
3260 pos(f32::NAN, f32::NAN),
3261 pos(f32::INFINITY, f32::INFINITY),
3262 pos(f32::NEG_INFINITY, f32::NEG_INFINITY),
3263 pos(f32::MAX, f32::MAX),
3264 pos(f32::MIN, f32::MIN),
3265 pos(-1.0, -1.0),
3266 pos(0.0, 0.0),
3267 ] {
3268 assert!(
3269 m.hit_test_scrollbars(p).is_none(),
3270 "position {p:?} must not hit the 84..100 x 0..100 track"
3271 );
3272 assert!(m.hit_test_scrollbar(DOM, node(0), p).is_none());
3273 }
3274 }
3275
3276 #[test]
3277 fn hit_test_scrollbars_before_calculate_returns_none() {
3278 let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3281 assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
3282 assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
3283 }
3284
3285 #[test]
3286 fn hit_test_scrollbars_skips_invisible_bars() {
3287 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3288 m.calculate_scrollbar_states();
3289 m.scrollbar_states
3290 .get_mut(&(DOM, node(0), ScrollbarOrientation::Vertical))
3291 .unwrap()
3292 .visible = false;
3293 assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
3294 assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
3295 }
3296
3297 #[test]
3301 fn record_scroll_input_reports_start_timer_only_on_the_first_pending_event() {
3302 let mut m = ScrollManager::new();
3303 assert!(m.record_scroll_input(input(0.0, 1.0, 1)), "queue was empty => start");
3304 assert!(!m.record_scroll_input(input(0.0, 1.0, 2)), "timer already running");
3305 let _ = m.get_input_queue().take_all();
3306 assert!(m.record_scroll_input(input(0.0, 1.0, 3)), "drained => start again");
3307 }
3308
3309 #[test]
3310 fn record_scroll_input_applies_the_sign_to_extreme_deltas_without_overflow() {
3311 let mut m = ScrollManager::new();
3312 m.record_scroll_input(input(f32::MAX, f32::INFINITY, 1));
3313 m.record_scroll_input(input(f32::NAN, f32::MIN, 2));
3314 let q = m.get_input_queue().take_all();
3315 assert_eq!(q[0].delta.x, -f32::MAX, "sign flip must not overflow");
3316 assert_eq!(q[0].delta.y, f32::NEG_INFINITY);
3317 assert!(q[1].delta.x.is_nan(), "NaN * -1 stays NaN, no panic");
3318 assert_eq!(q[1].delta.y, f32::MAX);
3319 }
3320
3321 #[test]
3322 fn record_scroll_from_hit_test_records_the_wheel_delta_even_with_no_hover() {
3323 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3326 let hover = HoverManager::new(); let out = m.record_scroll_from_hit_test(
3328 3.0,
3329 -7.0,
3330 ScrollInputSource::WheelDiscrete,
3331 &hover,
3332 &InputPointId::Mouse,
3333 at(1),
3334 );
3335 assert!(out.is_none(), "no hover => no scroll target");
3336 assert_eq!(m.pending_wheel_event, Some(pos(3.0, -7.0)), "raw delta is recorded");
3337 assert!(!m.get_input_queue().has_pending(), "nothing queued for physics");
3338 }
3339
3340 #[test]
3341 fn record_scroll_from_hit_test_queues_the_raw_delta_and_signals_the_timer() {
3342 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3343 let hover = hover_over(&[0]);
3344 let (dom_id, node_id, start_timer) = m
3345 .record_scroll_from_hit_test(
3346 0.0,
3347 -10.0, ScrollInputSource::WheelDiscrete,
3349 &hover,
3350 &InputPointId::Mouse,
3351 at(1),
3352 )
3353 .expect("node 0 is scrollable and under the cursor");
3354 assert_eq!((dom_id, node_id), (DOM, node(0)));
3355 assert!(start_timer, "first queued input must start the physics timer");
3356 assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
3357
3358 let (_, _, start_again) = m
3360 .record_scroll_from_hit_test(
3361 0.0,
3362 -10.0,
3363 ScrollInputSource::WheelDiscrete,
3364 &hover,
3365 &InputPointId::Mouse,
3366 at(2),
3367 )
3368 .unwrap();
3369 assert!(!start_again);
3370
3371 let q = m.get_input_queue().take_all();
3372 assert_eq!(q.len(), 2);
3373 assert_eq!(q[0].delta.y, 10.0, "raw -10 * traditional sign (-1) = +10");
3375 assert_eq!(q[0].source, ScrollInputSource::WheelDiscrete);
3376 assert_eq!(q[0].timestamp, at(1));
3377 }
3378
3379 #[test]
3380 fn record_scroll_from_hit_test_ignores_hovered_nodes_that_cannot_scroll() {
3381 let mut m = ScrollManager::new();
3382 m.register_or_update_scroll_node(
3384 DOM,
3385 node(0),
3386 rect(0.0, 0.0, 100.0, 100.0),
3387 size(100.0, 100.0),
3388 at(0),
3389 16.0,
3390 16.0,
3391 false,
3392 false,
3393 );
3394 let hover = hover_over(&[0]);
3395 let out = m.record_scroll_from_hit_test(
3396 0.0,
3397 -10.0,
3398 ScrollInputSource::WheelDiscrete,
3399 &hover,
3400 &InputPointId::Mouse,
3401 at(1),
3402 );
3403 assert!(out.is_none(), "a non-overflowing node must not swallow the wheel");
3404 assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
3405 assert!(!m.get_input_queue().has_pending());
3406 }
3407
3408 #[test]
3409 fn record_scroll_from_hit_test_with_non_finite_deltas_does_not_panic() {
3410 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3411 let hover = hover_over(&[0]);
3412 let out = m.record_scroll_from_hit_test(
3414 f32::NAN,
3415 f32::NAN,
3416 ScrollInputSource::TrackpadContinuous,
3417 &hover,
3418 &InputPointId::Mouse,
3419 at(1),
3420 );
3421 assert_eq!(out.map(|(d, n, _)| (d, n)), Some((DOM, node(0))));
3422 assert!(m.pending_wheel_event.unwrap().x.is_nan());
3423 let q = m.get_input_queue().take_all();
3424 assert_eq!(q.len(), 1);
3425 assert!(q[0].delta.x.is_nan(), "NaN is queued verbatim, no panic");
3426
3427 let out = m.record_scroll_from_hit_test(
3429 0.0,
3430 f32::NEG_INFINITY,
3431 ScrollInputSource::WheelDiscrete,
3432 &hover,
3433 &InputPointId::Mouse,
3434 at(2),
3435 );
3436 assert!(out.is_some());
3437 let q = m.get_input_queue().take_all();
3438 assert_eq!(q[0].delta.y, f32::INFINITY, "-inf * -1 = +inf");
3439 }
3440
3441 #[test]
3442 fn record_scroll_from_hit_test_picks_the_innermost_scrollable_under_the_cursor() {
3443 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3446 m.register_or_update_scroll_node(
3447 DOM,
3448 node(5),
3449 rect(0.0, 0.0, 50.0, 50.0),
3450 size(50.0, 200.0),
3451 at(0),
3452 16.0,
3453 16.0,
3454 false,
3455 true,
3456 );
3457 let hover = hover_over(&[0, 5]);
3458 let (_, node_id, _) = m
3459 .record_scroll_from_hit_test(
3460 0.0,
3461 -10.0,
3462 ScrollInputSource::WheelDiscrete,
3463 &hover,
3464 &InputPointId::Mouse,
3465 at(1),
3466 )
3467 .unwrap();
3468 assert_eq!(node_id, node(5), "innermost (deepest) scrollable wins");
3469 }
3470
3471 #[test]
3475 fn getters_agree_with_the_recorded_state() {
3476 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3477 m.set_scroll_position(DOM, node(0), pos(0.0, 33.0), at(7));
3478 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 33.0)));
3479 assert_eq!(m.get_last_activity_time(DOM, node(0)), Some(at(7)));
3480 let s = m.get_scroll_state(DOM, node(0)).unwrap();
3481 assert_eq!(s.current_offset, pos(0.0, 33.0));
3482 assert!(s.animation.is_none());
3483 assert!(m.get_current_offset(DOM1, node(0)).is_none());
3485 assert!(m.get_last_activity_time(DOM, node(1)).is_none());
3486 assert!(m.get_scroll_state(DOM1, node(1)).is_none());
3487 }
3488
3489 #[test]
3490 fn get_input_queue_hands_out_a_shared_handle() {
3491 let mut m = ScrollManager::new();
3492 let q = m.get_input_queue();
3493 assert!(!q.has_pending());
3494 m.record_scroll_input(input(0.0, 1.0, 1));
3495 assert!(q.has_pending(), "the handle must observe pushes made by the manager");
3496 assert_eq!(q.take_all().len(), 1);
3497 assert!(
3498 !m.get_input_queue().has_pending(),
3499 "draining the handle drains the manager's queue"
3500 );
3501 }
3502}