Skip to main content

gpui/elements/
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. If your elements
4//! do change height, notify the list element via [`ListState::splice`] or [`ListState::reset`].
5//! In order to minimize re-renders, this element's state is stored intrusively
6//! on your own views, so that your code can coordinate directly with the list element's cached state.
7//!
8//! If all of your elements are the same height, see [`crate::UniformList`] for a simpler API
9
10use crate::{
11    AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges, Element, EntityId,
12    FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, IntoElement,
13    Overflow, Pixels, Point, ScrollDelta, ScrollWheelEvent, Size, Style, StyleRefinement, Styled,
14    Window, point, px, size,
15};
16use collections::VecDeque;
17use refineable::Refineable as _;
18use std::{cell::RefCell, ops::Range, rc::Rc};
19use sum_tree::{Bias, Dimensions, SumTree};
20
21type RenderItemFn = dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static;
22
23/// Construct a new list element
24pub fn list(
25    state: ListState,
26    render_item: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
27) -> List {
28    List {
29        state,
30        render_item: Box::new(render_item),
31        style: StyleRefinement::default(),
32        sizing_behavior: ListSizingBehavior::default(),
33    }
34}
35
36/// A list element
37pub struct List {
38    state: ListState,
39    render_item: Box<RenderItemFn>,
40    style: StyleRefinement,
41    sizing_behavior: ListSizingBehavior,
42}
43
44impl List {
45    /// Set the sizing behavior for the list.
46    pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
47        self.sizing_behavior = behavior;
48        self
49    }
50}
51
52/// The list state that views must hold on behalf of the list element.
53#[derive(Clone)]
54pub struct ListState(Rc<RefCell<StateInner>>);
55
56impl std::fmt::Debug for ListState {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str("ListState")
59    }
60}
61
62struct StateInner {
63    last_layout_bounds: Option<Bounds<Pixels>>,
64    last_padding: Option<Edges<Pixels>>,
65    items: SumTree<ListItem>,
66    logical_scroll_top: Option<ListOffset>,
67    alignment: ListAlignment,
68    overdraw: Pixels,
69    reset: bool,
70    #[allow(clippy::type_complexity)]
71    scroll_handler: Option<Box<dyn FnMut(&ListScrollEvent, &mut Window, &mut App)>>,
72    scrollbar_drag_start_height: Option<Pixels>,
73    measuring_behavior: ListMeasuringBehavior,
74    pending_scroll: Option<PendingScroll>,
75    follow_state: FollowState,
76}
77
78/// Deferred scroll adjustment applied after the scroll-top item has been remeasured.
79///
80/// An absolute pending scroll preserves the same pixel offset into the item, which keeps
81/// visible text stable while content is appended to or removed from that item. A
82/// proportional pending scroll preserves the same fractional position within the item,
83/// which is useful when the whole list is being resized and each item scales similarly.
84#[derive(Clone)]
85enum PendingScroll {
86    /// Preserve the same pixel offset into the item after it is remeasured.
87    Absolute { item_ix: usize, offset: Pixels },
88    /// Preserve the same fractional offset into the item after it is remeasured.
89    Proportional(PendingScrollFraction),
90}
91
92/// Keeps track of a fractional scroll position within an item for restoration
93/// after remeasurement.
94#[derive(Clone)]
95struct PendingScrollFraction {
96    /// The index of the item to scroll within.
97    item_ix: usize,
98    /// Fractional offset (0.0 to 1.0) within the item's height.
99    fraction: f32,
100}
101
102/// Determines how remeasurement preserves the scroll position when the scroll-top item
103/// changes height.
104enum ScrollAnchor {
105    /// Preserve the same pixel offset into the scroll-top item.
106    Absolute,
107    /// Preserve the same fractional position within the scroll-top item.
108    Proportional,
109}
110
111/// Controls whether the list automatically follows new content at the end.
112#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
113pub enum FollowMode {
114    /// Normal scrolling — no automatic following.
115    #[default]
116    Normal,
117    /// The list should auto-scroll along with the tail, when scrolled to bottom.
118    Tail,
119}
120
121#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
122enum FollowState {
123    #[default]
124    Normal,
125    Tail {
126        is_following: bool,
127    },
128}
129
130impl FollowState {
131    fn is_following(&self) -> bool {
132        matches!(self, FollowState::Tail { is_following: true })
133    }
134
135    fn has_stopped_following(&self) -> bool {
136        matches!(
137            self,
138            FollowState::Tail {
139                is_following: false
140            }
141        )
142    }
143
144    fn start_following(&mut self) {
145        if let FollowState::Tail {
146            is_following: false,
147        } = self
148        {
149            *self = FollowState::Tail { is_following: true };
150        }
151    }
152
153    fn stop_following(&mut self) {
154        if let FollowState::Tail { is_following: true } = self {
155            *self = FollowState::Tail {
156                is_following: false,
157            };
158        }
159    }
160}
161
162/// Whether the list is scrolling from top to bottom or bottom to top.
163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
164pub enum ListAlignment {
165    /// The list is scrolling from top to bottom, like most lists.
166    Top,
167    /// The list is scrolling from bottom to top, like a chat log.
168    Bottom,
169}
170
171/// A scroll event that has been converted to be in terms of the list's items.
172pub struct ListScrollEvent {
173    /// The range of items currently visible in the list, after applying the scroll event.
174    pub visible_range: Range<usize>,
175
176    /// The number of items that are currently visible in the list, after applying the scroll event.
177    pub count: usize,
178
179    /// Whether the list has been scrolled.
180    pub is_scrolled: bool,
181
182    /// Whether the list is currently in follow-tail mode (auto-scrolling to end).
183    pub is_following_tail: bool,
184}
185
186/// The sizing behavior to apply during layout.
187#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
188pub enum ListSizingBehavior {
189    /// The list should calculate its size based on the size of its items.
190    Infer,
191    /// The list should not calculate a fixed size.
192    #[default]
193    Auto,
194}
195
196/// The measuring behavior to apply during layout.
197#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
198pub enum ListMeasuringBehavior {
199    /// Measure all items in the list.
200    /// Note: This can be expensive for the first frame in a large list.
201    Measure(bool),
202    /// Only measure visible items
203    #[default]
204    Visible,
205}
206
207impl ListMeasuringBehavior {
208    fn reset(&mut self) {
209        match self {
210            ListMeasuringBehavior::Measure(has_measured) => *has_measured = false,
211            ListMeasuringBehavior::Visible => {}
212        }
213    }
214}
215
216/// The horizontal sizing behavior to apply during layout.
217#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
218pub enum ListHorizontalSizingBehavior {
219    /// List items' width can never exceed the width of the list.
220    #[default]
221    FitList,
222    /// List items' width may go over the width of the list, if any item is wider.
223    Unconstrained,
224}
225
226struct LayoutItemsResponse {
227    max_item_width: Pixels,
228    scroll_top: ListOffset,
229    item_layouts: VecDeque<ItemLayout>,
230}
231
232struct ItemLayout {
233    index: usize,
234    element: AnyElement,
235    size: Size<Pixels>,
236}
237
238/// Frame state used by the [List] element after layout.
239pub struct ListPrepaintState {
240    hitbox: Hitbox,
241    layout: LayoutItemsResponse,
242}
243
244#[derive(Clone)]
245enum ListItem {
246    Unmeasured {
247        size_hint: Option<Size<Pixels>>,
248        focus_handle: Option<FocusHandle>,
249    },
250    Measured {
251        size: Size<Pixels>,
252        focus_handle: Option<FocusHandle>,
253    },
254}
255
256impl ListItem {
257    fn size(&self) -> Option<Size<Pixels>> {
258        if let ListItem::Measured { size, .. } = self {
259            Some(*size)
260        } else {
261            None
262        }
263    }
264
265    fn size_hint(&self) -> Option<Size<Pixels>> {
266        match self {
267            ListItem::Measured { size, .. } => Some(*size),
268            ListItem::Unmeasured { size_hint, .. } => *size_hint,
269        }
270    }
271
272    fn focus_handle(&self) -> Option<FocusHandle> {
273        match self {
274            ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
275                focus_handle.clone()
276            }
277        }
278    }
279
280    fn contains_focused(&self, window: &Window, cx: &App) -> bool {
281        match self {
282            ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
283                focus_handle
284                    .as_ref()
285                    .is_some_and(|handle| handle.contains_focused(window, cx))
286            }
287        }
288    }
289}
290
291#[derive(Clone, Debug, Default, PartialEq)]
292struct ListItemSummary {
293    count: usize,
294    rendered_count: usize,
295    unrendered_count: usize,
296    height: Pixels,
297    has_focus_handles: bool,
298    has_unknown_height: bool,
299}
300
301#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
302struct Count(usize);
303
304#[derive(Clone, Debug, Default)]
305struct Height(Pixels);
306
307impl ListState {
308    /// Construct a new list state, for storage on a view.
309    ///
310    /// The overdraw parameter controls how much extra space is rendered
311    /// above and below the visible area. Elements within this area will
312    /// be measured even though they are not visible. This can help ensure
313    /// that the list doesn't flicker or pop in when scrolling.
314    pub fn new(item_count: usize, alignment: ListAlignment, overdraw: Pixels) -> Self {
315        let this = Self(Rc::new(RefCell::new(StateInner {
316            last_layout_bounds: None,
317            last_padding: None,
318            items: SumTree::default(),
319            logical_scroll_top: None,
320            alignment,
321            overdraw,
322            scroll_handler: None,
323            reset: false,
324            scrollbar_drag_start_height: None,
325            measuring_behavior: ListMeasuringBehavior::default(),
326            pending_scroll: None,
327            follow_state: FollowState::default(),
328        })));
329        this.splice(0..0, item_count);
330        this
331    }
332
333    /// Set the list to measure all items in the list in the first layout phase.
334    ///
335    /// This is useful for ensuring that the scrollbar size is correct instead of based on only rendered elements.
336    pub fn measure_all(self) -> Self {
337        self.0.borrow_mut().measuring_behavior = ListMeasuringBehavior::Measure(false);
338        self
339    }
340
341    /// Reset this instantiation of the list state.
342    ///
343    /// Note that this will cause scroll events to be dropped until the next paint.
344    pub fn reset(&self, element_count: usize) {
345        let old_count = {
346            let state = &mut *self.0.borrow_mut();
347            state.reset = true;
348            state.measuring_behavior.reset();
349            state.logical_scroll_top = None;
350            state.scrollbar_drag_start_height = None;
351            state.items.summary().count
352        };
353
354        self.splice(0..old_count, element_count);
355    }
356
357    /// Remeasure all items while preserving proportional scroll position.
358    ///
359    /// Use this when item heights may have changed (e.g., font size changes)
360    /// but the number and identity of items remains the same.
361    pub fn remeasure(&self) {
362        let count = self.item_count();
363        self.remeasure_items_with_scroll_anchor(0..count, ScrollAnchor::Proportional);
364    }
365
366    /// Mark items in `range` as needing remeasurement while preserving
367    /// the current scroll position. Unlike [`Self::splice`], this does
368    /// not change the number of items or blow away `logical_scroll_top`.
369    ///
370    /// Use this when an item's content has changed and its rendered
371    /// height may be different (e.g., streaming text, tool results
372    /// loading), but the item itself still exists at the same index.
373    pub fn remeasure_items(&self, range: Range<usize>) {
374        self.remeasure_items_with_scroll_anchor(range, ScrollAnchor::Absolute);
375    }
376
377    fn remeasure_items_with_scroll_anchor(&self, range: Range<usize>, scroll_anchor: ScrollAnchor) {
378        let state = &mut *self.0.borrow_mut();
379
380        if let Some(scroll_top) = state.logical_scroll_top {
381            if range.contains(&scroll_top.item_ix) {
382                state.pending_scroll = match scroll_anchor {
383                    ScrollAnchor::Absolute => Some(PendingScroll::Absolute {
384                        item_ix: scroll_top.item_ix,
385                        offset: scroll_top.offset_in_item,
386                    }),
387                    ScrollAnchor::Proportional => {
388                        // If the scroll-top item falls within the remeasured range,
389                        // store a fractional offset so the layout can restore the
390                        // proportional scroll position after the item is re-rendered
391                        // at its new height.
392                        let mut cursor = state.items.cursor::<Count>(());
393                        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
394
395                        cursor
396                            .item()
397                            .and_then(|item| {
398                                item.size().map(|size| {
399                                    let fraction = if size.height.0 > 0.0 {
400                                        (scroll_top.offset_in_item.0 / size.height.0)
401                                            .clamp(0.0, 1.0)
402                                    } else {
403                                        0.0
404                                    };
405
406                                    PendingScroll::Proportional(PendingScrollFraction {
407                                        item_ix: scroll_top.item_ix,
408                                        fraction,
409                                    })
410                                })
411                            })
412                            .or_else(|| state.pending_scroll.clone())
413                    }
414                };
415            }
416        }
417
418        // Rebuild the tree, replacing items in the range with
419        // Unmeasured copies that keep their focus handles.
420        let new_items = {
421            let mut cursor = state.items.cursor::<Count>(());
422            let mut new_items = cursor.slice(&Count(range.start), Bias::Right);
423            let invalidated = cursor.slice(&Count(range.end), Bias::Right);
424            new_items.extend(
425                invalidated.iter().map(|item| ListItem::Unmeasured {
426                    size_hint: item.size_hint(),
427                    focus_handle: item.focus_handle(),
428                }),
429                (),
430            );
431            new_items.append(cursor.suffix(), ());
432            new_items
433        };
434        state.items = new_items;
435        state.measuring_behavior.reset();
436    }
437
438    /// The number of items in this list.
439    pub fn item_count(&self) -> usize {
440        self.0.borrow().items.summary().count
441    }
442
443    /// Whether the list is scrolled to the end, or `None` if the list is
444    /// not scrollable or the total content height is not yet known.
445    pub fn is_scrolled_to_end(&self) -> Option<bool> {
446        let state = self.0.borrow();
447        let bounds = state.last_layout_bounds?;
448        let summary = state.items.summary();
449        if summary.has_unknown_height {
450            return None;
451        }
452        let padding = state.last_padding.unwrap_or_default();
453        let content_height = summary.height + padding.top + padding.bottom;
454        let scroll_max = (content_height - bounds.size.height).max(px(0.));
455        if scroll_max <= px(0.) {
456            return None;
457        }
458        let scroll_top = state.scroll_top(&state.logical_scroll_top());
459        Some(scroll_top >= scroll_max)
460    }
461
462    /// Inform the list state that the items in `old_range` have been replaced
463    /// by `count` new items that must be recalculated.
464    pub fn splice(&self, old_range: Range<usize>, count: usize) {
465        self.splice_focusable(old_range, (0..count).map(|_| None))
466    }
467
468    /// Register with the list state that the items in `old_range` have been replaced
469    /// by new items. As opposed to [`Self::splice`], this method allows an iterator of optional focus handles
470    /// to be supplied to properly integrate with items in the list that can be focused. If a focused item
471    /// is scrolled out of view, the list will continue to render it to allow keyboard interaction.
472    pub fn splice_focusable(
473        &self,
474        old_range: Range<usize>,
475        focus_handles: impl IntoIterator<Item = Option<FocusHandle>>,
476    ) {
477        let state = &mut *self.0.borrow_mut();
478
479        let mut old_items = state.items.cursor::<Count>(());
480        let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right);
481        old_items.seek_forward(&Count(old_range.end), Bias::Right);
482
483        let mut spliced_count = 0;
484        new_items.extend(
485            focus_handles.into_iter().map(|focus_handle| {
486                spliced_count += 1;
487                ListItem::Unmeasured {
488                    size_hint: None,
489                    focus_handle,
490                }
491            }),
492            (),
493        );
494        new_items.append(old_items.suffix(), ());
495        drop(old_items);
496        state.items = new_items;
497
498        if let Some(ListOffset {
499            item_ix,
500            offset_in_item,
501        }) = state.logical_scroll_top.as_mut()
502        {
503            if old_range.contains(item_ix) {
504                *item_ix = old_range.start;
505                *offset_in_item = px(0.);
506            } else if old_range.end <= *item_ix {
507                *item_ix = *item_ix - (old_range.end - old_range.start) + spliced_count;
508            }
509        }
510    }
511
512    /// Set a handler that will be called when the list is scrolled.
513    pub fn set_scroll_handler(
514        &self,
515        handler: impl FnMut(&ListScrollEvent, &mut Window, &mut App) + 'static,
516    ) {
517        self.0.borrow_mut().scroll_handler = Some(Box::new(handler))
518    }
519
520    /// Get the current scroll offset, in terms of the list's items.
521    pub fn logical_scroll_top(&self) -> ListOffset {
522        self.0.borrow().logical_scroll_top()
523    }
524
525    /// Scroll the list by the given offset
526    pub fn scroll_by(&self, distance: Pixels) {
527        if distance == px(0.) {
528            return;
529        }
530
531        let current_offset = self.logical_scroll_top();
532        let state = &mut *self.0.borrow_mut();
533
534        if distance < px(0.) {
535            state.follow_state.stop_following();
536        }
537
538        let mut cursor = state.items.cursor::<ListItemSummary>(());
539        cursor.seek(&Count(current_offset.item_ix), Bias::Right);
540
541        let start_pixel_offset = cursor.start().height + current_offset.offset_in_item;
542        let new_pixel_offset = (start_pixel_offset + distance).max(px(0.));
543        if new_pixel_offset > start_pixel_offset {
544            cursor.seek_forward(&Height(new_pixel_offset), Bias::Right);
545        } else {
546            cursor.seek(&Height(new_pixel_offset), Bias::Right);
547        }
548
549        state.logical_scroll_top = Some(ListOffset {
550            item_ix: cursor.start().count,
551            offset_in_item: new_pixel_offset - cursor.start().height,
552        });
553    }
554
555    /// Scroll the list to the very end (past the last item).
556    ///
557    /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the
558    /// anchor, so the list's layout pass will walk backwards from the end and
559    /// always show the bottom of the last item — even when that item is still
560    /// growing (e.g. during streaming).
561    pub fn scroll_to_end(&self) {
562        let state = &mut *self.0.borrow_mut();
563        let item_count = state.items.summary().count;
564        state.logical_scroll_top = Some(ListOffset {
565            item_ix: item_count,
566            offset_in_item: px(0.),
567        });
568    }
569
570    /// Set the follow mode for the list. In `Tail` mode, the list
571    /// will auto-scroll to the end and re-engage after the user
572    /// scrolls back to the bottom. In `Normal` mode, no automatic
573    /// following occurs.
574    pub fn set_follow_mode(&self, mode: FollowMode) {
575        let state = &mut *self.0.borrow_mut();
576
577        match mode {
578            FollowMode::Normal => {
579                state.follow_state = FollowState::Normal;
580            }
581            FollowMode::Tail => {
582                state.follow_state = FollowState::Tail { is_following: true };
583                if matches!(mode, FollowMode::Tail) {
584                    let item_count = state.items.summary().count;
585                    state.logical_scroll_top = Some(ListOffset {
586                        item_ix: item_count,
587                        offset_in_item: px(0.),
588                    });
589                }
590            }
591        }
592    }
593
594    /// Returns whether the list is currently actively following the
595    /// tail (snapping to the end on each layout).
596    pub fn is_following_tail(&self) -> bool {
597        matches!(
598            self.0.borrow().follow_state,
599            FollowState::Tail { is_following: true }
600        )
601    }
602
603    /// Scroll the list to the given offset
604    pub fn scroll_to(&self, mut scroll_top: ListOffset) {
605        let state = &mut *self.0.borrow_mut();
606        let item_count = state.items.summary().count;
607        if scroll_top.item_ix >= item_count {
608            scroll_top.item_ix = item_count;
609            scroll_top.offset_in_item = px(0.);
610        }
611
612        if scroll_top.item_ix < item_count {
613            state.follow_state.stop_following();
614        }
615
616        state.logical_scroll_top = Some(scroll_top);
617    }
618
619    /// Scroll the list to the given item, such that the item is fully visible.
620    pub fn scroll_to_reveal_item(&self, ix: usize) {
621        let state = &mut *self.0.borrow_mut();
622
623        let mut scroll_top = state.logical_scroll_top();
624        let height = state
625            .last_layout_bounds
626            .map_or(px(0.), |bounds| bounds.size.height);
627        let padding = state.last_padding.unwrap_or_default();
628
629        if ix <= scroll_top.item_ix {
630            scroll_top.item_ix = ix;
631            scroll_top.offset_in_item = px(0.);
632        } else {
633            let mut cursor = state.items.cursor::<ListItemSummary>(());
634            cursor.seek(&Count(ix + 1), Bias::Right);
635            let bottom = cursor.start().height + padding.top;
636            let goal_top = px(0.).max(bottom - height + padding.bottom);
637
638            cursor.seek(&Height(goal_top), Bias::Left);
639            let start_ix = cursor.start().count;
640            let start_item_top = cursor.start().height;
641
642            if start_ix >= scroll_top.item_ix {
643                scroll_top.item_ix = start_ix;
644                scroll_top.offset_in_item = goal_top - start_item_top;
645            }
646        }
647
648        state.logical_scroll_top = Some(scroll_top);
649    }
650
651    /// Get the bounds for the given item in window coordinates, if it's
652    /// been rendered.
653    pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
654        let state = &*self.0.borrow();
655
656        let bounds = state.last_layout_bounds.unwrap_or_default();
657        let scroll_top = state.logical_scroll_top();
658        if ix < scroll_top.item_ix {
659            return None;
660        }
661
662        let mut cursor = state.items.cursor::<Dimensions<Count, Height>>(());
663        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
664
665        let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item;
666
667        cursor.seek_forward(&Count(ix), Bias::Right);
668        if let Some(&ListItem::Measured { size, .. }) = cursor.item() {
669            let &Dimensions(Count(count), Height(top), _) = cursor.start();
670            if count == ix {
671                let top = bounds.top() + top - scroll_top;
672                return Some(Bounds::from_corners(
673                    point(bounds.left(), top),
674                    point(bounds.right(), top + size.height),
675                ));
676            }
677        }
678        None
679    }
680
681    /// Call this method when the user starts dragging the scrollbar.
682    ///
683    /// This will prevent the height reported to the scrollbar from changing during the drag
684    /// as items in the overdraw get measured, and help offset scroll position changes accordingly.
685    pub fn scrollbar_drag_started(&self) {
686        let mut state = self.0.borrow_mut();
687        state.scrollbar_drag_start_height = Some(state.items.summary().height);
688    }
689
690    /// Called when the user stops dragging the scrollbar.
691    ///
692    /// See `scrollbar_drag_started`.
693    pub fn scrollbar_drag_ended(&self) {
694        self.0.borrow_mut().scrollbar_drag_start_height.take();
695    }
696
697    /// Returns `true` if the scrollbar is currently being dragged.
698    ///
699    /// This is set between [`scrollbar_drag_started`](Self::scrollbar_drag_started)
700    /// and [`scrollbar_drag_ended`](Self::scrollbar_drag_ended) calls. Useful for
701    /// consumers that need to distinguish scrollbar drags from wheel/trackpad scrolls,
702    /// e.g. to suppress auto-scroll behavior during manual positioning.
703    pub fn is_scrollbar_dragging(&self) -> bool {
704        self.0.borrow().scrollbar_drag_start_height.is_some()
705    }
706
707    /// Set the offset from the scrollbar
708    pub fn set_offset_from_scrollbar(&self, point: Point<Pixels>) {
709        self.0.borrow_mut().set_offset_from_scrollbar(point);
710    }
711
712    /// Returns the maximum scroll offset according to the items we have measured.
713    /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly.
714    pub fn max_offset_for_scrollbar(&self) -> Point<Pixels> {
715        let state = self.0.borrow();
716        point(Pixels::ZERO, state.max_scroll_offset())
717    }
718
719    /// Returns the current scroll offset adjusted for the scrollbar.
720    ///
721    /// The returned offset has a negative `y` component representing
722    /// how far the content has scrolled.
723    pub fn scroll_px_offset_for_scrollbar(&self) -> Point<Pixels> {
724        let state = &self.0.borrow();
725
726        if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom {
727            return Point::new(px(0.), -state.max_scroll_offset());
728        }
729
730        let logical_scroll_top = state.logical_scroll_top();
731
732        let mut cursor = state.items.cursor::<ListItemSummary>(());
733        let summary: ListItemSummary =
734            cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right);
735        let offset = summary.height + logical_scroll_top.offset_in_item;
736
737        Point::new(px(0.), -offset)
738    }
739
740    /// Return the bounds of the viewport in pixels.
741    pub fn viewport_bounds(&self) -> Bounds<Pixels> {
742        self.0.borrow().last_layout_bounds.unwrap_or_default()
743    }
744}
745
746impl StateInner {
747    fn max_scroll_offset(&self) -> Pixels {
748        let bounds = self.last_layout_bounds.unwrap_or_default();
749        let height = self
750            .scrollbar_drag_start_height
751            .unwrap_or_else(|| self.items.summary().height);
752        (height - bounds.size.height).max(px(0.))
753    }
754
755    fn visible_range(
756        items: &SumTree<ListItem>,
757        height: Pixels,
758        scroll_top: &ListOffset,
759    ) -> Range<usize> {
760        let mut cursor = items.cursor::<ListItemSummary>(());
761        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
762        let start_y = cursor.start().height + scroll_top.offset_in_item;
763        cursor.seek_forward(&Height(start_y + height), Bias::Left);
764        scroll_top.item_ix..cursor.start().count + 1
765    }
766
767    fn scroll(
768        &mut self,
769        scroll_top: &ListOffset,
770        height: Pixels,
771        delta: Point<Pixels>,
772        current_view: EntityId,
773        window: &mut Window,
774        cx: &mut App,
775    ) {
776        // Drop scroll events after a reset, since we can't calculate
777        // the new logical scroll top without the item heights
778        if self.reset {
779            return;
780        }
781
782        let padding = self.last_padding.unwrap_or_default();
783        let scroll_max =
784            (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
785        let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
786            .max(px(0.))
787            .min(scroll_max);
788
789        if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
790            self.logical_scroll_top = None;
791        } else {
792            let (start, ..) =
793                self.items
794                    .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
795            let item_ix = start.count;
796            let offset_in_item = new_scroll_top - start.height;
797            self.logical_scroll_top = Some(ListOffset {
798                item_ix,
799                offset_in_item,
800            });
801        }
802
803        if delta.y > px(0.) {
804            self.follow_state.stop_following();
805        }
806
807        if let Some(handler) = self.scroll_handler.as_mut() {
808            let visible_range = Self::visible_range(&self.items, height, scroll_top);
809            handler(
810                &ListScrollEvent {
811                    visible_range,
812                    count: self.items.summary().count,
813                    is_scrolled: self.logical_scroll_top.is_some(),
814                    is_following_tail: matches!(
815                        self.follow_state,
816                        FollowState::Tail { is_following: true }
817                    ),
818                },
819                window,
820                cx,
821            );
822        }
823
824        cx.notify(current_view);
825    }
826
827    fn logical_scroll_top(&self) -> ListOffset {
828        self.logical_scroll_top
829            .unwrap_or_else(|| match self.alignment {
830                ListAlignment::Top => ListOffset {
831                    item_ix: 0,
832                    offset_in_item: px(0.),
833                },
834                ListAlignment::Bottom => ListOffset {
835                    item_ix: self.items.summary().count,
836                    offset_in_item: px(0.),
837                },
838            })
839    }
840
841    fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels {
842        let (start, ..) = self.items.find::<ListItemSummary, _>(
843            (),
844            &Count(logical_scroll_top.item_ix),
845            Bias::Right,
846        );
847        start.height + logical_scroll_top.offset_in_item
848    }
849
850    fn layout_all_items(
851        &mut self,
852        available_width: Pixels,
853        render_item: &mut RenderItemFn,
854        window: &mut Window,
855        cx: &mut App,
856    ) {
857        match &mut self.measuring_behavior {
858            ListMeasuringBehavior::Visible => {
859                return;
860            }
861            ListMeasuringBehavior::Measure(has_measured) => {
862                if *has_measured {
863                    return;
864                }
865                *has_measured = true;
866            }
867        }
868
869        let mut cursor = self.items.cursor::<Count>(());
870        let available_item_space = size(
871            AvailableSpace::Definite(available_width),
872            AvailableSpace::MinContent,
873        );
874
875        let mut measured_items = Vec::default();
876
877        for (ix, item) in cursor.enumerate() {
878            let size = item.size().unwrap_or_else(|| {
879                let mut element = render_item(ix, window, cx);
880                element.layout_as_root(available_item_space, window, cx)
881            });
882
883            measured_items.push(ListItem::Measured {
884                size,
885                focus_handle: item.focus_handle(),
886            });
887        }
888
889        self.items = SumTree::from_iter(measured_items, ());
890    }
891
892    fn layout_items(
893        &mut self,
894        available_width: Option<Pixels>,
895        available_height: Pixels,
896        padding: &Edges<Pixels>,
897        render_item: &mut RenderItemFn,
898        window: &mut Window,
899        cx: &mut App,
900    ) -> LayoutItemsResponse {
901        let old_items = self.items.clone();
902        let mut measured_items = VecDeque::new();
903        let mut item_layouts = VecDeque::new();
904        let mut rendered_height = padding.top;
905        let mut max_item_width = px(0.);
906        let mut scroll_top = self.logical_scroll_top();
907
908        if self.follow_state.is_following() {
909            scroll_top = ListOffset {
910                item_ix: self.items.summary().count,
911                offset_in_item: px(0.),
912            };
913            self.logical_scroll_top = Some(scroll_top);
914        }
915
916        let mut rendered_focused_item = false;
917
918        let available_item_space = size(
919            available_width.map_or(AvailableSpace::MinContent, |width| {
920                AvailableSpace::Definite(width)
921            }),
922            AvailableSpace::MinContent,
923        );
924
925        let mut cursor = old_items.cursor::<Count>(());
926
927        // Render items after the scroll top, including those in the trailing overdraw
928        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
929        for (ix, item) in cursor.by_ref().enumerate() {
930            let visible_height = rendered_height - scroll_top.offset_in_item;
931            if visible_height >= available_height + self.overdraw {
932                break;
933            }
934
935            // Use the previously cached height and focus handle if available
936            let mut size = item.size();
937
938            // If we're within the visible area or the height wasn't cached, render and measure the item's element
939            if visible_height < available_height || size.is_none() {
940                let item_index = scroll_top.item_ix + ix;
941                let mut element = render_item(item_index, window, cx);
942                let element_size = element.layout_as_root(available_item_space, window, cx);
943                size = Some(element_size);
944
945                // If there's a pending scroll adjustment for the scroll-top
946                // item, apply it.
947                if ix == 0 {
948                    if let Some(pending_scroll) = self.pending_scroll.take() {
949                        match pending_scroll {
950                            PendingScroll::Absolute { item_ix, offset }
951                                if item_ix == scroll_top.item_ix =>
952                            {
953                                scroll_top.offset_in_item = offset.min(element_size.height);
954                                self.logical_scroll_top = Some(scroll_top);
955                            }
956                            PendingScroll::Proportional(pending_scroll)
957                                if pending_scroll.item_ix == scroll_top.item_ix =>
958                            {
959                                // Ensuring proportional scroll position is
960                                // maintained after re-measuring.
961                                scroll_top.offset_in_item =
962                                    Pixels(pending_scroll.fraction * element_size.height.0);
963                                self.logical_scroll_top = Some(scroll_top);
964                            }
965                            _ => {}
966                        }
967                    }
968                }
969
970                if visible_height < available_height {
971                    item_layouts.push_back(ItemLayout {
972                        index: item_index,
973                        element,
974                        size: element_size,
975                    });
976                    if item.contains_focused(window, cx) {
977                        rendered_focused_item = true;
978                    }
979                }
980            }
981
982            let size = size.unwrap();
983            rendered_height += size.height;
984            max_item_width = max_item_width.max(size.width);
985            measured_items.push_back(ListItem::Measured {
986                size,
987                focus_handle: item.focus_handle(),
988            });
989        }
990        rendered_height += padding.bottom;
991
992        // Prepare to start walking upward from the item at the scroll top.
993        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
994
995        // If the rendered items do not fill the visible region, then adjust
996        // the scroll top upward.
997        if rendered_height - scroll_top.offset_in_item < available_height {
998            while rendered_height < available_height {
999                cursor.prev();
1000                if let Some(item) = cursor.item() {
1001                    let item_index = cursor.start().0;
1002                    let mut element = render_item(item_index, window, cx);
1003                    let element_size = element.layout_as_root(available_item_space, window, cx);
1004                    let focus_handle = item.focus_handle();
1005                    rendered_height += element_size.height;
1006                    measured_items.push_front(ListItem::Measured {
1007                        size: element_size,
1008                        focus_handle,
1009                    });
1010                    item_layouts.push_front(ItemLayout {
1011                        index: item_index,
1012                        element,
1013                        size: element_size,
1014                    });
1015                    if item.contains_focused(window, cx) {
1016                        rendered_focused_item = true;
1017                    }
1018                } else {
1019                    break;
1020                }
1021            }
1022
1023            scroll_top = ListOffset {
1024                item_ix: cursor.start().0,
1025                offset_in_item: rendered_height - available_height,
1026            };
1027
1028            match self.alignment {
1029                ListAlignment::Top => {
1030                    scroll_top.offset_in_item = scroll_top.offset_in_item.max(px(0.));
1031                    self.logical_scroll_top = Some(scroll_top);
1032                }
1033                ListAlignment::Bottom => {
1034                    scroll_top = ListOffset {
1035                        item_ix: cursor.start().0,
1036                        offset_in_item: rendered_height - available_height,
1037                    };
1038                    self.logical_scroll_top = None;
1039                }
1040            };
1041        }
1042
1043        // Measure items in the leading overdraw
1044        let mut leading_overdraw = scroll_top.offset_in_item;
1045        while leading_overdraw < self.overdraw {
1046            cursor.prev();
1047            if let Some(item) = cursor.item() {
1048                let size = if let ListItem::Measured { size, .. } = item {
1049                    *size
1050                } else {
1051                    let mut element = render_item(cursor.start().0, window, cx);
1052                    element.layout_as_root(available_item_space, window, cx)
1053                };
1054
1055                leading_overdraw += size.height;
1056                measured_items.push_front(ListItem::Measured {
1057                    size,
1058                    focus_handle: item.focus_handle(),
1059                });
1060            } else {
1061                break;
1062            }
1063        }
1064
1065        let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len());
1066        let mut cursor = old_items.cursor::<Count>(());
1067        let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right);
1068        new_items.extend(measured_items, ());
1069        cursor.seek(&Count(measured_range.end), Bias::Right);
1070        new_items.append(cursor.suffix(), ());
1071        self.items = new_items;
1072
1073        // If follow_tail mode is on but the user scrolled away
1074        // (is_following is false), check whether the current scroll
1075        // position has returned to the bottom.
1076        if self.follow_state.has_stopped_following() {
1077            let padding = self.last_padding.unwrap_or_default();
1078            let total_height = self.items.summary().height + padding.top + padding.bottom;
1079            let scroll_offset = self.scroll_top(&scroll_top);
1080            if scroll_offset + available_height >= total_height - px(1.0) {
1081                self.follow_state.start_following();
1082            }
1083        }
1084
1085        // If none of the visible items are focused, check if an off-screen item is focused
1086        // and include it to be rendered after the visible items so keyboard interaction continues
1087        // to work for it.
1088        if !rendered_focused_item {
1089            let mut cursor = self
1090                .items
1091                .filter::<_, Count>((), |summary| summary.has_focus_handles);
1092            cursor.next();
1093            while let Some(item) = cursor.item() {
1094                if item.contains_focused(window, cx) {
1095                    let item_index = cursor.start().0;
1096                    let mut element = render_item(cursor.start().0, window, cx);
1097                    let size = element.layout_as_root(available_item_space, window, cx);
1098                    item_layouts.push_back(ItemLayout {
1099                        index: item_index,
1100                        element,
1101                        size,
1102                    });
1103                    break;
1104                }
1105                cursor.next();
1106            }
1107        }
1108
1109        LayoutItemsResponse {
1110            max_item_width,
1111            scroll_top,
1112            item_layouts,
1113        }
1114    }
1115
1116    fn prepaint_items(
1117        &mut self,
1118        bounds: Bounds<Pixels>,
1119        padding: Edges<Pixels>,
1120        autoscroll: bool,
1121        render_item: &mut RenderItemFn,
1122        window: &mut Window,
1123        cx: &mut App,
1124    ) -> Result<LayoutItemsResponse, ListOffset> {
1125        window.transact(|window| {
1126            match self.measuring_behavior {
1127                ListMeasuringBehavior::Measure(has_measured) if !has_measured => {
1128                    self.layout_all_items(bounds.size.width, render_item, window, cx);
1129                }
1130                _ => {}
1131            }
1132
1133            let mut layout_response = self.layout_items(
1134                Some(bounds.size.width),
1135                bounds.size.height,
1136                &padding,
1137                render_item,
1138                window,
1139                cx,
1140            );
1141
1142            // Avoid honoring autoscroll requests from elements other than our children.
1143            window.take_autoscroll();
1144
1145            // Only paint the visible items, if there is actually any space for them (taking padding into account)
1146            if bounds.size.height > padding.top + padding.bottom {
1147                let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
1148                item_origin.y -= layout_response.scroll_top.offset_in_item;
1149                for item in &mut layout_response.item_layouts {
1150                    window.with_content_mask(Some(ContentMask { bounds }), |window| {
1151                        item.element.prepaint_at(item_origin, window, cx);
1152                    });
1153
1154                    if let Some(autoscroll_bounds) = window.take_autoscroll()
1155                        && autoscroll
1156                    {
1157                        if autoscroll_bounds.top() < bounds.top() {
1158                            return Err(ListOffset {
1159                                item_ix: item.index,
1160                                offset_in_item: autoscroll_bounds.top() - item_origin.y,
1161                            });
1162                        } else if autoscroll_bounds.bottom() > bounds.bottom() {
1163                            let mut cursor = self.items.cursor::<Count>(());
1164                            cursor.seek(&Count(item.index), Bias::Right);
1165                            let mut height = bounds.size.height - padding.top - padding.bottom;
1166
1167                            // Account for the height of the element down until the autoscroll bottom.
1168                            height -= autoscroll_bounds.bottom() - item_origin.y;
1169
1170                            // Keep decreasing the scroll top until we fill all the available space.
1171                            while height > Pixels::ZERO {
1172                                cursor.prev();
1173                                let Some(item) = cursor.item() else { break };
1174
1175                                let size = item.size().unwrap_or_else(|| {
1176                                    let mut item = render_item(cursor.start().0, window, cx);
1177                                    let item_available_size =
1178                                        size(bounds.size.width.into(), AvailableSpace::MinContent);
1179                                    item.layout_as_root(item_available_size, window, cx)
1180                                });
1181                                height -= size.height;
1182                            }
1183
1184                            return Err(ListOffset {
1185                                item_ix: cursor.start().0,
1186                                offset_in_item: if height < Pixels::ZERO {
1187                                    -height
1188                                } else {
1189                                    Pixels::ZERO
1190                                },
1191                            });
1192                        }
1193                    }
1194
1195                    item_origin.y += item.size.height;
1196                }
1197            } else {
1198                layout_response.item_layouts.clear();
1199            }
1200
1201            Ok(layout_response)
1202        })
1203    }
1204
1205    // Scrollbar support
1206
1207    fn set_offset_from_scrollbar(&mut self, point: Point<Pixels>) {
1208        let Some(bounds) = self.last_layout_bounds else {
1209            return;
1210        };
1211        let height = bounds.size.height;
1212
1213        let padding = self.last_padding.unwrap_or_default();
1214        // Scrollbar drag positions are computed from the content height
1215        // captured at drag start, so map them back using the same height.
1216        let content_height = self
1217            .scrollbar_drag_start_height
1218            .unwrap_or_else(|| self.items.summary().height);
1219        let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.));
1220        let new_scroll_top = (-point.y).max(px(0.)).min(scroll_max);
1221
1222        // If content grew during the drag, the frozen bottom is below the
1223        // live bottom. Treat dragging to the frozen end as resuming tail follow.
1224        let dragged_to_end =
1225            scroll_max > px(0.) && new_scroll_top >= (scroll_max - px(1.0)).max(px(0.));
1226        if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) {
1227            self.follow_state = FollowState::Tail { is_following: true };
1228            let item_count = self.items.summary().count;
1229            self.logical_scroll_top = Some(ListOffset {
1230                item_ix: item_count,
1231                offset_in_item: px(0.),
1232            });
1233            return;
1234        }
1235
1236        self.follow_state.stop_following();
1237
1238        if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
1239            self.logical_scroll_top = None;
1240        } else {
1241            let (start, _, _) =
1242                self.items
1243                    .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
1244
1245            let item_ix = start.count;
1246            let offset_in_item = new_scroll_top - start.height;
1247            self.logical_scroll_top = Some(ListOffset {
1248                item_ix,
1249                offset_in_item,
1250            });
1251        }
1252    }
1253}
1254
1255impl std::fmt::Debug for ListItem {
1256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1257        match self {
1258            Self::Unmeasured { .. } => write!(f, "Unrendered"),
1259            Self::Measured { size, .. } => f.debug_struct("Rendered").field("size", size).finish(),
1260        }
1261    }
1262}
1263
1264/// An offset into the list's items, in terms of the item index and the number
1265/// of pixels off the top left of the item.
1266#[derive(Debug, Clone, Copy, Default)]
1267pub struct ListOffset {
1268    /// The index of an item in the list
1269    pub item_ix: usize,
1270    /// The number of pixels to offset from the item index.
1271    pub offset_in_item: Pixels,
1272}
1273
1274impl Element for List {
1275    type RequestLayoutState = ();
1276    type PrepaintState = ListPrepaintState;
1277
1278    fn id(&self) -> Option<crate::ElementId> {
1279        None
1280    }
1281
1282    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1283        None
1284    }
1285
1286    fn request_layout(
1287        &mut self,
1288        _id: Option<&GlobalElementId>,
1289        _inspector_id: Option<&InspectorElementId>,
1290        window: &mut Window,
1291        cx: &mut App,
1292    ) -> (crate::LayoutId, Self::RequestLayoutState) {
1293        let layout_id = match self.sizing_behavior {
1294            ListSizingBehavior::Infer => {
1295                let mut style = Style::default();
1296                style.overflow.y = Overflow::Scroll;
1297                style.refine(&self.style);
1298                window.with_text_style(style.text_style().cloned(), |window| {
1299                    let state = &mut *self.state.0.borrow_mut();
1300
1301                    let available_height = if let Some(last_bounds) = state.last_layout_bounds {
1302                        last_bounds.size.height
1303                    } else {
1304                        // If we don't have the last layout bounds (first render),
1305                        // we might just use the overdraw value as the available height to layout enough items.
1306                        state.overdraw
1307                    };
1308                    let padding = style.padding.to_pixels(
1309                        state.last_layout_bounds.unwrap_or_default().size.into(),
1310                        window.rem_size(),
1311                    );
1312
1313                    let layout_response = state.layout_items(
1314                        None,
1315                        available_height,
1316                        &padding,
1317                        &mut self.render_item,
1318                        window,
1319                        cx,
1320                    );
1321                    let max_element_width = layout_response.max_item_width;
1322
1323                    let summary = state.items.summary();
1324                    let total_height = summary.height;
1325
1326                    window.request_measured_layout(
1327                        style,
1328                        move |known_dimensions, available_space, _window, _cx| {
1329                            let width =
1330                                known_dimensions
1331                                    .width
1332                                    .unwrap_or(match available_space.width {
1333                                        AvailableSpace::Definite(x) => x,
1334                                        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1335                                            max_element_width
1336                                        }
1337                                    });
1338                            let height = match available_space.height {
1339                                AvailableSpace::Definite(height) => total_height.min(height),
1340                                AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1341                                    total_height
1342                                }
1343                            };
1344                            size(width, height)
1345                        },
1346                    )
1347                })
1348            }
1349            ListSizingBehavior::Auto => {
1350                let mut style = Style::default();
1351                style.refine(&self.style);
1352                window.with_text_style(style.text_style().cloned(), |window| {
1353                    window.request_layout(style, None, cx)
1354                })
1355            }
1356        };
1357        (layout_id, ())
1358    }
1359
1360    fn prepaint(
1361        &mut self,
1362        _id: Option<&GlobalElementId>,
1363        _inspector_id: Option<&InspectorElementId>,
1364        bounds: Bounds<Pixels>,
1365        _: &mut Self::RequestLayoutState,
1366        window: &mut Window,
1367        cx: &mut App,
1368    ) -> ListPrepaintState {
1369        let state = &mut *self.state.0.borrow_mut();
1370        state.reset = false;
1371
1372        let mut style = Style::default();
1373        style.refine(&self.style);
1374
1375        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
1376
1377        // If the width of the list has changed, invalidate all cached item heights
1378        if state
1379            .last_layout_bounds
1380            .is_none_or(|last_bounds| last_bounds.size.width != bounds.size.width)
1381        {
1382            let new_items = SumTree::from_iter(
1383                state.items.iter().map(|item| ListItem::Unmeasured {
1384                    size_hint: None,
1385                    focus_handle: item.focus_handle(),
1386                }),
1387                (),
1388            );
1389
1390            state.items = new_items;
1391            state.measuring_behavior.reset();
1392        }
1393
1394        let padding = style
1395            .padding
1396            .to_pixels(bounds.size.into(), window.rem_size());
1397        let layout =
1398            match state.prepaint_items(bounds, padding, true, &mut self.render_item, window, cx) {
1399                Ok(layout) => layout,
1400                Err(autoscroll_request) => {
1401                    state.logical_scroll_top = Some(autoscroll_request);
1402                    state
1403                        .prepaint_items(bounds, padding, false, &mut self.render_item, window, cx)
1404                        .unwrap()
1405                }
1406            };
1407
1408        state.last_layout_bounds = Some(bounds);
1409        state.last_padding = Some(padding);
1410        ListPrepaintState { hitbox, layout }
1411    }
1412
1413    fn paint(
1414        &mut self,
1415        _id: Option<&GlobalElementId>,
1416        _inspector_id: Option<&InspectorElementId>,
1417        bounds: Bounds<crate::Pixels>,
1418        _: &mut Self::RequestLayoutState,
1419        prepaint: &mut Self::PrepaintState,
1420        window: &mut Window,
1421        cx: &mut App,
1422    ) {
1423        let current_view = window.current_view();
1424        window.with_content_mask(Some(ContentMask { bounds }), |window| {
1425            for item in &mut prepaint.layout.item_layouts {
1426                item.element.paint(window, cx);
1427            }
1428        });
1429
1430        let list_state = self.state.clone();
1431        let height = bounds.size.height;
1432        let scroll_top = prepaint.layout.scroll_top;
1433        let hitbox_id = prepaint.hitbox.id;
1434        let mut accumulated_scroll_delta = ScrollDelta::default();
1435        window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
1436            if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) {
1437                accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta);
1438                let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.));
1439                list_state.0.borrow_mut().scroll(
1440                    &scroll_top,
1441                    height,
1442                    pixel_delta,
1443                    current_view,
1444                    window,
1445                    cx,
1446                )
1447            }
1448        });
1449    }
1450}
1451
1452impl IntoElement for List {
1453    type Element = Self;
1454
1455    fn into_element(self) -> Self::Element {
1456        self
1457    }
1458}
1459
1460impl Styled for List {
1461    fn style(&mut self) -> &mut StyleRefinement {
1462        &mut self.style
1463    }
1464}
1465
1466impl sum_tree::Item for ListItem {
1467    type Summary = ListItemSummary;
1468
1469    fn summary(&self, _: ()) -> Self::Summary {
1470        match self {
1471            ListItem::Unmeasured {
1472                size_hint,
1473                focus_handle,
1474            } => ListItemSummary {
1475                count: 1,
1476                rendered_count: 0,
1477                unrendered_count: 1,
1478                height: if let Some(size) = size_hint {
1479                    size.height
1480                } else {
1481                    px(0.)
1482                },
1483                has_focus_handles: focus_handle.is_some(),
1484                has_unknown_height: size_hint.is_none(),
1485            },
1486            ListItem::Measured {
1487                size, focus_handle, ..
1488            } => ListItemSummary {
1489                count: 1,
1490                rendered_count: 1,
1491                unrendered_count: 0,
1492                height: size.height,
1493                has_focus_handles: focus_handle.is_some(),
1494                has_unknown_height: false,
1495            },
1496        }
1497    }
1498}
1499
1500impl sum_tree::ContextLessSummary for ListItemSummary {
1501    fn zero() -> Self {
1502        Default::default()
1503    }
1504
1505    fn add_summary(&mut self, summary: &Self) {
1506        self.count += summary.count;
1507        self.rendered_count += summary.rendered_count;
1508        self.unrendered_count += summary.unrendered_count;
1509        self.height += summary.height;
1510        self.has_focus_handles |= summary.has_focus_handles;
1511        self.has_unknown_height |= summary.has_unknown_height;
1512    }
1513}
1514
1515impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Count {
1516    fn zero(_cx: ()) -> Self {
1517        Default::default()
1518    }
1519
1520    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1521        self.0 += summary.count;
1522    }
1523}
1524
1525impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Height {
1526    fn zero(_cx: ()) -> Self {
1527        Default::default()
1528    }
1529
1530    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1531        self.0 += summary.height;
1532    }
1533}
1534
1535impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Count {
1536    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1537        self.0.partial_cmp(&other.count).unwrap()
1538    }
1539}
1540
1541impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Height {
1542    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1543        self.0.partial_cmp(&other.height).unwrap()
1544    }
1545}
1546
1547#[cfg(test)]
1548mod test {
1549
1550    use gpui::{ScrollDelta, ScrollWheelEvent};
1551    use std::cell::Cell;
1552    use std::rc::Rc;
1553
1554    use crate::{
1555        self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render,
1556        Styled, TestAppContext, Window, div, list, point, px, size,
1557    };
1558
1559    #[gpui::test]
1560    fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) {
1561        let cx = cx.add_empty_window();
1562
1563        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1564
1565        // Ensure that the list is scrolled to the top
1566        state.scroll_to(gpui::ListOffset {
1567            item_ix: 0,
1568            offset_in_item: px(0.0),
1569        });
1570
1571        struct TestView(ListState);
1572        impl Render for TestView {
1573            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1574                list(self.0.clone(), |_, _, _| {
1575                    div().h(px(10.)).w_full().into_any()
1576                })
1577                .w_full()
1578                .h_full()
1579            }
1580        }
1581
1582        // Paint
1583        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1584            cx.new(|_| TestView(state.clone())).into_any_element()
1585        });
1586
1587        // Reset
1588        state.reset(5);
1589
1590        // And then receive a scroll event _before_ the next paint
1591        cx.simulate_event(ScrollWheelEvent {
1592            position: point(px(1.), px(1.)),
1593            delta: ScrollDelta::Pixels(point(px(0.), px(-500.))),
1594            ..Default::default()
1595        });
1596
1597        // Scroll position should stay at the top of the list
1598        assert_eq!(state.logical_scroll_top().item_ix, 0);
1599        assert_eq!(state.logical_scroll_top().offset_in_item, px(0.));
1600    }
1601
1602    #[gpui::test]
1603    fn test_scroll_by_positive_and_negative_distance(cx: &mut TestAppContext) {
1604        let cx = cx.add_empty_window();
1605
1606        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1607
1608        struct TestView(ListState);
1609        impl Render for TestView {
1610            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1611                list(self.0.clone(), |_, _, _| {
1612                    div().h(px(20.)).w_full().into_any()
1613                })
1614                .w_full()
1615                .h_full()
1616            }
1617        }
1618
1619        // Paint
1620        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
1621            cx.new(|_| TestView(state.clone())).into_any_element()
1622        });
1623
1624        // Test positive distance: start at item 1, move down 30px
1625        state.scroll_by(px(30.));
1626
1627        // Should move to item 2
1628        let offset = state.logical_scroll_top();
1629        assert_eq!(offset.item_ix, 1);
1630        assert_eq!(offset.offset_in_item, px(10.));
1631
1632        // Test negative distance: start at item 2, move up 30px
1633        state.scroll_by(px(-30.));
1634
1635        // Should move back to item 1
1636        let offset = state.logical_scroll_top();
1637        assert_eq!(offset.item_ix, 0);
1638        assert_eq!(offset.offset_in_item, px(0.));
1639
1640        // Test zero distance
1641        state.scroll_by(px(0.));
1642        let offset = state.logical_scroll_top();
1643        assert_eq!(offset.item_ix, 0);
1644        assert_eq!(offset.offset_in_item, px(0.));
1645    }
1646
1647    #[gpui::test]
1648    fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
1649        let cx = cx.add_empty_window();
1650
1651        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1652
1653        struct TestView(ListState);
1654        impl Render for TestView {
1655            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1656                list(self.0.clone(), |_, _, _| {
1657                    div().h(px(50.)).w_full().into_any()
1658                })
1659                .w_full()
1660                .h_full()
1661            }
1662        }
1663
1664        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1665
1666        // First draw at width 100: all 10 items measured (total 500px).
1667        // Viewport is 200px, so max scroll offset should be 300px.
1668        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1669            view.clone().into_any_element()
1670        });
1671        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
1672
1673        // Second draw at a different width: items get invalidated.
1674        // Without the fix, max_offset would drop because unmeasured items
1675        // contribute 0 height.
1676        cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| {
1677            view.into_any_element()
1678        });
1679        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
1680    }
1681
1682    #[gpui::test]
1683    fn test_remeasure(cx: &mut TestAppContext) {
1684        let cx = cx.add_empty_window();
1685
1686        // Create a list with 10 items, each 100px tall. We'll keep a reference
1687        // to the item height so we can later change the height and assert how
1688        // `ListState` handles it.
1689        let item_height = Rc::new(Cell::new(100usize));
1690        let state = ListState::new(10, crate::ListAlignment::Top, px(10.));
1691
1692        struct TestView {
1693            state: ListState,
1694            item_height: Rc<Cell<usize>>,
1695        }
1696
1697        impl Render for TestView {
1698            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1699                let height = self.item_height.get();
1700                list(self.state.clone(), move |_, _, _| {
1701                    div().h(px(height as f32)).w_full().into_any()
1702                })
1703                .w_full()
1704                .h_full()
1705            }
1706        }
1707
1708        let state_clone = state.clone();
1709        let item_height_clone = item_height.clone();
1710        let view = cx.update(|_, cx| {
1711            cx.new(|_| TestView {
1712                state: state_clone,
1713                item_height: item_height_clone,
1714            })
1715        });
1716
1717        // Simulate scrolling 40px inside the element with index 2. Since the
1718        // original item height is 100px, this equates to 40% inside the item.
1719        state.scroll_to(gpui::ListOffset {
1720            item_ix: 2,
1721            offset_in_item: px(40.),
1722        });
1723
1724        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1725            view.clone().into_any_element()
1726        });
1727
1728        let offset = state.logical_scroll_top();
1729        assert_eq!(offset.item_ix, 2);
1730        assert_eq!(offset.offset_in_item, px(40.));
1731
1732        // Update the `item_height` to be 50px instead of 100px so we can assert
1733        // that the scroll position is proportionally preserved, that is,
1734        // instead of 40px from the top of item 2, it should be 20px, since the
1735        // item's height has been halved.
1736        item_height.set(50);
1737        state.remeasure();
1738
1739        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1740            view.into_any_element()
1741        });
1742
1743        let offset = state.logical_scroll_top();
1744        assert_eq!(offset.item_ix, 2);
1745        assert_eq!(offset.offset_in_item, px(20.));
1746    }
1747
1748    #[gpui::test]
1749    fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) {
1750        let cx = cx.add_empty_window();
1751
1752        let item_height = Rc::new(Cell::new(100usize));
1753        let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
1754
1755        struct TestView {
1756            state: ListState,
1757            item_height: Rc<Cell<usize>>,
1758        }
1759
1760        impl Render for TestView {
1761            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1762                let height = self.item_height.get();
1763                list(self.state.clone(), move |index, _, _| {
1764                    let height = if index == 5 { height } else { 100 };
1765                    div().h(px(height as f32)).w_full().into_any()
1766                })
1767                .w_full()
1768                .h_full()
1769            }
1770        }
1771
1772        let state_clone = state.clone();
1773        let item_height_clone = item_height.clone();
1774        let view = cx.update(|_, cx| {
1775            cx.new(|_| TestView {
1776                state: state_clone,
1777                item_height: item_height_clone,
1778            })
1779        });
1780
1781        state.scroll_to(gpui::ListOffset {
1782            item_ix: 5,
1783            offset_in_item: px(40.),
1784        });
1785
1786        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1787            view.clone().into_any_element()
1788        });
1789
1790        item_height.set(200);
1791        state.remeasure_items(5..6);
1792
1793        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1794            view.into_any_element()
1795        });
1796
1797        let offset = state.logical_scroll_top();
1798        assert_eq!(offset.item_ix, 5);
1799        assert_eq!(offset.offset_in_item, px(40.));
1800    }
1801
1802    #[gpui::test]
1803    fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
1804        let cx = cx.add_empty_window();
1805
1806        // 10 items, each 50px tall → 500px total content, 200px viewport.
1807        // With follow-tail on, the list should always show the bottom.
1808        let item_height = Rc::new(Cell::new(50usize));
1809        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
1810
1811        struct TestView {
1812            state: ListState,
1813            item_height: Rc<Cell<usize>>,
1814        }
1815        impl Render for TestView {
1816            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1817                let height = self.item_height.get();
1818                list(self.state.clone(), move |_, _, _| {
1819                    div().h(px(height as f32)).w_full().into_any()
1820                })
1821                .w_full()
1822                .h_full()
1823            }
1824        }
1825
1826        let state_clone = state.clone();
1827        let item_height_clone = item_height.clone();
1828        let view = cx.update(|_, cx| {
1829            cx.new(|_| TestView {
1830                state: state_clone,
1831                item_height: item_height_clone,
1832            })
1833        });
1834
1835        state.set_follow_mode(FollowMode::Tail);
1836
1837        // First paint — items are 50px, total 500px, viewport 200px.
1838        // Follow-tail should anchor to the end.
1839        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1840            view.clone().into_any_element()
1841        });
1842
1843        // The scroll should be at the bottom: the last visible items fill the
1844        // 200px viewport from the end of 500px of content (offset 300px).
1845        let offset = state.logical_scroll_top();
1846        assert_eq!(offset.item_ix, 6);
1847        assert_eq!(offset.offset_in_item, px(0.));
1848        assert!(state.is_following_tail());
1849
1850        // Simulate items growing (e.g. streaming content makes each item taller).
1851        // 10 items × 80px = 800px total.
1852        item_height.set(80);
1853        state.remeasure();
1854
1855        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1856            view.into_any_element()
1857        });
1858
1859        // After growth, follow-tail should have re-anchored to the new end.
1860        // 800px total − 200px viewport = 600px offset → item 7 at offset 40px,
1861        // but follow-tail anchors to item_count (10), and layout walks back to
1862        // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200,
1863        // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking →
1864        // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40).
1865        let offset = state.logical_scroll_top();
1866        assert_eq!(offset.item_ix, 7);
1867        assert_eq!(offset.offset_in_item, px(40.));
1868        assert!(state.is_following_tail());
1869    }
1870
1871    #[gpui::test]
1872    fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) {
1873        let cx = cx.add_empty_window();
1874
1875        // 10 items × 50px = 500px total, 200px viewport.
1876        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
1877
1878        struct TestView(ListState);
1879        impl Render for TestView {
1880            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1881                list(self.0.clone(), |_, _, _| {
1882                    div().h(px(50.)).w_full().into_any()
1883                })
1884                .w_full()
1885                .h_full()
1886            }
1887        }
1888
1889        state.set_follow_mode(FollowMode::Tail);
1890
1891        // Paint with follow-tail — scroll anchored to the bottom.
1892        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
1893            cx.new(|_| TestView(state.clone())).into_any_element()
1894        });
1895        assert!(state.is_following_tail());
1896
1897        // Simulate the user scrolling up.
1898        // This should disengage follow-tail.
1899        cx.simulate_event(ScrollWheelEvent {
1900            position: point(px(50.), px(100.)),
1901            delta: ScrollDelta::Pixels(point(px(0.), px(100.))),
1902            ..Default::default()
1903        });
1904
1905        assert!(
1906            !state.is_following_tail(),
1907            "follow-tail should disengage when the user scrolls toward the start"
1908        );
1909    }
1910
1911    #[gpui::test]
1912    fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) {
1913        let cx = cx.add_empty_window();
1914
1915        // 10 items × 50px = 500px total, 200px viewport.
1916        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1917
1918        struct TestView(ListState);
1919        impl Render for TestView {
1920            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1921                list(self.0.clone(), |_, _, _| {
1922                    div().h(px(50.)).w_full().into_any()
1923                })
1924                .w_full()
1925                .h_full()
1926            }
1927        }
1928
1929        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1930
1931        state.set_follow_mode(FollowMode::Tail);
1932
1933        // Paint with follow-tail — scroll anchored to the bottom.
1934        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1935            view.clone().into_any_element()
1936        });
1937        assert!(state.is_following_tail());
1938
1939        // Simulate the scrollbar moving the viewport to the middle.
1940        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
1941
1942        let offset = state.logical_scroll_top();
1943        assert_eq!(offset.item_ix, 3);
1944        assert_eq!(offset.offset_in_item, px(0.));
1945        assert!(
1946            !state.is_following_tail(),
1947            "follow-tail should disengage when the scrollbar manually repositions the list"
1948        );
1949
1950        // A subsequent draw should preserve the user's manual position instead
1951        // of snapping back to the end.
1952        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1953            view.into_any_element()
1954        });
1955
1956        let offset = state.logical_scroll_top();
1957        assert_eq!(offset.item_ix, 3);
1958        assert_eq!(offset.offset_in_item, px(0.));
1959    }
1960
1961    #[gpui::test]
1962    fn test_scrollbar_drag_with_growing_content(cx: &mut TestAppContext) {
1963        let cx = cx.add_empty_window();
1964
1965        let last_item_height = Rc::new(Cell::new(50usize));
1966        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1967
1968        struct TestView {
1969            state: ListState,
1970            last_item_height: Rc<Cell<usize>>,
1971        }
1972        impl Render for TestView {
1973            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1974                let last_item_height = self.last_item_height.clone();
1975                list(self.state.clone(), move |index, _, _| {
1976                    let height = if index == 9 {
1977                        last_item_height.get()
1978                    } else {
1979                        50
1980                    };
1981                    div().h(px(height as f32)).w_full().into_any()
1982                })
1983                .w_full()
1984                .h_full()
1985            }
1986        }
1987
1988        let view = cx.update(|_, cx| {
1989            cx.new(|_| TestView {
1990                state: state.clone(),
1991                last_item_height: last_item_height.clone(),
1992            })
1993        });
1994
1995        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1996            view.clone().into_any_element()
1997        });
1998
1999        state.scrollbar_drag_started();
2000
2001        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2002        let scrollbar_offset_before_growth = state.scroll_px_offset_for_scrollbar();
2003
2004        let offset = state.logical_scroll_top();
2005        assert_eq!(offset.item_ix, 3);
2006        assert_eq!(offset.offset_in_item, px(0.));
2007
2008        last_item_height.set(550);
2009        state.remeasure_items(9..10);
2010        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2011            view.clone().into_any_element()
2012        });
2013
2014        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2015        assert_eq!(
2016            state.scroll_px_offset_for_scrollbar(),
2017            scrollbar_offset_before_growth
2018        );
2019
2020        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2021        let offset = state.logical_scroll_top();
2022        assert_eq!(offset.item_ix, 3);
2023        assert_eq!(offset.offset_in_item, px(0.));
2024    }
2025
2026    #[gpui::test]
2027    fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) {
2028        let cx = cx.add_empty_window();
2029
2030        // 10 items × 50px = 500px total, 200px viewport.
2031        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2032
2033        struct TestView(ListState);
2034        impl Render for TestView {
2035            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2036                list(self.0.clone(), |_, _, _| {
2037                    div().h(px(50.)).w_full().into_any()
2038                })
2039                .w_full()
2040                .h_full()
2041            }
2042        }
2043
2044        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2045
2046        // Scroll to the middle of the list (item 3).
2047        state.scroll_to(gpui::ListOffset {
2048            item_ix: 3,
2049            offset_in_item: px(0.),
2050        });
2051
2052        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2053            view.clone().into_any_element()
2054        });
2055
2056        let offset = state.logical_scroll_top();
2057        assert_eq!(offset.item_ix, 3);
2058        assert_eq!(offset.offset_in_item, px(0.));
2059        assert!(!state.is_following_tail());
2060
2061        // Enable follow-tail — this should immediately snap the scroll anchor
2062        // to the end, like the user just sent a prompt.
2063        state.set_follow_mode(FollowMode::Tail);
2064
2065        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2066            view.into_any_element()
2067        });
2068
2069        // After paint, scroll should be at the bottom.
2070        // 500px total − 200px viewport = 300px offset → item 6, offset 0.
2071        let offset = state.logical_scroll_top();
2072        assert_eq!(offset.item_ix, 6);
2073        assert_eq!(offset.offset_in_item, px(0.));
2074        assert!(state.is_following_tail());
2075    }
2076
2077    #[gpui::test]
2078    fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) {
2079        let cx = cx.add_empty_window();
2080
2081        const ITEMS: usize = 10;
2082        const ITEM_SIZE: f32 = 50.0;
2083
2084        let state = ListState::new(
2085            ITEMS,
2086            crate::ListAlignment::Bottom,
2087            px(ITEMS as f32 * ITEM_SIZE),
2088        );
2089
2090        struct TestView(ListState);
2091        impl Render for TestView {
2092            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2093                list(self.0.clone(), |_, _, _| {
2094                    div().h(px(ITEM_SIZE)).w_full().into_any()
2095                })
2096                .w_full()
2097                .h_full()
2098            }
2099        }
2100
2101        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
2102            cx.new(|_| TestView(state.clone())).into_any_element()
2103        });
2104
2105        // Bottom-aligned lists start pinned to the end: logical_scroll_top returns
2106        // item_ix == item_count, meaning no explicit scroll position has been set.
2107        assert_eq!(state.logical_scroll_top().item_ix, ITEMS);
2108
2109        let max_offset = state.max_offset_for_scrollbar();
2110        let scroll_offset = state.scroll_px_offset_for_scrollbar();
2111
2112        assert_eq!(
2113            -scroll_offset.y, max_offset.y,
2114            "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom",
2115            -scroll_offset.y, max_offset.y,
2116        );
2117    }
2118
2119    /// When the user scrolls away from the bottom during follow_tail,
2120    /// follow_tail suspends. If they scroll back to the bottom, the
2121    /// next paint should re-engage follow_tail using fresh measurements.
2122    #[gpui::test]
2123    fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) {
2124        let cx = cx.add_empty_window();
2125
2126        // 10 items × 50px = 500px total, 200px viewport.
2127        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2128
2129        struct TestView(ListState);
2130        impl Render for TestView {
2131            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2132                list(self.0.clone(), |_, _, _| {
2133                    div().h(px(50.)).w_full().into_any()
2134                })
2135                .w_full()
2136                .h_full()
2137            }
2138        }
2139
2140        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2141
2142        state.set_follow_mode(FollowMode::Tail);
2143
2144        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2145            view.clone().into_any_element()
2146        });
2147        assert!(state.is_following_tail());
2148
2149        // Scroll up — follow_tail should suspend (not fully disengage).
2150        cx.simulate_event(ScrollWheelEvent {
2151            position: point(px(50.), px(100.)),
2152            delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
2153            ..Default::default()
2154        });
2155        assert!(!state.is_following_tail());
2156
2157        // Scroll back down to the bottom.
2158        cx.simulate_event(ScrollWheelEvent {
2159            position: point(px(50.), px(100.)),
2160            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
2161            ..Default::default()
2162        });
2163
2164        // After a paint, follow_tail should re-engage because the
2165        // layout confirmed we're at the true bottom.
2166        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2167            view.clone().into_any_element()
2168        });
2169        assert!(
2170            state.is_following_tail(),
2171            "follow_tail should re-engage after scrolling back to the bottom"
2172        );
2173    }
2174
2175    /// When an item is spliced to unmeasured (0px) while follow_tail
2176    /// is suspended, the re-engagement check should still work correctly
2177    #[gpui::test]
2178    fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) {
2179        let cx = cx.add_empty_window();
2180
2181        // 20 items × 50px = 1000px total, 200px viewport, 1000px
2182        // overdraw so all items get measured during the follow_tail
2183        // paint (matching realistic production settings).
2184        let state = ListState::new(20, crate::ListAlignment::Top, px(1000.));
2185
2186        struct TestView(ListState);
2187        impl Render for TestView {
2188            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2189                list(self.0.clone(), |_, _, _| {
2190                    div().h(px(50.)).w_full().into_any()
2191                })
2192                .w_full()
2193                .h_full()
2194            }
2195        }
2196
2197        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2198
2199        state.set_follow_mode(FollowMode::Tail);
2200
2201        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2202            view.clone().into_any_element()
2203        });
2204        assert!(state.is_following_tail());
2205
2206        // Scroll up a meaningful amount — suspends follow_tail.
2207        // 20 items × 50px = 1000px. viewport 200px. scroll_max = 800px.
2208        // Scrolling up 200px puts us at 600px, clearly not at bottom.
2209        cx.simulate_event(ScrollWheelEvent {
2210            position: point(px(50.), px(100.)),
2211            delta: ScrollDelta::Pixels(point(px(0.), px(200.))),
2212            ..Default::default()
2213        });
2214        assert!(!state.is_following_tail());
2215
2216        // Invalidate the last item (simulates EntryUpdated calling
2217        // remeasure_items). This makes items.summary().height
2218        // temporarily wrong (0px for the invalidated item).
2219        state.remeasure_items(19..20);
2220
2221        // Paint — layout re-measures the invalidated item with its true
2222        // height. The re-engagement check uses these fresh measurements.
2223        // Since we scrolled 200px up from the 800px max, we're at
2224        // ~600px — NOT at the bottom, so follow_tail should NOT
2225        // re-engage.
2226        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2227            view.clone().into_any_element()
2228        });
2229        assert!(
2230            !state.is_following_tail(),
2231            "follow_tail should not falsely re-engage due to an unmeasured item \
2232             reducing items.summary().height"
2233        );
2234    }
2235
2236    #[gpui::test]
2237    fn test_follow_tail_reengages_after_scrollbar_disengagement(cx: &mut TestAppContext) {
2238        let cx = cx.add_empty_window();
2239
2240        // 10 items × 50px = 500px total, 200px viewport, scroll_max = 300px.
2241        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2242
2243        struct TestView(ListState);
2244        impl Render for TestView {
2245            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2246                list(self.0.clone(), |_, _, _| {
2247                    div().h(px(50.)).w_full().into_any()
2248                })
2249                .w_full()
2250                .h_full()
2251            }
2252        }
2253
2254        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2255
2256        state.set_follow_mode(FollowMode::Tail);
2257        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2258            view.clone().into_any_element()
2259        });
2260        assert!(state.is_following_tail());
2261
2262        // Drag the scrollbar up to the middle — follow_tail should suspend.
2263        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2264        assert!(!state.is_following_tail());
2265
2266        // Drag the scrollbar back to the bottom — follow_tail should re-engage
2267        // on the next paint.
2268        state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
2269        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2270            view.into_any_element()
2271        });
2272        assert!(
2273            state.is_following_tail(),
2274            "follow_tail should re-engage after scrolling back to the bottom via the scrollbar"
2275        );
2276    }
2277
2278    #[gpui::test]
2279    fn test_follow_tail_reengages_after_scrollbar_drag_to_bottom_while_growing(
2280        cx: &mut TestAppContext,
2281    ) {
2282        let cx = cx.add_empty_window();
2283
2284        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2285
2286        struct TestView(ListState);
2287        impl Render for TestView {
2288            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2289                list(self.0.clone(), |_, _, _| {
2290                    div().h(px(50.)).w_full().into_any()
2291                })
2292                .w_full()
2293                .h_full()
2294            }
2295        }
2296
2297        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2298
2299        state.set_follow_mode(FollowMode::Tail);
2300        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2301            view.clone().into_any_element()
2302        });
2303        assert!(state.is_following_tail());
2304
2305        state.scrollbar_drag_started();
2306
2307        state.splice(10..10, 10);
2308        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2309            view.clone().into_any_element()
2310        });
2311
2312        state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
2313        state.scrollbar_drag_ended();
2314
2315        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2316            view.into_any_element()
2317        });
2318
2319        assert!(
2320            state.is_following_tail(),
2321            "follow_tail should re-engage when the user drags the scrollbar to \
2322             the bottom of its track, even when content has grown during the drag \
2323             (so frozen_bottom < live_bottom)"
2324        );
2325    }
2326}