1use crate::{
8 AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity,
9 GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement,
10 IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size,
11 StyleRefinement, Styled, Window, point, px, size,
12};
13use smallvec::SmallVec;
14use std::{cell::RefCell, cmp, ops::Range, rc::Rc, usize};
15
16use super::ListHorizontalSizingBehavior;
17
18#[track_caller]
22pub fn uniform_list<R>(
23 id: impl Into<ElementId>,
24 item_count: usize,
25 f: impl 'static + Fn(Range<usize>, &mut Window, &mut App) -> Vec<R>,
26) -> UniformList
27where
28 R: IntoElement,
29{
30 let id = id.into();
31 let mut base_style = StyleRefinement::default();
32 base_style.overflow.y = Some(Overflow::Scroll);
33
34 let render_range = move |range: Range<usize>, window: &mut Window, cx: &mut App| {
35 f(range, window, cx)
36 .into_iter()
37 .map(|component| component.into_any_element())
38 .collect()
39 };
40
41 UniformList {
42 item_count,
43 item_to_measure_index: 0,
44 render_items: Box::new(render_range),
45 decorations: Vec::new(),
46 interactivity: Interactivity {
47 element_id: Some(id),
48 base_style: Box::new(base_style),
49 ..Interactivity::new()
50 },
51 scroll_handle: None,
52 sizing_behavior: ListSizingBehavior::default(),
53 horizontal_sizing_behavior: ListHorizontalSizingBehavior::default(),
54 }
55}
56
57pub struct UniformList {
59 item_count: usize,
60 item_to_measure_index: usize,
61 render_items: Box<
62 dyn for<'a> Fn(Range<usize>, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>,
63 >,
64 decorations: Vec<Box<dyn UniformListDecoration>>,
65 interactivity: Interactivity,
66 scroll_handle: Option<UniformListScrollHandle>,
67 sizing_behavior: ListSizingBehavior,
68 horizontal_sizing_behavior: ListHorizontalSizingBehavior,
69}
70
71pub struct UniformListFrameState {
73 items: SmallVec<[AnyElement; 32]>,
74 decorations: SmallVec<[AnyElement; 2]>,
75}
76
77#[derive(Clone, Debug, Default)]
80pub struct UniformListScrollHandle(pub Rc<RefCell<UniformListScrollState>>);
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum ScrollStrategy {
85 Top,
87 Center,
91 Bottom,
95 Nearest,
99}
100
101#[derive(Clone, Copy, Debug)]
102#[allow(missing_docs)]
103pub struct DeferredScrollToItem {
104 pub item_index: usize,
106 pub strategy: ScrollStrategy,
108 pub offset: usize,
110 pub scroll_strict: bool,
111}
112
113#[derive(Clone, Debug, Default)]
114#[allow(missing_docs)]
115pub struct UniformListScrollState {
116 pub base_handle: ScrollHandle,
117 pub deferred_scroll_to_item: Option<DeferredScrollToItem>,
118 pub last_item_size: Option<ItemSize>,
120 pub y_flipped: bool,
122}
123
124#[derive(Copy, Clone, Debug, Default)]
125pub struct ItemSize {
127 pub item: Size<Pixels>,
129 pub contents: Size<Pixels>,
132}
133
134impl UniformListScrollHandle {
135 pub fn new() -> Self {
137 Self(Rc::new(RefCell::new(UniformListScrollState {
138 base_handle: ScrollHandle::new(),
139 deferred_scroll_to_item: None,
140 last_item_size: None,
141 y_flipped: false,
142 })))
143 }
144
145 pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
151 self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
152 item_index: ix,
153 strategy,
154 offset: 0,
155 scroll_strict: false,
156 });
157 }
158
159 pub fn scroll_to_item_strict(&self, ix: usize, strategy: ScrollStrategy) {
164 self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
165 item_index: ix,
166 strategy,
167 offset: 0,
168 scroll_strict: true,
169 });
170 }
171
172 pub fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) {
183 self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
184 item_index: ix,
185 strategy,
186 offset,
187 scroll_strict: false,
188 });
189 }
190
191 pub fn scroll_to_item_strict_with_offset(
202 &self,
203 ix: usize,
204 strategy: ScrollStrategy,
205 offset: usize,
206 ) {
207 self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
208 item_index: ix,
209 strategy,
210 offset,
211 scroll_strict: true,
212 });
213 }
214
215 pub fn y_flipped(&self) -> bool {
217 self.0.borrow().y_flipped
218 }
219
220 #[cfg(any(test, feature = "test-support"))]
222 pub fn logical_scroll_top_index(&self) -> usize {
223 let this = self.0.borrow();
224 this.deferred_scroll_to_item
225 .as_ref()
226 .map(|deferred| deferred.item_index)
227 .unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
228 }
229
230 pub fn is_scrollable(&self) -> bool {
232 if let Some(size) = self.0.borrow().last_item_size {
233 size.contents.height > size.item.height
234 } else {
235 false
236 }
237 }
238
239 pub fn is_scrolled_to_end(&self) -> Option<bool> {
242 let state = self.0.borrow();
243 let max_offset = state.base_handle.max_offset();
244 if max_offset.y <= px(0.) {
245 return None;
246 }
247 let offset = state.base_handle.offset();
248 Some(-offset.y >= max_offset.y)
249 }
250
251 pub fn scroll_to_bottom(&self) {
253 self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom);
254 }
255}
256
257impl Styled for UniformList {
258 fn style(&mut self) -> &mut StyleRefinement {
259 &mut self.interactivity.base_style
260 }
261}
262
263impl Element for UniformList {
264 type RequestLayoutState = UniformListFrameState;
265 type PrepaintState = Option<Hitbox>;
266
267 fn id(&self) -> Option<ElementId> {
268 self.interactivity.element_id.clone()
269 }
270
271 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
272 None
273 }
274
275 fn request_layout(
276 &mut self,
277 global_id: Option<&GlobalElementId>,
278 inspector_id: Option<&InspectorElementId>,
279 window: &mut Window,
280 cx: &mut App,
281 ) -> (LayoutId, Self::RequestLayoutState) {
282 let max_items = self.item_count;
283 let item_size = self.measure_item(None, window, cx);
284 let layout_id = self.interactivity.request_layout(
285 global_id,
286 inspector_id,
287 window,
288 cx,
289 |style, window, cx| match self.sizing_behavior {
290 ListSizingBehavior::Infer => {
291 window.with_text_style(style.text_style().cloned(), |window| {
292 window.request_measured_layout(
293 style,
294 move |known_dimensions, available_space, _window, _cx| {
295 let desired_height = item_size.height * max_items;
296 let width = known_dimensions.width.unwrap_or(match available_space
297 .width
298 {
299 AvailableSpace::Definite(x) => x,
300 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
301 item_size.width
302 }
303 });
304 let height = match available_space.height {
305 AvailableSpace::Definite(height) => desired_height.min(height),
306 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
307 desired_height
308 }
309 };
310 size(width, height)
311 },
312 )
313 })
314 }
315 ListSizingBehavior::Auto => window
316 .with_text_style(style.text_style().cloned(), |window| {
317 window.request_layout(style, None, cx)
318 }),
319 },
320 );
321
322 (
323 layout_id,
324 UniformListFrameState {
325 items: SmallVec::new(),
326 decorations: SmallVec::new(),
327 },
328 )
329 }
330
331 fn prepaint(
332 &mut self,
333 global_id: Option<&GlobalElementId>,
334 inspector_id: Option<&InspectorElementId>,
335 bounds: Bounds<Pixels>,
336 frame_state: &mut Self::RequestLayoutState,
337 window: &mut Window,
338 cx: &mut App,
339 ) -> Option<Hitbox> {
340 let style = self
341 .interactivity
342 .compute_style(global_id, None, window, cx);
343 let border = style.border_widths.to_pixels(window.rem_size());
344 let padding = style
345 .padding
346 .to_pixels(bounds.size.into(), window.rem_size());
347
348 let padded_bounds = Bounds::from_corners(
349 bounds.origin + point(border.left + padding.left, border.top + padding.top),
350 bounds.bottom_right()
351 - point(border.right + padding.right, border.bottom + padding.bottom),
352 );
353
354 let can_scroll_horizontally = matches!(
355 self.horizontal_sizing_behavior,
356 ListHorizontalSizingBehavior::Unconstrained
357 );
358
359 let longest_item_size = self.measure_item(None, window, cx);
360 let content_width = if can_scroll_horizontally {
361 padded_bounds.size.width.max(longest_item_size.width)
362 } else {
363 padded_bounds.size.width
364 };
365 let content_size = Size {
366 width: content_width,
367 height: longest_item_size.height * self.item_count,
368 };
369
370 let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
371 let item_height = longest_item_size.height;
372 let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| {
373 let mut handle = handle.0.borrow_mut();
374 handle.last_item_size = Some(ItemSize {
375 item: padded_bounds.size,
376 contents: content_size,
377 });
378 handle.deferred_scroll_to_item.take()
379 });
380
381 self.interactivity.prepaint(
382 global_id,
383 inspector_id,
384 bounds,
385 content_size,
386 window,
387 cx,
388 |_style, mut scroll_offset, hitbox, window, cx| {
389 let y_flipped = if let Some(scroll_handle) = &self.scroll_handle {
390 let scroll_state = scroll_handle.0.borrow();
391 scroll_state.y_flipped
392 } else {
393 false
394 };
395
396 if self.item_count > 0 {
397 let content_height = item_height * self.item_count;
398
399 let is_scrolled_vertically = !scroll_offset.y.is_zero();
400 let max_scroll_offset = padded_bounds.size.height - content_height;
401
402 if is_scrolled_vertically && scroll_offset.y < max_scroll_offset {
403 shared_scroll_offset.borrow_mut().y = max_scroll_offset;
404 scroll_offset.y = max_scroll_offset;
405 }
406
407 let content_width = content_size.width + padding.left + padding.right;
408 let is_scrolled_horizontally =
409 can_scroll_horizontally && !scroll_offset.x.is_zero();
410 if is_scrolled_horizontally && content_width <= padded_bounds.size.width {
411 shared_scroll_offset.borrow_mut().x = Pixels::ZERO;
412 scroll_offset.x = Pixels::ZERO;
413 }
414
415 if let Some(DeferredScrollToItem {
416 mut item_index,
417 mut strategy,
418 offset,
419 scroll_strict,
420 }) = shared_scroll_to_item
421 {
422 if y_flipped {
423 item_index = self.item_count.saturating_sub(item_index + 1);
424 }
425 let list_height = padded_bounds.size.height;
426 let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
427 let item_top = item_height * item_index;
428 let item_bottom = item_top + item_height;
429 let scroll_top = -updated_scroll_offset.y;
430 let offset_pixels = item_height * offset;
431
432 let is_above = item_top < scroll_top + offset_pixels;
434 let is_below = item_bottom > scroll_top + list_height;
435
436 if scroll_strict || is_above || is_below {
437 if strategy == ScrollStrategy::Nearest {
438 if is_above {
439 strategy = ScrollStrategy::Top;
440 } else if is_below {
441 strategy = ScrollStrategy::Bottom;
442 }
443 }
444
445 let max_scroll_offset =
446 (content_height - list_height).max(Pixels::ZERO);
447 match strategy {
448 ScrollStrategy::Top => {
449 updated_scroll_offset.y = -(item_top - offset_pixels)
450 .clamp(Pixels::ZERO, max_scroll_offset);
451 }
452 ScrollStrategy::Center => {
453 let item_center = item_top + item_height / 2.0;
454
455 let viewport_height = list_height - offset_pixels;
456 let viewport_center = offset_pixels + viewport_height / 2.0;
457 let target_scroll_top = item_center - viewport_center;
458 updated_scroll_offset.y =
459 -target_scroll_top.clamp(Pixels::ZERO, max_scroll_offset);
460 }
461 ScrollStrategy::Bottom => {
462 updated_scroll_offset.y = -(item_bottom - list_height)
463 .clamp(Pixels::ZERO, max_scroll_offset);
464 }
465 ScrollStrategy::Nearest => {
466 }
468 }
469 }
470 scroll_offset = *updated_scroll_offset
471 }
472
473 let first_visible_element_ix =
474 (-(scroll_offset.y + padding.top) / item_height).floor() as usize;
475 let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height)
476 / item_height)
477 .ceil() as usize;
478
479 let visible_range = first_visible_element_ix
480 ..cmp::min(last_visible_element_ix, self.item_count);
481
482 let items = if y_flipped {
483 let flipped_range = self.item_count.saturating_sub(visible_range.end)
484 ..self.item_count.saturating_sub(visible_range.start);
485 let mut items = (self.render_items)(flipped_range, window, cx);
486 items.reverse();
487 items
488 } else {
489 (self.render_items)(visible_range.clone(), window, cx)
490 };
491
492 let content_mask = ContentMask { bounds };
493 window.with_content_mask(Some(content_mask), |window| {
494 for (mut item, ix) in items.into_iter().zip(visible_range.clone()) {
495 let item_origin = padded_bounds.origin
496 + scroll_offset
497 + point(Pixels::ZERO, item_height * ix);
498
499 let available_width = if can_scroll_horizontally {
500 padded_bounds.size.width + scroll_offset.x.abs()
501 } else {
502 padded_bounds.size.width
503 };
504 let available_space = size(
505 AvailableSpace::Definite(available_width),
506 AvailableSpace::Definite(item_height),
507 );
508 item.layout_as_root(available_space, window, cx);
509 item.prepaint_at(item_origin, window, cx);
510 frame_state.items.push(item);
511 }
512
513 let bounds =
514 Bounds::new(padded_bounds.origin + scroll_offset, padded_bounds.size);
515 for decoration in &self.decorations {
516 let mut decoration = decoration.as_ref().compute(
517 visible_range.clone(),
518 bounds,
519 scroll_offset,
520 item_height,
521 self.item_count,
522 window,
523 cx,
524 );
525 let available_space = size(
526 AvailableSpace::Definite(bounds.size.width),
527 AvailableSpace::Definite(bounds.size.height),
528 );
529 decoration.layout_as_root(available_space, window, cx);
530 decoration.prepaint_at(bounds.origin, window, cx);
531 frame_state.decorations.push(decoration);
532 }
533 });
534 }
535
536 hitbox
537 },
538 )
539 }
540
541 fn paint(
542 &mut self,
543 global_id: Option<&GlobalElementId>,
544 inspector_id: Option<&InspectorElementId>,
545 bounds: Bounds<crate::Pixels>,
546 request_layout: &mut Self::RequestLayoutState,
547 hitbox: &mut Option<Hitbox>,
548 window: &mut Window,
549 cx: &mut App,
550 ) {
551 self.interactivity.paint(
552 global_id,
553 inspector_id,
554 bounds,
555 hitbox.as_ref(),
556 window,
557 cx,
558 |_, window, cx| {
559 for item in &mut request_layout.items {
560 item.paint(window, cx);
561 }
562 for decoration in &mut request_layout.decorations {
563 decoration.paint(window, cx);
564 }
565 },
566 )
567 }
568}
569
570impl IntoElement for UniformList {
571 type Element = Self;
572
573 fn into_element(self) -> Self::Element {
574 self
575 }
576}
577
578pub trait UniformListDecoration {
581 fn compute(
584 &self,
585 visible_range: Range<usize>,
586 bounds: Bounds<Pixels>,
587 scroll_offset: Point<Pixels>,
588 item_height: Pixels,
589 item_count: usize,
590 window: &mut Window,
591 cx: &mut App,
592 ) -> AnyElement;
593}
594
595impl<T: UniformListDecoration + 'static> UniformListDecoration for Entity<T> {
596 fn compute(
597 &self,
598 visible_range: Range<usize>,
599 bounds: Bounds<Pixels>,
600 scroll_offset: Point<Pixels>,
601 item_height: Pixels,
602 item_count: usize,
603 window: &mut Window,
604 cx: &mut App,
605 ) -> AnyElement {
606 self.update(cx, |inner, cx| {
607 inner.compute(
608 visible_range,
609 bounds,
610 scroll_offset,
611 item_height,
612 item_count,
613 window,
614 cx,
615 )
616 })
617 }
618}
619
620impl UniformList {
621 pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
623 self.item_to_measure_index = item_index.unwrap_or(0);
624 self
625 }
626
627 pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
629 self.sizing_behavior = behavior;
630 self
631 }
632
633 pub fn with_horizontal_sizing_behavior(
637 mut self,
638 behavior: ListHorizontalSizingBehavior,
639 ) -> Self {
640 self.horizontal_sizing_behavior = behavior;
641 match behavior {
642 ListHorizontalSizingBehavior::FitList => {
643 self.interactivity.base_style.overflow.x = None;
644 }
645 ListHorizontalSizingBehavior::Unconstrained => {
646 self.interactivity.base_style.overflow.x = Some(Overflow::Scroll);
647 }
648 }
649 self
650 }
651
652 pub fn with_decoration(mut self, decoration: impl UniformListDecoration + 'static) -> Self {
654 self.decorations.push(Box::new(decoration));
655 self
656 }
657
658 fn measure_item(
659 &self,
660 list_width: Option<Pixels>,
661 window: &mut Window,
662 cx: &mut App,
663 ) -> Size<Pixels> {
664 if self.item_count == 0 {
665 return Size::default();
666 }
667
668 let item_ix = cmp::min(self.item_to_measure_index, self.item_count - 1);
669 let mut items = (self.render_items)(item_ix..item_ix + 1, window, cx);
670 let Some(mut item_to_measure) = items.pop() else {
671 return Size::default();
672 };
673 let available_space = size(
674 list_width.map_or(AvailableSpace::MinContent, |width| {
675 AvailableSpace::Definite(width)
676 }),
677 AvailableSpace::MinContent,
678 );
679 item_to_measure.layout_as_root(available_space, window, cx)
680 }
681
682 pub fn track_scroll(mut self, handle: &UniformListScrollHandle) -> Self {
684 self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone());
685 self.scroll_handle = Some(handle.clone());
686 self
687 }
688
689 pub fn y_flipped(mut self, y_flipped: bool) -> Self {
691 if let Some(ref scroll_handle) = self.scroll_handle {
692 let mut scroll_state = scroll_handle.0.borrow_mut();
693 let mut base_handle = &scroll_state.base_handle;
694 let offset = base_handle.offset();
695 match scroll_state.last_item_size {
696 Some(last_size) if scroll_state.y_flipped != y_flipped => {
697 let new_y_offset =
698 -(offset.y + last_size.contents.height - last_size.item.height);
699 base_handle.set_offset(point(offset.x, new_y_offset));
700 scroll_state.y_flipped = y_flipped;
701 }
702 None if y_flipped => {
704 base_handle.set_offset(point(offset.x, Pixels::MIN));
705 scroll_state.y_flipped = y_flipped;
706 }
707 _ => {}
708 }
709 }
710 self
711 }
712}
713
714impl InteractiveElement for UniformList {
715 fn interactivity(&mut self) -> &mut crate::Interactivity {
716 &mut self.interactivity
717 }
718}
719
720#[cfg(test)]
721mod test {
722 use crate::TestAppContext;
723
724 #[gpui::test]
725 fn test_scroll_strategy_nearest(cx: &mut TestAppContext) {
726 use crate::{
727 Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, div, prelude::*,
728 px, uniform_list,
729 };
730 use std::ops::Range;
731
732 actions!(example, [SelectNext, SelectPrev]);
733
734 struct TestView {
735 index: usize,
736 length: usize,
737 scroll_handle: UniformListScrollHandle,
738 focus_handle: FocusHandle,
739 visible_range: Range<usize>,
740 }
741
742 impl TestView {
743 pub fn select_next(
744 &mut self,
745 _: &SelectNext,
746 window: &mut Window,
747 _: &mut Context<Self>,
748 ) {
749 if self.index + 1 == self.length {
750 self.index = 0
751 } else {
752 self.index += 1;
753 }
754 self.scroll_handle
755 .scroll_to_item(self.index, ScrollStrategy::Nearest);
756 window.refresh();
757 }
758
759 pub fn select_previous(
760 &mut self,
761 _: &SelectPrev,
762 window: &mut Window,
763 _: &mut Context<Self>,
764 ) {
765 if self.index == 0 {
766 self.index = self.length - 1
767 } else {
768 self.index -= 1;
769 }
770 self.scroll_handle
771 .scroll_to_item(self.index, ScrollStrategy::Nearest);
772 window.refresh();
773 }
774 }
775
776 impl Render for TestView {
777 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
778 div()
779 .id("list-example")
780 .track_focus(&self.focus_handle)
781 .on_action(cx.listener(Self::select_next))
782 .on_action(cx.listener(Self::select_previous))
783 .size_full()
784 .child(
785 uniform_list(
786 "entries",
787 self.length,
788 cx.processor(|this, range: Range<usize>, _window, _cx| {
789 this.visible_range = range.clone();
790 range
791 .map(|ix| div().id(ix).h(px(20.0)).child(format!("Item {ix}")))
792 .collect()
793 }),
794 )
795 .track_scroll(&self.scroll_handle)
796 .h(px(200.0)),
797 )
798 }
799 }
800
801 let (view, cx) = cx.add_window_view(|window, cx| {
802 let focus_handle = cx.focus_handle();
803 window.focus(&focus_handle, cx);
804 TestView {
805 scroll_handle: UniformListScrollHandle::new(),
806 index: 0,
807 focus_handle,
808 length: 47,
809 visible_range: 0..0,
810 }
811 });
812
813 for ix in 1..10 {
817 cx.dispatch_action(SelectNext);
818 view.read_with(cx, |view, _| {
819 assert_eq!(view.index, ix);
820 assert_eq!(view.visible_range, 0..10);
821 })
822 }
823
824 for ix in 10..47 {
826 cx.dispatch_action(SelectNext);
827 view.read_with(cx, |view, _| {
828 assert_eq!(view.index, ix);
829 assert_eq!(view.visible_range, ix - 9..ix + 1);
830 })
831 }
832
833 cx.dispatch_action(SelectNext);
835 view.read_with(cx, |view, _| {
836 assert_eq!(view.index, 0);
837 assert_eq!(view.visible_range, 0..10);
838 });
839
840 cx.dispatch_action(SelectPrev);
842 view.read_with(cx, |view, _| {
843 assert_eq!(view.index, 46);
844 assert_eq!(view.visible_range, 37..47);
845 });
846
847 for ix in (37..46).rev() {
849 cx.dispatch_action(SelectPrev);
850 view.read_with(cx, |view, _| {
851 assert_eq!(view.index, ix);
852 assert_eq!(view.visible_range, 37..47);
853 })
854 }
855
856 for ix in (0..37).rev() {
858 cx.dispatch_action(SelectPrev);
859 view.read_with(cx, |view, _| {
860 assert_eq!(view.index, ix);
861 assert_eq!(view.visible_range, ix..ix + 10);
862 })
863 }
864 }
865}