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

            
46
use alloc::collections::BTreeMap;
47
#[cfg(feature = "std")]
48
use alloc::vec::Vec;
49

            
50
use 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")]
60
use std::sync::{Arc, Mutex};
61

            
62
use crate::managers::hover::InputPointId;
63
use 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.
67
const 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)]
80
pub 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)]
103
pub 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)]
123
pub struct ScrollInputQueue {
124
    inner: Arc<Mutex<Vec<ScrollInput>>>,
125
}
126

            
127
#[cfg(feature = "std")]
128
impl 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
1
    pub fn push(&self, input: ScrollInput) {
137
1
        if let Ok(mut queue) = self.inner.lock() {
138
1
            queue.push(input);
139
1
        }
140
1
    }
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
1
    pub fn has_pending(&self) -> bool {
170
1
        self.inner
171
1
            .lock()
172
1
            .map(|q| !q.is_empty())
173
1
            .unwrap_or(false)
174
1
    }
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)]
181
pub 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)]
194
pub 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

            
219
impl 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)]
278
pub 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)]
297
pub 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)]
317
pub 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)]
353
struct 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)]
371
pub 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)]
392
pub 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

            
401
impl ScrollManager {
402
    /// Creates a new empty ScrollManager
403
2496
    pub fn new() -> Self {
404
2496
        Self::default()
405
2496
    }
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
2275
    pub fn clear_scroll_dirty(&mut self) {
425
2275
        self.scroll_dirty = false;
426
2275
    }
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
1
    pub fn record_scroll_input(&mut self, input: ScrollInput) -> bool {
468
1
        let was_empty = !self.scroll_input_queue.has_pending();
469
1
        self.scroll_input_queue.push(input);
470
1
        was_empty // caller should start timer if this returns true
471
1
    }
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
71
    pub fn tick(&mut self, now: Instant) -> ScrollTickResult {
522
71
        let mut result = ScrollTickResult::default();
523
71
        for ((dom_id, node_id), state) in self.states.iter_mut() {
524
71
            if let Some(anim) = &state.animation {
525
1
                let elapsed = now.duration_since(&anim.start_time);
526
1
                let t = elapsed.div(&anim.duration).min(1.0);
527
1
                let eased_t = apply_easing(t, anim.easing);
528

            
529
1
                state.current_offset = LogicalPosition {
530
1
                    x: anim.start_offset.x + (anim.target_offset.x - anim.start_offset.x) * eased_t,
531
1
                    y: anim.start_offset.y + (anim.target_offset.y - anim.start_offset.y) * eased_t,
532
1
                };
533
1
                result.needs_repaint = true;
534
1
                result.updated_nodes.push((*dom_id, *node_id));
535

            
536
1
                if t >= 1.0 {
537
                    state.animation = None;
538
1
                }
539
70
            }
540
        }
541
71
        result
542
71
    }
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
70
    pub fn set_scroll_position(
598
70
        &mut self,
599
70
        dom_id: DomId,
600
70
        node_id: NodeId,
601
70
        position: LogicalPosition,
602
70
        now: Instant,
603
70
    ) {
604
70
        let state = self
605
70
            .states
606
70
            .entry((dom_id, node_id))
607
70
            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
608
70
        let clamped = state.clamp(position);
609
70
        if (clamped.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
610
35
            || (clamped.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
611
70
        {
612
70
            self.scroll_dirty = true;
613
70
        }
614
70
        state.current_offset = clamped;
615
70
        state.animation = None;
616
70
        state.last_activity = now;
617
70
    }
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
71
    pub fn scroll_to(
666
71
        &mut self,
667
71
        dom_id: DomId,
668
71
        node_id: NodeId,
669
71
        target: LogicalPosition,
670
71
        duration: Duration,
671
71
        easing: EasingFunction,
672
71
        now: Instant,
673
71
    ) {
674
        // For zero duration, set position immediately
675
71
        let is_zero = match &duration {
676
71
            Duration::System(s) => s.secs == 0 && s.nanos == 0,
677
            Duration::Tick(t) => t.tick_diff == 0,
678
        };
679

            
680
71
        if is_zero {
681
70
            self.set_scroll_position(dom_id, node_id, target, now);
682
70
            return;
683
1
        }
684

            
685
1
        let state = self
686
1
            .states
687
1
            .entry((dom_id, node_id))
688
1
            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
689
1
        let clamped_target = state.clamp(target);
690
1
        state.animation = Some(ScrollAnimation {
691
1
            start_time: now.clone(),
692
1
            duration,
693
1
            start_offset: state.current_offset,
694
1
            target_offset: clamped_target,
695
1
            easing,
696
1
        });
697
1
        state.last_activity = now;
698
71
    }
699

            
700
    /// Updates the container and content bounds for a scrollable node
701
70
    pub fn update_node_bounds(
702
70
        &mut self,
703
70
        dom_id: DomId,
704
70
        node_id: NodeId,
705
70
        container_rect: LogicalRect,
706
70
        content_rect: LogicalRect,
707
70
        now: Instant,
708
70
    ) {
709
70
        let state = self
710
70
            .states
711
70
            .entry((dom_id, node_id))
712
70
            .or_insert_with(|| AnimatedScrollState::new(now));
713
70
        state.container_rect = container_rect;
714
70
        state.content_rect = content_rect;
715
70
        state.current_offset = state.clamp(state.current_offset);
716
70
    }
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
10011
    pub fn get_current_offset(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
748
10011
        self.states
749
10011
            .get(&(dom_id, node_id))
750
10011
            .map(|s| s.current_offset)
751
10011
    }
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
2731
    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
2731
        if self.states.is_empty() {
805
2731
            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
2731
    }
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 })
            .unwrap_or(scroll_state.content_rect.size);
        let scroll_offset = match orientation {
            ScrollbarOrientation::Vertical => scroll_state.current_offset.y,
            ScrollbarOrientation::Horizontal => scroll_state.current_offset.x,
        };
        let has_other_scrollbar = match orientation {
            ScrollbarOrientation::Vertical => scroll_state.has_horizontal_scrollbar,
            ScrollbarOrientation::Horizontal => scroll_state.has_vertical_scrollbar,
        };
        // Overlay scrollbars (thickness == 0 from layout) have no arrow buttons
        let is_overlay = scroll_state.scrollbar_thickness == 0.0;
        let button_size = if is_overlay { 0.0 } else { scrollbar_thickness };
        let geom = compute_scrollbar_geometry_with_button_size(
            orientation,
            scroll_state.container_rect,
            content_size,
            scroll_offset,
            scrollbar_thickness,
            has_other_scrollbar,
            button_size,
        );
        // Build ScrollbarState from the shared geometry
        let scale = match orientation {
            ScrollbarOrientation::Vertical => {
                LogicalPosition::new(1.0, geom.track_rect.size.height / scrollbar_thickness)
            }
            ScrollbarOrientation::Horizontal => {
                LogicalPosition::new(geom.track_rect.size.width / scrollbar_thickness, 1.0)
            }
        };
        ScrollbarState {
            visible: true,
            orientation,
            base_size: scrollbar_thickness,
            scale,
            thumb_position_ratio: geom.scroll_ratio,
            thumb_size_ratio: geom.thumb_size_ratio,
            track_rect: geom.track_rect,
            button_size: geom.button_size,
            usable_track_length: geom.usable_track_length,
            thumb_length: geom.thumb_length,
            thumb_offset: geom.thumb_offset,
        }
    }
    /// Get scrollbar state for hit-testing
    pub fn get_scrollbar_state(
        &self,
        dom_id: DomId,
        node_id: NodeId,
        orientation: ScrollbarOrientation,
    ) -> Option<&ScrollbarState> {
        self.scrollbar_states.get(&(dom_id, node_id, orientation))
    }
    /// Iterate over all visible scrollbar states
    pub fn iter_scrollbar_states(
        &self,
    ) -> impl Iterator<Item = ((DomId, NodeId, ScrollbarOrientation), &ScrollbarState)> + '_ {
        self.scrollbar_states.iter().map(|(k, v)| (*k, v))
    }
    // Scrollbar Hit-Testing
    /// Hit-test scrollbars for a specific node at the given position.
    /// Returns Some if the position is inside a scrollbar for this node.
    pub fn hit_test_scrollbar(
        &self,
        dom_id: DomId,
        node_id: NodeId,
        global_pos: LogicalPosition,
    ) -> Option<ScrollbarHit> {
        // Check both vertical and horizontal scrollbars for this node
        for orientation in [
            ScrollbarOrientation::Vertical,
            ScrollbarOrientation::Horizontal,
        ] {
            let Some(scrollbar_state) = self.scrollbar_states.get(&(dom_id, node_id, orientation)) else {
                continue;
            };
            if !scrollbar_state.visible {
                continue;
            }
            // Check if position is inside scrollbar track using LogicalRect::contains
            if !scrollbar_state.track_rect.contains(global_pos) {
                continue;
            }
            // Calculate local position relative to track origin
            let local_pos = LogicalPosition::new(
                global_pos.x - scrollbar_state.track_rect.origin.x,
                global_pos.y - scrollbar_state.track_rect.origin.y,
            );
            // Determine which component was hit
            let component = scrollbar_state.hit_test_component(local_pos);
            return Some(ScrollbarHit {
                dom_id,
                node_id,
                orientation,
                component,
                local_position: local_pos,
                global_position: global_pos,
            });
        }
        None
    }
    /// Perform hit-testing for all scrollbars at the given global position.
    ///
    /// This iterates through all visible scrollbars in reverse z-order (top to bottom)
    /// and returns the first hit. Use this when you don't know which node to check.
    ///
    /// For better performance, use `hit_test_scrollbar()` when you already have
    /// a hit-tested node from WebRender.
    pub fn hit_test_scrollbars(&self, global_pos: LogicalPosition) -> Option<ScrollbarHit> {
        // Iterate in reverse order to hit top-most scrollbars first
        for ((dom_id, node_id, orientation), scrollbar_state) in self.scrollbar_states.iter().rev()
        {
            if !scrollbar_state.visible {
                continue;
            }
            // Check if position is inside scrollbar track
            if !scrollbar_state.track_rect.contains(global_pos) {
                continue;
            }
            // Calculate local position relative to track origin
            let local_pos = LogicalPosition::new(
                global_pos.x - scrollbar_state.track_rect.origin.x,
                global_pos.y - scrollbar_state.track_rect.origin.y,
            );
            // Determine which component was hit
            let component = scrollbar_state.hit_test_component(local_pos);
            return Some(ScrollbarHit {
                dom_id: *dom_id,
                node_id: *node_id,
                orientation: *orientation,
                component,
                local_position: local_pos,
                global_position: global_pos,
            });
        }
        None
    }
}
// AnimatedScrollState Implementation
impl AnimatedScrollState {
    // +spec:overflow:60f6a1 - scroll origin defaults to block-start inline-start corner (0,0)
    /// Create a new scroll state initialized at offset (0, 0).
71
    pub fn new(now: Instant) -> Self {
71
        Self {
71
            current_offset: LogicalPosition::zero(),
71
            animation: None,
71
            last_activity: now,
71
            container_rect: LogicalRect::zero(),
71
            content_rect: LogicalRect::zero(),
71
            virtual_scroll_size: None,
71
            virtual_scroll_offset: None,
71
            overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
71
            overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
71
            overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
71
            scrollbar_thickness: crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX,
71
            visual_width_px: 0.0,
71
            has_horizontal_scrollbar: false,
71
            has_vertical_scrollbar: false,
71
        }
71
    }
    /// Clamp a scroll position to valid bounds (0 to max_scroll).
    ///
    /// When `virtual_scroll_size` is set (for VirtualView nodes), the max bounds
    /// are computed from the virtual size instead of content_rect.
141
    pub fn clamp(&self, position: LogicalPosition) -> LogicalPosition {
141
        let effective_width = self.virtual_scroll_size
141
            .map(|s| s.width)
141
            .unwrap_or(self.content_rect.size.width);
141
        let effective_height = self.virtual_scroll_size
141
            .map(|s| s.height)
141
            .unwrap_or(self.content_rect.size.height);
141
        let max_x = (effective_width - self.container_rect.size.width).max(0.0);
141
        let max_y = (effective_height - self.container_rect.size.height).max(0.0);
141
        LogicalPosition {
141
            x: position.x.max(0.0).min(max_x),
141
            y: position.y.max(0.0).min(max_y),
141
        }
141
    }
}
// Easing Functions
/// Apply an easing function to a normalized time value (0.0 to 1.0).
/// Used by ScrollAnimation::tick() for smooth scroll animations.
1
pub fn apply_easing(t: f32, easing: EasingFunction) -> f32 {
1
    match easing {
        EasingFunction::Linear => t,
1
        EasingFunction::EaseOut => 1.0 - (1.0 - t).powi(3),
        EasingFunction::EaseInOut => {
            if t < 0.5 {
                4.0 * t * t * t
            } else {
                1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
            }
        }
    }
1
}
// Legacy type alias
pub type ScrollStates = ScrollManager;
impl ScrollManager {
    /// Remap NodeIds after DOM reconciliation
    ///
    /// When the DOM is regenerated, NodeIds can change. This method updates all
    /// internal state to use the new NodeIds based on the provided mapping.
    pub fn remap_node_ids(
        &mut self,
        dom_id: DomId,
        node_id_map: &std::collections::BTreeMap<NodeId, NodeId>,
    ) {
        // Only remap nodes that actually moved (old_id != new_id).
        // Nodes NOT in the map are stable (kept same NodeId) — don't touch them.
        // We cannot distinguish "not moved" from "removed" with just node_moves,
        // so we conservatively keep states that aren't in the map.
        // Remap states
        for (&old_node_id, &new_node_id) in node_id_map.iter() {
            if old_node_id != new_node_id {
                if let Some(state) = self.states.remove(&(dom_id, old_node_id)) {
                    self.states.insert((dom_id, new_node_id), state);
                }
            }
        }
        // Remap external_scroll_ids
        for (&old_node_id, &new_node_id) in node_id_map.iter() {
            if old_node_id != new_node_id {
                if let Some(scroll_id) = self.external_scroll_ids.remove(&(dom_id, old_node_id)) {
                    self.external_scroll_ids.insert((dom_id, new_node_id), scroll_id);
                }
            }
        }
        // Remap scrollbar_states
        let scrollbar_states_to_remap: Vec<_> = self.scrollbar_states.keys()
            .filter(|(d, node_id, _)| {
                *d == dom_id && node_id_map.get(node_id).map_or(false, |new_id| new_id != node_id)
            })
            .cloned()
            .collect();
        for (d, old_node_id, orientation) in scrollbar_states_to_remap {
            if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
                if let Some(state) = self.scrollbar_states.remove(&(d, old_node_id, orientation)) {
                    self.scrollbar_states.insert((d, new_node_id, orientation), state);
                }
            }
        }
    }
}