Skip to main content

gpui_base/
virtual_list.rs

1//! Virtual List for render a large number of differently sized rows/columns.
2//!
3//! > NOTE: This must ensure each column width or row height.
4//!
5//! Only visible range are rendered for performance reasons.
6//!
7//! Inspired by `gpui::uniform_list`.
8//! https://github.com/zed-industries/zed/blob/0ae1603610ab6b265bdfbee7b8dbc23c5ab06edc/crates/gpui/src/elements/uniform_list.rs
9//!
10//! Unlike the `uniform_list`, the each item can have different size.
11//!
12//! This is useful for more complex layout, for example, a table with different row height.
13use std::{
14    cell::RefCell,
15    ops::{Deref, Range},
16    rc::Rc,
17};
18
19use gpui::{
20    Along, AnyElement, App, AvailableSpace, Axis, Bounds, ContentMask, Context,
21    DeferredScrollToItem, Div, Element, ElementId, Entity, GlobalElementId, Half, Hitbox,
22    InteractiveElement, IntoElement, IsZero as _, ListSizingBehavior, Pixels, Point, Render,
23    ScrollHandle, ScrollStrategy, Size, Stateful, StatefulInteractiveElement, StyleRefinement,
24    Styled, Window, div, point, px, size,
25};
26use smallvec::SmallVec;
27
28use crate::{AxisExt, InteractiveElementExt as _};
29
30struct VirtualListScrollHandleState {
31    axis: Axis,
32    items_count: usize,
33    /// Content bounds size (excluding padding and border) from the last frame.
34    last_content_size: Option<Size<Pixels>>,
35    pub deferred_scroll_to_item: Option<DeferredScrollToItem>,
36}
37
38/// A scroll handle for [`VirtualList`].
39///
40/// See also [`ScrollHandle`].
41#[derive(Clone)]
42pub struct VirtualListScrollHandle {
43    state: Rc<RefCell<VirtualListScrollHandleState>>,
44    base_handle: ScrollHandle,
45}
46
47impl From<ScrollHandle> for VirtualListScrollHandle {
48    fn from(handle: ScrollHandle) -> Self {
49        let mut this = VirtualListScrollHandle::new();
50        this.base_handle = handle;
51        this
52    }
53}
54
55impl AsRef<ScrollHandle> for VirtualListScrollHandle {
56    fn as_ref(&self) -> &ScrollHandle {
57        &self.base_handle
58    }
59}
60
61impl crate::ScrollbarHandle for VirtualListScrollHandle {
62    fn viewport_bounds(&self) -> Bounds<Pixels> {
63        self.base_handle.bounds()
64    }
65
66    fn offset(&self) -> Point<Pixels> {
67        self.base_handle.offset()
68    }
69
70    fn set_offset(&self, offset: Point<Pixels>) {
71        self.base_handle.set_offset(offset);
72    }
73
74    fn content_size(&self) -> Size<Pixels> {
75        self.base_handle.content_size()
76    }
77}
78
79impl Deref for VirtualListScrollHandle {
80    type Target = ScrollHandle;
81
82    fn deref(&self) -> &Self::Target {
83        &self.base_handle
84    }
85}
86
87impl VirtualListScrollHandle {
88    /// Create a new VirtualListScrollHandle.
89    pub fn new() -> Self {
90        VirtualListScrollHandle {
91            state: Rc::new(RefCell::new(VirtualListScrollHandleState {
92                axis: Axis::Vertical,
93                items_count: 0,
94                last_content_size: None,
95                deferred_scroll_to_item: None,
96            })),
97            base_handle: ScrollHandle::default(),
98        }
99    }
100
101    /// Get the base scroll handle.
102    pub fn base_handle(&self) -> &ScrollHandle {
103        &self.base_handle
104    }
105
106    /// Scroll to the item at the given index.
107    pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
108        self.scroll_to_item_with_offset(ix, strategy, 0);
109    }
110
111    /// Scroll to the item at the given index, with an additional offset items.
112    fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) {
113        let mut state = self.state.borrow_mut();
114        state.deferred_scroll_to_item = Some(DeferredScrollToItem {
115            item_index: ix,
116            strategy,
117            offset,
118            scroll_strict: false,
119        });
120    }
121
122    /// Scrolls to the bottom of the list.
123    pub fn scroll_to_bottom(&self) {
124        let items_count = self.state.borrow().items_count;
125        self.scroll_to_item(items_count.saturating_sub(1), ScrollStrategy::Top);
126    }
127}
128
129/// Create a [`VirtualList`] in vertical direction.
130///
131/// This is like `uniform_list` in GPUI, but support two axis.
132///
133/// The `item_sizes` is the size of each row. Only the `height` is used; `width` is inferred
134/// by measuring the item selected with [`VirtualList::with_item_to_measure_index`], defaulting
135/// to the first item.
136///
137/// See also [`h_virtual_list`]
138#[inline]
139pub fn v_virtual_list<R, V>(
140    view: Entity<V>,
141    id: impl Into<ElementId>,
142    item_sizes: Rc<Vec<Size<Pixels>>>,
143    f: impl 'static + Fn(&mut V, Range<usize>, &mut Window, &mut Context<V>) -> Vec<R>,
144) -> VirtualList
145where
146    R: IntoElement,
147    V: Render,
148{
149    virtual_list(view, id, Axis::Vertical, item_sizes, f)
150}
151
152/// Create a [`VirtualList`] in horizontal direction.
153///
154/// The `item_sizes` is the size of each column. Only the `width` is used; `height` is inferred
155/// by measuring the item selected with [`VirtualList::with_item_to_measure_index`], defaulting
156/// to the first item.
157///
158/// See also [`v_virtual_list`]
159#[inline]
160pub fn h_virtual_list<R, V>(
161    view: Entity<V>,
162    id: impl Into<ElementId>,
163    item_sizes: Rc<Vec<Size<Pixels>>>,
164    f: impl 'static + Fn(&mut V, Range<usize>, &mut Window, &mut Context<V>) -> Vec<R>,
165) -> VirtualList
166where
167    R: IntoElement,
168    V: Render,
169{
170    virtual_list(view, id, Axis::Horizontal, item_sizes, f)
171}
172
173#[doc(hidden)]
174pub fn virtual_list<R, V>(
175    view: Entity<V>,
176    id: impl Into<ElementId>,
177    axis: Axis,
178    item_sizes: Rc<Vec<Size<Pixels>>>,
179    f: impl 'static + Fn(&mut V, Range<usize>, &mut Window, &mut Context<V>) -> Vec<R>,
180) -> VirtualList
181where
182    R: IntoElement,
183    V: Render,
184{
185    let id: ElementId = id.into();
186    let scroll_handle = VirtualListScrollHandle::new();
187    let render_range = move |visible_range, window: &mut Window, cx: &mut App| {
188        view.update(cx, |this, cx| {
189            f(this, visible_range, window, cx)
190                .into_iter()
191                .map(|component| component.into_any_element())
192                .collect()
193        })
194    };
195
196    VirtualList {
197        id: id.clone(),
198        axis,
199        base: div()
200            .id(id)
201            .size_full()
202            .overflow_scroll()
203            .lock_scroll_axis()
204            .track_scroll(&scroll_handle),
205        scroll_handle,
206        items_count: item_sizes.len(),
207        item_sizes,
208        render_items: Box::new(render_range),
209        sizing_behavior: ListSizingBehavior::default(),
210        item_to_measure_index: 0,
211    }
212}
213
214/// VirtualList component for rendering a large number of differently sized items.
215pub struct VirtualList {
216    id: ElementId,
217    axis: Axis,
218    base: Stateful<Div>,
219    scroll_handle: VirtualListScrollHandle,
220    items_count: usize,
221    item_sizes: Rc<Vec<Size<Pixels>>>,
222    render_items: Box<
223        dyn for<'a> Fn(Range<usize>, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>,
224    >,
225    sizing_behavior: ListSizingBehavior,
226    item_to_measure_index: usize,
227}
228
229impl Styled for VirtualList {
230    fn style(&mut self) -> &mut StyleRefinement {
231        self.base.style()
232    }
233}
234
235impl VirtualList {
236    pub fn track_scroll(mut self, scroll_handle: &VirtualListScrollHandle) -> Self {
237        self.base = self.base.track_scroll(&scroll_handle);
238        self.scroll_handle = scroll_handle.clone();
239        self
240    }
241
242    /// Set the sizing behavior for the list.
243    pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
244        self.sizing_behavior = behavior;
245        self
246    }
247
248    /// Set the item index used to infer the list's cross-axis size.
249    pub fn with_item_to_measure_index(mut self, index: usize) -> Self {
250        self.item_to_measure_index = index;
251        self
252    }
253
254    /// Specify for table.
255    ///
256    /// Table is special, because the `scroll_handle` is based on Table head (That is not a virtual list).
257    #[doc(hidden)]
258    pub fn with_scroll_handle(mut self, scroll_handle: &VirtualListScrollHandle) -> Self {
259        self.base = div().id(self.id.clone()).size_full();
260        self.scroll_handle = scroll_handle.clone();
261        self
262    }
263
264    fn scroll_to_deferred_item(
265        &self,
266        scroll_offset: Point<Pixels>,
267        size_layout: &ItemSizeLayout,
268        content_bounds: &Bounds<Pixels>,
269        scroll_to_item: DeferredScrollToItem,
270    ) -> Point<Pixels> {
271        let Some(bounds) = size_layout.item_bounds(
272            scroll_to_item.item_index + scroll_to_item.offset,
273            self.axis,
274            content_bounds,
275        ) else {
276            return scroll_offset;
277        };
278
279        let mut scroll_offset = scroll_offset;
280        match scroll_to_item.strategy {
281            ScrollStrategy::Center => {
282                if self.axis.is_vertical() {
283                    scroll_offset.y = content_bounds.top() + content_bounds.size.height.half()
284                        - bounds.top()
285                        - bounds.size.height.half()
286                } else {
287                    scroll_offset.x = content_bounds.left() + content_bounds.size.width.half()
288                        - bounds.left()
289                        - bounds.size.width.half()
290                }
291            }
292            _ => {
293                // Ref: https://github.com/zed-industries/zed/blob/0d145289e0867a8d5d63e5e1397a5ca69c9d49c3/crates/gpui/src/elements/div.rs#L3026
294                if self.axis.is_vertical() {
295                    if bounds.top() + scroll_offset.y < content_bounds.top() {
296                        scroll_offset.y = content_bounds.top() - bounds.top()
297                    } else if bounds.bottom() + scroll_offset.y > content_bounds.bottom() {
298                        scroll_offset.y = content_bounds.bottom() - bounds.bottom();
299                    }
300                } else {
301                    if bounds.left() + scroll_offset.x < content_bounds.left() {
302                        scroll_offset.x = content_bounds.left() - bounds.left();
303                    } else if bounds.right() + scroll_offset.x > content_bounds.right() {
304                        scroll_offset.x = content_bounds.right() - bounds.right();
305                    }
306                }
307            }
308        }
309        self.scroll_handle.set_offset(scroll_offset);
310        scroll_offset
311    }
312
313    /// Ref from: https://github.com/zed-industries/zed/blob/83f9f9d9e3f5914392cab9a09e3472711a1d7b38/crates/gpui/src/elements/uniform_list.rs#L660
314    fn measure_item(
315        &self,
316        list_width: Option<Pixels>,
317        window: &mut Window,
318        cx: &mut App,
319    ) -> Size<Pixels> {
320        if self.items_count == 0 {
321            return Size::default();
322        }
323
324        let item_ix = self.item_to_measure_index.min(self.items_count - 1);
325        let mut items = (self.render_items)(item_ix..item_ix + 1, window, cx);
326        let Some(mut item_to_measure) = items.pop() else {
327            return Size::default();
328        };
329        let available_space = size(
330            list_width.map_or(AvailableSpace::MinContent, |width| {
331                AvailableSpace::Definite(width)
332            }),
333            AvailableSpace::MinContent,
334        );
335        item_to_measure.layout_as_root(available_space, window, cx)
336    }
337}
338
339/// Frame state used by the [VirtualItem].
340pub struct VirtualListFrameState {
341    /// Visible items to be painted.
342    items: SmallVec<[AnyElement; 32]>,
343    size_layout: ItemSizeLayout,
344}
345
346/// Per-item sizes along the list axis, gap included, and their prefix sums.
347///
348/// Shared between the element state and the frame state so that carrying
349/// them across a frame is a reference count, not a copy of every item.
350#[derive(Default, Clone)]
351pub struct ItemSizeLayout {
352    items_sizes: Rc<Vec<Size<Pixels>>>,
353    axis_and_gap: Option<(Axis, Pixels)>,
354    content_size: Size<Pixels>,
355    sizes: Rc<[Pixels]>,
356    origins: Rc<[Pixels]>,
357    last_layout_bounds: Bounds<Pixels>,
358}
359
360impl ItemSizeLayout {
361    /// The bounds of item `ix` in the list's content space: offset from the
362    /// content origin along the list axis, filling it on the other.
363    fn item_bounds(
364        &self,
365        ix: usize,
366        axis: Axis,
367        content_bounds: &Bounds<Pixels>,
368    ) -> Option<Bounds<Pixels>> {
369        let origin = *self.origins.get(ix)?;
370        let item_size = self.sizes[ix];
371        Some(match axis {
372            Axis::Horizontal => Bounds {
373                origin: point(content_bounds.left() + origin, px(0.)),
374                size: size(item_size, content_bounds.size.height),
375            },
376            Axis::Vertical => Bounds {
377                origin: point(px(0.), content_bounds.top() + origin),
378                size: size(content_bounds.size.width, item_size),
379            },
380        })
381    }
382}
383
384/// The items that intersect `viewport` along the list axis, plus one item of
385/// overdraw past its end, given each item's origin and size (gap included).
386///
387/// Both edges are binary searches.
388fn visible_range(origins: &[Pixels], sizes: &[Pixels], viewport: Range<Pixels>) -> Range<usize> {
389    let count = origins.len();
390    // Item ends grow with the index, so the first item ending past `edge` is
391    // a partition point over the indices.
392    let ends_before = |edge: Pixels| {
393        let mut low = 0;
394        let mut high = count;
395        while low < high {
396            let mid = low + (high - low) / 2;
397            if origins[mid] + sizes[mid] <= edge {
398                low = mid + 1;
399            } else {
400                high = mid;
401            }
402        }
403        low
404    };
405    let first = ends_before(viewport.start);
406    let past_end = ends_before(viewport.end);
407    let last = if past_end == count {
408        count
409    } else {
410        (past_end + 2).min(count)
411    };
412    first..last.max(first)
413}
414
415impl IntoElement for VirtualList {
416    type Element = Self;
417
418    fn into_element(self) -> Self::Element {
419        self
420    }
421}
422
423impl Element for VirtualList {
424    type RequestLayoutState = VirtualListFrameState;
425    type PrepaintState = Option<Hitbox>;
426
427    fn id(&self) -> Option<ElementId> {
428        Some(self.id.clone())
429    }
430
431    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
432        None
433    }
434
435    fn request_layout(
436        &mut self,
437        global_id: Option<&GlobalElementId>,
438        inspector_id: Option<&gpui::InspectorElementId>,
439        window: &mut Window,
440        cx: &mut App,
441    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
442        let rem_size = window.rem_size();
443        let font_size = window.text_style().font_size.to_pixels(rem_size);
444        let mut size_layout = ItemSizeLayout::default();
445        // Measure vertical lists at the last known content width so relative
446        // widths and text truncation resolve like the visible rows; an
447        // unconstrained measure would turn one long non-wrapping text into a
448        // phantom horizontal scroll range.
449        let list_width = if self.axis.is_vertical() {
450            self.scroll_handle
451                .state
452                .borrow()
453                .last_content_size
454                .map(|size| size.width)
455                .filter(|width| !width.is_zero())
456        } else {
457            None
458        };
459        let longest_item_size = self.measure_item(list_width, window, cx);
460
461        let layout_id = self.base.interactivity().request_layout(
462            global_id,
463            inspector_id,
464            window,
465            cx,
466            |style, window, cx| {
467                size_layout = window.with_element_state(
468                    global_id.unwrap(),
469                    |state: Option<ItemSizeLayout>, _window| {
470                        let mut state = state.unwrap_or(ItemSizeLayout::default());
471
472                        // Including the gap between items for calculate the item size
473                        let gap = style
474                            .gap
475                            .along(self.axis)
476                            .to_pixels(font_size.into(), rem_size);
477
478                        if state.items_sizes != self.item_sizes
479                            || state.axis_and_gap != Some((self.axis, gap))
480                        {
481                            state.items_sizes = self.item_sizes.clone();
482                            state.axis_and_gap = Some((self.axis, gap));
483                            // Prepare each item's size by axis
484                            state.sizes = self
485                                .item_sizes
486                                .iter()
487                                .enumerate()
488                                .map(|(i, size)| {
489                                    let size = size.along(self.axis);
490                                    if i + 1 == self.items_count {
491                                        size
492                                    } else {
493                                        size + gap
494                                    }
495                                })
496                                .collect();
497
498                            // Prepare each item's origin by axis
499                            let mut cumulative = px(0.);
500                            state.origins = state
501                                .sizes
502                                .iter()
503                                .map(|size| {
504                                    let origin = cumulative;
505                                    cumulative += *size;
506                                    origin
507                                })
508                                .collect();
509
510                            if self.axis.is_horizontal() {
511                                state.content_size.width = cumulative;
512                            } else {
513                                state.content_size.height = cumulative;
514                            }
515                        }
516
517                        if self.axis.is_horizontal() {
518                            state.content_size.height = longest_item_size.height;
519                        } else {
520                            state.content_size.width = longest_item_size.width;
521                        }
522
523                        (state.clone(), state)
524                    },
525                );
526
527                let axis = self.axis;
528                let layout_id =
529                    match self.sizing_behavior {
530                        ListSizingBehavior::Infer => {
531                            window.with_text_style(style.text_style().cloned(), |window| {
532                                let size_layout = size_layout.clone();
533
534                                window.request_measured_layout(style, {
535                                    move |known_dimensions, available_space, _, _| {
536                                        let mut size = Size::default();
537                                        if axis.is_horizontal() {
538                                            size.width = known_dimensions.width.unwrap_or(
539                                                match available_space.width {
540                                                    AvailableSpace::Definite(x) => x,
541                                                    AvailableSpace::MinContent
542                                                    | AvailableSpace::MaxContent => {
543                                                        size_layout.content_size.width
544                                                    }
545                                                },
546                                            );
547                                            size.height = known_dimensions.height.unwrap_or(
548                                                match available_space.height {
549                                                    AvailableSpace::Definite(x) => x,
550                                                    AvailableSpace::MinContent
551                                                    | AvailableSpace::MaxContent => {
552                                                        size_layout.content_size.height
553                                                    }
554                                                },
555                                            );
556                                        } else {
557                                            size.width = known_dimensions.width.unwrap_or(
558                                                match available_space.width {
559                                                    AvailableSpace::Definite(x) => x,
560                                                    AvailableSpace::MinContent
561                                                    | AvailableSpace::MaxContent => {
562                                                        size_layout.content_size.width
563                                                    }
564                                                },
565                                            );
566                                            size.height = known_dimensions.height.unwrap_or(
567                                                match available_space.height {
568                                                    AvailableSpace::Definite(x) => x,
569                                                    AvailableSpace::MinContent
570                                                    | AvailableSpace::MaxContent => {
571                                                        size_layout.content_size.height
572                                                    }
573                                                },
574                                            );
575                                        }
576
577                                        size
578                                    }
579                                })
580                            })
581                        }
582                        ListSizingBehavior::Auto => window
583                            .with_text_style(style.text_style().cloned(), |window| {
584                                window.request_layout(style, None, cx)
585                            }),
586                    };
587
588                layout_id
589            },
590        );
591
592        (
593            layout_id,
594            VirtualListFrameState {
595                items: SmallVec::new(),
596                size_layout,
597            },
598        )
599    }
600
601    fn prepaint(
602        &mut self,
603        global_id: Option<&GlobalElementId>,
604        inspector_id: Option<&gpui::InspectorElementId>,
605        bounds: Bounds<Pixels>,
606        layout: &mut Self::RequestLayoutState,
607        window: &mut Window,
608        cx: &mut App,
609    ) -> Self::PrepaintState {
610        layout.size_layout.last_layout_bounds = bounds;
611
612        let style = self
613            .base
614            .interactivity()
615            .compute_style(global_id, None, window, cx);
616        let border_widths = style.border_widths.to_pixels(window.rem_size());
617        let paddings = style
618            .padding
619            .to_pixels(bounds.size.into(), window.rem_size());
620
621        let item_sizes = &layout.size_layout.sizes;
622        let item_origins = &layout.size_layout.origins;
623
624        let content_bounds = Bounds::from_corners(
625            bounds.origin
626                + point(
627                    border_widths.left + paddings.left,
628                    border_widths.top + paddings.top,
629                ),
630            bounds.bottom_right()
631                - point(
632                    border_widths.right + paddings.right,
633                    border_widths.bottom + paddings.bottom,
634                ),
635        );
636
637        let axis = self.axis;
638
639        let mut scroll_state = self.scroll_handle.state.borrow_mut();
640        scroll_state.axis = axis;
641        scroll_state.items_count = self.items_count;
642        scroll_state.last_content_size = Some(content_bounds.size);
643
644        let mut scroll_offset = self.scroll_handle.offset();
645        if let Some(scroll_to_item) = scroll_state.deferred_scroll_to_item.take() {
646            scroll_offset = self.scroll_to_deferred_item(
647                scroll_offset,
648                &layout.size_layout,
649                &content_bounds,
650                scroll_to_item,
651            );
652        }
653
654        scroll_offset = scroll_offset
655            .max(&point(
656                content_bounds.size.width - layout.size_layout.content_size.width,
657                content_bounds.size.height - layout.size_layout.content_size.height,
658            ))
659            .min(&point(px(0.), px(0.)));
660        if scroll_offset != self.scroll_handle.offset() {
661            self.scroll_handle.set_offset(scroll_offset);
662        }
663
664        self.base.interactivity().prepaint(
665            global_id,
666            inspector_id,
667            bounds,
668            layout.size_layout.content_size,
669            window,
670            cx,
671            |_style, _, hitbox, window, cx| {
672                if self.items_count > 0 {
673                    let min_scroll_offset = content_bounds.size.along(self.axis)
674                        - layout.size_layout.content_size.along(self.axis);
675
676                    let is_scrolled = !scroll_offset.along(self.axis).is_zero();
677                    if is_scrolled {
678                        match self.axis {
679                            Axis::Horizontal if scroll_offset.x < min_scroll_offset => {
680                                scroll_offset.x = min_scroll_offset;
681                                self.scroll_handle.set_offset(scroll_offset);
682                            }
683                            Axis::Vertical if scroll_offset.y < min_scroll_offset => {
684                                scroll_offset.y = min_scroll_offset;
685                                self.scroll_handle.set_offset(scroll_offset);
686                            }
687                            _ => {}
688                        }
689                    }
690
691                    // The viewport in content space: the scroll offset is
692                    // negative, and the leading padding is admitted at the
693                    // start so an item under it is still drawn.
694                    let viewport = match self.axis {
695                        Axis::Horizontal => {
696                            -(scroll_offset.x + paddings.left)
697                                ..-scroll_offset.x + content_bounds.size.width
698                        }
699                        Axis::Vertical => {
700                            -(scroll_offset.y + paddings.top)
701                                ..-scroll_offset.y + content_bounds.size.height
702                        }
703                    };
704                    let visible_range = visible_range(item_origins, item_sizes, viewport);
705
706                    let items = (self.render_items)(visible_range.clone(), window, cx);
707
708                    let content_mask = ContentMask { bounds };
709                    window.with_content_mask(Some(content_mask), |window| {
710                        for (mut item, ix) in items.into_iter().zip(visible_range.clone()) {
711                            let item_origin = match self.axis {
712                                Axis::Horizontal => {
713                                    content_bounds.origin
714                                        + point(item_origins[ix] + scroll_offset.x, scroll_offset.y)
715                                }
716                                Axis::Vertical => {
717                                    content_bounds.origin
718                                        + point(scroll_offset.x, item_origins[ix] + scroll_offset.y)
719                                }
720                            };
721
722                            let available_space = match self.axis {
723                                Axis::Horizontal => size(
724                                    AvailableSpace::Definite(item_sizes[ix]),
725                                    AvailableSpace::Definite(content_bounds.size.height),
726                                ),
727                                Axis::Vertical => size(
728                                    AvailableSpace::Definite(content_bounds.size.width),
729                                    AvailableSpace::Definite(item_sizes[ix]),
730                                ),
731                            };
732
733                            item.layout_as_root(available_space, window, cx);
734                            item.prepaint_at(item_origin, window, cx);
735                            layout.items.push(item);
736                        }
737                    });
738                }
739
740                hitbox
741            },
742        )
743    }
744
745    fn paint(
746        &mut self,
747        global_id: Option<&GlobalElementId>,
748        inspector_id: Option<&gpui::InspectorElementId>,
749        bounds: Bounds<Pixels>,
750        layout: &mut Self::RequestLayoutState,
751        hitbox: &mut Self::PrepaintState,
752        window: &mut Window,
753        cx: &mut App,
754    ) {
755        self.base.interactivity().paint(
756            global_id,
757            inspector_id,
758            bounds,
759            hitbox.as_ref(),
760            window,
761            cx,
762            |_, window, cx| {
763                for item in &mut layout.items {
764                    item.paint(window, cx);
765                }
766            },
767        )
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    use gpui::{Context, ParentElement as _, TestAppContext, VisualTestContext};
776
777    struct VirtualListHarness {
778        axis: Axis,
779        item_sizes: Rc<Vec<Size<Pixels>>>,
780        scroll_handle: VirtualListScrollHandle,
781        visible_ranges: Rc<RefCell<Vec<Range<usize>>>>,
782    }
783
784    impl Render for VirtualListHarness {
785        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
786            let item_sizes = self.item_sizes.clone();
787            let visible_ranges = self.visible_ranges.clone();
788            virtual_list(
789                cx.entity(),
790                "virtual-list-test",
791                self.axis,
792                self.item_sizes.clone(),
793                move |_, visible_range, _, _| {
794                    visible_ranges.borrow_mut().push(visible_range.clone());
795                    visible_range
796                        .map(|ix| div().w(item_sizes[ix].width).h(item_sizes[ix].height))
797                        .collect::<Vec<_>>()
798                },
799            )
800            .track_scroll(&self.scroll_handle)
801            .w(px(60.))
802            .h(px(60.))
803        }
804    }
805
806    fn exercise_axis(cx: &mut TestAppContext, axis: Axis) {
807        let item_size = match axis {
808            Axis::Horizontal => size(px(20.), px(10.)),
809            Axis::Vertical => size(px(10.), px(20.)),
810        };
811        let item_sizes = Rc::new(vec![item_size; 20]);
812        let scroll_handle = VirtualListScrollHandle::new();
813        let visible_ranges = Rc::new(RefCell::new(Vec::new()));
814        let (_, cx) = cx.add_window_view({
815            let item_sizes = item_sizes.clone();
816            let scroll_handle = scroll_handle.clone();
817            let visible_ranges = visible_ranges.clone();
818            move |_, _| VirtualListHarness {
819                axis,
820                item_sizes,
821                scroll_handle,
822                visible_ranges,
823            }
824        });
825
826        cx.update(|window, cx| window.draw(cx).clear(cx));
827        let initial_range = visible_ranges.borrow().last().cloned().unwrap();
828        assert_eq!(initial_range.start, 0);
829        assert!(initial_range.end < item_sizes.len());
830
831        scroll_handle.scroll_to_item(12, ScrollStrategy::Top);
832        cx.update(|window, cx| window.draw(cx).clear(cx));
833        let scrolled_range = visible_ranges.borrow().last().cloned().unwrap();
834        assert!(scrolled_range.contains(&12));
835        match axis {
836            Axis::Horizontal => assert!(scroll_handle.offset().x < px(0.)),
837            Axis::Vertical => assert!(scroll_handle.offset().y < px(0.)),
838        }
839    }
840
841    fn layout(sizes: &[f32]) -> (Vec<Pixels>, Vec<Pixels>) {
842        let sizes: Vec<Pixels> = sizes.iter().map(|size| px(*size)).collect();
843        let origins = sizes
844            .iter()
845            .scan(px(0.), |cumulative, size| {
846                let origin = *cumulative;
847                *cumulative += *size;
848                Some(origin)
849            })
850            .collect();
851        (origins, sizes)
852    }
853
854    #[test]
855    fn visible_range_starts_at_the_first_item_crossing_the_viewport() {
856        let (origins, sizes) = layout(&[20.; 10]);
857        assert_eq!(visible_range(&origins, &sizes, px(0.)..px(60.)), 0..5);
858        assert_eq!(visible_range(&origins, &sizes, px(50.)..px(110.)), 2..7);
859    }
860
861    #[test]
862    fn visible_range_overdraws_one_item_past_the_viewport_but_not_past_the_end() {
863        let (origins, sizes) = layout(&[20.; 10]);
864        assert_eq!(visible_range(&origins, &sizes, px(150.)..px(210.)), 7..10);
865        assert_eq!(visible_range(&origins, &sizes, px(0.)..px(500.)), 0..10);
866    }
867
868    #[test]
869    fn visible_range_handles_uneven_sizes() {
870        let (origins, sizes) = layout(&[10., 30., 5., 50.]);
871        assert_eq!(visible_range(&origins, &sizes, px(12.)..px(44.)), 1..4);
872        assert_eq!(visible_range(&origins, &sizes, px(45.)..px(50.)), 3..4);
873    }
874
875    #[test]
876    fn visible_range_is_empty_without_items_or_past_the_content() {
877        let (origins, sizes) = layout(&[]);
878        assert_eq!(visible_range(&origins, &sizes, px(0.)..px(60.)), 0..0);
879        let (origins, sizes) = layout(&[20.; 10]);
880        assert!(visible_range(&origins, &sizes, px(300.)..px(360.)).is_empty());
881    }
882
883    #[gpui::test]
884    fn vertical_visible_range_and_deferred_scroll_are_preserved(cx: &mut TestAppContext) {
885        exercise_axis(cx, Axis::Vertical);
886    }
887
888    #[gpui::test]
889    fn horizontal_visible_range_and_deferred_scroll_are_preserved(cx: &mut TestAppContext) {
890        exercise_axis(cx, Axis::Horizontal);
891    }
892
893    /// Three 30 by 10 items, laid out along `axis` with `gap` between them.
894    struct GeometryHarness {
895        axis: Axis,
896        gap: Pixels,
897    }
898
899    impl Render for GeometryHarness {
900        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
901            virtual_list(
902                cx.entity(),
903                "virtual-list-geometry",
904                self.axis,
905                Rc::new(vec![size(px(30.), px(10.)); 3]),
906                |_, visible_range, _, _| {
907                    visible_range
908                        .map(|ix| {
909                            div()
910                                .w(px(30.))
911                                .h(px(10.))
912                                .debug_selector(move || format!("geometry-item-{ix}"))
913                        })
914                        .collect::<Vec<_>>()
915                },
916            )
917            .gap(self.gap)
918            .w(px(120.))
919            .h(px(100.))
920        }
921    }
922
923    /// Where the second item sits relative to the first.
924    fn second_item_offset(cx: &mut VisualTestContext) -> Point<Pixels> {
925        let first = cx.debug_bounds("geometry-item-0").unwrap().origin;
926        let second = cx.debug_bounds("geometry-item-1").unwrap().origin;
927        second - first
928    }
929
930    #[gpui::test]
931    fn changing_the_gap_moves_the_items(cx: &mut TestAppContext) {
932        let (view, cx) = cx.add_window_view(|_, _| GeometryHarness {
933            axis: Axis::Vertical,
934            gap: px(0.),
935        });
936        cx.update(|window, cx| window.draw(cx).clear(cx));
937        assert_eq!(second_item_offset(cx), point(px(0.), px(10.)));
938
939        cx.update(|window, cx| {
940            view.update(cx, |harness, cx| {
941                harness.gap = px(20.);
942                cx.notify();
943            });
944            window.draw(cx).clear(cx);
945        });
946        assert_eq!(second_item_offset(cx), point(px(0.), px(30.)));
947    }
948
949    #[gpui::test]
950    fn changing_the_axis_moves_the_items(cx: &mut TestAppContext) {
951        let (view, cx) = cx.add_window_view(|_, _| GeometryHarness {
952            axis: Axis::Vertical,
953            gap: px(0.),
954        });
955        cx.update(|window, cx| window.draw(cx).clear(cx));
956        assert_eq!(second_item_offset(cx), point(px(0.), px(10.)));
957
958        cx.update(|window, cx| {
959            view.update(cx, |harness, cx| {
960                harness.axis = Axis::Horizontal;
961                cx.notify();
962            });
963            window.draw(cx).clear(cx);
964        });
965        assert_eq!(second_item_offset(cx), point(px(30.), px(0.)));
966    }
967
968    #[gpui::test]
969    fn a_horizontal_list_infers_its_height_from_its_items(cx: &mut TestAppContext) {
970        struct InferHarness;
971
972        impl Render for InferHarness {
973            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
974                div()
975                    .flex()
976                    .flex_col()
977                    .items_start()
978                    .w(px(120.))
979                    .child(
980                        virtual_list(
981                            cx.entity(),
982                            "virtual-list-infer",
983                            Axis::Horizontal,
984                            Rc::new(vec![size(px(30.), px(10.)); 3]),
985                            |_, visible_range, _, _| {
986                                visible_range
987                                    .map(|_| div().w(px(30.)).h(px(10.)))
988                                    .collect::<Vec<_>>()
989                            },
990                        )
991                        .with_sizing_behavior(ListSizingBehavior::Infer)
992                        .w(px(120.))
993                        .h_auto()
994                        .flex_none(),
995                    )
996                    .child(div().size(px(1.)).debug_selector(|| "after-list".into()))
997            }
998        }
999
1000        let (_, cx) = cx.add_window_view(|_, _| InferHarness);
1001        cx.update(|window, cx| window.draw(cx).clear(cx));
1002        // The list is as tall as its items, not as wide as itself.
1003        assert_eq!(cx.debug_bounds("after-list").unwrap().top(), px(10.));
1004    }
1005
1006    #[gpui::test]
1007    fn empty_list_draws_without_requesting_items(cx: &mut TestAppContext) {
1008        let visible_ranges = Rc::new(RefCell::new(Vec::new()));
1009        let (_, cx) = cx.add_window_view({
1010            let visible_ranges = visible_ranges.clone();
1011            move |_, _| VirtualListHarness {
1012                axis: Axis::Vertical,
1013                item_sizes: Rc::new(Vec::new()),
1014                scroll_handle: VirtualListScrollHandle::new(),
1015                visible_ranges,
1016            }
1017        });
1018
1019        cx.update(|window, cx| window.draw(cx).clear(cx));
1020        assert!(visible_ranges.borrow().is_empty());
1021    }
1022}