Skip to main content

gpui/elements/
list.rs

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