gpui/elements/uniform_list.rs
1//! Lists of rows that all have the same height, rendered lazily.
2//!
3//! [`uniform_list`] renders only the rows in the viewport. It measures one row, then
4//! places every row at `index * height`, so it never runs layout on rows that are
5//! off screen. Use [`crate::list`] when rows have different heights.
6//!
7//! The [lists and scrolling guide](crate::_documentation::guides::lists_and_scrolling) compares
8//! both list elements and shows how to keep their state on a view.
9
10use crate::{
11 AnyElement, App, AvailableSpace, Bounds, Element, ElementId, Entity, GlobalElementId, Hitbox,
12 InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
13 ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, StyleRefinement, Styled,
14 Window, point, px, size,
15};
16use smallvec::SmallVec;
17use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
18
19use super::ListHorizontalSizingBehavior;
20
21/// Creates a list of rows that all have the same height, and renders only the rows in view.
22///
23/// Use this for long lists, such as tables, file trees or contact lists, when every row has
24/// the same height. Use [`list`](fn@crate::list) when rows vary in height.
25///
26/// `f` receives the range of visible row indices and returns one element per row, in order.
27/// Each frame it runs for the visible range. It also runs for a single row to measure it, in
28/// both layout and prepaint.
29/// So `f` must accept any range within `0..item_count`, and every row it returns must be the
30/// height of the measured row.
31///
32/// The list sets its own overflow to scroll. Give it a bounded height, from its parent or from
33/// [`Styled::h`] or [`Styled::size_full`]. By default the height comes from the style. With
34/// [`UniformList::with_sizing_behavior`] set to [`ListSizingBehavior::Infer`], a list with no
35/// bounded height is as tall as all its rows, so every row is in view and gets rendered.
36///
37/// Rows outside the viewport are not created. Keep your data on your view and index into it
38/// from `f`, because row elements are dropped as they scroll away.
39///
40/// # Examples
41///
42/// ```
43/// # use gpui::*;
44/// # use std::{cell::Cell, rc::Rc};
45/// struct Contacts {
46/// names: Rc<Vec<String>>,
47/// widest_batch: Rc<Cell<usize>>,
48/// }
49///
50/// impl Render for Contacts {
51/// fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
52/// let names = self.names.clone();
53/// let widest_batch = self.widest_batch.clone();
54/// uniform_list("contacts", names.len(), move |range, _window, _cx| {
55/// widest_batch.set(widest_batch.get().max(range.len()));
56/// range
57/// .map(|ix| div().id(ix).h(px(32.)).child(names[ix].clone()))
58/// .collect::<Vec<_>>()
59/// })
60/// .size_full()
61/// }
62/// }
63/// # let mut app = TestApp::new();
64/// # let widest_batch = Rc::new(Cell::new(0));
65/// # let names: Rc<Vec<String>> = Rc::new((0..10_000).map(|ix| format!("Contact {ix}")).collect());
66/// # let mut window = app.open_window(|_, _| Contacts { names, widest_batch: widest_batch.clone() });
67/// # window.draw();
68/// // Only the rows on screen are passed to the closure, never all 10,000.
69/// # assert!(widest_batch.get() > 0 && widest_batch.get() < 1_000);
70/// ```
71#[track_caller]
72pub fn uniform_list<R>(
73 id: impl Into<ElementId>,
74 item_count: usize,
75 f: impl 'static + Fn(Range<usize>, &mut Window, &mut App) -> Vec<R>,
76) -> UniformList
77where
78 R: IntoElement,
79{
80 let id = id.into();
81 let mut base_style = StyleRefinement::default();
82 base_style.overflow.y = Some(Overflow::Scroll);
83
84 let render_range = move |range: Range<usize>, window: &mut Window, cx: &mut App| {
85 f(range, window, cx)
86 .into_iter()
87 .map(|component| component.into_any_element())
88 .collect()
89 };
90
91 UniformList {
92 item_count,
93 item_to_measure_index: 0,
94 render_items: Box::new(render_range),
95 decorations: Vec::new(),
96 interactivity: Interactivity {
97 element_id: Some(id),
98 base_style: Box::new(base_style),
99 ..Interactivity::new()
100 },
101 scroll_handle: None,
102 sizing_behavior: ListSizingBehavior::default(),
103 horizontal_sizing_behavior: ListHorizontalSizingBehavior::default(),
104 }
105}
106
107/// The element returned by [`uniform_list`]. Configure it with the builder methods, then
108/// return it from `render`. To read or set the scroll position, pass a
109/// [`UniformListScrollHandle`] to [`UniformList::track_scroll`].
110pub struct UniformList {
111 item_count: usize,
112 item_to_measure_index: usize,
113 render_items: Box<
114 dyn for<'a> Fn(Range<usize>, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>,
115 >,
116 decorations: Vec<Box<dyn UniformListDecoration>>,
117 interactivity: Interactivity,
118 scroll_handle: Option<UniformListScrollHandle>,
119 sizing_behavior: ListSizingBehavior,
120 horizontal_sizing_behavior: ListHorizontalSizingBehavior,
121}
122
123/// The rows and decorations that a [`UniformList`] lays out for one frame. You don't construct it.
124pub struct UniformListFrameState {
125 items: SmallVec<[AnyElement; 32]>,
126 decorations: SmallVec<[AnyElement; 2]>,
127}
128
129/// A handle that reads and sets the scroll position of a [`UniformList`].
130///
131/// Store it on your view, and pass it to [`UniformList::track_scroll`] when you build the list
132/// each frame. Clones share the same state.
133///
134/// Scroll requests (`scroll_to_*`) take effect during the next prepaint, not immediately. A
135/// request does nothing until a list tracks the handle.
136#[derive(Clone, Debug, Default)]
137pub struct UniformListScrollHandle(pub Rc<RefCell<UniformListScrollState>>);
138
139/// Where a scroll request places the target row in the viewport.
140///
141/// The list stops at its first and last row, so a row near either end can't always reach the
142/// position you ask for. It lands at the closest position the list allows.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum ScrollStrategy {
145 /// Places the row at the top of the viewport.
146 Top,
147 /// Places the row in the middle of the viewport, as close as the start and end of the list allow.
148 Center,
149 /// Places the row at the bottom of the viewport, as close as the end of the list allows.
150 Bottom,
151 /// Moves the least amount that shows the row. A row above the viewport is placed at the top,
152 /// and a row below it at the bottom. A row that is already fully visible doesn't move the list.
153 Nearest,
154}
155
156#[derive(Clone, Copy, Debug)]
157#[allow(missing_docs)]
158/// A scroll request that waits for the next prepaint.
159pub struct DeferredScrollToItem {
160 /// The row to scroll to.
161 pub item_index: usize,
162 /// Where the row should land.
163 pub strategy: ScrollStrategy,
164 /// The number of rows to keep clear, as described for
165 /// [`UniformListScrollHandle::scroll_to_item_with_offset`].
166 pub offset: usize,
167 /// Whether to apply `strategy` even when the row is already visible.
168 pub scroll_strict: bool,
169}
170
171#[derive(Clone, Debug, Default)]
172#[allow(missing_docs)]
173/// The state that a [`UniformListScrollHandle`] shares with its [`UniformList`].
174pub struct UniformListScrollState {
175 /// The scroll position of the list. [`UniformList::track_scroll`] shares it with the list.
176 pub base_handle: ScrollHandle,
177 /// A scroll request from a `scroll_to_*` method, applied and cleared on the next prepaint.
178 pub deferred_scroll_to_item: Option<DeferredScrollToItem>,
179 /// The viewport and content sizes from the last prepaint, or `None` before the first one.
180 pub last_item_size: Option<ItemSize>,
181 /// Whether item 0 is at the bottom of the list, as set by [`UniformList::y_flipped`].
182 pub y_flipped: bool,
183}
184
185#[derive(Copy, Clone, Debug, Default)]
186/// The sizes of a [`UniformList`]'s viewport and of all its rows, from the last prepaint.
187pub struct ItemSize {
188 /// The size of the viewport, after the list's border and padding.
189 pub item: Size<Pixels>,
190 /// The size of all the rows together: the row height times the row count, and the
191 /// width of the content, which is wider than the viewport when rows overflow horizontally.
192 pub contents: Size<Pixels>,
193}
194
195impl UniformListScrollHandle {
196 fn revised(&self) -> std::cell::RefMut<'_, UniformListScrollState> {
197 let state = self.0.borrow_mut();
198 state.base_handle.revision().bump();
199 state
200 }
201
202 /// Creates a handle that isn't attached to a list yet.
203 ///
204 /// Pass it to [`UniformList::track_scroll`] when you build the list.
205 pub fn new() -> Self {
206 Self(Rc::new(RefCell::new(UniformListScrollState {
207 base_handle: ScrollHandle::new(),
208 deferred_scroll_to_item: None,
209 last_item_size: None,
210 y_flipped: false,
211 })))
212 }
213
214 /// Scrolls the least amount that brings row `ix` fully into view, placed by `strategy`.
215 ///
216 /// This is non-strict: a row that is already fully visible doesn't move the list, so
217 /// `ScrollStrategy::Top` does nothing for a visible row. Use [`Self::scroll_to_item_strict`]
218 /// to place the row every time. The scroll takes effect on the next prepaint.
219 ///
220 /// # Examples
221 ///
222 /// ```
223 /// # use gpui::*;
224 /// struct Rows {
225 /// scroll: UniformListScrollHandle,
226 /// }
227 ///
228 /// impl Render for Rows {
229 /// fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
230 /// div().h(px(320.)).child(
231 /// uniform_list("rows", 100, |range, _window, _cx| {
232 /// range
233 /// .map(|ix| div().id(ix).h(px(32.)).child(format!("Row {ix}")))
234 /// .collect::<Vec<_>>()
235 /// })
236 /// .track_scroll(&self.scroll)
237 /// .size_full(),
238 /// )
239 /// }
240 /// }
241 /// # let mut app = TestApp::new();
242 /// # let mut window = app.open_window(|_, _| Rows { scroll: UniformListScrollHandle::new() });
243 /// window.update(|rows, _, _| rows.scroll.scroll_to_item(3, ScrollStrategy::Top));
244 /// window.draw();
245 /// // Row 3 is already in the 320px viewport, so the list didn't move.
246 /// window.read(|rows, _| assert_eq!(rows.scroll.0.borrow().base_handle.offset().y, px(0.)));
247 ///
248 /// window.update(|rows, _, _| rows.scroll.scroll_to_item_strict(3, ScrollStrategy::Top));
249 /// window.draw();
250 /// // The strict form puts row 3 at the top: 3 rows of 32px scrolled past.
251 /// window.read(|rows, _| assert_eq!(rows.scroll.0.borrow().base_handle.offset().y, px(-96.)));
252 /// ```
253 pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
254 self.revised().deferred_scroll_to_item = Some(DeferredScrollToItem {
255 item_index: ix,
256 strategy,
257 offset: 0,
258 scroll_strict: false,
259 });
260 }
261
262 /// Places row `ix` where `strategy` says, even when the row is already visible.
263 ///
264 /// Unlike [`Self::scroll_to_item`], this forces the requested placement when the strategy
265 /// supports one. With [`ScrollStrategy::Nearest`], a visible row still doesn't move. Use it
266 /// when a row must land at a fixed position, such as the top after a keyboard jump. The scroll
267 /// takes effect on the next prepaint; see [`Self::scroll_to_item`] for an example.
268 pub fn scroll_to_item_strict(&self, ix: usize, strategy: ScrollStrategy) {
269 self.revised().deferred_scroll_to_item = Some(DeferredScrollToItem {
270 item_index: ix,
271 strategy,
272 offset: 0,
273 scroll_strict: true,
274 });
275 }
276
277 /// Scrolls like [`Self::scroll_to_item`], and keeps `offset` rows clear at the top of the viewport.
278 ///
279 /// `offset` shrinks the viewport by that many rows from the top, and `strategy` places the
280 /// row in what remains:
281 /// - [`ScrollStrategy::Top`] puts the row just below the cleared rows.
282 /// - [`ScrollStrategy::Center`] centers the row in the rows below the cleared rows.
283 /// - [`ScrollStrategy::Bottom`] places the row at the bottom of the viewport. `offset` affects
284 /// only whether the row counts as already visible, not where it lands.
285 ///
286 /// This is non-strict, so a row that is visible below the cleared rows doesn't move the list.
287 pub fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) {
288 self.revised().deferred_scroll_to_item = Some(DeferredScrollToItem {
289 item_index: ix,
290 strategy,
291 offset,
292 scroll_strict: false,
293 });
294 }
295
296 /// Places row `ix` like [`Self::scroll_to_item_with_offset`], even when the row is already visible.
297 ///
298 /// The `offset` rules are the same as for [`Self::scroll_to_item_with_offset`].
299 pub fn scroll_to_item_strict_with_offset(
300 &self,
301 ix: usize,
302 strategy: ScrollStrategy,
303 offset: usize,
304 ) {
305 self.revised().deferred_scroll_to_item = Some(DeferredScrollToItem {
306 item_index: ix,
307 strategy,
308 offset,
309 scroll_strict: true,
310 });
311 }
312
313 /// Returns whether item 0 is at the bottom of the list, as set by [`UniformList::y_flipped`]
314 /// on the last frame.
315 pub fn y_flipped(&self) -> bool {
316 self.0.borrow().y_flipped
317 }
318
319 /// Returns the index of the first visible row.
320 ///
321 /// If a scroll request is pending, this returns the row that request targets instead, so the
322 /// value is right after a request and one frame later. It is zero before the first prepaint.
323 ///
324 /// Only available with the `test-support` feature, or in GPUI's own tests.
325 #[cfg(any(test, feature = "test-support"))]
326 pub fn logical_scroll_top_index(&self) -> usize {
327 let this = self.0.borrow();
328 this.deferred_scroll_to_item
329 .as_ref()
330 .map(|deferred| deferred.item_index)
331 .unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
332 }
333
334 /// Returns whether the rows are taller than the viewport, as of the last prepaint.
335 ///
336 /// Returns `false` before the first prepaint.
337 pub fn is_scrollable(&self) -> bool {
338 if let Some(size) = self.0.borrow().last_item_size {
339 size.contents.height > size.item.height
340 } else {
341 false
342 }
343 }
344
345 /// Returns whether the list shows its last row, or `None` if the list isn't scrollable.
346 ///
347 /// `None` is also returned before the first prepaint.
348 pub fn is_scrolled_to_end(&self) -> Option<bool> {
349 let state = self.0.borrow();
350 let max_offset = state.base_handle.max_offset();
351 if max_offset.y <= px(0.) {
352 return None;
353 }
354 let offset = state.base_handle.offset();
355 Some(-offset.y >= max_offset.y)
356 }
357
358 /// Scrolls to the last row, as a request for the next prepaint.
359 pub fn scroll_to_bottom(&self) {
360 self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom);
361 }
362}
363
364impl Styled for UniformList {
365 fn style(&mut self) -> &mut StyleRefinement {
366 &mut self.interactivity.base_style
367 }
368}
369
370impl Element for UniformList {
371 type RequestLayoutState = UniformListFrameState;
372 type PrepaintState = Option<Hitbox>;
373
374 fn id(&self) -> Option<ElementId> {
375 self.interactivity.element_id.clone()
376 }
377
378 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
379 None
380 }
381
382 fn request_layout(
383 &mut self,
384 global_id: Option<&GlobalElementId>,
385 inspector_id: Option<&InspectorElementId>,
386 window: &mut Window,
387 cx: &mut App,
388 ) -> (LayoutId, Self::RequestLayoutState) {
389 let max_items = self.item_count;
390 let item_size = self.measure_item(None, window, cx);
391 let layout_id = self.interactivity.request_layout(
392 global_id,
393 inspector_id,
394 window,
395 cx,
396 |style, window, cx| match self.sizing_behavior {
397 ListSizingBehavior::Infer => {
398 window.with_text_style(style.text_style().cloned(), |window| {
399 window.request_measured_layout(
400 style,
401 move |known_dimensions, available_space, _window, _cx| {
402 let desired_height = item_size.height * max_items;
403 let width = known_dimensions.width.unwrap_or(match available_space
404 .width
405 {
406 AvailableSpace::Definite(x) => x,
407 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
408 item_size.width
409 }
410 });
411 let height = match available_space.height {
412 AvailableSpace::Definite(height) => desired_height.min(height),
413 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
414 desired_height
415 }
416 };
417 size(width, height)
418 },
419 )
420 })
421 }
422 ListSizingBehavior::Auto => window
423 .with_text_style(style.text_style().cloned(), |window| {
424 window.request_layout(style, None, cx)
425 }),
426 },
427 );
428
429 (
430 layout_id,
431 UniformListFrameState {
432 items: SmallVec::new(),
433 decorations: SmallVec::new(),
434 },
435 )
436 }
437
438 fn prepaint(
439 &mut self,
440 global_id: Option<&GlobalElementId>,
441 inspector_id: Option<&InspectorElementId>,
442 bounds: Bounds<Pixels>,
443 frame_state: &mut Self::RequestLayoutState,
444 window: &mut Window,
445 cx: &mut App,
446 ) -> Option<Hitbox> {
447 let style = self
448 .interactivity
449 .compute_style(global_id, None, window, cx);
450 let border = style.border_widths.to_pixels(window.rem_size());
451 let padding = style
452 .padding
453 .to_pixels(bounds.size.into(), window.rem_size());
454
455 let padded_bounds = Bounds::from_corners(
456 bounds.origin + point(border.left + padding.left, border.top + padding.top),
457 bounds.bottom_right()
458 - point(border.right + padding.right, border.bottom + padding.bottom),
459 );
460
461 let can_scroll_horizontally = matches!(
462 self.horizontal_sizing_behavior,
463 ListHorizontalSizingBehavior::Unconstrained
464 );
465
466 let longest_item_size = self.measure_item(None, window, cx);
467 let content_width = if can_scroll_horizontally {
468 padded_bounds.size.width.max(longest_item_size.width)
469 } else {
470 padded_bounds.size.width
471 };
472 let content_size = Size {
473 width: content_width,
474 height: longest_item_size.height * self.item_count,
475 };
476
477 let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
478 let item_height = longest_item_size.height;
479 let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| {
480 let mut handle = handle.0.borrow_mut();
481 handle.last_item_size = Some(ItemSize {
482 item: padded_bounds.size,
483 contents: content_size,
484 });
485 handle.deferred_scroll_to_item.take()
486 });
487
488 self.interactivity.prepaint(
489 global_id,
490 inspector_id,
491 bounds,
492 content_size,
493 window,
494 cx,
495 |_style, mut scroll_offset, hitbox, window, cx| {
496 let y_flipped = if let Some(scroll_handle) = &self.scroll_handle {
497 let scroll_state = scroll_handle.0.borrow();
498 scroll_state.y_flipped
499 } else {
500 false
501 };
502
503 if self.item_count > 0 {
504 let content_height = item_height * self.item_count;
505
506 let is_scrolled_vertically = !scroll_offset.y.is_zero();
507 let max_scroll_offset = padded_bounds.size.height - content_height;
508
509 if is_scrolled_vertically && scroll_offset.y < max_scroll_offset {
510 shared_scroll_offset.borrow_mut().y = max_scroll_offset;
511 scroll_offset.y = max_scroll_offset;
512 }
513
514 let content_width = content_size.width + padding.left + padding.right;
515 let is_scrolled_horizontally =
516 can_scroll_horizontally && !scroll_offset.x.is_zero();
517 if is_scrolled_horizontally && content_width <= padded_bounds.size.width {
518 shared_scroll_offset.borrow_mut().x = Pixels::ZERO;
519 scroll_offset.x = Pixels::ZERO;
520 }
521
522 if let Some(DeferredScrollToItem {
523 mut item_index,
524 mut strategy,
525 offset,
526 scroll_strict,
527 }) = shared_scroll_to_item
528 {
529 if y_flipped {
530 item_index =
531 self.item_count.saturating_sub(item_index.saturating_add(1));
532 }
533 let list_height = padded_bounds.size.height;
534 let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
535 let item_top = item_height * item_index;
536 let item_bottom = item_top + item_height;
537 let scroll_top = -updated_scroll_offset.y;
538 let offset_pixels = item_height * offset;
539
540 // is the selected item above/below currently visible items
541 let is_above = item_top < scroll_top + offset_pixels;
542 let is_below = item_bottom > scroll_top + list_height;
543
544 if scroll_strict || is_above || is_below {
545 if strategy == ScrollStrategy::Nearest {
546 if is_above {
547 strategy = ScrollStrategy::Top;
548 } else if is_below {
549 strategy = ScrollStrategy::Bottom;
550 }
551 }
552
553 let max_scroll_offset =
554 (content_height - list_height).max(Pixels::ZERO);
555 match strategy {
556 ScrollStrategy::Top => {
557 updated_scroll_offset.y = -(item_top - offset_pixels)
558 .clamp(Pixels::ZERO, max_scroll_offset);
559 }
560 ScrollStrategy::Center => {
561 let item_center = item_top + item_height / 2.0;
562
563 let viewport_height = list_height - offset_pixels;
564 let viewport_center = offset_pixels + viewport_height / 2.0;
565 let target_scroll_top = item_center - viewport_center;
566 updated_scroll_offset.y =
567 -target_scroll_top.clamp(Pixels::ZERO, max_scroll_offset);
568 }
569 ScrollStrategy::Bottom => {
570 updated_scroll_offset.y = -(item_bottom - list_height)
571 .clamp(Pixels::ZERO, max_scroll_offset);
572 }
573 ScrollStrategy::Nearest => {
574 // Nearest, but the item is visible -> no scroll is required
575 }
576 }
577 }
578 scroll_offset = *updated_scroll_offset
579 }
580
581 let scroll_top = -scroll_offset.y.to_f64();
582 let item_height_f64 = item_height.to_f64();
583 let first_visible_element_ix =
584 ((scroll_top - padding.top.to_f64()) / item_height_f64).floor() as usize;
585 let last_visible_element_ix = ((scroll_top
586 + padded_bounds.size.height.to_f64())
587 / item_height_f64)
588 .ceil() as usize;
589
590 let visible_range = first_visible_element_ix
591 ..cmp::min(last_visible_element_ix, self.item_count);
592
593 let items = if y_flipped {
594 let flipped_range = self.item_count.saturating_sub(visible_range.end)
595 ..self.item_count.saturating_sub(visible_range.start);
596 let mut items = (self.render_items)(flipped_range, window, cx);
597 items.reverse();
598 items
599 } else {
600 (self.render_items)(visible_range.clone(), window, cx)
601 };
602
603 window.with_content_mask(
604 Some(style.scroll_mask(bounds, window.rem_size())),
605 |window| {
606 for (mut item, ix) in items.into_iter().zip(visible_range.clone()) {
607 let item_top = item_height_f64 * ix as f64 - scroll_top;
608 let item_origin = padded_bounds.origin
609 + point(scroll_offset.x, Pixels::from(item_top));
610 let available_width = if can_scroll_horizontally {
611 padded_bounds.size.width + scroll_offset.x.abs()
612 } else {
613 padded_bounds.size.width
614 };
615 let available_space = size(
616 AvailableSpace::Definite(available_width),
617 AvailableSpace::Definite(item_height),
618 );
619 item.layout_as_root(available_space, window, cx);
620 item.prepaint_at(item_origin, window, cx);
621 frame_state.items.push(item);
622 }
623
624 let bounds = Bounds::new(
625 padded_bounds.origin + scroll_offset,
626 padded_bounds.size,
627 );
628 for decoration in &self.decorations {
629 let mut decoration = decoration.as_ref().compute(
630 visible_range.clone(),
631 bounds,
632 scroll_offset,
633 item_height,
634 self.item_count,
635 window,
636 cx,
637 );
638 let available_space = size(
639 AvailableSpace::Definite(bounds.size.width),
640 AvailableSpace::Definite(bounds.size.height),
641 );
642 decoration.layout_as_root(available_space, window, cx);
643 decoration.prepaint_at(bounds.origin, window, cx);
644 frame_state.decorations.push(decoration);
645 }
646 },
647 );
648 }
649
650 hitbox
651 },
652 )
653 }
654
655 fn paint(
656 &mut self,
657 global_id: Option<&GlobalElementId>,
658 inspector_id: Option<&InspectorElementId>,
659 bounds: Bounds<crate::Pixels>,
660 request_layout: &mut Self::RequestLayoutState,
661 hitbox: &mut Option<Hitbox>,
662 window: &mut Window,
663 cx: &mut App,
664 ) {
665 self.interactivity.paint(
666 global_id,
667 inspector_id,
668 bounds,
669 hitbox.as_ref(),
670 window,
671 cx,
672 |_, window, cx| {
673 for item in &mut request_layout.items {
674 item.paint(window, cx);
675 }
676 for decoration in &mut request_layout.decorations {
677 decoration.paint(window, cx);
678 }
679 },
680 )
681 }
682}
683
684impl IntoElement for UniformList {
685 type Element = Self;
686
687 fn into_element(self) -> Self::Element {
688 self
689 }
690}
691
692/// An element drawn over the rows of a [`UniformList`], such as indent guides.
693///
694/// Add one with [`UniformList::with_decoration`]. The list computes it each frame, after the rows.
695///
696/// # Examples
697///
698/// ```
699/// # use gpui::*;
700/// # use std::{cell::Cell, ops::Range, rc::Rc};
701/// struct CountCalls(Rc<Cell<usize>>);
702///
703/// impl UniformListDecoration for CountCalls {
704/// fn compute(
705/// &self,
706/// _visible_range: Range<usize>,
707/// _bounds: Bounds<Pixels>,
708/// _scroll_offset: Point<Pixels>,
709/// _item_height: Pixels,
710/// _item_count: usize,
711/// _window: &mut Window,
712/// _cx: &mut App,
713/// ) -> AnyElement {
714/// self.0.set(self.0.get() + 1);
715/// div().into_any_element()
716/// }
717/// }
718///
719/// struct Rows {
720/// calls: Rc<Cell<usize>>,
721/// scroll: UniformListScrollHandle,
722/// }
723///
724/// impl Render for Rows {
725/// fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
726/// uniform_list("rows", 100, |range, _window, _cx| {
727/// range
728/// .map(|ix| div().id(ix).h(px(32.)).child(format!("Row {ix}")))
729/// .collect::<Vec<_>>()
730/// })
731/// .track_scroll(&self.scroll)
732/// .with_decoration(CountCalls(self.calls.clone()))
733/// .size_full()
734/// }
735/// }
736/// # let mut app = TestApp::new();
737/// # let calls = Rc::new(Cell::new(0));
738/// # let mut window = app.open_window(|_, _| Rows {
739/// # calls: calls.clone(),
740/// # scroll: UniformListScrollHandle::new(),
741/// # });
742/// window.draw();
743/// assert!(calls.get() > 0);
744/// ```
745pub trait UniformListDecoration {
746 /// Returns the element to draw, given the rows in view and the list's geometry.
747 ///
748 /// `visible_range` holds the rows in view. `bounds` has the viewport's size, moved by
749 /// `scroll_offset`, so that row `ix` is at `bounds.origin + point(0, item_height * ix)`.
750 /// `scroll_offset` is zero or negative, and `item_count` is the number of rows. The element
751 /// is laid out at `bounds`.
752 fn compute(
753 &self,
754 visible_range: Range<usize>,
755 bounds: Bounds<Pixels>,
756 scroll_offset: Point<Pixels>,
757 item_height: Pixels,
758 item_count: usize,
759 window: &mut Window,
760 cx: &mut App,
761 ) -> AnyElement;
762}
763
764impl<T: UniformListDecoration + 'static> UniformListDecoration for Entity<T> {
765 fn compute(
766 &self,
767 visible_range: Range<usize>,
768 bounds: Bounds<Pixels>,
769 scroll_offset: Point<Pixels>,
770 item_height: Pixels,
771 item_count: usize,
772 window: &mut Window,
773 cx: &mut App,
774 ) -> AnyElement {
775 self.update(cx, |inner, cx| {
776 inner.compute(
777 visible_range,
778 bounds,
779 scroll_offset,
780 item_height,
781 item_count,
782 window,
783 cx,
784 )
785 })
786 }
787}
788
789impl UniformList {
790 /// Chooses the row that sets the width and height of every row. `None` measures row 0.
791 ///
792 /// An index past the last row measures the last row. The list measures the chosen row every
793 /// frame, so the row should be cheap to render.
794 pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
795 self.item_to_measure_index = item_index.unwrap_or(0);
796 self
797 }
798
799 /// Sets how the list sizes itself, like [`List::with_sizing_behavior`](crate::List::with_sizing_behavior).
800 ///
801 /// [`ListSizingBehavior::Auto`], the default, takes the size from the style.
802 /// [`ListSizingBehavior::Infer`] makes the list as tall as all its rows, up to the space its
803 /// parent gives it.
804 pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
805 self.sizing_behavior = behavior;
806 self
807 }
808
809 /// Sets whether rows can be wider than the list.
810 ///
811 /// With [`ListHorizontalSizingBehavior::FitList`], the default, rows are no wider than the list.
812 /// With [`ListHorizontalSizingBehavior::Unconstrained`], the content is as wide as the row
813 /// chosen by [`Self::with_width_from_item`], and the list scrolls horizontally when that is
814 /// wider than the list.
815 pub fn with_horizontal_sizing_behavior(
816 mut self,
817 behavior: ListHorizontalSizingBehavior,
818 ) -> Self {
819 self.horizontal_sizing_behavior = behavior;
820 match behavior {
821 ListHorizontalSizingBehavior::FitList => {
822 self.interactivity.base_style.overflow.x = None;
823 }
824 ListHorizontalSizingBehavior::Unconstrained => {
825 self.interactivity.base_style.overflow.x = Some(Overflow::Scroll);
826 }
827 }
828 self
829 }
830
831 /// Adds a decoration that is drawn over the rows. Decorations are drawn in the order added.
832 pub fn with_decoration(mut self, decoration: impl UniformListDecoration + 'static) -> Self {
833 self.decorations.push(Box::new(decoration));
834 self
835 }
836
837 fn measure_item(
838 &self,
839 list_width: Option<Pixels>,
840 window: &mut Window,
841 cx: &mut App,
842 ) -> Size<Pixels> {
843 if self.item_count == 0 {
844 return Size::default();
845 }
846
847 let item_ix = cmp::min(self.item_to_measure_index, self.item_count - 1);
848 let mut items = (self.render_items)(item_ix..item_ix + 1, window, cx);
849 let Some(mut item_to_measure) = items.pop() else {
850 return Size::default();
851 };
852 let available_space = size(
853 list_width.map_or(AvailableSpace::MaxContent, |width| {
854 AvailableSpace::Definite(width)
855 }),
856 AvailableSpace::MinContent,
857 );
858 item_to_measure.layout_as_root(available_space, window, cx)
859 }
860
861 /// Connects the list to `handle`, so the list reads scroll requests from it and writes its
862 /// scroll position to it.
863 ///
864 /// Without this, the list still scrolls with the wheel, but nothing outside it can read or
865 /// set the position.
866 pub fn track_scroll(mut self, handle: &UniformListScrollHandle) -> Self {
867 self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone());
868 self.scroll_handle = Some(handle.clone());
869 self
870 }
871
872 /// Puts item 0 at the bottom of the list, so the first row sits at the bottom and the last at
873 /// the top.
874 ///
875 /// The list keeps its scroll position on the same rows when the value changes. This only takes
876 /// effect when a handle is set with [`Self::track_scroll`]. Call it after `track_scroll`,
877 /// because it reads the handle that `track_scroll` sets.
878 pub fn y_flipped(mut self, y_flipped: bool) -> Self {
879 if let Some(ref scroll_handle) = self.scroll_handle {
880 let mut scroll_state = scroll_handle.0.borrow_mut();
881 let mut base_handle = &scroll_state.base_handle;
882 let offset = base_handle.offset();
883 match scroll_state.last_item_size {
884 Some(last_size) if scroll_state.y_flipped != y_flipped => {
885 let new_y_offset =
886 -(offset.y + last_size.contents.height - last_size.item.height);
887 base_handle.set_offset(point(offset.x, new_y_offset));
888 scroll_state.y_flipped = y_flipped;
889 }
890 // Handle case where list is initially flipped.
891 None if y_flipped => {
892 base_handle.set_offset(point(offset.x, Pixels::MIN));
893 scroll_state.y_flipped = y_flipped;
894 }
895 _ => {}
896 }
897 }
898 self
899 }
900}
901
902impl InteractiveElement for UniformList {
903 fn interactivity(&mut self) -> &mut crate::Interactivity {
904 &mut self.interactivity
905 }
906}
907
908#[cfg(test)]
909mod test {
910 use crate::TestAppContext;
911
912 #[gpui::test]
913 fn scroll_to_bottom_in_flipped_list_does_not_overflow(cx: &mut TestAppContext) {
914 use crate::{
915 AppContext as _, Context, IntoElement, Render, Styled, UniformListScrollHandle, Window,
916 div, px, size, uniform_list,
917 };
918
919 struct FlippedRows(UniformListScrollHandle);
920 impl Render for FlippedRows {
921 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
922 uniform_list("rows", 47, |range, _, _| {
923 range.map(|_| div().h(px(20.))).collect()
924 })
925 .track_scroll(&self.0)
926 .y_flipped(true)
927 .size_full()
928 }
929 }
930
931 let handle = UniformListScrollHandle::new();
932 let window = cx.open_window(size(px(200.), px(200.)), |_, _| FlippedRows(handle.clone()));
933 let mut draw = || {
934 cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear(cx))
935 .unwrap();
936 };
937 draw();
938 handle.scroll_to_bottom();
939 draw();
940 assert_eq!(handle.0.borrow().base_handle.offset().y, px(0.));
941 assert!(handle.0.borrow().deferred_scroll_to_item.is_none());
942 }
943
944 #[gpui::test]
945 fn test_scroll_strategy_nearest(cx: &mut TestAppContext) {
946 use crate::{
947 Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, div, prelude::*,
948 px, uniform_list,
949 };
950 use std::ops::Range;
951
952 actions!(example, [SelectNext, SelectPrev]);
953
954 struct TestView {
955 index: usize,
956 length: usize,
957 scroll_handle: UniformListScrollHandle,
958 focus_handle: FocusHandle,
959 visible_range: Range<usize>,
960 }
961
962 impl TestView {
963 pub fn select_next(
964 &mut self,
965 _: &SelectNext,
966 window: &mut Window,
967 _: &mut Context<Self>,
968 ) {
969 if self.index + 1 == self.length {
970 self.index = 0
971 } else {
972 self.index += 1;
973 }
974 self.scroll_handle
975 .scroll_to_item(self.index, ScrollStrategy::Nearest);
976 window.refresh();
977 }
978
979 pub fn select_previous(
980 &mut self,
981 _: &SelectPrev,
982 window: &mut Window,
983 _: &mut Context<Self>,
984 ) {
985 if self.index == 0 {
986 self.index = self.length - 1
987 } else {
988 self.index -= 1;
989 }
990 self.scroll_handle
991 .scroll_to_item(self.index, ScrollStrategy::Nearest);
992 window.refresh();
993 }
994 }
995
996 impl Render for TestView {
997 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
998 div()
999 .id("list-example")
1000 .track_focus(&self.focus_handle)
1001 .on_action(cx.listener(Self::select_next))
1002 .on_action(cx.listener(Self::select_previous))
1003 .size_full()
1004 .child(
1005 uniform_list(
1006 "entries",
1007 self.length,
1008 cx.processor(|this, range: Range<usize>, _window, _cx| {
1009 this.visible_range = range.clone();
1010 range
1011 .map(|ix| div().id(ix).h(px(20.0)).child(format!("Item {ix}")))
1012 .collect()
1013 }),
1014 )
1015 .track_scroll(&self.scroll_handle)
1016 .h(px(200.0)),
1017 )
1018 }
1019 }
1020
1021 let (view, cx) = cx.add_window_view(|window, cx| {
1022 let focus_handle = cx.focus_handle();
1023 window.focus(&focus_handle, cx);
1024 TestView {
1025 scroll_handle: UniformListScrollHandle::new(),
1026 index: 0,
1027 focus_handle,
1028 length: 47,
1029 visible_range: 0..0,
1030 }
1031 });
1032
1033 // 10 out of 47 items are visible
1034
1035 // First 9 times selecting next item does not scroll
1036 for ix in 1..10 {
1037 cx.dispatch_action(SelectNext);
1038 view.read_with(cx, |view, _| {
1039 assert_eq!(view.index, ix);
1040 assert_eq!(view.visible_range, 0..10);
1041 })
1042 }
1043
1044 // Now each time the list scrolls down by 1
1045 for ix in 10..47 {
1046 cx.dispatch_action(SelectNext);
1047 view.read_with(cx, |view, _| {
1048 assert_eq!(view.index, ix);
1049 assert_eq!(view.visible_range, ix - 9..ix + 1);
1050 })
1051 }
1052
1053 // After the last item we move back to the start
1054 cx.dispatch_action(SelectNext);
1055 view.read_with(cx, |view, _| {
1056 assert_eq!(view.index, 0);
1057 assert_eq!(view.visible_range, 0..10);
1058 });
1059
1060 // Return to the last element
1061 cx.dispatch_action(SelectPrev);
1062 view.read_with(cx, |view, _| {
1063 assert_eq!(view.index, 46);
1064 assert_eq!(view.visible_range, 37..47);
1065 });
1066
1067 // First 9 times selecting previous does not scroll
1068 for ix in (37..46).rev() {
1069 cx.dispatch_action(SelectPrev);
1070 view.read_with(cx, |view, _| {
1071 assert_eq!(view.index, ix);
1072 assert_eq!(view.visible_range, 37..47);
1073 })
1074 }
1075
1076 // Now each time the list scrolls up by 1
1077 for ix in (0..37).rev() {
1078 cx.dispatch_action(SelectPrev);
1079 view.read_with(cx, |view, _| {
1080 assert_eq!(view.index, ix);
1081 assert_eq!(view.visible_range, ix..ix + 10);
1082 })
1083 }
1084 }
1085
1086 #[gpui::test]
1087 fn test_item_positions_far_down_long_list(cx: &mut TestAppContext) {
1088 use crate::{
1089 Bounds, Context, Pixels, UniformListScrollHandle, Window, canvas, point, prelude::*,
1090 px, uniform_list,
1091 };
1092 use std::{cell::RefCell, ops::Range, rc::Rc};
1093
1094 struct TestView {
1095 scroll_handle: UniformListScrollHandle,
1096 item_bounds: Rc<RefCell<Vec<(usize, Bounds<Pixels>)>>>,
1097 }
1098
1099 impl Render for TestView {
1100 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1101 let item_bounds = self.item_bounds.clone();
1102 uniform_list(
1103 "entries",
1104 10_000_000,
1105 cx.processor(move |_, range: Range<usize>, _window, _cx| {
1106 range
1107 .map(|ix| {
1108 let item_bounds = item_bounds.clone();
1109 canvas(
1110 move |bounds, _, _| item_bounds.borrow_mut().push((ix, bounds)),
1111 |_, _, _, _| {},
1112 )
1113 .w_full()
1114 .h(px(24.0))
1115 })
1116 .collect()
1117 }),
1118 )
1119 .track_scroll(&self.scroll_handle)
1120 .h(px(200.0))
1121 }
1122 }
1123
1124 let item_bounds = Rc::new(RefCell::new(Vec::new()));
1125 let (view, cx) = cx.add_window_view(|_, _| TestView {
1126 scroll_handle: UniformListScrollHandle::new(),
1127 item_bounds: item_bounds.clone(),
1128 });
1129
1130 // Past 2^27px, f32 can only represent multiples of 16px. This offset is 16px into item
1131 // 9_000_002, and the items must still be laid out 24px apart from that point.
1132 item_bounds.borrow_mut().clear();
1133 view.update(cx, |view, cx| {
1134 view.scroll_handle
1135 .0
1136 .borrow()
1137 .base_handle
1138 .set_offset(point(px(0.0), px(-216_000_064.0)));
1139 cx.notify();
1140 });
1141 cx.run_until_parked();
1142
1143 let item_tops = item_bounds
1144 .borrow()
1145 .iter()
1146 .map(|(ix, bounds)| (*ix, bounds.origin.y))
1147 .collect::<Vec<_>>();
1148 let expected_tops = (9_000_002..9_000_011)
1149 .map(|ix| (ix, px(-16.0 + 24.0 * (ix - 9_000_002) as f32)))
1150 .collect::<Vec<_>>();
1151 assert_eq!(item_tops, expected_tops);
1152 }
1153}