list.rs

  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}