Skip to main content

presentar_core/
virtualization.rs

1// Scroll Virtualization - WASM-first list/grid virtualization
2//
3// Provides:
4// - Virtual scrolling for large lists
5// - Only renders visible items + overscan
6// - Variable item heights support
7// - Grid virtualization
8// - Infinite scroll support
9// - Scroll position restoration
10
11use std::collections::HashMap;
12use std::ops::Range;
13
14/// Index of an item in a virtualized list
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct ItemIndex(pub usize);
17
18impl ItemIndex {
19    pub fn as_usize(self) -> usize {
20        self.0
21    }
22}
23
24impl From<usize> for ItemIndex {
25    fn from(v: usize) -> Self {
26        Self(v)
27    }
28}
29
30/// Configuration for virtualized list
31#[derive(Debug, Clone)]
32pub struct VirtualListConfig {
33    /// Estimated height of each item (used when actual height unknown)
34    pub estimated_item_height: f32,
35    /// Number of items to render above/below visible area
36    pub overscan_count: usize,
37    /// Enable variable height items
38    pub variable_heights: bool,
39    /// Initial scroll position
40    pub initial_scroll: f32,
41    /// Scroll threshold for triggering load more
42    pub load_more_threshold: f32,
43}
44
45impl Default for VirtualListConfig {
46    fn default() -> Self {
47        Self {
48            estimated_item_height: 50.0,
49            overscan_count: 3,
50            variable_heights: false,
51            initial_scroll: 0.0,
52            load_more_threshold: 100.0,
53        }
54    }
55}
56
57/// Visible range information
58#[derive(Debug, Clone, PartialEq)]
59pub struct VisibleRange {
60    /// First visible item index
61    pub start: usize,
62    /// Last visible item index (exclusive)
63    pub end: usize,
64    /// First item to render (including overscan)
65    pub render_start: usize,
66    /// Last item to render (exclusive, including overscan)
67    pub render_end: usize,
68    /// Offset for the first rendered item
69    pub offset: f32,
70}
71
72impl VisibleRange {
73    /// Get range of visible items
74    pub fn visible_range(&self) -> Range<usize> {
75        self.start..self.end
76    }
77
78    /// Get range of items to render
79    pub fn render_range(&self) -> Range<usize> {
80        contract_pre_render!();
81        self.render_start..self.render_end
82    }
83
84    /// Check if index is visible
85    pub fn is_visible(&self, index: usize) -> bool {
86        index >= self.start && index < self.end
87    }
88
89    /// Check if index should be rendered
90    pub fn should_render(&self, index: usize) -> bool {
91        contract_pre_render!();
92        index >= self.render_start && index < self.render_end
93    }
94
95    /// Number of visible items
96    pub fn visible_count(&self) -> usize {
97        self.end.saturating_sub(self.start)
98    }
99
100    /// Number of items to render
101    pub fn render_count(&self) -> usize {
102        contract_pre_render!();
103        self.render_end.saturating_sub(self.render_start)
104    }
105}
106
107/// Item layout information
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub struct ItemLayout {
110    /// Y position of the item
111    pub y: f32,
112    /// Height of the item
113    pub height: f32,
114}
115
116impl ItemLayout {
117    pub fn new(y: f32, height: f32) -> Self {
118        Self { y, height }
119    }
120
121    /// Get the bottom edge of this item
122    pub fn bottom(&self) -> f32 {
123        self.y + self.height
124    }
125}
126
127/// Virtualized list state
128#[derive(Debug)]
129pub struct VirtualList {
130    config: VirtualListConfig,
131    /// Total number of items
132    item_count: usize,
133    /// Known item heights (for variable height lists)
134    item_heights: HashMap<usize, f32>,
135    /// Cached item positions
136    item_positions: Vec<f32>,
137    /// Whether positions need recalculation
138    positions_dirty: bool,
139    /// Current scroll position
140    scroll_position: f32,
141    /// Viewport height
142    viewport_height: f32,
143    /// Total content height
144    content_height: f32,
145    /// Currently visible range
146    visible_range: Option<VisibleRange>,
147}
148
149impl Default for VirtualList {
150    fn default() -> Self {
151        Self::new(VirtualListConfig::default())
152    }
153}
154
155impl VirtualList {
156    pub fn new(config: VirtualListConfig) -> Self {
157        let initial_scroll = config.initial_scroll;
158        Self {
159            config,
160            item_count: 0,
161            item_heights: HashMap::new(),
162            item_positions: Vec::new(),
163            positions_dirty: true,
164            scroll_position: initial_scroll,
165            viewport_height: 0.0,
166            content_height: 0.0,
167            visible_range: None,
168        }
169    }
170
171    /// Set total item count
172    pub fn set_item_count(&mut self, count: usize) {
173        if count != self.item_count {
174            self.item_count = count;
175            self.positions_dirty = true;
176        }
177    }
178
179    /// Get total item count
180    pub fn item_count(&self) -> usize {
181        self.item_count
182    }
183
184    /// Set viewport height
185    pub fn set_viewport_height(&mut self, height: f32) {
186        if (height - self.viewport_height).abs() > 0.1 {
187            self.viewport_height = height;
188            self.update_visible_range();
189        }
190    }
191
192    /// Get viewport height
193    pub fn viewport_height(&self) -> f32 {
194        self.viewport_height
195    }
196
197    /// Set scroll position
198    pub fn set_scroll_position(&mut self, position: f32) {
199        let clamped = position.max(0.0).min(self.max_scroll());
200        if (clamped - self.scroll_position).abs() > 0.1 {
201            self.scroll_position = clamped;
202            self.update_visible_range();
203        }
204    }
205
206    /// Get current scroll position
207    pub fn scroll_position(&self) -> f32 {
208        self.scroll_position
209    }
210
211    /// Get maximum scroll position
212    pub fn max_scroll(&self) -> f32 {
213        (self.calculate_content_height() - self.viewport_height).max(0.0)
214    }
215
216    /// Calculate content height without caching
217    fn calculate_content_height(&self) -> f32 {
218        if !self.config.variable_heights {
219            return self.item_count as f32 * self.config.estimated_item_height;
220        }
221
222        let mut height = 0.0;
223        for i in 0..self.item_count {
224            height += self.get_item_height(i);
225        }
226        height
227    }
228
229    /// Scroll by delta
230    pub fn scroll_by(&mut self, delta: f32) {
231        self.set_scroll_position(self.scroll_position + delta);
232    }
233
234    /// Scroll to specific item
235    pub fn scroll_to_item(&mut self, index: usize, align: ScrollAlign) {
236        if index >= self.item_count {
237            return;
238        }
239
240        if self.positions_dirty {
241            self.recalculate_positions();
242        }
243        let item_y = self.get_item_position(index);
244        let item_height = self.get_item_height(index);
245
246        let new_scroll = match align {
247            ScrollAlign::Start => item_y,
248            ScrollAlign::Center => item_y - (self.viewport_height - item_height) / 2.0,
249            ScrollAlign::End => item_y - self.viewport_height + item_height,
250            ScrollAlign::Auto => {
251                // Only scroll if item is not fully visible
252                if item_y < self.scroll_position {
253                    item_y
254                } else if item_y + item_height > self.scroll_position + self.viewport_height {
255                    item_y + item_height - self.viewport_height
256                } else {
257                    self.scroll_position
258                }
259            }
260        };
261
262        self.set_scroll_position(new_scroll);
263    }
264
265    /// Set height for a specific item
266    pub fn set_item_height(&mut self, index: usize, height: f32) {
267        if self.config.variable_heights {
268            self.item_heights.insert(index, height);
269            self.positions_dirty = true;
270        }
271    }
272
273    /// Get height for a specific item
274    pub fn get_item_height(&self, index: usize) -> f32 {
275        if self.config.variable_heights {
276            self.item_heights
277                .get(&index)
278                .copied()
279                .unwrap_or(self.config.estimated_item_height)
280        } else {
281            self.config.estimated_item_height
282        }
283    }
284
285    /// Get position for a specific item
286    pub fn get_item_position(&self, index: usize) -> f32 {
287        if index == 0 {
288            return 0.0;
289        }
290
291        // For fixed height, calculate directly
292        if !self.config.variable_heights {
293            return index as f32 * self.config.estimated_item_height;
294        }
295
296        // For variable heights, use cached positions if available
297        if index < self.item_positions.len() {
298            self.item_positions[index]
299        } else {
300            // Calculate position on the fly
301            let mut y = 0.0;
302            for i in 0..index {
303                y += self.get_item_height(i);
304            }
305            y
306        }
307    }
308
309    /// Get layout for a specific item
310    pub fn get_item_layout(&self, index: usize) -> ItemLayout {
311        ItemLayout {
312            y: self.get_item_position(index),
313            height: self.get_item_height(index),
314        }
315    }
316
317    /// Get total content height
318    pub fn content_height(&self) -> f32 {
319        self.calculate_content_height()
320    }
321
322    /// Get currently visible range
323    pub fn visible_range(&self) -> Option<&VisibleRange> {
324        self.visible_range.as_ref()
325    }
326
327    /// Check if we're near the end (for infinite scroll)
328    pub fn is_near_end(&self) -> bool {
329        self.scroll_position + self.viewport_height + self.config.load_more_threshold
330            >= self.content_height
331    }
332
333    /// Check if we're near the start
334    pub fn is_near_start(&self) -> bool {
335        self.scroll_position <= self.config.load_more_threshold
336    }
337
338    /// Recalculate visible range
339    fn update_visible_range(&mut self) {
340        if self.positions_dirty {
341            self.recalculate_positions();
342        }
343
344        if self.item_count == 0 || self.viewport_height <= 0.0 {
345            self.visible_range = None;
346            return;
347        }
348
349        // Find first visible item
350        let start = self.find_item_at_position(self.scroll_position);
351        let end = self.find_item_at_position(self.scroll_position + self.viewport_height) + 1;
352        let end = end.min(self.item_count);
353
354        // Calculate render range with overscan
355        let render_start = start.saturating_sub(self.config.overscan_count);
356        let render_end = (end + self.config.overscan_count).min(self.item_count);
357
358        // Calculate offset for first rendered item
359        let offset = self.get_item_position(render_start);
360
361        self.visible_range = Some(VisibleRange {
362            start,
363            end,
364            render_start,
365            render_end,
366            offset,
367        });
368    }
369
370    /// Find item at a given scroll position
371    fn find_item_at_position(&self, position: f32) -> usize {
372        if position <= 0.0 {
373            return 0;
374        }
375
376        if !self.config.variable_heights {
377            // Fast path for fixed height
378            return (position / self.config.estimated_item_height) as usize;
379        }
380
381        // Binary search for variable heights
382        let mut low = 0;
383        let mut high = self.item_count;
384
385        while low < high {
386            let mid = (low + high) / 2;
387            let item_pos = self.get_item_position(mid);
388
389            if item_pos <= position {
390                low = mid + 1;
391            } else {
392                high = mid;
393            }
394        }
395
396        low.saturating_sub(1)
397    }
398
399    /// Recalculate all positions
400    fn recalculate_positions(&mut self) {
401        self.item_positions.clear();
402        self.item_positions.reserve(self.item_count);
403
404        let mut current_y = 0.0;
405        for i in 0..self.item_count {
406            self.item_positions.push(current_y);
407            current_y += self.get_item_height(i);
408        }
409
410        self.content_height = current_y;
411        self.positions_dirty = false;
412    }
413
414    /// Reset scroll position
415    pub fn reset(&mut self) {
416        self.scroll_position = 0.0;
417        self.visible_range = None;
418        self.update_visible_range();
419    }
420}
421
422/// Scroll alignment options
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424pub enum ScrollAlign {
425    /// Align item to start of viewport
426    Start,
427    /// Align item to center of viewport
428    Center,
429    /// Align item to end of viewport
430    End,
431    /// Only scroll if item not visible
432    Auto,
433}
434
435/// Grid cell position
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
437pub struct GridCell {
438    pub row: usize,
439    pub col: usize,
440}
441
442impl GridCell {
443    pub fn new(row: usize, col: usize) -> Self {
444        Self { row, col }
445    }
446}
447
448/// Configuration for virtualized grid
449#[derive(Debug, Clone)]
450pub struct VirtualGridConfig {
451    /// Number of columns
452    pub columns: usize,
453    /// Cell width
454    pub cell_width: f32,
455    /// Cell height
456    pub cell_height: f32,
457    /// Gap between cells
458    pub gap: f32,
459    /// Number of rows to render above/below visible area
460    pub overscan_rows: usize,
461}
462
463impl Default for VirtualGridConfig {
464    fn default() -> Self {
465        Self {
466            columns: 3,
467            cell_width: 100.0,
468            cell_height: 100.0,
469            gap: 8.0,
470            overscan_rows: 2,
471        }
472    }
473}
474
475/// Visible grid range
476#[derive(Debug, Clone, PartialEq)]
477pub struct VisibleGridRange {
478    /// First visible row
479    pub start_row: usize,
480    /// Last visible row (exclusive)
481    pub end_row: usize,
482    /// First row to render (including overscan)
483    pub render_start_row: usize,
484    /// Last row to render (exclusive, including overscan)
485    pub render_end_row: usize,
486    /// Number of columns
487    pub columns: usize,
488    /// Y offset for first rendered row
489    pub offset: f32,
490}
491
492impl VisibleGridRange {
493    /// Get all cells that should be rendered
494    pub fn cells_to_render(&self, total_items: usize) -> Vec<GridCell> {
495        contract_pre_render!();
496        let mut cells = Vec::new();
497        for row in self.render_start_row..self.render_end_row {
498            for col in 0..self.columns {
499                let index = row * self.columns + col;
500                if index < total_items {
501                    cells.push(GridCell::new(row, col));
502                }
503            }
504        }
505        cells
506    }
507
508    /// Check if a cell should be rendered
509    pub fn should_render_cell(&self, row: usize, col: usize) -> bool {
510        contract_pre_render!();
511        row >= self.render_start_row && row < self.render_end_row && col < self.columns
512    }
513}
514
515/// Cell layout information
516#[derive(Debug, Clone, Copy, PartialEq)]
517pub struct CellLayout {
518    pub x: f32,
519    pub y: f32,
520    pub width: f32,
521    pub height: f32,
522}
523
524impl CellLayout {
525    pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
526        Self {
527            x,
528            y,
529            width,
530            height,
531        }
532    }
533}
534
535/// Virtualized grid state
536#[derive(Debug)]
537pub struct VirtualGrid {
538    config: VirtualGridConfig,
539    /// Total number of items
540    item_count: usize,
541    /// Current scroll position
542    scroll_position: f32,
543    /// Viewport height
544    viewport_height: f32,
545    /// Currently visible range
546    visible_range: Option<VisibleGridRange>,
547}
548
549impl Default for VirtualGrid {
550    fn default() -> Self {
551        Self::new(VirtualGridConfig::default())
552    }
553}
554
555impl VirtualGrid {
556    pub fn new(config: VirtualGridConfig) -> Self {
557        Self {
558            config,
559            item_count: 0,
560            scroll_position: 0.0,
561            viewport_height: 0.0,
562            visible_range: None,
563        }
564    }
565
566    /// Set total item count
567    pub fn set_item_count(&mut self, count: usize) {
568        if count != self.item_count {
569            self.item_count = count;
570            self.update_visible_range();
571        }
572    }
573
574    /// Get total item count
575    pub fn item_count(&self) -> usize {
576        self.item_count
577    }
578
579    /// Get row count
580    pub fn row_count(&self) -> usize {
581        self.item_count.div_ceil(self.config.columns)
582    }
583
584    /// Set viewport height
585    pub fn set_viewport_height(&mut self, height: f32) {
586        if (height - self.viewport_height).abs() > 0.1 {
587            self.viewport_height = height;
588            self.update_visible_range();
589        }
590    }
591
592    /// Get viewport height
593    pub fn viewport_height(&self) -> f32 {
594        self.viewport_height
595    }
596
597    /// Set scroll position
598    pub fn set_scroll_position(&mut self, position: f32) {
599        let clamped = position.max(0.0).min(self.max_scroll());
600        if (clamped - self.scroll_position).abs() > 0.1 {
601            self.scroll_position = clamped;
602            self.update_visible_range();
603        }
604    }
605
606    /// Get current scroll position
607    pub fn scroll_position(&self) -> f32 {
608        self.scroll_position
609    }
610
611    /// Get maximum scroll position
612    pub fn max_scroll(&self) -> f32 {
613        (self.content_height() - self.viewport_height).max(0.0)
614    }
615
616    /// Scroll by delta
617    pub fn scroll_by(&mut self, delta: f32) {
618        self.set_scroll_position(self.scroll_position + delta);
619    }
620
621    /// Scroll to specific item
622    pub fn scroll_to_item(&mut self, index: usize, align: ScrollAlign) {
623        if index >= self.item_count {
624            return;
625        }
626
627        let row = index / self.config.columns;
628        let row_y = self.row_position(row);
629
630        let new_scroll = match align {
631            ScrollAlign::Start => row_y,
632            ScrollAlign::Center => row_y - (self.viewport_height - self.row_height()) / 2.0,
633            ScrollAlign::End => row_y - self.viewport_height + self.row_height(),
634            ScrollAlign::Auto => {
635                if row_y < self.scroll_position {
636                    row_y
637                } else if row_y + self.row_height() > self.scroll_position + self.viewport_height {
638                    row_y + self.row_height() - self.viewport_height
639                } else {
640                    self.scroll_position
641                }
642            }
643        };
644
645        self.set_scroll_position(new_scroll);
646    }
647
648    /// Get row height (cell height + gap)
649    pub fn row_height(&self) -> f32 {
650        self.config.cell_height + self.config.gap
651    }
652
653    /// Get row position
654    pub fn row_position(&self, row: usize) -> f32 {
655        row as f32 * self.row_height()
656    }
657
658    /// Get content height
659    pub fn content_height(&self) -> f32 {
660        let rows = self.row_count();
661        if rows == 0 {
662            0.0
663        } else {
664            (rows as f32).mul_add(self.config.cell_height, (rows - 1) as f32 * self.config.gap)
665        }
666    }
667
668    /// Get cell layout by index
669    pub fn get_cell_layout(&self, index: usize) -> CellLayout {
670        let row = index / self.config.columns;
671        let col = index % self.config.columns;
672        self.get_cell_layout_by_position(row, col)
673    }
674
675    /// Get cell layout by row/column
676    pub fn get_cell_layout_by_position(&self, row: usize, col: usize) -> CellLayout {
677        let x = col as f32 * (self.config.cell_width + self.config.gap);
678        let y = row as f32 * (self.config.cell_height + self.config.gap);
679        CellLayout::new(x, y, self.config.cell_width, self.config.cell_height)
680    }
681
682    /// Convert grid cell to item index
683    pub fn cell_to_index(&self, cell: &GridCell) -> usize {
684        cell.row * self.config.columns + cell.col
685    }
686
687    /// Convert item index to grid cell
688    pub fn index_to_cell(&self, index: usize) -> GridCell {
689        GridCell {
690            row: index / self.config.columns,
691            col: index % self.config.columns,
692        }
693    }
694
695    /// Get visible range
696    pub fn visible_range(&self) -> Option<&VisibleGridRange> {
697        self.visible_range.as_ref()
698    }
699
700    /// Update visible range
701    fn update_visible_range(&mut self) {
702        if self.item_count == 0 || self.viewport_height <= 0.0 {
703            self.visible_range = None;
704            return;
705        }
706
707        let row_height = self.row_height();
708        let start_row = (self.scroll_position / row_height) as usize;
709        let visible_rows = (self.viewport_height / row_height).ceil() as usize + 1;
710        let end_row = (start_row + visible_rows).min(self.row_count());
711
712        let render_start_row = start_row.saturating_sub(self.config.overscan_rows);
713        let render_end_row = (end_row + self.config.overscan_rows).min(self.row_count());
714
715        let offset = render_start_row as f32 * row_height;
716
717        self.visible_range = Some(VisibleGridRange {
718            start_row,
719            end_row,
720            render_start_row,
721            render_end_row,
722            columns: self.config.columns,
723            offset,
724        });
725    }
726
727    /// Reset scroll position
728    pub fn reset(&mut self) {
729        self.scroll_position = 0.0;
730        self.visible_range = None;
731        self.update_visible_range();
732    }
733}
734
735#[cfg(test)]
736#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn test_virtual_list_default() {
742        let list = VirtualList::default();
743        assert_eq!(list.item_count(), 0);
744        assert_eq!(list.scroll_position(), 0.0);
745    }
746
747    #[test]
748    fn test_virtual_list_set_item_count() {
749        let mut list = VirtualList::default();
750        list.set_item_count(100);
751        assert_eq!(list.item_count(), 100);
752    }
753
754    #[test]
755    fn test_virtual_list_viewport() {
756        let mut list = VirtualList::default();
757        list.set_viewport_height(500.0);
758        assert_eq!(list.viewport_height(), 500.0);
759    }
760
761    #[test]
762    fn test_virtual_list_scroll_position() {
763        let mut list = VirtualList::default();
764        list.set_item_count(100);
765        list.set_viewport_height(500.0);
766
767        list.set_scroll_position(100.0);
768        assert_eq!(list.scroll_position(), 100.0);
769    }
770
771    #[test]
772    fn test_virtual_list_scroll_clamped() {
773        let mut list = VirtualList::default();
774        list.set_item_count(10);
775        list.set_viewport_height(500.0);
776
777        // Should be clamped to 0
778        list.set_scroll_position(-100.0);
779        assert_eq!(list.scroll_position(), 0.0);
780    }
781
782    #[test]
783    fn test_virtual_list_scroll_by() {
784        let mut list = VirtualList::default();
785        list.set_item_count(100);
786        list.set_viewport_height(500.0);
787
788        list.scroll_by(50.0);
789        assert_eq!(list.scroll_position(), 50.0);
790
791        list.scroll_by(25.0);
792        assert_eq!(list.scroll_position(), 75.0);
793    }
794
795    #[test]
796    fn test_virtual_list_content_height() {
797        let config = VirtualListConfig {
798            estimated_item_height: 40.0,
799            ..Default::default()
800        };
801        let mut list = VirtualList::new(config);
802        list.set_item_count(10);
803
804        assert_eq!(list.content_height(), 400.0);
805    }
806
807    #[test]
808    fn test_virtual_list_max_scroll() {
809        let config = VirtualListConfig {
810            estimated_item_height: 50.0,
811            ..Default::default()
812        };
813        let mut list = VirtualList::new(config);
814        list.set_item_count(20);
815        list.set_viewport_height(400.0);
816
817        // 20 items * 50 height = 1000, minus viewport 400 = 600 max scroll
818        assert_eq!(list.max_scroll(), 600.0);
819    }
820
821    #[test]
822    fn test_virtual_list_visible_range() {
823        let config = VirtualListConfig {
824            estimated_item_height: 50.0,
825            overscan_count: 2,
826            ..Default::default()
827        };
828        let mut list = VirtualList::new(config);
829        list.set_item_count(100);
830        list.set_viewport_height(200.0);
831
832        let range = list.visible_range().unwrap();
833        // 200 / 50 = 4 visible items (0-3), plus item 4 starts at position 200
834        // so items 0-4 are at least partially visible
835        assert_eq!(range.start, 0);
836        assert_eq!(range.end, 5);
837        assert_eq!(range.render_start, 0);
838        assert_eq!(range.render_end, 7); // 5 + 2 overscan
839    }
840
841    #[test]
842    fn test_virtual_list_visible_range_scrolled() {
843        let config = VirtualListConfig {
844            estimated_item_height: 50.0,
845            overscan_count: 2,
846            ..Default::default()
847        };
848        let mut list = VirtualList::new(config);
849        list.set_item_count(100);
850        list.set_viewport_height(200.0);
851        list.set_scroll_position(250.0);
852
853        let range = list.visible_range().unwrap();
854        // 250 / 50 = 5, (250 + 200) / 50 = 9, + 1 = 10
855        assert_eq!(range.start, 5);
856        assert_eq!(range.end, 10);
857        assert_eq!(range.render_start, 3); // 5 - 2
858        assert_eq!(range.render_end, 12); // 10 + 2
859    }
860
861    #[test]
862    fn test_virtual_list_scroll_to_item_start() {
863        let config = VirtualListConfig {
864            estimated_item_height: 50.0,
865            ..Default::default()
866        };
867        let mut list = VirtualList::new(config);
868        list.set_item_count(100);
869        list.set_viewport_height(200.0);
870
871        list.scroll_to_item(10, ScrollAlign::Start);
872        assert_eq!(list.scroll_position(), 500.0);
873    }
874
875    #[test]
876    fn test_virtual_list_scroll_to_item_center() {
877        let config = VirtualListConfig {
878            estimated_item_height: 50.0,
879            ..Default::default()
880        };
881        let mut list = VirtualList::new(config);
882        list.set_item_count(100);
883        list.set_viewport_height(200.0);
884
885        list.scroll_to_item(10, ScrollAlign::Center);
886        // Item 10 at y=500, viewport=200, item height=50
887        // center = 500 - (200 - 50) / 2 = 500 - 75 = 425
888        assert_eq!(list.scroll_position(), 425.0);
889    }
890
891    #[test]
892    fn test_virtual_list_scroll_to_item_end() {
893        let config = VirtualListConfig {
894            estimated_item_height: 50.0,
895            ..Default::default()
896        };
897        let mut list = VirtualList::new(config);
898        list.set_item_count(100);
899        list.set_viewport_height(200.0);
900
901        list.scroll_to_item(10, ScrollAlign::End);
902        // Item 10 at y=500, viewport=200, item height=50
903        // end = 500 - 200 + 50 = 350
904        assert_eq!(list.scroll_position(), 350.0);
905    }
906
907    #[test]
908    fn test_virtual_list_scroll_to_item_auto() {
909        let config = VirtualListConfig {
910            estimated_item_height: 50.0,
911            ..Default::default()
912        };
913        let mut list = VirtualList::new(config);
914        list.set_item_count(100);
915        list.set_viewport_height(200.0);
916
917        // Item 2 (at y=100) is already visible - shouldn't scroll
918        list.scroll_to_item(2, ScrollAlign::Auto);
919        assert_eq!(list.scroll_position(), 0.0);
920
921        // Item 10 (at y=500) is not visible - should scroll
922        list.scroll_to_item(10, ScrollAlign::Auto);
923        assert!(list.scroll_position() > 0.0);
924    }
925
926    #[test]
927    fn test_virtual_list_variable_heights() {
928        let config = VirtualListConfig {
929            estimated_item_height: 50.0,
930            variable_heights: true,
931            ..Default::default()
932        };
933        let mut list = VirtualList::new(config);
934        list.set_item_count(10);
935
936        list.set_item_height(2, 100.0);
937        assert_eq!(list.get_item_height(2), 100.0);
938        assert_eq!(list.get_item_height(3), 50.0); // Default
939    }
940
941    #[test]
942    fn test_virtual_list_item_layout() {
943        let config = VirtualListConfig {
944            estimated_item_height: 50.0,
945            ..Default::default()
946        };
947        let mut list = VirtualList::new(config);
948        list.set_item_count(10);
949
950        let layout = list.get_item_layout(5);
951        assert_eq!(layout.y, 250.0);
952        assert_eq!(layout.height, 50.0);
953    }
954
955    #[test]
956    fn test_virtual_list_is_near_end() {
957        let config = VirtualListConfig {
958            estimated_item_height: 50.0,
959            load_more_threshold: 100.0,
960            ..Default::default()
961        };
962        let mut list = VirtualList::new(config);
963        list.set_item_count(20); // 1000 total height
964        list.set_viewport_height(300.0);
965
966        assert!(!list.is_near_end());
967
968        list.set_scroll_position(600.0); // Near end
969        assert!(list.is_near_end());
970    }
971
972    #[test]
973    fn test_virtual_list_is_near_start() {
974        let config = VirtualListConfig {
975            load_more_threshold: 100.0,
976            ..Default::default()
977        };
978        let mut list = VirtualList::new(config);
979        list.set_item_count(100);
980        list.set_viewport_height(300.0);
981
982        assert!(list.is_near_start());
983
984        list.set_scroll_position(200.0);
985        assert!(!list.is_near_start());
986    }
987
988    #[test]
989    fn test_virtual_list_reset() {
990        let mut list = VirtualList::default();
991        list.set_item_count(100);
992        list.set_viewport_height(300.0);
993        list.set_scroll_position(500.0);
994
995        list.reset();
996        assert_eq!(list.scroll_position(), 0.0);
997    }
998
999    #[test]
1000    fn test_visible_range_methods() {
1001        let range = VisibleRange {
1002            start: 5,
1003            end: 10,
1004            render_start: 3,
1005            render_end: 12,
1006            offset: 150.0,
1007        };
1008
1009        assert_eq!(range.visible_range(), 5..10);
1010        assert_eq!(range.render_range(), 3..12);
1011        assert_eq!(range.visible_count(), 5);
1012        assert_eq!(range.render_count(), 9);
1013        assert!(range.is_visible(7));
1014        assert!(!range.is_visible(2));
1015        assert!(range.should_render(5));
1016        assert!(!range.should_render(15));
1017    }
1018
1019    #[test]
1020    fn test_item_layout() {
1021        let layout = ItemLayout::new(100.0, 50.0);
1022        assert_eq!(layout.y, 100.0);
1023        assert_eq!(layout.height, 50.0);
1024        assert_eq!(layout.bottom(), 150.0);
1025    }
1026
1027    #[test]
1028    fn test_item_index() {
1029        let index = ItemIndex(42);
1030        assert_eq!(index.as_usize(), 42);
1031
1032        let from_usize: ItemIndex = 100.into();
1033        assert_eq!(from_usize.0, 100);
1034    }
1035
1036    // ========== Virtual Grid Tests ==========
1037
1038    #[test]
1039    fn test_virtual_grid_default() {
1040        let grid = VirtualGrid::default();
1041        assert_eq!(grid.item_count(), 0);
1042        assert_eq!(grid.scroll_position(), 0.0);
1043    }
1044
1045    #[test]
1046    fn test_virtual_grid_set_item_count() {
1047        let mut grid = VirtualGrid::default();
1048        grid.set_item_count(100);
1049        assert_eq!(grid.item_count(), 100);
1050    }
1051
1052    #[test]
1053    fn test_virtual_grid_row_count() {
1054        let config = VirtualGridConfig {
1055            columns: 3,
1056            ..Default::default()
1057        };
1058        let mut grid = VirtualGrid::new(config);
1059        grid.set_item_count(10);
1060        assert_eq!(grid.row_count(), 4); // ceil(10/3) = 4
1061    }
1062
1063    #[test]
1064    fn test_virtual_grid_viewport() {
1065        let mut grid = VirtualGrid::default();
1066        grid.set_viewport_height(500.0);
1067        assert_eq!(grid.viewport_height(), 500.0);
1068    }
1069
1070    #[test]
1071    fn test_virtual_grid_scroll_position() {
1072        let mut grid = VirtualGrid::default();
1073        grid.set_item_count(100);
1074        grid.set_viewport_height(500.0);
1075
1076        grid.set_scroll_position(200.0);
1077        assert_eq!(grid.scroll_position(), 200.0);
1078    }
1079
1080    #[test]
1081    fn test_virtual_grid_content_height() {
1082        let config = VirtualGridConfig {
1083            columns: 3,
1084            cell_height: 100.0,
1085            gap: 10.0,
1086            ..Default::default()
1087        };
1088        let mut grid = VirtualGrid::new(config);
1089        grid.set_item_count(9); // 3 rows
1090
1091        // 3 rows * 100 height + 2 gaps * 10 = 320
1092        assert_eq!(grid.content_height(), 320.0);
1093    }
1094
1095    #[test]
1096    fn test_virtual_grid_cell_layout() {
1097        let config = VirtualGridConfig {
1098            columns: 3,
1099            cell_width: 100.0,
1100            cell_height: 80.0,
1101            gap: 10.0,
1102            ..Default::default()
1103        };
1104        let grid = VirtualGrid::new(config);
1105
1106        // Item 0 at (0, 0)
1107        let layout = grid.get_cell_layout(0);
1108        assert_eq!(layout.x, 0.0);
1109        assert_eq!(layout.y, 0.0);
1110
1111        // Item 1 at (110, 0)
1112        let layout = grid.get_cell_layout(1);
1113        assert_eq!(layout.x, 110.0);
1114        assert_eq!(layout.y, 0.0);
1115
1116        // Item 3 at (0, 90) - second row
1117        let layout = grid.get_cell_layout(3);
1118        assert_eq!(layout.x, 0.0);
1119        assert_eq!(layout.y, 90.0);
1120    }
1121
1122    #[test]
1123    fn test_virtual_grid_cell_conversion() {
1124        let config = VirtualGridConfig {
1125            columns: 4,
1126            ..Default::default()
1127        };
1128        let grid = VirtualGrid::new(config);
1129
1130        let cell = grid.index_to_cell(10);
1131        assert_eq!(cell.row, 2);
1132        assert_eq!(cell.col, 2);
1133
1134        assert_eq!(grid.cell_to_index(&cell), 10);
1135    }
1136
1137    #[test]
1138    fn test_virtual_grid_visible_range() {
1139        let config = VirtualGridConfig {
1140            columns: 3,
1141            cell_height: 100.0,
1142            gap: 10.0,
1143            overscan_rows: 1,
1144            ..Default::default()
1145        };
1146        let mut grid = VirtualGrid::new(config);
1147        grid.set_item_count(30); // 10 rows
1148        grid.set_viewport_height(250.0);
1149
1150        let range = grid.visible_range().unwrap();
1151        // Row height = 110, viewport = 250
1152        // Visible rows = ceil(250/110) + 1 = 4
1153        assert_eq!(range.start_row, 0);
1154        assert!(range.end_row >= 2);
1155    }
1156
1157    #[test]
1158    fn test_virtual_grid_scroll_to_item() {
1159        let config = VirtualGridConfig {
1160            columns: 3,
1161            cell_height: 100.0,
1162            gap: 10.0,
1163            ..Default::default()
1164        };
1165        let mut grid = VirtualGrid::new(config);
1166        grid.set_item_count(30);
1167        grid.set_viewport_height(250.0);
1168
1169        grid.scroll_to_item(15, ScrollAlign::Start); // Row 5
1170        assert_eq!(grid.scroll_position(), 550.0); // 5 * 110
1171    }
1172
1173    #[test]
1174    fn test_virtual_grid_reset() {
1175        let mut grid = VirtualGrid::default();
1176        grid.set_item_count(100);
1177        grid.set_viewport_height(300.0);
1178        grid.set_scroll_position(500.0);
1179
1180        grid.reset();
1181        assert_eq!(grid.scroll_position(), 0.0);
1182    }
1183
1184    #[test]
1185    fn test_grid_cell() {
1186        let cell = GridCell::new(5, 2);
1187        assert_eq!(cell.row, 5);
1188        assert_eq!(cell.col, 2);
1189    }
1190
1191    #[test]
1192    fn test_visible_grid_range_cells() {
1193        let range = VisibleGridRange {
1194            start_row: 2,
1195            end_row: 5,
1196            render_start_row: 1,
1197            render_end_row: 6,
1198            columns: 3,
1199            offset: 100.0,
1200        };
1201
1202        // 5 render rows * 3 columns = 15 cells max
1203        let cells = range.cells_to_render(100);
1204        assert_eq!(cells.len(), 15);
1205
1206        // With 10 items total (rows 0-3, plus 1 item in row 3):
1207        // Render rows 1-5, so items from row 1 onwards = items 3,4,5,6,7,8,9 = 7 items
1208        let cells = range.cells_to_render(10);
1209        assert_eq!(cells.len(), 7);
1210    }
1211
1212    #[test]
1213    fn test_visible_grid_range_should_render() {
1214        let range = VisibleGridRange {
1215            start_row: 2,
1216            end_row: 5,
1217            render_start_row: 1,
1218            render_end_row: 6,
1219            columns: 3,
1220            offset: 100.0,
1221        };
1222
1223        assert!(range.should_render_cell(3, 1));
1224        assert!(!range.should_render_cell(0, 0));
1225        assert!(!range.should_render_cell(3, 5)); // col out of range
1226    }
1227
1228    #[test]
1229    fn test_cell_layout() {
1230        let layout = CellLayout::new(100.0, 200.0, 50.0, 60.0);
1231        assert_eq!(layout.x, 100.0);
1232        assert_eq!(layout.y, 200.0);
1233        assert_eq!(layout.width, 50.0);
1234        assert_eq!(layout.height, 60.0);
1235    }
1236
1237    #[test]
1238    fn test_scroll_align_variants() {
1239        // Just make sure all variants exist and are comparable
1240        assert_ne!(ScrollAlign::Start, ScrollAlign::End);
1241        assert_ne!(ScrollAlign::Center, ScrollAlign::Auto);
1242    }
1243
1244    #[test]
1245    fn test_virtual_list_empty() {
1246        let mut list = VirtualList::default();
1247        list.set_viewport_height(300.0);
1248        // Empty list should have no visible range
1249        assert!(list.visible_range().is_none());
1250    }
1251
1252    #[test]
1253    fn test_virtual_grid_empty() {
1254        let mut grid = VirtualGrid::default();
1255        grid.set_viewport_height(300.0);
1256        // Empty grid should have no visible range
1257        assert!(grid.visible_range().is_none());
1258    }
1259
1260    #[test]
1261    fn test_virtual_list_config_default() {
1262        let config = VirtualListConfig::default();
1263        assert_eq!(config.estimated_item_height, 50.0);
1264        assert_eq!(config.overscan_count, 3);
1265        assert!(!config.variable_heights);
1266        assert_eq!(config.initial_scroll, 0.0);
1267        assert_eq!(config.load_more_threshold, 100.0);
1268    }
1269
1270    #[test]
1271    fn test_virtual_grid_config_default() {
1272        let config = VirtualGridConfig::default();
1273        assert_eq!(config.columns, 3);
1274        assert_eq!(config.cell_width, 100.0);
1275        assert_eq!(config.cell_height, 100.0);
1276        assert_eq!(config.gap, 8.0);
1277        assert_eq!(config.overscan_rows, 2);
1278    }
1279}