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}