Skip to main content

tessera_components/
lazy_grid.rs

1//! Virtualized grid components for efficient, scrollable tile layouts.
2//!
3//! ## Usage
4//!
5//! Use lazy grids to display large, scrollable collections of tiles.
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::{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/// Defines how grid slots are computed along the cross axis.
33#[derive(Clone, PartialEq, Debug)]
34pub enum GridCells {
35    /// A fixed number of slots per line.
36    Fixed(usize),
37    /// As many slots as possible while keeping each slot at least `min_size`.
38    Adaptive(Dp),
39    /// Slots with a fixed cross-axis size.
40    FixedSize(Dp),
41}
42
43impl GridCells {
44    /// Creates a fixed-count slot configuration.
45    pub fn fixed(count: usize) -> Self {
46        Self::Fixed(count)
47    }
48
49    /// Creates an adaptive slot configuration with a minimum slot size.
50    pub fn adaptive(min_size: Dp) -> Self {
51        Self::Adaptive(min_size)
52    }
53
54    /// Creates a fixed-size slot configuration.
55    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
99/// Persistent state shared by lazy grid components.
100pub struct LazyGridController {
101    cache: LazyGridCache,
102}
103
104impl Default for LazyGridController {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl LazyGridController {
111    /// Creates a new lazy grid state with default caches.
112    pub fn new() -> Self {
113        Self {
114            cache: LazyGridCache::default(),
115        }
116    }
117}
118
119/// Accumulated lazy-grid item declarations carried by the builder.
120#[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        // Preserve usable line measurements while the cross axis is collapsed.
208        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/// # lazy_vertical_grid
366///
367/// A vertically scrolling grid that only renders items visible in the
368/// viewport.
369///
370/// ## Usage
371///
372/// Display tiled content such as photo galleries or dashboards.
373///
374/// ## Parameters
375///
376/// - `modifier` - optional modifier for the scroll container.
377/// - `scroll_smoothing` - fraction of the remaining distance kept per 60Hz
378///   frame while animating a scroll step; defaults to the scrollable default.
379/// - `scrollbar_behavior` - visibility behavior of the scrollbars.
380/// - `scrollbar_track_color` - optional scrollbar track color override.
381/// - `scrollbar_thumb_color` - optional scrollbar thumb color override.
382/// - `scrollbar_thumb_hover_color` - optional scrollbar thumb hover color
383///   override.
384/// - `scrollbar_layout` - whether scrollbars are overlaid or laid out alongside
385///   content.
386/// - `columns` - grid cell definition for columns.
387/// - `main_axis_spacing` - spacing between rows.
388/// - `cross_axis_spacing` - spacing between columns.
389/// - `cross_axis_alignment` - how columns are arranged when extra horizontal
390///   space is available.
391/// - `item_alignment` - alignment of items within each cell.
392/// - `overscan` - number of extra rows instantiated before and after the
393///   viewport.
394/// - `estimated_item_size` - estimated main-axis size for each row.
395/// - `content_padding` - symmetric padding applied around the grid content.
396/// - `max_viewport_main` - optional maximum viewport length reported back to
397///   parents.
398/// - `controller` - optional external controller for scroll position and cache.
399/// - item declarations are appended through builder methods like `item` and
400///   `items`.
401///
402/// ## Examples
403///
404/// ```
405/// use tessera_components::{
406///     lazy_grid::{GridCells, lazy_vertical_grid},
407///     text::text,
408///     theme::material_theme,
409/// };
410/// use tessera_components::modifier::{ModifierExt as _, SemanticsArgs};
411/// use tessera_ui::{Modifier, tessera};
412/// #[tessera]
413/// fn demo() {
414///     material_theme().child(|| {
415///         lazy_vertical_grid()
416///             .columns(GridCells::fixed(2))
417///             .overscan(0)
418///             .items(4, move |i| {
419///                 text(format!("Tile {i}")).modifier(
420///                     Modifier::new().semantics(SemanticsArgs {
421///                         test_tag: Some(format!("tile-{i}")), ..Default::default()
422///                     })
423///                 );
424///             });
425///     });
426/// }
427///
428/// let snapshot = tessera_ui::testing::layout_test(demo).run();
429/// for index in 0..4 {
430///     snapshot.node(&format!("tile-{index}")).exists();
431/// }
432/// ```
433#[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/// # lazy_horizontal_grid
559///
560/// A horizontally scrolling grid that only renders items visible in the
561/// viewport.
562///
563/// ## Usage
564///
565/// Display large, horizontally scrolling tile collections.
566///
567/// ## Parameters
568///
569/// - `modifier` - optional modifier for the scroll container.
570/// - `scroll_smoothing` - fraction of the remaining distance kept per 60Hz
571///   frame while animating a scroll step; defaults to the scrollable default.
572/// - `scrollbar_behavior` - visibility behavior of the scrollbars.
573/// - `scrollbar_track_color` - optional scrollbar track color override.
574/// - `scrollbar_thumb_color` - optional scrollbar thumb color override.
575/// - `scrollbar_thumb_hover_color` - optional scrollbar thumb hover color
576///   override.
577/// - `scrollbar_layout` - whether scrollbars are overlaid or laid out alongside
578///   content.
579/// - `rows` - grid cell definition for rows.
580/// - `main_axis_spacing` - spacing between columns.
581/// - `cross_axis_spacing` - spacing between rows.
582/// - `cross_axis_alignment` - how rows are arranged when extra vertical space
583///   is available.
584/// - `item_alignment` - alignment of items within each cell.
585/// - `overscan` - number of extra columns instantiated before and after the
586///   viewport.
587/// - `estimated_item_size` - estimated main-axis size for each column.
588/// - `content_padding` - symmetric padding applied around the grid content.
589/// - `max_viewport_main` - optional maximum viewport length reported back to
590///   parents.
591/// - `controller` - optional external controller for scroll position and cache.
592/// - item declarations are appended through builder methods like `item` and
593///   `items`.
594///
595/// ## Examples
596///
597/// ```
598/// use tessera_components::{
599///     lazy_grid::{GridCells, lazy_horizontal_grid},
600///     text::text,
601///     theme::material_theme,
602/// };
603/// use tessera_components::modifier::{ModifierExt as _, SemanticsArgs};
604/// use tessera_ui::{Modifier, tessera};
605/// #[tessera]
606/// fn demo() {
607///     material_theme().child(|| {
608///         lazy_horizontal_grid()
609///             .rows(GridCells::fixed(2))
610///             .overscan(0)
611///             .items(3, move |i| {
612///                 text(format!("Tile {i}")).modifier(
613///                     Modifier::new().semantics(SemanticsArgs {
614///                         test_tag: Some(format!("tile-{i}")), ..Default::default()
615///                     })
616///                 );
617///             });
618///     });
619/// }
620///
621/// let snapshot = tessera_ui::testing::layout_test(demo).run();
622/// for index in 0..3 {
623///     snapshot.node(&format!("tile-{index}")).exists();
624/// }
625/// ```
626#[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        // The cell defines an exact cross extent, so the shared rule reproduces
1024        // the previous behavior: `Stretch` fills the cell, other alignments let
1025        // the item measure itself within that extent.
1026        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}