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    Along as _, AnyElement, App, AvailableSpace, Axis, Bounds, ContentMask, DispatchPhase, Edges,
12    Element, EntityId, FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
13    IntoElement, Overflow, Pixels, Point, ScrollDelta, ScrollEvent, Size, Style, StyleRefinement,
14    Styled, 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 padding = state.last_padding.unwrap_or_default();
718        let scroll_top = state.logical_scroll_top();
719        if ix < scroll_top.item_ix {
720            return None;
721        }
722
723        let mut cursor = state.items.cursor::<Dimensions<Count, Height>>(());
724        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
725
726        let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item;
727
728        cursor.seek_forward(&Count(ix), Bias::Right);
729        if let Some(&ListItem::Measured { size, .. }) = cursor.item() {
730            let &Dimensions(Count(count), Height(top), _) = cursor.start();
731            if count == ix {
732                let top = bounds.top() + top - scroll_top;
733                return Some(Bounds::from_corners(
734                    point(bounds.left() + padding.left, top),
735                    point(bounds.right() - padding.right, top + size.height),
736                ));
737            }
738        }
739        None
740    }
741
742    /// Call this method when the user starts dragging the scrollbar.
743    ///
744    /// This will prevent the height reported to the scrollbar from changing during the drag
745    /// as items in the overdraw get measured, and help offset scroll position changes accordingly.
746    pub fn scrollbar_drag_started(&self) {
747        let mut state = self.0.borrow_mut();
748        state.scrollbar_drag_start_height = Some(state.items.summary().height);
749    }
750
751    /// Called when the user stops dragging the scrollbar.
752    ///
753    /// See `scrollbar_drag_started`.
754    pub fn scrollbar_drag_ended(&self) {
755        self.0.borrow_mut().scrollbar_drag_start_height.take();
756    }
757
758    /// Returns `true` if the scrollbar is currently being dragged.
759    ///
760    /// This is set between [`scrollbar_drag_started`](Self::scrollbar_drag_started)
761    /// and [`scrollbar_drag_ended`](Self::scrollbar_drag_ended) calls. Useful for
762    /// consumers that need to distinguish scrollbar drags from wheel/trackpad scrolls,
763    /// e.g. to suppress auto-scroll behavior during manual positioning.
764    pub fn is_scrollbar_dragging(&self) -> bool {
765        self.0.borrow().scrollbar_drag_start_height.is_some()
766    }
767
768    /// Set the offset from the scrollbar
769    pub fn set_offset_from_scrollbar(&self, point: Point<Pixels>) {
770        self.0.borrow_mut().set_offset_from_scrollbar(point);
771    }
772
773    /// Returns the maximum scroll offset according to the items we have measured.
774    /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly.
775    pub fn max_offset_for_scrollbar(&self) -> Point<Pixels> {
776        let state = self.0.borrow();
777        point(Pixels::ZERO, state.max_scroll_offset())
778    }
779
780    /// Returns the current scroll offset adjusted for the scrollbar.
781    ///
782    /// The returned offset has a negative `y` component representing
783    /// how far the content has scrolled.
784    pub fn scroll_px_offset_for_scrollbar(&self) -> Point<Pixels> {
785        let state = &self.0.borrow();
786
787        if state.scroll_anchor.is_none() && state.alignment == ListAlignment::Bottom {
788            return Point::new(px(0.), -state.max_scroll_offset());
789        }
790
791        let logical_scroll_top = state.logical_scroll_top();
792
793        let mut cursor = state.items.cursor::<ListItemSummary>(());
794        let summary: ListItemSummary =
795            cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right);
796        let offset = summary.height + logical_scroll_top.offset_in_item;
797
798        Point::new(px(0.), -offset)
799    }
800
801    /// Return the bounds of the viewport in pixels.
802    pub fn viewport_bounds(&self) -> Bounds<Pixels> {
803        self.0.borrow().last_layout_bounds.unwrap_or_default()
804    }
805
806    /// Returns whether the item is entirely above the viewport, or `None` if
807    /// the list has not measured enough layout to know.
808    ///
809    /// A zero-height viewport still yields a definitive answer: callers may
810    /// size sibling UI based on this query (potentially squeezing the list
811    /// itself to zero height), so returning `None` in that case would make
812    /// the answer oscillate from frame to frame.
813    pub fn item_is_above_viewport(&self, ix: usize) -> Option<bool> {
814        let viewport_bounds = self.0.borrow().last_layout_bounds?;
815
816        let scroll_top = self.logical_scroll_top();
817        if ix < scroll_top.item_ix {
818            // Rows before the logical scroll top have no item bounds, but
819            // their position relative to the viewport is known from scroll state.
820            return Some(true);
821        }
822
823        let item_bounds = self.bounds_for_item(ix)?;
824        Some(item_bounds.bottom() <= viewport_bounds.top())
825    }
826
827    /// Returns whether the item is entirely below the viewport, or `None` if
828    /// the list has not measured enough layout to know.
829    ///
830    /// See [`Self::item_is_above_viewport`] for why a zero-height viewport
831    /// still yields a definitive answer.
832    pub fn item_is_below_viewport(&self, ix: usize) -> Option<bool> {
833        let viewport_bounds = self.0.borrow().last_layout_bounds?;
834
835        let scroll_top = self.logical_scroll_top();
836        if ix < scroll_top.item_ix {
837            // Rows before the logical scroll top have no item bounds, but
838            // their position relative to the viewport is known from scroll state.
839            return Some(false);
840        }
841
842        let item_bounds = self.bounds_for_item(ix)?;
843        Some(item_bounds.top() >= viewport_bounds.bottom())
844    }
845}
846
847impl StateInner {
848    fn max_scroll_offset(&self) -> Pixels {
849        let bounds = self.last_layout_bounds.unwrap_or_default();
850        let height = self
851            .scrollbar_drag_start_height
852            .unwrap_or_else(|| self.items.summary().height);
853        (height - bounds.size.height).max(px(0.))
854    }
855
856    fn visible_range(
857        items: &SumTree<ListItem>,
858        height: Pixels,
859        scroll_top: &ListOffset,
860    ) -> Range<usize> {
861        let mut cursor = items.cursor::<ListItemSummary>(());
862        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
863        let start_y = cursor.start().height + scroll_top.offset_in_item;
864        cursor.seek_forward(&Height(start_y + height), Bias::Left);
865        scroll_top.item_ix..cursor.start().count + 1
866    }
867
868    fn scroll(
869        &mut self,
870        scroll_top: &ListOffset,
871        height: Pixels,
872        delta: Point<Pixels>,
873        current_view: EntityId,
874        window: &mut Window,
875        cx: &mut App,
876    ) {
877        // Drop scroll events after a reset, since we can't calculate
878        // the new logical scroll top without the item heights
879        if self.reset || delta.y == px(0.) {
880            return;
881        }
882
883        let padding = self.last_padding.unwrap_or_default();
884        let scroll_max =
885            (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
886        let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
887            .max(px(0.))
888            .min(scroll_max);
889
890        if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
891            self.set_scroll_anchor(None);
892        } else {
893            let (start, ..) =
894                self.items
895                    .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
896            let scroll_top = ListOffset {
897                item_ix: start.count,
898                offset_in_item: new_scroll_top - start.height,
899            };
900            self.set_scroll_anchor(Some(scroll_top));
901        }
902
903        if delta.y > px(0.) {
904            self.follow_state.stop_following();
905        }
906
907        if let Some(handler) = self.scroll_handler.as_mut() {
908            let visible_range = Self::visible_range(&self.items, height, scroll_top);
909            handler(
910                &ListScrollEvent {
911                    visible_range,
912                    count: self.items.summary().count,
913                    is_scrolled: self.scroll_anchor.is_some(),
914                    is_following_tail: matches!(
915                        self.follow_state,
916                        FollowState::Tail { is_following: true }
917                    ),
918                },
919                window,
920                cx,
921            );
922        }
923
924        cx.notify(current_view);
925    }
926
927    fn logical_scroll_top(&self) -> ListOffset {
928        self.scroll_anchor
929            .map(|anchor| anchor.position)
930            .unwrap_or_else(|| match self.alignment {
931                ListAlignment::Top => ListOffset {
932                    item_ix: 0,
933                    offset_in_item: px(0.),
934                },
935                ListAlignment::Bottom => ListOffset {
936                    item_ix: self.items.summary().count,
937                    offset_in_item: px(0.),
938                },
939            })
940    }
941
942    fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels {
943        let (start, ..) = self.items.find::<ListItemSummary, _>(
944            (),
945            &Count(logical_scroll_top.item_ix),
946            Bias::Right,
947        );
948        start.height + logical_scroll_top.offset_in_item
949    }
950
951    fn is_item_ready(&self, index: usize, cx: &App) -> bool {
952        self.item_measurement_ready
953            .as_ref()
954            .is_none_or(|ready| ready(index, cx))
955    }
956
957    fn is_provisional(&self) -> bool {
958        self.scroll_anchor.is_some_and(|anchor| anchor.provisional)
959    }
960
961    fn set_scroll_anchor(&mut self, position: Option<ListOffset>) {
962        let Some(position) = position else {
963            self.scroll_anchor = None;
964            return;
965        };
966        let pending = self
967            .scroll_anchor
968            .and_then(|anchor| anchor.pending)
969            .filter(|_| position.item_ix < self.items.summary().count)
970            .and_then(|pending| match (pending, self.is_provisional()) {
971                (PendingScroll::Absolute(_), _) | (_, true) => {
972                    Some(PendingScroll::Absolute(position.offset_in_item))
973                }
974                (PendingScroll::Proportional(_), false) => {
975                    let mut cursor = self.items.cursor::<Count>(());
976                    cursor.seek(&Count(position.item_ix), Bias::Right);
977                    cursor
978                        .item()
979                        .and_then(|item| item.size_hint())
980                        .filter(|size| size.height > px(0.))
981                        .map(|size| {
982                            PendingScroll::Proportional(
983                                (position.offset_in_item / size.height).clamp(0., 1.),
984                            )
985                        })
986                }
987            });
988        self.scroll_anchor = Some(ScrollAnchor {
989            position,
990            pending,
991            provisional: false,
992            pending_end: false,
993        });
994    }
995
996    fn layout_all_items(
997        &mut self,
998        available_width: Pixels,
999        render_item: &mut RenderItemFn,
1000        window: &mut Window,
1001        cx: &mut App,
1002    ) {
1003        match &mut self.measuring_behavior {
1004            ListMeasuringBehavior::Visible => {
1005                return;
1006            }
1007            ListMeasuringBehavior::Measure(has_measured) => {
1008                if *has_measured {
1009                    return;
1010                }
1011                *has_measured = true;
1012            }
1013        }
1014
1015        let cursor = self.items.cursor::<Count>(());
1016        let available_item_space = size(
1017            AvailableSpace::Definite(available_width),
1018            AvailableSpace::MinContent,
1019        );
1020
1021        let mut measured_items = Vec::default();
1022
1023        for (ix, item) in cursor.enumerate() {
1024            let size = item.size().unwrap_or_else(|| {
1025                let mut element = render_item(ix, window, cx);
1026                element.layout_as_root(available_item_space, window, cx)
1027            });
1028
1029            measured_items.push(item.with_measurement(size, self.is_item_ready(ix, cx)));
1030        }
1031
1032        self.items = SumTree::from_iter(measured_items, ());
1033        self.measuring_behavior =
1034            ListMeasuringBehavior::Measure(self.items.summary().unrendered_count == 0);
1035    }
1036
1037    fn layout_items(
1038        &mut self,
1039        available_width: Option<Pixels>,
1040        available_height: Pixels,
1041        padding: &Edges<Pixels>,
1042        render_item: &mut RenderItemFn,
1043        window: &mut Window,
1044        cx: &mut App,
1045    ) -> LayoutItemsResponse {
1046        let old_items = self.items.clone();
1047        let mut measured_items = VecDeque::new();
1048        let mut item_layouts = VecDeque::new();
1049        let mut rendered_height = padding.top;
1050        let mut max_item_width = px(0.);
1051        let mut scroll_top = self.logical_scroll_top();
1052
1053        if self.follow_state.is_following()
1054            || self.scroll_anchor.is_some_and(|anchor| anchor.pending_end)
1055        {
1056            scroll_top = ListOffset {
1057                item_ix: self.items.summary().count,
1058                offset_in_item: px(0.),
1059            };
1060            self.set_scroll_anchor(Some(scroll_top));
1061        }
1062
1063        let preserve_anchor = scroll_top.item_ix < self.items.summary().count;
1064        if !preserve_anchor {
1065            self.follow_state.start_following();
1066        }
1067        let mut provisional = false;
1068        let mut rendered_focused_item = false;
1069
1070        let available_item_space = size(
1071            available_width.map_or(AvailableSpace::MaxContent, |width| {
1072                AvailableSpace::Definite(width)
1073            }),
1074            AvailableSpace::MinContent,
1075        );
1076
1077        let mut cursor = old_items.cursor::<Count>(());
1078
1079        // Render items after the scroll top, including those in the trailing overdraw
1080        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1081        for (ix, item) in cursor.by_ref().enumerate() {
1082            let visible_height = rendered_height - scroll_top.offset_in_item;
1083            if visible_height >= available_height + self.overdraw {
1084                break;
1085            }
1086
1087            let item_index = scroll_top.item_ix + ix;
1088            let (size, ready) = if visible_height >= available_height
1089                && let Some(size) = item.size()
1090            {
1091                (size, self.is_item_ready(item_index, cx))
1092            } else {
1093                let mut element = render_item(item_index, window, cx);
1094                let element_size = element.layout_as_root(available_item_space, window, cx);
1095                let ready = self.is_item_ready(item_index, cx);
1096                if ix == 0
1097                    && ready
1098                    && let Some(anchor) = self.scroll_anchor.as_mut()
1099                    && let Some(pending) = anchor.pending.take()
1100                {
1101                    scroll_top.offset_in_item = match pending {
1102                        PendingScroll::Absolute(offset) => offset.min(element_size.height),
1103                        PendingScroll::Proportional(fraction) => element_size.height * fraction,
1104                    };
1105                    anchor.position = scroll_top;
1106                }
1107
1108                if visible_height < available_height {
1109                    item_layouts.push_back(ItemLayout {
1110                        index: item_index,
1111                        element,
1112                        size: element_size,
1113                    });
1114                    if item.contains_focused(window, cx) {
1115                        rendered_focused_item = true;
1116                    }
1117                }
1118                (element_size, ready)
1119            };
1120
1121            rendered_height += size.height;
1122            max_item_width = max_item_width.max(size.width);
1123            provisional |= !ready;
1124            measured_items.push_back(item.with_measurement(size, ready));
1125            if !ready && visible_height >= available_height {
1126                break;
1127            }
1128        }
1129        rendered_height += padding.bottom;
1130
1131        // Prepare to start walking upward from the item at the scroll top.
1132        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1133
1134        // If the rendered items do not fill the visible region, then adjust
1135        // the scroll top upward.
1136        if !provisional && rendered_height - scroll_top.offset_in_item < available_height {
1137            let forward_layout_count = item_layouts.len();
1138            let forward_focused = rendered_focused_item;
1139            while rendered_height < available_height {
1140                cursor.prev();
1141                if let Some(item) = cursor.item() {
1142                    let item_index = cursor.start().0;
1143                    let mut element = render_item(item_index, window, cx);
1144                    let element_size = element.layout_as_root(available_item_space, window, cx);
1145                    let ready = self.is_item_ready(item_index, cx);
1146                    provisional |= !ready;
1147                    rendered_height += element_size.height;
1148                    measured_items.push_front(item.with_measurement(element_size, ready));
1149                    item_layouts.push_front(ItemLayout {
1150                        index: item_index,
1151                        element,
1152                        size: element_size,
1153                    });
1154                    if item.contains_focused(window, cx) {
1155                        rendered_focused_item = true;
1156                    }
1157                    if preserve_anchor && provisional {
1158                        break;
1159                    }
1160                } else {
1161                    break;
1162                }
1163            }
1164
1165            if preserve_anchor && provisional {
1166                item_layouts.drain(..item_layouts.len() - forward_layout_count);
1167                rendered_focused_item = forward_focused;
1168            } else {
1169                scroll_top = ListOffset {
1170                    item_ix: cursor.start().0,
1171                    offset_in_item: rendered_height - available_height,
1172                };
1173
1174                match self.alignment {
1175                    ListAlignment::Top => {
1176                        scroll_top.offset_in_item = scroll_top.offset_in_item.max(px(0.));
1177                        self.set_scroll_anchor(Some(scroll_top));
1178                    }
1179                    ListAlignment::Bottom => {
1180                        scroll_top = ListOffset {
1181                            item_ix: cursor.start().0,
1182                            offset_in_item: rendered_height - available_height,
1183                        };
1184                        self.set_scroll_anchor(None);
1185                    }
1186                };
1187            }
1188        }
1189
1190        if let Some(anchor) = self.scroll_anchor.as_mut() {
1191            anchor.provisional = preserve_anchor && provisional;
1192            anchor.pending_end = !preserve_anchor && provisional;
1193        }
1194
1195        // Measure items in the leading overdraw
1196        if !provisional {
1197            let mut leading_overdraw = scroll_top.offset_in_item;
1198            while leading_overdraw < self.overdraw {
1199                cursor.prev();
1200                if let Some(item) = cursor.item() {
1201                    let size = if let ListItem::Measured { size, .. } = item {
1202                        *size
1203                    } else {
1204                        let mut element = render_item(cursor.start().0, window, cx);
1205                        element.layout_as_root(available_item_space, window, cx)
1206                    };
1207
1208                    leading_overdraw += size.height;
1209                    let ready = self.is_item_ready(cursor.start().0, cx);
1210                    measured_items.push_front(item.with_measurement(size, ready));
1211                    if !ready {
1212                        break;
1213                    }
1214                } else {
1215                    break;
1216                }
1217            }
1218        }
1219
1220        let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len());
1221        let mut cursor = old_items.cursor::<Count>(());
1222        let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right);
1223        new_items.extend(measured_items, ());
1224        cursor.seek(&Count(measured_range.end), Bias::Right);
1225        new_items.append(cursor.suffix(), ());
1226        self.items = new_items;
1227
1228        // If follow_tail mode is on but the user scrolled away
1229        // (is_following is false), check whether the current scroll
1230        // position has returned to the bottom.
1231        if !provisional && self.follow_state.has_stopped_following() {
1232            let padding = self.last_padding.unwrap_or_default();
1233            let total_height = self.items.summary().height + padding.top + padding.bottom;
1234            let scroll_offset = self.scroll_top(&scroll_top);
1235            if scroll_offset + available_height >= total_height - px(1.0) {
1236                self.follow_state.start_following();
1237            }
1238        }
1239
1240        // If none of the visible items are focused, check if an off-screen item is focused
1241        // and include it to be rendered after the visible items so keyboard interaction continues
1242        // to work for it.
1243        if !rendered_focused_item {
1244            let mut cursor = self
1245                .items
1246                .filter::<_, Count>((), |summary| summary.has_focus_handles);
1247            cursor.next();
1248            while let Some(item) = cursor.item() {
1249                if item.contains_focused(window, cx) {
1250                    let item_index = cursor.start().0;
1251                    let mut element = render_item(cursor.start().0, window, cx);
1252                    let size = element.layout_as_root(available_item_space, window, cx);
1253                    item_layouts.push_back(ItemLayout {
1254                        index: item_index,
1255                        element,
1256                        size,
1257                    });
1258                    break;
1259                }
1260                cursor.next();
1261            }
1262        }
1263
1264        LayoutItemsResponse {
1265            max_item_width,
1266            scroll_top,
1267            item_layouts,
1268        }
1269    }
1270
1271    fn prepaint_items(
1272        &mut self,
1273        bounds: Bounds<Pixels>,
1274        padding: Edges<Pixels>,
1275        autoscroll: bool,
1276        render_item: &mut RenderItemFn,
1277        window: &mut Window,
1278        cx: &mut App,
1279    ) -> Result<LayoutItemsResponse, ListOffset> {
1280        let item_width = bounds.size.width - padding.along(Axis::Horizontal);
1281        window.transact(|window| {
1282            match self.measuring_behavior {
1283                ListMeasuringBehavior::Measure(has_measured) if !has_measured => {
1284                    self.layout_all_items(item_width, render_item, window, cx);
1285                }
1286                _ => {}
1287            }
1288
1289            let mut layout_response = self.layout_items(
1290                Some(item_width),
1291                bounds.size.height,
1292                &padding,
1293                render_item,
1294                window,
1295                cx,
1296            );
1297
1298            // Avoid honoring autoscroll requests from elements other than our children.
1299            window.take_autoscroll();
1300
1301            // Only paint the visible items, if there is actually any space for them (taking padding into account)
1302            if bounds.size.height > padding.top + padding.bottom {
1303                let mut item_origin = bounds.origin + Point::new(padding.left, padding.top);
1304                item_origin.y -= layout_response.scroll_top.offset_in_item;
1305                for item in &mut layout_response.item_layouts {
1306                    window.with_content_mask(Some(ContentMask { bounds }), |window| {
1307                        item.element.prepaint_at(item_origin, window, cx);
1308                    });
1309
1310                    if let Some(autoscroll_bounds) = window.take_autoscroll()
1311                        && autoscroll
1312                    {
1313                        if autoscroll_bounds.top() < bounds.top() {
1314                            let mut item_ix = item.index;
1315                            let mut offset_in_item = autoscroll_bounds.top() - item_origin.y;
1316
1317                            // The requested top can sit above this item's own
1318                            // top. Walk into earlier items so the offset stays
1319                            // non-negative and no blank space appears above the
1320                            // list.
1321                            if offset_in_item < Pixels::ZERO {
1322                                let mut cursor = self.items.cursor::<Count>(());
1323                                cursor.seek(&Count(item_ix), Bias::Right);
1324                                while offset_in_item < Pixels::ZERO {
1325                                    cursor.prev();
1326                                    let Some(prev_item) = cursor.item() else {
1327                                        offset_in_item = Pixels::ZERO;
1328                                        break;
1329                                    };
1330                                    let size = prev_item.size().unwrap_or_else(|| {
1331                                        let mut element = render_item(cursor.start().0, window, cx);
1332                                        let item_available_size =
1333                                            size(item_width.into(), AvailableSpace::MinContent);
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(item_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 = content_insets(
1491                        &style,
1492                        state.last_layout_bounds.unwrap_or_default().size,
1493                        window.rem_size(),
1494                    );
1495
1496                    let layout_response = state.layout_items(
1497                        None,
1498                        available_height,
1499                        &padding,
1500                        &mut self.render_item,
1501                        window,
1502                        cx,
1503                    );
1504                    let max_element_width = layout_response.max_item_width;
1505
1506                    let summary = state.items.summary();
1507                    let total_height = summary.height;
1508
1509                    window.request_measured_layout(
1510                        style,
1511                        move |known_dimensions, available_space, _window, _cx| {
1512                            let width =
1513                                known_dimensions
1514                                    .width
1515                                    .unwrap_or(match available_space.width {
1516                                        AvailableSpace::Definite(x) => x,
1517                                        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1518                                            max_element_width
1519                                        }
1520                                    });
1521                            let height = match available_space.height {
1522                                AvailableSpace::Definite(height) => total_height.min(height),
1523                                AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1524                                    total_height
1525                                }
1526                            };
1527                            size(width, height)
1528                        },
1529                    )
1530                })
1531            }
1532            ListSizingBehavior::Auto => {
1533                let mut style = Style::default();
1534                style.refine(&self.style);
1535                window.with_text_style(style.text_style().cloned(), |window| {
1536                    window.request_layout(style, None, cx)
1537                })
1538            }
1539        };
1540        (layout_id, ())
1541    }
1542
1543    fn prepaint(
1544        &mut self,
1545        _id: Option<&GlobalElementId>,
1546        _inspector_id: Option<&InspectorElementId>,
1547        bounds: Bounds<Pixels>,
1548        _: &mut Self::RequestLayoutState,
1549        window: &mut Window,
1550        cx: &mut App,
1551    ) -> ListPrepaintState {
1552        let state = &mut *self.state.0.borrow_mut();
1553        state.reset = false;
1554
1555        let mut style = Style::default();
1556        style.refine(&self.style);
1557
1558        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
1559
1560        let padding = content_insets(&style, bounds.size, window.rem_size());
1561
1562        // If the width of the list or the list content insets have changed,
1563        // invalidate all cached item heights
1564        let last_item_width =
1565            state
1566                .last_layout_bounds
1567                .zip(state.last_padding)
1568                .map(|(last_bounds, last_padding)| {
1569                    last_bounds.size.width - last_padding.along(Axis::Horizontal)
1570                });
1571        if last_item_width
1572            .is_none_or(|width| width != bounds.size.width - padding.along(Axis::Horizontal))
1573        {
1574            let new_items = SumTree::from_iter(
1575                state.items.iter().map(|item| ListItem::Unmeasured {
1576                    size_hint: None,
1577                    focus_handle: item.focus_handle(),
1578                }),
1579                (),
1580            );
1581
1582            state.items = new_items;
1583            state.measuring_behavior.reset();
1584        }
1585
1586        let mut autoscroll = true;
1587        let layout = loop {
1588            match state.prepaint_items(
1589                bounds,
1590                padding,
1591                autoscroll,
1592                &mut self.render_item,
1593                window,
1594                cx,
1595            ) {
1596                Ok(layout) => break layout,
1597                Err(autoscroll_request) => {
1598                    state.set_scroll_anchor(Some(autoscroll_request));
1599                    autoscroll = false;
1600                }
1601            }
1602        };
1603
1604        state.last_layout_bounds = Some(bounds);
1605        state.last_padding = Some(padding);
1606        ListPrepaintState { hitbox, layout }
1607    }
1608
1609    fn paint(
1610        &mut self,
1611        _id: Option<&GlobalElementId>,
1612        _inspector_id: Option<&InspectorElementId>,
1613        bounds: Bounds<crate::Pixels>,
1614        _: &mut Self::RequestLayoutState,
1615        prepaint: &mut Self::PrepaintState,
1616        window: &mut Window,
1617        cx: &mut App,
1618    ) {
1619        let current_view = window.current_view();
1620
1621        // Register the scroll listener before painting children so that, in
1622        // the bubble phase (which runs in reverse registration order),
1623        // children's scroll-wheel handlers run first and can stop propagation
1624        // to prevent the list from scrolling. This matches the ordering of
1625        // div-based scroll containers.
1626        let list_state = self.state.clone();
1627        let height = bounds.size.height;
1628        let scroll_top = prepaint.layout.scroll_top;
1629        let hitbox_id = prepaint.hitbox.id;
1630        let mut accumulated_scroll_delta = ScrollDelta::default();
1631        window.on_mouse_event(move |event: &ScrollEvent, phase, window, cx| {
1632            if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) {
1633                accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta);
1634                let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.));
1635                list_state.0.borrow_mut().scroll(
1636                    &scroll_top,
1637                    height,
1638                    pixel_delta,
1639                    current_view,
1640                    window,
1641                    cx,
1642                )
1643            }
1644        });
1645
1646        window.with_content_mask(Some(ContentMask { bounds }), |window| {
1647            for item in &mut prepaint.layout.item_layouts {
1648                item.element.paint(window, cx);
1649            }
1650        });
1651    }
1652}
1653
1654fn content_insets(style: &Style, size: Size<Pixels>, rem_size: Pixels) -> Edges<Pixels> {
1655    let padding = style.padding.to_pixels(size.into(), rem_size);
1656    let border = style.border_widths.to_pixels(rem_size);
1657
1658    padding + border
1659}
1660
1661impl IntoElement for List {
1662    type Element = Self;
1663
1664    fn into_element(self) -> Self::Element {
1665        self
1666    }
1667}
1668
1669impl Styled for List {
1670    fn style(&mut self) -> &mut StyleRefinement {
1671        &mut self.style
1672    }
1673}
1674
1675impl sum_tree::Item for ListItem {
1676    type Summary = ListItemSummary;
1677
1678    fn summary(&self, _: ()) -> Self::Summary {
1679        match self {
1680            ListItem::Unmeasured {
1681                size_hint,
1682                focus_handle,
1683            } => ListItemSummary {
1684                count: 1,
1685                rendered_count: 0,
1686                unrendered_count: 1,
1687                height: if let Some(size) = size_hint {
1688                    size.height
1689                } else {
1690                    px(0.)
1691                },
1692                has_focus_handles: focus_handle.is_some(),
1693                has_unknown_height: size_hint.is_none(),
1694            },
1695            ListItem::Measured {
1696                size, focus_handle, ..
1697            } => ListItemSummary {
1698                count: 1,
1699                rendered_count: 1,
1700                unrendered_count: 0,
1701                height: size.height,
1702                has_focus_handles: focus_handle.is_some(),
1703                has_unknown_height: false,
1704            },
1705        }
1706    }
1707}
1708
1709impl sum_tree::ContextLessSummary for ListItemSummary {
1710    fn zero() -> Self {
1711        Default::default()
1712    }
1713
1714    fn add_summary(&mut self, summary: &Self) {
1715        self.count += summary.count;
1716        self.rendered_count += summary.rendered_count;
1717        self.unrendered_count += summary.unrendered_count;
1718        self.height += summary.height;
1719        self.has_focus_handles |= summary.has_focus_handles;
1720        self.has_unknown_height |= summary.has_unknown_height;
1721    }
1722}
1723
1724impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Count {
1725    fn zero(_cx: ()) -> Self {
1726        Default::default()
1727    }
1728
1729    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1730        self.0 += summary.count;
1731    }
1732}
1733
1734impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Height {
1735    fn zero(_cx: ()) -> Self {
1736        Default::default()
1737    }
1738
1739    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1740        self.0 += summary.height;
1741    }
1742}
1743
1744impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Count {
1745    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1746        Ord::cmp(&self.0, &other.count)
1747    }
1748}
1749
1750impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Height {
1751    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1752        Ord::cmp(&self.0, &other.height)
1753    }
1754}
1755
1756#[cfg(test)]
1757mod test {
1758
1759    use gpui::{ScrollDelta, ScrollEvent};
1760    use std::cell::{Cell, RefCell};
1761    use std::rc::Rc;
1762
1763    use crate::{
1764        self as gpui, AnyElement, AppContext, Bounds, Context, Element, FollowMode,
1765        InteractiveElement, IntoElement, ListOffset, ListSizingBehavior, ListState, ParentElement,
1766        Pixels, Render, Styled, TestAppContext, VisualTestContext, Window, canvas, div, list,
1767        point, px, size,
1768    };
1769
1770    #[gpui::test]
1771    fn test_autoscroll_above_item_top_renders_items_above(cx: &mut TestAppContext) {
1772        let cx = cx.add_empty_window();
1773
1774        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1775        state.scroll_to(gpui::ListOffset {
1776            item_ix: 2,
1777            offset_in_item: px(0.),
1778        });
1779
1780        struct TestView(ListState);
1781        impl Render for TestView {
1782            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1783                list(self.0.clone(), |ix, _, _| {
1784                    if ix == 2 {
1785                        // Request an autoscroll whose top sits 30px above item 2's
1786                        // own top, mimicking a scroll-margin overshoot.
1787                        canvas(
1788                            |bounds, window, _| {
1789                                window.request_autoscroll(Bounds::from_corners(
1790                                    point(bounds.left(), bounds.top() - px(30.)),
1791                                    point(bounds.right(), bounds.top() + px(5.)),
1792                                ));
1793                            },
1794                            |_, _, _, _| {},
1795                        )
1796                        .h(px(20.))
1797                        .w_full()
1798                        .into_any()
1799                    } else {
1800                        div().h(px(20.)).w_full().into_any()
1801                    }
1802                })
1803                .w_full()
1804                .h_full()
1805            }
1806        }
1807
1808        cx.draw(point(px(0.), px(0.)), size(px(100.), px(60.)), |_, cx| {
1809            cx.new(|_| TestView(state.clone())).into_any_element()
1810        });
1811
1812        // 30px above item 2's top, with 20px items, lands 10px into item 0.
1813        let scroll_top = state.logical_scroll_top();
1814        assert!(
1815            scroll_top.offset_in_item >= px(0.),
1816            "offset_in_item must never be negative (would leave blank space above), got {:?}",
1817            scroll_top.offset_in_item,
1818        );
1819        assert_eq!(scroll_top.item_ix, 0);
1820        assert_eq!(scroll_top.offset_in_item, px(10.));
1821    }
1822
1823    #[gpui::test]
1824    fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) {
1825        let cx = cx.add_empty_window();
1826
1827        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1828
1829        // Ensure that the list is scrolled to the top
1830        state.scroll_to(gpui::ListOffset {
1831            item_ix: 0,
1832            offset_in_item: px(0.0),
1833        });
1834
1835        struct TestView(ListState);
1836        impl Render for TestView {
1837            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1838                list(self.0.clone(), |_, _, _| {
1839                    div().h(px(10.)).w_full().into_any()
1840                })
1841                .w_full()
1842                .h_full()
1843            }
1844        }
1845
1846        // Paint
1847        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1848            cx.new(|_| TestView(state.clone())).into_any_element()
1849        });
1850
1851        // Reset
1852        state.reset(5);
1853
1854        // And then receive a scroll event _before_ the next paint
1855        cx.simulate_event(ScrollEvent {
1856            position: point(px(1.), px(1.)),
1857            delta: ScrollDelta::Pixels(point(px(0.), px(-500.))),
1858            ..Default::default()
1859        });
1860
1861        // Scroll position should stay at the top of the list
1862        assert_eq!(state.logical_scroll_top().item_ix, 0);
1863        assert_eq!(state.logical_scroll_top().offset_in_item, px(0.));
1864    }
1865
1866    #[gpui::test]
1867    fn test_scroll_by_positive_and_negative_distance(cx: &mut TestAppContext) {
1868        let cx = cx.add_empty_window();
1869
1870        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1871
1872        struct TestView(ListState);
1873        impl Render for TestView {
1874            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1875                list(self.0.clone(), |_, _, _| {
1876                    div().h(px(20.)).w_full().into_any()
1877                })
1878                .w_full()
1879                .h_full()
1880            }
1881        }
1882
1883        // Paint
1884        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
1885            cx.new(|_| TestView(state.clone())).into_any_element()
1886        });
1887
1888        // Test positive distance: start at item 1, move down 30px
1889        state.scroll_by(px(30.));
1890
1891        // Should move to item 2
1892        let offset = state.logical_scroll_top();
1893        assert_eq!(offset.item_ix, 1);
1894        assert_eq!(offset.offset_in_item, px(10.));
1895
1896        // Test negative distance: start at item 2, move up 30px
1897        state.scroll_by(px(-30.));
1898
1899        // Should move back to item 1
1900        let offset = state.logical_scroll_top();
1901        assert_eq!(offset.item_ix, 0);
1902        assert_eq!(offset.offset_in_item, px(0.));
1903
1904        // Test zero distance
1905        state.scroll_by(px(0.));
1906        let offset = state.logical_scroll_top();
1907        assert_eq!(offset.item_ix, 0);
1908        assert_eq!(offset.offset_in_item, px(0.));
1909    }
1910
1911    #[gpui::test]
1912    fn test_child_scroll_handler_can_stop_list_scroll(cx: &mut TestAppContext) {
1913        let cx = cx.add_empty_window();
1914
1915        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1916        let child_saw_event = Rc::new(Cell::new(false));
1917
1918        struct TestView {
1919            state: ListState,
1920            child_saw_event: Rc<Cell<bool>>,
1921        }
1922        impl Render for TestView {
1923            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1924                let child_saw_event = self.child_saw_event.clone();
1925                list(self.state.clone(), move |_, _, _| {
1926                    let child_saw_event = child_saw_event.clone();
1927                    div()
1928                        .h(px(20.))
1929                        .w_full()
1930                        .on_scroll(move |_, _, cx| {
1931                            child_saw_event.set(true);
1932                            cx.stop_propagation();
1933                        })
1934                        .into_any()
1935                })
1936                .w_full()
1937                .h_full()
1938            }
1939        }
1940
1941        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
1942            cx.new(|_| TestView {
1943                state: state.clone(),
1944                child_saw_event: child_saw_event.clone(),
1945            })
1946            .into_any_element()
1947        });
1948
1949        cx.simulate_event(ScrollEvent {
1950            position: point(px(50.), px(10.)),
1951            delta: ScrollDelta::Pixels(point(px(0.), px(-30.))),
1952            ..Default::default()
1953        });
1954
1955        assert!(
1956            child_saw_event.get(),
1957            "the child's scroll-wheel handler should run"
1958        );
1959        // The child stopped propagation, so the list must not have scrolled.
1960        let offset = state.logical_scroll_top();
1961        assert_eq!(offset.item_ix, 0);
1962        assert_eq!(offset.offset_in_item, px(0.));
1963    }
1964
1965    #[gpui::test]
1966    fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) {
1967        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1968
1969        assert_eq!(state.item_is_above_viewport(0), None);
1970        assert_eq!(state.item_is_below_viewport(0), None);
1971    }
1972
1973    #[gpui::test]
1974    fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) {
1975        let cx = cx.add_empty_window();
1976
1977        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1978
1979        state.scroll_to(gpui::ListOffset {
1980            item_ix: 2,
1981            offset_in_item: px(0.),
1982        });
1983        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1984            cx.new(|_| TestListView(state.clone())).into_any_element()
1985        });
1986
1987        assert_eq!(state.item_is_above_viewport(1), Some(true));
1988        assert_eq!(state.item_is_below_viewport(1), Some(false));
1989    }
1990
1991    #[gpui::test]
1992    fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) {
1993        let cx = cx.add_empty_window();
1994
1995        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1996
1997        state.scroll_to(gpui::ListOffset {
1998            item_ix: 2,
1999            offset_in_item: px(0.),
2000        });
2001        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2002            cx.new(|_| TestListView(state.clone())).into_any_element()
2003        });
2004
2005        assert_eq!(state.item_is_above_viewport(2), Some(false));
2006        assert_eq!(state.item_is_below_viewport(2), Some(false));
2007    }
2008
2009    #[gpui::test]
2010    fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) {
2011        let cx = cx.add_empty_window();
2012
2013        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2014
2015        state.scroll_to(gpui::ListOffset {
2016            item_ix: 2,
2017            offset_in_item: px(20.),
2018        });
2019        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2020            cx.new(|_| TestListView(state.clone())).into_any_element()
2021        });
2022
2023        assert_eq!(state.item_is_above_viewport(2), Some(true));
2024        assert_eq!(state.item_is_below_viewport(2), Some(false));
2025    }
2026
2027    #[gpui::test]
2028    fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) {
2029        let cx = cx.add_empty_window();
2030
2031        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2032
2033        state.scroll_to(gpui::ListOffset {
2034            item_ix: 2,
2035            offset_in_item: px(0.),
2036        });
2037        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2038            cx.new(|_| TestListView(state.clone())).into_any_element()
2039        });
2040
2041        assert_eq!(state.item_is_above_viewport(3), Some(false));
2042        assert_eq!(state.item_is_below_viewport(3), Some(true));
2043    }
2044
2045    #[gpui::test]
2046    fn test_item_viewport_queries_remain_stable_with_zero_height_viewport(cx: &mut TestAppContext) {
2047        let cx = cx.add_empty_window();
2048
2049        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2050
2051        state.scroll_to(gpui::ListOffset {
2052            item_ix: 2,
2053            offset_in_item: px(0.),
2054        });
2055        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2056            cx.new(|_| TestListView(state.clone())).into_any_element()
2057        });
2058
2059        assert_eq!(state.item_is_above_viewport(3), Some(false));
2060        assert_eq!(state.item_is_below_viewport(3), Some(true));
2061
2062        // Squeeze the list to zero height, e.g. because a sibling element
2063        // (sized based on the queries above) consumed all the space. The
2064        // answers must remain definitive rather than becoming `None`,
2065        // otherwise the sibling's size can oscillate between frames.
2066        cx.draw(point(px(0.), px(0.)), size(px(100.), px(0.)), |_, cx| {
2067            cx.new(|_| TestListView(state.clone())).into_any_element()
2068        });
2069
2070        assert_eq!(state.item_is_above_viewport(1), Some(true));
2071        assert_eq!(state.item_is_below_viewport(1), Some(false));
2072        assert_eq!(state.item_is_above_viewport(3), Some(false));
2073        assert_eq!(state.item_is_below_viewport(3), Some(true));
2074    }
2075
2076    #[gpui::test]
2077    fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) {
2078        let cx = cx.add_empty_window();
2079
2080        let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
2081
2082        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
2083            cx.new(|_| TestListView(state.clone())).into_any_element()
2084        });
2085
2086        state.scroll_to_end();
2087
2088        assert_eq!(state.logical_scroll_top().item_ix, state.item_count());
2089        assert_eq!(state.item_is_above_viewport(0), Some(true));
2090        assert_eq!(state.item_is_below_viewport(0), Some(false));
2091    }
2092
2093    #[gpui::test]
2094    fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
2095        let cx = cx.add_empty_window();
2096
2097        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2098
2099        struct TestView(ListState);
2100        impl Render for TestView {
2101            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2102                list(self.0.clone(), |_, _, _| {
2103                    div().h(px(50.)).w_full().into_any()
2104                })
2105                .w_full()
2106                .h_full()
2107            }
2108        }
2109
2110        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2111
2112        // First draw at width 100: all 10 items measured (total 500px).
2113        // Viewport is 200px, so max scroll offset should be 300px.
2114        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2115            view.clone().into_any_element()
2116        });
2117        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2118
2119        // Second draw at a different width: items get invalidated.
2120        // Without the fix, max_offset would drop because unmeasured items
2121        // contribute 0 height.
2122        cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| {
2123            view.into_any_element()
2124        });
2125        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2126    }
2127
2128    #[gpui::test]
2129    fn test_remeasure(cx: &mut TestAppContext) {
2130        let cx = cx.add_empty_window();
2131
2132        // Create a list with 10 items, each 100px tall. We'll keep a reference
2133        // to the item height so we can later change the height and assert how
2134        // `ListState` handles it.
2135        let item_height = Rc::new(Cell::new(100usize));
2136        let state = ListState::new(10, crate::ListAlignment::Top, px(10.));
2137
2138        struct TestView {
2139            state: ListState,
2140            item_height: Rc<Cell<usize>>,
2141        }
2142
2143        impl Render for TestView {
2144            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2145                let height = self.item_height.get();
2146                list(self.state.clone(), move |_, _, _| {
2147                    div().h(px(height as f32)).w_full().into_any()
2148                })
2149                .w_full()
2150                .h_full()
2151            }
2152        }
2153
2154        let state_clone = state.clone();
2155        let item_height_clone = item_height.clone();
2156        let view = cx.update(|_, cx| {
2157            cx.new(|_| TestView {
2158                state: state_clone,
2159                item_height: item_height_clone,
2160            })
2161        });
2162
2163        // Simulate scrolling 40px inside the element with index 2. Since the
2164        // original item height is 100px, this equates to 40% inside the item.
2165        state.scroll_to(gpui::ListOffset {
2166            item_ix: 2,
2167            offset_in_item: px(40.),
2168        });
2169
2170        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2171            view.clone().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(40.));
2177
2178        // Update the `item_height` to be 50px instead of 100px so we can assert
2179        // that the scroll position is proportionally preserved, that is,
2180        // instead of 40px from the top of item 2, it should be 20px, since the
2181        // item's height has been halved.
2182        item_height.set(50);
2183        state.remeasure();
2184
2185        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2186            view.into_any_element()
2187        });
2188
2189        let offset = state.logical_scroll_top();
2190        assert_eq!(offset.item_ix, 2);
2191        assert_eq!(offset.offset_in_item, px(20.));
2192    }
2193
2194    #[gpui::test]
2195    fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) {
2196        let cx = cx.add_empty_window();
2197
2198        let item_height = Rc::new(Cell::new(100usize));
2199        let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
2200
2201        struct TestView {
2202            state: ListState,
2203            item_height: Rc<Cell<usize>>,
2204        }
2205
2206        impl Render for TestView {
2207            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2208                let height = self.item_height.get();
2209                list(self.state.clone(), move |index, _, _| {
2210                    let height = if index == 5 { height } else { 100 };
2211                    div().h(px(height as f32)).w_full().into_any()
2212                })
2213                .w_full()
2214                .h_full()
2215            }
2216        }
2217
2218        let state_clone = state.clone();
2219        let item_height_clone = item_height.clone();
2220        let view = cx.update(|_, cx| {
2221            cx.new(|_| TestView {
2222                state: state_clone,
2223                item_height: item_height_clone,
2224            })
2225        });
2226
2227        state.scroll_to(gpui::ListOffset {
2228            item_ix: 5,
2229            offset_in_item: px(40.),
2230        });
2231
2232        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2233            view.clone().into_any_element()
2234        });
2235
2236        item_height.set(200);
2237        state.remeasure_items(5..6);
2238
2239        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2240            view.into_any_element()
2241        });
2242
2243        let offset = state.logical_scroll_top();
2244        assert_eq!(offset.item_ix, 5);
2245        assert_eq!(offset.offset_in_item, px(40.));
2246    }
2247
2248    #[gpui::test]
2249    fn test_provisional_item_heights_keep_scroll_anchor(cx: &mut TestAppContext) {
2250        let cx = cx.add_empty_window();
2251        let state = ListState::new(20, crate::ListAlignment::Top, px(100.));
2252        let painted = Rc::new(RefCell::new(Vec::new()));
2253        let first_rendered = Rc::new(Cell::new(None::<usize>));
2254        let ready = item_readiness(&state, false);
2255        state.set_follow_mode(FollowMode::Tail);
2256        state.scroll_to(list_offset(10, 40.));
2257
2258        for (height, measurement_ready) in [(0., false), (100., false), (100., true)] {
2259            ready.set(measurement_ready);
2260            painted.borrow_mut().clear();
2261            first_rendered.set(None);
2262            draw_list(
2263                &state,
2264                200.,
2265                {
2266                    let painted = painted.clone();
2267                    let first_rendered = first_rendered.clone();
2268                    move |index| {
2269                        first_rendered.set(Some(first_rendered.get().unwrap_or(index).min(index)));
2270                        painted_item(index, height, &painted)
2271                    }
2272                },
2273                cx,
2274            );
2275            assert_eq!(state.logical_scroll_top(), list_offset(10, 40.));
2276            assert!(!state.is_following_tail());
2277            assert_eq!(
2278                first_rendered.get(),
2279                Some(if measurement_ready { 9 } else { 10 })
2280            );
2281            if height > 0. {
2282                assert_eq!(
2283                    painted.borrow().as_slice(),
2284                    &[
2285                        (10, px(-40.), px(60.)),
2286                        (11, px(60.), px(160.)),
2287                        (12, px(160.), px(260.)),
2288                    ]
2289                );
2290            }
2291        }
2292    }
2293
2294    #[gpui::test]
2295    fn test_unready_measurements_preserve_pending_fraction(cx: &mut TestAppContext) {
2296        let cx = cx.add_empty_window();
2297        for (provisional_height, destination, final_height, expected_offset) in [
2298            (Some(200.), None, 200., 100.),
2299            (None, Some(40.), 200., 80.),
2300            (Some(200.), Some(50.), 200., 50.),
2301            (Some(0.), Some(40.), 20., 20.),
2302            (Some(10.), Some(40.), 100., 40.),
2303            (Some(200.), Some(50.), 300., 50.),
2304        ] {
2305            let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2306            let painted = Rc::new(RefCell::new(Vec::new()));
2307            let draw = |height, cx: &mut VisualTestContext| {
2308                painted.borrow_mut().clear();
2309                let painted = painted.clone();
2310                draw_list(
2311                    &state,
2312                    200.,
2313                    move |index| {
2314                        painted_item(index, if index == 5 { height } else { 100. }, &painted)
2315                    },
2316                    cx,
2317                );
2318            };
2319            let ready = item_readiness(&state, true);
2320            state.scroll_to(list_offset(5, 50.));
2321            draw(100., cx);
2322            ready.set(false);
2323            state.remeasure();
2324            if let Some(height) = provisional_height {
2325                for height in [0., height, height] {
2326                    draw(height, cx);
2327                    assert_eq!(state.logical_scroll_top(), list_offset(5, 50.));
2328                }
2329            }
2330            if let Some(offset) = destination {
2331                state.scroll_to(list_offset(5, offset));
2332            }
2333            ready.set(true);
2334            for _ in 0..2 {
2335                draw(final_height, cx);
2336                assert_eq!(state.logical_scroll_top(), list_offset(5, expected_offset));
2337                assert_eq!(
2338                    painted.borrow().first().copied(),
2339                    Some((5, px(-expected_offset), px(final_height - expected_offset)))
2340                );
2341            }
2342        }
2343    }
2344
2345    #[gpui::test]
2346    fn test_navigation_cancels_provisional_and_paints_destination(cx: &mut TestAppContext) {
2347        let cx = cx.add_empty_window();
2348        for measured in [false, true] {
2349            let navigations: [(&str, fn(&ListState), usize, f32); 7] = [
2350                (
2351                    "scroll_to",
2352                    |state| state.scroll_to(list_offset(12, 0.)),
2353                    12,
2354                    0.,
2355                ),
2356                ("end", ListState::scroll_to_end, 19, 150.),
2357                ("down", |state| state.scroll_by(px(5000.)), 19, 150.),
2358                (
2359                    "up",
2360                    |state| state.scroll_by(px(-5000.)),
2361                    if measured { 0 } else { 6 },
2362                    0.,
2363                ),
2364                (
2365                    "reveal_below",
2366                    |state| state.scroll_to_reveal_item(10),
2367                    10,
2368                    150.,
2369                ),
2370                (
2371                    "reveal_above",
2372                    |state| state.scroll_to_reveal_item(0),
2373                    0,
2374                    0.,
2375                ),
2376                (
2377                    "follow_tail",
2378                    |state| state.set_follow_mode(FollowMode::Tail),
2379                    19,
2380                    150.,
2381                ),
2382            ];
2383            for (name, navigate, destination, top) in navigations {
2384                let state = ListState::new(20, crate::ListAlignment::Top, px(0.));
2385                let state = if measured { state.measure_all() } else { state };
2386                let painted = Rc::new(RefCell::new(Vec::new()));
2387                let draw = |cx: &mut VisualTestContext| {
2388                    painted.borrow_mut().clear();
2389                    let painted = painted.clone();
2390                    draw_list(
2391                        &state,
2392                        200.,
2393                        move |index| {
2394                            painted_item(index, if index == 5 { 0. } else { 50. }, &painted)
2395                        },
2396                        cx,
2397                    );
2398                };
2399                state.set_item_measurement_readiness(|index, _| index != 5);
2400                state.scroll_to(list_offset(5, 20.));
2401                state.remeasure_items(5..6);
2402                draw(cx);
2403                assert!(state.0.borrow().is_provisional(), "{name}");
2404                navigate(&state);
2405                assert!(!state.0.borrow().is_provisional(), "{name}");
2406                draw(cx);
2407                assert!(!state.0.borrow().is_provisional(), "{name}");
2408                assert_eq!(
2409                    painted
2410                        .borrow()
2411                        .iter()
2412                        .find(|(index, _, _)| *index == destination)
2413                        .copied(),
2414                    Some((destination, px(top), px(top + 50.))),
2415                    "{name}, measured={measured}",
2416                );
2417            }
2418        }
2419
2420        let navigations: [(&str, fn(&ListState)); 6] = [
2421            ("end_sentinel", |state| state.scroll_to(list_offset(3, 0.))),
2422            ("scroll_to_end", ListState::scroll_to_end),
2423            ("follow_tail", |state| {
2424                state.set_follow_mode(FollowMode::Tail)
2425            }),
2426            ("horizontal", ListState::scroll_to_end),
2427            ("pause_tail", |state| {
2428                state.set_follow_mode(FollowMode::Tail)
2429            }),
2430            ("disable_tail", |state| {
2431                state.set_follow_mode(FollowMode::Tail)
2432            }),
2433        ];
2434        for (name, navigate) in navigations {
2435            let state = ListState::new(3, crate::ListAlignment::Top, px(0.)).measure_all();
2436            let ready = item_readiness(&state, false);
2437            let painted = Rc::new(RefCell::new(Vec::new()));
2438            let draw = |height, cx: &mut VisualTestContext| {
2439                painted.borrow_mut().clear();
2440                let painted = painted.clone();
2441                draw_list(
2442                    &state,
2443                    200.,
2444                    move |index| painted_item(index, height, &painted),
2445                    cx,
2446                );
2447            };
2448            navigate(&state);
2449            for (height, measurement_ready, expected) in [
2450                (
2451                    100.,
2452                    false,
2453                    [(1, px(0.), px(100.)), (2, px(100.), px(200.))],
2454                ),
2455                (
2456                    150.,
2457                    true,
2458                    [(1, px(-100.), px(50.)), (2, px(50.), px(200.))],
2459                ),
2460            ] {
2461                if measurement_ready {
2462                    match name {
2463                        "horizontal" => cx.simulate_event(ScrollEvent {
2464                            position: point(px(50.), px(100.)),
2465                            delta: ScrollDelta::Pixels(point(px(20.), px(0.))),
2466                            ..ScrollEvent::default()
2467                        }),
2468                        "pause_tail" => state.pause_following_tail(),
2469                        "disable_tail" => state.set_follow_mode(FollowMode::Normal),
2470                        _ => {}
2471                    }
2472                }
2473                ready.set(measurement_ready);
2474                draw(height, cx);
2475                if !measurement_ready {
2476                    assert_eq!(
2477                        state.scroll_px_offset_for_scrollbar().y,
2478                        px(-100.),
2479                        "{name}"
2480                    );
2481                    assert_eq!(state.logical_scroll_top(), list_offset(1, 0.), "{name}");
2482                    assert_eq!(state.item_is_above_viewport(2), None, "{name}");
2483                }
2484                let paused = name == "pause_tail" || name == "disable_tail";
2485                let expected = if measurement_ready && paused {
2486                    [(1, px(0.), px(150.)), (2, px(150.), px(300.))]
2487                } else {
2488                    expected
2489                };
2490                assert_eq!(
2491                    state.is_following_tail(),
2492                    name == "follow_tail" || (paused && !measurement_ready),
2493                    "{name}"
2494                );
2495                assert_eq!(painted.borrow().as_slice(), &expected, "{name}");
2496            }
2497            ready.set(false);
2498            navigate(&state);
2499            draw(150., cx);
2500            state.scroll_by(px(-20.));
2501            draw(150., cx);
2502            assert_eq!(state.logical_scroll_top(), list_offset(1, 80.), "{name}");
2503            assert!(!state.is_following_tail(), "{name}");
2504            assert_eq!(
2505                painted.borrow().as_slice(),
2506                &[(1, px(-80.), px(70.)), (2, px(70.), px(220.)),],
2507                "{name}"
2508            );
2509        }
2510    }
2511
2512    #[gpui::test]
2513    fn test_unready_overdraw_and_backfill_preserve_scroll_anchor(cx: &mut TestAppContext) {
2514        let cx = cx.add_empty_window();
2515        for (overdraw, anchor, unready_item) in
2516            [(100., list_offset(0, 0.), 2), (0., list_offset(2, 40.), 1)]
2517        {
2518            let state = ListState::new(3, crate::ListAlignment::Top, px(overdraw));
2519            let ready = Rc::new(Cell::new(false));
2520            state.set_item_measurement_readiness({
2521                let ready = ready.clone();
2522                move |index, _| index != unready_item || ready.get()
2523            });
2524            let painted = Rc::new(RefCell::new(Vec::new()));
2525            let draw = |height, cx: &mut VisualTestContext| {
2526                painted.borrow_mut().clear();
2527                let painted = painted.clone();
2528                draw_list(
2529                    &state,
2530                    200.,
2531                    move |index| {
2532                        let height = if index == unready_item { height } else { 100. };
2533                        painted_item(index, height, &painted)
2534                    },
2535                    cx,
2536                );
2537            };
2538            state.set_follow_mode(FollowMode::Tail);
2539            state.scroll_to(anchor);
2540            draw(0., cx);
2541            assert_eq!(state.logical_scroll_top(), anchor);
2542            assert!(!state.is_following_tail());
2543            assert_eq!(
2544                *painted.borrow(),
2545                if unready_item == 2 {
2546                    vec![(0, px(0.), px(100.)), (1, px(100.), px(200.))]
2547                } else {
2548                    vec![(2, px(-40.), px(60.))]
2549                }
2550            );
2551            ready.set(true);
2552            draw(500., cx);
2553            if unready_item == 2 {
2554                assert_eq!(state.logical_scroll_top(), anchor);
2555                assert!(!state.is_following_tail());
2556                assert_eq!(state.max_offset_for_scrollbar().y, px(500.));
2557                assert_eq!(
2558                    painted.borrow().as_slice(),
2559                    &[(0, px(0.), px(100.)), (1, px(100.), px(200.))]
2560                );
2561            } else {
2562                assert_eq!(state.logical_scroll_top(), list_offset(1, 400.));
2563                assert!(state.is_following_tail());
2564                assert_eq!(
2565                    painted.borrow().as_slice(),
2566                    &[(1, px(-400.), px(100.)), (2, px(100.), px(200.))]
2567                );
2568            }
2569        }
2570    }
2571
2572    #[gpui::test]
2573    fn test_unready_overdraw_stops_at_first_pending_item(cx: &mut TestAppContext) {
2574        let cx = cx.add_empty_window();
2575        for anchor in [0, 10] {
2576            let state = ListState::new(13, crate::ListAlignment::Top, px(100.));
2577            let ready = Rc::new(Cell::new(false));
2578            let initially_ready = move |index| {
2579                if anchor == 0 { index < 2 } else { index >= 10 }
2580            };
2581            state.set_item_measurement_readiness({
2582                let ready = ready.clone();
2583                move |index, _| initially_ready(index) || ready.get()
2584            });
2585            state.scroll_to(list_offset(anchor, 0.));
2586            let rendered = Rc::new(RefCell::new(Vec::new()));
2587            let painted = Rc::new(RefCell::new(Vec::new()));
2588            let expected_frames = if anchor == 0 {
2589                [vec![0, 1, 2], vec![0, 1, 2], vec![0, 1, 2], vec![0, 1]]
2590            } else {
2591                [
2592                    vec![10, 11, 12, 9],
2593                    vec![10, 11, 9],
2594                    vec![10, 11, 9],
2595                    vec![10, 11],
2596                ]
2597            };
2598            for (frame, expected_rendered) in expected_frames.into_iter().enumerate() {
2599                let measurement_ready = frame >= 2;
2600                ready.set(measurement_ready);
2601                rendered.borrow_mut().clear();
2602                painted.borrow_mut().clear();
2603                draw_list(
2604                    &state,
2605                    200.,
2606                    {
2607                        let rendered = rendered.clone();
2608                        let painted = painted.clone();
2609                        move |index| {
2610                            rendered.borrow_mut().push(index);
2611                            let height = if initially_ready(index) || measurement_ready {
2612                                100.
2613                            } else {
2614                                0.
2615                            };
2616                            painted_item(index, height, &painted)
2617                        }
2618                    },
2619                    cx,
2620                );
2621                assert_eq!(*rendered.borrow(), expected_rendered);
2622                assert_eq!(state.item_count(), 13);
2623                assert_eq!(state.logical_scroll_top(), list_offset(anchor, 0.));
2624                assert_eq!(
2625                    painted.borrow().as_slice(),
2626                    &[(anchor, px(0.), px(100.)), (anchor + 1, px(100.), px(200.))]
2627                );
2628            }
2629        }
2630    }
2631
2632    #[gpui::test]
2633    fn test_scrollbar_and_reset_do_not_resurrect_provisional(cx: &mut TestAppContext) {
2634        let cx = cx.add_empty_window();
2635        let state = ListState::new(20, crate::ListAlignment::Top, px(0.));
2636        let painted = Rc::new(RefCell::new(Vec::new()));
2637        let draw = |cx: &mut VisualTestContext| {
2638            painted.borrow_mut().clear();
2639            let painted = painted.clone();
2640            draw_list(
2641                &state,
2642                200.,
2643                move |index| painted_item(index, if index == 5 { 0. } else { 50. }, &painted),
2644                cx,
2645            );
2646        };
2647        state.set_item_measurement_readiness(|index, _| index != 5);
2648        assert!(!state.0.borrow().is_provisional());
2649        state.set_follow_mode(FollowMode::Tail);
2650        for intervening_frame in [false, true] {
2651            state.scroll_to(list_offset(5, 20.));
2652            state.remeasure_items(5..6);
2653            draw(cx);
2654            state.scrollbar_drag_started();
2655            if intervening_frame {
2656                draw(cx);
2657                assert!(state.0.borrow().is_provisional());
2658            }
2659            state.set_offset_from_scrollbar(point(px(0.), px(-10_000.)));
2660            assert!(!state.0.borrow().is_provisional());
2661            state.scrollbar_drag_ended();
2662            assert!(!state.is_scrollbar_dragging());
2663            draw(cx);
2664            assert_eq!(
2665                painted.borrow().last().copied(),
2666                Some((19, px(150.), px(200.)))
2667            );
2668            assert!(!state.0.borrow().is_provisional());
2669            state.scroll_to(list_offset(5, 20.));
2670            assert!(!state.0.borrow().is_provisional());
2671            draw(cx);
2672            assert!(state.0.borrow().is_provisional());
2673            assert_eq!(state.logical_scroll_top(), list_offset(5, 20.));
2674        }
2675        state.remeasure_items(5..6);
2676        state.reset(20);
2677        assert!(!state.0.borrow().is_provisional());
2678        draw(cx);
2679        assert!(!state.0.borrow().is_provisional());
2680        assert_eq!(state.logical_scroll_top(), ListOffset::default());
2681        assert_eq!(
2682            painted.borrow().first().copied(),
2683            Some((0, px(0.), px(50.)))
2684        );
2685    }
2686
2687    #[gpui::test]
2688    fn test_splice_preserves_only_surviving_scroll_offsets(cx: &mut TestAppContext) {
2689        let cx = cx.add_empty_window();
2690        let painted = Rc::new(RefCell::new(Vec::new()));
2691        let draw = |state: &ListState, height, cx: &mut VisualTestContext| {
2692            painted.borrow_mut().clear();
2693            let painted = painted.clone();
2694            draw_list(
2695                state,
2696                200.,
2697                move |index| painted_item(index, height, &painted),
2698                cx,
2699            );
2700        };
2701        for (provisional, replacement_count) in [(true, 0), (false, 1)] {
2702            let state = ListState::new(20, crate::ListAlignment::Top, px(0.));
2703            let ready = item_readiness(&state, !provisional);
2704            state.scroll_to(list_offset(8, 50.));
2705            state.remeasure_items(8..9);
2706            if provisional {
2707                draw(&state, 100., cx);
2708            }
2709            state.splice(8..9, replacement_count);
2710            assert!(!state.0.borrow().is_provisional());
2711            ready.set(true);
2712            draw(&state, 100., cx);
2713            assert_eq!(state.logical_scroll_top(), list_offset(8, 0.));
2714            assert_eq!(
2715                painted.borrow().first().copied(),
2716                Some((8, px(0.), px(100.)))
2717            );
2718        }
2719        let state = ListState::new(20, crate::ListAlignment::Top, px(0.)).measure_all();
2720        state.scroll_to(list_offset(8, 50.));
2721        draw(&state, 100., cx);
2722        state.remeasure();
2723        state.splice(0..2, 0);
2724        draw(&state, 80., cx);
2725        assert_eq!(state.logical_scroll_top(), list_offset(6, 40.));
2726        assert_eq!(
2727            painted.borrow().first().copied(),
2728            Some((6, px(-40.), px(40.)))
2729        );
2730        let ready = item_readiness(&state, false);
2731        state.remeasure_items(6..7);
2732        state.splice(8..10, 3);
2733        draw(&state, 100., cx);
2734        assert!(state.0.borrow().is_provisional());
2735        assert_eq!(state.logical_scroll_top(), list_offset(6, 40.));
2736        state.splice(0..0, 2);
2737        assert!(!state.0.borrow().is_provisional());
2738        draw(&state, 100., cx);
2739        assert!(state.0.borrow().is_provisional());
2740        assert_eq!(state.logical_scroll_top(), list_offset(8, 40.));
2741        ready.set(true);
2742        draw(&state, 100., cx);
2743        assert_eq!(state.logical_scroll_top(), list_offset(8, 40.));
2744        assert_eq!(
2745            painted.borrow().first().copied(),
2746            Some((8, px(-40.), px(60.)))
2747        );
2748    }
2749
2750    #[gpui::test]
2751    fn test_child_autoscroll_overrides_pending_and_release_fills_viewport(cx: &mut TestAppContext) {
2752        let cx = cx.add_empty_window();
2753        for (count, child, request, expected, released) in [
2754            (10, 2, 40.0..45.0, list_offset(2, 40.), list_offset(2, 40.)),
2755            (3, 2, 40.0..45.0, list_offset(2, 40.), list_offset(1, 0.)),
2756            (10, 4, 80.0..90.0, list_offset(2, 90.), list_offset(2, 90.)),
2757            (10, 2, -130.0..5.0, list_offset(0, 70.), list_offset(0, 70.)),
2758            (10, 2, -250.0..5.0, list_offset(0, 0.), list_offset(0, 0.)),
2759        ] {
2760            let state = ListState::new(count, crate::ListAlignment::Top, px(0.));
2761            let requested = Rc::new(Cell::new(false));
2762            let painted = Rc::new(RefCell::new(Vec::new()));
2763            let draw = |cx: &mut VisualTestContext| {
2764                painted.borrow_mut().clear();
2765                let painted = painted.clone();
2766                let requested = requested.clone();
2767                let request = request.clone();
2768                draw_list(
2769                    &state,
2770                    200.,
2771                    move |index| {
2772                        let painted = painted.clone();
2773                        let requested = requested.clone();
2774                        let request = request.clone();
2775                        canvas(
2776                            move |bounds, window, _| {
2777                                if index == child && !requested.replace(true) {
2778                                    window.request_autoscroll(Bounds::from_corners(
2779                                        point(bounds.left(), bounds.top() + px(request.start)),
2780                                        point(bounds.right(), bounds.top() + px(request.end)),
2781                                    ));
2782                                }
2783                            },
2784                            move |bounds, _, _, _| {
2785                                painted
2786                                    .borrow_mut()
2787                                    .push((index, bounds.top(), bounds.bottom()));
2788                            },
2789                        )
2790                        .h(px(100.))
2791                        .w_full()
2792                        .into_any()
2793                    },
2794                    cx,
2795                );
2796            };
2797            let ready = item_readiness(&state, false);
2798            state.scroll_to(list_offset(2, 50.));
2799            state.remeasure_items(2..3);
2800            for _ in 0..2 {
2801                draw(cx);
2802                assert!(requested.get());
2803                assert!(state.0.borrow().is_provisional());
2804                assert_eq!(state.logical_scroll_top(), expected);
2805                assert_eq!(
2806                    painted.borrow().first().copied(),
2807                    Some((
2808                        expected.item_ix,
2809                        -expected.offset_in_item,
2810                        px(100.) - expected.offset_in_item,
2811                    ))
2812                );
2813            }
2814            ready.set(true);
2815            draw(cx);
2816            assert!(!state.0.borrow().is_provisional());
2817            assert_eq!(state.logical_scroll_top(), released);
2818            let visible_count = if released.offset_in_item == px(0.) {
2819                2
2820            } else {
2821                3
2822            };
2823            assert_eq!(
2824                painted.borrow().as_slice(),
2825                (0..visible_count)
2826                    .map(|index| {
2827                        let top = px(index as f32 * 100.) - released.offset_in_item;
2828                        (released.item_ix + index, top, top + px(100.))
2829                    })
2830                    .collect::<Vec<_>>()
2831                    .as_slice()
2832            );
2833        }
2834    }
2835
2836    #[gpui::test]
2837    fn test_remeasure_then_scroll_does_not_revert_scroll_position(cx: &mut TestAppContext) {
2838        let cx = cx.add_empty_window();
2839
2840        let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
2841
2842        struct TestView(ListState);
2843        impl Render for TestView {
2844            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2845                list(self.0.clone(), |_, _, _| {
2846                    div().h(px(100.)).w_full().into_any()
2847                })
2848                .w_full()
2849                .h_full()
2850            }
2851        }
2852
2853        let view = {
2854            let state = state.clone();
2855            cx.update(|_, cx| cx.new(|_| TestView(state)))
2856        };
2857
2858        state.scroll_to(gpui::ListOffset {
2859            item_ix: 5,
2860            offset_in_item: px(40.),
2861        });
2862
2863        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2864            view.clone().into_any_element()
2865        });
2866
2867        state.remeasure_items(5..6);
2868
2869        cx.simulate_event(ScrollEvent {
2870            position: point(px(50.), px(100.)),
2871            delta: ScrollDelta::Pixels(point(px(0.), px(-30.))),
2872            ..Default::default()
2873        });
2874
2875        let offset = state.logical_scroll_top();
2876        assert_eq!(offset.item_ix, 5);
2877        assert_eq!(offset.offset_in_item, px(70.));
2878
2879        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2880            view.into_any_element()
2881        });
2882
2883        let offset = state.logical_scroll_top();
2884        assert_eq!(offset.item_ix, 5);
2885        assert_eq!(
2886            offset.offset_in_item,
2887            px(70.),
2888            "scrolling after a remeasure should not be reverted by the stale pending scroll"
2889        );
2890    }
2891
2892    #[gpui::test]
2893    fn test_scroll_after_remeasure_clamps_to_shrunk_item_height(cx: &mut TestAppContext) {
2894        let cx = cx.add_empty_window();
2895        for provisional in [false, true] {
2896            let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
2897            let painted = Rc::new(RefCell::new(Vec::new()));
2898            let draw = |height, cx: &mut VisualTestContext| {
2899                painted.borrow_mut().clear();
2900                let painted = painted.clone();
2901                draw_list(
2902                    &state,
2903                    200.,
2904                    move |index| {
2905                        painted_item(index, if index == 5 { height } else { 100. }, &painted)
2906                    },
2907                    cx,
2908                );
2909            };
2910            let ready = item_readiness(&state, true);
2911            state.scroll_to(list_offset(5, 40.));
2912            draw(100., cx);
2913            ready.set(!provisional);
2914            state.remeasure_items(5..6);
2915            if provisional {
2916                draw(100., cx);
2917            }
2918            ready.set(true);
2919            cx.simulate_event(ScrollEvent {
2920                position: point(px(50.), px(100.)),
2921                delta: ScrollDelta::Pixels(point(px(0.), px(-30.))),
2922                ..ScrollEvent::default()
2923            });
2924            assert_eq!(state.logical_scroll_top(), list_offset(5, 70.));
2925            for _ in 0..2 {
2926                draw(50., cx);
2927                assert_eq!(state.logical_scroll_top(), list_offset(5, 50.));
2928                assert_eq!(
2929                    painted
2930                        .borrow()
2931                        .iter()
2932                        .find(|(index, _, _)| *index == 6)
2933                        .copied(),
2934                    Some((6, px(0.), px(100.)))
2935                );
2936            }
2937        }
2938    }
2939
2940    #[gpui::test]
2941    fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
2942        let cx = cx.add_empty_window();
2943
2944        // 10 items, each 50px tall → 500px total content, 200px viewport.
2945        // With follow-tail on, the list should always show the bottom.
2946        let item_height = Rc::new(Cell::new(50usize));
2947        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2948
2949        struct TestView {
2950            state: ListState,
2951            item_height: Rc<Cell<usize>>,
2952        }
2953        impl Render for TestView {
2954            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2955                let height = self.item_height.get();
2956                list(self.state.clone(), move |_, _, _| {
2957                    div().h(px(height as f32)).w_full().into_any()
2958                })
2959                .w_full()
2960                .h_full()
2961            }
2962        }
2963
2964        let state_clone = state.clone();
2965        let item_height_clone = item_height.clone();
2966        let view = cx.update(|_, cx| {
2967            cx.new(|_| TestView {
2968                state: state_clone,
2969                item_height: item_height_clone,
2970            })
2971        });
2972
2973        state.set_follow_mode(FollowMode::Tail);
2974
2975        // First paint — items are 50px, total 500px, viewport 200px.
2976        // Follow-tail should anchor to the end.
2977        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2978            view.clone().into_any_element()
2979        });
2980
2981        // The scroll should be at the bottom: the last visible items fill the
2982        // 200px viewport from the end of 500px of content (offset 300px).
2983        let offset = state.logical_scroll_top();
2984        assert_eq!(offset.item_ix, 6);
2985        assert_eq!(offset.offset_in_item, px(0.));
2986        assert!(state.is_following_tail());
2987
2988        // Simulate items growing (e.g. streaming content makes each item taller).
2989        // 10 items × 80px = 800px total.
2990        item_height.set(80);
2991        state.remeasure();
2992
2993        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2994            view.into_any_element()
2995        });
2996
2997        // After growth, follow-tail should have re-anchored to the new end.
2998        // 800px total − 200px viewport = 600px offset → item 7 at offset 40px,
2999        // but follow-tail anchors to item_count (10), and layout walks back to
3000        // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200,
3001        // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking →
3002        // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40).
3003        let offset = state.logical_scroll_top();
3004        assert_eq!(offset.item_ix, 7);
3005        assert_eq!(offset.offset_in_item, px(40.));
3006        assert!(state.is_following_tail());
3007    }
3008
3009    #[gpui::test]
3010    fn test_pause_following_tail_reengages_when_still_at_bottom(cx: &mut TestAppContext) {
3011        let cx = cx.add_empty_window();
3012
3013        // 10 items × 50px = 500px total, 200px viewport.
3014        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
3015
3016        struct TestView(ListState);
3017        impl Render for TestView {
3018            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3019                list(self.0.clone(), |_, _, _| {
3020                    div().h(px(50.)).w_full().into_any()
3021                })
3022                .w_full()
3023                .h_full()
3024            }
3025        }
3026
3027        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3028        state.set_follow_mode(FollowMode::Tail);
3029
3030        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3031            view.clone().into_any_element()
3032        });
3033        assert!(state.is_following_tail());
3034
3035        // Pausing while the view is still at the bottom (e.g. a no-op zoom)
3036        // must not strand follow-tail: the next layout has to re-engage so the
3037        // invariant "at the bottom + new content => visible" is preserved.
3038        state.pause_following_tail();
3039        assert!(!state.is_following_tail());
3040
3041        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3042            view.into_any_element()
3043        });
3044        assert!(
3045            state.is_following_tail(),
3046            "pausing while at the bottom must re-engage follow-tail on the next layout"
3047        );
3048    }
3049
3050    #[gpui::test]
3051    fn test_pause_following_tail_freezes_off_bottom(cx: &mut TestAppContext) {
3052        let cx = cx.add_empty_window();
3053
3054        // 10 items, 200px viewport. Item height is adjustable to simulate a
3055        // diagram block growing/shrinking on zoom.
3056        let item_height = Rc::new(Cell::new(50usize));
3057        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
3058
3059        struct TestView {
3060            state: ListState,
3061            item_height: Rc<Cell<usize>>,
3062        }
3063        impl Render for TestView {
3064            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3065                let height = self.item_height.get();
3066                list(self.state.clone(), move |_, _, _| {
3067                    div().h(px(height as f32)).w_full().into_any()
3068                })
3069                .w_full()
3070                .h_full()
3071            }
3072        }
3073
3074        let view = cx.update(|_, cx| {
3075            cx.new(|_| TestView {
3076                state: state.clone(),
3077                item_height: item_height.clone(),
3078            })
3079        });
3080        state.set_follow_mode(FollowMode::Tail);
3081
3082        // At the bottom: 500px content, 200px viewport → top at item 6.
3083        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3084            view.clone().into_any_element()
3085        });
3086        assert_eq!(state.logical_scroll_top().item_ix, 6);
3087        assert!(state.is_following_tail());
3088
3089        // Pause, then grow items (a zoom-in that pushes content below the fold).
3090        // The frozen top must stay put rather than snapping to the new end, and
3091        // following stays paused since we're no longer at the bottom.
3092        state.pause_following_tail();
3093        item_height.set(80);
3094        state.remeasure();
3095        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3096            view.clone().into_any_element()
3097        });
3098        let offset = state.logical_scroll_top();
3099        assert_eq!(offset.item_ix, 6);
3100        assert_eq!(offset.offset_in_item, px(0.));
3101        assert!(
3102            !state.is_following_tail(),
3103            "a paused list must not re-engage while the frozen top is off the bottom"
3104        );
3105
3106        // Shrink back (zoom-out) so the frozen top once again reaches the
3107        // bottom: follow-tail must re-engage on its own.
3108        item_height.set(50);
3109        state.remeasure();
3110        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3111            view.into_any_element()
3112        });
3113        assert!(
3114            state.is_following_tail(),
3115            "returning to the bottom must restore follow-tail"
3116        );
3117    }
3118
3119    #[gpui::test]
3120    fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) {
3121        let cx = cx.add_empty_window();
3122
3123        // 10 items × 50px = 500px total, 200px viewport.
3124        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
3125
3126        struct TestView(ListState);
3127        impl Render for TestView {
3128            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3129                list(self.0.clone(), |_, _, _| {
3130                    div().h(px(50.)).w_full().into_any()
3131                })
3132                .w_full()
3133                .h_full()
3134            }
3135        }
3136
3137        state.set_follow_mode(FollowMode::Tail);
3138
3139        // Paint with follow-tail — scroll anchored to the bottom.
3140        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
3141            cx.new(|_| TestView(state.clone())).into_any_element()
3142        });
3143        assert!(state.is_following_tail());
3144
3145        // Simulate the user scrolling up.
3146        // This should disengage follow-tail.
3147        cx.simulate_event(ScrollEvent {
3148            position: point(px(50.), px(100.)),
3149            delta: ScrollDelta::Pixels(point(px(0.), px(100.))),
3150            ..Default::default()
3151        });
3152
3153        assert!(
3154            !state.is_following_tail(),
3155            "follow-tail should disengage when the user scrolls toward the start"
3156        );
3157    }
3158
3159    #[gpui::test]
3160    fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) {
3161        let cx = cx.add_empty_window();
3162
3163        // 10 items × 50px = 500px total, 200px viewport.
3164        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3165
3166        struct TestView(ListState);
3167        impl Render for TestView {
3168            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3169                list(self.0.clone(), |_, _, _| {
3170                    div().h(px(50.)).w_full().into_any()
3171                })
3172                .w_full()
3173                .h_full()
3174            }
3175        }
3176
3177        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3178
3179        state.set_follow_mode(FollowMode::Tail);
3180
3181        // Paint with follow-tail — scroll anchored to the bottom.
3182        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3183            view.clone().into_any_element()
3184        });
3185        assert!(state.is_following_tail());
3186
3187        // Simulate the scrollbar moving the viewport to the middle.
3188        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
3189
3190        let offset = state.logical_scroll_top();
3191        assert_eq!(offset.item_ix, 3);
3192        assert_eq!(offset.offset_in_item, px(0.));
3193        assert!(
3194            !state.is_following_tail(),
3195            "follow-tail should disengage when the scrollbar manually repositions the list"
3196        );
3197
3198        // A subsequent draw should preserve the user's manual position instead
3199        // of snapping back to the end.
3200        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3201            view.into_any_element()
3202        });
3203
3204        let offset = state.logical_scroll_top();
3205        assert_eq!(offset.item_ix, 3);
3206        assert_eq!(offset.offset_in_item, px(0.));
3207    }
3208
3209    #[gpui::test]
3210    fn test_scrollbar_drag_with_growing_content(cx: &mut TestAppContext) {
3211        let cx = cx.add_empty_window();
3212
3213        let last_item_height = Rc::new(Cell::new(50usize));
3214        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3215
3216        struct TestView {
3217            state: ListState,
3218            last_item_height: Rc<Cell<usize>>,
3219        }
3220        impl Render for TestView {
3221            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3222                let last_item_height = self.last_item_height.clone();
3223                list(self.state.clone(), move |index, _, _| {
3224                    let height = if index == 9 {
3225                        last_item_height.get()
3226                    } else {
3227                        50
3228                    };
3229                    div().h(px(height as f32)).w_full().into_any()
3230                })
3231                .w_full()
3232                .h_full()
3233            }
3234        }
3235
3236        let view = cx.update(|_, cx| {
3237            cx.new(|_| TestView {
3238                state: state.clone(),
3239                last_item_height: last_item_height.clone(),
3240            })
3241        });
3242
3243        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3244            view.clone().into_any_element()
3245        });
3246
3247        state.scrollbar_drag_started();
3248
3249        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
3250        let scrollbar_offset_before_growth = state.scroll_px_offset_for_scrollbar();
3251
3252        let offset = state.logical_scroll_top();
3253        assert_eq!(offset.item_ix, 3);
3254        assert_eq!(offset.offset_in_item, px(0.));
3255
3256        last_item_height.set(550);
3257        state.remeasure_items(9..10);
3258        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3259            view.clone().into_any_element()
3260        });
3261
3262        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
3263        assert_eq!(
3264            state.scroll_px_offset_for_scrollbar(),
3265            scrollbar_offset_before_growth
3266        );
3267
3268        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
3269        let offset = state.logical_scroll_top();
3270        assert_eq!(offset.item_ix, 3);
3271        assert_eq!(offset.offset_in_item, px(0.));
3272    }
3273
3274    #[gpui::test]
3275    fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) {
3276        let cx = cx.add_empty_window();
3277
3278        // 10 items × 50px = 500px total, 200px viewport.
3279        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
3280
3281        struct TestView(ListState);
3282        impl Render for TestView {
3283            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3284                list(self.0.clone(), |_, _, _| {
3285                    div().h(px(50.)).w_full().into_any()
3286                })
3287                .w_full()
3288                .h_full()
3289            }
3290        }
3291
3292        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3293
3294        // Scroll to the middle of the list (item 3).
3295        state.scroll_to(gpui::ListOffset {
3296            item_ix: 3,
3297            offset_in_item: px(0.),
3298        });
3299
3300        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3301            view.clone().into_any_element()
3302        });
3303
3304        let offset = state.logical_scroll_top();
3305        assert_eq!(offset.item_ix, 3);
3306        assert_eq!(offset.offset_in_item, px(0.));
3307        assert!(!state.is_following_tail());
3308
3309        // Enable follow-tail — this should immediately snap the scroll anchor
3310        // to the end, like the user just sent a prompt.
3311        state.set_follow_mode(FollowMode::Tail);
3312
3313        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3314            view.into_any_element()
3315        });
3316
3317        // After paint, scroll should be at the bottom.
3318        // 500px total − 200px viewport = 300px offset → item 6, offset 0.
3319        let offset = state.logical_scroll_top();
3320        assert_eq!(offset.item_ix, 6);
3321        assert_eq!(offset.offset_in_item, px(0.));
3322        assert!(state.is_following_tail());
3323    }
3324
3325    #[gpui::test]
3326    fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) {
3327        let cx = cx.add_empty_window();
3328
3329        const ITEMS: usize = 10;
3330        const ITEM_SIZE: f32 = 50.0;
3331
3332        let state = ListState::new(
3333            ITEMS,
3334            crate::ListAlignment::Bottom,
3335            px(ITEMS as f32 * ITEM_SIZE),
3336        );
3337
3338        struct TestView(ListState);
3339        impl Render for TestView {
3340            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3341                list(self.0.clone(), |_, _, _| {
3342                    div().h(px(ITEM_SIZE)).w_full().into_any()
3343                })
3344                .w_full()
3345                .h_full()
3346            }
3347        }
3348
3349        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
3350            cx.new(|_| TestView(state.clone())).into_any_element()
3351        });
3352
3353        // Bottom-aligned lists start pinned to the end: logical_scroll_top returns
3354        // item_ix == item_count, meaning no explicit scroll position has been set.
3355        assert_eq!(state.logical_scroll_top().item_ix, ITEMS);
3356
3357        let max_offset = state.max_offset_for_scrollbar();
3358        let scroll_offset = state.scroll_px_offset_for_scrollbar();
3359
3360        assert_eq!(
3361            -scroll_offset.y, max_offset.y,
3362            "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom",
3363            -scroll_offset.y, max_offset.y,
3364        );
3365    }
3366
3367    /// When the user scrolls away from the bottom during follow_tail,
3368    /// follow_tail suspends. If they scroll back to the bottom, the
3369    /// next paint should re-engage follow_tail using fresh measurements.
3370    #[gpui::test]
3371    fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) {
3372        let cx = cx.add_empty_window();
3373
3374        // 10 items × 50px = 500px total, 200px viewport.
3375        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
3376
3377        struct TestView(ListState);
3378        impl Render for TestView {
3379            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3380                list(self.0.clone(), |_, _, _| {
3381                    div().h(px(50.)).w_full().into_any()
3382                })
3383                .w_full()
3384                .h_full()
3385            }
3386        }
3387
3388        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3389
3390        state.set_follow_mode(FollowMode::Tail);
3391
3392        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3393            view.clone().into_any_element()
3394        });
3395        assert!(state.is_following_tail());
3396
3397        // Scroll up — follow_tail should suspend (not fully disengage).
3398        cx.simulate_event(ScrollEvent {
3399            position: point(px(50.), px(100.)),
3400            delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
3401            ..Default::default()
3402        });
3403        assert!(!state.is_following_tail());
3404
3405        // Scroll back down to the bottom.
3406        cx.simulate_event(ScrollEvent {
3407            position: point(px(50.), px(100.)),
3408            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
3409            ..Default::default()
3410        });
3411
3412        // After a paint, follow_tail should re-engage because the
3413        // layout confirmed we're at the true bottom.
3414        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3415            view.clone().into_any_element()
3416        });
3417        assert!(
3418            state.is_following_tail(),
3419            "follow_tail should re-engage after scrolling back to the bottom"
3420        );
3421    }
3422
3423    /// When an item is spliced to unmeasured (0px) while follow_tail
3424    /// is suspended, the re-engagement check should still work correctly
3425    #[gpui::test]
3426    fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) {
3427        let cx = cx.add_empty_window();
3428
3429        // 20 items × 50px = 1000px total, 200px viewport, 1000px
3430        // overdraw so all items get measured during the follow_tail
3431        // paint (matching realistic production settings).
3432        let state = ListState::new(20, crate::ListAlignment::Top, px(1000.));
3433
3434        struct TestView(ListState);
3435        impl Render for TestView {
3436            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3437                list(self.0.clone(), |_, _, _| {
3438                    div().h(px(50.)).w_full().into_any()
3439                })
3440                .w_full()
3441                .h_full()
3442            }
3443        }
3444
3445        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3446
3447        state.set_follow_mode(FollowMode::Tail);
3448
3449        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3450            view.clone().into_any_element()
3451        });
3452        assert!(state.is_following_tail());
3453
3454        // Scroll up a meaningful amount — suspends follow_tail.
3455        // 20 items × 50px = 1000px. viewport 200px. scroll_max = 800px.
3456        // Scrolling up 200px puts us at 600px, clearly not at bottom.
3457        cx.simulate_event(ScrollEvent {
3458            position: point(px(50.), px(100.)),
3459            delta: ScrollDelta::Pixels(point(px(0.), px(200.))),
3460            ..Default::default()
3461        });
3462        assert!(!state.is_following_tail());
3463
3464        // Invalidate the last item (simulates EntryUpdated calling
3465        // remeasure_items). This makes items.summary().height
3466        // temporarily wrong (0px for the invalidated item).
3467        state.remeasure_items(19..20);
3468
3469        // Paint — layout re-measures the invalidated item with its true
3470        // height. The re-engagement check uses these fresh measurements.
3471        // Since we scrolled 200px up from the 800px max, we're at
3472        // ~600px — NOT at the bottom, so follow_tail should NOT
3473        // re-engage.
3474        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3475            view.clone().into_any_element()
3476        });
3477        assert!(
3478            !state.is_following_tail(),
3479            "follow_tail should not falsely re-engage due to an unmeasured item \
3480             reducing items.summary().height"
3481        );
3482    }
3483
3484    #[gpui::test]
3485    fn test_follow_tail_reengages_after_scrollbar_disengagement(cx: &mut TestAppContext) {
3486        let cx = cx.add_empty_window();
3487
3488        // 10 items × 50px = 500px total, 200px viewport, scroll_max = 300px.
3489        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3490
3491        struct TestView(ListState);
3492        impl Render for TestView {
3493            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3494                list(self.0.clone(), |_, _, _| {
3495                    div().h(px(50.)).w_full().into_any()
3496                })
3497                .w_full()
3498                .h_full()
3499            }
3500        }
3501
3502        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3503
3504        state.set_follow_mode(FollowMode::Tail);
3505        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3506            view.clone().into_any_element()
3507        });
3508        assert!(state.is_following_tail());
3509
3510        // Drag the scrollbar up to the middle — follow_tail should suspend.
3511        state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
3512        assert!(!state.is_following_tail());
3513
3514        // Drag the scrollbar back to the bottom — follow_tail should re-engage
3515        // on the next paint.
3516        state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
3517        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3518            view.into_any_element()
3519        });
3520        assert!(
3521            state.is_following_tail(),
3522            "follow_tail should re-engage after scrolling back to the bottom via the scrollbar"
3523        );
3524    }
3525
3526    #[gpui::test]
3527    fn test_follow_tail_reengages_after_scrollbar_drag_to_bottom_while_growing(
3528        cx: &mut TestAppContext,
3529    ) {
3530        let cx = cx.add_empty_window();
3531
3532        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3533
3534        struct TestView(ListState);
3535        impl Render for TestView {
3536            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3537                list(self.0.clone(), |_, _, _| {
3538                    div().h(px(50.)).w_full().into_any()
3539                })
3540                .w_full()
3541                .h_full()
3542            }
3543        }
3544
3545        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
3546
3547        state.set_follow_mode(FollowMode::Tail);
3548        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3549            view.clone().into_any_element()
3550        });
3551        assert!(state.is_following_tail());
3552
3553        state.scrollbar_drag_started();
3554
3555        state.splice(10..10, 10);
3556        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3557            view.clone().into_any_element()
3558        });
3559
3560        state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
3561        state.scrollbar_drag_ended();
3562
3563        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
3564            view.into_any_element()
3565        });
3566
3567        assert!(
3568            state.is_following_tail(),
3569            "follow_tail should re-engage when the user drags the scrollbar to \
3570             the bottom of its track, even when content has grown during the drag \
3571             (so frozen_bottom < live_bottom)"
3572        );
3573    }
3574
3575    #[gpui::test]
3576    fn test_horizontal_padding_insets_items(cx: &mut TestAppContext) {
3577        let cx = cx.add_empty_window();
3578
3579        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3580        let item_bounds = Rc::new(RefCell::new(Vec::new()));
3581
3582        struct TestView {
3583            state: ListState,
3584            item_bounds: Rc<RefCell<Vec<Bounds<Pixels>>>>,
3585        }
3586        impl Render for TestView {
3587            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3588                let item_bounds = self.item_bounds.clone();
3589                list(self.state.clone(), move |_, _, _| {
3590                    let item_bounds = item_bounds.clone();
3591                    canvas(
3592                        move |bounds, _, _| item_bounds.borrow_mut().push(bounds),
3593                        |_, _, _, _| {},
3594                    )
3595                    .w_full()
3596                    .aspect_ratio(2.)
3597                    .into_any()
3598                })
3599                .pl(px(10.))
3600                .pr(px(30.))
3601                .w_full()
3602                .h_full()
3603            }
3604        }
3605
3606        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
3607            cx.new(|_| TestView {
3608                state: state.clone(),
3609                item_bounds: item_bounds.clone(),
3610            })
3611            .into_any_element()
3612        });
3613
3614        for bounds in item_bounds.borrow().iter() {
3615            assert_eq!(bounds.origin.x, px(10.));
3616            assert_eq!(bounds.size.width, px(60.));
3617        }
3618        // Each item is 60px wide, so 30px tall: 300px in total.
3619        assert_eq!(state.max_offset_for_scrollbar().y, px(200.));
3620    }
3621
3622    #[gpui::test]
3623    fn test_inferred_width_includes_horizontal_padding(cx: &mut TestAppContext) {
3624        let cx = cx.add_empty_window();
3625
3626        let state = ListState::new(2, crate::ListAlignment::Top, px(10.));
3627        let full_width_item_bounds = Rc::new(RefCell::new(None));
3628
3629        struct TestView {
3630            state: ListState,
3631            full_width_item_bounds: Rc<RefCell<Option<Bounds<Pixels>>>>,
3632        }
3633        impl Render for TestView {
3634            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3635                let full_width_item_bounds = self.full_width_item_bounds.clone();
3636                div().flex().size_full().child(
3637                    list(self.state.clone(), move |ix, _, _| {
3638                        if ix == 0 {
3639                            div().w(px(50.)).h(px(20.)).into_any()
3640                        } else {
3641                            let full_width_item_bounds = full_width_item_bounds.clone();
3642                            canvas(
3643                                move |bounds, _, _| {
3644                                    *full_width_item_bounds.borrow_mut() = Some(bounds)
3645                                },
3646                                |_, _, _, _| {},
3647                            )
3648                            .h(px(20.))
3649                            .w_full()
3650                            .into_any()
3651                        }
3652                    })
3653                    .with_sizing_behavior(ListSizingBehavior::Infer)
3654                    .pl(px(10.))
3655                    .pr(px(30.))
3656                    .h_full(),
3657                )
3658            }
3659        }
3660
3661        cx.draw(point(px(0.), px(0.)), size(px(200.), px(100.)), |_, cx| {
3662            cx.new(|_| TestView {
3663                state: state.clone(),
3664                full_width_item_bounds: full_width_item_bounds.clone(),
3665            })
3666            .into_any_element()
3667        });
3668
3669        let bounds = full_width_item_bounds
3670            .borrow()
3671            .expect("item 1 was not painted");
3672        assert_eq!(bounds.origin.x, px(10.));
3673        assert_eq!(bounds.size.width, px(50.));
3674    }
3675
3676    #[gpui::test]
3677    fn test_autoscroll_above_item_top_with_horizontal_padding(cx: &mut TestAppContext) {
3678        let cx = cx.add_empty_window();
3679
3680        let state = ListState::new(5, crate::ListAlignment::Top, px(0.));
3681        state.scroll_to(gpui::ListOffset {
3682            item_ix: 2,
3683            offset_in_item: px(0.),
3684        });
3685
3686        struct TestView(ListState);
3687        impl Render for TestView {
3688            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3689                list(self.0.clone(), |ix, _, _| {
3690                    if ix == 2 {
3691                        canvas(
3692                            |bounds, window, _| {
3693                                window.request_autoscroll(Bounds::from_corners(
3694                                    point(bounds.left(), bounds.top() - px(30.)),
3695                                    point(bounds.right(), bounds.top() + px(5.)),
3696                                ));
3697                            },
3698                            |_, _, _, _| {},
3699                        )
3700                        .w_full()
3701                        .aspect_ratio(2.)
3702                        .into_any()
3703                    } else {
3704                        div().w_full().aspect_ratio(2.).into_any()
3705                    }
3706                })
3707                .pl(px(10.))
3708                .pr(px(30.))
3709                .w_full()
3710                .h_full()
3711            }
3712        }
3713
3714        cx.draw(point(px(0.), px(0.)), size(px(100.), px(60.)), |_, cx| {
3715            cx.new(|_| TestView(state.clone())).into_any_element()
3716        });
3717
3718        // Items are 60px wide, so 30px tall: 30px above item 2 is the top of item 1.
3719        let scroll_top = state.logical_scroll_top();
3720        assert_eq!(scroll_top.item_ix, 1);
3721        assert_eq!(scroll_top.offset_in_item, px(0.));
3722    }
3723
3724    #[gpui::test]
3725    fn test_horizontal_padding_change_remeasures_items(cx: &mut TestAppContext) {
3726        let cx = cx.add_empty_window();
3727
3728        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3729
3730        struct TestView {
3731            state: ListState,
3732            padding_right: Pixels,
3733        }
3734        impl Render for TestView {
3735            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3736                list(self.state.clone(), |_, _, _| {
3737                    div().w_full().aspect_ratio(2.).into_any()
3738                })
3739                .pl(px(10.))
3740                .pr(self.padding_right)
3741                .w_full()
3742                .h_full()
3743            }
3744        }
3745
3746        // The list width stays at 100px while the padding shrinks, so items go
3747        // from 60px wide (30px tall) to 80px wide (40px tall).
3748        for (padding_right, expected_max_offset) in [(px(30.), px(200.)), (px(10.), px(300.))] {
3749            cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
3750                cx.new(|_| TestView {
3751                    state: state.clone(),
3752                    padding_right,
3753                })
3754                .into_any_element()
3755            });
3756            assert_eq!(state.max_offset_for_scrollbar().y, expected_max_offset);
3757        }
3758    }
3759
3760    #[gpui::test]
3761    fn test_border_and_padding_inset_items(cx: &mut TestAppContext) {
3762        let cx = cx.add_empty_window();
3763
3764        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
3765        let item_bounds = Rc::new(RefCell::new(Vec::new()));
3766
3767        struct TestView {
3768            state: ListState,
3769            item_bounds: Rc<RefCell<Vec<Bounds<Pixels>>>>,
3770        }
3771        impl Render for TestView {
3772            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3773                let item_bounds = self.item_bounds.clone();
3774                list(self.state.clone(), move |_, _, _| {
3775                    let item_bounds = item_bounds.clone();
3776                    canvas(
3777                        move |bounds, _, _| item_bounds.borrow_mut().push(bounds),
3778                        |_, _, _, _| {},
3779                    )
3780                    .w_full()
3781                    .aspect_ratio(2.)
3782                    .into_any()
3783                })
3784                .border_l_4()
3785                .pl(px(6.))
3786                .border_r_8()
3787                .pr(px(22.))
3788                .w_full()
3789                .h_full()
3790            }
3791        }
3792
3793        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
3794            cx.new(|_| TestView {
3795                state: state.clone(),
3796                item_bounds: item_bounds.clone(),
3797            })
3798            .into_any_element()
3799        });
3800
3801        let painted_bounds = item_bounds.borrow();
3802        assert!(!painted_bounds.is_empty());
3803        for bounds in painted_bounds.iter() {
3804            assert_eq!(bounds.origin.x, px(10.));
3805            assert_eq!(bounds.size.width, px(60.));
3806        }
3807        assert_eq!(state.max_offset_for_scrollbar().y, px(200.));
3808
3809        let reported_bounds = state.bounds_for_item(0).expect("item 0 should be measured");
3810        assert_eq!(reported_bounds.origin.x, px(10.));
3811        assert_eq!(reported_bounds.size.width, px(60.));
3812    }
3813
3814    fn draw_list(
3815        state: &ListState,
3816        viewport_height: f32,
3817        render_item: impl Fn(usize) -> AnyElement + 'static,
3818        cx: &mut VisualTestContext,
3819    ) {
3820        cx.draw(
3821            point(px(0.), px(0.)),
3822            size(px(100.), px(viewport_height)),
3823            |_, cx| {
3824                cx.new(|_| TestView {
3825                    state: state.clone(),
3826                    render_item: Rc::new(render_item),
3827                })
3828                .into_any_element()
3829            },
3830        );
3831    }
3832
3833    fn painted_item(
3834        index: usize,
3835        height: f32,
3836        painted: &Rc<RefCell<Vec<(usize, Pixels, Pixels)>>>,
3837    ) -> AnyElement {
3838        let painted = painted.clone();
3839        canvas(
3840            |_, _, _| {},
3841            move |bounds, _, _, _| {
3842                painted
3843                    .borrow_mut()
3844                    .push((index, bounds.top(), bounds.bottom()));
3845            },
3846        )
3847        .h(px(height))
3848        .w_full()
3849        .into_any()
3850    }
3851
3852    fn item_readiness(state: &ListState, ready: bool) -> Rc<Cell<bool>> {
3853        let ready = Rc::new(Cell::new(ready));
3854        state.set_item_measurement_readiness({
3855            let ready = ready.clone();
3856            move |_, _| ready.get()
3857        });
3858        ready
3859    }
3860
3861    fn list_offset(item_ix: usize, offset: f32) -> ListOffset {
3862        ListOffset {
3863            item_ix,
3864            offset_in_item: px(offset),
3865        }
3866    }
3867
3868    struct TestView {
3869        state: ListState,
3870        render_item: Rc<dyn Fn(usize) -> AnyElement>,
3871    }
3872
3873    impl Render for TestView {
3874        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3875            let render_item = self.render_item.clone();
3876            list(self.state.clone(), move |index, _, _| render_item(index))
3877                .w_full()
3878                .h_full()
3879        }
3880    }
3881
3882    struct TestListView(ListState);
3883    impl Render for TestListView {
3884        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
3885            list(self.0.clone(), |_, _, _| {
3886                div().h(px(20.)).w_full().into_any()
3887            })
3888            .w_full()
3889            .h_full()
3890        }
3891    }
3892}