Skip to main content

open_gpui/elements/
list.rs

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