Skip to main content

astrelis_ui/
virtual_scroll.rs

1//! Virtual scrolling for efficient rendering of large lists.
2//!
3//! This module provides virtualization support for lists with thousands of items,
4//! only rendering visible items plus a configurable overscan buffer.
5
6use std::ops::Range;
7
8use astrelis_core::alloc::HashMap;
9
10use crate::tree::{NodeId, UiTree};
11
12/// Builder function type for creating UI nodes from virtual scroll items.
13type ItemBuilder<T> = Box<dyn Fn(usize, &T, &mut UiTree) -> NodeId>;
14
15/// Configuration for virtual scrolling behavior.
16#[derive(Debug, Clone)]
17pub struct VirtualScrollConfig {
18    /// Number of items to render above/below visible area.
19    pub overscan: usize,
20    /// Minimum scroll delta before updating visible range.
21    pub scroll_threshold: f32,
22    /// Whether to enable smooth scrolling animations.
23    pub smooth_scrolling: bool,
24    /// Animation duration for smooth scrolling in seconds.
25    pub scroll_animation_duration: f32,
26}
27
28impl Default for VirtualScrollConfig {
29    fn default() -> Self {
30        Self {
31            overscan: 3,
32            scroll_threshold: 1.0,
33            smooth_scrolling: true,
34            scroll_animation_duration: 0.15,
35        }
36    }
37}
38
39/// Specifies how item heights are determined.
40#[derive(Debug, Clone)]
41pub enum ItemHeight {
42    /// All items have the same fixed height.
43    Fixed(f32),
44    /// Items have variable heights with an estimated default.
45    Variable {
46        /// Estimated height for unmeasured items.
47        estimated: f32,
48        /// Measured heights for items that have been rendered.
49        measured: HashMap<usize, f32>,
50    },
51}
52
53impl ItemHeight {
54    /// Creates a fixed item height.
55    pub fn fixed(height: f32) -> Self {
56        Self::Fixed(height)
57    }
58
59    /// Creates a variable item height with an estimated default.
60    pub fn variable(estimated: f32) -> Self {
61        Self::Variable {
62            estimated,
63            measured: HashMap::default(),
64        }
65    }
66
67    /// Gets the height for a specific item index.
68    pub fn get(&self, index: usize) -> f32 {
69        match self {
70            Self::Fixed(h) => *h,
71            Self::Variable {
72                estimated,
73                measured,
74            } => measured.get(&index).copied().unwrap_or(*estimated),
75        }
76    }
77
78    /// Sets the measured height for a specific item.
79    pub fn set_measured(&mut self, index: usize, height: f32) {
80        if let Self::Variable { measured, .. } = self {
81            measured.insert(index, height);
82        }
83    }
84
85    /// Returns true if this is a fixed height.
86    pub fn is_fixed(&self) -> bool {
87        matches!(self, Self::Fixed(_))
88    }
89}
90
91/// Tracks the state of a mounted (rendered) item.
92#[derive(Debug, Clone)]
93pub struct MountedItem {
94    /// The node ID of the rendered widget.
95    pub node_id: NodeId,
96    /// The computed Y offset of this item.
97    pub y_offset: f32,
98    /// The measured height of this item.
99    pub height: f32,
100}
101
102/// Statistics about virtual scroll performance.
103#[derive(Debug, Clone, Default)]
104pub struct VirtualScrollStats {
105    /// Total number of items in the list.
106    pub total_items: usize,
107    /// Number of currently mounted (rendered) items.
108    pub mounted_count: usize,
109    /// Current visible range.
110    pub visible_range: Range<usize>,
111    /// Total scroll height.
112    pub total_height: f32,
113    /// Current scroll offset.
114    pub scroll_offset: f32,
115    /// Number of items recycled this frame.
116    pub recycled_count: usize,
117    /// Number of items created this frame.
118    pub created_count: usize,
119}
120
121/// State for virtual scrolling of a list.
122#[derive(Debug)]
123pub struct VirtualScrollState {
124    /// Configuration.
125    config: VirtualScrollConfig,
126    /// Total number of items.
127    total_items: usize,
128    /// Item height specification.
129    item_height: ItemHeight,
130    /// Current scroll offset (pixels from top).
131    scroll_offset: f32,
132    /// Target scroll offset for smooth scrolling.
133    target_scroll_offset: f32,
134    /// Viewport height.
135    viewport_height: f32,
136    /// Currently visible range (including overscan).
137    visible_range: Range<usize>,
138    /// Mounted items by index.
139    mounted: HashMap<usize, MountedItem>,
140    /// Container node ID for the scroll content.
141    container_node: Option<NodeId>,
142    /// Cached total height.
143    cached_total_height: Option<f32>,
144    /// Statistics.
145    stats: VirtualScrollStats,
146}
147
148impl VirtualScrollState {
149    /// Creates a new virtual scroll state.
150    pub fn new(total_items: usize, item_height: ItemHeight) -> Self {
151        Self {
152            config: VirtualScrollConfig::default(),
153            total_items,
154            item_height,
155            scroll_offset: 0.0,
156            target_scroll_offset: 0.0,
157            viewport_height: 0.0,
158            visible_range: 0..0,
159            mounted: HashMap::default(),
160            container_node: None,
161            cached_total_height: None,
162            stats: VirtualScrollStats::default(),
163        }
164    }
165
166    /// Creates a new virtual scroll state with configuration.
167    pub fn with_config(
168        total_items: usize,
169        item_height: ItemHeight,
170        config: VirtualScrollConfig,
171    ) -> Self {
172        Self {
173            config,
174            total_items,
175            item_height,
176            scroll_offset: 0.0,
177            target_scroll_offset: 0.0,
178            viewport_height: 0.0,
179            visible_range: 0..0,
180            mounted: HashMap::default(),
181            container_node: None,
182            cached_total_height: None,
183            stats: VirtualScrollStats::default(),
184        }
185    }
186
187    /// Sets the container node for the scroll content.
188    pub fn set_container(&mut self, node: NodeId) {
189        self.container_node = Some(node);
190    }
191
192    /// Gets the container node.
193    pub fn container(&self) -> Option<NodeId> {
194        self.container_node
195    }
196
197    /// Updates the total number of items.
198    pub fn set_total_items(&mut self, count: usize) {
199        if self.total_items != count {
200            self.total_items = count;
201            self.cached_total_height = None;
202            // Clamp scroll offset if needed
203            let max_offset = self.max_scroll_offset();
204            if self.scroll_offset > max_offset {
205                self.scroll_offset = max_offset;
206                self.target_scroll_offset = max_offset;
207            }
208        }
209    }
210
211    /// Gets the total number of items.
212    pub fn total_items(&self) -> usize {
213        self.total_items
214    }
215
216    /// Updates the viewport height.
217    pub fn set_viewport_height(&mut self, height: f32) {
218        if (self.viewport_height - height).abs() > 0.1 {
219            self.viewport_height = height;
220        }
221    }
222
223    /// Gets the viewport height.
224    pub fn viewport_height(&self) -> f32 {
225        self.viewport_height
226    }
227
228    /// Calculates the total scroll height.
229    pub fn total_height(&self) -> f32 {
230        if let Some(cached) = self.cached_total_height {
231            return cached;
232        }
233
234        match &self.item_height {
235            ItemHeight::Fixed(h) => *h * self.total_items as f32,
236            ItemHeight::Variable {
237                estimated,
238                measured,
239            } => {
240                let mut height = 0.0;
241                for i in 0..self.total_items {
242                    height += measured.get(&i).copied().unwrap_or(*estimated);
243                }
244                height
245            }
246        }
247    }
248
249    /// Gets the maximum scroll offset.
250    pub fn max_scroll_offset(&self) -> f32 {
251        (self.total_height() - self.viewport_height).max(0.0)
252    }
253
254    /// Gets the current scroll offset.
255    pub fn scroll_offset(&self) -> f32 {
256        self.scroll_offset
257    }
258
259    /// Sets the scroll offset directly.
260    pub fn set_scroll_offset(&mut self, offset: f32) {
261        let clamped = offset.clamp(0.0, self.max_scroll_offset());
262        self.scroll_offset = clamped;
263        self.target_scroll_offset = clamped;
264    }
265
266    /// Scrolls by a delta amount.
267    pub fn scroll_by(&mut self, delta: f32) {
268        if self.config.smooth_scrolling {
269            self.target_scroll_offset =
270                (self.target_scroll_offset + delta).clamp(0.0, self.max_scroll_offset());
271        } else {
272            self.set_scroll_offset(self.scroll_offset + delta);
273        }
274    }
275
276    /// Scrolls to show a specific item.
277    pub fn scroll_to_item(&mut self, index: usize) {
278        if index >= self.total_items {
279            return;
280        }
281
282        let item_offset = self.get_item_offset(index);
283        let item_height = self.item_height.get(index);
284
285        // Check if item is already fully visible
286        if item_offset >= self.scroll_offset
287            && item_offset + item_height <= self.scroll_offset + self.viewport_height
288        {
289            return;
290        }
291
292        // Scroll to show the item
293        let target = if item_offset < self.scroll_offset {
294            // Item is above viewport
295            item_offset
296        } else {
297            // Item is below viewport
298            (item_offset + item_height - self.viewport_height).max(0.0)
299        };
300
301        if self.config.smooth_scrolling {
302            self.target_scroll_offset = target;
303        } else {
304            self.set_scroll_offset(target);
305        }
306    }
307
308    /// Scrolls to center a specific item in the viewport.
309    pub fn scroll_to_item_centered(&mut self, index: usize) {
310        if index >= self.total_items {
311            return;
312        }
313
314        let item_offset = self.get_item_offset(index);
315        let item_height = self.item_height.get(index);
316        let target = (item_offset + item_height / 2.0 - self.viewport_height / 2.0).max(0.0);
317
318        if self.config.smooth_scrolling {
319            self.target_scroll_offset = target.min(self.max_scroll_offset());
320        } else {
321            self.set_scroll_offset(target);
322        }
323    }
324
325    /// Gets the Y offset for an item at the given index.
326    pub fn get_item_offset(&self, index: usize) -> f32 {
327        match &self.item_height {
328            ItemHeight::Fixed(h) => *h * index as f32,
329            ItemHeight::Variable {
330                estimated,
331                measured,
332            } => {
333                let mut offset = 0.0;
334                for i in 0..index {
335                    offset += measured.get(&i).copied().unwrap_or(*estimated);
336                }
337                offset
338            }
339        }
340    }
341
342    /// Gets the item index at a given Y position.
343    pub fn get_item_at_position(&self, y: f32) -> Option<usize> {
344        if y < 0.0 || self.total_items == 0 {
345            return None;
346        }
347
348        match &self.item_height {
349            ItemHeight::Fixed(h) => {
350                let index = (y / h) as usize;
351                if index < self.total_items {
352                    Some(index)
353                } else {
354                    None
355                }
356            }
357            ItemHeight::Variable {
358                estimated,
359                measured,
360            } => {
361                let mut offset = 0.0;
362                for i in 0..self.total_items {
363                    let height = measured.get(&i).copied().unwrap_or(*estimated);
364                    if y >= offset && y < offset + height {
365                        return Some(i);
366                    }
367                    offset += height;
368                }
369                None
370            }
371        }
372    }
373
374    /// Updates smooth scrolling animation.
375    /// Returns true if the scroll position changed.
376    pub fn update_animation(&mut self, dt: f32) -> bool {
377        if !self.config.smooth_scrolling {
378            return false;
379        }
380
381        let diff = self.target_scroll_offset - self.scroll_offset;
382        if diff.abs() < 0.5 {
383            if diff.abs() > 0.0 {
384                self.scroll_offset = self.target_scroll_offset;
385                return true;
386            }
387            return false;
388        }
389
390        // Exponential easing
391        let t = (dt / self.config.scroll_animation_duration).min(1.0);
392        let eased = 1.0 - (1.0 - t).powi(3); // Ease-out cubic
393        self.scroll_offset += diff * eased;
394        true
395    }
396
397    /// Calculates the visible range based on current scroll position.
398    pub fn calculate_visible_range(&self) -> Range<usize> {
399        if self.total_items == 0 || self.viewport_height <= 0.0 {
400            return 0..0;
401        }
402
403        let start_index = self
404            .get_item_at_position(self.scroll_offset)
405            .unwrap_or(0)
406            .saturating_sub(self.config.overscan);
407
408        let end_y = self.scroll_offset + self.viewport_height;
409        let end_index = self
410            .get_item_at_position(end_y)
411            .map(|i| i + 1)
412            .unwrap_or(self.total_items)
413            .saturating_add(self.config.overscan)
414            .min(self.total_items);
415
416        start_index..end_index
417    }
418
419    /// Updates the visible range and returns items that need to be mounted/unmounted.
420    /// Returns (items_to_mount, items_to_unmount).
421    pub fn update_visible(&mut self) -> (Vec<usize>, Vec<usize>) {
422        let new_range = self.calculate_visible_range();
423
424        if new_range == self.visible_range {
425            return (vec![], vec![]);
426        }
427
428        let old_range = self.visible_range.clone();
429        self.visible_range = new_range.clone();
430
431        // Find items to unmount (in old range but not in new)
432        let to_unmount: Vec<usize> = old_range
433            .filter(|i| !new_range.contains(i))
434            .filter(|i| self.mounted.contains_key(i))
435            .collect();
436
437        // Find items to mount (in new range but not mounted)
438        let to_mount: Vec<usize> = new_range
439            .filter(|i| !self.mounted.contains_key(i))
440            .collect();
441
442        (to_mount, to_unmount)
443    }
444
445    /// Records a mounted item.
446    pub fn mount_item(&mut self, index: usize, node_id: NodeId, height: f32) {
447        let y_offset = self.get_item_offset(index);
448        self.mounted.insert(
449            index,
450            MountedItem {
451                node_id,
452                y_offset,
453                height,
454            },
455        );
456
457        // Update measured height for variable height items
458        self.item_height.set_measured(index, height);
459        self.cached_total_height = None;
460    }
461
462    /// Unmounts an item and returns its node ID.
463    pub fn unmount_item(&mut self, index: usize) -> Option<NodeId> {
464        self.mounted.remove(&index).map(|item| item.node_id)
465    }
466
467    /// Gets the mounted item for an index.
468    pub fn get_mounted(&self, index: usize) -> Option<&MountedItem> {
469        self.mounted.get(&index)
470    }
471
472    /// Gets all mounted items.
473    pub fn mounted_items(&self) -> impl Iterator<Item = (usize, &MountedItem)> {
474        self.mounted.iter().map(|(k, v)| (*k, v))
475    }
476
477    /// Gets the current visible range.
478    pub fn visible_range(&self) -> Range<usize> {
479        self.visible_range.clone()
480    }
481
482    /// Checks if an index is in the visible range.
483    pub fn is_visible(&self, index: usize) -> bool {
484        self.visible_range.contains(&index)
485    }
486
487    /// Gets statistics about the virtual scroll.
488    pub fn stats(&self) -> &VirtualScrollStats {
489        &self.stats
490    }
491
492    /// Updates statistics.
493    pub fn update_stats(&mut self) {
494        self.stats = VirtualScrollStats {
495            total_items: self.total_items,
496            mounted_count: self.mounted.len(),
497            visible_range: self.visible_range.clone(),
498            total_height: self.total_height(),
499            scroll_offset: self.scroll_offset,
500            recycled_count: 0,
501            created_count: 0,
502        };
503    }
504
505    /// Gets the configuration.
506    pub fn config(&self) -> &VirtualScrollConfig {
507        &self.config
508    }
509
510    /// Gets mutable configuration.
511    pub fn config_mut(&mut self) -> &mut VirtualScrollConfig {
512        &mut self.config
513    }
514}
515
516/// A virtual scroll view that manages item rendering.
517pub struct VirtualScrollView<T> {
518    /// The items being virtualized.
519    items: Vec<T>,
520    /// Scroll state.
521    state: VirtualScrollState,
522    /// Item builder function.
523    builder: ItemBuilder<T>,
524}
525
526impl<T> VirtualScrollView<T> {
527    /// Creates a new virtual scroll view with fixed height items.
528    pub fn new<F>(items: Vec<T>, item_height: f32, builder: F) -> Self
529    where
530        F: Fn(usize, &T, &mut UiTree) -> NodeId + 'static,
531    {
532        Self {
533            state: VirtualScrollState::new(items.len(), ItemHeight::fixed(item_height)),
534            items,
535            builder: Box::new(builder),
536        }
537    }
538
539    /// Creates a new virtual scroll view with variable height items.
540    pub fn with_variable_height<F>(items: Vec<T>, estimated_height: f32, builder: F) -> Self
541    where
542        F: Fn(usize, &T, &mut UiTree) -> NodeId + 'static,
543    {
544        Self {
545            state: VirtualScrollState::new(items.len(), ItemHeight::variable(estimated_height)),
546            items,
547            builder: Box::new(builder),
548        }
549    }
550
551    /// Sets the configuration.
552    pub fn with_config(mut self, config: VirtualScrollConfig) -> Self {
553        self.state.config = config;
554        self
555    }
556
557    /// Gets the scroll state.
558    pub fn state(&self) -> &VirtualScrollState {
559        &self.state
560    }
561
562    /// Gets mutable scroll state.
563    pub fn state_mut(&mut self) -> &mut VirtualScrollState {
564        &mut self.state
565    }
566
567    /// Gets the items.
568    pub fn items(&self) -> &[T] {
569        &self.items
570    }
571
572    /// Updates the items list.
573    pub fn set_items(&mut self, items: Vec<T>) {
574        self.items = items;
575        self.state.set_total_items(self.items.len());
576    }
577
578    /// Sets the viewport height.
579    pub fn set_viewport_height(&mut self, height: f32) {
580        self.state.set_viewport_height(height);
581    }
582
583    /// Scrolls by a delta amount.
584    pub fn scroll_by(&mut self, delta: f32) {
585        self.state.scroll_by(delta);
586    }
587
588    /// Scrolls to show a specific item.
589    pub fn scroll_to_item(&mut self, index: usize) {
590        self.state.scroll_to_item(index);
591    }
592
593    /// Updates the scroll animation and visible items.
594    /// Returns the nodes that were added or removed.
595    pub fn update(&mut self, tree: &mut UiTree, dt: f32) -> VirtualScrollUpdate {
596        let mut update = VirtualScrollUpdate::default();
597
598        // Update animation
599        if self.state.update_animation(dt) {
600            update.scroll_changed = true;
601        }
602
603        // Calculate visible range changes
604        let (to_mount, to_unmount) = self.state.update_visible();
605
606        // Unmount items that are no longer visible
607        for index in to_unmount {
608            if let Some(node_id) = self.state.unmount_item(index) {
609                // Remove the node from the tree to free memory
610                tree.remove_node(node_id);
611                update.removed.push((index, node_id));
612            }
613        }
614
615        // Mount newly visible items
616        for index in to_mount {
617            if let Some(item) = self.items.get(index) {
618                let node_id = (self.builder)(index, item, tree);
619
620                // Add to container
621                if let Some(container) = self.state.container() {
622                    tree.add_child(container, node_id);
623                }
624
625                // Get the measured height from layout
626                let height = tree
627                    .get_layout(node_id)
628                    .map(|l| l.height)
629                    .unwrap_or(self.state.item_height.get(index));
630
631                self.state.mount_item(index, node_id, height);
632                update.added.push((index, node_id));
633            }
634        }
635
636        // Update item positions based on scroll offset
637        self.update_item_positions(tree);
638
639        self.state.update_stats();
640        update
641    }
642
643    /// Updates the Y positions of all mounted items.
644    fn update_item_positions(&self, tree: &mut UiTree) {
645        let scroll_offset = self.state.scroll_offset();
646
647        for (_index, item) in self.state.mounted_items() {
648            // Calculate the visual Y position by subtracting scroll offset
649            let visual_y = item.y_offset - scroll_offset;
650
651            // Apply position offset for scrolling
652            tree.set_position_offset(item.node_id, 0.0, visual_y);
653        }
654    }
655}
656
657/// Information about what changed during a virtual scroll update.
658#[derive(Debug, Default)]
659pub struct VirtualScrollUpdate {
660    /// Whether the scroll position changed.
661    pub scroll_changed: bool,
662    /// Items that were added (index, node_id).
663    pub added: Vec<(usize, NodeId)>,
664    /// Items that were removed (index, node_id).
665    pub removed: Vec<(usize, NodeId)>,
666}
667
668impl VirtualScrollUpdate {
669    /// Returns true if any changes occurred.
670    pub fn has_changes(&self) -> bool {
671        self.scroll_changed || !self.added.is_empty() || !self.removed.is_empty()
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn test_fixed_height_offset() {
681        let state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
682        assert_eq!(state.get_item_offset(0), 0.0);
683        assert_eq!(state.get_item_offset(1), 50.0);
684        assert_eq!(state.get_item_offset(10), 500.0);
685    }
686
687    #[test]
688    fn test_total_height_fixed() {
689        let state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
690        assert_eq!(state.total_height(), 5000.0);
691    }
692
693    #[test]
694    fn test_variable_height() {
695        let mut item_height = ItemHeight::variable(50.0);
696        item_height.set_measured(0, 30.0);
697        item_height.set_measured(1, 70.0);
698
699        assert_eq!(item_height.get(0), 30.0);
700        assert_eq!(item_height.get(1), 70.0);
701        assert_eq!(item_height.get(2), 50.0); // Uses estimated
702    }
703
704    #[test]
705    fn test_get_item_at_position() {
706        let state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
707        assert_eq!(state.get_item_at_position(0.0), Some(0));
708        assert_eq!(state.get_item_at_position(49.0), Some(0));
709        assert_eq!(state.get_item_at_position(50.0), Some(1));
710        assert_eq!(state.get_item_at_position(125.0), Some(2));
711        assert_eq!(state.get_item_at_position(5000.0), None);
712    }
713
714    #[test]
715    fn test_visible_range_calculation() {
716        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
717        state.set_viewport_height(200.0);
718        state.config_mut().overscan = 2;
719
720        // At scroll offset 0, visible items are 0-3 (4 items fit in 200px)
721        // With overscan of 2, range should include items 0 through 5 (end exclusive = 6)
722        let range = state.calculate_visible_range();
723        assert_eq!(range.start, 0);
724        // The visible range calculation: start_index=0-2=0, end_index=(3+1)+2=6
725        assert!(
726            range.end >= 4,
727            "end should be at least 4, got {}",
728            range.end
729        );
730    }
731
732    #[test]
733    fn test_scroll_clamping() {
734        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
735        state.set_viewport_height(200.0);
736
737        // Max offset = 5000 - 200 = 4800
738        state.set_scroll_offset(10000.0);
739        assert_eq!(state.scroll_offset(), 4800.0);
740
741        state.set_scroll_offset(-100.0);
742        assert_eq!(state.scroll_offset(), 0.0);
743    }
744
745    #[test]
746    fn test_scroll_to_item() {
747        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
748        state.config_mut().smooth_scrolling = false;
749        state.set_viewport_height(200.0);
750
751        // Scroll to item 20 (at y=1000, height 50)
752        // The implementation scrolls to make item visible at bottom of viewport
753        // So offset = item_offset + item_height - viewport_height = 1000 + 50 - 200 = 850
754        state.scroll_to_item(20);
755        assert_eq!(state.scroll_offset(), 850.0);
756    }
757
758    #[test]
759    fn test_empty_list() {
760        let state = VirtualScrollState::new(0, ItemHeight::fixed(50.0));
761        assert_eq!(state.total_height(), 0.0);
762        assert_eq!(state.max_scroll_offset(), 0.0);
763        assert_eq!(state.calculate_visible_range(), 0..0);
764    }
765
766    #[test]
767    fn test_config_default() {
768        let config = VirtualScrollConfig::default();
769        assert!(config.overscan > 0);
770        assert!(config.smooth_scrolling);
771    }
772
773    #[test]
774    fn test_item_height_fixed() {
775        let item_height = ItemHeight::fixed(30.0);
776        assert_eq!(item_height.get(0), 30.0);
777        assert_eq!(item_height.get(100), 30.0);
778        assert_eq!(item_height.get(9999), 30.0);
779    }
780
781    #[test]
782    fn test_item_height_variable_update() {
783        let mut item_height = ItemHeight::variable(50.0);
784
785        // Initially uses estimate
786        assert_eq!(item_height.get(5), 50.0);
787
788        // After measuring, uses actual
789        item_height.set_measured(5, 75.0);
790        assert_eq!(item_height.get(5), 75.0);
791
792        // Other items still use estimate
793        assert_eq!(item_height.get(6), 50.0);
794    }
795
796    #[test]
797    fn test_scroll_delta() {
798        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
799        state.set_viewport_height(200.0);
800        state.config_mut().smooth_scrolling = false; // Disable smooth scrolling for direct updates
801
802        state.scroll_by(100.0);
803        assert_eq!(state.scroll_offset(), 100.0);
804
805        state.scroll_by(50.0);
806        assert_eq!(state.scroll_offset(), 150.0);
807
808        state.scroll_by(-200.0);
809        assert_eq!(state.scroll_offset(), 0.0); // Clamped to 0
810    }
811
812    #[test]
813    fn test_item_count_change() {
814        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
815        assert_eq!(state.total_items(), 100);
816
817        state.set_total_items(200);
818        assert_eq!(state.total_items(), 200);
819        assert_eq!(state.total_height(), 10000.0);
820
821        state.set_total_items(50);
822        assert_eq!(state.total_items(), 50);
823        assert_eq!(state.total_height(), 2500.0);
824    }
825
826    #[test]
827    fn test_visible_range_scrolled() {
828        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
829        state.set_viewport_height(200.0);
830        state.config_mut().overscan = 0; // Disable overscan for clearer test
831
832        // At scroll 0, items 0-3 visible (4 items * 50px = 200px)
833        let range = state.calculate_visible_range();
834        assert_eq!(range.start, 0);
835
836        // Scroll to show items 10-13
837        state.set_scroll_offset(500.0);
838        let range = state.calculate_visible_range();
839        assert_eq!(range.start, 10);
840    }
841
842    #[test]
843    fn test_is_visible() {
844        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
845        state.set_viewport_height(200.0);
846        state.update_visible(); // Need to update visible range first
847
848        // At scroll 0, items near top should be visible
849        assert!(state.is_visible(0));
850        assert!(state.is_visible(3));
851        // Items far down should not be visible
852        assert!(!state.is_visible(50));
853        assert!(!state.is_visible(99));
854    }
855
856    #[test]
857    fn test_stats() {
858        let mut state = VirtualScrollState::new(1000, ItemHeight::fixed(50.0));
859        state.set_viewport_height(400.0);
860        state.update_visible(); // Update visible range
861        state.update_stats(); // Update stats
862
863        let stats = state.stats();
864        assert_eq!(stats.total_items, 1000);
865        assert_eq!(stats.total_height, 50000.0);
866        // Visible range should be a subset of total items
867        assert!(stats.visible_range.end <= stats.total_items);
868    }
869
870    #[test]
871    fn test_scroll_to_first_and_last() {
872        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
873        state.config_mut().smooth_scrolling = false;
874        state.set_viewport_height(200.0);
875
876        // Scroll to last item
877        state.scroll_to_item(99);
878        assert!(state.scroll_offset() > 0.0);
879
880        // Scroll back to first
881        state.scroll_to_item(0);
882        assert_eq!(state.scroll_offset(), 0.0);
883    }
884
885    #[test]
886    fn test_variable_height_total() {
887        let mut item_height = ItemHeight::variable(50.0);
888        item_height.set_measured(0, 100.0);
889        item_height.set_measured(1, 25.0);
890
891        let state = VirtualScrollState::new(3, item_height);
892        // Total = 100 + 25 + 50 = 175
893        assert_eq!(state.total_height(), 175.0);
894    }
895
896    #[test]
897    fn test_scroll_preserves_position_on_resize() {
898        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
899        state.set_viewport_height(200.0);
900        state.set_scroll_offset(500.0);
901
902        // Resize viewport
903        state.set_viewport_height(400.0);
904
905        // Scroll position should be preserved
906        assert_eq!(state.scroll_offset(), 500.0);
907    }
908
909    #[test]
910    fn test_overscan_increases_visible_range() {
911        let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
912        state.set_viewport_height(200.0);
913
914        state.config_mut().overscan = 0;
915        let range_no_overscan = state.calculate_visible_range();
916
917        state.config_mut().overscan = 5;
918        let range_with_overscan = state.calculate_visible_range();
919
920        // With overscan, more items should be in range
921        let no_overscan_count = range_no_overscan.end - range_no_overscan.start;
922        let with_overscan_count = range_with_overscan.end - range_with_overscan.start;
923        assert!(with_overscan_count > no_overscan_count);
924    }
925
926    #[test]
927    fn test_single_item_list() {
928        let state = VirtualScrollState::new(1, ItemHeight::fixed(50.0));
929        assert_eq!(state.total_height(), 50.0);
930        assert_eq!(state.get_item_at_position(25.0), Some(0));
931        assert_eq!(state.get_item_at_position(60.0), None);
932    }
933}