Skip to main content

tessera_components/
lazy_list.rs

1//! Virtualized list components for displaying long scrolling feeds.
2//!
3//! ## Usage
4//!
5//! Use `lazy_column` or `lazy_row` to efficiently display large datasets.
6use std::{collections::BTreeMap, hash::Hash, ops::Range, sync::Arc};
7
8use tessera_ui::{
9    AxisConstraint, CallbackWith, ComputedData, Constraint, Dp, FocusDirection, FocusScopeNode,
10    LayoutResult, MeasurementError, Modifier, ParentConstraint, Px, PxPosition, RenderSlotWith,
11    State, key,
12    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
13    modifier::FocusModifierExt as _,
14    remember, remember_with_key, tessera,
15};
16
17use crate::{
18    alignment::CrossAxisAlignment,
19    layout_flex::{cross_axis_child_constraint, px_mul},
20    lazy_shared::{
21        FenwickTree, LazyItemsSlot, MeasuredMain, ZeroLayout, clamp_reported_main, hash_key,
22    },
23    scrollable::{ScrollBarBehavior, ScrollableController, scrollable, synchronize_scroll_density},
24};
25
26const DEFAULT_VIEWPORT_ITEMS: usize = 8;
27
28/// Persistent state for lazy list components.
29///
30/// This controller holds both the scroll position ([`ScrollableController`])
31/// and the item measurement cache. When retained across navigation (via
32/// [`retain`] or [`retain_with_key`]), both the scroll position and cached
33/// measurements will be preserved, ensuring correct layout when remounting.
34///
35/// [`retain`]: tessera_ui::retain
36/// [`retain_with_key`]: tessera_ui::retain_with_key
37///
38/// # Examples
39///
40/// ```
41/// use tessera_components::lazy_list::{LazyListController, lazy_column};
42/// use tessera_ui::{LayoutResult, retain_with_key, tessera};
43///
44/// #[tessera]
45/// fn scrollable_page(page_id: String) {
46///     // Both scroll position and measurement cache persist across navigation
47///     let controller = retain_with_key(page_id.clone(), LazyListController::new);
48///     lazy_column().controller(controller).items(100, |i| {
49///         let _ = i;
50///     });
51/// }
52/// ```
53pub struct LazyListController {
54    scroll: ScrollableController,
55    cache: LazyListCache,
56    pending_position: Option<PxPosition>,
57    pending_item: Option<ItemScrollRequest>,
58    viewport: Option<State<ScrollableController>>,
59    geometry: Option<ListScrollGeometry>,
60    composed: BTreeMap<usize, u64>,
61    focused_item: Option<usize>,
62    retains_composition: bool,
63}
64
65struct ListAttachment {
66    controller: State<LazyListController>,
67    viewport: State<ScrollableController>,
68}
69
70impl Drop for ListAttachment {
71    fn drop(&mut self) {
72        if self.controller.is_alive()
73            && self
74                .controller
75                .with(|controller| controller.viewport == Some(self.viewport))
76        {
77            self.controller.with_mut(|controller| {
78                controller.composed.clear();
79                controller.focused_item = None;
80                controller.retains_composition = false;
81                controller.viewport = None;
82                controller.geometry = None;
83            });
84        }
85    }
86}
87
88#[derive(Clone, Copy)]
89struct ItemScrollRequest {
90    index: usize,
91    offset: Dp,
92    position: Option<PxPosition>,
93}
94
95#[derive(Clone, Copy, PartialEq)]
96struct ListScrollGeometry {
97    axis: LazyListAxis,
98    estimated_main: Px,
99    spacing: Px,
100    padding: Px,
101}
102
103impl Default for LazyListController {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl LazyListController {
110    /// Creates a new lazy list controller with default scroll position and
111    /// empty cache.
112    pub fn new() -> Self {
113        Self {
114            scroll: ScrollableController::new(),
115            cache: LazyListCache::default(),
116            pending_position: None,
117            pending_item: None,
118            viewport: None,
119            geometry: None,
120            composed: BTreeMap::new(),
121            focused_item: None,
122            retains_composition: false,
123        }
124    }
125
126    /// Returns a reference to the underlying scroll controller.
127    pub fn scroll_controller(&self) -> &ScrollableController {
128        &self.scroll
129    }
130
131    /// Returns a mutable reference to the underlying scroll controller.
132    pub fn scroll_controller_mut(&mut self) -> &mut ScrollableController {
133        &mut self.scroll
134    }
135
136    /// Scrolls to content offsets from the start of each axis, in logical
137    /// pixels.
138    ///
139    /// The request is applied once when the list next builds. Subsequent wheel
140    /// and drag input continues from that position.
141    pub fn scroll_to(&mut self, x: Dp, y: Dp) {
142        let position = PxPosition::new(-Dp(x.0.max(0.0)).to_px(), -Dp(y.0.max(0.0)).to_px());
143        self.scroll.set_scroll_position(position);
144        self.pending_position = Some(position);
145        self.pending_item = None;
146    }
147
148    /// Reveals an item at an offset from the viewport start.
149    ///
150    /// Earlier unmeasured items use the list estimate. The requested item is
151    /// composed and measured before its final visible placement is published.
152    /// Requests outside the current item range are ignored. Later wheel input
153    /// continues from the request rather than being overwritten by it.
154    ///
155    /// # Panics
156    ///
157    /// Panics if `offset` is not finite.
158    pub fn scroll_to_item(&mut self, index: usize, offset: Dp) {
159        assert!(offset.0.is_finite(), "item offset must be finite");
160        let mut request = ItemScrollRequest {
161            index,
162            offset,
163            position: None,
164        };
165        if let Some(geometry) = self.geometry {
166            if index >= self.cache.total_items {
167                return;
168            }
169            let main = (self
170                .cache
171                .offset_for(index, geometry.estimated_main, geometry.spacing)
172                + geometry.padding
173                - Px::from(offset))
174            .max(Px::ZERO);
175            let position = geometry.axis.scroll_position(main);
176            self.scroll.set_scroll_position(position);
177            if let Some(viewport) = self.viewport.filter(State::is_alive) {
178                viewport.with_mut(|viewport| viewport.set_scroll_position(position));
179            }
180            request.position = Some(position);
181        }
182        self.pending_position = None;
183        self.pending_item = Some(request);
184    }
185}
186
187fn restore_scroll_position(
188    controller: State<LazyListController>,
189    viewport: State<ScrollableController>,
190) {
191    let density = tessera_ui::dp::scale_factor();
192    if controller.with(|controller| controller.cache.density != density) {
193        controller.with_mut(|controller| {
194            let ratio = controller.scroll.update_density(density);
195            if let Some(position) = &mut controller.pending_position {
196                position.x = (f64::from(position.x) * ratio) as f32;
197                position.y = (f64::from(position.y) * ratio) as f32;
198            }
199            if let Some(position) = controller
200                .pending_item
201                .as_mut()
202                .and_then(|request| request.position.as_mut())
203            {
204                position.x = (f64::from(position.x) * ratio) as f32;
205                position.y = (f64::from(position.y) * ratio) as f32;
206            }
207        });
208    }
209    synchronize_scroll_density(viewport);
210    let request = controller.with(|controller| controller.pending_position);
211    if request.is_some() {
212        controller.with_mut(|controller| controller.pending_position = None);
213    }
214    let saved = controller.with(|controller| controller.scroll.child_position());
215    let restore = saved != PxPosition::ZERO && !viewport.with(|viewport| viewport.is_positioned());
216    if let Some(position) = request.or_else(|| restore.then_some(saved)) {
217        viewport.with_mut(|viewport| viewport.set_scroll_position(position));
218    }
219}
220
221/// Accumulated lazy-list item declarations carried by the builder.
222#[derive(Clone, Default, PartialEq)]
223pub struct LazyListContent {
224    slots: Vec<LazySlot>,
225}
226
227impl LazyListContent {
228    fn push(&mut self, slot: LazySlot) {
229        self.slots.push(slot);
230    }
231}
232
233/// # lazy_column
234///
235/// A vertically scrolling list that only renders items visible in the viewport.
236///
237/// ## Usage
238///
239/// Display a long, vertical list of items without incurring the performance
240/// cost of rendering every item at once.
241/// Remeasurement preserves the first item start visible in the viewport. If no
242/// item start is visible, the partially visible item retains its offset.
243///
244/// ## Parameters
245///
246/// - `modifier` — optional modifier for the scroll container.
247/// - `cross_axis_alignment` — how items align along the cross axis.
248/// - `item_spacing` — gap between successive items.
249/// - `overscan` — number of extra items instantiated before and after the
250///   viewport.
251/// - `estimated_item_size` — estimated main-axis size used before real
252///   measurements exist.
253/// - `content_padding` — symmetric padding around the lazy list content.
254/// - `max_viewport_main` — optional maximum viewport length, also limiting
255///   initial item construction before the viewport is measured.
256/// - `scroll_smoothing` — fraction of the remaining distance kept per 60Hz
257///   frame while animating a scroll step; defaults to the scrollable default.
258/// - `scrollbar_behavior` — scrollbar visibility; visible by default.
259/// - `controller` — optional external controller for scroll position and cache.
260/// - `viewport_controller` — optional shared controller for the actual scroll
261///   viewport.
262/// - `keep_composed_items` — retain visited item state until this list
263///   unmounts; hidden items are unplaced, except the active focused item kept
264///   for reveal.
265/// - item declarations are appended through builder methods like `item`,
266///   `items`, and `sticky_header`.
267///
268/// ## Examples
269///
270/// ```
271/// use tessera_components::{lazy_list::lazy_column, text::text};
272/// use tessera_ui::tessera;
273///
274/// #[tessera]
275/// fn demo() {
276///     lazy_column().items(1000, |i| {
277///         let text_content = format!("Item #{i}");
278///         text(text_content);
279///     });
280/// }
281/// ```
282#[tessera]
283pub fn lazy_column(
284    modifier: Option<Modifier>,
285    cross_axis_alignment: Option<CrossAxisAlignment>,
286    item_spacing: Option<Dp>,
287    overscan: Option<usize>,
288    estimated_item_size: Option<Dp>,
289    content_padding: Option<Dp>,
290    max_viewport_main: Option<Px>,
291    scroll_smoothing: Option<f32>,
292    scrollbar_behavior: Option<ScrollBarBehavior>,
293    controller: Option<State<LazyListController>>,
294    viewport_controller: Option<State<ScrollableController>>,
295    keep_composed_items: Option<bool>,
296    #[prop(skip_setter)] content: Option<LazyListContent>,
297) {
298    let content = content.unwrap_or_default();
299    let controller = match controller {
300        Some(value) => value,
301        None => remember(LazyListController::new),
302    };
303    let scroll_controller = if let Some(controller) = viewport_controller {
304        controller
305    } else {
306        remember(ScrollableController::default)
307    };
308    let args = LazyListSlotsArgs {
309        modifier: modifier.unwrap_or_default(),
310        cross_axis_alignment: cross_axis_alignment.unwrap_or_default(),
311        item_spacing: item_spacing.unwrap_or(Dp(0.0)),
312        overscan: overscan.unwrap_or(0),
313        estimated_item_size: estimated_item_size.unwrap_or(Dp(0.0)),
314        content_padding: content_padding.unwrap_or(Dp(0.0)),
315        max_viewport_main,
316        scroll_smoothing: scroll_smoothing.unwrap_or(crate::scrollable::DEFAULT_SCROLL_SMOOTHING),
317        scrollbar_behavior: scrollbar_behavior.unwrap_or_default(),
318        controller,
319        scroll_controller,
320        keep_composed_items: keep_composed_items.unwrap_or(false),
321        slots: content.slots,
322    };
323    let parameters = remember(|| args.clone());
324    if parameters.with(|parameters| parameters != &args) {
325        parameters.set(args);
326    }
327    lazy_column_slots(parameters);
328}
329
330#[derive(Clone, PartialEq)]
331struct LazyListSlotsArgs {
332    modifier: Modifier,
333    cross_axis_alignment: CrossAxisAlignment,
334    item_spacing: Dp,
335    overscan: usize,
336    estimated_item_size: Dp,
337    content_padding: Dp,
338    max_viewport_main: Option<Px>,
339    scroll_smoothing: f32,
340    scrollbar_behavior: ScrollBarBehavior,
341    controller: State<LazyListController>,
342    scroll_controller: State<ScrollableController>,
343    keep_composed_items: bool,
344    slots: Vec<LazySlot>,
345}
346
347#[tessera]
348fn lazy_column_slots(parameters: State<LazyListSlotsArgs>) {
349    let args = parameters.get();
350    // The viewport controller is recreated whenever the list is remounted,
351    // while the list controller retains the last scroll position. Restore
352    // that position once, before the viewport has been positioned;
353    // comparing every frame would overwrite a newer wheel or drag offset
354    // with the previous frame's value.
355    let scroll_controller = args.scroll_controller;
356    restore_scroll_position(args.controller, scroll_controller);
357
358    scrollable()
359        .modifier(args.modifier)
360        .vertical(true)
361        .horizontal(false)
362        .apply_child_offset(false)
363        .scroll_smoothing(args.scroll_smoothing)
364        .scrollbar_behavior(args.scrollbar_behavior)
365        .controller(scroll_controller)
366        .child(move || {
367            let args = parameters.get();
368            let scroll_controller = args.scroll_controller;
369            let item_spacing = sanitize_spacing(Px::from(args.item_spacing));
370            let estimated_item_main = ensure_positive_px(Px::from(args.estimated_item_size));
371            let padding_main = sanitize_spacing(Px::from(args.content_padding));
372            let padding_cross = sanitize_spacing(Px::from(args.content_padding));
373            // Sync scroll position back to controller
374            let current_pos = scroll_controller.with(|sc| sc.child_position());
375            let should_sync_position = args
376                .controller
377                .with(|c| c.scroll.child_position() != current_pos);
378            if should_sync_position {
379                args.controller
380                    .with_mut(|c| c.scroll.set_scroll_position(current_pos));
381            }
382            lazy_list_view()
383                .axis(LazyListAxis::Vertical)
384                .cross_axis_alignment(args.cross_axis_alignment)
385                .item_spacing(item_spacing)
386                .estimated_item_main(estimated_item_main)
387                .overscan(args.overscan)
388                .padding_main(padding_main)
389                .padding_cross(padding_cross)
390                .slots(args.slots.clone())
391                .controller(args.controller)
392                .scroll_controller(scroll_controller)
393                .keep_composed_items(args.keep_composed_items)
394                .max_viewport_main_optional(args.max_viewport_main);
395        });
396}
397
398/// # lazy_row
399///
400/// A horizontally scrolling list that only renders items visible in the
401/// viewport.
402///
403/// ## Usage
404///
405/// Display a long, horizontal list of items, such as a gallery or a set of
406/// chips.
407///
408/// ## Parameters
409///
410/// - `modifier` — optional modifier for the scroll container.
411/// - `cross_axis_alignment` — how items align along the cross axis.
412/// - `item_spacing` — gap between successive items.
413/// - `overscan` — number of extra items instantiated before and after the
414///   viewport.
415/// - `estimated_item_size` — estimated main-axis size used before real
416///   measurements exist.
417/// - `content_padding` — symmetric padding around the lazy list content.
418/// - `max_viewport_main` — optional maximum viewport length, also limiting
419///   initial item construction before the viewport is measured.
420/// - `scroll_smoothing` — fraction of the remaining distance kept per 60Hz
421///   frame while animating a scroll step; defaults to the scrollable default.
422/// - `scrollbar_behavior` — scrollbar visibility; visible by default.
423/// - `controller` — optional external controller for scroll position and cache.
424/// - `viewport_controller` — optional shared controller for the actual scroll
425///   viewport.
426/// - `keep_composed_items` — retain visited item state until this list
427///   unmounts; hidden items are unplaced, except the active focused item kept
428///   for reveal.
429/// - item declarations are appended through builder methods like `item`,
430///   `items`, and `sticky_header`.
431///
432/// ## Examples
433///
434/// ```
435/// use tessera_components::{lazy_list::lazy_row, text::text};
436/// use tessera_ui::tessera;
437///
438/// #[tessera]
439/// fn demo() {
440///     lazy_row().items(100, |i| {
441///         let text_content = format!("Item {i}");
442///         text(text_content);
443///     });
444/// }
445/// ```
446#[tessera]
447pub fn lazy_row(
448    modifier: Option<Modifier>,
449    cross_axis_alignment: Option<CrossAxisAlignment>,
450    item_spacing: Option<Dp>,
451    overscan: Option<usize>,
452    estimated_item_size: Option<Dp>,
453    content_padding: Option<Dp>,
454    max_viewport_main: Option<Px>,
455    scroll_smoothing: Option<f32>,
456    scrollbar_behavior: Option<ScrollBarBehavior>,
457    controller: Option<State<LazyListController>>,
458    viewport_controller: Option<State<ScrollableController>>,
459    keep_composed_items: Option<bool>,
460    #[prop(skip_setter)] content: Option<LazyListContent>,
461) {
462    let content = content.unwrap_or_default();
463    let controller = match controller {
464        Some(value) => value,
465        None => remember(LazyListController::new),
466    };
467    let scroll_controller = if let Some(controller) = viewport_controller {
468        controller
469    } else {
470        remember(ScrollableController::default)
471    };
472    let args = LazyListSlotsArgs {
473        modifier: modifier.unwrap_or_default(),
474        cross_axis_alignment: cross_axis_alignment.unwrap_or_default(),
475        item_spacing: item_spacing.unwrap_or(Dp(0.0)),
476        overscan: overscan.unwrap_or(0),
477        estimated_item_size: estimated_item_size.unwrap_or(Dp(0.0)),
478        content_padding: content_padding.unwrap_or(Dp(0.0)),
479        max_viewport_main,
480        scroll_smoothing: scroll_smoothing.unwrap_or(crate::scrollable::DEFAULT_SCROLL_SMOOTHING),
481        scrollbar_behavior: scrollbar_behavior.unwrap_or_default(),
482        controller,
483        scroll_controller,
484        keep_composed_items: keep_composed_items.unwrap_or(false),
485        slots: content.slots,
486    };
487    let parameters = remember(|| args.clone());
488    if parameters.with(|parameters| parameters != &args) {
489        parameters.set(args);
490    }
491    lazy_row_slots(parameters);
492}
493
494macro_rules! impl_lazy_list_builder {
495    ($builder:ident) => {
496        impl $builder {
497            /// Append indexed items with stable keys and a shared render slot.
498            pub fn items_with_key_shared<K, KF>(
499                &self,
500                count: usize,
501                key_provider: KF,
502                builder: impl Into<RenderSlotWith<usize>> + Send + Sync + 'static,
503            ) -> Self
504            where
505                K: Hash,
506                KF: Fn(usize) -> K + Send + Sync + 'static,
507            {
508                self.props.append("content", move |props| {
509                    let slot = {
510                        let builder = builder.into();
511                        LazySlot::Items(LazyItemsSlot {
512                            count,
513                            builder,
514                            key_provider: Some(CallbackWith::new(move |index| {
515                                hash_key(key_provider(index))
516                            })),
517                        })
518                    };
519                    props
520                        .content
521                        .get_or_insert_with(LazyListContent::default)
522                        .push(slot);
523                });
524                Self { props: self.props }
525            }
526
527            /// Append a sticky header item.
528            pub fn sticky_header<F, Output>(&self, builder: F) -> Self
529            where
530                F: Fn() -> Output + Send + Sync + 'static,
531            {
532                self.props.append("content", move |props| {
533                    let slot = LazySlot::sticky(
534                        RenderSlotWith::new(move |_| {
535                            builder();
536                        }),
537                        None,
538                    );
539                    props
540                        .content
541                        .get_or_insert_with(LazyListContent::default)
542                        .push(slot);
543                });
544                Self { props: self.props }
545            }
546
547            /// Append a sticky header item with a stable key.
548            pub fn sticky_header_with_key<K, F, Output>(&self, key: K, builder: F) -> Self
549            where
550                K: Hash,
551                F: Fn() -> Output + Send + Sync + 'static,
552            {
553                let key = hash_key(key);
554                self.props.append("content", move |props| {
555                    let slot = LazySlot::sticky(
556                        RenderSlotWith::new(move |_| {
557                            builder();
558                        }),
559                        Some(key),
560                    );
561                    props
562                        .content
563                        .get_or_insert_with(LazyListContent::default)
564                        .push(slot);
565                });
566                Self { props: self.props }
567            }
568
569            /// Append a sticky header with a stable key and a shared render slot.
570            pub fn sticky_header_with_key_shared<K>(
571                &self,
572                key: K,
573                builder: impl Into<RenderSlotWith<usize>> + Send + Sync + 'static,
574            ) -> Self
575            where
576                K: Hash,
577            {
578                let key_hash = Some(hash_key(key));
579                self.props.append("content", move |props| {
580                    let slot = {
581                        let builder = builder.into();
582                        LazySlot::Sticky(LazyStickySlot { builder, key_hash })
583                    };
584                    props
585                        .content
586                        .get_or_insert_with(LazyListContent::default)
587                        .push(slot);
588                });
589                Self { props: self.props }
590            }
591        }
592    };
593}
594
595impl_lazy_list_builder!(LazyColumnBuilder);
596impl_lazy_list_builder!(LazyRowBuilder);
597crate::lazy_shared::impl_lazy_builder!(LazyColumnBuilder, LazyListContent);
598crate::lazy_shared::impl_lazy_builder!(LazyRowBuilder, LazyListContent);
599
600#[tessera]
601fn lazy_row_slots(parameters: State<LazyListSlotsArgs>) {
602    let args = parameters.get();
603    // The viewport controller is recreated whenever the list is remounted,
604    // while the list controller retains the last scroll position. Restore
605    // that position once, before the viewport has been positioned;
606    // comparing every frame would overwrite a newer wheel or drag offset
607    // with the previous frame's value.
608    let scroll_controller = args.scroll_controller;
609    restore_scroll_position(args.controller, scroll_controller);
610
611    scrollable()
612        .modifier(args.modifier)
613        .vertical(false)
614        .horizontal(true)
615        .apply_child_offset(false)
616        .scroll_smoothing(args.scroll_smoothing)
617        .scrollbar_behavior(args.scrollbar_behavior)
618        .controller(scroll_controller)
619        .child(move || {
620            let args = parameters.get();
621            let scroll_controller = args.scroll_controller;
622            let item_spacing = sanitize_spacing(Px::from(args.item_spacing));
623            let estimated_item_main = ensure_positive_px(Px::from(args.estimated_item_size));
624            let padding_main = sanitize_spacing(Px::from(args.content_padding));
625            let padding_cross = sanitize_spacing(Px::from(args.content_padding));
626            // Sync scroll position back to controller
627            let current_pos = scroll_controller.with(|sc| sc.child_position());
628            let should_sync_position = args
629                .controller
630                .with(|c| c.scroll.child_position() != current_pos);
631            if should_sync_position {
632                args.controller
633                    .with_mut(|c| c.scroll.set_scroll_position(current_pos));
634            }
635            lazy_list_view()
636                .axis(LazyListAxis::Horizontal)
637                .cross_axis_alignment(args.cross_axis_alignment)
638                .item_spacing(item_spacing)
639                .estimated_item_main(estimated_item_main)
640                .overscan(args.overscan)
641                .padding_main(padding_main)
642                .padding_cross(padding_cross)
643                .slots(args.slots.clone())
644                .controller(args.controller)
645                .scroll_controller(scroll_controller)
646                .keep_composed_items(args.keep_composed_items)
647                .max_viewport_main_optional(args.max_viewport_main);
648        });
649}
650
651#[tessera]
652fn lazy_list_view(
653    axis: Option<LazyListAxis>,
654    cross_axis_alignment: Option<CrossAxisAlignment>,
655    item_spacing: Option<Px>,
656    estimated_item_main: Option<Px>,
657    overscan: Option<usize>,
658    max_viewport_main: Option<Px>,
659    padding_main: Option<Px>,
660    padding_cross: Option<Px>,
661    controller: Option<State<LazyListController>>,
662    slots: Option<Vec<LazySlot>>,
663    scroll_controller: Option<State<ScrollableController>>,
664    keep_composed_items: Option<bool>,
665) {
666    let keep_composed_items = keep_composed_items.unwrap_or(false);
667    let axis = axis.unwrap_or_default();
668    let cross_axis_alignment = cross_axis_alignment.unwrap_or_default();
669    let item_spacing = item_spacing.unwrap_or(Px::ZERO);
670    let estimated_item_main = estimated_item_main.unwrap_or(Px::ZERO);
671    let overscan = overscan.unwrap_or(0);
672    let padding_main = padding_main.unwrap_or(Px::ZERO);
673    let padding_cross = padding_cross.unwrap_or(Px::ZERO);
674    let slots = slots.unwrap_or_default();
675    let controller = controller.expect("lazy_list_view requires controller");
676    let scroll_controller = scroll_controller.expect("lazy_list_view requires scroll_controller");
677    let _attachment = remember_with_key((controller, scroll_controller), || ListAttachment {
678        controller,
679        viewport: scroll_controller,
680    });
681    let plan = LazySlotPlan::new(slots.clone());
682    let total_count = plan.total_count();
683
684    let cache_config_changed = controller.with(|c| {
685        c.cache.total_items != total_count
686            || c.cache.estimated_main != estimated_item_main
687            || c.cache.density != tessera_ui::dp::scale_factor()
688    });
689    if cache_config_changed {
690        controller.with_mut(|c| c.cache.configure(total_count, estimated_item_main));
691    }
692
693    let geometry = ListScrollGeometry {
694        axis,
695        estimated_main: estimated_item_main,
696        spacing: item_spacing,
697        padding: padding_main,
698    };
699    if controller.with(|controller| {
700        controller.viewport != Some(scroll_controller)
701            || controller.geometry != Some(geometry)
702            || controller.retains_composition != keep_composed_items
703    }) {
704        controller.with_mut(|controller| {
705            controller.viewport = Some(scroll_controller);
706            controller.geometry = Some(geometry);
707            controller.retains_composition = keep_composed_items;
708        });
709    }
710    if !keep_composed_items
711        && controller
712            .with(|controller| !controller.composed.is_empty() || controller.focused_item.is_some())
713    {
714        controller.with_mut(|controller| {
715            controller.composed.clear();
716            controller.focused_item = None;
717        });
718    }
719    let request = controller.with(|controller| controller.pending_item);
720    if let Some(mut request) = request {
721        if request.index >= total_count {
722            controller.with_mut(|controller| controller.pending_item = None);
723        } else {
724            let main = controller.with(|controller| {
725                controller
726                    .cache
727                    .offset_for(request.index, estimated_item_main, item_spacing)
728            }) + padding_main
729                - Px::from(request.offset);
730            let desired = axis.scroll_position(main.max(Px::ZERO));
731            if let Some(previous) = request.position {
732                let delta = desired - previous;
733                if delta != PxPosition::ZERO {
734                    scroll_controller.with_mut(|scroll| scroll.translate_scroll_position(delta));
735                }
736            } else {
737                scroll_controller.with_mut(|scroll| scroll.set_scroll_position(desired));
738            }
739            request.position = Some(desired);
740            controller.with_mut(|controller| controller.pending_item = Some(request));
741        }
742    }
743    let scroll_offset = axis.scroll_offset(scroll_controller.with(|s| s.child_position()));
744    let viewport_span = resolve_viewport_span(
745        axis.visible_span(scroll_controller.with(|s| s.visible_size())),
746        estimated_item_main,
747        item_spacing,
748    );
749    let viewport_span = max_viewport_main
750        .map(|limit| viewport_span.min(limit.max(Px::ZERO)))
751        .unwrap_or(viewport_span);
752    let viewport_span = (viewport_span - (padding_main * 2)).max(Px::ZERO);
753    let total_main =
754        controller.with(|c| c.cache.total_main_size(estimated_item_main, item_spacing));
755    let total_main_with_padding = total_main + padding_main + padding_main;
756    let visible_cross = axis.cross(&scroll_controller.with(|s| s.visible_size()));
757    // Only the main axis affects lazy-list scroll extents. Cross-axis size is
758    // finalized during measurement (with real child sizes), so keep the
759    // existing cross value here to avoid view/measure override oscillation.
760    let current_child_cross = axis.cross(&scroll_controller.with(|s| s.child_size()));
761    let estimated_cross = if current_child_cross > Px::ZERO {
762        current_child_cross
763    } else {
764        visible_cross
765    };
766    let scroll_child_size = axis.pack_size(total_main_with_padding, estimated_cross);
767    let needs_scroll_child_size_update =
768        scroll_controller.with(|c| c.child_size() != scroll_child_size);
769    if needs_scroll_child_size_update {
770        scroll_controller.with_mut(|c| c.override_child_size(scroll_child_size));
771    }
772
773    let mut visible_children = controller.with(|c| {
774        compute_visible_children(
775            &plan,
776            &c.cache,
777            total_count,
778            scroll_offset,
779            viewport_span,
780            overscan,
781            estimated_item_main,
782            item_spacing,
783        )
784    });
785
786    let invalid_focus = controller.with(|controller| {
787        controller.focused_item.is_some_and(|index| {
788            plan.visible_child(index)
789                .is_none_or(|child| controller.composed.get(&index) != Some(&child.key_hash))
790        })
791    });
792    if invalid_focus {
793        controller.with_mut(|controller| controller.focused_item = None);
794    }
795    for index in controller
796        .with(|controller| {
797            [
798                controller.pending_item.map(|request| request.index),
799                controller.focused_item,
800            ]
801        })
802        .into_iter()
803        .flatten()
804    {
805        if !visible_children
806            .children
807            .iter()
808            .any(|child| child.item_index == index)
809            && let Some(child) = plan.visible_child(index)
810        {
811            visible_children.children.push(child);
812        }
813    }
814    visible_children
815        .children
816        .sort_unstable_by_key(|child| child.item_index);
817    if visible_children.children.is_empty() {
818        if controller
819            .with(|controller| !controller.composed.is_empty() || controller.focused_item.is_some())
820        {
821            controller.with_mut(|controller| {
822                controller.composed.clear();
823                controller.focused_item = None;
824            });
825        }
826        layout().policy(ZeroLayout);
827        return;
828    }
829
830    let next_focus = LazyListFocusArgs {
831        axis,
832        controller,
833        scroll_controller,
834        total_count,
835        total_main,
836        viewport_span,
837        scroll_offset,
838        visible_range: visible_children.range.clone(),
839        estimated_item_main,
840        item_spacing,
841    };
842    let focus_args = remember(|| next_focus.clone());
843    if focus_args.with(|args| args != &next_focus) {
844        focus_args.set(next_focus);
845    }
846    let focus_modifier = lazy_list_focus_beyond_bounds_modifier(focus_args);
847
848    let viewport_limit = viewport_span + padding_main + padding_main;
849    let visible_item_indices = visible_children
850        .children
851        .iter()
852        .map(|visible| visible.item_index)
853        .collect();
854
855    let mut children = if keep_composed_items {
856        let mut history = controller.with(|controller| controller.composed.clone());
857        history.retain(|index, key| {
858            plan.visible_child(*index)
859                .is_some_and(|child| child.key_hash == *key)
860        });
861        for child in &visible_children.children {
862            history.insert(child.item_index, child.key_hash);
863        }
864        if controller.with(|controller| controller.composed != history) {
865            controller.with_mut(|controller| controller.composed = history.clone());
866        }
867        history
868            .keys()
869            .filter_map(|index| plan.visible_child(*index))
870            .collect::<Vec<_>>()
871    } else {
872        visible_children.children
873    };
874    // Sticky items overlap scrolling content and must own the upper paint and
875    // hit-test order.
876    children.sort_by_key(|child| {
877        plan.sticky_indices()
878            .binary_search(&child.item_index)
879            .is_ok()
880    });
881    let composed_item_indices = children.iter().map(|child| child.item_index).collect();
882    layout()
883        .modifier(focus_modifier)
884        .policy(LazyListLayout {
885            axis,
886            cross_axis_alignment,
887            item_spacing,
888            estimated_item_main,
889            max_viewport_main,
890            padding_main,
891            padding_cross,
892            viewport_limit,
893            visible_item_indices,
894            composed_item_indices,
895            sticky_indices: plan.sticky_indices().to_vec(),
896            scroll_offset,
897            controller,
898            scroll_controller,
899        })
900        .child(move || {
901            for child in &children {
902                let child = child.clone();
903                key(child.key_hash, || {
904                    retained_lazy_item(
905                        child.item_index,
906                        child.local_index,
907                        child.builder,
908                        controller,
909                        keep_composed_items,
910                    );
911                });
912            }
913        });
914}
915
916#[derive(Clone, PartialEq)]
917struct LazyListLayout {
918    axis: LazyListAxis,
919    cross_axis_alignment: CrossAxisAlignment,
920    item_spacing: Px,
921    estimated_item_main: Px,
922    max_viewport_main: Option<Px>,
923    padding_main: Px,
924    padding_cross: Px,
925    viewport_limit: Px,
926    visible_item_indices: Vec<usize>,
927    composed_item_indices: Vec<usize>,
928    sticky_indices: Vec<usize>,
929    scroll_offset: Px,
930    controller: State<LazyListController>,
931    scroll_controller: State<ScrollableController>,
932}
933
934impl tessera_ui::LayoutModifierNode for LazyListLayout {
935    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
936        let mut result = LayoutResult::default();
937        let children = input.children();
938        if children.len() != self.composed_item_indices.len() {
939            return Err(MeasurementError::MeasureFnFailed(
940                "Lazy list measured child count mismatch".into(),
941            ));
942        }
943
944        let mut measured_children: Vec<_> = self
945            .composed_item_indices
946            .iter()
947            .copied()
948            .zip(children.iter().copied())
949            .filter(|(index, _)| self.visible_item_indices.binary_search(index).is_ok())
950            .collect();
951        measured_children.sort_unstable_by_key(|(item_index, _)| *item_index);
952
953        let mut child_constraint = self
954            .axis
955            .child_constraint(input.parent_constraint(), self.cross_axis_alignment);
956        apply_cross_padding(&mut child_constraint, self.axis, self.padding_cross);
957        let mut measured_entries = Vec::with_capacity(self.visible_item_indices.len());
958        let mut inner_cross = Px::ZERO;
959        for (item_index, child) in &measured_children {
960            let child_size = child.measure(input, &child_constraint)?;
961            let child_size = child_size.size();
962            let measured_main = self.axis.main(&child_size);
963            inner_cross = inner_cross.max(self.axis.cross(&child_size));
964            measured_entries.push((*item_index, *child, child_size, measured_main));
965        }
966
967        let cross_limit = match self.axis {
968            LazyListAxis::Vertical => child_constraint.width.resolve_max(),
969            LazyListAxis::Horizontal => child_constraint.height.resolve_max(),
970        };
971        // Collapsed viewports can wrap text into misleading main-axis sizes.
972        let has_cache_updates = cross_limit.is_none_or(|size| size > Px::ZERO)
973            && self.controller.with(|c| {
974                measured_entries
975                    .iter()
976                    .any(|(item_index, _, _, measured_main)| {
977                        c.cache.measurement(*item_index) != Some(*measured_main)
978                    })
979            });
980        if has_cache_updates {
981            let scroll_offset = self.axis.scroll_offset(
982                self.scroll_controller
983                    .with(|controller| controller.child_position()),
984            );
985            let anchor_delta = self.controller.with_mut(|c| {
986                let mut anchor_index =
987                    c.pending_item
988                        .map(|request| request.index)
989                        .unwrap_or_else(|| {
990                            c.cache.index_for_offset(
991                                (scroll_offset - self.padding_main).max(Px::ZERO),
992                                self.estimated_item_main,
993                                self.item_spacing,
994                            )
995                        });
996                let first_start =
997                    c.cache
998                        .offset_for(anchor_index, self.estimated_item_main, self.item_spacing)
999                        + self.padding_main;
1000                let next_start = c.cache.offset_for(
1001                    anchor_index + 1,
1002                    self.estimated_item_main,
1003                    self.item_spacing,
1004                ) + self.padding_main;
1005                // A mostly hidden large item must not displace the next visible
1006                // item start.
1007                if c.pending_item.is_none()
1008                    && first_start < scroll_offset
1009                    && anchor_index + 1 < c.cache.total_items
1010                    && next_start < scroll_offset + self.viewport_limit
1011                {
1012                    anchor_index += 1;
1013                }
1014                let anchor_offset = (c.pending_item.is_some()
1015                    || c.cache.measurement(anchor_index).is_some())
1016                .then(|| {
1017                    c.cache
1018                        .offset_for(anchor_index, self.estimated_item_main, self.item_spacing)
1019                });
1020                for (item_index, _, _, measured_main) in &measured_entries {
1021                    c.cache.record_measurement(
1022                        *item_index,
1023                        *measured_main,
1024                        self.estimated_item_main,
1025                    );
1026                }
1027                anchor_offset.map_or(Px::ZERO, |previous| {
1028                    c.cache
1029                        .offset_for(anchor_index, self.estimated_item_main, self.item_spacing)
1030                        - previous
1031                })
1032            });
1033            if anchor_delta != Px::ZERO {
1034                self.scroll_controller.with_mut(|controller| {
1035                    controller.translate_scroll_position(self.axis.scroll_position(anchor_delta));
1036                });
1037            }
1038        }
1039
1040        if self.controller.with(|controller| {
1041            controller.pending_item.is_some_and(|request| {
1042                measured_entries
1043                    .iter()
1044                    .any(|(index, _, _, _)| *index == request.index)
1045            })
1046        }) {
1047            self.controller
1048                .with_mut(|controller| controller.pending_item = None);
1049        }
1050        let scroll_offset = self.axis.scroll_offset(
1051            self.scroll_controller
1052                .with(|controller| controller.child_position()),
1053        );
1054
1055        let (item_offsets, total_main) = self.controller.with(|c| {
1056            let item_offsets = measured_entries
1057                .iter()
1058                .map(|(item_index, _, _, _)| {
1059                    c.cache
1060                        .offset_for(*item_index, self.estimated_item_main, self.item_spacing)
1061                })
1062                .collect::<Vec<_>>();
1063            let total_main = c
1064                .cache
1065                .total_main_size(self.estimated_item_main, self.item_spacing);
1066            (item_offsets, total_main)
1067        });
1068        let mut placements = Vec::with_capacity(measured_entries.len());
1069        for ((item_index, child_id, child_size, _), offset_main) in
1070            measured_entries.into_iter().zip(item_offsets)
1071        {
1072            placements.push((item_index, child_id, offset_main, child_size));
1073        }
1074
1075        let total_main_with_padding = total_main + self.padding_main + self.padding_main;
1076        let cross_with_padding = inner_cross + self.padding_cross + self.padding_cross;
1077        let size = self
1078            .axis
1079            .pack_size(total_main_with_padding, cross_with_padding);
1080        let needs_scroll_child_size_update =
1081            self.scroll_controller.with(|c| c.child_size() != size);
1082        if needs_scroll_child_size_update {
1083            self.scroll_controller
1084                .with_mut(|c| c.override_child_size(size));
1085        }
1086
1087        let reported_main = clamp_reported_main(
1088            total_main_with_padding,
1089            self.viewport_limit,
1090            self.axis.constraint_max(input.parent_constraint()),
1091            self.max_viewport_main,
1092        );
1093
1094        for placement in &placements {
1095            let cross_offset = compute_cross_offset(
1096                inner_cross,
1097                self.axis.cross(&placement.3),
1098                self.cross_axis_alignment,
1099            );
1100            let mut main_offset = placement.2 + self.padding_main - scroll_offset;
1101            if self.is_sticky(placement.0) {
1102                let sticky_start = self.padding_main;
1103                main_offset = main_offset.max(sticky_start);
1104                if let Some(next_index) = self.next_sticky_after(placement.0) {
1105                    let next_offset = self.controller.with(|c| {
1106                        c.cache
1107                            .offset_for(next_index, self.estimated_item_main, self.item_spacing)
1108                    });
1109                    let max_offset = next_offset + self.padding_main
1110                        - self.axis.main(&placement.3)
1111                        - scroll_offset;
1112                    main_offset = main_offset.min(max_offset);
1113                }
1114            }
1115            let position = self
1116                .axis
1117                .position(main_offset, self.padding_cross + cross_offset);
1118            result.place_child(placement.1, position);
1119        }
1120
1121        Ok(result.with_size(self.axis.pack_size(reported_main, cross_with_padding)))
1122    }
1123
1124    fn measure_eq(&self, other: &Self) -> bool {
1125        self.axis == other.axis
1126            && self.cross_axis_alignment == other.cross_axis_alignment
1127            && self.item_spacing == other.item_spacing
1128            && self.estimated_item_main == other.estimated_item_main
1129            && self.max_viewport_main == other.max_viewport_main
1130            && self.padding_main == other.padding_main
1131            && self.padding_cross == other.padding_cross
1132            && self.viewport_limit == other.viewport_limit
1133            && self.visible_item_indices == other.visible_item_indices
1134            && self.composed_item_indices == other.composed_item_indices
1135            && self.sticky_indices == other.sticky_indices
1136    }
1137
1138    fn placement_eq(&self, other: &Self) -> bool {
1139        self.measure_eq(other) && self.scroll_offset == other.scroll_offset
1140    }
1141
1142    fn place_children(
1143        &self,
1144        input: &PlacementScope<'_>,
1145        _inner: &PlacementResult,
1146    ) -> Option<PlacementResult> {
1147        let mut result = LayoutResult::default();
1148        let children = input.children();
1149        if children.len() != self.composed_item_indices.len() {
1150            return None;
1151        }
1152
1153        let mut placements = Vec::with_capacity(self.visible_item_indices.len());
1154        let scroll_offset = self.axis.scroll_offset(
1155            self.scroll_controller
1156                .with(|controller| controller.child_position()),
1157        );
1158        let mut inner_cross = Px::ZERO;
1159        for (&item_index, &child) in self.composed_item_indices.iter().zip(children.iter()) {
1160            if self
1161                .visible_item_indices
1162                .binary_search(&item_index)
1163                .is_err()
1164            {
1165                continue;
1166            }
1167            let child_size = child.size();
1168            let measured_cross = self.axis.cross(&child_size);
1169            inner_cross = inner_cross.max(measured_cross);
1170            let offset_main = self.controller.with(|c| {
1171                c.cache
1172                    .offset_for(item_index, self.estimated_item_main, self.item_spacing)
1173            });
1174            placements.push((item_index, child, offset_main, child_size));
1175        }
1176
1177        placements.sort_unstable_by_key(|placement| placement.0);
1178        for placement in &placements {
1179            let cross_offset = compute_cross_offset(
1180                inner_cross,
1181                self.axis.cross(&placement.3),
1182                self.cross_axis_alignment,
1183            );
1184            let mut main_offset = placement.2 + self.padding_main - scroll_offset;
1185            if self.is_sticky(placement.0) {
1186                let sticky_start = self.padding_main;
1187                main_offset = main_offset.max(sticky_start);
1188                if let Some(next_index) = self.next_sticky_after(placement.0) {
1189                    let next_offset = self.controller.with(|c| {
1190                        c.cache
1191                            .offset_for(next_index, self.estimated_item_main, self.item_spacing)
1192                    });
1193                    let max_offset = next_offset + self.padding_main
1194                        - self.axis.main(&placement.3)
1195                        - scroll_offset;
1196                    main_offset = main_offset.min(max_offset);
1197                }
1198            }
1199            let position = self
1200                .axis
1201                .position(main_offset, self.padding_cross + cross_offset);
1202            result.place_child(placement.1, position);
1203        }
1204
1205        Some(result.into_placement_result())
1206    }
1207}
1208
1209impl LazyListLayout {
1210    fn is_sticky(&self, index: usize) -> bool {
1211        self.sticky_indices.binary_search(&index).is_ok()
1212    }
1213
1214    fn next_sticky_after(&self, index: usize) -> Option<usize> {
1215        match self.sticky_indices.binary_search(&index) {
1216            Ok(pos) => self.sticky_indices.get(pos + 1).copied(),
1217            Err(pos) => self.sticky_indices.get(pos).copied(),
1218        }
1219    }
1220}
1221
1222fn resolve_viewport_span(current: Px, estimated: Px, spacing: Px) -> Px {
1223    if current > Px::ZERO {
1224        current
1225    } else {
1226        let per_item = estimated + spacing;
1227        px_mul(per_item, DEFAULT_VIEWPORT_ITEMS.max(1))
1228    }
1229}
1230
1231#[allow(clippy::too_many_arguments)]
1232fn compute_visible_children(
1233    plan: &LazySlotPlan,
1234    cache: &LazyListCache,
1235    total_count: usize,
1236    scroll_offset: Px,
1237    viewport_span: Px,
1238    overscan: usize,
1239    estimated_main: Px,
1240    spacing: Px,
1241) -> VisibleChildrenPlan {
1242    if total_count == 0 {
1243        return VisibleChildrenPlan::empty();
1244    }
1245
1246    let mut start_index = cache.index_for_offset(scroll_offset, estimated_main, spacing);
1247    let end_target = scroll_offset + viewport_span;
1248    let mut end_index = cache.index_for_offset(end_target, estimated_main, spacing) + 1;
1249
1250    start_index = start_index.saturating_sub(overscan);
1251    end_index = (end_index + overscan).min(total_count);
1252    if start_index >= end_index {
1253        end_index = (start_index + 1).min(total_count);
1254        start_index = start_index.saturating_sub(1);
1255    }
1256
1257    let visible_range = start_index..end_index;
1258    let mut result = plan.visible_children(visible_range.clone());
1259    if let Some(sticky_index) = plan.last_sticky_before(start_index) {
1260        let existing = result
1261            .iter()
1262            .position(|child| child.item_index == sticky_index);
1263        let sticky_child = existing
1264            .map(|pos| result.remove(pos))
1265            .or_else(|| plan.visible_child(sticky_index));
1266        if let Some(child) = sticky_child {
1267            result.push(child);
1268        }
1269    }
1270    VisibleChildrenPlan {
1271        children: result,
1272        range: visible_range,
1273    }
1274}
1275
1276fn compute_cross_offset(final_cross: Px, child_cross: Px, alignment: CrossAxisAlignment) -> Px {
1277    match alignment {
1278        CrossAxisAlignment::Start | CrossAxisAlignment::Stretch => Px::ZERO,
1279        CrossAxisAlignment::Center => (final_cross - child_cross).max(Px::ZERO) / 2,
1280        CrossAxisAlignment::End => (final_cross - child_cross).max(Px::ZERO),
1281    }
1282}
1283
1284#[tessera]
1285fn retained_lazy_item(
1286    index: usize,
1287    local_index: usize,
1288    builder: RenderSlotWith<usize>,
1289    controller: State<LazyListController>,
1290    retain_focus: bool,
1291) {
1292    let scope = remember(FocusScopeNode::new).get();
1293    let binding = remember(|| {
1294        (
1295            index,
1296            local_index,
1297            builder.clone(),
1298            controller,
1299            retain_focus,
1300        )
1301    });
1302    let binding_changed = binding.with(|binding| {
1303        binding.0 != index
1304            || binding.1 != local_index
1305            || binding.2 != builder
1306            || binding.3 != controller
1307            || binding.4 != retain_focus
1308    });
1309    if binding_changed {
1310        if retain_focus
1311            && scope.has_focus()
1312            && controller.with(|controller| controller.focused_item != Some(index))
1313        {
1314            controller.with_mut(|controller| controller.focused_item = Some(index));
1315        }
1316        binding.set((index, local_index, builder, controller, retain_focus));
1317    }
1318    let modifier = Modifier::new().focus_scope_with(scope);
1319    let modifier = if retain_focus {
1320        modifier.on_focus_changed(move |focus: tessera_ui::FocusState| {
1321            if !binding.is_alive() {
1322                return;
1323            }
1324            let (index, _, _, controller, _) = binding.get();
1325            if !controller.is_alive()
1326                || !controller.with(|controller| controller.retains_composition)
1327            {
1328                return;
1329            }
1330            let previous = controller.with(|controller| controller.focused_item);
1331            let next = if focus.has_focus() {
1332                Some(index)
1333            } else if previous == Some(index) {
1334                None
1335            } else {
1336                previous
1337            };
1338            if previous != next {
1339                controller.with_mut(|controller| controller.focused_item = next);
1340            }
1341        })
1342    } else {
1343        modifier
1344    };
1345    layout().modifier(modifier).child(move || {
1346        let (_, local_index, builder, _, _) = binding.get();
1347        builder.render(local_index);
1348    });
1349}
1350
1351#[derive(Clone, Copy, PartialEq, Eq, Default)]
1352enum LazyListAxis {
1353    #[default]
1354    Vertical,
1355    Horizontal,
1356}
1357
1358#[derive(Clone, Copy)]
1359enum FocusScrollDirection {
1360    Forward,
1361    Backward,
1362}
1363
1364impl LazyListAxis {
1365    fn main(&self, size: &ComputedData) -> Px {
1366        match self {
1367            Self::Vertical => size.height,
1368            Self::Horizontal => size.width,
1369        }
1370    }
1371
1372    fn cross(&self, size: &ComputedData) -> Px {
1373        match self {
1374            Self::Vertical => size.width,
1375            Self::Horizontal => size.height,
1376        }
1377    }
1378
1379    fn pack_size(&self, main: Px, cross: Px) -> ComputedData {
1380        match self {
1381            Self::Vertical => ComputedData {
1382                width: cross,
1383                height: main,
1384            },
1385            Self::Horizontal => ComputedData {
1386                width: main,
1387                height: cross,
1388            },
1389        }
1390    }
1391
1392    fn position(&self, main: Px, cross: Px) -> PxPosition {
1393        match self {
1394            Self::Vertical => PxPosition::new(cross, main),
1395            Self::Horizontal => PxPosition::new(main, cross),
1396        }
1397    }
1398
1399    fn visible_span(&self, size: ComputedData) -> Px {
1400        match self {
1401            Self::Vertical => size.height,
1402            Self::Horizontal => size.width,
1403        }
1404    }
1405
1406    fn scroll_position(&self, offset: Px) -> PxPosition {
1407        match self {
1408            Self::Vertical => PxPosition::new(Px::ZERO, -offset),
1409            Self::Horizontal => PxPosition::new(-offset, Px::ZERO),
1410        }
1411    }
1412
1413    fn scroll_offset(&self, position: PxPosition) -> Px {
1414        match self {
1415            Self::Vertical => Px((-position.y).max(0.0) as i32),
1416            Self::Horizontal => Px((-position.x).max(0.0) as i32),
1417        }
1418    }
1419
1420    fn focus_scroll_direction(&self, direction: FocusDirection) -> Option<FocusScrollDirection> {
1421        match (self, direction) {
1422            (_, FocusDirection::Next | FocusDirection::Enter) => {
1423                Some(FocusScrollDirection::Forward)
1424            }
1425            (_, FocusDirection::Previous | FocusDirection::Exit) => {
1426                Some(FocusScrollDirection::Backward)
1427            }
1428            (Self::Vertical, FocusDirection::Down) => Some(FocusScrollDirection::Forward),
1429            (Self::Vertical, FocusDirection::Up) => Some(FocusScrollDirection::Backward),
1430            (Self::Horizontal, FocusDirection::Right) => Some(FocusScrollDirection::Forward),
1431            (Self::Horizontal, FocusDirection::Left) => Some(FocusScrollDirection::Backward),
1432            _ => None,
1433        }
1434    }
1435
1436    fn child_constraint(
1437        &self,
1438        parent: ParentConstraint<'_>,
1439        alignment: CrossAxisAlignment,
1440    ) -> Constraint {
1441        let cross_constraint = match self {
1442            Self::Vertical => cross_axis_child_constraint(parent.width(), alignment),
1443            Self::Horizontal => cross_axis_child_constraint(parent.height(), alignment),
1444        };
1445
1446        match self {
1447            Self::Vertical => Constraint::new(cross_constraint, AxisConstraint::NONE),
1448            Self::Horizontal => Constraint::new(AxisConstraint::NONE, cross_constraint),
1449        }
1450    }
1451
1452    fn constraint_max(&self, constraint: ParentConstraint<'_>) -> Option<Px> {
1453        match self {
1454            Self::Vertical => constraint.height().resolve_max(),
1455            Self::Horizontal => constraint.width().resolve_max(),
1456        }
1457    }
1458}
1459
1460#[derive(Clone)]
1461struct VisibleChildrenPlan {
1462    children: Vec<VisibleChild>,
1463    range: Range<usize>,
1464}
1465
1466impl VisibleChildrenPlan {
1467    fn empty() -> Self {
1468        Self {
1469            children: Vec::new(),
1470            range: 0..0,
1471        }
1472    }
1473}
1474
1475#[derive(Clone, PartialEq)]
1476struct LazyListFocusArgs {
1477    axis: LazyListAxis,
1478    controller: State<LazyListController>,
1479    scroll_controller: State<ScrollableController>,
1480    total_count: usize,
1481    total_main: Px,
1482    viewport_span: Px,
1483    scroll_offset: Px,
1484    visible_range: Range<usize>,
1485    estimated_item_main: Px,
1486    item_spacing: Px,
1487}
1488
1489fn lazy_list_focus_beyond_bounds_modifier(args: State<LazyListFocusArgs>) -> Modifier {
1490    Modifier::new().focus_beyond_bounds_handler(CallbackWith::new(move |direction| {
1491        if !args.is_alive() {
1492            return false;
1493        }
1494        let args = args.get();
1495        let max_scroll = (args.total_main - args.viewport_span).max(Px::ZERO);
1496        let Some(scroll_direction) = args.axis.focus_scroll_direction(direction) else {
1497            return false;
1498        };
1499        if args.total_count == 0 || args.viewport_span <= Px::ZERO {
1500            return false;
1501        }
1502
1503        let target_index = match scroll_direction {
1504            FocusScrollDirection::Forward => {
1505                if args.visible_range.end >= args.total_count {
1506                    return false;
1507                }
1508                args.visible_range.end
1509            }
1510            FocusScrollDirection::Backward => {
1511                let Some(index) = args.visible_range.start.checked_sub(1) else {
1512                    return false;
1513                };
1514                index
1515            }
1516        };
1517
1518        let (target_offset, target_main) = args.controller.with(|c| {
1519            (
1520                c.cache
1521                    .offset_for(target_index, args.estimated_item_main, args.item_spacing),
1522                c.cache
1523                    .measurement(target_index)
1524                    .unwrap_or(args.estimated_item_main),
1525            )
1526        });
1527
1528        let desired_scroll = match scroll_direction {
1529            FocusScrollDirection::Forward => {
1530                (target_offset + target_main - args.viewport_span).max(Px::ZERO)
1531            }
1532            FocusScrollDirection::Backward => target_offset,
1533        }
1534        .min(max_scroll);
1535
1536        if desired_scroll == args.scroll_offset {
1537            return false;
1538        }
1539
1540        let position = args.axis.scroll_position(desired_scroll);
1541        args.scroll_controller
1542            .with_mut(|c| c.set_scroll_position(position));
1543        args.controller
1544            .with_mut(|c| c.scroll.set_scroll_position(position));
1545        true
1546    }))
1547}
1548
1549#[derive(Clone, PartialEq)]
1550enum LazySlot {
1551    Items(LazyItemsSlot),
1552    Sticky(LazyStickySlot),
1553}
1554
1555impl LazySlot {
1556    fn items(
1557        count: usize,
1558        builder: RenderSlotWith<usize>,
1559        key_provider: Option<CallbackWith<usize, u64>>,
1560    ) -> Self {
1561        Self::Items(LazyItemsSlot {
1562            count,
1563            builder,
1564            key_provider,
1565        })
1566    }
1567
1568    fn sticky(builder: RenderSlotWith<usize>, key_hash: Option<u64>) -> Self {
1569        Self::Sticky(LazyStickySlot { builder, key_hash })
1570    }
1571
1572    fn len(&self) -> usize {
1573        match self {
1574            Self::Items(slot) => slot.count,
1575            Self::Sticky(_) => 1,
1576        }
1577    }
1578}
1579
1580#[derive(Clone, PartialEq)]
1581struct LazyStickySlot {
1582    builder: RenderSlotWith<usize>,
1583    key_hash: Option<u64>,
1584}
1585
1586#[derive(Clone, PartialEq)]
1587struct LazySlotPlan {
1588    entries: Vec<LazySlotEntry>,
1589    total_count: usize,
1590    sticky_indices: Vec<usize>,
1591}
1592
1593impl LazySlotPlan {
1594    fn new(slots: Vec<LazySlot>) -> Self {
1595        let mut entries = Vec::with_capacity(slots.len());
1596        let mut cursor = 0;
1597        let mut sticky_indices = Vec::new();
1598        for slot in slots {
1599            let len = slot.len();
1600            if matches!(slot, LazySlot::Sticky(_)) {
1601                sticky_indices.push(cursor);
1602            }
1603            entries.push(LazySlotEntry {
1604                start: cursor,
1605                len,
1606                slot,
1607            });
1608            cursor += len;
1609        }
1610        Self {
1611            entries,
1612            total_count: cursor,
1613            sticky_indices,
1614        }
1615    }
1616
1617    fn total_count(&self) -> usize {
1618        self.total_count
1619    }
1620
1621    fn sticky_indices(&self) -> &[usize] {
1622        &self.sticky_indices
1623    }
1624
1625    fn last_sticky_before(&self, index: usize) -> Option<usize> {
1626        if self.sticky_indices.is_empty() {
1627            return None;
1628        }
1629        match self.sticky_indices.binary_search(&index) {
1630            Ok(pos) => self.sticky_indices.get(pos).copied(),
1631            Err(0) => None,
1632            Err(pos) => self.sticky_indices.get(pos.saturating_sub(1)).copied(),
1633        }
1634    }
1635
1636    fn visible_child(&self, index: usize) -> Option<VisibleChild> {
1637        let resolved = self.resolve(index)?;
1638        let key_hash = match resolved {
1639            ResolvedSlot::Items(slot, local_index) => {
1640                if let Some(provider) = &slot.key_provider {
1641                    provider.call(local_index)
1642                } else {
1643                    hash_key(index)
1644                }
1645            }
1646            ResolvedSlot::Sticky(slot) => slot.key_hash.unwrap_or_else(|| hash_key(index)),
1647        };
1648        let (builder, local_index) = match resolved {
1649            ResolvedSlot::Items(slot, local_index) => (slot.builder, local_index),
1650            ResolvedSlot::Sticky(slot) => (slot.builder, 0),
1651        };
1652        Some(VisibleChild {
1653            item_index: index,
1654            local_index,
1655            builder,
1656            key_hash,
1657        })
1658    }
1659
1660    fn visible_children(&self, range: Range<usize>) -> Vec<VisibleChild> {
1661        let mut result = Vec::new();
1662        for index in range {
1663            if let Some(child) = self.visible_child(index) {
1664                result.push(child);
1665            }
1666        }
1667        result
1668    }
1669
1670    fn resolve(&self, index: usize) -> Option<ResolvedSlot<'_>> {
1671        self.entries.iter().find_map(|entry| {
1672            if index >= entry.start && index < entry.start + entry.len {
1673                let local_index = index - entry.start;
1674                match &entry.slot {
1675                    LazySlot::Items(slot) => Some(ResolvedSlot::Items(slot, local_index)),
1676                    LazySlot::Sticky(slot) => Some(ResolvedSlot::Sticky(slot)),
1677                }
1678            } else {
1679                None
1680            }
1681        })
1682    }
1683}
1684
1685enum ResolvedSlot<'a> {
1686    Items(&'a LazyItemsSlot, usize),
1687    Sticky(&'a LazyStickySlot),
1688}
1689
1690#[derive(Clone, PartialEq)]
1691struct LazySlotEntry {
1692    start: usize,
1693    len: usize,
1694    slot: LazySlot,
1695}
1696
1697#[derive(Clone, PartialEq)]
1698struct VisibleChild {
1699    item_index: usize,
1700    local_index: usize,
1701    builder: RenderSlotWith<usize>,
1702    key_hash: u64,
1703}
1704
1705#[derive(PartialEq, Default)]
1706struct LazyListCache {
1707    total_items: usize,
1708    estimated_main: Px,
1709    density: f64,
1710    measured_main: Vec<Option<MeasuredMain>>,
1711    fenwick: FenwickTree,
1712}
1713
1714impl LazyListCache {
1715    fn configure(&mut self, count: usize, estimated: Px) {
1716        let density = tessera_ui::dp::scale_factor();
1717        let density_changed = self.density != density;
1718        if self.total_items != count {
1719            self.total_items = count;
1720            self.measured_main = vec![None; count];
1721            self.fenwick.resize(count);
1722        }
1723        if self.estimated_main != estimated || density_changed {
1724            self.estimated_main = estimated;
1725            self.density = density;
1726            self.fenwick.resize(count);
1727            for (index, actual) in self.measured_main.iter_mut().enumerate() {
1728                if let Some(actual) = actual {
1729                    if density_changed {
1730                        actual.rebase(density);
1731                    }
1732                    self.fenwick.update(index, actual.pixels() - estimated);
1733                }
1734            }
1735        }
1736    }
1737
1738    fn measurement(&self, index: usize) -> Option<Px> {
1739        self.measured_main
1740            .get(index)
1741            .copied()
1742            .flatten()
1743            .map(MeasuredMain::pixels)
1744    }
1745
1746    fn record_measurement(&mut self, index: usize, actual: Px, estimated: Px) {
1747        if index >= self.total_items {
1748            return;
1749        }
1750        let previous = self.measurement(index);
1751        if previous == Some(actual) {
1752            return;
1753        }
1754
1755        let prev_delta = previous.map(|val| val - estimated).unwrap_or(Px::ZERO);
1756        let new_delta = actual - estimated;
1757        let delta_change = new_delta - prev_delta;
1758        self.measured_main[index] = Some(MeasuredMain::new(actual, self.density));
1759        self.fenwick.update(index, delta_change);
1760    }
1761
1762    fn offset_for(&self, index: usize, estimated: Px, spacing: Px) -> Px {
1763        if self.total_items == 0 {
1764            return Px::ZERO;
1765        }
1766        let clamped = index.min(self.total_items);
1767        let spacing_contrib = px_mul(spacing, clamped);
1768        let estimated_contrib = px_mul(estimated, clamped);
1769        spacing_contrib + estimated_contrib + self.fenwick.prefix_sum(clamped)
1770    }
1771
1772    fn total_main_size(&self, estimated: Px, spacing: Px) -> Px {
1773        if self.total_items == 0 {
1774            return Px::ZERO;
1775        }
1776        let spacing_contrib = px_mul(spacing, self.total_items.saturating_sub(1));
1777        let estimated_contrib = px_mul(estimated, self.total_items);
1778        spacing_contrib + estimated_contrib + self.fenwick.prefix_sum(self.total_items)
1779    }
1780
1781    fn index_for_offset(&self, offset: Px, estimated: Px, spacing: Px) -> usize {
1782        if self.total_items == 0 {
1783            return 0;
1784        }
1785
1786        let mut low = 0usize;
1787        let mut high = self.total_items;
1788        while low < high {
1789            let mid = (low + high) / 2;
1790            if self.offset_for(mid, estimated, spacing) <= offset {
1791                low = mid + 1;
1792            } else {
1793                high = mid;
1794            }
1795        }
1796        low.saturating_sub(1)
1797            .min(self.total_items.saturating_sub(1))
1798    }
1799}
1800
1801fn ensure_positive_px(px: Px) -> Px {
1802    if px <= Px::ZERO { Px(1) } else { px }
1803}
1804
1805fn sanitize_spacing(px: Px) -> Px {
1806    if px < Px::ZERO { Px::ZERO } else { px }
1807}
1808
1809fn apply_cross_padding(constraint: &mut Constraint, axis: LazyListAxis, padding: Px) {
1810    let total_padding = padding + padding;
1811    match axis {
1812        LazyListAxis::Vertical => {
1813            constraint.width = shrink_axis_max(constraint.width, total_padding);
1814        }
1815        LazyListAxis::Horizontal => {
1816            constraint.height = shrink_axis_max(constraint.height, total_padding);
1817        }
1818    }
1819}
1820
1821fn shrink_axis_max(axis: AxisConstraint, amount: Px) -> AxisConstraint {
1822    let min = axis.min;
1823    let max = axis.max.map(|m| saturating_sub_px(m, amount));
1824    if max == Some(min) {
1825        AxisConstraint::exact(saturating_sub_px(min, amount))
1826    } else {
1827        AxisConstraint::new(min, max)
1828    }
1829}
1830
1831fn saturating_sub_px(lhs: Px, rhs: Px) -> Px {
1832    Px(lhs.0.saturating_sub(rhs.0))
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837
1838    use tessera_ui::{
1839        AxisConstraint, ComputedData, Dp, LayoutResult, MeasurementError, Modifier,
1840        NoopDrawModifier, Px, PxPosition, RenderSlotWith, State,
1841        layout::{MeasureScope, layout},
1842        receive_frame_nanos, remember, remember_with_key, tessera,
1843    };
1844
1845    use crate::{
1846        alignment::CrossAxisAlignment,
1847        modifier::{ModifierExt as _, SemanticsArgs},
1848        text::text,
1849        theme::{MaterialTheme, material_theme},
1850    };
1851
1852    use super::{LazyListCache, LazyListController, lazy_column, lazy_row};
1853
1854    #[tessera]
1855    fn capped_list_fixture(horizontal: bool) {
1856        material_theme().child(move || {
1857            if horizontal {
1858                lazy_row()
1859                    .modifier(Modifier::new().size(Dp(360.0), Dp(40.0)))
1860                    .max_viewport_main(Px(360))
1861                    .estimated_item_size(Dp(336.0))
1862                    .items(20, |index| {
1863                        fixed_test_box()
1864                            .tag(format!("capped_{index}"))
1865                            .width(336)
1866                            .height(40);
1867                    });
1868            } else {
1869                lazy_column()
1870                    .modifier(Modifier::new().size(Dp(40.0), Dp(360.0)))
1871                    .max_viewport_main(Px(360))
1872                    .estimated_item_size(Dp(336.0))
1873                    .items(20, |index| {
1874                        fixed_test_box()
1875                            .tag(format!("capped_{index}"))
1876                            .width(40)
1877                            .height(336);
1878                    });
1879            }
1880        });
1881    }
1882
1883    #[test]
1884    fn viewport_cap_limits_initial_item_construction_on_both_axes() {
1885        crate::test_utils::run_isolated_test(
1886            "lazy_list::tests::viewport_cap_limits_initial_item_construction_on_both_axes",
1887            || {
1888                for horizontal in [false, true] {
1889                    let mut session = tessera_ui::testing::start_layout_test_session(
1890                        tessera_ui::testing::layout_test(move || {
1891                            capped_list_fixture(horizontal);
1892                        })
1893                        .viewport_px(360, 360),
1894                    );
1895                    let snapshot = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1896                    snapshot.node("capped_0").exists();
1897                    snapshot.node("capped_1").exists();
1898                    assert_eq!(
1899                        session
1900                            .work()
1901                            .count("fixed_test_box", tessera_ui::WorkPhase::Build),
1902                        2,
1903                        "the first layout must only construct the two intersecting items"
1904                    );
1905                }
1906            },
1907        );
1908    }
1909
1910    #[derive(Clone, Copy, Default)]
1911    struct FixtureHandles {
1912        estimate: Option<State<Dp>>,
1913        collapsed_width: Option<State<Dp>>,
1914        collapsed_list: Option<State<LazyListController>>,
1915        programmatic_list: Option<State<LazyListController>>,
1916        responsive_cross: Option<State<Dp>>,
1917        responsive_main: Option<State<Dp>>,
1918        list_generation: Option<State<u64>>,
1919        replaced_list: Option<State<LazyListController>>,
1920    }
1921
1922    #[tessera]
1923    fn programmatic_list_fixture(
1924        horizontal: bool,
1925        observed: tessera_ui::testing::TestProbe<FixtureHandles>,
1926    ) {
1927        let controller = remember(LazyListController::new);
1928        observed.with_mut(|handles| handles.programmatic_list = Some(controller));
1929        let items = RenderSlotWith::new(|index| {
1930            fixed_test_box()
1931                .tag(format!("programmatic_{index}"))
1932                .width(20)
1933                .height(20);
1934        });
1935        material_theme().child(move || {
1936            if horizontal {
1937                lazy_row()
1938                    .modifier(Modifier::new().size(Dp(100.0), Dp(40.0)))
1939                    .controller(controller)
1940                    .estimated_item_size(Dp(20.0))
1941                    .items_with_key_shared(100, |index| index, items);
1942            } else {
1943                lazy_column()
1944                    .modifier(Modifier::new().size(Dp(40.0), Dp(100.0)))
1945                    .controller(controller)
1946                    .estimated_item_size(Dp(20.0))
1947                    .items_with_key_shared(100, |index| index, items);
1948            }
1949        });
1950    }
1951
1952    fn check_programmatic_position(horizontal: bool) {
1953        tessera_ui::dp::set_scale_factor(1.0);
1954        let observed = tessera_ui::testing::TestProbe::default();
1955        let fixture_observed = observed.clone();
1956        let mut session = tessera_ui::testing::start_layout_test_session(
1957            tessera_ui::testing::layout_test(move || {
1958                programmatic_list_fixture(horizontal, fixture_observed.clone());
1959            })
1960            .viewport_px(120, 120),
1961        );
1962        tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1963        let controller = observed.get().programmatic_list.expect("controller");
1964        controller.with_mut(|controller| {
1965            controller.scroll_to(
1966                if horizontal { Dp(80.0) } else { Dp::ZERO },
1967                if horizontal { Dp::ZERO } else { Dp(80.0) },
1968            )
1969        });
1970        tessera_ui::testing::pump_layout_test_session(&mut session, 16_666_667);
1971        let snapshot = tessera_ui::testing::pump_layout_test_session(&mut session, 33_333_334);
1972        snapshot.node("programmatic_4").position(0, 0);
1973        controller.with_mut(|controller| controller.scroll_to(Dp::ZERO, Dp::ZERO));
1974        tessera_ui::testing::pump_layout_test_session(&mut session, 50_000_001);
1975        let snapshot = tessera_ui::testing::pump_layout_test_session(&mut session, 66_666_668);
1976        snapshot.node("programmatic_0").position(0, 0);
1977    }
1978
1979    #[test]
1980    fn lazy_column_applies_programmatic_positions_after_mounting() {
1981        let runtime = tessera_ui::RuntimeSession::new();
1982        let _scope = runtime.enter();
1983        check_programmatic_position(false);
1984    }
1985
1986    #[test]
1987    fn lazy_row_applies_programmatic_positions_after_mounting() {
1988        let runtime = tessera_ui::RuntimeSession::new();
1989        let _scope = runtime.enter();
1990        check_programmatic_position(true);
1991    }
1992
1993    #[tessera]
1994    fn replaceable_list_fixture(
1995        horizontal: bool,
1996        observed: tessera_ui::testing::TestProbe<FixtureHandles>,
1997    ) {
1998        let generation = remember(|| 0_u64);
1999        observed.with_mut(|handles| handles.list_generation = Some(generation));
2000        let controller = remember_with_key(generation.get(), LazyListController::new);
2001        observed.with_mut(|handles| handles.replaced_list = Some(controller));
2002        let items = RenderSlotWith::new(|index| {
2003            fixed_test_box()
2004                .tag(format!("replacement_{index}"))
2005                .width(20)
2006                .height(20);
2007        });
2008        material_theme().child(move || {
2009            if horizontal {
2010                lazy_row()
2011                    .modifier(Modifier::new().size(Dp(100.0), Dp(40.0)))
2012                    .controller(controller)
2013                    .estimated_item_size(Dp(20.0))
2014                    .items_with_key_shared(100, |index| index, items);
2015            } else {
2016                lazy_column()
2017                    .modifier(Modifier::new().size(Dp(40.0), Dp(100.0)))
2018                    .controller(controller)
2019                    .estimated_item_size(Dp(20.0))
2020                    .items_with_key_shared(100, |index| index, items);
2021            }
2022        });
2023    }
2024
2025    #[test]
2026    fn replacing_lazy_list_controllers_refreshes_retained_layout_and_scroll_ownership() {
2027        let runtime = tessera_ui::RuntimeSession::new();
2028        let _scope = runtime.enter();
2029        crate::test_utils::run_isolated_test(
2030            "lazy_list::tests::replacing_lazy_list_controllers_refreshes_retained_layout_and_scroll_ownership",
2031            || {
2032                tessera_ui::dp::set_scale_factor(1.0);
2033                for horizontal in [false, true] {
2034                    let observed = tessera_ui::testing::TestProbe::default();
2035                    let fixture_observed = observed.clone();
2036                    let mut session = tessera_ui::testing::start_layout_test_session(
2037                        tessera_ui::testing::layout_test(move || {
2038                            replaceable_list_fixture(horizontal, fixture_observed.clone());
2039                        })
2040                        .viewport_px(120, 120),
2041                    );
2042                    tessera_ui::testing::pump_layout_test_session(&mut session, 0);
2043                    let mut time = 0;
2044                    for generation in 1..=4 {
2045                        let previous = observed.get().replaced_list.unwrap();
2046                        observed.get().list_generation.unwrap().set(generation);
2047                        time += 16_666_667;
2048                        tessera_ui::testing::pump_layout_test_session(&mut session, time);
2049                        time += 16_666_667;
2050                        let snapshot =
2051                            tessera_ui::testing::pump_layout_test_session(&mut session, time);
2052                        assert!(
2053                            !previous.is_alive(),
2054                            "the old keyed controller was actually released"
2055                        );
2056                        let current = observed.get().replaced_list.unwrap();
2057                        assert!(current.is_alive());
2058                        assert!(
2059                            current != previous,
2060                            "the controller identity must change with its key"
2061                        );
2062                        snapshot.node("replacement_0").position(0, 0);
2063                        current.with_mut(|controller| {
2064                            controller.scroll_to(
2065                                if horizontal { Dp(80.0) } else { Dp::ZERO },
2066                                if horizontal { Dp::ZERO } else { Dp(80.0) },
2067                            )
2068                        });
2069                        time += 16_666_667;
2070                        tessera_ui::testing::pump_layout_test_session(&mut session, time);
2071                        time += 16_666_667;
2072                        let snapshot =
2073                            tessera_ui::testing::pump_layout_test_session(&mut session, time);
2074                        snapshot.node("replacement_4").position(0, 0);
2075                        current.with_mut(|controller| controller.scroll_to(Dp::ZERO, Dp::ZERO));
2076                        time += 16_666_667;
2077                        tessera_ui::testing::pump_layout_test_session(&mut session, time);
2078                        time += 16_666_667;
2079                        let snapshot =
2080                            tessera_ui::testing::pump_layout_test_session(&mut session, time);
2081                        snapshot.node("replacement_0").position(0, 0);
2082                    }
2083                }
2084            },
2085        );
2086    }
2087
2088    #[derive(Clone, PartialEq)]
2089    struct ResponsiveItemLayout(bool, bool);
2090
2091    impl tessera_ui::LayoutModifierNode for ResponsiveItemLayout {
2092        fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
2093            let cross = if self.0 {
2094                input.parent_constraint().height().resolve_max()
2095            } else {
2096                input.parent_constraint().width().resolve_max()
2097            }
2098            .expect("bounded cross axis");
2099            let main = if cross < Dp(200.0).to_px() {
2100                Dp(80.0).to_px()
2101            } else {
2102                Dp(40.0).to_px()
2103            };
2104            let main = if self.1 { main * 10 } else { main };
2105            Ok(LayoutResult::new(if self.0 {
2106                ComputedData {
2107                    width: main,
2108                    height: cross,
2109                }
2110            } else {
2111                ComputedData {
2112                    width: cross,
2113                    height: main,
2114                }
2115            }))
2116        }
2117    }
2118
2119    #[tessera]
2120    fn responsive_anchor_fixture(
2121        horizontal: bool,
2122        tall: bool,
2123        observed: tessera_ui::testing::TestProbe<FixtureHandles>,
2124    ) {
2125        let cross = remember(|| Dp(300.0));
2126        let main = remember(|| Dp(120.0));
2127        let controller = remember(LazyListController::new);
2128        observed.with_mut(|handles| handles.responsive_cross = Some(cross));
2129        observed.with_mut(|handles| handles.responsive_main = Some(main));
2130        observed.with_mut(|handles| handles.programmatic_list = Some(controller));
2131        let items = RenderSlotWith::new(move |index| {
2132            layout()
2133                .modifier(Modifier::new().semantics(SemanticsArgs {
2134                    test_tag: Some(format!("responsive_{index}")),
2135                    ..Default::default()
2136                }))
2137                .policy(ResponsiveItemLayout(horizontal, tall));
2138        });
2139        material_theme().child(move || {
2140            crate::boxed::boxed().children(move || {
2141                if horizontal {
2142                    lazy_row()
2143                        .modifier(Modifier::new().size(main.get(), cross.get()))
2144                        .scrollbar_behavior(crate::scrollable::ScrollBarBehavior::Hidden)
2145                        .controller(controller)
2146                        .overscan(2)
2147                        .estimated_item_size(Dp(40.0))
2148                        .items_with_key_shared(20, |index| index, items);
2149                } else {
2150                    lazy_column()
2151                        .modifier(Modifier::new().size(cross.get(), main.get()))
2152                        .scrollbar_behavior(crate::scrollable::ScrollBarBehavior::Hidden)
2153                        .controller(controller)
2154                        .overscan(2)
2155                        .estimated_item_size(Dp(40.0))
2156                        .items_with_key_shared(20, |index| index, items);
2157                }
2158            });
2159        });
2160    }
2161
2162    #[test]
2163    fn responsive_measurements_preserve_the_first_visible_item_start_on_both_axes() {
2164        let runtime = tessera_ui::RuntimeSession::new();
2165        let _scope = runtime.enter();
2166        crate::test_utils::run_isolated_test(
2167            "lazy_list::tests::responsive_measurements_preserve_the_first_visible_item_start_on_both_axes",
2168            || {
2169                for density in [1.0, 1.25, 1.5, 2.0] {
2170                    tessera_ui::dp::set_scale_factor(density);
2171                    for (horizontal, tall) in
2172                        [(false, false), (true, false), (false, true), (true, true)]
2173                    {
2174                        let observed = tessera_ui::testing::TestProbe::default();
2175                        let fixture_observed = observed.clone();
2176                        let mut session = tessera_ui::testing::start_layout_test_session(
2177                            tessera_ui::testing::layout_test(move || {
2178                                responsive_anchor_fixture(
2179                                    horizontal,
2180                                    tall,
2181                                    fixture_observed.clone(),
2182                                );
2183                            })
2184                            .viewport_px(800, 800),
2185                        );
2186                        tessera_ui::testing::pump_layout_test_session(&mut session, 0);
2187                        let controller = observed.get().programmatic_list.expect("list controller");
2188                        let offset = if tall { Dp(60.0) } else { Dp(180.0) };
2189                        controller.with_mut(|controller| {
2190                            controller.scroll_to(
2191                                if horizontal { offset } else { Dp::ZERO },
2192                                if horizontal { Dp::ZERO } else { offset },
2193                            );
2194                        });
2195                        let cross = observed.get().responsive_cross.expect("cross-axis state");
2196                        let main_span = observed.get().responsive_main.expect("main-axis state");
2197                        let mut frame = 1;
2198                        for (width, span) in [
2199                            (300.0, 120.0),
2200                            (150.0, 60.0),
2201                            (300.0, 120.0),
2202                            (150.0, 60.0),
2203                            (300.0, 120.0),
2204                        ] {
2205                            cross.set(Dp(width));
2206                            main_span.set(Dp(span));
2207                            let main = Dp(if width < 200.0 { 80.0 } else { 40.0 }).to_px().0
2208                                * if tall { 10 } else { 1 };
2209                            let position = Dp(if tall { -60.0 } else { 20.0 }).to_px().0;
2210                            let cross_size = Dp(width).to_px().0;
2211                            for _ in 0..4 {
2212                                let snapshot = tessera_ui::testing::pump_layout_test_session(
2213                                    &mut session,
2214                                    frame * 16_666_667,
2215                                );
2216                                snapshot
2217                                    .node(if tall { "responsive_0" } else { "responsive_5" })
2218                                    .position(
2219                                        if horizontal { position } else { 0 },
2220                                        if horizontal { 0 } else { position },
2221                                    )
2222                                    .size(
2223                                        if horizontal { main } else { cross_size },
2224                                        if horizontal { cross_size } else { main },
2225                                    );
2226                                frame += 1;
2227                            }
2228                        }
2229                    }
2230                }
2231            },
2232        );
2233    }
2234
2235    #[tessera]
2236    fn collapsed_viewport_fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
2237        let width = remember(|| Dp(300.0));
2238        let controller = remember(LazyListController::new);
2239        observed.with_mut(|handles| handles.collapsed_width = Some(width));
2240        observed.with_mut(|handles| handles.collapsed_list = Some(controller));
2241        material_theme().child(move || {
2242            lazy_column()
2243                .modifier(Modifier::new().width(width.get()).height(Dp(240.0)))
2244                .controller(controller)
2245                .estimated_item_size(Dp(60.0))
2246                .items(3, |_| {
2247                    text("A responsive list retains measured geometry while its viewport is temporarily collapsed.");
2248                });
2249        });
2250    }
2251
2252    #[tessera]
2253    fn density_scroll_fixture(
2254        horizontal: bool,
2255        observed: tessera_ui::testing::TestProbe<FixtureHandles>,
2256    ) {
2257        let controller = remember(LazyListController::new);
2258        observed.with_mut(|handles| handles.programmatic_list = Some(controller));
2259        let items = RenderSlotWith::new(|index| {
2260            fixed_test_box()
2261                .tag(format!("density_{index}"))
2262                .width(Dp(20.0).to_px().0)
2263                .height(Dp(40.0).to_px().0);
2264        });
2265        material_theme().child(move || {
2266            crate::boxed::boxed().children(move || {
2267                if horizontal {
2268                    lazy_row()
2269                        .modifier(Modifier::new().size(Dp(100.0), Dp(100.0)))
2270                        .controller(controller)
2271                        .content_padding(Dp(4.0))
2272                        .item_spacing(Dp(4.0))
2273                        .scrollbar_behavior(crate::scrollable::ScrollBarBehavior::Hidden)
2274                        .estimated_item_size(Dp(20.0))
2275                        .items_with_key_shared(100, |index| index, items);
2276                } else {
2277                    lazy_column()
2278                        .modifier(Modifier::new().size(Dp(100.0), Dp(100.0)))
2279                        .controller(controller)
2280                        .content_padding(Dp(4.0))
2281                        .item_spacing(Dp(4.0))
2282                        .scrollbar_behavior(crate::scrollable::ScrollBarBehavior::Hidden)
2283                        .estimated_item_size(Dp(40.0))
2284                        .items_with_key_shared(100, |index| index, items);
2285                }
2286            });
2287        });
2288    }
2289
2290    #[test]
2291    fn density_changes_preserve_scrolled_items_and_new_programmatic_requests() {
2292        let runtime = tessera_ui::RuntimeSession::new();
2293        let _scope = runtime.enter();
2294        crate::test_utils::run_isolated_test(
2295            "lazy_list::tests::density_changes_preserve_scrolled_items_and_new_programmatic_requests",
2296            || {
2297                for horizontal in [false, true] {
2298                    tessera_ui::dp::set_scale_factor(1.0);
2299                    let observed = tessera_ui::testing::TestProbe::default();
2300                    let fixture_observed = observed.clone();
2301                    let mut session = tessera_ui::testing::start_layout_test_session(
2302                        tessera_ui::testing::layout_test(move || {
2303                            density_scroll_fixture(horizontal, fixture_observed.clone());
2304                        })
2305                        .viewport_px(400, 400),
2306                    );
2307                    for frame in 0..4 {
2308                        tessera_ui::testing::pump_layout_test_session(
2309                            &mut session,
2310                            frame * 16_666_667,
2311                        );
2312                    }
2313                    let controller = observed.get().programmatic_list.expect("controller");
2314                    let offset = if horizontal { Dp(104.0) } else { Dp(204.0) };
2315                    controller.with_mut(|controller| {
2316                        controller.scroll_to(
2317                            if horizontal { offset } else { Dp::ZERO },
2318                            if horizontal { Dp::ZERO } else { offset },
2319                        )
2320                    });
2321                    let mut frame = 4;
2322                    for density in [1.0, 1.25, 1.5, 2.0, 1.0] {
2323                        tessera_ui::dp::set_scale_factor(density);
2324                        if density == 1.5 {
2325                            controller.with_mut(|controller| {
2326                                controller.scroll_to(
2327                                    if horizontal { offset } else { Dp::ZERO },
2328                                    if horizontal { Dp::ZERO } else { offset },
2329                                )
2330                            });
2331                        }
2332                        for _ in 0..4 {
2333                            let snapshot = tessera_ui::testing::pump_layout_test_session(
2334                                &mut session,
2335                                frame * 16_666_667,
2336                            );
2337                            snapshot
2338                                .node("density_5")
2339                                .position(
2340                                    Dp(if horizontal { 20.0 } else { 4.0 }).to_px().0,
2341                                    Dp(if horizontal { 4.0 } else { 20.0 }).to_px().0,
2342                                )
2343                                .size(Dp(20.0).to_px().0, Dp(40.0).to_px().0);
2344                            frame += 1;
2345                        }
2346                    }
2347                }
2348            },
2349        );
2350    }
2351
2352    #[test]
2353    fn collapsed_cross_axis_preserves_usable_measurements() {
2354        let observed = tessera_ui::testing::TestProbe::default();
2355        let fixture_observed = observed.clone();
2356        let mut session = tessera_ui::testing::start_layout_test_session(
2357            tessera_ui::testing::layout_test(move || {
2358                collapsed_viewport_fixture(fixture_observed.clone());
2359            })
2360            .viewport_px(300, 240),
2361        );
2362        for frame in 0..8 {
2363            let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
2364        }
2365        let controller = observed.get().collapsed_list.expect("list controller");
2366        let before = controller.with(|state| state.cache.measured_main.clone());
2367        assert!(before.iter().all(Option::is_some));
2368        let width = observed.get().collapsed_width.expect("viewport width");
2369        width.set(Dp(0.0));
2370        for frame in 8..12 {
2371            let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
2372        }
2373        assert_eq!(
2374            controller.with(|state| state.cache.measured_main.clone()),
2375            before
2376        );
2377        width.set(Dp(300.0));
2378        for frame in 12..16 {
2379            let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
2380        }
2381        assert_eq!(
2382            controller.with(|state| state.cache.measured_main.clone()),
2383            before
2384        );
2385    }
2386
2387    #[test]
2388    fn estimate_changes_rebase_measured_and_unmeasured_offsets() {
2389        let mut cache = LazyListCache::default();
2390        cache.configure(5, Px(100));
2391        for (index, size) in [(0, 40), (1, 60), (3, 80)] {
2392            cache.record_measurement(index, Px(size), Px(100));
2393        }
2394        assert_eq!(cache.total_main_size(Px(100), Px(8)), Px(412));
2395        cache.configure(5, Px(20));
2396        assert_eq!(cache.total_main_size(Px(20), Px(8)), Px(252));
2397        assert_eq!(cache.offset_for(2, Px(20), Px(8)), Px(116));
2398        assert_eq!(cache.offset_for(4, Px(20), Px(8)), Px(232));
2399        assert_eq!(cache.index_for_offset(Px(115), Px(20), Px(8)), 1);
2400        assert_eq!(cache.index_for_offset(Px(116), Px(20), Px(8)), 2);
2401        cache.record_measurement(1, Px(75), Px(20));
2402        cache.configure(5, Px(100));
2403        assert_eq!(cache.total_main_size(Px(100), Px(8)), Px(427));
2404        cache.configure(0, Px(20));
2405        assert_eq!(cache.total_main_size(Px(20), Px(8)), Px::ZERO);
2406        cache.configure(3, Px(100));
2407        assert_eq!(cache.total_main_size(Px(100), Px(8)), Px(316));
2408    }
2409
2410    #[test]
2411    fn unvisited_measurements_rebase_density_without_round_trip_drift() {
2412        let runtime = tessera_ui::RuntimeSession::new();
2413        let _scope = runtime.enter();
2414        crate::test_utils::run_isolated_test(
2415            "lazy_list::tests::unvisited_measurements_rebase_density_without_round_trip_drift",
2416            || {
2417                tessera_ui::dp::set_scale_factor(1.0);
2418                let mut cache = LazyListCache::default();
2419                cache.configure(3, Px(20));
2420                cache.record_measurement(0, Px(33), Px(20));
2421                cache.record_measurement(2, Px(55), Px(20));
2422                for (density, estimated, spacing, offset, total) in [
2423                    (1.25, 25, 5, 46, 144),
2424                    (1.5, 30, 6, 55, 173),
2425                    (2.0, 40, 8, 74, 232),
2426                    (1.0, 20, 4, 37, 116),
2427                ] {
2428                    tessera_ui::dp::set_scale_factor(density);
2429                    cache.configure(3, Px(estimated));
2430                    assert_eq!(cache.offset_for(1, Px(estimated), Px(spacing)), Px(offset));
2431                    assert_eq!(cache.total_main_size(Px(estimated), Px(spacing)), Px(total));
2432                }
2433            },
2434        );
2435    }
2436
2437    #[tessera]
2438    fn changing_estimate_fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
2439        let estimate = remember(|| Dp(100.0));
2440        observed.with_mut(|handles| handles.estimate = Some(estimate));
2441        material_theme().child(move || {
2442            lazy_column()
2443                .modifier(Modifier::new().fill_max_size())
2444                .cross_axis_alignment(CrossAxisAlignment::Start)
2445                .estimated_item_size(estimate.get())
2446                .item_spacing(Dp(8.0))
2447                .overscan(10)
2448                .items(4, |index| {
2449                    fixed_test_box()
2450                        .tag(format!("estimate_{index}"))
2451                        .width(20)
2452                        .height(40);
2453                });
2454        });
2455    }
2456
2457    #[test]
2458    fn changing_estimates_and_density_keep_items_separated() {
2459        let runtime = tessera_ui::RuntimeSession::new();
2460        let _scope = runtime.enter();
2461        crate::test_utils::run_isolated_test(
2462            "lazy_list::tests::changing_estimates_and_density_keep_items_separated",
2463            || {
2464                tessera_ui::dp::set_scale_factor(1.0);
2465                let observed = tessera_ui::testing::TestProbe::default();
2466                let fixture_observed = observed.clone();
2467                let mut session = tessera_ui::testing::start_layout_test_session(
2468                    tessera_ui::testing::layout_test(move || {
2469                        changing_estimate_fixture(fixture_observed.clone());
2470                    })
2471                    .viewport_px(200, 240),
2472                );
2473                let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
2474                let estimate = observed.get().estimate.expect("estimate state");
2475                for (frame, (density, value)) in [(1.0, 20.0), (1.5, 140.0), (1.0, 60.0)]
2476                    .into_iter()
2477                    .enumerate()
2478                {
2479                    tessera_ui::dp::set_scale_factor(density);
2480                    estimate.set(Dp(value));
2481                    let snapshot = tessera_ui::testing::pump_layout_test_session(
2482                        &mut session,
2483                        (frame as u64 + 1) * 16_666_667,
2484                    );
2485                    for index in 0..4 {
2486                        snapshot
2487                            .node(&format!("estimate_{index}"))
2488                            .position(0, index * (40 + (8.0 * density) as i32));
2489                    }
2490                }
2491                tessera_ui::dp::set_scale_factor(1.0);
2492            },
2493        );
2494    }
2495
2496    #[derive(Clone, PartialEq)]
2497    struct FixedTestLayout {
2498        width: i32,
2499        height: i32,
2500    }
2501
2502    #[derive(Clone, PartialEq)]
2503    struct PreferredConstraintTestLayout {
2504        preferred_width: i32,
2505        preferred_height: i32,
2506    }
2507
2508    impl tessera_ui::LayoutModifierNode for FixedTestLayout {
2509        fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
2510            Ok(LayoutResult::new(ComputedData {
2511                width: Px::new(self.width),
2512                height: Px::new(self.height),
2513            }))
2514        }
2515    }
2516
2517    impl tessera_ui::LayoutModifierNode for PreferredConstraintTestLayout {
2518        fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
2519            let preferred_width = Px::new(self.preferred_width);
2520            let preferred_height = Px::new(self.preferred_height);
2521            let width = AxisConstraint::exact(preferred_width)
2522                .intersect(input.parent_constraint().width())
2523                .clamp(preferred_width);
2524            let height = AxisConstraint::exact(preferred_height)
2525                .intersect(input.parent_constraint().height())
2526                .clamp(preferred_height);
2527            Ok(LayoutResult::new(ComputedData { width, height }))
2528        }
2529    }
2530
2531    #[tessera]
2532    fn fixed_test_box(tag: Option<String>, width: Option<i32>, height: Option<i32>) {
2533        let tag = tag.unwrap_or_default();
2534        let width = width.unwrap_or(0);
2535        let height = height.unwrap_or(0);
2536
2537        layout()
2538            .policy(FixedTestLayout { width, height })
2539            .draw(NoopDrawModifier)
2540            .modifier(Modifier::new().semantics(SemanticsArgs {
2541                test_tag: Some(tag),
2542                ..Default::default()
2543            }));
2544    }
2545
2546    #[tessera]
2547    fn preferred_constraint_test_box(tag: Option<String>, width: Option<i32>, height: Option<i32>) {
2548        let tag = tag.unwrap_or_default();
2549        let width = width.unwrap_or(0);
2550        let height = height.unwrap_or(0);
2551
2552        layout()
2553            .policy(PreferredConstraintTestLayout {
2554                preferred_width: width,
2555                preferred_height: height,
2556            })
2557            .draw(NoopDrawModifier)
2558            .modifier(Modifier::new().semantics(SemanticsArgs {
2559                test_tag: Some(tag),
2560                ..Default::default()
2561            }));
2562    }
2563
2564    #[tessera]
2565    fn lazy_column_layout_case() {
2566        lazy_column()
2567            .modifier(Modifier::new().constrain(
2568                Some(AxisConstraint::exact(Px::new(60))),
2569                Some(AxisConstraint::exact(Px::new(50))),
2570            ))
2571            .cross_axis_alignment(CrossAxisAlignment::Start)
2572            .item_spacing(Px::new(3).into())
2573            .estimated_item_size(Px::new(10).into())
2574            .content_padding(Px::new(4).into())
2575            .item(|| {
2576                fixed_test_box()
2577                    .tag("lazy_column_first".to_string())
2578                    .width(20)
2579                    .height(10);
2580            })
2581            .item(|| {
2582                fixed_test_box()
2583                    .tag("lazy_column_second".to_string())
2584                    .width(18)
2585                    .height(12);
2586            })
2587            .item(|| {
2588                fixed_test_box()
2589                    .tag("lazy_column_third".to_string())
2590                    .width(16)
2591                    .height(8);
2592            });
2593    }
2594
2595    #[tessera]
2596    fn lazy_row_layout_case() {
2597        lazy_row()
2598            .modifier(Modifier::new().constrain(
2599                Some(AxisConstraint::exact(Px::new(70))),
2600                Some(AxisConstraint::exact(Px::new(30))),
2601            ))
2602            .cross_axis_alignment(CrossAxisAlignment::Start)
2603            .item_spacing(Px::new(3).into())
2604            .estimated_item_size(Px::new(10).into())
2605            .content_padding(Px::new(4).into())
2606            .item(|| {
2607                fixed_test_box()
2608                    .tag("lazy_row_first".to_string())
2609                    .width(20)
2610                    .height(10);
2611            })
2612            .item(|| {
2613                fixed_test_box()
2614                    .tag("lazy_row_second".to_string())
2615                    .width(15)
2616                    .height(12);
2617            })
2618            .item(|| {
2619                fixed_test_box()
2620                    .tag("lazy_row_third".to_string())
2621                    .width(18)
2622                    .height(8);
2623            });
2624    }
2625
2626    #[tessera]
2627    fn lazy_column_scrolled_layout_case() {
2628        let controller = remember(LazyListController::new);
2629        let target_position = PxPosition::new(Px::ZERO, Px::new(-5));
2630        if controller.with(|c| c.scroll_controller().child_position()) != target_position {
2631            controller.with_mut(|c| {
2632                c.scroll_controller_mut()
2633                    .set_scroll_position(target_position);
2634            });
2635        }
2636
2637        lazy_column()
2638            .modifier(Modifier::new().constrain(
2639                Some(AxisConstraint::exact(Px::new(60))),
2640                Some(AxisConstraint::exact(Px::new(39))),
2641            ))
2642            .controller(controller)
2643            .cross_axis_alignment(CrossAxisAlignment::Start)
2644            .item_spacing(Px::new(3).into())
2645            .estimated_item_size(Px::new(10).into())
2646            .content_padding(Px::new(4).into())
2647            .item(|| {
2648                fixed_test_box()
2649                    .tag("lazy_column_scrolled_first".to_string())
2650                    .width(20)
2651                    .height(10);
2652            })
2653            .item(|| {
2654                fixed_test_box()
2655                    .tag("lazy_column_scrolled_second".to_string())
2656                    .width(18)
2657                    .height(12);
2658            })
2659            .item(|| {
2660                fixed_test_box()
2661                    .tag("lazy_column_scrolled_third".to_string())
2662                    .width(16)
2663                    .height(8);
2664            });
2665    }
2666
2667    #[tessera]
2668    fn lazy_column_sticky_layout_case() {
2669        let controller = remember(LazyListController::new);
2670        let target_position = PxPosition::new(Px::ZERO, Px::new(-6));
2671        if controller.with(|c| c.scroll_controller().child_position()) != target_position {
2672            controller.with_mut(|c| {
2673                c.scroll_controller_mut()
2674                    .set_scroll_position(target_position);
2675            });
2676        }
2677
2678        lazy_column()
2679            .modifier(Modifier::new().constrain(
2680                Some(AxisConstraint::exact(Px::new(60))),
2681                Some(AxisConstraint::exact(Px::new(38))),
2682            ))
2683            .controller(controller)
2684            .cross_axis_alignment(CrossAxisAlignment::Start)
2685            .item_spacing(Px::new(3).into())
2686            .estimated_item_size(Px::new(10).into())
2687            .content_padding(Px::new(4).into())
2688            .sticky_header(|| {
2689                fixed_test_box()
2690                    .tag("lazy_column_sticky_header".to_string())
2691                    .width(20)
2692                    .height(10);
2693            })
2694            .item(|| {
2695                fixed_test_box()
2696                    .tag("lazy_column_sticky_body".to_string())
2697                    .width(18)
2698                    .height(12);
2699            })
2700            .item(|| {
2701                fixed_test_box()
2702                    .tag("lazy_column_sticky_tail".to_string())
2703                    .width(16)
2704                    .height(8);
2705            });
2706    }
2707
2708    #[tessera]
2709    fn lazy_column_non_stretch_cross_axis_case() {
2710        lazy_column()
2711            .modifier(Modifier::new().constrain(
2712                Some(AxisConstraint::exact(Px::new(60))),
2713                Some(AxisConstraint::exact(Px::new(50))),
2714            ))
2715            .cross_axis_alignment(CrossAxisAlignment::Start)
2716            .estimated_item_size(Px::new(10).into())
2717            .content_padding(Px::new(4).into())
2718            .item(|| {
2719                preferred_constraint_test_box()
2720                    .tag("lazy_column_non_stretch".to_string())
2721                    .width(20)
2722                    .height(10);
2723            });
2724    }
2725
2726    #[tessera]
2727    fn lazy_column_stable_visible_items_case() {
2728        let controller = remember(LazyListController::new);
2729        receive_frame_nanos(move |frame_nanos| {
2730            let offset = match frame_nanos {
2731                0 => 0,
2732                16_666_667 => 2,
2733                33_333_334 => 4,
2734                _ => 4,
2735            };
2736            let position = PxPosition::new(Px::ZERO, Px::new(-offset));
2737            controller.with_mut(|c| {
2738                c.scroll_controller_mut().set_scroll_position(position);
2739            });
2740            tessera_ui::FrameNanosControl::Continue
2741        });
2742
2743        material_theme()
2744            .theme(MaterialTheme::default)
2745            .child(move || {
2746                let item_width =
2747                    controller.with(|c| 20 - c.scroll_controller().child_position().y as i32);
2748                lazy_column()
2749                    .modifier(Modifier::new().constrain(
2750                        Some(AxisConstraint::exact(Px::new(60))),
2751                        Some(AxisConstraint::exact(Px::new(50))),
2752                    ))
2753                    .controller(controller)
2754                    .cross_axis_alignment(CrossAxisAlignment::Start)
2755                    .estimated_item_size(Px::new(20).into())
2756                    .items(10, move |index| {
2757                        fixed_test_box()
2758                            .tag(format!("stable_visible_item_{index}"))
2759                            .width(item_width)
2760                            .height(20);
2761                    });
2762            });
2763    }
2764
2765    #[test]
2766    fn lazy_column_positions_items_with_padding_and_spacing() {
2767        tessera_ui::assert_layout! {
2768            viewport: (80, 60),
2769            content: {
2770                material_theme().child(|| { lazy_column_layout_case(); });
2771            },
2772            expect: {
2773                node("lazy_column_first").position(4, 4).size(20, 10);
2774                node("lazy_column_second").position(4, 17).size(18, 12);
2775                node("lazy_column_third").position(4, 32).size(16, 8);
2776            }
2777        }
2778    }
2779
2780    #[test]
2781    fn lazy_row_positions_items_with_padding_and_spacing() {
2782        tessera_ui::assert_layout! {
2783            viewport: (90, 40),
2784            content: {
2785                material_theme().child(|| { lazy_row_layout_case(); });
2786            },
2787            expect: {
2788                node("lazy_row_first").position(4, 4).size(20, 10);
2789                node("lazy_row_second").position(27, 4).size(15, 12);
2790                node("lazy_row_third").position(45, 4).size(18, 8);
2791            }
2792        }
2793    }
2794
2795    #[test]
2796    fn lazy_column_scroll_offset_repositions_visible_items() {
2797        tessera_ui::assert_layout! {
2798            viewport: (80, 60),
2799            content: {
2800                material_theme().child(|| { lazy_column_scrolled_layout_case(); });
2801            },
2802            expect: {
2803                node("lazy_column_scrolled_first").position(4, -1).size(20, 10);
2804                node("lazy_column_scrolled_second").position(4, 12).size(18, 12);
2805                node("lazy_column_scrolled_third").position(4, 27).size(16, 8);
2806            }
2807        }
2808    }
2809
2810    #[test]
2811    fn lazy_column_sticky_header_stays_pinned_to_padding() {
2812        tessera_ui::assert_layout! {
2813            viewport: (80, 50),
2814            content: {
2815                material_theme().child(|| { lazy_column_sticky_layout_case(); });
2816            },
2817            expect: {
2818                node("lazy_column_sticky_header").position(4, 4).size(20, 10);
2819                node("lazy_column_sticky_body").position(4, 11).size(18, 12);
2820                node("lazy_column_sticky_tail").position(4, 26).size(16, 8);
2821            }
2822        }
2823    }
2824
2825    #[test]
2826    fn lazy_column_non_stretch_cross_axis_does_not_force_exact_parent_width() {
2827        tessera_ui::assert_layout! {
2828            viewport: (80, 60),
2829            content: {
2830                material_theme().child(|| { lazy_column_non_stretch_cross_axis_case(); });
2831            },
2832            expect: {
2833                node("lazy_column_non_stretch").position(4, 4).size(20, 10);
2834            }
2835        }
2836    }
2837
2838    #[test]
2839    fn lazy_column_rebuilds_visible_items_when_item_slot_updates() {
2840        let mut session = tessera_ui::testing::start_layout_test_session(
2841            tessera_ui::testing::layout_test(|| {
2842                lazy_column_stable_visible_items_case();
2843            })
2844            .viewport_px(80, 60),
2845        );
2846
2847        tessera_ui::testing::pump_layout_test_session(&mut session, 0)
2848            .node("stable_visible_item_0")
2849            .width(20);
2850        let first_builds = session
2851            .work()
2852            .count("fixed_test_box", tessera_ui::WorkPhase::Build);
2853        assert!(first_builds > 0);
2854
2855        tessera_ui::testing::pump_layout_test_session(&mut session, 16_666_667)
2856            .node("stable_visible_item_0")
2857            .width(22);
2858        let second_builds = session
2859            .work()
2860            .count("fixed_test_box", tessera_ui::WorkPhase::Build);
2861        assert!(second_builds > 0);
2862
2863        tessera_ui::testing::pump_layout_test_session(&mut session, 33_333_334)
2864            .node("stable_visible_item_0")
2865            .width(24);
2866        let third_builds = session
2867            .work()
2868            .count("fixed_test_box", tessera_ui::WorkPhase::Build);
2869        assert!(third_builds > 0);
2870    }
2871}
2872
2873#[cfg(all(test, not(target_family = "wasm")))]
2874#[path = "lazy_list_retention_tests.rs"]
2875mod retention_tests;