1//! A scrollable list of elements with uniform height, optimized for large lists.
2//! Rather than use the full taffy layout system, uniform_list simply measures
3//! the first element and then lays out all remaining elements in a line based on that
4//! measurement. This is much faster than the full layout system, but only works for
5//! elements with uniform height.
6
7use crate::{
8 point, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementId,
9 GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
10 ListSizingBehavior, Pixels, Render, ScrollHandle, Size, StyleRefinement, Styled, View,
11 ViewContext, WindowContext,
12};
13use smallvec::SmallVec;
14use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
15use taffy::style::Overflow;
16
17use super::ListHorizontalSizingBehavior;
18
19/// uniform_list provides lazy rendering for a set of items that are of uniform height.
20/// When rendered into a container with overflow-y: hidden and a fixed (or max) height,
21/// uniform_list will only render the visible subset of items.
22#[track_caller]
23pub fn uniform_list<I, R, V>(
24 view: View<V>,
25 id: I,
26 item_count: usize,
27 f: impl 'static + Fn(&mut V, Range<usize>, &mut ViewContext<V>) -> Vec<R>,
28) -> UniformList
29where
30 I: Into<ElementId>,
31 R: IntoElement,
32 V: Render,
33{
34 let id = id.into();
35 let mut base_style = StyleRefinement::default();
36 base_style.overflow.y = Some(Overflow::Scroll);
37
38 let render_range = move |range, cx: &mut WindowContext| {
39 view.update(cx, |this, cx| {
40 f(this, range, cx)
41 .into_iter()
42 .map(|component| component.into_any_element())
43 .collect()
44 })
45 };
46
47 UniformList {
48 item_count,
49 item_to_measure_index: 0,
50 render_items: Box::new(render_range),
51 decorations: Vec::new(),
52 interactivity: Interactivity {
53 element_id: Some(id),
54 base_style: Box::new(base_style),
55
56 #[cfg(debug_assertions)]
57 location: Some(*core::panic::Location::caller()),
58
59 ..Default::default()
60 },
61 scroll_handle: None,
62 sizing_behavior: ListSizingBehavior::default(),
63 horizontal_sizing_behavior: ListHorizontalSizingBehavior::default(),
64 }
65}
66
67/// A list element for efficiently laying out and displaying a list of uniform-height elements.
68pub struct UniformList {
69 item_count: usize,
70 item_to_measure_index: usize,
71 render_items:
72 Box<dyn for<'a> Fn(Range<usize>, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>,
73 decorations: Vec<Box<dyn UniformListDecoration>>,
74 interactivity: Interactivity,
75 scroll_handle: Option<UniformListScrollHandle>,
76 sizing_behavior: ListSizingBehavior,
77 horizontal_sizing_behavior: ListHorizontalSizingBehavior,
78}
79
80/// Frame state used by the [UniformList].
81pub struct UniformListFrameState {
82 items: SmallVec<[AnyElement; 32]>,
83 decorations: SmallVec<[AnyElement; 1]>,
84}
85
86/// A handle for controlling the scroll position of a uniform list.
87/// This should be stored in your view and passed to the uniform_list on each frame.
88#[derive(Clone, Debug, Default)]
89pub struct UniformListScrollHandle(pub Rc<RefCell<UniformListScrollState>>);
90
91/// Where to place the element scrolled to.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum ScrollStrategy {
94 /// Place the element at the top of the list's viewport.
95 Top,
96 /// Attempt to place the element in the middle of the list's viewport.
97 /// May not be possible if there's not enough list items above the item scrolled to:
98 /// in this case, the element will be placed at the closest possible position.
99 Center,
100}
101
102#[derive(Clone, Debug, Default)]
103#[allow(missing_docs)]
104pub struct UniformListScrollState {
105 pub base_handle: ScrollHandle,
106 pub deferred_scroll_to_item: Option<(usize, ScrollStrategy)>,
107 /// Size of the item, captured during last layout.
108 pub last_item_size: Option<ItemSize>,
109}
110
111#[derive(Copy, Clone, Debug, Default)]
112/// The size of the item and its contents.
113pub struct ItemSize {
114 /// The size of the item.
115 pub item: Size<Pixels>,
116 /// The size of the item's contents, which may be larger than the item itself,
117 /// if the item was bounded by a parent element.
118 pub contents: Size<Pixels>,
119}
120
121impl UniformListScrollHandle {
122 /// Create a new scroll handle to bind to a uniform list.
123 pub fn new() -> Self {
124 Self(Rc::new(RefCell::new(UniformListScrollState {
125 base_handle: ScrollHandle::new(),
126 deferred_scroll_to_item: None,
127 last_item_size: None,
128 })))
129 }
130
131 /// Scroll the list to the given item index.
132 pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
133 self.0.borrow_mut().deferred_scroll_to_item = Some((ix, strategy));
134 }
135
136 /// Get the index of the topmost visible child.
137 #[cfg(any(test, feature = "test-support"))]
138 pub fn logical_scroll_top_index(&self) -> usize {
139 let this = self.0.borrow();
140 this.deferred_scroll_to_item
141 .map(|(ix, _)| ix)
142 .unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
143 }
144}
145
146impl Styled for UniformList {
147 fn style(&mut self) -> &mut StyleRefinement {
148 &mut self.interactivity.base_style
149 }
150}
151
152impl Element for UniformList {
153 type RequestLayoutState = UniformListFrameState;
154 type PrepaintState = Option<Hitbox>;
155
156 fn id(&self) -> Option<ElementId> {
157 self.interactivity.element_id.clone()
158 }
159
160 fn request_layout(
161 &mut self,
162 global_id: Option<&GlobalElementId>,
163 cx: &mut WindowContext,
164 ) -> (LayoutId, Self::RequestLayoutState) {
165 let max_items = self.item_count;
166 let item_size = self.measure_item(None, cx);
167 let layout_id = self
168 .interactivity
169 .request_layout(global_id, cx, |style, cx| match self.sizing_behavior {
170 ListSizingBehavior::Infer => {
171 cx.with_text_style(style.text_style().cloned(), |cx| {
172 cx.request_measured_layout(
173 style,
174 move |known_dimensions, available_space, _cx| {
175 let desired_height = item_size.height * max_items;
176 let width = known_dimensions.width.unwrap_or(match available_space
177 .width
178 {
179 AvailableSpace::Definite(x) => x,
180 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
181 item_size.width
182 }
183 });
184 let height = match available_space.height {
185 AvailableSpace::Definite(height) => desired_height.min(height),
186 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
187 desired_height
188 }
189 };
190 size(width, height)
191 },
192 )
193 })
194 }
195 ListSizingBehavior::Auto => cx.with_text_style(style.text_style().cloned(), |cx| {
196 cx.request_layout(style, None)
197 }),
198 });
199
200 (
201 layout_id,
202 UniformListFrameState {
203 items: SmallVec::new(),
204 decorations: SmallVec::new(),
205 },
206 )
207 }
208
209 fn prepaint(
210 &mut self,
211 global_id: Option<&GlobalElementId>,
212 bounds: Bounds<Pixels>,
213 frame_state: &mut Self::RequestLayoutState,
214 cx: &mut WindowContext,
215 ) -> Option<Hitbox> {
216 let style = self.interactivity.compute_style(global_id, None, cx);
217 let border = style.border_widths.to_pixels(cx.rem_size());
218 let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
219
220 let padded_bounds = Bounds::from_corners(
221 bounds.origin + point(border.left + padding.left, border.top + padding.top),
222 bounds.bottom_right()
223 - point(border.right + padding.right, border.bottom + padding.bottom),
224 );
225
226 let can_scroll_horizontally = matches!(
227 self.horizontal_sizing_behavior,
228 ListHorizontalSizingBehavior::Unconstrained
229 );
230
231 let longest_item_size = self.measure_item(None, cx);
232 let content_width = if can_scroll_horizontally {
233 padded_bounds.size.width.max(longest_item_size.width)
234 } else {
235 padded_bounds.size.width
236 };
237 let content_size = Size {
238 width: content_width,
239 height: longest_item_size.height * self.item_count + padding.top + padding.bottom,
240 };
241
242 let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
243 let item_height = longest_item_size.height;
244 let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| {
245 let mut handle = handle.0.borrow_mut();
246 handle.last_item_size = Some(ItemSize {
247 item: padded_bounds.size,
248 contents: content_size,
249 });
250 handle.deferred_scroll_to_item.take()
251 });
252
253 self.interactivity.prepaint(
254 global_id,
255 bounds,
256 content_size,
257 cx,
258 |style, mut scroll_offset, hitbox, cx| {
259 let border = style.border_widths.to_pixels(cx.rem_size());
260 let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
261
262 let padded_bounds = Bounds::from_corners(
263 bounds.origin + point(border.left + padding.left, border.top),
264 bounds.bottom_right() - point(border.right + padding.right, border.bottom),
265 );
266
267 if let Some(handle) = self.scroll_handle.as_mut() {
268 handle.0.borrow_mut().base_handle.set_bounds(bounds);
269 }
270
271 if self.item_count > 0 {
272 let content_height =
273 item_height * self.item_count + padding.top + padding.bottom;
274 let is_scrolled_vertically = !scroll_offset.y.is_zero();
275 let min_vertical_scroll_offset = padded_bounds.size.height - content_height;
276 if is_scrolled_vertically && scroll_offset.y < min_vertical_scroll_offset {
277 shared_scroll_offset.borrow_mut().y = min_vertical_scroll_offset;
278 scroll_offset.y = min_vertical_scroll_offset;
279 }
280
281 let content_width = content_size.width + padding.left + padding.right;
282 let is_scrolled_horizontally =
283 can_scroll_horizontally && !scroll_offset.x.is_zero();
284 if is_scrolled_horizontally && content_width <= padded_bounds.size.width {
285 shared_scroll_offset.borrow_mut().x = Pixels::ZERO;
286 scroll_offset.x = Pixels::ZERO;
287 }
288
289 if let Some((ix, scroll_strategy)) = shared_scroll_to_item {
290 let list_height = padded_bounds.size.height;
291 let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
292 let item_top = item_height * ix + padding.top;
293 let item_bottom = item_top + item_height;
294 let scroll_top = -updated_scroll_offset.y;
295 let mut scrolled_to_top = false;
296 if item_top < scroll_top + padding.top {
297 scrolled_to_top = true;
298 updated_scroll_offset.y = -(item_top) + padding.top;
299 } else if item_bottom > scroll_top + list_height - padding.bottom {
300 scrolled_to_top = true;
301 updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom;
302 }
303
304 match scroll_strategy {
305 ScrollStrategy::Top => {}
306 ScrollStrategy::Center => {
307 if scrolled_to_top {
308 let item_center = item_top + item_height / 2.0;
309 let target_scroll_top = item_center - list_height / 2.0;
310
311 if item_top < scroll_top
312 || item_bottom > scroll_top + list_height
313 {
314 updated_scroll_offset.y = -target_scroll_top
315 .max(Pixels::ZERO)
316 .min(content_height - list_height)
317 .max(Pixels::ZERO);
318 }
319 }
320 }
321 }
322 scroll_offset = *updated_scroll_offset
323 }
324
325 let first_visible_element_ix =
326 (-(scroll_offset.y + padding.top) / item_height).floor() as usize;
327 let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height)
328 / item_height)
329 .ceil() as usize;
330 let visible_range = first_visible_element_ix
331 ..cmp::min(last_visible_element_ix, self.item_count);
332
333 let mut items = (self.render_items)(visible_range.clone(), cx);
334
335 let content_mask = ContentMask { bounds };
336 cx.with_content_mask(Some(content_mask), |cx| {
337 for (mut item, ix) in items.into_iter().zip(visible_range.clone()) {
338 let item_origin = padded_bounds.origin
339 + point(
340 if can_scroll_horizontally {
341 scroll_offset.x + padding.left
342 } else {
343 scroll_offset.x
344 },
345 item_height * ix + scroll_offset.y + padding.top,
346 );
347 let available_width = if can_scroll_horizontally {
348 padded_bounds.size.width + scroll_offset.x.abs()
349 } else {
350 padded_bounds.size.width
351 };
352 let available_space = size(
353 AvailableSpace::Definite(available_width),
354 AvailableSpace::Definite(item_height),
355 );
356 item.layout_as_root(available_space, cx);
357 item.prepaint_at(item_origin, cx);
358 frame_state.items.push(item);
359 }
360
361 let bounds = Bounds::new(
362 padded_bounds.origin
363 + point(
364 if can_scroll_horizontally {
365 scroll_offset.x + padding.left
366 } else {
367 scroll_offset.x
368 },
369 scroll_offset.y + padding.top,
370 ),
371 padded_bounds.size,
372 );
373 for decoration in &self.decorations {
374 let mut decoration = decoration.as_ref().compute(
375 visible_range.clone(),
376 bounds,
377 item_height,
378 self.item_count,
379 cx,
380 );
381 let available_space = size(
382 AvailableSpace::Definite(bounds.size.width),
383 AvailableSpace::Definite(bounds.size.height),
384 );
385 decoration.layout_as_root(available_space, cx);
386 decoration.prepaint_at(bounds.origin, cx);
387 frame_state.decorations.push(decoration);
388 }
389 });
390 }
391
392 hitbox
393 },
394 )
395 }
396
397 fn paint(
398 &mut self,
399 global_id: Option<&GlobalElementId>,
400 bounds: Bounds<crate::Pixels>,
401 request_layout: &mut Self::RequestLayoutState,
402 hitbox: &mut Option<Hitbox>,
403 cx: &mut WindowContext,
404 ) {
405 self.interactivity
406 .paint(global_id, bounds, hitbox.as_ref(), cx, |_, cx| {
407 for item in &mut request_layout.items {
408 item.paint(cx);
409 }
410 for decoration in &mut request_layout.decorations {
411 decoration.paint(cx);
412 }
413 })
414 }
415}
416
417impl IntoElement for UniformList {
418 type Element = Self;
419
420 fn into_element(self) -> Self::Element {
421 self
422 }
423}
424
425/// A decoration for a [`UniformList`]. This can be used for various things,
426/// such as rendering indent guides, or other visual effects.
427pub trait UniformListDecoration {
428 /// Compute the decoration element, given the visible range of list items,
429 /// the bounds of the list, and the height of each item.
430 fn compute(
431 &self,
432 visible_range: Range<usize>,
433 bounds: Bounds<Pixels>,
434 item_height: Pixels,
435 item_count: usize,
436 cx: &mut WindowContext,
437 ) -> AnyElement;
438}
439
440impl UniformList {
441 /// Selects a specific list item for measurement.
442 pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {
443 self.item_to_measure_index = item_index.unwrap_or(0);
444 self
445 }
446
447 /// Sets the sizing behavior, similar to the `List` element.
448 pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
449 self.sizing_behavior = behavior;
450 self
451 }
452
453 /// Sets the horizontal sizing behavior, controlling the way list items laid out horizontally.
454 /// With [`ListHorizontalSizingBehavior::Unconstrained`] behavior, every item and the list itself will
455 /// have the size of the widest item and lay out pushing the `end_slot` to the right end.
456 pub fn with_horizontal_sizing_behavior(
457 mut self,
458 behavior: ListHorizontalSizingBehavior,
459 ) -> Self {
460 self.horizontal_sizing_behavior = behavior;
461 match behavior {
462 ListHorizontalSizingBehavior::FitList => {
463 self.interactivity.base_style.overflow.x = None;
464 }
465 ListHorizontalSizingBehavior::Unconstrained => {
466 self.interactivity.base_style.overflow.x = Some(Overflow::Scroll);
467 }
468 }
469 self
470 }
471
472 /// Adds a decoration element to the list.
473 pub fn with_decoration(mut self, decoration: impl UniformListDecoration + 'static) -> Self {
474 self.decorations.push(Box::new(decoration));
475 self
476 }
477
478 fn measure_item(&self, list_width: Option<Pixels>, cx: &mut WindowContext) -> Size<Pixels> {
479 if self.item_count == 0 {
480 return Size::default();
481 }
482
483 let item_ix = cmp::min(self.item_to_measure_index, self.item_count - 1);
484 let mut items = (self.render_items)(item_ix..item_ix + 1, cx);
485 let Some(mut item_to_measure) = items.pop() else {
486 return Size::default();
487 };
488 let available_space = size(
489 list_width.map_or(AvailableSpace::MinContent, |width| {
490 AvailableSpace::Definite(width)
491 }),
492 AvailableSpace::MinContent,
493 );
494 item_to_measure.layout_as_root(available_space, cx)
495 }
496
497 /// Track and render scroll state of this list with reference to the given scroll handle.
498 pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self {
499 self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone());
500 self.scroll_handle = Some(handle);
501 self
502 }
503}
504
505impl InteractiveElement for UniformList {
506 fn interactivity(&mut self) -> &mut crate::Interactivity {
507 &mut self.interactivity
508 }
509}