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