Skip to main content

tessera_components/
lazy_staggered_grid.rs

1//! Virtualized staggered grid components for masonry layouts.
2//!
3//! ## Usage
4//!
5//! Use staggered grids to show variable-size tiles in galleries or feeds.
6use 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::{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_grid::{GridCells, GridSlots, compute_cell_offset, resolve_grid_slots},
21    lazy_shared::{
22        LazyItemsPlan as LazySlotPlan, LazyItemsSlot as LazySlot, MeasuredMain, ZeroLayout,
23        clamp_reported_main, hash_key,
24    },
25    scrollable::{
26        ScrollBarBehavior, ScrollBarLayout, ScrollableController, scrollable,
27        synchronize_scroll_density,
28    },
29};
30
31const DEFAULT_VIEWPORT_ITEMS: usize = 8;
32
33/// Cell configuration for staggered grids.
34pub type StaggeredGridCells = GridCells;
35
36/// Persistent state shared by staggered grid components.
37pub struct LazyStaggeredGridController {
38    cache: StaggeredGridCache,
39}
40
41impl Default for LazyStaggeredGridController {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl LazyStaggeredGridController {
48    /// Creates a new staggered grid state with default caches.
49    pub fn new() -> Self {
50        Self {
51            cache: StaggeredGridCache::default(),
52        }
53    }
54}
55
56/// Accumulated lazy staggered-grid item declarations carried by the builder.
57#[derive(Clone, Default, PartialEq)]
58pub struct LazyStaggeredGridContent {
59    slots: Vec<LazySlot>,
60}
61
62impl LazyStaggeredGridContent {
63    fn push(&mut self, slot: LazySlot) {
64        self.slots.push(slot);
65    }
66}
67
68/// # lazy_vertical_staggered_grid
69///
70/// A vertically scrolling staggered grid for masonry galleries and feeds.
71///
72/// ## Usage
73///
74/// Display mixed-height tiles in photo galleries or content feeds.
75///
76/// ## Parameters
77///
78/// - `modifier` - optional modifier for the scroll container.
79/// - `scroll_smoothing` - fraction of the remaining distance kept per 60Hz
80///   frame while animating a scroll step; defaults to the scrollable default.
81/// - `scrollbar_behavior` - visibility behavior of the scrollbars.
82/// - `scrollbar_track_color` - optional scrollbar track color override.
83/// - `scrollbar_thumb_color` - optional scrollbar thumb color override.
84/// - `scrollbar_thumb_hover_color` - optional scrollbar thumb hover color
85///   override.
86/// - `scrollbar_layout` - whether scrollbars are overlaid or laid out alongside
87///   content.
88/// - `columns` - lane definition for columns.
89/// - `main_axis_spacing` - spacing between items within a lane.
90/// - `cross_axis_spacing` - spacing between lanes.
91/// - `cross_axis_alignment` - how lanes are arranged when extra cross-axis
92///   space is available.
93/// - `item_alignment` - alignment of items within each lane.
94/// - `overscan` - number of extra items instantiated before and after the
95///   viewport.
96/// - `estimated_item_size` - estimated main-axis size for each item.
97/// - `content_padding` - symmetric padding applied around the grid content.
98/// - `max_viewport_main` - optional maximum viewport length reported back to
99///   parents.
100/// - `controller` - optional external controller for scroll position and cache.
101/// - item declarations are appended through builder methods like `item` and
102///   `items`.
103///
104/// ## Examples
105///
106/// ```
107/// use tessera_components::{
108///     lazy_staggered_grid::{StaggeredGridCells, lazy_vertical_staggered_grid},
109///     text::text,
110///     theme::material_theme,
111/// };
112/// use tessera_components::modifier::{ModifierExt as _, SemanticsArgs};
113/// use tessera_ui::{Modifier, tessera};
114/// #[tessera]
115/// fn demo() {
116///     material_theme().child(|| {
117///         lazy_vertical_staggered_grid()
118///             .columns(StaggeredGridCells::fixed(2))
119///             .overscan(0)
120///             .items(4, move |i| {
121///                 text(format!("Tile {i}")).modifier(
122///                     Modifier::new().semantics(SemanticsArgs {
123///                         test_tag: Some(format!("tile-{i}")), ..Default::default()
124///                     })
125///                 );
126///             });
127///     });
128/// }
129///
130/// let snapshot = tessera_ui::testing::layout_test(demo).run();
131/// for index in 0..4 {
132///     snapshot.node(&format!("tile-{index}")).exists();
133/// }
134/// ```
135#[tessera]
136pub fn lazy_vertical_staggered_grid(
137    modifier: Option<Modifier>,
138    scroll_smoothing: Option<f32>,
139    scrollbar_behavior: Option<ScrollBarBehavior>,
140    scrollbar_track_color: Option<Color>,
141    scrollbar_thumb_color: Option<Color>,
142    scrollbar_thumb_hover_color: Option<Color>,
143    scrollbar_layout: Option<ScrollBarLayout>,
144    columns: Option<StaggeredGridCells>,
145    main_axis_spacing: Option<Dp>,
146    cross_axis_spacing: Option<Dp>,
147    cross_axis_alignment: Option<MainAxisAlignment>,
148    item_alignment: Option<CrossAxisAlignment>,
149    overscan: Option<usize>,
150    estimated_item_size: Option<Dp>,
151    content_padding: Option<Dp>,
152    max_viewport_main: Option<Px>,
153    controller: Option<State<LazyStaggeredGridController>>,
154    #[prop(skip_setter)] content: Option<LazyStaggeredGridContent>,
155) {
156    let scroll_smoothing = scroll_smoothing.unwrap_or(crate::scrollable::DEFAULT_SCROLL_SMOOTHING);
157    let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
158    let scrollbar_layout = scrollbar_layout.unwrap_or_default();
159    let columns = columns.unwrap_or_default();
160    let main_axis_spacing = main_axis_spacing.unwrap_or(Dp(0.0));
161    let cross_axis_spacing = cross_axis_spacing.unwrap_or(Dp(0.0));
162    let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
163    let item_alignment = item_alignment.unwrap_or_default();
164    let overscan = overscan.unwrap_or(0);
165    let estimated_item_size = estimated_item_size.unwrap_or(Dp(0.0));
166    let content_padding = content_padding.unwrap_or(Dp(0.0));
167    let content = content.unwrap_or_default();
168    let controller = match controller {
169        Some(value) => value,
170        None => remember(LazyStaggeredGridController::new),
171    };
172    let scroll_controller = remember(ScrollableController::default);
173    lazy_staggered_grid_slots(
174        LazyStaggeredGridSlotsArgs {
175            modifier: modifier.unwrap_or_default(),
176            scroll_smoothing,
177            scrollbar_behavior,
178            scrollbar_track_color,
179            scrollbar_thumb_color,
180            scrollbar_thumb_hover_color,
181            scrollbar_layout,
182            grid_cells: columns,
183            main_axis_spacing,
184            cross_axis_spacing,
185            cross_axis_alignment,
186            item_alignment,
187            overscan,
188            estimated_item_size,
189            content_padding,
190            max_viewport_main,
191            controller,
192            scroll_controller,
193            slots: content.slots,
194        },
195        StaggeredGridAxis::Vertical,
196    );
197}
198
199#[derive(Clone, PartialEq)]
200struct LazyStaggeredGridSlotsArgs {
201    modifier: Modifier,
202    scroll_smoothing: f32,
203    scrollbar_behavior: ScrollBarBehavior,
204    scrollbar_track_color: Option<Color>,
205    scrollbar_thumb_color: Option<Color>,
206    scrollbar_thumb_hover_color: Option<Color>,
207    scrollbar_layout: ScrollBarLayout,
208    grid_cells: StaggeredGridCells,
209    main_axis_spacing: Dp,
210    cross_axis_spacing: Dp,
211    cross_axis_alignment: MainAxisAlignment,
212    item_alignment: CrossAxisAlignment,
213    overscan: usize,
214    estimated_item_size: Dp,
215    content_padding: Dp,
216    max_viewport_main: Option<Px>,
217    controller: State<LazyStaggeredGridController>,
218    scroll_controller: State<ScrollableController>,
219    slots: Vec<LazySlot>,
220}
221
222#[tessera]
223fn lazy_staggered_grid_slots(args: LazyStaggeredGridSlotsArgs, axis: StaggeredGridAxis) {
224    let scroll_controller = args.scroll_controller;
225    let main_axis_spacing = sanitize_spacing(Px::from(args.main_axis_spacing));
226    let cross_axis_spacing = sanitize_spacing(Px::from(args.cross_axis_spacing));
227    let estimated_item_main = ensure_positive_px(Px::from(args.estimated_item_size));
228    let padding_main = sanitize_spacing(Px::from(args.content_padding));
229    let padding_cross = sanitize_spacing(Px::from(args.content_padding));
230    scrollable()
231        .modifier(args.modifier)
232        .vertical(axis == StaggeredGridAxis::Vertical)
233        .horizontal(axis == StaggeredGridAxis::Horizontal)
234        .apply_child_offset(false)
235        .scroll_smoothing(args.scroll_smoothing)
236        .scrollbar_behavior(args.scrollbar_behavior)
237        .scrollbar_layout(args.scrollbar_layout)
238        .controller(scroll_controller)
239        .scrollbar_track_color_optional(args.scrollbar_track_color)
240        .scrollbar_thumb_color_optional(args.scrollbar_thumb_color)
241        .scrollbar_thumb_hover_color_optional(args.scrollbar_thumb_hover_color)
242        .child(move || {
243            lazy_staggered_grid_view()
244                .axis(axis)
245                .grid_cells(args.grid_cells.clone())
246                .main_axis_spacing(main_axis_spacing)
247                .cross_axis_spacing(cross_axis_spacing)
248                .cross_axis_alignment(args.cross_axis_alignment)
249                .item_alignment(args.item_alignment)
250                .estimated_item_main(estimated_item_main)
251                .overscan(args.overscan)
252                .padding_main(padding_main)
253                .padding_cross(padding_cross)
254                .slots(args.slots.clone())
255                .controller(args.controller)
256                .scroll_controller(scroll_controller)
257                .max_viewport_main_optional(args.max_viewport_main);
258        });
259}
260
261/// # lazy_horizontal_staggered_grid
262///
263/// A horizontally scrolling staggered grid for masonry carousels.
264///
265/// ## Usage
266///
267/// Display mixed-width tiles in horizontally scrolling galleries.
268///
269/// ## Parameters
270///
271/// - `modifier` - optional modifier for the scroll container.
272/// - `scroll_smoothing` - fraction of the remaining distance kept per 60Hz
273///   frame while animating a scroll step; defaults to the scrollable default.
274/// - `scrollbar_behavior` - visibility behavior of the scrollbars.
275/// - `scrollbar_track_color` - optional scrollbar track color override.
276/// - `scrollbar_thumb_color` - optional scrollbar thumb color override.
277/// - `scrollbar_thumb_hover_color` - optional scrollbar thumb hover color
278///   override.
279/// - `scrollbar_layout` - whether scrollbars are overlaid or laid out alongside
280///   content.
281/// - `rows` - lane definition for rows.
282/// - `main_axis_spacing` - spacing between items within a lane.
283/// - `cross_axis_spacing` - spacing between lanes.
284/// - `cross_axis_alignment` - how lanes are arranged when extra cross-axis
285///   space is available.
286/// - `item_alignment` - alignment of items within each lane.
287/// - `overscan` - number of extra items instantiated before and after the
288///   viewport.
289/// - `estimated_item_size` - estimated main-axis size for each item.
290/// - `content_padding` - symmetric padding applied around the grid content.
291/// - `max_viewport_main` - optional maximum viewport length reported back to
292///   parents.
293/// - `controller` - optional external controller for scroll position and cache.
294/// - item declarations are appended through builder methods like `item` and
295///   `items`.
296///
297/// ## Examples
298///
299/// ```
300/// use tessera_components::{
301///     lazy_staggered_grid::{StaggeredGridCells, lazy_horizontal_staggered_grid},
302///     text::text,
303///     theme::material_theme,
304/// };
305/// use tessera_components::modifier::{ModifierExt as _, SemanticsArgs};
306/// use tessera_ui::{Modifier, tessera};
307/// #[tessera]
308/// fn demo() {
309///     material_theme().child(|| {
310///         lazy_horizontal_staggered_grid()
311///             .rows(StaggeredGridCells::fixed(2))
312///             .overscan(0)
313///             .items(3, move |i| {
314///                 text(format!("Tile {i}")).modifier(
315///                     Modifier::new().semantics(SemanticsArgs {
316///                         test_tag: Some(format!("tile-{i}")), ..Default::default()
317///                     })
318///                 );
319///             });
320///     });
321/// }
322///
323/// let snapshot = tessera_ui::testing::layout_test(demo).run();
324/// for index in 0..3 {
325///     snapshot.node(&format!("tile-{index}")).exists();
326/// }
327/// ```
328#[tessera]
329pub fn lazy_horizontal_staggered_grid(
330    modifier: Option<Modifier>,
331    scroll_smoothing: Option<f32>,
332    scrollbar_behavior: Option<ScrollBarBehavior>,
333    scrollbar_track_color: Option<Color>,
334    scrollbar_thumb_color: Option<Color>,
335    scrollbar_thumb_hover_color: Option<Color>,
336    scrollbar_layout: Option<ScrollBarLayout>,
337    rows: Option<StaggeredGridCells>,
338    main_axis_spacing: Option<Dp>,
339    cross_axis_spacing: Option<Dp>,
340    cross_axis_alignment: Option<MainAxisAlignment>,
341    item_alignment: Option<CrossAxisAlignment>,
342    overscan: Option<usize>,
343    estimated_item_size: Option<Dp>,
344    content_padding: Option<Dp>,
345    max_viewport_main: Option<Px>,
346    controller: Option<State<LazyStaggeredGridController>>,
347    #[prop(skip_setter)] content: Option<LazyStaggeredGridContent>,
348) {
349    let scroll_smoothing = scroll_smoothing.unwrap_or(crate::scrollable::DEFAULT_SCROLL_SMOOTHING);
350    let scrollbar_behavior = scrollbar_behavior.unwrap_or_default();
351    let scrollbar_layout = scrollbar_layout.unwrap_or_default();
352    let rows = rows.unwrap_or_default();
353    let main_axis_spacing = main_axis_spacing.unwrap_or(Dp(0.0));
354    let cross_axis_spacing = cross_axis_spacing.unwrap_or(Dp(0.0));
355    let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
356    let item_alignment = item_alignment.unwrap_or_default();
357    let overscan = overscan.unwrap_or(0);
358    let estimated_item_size = estimated_item_size.unwrap_or(Dp(0.0));
359    let content_padding = content_padding.unwrap_or(Dp(0.0));
360    let content = content.unwrap_or_default();
361    let controller = match controller {
362        Some(value) => value,
363        None => remember(LazyStaggeredGridController::new),
364    };
365    let scroll_controller = remember(ScrollableController::default);
366    lazy_staggered_grid_slots(
367        LazyStaggeredGridSlotsArgs {
368            modifier: modifier.unwrap_or_default(),
369            scroll_smoothing,
370            scrollbar_behavior,
371            scrollbar_track_color,
372            scrollbar_thumb_color,
373            scrollbar_thumb_hover_color,
374            scrollbar_layout,
375            grid_cells: rows,
376            main_axis_spacing,
377            cross_axis_spacing,
378            cross_axis_alignment,
379            item_alignment,
380            overscan,
381            estimated_item_size,
382            content_padding,
383            max_viewport_main,
384            controller,
385            scroll_controller,
386            slots: content.slots,
387        },
388        StaggeredGridAxis::Horizontal,
389    );
390}
391
392crate::lazy_shared::impl_lazy_builder!(LazyVerticalStaggeredGridBuilder, LazyStaggeredGridContent);
393crate::lazy_shared::impl_lazy_builder!(
394    LazyHorizontalStaggeredGridBuilder,
395    LazyStaggeredGridContent
396);
397
398#[derive(Clone, Copy, PartialEq, Eq, Default)]
399enum StaggeredGridAxis {
400    #[default]
401    Vertical,
402    Horizontal,
403}
404
405#[derive(Clone, PartialEq, Eq)]
406struct VisibleStaggeredLayoutItem {
407    item_index: usize,
408}
409
410#[derive(Clone)]
411struct LazyStaggeredGridLayout {
412    axis: StaggeredGridAxis,
413    item_alignment: CrossAxisAlignment,
414    estimated_item_main: Px,
415    main_spacing: Px,
416    max_viewport_main: Option<Px>,
417    padding_main: Px,
418    padding_cross: Px,
419    viewport_limit: Px,
420    total_count: usize,
421    slots: GridSlots,
422    visible_items: Vec<VisibleStaggeredLayoutItem>,
423    scroll_offset: Px,
424    controller: State<LazyStaggeredGridController>,
425    scroll_controller: State<ScrollableController>,
426}
427
428impl PartialEq for LazyStaggeredGridLayout {
429    fn eq(&self, other: &Self) -> bool {
430        tessera_ui::LayoutModifierNode::placement_eq(self, other)
431    }
432}
433
434impl tessera_ui::LayoutModifierNode for LazyStaggeredGridLayout {
435    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
436        let mut result = LayoutResult::default();
437        let children = input.children();
438        if children.len() != self.visible_items.len() {
439            return Err(MeasurementError::MeasureFnFailed(
440                "Lazy staggered grid measured child count mismatch".into(),
441            ));
442        }
443
444        let (placements, total_main) = self.controller.with_mut(|c| {
445            c.cache.prepare(
446                self.total_count,
447                self.slots.len(),
448                self.estimated_item_main,
449                self.main_spacing,
450            );
451
452            let mut placements = Vec::with_capacity(self.visible_items.len());
453            for (visible, child) in self.visible_items.iter().zip(children.iter()) {
454                let index = visible.item_index;
455                let Some(lane) = c.cache.lane_of(index) else {
456                    continue;
457                };
458                let lane_cross = self.slots.sizes.get(lane).copied().unwrap_or(Px::ZERO);
459                let child_constraint = self.axis.child_constraint(lane_cross, self.item_alignment);
460                let child_size = child.measure(input, &child_constraint)?;
461                let child_size = child_size.size();
462                let item_main = self.axis.main(&child_size);
463                c.cache.record_measurement(index, item_main);
464                let Some(lane) = c.cache.lane_of(index) else {
465                    continue;
466                };
467                let item_start = c.cache.item_start(index);
468
469                let cell_offset = compute_cell_offset(
470                    lane_cross,
471                    self.axis.cross(&child_size),
472                    self.item_alignment,
473                );
474                let cross_offset = self.padding_cross
475                    + self.slots.positions.get(lane).copied().unwrap_or(Px::ZERO)
476                    + cell_offset;
477                let position = self.axis.position(
478                    item_start + self.padding_main - self.scroll_offset,
479                    cross_offset,
480                );
481                placements.push((*child, position));
482            }
483
484            let total_main = c.cache.total_main();
485            Ok::<_, MeasurementError>((placements, total_main))
486        })?;
487
488        let total_main_with_padding = total_main + self.padding_main + self.padding_main;
489        let cross_with_padding = self.slots.cross_size + self.padding_cross + self.padding_cross;
490        let size = self
491            .axis
492            .pack_size(total_main_with_padding, cross_with_padding);
493        self.scroll_controller
494            .with_mut(|c| c.override_child_size(size));
495
496        let reported_main = clamp_reported_main(
497            total_main_with_padding,
498            self.viewport_limit,
499            self.axis.constraint_max(input.parent_constraint()),
500            self.max_viewport_main,
501        );
502
503        for (child, position) in placements {
504            result.place_child(child, position);
505        }
506
507        Ok(result.with_size(self.axis.pack_size(reported_main, cross_with_padding)))
508    }
509
510    fn measure_eq(&self, other: &Self) -> bool {
511        self.axis == other.axis
512            && self.item_alignment == other.item_alignment
513            && self.estimated_item_main == other.estimated_item_main
514            && self.main_spacing == other.main_spacing
515            && self.max_viewport_main == other.max_viewport_main
516            && self.padding_main == other.padding_main
517            && self.padding_cross == other.padding_cross
518            && self.viewport_limit == other.viewport_limit
519            && self.total_count == other.total_count
520            && self.slots == other.slots
521            && self.visible_items == other.visible_items
522    }
523
524    fn placement_eq(&self, other: &Self) -> bool {
525        self.measure_eq(other) && self.scroll_offset == other.scroll_offset
526    }
527
528    fn place_children(
529        &self,
530        input: &PlacementScope<'_>,
531        _inner: &PlacementResult,
532    ) -> Option<PlacementResult> {
533        let children = input.children();
534        if children.len() != self.visible_items.len() {
535            return None;
536        }
537
538        let mut result = LayoutResult::default();
539        let child_sizes: Vec<_> = children.iter().map(|child| child.size()).collect();
540        let positions = self.controller.with(|c| {
541            compute_staggered_layout_positions(ComputeStaggeredLayoutPositionsArgs {
542                axis: self.axis,
543                item_alignment: self.item_alignment,
544                cache: &c.cache,
545                slots: &self.slots,
546                padding_main: self.padding_main,
547                padding_cross: self.padding_cross,
548                visible_items: &self.visible_items,
549                child_sizes: &child_sizes,
550                scroll_offset: self.scroll_offset,
551            })
552        });
553        if positions.len() != children.len() {
554            return None;
555        }
556
557        for (child, position) in children.iter().zip(positions) {
558            result.place_child(*child, position);
559        }
560
561        Some(result.into_placement_result())
562    }
563}
564
565#[tessera]
566fn lazy_staggered_grid_view(
567    axis: Option<StaggeredGridAxis>,
568    grid_cells: Option<StaggeredGridCells>,
569    main_axis_spacing: Option<Px>,
570    cross_axis_spacing: Option<Px>,
571    cross_axis_alignment: Option<MainAxisAlignment>,
572    item_alignment: Option<CrossAxisAlignment>,
573    estimated_item_main: Option<Px>,
574    overscan: Option<usize>,
575    max_viewport_main: Option<Px>,
576    padding_main: Option<Px>,
577    padding_cross: Option<Px>,
578    controller: Option<State<LazyStaggeredGridController>>,
579    slots: Option<Vec<LazySlot>>,
580    scroll_controller: Option<State<ScrollableController>>,
581) {
582    let axis = axis.unwrap_or_default();
583    let grid_cells = grid_cells.unwrap_or_default();
584    let main_axis_spacing = main_axis_spacing.unwrap_or(Px::ZERO);
585    let cross_axis_spacing = cross_axis_spacing.unwrap_or(Px::ZERO);
586    let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
587    let item_alignment = item_alignment.unwrap_or_default();
588    let estimated_item_main = estimated_item_main.unwrap_or(Px::ZERO);
589    let overscan = overscan.unwrap_or(0);
590    let padding_main = padding_main.unwrap_or(Px::ZERO);
591    let padding_cross = padding_cross.unwrap_or(Px::ZERO);
592    let slots = slots.unwrap_or_default();
593    let controller = controller.expect("lazy_staggered_grid_view requires controller");
594    let scroll_controller =
595        scroll_controller.expect("lazy_staggered_grid_view requires scroll_controller");
596    synchronize_scroll_density(scroll_controller);
597    let plan = LazySlotPlan::new(slots.clone());
598    let total_count = plan.total_count();
599
600    let visible_size = scroll_controller.with(|s| s.visible_size());
601    let available_cross = axis.visible_cross(visible_size);
602    let available_cross = (available_cross - padding_cross * 2).max(Px::ZERO);
603    let grid_slots = resolve_grid_slots(
604        available_cross,
605        &grid_cells,
606        cross_axis_spacing,
607        cross_axis_alignment,
608    );
609    let lane_count = grid_slots.len();
610
611    controller.with_mut(|c| {
612        c.cache.prepare(
613            total_count,
614            lane_count,
615            estimated_item_main,
616            main_axis_spacing,
617        );
618    });
619    let total_main = controller.with(|c| c.cache.total_main());
620    let total_main_with_padding = total_main + padding_main + padding_main;
621    let cross_with_padding = grid_slots.cross_size + padding_cross + padding_cross;
622    scroll_controller.with_mut(|c| {
623        c.override_child_size(axis.pack_size(total_main_with_padding, cross_with_padding));
624    });
625
626    let scroll_offset = axis.scroll_offset(scroll_controller.with(|s| s.child_position()));
627    let viewport_span = resolve_viewport_span(
628        axis.visible_span(visible_size),
629        estimated_item_main,
630        main_axis_spacing,
631    );
632    let viewport_span = (viewport_span - (padding_main * 2)).max(Px::ZERO);
633
634    let visible_range = controller.with(|c| {
635        compute_visible_range(
636            &c.cache,
637            total_count,
638            lane_count,
639            scroll_offset,
640            viewport_span,
641            overscan,
642            main_axis_spacing,
643        )
644    });
645    let visible_items = visible_items(&plan, visible_range.clone());
646
647    if visible_items.is_empty() {
648        layout().policy(ZeroLayout);
649        return;
650    }
651
652    let focus_modifier =
653        lazy_staggered_grid_focus_beyond_bounds_modifier(LazyStaggeredGridFocusArgs {
654            axis,
655            controller,
656            scroll_controller,
657            total_count,
658            total_main,
659            viewport_span,
660            visible_range,
661            lane_count,
662        });
663
664    let viewport_limit = viewport_span + padding_main + padding_main;
665    let visible_layout_items = visible_items
666        .iter()
667        .map(|item| VisibleStaggeredLayoutItem {
668            item_index: item.item_index,
669        })
670        .collect();
671
672    layout()
673        .modifier(focus_modifier)
674        .policy(LazyStaggeredGridLayout {
675            axis,
676            item_alignment,
677            estimated_item_main,
678            main_spacing: main_axis_spacing,
679            max_viewport_main,
680            padding_main,
681            padding_cross,
682            viewport_limit,
683            total_count,
684            slots: grid_slots.clone(),
685            visible_items: visible_layout_items,
686            scroll_offset,
687            controller,
688            scroll_controller,
689        })
690        .child(move || {
691            for child in &visible_items {
692                let child = child.clone();
693                key(child.key_hash, || {
694                    lazy_staggered_grid_item_host()
695                        .builder_handle_shared(child.builder)
696                        .local_index(child.local_index);
697                });
698            }
699        });
700}
701
702#[tessera]
703fn lazy_staggered_grid_item_host(
704    builder_handle: Option<RenderSlotWith<usize>>,
705    local_index: Option<usize>,
706) {
707    let local_index = local_index.unwrap_or(0);
708    if let Some(builder_handle) = builder_handle {
709        builder_handle.render(local_index);
710    }
711}
712
713fn resolve_viewport_span(current: Px, estimated: Px, spacing: Px) -> Px {
714    if current > Px::ZERO {
715        current
716    } else {
717        let per_item = estimated + spacing;
718        px_mul(per_item, DEFAULT_VIEWPORT_ITEMS.max(1))
719    }
720}
721
722fn compute_visible_range(
723    cache: &StaggeredGridCache,
724    total_count: usize,
725    lane_count: usize,
726    scroll_offset: Px,
727    viewport_span: Px,
728    overscan: usize,
729    spacing: Px,
730) -> Range<usize> {
731    if total_count == 0 || lane_count == 0 || cache.built_len() != total_count {
732        return 0..0;
733    }
734
735    let viewport_start = scroll_offset;
736    let viewport_end = scroll_offset + viewport_span;
737    let mut first_visible = None;
738    let mut last_visible = None;
739
740    for lane in 0..lane_count {
741        let ends = cache.lane_ends(lane);
742        if ends.is_empty() {
743            continue;
744        }
745        let first = ends.partition_point(|end| *end < viewport_start);
746        let last = if viewport_end < Px::ZERO {
747            0
748        } else {
749            (ends.partition_point(|end| *end + spacing <= viewport_end) + 1).min(ends.len())
750        };
751        if first >= last {
752            continue;
753        }
754        if let Some(start_index) = cache.item_at_lane_slot(lane, first) {
755            first_visible =
756                Some(first_visible.map_or(start_index, |current: usize| current.min(start_index)));
757        }
758        if let Some(end_index) = cache.item_at_lane_slot(lane, last - 1) {
759            last_visible =
760                Some(last_visible.map_or(end_index, |current: usize| current.max(end_index)));
761        }
762    }
763
764    let (mut start, mut end) = match (first_visible, last_visible) {
765        (Some(start), Some(end)) => (start, end + 1),
766        _ => {
767            let end = total_count;
768            let start = total_count.saturating_sub(1);
769            (start, end)
770        }
771    };
772
773    start = start.saturating_sub(overscan);
774    end = end.saturating_add(overscan).min(total_count);
775    if start >= end {
776        end = (start + 1).min(total_count);
777        start = start.saturating_sub(1);
778    }
779
780    start..end
781}
782
783#[derive(Clone, Copy)]
784enum FocusScrollDirection {
785    Forward,
786    Backward,
787}
788
789impl StaggeredGridAxis {
790    fn main(&self, size: &ComputedData) -> Px {
791        match self {
792            Self::Vertical => size.height,
793            Self::Horizontal => size.width,
794        }
795    }
796
797    fn cross(&self, size: &ComputedData) -> Px {
798        match self {
799            Self::Vertical => size.width,
800            Self::Horizontal => size.height,
801        }
802    }
803
804    fn pack_size(&self, main: Px, cross: Px) -> ComputedData {
805        match self {
806            Self::Vertical => ComputedData {
807                width: cross,
808                height: main,
809            },
810            Self::Horizontal => ComputedData {
811                width: main,
812                height: cross,
813            },
814        }
815    }
816
817    fn position(&self, main: Px, cross: Px) -> PxPosition {
818        match self {
819            Self::Vertical => PxPosition::new(cross, main),
820            Self::Horizontal => PxPosition::new(main, cross),
821        }
822    }
823
824    fn visible_span(&self, size: ComputedData) -> Px {
825        match self {
826            Self::Vertical => size.height,
827            Self::Horizontal => size.width,
828        }
829    }
830
831    fn visible_cross(&self, size: ComputedData) -> Px {
832        match self {
833            Self::Vertical => size.width,
834            Self::Horizontal => size.height,
835        }
836    }
837
838    fn scroll_position(&self, offset: Px) -> PxPosition {
839        match self {
840            Self::Vertical => PxPosition::new(Px::ZERO, -offset),
841            Self::Horizontal => PxPosition::new(-offset, Px::ZERO),
842        }
843    }
844
845    fn scroll_offset(&self, position: PxPosition) -> Px {
846        match self {
847            Self::Vertical => Px((-position.y).max(0.0) as i32),
848            Self::Horizontal => Px((-position.x).max(0.0) as i32),
849        }
850    }
851
852    fn focus_scroll_direction(&self, direction: FocusDirection) -> Option<FocusScrollDirection> {
853        match (self, direction) {
854            (_, FocusDirection::Next | FocusDirection::Enter) => {
855                Some(FocusScrollDirection::Forward)
856            }
857            (_, FocusDirection::Previous | FocusDirection::Exit) => {
858                Some(FocusScrollDirection::Backward)
859            }
860            (Self::Vertical, FocusDirection::Down) => Some(FocusScrollDirection::Forward),
861            (Self::Vertical, FocusDirection::Up) => Some(FocusScrollDirection::Backward),
862            (Self::Horizontal, FocusDirection::Right) => Some(FocusScrollDirection::Forward),
863            (Self::Horizontal, FocusDirection::Left) => Some(FocusScrollDirection::Backward),
864            _ => None,
865        }
866    }
867
868    fn child_constraint(&self, cross: Px, alignment: CrossAxisAlignment) -> Constraint {
869        let cross = cross.max(Px::ZERO);
870        // The cell defines an exact cross extent, so the shared rule reproduces
871        // the previous behavior: `Stretch` fills the cell, other alignments let
872        // the item measure itself within that extent.
873        let cross_constraint = cross_axis_child_constraint(AxisConstraint::exact(cross), alignment);
874
875        match self {
876            Self::Vertical => Constraint::new(cross_constraint, AxisConstraint::NONE),
877            Self::Horizontal => Constraint::new(AxisConstraint::NONE, cross_constraint),
878        }
879    }
880
881    fn constraint_max(&self, constraint: ParentConstraint<'_>) -> Option<Px> {
882        match self {
883            Self::Vertical => constraint.height().resolve_max(),
884            Self::Horizontal => constraint.width().resolve_max(),
885        }
886    }
887}
888
889fn visible_items(plan: &LazySlotPlan, range: Range<usize>) -> Vec<VisibleStaggeredItem> {
890    let mut result = Vec::new();
891    for index in range {
892        if let Some((slot, local_index)) = plan.resolve(index) {
893            let key_hash = if let Some(provider) = &slot.key_provider {
894                provider.call(local_index)
895            } else {
896                hash_key(index)
897            };
898
899            result.push(VisibleStaggeredItem {
900                item_index: index,
901                local_index,
902                builder: slot.builder,
903                key_hash,
904            });
905        }
906    }
907    result
908}
909
910#[derive(Clone, PartialEq)]
911struct VisibleStaggeredItem {
912    item_index: usize,
913    local_index: usize,
914    builder: RenderSlotWith<usize>,
915    key_hash: u64,
916}
917
918struct LazyStaggeredGridFocusArgs {
919    axis: StaggeredGridAxis,
920    controller: State<LazyStaggeredGridController>,
921    scroll_controller: State<ScrollableController>,
922    total_count: usize,
923    total_main: Px,
924    viewport_span: Px,
925    visible_range: Range<usize>,
926    lane_count: usize,
927}
928
929fn lazy_staggered_grid_focus_beyond_bounds_modifier(args: LazyStaggeredGridFocusArgs) -> Modifier {
930    let current_scroll_offset = args
931        .axis
932        .scroll_offset(args.scroll_controller.with(|s| s.child_position()));
933    let max_scroll = (args.total_main - args.viewport_span).max(Px::ZERO);
934    Modifier::new().focus_beyond_bounds_handler(CallbackWith::new(move |direction| {
935        let Some(scroll_direction) = args.axis.focus_scroll_direction(direction) else {
936            return false;
937        };
938        if args.total_count == 0 || args.lane_count == 0 || args.viewport_span <= Px::ZERO {
939            return false;
940        }
941
942        let target_index = match scroll_direction {
943            FocusScrollDirection::Forward => {
944                if args.visible_range.end >= args.total_count {
945                    return false;
946                }
947                args.visible_range.end
948            }
949            FocusScrollDirection::Backward => {
950                let Some(index) = args.visible_range.start.checked_sub(1) else {
951                    return false;
952                };
953                index
954            }
955        };
956
957        let Some((target_offset, target_main)) =
958            args.controller.with(|c| c.cache.item_layout(target_index))
959        else {
960            return false;
961        };
962
963        let desired_scroll = match scroll_direction {
964            FocusScrollDirection::Forward => {
965                (target_offset + target_main - args.viewport_span).max(Px::ZERO)
966            }
967            FocusScrollDirection::Backward => target_offset,
968        }
969        .min(max_scroll);
970
971        if desired_scroll == current_scroll_offset {
972            return false;
973        }
974
975        let position = args.axis.scroll_position(desired_scroll);
976        args.scroll_controller
977            .with_mut(|c| c.set_scroll_position(position));
978        true
979    }))
980}
981
982#[derive(PartialEq)]
983struct StaggeredGridCache {
984    /// Measured main-axis sizes. `None` still uses the estimated size.
985    item_main: Vec<Option<MeasuredMain>>,
986    density: f64,
987    /// Lane assignment for each built item, parallel to the item prefix.
988    lane_of: Vec<usize>,
989    /// Exclusive main-axis end of every item in a lane, in placement order.
990    lane_ends: Vec<Vec<Px>>,
991    /// Item index occupying each slot of `lane_ends`.
992    lane_items: Vec<Vec<usize>>,
993    lane_count: usize,
994    spacing: Px,
995    estimated_item_main: Px,
996}
997
998impl Default for StaggeredGridCache {
999    fn default() -> Self {
1000        Self {
1001            item_main: Vec::new(),
1002            density: 0.0,
1003            lane_of: Vec::new(),
1004            lane_ends: Vec::new(),
1005            lane_items: Vec::new(),
1006            lane_count: 0,
1007            spacing: Px::ZERO,
1008            estimated_item_main: Px::ZERO,
1009        }
1010    }
1011}
1012
1013impl StaggeredGridCache {
1014    /// Keep `item_main` aligned with `count` and refresh lane geometry.
1015    ///
1016    /// A longer list only appends. A shorter list truncates. Changing lane
1017    /// count, spacing, or estimated size drops the lane tables. Inserts and
1018    /// reorders cannot be told apart from length, so they invalidate the whole
1019    /// table instead of pretending keys stayed stable.
1020    fn prepare(&mut self, count: usize, lane_count: usize, estimated_item_main: Px, spacing: Px) {
1021        let density = tessera_ui::dp::scale_factor();
1022        if self.density != density {
1023            for measurement in self.item_main.iter_mut().flatten() {
1024                measurement.rebase(density);
1025            }
1026            self.density = density;
1027            self.invalidate_geometry();
1028        }
1029        if self.lane_count != lane_count
1030            || self.spacing != spacing
1031            || self.estimated_item_main != estimated_item_main
1032        {
1033            self.invalidate_geometry();
1034            self.lane_count = lane_count;
1035            self.spacing = spacing;
1036            self.estimated_item_main = estimated_item_main;
1037        }
1038
1039        if self.item_main.len() > count {
1040            self.item_main.truncate(count);
1041        } else if self.item_main.len() < count {
1042            self.item_main.resize(count, None);
1043        }
1044
1045        if self.lane_of.len() > self.item_main.len() {
1046            self.truncate_geometry(self.item_main.len());
1047        }
1048
1049        self.ensure_built(self.item_main.len());
1050    }
1051
1052    fn item_main(&self, index: usize) -> Option<Px> {
1053        self.item_main
1054            .get(index)
1055            .copied()
1056            .flatten()
1057            .map(MeasuredMain::pixels)
1058    }
1059
1060    fn record_measurement(&mut self, index: usize, actual: Px) {
1061        if index >= self.item_main.len() {
1062            return;
1063        }
1064        if self.item_main(index) == Some(actual) {
1065            return;
1066        }
1067        self.item_main[index] = Some(MeasuredMain::new(actual, self.density));
1068        self.truncate_geometry(index);
1069        self.ensure_built(self.item_main.len());
1070    }
1071
1072    fn built_len(&self) -> usize {
1073        self.lane_of.len()
1074    }
1075
1076    fn lane_of(&self, index: usize) -> Option<usize> {
1077        self.lane_of.get(index).copied()
1078    }
1079
1080    fn lane_ends(&self, lane: usize) -> &[Px] {
1081        self.lane_ends.get(lane).map(Vec::as_slice).unwrap_or(&[])
1082    }
1083
1084    fn item_start(&self, index: usize) -> Px {
1085        let Some(lane) = self.lane_of.get(index).copied() else {
1086            return Px::ZERO;
1087        };
1088        let Some(items) = self.lane_items.get(lane) else {
1089            return Px::ZERO;
1090        };
1091        let Some(slot) = items.iter().position(|item| *item == index) else {
1092            return Px::ZERO;
1093        };
1094        if slot == 0 {
1095            Px::ZERO
1096        } else {
1097            self.lane_ends
1098                .get(lane)
1099                .and_then(|ends| ends.get(slot - 1))
1100                .copied()
1101                .unwrap_or(Px::ZERO)
1102                + self.spacing
1103        }
1104    }
1105
1106    fn item_layout(&self, index: usize) -> Option<(Px, Px)> {
1107        if index >= self.lane_of.len() {
1108            return None;
1109        }
1110        Some((self.item_start(index), self.resolved_main(index)))
1111    }
1112
1113    fn item_at_lane_slot(&self, lane: usize, slot: usize) -> Option<usize> {
1114        self.lane_items.get(lane)?.get(slot).copied()
1115    }
1116
1117    fn total_main(&self) -> Px {
1118        if self.lane_count == 0
1119            || self.item_main.is_empty()
1120            || self.lane_of.len() != self.item_main.len()
1121        {
1122            return Px::ZERO;
1123        }
1124        self.lane_ends
1125            .iter()
1126            .filter_map(|ends| ends.last().copied())
1127            .max()
1128            .unwrap_or(Px::ZERO)
1129    }
1130
1131    fn resolved_main(&self, index: usize) -> Px {
1132        self.item_main(index).unwrap_or(self.estimated_item_main)
1133    }
1134
1135    fn invalidate_geometry(&mut self) {
1136        self.lane_of.clear();
1137        self.lane_ends.clear();
1138        self.lane_items.clear();
1139    }
1140
1141    /// Drop lane rows at and after `from`. Rows before `from` stay put.
1142    fn truncate_geometry(&mut self, from: usize) {
1143        let keep = from.min(self.lane_of.len());
1144        if keep == self.lane_of.len() && self.lane_ends.len() == self.lane_count {
1145            return;
1146        }
1147        self.lane_of.truncate(keep);
1148        for items in &mut self.lane_items {
1149            items.retain(|index| *index < keep);
1150        }
1151        let spacing = self.spacing;
1152        let mains: Vec<Px> = (0..keep).map(|index| self.resolved_main(index)).collect();
1153        for lane in 0..self.lane_items.len() {
1154            let Some(items) = self.lane_items.get(lane).cloned() else {
1155                continue;
1156            };
1157            let mut cursor = Px::ZERO;
1158            let mut rebuilt = Vec::with_capacity(items.len());
1159            for (slot, index) in items.iter().copied().enumerate() {
1160                let main = mains.get(index).copied().unwrap_or(Px::ZERO);
1161                if slot > 0 {
1162                    cursor += spacing;
1163                }
1164                cursor += main;
1165                rebuilt.push(cursor);
1166            }
1167            if let Some(ends) = self.lane_ends.get_mut(lane) {
1168                *ends = rebuilt;
1169            }
1170        }
1171    }
1172
1173    fn ensure_built(&mut self, through: usize) {
1174        let through = through.min(self.item_main.len());
1175        if self.lane_count == 0 {
1176            self.lane_of.clear();
1177            self.lane_ends.clear();
1178            self.lane_items.clear();
1179            return;
1180        }
1181        if self.lane_ends.len() != self.lane_count || self.lane_items.len() != self.lane_count {
1182            self.invalidate_geometry();
1183            self.lane_ends = vec![Vec::new(); self.lane_count];
1184            self.lane_items = vec![Vec::new(); self.lane_count];
1185        }
1186        if self.lane_of.len() > through {
1187            self.truncate_geometry(through);
1188        }
1189        while self.lane_of.len() < through {
1190            let index = self.lane_of.len();
1191            let lane = shortest_lane_index(&self.lane_ends);
1192            let item_main = self.resolved_main(index);
1193            let start = self
1194                .lane_ends
1195                .get(lane)
1196                .and_then(|ends| ends.last().copied())
1197                .map(|end| end + self.spacing)
1198                .unwrap_or(Px::ZERO);
1199            self.lane_of.push(lane);
1200            if let Some(items) = self.lane_items.get_mut(lane) {
1201                items.push(index);
1202            }
1203            if let Some(ends) = self.lane_ends.get_mut(lane) {
1204                ends.push(start + item_main);
1205            }
1206        }
1207    }
1208}
1209
1210fn shortest_lane_index(lane_ends: &[Vec<Px>]) -> usize {
1211    let mut index = 0;
1212    let mut best = lane_ends
1213        .first()
1214        .and_then(|ends| ends.last().copied())
1215        .unwrap_or(Px::ZERO);
1216    for (lane, ends) in lane_ends.iter().enumerate().skip(1) {
1217        let end = ends.last().copied().unwrap_or(Px::ZERO);
1218        if end < best {
1219            best = end;
1220            index = lane;
1221        }
1222    }
1223    index
1224}
1225
1226fn ensure_positive_px(px: Px) -> Px {
1227    if px <= Px::ZERO { Px(1) } else { px }
1228}
1229
1230fn sanitize_spacing(px: Px) -> Px {
1231    if px < Px::ZERO { Px::ZERO } else { px }
1232}
1233
1234struct ComputeStaggeredLayoutPositionsArgs<'a> {
1235    axis: StaggeredGridAxis,
1236    item_alignment: CrossAxisAlignment,
1237    cache: &'a StaggeredGridCache,
1238    slots: &'a GridSlots,
1239    padding_main: Px,
1240    padding_cross: Px,
1241    visible_items: &'a [VisibleStaggeredLayoutItem],
1242    child_sizes: &'a [ComputedData],
1243    scroll_offset: Px,
1244}
1245
1246fn compute_staggered_layout_positions(
1247    args: ComputeStaggeredLayoutPositionsArgs<'_>,
1248) -> Vec<PxPosition> {
1249    let ComputeStaggeredLayoutPositionsArgs {
1250        axis,
1251        item_alignment,
1252        cache,
1253        slots,
1254        padding_main,
1255        padding_cross,
1256        visible_items,
1257        child_sizes,
1258        scroll_offset,
1259    } = args;
1260
1261    let mut positions = Vec::with_capacity(visible_items.len());
1262    if slots.len() == 0 {
1263        return positions;
1264    }
1265
1266    for (visible, child_size) in visible_items.iter().zip(child_sizes.iter()) {
1267        let index = visible.item_index;
1268        let Some(lane) = cache.lane_of(index) else {
1269            continue;
1270        };
1271        let lane_cross = slots.sizes.get(lane).copied().unwrap_or(Px::ZERO);
1272        let item_start = cache.item_start(index);
1273        let cell_offset = compute_cell_offset(lane_cross, axis.cross(child_size), item_alignment);
1274        let cross_offset =
1275            padding_cross + slots.positions.get(lane).copied().unwrap_or(Px::ZERO) + cell_offset;
1276        positions.push(axis.position(item_start + padding_main - scroll_offset, cross_offset));
1277    }
1278
1279    positions
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284    use tessera_ui::{
1285        AxisConstraint, ComputedData, LayoutResult, MeasurementError, Modifier, NoopDrawModifier,
1286        Px, PxPosition, RenderSlotWith,
1287        layout::{MeasureScope, layout},
1288        receive_frame_nanos, remember, tessera,
1289    };
1290
1291    use crate::{
1292        alignment::{CrossAxisAlignment, MainAxisAlignment},
1293        modifier::{ModifierExt as _, SemanticsArgs},
1294        scrollable::{ScrollBarBehavior, ScrollableController, scrollable},
1295        theme::{MaterialTheme, material_theme},
1296    };
1297
1298    use super::{
1299        GridCells, LazyStaggeredGridController, StaggeredGridAxis, lazy_staggered_grid_view,
1300    };
1301
1302    #[derive(Clone, PartialEq)]
1303    struct FixedTestLayout {
1304        width: i32,
1305        height: i32,
1306    }
1307
1308    impl tessera_ui::LayoutModifierNode for FixedTestLayout {
1309        fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
1310            Ok(LayoutResult::new(ComputedData {
1311                width: Px::new(self.width),
1312                height: Px::new(self.height),
1313            }))
1314        }
1315    }
1316
1317    #[tessera]
1318    fn fixed_test_box(tag: Option<String>, width: Option<i32>, height: Option<i32>) {
1319        let tag = tag.unwrap_or_default();
1320        let width = width.unwrap_or(0);
1321        let height = height.unwrap_or(0);
1322
1323        layout()
1324            .policy(FixedTestLayout { width, height })
1325            .draw(NoopDrawModifier)
1326            .modifier(Modifier::new().semantics(SemanticsArgs {
1327                test_tag: Some(tag),
1328                ..Default::default()
1329            }));
1330    }
1331
1332    #[tessera]
1333    fn lazy_vertical_staggered_layout_case() {
1334        let controller = remember(LazyStaggeredGridController::new);
1335        let scroll_controller = remember(ScrollableController::default);
1336        scroll_controller.with_mut(|c| {
1337            c.set_visible_size_for_test(ComputedData {
1338                width: Px::new(60),
1339                height: Px::new(60),
1340            });
1341        });
1342
1343        lazy_staggered_grid_view()
1344            .axis(StaggeredGridAxis::Vertical)
1345            .grid_cells(GridCells::fixed(2))
1346            .main_axis_spacing(Px::new(3))
1347            .cross_axis_spacing(Px::new(3))
1348            .cross_axis_alignment(MainAxisAlignment::Start)
1349            .item_alignment(CrossAxisAlignment::Start)
1350            .estimated_item_main(Px::new(10))
1351            .overscan(0)
1352            .padding_main(Px::new(4))
1353            .padding_cross(Px::new(4))
1354            .slots(vec![super::LazySlot::items(
1355                4,
1356                RenderSlotWith::new(|index| match index {
1357                    0 => {
1358                        fixed_test_box()
1359                            .tag("lazy_staggered_v_first".to_string())
1360                            .width(10)
1361                            .height(10);
1362                    }
1363                    1 => {
1364                        fixed_test_box()
1365                            .tag("lazy_staggered_v_second".to_string())
1366                            .width(12)
1367                            .height(8);
1368                    }
1369                    2 => {
1370                        fixed_test_box()
1371                            .tag("lazy_staggered_v_third".to_string())
1372                            .width(11)
1373                            .height(12);
1374                    }
1375                    _ => {
1376                        fixed_test_box()
1377                            .tag("lazy_staggered_v_fourth".to_string())
1378                            .width(9)
1379                            .height(9);
1380                    }
1381                }),
1382                None,
1383            )])
1384            .controller(controller)
1385            .scroll_controller(scroll_controller);
1386    }
1387
1388    #[tessera]
1389    fn lazy_horizontal_staggered_layout_case() {
1390        let controller = remember(LazyStaggeredGridController::new);
1391        let scroll_controller = remember(ScrollableController::default);
1392        scroll_controller.with_mut(|c| {
1393            c.set_visible_size_for_test(ComputedData {
1394                width: Px::new(70),
1395                height: Px::new(40),
1396            });
1397        });
1398
1399        lazy_staggered_grid_view()
1400            .axis(StaggeredGridAxis::Horizontal)
1401            .grid_cells(GridCells::fixed(2))
1402            .main_axis_spacing(Px::new(3))
1403            .cross_axis_spacing(Px::new(3))
1404            .cross_axis_alignment(MainAxisAlignment::Start)
1405            .item_alignment(CrossAxisAlignment::Start)
1406            .estimated_item_main(Px::new(10))
1407            .overscan(0)
1408            .padding_main(Px::new(4))
1409            .padding_cross(Px::new(4))
1410            .slots(vec![super::LazySlot::items(
1411                4,
1412                RenderSlotWith::new(|index| match index {
1413                    0 => {
1414                        fixed_test_box()
1415                            .tag("lazy_staggered_h_first".to_string())
1416                            .width(20)
1417                            .height(10);
1418                    }
1419                    1 => {
1420                        fixed_test_box()
1421                            .tag("lazy_staggered_h_second".to_string())
1422                            .width(15)
1423                            .height(12);
1424                    }
1425                    2 => {
1426                        fixed_test_box()
1427                            .tag("lazy_staggered_h_third".to_string())
1428                            .width(18)
1429                            .height(8);
1430                    }
1431                    _ => {
1432                        fixed_test_box()
1433                            .tag("lazy_staggered_h_fourth".to_string())
1434                            .width(16)
1435                            .height(9);
1436                    }
1437                }),
1438                None,
1439            )])
1440            .controller(controller)
1441            .scroll_controller(scroll_controller);
1442    }
1443
1444    #[tessera]
1445    fn lazy_vertical_staggered_scrolled_layout_case() {
1446        let controller = remember(LazyStaggeredGridController::new);
1447        let scroll_controller = remember(ScrollableController::default);
1448        let target_position = PxPosition::new(Px::ZERO, Px::new(-5));
1449        scroll_controller.with_mut(|c| {
1450            c.set_visible_size_for_test(ComputedData {
1451                width: Px::new(60),
1452                height: Px::new(20),
1453            });
1454            c.set_scroll_position(target_position);
1455        });
1456
1457        scrollable()
1458            .scrollbar_behavior(ScrollBarBehavior::Hidden)
1459            .modifier(Modifier::new().constrain(
1460                Some(AxisConstraint::exact(Px::new(60))),
1461                Some(AxisConstraint::exact(Px::new(20))),
1462            ))
1463            .vertical(true)
1464            .horizontal(false)
1465            .apply_child_offset(false)
1466            .controller(scroll_controller)
1467            .child(move || {
1468                lazy_staggered_grid_view()
1469                    .axis(StaggeredGridAxis::Vertical)
1470                    .grid_cells(GridCells::fixed(2))
1471                    .main_axis_spacing(Px::new(3))
1472                    .cross_axis_spacing(Px::new(3))
1473                    .cross_axis_alignment(MainAxisAlignment::Start)
1474                    .item_alignment(CrossAxisAlignment::Start)
1475                    .estimated_item_main(Px::new(10))
1476                    .overscan(0)
1477                    .padding_main(Px::new(4))
1478                    .padding_cross(Px::new(4))
1479                    .slots(vec![super::LazySlot::items(
1480                        4,
1481                        RenderSlotWith::new(|index| match index {
1482                            0 => {
1483                                fixed_test_box()
1484                                    .tag("lazy_staggered_scroll_first".to_string())
1485                                    .width(10)
1486                                    .height(10);
1487                            }
1488                            1 => {
1489                                fixed_test_box()
1490                                    .tag("lazy_staggered_scroll_second".to_string())
1491                                    .width(12)
1492                                    .height(8);
1493                            }
1494                            2 => {
1495                                fixed_test_box()
1496                                    .tag("lazy_staggered_scroll_third".to_string())
1497                                    .width(11)
1498                                    .height(12);
1499                            }
1500                            _ => {
1501                                fixed_test_box()
1502                                    .tag("lazy_staggered_scroll_fourth".to_string())
1503                                    .width(9)
1504                                    .height(9);
1505                            }
1506                        }),
1507                        None,
1508                    )])
1509                    .controller(controller)
1510                    .scroll_controller(scroll_controller);
1511            });
1512    }
1513
1514    #[tessera]
1515    fn lazy_vertical_staggered_stable_visible_items_case() {
1516        let controller = remember(LazyStaggeredGridController::new);
1517        let scroll_controller = remember(ScrollableController::default);
1518        scroll_controller.with_mut(|c| {
1519            c.set_visible_size_for_test(ComputedData {
1520                width: Px::new(60),
1521                height: Px::new(50),
1522            });
1523        });
1524
1525        receive_frame_nanos(move |frame_nanos| {
1526            let offset = match frame_nanos {
1527                0 => 0,
1528                16_666_667 => 1,
1529                33_333_334 => 2,
1530                _ => 2,
1531            };
1532            scroll_controller.with_mut(|c| {
1533                c.set_scroll_position(PxPosition::new(Px::ZERO, Px::new(-offset)));
1534            });
1535            tessera_ui::FrameNanosControl::Continue
1536        });
1537
1538        material_theme()
1539            .theme(MaterialTheme::default)
1540            .child(move || {
1541                lazy_staggered_grid_view()
1542                    .axis(StaggeredGridAxis::Vertical)
1543                    .grid_cells(GridCells::fixed(2))
1544                    .main_axis_spacing(Px::new(3))
1545                    .cross_axis_spacing(Px::new(3))
1546                    .cross_axis_alignment(MainAxisAlignment::Start)
1547                    .item_alignment(CrossAxisAlignment::Start)
1548                    .estimated_item_main(Px::new(20))
1549                    .overscan(0)
1550                    .padding_main(Px::new(4))
1551                    .padding_cross(Px::new(4))
1552                    .slots(vec![super::LazySlot::items(
1553                        20,
1554                        RenderSlotWith::new(|index| {
1555                            fixed_test_box()
1556                                .tag(format!("lazy_staggered_stable_visible_{index}"))
1557                                .width(20)
1558                                .height(20);
1559                        }),
1560                        None,
1561                    )])
1562                    .controller(controller)
1563                    .scroll_controller(scroll_controller);
1564            });
1565    }
1566
1567    #[test]
1568    fn lazy_vertical_staggered_positions_items_in_shortest_lanes() {
1569        tessera_ui::assert_layout! {
1570            viewport: (80, 80),
1571            content: {
1572                lazy_vertical_staggered_layout_case();
1573            },
1574            expect: {
1575                node("lazy_staggered_v_first").position(4, 4).size(10, 10);
1576                node("lazy_staggered_v_second").position(32, 4).size(12, 8);
1577                node("lazy_staggered_v_third").position(32, 15).size(11, 12);
1578                node("lazy_staggered_v_fourth").position(4, 17).size(9, 9);
1579            }
1580        }
1581    }
1582
1583    #[test]
1584    fn lazy_horizontal_staggered_positions_items_in_shortest_lanes() {
1585        tessera_ui::assert_layout! {
1586            viewport: (90, 50),
1587            content: {
1588                lazy_horizontal_staggered_layout_case();
1589            },
1590            expect: {
1591                node("lazy_staggered_h_first").position(4, 4).size(20, 10);
1592                node("lazy_staggered_h_second").position(4, 22).size(15, 12);
1593                node("lazy_staggered_h_third").position(22, 22).size(18, 8);
1594                node("lazy_staggered_h_fourth").position(27, 4).size(16, 9);
1595            }
1596        }
1597    }
1598
1599    #[test]
1600    fn lazy_vertical_staggered_scroll_repositions_visible_items() {
1601        tessera_ui::assert_layout! {
1602            viewport: (80, 80),
1603            content: {
1604                lazy_vertical_staggered_scrolled_layout_case();
1605            },
1606            expect: {
1607                node("lazy_staggered_scroll_first").position(4, -1).size(10, 10);
1608                node("lazy_staggered_scroll_second").position(32, -1).size(12, 8);
1609                node("lazy_staggered_scroll_third").position(32, 10).size(11, 12);
1610                node("lazy_staggered_scroll_fourth").position(4, 12).size(9, 9);
1611            }
1612        }
1613    }
1614
1615    #[test]
1616    fn lazy_vertical_staggered_does_not_rebuild_visible_items_when_scroll_stays_in_same_window() {
1617        let mut session = tessera_ui::testing::start_layout_test_session(
1618            tessera_ui::testing::layout_test(|| {
1619                lazy_vertical_staggered_stable_visible_items_case();
1620            })
1621            .viewport_px(80, 60),
1622        );
1623
1624        let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1625        let first_builds = session
1626            .work()
1627            .count("fixed_test_box", tessera_ui::WorkPhase::Build);
1628        assert!(first_builds > 0);
1629
1630        let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 16_666_667);
1631        let second_builds = session
1632            .work()
1633            .count("fixed_test_box", tessera_ui::WorkPhase::Build);
1634        assert_eq!(second_builds, 0);
1635
1636        let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 33_333_334);
1637        let third_builds = session
1638            .work()
1639            .count("fixed_test_box", tessera_ui::WorkPhase::Build);
1640        assert_eq!(third_builds, 0);
1641    }
1642
1643    #[test]
1644    fn density_changes_rebase_lanes_and_keep_the_complete_final_item_extent() {
1645        let runtime = tessera_ui::RuntimeSession::new();
1646        let _scope = runtime.enter();
1647        crate::test_utils::run_isolated_test(
1648            "lazy_staggered_grid::tests::density_changes_rebase_lanes_and_keep_the_complete_final_item_extent",
1649            || {
1650                tessera_ui::dp::set_scale_factor(1.0);
1651                let mut cache = super::StaggeredGridCache::default();
1652                cache.prepare(6, 2, Px(20), Px(4));
1653                for (index, size) in [(0, 33), (1, 33), (4, 55), (5, 55)] {
1654                    cache.record_measurement(index, Px(size));
1655                }
1656                for (density, estimated, spacing, last_start, last_main, total) in [
1657                    (1.25, 25, 5, 76, 68, 144),
1658                    (1.5, 30, 6, 91, 82, 173),
1659                    (2.0, 40, 8, 122, 110, 232),
1660                    (1.0, 20, 4, 61, 55, 116),
1661                ] {
1662                    tessera_ui::dp::set_scale_factor(density);
1663                    cache.prepare(6, 2, Px(estimated), Px(spacing));
1664                    assert_eq!(cache.item_layout(4), Some((Px(last_start), Px(last_main))));
1665                    assert_eq!(cache.item_layout(5), Some((Px(last_start), Px(last_main))));
1666                    assert_eq!(cache.total_main(), Px(total));
1667                }
1668            },
1669        );
1670    }
1671
1672    #[test]
1673    fn staggered_cache_keeps_lanes_before_a_remeasured_item() {
1674        let mut cache = super::StaggeredGridCache::default();
1675        let estimated = Px::new(10);
1676        let spacing = Px::new(2);
1677        cache.prepare(6, 2, estimated, spacing);
1678        cache.record_measurement(0, Px::new(30));
1679        cache.record_measurement(1, Px::new(10));
1680        cache.record_measurement(2, Px::new(10));
1681        cache.record_measurement(3, Px::new(40));
1682        cache.record_measurement(4, Px::new(12));
1683        cache.record_measurement(5, Px::new(8));
1684
1685        let lanes_before: Vec<usize> = (0..6)
1686            .map(|index| {
1687                cache
1688                    .lane_of(index)
1689                    .expect("every measured item must have a lane")
1690            })
1691            .collect();
1692        let starts_before: Vec<Px> = (0..3).map(|index| cache.item_start(index)).collect();
1693
1694        cache.record_measurement(3, Px::new(4));
1695
1696        let lanes_after: Vec<usize> = (0..6)
1697            .map(|index| {
1698                cache
1699                    .lane_of(index)
1700                    .expect("every measured item must have a lane")
1701            })
1702            .collect();
1703        let starts_after: Vec<Px> = (0..3).map(|index| cache.item_start(index)).collect();
1704        assert_eq!(&lanes_after[..3], &lanes_before[..3]);
1705        assert_eq!(starts_after, starts_before);
1706        assert_ne!(&lanes_after[3..], &lanes_before[3..]);
1707    }
1708}