list.rs

   1//! A list element that can be used to render a large number of differently sized elements
   2//! efficiently. Clients of this API need to ensure that elements outside of the scrolled
   3//! area do not change their height for this element to function correctly. If your elements
   4//! do change height, notify the list element via [`ListState::splice`] or [`ListState::reset`].
   5//! In order to minimize re-renders, this element's state is stored intrusively
   6//! on your own views, so that your code can coordinate directly with the list element's cached state.
   7//!
   8//! If all of your elements are the same height, see [`crate::UniformList`] for a simpler API
   9
  10use crate::{
  11    AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges, Element, EntityId,
  12    FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, IntoElement,
  13    Overflow, Pixels, Point, ScrollDelta, ScrollWheelEvent, Size, Style, StyleRefinement, Styled,
  14    Window, point, px, size,
  15};
  16use collections::VecDeque;
  17use refineable::Refineable as _;
  18use std::{cell::RefCell, ops::Range, rc::Rc};
  19use sum_tree::{Bias, Dimensions, SumTree};
  20
  21type RenderItemFn = dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static;
  22
  23/// Construct a new list element
  24pub fn list(
  25    state: ListState,
  26    render_item: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
  27) -> List {
  28    List {
  29        state,
  30        render_item: Box::new(render_item),
  31        style: StyleRefinement::default(),
  32        sizing_behavior: ListSizingBehavior::default(),
  33    }
  34}
  35
  36/// A list element
  37pub struct List {
  38    state: ListState,
  39    render_item: Box<RenderItemFn>,
  40    style: StyleRefinement,
  41    sizing_behavior: ListSizingBehavior,
  42}
  43
  44impl List {
  45    /// Set the sizing behavior for the list.
  46    pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
  47        self.sizing_behavior = behavior;
  48        self
  49    }
  50}
  51
  52/// The list state that views must hold on behalf of the list element.
  53#[derive(Clone)]
  54pub struct ListState(Rc<RefCell<StateInner>>);
  55
  56impl std::fmt::Debug for ListState {
  57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  58        f.write_str("ListState")
  59    }
  60}
  61
  62struct StateInner {
  63    last_layout_bounds: Option<Bounds<Pixels>>,
  64    last_padding: Option<Edges<Pixels>>,
  65    items: SumTree<ListItem>,
  66    logical_scroll_top: Option<ListOffset>,
  67    alignment: ListAlignment,
  68    overdraw: Pixels,
  69    reset: bool,
  70    #[allow(clippy::type_complexity)]
  71    scroll_handler: Option<Box<dyn FnMut(&ListScrollEvent, &mut Window, &mut App)>>,
  72    scrollbar_drag_start_height: Option<Pixels>,
  73    measuring_behavior: ListMeasuringBehavior,
  74    pending_scroll: Option<PendingScrollFraction>,
  75    follow_state: FollowState,
  76}
  77
  78/// Keeps track of a fractional scroll position within an item for restoration
  79/// after remeasurement.
  80struct PendingScrollFraction {
  81    /// The index of the item to scroll within.
  82    item_ix: usize,
  83    /// Fractional offset (0.0 to 1.0) within the item's height.
  84    fraction: f32,
  85}
  86
  87/// Controls whether the list automatically follows new content at the end.
  88#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
  89pub enum FollowMode {
  90    /// Normal scrolling β€” no automatic following.
  91    #[default]
  92    Normal,
  93    /// The list should auto-scroll along with the tail, when scrolled to bottom.
  94    Tail,
  95}
  96
  97#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
  98enum FollowState {
  99    #[default]
 100    Normal,
 101    Tail {
 102        is_following: bool,
 103    },
 104}
 105
 106impl FollowState {
 107    fn is_following(&self) -> bool {
 108        matches!(self, FollowState::Tail { is_following: true })
 109    }
 110
 111    fn has_stopped_following(&self) -> bool {
 112        matches!(
 113            self,
 114            FollowState::Tail {
 115                is_following: false
 116            }
 117        )
 118    }
 119
 120    fn start_following(&mut self) {
 121        if let FollowState::Tail {
 122            is_following: false,
 123        } = self
 124        {
 125            *self = FollowState::Tail { is_following: true };
 126        }
 127    }
 128}
 129
 130/// Whether the list is scrolling from top to bottom or bottom to top.
 131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
 132pub enum ListAlignment {
 133    /// The list is scrolling from top to bottom, like most lists.
 134    Top,
 135    /// The list is scrolling from bottom to top, like a chat log.
 136    Bottom,
 137}
 138
 139/// A scroll event that has been converted to be in terms of the list's items.
 140pub struct ListScrollEvent {
 141    /// The range of items currently visible in the list, after applying the scroll event.
 142    pub visible_range: Range<usize>,
 143
 144    /// The number of items that are currently visible in the list, after applying the scroll event.
 145    pub count: usize,
 146
 147    /// Whether the list has been scrolled.
 148    pub is_scrolled: bool,
 149
 150    /// Whether the list is currently in follow-tail mode (auto-scrolling to end).
 151    pub is_following_tail: bool,
 152}
 153
 154/// The sizing behavior to apply during layout.
 155#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
 156pub enum ListSizingBehavior {
 157    /// The list should calculate its size based on the size of its items.
 158    Infer,
 159    /// The list should not calculate a fixed size.
 160    #[default]
 161    Auto,
 162}
 163
 164/// The measuring behavior to apply during layout.
 165#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
 166pub enum ListMeasuringBehavior {
 167    /// Measure all items in the list.
 168    /// Note: This can be expensive for the first frame in a large list.
 169    Measure(bool),
 170    /// Only measure visible items
 171    #[default]
 172    Visible,
 173}
 174
 175impl ListMeasuringBehavior {
 176    fn reset(&mut self) {
 177        match self {
 178            ListMeasuringBehavior::Measure(has_measured) => *has_measured = false,
 179            ListMeasuringBehavior::Visible => {}
 180        }
 181    }
 182}
 183
 184/// The horizontal sizing behavior to apply during layout.
 185#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
 186pub enum ListHorizontalSizingBehavior {
 187    /// List items' width can never exceed the width of the list.
 188    #[default]
 189    FitList,
 190    /// List items' width may go over the width of the list, if any item is wider.
 191    Unconstrained,
 192}
 193
 194struct LayoutItemsResponse {
 195    max_item_width: Pixels,
 196    scroll_top: ListOffset,
 197    item_layouts: VecDeque<ItemLayout>,
 198}
 199
 200struct ItemLayout {
 201    index: usize,
 202    element: AnyElement,
 203    size: Size<Pixels>,
 204}
 205
 206/// Frame state used by the [List] element after layout.
 207pub struct ListPrepaintState {
 208    hitbox: Hitbox,
 209    layout: LayoutItemsResponse,
 210}
 211
 212#[derive(Clone)]
 213enum ListItem {
 214    Unmeasured {
 215        size_hint: Option<Size<Pixels>>,
 216        focus_handle: Option<FocusHandle>,
 217    },
 218    Measured {
 219        size: Size<Pixels>,
 220        focus_handle: Option<FocusHandle>,
 221    },
 222}
 223
 224impl ListItem {
 225    fn size(&self) -> Option<Size<Pixels>> {
 226        if let ListItem::Measured { size, .. } = self {
 227            Some(*size)
 228        } else {
 229            None
 230        }
 231    }
 232
 233    fn size_hint(&self) -> Option<Size<Pixels>> {
 234        match self {
 235            ListItem::Measured { size, .. } => Some(*size),
 236            ListItem::Unmeasured { size_hint, .. } => *size_hint,
 237        }
 238    }
 239
 240    fn focus_handle(&self) -> Option<FocusHandle> {
 241        match self {
 242            ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
 243                focus_handle.clone()
 244            }
 245        }
 246    }
 247
 248    fn contains_focused(&self, window: &Window, cx: &App) -> bool {
 249        match self {
 250            ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
 251                focus_handle
 252                    .as_ref()
 253                    .is_some_and(|handle| handle.contains_focused(window, cx))
 254            }
 255        }
 256    }
 257}
 258
 259#[derive(Clone, Debug, Default, PartialEq)]
 260struct ListItemSummary {
 261    count: usize,
 262    rendered_count: usize,
 263    unrendered_count: usize,
 264    height: Pixels,
 265    has_focus_handles: bool,
 266}
 267
 268#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
 269struct Count(usize);
 270
 271#[derive(Clone, Debug, Default)]
 272struct Height(Pixels);
 273
 274impl ListState {
 275    /// Construct a new list state, for storage on a view.
 276    ///
 277    /// The overdraw parameter controls how much extra space is rendered
 278    /// above and below the visible area. Elements within this area will
 279    /// be measured even though they are not visible. This can help ensure
 280    /// that the list doesn't flicker or pop in when scrolling.
 281    pub fn new(item_count: usize, alignment: ListAlignment, overdraw: Pixels) -> Self {
 282        let this = Self(Rc::new(RefCell::new(StateInner {
 283            last_layout_bounds: None,
 284            last_padding: None,
 285            items: SumTree::default(),
 286            logical_scroll_top: None,
 287            alignment,
 288            overdraw,
 289            scroll_handler: None,
 290            reset: false,
 291            scrollbar_drag_start_height: None,
 292            measuring_behavior: ListMeasuringBehavior::default(),
 293            pending_scroll: None,
 294            follow_state: FollowState::default(),
 295        })));
 296        this.splice(0..0, item_count);
 297        this
 298    }
 299
 300    /// Set the list to measure all items in the list in the first layout phase.
 301    ///
 302    /// This is useful for ensuring that the scrollbar size is correct instead of based on only rendered elements.
 303    pub fn measure_all(self) -> Self {
 304        self.0.borrow_mut().measuring_behavior = ListMeasuringBehavior::Measure(false);
 305        self
 306    }
 307
 308    /// Reset this instantiation of the list state.
 309    ///
 310    /// Note that this will cause scroll events to be dropped until the next paint.
 311    pub fn reset(&self, element_count: usize) {
 312        let old_count = {
 313            let state = &mut *self.0.borrow_mut();
 314            state.reset = true;
 315            state.measuring_behavior.reset();
 316            state.logical_scroll_top = None;
 317            state.scrollbar_drag_start_height = None;
 318            state.items.summary().count
 319        };
 320
 321        self.splice(0..old_count, element_count);
 322    }
 323
 324    /// Remeasure all items while preserving proportional scroll position.
 325    ///
 326    /// Use this when item heights may have changed (e.g., font size changes)
 327    /// but the number and identity of items remains the same.
 328    pub fn remeasure(&self) {
 329        let count = self.item_count();
 330        self.remeasure_items(0..count);
 331    }
 332
 333    /// Mark items in `range` as needing remeasurement while preserving
 334    /// the current scroll position. Unlike [`Self::splice`], this does
 335    /// not change the number of items or blow away `logical_scroll_top`.
 336    ///
 337    /// Use this when an item's content has changed and its rendered
 338    /// height may be different (e.g., streaming text, tool results
 339    /// loading), but the item itself still exists at the same index.
 340    pub fn remeasure_items(&self, range: Range<usize>) {
 341        let state = &mut *self.0.borrow_mut();
 342
 343        // If the scroll-top item falls within the remeasured range,
 344        // store a fractional offset so the layout can restore the
 345        // proportional scroll position after the item is re-rendered
 346        // at its new height.
 347        if let Some(scroll_top) = state.logical_scroll_top {
 348            if range.contains(&scroll_top.item_ix) {
 349                let mut cursor = state.items.cursor::<Count>(());
 350                cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
 351
 352                if let Some(item) = cursor.item() {
 353                    if let Some(size) = item.size() {
 354                        let fraction = if size.height.0 > 0.0 {
 355                            (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0)
 356                        } else {
 357                            0.0
 358                        };
 359
 360                        state.pending_scroll = Some(PendingScrollFraction {
 361                            item_ix: scroll_top.item_ix,
 362                            fraction,
 363                        });
 364                    }
 365                }
 366            }
 367        }
 368
 369        // Rebuild the tree, replacing items in the range with
 370        // Unmeasured copies that keep their focus handles.
 371        let new_items = {
 372            let mut cursor = state.items.cursor::<Count>(());
 373            let mut new_items = cursor.slice(&Count(range.start), Bias::Right);
 374            let invalidated = cursor.slice(&Count(range.end), Bias::Right);
 375            new_items.extend(
 376                invalidated.iter().map(|item| ListItem::Unmeasured {
 377                    size_hint: item.size_hint(),
 378                    focus_handle: item.focus_handle(),
 379                }),
 380                (),
 381            );
 382            new_items.append(cursor.suffix(), ());
 383            new_items
 384        };
 385        state.items = new_items;
 386        state.measuring_behavior.reset();
 387    }
 388
 389    /// The number of items in this list.
 390    pub fn item_count(&self) -> usize {
 391        self.0.borrow().items.summary().count
 392    }
 393
 394    /// Inform the list state that the items in `old_range` have been replaced
 395    /// by `count` new items that must be recalculated.
 396    pub fn splice(&self, old_range: Range<usize>, count: usize) {
 397        self.splice_focusable(old_range, (0..count).map(|_| None))
 398    }
 399
 400    /// Register with the list state that the items in `old_range` have been replaced
 401    /// by new items. As opposed to [`Self::splice`], this method allows an iterator of optional focus handles
 402    /// to be supplied to properly integrate with items in the list that can be focused. If a focused item
 403    /// is scrolled out of view, the list will continue to render it to allow keyboard interaction.
 404    pub fn splice_focusable(
 405        &self,
 406        old_range: Range<usize>,
 407        focus_handles: impl IntoIterator<Item = Option<FocusHandle>>,
 408    ) {
 409        let state = &mut *self.0.borrow_mut();
 410
 411        let mut old_items = state.items.cursor::<Count>(());
 412        let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right);
 413        old_items.seek_forward(&Count(old_range.end), Bias::Right);
 414
 415        let mut spliced_count = 0;
 416        new_items.extend(
 417            focus_handles.into_iter().map(|focus_handle| {
 418                spliced_count += 1;
 419                ListItem::Unmeasured {
 420                    size_hint: None,
 421                    focus_handle,
 422                }
 423            }),
 424            (),
 425        );
 426        new_items.append(old_items.suffix(), ());
 427        drop(old_items);
 428        state.items = new_items;
 429
 430        if let Some(ListOffset {
 431            item_ix,
 432            offset_in_item,
 433        }) = state.logical_scroll_top.as_mut()
 434        {
 435            if old_range.contains(item_ix) {
 436                *item_ix = old_range.start;
 437                *offset_in_item = px(0.);
 438            } else if old_range.end <= *item_ix {
 439                *item_ix = *item_ix - (old_range.end - old_range.start) + spliced_count;
 440            }
 441        }
 442    }
 443
 444    /// Set a handler that will be called when the list is scrolled.
 445    pub fn set_scroll_handler(
 446        &self,
 447        handler: impl FnMut(&ListScrollEvent, &mut Window, &mut App) + 'static,
 448    ) {
 449        self.0.borrow_mut().scroll_handler = Some(Box::new(handler))
 450    }
 451
 452    /// Get the current scroll offset, in terms of the list's items.
 453    pub fn logical_scroll_top(&self) -> ListOffset {
 454        self.0.borrow().logical_scroll_top()
 455    }
 456
 457    /// Scroll the list by the given offset
 458    pub fn scroll_by(&self, distance: Pixels) {
 459        if distance == px(0.) {
 460            return;
 461        }
 462
 463        let current_offset = self.logical_scroll_top();
 464        let state = &mut *self.0.borrow_mut();
 465
 466        if distance < px(0.) {
 467            if let FollowState::Tail { is_following } = &mut state.follow_state {
 468                *is_following = false;
 469            }
 470        }
 471
 472        let mut cursor = state.items.cursor::<ListItemSummary>(());
 473        cursor.seek(&Count(current_offset.item_ix), Bias::Right);
 474
 475        let start_pixel_offset = cursor.start().height + current_offset.offset_in_item;
 476        let new_pixel_offset = (start_pixel_offset + distance).max(px(0.));
 477        if new_pixel_offset > start_pixel_offset {
 478            cursor.seek_forward(&Height(new_pixel_offset), Bias::Right);
 479        } else {
 480            cursor.seek(&Height(new_pixel_offset), Bias::Right);
 481        }
 482
 483        state.logical_scroll_top = Some(ListOffset {
 484            item_ix: cursor.start().count,
 485            offset_in_item: new_pixel_offset - cursor.start().height,
 486        });
 487    }
 488
 489    /// Scroll the list to the very end (past the last item).
 490    ///
 491    /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the
 492    /// anchor, so the list's layout pass will walk backwards from the end and
 493    /// always show the bottom of the last item β€” even when that item is still
 494    /// growing (e.g. during streaming).
 495    pub fn scroll_to_end(&self) {
 496        let state = &mut *self.0.borrow_mut();
 497        let item_count = state.items.summary().count;
 498        state.logical_scroll_top = Some(ListOffset {
 499            item_ix: item_count,
 500            offset_in_item: px(0.),
 501        });
 502    }
 503
 504    /// Set the follow mode for the list. In `Tail` mode, the list
 505    /// will auto-scroll to the end and re-engage after the user
 506    /// scrolls back to the bottom. In `Normal` mode, no automatic
 507    /// following occurs.
 508    pub fn set_follow_mode(&self, mode: FollowMode) {
 509        let state = &mut *self.0.borrow_mut();
 510
 511        match mode {
 512            FollowMode::Normal => {
 513                state.follow_state = FollowState::Normal;
 514            }
 515            FollowMode::Tail => {
 516                state.follow_state = FollowState::Tail { is_following: true };
 517                if matches!(mode, FollowMode::Tail) {
 518                    let item_count = state.items.summary().count;
 519                    state.logical_scroll_top = Some(ListOffset {
 520                        item_ix: item_count,
 521                        offset_in_item: px(0.),
 522                    });
 523                }
 524            }
 525        }
 526    }
 527
 528    /// Returns whether the list is currently actively following the
 529    /// tail (snapping to the end on each layout).
 530    pub fn is_following_tail(&self) -> bool {
 531        matches!(
 532            self.0.borrow().follow_state,
 533            FollowState::Tail { is_following: true }
 534        )
 535    }
 536
 537    /// Scroll the list to the given offset
 538    pub fn scroll_to(&self, mut scroll_top: ListOffset) {
 539        let state = &mut *self.0.borrow_mut();
 540        let item_count = state.items.summary().count;
 541        if scroll_top.item_ix >= item_count {
 542            scroll_top.item_ix = item_count;
 543            scroll_top.offset_in_item = px(0.);
 544        }
 545
 546        if scroll_top.item_ix < item_count {
 547            if let FollowState::Tail { is_following } = &mut state.follow_state {
 548                *is_following = false;
 549            }
 550        }
 551
 552        state.logical_scroll_top = Some(scroll_top);
 553    }
 554
 555    /// Scroll the list to the given item, such that the item is fully visible.
 556    pub fn scroll_to_reveal_item(&self, ix: usize) {
 557        let state = &mut *self.0.borrow_mut();
 558
 559        let mut scroll_top = state.logical_scroll_top();
 560        let height = state
 561            .last_layout_bounds
 562            .map_or(px(0.), |bounds| bounds.size.height);
 563        let padding = state.last_padding.unwrap_or_default();
 564
 565        if ix <= scroll_top.item_ix {
 566            scroll_top.item_ix = ix;
 567            scroll_top.offset_in_item = px(0.);
 568        } else {
 569            let mut cursor = state.items.cursor::<ListItemSummary>(());
 570            cursor.seek(&Count(ix + 1), Bias::Right);
 571            let bottom = cursor.start().height + padding.top;
 572            let goal_top = px(0.).max(bottom - height + padding.bottom);
 573
 574            cursor.seek(&Height(goal_top), Bias::Left);
 575            let start_ix = cursor.start().count;
 576            let start_item_top = cursor.start().height;
 577
 578            if start_ix >= scroll_top.item_ix {
 579                scroll_top.item_ix = start_ix;
 580                scroll_top.offset_in_item = goal_top - start_item_top;
 581            }
 582        }
 583
 584        state.logical_scroll_top = Some(scroll_top);
 585    }
 586
 587    /// Get the bounds for the given item in window coordinates, if it's
 588    /// been rendered.
 589    pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
 590        let state = &*self.0.borrow();
 591
 592        let bounds = state.last_layout_bounds.unwrap_or_default();
 593        let scroll_top = state.logical_scroll_top();
 594        if ix < scroll_top.item_ix {
 595            return None;
 596        }
 597
 598        let mut cursor = state.items.cursor::<Dimensions<Count, Height>>(());
 599        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
 600
 601        let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item;
 602
 603        cursor.seek_forward(&Count(ix), Bias::Right);
 604        if let Some(&ListItem::Measured { size, .. }) = cursor.item() {
 605            let &Dimensions(Count(count), Height(top), _) = cursor.start();
 606            if count == ix {
 607                let top = bounds.top() + top - scroll_top;
 608                return Some(Bounds::from_corners(
 609                    point(bounds.left(), top),
 610                    point(bounds.right(), top + size.height),
 611                ));
 612            }
 613        }
 614        None
 615    }
 616
 617    /// Call this method when the user starts dragging the scrollbar.
 618    ///
 619    /// This will prevent the height reported to the scrollbar from changing during the drag
 620    /// as items in the overdraw get measured, and help offset scroll position changes accordingly.
 621    pub fn scrollbar_drag_started(&self) {
 622        let mut state = self.0.borrow_mut();
 623        state.scrollbar_drag_start_height = Some(state.items.summary().height);
 624    }
 625
 626    /// Called when the user stops dragging the scrollbar.
 627    ///
 628    /// See `scrollbar_drag_started`.
 629    pub fn scrollbar_drag_ended(&self) {
 630        self.0.borrow_mut().scrollbar_drag_start_height.take();
 631    }
 632
 633    /// Set the offset from the scrollbar
 634    pub fn set_offset_from_scrollbar(&self, point: Point<Pixels>) {
 635        self.0.borrow_mut().set_offset_from_scrollbar(point);
 636    }
 637
 638    /// Returns the maximum scroll offset according to the items we have measured.
 639    /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly.
 640    pub fn max_offset_for_scrollbar(&self) -> Point<Pixels> {
 641        let state = self.0.borrow();
 642        point(Pixels::ZERO, state.max_scroll_offset())
 643    }
 644
 645    /// Returns the current scroll offset adjusted for the scrollbar
 646    pub fn scroll_px_offset_for_scrollbar(&self) -> Point<Pixels> {
 647        let state = &self.0.borrow();
 648
 649        if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom {
 650            return Point::new(px(0.), -state.max_scroll_offset());
 651        }
 652
 653        let logical_scroll_top = state.logical_scroll_top();
 654
 655        let mut cursor = state.items.cursor::<ListItemSummary>(());
 656        let summary: ListItemSummary =
 657            cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right);
 658        let content_height = state.items.summary().height;
 659        let drag_offset =
 660            // if dragging the scrollbar, we want to offset the point if the height changed
 661            content_height - state.scrollbar_drag_start_height.unwrap_or(content_height);
 662        let offset = summary.height + logical_scroll_top.offset_in_item - drag_offset;
 663
 664        Point::new(px(0.), -offset)
 665    }
 666
 667    /// Return the bounds of the viewport in pixels.
 668    pub fn viewport_bounds(&self) -> Bounds<Pixels> {
 669        self.0.borrow().last_layout_bounds.unwrap_or_default()
 670    }
 671}
 672
 673impl StateInner {
 674    fn max_scroll_offset(&self) -> Pixels {
 675        let bounds = self.last_layout_bounds.unwrap_or_default();
 676        let height = self
 677            .scrollbar_drag_start_height
 678            .unwrap_or_else(|| self.items.summary().height);
 679        (height - bounds.size.height).max(px(0.))
 680    }
 681
 682    fn visible_range(
 683        items: &SumTree<ListItem>,
 684        height: Pixels,
 685        scroll_top: &ListOffset,
 686    ) -> Range<usize> {
 687        let mut cursor = items.cursor::<ListItemSummary>(());
 688        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
 689        let start_y = cursor.start().height + scroll_top.offset_in_item;
 690        cursor.seek_forward(&Height(start_y + height), Bias::Left);
 691        scroll_top.item_ix..cursor.start().count + 1
 692    }
 693
 694    fn scroll(
 695        &mut self,
 696        scroll_top: &ListOffset,
 697        height: Pixels,
 698        delta: Point<Pixels>,
 699        current_view: EntityId,
 700        window: &mut Window,
 701        cx: &mut App,
 702    ) {
 703        // Drop scroll events after a reset, since we can't calculate
 704        // the new logical scroll top without the item heights
 705        if self.reset {
 706            return;
 707        }
 708
 709        let padding = self.last_padding.unwrap_or_default();
 710        let scroll_max =
 711            (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
 712        let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
 713            .max(px(0.))
 714            .min(scroll_max);
 715
 716        if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
 717            self.logical_scroll_top = None;
 718        } else {
 719            let (start, ..) =
 720                self.items
 721                    .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
 722            let item_ix = start.count;
 723            let offset_in_item = new_scroll_top - start.height;
 724            self.logical_scroll_top = Some(ListOffset {
 725                item_ix,
 726                offset_in_item,
 727            });
 728        }
 729
 730        if let FollowState::Tail { is_following } = &mut self.follow_state {
 731            if delta.y > px(0.) {
 732                *is_following = false;
 733            }
 734        }
 735
 736        if let Some(handler) = self.scroll_handler.as_mut() {
 737            let visible_range = Self::visible_range(&self.items, height, scroll_top);
 738            handler(
 739                &ListScrollEvent {
 740                    visible_range,
 741                    count: self.items.summary().count,
 742                    is_scrolled: self.logical_scroll_top.is_some(),
 743                    is_following_tail: matches!(
 744                        self.follow_state,
 745                        FollowState::Tail { is_following: true }
 746                    ),
 747                },
 748                window,
 749                cx,
 750            );
 751        }
 752
 753        cx.notify(current_view);
 754    }
 755
 756    fn logical_scroll_top(&self) -> ListOffset {
 757        self.logical_scroll_top
 758            .unwrap_or_else(|| match self.alignment {
 759                ListAlignment::Top => ListOffset {
 760                    item_ix: 0,
 761                    offset_in_item: px(0.),
 762                },
 763                ListAlignment::Bottom => ListOffset {
 764                    item_ix: self.items.summary().count,
 765                    offset_in_item: px(0.),
 766                },
 767            })
 768    }
 769
 770    fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels {
 771        let (start, ..) = self.items.find::<ListItemSummary, _>(
 772            (),
 773            &Count(logical_scroll_top.item_ix),
 774            Bias::Right,
 775        );
 776        start.height + logical_scroll_top.offset_in_item
 777    }
 778
 779    fn layout_all_items(
 780        &mut self,
 781        available_width: Pixels,
 782        render_item: &mut RenderItemFn,
 783        window: &mut Window,
 784        cx: &mut App,
 785    ) {
 786        match &mut self.measuring_behavior {
 787            ListMeasuringBehavior::Visible => {
 788                return;
 789            }
 790            ListMeasuringBehavior::Measure(has_measured) => {
 791                if *has_measured {
 792                    return;
 793                }
 794                *has_measured = true;
 795            }
 796        }
 797
 798        let mut cursor = self.items.cursor::<Count>(());
 799        let available_item_space = size(
 800            AvailableSpace::Definite(available_width),
 801            AvailableSpace::MinContent,
 802        );
 803
 804        let mut measured_items = Vec::default();
 805
 806        for (ix, item) in cursor.enumerate() {
 807            let size = item.size().unwrap_or_else(|| {
 808                let mut element = render_item(ix, window, cx);
 809                element.layout_as_root(available_item_space, window, cx)
 810            });
 811
 812            measured_items.push(ListItem::Measured {
 813                size,
 814                focus_handle: item.focus_handle(),
 815            });
 816        }
 817
 818        self.items = SumTree::from_iter(measured_items, ());
 819    }
 820
 821    fn layout_items(
 822        &mut self,
 823        available_width: Option<Pixels>,
 824        available_height: Pixels,
 825        padding: &Edges<Pixels>,
 826        render_item: &mut RenderItemFn,
 827        window: &mut Window,
 828        cx: &mut App,
 829    ) -> LayoutItemsResponse {
 830        let old_items = self.items.clone();
 831        let mut measured_items = VecDeque::new();
 832        let mut item_layouts = VecDeque::new();
 833        let mut rendered_height = padding.top;
 834        let mut max_item_width = px(0.);
 835        let mut scroll_top = self.logical_scroll_top();
 836
 837        if self.follow_state.is_following() {
 838            scroll_top = ListOffset {
 839                item_ix: self.items.summary().count,
 840                offset_in_item: px(0.),
 841            };
 842            self.logical_scroll_top = Some(scroll_top);
 843        }
 844
 845        let mut rendered_focused_item = false;
 846
 847        let available_item_space = size(
 848            available_width.map_or(AvailableSpace::MinContent, |width| {
 849                AvailableSpace::Definite(width)
 850            }),
 851            AvailableSpace::MinContent,
 852        );
 853
 854        let mut cursor = old_items.cursor::<Count>(());
 855
 856        // Render items after the scroll top, including those in the trailing overdraw
 857        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
 858        for (ix, item) in cursor.by_ref().enumerate() {
 859            let visible_height = rendered_height - scroll_top.offset_in_item;
 860            if visible_height >= available_height + self.overdraw {
 861                break;
 862            }
 863
 864            // Use the previously cached height and focus handle if available
 865            let mut size = item.size();
 866
 867            // If we're within the visible area or the height wasn't cached, render and measure the item's element
 868            if visible_height < available_height || size.is_none() {
 869                let item_index = scroll_top.item_ix + ix;
 870                let mut element = render_item(item_index, window, cx);
 871                let element_size = element.layout_as_root(available_item_space, window, cx);
 872                size = Some(element_size);
 873
 874                // If there's a pending scroll adjustment for the scroll-top
 875                // item, apply it, ensuring proportional scroll position is
 876                // maintained after re-measuring.
 877                if ix == 0 {
 878                    if let Some(pending_scroll) = self.pending_scroll.take() {
 879                        if pending_scroll.item_ix == scroll_top.item_ix {
 880                            scroll_top.offset_in_item =
 881                                Pixels(pending_scroll.fraction * element_size.height.0);
 882                            self.logical_scroll_top = Some(scroll_top);
 883                        }
 884                    }
 885                }
 886
 887                if visible_height < available_height {
 888                    item_layouts.push_back(ItemLayout {
 889                        index: item_index,
 890                        element,
 891                        size: element_size,
 892                    });
 893                    if item.contains_focused(window, cx) {
 894                        rendered_focused_item = true;
 895                    }
 896                }
 897            }
 898
 899            let size = size.unwrap();
 900            rendered_height += size.height;
 901            max_item_width = max_item_width.max(size.width);
 902            measured_items.push_back(ListItem::Measured {
 903                size,
 904                focus_handle: item.focus_handle(),
 905            });
 906        }
 907        rendered_height += padding.bottom;
 908
 909        // Prepare to start walking upward from the item at the scroll top.
 910        cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
 911
 912        // If the rendered items do not fill the visible region, then adjust
 913        // the scroll top upward.
 914        if rendered_height - scroll_top.offset_in_item < available_height {
 915            while rendered_height < available_height {
 916                cursor.prev();
 917                if let Some(item) = cursor.item() {
 918                    let item_index = cursor.start().0;
 919                    let mut element = render_item(item_index, window, cx);
 920                    let element_size = element.layout_as_root(available_item_space, window, cx);
 921                    let focus_handle = item.focus_handle();
 922                    rendered_height += element_size.height;
 923                    measured_items.push_front(ListItem::Measured {
 924                        size: element_size,
 925                        focus_handle,
 926                    });
 927                    item_layouts.push_front(ItemLayout {
 928                        index: item_index,
 929                        element,
 930                        size: element_size,
 931                    });
 932                    if item.contains_focused(window, cx) {
 933                        rendered_focused_item = true;
 934                    }
 935                } else {
 936                    break;
 937                }
 938            }
 939
 940            scroll_top = ListOffset {
 941                item_ix: cursor.start().0,
 942                offset_in_item: rendered_height - available_height,
 943            };
 944
 945            match self.alignment {
 946                ListAlignment::Top => {
 947                    scroll_top.offset_in_item = scroll_top.offset_in_item.max(px(0.));
 948                    self.logical_scroll_top = Some(scroll_top);
 949                }
 950                ListAlignment::Bottom => {
 951                    scroll_top = ListOffset {
 952                        item_ix: cursor.start().0,
 953                        offset_in_item: rendered_height - available_height,
 954                    };
 955                    self.logical_scroll_top = None;
 956                }
 957            };
 958        }
 959
 960        // Measure items in the leading overdraw
 961        let mut leading_overdraw = scroll_top.offset_in_item;
 962        while leading_overdraw < self.overdraw {
 963            cursor.prev();
 964            if let Some(item) = cursor.item() {
 965                let size = if let ListItem::Measured { size, .. } = item {
 966                    *size
 967                } else {
 968                    let mut element = render_item(cursor.start().0, window, cx);
 969                    element.layout_as_root(available_item_space, window, cx)
 970                };
 971
 972                leading_overdraw += size.height;
 973                measured_items.push_front(ListItem::Measured {
 974                    size,
 975                    focus_handle: item.focus_handle(),
 976                });
 977            } else {
 978                break;
 979            }
 980        }
 981
 982        let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len());
 983        let mut cursor = old_items.cursor::<Count>(());
 984        let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right);
 985        new_items.extend(measured_items, ());
 986        cursor.seek(&Count(measured_range.end), Bias::Right);
 987        new_items.append(cursor.suffix(), ());
 988        self.items = new_items;
 989
 990        // If follow_tail mode is on but the user scrolled away
 991        // (is_following is false), check whether the current scroll
 992        // position has returned to the bottom.
 993        if self.follow_state.has_stopped_following() {
 994            let padding = self.last_padding.unwrap_or_default();
 995            let total_height = self.items.summary().height + padding.top + padding.bottom;
 996            let scroll_offset = self.scroll_top(&scroll_top);
 997            if scroll_offset + available_height >= total_height - px(1.0) {
 998                self.follow_state.start_following();
 999            }
1000        }
1001
1002        // If none of the visible items are focused, check if an off-screen item is focused
1003        // and include it to be rendered after the visible items so keyboard interaction continues
1004        // to work for it.
1005        if !rendered_focused_item {
1006            let mut cursor = self
1007                .items
1008                .filter::<_, Count>((), |summary| summary.has_focus_handles);
1009            cursor.next();
1010            while let Some(item) = cursor.item() {
1011                if item.contains_focused(window, cx) {
1012                    let item_index = cursor.start().0;
1013                    let mut element = render_item(cursor.start().0, window, cx);
1014                    let size = element.layout_as_root(available_item_space, window, cx);
1015                    item_layouts.push_back(ItemLayout {
1016                        index: item_index,
1017                        element,
1018                        size,
1019                    });
1020                    break;
1021                }
1022                cursor.next();
1023            }
1024        }
1025
1026        LayoutItemsResponse {
1027            max_item_width,
1028            scroll_top,
1029            item_layouts,
1030        }
1031    }
1032
1033    fn prepaint_items(
1034        &mut self,
1035        bounds: Bounds<Pixels>,
1036        padding: Edges<Pixels>,
1037        autoscroll: bool,
1038        render_item: &mut RenderItemFn,
1039        window: &mut Window,
1040        cx: &mut App,
1041    ) -> Result<LayoutItemsResponse, ListOffset> {
1042        window.transact(|window| {
1043            match self.measuring_behavior {
1044                ListMeasuringBehavior::Measure(has_measured) if !has_measured => {
1045                    self.layout_all_items(bounds.size.width, render_item, window, cx);
1046                }
1047                _ => {}
1048            }
1049
1050            let mut layout_response = self.layout_items(
1051                Some(bounds.size.width),
1052                bounds.size.height,
1053                &padding,
1054                render_item,
1055                window,
1056                cx,
1057            );
1058
1059            // Avoid honoring autoscroll requests from elements other than our children.
1060            window.take_autoscroll();
1061
1062            // Only paint the visible items, if there is actually any space for them (taking padding into account)
1063            if bounds.size.height > padding.top + padding.bottom {
1064                let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
1065                item_origin.y -= layout_response.scroll_top.offset_in_item;
1066                for item in &mut layout_response.item_layouts {
1067                    window.with_content_mask(Some(ContentMask { bounds }), |window| {
1068                        item.element.prepaint_at(item_origin, window, cx);
1069                    });
1070
1071                    if let Some(autoscroll_bounds) = window.take_autoscroll()
1072                        && autoscroll
1073                    {
1074                        if autoscroll_bounds.top() < bounds.top() {
1075                            return Err(ListOffset {
1076                                item_ix: item.index,
1077                                offset_in_item: autoscroll_bounds.top() - item_origin.y,
1078                            });
1079                        } else if autoscroll_bounds.bottom() > bounds.bottom() {
1080                            let mut cursor = self.items.cursor::<Count>(());
1081                            cursor.seek(&Count(item.index), Bias::Right);
1082                            let mut height = bounds.size.height - padding.top - padding.bottom;
1083
1084                            // Account for the height of the element down until the autoscroll bottom.
1085                            height -= autoscroll_bounds.bottom() - item_origin.y;
1086
1087                            // Keep decreasing the scroll top until we fill all the available space.
1088                            while height > Pixels::ZERO {
1089                                cursor.prev();
1090                                let Some(item) = cursor.item() else { break };
1091
1092                                let size = item.size().unwrap_or_else(|| {
1093                                    let mut item = render_item(cursor.start().0, window, cx);
1094                                    let item_available_size =
1095                                        size(bounds.size.width.into(), AvailableSpace::MinContent);
1096                                    item.layout_as_root(item_available_size, window, cx)
1097                                });
1098                                height -= size.height;
1099                            }
1100
1101                            return Err(ListOffset {
1102                                item_ix: cursor.start().0,
1103                                offset_in_item: if height < Pixels::ZERO {
1104                                    -height
1105                                } else {
1106                                    Pixels::ZERO
1107                                },
1108                            });
1109                        }
1110                    }
1111
1112                    item_origin.y += item.size.height;
1113                }
1114            } else {
1115                layout_response.item_layouts.clear();
1116            }
1117
1118            Ok(layout_response)
1119        })
1120    }
1121
1122    // Scrollbar support
1123
1124    fn set_offset_from_scrollbar(&mut self, point: Point<Pixels>) {
1125        let Some(bounds) = self.last_layout_bounds else {
1126            return;
1127        };
1128        let height = bounds.size.height;
1129
1130        let padding = self.last_padding.unwrap_or_default();
1131        let content_height = self.items.summary().height;
1132        let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.));
1133        let drag_offset =
1134            // if dragging the scrollbar, we want to offset the point if the height changed
1135            content_height - self.scrollbar_drag_start_height.unwrap_or(content_height);
1136        let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max);
1137
1138        self.follow_state = FollowState::Normal;
1139
1140        if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
1141            self.logical_scroll_top = None;
1142        } else {
1143            let (start, _, _) =
1144                self.items
1145                    .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
1146
1147            let item_ix = start.count;
1148            let offset_in_item = new_scroll_top - start.height;
1149            self.logical_scroll_top = Some(ListOffset {
1150                item_ix,
1151                offset_in_item,
1152            });
1153        }
1154    }
1155}
1156
1157impl std::fmt::Debug for ListItem {
1158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1159        match self {
1160            Self::Unmeasured { .. } => write!(f, "Unrendered"),
1161            Self::Measured { size, .. } => f.debug_struct("Rendered").field("size", size).finish(),
1162        }
1163    }
1164}
1165
1166/// An offset into the list's items, in terms of the item index and the number
1167/// of pixels off the top left of the item.
1168#[derive(Debug, Clone, Copy, Default)]
1169pub struct ListOffset {
1170    /// The index of an item in the list
1171    pub item_ix: usize,
1172    /// The number of pixels to offset from the item index.
1173    pub offset_in_item: Pixels,
1174}
1175
1176impl Element for List {
1177    type RequestLayoutState = ();
1178    type PrepaintState = ListPrepaintState;
1179
1180    fn id(&self) -> Option<crate::ElementId> {
1181        None
1182    }
1183
1184    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1185        None
1186    }
1187
1188    fn request_layout(
1189        &mut self,
1190        _id: Option<&GlobalElementId>,
1191        _inspector_id: Option<&InspectorElementId>,
1192        window: &mut Window,
1193        cx: &mut App,
1194    ) -> (crate::LayoutId, Self::RequestLayoutState) {
1195        let layout_id = match self.sizing_behavior {
1196            ListSizingBehavior::Infer => {
1197                let mut style = Style::default();
1198                style.overflow.y = Overflow::Scroll;
1199                style.refine(&self.style);
1200                window.with_text_style(style.text_style().cloned(), |window| {
1201                    let state = &mut *self.state.0.borrow_mut();
1202
1203                    let available_height = if let Some(last_bounds) = state.last_layout_bounds {
1204                        last_bounds.size.height
1205                    } else {
1206                        // If we don't have the last layout bounds (first render),
1207                        // we might just use the overdraw value as the available height to layout enough items.
1208                        state.overdraw
1209                    };
1210                    let padding = style.padding.to_pixels(
1211                        state.last_layout_bounds.unwrap_or_default().size.into(),
1212                        window.rem_size(),
1213                    );
1214
1215                    let layout_response = state.layout_items(
1216                        None,
1217                        available_height,
1218                        &padding,
1219                        &mut self.render_item,
1220                        window,
1221                        cx,
1222                    );
1223                    let max_element_width = layout_response.max_item_width;
1224
1225                    let summary = state.items.summary();
1226                    let total_height = summary.height;
1227
1228                    window.request_measured_layout(
1229                        style,
1230                        move |known_dimensions, available_space, _window, _cx| {
1231                            let width =
1232                                known_dimensions
1233                                    .width
1234                                    .unwrap_or(match available_space.width {
1235                                        AvailableSpace::Definite(x) => x,
1236                                        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1237                                            max_element_width
1238                                        }
1239                                    });
1240                            let height = match available_space.height {
1241                                AvailableSpace::Definite(height) => total_height.min(height),
1242                                AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1243                                    total_height
1244                                }
1245                            };
1246                            size(width, height)
1247                        },
1248                    )
1249                })
1250            }
1251            ListSizingBehavior::Auto => {
1252                let mut style = Style::default();
1253                style.refine(&self.style);
1254                window.with_text_style(style.text_style().cloned(), |window| {
1255                    window.request_layout(style, None, cx)
1256                })
1257            }
1258        };
1259        (layout_id, ())
1260    }
1261
1262    fn prepaint(
1263        &mut self,
1264        _id: Option<&GlobalElementId>,
1265        _inspector_id: Option<&InspectorElementId>,
1266        bounds: Bounds<Pixels>,
1267        _: &mut Self::RequestLayoutState,
1268        window: &mut Window,
1269        cx: &mut App,
1270    ) -> ListPrepaintState {
1271        let state = &mut *self.state.0.borrow_mut();
1272        state.reset = false;
1273
1274        let mut style = Style::default();
1275        style.refine(&self.style);
1276
1277        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
1278
1279        // If the width of the list has changed, invalidate all cached item heights
1280        if state
1281            .last_layout_bounds
1282            .is_none_or(|last_bounds| last_bounds.size.width != bounds.size.width)
1283        {
1284            let new_items = SumTree::from_iter(
1285                state.items.iter().map(|item| ListItem::Unmeasured {
1286                    size_hint: None,
1287                    focus_handle: item.focus_handle(),
1288                }),
1289                (),
1290            );
1291
1292            state.items = new_items;
1293            state.measuring_behavior.reset();
1294        }
1295
1296        let padding = style
1297            .padding
1298            .to_pixels(bounds.size.into(), window.rem_size());
1299        let layout =
1300            match state.prepaint_items(bounds, padding, true, &mut self.render_item, window, cx) {
1301                Ok(layout) => layout,
1302                Err(autoscroll_request) => {
1303                    state.logical_scroll_top = Some(autoscroll_request);
1304                    state
1305                        .prepaint_items(bounds, padding, false, &mut self.render_item, window, cx)
1306                        .unwrap()
1307                }
1308            };
1309
1310        state.last_layout_bounds = Some(bounds);
1311        state.last_padding = Some(padding);
1312        ListPrepaintState { hitbox, layout }
1313    }
1314
1315    fn paint(
1316        &mut self,
1317        _id: Option<&GlobalElementId>,
1318        _inspector_id: Option<&InspectorElementId>,
1319        bounds: Bounds<crate::Pixels>,
1320        _: &mut Self::RequestLayoutState,
1321        prepaint: &mut Self::PrepaintState,
1322        window: &mut Window,
1323        cx: &mut App,
1324    ) {
1325        let current_view = window.current_view();
1326        window.with_content_mask(Some(ContentMask { bounds }), |window| {
1327            for item in &mut prepaint.layout.item_layouts {
1328                item.element.paint(window, cx);
1329            }
1330        });
1331
1332        let list_state = self.state.clone();
1333        let height = bounds.size.height;
1334        let scroll_top = prepaint.layout.scroll_top;
1335        let hitbox_id = prepaint.hitbox.id;
1336        let mut accumulated_scroll_delta = ScrollDelta::default();
1337        window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
1338            if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) {
1339                accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta);
1340                let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.));
1341                list_state.0.borrow_mut().scroll(
1342                    &scroll_top,
1343                    height,
1344                    pixel_delta,
1345                    current_view,
1346                    window,
1347                    cx,
1348                )
1349            }
1350        });
1351    }
1352}
1353
1354impl IntoElement for List {
1355    type Element = Self;
1356
1357    fn into_element(self) -> Self::Element {
1358        self
1359    }
1360}
1361
1362impl Styled for List {
1363    fn style(&mut self) -> &mut StyleRefinement {
1364        &mut self.style
1365    }
1366}
1367
1368impl sum_tree::Item for ListItem {
1369    type Summary = ListItemSummary;
1370
1371    fn summary(&self, _: ()) -> Self::Summary {
1372        match self {
1373            ListItem::Unmeasured {
1374                size_hint,
1375                focus_handle,
1376            } => ListItemSummary {
1377                count: 1,
1378                rendered_count: 0,
1379                unrendered_count: 1,
1380                height: if let Some(size) = size_hint {
1381                    size.height
1382                } else {
1383                    px(0.)
1384                },
1385                has_focus_handles: focus_handle.is_some(),
1386            },
1387            ListItem::Measured {
1388                size, focus_handle, ..
1389            } => ListItemSummary {
1390                count: 1,
1391                rendered_count: 1,
1392                unrendered_count: 0,
1393                height: size.height,
1394                has_focus_handles: focus_handle.is_some(),
1395            },
1396        }
1397    }
1398}
1399
1400impl sum_tree::ContextLessSummary for ListItemSummary {
1401    fn zero() -> Self {
1402        Default::default()
1403    }
1404
1405    fn add_summary(&mut self, summary: &Self) {
1406        self.count += summary.count;
1407        self.rendered_count += summary.rendered_count;
1408        self.unrendered_count += summary.unrendered_count;
1409        self.height += summary.height;
1410        self.has_focus_handles |= summary.has_focus_handles;
1411    }
1412}
1413
1414impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Count {
1415    fn zero(_cx: ()) -> Self {
1416        Default::default()
1417    }
1418
1419    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1420        self.0 += summary.count;
1421    }
1422}
1423
1424impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Height {
1425    fn zero(_cx: ()) -> Self {
1426        Default::default()
1427    }
1428
1429    fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1430        self.0 += summary.height;
1431    }
1432}
1433
1434impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Count {
1435    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1436        self.0.partial_cmp(&other.count).unwrap()
1437    }
1438}
1439
1440impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Height {
1441    fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1442        self.0.partial_cmp(&other.height).unwrap()
1443    }
1444}
1445
1446#[cfg(test)]
1447mod test {
1448
1449    use gpui::{ScrollDelta, ScrollWheelEvent};
1450    use std::cell::Cell;
1451    use std::rc::Rc;
1452
1453    use crate::{
1454        self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render,
1455        Styled, TestAppContext, Window, div, list, point, px, size,
1456    };
1457
1458    #[gpui::test]
1459    fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) {
1460        let cx = cx.add_empty_window();
1461
1462        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1463
1464        // Ensure that the list is scrolled to the top
1465        state.scroll_to(gpui::ListOffset {
1466            item_ix: 0,
1467            offset_in_item: px(0.0),
1468        });
1469
1470        struct TestView(ListState);
1471        impl Render for TestView {
1472            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1473                list(self.0.clone(), |_, _, _| {
1474                    div().h(px(10.)).w_full().into_any()
1475                })
1476                .w_full()
1477                .h_full()
1478            }
1479        }
1480
1481        // Paint
1482        cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1483            cx.new(|_| TestView(state.clone())).into_any_element()
1484        });
1485
1486        // Reset
1487        state.reset(5);
1488
1489        // And then receive a scroll event _before_ the next paint
1490        cx.simulate_event(ScrollWheelEvent {
1491            position: point(px(1.), px(1.)),
1492            delta: ScrollDelta::Pixels(point(px(0.), px(-500.))),
1493            ..Default::default()
1494        });
1495
1496        // Scroll position should stay at the top of the list
1497        assert_eq!(state.logical_scroll_top().item_ix, 0);
1498        assert_eq!(state.logical_scroll_top().offset_in_item, px(0.));
1499    }
1500
1501    #[gpui::test]
1502    fn test_scroll_by_positive_and_negative_distance(cx: &mut TestAppContext) {
1503        let cx = cx.add_empty_window();
1504
1505        let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1506
1507        struct TestView(ListState);
1508        impl Render for TestView {
1509            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1510                list(self.0.clone(), |_, _, _| {
1511                    div().h(px(20.)).w_full().into_any()
1512                })
1513                .w_full()
1514                .h_full()
1515            }
1516        }
1517
1518        // Paint
1519        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
1520            cx.new(|_| TestView(state.clone())).into_any_element()
1521        });
1522
1523        // Test positive distance: start at item 1, move down 30px
1524        state.scroll_by(px(30.));
1525
1526        // Should move to item 2
1527        let offset = state.logical_scroll_top();
1528        assert_eq!(offset.item_ix, 1);
1529        assert_eq!(offset.offset_in_item, px(10.));
1530
1531        // Test negative distance: start at item 2, move up 30px
1532        state.scroll_by(px(-30.));
1533
1534        // Should move back to item 1
1535        let offset = state.logical_scroll_top();
1536        assert_eq!(offset.item_ix, 0);
1537        assert_eq!(offset.offset_in_item, px(0.));
1538
1539        // Test zero distance
1540        state.scroll_by(px(0.));
1541        let offset = state.logical_scroll_top();
1542        assert_eq!(offset.item_ix, 0);
1543        assert_eq!(offset.offset_in_item, px(0.));
1544    }
1545
1546    #[gpui::test]
1547    fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
1548        let cx = cx.add_empty_window();
1549
1550        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1551
1552        struct TestView(ListState);
1553        impl Render for TestView {
1554            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1555                list(self.0.clone(), |_, _, _| {
1556                    div().h(px(50.)).w_full().into_any()
1557                })
1558                .w_full()
1559                .h_full()
1560            }
1561        }
1562
1563        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1564
1565        // First draw at width 100: all 10 items measured (total 500px).
1566        // Viewport is 200px, so max scroll offset should be 300px.
1567        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1568            view.clone().into_any_element()
1569        });
1570        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
1571
1572        // Second draw at a different width: items get invalidated.
1573        // Without the fix, max_offset would drop because unmeasured items
1574        // contribute 0 height.
1575        cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| {
1576            view.into_any_element()
1577        });
1578        assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
1579    }
1580
1581    #[gpui::test]
1582    fn test_remeasure(cx: &mut TestAppContext) {
1583        let cx = cx.add_empty_window();
1584
1585        // Create a list with 10 items, each 100px tall. We'll keep a reference
1586        // to the item height so we can later change the height and assert how
1587        // `ListState` handles it.
1588        let item_height = Rc::new(Cell::new(100usize));
1589        let state = ListState::new(10, crate::ListAlignment::Top, px(10.));
1590
1591        struct TestView {
1592            state: ListState,
1593            item_height: Rc<Cell<usize>>,
1594        }
1595
1596        impl Render for TestView {
1597            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1598                let height = self.item_height.get();
1599                list(self.state.clone(), move |_, _, _| {
1600                    div().h(px(height as f32)).w_full().into_any()
1601                })
1602                .w_full()
1603                .h_full()
1604            }
1605        }
1606
1607        let state_clone = state.clone();
1608        let item_height_clone = item_height.clone();
1609        let view = cx.update(|_, cx| {
1610            cx.new(|_| TestView {
1611                state: state_clone,
1612                item_height: item_height_clone,
1613            })
1614        });
1615
1616        // Simulate scrolling 40px inside the element with index 2. Since the
1617        // original item height is 100px, this equates to 40% inside the item.
1618        state.scroll_to(gpui::ListOffset {
1619            item_ix: 2,
1620            offset_in_item: px(40.),
1621        });
1622
1623        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1624            view.clone().into_any_element()
1625        });
1626
1627        let offset = state.logical_scroll_top();
1628        assert_eq!(offset.item_ix, 2);
1629        assert_eq!(offset.offset_in_item, px(40.));
1630
1631        // Update the `item_height` to be 50px instead of 100px so we can assert
1632        // that the scroll position is proportionally preserved, that is,
1633        // instead of 40px from the top of item 2, it should be 20px, since the
1634        // item's height has been halved.
1635        item_height.set(50);
1636        state.remeasure();
1637
1638        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1639            view.into_any_element()
1640        });
1641
1642        let offset = state.logical_scroll_top();
1643        assert_eq!(offset.item_ix, 2);
1644        assert_eq!(offset.offset_in_item, px(20.));
1645    }
1646
1647    #[gpui::test]
1648    fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
1649        let cx = cx.add_empty_window();
1650
1651        // 10 items, each 50px tall β†’ 500px total content, 200px viewport.
1652        // With follow-tail on, the list should always show the bottom.
1653        let item_height = Rc::new(Cell::new(50usize));
1654        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
1655
1656        struct TestView {
1657            state: ListState,
1658            item_height: Rc<Cell<usize>>,
1659        }
1660        impl Render for TestView {
1661            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1662                let height = self.item_height.get();
1663                list(self.state.clone(), move |_, _, _| {
1664                    div().h(px(height as f32)).w_full().into_any()
1665                })
1666                .w_full()
1667                .h_full()
1668            }
1669        }
1670
1671        let state_clone = state.clone();
1672        let item_height_clone = item_height.clone();
1673        let view = cx.update(|_, cx| {
1674            cx.new(|_| TestView {
1675                state: state_clone,
1676                item_height: item_height_clone,
1677            })
1678        });
1679
1680        state.set_follow_mode(FollowMode::Tail);
1681
1682        // First paint β€” items are 50px, total 500px, viewport 200px.
1683        // Follow-tail should anchor to the end.
1684        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1685            view.clone().into_any_element()
1686        });
1687
1688        // The scroll should be at the bottom: the last visible items fill the
1689        // 200px viewport from the end of 500px of content (offset 300px).
1690        let offset = state.logical_scroll_top();
1691        assert_eq!(offset.item_ix, 6);
1692        assert_eq!(offset.offset_in_item, px(0.));
1693        assert!(state.is_following_tail());
1694
1695        // Simulate items growing (e.g. streaming content makes each item taller).
1696        // 10 items Γ— 80px = 800px total.
1697        item_height.set(80);
1698        state.remeasure();
1699
1700        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1701            view.into_any_element()
1702        });
1703
1704        // After growth, follow-tail should have re-anchored to the new end.
1705        // 800px total βˆ’ 200px viewport = 600px offset β†’ item 7 at offset 40px,
1706        // but follow-tail anchors to item_count (10), and layout walks back to
1707        // fill 200px, landing at item 7 (7 Γ— 80 = 560, 800 βˆ’ 560 = 240 > 200,
1708        // so item 8: 8 Γ— 80 = 640, 800 βˆ’ 640 = 160 < 200 β†’ keeps walking β†’
1709        // item 7: offset = 800 βˆ’ 200 = 600, item_ix = 600/80 = 7, remainder 40).
1710        let offset = state.logical_scroll_top();
1711        assert_eq!(offset.item_ix, 7);
1712        assert_eq!(offset.offset_in_item, px(40.));
1713        assert!(state.is_following_tail());
1714    }
1715
1716    #[gpui::test]
1717    fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) {
1718        let cx = cx.add_empty_window();
1719
1720        // 10 items Γ— 50px = 500px total, 200px viewport.
1721        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
1722
1723        struct TestView(ListState);
1724        impl Render for TestView {
1725            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1726                list(self.0.clone(), |_, _, _| {
1727                    div().h(px(50.)).w_full().into_any()
1728                })
1729                .w_full()
1730                .h_full()
1731            }
1732        }
1733
1734        state.set_follow_mode(FollowMode::Tail);
1735
1736        // Paint with follow-tail β€” scroll anchored to the bottom.
1737        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
1738            cx.new(|_| TestView(state.clone())).into_any_element()
1739        });
1740        assert!(state.is_following_tail());
1741
1742        // Simulate the user scrolling up.
1743        // This should disengage follow-tail.
1744        cx.simulate_event(ScrollWheelEvent {
1745            position: point(px(50.), px(100.)),
1746            delta: ScrollDelta::Pixels(point(px(0.), px(100.))),
1747            ..Default::default()
1748        });
1749
1750        assert!(
1751            !state.is_following_tail(),
1752            "follow-tail should disengage when the user scrolls toward the start"
1753        );
1754    }
1755
1756    #[gpui::test]
1757    fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) {
1758        let cx = cx.add_empty_window();
1759
1760        // 10 items Γ— 50px = 500px total, 200px viewport.
1761        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1762
1763        struct TestView(ListState);
1764        impl Render for TestView {
1765            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1766                list(self.0.clone(), |_, _, _| {
1767                    div().h(px(50.)).w_full().into_any()
1768                })
1769                .w_full()
1770                .h_full()
1771            }
1772        }
1773
1774        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1775
1776        state.set_follow_mode(FollowMode::Tail);
1777
1778        // Paint with follow-tail β€” scroll anchored to the bottom.
1779        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1780            view.clone().into_any_element()
1781        });
1782        assert!(state.is_following_tail());
1783
1784        // Simulate the scrollbar moving the viewport to the middle.
1785        // `set_offset_from_scrollbar` accepts a positive distance from the start.
1786        state.set_offset_from_scrollbar(point(px(0.), px(150.)));
1787
1788        let offset = state.logical_scroll_top();
1789        assert_eq!(offset.item_ix, 3);
1790        assert_eq!(offset.offset_in_item, px(0.));
1791        assert!(
1792            !state.is_following_tail(),
1793            "follow-tail should disengage when the scrollbar manually repositions the list"
1794        );
1795
1796        // A subsequent draw should preserve the user's manual position instead
1797        // of snapping back to the end.
1798        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1799            view.into_any_element()
1800        });
1801
1802        let offset = state.logical_scroll_top();
1803        assert_eq!(offset.item_ix, 3);
1804        assert_eq!(offset.offset_in_item, px(0.));
1805    }
1806
1807    #[gpui::test]
1808    fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) {
1809        let cx = cx.add_empty_window();
1810
1811        // 10 items Γ— 50px = 500px total, 200px viewport.
1812        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
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(50.)).w_full().into_any()
1819                })
1820                .w_full()
1821                .h_full()
1822            }
1823        }
1824
1825        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1826
1827        // Scroll to the middle of the list (item 3).
1828        state.scroll_to(gpui::ListOffset {
1829            item_ix: 3,
1830            offset_in_item: px(0.),
1831        });
1832
1833        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1834            view.clone().into_any_element()
1835        });
1836
1837        let offset = state.logical_scroll_top();
1838        assert_eq!(offset.item_ix, 3);
1839        assert_eq!(offset.offset_in_item, px(0.));
1840        assert!(!state.is_following_tail());
1841
1842        // Enable follow-tail β€” this should immediately snap the scroll anchor
1843        // to the end, like the user just sent a prompt.
1844        state.set_follow_mode(FollowMode::Tail);
1845
1846        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1847            view.into_any_element()
1848        });
1849
1850        // After paint, scroll should be at the bottom.
1851        // 500px total βˆ’ 200px viewport = 300px offset β†’ item 6, offset 0.
1852        let offset = state.logical_scroll_top();
1853        assert_eq!(offset.item_ix, 6);
1854        assert_eq!(offset.offset_in_item, px(0.));
1855        assert!(state.is_following_tail());
1856    }
1857
1858    #[gpui::test]
1859    fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) {
1860        let cx = cx.add_empty_window();
1861
1862        const ITEMS: usize = 10;
1863        const ITEM_SIZE: f32 = 50.0;
1864
1865        let state = ListState::new(
1866            ITEMS,
1867            crate::ListAlignment::Bottom,
1868            px(ITEMS as f32 * ITEM_SIZE),
1869        );
1870
1871        struct TestView(ListState);
1872        impl Render for TestView {
1873            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1874                list(self.0.clone(), |_, _, _| {
1875                    div().h(px(ITEM_SIZE)).w_full().into_any()
1876                })
1877                .w_full()
1878                .h_full()
1879            }
1880        }
1881
1882        cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
1883            cx.new(|_| TestView(state.clone())).into_any_element()
1884        });
1885
1886        // Bottom-aligned lists start pinned to the end: logical_scroll_top returns
1887        // item_ix == item_count, meaning no explicit scroll position has been set.
1888        assert_eq!(state.logical_scroll_top().item_ix, ITEMS);
1889
1890        let max_offset = state.max_offset_for_scrollbar();
1891        let scroll_offset = state.scroll_px_offset_for_scrollbar();
1892
1893        assert_eq!(
1894            -scroll_offset.y, max_offset.y,
1895            "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom",
1896            -scroll_offset.y, max_offset.y,
1897        );
1898    }
1899
1900    /// When the user scrolls away from the bottom during follow_tail,
1901    /// follow_tail suspends. If they scroll back to the bottom, the
1902    /// next paint should re-engage follow_tail using fresh measurements.
1903    #[gpui::test]
1904    fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) {
1905        let cx = cx.add_empty_window();
1906
1907        // 10 items Γ— 50px = 500px total, 200px viewport.
1908        let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
1909
1910        struct TestView(ListState);
1911        impl Render for TestView {
1912            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1913                list(self.0.clone(), |_, _, _| {
1914                    div().h(px(50.)).w_full().into_any()
1915                })
1916                .w_full()
1917                .h_full()
1918            }
1919        }
1920
1921        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1922
1923        state.set_follow_mode(FollowMode::Tail);
1924
1925        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1926            view.clone().into_any_element()
1927        });
1928        assert!(state.is_following_tail());
1929
1930        // Scroll up β€” follow_tail should suspend (not fully disengage).
1931        cx.simulate_event(ScrollWheelEvent {
1932            position: point(px(50.), px(100.)),
1933            delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
1934            ..Default::default()
1935        });
1936        assert!(!state.is_following_tail());
1937
1938        // Scroll back down to the bottom.
1939        cx.simulate_event(ScrollWheelEvent {
1940            position: point(px(50.), px(100.)),
1941            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
1942            ..Default::default()
1943        });
1944
1945        // After a paint, follow_tail should re-engage because the
1946        // layout confirmed we're at the true bottom.
1947        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1948            view.clone().into_any_element()
1949        });
1950        assert!(
1951            state.is_following_tail(),
1952            "follow_tail should re-engage after scrolling back to the bottom"
1953        );
1954    }
1955
1956    /// When an item is spliced to unmeasured (0px) while follow_tail
1957    /// is suspended, the re-engagement check should still work correctly
1958    #[gpui::test]
1959    fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) {
1960        let cx = cx.add_empty_window();
1961
1962        // 20 items Γ— 50px = 1000px total, 200px viewport, 1000px
1963        // overdraw so all items get measured during the follow_tail
1964        // paint (matching realistic production settings).
1965        let state = ListState::new(20, crate::ListAlignment::Top, px(1000.));
1966
1967        struct TestView(ListState);
1968        impl Render for TestView {
1969            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1970                list(self.0.clone(), |_, _, _| {
1971                    div().h(px(50.)).w_full().into_any()
1972                })
1973                .w_full()
1974                .h_full()
1975            }
1976        }
1977
1978        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1979
1980        state.set_follow_mode(FollowMode::Tail);
1981
1982        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1983            view.clone().into_any_element()
1984        });
1985        assert!(state.is_following_tail());
1986
1987        // Scroll up a meaningful amount β€” suspends follow_tail.
1988        // 20 items Γ— 50px = 1000px. viewport 200px. scroll_max = 800px.
1989        // Scrolling up 200px puts us at 600px, clearly not at bottom.
1990        cx.simulate_event(ScrollWheelEvent {
1991            position: point(px(50.), px(100.)),
1992            delta: ScrollDelta::Pixels(point(px(0.), px(200.))),
1993            ..Default::default()
1994        });
1995        assert!(!state.is_following_tail());
1996
1997        // Invalidate the last item (simulates EntryUpdated calling
1998        // remeasure_items). This makes items.summary().height
1999        // temporarily wrong (0px for the invalidated item).
2000        state.remeasure_items(19..20);
2001
2002        // Paint β€” layout re-measures the invalidated item with its true
2003        // height. The re-engagement check uses these fresh measurements.
2004        // Since we scrolled 200px up from the 800px max, we're at
2005        // ~600px β€” NOT at the bottom, so follow_tail should NOT
2006        // re-engage.
2007        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2008            view.clone().into_any_element()
2009        });
2010        assert!(
2011            !state.is_following_tail(),
2012            "follow_tail should not falsely re-engage due to an unmeasured item \
2013             reducing items.summary().height"
2014        );
2015    }
2016
2017    /// Calling `set_follow_mode(FollowState::Normal)` or dragging the scrollbar should
2018    /// fully disengage follow_tail β€” clearing any suspended state so
2019    /// follow_tail won’t auto-re-engage.
2020    #[gpui::test]
2021    fn test_follow_tail_suspended_state_cleared_by_explicit_actions(cx: &mut TestAppContext) {
2022        let cx = cx.add_empty_window();
2023
2024        // 10 items Γ— 50px = 500px total, 200px viewport.
2025        let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2026
2027        struct TestView(ListState);
2028        impl Render for TestView {
2029            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2030                list(self.0.clone(), |_, _, _| {
2031                    div().h(px(50.)).w_full().into_any()
2032                })
2033                .w_full()
2034                .h_full()
2035            }
2036        }
2037
2038        let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2039
2040        state.set_follow_mode(FollowMode::Tail);
2041        // --- Part 1: set_follow_mode(FollowState::Normal) clears suspended state ---
2042
2043        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2044            view.clone().into_any_element()
2045        });
2046
2047        // Scroll up β€” suspends follow_tail.
2048        cx.simulate_event(ScrollWheelEvent {
2049            position: point(px(50.), px(100.)),
2050            delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
2051            ..Default::default()
2052        });
2053        assert!(!state.is_following_tail());
2054
2055        // Scroll back to the bottom β€” should re-engage follow_tail.
2056        cx.simulate_event(ScrollWheelEvent {
2057            position: point(px(50.), px(100.)),
2058            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
2059            ..Default::default()
2060        });
2061
2062        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2063            view.clone().into_any_element()
2064        });
2065        assert!(
2066            state.is_following_tail(),
2067            "follow_tail should re-engage after scrolling back to the bottom"
2068        );
2069
2070        // --- Part 2: scrollbar drag clears suspended state ---
2071
2072        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2073            view.clone().into_any_element()
2074        });
2075
2076        // Drag the scrollbar to the middle β€” should clear suspended state.
2077        state.set_offset_from_scrollbar(point(px(0.), px(150.)));
2078
2079        // Scroll to the bottom.
2080        cx.simulate_event(ScrollWheelEvent {
2081            position: point(px(50.), px(100.)),
2082            delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
2083            ..Default::default()
2084        });
2085
2086        // Paint β€” should NOT re-engage because the scrollbar drag
2087        // cleared the suspended state.
2088        cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2089            view.clone().into_any_element()
2090        });
2091        assert!(
2092            !state.is_following_tail(),
2093            "follow_tail should not re-engage after scrollbar drag cleared the suspended state"
2094        );
2095    }
2096}