1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
//! `VirtualList` — a vertical list that renders only the rows in (and just
//! around) the viewport, with **variable row heights** and programmatic
//! scrolling to a row (HeroGPUI extension; HeroUI v3 has no such component).
//!
//! It wraps GPUI's own `list` element, which measures each row the first time
//! it is laid out and keeps a height summary, so rows need no declared
//! height. `ListBox` and `Table` render their `estimated_row_height` bodies
//! through it. Their fixed-height fast path (`uniform_list`, via
//! `row_height`) and `ComboBox`'s popover list stay on `uniform_list`: that
//! path centres the keyboard cursor (`ScrollStrategy::Center`), and pages
//! and arms `Table`'s load-more from the declared row height and the uniform
//! list's viewport, none of which a measured list reproduces.
//!
//! State lives in a caller-owned [`VirtualListHandle`], which is how a view
//! scrolls the list from outside (`scroll_to_item`) and tells it that the
//! collection changed (`set_item_count`, `splice`):
//!
//! ```
//! use herogpui_components::{VirtualListScroll, VirtualList, VirtualListHandle};
//! use gpui::{div, prelude::*, px};
//!
//! struct Log {
//! rows: Vec<String>,
//! list: VirtualListHandle,
//! }
//!
//! impl Log {
//! fn new(rows: Vec<String>) -> Self {
//! let list = VirtualListHandle::new(rows.len());
//! Self { rows, list }
//! }
//!
//! fn jump_to_end(&self) {
//! // Clamped to the last row; an empty list stays put.
//! self.list.scroll_to_item(usize::MAX, VirtualListScroll::Reveal);
//! }
//!
//! fn view(&self) -> VirtualList {
//! let rows = self.rows.clone();
//! VirtualList::new("log", &self.list, move |ix, _window, _cx| {
//! div().p(px(4.)).child(rows[ix].clone()).into_any_element()
//! })
//! .height(px(240.))
//! }
//! }
//! ```
use gpui::{
div, list, prelude::*, AnyElement, App, Bounds, ElementId, ListAlignment, ListOffset,
ListState, Pixels, Window,
};
/// Where [`VirtualListHandle::scroll_to_item`] places the row.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum VirtualListScroll {
/// Scroll the least distance that makes the row fully visible; a row
/// already fully visible does not move. The distance needs the heights
/// of the rows in between, so a row that was not laid out in the last
/// frame (far outside the viewport, never measured) is brought to the
/// top edge instead, as [`Top`](Self::Top) does.
#[default]
Reveal,
/// Put the row's top edge at the top of the viewport.
Top,
}
/// The scroll and measurement state of one [`VirtualList`]. Cheap to clone;
/// clones share the state. Keep one per list in the owning view.
#[derive(Clone)]
pub struct VirtualListHandle {
state: ListState,
}
impl std::fmt::Debug for VirtualListHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VirtualListHandle")
.field("item_count", &self.item_count())
.finish()
}
}
/// Rows rendered beyond each edge of the viewport, so a short scroll shows
/// already-laid-out rows instead of a blank band.
const OVERDRAW: f32 = 200.;
impl VirtualListHandle {
/// A handle for a list of `item_count` rows, scrolled to the top.
pub fn new(item_count: usize) -> Self {
Self {
state: ListState::new(item_count, ListAlignment::Top, gpui::px(OVERDRAW)),
}
}
/// A handle with an explicit overdraw distance, for the components whose
/// overdraw follows their row estimate.
pub(crate) fn with_overdraw(item_count: usize, overdraw: Pixels) -> Self {
Self {
state: ListState::new(item_count, ListAlignment::Top, overdraw),
}
}
/// Wraps a list state a component already holds.
pub(crate) fn from_list_state(state: ListState) -> Self {
Self { state }
}
/// The underlying GPUI list state, for the components that read its
/// viewport and scroll top directly (paging, load-more, edge rounding).
pub(crate) fn list_state(&self) -> &ListState {
&self.state
}
/// The number of rows the list renders.
pub fn item_count(&self) -> usize {
self.state.item_count()
}
/// Replaces the collection: `item_count` rows, all remeasured, scrolled
/// back to the top. For an append or a local edit, [`splice`](Self::splice)
/// keeps the scroll position and the other rows' measurements.
pub fn set_item_count(&self, item_count: usize) {
self.state.reset(item_count);
}
/// Replaces the rows in `old_range` with `count` new rows, keeping every
/// other row's measured height and the scroll position.
pub fn splice(&self, old_range: std::ops::Range<usize>, count: usize) {
self.state.splice(old_range, count);
}
/// Discards every measured height, for when row content changed size
/// without the count changing (a font or density switch).
pub fn remeasure(&self) {
self.state.remeasure();
}
/// Scrolls so row `ix` is visible, per `strategy`. An index past the end
/// scrolls to the end. Takes effect on the next frame.
pub fn scroll_to_item(&self, ix: usize, strategy: VirtualListScroll) {
let ix = ix.min(self.item_count().saturating_sub(1));
match strategy {
VirtualListScroll::Reveal if self.state.bounds_for_item(ix).is_some() => {
self.state.scroll_to_reveal_item(ix);
}
VirtualListScroll::Reveal | VirtualListScroll::Top => {
self.state.scroll_to(ListOffset {
item_ix: ix,
offset_in_item: gpui::px(0.),
});
}
}
}
/// Scrolls by `distance` (positive moves the content up, towards later rows).
pub fn scroll_by(&self, distance: Pixels) {
self.state.scroll_by(distance);
}
/// The index of the first row at the top of the viewport.
pub fn first_visible_item(&self) -> usize {
self.state.logical_scroll_top().item_ix
}
/// Row `ix`'s window-coordinate bounds from the last frame, when it was
/// rendered.
pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
self.state.bounds_for_item(ix)
}
}
type RenderRow = dyn FnMut(usize, &mut Window, &mut App) -> AnyElement;
/// A virtualised, variable-row-height vertical list. See the
/// [module docs](self).
#[derive(IntoElement)]
pub struct VirtualList {
id: ElementId,
handle: VirtualListHandle,
render_row: Box<RenderRow>,
height: Option<Pixels>,
}
impl VirtualList {
/// A list over `handle` whose row `ix` is `render_row(ix, ..)`. Only
/// rows near the viewport are built, each frame, so `render_row` should
/// be cheap and must not assume it sees every index.
pub fn new(
id: impl Into<ElementId>,
handle: &VirtualListHandle,
render_row: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
Self {
id: id.into(),
handle: handle.clone(),
render_row: Box::new(render_row),
height: None,
}
}
/// A fixed viewport height. Without one the list fills its parent's
/// height (`size_full`), which then needs a definite height itself.
pub fn height(mut self, height: impl Into<Pixels>) -> Self {
self.height = Some(height.into());
self
}
}
impl RenderOnce for VirtualList {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let rows = list(self.handle.state, self.render_row).size_full();
let root = div().id(self.id).w_full().flex().flex_col();
match self.height {
Some(height) => root.h(height),
None => root.size_full(),
}
.child(rows)
}
}