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