1use 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
23pub 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
36pub struct List {
38 state: ListState,
39 render_item: Box<RenderItemFn>,
40 style: StyleRefinement,
41 sizing_behavior: ListSizingBehavior,
42}
43
44impl List {
45 pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
47 self.sizing_behavior = behavior;
48 self
49 }
50}
51
52#[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#[derive(Clone)]
85enum PendingScroll {
86 Absolute { item_ix: usize, offset: Pixels },
88 Proportional(PendingScrollFraction),
90}
91
92#[derive(Clone)]
95struct PendingScrollFraction {
96 item_ix: usize,
98 fraction: f32,
100}
101
102enum ScrollAnchor {
105 Absolute,
107 Proportional,
109}
110
111#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
113pub enum FollowMode {
114 #[default]
116 Normal,
117 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
164pub enum ListAlignment {
165 Top,
167 Bottom,
169}
170
171pub struct ListScrollEvent {
173 pub visible_range: Range<usize>,
175
176 pub count: usize,
178
179 pub is_scrolled: bool,
181
182 pub is_following_tail: bool,
184}
185
186#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
188pub enum ListSizingBehavior {
189 Infer,
191 #[default]
193 Auto,
194}
195
196#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
198pub enum ListMeasuringBehavior {
199 Measure(bool),
202 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
218pub enum ListHorizontalSizingBehavior {
219 #[default]
221 FitList,
222 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
238pub 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 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 pub fn measure_all(self) -> Self {
337 self.0.borrow_mut().measuring_behavior = ListMeasuringBehavior::Measure(false);
338 self
339 }
340
341 pub fn reset(&self, element_count: usize) {
345 let old_count = {
346 let state = &mut *self.0.borrow_mut();
347 state.reset = true;
348 state.measuring_behavior.reset();
349 state.logical_scroll_top = None;
350 state.scrollbar_drag_start_height = None;
351 state.items.summary().count
352 };
353
354 self.splice(0..old_count, element_count);
355 }
356
357 pub fn remeasure(&self) {
362 let count = self.item_count();
363 self.remeasure_items_with_scroll_anchor(0..count, ScrollAnchor::Proportional);
364 }
365
366 pub fn remeasure_items(&self, range: Range<usize>) {
374 self.remeasure_items_with_scroll_anchor(range, ScrollAnchor::Absolute);
375 }
376
377 fn remeasure_items_with_scroll_anchor(&self, range: Range<usize>, scroll_anchor: ScrollAnchor) {
378 let state = &mut *self.0.borrow_mut();
379
380 if let Some(scroll_top) = state.logical_scroll_top {
381 if range.contains(&scroll_top.item_ix) {
382 state.pending_scroll = match scroll_anchor {
383 ScrollAnchor::Absolute => Some(PendingScroll::Absolute {
384 item_ix: scroll_top.item_ix,
385 offset: scroll_top.offset_in_item,
386 }),
387 ScrollAnchor::Proportional => {
388 let mut cursor = state.items.cursor::<Count>(());
393 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
394
395 cursor
396 .item()
397 .and_then(|item| {
398 item.size().map(|size| {
399 let fraction = if size.height.0 > 0.0 {
400 (scroll_top.offset_in_item.0 / size.height.0)
401 .clamp(0.0, 1.0)
402 } else {
403 0.0
404 };
405
406 PendingScroll::Proportional(PendingScrollFraction {
407 item_ix: scroll_top.item_ix,
408 fraction,
409 })
410 })
411 })
412 .or_else(|| state.pending_scroll.clone())
413 }
414 };
415 }
416 }
417
418 let new_items = {
421 let mut cursor = state.items.cursor::<Count>(());
422 let mut new_items = cursor.slice(&Count(range.start), Bias::Right);
423 let invalidated = cursor.slice(&Count(range.end), Bias::Right);
424 new_items.extend(
425 invalidated.iter().map(|item| ListItem::Unmeasured {
426 size_hint: item.size_hint(),
427 focus_handle: item.focus_handle(),
428 }),
429 (),
430 );
431 new_items.append(cursor.suffix(), ());
432 new_items
433 };
434 state.items = new_items;
435 state.measuring_behavior.reset();
436 }
437
438 pub fn item_count(&self) -> usize {
440 self.0.borrow().items.summary().count
441 }
442
443 pub fn is_scrolled_to_end(&self) -> Option<bool> {
446 let state = self.0.borrow();
447 let bounds = state.last_layout_bounds?;
448 let summary = state.items.summary();
449 if summary.has_unknown_height {
450 return None;
451 }
452 let padding = state.last_padding.unwrap_or_default();
453 let content_height = summary.height + padding.top + padding.bottom;
454 let scroll_max = (content_height - bounds.size.height).max(px(0.));
455 if scroll_max <= px(0.) {
456 return None;
457 }
458 let scroll_top = state.scroll_top(&state.logical_scroll_top());
459 Some(scroll_top >= scroll_max)
460 }
461
462 pub fn splice(&self, old_range: Range<usize>, count: usize) {
465 self.splice_focusable(old_range, (0..count).map(|_| None))
466 }
467
468 pub fn splice_focusable(
473 &self,
474 old_range: Range<usize>,
475 focus_handles: impl IntoIterator<Item = Option<FocusHandle>>,
476 ) {
477 let state = &mut *self.0.borrow_mut();
478
479 let mut old_items = state.items.cursor::<Count>(());
480 let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right);
481 old_items.seek_forward(&Count(old_range.end), Bias::Right);
482
483 let mut spliced_count = 0;
484 new_items.extend(
485 focus_handles.into_iter().map(|focus_handle| {
486 spliced_count += 1;
487 ListItem::Unmeasured {
488 size_hint: None,
489 focus_handle,
490 }
491 }),
492 (),
493 );
494 new_items.append(old_items.suffix(), ());
495 drop(old_items);
496 state.items = new_items;
497
498 if let Some(ListOffset {
499 item_ix,
500 offset_in_item,
501 }) = state.logical_scroll_top.as_mut()
502 {
503 if old_range.contains(item_ix) {
504 *item_ix = old_range.start;
505 *offset_in_item = px(0.);
506 } else if old_range.end <= *item_ix {
507 *item_ix = *item_ix - (old_range.end - old_range.start) + spliced_count;
508 }
509 }
510 }
511
512 pub fn set_scroll_handler(
514 &self,
515 handler: impl FnMut(&ListScrollEvent, &mut Window, &mut App) + 'static,
516 ) {
517 self.0.borrow_mut().scroll_handler = Some(Box::new(handler))
518 }
519
520 pub fn logical_scroll_top(&self) -> ListOffset {
522 self.0.borrow().logical_scroll_top()
523 }
524
525 pub fn scroll_by(&self, distance: Pixels) {
527 if distance == px(0.) {
528 return;
529 }
530
531 let current_offset = self.logical_scroll_top();
532 let state = &mut *self.0.borrow_mut();
533
534 if distance < px(0.) {
535 state.follow_state.stop_following();
536 }
537
538 let mut cursor = state.items.cursor::<ListItemSummary>(());
539 cursor.seek(&Count(current_offset.item_ix), Bias::Right);
540
541 let start_pixel_offset = cursor.start().height + current_offset.offset_in_item;
542 let new_pixel_offset = (start_pixel_offset + distance).max(px(0.));
543 if new_pixel_offset > start_pixel_offset {
544 cursor.seek_forward(&Height(new_pixel_offset), Bias::Right);
545 } else {
546 cursor.seek(&Height(new_pixel_offset), Bias::Right);
547 }
548
549 state.logical_scroll_top = Some(ListOffset {
550 item_ix: cursor.start().count,
551 offset_in_item: new_pixel_offset - cursor.start().height,
552 });
553 }
554
555 pub fn scroll_to_end(&self) {
562 let state = &mut *self.0.borrow_mut();
563 let item_count = state.items.summary().count;
564 state.logical_scroll_top = Some(ListOffset {
565 item_ix: item_count,
566 offset_in_item: px(0.),
567 });
568 }
569
570 pub fn set_follow_mode(&self, mode: FollowMode) {
575 let state = &mut *self.0.borrow_mut();
576
577 match mode {
578 FollowMode::Normal => {
579 state.follow_state = FollowState::Normal;
580 }
581 FollowMode::Tail => {
582 state.follow_state = FollowState::Tail { is_following: true };
583 if matches!(mode, FollowMode::Tail) {
584 let item_count = state.items.summary().count;
585 state.logical_scroll_top = Some(ListOffset {
586 item_ix: item_count,
587 offset_in_item: px(0.),
588 });
589 }
590 }
591 }
592 }
593
594 pub fn is_following_tail(&self) -> bool {
597 matches!(
598 self.0.borrow().follow_state,
599 FollowState::Tail { is_following: true }
600 )
601 }
602
603 pub fn scroll_to(&self, mut scroll_top: ListOffset) {
605 let state = &mut *self.0.borrow_mut();
606 let item_count = state.items.summary().count;
607 if scroll_top.item_ix >= item_count {
608 scroll_top.item_ix = item_count;
609 scroll_top.offset_in_item = px(0.);
610 }
611
612 if scroll_top.item_ix < item_count {
613 state.follow_state.stop_following();
614 }
615
616 state.logical_scroll_top = Some(scroll_top);
617 }
618
619 pub fn scroll_to_reveal_item(&self, ix: usize) {
621 let state = &mut *self.0.borrow_mut();
622
623 let mut scroll_top = state.logical_scroll_top();
624 let height = state
625 .last_layout_bounds
626 .map_or(px(0.), |bounds| bounds.size.height);
627 let padding = state.last_padding.unwrap_or_default();
628
629 if ix <= scroll_top.item_ix {
630 scroll_top.item_ix = ix;
631 scroll_top.offset_in_item = px(0.);
632 } else {
633 let mut cursor = state.items.cursor::<ListItemSummary>(());
634 cursor.seek(&Count(ix + 1), Bias::Right);
635 let bottom = cursor.start().height + padding.top;
636 let goal_top = px(0.).max(bottom - height + padding.bottom);
637
638 cursor.seek(&Height(goal_top), Bias::Left);
639 let start_ix = cursor.start().count;
640 let start_item_top = cursor.start().height;
641
642 if start_ix >= scroll_top.item_ix {
643 scroll_top.item_ix = start_ix;
644 scroll_top.offset_in_item = goal_top - start_item_top;
645 }
646 }
647
648 state.logical_scroll_top = Some(scroll_top);
649 }
650
651 pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
654 let state = &*self.0.borrow();
655
656 let bounds = state.last_layout_bounds.unwrap_or_default();
657 let scroll_top = state.logical_scroll_top();
658 if ix < scroll_top.item_ix {
659 return None;
660 }
661
662 let mut cursor = state.items.cursor::<Dimensions<Count, Height>>(());
663 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
664
665 let scroll_top = cursor.start().1.0 + scroll_top.offset_in_item;
666
667 cursor.seek_forward(&Count(ix), Bias::Right);
668 if let Some(&ListItem::Measured { size, .. }) = cursor.item() {
669 let &Dimensions(Count(count), Height(top), _) = cursor.start();
670 if count == ix {
671 let top = bounds.top() + top - scroll_top;
672 return Some(Bounds::from_corners(
673 point(bounds.left(), top),
674 point(bounds.right(), top + size.height),
675 ));
676 }
677 }
678 None
679 }
680
681 pub fn scrollbar_drag_started(&self) {
686 let mut state = self.0.borrow_mut();
687 state.scrollbar_drag_start_height = Some(state.items.summary().height);
688 }
689
690 pub fn scrollbar_drag_ended(&self) {
694 self.0.borrow_mut().scrollbar_drag_start_height.take();
695 }
696
697 pub fn is_scrollbar_dragging(&self) -> bool {
704 self.0.borrow().scrollbar_drag_start_height.is_some()
705 }
706
707 pub fn set_offset_from_scrollbar(&self, point: Point<Pixels>) {
709 self.0.borrow_mut().set_offset_from_scrollbar(point);
710 }
711
712 pub fn max_offset_for_scrollbar(&self) -> Point<Pixels> {
715 let state = self.0.borrow();
716 point(Pixels::ZERO, state.max_scroll_offset())
717 }
718
719 pub fn scroll_px_offset_for_scrollbar(&self) -> Point<Pixels> {
724 let state = &self.0.borrow();
725
726 if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom {
727 return Point::new(px(0.), -state.max_scroll_offset());
728 }
729
730 let logical_scroll_top = state.logical_scroll_top();
731
732 let mut cursor = state.items.cursor::<ListItemSummary>(());
733 let summary: ListItemSummary =
734 cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right);
735 let offset = summary.height + logical_scroll_top.offset_in_item;
736
737 Point::new(px(0.), -offset)
738 }
739
740 pub fn viewport_bounds(&self) -> Bounds<Pixels> {
742 self.0.borrow().last_layout_bounds.unwrap_or_default()
743 }
744}
745
746impl StateInner {
747 fn max_scroll_offset(&self) -> Pixels {
748 let bounds = self.last_layout_bounds.unwrap_or_default();
749 let height = self
750 .scrollbar_drag_start_height
751 .unwrap_or_else(|| self.items.summary().height);
752 (height - bounds.size.height).max(px(0.))
753 }
754
755 fn visible_range(
756 items: &SumTree<ListItem>,
757 height: Pixels,
758 scroll_top: &ListOffset,
759 ) -> Range<usize> {
760 let mut cursor = items.cursor::<ListItemSummary>(());
761 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
762 let start_y = cursor.start().height + scroll_top.offset_in_item;
763 cursor.seek_forward(&Height(start_y + height), Bias::Left);
764 scroll_top.item_ix..cursor.start().count + 1
765 }
766
767 fn scroll(
768 &mut self,
769 scroll_top: &ListOffset,
770 height: Pixels,
771 delta: Point<Pixels>,
772 current_view: EntityId,
773 window: &mut Window,
774 cx: &mut App,
775 ) {
776 if self.reset {
779 return;
780 }
781
782 let padding = self.last_padding.unwrap_or_default();
783 let scroll_max =
784 (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
785 let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
786 .max(px(0.))
787 .min(scroll_max);
788
789 if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
790 self.logical_scroll_top = None;
791 } else {
792 let (start, ..) =
793 self.items
794 .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
795 let item_ix = start.count;
796 let offset_in_item = new_scroll_top - start.height;
797 self.logical_scroll_top = Some(ListOffset {
798 item_ix,
799 offset_in_item,
800 });
801 }
802
803 if delta.y > px(0.) {
804 self.follow_state.stop_following();
805 }
806
807 if let Some(handler) = self.scroll_handler.as_mut() {
808 let visible_range = Self::visible_range(&self.items, height, scroll_top);
809 handler(
810 &ListScrollEvent {
811 visible_range,
812 count: self.items.summary().count,
813 is_scrolled: self.logical_scroll_top.is_some(),
814 is_following_tail: matches!(
815 self.follow_state,
816 FollowState::Tail { is_following: true }
817 ),
818 },
819 window,
820 cx,
821 );
822 }
823
824 cx.notify(current_view);
825 }
826
827 fn logical_scroll_top(&self) -> ListOffset {
828 self.logical_scroll_top
829 .unwrap_or_else(|| match self.alignment {
830 ListAlignment::Top => ListOffset {
831 item_ix: 0,
832 offset_in_item: px(0.),
833 },
834 ListAlignment::Bottom => ListOffset {
835 item_ix: self.items.summary().count,
836 offset_in_item: px(0.),
837 },
838 })
839 }
840
841 fn scroll_top(&self, logical_scroll_top: &ListOffset) -> Pixels {
842 let (start, ..) = self.items.find::<ListItemSummary, _>(
843 (),
844 &Count(logical_scroll_top.item_ix),
845 Bias::Right,
846 );
847 start.height + logical_scroll_top.offset_in_item
848 }
849
850 fn layout_all_items(
851 &mut self,
852 available_width: Pixels,
853 render_item: &mut RenderItemFn,
854 window: &mut Window,
855 cx: &mut App,
856 ) {
857 match &mut self.measuring_behavior {
858 ListMeasuringBehavior::Visible => {
859 return;
860 }
861 ListMeasuringBehavior::Measure(has_measured) => {
862 if *has_measured {
863 return;
864 }
865 *has_measured = true;
866 }
867 }
868
869 let mut cursor = self.items.cursor::<Count>(());
870 let available_item_space = size(
871 AvailableSpace::Definite(available_width),
872 AvailableSpace::MinContent,
873 );
874
875 let mut measured_items = Vec::default();
876
877 for (ix, item) in cursor.enumerate() {
878 let size = item.size().unwrap_or_else(|| {
879 let mut element = render_item(ix, window, cx);
880 element.layout_as_root(available_item_space, window, cx)
881 });
882
883 measured_items.push(ListItem::Measured {
884 size,
885 focus_handle: item.focus_handle(),
886 });
887 }
888
889 self.items = SumTree::from_iter(measured_items, ());
890 }
891
892 fn layout_items(
893 &mut self,
894 available_width: Option<Pixels>,
895 available_height: Pixels,
896 padding: &Edges<Pixels>,
897 render_item: &mut RenderItemFn,
898 window: &mut Window,
899 cx: &mut App,
900 ) -> LayoutItemsResponse {
901 let old_items = self.items.clone();
902 let mut measured_items = VecDeque::new();
903 let mut item_layouts = VecDeque::new();
904 let mut rendered_height = padding.top;
905 let mut max_item_width = px(0.);
906 let mut scroll_top = self.logical_scroll_top();
907
908 if self.follow_state.is_following() {
909 scroll_top = ListOffset {
910 item_ix: self.items.summary().count,
911 offset_in_item: px(0.),
912 };
913 self.logical_scroll_top = Some(scroll_top);
914 }
915
916 let mut rendered_focused_item = false;
917
918 let available_item_space = size(
919 available_width.map_or(AvailableSpace::MinContent, |width| {
920 AvailableSpace::Definite(width)
921 }),
922 AvailableSpace::MinContent,
923 );
924
925 let mut cursor = old_items.cursor::<Count>(());
926
927 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
929 for (ix, item) in cursor.by_ref().enumerate() {
930 let visible_height = rendered_height - scroll_top.offset_in_item;
931 if visible_height >= available_height + self.overdraw {
932 break;
933 }
934
935 let mut size = item.size();
937
938 if visible_height < available_height || size.is_none() {
940 let item_index = scroll_top.item_ix + ix;
941 let mut element = render_item(item_index, window, cx);
942 let element_size = element.layout_as_root(available_item_space, window, cx);
943 size = Some(element_size);
944
945 if ix == 0 {
948 if let Some(pending_scroll) = self.pending_scroll.take() {
949 match pending_scroll {
950 PendingScroll::Absolute { item_ix, offset }
951 if item_ix == scroll_top.item_ix =>
952 {
953 scroll_top.offset_in_item = offset.min(element_size.height);
954 self.logical_scroll_top = Some(scroll_top);
955 }
956 PendingScroll::Proportional(pending_scroll)
957 if pending_scroll.item_ix == scroll_top.item_ix =>
958 {
959 scroll_top.offset_in_item =
962 Pixels(pending_scroll.fraction * element_size.height.0);
963 self.logical_scroll_top = Some(scroll_top);
964 }
965 _ => {}
966 }
967 }
968 }
969
970 if visible_height < available_height {
971 item_layouts.push_back(ItemLayout {
972 index: item_index,
973 element,
974 size: element_size,
975 });
976 if item.contains_focused(window, cx) {
977 rendered_focused_item = true;
978 }
979 }
980 }
981
982 let size = size.unwrap();
983 rendered_height += size.height;
984 max_item_width = max_item_width.max(size.width);
985 measured_items.push_back(ListItem::Measured {
986 size,
987 focus_handle: item.focus_handle(),
988 });
989 }
990 rendered_height += padding.bottom;
991
992 cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
994
995 if rendered_height - scroll_top.offset_in_item < available_height {
998 while rendered_height < available_height {
999 cursor.prev();
1000 if let Some(item) = cursor.item() {
1001 let item_index = cursor.start().0;
1002 let mut element = render_item(item_index, window, cx);
1003 let element_size = element.layout_as_root(available_item_space, window, cx);
1004 let focus_handle = item.focus_handle();
1005 rendered_height += element_size.height;
1006 measured_items.push_front(ListItem::Measured {
1007 size: element_size,
1008 focus_handle,
1009 });
1010 item_layouts.push_front(ItemLayout {
1011 index: item_index,
1012 element,
1013 size: element_size,
1014 });
1015 if item.contains_focused(window, cx) {
1016 rendered_focused_item = true;
1017 }
1018 } else {
1019 break;
1020 }
1021 }
1022
1023 scroll_top = ListOffset {
1024 item_ix: cursor.start().0,
1025 offset_in_item: rendered_height - available_height,
1026 };
1027
1028 match self.alignment {
1029 ListAlignment::Top => {
1030 scroll_top.offset_in_item = scroll_top.offset_in_item.max(px(0.));
1031 self.logical_scroll_top = Some(scroll_top);
1032 }
1033 ListAlignment::Bottom => {
1034 scroll_top = ListOffset {
1035 item_ix: cursor.start().0,
1036 offset_in_item: rendered_height - available_height,
1037 };
1038 self.logical_scroll_top = None;
1039 }
1040 };
1041 }
1042
1043 let mut leading_overdraw = scroll_top.offset_in_item;
1045 while leading_overdraw < self.overdraw {
1046 cursor.prev();
1047 if let Some(item) = cursor.item() {
1048 let size = if let ListItem::Measured { size, .. } = item {
1049 *size
1050 } else {
1051 let mut element = render_item(cursor.start().0, window, cx);
1052 element.layout_as_root(available_item_space, window, cx)
1053 };
1054
1055 leading_overdraw += size.height;
1056 measured_items.push_front(ListItem::Measured {
1057 size,
1058 focus_handle: item.focus_handle(),
1059 });
1060 } else {
1061 break;
1062 }
1063 }
1064
1065 let measured_range = cursor.start().0..(cursor.start().0 + measured_items.len());
1066 let mut cursor = old_items.cursor::<Count>(());
1067 let mut new_items = cursor.slice(&Count(measured_range.start), Bias::Right);
1068 new_items.extend(measured_items, ());
1069 cursor.seek(&Count(measured_range.end), Bias::Right);
1070 new_items.append(cursor.suffix(), ());
1071 self.items = new_items;
1072
1073 if self.follow_state.has_stopped_following() {
1077 let padding = self.last_padding.unwrap_or_default();
1078 let total_height = self.items.summary().height + padding.top + padding.bottom;
1079 let scroll_offset = self.scroll_top(&scroll_top);
1080 if scroll_offset + available_height >= total_height - px(1.0) {
1081 self.follow_state.start_following();
1082 }
1083 }
1084
1085 if !rendered_focused_item {
1089 let mut cursor = self
1090 .items
1091 .filter::<_, Count>((), |summary| summary.has_focus_handles);
1092 cursor.next();
1093 while let Some(item) = cursor.item() {
1094 if item.contains_focused(window, cx) {
1095 let item_index = cursor.start().0;
1096 let mut element = render_item(cursor.start().0, window, cx);
1097 let size = element.layout_as_root(available_item_space, window, cx);
1098 item_layouts.push_back(ItemLayout {
1099 index: item_index,
1100 element,
1101 size,
1102 });
1103 break;
1104 }
1105 cursor.next();
1106 }
1107 }
1108
1109 LayoutItemsResponse {
1110 max_item_width,
1111 scroll_top,
1112 item_layouts,
1113 }
1114 }
1115
1116 fn prepaint_items(
1117 &mut self,
1118 bounds: Bounds<Pixels>,
1119 padding: Edges<Pixels>,
1120 autoscroll: bool,
1121 render_item: &mut RenderItemFn,
1122 window: &mut Window,
1123 cx: &mut App,
1124 ) -> Result<LayoutItemsResponse, ListOffset> {
1125 window.transact(|window| {
1126 match self.measuring_behavior {
1127 ListMeasuringBehavior::Measure(has_measured) if !has_measured => {
1128 self.layout_all_items(bounds.size.width, render_item, window, cx);
1129 }
1130 _ => {}
1131 }
1132
1133 let mut layout_response = self.layout_items(
1134 Some(bounds.size.width),
1135 bounds.size.height,
1136 &padding,
1137 render_item,
1138 window,
1139 cx,
1140 );
1141
1142 window.take_autoscroll();
1144
1145 if bounds.size.height > padding.top + padding.bottom {
1147 let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
1148 item_origin.y -= layout_response.scroll_top.offset_in_item;
1149 for item in &mut layout_response.item_layouts {
1150 window.with_content_mask(Some(ContentMask { bounds }), |window| {
1151 item.element.prepaint_at(item_origin, window, cx);
1152 });
1153
1154 if let Some(autoscroll_bounds) = window.take_autoscroll()
1155 && autoscroll
1156 {
1157 if autoscroll_bounds.top() < bounds.top() {
1158 return Err(ListOffset {
1159 item_ix: item.index,
1160 offset_in_item: autoscroll_bounds.top() - item_origin.y,
1161 });
1162 } else if autoscroll_bounds.bottom() > bounds.bottom() {
1163 let mut cursor = self.items.cursor::<Count>(());
1164 cursor.seek(&Count(item.index), Bias::Right);
1165 let mut height = bounds.size.height - padding.top - padding.bottom;
1166
1167 height -= autoscroll_bounds.bottom() - item_origin.y;
1169
1170 while height > Pixels::ZERO {
1172 cursor.prev();
1173 let Some(item) = cursor.item() else { break };
1174
1175 let size = item.size().unwrap_or_else(|| {
1176 let mut item = render_item(cursor.start().0, window, cx);
1177 let item_available_size =
1178 size(bounds.size.width.into(), AvailableSpace::MinContent);
1179 item.layout_as_root(item_available_size, window, cx)
1180 });
1181 height -= size.height;
1182 }
1183
1184 return Err(ListOffset {
1185 item_ix: cursor.start().0,
1186 offset_in_item: if height < Pixels::ZERO {
1187 -height
1188 } else {
1189 Pixels::ZERO
1190 },
1191 });
1192 }
1193 }
1194
1195 item_origin.y += item.size.height;
1196 }
1197 } else {
1198 layout_response.item_layouts.clear();
1199 }
1200
1201 Ok(layout_response)
1202 })
1203 }
1204
1205 fn set_offset_from_scrollbar(&mut self, point: Point<Pixels>) {
1208 let Some(bounds) = self.last_layout_bounds else {
1209 return;
1210 };
1211 let height = bounds.size.height;
1212
1213 let padding = self.last_padding.unwrap_or_default();
1214 let content_height = self
1217 .scrollbar_drag_start_height
1218 .unwrap_or_else(|| self.items.summary().height);
1219 let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.));
1220 let new_scroll_top = (-point.y).max(px(0.)).min(scroll_max);
1221
1222 let dragged_to_end =
1225 scroll_max > px(0.) && new_scroll_top >= (scroll_max - px(1.0)).max(px(0.));
1226 if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) {
1227 self.follow_state = FollowState::Tail { is_following: true };
1228 let item_count = self.items.summary().count;
1229 self.logical_scroll_top = Some(ListOffset {
1230 item_ix: item_count,
1231 offset_in_item: px(0.),
1232 });
1233 return;
1234 }
1235
1236 self.follow_state.stop_following();
1237
1238 if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
1239 self.logical_scroll_top = None;
1240 } else {
1241 let (start, _, _) =
1242 self.items
1243 .find::<ListItemSummary, _>((), &Height(new_scroll_top), Bias::Right);
1244
1245 let item_ix = start.count;
1246 let offset_in_item = new_scroll_top - start.height;
1247 self.logical_scroll_top = Some(ListOffset {
1248 item_ix,
1249 offset_in_item,
1250 });
1251 }
1252 }
1253}
1254
1255impl std::fmt::Debug for ListItem {
1256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1257 match self {
1258 Self::Unmeasured { .. } => write!(f, "Unrendered"),
1259 Self::Measured { size, .. } => f.debug_struct("Rendered").field("size", size).finish(),
1260 }
1261 }
1262}
1263
1264#[derive(Debug, Clone, Copy, Default)]
1267pub struct ListOffset {
1268 pub item_ix: usize,
1270 pub offset_in_item: Pixels,
1272}
1273
1274impl Element for List {
1275 type RequestLayoutState = ();
1276 type PrepaintState = ListPrepaintState;
1277
1278 fn id(&self) -> Option<crate::ElementId> {
1279 None
1280 }
1281
1282 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1283 None
1284 }
1285
1286 fn request_layout(
1287 &mut self,
1288 _id: Option<&GlobalElementId>,
1289 _inspector_id: Option<&InspectorElementId>,
1290 window: &mut Window,
1291 cx: &mut App,
1292 ) -> (crate::LayoutId, Self::RequestLayoutState) {
1293 let layout_id = match self.sizing_behavior {
1294 ListSizingBehavior::Infer => {
1295 let mut style = Style::default();
1296 style.overflow.y = Overflow::Scroll;
1297 style.refine(&self.style);
1298 window.with_text_style(style.text_style().cloned(), |window| {
1299 let state = &mut *self.state.0.borrow_mut();
1300
1301 let available_height = if let Some(last_bounds) = state.last_layout_bounds {
1302 last_bounds.size.height
1303 } else {
1304 state.overdraw
1307 };
1308 let padding = style.padding.to_pixels(
1309 state.last_layout_bounds.unwrap_or_default().size.into(),
1310 window.rem_size(),
1311 );
1312
1313 let layout_response = state.layout_items(
1314 None,
1315 available_height,
1316 &padding,
1317 &mut self.render_item,
1318 window,
1319 cx,
1320 );
1321 let max_element_width = layout_response.max_item_width;
1322
1323 let summary = state.items.summary();
1324 let total_height = summary.height;
1325
1326 window.request_measured_layout(
1327 style,
1328 move |known_dimensions, available_space, _window, _cx| {
1329 let width =
1330 known_dimensions
1331 .width
1332 .unwrap_or(match available_space.width {
1333 AvailableSpace::Definite(x) => x,
1334 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1335 max_element_width
1336 }
1337 });
1338 let height = match available_space.height {
1339 AvailableSpace::Definite(height) => total_height.min(height),
1340 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
1341 total_height
1342 }
1343 };
1344 size(width, height)
1345 },
1346 )
1347 })
1348 }
1349 ListSizingBehavior::Auto => {
1350 let mut style = Style::default();
1351 style.refine(&self.style);
1352 window.with_text_style(style.text_style().cloned(), |window| {
1353 window.request_layout(style, None, cx)
1354 })
1355 }
1356 };
1357 (layout_id, ())
1358 }
1359
1360 fn prepaint(
1361 &mut self,
1362 _id: Option<&GlobalElementId>,
1363 _inspector_id: Option<&InspectorElementId>,
1364 bounds: Bounds<Pixels>,
1365 _: &mut Self::RequestLayoutState,
1366 window: &mut Window,
1367 cx: &mut App,
1368 ) -> ListPrepaintState {
1369 let state = &mut *self.state.0.borrow_mut();
1370 state.reset = false;
1371
1372 let mut style = Style::default();
1373 style.refine(&self.style);
1374
1375 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
1376
1377 if state
1379 .last_layout_bounds
1380 .is_none_or(|last_bounds| last_bounds.size.width != bounds.size.width)
1381 {
1382 let new_items = SumTree::from_iter(
1383 state.items.iter().map(|item| ListItem::Unmeasured {
1384 size_hint: None,
1385 focus_handle: item.focus_handle(),
1386 }),
1387 (),
1388 );
1389
1390 state.items = new_items;
1391 state.measuring_behavior.reset();
1392 }
1393
1394 let padding = style
1395 .padding
1396 .to_pixels(bounds.size.into(), window.rem_size());
1397 let layout =
1398 match state.prepaint_items(bounds, padding, true, &mut self.render_item, window, cx) {
1399 Ok(layout) => layout,
1400 Err(autoscroll_request) => {
1401 state.logical_scroll_top = Some(autoscroll_request);
1402 state
1403 .prepaint_items(bounds, padding, false, &mut self.render_item, window, cx)
1404 .unwrap()
1405 }
1406 };
1407
1408 state.last_layout_bounds = Some(bounds);
1409 state.last_padding = Some(padding);
1410 ListPrepaintState { hitbox, layout }
1411 }
1412
1413 fn paint(
1414 &mut self,
1415 _id: Option<&GlobalElementId>,
1416 _inspector_id: Option<&InspectorElementId>,
1417 bounds: Bounds<crate::Pixels>,
1418 _: &mut Self::RequestLayoutState,
1419 prepaint: &mut Self::PrepaintState,
1420 window: &mut Window,
1421 cx: &mut App,
1422 ) {
1423 let current_view = window.current_view();
1424 window.with_content_mask(Some(ContentMask { bounds }), |window| {
1425 for item in &mut prepaint.layout.item_layouts {
1426 item.element.paint(window, cx);
1427 }
1428 });
1429
1430 let list_state = self.state.clone();
1431 let height = bounds.size.height;
1432 let scroll_top = prepaint.layout.scroll_top;
1433 let hitbox_id = prepaint.hitbox.id;
1434 let mut accumulated_scroll_delta = ScrollDelta::default();
1435 window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
1436 if phase == DispatchPhase::Bubble && hitbox_id.should_handle_scroll(window) {
1437 accumulated_scroll_delta = accumulated_scroll_delta.coalesce(event.delta);
1438 let pixel_delta = accumulated_scroll_delta.pixel_delta(px(20.));
1439 list_state.0.borrow_mut().scroll(
1440 &scroll_top,
1441 height,
1442 pixel_delta,
1443 current_view,
1444 window,
1445 cx,
1446 )
1447 }
1448 });
1449 }
1450}
1451
1452impl IntoElement for List {
1453 type Element = Self;
1454
1455 fn into_element(self) -> Self::Element {
1456 self
1457 }
1458}
1459
1460impl Styled for List {
1461 fn style(&mut self) -> &mut StyleRefinement {
1462 &mut self.style
1463 }
1464}
1465
1466impl sum_tree::Item for ListItem {
1467 type Summary = ListItemSummary;
1468
1469 fn summary(&self, _: ()) -> Self::Summary {
1470 match self {
1471 ListItem::Unmeasured {
1472 size_hint,
1473 focus_handle,
1474 } => ListItemSummary {
1475 count: 1,
1476 rendered_count: 0,
1477 unrendered_count: 1,
1478 height: if let Some(size) = size_hint {
1479 size.height
1480 } else {
1481 px(0.)
1482 },
1483 has_focus_handles: focus_handle.is_some(),
1484 has_unknown_height: size_hint.is_none(),
1485 },
1486 ListItem::Measured {
1487 size, focus_handle, ..
1488 } => ListItemSummary {
1489 count: 1,
1490 rendered_count: 1,
1491 unrendered_count: 0,
1492 height: size.height,
1493 has_focus_handles: focus_handle.is_some(),
1494 has_unknown_height: false,
1495 },
1496 }
1497 }
1498}
1499
1500impl sum_tree::ContextLessSummary for ListItemSummary {
1501 fn zero() -> Self {
1502 Default::default()
1503 }
1504
1505 fn add_summary(&mut self, summary: &Self) {
1506 self.count += summary.count;
1507 self.rendered_count += summary.rendered_count;
1508 self.unrendered_count += summary.unrendered_count;
1509 self.height += summary.height;
1510 self.has_focus_handles |= summary.has_focus_handles;
1511 self.has_unknown_height |= summary.has_unknown_height;
1512 }
1513}
1514
1515impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Count {
1516 fn zero(_cx: ()) -> Self {
1517 Default::default()
1518 }
1519
1520 fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1521 self.0 += summary.count;
1522 }
1523}
1524
1525impl<'a> sum_tree::Dimension<'a, ListItemSummary> for Height {
1526 fn zero(_cx: ()) -> Self {
1527 Default::default()
1528 }
1529
1530 fn add_summary(&mut self, summary: &'a ListItemSummary, _: ()) {
1531 self.0 += summary.height;
1532 }
1533}
1534
1535impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Count {
1536 fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1537 self.0.partial_cmp(&other.count).unwrap()
1538 }
1539}
1540
1541impl sum_tree::SeekTarget<'_, ListItemSummary, ListItemSummary> for Height {
1542 fn cmp(&self, other: &ListItemSummary, _: ()) -> std::cmp::Ordering {
1543 self.0.partial_cmp(&other.height).unwrap()
1544 }
1545}
1546
1547#[cfg(test)]
1548mod test {
1549
1550 use gpui::{ScrollDelta, ScrollWheelEvent};
1551 use std::cell::Cell;
1552 use std::rc::Rc;
1553
1554 use crate::{
1555 self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render,
1556 Styled, TestAppContext, Window, div, list, point, px, size,
1557 };
1558
1559 #[gpui::test]
1560 fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) {
1561 let cx = cx.add_empty_window();
1562
1563 let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1564
1565 state.scroll_to(gpui::ListOffset {
1567 item_ix: 0,
1568 offset_in_item: px(0.0),
1569 });
1570
1571 struct TestView(ListState);
1572 impl Render for TestView {
1573 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1574 list(self.0.clone(), |_, _, _| {
1575 div().h(px(10.)).w_full().into_any()
1576 })
1577 .w_full()
1578 .h_full()
1579 }
1580 }
1581
1582 cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
1584 cx.new(|_| TestView(state.clone())).into_any_element()
1585 });
1586
1587 state.reset(5);
1589
1590 cx.simulate_event(ScrollWheelEvent {
1592 position: point(px(1.), px(1.)),
1593 delta: ScrollDelta::Pixels(point(px(0.), px(-500.))),
1594 ..Default::default()
1595 });
1596
1597 assert_eq!(state.logical_scroll_top().item_ix, 0);
1599 assert_eq!(state.logical_scroll_top().offset_in_item, px(0.));
1600 }
1601
1602 #[gpui::test]
1603 fn test_scroll_by_positive_and_negative_distance(cx: &mut TestAppContext) {
1604 let cx = cx.add_empty_window();
1605
1606 let state = ListState::new(5, crate::ListAlignment::Top, px(10.));
1607
1608 struct TestView(ListState);
1609 impl Render for TestView {
1610 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1611 list(self.0.clone(), |_, _, _| {
1612 div().h(px(20.)).w_full().into_any()
1613 })
1614 .w_full()
1615 .h_full()
1616 }
1617 }
1618
1619 cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
1621 cx.new(|_| TestView(state.clone())).into_any_element()
1622 });
1623
1624 state.scroll_by(px(30.));
1626
1627 let offset = state.logical_scroll_top();
1629 assert_eq!(offset.item_ix, 1);
1630 assert_eq!(offset.offset_in_item, px(10.));
1631
1632 state.scroll_by(px(-30.));
1634
1635 let offset = state.logical_scroll_top();
1637 assert_eq!(offset.item_ix, 0);
1638 assert_eq!(offset.offset_in_item, px(0.));
1639
1640 state.scroll_by(px(0.));
1642 let offset = state.logical_scroll_top();
1643 assert_eq!(offset.item_ix, 0);
1644 assert_eq!(offset.offset_in_item, px(0.));
1645 }
1646
1647 #[gpui::test]
1648 fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
1649 let cx = cx.add_empty_window();
1650
1651 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1652
1653 struct TestView(ListState);
1654 impl Render for TestView {
1655 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1656 list(self.0.clone(), |_, _, _| {
1657 div().h(px(50.)).w_full().into_any()
1658 })
1659 .w_full()
1660 .h_full()
1661 }
1662 }
1663
1664 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1665
1666 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1669 view.clone().into_any_element()
1670 });
1671 assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
1672
1673 cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| {
1677 view.into_any_element()
1678 });
1679 assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
1680 }
1681
1682 #[gpui::test]
1683 fn test_remeasure(cx: &mut TestAppContext) {
1684 let cx = cx.add_empty_window();
1685
1686 let item_height = Rc::new(Cell::new(100usize));
1690 let state = ListState::new(10, crate::ListAlignment::Top, px(10.));
1691
1692 struct TestView {
1693 state: ListState,
1694 item_height: Rc<Cell<usize>>,
1695 }
1696
1697 impl Render for TestView {
1698 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1699 let height = self.item_height.get();
1700 list(self.state.clone(), move |_, _, _| {
1701 div().h(px(height as f32)).w_full().into_any()
1702 })
1703 .w_full()
1704 .h_full()
1705 }
1706 }
1707
1708 let state_clone = state.clone();
1709 let item_height_clone = item_height.clone();
1710 let view = cx.update(|_, cx| {
1711 cx.new(|_| TestView {
1712 state: state_clone,
1713 item_height: item_height_clone,
1714 })
1715 });
1716
1717 state.scroll_to(gpui::ListOffset {
1720 item_ix: 2,
1721 offset_in_item: px(40.),
1722 });
1723
1724 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1725 view.clone().into_any_element()
1726 });
1727
1728 let offset = state.logical_scroll_top();
1729 assert_eq!(offset.item_ix, 2);
1730 assert_eq!(offset.offset_in_item, px(40.));
1731
1732 item_height.set(50);
1737 state.remeasure();
1738
1739 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1740 view.into_any_element()
1741 });
1742
1743 let offset = state.logical_scroll_top();
1744 assert_eq!(offset.item_ix, 2);
1745 assert_eq!(offset.offset_in_item, px(20.));
1746 }
1747
1748 #[gpui::test]
1749 fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) {
1750 let cx = cx.add_empty_window();
1751
1752 let item_height = Rc::new(Cell::new(100usize));
1753 let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
1754
1755 struct TestView {
1756 state: ListState,
1757 item_height: Rc<Cell<usize>>,
1758 }
1759
1760 impl Render for TestView {
1761 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1762 let height = self.item_height.get();
1763 list(self.state.clone(), move |index, _, _| {
1764 let height = if index == 5 { height } else { 100 };
1765 div().h(px(height as f32)).w_full().into_any()
1766 })
1767 .w_full()
1768 .h_full()
1769 }
1770 }
1771
1772 let state_clone = state.clone();
1773 let item_height_clone = item_height.clone();
1774 let view = cx.update(|_, cx| {
1775 cx.new(|_| TestView {
1776 state: state_clone,
1777 item_height: item_height_clone,
1778 })
1779 });
1780
1781 state.scroll_to(gpui::ListOffset {
1782 item_ix: 5,
1783 offset_in_item: px(40.),
1784 });
1785
1786 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1787 view.clone().into_any_element()
1788 });
1789
1790 item_height.set(200);
1791 state.remeasure_items(5..6);
1792
1793 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1794 view.into_any_element()
1795 });
1796
1797 let offset = state.logical_scroll_top();
1798 assert_eq!(offset.item_ix, 5);
1799 assert_eq!(offset.offset_in_item, px(40.));
1800 }
1801
1802 #[gpui::test]
1803 fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
1804 let cx = cx.add_empty_window();
1805
1806 let item_height = Rc::new(Cell::new(50usize));
1809 let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
1810
1811 struct TestView {
1812 state: ListState,
1813 item_height: Rc<Cell<usize>>,
1814 }
1815 impl Render for TestView {
1816 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1817 let height = self.item_height.get();
1818 list(self.state.clone(), move |_, _, _| {
1819 div().h(px(height as f32)).w_full().into_any()
1820 })
1821 .w_full()
1822 .h_full()
1823 }
1824 }
1825
1826 let state_clone = state.clone();
1827 let item_height_clone = item_height.clone();
1828 let view = cx.update(|_, cx| {
1829 cx.new(|_| TestView {
1830 state: state_clone,
1831 item_height: item_height_clone,
1832 })
1833 });
1834
1835 state.set_follow_mode(FollowMode::Tail);
1836
1837 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1840 view.clone().into_any_element()
1841 });
1842
1843 let offset = state.logical_scroll_top();
1846 assert_eq!(offset.item_ix, 6);
1847 assert_eq!(offset.offset_in_item, px(0.));
1848 assert!(state.is_following_tail());
1849
1850 item_height.set(80);
1853 state.remeasure();
1854
1855 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1856 view.into_any_element()
1857 });
1858
1859 let offset = state.logical_scroll_top();
1866 assert_eq!(offset.item_ix, 7);
1867 assert_eq!(offset.offset_in_item, px(40.));
1868 assert!(state.is_following_tail());
1869 }
1870
1871 #[gpui::test]
1872 fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) {
1873 let cx = cx.add_empty_window();
1874
1875 let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
1877
1878 struct TestView(ListState);
1879 impl Render for TestView {
1880 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1881 list(self.0.clone(), |_, _, _| {
1882 div().h(px(50.)).w_full().into_any()
1883 })
1884 .w_full()
1885 .h_full()
1886 }
1887 }
1888
1889 state.set_follow_mode(FollowMode::Tail);
1890
1891 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
1893 cx.new(|_| TestView(state.clone())).into_any_element()
1894 });
1895 assert!(state.is_following_tail());
1896
1897 cx.simulate_event(ScrollWheelEvent {
1900 position: point(px(50.), px(100.)),
1901 delta: ScrollDelta::Pixels(point(px(0.), px(100.))),
1902 ..Default::default()
1903 });
1904
1905 assert!(
1906 !state.is_following_tail(),
1907 "follow-tail should disengage when the user scrolls toward the start"
1908 );
1909 }
1910
1911 #[gpui::test]
1912 fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) {
1913 let cx = cx.add_empty_window();
1914
1915 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1917
1918 struct TestView(ListState);
1919 impl Render for TestView {
1920 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1921 list(self.0.clone(), |_, _, _| {
1922 div().h(px(50.)).w_full().into_any()
1923 })
1924 .w_full()
1925 .h_full()
1926 }
1927 }
1928
1929 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
1930
1931 state.set_follow_mode(FollowMode::Tail);
1932
1933 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1935 view.clone().into_any_element()
1936 });
1937 assert!(state.is_following_tail());
1938
1939 state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
1941
1942 let offset = state.logical_scroll_top();
1943 assert_eq!(offset.item_ix, 3);
1944 assert_eq!(offset.offset_in_item, px(0.));
1945 assert!(
1946 !state.is_following_tail(),
1947 "follow-tail should disengage when the scrollbar manually repositions the list"
1948 );
1949
1950 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1953 view.into_any_element()
1954 });
1955
1956 let offset = state.logical_scroll_top();
1957 assert_eq!(offset.item_ix, 3);
1958 assert_eq!(offset.offset_in_item, px(0.));
1959 }
1960
1961 #[gpui::test]
1962 fn test_scrollbar_drag_with_growing_content(cx: &mut TestAppContext) {
1963 let cx = cx.add_empty_window();
1964
1965 let last_item_height = Rc::new(Cell::new(50usize));
1966 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
1967
1968 struct TestView {
1969 state: ListState,
1970 last_item_height: Rc<Cell<usize>>,
1971 }
1972 impl Render for TestView {
1973 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1974 let last_item_height = self.last_item_height.clone();
1975 list(self.state.clone(), move |index, _, _| {
1976 let height = if index == 9 {
1977 last_item_height.get()
1978 } else {
1979 50
1980 };
1981 div().h(px(height as f32)).w_full().into_any()
1982 })
1983 .w_full()
1984 .h_full()
1985 }
1986 }
1987
1988 let view = cx.update(|_, cx| {
1989 cx.new(|_| TestView {
1990 state: state.clone(),
1991 last_item_height: last_item_height.clone(),
1992 })
1993 });
1994
1995 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
1996 view.clone().into_any_element()
1997 });
1998
1999 state.scrollbar_drag_started();
2000
2001 state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2002 let scrollbar_offset_before_growth = state.scroll_px_offset_for_scrollbar();
2003
2004 let offset = state.logical_scroll_top();
2005 assert_eq!(offset.item_ix, 3);
2006 assert_eq!(offset.offset_in_item, px(0.));
2007
2008 last_item_height.set(550);
2009 state.remeasure_items(9..10);
2010 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2011 view.clone().into_any_element()
2012 });
2013
2014 assert_eq!(state.max_offset_for_scrollbar().y, px(300.));
2015 assert_eq!(
2016 state.scroll_px_offset_for_scrollbar(),
2017 scrollbar_offset_before_growth
2018 );
2019
2020 state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2021 let offset = state.logical_scroll_top();
2022 assert_eq!(offset.item_ix, 3);
2023 assert_eq!(offset.offset_in_item, px(0.));
2024 }
2025
2026 #[gpui::test]
2027 fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) {
2028 let cx = cx.add_empty_window();
2029
2030 let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2032
2033 struct TestView(ListState);
2034 impl Render for TestView {
2035 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2036 list(self.0.clone(), |_, _, _| {
2037 div().h(px(50.)).w_full().into_any()
2038 })
2039 .w_full()
2040 .h_full()
2041 }
2042 }
2043
2044 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2045
2046 state.scroll_to(gpui::ListOffset {
2048 item_ix: 3,
2049 offset_in_item: px(0.),
2050 });
2051
2052 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2053 view.clone().into_any_element()
2054 });
2055
2056 let offset = state.logical_scroll_top();
2057 assert_eq!(offset.item_ix, 3);
2058 assert_eq!(offset.offset_in_item, px(0.));
2059 assert!(!state.is_following_tail());
2060
2061 state.set_follow_mode(FollowMode::Tail);
2064
2065 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2066 view.into_any_element()
2067 });
2068
2069 let offset = state.logical_scroll_top();
2072 assert_eq!(offset.item_ix, 6);
2073 assert_eq!(offset.offset_in_item, px(0.));
2074 assert!(state.is_following_tail());
2075 }
2076
2077 #[gpui::test]
2078 fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) {
2079 let cx = cx.add_empty_window();
2080
2081 const ITEMS: usize = 10;
2082 const ITEM_SIZE: f32 = 50.0;
2083
2084 let state = ListState::new(
2085 ITEMS,
2086 crate::ListAlignment::Bottom,
2087 px(ITEMS as f32 * ITEM_SIZE),
2088 );
2089
2090 struct TestView(ListState);
2091 impl Render for TestView {
2092 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2093 list(self.0.clone(), |_, _, _| {
2094 div().h(px(ITEM_SIZE)).w_full().into_any()
2095 })
2096 .w_full()
2097 .h_full()
2098 }
2099 }
2100
2101 cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| {
2102 cx.new(|_| TestView(state.clone())).into_any_element()
2103 });
2104
2105 assert_eq!(state.logical_scroll_top().item_ix, ITEMS);
2108
2109 let max_offset = state.max_offset_for_scrollbar();
2110 let scroll_offset = state.scroll_px_offset_for_scrollbar();
2111
2112 assert_eq!(
2113 -scroll_offset.y, max_offset.y,
2114 "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom",
2115 -scroll_offset.y, max_offset.y,
2116 );
2117 }
2118
2119 #[gpui::test]
2123 fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) {
2124 let cx = cx.add_empty_window();
2125
2126 let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
2128
2129 struct TestView(ListState);
2130 impl Render for TestView {
2131 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2132 list(self.0.clone(), |_, _, _| {
2133 div().h(px(50.)).w_full().into_any()
2134 })
2135 .w_full()
2136 .h_full()
2137 }
2138 }
2139
2140 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2141
2142 state.set_follow_mode(FollowMode::Tail);
2143
2144 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2145 view.clone().into_any_element()
2146 });
2147 assert!(state.is_following_tail());
2148
2149 cx.simulate_event(ScrollWheelEvent {
2151 position: point(px(50.), px(100.)),
2152 delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
2153 ..Default::default()
2154 });
2155 assert!(!state.is_following_tail());
2156
2157 cx.simulate_event(ScrollWheelEvent {
2159 position: point(px(50.), px(100.)),
2160 delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
2161 ..Default::default()
2162 });
2163
2164 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2167 view.clone().into_any_element()
2168 });
2169 assert!(
2170 state.is_following_tail(),
2171 "follow_tail should re-engage after scrolling back to the bottom"
2172 );
2173 }
2174
2175 #[gpui::test]
2178 fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) {
2179 let cx = cx.add_empty_window();
2180
2181 let state = ListState::new(20, crate::ListAlignment::Top, px(1000.));
2185
2186 struct TestView(ListState);
2187 impl Render for TestView {
2188 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2189 list(self.0.clone(), |_, _, _| {
2190 div().h(px(50.)).w_full().into_any()
2191 })
2192 .w_full()
2193 .h_full()
2194 }
2195 }
2196
2197 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2198
2199 state.set_follow_mode(FollowMode::Tail);
2200
2201 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2202 view.clone().into_any_element()
2203 });
2204 assert!(state.is_following_tail());
2205
2206 cx.simulate_event(ScrollWheelEvent {
2210 position: point(px(50.), px(100.)),
2211 delta: ScrollDelta::Pixels(point(px(0.), px(200.))),
2212 ..Default::default()
2213 });
2214 assert!(!state.is_following_tail());
2215
2216 state.remeasure_items(19..20);
2220
2221 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2227 view.clone().into_any_element()
2228 });
2229 assert!(
2230 !state.is_following_tail(),
2231 "follow_tail should not falsely re-engage due to an unmeasured item \
2232 reducing items.summary().height"
2233 );
2234 }
2235
2236 #[gpui::test]
2237 fn test_follow_tail_reengages_after_scrollbar_disengagement(cx: &mut TestAppContext) {
2238 let cx = cx.add_empty_window();
2239
2240 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2242
2243 struct TestView(ListState);
2244 impl Render for TestView {
2245 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2246 list(self.0.clone(), |_, _, _| {
2247 div().h(px(50.)).w_full().into_any()
2248 })
2249 .w_full()
2250 .h_full()
2251 }
2252 }
2253
2254 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2255
2256 state.set_follow_mode(FollowMode::Tail);
2257 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2258 view.clone().into_any_element()
2259 });
2260 assert!(state.is_following_tail());
2261
2262 state.set_offset_from_scrollbar(point(px(0.), px(-150.)));
2264 assert!(!state.is_following_tail());
2265
2266 state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
2269 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2270 view.into_any_element()
2271 });
2272 assert!(
2273 state.is_following_tail(),
2274 "follow_tail should re-engage after scrolling back to the bottom via the scrollbar"
2275 );
2276 }
2277
2278 #[gpui::test]
2279 fn test_follow_tail_reengages_after_scrollbar_drag_to_bottom_while_growing(
2280 cx: &mut TestAppContext,
2281 ) {
2282 let cx = cx.add_empty_window();
2283
2284 let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
2285
2286 struct TestView(ListState);
2287 impl Render for TestView {
2288 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2289 list(self.0.clone(), |_, _, _| {
2290 div().h(px(50.)).w_full().into_any()
2291 })
2292 .w_full()
2293 .h_full()
2294 }
2295 }
2296
2297 let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
2298
2299 state.set_follow_mode(FollowMode::Tail);
2300 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2301 view.clone().into_any_element()
2302 });
2303 assert!(state.is_following_tail());
2304
2305 state.scrollbar_drag_started();
2306
2307 state.splice(10..10, 10);
2308 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2309 view.clone().into_any_element()
2310 });
2311
2312 state.set_offset_from_scrollbar(point(px(0.), px(-300.)));
2313 state.scrollbar_drag_ended();
2314
2315 cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
2316 view.into_any_element()
2317 });
2318
2319 assert!(
2320 state.is_following_tail(),
2321 "follow_tail should re-engage when the user drags the scrollbar to \
2322 the bottom of its track, even when content has grown during the drag \
2323 (so frozen_bottom < live_bottom)"
2324 );
2325 }
2326}