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