Skip to main content

gpui/elements/
list.rs

1//! Lists of rows with any height, rendered lazily.
2//!
3//! [`list`] lays out and paints only rows in the viewport, plus the overdraw set with
4//! [`ListState::new`] (unless you call [`ListState::measure_all`]). It measures rows as it renders
5//! them, so rows can differ in height. If every row is the same height, use
6//! [`crate::uniform_list`], which skips per-row measurement.
7//!
8//! The list's state lives on your view, in a [`ListState`]. Call [`ListState::splice`] or
9//! [`ListState::reset`] when rows are added, removed or moved, and [`ListState::remeasure_items`]
10//! when a row changes height. A row outside the scrolled area must not change height without that
11//! notice.
12//!
13//! For scrolling behavior and update patterns, see the
14//! [lists and scrolling guide](crate::_documentation::guides::lists_and_scrolling).
15
16use crate::{
17    AnyElement, App, AppContext, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges,
18    Element, EntityId, FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
19    IntoElement, Overflow, Pixels, Point, ScrollDelta, ScrollWheelEvent, Size, Style,
20    StyleRefinement, Styled, Window, point, px, size,
21};
22use collections::VecDeque;
23use refineable::Refineable as _;
24use std::{cell::RefCell, ops::Range, rc::Rc};
25use sum_tree::{Bias, Dimensions, SumTree};
26
27type RenderItemFn = dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static;
28
29/// Creates a list element that renders rows of any height, using the state in `state`.
30///
31/// `render_item` receives the index of a row and returns its element. It runs only for rows in the
32/// viewport or the overdraw, unless the state uses [`ListState::measure_all`], which renders every
33/// row. Keep your data on your view and index into it from `render_item`, because row elements are
34/// dropped as they scroll away.
35///
36/// The element sizes itself from its style by default. Use [`List::with_sizing_behavior`] to size it
37/// from its rows instead. For rows that are all the same height, use [`crate::uniform_list`].
38///
39/// # Examples
40///
41/// ```
42/// # use gpui::*;
43/// # use std::{cell::Cell, rc::Rc};
44/// struct Feed {
45///     state: ListState,
46///     rendered: Rc<Cell<usize>>,
47/// }
48///
49/// impl Render for Feed {
50///     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
51///         let rendered = self.rendered.clone();
52///         div().h(px(200.)).child(
53///             list(self.state.clone(), move |ix, _window, _cx| {
54///                 rendered.set(rendered.get() + 1);
55///                 div().h(px(50.)).child(format!("Row {ix}")).into_any_element()
56///             })
57///             .size_full(),
58///         )
59///     }
60/// }
61/// # let mut app = TestApp::new();
62/// # let rendered = Rc::new(Cell::new(0));
63/// # let mut window = app.open_window(|_, _| Feed {
64/// #     state: ListState::new(1_000, ListAlignment::Top, px(0.)),
65/// #     rendered: rendered.clone(),
66/// # });
67/// window.draw();
68/// // A 200px viewport holds four 50px rows, so only about four rows were rendered.
69/// assert!(rendered.get() > 0 && rendered.get() < 20);
70///
71/// window.update(|feed, _, _| feed.state.splice(1_000..1_000, 1));
72/// window.draw();
73/// window.read(|feed, _| assert_eq!(feed.state.item_count(), 1_001));
74/// ```
75pub fn list(
76    state: ListState,
77    render_item: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
78) -> List {
79    List {
80        state,
81        render_item: Box::new(render_item),
82        style: StyleRefinement::default(),
83        sizing_behavior: ListSizingBehavior::default(),
84    }
85}
86
87/// The element returned by [`list`]. Style it, then return it from `render`.
88pub struct List {
89    state: ListState,
90    render_item: Box<RenderItemFn>,
91    style: StyleRefinement,
92    sizing_behavior: ListSizingBehavior,
93}
94
95impl List {
96    /// Sets whether the list sizes itself from its style or from its rows.
97    ///
98    /// [`ListSizingBehavior::Auto`], the default, takes the size from the style. With
99    /// [`ListSizingBehavior::Infer`], the list is as tall as its rows, up to the space its parent gives it.
100    pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
101        self.sizing_behavior = behavior;
102        self
103    }
104}
105
106/// The state of a [`list`]. Store it on your view and pass a clone to [`list`] each frame.
107///
108/// The state records each row's height, the scroll position and the follow mode. Call its
109/// methods when the rows change, such as [`Self::splice`] or [`Self::remeasure_items`].
110/// Clones share the same state.
111#[derive(Clone)]
112pub struct ListState(Rc<RefCell<StateInner>>);
113
114impl ListState {
115    fn state_mut(&self) -> std::cell::RefMut<'_, StateInner> {
116        let state = self.0.borrow_mut();
117        state.revision.bump();
118        state
119    }
120}
121
122impl std::fmt::Debug for ListState {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.write_str("ListState")
125    }
126}
127
128struct StateInner {
129    /// Bumped on every mutation, so views that drew the list are drawn again.
130    revision: crate::Revision,
131    last_layout_bounds: Option<Bounds<Pixels>>,
132    last_padding: Option<Edges<Pixels>>,
133    items: SumTree<ListItem>,
134    logical_scroll_top: Option<ListOffset>,
135    alignment: ListAlignment,
136    overdraw: Pixels,
137    reset: bool,
138    #[allow(clippy::type_complexity)]
139    scroll_handler: Option<Box<dyn FnMut(&ListScrollEvent, &mut Window, &mut App)>>,
140    scrollbar_drag_start_height: Option<Pixels>,
141    measuring_behavior: ListMeasuringBehavior,
142    pending_scroll: Option<PendingScroll>,
143    follow_state: FollowState,
144}
145
146/// Deferred scroll adjustment applied after the scroll-top item has been remeasured.
147///
148/// An absolute pending scroll preserves the same pixel offset into the item, which keeps
149/// visible text stable while content is appended to or removed from that item. A
150/// proportional pending scroll preserves the same fractional position within the item,
151/// which is useful when the whole list is being resized and each item scales similarly.
152#[derive(Clone)]
153enum PendingScroll {
154    /// Preserve the same pixel offset into the item after it is remeasured.
155    Absolute { item_ix: usize, offset: Pixels },
156    /// Preserve the same fractional offset into the item after it is remeasured.
157    Proportional(PendingScrollFraction),
158}
159
160/// Keeps track of a fractional scroll position within an item for restoration
161/// after remeasurement.
162#[derive(Clone)]
163struct PendingScrollFraction {
164    /// The index of the item to scroll within.
165    item_ix: usize,
166    /// Fractional offset (0.0 to 1.0) within the item's height.
167    fraction: f32,
168}
169
170/// Determines how remeasurement preserves the scroll position when the scroll-top item
171/// changes height.
172enum ScrollAnchor {
173    /// Preserve the same pixel offset into the scroll-top item.
174    Absolute,
175    /// Preserve the same fractional position within the scroll-top item.
176    Proportional,
177}
178
179/// Whether a [`ListState`] keeps the end of the list in view as rows are added.
180///
181/// Set it with [`ListState::set_follow_mode`].
182#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
183pub enum FollowMode {
184    /// Doesn't follow. The position stays where the user or your code leaves it.
185    #[default]
186    Normal,
187    /// Keeps the end in view, and resumes doing so when the user scrolls back to the end.
188    Tail,
189}
190
191#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
192enum FollowState {
193    #[default]
194    Normal,
195    Tail {
196        is_following: bool,
197    },
198}
199
200impl FollowState {
201    fn is_following(&self) -> bool {
202        matches!(self, FollowState::Tail { is_following: true })
203    }
204
205    fn has_stopped_following(&self) -> bool {
206        matches!(
207            self,
208            FollowState::Tail {
209                is_following: false
210            }
211        )
212    }
213
214    fn start_following(&mut self) {
215        if let FollowState::Tail {
216            is_following: false,
217        } = self
218        {
219            *self = FollowState::Tail { is_following: true };
220        }
221    }
222
223    fn stop_following(&mut self) {
224        if let FollowState::Tail { is_following: true } = self {
225            *self = FollowState::Tail {
226                is_following: false,
227            };
228        }
229    }
230}
231
232/// Where a [`ListState`] starts: at its first row, or at its last row.
233#[derive(Clone, Copy, Debug, Eq, PartialEq)]
234pub enum ListAlignment {
235    /// Starts at the first row, as most lists do.
236    Top,
237    /// Starts at the last row, as a chat log does. The list stays at the end until the user
238    /// scrolls up.
239    Bottom,
240}
241
242/// The state of a [`ListState`] after a wheel or trackpad scroll.
243///
244/// The callback set by [`ListState::set_scroll_handler`] receives this.
245pub struct ListScrollEvent {
246    /// The rows in view after this scroll, never past `count`.
247    pub visible_range: Range<usize>,
248
249    /// The total number of rows in the list.
250    pub count: usize,
251
252    /// Whether the list has an explicit scroll position. A bottom-aligned list that is pinned to
253    /// the end reports `false`.
254    pub is_scrolled: bool,
255
256    /// Whether the list is following the tail after the scroll.
257    pub is_following_tail: bool,
258}
259
260/// How a [`list`] or [`crate::UniformList`] sizes itself during layout.
261#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
262pub enum ListSizingBehavior {
263    /// Makes the list as tall as its rows, up to the space its parent offers.
264    Infer,
265    /// Takes the size from the style, like any other element.
266    #[default]
267    Auto,
268}
269
270/// Whether a [`ListState`] measures every row or only the rows it renders.
271///
272/// Set it with [`ListState::measure_all`]. The default is [`Self::Visible`].
273#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
274pub enum ListMeasuringBehavior {
275    /// Measures every row on the next layout. The `bool` records whether that pass has run for
276    /// the current width. Measuring every row is expensive for a large list.
277    Measure(bool),
278    /// Measures only the rows that are rendered.
279    #[default]
280    Visible,
281}
282
283impl ListMeasuringBehavior {
284    fn reset(&mut self) {
285        match self {
286            ListMeasuringBehavior::Measure(has_measured) => *has_measured = false,
287            ListMeasuringBehavior::Visible => {}
288        }
289    }
290}
291
292/// Whether the rows of a [`crate::UniformList`] can be wider than the list.
293///
294/// Set it with [`crate::UniformList::with_horizontal_sizing_behavior`].
295#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
296pub enum ListHorizontalSizingBehavior {
297    /// Rows are no wider than the list. This is the default.
298    #[default]
299    FitList,
300    /// The content is as wide as the widest row, and the list scrolls horizontally when that is
301    /// wider than the list.
302    Unconstrained,
303}
304
305struct LayoutItemsResponse {
306    max_item_width: Pixels,
307    scroll_top: ListOffset,
308    item_layouts: VecDeque<ItemLayout>,
309}
310
311struct ItemLayout {
312    index: usize,
313    element: AnyElement,
314    size: Size<Pixels>,
315}
316
317/// The hitbox and laid-out rows that a [`List`] carries from prepaint to paint. You don't construct it.
318pub struct ListPrepaintState {
319    hitbox: Hitbox,
320    layout: LayoutItemsResponse,
321}
322
323#[derive(Clone)]
324enum ListItem {
325    Unmeasured {
326        size_hint: Option<Size<Pixels>>,
327        focus_handle: Option<FocusHandle>,
328    },
329    Measured {
330        size: Size<Pixels>,
331        focus_handle: Option<FocusHandle>,
332    },
333}
334
335impl ListItem {
336    fn size(&self) -> Option<Size<Pixels>> {
337        if let ListItem::Measured { size, .. } = self {
338            Some(*size)
339        } else {
340            None
341        }
342    }
343
344    fn size_hint(&self) -> Option<Size<Pixels>> {
345        match self {
346            ListItem::Measured { size, .. } => Some(*size),
347            ListItem::Unmeasured { size_hint, .. } => *size_hint,
348        }
349    }
350
351    fn focus_handle(&self) -> Option<FocusHandle> {
352        match self {
353            ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
354                focus_handle.clone()
355            }
356        }
357    }
358
359    fn contains_focused(&self, window: &Window, cx: &App) -> bool {
360        match self {
361            ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
362                focus_handle
363                    .as_ref()
364                    .is_some_and(|handle| handle.contains_focused(window, cx))
365            }
366        }
367    }
368}
369
370#[derive(Clone, Debug, Default, PartialEq)]
371struct ListItemSummary {
372    count: usize,
373    rendered_count: usize,
374    unrendered_count: usize,
375    height: Pixels,
376    has_focus_handles: bool,
377    has_unknown_height: bool,
378}
379
380#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
381struct Count(usize);
382
383#[derive(Clone, Debug, Default)]
384struct Height(Pixels);
385
386impl ListState {
387    /// Creates the state for a list of `item_count` rows, to store on your view.
388    ///
389    /// `alignment` sets where the list starts: [`ListAlignment::Top`] at the first row, or
390    /// [`ListAlignment::Bottom`] at the last row, as a chat log does.
391    ///
392    /// `overdraw` is extra space above and below the viewport. Rows in that space are rendered and
393    /// measured, but not painted, so rows that scroll into view are already measured.
394    ///
395    /// Rows start unmeasured. An unmeasured row counts as zero pixels tall until it is rendered,
396    /// unless it has a height hint from [`Self::with_uniform_item_height`].
397    ///
398    /// # Examples
399    ///
400    /// ```
401    /// # use gpui::*;
402    /// let state = ListState::new(3, ListAlignment::Top, px(100.));
403    /// assert_eq!(state.item_count(), 3);
404    /// ```
405    pub fn new(item_count: usize, alignment: ListAlignment, overdraw: Pixels) -> Self {
406        let this = Self(Rc::new(RefCell::new(StateInner {
407            revision: Default::default(),
408            last_layout_bounds: None,
409            last_padding: None,
410            items: SumTree::default(),
411            logical_scroll_top: None,
412            alignment,
413            overdraw,
414            scroll_handler: None,
415            reset: false,
416            scrollbar_drag_start_height: None,
417            measuring_behavior: ListMeasuringBehavior::default(),
418            pending_scroll: None,
419            follow_state: FollowState::default(),
420        })));
421        this.splice(0..0, item_count);
422        this
423    }
424
425    /// Measures every row on the next layout, instead of only the rows that are rendered.
426    ///
427    /// The scrollbar then reflects the full content height from the first frame. The cost is one
428    /// render and measure per row, which is slow for a large list. For rows of similar height,
429    /// [`Self::with_uniform_item_height`] gives a close estimate without measuring. Measuring runs
430    /// again after the width changes, and after [`Self::reset`], [`Self::remeasure`] or
431    /// [`Self::remeasure_items`].
432    pub fn measure_all(self) -> Self {
433        self.state_mut().measuring_behavior = ListMeasuringBehavior::Measure(false);
434        self
435    }
436
437    /// Gives every unmeasured row a height estimate of `height`, so the scrollbar is the right
438    /// size before the rows are rendered.
439    ///
440    /// Each row's real height replaces the estimate when the row is rendered. This costs less than
441    /// [`Self::measure_all`], and suits lists whose rows are close to one height, such as table rows.
442    ///
443    /// # Examples
444    ///
445    /// ```
446    /// # use gpui::*;
447    /// let state = ListState::new(1_000, ListAlignment::Top, px(0.)).with_uniform_item_height(px(50.));
448    /// assert_eq!(state.max_offset_for_scrollbar().y, px(50_000.));
449    /// ```
450    pub fn with_uniform_item_height(self, height: Pixels) -> Self {
451        self.apply_uniform_item_height(height);
452        self
453    }
454
455    /// Replaces every row with `element_count` new, unmeasured rows, and returns the scroll
456    /// position to the start.
457    ///
458    /// The position goes to the first row for [`ListAlignment::Top`] and to the last row for
459    /// [`ListAlignment::Bottom`]. Scroll events are dropped until the next paint. To keep the scroll
460    /// position, use [`Self::splice`].
461    ///
462    /// # Examples
463    ///
464    /// ```
465    /// # use gpui::*;
466    /// let state = ListState::new(3, ListAlignment::Top, px(0.));
467    /// state.reset(10);
468    /// assert_eq!(state.item_count(), 10);
469    /// assert_eq!(state.logical_scroll_top().item_ix, 0);
470    /// ```
471    pub fn reset(&self, element_count: usize) {
472        let old_count = {
473            let state = &mut *self.state_mut();
474            state.reset = true;
475            state.measuring_behavior.reset();
476            state.logical_scroll_top = None;
477            state.pending_scroll = None;
478            state.scrollbar_drag_start_height = None;
479            state.items.summary().count
480        };
481
482        self.splice(0..old_count, element_count);
483    }
484
485    /// Like [`Self::reset`], and gives every new row the height estimate `height`.
486    ///
487    /// See [`Self::with_uniform_item_height`].
488    pub fn reset_with_uniform_height(&self, element_count: usize, height: Pixels) {
489        self.reset(element_count);
490        self.apply_uniform_item_height(height);
491    }
492
493    fn apply_uniform_item_height(&self, height: Pixels) {
494        let size_hint = Size {
495            width: px(0.),
496            height,
497        };
498        let mut state = self.state_mut();
499        let new_items = state
500            .items
501            .iter()
502            .map(|item| ListItem::Unmeasured {
503                size_hint: Some(item.size_hint().unwrap_or(size_hint)),
504                focus_handle: item.focus_handle(),
505            })
506            .collect::<Vec<_>>();
507        let mut tree = SumTree::default();
508        tree.extend(new_items, ());
509        state.items = tree;
510    }
511
512    /// Marks every row unmeasured, so each is measured again when next rendered.
513    ///
514    /// Use this when all rows change height together, such as when the font size changes. The
515    /// scroll position keeps the same fraction of the row at the top of the viewport. For one row
516    /// whose content changed, use [`Self::remeasure_items`].
517    pub fn remeasure(&self) {
518        let count = self.item_count();
519        self.remeasure_items_with_scroll_anchor(0..count, ScrollAnchor::Proportional);
520    }
521
522    /// Marks the rows in `range` unmeasured, so they are measured again when next rendered.
523    ///
524    /// Use this when a row's content changed height but the row is still at the same index, such as
525    /// streaming text. The scroll position is kept. If the row at the top of the viewport is in
526    /// `range`, the same pixel offset into that row is kept. Unlike [`Self::splice`], the row count
527    /// doesn't change.
528    pub fn remeasure_items(&self, range: Range<usize>) {
529        self.remeasure_items_with_scroll_anchor(range, ScrollAnchor::Absolute);
530    }
531
532    fn remeasure_items_with_scroll_anchor(&self, range: Range<usize>, scroll_anchor: ScrollAnchor) {
533        let state = &mut *self.state_mut();
534
535        if let Some(scroll_top) = state.logical_scroll_top {
536            if range.contains(&scroll_top.item_ix) {
537                state.pending_scroll = match scroll_anchor {
538                    ScrollAnchor::Absolute => Some(PendingScroll::Absolute {
539                        item_ix: scroll_top.item_ix,
540                        offset: scroll_top.offset_in_item,
541                    }),
542                    ScrollAnchor::Proportional => {
543                        // If the scroll-top item falls within the remeasured range,
544                        // store a fractional offset so the layout can restore the
545                        // proportional scroll position after the item is re-rendered
546                        // at its new height.
547                        let mut cursor = state.items.cursor::<Count>(());
548                        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
549
550                        cursor
551                            .item()
552                            .and_then(|item| {
553                                item.size().map(|size| {
554                                    let fraction = if size.height.0 > 0.0 {
555                                        (scroll_top.offset_in_item.0 / size.height.0)
556                                            .clamp(0.0, 1.0)
557                                    } else {
558                                        0.0
559                                    };
560
561                                    PendingScroll::Proportional(PendingScrollFraction {
562                                        item_ix: scroll_top.item_ix,
563                                        fraction,
564                                    })
565                                })
566                            })
567                            .or_else(|| state.pending_scroll.clone())
568                    }
569                };
570            }
571        }
572
573        // Rebuild the tree, replacing items in the range with
574        // Unmeasured copies that keep their focus handles.
575        let new_items = {
576            let mut cursor = state.items.cursor::<Count>(());
577            let mut new_items = cursor.slice(&Count(range.start), Bias::Right);
578            let invalidated = cursor.slice(&Count(range.end), Bias::Right);
579            new_items.extend(
580                invalidated.iter().map(|item| ListItem::Unmeasured {
581                    size_hint: item.size_hint(),
582                    focus_handle: item.focus_handle(),
583                }),
584                (),
585            );
586            new_items.append(cursor.suffix(), ());
587            new_items
588        };
589        state.items = new_items;
590        state.measuring_behavior.reset();
591    }
592
593    /// Returns the number of rows in this list.
594    ///
595    /// # Examples
596    ///
597    /// ```
598    /// # use gpui::*;
599    /// let state = ListState::new(3, ListAlignment::Top, px(0.));
600    /// assert_eq!(state.item_count(), 3);
601    /// ```
602    pub fn item_count(&self) -> usize {
603        self.0.borrow().items.summary().count
604    }
605
606    /// Returns whether the list is scrolled to its end.
607    ///
608    /// Returns `None` when the list isn't scrollable, when it hasn't been painted yet, or when a row
609    /// has no height yet, because the end isn't known until then. A row has a height once it is
610    /// rendered, or once it has a height hint from [`Self::with_uniform_item_height`].
611    ///
612    /// # Examples
613    ///
614    /// ```
615    /// # use gpui::*;
616    /// let state = ListState::new(3, ListAlignment::Top, px(0.));
617    /// assert_eq!(state.is_scrolled_to_end(), None);
618    /// ```
619    pub fn is_scrolled_to_end(&self) -> Option<bool> {
620        let state = self.0.borrow();
621        let bounds = state.last_layout_bounds?;
622        let summary = state.items.summary();
623        if summary.has_unknown_height {
624            return None;
625        }
626        let padding = state.last_padding.unwrap_or_default();
627        let content_height = summary.height + padding.top + padding.bottom;
628        let scroll_max = (content_height - bounds.size.height).max(px(0.));
629        if scroll_max <= px(0.) {
630            return None;
631        }
632        let scroll_top = state.scroll_top(&state.logical_scroll_top());
633        Some(scroll_top >= scroll_max)
634    }
635
636    /// Replaces the rows in `old_range` with `count` new, unmeasured rows.
637    ///
638    /// Call this after you add, remove or move rows. Rows after `old_range` keep their measurements
639    /// and shift by the change in count. New rows count as zero pixels tall until they are rendered.
640    /// If the row at the top of the viewport is in `old_range`, the list scrolls to the start of the
641    /// new rows. Use [`Self::splice_focusable`] to keep a focused row rendered.
642    ///
643    /// `splice(old_count..old_count, 1)` appends one row, and `splice(0..0, 1)` inserts one at the top.
644    ///
645    /// # Examples
646    ///
647    /// ```
648    /// # use gpui::*;
649    /// let state = ListState::new(3, ListAlignment::Top, px(0.));
650    /// state.splice(1..2, 4); // replace the middle row with four new rows
651    /// assert_eq!(state.item_count(), 6);
652    /// ```
653    pub fn splice(&self, old_range: Range<usize>, count: usize) {
654        self.splice_focusable(old_range, (0..count).map(|_| None))
655    }
656
657    /// Replaces the rows in `old_range` like [`Self::splice`], and gives each new row an optional focus handle.
658    ///
659    /// Pass `None` for rows that can't take focus. If a row with a focus handle holds focus when it
660    /// scrolls out of view, the list keeps rendering it after the visible rows, so keyboard input
661    /// still reaches it, until focus moves elsewhere.
662    pub fn splice_focusable(
663        &self,
664        old_range: Range<usize>,
665        focus_handles: impl IntoIterator<Item = Option<FocusHandle>>,
666    ) {
667        let state = &mut *self.state_mut();
668
669        let mut old_items = state.items.cursor::<Count>(());
670        let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right);
671        old_items.seek_forward(&Count(old_range.end), Bias::Right);
672
673        let mut spliced_count = 0;
674        new_items.extend(
675            focus_handles.into_iter().map(|focus_handle| {
676                spliced_count += 1;
677                ListItem::Unmeasured {
678                    size_hint: None,
679                    focus_handle,
680                }
681            }),
682            (),
683        );
684        new_items.append(old_items.suffix(), ());
685        drop(old_items);
686        state.items = new_items;
687
688        if let Some(ListOffset {
689            item_ix,
690            offset_in_item,
691        }) = state.logical_scroll_top.as_mut()
692        {
693            if old_range.contains(item_ix) {
694                *item_ix = old_range.start;
695                *offset_in_item = px(0.);
696            } else if old_range.end <= *item_ix {
697                *item_ix = *item_ix - (old_range.end - old_range.start) + spliced_count;
698            }
699        }
700    }
701
702    /// Sets a callback that runs after each wheel or trackpad scroll of the list.
703    ///
704    /// The callback runs on every wheel event the list handles, including events that don't move it.
705    /// It does not run for [`Self::scroll_to`], [`Self::scroll_by`], [`Self::scroll_to_reveal_item`]
706    /// or scrollbar drags.
707    ///
708    /// The callback runs while the list's state is borrowed. Use the [`ListScrollEvent`] it receives,
709    /// and don't call methods on the [`ListState`] from inside it.
710    ///
711    /// # Examples
712    ///
713    /// ```
714    /// # use gpui::*;
715    /// # use std::{cell::Cell, rc::Rc};
716    /// # struct Feed { state: ListState }
717    /// # impl Render for Feed {
718    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
719    /// #         div().h(px(200.)).child(
720    /// #             list(self.state.clone(), |ix, _window, _cx| {
721    /// #                 div().h(px(50.)).child(format!("Row {ix}")).into_any_element()
722    /// #             })
723    /// #             .size_full(),
724    /// #         )
725    /// #     }
726    /// # }
727    /// # let mut app = TestApp::new();
728    /// # let mut window = app.open_window(|_, _| Feed {
729    /// #     state: ListState::new(10, ListAlignment::Top, px(0.)).with_uniform_item_height(px(50.)),
730    /// # });
731    /// let scrolls = Rc::new(Cell::new(0));
732    /// let counted = scrolls.clone();
733    /// window.update(|feed, _, _| {
734    ///     feed.state.set_scroll_handler(move |event, _window, _cx| {
735    ///         assert_eq!(event.count, 10);
736    ///         counted.set(counted.get() + 1);
737    ///     });
738    /// });
739    /// window.draw();
740    /// // Scroll down 100px, which is row 2 of 50px rows.
741    /// window.simulate_scroll(point(px(20.), px(20.)), point(px(0.), px(-100.)));
742    /// assert_eq!(scrolls.get(), 1);
743    /// window.read(|feed, _| assert_eq!(feed.state.logical_scroll_top().item_ix, 2));
744    /// ```
745    pub fn set_scroll_handler(
746        &self,
747        handler: impl FnMut(&ListScrollEvent, &mut Window, &mut App) + 'static,
748    ) {
749        self.state_mut().scroll_handler = Some(Box::new(handler))
750    }
751
752    /// Returns the first visible row, and the pixel offset into that row.
753    ///
754    /// Before the list is scrolled, this is row 0 for [`ListAlignment::Top`]. For
755    /// [`ListAlignment::Bottom`] it is the end of the list, the item count, with an offset of zero.
756    /// Pass a value you saved to [`Self::scroll_to`] to restore a position.
757    pub fn logical_scroll_top(&self) -> ListOffset {
758        self.0.borrow().logical_scroll_top()
759    }
760
761    /// Scrolls the list by `distance` pixels: toward the end when positive, and toward the start when negative.
762    ///
763    /// The start is clamped at zero. Past the end, the next paint clamps the position. A scroll
764    /// toward the start stops following the tail. This doesn't call the scroll handler, and it
765    /// doesn't redraw, so call `cx.notify()` to show the new position.
766    ///
767    /// # Examples
768    ///
769    /// ```
770    /// # use gpui::*;
771    /// # use std::rc::Rc;
772    /// # struct Feed { state: ListState, rows: Rc<Vec<usize>> }
773    /// # impl Render for Feed {
774    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
775    /// #         let rows = self.rows.clone();
776    /// #         div().h(px(200.)).child(
777    /// #             list(self.state.clone(), move |ix, _window, _cx| {
778    /// #                 div().h(px(50.)).child(format!("Row {}", rows[ix])).into_any_element()
779    /// #             })
780    /// #             .size_full(),
781    /// #         )
782    /// #     }
783    /// # }
784    /// # let mut app = TestApp::new();
785    /// # let mut window = app.open_window(|_, _| Feed {
786    /// #     state: ListState::new(10, ListAlignment::Top, px(0.)),
787    /// #     rows: Rc::new((0..10).collect()),
788    /// # });
789    /// window.draw();
790    /// window.update(|feed, _, cx| {
791    ///     feed.state.scroll_by(px(120.));
792    ///     cx.notify();
793    /// });
794    /// window.draw();
795    /// // 120px down is row 2, 20px into it.
796    /// window.read(|feed, _| {
797    ///     let top = feed.state.logical_scroll_top();
798    ///     assert_eq!((top.item_ix, top.offset_in_item), (2, px(20.)));
799    /// });
800    /// ```
801    pub fn scroll_by(&self, distance: Pixels) {
802        if distance == px(0.) {
803            return;
804        }
805
806        let current_offset = self.logical_scroll_top();
807        let state = &mut *self.state_mut();
808
809        if distance < px(0.) {
810            state.follow_state.stop_following();
811        }
812
813        let mut cursor = state.items.cursor::<ListItemSummary>(());
814        cursor.seek(&Count(current_offset.item_ix), Bias::Right);
815
816        let start_pixel_offset = cursor.start().height + current_offset.offset_in_item;
817        let new_pixel_offset = (start_pixel_offset + distance).max(px(0.));
818        if new_pixel_offset > start_pixel_offset {
819            cursor.seek_forward(&Height(new_pixel_offset), Bias::Right);
820        } else {
821            cursor.seek(&Height(new_pixel_offset), Bias::Right);
822        }
823
824        let scroll_top = ListOffset {
825            item_ix: cursor.start().count,
826            offset_in_item: new_pixel_offset - cursor.start().height,
827        };
828        drop(cursor);
829        state.rebase_pending_scroll(scroll_top);
830        state.logical_scroll_top = Some(scroll_top);
831    }
832
833    /// Scrolls past the last row, so the bottom of the list stays in view while the last row grows.
834    ///
835    /// Unlike [`Self::scroll_to_reveal_item`], this anchors to the end of the list, not to a row. The
836    /// next paint walks back from the end and shows the bottom of the last row, even while that row
837    /// is still growing, as when text streams in. The scroll takes effect on the next paint.
838    pub fn scroll_to_end(&self) {
839        let state = &mut *self.state_mut();
840        let item_count = state.items.summary().count;
841        state.pending_scroll = None;
842        state.logical_scroll_top = Some(ListOffset {
843            item_ix: item_count,
844            offset_in_item: px(0.),
845        });
846    }
847
848    /// Sets whether the list keeps the end in view as rows are added.
849    ///
850    /// [`FollowMode::Tail`] scrolls to the end now, and keeps it there on each paint while
851    /// [`Self::is_following_tail`] is true. A user scroll toward the start stops following, and the
852    /// list starts following again when the user scrolls back to the end. [`FollowMode::Normal`]
853    /// stops following and leaves the position where it is.
854    ///
855    /// # Examples
856    ///
857    /// ```
858    /// # use gpui::*;
859    /// let state = ListState::new(5, ListAlignment::Top, px(0.));
860    /// state.set_follow_mode(FollowMode::Tail);
861    /// assert!(state.is_following_tail());
862    /// state.set_follow_mode(FollowMode::Normal);
863    /// assert!(!state.is_following_tail());
864    /// ```
865    pub fn set_follow_mode(&self, mode: FollowMode) {
866        let state = &mut *self.state_mut();
867
868        match mode {
869            FollowMode::Normal => {
870                state.follow_state = FollowState::Normal;
871            }
872            FollowMode::Tail => {
873                state.follow_state = FollowState::Tail { is_following: true };
874                if matches!(mode, FollowMode::Tail) {
875                    let item_count = state.items.summary().count;
876                    state.logical_scroll_top = Some(ListOffset {
877                        item_ix: item_count,
878                        offset_in_item: px(0.),
879                    });
880                }
881            }
882        }
883    }
884
885    /// Stops following the tail and keeps the current position, until the user scrolls back to the end.
886    ///
887    /// Unlike [`Self::set_follow_mode`] with [`FollowMode::Normal`], the list stays in
888    /// [`FollowMode::Tail`], so it resumes following when the view returns to the end. Does nothing
889    /// if the list isn't following.
890    ///
891    /// Use this when something other than the user changes a row's height, such as zooming a
892    /// diagram, and the position should not jump to the end.
893    ///
894    /// # Examples
895    ///
896    /// ```
897    /// # use gpui::*;
898    /// let state = ListState::new(5, ListAlignment::Top, px(0.));
899    /// state.set_follow_mode(FollowMode::Tail);
900    /// state.pause_following_tail();
901    /// assert!(!state.is_following_tail());
902    /// ```
903    pub fn pause_following_tail(&self) {
904        self.state_mut().follow_state.stop_following();
905    }
906
907    /// Returns whether the list is following the tail right now.
908    ///
909    /// This is true after [`Self::set_follow_mode`] with [`FollowMode::Tail`], until the user scrolls
910    /// toward the start or [`Self::pause_following_tail`] is called. It is false in
911    /// [`FollowMode::Normal`].
912    pub fn is_following_tail(&self) -> bool {
913        matches!(
914            self.0.borrow().follow_state,
915            FollowState::Tail { is_following: true }
916        )
917    }
918
919    /// Scrolls the list so that `scroll_top` is at the top of the viewport.
920    ///
921    /// If `scroll_top` is past the last row, the list scrolls to the end. A position before the end
922    /// stops following the tail.
923    ///
924    /// # Examples
925    ///
926    /// ```
927    /// # use gpui::*;
928    /// let state = ListState::new(5, ListAlignment::Top, px(0.));
929    /// state.scroll_to(ListOffset { item_ix: 2, offset_in_item: px(10.) });
930    /// assert_eq!(state.logical_scroll_top().item_ix, 2);
931    /// assert_eq!(state.logical_scroll_top().offset_in_item, px(10.));
932    /// ```
933    pub fn scroll_to(&self, mut scroll_top: ListOffset) {
934        let state = &mut *self.state_mut();
935        let item_count = state.items.summary().count;
936        if scroll_top.item_ix >= item_count {
937            scroll_top.item_ix = item_count;
938            scroll_top.offset_in_item = px(0.);
939        }
940
941        if scroll_top.item_ix < item_count {
942            state.follow_state.stop_following();
943        }
944
945        state.rebase_pending_scroll(scroll_top);
946        state.logical_scroll_top = Some(scroll_top);
947    }
948
949    /// Scrolls the least amount that shows row `ix` fully.
950    ///
951    /// A row above the current top is moved to the top of the viewport. A row below it is moved so
952    /// its bottom edge sits at the bottom of the viewport. The position takes effect on the next paint.
953    ///
954    /// # Examples
955    ///
956    /// ```
957    /// # use gpui::*;
958    /// # use std::rc::Rc;
959    /// # struct Feed { state: ListState, rows: Rc<Vec<usize>> }
960    /// # impl Render for Feed {
961    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
962    /// #         let rows = self.rows.clone();
963    /// #         div().h(px(200.)).child(
964    /// #             list(self.state.clone(), move |ix, _window, _cx| {
965    /// #                 div().h(px(50.)).child(format!("Row {}", rows[ix])).into_any_element()
966    /// #             })
967    /// #             .size_full(),
968    /// #         )
969    /// #     }
970    /// # }
971    /// # let mut app = TestApp::new();
972    /// # let mut window = app.open_window(|_, _| Feed {
973    /// #     state: ListState::new(10, ListAlignment::Top, px(0.)).with_uniform_item_height(px(50.)),
974    /// #     rows: Rc::new((0..10).collect()),
975    /// # });
976    /// window.draw();
977    /// window.update(|feed, _, _| feed.state.scroll_to_reveal_item(9));
978    /// window.draw();
979    /// window.read(|feed, _| {
980    ///     assert_eq!(feed.state.item_is_above_viewport(9), Some(false));
981    ///     assert_eq!(feed.state.item_is_below_viewport(9), Some(false));
982    /// });
983    /// ```
984    pub fn scroll_to_reveal_item(&self, ix: usize) {
985        let state = &mut *self.state_mut();
986
987        let mut scroll_top = state.logical_scroll_top();
988        let height = state
989            .last_layout_bounds
990            .map_or(px(0.), |bounds| bounds.size.height);
991        let padding = state.last_padding.unwrap_or_default();
992
993        if ix <= scroll_top.item_ix {
994            scroll_top.item_ix = ix;
995            scroll_top.offset_in_item = px(0.);
996        } else {
997            let mut cursor = state.items.cursor::<ListItemSummary>(());
998            cursor.seek(&Count(ix + 1), Bias::Right);
999            let bottom = cursor.start().height + padding.top;
1000            let goal_top = px(0.).max(bottom - height + padding.bottom);
1001
1002            cursor.seek(&Height(goal_top), Bias::Left);
1003            let start_ix = cursor.start().count;
1004            let start_item_top = cursor.start().height;
1005
1006            if start_ix >= scroll_top.item_ix {
1007                scroll_top.item_ix = start_ix;
1008                scroll_top.offset_in_item = goal_top - start_item_top;
1009            }
1010        }
1011
1012        state.rebase_pending_scroll(scroll_top);
1013        state.logical_scroll_top = Some(scroll_top);
1014    }
1015
1016    /// Returns the bounds of row `ix` in window coordinates, or `None` if the row is above the scroll
1017    /// position or has not been measured.
1018    ///
1019    /// A measured row below the viewport still has bounds, so compare them with
1020    /// [`Self::viewport_bounds`]. [`Self::item_is_above_viewport`] and
1021    /// [`Self::item_is_below_viewport`] answer that directly.
1022    pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
1023        let state = &*self.0.borrow();
1024
1025        let bounds = state.last_layout_bounds.unwrap_or_default();
1026        let scroll_top = state.logical_scroll_top();
1027        if ix < scroll_top.item_ix {
1028            return None;
1029        }
1030
1031        let mut cursor = state.items.cursor::<Dimensions<Count, Height>>(());
1032        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1033
1034        let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item;
1035
1036        cursor.seek_forward(&Count(ix), Bias::Right);
1037        if let Some(&ListItem::Measured { size, .. }) = cursor.item() {
1038            let &Dimensions(Count(count), Height(top), _) = cursor.start();
1039            if count == ix {
1040                let top = bounds.top() + top - scroll_top;
1041                return Some(Bounds::from_corners(
1042                    point(bounds.left(), top),
1043                    point(bounds.right(), top + size.height),
1044                ));
1045            }
1046        }
1047        None
1048    }
1049
1050    /// Tells the list that the user started dragging its scrollbar.
1051    ///
1052    /// Until [`Self::scrollbar_drag_ended`], the scrollbar uses the content height from this moment,
1053    /// so the thumb doesn't move while rows in the overdraw are measured.
1054    pub fn scrollbar_drag_started(&self) {
1055        let mut state = self.state_mut();
1056        state.scrollbar_drag_start_height = Some(state.items.summary().height);
1057    }
1058
1059    /// Tells the list that the scrollbar drag has ended, so the scrollbar uses the live content
1060    /// height again. See [`Self::scrollbar_drag_started`].
1061    pub fn scrollbar_drag_ended(&self) {
1062        self.state_mut().scrollbar_drag_start_height.take();
1063    }
1064
1065    /// Returns whether the scrollbar is being dragged.
1066    ///
1067    /// This is true between [`Self::scrollbar_drag_started`] and [`Self::scrollbar_drag_ended`].
1068    /// Use it to tell a scrollbar drag from a wheel or trackpad scroll, for example to stop
1069    /// auto-scrolling during a manual drag.
1070    pub fn is_scrollbar_dragging(&self) -> bool {
1071        self.0.borrow().scrollbar_drag_start_height.is_some()
1072    }
1073
1074    /// Scrolls the list to the position that a scrollbar reports.
1075    ///
1076    /// `point.y` is a negative scroll offset, as [`Self::scroll_px_offset_for_scrollbar`] returns it.
1077    /// It is clamped to the content. A drag to the end while the list is in [`FollowMode::Tail`]
1078    /// resumes following the tail, and any other position stops it. Does nothing before the first paint.
1079    pub fn set_offset_from_scrollbar(&self, point: Point<Pixels>) {
1080        self.state_mut().set_offset_from_scrollbar(point);
1081    }
1082
1083    /// Returns the largest scroll distance, as a positive `y`.
1084    ///
1085    /// This is the content height minus the viewport height, using the heights measured so far and
1086    /// any height hints. During a scrollbar drag it keeps the value from when the drag started.
1087    pub fn max_offset_for_scrollbar(&self) -> Point<Pixels> {
1088        let state = self.0.borrow();
1089        point(Pixels::ZERO, state.max_scroll_offset())
1090    }
1091
1092    /// Returns the current scroll position for the scrollbar, as a negative `y`.
1093    ///
1094    /// The value is the negation of how far the content has moved, so it is the negative of
1095    /// [`Self::max_offset_for_scrollbar`] for a bottom-aligned list that is pinned to the end.
1096    pub fn scroll_px_offset_for_scrollbar(&self) -> Point<Pixels> {
1097        let state = &self.0.borrow();
1098
1099        if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom {
1100            return Point::new(px(0.), -state.max_scroll_offset());
1101        }
1102
1103        let logical_scroll_top = state.logical_scroll_top();
1104
1105        let mut cursor = state.items.cursor::<ListItemSummary>(());
1106        let summary: ListItemSummary =
1107            cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right);
1108        let offset = summary.height + logical_scroll_top.offset_in_item;
1109
1110        Point::new(px(0.), -offset)
1111    }
1112
1113    /// Returns the viewport's bounds in window coordinates, from the last prepaint.
1114    ///
1115    /// Before the first prepaint this returns empty bounds at the origin.
1116    pub fn viewport_bounds(&self) -> Bounds<Pixels> {
1117        self.0.borrow().last_layout_bounds.unwrap_or_default()
1118    }
1119
1120    /// Returns whether row `ix` is entirely above the viewport, or `None` before the first layout.
1121    ///
1122    /// Rows before the scroll position are above the viewport (`Some(true)`) even when they were
1123    /// never measured. Any other row needs bounds, so it returns `None` if it hasn't been measured.
1124    /// A zero-height viewport still gives an answer, so the result doesn't flicker while sibling UI
1125    /// resizes the list.
1126    pub fn item_is_above_viewport(&self, ix: usize) -> Option<bool> {
1127        let viewport_bounds = self.0.borrow().last_layout_bounds?;
1128
1129        let scroll_top = self.logical_scroll_top();
1130        if ix < scroll_top.item_ix {
1131            // Rows before the logical scroll top have no item bounds, but
1132            // their position relative to the viewport is known from scroll state.
1133            return Some(true);
1134        }
1135
1136        let item_bounds = self.bounds_for_item(ix)?;
1137        Some(item_bounds.bottom() <= viewport_bounds.top())
1138    }
1139
1140    /// Returns whether row `ix` is entirely below the viewport, or `None` when the answer isn't known yet.
1141    ///
1142    /// Rows before the scroll position return `Some(false)`. Other rows follow the rules of
1143    /// [`Self::item_is_above_viewport`].
1144    pub fn item_is_below_viewport(&self, ix: usize) -> Option<bool> {
1145        let viewport_bounds = self.0.borrow().last_layout_bounds?;
1146
1147        let scroll_top = self.logical_scroll_top();
1148        if ix < scroll_top.item_ix {
1149            // Rows before the logical scroll top have no item bounds, but
1150            // their position relative to the viewport is known from scroll state.
1151            return Some(false);
1152        }
1153
1154        let item_bounds = self.bounds_for_item(ix)?;
1155        Some(item_bounds.top() >= viewport_bounds.bottom())
1156    }
1157}
1158
1159impl StateInner {
1160    /// Re-anchor a pending scroll adjustment from a remeasure onto a newly set
1161    /// scroll position, so it clamps to the remeasured item's new height on
1162    /// the next layout instead of reverting the scroll.
1163    fn rebase_pending_scroll(&mut self, scroll_top: ListOffset) {
1164        let Some(pending) = self.pending_scroll.take() else {
1165            return;
1166        };
1167        if scroll_top.item_ix >= self.items.summary().count {
1168            return;
1169        }
1170
1171        self.pending_scroll = match pending {
1172            PendingScroll::Absolute { .. } => Some(PendingScroll::Absolute {
1173                item_ix: scroll_top.item_ix,
1174                offset: scroll_top.offset_in_item,
1175            }),
1176            PendingScroll::Proportional(_) => {
1177                let mut cursor = self.items.cursor::<Count>(());
1178                cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1179                cursor
1180                    .item()
1181                    .and_then(|item| item.size_hint())
1182                    .filter(|size| size.height.0 > 0.0)
1183                    .map(|size| {
1184                        PendingScroll::Proportional(PendingScrollFraction {
1185                            item_ix: scroll_top.item_ix,
1186                            fraction: (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0),
1187                        })
1188                    })
1189            }
1190        };
1191    }
1192
1193    fn max_scroll_offset(&self) -> Pixels {
1194        let bounds = self.last_layout_bounds.unwrap_or_default();
1195        let height = self
1196            .scrollbar_drag_start_height
1197            .unwrap_or_else(|| self.items.summary().height);
1198        (height - bounds.size.height).max(px(0.))
1199    }
1200
1201    fn visible_range(
1202        items: &SumTree<ListItem>,
1203        height: Pixels,
1204        scroll_top: &ListOffset,
1205    ) -> Range<usize> {
1206        let mut cursor = items.cursor::<ListItemSummary>(());
1207        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1208        let start_y = cursor.start().height + scroll_top.offset_in_item;
1209        cursor.seek_forward(&Height(start_y + height), Bias::Left);
1210        scroll_top.item_ix..(cursor.start().count + 1).min(items.summary().count)
1211    }
1212
1213    fn scroll(
1214        &mut self,
1215        scroll_top: &ListOffset,
1216        height: Pixels,
1217        delta: Point<Pixels>,
1218        current_view: EntityId,
1219        window: &mut Window,
1220        cx: &mut App,
1221    ) {
1222        // Drop scroll events after a reset, since we can't calculate
1223        // the new logical scroll top without the item heights
1224        if self.reset {
1225            return;
1226        }
1227
1228        let padding = self.last_padding.unwrap_or_default();
1229        let scroll_max =
1230            (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
1231        let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
1232            .max(px(0.))
1233            .min(scroll_max);
1234
1235        let (start, ..) =
1236            self.items
1237                .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
1238        let resulting_scroll_top = ListOffset {
1239            item_ix: start.count,
1240            offset_in_item: new_scroll_top - start.height,
1241        };
1242
1243        if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
1244            self.pending_scroll = None;
1245            self.logical_scroll_top = None;
1246        } else {
1247            // The user's scroll supersedes the position stashed by a
1248            // remeasure; re-anchor the pending adjustment so it doesn't revert
1249            // this scroll on the next layout.
1250            self.rebase_pending_scroll(resulting_scroll_top);
1251            self.logical_scroll_top = Some(resulting_scroll_top);
1252        }
1253
1254        if delta.y > px(0.) {
1255            self.follow_state.stop_following();
1256        }
1257
1258        if let Some(handler) = self.scroll_handler.as_mut() {
1259            let visible_range = Self::visible_range(&self.items, height, &resulting_scroll_top);
1260            handler(
1261                &ListScrollEvent {
1262                    visible_range,
1263                    count: self.items.summary().count,
1264                    is_scrolled: self.logical_scroll_top.is_some(),
1265                    is_following_tail: matches!(
1266                        self.follow_state,
1267                        FollowState::Tail { is_following: true }
1268                    ),
1269                },
1270                window,
1271                cx,
1272            );
1273        }
1274
1275        cx.notify(current_view);
1276    }
1277
1278    fn logical_scroll_top(&self) -> ListOffset {
1279        self.logical_scroll_top
1280            .unwrap_or_else(|| match self.alignment {
1281                ListAlignment::Top => ListOffset {
1282                    item_ix: 0,
1283                    offset_in_item: px(0.),
1284                },
1285                ListAlignment::Bottom => ListOffset {
1286                    item_ix: self.items.summary().count,
1287                    offset_in_item: px(0.),
1288                },
1289            })
1290    }
1291
1292    fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels {
1293        let (start, ..) = self.items.find::<ListItemSummary, _>(
1294            (),
1295            &Count(logical_scroll_top.item_ix),
1296            Bias::Right,
1297        );
1298        start.height + logical_scroll_top.offset_in_item
1299    }
1300
1301    fn layout_all_items(
1302        &mut self,
1303        available_width: Pixels,
1304        render_item: &mut RenderItemFn,
1305        window: &mut Window,
1306        cx: &mut App,
1307    ) {
1308        match &mut self.measuring_behavior {
1309            ListMeasuringBehavior::Visible => {
1310                return;
1311            }
1312            ListMeasuringBehavior::Measure(has_measured) => {
1313                if *has_measured {
1314                    return;
1315                }
1316                *has_measured = true;
1317            }
1318        }
1319
1320        let mut cursor = self.items.cursor::<Count>(());
1321        let available_item_space = size(
1322            AvailableSpace::Definite(available_width),
1323            AvailableSpace::MinContent,
1324        );
1325
1326        let mut measured_items = Vec::default();
1327
1328        for (ix, item) in cursor.enumerate() {
1329            let size = item.size().unwrap_or_else(|| {
1330                let mut element = render_item(ix, window, cx);
1331                element.layout_as_root(available_item_space, window, cx)
1332            });
1333
1334            measured_items.push(ListItem::Measured {
1335                size,
1336                focus_handle: item.focus_handle(),
1337            });
1338        }
1339
1340        self.items = SumTree::from_iter(measured_items, ());
1341    }
1342
1343    fn layout_items(
1344        &mut self,
1345        available_width: Option<Pixels>,
1346        available_height: Pixels,
1347        padding: &Edges<Pixels>,
1348        render_item: &mut RenderItemFn,
1349        window: &mut Window,
1350        cx: &mut App,
1351    ) -> LayoutItemsResponse {
1352        let old_items = self.items.clone();
1353        let mut measured_items = VecDeque::new();
1354        let mut item_layouts = VecDeque::new();
1355        let mut rendered_height = padding.top;
1356        let mut max_item_width = px(0.);
1357        let mut scroll_top = self.logical_scroll_top();
1358
1359        if self.follow_state.is_following() {
1360            scroll_top = ListOffset {
1361                item_ix: self.items.summary().count,
1362                offset_in_item: px(0.),
1363            };
1364            self.logical_scroll_top = Some(scroll_top);
1365        }
1366
1367        let mut rendered_focused_item = false;
1368
1369        let available_item_space = size(
1370            available_width.map_or(AvailableSpace::MaxContent, |width| {
1371                AvailableSpace::Definite(width)
1372            }),
1373            AvailableSpace::MinContent,
1374        );
1375
1376        let mut cursor = old_items.cursor::<Count>(());
1377
1378        // Render items after the scroll top, including those in the trailing overdraw
1379        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1380        for (ix, item) in cursor.by_ref().enumerate() {
1381            let visible_height = rendered_height - scroll_top.offset_in_item;
1382            if visible_height >= available_height + self.overdraw {
1383                break;
1384            }
1385
1386            // Use the previously cached height and focus handle if available
1387            let mut size = item.size();
1388
1389            // If we're within the visible area or the height wasn't cached, render and measure the item's element
1390            if visible_height < available_height || size.is_none() {
1391                let item_index = scroll_top.item_ix + ix;
1392                let mut element = render_item(item_index, window, cx);
1393                let element_size = element.layout_as_root(available_item_space, window, cx);
1394                size = Some(element_size);
1395
1396                // If there's a pending scroll adjustment for the scroll-top
1397                // item, apply it.
1398                if ix == 0 {
1399                    if let Some(pending_scroll) = self.pending_scroll.take() {
1400                        match pending_scroll {
1401                            PendingScroll::Absolute { item_ix, offset }
1402                                if item_ix == scroll_top.item_ix =>
1403                            {
1404                                scroll_top.offset_in_item = offset.min(element_size.height);
1405                                self.logical_scroll_top = Some(scroll_top);
1406                            }
1407                            PendingScroll::Proportional(pending_scroll)
1408                                if pending_scroll.item_ix == scroll_top.item_ix =>
1409                            {
1410                                // Ensuring proportional scroll position is
1411                                // maintained after re-measuring.
1412                                scroll_top.offset_in_item =
1413                                    Pixels(pending_scroll.fraction * element_size.height.0);
1414                                self.logical_scroll_top = Some(scroll_top);
1415                            }
1416                            _ => {}
1417                        }
1418                    }
1419                }
1420
1421                if visible_height < available_height {
1422                    item_layouts.push_back(ItemLayout {
1423                        index: item_index,
1424                        element,
1425                        size: element_size,
1426                    });
1427                    if item.contains_focused(window, cx) {
1428                        rendered_focused_item = true;
1429                    }
1430                }
1431            }
1432
1433            let size = size.unwrap();
1434            rendered_height += size.height;
1435            max_item_width = max_item_width.max(size.width);
1436            measured_items.push_back(ListItem::Measured {
1437                size,
1438                focus_handle: item.focus_handle(),
1439            });
1440        }
1441        rendered_height += padding.bottom;
1442
1443        // Prepare to start walking upward from the item at the scroll top.
1444        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1445
1446        // If the rendered items do not fill the visible region, then adjust
1447        // the scroll top upward.
1448        if rendered_height - scroll_top.offset_in_item < available_height {
1449            while rendered_height < available_height {
1450                cursor.prev();
1451                if let Some(item) = cursor.item() {
1452                    let item_index = cursor.start().0;
1453                    let mut element = render_item(item_index, window, cx);
1454                    let element_size = element.layout_as_root(available_item_space, window, cx);
1455                    let focus_handle = item.focus_handle();
1456                    rendered_height += element_size.height;
1457                    measured_items.push_front(ListItem::Measured {
1458                        size: element_size,
1459                        focus_handle,
1460                    });
1461                    item_layouts.push_front(ItemLayout {
1462                        index: item_index,
1463                        element,
1464                        size: element_size,
1465                    });
1466                    if item.contains_focused(window, cx) {
1467                        rendered_focused_item = true;
1468                    }
1469                } else {
1470                    break;
1471                }
1472            }
1473
1474            scroll_top = ListOffset {
1475                item_ix: cursor.start().0,
1476                offset_in_item: rendered_height - available_height,
1477            };
1478
1479            match self.alignment {
1480                ListAlignment::Top => {
1481                    scroll_top.offset_in_item = scroll_top.offset_in_item.max(px(0.));
1482                    self.logical_scroll_top = Some(scroll_top);
1483                }
1484                ListAlignment::Bottom => {
1485                    scroll_top = ListOffset {
1486                        item_ix: cursor.start().0,
1487                        offset_in_item: rendered_height - available_height,
1488                    };
1489                    self.logical_scroll_top = None;
1490                }
1491            };
1492        }
1493
1494        // Measure items in the leading overdraw
1495        let mut leading_overdraw = scroll_top.offset_in_item;
1496        while leading_overdraw < self.overdraw {
1497            cursor.prev();
1498            if let Some(item) = cursor.item() {
1499                let size = if let ListItem::Measured { size, .. } = item {
1500                    *size
1501                } else {
1502                    let mut element = render_item(cursor.start().0, window, cx);
1503                    element.layout_as_root(available_item_space, window, cx)
1504                };
1505
1506                leading_overdraw += size.height;
1507                measured_items.push_front(ListItem::Measured {
1508                    size,
1509                    focus_handle: item.focus_handle(),
1510                });
1511            } else {
1512                break;
1513            }
1514        }
1515
1516        let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len());
1517        let mut cursor = old_items.cursor::<Count>(());
1518        let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right);
1519        new_items.extend(measured_items, ());
1520        cursor.seek(&Count(measured_range.end), Bias::Right);
1521        new_items.append(cursor.suffix(), ());
1522        self.items = new_items;
1523
1524        // If follow_tail mode is on but the user scrolled away
1525        // (is_following is false), check whether the current scroll
1526        // position has returned to the bottom.
1527        if self.follow_state.has_stopped_following() {
1528            let padding = self.last_padding.unwrap_or_default();
1529            let total_height = self.items.summary().height + padding.top + padding.bottom;
1530            let scroll_offset = self.scroll_top(&scroll_top);
1531            if scroll_offset + available_height >= total_height - px(1.0) {
1532                self.follow_state.start_following();
1533            }
1534        }
1535
1536        // If none of the visible items are focused, check if an off-screen item is focused
1537        // and include it to be rendered after the visible items so keyboard interaction continues
1538        // to work for it.
1539        if !rendered_focused_item {
1540            let mut cursor = self
1541                .items
1542                .filter::<_, Count>((), |summary| summary.has_focus_handles);
1543            cursor.next();
1544            while let Some(item) = cursor.item() {
1545                if item.contains_focused(window, cx) {
1546                    let item_index = cursor.start().0;
1547                    let mut element = render_item(cursor.start().0, window, cx);
1548                    let size = element.layout_as_root(available_item_space, window, cx);
1549                    item_layouts.push_back(ItemLayout {
1550                        index: item_index,
1551                        element,
1552                        size,
1553                    });
1554                    break;
1555                }
1556                cursor.next();
1557            }
1558        }
1559
1560        LayoutItemsResponse {
1561            max_item_width,
1562            scroll_top,
1563            item_layouts,
1564        }
1565    }
1566
1567    fn prepaint_items(
1568        &mut self,
1569        bounds: Bounds<Pixels>,
1570        padding: Edges<Pixels>,
1571        content_mask: ContentMask<Pixels>,
1572        autoscroll: bool,
1573        render_item: &mut RenderItemFn,
1574        window: &mut Window,
1575        cx: &mut App,
1576    ) -> Result<LayoutItemsResponse, ListOffset> {
1577        window.transact(|window| {
1578            match self.measuring_behavior {
1579                ListMeasuringBehavior::Measure(has_measured) if !has_measured => {
1580                    self.layout_all_items(bounds.size.width, render_item, window, cx);
1581                }
1582                _ => {}
1583            }
1584
1585            let mut layout_response = self.layout_items(
1586                Some(bounds.size.width),
1587                bounds.size.height,
1588                &padding,
1589                render_item,
1590                window,
1591                cx,
1592            );
1593
1594            // Avoid honoring autoscroll requests from elements other than our children.
1595            window.take_autoscroll();
1596
1597            // Only paint the visible items, if there is actually any space for them (taking padding into account)
1598            if bounds.size.height > padding.top + padding.bottom {
1599                let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
1600                item_origin.y -= layout_response.scroll_top.offset_in_item;
1601                for item in &mut layout_response.item_layouts {
1602                    window.with_content_mask(Some(content_mask), |window| {
1603                        item.element.prepaint_at(item_origin, window, cx);
1604                    });
1605
1606                    if let Some(autoscroll_bounds) = window.take_autoscroll()
1607                        && autoscroll
1608                    {
1609                        if autoscroll_bounds.top() < bounds.top() {
1610                            let mut item_ix = item.index;
1611                            let mut offset_in_item = autoscroll_bounds.top() - item_origin.y;
1612
1613                            // The requested top can sit above this item's own
1614                            // top. Walk into earlier items so the offset stays
1615                            // non-negative and no blank space appears above the
1616                            // list.
1617                            if offset_in_item < Pixels::ZERO {
1618                                let mut cursor = self.items.cursor::<Count>(());
1619                                cursor.seek(&Count(item_ix), Bias::Right);
1620                                while offset_in_item < Pixels::ZERO {
1621                                    cursor.prev();
1622                                    let Some(prev_item) = cursor.item() else {
1623                                        offset_in_item = Pixels::ZERO;
1624                                        break;
1625                                    };
1626                                    let size = prev_item.size().unwrap_or_else(|| {
1627                                        let mut element = render_item(cursor.start().0, window, cx);
1628                                        let item_available_size = size(
1629                                            bounds.size.width.into(),
1630                                            AvailableSpace::MinContent,
1631                                        );
1632                                        element.layout_as_root(item_available_size, window, cx)
1633                                    });
1634                                    item_ix = cursor.start().0;
1635                                    offset_in_item += size.height;
1636                                }
1637                            }
1638
1639                            return Err(ListOffset {
1640                                item_ix,
1641                                offset_in_item,
1642                            });
1643                        } else if autoscroll_bounds.bottom() > bounds.bottom() {
1644                            let mut cursor = self.items.cursor::<Count>(());
1645                            cursor.seek(&Count(item.index), Bias::Right);
1646                            let mut height = bounds.size.height - padding.top - padding.bottom;
1647
1648                            // Account for the height of the element down until the autoscroll bottom.
1649                            height -= autoscroll_bounds.bottom() - item_origin.y;
1650
1651                            // Keep decreasing the scroll top until we fill all the available space.
1652                            while height > Pixels::ZERO {
1653                                cursor.prev();
1654                                let Some(item) = cursor.item() else { break };
1655
1656                                let size = item.size().unwrap_or_else(|| {
1657                                    let mut item = render_item(cursor.start().0, window, cx);
1658                                    let item_available_size =
1659                                        size(bounds.size.width.into(), AvailableSpace::MinContent);
1660                                    item.layout_as_root(item_available_size, window, cx)
1661                                });
1662                                height -= size.height;
1663                            }
1664
1665                            return Err(ListOffset {
1666                                item_ix: cursor.start().0,
1667                                offset_in_item: if height < Pixels::ZERO {
1668                                    -height
1669                                } else {
1670                                    Pixels::ZERO
1671                                },
1672                            });
1673                        }
1674                    }
1675
1676                    item_origin.y += item.size.height;
1677                }
1678            } else {
1679                layout_response.item_layouts.clear();
1680            }
1681
1682            Ok(layout_response)
1683        })
1684    }
1685
1686    // Scrollbar support
1687
1688    fn set_offset_from_scrollbar(&mut self, point: Point<Pixels>) {
1689        let Some(bounds) = self.last_layout_bounds else {
1690            return;
1691        };
1692        let height = bounds.size.height;
1693
1694        let padding = self.last_padding.unwrap_or_default();
1695        // Scrollbar drag positions are computed from the content height
1696        // captured at drag start, so map them back using the same height.
1697        let content_height = self
1698            .scrollbar_drag_start_height
1699            .unwrap_or_else(|| self.items.summary().height);
1700        let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.));
1701        let new_scroll_top = (-point.y).max(px(0.)).min(scroll_max);
1702
1703        // If content grew during the drag, the frozen bottom is below the
1704        // live bottom. Treat dragging to the frozen end as resuming tail follow.
1705        let dragged_to_end =
1706            scroll_max > px(0.) && new_scroll_top >= (scroll_max - px(1.0)).max(px(0.));
1707        if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) {
1708            self.follow_state = FollowState::Tail { is_following: true };
1709            let item_count = self.items.summary().count;
1710            self.pending_scroll = None;
1711            self.logical_scroll_top = Some(ListOffset {
1712                item_ix: item_count,
1713                offset_in_item: px(0.),
1714            });
1715            return;
1716        }
1717
1718        self.follow_state.stop_following();
1719
1720        if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
1721            self.pending_scroll = None;
1722            self.logical_scroll_top = None;
1723        } else {
1724            let (start, _, _) =
1725                self.items
1726                    .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
1727
1728            let scroll_top = ListOffset {
1729                item_ix: start.count,
1730                offset_in_item: new_scroll_top - start.height,
1731            };
1732            self.rebase_pending_scroll(scroll_top);
1733            self.logical_scroll_top = Some(scroll_top);
1734        }
1735    }
1736}
1737
1738impl std::fmt::Debug for ListItem {
1739    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1740        match self {
1741            Self::Unmeasured { .. } => write!(f, "Unrendered"),
1742            Self::Measured { size, .. } => f.debug_struct("Rendered").field("size", size).finish(),
1743        }
1744    }
1745}
1746
1747/// A position in a list: a row index and a pixel offset into that row.
1748///
1749/// [`ListState::logical_scroll_top`] returns one. Pass one to [`ListState::scroll_to`] to restore a position.
1750#[derive(Debug, Clone, Copy, Default)]
1751pub struct ListOffset {
1752    /// The index of the row at the top of the viewport.
1753    pub item_ix: usize,
1754    /// The distance in pixels from the top of that row to the top of the viewport.
1755    pub offset_in_item: Pixels,
1756}
1757
1758impl List {
1759    /// The list's style with vertical scrolling enabled by default, so the list
1760    /// clips and masks its items even when no overflow was refined.
1761    fn base_style(&self) -> Style {
1762        let mut style = Style::default();
1763        style.overflow.y = Overflow::Scroll;
1764        style.refine(&self.style);
1765        style
1766    }
1767}
1768
1769impl Element for List {
1770    type RequestLayoutState = ();
1771    type PrepaintState = ListPrepaintState;
1772
1773    fn id(&self) -> Option<crate::ElementId> {
1774        None
1775    }
1776
1777    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1778        None
1779    }
1780
1781    fn request_layout(
1782        &mut self,
1783        _id: Option<&GlobalElementId>,
1784        _inspector_id: Option<&InspectorElementId>,
1785        window: &mut Window,
1786        cx: &mut App,
1787    ) -> (crate::LayoutId, Self::RequestLayoutState) {
1788        cx.note_revision(&self.state.0.borrow().revision);
1789        let layout_id = match self.sizing_behavior {
1790            ListSizingBehavior::Infer => {
1791                let style = self.base_style();
1792                window.with_text_style(style.text_style().cloned(), |window| {
1793                    let state = &mut *self.state.state_mut();
1794
1795                    let available_height = if let Some(last_bounds) = state.last_layout_bounds {
1796                        last_bounds.size.height
1797                    } else {
1798                        // If we don't have the last layout bounds (first render),
1799                        // we might just use the overdraw value as the available height to layout enough items.
1800                        state.overdraw
1801                    };
1802                    let padding = style.padding.to_pixels(
1803                        state.last_layout_bounds.unwrap_or_default().size.into(),
1804                        window.rem_size(),
1805                    );
1806
1807                    let layout_response = state.layout_items(
1808                        None,
1809                        available_height,
1810                        &padding,
1811                        &mut self.render_item,
1812                        window,
1813                        cx,
1814                    );
1815                    let max_element_width = layout_response.max_item_width;
1816
1817                    let summary = state.items.summary();
1818                    let total_height = summary.height;
1819
1820                    window.request_measured_layout(
1821                        style,
1822                        move |known_dimensions, available_space, _window, _cx| {
1823                            let width =
1824                                known_dimensions
1825                                    .width
1826                                    .unwrap_or(match available_space.width {
1827                                        AvailableSpace::Definite(x) => x,
1828                                        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1829                                            max_element_width
1830                                        }
1831                                    });
1832                            let height = match available_space.height {
1833                                AvailableSpace::Definite(height) => total_height.min(height),
1834                                AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1835                                    total_height
1836                                }
1837                            };
1838                            size(width, height)
1839                        },
1840                    )
1841                })
1842            }
1843            ListSizingBehavior::Auto => {
1844                let mut style = Style::default();
1845                style.refine(&self.style);
1846                window.with_text_style(style.text_style().cloned(), |window| {
1847                    window.request_layout(style, None, cx)
1848                })
1849            }
1850        };
1851        (layout_id, ())
1852    }
1853
1854    fn prepaint(
1855        &mut self,
1856        _id: Option<&GlobalElementId>,
1857        _inspector_id: Option<&InspectorElementId>,
1858        bounds: Bounds<Pixels>,
1859        _: &mut Self::RequestLayoutState,
1860        window: &mut Window,
1861        cx: &mut App,
1862    ) -> ListPrepaintState {
1863        let state = &mut *self.state.state_mut();
1864        state.reset = false;
1865
1866        let style = self.base_style();
1867        let content_mask = style.scroll_mask(bounds, window.rem_size());
1868
1869        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
1870
1871        // If the width of the list has changed, invalidate all cached item heights. A measured
1872        // height depends on the old width, but a hint given up front, such as the one from
1873        // `with_uniform_item_height`, doesn't, so unmeasured rows keep theirs.
1874        if state
1875            .last_layout_bounds
1876            .is_none_or(|last_bounds| last_bounds.size.width != bounds.size.width)
1877        {
1878            let new_items = SumTree::from_iter(
1879                state.items.iter().map(|item| ListItem::Unmeasured {
1880                    size_hint: match item {
1881                        ListItem::Unmeasured { size_hint, .. } => *size_hint,
1882                        ListItem::Measured { .. } => None,
1883                    },
1884                    focus_handle: item.focus_handle(),
1885                }),
1886                (),
1887            );
1888
1889            state.items = new_items;
1890            state.measuring_behavior.reset();
1891        }
1892
1893        let padding = style
1894            .padding
1895            .to_pixels(bounds.size.into(), window.rem_size());
1896        let layout = match state.prepaint_items(
1897            bounds,
1898            padding,
1899            content_mask,
1900            true,
1901            &mut self.render_item,
1902            window,
1903            cx,
1904        ) {
1905            Ok(layout) => layout,
1906            Err(autoscroll_request) => {
1907                state.logical_scroll_top = Some(autoscroll_request);
1908                state
1909                    .prepaint_items(
1910                        bounds,
1911                        padding,
1912                        content_mask,
1913                        false,
1914                        &mut self.render_item,
1915                        window,
1916                        cx,
1917                    )
1918                    .unwrap()
1919            }
1920        };
1921
1922        state.last_layout_bounds = Some(bounds);
1923        state.last_padding = Some(padding);
1924        ListPrepaintState { hitbox, layout }
1925    }
1926
1927    fn paint(
1928        &mut self,
1929        _id: Option<&GlobalElementId>,
1930        _inspector_id: Option<&InspectorElementId>,
1931        bounds: Bounds<crate::Pixels>,
1932        _: &mut Self::RequestLayoutState,
1933        prepaint: &mut Self::PrepaintState,
1934        window: &mut Window,
1935        cx: &mut App,
1936    ) {
1937        let current_view = window.current_view();
1938
1939        // Register the scroll listener before painting children so that, in
1940        // the bubble phase (which runs in reverse registration order),
1941        // children's scroll-wheel handlers run first and can stop propagation
1942        // to prevent the list from scrolling. This matches the ordering of
1943        // div-based scroll containers.
1944        let list_state = self.state.clone();
1945        let height = bounds.size.height;
1946        let scroll_top = prepaint.layout.scroll_top;
1947        let hitbox_id = prepaint.hitbox.id;
1948        let mut accumulated_scroll_delta = ScrollDelta::default();
1949        window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
1950            if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) {
1951                accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta);
1952                let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.));
1953                list_state.state_mut().scroll(
1954                    &scroll_top,
1955                    height,
1956                    pixel_delta,
1957                    current_view,
1958                    window,
1959                    cx,
1960                )
1961            }
1962        });
1963
1964        let style = self.base_style();
1965        window.with_content_mask(
1966            Some(style.scroll_mask(bounds, window.rem_size())),
1967            |window| {
1968                for item in &mut prepaint.layout.item_layouts {
1969                    item.element.paint(window, cx);
1970                }
1971            },
1972        );
1973    }
1974}
1975
1976impl IntoElement for List {
1977    type Element = Self;
1978
1979    fn into_element(self) -> Self::Element {
1980        self
1981    }
1982}
1983
1984impl Styled for List {
1985    fn style(&mut self) -> &mut StyleRefinement {
1986        &mut self.style
1987    }
1988}
1989
1990impl sum_tree::Item for ListItem {
1991    type Summary = ListItemSummary;
1992
1993    fn summary(&self, _: ()) -> Self::Summary {
1994        match self {
1995            ListItem::Unmeasured {
1996                size_hint,
1997                focus_handle,
1998            } => ListItemSummary {
1999                count: 1,
2000                rendered_count: 0,
2001                unrendered_count: 1,
2002                height: if let Some(size) = size_hint {
2003                    size.height
2004                } else {
2005                    px(0.)
2006                },
2007                has_focus_handles: focus_handle.is_some(),
2008                has_unknown_height: size_hint.is_none(),
2009            },
2010            ListItem::Measured {
2011                size, focus_handle, ..
2012            } => ListItemSummary {
2013                count: 1,
2014                rendered_count: 1,
2015                unrendered_count: 0,
2016                height: size.height,
2017                has_focus_handles: focus_handle.is_some(),
2018                has_unknown_height: false,
2019            },
2020        }
2021    }
2022}
2023
2024impl sum_tree::ContextLessSummary for ListItemSummary {
2025    fn zero() -> Self {
2026        Default::default()
2027    }
2028
2029    fn add_summary(&mut self, summary: &Self) {
2030        self.count += summary.count;
2031        self.rendered_count += summary.rendered_count;
2032        self.unrendered_count += summary.unrendered_count;
2033        self.height += summary.height;
2034        self.has_focus_handles |= summary.has_focus_handles;
2035        self.has_unknown_height |= summary.has_unknown_height;
2036    }
2037}
2038
2039impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Count {
2040    fn zero(_cx: ()) -> Self {
2041        Default::default()
2042    }
2043
2044    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
2045        self.0 += summary.count;
2046    }
2047}
2048
2049impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Height {
2050    fn zero(_cx: ()) -> Self {
2051        Default::default()
2052    }
2053
2054    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
2055        self.0 += summary.height;
2056    }
2057}
2058
2059impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Count {
2060    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
2061        self.0.partial_cmp(&other.count).unwrap()
2062    }
2063}
2064
2065impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Height {
2066    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
2067        self.0.partial_cmp(&other.height).unwrap()
2068    }
2069}
2070
2071#[cfg(test)]
2072mod test {
2073
2074    use gpui::{ScrollDelta, ScrollWheelEvent};
2075    use std::cell::{Cell, RefCell};
2076    use std::rc::Rc;
2077
2078    use crate::{
2079        self as gpui, AppContext, Bounds, Context, Element, FollowMode, InteractiveElement,
2080        IntoElement, ListState, Render, Styled, TestAppContext, Window, canvas, div, list, point,
2081        px, size,
2082    };
2083
2084    #[gpui::test]
2085    fn test_autoscroll_above_item_top_renders_items_above(cx: &mut TestAppContext) {
2086        let cx = cx.add_empty_window();
2087
2088        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
2089        state.scroll_to(gpui::ListOffset {
2090            item_ix: 2,
2091            offset_in_item: px(0.),
2092        });
2093
2094        struct TestView(ListState);
2095        impl Render for TestView {
2096            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2097                list(self.0.clone(), |ix, _, _| {
2098                    if ix == 2 {
2099                        // Request an autoscroll whose top sits 30px above item 2's
2100                        // own top, mimicking a scroll-margin overshoot.
2101                        canvas(
2102                            |bounds, window, _| {
2103                                window.request_autoscroll(Bounds::from_corners(
2104                                    point(bounds.left(), bounds.top() - px(30.)),
2105                                    point(bounds.right(), bounds.top() + px(5.)),
2106                                ));
2107                            },
2108                            |_, _, _, _| {},
2109                        )
2110                        .h(px(20.))
2111                        .w_full()
2112                        .into_any()
2113                    } else {
2114                        div().h(px(20.)).w_full().into_any()
2115                    }
2116                })
2117                .w_full()
2118                .h_full()
2119            }
2120        }
2121
2122        cx.draw(point(px(0.), px(0.)), size(px(100.), px(60.)), |_, cx| {
2123            cx.new(|_| TestView(state.clone())).into_any_element()
2124        });
2125
2126        // 30px above item 2's top, with 20px items, lands 10px into item 0.
2127        let scroll_top = state.logical_scroll_top();
2128        assert!(
2129            scroll_top.offset_in_item >= px(0.),
2130            "offset_in_item must never be negative (would leave blank space above), got {:?}",
2131            scroll_top.offset_in_item,
2132        );
2133        assert_eq!(scroll_top.item_ix, 0);
2134        assert_eq!(scroll_top.offset_in_item, px(10.));
2135    }
2136
2137    #[gpui::test]
2138    fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) {
2139        let cx = cx.add_empty_window();
2140
2141        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
2142
2143        // Ensure that the list is scrolled to the top
2144        state.scroll_to(gpui::ListOffset {
2145            item_ix: 0,
2146            offset_in_item: px(0.0),
2147        });
2148
2149        struct TestView(ListState);
2150        impl Render for TestView {
2151            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2152                list(self.0.clone(), |_, _, _| {
2153                    div().h(px(10.)).w_full().into_any()
2154                })
2155                .w_full()
2156                .h_full()
2157            }
2158        }
2159
2160        // Paint
2161        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2162            cx.new(|_| TestView(state.clone())).into_any_element()
2163        });
2164
2165        // Reset
2166        state.reset(5);
2167
2168        // And then receive a scroll event _before_ the next paint
2169        cx.simulate_event(ScrollWheelEvent {
2170            position: point(px(1.), px(1.)),
2171            delta: ScrollDelta::Pixels(point(px(0.), px(-500.))),
2172            ..Default::default()
2173        });
2174
2175        // Scroll position should stay at the top of the list
2176        assert_eq!(state.logical_scroll_top().item_ix, 0);
2177        assert_eq!(state.logical_scroll_top().offset_in_item, px(0.));
2178    }
2179
2180    #[gpui::test]
2181    fn test_uniform_item_height_survives_the_first_layout(cx: &mut TestAppContext) {
2182        let cx = cx.add_empty_window();
2183
2184        let state =
2185            ListState::new(10, crate::ListAlignment::Top, px(0.)).with_uniform_item_height(px(50.));
2186
2187        struct TestView(ListState);
2188        impl Render for TestView {
2189            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2190                list(self.0.clone(), |_, _, _| {
2191                    div().h(px(50.)).w_full().into_any()
2192                })
2193                .w_full()
2194                .h_full()
2195            }
2196        }
2197
2198        // Only the rows that fit in 200px are measured. The rest keep their hint.
2199        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
2200            cx.new(|_| TestView(state.clone())).into_any_element()
2201        });
2202        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2203
2204        // Revealing the last row scrolls it to the bottom edge, 300px down.
2205        state.scroll_to_reveal_item(9);
2206        let top = state.logical_scroll_top();
2207        assert_eq!(px(50. * top.item_ix as f32) + top.offset_in_item, px(300.));
2208    }
2209
2210    #[gpui::test]
2211    fn test_scroll_by_positive_and_negative_distance(cx: &mut TestAppContext) {
2212        let cx = cx.add_empty_window();
2213
2214        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
2215
2216        struct TestView(ListState);
2217        impl Render for TestView {
2218            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2219                list(self.0.clone(), |_, _, _| {
2220                    div().h(px(20.)).w_full().into_any()
2221                })
2222                .w_full()
2223                .h_full()
2224            }
2225        }
2226
2227        // Paint
2228        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
2229            cx.new(|_| TestView(state.clone())).into_any_element()
2230        });
2231
2232        // Test positive distance: start at item 1, move down 30px
2233        state.scroll_by(px(30.));
2234
2235        // Should move to item 2
2236        let offset = state.logical_scroll_top();
2237        assert_eq!(offset.item_ix, 1);
2238        assert_eq!(offset.offset_in_item, px(10.));
2239
2240        // Test negative distance: start at item 2, move up 30px
2241        state.scroll_by(px(-30.));
2242
2243        // Should move back to item 1
2244        let offset = state.logical_scroll_top();
2245        assert_eq!(offset.item_ix, 0);
2246        assert_eq!(offset.offset_in_item, px(0.));
2247
2248        // Test zero distance
2249        state.scroll_by(px(0.));
2250        let offset = state.logical_scroll_top();
2251        assert_eq!(offset.item_ix, 0);
2252        assert_eq!(offset.offset_in_item, px(0.));
2253    }
2254
2255    #[gpui::test]
2256    fn test_scroll_handler_reports_the_resulting_visible_range(cx: &mut TestAppContext) {
2257        struct TestView(ListState);
2258        impl Render for TestView {
2259            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2260                list(self.0.clone(), |_, _, _| {
2261                    div().h(px(20.)).w_full().into_any()
2262                })
2263                .w_full()
2264                .h_full()
2265            }
2266        }
2267
2268        for alignment in [crate::ListAlignment::Top, crate::ListAlignment::Bottom] {
2269            let cx = cx.add_empty_window();
2270            let state = ListState::new(5, alignment, px(0.)).measure_all();
2271            state.scroll_to(gpui::ListOffset {
2272                item_ix: 0,
2273                offset_in_item: px(0.),
2274            });
2275            let reported = Rc::new(RefCell::new(Vec::new()));
2276            state.set_scroll_handler({
2277                let reported = reported.clone();
2278                move |event, _, _| {
2279                    reported.borrow_mut().push((
2280                        event.visible_range.clone(),
2281                        event.count,
2282                        event.is_scrolled,
2283                    ));
2284                }
2285            });
2286            cx.draw(point(px(0.), px(0.)), size(px(100.), px(40.)), |_, cx| {
2287                cx.new(|_| TestView(state.clone())).into_any_element()
2288            });
2289
2290            // Scroll two items down, then past the end of the list.
2291            for distance in [-40., -1000.] {
2292                cx.simulate_event(ScrollWheelEvent {
2293                    position: point(px(50.), px(10.)),
2294                    delta: ScrollDelta::Pixels(point(px(0.), px(distance))),
2295                    ..Default::default()
2296                });
2297            }
2298
2299            assert_eq!(
2300                &*reported.borrow(),
2301                &[
2302                    (2..4, 5, true),
2303                    (3..5, 5, alignment == crate::ListAlignment::Top),
2304                ],
2305                "{alignment:?}: callbacks should report the viewport after the scroll"
2306            );
2307        }
2308    }
2309
2310    #[gpui::test]
2311    fn test_child_scroll_handler_can_stop_list_scroll(cx: &mut TestAppContext) {
2312        let cx = cx.add_empty_window();
2313
2314        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
2315        let child_saw_event = Rc::new(Cell::new(false));
2316
2317        struct TestView {
2318            state: ListState,
2319            child_saw_event: Rc<Cell<bool>>,
2320        }
2321        impl Render for TestView {
2322            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2323                let child_saw_event = self.child_saw_event.clone();
2324                list(self.state.clone(), move |_, _, _| {
2325                    let child_saw_event = child_saw_event.clone();
2326                    div()
2327                        .h(px(20.))
2328                        .w_full()
2329                        .on_scroll_wheel(move |_, _, cx| {
2330                            child_saw_event.set(true);
2331                            cx.stop_propagation();
2332                        })
2333                        .into_any()
2334                })
2335                .w_full()
2336                .h_full()
2337            }
2338        }
2339
2340        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
2341            cx.new(|_| TestView {
2342                state: state.clone(),
2343                child_saw_event: child_saw_event.clone(),
2344            })
2345            .into_any_element()
2346        });
2347
2348        cx.simulate_event(ScrollWheelEvent {
2349            position: point(px(50.), px(10.)),
2350            delta: ScrollDelta::Pixels(point(px(0.), px(-30.))),
2351            ..Default::default()
2352        });
2353
2354        assert!(
2355            child_saw_event.get(),
2356            "the child's scroll-wheel handler should run"
2357        );
2358        // The child stopped propagation, so the list must not have scrolled.
2359        let offset = state.logical_scroll_top();
2360        assert_eq!(offset.item_ix, 0);
2361        assert_eq!(offset.offset_in_item, px(0.));
2362    }
2363
2364    struct TestListView(ListState);
2365    impl Render for TestListView {
2366        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2367            list(self.0.clone(), |_, _, _| {
2368                div().h(px(20.)).w_full().into_any()
2369            })
2370            .w_full()
2371            .h_full()
2372        }
2373    }
2374
2375    #[gpui::test]
2376    fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) {
2377        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2378
2379        assert_eq!(state.item_is_above_viewport(0), None);
2380        assert_eq!(state.item_is_below_viewport(0), None);
2381    }
2382
2383    #[gpui::test]
2384    fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) {
2385        let cx = cx.add_empty_window();
2386
2387        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2388
2389        state.scroll_to(gpui::ListOffset {
2390            item_ix: 2,
2391            offset_in_item: px(0.),
2392        });
2393        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2394            cx.new(|_| TestListView(state.clone())).into_any_element()
2395        });
2396
2397        assert_eq!(state.item_is_above_viewport(1), Some(true));
2398        assert_eq!(state.item_is_below_viewport(1), Some(false));
2399    }
2400
2401    #[gpui::test]
2402    fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) {
2403        let cx = cx.add_empty_window();
2404
2405        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2406
2407        state.scroll_to(gpui::ListOffset {
2408            item_ix: 2,
2409            offset_in_item: px(0.),
2410        });
2411        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2412            cx.new(|_| TestListView(state.clone())).into_any_element()
2413        });
2414
2415        assert_eq!(state.item_is_above_viewport(2), Some(false));
2416        assert_eq!(state.item_is_below_viewport(2), Some(false));
2417    }
2418
2419    #[gpui::test]
2420    fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) {
2421        let cx = cx.add_empty_window();
2422
2423        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2424
2425        state.scroll_to(gpui::ListOffset {
2426            item_ix: 2,
2427            offset_in_item: px(20.),
2428        });
2429        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2430            cx.new(|_| TestListView(state.clone())).into_any_element()
2431        });
2432
2433        assert_eq!(state.item_is_above_viewport(2), Some(true));
2434        assert_eq!(state.item_is_below_viewport(2), Some(false));
2435    }
2436
2437    #[gpui::test]
2438    fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) {
2439        let cx = cx.add_empty_window();
2440
2441        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2442
2443        state.scroll_to(gpui::ListOffset {
2444            item_ix: 2,
2445            offset_in_item: px(0.),
2446        });
2447        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2448            cx.new(|_| TestListView(state.clone())).into_any_element()
2449        });
2450
2451        assert_eq!(state.item_is_above_viewport(3), Some(false));
2452        assert_eq!(state.item_is_below_viewport(3), Some(true));
2453    }
2454
2455    #[gpui::test]
2456    fn test_item_viewport_queries_remain_stable_with_zero_height_viewport(cx: &mut TestAppContext) {
2457        let cx = cx.add_empty_window();
2458
2459        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2460
2461        state.scroll_to(gpui::ListOffset {
2462            item_ix: 2,
2463            offset_in_item: px(0.),
2464        });
2465        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2466            cx.new(|_| TestListView(state.clone())).into_any_element()
2467        });
2468
2469        assert_eq!(state.item_is_above_viewport(3), Some(false));
2470        assert_eq!(state.item_is_below_viewport(3), Some(true));
2471
2472        // Squeeze the list to zero height, e.g. because a sibling element
2473        // (sized based on the queries above) consumed all the space. The
2474        // answers must remain definitive rather than becoming `None`,
2475        // otherwise the sibling's size can oscillate between frames.
2476        cx.draw(point(px(0.), px(0.)), size(px(100.), px(0.)), |_, cx| {
2477            cx.new(|_| TestListView(state.clone())).into_any_element()
2478        });
2479
2480        assert_eq!(state.item_is_above_viewport(1), Some(true));
2481        assert_eq!(state.item_is_below_viewport(1), Some(false));
2482        assert_eq!(state.item_is_above_viewport(3), Some(false));
2483        assert_eq!(state.item_is_below_viewport(3), Some(true));
2484    }
2485
2486    #[gpui::test]
2487    fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) {
2488        let cx = cx.add_empty_window();
2489
2490        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2491
2492        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2493            cx.new(|_| TestListView(state.clone())).into_any_element()
2494        });
2495
2496        state.scroll_to_end();
2497
2498        assert_eq!(state.logical_scroll_top().item_ix, state.item_count());
2499        assert_eq!(state.item_is_above_viewport(0), Some(true));
2500        assert_eq!(state.item_is_below_viewport(0), Some(false));
2501    }
2502
2503    #[gpui::test]
2504    fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
2505        let cx = cx.add_empty_window();
2506
2507        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2508
2509        struct TestView(ListState);
2510        impl Render for TestView {
2511            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2512                list(self.0.clone(), |_, _, _| {
2513                    div().h(px(50.)).w_full().into_any()
2514                })
2515                .w_full()
2516                .h_full()
2517            }
2518        }
2519
2520        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2521
2522        // First draw at width 100: all 10 items measured (total 500px).
2523        // Viewport is 200px, so max scroll offset should be 300px.
2524        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2525            view.clone().into_any_element()
2526        });
2527        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2528
2529        // Second draw at a different width: items get invalidated.
2530        // Without the fix, max_offset would drop because unmeasured items
2531        // contribute 0 height.
2532        cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| {
2533            view.into_any_element()
2534        });
2535        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2536    }
2537
2538    #[gpui::test]
2539    fn test_remeasure(cx: &mut TestAppContext) {
2540        let cx = cx.add_empty_window();
2541
2542        // Create a list with 10 items, each 100px tall. We'll keep a reference
2543        // to the item height so we can later change the height and assert how
2544        // `ListState` handles it.
2545        let item_height = Rc::new(Cell::new(100usize));
2546        let state = ListState::new(10, crate::ListAlignment::Top, px(10.));
2547
2548        struct TestView {
2549            state: ListState,
2550            item_height: Rc<Cell<usize>>,
2551        }
2552
2553        impl Render for TestView {
2554            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2555                let height = self.item_height.get();
2556                list(self.state.clone(), move |_, _, _| {
2557                    div().h(px(height as f32)).w_full().into_any()
2558                })
2559                .w_full()
2560                .h_full()
2561            }
2562        }
2563
2564        let state_clone = state.clone();
2565        let item_height_clone = item_height.clone();
2566        let view = cx.update(|_, cx| {
2567            cx.new(|_| TestView {
2568                state: state_clone,
2569                item_height: item_height_clone,
2570            })
2571        });
2572
2573        // Simulate scrolling 40px inside the element with index 2. Since the
2574        // original item height is 100px, this equates to 40% inside the item.
2575        state.scroll_to(gpui::ListOffset {
2576            item_ix: 2,
2577            offset_in_item: px(40.),
2578        });
2579
2580        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2581            view.clone().into_any_element()
2582        });
2583
2584        let offset = state.logical_scroll_top();
2585        assert_eq!(offset.item_ix, 2);
2586        assert_eq!(offset.offset_in_item, px(40.));
2587
2588        // Update the `item_height` to be 50px instead of 100px so we can assert
2589        // that the scroll position is proportionally preserved, that is,
2590        // instead of 40px from the top of item 2, it should be 20px, since the
2591        // item's height has been halved.
2592        item_height.set(50);
2593        state.remeasure();
2594
2595        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2596            view.into_any_element()
2597        });
2598
2599        let offset = state.logical_scroll_top();
2600        assert_eq!(offset.item_ix, 2);
2601        assert_eq!(offset.offset_in_item, px(20.));
2602    }
2603
2604    #[gpui::test]
2605    fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) {
2606        let cx = cx.add_empty_window();
2607
2608        let item_height = Rc::new(Cell::new(100usize));
2609        let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
2610
2611        struct TestView {
2612            state: ListState,
2613            item_height: Rc<Cell<usize>>,
2614        }
2615
2616        impl Render for TestView {
2617            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2618                let height = self.item_height.get();
2619                list(self.state.clone(), move |index, _, _| {
2620                    let height = if index == 5 { height } else { 100 };
2621                    div().h(px(height as f32)).w_full().into_any()
2622                })
2623                .w_full()
2624                .h_full()
2625            }
2626        }
2627
2628        let state_clone = state.clone();
2629        let item_height_clone = item_height.clone();
2630        let view = cx.update(|_, cx| {
2631            cx.new(|_| TestView {
2632                state: state_clone,
2633                item_height: item_height_clone,
2634            })
2635        });
2636
2637        state.scroll_to(gpui::ListOffset {
2638            item_ix: 5,
2639            offset_in_item: px(40.),
2640        });
2641
2642        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2643            view.clone().into_any_element()
2644        });
2645
2646        item_height.set(200);
2647        state.remeasure_items(5..6);
2648
2649        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2650            view.into_any_element()
2651        });
2652
2653        let offset = state.logical_scroll_top();
2654        assert_eq!(offset.item_ix, 5);
2655        assert_eq!(offset.offset_in_item, px(40.));
2656    }
2657
2658    #[gpui::test]
2659    fn test_remeasure_then_scroll_does_not_revert_scroll_position(cx: &mut TestAppContext) {
2660        let cx = cx.add_empty_window();
2661
2662        let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
2663
2664        struct TestView(ListState);
2665        impl Render for TestView {
2666            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2667                list(self.0.clone(), |_, _, _| {
2668                    div().h(px(100.)).w_full().into_any()
2669                })
2670                .w_full()
2671                .h_full()
2672            }
2673        }
2674
2675        let view = {
2676            let state = state.clone();
2677            cx.update(|_, cx| cx.new(|_| TestView(state)))
2678        };
2679
2680        state.scroll_to(gpui::ListOffset {
2681            item_ix: 5,
2682            offset_in_item: px(40.),
2683        });
2684
2685        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2686            view.clone().into_any_element()
2687        });
2688
2689        state.remeasure_items(5..6);
2690
2691        cx.simulate_event(ScrollWheelEvent {
2692            position: point(px(50.), px(100.)),
2693            delta: ScrollDelta::Pixels(point(px(0.), px(-30.))),
2694            ..Default::default()
2695        });
2696
2697        let offset = state.logical_scroll_top();
2698        assert_eq!(offset.item_ix, 5);
2699        assert_eq!(offset.offset_in_item, px(70.));
2700
2701        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2702            view.into_any_element()
2703        });
2704
2705        let offset = state.logical_scroll_top();
2706        assert_eq!(offset.item_ix, 5);
2707        assert_eq!(
2708            offset.offset_in_item,
2709            px(70.),
2710            "scrolling after a remeasure should not be reverted by the stale pending scroll"
2711        );
2712    }
2713
2714    #[gpui::test]
2715    fn test_scroll_after_remeasure_clamps_to_shrunk_item_height(cx: &mut TestAppContext) {
2716        let cx = cx.add_empty_window();
2717
2718        let item_height = Rc::new(Cell::new(100usize));
2719        let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
2720
2721        struct TestView {
2722            state: ListState,
2723            item_height: Rc<Cell<usize>>,
2724        }
2725
2726        impl Render for TestView {
2727            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2728                let height = self.item_height.get();
2729                list(self.state.clone(), move |index, _, _| {
2730                    let height = if index == 5 { height } else { 100 };
2731                    div().h(px(height as f32)).w_full().into_any()
2732                })
2733                .w_full()
2734                .h_full()
2735            }
2736        }
2737
2738        let view = {
2739            let state = state.clone();
2740            let item_height = item_height.clone();
2741            cx.update(|_, cx| cx.new(|_| TestView { state, item_height }))
2742        };
2743
2744        state.scroll_to(gpui::ListOffset {
2745            item_ix: 5,
2746            offset_in_item: px(40.),
2747        });
2748
2749        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2750            view.clone().into_any_element()
2751        });
2752
2753        // Item 5 shrinks from 100px to 50px and is remeasured...
2754        item_height.set(50);
2755        state.remeasure_items(5..6);
2756
2757        // ...and then the user scrolls down by 30px before the next frame,
2758        // landing at offset 70.
2759        cx.simulate_event(ScrollWheelEvent {
2760            position: point(px(50.), px(100.)),
2761            delta: ScrollDelta::Pixels(point(px(0.), px(-30.))),
2762            ..Default::default()
2763        });
2764
2765        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2766            view.into_any_element()
2767        });
2768
2769        // The rebased pending scroll clamps the user's offset to the item's
2770        // new height instead of leaving it pointing past the end of the item.
2771        let offset = state.logical_scroll_top();
2772        assert_eq!(offset.item_ix, 5);
2773        assert_eq!(offset.offset_in_item, px(50.));
2774    }
2775
2776    #[gpui::test]
2777    fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
2778        let cx = cx.add_empty_window();
2779
2780        // 10 items, each 50px tall → 500px total content, 200px viewport.
2781        // With follow-tail on, the list should always show the bottom.
2782        let item_height = Rc::new(Cell::new(50usize));
2783        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2784
2785        struct TestView {
2786            state: ListState,
2787            item_height: Rc<Cell<usize>>,
2788        }
2789        impl Render for TestView {
2790            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2791                let height = self.item_height.get();
2792                list(self.state.clone(), move |_, _, _| {
2793                    div().h(px(height as f32)).w_full().into_any()
2794                })
2795                .w_full()
2796                .h_full()
2797            }
2798        }
2799
2800        let state_clone = state.clone();
2801        let item_height_clone = item_height.clone();
2802        let view = cx.update(|_, cx| {
2803            cx.new(|_| TestView {
2804                state: state_clone,
2805                item_height: item_height_clone,
2806            })
2807        });
2808
2809        state.set_follow_mode(FollowMode::Tail);
2810
2811        // First paint — items are 50px, total 500px, viewport 200px.
2812        // Follow-tail should anchor to the end.
2813        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2814            view.clone().into_any_element()
2815        });
2816
2817        // The scroll should be at the bottom: the last visible items fill the
2818        // 200px viewport from the end of 500px of content (offset 300px).
2819        let offset = state.logical_scroll_top();
2820        assert_eq!(offset.item_ix, 6);
2821        assert_eq!(offset.offset_in_item, px(0.));
2822        assert!(state.is_following_tail());
2823
2824        // Simulate items growing (e.g. streaming content makes each item taller).
2825        // 10 items × 80px = 800px total.
2826        item_height.set(80);
2827        state.remeasure();
2828
2829        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2830            view.into_any_element()
2831        });
2832
2833        // After growth, follow-tail should have re-anchored to the new end.
2834        // 800px total − 200px viewport = 600px offset → item 7 at offset 40px,
2835        // but follow-tail anchors to item_count (10), and layout walks back to
2836        // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200,
2837        // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking →
2838        // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40).
2839        let offset = state.logical_scroll_top();
2840        assert_eq!(offset.item_ix, 7);
2841        assert_eq!(offset.offset_in_item, px(40.));
2842        assert!(state.is_following_tail());
2843    }
2844
2845    #[gpui::test]
2846    fn test_pause_following_tail_reengages_when_still_at_bottom(cx: &mut TestAppContext) {
2847        let cx = cx.add_empty_window();
2848
2849        // 10 items × 50px = 500px total, 200px viewport.
2850        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2851
2852        struct TestView(ListState);
2853        impl Render for TestView {
2854            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2855                list(self.0.clone(), |_, _, _| {
2856                    div().h(px(50.)).w_full().into_any()
2857                })
2858                .w_full()
2859                .h_full()
2860            }
2861        }
2862
2863        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2864        state.set_follow_mode(FollowMode::Tail);
2865
2866        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2867            view.clone().into_any_element()
2868        });
2869        assert!(state.is_following_tail());
2870
2871        // Pausing while the view is still at the bottom (e.g. a no-op zoom)
2872        // must not strand follow-tail: the next layout has to re-engage so the
2873        // invariant "at the bottom + new content => visible" is preserved.
2874        state.pause_following_tail();
2875        assert!(!state.is_following_tail());
2876
2877        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2878            view.into_any_element()
2879        });
2880        assert!(
2881            state.is_following_tail(),
2882            "pausing while at the bottom must re-engage follow-tail on the next layout"
2883        );
2884    }
2885
2886    #[gpui::test]
2887    fn test_pause_following_tail_freezes_off_bottom(cx: &mut TestAppContext) {
2888        let cx = cx.add_empty_window();
2889
2890        // 10 items, 200px viewport. Item height is adjustable to simulate a
2891        // diagram block growing/shrinking on zoom.
2892        let item_height = Rc::new(Cell::new(50usize));
2893        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2894
2895        struct TestView {
2896            state: ListState,
2897            item_height: Rc<Cell<usize>>,
2898        }
2899        impl Render for TestView {
2900            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2901                let height = self.item_height.get();
2902                list(self.state.clone(), move |_, _, _| {
2903                    div().h(px(height as f32)).w_full().into_any()
2904                })
2905                .w_full()
2906                .h_full()
2907            }
2908        }
2909
2910        let view = cx.update(|_, cx| {
2911            cx.new(|_| TestView {
2912                state: state.clone(),
2913                item_height: item_height.clone(),
2914            })
2915        });
2916        state.set_follow_mode(FollowMode::Tail);
2917
2918        // At the bottom: 500px content, 200px viewport → top at item 6.
2919        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2920            view.clone().into_any_element()
2921        });
2922        assert_eq!(state.logical_scroll_top().item_ix, 6);
2923        assert!(state.is_following_tail());
2924
2925        // Pause, then grow items (a zoom-in that pushes content below the fold).
2926        // The frozen top must stay put rather than snapping to the new end, and
2927        // following stays paused since we're no longer at the bottom.
2928        state.pause_following_tail();
2929        item_height.set(80);
2930        state.remeasure();
2931        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2932            view.clone().into_any_element()
2933        });
2934        let offset = state.logical_scroll_top();
2935        assert_eq!(offset.item_ix, 6);
2936        assert_eq!(offset.offset_in_item, px(0.));
2937        assert!(
2938            !state.is_following_tail(),
2939            "a paused list must not re-engage while the frozen top is off the bottom"
2940        );
2941
2942        // Shrink back (zoom-out) so the frozen top once again reaches the
2943        // bottom: follow-tail must re-engage on its own.
2944        item_height.set(50);
2945        state.remeasure();
2946        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2947            view.into_any_element()
2948        });
2949        assert!(
2950            state.is_following_tail(),
2951            "returning to the bottom must restore follow-tail"
2952        );
2953    }
2954
2955    #[gpui::test]
2956    fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) {
2957        let cx = cx.add_empty_window();
2958
2959        // 10 items × 50px = 500px total, 200px viewport.
2960        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2961
2962        struct TestView(ListState);
2963        impl Render for TestView {
2964            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2965                list(self.0.clone(), |_, _, _| {
2966                    div().h(px(50.)).w_full().into_any()
2967                })
2968                .w_full()
2969                .h_full()
2970            }
2971        }
2972
2973        state.set_follow_mode(FollowMode::Tail);
2974
2975        // Paint with follow-tail — scroll anchored to the bottom.
2976        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
2977            cx.new(|_| TestView(state.clone())).into_any_element()
2978        });
2979        assert!(state.is_following_tail());
2980
2981        // Simulate the user scrolling up.
2982        // This should disengage follow-tail.
2983        cx.simulate_event(ScrollWheelEvent {
2984            position: point(px(50.), px(100.)),
2985            delta: ScrollDelta::Pixels(point(px(0.), px(100.))),
2986            ..Default::default()
2987        });
2988
2989        assert!(
2990            !state.is_following_tail(),
2991            "follow-tail should disengage when the user scrolls toward the start"
2992        );
2993    }
2994
2995    #[gpui::test]
2996    fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) {
2997        let cx = cx.add_empty_window();
2998
2999        // 10 items × 50px = 500px total, 200px viewport.
3000        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3001
3002        struct TestView(ListState);
3003        impl Render for TestView {
3004            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3005                list(self.0.clone(), |_, _, _| {
3006                    div().h(px(50.)).w_full().into_any()
3007                })
3008                .w_full()
3009                .h_full()
3010            }
3011        }
3012
3013        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3014
3015        state.set_follow_mode(FollowMode::Tail);
3016
3017        // Paint with follow-tail — scroll anchored to the bottom.
3018        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3019            view.clone().into_any_element()
3020        });
3021        assert!(state.is_following_tail());
3022
3023        // Simulate the scrollbar moving the viewport to the middle.
3024        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
3025
3026        let offset = state.logical_scroll_top();
3027        assert_eq!(offset.item_ix, 3);
3028        assert_eq!(offset.offset_in_item, px(0.));
3029        assert!(
3030            !state.is_following_tail(),
3031            "follow-tail should disengage when the scrollbar manually repositions the list"
3032        );
3033
3034        // A subsequent draw should preserve the user's manual position instead
3035        // of snapping back to the end.
3036        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3037            view.into_any_element()
3038        });
3039
3040        let offset = state.logical_scroll_top();
3041        assert_eq!(offset.item_ix, 3);
3042        assert_eq!(offset.offset_in_item, px(0.));
3043    }
3044
3045    #[gpui::test]
3046    fn test_scrollbar_drag_with_growing_content(cx: &mut TestAppContext) {
3047        let cx = cx.add_empty_window();
3048
3049        let last_item_height = Rc::new(Cell::new(50usize));
3050        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3051
3052        struct TestView {
3053            state: ListState,
3054            last_item_height: Rc<Cell<usize>>,
3055        }
3056        impl Render for TestView {
3057            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3058                let last_item_height = self.last_item_height.clone();
3059                list(self.state.clone(), move |index, _, _| {
3060                    let height = if index == 9 {
3061                        last_item_height.get()
3062                    } else {
3063                        50
3064                    };
3065                    div().h(px(height as f32)).w_full().into_any()
3066                })
3067                .w_full()
3068                .h_full()
3069            }
3070        }
3071
3072        let view = cx.update(|_, cx| {
3073            cx.new(|_| TestView {
3074                state: state.clone(),
3075                last_item_height: last_item_height.clone(),
3076            })
3077        });
3078
3079        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3080            view.clone().into_any_element()
3081        });
3082
3083        state.scrollbar_drag_started();
3084
3085        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
3086        let scrollbar_offset_before_growth = state.scroll_px_offset_for_scrollbar();
3087
3088        let offset = state.logical_scroll_top();
3089        assert_eq!(offset.item_ix, 3);
3090        assert_eq!(offset.offset_in_item, px(0.));
3091
3092        last_item_height.set(550);
3093        state.remeasure_items(9..10);
3094        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3095            view.clone().into_any_element()
3096        });
3097
3098        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
3099        assert_eq!(
3100            state.scroll_px_offset_for_scrollbar(),
3101            scrollbar_offset_before_growth
3102        );
3103
3104        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
3105        let offset = state.logical_scroll_top();
3106        assert_eq!(offset.item_ix, 3);
3107        assert_eq!(offset.offset_in_item, px(0.));
3108    }
3109
3110    #[gpui::test]
3111    fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) {
3112        let cx = cx.add_empty_window();
3113
3114        // 10 items × 50px = 500px total, 200px viewport.
3115        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
3116
3117        struct TestView(ListState);
3118        impl Render for TestView {
3119            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3120                list(self.0.clone(), |_, _, _| {
3121                    div().h(px(50.)).w_full().into_any()
3122                })
3123                .w_full()
3124                .h_full()
3125            }
3126        }
3127
3128        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3129
3130        // Scroll to the middle of the list (item 3).
3131        state.scroll_to(gpui::ListOffset {
3132            item_ix: 3,
3133            offset_in_item: px(0.),
3134        });
3135
3136        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3137            view.clone().into_any_element()
3138        });
3139
3140        let offset = state.logical_scroll_top();
3141        assert_eq!(offset.item_ix, 3);
3142        assert_eq!(offset.offset_in_item, px(0.));
3143        assert!(!state.is_following_tail());
3144
3145        // Enable follow-tail — this should immediately snap the scroll anchor
3146        // to the end, like the user just sent a prompt.
3147        state.set_follow_mode(FollowMode::Tail);
3148
3149        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3150            view.into_any_element()
3151        });
3152
3153        // After paint, scroll should be at the bottom.
3154        // 500px total − 200px viewport = 300px offset → item 6, offset 0.
3155        let offset = state.logical_scroll_top();
3156        assert_eq!(offset.item_ix, 6);
3157        assert_eq!(offset.offset_in_item, px(0.));
3158        assert!(state.is_following_tail());
3159    }
3160
3161    #[gpui::test]
3162    fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) {
3163        let cx = cx.add_empty_window();
3164
3165        const ITEMS: usize = 10;
3166        const ITEM_SIZE: f32 = 50.0;
3167
3168        let state = ListState::new(
3169            ITEMS,
3170            crate::ListAlignment::Bottom,
3171            px(ITEMS as f32 * ITEM_SIZE),
3172        );
3173
3174        struct TestView(ListState);
3175        impl Render for TestView {
3176            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3177                list(self.0.clone(), |_, _, _| {
3178                    div().h(px(ITEM_SIZE)).w_full().into_any()
3179                })
3180                .w_full()
3181                .h_full()
3182            }
3183        }
3184
3185        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
3186            cx.new(|_| TestView(state.clone())).into_any_element()
3187        });
3188
3189        // Bottom-aligned lists start pinned to the end: logical_scroll_top returns
3190        // item_ix == item_count, meaning no explicit scroll position has been set.
3191        assert_eq!(state.logical_scroll_top().item_ix, ITEMS);
3192
3193        let max_offset = state.max_offset_for_scrollbar();
3194        let scroll_offset = state.scroll_px_offset_for_scrollbar();
3195
3196        assert_eq!(
3197            -scroll_offset.y, max_offset.y,
3198            "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom",
3199            -scroll_offset.y, max_offset.y,
3200        );
3201    }
3202
3203    /// When the user scrolls away from the bottom during follow_tail,
3204    /// follow_tail suspends. If they scroll back to the bottom, the
3205    /// next paint should re-engage follow_tail using fresh measurements.
3206    #[gpui::test]
3207    fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) {
3208        let cx = cx.add_empty_window();
3209
3210        // 10 items × 50px = 500px total, 200px viewport.
3211        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
3212
3213        struct TestView(ListState);
3214        impl Render for TestView {
3215            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3216                list(self.0.clone(), |_, _, _| {
3217                    div().h(px(50.)).w_full().into_any()
3218                })
3219                .w_full()
3220                .h_full()
3221            }
3222        }
3223
3224        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3225
3226        state.set_follow_mode(FollowMode::Tail);
3227
3228        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3229            view.clone().into_any_element()
3230        });
3231        assert!(state.is_following_tail());
3232
3233        // Scroll up — follow_tail should suspend (not fully disengage).
3234        cx.simulate_event(ScrollWheelEvent {
3235            position: point(px(50.), px(100.)),
3236            delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
3237            ..Default::default()
3238        });
3239        assert!(!state.is_following_tail());
3240
3241        // Scroll back down to the bottom.
3242        cx.simulate_event(ScrollWheelEvent {
3243            position: point(px(50.), px(100.)),
3244            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
3245            ..Default::default()
3246        });
3247
3248        // After a paint, follow_tail should re-engage because the
3249        // layout confirmed we're at the true bottom.
3250        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3251            view.clone().into_any_element()
3252        });
3253        assert!(
3254            state.is_following_tail(),
3255            "follow_tail should re-engage after scrolling back to the bottom"
3256        );
3257    }
3258
3259    /// When an item is spliced to unmeasured (0px) while follow_tail
3260    /// is suspended, the re-engagement check should still work correctly
3261    #[gpui::test]
3262    fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) {
3263        let cx = cx.add_empty_window();
3264
3265        // 20 items × 50px = 1000px total, 200px viewport, 1000px
3266        // overdraw so all items get measured during the follow_tail
3267        // paint (matching realistic production settings).
3268        let state = ListState::new(20, crate::ListAlignment::Top, px(1000.));
3269
3270        struct TestView(ListState);
3271        impl Render for TestView {
3272            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3273                list(self.0.clone(), |_, _, _| {
3274                    div().h(px(50.)).w_full().into_any()
3275                })
3276                .w_full()
3277                .h_full()
3278            }
3279        }
3280
3281        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3282
3283        state.set_follow_mode(FollowMode::Tail);
3284
3285        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3286            view.clone().into_any_element()
3287        });
3288        assert!(state.is_following_tail());
3289
3290        // Scroll up a meaningful amount — suspends follow_tail.
3291        // 20 items × 50px = 1000px. viewport 200px. scroll_max = 800px.
3292        // Scrolling up 200px puts us at 600px, clearly not at bottom.
3293        cx.simulate_event(ScrollWheelEvent {
3294            position: point(px(50.), px(100.)),
3295            delta: ScrollDelta::Pixels(point(px(0.), px(200.))),
3296            ..Default::default()
3297        });
3298        assert!(!state.is_following_tail());
3299
3300        // Invalidate the last item (simulates EntryUpdated calling
3301        // remeasure_items). This makes items.summary().height
3302        // temporarily wrong (0px for the invalidated item).
3303        state.remeasure_items(19..20);
3304
3305        // Paint — layout re-measures the invalidated item with its true
3306        // height. The re-engagement check uses these fresh measurements.
3307        // Since we scrolled 200px up from the 800px max, we're at
3308        // ~600px — NOT at the bottom, so follow_tail should NOT
3309        // re-engage.
3310        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3311            view.clone().into_any_element()
3312        });
3313        assert!(
3314            !state.is_following_tail(),
3315            "follow_tail should not falsely re-engage due to an unmeasured item \
3316             reducing items.summary().height"
3317        );
3318    }
3319
3320    #[gpui::test]
3321    fn test_follow_tail_reengages_after_scrollbar_disengagement(cx: &mut TestAppContext) {
3322        let cx = cx.add_empty_window();
3323
3324        // 10 items × 50px = 500px total, 200px viewport, scroll_max = 300px.
3325        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3326
3327        struct TestView(ListState);
3328        impl Render for TestView {
3329            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3330                list(self.0.clone(), |_, _, _| {
3331                    div().h(px(50.)).w_full().into_any()
3332                })
3333                .w_full()
3334                .h_full()
3335            }
3336        }
3337
3338        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3339
3340        state.set_follow_mode(FollowMode::Tail);
3341        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3342            view.clone().into_any_element()
3343        });
3344        assert!(state.is_following_tail());
3345
3346        // Drag the scrollbar up to the middle — follow_tail should suspend.
3347        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
3348        assert!(!state.is_following_tail());
3349
3350        // Drag the scrollbar back to the bottom — follow_tail should re-engage
3351        // on the next paint.
3352        state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
3353        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3354            view.into_any_element()
3355        });
3356        assert!(
3357            state.is_following_tail(),
3358            "follow_tail should re-engage after scrolling back to the bottom via the scrollbar"
3359        );
3360    }
3361
3362    #[gpui::test]
3363    fn test_follow_tail_reengages_after_scrollbar_drag_to_bottom_while_growing(
3364        cx: &mut TestAppContext,
3365    ) {
3366        let cx = cx.add_empty_window();
3367
3368        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3369
3370        struct TestView(ListState);
3371        impl Render for TestView {
3372            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3373                list(self.0.clone(), |_, _, _| {
3374                    div().h(px(50.)).w_full().into_any()
3375                })
3376                .w_full()
3377                .h_full()
3378            }
3379        }
3380
3381        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3382
3383        state.set_follow_mode(FollowMode::Tail);
3384        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3385            view.clone().into_any_element()
3386        });
3387        assert!(state.is_following_tail());
3388
3389        state.scrollbar_drag_started();
3390
3391        state.splice(10..10, 10);
3392        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3393            view.clone().into_any_element()
3394        });
3395
3396        state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
3397        state.scrollbar_drag_ended();
3398
3399        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3400            view.into_any_element()
3401        });
3402
3403        assert!(
3404            state.is_following_tail(),
3405            "follow_tail should re-engage when the user drags the scrollbar to \
3406             the bottom of its track, even when content has grown during the drag \
3407             (so frozen_bottom < live_bottom)"
3408        );
3409    }
3410}