1use std::{hash::Hash, ops::Range, sync::Arc};
7
8use tessera_ui::{
9 AxisConstraint, CallbackWith, Color, ComputedData, Constraint, Dp, FocusDirection,
10 LayoutResult, MeasurementError, Modifier, ParentConstraint, Px, PxPosition, RenderSlotWith,
11 State, key,
12 layout::{LayoutChild, MeasureScope, PlacementResult, PlacementScope, layout},
13 modifier::FocusModifierExt as _,
14 remember, tessera,
15};
16
17use crate::{
18 alignment::{CrossAxisAlignment, MainAxisAlignment},
19 layout_flex::{cross_axis_child_constraint, px_mul},
20 lazy_shared::{
21 FenwickTree, LazyItemsPlan as LazySlotPlan, LazyItemsSlot as LazySlot, MeasuredMain,
22 ZeroLayout, clamp_reported_main, hash_key,
23 },
24 scrollable::{
25 ScrollBarBehavior, ScrollBarLayout, ScrollableController, scrollable,
26 synchronize_scroll_density,
27 },
28};
29
30const DEFAULT_VIEWPORT_LINES: usize = 8;
31
32#[derive(Clone, PartialEq, Debug)]
34pub enum GridCells {
35 Fixed(usize),
37 Adaptive(Dp),
39 FixedSize(Dp),
41}
42
43impl GridCells {
44 pub fn fixed(count: usize) -> Self {
46 Self::Fixed(count)
47 }
48
49 pub fn adaptive(min_size: Dp) -> Self {
51 Self::Adaptive(min_size)
52 }
53
54 pub fn fixed_size(size: Dp) -> Self {
56 Self::FixedSize(size)
57 }
58
59 fn resolve_slots(&self, available: Px, spacing: Px) -> Vec<Px> {
60 match self {
61 Self::Fixed(count) => {
62 let count = (*count).max(1);
63 calculate_cells_cross_axis_size(available, count, spacing)
64 }
65 Self::Adaptive(min_size) => {
66 let min_px = ensure_positive_px(Px::from(*min_size));
67 let spacing = sanitize_spacing(spacing);
68 let available_i32 = available.0.max(0);
69 let min_i32 = min_px.0.max(1);
70 let spacing_i32 = spacing.0.max(0);
71 let count =
72 ((available_i32 + spacing_i32) / (min_i32 + spacing_i32)).max(1) as usize;
73 calculate_cells_cross_axis_size(available, count, spacing)
74 }
75 Self::FixedSize(size) => {
76 let cell_size = ensure_positive_px(Px::from(*size));
77 let spacing = sanitize_spacing(spacing);
78 let available_i32 = available.0.max(0);
79 let cell_i32 = cell_size.0.max(1);
80 let spacing_i32 = spacing.0.max(0);
81 if cell_i32 + spacing_i32 < available_i32 + spacing_i32 {
82 let count =
83 ((available_i32 + spacing_i32) / (cell_i32 + spacing_i32)).max(1) as usize;
84 vec![cell_size; count]
85 } else {
86 vec![available.max(Px::ZERO)]
87 }
88 }
89 }
90 }
91}
92
93impl Default for GridCells {
94 fn default() -> Self {
95 Self::fixed(2)
96 }
97}
98
99pub struct LazyGridController {
101 cache: LazyGridCache,
102}
103
104impl Default for LazyGridController {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110impl LazyGridController {
111 pub fn new() -> Self {
113 Self {
114 cache: LazyGridCache::default(),
115 }
116 }
117}
118
119#[derive(Clone, Default, PartialEq)]
121pub struct LazyGridContent {
122 slots: Vec<LazySlot>,
123}
124
125impl LazyGridContent {
126 fn push(&mut self, slot: LazySlot) {
127 self.slots.push(slot);
128 }
129}
130
131#[derive(Clone, PartialEq, Eq)]
132struct VisibleGridLayoutItem {
133 line_index: usize,
134 slot_index: usize,
135}
136
137#[derive(Clone)]
138struct LazyGridLayout {
139 axis: LazyGridAxis,
140 item_alignment: CrossAxisAlignment,
141 estimated_line_main: Px,
142 main_spacing: Px,
143 max_viewport_main: Option<Px>,
144 padding_main: Px,
145 padding_cross: Px,
146 viewport_limit: Px,
147 line_range: Range<usize>,
148 slots: GridSlots,
149 visible_items: Vec<VisibleGridLayoutItem>,
150 scroll_offset: Px,
151 controller: State<LazyGridController>,
152 scroll_controller: State<ScrollableController>,
153}
154
155impl PartialEq for LazyGridLayout {
156 fn eq(&self, other: &Self) -> bool {
157 self.axis == other.axis
158 && self.item_alignment == other.item_alignment
159 && self.estimated_line_main == other.estimated_line_main
160 && self.main_spacing == other.main_spacing
161 && self.max_viewport_main == other.max_viewport_main
162 && self.padding_main == other.padding_main
163 && self.padding_cross == other.padding_cross
164 && self.viewport_limit == other.viewport_limit
165 && self.line_range == other.line_range
166 && self.slots == other.slots
167 && self.visible_items == other.visible_items
168 && self.scroll_offset == other.scroll_offset
169 }
170}
171
172impl tessera_ui::LayoutModifierNode for LazyGridLayout {
173 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
174 let mut result = LayoutResult::default();
175 let children = input.children();
176 if children.len() != self.visible_items.len() {
177 return Err(MeasurementError::MeasureFnFailed(
178 "Lazy grid measured child count mismatch".into(),
179 ));
180 }
181
182 let mut measured_items = Vec::with_capacity(self.visible_items.len());
183 let line_count = self.line_range.end.saturating_sub(self.line_range.start);
184 let mut line_max = vec![Px::ZERO; line_count];
185
186 for (visible, child) in self.visible_items.iter().zip(children.iter()) {
187 let cell_cross = self
188 .slots
189 .sizes
190 .get(visible.slot_index)
191 .copied()
192 .unwrap_or(Px::ZERO);
193 let child_constraint = self.axis.child_constraint(cell_cross, self.item_alignment);
194 let child_size = child.measure(input, &child_constraint)?;
195 let line_idx = visible.line_index - self.line_range.start;
196 if let Some(line_value) = line_max.get_mut(line_idx) {
197 *line_value = (*line_value).max(self.axis.main(&child_size.size()));
198 }
199 measured_items.push(MeasuredGridItem {
200 child: *child,
201 line_index: visible.line_index,
202 slot_index: visible.slot_index,
203 size: child_size.size(),
204 });
205 }
206
207 let measurements_changed = self.slots.sizes.iter().all(|size| *size > Px::ZERO)
209 && self.controller.with(|c| {
210 line_max.iter().enumerate().any(|(offset, size)| {
211 c.cache
212 .measured_line_main
213 .get(self.line_range.start + offset)
214 .copied()
215 .flatten()
216 .map(MeasuredMain::pixels)
217 != Some(*size)
218 })
219 });
220 if measurements_changed {
221 self.controller.with_mut(|c| {
222 for (offset, line_main) in line_max.iter().enumerate() {
223 let line_index = self.line_range.start + offset;
224 c.cache.record_line_measurement(
225 line_index,
226 *line_main,
227 self.estimated_line_main,
228 );
229 }
230 });
231 }
232
233 let (placements, total_main) = self.controller.with(|c| {
234 let mut placements = Vec::with_capacity(measured_items.len());
235 for item in &measured_items {
236 let line_offset = c.cache.offset_for_line(
237 item.line_index,
238 self.estimated_line_main,
239 self.main_spacing,
240 );
241 let cell_cross = self
242 .slots
243 .sizes
244 .get(item.slot_index)
245 .copied()
246 .unwrap_or(Px::ZERO);
247 let cell_offset = compute_cell_offset(
248 cell_cross,
249 self.axis.cross(&item.size),
250 self.item_alignment,
251 );
252 let cross_offset = self.padding_cross
253 + self
254 .slots
255 .positions
256 .get(item.slot_index)
257 .copied()
258 .unwrap_or(Px::ZERO)
259 + cell_offset;
260 let position = self.axis.position(
261 line_offset + self.padding_main - self.scroll_offset,
262 cross_offset,
263 );
264 placements.push((item.child, position));
265 }
266
267 let total_main = c
268 .cache
269 .total_main_size(self.estimated_line_main, self.main_spacing);
270 Ok::<_, MeasurementError>((placements, total_main))
271 })?;
272
273 let total_main_with_padding = total_main + self.padding_main + self.padding_main;
274 let cross_with_padding = self.slots.cross_size + self.padding_cross + self.padding_cross;
275 let size = self
276 .axis
277 .pack_size(total_main_with_padding, cross_with_padding);
278 if self.scroll_controller.with(|c| c.child_size() != size) {
279 self.scroll_controller
280 .with_mut(|c| c.override_child_size(size));
281 }
282
283 let reported_main = clamp_reported_main(
284 total_main_with_padding,
285 self.viewport_limit,
286 self.axis.constraint_max(input.parent_constraint()),
287 self.max_viewport_main,
288 );
289
290 for (child, position) in placements {
291 result.place_child(child, position);
292 }
293
294 Ok(result.with_size(self.axis.pack_size(reported_main, cross_with_padding)))
295 }
296
297 fn measure_eq(&self, other: &Self) -> bool {
298 self.axis == other.axis
299 && self.item_alignment == other.item_alignment
300 && self.estimated_line_main == other.estimated_line_main
301 && self.main_spacing == other.main_spacing
302 && self.max_viewport_main == other.max_viewport_main
303 && self.padding_main == other.padding_main
304 && self.padding_cross == other.padding_cross
305 && self.viewport_limit == other.viewport_limit
306 && self.line_range == other.line_range
307 && self.slots == other.slots
308 && self.visible_items == other.visible_items
309 }
310
311 fn placement_eq(&self, other: &Self) -> bool {
312 self.measure_eq(other) && self.scroll_offset == other.scroll_offset
313 }
314
315 fn place_children(
316 &self,
317 input: &PlacementScope<'_>,
318 _inner: &PlacementResult,
319 ) -> Option<PlacementResult> {
320 let children = input.children();
321 if children.len() != self.visible_items.len() {
322 return None;
323 }
324
325 let mut result = LayoutResult::default();
326 for (visible, child) in self.visible_items.iter().zip(children.iter()) {
327 let line_offset = self.controller.with(|c| {
328 c.cache.offset_for_line(
329 visible.line_index,
330 self.estimated_line_main,
331 self.main_spacing,
332 )
333 });
334 let cell_cross = self
335 .slots
336 .sizes
337 .get(visible.slot_index)
338 .copied()
339 .unwrap_or(Px::ZERO);
340 let child_size = child.size();
341 let cell_offset = compute_cell_offset(
342 cell_cross,
343 self.axis.cross(&child_size),
344 self.item_alignment,
345 );
346 let cross_offset = self.padding_cross
347 + self
348 .slots
349 .positions
350 .get(visible.slot_index)
351 .copied()
352 .unwrap_or(Px::ZERO)
353 + cell_offset;
354 let position = self.axis.position(
355 line_offset + self.padding_main - self.scroll_offset,
356 cross_offset,
357 );
358 result.place_child(*child, position);
359 }
360
361 Some(result.into_placement_result())
362 }
363}
364
365#[tessera]
434pub fn lazy_vertical_grid(
435 modifier: Option<Modifier>,
436 scroll_smoothing: Option<f32>,
437 scrollbar_behavior: Option<ScrollBarBehavior>,
438 scrollbar_track_color: Option<Color>,
439 scrollbar_thumb_color: Option<Color>,
440 scrollbar_thumb_hover_color: Option<Color>,
441 scrollbar_layout: Option<ScrollBarLayout>,
442 columns: Option<GridCells>,
443 main_axis_spacing: Option<Dp>,
444 cross_axis_spacing: Option<Dp>,
445 cross_axis_alignment: Option<MainAxisAlignment>,
446 item_alignment: Option<CrossAxisAlignment>,
447 overscan: Option<usize>,
448 estimated_item_size: Option<Dp>,
449 content_padding: Option<Dp>,
450 max_viewport_main: Option<Px>,
451 controller: Option<State<LazyGridController>>,
452 #[prop(skip_setter)] content: Option<LazyGridContent>,
453) {
454 let scroll_smoothing = scroll_smoothing.unwrap_or(crate::scrollable::DEFAULT_SCROLL_SMOOTHING);
455 let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
456 let scrollbar_layout = scrollbar_layout.unwrap_or_default();
457 let columns = columns.unwrap_or_default();
458 let main_axis_spacing = main_axis_spacing.unwrap_or(Dp(0.0));
459 let cross_axis_spacing = cross_axis_spacing.unwrap_or(Dp(0.0));
460 let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
461 let item_alignment = item_alignment.unwrap_or_default();
462 let overscan = overscan.unwrap_or(0);
463 let estimated_item_size = estimated_item_size.unwrap_or(Dp(0.0));
464 let content_padding = content_padding.unwrap_or(Dp(0.0));
465 let content = content.unwrap_or_default();
466 let controller = match controller {
467 Some(value) => value,
468 None => remember(LazyGridController::new),
469 };
470 let scroll_controller = remember(ScrollableController::default);
471 lazy_grid_slots(
472 LazyGridSlotsArgs {
473 modifier: modifier.unwrap_or_default(),
474 scroll_smoothing,
475 scrollbar_behavior,
476 scrollbar_track_color,
477 scrollbar_thumb_color,
478 scrollbar_thumb_hover_color,
479 scrollbar_layout,
480 grid_cells: columns,
481 main_axis_spacing,
482 cross_axis_spacing,
483 cross_axis_alignment,
484 item_alignment,
485 overscan,
486 estimated_item_size,
487 content_padding,
488 max_viewport_main,
489 controller,
490 scroll_controller,
491 slots: content.slots,
492 },
493 LazyGridAxis::Vertical,
494 );
495}
496
497#[derive(Clone, PartialEq)]
498struct LazyGridSlotsArgs {
499 modifier: Modifier,
500 scroll_smoothing: f32,
501 scrollbar_behavior: ScrollBarBehavior,
502 scrollbar_track_color: Option<Color>,
503 scrollbar_thumb_color: Option<Color>,
504 scrollbar_thumb_hover_color: Option<Color>,
505 scrollbar_layout: ScrollBarLayout,
506 grid_cells: GridCells,
507 main_axis_spacing: Dp,
508 cross_axis_spacing: Dp,
509 cross_axis_alignment: MainAxisAlignment,
510 item_alignment: CrossAxisAlignment,
511 overscan: usize,
512 estimated_item_size: Dp,
513 content_padding: Dp,
514 max_viewport_main: Option<Px>,
515 controller: State<LazyGridController>,
516 scroll_controller: State<ScrollableController>,
517 slots: Vec<LazySlot>,
518}
519
520#[tessera]
521fn lazy_grid_slots(args: LazyGridSlotsArgs, axis: LazyGridAxis) {
522 let scroll_controller = args.scroll_controller;
523 let main_axis_spacing = sanitize_spacing(Px::from(args.main_axis_spacing));
524 let cross_axis_spacing = sanitize_spacing(Px::from(args.cross_axis_spacing));
525 let estimated_line_main = ensure_positive_px(Px::from(args.estimated_item_size));
526 let padding_main = sanitize_spacing(Px::from(args.content_padding));
527 let padding_cross = sanitize_spacing(Px::from(args.content_padding));
528 scrollable()
529 .modifier(args.modifier)
530 .vertical(axis == LazyGridAxis::Vertical)
531 .horizontal(axis == LazyGridAxis::Horizontal)
532 .apply_child_offset(false)
533 .scroll_smoothing(args.scroll_smoothing)
534 .scrollbar_behavior(args.scrollbar_behavior)
535 .scrollbar_layout(args.scrollbar_layout)
536 .controller(scroll_controller)
537 .scrollbar_track_color_optional(args.scrollbar_track_color)
538 .scrollbar_thumb_color_optional(args.scrollbar_thumb_color)
539 .scrollbar_thumb_hover_color_optional(args.scrollbar_thumb_hover_color)
540 .child(move || {
541 lazy_grid_view()
542 .axis(axis)
543 .grid_cells(args.grid_cells.clone())
544 .main_axis_spacing(main_axis_spacing)
545 .cross_axis_spacing(cross_axis_spacing)
546 .cross_axis_alignment(args.cross_axis_alignment)
547 .item_alignment(args.item_alignment)
548 .estimated_line_main(estimated_line_main)
549 .overscan(args.overscan)
550 .padding_main(padding_main)
551 .padding_cross(padding_cross)
552 .slots(args.slots.clone())
553 .controller(args.controller)
554 .scroll_controller(scroll_controller)
555 .max_viewport_main_optional(args.max_viewport_main);
556 });
557}
558#[tessera]
627pub fn lazy_horizontal_grid(
628 modifier: Option<Modifier>,
629 scroll_smoothing: Option<f32>,
630 scrollbar_behavior: Option<ScrollBarBehavior>,
631 scrollbar_track_color: Option<Color>,
632 scrollbar_thumb_color: Option<Color>,
633 scrollbar_thumb_hover_color: Option<Color>,
634 scrollbar_layout: Option<ScrollBarLayout>,
635 rows: Option<GridCells>,
636 main_axis_spacing: Option<Dp>,
637 cross_axis_spacing: Option<Dp>,
638 cross_axis_alignment: Option<MainAxisAlignment>,
639 item_alignment: Option<CrossAxisAlignment>,
640 overscan: Option<usize>,
641 estimated_item_size: Option<Dp>,
642 content_padding: Option<Dp>,
643 max_viewport_main: Option<Px>,
644 controller: Option<State<LazyGridController>>,
645 #[prop(skip_setter)] content: Option<LazyGridContent>,
646) {
647 let scroll_smoothing = scroll_smoothing.unwrap_or(crate::scrollable::DEFAULT_SCROLL_SMOOTHING);
648 let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
649 let scrollbar_layout = scrollbar_layout.unwrap_or_default();
650 let rows = rows.unwrap_or_default();
651 let main_axis_spacing = main_axis_spacing.unwrap_or(Dp(0.0));
652 let cross_axis_spacing = cross_axis_spacing.unwrap_or(Dp(0.0));
653 let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
654 let item_alignment = item_alignment.unwrap_or_default();
655 let overscan = overscan.unwrap_or(0);
656 let estimated_item_size = estimated_item_size.unwrap_or(Dp(0.0));
657 let content_padding = content_padding.unwrap_or(Dp(0.0));
658 let content = content.unwrap_or_default();
659 let controller = match controller {
660 Some(value) => value,
661 None => remember(LazyGridController::new),
662 };
663 let scroll_controller = remember(ScrollableController::default);
664 lazy_grid_slots(
665 LazyGridSlotsArgs {
666 modifier: modifier.unwrap_or_default(),
667 scroll_smoothing,
668 scrollbar_behavior,
669 scrollbar_track_color,
670 scrollbar_thumb_color,
671 scrollbar_thumb_hover_color,
672 scrollbar_layout,
673 grid_cells: rows,
674 main_axis_spacing,
675 cross_axis_spacing,
676 cross_axis_alignment,
677 item_alignment,
678 overscan,
679 estimated_item_size,
680 content_padding,
681 max_viewport_main,
682 controller,
683 scroll_controller,
684 slots: content.slots,
685 },
686 LazyGridAxis::Horizontal,
687 );
688}
689
690crate::lazy_shared::impl_lazy_builder!(LazyVerticalGridBuilder, LazyGridContent);
691crate::lazy_shared::impl_lazy_builder!(LazyHorizontalGridBuilder, LazyGridContent);
692
693#[derive(Clone, Copy, PartialEq, Eq, Default)]
694enum LazyGridAxis {
695 #[default]
696 Vertical,
697 Horizontal,
698}
699
700#[tessera]
701fn lazy_grid_view(
702 axis: Option<LazyGridAxis>,
703 grid_cells: Option<GridCells>,
704 main_axis_spacing: Option<Px>,
705 cross_axis_spacing: Option<Px>,
706 cross_axis_alignment: Option<MainAxisAlignment>,
707 item_alignment: Option<CrossAxisAlignment>,
708 estimated_line_main: Option<Px>,
709 overscan: Option<usize>,
710 max_viewport_main: Option<Px>,
711 padding_main: Option<Px>,
712 padding_cross: Option<Px>,
713 controller: Option<State<LazyGridController>>,
714 slots: Option<Vec<LazySlot>>,
715 scroll_controller: Option<State<ScrollableController>>,
716) {
717 let axis = axis.unwrap_or_default();
718 let grid_cells = grid_cells.unwrap_or_default();
719 let main_axis_spacing = main_axis_spacing.unwrap_or(Px::ZERO);
720 let cross_axis_spacing = cross_axis_spacing.unwrap_or(Px::ZERO);
721 let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
722 let item_alignment = item_alignment.unwrap_or_default();
723 let estimated_line_main = estimated_line_main.unwrap_or(Px::ZERO);
724 let overscan = overscan.unwrap_or(0);
725 let padding_main = padding_main.unwrap_or(Px::ZERO);
726 let padding_cross = padding_cross.unwrap_or(Px::ZERO);
727 let slots = slots.unwrap_or_default();
728 let controller = controller.expect("lazy_grid_view requires controller");
729 let scroll_controller = scroll_controller.expect("lazy_grid_view requires scroll_controller");
730 synchronize_scroll_density(scroll_controller);
731
732 let plan = LazySlotPlan::new(slots.clone());
733 let total_count = plan.total_count();
734
735 let visible_size = scroll_controller.with(|s| s.visible_size());
736 let available_cross = axis.visible_cross(visible_size);
737 let available_cross = (available_cross - padding_cross * 2).max(Px::ZERO);
738 let grid_slots = resolve_grid_slots(
739 available_cross,
740 &grid_cells,
741 cross_axis_spacing,
742 cross_axis_alignment,
743 );
744 let slots_per_line = grid_slots.len();
745
746 let cache_config_changed = controller.with(|c| {
747 c.cache.total_items != total_count
748 || c.cache.slots_per_line != slots_per_line.max(1)
749 || c.cache.estimated_main != estimated_line_main
750 || c.cache.density != tessera_ui::dp::scale_factor()
751 });
752 if cache_config_changed {
753 controller.with_mut(|c| {
754 c.cache
755 .configure(total_count, slots_per_line, estimated_line_main)
756 });
757 }
758 let total_main = controller.with(|c| {
759 c.cache
760 .total_main_size(estimated_line_main, main_axis_spacing)
761 });
762 let total_main_with_padding = total_main + padding_main + padding_main;
763 let cross_with_padding = grid_slots.cross_size + padding_cross + padding_cross;
764 let child_size = axis.pack_size(total_main_with_padding, cross_with_padding);
765 if scroll_controller.with(|c| c.child_size() != child_size) {
766 scroll_controller.with_mut(|c| c.override_child_size(child_size));
767 }
768
769 let scroll_offset = axis.scroll_offset(scroll_controller.with(|s| s.child_position()));
770 let viewport_span = resolve_viewport_span(
771 axis.visible_span(visible_size),
772 estimated_line_main,
773 main_axis_spacing,
774 );
775 let viewport_span = (viewport_span - (padding_main * 2)).max(Px::ZERO);
776
777 let visible_plan = controller.with(|c| {
778 compute_visible_items(
779 &plan,
780 &c.cache,
781 total_count,
782 slots_per_line,
783 scroll_offset,
784 viewport_span,
785 overscan,
786 estimated_line_main,
787 main_axis_spacing,
788 )
789 });
790
791 if visible_plan.items.is_empty() {
792 layout().policy(ZeroLayout);
793 return;
794 }
795
796 let focus_modifier = lazy_grid_focus_beyond_bounds_modifier(LazyGridFocusArgs {
797 axis,
798 controller,
799 scroll_controller,
800 estimated_line_main,
801 main_axis_spacing,
802 total_main,
803 viewport_span,
804 visible_line_range: visible_plan.line_range.clone(),
805 total_lines: controller.with(|c| c.cache.line_count()),
806 });
807
808 let viewport_limit = viewport_span + padding_main + padding_main;
809 let visible_layout_items = visible_plan
810 .items
811 .iter()
812 .map(|item| VisibleGridLayoutItem {
813 line_index: item.line_index,
814 slot_index: item.slot_index,
815 })
816 .collect();
817
818 let items = visible_plan.items;
819 let line_range = visible_plan.line_range.clone();
820 layout()
821 .modifier(focus_modifier)
822 .policy(LazyGridLayout {
823 axis,
824 item_alignment,
825 estimated_line_main,
826 main_spacing: main_axis_spacing,
827 max_viewport_main,
828 padding_main,
829 padding_cross,
830 viewport_limit,
831 line_range,
832 slots: grid_slots.clone(),
833 visible_items: visible_layout_items,
834 scroll_offset,
835 controller,
836 scroll_controller,
837 })
838 .child(move || {
839 for child in &items {
840 let child = child.clone();
841 key(child.key_hash, || {
842 lazy_grid_item_host()
843 .builder_handle_shared(child.builder)
844 .local_index(child.local_index);
845 });
846 }
847 });
848}
849
850#[tessera]
851fn lazy_grid_item_host(builder_handle: Option<RenderSlotWith<usize>>, local_index: Option<usize>) {
852 let local_index = local_index.unwrap_or(0);
853 if let Some(builder_handle) = builder_handle {
854 builder_handle.render(local_index);
855 }
856}
857
858fn resolve_viewport_span(current: Px, estimated: Px, spacing: Px) -> Px {
859 if current > Px::ZERO {
860 current
861 } else {
862 let per_line = estimated + spacing;
863 px_mul(per_line, DEFAULT_VIEWPORT_LINES.max(1))
864 }
865}
866
867#[allow(clippy::too_many_arguments)]
868fn compute_visible_items(
869 plan: &LazySlotPlan,
870 cache: &LazyGridCache,
871 total_count: usize,
872 slots_per_line: usize,
873 scroll_offset: Px,
874 viewport_span: Px,
875 overscan: usize,
876 estimated_line_main: Px,
877 spacing: Px,
878) -> VisibleGridPlan {
879 let total_lines = cache.line_count();
880 if total_count == 0 || slots_per_line == 0 || total_lines == 0 {
881 return VisibleGridPlan::empty();
882 }
883
884 let mut start_line = cache.line_index_for_offset(scroll_offset, estimated_line_main, spacing);
885 let end_target = scroll_offset + viewport_span;
886 let mut end_line = cache.line_index_for_offset(end_target, estimated_line_main, spacing) + 1;
887
888 start_line = start_line.saturating_sub(overscan);
889 end_line = (end_line + overscan).min(total_lines);
890 if start_line >= end_line {
891 end_line = (start_line + 1).min(total_lines);
892 start_line = start_line.saturating_sub(1);
893 }
894
895 let mut items = Vec::new();
896 for line in start_line..end_line {
897 let start_index = line * slots_per_line;
898 let end_index = (start_index + slots_per_line).min(total_count);
899 for index in start_index..end_index {
900 if let Some((slot, local_index)) = plan.resolve(index) {
901 let key_hash = if let Some(provider) = &slot.key_provider {
902 provider.call(local_index)
903 } else {
904 hash_key(index)
905 };
906
907 items.push(VisibleGridItem {
908 local_index,
909 line_index: line,
910 slot_index: index - start_index,
911 builder: slot.builder,
912 key_hash,
913 });
914 }
915 }
916 }
917
918 VisibleGridPlan {
919 items,
920 line_range: start_line..end_line,
921 }
922}
923
924pub(crate) fn compute_cell_offset(
925 cell_cross: Px,
926 child_cross: Px,
927 alignment: CrossAxisAlignment,
928) -> Px {
929 match alignment {
930 CrossAxisAlignment::Start | CrossAxisAlignment::Stretch => Px::ZERO,
931 CrossAxisAlignment::Center => (cell_cross - child_cross).max(Px::ZERO) / 2,
932 CrossAxisAlignment::End => (cell_cross - child_cross).max(Px::ZERO),
933 }
934}
935
936#[derive(Clone, Copy)]
937enum FocusScrollDirection {
938 Forward,
939 Backward,
940}
941
942impl LazyGridAxis {
943 fn main(&self, size: &ComputedData) -> Px {
944 match self {
945 Self::Vertical => size.height,
946 Self::Horizontal => size.width,
947 }
948 }
949
950 fn cross(&self, size: &ComputedData) -> Px {
951 match self {
952 Self::Vertical => size.width,
953 Self::Horizontal => size.height,
954 }
955 }
956
957 fn pack_size(&self, main: Px, cross: Px) -> ComputedData {
958 match self {
959 Self::Vertical => ComputedData {
960 width: cross,
961 height: main,
962 },
963 Self::Horizontal => ComputedData {
964 width: main,
965 height: cross,
966 },
967 }
968 }
969
970 fn position(&self, main: Px, cross: Px) -> PxPosition {
971 match self {
972 Self::Vertical => PxPosition::new(cross, main),
973 Self::Horizontal => PxPosition::new(main, cross),
974 }
975 }
976
977 fn visible_span(&self, size: ComputedData) -> Px {
978 match self {
979 Self::Vertical => size.height,
980 Self::Horizontal => size.width,
981 }
982 }
983
984 fn visible_cross(&self, size: ComputedData) -> Px {
985 match self {
986 Self::Vertical => size.width,
987 Self::Horizontal => size.height,
988 }
989 }
990
991 fn scroll_position(&self, offset: Px) -> PxPosition {
992 match self {
993 Self::Vertical => PxPosition::new(Px::ZERO, -offset),
994 Self::Horizontal => PxPosition::new(-offset, Px::ZERO),
995 }
996 }
997
998 fn scroll_offset(&self, position: PxPosition) -> Px {
999 match self {
1000 Self::Vertical => Px((-position.y).max(0.0) as i32),
1001 Self::Horizontal => Px((-position.x).max(0.0) as i32),
1002 }
1003 }
1004
1005 fn focus_scroll_direction(&self, direction: FocusDirection) -> Option<FocusScrollDirection> {
1006 match (self, direction) {
1007 (_, FocusDirection::Next | FocusDirection::Enter) => {
1008 Some(FocusScrollDirection::Forward)
1009 }
1010 (_, FocusDirection::Previous | FocusDirection::Exit) => {
1011 Some(FocusScrollDirection::Backward)
1012 }
1013 (Self::Vertical, FocusDirection::Down) => Some(FocusScrollDirection::Forward),
1014 (Self::Vertical, FocusDirection::Up) => Some(FocusScrollDirection::Backward),
1015 (Self::Horizontal, FocusDirection::Right) => Some(FocusScrollDirection::Forward),
1016 (Self::Horizontal, FocusDirection::Left) => Some(FocusScrollDirection::Backward),
1017 _ => None,
1018 }
1019 }
1020
1021 fn child_constraint(&self, cross: Px, alignment: CrossAxisAlignment) -> Constraint {
1022 let cross = cross.max(Px::ZERO);
1023 let cross_constraint = cross_axis_child_constraint(AxisConstraint::exact(cross), alignment);
1027
1028 match self {
1029 Self::Vertical => Constraint::new(cross_constraint, AxisConstraint::NONE),
1030 Self::Horizontal => Constraint::new(AxisConstraint::NONE, cross_constraint),
1031 }
1032 }
1033
1034 fn constraint_max(&self, constraint: ParentConstraint<'_>) -> Option<Px> {
1035 match self {
1036 Self::Vertical => constraint.height().resolve_max(),
1037 Self::Horizontal => constraint.width().resolve_max(),
1038 }
1039 }
1040}
1041#[derive(Clone, PartialEq, Eq)]
1042pub(crate) struct GridSlots {
1043 pub(crate) sizes: Vec<Px>,
1044 pub(crate) positions: Vec<Px>,
1045 pub(crate) cross_size: Px,
1046}
1047
1048impl GridSlots {
1049 pub(crate) fn len(&self) -> usize {
1050 self.sizes.len()
1051 }
1052}
1053
1054pub(crate) fn resolve_grid_slots(
1055 available_cross: Px,
1056 grid_cells: &GridCells,
1057 spacing: Px,
1058 alignment: MainAxisAlignment,
1059) -> GridSlots {
1060 let spacing = sanitize_spacing(spacing);
1061 let sizes = grid_cells.resolve_slots(available_cross, spacing);
1062 if sizes.is_empty() {
1063 return GridSlots {
1064 sizes: Vec::new(),
1065 positions: Vec::new(),
1066 cross_size: Px::ZERO,
1067 };
1068 }
1069
1070 let total_sizes = sizes.iter().copied().fold(Px::ZERO, |acc, size| acc + size);
1071 let base_spacing = if sizes.len() > 1 {
1072 px_mul(spacing, sizes.len().saturating_sub(1))
1073 } else {
1074 Px::ZERO
1075 };
1076 let content_size = total_sizes + base_spacing;
1077 let available_space = (available_cross - content_size).max(Px::ZERO);
1078 let (start_cross, extra_spacing) =
1079 calculate_alignment_offsets(available_space, sizes.len(), alignment);
1080 let gap = spacing + extra_spacing;
1081
1082 let mut positions = Vec::with_capacity(sizes.len());
1083 let mut cursor = start_cross;
1084 for (pos, size) in sizes.iter().enumerate() {
1085 positions.push(cursor);
1086 cursor += *size;
1087 if pos + 1 < sizes.len() {
1088 cursor += gap;
1089 }
1090 }
1091
1092 let cross_size =
1093 positions.last().copied().unwrap_or(Px::ZERO) + sizes.last().copied().unwrap_or(Px::ZERO);
1094
1095 GridSlots {
1096 sizes,
1097 positions,
1098 cross_size,
1099 }
1100}
1101
1102fn calculate_alignment_offsets(
1103 available_space: Px,
1104 count: usize,
1105 alignment: MainAxisAlignment,
1106) -> (Px, Px) {
1107 match alignment {
1108 MainAxisAlignment::Start => (Px::ZERO, Px::ZERO),
1109 MainAxisAlignment::Center => (available_space / 2, Px::ZERO),
1110 MainAxisAlignment::End => (available_space, Px::ZERO),
1111 MainAxisAlignment::SpaceEvenly => {
1112 if count > 0 {
1113 let s = available_space / (count as i32 + 1);
1114 (s, s)
1115 } else {
1116 (Px::ZERO, Px::ZERO)
1117 }
1118 }
1119 MainAxisAlignment::SpaceBetween => {
1120 if count > 1 {
1121 (Px::ZERO, available_space / (count as i32 - 1))
1122 } else if count == 1 {
1123 (available_space / 2, Px::ZERO)
1124 } else {
1125 (Px::ZERO, Px::ZERO)
1126 }
1127 }
1128 MainAxisAlignment::SpaceAround => {
1129 if count > 0 {
1130 let s = available_space / (count as i32);
1131 (s / 2, s)
1132 } else {
1133 (Px::ZERO, Px::ZERO)
1134 }
1135 }
1136 }
1137}
1138
1139#[derive(Clone, PartialEq)]
1140struct MeasuredGridItem {
1141 child: LayoutChild,
1142 line_index: usize,
1143 slot_index: usize,
1144 size: ComputedData,
1145}
1146
1147#[derive(Clone, PartialEq)]
1148struct VisibleGridItem {
1149 local_index: usize,
1150 line_index: usize,
1151 slot_index: usize,
1152 builder: RenderSlotWith<usize>,
1153 key_hash: u64,
1154}
1155
1156#[derive(Clone, PartialEq)]
1157struct VisibleGridPlan {
1158 items: Vec<VisibleGridItem>,
1159 line_range: Range<usize>,
1160}
1161
1162impl VisibleGridPlan {
1163 fn empty() -> Self {
1164 Self {
1165 items: Vec::new(),
1166 line_range: 0..0,
1167 }
1168 }
1169}
1170
1171struct LazyGridFocusArgs {
1172 axis: LazyGridAxis,
1173 controller: State<LazyGridController>,
1174 scroll_controller: State<ScrollableController>,
1175 estimated_line_main: Px,
1176 main_axis_spacing: Px,
1177 total_main: Px,
1178 viewport_span: Px,
1179 visible_line_range: Range<usize>,
1180 total_lines: usize,
1181}
1182
1183fn lazy_grid_focus_beyond_bounds_modifier(args: LazyGridFocusArgs) -> Modifier {
1184 let current_scroll_offset = args
1185 .axis
1186 .scroll_offset(args.scroll_controller.with(|s| s.child_position()));
1187 let max_scroll = (args.total_main - args.viewport_span).max(Px::ZERO);
1188 Modifier::new().focus_beyond_bounds_handler(CallbackWith::new(move |direction| {
1189 let Some(scroll_direction) = args.axis.focus_scroll_direction(direction) else {
1190 return false;
1191 };
1192 if args.total_lines == 0 || args.viewport_span <= Px::ZERO {
1193 return false;
1194 }
1195
1196 let target_line = match scroll_direction {
1197 FocusScrollDirection::Forward => {
1198 if args.visible_line_range.end >= args.total_lines {
1199 return false;
1200 }
1201 args.visible_line_range.end
1202 }
1203 FocusScrollDirection::Backward => {
1204 let Some(line) = args.visible_line_range.start.checked_sub(1) else {
1205 return false;
1206 };
1207 line
1208 }
1209 };
1210
1211 let (target_offset, target_main) = args.controller.with(|c| {
1212 (
1213 c.cache.offset_for_line(
1214 target_line,
1215 args.estimated_line_main,
1216 args.main_axis_spacing,
1217 ),
1218 c.cache
1219 .measured_line_main
1220 .get(target_line)
1221 .copied()
1222 .flatten()
1223 .map(MeasuredMain::pixels)
1224 .unwrap_or(args.estimated_line_main),
1225 )
1226 });
1227
1228 let desired_scroll = match scroll_direction {
1229 FocusScrollDirection::Forward => {
1230 (target_offset + target_main - args.viewport_span).max(Px::ZERO)
1231 }
1232 FocusScrollDirection::Backward => target_offset,
1233 }
1234 .min(max_scroll);
1235
1236 if desired_scroll == current_scroll_offset {
1237 return false;
1238 }
1239
1240 let position = args.axis.scroll_position(desired_scroll);
1241 args.scroll_controller
1242 .with_mut(|c| c.set_scroll_position(position));
1243 true
1244 }))
1245}
1246#[derive(PartialEq, Default)]
1247struct LazyGridCache {
1248 total_items: usize,
1249 slots_per_line: usize,
1250 estimated_main: Px,
1251 density: f64,
1252 measured_line_main: Vec<Option<MeasuredMain>>,
1253 fenwick: FenwickTree,
1254}
1255
1256impl LazyGridCache {
1257 fn configure(&mut self, count: usize, slots_per_line: usize, estimated: Px) {
1258 let density = tessera_ui::dp::scale_factor();
1259 let density_changed = self.density != density;
1260 let slots_per_line = slots_per_line.max(1);
1261 if self.total_items != count || self.slots_per_line != slots_per_line {
1262 self.total_items = count;
1263 self.slots_per_line = slots_per_line;
1264 let lines = line_count(count, self.slots_per_line);
1265 self.measured_line_main = vec![None; lines];
1266 self.fenwick.resize(lines);
1267 }
1268 if self.estimated_main != estimated || density_changed {
1269 self.estimated_main = estimated;
1270 self.density = density;
1271 self.fenwick.resize(self.measured_line_main.len());
1272 for (index, actual) in self.measured_line_main.iter_mut().enumerate() {
1273 if let Some(actual) = actual {
1274 if density_changed {
1275 actual.rebase(density);
1276 }
1277 self.fenwick.update(index, actual.pixels() - estimated);
1278 }
1279 }
1280 }
1281 }
1282
1283 fn line_count(&self) -> usize {
1284 self.measured_line_main.len()
1285 }
1286
1287 fn record_line_measurement(&mut self, index: usize, actual: Px, estimated: Px) {
1288 if index >= self.measured_line_main.len() {
1289 return;
1290 }
1291 let previous = self.measured_line_main[index].map(MeasuredMain::pixels);
1292 if previous == Some(actual) {
1293 return;
1294 }
1295
1296 let prev_delta = previous.map(|val| val - estimated).unwrap_or(Px::ZERO);
1297 let new_delta = actual - estimated;
1298 let delta_change = new_delta - prev_delta;
1299 self.measured_line_main[index] = Some(MeasuredMain::new(actual, self.density));
1300 self.fenwick.update(index, delta_change);
1301 }
1302
1303 fn offset_for_line(&self, index: usize, estimated: Px, spacing: Px) -> Px {
1304 if self.measured_line_main.is_empty() {
1305 return Px::ZERO;
1306 }
1307 let clamped = index.min(self.measured_line_main.len());
1308 let spacing_contrib = px_mul(spacing, clamped);
1309 let estimated_contrib = px_mul(estimated, clamped);
1310 spacing_contrib + estimated_contrib + self.fenwick.prefix_sum(clamped)
1311 }
1312
1313 fn total_main_size(&self, estimated: Px, spacing: Px) -> Px {
1314 let line_count = self.measured_line_main.len();
1315 if line_count == 0 {
1316 return Px::ZERO;
1317 }
1318 let spacing_contrib = px_mul(spacing, line_count.saturating_sub(1));
1319 let estimated_contrib = px_mul(estimated, line_count);
1320 spacing_contrib + estimated_contrib + self.fenwick.prefix_sum(line_count)
1321 }
1322
1323 fn line_index_for_offset(&self, offset: Px, estimated: Px, spacing: Px) -> usize {
1324 if self.measured_line_main.is_empty() {
1325 return 0;
1326 }
1327
1328 let mut low = 0usize;
1329 let mut high = self.measured_line_main.len();
1330 while low < high {
1331 let mid = (low + high) / 2;
1332 if self.offset_for_line(mid, estimated, spacing) <= offset {
1333 low = mid + 1;
1334 } else {
1335 high = mid;
1336 }
1337 }
1338 low.saturating_sub(1)
1339 .min(self.measured_line_main.len().saturating_sub(1))
1340 }
1341}
1342
1343fn line_count(item_count: usize, slots_per_line: usize) -> usize {
1344 if item_count == 0 || slots_per_line == 0 {
1345 0
1346 } else {
1347 item_count.div_ceil(slots_per_line)
1348 }
1349}
1350
1351fn calculate_cells_cross_axis_size(available: Px, slot_count: usize, spacing: Px) -> Vec<Px> {
1352 let slot_count = slot_count.max(1);
1353 let spacing_total = px_mul(spacing, slot_count.saturating_sub(1));
1354 let available_without_spacing = (available - spacing_total).max(Px::ZERO);
1355 let base = available_without_spacing.0 / slot_count as i32;
1356 let remainder = available_without_spacing.0 % slot_count as i32;
1357 (0..slot_count)
1358 .map(|index| {
1359 let extra = if (index as i32) < remainder { 1 } else { 0 };
1360 Px(base + extra)
1361 })
1362 .collect()
1363}
1364
1365fn ensure_positive_px(px: Px) -> Px {
1366 if px <= Px::ZERO { Px(1) } else { px }
1367}
1368
1369fn sanitize_spacing(px: Px) -> Px {
1370 if px < Px::ZERO { Px::ZERO } else { px }
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375 use tessera_ui::RenderSlotWith;
1376
1377 use tessera_ui::{
1378 AxisConstraint, ComputedData, Dp, LayoutResult, MeasurementError, Modifier,
1379 NoopDrawModifier, Px, PxPosition, State,
1380 layout::{MeasureScope, layout},
1381 receive_frame_nanos, remember, tessera,
1382 };
1383
1384 use crate::{
1385 alignment::{CrossAxisAlignment, MainAxisAlignment},
1386 modifier::{ModifierExt as _, SemanticsArgs},
1387 scrollable::{ScrollBarBehavior, ScrollableController, scrollable},
1388 text::text,
1389 theme::{MaterialTheme, material_theme},
1390 };
1391
1392 use super::{
1393 GridCells, LazyGridAxis, LazyGridCache, LazyGridController, lazy_grid_view,
1394 lazy_vertical_grid,
1395 };
1396
1397 #[tessera]
1398 fn collapsed_viewport_fixture(
1399 observed: tessera_ui::testing::TestProbe<Option<(State<Dp>, State<LazyGridController>)>>,
1400 ) {
1401 let width = remember(|| Dp(300.0));
1402 let controller = remember(LazyGridController::new);
1403 observed.set(Some((width, controller)));
1404 material_theme().child(move || {
1405 lazy_vertical_grid()
1406 .columns(GridCells::fixed(2))
1407 .modifier(Modifier::new().width(width.get()).height(Dp(240.0)))
1408 .controller(controller)
1409 .cross_axis_spacing(Dp(8.0))
1410 .estimated_item_size(Dp(60.0))
1411 .items(4, |_| {
1412 text("Keep measured grid geometry when the viewport temporarily collapses.");
1413 });
1414 });
1415 }
1416
1417 #[test]
1418 fn collapsed_cells_preserve_measurements_even_with_nonzero_spacing() {
1419 let observed = tessera_ui::testing::TestProbe::default();
1420 let fixture_observed = observed.clone();
1421 let mut session = tessera_ui::testing::start_layout_test_session(
1422 tessera_ui::testing::layout_test(move || {
1423 collapsed_viewport_fixture(fixture_observed.clone());
1424 })
1425 .viewport_px(300, 240),
1426 );
1427 for frame in 0..8 {
1428 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
1429 }
1430 let controller = observed.get().expect("grid handles").1;
1431 let before = controller.with(|state| state.cache.measured_line_main.clone());
1432 assert!(before.iter().all(Option::is_some));
1433 let width = observed.get().expect("grid handles").0;
1434 width.set(Dp(0.0));
1435 for frame in 8..12 {
1436 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
1437 }
1438 assert_eq!(
1439 controller.with(|state| state.cache.measured_line_main.clone()),
1440 before
1441 );
1442 width.set(Dp(300.0));
1443 for frame in 12..16 {
1444 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
1445 }
1446 assert_eq!(
1447 controller.with(|state| state.cache.measured_line_main.clone()),
1448 before
1449 );
1450 }
1451
1452 #[test]
1453 fn density_changes_rebase_unvisited_lines_without_round_trip_drift() {
1454 let runtime = tessera_ui::RuntimeSession::new();
1455 let _scope = runtime.enter();
1456 crate::test_utils::run_isolated_test(
1457 "lazy_grid::tests::density_changes_rebase_unvisited_lines_without_round_trip_drift",
1458 || {
1459 tessera_ui::dp::set_scale_factor(1.0);
1460 let mut cache = LazyGridCache::default();
1461 cache.configure(6, 2, Px(20));
1462 cache.record_line_measurement(0, Px(33), Px(20));
1463 cache.record_line_measurement(2, Px(55), Px(20));
1464 for (density, estimated, spacing, offset, total) in [
1465 (1.25, 25, 5, 46, 144),
1466 (1.5, 30, 6, 55, 173),
1467 (2.0, 40, 8, 74, 232),
1468 (1.0, 20, 4, 37, 116),
1469 ] {
1470 tessera_ui::dp::set_scale_factor(density);
1471 cache.configure(6, 2, Px(estimated));
1472 assert_eq!(
1473 cache.offset_for_line(1, Px(estimated), Px(spacing)),
1474 Px(offset)
1475 );
1476 assert_eq!(cache.total_main_size(Px(estimated), Px(spacing)), Px(total));
1477 }
1478 },
1479 );
1480 }
1481
1482 #[test]
1483 fn estimate_changes_rebase_line_offsets_and_column_changes_clear_measurements() {
1484 let mut cache = LazyGridCache::default();
1485 cache.configure(10, 2, Px(100));
1486 for (index, size) in [(0, 40), (1, 60), (3, 80)] {
1487 cache.record_line_measurement(index, Px(size), Px(100));
1488 }
1489 assert_eq!(cache.total_main_size(Px(100), Px(8)), Px(412));
1490 cache.configure(10, 2, Px(20));
1491 assert_eq!(cache.total_main_size(Px(20), Px(8)), Px(252));
1492 assert_eq!(cache.offset_for_line(2, Px(20), Px(8)), Px(116));
1493 assert_eq!(cache.line_index_for_offset(Px(116), Px(20), Px(8)), 2);
1494 cache.configure(10, 3, Px(20));
1495 assert_eq!(cache.total_main_size(Px(20), Px(8)), Px(104));
1496 }
1497
1498 #[derive(Clone, PartialEq)]
1499 struct FixedTestLayout {
1500 width: i32,
1501 height: i32,
1502 }
1503
1504 impl tessera_ui::LayoutModifierNode for FixedTestLayout {
1505 fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
1506 Ok(LayoutResult::new(ComputedData {
1507 width: Px::new(self.width),
1508 height: Px::new(self.height),
1509 }))
1510 }
1511 }
1512
1513 #[tessera]
1514 fn fixed_test_box(tag: Option<String>, width: Option<i32>, height: Option<i32>) {
1515 let tag = tag.unwrap_or_default();
1516 let width = width.unwrap_or(0);
1517 let height = height.unwrap_or(0);
1518
1519 layout()
1520 .policy(FixedTestLayout { width, height })
1521 .draw(NoopDrawModifier)
1522 .modifier(Modifier::new().semantics(SemanticsArgs {
1523 test_tag: Some(tag),
1524 ..Default::default()
1525 }));
1526 }
1527
1528 #[tessera]
1529 fn lazy_vertical_grid_layout_case() {
1530 let controller = remember(LazyGridController::new);
1531 let scroll_controller = remember(ScrollableController::default);
1532 scroll_controller.with_mut(|c| {
1533 c.set_visible_size_for_test(ComputedData {
1534 width: Px::new(60),
1535 height: Px::new(60),
1536 });
1537 });
1538
1539 lazy_grid_view()
1540 .axis(LazyGridAxis::Vertical)
1541 .grid_cells(GridCells::fixed(2))
1542 .main_axis_spacing(Px::new(3))
1543 .cross_axis_spacing(Px::new(3))
1544 .cross_axis_alignment(MainAxisAlignment::Start)
1545 .item_alignment(CrossAxisAlignment::Start)
1546 .estimated_line_main(Px::new(10))
1547 .overscan(0)
1548 .padding_main(Px::new(4))
1549 .padding_cross(Px::new(4))
1550 .slots(vec![super::LazySlot::items(
1551 4,
1552 RenderSlotWith::new(|index| match index {
1553 0 => {
1554 fixed_test_box()
1555 .tag("lazy_grid_v_first".to_string())
1556 .width(10)
1557 .height(10);
1558 }
1559 1 => {
1560 fixed_test_box()
1561 .tag("lazy_grid_v_second".to_string())
1562 .width(12)
1563 .height(8);
1564 }
1565 2 => {
1566 fixed_test_box()
1567 .tag("lazy_grid_v_third".to_string())
1568 .width(11)
1569 .height(12);
1570 }
1571 _ => {
1572 fixed_test_box()
1573 .tag("lazy_grid_v_fourth".to_string())
1574 .width(9)
1575 .height(9);
1576 }
1577 }),
1578 None,
1579 )])
1580 .controller(controller)
1581 .scroll_controller(scroll_controller);
1582 }
1583
1584 #[tessera]
1585 fn lazy_horizontal_grid_layout_case() {
1586 let controller = remember(LazyGridController::new);
1587 let scroll_controller = remember(ScrollableController::default);
1588 scroll_controller.with_mut(|c| {
1589 c.set_visible_size_for_test(ComputedData {
1590 width: Px::new(70),
1591 height: Px::new(40),
1592 });
1593 });
1594
1595 lazy_grid_view()
1596 .axis(LazyGridAxis::Horizontal)
1597 .grid_cells(GridCells::fixed(2))
1598 .main_axis_spacing(Px::new(3))
1599 .cross_axis_spacing(Px::new(3))
1600 .cross_axis_alignment(MainAxisAlignment::Start)
1601 .item_alignment(CrossAxisAlignment::Start)
1602 .estimated_line_main(Px::new(10))
1603 .overscan(0)
1604 .padding_main(Px::new(4))
1605 .padding_cross(Px::new(4))
1606 .slots(vec![super::LazySlot::items(
1607 4,
1608 RenderSlotWith::new(|index| match index {
1609 0 => {
1610 fixed_test_box()
1611 .tag("lazy_grid_h_first".to_string())
1612 .width(20)
1613 .height(10);
1614 }
1615 1 => {
1616 fixed_test_box()
1617 .tag("lazy_grid_h_second".to_string())
1618 .width(15)
1619 .height(12);
1620 }
1621 2 => {
1622 fixed_test_box()
1623 .tag("lazy_grid_h_third".to_string())
1624 .width(18)
1625 .height(8);
1626 }
1627 _ => {
1628 fixed_test_box()
1629 .tag("lazy_grid_h_fourth".to_string())
1630 .width(16)
1631 .height(9);
1632 }
1633 }),
1634 None,
1635 )])
1636 .controller(controller)
1637 .scroll_controller(scroll_controller);
1638 }
1639
1640 #[tessera]
1641 fn lazy_vertical_grid_scrolled_layout_case() {
1642 let controller = remember(LazyGridController::new);
1643 let scroll_controller = remember(ScrollableController::default);
1644 let target_position = PxPosition::new(Px::ZERO, Px::new(-5));
1645 scroll_controller.with_mut(|c| {
1646 c.set_visible_size_for_test(ComputedData {
1647 width: Px::new(60),
1648 height: Px::new(28),
1649 });
1650 c.set_scroll_position(target_position);
1651 });
1652
1653 scrollable()
1654 .scrollbar_behavior(ScrollBarBehavior::Hidden)
1655 .modifier(Modifier::new().constrain(
1656 Some(AxisConstraint::exact(Px::new(60))),
1657 Some(AxisConstraint::exact(Px::new(28))),
1658 ))
1659 .vertical(true)
1660 .horizontal(false)
1661 .apply_child_offset(false)
1662 .controller(scroll_controller)
1663 .child(move || {
1664 lazy_grid_view()
1665 .axis(LazyGridAxis::Vertical)
1666 .grid_cells(GridCells::fixed(2))
1667 .main_axis_spacing(Px::new(3))
1668 .cross_axis_spacing(Px::new(3))
1669 .cross_axis_alignment(MainAxisAlignment::Start)
1670 .item_alignment(CrossAxisAlignment::Start)
1671 .estimated_line_main(Px::new(10))
1672 .overscan(0)
1673 .padding_main(Px::new(4))
1674 .padding_cross(Px::new(4))
1675 .slots(vec![super::LazySlot::items(
1676 4,
1677 RenderSlotWith::new(|index| match index {
1678 0 => {
1679 fixed_test_box()
1680 .tag("lazy_grid_scroll_first".to_string())
1681 .width(10)
1682 .height(10);
1683 }
1684 1 => {
1685 fixed_test_box()
1686 .tag("lazy_grid_scroll_second".to_string())
1687 .width(12)
1688 .height(8);
1689 }
1690 2 => {
1691 fixed_test_box()
1692 .tag("lazy_grid_scroll_third".to_string())
1693 .width(11)
1694 .height(12);
1695 }
1696 _ => {
1697 fixed_test_box()
1698 .tag("lazy_grid_scroll_fourth".to_string())
1699 .width(9)
1700 .height(9);
1701 }
1702 }),
1703 None,
1704 )])
1705 .controller(controller)
1706 .scroll_controller(scroll_controller);
1707 });
1708 }
1709
1710 #[tessera]
1711 fn lazy_vertical_grid_stable_visible_items_case() {
1712 let controller = remember(LazyGridController::new);
1713 let scroll_controller = remember(ScrollableController::default);
1714 scroll_controller.with_mut(|c| {
1715 c.set_visible_size_for_test(ComputedData {
1716 width: Px::new(60),
1717 height: Px::new(50),
1718 });
1719 });
1720
1721 receive_frame_nanos(move |frame_nanos| {
1722 let offset = match frame_nanos {
1723 0 => 0,
1724 16_666_667 => 1,
1725 33_333_334 => 2,
1726 _ => 2,
1727 };
1728 scroll_controller.with_mut(|c| {
1729 c.set_scroll_position(PxPosition::new(Px::ZERO, Px::new(-offset)));
1730 });
1731 tessera_ui::FrameNanosControl::Continue
1732 });
1733
1734 material_theme()
1735 .theme(MaterialTheme::default)
1736 .child(move || {
1737 lazy_grid_view()
1738 .axis(LazyGridAxis::Vertical)
1739 .grid_cells(GridCells::fixed(2))
1740 .main_axis_spacing(Px::new(3))
1741 .cross_axis_spacing(Px::new(3))
1742 .cross_axis_alignment(MainAxisAlignment::Start)
1743 .item_alignment(CrossAxisAlignment::Start)
1744 .estimated_line_main(Px::new(20))
1745 .overscan(0)
1746 .padding_main(Px::new(4))
1747 .padding_cross(Px::new(4))
1748 .slots(vec![super::LazySlot::items(
1749 20,
1750 RenderSlotWith::new(|index| {
1751 fixed_test_box()
1752 .tag(format!("lazy_grid_stable_visible_{index}"))
1753 .width(20)
1754 .height(20);
1755 }),
1756 None,
1757 )])
1758 .controller(controller)
1759 .scroll_controller(scroll_controller);
1760 });
1761 }
1762
1763 #[test]
1764 fn lazy_vertical_grid_positions_items_with_padding_and_spacing() {
1765 tessera_ui::assert_layout! {
1766 viewport: (80, 80),
1767 content: {
1768 lazy_vertical_grid_layout_case();
1769 },
1770 expect: {
1771 node("lazy_grid_v_first").position(4, 4).size(10, 10);
1772 node("lazy_grid_v_second").position(32, 4).size(12, 8);
1773 node("lazy_grid_v_third").position(4, 17).size(11, 12);
1774 node("lazy_grid_v_fourth").position(32, 17).size(9, 9);
1775 }
1776 }
1777 }
1778
1779 #[test]
1780 fn lazy_horizontal_grid_positions_items_with_padding_and_spacing() {
1781 tessera_ui::assert_layout! {
1782 viewport: (90, 50),
1783 content: {
1784 lazy_horizontal_grid_layout_case();
1785 },
1786 expect: {
1787 node("lazy_grid_h_first").position(4, 4).size(20, 10);
1788 node("lazy_grid_h_second").position(4, 22).size(15, 12);
1789 node("lazy_grid_h_third").position(27, 4).size(18, 8);
1790 node("lazy_grid_h_fourth").position(27, 22).size(16, 9);
1791 }
1792 }
1793 }
1794
1795 #[test]
1796 fn lazy_vertical_grid_scroll_offset_repositions_visible_items() {
1797 tessera_ui::assert_layout! {
1798 viewport: (80, 80),
1799 content: {
1800 lazy_vertical_grid_scrolled_layout_case();
1801 },
1802 expect: {
1803 node("lazy_grid_scroll_first").position(4, -1).size(10, 10);
1804 node("lazy_grid_scroll_second").position(32, -1).size(12, 8);
1805 node("lazy_grid_scroll_third").position(4, 12).size(11, 12);
1806 node("lazy_grid_scroll_fourth").position(32, 12).size(9, 9);
1807 }
1808 }
1809 }
1810
1811 #[test]
1812 fn lazy_vertical_grid_does_not_rebuild_visible_items_when_scroll_stays_in_same_window() {
1813 let mut session = tessera_ui::testing::start_layout_test_session(
1814 tessera_ui::testing::layout_test(|| {
1815 lazy_vertical_grid_stable_visible_items_case();
1816 })
1817 .viewport_px(80, 60),
1818 );
1819
1820 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1821 let first_builds = session
1822 .work()
1823 .count("fixed_test_box", tessera_ui::WorkPhase::Build);
1824 assert!(first_builds > 0);
1825
1826 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 16_666_667);
1827 let second_builds = session
1828 .work()
1829 .count("fixed_test_box", tessera_ui::WorkPhase::Build);
1830 assert_eq!(second_builds, 0);
1831
1832 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 33_333_334);
1833 let third_builds = session
1834 .work()
1835 .count("fixed_test_box", tessera_ui::WorkPhase::Build);
1836 assert_eq!(third_builds, 0);
1837 }
1838}