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 reverse: bool,
47 padding: Insets,
49 on_scroll: Option<Box<dyn Fn(crate::ScrollPosition) -> Msg>>,
51 grain: f32,
53 build: Box<dyn Fn(usize) -> Box<dyn Widget<Msg>>>,
54}
55
56impl<Msg> ListView<Msg> {
57 pub fn new<W: Widget<Msg> + 'static>(
64 count: usize,
65 item_extent: f32,
66 build: impl Fn(usize) -> W + 'static,
67 ) -> Self {
68 Self {
69 count,
70 item_extent,
71 axis: Axis::Vertical,
72 width: Dimension::Auto,
73 height: Dimension::Length(200.0),
74 sized: (false, false),
75 flex_grow: 0.0,
76 physics: None,
77 reverse: false,
78 padding: Insets::ZERO,
79 on_scroll: None,
80 grain: 0.0,
81 build: Box::new(move |index| Box::new(build(index)) as Box<dyn Widget<Msg>>),
82 }
83 }
84
85 pub fn axis(mut self, axis: Axis) -> Self {
100 self.axis = axis;
101 self
102 }
103
104 pub fn reverse(mut self) -> Self {
114 self.reverse = true;
115 self
116 }
117
118 pub fn padding(mut self, padding: f32) -> Self {
139 self.padding = Insets::uniform(padding);
140 self
141 }
142
143 pub fn padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
145 self.padding = Insets::new(top, right, bottom, left);
146 self
147 }
148
149 pub fn physics(mut self, physics: ScrollPhysics) -> Self {
152 self.physics = Some(physics);
153 self
154 }
155
156 pub fn width(mut self, width: f32) -> Self {
158 self.width = Dimension::Length(width);
159 self.sized.0 = true;
160 self
161 }
162
163 pub fn height(mut self, height: f32) -> Self {
165 self.height = Dimension::Length(height);
166 self.sized.1 = true;
167 self
168 }
169
170 pub fn flex(mut self, grow: f32) -> Self {
172 self.flex_grow = grow;
173 self
174 }
175
176 pub fn on_scroll<R: crate::callback::IntoMsg<Msg>>(
188 mut self,
189 on_scroll: impl Fn(crate::ScrollPosition) -> R + 'static,
190 ) -> Self {
191 self.on_scroll = Some(Box::new(crate::callback::handler1(on_scroll)));
192 self
193 }
194
195 pub fn notify_every(mut self, px: f32) -> Self {
198 self.grain = px.max(0.0);
199 self
200 }
201}
202
203impl<Msg> Widget<Msg> for ListView<Msg> {
204 fn style(&self) -> Style {
205 Style {
206 width: self.width,
207 height: self.height,
208 flex_grow: self.flex_grow,
209 ..Default::default()
210 }
211 }
212
213 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
214 &[]
215 }
216
217 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
218
219 fn on_click(&self) -> Option<Msg> {
220 None
221 }
222
223 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
226 if self.flex_grow > 0.0 {
227 return crate::widget::FillAxes::NONE;
228 }
229 crate::widget::FillAxes {
230 horizontal: !self.sized.0,
231 vertical: !self.sized.1,
232 }
233 }
234
235 fn soft_extent(&self) -> crate::widget::FillAxes {
238 crate::widget::FillAxes {
239 horizontal: false,
240 vertical: !self.sized.1,
241 }
242 }
243
244 fn virtual_list(&self, _viewport: frus_core::Size) -> Option<VirtualList<'_, Msg>> {
245 Some(VirtualList {
246 count: self.count,
247 item_extent: self.item_extent,
248 axis: self.axis,
249 build: &*self.build,
250 })
251 }
252
253 fn scroll_axis(&self) -> Axis {
254 self.axis
255 }
256
257 fn scroll_physics(&self) -> Option<ScrollPhysics> {
258 self.physics
259 }
260
261 fn scroll_reverse(&self) -> bool {
262 self.reverse
263 }
264
265 fn scroll_padding(&self) -> Insets {
266 self.padding
267 }
268
269 fn on_scroll(&self, position: crate::ScrollPosition) -> Option<Msg> {
270 self.on_scroll.as_ref().map(|report| report(position))
271 }
272
273 fn scroll_grain(&self) -> f32 {
274 self.grain
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::{build_ui, Container, Runtime, Size};
282 use frus_core::{Color, Primitive};
283
284 #[test]
285 fn only_visible_items_are_built() {
286 use std::cell::Cell;
287 use std::rc::Rc;
288
289 let built = Rc::new(Cell::new(0usize));
291 let counter = built.clone();
292 let list = ListView::<()>::new(5000, 40.0, move |_i| {
293 counter.set(counter.get() + 1);
294 Container::<()>::new()
295 .width(180.0)
296 .height(40.0)
297 .color(Color::rgb(1.0, 0.0, 0.0))
298 })
299 .width(200.0)
300 .height(200.0);
301
302 let _ui = build_ui(
303 &list,
304 Size::new(200.0, 200.0),
305 &Runtime::default(),
306 &Theme::default(),
307 );
308 assert!(
310 built.get() <= 8,
311 "only the visible items are built: {}",
312 built.get()
313 );
314 assert!(
315 built.get() >= 5,
316 "at least the visible window: {}",
317 built.get()
318 );
319 }
320
321 #[test]
328 fn an_item_is_handed_the_list_s_width() {
329 let list = ListView::<()>::new(20, 40.0, |i| {
330 Container::<()>::new()
331 .height(40.0)
332 .color(Color::rgb(1.0, 0.0, 0.0))
333 .child(crate::Text::new(format!("Row {i}")))
334 })
335 .width(200.0)
336 .height(200.0);
337 let ui = build_ui(
338 &list,
339 Size::new(200.0, 200.0),
340 &Runtime::default(),
341 &Theme::default(),
342 );
343 let row = ui
344 .scene()
345 .primitives()
346 .iter()
347 .find_map(|p| match p {
348 Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
349 Some(*rect)
350 }
351 _ => None,
352 })
353 .expect("a row is painted");
354 assert_eq!(row.width, 200.0, "the row is as wide as the list: {row:?}");
355 }
356
357 #[test]
360 fn a_reversed_list_builds_from_the_bottom() {
361 let colour = |i: usize| {
362 if i == 0 {
363 Color::rgb(1.0, 0.0, 0.0)
364 } else {
365 Color::rgb(0.0, 0.0, 1.0)
366 }
367 };
368 let list = ListView::<()>::new(3, 40.0, move |i| {
369 Container::<()>::new().height(40.0).color(colour(i))
370 })
371 .reverse()
372 .width(100.0)
373 .height(200.0);
374 let ui = build_ui(
375 &list,
376 Size::new(100.0, 200.0),
377 &Runtime::default(),
378 &Theme::default(),
379 );
380 let rects: Vec<Rect> = ui
381 .scene()
382 .primitives()
383 .iter()
384 .filter_map(|p| match p {
385 Primitive::Rect { rect, color, .. } if color.b > 0.9 || color.r > 0.9 => {
386 Some(*rect)
387 }
388 _ => None,
389 })
390 .collect();
391 let first = ui
392 .scene()
393 .primitives()
394 .iter()
395 .find_map(|p| match p {
396 Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.b < 0.1 => {
397 Some(*rect)
398 }
399 _ => None,
400 })
401 .expect("item 0 is painted");
402 assert_eq!(
403 first.y + first.height,
404 200.0,
405 "item 0 at the bottom: {first:?}"
406 );
407 assert_eq!(rects.len(), 3, "all three fit");
408 let above = rects
410 .iter()
411 .filter(|r| r.y < first.y)
412 .max_by(|a, b| a.y.total_cmp(&b.y))
413 .expect("something above");
414 assert_eq!(above.y + above.height, first.y);
415 }
416
417 #[test]
420 fn a_short_reversed_list_rests_at_the_bottom() {
421 let plain = ListView::<()>::new(3, 40.0, |_| {
422 Container::<()>::new()
423 .height(40.0)
424 .color(Color::rgb(1.0, 0.0, 0.0))
425 })
426 .width(100.0)
427 .height(200.0);
428 let top_of = |list: &ListView<()>| {
429 let ui = build_ui(
430 list,
431 Size::new(100.0, 200.0),
432 &Runtime::default(),
433 &Theme::default(),
434 );
435 ui.scene()
436 .primitives()
437 .iter()
438 .filter_map(|p| match p {
439 Primitive::Rect { rect, color, .. } if color.r > 0.9 => Some(rect.y),
440 _ => None,
441 })
442 .fold(f32::MAX, f32::min)
443 };
444 assert_eq!(top_of(&plain), 0.0);
445
446 let reversed = ListView::<()>::new(3, 40.0, |_| {
447 Container::<()>::new()
448 .height(40.0)
449 .color(Color::rgb(1.0, 0.0, 0.0))
450 })
451 .reverse()
452 .width(100.0)
453 .height(200.0);
454 assert_eq!(top_of(&reversed), 80.0, "200 - 3 x 40");
455 }
456
457 #[test]
458 fn scroll_max_covers_full_content() {
459 let list = ListView::<()>::new(100, 40.0, |_i| Container::<()>::new().height(40.0))
460 .width(200.0)
461 .height(200.0);
462 let ui = build_ui(
463 &list,
464 Size::new(200.0, 200.0),
465 &Runtime::default(),
466 &Theme::default(),
467 );
468 let maxes = ui.scrollable_maxes();
470 assert_eq!(maxes.len(), 1);
471 assert_eq!(maxes[0].2, 3800.0);
472 }
473
474 #[test]
475 fn builds_a_scene() {
476 let list = ListView::<()>::new(50, 30.0, |i| {
477 Container::<()>::new().height(30.0).color(if i % 2 == 0 {
478 Color::rgb(0.2, 0.2, 0.2)
479 } else {
480 Color::rgb(0.3, 0.3, 0.3)
481 })
482 })
483 .width(200.0)
484 .height(120.0);
485 let ui = build_ui(
486 &list,
487 Size::new(200.0, 120.0),
488 &Runtime::default(),
489 &Theme::default(),
490 );
491 let rects = ui
492 .scene()
493 .primitives()
494 .iter()
495 .filter(|p| matches!(p, Primitive::Rect { .. }))
496 .count();
497 assert!(rects > 0);
498 }
499}
500
501#[cfg(test)]
502mod padding_tests {
503 use super::*;
504 use crate::{build_ui, Container, Runtime, Size};
505 use frus_core::{Color, Primitive};
506
507 const MARK: Color = Color::rgb(1.0, 0.0, 0.0);
508
509 fn items(list: &ListView<()>, size: Size) -> Vec<Rect> {
511 build_ui(list, size, &Runtime::default(), &Theme::default())
512 .scene()
513 .primitives()
514 .iter()
515 .filter_map(|p| match p {
516 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
517 _ => None,
518 })
519 .collect()
520 }
521
522 fn list(count: usize) -> ListView<()> {
523 ListView::<()>::new(count, 40.0, |_| {
524 Container::<()>::new().height(40.0).color(MARK)
525 })
526 .width(100.0)
527 .height(200.0)
528 }
529
530 #[test]
532 fn the_first_item_clears_the_leading_inset() {
533 let plain = items(&list(5), Size::new(100.0, 200.0));
534 assert_eq!((plain[0].y, plain[0].x, plain[0].width), (0.0, 0.0, 100.0));
535
536 let padded = items(
537 &list(5).padding_each(12.0, 8.0, 24.0, 8.0),
538 Size::new(100.0, 200.0),
539 );
540 assert_eq!(padded[0].y, 12.0, "item 0 starts after the top inset");
541 assert_eq!(padded[0].x, 8.0, "and inside the left one");
542 assert_eq!(padded[0].width, 84.0, "the sides come off the width");
543 assert_eq!(padded[1].y, 52.0, "the pitch is unchanged");
544 }
545
546 #[test]
549 fn the_far_inset_is_reachable_rather_than_lost() {
550 let bare = build_ui(
551 &list(5),
552 Size::new(100.0, 200.0),
553 &Runtime::default(),
554 &Theme::default(),
555 );
556 assert_eq!(bare.scroll_regions()[0].max_y, 0.0);
558
559 let padded = build_ui(
560 &list(5).padding_each(12.0, 0.0, 24.0, 0.0),
561 Size::new(100.0, 200.0),
562 &Runtime::default(),
563 &Theme::default(),
564 );
565 assert_eq!(
566 padded.scroll_regions()[0].max_y,
567 36.0,
568 "both insets joined the content"
569 );
570 }
571
572 #[test]
575 fn a_reversed_list_clears_its_bottom_inset() {
576 let padded = items(
577 &list(3).reverse().padding_each(12.0, 0.0, 24.0, 0.0),
578 Size::new(100.0, 200.0),
579 );
580 let first = padded
581 .iter()
582 .copied()
583 .max_by(|a, b| a.y.total_cmp(&b.y))
584 .expect("item 0 is painted");
585 assert_eq!(
586 first.y + first.height,
587 176.0,
588 "the bottom of item 0 sits one bottom inset above the viewport's"
589 );
590 }
591
592 #[test]
594 fn the_padding_does_not_widen_the_window() {
595 let built = items(&list(5000).padding(16.0), Size::new(100.0, 200.0));
596 assert!(built.len() <= 8, "only the visible items: {}", built.len());
597 }
598}
599
600#[cfg(test)]
602mod across_tests {
603 use super::*;
604 use crate::{build_ui, Container, Runtime, Size};
605 use frus_core::{Color, Primitive, Rect};
606
607 const MARK: Color = Color::rgb(0.0, 1.0, 0.0);
608
609 fn shelf(list: &ListView<()>, size: Size) -> Vec<Rect> {
611 build_ui(list, size, &Runtime::default(), &Theme::default())
612 .scene()
613 .primitives()
614 .iter()
615 .filter_map(|p| match p {
616 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
617 _ => None,
618 })
619 .collect()
620 }
621
622 fn shelf_of(count: usize) -> ListView<()> {
624 ListView::<()>::new(count, 50.0, |_| Container::<()>::new().color(MARK))
625 .axis(Axis::Horizontal)
626 .width(200.0)
627 .height(100.0)
628 }
629
630 #[test]
632 fn the_items_run_across_rather_than_down() {
633 let cards = shelf(&shelf_of(3), Size::new(200.0, 100.0));
634 assert_eq!(cards.len(), 3);
635 for (i, card) in cards.iter().enumerate() {
636 assert_eq!(card.x, i as f32 * 50.0, "card {i} sits along the row");
637 assert_eq!(card.y, 0.0, "and none of them moves down");
638 assert_eq!(card.width, 50.0, "the extent is a width here");
639 }
640 }
641
642 #[test]
647 fn an_item_is_handed_the_shelf_s_height() {
648 let cards = shelf(&shelf_of(2), Size::new(200.0, 100.0));
649 assert_eq!(cards[0].height, 100.0);
650 }
651
652 #[test]
655 fn only_the_visible_cards_are_built() {
656 use std::cell::RefCell;
657 use std::rc::Rc;
658
659 let built = Rc::new(RefCell::new(Vec::new()));
660 let seen = built.clone();
661 let list = ListView::<()>::new(200, 50.0, move |i| {
662 seen.borrow_mut().push(i);
663 Container::<()>::new().color(MARK)
664 })
665 .axis(Axis::Horizontal)
666 .width(200.0)
667 .height(100.0);
668 let _ = build_ui(
669 &list,
670 Size::new(200.0, 100.0),
671 &Runtime::default(),
672 &Theme::default(),
673 );
674 assert_eq!(
675 *built.borrow(),
676 vec![0, 1, 2, 3],
677 "four fit, four are built"
678 );
679 }
680
681 #[test]
685 fn it_scrolls_along_x_and_not_along_y() {
686 let ui = build_ui(
687 &shelf_of(10),
688 Size::new(200.0, 100.0),
689 &Runtime::default(),
690 &Theme::default(),
691 );
692 let maxes = ui.scrollable_maxes();
694 assert_eq!(maxes.len(), 1);
695 assert_eq!(maxes[0].1, 300.0, "it scrolls along x");
696 assert_eq!(maxes[0].2, 0.0, "and not along y");
697 }
698
699 #[test]
702 fn the_insets_follow_the_axis() {
703 let padded = shelf(
704 &shelf_of(3).padding_each(10.0, 4.0, 6.0, 8.0),
705 Size::new(200.0, 100.0),
706 );
707 assert_eq!(padded[0].x, 8.0, "the left inset leads");
708 assert_eq!(padded[0].y, 10.0, "the top inset is a cross inset here");
709 assert_eq!(
710 padded[0].height,
711 100.0 - 10.0 - 6.0,
712 "top and bottom come off"
713 );
714 assert_eq!(padded[0].width, 50.0, "the extent is untouched by padding");
715 }
716
717 #[test]
721 fn a_reversed_shelf_starts_at_the_far_edge() {
722 let cards = shelf(&shelf_of(3).reverse(), Size::new(200.0, 100.0));
723 assert_eq!(
724 cards[0].x + cards[0].width,
725 200.0,
726 "card 0 ends at the edge"
727 );
728 assert_eq!(cards[1].x + cards[1].width, 150.0, "card 1 is before it");
729 }
730
731 #[test]
737 fn scrolling_it_moves_the_window_along_x() {
738 let mut runtime = Runtime::default();
739 runtime
740 .scroll
741 .insert(crate::interaction::WidgetId::ROOT, (120.0, 0.0));
742 let list = shelf_of(10);
743 let cards: Vec<Rect> =
744 build_ui(&list, Size::new(200.0, 100.0), &runtime, &Theme::default())
745 .scene()
746 .primitives()
747 .iter()
748 .filter_map(|p| match p {
749 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
750 _ => None,
751 })
752 .collect();
753 assert_eq!(cards.first().map(|c| c.x), Some(-20.0));
756 assert!(
757 cards.iter().all(|c| c.y == 0.0),
758 "and nothing drifted down the cross axis"
759 );
760 }
761
762 #[test]
764 fn a_list_that_says_nothing_still_runs_down() {
765 let rows = shelf(
766 &ListView::<()>::new(3, 50.0, |_| Container::<()>::new().color(MARK))
767 .width(200.0)
768 .height(100.0),
769 Size::new(200.0, 100.0),
770 );
771 assert_eq!(rows[0].y, 0.0);
772 assert_eq!(rows[1].y, 50.0, "the second is below, not beside");
773 assert_eq!(rows[0].width, 200.0, "and the width is handed over whole");
774 }
775}