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)]
371struct ScrollAnimation {
372 start_time: Instant,
374 duration: Duration,
376 start_offset: LogicalPosition,
378 target_offset: LogicalPosition,
380 easing: EasingFunction,
382}
383
384#[derive(Copy, Debug, Clone)]
389pub struct ScrollNodeInfo {
390 pub current_offset: LogicalPosition,
392 pub container_rect: LogicalRect,
394 pub content_rect: LogicalRect,
396 pub max_scroll_x: f32,
398 pub max_scroll_y: f32,
400 pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
402 pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
404 pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
406}
407
408#[derive(Debug, Default)]
410pub struct ScrollTickResult {
411 pub needs_repaint: bool,
413 pub updated_nodes: Vec<(DomId, NodeId)>,
415}
416
417impl ScrollManager {
420 #[must_use] pub fn new() -> Self {
422 let mut m = Self::default();
423 #[cfg(feature = "std")]
427 if let Some(v) = std::env::var_os("AZ_NATURAL_SCROLL") {
428 m.natural_scroll = matches!(v.to_str(), Some("1" | "true" | "TRUE"));
429 }
430 m
431 }
432
433 pub const fn set_natural_scroll(&mut self, natural: bool) {
438 self.natural_scroll = natural;
439 }
440
441 #[must_use] pub const fn is_natural_scroll(&self) -> bool {
443 self.natural_scroll
444 }
445
446 #[inline]
450 const fn scroll_sign(&self) -> f32 {
451 if self.natural_scroll {
452 1.0
453 } else {
454 -1.0
455 }
456 }
457
458 #[must_use] pub fn debug_counts(&self) -> (usize, usize) {
461 (self.states.len(), self.scrollbar_states.len())
462 }
463
464 pub(crate) const fn has_pending_scroll_changes(&self) -> bool {
467 self.scroll_dirty
468 }
469
470 pub const fn clear_scroll_dirty(&mut self) {
472 self.scroll_dirty = false;
473 }
474
475 #[must_use] pub fn build_scroll_offset_map(
483 &self,
484 dom_id: DomId,
485 scroll_ids: &std::collections::HashMap<usize, u64>,
486 ) -> std::collections::HashMap<u64, (f32, f32)> {
487 let mut map = std::collections::HashMap::new();
488 for ((d, node_id), state) in &self.states {
489 if *d != dom_id { continue; }
490 let node_idx = node_id.index();
494 if let Some(&scroll_id) = scroll_ids.get(&node_idx) {
495 map.insert(scroll_id, (state.current_offset.x, state.current_offset.y));
496 }
497 }
498 map
499 }
500
501 #[cfg(feature = "std")]
518 pub fn record_scroll_input(&mut self, mut input: ScrollInput) -> bool {
519 let sign = self.scroll_sign();
520 input.delta.x *= sign;
521 input.delta.y *= sign;
522 let was_empty = !self.scroll_input_queue.has_pending();
523 self.scroll_input_queue.push(input);
524 was_empty }
526
527 #[cfg(feature = "std")]
533 pub fn record_scroll_from_hit_test(
534 &mut self,
535 delta_x: f32,
536 delta_y: f32,
537 source: ScrollInputSource,
538 hover_manager: &crate::managers::hover::HoverManager,
539 input_point_id: &InputPointId,
540 now: Instant,
541 ) -> Option<(DomId, NodeId, bool)> {
542 self.pending_wheel_event = Some(LogicalPosition { x: delta_x, y: delta_y });
546
547 let hit_test = hover_manager.get_current(input_point_id)?;
548
549 let sign = self.scroll_sign();
560 let (eff_x, eff_y) = (delta_x * sign, delta_y * sign);
561 let target = self.select_scroll_target(
562 hit_test.hovered_nodes.iter().flat_map(|(dom_id, hit_node)| {
563 hit_node
564 .scroll_hit_test_nodes
565 .keys()
566 .rev()
567 .map(move |node_id| (*dom_id, *node_id))
568 }),
569 eff_x,
570 eff_y,
571 );
572 let (dom_id, node_id) = target?;
573 let input = ScrollInput {
574 dom_id,
575 node_id,
576 delta: LogicalPosition { x: delta_x, y: delta_y },
578 timestamp: now,
579 source,
580 };
581 let should_start_timer = self.record_scroll_input(input);
582 Some((dom_id, node_id, should_start_timer))
583 }
584
585 fn select_scroll_target<I>(
594 &self,
595 candidates: I,
596 eff_x: f32,
597 eff_y: f32,
598 ) -> Option<(DomId, NodeId)>
599 where
600 I: Iterator<Item = (DomId, NodeId)>,
601 {
602 let mut fallback = None;
603 for (dom_id, node_id) in candidates {
604 if !self.is_node_scrollable(dom_id, node_id) {
605 continue;
606 }
607 if fallback.is_none() {
608 fallback = Some((dom_id, node_id));
609 }
610 if self.can_consume_delta(dom_id, node_id, eff_x, eff_y) {
611 return Some((dom_id, node_id));
612 }
613 }
614 fallback
615 }
616
617 #[must_use] pub fn a11y_scroll_info(
622 &self,
623 dom_id: DomId,
624 node_id: NodeId,
625 ) -> Option<(LogicalPosition, f32, f32)> {
626 let state = self.states.get(&(dom_id, node_id))?;
627 let effective_width = state
628 .virtual_scroll_size
629 .map_or(state.content_rect.size.width, |s| s.width);
630 let effective_height = state
631 .virtual_scroll_size
632 .map_or(state.content_rect.size.height, |s| s.height);
633 let max_x = (effective_width - state.container_rect.size.width).max(0.0);
634 let max_y = (effective_height - state.container_rect.size.height).max(0.0);
635 if max_x <= 0.0 && max_y <= 0.0 {
636 return None;
637 }
638 Some((state.current_offset, max_x, max_y))
639 }
640
641 fn can_consume_delta(
645 &self,
646 dom_id: DomId,
647 node_id: NodeId,
648 eff_x: f32,
649 eff_y: f32,
650 ) -> bool {
651 const EPS: f32 = 0.5;
652 let Some(state) = self.states.get(&(dom_id, node_id)) else {
653 return false;
654 };
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 let off = state.current_offset;
664
665 let x_ok = if eff_x > EPS {
666 off.x < max_x - EPS
667 } else if eff_x < -EPS {
668 off.x > EPS
669 } else {
670 false
671 };
672 let y_ok = if eff_y > EPS {
673 off.y < max_y - EPS
674 } else if eff_y < -EPS {
675 off.y > EPS
676 } else {
677 false
678 };
679 x_ok || y_ok
680 }
681
682 #[cfg(feature = "std")]
687 #[must_use] pub fn get_input_queue(&self) -> ScrollInputQueue {
688 self.scroll_input_queue.clone()
689 }
690
691 #[allow(clippy::suboptimal_flops)] #[allow(clippy::needless_pass_by_value)]
695 pub fn tick(&mut self, now: Instant) -> ScrollTickResult {
696 let mut result = ScrollTickResult::default();
697 for ((dom_id, node_id), state) in &mut self.states {
698 if let Some(anim) = &state.animation {
699 let elapsed = now.duration_since(&anim.start_time);
700 let t = elapsed.div(&anim.duration).min(1.0);
701 let eased_t = apply_easing(t, anim.easing);
702
703 state.current_offset = LogicalPosition {
704 x: anim.start_offset.x + (anim.target_offset.x - anim.start_offset.x) * eased_t,
705 y: anim.start_offset.y + (anim.target_offset.y - anim.start_offset.y) * eased_t,
706 };
707 result.needs_repaint = true;
708 result.updated_nodes.push((*dom_id, *node_id));
709
710 if t >= 1.0 {
711 state.animation = None;
712 }
713 }
714 }
715 result
716 }
717
718 #[must_use] pub fn has_active_animations(&self) -> bool {
723 self.states.values().any(|s| s.animation.is_some())
724 }
725
726 #[must_use] pub fn find_scroll_parent(
732 &self,
733 dom_id: DomId,
734 node_id: NodeId,
735 node_hierarchy: &[azul_core::styled_dom::NodeHierarchyItem],
736 ) -> Option<NodeId> {
737 let mut current = Some(node_id);
738 while let Some(nid) = current {
739 if self.states.contains_key(&(dom_id, nid)) && nid != node_id {
740 return Some(nid);
741 }
742 current = node_hierarchy
743 .get(nid.index())
744 .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
745 }
746 None
747 }
748
749 fn is_node_scrollable(&self, dom_id: DomId, node_id: NodeId) -> bool {
755 let result = self.states.get(&(dom_id, node_id)).is_some_and(|state| {
756 let effective_width = state.virtual_scroll_size
757 .map_or(state.content_rect.size.width, |s| s.width);
758 let effective_height = state.virtual_scroll_size
759 .map_or(state.content_rect.size.height, |s| s.height);
760 let has_horizontal = effective_width > state.container_rect.size.width;
761 let has_vertical = effective_height > state.container_rect.size.height;
762 has_horizontal || has_vertical
763 });
764 result
765 }
766
767 pub fn set_scroll_position(
770 &mut self,
771 dom_id: DomId,
772 node_id: NodeId,
773 position: LogicalPosition,
774 now: Instant,
775 ) {
776 let state = self
777 .states
778 .entry((dom_id, node_id))
779 .or_insert_with(|| AnimatedScrollState::new(now.clone()));
780 let clamped = state.clamp(position);
781 if (clamped.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
782 || (clamped.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
783 {
784 self.scroll_dirty = true;
785 }
786 state.current_offset = clamped;
787 state.animation = None;
788 state.last_activity = now;
789 }
790
791 pub fn set_scroll_position_unclamped(
796 &mut self,
797 dom_id: DomId,
798 node_id: NodeId,
799 position: LogicalPosition,
800 now: Instant,
801 ) {
802 let state = self
803 .states
804 .entry((dom_id, node_id))
805 .or_insert_with(|| AnimatedScrollState::new(now.clone()));
806 if (position.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
807 || (position.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
808 {
809 self.scroll_dirty = true;
810 }
811 state.current_offset = position;
812 state.animation = None;
813 state.last_activity = now;
814 }
815
816 pub fn scroll_by(
818 &mut self,
819 dom_id: DomId,
820 node_id: NodeId,
821 delta: LogicalPosition,
822 duration: Duration,
823 easing: EasingFunction,
824 now: Instant,
825 ) {
826 let current = self.get_current_offset(dom_id, node_id).unwrap_or_default();
827 let target = LogicalPosition {
828 x: current.x + delta.x,
829 y: current.y + delta.y,
830 };
831 self.scroll_to(dom_id, node_id, target, duration, easing, now);
832 }
833
834 pub fn scroll_to(
838 &mut self,
839 dom_id: DomId,
840 node_id: NodeId,
841 target: LogicalPosition,
842 duration: Duration,
843 easing: EasingFunction,
844 now: Instant,
845 ) {
846 let is_zero = match &duration {
848 Duration::System(s) => s.secs == 0 && s.nanos == 0,
849 Duration::Tick(t) => t.tick_diff == 0,
850 };
851
852 if is_zero {
853 self.set_scroll_position(dom_id, node_id, target, now);
854 return;
855 }
856
857 let state = self
858 .states
859 .entry((dom_id, node_id))
860 .or_insert_with(|| AnimatedScrollState::new(now.clone()));
861 let clamped_target = state.clamp(target);
862 state.animation = Some(ScrollAnimation {
863 start_time: now.clone(),
864 duration,
865 start_offset: state.current_offset,
866 target_offset: clamped_target,
867 easing,
868 });
869 state.last_activity = now;
870 }
871
872 pub fn update_node_bounds(
874 &mut self,
875 dom_id: DomId,
876 node_id: NodeId,
877 container_rect: LogicalRect,
878 content_rect: LogicalRect,
879 now: Instant,
880 ) {
881 let state = self
882 .states
883 .entry((dom_id, node_id))
884 .or_insert_with(|| AnimatedScrollState::new(now));
885 state.container_rect = container_rect;
886 state.content_rect = content_rect;
887 state.current_offset = state.clamp(state.current_offset);
888 }
889
890 pub fn update_virtual_scroll_bounds(
899 &mut self,
900 dom_id: DomId,
901 node_id: NodeId,
902 virtual_scroll_size: LogicalSize,
903 virtual_scroll_offset: Option<LogicalPosition>,
904 ) {
905 let key = (dom_id, node_id);
906 let state = self.states.entry(key).or_insert_with(|| {
907 AnimatedScrollState::new(Instant::now())
911 });
912 state.virtual_scroll_size = Some(virtual_scroll_size);
913 state.virtual_scroll_offset = virtual_scroll_offset;
914 state.current_offset = state.clamp(state.current_offset);
916 }
917
918 #[must_use] pub fn get_current_offset(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
920 self.states
921 .get(&(dom_id, node_id))
922 .map(|s| s.current_offset)
923 }
924
925 #[must_use] pub fn get_last_activity_time(&self, dom_id: DomId, node_id: NodeId) -> Option<Instant> {
927 self.states
928 .get(&(dom_id, node_id))
929 .map(|s| s.last_activity.clone())
930 }
931
932 #[must_use] pub fn get_scroll_state(&self, dom_id: DomId, node_id: NodeId) -> Option<&AnimatedScrollState> {
934 self.states.get(&(dom_id, node_id))
935 }
936
937 #[must_use] pub fn get_scroll_node_info(
945 &self,
946 dom_id: DomId,
947 node_id: NodeId,
948 ) -> Option<ScrollNodeInfo> {
949 let state = self.states.get(&(dom_id, node_id))?;
950 let effective_content_width = state.virtual_scroll_size
951 .map_or(state.content_rect.size.width, |s| s.width);
952 let effective_content_height = state.virtual_scroll_size
953 .map_or(state.content_rect.size.height, |s| s.height);
954 let max_x = (effective_content_width - state.container_rect.size.width).max(0.0);
955 let max_y = (effective_content_height - state.container_rect.size.height).max(0.0);
956 Some(ScrollNodeInfo {
957 current_offset: state.current_offset,
958 container_rect: state.container_rect,
959 content_rect: state.content_rect,
960 max_scroll_x: max_x,
961 max_scroll_y: max_y,
962 overscroll_behavior_x: state.overscroll_behavior_x,
963 overscroll_behavior_y: state.overscroll_behavior_y,
964 overflow_scrolling: state.overflow_scrolling,
965 })
966 }
967
968 #[must_use] pub fn get_scroll_states_for_dom(&self, dom_id: DomId) -> BTreeMap<NodeId, ScrollPosition> {
970 if self.states.is_empty() {
975 return BTreeMap::new();
976 }
977 self.states
978 .iter()
979 .filter(|((d, _), _)| *d == dom_id)
980 .map(|((_, node_id), state)| {
981 let effective_content_size = state.virtual_scroll_size
984 .unwrap_or(state.content_rect.size);
985 (
986 *node_id,
987 ScrollPosition {
988 parent_rect: state.container_rect,
989 children_rect: LogicalRect::new(
990 state.current_offset,
991 effective_content_size,
992 ),
993 },
994 )
995 })
996 .collect()
997 }
998
999 pub fn register_or_update_scroll_node(
1006 &mut self,
1007 dom_id: DomId,
1008 node_id: NodeId,
1009 container_rect: LogicalRect,
1010 content_size: LogicalSize,
1011 now: Instant,
1012 scrollbar_thickness: f32,
1013 visual_width_px: f32,
1014 has_horizontal_scrollbar: bool,
1015 has_vertical_scrollbar: bool,
1016 ) {
1017 let key = (dom_id, node_id);
1018
1019 let content_rect = LogicalRect {
1020 origin: LogicalPosition::zero(),
1021 size: content_size,
1022 };
1023
1024 if let Some(existing) = self.states.get_mut(&key) {
1025 existing.container_rect = container_rect;
1027 existing.content_rect = content_rect;
1028 existing.scrollbar_thickness = scrollbar_thickness;
1029 existing.visual_width_px = visual_width_px;
1030 existing.has_horizontal_scrollbar = has_horizontal_scrollbar;
1031 existing.has_vertical_scrollbar = has_vertical_scrollbar;
1032 existing.current_offset = existing.clamp(existing.current_offset);
1034 } else {
1035 self.states.insert(
1037 key,
1038 AnimatedScrollState {
1039 current_offset: LogicalPosition::zero(),
1040 animation: None,
1041 last_activity: now,
1042 container_rect,
1043 content_rect,
1044 virtual_scroll_size: None,
1045 virtual_scroll_offset: None,
1046 overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1047 overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1048 overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
1049 scrollbar_thickness,
1050 visual_width_px,
1051 has_horizontal_scrollbar,
1052 has_vertical_scrollbar,
1053 },
1054 );
1055 }
1056 }
1057
1058 pub fn calculate_scrollbar_states(&mut self) {
1064 self.scrollbar_states.clear();
1065
1066 for orientation in [ScrollbarOrientation::Vertical, ScrollbarOrientation::Horizontal] {
1069 let states: Vec<_> = self
1070 .states
1071 .iter()
1072 .filter(|(_, s)| {
1073 let (effective, container) = match orientation {
1074 ScrollbarOrientation::Vertical => (
1075 s.virtual_scroll_size.map_or(s.content_rect.size.height, |vs| vs.height),
1076 s.container_rect.size.height,
1077 ),
1078 ScrollbarOrientation::Horizontal => (
1079 s.virtual_scroll_size.map_or(s.content_rect.size.width, |vs| vs.width),
1080 s.container_rect.size.width,
1081 ),
1082 };
1083 effective > container
1084 })
1085 .map(|((dom_id, node_id), scroll_state)| {
1086 let state = Self::calculate_scrollbar_state_from_geometry(
1087 scroll_state,
1088 orientation,
1089 );
1090 ((*dom_id, *node_id, orientation), state)
1091 })
1092 .collect();
1093
1094 self.scrollbar_states.extend(states);
1095 }
1096 }
1097
1098 fn calculate_scrollbar_state_from_geometry(
1100 scroll_state: &AnimatedScrollState,
1101 orientation: ScrollbarOrientation,
1102 ) -> ScrollbarState {
1103 let scrollbar_thickness = if scroll_state.visual_width_px > 0.0 {
1104 scroll_state.visual_width_px
1105 } else if scroll_state.scrollbar_thickness > 0.0 {
1106 scroll_state.scrollbar_thickness
1107 } else {
1108 crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX
1109 };
1110
1111 let content_size = scroll_state.virtual_scroll_size
1112 .map_or(scroll_state.content_rect.size, |vs| vs);
1113
1114 let scroll_offset = match orientation {
1115 ScrollbarOrientation::Vertical => scroll_state.current_offset.y,
1116 ScrollbarOrientation::Horizontal => scroll_state.current_offset.x,
1117 };
1118
1119 let has_other_scrollbar = match orientation {
1120 ScrollbarOrientation::Vertical => scroll_state.has_horizontal_scrollbar,
1121 ScrollbarOrientation::Horizontal => scroll_state.has_vertical_scrollbar,
1122 };
1123
1124 let is_overlay = scroll_state.scrollbar_thickness == 0.0;
1126 let button_size = if is_overlay { 0.0 } else { scrollbar_thickness };
1127 let geom = compute_scrollbar_geometry_with_button_size(
1128 orientation,
1129 scroll_state.container_rect,
1130 content_size,
1131 scroll_offset,
1132 scrollbar_thickness,
1133 has_other_scrollbar,
1134 button_size,
1135 );
1136
1137 let scale = match orientation {
1139 ScrollbarOrientation::Vertical => {
1140 LogicalPosition::new(1.0, geom.track_rect.size.height / scrollbar_thickness)
1141 }
1142 ScrollbarOrientation::Horizontal => {
1143 LogicalPosition::new(geom.track_rect.size.width / scrollbar_thickness, 1.0)
1144 }
1145 };
1146
1147 ScrollbarState {
1148 visible: true,
1149 orientation,
1150 base_size: scrollbar_thickness,
1151 scale,
1152 thumb_position_ratio: geom.scroll_ratio,
1153 thumb_size_ratio: geom.thumb_size_ratio,
1154 track_rect: geom.track_rect,
1155 button_size: geom.button_size,
1156 usable_track_length: geom.usable_track_length,
1157 thumb_length: geom.thumb_length,
1158 thumb_offset: geom.thumb_offset,
1159 }
1160 }
1161
1162 #[must_use] pub fn get_scrollbar_state(
1164 &self,
1165 dom_id: DomId,
1166 node_id: NodeId,
1167 orientation: ScrollbarOrientation,
1168 ) -> Option<&ScrollbarState> {
1169 self.scrollbar_states.get(&(dom_id, node_id, orientation))
1170 }
1171
1172 pub(crate) fn iter_scrollbar_states(
1174 &self,
1175 ) -> impl Iterator<Item = ((DomId, NodeId, ScrollbarOrientation), &ScrollbarState)> + '_ {
1176 self.scrollbar_states.iter().map(|(k, v)| (*k, v))
1177 }
1178
1179 pub(crate) fn hit_test_scrollbar(
1184 &self,
1185 dom_id: DomId,
1186 node_id: NodeId,
1187 global_pos: LogicalPosition,
1188 ) -> Option<ScrollbarHit> {
1189 for orientation in [
1191 ScrollbarOrientation::Vertical,
1192 ScrollbarOrientation::Horizontal,
1193 ] {
1194 let Some(scrollbar_state) = self.scrollbar_states.get(&(dom_id, node_id, orientation)) else {
1195 continue;
1196 };
1197
1198 if !scrollbar_state.visible {
1199 continue;
1200 }
1201
1202 if !scrollbar_state.track_rect.contains(global_pos) {
1204 continue;
1205 }
1206
1207 let local_pos = LogicalPosition::new(
1209 global_pos.x - scrollbar_state.track_rect.origin.x,
1210 global_pos.y - scrollbar_state.track_rect.origin.y,
1211 );
1212
1213 let component = scrollbar_state.hit_test_component(local_pos);
1215
1216 return Some(ScrollbarHit {
1217 dom_id,
1218 node_id,
1219 orientation,
1220 component,
1221 local_position: local_pos,
1222 global_position: global_pos,
1223 });
1224 }
1225
1226 None
1227 }
1228
1229 #[must_use] pub fn hit_test_scrollbars(&self, global_pos: LogicalPosition) -> Option<ScrollbarHit> {
1237 for ((dom_id, node_id, orientation), scrollbar_state) in self.scrollbar_states.iter().rev()
1239 {
1240 if !scrollbar_state.visible {
1241 continue;
1242 }
1243
1244 if !scrollbar_state.track_rect.contains(global_pos) {
1246 continue;
1247 }
1248
1249 let local_pos = LogicalPosition::new(
1251 global_pos.x - scrollbar_state.track_rect.origin.x,
1252 global_pos.y - scrollbar_state.track_rect.origin.y,
1253 );
1254
1255 let component = scrollbar_state.hit_test_component(local_pos);
1257
1258 return Some(ScrollbarHit {
1259 dom_id: *dom_id,
1260 node_id: *node_id,
1261 orientation: *orientation,
1262 component,
1263 local_position: local_pos,
1264 global_position: global_pos,
1265 });
1266 }
1267
1268 None
1269 }
1270}
1271
1272impl AnimatedScrollState {
1275 pub(crate) const fn new(now: Instant) -> Self {
1278 Self {
1279 current_offset: LogicalPosition::zero(),
1280 animation: None,
1281 last_activity: now,
1282 container_rect: LogicalRect::zero(),
1283 content_rect: LogicalRect::zero(),
1284 virtual_scroll_size: None,
1285 virtual_scroll_offset: None,
1286 overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1287 overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1288 overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
1289 scrollbar_thickness: crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX,
1290 visual_width_px: 0.0,
1291 has_horizontal_scrollbar: false,
1292 has_vertical_scrollbar: false,
1293 }
1294 }
1295
1296 pub(crate) fn clamp(&self, position: LogicalPosition) -> LogicalPosition {
1301 let effective_width = self.virtual_scroll_size
1302 .map_or(self.content_rect.size.width, |s| s.width);
1303 let effective_height = self.virtual_scroll_size
1304 .map_or(self.content_rect.size.height, |s| s.height);
1305 let max_x = (effective_width - self.container_rect.size.width).max(0.0);
1306 let max_y = (effective_height - self.container_rect.size.height).max(0.0);
1307 LogicalPosition {
1308 x: position.x.max(0.0).min(max_x),
1309 y: position.y.max(0.0).min(max_y),
1310 }
1311 }
1312}
1313
1314#[allow(clippy::suboptimal_flops)] pub(crate) fn apply_easing(t: f32, easing: EasingFunction) -> f32 {
1320 match easing {
1321 EasingFunction::Linear => t,
1322 EasingFunction::EaseOut => 1.0 - (1.0 - t).powi(3),
1323 EasingFunction::EaseInOut => {
1324 if t < 0.5 {
1325 4.0 * t * t * t
1326 } else {
1327 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
1328 }
1329 }
1330 }
1331}
1332
1333impl crate::managers::NodeIdRemap for ScrollManager {
1334 fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
1343 crate::managers::remap_dom_keys(&mut self.states, dom, map);
1344
1345 let old = core::mem::take(&mut self.scrollbar_states);
1346 for ((d, old_node_id, orientation), state) in old {
1347 if d != dom {
1348 self.scrollbar_states
1349 .insert((d, old_node_id, orientation), state);
1350 } else if let Some(new_node_id) = map.resolve(old_node_id) {
1351 self.scrollbar_states
1352 .insert((d, new_node_id, orientation), state);
1353 }
1354 }
1355 }
1356}
1357
1358#[cfg(all(test, feature = "std"))]
1362mod natural_scroll_tests {
1363 use super::*;
1364 use azul_core::dom::{DomId, NodeId};
1365 use azul_core::geom::LogicalPosition;
1366 use azul_core::task::Instant;
1367
1368 fn raw_input(dx: f32, dy: f32) -> ScrollInput {
1369 ScrollInput {
1370 dom_id: DomId::ROOT_ID,
1371 node_id: NodeId::new(0),
1372 delta: LogicalPosition::new(dx, dy),
1373 timestamp: Instant::from(std::time::Instant::now()),
1374 source: ScrollInputSource::WheelDiscrete,
1375 }
1376 }
1377
1378 #[test]
1379 #[allow(clippy::float_cmp)] fn default_is_traditional_and_inverts_raw_delta() {
1381 let mut m = ScrollManager::new();
1385 assert!(!m.is_natural_scroll(), "default must be traditional");
1386 m.record_scroll_input(raw_input(3.0, 10.0));
1387 let q = m.get_input_queue().take_all();
1388 assert_eq!(q.len(), 1);
1389 assert_eq!(q[0].delta.x, -3.0, "x must be inverted by the default sign");
1390 assert_eq!(q[0].delta.y, -10.0, "y must be inverted by the default sign");
1391 }
1392
1393 #[test]
1394 #[allow(clippy::float_cmp)] fn natural_passes_raw_delta_through() {
1396 let mut m = ScrollManager::new();
1397 m.set_natural_scroll(true);
1398 assert!(m.is_natural_scroll());
1399 m.record_scroll_input(raw_input(3.0, 10.0));
1400 let q = m.get_input_queue().take_all();
1401 assert_eq!(q.len(), 1);
1402 assert_eq!(q[0].delta.x, 3.0, "natural mode must NOT invert x");
1403 assert_eq!(q[0].delta.y, 10.0, "natural mode must NOT invert y");
1404 }
1405
1406 #[test]
1407 #[allow(clippy::float_cmp)] fn toggling_flips_sign_for_subsequent_input() {
1409 let mut m = ScrollManager::new();
1412 m.record_scroll_input(raw_input(0.0, 5.0));
1413 m.set_natural_scroll(true);
1414 m.record_scroll_input(raw_input(0.0, 5.0));
1415 let q = m.get_input_queue().take_all();
1416 assert_eq!(q.len(), 2);
1417 assert_eq!(q[0].delta.y, -5.0, "traditional first");
1418 assert_eq!(q[1].delta.y, 5.0, "natural after toggle");
1419 }
1420
1421 fn nested_setup() -> (ScrollManager, DomId, NodeId, NodeId) {
1424 use azul_core::geom::{LogicalRect, LogicalSize};
1425
1426 let now = Instant::now();
1427 let mut m = ScrollManager::new();
1428 let dom = DomId::ROOT_ID;
1429 let outer = NodeId::from_usize(1).unwrap();
1431 let inner = NodeId::from_usize(9).unwrap();
1432 m.register_or_update_scroll_node(
1434 dom,
1435 outer,
1436 LogicalRect {
1437 origin: LogicalPosition::zero(),
1438 size: LogicalSize { width: 200.0, height: 200.0 },
1439 },
1440 LogicalSize { width: 200.0, height: 1000.0 },
1441 now.clone(),
1442 8.0,
1443 8.0,
1444 false,
1445 true,
1446 );
1447 m.register_or_update_scroll_node(
1449 dom,
1450 inner,
1451 LogicalRect {
1452 origin: LogicalPosition::zero(),
1453 size: LogicalSize { width: 100.0, height: 100.0 },
1454 },
1455 LogicalSize { width: 100.0, height: 300.0 },
1456 now,
1457 8.0,
1458 8.0,
1459 false,
1460 true,
1461 );
1462 (m, dom, outer, inner)
1463 }
1464
1465 #[test]
1466 fn nested_scroll_prefers_innermost_with_room() {
1467 let (m, dom, outer, inner) = nested_setup();
1468 let picked = m.select_scroll_target(
1470 [(dom, inner), (dom, outer)].into_iter(),
1471 0.0,
1472 1.0,
1473 );
1474 assert_eq!(picked, Some((dom, inner)), "inner has room → inner wins");
1475 }
1476
1477 #[test]
1478 fn nested_scroll_hands_off_to_ancestor_at_boundary() {
1479 let (mut m, dom, outer, inner) = nested_setup();
1480 m.states.get_mut(&(dom, inner)).unwrap().current_offset =
1482 LogicalPosition { x: 0.0, y: 200.0 };
1483
1484 let down = m.select_scroll_target(
1485 [(dom, inner), (dom, outer)].into_iter(),
1486 0.0,
1487 1.0,
1488 );
1489 assert_eq!(down, Some((dom, outer)), "inner pinned at bottom → handoff");
1490
1491 let up = m.select_scroll_target(
1492 [(dom, inner), (dom, outer)].into_iter(),
1493 0.0,
1494 -1.0,
1495 );
1496 assert_eq!(up, Some((dom, inner)), "inner has room upward → inner again");
1497 }
1498
1499 #[test]
1500 fn nested_scroll_falls_back_to_innermost_when_all_pinned() {
1501 let (mut m, dom, outer, inner) = nested_setup();
1502 m.states.get_mut(&(dom, inner)).unwrap().current_offset =
1503 LogicalPosition { x: 0.0, y: 200.0 };
1504 m.states.get_mut(&(dom, outer)).unwrap().current_offset =
1505 LogicalPosition { x: 0.0, y: 800.0 };
1506
1507 let picked = m.select_scroll_target(
1508 [(dom, inner), (dom, outer)].into_iter(),
1509 0.0,
1510 1.0,
1511 );
1512 assert_eq!(
1513 picked,
1514 Some((dom, inner)),
1515 "everything pinned → innermost fallback (gesture stays under pointer)"
1516 );
1517 }
1518}
1519
1520#[cfg(all(test, feature = "std"))]
1530mod autotest_generated {
1531 #![allow(clippy::float_cmp)] use std::collections::HashMap;
1534
1535 use azul_core::{
1536 dom::{DomId, NodeId, ScrollbarOrientation},
1537 events::EasingFunction,
1538 geom::{LogicalPosition, LogicalRect, LogicalSize},
1539 hit_test::{FullHitTest, HitTest, OverflowingScrollNode, ScrollHitTestItem},
1540 styled_dom::NodeHierarchyItem,
1541 task::{Duration, Instant, SystemTick, SystemTickDiff, SystemTimeDiff},
1542 };
1543
1544 use super::*;
1545 use crate::managers::hover::HoverManager;
1546
1547 const DOM: DomId = DomId::ROOT_ID;
1550 const DOM1: DomId = DomId { inner: 1 };
1551
1552 fn node(i: usize) -> NodeId {
1553 NodeId::new(i)
1554 }
1555
1556 fn at(t: u64) -> Instant {
1558 Instant::Tick(SystemTick::new(t))
1559 }
1560
1561 fn tick_dur(d: u64) -> Duration {
1562 Duration::Tick(SystemTickDiff { tick_diff: d })
1563 }
1564
1565 fn sys_dur(secs: u64, nanos: u32) -> Duration {
1566 Duration::System(SystemTimeDiff { secs, nanos })
1567 }
1568
1569 fn pos(x: f32, y: f32) -> LogicalPosition {
1570 LogicalPosition::new(x, y)
1571 }
1572
1573 fn size(w: f32, h: f32) -> LogicalSize {
1574 LogicalSize::new(w, h)
1575 }
1576
1577 fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
1578 LogicalRect::new(pos(x, y), size(w, h))
1579 }
1580
1581 fn mgr(container: LogicalSize, content: LogicalSize) -> ScrollManager {
1583 let mut m = ScrollManager::new();
1584 m.register_or_update_scroll_node(
1585 DOM,
1586 node(0),
1587 LogicalRect::new(LogicalPosition::zero(), container),
1588 content,
1589 at(0),
1590 16.0,
1591 16.0,
1592 false,
1593 true,
1594 );
1595 m
1596 }
1597
1598 fn state(container: LogicalSize, content: LogicalSize) -> AnimatedScrollState {
1600 let mut s = AnimatedScrollState::new(at(0));
1601 s.container_rect = LogicalRect::new(LogicalPosition::zero(), container);
1602 s.content_rect = LogicalRect::new(LogicalPosition::zero(), content);
1603 s
1604 }
1605
1606 fn input(dx: f32, dy: f32, ts: u64) -> ScrollInput {
1607 ScrollInput {
1608 dom_id: DOM,
1609 node_id: node(0),
1610 delta: pos(dx, dy),
1611 timestamp: at(ts),
1612 source: ScrollInputSource::WheelDiscrete,
1613 }
1614 }
1615
1616 fn scrollbar(
1617 orientation: ScrollbarOrientation,
1618 track: LogicalRect,
1619 button_size: f32,
1620 thumb_offset: f32,
1621 thumb_length: f32,
1622 ) -> ScrollbarState {
1623 ScrollbarState {
1624 visible: true,
1625 orientation,
1626 base_size: 16.0,
1627 scale: LogicalPosition::new(1.0, 1.0),
1628 thumb_position_ratio: 0.0,
1629 thumb_size_ratio: 0.5,
1630 track_rect: track,
1631 button_size,
1632 usable_track_length: 0.0,
1633 thumb_length,
1634 thumb_offset,
1635 }
1636 }
1637
1638 fn hover_over(nodes: &[usize]) -> HoverManager {
1642 let mut ht = HitTest::empty();
1643 for n in nodes {
1644 ht.scroll_hit_test_nodes.insert(
1645 node(*n),
1646 ScrollHitTestItem {
1647 point_in_viewport: LogicalPosition::zero(),
1648 point_relative_to_item: LogicalPosition::zero(),
1649 scroll_node: OverflowingScrollNode::default(),
1650 },
1651 );
1652 }
1653 let mut full = FullHitTest::empty(None);
1654 full.hovered_nodes.insert(DOM, ht);
1655 let mut hm = HoverManager::new();
1656 hm.push_hit_test(InputPointId::Mouse, full);
1657 hm
1658 }
1659
1660 #[test]
1664 fn apply_easing_endpoints_are_exact_for_every_curve() {
1665 for e in [
1668 EasingFunction::Linear,
1669 EasingFunction::EaseOut,
1670 EasingFunction::EaseInOut,
1671 ] {
1672 assert_eq!(apply_easing(0.0, e), 0.0, "f(0) must be 0 for {e:?}");
1673 assert_eq!(apply_easing(1.0, e), 1.0, "f(1) must be 1 for {e:?}");
1674 }
1675 }
1676
1677 #[test]
1678 fn apply_easing_is_monotonic_and_bounded_on_the_unit_interval() {
1679 for e in [
1680 EasingFunction::Linear,
1681 EasingFunction::EaseOut,
1682 EasingFunction::EaseInOut,
1683 ] {
1684 let mut prev = f32::NEG_INFINITY;
1685 for i in 0..=100 {
1686 let t = i as f32 / 100.0;
1687 let v = apply_easing(t, e);
1688 assert!(v.is_finite(), "{e:?}({t}) must be finite, got {v}");
1689 assert!(
1690 (-1e-6..=1.0 + 1e-6).contains(&v),
1691 "{e:?}({t}) = {v} escaped [0, 1]"
1692 );
1693 assert!(v >= prev - 1e-6, "{e:?} must not go backwards at t={t}");
1694 prev = v;
1695 }
1696 }
1697 }
1698
1699 #[test]
1700 fn apply_easing_nan_propagates_without_panicking() {
1701 for e in [
1703 EasingFunction::Linear,
1704 EasingFunction::EaseOut,
1705 EasingFunction::EaseInOut,
1706 ] {
1707 assert!(
1708 apply_easing(f32::NAN, e).is_nan(),
1709 "{e:?}(NaN) must be NaN, not a silently-wrong number"
1710 );
1711 }
1712 }
1713
1714 #[test]
1715 fn apply_easing_infinities_saturate_to_infinity_not_panic() {
1716 assert_eq!(apply_easing(f32::INFINITY, EasingFunction::Linear), f32::INFINITY);
1717 assert_eq!(
1718 apply_easing(f32::NEG_INFINITY, EasingFunction::Linear),
1719 f32::NEG_INFINITY
1720 );
1721 assert_eq!(apply_easing(f32::INFINITY, EasingFunction::EaseOut), f32::INFINITY);
1723 assert_eq!(
1724 apply_easing(f32::NEG_INFINITY, EasingFunction::EaseOut),
1725 f32::NEG_INFINITY
1726 );
1727 assert_eq!(
1729 apply_easing(f32::INFINITY, EasingFunction::EaseInOut),
1730 f32::INFINITY
1731 );
1732 assert_eq!(
1733 apply_easing(f32::NEG_INFINITY, EasingFunction::EaseInOut),
1734 f32::NEG_INFINITY
1735 );
1736 }
1737
1738 #[test]
1739 fn apply_easing_f32_extremes_do_not_panic() {
1740 for e in [
1743 EasingFunction::Linear,
1744 EasingFunction::EaseOut,
1745 EasingFunction::EaseInOut,
1746 ] {
1747 let hi = apply_easing(f32::MAX, e);
1748 let lo = apply_easing(f32::MIN, e);
1749 assert!(!hi.is_nan(), "{e:?}(f32::MAX) must not be NaN");
1750 assert!(!lo.is_nan(), "{e:?}(f32::MIN) must not be NaN");
1751 }
1752 assert!(apply_easing(f32::MIN_POSITIVE, EasingFunction::EaseInOut).abs() < 1e-30);
1754 }
1755
1756 #[test]
1757 fn apply_easing_negative_t_is_deterministic_extrapolation() {
1758 assert_eq!(apply_easing(-1.0, EasingFunction::Linear), -1.0);
1761 assert_eq!(apply_easing(-1.0, EasingFunction::EaseOut), -7.0);
1762 assert_eq!(apply_easing(-1.0, EasingFunction::EaseInOut), -4.0);
1763 }
1764
1765 #[test]
1766 fn apply_easing_ease_in_out_is_continuous_at_the_branch_boundary() {
1767 assert_eq!(apply_easing(0.5, EasingFunction::EaseInOut), 0.5);
1769 let just_below = apply_easing(0.499_999, EasingFunction::EaseInOut);
1770 assert!(
1771 (just_below - 0.5).abs() < 1e-4,
1772 "discontinuity at the 0.5 branch: {just_below}"
1773 );
1774 assert_eq!(apply_easing(0.5, EasingFunction::EaseOut), 0.875);
1775 }
1776
1777 #[test]
1781 fn animated_scroll_state_new_starts_at_scroll_origin() {
1782 let s = AnimatedScrollState::new(at(0));
1783 assert_eq!(s.current_offset, LogicalPosition::zero());
1784 assert!(s.animation.is_none());
1785 assert_eq!(s.container_rect, LogicalRect::zero());
1786 assert_eq!(s.content_rect, LogicalRect::zero());
1787 assert!(s.virtual_scroll_size.is_none());
1788 assert!(s.virtual_scroll_offset.is_none());
1789 assert!(!s.has_horizontal_scrollbar);
1790 assert!(!s.has_vertical_scrollbar);
1791 assert_eq!(s.clamp(pos(1e9, 1e9)), LogicalPosition::zero());
1793 }
1794
1795 #[test]
1799 fn clamp_pins_to_zero_and_max_travel() {
1800 let s = state(size(100.0, 100.0), size(100.0, 500.0));
1801 assert_eq!(s.clamp(pos(0.0, 0.0)), pos(0.0, 0.0));
1803 assert_eq!(s.clamp(pos(50.0, 250.0)), pos(0.0, 250.0));
1804 assert_eq!(s.clamp(pos(-1.0, -1.0)), pos(0.0, 0.0));
1805 assert_eq!(s.clamp(pos(9999.0, 9999.0)), pos(0.0, 400.0));
1806 }
1807
1808 #[test]
1809 fn clamp_never_produces_negative_max_when_content_is_smaller_than_container() {
1810 let s = state(size(500.0, 500.0), size(10.0, 10.0));
1812 assert_eq!(s.clamp(pos(100.0, 100.0)), LogicalPosition::zero());
1813 assert_eq!(s.clamp(pos(-100.0, -100.0)), LogicalPosition::zero());
1814 }
1815
1816 #[test]
1817 fn clamp_nan_position_collapses_to_origin_never_stores_nan() {
1818 let s = state(size(100.0, 100.0), size(100.0, 500.0));
1821 let c = s.clamp(pos(f32::NAN, f32::NAN));
1822 assert!(!c.x.is_nan() && !c.y.is_nan(), "clamp must not leak NaN");
1823 assert_eq!(c, LogicalPosition::zero());
1824 }
1825
1826 #[test]
1827 fn clamp_infinite_position_saturates_to_max_travel() {
1828 let s = state(size(100.0, 100.0), size(100.0, 500.0));
1829 assert_eq!(s.clamp(pos(f32::INFINITY, f32::INFINITY)), pos(0.0, 400.0));
1830 assert_eq!(
1831 s.clamp(pos(f32::NEG_INFINITY, f32::NEG_INFINITY)),
1832 LogicalPosition::zero()
1833 );
1834 assert_eq!(s.clamp(pos(f32::MAX, f32::MAX)), pos(0.0, 400.0));
1835 assert_eq!(s.clamp(pos(f32::MIN, f32::MIN)), LogicalPosition::zero());
1836 }
1837
1838 #[test]
1839 fn clamp_nan_geometry_degrades_to_zero_travel() {
1840 let s = state(size(100.0, 100.0), size(f32::NAN, f32::NAN));
1842 let c = s.clamp(pos(50.0, 50.0));
1843 assert!(!c.x.is_nan() && !c.y.is_nan());
1844 assert_eq!(c, LogicalPosition::zero());
1845 }
1846
1847 #[test]
1848 fn clamp_infinite_content_minus_infinite_container_is_zero_travel_not_nan() {
1849 let s = state(
1851 size(f32::INFINITY, f32::INFINITY),
1852 size(f32::INFINITY, f32::INFINITY),
1853 );
1854 let c = s.clamp(pos(10.0, 10.0));
1855 assert!(!c.x.is_nan() && !c.y.is_nan());
1856 assert_eq!(c, LogicalPosition::zero());
1857 }
1858
1859 #[test]
1860 fn clamp_prefers_virtual_scroll_size_over_content_rect() {
1861 let mut s = state(size(100.0, 100.0), size(100.0, 120.0));
1862 assert_eq!(s.clamp(pos(0.0, 1e9)), pos(0.0, 20.0), "content_rect bound");
1863 s.virtual_scroll_size = Some(size(100.0, 10_000.0));
1864 assert_eq!(
1865 s.clamp(pos(0.0, 1e9)),
1866 pos(0.0, 9900.0),
1867 "virtual size must override content_rect"
1868 );
1869 }
1870
1871 #[test]
1875 fn input_queue_new_is_empty_and_default_matches() {
1876 let q = ScrollInputQueue::new();
1877 assert!(!q.has_pending());
1878 assert!(q.take_all().is_empty());
1879 assert!(q.take_recent(10).is_empty());
1880 assert!(!ScrollInputQueue::default().has_pending());
1881 }
1882
1883 #[test]
1884 fn input_queue_take_all_drains_and_preserves_push_order() {
1885 let q = ScrollInputQueue::new();
1886 q.push(input(1.0, 1.0, 1));
1887 q.push(input(2.0, 2.0, 2));
1888 assert!(q.has_pending());
1889 let taken = q.take_all();
1890 assert_eq!(taken.len(), 2);
1891 assert_eq!(taken[0].delta.x, 1.0);
1892 assert_eq!(taken[1].delta.x, 2.0);
1893 assert!(!q.has_pending(), "take_all must drain the queue");
1894 assert!(q.take_all().is_empty(), "second take_all is empty, not stale");
1895 }
1896
1897 #[test]
1898 fn input_queue_take_recent_zero_discards_everything() {
1899 let q = ScrollInputQueue::new();
1903 q.push(input(1.0, 1.0, 1));
1904 q.push(input(2.0, 2.0, 2));
1905 let taken = q.take_recent(0);
1906 assert!(taken.is_empty(), "take_recent(0) must return nothing");
1907 assert!(!q.has_pending(), "take_recent(0) must still drain the queue");
1908 }
1909
1910 #[test]
1911 fn input_queue_take_recent_keeps_the_newest_events_sorted_oldest_first() {
1912 let q = ScrollInputQueue::new();
1913 q.push(input(0.0, 0.0, 5));
1915 q.push(input(0.0, 0.0, 1));
1916 q.push(input(0.0, 0.0, 3));
1917 q.push(input(0.0, 0.0, 9));
1918 let taken = q.take_recent(2);
1919 assert_eq!(taken.len(), 2, "backlog must be truncated to max_events");
1920 assert_eq!(taken[0].timestamp, at(5));
1921 assert_eq!(taken[1].timestamp, at(9), "newest event must be last");
1922 assert!(!q.has_pending());
1923 }
1924
1925 #[test]
1926 fn input_queue_take_recent_below_limit_returns_push_order_not_sorted() {
1927 let q = ScrollInputQueue::new();
1931 q.push(input(0.0, 0.0, 5));
1932 q.push(input(0.0, 0.0, 1));
1933 q.push(input(0.0, 0.0, 3));
1934 let taken = q.take_recent(3);
1935 assert_eq!(taken.len(), 3);
1936 let stamps: Vec<_> = taken.iter().map(|e| e.timestamp.clone()).collect();
1937 assert_eq!(stamps, vec![at(5), at(1), at(3)]);
1938 }
1939
1940 #[test]
1941 fn input_queue_take_recent_usize_max_does_not_overflow() {
1942 let q = ScrollInputQueue::new();
1945 q.push(input(1.0, 0.0, 1));
1946 q.push(input(2.0, 0.0, 2));
1947 let taken = q.take_recent(usize::MAX);
1948 assert_eq!(taken.len(), 2);
1949 assert!(!q.has_pending());
1950 assert!(q.take_recent(usize::MAX).is_empty());
1952 assert!(q.take_recent(0).is_empty());
1953 }
1954
1955 #[test]
1956 fn input_queue_clone_shares_one_backing_store() {
1957 let q = ScrollInputQueue::new();
1960 let c = q.clone();
1961 c.push(input(1.0, 2.0, 1));
1962 assert!(q.has_pending(), "clone must not deep-copy the queue");
1963 assert_eq!(q.take_all().len(), 1);
1964 assert!(!c.has_pending(), "draining one handle drains both");
1965 }
1966
1967 #[test]
1968 fn input_queue_accepts_non_finite_deltas_without_panicking() {
1969 let q = ScrollInputQueue::new();
1970 q.push(input(f32::NAN, f32::INFINITY, 1));
1971 q.push(input(f32::MAX, f32::MIN, 2));
1972 let taken = q.take_recent(usize::MAX);
1973 assert_eq!(taken.len(), 2);
1974 assert!(taken[0].delta.x.is_nan());
1975 assert_eq!(taken[0].delta.y, f32::INFINITY);
1976 }
1977
1978 #[test]
1982 fn hit_test_component_vertical_maps_each_region() {
1983 let sb = scrollbar(
1984 ScrollbarOrientation::Vertical,
1985 rect(0.0, 0.0, 16.0, 100.0),
1986 16.0, 10.0, 30.0, );
1990 assert_eq!(sb.hit_test_component(pos(8.0, 0.0)), ScrollbarComponent::TopButton);
1991 assert_eq!(sb.hit_test_component(pos(8.0, 15.9)), ScrollbarComponent::TopButton);
1992 assert_eq!(
1993 sb.hit_test_component(pos(8.0, 99.0)),
1994 ScrollbarComponent::BottomButton
1995 );
1996 assert_eq!(sb.hit_test_component(pos(8.0, 26.0)), ScrollbarComponent::Thumb);
1998 assert_eq!(sb.hit_test_component(pos(8.0, 56.0)), ScrollbarComponent::Thumb);
1999 assert_eq!(sb.hit_test_component(pos(8.0, 20.0)), ScrollbarComponent::Track);
2000 assert_eq!(sb.hit_test_component(pos(8.0, 60.0)), ScrollbarComponent::Track);
2001 }
2002
2003 #[test]
2004 fn hit_test_component_boundaries_are_exact() {
2005 let sb = scrollbar(
2006 ScrollbarOrientation::Vertical,
2007 rect(0.0, 0.0, 16.0, 100.0),
2008 16.0,
2009 0.0,
2010 30.0,
2011 );
2012 assert_eq!(sb.hit_test_component(pos(0.0, 16.0)), ScrollbarComponent::Thumb);
2014 assert_eq!(sb.hit_test_component(pos(0.0, 84.0)), ScrollbarComponent::Track);
2016 assert_eq!(
2017 sb.hit_test_component(pos(0.0, 84.001)),
2018 ScrollbarComponent::BottomButton
2019 );
2020 }
2021
2022 #[test]
2023 fn hit_test_component_overlay_zero_button_size_has_no_buttons() {
2024 let sb = scrollbar(
2027 ScrollbarOrientation::Vertical,
2028 rect(0.0, 0.0, 8.0, 100.0),
2029 0.0,
2030 0.0,
2031 50.0,
2032 );
2033 assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::Thumb);
2034 assert_eq!(sb.hit_test_component(pos(0.0, 50.0)), ScrollbarComponent::Thumb);
2035 assert_eq!(sb.hit_test_component(pos(0.0, 60.0)), ScrollbarComponent::Track);
2036 assert_eq!(sb.hit_test_component(pos(0.0, 100.0)), ScrollbarComponent::Track);
2038 }
2039
2040 #[test]
2041 fn hit_test_component_nan_position_falls_through_to_track() {
2042 let sb = scrollbar(
2045 ScrollbarOrientation::Vertical,
2046 rect(0.0, 0.0, 16.0, 100.0),
2047 16.0,
2048 10.0,
2049 30.0,
2050 );
2051 assert_eq!(
2052 sb.hit_test_component(pos(f32::NAN, f32::NAN)),
2053 ScrollbarComponent::Track
2054 );
2055 let hb = scrollbar(
2056 ScrollbarOrientation::Horizontal,
2057 rect(0.0, 0.0, 100.0, 16.0),
2058 16.0,
2059 10.0,
2060 30.0,
2061 );
2062 assert_eq!(
2063 hb.hit_test_component(pos(f32::NAN, f32::NAN)),
2064 ScrollbarComponent::Track
2065 );
2066 }
2067
2068 #[test]
2069 fn hit_test_component_infinite_position_picks_an_end_button() {
2070 let sb = scrollbar(
2071 ScrollbarOrientation::Vertical,
2072 rect(0.0, 0.0, 16.0, 100.0),
2073 16.0,
2074 10.0,
2075 30.0,
2076 );
2077 assert_eq!(
2078 sb.hit_test_component(pos(0.0, f32::NEG_INFINITY)),
2079 ScrollbarComponent::TopButton
2080 );
2081 assert_eq!(
2082 sb.hit_test_component(pos(0.0, f32::INFINITY)),
2083 ScrollbarComponent::BottomButton
2084 );
2085 assert_eq!(
2086 sb.hit_test_component(pos(0.0, f32::MIN)),
2087 ScrollbarComponent::TopButton
2088 );
2089 assert_eq!(
2090 sb.hit_test_component(pos(0.0, f32::MAX)),
2091 ScrollbarComponent::BottomButton
2092 );
2093 }
2094
2095 #[test]
2096 fn hit_test_component_ignores_the_cross_axis() {
2097 let v = scrollbar(
2100 ScrollbarOrientation::Vertical,
2101 rect(0.0, 0.0, 16.0, 100.0),
2102 16.0,
2103 10.0,
2104 30.0,
2105 );
2106 for x in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
2107 assert_eq!(v.hit_test_component(pos(x, 30.0)), ScrollbarComponent::Thumb);
2108 }
2109 let h = scrollbar(
2110 ScrollbarOrientation::Horizontal,
2111 rect(0.0, 0.0, 100.0, 16.0),
2112 16.0,
2113 10.0,
2114 30.0,
2115 );
2116 for y in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
2117 assert_eq!(h.hit_test_component(pos(30.0, y)), ScrollbarComponent::Thumb);
2118 }
2119 }
2120
2121 #[test]
2122 fn hit_test_component_degenerate_track_shorter_than_buttons_prefers_top() {
2123 let sb = scrollbar(
2126 ScrollbarOrientation::Vertical,
2127 rect(0.0, 0.0, 16.0, 4.0),
2128 16.0,
2129 0.0,
2130 0.0,
2131 );
2132 assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::TopButton);
2133 assert_eq!(sb.hit_test_component(pos(0.0, 3.0)), ScrollbarComponent::TopButton);
2134 }
2135
2136 #[test]
2140 fn manager_new_is_empty_and_traditional_by_default() {
2141 let m = ScrollManager::new();
2142 assert_eq!(m.debug_counts(), (0, 0));
2143 assert!(!m.has_active_animations());
2144 assert!(!m.has_pending_scroll_changes());
2145 assert!(!m.is_natural_scroll());
2146 assert_eq!(m.scroll_sign(), -1.0);
2147 assert!(m.pending_wheel_event.is_none());
2148 assert!(!m.get_input_queue().has_pending());
2149 assert!(m.get_current_offset(DOM, node(0)).is_none());
2151 assert!(m.get_last_activity_time(DOM, node(0)).is_none());
2152 assert!(m.get_scroll_state(DOM, node(0)).is_none());
2153 assert!(m.get_scroll_node_info(DOM, node(0)).is_none());
2154 assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
2155 assert!(m.get_scroll_states_for_dom(DOM).is_empty());
2156 assert!(m
2157 .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
2158 .is_none());
2159 assert!(m.hit_test_scrollbars(pos(0.0, 0.0)).is_none());
2160 assert_eq!(m.iter_scrollbar_states().count(), 0);
2161 assert!(!m.is_node_scrollable(DOM, node(0)));
2162 assert!(!m.can_consume_delta(DOM, node(0), 10.0, 10.0));
2163 }
2164
2165 #[test]
2166 fn scroll_sign_flips_with_the_preference() {
2167 let mut m = ScrollManager::new();
2168 assert_eq!(m.scroll_sign(), -1.0);
2169 m.set_natural_scroll(true);
2170 assert!(m.is_natural_scroll());
2171 assert_eq!(m.scroll_sign(), 1.0);
2172 m.set_natural_scroll(false);
2173 assert_eq!(m.scroll_sign(), -1.0);
2174 m.set_natural_scroll(false);
2176 assert_eq!(m.scroll_sign(), -1.0);
2177 }
2178
2179 #[test]
2183 fn scroll_dirty_is_set_only_on_a_real_move_and_cleared_on_demand() {
2184 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2185 assert!(!m.has_pending_scroll_changes());
2186
2187 m.set_scroll_position(DOM, node(0), pos(0.0, 0.005), at(1));
2190 assert!(!m.has_pending_scroll_changes(), "0.005px must not be 'moved'");
2191
2192 m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(2));
2193 assert!(m.has_pending_scroll_changes());
2194
2195 m.clear_scroll_dirty();
2196 assert!(!m.has_pending_scroll_changes());
2197 m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(3));
2199 assert!(!m.has_pending_scroll_changes());
2200 }
2201
2202 #[test]
2203 fn clear_scroll_dirty_on_a_clean_manager_is_a_noop() {
2204 let mut m = ScrollManager::new();
2205 m.clear_scroll_dirty();
2206 m.clear_scroll_dirty();
2207 assert!(!m.has_pending_scroll_changes());
2208 }
2209
2210 #[test]
2214 fn set_scroll_position_clamps_extremes_into_bounds() {
2215 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2216 m.set_scroll_position(DOM, node(0), pos(f32::MAX, f32::MAX), at(1));
2217 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2218
2219 m.set_scroll_position(DOM, node(0), pos(f32::MIN, f32::MIN), at(2));
2220 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 0.0)));
2221
2222 m.set_scroll_position(DOM, node(0), pos(f32::INFINITY, f32::INFINITY), at(3));
2223 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2224
2225 m.set_scroll_position(DOM, node(0), pos(f32::NAN, f32::NAN), at(4));
2226 let off = m.get_current_offset(DOM, node(0)).unwrap();
2227 assert!(!off.x.is_nan() && !off.y.is_nan(), "clamped path must kill NaN");
2228 assert_eq!(off, LogicalPosition::zero());
2229 }
2230
2231 #[test]
2232 fn set_scroll_position_cancels_a_running_animation() {
2233 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2234 m.scroll_to(DOM, node(0), pos(0.0, 300.0), tick_dur(100), EasingFunction::Linear, at(0));
2235 assert!(m.has_active_animations());
2236 m.set_scroll_position(DOM, node(0), pos(0.0, 10.0), at(1));
2237 assert!(!m.has_active_animations(), "an explicit set must win over easing");
2238 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 10.0)));
2239 }
2240
2241 #[test]
2242 fn set_scroll_position_on_an_unknown_node_creates_a_pinned_zero_state() {
2243 let mut m = ScrollManager::new();
2246 m.set_scroll_position(DOM, node(42), pos(500.0, 500.0), at(1));
2247 assert_eq!(m.get_current_offset(DOM, node(42)), Some(LogicalPosition::zero()));
2248 assert_eq!(m.debug_counts(), (1, 0));
2249 m.set_scroll_position(DOM, node(42), pos(600.0, 600.0), at(2));
2250 assert_eq!(m.debug_counts(), (1, 0), "repeat set must not grow the map");
2251 }
2252
2253 #[test]
2254 fn set_scroll_position_unclamped_keeps_overscroll_values_verbatim() {
2255 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2258 m.set_scroll_position_unclamped(DOM, node(0), pos(-50.0, -80.0), at(1));
2259 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(-50.0, -80.0)));
2260 m.set_scroll_position_unclamped(DOM, node(0), pos(0.0, 9999.0), at(2));
2261 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 9999.0)));
2262 assert!(m.has_pending_scroll_changes());
2263 }
2264
2265 #[test]
2266 fn set_scroll_position_unclamped_stores_non_finite_values_unfiltered() {
2267 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2271 m.set_scroll_position_unclamped(DOM, node(0), pos(f32::NAN, f32::NAN), at(1));
2272 let off = m.get_current_offset(DOM, node(0)).unwrap();
2273 assert!(off.x.is_nan() && off.y.is_nan(), "unclamped stores NaN as-is");
2274 assert!(
2275 !m.has_pending_scroll_changes(),
2276 "a NaN write does not trip the dirty flag (NaN comparisons are false)"
2277 );
2278 m.register_or_update_scroll_node(
2280 DOM,
2281 node(0),
2282 rect(0.0, 0.0, 100.0, 100.0),
2283 size(100.0, 500.0),
2284 at(2),
2285 16.0,
2286 16.0,
2287 false,
2288 true,
2289 );
2290 let off = m.get_current_offset(DOM, node(0)).unwrap();
2291 assert!(!off.x.is_nan() && !off.y.is_nan(), "re-clamp must sanitize NaN");
2292 }
2293
2294 #[test]
2298 fn scroll_to_zero_duration_is_immediate_for_both_clock_kinds() {
2299 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2300 m.scroll_to(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
2301 assert!(!m.has_active_animations(), "zero Tick duration must not animate");
2302 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
2303
2304 m.scroll_to(
2305 DOM,
2306 node(0),
2307 pos(0.0, 200.0),
2308 sys_dur(0, 0),
2309 EasingFunction::EaseOut,
2310 at(2),
2311 );
2312 assert!(!m.has_active_animations(), "zero System duration must not animate");
2313 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2314 }
2315
2316 #[test]
2317 fn scroll_to_clamps_the_animation_target_not_just_the_final_offset() {
2318 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2319 m.scroll_to(DOM, node(0), pos(0.0, 1e9), tick_dur(100), EasingFunction::Linear, at(0));
2320 let anim_target = m
2321 .get_scroll_state(DOM, node(0))
2322 .and_then(|s| s.animation.as_ref())
2323 .map(|a| a.target_offset)
2324 .unwrap();
2325 assert_eq!(anim_target, pos(0.0, 400.0), "target must be pre-clamped");
2326 let r = m.tick(at(100));
2328 assert!(r.needs_repaint);
2329 assert_eq!(r.updated_nodes, vec![(DOM, node(0))]);
2330 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2331 assert!(!m.has_active_animations(), "animation must clear at t >= 1");
2332 }
2333
2334 #[test]
2335 fn scroll_to_nan_target_animates_to_the_origin_never_to_nan() {
2336 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2337 m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(0));
2338 m.scroll_to(
2339 DOM,
2340 node(0),
2341 pos(f32::NAN, f32::NAN),
2342 tick_dur(10),
2343 EasingFunction::Linear,
2344 at(0),
2345 );
2346 m.tick(at(10));
2347 let off = m.get_current_offset(DOM, node(0)).unwrap();
2348 assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN target must be clamped away");
2349 assert_eq!(off, LogicalPosition::zero());
2350 }
2351
2352 #[test]
2353 fn scroll_by_accumulates_from_the_current_offset_and_saturates() {
2354 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2355 m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
2356 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
2357 m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(2));
2358 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2359 m.scroll_by(
2361 DOM,
2362 node(0),
2363 pos(f32::MAX, f32::MAX),
2364 tick_dur(0),
2365 EasingFunction::Linear,
2366 at(3),
2367 );
2368 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2369 m.scroll_by(
2371 DOM,
2372 node(0),
2373 pos(f32::MIN, f32::MIN),
2374 tick_dur(0),
2375 EasingFunction::Linear,
2376 at(4),
2377 );
2378 assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
2379 }
2380
2381 #[test]
2382 fn scroll_by_on_an_unknown_node_defaults_to_origin_and_stays_pinned() {
2383 let mut m = ScrollManager::new();
2384 m.scroll_by(
2385 DOM,
2386 node(7),
2387 pos(1e9, 1e9),
2388 tick_dur(0),
2389 EasingFunction::Linear,
2390 at(1),
2391 );
2392 assert_eq!(m.get_current_offset(DOM, node(7)), Some(LogicalPosition::zero()));
2394 }
2395
2396 #[test]
2397 fn scroll_by_nan_delta_does_not_poison_the_offset() {
2398 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2399 m.set_scroll_position(DOM, node(0), pos(0.0, 100.0), at(0));
2400 m.scroll_by(
2401 DOM,
2402 node(0),
2403 pos(f32::NAN, f32::NAN),
2404 tick_dur(0),
2405 EasingFunction::Linear,
2406 at(1),
2407 );
2408 let off = m.get_current_offset(DOM, node(0)).unwrap();
2409 assert!(!off.x.is_nan() && !off.y.is_nan());
2410 assert_eq!(off, LogicalPosition::zero(), "NaN target clamps to origin");
2411 }
2412
2413 #[test]
2417 fn tick_on_an_empty_manager_reports_no_work() {
2418 let mut m = ScrollManager::new();
2419 let r = m.tick(at(1));
2420 assert!(!r.needs_repaint);
2421 assert!(r.updated_nodes.is_empty());
2422 }
2423
2424 #[test]
2425 fn tick_interpolates_linearly_and_completes_exactly_once() {
2426 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2427 m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(0));
2428
2429 let r = m.tick(at(50));
2430 assert!(r.needs_repaint);
2431 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2432 assert!(m.has_active_animations(), "still mid-flight at t = 0.5");
2433
2434 let r = m.tick(at(100));
2435 assert!(r.needs_repaint);
2436 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2437 assert!(!m.has_active_animations());
2438
2439 let r = m.tick(at(500));
2441 assert!(!r.needs_repaint);
2442 assert!(r.updated_nodes.is_empty());
2443 }
2444
2445 #[test]
2446 fn tick_before_the_animation_start_time_saturates_to_zero_progress() {
2447 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2450 m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(0));
2451 m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(100));
2452 m.tick(at(0)); assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
2454 assert!(m.has_active_animations(), "no progress => still animating");
2455 }
2456
2457 #[test]
2458 fn tick_with_a_zero_duration_animation_completes_instead_of_producing_nan() {
2459 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2464 m.states.get_mut(&(DOM, node(0))).unwrap().animation = Some(ScrollAnimation {
2465 start_time: at(0),
2466 duration: tick_dur(0),
2467 start_offset: pos(0.0, 0.0),
2468 target_offset: pos(0.0, 300.0),
2469 easing: EasingFunction::Linear,
2470 });
2471 let r = m.tick(at(0));
2472 assert!(r.needs_repaint);
2473 let off = m.get_current_offset(DOM, node(0)).unwrap();
2474 assert!(!off.y.is_nan(), "0/0 must not leak NaN into the offset");
2475 assert_eq!(off, pos(0.0, 300.0));
2476 assert!(!m.has_active_animations());
2477 }
2478
2479 #[test]
2480 fn tick_with_a_mismatched_clock_kind_stalls_at_zero_instead_of_panicking() {
2481 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2485 m.set_scroll_position(DOM, node(0), pos(0.0, 25.0), at(0));
2486 m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
2487 let r = m.tick(Instant::now()); assert!(r.needs_repaint);
2489 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 25.0)));
2490 assert!(
2491 m.has_active_animations(),
2492 "mismatched clocks stall the animation (t stays 0) — it never completes"
2493 );
2494 }
2495
2496 #[test]
2497 fn tick_advances_every_animating_node_in_one_pass() {
2498 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2499 m.register_or_update_scroll_node(
2500 DOM,
2501 node(1),
2502 rect(0.0, 0.0, 100.0, 100.0),
2503 size(100.0, 300.0),
2504 at(0),
2505 16.0,
2506 16.0,
2507 false,
2508 true,
2509 );
2510 m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
2511 m.scroll_to(DOM, node(1), pos(0.0, 200.0), tick_dur(10), EasingFunction::Linear, at(0));
2512 let r = m.tick(at(10));
2513 assert_eq!(r.updated_nodes.len(), 2);
2514 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2515 assert_eq!(m.get_current_offset(DOM, node(1)), Some(pos(0.0, 200.0)));
2516 }
2517
2518 #[test]
2522 fn register_twice_updates_in_place_and_keeps_the_offset() {
2523 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2524 m.set_scroll_position(DOM, node(0), pos(0.0, 300.0), at(1));
2525 m.register_or_update_scroll_node(
2526 DOM,
2527 node(0),
2528 rect(0.0, 0.0, 100.0, 100.0),
2529 size(100.0, 500.0),
2530 at(2),
2531 16.0,
2532 16.0,
2533 false,
2534 true,
2535 );
2536 assert_eq!(m.debug_counts(), (1, 0), "re-register must not grow the map");
2537 assert_eq!(
2538 m.get_current_offset(DOM, node(0)),
2539 Some(pos(0.0, 300.0)),
2540 "an existing node keeps its scroll offset across relayout"
2541 );
2542 }
2543
2544 #[test]
2545 fn re_registering_with_shrunken_content_re_clamps_the_offset() {
2546 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2548 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2549 m.register_or_update_scroll_node(
2550 DOM,
2551 node(0),
2552 rect(0.0, 0.0, 100.0, 100.0),
2553 size(100.0, 150.0), at(2),
2555 16.0,
2556 16.0,
2557 false,
2558 true,
2559 );
2560 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
2561 }
2562
2563 #[test]
2564 fn register_with_non_finite_geometry_does_not_panic_or_leak_nan() {
2565 let mut m = ScrollManager::new();
2566 m.register_or_update_scroll_node(
2567 DOM,
2568 node(0),
2569 rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
2570 size(f32::NAN, f32::NAN),
2571 at(0),
2572 f32::NAN,
2573 f32::NAN,
2574 true,
2575 true,
2576 );
2577 let off = m.get_current_offset(DOM, node(0)).unwrap();
2578 assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN geometry must clamp to 0");
2579 assert_eq!(off, LogicalPosition::zero());
2580 assert!(!m.is_node_scrollable(DOM, node(0)), "NaN overflow check is false");
2581
2582 m.register_or_update_scroll_node(
2583 DOM,
2584 node(1),
2585 rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
2586 size(f32::INFINITY, f32::INFINITY),
2587 at(0),
2588 f32::MAX,
2589 f32::MAX,
2590 true,
2591 true,
2592 );
2593 let off = m.get_current_offset(DOM, node(1)).unwrap();
2594 assert!(!off.x.is_nan() && !off.y.is_nan());
2595 assert_eq!(m.debug_counts(), (2, 0));
2596 }
2597
2598 #[test]
2599 fn register_with_zero_sized_geometry_yields_a_non_scrollable_pinned_node() {
2600 let mut m = ScrollManager::new();
2601 m.register_or_update_scroll_node(
2602 DOM,
2603 node(0),
2604 LogicalRect::zero(),
2605 LogicalSize::zero(),
2606 at(0),
2607 0.0,
2608 0.0,
2609 false,
2610 false,
2611 );
2612 assert!(!m.is_node_scrollable(DOM, node(0)));
2613 assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
2614 let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
2615 assert_eq!(info.max_scroll_x, 0.0);
2616 assert_eq!(info.max_scroll_y, 0.0);
2617 }
2618
2619 #[test]
2623 fn is_node_scrollable_is_strict_overflow_not_equality() {
2624 let mut m = ScrollManager::new();
2625 m.register_or_update_scroll_node(
2627 DOM,
2628 node(0),
2629 rect(0.0, 0.0, 100.0, 100.0),
2630 size(100.0, 100.0),
2631 at(0),
2632 16.0,
2633 16.0,
2634 false,
2635 false,
2636 );
2637 assert!(!m.is_node_scrollable(DOM, node(0)));
2638 m.register_or_update_scroll_node(
2640 DOM,
2641 node(1),
2642 rect(0.0, 0.0, 100.0, 100.0),
2643 size(100.0, 100.1),
2644 at(0),
2645 16.0,
2646 16.0,
2647 false,
2648 true,
2649 );
2650 assert!(m.is_node_scrollable(DOM, node(1)));
2651 assert!(!m.is_node_scrollable(DOM, node(999)));
2653 assert!(!m.is_node_scrollable(DOM1, node(1)));
2654 }
2655
2656 #[test]
2657 fn is_node_scrollable_uses_the_virtual_size_when_present() {
2658 let mut m = ScrollManager::new();
2659 m.register_or_update_scroll_node(
2661 DOM,
2662 node(0),
2663 rect(0.0, 0.0, 100.0, 100.0),
2664 size(100.0, 50.0),
2665 at(0),
2666 16.0,
2667 16.0,
2668 false,
2669 true,
2670 );
2671 assert!(!m.is_node_scrollable(DOM, node(0)));
2672 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 100_000.0), None);
2673 assert!(
2674 m.is_node_scrollable(DOM, node(0)),
2675 "a VirtualView with a large virtual size must be scrollable"
2676 );
2677 }
2678
2679 #[test]
2683 fn can_consume_delta_respects_the_half_pixel_deadzone() {
2684 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));
2686 assert!(!m.can_consume_delta(DOM, node(0), 0.0, 0.0));
2688 assert!(!m.can_consume_delta(DOM, node(0), 0.5, 0.5), "exactly EPS is a no-move");
2689 assert!(!m.can_consume_delta(DOM, node(0), -0.5, -0.5));
2690 assert!(m.can_consume_delta(DOM, node(0), 0.0, 0.51));
2691 assert!(m.can_consume_delta(DOM, node(0), 0.0, -0.51));
2692 assert!(!m.can_consume_delta(DOM, node(0), 100.0, 0.0));
2694 }
2695
2696 #[test]
2697 fn can_consume_delta_is_false_at_the_pinned_edges() {
2698 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2699 m.set_scroll_position(DOM, node(0), pos(0.0, 0.0), at(1));
2701 assert!(!m.can_consume_delta(DOM, node(0), 0.0, -10.0));
2702 assert!(m.can_consume_delta(DOM, node(0), 0.0, 10.0));
2703 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(2));
2705 assert!(!m.can_consume_delta(DOM, node(0), 0.0, 10.0));
2706 assert!(m.can_consume_delta(DOM, node(0), 0.0, -10.0));
2707 }
2708
2709 #[test]
2710 fn can_consume_delta_rejects_nan_and_accepts_infinite_deltas() {
2711 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2712 m.set_scroll_position(DOM, node(0), pos(0.0, 200.0), at(1));
2713 assert!(
2714 !m.can_consume_delta(DOM, node(0), f32::NAN, f32::NAN),
2715 "a NaN delta consumes nothing (every comparison is false)"
2716 );
2717 assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::INFINITY));
2718 assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::NEG_INFINITY));
2719 assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::MAX));
2720 assert!(!m.can_consume_delta(DOM, node(999), 0.0, f32::MAX), "unknown node");
2721 }
2722
2723 #[test]
2727 fn select_scroll_target_on_no_candidates_is_none() {
2728 let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2729 assert!(m
2730 .select_scroll_target(core::iter::empty(), 0.0, 10.0)
2731 .is_none());
2732 assert!(m
2734 .select_scroll_target([(DOM, node(50)), (DOM1, node(0))].into_iter(), 0.0, 10.0)
2735 .is_none());
2736 }
2737
2738 #[test]
2739 fn select_scroll_target_with_zero_or_nan_delta_falls_back_to_the_innermost() {
2740 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2743 m.register_or_update_scroll_node(
2744 DOM,
2745 node(9),
2746 rect(0.0, 0.0, 50.0, 50.0),
2747 size(50.0, 200.0),
2748 at(0),
2749 16.0,
2750 16.0,
2751 false,
2752 true,
2753 );
2754 let inner_first = [(DOM, node(9)), (DOM, node(0))];
2755 assert_eq!(
2756 m.select_scroll_target(inner_first.into_iter(), 0.0, 0.0),
2757 Some((DOM, node(9)))
2758 );
2759 assert_eq!(
2760 m.select_scroll_target(inner_first.into_iter(), f32::NAN, f32::NAN),
2761 Some((DOM, node(9)))
2762 );
2763 assert_eq!(
2765 m.select_scroll_target(inner_first.into_iter(), 0.0, f32::INFINITY),
2766 Some((DOM, node(9)))
2767 );
2768 }
2769
2770 #[test]
2774 fn a11y_scroll_info_reports_travel_only_for_scrollable_nodes() {
2775 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2776 m.set_scroll_position(DOM, node(0), pos(0.0, 120.0), at(1));
2777 let (off, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
2778 assert_eq!(off, pos(0.0, 120.0));
2779 assert_eq!(max_x, 0.0);
2780 assert_eq!(max_y, 400.0);
2781
2782 m.register_or_update_scroll_node(
2784 DOM,
2785 node(1),
2786 rect(0.0, 0.0, 100.0, 100.0),
2787 size(10.0, 10.0),
2788 at(0),
2789 16.0,
2790 16.0,
2791 false,
2792 false,
2793 );
2794 assert!(m.a11y_scroll_info(DOM, node(1)).is_none());
2795 assert!(m.a11y_scroll_info(DOM, node(404)).is_none());
2796 assert!(m.a11y_scroll_info(DOM1, node(0)).is_none());
2797 }
2798
2799 #[test]
2800 fn a11y_scroll_info_uses_the_virtual_size() {
2801 let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
2802 assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
2803 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 1000.0), None);
2804 let (_, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
2805 assert_eq!(max_x, 0.0);
2806 assert_eq!(max_y, 900.0);
2807 }
2808
2809 #[test]
2813 fn get_scroll_node_info_max_scroll_is_never_negative() {
2814 let m = mgr(size(500.0, 500.0), size(10.0, 10.0));
2815 let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
2816 assert_eq!(info.max_scroll_x, 0.0, "underflow must clamp to 0, not go negative");
2817 assert_eq!(info.max_scroll_y, 0.0);
2818 assert_eq!(info.current_offset, LogicalPosition::zero());
2819 assert!(m.get_scroll_node_info(DOM, node(1)).is_none());
2820 }
2821
2822 #[test]
2823 fn get_scroll_node_info_prefers_the_virtual_size_for_max_travel() {
2824 let mut m = mgr(size(100.0, 100.0), size(100.0, 200.0));
2825 assert_eq!(m.get_scroll_node_info(DOM, node(0)).unwrap().max_scroll_y, 100.0);
2826 m.update_virtual_scroll_bounds(DOM, node(0), size(600.0, 5000.0), Some(pos(1.0, 2.0)));
2827 let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
2828 assert_eq!(info.max_scroll_x, 500.0);
2829 assert_eq!(info.max_scroll_y, 4900.0);
2830 assert_eq!(info.content_rect.size, size(100.0, 200.0));
2833 }
2834
2835 #[test]
2839 fn update_virtual_scroll_bounds_creates_a_state_for_an_unknown_node() {
2840 let mut m = ScrollManager::new();
2841 m.update_virtual_scroll_bounds(DOM, node(3), size(100.0, 9000.0), Some(pos(0.0, 4.0)));
2842 assert_eq!(m.debug_counts(), (1, 0));
2843 let s = m.get_scroll_state(DOM, node(3)).unwrap();
2844 assert_eq!(s.virtual_scroll_size, Some(size(100.0, 9000.0)));
2845 assert_eq!(s.virtual_scroll_offset, Some(pos(0.0, 4.0)));
2846 assert_eq!(s.current_offset, LogicalPosition::zero());
2847 assert!(m.is_node_scrollable(DOM, node(3)));
2849 }
2850
2851 #[test]
2852 fn update_virtual_scroll_bounds_re_clamps_a_shrinking_virtual_size() {
2853 let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
2854 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 5000.0), None);
2855 m.set_scroll_position(DOM, node(0), pos(0.0, 4900.0), at(1));
2856 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 4900.0)));
2857 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 300.0), None);
2859 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
2860 }
2861
2862 #[test]
2863 fn update_virtual_scroll_bounds_with_non_finite_size_does_not_panic() {
2864 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2865 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2866 m.update_virtual_scroll_bounds(DOM, node(0), size(f32::NAN, f32::NAN), None);
2867 let off = m.get_current_offset(DOM, node(0)).unwrap();
2868 assert!(!off.x.is_nan() && !off.y.is_nan());
2869 assert_eq!(off, LogicalPosition::zero(), "NaN virtual size => zero travel");
2870 assert!(!m.is_node_scrollable(DOM, node(0)));
2871
2872 m.update_virtual_scroll_bounds(DOM, node(0), size(0.0, f32::INFINITY), None);
2873 let off = m.get_current_offset(DOM, node(0)).unwrap();
2874 assert!(!off.y.is_nan(), "infinite virtual height must not produce NaN");
2875 }
2876
2877 #[test]
2881 fn update_node_bounds_creates_the_state_and_re_clamps_a_shrinking_content() {
2882 let mut m = ScrollManager::new();
2883 m.update_node_bounds(
2885 DOM,
2886 node(0),
2887 rect(0.0, 0.0, 100.0, 100.0),
2888 rect(0.0, 0.0, 100.0, 500.0),
2889 at(0),
2890 );
2891 assert_eq!(m.debug_counts(), (1, 0));
2892 assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
2893
2894 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2895 m.clear_scroll_dirty();
2896
2897 m.update_node_bounds(
2899 DOM,
2900 node(0),
2901 rect(0.0, 0.0, 100.0, 100.0),
2902 rect(0.0, 0.0, 100.0, 150.0),
2903 at(2),
2904 );
2905 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
2906 assert!(!m.has_pending_scroll_changes());
2909 }
2910
2911 #[test]
2912 fn update_node_bounds_ignores_the_content_rect_origin() {
2913 let mut m = ScrollManager::new();
2916 m.update_node_bounds(
2917 DOM,
2918 node(0),
2919 rect(0.0, 0.0, 100.0, 100.0),
2920 rect(999.0, 999.0, 100.0, 500.0),
2921 at(0),
2922 );
2923 m.set_scroll_position(DOM, node(0), pos(1e9, 1e9), at(1));
2924 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
2925 }
2926
2927 #[test]
2928 fn update_node_bounds_with_non_finite_rects_does_not_leak_nan() {
2929 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2930 m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
2931 m.update_node_bounds(
2932 DOM,
2933 node(0),
2934 rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
2935 rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
2936 at(2),
2937 );
2938 let off = m.get_current_offset(DOM, node(0)).unwrap();
2939 assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN bounds must clamp to 0");
2940 assert_eq!(off, LogicalPosition::zero());
2941
2942 m.update_node_bounds(
2944 DOM,
2945 node(0),
2946 rect(0.0, 0.0, 100.0, 100.0),
2947 rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
2948 at(3),
2949 );
2950 let off = m.get_current_offset(DOM, node(0)).unwrap();
2951 assert!(off.x.is_finite() && off.y.is_finite());
2952 }
2953
2954 #[test]
2959 fn get_scroll_states_for_dom_filters_by_dom_and_reports_the_live_offset() {
2960 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
2961 m.set_scroll_position(DOM, node(0), pos(0.0, 42.0), at(1));
2962 m.register_or_update_scroll_node(
2963 DOM1,
2964 node(0),
2965 rect(0.0, 0.0, 10.0, 10.0),
2966 size(10.0, 100.0),
2967 at(0),
2968 16.0,
2969 16.0,
2970 false,
2971 true,
2972 );
2973
2974 let states = m.get_scroll_states_for_dom(DOM);
2975 assert_eq!(states.len(), 1, "other DOMs must not leak in");
2976 let sp = states.get(&node(0)).unwrap();
2977 assert_eq!(sp.parent_rect, rect(0.0, 0.0, 100.0, 100.0));
2978 assert_eq!(sp.children_rect.origin, pos(0.0, 42.0));
2979 assert_eq!(sp.children_rect.size, size(100.0, 500.0));
2980
2981 assert!(m.get_scroll_states_for_dom(DomId { inner: 99 }).is_empty());
2983 }
2984
2985 #[test]
2986 fn get_scroll_states_for_dom_uses_the_virtual_size_as_children_rect() {
2987 let mut m = mgr(size(100.0, 100.0), size(100.0, 120.0));
2988 m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 8000.0), None);
2989 let states = m.get_scroll_states_for_dom(DOM);
2990 assert_eq!(states.get(&node(0)).unwrap().children_rect.size, size(100.0, 8000.0));
2991 }
2992
2993 #[test]
2994 fn build_scroll_offset_map_only_emits_nodes_present_in_scroll_ids() {
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, 25.0), at(1));
2997 m.register_or_update_scroll_node(
2998 DOM,
2999 node(4),
3000 rect(0.0, 0.0, 100.0, 100.0),
3001 size(100.0, 500.0),
3002 at(0),
3003 16.0,
3004 16.0,
3005 false,
3006 true,
3007 );
3008 m.register_or_update_scroll_node(
3009 DOM1,
3010 node(0),
3011 rect(0.0, 0.0, 100.0, 100.0),
3012 size(100.0, 500.0),
3013 at(0),
3014 16.0,
3015 16.0,
3016 false,
3017 true,
3018 );
3019
3020 assert!(m.build_scroll_offset_map(DOM, &HashMap::new()).is_empty());
3022
3023 let mut ids: HashMap<usize, u64> = HashMap::new();
3024 ids.insert(0, 100); ids.insert(7, 700); let map = m.build_scroll_offset_map(DOM, &ids);
3027 assert_eq!(map.len(), 1, "node 4 has no scroll_id; DOM1 is a different dom");
3028 assert_eq!(map.get(&100), Some(&(0.0, 25.0)));
3029 assert!(!map.contains_key(&700));
3030 }
3031
3032 #[test]
3036 fn find_scroll_parent_walks_up_to_the_nearest_registered_ancestor() {
3037 let hierarchy = [
3039 NodeHierarchyItem { parent: 0, previous_sibling: 0, next_sibling: 0, last_child: 2 },
3040 NodeHierarchyItem { parent: 1, previous_sibling: 0, next_sibling: 0, last_child: 3 },
3041 NodeHierarchyItem { parent: 2, previous_sibling: 0, next_sibling: 0, last_child: 0 },
3042 ];
3043 let m = mgr(size(100.0, 100.0), size(100.0, 500.0)); assert_eq!(
3045 m.find_scroll_parent(DOM, node(2), &hierarchy),
3046 Some(node(0)),
3047 "must skip the unregistered node 1 and find the root scroll container"
3048 );
3049 assert_eq!(m.find_scroll_parent(DOM, node(0), &hierarchy), None);
3051 assert_eq!(m.find_scroll_parent(DOM1, node(2), &hierarchy), None);
3053 }
3054
3055 #[test]
3056 fn find_scroll_parent_handles_empty_and_out_of_range_hierarchies() {
3057 let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3058 assert_eq!(m.find_scroll_parent(DOM, node(0), &[]), None);
3060 assert_eq!(m.find_scroll_parent(DOM, node(9999), &[]), None);
3061 let hierarchy = [NodeHierarchyItem::zeroed()];
3063 assert_eq!(m.find_scroll_parent(DOM, node(9999), &hierarchy), None);
3064 }
3065
3066 #[test]
3070 fn calculate_scrollbar_states_is_idempotent_and_only_for_overflowing_axes() {
3071 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3072 m.calculate_scrollbar_states();
3073 assert_eq!(m.debug_counts(), (1, 1), "only the vertical axis overflows");
3074 assert!(m
3075 .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
3076 .is_some());
3077 assert!(m
3078 .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Horizontal)
3079 .is_none());
3080
3081 for _ in 0..10 {
3083 m.calculate_scrollbar_states();
3084 }
3085 assert_eq!(m.debug_counts(), (1, 1), "per-frame recompute must not accumulate");
3086 assert_eq!(m.iter_scrollbar_states().count(), 1);
3087 }
3088
3089 #[test]
3090 fn calculate_scrollbar_states_drops_bars_once_the_content_fits() {
3091 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3092 m.calculate_scrollbar_states();
3093 assert_eq!(m.debug_counts().1, 1);
3094 m.register_or_update_scroll_node(
3096 DOM,
3097 node(0),
3098 rect(0.0, 0.0, 100.0, 100.0),
3099 size(100.0, 50.0),
3100 at(1),
3101 16.0,
3102 16.0,
3103 false,
3104 false,
3105 );
3106 m.calculate_scrollbar_states();
3107 assert_eq!(m.debug_counts().1, 0);
3108 assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
3109 }
3110
3111 #[test]
3112 fn calculate_scrollbar_states_produces_finite_geometry_for_both_axes() {
3113 let mut m = ScrollManager::new();
3114 m.register_or_update_scroll_node(
3115 DOM,
3116 node(0),
3117 rect(0.0, 0.0, 100.0, 100.0),
3118 size(1000.0, 1000.0),
3119 at(0),
3120 16.0,
3121 16.0,
3122 true,
3123 true,
3124 );
3125 m.calculate_scrollbar_states();
3126 assert_eq!(m.debug_counts(), (1, 2), "both axes overflow");
3127 for (_, sb) in m.iter_scrollbar_states() {
3128 assert!(sb.visible);
3129 assert!(sb.base_size.is_finite() && sb.base_size > 0.0);
3130 assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite());
3131 assert!(sb.thumb_length.is_finite());
3132 assert!(sb.thumb_offset.is_finite());
3133 assert!(sb.usable_track_length.is_finite());
3134 assert!(sb.track_rect.size.width.is_finite());
3135 assert!(sb.track_rect.size.height.is_finite());
3136 }
3137 }
3138
3139 #[test]
3140 fn calculate_scrollbar_states_zero_thickness_falls_back_to_the_default_width() {
3141 let mut m = ScrollManager::new();
3144 m.register_or_update_scroll_node(
3145 DOM,
3146 node(0),
3147 rect(0.0, 0.0, 100.0, 100.0),
3148 size(100.0, 400.0),
3149 at(0),
3150 0.0, 0.0, false,
3153 true,
3154 );
3155 m.calculate_scrollbar_states();
3156 let sb = m
3157 .get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
3158 .unwrap();
3159 assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
3160 assert_eq!(sb.button_size, 0.0, "overlay scrollbars have no arrow buttons");
3161 assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite(), "no div-by-zero");
3162 }
3163
3164 #[test]
3165 fn calculate_scrollbar_state_from_geometry_survives_nan_input() {
3166 let mut s = state(size(f32::NAN, f32::NAN), size(f32::NAN, f32::NAN));
3167 s.scrollbar_thickness = f32::NAN;
3168 s.visual_width_px = f32::NAN;
3169 let sb = ScrollManager::calculate_scrollbar_state_from_geometry(
3172 &s,
3173 ScrollbarOrientation::Vertical,
3174 );
3175 assert!(sb.visible);
3176 assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
3177 assert_eq!(sb.usable_track_length, 0.0);
3180 assert_eq!(sb.thumb_length, 0.0);
3181 assert_eq!(sb.thumb_offset, 0.0);
3182 assert_eq!(sb.thumb_position_ratio, 0.0);
3183 assert!(
3186 sb.scale.y.is_nan(),
3187 "NaN container height still leaks into ScrollbarState::scale"
3188 );
3189 assert_eq!(
3191 sb.hit_test_component(pos(0.0, 5.0)),
3192 ScrollbarComponent::TopButton
3193 );
3194 }
3195
3196 #[test]
3200 fn hit_test_scrollbars_finds_the_vertical_bar_and_reports_local_coords() {
3201 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3202 m.calculate_scrollbar_states();
3203 let hit = m.hit_test_scrollbars(pos(90.0, 50.0)).expect("inside the track");
3205 assert_eq!(hit.dom_id, DOM);
3206 assert_eq!(hit.node_id, node(0));
3207 assert_eq!(hit.orientation, ScrollbarOrientation::Vertical);
3208 assert_eq!(hit.global_position, pos(90.0, 50.0));
3209 assert_eq!(hit.local_position, pos(6.0, 50.0), "local = global - track origin");
3210
3211 assert!(m.hit_test_scrollbars(pos(10.0, 50.0)).is_none());
3213 let hit2 = m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).unwrap();
3215 assert_eq!(hit2.local_position, hit.local_position);
3216 assert_eq!(hit2.component, hit.component);
3217 assert!(m.hit_test_scrollbar(DOM, node(1), pos(90.0, 50.0)).is_none());
3218 }
3219
3220 #[test]
3221 fn hit_test_scrollbars_rejects_non_finite_and_out_of_range_positions() {
3222 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3223 m.calculate_scrollbar_states();
3224 for p in [
3225 pos(f32::NAN, f32::NAN),
3226 pos(f32::INFINITY, f32::INFINITY),
3227 pos(f32::NEG_INFINITY, f32::NEG_INFINITY),
3228 pos(f32::MAX, f32::MAX),
3229 pos(f32::MIN, f32::MIN),
3230 pos(-1.0, -1.0),
3231 pos(0.0, 0.0),
3232 ] {
3233 assert!(
3234 m.hit_test_scrollbars(p).is_none(),
3235 "position {p:?} must not hit the 84..100 x 0..100 track"
3236 );
3237 assert!(m.hit_test_scrollbar(DOM, node(0), p).is_none());
3238 }
3239 }
3240
3241 #[test]
3242 fn hit_test_scrollbars_before_calculate_returns_none() {
3243 let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3246 assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
3247 assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
3248 }
3249
3250 #[test]
3251 fn hit_test_scrollbars_skips_invisible_bars() {
3252 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3253 m.calculate_scrollbar_states();
3254 m.scrollbar_states
3255 .get_mut(&(DOM, node(0), ScrollbarOrientation::Vertical))
3256 .unwrap()
3257 .visible = false;
3258 assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
3259 assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
3260 }
3261
3262 #[test]
3266 fn record_scroll_input_reports_start_timer_only_on_the_first_pending_event() {
3267 let mut m = ScrollManager::new();
3268 assert!(m.record_scroll_input(input(0.0, 1.0, 1)), "queue was empty => start");
3269 assert!(!m.record_scroll_input(input(0.0, 1.0, 2)), "timer already running");
3270 let _ = m.get_input_queue().take_all();
3271 assert!(m.record_scroll_input(input(0.0, 1.0, 3)), "drained => start again");
3272 }
3273
3274 #[test]
3275 fn record_scroll_input_applies_the_sign_to_extreme_deltas_without_overflow() {
3276 let mut m = ScrollManager::new();
3277 m.record_scroll_input(input(f32::MAX, f32::INFINITY, 1));
3278 m.record_scroll_input(input(f32::NAN, f32::MIN, 2));
3279 let q = m.get_input_queue().take_all();
3280 assert_eq!(q[0].delta.x, -f32::MAX, "sign flip must not overflow");
3281 assert_eq!(q[0].delta.y, f32::NEG_INFINITY);
3282 assert!(q[1].delta.x.is_nan(), "NaN * -1 stays NaN, no panic");
3283 assert_eq!(q[1].delta.y, f32::MAX);
3284 }
3285
3286 #[test]
3287 fn record_scroll_from_hit_test_records_the_wheel_delta_even_with_no_hover() {
3288 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3291 let hover = HoverManager::new(); let out = m.record_scroll_from_hit_test(
3293 3.0,
3294 -7.0,
3295 ScrollInputSource::WheelDiscrete,
3296 &hover,
3297 &InputPointId::Mouse,
3298 at(1),
3299 );
3300 assert!(out.is_none(), "no hover => no scroll target");
3301 assert_eq!(m.pending_wheel_event, Some(pos(3.0, -7.0)), "raw delta is recorded");
3302 assert!(!m.get_input_queue().has_pending(), "nothing queued for physics");
3303 }
3304
3305 #[test]
3306 fn record_scroll_from_hit_test_queues_the_raw_delta_and_signals_the_timer() {
3307 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3308 let hover = hover_over(&[0]);
3309 let (dom_id, node_id, start_timer) = m
3310 .record_scroll_from_hit_test(
3311 0.0,
3312 -10.0, ScrollInputSource::WheelDiscrete,
3314 &hover,
3315 &InputPointId::Mouse,
3316 at(1),
3317 )
3318 .expect("node 0 is scrollable and under the cursor");
3319 assert_eq!((dom_id, node_id), (DOM, node(0)));
3320 assert!(start_timer, "first queued input must start the physics timer");
3321 assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
3322
3323 let (_, _, start_again) = m
3325 .record_scroll_from_hit_test(
3326 0.0,
3327 -10.0,
3328 ScrollInputSource::WheelDiscrete,
3329 &hover,
3330 &InputPointId::Mouse,
3331 at(2),
3332 )
3333 .unwrap();
3334 assert!(!start_again);
3335
3336 let q = m.get_input_queue().take_all();
3337 assert_eq!(q.len(), 2);
3338 assert_eq!(q[0].delta.y, 10.0, "raw -10 * traditional sign (-1) = +10");
3340 assert_eq!(q[0].source, ScrollInputSource::WheelDiscrete);
3341 assert_eq!(q[0].timestamp, at(1));
3342 }
3343
3344 #[test]
3345 fn record_scroll_from_hit_test_ignores_hovered_nodes_that_cannot_scroll() {
3346 let mut m = ScrollManager::new();
3347 m.register_or_update_scroll_node(
3349 DOM,
3350 node(0),
3351 rect(0.0, 0.0, 100.0, 100.0),
3352 size(100.0, 100.0),
3353 at(0),
3354 16.0,
3355 16.0,
3356 false,
3357 false,
3358 );
3359 let hover = hover_over(&[0]);
3360 let out = m.record_scroll_from_hit_test(
3361 0.0,
3362 -10.0,
3363 ScrollInputSource::WheelDiscrete,
3364 &hover,
3365 &InputPointId::Mouse,
3366 at(1),
3367 );
3368 assert!(out.is_none(), "a non-overflowing node must not swallow the wheel");
3369 assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
3370 assert!(!m.get_input_queue().has_pending());
3371 }
3372
3373 #[test]
3374 fn record_scroll_from_hit_test_with_non_finite_deltas_does_not_panic() {
3375 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3376 let hover = hover_over(&[0]);
3377 let out = m.record_scroll_from_hit_test(
3379 f32::NAN,
3380 f32::NAN,
3381 ScrollInputSource::TrackpadContinuous,
3382 &hover,
3383 &InputPointId::Mouse,
3384 at(1),
3385 );
3386 assert_eq!(out.map(|(d, n, _)| (d, n)), Some((DOM, node(0))));
3387 assert!(m.pending_wheel_event.unwrap().x.is_nan());
3388 let q = m.get_input_queue().take_all();
3389 assert_eq!(q.len(), 1);
3390 assert!(q[0].delta.x.is_nan(), "NaN is queued verbatim, no panic");
3391
3392 let out = m.record_scroll_from_hit_test(
3394 0.0,
3395 f32::NEG_INFINITY,
3396 ScrollInputSource::WheelDiscrete,
3397 &hover,
3398 &InputPointId::Mouse,
3399 at(2),
3400 );
3401 assert!(out.is_some());
3402 let q = m.get_input_queue().take_all();
3403 assert_eq!(q[0].delta.y, f32::INFINITY, "-inf * -1 = +inf");
3404 }
3405
3406 #[test]
3407 fn record_scroll_from_hit_test_picks_the_innermost_scrollable_under_the_cursor() {
3408 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3411 m.register_or_update_scroll_node(
3412 DOM,
3413 node(5),
3414 rect(0.0, 0.0, 50.0, 50.0),
3415 size(50.0, 200.0),
3416 at(0),
3417 16.0,
3418 16.0,
3419 false,
3420 true,
3421 );
3422 let hover = hover_over(&[0, 5]);
3423 let (_, node_id, _) = m
3424 .record_scroll_from_hit_test(
3425 0.0,
3426 -10.0,
3427 ScrollInputSource::WheelDiscrete,
3428 &hover,
3429 &InputPointId::Mouse,
3430 at(1),
3431 )
3432 .unwrap();
3433 assert_eq!(node_id, node(5), "innermost (deepest) scrollable wins");
3434 }
3435
3436 #[test]
3440 fn getters_agree_with_the_recorded_state() {
3441 let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
3442 m.set_scroll_position(DOM, node(0), pos(0.0, 33.0), at(7));
3443 assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 33.0)));
3444 assert_eq!(m.get_last_activity_time(DOM, node(0)), Some(at(7)));
3445 let s = m.get_scroll_state(DOM, node(0)).unwrap();
3446 assert_eq!(s.current_offset, pos(0.0, 33.0));
3447 assert!(s.animation.is_none());
3448 assert!(m.get_current_offset(DOM1, node(0)).is_none());
3450 assert!(m.get_last_activity_time(DOM, node(1)).is_none());
3451 assert!(m.get_scroll_state(DOM1, node(1)).is_none());
3452 }
3453
3454 #[test]
3455 fn get_input_queue_hands_out_a_shared_handle() {
3456 let mut m = ScrollManager::new();
3457 let q = m.get_input_queue();
3458 assert!(!q.has_pending());
3459 m.record_scroll_input(input(0.0, 1.0, 1));
3460 assert!(q.has_pending(), "the handle must observe pushes made by the manager");
3461 assert_eq!(q.take_all().len(), 1);
3462 assert!(
3463 !m.get_input_queue().has_pending(),
3464 "draining the handle drains the manager's queue"
3465 );
3466 }
3467}