Skip to main content

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 IFrame 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//!   → iframe_manager.check_reinvoke() (transparent IFrame 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//! - ExternalScrollId mapping for WebRender integration
44//! - Virtual scroll bounds for IFrame nodes
45
46use alloc::collections::BTreeMap;
47#[cfg(feature = "std")]
48use alloc::vec::Vec;
49
50use azul_core::{
51    dom::{DomId, NodeId, ScrollbarOrientation},
52    events::EasingFunction,
53    geom::{LogicalPosition, LogicalRect, LogicalSize},
54    hit_test::{ExternalScrollId, ScrollPosition},
55    styled_dom::NodeHierarchyItemId,
56    task::{Duration, Instant},
57};
58
59#[cfg(feature = "std")]
60use std::sync::{Arc, Mutex};
61
62use crate::managers::hover::InputPointId;
63
64// ============================================================================
65// Scroll Input Types (for timer-based physics architecture)
66// ============================================================================
67
68/// Classifies the source of a scroll input event.
69///
70/// This determines how the scroll physics timer processes the input:
71/// - `TrackpadContinuous`: The OS already applies momentum — set position directly
72/// - `WheelDiscrete`: Mouse wheel clicks — apply as impulse with momentum decay
73/// - `Programmatic`: API-driven scroll — apply with optional easing animation
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub enum ScrollInputSource {
76    /// Continuous trackpad gesture (macOS precise scrolling).
77    /// Position is set directly — the OS handles momentum/physics.
78    TrackpadContinuous,
79    /// Discrete mouse wheel steps (Windows/Linux mouse wheel).
80    /// Applied as velocity impulse with momentum decay.
81    WheelDiscrete,
82    /// Programmatic scroll (scrollTo API, keyboard Page Up/Down).
83    /// Applied with optional easing animation.
84    Programmatic,
85}
86
87/// A single scroll input event to be processed by the physics timer.
88///
89/// Scroll inputs are recorded by the platform event handler and consumed
90/// by the scroll physics timer callback. This decouples input recording
91/// from physics simulation.
92#[derive(Debug, Clone)]
93pub struct ScrollInput {
94    /// DOM containing the scrollable node
95    pub dom_id: DomId,
96    /// Target scroll node
97    pub node_id: NodeId,
98    /// Scroll delta (positive = scroll down/right)
99    pub delta: LogicalPosition,
100    /// When this input was recorded
101    pub timestamp: Instant,
102    /// How this input should be processed
103    pub source: ScrollInputSource,
104}
105
106/// Thread-safe queue for scroll inputs, shared between event handlers and timer callbacks.
107///
108/// Event handlers push inputs, the physics timer pops them. Protected by a Mutex
109/// so that the timer callback (which only has `&CallbackInfo` / `*const LayoutWindow`)
110/// can still consume pending inputs without needing `&mut`.
111#[cfg(feature = "std")]
112#[derive(Debug, Clone, Default)]
113pub struct ScrollInputQueue {
114    inner: Arc<Mutex<Vec<ScrollInput>>>,
115}
116
117#[cfg(feature = "std")]
118impl ScrollInputQueue {
119    pub fn new() -> Self {
120        Self {
121            inner: Arc::new(Mutex::new(Vec::new())),
122        }
123    }
124
125    /// Push a new scroll input (called from platform event handler)
126    pub fn push(&self, input: ScrollInput) {
127        if let Ok(mut queue) = self.inner.lock() {
128            queue.push(input);
129        }
130    }
131
132    /// Take all pending inputs (called from timer callback)
133    pub fn take_all(&self) -> Vec<ScrollInput> {
134        if let Ok(mut queue) = self.inner.lock() {
135            core::mem::take(&mut *queue)
136        } else {
137            Vec::new()
138        }
139    }
140
141    /// Take at most `max_events` recent inputs, sorted by timestamp (newest last).
142    /// Any older events beyond `max_events` are discarded.
143    /// This prevents the physics timer from processing an unbounded backlog.
144    pub fn take_recent(&self, max_events: usize) -> Vec<ScrollInput> {
145        if let Ok(mut queue) = self.inner.lock() {
146            let mut events = core::mem::take(&mut *queue);
147            if events.len() > max_events {
148                // Sort by timestamp ascending (oldest first), keep last N
149                events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
150                events.drain(..events.len() - max_events);
151            }
152            events
153        } else {
154            Vec::new()
155        }
156    }
157
158    /// Check if there are pending inputs without consuming them
159    pub fn has_pending(&self) -> bool {
160        self.inner
161            .lock()
162            .map(|q| !q.is_empty())
163            .unwrap_or(false)
164    }
165}
166
167// Scrollbar Component Types
168
169/// Which component of a scrollbar was hit during hit-testing
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171pub enum ScrollbarComponent {
172    /// The track (background) of the scrollbar
173    Track,
174    /// The draggable thumb (indicator of current scroll position)
175    Thumb,
176    /// Top/left button (scrolls by one page up/left)
177    TopButton,
178    /// Bottom/right button (scrolls by one page down/right)
179    BottomButton,
180}
181
182/// Scrollbar geometry state (calculated per frame, used for hit-testing and rendering)
183#[derive(Debug, Clone)]
184pub struct ScrollbarState {
185    /// Is this scrollbar visible? (content larger than container)
186    pub visible: bool,
187    /// Orientation
188    pub orientation: ScrollbarOrientation,
189    /// Base size (1:1 square, width = height). This is the unscaled size.
190    pub base_size: f32,
191    /// Scale transform to apply (calculated from container size)
192    pub scale: LogicalPosition, // x = width scale, y = height scale
193    /// Thumb position ratio (0.0 = top/left, 1.0 = bottom/right)
194    pub thumb_position_ratio: f32,
195    /// Thumb size ratio (0.0 = invisible, 1.0 = entire track)
196    pub thumb_size_ratio: f32,
197    /// Position of the scrollbar in the container (for hit-testing)
198    pub track_rect: LogicalRect,
199}
200
201impl ScrollbarState {
202    /// Determine which component was hit at the given local position (relative to track_rect
203    /// origin)
204    pub fn hit_test_component(&self, local_pos: LogicalPosition) -> ScrollbarComponent {
205        match self.orientation {
206            ScrollbarOrientation::Vertical => {
207                let button_height = self.base_size;
208
209                // Top button
210                if local_pos.y < button_height {
211                    return ScrollbarComponent::TopButton;
212                }
213
214                // Bottom button
215                let track_height = self.track_rect.size.height;
216                if local_pos.y > track_height - button_height {
217                    return ScrollbarComponent::BottomButton;
218                }
219
220                // Calculate thumb bounds
221                let track_height_usable = track_height - 2.0 * button_height;
222                let thumb_height = track_height_usable * self.thumb_size_ratio;
223                let thumb_y_start = button_height
224                    + (track_height_usable - thumb_height) * self.thumb_position_ratio;
225                let thumb_y_end = thumb_y_start + thumb_height;
226
227                // Check if inside thumb
228                if local_pos.y >= thumb_y_start && local_pos.y <= thumb_y_end {
229                    ScrollbarComponent::Thumb
230                } else {
231                    ScrollbarComponent::Track
232                }
233            }
234            ScrollbarOrientation::Horizontal => {
235                let button_width = self.base_size;
236
237                // Left button
238                if local_pos.x < button_width {
239                    return ScrollbarComponent::TopButton;
240                }
241
242                // Right button
243                let track_width = self.track_rect.size.width;
244                if local_pos.x > track_width - button_width {
245                    return ScrollbarComponent::BottomButton;
246                }
247
248                // Calculate thumb bounds
249                let track_width_usable = track_width - 2.0 * button_width;
250                let thumb_width = track_width_usable * self.thumb_size_ratio;
251                let thumb_x_start =
252                    button_width + (track_width_usable - thumb_width) * self.thumb_position_ratio;
253                let thumb_x_end = thumb_x_start + thumb_width;
254
255                // Check if inside thumb
256                if local_pos.x >= thumb_x_start && local_pos.x <= thumb_x_end {
257                    ScrollbarComponent::Thumb
258                } else {
259                    ScrollbarComponent::Track
260                }
261            }
262        }
263    }
264}
265
266/// Result of a scrollbar hit-test
267///
268/// Contains information about which scrollbar component was hit
269/// and the position relative to both the track and the window.
270#[derive(Debug, Clone, Copy)]
271pub struct ScrollbarHit {
272    /// DOM containing the scrollable node
273    pub dom_id: DomId,
274    /// Node with the scrollbar
275    pub node_id: NodeId,
276    /// Whether this is a vertical or horizontal scrollbar
277    pub orientation: ScrollbarOrientation,
278    /// Which component was hit (track, thumb, buttons)
279    pub component: ScrollbarComponent,
280    /// Position relative to track_rect origin
281    pub local_position: LogicalPosition,
282    /// Original global window position
283    pub global_position: LogicalPosition,
284}
285
286// Core Scroll Manager
287
288/// Manages all scroll state and animations for a window
289#[derive(Debug, Clone, Default)]
290pub struct ScrollManager {
291    /// Maps (DomId, NodeId) to their scroll state
292    states: BTreeMap<(DomId, NodeId), AnimatedScrollState>,
293    /// Maps (DomId, NodeId) to WebRender ExternalScrollId
294    external_scroll_ids: BTreeMap<(DomId, NodeId), ExternalScrollId>,
295    /// Counter for generating unique ExternalScrollId values
296    next_external_scroll_id: u64,
297    /// Scrollbar geometry states (calculated per frame)
298    scrollbar_states: BTreeMap<(DomId, NodeId, ScrollbarOrientation), ScrollbarState>,
299    /// Thread-safe queue for scroll inputs (shared with timer callbacks)
300    #[cfg(feature = "std")]
301    pub scroll_input_queue: ScrollInputQueue,
302}
303
304/// The complete scroll state for a single node (with animation support)
305#[derive(Debug, Clone)]
306pub struct AnimatedScrollState {
307    /// Current scroll offset (live, may be animating)
308    pub current_offset: LogicalPosition,
309    /// Ongoing smooth scroll animation, if any
310    pub animation: Option<ScrollAnimation>,
311    /// Last time scroll activity occurred (for fading scrollbars)
312    pub last_activity: Instant,
313    /// Bounds of the scrollable container
314    pub container_rect: LogicalRect,
315    /// Bounds of the total content (for calculating scroll limits)
316    pub content_rect: LogicalRect,
317    /// Virtual scroll size from IFrame callback (if this node hosts an IFrame).
318    /// When set, clamp logic uses this instead of content_rect for max scroll bounds.
319    pub virtual_scroll_size: Option<LogicalSize>,
320    /// Virtual scroll offset from IFrame callback
321    pub virtual_scroll_offset: Option<LogicalPosition>,
322    /// Per-node overscroll behavior for X axis (from CSS `overscroll-behavior-x`)
323    pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
324    /// Per-node overscroll behavior for Y axis (from CSS `overscroll-behavior-y`)
325    pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
326    /// Per-node overflow scrolling mode (from CSS `-azul-overflow-scrolling`)
327    pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
328}
329
330/// Details of an in-progress smooth scroll animation
331#[derive(Debug, Clone)]
332struct ScrollAnimation {
333    /// When the animation started
334    start_time: Instant,
335    /// Total duration of the animation
336    duration: Duration,
337    /// Scroll offset at animation start
338    start_offset: LogicalPosition,
339    /// Target scroll offset at animation end
340    target_offset: LogicalPosition,
341    /// Easing function for interpolation
342    easing: EasingFunction,
343}
344
345/// Read-only snapshot of a scroll node's state, returned by CallbackInfo queries.
346///
347/// Provides all the information a timer callback needs to compute scroll physics
348/// without requiring mutable access to the ScrollManager.
349#[derive(Debug, Clone)]
350pub struct ScrollNodeInfo {
351    /// Current scroll offset
352    pub current_offset: LogicalPosition,
353    /// Container (viewport) bounds
354    pub container_rect: LogicalRect,
355    /// Content bounds (total scrollable area)
356    pub content_rect: LogicalRect,
357    /// Maximum scroll in X direction
358    pub max_scroll_x: f32,
359    /// Maximum scroll in Y direction
360    pub max_scroll_y: f32,
361    /// Per-node overscroll behavior for X axis
362    pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
363    /// Per-node overscroll behavior for Y axis
364    pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
365    /// Per-node overflow scrolling mode (auto vs touch)
366    pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
367}
368
369/// Result of a scroll tick, indicating what actions are needed
370#[derive(Debug, Default)]
371pub struct ScrollTickResult {
372    /// If true, a repaint is needed (scroll offset changed)
373    pub needs_repaint: bool,
374    /// Nodes whose scroll position was updated this tick
375    pub updated_nodes: Vec<(DomId, NodeId)>,
376}
377
378// ScrollManager Implementation
379
380impl ScrollManager {
381    /// Creates a new empty ScrollManager
382    pub fn new() -> Self {
383        Self::default()
384    }
385
386    // ========================================================================
387    // Input Recording API (timer-based architecture)
388    // ========================================================================
389
390    /// Records a scroll input event into the shared queue.
391    ///
392    /// This is the primary entry point for platform event handlers. Instead of
393    /// directly modifying scroll positions, the input is queued for the scroll
394    /// physics timer to process. This decouples input from physics simulation.
395    ///
396    /// Returns `true` if the physics timer should be started (i.e., there are
397    /// now pending inputs and no timer is running yet).
398    #[cfg(feature = "std")]
399    pub fn record_scroll_input(&mut self, input: ScrollInput) -> bool {
400        let was_empty = !self.scroll_input_queue.has_pending();
401        self.scroll_input_queue.push(input);
402        was_empty // caller should start timer if this returns true
403    }
404
405    /// High-level entry point for platform event handlers: performs hit-test lookup
406    /// and queues the input for the physics timer, instead of directly modifying offsets.
407    ///
408    /// Returns `Some((dom_id, node_id, should_start_timer))` if a scrollable node was found.
409    /// The caller should start `SCROLL_MOMENTUM_TIMER_ID` when `should_start_timer` is true.
410    #[cfg(feature = "std")]
411    pub fn record_scroll_from_hit_test(
412        &mut self,
413        delta_x: f32,
414        delta_y: f32,
415        source: ScrollInputSource,
416        hover_manager: &crate::managers::hover::HoverManager,
417        input_point_id: &InputPointId,
418        now: Instant,
419    ) -> Option<(DomId, NodeId, bool)> {
420        let hit_test = hover_manager.get_current(input_point_id)?;
421
422        for (dom_id, hit_node) in &hit_test.hovered_nodes {
423            for (node_id, _scroll_item) in &hit_node.scroll_hit_test_nodes {
424                if !self.is_node_scrollable(*dom_id, *node_id) {
425                    continue;
426                }
427                let input = ScrollInput {
428                    dom_id: *dom_id,
429                    node_id: *node_id,
430                    delta: LogicalPosition { x: delta_x, y: delta_y },
431                    timestamp: now,
432                    source,
433                };
434                let should_start_timer = self.record_scroll_input(input);
435                return Some((*dom_id, *node_id, should_start_timer));
436            }
437        }
438
439        None
440    }
441
442    /// Get a clone of the scroll input queue (for sharing with timer callbacks).
443    ///
444    /// The timer callback stores this in its RefAny data and calls `take_all()`
445    /// each tick to consume pending inputs.
446    #[cfg(feature = "std")]
447    pub fn get_input_queue(&self) -> ScrollInputQueue {
448        self.scroll_input_queue.clone()
449    }
450
451    /// Advances scroll animations by one tick, returns repaint info
452    pub fn tick(&mut self, now: Instant) -> ScrollTickResult {
453        let mut result = ScrollTickResult::default();
454        for ((dom_id, node_id), state) in self.states.iter_mut() {
455            if let Some(anim) = &state.animation {
456                let elapsed = now.duration_since(&anim.start_time);
457                let t = elapsed.div(&anim.duration).min(1.0);
458                let eased_t = apply_easing(t, anim.easing);
459
460                state.current_offset = LogicalPosition {
461                    x: anim.start_offset.x + (anim.target_offset.x - anim.start_offset.x) * eased_t,
462                    y: anim.start_offset.y + (anim.target_offset.y - anim.start_offset.y) * eased_t,
463                };
464                result.needs_repaint = true;
465                result.updated_nodes.push((*dom_id, *node_id));
466
467                if t >= 1.0 {
468                    state.animation = None;
469                }
470            }
471        }
472        result
473    }
474
475    /// Finds the closest scroll-container ancestor for a given node.
476    ///
477    /// Walks up the node hierarchy to find a node that is registered as a
478    /// scrollable node in this ScrollManager. Returns `None` if no scrollable
479    /// ancestor is found.
480    pub fn find_scroll_parent(
481        &self,
482        dom_id: DomId,
483        node_id: NodeId,
484        node_hierarchy: &[azul_core::styled_dom::NodeHierarchyItem],
485    ) -> Option<NodeId> {
486        let mut current = Some(node_id);
487        while let Some(nid) = current {
488            if self.states.contains_key(&(dom_id, nid)) && nid != node_id {
489                return Some(nid);
490            }
491            current = node_hierarchy
492                .get(nid.index())
493                .and_then(|item| item.parent_id());
494        }
495        None
496    }
497
498    /// Check if a node is scrollable (has overflow:scroll/auto and overflowing content)
499    ///
500    /// Uses `virtual_scroll_size` (when set) instead of `content_rect` for the
501    /// overflow check, so IFrame nodes with large virtual content are correctly
502    /// identified as scrollable even when only a small subset is rendered.
503    fn is_node_scrollable(&self, dom_id: DomId, node_id: NodeId) -> bool {
504        self.states.get(&(dom_id, node_id)).map_or(false, |state| {
505            let effective_width = state.virtual_scroll_size
506                .map(|s| s.width)
507                .unwrap_or(state.content_rect.size.width);
508            let effective_height = state.virtual_scroll_size
509                .map(|s| s.height)
510                .unwrap_or(state.content_rect.size.height);
511            let has_horizontal = effective_width > state.container_rect.size.width;
512            let has_vertical = effective_height > state.container_rect.size.height;
513            has_horizontal || has_vertical
514        })
515    }
516
517    /// Sets scroll position immediately (no animation)
518    pub fn set_scroll_position(
519        &mut self,
520        dom_id: DomId,
521        node_id: NodeId,
522        position: LogicalPosition,
523        now: Instant,
524    ) {
525        let state = self
526            .states
527            .entry((dom_id, node_id))
528            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
529        state.current_offset = state.clamp(position);
530        state.animation = None;
531        state.last_activity = now;
532    }
533
534    /// Scrolls by a delta amount with animation
535    pub fn scroll_by(
536        &mut self,
537        dom_id: DomId,
538        node_id: NodeId,
539        delta: LogicalPosition,
540        duration: Duration,
541        easing: EasingFunction,
542        now: Instant,
543    ) {
544        let current = self.get_current_offset(dom_id, node_id).unwrap_or_default();
545        let target = LogicalPosition {
546            x: current.x + delta.x,
547            y: current.y + delta.y,
548        };
549        self.scroll_to(dom_id, node_id, target, duration, easing, now);
550    }
551
552    /// Scrolls to an absolute position with animation
553    ///
554    /// If duration is zero, the position is set immediately without animation.
555    pub fn scroll_to(
556        &mut self,
557        dom_id: DomId,
558        node_id: NodeId,
559        target: LogicalPosition,
560        duration: Duration,
561        easing: EasingFunction,
562        now: Instant,
563    ) {
564        // For zero duration, set position immediately
565        let is_zero = match &duration {
566            Duration::System(s) => s.secs == 0 && s.nanos == 0,
567            Duration::Tick(t) => t.tick_diff == 0,
568        };
569
570        if is_zero {
571            self.set_scroll_position(dom_id, node_id, target, now);
572            return;
573        }
574
575        let state = self
576            .states
577            .entry((dom_id, node_id))
578            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
579        let clamped_target = state.clamp(target);
580        state.animation = Some(ScrollAnimation {
581            start_time: now.clone(),
582            duration,
583            start_offset: state.current_offset,
584            target_offset: clamped_target,
585            easing,
586        });
587        state.last_activity = now;
588    }
589
590    /// Updates the container and content bounds for a scrollable node
591    pub fn update_node_bounds(
592        &mut self,
593        dom_id: DomId,
594        node_id: NodeId,
595        container_rect: LogicalRect,
596        content_rect: LogicalRect,
597        now: Instant,
598    ) {
599        let state = self
600            .states
601            .entry((dom_id, node_id))
602            .or_insert_with(|| AnimatedScrollState::new(now));
603        state.container_rect = container_rect;
604        state.content_rect = content_rect;
605        state.current_offset = state.clamp(state.current_offset);
606    }
607
608    /// Updates virtual scroll bounds for an IFrame node.
609    ///
610    /// Called after IFrame callback returns to propagate the virtual content size
611    /// to the ScrollManager. Clamp logic then uses `virtual_scroll_size` (when set)
612    /// instead of `content_rect` for max scroll bounds.
613    pub fn update_virtual_scroll_bounds(
614        &mut self,
615        dom_id: DomId,
616        node_id: NodeId,
617        virtual_scroll_size: LogicalSize,
618        virtual_scroll_offset: Option<LogicalPosition>,
619    ) {
620        if let Some(state) = self.states.get_mut(&(dom_id, node_id)) {
621            state.virtual_scroll_size = Some(virtual_scroll_size);
622            state.virtual_scroll_offset = virtual_scroll_offset;
623            // Re-clamp with new virtual bounds
624            state.current_offset = state.clamp(state.current_offset);
625        }
626    }
627
628    /// Returns the current scroll offset for a node
629    pub fn get_current_offset(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
630        self.states
631            .get(&(dom_id, node_id))
632            .map(|s| s.current_offset)
633    }
634
635    /// Returns the timestamp of last scroll activity for a node
636    pub fn get_last_activity_time(&self, dom_id: DomId, node_id: NodeId) -> Option<Instant> {
637        self.states
638            .get(&(dom_id, node_id))
639            .map(|s| s.last_activity.clone())
640    }
641
642    /// Returns the internal scroll state for a node
643    pub fn get_scroll_state(&self, dom_id: DomId, node_id: NodeId) -> Option<&AnimatedScrollState> {
644        self.states.get(&(dom_id, node_id))
645    }
646
647    /// Returns a read-only snapshot of a scroll node's state.
648    ///
649    /// This is the preferred way for timer callbacks to query scroll state,
650    /// since they only have `&CallbackInfo` (read-only access).
651    ///
652    /// When `virtual_scroll_size` is set (for IFrame nodes), the max scroll
653    /// bounds are computed from the virtual size instead of `content_rect`.
654    pub fn get_scroll_node_info(
655        &self,
656        dom_id: DomId,
657        node_id: NodeId,
658    ) -> Option<ScrollNodeInfo> {
659        let state = self.states.get(&(dom_id, node_id))?;
660        let effective_content_width = state.virtual_scroll_size
661            .map(|s| s.width)
662            .unwrap_or(state.content_rect.size.width);
663        let effective_content_height = state.virtual_scroll_size
664            .map(|s| s.height)
665            .unwrap_or(state.content_rect.size.height);
666        let max_x = (effective_content_width - state.container_rect.size.width).max(0.0);
667        let max_y = (effective_content_height - state.container_rect.size.height).max(0.0);
668        Some(ScrollNodeInfo {
669            current_offset: state.current_offset,
670            container_rect: state.container_rect,
671            content_rect: state.content_rect,
672            max_scroll_x: max_x,
673            max_scroll_y: max_y,
674            overscroll_behavior_x: state.overscroll_behavior_x,
675            overscroll_behavior_y: state.overscroll_behavior_y,
676            overflow_scrolling: state.overflow_scrolling,
677        })
678    }
679
680    /// Returns all scroll positions for nodes in a specific DOM
681    pub fn get_scroll_states_for_dom(&self, dom_id: DomId) -> BTreeMap<NodeId, ScrollPosition> {
682        self.states
683            .iter()
684            .filter(|((d, _), _)| *d == dom_id)
685            .map(|((_, node_id), state)| {
686                (
687                    *node_id,
688                    ScrollPosition {
689                        parent_rect: state.container_rect,
690                        children_rect: LogicalRect::new(
691                            state.current_offset,
692                            state.content_rect.size,
693                        ),
694                    },
695                )
696            })
697            .collect()
698    }
699
700    /// Registers or updates a scrollable node with its container and content sizes.
701    /// This should be called after layout for each node that has overflow:scroll or overflow:auto
702    /// with overflowing content.
703    ///
704    /// If the node already exists, updates container/content rects without changing scroll offset.
705    /// If the node is new, initializes with zero scroll offset.
706    pub fn register_or_update_scroll_node(
707        &mut self,
708        dom_id: DomId,
709        node_id: NodeId,
710        container_rect: LogicalRect,
711        content_size: LogicalSize,
712        now: Instant,
713    ) {
714        let key = (dom_id, node_id);
715
716        let content_rect = LogicalRect {
717            origin: LogicalPosition::zero(),
718            size: content_size,
719        };
720
721        if let Some(existing) = self.states.get_mut(&key) {
722            // Update rects, keep scroll offset
723            existing.container_rect = container_rect;
724            existing.content_rect = content_rect;
725            // Re-clamp current offset to new bounds
726            existing.current_offset = existing.clamp(existing.current_offset);
727        } else {
728            // New scrollable node
729            self.states.insert(
730                key,
731                AnimatedScrollState {
732                    current_offset: LogicalPosition::zero(),
733                    animation: None,
734                    last_activity: now,
735                    container_rect,
736                    content_rect,
737                    virtual_scroll_size: None,
738                    virtual_scroll_offset: None,
739                    overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
740                    overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
741                    overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
742                },
743            );
744        }
745    }
746
747    // ExternalScrollId Management
748
749    /// Register a scroll node and get its ExternalScrollId for WebRender.
750    /// If the node already has an ID, returns the existing one.
751    pub fn register_scroll_node(&mut self, dom_id: DomId, node_id: NodeId) -> ExternalScrollId {
752        use azul_core::hit_test::PipelineId;
753
754        let key = (dom_id, node_id);
755        if let Some(&existing_id) = self.external_scroll_ids.get(&key) {
756            return existing_id;
757        }
758
759        // Generate new ExternalScrollId (id, pipeline_id)
760        // PipelineId = (PipelineSourceId: u32, u32)
761        // Use dom_id.inner for PipelineSourceId, node_id.index() for second part
762        let pipeline_id = PipelineId(
763            dom_id.inner as u32, // PipelineSourceId is just u32
764            node_id.index() as u32,
765        );
766        let new_id = ExternalScrollId(self.next_external_scroll_id, pipeline_id);
767        self.next_external_scroll_id += 1;
768        self.external_scroll_ids.insert(key, new_id);
769        new_id
770    }
771
772    /// Get the ExternalScrollId for a node (returns None if not registered)
773    pub fn get_external_scroll_id(
774        &self,
775        dom_id: DomId,
776        node_id: NodeId,
777    ) -> Option<ExternalScrollId> {
778        self.external_scroll_ids.get(&(dom_id, node_id)).copied()
779    }
780
781    /// Iterate over all registered external scroll IDs
782    pub fn iter_external_scroll_ids(
783        &self,
784    ) -> impl Iterator<Item = ((DomId, NodeId), ExternalScrollId)> + '_ {
785        self.external_scroll_ids.iter().map(|(k, v)| (*k, *v))
786    }
787
788    // Scrollbar State Management
789
790    /// Calculate scrollbar states for all visible scrollbars.
791    /// This should be called once per frame after layout is complete.
792    pub fn calculate_scrollbar_states(&mut self) {
793        self.scrollbar_states.clear();
794
795        // Collect vertical scrollbar states
796        // Uses virtual_scroll_size (when set) for the overflow check and thumb ratio,
797        // so IFrame nodes with large virtual content show correct scrollbar geometry.
798        let vertical_states: Vec<_> = self
799            .states
800            .iter()
801            .filter(|(_, s)| {
802                let effective_height = s.virtual_scroll_size
803                    .map(|vs| vs.height)
804                    .unwrap_or(s.content_rect.size.height);
805                effective_height > s.container_rect.size.height
806            })
807            .map(|((dom_id, node_id), scroll_state)| {
808                let v_state = Self::calculate_vertical_scrollbar_static(scroll_state);
809                ((*dom_id, *node_id, ScrollbarOrientation::Vertical), v_state)
810            })
811            .collect();
812
813        // Collect horizontal scrollbar states
814        let horizontal_states: Vec<_> = self
815            .states
816            .iter()
817            .filter(|(_, s)| {
818                let effective_width = s.virtual_scroll_size
819                    .map(|vs| vs.width)
820                    .unwrap_or(s.content_rect.size.width);
821                effective_width > s.container_rect.size.width
822            })
823            .map(|((dom_id, node_id), scroll_state)| {
824                let h_state = Self::calculate_horizontal_scrollbar_static(scroll_state);
825                (
826                    (*dom_id, *node_id, ScrollbarOrientation::Horizontal),
827                    h_state,
828                )
829            })
830            .collect();
831
832        // Insert all states
833        self.scrollbar_states.extend(vertical_states);
834        self.scrollbar_states.extend(horizontal_states);
835    }
836
837    /// Calculate vertical scrollbar geometry
838    fn calculate_vertical_scrollbar_static(scroll_state: &AnimatedScrollState) -> ScrollbarState {
839        // Default scrollbar base size. This is the *rendering* size, not the layout
840        // reservation (which uses per-node CSS via get_layout_scrollbar_width_px).
841        const SCROLLBAR_WIDTH: f32 = 16.0;
842
843        let container_height = scroll_state.container_rect.size.height;
844        let content_height = scroll_state.virtual_scroll_size
845            .map(|s| s.height)
846            .unwrap_or(scroll_state.content_rect.size.height);
847
848        // Thumb size ratio = visible_height / total_height
849        let thumb_size_ratio = (container_height / content_height).min(1.0);
850
851        // Thumb position ratio = scroll_offset / max_scroll
852        let max_scroll = (content_height - container_height).max(0.0);
853        let thumb_position_ratio = if max_scroll > 0.0 {
854            (scroll_state.current_offset.y / max_scroll).clamp(0.0, 1.0)
855        } else {
856            0.0
857        };
858
859        // Scale: width = 1.0 (SCROLLBAR_WIDTH), height = container_height / SCROLLBAR_WIDTH
860        let scale = LogicalPosition::new(1.0, container_height / SCROLLBAR_WIDTH);
861
862        // Track rect (positioned at right edge of container)
863        let track_x = scroll_state.container_rect.origin.x + scroll_state.container_rect.size.width
864            - SCROLLBAR_WIDTH;
865        let track_y = scroll_state.container_rect.origin.y;
866        let track_rect = LogicalRect::new(
867            LogicalPosition::new(track_x, track_y),
868            LogicalSize::new(SCROLLBAR_WIDTH, container_height),
869        );
870
871        ScrollbarState {
872            visible: true,
873            orientation: ScrollbarOrientation::Vertical,
874            base_size: SCROLLBAR_WIDTH,
875            scale,
876            thumb_position_ratio,
877            thumb_size_ratio,
878            track_rect,
879        }
880    }
881
882    /// Calculate horizontal scrollbar geometry
883    fn calculate_horizontal_scrollbar_static(scroll_state: &AnimatedScrollState) -> ScrollbarState {
884        // Default scrollbar base size. This is the *rendering* size, not the layout
885        // reservation (which uses per-node CSS via get_layout_scrollbar_width_px).
886        const SCROLLBAR_HEIGHT: f32 = 16.0;
887
888        let container_width = scroll_state.container_rect.size.width;
889        let content_width = scroll_state.virtual_scroll_size
890            .map(|s| s.width)
891            .unwrap_or(scroll_state.content_rect.size.width);
892
893        let thumb_size_ratio = (container_width / content_width).min(1.0);
894
895        let max_scroll = (content_width - container_width).max(0.0);
896        let thumb_position_ratio = if max_scroll > 0.0 {
897            (scroll_state.current_offset.x / max_scroll).clamp(0.0, 1.0)
898        } else {
899            0.0
900        };
901
902        let scale = LogicalPosition::new(container_width / SCROLLBAR_HEIGHT, 1.0);
903
904        let track_x = scroll_state.container_rect.origin.x;
905        let track_y = scroll_state.container_rect.origin.y
906            + scroll_state.container_rect.size.height
907            - SCROLLBAR_HEIGHT;
908        let track_rect = LogicalRect::new(
909            LogicalPosition::new(track_x, track_y),
910            LogicalSize::new(container_width, SCROLLBAR_HEIGHT),
911        );
912
913        ScrollbarState {
914            visible: true,
915            orientation: ScrollbarOrientation::Horizontal,
916            base_size: SCROLLBAR_HEIGHT,
917            scale,
918            thumb_position_ratio,
919            thumb_size_ratio,
920            track_rect,
921        }
922    }
923
924    /// Get scrollbar state for hit-testing
925    pub fn get_scrollbar_state(
926        &self,
927        dom_id: DomId,
928        node_id: NodeId,
929        orientation: ScrollbarOrientation,
930    ) -> Option<&ScrollbarState> {
931        self.scrollbar_states.get(&(dom_id, node_id, orientation))
932    }
933
934    /// Iterate over all visible scrollbar states
935    pub fn iter_scrollbar_states(
936        &self,
937    ) -> impl Iterator<Item = ((DomId, NodeId, ScrollbarOrientation), &ScrollbarState)> + '_ {
938        self.scrollbar_states.iter().map(|(k, v)| (*k, v))
939    }
940
941    // Scrollbar Hit-Testing
942
943    /// Hit-test scrollbars for a specific node at the given position.
944    /// Returns Some if the position is inside a scrollbar for this node.
945    pub fn hit_test_scrollbar(
946        &self,
947        dom_id: DomId,
948        node_id: NodeId,
949        global_pos: LogicalPosition,
950    ) -> Option<ScrollbarHit> {
951        // Check both vertical and horizontal scrollbars for this node
952        for orientation in [
953            ScrollbarOrientation::Vertical,
954            ScrollbarOrientation::Horizontal,
955        ] {
956            let scrollbar_state = self.scrollbar_states.get(&(dom_id, node_id, orientation))?;
957
958            if !scrollbar_state.visible {
959                continue;
960            }
961
962            // Check if position is inside scrollbar track using LogicalRect::contains
963            if !scrollbar_state.track_rect.contains(global_pos) {
964                continue;
965            }
966
967            // Calculate local position relative to track origin
968            let local_pos = LogicalPosition::new(
969                global_pos.x - scrollbar_state.track_rect.origin.x,
970                global_pos.y - scrollbar_state.track_rect.origin.y,
971            );
972
973            // Determine which component was hit
974            let component = scrollbar_state.hit_test_component(local_pos);
975
976            return Some(ScrollbarHit {
977                dom_id,
978                node_id,
979                orientation,
980                component,
981                local_position: local_pos,
982                global_position: global_pos,
983            });
984        }
985
986        None
987    }
988
989    /// Perform hit-testing for all scrollbars at the given global position.
990    ///
991    /// This iterates through all visible scrollbars in reverse z-order (top to bottom)
992    /// and returns the first hit. Use this when you don't know which node to check.
993    ///
994    /// For better performance, use `hit_test_scrollbar()` when you already have
995    /// a hit-tested node from WebRender.
996    pub fn hit_test_scrollbars(&self, global_pos: LogicalPosition) -> Option<ScrollbarHit> {
997        // Iterate in reverse order to hit top-most scrollbars first
998        for ((dom_id, node_id, orientation), scrollbar_state) in self.scrollbar_states.iter().rev()
999        {
1000            if !scrollbar_state.visible {
1001                continue;
1002            }
1003
1004            // Check if position is inside scrollbar track
1005            if !scrollbar_state.track_rect.contains(global_pos) {
1006                continue;
1007            }
1008
1009            // Calculate local position relative to track origin
1010            let local_pos = LogicalPosition::new(
1011                global_pos.x - scrollbar_state.track_rect.origin.x,
1012                global_pos.y - scrollbar_state.track_rect.origin.y,
1013            );
1014
1015            // Determine which component was hit
1016            let component = scrollbar_state.hit_test_component(local_pos);
1017
1018            return Some(ScrollbarHit {
1019                dom_id: *dom_id,
1020                node_id: *node_id,
1021                orientation: *orientation,
1022                component,
1023                local_position: local_pos,
1024                global_position: global_pos,
1025            });
1026        }
1027
1028        None
1029    }
1030}
1031
1032// AnimatedScrollState Implementation
1033
1034impl AnimatedScrollState {
1035    /// Create a new scroll state initialized at offset (0, 0).
1036    pub fn new(now: Instant) -> Self {
1037        Self {
1038            current_offset: LogicalPosition::zero(),
1039            animation: None,
1040            last_activity: now,
1041            container_rect: LogicalRect::zero(),
1042            content_rect: LogicalRect::zero(),
1043            virtual_scroll_size: None,
1044            virtual_scroll_offset: None,
1045            overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1046            overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
1047            overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
1048        }
1049    }
1050
1051    /// Clamp a scroll position to valid bounds (0 to max_scroll).
1052    ///
1053    /// When `virtual_scroll_size` is set (for IFrame nodes), the max bounds
1054    /// are computed from the virtual size instead of content_rect.
1055    pub fn clamp(&self, position: LogicalPosition) -> LogicalPosition {
1056        let effective_width = self.virtual_scroll_size
1057            .map(|s| s.width)
1058            .unwrap_or(self.content_rect.size.width);
1059        let effective_height = self.virtual_scroll_size
1060            .map(|s| s.height)
1061            .unwrap_or(self.content_rect.size.height);
1062        let max_x = (effective_width - self.container_rect.size.width).max(0.0);
1063        let max_y = (effective_height - self.container_rect.size.height).max(0.0);
1064        LogicalPosition {
1065            x: position.x.max(0.0).min(max_x),
1066            y: position.y.max(0.0).min(max_y),
1067        }
1068    }
1069}
1070
1071// Easing Functions
1072
1073/// Apply an easing function to a normalized time value (0.0 to 1.0).
1074/// Used by ScrollAnimation::tick() for smooth scroll animations.
1075pub fn apply_easing(t: f32, easing: EasingFunction) -> f32 {
1076    match easing {
1077        EasingFunction::Linear => t,
1078        EasingFunction::EaseOut => 1.0 - (1.0 - t).powi(3),
1079        EasingFunction::EaseInOut => {
1080            if t < 0.5 {
1081                4.0 * t * t * t
1082            } else {
1083                1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
1084            }
1085        }
1086    }
1087}
1088
1089// Legacy type alias
1090pub type ScrollStates = ScrollManager;
1091
1092impl ScrollManager {
1093    /// Remap NodeIds after DOM reconciliation
1094    ///
1095    /// When the DOM is regenerated, NodeIds can change. This method updates all
1096    /// internal state to use the new NodeIds based on the provided mapping.
1097    pub fn remap_node_ids(
1098        &mut self,
1099        dom_id: DomId,
1100        node_id_map: &std::collections::BTreeMap<NodeId, NodeId>,
1101    ) {
1102        // Remap states
1103        let states_to_update: Vec<_> = self.states.keys()
1104            .filter(|(d, _)| *d == dom_id)
1105            .cloned()
1106            .collect();
1107        
1108        for (d, old_node_id) in states_to_update {
1109            if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
1110                if let Some(state) = self.states.remove(&(d, old_node_id)) {
1111                    self.states.insert((d, new_node_id), state);
1112                }
1113            } else {
1114                // Node was removed, remove its scroll state
1115                self.states.remove(&(d, old_node_id));
1116            }
1117        }
1118        
1119        // Remap external_scroll_ids
1120        let scroll_ids_to_update: Vec<_> = self.external_scroll_ids.keys()
1121            .filter(|(d, _)| *d == dom_id)
1122            .cloned()
1123            .collect();
1124        
1125        for (d, old_node_id) in scroll_ids_to_update {
1126            if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
1127                if let Some(scroll_id) = self.external_scroll_ids.remove(&(d, old_node_id)) {
1128                    self.external_scroll_ids.insert((d, new_node_id), scroll_id);
1129                }
1130            } else {
1131                self.external_scroll_ids.remove(&(d, old_node_id));
1132            }
1133        }
1134        
1135        // Remap scrollbar_states
1136        let scrollbar_states_to_update: Vec<_> = self.scrollbar_states.keys()
1137            .filter(|(d, _, _)| *d == dom_id)
1138            .cloned()
1139            .collect();
1140        
1141        for (d, old_node_id, orientation) in scrollbar_states_to_update {
1142            if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
1143                if let Some(state) = self.scrollbar_states.remove(&(d, old_node_id, orientation)) {
1144                    self.scrollbar_states.insert((d, new_node_id, orientation), state);
1145                }
1146            } else {
1147                self.scrollbar_states.remove(&(d, old_node_id, orientation));
1148            }
1149        }
1150    }
1151}