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