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}