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