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