1use crate::{
8 AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity,
9 GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
10 ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, StyleRefinement, Styled,
11 Window, point, px, size,
12};
13use smallvec::SmallVec;
14use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
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 window: &mut Window,
279 cx: &mut App,
280 ) -> (LayoutId, Self::RequestLayoutState) {
281 let max_items = self.item_count;
282 let item_size = self.measure_item(None, window, cx);
283 let layout_id = self.interactivity.request_layout(
284 global_id,
285 window,
286 cx,
287 |style, window, cx| match self.sizing_behavior {
288 ListSizingBehavior::Infer => {
289 window.with_text_style(style.text_style().cloned(), |window| {
290 window.request_measured_layout(
291 style,
292 move |known_dimensions, available_space, _window, _cx| {
293 let desired_height = item_size.height * max_items;
294 let width = known_dimensions.width.unwrap_or(match available_space
295 .width
296 {
297 AvailableSpace::Definite(x) => x,
298 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
299 item_size.width
300 }
301 });
302 let height = match available_space.height {
303 AvailableSpace::Definite(height) => desired_height.min(height),
304 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
305 desired_height
306 }
307 };
308 size(width, height)
309 },
310 )
311 })
312 }
313 ListSizingBehavior::Auto => window
314 .with_text_style(style.text_style().cloned(), |window| {
315 window.request_layout(style, None, cx)
316 }),
317 },
318 );
319
320 (
321 layout_id,
322 UniformListFrameState {
323 items: SmallVec::new(),
324 decorations: SmallVec::new(),
325 },
326 )
327 }
328
329 fn prepaint(
330 &mut self,
331 global_id: Option<&GlobalElementId>,
332 bounds: Bounds<Pixels>,
333 frame_state: &mut Self::RequestLayoutState,
334 window: &mut Window,
335 cx: &mut App,
336 ) -> Option<Hitbox> {
337 let style = self
338 .interactivity
339 .compute_style(global_id, None, window, cx);
340 let border = style.border_widths.to_pixels(window.rem_size());
341 let padding = style
342 .padding
343 .to_pixels(bounds.size.into(), window.rem_size());
344
345 let padded_bounds = Bounds::from_corners(
346 bounds.origin + point(border.left + padding.left, border.top + padding.top),
347 bounds.bottom_right()
348 - point(border.right + padding.right, border.bottom + padding.bottom),
349 );
350
351 let can_scroll_horizontally = matches!(
352 self.horizontal_sizing_behavior,
353 ListHorizontalSizingBehavior::Unconstrained
354 );
355
356 let longest_item_size = self.measure_item(None, window, cx);
357 let content_width = if can_scroll_horizontally {
358 padded_bounds.size.width.max(longest_item_size.width)
359 } else {
360 padded_bounds.size.width
361 };
362 let content_size = Size {
363 width: content_width,
364 height: longest_item_size.height * self.item_count,
365 };
366
367 let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
368 let item_height = longest_item_size.height;
369 let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| {
370 let mut handle = handle.0.borrow_mut();
371 handle.last_item_size = Some(ItemSize {
372 item: padded_bounds.size,
373 contents: content_size,
374 });
375 handle.deferred_scroll_to_item.take()
376 });
377
378 self.interactivity.prepaint(
379 global_id,
380 bounds,
381 content_size,
382 window,
383 cx,
384 |_style, mut scroll_offset, hitbox, window, cx| {
385 let y_flipped = if let Some(scroll_handle) = &self.scroll_handle {
386 let scroll_state = scroll_handle.0.borrow();
387 scroll_state.y_flipped
388 } else {
389 false
390 };
391
392 if self.item_count > 0 {
393 let content_height = item_height * self.item_count;
394
395 let is_scrolled_vertically = !scroll_offset.y.is_zero();
396 let max_scroll_offset = padded_bounds.size.height - content_height;
397
398 if is_scrolled_vertically && scroll_offset.y < max_scroll_offset {
399 shared_scroll_offset.borrow_mut().y = max_scroll_offset;
400 scroll_offset.y = max_scroll_offset;
401 }
402
403 let content_width = content_size.width + padding.left + padding.right;
404 let is_scrolled_horizontally =
405 can_scroll_horizontally && !scroll_offset.x.is_zero();
406 if is_scrolled_horizontally && content_width <= padded_bounds.size.width {
407 shared_scroll_offset.borrow_mut().x = Pixels::ZERO;
408 scroll_offset.x = Pixels::ZERO;
409 }
410
411 if let Some(DeferredScrollToItem {
412 mut item_index,
413 mut strategy,
414 offset,
415 scroll_strict,
416 }) = shared_scroll_to_item
417 {
418 if y_flipped {
419 item_index = self.item_count.saturating_sub(item_index + 1);
420 }
421 let list_height = padded_bounds.size.height;
422 let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
423 let item_top = item_height * item_index;
424 let item_bottom = item_top + item_height;
425 let scroll_top = -updated_scroll_offset.y;
426 let offset_pixels = item_height * offset;
427
428 let is_above = item_top < scroll_top + offset_pixels;
430 let is_below = item_bottom > scroll_top + list_height;
431
432 if scroll_strict || is_above || is_below {
433 if strategy == ScrollStrategy::Nearest {
434 if is_above {
435 strategy = ScrollStrategy::Top;
436 } else if is_below {
437 strategy = ScrollStrategy::Bottom;
438 }
439 }
440
441 let max_scroll_offset =
442 (content_height - list_height).max(Pixels::ZERO);
443 match strategy {
444 ScrollStrategy::Top => {
445 updated_scroll_offset.y = -(item_top - offset_pixels)
446 .clamp(Pixels::ZERO, max_scroll_offset);
447 }
448 ScrollStrategy::Center => {
449 let item_center = item_top + item_height / 2.0;
450
451 let viewport_height = list_height - offset_pixels;
452 let viewport_center = offset_pixels + viewport_height / 2.0;
453 let target_scroll_top = item_center - viewport_center;
454 updated_scroll_offset.y =
455 -target_scroll_top.clamp(Pixels::ZERO, max_scroll_offset);
456 }
457 ScrollStrategy::Bottom => {
458 updated_scroll_offset.y = -(item_bottom - list_height)
459 .clamp(Pixels::ZERO, max_scroll_offset);
460 }
461 ScrollStrategy::Nearest => {
462 }
464 }
465 }
466 scroll_offset = *updated_scroll_offset
467 }
468
469 let first_visible_element_ix =
470 (-(scroll_offset.y + padding.top) / item_height).floor() as usize;
471 let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height)
472 / item_height)
473 .ceil() as usize;
474
475 let visible_range = first_visible_element_ix
476 ..cmp::min(last_visible_element_ix, self.item_count);
477
478 let items = if y_flipped {
479 let flipped_range = self.item_count.saturating_sub(visible_range.end)
480 ..self.item_count.saturating_sub(visible_range.start);
481 let mut items = (self.render_items)(flipped_range, window, cx);
482 items.reverse();
483 items
484 } else {
485 (self.render_items)(visible_range.clone(), window, cx)
486 };
487
488 let content_mask = ContentMask { bounds };
489 window.with_content_mask(Some(content_mask), |window| {
490 for (mut item, ix) in items.into_iter().zip(visible_range.clone()) {
491 let item_origin = padded_bounds.origin
492 + scroll_offset
493 + point(Pixels::ZERO, item_height * ix);
494
495 let available_width = if can_scroll_horizontally {
496 padded_bounds.size.width + scroll_offset.x.abs()
497 } else {
498 padded_bounds.size.width
499 };
500 let available_space = size(
501 AvailableSpace::Definite(available_width),
502 AvailableSpace::Definite(item_height),
503 );
504 item.layout_as_root(available_space, window, cx);
505 item.prepaint_at(item_origin, window, cx);
506 frame_state.items.push(item);
507 }
508
509 let bounds =
510 Bounds::new(padded_bounds.origin + scroll_offset, padded_bounds.size);
511 for decoration in &self.decorations {
512 let mut decoration = decoration.as_ref().compute(
513 visible_range.clone(),
514 bounds,
515 scroll_offset,
516 item_height,
517 self.item_count,
518 window,
519 cx,
520 );
521 let available_space = size(
522 AvailableSpace::Definite(bounds.size.width),
523 AvailableSpace::Definite(bounds.size.height),
524 );
525 decoration.layout_as_root(available_space, window, cx);
526 decoration.prepaint_at(bounds.origin, window, cx);
527 frame_state.decorations.push(decoration);
528 }
529 });
530 }
531
532 hitbox
533 },
534 )
535 }
536
537 fn paint(
538 &mut self,
539 global_id: Option<&GlobalElementId>,
540 bounds: Bounds<crate::Pixels>,
541 request_layout: &mut Self::RequestLayoutState,
542 hitbox: &mut Option<Hitbox>,
543 window: &mut Window,
544 cx: &mut App,
545 ) {
546 self.interactivity.paint(
547 global_id,
548 bounds,
549 hitbox.as_ref(),
550 window,
551 cx,
552 |_, window, cx| {
553 for item in &mut request_layout.items {
554 item.paint(window, cx);
555 }
556 for decoration in &mut request_layout.decorations {
557 decoration.paint(window, cx);
558 }
559 },
560 )
561 }
562}
563
564impl IntoElement for UniformList {
565 type Element = Self;
566
567 fn into_element(self) -> Self::Element {
568 self
569 }
570}
571
572pub trait UniformListDecoration {
575 fn compute(
578 &self,
579 visible_range: Range<usize>,
580 bounds: Bounds<Pixels>,
581 scroll_offset: Point<Pixels>,
582 item_height: Pixels,
583 item_count: usize,
584 window: &mut Window,
585 cx: &mut App,
586 ) -> AnyElement;
587}
588
589impl<T: UniformListDecoration + 'static> UniformListDecoration for Entity<T> {
590 fn compute(
591 &self,
592 visible_range: Range<usize>,
593 bounds: Bounds<Pixels>,
594 scroll_offset: Point<Pixels>,
595 item_height: Pixels,
596 item_count: usize,
597 window: &mut Window,
598 cx: &mut App,
599 ) -> AnyElement {
600 self.update(cx, |inner, cx| {
601 inner.compute(
602 visible_range,
603 bounds,
604 scroll_offset,
605 item_height,
606 item_count,
607 window,
608 cx,
609 )
610 })
611 }
612}
613
614impl UniformList {
615 pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
617 self.item_to_measure_index = item_index.unwrap_or(0);
618 self
619 }
620
621 pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
623 self.sizing_behavior = behavior;
624 self
625 }
626
627 pub fn with_horizontal_sizing_behavior(
631 mut self,
632 behavior: ListHorizontalSizingBehavior,
633 ) -> Self {
634 self.horizontal_sizing_behavior = behavior;
635 match behavior {
636 ListHorizontalSizingBehavior::FitList => {
637 self.interactivity.base_style.overflow.x = None;
638 }
639 ListHorizontalSizingBehavior::Unconstrained => {
640 self.interactivity.base_style.overflow.x = Some(Overflow::Scroll);
641 }
642 }
643 self
644 }
645
646 pub fn with_decoration(mut self, decoration: impl UniformListDecoration + 'static) -> Self {
648 self.decorations.push(Box::new(decoration));
649 self
650 }
651
652 fn measure_item(
653 &self,
654 list_width: Option<Pixels>,
655 window: &mut Window,
656 cx: &mut App,
657 ) -> Size<Pixels> {
658 if self.item_count == 0 {
659 return Size::default();
660 }
661
662 let item_ix = cmp::min(self.item_to_measure_index, self.item_count - 1);
663 let mut items = (self.render_items)(item_ix..item_ix + 1, window, cx);
664 let Some(mut item_to_measure) = items.pop() else {
665 return Size::default();
666 };
667 let available_space = size(
668 list_width.map_or(AvailableSpace::MaxContent, |width| {
669 AvailableSpace::Definite(width)
670 }),
671 AvailableSpace::MinContent,
672 );
673 item_to_measure.layout_as_root(available_space, window, cx)
674 }
675
676 pub fn track_scroll(mut self, handle: &UniformListScrollHandle) -> Self {
678 self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone());
679 self.scroll_handle = Some(handle.clone());
680 self
681 }
682
683 pub fn y_flipped(mut self, y_flipped: bool) -> Self {
685 if let Some(ref scroll_handle) = self.scroll_handle {
686 let mut scroll_state = scroll_handle.0.borrow_mut();
687 let mut base_handle = &scroll_state.base_handle;
688 let offset = base_handle.offset();
689 match scroll_state.last_item_size {
690 Some(last_size) if scroll_state.y_flipped != y_flipped => {
691 let new_y_offset =
692 -(offset.y + last_size.contents.height - last_size.item.height);
693 base_handle.set_offset(point(offset.x, new_y_offset));
694 scroll_state.y_flipped = y_flipped;
695 }
696 None if y_flipped => {
698 base_handle.set_offset(point(offset.x, Pixels::MIN));
699 scroll_state.y_flipped = y_flipped;
700 }
701 _ => {}
702 }
703 }
704 self
705 }
706}
707
708impl InteractiveElement for UniformList {
709 fn interactivity(&mut self) -> &mut crate::Interactivity {
710 &mut self.interactivity
711 }
712}
713
714#[cfg(test)]
715mod test {
716 use crate::TestAppContext;
717
718 #[gpui::test]
719 fn test_scroll_strategy_nearest(cx: &mut TestAppContext) {
720 use crate::{
721 Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, div, prelude::*,
722 px, uniform_list,
723 };
724 use std::ops::Range;
725
726 actions!(example, [SelectNext, SelectPrev]);
727
728 struct TestView {
729 index: usize,
730 length: usize,
731 scroll_handle: UniformListScrollHandle,
732 focus_handle: FocusHandle,
733 visible_range: Range<usize>,
734 }
735
736 impl TestView {
737 pub fn select_next(
738 &mut self,
739 _: &SelectNext,
740 window: &mut Window,
741 _: &mut Context<Self>,
742 ) {
743 if self.index + 1 == self.length {
744 self.index = 0
745 } else {
746 self.index += 1;
747 }
748 self.scroll_handle
749 .scroll_to_item(self.index, ScrollStrategy::Nearest);
750 window.refresh();
751 }
752
753 pub fn select_previous(
754 &mut self,
755 _: &SelectPrev,
756 window: &mut Window,
757 _: &mut Context<Self>,
758 ) {
759 if self.index == 0 {
760 self.index = self.length - 1
761 } else {
762 self.index -= 1;
763 }
764 self.scroll_handle
765 .scroll_to_item(self.index, ScrollStrategy::Nearest);
766 window.refresh();
767 }
768 }
769
770 impl Render for TestView {
771 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
772 div()
773 .id("list-example")
774 .track_focus(&self.focus_handle)
775 .on_action(cx.listener(Self::select_next))
776 .on_action(cx.listener(Self::select_previous))
777 .size_full()
778 .child(
779 uniform_list(
780 "entries",
781 self.length,
782 cx.processor(|this, range: Range<usize>, _window, _cx| {
783 this.visible_range = range.clone();
784 range
785 .map(|ix| div().id(ix).h(px(20.0)).child(format!("Item {ix}")))
786 .collect()
787 }),
788 )
789 .track_scroll(&self.scroll_handle)
790 .h(px(200.0)),
791 )
792 }
793 }
794
795 let (view, cx) = cx.add_window_view(|window, cx| {
796 let focus_handle = cx.focus_handle();
797 window.focus(&focus_handle, cx);
798 TestView {
799 scroll_handle: UniformListScrollHandle::new(),
800 index: 0,
801 focus_handle,
802 length: 47,
803 visible_range: 0..0,
804 }
805 });
806
807 for ix in 1..10 {
811 cx.dispatch_action(SelectNext);
812 view.read_with(cx, |view, _| {
813 assert_eq!(view.index, ix);
814 assert_eq!(view.visible_range, 0..10);
815 })
816 }
817
818 for ix in 10..47 {
820 cx.dispatch_action(SelectNext);
821 view.read_with(cx, |view, _| {
822 assert_eq!(view.index, ix);
823 assert_eq!(view.visible_range, ix - 9..ix + 1);
824 })
825 }
826
827 cx.dispatch_action(SelectNext);
829 view.read_with(cx, |view, _| {
830 assert_eq!(view.index, 0);
831 assert_eq!(view.visible_range, 0..10);
832 });
833
834 cx.dispatch_action(SelectPrev);
836 view.read_with(cx, |view, _| {
837 assert_eq!(view.index, 46);
838 assert_eq!(view.visible_range, 37..47);
839 });
840
841 for ix in (37..46).rev() {
843 cx.dispatch_action(SelectPrev);
844 view.read_with(cx, |view, _| {
845 assert_eq!(view.index, ix);
846 assert_eq!(view.visible_range, 37..47);
847 })
848 }
849
850 for ix in (0..37).rev() {
852 cx.dispatch_action(SelectPrev);
853 view.read_with(cx, |view, _| {
854 assert_eq!(view.index, ix);
855 assert_eq!(view.visible_range, ix..ix + 10);
856 })
857 }
858 }
859}