Skip to main content

fui/node/
virtual_list.rs

1use super::*;
2use crate::bindings::ui;
3use crate::controls::selection_area;
4use crate::controls::SelectionArea;
5use crate::frame_scheduler::mark_needs_commit;
6use crate::signal::Subscription;
7use std::any::Any;
8use std::cell::{Cell, RefCell};
9use std::rc::Rc;
10
11const FULL_SIZE: f32 = 100.0;
12const DEFAULT_MAX_VISIBLE_ITEMS: i32 = 20;
13const POOL_OVERSCAN_ITEMS: i32 = 2;
14const MISSING_BIND_ITEM_MESSAGE: &str =
15    "VirtualList: item renderer not configured. Call .on_bind_item() after construction.";
16
17type VirtualListBinder = Rc<dyn Fn(usize, i32)>;
18
19struct VirtualListInner {
20    root: FlexBox,
21    total_items: Cell<i32>,
22    item_height: f32,
23    bind_item: RefCell<VirtualListBinder>,
24    scroll_state: ScrollState,
25    scroll_box: ScrollBox,
26    top_spacer: FlexBox,
27    bottom_spacer: FlexBox,
28    pool_size: i32,
29    pool_rows: Vec<SelectionArea>,
30    pool_containers: Vec<FlexBox>,
31    row_state_owner: RefCell<Option<Rc<dyn Any>>>,
32    pool_item_index_by_row: RefCell<Vec<i32>>,
33    subscriptions: RefCell<Vec<Subscription>>,
34    current_first_visible_index: Cell<i32>,
35    current_last_visible_index: Cell<i32>,
36}
37
38pub struct VirtualList<T = FlexBox> {
39    inner: Rc<VirtualListInner>,
40    row_states: Rc<Vec<T>>,
41}
42
43impl<T> Clone for VirtualList<T> {
44    fn clone(&self) -> Self {
45        Self {
46            inner: self.inner.clone(),
47            row_states: self.row_states.clone(),
48        }
49    }
50}
51
52impl VirtualList<FlexBox> {
53    pub fn new(total_items: i32, item_height: f32) -> Self {
54        Self::with_max_visible(total_items, item_height, DEFAULT_MAX_VISIBLE_ITEMS)
55    }
56
57    pub fn with_max_visible(total_items: i32, item_height: f32, max_visible: i32) -> Self {
58        let total_items = total_items.max(0);
59        let item_height = if item_height > 0.0 { item_height } else { 1.0 };
60        let pool_size = if max_visible > 0 {
61            max_visible + POOL_OVERSCAN_ITEMS
62        } else {
63            POOL_OVERSCAN_ITEMS
64        };
65        let scroll_state = ScrollState::new();
66        let top_spacer = flex_box();
67        top_spacer
68            .width(FULL_SIZE, Unit::Percent)
69            .height(0.0, Unit::Pixel);
70        let bottom_spacer = flex_box();
71        bottom_spacer
72            .width(FULL_SIZE, Unit::Percent)
73            .height(0.0, Unit::Pixel);
74        let content = column();
75        content.width(FULL_SIZE, Unit::Percent).child(&top_spacer);
76
77        let mut pool_rows = Vec::new();
78        let mut pool_containers = Vec::new();
79        let mut pool_item_index_by_row = Vec::new();
80        for _ in 0..pool_size {
81            let container = flex_box();
82            container.fill_size();
83            let row_area = selection_area();
84            row_area
85                .width(FULL_SIZE, Unit::Percent)
86                .height(0.0, Unit::Pixel)
87                .child(&container);
88            content.child(&row_area);
89            pool_rows.push(row_area);
90            pool_containers.push(container);
91            pool_item_index_by_row.push(-1);
92        }
93        content.child(&bottom_spacer);
94
95        let scroll_box = ScrollBox::with_parts(scroll_state.clone(), ScrollView::new());
96        scroll_box
97            .scroll_enabled_x(false)
98            .scroll_enabled_y(true)
99            .scroll_offset(scroll_state.offset_x(), scroll_state.offset_y())
100            .scroll_content_size(-1.0, total_items as f32 * item_height)
101            .fill_size()
102            .child(&content);
103        scroll_state.set_content_height(total_items as f32 * item_height);
104
105        let root = column();
106        root.fill_size().child(&scroll_box);
107
108        let inner = Rc::new(VirtualListInner {
109            root,
110            total_items: Cell::new(total_items),
111            item_height,
112            bind_item: RefCell::new(Rc::new(|_, _| panic!("{MISSING_BIND_ITEM_MESSAGE}"))),
113            scroll_state,
114            scroll_box,
115            top_spacer,
116            bottom_spacer,
117            pool_size,
118            pool_rows,
119            pool_containers: pool_containers.clone(),
120            row_state_owner: RefCell::new(None),
121            pool_item_index_by_row: RefCell::new(pool_item_index_by_row),
122            subscriptions: RefCell::new(Vec::new()),
123            current_first_visible_index: Cell::new(-1),
124            current_last_visible_index: Cell::new(-1),
125        });
126        let row_states = Rc::new(pool_containers);
127        inner
128            .row_state_owner
129            .replace(Some(row_states.clone() as Rc<dyn Any>));
130        let list = Self { inner, row_states };
131        list.attach_listeners();
132        list
133    }
134}
135
136impl<T: 'static> VirtualList<T> {
137    pub fn item_template<U: 'static>(self, template: impl Fn(&FlexBox) -> U) -> VirtualList<U> {
138        let row_states = Rc::new(
139            self.inner
140                .pool_containers
141                .iter()
142                .map(template)
143                .collect::<Vec<_>>(),
144        );
145        self.inner
146            .row_state_owner
147            .replace(Some(row_states.clone() as Rc<dyn Any>));
148        *self.inner.bind_item.borrow_mut() = Rc::new(|_, _| panic!("{MISSING_BIND_ITEM_MESSAGE}"));
149        self.inner.current_first_visible_index.set(-1);
150        self.inner.current_last_visible_index.set(-1);
151        self.inner.subscriptions.borrow_mut().clear();
152        let list = VirtualList {
153            inner: self.inner,
154            row_states,
155        };
156        list.attach_listeners();
157        list
158    }
159
160    pub fn scroll_state(&self) -> ScrollState {
161        self.inner.scroll_state.clone()
162    }
163
164    pub fn total_items(&self) -> i32 {
165        self.inner.total_items.get()
166    }
167
168    pub fn scroll_box(&self) -> ScrollBox {
169        self.inner.scroll_box.clone()
170    }
171
172    pub fn item_height(&self) -> f32 {
173        self.inner.item_height
174    }
175
176    pub fn total_content_height(&self) -> f32 {
177        self.total_items() as f32 * self.item_height()
178    }
179
180    pub fn first_visible_index(&self) -> i32 {
181        let current = self.inner.current_first_visible_index.get();
182        if current >= 0 {
183            current
184        } else {
185            0
186        }
187    }
188
189    pub fn rendered_item_count(&self) -> i32 {
190        let first = self.inner.current_first_visible_index.get();
191        let last = self.inner.current_last_visible_index.get();
192        if first < 0 || last < first {
193            0
194        } else {
195            last - first
196        }
197    }
198
199    pub fn is_selection_barrier(&self) -> bool {
200        true
201    }
202
203    pub fn render(&self) -> &Self {
204        self.rebuild_visible_range(true);
205        self
206    }
207
208    pub fn node_id(&self, id: impl Into<String>) -> &Self {
209        self.inner.scroll_box.node_id(id);
210        self
211    }
212
213    pub fn persist_scroll(&self, flag: bool) -> &Self {
214        self.inner.scroll_box.persist_scroll(flag);
215        self
216    }
217
218    pub fn width(&self, value: f32, unit: Unit) -> &Self {
219        self.inner.root.width(value, unit);
220        if unit == Unit::Pixel {
221            self.inner.scroll_state.set_viewport_width(value);
222        }
223        self
224    }
225
226    pub fn height(&self, value: f32, unit: Unit) -> &Self {
227        self.inner.root.height(value, unit);
228        if unit == Unit::Pixel {
229            self.inner.scroll_state.set_viewport_height(value);
230        }
231        self
232    }
233
234    pub fn on_bind_item(&self, renderer: impl Fn(&T, i32) + 'static) -> &Self {
235        let row_states = self.row_states.clone();
236        *self.inner.bind_item.borrow_mut() = Rc::new(move |pool_index, item_index| {
237            renderer(&row_states[pool_index], item_index);
238        });
239        self.inner.current_first_visible_index.set(-1);
240        self.inner.current_last_visible_index.set(-1);
241        self.rebuild_visible_range(true);
242        self
243    }
244
245    pub fn update_item_count(&self, next: i32) {
246        self.inner.total_items.set(next.max(0));
247        self.inner
248            .scroll_state
249            .set_content_height(self.total_content_height());
250        self.inner
251            .scroll_box
252            .scroll_content_size(-1.0, self.total_content_height());
253        self.inner.current_first_visible_index.set(-1);
254        self.inner.current_last_visible_index.set(-1);
255
256        let clamped_offset = self.max_offset_for_current_viewport();
257        if self.inner.scroll_state.offset_y() > clamped_offset {
258            self.inner
259                .scroll_box
260                .scroll_offset(self.inner.scroll_state.offset_x(), clamped_offset);
261            return;
262        }
263        self.rebuild_visible_range(true);
264    }
265
266    fn attach_listeners(&self) {
267        let weak = Rc::downgrade(&self.inner);
268        let weak_row_states = Rc::downgrade(&self.row_states);
269        self.inner
270            .subscriptions
271            .borrow_mut()
272            .push(self.inner.scroll_state.subscribe_offset_y(move || {
273                if let (Some(inner), Some(row_states)) = (weak.upgrade(), weak_row_states.upgrade())
274                {
275                    VirtualList { inner, row_states }.handle_scroll_offset_changed();
276                }
277            }));
278        let weak = Rc::downgrade(&self.inner);
279        let weak_row_states = Rc::downgrade(&self.row_states);
280        self.inner.subscriptions.borrow_mut().push(
281            self.inner.scroll_state.subscribe_viewport_height(move || {
282                if let (Some(inner), Some(row_states)) = (weak.upgrade(), weak_row_states.upgrade())
283                {
284                    VirtualList { inner, row_states }.handle_metrics_changed();
285                }
286            }),
287        );
288        let weak = Rc::downgrade(&self.inner);
289        let weak_row_states = Rc::downgrade(&self.row_states);
290        self.inner.subscriptions.borrow_mut().push(
291            self.inner.scroll_state.subscribe_content_height(move || {
292                if let (Some(inner), Some(row_states)) = (weak.upgrade(), weak_row_states.upgrade())
293                {
294                    VirtualList { inner, row_states }.handle_metrics_changed();
295                }
296            }),
297        );
298    }
299
300    fn handle_metrics_changed(&self) {
301        self.rebuild_visible_range(true);
302    }
303
304    fn handle_scroll_offset_changed(&self) {
305        self.rebuild_visible_range(true);
306    }
307
308    fn rebuild_visible_range(&self, commit: bool) {
309        let total_items = self.total_items();
310        if total_items <= 0 {
311            if self.inner.current_first_visible_index.get() == 0
312                && self.inner.current_last_visible_index.get() == 0
313            {
314                return;
315            }
316            self.inner.current_first_visible_index.set(0);
317            self.inner.current_last_visible_index.set(0);
318            self.inner.top_spacer.height(0.0, Unit::Pixel);
319            self.inner.bottom_spacer.height(0.0, Unit::Pixel);
320            for pool_index in 0..self.inner.pool_size as usize {
321                self.hide_pool_item(pool_index);
322            }
323            self.commit_if_built(commit);
324            return;
325        }
326
327        let mut first_visible_index =
328            (self.inner.scroll_state.offset_y() / self.inner.item_height).floor() as i32;
329        if first_visible_index < 0 {
330            first_visible_index = 0;
331        }
332        if first_visible_index > total_items {
333            first_visible_index = total_items;
334        }
335
336        let viewport_height = if self.inner.scroll_state.viewport_height() > 0.0 {
337            self.inner.scroll_state.viewport_height()
338        } else {
339            self.inner.item_height
340        };
341        let mut visible_count = (viewport_height / self.inner.item_height).ceil() as i32 + 1;
342        if visible_count < 1 {
343            visible_count = 1;
344        }
345        if visible_count > self.inner.pool_size {
346            visible_count = self.inner.pool_size;
347        }
348
349        let mut last_visible_index = first_visible_index + visible_count;
350        if last_visible_index > total_items {
351            last_visible_index = total_items;
352        }
353
354        if first_visible_index == self.inner.current_first_visible_index.get()
355            && last_visible_index == self.inner.current_last_visible_index.get()
356        {
357            return;
358        }
359
360        self.inner
361            .current_first_visible_index
362            .set(first_visible_index);
363        self.inner
364            .current_last_visible_index
365            .set(last_visible_index);
366
367        let top_spacer_height = first_visible_index as f32 * self.inner.item_height;
368        let mut bottom_spacer_height =
369            self.total_content_height() - (last_visible_index as f32 * self.inner.item_height);
370        if bottom_spacer_height < 0.0 {
371            bottom_spacer_height = 0.0;
372        }
373
374        self.inner.top_spacer.height(top_spacer_height, Unit::Pixel);
375        self.inner
376            .bottom_spacer
377            .height(bottom_spacer_height, Unit::Pixel);
378
379        let previous_item_index_by_row = self.inner.pool_item_index_by_row.borrow().clone();
380        let visible_items = (last_visible_index - first_visible_index) as usize;
381
382        for (pool_index, previous_item_index) in previous_item_index_by_row
383            .iter()
384            .copied()
385            .enumerate()
386            .take(self.inner.pool_size as usize)
387        {
388            if previous_item_index != -1
389                && (previous_item_index < first_visible_index
390                    || previous_item_index >= last_visible_index)
391            {
392                self.clear_row_selection(pool_index);
393            }
394        }
395
396        for pool_index in 0..self.inner.pool_size as usize {
397            let row_area = &self.inner.pool_rows[pool_index];
398            if pool_index < visible_items {
399                let next_item_index = first_visible_index + pool_index as i32;
400                row_area.height(self.inner.item_height, Unit::Pixel);
401                self.render_item(pool_index, next_item_index);
402                self.inner.pool_item_index_by_row.borrow_mut()[pool_index] = next_item_index;
403            } else {
404                self.hide_pool_item(pool_index);
405            }
406        }
407
408        for pool_index in 0..visible_items {
409            let next_item_index = first_visible_index + pool_index as i32;
410            let previous_pool_index =
411                Self::find_pool_index_for_item(&previous_item_index_by_row, next_item_index);
412            if previous_pool_index != -1 && previous_pool_index as usize != pool_index {
413                self.retarget_row_selection(previous_pool_index as usize, pool_index);
414            }
415        }
416
417        self.commit_if_built(commit);
418    }
419
420    fn hide_pool_item(&self, pool_index: usize) {
421        if self.inner.pool_item_index_by_row.borrow()[pool_index] != -1 {
422            self.clear_row_selection(pool_index);
423            self.inner.pool_item_index_by_row.borrow_mut()[pool_index] = -1;
424        }
425        self.inner.pool_rows[pool_index].height(0.0, Unit::Pixel);
426        self.clear_item_node(&self.inner.pool_containers[pool_index].retained_node_ref());
427    }
428
429    fn clear_row_selection(&self, pool_index: usize) {
430        if !self.has_built_handle() {
431            return;
432        }
433        self.clear_selection_node(&self.inner.pool_rows[pool_index].retained_node_ref());
434    }
435
436    fn retarget_row_selection(&self, from_pool_index: usize, to_pool_index: usize) {
437        if !self.has_built_handle() {
438            return;
439        }
440        let mut from_texts = Vec::new();
441        let mut to_texts = Vec::new();
442        self.collect_text_nodes(
443            &self.inner.pool_rows[from_pool_index].retained_node_ref(),
444            &mut from_texts,
445        );
446        self.collect_text_nodes(
447            &self.inner.pool_rows[to_pool_index].retained_node_ref(),
448            &mut to_texts,
449        );
450        let pair_count = from_texts.len().min(to_texts.len());
451        for index in 0..pair_count {
452            let from_text = from_texts[index];
453            let to_text = to_texts[index];
454            if from_text != NodeHandle::INVALID
455                && to_text != NodeHandle::INVALID
456                && from_text != to_text
457            {
458                ui::retarget_selection(from_text.raw(), to_text.raw());
459            }
460        }
461    }
462
463    fn collect_text_nodes(&self, node: &NodeRef, out: &mut Vec<NodeHandle>) {
464        if node.text_content_for_routing().is_some() {
465            out.push(node.handle());
466            return;
467        }
468        for child in node.children() {
469            self.collect_text_nodes(&child, out);
470        }
471    }
472
473    fn clear_selection_node(&self, node: &NodeRef) {
474        if node.text_content_for_routing().is_some() {
475            let handle = node.handle();
476            if handle != NodeHandle::INVALID {
477                ui::clear_selection(handle.raw());
478            }
479            return;
480        }
481        for child in node.children() {
482            self.clear_selection_node(&child);
483        }
484    }
485
486    fn clear_item_node(&self, node: &NodeRef) {
487        node.set_semantic_label_for_routing(Some(String::new()));
488        let handle = node.handle();
489        if handle != NodeHandle::INVALID {
490            ui::set_semantic_label(handle.raw(), "");
491        }
492        if node.text_content_for_routing().is_some() {
493            node.set_text_content_for_routing(Some(String::new()));
494            if handle != NodeHandle::INVALID {
495                ui::set_text(handle.raw(), "");
496            }
497        }
498        for child in node.children() {
499            self.clear_item_node(&child);
500        }
501    }
502
503    fn commit_if_built(&self, commit: bool) {
504        if !commit || !self.has_built_handle() {
505            return;
506        }
507        mark_needs_commit();
508    }
509
510    fn render_item(&self, pool_index: usize, index: i32) {
511        let binder = self.inner.bind_item.borrow().clone();
512        binder(pool_index, index);
513    }
514
515    fn max_offset_for_current_viewport(&self) -> f32 {
516        let viewport_height = if self.inner.scroll_state.viewport_height() > 0.0 {
517            self.inner.scroll_state.viewport_height()
518        } else {
519            0.0
520        };
521        let max_offset = self.total_content_height() - viewport_height;
522        if max_offset > 0.0 {
523            max_offset
524        } else {
525            0.0
526        }
527    }
528
529    fn find_pool_index_for_item(previous_item_index_by_row: &[i32], item_index: i32) -> i32 {
530        previous_item_index_by_row
531            .iter()
532            .position(|candidate| *candidate == item_index)
533            .map(|index| index as i32)
534            .unwrap_or(-1)
535    }
536}
537
538impl<T: 'static> Node for VirtualList<T> {
539    fn retained_node_ref(&self) -> NodeRef {
540        let list = self.clone();
541        self.inner
542            .root
543            .retained_node_ref()
544            .with_build_callback(move || list.build())
545    }
546
547    fn build(&self) {
548        self.ensure_handle();
549        self.inner.root.build_self();
550        self.build_children();
551        if self.inner.current_first_visible_index.get() < 0 && self.total_items() > 0 {
552            self.rebuild_visible_range(false);
553        }
554        ui::set_selection_area_barrier(self.handle().raw(), true);
555    }
556
557    fn build_self(&self) {
558        self.inner.root.build_self();
559    }
560
561    fn dispose(&self) {
562        self.inner.subscriptions.borrow_mut().clear();
563        self.inner.root.dispose();
564    }
565}
566
567impl<T: 'static> HasFlexBoxRoot for VirtualList<T> {
568    fn flex_box_root(&self) -> &FlexBox {
569        &self.inner.root
570    }
571
572    fn set_flex_box_surface_width(&self, value: f32, unit: Unit) {
573        self.width(value, unit);
574    }
575
576    fn set_flex_box_surface_height(&self, value: f32, unit: Unit) {
577        self.height(value, unit);
578    }
579}
580
581impl<T: 'static> ThemeBindable for VirtualList<T> {
582    fn theme_binding_node(&self) -> NodeRef {
583        self.inner.root.retained_node_ref()
584    }
585
586    fn weak_theme_target(&self) -> Box<dyn Fn() -> Option<Self>> {
587        let weak = Rc::downgrade(&self.inner);
588        let weak_row_states = Rc::downgrade(&self.row_states);
589        Box::new(move || {
590            Some(VirtualList {
591                inner: weak.upgrade()?,
592                row_states: weak_row_states.upgrade()?,
593            })
594        })
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::ffi::{self, Call};
602    use crate::prelude::*;
603    use std::collections::HashMap;
604
605    fn tracked_bind_virtual_list_item(
606        rendered_indices: &Rc<RefCell<Vec<i32>>>,
607        labels: &Rc<RefCell<HashMap<usize, TextNode>>>,
608        container: &FlexBox,
609        index: i32,
610    ) {
611        rendered_indices.borrow_mut().push(index);
612        let key = std::ptr::from_ref(container) as usize;
613        let existing = { labels.borrow().get(&key).cloned() };
614        let label = if let Some(existing) = existing {
615            existing
616        } else {
617            let label = text("");
618            container.child(&label);
619            labels.borrow_mut().insert(key, label.clone());
620            label
621        };
622        let text_value = format!("Item {}", index);
623        label.text(&text_value);
624        label.semantic_label(&text_value);
625    }
626
627    fn static_bind_virtual_list_item(
628        labels: &Rc<RefCell<HashMap<usize, TextNode>>>,
629        container: &FlexBox,
630        index: i32,
631    ) {
632        let key = std::ptr::from_ref(container) as usize;
633        let existing = { labels.borrow().get(&key).cloned() };
634        let label = if let Some(existing) = existing {
635            existing
636        } else {
637            let label = text("");
638            container.child(&label);
639            labels.borrow_mut().insert(key, label.clone());
640            label
641        };
642        label.text(format!("row {}", index));
643    }
644
645    #[test]
646    fn binds_only_the_visible_window_for_a_fixed_height_list() {
647        ffi::test::reset();
648        let rendered_indices = Rc::new(RefCell::new(Vec::new()));
649        let labels = Rc::new(RefCell::new(HashMap::new()));
650        let list = VirtualList::new(10_000, 20.0);
651        let captured_indices = rendered_indices.clone();
652        let captured_labels = labels.clone();
653        list.on_bind_item(move |container, index| {
654            tracked_bind_virtual_list_item(&captured_indices, &captured_labels, container, index);
655        });
656        list.width(180.0, Unit::Pixel);
657        list.height(100.0, Unit::Pixel);
658
659        list.build();
660
661        let rendered = rendered_indices.borrow();
662        let last_window_start = rendered.len() - 6;
663        assert_eq!(rendered[last_window_start], 0);
664        assert_eq!(rendered[last_window_start + 5], 5);
665        assert_eq!(list.first_visible_index(), 0);
666        assert_eq!(list.rendered_item_count(), 6);
667        list.dispose();
668    }
669
670    #[test]
671    fn rebinds_pooled_rows_without_recreating_nodes_when_scroll_offset_changes() {
672        ffi::test::reset();
673        let rendered_indices = Rc::new(RefCell::new(Vec::new()));
674        let labels = Rc::new(RefCell::new(HashMap::new()));
675        let list = VirtualList::new(10_000, 20.0);
676        let captured_indices = rendered_indices.clone();
677        let captured_labels = labels.clone();
678        list.on_bind_item(move |container, index| {
679            tracked_bind_virtual_list_item(&captured_indices, &captured_labels, container, index);
680        });
681        list.width(180.0, Unit::Pixel);
682        list.height(100.0, Unit::Pixel);
683        Application::mount(list.clone());
684
685        rendered_indices.borrow_mut().clear();
686        ffi::test::reset();
687        list.scroll_state().set_offset_y(60.0);
688
689        let rendered = rendered_indices.borrow();
690        assert_eq!(rendered.len(), 6);
691        assert_eq!(rendered[0], 3);
692        assert_eq!(rendered[5], 8);
693        assert_eq!(list.first_visible_index(), 3);
694        let calls = ffi::test::take_calls();
695        assert!(!calls
696            .iter()
697            .any(|call| matches!(call, Call::CreateNode { .. })));
698        assert!(!calls
699            .iter()
700            .any(|call| matches!(call, Call::DeleteNode { .. })));
701        assert!(calls
702            .iter()
703            .any(|call| matches!(call, Call::SetText { text, .. } if text == "Item 3")));
704        assert!(calls.iter().any(|call| {
705            matches!(
706                call,
707                Call::SetHeight {
708                    value,
709                    unit_enum,
710                    ..
711                } if *value == 20.0 && *unit_enum == Unit::Pixel as u32
712            )
713        }));
714        Application::unmount();
715    }
716
717    #[test]
718    fn tracks_content_height_and_clamps_the_visible_window_when_item_count_changes() {
719        ffi::test::reset();
720        let labels = Rc::new(RefCell::new(HashMap::new()));
721        let list = VirtualList::new(10_000, 24.0);
722        let captured_labels = labels.clone();
723        list.on_bind_item(move |container, index| {
724            static_bind_virtual_list_item(&captured_labels, container, index);
725        });
726        list.height(120.0, Unit::Pixel);
727
728        assert_eq!(list.scroll_state().content_height(), 240_000.0);
729        assert_eq!(list.rendered_item_count(), 6);
730
731        list.update_item_count(5);
732
733        assert_eq!(list.scroll_state().content_height(), 120.0);
734        assert_eq!(list.rendered_item_count(), 5);
735        list.dispose();
736    }
737
738    #[test]
739    fn supports_owner_captured_renderers_with_on_bind_item() {
740        ffi::test::reset();
741        let rendered_indices = Rc::new(RefCell::new(Vec::new()));
742        let labels = Rc::new(RefCell::new(HashMap::new()));
743        let list = VirtualList::new(10_000, 20.0);
744        let captured_indices = rendered_indices.clone();
745        let captured_labels = labels.clone();
746        list.on_bind_item(move |container, index| {
747            tracked_bind_virtual_list_item(&captured_indices, &captured_labels, container, index);
748        });
749        list.width(180.0, Unit::Pixel);
750        list.height(100.0, Unit::Pixel);
751
752        list.build();
753
754        let rendered = rendered_indices.borrow();
755        assert!(rendered.len() >= 6);
756        assert_eq!(rendered[0], 0);
757        assert_eq!(rendered[rendered.len() - 1], 5);
758        list.dispose();
759    }
760
761    #[test]
762    fn typed_item_template_creates_one_retained_state_per_pool_row_and_rebinds_it() {
763        #[derive(Clone)]
764        struct Row {
765            label: TextNode,
766        }
767
768        ffi::test::reset();
769        let template_count = Rc::new(Cell::new(0));
770        let captured_template_count = template_count.clone();
771        let list = VirtualList::with_max_visible(10_000, 20.0, 4).item_template(move |container| {
772            captured_template_count.set(captured_template_count.get() + 1);
773            let label = text("");
774            container.child(&label);
775            Row { label }
776        });
777        let bound_indices = Rc::new(RefCell::new(Vec::new()));
778        let captured_indices = bound_indices.clone();
779        list.on_bind_item(move |row, index| {
780            captured_indices.borrow_mut().push(index);
781            row.label.text(format!("typed item {index}"));
782        });
783        list.height(60.0, Unit::Pixel);
784        Application::mount(list.clone());
785
786        assert_eq!(template_count.get(), 6);
787        bound_indices.borrow_mut().clear();
788        ffi::test::reset();
789        list.scroll_state().set_offset_y(40.0);
790
791        assert_eq!(&*bound_indices.borrow(), &[2, 3, 4, 5]);
792        assert!(!ffi::test::take_calls()
793            .iter()
794            .any(|call| matches!(call, Call::CreateNode { .. } | Call::DeleteNode { .. })));
795        Application::unmount();
796    }
797}