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//! - Nested DOM ID management
6
7use alloc::collections::BTreeMap;
8
9use azul_core::{
10    callbacks::{EdgeType, VirtualViewCallbackReason},
11    dom::{DomId, NodeId},
12    geom::{LogicalPosition, LogicalRect, LogicalSize},
13};
14
15use crate::managers::scroll_state::ScrollManager;
16
17/// Distance in pixels from edge that triggers edge-scrolled callback
18const EDGE_THRESHOLD: f32 = 200.0;
19
20/// Manages `VirtualView` lifecycle, including re-invocation
21///
22/// Tracks which `VirtualViews` have been invoked, assigns unique DOM IDs to nested
23/// virtual views, and determines when `VirtualViews` need to be re-invoked (e.g., when
24/// the container bounds expand or the user scrolls near an edge).
25#[derive(Debug, Clone, Default)]
26pub struct VirtualViewManager {
27    /// Per-VirtualView state keyed by (parent `DomId`, `NodeId` of virtualized view element)
28    states: BTreeMap<(DomId, NodeId), VirtualViewState>,
29    /// Counter for generating unique nested DOM IDs
30    next_dom_id: usize,
31    /// MWA-C-virtual_view: queue-time callback reasons, consumed by the very
32    /// next `check_reinvoke` for the same view (set by
33    /// `process_virtual_view_updates` right before the invoke). Replaces the
34    /// `force_reinvoke` clear-flag trick that collapsed every delivered
35    /// reason to `InitialRender`.
36    reason_overrides: Vec<((DomId, NodeId), VirtualViewCallbackReason)>,
37}
38
39/// Internal state for a single `VirtualView` instance
40///
41/// Tracks invocation status, content dimensions, and edge triggers
42/// to determine when the `VirtualView` callback needs to be re-invoked.
43#[derive(Debug, Clone)]
44struct VirtualViewState {
45    /// Content size reported by `VirtualView` callback (actual rendered size)
46    virtual_view_scroll_size: Option<LogicalSize>,
47    /// Virtual scroll size for infinite scroll scenarios
48    virtual_view_virtual_scroll_size: Option<LogicalSize>,
49    /// Whether the `VirtualView` has ever been invoked
50    virtual_view_was_invoked: bool,
51    /// Whether invoked for current container expansion
52    invoked_for_current_expansion: bool,
53    /// Whether invoked for current edge scroll event
54    invoked_for_current_edge: bool,
55    /// Which edges have already triggered callbacks
56    last_edge_triggered: EdgeFlags,
57    /// Unique DOM ID assigned to this `VirtualView`'s content
58    nested_dom_id: DomId,
59    /// Last known layout bounds of the `VirtualView` container
60    last_bounds: LogicalRect,
61    /// Scroll offset captured at `InitialRender`. Edge-scroll callbacks only fire
62    /// once the user has scrolled away from this resting position — being at an
63    /// edge from the very start (e.g. the top/left edge at offset 0) is the
64    /// initial position, not a scroll-to-edge event.
65    initial_scroll_offset: LogicalPosition,
66}
67
68/// Flags indicating which scroll edges have been triggered
69///
70/// Used to prevent repeated edge-scroll callbacks for the same edge
71/// until the user scrolls away and back.
72#[derive(Debug, Clone, Copy, PartialEq, Default)]
73#[allow(clippy::struct_excessive_bools)] // one independent bool per box edge (top/bottom/left/right)
74struct EdgeFlags {
75    /// Near top edge
76    top: bool,
77    /// Near bottom edge
78    bottom: bool,
79    /// Near left edge
80    left: bool,
81    /// Near right edge
82    right: bool,
83}
84
85impl VirtualViewManager {
86    /// Creates a new `VirtualViewManager` with no tracked `VirtualViews`
87    #[must_use] pub fn new() -> Self {
88        Self {
89            next_dom_id: 1, // 0 is root
90            ..Default::default()
91        }
92    }
93
94    /// Number of tracked `VirtualView` states. Used by `AZ_E2E_TEST` to watch growth.
95    #[must_use] pub fn debug_counts(&self) -> usize {
96        self.states.len()
97    }
98
99    /// MWA-C-virtual_view: stage the reason the next invoke of this view
100    /// should deliver to the user callback (consumed by `check_reinvoke`).
101    pub fn set_reason_override(
102        &mut self,
103        dom_id: DomId,
104        node_id: NodeId,
105        reason: VirtualViewCallbackReason,
106    ) {
107        self.reason_overrides
108            .retain(|((d, n), _)| !(*d == dom_id && *n == node_id));
109        self.reason_overrides.push(((dom_id, node_id), reason));
110    }
111
112    /// Gets or creates a unique nested DOM ID for a `VirtualView`
113    ///
114    /// Returns the existing DOM ID if the `VirtualView` was previously registered,
115    /// otherwise allocates a new unique ID and initializes the `VirtualView` state.
116    pub fn get_or_create_nested_dom_id(&mut self, dom_id: DomId, node_id: NodeId) -> DomId {
117        let key = (dom_id, node_id);
118
119        // Check if already exists
120        if let Some(state) = self.states.get(&key) {
121            return state.nested_dom_id;
122        }
123
124        // Create new nested DOM ID
125        let nested_dom_id = DomId {
126            inner: self.next_dom_id,
127        };
128        self.next_dom_id += 1;
129
130        self.states.insert(key, VirtualViewState::new(nested_dom_id));
131        nested_dom_id
132    }
133
134    /// Gets the nested DOM ID for a `VirtualView` if it exists
135    #[must_use] pub fn get_nested_dom_id(&self, dom_id: DomId, node_id: NodeId) -> Option<DomId> {
136        self.states.get(&(dom_id, node_id)).map(|s| s.nested_dom_id)
137    }
138
139    /// Returns whether the `VirtualView` has ever been invoked
140    #[must_use] pub fn was_virtual_view_invoked(&self, dom_id: DomId, node_id: NodeId) -> bool {
141        self.states
142            .get(&(dom_id, node_id))
143            .is_some_and(|s| s.virtual_view_was_invoked)
144    }
145
146    /// Updates the `VirtualView`'s content size information
147    ///
148    /// Called after the `VirtualView` callback returns to record the actual content
149    /// dimensions. If the new size is larger than previously recorded, clears
150    /// the expansion flag to allow `BoundsExpanded` re-invocation.
151    pub fn update_virtual_view_info(
152        &mut self,
153        dom_id: DomId,
154        node_id: NodeId,
155        scroll_size: LogicalSize,
156        virtual_scroll_size: LogicalSize,
157    ) -> Option<()> {
158        let state = self.states.get_mut(&(dom_id, node_id))?;
159
160        // Reset expansion flag if content grew
161        if let Some(old_size) = state.virtual_view_scroll_size {
162            if scroll_size.width > old_size.width || scroll_size.height > old_size.height {
163                state.invoked_for_current_expansion = false;
164            }
165        }
166        state.virtual_view_scroll_size = Some(scroll_size);
167        state.virtual_view_virtual_scroll_size = Some(virtual_scroll_size);
168
169        Some(())
170    }
171
172    /// Marks a `VirtualView` as invoked for a specific reason
173    ///
174    /// Updates internal state flags based on the callback reason to prevent
175    /// duplicate callbacks for the same trigger condition.
176    pub fn mark_invoked(
177        &mut self,
178        dom_id: DomId,
179        node_id: NodeId,
180        reason: VirtualViewCallbackReason,
181    ) -> Option<()> {
182        let state = self.states.get_mut(&(dom_id, node_id))?;
183
184        state.virtual_view_was_invoked = true;
185        match reason {
186            VirtualViewCallbackReason::BoundsExpanded => state.invoked_for_current_expansion = true,
187            VirtualViewCallbackReason::EdgeScrolled(edge) => {
188                state.invoked_for_current_edge = true;
189                state.last_edge_triggered = edge.into();
190            }
191            _ => {}
192        }
193
194        Some(())
195    }
196
197    /// Reset invocation flags for ALL tracked `VirtualViews`
198    ///
199    /// After `layout_results.clear()`, the child DOMs no longer exist in memory.
200    /// This method ensures `check_reinvoke()` returns `InitialRender` for every
201    /// `VirtualView`, so the callbacks re-run and re-populate `layout_results`.
202    ///
203    /// Called from `layout_and_generate_display_list()` after clearing layout results.
204    pub fn reset_all_invocation_flags(&mut self) {
205        for state in self.states.values_mut() {
206            state.virtual_view_was_invoked = false;
207            state.invoked_for_current_expansion = false;
208            state.invoked_for_current_edge = false;
209            state.last_edge_triggered = EdgeFlags::default();
210        }
211    }
212
213    /// Force a `VirtualView` to be re-invoked on the next layout pass
214    ///
215    /// Clears all invocation flags, causing `check_reinvoke()` to return `InitialRender`.
216    /// Used by `trigger_virtual_view_rerender()` to manually refresh `VirtualView` content.
217    pub fn force_reinvoke(&mut self, dom_id: DomId, node_id: NodeId) -> Option<()> {
218        let state = self.states.get_mut(&(dom_id, node_id))?;
219
220        state.virtual_view_was_invoked = false;
221        state.invoked_for_current_expansion = false;
222        state.invoked_for_current_edge = false;
223
224        Some(())
225    }
226
227    /// `(DomId, NodeId)` of every `VirtualView` registered so far (invoked at
228    /// least once). Used to re-invoke *all* views after a shared-dataset change
229    /// arrives out-of-band (e.g. a background tile-fetch writeback) without
230    /// needing to know which node the data belongs to.
231    #[must_use] pub fn all_view_keys(&self) -> Vec<(DomId, NodeId)> {
232        self.states.keys().copied().collect()
233    }
234
235    /// Checks whether a `VirtualView` needs to be re-invoked and returns the reason
236    ///
237    /// Returns `Some(reason)` if the `VirtualView` callback should be invoked:
238    /// - `InitialRender`: `VirtualView` has never been invoked
239    /// - `BoundsExpanded`: Container grew larger than content
240    /// - `EdgeScrolled`: User scrolled near an edge (for lazy loading)
241    ///
242    /// Returns `None` if no re-invocation is needed.
243    pub fn check_reinvoke(
244        &mut self,
245        dom_id: DomId,
246        node_id: NodeId,
247        scroll_manager: &ScrollManager,
248        layout_bounds: LogicalRect,
249    ) -> Option<VirtualViewCallbackReason> {
250        // MWA-C-virtual_view: a staged reason override wins (set by
251        // process_virtual_view_updates immediately before the invoke). The
252        // old force_reinvoke path cleared was_invoked instead, which
253        // collapsed EVERY queued re-invocation to InitialRender at delivery
254        // time — user callbacks could never see EdgeScrolled/BoundsExpanded/
255        // DomRecreated (the latter had zero producers at all).
256        if let Some(pos) = self
257            .reason_overrides
258            .iter()
259            .position(|((d, n), _)| *d == dom_id && *n == node_id)
260        {
261            let (_, reason) = self.reason_overrides.remove(pos);
262            return Some(reason);
263        }
264
265        let state = self.states.entry((dom_id, node_id)).or_insert_with(|| {
266            let nested_dom_id = DomId {
267                inner: self.next_dom_id,
268            };
269            self.next_dom_id += 1;
270            VirtualViewState::new(nested_dom_id)
271        });
272
273        if !state.virtual_view_was_invoked {
274            // Remember where we started, so edge callbacks fire on scroll-to-edge,
275            // not for the edge we happen to rest on at the initial position.
276            state.initial_scroll_offset = scroll_manager
277                .get_current_offset(dom_id, node_id)
278                .unwrap_or_default();
279            return Some(VirtualViewCallbackReason::InitialRender);
280        }
281
282        // Check for bounds expansion
283        if layout_bounds.size.width > state.last_bounds.size.width
284            || layout_bounds.size.height > state.last_bounds.size.height
285        {
286            state.invoked_for_current_expansion = false;
287        }
288        state.last_bounds = layout_bounds;
289
290        let scroll_offset = scroll_manager
291            .get_current_offset(dom_id, node_id)
292            .unwrap_or_default();
293
294        state.check_reinvoke_condition(scroll_offset, layout_bounds.size)
295    }
296
297    /// Returns debug info for all tracked `VirtualViews`
298    ///
299    /// Each entry contains: (`parent_dom_id`, `parent_node_id`, `nested_dom_id`,
300    /// `scroll_size`, `virtual_scroll_size`, `was_invoked`, `last_bounds`)
301    #[must_use] pub fn get_all_virtual_view_infos(&self) -> Vec<VirtualViewDebugInfo> {
302        self.states
303            .iter()
304            .map(|((dom_id, node_id), state)| VirtualViewDebugInfo {
305                parent_dom_id: dom_id.inner,
306                parent_node_id: node_id.index(),
307                nested_dom_id: state.nested_dom_id.inner,
308                scroll_size_width: state.virtual_view_scroll_size.map(|s| s.width),
309                scroll_size_height: state.virtual_view_scroll_size.map(|s| s.height),
310                virtual_scroll_size_width: state.virtual_view_virtual_scroll_size.map(|s| s.width),
311                virtual_scroll_size_height: state.virtual_view_virtual_scroll_size.map(|s| s.height),
312                was_invoked: state.virtual_view_was_invoked,
313                last_bounds_x: state.last_bounds.origin.x,
314                last_bounds_y: state.last_bounds.origin.y,
315                last_bounds_width: state.last_bounds.size.width,
316                last_bounds_height: state.last_bounds.size.height,
317            })
318            .collect()
319    }
320}
321
322/// Debug info for a single `VirtualView`, returned by `get_all_virtual_view_infos`
323#[derive(Copy, Debug, Clone)]
324pub struct VirtualViewDebugInfo {
325    pub parent_dom_id: usize,
326    pub parent_node_id: usize,
327    pub nested_dom_id: usize,
328    pub scroll_size_width: Option<f32>,
329    pub scroll_size_height: Option<f32>,
330    pub virtual_scroll_size_width: Option<f32>,
331    pub virtual_scroll_size_height: Option<f32>,
332    pub was_invoked: bool,
333    pub last_bounds_x: f32,
334    pub last_bounds_y: f32,
335    pub last_bounds_width: f32,
336    pub last_bounds_height: f32,
337}
338
339impl VirtualViewState {
340    /// Creates a new `VirtualViewState` with the given nested DOM ID
341    fn new(nested_dom_id: DomId) -> Self {
342        Self {
343            virtual_view_scroll_size: None,
344            virtual_view_virtual_scroll_size: None,
345            virtual_view_was_invoked: false,
346            invoked_for_current_expansion: false,
347            invoked_for_current_edge: false,
348            last_edge_triggered: EdgeFlags::default(),
349            nested_dom_id,
350            last_bounds: LogicalRect::zero(),
351            initial_scroll_offset: LogicalPosition::zero(),
352        }
353    }
354
355    /// Determines if the `VirtualView` callback should be re-invoked based on
356    /// scroll position
357    ///
358    /// Checks two conditions:
359    /// 1. Container bounds expanded beyond content size
360    /// 2. User scrolled within `EDGE_THRESHOLD` pixels of an edge (for lazy loading)
361    fn check_reinvoke_condition(
362        &self,
363        current_offset: LogicalPosition,
364        container_size: LogicalSize,
365    ) -> Option<VirtualViewCallbackReason> {
366        // Need scroll_size to determine if we can scroll at all
367        let scroll_size = self.virtual_view_scroll_size?;
368
369        // Check 1: Container grew larger than content - need more content
370        if !self.invoked_for_current_expansion
371            && (container_size.width > scroll_size.width
372                || container_size.height > scroll_size.height)
373        {
374            return Some(VirtualViewCallbackReason::BoundsExpanded);
375        }
376
377        // Check 2: Edge-based lazy loading
378        // Determine if scrolling is possible in each direction
379        let scrollable_width = scroll_size.width > container_size.width;
380        let scrollable_height = scroll_size.height > container_size.height;
381
382        // Calculate which edges the user is currently near
383        let current_edges = EdgeFlags {
384            top: scrollable_height && current_offset.y <= EDGE_THRESHOLD,
385            bottom: scrollable_height
386                && (scroll_size.height - container_size.height - current_offset.y)
387                    <= EDGE_THRESHOLD,
388            left: scrollable_width && current_offset.x <= EDGE_THRESHOLD,
389            right: scrollable_width
390                && (scroll_size.width - container_size.width - current_offset.x) <= EDGE_THRESHOLD,
391        };
392
393        // Only treat an edge as "scrolled to" once the user has actually moved
394        // from the resting position captured at InitialRender — sitting at the
395        // initial top/left edge from the start is not an edge-scroll event.
396        let has_scrolled = current_offset != self.initial_scroll_offset;
397
398        // Trigger edge callback if near an edge that hasn't been triggered yet
399        // Prioritize bottom/right edges (common infinite scroll directions)
400        if has_scrolled && !self.invoked_for_current_edge && current_edges.any() {
401            if current_edges.bottom && !self.last_edge_triggered.bottom {
402                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Bottom));
403            }
404            if current_edges.right && !self.last_edge_triggered.right {
405                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Right));
406            }
407            if current_edges.top && !self.last_edge_triggered.top {
408                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Top));
409            }
410            if current_edges.left && !self.last_edge_triggered.left {
411                return Some(VirtualViewCallbackReason::EdgeScrolled(EdgeType::Left));
412            }
413        }
414
415        None
416    }
417}
418
419impl EdgeFlags {
420    /// Returns true if any edge flag is set
421    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
422    const fn any(&self) -> bool {
423        self.top || self.bottom || self.left || self.right
424    }
425}
426
427impl From<EdgeType> for EdgeFlags {
428    fn from(edge: EdgeType) -> Self {
429        match edge {
430            EdgeType::Top => Self {
431                top: true,
432                ..Default::default()
433            },
434            EdgeType::Bottom => Self {
435                bottom: true,
436                ..Default::default()
437            },
438            EdgeType::Left => Self {
439                left: true,
440                ..Default::default()
441            },
442            EdgeType::Right => Self {
443                right: true,
444                ..Default::default()
445            },
446        }
447    }
448}
449
450impl crate::managers::NodeIdRemap for VirtualViewManager {
451    /// Remap the `(DomId, NodeId)` keys of every tracked `VirtualView`.
452    ///
453    /// A `VirtualView` whose host node was unmounted has its state dropped —
454    /// including the `nested_dom_id` binding, which would otherwise resurface
455    /// on whatever node inherited the index (rendering the *wrong* nested DOM
456    /// into it) and leak forever.
457    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
458        crate::managers::remap_dom_keys(&mut self.states, dom, map);
459
460        self.reason_overrides.retain_mut(|((d, node_id), _)| {
461            if *d != dom {
462                return true;
463            }
464            match map.resolve(*node_id) {
465                Some(new_id) => {
466                    *node_id = new_id;
467                    true
468                }
469                None => false,
470            }
471        });
472    }
473}