Skip to main content

open_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 open_gpui_collections::VecDeque;
17use open_gpui_refineable::Refineable as _;
18use open_gpui_sum_tree::{Bias, Dimensions, SumTree};
19use std::{cell::RefCell, ops::Range, rc::Rc};
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    /// Returns whether the item is entirely above the viewport, or `None` if
746    /// the list has not measured enough layout to know.
747    ///
748    /// A zero-height viewport still yields a definitive answer: callers may
749    /// size sibling UI based on this query (potentially squeezing the list
750    /// itself to zero height), so returning `None` in that case would make
751    /// the answer oscillate from frame to frame.
752    pub fn item_is_above_viewport(&self, ix: usize) -> Option<bool> {
753        let viewport_bounds = self.0.borrow().last_layout_bounds?;
754
755        let scroll_top = self.logical_scroll_top();
756        if ix < scroll_top.item_ix {
757            // Rows before the logical scroll top have no item bounds, but
758            // their position relative to the viewport is known from scroll state.
759            return Some(true);
760        }
761
762        let item_bounds = self.bounds_for_item(ix)?;
763        Some(item_bounds.bottom() <= viewport_bounds.top())
764    }
765
766    /// Returns whether the item is entirely below the viewport, or `None` if
767    /// the list has not measured enough layout to know.
768    ///
769    /// See [`Self::item_is_above_viewport`] for why a zero-height viewport
770    /// still yields a definitive answer.
771    pub fn item_is_below_viewport(&self, ix: usize) -> Option<bool> {
772        let viewport_bounds = self.0.borrow().last_layout_bounds?;
773
774        let scroll_top = self.logical_scroll_top();
775        if ix < scroll_top.item_ix {
776            // Rows before the logical scroll top have no item bounds, but
777            // their position relative to the viewport is known from scroll state.
778            return Some(false);
779        }
780
781        let item_bounds = self.bounds_for_item(ix)?;
782        Some(item_bounds.top() >= viewport_bounds.bottom())
783    }
784}
785
786impl StateInner {
787    fn max_scroll_offset(&self) -> Pixels {
788        let bounds = self.last_layout_bounds.unwrap_or_default();
789        let height = self
790            .scrollbar_drag_start_height
791            .unwrap_or_else(|| self.items.summary().height);
792        (height - bounds.size.height).max(px(0.))
793    }
794
795    fn visible_range(
796        items: &SumTree<ListItem>,
797        height: Pixels,
798        scroll_top: &ListOffset,
799    ) -> Range<usize> {
800        let mut cursor = items.cursor::<ListItemSummary>(());
801        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
802        let start_y = cursor.start().height + scroll_top.offset_in_item;
803        cursor.seek_forward(&Height(start_y + height), Bias::Left);
804        scroll_top.item_ix..cursor.start().count + 1
805    }
806
807    fn scroll(
808        &mut self,
809        scroll_top: &ListOffset,
810        height: Pixels,
811        delta: Point<Pixels>,
812        current_view: EntityId,
813        window: &mut Window,
814        cx: &mut App,
815    ) {
816        // Drop scroll events after a reset, since we can't calculate
817        // the new logical scroll top without the item heights
818        if self.reset {
819            return;
820        }
821
822        let padding = self.last_padding.unwrap_or_default();
823        let scroll_max =
824            (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
825        let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
826            .max(px(0.))
827            .min(scroll_max);
828
829        if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
830            self.logical_scroll_top = None;
831        } else {
832            let (start, ..) =
833                self.items
834                    .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
835            let item_ix = start.count;
836            let offset_in_item = new_scroll_top - start.height;
837            self.logical_scroll_top = Some(ListOffset {
838                item_ix,
839                offset_in_item,
840            });
841        }
842
843        if delta.y > px(0.) {
844            self.follow_state.stop_following();
845        }
846
847        if let Some(handler) = self.scroll_handler.as_mut() {
848            let visible_range = Self::visible_range(&self.items, height, scroll_top);
849            handler(
850                &ListScrollEvent {
851                    visible_range,
852                    count: self.items.summary().count,
853                    is_scrolled: self.logical_scroll_top.is_some(),
854                    is_following_tail: matches!(
855                        self.follow_state,
856                        FollowState::Tail { is_following: true }
857                    ),
858                },
859                window,
860                cx,
861            );
862        }
863
864        cx.notify(current_view);
865    }
866
867    fn logical_scroll_top(&self) -> ListOffset {
868        self.logical_scroll_top
869            .unwrap_or_else(|| match self.alignment {
870                ListAlignment::Top => ListOffset {
871                    item_ix: 0,
872                    offset_in_item: px(0.),
873                },
874                ListAlignment::Bottom => ListOffset {
875                    item_ix: self.items.summary().count,
876                    offset_in_item: px(0.),
877                },
878            })
879    }
880
881    fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels {
882        let (start, ..) = self.items.find::<ListItemSummary, _>(
883            (),
884            &Count(logical_scroll_top.item_ix),
885            Bias::Right,
886        );
887        start.height + logical_scroll_top.offset_in_item
888    }
889
890    fn layout_all_items(
891        &mut self,
892        available_width: Pixels,
893        render_item: &mut RenderItemFn,
894        window: &mut Window,
895        cx: &mut App,
896    ) {
897        match &mut self.measuring_behavior {
898            ListMeasuringBehavior::Visible => {
899                return;
900            }
901            ListMeasuringBehavior::Measure(has_measured) => {
902                if *has_measured {
903                    return;
904                }
905                *has_measured = true;
906            }
907        }
908
909        let mut cursor = self.items.cursor::<Count>(());
910        let available_item_space = size(
911            AvailableSpace::Definite(available_width),
912            AvailableSpace::MinContent,
913        );
914
915        let mut measured_items = Vec::default();
916
917        for (ix, item) in cursor.enumerate() {
918            let size = item.size().unwrap_or_else(|| {
919                let mut element = render_item(ix, window, cx);
920                element.layout_as_root(available_item_space, window, cx)
921            });
922
923            measured_items.push(ListItem::Measured {
924                size,
925                focus_handle: item.focus_handle(),
926            });
927        }
928
929        self.items = SumTree::from_iter(measured_items, ());
930    }
931
932    fn layout_items(
933        &mut self,
934        available_width: Option<Pixels>,
935        available_height: Pixels,
936        padding: &Edges<Pixels>,
937        render_item: &mut RenderItemFn,
938        window: &mut Window,
939        cx: &mut App,
940    ) -> LayoutItemsResponse {
941        let old_items = self.items.clone();
942        let mut measured_items = VecDeque::new();
943        let mut item_layouts = VecDeque::new();
944        let mut rendered_height = padding.top;
945        let mut max_item_width = px(0.);
946        let mut scroll_top = self.logical_scroll_top();
947
948        if self.follow_state.is_following() {
949            scroll_top = ListOffset {
950                item_ix: self.items.summary().count,
951                offset_in_item: px(0.),
952            };
953            self.logical_scroll_top = Some(scroll_top);
954        }
955
956        let mut rendered_focused_item = false;
957
958        let available_item_space = size(
959            available_width.map_or(AvailableSpace::MinContent, |width| {
960                AvailableSpace::Definite(width)
961            }),
962            AvailableSpace::MinContent,
963        );
964
965        let mut cursor = old_items.cursor::<Count>(());
966
967        // Render items after the scroll top, including those in the trailing overdraw
968        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
969        for (ix, item) in cursor.by_ref().enumerate() {
970            let visible_height = rendered_height - scroll_top.offset_in_item;
971            if visible_height >= available_height + self.overdraw {
972                break;
973            }
974
975            // Use the previously cached height and focus handle if available
976            let mut size = item.size();
977
978            // If we're within the visible area or the height wasn't cached, render and measure the item's element
979            if visible_height < available_height || size.is_none() {
980                let item_index = scroll_top.item_ix + ix;
981                let mut element = render_item(item_index, window, cx);
982                let element_size = element.layout_as_root(available_item_space, window, cx);
983                size = Some(element_size);
984
985                // If there's a pending scroll adjustment for the scroll-top
986                // item, apply it.
987                if ix == 0 {
988                    if let Some(pending_scroll) = self.pending_scroll.take() {
989                        match pending_scroll {
990                            PendingScroll::Absolute { item_ix, offset }
991                                if item_ix == scroll_top.item_ix =>
992                            {
993                                scroll_top.offset_in_item = offset.min(element_size.height);
994                                self.logical_scroll_top = Some(scroll_top);
995                            }
996                            PendingScroll::Proportional(pending_scroll)
997                                if pending_scroll.item_ix == scroll_top.item_ix =>
998                            {
999                                // Ensuring proportional scroll position is
1000                                // maintained after re-measuring.
1001                                scroll_top.offset_in_item =
1002                                    Pixels(pending_scroll.fraction * element_size.height.0);
1003                                self.logical_scroll_top = Some(scroll_top);
1004                            }
1005                            _ => {}
1006                        }
1007                    }
1008                }
1009
1010                if visible_height < available_height {
1011                    item_layouts.push_back(ItemLayout {
1012                        index: item_index,
1013                        element,
1014                        size: element_size,
1015                    });
1016                    if item.contains_focused(window, cx) {
1017                        rendered_focused_item = true;
1018                    }
1019                }
1020            }
1021
1022            let size = size.unwrap();
1023            rendered_height += size.height;
1024            max_item_width = max_item_width.max(size.width);
1025            measured_items.push_back(ListItem::Measured {
1026                size,
1027                focus_handle: item.focus_handle(),
1028            });
1029        }
1030        rendered_height += padding.bottom;
1031
1032        // Prepare to start walking upward from the item at the scroll top.
1033        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1034
1035        // If the rendered items do not fill the visible region, then adjust
1036        // the scroll top upward.
1037        if rendered_height - scroll_top.offset_in_item < available_height {
1038            while rendered_height < available_height {
1039                cursor.prev();
1040                if let Some(item) = cursor.item() {
1041                    let item_index = cursor.start().0;
1042                    let mut element = render_item(item_index, window, cx);
1043                    let element_size = element.layout_as_root(available_item_space, window, cx);
1044                    let focus_handle = item.focus_handle();
1045                    rendered_height += element_size.height;
1046                    measured_items.push_front(ListItem::Measured {
1047                        size: element_size,
1048                        focus_handle,
1049                    });
1050                    item_layouts.push_front(ItemLayout {
1051                        index: item_index,
1052                        element,
1053                        size: element_size,
1054                    });
1055                    if item.contains_focused(window, cx) {
1056                        rendered_focused_item = true;
1057                    }
1058                } else {
1059                    break;
1060                }
1061            }
1062
1063            scroll_top = ListOffset {
1064                item_ix: cursor.start().0,
1065                offset_in_item: rendered_height - available_height,
1066            };
1067
1068            match self.alignment {
1069                ListAlignment::Top => {
1070                    scroll_top.offset_in_item = scroll_top.offset_in_item.max(px(0.));
1071                    self.logical_scroll_top = Some(scroll_top);
1072                }
1073                ListAlignment::Bottom => {
1074                    scroll_top = ListOffset {
1075                        item_ix: cursor.start().0,
1076                        offset_in_item: rendered_height - available_height,
1077                    };
1078                    self.logical_scroll_top = None;
1079                }
1080            };
1081        }
1082
1083        // Measure items in the leading overdraw
1084        let mut leading_overdraw = scroll_top.offset_in_item;
1085        while leading_overdraw < self.overdraw {
1086            cursor.prev();
1087            if let Some(item) = cursor.item() {
1088                let size = if let ListItem::Measured { size, .. } = item {
1089                    *size
1090                } else {
1091                    let mut element = render_item(cursor.start().0, window, cx);
1092                    element.layout_as_root(available_item_space, window, cx)
1093                };
1094
1095                leading_overdraw += size.height;
1096                measured_items.push_front(ListItem::Measured {
1097                    size,
1098                    focus_handle: item.focus_handle(),
1099                });
1100            } else {
1101                break;
1102            }
1103        }
1104
1105        let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len());
1106        let mut cursor = old_items.cursor::<Count>(());
1107        let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right);
1108        new_items.extend(measured_items, ());
1109        cursor.seek(&Count(measured_range.end), Bias::Right);
1110        new_items.append(cursor.suffix(), ());
1111        self.items = new_items;
1112
1113        // If follow_tail mode is on but the user scrolled away
1114        // (is_following is false), check whether the current scroll
1115        // position has returned to the bottom.
1116        if self.follow_state.has_stopped_following() {
1117            let padding = self.last_padding.unwrap_or_default();
1118            let total_height = self.items.summary().height + padding.top + padding.bottom;
1119            let scroll_offset = self.scroll_top(&scroll_top);
1120            if scroll_offset + available_height >= total_height - px(1.0) {
1121                self.follow_state.start_following();
1122            }
1123        }
1124
1125        // If none of the visible items are focused, check if an off-screen item is focused
1126        // and include it to be rendered after the visible items so keyboard interaction continues
1127        // to work for it.
1128        if !rendered_focused_item {
1129            let mut cursor = self
1130                .items
1131                .filter::<_, Count>((), |summary| summary.has_focus_handles);
1132            cursor.next();
1133            while let Some(item) = cursor.item() {
1134                if item.contains_focused(window, cx) {
1135                    let item_index = cursor.start().0;
1136                    let mut element = render_item(cursor.start().0, window, cx);
1137                    let size = element.layout_as_root(available_item_space, window, cx);
1138                    item_layouts.push_back(ItemLayout {
1139                        index: item_index,
1140                        element,
1141                        size,
1142                    });
1143                    break;
1144                }
1145                cursor.next();
1146            }
1147        }
1148
1149        LayoutItemsResponse {
1150            max_item_width,
1151            scroll_top,
1152            item_layouts,
1153        }
1154    }
1155
1156    fn prepaint_items(
1157        &mut self,
1158        bounds: Bounds<Pixels>,
1159        padding: Edges<Pixels>,
1160        autoscroll: bool,
1161        render_item: &mut RenderItemFn,
1162        window: &mut Window,
1163        cx: &mut App,
1164    ) -> Result<LayoutItemsResponse, ListOffset> {
1165        window.transact(|window| {
1166            match self.measuring_behavior {
1167                ListMeasuringBehavior::Measure(has_measured) if !has_measured => {
1168                    self.layout_all_items(bounds.size.width, render_item, window, cx);
1169                }
1170                _ => {}
1171            }
1172
1173            let mut layout_response = self.layout_items(
1174                Some(bounds.size.width),
1175                bounds.size.height,
1176                &padding,
1177                render_item,
1178                window,
1179                cx,
1180            );
1181
1182            // Avoid honoring autoscroll requests from elements other than our children.
1183            window.take_autoscroll();
1184
1185            // Only paint the visible items, if there is actually any space for them (taking padding into account)
1186            if bounds.size.height > padding.top + padding.bottom {
1187                let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
1188                item_origin.y -= layout_response.scroll_top.offset_in_item;
1189                for item in &mut layout_response.item_layouts {
1190                    window.with_content_mask(Some(ContentMask { bounds }), |window| {
1191                        item.element.prepaint_at(item_origin, window, cx);
1192                    });
1193
1194                    if let Some(autoscroll_bounds) = window.take_autoscroll()
1195                        && autoscroll
1196                    {
1197                        if autoscroll_bounds.top() < bounds.top() {
1198                            return Err(ListOffset {
1199                                item_ix: item.index,
1200                                offset_in_item: autoscroll_bounds.top() - item_origin.y,
1201                            });
1202                        } else if autoscroll_bounds.bottom() > bounds.bottom() {
1203                            let mut cursor = self.items.cursor::<Count>(());
1204                            cursor.seek(&Count(item.index), Bias::Right);
1205                            let mut height = bounds.size.height - padding.top - padding.bottom;
1206
1207                            // Account for the height of the element down until the autoscroll bottom.
1208                            height -= autoscroll_bounds.bottom() - item_origin.y;
1209
1210                            // Keep decreasing the scroll top until we fill all the available space.
1211                            while height > Pixels::ZERO {
1212                                cursor.prev();
1213                                let Some(item) = cursor.item() else { break };
1214
1215                                let size = item.size().unwrap_or_else(|| {
1216                                    let mut item = render_item(cursor.start().0, window, cx);
1217                                    let item_available_size =
1218                                        size(bounds.size.width.into(), AvailableSpace::MinContent);
1219                                    item.layout_as_root(item_available_size, window, cx)
1220                                });
1221                                height -= size.height;
1222                            }
1223
1224                            return Err(ListOffset {
1225                                item_ix: cursor.start().0,
1226                                offset_in_item: if height < Pixels::ZERO {
1227                                    -height
1228                                } else {
1229                                    Pixels::ZERO
1230                                },
1231                            });
1232                        }
1233                    }
1234
1235                    item_origin.y += item.size.height;
1236                }
1237            } else {
1238                layout_response.item_layouts.clear();
1239            }
1240
1241            Ok(layout_response)
1242        })
1243    }
1244
1245    // Scrollbar support
1246
1247    fn set_offset_from_scrollbar(&mut self, point: Point<Pixels>) {
1248        let Some(bounds) = self.last_layout_bounds else {
1249            return;
1250        };
1251        let height = bounds.size.height;
1252
1253        let padding = self.last_padding.unwrap_or_default();
1254        // Scrollbar drag positions are computed from the content height
1255        // captured at drag start, so map them back using the same height.
1256        let content_height = self
1257            .scrollbar_drag_start_height
1258            .unwrap_or_else(|| self.items.summary().height);
1259        let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.));
1260        let new_scroll_top = (-point.y).max(px(0.)).min(scroll_max);
1261
1262        // If content grew during the drag, the frozen bottom is below the
1263        // live bottom. Treat dragging to the frozen end as resuming tail follow.
1264        let dragged_to_end =
1265            scroll_max > px(0.) && new_scroll_top >= (scroll_max - px(1.0)).max(px(0.));
1266        if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) {
1267            self.follow_state = FollowState::Tail { is_following: true };
1268            let item_count = self.items.summary().count;
1269            self.logical_scroll_top = Some(ListOffset {
1270                item_ix: item_count,
1271                offset_in_item: px(0.),
1272            });
1273            return;
1274        }
1275
1276        self.follow_state.stop_following();
1277
1278        if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
1279            self.logical_scroll_top = None;
1280        } else {
1281            let (start, _, _) =
1282                self.items
1283                    .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
1284
1285            let item_ix = start.count;
1286            let offset_in_item = new_scroll_top - start.height;
1287            self.logical_scroll_top = Some(ListOffset {
1288                item_ix,
1289                offset_in_item,
1290            });
1291        }
1292    }
1293}
1294
1295impl std::fmt::Debug for ListItem {
1296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1297        match self {
1298            Self::Unmeasured { .. } => write!(f, "Unrendered"),
1299            Self::Measured { size, .. } => f.debug_struct("Rendered").field("size", size).finish(),
1300        }
1301    }
1302}
1303
1304/// An offset into the list's items, in terms of the item index and the number
1305/// of pixels off the top left of the item.
1306#[derive(Debug, Clone, Copy, Default)]
1307pub struct ListOffset {
1308    /// The index of an item in the list
1309    pub item_ix: usize,
1310    /// The number of pixels to offset from the item index.
1311    pub offset_in_item: Pixels,
1312}
1313
1314impl Element for List {
1315    type RequestLayoutState = ();
1316    type PrepaintState = ListPrepaintState;
1317
1318    fn id(&self) -> Option<crate::ElementId> {
1319        None
1320    }
1321
1322    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1323        None
1324    }
1325
1326    fn request_layout(
1327        &mut self,
1328        _id: Option<&GlobalElementId>,
1329        _inspector_id: Option<&InspectorElementId>,
1330        window: &mut Window,
1331        cx: &mut App,
1332    ) -> (crate::LayoutId, Self::RequestLayoutState) {
1333        let layout_id = match self.sizing_behavior {
1334            ListSizingBehavior::Infer => {
1335                let mut style = Style::default();
1336                style.overflow.y = Overflow::Scroll;
1337                style.refine(&self.style);
1338                window.with_text_style(style.text_style().cloned(), |window| {
1339                    let state = &mut *self.state.0.borrow_mut();
1340
1341                    let available_height = if let Some(last_bounds) = state.last_layout_bounds {
1342                        last_bounds.size.height
1343                    } else {
1344                        // If we don't have the last layout bounds (first render),
1345                        // we might just use the overdraw value as the available height to layout enough items.
1346                        state.overdraw
1347                    };
1348                    let padding = style.padding.to_pixels(
1349                        state.last_layout_bounds.unwrap_or_default().size.into(),
1350                        window.rem_size(),
1351                    );
1352
1353                    let layout_response = state.layout_items(
1354                        None,
1355                        available_height,
1356                        &padding,
1357                        &mut self.render_item,
1358                        window,
1359                        cx,
1360                    );
1361                    let max_element_width = layout_response.max_item_width;
1362
1363                    let summary = state.items.summary();
1364                    let total_height = summary.height;
1365
1366                    window.request_measured_layout(
1367                        style,
1368                        move |known_dimensions, available_space, _window, _cx| {
1369                            let width =
1370                                known_dimensions
1371                                    .width
1372                                    .unwrap_or(match available_space.width {
1373                                        AvailableSpace::Definite(x) => x,
1374                                        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1375                                            max_element_width
1376                                        }
1377                                    });
1378                            let height = match available_space.height {
1379                                AvailableSpace::Definite(height) => total_height.min(height),
1380                                AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1381                                    total_height
1382                                }
1383                            };
1384                            size(width, height)
1385                        },
1386                    )
1387                })
1388            }
1389            ListSizingBehavior::Auto => {
1390                let mut style = Style::default();
1391                style.refine(&self.style);
1392                window.with_text_style(style.text_style().cloned(), |window| {
1393                    window.request_layout(style, None, cx)
1394                })
1395            }
1396        };
1397        (layout_id, ())
1398    }
1399
1400    fn prepaint(
1401        &mut self,
1402        _id: Option<&GlobalElementId>,
1403        _inspector_id: Option<&InspectorElementId>,
1404        bounds: Bounds<Pixels>,
1405        _: &mut Self::RequestLayoutState,
1406        window: &mut Window,
1407        cx: &mut App,
1408    ) -> ListPrepaintState {
1409        let state = &mut *self.state.0.borrow_mut();
1410        state.reset = false;
1411
1412        let mut style = Style::default();
1413        style.refine(&self.style);
1414
1415        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
1416
1417        // If the width of the list has changed, invalidate all cached item heights
1418        if state
1419            .last_layout_bounds
1420            .is_none_or(|last_bounds| last_bounds.size.width != bounds.size.width)
1421        {
1422            let new_items = SumTree::from_iter(
1423                state.items.iter().map(|item| ListItem::Unmeasured {
1424                    size_hint: None,
1425                    focus_handle: item.focus_handle(),
1426                }),
1427                (),
1428            );
1429
1430            state.items = new_items;
1431            state.measuring_behavior.reset();
1432        }
1433
1434        let padding = style
1435            .padding
1436            .to_pixels(bounds.size.into(), window.rem_size());
1437        let layout =
1438            match state.prepaint_items(bounds, padding, true, &mut self.render_item, window, cx) {
1439                Ok(layout) => layout,
1440                Err(autoscroll_request) => {
1441                    state.logical_scroll_top = Some(autoscroll_request);
1442                    state
1443                        .prepaint_items(bounds, padding, false, &mut self.render_item, window, cx)
1444                        .unwrap()
1445                }
1446            };
1447
1448        state.last_layout_bounds = Some(bounds);
1449        state.last_padding = Some(padding);
1450        ListPrepaintState { hitbox, layout }
1451    }
1452
1453    fn paint(
1454        &mut self,
1455        _id: Option<&GlobalElementId>,
1456        _inspector_id: Option<&InspectorElementId>,
1457        bounds: Bounds<crate::Pixels>,
1458        _: &mut Self::RequestLayoutState,
1459        prepaint: &mut Self::PrepaintState,
1460        window: &mut Window,
1461        cx: &mut App,
1462    ) {
1463        let current_view = window.current_view();
1464        window.with_content_mask(Some(ContentMask { bounds }), |window| {
1465            for item in &mut prepaint.layout.item_layouts {
1466                item.element.paint(window, cx);
1467            }
1468        });
1469
1470        let list_state = self.state.clone();
1471        let height = bounds.size.height;
1472        let scroll_top = prepaint.layout.scroll_top;
1473        let hitbox_id = prepaint.hitbox.id;
1474        let mut accumulated_scroll_delta = ScrollDelta::default();
1475        window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
1476            if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) {
1477                accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta);
1478                let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.));
1479                list_state.0.borrow_mut().scroll(
1480                    &scroll_top,
1481                    height,
1482                    pixel_delta,
1483                    current_view,
1484                    window,
1485                    cx,
1486                )
1487            }
1488        });
1489    }
1490}
1491
1492impl IntoElement for List {
1493    type Element = Self;
1494
1495    fn into_element(self) -> Self::Element {
1496        self
1497    }
1498}
1499
1500impl Styled for List {
1501    fn style(&mut self) -> &mut StyleRefinement {
1502        &mut self.style
1503    }
1504}
1505
1506impl open_gpui_sum_tree::Item for ListItem {
1507    type Summary = ListItemSummary;
1508
1509    fn summary(&self, _: ()) -> Self::Summary {
1510        match self {
1511            ListItem::Unmeasured {
1512                size_hint,
1513                focus_handle,
1514            } => ListItemSummary {
1515                count: 1,
1516                rendered_count: 0,
1517                unrendered_count: 1,
1518                height: if let Some(size) = size_hint {
1519                    size.height
1520                } else {
1521                    px(0.)
1522                },
1523                has_focus_handles: focus_handle.is_some(),
1524                has_unknown_height: size_hint.is_none(),
1525            },
1526            ListItem::Measured {
1527                size, focus_handle, ..
1528            } => ListItemSummary {
1529                count: 1,
1530                rendered_count: 1,
1531                unrendered_count: 0,
1532                height: size.height,
1533                has_focus_handles: focus_handle.is_some(),
1534                has_unknown_height: false,
1535            },
1536        }
1537    }
1538}
1539
1540impl open_gpui_sum_tree::ContextLessSummary for ListItemSummary {
1541    fn zero() -> Self {
1542        Default::default()
1543    }
1544
1545    fn add_summary(&mut self, summary: &Self) {
1546        self.count += summary.count;
1547        self.rendered_count += summary.rendered_count;
1548        self.unrendered_count += summary.unrendered_count;
1549        self.height += summary.height;
1550        self.has_focus_handles |= summary.has_focus_handles;
1551        self.has_unknown_height |= summary.has_unknown_height;
1552    }
1553}
1554
1555impl<'a> open_gpui_sum_tree::Dimension<'a, ListItemSummary> for Count {
1556    fn zero(_cx: ()) -> Self {
1557        Default::default()
1558    }
1559
1560    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1561        self.0 += summary.count;
1562    }
1563}
1564
1565impl<'a> open_gpui_sum_tree::Dimension<'a, ListItemSummary> for Height {
1566    fn zero(_cx: ()) -> Self {
1567        Default::default()
1568    }
1569
1570    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1571        self.0 += summary.height;
1572    }
1573}
1574
1575impl open_gpui_sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Count {
1576    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1577        self.0.partial_cmp(&other.count).unwrap()
1578    }
1579}
1580
1581impl open_gpui_sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Height {
1582    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1583        self.0.partial_cmp(&other.height).unwrap()
1584    }
1585}
1586
1587#[cfg(test)]
1588mod test {
1589
1590    use open_gpui::{ScrollDelta, ScrollWheelEvent};
1591    use std::cell::Cell;
1592    use std::rc::Rc;
1593
1594    use crate::{
1595        self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render,
1596        Styled, TestAppContext, Window, div, list, point, px, size,
1597    };
1598
1599    #[open_gpui::test]
1600    fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) {
1601        let cx = cx.add_empty_window();
1602
1603        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1604
1605        // Ensure that the list is scrolled to the top
1606        state.scroll_to(open_gpui::ListOffset {
1607            item_ix: 0,
1608            offset_in_item: px(0.0),
1609        });
1610
1611        struct TestView(ListState);
1612        impl Render for TestView {
1613            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1614                list(self.0.clone(), |_, _, _| {
1615                    div().h(px(10.)).w_full().into_any()
1616                })
1617                .w_full()
1618                .h_full()
1619            }
1620        }
1621
1622        // Paint
1623        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1624            cx.new(|_| TestView(state.clone())).into_any_element()
1625        });
1626
1627        // Reset
1628        state.reset(5);
1629
1630        // And then receive a scroll event _before_ the next paint
1631        cx.simulate_event(ScrollWheelEvent {
1632            position: point(px(1.), px(1.)),
1633            delta: ScrollDelta::Pixels(point(px(0.), px(-500.))),
1634            ..Default::default()
1635        });
1636
1637        // Scroll position should stay at the top of the list
1638        assert_eq!(state.logical_scroll_top().item_ix, 0);
1639        assert_eq!(state.logical_scroll_top().offset_in_item, px(0.));
1640    }
1641
1642    #[open_gpui::test]
1643    fn test_scroll_by_positive_and_negative_distance(cx: &mut TestAppContext) {
1644        let cx = cx.add_empty_window();
1645
1646        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1647
1648        struct TestView(ListState);
1649        impl Render for TestView {
1650            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1651                list(self.0.clone(), |_, _, _| {
1652                    div().h(px(20.)).w_full().into_any()
1653                })
1654                .w_full()
1655                .h_full()
1656            }
1657        }
1658
1659        // Paint
1660        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
1661            cx.new(|_| TestView(state.clone())).into_any_element()
1662        });
1663
1664        // Test positive distance: start at item 1, move down 30px
1665        state.scroll_by(px(30.));
1666
1667        // Should move to item 2
1668        let offset = state.logical_scroll_top();
1669        assert_eq!(offset.item_ix, 1);
1670        assert_eq!(offset.offset_in_item, px(10.));
1671
1672        // Test negative distance: start at item 2, move up 30px
1673        state.scroll_by(px(-30.));
1674
1675        // Should move back to item 1
1676        let offset = state.logical_scroll_top();
1677        assert_eq!(offset.item_ix, 0);
1678        assert_eq!(offset.offset_in_item, px(0.));
1679
1680        // Test zero distance
1681        state.scroll_by(px(0.));
1682        let offset = state.logical_scroll_top();
1683        assert_eq!(offset.item_ix, 0);
1684        assert_eq!(offset.offset_in_item, px(0.));
1685    }
1686
1687    struct TestListView(ListState);
1688    impl Render for TestListView {
1689        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1690            list(self.0.clone(), |_, _, _| {
1691                div().h(px(20.)).w_full().into_any()
1692            })
1693            .w_full()
1694            .h_full()
1695        }
1696    }
1697
1698    #[open_gpui::test]
1699    fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) {
1700        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1701
1702        assert_eq!(state.item_is_above_viewport(0), None);
1703        assert_eq!(state.item_is_below_viewport(0), None);
1704    }
1705
1706    #[open_gpui::test]
1707    fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) {
1708        let cx = cx.add_empty_window();
1709
1710        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1711
1712        state.scroll_to(open_gpui::ListOffset {
1713            item_ix: 2,
1714            offset_in_item: px(0.),
1715        });
1716        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1717            cx.new(|_| TestListView(state.clone())).into_any_element()
1718        });
1719
1720        assert_eq!(state.item_is_above_viewport(1), Some(true));
1721        assert_eq!(state.item_is_below_viewport(1), Some(false));
1722    }
1723
1724    #[open_gpui::test]
1725    fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) {
1726        let cx = cx.add_empty_window();
1727
1728        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1729
1730        state.scroll_to(open_gpui::ListOffset {
1731            item_ix: 2,
1732            offset_in_item: px(0.),
1733        });
1734        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1735            cx.new(|_| TestListView(state.clone())).into_any_element()
1736        });
1737
1738        assert_eq!(state.item_is_above_viewport(2), Some(false));
1739        assert_eq!(state.item_is_below_viewport(2), Some(false));
1740    }
1741
1742    #[open_gpui::test]
1743    fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) {
1744        let cx = cx.add_empty_window();
1745
1746        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1747
1748        state.scroll_to(open_gpui::ListOffset {
1749            item_ix: 2,
1750            offset_in_item: px(20.),
1751        });
1752        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1753            cx.new(|_| TestListView(state.clone())).into_any_element()
1754        });
1755
1756        assert_eq!(state.item_is_above_viewport(2), Some(true));
1757        assert_eq!(state.item_is_below_viewport(2), Some(false));
1758    }
1759
1760    #[open_gpui::test]
1761    fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) {
1762        let cx = cx.add_empty_window();
1763
1764        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1765
1766        state.scroll_to(open_gpui::ListOffset {
1767            item_ix: 2,
1768            offset_in_item: px(0.),
1769        });
1770        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1771            cx.new(|_| TestListView(state.clone())).into_any_element()
1772        });
1773
1774        assert_eq!(state.item_is_above_viewport(3), Some(false));
1775        assert_eq!(state.item_is_below_viewport(3), Some(true));
1776    }
1777
1778    #[open_gpui::test]
1779    fn test_item_viewport_queries_remain_stable_with_zero_height_viewport(cx: &mut TestAppContext) {
1780        let cx = cx.add_empty_window();
1781
1782        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1783
1784        state.scroll_to(open_gpui::ListOffset {
1785            item_ix: 2,
1786            offset_in_item: px(0.),
1787        });
1788        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1789            cx.new(|_| TestListView(state.clone())).into_any_element()
1790        });
1791
1792        assert_eq!(state.item_is_above_viewport(3), Some(false));
1793        assert_eq!(state.item_is_below_viewport(3), Some(true));
1794
1795        // Squeeze the list to zero height, e.g. because a sibling element
1796        // (sized based on the queries above) consumed all the space. The
1797        // answers must remain definitive rather than becoming `None`,
1798        // otherwise the sibling's size can oscillate between frames.
1799        cx.draw(point(px(0.), px(0.)), size(px(100.), px(0.)), |_, cx| {
1800            cx.new(|_| TestListView(state.clone())).into_any_element()
1801        });
1802
1803        assert_eq!(state.item_is_above_viewport(1), Some(true));
1804        assert_eq!(state.item_is_below_viewport(1), Some(false));
1805        assert_eq!(state.item_is_above_viewport(3), Some(false));
1806        assert_eq!(state.item_is_below_viewport(3), Some(true));
1807    }
1808
1809    #[open_gpui::test]
1810    fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) {
1811        let cx = cx.add_empty_window();
1812
1813        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1814
1815        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1816            cx.new(|_| TestListView(state.clone())).into_any_element()
1817        });
1818
1819        state.scroll_to_end();
1820
1821        assert_eq!(state.logical_scroll_top().item_ix, state.item_count());
1822        assert_eq!(state.item_is_above_viewport(0), Some(true));
1823        assert_eq!(state.item_is_below_viewport(0), Some(false));
1824    }
1825
1826    #[open_gpui::test]
1827    fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
1828        let cx = cx.add_empty_window();
1829
1830        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1831
1832        struct TestView(ListState);
1833        impl Render for TestView {
1834            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1835                list(self.0.clone(), |_, _, _| {
1836                    div().h(px(50.)).w_full().into_any()
1837                })
1838                .w_full()
1839                .h_full()
1840            }
1841        }
1842
1843        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1844
1845        // First draw at width 100: all 10 items measured (total 500px).
1846        // Viewport is 200px, so max scroll offset should be 300px.
1847        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1848            view.clone().into_any_element()
1849        });
1850        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
1851
1852        // Second draw at a different width: items get invalidated.
1853        // Without the fix, max_offset would drop because unmeasured items
1854        // contribute 0 height.
1855        cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| {
1856            view.into_any_element()
1857        });
1858        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
1859    }
1860
1861    #[open_gpui::test]
1862    fn test_remeasure(cx: &mut TestAppContext) {
1863        let cx = cx.add_empty_window();
1864
1865        // Create a list with 10 items, each 100px tall. We'll keep a reference
1866        // to the item height so we can later change the height and assert how
1867        // `ListState` handles it.
1868        let item_height = Rc::new(Cell::new(100usize));
1869        let state = ListState::new(10, crate::ListAlignment::Top, px(10.));
1870
1871        struct TestView {
1872            state: ListState,
1873            item_height: Rc<Cell<usize>>,
1874        }
1875
1876        impl Render for TestView {
1877            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1878                let height = self.item_height.get();
1879                list(self.state.clone(), move |_, _, _| {
1880                    div().h(px(height as f32)).w_full().into_any()
1881                })
1882                .w_full()
1883                .h_full()
1884            }
1885        }
1886
1887        let state_clone = state.clone();
1888        let item_height_clone = item_height.clone();
1889        let view = cx.update(|_, cx| {
1890            cx.new(|_| TestView {
1891                state: state_clone,
1892                item_height: item_height_clone,
1893            })
1894        });
1895
1896        // Simulate scrolling 40px inside the element with index 2. Since the
1897        // original item height is 100px, this equates to 40% inside the item.
1898        state.scroll_to(open_gpui::ListOffset {
1899            item_ix: 2,
1900            offset_in_item: px(40.),
1901        });
1902
1903        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1904            view.clone().into_any_element()
1905        });
1906
1907        let offset = state.logical_scroll_top();
1908        assert_eq!(offset.item_ix, 2);
1909        assert_eq!(offset.offset_in_item, px(40.));
1910
1911        // Update the `item_height` to be 50px instead of 100px so we can assert
1912        // that the scroll position is proportionally preserved, that is,
1913        // instead of 40px from the top of item 2, it should be 20px, since the
1914        // item's height has been halved.
1915        item_height.set(50);
1916        state.remeasure();
1917
1918        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1919            view.into_any_element()
1920        });
1921
1922        let offset = state.logical_scroll_top();
1923        assert_eq!(offset.item_ix, 2);
1924        assert_eq!(offset.offset_in_item, px(20.));
1925    }
1926
1927    #[open_gpui::test]
1928    fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) {
1929        let cx = cx.add_empty_window();
1930
1931        let item_height = Rc::new(Cell::new(100usize));
1932        let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
1933
1934        struct TestView {
1935            state: ListState,
1936            item_height: Rc<Cell<usize>>,
1937        }
1938
1939        impl Render for TestView {
1940            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1941                let height = self.item_height.get();
1942                list(self.state.clone(), move |index, _, _| {
1943                    let height = if index == 5 { height } else { 100 };
1944                    div().h(px(height as f32)).w_full().into_any()
1945                })
1946                .w_full()
1947                .h_full()
1948            }
1949        }
1950
1951        let state_clone = state.clone();
1952        let item_height_clone = item_height.clone();
1953        let view = cx.update(|_, cx| {
1954            cx.new(|_| TestView {
1955                state: state_clone,
1956                item_height: item_height_clone,
1957            })
1958        });
1959
1960        state.scroll_to(open_gpui::ListOffset {
1961            item_ix: 5,
1962            offset_in_item: px(40.),
1963        });
1964
1965        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1966            view.clone().into_any_element()
1967        });
1968
1969        item_height.set(200);
1970        state.remeasure_items(5..6);
1971
1972        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1973            view.into_any_element()
1974        });
1975
1976        let offset = state.logical_scroll_top();
1977        assert_eq!(offset.item_ix, 5);
1978        assert_eq!(offset.offset_in_item, px(40.));
1979    }
1980
1981    #[open_gpui::test]
1982    fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
1983        let cx = cx.add_empty_window();
1984
1985        // 10 items, each 50px tall → 500px total content, 200px viewport.
1986        // With follow-tail on, the list should always show the bottom.
1987        let item_height = Rc::new(Cell::new(50usize));
1988        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
1989
1990        struct TestView {
1991            state: ListState,
1992            item_height: Rc<Cell<usize>>,
1993        }
1994        impl Render for TestView {
1995            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1996                let height = self.item_height.get();
1997                list(self.state.clone(), move |_, _, _| {
1998                    div().h(px(height as f32)).w_full().into_any()
1999                })
2000                .w_full()
2001                .h_full()
2002            }
2003        }
2004
2005        let state_clone = state.clone();
2006        let item_height_clone = item_height.clone();
2007        let view = cx.update(|_, cx| {
2008            cx.new(|_| TestView {
2009                state: state_clone,
2010                item_height: item_height_clone,
2011            })
2012        });
2013
2014        state.set_follow_mode(FollowMode::Tail);
2015
2016        // First paint — items are 50px, total 500px, viewport 200px.
2017        // Follow-tail should anchor to the end.
2018        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2019            view.clone().into_any_element()
2020        });
2021
2022        // The scroll should be at the bottom: the last visible items fill the
2023        // 200px viewport from the end of 500px of content (offset 300px).
2024        let offset = state.logical_scroll_top();
2025        assert_eq!(offset.item_ix, 6);
2026        assert_eq!(offset.offset_in_item, px(0.));
2027        assert!(state.is_following_tail());
2028
2029        // Simulate items growing (e.g. streaming content makes each item taller).
2030        // 10 items × 80px = 800px total.
2031        item_height.set(80);
2032        state.remeasure();
2033
2034        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2035            view.into_any_element()
2036        });
2037
2038        // After growth, follow-tail should have re-anchored to the new end.
2039        // 800px total − 200px viewport = 600px offset → item 7 at offset 40px,
2040        // but follow-tail anchors to item_count (10), and layout walks back to
2041        // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200,
2042        // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking →
2043        // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40).
2044        let offset = state.logical_scroll_top();
2045        assert_eq!(offset.item_ix, 7);
2046        assert_eq!(offset.offset_in_item, px(40.));
2047        assert!(state.is_following_tail());
2048    }
2049
2050    #[open_gpui::test]
2051    fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) {
2052        let cx = cx.add_empty_window();
2053
2054        // 10 items × 50px = 500px total, 200px viewport.
2055        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2056
2057        struct TestView(ListState);
2058        impl Render for TestView {
2059            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2060                list(self.0.clone(), |_, _, _| {
2061                    div().h(px(50.)).w_full().into_any()
2062                })
2063                .w_full()
2064                .h_full()
2065            }
2066        }
2067
2068        state.set_follow_mode(FollowMode::Tail);
2069
2070        // Paint with follow-tail — scroll anchored to the bottom.
2071        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
2072            cx.new(|_| TestView(state.clone())).into_any_element()
2073        });
2074        assert!(state.is_following_tail());
2075
2076        // Simulate the user scrolling up.
2077        // This should disengage follow-tail.
2078        cx.simulate_event(ScrollWheelEvent {
2079            position: point(px(50.), px(100.)),
2080            delta: ScrollDelta::Pixels(point(px(0.), px(100.))),
2081            ..Default::default()
2082        });
2083
2084        assert!(
2085            !state.is_following_tail(),
2086            "follow-tail should disengage when the user scrolls toward the start"
2087        );
2088    }
2089
2090    #[open_gpui::test]
2091    fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) {
2092        let cx = cx.add_empty_window();
2093
2094        // 10 items × 50px = 500px total, 200px viewport.
2095        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2096
2097        struct TestView(ListState);
2098        impl Render for TestView {
2099            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2100                list(self.0.clone(), |_, _, _| {
2101                    div().h(px(50.)).w_full().into_any()
2102                })
2103                .w_full()
2104                .h_full()
2105            }
2106        }
2107
2108        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2109
2110        state.set_follow_mode(FollowMode::Tail);
2111
2112        // Paint with follow-tail — scroll anchored to the bottom.
2113        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2114            view.clone().into_any_element()
2115        });
2116        assert!(state.is_following_tail());
2117
2118        // Simulate the scrollbar moving the viewport to the middle.
2119        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2120
2121        let offset = state.logical_scroll_top();
2122        assert_eq!(offset.item_ix, 3);
2123        assert_eq!(offset.offset_in_item, px(0.));
2124        assert!(
2125            !state.is_following_tail(),
2126            "follow-tail should disengage when the scrollbar manually repositions the list"
2127        );
2128
2129        // A subsequent draw should preserve the user's manual position instead
2130        // of snapping back to the end.
2131        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2132            view.into_any_element()
2133        });
2134
2135        let offset = state.logical_scroll_top();
2136        assert_eq!(offset.item_ix, 3);
2137        assert_eq!(offset.offset_in_item, px(0.));
2138    }
2139
2140    #[open_gpui::test]
2141    fn test_scrollbar_drag_with_growing_content(cx: &mut TestAppContext) {
2142        let cx = cx.add_empty_window();
2143
2144        let last_item_height = Rc::new(Cell::new(50usize));
2145        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2146
2147        struct TestView {
2148            state: ListState,
2149            last_item_height: Rc<Cell<usize>>,
2150        }
2151        impl Render for TestView {
2152            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2153                let last_item_height = self.last_item_height.clone();
2154                list(self.state.clone(), move |index, _, _| {
2155                    let height = if index == 9 {
2156                        last_item_height.get()
2157                    } else {
2158                        50
2159                    };
2160                    div().h(px(height as f32)).w_full().into_any()
2161                })
2162                .w_full()
2163                .h_full()
2164            }
2165        }
2166
2167        let view = cx.update(|_, cx| {
2168            cx.new(|_| TestView {
2169                state: state.clone(),
2170                last_item_height: last_item_height.clone(),
2171            })
2172        });
2173
2174        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2175            view.clone().into_any_element()
2176        });
2177
2178        state.scrollbar_drag_started();
2179
2180        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2181        let scrollbar_offset_before_growth = state.scroll_px_offset_for_scrollbar();
2182
2183        let offset = state.logical_scroll_top();
2184        assert_eq!(offset.item_ix, 3);
2185        assert_eq!(offset.offset_in_item, px(0.));
2186
2187        last_item_height.set(550);
2188        state.remeasure_items(9..10);
2189        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2190            view.clone().into_any_element()
2191        });
2192
2193        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2194        assert_eq!(
2195            state.scroll_px_offset_for_scrollbar(),
2196            scrollbar_offset_before_growth
2197        );
2198
2199        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2200        let offset = state.logical_scroll_top();
2201        assert_eq!(offset.item_ix, 3);
2202        assert_eq!(offset.offset_in_item, px(0.));
2203    }
2204
2205    #[open_gpui::test]
2206    fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) {
2207        let cx = cx.add_empty_window();
2208
2209        // 10 items × 50px = 500px total, 200px viewport.
2210        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2211
2212        struct TestView(ListState);
2213        impl Render for TestView {
2214            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2215                list(self.0.clone(), |_, _, _| {
2216                    div().h(px(50.)).w_full().into_any()
2217                })
2218                .w_full()
2219                .h_full()
2220            }
2221        }
2222
2223        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2224
2225        // Scroll to the middle of the list (item 3).
2226        state.scroll_to(open_gpui::ListOffset {
2227            item_ix: 3,
2228            offset_in_item: px(0.),
2229        });
2230
2231        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2232            view.clone().into_any_element()
2233        });
2234
2235        let offset = state.logical_scroll_top();
2236        assert_eq!(offset.item_ix, 3);
2237        assert_eq!(offset.offset_in_item, px(0.));
2238        assert!(!state.is_following_tail());
2239
2240        // Enable follow-tail — this should immediately snap the scroll anchor
2241        // to the end, like the user just sent a prompt.
2242        state.set_follow_mode(FollowMode::Tail);
2243
2244        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2245            view.into_any_element()
2246        });
2247
2248        // After paint, scroll should be at the bottom.
2249        // 500px total − 200px viewport = 300px offset → item 6, offset 0.
2250        let offset = state.logical_scroll_top();
2251        assert_eq!(offset.item_ix, 6);
2252        assert_eq!(offset.offset_in_item, px(0.));
2253        assert!(state.is_following_tail());
2254    }
2255
2256    #[open_gpui::test]
2257    fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) {
2258        let cx = cx.add_empty_window();
2259
2260        const ITEMS: usize = 10;
2261        const ITEM_SIZE: f32 = 50.0;
2262
2263        let state = ListState::new(
2264            ITEMS,
2265            crate::ListAlignment::Bottom,
2266            px(ITEMS as f32 * ITEM_SIZE),
2267        );
2268
2269        struct TestView(ListState);
2270        impl Render for TestView {
2271            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2272                list(self.0.clone(), |_, _, _| {
2273                    div().h(px(ITEM_SIZE)).w_full().into_any()
2274                })
2275                .w_full()
2276                .h_full()
2277            }
2278        }
2279
2280        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
2281            cx.new(|_| TestView(state.clone())).into_any_element()
2282        });
2283
2284        // Bottom-aligned lists start pinned to the end: logical_scroll_top returns
2285        // item_ix == item_count, meaning no explicit scroll position has been set.
2286        assert_eq!(state.logical_scroll_top().item_ix, ITEMS);
2287
2288        let max_offset = state.max_offset_for_scrollbar();
2289        let scroll_offset = state.scroll_px_offset_for_scrollbar();
2290
2291        assert_eq!(
2292            -scroll_offset.y, max_offset.y,
2293            "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom",
2294            -scroll_offset.y, max_offset.y,
2295        );
2296    }
2297
2298    /// When the user scrolls away from the bottom during follow_tail,
2299    /// follow_tail suspends. If they scroll back to the bottom, the
2300    /// next paint should re-engage follow_tail using fresh measurements.
2301    #[open_gpui::test]
2302    fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) {
2303        let cx = cx.add_empty_window();
2304
2305        // 10 items × 50px = 500px total, 200px viewport.
2306        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2307
2308        struct TestView(ListState);
2309        impl Render for TestView {
2310            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2311                list(self.0.clone(), |_, _, _| {
2312                    div().h(px(50.)).w_full().into_any()
2313                })
2314                .w_full()
2315                .h_full()
2316            }
2317        }
2318
2319        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2320
2321        state.set_follow_mode(FollowMode::Tail);
2322
2323        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2324            view.clone().into_any_element()
2325        });
2326        assert!(state.is_following_tail());
2327
2328        // Scroll up — follow_tail should suspend (not fully disengage).
2329        cx.simulate_event(ScrollWheelEvent {
2330            position: point(px(50.), px(100.)),
2331            delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
2332            ..Default::default()
2333        });
2334        assert!(!state.is_following_tail());
2335
2336        // Scroll back down to the bottom.
2337        cx.simulate_event(ScrollWheelEvent {
2338            position: point(px(50.), px(100.)),
2339            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
2340            ..Default::default()
2341        });
2342
2343        // After a paint, follow_tail should re-engage because the
2344        // layout confirmed we're at the true bottom.
2345        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2346            view.clone().into_any_element()
2347        });
2348        assert!(
2349            state.is_following_tail(),
2350            "follow_tail should re-engage after scrolling back to the bottom"
2351        );
2352    }
2353
2354    /// When an item is spliced to unmeasured (0px) while follow_tail
2355    /// is suspended, the re-engagement check should still work correctly
2356    #[open_gpui::test]
2357    fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) {
2358        let cx = cx.add_empty_window();
2359
2360        // 20 items × 50px = 1000px total, 200px viewport, 1000px
2361        // overdraw so all items get measured during the follow_tail
2362        // paint (matching realistic production settings).
2363        let state = ListState::new(20, crate::ListAlignment::Top, px(1000.));
2364
2365        struct TestView(ListState);
2366        impl Render for TestView {
2367            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2368                list(self.0.clone(), |_, _, _| {
2369                    div().h(px(50.)).w_full().into_any()
2370                })
2371                .w_full()
2372                .h_full()
2373            }
2374        }
2375
2376        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2377
2378        state.set_follow_mode(FollowMode::Tail);
2379
2380        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2381            view.clone().into_any_element()
2382        });
2383        assert!(state.is_following_tail());
2384
2385        // Scroll up a meaningful amount — suspends follow_tail.
2386        // 20 items × 50px = 1000px. viewport 200px. scroll_max = 800px.
2387        // Scrolling up 200px puts us at 600px, clearly not at bottom.
2388        cx.simulate_event(ScrollWheelEvent {
2389            position: point(px(50.), px(100.)),
2390            delta: ScrollDelta::Pixels(point(px(0.), px(200.))),
2391            ..Default::default()
2392        });
2393        assert!(!state.is_following_tail());
2394
2395        // Invalidate the last item (simulates EntryUpdated calling
2396        // remeasure_items). This makes items.summary().height
2397        // temporarily wrong (0px for the invalidated item).
2398        state.remeasure_items(19..20);
2399
2400        // Paint — layout re-measures the invalidated item with its true
2401        // height. The re-engagement check uses these fresh measurements.
2402        // Since we scrolled 200px up from the 800px max, we're at
2403        // ~600px — NOT at the bottom, so follow_tail should NOT
2404        // re-engage.
2405        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2406            view.clone().into_any_element()
2407        });
2408        assert!(
2409            !state.is_following_tail(),
2410            "follow_tail should not falsely re-engage due to an unmeasured item \
2411             reducing items.summary().height"
2412        );
2413    }
2414
2415    #[open_gpui::test]
2416    fn test_follow_tail_reengages_after_scrollbar_disengagement(cx: &mut TestAppContext) {
2417        let cx = cx.add_empty_window();
2418
2419        // 10 items × 50px = 500px total, 200px viewport, scroll_max = 300px.
2420        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2421
2422        struct TestView(ListState);
2423        impl Render for TestView {
2424            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2425                list(self.0.clone(), |_, _, _| {
2426                    div().h(px(50.)).w_full().into_any()
2427                })
2428                .w_full()
2429                .h_full()
2430            }
2431        }
2432
2433        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2434
2435        state.set_follow_mode(FollowMode::Tail);
2436        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2437            view.clone().into_any_element()
2438        });
2439        assert!(state.is_following_tail());
2440
2441        // Drag the scrollbar up to the middle — follow_tail should suspend.
2442        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2443        assert!(!state.is_following_tail());
2444
2445        // Drag the scrollbar back to the bottom — follow_tail should re-engage
2446        // on the next paint.
2447        state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
2448        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2449            view.into_any_element()
2450        });
2451        assert!(
2452            state.is_following_tail(),
2453            "follow_tail should re-engage after scrolling back to the bottom via the scrollbar"
2454        );
2455    }
2456
2457    #[open_gpui::test]
2458    fn test_follow_tail_reengages_after_scrollbar_drag_to_bottom_while_growing(
2459        cx: &mut TestAppContext,
2460    ) {
2461        let cx = cx.add_empty_window();
2462
2463        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2464
2465        struct TestView(ListState);
2466        impl Render for TestView {
2467            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2468                list(self.0.clone(), |_, _, _| {
2469                    div().h(px(50.)).w_full().into_any()
2470                })
2471                .w_full()
2472                .h_full()
2473            }
2474        }
2475
2476        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2477
2478        state.set_follow_mode(FollowMode::Tail);
2479        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2480            view.clone().into_any_element()
2481        });
2482        assert!(state.is_following_tail());
2483
2484        state.scrollbar_drag_started();
2485
2486        state.splice(10..10, 10);
2487        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2488            view.clone().into_any_element()
2489        });
2490
2491        state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
2492        state.scrollbar_drag_ended();
2493
2494        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2495            view.into_any_element()
2496        });
2497
2498        assert!(
2499            state.is_following_tail(),
2500            "follow_tail should re-engage when the user drags the scrollbar to \
2501             the bottom of its track, even when content has grown during the drag \
2502             (so frozen_bottom < live_bottom)"
2503        );
2504    }
2505}