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
5
    pub fn push(&self, input: ScrollInput) {
137
5
        if let Ok(mut queue) = self.inner.lock() {
138
5
            queue.push(input);
139
5
        }
140
5
    }
141

            
142
    /// Take all pending inputs (called from timer callback)
143
3
    pub fn take_all(&self) -> Vec<ScrollInput> {
144
3
        if let Ok(mut queue) = self.inner.lock() {
145
3
            core::mem::take(&mut *queue)
146
        } else {
147
            Vec::new()
148
        }
149
3
    }
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
5
    pub fn has_pending(&self) -> bool {
170
5
        self.inner
171
5
            .lock()
172
5
            .map(|q| !q.is_empty())
173
5
            .unwrap_or(false)
174
5
    }
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
    /// Raw wheel/trackpad delta recorded *this input pass*, regardless of whether
310
    /// a scrollable node was under the cursor. The scroll input queue only carries
311
    /// deltas destined for scrollable containers (consumed by the physics timer);
312
    /// this field additionally lets `determine_all_events` synthesize a `Scroll`
313
    /// event aimed at the hovered node so non-scroll-container widgets (e.g. the
314
    /// map, which treats wheel = zoom) can react via a `HoverEventFilter::Scroll`
315
    /// callback + `CallbackInfo::get_scroll_delta`. Set in
316
    /// [`Self::record_scroll_from_hit_test`]; read during event determination and
317
    /// callback dispatch, then cleared at the end of the pass.
318
    pub pending_wheel_event: Option<LogicalPosition>,
319
    /// Set when a scroll position changes; cleared after the display list
320
    /// is regenerated.  Used by the CPU renderer path to detect when the
321
    /// display list must be rebuilt even though the DOM hasn't changed.
322
    scroll_dirty: bool,
323
    /// Scroll-direction preference, applied ONCE in [`Self::record_scroll_input`]
324
    /// (the single chokepoint every platform's wheel/axis event flows through).
325
    ///
326
    /// `false` (default) = traditional desktop wheel: a raw "scroll down" event
327
    /// increases the offset (content moves up). `true` = natural: inverted.
328
    /// Replaces the per-platform hardcoded `-delta` negations so the sign lives
329
    /// in one configurable place ([`Self::set_natural_scroll`]).
330
    ///
331
    /// CAVEAT: on macOS and on Linux touchpads via libinput the OS/driver ALREADY
332
    /// applies the user's natural-scroll preference before azul sees the delta, so
333
    /// this flag must stay at its default there (we preserve current behavior) and
334
    /// primarily controls mouse-wheel direction on platforms that don't pre-apply.
335
    natural_scroll: bool,
