use crate::ecs::{Entity, World};
use crate::input::event::scroll::ScrollOffset;
use crate::types::Fixed;
use crate::ui::ComputedRect;
use crate::ui::dirty::Dirty;
use alloc::vec::Vec;
#[derive(crate::Component)]
pub struct LazyList {
pub item_count: u32,
pub item_height: Fixed,
pub pool_size: u8,
pub visible_start: u32,
}
impl Default for LazyList {
fn default() -> Self {
Self {
item_count: 0,
item_height: Fixed::ZERO,
pool_size: 0,
visible_start: 0,
}
}
}
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,
visible_count: 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 visible_count = match world.get::<ComputedRect>(entity).map(|r| r.0.h) {
Some(h) if h > Fixed::ZERO => {
((h / item_height).ceil().to_int().max(0) as u32 + 2).min(pool_size)
}
_ => 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,
visible_count,
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;
}
let active = (ctx.visible_count as usize).min(pool_size);
for i in 0..active {
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::ui::Hidden>(slot).is_some() {
world.remove::<crate::ui::Hidden>(slot);
any_changed = true;
}
let y = ctx.item_height * Fixed::from_int(target as i32);
if rebound {
crate::ui::set_position(world, slot, Fixed::ZERO, y);
} else {
crate::ui::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 active = (ctx.visible_count as usize).min(pool_size);
let mut all_bound = true;
for i in 0..active {
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 active = (ctx.visible_count as usize).min(pool_size);
let in_window = |row: u32| {
row >= ctx.visible_start && row < ctx.visible_start + active as u32 && row < ctx.item_count
};
let active_slot = |slot_idx: usize| {
(0..active).any(|i| {
let target = ctx.visible_start + i as u32;
target < ctx.item_count && (target as usize) % pool_size == slot_idx
})
};
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 => !active_slot(i),
n => !in_window(n),
};
if !needs_hide {
continue;
}
let slot = ctx.items[i];
if world.get::<crate::ui::Hidden>(slot).is_some() {
continue;
}
*bound = u32::MAX;
any_cleared = true;
world.insert(slot, crate::ui::Hidden);
}
if any_cleared {
if let Some(pool) = world.get_mut::<LazyListPool>(entity) {
pool.bound_indices = new_bindings;
}
}
any_cleared
}
pub fn view() -> crate::ui::view::View {
crate::ui::view::View::systems_only("LazyList", const { &[lazy_list_system::system()] })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Dimension;
use crate::ui::layout::{LayoutStyle, Position};
use crate::ui::{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_empty();
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::ui::Parent(list));
e
}
#[test]
fn first_tick_binds_all_slots_top_down() {
let mut world = World::default();
let list = world.spawn_empty();
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_empty();
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_empty();
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::ui::Hidden>(slot).is_none(),
"first 3 slots must be visible (bound)"
);
}
for &slot in &pool[3..] {
assert!(
world.get::<crate::ui::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_empty();
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::ui::Hidden>(pool[3]).is_some());
assert!(world.get::<crate::ui::Hidden>(pool[4]).is_some());
assert!(world.get::<crate::ui::Hidden>(pool[0]).is_none());
}
#[test]
fn growing_item_count_unhides_reused_slots() {
let mut world = World::default();
let list = world.spawn_empty();
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::ui::Hidden>(pool[3]).is_some());
assert!(world.get::<crate::ui::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::ui::Hidden>(slot).is_none(),
"all slots should be visible once item_count grows back",
);
}
}
fn set_list_height(world: &mut World, list: Entity, h: i32) {
world.insert(
list,
crate::ui::ComputedRect(crate::types::Rect {
x: Fixed::ZERO,
y: Fixed::ZERO,
w: Fixed::from_int(100),
h: Fixed::from_int(h),
}),
);
}
fn visible_count(world: &World, pool: &[Entity]) -> usize {
pool.iter()
.filter(|&&s| world.get::<crate::ui::Hidden>(s).is_none())
.count()
}
#[test]
fn visible_window_tracks_live_height() {
let mut world = World::default();
let list = world.spawn_empty();
let pool: Vec<Entity> = (0..12).map(|_| make_slot(&mut world, list)).collect();
world.insert(list, Widget);
world.insert(list, Style::default());
world.insert(list, LazyList::new(1000, 32, 12));
world.insert(list, LazyListPool::new(pool.clone()));
world.insert(
list,
LazyListBinder {
bind: recording_binder,
},
);
world.insert_resource(BindTrace(alloc::vec::Vec::new()));
set_list_height(&mut world, list, 64);
lazy_list_system(&mut world);
let narrow = visible_count(&world, &pool);
assert_eq!(narrow, 4, "short canvas binds ceil(h/item)+2 rows");
set_list_height(&mut world, list, 600);
lazy_list_system(&mut world);
let wide = visible_count(&world, &pool);
assert!(
wide > narrow,
"more rows must become visible as the list grows: {narrow} -> {wide}",
);
assert_eq!(wide, 12, "window is capped at the allocated pool");
}
}