azul_layout/managers/scroll_state.rs
1//! Pure scroll state management — the single source of truth for scroll offsets.
2//!
3//! # Architecture
4//!
5//! `ScrollManager` is the exclusive owner of all scroll state. Other modules
6//! interact with scrolling only through its public API:
7//!
8//! - **Platform shell** (macos/events.rs, etc.): Calls `record_scroll_from_hit_test()`
9//! to queue trackpad/mouse wheel input for the physics timer.
10//! - **Scroll physics timer** (`scroll_timer.rs)`: Consumes inputs via `ScrollInputQueue`,
11//! applies physics, and pushes `CallbackChange::ScrollTo` for each updated node.
12//! - **Event processing** (`event_v2.rs)`: Processes `ScrollTo` changes, sets scroll
13//! positions, and checks `VirtualView` re-invocation transparently.
14//! - **Gesture manager** (gesture.rs): Tracks drag state and emits
15//! `AutoScrollDirection` — does NOT modify scroll offsets directly.
16//! - **Render loop**: Calls `tick()` every frame to advance easing animations.
17//! - **`WebRender` sync** (`wr_translate2.rs)`: Reads offsets via
18//! `get_scroll_states_for_dom()` to synchronize scroll frames.
19//! - **Layout** (cache.rs): Registers scroll nodes via
20//! `register_or_update_scroll_node()` after layout completes.
21//!
22//! # Scroll Flow
23//!
24//! ```text
25//! Platform Event Handler
26//! → record_scroll_from_hit_test() → ScrollInputQueue
27//! → starts SCROLL_MOMENTUM_TIMER_ID if not running
28//!
29//! Timer fires (every ~16ms):
30//! → queue.take_all() → physics integration
31//! → push_change(CallbackChange::ScrollTo)
32//!
33//! ScrollTo processing (event_v2.rs):
34//! → scroll_manager.set_scroll_position()
35//! → virtual_view_manager.check_reinvoke() (transparent VirtualView support)
36//! → repaint
37//! ```
38//!
39//! This module provides:
40//! - Smooth scroll animations with easing
41//! - Event source classification for scroll events
42//! - Scrollbar geometry and hit-testing
43//! - Virtual scroll bounds for `VirtualView` nodes
44
45use 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
64/// Minimum change in scroll offset (in logical pixels) to consider the position
65/// "actually moved" and mark the scroll state dirty.
66const SCROLL_CHANGE_EPSILON: f32 = 0.01;
67
68// ============================================================================
69// Scroll Input Types (for timer-based physics architecture)
70// ============================================================================
71
72/// Classifies the source of a scroll input event.
73///
74/// This determines how the scroll physics timer processes the input:
75/// - `TrackpadContinuous`: The OS already applies momentum — set position directly
76/// - `WheelDiscrete`: Mouse wheel clicks — apply as impulse with momentum decay
77/// - `Programmatic`: API-driven scroll — apply with optional easing animation
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ScrollInputSource {
80 /// Continuous trackpad gesture (macOS precise scrolling).
81 /// Position is set directly — the OS handles momentum/physics.
82 TrackpadContinuous,
83 /// Trackpad gesture ended (fingers lifted off trackpad).
84 /// Triggers spring-back if the scroll position is past the bounds
85 /// (rubber-banding overshoot). The OS sends this when
86 /// `NSEventPhaseEnded` or momentumPhaseEnded is detected.
87 TrackpadEnd,
88 /// Discrete mouse wheel steps (Windows/Linux mouse wheel).
89 /// Applied as velocity impulse with momentum decay.
90 WheelDiscrete,
91 /// Programmatic scroll (scrollTo API, keyboard Page Up/Down).
92 /// Applied with optional easing animation.
93 Programmatic,
94}
95
96/// A single scroll input event to be processed by the physics timer.
97///
98/// Scroll inputs are recorded by the platform event handler and consumed
99/// by the scroll physics timer callback. This decouples input recording
100/// from physics simulation.
101#[derive(Debug, Clone)]
102pub struct ScrollInput {
103 /// DOM containing the scrollable node
104 pub dom_id: DomId,
105 /// Target scroll node
106 pub node_id: NodeId,
107 /// Scroll delta (positive = scroll down/right)
108 pub delta: LogicalPosition,
109 /// When this input was recorded
110 pub timestamp: Instant,
111 /// How this input should be processed
112 pub source: ScrollInputSource,
113}
114
115/// Thread-safe queue for scroll inputs, shared between event handlers and timer callbacks.
116///
117/// Event handlers push inputs, the physics timer pops them. Protected by a Mutex
118/// so that the timer callback (which only has `&CallbackInfo` / `*const LayoutWindow`)
119/// can still consume pending inputs without needing `&mut`.
120#[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 /// Push a new scroll input (called from platform event handler)
135 pub fn push(&self, input: ScrollInput) {
136 if let Ok(mut queue) = self.inner.lock() {
137 queue.push(input);
138 }
139 }
140
141 /// Take all pending inputs (called from timer callback)
142 #[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 /// Take at most `max_events` recent inputs, sorted by timestamp (newest last).
150 /// Any older events beyond `max_events` are discarded.
151 /// This prevents the physics timer from processing an unbounded backlog.
152 #[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 // Sort by timestamp ascending (oldest first), keep last N
159 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 /// Check if there are pending inputs without consuming them
168 #[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// Scrollbar Component Types
177
178/// Which component of a scrollbar was hit during hit-testing
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum ScrollbarComponent {
181 /// The track (background) of the scrollbar
182 Track,
183 /// The draggable thumb (indicator of current scroll position)
184 Thumb,
185 /// Top/left button (scrolls by one page up/left)
186 TopButton,
187 /// Bottom/right button (scrolls by one page down/right)
188 BottomButton,
189}
190
191/// Scrollbar geometry state (calculated per frame, used for hit-testing and rendering)
192#[derive(Copy, Debug, Clone)]
193pub struct ScrollbarState {
194 /// Is this scrollbar visible? (content larger than container)
195 pub visible: bool,
196 /// Orientation
197 pub orientation: ScrollbarOrientation,
198 /// Base size (1:1 square, width = height). This is the unscaled size.
199 pub base_size: f32,
200 /// Scale transform to apply (calculated from container size)
201 pub scale: LogicalPosition, // x = width scale, y = height scale
202 /// Thumb position ratio (0.0 = top/left, 1.0 = bottom/right)
203 pub thumb_position_ratio: f32,
204 /// Thumb size ratio (0.0 = invisible, 1.0 = entire track)
205 pub thumb_size_ratio: f32,
206 /// Position of the scrollbar in the container (for hit-testing)
207 pub track_rect: LogicalRect,
208 /// Button size (square: `button_size` × `button_size`)
209 pub button_size: f32,
210 /// Usable track length after subtracting buttons
211 pub usable_track_length: f32,
212 /// Thumb length in pixels
213 pub thumb_length: f32,
214 /// Thumb offset from start of usable track region
215 pub thumb_offset: f32,
216}
217
218impl ScrollbarState {
219 /// Determine which component was hit at the given local position (relative to `track_rect`
220 /// origin). Uses the shared geometry values (`button_size`, `usable_track_length`, `thumb_length`,
221 /// `thumb_offset`) for consistent hit-testing.
222 #[must_use] pub fn hit_test_component(&self, local_pos: LogicalPosition) -> ScrollbarComponent {
223 match self.orientation {
224 ScrollbarOrientation::Vertical => {
225 // Top button
226 if local_pos.y < self.button_size {
227 return ScrollbarComponent::TopButton;
228 }
229
230 // Bottom button
231 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 // Thumb region starts after top button
237 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 // Left button
248 if local_pos.x < self.button_size {
249 return ScrollbarComponent::TopButton;
250 }
251
252 // Right button
253 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 // Thumb region starts after left button
259 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/// Result of a scrollbar hit-test
273///
274/// Contains information about which scrollbar component was hit
275/// and the position relative to both the track and the window.
276#[derive(Debug, Clone, Copy)]
277pub struct ScrollbarHit {
278 /// DOM containing the scrollable node
279 pub dom_id: DomId,
280 /// Node with the scrollbar
281 pub node_id: NodeId,
282 /// Whether this is a vertical or horizontal scrollbar
283 pub orientation: ScrollbarOrientation,
284 /// Which component was hit (track, thumb, buttons)
285 pub component: ScrollbarComponent,
286 /// Position relative to `track_rect` origin
287 pub local_position: LogicalPosition,
288 /// Original global window position
289 pub global_position: LogicalPosition,
290}
291
292// Core Scroll Manager
293
294/// Manages all scroll state and animations for a window
295#[derive(Debug, Clone, Default)]
296pub struct ScrollManager {
297 /// Maps (`DomId`, `NodeId`) to their scroll state
298 states: BTreeMap<(DomId, NodeId), AnimatedScrollState>,
299 /// Scrollbar geometry states (calculated per frame)
300 scrollbar_states: BTreeMap<(DomId, NodeId, ScrollbarOrientation), ScrollbarState>,
301 /// Thread-safe queue for scroll inputs (shared with timer callbacks)
302 #[cfg(feature = "std")]
303 pub scroll_input_queue: ScrollInputQueue,
304 /// Raw wheel/trackpad delta recorded *this input pass*, regardless of whether
305 /// a scrollable node was under the cursor. The scroll input queue only carries
306 /// deltas destined for scrollable containers (consumed by the physics timer);
307 /// this field additionally lets `determine_all_events` synthesize a `Scroll`
308 /// event aimed at the hovered node so non-scroll-container widgets (e.g. the
309 /// map, which treats wheel = zoom) can react via a `HoverEventFilter::Scroll`
310 /// callback + `CallbackInfo::get_scroll_delta`. Set in
311 /// [`Self::record_scroll_from_hit_test`]; read during event determination and
312 /// callback dispatch, then cleared at the end of the pass.
313 pub pending_wheel_event: Option<LogicalPosition>,
314 /// Set when a scroll position changes; cleared after the display list
315 /// is regenerated. Used by the CPU renderer path to detect when the
316 /// display list must be rebuilt even though the DOM hasn't changed.
317 scroll_dirty: bool,
318 /// Scroll-direction preference, applied ONCE in [`Self::record_scroll_input`]
319 /// (the single chokepoint every platform's wheel/axis event flows through).
320 ///
321 /// `false` (default) = traditional desktop wheel: a raw "scroll down" event
322 /// increases the offset (content moves up). `true` = natural: inverted.
323 /// Replaces the per-platform hardcoded `-delta` negations so the sign lives
324 /// in one configurable place ([`Self::set_natural_scroll`]).
325 ///
326 /// CAVEAT: on macOS and on Linux touchpads via libinput the OS/driver ALREADY
327 /// applies the user's natural-scroll preference before azul sees the delta, so
328 /// this flag must stay at its default there (we preserve current behavior) and
329 /// primarily controls mouse-wheel direction on platforms that don't pre-apply.
330 natural_scroll: bool,
331}
332
333/// The complete scroll state for a single node (with animation support)
334#[derive(Debug, Clone)]
335pub struct AnimatedScrollState {
336 /// Current scroll offset (live, may be animating)
337 pub current_offset: LogicalPosition,
338 /// Ongoing smooth scroll animation, if any
339 pub animation: Option<ScrollAnimation>,
340 /// Last time scroll activity occurred (for fading scrollbars)
341 pub last_activity: Instant,
342 /// Bounds of the scrollable container
343 pub container_rect: LogicalRect,
344 /// Bounds of the total content (for calculating scroll limits)
345 pub content_rect: LogicalRect,
346 /// Virtual scroll size from `VirtualView` callback (if this node hosts a `VirtualView`).
347 /// When set, clamp logic uses this instead of `content_rect` for max scroll bounds.
348 pub virtual_scroll_size: Option<LogicalSize>,
349 /// Virtual scroll offset from `VirtualView` callback
350 pub virtual_scroll_offset: Option<LogicalPosition>,
351 /// Per-node overscroll behavior for X axis (from CSS `overscroll-behavior-x`)
352 pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
353 /// Per-node overscroll behavior for Y axis (from CSS `overscroll-behavior-y`)
354 pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
355 /// Per-node overflow scrolling mode (from CSS `-azul-overflow-scrolling`)
356 pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
357 /// CSS-resolved scrollbar thickness (from `scrollbar-width` property).
358 /// Used for rendering and hit-testing. Defaults to 16.0 if not set.
359 pub scrollbar_thickness: f32,
360 /// Visual rendering width in CSS pixels (e.g. 8.0 for thin overlay).
361 /// Non-zero even for overlay scrollbars. Falls back to `scrollbar_thickness` if 0.
362 pub visual_width_px: f32,
363 /// Whether this node also needs a horizontal scrollbar (affects vertical geometry)
364 pub has_horizontal_scrollbar: bool,
365 /// Whether this node also needs a vertical scrollbar (affects horizontal geometry)
366 pub has_vertical_scrollbar: bool,
367}
368
369/// Details of an in-progress smooth scroll animation
370#[derive(Debug, Clone)]
371struct ScrollAnimation {
372 /// When the animation started
373 start_time: Instant,
374 /// Total duration of the animation
375 duration: Duration,
376 /// Scroll offset at animation start
377 start_offset: LogicalPosition,
378 /// Target scroll offset at animation end
379 target_offset: LogicalPosition,
380 /// Easing function for interpolation
381 easing: EasingFunction,
382}
383
384/// Read-only snapshot of a scroll node's state, returned by `CallbackInfo` queries.
385///
386/// Provides all the information a timer callback needs to compute scroll physics
387/// without requiring mutable access to the `ScrollManager`.
388#[derive(Copy, Debug, Clone)]
389pub struct ScrollNodeInfo {
390 /// Current scroll offset
391 pub current_offset: LogicalPosition,
392 /// Container (viewport) bounds
393 pub container_rect: LogicalRect,
394 /// Content bounds (total scrollable area)
395 pub content_rect: LogicalRect,
396 /// Maximum scroll in X direction
397 pub max_scroll_x: f32,
398 /// Maximum scroll in Y direction
399 pub max_scroll_y: f32,
400 /// Per-node overscroll behavior for X axis
401 pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
402 /// Per-node overscroll behavior for Y axis
403 pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
404 /// Per-node overflow scrolling mode (auto vs touch)
405 pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
406}
407
408/// Result of a scroll tick, indicating what actions are needed
409#[derive(Debug, Default)]
410pub struct ScrollTickResult {
411 /// If true, a repaint is needed (scroll offset changed)
412 pub needs_repaint: bool,
413 /// Nodes whose scroll position was updated this tick
414 pub updated_nodes: Vec<(DomId, NodeId)>,
415}
416
417// ScrollManager Implementation
418
419impl ScrollManager {
420 /// Creates a new empty `ScrollManager`
421 #[must_use] pub fn new() -> Self {
422 let mut m = Self::default();
423 // Power-user / test override. Platform shells should call
424 // `set_natural_scroll` from the OS preference; this env var wins so the
425 // direction can be flipped without a rebuild and so tests are hermetic.
426 #[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 /// Set the scroll-direction preference. `true` = natural (content follows the
434 /// gesture / inverted from the traditional wheel). Platform shells call this
435 /// from the detected OS preference. See the `natural_scroll` field docs for the
436 /// macOS/libinput pre-application caveat.
437 pub const fn set_natural_scroll(&mut self, natural: bool) {
438 self.natural_scroll = natural;
439 }
440
441 /// Current scroll-direction preference (`true` = natural/inverted).
442 #[must_use] pub const fn is_natural_scroll(&self) -> bool {
443 self.natural_scroll
444 }
445
446 /// The sign applied to a raw input delta to get the offset delta:
447 /// `-1.0` traditional (default), `+1.0` natural. Centralises what used to be a
448 /// hardcoded `-delta` at every platform call site.
449 #[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 /// Sizes of the internal maps — used by `AZ_E2E_TEST` to watch for
459 /// unbounded growth across resize/tick iterations.
460 #[must_use] pub fn debug_counts(&self) -> (usize, usize) {
461 (self.states.len(), self.scrollbar_states.len())
462 }
463
464 /// Returns `true` if any scroll position changed since the last
465 /// `clear_scroll_dirty()` call.
466 pub(crate) const fn has_pending_scroll_changes(&self) -> bool {
467 self.scroll_dirty
468 }
469
470 /// Clear the dirty flag after the display list has been regenerated.
471 pub const fn clear_scroll_dirty(&mut self) {
472 self.scroll_dirty = false;
473 }
474
475 /// Build a map from `scroll_id` (`LocalScrollId`) to current scroll offset.
476 ///
477 /// Used by the CPU renderer to look up scroll positions at render time
478 /// without embedding them in the display list.
479 ///
480 /// `scroll_ids` maps layout-tree node index → `scroll_id`. We need to
481 /// convert our (`DomId`, `NodeId`) keys to `scroll_ids`.
482 #[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 // Find the scroll_id for this node_id by searching scroll_ids
491 // (scroll_ids maps layout_index → scroll_id, and node_id.index() == layout_index
492 // for the root DOM)
493 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 // ========================================================================
502 // Input Recording API (timer-based architecture)
503 // ========================================================================
504
505 /// Records a scroll input event into the shared queue.
506 ///
507 /// This is the primary entry point for platform event handlers. Instead of
508 /// directly modifying scroll positions, the input is queued for the scroll
509 /// physics timer to process. This decouples input from physics simulation.
510 ///
511 /// The scroll-direction sign ([`Self::scroll_sign`]) is applied HERE — the
512 /// single chokepoint every wheel/axis event flows through — so platform shells
513 /// pass the RAW delta and no longer hardcode `-delta` at each call site.
514 ///
515 /// Returns `true` if the physics timer should be started (i.e., there are
516 /// now pending inputs and no timer is running yet).
517 #[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 // caller should start timer if this returns true
525 }
526
527 /// High-level entry point for platform event handlers: performs hit-test lookup
528 /// and queues the input for the physics timer, instead of directly modifying offsets.
529 ///
530 /// Returns `Some((dom_id, node_id, should_start_timer))` if a scrollable node was found.
531 /// The caller should start `SCROLL_MOMENTUM_TIMER_ID` when `should_start_timer` is true.
532 #[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 // Record the raw wheel delta for this pass unconditionally — even when the
543 // cursor isn't over a scroll container — so a `Scroll` event can be aimed
544 // at the hovered node (wheel-as-zoom widgets like the map rely on this).
545 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 // MWA-B2: nested scroll containers — innermost-first with boundary
550 // handoff. The previous ascending iteration always picked the
551 // OUTERMOST scrollable ancestor (BTreeMap keys ascend; ancestors
552 // have lower arena NodeIds), so wheeling over a list inside a
553 // scrollable page scrolled the page instead of the list. We now
554 // walk innermost-first and give the event to the first candidate
555 // that can still move in the delta's direction (the web's default
556 // overscroll handoff); when every candidate is pinned, the
557 // innermost scrollable wins so the gesture still targets the node
558 // under the pointer.
559 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 // Raw delta — record_scroll_input applies scroll_sign() itself.
577 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 /// MWA-B2: choose the scroll node a wheel/trackpad event should drive.
586 ///
587 /// `candidates` must be ordered innermost-first; `eff_x`/`eff_y` are the
588 /// direction-normalized deltas (post `scroll_sign()`: positive = offset
589 /// grows = view moves toward content's down/right). The first candidate
590 /// with remaining travel in a moved direction wins; if every candidate
591 /// is pinned, the innermost scrollable is returned so the gesture still
592 /// anchors under the pointer (matches CSS default overscroll behavior).
593 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 /// MWA-B10: the a11y tree's scroll surface for a node — current offset
618 /// plus max travel per axis, or `None` when the node isn't scrollable.
619 /// Screen readers use this (with the ScrollUp/Down/... actions) to
620 /// drive the same inbound handler mouse users exercise.
621 #[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 /// `true` when the node still has travel in the direction of the
642 /// normalized delta on at least one moved axis — the boundary-handoff
643 /// test for [`select_scroll_target`](Self::select_scroll_target).
644 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 /// Get a clone of the scroll input queue (for sharing with timer callbacks).
683 ///
684 /// The timer callback stores this in its `RefAny` data and calls `take_all()`
685 /// each tick to consume pending inputs.
686 #[cfg(feature = "std")]
687 #[must_use] pub fn get_input_queue(&self) -> ScrollInputQueue {
688 self.scroll_input_queue.clone()
689 }
690
691 /// Advances scroll animations by one tick, returns repaint info
692 #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
693 // Instant is a ref-counted FFI clock handle; called by every dll backend's event loop by value.
694 #[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 /// Returns `true` if any scroll node has an active easing animation.
719 ///
720 /// Used by GPU render paths to skip rendering when the UI is completely
721 /// static (no scroll animations, no layout changes).
722 #[must_use] pub fn has_active_animations(&self) -> bool {
723 self.states.values().any(|s| s.animation.is_some())
724 }
725
726 /// Finds the closest scroll-container ancestor for a given node.
727 ///
728 /// Walks up the node hierarchy to find a node that is registered as a
729 /// scrollable node in this `ScrollManager`. Returns `None` if no scrollable
730 /// ancestor is found.
731 #[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 /// Check if a node is scrollable (has overflow:scroll/auto and overflowing content)
750 ///
751 /// Uses `virtual_scroll_size` (when set) instead of `content_rect` for the
752 /// overflow check, so `VirtualView` nodes with large virtual content are correctly
753 /// identified as scrollable even when only a small subset is rendered.
754 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 // +spec:overflow:4000a6 - scroll position as offset from scroll origin within scrollport
768 /// Sets scroll position immediately (no animation), clamped to valid bounds.
769 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 /// Sets scroll position immediately without clamping.
792 ///
793 /// Used by the scroll physics timer which does its own rubber-band clamping.
794 /// Allows the offset to go outside [0, `max_scroll`] for overscroll/rubber-banding.
795 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 /// Scrolls by a delta amount with animation
817 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 /// Scrolls to an absolute position with animation
835 ///
836 /// If duration is zero, the position is set immediately without animation.
837 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 // For zero duration, set position immediately
847 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 /// Updates the container and content bounds for a scrollable node
873 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 /// Updates virtual scroll bounds for a `VirtualView` node.
891 ///
892 /// Called after `VirtualView` callback returns to propagate the virtual content size
893 /// to the `ScrollManager`. Clamp logic then uses `virtual_scroll_size` (when set)
894 /// instead of `content_rect` for max scroll bounds.
895 ///
896 /// If no scroll state exists yet for this node (because `register_or_update_scroll_node`
897 /// hasn't been called yet), this creates a default state so the virtual size is preserved.
898 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 // AzInstant (System on std, safe Tick on no-clock targets) — not the
908 // WASM-panicking std::time::Instant::now(). (A refinement would thread
909 // the window's get_system_time_fn callback through for hookability.)
910 AnimatedScrollState::new(Instant::now())
911 });
912 state.virtual_scroll_size = Some(virtual_scroll_size);
913 state.virtual_scroll_offset = virtual_scroll_offset;
914 // Re-clamp with new virtual bounds
915 state.current_offset = state.clamp(state.current_offset);
916 }
917
918 /// Returns the current scroll offset for a node
919 #[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 /// Returns the timestamp of last scroll activity for a node
926 #[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 /// Returns the internal scroll state for a node
933 #[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 /// Returns a read-only snapshot of a scroll node's state.
938 ///
939 /// This is the preferred way for timer callbacks to query scroll state,
940 /// since they only have `&CallbackInfo` (read-only access).
941 ///
942 /// When `virtual_scroll_size` is set (for `VirtualView` nodes), the max scroll
943 /// bounds are computed from the virtual size instead of `content_rect`.
944 #[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 /// Returns all scroll positions for nodes in a specific DOM
969 #[must_use] pub fn get_scroll_states_for_dom(&self, dom_id: DomId) -> BTreeMap<NodeId, ScrollPosition> {
970 // M12.7: iterating an EMPTY hashbrown map (RawIterRange) mis-lifts to
971 // wasm and loops forever (same class as the font-id / GPU-cache loops).
972 // For the headless web path `states` is empty; guard it (len-based, no
973 // iteration). Desktop unchanged.
974 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 // Use virtual_scroll_size (from VirtualView callback) when available,
982 // otherwise fall back to content_rect.size from layout.
983 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 /// Registers or updates a scrollable node with its container and content sizes.
1000 /// This should be called after layout for each node that has overflow:scroll or overflow:auto
1001 /// with overflowing content.
1002 ///
1003 /// If the node already exists, updates container/content rects without changing scroll offset.
1004 /// If the node is new, initializes with zero scroll offset.
1005 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 // Update rects, keep scroll offset
1026 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 // Re-clamp current offset to new bounds
1033 existing.current_offset = existing.clamp(existing.current_offset);
1034 } else {
1035 // +spec:overflow:8c7aa1 - initial scroll position is zero (scroll origin for LTR/TTB)
1036 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 // Scrollbar State Management
1059
1060 /// Calculate scrollbar states for all visible scrollbars.
1061 /// This should be called once per frame after layout is complete.
1062 /// Uses the shared `compute_scrollbar_geometry()` for consistent geometry.
1063 pub fn calculate_scrollbar_states(&mut self) {
1064 self.scrollbar_states.clear();
1065
1066 // Uses virtual_scroll_size (when set) for the overflow check and thumb ratio,
1067 // so VirtualView nodes with large virtual content show correct scrollbar geometry.
1068 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 /// Calculate scrollbar state using the shared `compute_scrollbar_geometry()`.
1099 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 // Overlay scrollbars (thickness == 0 from layout) have no arrow buttons
1125 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 // Build ScrollbarState from the shared geometry
1138 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 /// Get scrollbar state for hit-testing
1163 #[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 /// Iterate over all visible scrollbar states
1173 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 // Scrollbar Hit-Testing
1180
1181 /// Hit-test scrollbars for a specific node at the given position.
1182 /// Returns Some if the position is inside a scrollbar for this node.
1183 pub(crate) fn hit_test_scrollbar(
1184 &self,
1185 dom_id: DomId,
1186 node_id: NodeId,
1187 global_pos: LogicalPosition,
1188 ) -> Option<ScrollbarHit> {
1189 // Check both vertical and horizontal scrollbars for this node
1190 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 // Check if position is inside scrollbar track using LogicalRect::contains
1203 if !scrollbar_state.track_rect.contains(global_pos) {
1204 continue;
1205 }
1206
1207 // Calculate local position relative to track origin
1208 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 // Determine which component was hit
1214 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 /// Perform hit-testing for all scrollbars at the given global position.
1230 ///
1231 /// This iterates through all visible scrollbars in reverse z-order (top to bottom)
1232 /// and returns the first hit. Use this when you don't know which node to check.
1233 ///
1234 /// For better performance, use `hit_test_scrollbar()` when you already have
1235 /// a hit-tested node from `WebRender`.
1236 #[must_use] pub fn hit_test_scrollbars(&self, global_pos: LogicalPosition) -> Option<ScrollbarHit> {
1237 // Iterate in reverse order to hit top-most scrollbars first
1238 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 // Check if position is inside scrollbar track
1245 if !scrollbar_state.track_rect.contains(global_pos) {
1246 continue;
1247 }
1248
1249 // Calculate local position relative to track origin
1250 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 // Determine which component was hit
1256 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
1272// AnimatedScrollState Implementation
1273
1274impl AnimatedScrollState {
1275 // +spec:overflow:60f6a1 - scroll origin defaults to block-start inline-start corner (0,0)
1276 /// Create a new scroll state initialized at offset (0, 0).
1277 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 /// Clamp a scroll position to valid bounds (0 to `max_scroll`).
1297 ///
1298 /// When `virtual_scroll_size` is set (for `VirtualView` nodes), the max bounds
1299 /// are computed from the virtual size instead of `content_rect`.
1300 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// Easing Functions
1315
1316/// Apply an easing function to a normalized time value (0.0 to 1.0).
1317/// Used by `ScrollAnimation::tick()` for smooth scroll animations.
1318#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
1319pub(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 /// Rewrite every `(DomId, NodeId)` key for `dom` and DROP the scroll state of
1335 /// nodes that were unmounted.
1336 ///
1337 /// The previous implementation only rewrote keys whose id actually changed and
1338 /// *kept* everything else "conservatively" — which silently re-attached the
1339 /// scroll offset of a deleted node to whatever node inherited its index.
1340 /// `node_moves` contains an entry for every matched node, so "absent from the
1341 /// map" unambiguously means "unmounted".
1342 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// ============================================================================
1359// Natural-scroll direction — unit tests (#17)
1360// ============================================================================
1361#[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)] // test asserts exact float equality on deterministic values
1380 fn default_is_traditional_and_inverts_raw_delta() {
1381 // With AZ_NATURAL_SCROLL unset, the default is traditional: the offset
1382 // delta is the NEGATION of the raw input — exactly what the per-platform
1383 // `-delta` hardcodes used to do, now centralised.
1384 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)] // test asserts exact float equality on deterministic values
1395 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)] // test asserts exact float equality on deterministic values
1408 fn toggling_flips_sign_for_subsequent_input() {
1409 // Same raw input, opposite directions before/after the toggle — proves the
1410 // single flag is the only thing controlling direction.
1411 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 // MWA-B2: nested-scroll target selection (innermost-first + handoff).
1422
1423 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 // Ancestors have LOWER arena ids than descendants.
1430 let outer = NodeId::from_usize(1).unwrap();
1431 let inner = NodeId::from_usize(9).unwrap();
1432 // Outer: 200x200 viewport over 200x1000 content → max_y = 800.
1433 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 // Inner: 100x100 viewport over 100x300 content → max_y = 200.
1448 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 // Innermost-first candidate order, scrolling "down" (eff +y).
1469 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 // Pin the inner container at its bottom edge (max_y = 200).
1481 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}