Skip to repository content2790 lines · 104.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T06:40:55.780Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
list.rs
1//! A list element that can be used to render a large number of differently sized elements
2//! efficiently. Clients of this API need to ensure that elements outside of the scrolled
3//! area do not change their height for this element to function correctly. If your elements
4//! do change height, notify the list element via [`ListState::splice`] or [`ListState::reset`].
5//! In order to minimize re-renders, this element's state is stored intrusively
6//! on your own views, so that your code can coordinate directly with the list element's cached state.
7//!
8//! If all of your elements are the same height, see [`crate::UniformList`] for a simpler API
9
10use crate::{
11 AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges, Element, EntityId,
12 FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, IntoElement,
13 Overflow, Pixels, Point, ScrollDelta, ScrollWheelEvent, Size, Style, StyleRefinement, Styled,
14 Window, point, px, size,
15};
16use collections::VecDeque;
17use refineable::Refineable as _;
18use std::{cell::RefCell, ops::Range, rc::Rc};
19use sum_tree::{Bias, Dimensions, SumTree};
20
21type RenderItemFn = dyn FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static;
22
23/// Construct a new list element
24pub fn list(
25 state: ListState,
26 render_item: impl FnMut(usize, &mut Window, &mut App) -> AnyElement + 'static,
27) -> List {
28 List {
29 state,
30 render_item: Box::new(render_item),
31 style: StyleRefinement::default(),
32 sizing_behavior: ListSizingBehavior::default(),
33 }
34}
35
36/// A list element
37pub struct List {
38 state: ListState,
39 render_item: Box<RenderItemFn>,
40 style: StyleRefinement,
41 sizing_behavior: ListSizingBehavior,
42}
43
44impl List {
45 /// Set the sizing behavior for the list.
46 pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
47 self.sizing_behavior = behavior;
48 self
49 }
50}
51
52/// The list state that views must hold on behalf of the list element.
53#[derive(Clone)]
54pub struct ListState(Rc<RefCell<StateInner>>);
55
56impl std::fmt::Debug for ListState {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.write_str("ListState")
59 }
60}
61
62struct StateInner {
63 last_layout_bounds: Option<Bounds<Pixels>>,
64 last_padding: Option<Edges<Pixels>>,
65 items: SumTree<ListItem>,
66 logical_scroll_top: Option<ListOffset>,
67 alignment: ListAlignment,
68 overdraw: Pixels,
69 reset: bool,
70 #[allow(clippy::type_complexity)]
71 scroll_handler: Option<Box<dyn FnMut(&ListScrollEvent, &mut Window, &mut App)>>,
72 scrollbar_drag_start_height: Option<Pixels>,
73 measuring_behavior: ListMeasuringBehavior,
74 pending_scroll: Option<PendingScroll>,
75 follow_state: FollowState,
76}
77
78/// Deferred scroll adjustment applied after the scroll-top item has been remeasured.
79///
80/// An absolute pending scroll preserves the same pixel offset into the item, which keeps
81/// visible text stable while content is appended to or removed from that item. A
82/// proportional pending scroll preserves the same fractional position within the item,
83/// which is useful when the whole list is being resized and each item scales similarly.
84#[derive(Clone)]
85enum PendingScroll {
86 /// Preserve the same pixel offset into the item after it is remeasured.
87 Absolute { item_ix: usize, offset: Pixels },
88 /// Preserve the same fractional offset into the item after it is remeasured.
89 Proportional(PendingScrollFraction),
90}
91
92/// Keeps track of a fractional scroll position within an item for restoration
93/// after remeasurement.
94#[derive(Clone)]
95struct PendingScrollFraction {
96 /// The index of the item to scroll within.
97 item_ix: usize,
98 /// Fractional offset (0.0 to 1.0) within the item's height.
99 fraction: f32,
100}
101
102/// Determines how remeasurement preserves the scroll position when the scroll-top item
103/// changes height.
104enum ScrollAnchor {
105 /// Preserve the same pixel offset into the scroll-top item.
106 Absolute,
107 /// Preserve the same fractional position within the scroll-top item.
108 Proportional,
109}
110
111/// Controls whether the list automatically follows new content at the end.
112#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
113pub enum FollowMode {
114 /// Normal scrolling — no automatic following.
115 #[default]
116 Normal,
117 /// The list should auto-scroll along with the tail, when scrolled to bottom.
118 Tail,
119}
120
121#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
122enum FollowState {
123 #[default]
124 Normal,
125 Tail {
126 is_following: bool,
127 },
128}
129
130impl FollowState {
131 fn is_following(&self) -> bool {
132 matches!(self, FollowState::Tail { is_following: true })
133 }
134
135 fn has_stopped_following(&self) -> bool {
136 matches!(
137 self,
138 FollowState::Tail {
139 is_following: false
140 }
141 )
142 }
143
144 fn start_following(&mut self) {
145 if let FollowState::Tail {
146 is_following: false,
147 } = self
148 {
149 *self = FollowState::Tail { is_following: true };
150 }
151 }
152
153 fn stop_following(&mut self) {
154 if let FollowState::Tail { is_following: true } = self {
155 *self = FollowState::Tail {
156 is_following: false,
157 };
158 }
159 }
160}
161
162/// Whether the list is scrolling from top to bottom or bottom to top.
163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
164pub enum ListAlignment {
165 /// The list is scrolling from top to bottom, like most lists.
166 Top,
167 /// The list is scrolling from bottom to top, like a chat log.
168 Bottom,
169}
170
171/// A scroll event that has been converted to be in terms of the list's items.
172pub struct ListScrollEvent {
173 /// The range of items currently visible in the list, after applying the scroll event.
174 pub visible_range: Range<usize>,
175
176 /// The number of items that are currently visible in the list, after applying the scroll event.
177 pub count: usize,
178
179 /// Whether the list has been scrolled.
180 pub is_scrolled: bool,
181
182 /// Whether the list is currently in follow-tail mode (auto-scrolling to end).
183 pub is_following_tail: bool,
184}
185
186/// The sizing behavior to apply during layout.
187#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
188pub enum ListSizingBehavior {
189 /// The list should calculate its size based on the size of its items.
190 Infer,
191 /// The list should not calculate a fixed size.
192 #[default]
193 Auto,
194}
195
196/// The measuring behavior to apply during layout.
197#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
198pub enum ListMeasuringBehavior {
199 /// Measure all items in the list.
200 /// Note: This can be expensive for the first frame in a large list.
201 Measure(bool),
202 /// Only measure visible items
203 #[default]
204 Visible,
205}
206
207impl ListMeasuringBehavior {
208 fn reset(&mut self) {
209 match self {
210 ListMeasuringBehavior::Measure(has_measured) => *has_measured = false,
211 ListMeasuringBehavior::Visible => {}
212 }
213 }
214}
215
216/// The horizontal sizing behavior to apply during layout.
217#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
218pub enum ListHorizontalSizingBehavior {
219 /// List items' width can never exceed the width of the list.
220 #[default]
221 FitList,
222 /// List items' width may go over the width of the list, if any item is wider.
223 Unconstrained,
224}
225
226struct LayoutItemsResponse {
227 max_item_width: Pixels,
228 scroll_top: ListOffset,
229 item_layouts: VecDeque<ItemLayout>,
230}
231
232struct ItemLayout {
233 index: usize,
234 element: AnyElement,
235 size: Size<Pixels>,
236}
237
238/// Frame state used by the [List] element after layout.
239pub struct ListPrepaintState {
240 hitbox: Hitbox,
241 layout: LayoutItemsResponse,
242}
243
244#[derive(Clone)]
245enum ListItem {
246 Unmeasured {
247 size_hint: Option<Size<Pixels>>,
248 focus_handle: Option<FocusHandle>,
249 },
250 Measured {
251 size: Size<Pixels>,
252 focus_handle: Option<FocusHandle>,
253 },
254}
255
256impl ListItem {
257 fn size(&self) -> Option<Size<Pixels>> {
258 if let ListItem::Measured { size, .. } = self {
259 Some(*size)
260 } else {
261 None
262 }
263 }
264
265 fn size_hint(&self) -> Option<Size<Pixels>> {
266 match self {
267 ListItem::Measured { size, .. } => Some(*size),
268 ListItem::Unmeasured { size_hint, .. } => *size_hint,
269 }
270 }
271
272 fn focus_handle(&self) -> Option<FocusHandle> {
273 match self {
274 ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
275 focus_handle.clone()
276 }
277 }
278 }
279
280 fn contains_focused(&self, window: &Window, cx: &App) -> bool {
281 match self {
282 ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
283 focus_handle
284 .as_ref()
285 .is_some_and(|handle| handle.contains_focused(window, cx))
286 }
287 }
288 }
289}
290
291#[derive(Clone, Debug, Default, PartialEq)]
292struct ListItemSummary {
293 count: usize,
294 rendered_count: usize,
295 unrendered_count: usize,
296 height: Pixels,
297 has_focus_handles: bool,
298 has_unknown_height: bool,
299}
300
301#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
302struct Count(usize);
303
304#[derive(Clone, Debug, Default)]
305struct Height(Pixels);
306
307impl ListState {
308 /// Construct a new list state, for storage on a view.
309 ///
310 /// The overdraw parameter controls how much extra space is rendered
311 /// above and below the visible area. Elements within this area will
312 /// be measured even though they are not visible. This can help ensure
313 /// that the list doesn't flicker or pop in when scrolling.
314 pub fn new(item_count: usize, alignment: ListAlignment, overdraw: Pixels) -> Self {
315 let this = Self(Rc::new(RefCell::new(StateInner {
316 last_layout_bounds: None,
317 last_padding: None,
318 items: SumTree::default(),
319 logical_scroll_top: None,
320 alignment,
321 overdraw,
322 scroll_handler: None,
323 reset: false,
324 scrollbar_drag_start_height: None,
325 measuring_behavior: ListMeasuringBehavior::default(),
326 pending_scroll: None,
327 follow_state: FollowState::default(),
328 })));
329 this.splice(0..0, item_count);
330 this
331 }
332
333 /// Set the list to measure all items in the list in the first layout phase.
334 ///
335 /// This is useful for ensuring that the scrollbar size is correct instead of based on only rendered elements.
336 pub fn measure_all(self) -> Self {
337 self.0.borrow_mut().measuring_behavior = ListMeasuringBehavior::Measure(false);
338 self
339 }
340
341 /// Pre-populate every unmeasured item with a uniform height hint so the scrollbar thumb
342 /// is correctly sized from the first frame, without measuring all items up front.
343 ///
344 /// As items are actually rendered their real heights replace the hint, so the scrollbar
345 /// converges to the exact size over time. This is a cheaper alternative to [`Self::measure_all`]
346 /// for lists where items have roughly uniform heights (e.g. table rows).
347 pub fn with_uniform_item_height(self, height: Pixels) -> Self {
348 self.apply_uniform_item_height(height);
349 self
350 }
351
352 /// Reset this instantiation of the list state.
353 ///
354 /// Note that this will cause scroll events to be dropped until the next paint.
355 pub fn reset(&self, element_count: usize) {
356 let old_count = {
357 let state = &mut *self.0.borrow_mut();
358 state.reset = true;
359 state.measuring_behavior.reset();
360 state.logical_scroll_top = None;
361 state.pending_scroll = None;
362 state.scrollbar_drag_start_height = None;
363 state.items.summary().count
364 };
365
366 self.splice(0..old_count, element_count);
367 }
368
369 /// Reset the list to `element_count` items, pre-populating every item with a
370 /// uniform height hint so the scrollbar thumb is correctly sized from the first
371 /// frame even for off-screen items.
372 pub fn reset_with_uniform_height(&self, element_count: usize, height: Pixels) {
373 self.reset(element_count);
374 self.apply_uniform_item_height(height);
375 }
376
377 fn apply_uniform_item_height(&self, height: Pixels) {
378 let size_hint = Size {
379 width: px(0.),
380 height,
381 };
382 let mut state = self.0.borrow_mut();
383 let new_items = state
384 .items
385 .iter()
386 .map(|item| ListItem::Unmeasured {
387 size_hint: Some(item.size_hint().unwrap_or(size_hint)),
388 focus_handle: item.focus_handle(),
389 })
390 .collect::<Vec<_>>();
391 let mut tree = SumTree::default();
392 tree.extend(new_items, ());
393 state.items = tree;
394 }
395
396 /// Remeasure all items while preserving proportional scroll position.
397 ///
398 /// Use this when item heights may have changed (e.g., font size changes)
399 /// but the number and identity of items remains the same.
400 pub fn remeasure(&self) {
401 let count = self.item_count();
402 self.remeasure_items_with_scroll_anchor(0..count, ScrollAnchor::Proportional);
403 }
404
405 /// Mark items in `range` as needing remeasurement while preserving
406 /// the current scroll position. Unlike [`Self::splice`], this does
407 /// not change the number of items or blow away `logical_scroll_top`.
408 ///
409 /// Use this when an item's content has changed and its rendered
410 /// height may be different (e.g., streaming text, tool results
411 /// loading), but the item itself still exists at the same index.
412 pub fn remeasure_items(&self, range: Range<usize>) {
413 self.remeasure_items_with_scroll_anchor(range, ScrollAnchor::Absolute);
414 }
415
416 fn remeasure_items_with_scroll_anchor(&self, range: Range<usize>, scroll_anchor: ScrollAnchor) {
417 let state = &mut *self.0.borrow_mut();
418
419 if let Some(scroll_top) = state.logical_scroll_top {
420 if range.contains(&scroll_top.item_ix) {
421 state.pending_scroll = match scroll_anchor {
422 ScrollAnchor::Absolute => Some(PendingScroll::Absolute {
423 item_ix: scroll_top.item_ix,
424 offset: scroll_top.offset_in_item,
425 }),
426 ScrollAnchor::Proportional => {
427 // If the scroll-top item falls within the remeasured range,
428 // store a fractional offset so the layout can restore the
429 // proportional scroll position after the item is re-rendered
430 // at its new height.
431 let mut cursor = state.items.cursor::<Count>(());
432 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
433
434 cursor
435 .item()
436 .and_then(|item| {
437 item.size().map(|size| {
438 let fraction = if size.height.0 > 0.0 {
439 (scroll_top.offset_in_item.0 / size.height.0)
440 .clamp(0.0, 1.0)
441 } else {
442 0.0
443 };
444
445 PendingScroll::Proportional(PendingScrollFraction {
446 item_ix: scroll_top.item_ix,
447 fraction,
448 })
449 })
450 })
451 .or_else(|| state.pending_scroll.clone())
452 }
453 };
454 }
455 }
456
457 // Rebuild the tree, replacing items in the range with
458 // Unmeasured copies that keep their focus handles.
459 let new_items = {
460 let mut cursor = state.items.cursor::<Count>(());
461 let mut new_items = cursor.slice(&Count(range.start), Bias::Right);
462 let invalidated = cursor.slice(&Count(range.end), Bias::Right);
463 new_items.extend(
464 invalidated.iter().map(|item| ListItem::Unmeasured {
465 size_hint: item.size_hint(),
466 focus_handle: item.focus_handle(),
467 }),
468 (),
469 );
470 new_items.append(cursor.suffix(), ());
471 new_items
472 };
473 state.items = new_items;
474 state.measuring_behavior.reset();
475 }
476
477 /// The number of items in this list.
478 pub fn item_count(&self) -> usize {
479 self.0.borrow().items.summary().count
480 }
481
482 /// Whether the list is scrolled to the end, or `None` if the list is
483 /// not scrollable or the total content height is not yet known.
484 pub fn is_scrolled_to_end(&self) -> Option<bool> {
485 let state = self.0.borrow();
486 let bounds = state.last_layout_bounds?;
487 let summary = state.items.summary();
488 if summary.has_unknown_height {
489 return None;
490 }
491 let padding = state.last_padding.unwrap_or_default();
492 let content_height = summary.height + padding.top + padding.bottom;
493 let scroll_max = (content_height - bounds.size.height).max(px(0.));
494 if scroll_max <= px(0.) {
495 return None;
496 }
497 let scroll_top = state.scroll_top(&state.logical_scroll_top());
498 Some(scroll_top >= scroll_max)
499 }
500
501 /// Inform the list state that the items in `old_range` have been replaced
502 /// by `count` new items that must be recalculated.
503 pub fn splice(&self, old_range: Range<usize>, count: usize) {
504 self.splice_focusable(old_range, (0..count).map(|_| None))
505 }
506
507 /// Register with the list state that the items in `old_range` have been replaced
508 /// by new items. As opposed to [`Self::splice`], this method allows an iterator of optional focus handles
509 /// to be supplied to properly integrate with items in the list that can be focused. If a focused item
510 /// is scrolled out of view, the list will continue to render it to allow keyboard interaction.
511 pub fn splice_focusable(
512 &self,
513 old_range: Range<usize>,
514 focus_handles: impl IntoIterator<Item = Option<FocusHandle>>,
515 ) {
516 let state = &mut *self.0.borrow_mut();
517
518 let mut old_items = state.items.cursor::<Count>(());
519 let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right);
520 old_items.seek_forward(&Count(old_range.end), Bias::Right);
521
522 let mut spliced_count = 0;
523 new_items.extend(
524 focus_handles.into_iter().map(|focus_handle| {
525 spliced_count += 1;
526 ListItem::Unmeasured {
527 size_hint: None,
528 focus_handle,
529 }
530 }),
531 (),
532 );
533 new_items.append(old_items.suffix(), ());
534 drop(old_items);
535 state.items = new_items;
536
537 if let Some(ListOffset {
538 item_ix,
539 offset_in_item,
540 }) = state.logical_scroll_top.as_mut()
541 {
542 if old_range.contains(item_ix) {
543 *item_ix = old_range.start;
544 *offset_in_item = px(0.);
545 } else if old_range.end <= *item_ix {
546 *item_ix = *item_ix - (old_range.end - old_range.start) + spliced_count;
547 }
548 }
549 }
550
551 /// Set a handler that will be called when the list is scrolled.
552 pub fn set_scroll_handler(
553 &self,
554 handler: impl FnMut(&ListScrollEvent, &mut Window, &mut App) + 'static,
555 ) {
556 self.0.borrow_mut().scroll_handler = Some(Box::new(handler))
557 }
558
559 /// Get the current scroll offset, in terms of the list's items.
560 pub fn logical_scroll_top(&self) -> ListOffset {
561 self.0.borrow().logical_scroll_top()
562 }
563
564 /// Scroll the list by the given offset
565 pub fn scroll_by(&self, distance: Pixels) {
566 if distance == px(0.) {
567 return;
568 }
569
570 let current_offset = self.logical_scroll_top();
571 let state = &mut *self.0.borrow_mut();
572
573 if distance < px(0.) {
574 state.follow_state.stop_following();
575 }
576
577 let mut cursor = state.items.cursor::<ListItemSummary>(());
578 cursor.seek(&Count(current_offset.item_ix), Bias::Right);
579
580 let start_pixel_offset = cursor.start().height + current_offset.offset_in_item;
581 let new_pixel_offset = (start_pixel_offset + distance).max(px(0.));
582 if new_pixel_offset > start_pixel_offset {
583 cursor.seek_forward(&Height(new_pixel_offset), Bias::Right);
584 } else {
585 cursor.seek(&Height(new_pixel_offset), Bias::Right);
586 }
587
588 let scroll_top = ListOffset {
589 item_ix: cursor.start().count,
590 offset_in_item: new_pixel_offset - cursor.start().height,
591 };
592 drop(cursor);
593 state.rebase_pending_scroll(scroll_top);
594 state.logical_scroll_top = Some(scroll_top);
595 }
596
597 /// Scroll the list to the very end (past the last item).
598 ///
599 /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the
600 /// anchor, so the list's layout pass will walk backwards from the end and
601 /// always show the bottom of the last item — even when that item is still
602 /// growing (e.g. during streaming).
603 pub fn scroll_to_end(&self) {
604 let state = &mut *self.0.borrow_mut();
605 let item_count = state.items.summary().count;
606 state.pending_scroll = None;
607 state.logical_scroll_top = Some(ListOffset {
608 item_ix: item_count,
609 offset_in_item: px(0.),
610 });
611 }
612
613 /// Set the follow mode for the list. In `Tail` mode, the list
614 /// will auto-scroll to the end and re-engage after the user
615 /// scrolls back to the bottom. In `Normal` mode, no automatic
616 /// following occurs.
617 pub fn set_follow_mode(&self, mode: FollowMode) {
618 let state = &mut *self.0.borrow_mut();
619
620 match mode {
621 FollowMode::Normal => {
622 state.follow_state = FollowState::Normal;
623 }
624 FollowMode::Tail => {
625 state.follow_state = FollowState::Tail { is_following: true };
626 if matches!(mode, FollowMode::Tail) {
627 let item_count = state.items.summary().count;
628 state.logical_scroll_top = Some(ListOffset {
629 item_ix: item_count,
630 offset_in_item: px(0.),
631 });
632 }
633 }
634 }
635 }
636
637 /// Returns whether the list is currently actively following the
638 /// tail (snapping to the end on each layout).
639 pub fn is_following_tail(&self) -> bool {
640 matches!(
641 self.0.borrow().follow_state,
642 FollowState::Tail { is_following: true }
643 )
644 }
645
646 /// Scroll the list to the given offset
647 pub fn scroll_to(&self, mut scroll_top: ListOffset) {
648 let state = &mut *self.0.borrow_mut();
649 let item_count = state.items.summary().count;
650 if scroll_top.item_ix >= item_count {
651 scroll_top.item_ix = item_count;
652 scroll_top.offset_in_item = px(0.);
653 }
654
655 if scroll_top.item_ix < item_count {
656 state.follow_state.stop_following();
657 }
658
659 state.rebase_pending_scroll(scroll_top);
660 state.logical_scroll_top = Some(scroll_top);
661 }
662
663 /// Scroll the list to the given item, such that the item is fully visible.
664 pub fn scroll_to_reveal_item(&self, ix: usize) {
665 let state = &mut *self.0.borrow_mut();
666
667 let mut scroll_top = state.logical_scroll_top();
668 let height = state
669 .last_layout_bounds
670 .map_or(px(0.), |bounds| bounds.size.height);
671 let padding = state.last_padding.unwrap_or_default();
672
673 if ix <= scroll_top.item_ix {
674 scroll_top.item_ix = ix;
675 scroll_top.offset_in_item = px(0.);
676 } else {
677 let mut cursor = state.items.cursor::<ListItemSummary>(());
678 cursor.seek(&Count(ix + 1), Bias::Right);
679 let bottom = cursor.start().height + padding.top;
680 let goal_top = px(0.).max(bottom - height + padding.bottom);
681
682 cursor.seek(&Height(goal_top), Bias::Left);
683 let start_ix = cursor.start().count;
684 let start_item_top = cursor.start().height;
685
686 if start_ix >= scroll_top.item_ix {
687 scroll_top.item_ix = start_ix;
688 scroll_top.offset_in_item = goal_top - start_item_top;
689 }
690 }
691
692 state.rebase_pending_scroll(scroll_top);
693 state.logical_scroll_top = Some(scroll_top);
694 }
695
696 /// Get the bounds for the given item in window coordinates, if it's
697 /// been rendered.
698 pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
699 let state = &*self.0.borrow();
700
701 let bounds = state.last_layout_bounds.unwrap_or_default();
702 let scroll_top = state.logical_scroll_top();
703 if ix < scroll_top.item_ix {
704 return None;
705 }
706
707 let mut cursor = state.items.cursor::<Dimensions<Count, Height>>(());
708 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
709
710 let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item;
711
712 cursor.seek_forward(&Count(ix), Bias::Right);
713 if let Some(&ListItem::Measured { size, .. }) = cursor.item() {
714 let &Dimensions(Count(count), Height(top), _) = cursor.start();
715 if count == ix {
716 let top = bounds.top() + top - scroll_top;
717 return Some(Bounds::from_corners(
718 point(bounds.left(), top),
719 point(bounds.right(), top + size.height),
720 ));
721 }
722 }
723 None
724 }
725
726 /// Call this method when the user starts dragging the scrollbar.
727 ///
728 /// This will prevent the height reported to the scrollbar from changing during the drag
729 /// as items in the overdraw get measured, and help offset scroll position changes accordingly.
730 pub fn scrollbar_drag_started(&self) {
731 let mut state = self.0.borrow_mut();
732 state.scrollbar_drag_start_height = Some(state.items.summary().height);
733 }
734
735 /// Called when the user stops dragging the scrollbar.
736 ///
737 /// See `scrollbar_drag_started`.
738 pub fn scrollbar_drag_ended(&self) {
739 self.0.borrow_mut().scrollbar_drag_start_height.take();
740 }
741
742 /// Returns `true` if the scrollbar is currently being dragged.
743 ///
744 /// This is set between [`scrollbar_drag_started`](Self::scrollbar_drag_started)
745 /// and [`scrollbar_drag_ended`](Self::scrollbar_drag_ended) calls. Useful for
746 /// consumers that need to distinguish scrollbar drags from wheel/trackpad scrolls,
747 /// e.g. to suppress auto-scroll behavior during manual positioning.
748 pub fn is_scrollbar_dragging(&self) -> bool {
749 self.0.borrow().scrollbar_drag_start_height.is_some()
750 }
751
752 /// Set the offset from the scrollbar
753 pub fn set_offset_from_scrollbar(&self, point: Point<Pixels>) {
754 self.0.borrow_mut().set_offset_from_scrollbar(point);
755 }
756
757 /// Returns the maximum scroll offset according to the items we have measured.
758 /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly.
759 pub fn max_offset_for_scrollbar(&self) -> Point<Pixels> {
760 let state = self.0.borrow();
761 point(Pixels::ZERO, state.max_scroll_offset())
762 }
763
764 /// Returns the current scroll offset adjusted for the scrollbar.
765 ///
766 /// The returned offset has a negative `y` component representing
767 /// how far the content has scrolled.
768 pub fn scroll_px_offset_for_scrollbar(&self) -> Point<Pixels> {
769 let state = &self.0.borrow();
770
771 if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom {
772 return Point::new(px(0.), -state.max_scroll_offset());
773 }
774
775 let logical_scroll_top = state.logical_scroll_top();
776
777 let mut cursor = state.items.cursor::<ListItemSummary>(());
778 let summary: ListItemSummary =
779 cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right);
780 let offset = summary.height + logical_scroll_top.offset_in_item;
781
782 Point::new(px(0.), -offset)
783 }
784
785 /// Return the bounds of the viewport in pixels.
786 pub fn viewport_bounds(&self) -> Bounds<Pixels> {
787 self.0.borrow().last_layout_bounds.unwrap_or_default()
788 }
789
790 /// Returns whether the item is entirely above the viewport, or `None` if
791 /// the list has not measured enough layout to know.
792 ///
793 /// A zero-height viewport still yields a definitive answer: callers may
794 /// size sibling UI based on this query (potentially squeezing the list
795 /// itself to zero height), so returning `None` in that case would make
796 /// the answer oscillate from frame to frame.
797 pub fn item_is_above_viewport(&self, ix: usize) -> Option<bool> {
798 let viewport_bounds = self.0.borrow().last_layout_bounds?;
799
800 let scroll_top = self.logical_scroll_top();
801 if ix < scroll_top.item_ix {
802 // Rows before the logical scroll top have no item bounds, but
803 // their position relative to the viewport is known from scroll state.
804 return Some(true);
805 }
806
807 let item_bounds = self.bounds_for_item(ix)?;
808 Some(item_bounds.bottom() <= viewport_bounds.top())
809 }
810
811 /// Returns whether the item is entirely below the viewport, or `None` if
812 /// the list has not measured enough layout to know.
813 ///
814 /// See [`Self::item_is_above_viewport`] for why a zero-height viewport
815 /// still yields a definitive answer.
816 pub fn item_is_below_viewport(&self, ix: usize) -> Option<bool> {
817 let viewport_bounds = self.0.borrow().last_layout_bounds?;
818
819 let scroll_top = self.logical_scroll_top();
820 if ix < scroll_top.item_ix {
821 // Rows before the logical scroll top have no item bounds, but
822 // their position relative to the viewport is known from scroll state.
823 return Some(false);
824 }
825
826 let item_bounds = self.bounds_for_item(ix)?;
827 Some(item_bounds.top() >= viewport_bounds.bottom())
828 }
829}
830
831impl StateInner {
832 /// Re-anchor a pending scroll adjustment from a remeasure onto a newly set
833 /// scroll position, so it clamps to the remeasured item's new height on
834 /// the next layout instead of reverting the scroll.
835 fn rebase_pending_scroll(&mut self, scroll_top: ListOffset) {
836 let Some(pending) = self.pending_scroll.take() else {
837 return;
838 };
839 if scroll_top.item_ix >= self.items.summary().count {
840 return;
841 }
842
843 self.pending_scroll = match pending {
844 PendingScroll::Absolute { .. } => Some(PendingScroll::Absolute {
845 item_ix: scroll_top.item_ix,
846 offset: scroll_top.offset_in_item,
847 }),
848 PendingScroll::Proportional(_) => {
849 let mut cursor = self.items.cursor::<Count>(());
850 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
851 cursor
852 .item()
853 .and_then(|item| item.size_hint())
854 .filter(|size| size.height.0 > 0.0)
855 .map(|size| {
856 PendingScroll::Proportional(PendingScrollFraction {
857 item_ix: scroll_top.item_ix,
858 fraction: (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0),
859 })
860 })
861 }
862 };
863 }
864
865 fn max_scroll_offset(&self) -> Pixels {
866 let bounds = self.last_layout_bounds.unwrap_or_default();
867 let height = self
868 .scrollbar_drag_start_height
869 .unwrap_or_else(|| self.items.summary().height);
870 (height - bounds.size.height).max(px(0.))
871 }
872
873 fn visible_range(
874 items: &SumTree<ListItem>,
875 height: Pixels,
876 scroll_top: &ListOffset,
877 ) -> Range<usize> {
878 let mut cursor = items.cursor::<ListItemSummary>(());
879 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
880 let start_y = cursor.start().height + scroll_top.offset_in_item;
881 cursor.seek_forward(&Height(start_y + height), Bias::Left);
882 scroll_top.item_ix..cursor.start().count + 1
883 }
884
885 fn scroll(
886 &mut self,
887 scroll_top: &ListOffset,
888 height: Pixels,
889 delta: Point<Pixels>,
890 current_view: EntityId,
891 window: &mut Window,
892 cx: &mut App,
893 ) {
894 // Drop scroll events after a reset, since we can't calculate
895 // the new logical scroll top without the item heights
896 if self.reset {
897 return;
898 }
899
900 let padding = self.last_padding.unwrap_or_default();
901 let scroll_max =
902 (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
903 let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
904 .max(px(0.))
905 .min(scroll_max);
906
907 if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
908 self.pending_scroll = None;
909 self.logical_scroll_top = None;
910 } else {
911 let (start, ..) =
912 self.items
913 .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
914 let scroll_top = ListOffset {
915 item_ix: start.count,
916 offset_in_item: new_scroll_top - start.height,
917 };
918 // The user's scroll supersedes the position stashed by a
919 // remeasure; re-anchor the pending adjustment so it doesn't revert
920 // this scroll on the next layout.
921 self.rebase_pending_scroll(scroll_top);
922 self.logical_scroll_top = Some(scroll_top);
923 }
924
925 if delta.y > px(0.) {
926 self.follow_state.stop_following();
927 }
928
929 if let Some(handler) = self.scroll_handler.as_mut() {
930 let visible_range = Self::visible_range(&self.items, height, scroll_top);
931 handler(
932 &ListScrollEvent {
933 visible_range,
934 count: self.items.summary().count,
935 is_scrolled: self.logical_scroll_top.is_some(),
936 is_following_tail: matches!(
937 self.follow_state,
938 FollowState::Tail { is_following: true }
939 ),
940 },
941 window,
942 cx,
943 );
944 }
945
946 cx.notify(current_view);
947 }
948
949 fn logical_scroll_top(&self) -> ListOffset {
950 self.logical_scroll_top
951 .unwrap_or_else(|| match self.alignment {
952 ListAlignment::Top => ListOffset {
953 item_ix: 0,
954 offset_in_item: px(0.),
955 },
956 ListAlignment::Bottom => ListOffset {
957 item_ix: self.items.summary().count,
958 offset_in_item: px(0.),
959 },
960 })
961 }
962
963 fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels {
964 let (start, ..) = self.items.find::<ListItemSummary, _>(
965 (),
966 &Count(logical_scroll_top.item_ix),
967 Bias::Right,
968 );
969 start.height + logical_scroll_top.offset_in_item
970 }
971
972 fn layout_all_items(
973 &mut self,
974 available_width: Pixels,
975 render_item: &mut RenderItemFn,
976 window: &mut Window,
977 cx: &mut App,
978 ) {
979 match &mut self.measuring_behavior {
980 ListMeasuringBehavior::Visible => {
981 return;
982 }
983 ListMeasuringBehavior::Measure(has_measured) => {
984 if *has_measured {
985 return;
986 }
987 *has_measured = true;
988 }
989 }
990
991 let mut cursor = self.items.cursor::<Count>(());
992 let available_item_space = size(
993 AvailableSpace::Definite(available_width),
994 AvailableSpace::MinContent,
995 );
996
997 let mut measured_items = Vec::default();
998
999 for (ix, item) in cursor.enumerate() {
1000 let size = item.size().unwrap_or_else(|| {
1001 let mut element = render_item(ix, window, cx);
1002 element.layout_as_root(available_item_space, window, cx)
1003 });
1004
1005 measured_items.push(ListItem::Measured {
1006 size,
1007 focus_handle: item.focus_handle(),
1008 });
1009 }
1010
1011 self.items = SumTree::from_iter(measured_items, ());
1012 }
1013
1014 fn layout_items(
1015 &mut self,
1016 available_width: Option<Pixels>,
1017 available_height: Pixels,
1018 padding: &Edges<Pixels>,
1019 render_item: &mut RenderItemFn,
1020 window: &mut Window,
1021 cx: &mut App,
1022 ) -> LayoutItemsResponse {
1023 let old_items = self.items.clone();
1024 let mut measured_items = VecDeque::new();
1025 let mut item_layouts = VecDeque::new();
1026 let mut rendered_height = padding.top;
1027 let mut max_item_width = px(0.);
1028 let mut scroll_top = self.logical_scroll_top();
1029
1030 if self.follow_state.is_following() {
1031 scroll_top = ListOffset {
1032 item_ix: self.items.summary().count,
1033 offset_in_item: px(0.),
1034 };
1035 self.logical_scroll_top = Some(scroll_top);
1036 }
1037
1038 let mut rendered_focused_item = false;
1039
1040 let available_item_space = size(
1041 available_width.map_or(AvailableSpace::MaxContent, |width| {
1042 AvailableSpace::Definite(width)
1043 }),
1044 AvailableSpace::MinContent,
1045 );
1046
1047 let mut cursor = old_items.cursor::<Count>(());
1048
1049 // Render items after the scroll top, including those in the trailing overdraw
1050 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1051 for (ix, item) in cursor.by_ref().enumerate() {
1052 let visible_height = rendered_height - scroll_top.offset_in_item;
1053 if visible_height >= available_height + self.overdraw {
1054 break;
1055 }
1056
1057 // Use the previously cached height and focus handle if available
1058 let mut size = item.size();
1059
1060 // If we're within the visible area or the height wasn't cached, render and measure the item's element
1061 if visible_height < available_height || size.is_none() {
1062 let item_index = scroll_top.item_ix + ix;
1063 let mut element = render_item(item_index, window, cx);
1064 let element_size = element.layout_as_root(available_item_space, window, cx);
1065 size = Some(element_size);
1066
1067 // If there's a pending scroll adjustment for the scroll-top
1068 // item, apply it.
1069 if ix == 0 {
1070 if let Some(pending_scroll) = self.pending_scroll.take() {
1071 match pending_scroll {
1072 PendingScroll::Absolute { item_ix, offset }
1073 if item_ix == scroll_top.item_ix =>
1074 {
1075 scroll_top.offset_in_item = offset.min(element_size.height);
1076 self.logical_scroll_top = Some(scroll_top);
1077 }
1078 PendingScroll::Proportional(pending_scroll)
1079 if pending_scroll.item_ix == scroll_top.item_ix =>
1080 {
1081 // Ensuring proportional scroll position is
1082 // maintained after re-measuring.
1083 scroll_top.offset_in_item =
1084 Pixels(pending_scroll.fraction * element_size.height.0);
1085 self.logical_scroll_top = Some(scroll_top);
1086 }
1087 _ => {}
1088 }
1089 }
1090 }
1091
1092 if visible_height < available_height {
1093 item_layouts.push_back(ItemLayout {
1094 index: item_index,
1095 element,
1096 size: element_size,
1097 });
1098 if item.contains_focused(window, cx) {
1099 rendered_focused_item = true;
1100 }
1101 }
1102 }
1103
1104 let size = size.unwrap();
1105 rendered_height += size.height;
1106 max_item_width = max_item_width.max(size.width);
1107 measured_items.push_back(ListItem::Measured {
1108 size,
1109 focus_handle: item.focus_handle(),
1110 });
1111 }
1112 rendered_height += padding.bottom;
1113
1114 // Prepare to start walking upward from the item at the scroll top.
1115 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
1116
1117 // If the rendered items do not fill the visible region, then adjust
1118 // the scroll top upward.
1119 if rendered_height - scroll_top.offset_in_item < available_height {
1120 while rendered_height < available_height {
1121 cursor.prev();
1122 if let Some(item) = cursor.item() {
1123 let item_index = cursor.start().0;
1124 let mut element = render_item(item_index, window, cx);
1125 let element_size = element.layout_as_root(available_item_space, window, cx);
1126 let focus_handle = item.focus_handle();
1127 rendered_height += element_size.height;
1128 measured_items.push_front(ListItem::Measured {
1129 size: element_size,
1130 focus_handle,
1131 });
1132 item_layouts.push_front(ItemLayout {
1133 index: item_index,
1134 element,
1135 size: element_size,
1136 });
1137 if item.contains_focused(window, cx) {
1138 rendered_focused_item = true;
1139 }
1140 } else {
1141 break;
1142 }
1143 }
1144
1145 scroll_top = ListOffset {
1146 item_ix: cursor.start().0,
1147 offset_in_item: rendered_height - available_height,
1148 };
1149
1150 match self.alignment {
1151 ListAlignment::Top => {
1152 scroll_top.offset_in_item = scroll_top.offset_in_item.max(px(0.));
1153 self.logical_scroll_top = Some(scroll_top);
1154 }
1155 ListAlignment::Bottom => {
1156 scroll_top = ListOffset {
1157 item_ix: cursor.start().0,
1158 offset_in_item: rendered_height - available_height,
1159 };
1160 self.logical_scroll_top = None;
1161 }
1162 };
1163 }
1164
1165 // Measure items in the leading overdraw
1166 let mut leading_overdraw = scroll_top.offset_in_item;
1167 while leading_overdraw < self.overdraw {
1168 cursor.prev();
1169 if let Some(item) = cursor.item() {
1170 let size = if let ListItem::Measured { size, .. } = item {
1171 *size
1172 } else {
1173 let mut element = render_item(cursor.start().0, window, cx);
1174 element.layout_as_root(available_item_space, window, cx)
1175 };
1176
1177 leading_overdraw += size.height;
1178 measured_items.push_front(ListItem::Measured {
1179 size,
1180 focus_handle: item.focus_handle(),
1181 });
1182 } else {
1183 break;
1184 }
1185 }
1186
1187 let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len());
1188 let mut cursor = old_items.cursor::<Count>(());
1189 let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right);
1190 new_items.extend(measured_items, ());
1191 cursor.seek(&Count(measured_range.end), Bias::Right);
1192 new_items.append(cursor.suffix(), ());
1193 self.items = new_items;
1194
1195 // If follow_tail mode is on but the user scrolled away
1196 // (is_following is false), check whether the current scroll
1197 // position has returned to the bottom.
1198 if self.follow_state.has_stopped_following() {
1199 let padding = self.last_padding.unwrap_or_default();
1200 let total_height = self.items.summary().height + padding.top + padding.bottom;
1201 let scroll_offset = self.scroll_top(&scroll_top);
1202 if scroll_offset + available_height >= total_height - px(1.0) {
1203 self.follow_state.start_following();
1204 }
1205 }
1206
1207 // If none of the visible items are focused, check if an off-screen item is focused
1208 // and include it to be rendered after the visible items so keyboard interaction continues
1209 // to work for it.
1210 if !rendered_focused_item {
1211 let mut cursor = self
1212 .items
1213 .filter::<_, Count>((), |summary| summary.has_focus_handles);
1214 cursor.next();
1215 while let Some(item) = cursor.item() {
1216 if item.contains_focused(window, cx) {
1217 let item_index = cursor.start().0;
1218 let mut element = render_item(cursor.start().0, window, cx);
1219 let size = element.layout_as_root(available_item_space, window, cx);
1220 item_layouts.push_back(ItemLayout {
1221 index: item_index,
1222 element,
1223 size,
1224 });
1225 break;
1226 }
1227 cursor.next();
1228 }
1229 }
1230
1231 LayoutItemsResponse {
1232 max_item_width,
1233 scroll_top,
1234 item_layouts,
1235 }
1236 }
1237
1238 fn prepaint_items(
1239 &mut self,
1240 bounds: Bounds<Pixels>,
1241 padding: Edges<Pixels>,
1242 autoscroll: bool,
1243 render_item: &mut RenderItemFn,
1244 window: &mut Window,
1245 cx: &mut App,
1246 ) -> Result<LayoutItemsResponse, ListOffset> {
1247 window.transact(|window| {
1248 match self.measuring_behavior {
1249 ListMeasuringBehavior::Measure(has_measured) if !has_measured => {
1250 self.layout_all_items(bounds.size.width, render_item, window, cx);
1251 }
1252 _ => {}
1253 }
1254
1255 let mut layout_response = self.layout_items(
1256 Some(bounds.size.width),
1257 bounds.size.height,
1258 &padding,
1259 render_item,
1260 window,
1261 cx,
1262 );
1263
1264 // Avoid honoring autoscroll requests from elements other than our children.
1265 window.take_autoscroll();
1266
1267 // Only paint the visible items, if there is actually any space for them (taking padding into account)
1268 if bounds.size.height > padding.top + padding.bottom {
1269 let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
1270 item_origin.y -= layout_response.scroll_top.offset_in_item;
1271 for item in &mut layout_response.item_layouts {
1272 window.with_content_mask(Some(ContentMask { bounds }), |window| {
1273 item.element.prepaint_at(item_origin, window, cx);
1274 });
1275
1276 if let Some(autoscroll_bounds) = window.take_autoscroll()
1277 && autoscroll
1278 {
1279 if autoscroll_bounds.top() < bounds.top() {
1280 let mut item_ix = item.index;
1281 let mut offset_in_item = autoscroll_bounds.top() - item_origin.y;
1282
1283 // The requested top can sit above this item's own
1284 // top. Walk into earlier items so the offset stays
1285 // non-negative and no blank space appears above the
1286 // list.
1287 if offset_in_item < Pixels::ZERO {
1288 let mut cursor = self.items.cursor::<Count>(());
1289 cursor.seek(&Count(item_ix), Bias::Right);
1290 while offset_in_item < Pixels::ZERO {
1291 cursor.prev();
1292 let Some(prev_item) = cursor.item() else {
1293 offset_in_item = Pixels::ZERO;
1294 break;
1295 };
1296 let size = prev_item.size().unwrap_or_else(|| {
1297 let mut element = render_item(cursor.start().0, window, cx);
1298 let item_available_size = size(
1299 bounds.size.width.into(),
1300 AvailableSpace::MinContent,
1301 );
1302 element.layout_as_root(item_available_size, window, cx)
1303 });
1304 item_ix = cursor.start().0;
1305 offset_in_item += size.height;
1306 }
1307 }
1308
1309 return Err(ListOffset {
1310 item_ix,
1311 offset_in_item,
1312 });
1313 } else if autoscroll_bounds.bottom() > bounds.bottom() {
1314 let mut cursor = self.items.cursor::<Count>(());
1315 cursor.seek(&Count(item.index), Bias::Right);
1316 let mut height = bounds.size.height - padding.top - padding.bottom;
1317
1318 // Account for the height of the element down until the autoscroll bottom.
1319 height -= autoscroll_bounds.bottom() - item_origin.y;
1320
1321 // Keep decreasing the scroll top until we fill all the available space.
1322 while height > Pixels::ZERO {
1323 cursor.prev();
1324 let Some(item) = cursor.item() else { break };
1325
1326 let size = item.size().unwrap_or_else(|| {
1327 let mut item = render_item(cursor.start().0, window, cx);
1328 let item_available_size =
1329 size(bounds.size.width.into(), AvailableSpace::MinContent);
1330 item.layout_as_root(item_available_size, window, cx)
1331 });
1332 height -= size.height;
1333 }
1334
1335 return Err(ListOffset {
1336 item_ix: cursor.start().0,
1337 offset_in_item: if height < Pixels::ZERO {
1338 -height
1339 } else {
1340 Pixels::ZERO
1341 },
1342 });
1343 }
1344 }
1345
1346 item_origin.y += item.size.height;
1347 }
1348 } else {
1349 layout_response.item_layouts.clear();
1350 }
1351
1352 Ok(layout_response)
1353 })
1354 }
1355
1356 // Scrollbar support
1357
1358 fn set_offset_from_scrollbar(&mut self, point: Point<Pixels>) {
1359 let Some(bounds) = self.last_layout_bounds else {
1360 return;
1361 };
1362 let height = bounds.size.height;
1363
1364 let padding = self.last_padding.unwrap_or_default();
1365 // Scrollbar drag positions are computed from the content height
1366 // captured at drag start, so map them back using the same height.
1367 let content_height = self
1368 .scrollbar_drag_start_height
1369 .unwrap_or_else(|| self.items.summary().height);
1370 let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.));
1371 let new_scroll_top = (-point.y).max(px(0.)).min(scroll_max);
1372
1373 // If content grew during the drag, the frozen bottom is below the
1374 // live bottom. Treat dragging to the frozen end as resuming tail follow.
1375 let dragged_to_end =
1376 scroll_max > px(0.) && new_scroll_top >= (scroll_max - px(1.0)).max(px(0.));
1377 if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) {
1378 self.follow_state = FollowState::Tail { is_following: true };
1379 let item_count = self.items.summary().count;
1380 self.pending_scroll = None;
1381 self.logical_scroll_top = Some(ListOffset {
1382 item_ix: item_count,
1383 offset_in_item: px(0.),
1384 });
1385 return;
1386 }
1387
1388 self.follow_state.stop_following();
1389
1390 if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
1391 self.pending_scroll = None;
1392 self.logical_scroll_top = None;
1393 } else {
1394 let (start, _, _) =
1395 self.items
1396 .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
1397
1398 let scroll_top = ListOffset {
1399 item_ix: start.count,
1400 offset_in_item: new_scroll_top - start.height,
1401 };
1402 self.rebase_pending_scroll(scroll_top);
1403 self.logical_scroll_top = Some(scroll_top);
1404 }
1405 }
1406}
1407
1408impl std::fmt::Debug for ListItem {
1409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1410 match self {
1411 Self::Unmeasured { .. } => write!(f, "Unrendered"),
1412 Self::Measured { size, .. } => f.debug_struct("Rendered").field("size", size).finish(),
1413 }
1414 }
1415}
1416
1417/// An offset into the list's items, in terms of the item index and the number
1418/// of pixels off the top left of the item.
1419#[derive(Debug, Clone, Copy, Default)]
1420pub struct ListOffset {
1421 /// The index of an item in the list
1422 pub item_ix: usize,
1423 /// The number of pixels to offset from the item index.
1424 pub offset_in_item: Pixels,
1425}
1426
1427impl Element for List {
1428 type RequestLayoutState = ();
1429 type PrepaintState = ListPrepaintState;
1430
1431 fn id(&self) -> Option<crate::ElementId> {
1432 None
1433 }
1434
1435 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1436 None
1437 }
1438
1439 fn request_layout(
1440 &mut self,
1441 _id: Option<&GlobalElementId>,
1442 _inspector_id: Option<&InspectorElementId>,
1443 window: &mut Window,
1444 cx: &mut App,
1445 ) -> (crate::LayoutId, Self::RequestLayoutState) {
1446 let layout_id = match self.sizing_behavior {
1447 ListSizingBehavior::Infer => {
1448 let mut style = Style::default();
1449 style.overflow.y = Overflow::Scroll;
1450 style.refine(&self.style);
1451 window.with_text_style(style.text_style().cloned(), |window| {
1452 let state = &mut *self.state.0.borrow_mut();
1453
1454 let available_height = if let Some(last_bounds) = state.last_layout_bounds {
1455 last_bounds.size.height
1456 } else {
1457 // If we don't have the last layout bounds (first render),
1458 // we might just use the overdraw value as the available height to layout enough items.
1459 state.overdraw
1460 };
1461 let padding = style.padding.to_pixels(
1462 state.last_layout_bounds.unwrap_or_default().size.into(),
1463 window.rem_size(),
1464 );
1465
1466 let layout_response = state.layout_items(
1467 None,
1468 available_height,
1469 &padding,
1470 &mut self.render_item,
1471 window,
1472 cx,
1473 );
1474 let max_element_width = layout_response.max_item_width;
1475
1476 let summary = state.items.summary();
1477 let total_height = summary.height;
1478
1479 window.request_measured_layout(
1480 style,
1481 move |known_dimensions, available_space, _window, _cx| {
1482 let width =
1483 known_dimensions
1484 .width
1485 .unwrap_or(match available_space.width {
1486 AvailableSpace::Definite(x) => x,
1487 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1488 max_element_width
1489 }
1490 });
1491 let height = match available_space.height {
1492 AvailableSpace::Definite(height) => total_height.min(height),
1493 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1494 total_height
1495 }
1496 };
1497 size(width, height)
1498 },
1499 )
1500 })
1501 }
1502 ListSizingBehavior::Auto => {
1503 let mut style = Style::default();
1504 style.refine(&self.style);
1505 window.with_text_style(style.text_style().cloned(), |window| {
1506 window.request_layout(style, None, cx)
1507 })
1508 }
1509 };
1510 (layout_id, ())
1511 }
1512
1513 fn prepaint(
1514 &mut self,
1515 _id: Option<&GlobalElementId>,
1516 _inspector_id: Option<&InspectorElementId>,
1517 bounds: Bounds<Pixels>,
1518 _: &mut Self::RequestLayoutState,
1519 window: &mut Window,
1520 cx: &mut App,
1521 ) -> ListPrepaintState {
1522 let state = &mut *self.state.0.borrow_mut();
1523 state.reset = false;
1524
1525 let mut style = Style::default();
1526 style.refine(&self.style);
1527
1528 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
1529
1530 // If the width of the list has changed, invalidate all cached item heights
1531 if state
1532 .last_layout_bounds
1533 .is_none_or(|last_bounds| last_bounds.size.width != bounds.size.width)
1534 {
1535 let new_items = SumTree::from_iter(
1536 state.items.iter().map(|item| ListItem::Unmeasured {
1537 size_hint: None,
1538 focus_handle: item.focus_handle(),
1539 }),
1540 (),
1541 );
1542
1543 state.items = new_items;
1544 state.measuring_behavior.reset();
1545 }
1546
1547 let padding = style
1548 .padding
1549 .to_pixels(bounds.size.into(), window.rem_size());
1550 let layout =
1551 match state.prepaint_items(bounds, padding, true, &mut self.render_item, window, cx) {
1552 Ok(layout) => layout,
1553 Err(autoscroll_request) => {
1554 state.logical_scroll_top = Some(autoscroll_request);
1555 state
1556 .prepaint_items(bounds, padding, false, &mut self.render_item, window, cx)
1557 .unwrap()
1558 }
1559 };
1560
1561 state.last_layout_bounds = Some(bounds);
1562 state.last_padding = Some(padding);
1563 ListPrepaintState { hitbox, layout }
1564 }
1565
1566 fn paint(
1567 &mut self,
1568 _id: Option<&GlobalElementId>,
1569 _inspector_id: Option<&InspectorElementId>,
1570 bounds: Bounds<crate::Pixels>,
1571 _: &mut Self::RequestLayoutState,
1572 prepaint: &mut Self::PrepaintState,
1573 window: &mut Window,
1574 cx: &mut App,
1575 ) {
1576 let current_view = window.current_view();
1577 window.with_content_mask(Some(ContentMask { bounds }), |window| {
1578 for item in &mut prepaint.layout.item_layouts {
1579 item.element.paint(window, cx);
1580 }
1581 });
1582
1583 let list_state = self.state.clone();
1584 let height = bounds.size.height;
1585 let scroll_top = prepaint.layout.scroll_top;
1586 let hitbox_id = prepaint.hitbox.id;
1587 let mut accumulated_scroll_delta = ScrollDelta::default();
1588 window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
1589 if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) {
1590 accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta);
1591 let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.));
1592 list_state.0.borrow_mut().scroll(
1593 &scroll_top,
1594 height,
1595 pixel_delta,
1596 current_view,
1597 window,
1598 cx,
1599 )
1600 }
1601 });
1602 }
1603}
1604
1605impl IntoElement for List {
1606 type Element = Self;
1607
1608 fn into_element(self) -> Self::Element {
1609 self
1610 }
1611}
1612
1613impl Styled for List {
1614 fn style(&mut self) -> &mut StyleRefinement {
1615 &mut self.style
1616 }
1617}
1618
1619impl sum_tree::Item for ListItem {
1620 type Summary = ListItemSummary;
1621
1622 fn summary(&self, _: ()) -> Self::Summary {
1623 match self {
1624 ListItem::Unmeasured {
1625 size_hint,
1626 focus_handle,
1627 } => ListItemSummary {
1628 count: 1,
1629 rendered_count: 0,
1630 unrendered_count: 1,
1631 height: if let Some(size) = size_hint {
1632 size.height
1633 } else {
1634 px(0.)
1635 },
1636 has_focus_handles: focus_handle.is_some(),
1637 has_unknown_height: size_hint.is_none(),
1638 },
1639 ListItem::Measured {
1640 size, focus_handle, ..
1641 } => ListItemSummary {
1642 count: 1,
1643 rendered_count: 1,
1644 unrendered_count: 0,
1645 height: size.height,
1646 has_focus_handles: focus_handle.is_some(),
1647 has_unknown_height: false,
1648 },
1649 }
1650 }
1651}
1652
1653impl sum_tree::ContextLessSummary for ListItemSummary {
1654 fn zero() -> Self {
1655 Default::default()
1656 }
1657
1658 fn add_summary(&mut self, summary: &Self) {
1659 self.count += summary.count;
1660 self.rendered_count += summary.rendered_count;
1661 self.unrendered_count += summary.unrendered_count;
1662 self.height += summary.height;
1663 self.has_focus_handles |= summary.has_focus_handles;
1664 self.has_unknown_height |= summary.has_unknown_height;
1665 }
1666}
1667
1668impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Count {
1669 fn zero(_cx: ()) -> Self {
1670 Default::default()
1671 }
1672
1673 fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1674 self.0 += summary.count;
1675 }
1676}
1677
1678impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Height {
1679 fn zero(_cx: ()) -> Self {
1680 Default::default()
1681 }
1682
1683 fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1684 self.0 += summary.height;
1685 }
1686}
1687
1688impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Count {
1689 fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1690 self.0.partial_cmp(&other.count).unwrap()
1691 }
1692}
1693
1694impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Height {
1695 fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1696 self.0.partial_cmp(&other.height).unwrap()
1697 }
1698}
1699
1700#[cfg(test)]
1701mod test {
1702
1703 use gpui::{ScrollDelta, ScrollWheelEvent};
1704 use std::cell::Cell;
1705 use std::rc::Rc;
1706
1707 use crate::{
1708 self as gpui, AppContext, Bounds, Context, Element, FollowMode, IntoElement, ListState,
1709 Render, Styled, TestAppContext, Window, canvas, div, list, point, px, size,
1710 };
1711
1712 #[gpui::test]
1713 fn test_autoscroll_above_item_top_renders_items_above(cx: &mut TestAppContext) {
1714 let cx = cx.add_empty_window();
1715
1716 let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1717 state.scroll_to(gpui::ListOffset {
1718 item_ix: 2,
1719 offset_in_item: px(0.),
1720 });
1721
1722 struct TestView(ListState);
1723 impl Render for TestView {
1724 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1725 list(self.0.clone(), |ix, _, _| {
1726 if ix == 2 {
1727 // Request an autoscroll whose top sits 30px above item 2's
1728 // own top, mimicking a scroll-margin overshoot.
1729 canvas(
1730 |bounds, window, _| {
1731 window.request_autoscroll(Bounds::from_corners(
1732 point(bounds.left(), bounds.top() - px(30.)),
1733 point(bounds.right(), bounds.top() + px(5.)),
1734 ));
1735 },
1736 |_, _, _, _| {},
1737 )
1738 .h(px(20.))
1739 .w_full()
1740 .into_any()
1741 } else {
1742 div().h(px(20.)).w_full().into_any()
1743 }
1744 })
1745 .w_full()
1746 .h_full()
1747 }
1748 }
1749
1750 cx.draw(point(px(0.), px(0.)), size(px(100.), px(60.)), |_, cx| {
1751 cx.new(|_| TestView(state.clone())).into_any_element()
1752 });
1753
1754 // 30px above item 2's top, with 20px items, lands 10px into item 0.
1755 let scroll_top = state.logical_scroll_top();
1756 assert!(
1757 scroll_top.offset_in_item >= px(0.),
1758 "offset_in_item must never be negative (would leave blank space above), got {:?}",
1759 scroll_top.offset_in_item,
1760 );
1761 assert_eq!(scroll_top.item_ix, 0);
1762 assert_eq!(scroll_top.offset_in_item, px(10.));
1763 }
1764
1765 #[gpui::test]
1766 fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) {
1767 let cx = cx.add_empty_window();
1768
1769 let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1770
1771 // Ensure that the list is scrolled to the top
1772 state.scroll_to(gpui::ListOffset {
1773 item_ix: 0,
1774 offset_in_item: px(0.0),
1775 });
1776
1777 struct TestView(ListState);
1778 impl Render for TestView {
1779 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1780 list(self.0.clone(), |_, _, _| {
1781 div().h(px(10.)).w_full().into_any()
1782 })
1783 .w_full()
1784 .h_full()
1785 }
1786 }
1787
1788 // Paint
1789 cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1790 cx.new(|_| TestView(state.clone())).into_any_element()
1791 });
1792
1793 // Reset
1794 state.reset(5);
1795
1796 // And then receive a scroll event _before_ the next paint
1797 cx.simulate_event(ScrollWheelEvent {
1798 position: point(px(1.), px(1.)),
1799 delta: ScrollDelta::Pixels(point(px(0.), px(-500.))),
1800 ..Default::default()
1801 });
1802
1803 // Scroll position should stay at the top of the list
1804 assert_eq!(state.logical_scroll_top().item_ix, 0);
1805 assert_eq!(state.logical_scroll_top().offset_in_item, px(0.));
1806 }
1807
1808 #[gpui::test]
1809 fn test_scroll_by_positive_and_negative_distance(cx: &mut TestAppContext) {
1810 let cx = cx.add_empty_window();
1811
1812 let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1813
1814 struct TestView(ListState);
1815 impl Render for TestView {
1816 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1817 list(self.0.clone(), |_, _, _| {
1818 div().h(px(20.)).w_full().into_any()
1819 })
1820 .w_full()
1821 .h_full()
1822 }
1823 }
1824
1825 // Paint
1826 cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
1827 cx.new(|_| TestView(state.clone())).into_any_element()
1828 });
1829
1830 // Test positive distance: start at item 1, move down 30px
1831 state.scroll_by(px(30.));
1832
1833 // Should move to item 2
1834 let offset = state.logical_scroll_top();
1835 assert_eq!(offset.item_ix, 1);
1836 assert_eq!(offset.offset_in_item, px(10.));
1837
1838 // Test negative distance: start at item 2, move up 30px
1839 state.scroll_by(px(-30.));
1840
1841 // Should move back to item 1
1842 let offset = state.logical_scroll_top();
1843 assert_eq!(offset.item_ix, 0);
1844 assert_eq!(offset.offset_in_item, px(0.));
1845
1846 // Test zero distance
1847 state.scroll_by(px(0.));
1848 let offset = state.logical_scroll_top();
1849 assert_eq!(offset.item_ix, 0);
1850 assert_eq!(offset.offset_in_item, px(0.));
1851 }
1852
1853 struct TestListView(ListState);
1854 impl Render for TestListView {
1855 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1856 list(self.0.clone(), |_, _, _| {
1857 div().h(px(20.)).w_full().into_any()
1858 })
1859 .w_full()
1860 .h_full()
1861 }
1862 }
1863
1864 #[gpui::test]
1865 fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) {
1866 let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1867
1868 assert_eq!(state.item_is_above_viewport(0), None);
1869 assert_eq!(state.item_is_below_viewport(0), None);
1870 }
1871
1872 #[gpui::test]
1873 fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) {
1874 let cx = cx.add_empty_window();
1875
1876 let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1877
1878 state.scroll_to(gpui::ListOffset {
1879 item_ix: 2,
1880 offset_in_item: px(0.),
1881 });
1882 cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1883 cx.new(|_| TestListView(state.clone())).into_any_element()
1884 });
1885
1886 assert_eq!(state.item_is_above_viewport(1), Some(true));
1887 assert_eq!(state.item_is_below_viewport(1), Some(false));
1888 }
1889
1890 #[gpui::test]
1891 fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) {
1892 let cx = cx.add_empty_window();
1893
1894 let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1895
1896 state.scroll_to(gpui::ListOffset {
1897 item_ix: 2,
1898 offset_in_item: px(0.),
1899 });
1900 cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1901 cx.new(|_| TestListView(state.clone())).into_any_element()
1902 });
1903
1904 assert_eq!(state.item_is_above_viewport(2), Some(false));
1905 assert_eq!(state.item_is_below_viewport(2), Some(false));
1906 }
1907
1908 #[gpui::test]
1909 fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) {
1910 let cx = cx.add_empty_window();
1911
1912 let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1913
1914 state.scroll_to(gpui::ListOffset {
1915 item_ix: 2,
1916 offset_in_item: px(20.),
1917 });
1918 cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1919 cx.new(|_| TestListView(state.clone())).into_any_element()
1920 });
1921
1922 assert_eq!(state.item_is_above_viewport(2), Some(true));
1923 assert_eq!(state.item_is_below_viewport(2), Some(false));
1924 }
1925
1926 #[gpui::test]
1927 fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) {
1928 let cx = cx.add_empty_window();
1929
1930 let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1931
1932 state.scroll_to(gpui::ListOffset {
1933 item_ix: 2,
1934 offset_in_item: px(0.),
1935 });
1936 cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1937 cx.new(|_| TestListView(state.clone())).into_any_element()
1938 });
1939
1940 assert_eq!(state.item_is_above_viewport(3), Some(false));
1941 assert_eq!(state.item_is_below_viewport(3), Some(true));
1942 }
1943
1944 #[gpui::test]
1945 fn test_item_viewport_queries_remain_stable_with_zero_height_viewport(cx: &mut TestAppContext) {
1946 let cx = cx.add_empty_window();
1947
1948 let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1949
1950 state.scroll_to(gpui::ListOffset {
1951 item_ix: 2,
1952 offset_in_item: px(0.),
1953 });
1954 cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1955 cx.new(|_| TestListView(state.clone())).into_any_element()
1956 });
1957
1958 assert_eq!(state.item_is_above_viewport(3), Some(false));
1959 assert_eq!(state.item_is_below_viewport(3), Some(true));
1960
1961 // Squeeze the list to zero height, e.g. because a sibling element
1962 // (sized based on the queries above) consumed all the space. The
1963 // answers must remain definitive rather than becoming `None`,
1964 // otherwise the sibling's size can oscillate between frames.
1965 cx.draw(point(px(0.), px(0.)), size(px(100.), px(0.)), |_, cx| {
1966 cx.new(|_| TestListView(state.clone())).into_any_element()
1967 });
1968
1969 assert_eq!(state.item_is_above_viewport(1), Some(true));
1970 assert_eq!(state.item_is_below_viewport(1), Some(false));
1971 assert_eq!(state.item_is_above_viewport(3), Some(false));
1972 assert_eq!(state.item_is_below_viewport(3), Some(true));
1973 }
1974
1975 #[gpui::test]
1976 fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) {
1977 let cx = cx.add_empty_window();
1978
1979 let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
1980
1981 cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1982 cx.new(|_| TestListView(state.clone())).into_any_element()
1983 });
1984
1985 state.scroll_to_end();
1986
1987 assert_eq!(state.logical_scroll_top().item_ix, state.item_count());
1988 assert_eq!(state.item_is_above_viewport(0), Some(true));
1989 assert_eq!(state.item_is_below_viewport(0), Some(false));
1990 }
1991
1992 #[gpui::test]
1993 fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
1994 let cx = cx.add_empty_window();
1995
1996 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1997
1998 struct TestView(ListState);
1999 impl Render for TestView {
2000 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2001 list(self.0.clone(), |_, _, _| {
2002 div().h(px(50.)).w_full().into_any()
2003 })
2004 .w_full()
2005 .h_full()
2006 }
2007 }
2008
2009 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2010
2011 // First draw at width 100: all 10 items measured (total 500px).
2012 // Viewport is 200px, so max scroll offset should be 300px.
2013 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2014 view.clone().into_any_element()
2015 });
2016 assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2017
2018 // Second draw at a different width: items get invalidated.
2019 // Without the fix, max_offset would drop because unmeasured items
2020 // contribute 0 height.
2021 cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| {
2022 view.into_any_element()
2023 });
2024 assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2025 }
2026
2027 #[gpui::test]
2028 fn test_remeasure(cx: &mut TestAppContext) {
2029 let cx = cx.add_empty_window();
2030
2031 // Create a list with 10 items, each 100px tall. We'll keep a reference
2032 // to the item height so we can later change the height and assert how
2033 // `ListState` handles it.
2034 let item_height = Rc::new(Cell::new(100usize));
2035 let state = ListState::new(10, crate::ListAlignment::Top, px(10.));
2036
2037 struct TestView {
2038 state: ListState,
2039 item_height: Rc<Cell<usize>>,
2040 }
2041
2042 impl Render for TestView {
2043 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2044 let height = self.item_height.get();
2045 list(self.state.clone(), move |_, _, _| {
2046 div().h(px(height as f32)).w_full().into_any()
2047 })
2048 .w_full()
2049 .h_full()
2050 }
2051 }
2052
2053 let state_clone = state.clone();
2054 let item_height_clone = item_height.clone();
2055 let view = cx.update(|_, cx| {
2056 cx.new(|_| TestView {
2057 state: state_clone,
2058 item_height: item_height_clone,
2059 })
2060 });
2061
2062 // Simulate scrolling 40px inside the element with index 2. Since the
2063 // original item height is 100px, this equates to 40% inside the item.
2064 state.scroll_to(gpui::ListOffset {
2065 item_ix: 2,
2066 offset_in_item: px(40.),
2067 });
2068
2069 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2070 view.clone().into_any_element()
2071 });
2072
2073 let offset = state.logical_scroll_top();
2074 assert_eq!(offset.item_ix, 2);
2075 assert_eq!(offset.offset_in_item, px(40.));
2076
2077 // Update the `item_height` to be 50px instead of 100px so we can assert
2078 // that the scroll position is proportionally preserved, that is,
2079 // instead of 40px from the top of item 2, it should be 20px, since the
2080 // item's height has been halved.
2081 item_height.set(50);
2082 state.remeasure();
2083
2084 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2085 view.into_any_element()
2086 });
2087
2088 let offset = state.logical_scroll_top();
2089 assert_eq!(offset.item_ix, 2);
2090 assert_eq!(offset.offset_in_item, px(20.));
2091 }
2092
2093 #[gpui::test]
2094 fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) {
2095 let cx = cx.add_empty_window();
2096
2097 let item_height = Rc::new(Cell::new(100usize));
2098 let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
2099
2100 struct TestView {
2101 state: ListState,
2102 item_height: Rc<Cell<usize>>,
2103 }
2104
2105 impl Render for TestView {
2106 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2107 let height = self.item_height.get();
2108 list(self.state.clone(), move |index, _, _| {
2109 let height = if index == 5 { height } else { 100 };
2110 div().h(px(height as f32)).w_full().into_any()
2111 })
2112 .w_full()
2113 .h_full()
2114 }
2115 }
2116
2117 let state_clone = state.clone();
2118 let item_height_clone = item_height.clone();
2119 let view = cx.update(|_, cx| {
2120 cx.new(|_| TestView {
2121 state: state_clone,
2122 item_height: item_height_clone,
2123 })
2124 });
2125
2126 state.scroll_to(gpui::ListOffset {
2127 item_ix: 5,
2128 offset_in_item: px(40.),
2129 });
2130
2131 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2132 view.clone().into_any_element()
2133 });
2134
2135 item_height.set(200);
2136 state.remeasure_items(5..6);
2137
2138 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2139 view.into_any_element()
2140 });
2141
2142 let offset = state.logical_scroll_top();
2143 assert_eq!(offset.item_ix, 5);
2144 assert_eq!(offset.offset_in_item, px(40.));
2145 }
2146
2147 #[gpui::test]
2148 fn test_remeasure_then_scroll_does_not_revert_scroll_position(cx: &mut TestAppContext) {
2149 let cx = cx.add_empty_window();
2150
2151 let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
2152
2153 struct TestView(ListState);
2154 impl Render for TestView {
2155 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2156 list(self.0.clone(), |_, _, _| {
2157 div().h(px(100.)).w_full().into_any()
2158 })
2159 .w_full()
2160 .h_full()
2161 }
2162 }
2163
2164 let view = {
2165 let state = state.clone();
2166 cx.update(|_, cx| cx.new(|_| TestView(state)))
2167 };
2168
2169 state.scroll_to(gpui::ListOffset {
2170 item_ix: 5,
2171 offset_in_item: px(40.),
2172 });
2173
2174 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2175 view.clone().into_any_element()
2176 });
2177
2178 state.remeasure_items(5..6);
2179
2180 cx.simulate_event(ScrollWheelEvent {
2181 position: point(px(50.), px(100.)),
2182 delta: ScrollDelta::Pixels(point(px(0.), px(-30.))),
2183 ..Default::default()
2184 });
2185
2186 let offset = state.logical_scroll_top();
2187 assert_eq!(offset.item_ix, 5);
2188 assert_eq!(offset.offset_in_item, px(70.));
2189
2190 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2191 view.into_any_element()
2192 });
2193
2194 let offset = state.logical_scroll_top();
2195 assert_eq!(offset.item_ix, 5);
2196 assert_eq!(
2197 offset.offset_in_item,
2198 px(70.),
2199 "scrolling after a remeasure should not be reverted by the stale pending scroll"
2200 );
2201 }
2202
2203 #[gpui::test]
2204 fn test_scroll_after_remeasure_clamps_to_shrunk_item_height(cx: &mut TestAppContext) {
2205 let cx = cx.add_empty_window();
2206
2207 let item_height = Rc::new(Cell::new(100usize));
2208 let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
2209
2210 struct TestView {
2211 state: ListState,
2212 item_height: Rc<Cell<usize>>,
2213 }
2214
2215 impl Render for TestView {
2216 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2217 let height = self.item_height.get();
2218 list(self.state.clone(), move |index, _, _| {
2219 let height = if index == 5 { height } else { 100 };
2220 div().h(px(height as f32)).w_full().into_any()
2221 })
2222 .w_full()
2223 .h_full()
2224 }
2225 }
2226
2227 let view = {
2228 let state = state.clone();
2229 let item_height = item_height.clone();
2230 cx.update(|_, cx| cx.new(|_| TestView { state, item_height }))
2231 };
2232
2233 state.scroll_to(gpui::ListOffset {
2234 item_ix: 5,
2235 offset_in_item: px(40.),
2236 });
2237
2238 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2239 view.clone().into_any_element()
2240 });
2241
2242 // Item 5 shrinks from 100px to 50px and is remeasured...
2243 item_height.set(50);
2244 state.remeasure_items(5..6);
2245
2246 // ...and then the user scrolls down by 30px before the next frame,
2247 // landing at offset 70.
2248 cx.simulate_event(ScrollWheelEvent {
2249 position: point(px(50.), px(100.)),
2250 delta: ScrollDelta::Pixels(point(px(0.), px(-30.))),
2251 ..Default::default()
2252 });
2253
2254 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2255 view.into_any_element()
2256 });
2257
2258 // The rebased pending scroll clamps the user's offset to the item's
2259 // new height instead of leaving it pointing past the end of the item.
2260 let offset = state.logical_scroll_top();
2261 assert_eq!(offset.item_ix, 5);
2262 assert_eq!(offset.offset_in_item, px(50.));
2263 }
2264
2265 #[gpui::test]
2266 fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
2267 let cx = cx.add_empty_window();
2268
2269 // 10 items, each 50px tall → 500px total content, 200px viewport.
2270 // With follow-tail on, the list should always show the bottom.
2271 let item_height = Rc::new(Cell::new(50usize));
2272 let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2273
2274 struct TestView {
2275 state: ListState,
2276 item_height: Rc<Cell<usize>>,
2277 }
2278 impl Render for TestView {
2279 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2280 let height = self.item_height.get();
2281 list(self.state.clone(), move |_, _, _| {
2282 div().h(px(height as f32)).w_full().into_any()
2283 })
2284 .w_full()
2285 .h_full()
2286 }
2287 }
2288
2289 let state_clone = state.clone();
2290 let item_height_clone = item_height.clone();
2291 let view = cx.update(|_, cx| {
2292 cx.new(|_| TestView {
2293 state: state_clone,
2294 item_height: item_height_clone,
2295 })
2296 });
2297
2298 state.set_follow_mode(FollowMode::Tail);
2299
2300 // First paint — items are 50px, total 500px, viewport 200px.
2301 // Follow-tail should anchor to the end.
2302 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2303 view.clone().into_any_element()
2304 });
2305
2306 // The scroll should be at the bottom: the last visible items fill the
2307 // 200px viewport from the end of 500px of content (offset 300px).
2308 let offset = state.logical_scroll_top();
2309 assert_eq!(offset.item_ix, 6);
2310 assert_eq!(offset.offset_in_item, px(0.));
2311 assert!(state.is_following_tail());
2312
2313 // Simulate items growing (e.g. streaming content makes each item taller).
2314 // 10 items × 80px = 800px total.
2315 item_height.set(80);
2316 state.remeasure();
2317
2318 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2319 view.into_any_element()
2320 });
2321
2322 // After growth, follow-tail should have re-anchored to the new end.
2323 // 800px total − 200px viewport = 600px offset → item 7 at offset 40px,
2324 // but follow-tail anchors to item_count (10), and layout walks back to
2325 // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200,
2326 // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking →
2327 // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40).
2328 let offset = state.logical_scroll_top();
2329 assert_eq!(offset.item_ix, 7);
2330 assert_eq!(offset.offset_in_item, px(40.));
2331 assert!(state.is_following_tail());
2332 }
2333
2334 #[gpui::test]
2335 fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) {
2336 let cx = cx.add_empty_window();
2337
2338 // 10 items × 50px = 500px total, 200px viewport.
2339 let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2340
2341 struct TestView(ListState);
2342 impl Render for TestView {
2343 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2344 list(self.0.clone(), |_, _, _| {
2345 div().h(px(50.)).w_full().into_any()
2346 })
2347 .w_full()
2348 .h_full()
2349 }
2350 }
2351
2352 state.set_follow_mode(FollowMode::Tail);
2353
2354 // Paint with follow-tail — scroll anchored to the bottom.
2355 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
2356 cx.new(|_| TestView(state.clone())).into_any_element()
2357 });
2358 assert!(state.is_following_tail());
2359
2360 // Simulate the user scrolling up.
2361 // This should disengage follow-tail.
2362 cx.simulate_event(ScrollWheelEvent {
2363 position: point(px(50.), px(100.)),
2364 delta: ScrollDelta::Pixels(point(px(0.), px(100.))),
2365 ..Default::default()
2366 });
2367
2368 assert!(
2369 !state.is_following_tail(),
2370 "follow-tail should disengage when the user scrolls toward the start"
2371 );
2372 }
2373
2374 #[gpui::test]
2375 fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) {
2376 let cx = cx.add_empty_window();
2377
2378 // 10 items × 50px = 500px total, 200px viewport.
2379 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2380
2381 struct TestView(ListState);
2382 impl Render for TestView {
2383 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2384 list(self.0.clone(), |_, _, _| {
2385 div().h(px(50.)).w_full().into_any()
2386 })
2387 .w_full()
2388 .h_full()
2389 }
2390 }
2391
2392 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2393
2394 state.set_follow_mode(FollowMode::Tail);
2395
2396 // Paint with follow-tail — scroll anchored to the bottom.
2397 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2398 view.clone().into_any_element()
2399 });
2400 assert!(state.is_following_tail());
2401
2402 // Simulate the scrollbar moving the viewport to the middle.
2403 state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2404
2405 let offset = state.logical_scroll_top();
2406 assert_eq!(offset.item_ix, 3);
2407 assert_eq!(offset.offset_in_item, px(0.));
2408 assert!(
2409 !state.is_following_tail(),
2410 "follow-tail should disengage when the scrollbar manually repositions the list"
2411 );
2412
2413 // A subsequent draw should preserve the user's manual position instead
2414 // of snapping back to the end.
2415 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2416 view.into_any_element()
2417 });
2418
2419 let offset = state.logical_scroll_top();
2420 assert_eq!(offset.item_ix, 3);
2421 assert_eq!(offset.offset_in_item, px(0.));
2422 }
2423
2424 #[gpui::test]
2425 fn test_scrollbar_drag_with_growing_content(cx: &mut TestAppContext) {
2426 let cx = cx.add_empty_window();
2427
2428 let last_item_height = Rc::new(Cell::new(50usize));
2429 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2430
2431 struct TestView {
2432 state: ListState,
2433 last_item_height: Rc<Cell<usize>>,
2434 }
2435 impl Render for TestView {
2436 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2437 let last_item_height = self.last_item_height.clone();
2438 list(self.state.clone(), move |index, _, _| {
2439 let height = if index == 9 {
2440 last_item_height.get()
2441 } else {
2442 50
2443 };
2444 div().h(px(height as f32)).w_full().into_any()
2445 })
2446 .w_full()
2447 .h_full()
2448 }
2449 }
2450
2451 let view = cx.update(|_, cx| {
2452 cx.new(|_| TestView {
2453 state: state.clone(),
2454 last_item_height: last_item_height.clone(),
2455 })
2456 });
2457
2458 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2459 view.clone().into_any_element()
2460 });
2461
2462 state.scrollbar_drag_started();
2463
2464 state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2465 let scrollbar_offset_before_growth = state.scroll_px_offset_for_scrollbar();
2466
2467 let offset = state.logical_scroll_top();
2468 assert_eq!(offset.item_ix, 3);
2469 assert_eq!(offset.offset_in_item, px(0.));
2470
2471 last_item_height.set(550);
2472 state.remeasure_items(9..10);
2473 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2474 view.clone().into_any_element()
2475 });
2476
2477 assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2478 assert_eq!(
2479 state.scroll_px_offset_for_scrollbar(),
2480 scrollbar_offset_before_growth
2481 );
2482
2483 state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2484 let offset = state.logical_scroll_top();
2485 assert_eq!(offset.item_ix, 3);
2486 assert_eq!(offset.offset_in_item, px(0.));
2487 }
2488
2489 #[gpui::test]
2490 fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) {
2491 let cx = cx.add_empty_window();
2492
2493 // 10 items × 50px = 500px total, 200px viewport.
2494 let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2495
2496 struct TestView(ListState);
2497 impl Render for TestView {
2498 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2499 list(self.0.clone(), |_, _, _| {
2500 div().h(px(50.)).w_full().into_any()
2501 })
2502 .w_full()
2503 .h_full()
2504 }
2505 }
2506
2507 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2508
2509 // Scroll to the middle of the list (item 3).
2510 state.scroll_to(gpui::ListOffset {
2511 item_ix: 3,
2512 offset_in_item: px(0.),
2513 });
2514
2515 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2516 view.clone().into_any_element()
2517 });
2518
2519 let offset = state.logical_scroll_top();
2520 assert_eq!(offset.item_ix, 3);
2521 assert_eq!(offset.offset_in_item, px(0.));
2522 assert!(!state.is_following_tail());
2523
2524 // Enable follow-tail — this should immediately snap the scroll anchor
2525 // to the end, like the user just sent a prompt.
2526 state.set_follow_mode(FollowMode::Tail);
2527
2528 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2529 view.into_any_element()
2530 });
2531
2532 // After paint, scroll should be at the bottom.
2533 // 500px total − 200px viewport = 300px offset → item 6, offset 0.
2534 let offset = state.logical_scroll_top();
2535 assert_eq!(offset.item_ix, 6);
2536 assert_eq!(offset.offset_in_item, px(0.));
2537 assert!(state.is_following_tail());
2538 }
2539
2540 #[gpui::test]
2541 fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) {
2542 let cx = cx.add_empty_window();
2543
2544 const ITEMS: usize = 10;
2545 const ITEM_SIZE: f32 = 50.0;
2546
2547 let state = ListState::new(
2548 ITEMS,
2549 crate::ListAlignment::Bottom,
2550 px(ITEMS as f32 * ITEM_SIZE),
2551 );
2552
2553 struct TestView(ListState);
2554 impl Render for TestView {
2555 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2556 list(self.0.clone(), |_, _, _| {
2557 div().h(px(ITEM_SIZE)).w_full().into_any()
2558 })
2559 .w_full()
2560 .h_full()
2561 }
2562 }
2563
2564 cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
2565 cx.new(|_| TestView(state.clone())).into_any_element()
2566 });
2567
2568 // Bottom-aligned lists start pinned to the end: logical_scroll_top returns
2569 // item_ix == item_count, meaning no explicit scroll position has been set.
2570 assert_eq!(state.logical_scroll_top().item_ix, ITEMS);
2571
2572 let max_offset = state.max_offset_for_scrollbar();
2573 let scroll_offset = state.scroll_px_offset_for_scrollbar();
2574
2575 assert_eq!(
2576 -scroll_offset.y, max_offset.y,
2577 "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom",
2578 -scroll_offset.y, max_offset.y,
2579 );
2580 }
2581
2582 /// When the user scrolls away from the bottom during follow_tail,
2583 /// follow_tail suspends. If they scroll back to the bottom, the
2584 /// next paint should re-engage follow_tail using fresh measurements.
2585 #[gpui::test]
2586 fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) {
2587 let cx = cx.add_empty_window();
2588
2589 // 10 items × 50px = 500px total, 200px viewport.
2590 let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2591
2592 struct TestView(ListState);
2593 impl Render for TestView {
2594 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2595 list(self.0.clone(), |_, _, _| {
2596 div().h(px(50.)).w_full().into_any()
2597 })
2598 .w_full()
2599 .h_full()
2600 }
2601 }
2602
2603 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2604
2605 state.set_follow_mode(FollowMode::Tail);
2606
2607 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2608 view.clone().into_any_element()
2609 });
2610 assert!(state.is_following_tail());
2611
2612 // Scroll up — follow_tail should suspend (not fully disengage).
2613 cx.simulate_event(ScrollWheelEvent {
2614 position: point(px(50.), px(100.)),
2615 delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
2616 ..Default::default()
2617 });
2618 assert!(!state.is_following_tail());
2619
2620 // Scroll back down to the bottom.
2621 cx.simulate_event(ScrollWheelEvent {
2622 position: point(px(50.), px(100.)),
2623 delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
2624 ..Default::default()
2625 });
2626
2627 // After a paint, follow_tail should re-engage because the
2628 // layout confirmed we're at the true bottom.
2629 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2630 view.clone().into_any_element()
2631 });
2632 assert!(
2633 state.is_following_tail(),
2634 "follow_tail should re-engage after scrolling back to the bottom"
2635 );
2636 }
2637
2638 /// When an item is spliced to unmeasured (0px) while follow_tail
2639 /// is suspended, the re-engagement check should still work correctly
2640 #[gpui::test]
2641 fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) {
2642 let cx = cx.add_empty_window();
2643
2644 // 20 items × 50px = 1000px total, 200px viewport, 1000px
2645 // overdraw so all items get measured during the follow_tail
2646 // paint (matching realistic production settings).
2647 let state = ListState::new(20, crate::ListAlignment::Top, px(1000.));
2648
2649 struct TestView(ListState);
2650 impl Render for TestView {
2651 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2652 list(self.0.clone(), |_, _, _| {
2653 div().h(px(50.)).w_full().into_any()
2654 })
2655 .w_full()
2656 .h_full()
2657 }
2658 }
2659
2660 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2661
2662 state.set_follow_mode(FollowMode::Tail);
2663
2664 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2665 view.clone().into_any_element()
2666 });
2667 assert!(state.is_following_tail());
2668
2669 // Scroll up a meaningful amount — suspends follow_tail.
2670 // 20 items × 50px = 1000px. viewport 200px. scroll_max = 800px.
2671 // Scrolling up 200px puts us at 600px, clearly not at bottom.
2672 cx.simulate_event(ScrollWheelEvent {
2673 position: point(px(50.), px(100.)),
2674 delta: ScrollDelta::Pixels(point(px(0.), px(200.))),
2675 ..Default::default()
2676 });
2677 assert!(!state.is_following_tail());
2678
2679 // Invalidate the last item (simulates EntryUpdated calling
2680 // remeasure_items). This makes items.summary().height
2681 // temporarily wrong (0px for the invalidated item).
2682 state.remeasure_items(19..20);
2683
2684 // Paint — layout re-measures the invalidated item with its true
2685 // height. The re-engagement check uses these fresh measurements.
2686 // Since we scrolled 200px up from the 800px max, we're at
2687 // ~600px — NOT at the bottom, so follow_tail should NOT
2688 // re-engage.
2689 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2690 view.clone().into_any_element()
2691 });
2692 assert!(
2693 !state.is_following_tail(),
2694 "follow_tail should not falsely re-engage due to an unmeasured item \
2695 reducing items.summary().height"
2696 );
2697 }
2698
2699 #[gpui::test]
2700 fn test_follow_tail_reengages_after_scrollbar_disengagement(cx: &mut TestAppContext) {
2701 let cx = cx.add_empty_window();
2702
2703 // 10 items × 50px = 500px total, 200px viewport, scroll_max = 300px.
2704 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2705
2706 struct TestView(ListState);
2707 impl Render for TestView {
2708 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2709 list(self.0.clone(), |_, _, _| {
2710 div().h(px(50.)).w_full().into_any()
2711 })
2712 .w_full()
2713 .h_full()
2714 }
2715 }
2716
2717 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2718
2719 state.set_follow_mode(FollowMode::Tail);
2720 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2721 view.clone().into_any_element()
2722 });
2723 assert!(state.is_following_tail());
2724
2725 // Drag the scrollbar up to the middle — follow_tail should suspend.
2726 state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2727 assert!(!state.is_following_tail());
2728
2729 // Drag the scrollbar back to the bottom — follow_tail should re-engage
2730 // on the next paint.
2731 state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
2732 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2733 view.into_any_element()
2734 });
2735 assert!(
2736 state.is_following_tail(),
2737 "follow_tail should re-engage after scrolling back to the bottom via the scrollbar"
2738 );
2739 }
2740
2741 #[gpui::test]
2742 fn test_follow_tail_reengages_after_scrollbar_drag_to_bottom_while_growing(
2743 cx: &mut TestAppContext,
2744 ) {
2745 let cx = cx.add_empty_window();
2746
2747 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2748
2749 struct TestView(ListState);
2750 impl Render for TestView {
2751 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2752 list(self.0.clone(), |_, _, _| {
2753 div().h(px(50.)).w_full().into_any()
2754 })
2755 .w_full()
2756 .h_full()
2757 }
2758 }
2759
2760 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2761
2762 state.set_follow_mode(FollowMode::Tail);
2763 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2764 view.clone().into_any_element()
2765 });
2766 assert!(state.is_following_tail());
2767
2768 state.scrollbar_drag_started();
2769
2770 state.splice(10..10, 10);
2771 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2772 view.clone().into_any_element()
2773 });
2774
2775 state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
2776 state.scrollbar_drag_ended();
2777
2778 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2779 view.into_any_element()
2780 });
2781
2782 assert!(
2783 state.is_following_tail(),
2784 "follow_tail should re-engage when the user drags the scrollbar to \
2785 the bottom of its track, even when content has grown during the drag \
2786 (so frozen_bottom < live_bottom)"
2787 );
2788 }
2789}
2790