Skip to main content

azul_layout/managers/
scroll_state.rs

1//! Pure scroll state management
2//!
3//! This module provides:
4//! - Smooth scroll animations with easing
5//! - Event source classification for scroll events
6//! - Scrollbar geometry and hit-testing
7//! - ExternalScrollId mapping for WebRender integration
8
9use alloc::collections::BTreeMap;
10
11use azul_core::{
12    dom::{DomId, NodeId, ScrollbarOrientation},
13    events::{
14        EasingFunction, EventData, EventProvider, EventSource, EventType, ScrollDeltaMode,
15        ScrollEventData, SyntheticEvent,
16    },
17    geom::{LogicalPosition, LogicalRect, LogicalSize},
18    hit_test::{ExternalScrollId, ScrollPosition},
19    styled_dom::NodeHierarchyItemId,
20    task::{Duration, Instant},
21};
22
23use crate::managers::hover::InputPointId;
24
25// Scrollbar Component Types
26
27/// Which component of a scrollbar was hit during hit-testing
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum ScrollbarComponent {
30    /// The track (background) of the scrollbar
31    Track,
32    /// The draggable thumb (indicator of current scroll position)
33    Thumb,
34    /// Top/left button (scrolls by one page up/left)
35    TopButton,
36    /// Bottom/right button (scrolls by one page down/right)
37    BottomButton,
38}
39
40/// Scrollbar geometry state (calculated per frame, used for hit-testing and rendering)
41#[derive(Debug, Clone)]
42pub struct ScrollbarState {
43    /// Is this scrollbar visible? (content larger than container)
44    pub visible: bool,
45    /// Orientation
46    pub orientation: ScrollbarOrientation,
47    /// Base size (1:1 square, width = height). This is the unscaled size.
48    pub base_size: f32,
49    /// Scale transform to apply (calculated from container size)
50    pub scale: LogicalPosition, // x = width scale, y = height scale
51    /// Thumb position ratio (0.0 = top/left, 1.0 = bottom/right)
52    pub thumb_position_ratio: f32,
53    /// Thumb size ratio (0.0 = invisible, 1.0 = entire track)
54    pub thumb_size_ratio: f32,
55    /// Position of the scrollbar in the container (for hit-testing)
56    pub track_rect: LogicalRect,
57}
58
59impl ScrollbarState {
60    /// Determine which component was hit at the given local position (relative to track_rect
61    /// origin)
62    pub fn hit_test_component(&self, local_pos: LogicalPosition) -> ScrollbarComponent {
63        match self.orientation {
64            ScrollbarOrientation::Vertical => {
65                let button_height = self.base_size;
66
67                // Top button
68                if local_pos.y < button_height {
69                    return ScrollbarComponent::TopButton;
70                }
71
72                // Bottom button
73                let track_height = self.track_rect.size.height;
74                if local_pos.y > track_height - button_height {
75                    return ScrollbarComponent::BottomButton;
76                }
77
78                // Calculate thumb bounds
79                let track_height_usable = track_height - 2.0 * button_height;
80                let thumb_height = track_height_usable * self.thumb_size_ratio;
81                let thumb_y_start = button_height
82                    + (track_height_usable - thumb_height) * self.thumb_position_ratio;
83                let thumb_y_end = thumb_y_start + thumb_height;
84
85                // Check if inside thumb
86                if local_pos.y >= thumb_y_start && local_pos.y <= thumb_y_end {
87                    ScrollbarComponent::Thumb
88                } else {
89                    ScrollbarComponent::Track
90                }
91            }
92            ScrollbarOrientation::Horizontal => {
93                let button_width = self.base_size;
94
95                // Left button
96                if local_pos.x < button_width {
97                    return ScrollbarComponent::TopButton;
98                }
99
100                // Right button
101                let track_width = self.track_rect.size.width;
102                if local_pos.x > track_width - button_width {
103                    return ScrollbarComponent::BottomButton;
104                }
105
106                // Calculate thumb bounds
107                let track_width_usable = track_width - 2.0 * button_width;
108                let thumb_width = track_width_usable * self.thumb_size_ratio;
109                let thumb_x_start =
110                    button_width + (track_width_usable - thumb_width) * self.thumb_position_ratio;
111                let thumb_x_end = thumb_x_start + thumb_width;
112
113                // Check if inside thumb
114                if local_pos.x >= thumb_x_start && local_pos.x <= thumb_x_end {
115                    ScrollbarComponent::Thumb
116                } else {
117                    ScrollbarComponent::Track
118                }
119            }
120        }
121    }
122}
123
124/// Result of a scrollbar hit-test
125///
126/// Contains information about which scrollbar component was hit
127/// and the position relative to both the track and the window.
128#[derive(Debug, Clone, Copy)]
129pub struct ScrollbarHit {
130    /// DOM containing the scrollable node
131    pub dom_id: DomId,
132    /// Node with the scrollbar
133    pub node_id: NodeId,
134    /// Whether this is a vertical or horizontal scrollbar
135    pub orientation: ScrollbarOrientation,
136    /// Which component was hit (track, thumb, buttons)
137    pub component: ScrollbarComponent,
138    /// Position relative to track_rect origin
139    pub local_position: LogicalPosition,
140    /// Original global window position
141    pub global_position: LogicalPosition,
142}
143
144// Core Scroll Manager
145
146/// Manages all scroll state and animations for a window
147#[derive(Debug, Clone, Default)]
148pub struct ScrollManager {
149    /// Maps (DomId, NodeId) to their scroll state
150    states: BTreeMap<(DomId, NodeId), AnimatedScrollState>,
151    /// Maps (DomId, NodeId) to WebRender ExternalScrollId
152    external_scroll_ids: BTreeMap<(DomId, NodeId), ExternalScrollId>,
153    /// Counter for generating unique ExternalScrollId values
154    next_external_scroll_id: u64,
155    /// Scrollbar geometry states (calculated per frame)
156    scrollbar_states: BTreeMap<(DomId, NodeId, ScrollbarOrientation), ScrollbarState>,
157    /// Track if we had any scroll activity this frame
158    had_scroll_activity: bool,
159    /// Track if we had any programmatic scroll this frame
160    had_programmatic_scroll: bool,
161    /// Track if any new DOMs were added this frame
162    had_new_doms: bool,
163}
164
165/// The complete scroll state for a single node (with animation support)
166#[derive(Debug, Clone)]
167pub struct AnimatedScrollState {
168    /// Current scroll offset (live, may be animating)
169    pub current_offset: LogicalPosition,
170    /// Previous frame's scroll offset (for delta calculation)
171    pub previous_offset: LogicalPosition,
172    /// Ongoing smooth scroll animation, if any
173    pub animation: Option<ScrollAnimation>,
174    /// Last time scroll activity occurred (for fading scrollbars)
175    pub last_activity: Instant,
176    /// Bounds of the scrollable container
177    pub container_rect: LogicalRect,
178    /// Bounds of the total content (for calculating scroll limits)
179    pub content_rect: LogicalRect,
180}
181
182/// Details of an in-progress smooth scroll animation
183#[derive(Debug, Clone)]
184struct ScrollAnimation {
185    /// When the animation started
186    start_time: Instant,
187    /// Total duration of the animation
188    duration: Duration,
189    /// Scroll offset at animation start
190    start_offset: LogicalPosition,
191    /// Target scroll offset at animation end
192    target_offset: LogicalPosition,
193    /// Easing function for interpolation
194    easing: EasingFunction,
195}
196
197/// Summary of scroll-related events that occurred during a frame
198#[derive(Debug, Clone, Copy, Default)]
199pub struct FrameScrollInfo {
200    /// Whether any scroll input occurred this frame
201    pub had_scroll_activity: bool,
202    /// Whether programmatic scroll (scrollTo) occurred
203    pub had_programmatic_scroll: bool,
204    /// Whether new scrollable DOMs were added
205    pub had_new_doms: bool,
206}
207
208/// Scroll event to be processed with source tracking
209#[derive(Debug, Clone)]
210pub struct ScrollEvent {
211    /// DOM containing the scrollable node
212    pub dom_id: DomId,
213    /// Target scroll node
214    pub node_id: NodeId,
215    /// Scroll delta (positive = scroll down/right)
216    pub delta: LogicalPosition,
217    /// Event source (User, Programmatic, etc.)
218    pub source: EventSource,
219    /// Animation duration (None = instant)
220    pub duration: Option<Duration>,
221    /// Easing function for smooth scrolling
222    pub easing: EasingFunction,
223}
224
225/// Result of a scroll tick, indicating what actions are needed
226#[derive(Debug, Default)]
227pub struct ScrollTickResult {
228    /// If true, a repaint is needed (scroll offset changed)
229    pub needs_repaint: bool,
230    /// Nodes whose scroll position was updated this tick
231    pub updated_nodes: Vec<(DomId, NodeId)>,
232}
233
234// ScrollManager Implementation
235
236impl ScrollManager {
237    /// Creates a new empty ScrollManager
238    pub fn new() -> Self {
239        Self::default()
240    }
241
242    /// Prepares state for a new frame by saving current offsets as previous
243    pub fn begin_frame(&mut self) {
244        self.had_scroll_activity = false;
245        self.had_programmatic_scroll = false;
246        self.had_new_doms = false;
247
248        // Save current offsets as previous for delta calculation
249        for state in self.states.values_mut() {
250            state.previous_offset = state.current_offset;
251        }
252    }
253
254    /// Returns scroll activity summary for the completed frame
255    pub fn end_frame(&self) -> FrameScrollInfo {
256        FrameScrollInfo {
257            had_scroll_activity: self.had_scroll_activity,
258            had_programmatic_scroll: self.had_programmatic_scroll,
259            had_new_doms: self.had_new_doms,
260        }
261    }
262
263    /// Advances scroll animations by one tick, returns repaint info
264    pub fn tick(&mut self, now: Instant) -> ScrollTickResult {
265        let mut result = ScrollTickResult::default();
266        for ((dom_id, node_id), state) in self.states.iter_mut() {
267            if let Some(anim) = &state.animation {
268                let elapsed = now.duration_since(&anim.start_time);
269                let t = elapsed.div(&anim.duration).min(1.0);
270                let eased_t = apply_easing(t, anim.easing);
271
272                state.current_offset = LogicalPosition {
273                    x: anim.start_offset.x + (anim.target_offset.x - anim.start_offset.x) * eased_t,
274                    y: anim.start_offset.y + (anim.target_offset.y - anim.start_offset.y) * eased_t,
275                };
276                result.needs_repaint = true;
277                result.updated_nodes.push((*dom_id, *node_id));
278
279                if t >= 1.0 {
280                    state.animation = None;
281                }
282            }
283        }
284        result
285    }
286
287    /// Processes a scroll event, applying immediate or animated scroll
288    pub fn process_scroll_event(&mut self, event: ScrollEvent, now: Instant) -> bool {
289        self.had_scroll_activity = true;
290        if event.source == EventSource::Programmatic || event.source == EventSource::User {
291            self.had_programmatic_scroll = true;
292        }
293
294        if let Some(duration) = event.duration {
295            self.scroll_by(
296                event.dom_id,
297                event.node_id,
298                event.delta,
299                duration,
300                event.easing,
301                now,
302            );
303        } else {
304            let current = self
305                .get_current_offset(event.dom_id, event.node_id)
306                .unwrap_or_default();
307            let new_position = LogicalPosition {
308                x: current.x + event.delta.x,
309                y: current.y + event.delta.y,
310            };
311            self.set_scroll_position(event.dom_id, event.node_id, new_position, now);
312        }
313        true
314    }
315
316    /// Records a scroll input sample and applies it to the first scrollable node under the cursor
317    ///
318    /// Finds the first scrollable node in the hit test hierarchy and applies
319    /// the scroll delta. Returns the scrolled node if successful.
320    pub fn record_sample(
321        &mut self,
322        delta_x: f32,
323        delta_y: f32,
324        hover_manager: &crate::managers::hover::HoverManager,
325        input_point_id: &InputPointId,
326        now: Instant,
327    ) -> Option<(DomId, NodeId)> {
328        let hit_test = hover_manager.get_current(input_point_id)?;
329        
330        // Find first scrollable node in hit test hierarchy
331        for (dom_id, hit_node) in &hit_test.hovered_nodes {
332            for (node_id, _scroll_item) in &hit_node.scroll_hit_test_nodes {
333                let is_scrollable = self.is_node_scrollable(*dom_id, *node_id);
334                if is_scrollable {
335                    let delta = LogicalPosition {
336                        x: delta_x,
337                        y: delta_y,
338                    };
339
340                    let current = self
341                        .get_current_offset(*dom_id, *node_id)
342                        .unwrap_or_default();
343                    let new_position = LogicalPosition {
344                        x: current.x + delta.x,
345                        y: current.y + delta.y,
346                    };
347
348                    self.set_scroll_position(*dom_id, *node_id, new_position, now);
349                    self.had_scroll_activity = true;
350
351                    return Some((*dom_id, *node_id));
352                }
353            }
354        }
355
356        None
357    }
358
359    /// Check if a node is scrollable (has overflow:scroll/auto and overflowing content)
360    fn is_node_scrollable(&self, dom_id: DomId, node_id: NodeId) -> bool {
361        self.states.get(&(dom_id, node_id)).map_or(false, |state| {
362            let has_horizontal = state.content_rect.size.width > state.container_rect.size.width;
363            let has_vertical = state.content_rect.size.height > state.container_rect.size.height;
364            has_horizontal || has_vertical
365        })
366    }
367
368    /// Returns the scroll delta applied this frame, if non-zero
369    pub fn get_scroll_delta(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
370        let state = self.states.get(&(dom_id, node_id))?;
371        let delta = LogicalPosition {
372            x: state.current_offset.x - state.previous_offset.x,
373            y: state.current_offset.y - state.previous_offset.y,
374        };
375        (delta.x.abs() > 0.001 || delta.y.abs() > 0.001).then_some(delta)
376    }
377
378    /// Returns true if the node had scroll activity this frame
379    pub fn had_scroll_activity_for_node(&self, dom_id: DomId, node_id: NodeId) -> bool {
380        self.get_scroll_delta(dom_id, node_id).is_some()
381    }
382
383    /// Sets scroll position immediately (no animation)
384    pub fn set_scroll_position(
385        &mut self,
386        dom_id: DomId,
387        node_id: NodeId,
388        position: LogicalPosition,
389        now: Instant,
390    ) {
391        let state = self
392            .states
393            .entry((dom_id, node_id))
394            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
395        state.current_offset = state.clamp(position);
396        state.animation = None;
397        state.last_activity = now;
398    }
399
400    /// Scrolls by a delta amount with animation
401    pub fn scroll_by(
402        &mut self,
403        dom_id: DomId,
404        node_id: NodeId,
405        delta: LogicalPosition,
406        duration: Duration,
407        easing: EasingFunction,
408        now: Instant,
409    ) {
410        let current = self.get_current_offset(dom_id, node_id).unwrap_or_default();
411        let target = LogicalPosition {
412            x: current.x + delta.x,
413            y: current.y + delta.y,
414        };
415        self.scroll_to(dom_id, node_id, target, duration, easing, now);
416    }
417
418    /// Scrolls to an absolute position with animation
419    ///
420    /// If duration is zero, the position is set immediately without animation.
421    pub fn scroll_to(
422        &mut self,
423        dom_id: DomId,
424        node_id: NodeId,
425        target: LogicalPosition,
426        duration: Duration,
427        easing: EasingFunction,
428        now: Instant,
429    ) {
430        // For zero duration, set position immediately
431        let is_zero = match &duration {
432            Duration::System(s) => s.secs == 0 && s.nanos == 0,
433            Duration::Tick(t) => t.tick_diff == 0,
434        };
435
436        if is_zero {
437            self.set_scroll_position(dom_id, node_id, target, now);
438            return;
439        }
440
441        let state = self
442            .states
443            .entry((dom_id, node_id))
444            .or_insert_with(|| AnimatedScrollState::new(now.clone()));
445        let clamped_target = state.clamp(target);
446        state.animation = Some(ScrollAnimation {
447            start_time: now.clone(),
448            duration,
449            start_offset: state.current_offset,
450            target_offset: clamped_target,
451            easing,
452        });
453        state.last_activity = now;
454    }
455
456    /// Updates the container and content bounds for a scrollable node
457    pub fn update_node_bounds(
458        &mut self,
459        dom_id: DomId,
460        node_id: NodeId,
461        container_rect: LogicalRect,
462        content_rect: LogicalRect,
463        now: Instant,
464    ) {
465        if !self.states.contains_key(&(dom_id, node_id)) {
466            self.had_new_doms = true;
467        }
468        let state = self
469            .states
470            .entry((dom_id, node_id))
471            .or_insert_with(|| AnimatedScrollState::new(now));
472        state.container_rect = container_rect;
473        state.content_rect = content_rect;
474        state.current_offset = state.clamp(state.current_offset);
475    }
476
477    /// Returns the current scroll offset for a node
478    pub fn get_current_offset(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalPosition> {
479        self.states
480            .get(&(dom_id, node_id))
481            .map(|s| s.current_offset)
482    }
483
484    /// Returns the timestamp of last scroll activity for a node
485    pub fn get_last_activity_time(&self, dom_id: DomId, node_id: NodeId) -> Option<Instant> {
486        self.states
487            .get(&(dom_id, node_id))
488            .map(|s| s.last_activity.clone())
489    }
490
491    /// Returns the internal scroll state for a node
492    pub fn get_scroll_state(&self, dom_id: DomId, node_id: NodeId) -> Option<&AnimatedScrollState> {
493        self.states.get(&(dom_id, node_id))
494    }
495
496    /// Returns all scroll positions for nodes in a specific DOM
497    pub fn get_scroll_states_for_dom(&self, dom_id: DomId) -> BTreeMap<NodeId, ScrollPosition> {
498        self.states
499            .iter()
500            .filter(|((d, _), _)| *d == dom_id)
501            .map(|((_, node_id), state)| {
502                (
503                    *node_id,
504                    ScrollPosition {
505                        parent_rect: state.container_rect,
506                        children_rect: LogicalRect::new(
507                            state.current_offset,
508                            state.content_rect.size,
509                        ),
510                    },
511                )
512            })
513            .collect()
514    }
515
516    /// Registers or updates a scrollable node with its container and content sizes.
517    /// This should be called after layout for each node that has overflow:scroll or overflow:auto
518    /// with overflowing content.
519    ///
520    /// If the node already exists, updates container/content rects without changing scroll offset.
521    /// If the node is new, initializes with zero scroll offset.
522    pub fn register_or_update_scroll_node(
523        &mut self,
524        dom_id: DomId,
525        node_id: NodeId,
526        container_rect: LogicalRect,
527        content_size: LogicalSize,
528        now: Instant,
529    ) {
530        let key = (dom_id, node_id);
531
532        let content_rect = LogicalRect {
533            origin: LogicalPosition::zero(),
534            size: content_size,
535        };
536
537        if let Some(existing) = self.states.get_mut(&key) {
538            // Update rects, keep scroll offset
539            existing.container_rect = container_rect;
540            existing.content_rect = content_rect;
541            // Re-clamp current offset to new bounds
542            existing.current_offset = existing.clamp(existing.current_offset);
543        } else {
544            // New scrollable node
545            self.states.insert(
546                key,
547                AnimatedScrollState {
548                    current_offset: LogicalPosition::zero(),
549                    previous_offset: LogicalPosition::zero(),
550                    animation: None,
551                    last_activity: now,
552                    container_rect,
553                    content_rect,
554                },
555            );
556        }
557    }
558
559    // ExternalScrollId Management
560
561    /// Register a scroll node and get its ExternalScrollId for WebRender.
562    /// If the node already has an ID, returns the existing one.
563    pub fn register_scroll_node(&mut self, dom_id: DomId, node_id: NodeId) -> ExternalScrollId {
564        use azul_core::hit_test::PipelineId;
565
566        let key = (dom_id, node_id);
567        if let Some(&existing_id) = self.external_scroll_ids.get(&key) {
568            return existing_id;
569        }
570
571        // Generate new ExternalScrollId (id, pipeline_id)
572        // PipelineId = (PipelineSourceId: u32, u32)
573        // Use dom_id.inner for PipelineSourceId, node_id.index() for second part
574        let pipeline_id = PipelineId(
575            dom_id.inner as u32, // PipelineSourceId is just u32
576            node_id.index() as u32,
577        );
578        let new_id = ExternalScrollId(self.next_external_scroll_id, pipeline_id);
579        self.next_external_scroll_id += 1;
580        self.external_scroll_ids.insert(key, new_id);
581        new_id
582    }
583
584    /// Get the ExternalScrollId for a node (returns None if not registered)
585    pub fn get_external_scroll_id(
586        &self,
587        dom_id: DomId,
588        node_id: NodeId,
589    ) -> Option<ExternalScrollId> {
590        self.external_scroll_ids.get(&(dom_id, node_id)).copied()
591    }
592
593    /// Iterate over all registered external scroll IDs
594    pub fn iter_external_scroll_ids(
595        &self,
596    ) -> impl Iterator<Item = ((DomId, NodeId), ExternalScrollId)> + '_ {
597        self.external_scroll_ids.iter().map(|(k, v)| (*k, *v))
598    }
599
600    // Scrollbar State Management
601
602    /// Calculate scrollbar states for all visible scrollbars.
603    /// This should be called once per frame after layout is complete.
604    pub fn calculate_scrollbar_states(&mut self) {
605        self.scrollbar_states.clear();
606
607        // Collect vertical scrollbar states
608        let vertical_states: Vec<_> = self
609            .states
610            .iter()
611            .filter(|(_, s)| s.content_rect.size.height > s.container_rect.size.height)
612            .map(|((dom_id, node_id), scroll_state)| {
613                let v_state = Self::calculate_vertical_scrollbar_static(scroll_state);
614                ((*dom_id, *node_id, ScrollbarOrientation::Vertical), v_state)
615            })
616            .collect();
617
618        // Collect horizontal scrollbar states
619        let horizontal_states: Vec<_> = self
620            .states
621            .iter()
622            .filter(|(_, s)| s.content_rect.size.width > s.container_rect.size.width)
623            .map(|((dom_id, node_id), scroll_state)| {
624                let h_state = Self::calculate_horizontal_scrollbar_static(scroll_state);
625                (
626                    (*dom_id, *node_id, ScrollbarOrientation::Horizontal),
627                    h_state,
628                )
629            })
630            .collect();
631
632        // Insert all states
633        self.scrollbar_states.extend(vertical_states);
634        self.scrollbar_states.extend(horizontal_states);
635    }
636
637    /// Calculate vertical scrollbar geometry
638    fn calculate_vertical_scrollbar_static(scroll_state: &AnimatedScrollState) -> ScrollbarState {
639        const SCROLLBAR_WIDTH: f32 = 12.0; // Base size (1:1 square)
640
641        let container_height = scroll_state.container_rect.size.height;
642        let content_height = scroll_state.content_rect.size.height;
643
644        // Thumb size ratio = visible_height / total_height
645        let thumb_size_ratio = (container_height / content_height).min(1.0);
646
647        // Thumb position ratio = scroll_offset / max_scroll
648        let max_scroll = (content_height - container_height).max(0.0);
649        let thumb_position_ratio = if max_scroll > 0.0 {
650            (scroll_state.current_offset.y / max_scroll).clamp(0.0, 1.0)
651        } else {
652            0.0
653        };
654
655        // Scale: width = 1.0 (SCROLLBAR_WIDTH), height = container_height / SCROLLBAR_WIDTH
656        let scale = LogicalPosition::new(1.0, container_height / SCROLLBAR_WIDTH);
657
658        // Track rect (positioned at right edge of container)
659        let track_x = scroll_state.container_rect.origin.x + scroll_state.container_rect.size.width
660            - SCROLLBAR_WIDTH;
661        let track_y = scroll_state.container_rect.origin.y;
662        let track_rect = LogicalRect::new(
663            LogicalPosition::new(track_x, track_y),
664            LogicalSize::new(SCROLLBAR_WIDTH, container_height),
665        );
666
667        ScrollbarState {
668            visible: true,
669            orientation: ScrollbarOrientation::Vertical,
670            base_size: SCROLLBAR_WIDTH,
671            scale,
672            thumb_position_ratio,
673            thumb_size_ratio,
674            track_rect,
675        }
676    }
677
678    /// Calculate horizontal scrollbar geometry
679    fn calculate_horizontal_scrollbar_static(scroll_state: &AnimatedScrollState) -> ScrollbarState {
680        const SCROLLBAR_HEIGHT: f32 = 12.0; // Base size (1:1 square)
681
682        let container_width = scroll_state.container_rect.size.width;
683        let content_width = scroll_state.content_rect.size.width;
684
685        let thumb_size_ratio = (container_width / content_width).min(1.0);
686
687        let max_scroll = (content_width - container_width).max(0.0);
688        let thumb_position_ratio = if max_scroll > 0.0 {
689            (scroll_state.current_offset.x / max_scroll).clamp(0.0, 1.0)
690        } else {
691            0.0
692        };
693
694        let scale = LogicalPosition::new(container_width / SCROLLBAR_HEIGHT, 1.0);
695
696        let track_x = scroll_state.container_rect.origin.x;
697        let track_y = scroll_state.container_rect.origin.y
698            + scroll_state.container_rect.size.height
699            - SCROLLBAR_HEIGHT;
700        let track_rect = LogicalRect::new(
701            LogicalPosition::new(track_x, track_y),
702            LogicalSize::new(container_width, SCROLLBAR_HEIGHT),
703        );
704
705        ScrollbarState {
706            visible: true,
707            orientation: ScrollbarOrientation::Horizontal,
708            base_size: SCROLLBAR_HEIGHT,
709            scale,
710            thumb_position_ratio,
711            thumb_size_ratio,
712            track_rect,
713        }
714    }
715
716    /// Get scrollbar state for hit-testing
717    pub fn get_scrollbar_state(
718        &self,
719        dom_id: DomId,
720        node_id: NodeId,
721        orientation: ScrollbarOrientation,
722    ) -> Option<&ScrollbarState> {
723        self.scrollbar_states.get(&(dom_id, node_id, orientation))
724    }
725
726    /// Iterate over all visible scrollbar states
727    pub fn iter_scrollbar_states(
728        &self,
729    ) -> impl Iterator<Item = ((DomId, NodeId, ScrollbarOrientation), &ScrollbarState)> + '_ {
730        self.scrollbar_states.iter().map(|(k, v)| (*k, v))
731    }
732
733    // Scrollbar Hit-Testing
734
735    /// Hit-test scrollbars for a specific node at the given position.
736    /// Returns Some if the position is inside a scrollbar for this node.
737    pub fn hit_test_scrollbar(
738        &self,
739        dom_id: DomId,
740        node_id: NodeId,
741        global_pos: LogicalPosition,
742    ) -> Option<ScrollbarHit> {
743        // Check both vertical and horizontal scrollbars for this node
744        for orientation in [
745            ScrollbarOrientation::Vertical,
746            ScrollbarOrientation::Horizontal,
747        ] {
748            let scrollbar_state = self.scrollbar_states.get(&(dom_id, node_id, orientation))?;
749
750            if !scrollbar_state.visible {
751                continue;
752            }
753
754            // Check if position is inside scrollbar track using LogicalRect::contains
755            if !scrollbar_state.track_rect.contains(global_pos) {
756                continue;
757            }
758
759            // Calculate local position relative to track origin
760            let local_pos = LogicalPosition::new(
761                global_pos.x - scrollbar_state.track_rect.origin.x,
762                global_pos.y - scrollbar_state.track_rect.origin.y,
763            );
764
765            // Determine which component was hit
766            let component = scrollbar_state.hit_test_component(local_pos);
767
768            return Some(ScrollbarHit {
769                dom_id,
770                node_id,
771                orientation,
772                component,
773                local_position: local_pos,
774                global_position: global_pos,
775            });
776        }
777
778        None
779    }
780
781    /// Perform hit-testing for all scrollbars at the given global position.
782    ///
783    /// This iterates through all visible scrollbars in reverse z-order (top to bottom)
784    /// and returns the first hit. Use this when you don't know which node to check.
785    ///
786    /// For better performance, use `hit_test_scrollbar()` when you already have
787    /// a hit-tested node from WebRender.
788    pub fn hit_test_scrollbars(&self, global_pos: LogicalPosition) -> Option<ScrollbarHit> {
789        // Iterate in reverse order to hit top-most scrollbars first
790        for ((dom_id, node_id, orientation), scrollbar_state) in self.scrollbar_states.iter().rev()
791        {
792            if !scrollbar_state.visible {
793                continue;
794            }
795
796            // Check if position is inside scrollbar track
797            if !scrollbar_state.track_rect.contains(global_pos) {
798                continue;
799            }
800
801            // Calculate local position relative to track origin
802            let local_pos = LogicalPosition::new(
803                global_pos.x - scrollbar_state.track_rect.origin.x,
804                global_pos.y - scrollbar_state.track_rect.origin.y,
805            );
806
807            // Determine which component was hit
808            let component = scrollbar_state.hit_test_component(local_pos);
809
810            return Some(ScrollbarHit {
811                dom_id: *dom_id,
812                node_id: *node_id,
813                orientation: *orientation,
814                component,
815                local_position: local_pos,
816                global_position: global_pos,
817            });
818        }
819
820        None
821    }
822}
823
824// AnimatedScrollState Implementation
825
826impl AnimatedScrollState {
827    /// Create a new scroll state initialized at offset (0, 0).
828    pub fn new(now: Instant) -> Self {
829        Self {
830            current_offset: LogicalPosition::zero(),
831            previous_offset: LogicalPosition::zero(),
832            animation: None,
833            last_activity: now,
834            container_rect: LogicalRect::zero(),
835            content_rect: LogicalRect::zero(),
836        }
837    }
838
839    /// Clamp a scroll position to valid bounds (0 to max_scroll).
840    pub fn clamp(&self, position: LogicalPosition) -> LogicalPosition {
841        let max_x = (self.content_rect.size.width - self.container_rect.size.width).max(0.0);
842        let max_y = (self.content_rect.size.height - self.container_rect.size.height).max(0.0);
843        LogicalPosition {
844            x: position.x.max(0.0).min(max_x),
845            y: position.y.max(0.0).min(max_y),
846        }
847    }
848}
849
850// Easing Functions
851
852/// Apply an easing function to a normalized time value (0.0 to 1.0).
853/// Used by ScrollAnimation::tick() for smooth scroll animations.
854pub fn apply_easing(t: f32, easing: EasingFunction) -> f32 {
855    match easing {
856        EasingFunction::Linear => t,
857        EasingFunction::EaseOut => 1.0 - (1.0 - t).powi(3),
858        EasingFunction::EaseInOut => {
859            if t < 0.5 {
860                4.0 * t * t * t
861            } else {
862                1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
863            }
864        }
865    }
866}
867
868// Legacy type alias
869pub type ScrollStates = ScrollManager;
870
871// EventProvider Implementation
872
873impl EventProvider for ScrollManager {
874    /// Get pending scroll events.
875    ///
876    /// Returns Scroll/ScrollStart/ScrollEnd events for nodes whose scroll
877    /// position changed this frame.
878    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
879        let mut events = Vec::new();
880
881        // Generate events for all nodes that scrolled this frame
882        for ((dom_id, node_id), state) in &self.states {
883            // Check if scroll offset changed (delta != 0)
884            let delta = LogicalPosition {
885                x: state.current_offset.x - state.previous_offset.x,
886                y: state.current_offset.y - state.previous_offset.y,
887            };
888
889            if delta.x.abs() > 0.001 || delta.y.abs() > 0.001 {
890                let target = azul_core::dom::DomNodeId {
891                    dom: *dom_id,
892                    node: NodeHierarchyItemId::from_crate_internal(Some(*node_id)),
893                };
894
895                // Determine event source
896                let event_source = if self.had_programmatic_scroll {
897                    EventSource::Programmatic
898                } else {
899                    EventSource::User
900                };
901
902                // Generate Scroll event
903                events.push(SyntheticEvent::new(
904                    EventType::Scroll,
905                    event_source,
906                    target,
907                    timestamp.clone(),
908                    EventData::Scroll(ScrollEventData {
909                        delta,
910                        delta_mode: ScrollDeltaMode::Pixel,
911                    }),
912                ));
913
914                // TODO: Generate ScrollStart/ScrollEnd events
915                // Need to track when scroll starts/stops (first/last frame with delta)
916            }
917        }
918
919        events
920    }
921}
922
923impl ScrollManager {
924    /// Remap NodeIds after DOM reconciliation
925    ///
926    /// When the DOM is regenerated, NodeIds can change. This method updates all
927    /// internal state to use the new NodeIds based on the provided mapping.
928    pub fn remap_node_ids(
929        &mut self,
930        dom_id: DomId,
931        node_id_map: &std::collections::BTreeMap<NodeId, NodeId>,
932    ) {
933        // Remap states
934        let states_to_update: Vec<_> = self.states.keys()
935            .filter(|(d, _)| *d == dom_id)
936            .cloned()
937            .collect();
938        
939        for (d, old_node_id) in states_to_update {
940            if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
941                if let Some(state) = self.states.remove(&(d, old_node_id)) {
942                    self.states.insert((d, new_node_id), state);
943                }
944            } else {
945                // Node was removed, remove its scroll state
946                self.states.remove(&(d, old_node_id));
947            }
948        }
949        
950        // Remap external_scroll_ids
951        let scroll_ids_to_update: Vec<_> = self.external_scroll_ids.keys()
952            .filter(|(d, _)| *d == dom_id)
953            .cloned()
954            .collect();
955        
956        for (d, old_node_id) in scroll_ids_to_update {
957            if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
958                if let Some(scroll_id) = self.external_scroll_ids.remove(&(d, old_node_id)) {
959                    self.external_scroll_ids.insert((d, new_node_id), scroll_id);
960                }
961            } else {
962                self.external_scroll_ids.remove(&(d, old_node_id));
963            }
964        }
965        
966        // Remap scrollbar_states
967        let scrollbar_states_to_update: Vec<_> = self.scrollbar_states.keys()
968            .filter(|(d, _, _)| *d == dom_id)
969            .cloned()
970            .collect();
971        
972        for (d, old_node_id, orientation) in scrollbar_states_to_update {
973            if let Some(&new_node_id) = node_id_map.get(&old_node_id) {
974                if let Some(state) = self.scrollbar_states.remove(&(d, old_node_id, orientation)) {
975                    self.scrollbar_states.insert((d, new_node_id, orientation), state);
976                }
977            } else {
978                self.scrollbar_states.remove(&(d, old_node_id, orientation));
979            }
980        }
981    }
982}