use alloc::collections::BTreeMap;
#[cfg(feature = "std")]
use alloc::vec::Vec;
use azul_core::{
dom::{DomId, NodeId, ScrollbarOrientation},
events::EasingFunction,
geom::{LogicalPosition, LogicalRect, LogicalSize},
hit_test::ScrollPosition,
styled_dom::NodeHierarchyItemId,
task::{Duration, Instant},
};
#[cfg(feature = "std")]
use std::sync::{Arc, Mutex};
use crate::managers::hover::InputPointId;
use crate::solver3::scrollbar::compute_scrollbar_geometry_with_button_size;
const SCROLL_CHANGE_EPSILON: f32 = 0.01;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollInputSource {
TrackpadContinuous,
TrackpadEnd,
WheelDiscrete,
Programmatic,
}
#[derive(Debug, Clone)]
pub struct ScrollInput {
pub dom_id: DomId,
pub node_id: NodeId,
pub delta: LogicalPosition,
pub timestamp: Instant,
pub source: ScrollInputSource,
}
#[cfg(feature = "std")]
#[derive(Debug, Clone, Default)]
pub struct ScrollInputQueue {
inner: Arc<Mutex<Vec<ScrollInput>>>,
}
#[cfg(feature = "std")]
impl ScrollInputQueue {
#[must_use] pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn push(&self, input: ScrollInput) {
if let Ok(mut queue) = self.inner.lock() {
queue.push(input);
}
}
#[must_use] pub fn take_all(&self) -> Vec<ScrollInput> {
self.inner.lock().map_or_else(
|_| Vec::new(),
|mut queue| core::mem::take(&mut *queue),
)
}
#[must_use] pub fn take_recent(&self, max_events: usize) -> Vec<ScrollInput> {
self.inner.lock().map_or_else(
|_| Vec::new(),
|mut queue| {
let mut events = core::mem::take(&mut *queue);
if events.len() > max_events {
events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
events.drain(..events.len() - max_events);
}
events
},
)
}
#[must_use] pub fn has_pending(&self) -> bool {
self.inner
.lock()
.map(|q| !q.is_empty())
.unwrap_or(false)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScrollbarComponent {
Track,
Thumb,
TopButton,
BottomButton,
}
#[derive(Copy, Debug, Clone)]
pub struct ScrollbarState {
pub visible: bool,
pub orientation: ScrollbarOrientation,
pub base_size: f32,
pub scale: LogicalPosition, pub thumb_position_ratio: f32,
pub thumb_size_ratio: f32,
pub track_rect: LogicalRect,
pub button_size: f32,
pub usable_track_length: f32,
pub thumb_length: f32,
pub thumb_offset: f32,
}
impl ScrollbarState {
#[must_use] pub fn hit_test_component(&self, local_pos: LogicalPosition) -> ScrollbarComponent {
match self.orientation {
ScrollbarOrientation::Vertical => {
if local_pos.y < self.button_size {
return ScrollbarComponent::TopButton;
}
let track_height = self.track_rect.size.height;
if local_pos.y > track_height - self.button_size {
return ScrollbarComponent::BottomButton;
}
let thumb_y_start = self.button_size + self.thumb_offset;
let thumb_y_end = thumb_y_start + self.thumb_length;
if local_pos.y >= thumb_y_start && local_pos.y <= thumb_y_end {
ScrollbarComponent::Thumb
} else {
ScrollbarComponent::Track
}
}
ScrollbarOrientation::Horizontal => {
if local_pos.x < self.button_size {
return ScrollbarComponent::TopButton;
}
let track_width = self.track_rect.size.width;
if local_pos.x > track_width - self.button_size {
return ScrollbarComponent::BottomButton;
}
let thumb_x_start = self.button_size + self.thumb_offset;
let thumb_x_end = thumb_x_start + self.thumb_length;
if local_pos.x >= thumb_x_start && local_pos.x <= thumb_x_end {
ScrollbarComponent::Thumb
} else {
ScrollbarComponent::Track
}
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ScrollbarHit {
pub dom_id: DomId,
pub node_id: NodeId,
pub orientation: ScrollbarOrientation,
pub component: ScrollbarComponent,
pub local_position: LogicalPosition,
pub global_position: LogicalPosition,
}
#[derive(Debug, Clone, Default)]
pub struct ScrollManager {
states: BTreeMap<(DomId, NodeId), AnimatedScrollState>,
scrollbar_states: BTreeMap<(DomId, NodeId, ScrollbarOrientation), ScrollbarState>,
#[cfg(feature = "std")]
pub scroll_input_queue: ScrollInputQueue,
pub pending_wheel_event: Option<LogicalPosition>,
scroll_dirty: bool,
natural_scroll: bool,
}
#[derive(Debug, Clone)]
pub struct AnimatedScrollState {
pub current_offset: LogicalPosition,
pub animation: Option<ScrollAnimation>,
pub last_activity: Instant,
pub container_rect: LogicalRect,
pub content_rect: LogicalRect,
pub virtual_scroll_size: Option<LogicalSize>,
pub virtual_scroll_offset: Option<LogicalPosition>,
pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
pub scrollbar_thickness: f32,
pub visual_width_px: f32,
pub has_horizontal_scrollbar: bool,
pub has_vertical_scrollbar: bool,
}
#[derive(Debug, Clone)]
struct ScrollAnimation {
start_time: Instant,
duration: Duration,
start_offset: LogicalPosition,
target_offset: LogicalPosition,
easing: EasingFunction,
}
#[derive(Copy, Debug, Clone)]
pub struct ScrollNodeInfo {
pub current_offset: LogicalPosition,
pub container_rect: LogicalRect,
pub content_rect: LogicalRect,
pub max_scroll_x: f32,
pub max_scroll_y: f32,
pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
}
#[derive(Debug, Default)]
pub struct ScrollTickResult {
pub needs_repaint: bool,
pub updated_nodes: Vec<(DomId, NodeId)>,
}
impl ScrollManager {
#[must_use] pub fn new() -> Self {
let mut m = Self::default();
#[cfg(feature = "std")]
if let Some(v) = std::env::var_os("AZ_NATURAL_SCROLL") {
m.natural_scroll = matches!(v.to_str(), Some("1" | "true" | "TRUE"));
}
m
}
pub const fn set_natural_scroll(&mut self, natural: bool) {
self.natural_scroll = natural;
}
#[must_use] pub const fn is_natural_scroll(&self) -> bool {
self.natural_scroll
}
#[inline]
const fn scroll_sign(&self) -> f32 {
if self.natural_scroll {
1.0
} else {
-1.0
}
}
#[must_use] pub fn debug_counts(&self) -> (usize, usize) {
(self.states.len(), self.scrollbar_states.len())
}
pub(crate) const fn has_pending_scroll_changes(&self) -> bool {
self.scroll_dirty
}
pub const fn clear_scroll_dirty(&mut self) {
self.scroll_dirty = false;
}
#[must_use] pub fn build_scroll_offset_map(
&self,
dom_id: DomId,
scroll_ids: &std::collections::HashMap<usize, u64>,
) -> std::collections::HashMap<u64, (f32, f32)> {
let mut map = std::collections::HashMap::new();
for ((d, node_id), state) in &self.states {
if *d != dom_id { continue; }
let node_idx = node_id.index();
if let Some(&scroll_id) = scroll_ids.get(&node_idx) {
map.insert(scroll_id, (state.current_offset.x, state.current_offset.y));
}
}
map
}
#[cfg(feature = "std")]
pub fn record_scroll_input(&mut self, mut input: ScrollInput) -> bool {
let sign = self.scroll_sign();
input.delta.x *= sign;
input.delta.y *= sign;
let was_empty = !self.scroll_input_queue.has_pending();
self.scroll_input_queue.push(input);
was_empty }
#[cfg(feature = "std")]
pub fn record_scroll_from_hit_test(
&mut self,
delta_x: f32,
delta_y: f32,
source: ScrollInputSource,
hover_manager: &crate::managers::hover::HoverManager,
input_point_id: &InputPointId,
now: Instant,
) -> Option<(DomId, NodeId, bool)> {
self.pending_wheel_event = Some(LogicalPosition { x: delta_x, y: delta_y });
let hit_test = hover_manager.get_current(input_point_id)?;
let sign = self.scroll_sign();
let (eff_x, eff_y) = (delta_x * sign, delta_y * sign);
let target = self.select_scroll_target(
hit_test.hovered_nodes.iter().flat_map(|(dom_id, hit_node)| {
hit_node
.scroll_hit_test_nodes
.keys()
.rev()
.map(move |node_id| (*dom_id, *node_id))
}),
eff_x,
eff_y,
);
let (dom_id, node_id) = target?;
let input = ScrollInput {
dom_id,
node_id,
delta: LogicalPosition { x: delta_x, y: delta_y },
timestamp: now,
source,
};
let should_start_timer = self.record_scroll_input(input);
Some((dom_id, node_id, should_start_timer))
}
fn select_scroll_target<I>(
&self,
candidates: I,
eff_x: f32,
eff_y: f32,
) -> Option<(DomId, NodeId)>
where
I: Iterator<Item = (DomId, NodeId)>,
{
let mut fallback = None;
for (dom_id, node_id) in candidates {
if !self.is_node_scrollable(dom_id, node_id) {
continue;
}
if fallback.is_none() {
fallback = Some((dom_id, node_id));
}
if self.can_consume_delta(dom_id, node_id, eff_x, eff_y) {
return Some((dom_id, node_id));
}
}
fallback
}
#[must_use] pub fn a11y_scroll_info(
&self,
dom_id: DomId,
node_id: NodeId,
) -> Option<(LogicalPosition, f32, f32)> {
let state = self.states.get(&(dom_id, node_id))?;
let effective_width = state
.virtual_scroll_size
.map_or(state.content_rect.size.width, |s| s.width);
let effective_height = state
.virtual_scroll_size
.map_or(state.content_rect.size.height, |s| s.height);
let max_x = (effective_width - state.container_rect.size.width).max(0.0);
let max_y = (effective_height - state.container_rect.size.height).max(0.0);
if max_x <= 0.0 && max_y <= 0.0 {
return None;
}
Some((state.current_offset, max_x, max_y))
}
fn can_consume_delta(
&self,
dom_id: DomId,
node_id: NodeId,
eff_x: f32,
eff_y: f32,
) -> bool {
const EPS: f32 = 0.5;
let Some(state) = self.states.get(&(dom_id, node_id)) else {
return false;
};
let effective_width = state
.virtual_scroll_size
.map_or(state.content_rect.size.width, |s| s.width);
let effective_height = state
.virtual_scroll_size
.map_or(state.content_rect.size.height, |s| s.height);
let max_x = (effective_width - state.container_rect.size.width).max(0.0);
let max_y = (effective_height - state.container_rect.size.height).max(0.0);
let off = state.current_offset;
let x_ok = if eff_x > EPS {
off.x < max_x - EPS
} else if eff_x < -EPS {
off.x > EPS
} else {
false
};
let y_ok = if eff_y > EPS {
off.y < max_y - EPS
} else if eff_y < -EPS {
off.y > EPS
} else {
false
};
x_ok || y_ok
}
#[cfg(feature = "std")]
#[must_use] pub fn get_input_queue(&self) -> ScrollInputQueue {
self.scroll_input_queue.clone()
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::needless_pass_by_value)]
pub fn tick(&mut self, now: Instant) -> ScrollTickResult {
let mut result = ScrollTickResult::default();
for ((dom_id, node_id), state) in &mut self.states {
if let Some(anim) = &state.animation {
let elapsed = now.duration_since(&anim.start_time);
let t = elapsed.div(&anim.duration).min(1.0);
let eased_t = apply_easing(t, anim.easing);
state.current_offset = LogicalPosition {
x: anim.start_offset.x + (anim.target_offset.x - anim.start_offset.x) * eased_t,
y: anim.start_offset.y + (anim.target_offset.y - anim.start_offset.y) * eased_t,
};
result.needs_repaint = true;
result.updated_nodes.push((*dom_id, *node_id));
if t >= 1.0 {
state.animation = None;
}
}
}
result
}
#[must_use] pub fn has_active_animations(&self) -> bool {
self.states.values().any(|s| s.animation.is_some())
}
#[must_use] pub fn find_scroll_parent(
&self,
dom_id: DomId,
node_id: NodeId,
node_hierarchy: &[azul_core::styled_dom::NodeHierarchyItem],
) -> Option<NodeId> {
let mut current = Some(node_id);
while let Some(nid) = current {
if self.states.contains_key(&(dom_id, nid)) && nid != node_id {
return Some(nid);
}
current = node_hierarchy
.get(nid.index())
.and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
}
None
}
fn is_node_scrollable(&self, dom_id: DomId, node_id: NodeId) -> bool {
let result = self.states.get(&(dom_id, node_id)).is_some_and(|state| {
let effective_width = state.virtual_scroll_size
.map_or(state.content_rect.size.width, |s| s.width);
let effective_height = state.virtual_scroll_size
.map_or(state.content_rect.size.height, |s| s.height);
let has_horizontal = effective_width > state.container_rect.size.width;
let has_vertical = effective_height > state.container_rect.size.height;
has_horizontal || has_vertical
});
result
}
pub fn set_scroll_position(
&mut self,
dom_id: DomId,
node_id: NodeId,
position: LogicalPosition,
now: Instant,
) {
let state = self
.states
.entry((dom_id, node_id))
.or_insert_with(|| AnimatedScrollState::new(now.clone()));
let clamped = state.clamp(position);
if (clamped.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
|| (clamped.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
{
self.scroll_dirty = true;
}
state.current_offset = clamped;
state.animation = None;
state.last_activity = now;
}
pub fn set_scroll_position_unclamped(
&mut self,
dom_id: DomId,
node_id: NodeId,
position: LogicalPosition,
now: Instant,
) {
let state = self
.states
.entry((dom_id, node_id))
.or_insert_with(|| AnimatedScrollState::new(now.clone()));
if (position.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
|| (position.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
{
self.scroll_dirty = true;
}
state.current_offset = position;
state.animation = None;
state.last_activity = now;
}
pub fn scroll_by(
&mut self,
dom_id: DomId,
node_id: NodeId,
delta: LogicalPosition,
duration: Duration,
easing: EasingFunction,
now: Instant,
) {
let current = self.get_current_offset(dom_id, node_id).unwrap_or_default();
let target = LogicalPosition {
x: current.x + delta.x,
y: current.y + delta.y,
};
self.scroll_to(dom_id, node_id, target, duration, easing, now);
}
pub fn scroll_to(
&mut self,
dom_id: DomId,
node_id: NodeId,
target: LogicalPosition,
duration: Duration,
easing: EasingFunction,
now: Instant,
) {
let is_zero = match &duration {
Duration::System(s) => s.secs == 0 && s.nanos == 0,
Duration::Tick(t) => t.tick_diff == 0,
};
if is_zero {
self.set_scroll_position(dom_id, node_id, target, now);
return;
}
let state = self
.states
.entry((dom_id, node_id))
.or_insert_with(|| AnimatedScrollState::new(now.clone()));
let clamped_target = state.clamp(target);
state.animation = Some(ScrollAnimation {
start_time: now.clone(),
duration,
start_offset: state.current_offset,
target_offset: clamped_target,
easing,
});
state.last_activity = now;
}
pub fn update_node_bounds(
&mut self,
dom_id: DomId,
node_id: NodeId,
container_rect: LogicalRect,
content_rect: LogicalRect,
now: Instant,
) {
let state = self
.states
.entry((dom_id, node_id))
.or_insert_with(|| AnimatedScrollState::new(now));
state.container_rect = container_rect;
state.content_rect = content_rect;
state.current_offset = state.clamp(state.current_offset);
}
pub fn update_virtual_scroll_bounds(
&mut self,
dom_id: DomId,
node_id: NodeId,
virtual_scroll_size: LogicalSize,
virtual_scroll_offset: Option<LogicalPosition>,
) {
let key = (dom_id, node_id);
let state = self.states.entry(key).or_insert_with(|| {
AnimatedScrollState::new(Instant::now())
});
state.virtual_scroll_size = Some(virtual_scroll_size);
state.virtual_scroll_offset = virtual_scroll_offset;
state.current_offset = state.clamp(state.current_offset);
}
#[must_use] pub fn get_current_offset(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
self.states
.get(&(dom_id, node_id))
.map(|s| s.current_offset)
}
#[must_use] pub fn get_last_activity_time(&self, dom_id: DomId, node_id: NodeId) -> Option<Instant> {
self.states
.get(&(dom_id, node_id))
.map(|s| s.last_activity.clone())
}
#[must_use] pub fn get_scroll_state(&self, dom_id: DomId, node_id: NodeId) -> Option<&AnimatedScrollState> {
self.states.get(&(dom_id, node_id))
}
#[must_use] pub fn get_scroll_node_info(
&self,
dom_id: DomId,
node_id: NodeId,
) -> Option<ScrollNodeInfo> {
let state = self.states.get(&(dom_id, node_id))?;
let effective_content_width = state.virtual_scroll_size
.map_or(state.content_rect.size.width, |s| s.width);
let effective_content_height = state.virtual_scroll_size
.map_or(state.content_rect.size.height, |s| s.height);
let max_x = (effective_content_width - state.container_rect.size.width).max(0.0);
let max_y = (effective_content_height - state.container_rect.size.height).max(0.0);
Some(ScrollNodeInfo {
current_offset: state.current_offset,
container_rect: state.container_rect,
content_rect: state.content_rect,
max_scroll_x: max_x,
max_scroll_y: max_y,
overscroll_behavior_x: state.overscroll_behavior_x,
overscroll_behavior_y: state.overscroll_behavior_y,
overflow_scrolling: state.overflow_scrolling,
})
}
#[must_use] pub fn get_scroll_states_for_dom(&self, dom_id: DomId) -> BTreeMap<NodeId, ScrollPosition> {
if self.states.is_empty() {
return BTreeMap::new();
}
self.states
.iter()
.filter(|((d, _), _)| *d == dom_id)
.map(|((_, node_id), state)| {
let effective_content_size = state.virtual_scroll_size
.unwrap_or(state.content_rect.size);
(
*node_id,
ScrollPosition {
parent_rect: state.container_rect,
children_rect: LogicalRect::new(
state.current_offset,
effective_content_size,
),
},
)
})
.collect()
}
pub fn register_or_update_scroll_node(
&mut self,
dom_id: DomId,
node_id: NodeId,
container_rect: LogicalRect,
content_size: LogicalSize,
now: Instant,
scrollbar_thickness: f32,
visual_width_px: f32,
has_horizontal_scrollbar: bool,
has_vertical_scrollbar: bool,
) {
let key = (dom_id, node_id);
let content_rect = LogicalRect {
origin: LogicalPosition::zero(),
size: content_size,
};
if let Some(existing) = self.states.get_mut(&key) {
existing.container_rect = container_rect;
existing.content_rect = content_rect;
existing.scrollbar_thickness = scrollbar_thickness;
existing.visual_width_px = visual_width_px;
existing.has_horizontal_scrollbar = has_horizontal_scrollbar;
existing.has_vertical_scrollbar = has_vertical_scrollbar;
existing.current_offset = existing.clamp(existing.current_offset);
} else {
self.states.insert(
key,
AnimatedScrollState {
current_offset: LogicalPosition::zero(),
animation: None,
last_activity: now,
container_rect,
content_rect,
virtual_scroll_size: None,
virtual_scroll_offset: None,
overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
scrollbar_thickness,
visual_width_px,
has_horizontal_scrollbar,
has_vertical_scrollbar,
},
);
}
}
pub fn calculate_scrollbar_states(&mut self) {
self.scrollbar_states.clear();
for orientation in [ScrollbarOrientation::Vertical, ScrollbarOrientation::Horizontal] {
let states: Vec<_> = self
.states
.iter()
.filter(|(_, s)| {
let (effective, container) = match orientation {
ScrollbarOrientation::Vertical => (
s.virtual_scroll_size.map_or(s.content_rect.size.height, |vs| vs.height),
s.container_rect.size.height,
),
ScrollbarOrientation::Horizontal => (
s.virtual_scroll_size.map_or(s.content_rect.size.width, |vs| vs.width),
s.container_rect.size.width,
),
};
effective > container
})
.map(|((dom_id, node_id), scroll_state)| {
let state = Self::calculate_scrollbar_state_from_geometry(
scroll_state,
orientation,
);
((*dom_id, *node_id, orientation), state)
})
.collect();
self.scrollbar_states.extend(states);
}
}
fn calculate_scrollbar_state_from_geometry(
scroll_state: &AnimatedScrollState,
orientation: ScrollbarOrientation,
) -> ScrollbarState {
let scrollbar_thickness = if scroll_state.visual_width_px > 0.0 {
scroll_state.visual_width_px
} else if scroll_state.scrollbar_thickness > 0.0 {
scroll_state.scrollbar_thickness
} else {
crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX
};
let content_size = scroll_state.virtual_scroll_size
.map_or(scroll_state.content_rect.size, |vs| vs);
let scroll_offset = match orientation {
ScrollbarOrientation::Vertical => scroll_state.current_offset.y,
ScrollbarOrientation::Horizontal => scroll_state.current_offset.x,
};
let has_other_scrollbar = match orientation {
ScrollbarOrientation::Vertical => scroll_state.has_horizontal_scrollbar,
ScrollbarOrientation::Horizontal => scroll_state.has_vertical_scrollbar,
};
let is_overlay = scroll_state.scrollbar_thickness == 0.0;
let button_size = if is_overlay { 0.0 } else { scrollbar_thickness };
let geom = compute_scrollbar_geometry_with_button_size(
orientation,
scroll_state.container_rect,
content_size,
scroll_offset,
scrollbar_thickness,
has_other_scrollbar,
button_size,
);
let scale = match orientation {
ScrollbarOrientation::Vertical => {
LogicalPosition::new(1.0, geom.track_rect.size.height / scrollbar_thickness)
}
ScrollbarOrientation::Horizontal => {
LogicalPosition::new(geom.track_rect.size.width / scrollbar_thickness, 1.0)
}
};
ScrollbarState {
visible: true,
orientation,
base_size: scrollbar_thickness,
scale,
thumb_position_ratio: geom.scroll_ratio,
thumb_size_ratio: geom.thumb_size_ratio,
track_rect: geom.track_rect,
button_size: geom.button_size,
usable_track_length: geom.usable_track_length,
thumb_length: geom.thumb_length,
thumb_offset: geom.thumb_offset,
}
}
#[must_use] pub fn get_scrollbar_state(
&self,
dom_id: DomId,
node_id: NodeId,
orientation: ScrollbarOrientation,
) -> Option<&ScrollbarState> {
self.scrollbar_states.get(&(dom_id, node_id, orientation))
}
pub(crate) fn iter_scrollbar_states(
&self,
) -> impl Iterator<Item = ((DomId, NodeId, ScrollbarOrientation), &ScrollbarState)> + '_ {
self.scrollbar_states.iter().map(|(k, v)| (*k, v))
}
pub(crate) fn hit_test_scrollbar(
&self,
dom_id: DomId,
node_id: NodeId,
global_pos: LogicalPosition,
) -> Option<ScrollbarHit> {
for orientation in [
ScrollbarOrientation::Vertical,
ScrollbarOrientation::Horizontal,
] {
let Some(scrollbar_state) = self.scrollbar_states.get(&(dom_id, node_id, orientation)) else {
continue;
};
if !scrollbar_state.visible {
continue;
}
if !scrollbar_state.track_rect.contains(global_pos) {
continue;
}
let local_pos = LogicalPosition::new(
global_pos.x - scrollbar_state.track_rect.origin.x,
global_pos.y - scrollbar_state.track_rect.origin.y,
);
let component = scrollbar_state.hit_test_component(local_pos);
return Some(ScrollbarHit {
dom_id,
node_id,
orientation,
component,
local_position: local_pos,
global_position: global_pos,
});
}
None
}
#[must_use] pub fn hit_test_scrollbars(&self, global_pos: LogicalPosition) -> Option<ScrollbarHit> {
for ((dom_id, node_id, orientation), scrollbar_state) in self.scrollbar_states.iter().rev()
{
if !scrollbar_state.visible {
continue;
}
if !scrollbar_state.track_rect.contains(global_pos) {
continue;
}
let local_pos = LogicalPosition::new(
global_pos.x - scrollbar_state.track_rect.origin.x,
global_pos.y - scrollbar_state.track_rect.origin.y,
);
let component = scrollbar_state.hit_test_component(local_pos);
return Some(ScrollbarHit {
dom_id: *dom_id,
node_id: *node_id,
orientation: *orientation,
component,
local_position: local_pos,
global_position: global_pos,
});
}
None
}
}
impl AnimatedScrollState {
pub(crate) const fn new(now: Instant) -> Self {
Self {
current_offset: LogicalPosition::zero(),
animation: None,
last_activity: now,
container_rect: LogicalRect::zero(),
content_rect: LogicalRect::zero(),
virtual_scroll_size: None,
virtual_scroll_offset: None,
overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
scrollbar_thickness: crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX,
visual_width_px: 0.0,
has_horizontal_scrollbar: false,
has_vertical_scrollbar: false,
}
}
pub(crate) fn clamp(&self, position: LogicalPosition) -> LogicalPosition {
let effective_width = self.virtual_scroll_size
.map_or(self.content_rect.size.width, |s| s.width);
let effective_height = self.virtual_scroll_size
.map_or(self.content_rect.size.height, |s| s.height);
let max_x = (effective_width - self.container_rect.size.width).max(0.0);
let max_y = (effective_height - self.container_rect.size.height).max(0.0);
LogicalPosition {
x: position.x.max(0.0).min(max_x),
y: position.y.max(0.0).min(max_y),
}
}
}
#[allow(clippy::suboptimal_flops)] pub(crate) fn apply_easing(t: f32, easing: EasingFunction) -> f32 {
match easing {
EasingFunction::Linear => t,
EasingFunction::EaseOut => 1.0 - (1.0 - t).powi(3),
EasingFunction::EaseInOut => {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
}
}
impl crate::managers::NodeIdRemap for ScrollManager {
fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
crate::managers::remap_dom_keys(&mut self.states, dom, map);
let old = core::mem::take(&mut self.scrollbar_states);
for ((d, old_node_id, orientation), state) in old {
if d != dom {
self.scrollbar_states
.insert((d, old_node_id, orientation), state);
} else if let Some(new_node_id) = map.resolve(old_node_id) {
self.scrollbar_states
.insert((d, new_node_id, orientation), state);
}
}
}
}
#[cfg(all(test, feature = "std"))]
mod natural_scroll_tests {
use super::*;
use azul_core::dom::{DomId, NodeId};
use azul_core::geom::LogicalPosition;
use azul_core::task::Instant;
fn raw_input(dx: f32, dy: f32) -> ScrollInput {
ScrollInput {
dom_id: DomId::ROOT_ID,
node_id: NodeId::new(0),
delta: LogicalPosition::new(dx, dy),
timestamp: Instant::from(std::time::Instant::now()),
source: ScrollInputSource::WheelDiscrete,
}
}
#[test]
#[allow(clippy::float_cmp)] fn default_is_traditional_and_inverts_raw_delta() {
let mut m = ScrollManager::new();
assert!(!m.is_natural_scroll(), "default must be traditional");
m.record_scroll_input(raw_input(3.0, 10.0));
let q = m.get_input_queue().take_all();
assert_eq!(q.len(), 1);
assert_eq!(q[0].delta.x, -3.0, "x must be inverted by the default sign");
assert_eq!(q[0].delta.y, -10.0, "y must be inverted by the default sign");
}
#[test]
#[allow(clippy::float_cmp)] fn natural_passes_raw_delta_through() {
let mut m = ScrollManager::new();
m.set_natural_scroll(true);
assert!(m.is_natural_scroll());
m.record_scroll_input(raw_input(3.0, 10.0));
let q = m.get_input_queue().take_all();
assert_eq!(q.len(), 1);
assert_eq!(q[0].delta.x, 3.0, "natural mode must NOT invert x");
assert_eq!(q[0].delta.y, 10.0, "natural mode must NOT invert y");
}
#[test]
#[allow(clippy::float_cmp)] fn toggling_flips_sign_for_subsequent_input() {
let mut m = ScrollManager::new();
m.record_scroll_input(raw_input(0.0, 5.0));
m.set_natural_scroll(true);
m.record_scroll_input(raw_input(0.0, 5.0));
let q = m.get_input_queue().take_all();
assert_eq!(q.len(), 2);
assert_eq!(q[0].delta.y, -5.0, "traditional first");
assert_eq!(q[1].delta.y, 5.0, "natural after toggle");
}
fn nested_setup() -> (ScrollManager, DomId, NodeId, NodeId) {
use azul_core::geom::{LogicalRect, LogicalSize};
let now = Instant::now();
let mut m = ScrollManager::new();
let dom = DomId::ROOT_ID;
let outer = NodeId::from_usize(1).unwrap();
let inner = NodeId::from_usize(9).unwrap();
m.register_or_update_scroll_node(
dom,
outer,
LogicalRect {
origin: LogicalPosition::zero(),
size: LogicalSize { width: 200.0, height: 200.0 },
},
LogicalSize { width: 200.0, height: 1000.0 },
now.clone(),
8.0,
8.0,
false,
true,
);
m.register_or_update_scroll_node(
dom,
inner,
LogicalRect {
origin: LogicalPosition::zero(),
size: LogicalSize { width: 100.0, height: 100.0 },
},
LogicalSize { width: 100.0, height: 300.0 },
now,
8.0,
8.0,
false,
true,
);
(m, dom, outer, inner)
}
#[test]
fn nested_scroll_prefers_innermost_with_room() {
let (m, dom, outer, inner) = nested_setup();
let picked = m.select_scroll_target(
[(dom, inner), (dom, outer)].into_iter(),
0.0,
1.0,
);
assert_eq!(picked, Some((dom, inner)), "inner has room → inner wins");
}
#[test]
fn nested_scroll_hands_off_to_ancestor_at_boundary() {
let (mut m, dom, outer, inner) = nested_setup();
m.states.get_mut(&(dom, inner)).unwrap().current_offset =
LogicalPosition { x: 0.0, y: 200.0 };
let down = m.select_scroll_target(
[(dom, inner), (dom, outer)].into_iter(),
0.0,
1.0,
);
assert_eq!(down, Some((dom, outer)), "inner pinned at bottom → handoff");
let up = m.select_scroll_target(
[(dom, inner), (dom, outer)].into_iter(),
0.0,
-1.0,
);
assert_eq!(up, Some((dom, inner)), "inner has room upward → inner again");
}
#[test]
fn nested_scroll_falls_back_to_innermost_when_all_pinned() {
let (mut m, dom, outer, inner) = nested_setup();
m.states.get_mut(&(dom, inner)).unwrap().current_offset =
LogicalPosition { x: 0.0, y: 200.0 };
m.states.get_mut(&(dom, outer)).unwrap().current_offset =
LogicalPosition { x: 0.0, y: 800.0 };
let picked = m.select_scroll_target(
[(dom, inner), (dom, outer)].into_iter(),
0.0,
1.0,
);
assert_eq!(
picked,
Some((dom, inner)),
"everything pinned → innermost fallback (gesture stays under pointer)"
);
}
}
#[cfg(all(test, feature = "std"))]
mod autotest_generated {
#![allow(clippy::float_cmp)]
use std::collections::HashMap;
use azul_core::{
dom::{DomId, NodeId, ScrollbarOrientation},
events::EasingFunction,
geom::{LogicalPosition, LogicalRect, LogicalSize},
hit_test::{FullHitTest, HitTest, OverflowingScrollNode, ScrollHitTestItem},
styled_dom::NodeHierarchyItem,
task::{Duration, Instant, SystemTick, SystemTickDiff, SystemTimeDiff},
};
use super::*;
use crate::managers::hover::HoverManager;
const DOM: DomId = DomId::ROOT_ID;
const DOM1: DomId = DomId { inner: 1 };
fn node(i: usize) -> NodeId {
NodeId::new(i)
}
fn at(t: u64) -> Instant {
Instant::Tick(SystemTick::new(t))
}
fn tick_dur(d: u64) -> Duration {
Duration::Tick(SystemTickDiff { tick_diff: d })
}
fn sys_dur(secs: u64, nanos: u32) -> Duration {
Duration::System(SystemTimeDiff { secs, nanos })
}
fn pos(x: f32, y: f32) -> LogicalPosition {
LogicalPosition::new(x, y)
}
fn size(w: f32, h: f32) -> LogicalSize {
LogicalSize::new(w, h)
}
fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
LogicalRect::new(pos(x, y), size(w, h))
}
fn mgr(container: LogicalSize, content: LogicalSize) -> ScrollManager {
let mut m = ScrollManager::new();
m.register_or_update_scroll_node(
DOM,
node(0),
LogicalRect::new(LogicalPosition::zero(), container),
content,
at(0),
16.0,
16.0,
false,
true,
);
m
}
fn state(container: LogicalSize, content: LogicalSize) -> AnimatedScrollState {
let mut s = AnimatedScrollState::new(at(0));
s.container_rect = LogicalRect::new(LogicalPosition::zero(), container);
s.content_rect = LogicalRect::new(LogicalPosition::zero(), content);
s
}
fn input(dx: f32, dy: f32, ts: u64) -> ScrollInput {
ScrollInput {
dom_id: DOM,
node_id: node(0),
delta: pos(dx, dy),
timestamp: at(ts),
source: ScrollInputSource::WheelDiscrete,
}
}
fn scrollbar(
orientation: ScrollbarOrientation,
track: LogicalRect,
button_size: f32,
thumb_offset: f32,
thumb_length: f32,
) -> ScrollbarState {
ScrollbarState {
visible: true,
orientation,
base_size: 16.0,
scale: LogicalPosition::new(1.0, 1.0),
thumb_position_ratio: 0.0,
thumb_size_ratio: 0.5,
track_rect: track,
button_size,
usable_track_length: 0.0,
thumb_length,
thumb_offset,
}
}
fn hover_over(nodes: &[usize]) -> HoverManager {
let mut ht = HitTest::empty();
for n in nodes {
ht.scroll_hit_test_nodes.insert(
node(*n),
ScrollHitTestItem {
point_in_viewport: LogicalPosition::zero(),
point_relative_to_item: LogicalPosition::zero(),
scroll_node: OverflowingScrollNode::default(),
},
);
}
let mut full = FullHitTest::empty(None);
full.hovered_nodes.insert(DOM, ht);
let mut hm = HoverManager::new();
hm.push_hit_test(InputPointId::Mouse, full);
hm
}
#[test]
fn apply_easing_endpoints_are_exact_for_every_curve() {
for e in [
EasingFunction::Linear,
EasingFunction::EaseOut,
EasingFunction::EaseInOut,
] {
assert_eq!(apply_easing(0.0, e), 0.0, "f(0) must be 0 for {e:?}");
assert_eq!(apply_easing(1.0, e), 1.0, "f(1) must be 1 for {e:?}");
}
}
#[test]
fn apply_easing_is_monotonic_and_bounded_on_the_unit_interval() {
for e in [
EasingFunction::Linear,
EasingFunction::EaseOut,
EasingFunction::EaseInOut,
] {
let mut prev = f32::NEG_INFINITY;
for i in 0..=100 {
let t = i as f32 / 100.0;
let v = apply_easing(t, e);
assert!(v.is_finite(), "{e:?}({t}) must be finite, got {v}");
assert!(
(-1e-6..=1.0 + 1e-6).contains(&v),
"{e:?}({t}) = {v} escaped [0, 1]"
);
assert!(v >= prev - 1e-6, "{e:?} must not go backwards at t={t}");
prev = v;
}
}
}
#[test]
fn apply_easing_nan_propagates_without_panicking() {
for e in [
EasingFunction::Linear,
EasingFunction::EaseOut,
EasingFunction::EaseInOut,
] {
assert!(
apply_easing(f32::NAN, e).is_nan(),
"{e:?}(NaN) must be NaN, not a silently-wrong number"
);
}
}
#[test]
fn apply_easing_infinities_saturate_to_infinity_not_panic() {
assert_eq!(apply_easing(f32::INFINITY, EasingFunction::Linear), f32::INFINITY);
assert_eq!(
apply_easing(f32::NEG_INFINITY, EasingFunction::Linear),
f32::NEG_INFINITY
);
assert_eq!(apply_easing(f32::INFINITY, EasingFunction::EaseOut), f32::INFINITY);
assert_eq!(
apply_easing(f32::NEG_INFINITY, EasingFunction::EaseOut),
f32::NEG_INFINITY
);
assert_eq!(
apply_easing(f32::INFINITY, EasingFunction::EaseInOut),
f32::INFINITY
);
assert_eq!(
apply_easing(f32::NEG_INFINITY, EasingFunction::EaseInOut),
f32::NEG_INFINITY
);
}
#[test]
fn apply_easing_f32_extremes_do_not_panic() {
for e in [
EasingFunction::Linear,
EasingFunction::EaseOut,
EasingFunction::EaseInOut,
] {
let hi = apply_easing(f32::MAX, e);
let lo = apply_easing(f32::MIN, e);
assert!(!hi.is_nan(), "{e:?}(f32::MAX) must not be NaN");
assert!(!lo.is_nan(), "{e:?}(f32::MIN) must not be NaN");
}
assert!(apply_easing(f32::MIN_POSITIVE, EasingFunction::EaseInOut).abs() < 1e-30);
}
#[test]
fn apply_easing_negative_t_is_deterministic_extrapolation() {
assert_eq!(apply_easing(-1.0, EasingFunction::Linear), -1.0);
assert_eq!(apply_easing(-1.0, EasingFunction::EaseOut), -7.0);
assert_eq!(apply_easing(-1.0, EasingFunction::EaseInOut), -4.0);
}
#[test]
fn apply_easing_ease_in_out_is_continuous_at_the_branch_boundary() {
assert_eq!(apply_easing(0.5, EasingFunction::EaseInOut), 0.5);
let just_below = apply_easing(0.499_999, EasingFunction::EaseInOut);
assert!(
(just_below - 0.5).abs() < 1e-4,
"discontinuity at the 0.5 branch: {just_below}"
);
assert_eq!(apply_easing(0.5, EasingFunction::EaseOut), 0.875);
}
#[test]
fn animated_scroll_state_new_starts_at_scroll_origin() {
let s = AnimatedScrollState::new(at(0));
assert_eq!(s.current_offset, LogicalPosition::zero());
assert!(s.animation.is_none());
assert_eq!(s.container_rect, LogicalRect::zero());
assert_eq!(s.content_rect, LogicalRect::zero());
assert!(s.virtual_scroll_size.is_none());
assert!(s.virtual_scroll_offset.is_none());
assert!(!s.has_horizontal_scrollbar);
assert!(!s.has_vertical_scrollbar);
assert_eq!(s.clamp(pos(1e9, 1e9)), LogicalPosition::zero());
}
#[test]
fn clamp_pins_to_zero_and_max_travel() {
let s = state(size(100.0, 100.0), size(100.0, 500.0));
assert_eq!(s.clamp(pos(0.0, 0.0)), pos(0.0, 0.0));
assert_eq!(s.clamp(pos(50.0, 250.0)), pos(0.0, 250.0));
assert_eq!(s.clamp(pos(-1.0, -1.0)), pos(0.0, 0.0));
assert_eq!(s.clamp(pos(9999.0, 9999.0)), pos(0.0, 400.0));
}
#[test]
fn clamp_never_produces_negative_max_when_content_is_smaller_than_container() {
let s = state(size(500.0, 500.0), size(10.0, 10.0));
assert_eq!(s.clamp(pos(100.0, 100.0)), LogicalPosition::zero());
assert_eq!(s.clamp(pos(-100.0, -100.0)), LogicalPosition::zero());
}
#[test]
fn clamp_nan_position_collapses_to_origin_never_stores_nan() {
let s = state(size(100.0, 100.0), size(100.0, 500.0));
let c = s.clamp(pos(f32::NAN, f32::NAN));
assert!(!c.x.is_nan() && !c.y.is_nan(), "clamp must not leak NaN");
assert_eq!(c, LogicalPosition::zero());
}
#[test]
fn clamp_infinite_position_saturates_to_max_travel() {
let s = state(size(100.0, 100.0), size(100.0, 500.0));
assert_eq!(s.clamp(pos(f32::INFINITY, f32::INFINITY)), pos(0.0, 400.0));
assert_eq!(
s.clamp(pos(f32::NEG_INFINITY, f32::NEG_INFINITY)),
LogicalPosition::zero()
);
assert_eq!(s.clamp(pos(f32::MAX, f32::MAX)), pos(0.0, 400.0));
assert_eq!(s.clamp(pos(f32::MIN, f32::MIN)), LogicalPosition::zero());
}
#[test]
fn clamp_nan_geometry_degrades_to_zero_travel() {
let s = state(size(100.0, 100.0), size(f32::NAN, f32::NAN));
let c = s.clamp(pos(50.0, 50.0));
assert!(!c.x.is_nan() && !c.y.is_nan());
assert_eq!(c, LogicalPosition::zero());
}
#[test]
fn clamp_infinite_content_minus_infinite_container_is_zero_travel_not_nan() {
let s = state(
size(f32::INFINITY, f32::INFINITY),
size(f32::INFINITY, f32::INFINITY),
);
let c = s.clamp(pos(10.0, 10.0));
assert!(!c.x.is_nan() && !c.y.is_nan());
assert_eq!(c, LogicalPosition::zero());
}
#[test]
fn clamp_prefers_virtual_scroll_size_over_content_rect() {
let mut s = state(size(100.0, 100.0), size(100.0, 120.0));
assert_eq!(s.clamp(pos(0.0, 1e9)), pos(0.0, 20.0), "content_rect bound");
s.virtual_scroll_size = Some(size(100.0, 10_000.0));
assert_eq!(
s.clamp(pos(0.0, 1e9)),
pos(0.0, 9900.0),
"virtual size must override content_rect"
);
}
#[test]
fn input_queue_new_is_empty_and_default_matches() {
let q = ScrollInputQueue::new();
assert!(!q.has_pending());
assert!(q.take_all().is_empty());
assert!(q.take_recent(10).is_empty());
assert!(!ScrollInputQueue::default().has_pending());
}
#[test]
fn input_queue_take_all_drains_and_preserves_push_order() {
let q = ScrollInputQueue::new();
q.push(input(1.0, 1.0, 1));
q.push(input(2.0, 2.0, 2));
assert!(q.has_pending());
let taken = q.take_all();
assert_eq!(taken.len(), 2);
assert_eq!(taken[0].delta.x, 1.0);
assert_eq!(taken[1].delta.x, 2.0);
assert!(!q.has_pending(), "take_all must drain the queue");
assert!(q.take_all().is_empty(), "second take_all is empty, not stale");
}
#[test]
fn input_queue_take_recent_zero_discards_everything() {
let q = ScrollInputQueue::new();
q.push(input(1.0, 1.0, 1));
q.push(input(2.0, 2.0, 2));
let taken = q.take_recent(0);
assert!(taken.is_empty(), "take_recent(0) must return nothing");
assert!(!q.has_pending(), "take_recent(0) must still drain the queue");
}
#[test]
fn input_queue_take_recent_keeps_the_newest_events_sorted_oldest_first() {
let q = ScrollInputQueue::new();
q.push(input(0.0, 0.0, 5));
q.push(input(0.0, 0.0, 1));
q.push(input(0.0, 0.0, 3));
q.push(input(0.0, 0.0, 9));
let taken = q.take_recent(2);
assert_eq!(taken.len(), 2, "backlog must be truncated to max_events");
assert_eq!(taken[0].timestamp, at(5));
assert_eq!(taken[1].timestamp, at(9), "newest event must be last");
assert!(!q.has_pending());
}
#[test]
fn input_queue_take_recent_below_limit_returns_push_order_not_sorted() {
let q = ScrollInputQueue::new();
q.push(input(0.0, 0.0, 5));
q.push(input(0.0, 0.0, 1));
q.push(input(0.0, 0.0, 3));
let taken = q.take_recent(3);
assert_eq!(taken.len(), 3);
let stamps: Vec<_> = taken.iter().map(|e| e.timestamp.clone()).collect();
assert_eq!(stamps, vec![at(5), at(1), at(3)]);
}
#[test]
fn input_queue_take_recent_usize_max_does_not_overflow() {
let q = ScrollInputQueue::new();
q.push(input(1.0, 0.0, 1));
q.push(input(2.0, 0.0, 2));
let taken = q.take_recent(usize::MAX);
assert_eq!(taken.len(), 2);
assert!(!q.has_pending());
assert!(q.take_recent(usize::MAX).is_empty());
assert!(q.take_recent(0).is_empty());
}
#[test]
fn input_queue_clone_shares_one_backing_store() {
let q = ScrollInputQueue::new();
let c = q.clone();
c.push(input(1.0, 2.0, 1));
assert!(q.has_pending(), "clone must not deep-copy the queue");
assert_eq!(q.take_all().len(), 1);
assert!(!c.has_pending(), "draining one handle drains both");
}
#[test]
fn input_queue_accepts_non_finite_deltas_without_panicking() {
let q = ScrollInputQueue::new();
q.push(input(f32::NAN, f32::INFINITY, 1));
q.push(input(f32::MAX, f32::MIN, 2));
let taken = q.take_recent(usize::MAX);
assert_eq!(taken.len(), 2);
assert!(taken[0].delta.x.is_nan());
assert_eq!(taken[0].delta.y, f32::INFINITY);
}
#[test]
fn hit_test_component_vertical_maps_each_region() {
let sb = scrollbar(
ScrollbarOrientation::Vertical,
rect(0.0, 0.0, 16.0, 100.0),
16.0, 10.0, 30.0, );
assert_eq!(sb.hit_test_component(pos(8.0, 0.0)), ScrollbarComponent::TopButton);
assert_eq!(sb.hit_test_component(pos(8.0, 15.9)), ScrollbarComponent::TopButton);
assert_eq!(
sb.hit_test_component(pos(8.0, 99.0)),
ScrollbarComponent::BottomButton
);
assert_eq!(sb.hit_test_component(pos(8.0, 26.0)), ScrollbarComponent::Thumb);
assert_eq!(sb.hit_test_component(pos(8.0, 56.0)), ScrollbarComponent::Thumb);
assert_eq!(sb.hit_test_component(pos(8.0, 20.0)), ScrollbarComponent::Track);
assert_eq!(sb.hit_test_component(pos(8.0, 60.0)), ScrollbarComponent::Track);
}
#[test]
fn hit_test_component_boundaries_are_exact() {
let sb = scrollbar(
ScrollbarOrientation::Vertical,
rect(0.0, 0.0, 16.0, 100.0),
16.0,
0.0,
30.0,
);
assert_eq!(sb.hit_test_component(pos(0.0, 16.0)), ScrollbarComponent::Thumb);
assert_eq!(sb.hit_test_component(pos(0.0, 84.0)), ScrollbarComponent::Track);
assert_eq!(
sb.hit_test_component(pos(0.0, 84.001)),
ScrollbarComponent::BottomButton
);
}
#[test]
fn hit_test_component_overlay_zero_button_size_has_no_buttons() {
let sb = scrollbar(
ScrollbarOrientation::Vertical,
rect(0.0, 0.0, 8.0, 100.0),
0.0,
0.0,
50.0,
);
assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::Thumb);
assert_eq!(sb.hit_test_component(pos(0.0, 50.0)), ScrollbarComponent::Thumb);
assert_eq!(sb.hit_test_component(pos(0.0, 60.0)), ScrollbarComponent::Track);
assert_eq!(sb.hit_test_component(pos(0.0, 100.0)), ScrollbarComponent::Track);
}
#[test]
fn hit_test_component_nan_position_falls_through_to_track() {
let sb = scrollbar(
ScrollbarOrientation::Vertical,
rect(0.0, 0.0, 16.0, 100.0),
16.0,
10.0,
30.0,
);
assert_eq!(
sb.hit_test_component(pos(f32::NAN, f32::NAN)),
ScrollbarComponent::Track
);
let hb = scrollbar(
ScrollbarOrientation::Horizontal,
rect(0.0, 0.0, 100.0, 16.0),
16.0,
10.0,
30.0,
);
assert_eq!(
hb.hit_test_component(pos(f32::NAN, f32::NAN)),
ScrollbarComponent::Track
);
}
#[test]
fn hit_test_component_infinite_position_picks_an_end_button() {
let sb = scrollbar(
ScrollbarOrientation::Vertical,
rect(0.0, 0.0, 16.0, 100.0),
16.0,
10.0,
30.0,
);
assert_eq!(
sb.hit_test_component(pos(0.0, f32::NEG_INFINITY)),
ScrollbarComponent::TopButton
);
assert_eq!(
sb.hit_test_component(pos(0.0, f32::INFINITY)),
ScrollbarComponent::BottomButton
);
assert_eq!(
sb.hit_test_component(pos(0.0, f32::MIN)),
ScrollbarComponent::TopButton
);
assert_eq!(
sb.hit_test_component(pos(0.0, f32::MAX)),
ScrollbarComponent::BottomButton
);
}
#[test]
fn hit_test_component_ignores_the_cross_axis() {
let v = scrollbar(
ScrollbarOrientation::Vertical,
rect(0.0, 0.0, 16.0, 100.0),
16.0,
10.0,
30.0,
);
for x in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
assert_eq!(v.hit_test_component(pos(x, 30.0)), ScrollbarComponent::Thumb);
}
let h = scrollbar(
ScrollbarOrientation::Horizontal,
rect(0.0, 0.0, 100.0, 16.0),
16.0,
10.0,
30.0,
);
for y in [-1e9, -1.0, 0.0, 8.0, 1e9, f32::NAN] {
assert_eq!(h.hit_test_component(pos(30.0, y)), ScrollbarComponent::Thumb);
}
}
#[test]
fn hit_test_component_degenerate_track_shorter_than_buttons_prefers_top() {
let sb = scrollbar(
ScrollbarOrientation::Vertical,
rect(0.0, 0.0, 16.0, 4.0),
16.0,
0.0,
0.0,
);
assert_eq!(sb.hit_test_component(pos(0.0, 0.0)), ScrollbarComponent::TopButton);
assert_eq!(sb.hit_test_component(pos(0.0, 3.0)), ScrollbarComponent::TopButton);
}
#[test]
fn manager_new_is_empty_and_traditional_by_default() {
let m = ScrollManager::new();
assert_eq!(m.debug_counts(), (0, 0));
assert!(!m.has_active_animations());
assert!(!m.has_pending_scroll_changes());
assert!(!m.is_natural_scroll());
assert_eq!(m.scroll_sign(), -1.0);
assert!(m.pending_wheel_event.is_none());
assert!(!m.get_input_queue().has_pending());
assert!(m.get_current_offset(DOM, node(0)).is_none());
assert!(m.get_last_activity_time(DOM, node(0)).is_none());
assert!(m.get_scroll_state(DOM, node(0)).is_none());
assert!(m.get_scroll_node_info(DOM, node(0)).is_none());
assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
assert!(m.get_scroll_states_for_dom(DOM).is_empty());
assert!(m
.get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
.is_none());
assert!(m.hit_test_scrollbars(pos(0.0, 0.0)).is_none());
assert_eq!(m.iter_scrollbar_states().count(), 0);
assert!(!m.is_node_scrollable(DOM, node(0)));
assert!(!m.can_consume_delta(DOM, node(0), 10.0, 10.0));
}
#[test]
fn scroll_sign_flips_with_the_preference() {
let mut m = ScrollManager::new();
assert_eq!(m.scroll_sign(), -1.0);
m.set_natural_scroll(true);
assert!(m.is_natural_scroll());
assert_eq!(m.scroll_sign(), 1.0);
m.set_natural_scroll(false);
assert_eq!(m.scroll_sign(), -1.0);
m.set_natural_scroll(false);
assert_eq!(m.scroll_sign(), -1.0);
}
#[test]
fn scroll_dirty_is_set_only_on_a_real_move_and_cleared_on_demand() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
assert!(!m.has_pending_scroll_changes());
m.set_scroll_position(DOM, node(0), pos(0.0, 0.005), at(1));
assert!(!m.has_pending_scroll_changes(), "0.005px must not be 'moved'");
m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(2));
assert!(m.has_pending_scroll_changes());
m.clear_scroll_dirty();
assert!(!m.has_pending_scroll_changes());
m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(3));
assert!(!m.has_pending_scroll_changes());
}
#[test]
fn clear_scroll_dirty_on_a_clean_manager_is_a_noop() {
let mut m = ScrollManager::new();
m.clear_scroll_dirty();
m.clear_scroll_dirty();
assert!(!m.has_pending_scroll_changes());
}
#[test]
fn set_scroll_position_clamps_extremes_into_bounds() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(f32::MAX, f32::MAX), at(1));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
m.set_scroll_position(DOM, node(0), pos(f32::MIN, f32::MIN), at(2));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 0.0)));
m.set_scroll_position(DOM, node(0), pos(f32::INFINITY, f32::INFINITY), at(3));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
m.set_scroll_position(DOM, node(0), pos(f32::NAN, f32::NAN), at(4));
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(!off.x.is_nan() && !off.y.is_nan(), "clamped path must kill NaN");
assert_eq!(off, LogicalPosition::zero());
}
#[test]
fn set_scroll_position_cancels_a_running_animation() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.scroll_to(DOM, node(0), pos(0.0, 300.0), tick_dur(100), EasingFunction::Linear, at(0));
assert!(m.has_active_animations());
m.set_scroll_position(DOM, node(0), pos(0.0, 10.0), at(1));
assert!(!m.has_active_animations(), "an explicit set must win over easing");
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 10.0)));
}
#[test]
fn set_scroll_position_on_an_unknown_node_creates_a_pinned_zero_state() {
let mut m = ScrollManager::new();
m.set_scroll_position(DOM, node(42), pos(500.0, 500.0), at(1));
assert_eq!(m.get_current_offset(DOM, node(42)), Some(LogicalPosition::zero()));
assert_eq!(m.debug_counts(), (1, 0));
m.set_scroll_position(DOM, node(42), pos(600.0, 600.0), at(2));
assert_eq!(m.debug_counts(), (1, 0), "repeat set must not grow the map");
}
#[test]
fn set_scroll_position_unclamped_keeps_overscroll_values_verbatim() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position_unclamped(DOM, node(0), pos(-50.0, -80.0), at(1));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(-50.0, -80.0)));
m.set_scroll_position_unclamped(DOM, node(0), pos(0.0, 9999.0), at(2));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 9999.0)));
assert!(m.has_pending_scroll_changes());
}
#[test]
fn set_scroll_position_unclamped_stores_non_finite_values_unfiltered() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position_unclamped(DOM, node(0), pos(f32::NAN, f32::NAN), at(1));
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(off.x.is_nan() && off.y.is_nan(), "unclamped stores NaN as-is");
assert!(
!m.has_pending_scroll_changes(),
"a NaN write does not trip the dirty flag (NaN comparisons are false)"
);
m.register_or_update_scroll_node(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 500.0),
at(2),
16.0,
16.0,
false,
true,
);
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(!off.x.is_nan() && !off.y.is_nan(), "re-clamp must sanitize NaN");
}
#[test]
fn scroll_to_zero_duration_is_immediate_for_both_clock_kinds() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.scroll_to(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
assert!(!m.has_active_animations(), "zero Tick duration must not animate");
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
m.scroll_to(
DOM,
node(0),
pos(0.0, 200.0),
sys_dur(0, 0),
EasingFunction::EaseOut,
at(2),
);
assert!(!m.has_active_animations(), "zero System duration must not animate");
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
}
#[test]
fn scroll_to_clamps_the_animation_target_not_just_the_final_offset() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.scroll_to(DOM, node(0), pos(0.0, 1e9), tick_dur(100), EasingFunction::Linear, at(0));
let anim_target = m
.get_scroll_state(DOM, node(0))
.and_then(|s| s.animation.as_ref())
.map(|a| a.target_offset)
.unwrap();
assert_eq!(anim_target, pos(0.0, 400.0), "target must be pre-clamped");
let r = m.tick(at(100));
assert!(r.needs_repaint);
assert_eq!(r.updated_nodes, vec![(DOM, node(0))]);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
assert!(!m.has_active_animations(), "animation must clear at t >= 1");
}
#[test]
fn scroll_to_nan_target_animates_to_the_origin_never_to_nan() {
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(0));
m.scroll_to(
DOM,
node(0),
pos(f32::NAN, f32::NAN),
tick_dur(10),
EasingFunction::Linear,
at(0),
);
m.tick(at(10));
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN target must be clamped away");
assert_eq!(off, LogicalPosition::zero());
}
#[test]
fn scroll_by_accumulates_from_the_current_offset_and_saturates() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(1));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 100.0)));
m.scroll_by(DOM, node(0), pos(0.0, 100.0), tick_dur(0), EasingFunction::Linear, at(2));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
m.scroll_by(
DOM,
node(0),
pos(f32::MAX, f32::MAX),
tick_dur(0),
EasingFunction::Linear,
at(3),
);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
m.scroll_by(
DOM,
node(0),
pos(f32::MIN, f32::MIN),
tick_dur(0),
EasingFunction::Linear,
at(4),
);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
}
#[test]
fn scroll_by_on_an_unknown_node_defaults_to_origin_and_stays_pinned() {
let mut m = ScrollManager::new();
m.scroll_by(
DOM,
node(7),
pos(1e9, 1e9),
tick_dur(0),
EasingFunction::Linear,
at(1),
);
assert_eq!(m.get_current_offset(DOM, node(7)), Some(LogicalPosition::zero()));
}
#[test]
fn scroll_by_nan_delta_does_not_poison_the_offset() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 100.0), at(0));
m.scroll_by(
DOM,
node(0),
pos(f32::NAN, f32::NAN),
tick_dur(0),
EasingFunction::Linear,
at(1),
);
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(!off.x.is_nan() && !off.y.is_nan());
assert_eq!(off, LogicalPosition::zero(), "NaN target clamps to origin");
}
#[test]
fn tick_on_an_empty_manager_reports_no_work() {
let mut m = ScrollManager::new();
let r = m.tick(at(1));
assert!(!r.needs_repaint);
assert!(r.updated_nodes.is_empty());
}
#[test]
fn tick_interpolates_linearly_and_completes_exactly_once() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(0));
let r = m.tick(at(50));
assert!(r.needs_repaint);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
assert!(m.has_active_animations(), "still mid-flight at t = 0.5");
let r = m.tick(at(100));
assert!(r.needs_repaint);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
assert!(!m.has_active_animations());
let r = m.tick(at(500));
assert!(!r.needs_repaint);
assert!(r.updated_nodes.is_empty());
}
#[test]
fn tick_before_the_animation_start_time_saturates_to_zero_progress() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 50.0), at(0));
m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(100), EasingFunction::Linear, at(100));
m.tick(at(0)); assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
assert!(m.has_active_animations(), "no progress => still animating");
}
#[test]
fn tick_with_a_zero_duration_animation_completes_instead_of_producing_nan() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.states.get_mut(&(DOM, node(0))).unwrap().animation = Some(ScrollAnimation {
start_time: at(0),
duration: tick_dur(0),
start_offset: pos(0.0, 0.0),
target_offset: pos(0.0, 300.0),
easing: EasingFunction::Linear,
});
let r = m.tick(at(0));
assert!(r.needs_repaint);
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(!off.y.is_nan(), "0/0 must not leak NaN into the offset");
assert_eq!(off, pos(0.0, 300.0));
assert!(!m.has_active_animations());
}
#[test]
fn tick_with_a_mismatched_clock_kind_stalls_at_zero_instead_of_panicking() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 25.0), at(0));
m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
let r = m.tick(Instant::now()); assert!(r.needs_repaint);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 25.0)));
assert!(
m.has_active_animations(),
"mismatched clocks stall the animation (t stays 0) — it never completes"
);
}
#[test]
fn tick_advances_every_animating_node_in_one_pass() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.register_or_update_scroll_node(
DOM,
node(1),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 300.0),
at(0),
16.0,
16.0,
false,
true,
);
m.scroll_to(DOM, node(0), pos(0.0, 400.0), tick_dur(10), EasingFunction::Linear, at(0));
m.scroll_to(DOM, node(1), pos(0.0, 200.0), tick_dur(10), EasingFunction::Linear, at(0));
let r = m.tick(at(10));
assert_eq!(r.updated_nodes.len(), 2);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
assert_eq!(m.get_current_offset(DOM, node(1)), Some(pos(0.0, 200.0)));
}
#[test]
fn register_twice_updates_in_place_and_keeps_the_offset() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 300.0), at(1));
m.register_or_update_scroll_node(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 500.0),
at(2),
16.0,
16.0,
false,
true,
);
assert_eq!(m.debug_counts(), (1, 0), "re-register must not grow the map");
assert_eq!(
m.get_current_offset(DOM, node(0)),
Some(pos(0.0, 300.0)),
"an existing node keeps its scroll offset across relayout"
);
}
#[test]
fn re_registering_with_shrunken_content_re_clamps_the_offset() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
m.register_or_update_scroll_node(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 150.0), at(2),
16.0,
16.0,
false,
true,
);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
}
#[test]
fn register_with_non_finite_geometry_does_not_panic_or_leak_nan() {
let mut m = ScrollManager::new();
m.register_or_update_scroll_node(
DOM,
node(0),
rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
size(f32::NAN, f32::NAN),
at(0),
f32::NAN,
f32::NAN,
true,
true,
);
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN geometry must clamp to 0");
assert_eq!(off, LogicalPosition::zero());
assert!(!m.is_node_scrollable(DOM, node(0)), "NaN overflow check is false");
m.register_or_update_scroll_node(
DOM,
node(1),
rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
size(f32::INFINITY, f32::INFINITY),
at(0),
f32::MAX,
f32::MAX,
true,
true,
);
let off = m.get_current_offset(DOM, node(1)).unwrap();
assert!(!off.x.is_nan() && !off.y.is_nan());
assert_eq!(m.debug_counts(), (2, 0));
}
#[test]
fn register_with_zero_sized_geometry_yields_a_non_scrollable_pinned_node() {
let mut m = ScrollManager::new();
m.register_or_update_scroll_node(
DOM,
node(0),
LogicalRect::zero(),
LogicalSize::zero(),
at(0),
0.0,
0.0,
false,
false,
);
assert!(!m.is_node_scrollable(DOM, node(0)));
assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
assert_eq!(info.max_scroll_x, 0.0);
assert_eq!(info.max_scroll_y, 0.0);
}
#[test]
fn is_node_scrollable_is_strict_overflow_not_equality() {
let mut m = ScrollManager::new();
m.register_or_update_scroll_node(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 100.0),
at(0),
16.0,
16.0,
false,
false,
);
assert!(!m.is_node_scrollable(DOM, node(0)));
m.register_or_update_scroll_node(
DOM,
node(1),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 100.1),
at(0),
16.0,
16.0,
false,
true,
);
assert!(m.is_node_scrollable(DOM, node(1)));
assert!(!m.is_node_scrollable(DOM, node(999)));
assert!(!m.is_node_scrollable(DOM1, node(1)));
}
#[test]
fn is_node_scrollable_uses_the_virtual_size_when_present() {
let mut m = ScrollManager::new();
m.register_or_update_scroll_node(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 50.0),
at(0),
16.0,
16.0,
false,
true,
);
assert!(!m.is_node_scrollable(DOM, node(0)));
m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 100_000.0), None);
assert!(
m.is_node_scrollable(DOM, node(0)),
"a VirtualView with a large virtual size must be scrollable"
);
}
#[test]
fn can_consume_delta_respects_the_half_pixel_deadzone() {
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));
assert!(!m.can_consume_delta(DOM, node(0), 0.0, 0.0));
assert!(!m.can_consume_delta(DOM, node(0), 0.5, 0.5), "exactly EPS is a no-move");
assert!(!m.can_consume_delta(DOM, node(0), -0.5, -0.5));
assert!(m.can_consume_delta(DOM, node(0), 0.0, 0.51));
assert!(m.can_consume_delta(DOM, node(0), 0.0, -0.51));
assert!(!m.can_consume_delta(DOM, node(0), 100.0, 0.0));
}
#[test]
fn can_consume_delta_is_false_at_the_pinned_edges() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 0.0), at(1));
assert!(!m.can_consume_delta(DOM, node(0), 0.0, -10.0));
assert!(m.can_consume_delta(DOM, node(0), 0.0, 10.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(2));
assert!(!m.can_consume_delta(DOM, node(0), 0.0, 10.0));
assert!(m.can_consume_delta(DOM, node(0), 0.0, -10.0));
}
#[test]
fn can_consume_delta_rejects_nan_and_accepts_infinite_deltas() {
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));
assert!(
!m.can_consume_delta(DOM, node(0), f32::NAN, f32::NAN),
"a NaN delta consumes nothing (every comparison is false)"
);
assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::INFINITY));
assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::NEG_INFINITY));
assert!(m.can_consume_delta(DOM, node(0), 0.0, f32::MAX));
assert!(!m.can_consume_delta(DOM, node(999), 0.0, f32::MAX), "unknown node");
}
#[test]
fn select_scroll_target_on_no_candidates_is_none() {
let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
assert!(m
.select_scroll_target(core::iter::empty(), 0.0, 10.0)
.is_none());
assert!(m
.select_scroll_target([(DOM, node(50)), (DOM1, node(0))].into_iter(), 0.0, 10.0)
.is_none());
}
#[test]
fn select_scroll_target_with_zero_or_nan_delta_falls_back_to_the_innermost() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.register_or_update_scroll_node(
DOM,
node(9),
rect(0.0, 0.0, 50.0, 50.0),
size(50.0, 200.0),
at(0),
16.0,
16.0,
false,
true,
);
let inner_first = [(DOM, node(9)), (DOM, node(0))];
assert_eq!(
m.select_scroll_target(inner_first.into_iter(), 0.0, 0.0),
Some((DOM, node(9)))
);
assert_eq!(
m.select_scroll_target(inner_first.into_iter(), f32::NAN, f32::NAN),
Some((DOM, node(9)))
);
assert_eq!(
m.select_scroll_target(inner_first.into_iter(), 0.0, f32::INFINITY),
Some((DOM, node(9)))
);
}
#[test]
fn a11y_scroll_info_reports_travel_only_for_scrollable_nodes() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 120.0), at(1));
let (off, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
assert_eq!(off, pos(0.0, 120.0));
assert_eq!(max_x, 0.0);
assert_eq!(max_y, 400.0);
m.register_or_update_scroll_node(
DOM,
node(1),
rect(0.0, 0.0, 100.0, 100.0),
size(10.0, 10.0),
at(0),
16.0,
16.0,
false,
false,
);
assert!(m.a11y_scroll_info(DOM, node(1)).is_none());
assert!(m.a11y_scroll_info(DOM, node(404)).is_none());
assert!(m.a11y_scroll_info(DOM1, node(0)).is_none());
}
#[test]
fn a11y_scroll_info_uses_the_virtual_size() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
assert!(m.a11y_scroll_info(DOM, node(0)).is_none());
m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 1000.0), None);
let (_, max_x, max_y) = m.a11y_scroll_info(DOM, node(0)).unwrap();
assert_eq!(max_x, 0.0);
assert_eq!(max_y, 900.0);
}
#[test]
fn get_scroll_node_info_max_scroll_is_never_negative() {
let m = mgr(size(500.0, 500.0), size(10.0, 10.0));
let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
assert_eq!(info.max_scroll_x, 0.0, "underflow must clamp to 0, not go negative");
assert_eq!(info.max_scroll_y, 0.0);
assert_eq!(info.current_offset, LogicalPosition::zero());
assert!(m.get_scroll_node_info(DOM, node(1)).is_none());
}
#[test]
fn get_scroll_node_info_prefers_the_virtual_size_for_max_travel() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 200.0));
assert_eq!(m.get_scroll_node_info(DOM, node(0)).unwrap().max_scroll_y, 100.0);
m.update_virtual_scroll_bounds(DOM, node(0), size(600.0, 5000.0), Some(pos(1.0, 2.0)));
let info = m.get_scroll_node_info(DOM, node(0)).unwrap();
assert_eq!(info.max_scroll_x, 500.0);
assert_eq!(info.max_scroll_y, 4900.0);
assert_eq!(info.content_rect.size, size(100.0, 200.0));
}
#[test]
fn update_virtual_scroll_bounds_creates_a_state_for_an_unknown_node() {
let mut m = ScrollManager::new();
m.update_virtual_scroll_bounds(DOM, node(3), size(100.0, 9000.0), Some(pos(0.0, 4.0)));
assert_eq!(m.debug_counts(), (1, 0));
let s = m.get_scroll_state(DOM, node(3)).unwrap();
assert_eq!(s.virtual_scroll_size, Some(size(100.0, 9000.0)));
assert_eq!(s.virtual_scroll_offset, Some(pos(0.0, 4.0)));
assert_eq!(s.current_offset, LogicalPosition::zero());
assert!(m.is_node_scrollable(DOM, node(3)));
}
#[test]
fn update_virtual_scroll_bounds_re_clamps_a_shrinking_virtual_size() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 100.0));
m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 5000.0), None);
m.set_scroll_position(DOM, node(0), pos(0.0, 4900.0), at(1));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 4900.0)));
m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 300.0), None);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 200.0)));
}
#[test]
fn update_virtual_scroll_bounds_with_non_finite_size_does_not_panic() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
m.update_virtual_scroll_bounds(DOM, node(0), size(f32::NAN, f32::NAN), None);
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(!off.x.is_nan() && !off.y.is_nan());
assert_eq!(off, LogicalPosition::zero(), "NaN virtual size => zero travel");
assert!(!m.is_node_scrollable(DOM, node(0)));
m.update_virtual_scroll_bounds(DOM, node(0), size(0.0, f32::INFINITY), None);
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(!off.y.is_nan(), "infinite virtual height must not produce NaN");
}
#[test]
fn update_node_bounds_creates_the_state_and_re_clamps_a_shrinking_content() {
let mut m = ScrollManager::new();
m.update_node_bounds(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
rect(0.0, 0.0, 100.0, 500.0),
at(0),
);
assert_eq!(m.debug_counts(), (1, 0));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(LogicalPosition::zero()));
m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
m.clear_scroll_dirty();
m.update_node_bounds(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
rect(0.0, 0.0, 100.0, 150.0),
at(2),
);
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 50.0)));
assert!(!m.has_pending_scroll_changes());
}
#[test]
fn update_node_bounds_ignores_the_content_rect_origin() {
let mut m = ScrollManager::new();
m.update_node_bounds(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
rect(999.0, 999.0, 100.0, 500.0),
at(0),
);
m.set_scroll_position(DOM, node(0), pos(1e9, 1e9), at(1));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 400.0)));
}
#[test]
fn update_node_bounds_with_non_finite_rects_does_not_leak_nan() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 400.0), at(1));
m.update_node_bounds(
DOM,
node(0),
rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
at(2),
);
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(!off.x.is_nan() && !off.y.is_nan(), "NaN bounds must clamp to 0");
assert_eq!(off, LogicalPosition::zero());
m.update_node_bounds(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
at(3),
);
let off = m.get_current_offset(DOM, node(0)).unwrap();
assert!(off.x.is_finite() && off.y.is_finite());
}
#[test]
fn get_scroll_states_for_dom_filters_by_dom_and_reports_the_live_offset() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 42.0), at(1));
m.register_or_update_scroll_node(
DOM1,
node(0),
rect(0.0, 0.0, 10.0, 10.0),
size(10.0, 100.0),
at(0),
16.0,
16.0,
false,
true,
);
let states = m.get_scroll_states_for_dom(DOM);
assert_eq!(states.len(), 1, "other DOMs must not leak in");
let sp = states.get(&node(0)).unwrap();
assert_eq!(sp.parent_rect, rect(0.0, 0.0, 100.0, 100.0));
assert_eq!(sp.children_rect.origin, pos(0.0, 42.0));
assert_eq!(sp.children_rect.size, size(100.0, 500.0));
assert!(m.get_scroll_states_for_dom(DomId { inner: 99 }).is_empty());
}
#[test]
fn get_scroll_states_for_dom_uses_the_virtual_size_as_children_rect() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 120.0));
m.update_virtual_scroll_bounds(DOM, node(0), size(100.0, 8000.0), None);
let states = m.get_scroll_states_for_dom(DOM);
assert_eq!(states.get(&node(0)).unwrap().children_rect.size, size(100.0, 8000.0));
}
#[test]
fn build_scroll_offset_map_only_emits_nodes_present_in_scroll_ids() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 25.0), at(1));
m.register_or_update_scroll_node(
DOM,
node(4),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 500.0),
at(0),
16.0,
16.0,
false,
true,
);
m.register_or_update_scroll_node(
DOM1,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 500.0),
at(0),
16.0,
16.0,
false,
true,
);
assert!(m.build_scroll_offset_map(DOM, &HashMap::new()).is_empty());
let mut ids: HashMap<usize, u64> = HashMap::new();
ids.insert(0, 100); ids.insert(7, 700); let map = m.build_scroll_offset_map(DOM, &ids);
assert_eq!(map.len(), 1, "node 4 has no scroll_id; DOM1 is a different dom");
assert_eq!(map.get(&100), Some(&(0.0, 25.0)));
assert!(!map.contains_key(&700));
}
#[test]
fn find_scroll_parent_walks_up_to_the_nearest_registered_ancestor() {
let hierarchy = [
NodeHierarchyItem { parent: 0, previous_sibling: 0, next_sibling: 0, last_child: 2 },
NodeHierarchyItem { parent: 1, previous_sibling: 0, next_sibling: 0, last_child: 3 },
NodeHierarchyItem { parent: 2, previous_sibling: 0, next_sibling: 0, last_child: 0 },
];
let m = mgr(size(100.0, 100.0), size(100.0, 500.0)); assert_eq!(
m.find_scroll_parent(DOM, node(2), &hierarchy),
Some(node(0)),
"must skip the unregistered node 1 and find the root scroll container"
);
assert_eq!(m.find_scroll_parent(DOM, node(0), &hierarchy), None);
assert_eq!(m.find_scroll_parent(DOM1, node(2), &hierarchy), None);
}
#[test]
fn find_scroll_parent_handles_empty_and_out_of_range_hierarchies() {
let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
assert_eq!(m.find_scroll_parent(DOM, node(0), &[]), None);
assert_eq!(m.find_scroll_parent(DOM, node(9999), &[]), None);
let hierarchy = [NodeHierarchyItem::zeroed()];
assert_eq!(m.find_scroll_parent(DOM, node(9999), &hierarchy), None);
}
#[test]
fn calculate_scrollbar_states_is_idempotent_and_only_for_overflowing_axes() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.calculate_scrollbar_states();
assert_eq!(m.debug_counts(), (1, 1), "only the vertical axis overflows");
assert!(m
.get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
.is_some());
assert!(m
.get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Horizontal)
.is_none());
for _ in 0..10 {
m.calculate_scrollbar_states();
}
assert_eq!(m.debug_counts(), (1, 1), "per-frame recompute must not accumulate");
assert_eq!(m.iter_scrollbar_states().count(), 1);
}
#[test]
fn calculate_scrollbar_states_drops_bars_once_the_content_fits() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.calculate_scrollbar_states();
assert_eq!(m.debug_counts().1, 1);
m.register_or_update_scroll_node(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 50.0),
at(1),
16.0,
16.0,
false,
false,
);
m.calculate_scrollbar_states();
assert_eq!(m.debug_counts().1, 0);
assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
}
#[test]
fn calculate_scrollbar_states_produces_finite_geometry_for_both_axes() {
let mut m = ScrollManager::new();
m.register_or_update_scroll_node(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(1000.0, 1000.0),
at(0),
16.0,
16.0,
true,
true,
);
m.calculate_scrollbar_states();
assert_eq!(m.debug_counts(), (1, 2), "both axes overflow");
for (_, sb) in m.iter_scrollbar_states() {
assert!(sb.visible);
assert!(sb.base_size.is_finite() && sb.base_size > 0.0);
assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite());
assert!(sb.thumb_length.is_finite());
assert!(sb.thumb_offset.is_finite());
assert!(sb.usable_track_length.is_finite());
assert!(sb.track_rect.size.width.is_finite());
assert!(sb.track_rect.size.height.is_finite());
}
}
#[test]
fn calculate_scrollbar_states_zero_thickness_falls_back_to_the_default_width() {
let mut m = ScrollManager::new();
m.register_or_update_scroll_node(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 400.0),
at(0),
0.0, 0.0, false,
true,
);
m.calculate_scrollbar_states();
let sb = m
.get_scrollbar_state(DOM, node(0), ScrollbarOrientation::Vertical)
.unwrap();
assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
assert_eq!(sb.button_size, 0.0, "overlay scrollbars have no arrow buttons");
assert!(sb.scale.x.is_finite() && sb.scale.y.is_finite(), "no div-by-zero");
}
#[test]
fn calculate_scrollbar_state_from_geometry_survives_nan_input() {
let mut s = state(size(f32::NAN, f32::NAN), size(f32::NAN, f32::NAN));
s.scrollbar_thickness = f32::NAN;
s.visual_width_px = f32::NAN;
let sb = ScrollManager::calculate_scrollbar_state_from_geometry(
&s,
ScrollbarOrientation::Vertical,
);
assert!(sb.visible);
assert_eq!(sb.base_size, crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX);
assert_eq!(sb.usable_track_length, 0.0);
assert_eq!(sb.thumb_length, 0.0);
assert_eq!(sb.thumb_offset, 0.0);
assert_eq!(sb.thumb_position_ratio, 0.0);
assert!(
sb.scale.y.is_nan(),
"NaN container height still leaks into ScrollbarState::scale"
);
assert_eq!(
sb.hit_test_component(pos(0.0, 5.0)),
ScrollbarComponent::TopButton
);
}
#[test]
fn hit_test_scrollbars_finds_the_vertical_bar_and_reports_local_coords() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.calculate_scrollbar_states();
let hit = m.hit_test_scrollbars(pos(90.0, 50.0)).expect("inside the track");
assert_eq!(hit.dom_id, DOM);
assert_eq!(hit.node_id, node(0));
assert_eq!(hit.orientation, ScrollbarOrientation::Vertical);
assert_eq!(hit.global_position, pos(90.0, 50.0));
assert_eq!(hit.local_position, pos(6.0, 50.0), "local = global - track origin");
assert!(m.hit_test_scrollbars(pos(10.0, 50.0)).is_none());
let hit2 = m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).unwrap();
assert_eq!(hit2.local_position, hit.local_position);
assert_eq!(hit2.component, hit.component);
assert!(m.hit_test_scrollbar(DOM, node(1), pos(90.0, 50.0)).is_none());
}
#[test]
fn hit_test_scrollbars_rejects_non_finite_and_out_of_range_positions() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.calculate_scrollbar_states();
for p in [
pos(f32::NAN, f32::NAN),
pos(f32::INFINITY, f32::INFINITY),
pos(f32::NEG_INFINITY, f32::NEG_INFINITY),
pos(f32::MAX, f32::MAX),
pos(f32::MIN, f32::MIN),
pos(-1.0, -1.0),
pos(0.0, 0.0),
] {
assert!(
m.hit_test_scrollbars(p).is_none(),
"position {p:?} must not hit the 84..100 x 0..100 track"
);
assert!(m.hit_test_scrollbar(DOM, node(0), p).is_none());
}
}
#[test]
fn hit_test_scrollbars_before_calculate_returns_none() {
let m = mgr(size(100.0, 100.0), size(100.0, 500.0));
assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
}
#[test]
fn hit_test_scrollbars_skips_invisible_bars() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.calculate_scrollbar_states();
m.scrollbar_states
.get_mut(&(DOM, node(0), ScrollbarOrientation::Vertical))
.unwrap()
.visible = false;
assert!(m.hit_test_scrollbars(pos(90.0, 50.0)).is_none());
assert!(m.hit_test_scrollbar(DOM, node(0), pos(90.0, 50.0)).is_none());
}
#[test]
fn record_scroll_input_reports_start_timer_only_on_the_first_pending_event() {
let mut m = ScrollManager::new();
assert!(m.record_scroll_input(input(0.0, 1.0, 1)), "queue was empty => start");
assert!(!m.record_scroll_input(input(0.0, 1.0, 2)), "timer already running");
let _ = m.get_input_queue().take_all();
assert!(m.record_scroll_input(input(0.0, 1.0, 3)), "drained => start again");
}
#[test]
fn record_scroll_input_applies_the_sign_to_extreme_deltas_without_overflow() {
let mut m = ScrollManager::new();
m.record_scroll_input(input(f32::MAX, f32::INFINITY, 1));
m.record_scroll_input(input(f32::NAN, f32::MIN, 2));
let q = m.get_input_queue().take_all();
assert_eq!(q[0].delta.x, -f32::MAX, "sign flip must not overflow");
assert_eq!(q[0].delta.y, f32::NEG_INFINITY);
assert!(q[1].delta.x.is_nan(), "NaN * -1 stays NaN, no panic");
assert_eq!(q[1].delta.y, f32::MAX);
}
#[test]
fn record_scroll_from_hit_test_records_the_wheel_delta_even_with_no_hover() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
let hover = HoverManager::new(); let out = m.record_scroll_from_hit_test(
3.0,
-7.0,
ScrollInputSource::WheelDiscrete,
&hover,
&InputPointId::Mouse,
at(1),
);
assert!(out.is_none(), "no hover => no scroll target");
assert_eq!(m.pending_wheel_event, Some(pos(3.0, -7.0)), "raw delta is recorded");
assert!(!m.get_input_queue().has_pending(), "nothing queued for physics");
}
#[test]
fn record_scroll_from_hit_test_queues_the_raw_delta_and_signals_the_timer() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
let hover = hover_over(&[0]);
let (dom_id, node_id, start_timer) = m
.record_scroll_from_hit_test(
0.0,
-10.0, ScrollInputSource::WheelDiscrete,
&hover,
&InputPointId::Mouse,
at(1),
)
.expect("node 0 is scrollable and under the cursor");
assert_eq!((dom_id, node_id), (DOM, node(0)));
assert!(start_timer, "first queued input must start the physics timer");
assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
let (_, _, start_again) = m
.record_scroll_from_hit_test(
0.0,
-10.0,
ScrollInputSource::WheelDiscrete,
&hover,
&InputPointId::Mouse,
at(2),
)
.unwrap();
assert!(!start_again);
let q = m.get_input_queue().take_all();
assert_eq!(q.len(), 2);
assert_eq!(q[0].delta.y, 10.0, "raw -10 * traditional sign (-1) = +10");
assert_eq!(q[0].source, ScrollInputSource::WheelDiscrete);
assert_eq!(q[0].timestamp, at(1));
}
#[test]
fn record_scroll_from_hit_test_ignores_hovered_nodes_that_cannot_scroll() {
let mut m = ScrollManager::new();
m.register_or_update_scroll_node(
DOM,
node(0),
rect(0.0, 0.0, 100.0, 100.0),
size(100.0, 100.0),
at(0),
16.0,
16.0,
false,
false,
);
let hover = hover_over(&[0]);
let out = m.record_scroll_from_hit_test(
0.0,
-10.0,
ScrollInputSource::WheelDiscrete,
&hover,
&InputPointId::Mouse,
at(1),
);
assert!(out.is_none(), "a non-overflowing node must not swallow the wheel");
assert_eq!(m.pending_wheel_event, Some(pos(0.0, -10.0)));
assert!(!m.get_input_queue().has_pending());
}
#[test]
fn record_scroll_from_hit_test_with_non_finite_deltas_does_not_panic() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
let hover = hover_over(&[0]);
let out = m.record_scroll_from_hit_test(
f32::NAN,
f32::NAN,
ScrollInputSource::TrackpadContinuous,
&hover,
&InputPointId::Mouse,
at(1),
);
assert_eq!(out.map(|(d, n, _)| (d, n)), Some((DOM, node(0))));
assert!(m.pending_wheel_event.unwrap().x.is_nan());
let q = m.get_input_queue().take_all();
assert_eq!(q.len(), 1);
assert!(q[0].delta.x.is_nan(), "NaN is queued verbatim, no panic");
let out = m.record_scroll_from_hit_test(
0.0,
f32::NEG_INFINITY,
ScrollInputSource::WheelDiscrete,
&hover,
&InputPointId::Mouse,
at(2),
);
assert!(out.is_some());
let q = m.get_input_queue().take_all();
assert_eq!(q[0].delta.y, f32::INFINITY, "-inf * -1 = +inf");
}
#[test]
fn record_scroll_from_hit_test_picks_the_innermost_scrollable_under_the_cursor() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.register_or_update_scroll_node(
DOM,
node(5),
rect(0.0, 0.0, 50.0, 50.0),
size(50.0, 200.0),
at(0),
16.0,
16.0,
false,
true,
);
let hover = hover_over(&[0, 5]);
let (_, node_id, _) = m
.record_scroll_from_hit_test(
0.0,
-10.0,
ScrollInputSource::WheelDiscrete,
&hover,
&InputPointId::Mouse,
at(1),
)
.unwrap();
assert_eq!(node_id, node(5), "innermost (deepest) scrollable wins");
}
#[test]
fn getters_agree_with_the_recorded_state() {
let mut m = mgr(size(100.0, 100.0), size(100.0, 500.0));
m.set_scroll_position(DOM, node(0), pos(0.0, 33.0), at(7));
assert_eq!(m.get_current_offset(DOM, node(0)), Some(pos(0.0, 33.0)));
assert_eq!(m.get_last_activity_time(DOM, node(0)), Some(at(7)));
let s = m.get_scroll_state(DOM, node(0)).unwrap();
assert_eq!(s.current_offset, pos(0.0, 33.0));
assert!(s.animation.is_none());
assert!(m.get_current_offset(DOM1, node(0)).is_none());
assert!(m.get_last_activity_time(DOM, node(1)).is_none());
assert!(m.get_scroll_state(DOM1, node(1)).is_none());
}
#[test]
fn get_input_queue_hands_out_a_shared_handle() {
let mut m = ScrollManager::new();
let q = m.get_input_queue();
assert!(!q.has_pending());
m.record_scroll_input(input(0.0, 1.0, 1));
assert!(q.has_pending(), "the handle must observe pushes made by the manager");
assert_eq!(q.take_all().len(), 1);
assert!(
!m.get_input_queue().has_pending(),
"draining the handle drains the manager's queue"
);
}
}