1use frus_core::{Insets, Rect, Scene};
13use frus_layout::{Dimension, Style};
14
15use crate::interaction::Status;
16use crate::physics::ScrollPhysics;
17use crate::scroll::Axis;
18use crate::theme::Theme;
19use crate::widget::Widget;
20
21pub struct VirtualList<'a, Msg = crate::callback::Callback> {
23 pub count: usize,
25 pub item_extent: f32,
28 pub axis: Axis,
30 pub build: &'a dyn Fn(usize) -> Box<dyn Widget<Msg>>,
32}
33
34pub struct ListView<Msg = crate::callback::Callback> {
36 count: usize,
37 item_extent: f32,
38 axis: Axis,
39 width: Dimension,
40 height: Dimension,
41 flex_grow: f32,
42 physics: Option<ScrollPhysics>,
43 reverse: bool,
44 padding: Insets,
46 on_scroll: Option<Box<dyn Fn(crate::ScrollPosition) -> Msg>>,
48 grain: f32,
50 build: Box<dyn Fn(usize) -> Box<dyn Widget<Msg>>>,
51}
52
53impl<Msg> ListView<Msg> {
54 pub fn new<W: Widget<Msg> + 'static>(
61 count: usize,
62 item_extent: f32,
63 build: impl Fn(usize) -> W + 'static,
64 ) -> Self {
65 Self {
66 count,
67 item_extent,
68 axis: Axis::Vertical,
69 width: Dimension::Auto,
70 height: Dimension::Length(200.0),
71 flex_grow: 0.0,
72 physics: None,
73 reverse: false,
74 padding: Insets::ZERO,
75 on_scroll: None,
76 grain: 0.0,
77 build: Box::new(move |index| Box::new(build(index)) as Box<dyn Widget<Msg>>),
78 }
79 }
80
81 pub fn axis(mut self, axis: Axis) -> Self {
96 self.axis = axis;
97 self
98 }
99
100 pub fn reverse(mut self) -> Self {
110 self.reverse = true;
111 self
112 }
113
114 pub fn padding(mut self, padding: f32) -> Self {
135 self.padding = Insets::uniform(padding);
136 self
137 }
138
139 pub fn padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
141 self.padding = Insets::new(top, right, bottom, left);
142 self
143 }
144
145 pub fn physics(mut self, physics: ScrollPhysics) -> Self {
148 self.physics = Some(physics);
149 self
150 }
151
152 pub fn width(mut self, width: f32) -> Self {
154 self.width = Dimension::Length(width);
155 self
156 }
157
158 pub fn height(mut self, height: f32) -> Self {
160 self.height = Dimension::Length(height);
161 self
162 }
163
164 pub fn flex(mut self, grow: f32) -> Self {
166 self.flex_grow = grow;
167 self
168 }
169
170 pub fn on_scroll<R: crate::callback::IntoMsg<Msg>>(
182 mut self,
183 on_scroll: impl Fn(crate::ScrollPosition) -> R + 'static,
184 ) -> Self {
185 self.on_scroll = Some(Box::new(crate::callback::handler1(on_scroll)));
186 self
187 }
188
189 pub fn notify_every(mut self, px: f32) -> Self {
192 self.grain = px.max(0.0);
193 self
194 }
195}
196
197impl<Msg> Widget<Msg> for ListView<Msg> {
198 fn style(&self) -> Style {
199 Style {
200 width: self.width,
201 height: self.height,
202 flex_grow: self.flex_grow,
203 ..Default::default()
204 }
205 }
206
207 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
208 &[]
209 }
210
211 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
212
213 fn on_click(&self) -> Option<Msg> {
214 None
215 }
216
217 fn virtual_list(&self, _viewport: frus_core::Size) -> Option<VirtualList<'_, Msg>> {
218 Some(VirtualList {
219 count: self.count,
220 item_extent: self.item_extent,
221 axis: self.axis,
222 build: &*self.build,
223 })
224 }
225
226 fn scroll_axis(&self) -> Axis {
227 self.axis
228 }
229
230 fn scroll_physics(&self) -> Option<ScrollPhysics> {
231 self.physics
232 }
233
234 fn scroll_reverse(&self) -> bool {
235 self.reverse
236 }
237
238 fn scroll_padding(&self) -> Insets {
239 self.padding
240 }
241
242 fn on_scroll(&self, position: crate::ScrollPosition) -> Option<Msg> {
243 self.on_scroll.as_ref().map(|report| report(position))
244 }
245
246 fn scroll_grain(&self) -> f32 {
247 self.grain
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::{build_ui, Container, Runtime, Size};
255 use frus_core::{Color, Primitive};
256
257 #[test]
258 fn only_visible_items_are_built() {
259 use std::cell::Cell;
260 use std::rc::Rc;
261
262 let built = Rc::new(Cell::new(0usize));
264 let counter = built.clone();
265 let list = ListView::<()>::new(5000, 40.0, move |_i| {
266 counter.set(counter.get() + 1);
267 Container::<()>::new()
268 .width(180.0)
269 .height(40.0)
270 .color(Color::rgb(1.0, 0.0, 0.0))
271 })
272 .width(200.0)
273 .height(200.0);
274
275 let _ui = build_ui(
276 &list,
277 Size::new(200.0, 200.0),
278 &Runtime::default(),
279 &Theme::default(),
280 );
281 assert!(
283 built.get() <= 8,
284 "only the visible items are built: {}",
285 built.get()
286 );
287 assert!(
288 built.get() >= 5,
289 "at least the visible window: {}",
290 built.get()
291 );
292 }
293
294 #[test]
301 fn an_item_is_handed_the_list_s_width() {
302 let list = ListView::<()>::new(20, 40.0, |i| {
303 Container::<()>::new()
304 .height(40.0)
305 .color(Color::rgb(1.0, 0.0, 0.0))
306 .child(crate::Text::new(format!("Row {i}")))
307 })
308 .width(200.0)
309 .height(200.0);
310 let ui = build_ui(
311 &list,
312 Size::new(200.0, 200.0),
313 &Runtime::default(),
314 &Theme::default(),
315 );
316 let row = ui
317 .scene()
318 .primitives()
319 .iter()
320 .find_map(|p| match p {
321 Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
322 Some(*rect)
323 }
324 _ => None,
325 })
326 .expect("a row is painted");
327 assert_eq!(row.width, 200.0, "the row is as wide as the list: {row:?}");
328 }
329
330 #[test]
333 fn a_reversed_list_builds_from_the_bottom() {
334 let colour = |i: usize| {
335 if i == 0 {
336 Color::rgb(1.0, 0.0, 0.0)
337 } else {
338 Color::rgb(0.0, 0.0, 1.0)
339 }
340 };
341 let list = ListView::<()>::new(3, 40.0, move |i| {
342 Container::<()>::new().height(40.0).color(colour(i))
343 })
344 .reverse()
345 .width(100.0)
346 .height(200.0);
347 let ui = build_ui(
348 &list,
349 Size::new(100.0, 200.0),
350 &Runtime::default(),
351 &Theme::default(),
352 );
353 let rects: Vec<Rect> = ui
354 .scene()
355 .primitives()
356 .iter()
357 .filter_map(|p| match p {
358 Primitive::Rect { rect, color, .. } if color.b > 0.9 || color.r > 0.9 => {
359 Some(*rect)
360 }
361 _ => None,
362 })
363 .collect();
364 let first = ui
365 .scene()
366 .primitives()
367 .iter()
368 .find_map(|p| match p {
369 Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.b < 0.1 => {
370 Some(*rect)
371 }
372 _ => None,
373 })
374 .expect("item 0 is painted");
375 assert_eq!(
376 first.y + first.height,
377 200.0,
378 "item 0 at the bottom: {first:?}"
379 );
380 assert_eq!(rects.len(), 3, "all three fit");
381 let above = rects
383 .iter()
384 .filter(|r| r.y < first.y)
385 .max_by(|a, b| a.y.total_cmp(&b.y))
386 .expect("something above");
387 assert_eq!(above.y + above.height, first.y);
388 }
389
390 #[test]
393 fn a_short_reversed_list_rests_at_the_bottom() {
394 let plain = ListView::<()>::new(3, 40.0, |_| {
395 Container::<()>::new()
396 .height(40.0)
397 .color(Color::rgb(1.0, 0.0, 0.0))
398 })
399 .width(100.0)
400 .height(200.0);
401 let top_of = |list: &ListView<()>| {
402 let ui = build_ui(
403 list,
404 Size::new(100.0, 200.0),
405 &Runtime::default(),
406 &Theme::default(),
407 );
408 ui.scene()
409 .primitives()
410 .iter()
411 .filter_map(|p| match p {
412 Primitive::Rect { rect, color, .. } if color.r > 0.9 => Some(rect.y),
413 _ => None,
414 })
415 .fold(f32::MAX, f32::min)
416 };
417 assert_eq!(top_of(&plain), 0.0);
418
419 let reversed = ListView::<()>::new(3, 40.0, |_| {
420 Container::<()>::new()
421 .height(40.0)
422 .color(Color::rgb(1.0, 0.0, 0.0))
423 })
424 .reverse()
425 .width(100.0)
426 .height(200.0);
427 assert_eq!(top_of(&reversed), 80.0, "200 - 3 x 40");
428 }
429
430 #[test]
431 fn scroll_max_covers_full_content() {
432 let list = ListView::<()>::new(100, 40.0, |_i| Container::<()>::new().height(40.0))
433 .width(200.0)
434 .height(200.0);
435 let ui = build_ui(
436 &list,
437 Size::new(200.0, 200.0),
438 &Runtime::default(),
439 &Theme::default(),
440 );
441 let maxes = ui.scrollable_maxes();
443 assert_eq!(maxes.len(), 1);
444 assert_eq!(maxes[0].2, 3800.0);
445 }
446
447 #[test]
448 fn builds_a_scene() {
449 let list = ListView::<()>::new(50, 30.0, |i| {
450 Container::<()>::new().height(30.0).color(if i % 2 == 0 {
451 Color::rgb(0.2, 0.2, 0.2)
452 } else {
453 Color::rgb(0.3, 0.3, 0.3)
454 })
455 })
456 .width(200.0)
457 .height(120.0);
458 let ui = build_ui(
459 &list,
460 Size::new(200.0, 120.0),
461 &Runtime::default(),
462 &Theme::default(),
463 );
464 let rects = ui
465 .scene()
466 .primitives()
467 .iter()
468 .filter(|p| matches!(p, Primitive::Rect { .. }))
469 .count();
470 assert!(rects > 0);
471 }
472}
473
474#[cfg(test)]
475mod padding_tests {
476 use super::*;
477 use crate::{build_ui, Container, Runtime, Size};
478 use frus_core::{Color, Primitive};
479
480 const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
481
482 fn items(list: &ListView<()>, size: Size) -> Vec<Rect> {
484 build_ui(list, size, &Runtime::default(), &Theme::default())
485 .scene()
486 .primitives()
487 .iter()
488 .filter_map(|p| match p {
489 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
490 _ => None,
491 })
492 .collect()
493 }
494
495 fn list(count: usize) -> ListView<()> {
496 ListView::<()>::new(count, 40.0, |_| {
497 Container::<()>::new().height(40.0).color(MARK)
498 })
499 .width(100.0)
500 .height(200.0)
501 }
502
503 #[test]
505 fn the_first_item_clears_the_leading_inset() {
506 let plain = items(&list(5), Size::new(100.0, 200.0));
507 assert_eq!((plain[0].y, plain[0].x, plain[0].width), (0.0, 0.0, 100.0));
508
509 let padded = items(
510 &list(5).padding_each(12.0, 8.0, 24.0, 8.0),
511 Size::new(100.0, 200.0),
512 );
513 assert_eq!(padded[0].y, 12.0, "item 0 starts after the top inset");
514 assert_eq!(padded[0].x, 8.0, "and inside the left one");
515 assert_eq!(padded[0].width, 84.0, "the sides come off the width");
516 assert_eq!(padded[1].y, 52.0, "the pitch is unchanged");
517 }
518
519 #[test]
522 fn the_far_inset_is_reachable_rather_than_lost() {
523 let bare = build_ui(
524 &list(5),
525 Size::new(100.0, 200.0),
526 &Runtime::default(),
527 &Theme::default(),
528 );
529 assert_eq!(bare.scroll_regions()[0].max_y, 0.0);
531
532 let padded = build_ui(
533 &list(5).padding_each(12.0, 0.0, 24.0, 0.0),
534 Size::new(100.0, 200.0),
535 &Runtime::default(),
536 &Theme::default(),
537 );
538 assert_eq!(
539 padded.scroll_regions()[0].max_y,
540 36.0,
541 "both insets joined the content"
542 );
543 }
544
545 #[test]
548 fn a_reversed_list_clears_its_bottom_inset() {
549 let padded = items(
550 &list(3).reverse().padding_each(12.0, 0.0, 24.0, 0.0),
551 Size::new(100.0, 200.0),
552 );
553 let first = padded
554 .iter()
555 .copied()
556 .max_by(|a, b| a.y.total_cmp(&b.y))
557 .expect("item 0 is painted");
558 assert_eq!(
559 first.y + first.height,
560 176.0,
561 "the bottom of item 0 sits one bottom inset above the viewport's"
562 );
563 }
564
565 #[test]
567 fn the_padding_does_not_widen_the_window() {
568 let built = items(&list(5000).padding(16.0), Size::new(100.0, 200.0));
569 assert!(built.len() <= 8, "only the visible items: {}", built.len());
570 }
571}
572
573#[cfg(test)]
575mod across_tests {
576 use super::*;
577 use crate::{build_ui, Container, Runtime, Size};
578 use frus_core::{Color, Primitive, Rect};
579
580 const MARK: Color = Color::rgb(0.0, 1.0, 0.0);
581
582 fn shelf(list: &ListView<()>, size: Size) -> Vec<Rect> {
584 build_ui(list, size, &Runtime::default(), &Theme::default())
585 .scene()
586 .primitives()
587 .iter()
588 .filter_map(|p| match p {
589 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
590 _ => None,
591 })
592 .collect()
593 }
594
595 fn shelf_of(count: usize) -> ListView<()> {
597 ListView::<()>::new(count, 50.0, |_| Container::<()>::new().color(MARK))
598 .axis(Axis::Horizontal)
599 .width(200.0)
600 .height(100.0)
601 }
602
603 #[test]
605 fn the_items_run_across_rather_than_down() {
606 let cards = shelf(&shelf_of(3), Size::new(200.0, 100.0));
607 assert_eq!(cards.len(), 3);
608 for (i, card) in cards.iter().enumerate() {
609 assert_eq!(card.x, i as f32 * 50.0, "card {i} sits along the row");
610 assert_eq!(card.y, 0.0, "and none of them moves down");
611 assert_eq!(card.width, 50.0, "the extent is a width here");
612 }
613 }
614
615 #[test]
620 fn an_item_is_handed_the_shelf_s_height() {
621 let cards = shelf(&shelf_of(2), Size::new(200.0, 100.0));
622 assert_eq!(cards[0].height, 100.0);
623 }
624
625 #[test]
628 fn only_the_visible_cards_are_built() {
629 use std::cell::RefCell;
630 use std::rc::Rc;
631
632 let built = Rc::new(RefCell::new(Vec::new()));
633 let seen = built.clone();
634 let list = ListView::<()>::new(200, 50.0, move |i| {
635 seen.borrow_mut().push(i);
636 Container::<()>::new().color(MARK)
637 })
638 .axis(Axis::Horizontal)
639 .width(200.0)
640 .height(100.0);
641 let _ = build_ui(
642 &list,
643 Size::new(200.0, 100.0),
644 &Runtime::default(),
645 &Theme::default(),
646 );
647 assert_eq!(
648 *built.borrow(),
649 vec![0, 1, 2, 3],
650 "four fit, four are built"
651 );
652 }
653
654 #[test]
658 fn it_scrolls_along_x_and_not_along_y() {
659 let ui = build_ui(
660 &shelf_of(10),
661 Size::new(200.0, 100.0),
662 &Runtime::default(),
663 &Theme::default(),
664 );
665 let maxes = ui.scrollable_maxes();
667 assert_eq!(maxes.len(), 1);
668 assert_eq!(maxes[0].1, 300.0, "it scrolls along x");
669 assert_eq!(maxes[0].2, 0.0, "and not along y");
670 }
671
672 #[test]
675 fn the_insets_follow_the_axis() {
676 let padded = shelf(
677 &shelf_of(3).padding_each(10.0, 4.0, 6.0, 8.0),
678 Size::new(200.0, 100.0),
679 );
680 assert_eq!(padded[0].x, 8.0, "the left inset leads");
681 assert_eq!(padded[0].y, 10.0, "the top inset is a cross inset here");
682 assert_eq!(
683 padded[0].height,
684 100.0 - 10.0 - 6.0,
685 "top and bottom come off"
686 );
687 assert_eq!(padded[0].width, 50.0, "the extent is untouched by padding");
688 }
689
690 #[test]
694 fn a_reversed_shelf_starts_at_the_far_edge() {
695 let cards = shelf(&shelf_of(3).reverse(), Size::new(200.0, 100.0));
696 assert_eq!(
697 cards[0].x + cards[0].width,
698 200.0,
699 "card 0 ends at the edge"
700 );
701 assert_eq!(cards[1].x + cards[1].width, 150.0, "card 1 is before it");
702 }
703
704 #[test]
710 fn scrolling_it_moves_the_window_along_x() {
711 let mut runtime = Runtime::default();
712 runtime
713 .scroll
714 .insert(crate::interaction::WidgetId::ROOT, (120.0, 0.0));
715 let list = shelf_of(10);
716 let cards: Vec<Rect> =
717 build_ui(&list, Size::new(200.0, 100.0), &runtime, &Theme::default())
718 .scene()
719 .primitives()
720 .iter()
721 .filter_map(|p| match p {
722 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
723 _ => None,
724 })
725 .collect();
726 assert_eq!(cards.first().map(|c| c.x), Some(-20.0));
729 assert!(
730 cards.iter().all(|c| c.y == 0.0),
731 "and nothing drifted down the cross axis"
732 );
733 }
734
735 #[test]
737 fn a_list_that_says_nothing_still_runs_down() {
738 let rows = shelf(
739 &ListView::<()>::new(3, 50.0, |_| Container::<()>::new().color(MARK))
740 .width(200.0)
741 .height(100.0),
742 Size::new(200.0, 100.0),
743 );
744 assert_eq!(rows[0].y, 0.0);
745 assert_eq!(rows[1].y, 50.0, "the second is below, not beside");
746 assert_eq!(rows[0].width, 200.0, "and the width is handed over whole");
747 }
748}