Skip to main content

herogpui_components/
virtual_list.rs

1//! `VirtualList` — a vertical list that renders only the rows in (and just
2//! around) the viewport, with programmatic scrolling to a row (HeroGPUI
3//! extension; HeroUI v3 has no such component). It has two modes, chosen by
4//! the [`VirtualListHandle`] it is built over:
5//!
6//! - **Measured** ([`VirtualListHandle::new`]): **variable row heights**.
7//!   It wraps GPUI's own `list` element, which measures each row the first
8//!   time it is laid out and keeps a height summary, so rows need no declared
9//!   height. `ListBox` and `Table` render their `estimated_row_height` bodies
10//!   through it.
11//! - **Uniform** ([`VirtualListHandle::uniform`]): every row is as tall as
12//!   the first one, which is measured each frame and multiplied, so the
13//!   geometry of a million rows costs one layout. It wraps GPUI's
14//!   `uniform_list`, and it is what the fixed `row_height` paths of
15//!   `ListBox`, `Table` and `ComboBox` render through: it centres a row
16//!   ([`VirtualListScroll::Center`]), reports its laid-out viewport (which
17//!   PageUp/PageDown step over by the declared row height) and how much
18//!   content remains below it (which arms `Table`'s load-more).
19//!
20//! State lives in a caller-owned [`VirtualListHandle`], which is how a view
21//! scrolls the list from outside (`scroll_to_item`) and tells it that the
22//! collection changed (`set_item_count`, `splice`):
23//!
24//! ```
25//! use herogpui_components::{VirtualListScroll, VirtualList, VirtualListHandle};
26//! use gpui::{div, prelude::*, px};
27//!
28//! struct Log {
29//!     rows: Vec<String>,
30//!     list: VirtualListHandle,
31//! }
32//!
33//! impl Log {
34//!     fn new(rows: Vec<String>) -> Self {
35//!         // `VirtualListHandle::uniform` when every row has one height.
36//!         let list = VirtualListHandle::new(rows.len());
37//!         Self { rows, list }
38//!     }
39//!
40//!     fn jump_to_end(&self) {
41//!         // Clamped to the last row; an empty list stays put.
42//!         self.list.scroll_to_item(usize::MAX, VirtualListScroll::Reveal);
43//!     }
44//!
45//!     fn view(&self) -> VirtualList {
46//!         let rows = self.rows.clone();
47//!         VirtualList::new("log", &self.list, move |ix, _window, _cx| {
48//!             div().p(px(4.)).child(rows[ix].clone()).into_any_element()
49//!         })
50//!         .height(px(240.))
51//!     }
52//! }
53//! ```
54
55use std::cell::{Cell, RefCell};
56use std::rc::Rc;
57
58use gpui::{
59    div, list, prelude::*, uniform_list, AnyElement, App, Bounds, ElementId, ListAlignment,
60    ListOffset, ListSizingBehavior, ListState, Pixels, ScrollStrategy, UniformListScrollHandle,
61    Window,
62};
63
64/// Where [`VirtualListHandle::scroll_to_item`] places the row.
65///
66/// Adding [`Center`](Self::Center) in 0.13 made this enum
67/// `#[non_exhaustive]`; a `match` on it needs a wildcard arm.
68#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum VirtualListScroll {
71    /// Scroll the least distance that makes the row fully visible; a row
72    /// already fully visible does not move. In a measured list the distance
73    /// needs the heights of the rows in between, so a row that was not laid
74    /// out in the last frame (far outside the viewport, never measured) is
75    /// brought to the top edge instead, as [`Top`](Self::Top) does.
76    #[default]
77    Reveal,
78    /// Put the row's top edge at the top of the viewport (clamped, in a
79    /// uniform list, so the last page stays full).
80    Top,
81    /// When the row is not fully visible, scroll so its centre sits at the
82    /// viewport's centre, clamped at both ends of the content; a row already
83    /// fully visible does not move. This is GPUI's
84    /// `ScrollStrategy::Center`, which the keyboard cursor of the fixed-row
85    /// collections follows. A measured list centres a row it laid out in the
86    /// last frame and brings any other row to the top edge, as
87    /// [`Top`](Self::Top) does.
88    Center,
89}
90
91/// The scroll and measurement state of one [`VirtualList`]. Cheap to clone;
92/// clones share the state. Keep one per list in the owning view.
93#[derive(Clone)]
94pub struct VirtualListHandle {
95    engine: Engine,
96}
97
98#[derive(Clone)]
99enum Engine {
100    Measured(ListState),
101    Uniform(Rc<UniformState>),
102}
103
104/// A uniform list's state: GPUI's scroll handle, the row count the next
105/// frame renders, and the count the last frame rendered (the one its
106/// measured content height is a multiple of).
107struct UniformState {
108    scroll: UniformListScrollHandle,
109    count: Cell<usize>,
110    rendered_count: Cell<usize>,
111}
112
113impl UniformState {
114    /// The measured row height of the last frame.
115    fn row_height(&self) -> Option<Pixels> {
116        let size = self.scroll.0.borrow().last_item_size?;
117        let rendered = self.rendered_count.get();
118        (rendered > 0).then(|| size.contents.height / rendered as f32)
119    }
120}
121
122impl std::fmt::Debug for VirtualListHandle {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("VirtualListHandle")
125            .field("uniform", &self.is_uniform())
126            .field("item_count", &self.item_count())
127            .finish()
128    }
129}
130
131/// Rows rendered beyond each edge of the viewport, so a short scroll shows
132/// already-laid-out rows instead of a blank band.
133const OVERDRAW: f32 = 200.;
134
135impl VirtualListHandle {
136    /// A measured (variable-row-height) handle for a list of `item_count`
137    /// rows, scrolled to the top.
138    pub fn new(item_count: usize) -> Self {
139        Self {
140            engine: Engine::Measured(ListState::new(
141                item_count,
142                ListAlignment::Top,
143                gpui::px(OVERDRAW),
144            )),
145        }
146    }
147
148    /// A uniform (fixed-row-height) handle for a list of `item_count` rows,
149    /// scrolled to the top. Every row is laid out at the height of the
150    /// first, so rows should be built at one explicit height.
151    pub fn uniform(item_count: usize) -> Self {
152        Self {
153            engine: Engine::Uniform(Rc::new(UniformState {
154                scroll: UniformListScrollHandle::new(),
155                count: Cell::new(item_count),
156                rendered_count: Cell::new(0),
157            })),
158        }
159    }
160
161    /// A handle with an explicit overdraw distance, for the components whose
162    /// overdraw follows their row estimate.
163    pub(crate) fn with_overdraw(item_count: usize, overdraw: Pixels) -> Self {
164        Self {
165            engine: Engine::Measured(ListState::new(item_count, ListAlignment::Top, overdraw)),
166        }
167    }
168
169    /// Wraps a list state a component already holds.
170    pub(crate) fn from_list_state(state: ListState) -> Self {
171        Self {
172            engine: Engine::Measured(state),
173        }
174    }
175
176    /// The underlying GPUI list state of a measured handle, for the
177    /// components that read its viewport and scroll top directly (paging,
178    /// load-more, edge rounding).
179    ///
180    /// # Panics
181    ///
182    /// On a [uniform](Self::uniform) handle.
183    pub(crate) fn list_state(&self) -> &ListState {
184        match &self.engine {
185            Engine::Measured(state) => state,
186            Engine::Uniform(_) => panic!("a uniform VirtualListHandle has no ListState"),
187        }
188    }
189
190    /// Whether this handle was made by [`uniform`](Self::uniform).
191    pub fn is_uniform(&self) -> bool {
192        matches!(self.engine, Engine::Uniform(_))
193    }
194
195    /// The number of rows the list renders.
196    pub fn item_count(&self) -> usize {
197        match &self.engine {
198            Engine::Measured(state) => state.item_count(),
199            Engine::Uniform(state) => state.count.get(),
200        }
201    }
202
203    /// Replaces the collection: `item_count` rows, all remeasured, scrolled
204    /// back to the top. For an append or a local edit, [`splice`](Self::splice)
205    /// keeps the scroll position and the other rows' measurements.
206    pub fn set_item_count(&self, item_count: usize) {
207        match &self.engine {
208            Engine::Measured(state) => state.reset(item_count),
209            Engine::Uniform(state) => {
210                state.count.set(item_count);
211                let mut scroll = state.scroll.0.borrow_mut();
212                scroll.deferred_scroll_to_item = None;
213                scroll.base_handle.set_offset(gpui::point(px0(), px0()));
214            }
215        }
216    }
217
218    /// Replaces the rows in `old_range` with `count` new rows, keeping every
219    /// other row's measured height and the scroll position (a uniform list
220    /// clamps it when the content got shorter).
221    pub fn splice(&self, old_range: std::ops::Range<usize>, count: usize) {
222        match &self.engine {
223            Engine::Measured(state) => state.splice(old_range, count),
224            Engine::Uniform(state) => {
225                let current = state.count.get();
226                let end = old_range.end.min(current);
227                let start = old_range.start.min(end);
228                state.count.set(current - (end - start) + count);
229            }
230        }
231    }
232
233    /// Discards every measured height, for when row content changed size
234    /// without the count changing (a font or density switch). A uniform list
235    /// measures its first row every frame, so this does nothing there.
236    pub fn remeasure(&self) {
237        if let Engine::Measured(state) = &self.engine {
238            state.remeasure();
239        }
240    }
241
242    /// Scrolls so row `ix` is visible, per `strategy`. An index past the end
243    /// scrolls to the end. Takes effect on the next frame.
244    pub fn scroll_to_item(&self, ix: usize, strategy: VirtualListScroll) {
245        let ix = ix.min(self.item_count().saturating_sub(1));
246        match &self.engine {
247            Engine::Uniform(state) => state.scroll.scroll_to_item(
248                ix,
249                match strategy {
250                    VirtualListScroll::Reveal => ScrollStrategy::Nearest,
251                    VirtualListScroll::Top => ScrollStrategy::Top,
252                    VirtualListScroll::Center => ScrollStrategy::Center,
253                },
254            ),
255            Engine::Measured(state) => match (strategy, state.bounds_for_item(ix)) {
256                (VirtualListScroll::Reveal, Some(_)) => state.scroll_to_reveal_item(ix),
257                (VirtualListScroll::Center, Some(row)) => {
258                    let viewport = state.viewport_bounds();
259                    let fully_visible =
260                        row.top() >= viewport.top() && row.bottom() <= viewport.bottom();
261                    if !fully_visible {
262                        state.scroll_by(row.center().y - viewport.center().y);
263                    }
264                }
265                _ => state.scroll_to(ListOffset {
266                    item_ix: ix,
267                    offset_in_item: px0(),
268                }),
269            },
270        }
271    }
272
273    /// Scrolls by `distance` (positive moves the content up, towards later
274    /// rows), clamped to the content.
275    pub fn scroll_by(&self, distance: Pixels) {
276        match &self.engine {
277            Engine::Measured(state) => state.scroll_by(distance),
278            Engine::Uniform(state) => {
279                let scroll = state.scroll.0.borrow();
280                let handle = &scroll.base_handle;
281                let max = handle.max_offset().y.max(px0());
282                let mut offset = handle.offset();
283                offset.y = (offset.y - distance).clamp(-max, px0());
284                handle.set_offset(offset);
285            }
286        }
287    }
288
289    /// The index of the first row at the top of the viewport.
290    pub fn first_visible_item(&self) -> usize {
291        match &self.engine {
292            Engine::Measured(state) => state.logical_scroll_top().item_ix,
293            Engine::Uniform(state) => {
294                let Some(row_height) = state.row_height().filter(|h| *h > px0()) else {
295                    return 0;
296                };
297                let offset = -state.scroll.0.borrow().base_handle.offset().y;
298                ((offset / row_height).floor().max(0.) as usize)
299                    .min(state.count.get().saturating_sub(1))
300            }
301        }
302    }
303
304    /// Row `ix`'s window-coordinate bounds from the last frame, when it was
305    /// rendered.
306    pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
307        match &self.engine {
308            Engine::Measured(state) => state.bounds_for_item(ix),
309            Engine::Uniform(state) => {
310                if ix >= state.rendered_count.get() {
311                    return None;
312                }
313                let row_height = state.row_height().filter(|h| *h > px0())?;
314                let scroll = state.scroll.0.borrow();
315                let viewport = scroll.base_handle.bounds();
316                let top = viewport.top() + scroll.base_handle.offset().y + row_height * ix as f32;
317                let row = Bounds::new(
318                    gpui::point(viewport.left(), top),
319                    gpui::size(viewport.size.width, row_height),
320                );
321                (row.bottom() > viewport.top() && row.top() < viewport.bottom()).then_some(row)
322            }
323        }
324    }
325
326    /// The list's viewport in window coordinates, as laid out in the last
327    /// frame (zero-sized before the first). This is what PageUp/PageDown
328    /// step over: it follows a bounded parent or a resized window, where a
329    /// configured height would not.
330    pub fn viewport_bounds(&self) -> Bounds<Pixels> {
331        match &self.engine {
332            Engine::Measured(state) => state.viewport_bounds(),
333            Engine::Uniform(state) => state.scroll.0.borrow().base_handle.bounds(),
334        }
335    }
336
337    /// How much content lies below the viewport's bottom edge after the
338    /// last frame, or `None` when that is not known. A uniform list knows it
339    /// exactly once laid out: the row count times the measured row, less the
340    /// scroll offset and the viewport. A measured list knows it only when
341    /// its last row was laid out in the last frame, since the rows it has
342    /// not built have no height yet.
343    pub fn remaining_below(&self) -> Option<Pixels> {
344        match &self.engine {
345            Engine::Uniform(state) => {
346                let scroll = state.scroll.0.borrow();
347                let size = scroll.last_item_size?;
348                Some(size.contents.height + scroll.base_handle.offset().y - size.item.height)
349            }
350            Engine::Measured(state) => {
351                let last = state.item_count().checked_sub(1)?;
352                let row = state.bounds_for_item(last)?;
353                Some((row.bottom() - state.viewport_bounds().bottom()).max(px0()))
354            }
355        }
356    }
357
358    /// Whether the list is scrolled to its top (within half a pixel).
359    pub fn is_scrolled_to_top(&self) -> bool {
360        match &self.engine {
361            Engine::Uniform(state) => {
362                state.scroll.0.borrow().base_handle.offset().y >= gpui::px(-HALF_PX)
363            }
364            Engine::Measured(state) => {
365                let top = state.logical_scroll_top();
366                top.item_ix == 0 && top.offset_in_item <= gpui::px(HALF_PX)
367            }
368        }
369    }
370
371    /// Whether the list's last row ends inside the viewport. An empty list
372    /// answers `true`. A nonempty measured list answers `false` until its
373    /// last row has been laid out; uniform mode defaults to `true` before
374    /// GPUI reports its scroll extent.
375    pub fn is_scrolled_to_end(&self) -> bool {
376        match &self.engine {
377            Engine::Uniform(state) => state.scroll.is_scrolled_to_end().unwrap_or(true),
378            Engine::Measured(state) => {
379                let count = state.item_count();
380                count == 0
381                    || state.bounds_for_item(count - 1).is_some_and(|row| {
382                        row.bottom() <= state.viewport_bounds().bottom() + gpui::px(HALF_PX)
383                    })
384            }
385        }
386    }
387}
388
389const HALF_PX: f32 = 0.5;
390
391fn px0() -> Pixels {
392    gpui::px(0.)
393}
394
395type RenderRow = dyn FnMut(usize, &mut Window, &mut App) -> AnyElement;
396
397/// A virtualised vertical list, measured or uniform per its handle. See the
398/// [module docs](self).
399#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
400#[derive(IntoElement)]
401pub struct VirtualList {
402    id: ElementId,
403    handle: VirtualListHandle,
404    render_row: Box<RenderRow>,
405    height: Option<Pixels>,
406    debug_selector: Option<String>,
407    padding: Option<(Pixels, Pixels, Pixels)>,
408    restrict_scroll_to_axis: bool,
409}
410
411impl VirtualList {
412    /// A list over `handle` whose row `ix` is `render_row(ix, ..)`. Only
413    /// rows near the viewport are built, each frame, so `render_row` should
414    /// be cheap and must not assume it sees every index.
415    pub fn new(
416        id: impl Into<ElementId>,
417        handle: &VirtualListHandle,
418        render_row: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
419    ) -> Self {
420        Self {
421            id: id.into(),
422            handle: handle.clone(),
423            render_row: Box::new(render_row),
424            height: None,
425            debug_selector: None,
426            padding: None,
427            restrict_scroll_to_axis: false,
428        }
429    }
430
431    /// A fixed viewport height. Without one a measured list fills its
432    /// parent's height (`size_full`), which then needs a definite height
433    /// itself, and a uniform list sizes to its rows (the row count times the
434    /// measured row), capped by the space its parent offers. A uniform list
435    /// with a height may still shrink below it as a flex item (`min_h_0`),
436    /// so a bounded parent hands it its real viewport.
437    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
438        self.height = Some(height.into());
439        self
440    }
441
442    /// Padding inside the scroll viewport (`top`, left and right `x`,
443    /// `bottom`). Rows scroll through it, and a row's outset focus ring can
444    /// paint into it at the edges, where the viewport would clip it.
445    pub(crate) fn padding(mut self, top: Pixels, x: Pixels, bottom: Pixels) -> Self {
446        self.padding = Some((top, x, bottom));
447        self
448    }
449
450    /// Keeps a horizontal wheel from scrolling a uniform list vertically:
451    /// gpui's `uniform_list` otherwise reads a gesture with no vertical
452    /// component as a vertical scroll. For rows that scroll horizontally
453    /// themselves (a `Table` with frozen columns). A measured list only ever
454    /// reads the vertical component.
455    pub(crate) fn restrict_scroll_to_axis(mut self) -> Self {
456        self.restrict_scroll_to_axis = true;
457        self
458    }
459
460    /// The headless-test probe name for the uniform list's viewport bounds.
461    pub(crate) fn debug_selector(mut self, selector: String) -> Self {
462        self.debug_selector = Some(selector);
463        self
464    }
465}
466
467impl RenderOnce for VirtualList {
468    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
469        match self.handle.engine {
470            Engine::Measured(state) => {
471                let rows = list(state, self.render_row).size_full();
472                let root = div().id(self.id).w_full().flex().flex_col();
473                let root = match self.padding {
474                    Some((top, x, bottom)) => root.pt(top).px(x).pb(bottom),
475                    None => root,
476                };
477                match self.height {
478                    Some(height) => root.h(height),
479                    None => root.size_full(),
480                }
481                .child(rows)
482                .into_any_element()
483            }
484            Engine::Uniform(state) => {
485                let count = state.count.get();
486                state.rendered_count.set(count);
487                let render_row = RefCell::new(self.render_row);
488                let rows = uniform_list(self.id, count, move |range, window, cx| {
489                    let mut render_row = render_row.borrow_mut();
490                    range
491                        .map(|ix| render_row(ix, window, cx))
492                        .collect::<Vec<_>>()
493                })
494                .track_scroll(&state.scroll)
495                .w_full();
496                let mut rows = rows;
497                if self.restrict_scroll_to_axis {
498                    rows.style().restrict_scroll_to_axis = Some(true);
499                }
500                let rows = match self.height {
501                    Some(height) => rows.h(height).min_h_0(),
502                    None => rows.with_sizing_behavior(ListSizingBehavior::Infer),
503                };
504                let rows = match self.padding {
505                    Some((top, x, bottom)) => rows.pt(top).px(x).pb(bottom),
506                    None => rows,
507                };
508                match self.debug_selector {
509                    Some(selector) => rows.debug_selector(move || selector),
510                    None => rows,
511                }
512                .into_any_element()
513            }
514        }
515    }
516}