336
}
337

            
338
/// The complete scroll state for a single node (with animation support)
339
#[derive(Debug, Clone)]
340
pub struct AnimatedScrollState {
341
    /// Current scroll offset (live, may be animating)
342
    pub current_offset: LogicalPosition,
343
    /// Ongoing smooth scroll animation, if any
344
    pub animation: Option<ScrollAnimation>,
345
    /// Last time scroll activity occurred (for fading scrollbars)
346
    pub last_activity: Instant,
347
    /// Bounds of the scrollable container
348
    pub container_rect: LogicalRect,
349
    /// Bounds of the total content (for calculating scroll limits)
350
    pub content_rect: LogicalRect,
351
    /// Virtual scroll size from VirtualView callback (if this node hosts a VirtualView).
352
    /// When set, clamp logic uses this instead of content_rect for max scroll bounds.
353
    pub virtual_scroll_size: Option<LogicalSize>,
354
    /// Virtual scroll offset from VirtualView callback
355
    pub virtual_scroll_offset: Option<LogicalPosition>,
356
    /// Per-node overscroll behavior for X axis (from CSS `overscroll-behavior-x`)
357
    pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
358
    /// Per-node overscroll behavior for Y axis (from CSS `overscroll-behavior-y`)
359
    pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
360
    /// Per-node overflow scrolling mode (from CSS `-azul-overflow-scrolling`)
361
    pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
362
    /// CSS-resolved scrollbar thickness (from `scrollbar-width` property).
363
    /// Used for rendering and hit-testing. Defaults to 16.0 if not set.
364
    pub scrollbar_thickness: f32,
365
    /// Visual rendering width in CSS pixels (e.g. 8.0 for thin overlay).
366
    /// Non-zero even for overlay scrollbars. Falls back to scrollbar_thickness if 0.
367
    pub visual_width_px: f32,
368
    /// Whether this node also needs a horizontal scrollbar (affects vertical geometry)
369
    pub has_horizontal_scrollbar: bool,
370
    /// Whether this node also needs a vertical scrollbar (affects horizontal geometry)
371
    pub has_vertical_scrollbar: bool,
372
}
373

            
374
/// Details of an in-progress smooth scroll animation
375
#[derive(Debug, Clone)]
376
struct ScrollAnimation {
377
    /// When the animation started
378
    start_time: Instant,
379
    /// Total duration of the animation
380
    duration: Duration,
381
    /// Scroll offset at animation start
382
    start_offset: LogicalPosition,
383
    /// Target scroll offset at animation end
384
    target_offset: LogicalPosition,
385
    /// Easing function for interpolation
386
    easing: EasingFunction,
387
}
388

            
389
/// Read-only snapshot of a scroll node's state, returned by CallbackInfo queries.
390
///
391
/// Provides all the information a timer callback needs to compute scroll physics
392
/// without requiring mutable access to the ScrollManager.
393
#[derive(Debug, Clone)]
394
pub struct ScrollNodeInfo {
395
    /// Current scroll offset
396
    pub current_offset: LogicalPosition,
397
    /// Container (viewport) bounds
398
    pub container_rect: LogicalRect,
399
    /// Content bounds (total scrollable area)
400
    pub content_rect: LogicalRect,
401
    /// Maximum scroll in X direction
402
    pub max_scroll_x: f32,
403
    /// Maximum scroll in Y direction
404
    pub max_scroll_y: f32,
405
    /// Per-node overscroll behavior for X axis
406
    pub overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior,
407
    /// Per-node overscroll behavior for Y axis
408
    pub overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior,
409
    /// Per-node overflow scrolling mode (auto vs touch)
410
    pub overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling,
411
}
412

            
413
/// Result of a scroll tick, indicating what actions are needed
414
#[derive(Debug, Default)]
415
pub struct ScrollTickResult {
416
    /// If true, a repaint is needed (scroll offset changed)
417
    pub needs_repaint: bool,
418
    /// Nodes whose scroll position was updated this tick
419
    pub updated_nodes: Vec<(DomId, NodeId)>,
420
}
421

            
422
// ScrollManager Implementation
423

            
424
impl ScrollManager {
425
    /// Creates a new empty ScrollManager
426
3710
    pub fn new() -> Self {
427
3710
        let mut m = Self::default();
428
        // Power-user / test override. Platform shells should call
429
        // `set_natural_scroll` from the OS preference; this env var wins so the
430
        // direction can be flipped without a rebuild and so tests are hermetic.
431
        #[cfg(feature = "std")]
432
3710
        if let Some(v) = std::env::var_os("AZ_NATURAL_SCROLL") {
433
            m.natural_scroll = matches!(v.to_str(), Some("1") | Some("true") | Some("TRUE"));
434
3710
        }
435
3710
        m
436
3710
    }
437

            
438
    /// Set the scroll-direction preference. `true` = natural (content follows the
439
    /// gesture / inverted from the traditional wheel). Platform shells call this
440
    /// from the detected OS preference. See the `natural_scroll` field docs for the
441
    /// macOS/libinput pre-application caveat.
442
2
    pub fn set_natural_scroll(&mut self, natural: bool) {
443
2
        self.natural_scroll = natural;
444
2
    }
445

            
446
    /// Current scroll-direction preference (`true` = natural/inverted).
447
2
    pub fn is_natural_scroll(&self) -> bool {
448
2
        self.natural_scroll
449
2
    }
450

            
451
    /// The sign applied to a raw input delta to get the offset delta:
452
    /// `-1.0` traditional (default), `+1.0` natural. Centralises what used to be a
453
    /// hardcoded `-delta` at every platform call site.
454
    #[inline]
455
5
    fn scroll_sign(&self) -> f32 {
456
5
        if self.natural_scroll {
457
2
            1.0
458
        } else {
459
3
            -1.0
460
        }
461
5
    }
462

            
463
    /// Sizes of the internal maps — used by `AZ_E2E_TEST` to watch for
464
    /// unbounded growth across resize/tick iterations.
465
    pub fn debug_counts(&self) -> (usize, usize, usize) {
466
        (
467
            self.states.len(),
468
            self.external_scroll_ids.len(),
469
            self.scrollbar_states.len(),
470
        )
471
    }
472

            
473
    /// Returns `true` if any scroll position changed since the last
474
    /// `clear_scroll_dirty()` call.
475
    pub fn has_pending_scroll_changes(&self) -> bool {
476
        self.scroll_dirty
477
    }
478

            
479
    /// Clear the dirty flag after the display list has been regenerated.
480
3828
    pub fn clear_scroll_dirty(&mut self) {
481
3828
        self.scroll_dirty = false;
482
3828
    }
483

            
484
    /// Build a map from scroll_id (LocalScrollId) to current scroll offset.
485
    ///
486
    /// Used by the CPU renderer to look up scroll positions at render time
487
    /// without embedding them in the display list.
488
    ///
489
    /// `scroll_ids` maps layout-tree node index → scroll_id. We need to
490
    /// convert our (DomId, NodeId) keys to scroll_ids.
491
    pub fn build_scroll_offset_map(
492
        &self,
493
        dom_id: DomId,
494
        scroll_ids: &std::collections::HashMap<usize, u64>,
495
    ) -> std::collections::HashMap<u64, (f32, f32)> {
496
        let mut map = std::collections::HashMap::new();
497
        for ((d, node_id), state) in &self.states {
498
            if *d != dom_id { continue; }
499
            // Find the scroll_id for this node_id by searching scroll_ids
500
            // (scroll_ids maps layout_index → scroll_id, and node_id.index() == layout_index
501
            // for the root DOM)
502
            let node_idx = node_id.index();
503
            if let Some(&scroll_id) = scroll_ids.get(&node_idx) {
504
                map.insert(scroll_id, (state.current_offset.x, state.current_offset.y));
505
            }
506
        }
507
        map
508
    }
509

            
510
    // ========================================================================
511
    // Input Recording API (timer-based architecture)
512
    // ========================================================================
513

            
514
    /// Records a scroll input event into the shared queue.
515
    ///
516
    /// This is the primary entry point for platform event handlers. Instead of
517
    /// directly modifying scroll positions, the input is queued for the scroll
518
    /// physics timer to process. This decouples input from physics simulation.
519
    ///
520
    /// The scroll-direction sign ([`Self::scroll_sign`]) is applied HERE — the
521
    /// single chokepoint every wheel/axis event flows through — so platform shells
522
    /// pass the RAW delta and no longer hardcode `-delta` at each call site.
523
    ///
524
    /// Returns `true` if the physics timer should be started (i.e., there are
525
    /// now pending inputs and no timer is running yet).
526
    #[cfg(feature = "std")]
527
5
    pub fn record_scroll_input(&mut self, mut input: ScrollInput) -> bool {
528
5
        let sign = self.scroll_sign();
529
5
        input.delta.x *= sign;
530
5
        input.delta.y *= sign;
531
5
        let was_empty = !self.scroll_input_queue.has_pending();
532
5
        self.scroll_input_queue.push(input);
533
5
        was_empty // caller should start timer if this returns true
534
5
    }
535

            
536
    /// High-level entry point for platform event handlers: performs hit-test lookup
537
    /// and queues the input for the physics timer, instead of directly modifying offsets.
538
    ///
539
    /// Returns `Some((dom_id, node_id, should_start_timer))` if a scrollable node was found.
540
    /// The caller should start `SCROLL_MOMENTUM_TIMER_ID` when `should_start_timer` is true.
541
    #[cfg(feature = "std")]
542
    pub fn record_scroll_from_hit_test(
543
        &mut self,
544
        delta_x: f32,
545
        delta_y: f32,
546
        source: ScrollInputSource,
547
        hover_manager: &crate::managers::hover::HoverManager,
548
        input_point_id: &InputPointId,
549
        now: Instant,
550
    ) -> Option<(DomId, NodeId, bool)> {
551
        // Record the raw wheel delta for this pass unconditionally — even when the
552
        // cursor isn't over a scroll container — so a `Scroll` event can be aimed
553
        // at the hovered node (wheel-as-zoom widgets like the map rely on this).
554
        self.pending_wheel_event = Some(LogicalPosition { x: delta_x, y: delta_y });
555

            
556
        let hit_test = hover_manager.get_current(input_point_id)?;
557

            
558
        for (dom_id, hit_node) in &hit_test.hovered_nodes {
559
            for (node_id, _scroll_item) in &hit_node.scroll_hit_test_nodes {
560
                let scrollable = self.is_node_scrollable(*dom_id, *node_id);
561
                if !scrollable {
562
                    continue;
563
                }
564
                let input = ScrollInput {
565
                    dom_id: *dom_id,
566
                    node_id: *node_id,
567
                    delta: LogicalPosition { x: delta_x, y: delta_y },
568
                    timestamp: now,
569
                    source,
570
                };
571
                let should_start_timer = self.record_scroll_input(input);
572
                return Some((*dom_id, *node_id, should_start_timer));
573
            }
574
        }
575

            
576
        None
577
    }
578

            
579
    /// Get a clone of the scroll input queue (for sharing with timer callbacks).
580
    ///
581
    /// The timer callback stores this in its RefAny data and calls `take_all()`
582
    /// each tick to consume pending inputs.
583
    #[cfg(feature = "std")]
584
3
    pub fn get_input_queue(&self) -> ScrollInputQueue {
585
3
        self.scroll_input_queue.clone()
586
3
    }
587

            
588
    /// Advances scroll animations by one tick, returns repaint info
589
89
    pub fn tick(&mut self, now: Instant) -> ScrollTickResult {
590
89
        let mut result = ScrollTickResult::default();
591
89
        for ((dom_id, node_id), state) in self.states.iter_mut() {
592
89
            if let Some(anim) = &state.animation {
593
1
                let elapsed = now.duration_since(&anim.start_time);
594
1
                let t = elapsed.div(&anim.duration).min(1.0);
595
1
                let eased_t = apply_easing(t, anim.easing);
596

            
597
1
                state.current_offset = LogicalPosition {
598
1
                    x: anim.start_offset.x + (anim.target_offset.x - anim.start_offset.x) * eased_t,
599
1
                    y: anim.start_offset.y + (anim.target_offset.y - anim.start_offset.y) * eased_t,
600
1
                };
601
1
                result.needs_repaint = true;
602
1
                result.updated_nodes.push((*dom_id, *node_id));
603

            
604
1
                if t >= 1.0 {
605
                    state.animation = None;
606
1
                }
607
88
            }
608
        }
609
89
        result
610
89
    }
611

            
612
    /// Returns `true` if any scroll node has an active easing animation.
613
    ///
614
    /// Used by GPU render paths to skip rendering when the UI is completely
615
    /// static (no scroll animations, no layout changes).
616
    pub fn has_active_animations(&self) -> bool {
617
        self.states.values().any(|s| s.animation.is_some())
618
    }
619

            
620
    /// Finds the closest scroll-container ancestor for a given node.
621
    ///
622
    /// Walks up the node hierarchy to find a node that is registered as a
623
    /// scrollable node in this ScrollManager. Returns `None` if no scrollable
624
    /// ancestor is found.
625
    pub fn find_scroll_parent(
626
        &self,
627
        dom_id: DomId,
628
        node_id: NodeId,
629
        node_hierarchy: &[azul_core::styled_dom::NodeHierarchyItem],
630
    ) -> Option<NodeId> {
631
        let mut current = Some(node_id);
632
        while let Some(nid) = current {
633
            if self.states.contains_key(&(dom_id, nid)) && nid != node_id {
634
                return Some(nid);
635
            }
636
            current = node_hierarchy
637
                .get(nid.index())
638
                .and_then(|item| item.parent_id());
639
        }
640
        None
641
    }
642

            
643
    /// Check if a node is scrollable (has overflow:scroll/auto and overflowing content)
644
    ///
645
    /// Uses `virtual_scroll_size` (when set) instead of `content_rect` for the
646
    /// overflow check, so VirtualView nodes with large virtual content are correctly
647
    /// identified as scrollable even when only a small subset is rendered.
648
    fn is_node_scrollable(&self, dom_id: DomId, node_id: NodeId) -> bool {
649
        let result = self.states.get(&(dom_id, node_id)).map_or(false, |state| {
650
            let effective_width = state.virtual_scroll_size
651
                .map(|s| s.width)
652
                .unwrap_or(state.content_rect.size.width);
653
            let effective_height = state.virtual_scroll_size
654
                .map(|s| s.height)
655
                .unwrap_or(state.content_rect.size.height);
656
            let has_horizontal = effective_width > state.container_rect.size.width;
657
            let has_vertical = effective_height > state.container_rect.size.height;
658
            has_horizontal || has_vertical
659
        });
660
        result
661
    }
662

            
663
    // +spec:overflow:4000a6 - scroll position as offset from scroll origin within scrollport
664
    /// Sets scroll position immediately (no animation), clamped to valid bounds.
665
88
    pub fn set_scroll_position(
666
88
        &mut self,
667
88
        dom_id: DomId,
668
88
        node_id: NodeId,
669
88
        position: LogicalPosition,
670
88
        now: Instant,
671
88
    ) {
672
88
        let state = self
673
88
            .states
674
88
            .entry((dom_id, node_id))
675
88
            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
676
88
        let clamped = state.clamp(position);
677
88
        if (clamped.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
678
44
            || (clamped.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
679
88
        {
680
88
            self.scroll_dirty = true;
681
88
        }
682
88
        state.current_offset = clamped;
683
88
        state.animation = None;
684
88
        state.last_activity = now;
685
88
    }
686

            
687
    /// Sets scroll position immediately without clamping.
688
    ///
689
    /// Used by the scroll physics timer which does its own rubber-band clamping.
690
    /// Allows the offset to go outside [0, max_scroll] for overscroll/rubber-banding.
691
    pub fn set_scroll_position_unclamped(
692
        &mut self,
693
        dom_id: DomId,
694
        node_id: NodeId,
695
        position: LogicalPosition,
696
        now: Instant,
697
    ) {
698
        let state = self
699
            .states
700
            .entry((dom_id, node_id))
701
            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
702
        if (position.x - state.current_offset.x).abs() > SCROLL_CHANGE_EPSILON
703
            || (position.y - state.current_offset.y).abs() > SCROLL_CHANGE_EPSILON
704
        {
705
            self.scroll_dirty = true;
706
        }
707
        state.current_offset = position;
708
        state.animation = None;
709
        state.last_activity = now;
710
    }
711

            
712
    /// Scrolls by a delta amount with animation
713
    pub fn scroll_by(
714
        &mut self,
715
        dom_id: DomId,
716
        node_id: NodeId,
717
        delta: LogicalPosition,
718
        duration: Duration,
719
        easing: EasingFunction,
720
        now: Instant,
721
    ) {
722
        let current = self.get_current_offset(dom_id, node_id).unwrap_or_default();
723
        let target = LogicalPosition {
724
            x: current.x + delta.x,
725
            y: current.y + delta.y,
726
        };
727
        self.scroll_to(dom_id, node_id, target, duration, easing, now);
728
    }
729

            
730
    /// Scrolls to an absolute position with animation
731
    ///
732
    /// If duration is zero, the position is set immediately without animation.
733
89
    pub fn scroll_to(
734
89
        &mut self,
735
89
        dom_id: DomId,
736
89
        node_id: NodeId,
737
89
        target: LogicalPosition,
738
89
        duration: Duration,
739
89
        easing: EasingFunction,
740
89
        now: Instant,
741
89
    ) {
742
        // For zero duration, set position immediately
743
89
        let is_zero = match &duration {
744
89
            Duration::System(s) => s.secs == 0 && s.nanos == 0,
745
            Duration::Tick(t) => t.tick_diff == 0,
746
        };
747

            
748
89
        if is_zero {
749
88
            self.set_scroll_position(dom_id, node_id, target, now);
750
88
            return;
751
1
        }
752

            
753
1
        let state = self
754
1
            .states
755
1
            .entry((dom_id, node_id))
756
1
            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
757
1
        let clamped_target = state.clamp(target);
758
1
        state.animation = Some(ScrollAnimation {
759
1
            start_time: now.clone(),
760
1
            duration,
761
1
            start_offset: state.current_offset,
762
1
            target_offset: clamped_target,
763
1
            easing,
764
1
        });
765
1
        state.last_activity = now;
766
89
    }
767

            
768
    /// Updates the container and content bounds for a scrollable node
769
396
    pub fn update_node_bounds(
770
396
        &mut self,
771
396
        dom_id: DomId,
772
396
        node_id: NodeId,
773
396
        container_rect: LogicalRect,
774
396
        content_rect: LogicalRect,
775
396
        now: Instant,
776
396
    ) {
777
396
        let state = self
778
396
            .states
779
396
            .entry((dom_id, node_id))
780
396
            .or_insert_with(|| AnimatedScrollState::new(now));
781
396
        state.container_rect = container_rect;
782
396
        state.content_rect = content_rect;
783
396
        state.current_offset = state.clamp(state.current_offset);
784
396
    }
785

            
786
    /// Updates virtual scroll bounds for a VirtualView node.
787
    ///
788
    /// Called after VirtualView callback returns to propagate the virtual content size
789
    /// to the ScrollManager. Clamp logic then uses `virtual_scroll_size` (when set)
790
    /// instead of `content_rect` for max scroll bounds.
791
    ///
792
    /// If no scroll state exists yet for this node (because `register_or_update_scroll_node`
793
    /// hasn't been called yet), this creates a default state so the virtual size is preserved.
794
308
    pub fn update_virtual_scroll_bounds(
795
308
        &mut self,
796
308
        dom_id: DomId,
797
308
        node_id: NodeId,
798
308
        virtual_scroll_size: LogicalSize,
799
308
        virtual_scroll_offset: Option<LogicalPosition>,
800
308
    ) {
801
308
        let key = (dom_id, node_id);
802
308
        let state = self.states.entry(key).or_insert_with(|| {
803
            // AzInstant (System on std, safe Tick on no-clock targets) — not the
804
            // WASM-panicking std::time::Instant::now(). (A refinement would thread
805
            // the window's get_system_time_fn callback through for hookability.)
806
            AnimatedScrollState::new(azul_core::task::Instant::now())
807
        });
808
308
        state.virtual_scroll_size = Some(virtual_scroll_size);
809
308
        state.virtual_scroll_offset = virtual_scroll_offset;
810
        // Re-clamp with new virtual bounds
811
308
        state.current_offset = state.clamp(state.current_offset);
812
308
    }
813

            
814
    /// Returns the current scroll offset for a node
815
32033
    pub fn get_current_offset(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
816
32033
        self.states
817
32033
            .get(&(dom_id, node_id))
818
32033
            .map(|s| s.current_offset)
819
32033
    }
820

            
821
    /// Returns the timestamp of last scroll activity for a node
822
    pub fn get_last_activity_time(&self, dom_id: DomId, node_id: NodeId) -> Option<Instant> {
823
        self.states
824
            .get(&(dom_id, node_id))
825
            .map(|s| s.last_activity.clone())
826
    }
827

            
828
    /// Returns the internal scroll state for a node
829
    pub fn get_scroll_state(&self, dom_id: DomId, node_id: NodeId) -> Option<&AnimatedScrollState> {
830
        self.states.get(&(dom_id, node_id))
831
    }
832

            
833
    /// Returns a read-only snapshot of a scroll node's state.
834
    ///
835
    /// This is the preferred way for timer callbacks to query scroll state,
836
    /// since they only have `&CallbackInfo` (read-only access).
837
    ///
838
    /// When `virtual_scroll_size` is set (for VirtualView nodes), the max scroll
839
    /// bounds are computed from the virtual size instead of `content_rect`.
840
    pub fn get_scroll_node_info(
841
        &self,
842
        dom_id: DomId,
843
        node_id: NodeId,
844
    ) -> Option<ScrollNodeInfo> {
845
        let state = self.states.get(&(dom_id, node_id))?;
846
        let effective_content_width = state.virtual_scroll_size
847
            .map(|s| s.width)
848
            .unwrap_or(state.content_rect.size.width);
849
        let effective_content_height = state.virtual_scroll_size
850
            .map(|s| s.height)
851
            .unwrap_or(state.content_rect.size.height);
852
        let max_x = (effective_content_width - state.container_rect.size.width).max(0.0);
853
        let max_y = (effective_content_height - state.container_rect.size.height).max(0.0);
854
        Some(ScrollNodeInfo {
855
            current_offset: state.current_offset,
856
            container_rect: state.container_rect,
857
            content_rect: state.content_rect,
858
            max_scroll_x: max_x,
859
            max_scroll_y: max_y,
860
            overscroll_behavior_x: state.overscroll_behavior_x,
861
            overscroll_behavior_y: state.overscroll_behavior_y,
862
            overflow_scrolling: state.overflow_scrolling,
863
        })
864
    }
865

            
866
    /// Returns all scroll positions for nodes in a specific DOM
867
4445
    pub fn get_scroll_states_for_dom(&self, dom_id: DomId) -> BTreeMap<NodeId, ScrollPosition> {
868
        // M12.7: iterating an EMPTY hashbrown map (RawIterRange) mis-lifts to
869
        // wasm and loops forever (same class as the font-id / GPU-cache loops).
870
        // For the headless web path `states` is empty; guard it (len-based, no
871
        // iteration). Desktop unchanged.
872
4445
        if self.states.is_empty() {
873
4049
            return BTreeMap::new();
874
396
        }
875
396
        self.states
876
396
            .iter()
877
396
            .filter(|((d, _), _)| *d == dom_id)
878
396
            .map(|((_, node_id), state)| {
879
                // Use virtual_scroll_size (from VirtualView callback) when available,
880
                // otherwise fall back to content_rect.size from layout.
881
88
                let effective_content_size = state.virtual_scroll_size
882
88
                    .unwrap_or(state.content_rect.size);
883
88
                (
884
88
                    *node_id,
885
88
                    ScrollPosition {
886
88
                        parent_rect: state.container_rect,
887
88
                        children_rect: LogicalRect::new(
888
88
                            state.current_offset,
889
88
                            effective_content_size,
890
88
                        ),
891
88
                    },
892
88
                )
893
88
            })
894
396
            .collect()
895
4445
    }
896

            
897
    /// Registers or updates a scrollable node with its container and content sizes.
898
    /// This should be called after layout for each node that has overflow:scroll or overflow:auto
899
    /// with overflowing content.
900
    ///
901
    /// If the node already exists, updates container/content rects without changing scroll offset.
902
    /// If the node is new, initializes with zero scroll offset.
903
    pub fn register_or_update_scroll_node(
904
        &mut self,
905
        dom_id: DomId,
906
        node_id: NodeId,
907
        container_rect: LogicalRect,
908
        content_size: LogicalSize,
909
        now: Instant,
910
        scrollbar_thickness: f32,
911
        visual_width_px: f32,
912
        has_horizontal_scrollbar: bool,
913
        has_vertical_scrollbar: bool,
914
    ) {
915
        let key = (dom_id, node_id);
916

            
917
        let content_rect = LogicalRect {
918
            origin: LogicalPosition::zero(),
919
            size: content_size,
920
        };
921

            
922
        if let Some(existing) = self.states.get_mut(&key) {
923
            // Update rects, keep scroll offset
924
            existing.container_rect = container_rect;
925
            existing.content_rect = content_rect;
926
            existing.scrollbar_thickness = scrollbar_thickness;
927
            existing.visual_width_px = visual_width_px;
928
            existing.has_horizontal_scrollbar = has_horizontal_scrollbar;
929
            existing.has_vertical_scrollbar = has_vertical_scrollbar;
930
            // Re-clamp current offset to new bounds
931
            existing.current_offset = existing.clamp(existing.current_offset);
932
        } else {
933
            // +spec:overflow:8c7aa1 - initial scroll position is zero (scroll origin for LTR/TTB)
934
            self.states.insert(
935
                key,
936
                AnimatedScrollState {
937
                    current_offset: LogicalPosition::zero(),
938
                    animation: None,
939
                    last_activity: now,
940
                    container_rect,
941
                    content_rect,
942
                    virtual_scroll_size: None,
943
                    virtual_scroll_offset: None,
944
                    overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
945
                    overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
946
                    overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
947
                    scrollbar_thickness,
948
                    visual_width_px,
949
                    has_horizontal_scrollbar,
950
                    has_vertical_scrollbar,
951
                },
952
            );
953
        }
954
    }
955

            
956
    // ExternalScrollId Management
957

            
958
    /// Register a scroll node and get its ExternalScrollId for WebRender.
959
    /// If the node already has an ID, returns the existing one.
960
    pub fn register_scroll_node(&mut self, dom_id: DomId, node_id: NodeId) -> ExternalScrollId {
961
        use azul_core::hit_test::PipelineId;
962

            
963
        let key = (dom_id, node_id);
964
        if let Some(&existing_id) = self.external_scroll_ids.get(&key) {
965
            return existing_id;
966
        }
967

            
968
        // Generate new ExternalScrollId (id, pipeline_id)
969
        // PipelineId = (PipelineSourceId: u32, u32)
970
        // Use dom_id.inner for PipelineSourceId, node_id.index() for second part
971
        let pipeline_id = PipelineId(
972
            dom_id.inner as u32, // PipelineSourceId is just u32
973
            node_id.index() as u32,
974
        );
975
        let new_id = ExternalScrollId(self.next_external_scroll_id, pipeline_id);
976
        self.next_external_scroll_id += 1;
977
        self.external_scroll_ids.insert(key, new_id);
978
        new_id
979
    }
980

            
981
    /// Get the ExternalScrollId for a node (returns None if not registered)
982
    pub fn get_external_scroll_id(
983
        &self,
984
        dom_id: DomId,
985
        node_id: NodeId,
986
    ) -> Option<ExternalScrollId> {
987
        self.external_scroll_ids.get(&(dom_id, node_id)).copied()
988
    }
989

            
990
    /// Iterate over all registered external scroll IDs
991
    pub fn iter_external_scroll_ids(
992
        &self,
993
    ) -> impl Iterator<Item = ((DomId, NodeId), ExternalScrollId)> + '_ {
994
        self.external_scroll_ids.iter().map(|(k, v)| (*k, *v))
995
    }
996

            
997
    // Scrollbar State Management
998

            
999
    /// Calculate scrollbar states for all visible scrollbars.
    /// This should be called once per frame after layout is complete.
    /// Uses the shared `compute_scrollbar_geometry()` for consistent geometry.
    pub fn calculate_scrollbar_states(&mut self) {
        self.scrollbar_states.clear();
        // Collect vertical scrollbar states
        // Uses virtual_scroll_size (when set) for the overflow check and thumb ratio,
        // so VirtualView nodes with large virtual content show correct scrollbar geometry.
        let vertical_states: Vec<_> = self
            .states
            .iter()
            .filter(|(_, s)| {
                let effective_height = s.virtual_scroll_size
                    .map(|vs| vs.height)
                    .unwrap_or(s.content_rect.size.height);
                effective_height > s.container_rect.size.height
            })
            .map(|((dom_id, node_id), scroll_state)| {
                let v_state = Self::calculate_scrollbar_state_from_geometry(
                    scroll_state,
                    ScrollbarOrientation::Vertical,
                );
                ((*dom_id, *node_id, ScrollbarOrientation::Vertical), v_state)
            })
            .collect();
        // Collect horizontal scrollbar states
        let horizontal_states: Vec<_> = self
            .states
            .iter()
            .filter(|(_, s)| {
                let effective_width = s.virtual_scroll_size
                    .map(|vs| vs.width)
                    .unwrap_or(s.content_rect.size.width);
                effective_width > s.container_rect.size.width
            })
            .map(|((dom_id, node_id), scroll_state)| {
                let h_state = Self::calculate_scrollbar_state_from_geometry(
                    scroll_state,
                    ScrollbarOrientation::Horizontal,
                );
                (
                    (*dom_id, *node_id, ScrollbarOrientation::Horizontal),
                    h_state,
                )
            })
            .collect();
        // Insert all states
        self.scrollbar_states.extend(vertical_states);
        self.scrollbar_states.extend(horizontal_states);
    }
    /// Calculate scrollbar state using the shared `compute_scrollbar_geometry()`.
    fn calculate_scrollbar_state_from_geometry(
        scroll_state: &AnimatedScrollState,
        orientation: ScrollbarOrientation,
    ) -> ScrollbarState {
        let scrollbar_thickness = if scroll_state.visual_width_px > 0.0 {
            scroll_state.visual_width_px
        } else if scroll_state.scrollbar_thickness > 0.0 {
            scroll_state.scrollbar_thickness
        } else {
            crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX
        };
        let content_size = scroll_state.virtual_scroll_size
            .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).
309
    pub fn new(now: Instant) -> Self {
309
        Self {
309
            current_offset: LogicalPosition::zero(),
309
            animation: None,
309
            last_activity: now,
309
            container_rect: LogicalRect::zero(),
309
            content_rect: LogicalRect::zero(),
309
            virtual_scroll_size: None,
309
            virtual_scroll_offset: None,
309
            overscroll_behavior_x: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
309
            overscroll_behavior_y: azul_css::props::style::scrollbar::OverscrollBehavior::Auto,
309
            overflow_scrolling: azul_css::props::style::scrollbar::OverflowScrolling::Auto,
309
            scrollbar_thickness: crate::solver3::fc::DEFAULT_SCROLLBAR_WIDTH_PX,
309
            visual_width_px: 0.0,
309
            has_horizontal_scrollbar: false,
309
            has_vertical_scrollbar: false,
309
        }
309
    }
    /// 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.
793
    pub fn clamp(&self, position: LogicalPosition) -> LogicalPosition {
793
        let effective_width = self.virtual_scroll_size
793
            .map(|s| s.width)
793
            .unwrap_or(self.content_rect.size.width);
793
        let effective_height = self.virtual_scroll_size
793
            .map(|s| s.height)
793
            .unwrap_or(self.content_rect.size.height);
793
        let max_x = (effective_width - self.container_rect.size.width).max(0.0);
793
        let max_y = (effective_height - self.container_rect.size.height).max(0.0);
793
        LogicalPosition {
793
            x: position.x.max(0.0).min(max_x),
793
            y: position.y.max(0.0).min(max_y),
793
        }
793
    }
}
// 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);
                }
            }
        }
    }
}
// ============================================================================
// Natural-scroll direction — unit tests (#17)
// ============================================================================
#[cfg(all(test, feature = "std"))]
mod natural_scroll_tests {
    use super::*;
    use azul_core::dom::{DomId, NodeId};
    use azul_core::geom::LogicalPosition;
    use azul_core::task::Instant;
4
    fn raw_input(dx: f32, dy: f32) -> ScrollInput {
4
        ScrollInput {
4
            dom_id: DomId::ROOT_ID,
4
            node_id: NodeId::new(0),
4
            delta: LogicalPosition::new(dx, dy),
4
            timestamp: Instant::from(std::time::Instant::now()),
4
            source: ScrollInputSource::WheelDiscrete,
4
        }
4
    }
    #[test]
1
    fn default_is_traditional_and_inverts_raw_delta() {
        // With AZ_NATURAL_SCROLL unset, the default is traditional: the offset
        // delta is the NEGATION of the raw input — exactly what the per-platform
        // `-delta` hardcodes used to do, now centralised.
1
        let mut m = ScrollManager::new();
1
        assert!(!m.is_natural_scroll(), "default must be traditional");
1
        m.record_scroll_input(raw_input(3.0, 10.0));
1
        let q = m.get_input_queue().take_all();
1
        assert_eq!(q.len(), 1);
1
        assert_eq!(q[0].delta.x, -3.0, "x must be inverted by the default sign");
1
        assert_eq!(q[0].delta.y, -10.0, "y must be inverted by the default sign");
1
    }
    #[test]
1
    fn natural_passes_raw_delta_through() {
1
        let mut m = ScrollManager::new();
1
        m.set_natural_scroll(true);
1
        assert!(m.is_natural_scroll());
1
        m.record_scroll_input(raw_input(3.0, 10.0));
1
        let q = m.get_input_queue().take_all();
1
        assert_eq!(q.len(), 1);
1
        assert_eq!(q[0].delta.x, 3.0, "natural mode must NOT invert x");
1
        assert_eq!(q[0].delta.y, 10.0, "natural mode must NOT invert y");
1
    }
    #[test]
1
    fn toggling_flips_sign_for_subsequent_input() {
        // Same raw input, opposite directions before/after the toggle — proves the
        // single flag is the only thing controlling direction.
1
        let mut m = ScrollManager::new();
1
        m.record_scroll_input(raw_input(0.0, 5.0));
1
        m.set_natural_scroll(true);
1
        m.record_scroll_input(raw_input(0.0, 5.0));
1
        let q = m.get_input_queue().take_all();
1
        assert_eq!(q.len(), 2);
1
        assert_eq!(q[0].delta.y, -5.0, "traditional first");
1
        assert_eq!(q[1].delta.y, 5.0, "natural after toggle");
1
    }
}