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}