Skip to main content

azul_layout/managers/
virtual_view.rs

1//! VirtualView lifecycle management for layout
2//!
3//! This module provides:
4//! - VirtualView re-invocation logic for lazy loading
5//! - WebRender PipelineId tracking
6//! - Nested DOM ID management
7
8use alloc::collections::BTreeMap;
9
10use azul_core::{
11    callbacks::{EdgeType, VirtualViewCallbackReason},
12    dom::{DomId, NodeId},
13    geom::{LogicalPosition, LogicalRect, LogicalSize},
14    hit_test::PipelineId,
15};
16
17use crate::managers::scroll_state::ScrollManager;
18
19/// Distance in pixels from edge that triggers edge-scrolled callback
20const EDGE_THRESHOLD: f32 = 200.0;
21
22/// Manages VirtualView lifecycle, including re-invocation and PipelineId generation
23///
24/// Tracks which VirtualViews have been invoked, assigns unique DOM IDs to nested
25/// virtual views, and determines when VirtualViews need to be re-invoked (e.g., when
26/// the container bounds expand or the user scrolls near an edge).
27#[derive(Debug, Clone, Default)]
28pub struct VirtualViewManager {
29    /// Per-VirtualView state keyed by (parent DomId, NodeId of virtualized view element)
30    states: BTreeMap<(DomId, NodeId), VirtualViewState>,
31    /// WebRender PipelineId for each VirtualView
32    pipeline_ids: BTreeMap<(DomId, NodeId), PipelineId>,
33    /// Counter for generating unique nested DOM IDs
34    next_dom_id: usize,
35}
36
37/// Internal state for a single VirtualView instance
38///
39/// Tracks invocation status, content dimensions, and edge triggers
40/// to determine when the VirtualView callback needs to be re-invoked.
41#[derive(Debug, Clone)]
42struct VirtualViewState {
43    /// Content size reported by VirtualView callback (actual rendered size)
44    virtual_view_scroll_size: Option<LogicalSize>,
45    /// Virtual scroll size for infinite scroll scenarios
46    virtual_view_virtual_scroll_size: Option<LogicalSize>,
47    /// Whether the VirtualView has ever been invoked
48    virtual_view_was_invoked: bool,
49    /// Whether invoked for current container expansion
50    invoked_for_current_expansion: bool,
51    /// Whether invoked for current edge scroll event
52    invoked_for_current_edge: bool,
53    /// Which edges have already triggered callbacks
54    last_edge_triggered: EdgeFlags,
55    /// Unique DOM ID assigned to this VirtualView's content
56    nested_dom_id: DomId,
57    /// Last known layout bounds of the VirtualView container
58    last_bounds: LogicalRect,
59}
60
61/// Flags indicating which scroll edges have been triggered
62///
63/// Used to prevent repeated edge-scroll callbacks for the same edge
64/// until the user scrolls away and back.
65#[derive(Debug, Clone, Copy, PartialEq, Default)]
66pub struct EdgeFlags {
67    /// Near top edge
68    pub top: bool,
69    /// Near bottom edge
70    pub bottom: bool,
71    /// Near left edge
72    pub left: bool,
73    /// Near right edge
74    pub right: bool,
75}
76
77impl VirtualViewManager {
78    /// Creates a new VirtualViewManager with no tracked VirtualViews
79    pub fn new() -> Self {
80        Self {
81            next_dom_id: 1, // 0 is root
82            ..Default::default()
83        }
84    }
85
86    /// (states, pipeline_ids). Used by `AZ_E2E_TEST` to watch growth.
87    pub fn debug_counts(&self) -> (usize, usize) {
88        (self.states.len(), self.pipeline_ids.len())
89    }
90
91    /// Called at the start of each frame (currently a no-op)
92    pub fn begin_frame(&mut self) {
93        // Nothing to do here for now, but good practice for stateful managers
94    }
95
96    /// Gets or creates a unique nested DOM ID for a VirtualView
97    ///
98    /// Returns the existing DOM ID if the VirtualView was previously registered,
99    /// otherwise allocates a new unique ID and initializes the VirtualView state.
100    pub fn get_or_create_nested_dom_id(&mut self, dom_id: DomId, node_id: NodeId) -> DomId {
101        let key = (dom_id, node_id);
102
103        // Check if already exists
104        if let Some(state) = self.states.get(&key) {
105            return state.nested_dom_id;
106        }
107
108        // Create new nested DOM ID
109        let nested_dom_id = DomId {
110            inner: self.next_dom_id,
111        };
112        self.next_dom_id += 1;
113
114        self.states.insert(key, VirtualViewState::new(nested_dom_id));
115        nested_dom_id
116    }
117
118    /// Gets the nested DOM ID for a VirtualView if it exists
119    pub fn get_nested_dom_id(&self, dom_id: DomId, node_id: NodeId) -> Option<DomId> {
120        self.states.get(&(dom_id, node_id)).map(|s| s.nested_dom_id)
121    }
122
123    /// Gets or creates a WebRender PipelineId for a VirtualView
124    ///
125    /// PipelineIds are used by WebRender to identify distinct rendering contexts.
126    pub fn get_or_create_pipeline_id(&mut self, dom_id: DomId, node_id: NodeId) -> PipelineId {
127        *self
128            .pipeline_ids
129            .entry((dom_id, node_id))
130            .or_insert_with(|| PipelineId(dom_id.inner as u32, node_id.index() as u32))
131    }
132
133    /// Returns whether the VirtualView has ever been invoked
134    pub fn was_virtual_view_invoked(&self, dom_id: DomId, node_id: NodeId) -> bool {
135        self.states
136            .get(&(dom_id, node_id))
137            .map(|s| s.virtual_view_was_invoked)
138            .unwrap_or(false)
139    }
140
141    /// Returns the virtual scroll size for a VirtualView (if set by the callback)
142    pub fn get_virtual_scroll_size(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalSize> {
143        self.states
144            .get(&(dom_id, node_id))
145            .and_then(|s| s.virtual_view_virtual_scroll_size)
146    }
147
148    /// Returns the scroll size for a VirtualView (actual content size, if set by the callback)
149    pub fn get_scroll_size(&self, dom_id: DomId, node_id: NodeId) -> Option<LogicalSize> {
150        self.states
151            .get(&(dom_id, node_id))
152            .and_then(|s| s.virtual_view_scroll_size)
153    }
154
155    /// Updates the VirtualView's content size information
156    ///
157    /// Called after the VirtualView callback returns to record the actual content
158    /// dimensions. If the new size is larger than previously recorded, clears
159    /// the expansion flag to allow BoundsExpanded re-invocation.
160    pub fn update_virtual_view_info(
161        &mut self,
162        dom_id: DomId,
163        node_id: NodeId,
164        scroll_size: LogicalSize,
165        virtual_scroll_size: LogicalSize,
166    ) -> Option<()> {
167        let state = self.states.get_mut(&(dom_id, node_id))?;
168
169        // Reset expansion flag if content grew
170        if let Some(old_size) = state.virtual_view_scroll_size {
171            if scroll_size.width > old_size.width || scroll_size.height > old_size.height {
172                state.invoked_for_current_expansion = false;
173            }
174        }
175        state.virtual_view_scroll_size = Some(scroll_size);
176        state.virtual_view_virtual_scroll_size = Some(virtual_scroll_size);
177
178        Some(())
179    }
180
181    /// Marks a VirtualView as invoked for a specific reason
182    ///
183    /// Updates internal state flags based on the callback reason to prevent
184    /// duplicate callbacks for the same trigger condition.
185    pub fn mark_invoked(
186        &mut self,
187        dom_id: DomId,
188        node_id: NodeId,
189        reason: VirtualViewCallbackReason,
190    ) -> Option<()> {
191        let state = self.states.get_mut(&(dom_id, node_id))?;
192
193        state.virtual_view_was_invoked = true;
194        match reason {
195            VirtualViewCallbackReason::BoundsExpanded => state.invoked_for_current_expansion = true,
196            VirtualViewCallbackReason::EdgeScrolled(edge) => {
197                state.invoked_for_current_edge = true;
198                state.last_edge_triggered = edge.into();
199            }
200            _ => {}
201        }
202
203        Some(())
204    }
205
206    /// Reset invocation flags for ALL tracked VirtualViews
207    ///
208    /// After `layout_results.clear()`, the child DOMs no longer exist in memory.
209    /// This method ensures `check_reinvoke()` returns `InitialRender` for every
210    /// VirtualView, so the callbacks re-run and re-populate `layout_results`.
211    ///
212    /// Called from `layout_and_generate_display_list()` after clearing layout results.
213    pub fn reset_all_invocation_flags(&mut self) {
214        for state in self.states.values_mut() {
215            state.virtual_view_was_invoked = false;
216            state.invoked_for_current_expansion = false;
217            state.invoked_for_current_edge = false;
218            state.last_edge_triggered = EdgeFlags::default();
219        }
220    }
221
222    /// Force a VirtualView to be re-invoked on the next layout pass
223    ///
224    /// Clears all invocation flags, causing check_reinvoke() to return InitialRender.
225    /// Used by trigger_virtual_view_rerender() to manually refresh VirtualView content.
226    pub fn force_reinvoke(&mut self, dom_id: DomId, node_id: NodeId) -> Option<()> {
227        let state = self.states.get_mut(&(dom_id, node_id))?;
228
229        state.virtual_view_was_invoked = false;
230        state.invoked_for_current_expansion = false;
231        state.invoked_for_current_edge = false;
232
233        Some(())
234    }
235
236    /// Checks whether a VirtualView needs to be re-invoked and returns the reason
237    ///
238    /// Returns `Some(reason)` if the VirtualView callback should be invoked:
239    /// - `InitialRender`: VirtualView has never been invoked
240    /// - `BoundsExpanded`: Container grew larger than content
241    /// - `EdgeScrolled`: User scrolled near an edge (for lazy loading)
242    ///
243    /// Returns `None` if no re-invocation is needed.
244    pub fn check_reinvoke(
245        &mut self,
246        dom_id: DomId,
247        node_id: NodeId,
248        scroll_manager: &ScrollManager,
249        layout_bounds: LogicalRect,
250    ) -> Option<VirtualViewCallbackReason> {
251        let state = self.states.entry((dom_id, node_id)).or_insert_with(|| {
252            let nested_dom_id = DomId {
253                inner: self.next_dom_id,
254            };
255            self.next_dom_id += 1;
256            VirtualViewState::new(nested_dom_id)
257        });
258
259        if !state.virtual_view_was_invoked {
260            return Some(VirtualViewCallbackReason::InitialRender);
261        }
262
263        // Check for bounds expansion
264        if layout_bounds.size.width > state.last_bounds.size.width
265            || layout_bounds.size.height > state.last_bounds.size.height
266        {
267            state.invoked_for_current_expansion = false;
268        }
269        state.last_bounds = layout_bounds;
270
271        let scroll_offset = scroll_manager
272            .get_current_offset(dom_id, node_id)
273            .unwrap_or_default();
274
275        state.check_reinvoke_condition(scroll_offset, layout_bounds.size)
276    }
277
278    /// Returns debug info for all tracked VirtualViews
279    ///
280    /// Each entry contains: (parent_dom_id, parent_node_id, nested_dom_id,
281    /// scroll_size, virtual_scroll_size, was_invoked, last_bounds)
282    pub fn get_all_virtual_view_infos(&self) -> alloc::vec::Vec<VirtualViewDebugInfo> {
283        self.states
284            .iter()
285            .map(|((dom_id, node_id), state)| VirtualViewDebugInfo {
286                parent_dom_id: dom_id.inner,
287                parent_node_id: node_id.index(),
288                nested_dom_id: state.nested_dom_id.inner,
289                scroll_size_width: state.virtual_view_scroll_size.map(|s| s.width),
290                scroll_size_height: state.virtual_view_scroll_size.map(|s| s.height),
291                virtual_scroll_size_width: state.virtual_view_virtual_scroll_size.map(|s| s.width),
292                virtual_scroll_size_height: state.virtual_view_virtual_scroll_size.map(|s| s.height),
293                was_invoked: state.virtual_view_was_invoked,
294                last_bounds_x: state.last_bounds.origin.x,
295                last_bounds_y: state.last_bounds.origin.y,
296                last_bounds_width: state.last_bounds.size.width,
297                last_bounds_height: state.last_bounds.size.height,
298            })
299            .collect()
300    }
301}
302
303/// Debug info for a single VirtualView, returned by `get_all_virtual_view_infos`
304#[derive(Debug, Clone)]
305pub struct VirtualViewDebugInfo {
306    pub parent_dom_id: usize,
307    pub parent_node_id: usize,
308    pub nested_dom_id: usize,
309    pub scroll_size_width: Option<f32>,
310    pub scroll_size_height: Option<f32>,
311    pub virtual_scroll_size_width: Option<f32>,
312    pub virtual_scroll_size_height: Option<f32>,
313    pub was_invoked: bool,
314    pub last_bounds_x: f32,
315    pub last_bounds_y: f32,
316    pub last_bounds_width: f32,
317    pub last_bounds_height: f32,
318}
319
320impl VirtualViewState {
321    /// Creates a new VirtualViewState with the given nested DOM ID
322    fn new(nested_dom_id: DomId) -> Self {
323        Self {
324            virtual_view_scroll_size: None,
325            virtual_view_virtual_scroll_size: None,
326            virtual_view_was_invoked: false,
327            invoked_for_current_expansion: false,
328            invoked_for_current_edge: false,
329            last_edge_triggered: EdgeFlags::default(),
330            nested_dom_id,
331            last_bounds: LogicalRect::zero(),
332        }
333    }
334
335    /// Determines if the VirtualView callback should be re-invoked based on
336    /// scroll position
337    ///
338    /// Checks two conditions:
339    /// 1. Container bounds expanded beyond content size
340    /// 2. User scrolled within EDGE_THRESHOLD pixels of an edge (for lazy loading)
341    fn check_reinvoke_condition(
342        &mut self,
343        current_offset: LogicalPosition,
344        container_size: LogicalSize,
345    ) -> Option<VirtualViewCallbackReason> {
346        // Need scroll_size to determine if we can scroll at all
347        let Some(scroll_size) = self.virtual_view_scroll_size else {
348            return None;
349        };
350
351        // Check 1: Container grew larger than content - need more content
352        if !self.invoked_for_current_expansion
353            && (container_size.width > scroll_size.width
354                || container_size.height > scroll_size.height)
355        {
356            return Some(VirtualViewCallbackReason::BoundsExpanded);
357        }
358
359        // Check 2: Edge-based lazy loading
360        // Determine if scrolling is possible in each direction
361        let scrollable_width = scroll_size.width > container_size.width;
362        let scrollable_height = scroll_size.height > container_size.height;
363
364        // Calculate which edges the user is currently near
365        let current_edges = EdgeFlags {
366            top: scrollable_height && current_offset.y <= EDGE_THRESHOLD,
367            bottom: scrollable_height
368                && (scroll_size.height - container_size.height - current_offset.y)
369                    <= EDGE_THRESHOLD,
370            left: scrollable_width && current_offset.x <= EDGE_THRESHOLD,
371            right: scrollable_width
372                && (scroll_size.width - container_size.width - current_offset.x) <= EDGE_THRESHOLD,
373        };
374
375        // Trigger edge callback if near an edge that hasn't been triggered yet
376        // Prioritize bottom/right edges (common infinite scroll directions)
377        if !self.invoked_for_current_edge && current_edges.any() {
378            if current_edges.bottom && !self.last_edge_triggered.bottom {
379                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom));
380            }
381            if current_edges.right && !self.last_edge_triggered.right {
382                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Right));
383            }
384        }
385
386        None
387    }
388}
389
390impl EdgeFlags {
391    /// Returns true if any edge flag is set
392    fn any(&self) -> bool {
393        self.top || self.bottom || self.left || self.right
394    }
395}
396
397impl From<EdgeType> for EdgeFlags {
398    fn from(edge: EdgeType) -> Self {
399        match edge {
400            EdgeType::Top => Self {
401                top: true,
402                ..Default::default()
403            },
404            EdgeType::Bottom => Self {
405                bottom: true,
406                ..Default::default()
407            },
408            EdgeType::Left => Self {
409                left: true,
410                ..Default::default()
411            },
412            EdgeType::Right => Self {
413                right: true,
414                ..Default::default()
415            },
416        }
417    }
418}