Skip to main content

azul_layout/managers/
scroll_into_view.rs

1//! Scroll-into-view implementation
2//!
3//! Provides W3C CSSOM View Module compliant scroll-into-view functionality.
4//! This module contains the core primitive `scroll_rect_into_view` which all
5//! higher-level scroll-into-view APIs build upon.
6//!
7//! # Architecture
8//!
9//! The core principle is that all scroll-into-view operations reduce to scrolling
10//! a rectangle into the visible area of its scroll container ancestry:
11//!
12//! - `scroll_rect_into_view`: Core primitive - scroll any rect into view
13//! - `scroll_node_into_view`: Scroll a DOM node's bounding rect into view
14//! - `scroll_cursor_into_view`: Scroll a text cursor position into view
15//!
16//! # W3C Compliance
17//!
18//! This implementation follows the W3C CSSOM View Module specification:
19//! - ScrollLogicalPosition: start, center, end, nearest
20//! - ScrollBehavior: auto, instant, smooth
21//! - Proper scroll ancestor chain traversal
22
23use alloc::vec::Vec;
24
25use azul_core::{
26    dom::{DomId, DomNodeId, NodeId},
27    geom::{LogicalPosition, LogicalRect, LogicalSize},
28    task::{Duration, Instant},
29};
30
31use crate::{
32    managers::scroll_state::ScrollManager,
33    solver3::getters::{get_overflow_x, get_overflow_y},
34    window::DomLayoutResult,
35};
36
37// Re-export types from core for public API
38pub use azul_core::events::{ScrollIntoViewBehavior, ScrollIntoViewOptions, ScrollLogicalPosition};
39
40/// Minimum scroll delta (in logical pixels) below which scrolling is skipped
41const SCROLL_DELTA_THRESHOLD: f32 = 0.5;
42/// Duration of smooth scroll animations in milliseconds
43const SMOOTH_SCROLL_DURATION_MS: u64 = 300;
44
45/// Calculated scroll adjustment for one scroll container
46#[derive(Debug, Clone)]
47pub struct ScrollAdjustment {
48    /// The DOM containing the scroll container
49    pub scroll_container_dom_id: DomId,
50    /// The node ID of the scroll container within the DOM
51    pub scroll_container_node_id: NodeId,
52    /// The scroll delta to apply
53    pub delta: LogicalPosition,
54    /// The scroll behavior to use
55    pub behavior: ScrollIntoViewBehavior,
56}
57
58/// Information about a scrollable ancestor
59#[derive(Debug, Clone)]
60struct ScrollableAncestor {
61    dom_id: DomId,
62    node_id: NodeId,
63    /// The visible rect of the scroll container (content area)
64    visible_rect: LogicalRect,
65    /// Whether horizontal scroll is enabled
66    scroll_x: bool,
67    /// Whether vertical scroll is enabled
68    scroll_y: bool,
69}
70
71// ============================================================================
72// Core API: scroll_rect_into_view
73// ============================================================================
74
75/// Core function: scroll a rect into the visible area of its scroll containers
76///
77/// This is the ONLY scroll-into-view primitive. All higher-level APIs call this.
78///
79/// # Arguments
80///
81/// * `target_rect` - The rectangle to make visible (in absolute coordinates)
82/// * `target_dom_id` - The DOM containing the target node
83/// * `target_node_id` - The target node (used for finding scroll ancestors)
84/// * `layout_results` - Layout data for all DOMs
85/// * `scroll_manager` - Current scroll state
86/// * `options` - How to scroll (alignment and animation)
87/// * `now` - Current timestamp for animation
88///
89/// # Returns
90///
91/// A vector of scroll adjustments for each scroll container in the ancestry chain.
92/// The adjustments are ordered from innermost (closest to target) to outermost.
93pub fn scroll_rect_into_view(
94    target_rect: LogicalRect,
95    target_dom_id: DomId,
96    target_node_id: NodeId,
97    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
98    scroll_manager: &mut ScrollManager,
99    options: ScrollIntoViewOptions,
100    now: Instant,
101) -> Vec<ScrollAdjustment> {
102    let mut adjustments = Vec::new();
103    
104    // Find scrollable ancestors from target to root
105    let scroll_ancestors = find_scrollable_ancestors(
106        target_dom_id,
107        target_node_id,
108        layout_results,
109        scroll_manager,
110    );
111    
112    if scroll_ancestors.is_empty() {
113        return adjustments;
114    }
115    
116    // Transform target_rect relative to each scroll container and calculate deltas
117    let mut current_rect = target_rect;
118    
119    for ancestor in scroll_ancestors {
120        // Calculate the scroll delta based on options
121        let delta = calculate_scroll_delta(
122            current_rect,
123            ancestor.visible_rect,
124            options.block,
125            options.inline_axis,
126            ancestor.scroll_x,
127            ancestor.scroll_y,
128        );
129        
130        // Only add adjustment if there's actual scrolling to do
131        if delta.x.abs() > SCROLL_DELTA_THRESHOLD || delta.y.abs() > SCROLL_DELTA_THRESHOLD {
132            // Resolve scroll behavior
133            let behavior = resolve_scroll_behavior(
134                options.behavior,
135                ancestor.dom_id,
136                ancestor.node_id,
137                layout_results,
138            );
139            
140            // Apply the scroll adjustment
141            apply_scroll_adjustment(
142                scroll_manager,
143                ancestor.dom_id,
144                ancestor.node_id,
145                delta,
146                behavior,
147                now.clone(),
148            );
149            
150            adjustments.push(ScrollAdjustment {
151                scroll_container_dom_id: ancestor.dom_id,
152                scroll_container_node_id: ancestor.node_id,
153                delta,
154                behavior,
155            });
156            
157            // Adjust current_rect for next iteration (relative to new scroll position)
158            current_rect.origin.x -= delta.x;
159            current_rect.origin.y -= delta.y;
160        }
161    }
162    
163    adjustments
164}
165
166// ============================================================================
167// Higher-Level APIs
168// ============================================================================
169
170/// Scroll a DOM node's bounding rect into view
171///
172/// This is a convenience wrapper around `scroll_rect_into_view` that
173/// automatically gets the node's bounding rect from layout results.
174pub fn scroll_node_into_view(
175    node_id: DomNodeId,
176    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
177    scroll_manager: &mut ScrollManager,
178    options: ScrollIntoViewOptions,
179    now: Instant,
180) -> Vec<ScrollAdjustment> {
181    // Get node's bounding rect from layout
182    let target_rect = match get_node_rect(node_id, layout_results) {
183        Some(rect) => rect,
184        None => return Vec::new(),
185    };
186    
187    let internal_node_id = match node_id.node.into_crate_internal() {
188        Some(nid) => nid,
189        None => return Vec::new(),
190    };
191    
192    // Call the core rect-based API
193    scroll_rect_into_view(
194        target_rect,
195        node_id.dom,
196        internal_node_id,
197        layout_results,
198        scroll_manager,
199        options,
200        now,
201    )
202}
203
204/// Scroll a text cursor position into view
205///
206/// Transforms the cursor's visual rect (in node-local coordinates) to absolute
207/// coordinates before scrolling.
208pub fn scroll_cursor_into_view(
209    cursor_rect: LogicalRect,
210    node_id: DomNodeId,
211    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
212    scroll_manager: &mut ScrollManager,
213    options: ScrollIntoViewOptions,
214    now: Instant,
215) -> Vec<ScrollAdjustment> {
216    // Get node's position to transform cursor_rect to absolute coordinates
217    let node_rect = match get_node_rect(node_id, layout_results) {
218        Some(rect) => rect,
219        None => return Vec::new(),
220    };
221    
222    // Transform cursor rect to absolute coordinates
223    let absolute_cursor_rect = LogicalRect {
224        origin: LogicalPosition {
225            x: node_rect.origin.x + cursor_rect.origin.x,
226            y: node_rect.origin.y + cursor_rect.origin.y,
227        },
228        size: cursor_rect.size,
229    };
230    
231    let internal_node_id = match node_id.node.into_crate_internal() {
232        Some(nid) => nid,
233        None => return Vec::new(),
234    };
235    
236    // Call the core rect-based API
237    scroll_rect_into_view(
238        absolute_cursor_rect,
239        node_id.dom,
240        internal_node_id,
241        layout_results,
242        scroll_manager,
243        options,
244        now,
245    )
246}
247
248// ============================================================================
249// Helper Functions
250// ============================================================================
251
252/// Find all scrollable ancestors from a node to the root
253///
254/// Returns ancestors ordered from innermost (closest to target) to outermost (root).
255fn find_scrollable_ancestors(
256    dom_id: DomId,
257    node_id: NodeId,
258    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
259    scroll_manager: &ScrollManager,
260) -> Vec<ScrollableAncestor> {
261    let mut ancestors = Vec::new();
262    
263    let layout_result = match layout_results.get(&dom_id) {
264        Some(lr) => lr,
265        None => return ancestors,
266    };
267    
268    let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
269
270    // Walk up the DOM tree from parent of target node
271    let mut current = node_hierarchy.get(node_id).and_then(|h| h.parent_id());
272    
273    while let Some(current_node_id) = current {
274        // Check if this node is scrollable
275        if let Some(ancestor) = check_if_scrollable(
276            dom_id,
277            current_node_id,
278            layout_result,
279            scroll_manager,
280        ) {
281            ancestors.push(ancestor);
282        }
283        
284        // Move to parent
285        current = node_hierarchy.get(current_node_id).and_then(|h| h.parent_id());
286    }
287    
288    ancestors
289}
290
291/// Check if a node is scrollable and return its scroll info
292fn check_if_scrollable(
293    dom_id: DomId,
294    node_id: NodeId,
295    layout_result: &DomLayoutResult,
296    scroll_manager: &ScrollManager,
297) -> Option<ScrollableAncestor> {
298    let styled_nodes = layout_result.styled_dom.styled_nodes.as_container();
299    let styled_node = styled_nodes.get(node_id)?;
300    
301    let overflow_x = get_overflow_x(
302        &layout_result.styled_dom,
303        node_id,
304        &styled_node.styled_node_state,
305    );
306    let overflow_y = get_overflow_y(
307        &layout_result.styled_dom,
308        node_id,
309        &styled_node.styled_node_state,
310    );
311    
312    let scroll_x = overflow_x.is_scroll();
313    let scroll_y = overflow_y.is_scroll();
314    
315    // If neither axis is scrollable, skip this node
316    if !scroll_x && !scroll_y {
317        return None;
318    }
319    
320    // Check if the scroll manager has scroll state for this node
321    // (which means it actually has overflowing content)
322    let scroll_state = scroll_manager.get_scroll_state(dom_id, node_id)?;
323    
324    // Check if content actually overflows (use virtual_scroll_size when set, e.g. for VirtualView)
325    let effective_width = scroll_state.virtual_scroll_size.map(|s| s.width).unwrap_or(scroll_state.content_rect.size.width);
326    let effective_height = scroll_state.virtual_scroll_size.map(|s| s.height).unwrap_or(scroll_state.content_rect.size.height);
327    let has_overflow_x = effective_width > scroll_state.container_rect.size.width;
328    let has_overflow_y = effective_height > scroll_state.container_rect.size.height;
329    
330    if !has_overflow_x && !has_overflow_y {
331        return None;
332    }
333    
334    // Get the visible rect (container rect minus current scroll offset)
335    let visible_rect = LogicalRect {
336        origin: LogicalPosition {
337            x: scroll_state.container_rect.origin.x + scroll_state.current_offset.x,
338            y: scroll_state.container_rect.origin.y + scroll_state.current_offset.y,
339        },
340        size: scroll_state.container_rect.size,
341    };
342    
343    Some(ScrollableAncestor {
344        dom_id,
345        node_id,
346        visible_rect,
347        scroll_x: scroll_x && has_overflow_x,
348        scroll_y: scroll_y && has_overflow_y,
349    })
350}
351
352/// Calculate the scroll delta needed to bring target into view within container
353fn calculate_scroll_delta(
354    target: LogicalRect,
355    container: LogicalRect,
356    block: ScrollLogicalPosition,
357    inline: ScrollLogicalPosition,
358    scroll_x_enabled: bool,
359    scroll_y_enabled: bool,
360) -> LogicalPosition {
361    LogicalPosition {
362        x: if scroll_x_enabled {
363            calculate_axis_delta(
364                target.origin.x,
365                target.size.width,
366                container.origin.x,
367                container.size.width,
368                inline,
369            )
370        } else {
371            0.0
372        },
373        y: if scroll_y_enabled {
374            calculate_axis_delta(
375                target.origin.y,
376                target.size.height,
377                container.origin.y,
378                container.size.height,
379                block,
380            )
381        } else {
382            0.0
383        },
384    }
385}
386
387/// Calculate scroll delta for a single axis
388pub fn calculate_axis_delta(
389    target_start: f32,
390    target_size: f32,
391    container_start: f32,
392    container_size: f32,
393    position: ScrollLogicalPosition,
394) -> f32 {
395    let target_end = target_start + target_size;
396    let container_end = container_start + container_size;
397    
398    match position {
399        ScrollLogicalPosition::Start => {
400            // Align target start with container start
401            target_start - container_start
402        }
403        ScrollLogicalPosition::End => {
404            // Align target end with container end
405            target_end - container_end
406        }
407        ScrollLogicalPosition::Center => {
408            // Center target in container
409            let target_center = target_start + target_size / 2.0;
410            let container_center = container_start + container_size / 2.0;
411            target_center - container_center
412        }
413        ScrollLogicalPosition::Nearest => {
414            // Minimum scroll to make target fully visible
415            if target_start < container_start {
416                // Target is above/left of visible area - scroll up/left
417                target_start - container_start
418            } else if target_end > container_end {
419                // Target is below/right of visible area
420                if target_size <= container_size {
421                    // Target fits, align end with container end
422                    target_end - container_end
423                } else {
424                    // Target doesn't fit, align start with container start
425                    target_start - container_start
426                }
427            } else {
428                // Target is already fully visible
429                0.0
430            }
431        }
432    }
433}
434
435/// Resolve scroll behavior based on options and CSS properties
436// +spec:containing-block:03528c - scroll-behavior on root element applies to viewport
437fn resolve_scroll_behavior(
438    requested: ScrollIntoViewBehavior,
439    _dom_id: DomId,
440    _node_id: NodeId,
441    _layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
442) -> ScrollIntoViewBehavior {
443    match requested {
444        ScrollIntoViewBehavior::Auto => {
445            // TODO: Check CSS scroll-behavior property on the scroll container
446            // For now, default to instant
447            ScrollIntoViewBehavior::Instant
448        }
449        other => other,
450    }
451}
452
453/// Apply a scroll adjustment to the scroll manager
454fn apply_scroll_adjustment(
455    scroll_manager: &mut ScrollManager,
456    dom_id: DomId,
457    node_id: NodeId,
458    delta: LogicalPosition,
459    behavior: ScrollIntoViewBehavior,
460    now: Instant,
461) {
462    use azul_core::events::EasingFunction;
463    use azul_core::task::SystemTimeDiff;
464    
465    let current = scroll_manager
466        .get_current_offset(dom_id, node_id)
467        .unwrap_or_default();
468    
469    let new_position = LogicalPosition {
470        x: current.x + delta.x,
471        y: current.y + delta.y,
472    };
473    
474    match behavior {
475        ScrollIntoViewBehavior::Instant | ScrollIntoViewBehavior::Auto => {
476            scroll_manager.set_scroll_position(dom_id, node_id, new_position, now);
477        }
478        ScrollIntoViewBehavior::Smooth => {
479            // Use smooth scroll with 300ms duration
480            let duration = Duration::System(SystemTimeDiff::from_millis(SMOOTH_SCROLL_DURATION_MS));
481            scroll_manager.scroll_to(
482                dom_id,
483                node_id,
484                new_position,
485                duration,
486                EasingFunction::EaseOut,
487                now,
488            );
489        }
490    }
491}
492
493/// Get a node's bounding rect from layout results
494fn get_node_rect(
495    node_id: DomNodeId,
496    layout_results: &alloc::collections::BTreeMap<DomId, DomLayoutResult>,
497) -> Option<LogicalRect> {
498    let layout_result = layout_results.get(&node_id.dom)?;
499    let nid = node_id.node.into_crate_internal()?;
500    
501    // Get position
502    let layout_indices = layout_result.layout_tree.dom_to_layout.get(&nid)?;
503    let layout_index = *layout_indices.first()?;
504    let position = *layout_result.calculated_positions.get(layout_index)?;
505    
506    // Get size
507    let layout_node = layout_result.layout_tree.get(layout_index)?;
508    let size = layout_node.used_size?;
509    
510    Some(LogicalRect::new(position, size))
511}