1use std::ops::Range;
7
8use astrelis_core::alloc::HashMap;
9
10use crate::tree::{NodeId, UiTree};
11
12type ItemBuilder<T> = Box<dyn Fn(usize, &T, &mut UiTree) -> NodeId>;
14
15#[derive(Debug, Clone)]
17pub struct VirtualScrollConfig {
18 pub overscan: usize,
20 pub scroll_threshold: f32,
22 pub smooth_scrolling: bool,
24 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#[derive(Debug, Clone)]
41pub enum ItemHeight {
42 Fixed(f32),
44 Variable {
46 estimated: f32,
48 measured: HashMap<usize, f32>,
50 },
51}
52
53impl ItemHeight {
54 pub fn fixed(height: f32) -> Self {
56 Self::Fixed(height)
57 }
58
59 pub fn variable(estimated: f32) -> Self {
61 Self::Variable {
62 estimated,
63 measured: HashMap::default(),
64 }
65 }
66
67 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 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 pub fn is_fixed(&self) -> bool {
87 matches!(self, Self::Fixed(_))
88 }
89}
90
91#[derive(Debug, Clone)]
93pub struct MountedItem {
94 pub node_id: NodeId,
96 pub y_offset: f32,
98 pub height: f32,
100}
101
102#[derive(Debug, Clone, Default)]
104pub struct VirtualScrollStats {
105 pub total_items: usize,
107 pub mounted_count: usize,
109 pub visible_range: Range<usize>,
111 pub total_height: f32,
113 pub scroll_offset: f32,
115 pub recycled_count: usize,
117 pub created_count: usize,
119}
120
121#[derive(Debug)]
123pub struct VirtualScrollState {
124 config: VirtualScrollConfig,
126 total_items: usize,
128 item_height: ItemHeight,
130 scroll_offset: f32,
132 target_scroll_offset: f32,
134 viewport_height: f32,
136 visible_range: Range<usize>,
138 mounted: HashMap<usize, MountedItem>,
140 container_node: Option<NodeId>,
142 cached_total_height: Option<f32>,
144 stats: VirtualScrollStats,
146}
147
148impl VirtualScrollState {
149 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 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 pub fn set_container(&mut self, node: NodeId) {
189 self.container_node = Some(node);
190 }
191
192 pub fn container(&self) -> Option<NodeId> {
194 self.container_node
195 }
196
197 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 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 pub fn total_items(&self) -> usize {
213 self.total_items
214 }
215
216 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 pub fn viewport_height(&self) -> f32 {
225 self.viewport_height
226 }
227
228 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 pub fn max_scroll_offset(&self) -> f32 {
251 (self.total_height() - self.viewport_height).max(0.0)
252 }
253
254 pub fn scroll_offset(&self) -> f32 {
256 self.scroll_offset
257 }
258
259 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 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 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 if item_offset >= self.scroll_offset
287 && item_offset + item_height <= self.scroll_offset + self.viewport_height
288 {
289 return;
290 }
291
292 let target = if item_offset < self.scroll_offset {
294 item_offset
296 } else {
297 (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 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 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 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 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 let t = (dt / self.config.scroll_animation_duration).min(1.0);
392 let eased = 1.0 - (1.0 - t).powi(3); self.scroll_offset += diff * eased;
394 true
395 }
396
397 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 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 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 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 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 self.item_height.set_measured(index, height);
459 self.cached_total_height = None;
460 }
461
462 pub fn unmount_item(&mut self, index: usize) -> Option<NodeId> {
464 self.mounted.remove(&index).map(|item| item.node_id)
465 }
466
467 pub fn get_mounted(&self, index: usize) -> Option<&MountedItem> {
469 self.mounted.get(&index)
470 }
471
472 pub fn mounted_items(&self) -> impl Iterator<Item = (usize, &MountedItem)> {
474 self.mounted.iter().map(|(k, v)| (*k, v))
475 }
476
477 pub fn visible_range(&self) -> Range<usize> {
479 self.visible_range.clone()
480 }
481
482 pub fn is_visible(&self, index: usize) -> bool {
484 self.visible_range.contains(&index)
485 }
486
487 pub fn stats(&self) -> &VirtualScrollStats {
489 &self.stats
490 }
491
492 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 pub fn config(&self) -> &VirtualScrollConfig {
507 &self.config
508 }
509
510 pub fn config_mut(&mut self) -> &mut VirtualScrollConfig {
512 &mut self.config
513 }
514}
515
516pub struct VirtualScrollView<T> {
518 items: Vec<T>,
520 state: VirtualScrollState,
522 builder: ItemBuilder<T>,
524}
525
526impl<T> VirtualScrollView<T> {
527 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 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 pub fn with_config(mut self, config: VirtualScrollConfig) -> Self {
553 self.state.config = config;
554 self
555 }
556
557 pub fn state(&self) -> &VirtualScrollState {
559 &self.state
560 }
561
562 pub fn state_mut(&mut self) -> &mut VirtualScrollState {
564 &mut self.state
565 }
566
567 pub fn items(&self) -> &[T] {
569 &self.items
570 }
571
572 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 pub fn set_viewport_height(&mut self, height: f32) {
580 self.state.set_viewport_height(height);
581 }
582
583 pub fn scroll_by(&mut self, delta: f32) {
585 self.state.scroll_by(delta);
586 }
587
588 pub fn scroll_to_item(&mut self, index: usize) {
590 self.state.scroll_to_item(index);
591 }
592
593 pub fn update(&mut self, tree: &mut UiTree, dt: f32) -> VirtualScrollUpdate {
596 let mut update = VirtualScrollUpdate::default();
597
598 if self.state.update_animation(dt) {
600 update.scroll_changed = true;
601 }
602
603 let (to_mount, to_unmount) = self.state.update_visible();
605
606 for index in to_unmount {
608 if let Some(node_id) = self.state.unmount_item(index) {
609 tree.remove_node(node_id);
611 update.removed.push((index, node_id));
612 }
613 }
614
615 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 if let Some(container) = self.state.container() {
622 tree.add_child(container, node_id);
623 }
624
625 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 self.update_item_positions(tree);
638
639 self.state.update_stats();
640 update
641 }
642
643 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 let visual_y = item.y_offset - scroll_offset;
650
651 tree.set_position_offset(item.node_id, 0.0, visual_y);
653 }
654 }
655}
656
657#[derive(Debug, Default)]
659pub struct VirtualScrollUpdate {
660 pub scroll_changed: bool,
662 pub added: Vec<(usize, NodeId)>,
664 pub removed: Vec<(usize, NodeId)>,
666}
667
668impl VirtualScrollUpdate {
669 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); }
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 let range = state.calculate_visible_range();
723 assert_eq!(range.start, 0);
724 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 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 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 assert_eq!(item_height.get(5), 50.0);
787
788 item_height.set_measured(5, 75.0);
790 assert_eq!(item_height.get(5), 75.0);
791
792 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; 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); }
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; let range = state.calculate_visible_range();
834 assert_eq!(range.start, 0);
835
836 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(); assert!(state.is_visible(0));
850 assert!(state.is_visible(3));
851 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(); state.update_stats(); let stats = state.stats();
864 assert_eq!(stats.total_items, 1000);
865 assert_eq!(stats.total_height, 50000.0);
866 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 state.scroll_to_item(99);
878 assert!(state.scroll_offset() > 0.0);
879
880 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 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 state.set_viewport_height(400.0);
904
905 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 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}