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