use std::ops::Range;
use astrelis_core::alloc::HashMap;
use crate::tree::{NodeId, UiTree};
type ItemBuilder<T> = Box<dyn Fn(usize, &T, &mut UiTree) -> NodeId>;
#[derive(Debug, Clone)]
pub struct VirtualScrollConfig {
pub overscan: usize,
pub scroll_threshold: f32,
pub smooth_scrolling: bool,
pub scroll_animation_duration: f32,
}
impl Default for VirtualScrollConfig {
fn default() -> Self {
Self {
overscan: 3,
scroll_threshold: 1.0,
smooth_scrolling: true,
scroll_animation_duration: 0.15,
}
}
}
#[derive(Debug, Clone)]
pub enum ItemHeight {
Fixed(f32),
Variable {
estimated: f32,
measured: HashMap<usize, f32>,
},
}
impl ItemHeight {
pub fn fixed(height: f32) -> Self {
Self::Fixed(height)
}
pub fn variable(estimated: f32) -> Self {
Self::Variable {
estimated,
measured: HashMap::default(),
}
}
pub fn get(&self, index: usize) -> f32 {
match self {
Self::Fixed(h) => *h,
Self::Variable {
estimated,
measured,
} => measured.get(&index).copied().unwrap_or(*estimated),
}
}
pub fn set_measured(&mut self, index: usize, height: f32) {
if let Self::Variable { measured, .. } = self {
measured.insert(index, height);
}
}
pub fn is_fixed(&self) -> bool {
matches!(self, Self::Fixed(_))
}
}
#[derive(Debug, Clone)]
pub struct MountedItem {
pub node_id: NodeId,
pub y_offset: f32,
pub height: f32,
}
#[derive(Debug, Clone, Default)]
pub struct VirtualScrollStats {
pub total_items: usize,
pub mounted_count: usize,
pub visible_range: Range<usize>,
pub total_height: f32,
pub scroll_offset: f32,
pub recycled_count: usize,
pub created_count: usize,
}
#[derive(Debug)]
pub struct VirtualScrollState {
config: VirtualScrollConfig,
total_items: usize,
item_height: ItemHeight,
scroll_offset: f32,
target_scroll_offset: f32,
viewport_height: f32,
visible_range: Range<usize>,
mounted: HashMap<usize, MountedItem>,
container_node: Option<NodeId>,
cached_total_height: Option<f32>,
stats: VirtualScrollStats,
}
impl VirtualScrollState {
pub fn new(total_items: usize, item_height: ItemHeight) -> Self {
Self {
config: VirtualScrollConfig::default(),
total_items,
item_height,
scroll_offset: 0.0,
target_scroll_offset: 0.0,
viewport_height: 0.0,
visible_range: 0..0,
mounted: HashMap::default(),
container_node: None,
cached_total_height: None,
stats: VirtualScrollStats::default(),
}
}
pub fn with_config(
total_items: usize,
item_height: ItemHeight,
config: VirtualScrollConfig,
) -> Self {
Self {
config,
total_items,
item_height,
scroll_offset: 0.0,
target_scroll_offset: 0.0,
viewport_height: 0.0,
visible_range: 0..0,
mounted: HashMap::default(),
container_node: None,
cached_total_height: None,
stats: VirtualScrollStats::default(),
}
}
pub fn set_container(&mut self, node: NodeId) {
self.container_node = Some(node);
}
pub fn container(&self) -> Option<NodeId> {
self.container_node
}
pub fn set_total_items(&mut self, count: usize) {
if self.total_items != count {
self.total_items = count;
self.cached_total_height = None;
let max_offset = self.max_scroll_offset();
if self.scroll_offset > max_offset {
self.scroll_offset = max_offset;
self.target_scroll_offset = max_offset;
}
}
}
pub fn total_items(&self) -> usize {
self.total_items
}
pub fn set_viewport_height(&mut self, height: f32) {
if (self.viewport_height - height).abs() > 0.1 {
self.viewport_height = height;
}
}
pub fn viewport_height(&self) -> f32 {
self.viewport_height
}
pub fn total_height(&self) -> f32 {
if let Some(cached) = self.cached_total_height {
return cached;
}
match &self.item_height {
ItemHeight::Fixed(h) => *h * self.total_items as f32,
ItemHeight::Variable {
estimated,
measured,
} => {
let mut height = 0.0;
for i in 0..self.total_items {
height += measured.get(&i).copied().unwrap_or(*estimated);
}
height
}
}
}
pub fn max_scroll_offset(&self) -> f32 {
(self.total_height() - self.viewport_height).max(0.0)
}
pub fn scroll_offset(&self) -> f32 {
self.scroll_offset
}
pub fn set_scroll_offset(&mut self, offset: f32) {
let clamped = offset.clamp(0.0, self.max_scroll_offset());
self.scroll_offset = clamped;
self.target_scroll_offset = clamped;
}
pub fn scroll_by(&mut self, delta: f32) {
if self.config.smooth_scrolling {
self.target_scroll_offset =
(self.target_scroll_offset + delta).clamp(0.0, self.max_scroll_offset());
} else {
self.set_scroll_offset(self.scroll_offset + delta);
}
}
pub fn scroll_to_item(&mut self, index: usize) {
if index >= self.total_items {
return;
}
let item_offset = self.get_item_offset(index);
let item_height = self.item_height.get(index);
if item_offset >= self.scroll_offset
&& item_offset + item_height <= self.scroll_offset + self.viewport_height
{
return;
}
let target = if item_offset < self.scroll_offset {
item_offset
} else {
(item_offset + item_height - self.viewport_height).max(0.0)
};
if self.config.smooth_scrolling {
self.target_scroll_offset = target;
} else {
self.set_scroll_offset(target);
}
}
pub fn scroll_to_item_centered(&mut self, index: usize) {
if index >= self.total_items {
return;
}
let item_offset = self.get_item_offset(index);
let item_height = self.item_height.get(index);
let target = (item_offset + item_height / 2.0 - self.viewport_height / 2.0).max(0.0);
if self.config.smooth_scrolling {
self.target_scroll_offset = target.min(self.max_scroll_offset());
} else {
self.set_scroll_offset(target);
}
}
pub fn get_item_offset(&self, index: usize) -> f32 {
match &self.item_height {
ItemHeight::Fixed(h) => *h * index as f32,
ItemHeight::Variable {
estimated,
measured,
} => {
let mut offset = 0.0;
for i in 0..index {
offset += measured.get(&i).copied().unwrap_or(*estimated);
}
offset
}
}
}
pub fn get_item_at_position(&self, y: f32) -> Option<usize> {
if y < 0.0 || self.total_items == 0 {
return None;
}
match &self.item_height {
ItemHeight::Fixed(h) => {
let index = (y / h) as usize;
if index < self.total_items {
Some(index)
} else {
None
}
}
ItemHeight::Variable {
estimated,
measured,
} => {
let mut offset = 0.0;
for i in 0..self.total_items {
let height = measured.get(&i).copied().unwrap_or(*estimated);
if y >= offset && y < offset + height {
return Some(i);
}
offset += height;
}
None
}
}
}
pub fn update_animation(&mut self, dt: f32) -> bool {
if !self.config.smooth_scrolling {
return false;
}
let diff = self.target_scroll_offset - self.scroll_offset;
if diff.abs() < 0.5 {
if diff.abs() > 0.0 {
self.scroll_offset = self.target_scroll_offset;
return true;
}
return false;
}
let t = (dt / self.config.scroll_animation_duration).min(1.0);
let eased = 1.0 - (1.0 - t).powi(3); self.scroll_offset += diff * eased;
true
}
pub fn calculate_visible_range(&self) -> Range<usize> {
if self.total_items == 0 || self.viewport_height <= 0.0 {
return 0..0;
}
let start_index = self
.get_item_at_position(self.scroll_offset)
.unwrap_or(0)
.saturating_sub(self.config.overscan);
let end_y = self.scroll_offset + self.viewport_height;
let end_index = self
.get_item_at_position(end_y)
.map(|i| i + 1)
.unwrap_or(self.total_items)
.saturating_add(self.config.overscan)
.min(self.total_items);
start_index..end_index
}
pub fn update_visible(&mut self) -> (Vec<usize>, Vec<usize>) {
let new_range = self.calculate_visible_range();
if new_range == self.visible_range {
return (vec![], vec![]);
}
let old_range = self.visible_range.clone();
self.visible_range = new_range.clone();
let to_unmount: Vec<usize> = old_range
.filter(|i| !new_range.contains(i))
.filter(|i| self.mounted.contains_key(i))
.collect();
let to_mount: Vec<usize> = new_range
.filter(|i| !self.mounted.contains_key(i))
.collect();
(to_mount, to_unmount)
}
pub fn mount_item(&mut self, index: usize, node_id: NodeId, height: f32) {
let y_offset = self.get_item_offset(index);
self.mounted.insert(
index,
MountedItem {
node_id,
y_offset,
height,
},
);
self.item_height.set_measured(index, height);
self.cached_total_height = None;
}
pub fn unmount_item(&mut self, index: usize) -> Option<NodeId> {
self.mounted.remove(&index).map(|item| item.node_id)
}
pub fn get_mounted(&self, index: usize) -> Option<&MountedItem> {
self.mounted.get(&index)
}
pub fn mounted_items(&self) -> impl Iterator<Item = (usize, &MountedItem)> {
self.mounted.iter().map(|(k, v)| (*k, v))
}
pub fn visible_range(&self) -> Range<usize> {
self.visible_range.clone()
}
pub fn is_visible(&self, index: usize) -> bool {
self.visible_range.contains(&index)
}
pub fn stats(&self) -> &VirtualScrollStats {
&self.stats
}
pub fn update_stats(&mut self) {
self.stats = VirtualScrollStats {
total_items: self.total_items,
mounted_count: self.mounted.len(),
visible_range: self.visible_range.clone(),
total_height: self.total_height(),
scroll_offset: self.scroll_offset,
recycled_count: 0,
created_count: 0,
};
}
pub fn config(&self) -> &VirtualScrollConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut VirtualScrollConfig {
&mut self.config
}
}
pub struct VirtualScrollView<T> {
items: Vec<T>,
state: VirtualScrollState,
builder: ItemBuilder<T>,
}
impl<T> VirtualScrollView<T> {
pub fn new<F>(items: Vec<T>, item_height: f32, builder: F) -> Self
where
F: Fn(usize, &T, &mut UiTree) -> NodeId + 'static,
{
Self {
state: VirtualScrollState::new(items.len(), ItemHeight::fixed(item_height)),
items,
builder: Box::new(builder),
}
}
pub fn with_variable_height<F>(items: Vec<T>, estimated_height: f32, builder: F) -> Self
where
F: Fn(usize, &T, &mut UiTree) -> NodeId + 'static,
{
Self {
state: VirtualScrollState::new(items.len(), ItemHeight::variable(estimated_height)),
items,
builder: Box::new(builder),
}
}
pub fn with_config(mut self, config: VirtualScrollConfig) -> Self {
self.state.config = config;
self
}
pub fn state(&self) -> &VirtualScrollState {
&self.state
}
pub fn state_mut(&mut self) -> &mut VirtualScrollState {
&mut self.state
}
pub fn items(&self) -> &[T] {
&self.items
}
pub fn set_items(&mut self, items: Vec<T>) {
self.items = items;
self.state.set_total_items(self.items.len());
}
pub fn set_viewport_height(&mut self, height: f32) {
self.state.set_viewport_height(height);
}
pub fn scroll_by(&mut self, delta: f32) {
self.state.scroll_by(delta);
}
pub fn scroll_to_item(&mut self, index: usize) {
self.state.scroll_to_item(index);
}
pub fn update(&mut self, tree: &mut UiTree, dt: f32) -> VirtualScrollUpdate {
let mut update = VirtualScrollUpdate::default();
if self.state.update_animation(dt) {
update.scroll_changed = true;
}
let (to_mount, to_unmount) = self.state.update_visible();
for index in to_unmount {
if let Some(node_id) = self.state.unmount_item(index) {
tree.remove_node(node_id);
update.removed.push((index, node_id));
}
}
for index in to_mount {
if let Some(item) = self.items.get(index) {
let node_id = (self.builder)(index, item, tree);
if let Some(container) = self.state.container() {
tree.add_child(container, node_id);
}
let height = tree
.get_layout(node_id)
.map(|l| l.height)
.unwrap_or(self.state.item_height.get(index));
self.state.mount_item(index, node_id, height);
update.added.push((index, node_id));
}
}
self.update_item_positions(tree);
self.state.update_stats();
update
}
fn update_item_positions(&self, tree: &mut UiTree) {
let scroll_offset = self.state.scroll_offset();
for (_index, item) in self.state.mounted_items() {
let visual_y = item.y_offset - scroll_offset;
tree.set_position_offset(item.node_id, 0.0, visual_y);
}
}
}
#[derive(Debug, Default)]
pub struct VirtualScrollUpdate {
pub scroll_changed: bool,
pub added: Vec<(usize, NodeId)>,
pub removed: Vec<(usize, NodeId)>,
}
impl VirtualScrollUpdate {
pub fn has_changes(&self) -> bool {
self.scroll_changed || !self.added.is_empty() || !self.removed.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fixed_height_offset() {
let state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
assert_eq!(state.get_item_offset(0), 0.0);
assert_eq!(state.get_item_offset(1), 50.0);
assert_eq!(state.get_item_offset(10), 500.0);
}
#[test]
fn test_total_height_fixed() {
let state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
assert_eq!(state.total_height(), 5000.0);
}
#[test]
fn test_variable_height() {
let mut item_height = ItemHeight::variable(50.0);
item_height.set_measured(0, 30.0);
item_height.set_measured(1, 70.0);
assert_eq!(item_height.get(0), 30.0);
assert_eq!(item_height.get(1), 70.0);
assert_eq!(item_height.get(2), 50.0); }
#[test]
fn test_get_item_at_position() {
let state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
assert_eq!(state.get_item_at_position(0.0), Some(0));
assert_eq!(state.get_item_at_position(49.0), Some(0));
assert_eq!(state.get_item_at_position(50.0), Some(1));
assert_eq!(state.get_item_at_position(125.0), Some(2));
assert_eq!(state.get_item_at_position(5000.0), None);
}
#[test]
fn test_visible_range_calculation() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
state.set_viewport_height(200.0);
state.config_mut().overscan = 2;
let range = state.calculate_visible_range();
assert_eq!(range.start, 0);
assert!(
range.end >= 4,
"end should be at least 4, got {}",
range.end
);
}
#[test]
fn test_scroll_clamping() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
state.set_viewport_height(200.0);
state.set_scroll_offset(10000.0);
assert_eq!(state.scroll_offset(), 4800.0);
state.set_scroll_offset(-100.0);
assert_eq!(state.scroll_offset(), 0.0);
}
#[test]
fn test_scroll_to_item() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
state.config_mut().smooth_scrolling = false;
state.set_viewport_height(200.0);
state.scroll_to_item(20);
assert_eq!(state.scroll_offset(), 850.0);
}
#[test]
fn test_empty_list() {
let state = VirtualScrollState::new(0, ItemHeight::fixed(50.0));
assert_eq!(state.total_height(), 0.0);
assert_eq!(state.max_scroll_offset(), 0.0);
assert_eq!(state.calculate_visible_range(), 0..0);
}
#[test]
fn test_config_default() {
let config = VirtualScrollConfig::default();
assert!(config.overscan > 0);
assert!(config.smooth_scrolling);
}
#[test]
fn test_item_height_fixed() {
let item_height = ItemHeight::fixed(30.0);
assert_eq!(item_height.get(0), 30.0);
assert_eq!(item_height.get(100), 30.0);
assert_eq!(item_height.get(9999), 30.0);
}
#[test]
fn test_item_height_variable_update() {
let mut item_height = ItemHeight::variable(50.0);
assert_eq!(item_height.get(5), 50.0);
item_height.set_measured(5, 75.0);
assert_eq!(item_height.get(5), 75.0);
assert_eq!(item_height.get(6), 50.0);
}
#[test]
fn test_scroll_delta() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
state.set_viewport_height(200.0);
state.config_mut().smooth_scrolling = false;
state.scroll_by(100.0);
assert_eq!(state.scroll_offset(), 100.0);
state.scroll_by(50.0);
assert_eq!(state.scroll_offset(), 150.0);
state.scroll_by(-200.0);
assert_eq!(state.scroll_offset(), 0.0); }
#[test]
fn test_item_count_change() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
assert_eq!(state.total_items(), 100);
state.set_total_items(200);
assert_eq!(state.total_items(), 200);
assert_eq!(state.total_height(), 10000.0);
state.set_total_items(50);
assert_eq!(state.total_items(), 50);
assert_eq!(state.total_height(), 2500.0);
}
#[test]
fn test_visible_range_scrolled() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
state.set_viewport_height(200.0);
state.config_mut().overscan = 0;
let range = state.calculate_visible_range();
assert_eq!(range.start, 0);
state.set_scroll_offset(500.0);
let range = state.calculate_visible_range();
assert_eq!(range.start, 10);
}
#[test]
fn test_is_visible() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
state.set_viewport_height(200.0);
state.update_visible();
assert!(state.is_visible(0));
assert!(state.is_visible(3));
assert!(!state.is_visible(50));
assert!(!state.is_visible(99));
}
#[test]
fn test_stats() {
let mut state = VirtualScrollState::new(1000, ItemHeight::fixed(50.0));
state.set_viewport_height(400.0);
state.update_visible(); state.update_stats();
let stats = state.stats();
assert_eq!(stats.total_items, 1000);
assert_eq!(stats.total_height, 50000.0);
assert!(stats.visible_range.end <= stats.total_items);
}
#[test]
fn test_scroll_to_first_and_last() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
state.config_mut().smooth_scrolling = false;
state.set_viewport_height(200.0);
state.scroll_to_item(99);
assert!(state.scroll_offset() > 0.0);
state.scroll_to_item(0);
assert_eq!(state.scroll_offset(), 0.0);
}
#[test]
fn test_variable_height_total() {
let mut item_height = ItemHeight::variable(50.0);
item_height.set_measured(0, 100.0);
item_height.set_measured(1, 25.0);
let state = VirtualScrollState::new(3, item_height);
assert_eq!(state.total_height(), 175.0);
}
#[test]
fn test_scroll_preserves_position_on_resize() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
state.set_viewport_height(200.0);
state.set_scroll_offset(500.0);
state.set_viewport_height(400.0);
assert_eq!(state.scroll_offset(), 500.0);
}
#[test]
fn test_overscan_increases_visible_range() {
let mut state = VirtualScrollState::new(100, ItemHeight::fixed(50.0));
state.set_viewport_height(200.0);
state.config_mut().overscan = 0;
let range_no_overscan = state.calculate_visible_range();
state.config_mut().overscan = 5;
let range_with_overscan = state.calculate_visible_range();
let no_overscan_count = range_no_overscan.end - range_no_overscan.start;
let with_overscan_count = range_with_overscan.end - range_with_overscan.start;
assert!(with_overscan_count > no_overscan_count);
}
#[test]
fn test_single_item_list() {
let state = VirtualScrollState::new(1, ItemHeight::fixed(50.0));
assert_eq!(state.total_height(), 50.0);
assert_eq!(state.get_item_at_position(25.0), Some(0));
assert_eq!(state.get_item_at_position(60.0), None);
}
}