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 sized: (bool, bool),
44 flex_grow: f32,
45 physics: Option<ScrollPhysics>,
46 primary: Option<bool>,
48 reverse: bool,
49 padding: Insets,
51 on_scroll: Option<Box<dyn Fn(crate::ScrollPosition) -> Msg>>,
53 grain: f32,
55 build: Box<dyn Fn(usize) -> Box<dyn Widget<Msg>>>,
56}
57
58impl<Msg> ListView<Msg> {
59 pub fn new<W: Widget<Msg> + 'static>(
66 count: usize,
67 item_extent: f32,
68 build: impl Fn(usize) -> W + 'static,
69 ) -> Self {
70 Self {
71 count,
72 item_extent,
73 axis: Axis::Vertical,
74 width: Dimension::Auto,
75 height: Dimension::Length(200.0),
76 sized: (false, false),
77 flex_grow: 0.0,
78 physics: None,
79 primary: None,
80 reverse: false,
81 padding: Insets::ZERO,
82 on_scroll: None,
83 grain: 0.0,
84 build: Box::new(move |index| Box::new(build(index)) as Box<dyn Widget<Msg>>),
85 }
86 }
87
88 pub fn axis(mut self, axis: Axis) -> Self {
103 self.axis = axis;
104 self
105 }
106
107 pub fn reverse(mut self) -> Self {
117 self.reverse = true;
118 self
119 }
120
121 pub fn padding(mut self, padding: f32) -> Self {
142 self.padding = Insets::uniform(padding);
143 self
144 }
145
146 pub fn padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
148 self.padding = Insets::new(top, right, bottom, left);
149 self
150 }
151
152 pub fn physics(mut self, physics: ScrollPhysics) -> Self {
155 self.physics = Some(physics);
156 self
157 }
158
159 pub fn primary(mut self, primary: bool) -> Self {
167 self.primary = Some(primary);
168 self
169 }
170
171 pub fn width(mut self, width: f32) -> Self {
173 self.width = Dimension::Length(width);
174 self.sized.0 = true;
175 self
176 }
177
178 pub fn height(mut self, height: f32) -> Self {
180 self.height = Dimension::Length(height);
181 self.sized.1 = true;
182 self
183 }
184
185 pub fn flex(mut self, grow: f32) -> Self {
187 self.flex_grow = grow;
188 self
189 }
190
191 pub fn on_scroll<R: crate::callback::IntoMsg<Msg>>(
203 mut self,
204 on_scroll: impl Fn(crate::ScrollPosition) -> R + 'static,
205 ) -> Self {
206 self.on_scroll = Some(Box::new(crate::callback::handler1(on_scroll)));
207 self
208 }
209
210 pub fn notify_every(mut self, px: f32) -> Self {
213 self.grain = px.max(0.0);
214 self
215 }
216}
217
218impl<Msg> Widget<Msg> for ListView<Msg> {
219 fn style(&self) -> Style {
220 Style {
221 width: self.width,
222 height: self.height,
223 flex_grow: self.flex_grow,
224 ..Default::default()
225 }
226 }
227
228 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
229 &[]
230 }
231
232 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
233
234 fn on_click(&self) -> Option<Msg> {
235 None
236 }
237
238 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
241 if self.flex_grow > 0.0 {
242 return crate::widget::FillAxes::NONE;
243 }
244 crate::widget::FillAxes {
245 horizontal: !self.sized.0,
246 vertical: !self.sized.1,
247 }
248 }
249
250 fn soft_extent(&self) -> crate::widget::FillAxes {
253 crate::widget::FillAxes {
254 horizontal: false,
255 vertical: !self.sized.1,
256 }
257 }
258
259 fn virtual_list(&self, _viewport: frus_core::Size) -> Option<VirtualList<'_, Msg>> {
260 Some(VirtualList {
261 count: self.count,
262 item_extent: self.item_extent,
263 axis: self.axis,
264 build: &*self.build,
265 })
266 }
267
268 fn scroll_axis(&self) -> Axis {
269 self.axis
270 }
271
272 fn scroll_physics(&self) -> Option<ScrollPhysics> {
273 self.physics.or_else(|| {
274 self.primary
275 .unwrap_or(self.axis == Axis::Vertical)
276 .then_some(ScrollPhysics::ALWAYS)
277 })
278 }
279
280 fn scroll_reverse(&self) -> bool {
281 self.reverse
282 }
283
284 fn scroll_padding(&self) -> Insets {
285 self.padding
286 }
287
288 fn on_scroll(&self, position: crate::ScrollPosition) -> Option<Msg> {
289 self.on_scroll.as_ref().map(|report| report(position))
290 }
291
292 fn scroll_grain(&self) -> f32 {
293 self.grain
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::{build_ui, Container, Runtime, Size};
301 use frus_core::{Color, Primitive};
302
303 #[test]
304 fn only_visible_items_are_built() {
305 use std::cell::Cell;
306 use std::rc::Rc;
307
308 let built = Rc::new(Cell::new(0usize));
310 let counter = built.clone();
311 let list = ListView::<()>::new(5000, 40.0, move |_i| {
312 counter.set(counter.get() + 1);
313 Container::<()>::new()
314 .width(180.0)
315 .height(40.0)
316 .color(Color::rgb(1.0, 0.0, 0.0))
317 })
318 .width(200.0)
319 .height(200.0);
320
321 let _ui = build_ui(
322 &list,
323 Size::new(200.0, 200.0),
324 &Runtime::default(),
325 &Theme::default(),
326 );
327 assert!(
329 built.get() <= 8,
330 "only the visible items are built: {}",
331 built.get()
332 );
333 assert!(
334 built.get() >= 5,
335 "at least the visible window: {}",
336 built.get()
337 );
338 }
339
340 #[test]
347 fn an_item_is_handed_the_list_s_width() {
348 let list = ListView::<()>::new(20, 40.0, |i| {
349 Container::<()>::new()
350 .height(40.0)
351 .color(Color::rgb(1.0, 0.0, 0.0))
352 .child(crate::Text::new(format!("Row {i}")))
353 })
354 .width(200.0)
355 .height(200.0);
356 let ui = build_ui(
357 &list,
358 Size::new(200.0, 200.0),
359 &Runtime::default(),
360 &Theme::default(),
361 );
362 let row = ui
363 .scene()
364 .primitives()
365 .iter()
366 .find_map(|p| match p {
367 Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
368 Some(*rect)
369 }
370 _ => None,
371 })
372 .expect("a row is painted");
373 assert_eq!(row.width, 200.0, "the row is as wide as the list: {row:?}");
374 }
375
376 #[test]
379 fn a_reversed_list_builds_from_the_bottom() {
380 let colour = |i: usize| {
381 if i == 0 {
382 Color::rgb(1.0, 0.0, 0.0)
383 } else {
384 Color::rgb(0.0, 0.0, 1.0)
385 }
386 };
387 let list = ListView::<()>::new(3, 40.0, move |i| {
388 Container::<()>::new().height(40.0).color(colour(i))
389 })
390 .reverse()
391 .width(100.0)
392 .height(200.0);
393 let ui = build_ui(
394 &list,
395 Size::new(100.0, 200.0),
396 &Runtime::default(),
397 &Theme::default(),
398 );
399 let rects: Vec<Rect> = ui
400 .scene()
401 .primitives()
402 .iter()
403 .filter_map(|p| match p {
404 Primitive::Rect { rect, color, .. } if color.b > 0.9 || color.r > 0.9 => {
405 Some(*rect)
406 }
407 _ => None,
408 })
409 .collect();
410 let first = ui
411 .scene()
412 .primitives()
413 .iter()
414 .find_map(|p| match p {
415 Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.b < 0.1 => {
416 Some(*rect)
417 }
418 _ => None,
419 })
420 .expect("item 0 is painted");
421 assert_eq!(
422 first.y + first.height,
423 200.0,
424 "item 0 at the bottom: {first:?}"
425 );
426 assert_eq!(rects.len(), 3, "all three fit");
427 let above = rects
429 .iter()
430 .filter(|r| r.y < first.y)
431 .max_by(|a, b| a.y.total_cmp(&b.y))
432 .expect("something above");
433 assert_eq!(above.y + above.height, first.y);
434 }
435
436 #[test]
439 fn a_short_reversed_list_rests_at_the_bottom() {
440 let plain = ListView::<()>::new(3, 40.0, |_| {
441 Container::<()>::new()
442 .height(40.0)
443 .color(Color::rgb(1.0, 0.0, 0.0))
444 })
445 .width(100.0)
446 .height(200.0);
447 let top_of = |list: &ListView<()>| {
448 let ui = build_ui(
449 list,
450 Size::new(100.0, 200.0),
451 &Runtime::default(),
452 &Theme::default(),
453 );
454 ui.scene()
455 .primitives()
456 .iter()
457 .filter_map(|p| match p {
458 Primitive::Rect { rect, color, .. } if color.r > 0.9 => Some(rect.y),
459 _ => None,
460 })
461 .fold(f32::MAX, f32::min)
462 };
463 assert_eq!(top_of(&plain), 0.0);
464
465 let reversed = ListView::<()>::new(3, 40.0, |_| {
466 Container::<()>::new()
467 .height(40.0)
468 .color(Color::rgb(1.0, 0.0, 0.0))
469 })
470 .reverse()
471 .width(100.0)
472 .height(200.0);
473 assert_eq!(top_of(&reversed), 80.0, "200 - 3 x 40");
474 }
475
476 #[test]
477 fn scroll_max_covers_full_content() {
478 let list = ListView::<()>::new(100, 40.0, |_i| Container::<()>::new().height(40.0))
479 .width(200.0)
480 .height(200.0);
481 let ui = build_ui(
482 &list,
483 Size::new(200.0, 200.0),
484 &Runtime::default(),
485 &Theme::default(),
486 );
487 let maxes = ui.scrollable_maxes();
489 assert_eq!(maxes.len(), 1);
490 assert_eq!(maxes[0].2, 3800.0);
491 }
492
493 #[test]
494 fn builds_a_scene() {
495 let list = ListView::<()>::new(50, 30.0, |i| {
496 Container::<()>::new().height(30.0).color(if i % 2 == 0 {
497 Color::rgb(0.2, 0.2, 0.2)
498 } else {
499 Color::rgb(0.3, 0.3, 0.3)
500 })
501 })
502 .width(200.0)
503 .height(120.0);
504 let ui = build_ui(
505 &list,
506 Size::new(200.0, 120.0),
507 &Runtime::default(),
508 &Theme::default(),
509 );
510 let rects = ui
511 .scene()
512 .primitives()
513 .iter()
514 .filter(|p| matches!(p, Primitive::Rect { .. }))
515 .count();
516 assert!(rects > 0);
517 }
518}
519
520#[cfg(test)]
521mod padding_tests {
522 use super::*;
523 use crate::{build_ui, Container, Runtime, Size};
524 use frus_core::{Color, Primitive};
525
526 const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
527
528 fn items(list: &ListView<()>, size: Size) -> Vec<Rect> {
530 build_ui(list, size, &Runtime::default(), &Theme::default())
531 .scene()
532 .primitives()
533 .iter()
534 .filter_map(|p| match p {
535 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
536 _ => None,
537 })
538 .collect()
539 }
540
541 fn list(count: usize) -> ListView<()> {
542 ListView::<()>::new(count, 40.0, |_| {
543 Container::<()>::new().height(40.0).color(MARK)
544 })
545 .width(100.0)
546 .height(200.0)
547 }
548
549 #[test]
551 fn the_first_item_clears_the_leading_inset() {
552 let plain = items(&list(5), Size::new(100.0, 200.0));
553 assert_eq!((plain[0].y, plain[0].x, plain[0].width), (0.0, 0.0, 100.0));
554
555 let padded = items(
556 &list(5).padding_each(12.0, 8.0, 24.0, 8.0),
557 Size::new(100.0, 200.0),
558 );
559 assert_eq!(padded[0].y, 12.0, "item 0 starts after the top inset");
560 assert_eq!(padded[0].x, 8.0, "and inside the left one");
561 assert_eq!(padded[0].width, 84.0, "the sides come off the width");
562 assert_eq!(padded[1].y, 52.0, "the pitch is unchanged");
563 }
564
565 #[test]
568 fn the_far_inset_is_reachable_rather_than_lost() {
569 let bare = build_ui(
570 &list(5),
571 Size::new(100.0, 200.0),
572 &Runtime::default(),
573 &Theme::default(),
574 );
575 assert_eq!(bare.scroll_regions()[0].max_y, 0.0);
577
578 let padded = build_ui(
579 &list(5).padding_each(12.0, 0.0, 24.0, 0.0),
580 Size::new(100.0, 200.0),
581 &Runtime::default(),
582 &Theme::default(),
583 );
584 assert_eq!(
585 padded.scroll_regions()[0].max_y,
586 36.0,
587 "both insets joined the content"
588 );
589 }
590
591 #[test]
594 fn a_reversed_list_clears_its_bottom_inset() {
595 let padded = items(
596 &list(3).reverse().padding_each(12.0, 0.0, 24.0, 0.0),
597 Size::new(100.0, 200.0),
598 );
599 let first = padded
600 .iter()
601 .copied()
602 .max_by(|a, b| a.y.total_cmp(&b.y))
603 .expect("item 0 is painted");
604 assert_eq!(
605 first.y + first.height,
606 176.0,
607 "the bottom of item 0 sits one bottom inset above the viewport's"
608 );
609 }
610
611 #[test]
613 fn the_padding_does_not_widen_the_window() {
614 let built = items(&list(5000).padding(16.0), Size::new(100.0, 200.0));
615 assert!(built.len() <= 8, "only the visible items: {}", built.len());
616 }
617}
618
619#[cfg(test)]
621mod across_tests {
622 use super::*;
623 use crate::{build_ui, Container, Runtime, Size};
624 use frus_core::{Color, Primitive, Rect};
625
626 const MARK: Color = Color::rgb(0.0, 1.0, 0.0);
627
628 fn shelf(list: &ListView<()>, size: Size) -> Vec<Rect> {
630 build_ui(list, size, &Runtime::default(), &Theme::default())
631 .scene()
632 .primitives()
633 .iter()
634 .filter_map(|p| match p {
635 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
636 _ => None,
637 })
638 .collect()
639 }
640
641 fn shelf_of(count: usize) -> ListView<()> {
643 ListView::<()>::new(count, 50.0, |_| Container::<()>::new().color(MARK))
644 .axis(Axis::Horizontal)
645 .width(200.0)
646 .height(100.0)
647 }
648
649 #[test]
651 fn the_items_run_across_rather_than_down() {
652 let cards = shelf(&shelf_of(3), Size::new(200.0, 100.0));
653 assert_eq!(cards.len(), 3);
654 for (i, card) in cards.iter().enumerate() {
655 assert_eq!(card.x, i as f32 * 50.0, "card {i} sits along the row");
656 assert_eq!(card.y, 0.0, "and none of them moves down");
657 assert_eq!(card.width, 50.0, "the extent is a width here");
658 }
659 }
660
661 #[test]
666 fn an_item_is_handed_the_shelf_s_height() {
667 let cards = shelf(&shelf_of(2), Size::new(200.0, 100.0));
668 assert_eq!(cards[0].height, 100.0);
669 }
670
671 #[test]
674 fn only_the_visible_cards_are_built() {
675 use std::cell::RefCell;
676 use std::rc::Rc;
677
678 let built = Rc::new(RefCell::new(Vec::new()));
679 let seen = built.clone();
680 let list = ListView::<()>::new(200, 50.0, move |i| {
681 seen.borrow_mut().push(i);
682 Container::<()>::new().color(MARK)
683 })
684 .axis(Axis::Horizontal)
685 .width(200.0)
686 .height(100.0);
687 let _ = build_ui(
688 &list,
689 Size::new(200.0, 100.0),
690 &Runtime::default(),
691 &Theme::default(),
692 );
693 assert_eq!(
694 *built.borrow(),
695 vec![0, 1, 2, 3],
696 "four fit, four are built"
697 );
698 }
699
700 #[test]
704 fn it_scrolls_along_x_and_not_along_y() {
705 let ui = build_ui(
706 &shelf_of(10),
707 Size::new(200.0, 100.0),
708 &Runtime::default(),
709 &Theme::default(),
710 );
711 let maxes = ui.scrollable_maxes();
713 assert_eq!(maxes.len(), 1);
714 assert_eq!(maxes[0].1, 300.0, "it scrolls along x");
715 assert_eq!(maxes[0].2, 0.0, "and not along y");
716 }
717
718 #[test]
721 fn the_insets_follow_the_axis() {
722 let padded = shelf(
723 &shelf_of(3).padding_each(10.0, 4.0, 6.0, 8.0),
724 Size::new(200.0, 100.0),
725 );
726 assert_eq!(padded[0].x, 8.0, "the left inset leads");
727 assert_eq!(padded[0].y, 10.0, "the top inset is a cross inset here");
728 assert_eq!(
729 padded[0].height,
730 100.0 - 10.0 - 6.0,
731 "top and bottom come off"
732 );
733 assert_eq!(padded[0].width, 50.0, "the extent is untouched by padding");
734 }
735
736 #[test]
740 fn a_reversed_shelf_starts_at_the_far_edge() {
741 let cards = shelf(&shelf_of(3).reverse(), Size::new(200.0, 100.0));
742 assert_eq!(
743 cards[0].x + cards[0].width,
744 200.0,
745 "card 0 ends at the edge"
746 );
747 assert_eq!(cards[1].x + cards[1].width, 150.0, "card 1 is before it");
748 }
749
750 #[test]
756 fn scrolling_it_moves_the_window_along_x() {
757 let mut runtime = Runtime::default();
758 runtime
759 .scroll
760 .insert(crate::interaction::WidgetId::ROOT, (120.0, 0.0));
761 let list = shelf_of(10);
762 let cards: Vec<Rect> =
763 build_ui(&list, Size::new(200.0, 100.0), &runtime, &Theme::default())
764 .scene()
765 .primitives()
766 .iter()
767 .filter_map(|p| match p {
768 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
769 _ => None,
770 })
771 .collect();
772 assert_eq!(cards.first().map(|c| c.x), Some(-20.0));
775 assert!(
776 cards.iter().all(|c| c.y == 0.0),
777 "and nothing drifted down the cross axis"
778 );
779 }
780
781 #[test]
783 fn a_list_that_says_nothing_still_runs_down() {
784 let rows = shelf(
785 &ListView::<()>::new(3, 50.0, |_| Container::<()>::new().color(MARK))
786 .width(200.0)
787 .height(100.0),
788 Size::new(200.0, 100.0),
789 );
790 assert_eq!(rows[0].y, 0.0);
791 assert_eq!(rows[1].y, 50.0, "the second is below, not beside");
792 assert_eq!(rows[0].width, 200.0, "and the width is handed over whole");
793 }
794}
795
796#[cfg(test)]
798mod primary_tests {
799 use super::*;
800 use crate::physics::UserScroll;
801 use crate::{build_ui, Runtime, Size, Theme};
802
803 fn short() -> ListView<()> {
805 ListView::new(3, 20.0, |_| crate::Container::<()>::new().height(20.0))
806 .width(200.0)
807 .height(200.0)
808 }
809
810 fn area(list: &ListView<()>) -> crate::ui::Scrollable {
812 let ui = build_ui(
813 list,
814 Size::new(200.0, 200.0),
815 &Runtime::default(),
816 &Theme::default(),
817 );
818 ui.scroll_regions()[0]
819 }
820
821 #[test]
824 fn a_vertical_list_is_always_scrollable() {
825 assert_eq!(
826 Widget::<()>::scroll_physics(&short()),
827 Some(ScrollPhysics::ALWAYS)
828 );
829 let page = area(&short());
830 assert_eq!(page.physics.user(), UserScroll::Always);
831 assert!(
832 page.takes_user_scroll(true),
833 "a drag along it, with nothing to scroll"
834 );
835 assert!(!page.takes_user_scroll(false), "not across it");
836
837 let across = short().axis(Axis::Horizontal);
838 assert_eq!(Widget::<()>::scroll_physics(&across), None);
839 assert!(
840 !area(&across).takes_user_scroll(false),
841 "a short row takes no drag"
842 );
843 }
844
845 #[test]
847 fn primary_and_physics_say_otherwise() {
848 let quiet = short().primary(false);
849 assert_eq!(Widget::<()>::scroll_physics(&quiet), None);
850 assert!(!area(&quiet).takes_user_scroll(true));
851
852 let row = short().axis(Axis::Horizontal).primary(true);
853 assert_eq!(
854 Widget::<()>::scroll_physics(&row),
855 Some(ScrollPhysics::ALWAYS)
856 );
857
858 let clamped = short().physics(ScrollPhysics::CLAMPING);
859 assert_eq!(
860 Widget::<()>::scroll_physics(&clamped),
861 Some(ScrollPhysics::CLAMPING)
862 );
863 assert!(
864 !area(&clamped).takes_user_scroll(true),
865 "the physics given decide"
866 );
867 let never = short().primary(true).physics(ScrollPhysics::NEVER);
868 assert_eq!(
869 Widget::<()>::scroll_physics(&never),
870 Some(ScrollPhysics::NEVER)
871 );
872 }
873}