Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T06:40:55.780Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

list.rs

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