use crate::ecs::{Entity, World};
use crate::event::scroll::ScrollOffset;
use crate::types::Fixed;
use crate::widget::dirty::Dirty;
use alloc::vec::Vec;
pub struct LazyList {
pub item_count: u32,
pub item_height: Fixed,
pub pool_size: u8,
pub visible_start: u32,
}
impl LazyList {
pub fn new(item_count: u32, item_height: impl Into<Fixed>, pool_size: u8) -> Self {
Self {
item_count,
item_height: item_height.into(),
pool_size,
visible_start: 0,
}
}
}
pub struct LazyListPool {
pub items: Vec<Entity>,
pub bound_indices: Vec<u32>,
}
impl LazyListPool {
pub fn new(items: Vec<Entity>) -> Self {
let n = items.len();
Self {
items,
bound_indices: alloc::vec![u32::MAX; n],
}
}
}
pub type ItemBinder = fn(&mut crate::ecs::World, Entity, u32);
pub struct LazyListBinder {
pub bind: ItemBinder,
}
struct ListContext {
item_count: u32,
item_height: Fixed,
pool_size: u32,
visible_start: u32,
items: Vec<Entity>,
bound_indices: Vec<u32>,
binder: ItemBinder,
}
fn collect_list_context(world: &World, entity: Entity) -> Option<ListContext> {
let (item_count, item_height, pool_size) = world
.get::<LazyList>(entity)
.map(|l| (l.item_count, l.item_height, l.pool_size as u32))?;
if pool_size == 0 || item_height <= Fixed::ZERO {
return None;
}
let scroll_y = world
.get::<ScrollOffset>(entity)
.map(|s| s.y)
.unwrap_or(Fixed::ZERO);
let raw_start = (scroll_y / item_height).to_int();
let visible_start = (raw_start.max(0) as u32).min(item_count.saturating_sub(pool_size));
let pool = world.get::<LazyListPool>(entity)?;
let binder = world.get::<LazyListBinder>(entity).map(|b| b.bind)?;
Some(ListContext {
item_count,
item_height,
pool_size,
visible_start,
items: pool.items.clone(),
bound_indices: pool.bound_indices.clone(),
binder,
})
}
fn apply_bindings(world: &mut World, entity: Entity, ctx: ListContext) -> bool {
let mut new_bindings = ctx.bound_indices;
let mut any_changed = false;
let pool_size = ctx.pool_size as usize;
if pool_size == 0 {
return false;
}
for i in 0..pool_size {
let target = ctx.visible_start + i as u32;
if target >= ctx.item_count {
continue;
}
let slot_idx = (target as usize) % pool_size;
let slot = ctx.items[slot_idx];
let rebound = new_bindings[slot_idx] != target;
if rebound {
(ctx.binder)(world, slot, target);
new_bindings[slot_idx] = target;
any_changed = true;
}
if world.get::<crate::widget::Hidden>(slot).is_some() {
world.remove::<crate::widget::Hidden>(slot);
any_changed = true;
}
let y = ctx.item_height * Fixed::from_int(target as i32);
if rebound {
crate::widget::set_position(world, slot, Fixed::ZERO, y);
} else {
crate::widget::set_position_quiet(world, slot, Fixed::ZERO, y);
}
}
if let Some(pool) = world.get_mut::<LazyListPool>(entity) {
pool.bound_indices = new_bindings;
}
if let Some(list) = world.get_mut::<LazyList>(entity) {
list.visible_start = ctx.visible_start;
}
any_changed
}
#[crate::system(order = LAZY_LIST, expect = LazyList)]
pub fn lazy_list_system(world: &mut World) {
let lists: Vec<Entity> = world.query::<LazyList>().collect();
for entity in lists {
let Some(ctx) = collect_list_context(world, entity) else {
continue;
};
let stale_cleared = clear_extra_slots(world, entity, &ctx);
let prev_start = world
.get::<LazyList>(entity)
.map(|l| l.visible_start)
.unwrap_or(u32::MAX);
let pool_size = ctx.pool_size as usize;
if prev_start == ctx.visible_start && pool_size > 0 {
let mut all_bound = true;
for i in 0..pool_size {
let target = ctx.visible_start + i as u32;
if target >= ctx.item_count {
continue;
}
let slot_idx = (target as usize) % pool_size;
if ctx.bound_indices[slot_idx] != target {
all_bound = false;
break;
}
}
if all_bound {
if stale_cleared {
world.insert(entity, Dirty);
}
continue;
}
}
let bindings_changed = apply_bindings(world, entity, ctx);
if bindings_changed || stale_cleared {
world.insert(entity, Dirty);
}
}
}
fn clear_extra_slots(world: &mut World, entity: Entity, ctx: &ListContext) -> bool {
let pool_size = ctx.pool_size as usize;
if pool_size == 0 {
return false;
}
let mut any_cleared = false;
let mut new_bindings = ctx.bound_indices.clone();
for (i, bound) in new_bindings.iter_mut().enumerate().take(pool_size) {
let needs_hide = match *bound {
u32::MAX => i as u32 >= ctx.item_count,
n if n >= ctx.item_count => true,
_ => false,
};
if !needs_hide {
continue;
}
let slot = ctx.items[i];
if world.get::<crate::widget::Hidden>(slot).is_some() {
continue;
}
*bound = u32::MAX;
any_cleared = true;
world.insert(slot, crate::widget::Hidden);
}
if any_cleared {
if let Some(pool) = world.get_mut::<LazyListPool>(entity) {
pool.bound_indices = new_bindings;
}
}
any_cleared
}
pub fn view() -> crate::widget::view::View {
crate::widget::view::View::systems_only("LazyList", const { &[lazy_list_system::system()] })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::{LayoutStyle, Position};
use crate::types::Dimension;
use crate::widget::{Style, Widget};
#[test]
fn pool_starts_unbound() {
let stub = Entity {
id: 0,
generation: 0,
};
let pool = LazyListPool::new(alloc::vec![stub; 5]);
assert_eq!(pool.items.len(), 5);
assert!(pool.bound_indices.iter().all(|&i| i == u32::MAX));
}
struct BindTrace(alloc::vec::Vec<u32>);
fn recording_binder(world: &mut World, _entity: Entity, index: u32) {
let trace = world.resource_mut::<BindTrace>().expect("trace resource");
trace.0.push(index);
}
fn make_slot(world: &mut World, list: Entity) -> Entity {
let e = world.spawn();
world.insert(e, Widget);
world.insert(
e,
Style {
layout: LayoutStyle {
position: Position::Absolute,
left: Dimension::Px(Fixed::ZERO),
top: Dimension::Px(Fixed::ZERO),
width: Dimension::Px(Fixed::from_int(100)),
height: Dimension::Px(Fixed::from_int(40)),
..Default::default()
},
..Default::default()
},
);
world.insert(e, crate::widget::Parent(list));
e
}
#[test]
fn first_tick_binds_all_slots_top_down() {
let mut world = World::default();
let list = world.spawn();
let pool: Vec<Entity> = (0..5).map(|_| make_slot(&mut world, list)).collect();
world.insert(list, Widget);
world.insert(list, Style::default());
world.insert(list, LazyList::new(100, 40, 5));
world.insert(list, LazyListPool::new(pool.clone()));
world.insert(
list,
LazyListBinder {
bind: recording_binder,
},
);
world.insert_resource(BindTrace(alloc::vec::Vec::new()));
lazy_list_system(&mut world);
let trace = &world.resource::<BindTrace>().unwrap().0;
assert_eq!(trace, &alloc::vec![0u32, 1, 2, 3, 4]);
let bound = &world.get::<LazyListPool>(list).unwrap().bound_indices;
assert_eq!(bound, &alloc::vec![0u32, 1, 2, 3, 4]);
}
#[test]
fn scroll_one_row_rebinds_one_slot() {
let mut world = World::default();
let list = world.spawn();
let pool: Vec<Entity> = (0..5).map(|_| make_slot(&mut world, list)).collect();
world.insert(list, Widget);
world.insert(list, Style::default());
world.insert(list, LazyList::new(100, 40, 5));
world.insert(list, LazyListPool::new(pool.clone()));
world.insert(
list,
LazyListBinder {
bind: recording_binder,
},
);
world.insert_resource(BindTrace(alloc::vec::Vec::new()));
lazy_list_system(&mut world);
world.resource_mut::<BindTrace>().unwrap().0.clear();
world.insert(
list,
ScrollOffset {
x: Fixed::ZERO,
y: Fixed::from_int(40),
},
);
lazy_list_system(&mut world);
let trace = &world.resource::<BindTrace>().unwrap().0;
assert_eq!(
trace.len(),
1,
"ring buffer rebinds one slot per row scrolled, got {trace:?}"
);
assert_eq!(trace, &alloc::vec![5u32]);
}
#[test]
fn pool_larger_than_item_count_hides_extra_slots() {
let mut world = World::default();
let list = world.spawn();
let pool: Vec<Entity> = (0..5).map(|_| make_slot(&mut world, list)).collect();
world.insert(list, Widget);
world.insert(list, Style::default());
world.insert(list, LazyList::new(3, 40, 5));
world.insert(list, LazyListPool::new(pool.clone()));
world.insert(
list,
LazyListBinder {
bind: recording_binder,
},
);
world.insert_resource(BindTrace(alloc::vec::Vec::new()));
lazy_list_system(&mut world);
for &slot in &pool[..3] {
assert!(
world.get::<crate::widget::Hidden>(slot).is_none(),
"first 3 slots must be visible (bound)"
);
}
for &slot in &pool[3..] {
assert!(
world.get::<crate::widget::Hidden>(slot).is_some(),
"extra slots beyond item_count must be Hidden"
);
}
}
#[test]
fn shrinking_item_count_clears_stale_bindings() {
let mut world = World::default();
let list = world.spawn();
let pool: Vec<Entity> = (0..5).map(|_| make_slot(&mut world, list)).collect();
world.insert(list, Widget);
world.insert(list, Style::default());
world.insert(list, LazyList::new(5, 40, 5));
world.insert(list, LazyListPool::new(pool.clone()));
world.insert(
list,
LazyListBinder {
bind: recording_binder,
},
);
world.insert_resource(BindTrace(alloc::vec::Vec::new()));
lazy_list_system(&mut world);
let bound_after_bind = world
.get::<LazyListPool>(list)
.unwrap()
.bound_indices
.clone();
assert_eq!(bound_after_bind, alloc::vec![0u32, 1, 2, 3, 4]);
if let Some(l) = world.get_mut::<LazyList>(list) {
l.item_count = 3;
}
lazy_list_system(&mut world);
let bound_after_shrink = &world.get::<LazyListPool>(list).unwrap().bound_indices;
assert_eq!(
bound_after_shrink,
&alloc::vec![0u32, 1, 2, u32::MAX, u32::MAX],
"rows 3 / 4 must be cleared from bound_indices",
);
assert!(world.get::<crate::widget::Hidden>(pool[3]).is_some());
assert!(world.get::<crate::widget::Hidden>(pool[4]).is_some());
assert!(world.get::<crate::widget::Hidden>(pool[0]).is_none());
}
#[test]
fn growing_item_count_unhides_reused_slots() {
let mut world = World::default();
let list = world.spawn();
let pool: Vec<Entity> = (0..5).map(|_| make_slot(&mut world, list)).collect();
world.insert(list, Widget);
world.insert(list, Style::default());
world.insert(list, LazyList::new(3, 40, 5));
world.insert(list, LazyListPool::new(pool.clone()));
world.insert(
list,
LazyListBinder {
bind: recording_binder,
},
);
world.insert_resource(BindTrace(alloc::vec::Vec::new()));
lazy_list_system(&mut world);
assert!(world.get::<crate::widget::Hidden>(pool[3]).is_some());
assert!(world.get::<crate::widget::Hidden>(pool[4]).is_some());
if let Some(l) = world.get_mut::<LazyList>(list) {
l.item_count = 5;
}
lazy_list_system(&mut world);
for &slot in &pool {
assert!(
world.get::<crate::widget::Hidden>(slot).is_none(),
"all slots should be visible once item_count grows back",
);
}
}
}