Skip to main content

tpt_appfront_core/
virtual_scroll.rs

1//! Virtual scrolling primitive for `List`/`DataGrid` (Phase 2). Backends
2//! that render large collections (`tpt-appfront-dom`, `tpt-appfront-canvas`) can use
3//! [`VirtualScroll::visible_range`] to render only the items currently in
4//! view plus a small overscan buffer, instead of every item in the
5//! collection. Backends that don't scroll interactively (`tpt-appfront-html`,
6//! `tpt-appfront-ai-schema`) simply ignore [`NodeMeta::virtual_scroll`] and
7//! render everything, which is correct for SSR/crawl/agent consumption.
8
9use serde::{Deserialize, Serialize};
10
11/// Configuration for windowed rendering of a fixed-height-item list.
12/// Set via [`NodeRef::virtual_scroll`][crate::NodeRef::virtual_scroll].
13#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14pub struct VirtualScroll {
15    /// Height (in the backend's own units — px for DOM/canvas) of a single item.
16    pub item_height: f32,
17    /// Height of the scrollable viewport.
18    pub viewport_height: f32,
19    /// Current scroll position, measured from the top of the full
20    /// (unvirtualized) list. Backends update this from their own scroll
21    /// event and feed it back in on the next render.
22    pub scroll_offset: f32,
23    /// Extra items rendered above/below the visible window to avoid a
24    /// blank flash during fast scrolling.
25    pub overscan: usize,
26}
27
28impl VirtualScroll {
29    pub fn new(item_height: f32, viewport_height: f32) -> Self {
30        VirtualScroll {
31            item_height,
32            viewport_height,
33            scroll_offset: 0.0,
34            overscan: 3,
35        }
36    }
37
38    pub fn with_offset(mut self, offset: f32) -> Self {
39        self.scroll_offset = offset.max(0.0);
40        self
41    }
42
43    pub fn with_overscan(mut self, overscan: usize) -> Self {
44        self.overscan = overscan;
45        self
46    }
47
48    /// Computes which item indices (of `total_items`) fall within the
49    /// viewport plus overscan, and the pixel-height spacers needed above/
50    /// below that window so the scrollable area's total height (and thus
51    /// scrollbar behavior) still matches the full, unvirtualized list.
52    pub fn visible_range(&self, total_items: usize) -> VisibleRange {
53        if total_items == 0 || self.item_height <= 0.0 {
54            return VisibleRange {
55                start: 0,
56                end: 0,
57                top_spacer: 0.0,
58                bottom_spacer: 0.0,
59            };
60        }
61
62        let first_visible = (self.scroll_offset / self.item_height).floor() as usize;
63        let visible_count = (self.viewport_height / self.item_height).ceil() as usize + 1;
64
65        let start = first_visible.saturating_sub(self.overscan).min(total_items);
66        let end = (first_visible + visible_count + self.overscan).min(total_items);
67
68        let top_spacer = start as f32 * self.item_height;
69        let bottom_spacer = (total_items - end) as f32 * self.item_height;
70
71        VisibleRange {
72            start,
73            end,
74            top_spacer,
75            bottom_spacer,
76        }
77    }
78}
79
80/// The slice of items to actually render, plus spacer heights to preserve
81/// the illusion of the full list being present for scrollbar purposes.
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub struct VisibleRange {
84    /// First visible item index (inclusive).
85    pub start: usize,
86    /// Last visible item index (exclusive).
87    pub end: usize,
88    /// Pixel height of a spacer to place before the rendered slice.
89    pub top_spacer: f32,
90    /// Pixel height of a spacer to place after the rendered slice.
91    pub bottom_spacer: f32,
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn empty_list_has_empty_range() {
100        let vs = VirtualScroll::new(20.0, 200.0);
101        let range = vs.visible_range(0);
102        assert_eq!(
103            range,
104            VisibleRange {
105                start: 0,
106                end: 0,
107                top_spacer: 0.0,
108                bottom_spacer: 0.0
109            }
110        );
111    }
112
113    #[test]
114    fn scrolled_to_top_renders_first_window_plus_overscan() {
115        // 20px items, 200px viewport => ~10 fit, +1 rounding, +3 overscan below (no overscan above at offset 0)
116        let vs = VirtualScroll::new(20.0, 200.0);
117        let range = vs.visible_range(1000);
118        assert_eq!(range.start, 0);
119        assert_eq!(range.end, 14); // 10 + 1 + 3 overscan
120        assert_eq!(range.top_spacer, 0.0);
121        assert_eq!(range.bottom_spacer, (1000 - 14) as f32 * 20.0);
122    }
123
124    #[test]
125    fn scrolled_mid_list_windows_around_offset() {
126        let vs = VirtualScroll::new(20.0, 200.0).with_offset(1000.0); // first_visible = 50
127        let range = vs.visible_range(1000);
128        assert_eq!(range.start, 47); // 50 - 3 overscan
129        assert_eq!(range.end, 64); // 50 + 11 + 3
130        assert!(range.top_spacer > 0.0);
131        assert!(range.bottom_spacer > 0.0);
132    }
133
134    #[test]
135    fn range_clamped_to_total_items() {
136        let vs = VirtualScroll::new(20.0, 200.0).with_offset(10_000.0);
137        let range = vs.visible_range(10);
138        assert_eq!(range.end, 10);
139        assert_eq!(range.bottom_spacer, 0.0);
140    }
141}