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