1//! A list element that can be used to render a large number of differently sized elements
2//! efficiently. Clients of this API need to ensure that elements outside of the scrolled
3//! area do not change their height for this element to function correctly. In order to minimize
4//! re-renders, this element's state is stored intrusively on your own views, so that your code
5//! can coordinate directly with the list element's cached state.
6//!
7//! If all of your elements are the same height, see [`UniformList`] for a simpler API
8
9use crate::{
10 point, px, AnyElement, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element,
11 IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style, StyleRefinement, Styled,
12 WindowContext,
13};
14use collections::VecDeque;
15use refineable::Refineable as _;
16use std::{cell::RefCell, ops::Range, rc::Rc};
17use sum_tree::{Bias, SumTree};
18
19/// Construct a new list element
20pub fn list(state: ListState) -> List {
21 List {
22 state,
23 style: StyleRefinement::default(),
24 }
25}
26
27/// A list element
28pub struct List {
29 state: ListState,
30 style: StyleRefinement,
31}
32
33/// The list state that views must hold on behalf of the list element.
34#[derive(Clone)]
35pub struct ListState(Rc<RefCell<StateInner>>);
36
37struct StateInner {
38 last_layout_bounds: Option<Bounds<Pixels>>,
39 render_item: Box<dyn FnMut(usize, &mut WindowContext) -> AnyElement>,
40 items: SumTree<ListItem>,
41 logical_scroll_top: Option<ListOffset>,
42 alignment: ListAlignment,
43 overdraw: Pixels,
44 reset: bool,
45 #[allow(clippy::type_complexity)]
46 scroll_handler: Option<Box<dyn FnMut(&ListScrollEvent, &mut WindowContext)>>,
47}
48
49/// Whether the list is scrolling from top to bottom or bottom to top.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum ListAlignment {
52 /// The list is scrolling from top to bottom, like most lists.
53 Top,
54 /// The list is scrolling from bottom to top, like a chat log.
55 Bottom,
56}
57
58/// A scroll event that has been converted to be in terms of the list's items.
59pub struct ListScrollEvent {
60 /// The range of items currently visible in the list, after applying the scroll event.
61 pub visible_range: Range<usize>,
62
63 /// The number of items that are currently visible in the list, after applying the scroll event.
64 pub count: usize,
65
66 /// Whether the list has been scrolled.
67 pub is_scrolled: bool,
68}
69
70#[derive(Clone)]
71enum ListItem {
72 Unrendered,
73 Rendered { height: Pixels },
74}
75
76#[derive(Clone, Debug, Default, PartialEq)]
77struct ListItemSummary {
78 count: usize,
79 rendered_count: usize,
80 unrendered_count: usize,
81 height: Pixels,
82}
83
84#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
85struct Count(usize);
86
87#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
88struct RenderedCount(usize);
89
90#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
91struct UnrenderedCount(usize);
92
93#[derive(Clone, Debug, Default)]
94struct Height(Pixels);
95
96impl ListState {
97 /// Construct a new list state, for storage on a view.
98 ///
99 /// the overdraw parameter controls how much extra space is rendered
100 /// above and below the visible area. This can help ensure that the list
101 /// doesn't flicker or pop in when scrolling.
102 pub fn new<F>(
103 element_count: usize,
104 orientation: ListAlignment,
105 overdraw: Pixels,
106 render_item: F,
107 ) -> Self
108 where
109 F: 'static + FnMut(usize, &mut WindowContext) -> AnyElement,
110 {
111 let mut items = SumTree::new();
112 items.extend((0..element_count).map(|_| ListItem::Unrendered), &());
113 Self(Rc::new(RefCell::new(StateInner {
114 last_layout_bounds: None,
115 render_item: Box::new(render_item),
116 items,
117 logical_scroll_top: None,
118 alignment: orientation,
119 overdraw,
120 scroll_handler: None,
121 reset: false,
122 })))
123 }
124
125 /// Reset this instantiation of the list state.
126 ///
127 /// Note that this will cause scroll events to be dropped until the next paint.
128 pub fn reset(&self, element_count: usize) {
129 let state = &mut *self.0.borrow_mut();
130 state.reset = true;
131
132 state.logical_scroll_top = None;
133 state.items = SumTree::new();
134 state
135 .items
136 .extend((0..element_count).map(|_| ListItem::Unrendered), &());
137 }
138
139 /// The number of items in this list.
140 pub fn item_count(&self) -> usize {
141 self.0.borrow().items.summary().count
142 }
143
144 /// Register with the list state that the items in `old_range` have been replaced
145 /// by `count` new items that must be recalculated.
146 pub fn splice(&self, old_range: Range<usize>, count: usize) {
147 let state = &mut *self.0.borrow_mut();
148
149 if let Some(ListOffset {
150 item_ix,
151 offset_in_item,
152 }) = state.logical_scroll_top.as_mut()
153 {
154 if old_range.contains(item_ix) {
155 *item_ix = old_range.start;
156 *offset_in_item = px(0.);
157 } else if old_range.end <= *item_ix {
158 *item_ix = *item_ix - (old_range.end - old_range.start) + count;
159 }
160 }
161
162 let mut old_heights = state.items.cursor::<Count>();
163 let mut new_heights = old_heights.slice(&Count(old_range.start), Bias::Right, &());
164 old_heights.seek_forward(&Count(old_range.end), Bias::Right, &());
165
166 new_heights.extend((0..count).map(|_| ListItem::Unrendered), &());
167 new_heights.append(old_heights.suffix(&()), &());
168 drop(old_heights);
169 state.items = new_heights;
170 }
171
172 /// Set a handler that will be called when the list is scrolled.
173 pub fn set_scroll_handler(
174 &self,
175 handler: impl FnMut(&ListScrollEvent, &mut WindowContext) + 'static,
176 ) {
177 self.0.borrow_mut().scroll_handler = Some(Box::new(handler))
178 }
179
180 /// Get the current scroll offset, in terms of the list's items.
181 pub fn logical_scroll_top(&self) -> ListOffset {
182 self.0.borrow().logical_scroll_top()
183 }
184
185 /// Scroll the list to the given offset
186 pub fn scroll_to(&self, mut scroll_top: ListOffset) {
187 let state = &mut *self.0.borrow_mut();
188 let item_count = state.items.summary().count;
189 if scroll_top.item_ix >= item_count {
190 scroll_top.item_ix = item_count;
191 scroll_top.offset_in_item = px(0.);
192 }
193
194 state.logical_scroll_top = Some(scroll_top);
195 }
196
197 /// Scroll the list to the given item, such that the item is fully visible.
198 pub fn scroll_to_reveal_item(&self, ix: usize) {
199 let state = &mut *self.0.borrow_mut();
200
201 let mut scroll_top = state.logical_scroll_top();
202 let height = state
203 .last_layout_bounds
204 .map_or(px(0.), |bounds| bounds.size.height);
205
206 if ix <= scroll_top.item_ix {
207 scroll_top.item_ix = ix;
208 scroll_top.offset_in_item = px(0.);
209 } else {
210 let mut cursor = state.items.cursor::<ListItemSummary>();
211 cursor.seek(&Count(ix + 1), Bias::Right, &());
212 let bottom = cursor.start().height;
213 let goal_top = px(0.).max(bottom - height);
214
215 cursor.seek(&Height(goal_top), Bias::Left, &());
216 let start_ix = cursor.start().count;
217 let start_item_top = cursor.start().height;
218
219 if start_ix >= scroll_top.item_ix {
220 scroll_top.item_ix = start_ix;
221 scroll_top.offset_in_item = goal_top - start_item_top;
222 }
223 }
224
225 state.logical_scroll_top = Some(scroll_top);
226 }
227
228 /// Get the bounds for the given item in window coordinates, if it's
229 /// been rendered.
230 pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
231 let state = &*self.0.borrow();
232
233 let bounds = state.last_layout_bounds.unwrap_or_default();
234 let scroll_top = state.logical_scroll_top();
235 if ix < scroll_top.item_ix {
236 return None;
237 }
238
239 let mut cursor = state.items.cursor::<(Count, Height)>();
240 cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
241
242 let scroll_top = cursor.start().1 .0 + scroll_top.offset_in_item;
243
244 cursor.seek_forward(&Count(ix), Bias::Right, &());
245 if let Some(&ListItem::Rendered { height }) = cursor.item() {
246 let &(Count(count), Height(top)) = cursor.start();
247 if count == ix {
248 let top = bounds.top() + top - scroll_top;
249 return Some(Bounds::from_corners(
250 point(bounds.left(), top),
251 point(bounds.right(), top + height),
252 ));
253 }
254 }
255 None
256 }
257}
258
259impl StateInner {
260 fn visible_range(&self, height: Pixels, scroll_top: &ListOffset) -> Range<usize> {
261 let mut cursor = self.items.cursor::<ListItemSummary>();
262 cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
263 let start_y = cursor.start().height + scroll_top.offset_in_item;
264 cursor.seek_forward(&Height(start_y + height), Bias::Left, &());
265 scroll_top.item_ix..cursor.start().count + 1
266 }
267
268 fn scroll(
269 &mut self,
270 scroll_top: &ListOffset,
271 height: Pixels,
272 delta: Point<Pixels>,
273 cx: &mut WindowContext,
274 ) {
275 // Drop scroll events after a reset, since we can't calculate
276 // the new logical scroll top without the item heights
277 if self.reset {
278 return;
279 }
280
281 let scroll_max = (self.items.summary().height - height).max(px(0.));
282 let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
283 .max(px(0.))
284 .min(scroll_max);
285
286 if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
287 self.logical_scroll_top = None;
288 } else {
289 let mut cursor = self.items.cursor::<ListItemSummary>();
290 cursor.seek(&Height(new_scroll_top), Bias::Right, &());
291 let item_ix = cursor.start().count;
292 let offset_in_item = new_scroll_top - cursor.start().height;
293 self.logical_scroll_top = Some(ListOffset {
294 item_ix,
295 offset_in_item,
296 });
297 }
298
299 if self.scroll_handler.is_some() {
300 let visible_range = self.visible_range(height, scroll_top);
301 self.scroll_handler.as_mut().unwrap()(
302 &ListScrollEvent {
303 visible_range,
304 count: self.items.summary().count,
305 is_scrolled: self.logical_scroll_top.is_some(),
306 },
307 cx,
308 );
309 }
310
311 cx.refresh();
312 }
313
314 fn logical_scroll_top(&self) -> ListOffset {
315 self.logical_scroll_top
316 .unwrap_or_else(|| match self.alignment {
317 ListAlignment::Top => ListOffset {
318 item_ix: 0,
319 offset_in_item: px(0.),
320 },
321 ListAlignment::Bottom => ListOffset {
322 item_ix: self.items.summary().count,
323 offset_in_item: px(0.),
324 },
325 })
326 }
327
328 fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels {
329 let mut cursor = self.items.cursor::<ListItemSummary>();
330 cursor.seek(&Count(logical_scroll_top.item_ix), Bias::Right, &());
331 cursor.start().height + logical_scroll_top.offset_in_item
332 }
333}
334
335impl std::fmt::Debug for ListItem {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 match self {
338 Self::Unrendered => write!(f, "Unrendered"),
339 Self::Rendered { height, .. } => {
340 f.debug_struct("Rendered").field("height", height).finish()
341 }
342 }
343 }
344}
345
346/// An offset into the list's items, in terms of the item index and the number
347/// of pixels off the top left of the item.
348#[derive(Debug, Clone, Copy, Default)]
349pub struct ListOffset {
350 /// The index of an item in the list
351 pub item_ix: usize,
352 /// The number of pixels to offset from the item index.
353 pub offset_in_item: Pixels,
354}
355
356impl Element for List {
357 type State = ();
358
359 fn request_layout(
360 &mut self,
361 _state: Option<Self::State>,
362 cx: &mut crate::ElementContext,
363 ) -> (crate::LayoutId, Self::State) {
364 let mut style = Style::default();
365 style.refine(&self.style);
366 let layout_id = cx.with_text_style(style.text_style().cloned(), |cx| {
367 cx.request_layout(&style, None)
368 });
369 (layout_id, ())
370 }
371
372 fn paint(
373 &mut self,
374 bounds: Bounds<crate::Pixels>,
375 _state: &mut Self::State,
376 cx: &mut crate::ElementContext,
377 ) {
378 let state = &mut *self.state.0.borrow_mut();
379
380 state.reset = false;
381
382 // If the width of the list has changed, invalidate all cached item heights
383 if state.last_layout_bounds.map_or(true, |last_bounds| {
384 last_bounds.size.width != bounds.size.width
385 }) {
386 state.items = SumTree::from_iter(
387 (0..state.items.summary().count).map(|_| ListItem::Unrendered),
388 &(),
389 )
390 }
391
392 let old_items = state.items.clone();
393 let mut measured_items = VecDeque::new();
394 let mut item_elements = VecDeque::new();
395 let mut rendered_height = px(0.);
396 let mut scroll_top = state.logical_scroll_top();
397
398 let available_item_space = Size {
399 width: AvailableSpace::Definite(bounds.size.width),
400 height: AvailableSpace::MinContent,
401 };
402
403 let mut cursor = old_items.cursor::<Count>();
404
405 // Render items after the scroll top, including those in the trailing overdraw
406 cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
407 for (ix, item) in cursor.by_ref().enumerate() {
408 let visible_height = rendered_height - scroll_top.offset_in_item;
409 if visible_height >= bounds.size.height + state.overdraw {
410 break;
411 }
412
413 // Use the previously cached height if available
414 let mut height = if let ListItem::Rendered { height } = item {
415 Some(*height)
416 } else {
417 None
418 };
419
420 // If we're within the visible area or the height wasn't cached, render and measure the item's element
421 if visible_height < bounds.size.height || height.is_none() {
422 let mut element = (state.render_item)(scroll_top.item_ix + ix, cx);
423 let element_size = element.measure(available_item_space, cx);
424 height = Some(element_size.height);
425 if visible_height < bounds.size.height {
426 item_elements.push_back(element);
427 }
428 }
429
430 let height = height.unwrap();
431 rendered_height += height;
432 measured_items.push_back(ListItem::Rendered { height });
433 }
434
435 // Prepare to start walking upward from the item at the scroll top.
436 cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
437
438 // If the rendered items do not fill the visible region, then adjust
439 // the scroll top upward.
440 if rendered_height - scroll_top.offset_in_item < bounds.size.height {
441 while rendered_height < bounds.size.height {
442 cursor.prev(&());
443 if cursor.item().is_some() {
444 let mut element = (state.render_item)(cursor.start().0, cx);
445 let element_size = element.measure(available_item_space, cx);
446
447 rendered_height += element_size.height;
448 measured_items.push_front(ListItem::Rendered {
449 height: element_size.height,
450 });
451 item_elements.push_front(element)
452 } else {
453 break;
454 }
455 }
456
457 scroll_top = ListOffset {
458 item_ix: cursor.start().0,
459 offset_in_item: rendered_height - bounds.size.height,
460 };
461
462 match state.alignment {
463 ListAlignment::Top => {
464 scroll_top.offset_in_item = scroll_top.offset_in_item.max(px(0.));
465 state.logical_scroll_top = Some(scroll_top);
466 }
467 ListAlignment::Bottom => {
468 scroll_top = ListOffset {
469 item_ix: cursor.start().0,
470 offset_in_item: rendered_height - bounds.size.height,
471 };
472 state.logical_scroll_top = None;
473 }
474 };
475 }
476
477 // Measure items in the leading overdraw
478 let mut leading_overdraw = scroll_top.offset_in_item;
479 while leading_overdraw < state.overdraw {
480 cursor.prev(&());
481 if let Some(item) = cursor.item() {
482 let height = if let ListItem::Rendered { height } = item {
483 *height
484 } else {
485 let mut element = (state.render_item)(cursor.start().0, cx);
486 element.measure(available_item_space, cx).height
487 };
488
489 leading_overdraw += height;
490 measured_items.push_front(ListItem::Rendered { height });
491 } else {
492 break;
493 }
494 }
495
496 let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len());
497 let mut cursor = old_items.cursor::<Count>();
498 let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right, &());
499 new_items.extend(measured_items, &());
500 cursor.seek(&Count(measured_range.end), Bias::Right, &());
501 new_items.append(cursor.suffix(&()), &());
502
503 // Paint the visible items
504 cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
505 let mut item_origin = bounds.origin;
506 item_origin.y -= scroll_top.offset_in_item;
507 for item_element in &mut item_elements {
508 let item_height = item_element.measure(available_item_space, cx).height;
509 item_element.draw(item_origin, available_item_space, cx);
510 item_origin.y += item_height;
511 }
512 });
513
514 state.items = new_items;
515 state.last_layout_bounds = Some(bounds);
516
517 let list_state = self.state.clone();
518 let height = bounds.size.height;
519
520 cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| {
521 if phase == DispatchPhase::Bubble
522 && bounds.contains(&event.position)
523 && cx.was_top_layer(&event.position, cx.stacking_order())
524 {
525 list_state.0.borrow_mut().scroll(
526 &scroll_top,
527 height,
528 event.delta.pixel_delta(px(20.)),
529 cx,
530 )
531 }
532 });
533 }
534}
535
536impl IntoElement for List {
537 type Element = Self;
538
539 fn element_id(&self) -> Option<crate::ElementId> {
540 None
541 }
542
543 fn into_element(self) -> Self::Element {
544 self
545 }
546}
547
548impl Styled for List {
549 fn style(&mut self) -> &mut StyleRefinement {
550 &mut self.style
551 }
552}
553
554impl sum_tree::Item for ListItem {
555 type Summary = ListItemSummary;
556
557 fn summary(&self) -> Self::Summary {
558 match self {
559 ListItem::Unrendered => ListItemSummary {
560 count: 1,
561 rendered_count: 0,
562 unrendered_count: 1,
563 height: px(0.),
564 },
565 ListItem::Rendered { height } => ListItemSummary {
566 count: 1,
567 rendered_count: 1,
568 unrendered_count: 0,
569 height: *height,
570 },
571 }
572 }
573}
574
575impl sum_tree::Summary for ListItemSummary {
576 type Context = ();
577
578 fn add_summary(&mut self, summary: &Self, _: &()) {
579 self.count += summary.count;
580 self.rendered_count += summary.rendered_count;
581 self.unrendered_count += summary.unrendered_count;
582 self.height += summary.height;
583 }
584}
585
586impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Count {
587 fn add_summary(&mut self, summary: &'a ListItemSummary, _: &()) {
588 self.0 += summary.count;
589 }
590}
591
592impl<'a> sum_tree::Dimension<'a, ListItemSummary> for RenderedCount {
593 fn add_summary(&mut self, summary: &'a ListItemSummary, _: &()) {
594 self.0 += summary.rendered_count;
595 }
596}
597
598impl<'a> sum_tree::Dimension<'a, ListItemSummary> for UnrenderedCount {
599 fn add_summary(&mut self, summary: &'a ListItemSummary, _: &()) {
600 self.0 += summary.unrendered_count;
601 }
602}
603
604impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Height {
605 fn add_summary(&mut self, summary: &'a ListItemSummary, _: &()) {
606 self.0 += summary.height;
607 }
608}
609
610impl<'a> sum_tree::SeekTarget<'a, ListItemSummary, ListItemSummary> for Count {
611 fn cmp(&self, other: &ListItemSummary, _: &()) -> std::cmp::Ordering {
612 self.0.partial_cmp(&other.count).unwrap()
613 }
614}
615
616impl<'a> sum_tree::SeekTarget<'a, ListItemSummary, ListItemSummary> for Height {
617 fn cmp(&self, other: &ListItemSummary, _: &()) -> std::cmp::Ordering {
618 self.0.partial_cmp(&other.height).unwrap()
619 }
620}
621
622#[cfg(test)]
623mod test {
624
625 use gpui::{ScrollDelta, ScrollWheelEvent};
626
627 use crate::{self as gpui, TestAppContext};
628
629 #[gpui::test]
630 fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) {
631 use crate::{div, list, point, px, size, Element, ListState, Styled};
632
633 let cx = cx.add_empty_window();
634
635 let state = ListState::new(5, crate::ListAlignment::Top, px(10.), |_, _| {
636 div().h(px(10.)).w_full().into_any()
637 });
638
639 // Ensure that the list is scrolled to the top
640 state.scroll_to(gpui::ListOffset {
641 item_ix: 0,
642 offset_in_item: px(0.0),
643 });
644
645 // Paint
646 cx.draw(
647 point(px(0.), px(0.)),
648 size(px(100.), px(20.)).into(),
649 |_| list(state.clone()).w_full().h_full().z_index(10).into_any(),
650 );
651
652 // Reset
653 state.reset(5);
654
655 // And then receive a scroll event _before_ the next paint
656 cx.simulate_event(ScrollWheelEvent {
657 position: point(px(1.), px(1.)),
658 delta: ScrollDelta::Pixels(point(px(0.), px(-500.))),
659 ..Default::default()
660 });
661
662 // Scroll position should stay at the top of the list
663 assert_eq!(state.logical_scroll_top().item_ix, 0);
664 assert_eq!(state.logical_scroll_top().offset_in_item, px(0.));
665 }
666}