Skip to main content

cranpose_ui/widgets/
lazy_list.rs

1//! LazyColumn and LazyRow widget implementations.
2//!
3//! Provides virtualized scrolling lists that only compose visible items,
4//! matching Jetpack Compose's `LazyColumn` and `LazyRow` APIs.
5
6#![expect(non_snake_case)]
7
8use std::{
9    cell::{Cell, RefCell},
10    rc::Rc,
11};
12
13use cranpose_core::{NodeId, SlotId, collections::map::HashMap};
14use cranpose_foundation::lazy::{
15    BeyondItem, LazyItemSource, LazyListIntervalContent, LazyListMeasureConfig,
16    LazyListMeasureResult, LazyListMeasuredItem, LazyListState, SmallNodeVec, SmallOffsetVec,
17    measure_lazy_list_with_beyond_bounds_policy,
18};
19pub use cranpose_foundation::lazy::{LazyListItemInfo, LazyListLayoutInfo};
20use cranpose_ui_layout::{Constraints, LinearArrangement, MeasureResult, MeasureScope as _};
21use smallvec::SmallVec;
22use web_time::Instant;
23
24use crate::{
25    composable,
26    layout::MeasuredNode,
27    modifier::{Modifier, Size},
28    scroll::{OverscrollEffect, ScrollMotionContextKey, scroll_motion_context_for_key},
29    subcompose_layout::{
30        MeasurePolicy, Placement, RetainedSlotChildren, SubcomposeChild, SubcomposeLayoutNode,
31        SubcomposeMeasureScope, SubcomposeMeasureScopeImpl,
32    },
33};
34
35const EXPENSIVE_RETAINED_REUSABLE_SLOTS: usize = 128;
36const IDLE_PREFETCH_READY_BEYOND_BOUNDS_ITEMS: usize = 2;
37const IDLE_PREFETCH_ITEMS_PER_PASS: usize = 1;
38/// Where idle prefetch passes run, a scrolling frame composes the new items
39/// beyond its viewport that its scroll would bring to the edge within this
40/// many frames, and leaves the farther ones to the passes. A fast scroll
41/// reaches the ready items, a slow one leaves them all.
42const REACH_FRAMES: f32 = 2.0;
43
44#[derive(Clone, Copy)]
45struct LazyItemMeasureContext {
46    index: usize,
47    key_slot_id: u64,
48    content_type: Option<u64>,
49    is_vertical: bool,
50    cross_axis_size: f32,
51    measure_start: Instant,
52}
53
54/// Specification for LazyColumn layout behavior.
55#[derive(Clone, Debug, PartialEq)]
56pub struct LazyColumnSpec {
57    /// Vertical arrangement for spacing between items.
58    pub vertical_arrangement: LinearArrangement,
59    /// Content padding before the first item.
60    pub content_padding_top: f32,
61    /// Content padding after the last item.
62    pub content_padding_bottom: f32,
63    /// Number of items to compose beyond the visible bounds.
64    /// Higher values reduce jank during fast scrolling but use more memory.
65    pub beyond_bounds_item_count: usize,
66    /// Whether to reverse the layout direction (bottom-to-top).
67    pub reverse_layout: bool,
68}
69
70impl Default for LazyColumnSpec {
71    fn default() -> Self {
72        Self {
73            vertical_arrangement: LinearArrangement::Start,
74            content_padding_top: 0.0,
75            content_padding_bottom: 0.0,
76            beyond_bounds_item_count: 2,
77            reverse_layout: false,
78        }
79    }
80}
81
82impl LazyColumnSpec {
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    pub fn vertical_arrangement(mut self, arrangement: LinearArrangement) -> Self {
88        self.vertical_arrangement = arrangement;
89        self
90    }
91
92    pub fn content_padding(mut self, top: f32, bottom: f32) -> Self {
93        self.content_padding_top = top;
94        self.content_padding_bottom = bottom;
95        self
96    }
97
98    /// Sets uniform content padding for top and bottom.
99    pub fn content_padding_all(mut self, padding: f32) -> Self {
100        self.content_padding_top = padding;
101        self.content_padding_bottom = padding;
102        self
103    }
104
105    pub fn reverse_layout(mut self, reverse: bool) -> Self {
106        self.reverse_layout = reverse;
107        self
108    }
109}
110
111/// Specification for LazyRow layout behavior.
112#[derive(Clone, Debug, PartialEq)]
113pub struct LazyRowSpec {
114    /// Horizontal arrangement for spacing between items.
115    pub horizontal_arrangement: LinearArrangement,
116    /// Content padding before the first item.
117    pub content_padding_start: f32,
118    /// Content padding after the last item.
119    pub content_padding_end: f32,
120    /// Number of items to compose beyond the visible bounds.
121    pub beyond_bounds_item_count: usize,
122    /// Whether to reverse the layout direction (end-to-start).
123    pub reverse_layout: bool,
124}
125
126impl Default for LazyRowSpec {
127    fn default() -> Self {
128        Self {
129            horizontal_arrangement: LinearArrangement::Start,
130            content_padding_start: 0.0,
131            content_padding_end: 0.0,
132            beyond_bounds_item_count: 2,
133            reverse_layout: false,
134        }
135    }
136}
137
138impl LazyRowSpec {
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    pub fn horizontal_arrangement(mut self, arrangement: LinearArrangement) -> Self {
144        self.horizontal_arrangement = arrangement;
145        self
146    }
147
148    pub fn content_padding(mut self, start: f32, end: f32) -> Self {
149        self.content_padding_start = start;
150        self.content_padding_end = end;
151        self
152    }
153
154    /// Sets uniform content padding for start and end.
155    pub fn content_padding_all(mut self, padding: f32) -> Self {
156        self.content_padding_start = padding;
157        self.content_padding_end = padding;
158        self
159    }
160
161    pub fn reverse_layout(mut self, reverse: bool) -> Self {
162        self.reverse_layout = reverse;
163        self
164    }
165}
166
167struct LazyListItemMeasureInputs<'a> {
168    is_vertical: bool,
169    cross_axis_size: f32,
170    content: &'a LazyListIntervalContent,
171    state: &'a LazyListState,
172    measured_item_cache: &'a Rc<RefCell<LazyMeasuredItemCache>>,
173}
174
175fn measure_lazy_list_item(
176    scope: &mut SubcomposeMeasureScopeImpl<'_>,
177    index: usize,
178    inputs: &LazyListItemMeasureInputs<'_>,
179) -> LazyListMeasuredItem {
180    let measure_start = Instant::now();
181    let key = inputs.content.get_key(index);
182    let key_slot_id = key.to_slot_id();
183    let content_type = inputs.content.get_content_type(index);
184    let slot_id = SlotId(key_slot_id);
185    let item_context = LazyItemMeasureContext {
186        index,
187        key_slot_id,
188        content_type,
189        is_vertical: inputs.is_vertical,
190        cross_axis_size: inputs.cross_axis_size,
191        measure_start,
192    };
193
194    scope.update_content_type(slot_id, content_type);
195
196    let candidate_cache = inputs.measured_item_cache.borrow();
197    let cached_candidate = candidate_cache
198        .candidate(index, key_slot_id, content_type)
199        .map(|cached| cached.item.clone());
200    let stale_candidate = cached_candidate.is_none() && candidate_cache.contains_index(index);
201    drop(candidate_cache);
202
203    if let Some(cached_item) = cached_candidate {
204        match scope.activate_exact_retained_slot_with_known_children(slot_id, &cached_item.node_ids)
205        {
206            Some(RetainedSlotChildren::Clean) => {
207                let cache = inputs.measured_item_cache.borrow();
208                let current_candidate = cache.candidate(index, key_slot_id, content_type);
209                if let Some(cached) =
210                    current_candidate.filter(|cached| cached.item.node_ids == cached_item.node_ids)
211                {
212                    scope.register_retained_measurements(&cached.retained_children);
213                    drop(cache);
214                    let mut cache = inputs.measured_item_cache.borrow_mut();
215                    cache.record_candidate_hit();
216                    cache.record_exact_reuse();
217                    return cached_item;
218                }
219                drop(cache);
220
221                let mut cache = inputs.measured_item_cache.borrow_mut();
222                cache.record_candidate_hit();
223                cache.record_exact_miss();
224            }
225            Some(RetainedSlotChildren::Remeasure {
226                children,
227                children_match,
228            }) => {
229                let mut cache = inputs.measured_item_cache.borrow_mut();
230                cache.record_candidate_hit();
231                cache.remove(index);
232                if children_match {
233                    cache.record_dirty_children();
234                } else {
235                    cache.record_exact_miss();
236                }
237                drop(cache);
238                return measure_lazy_list_children(
239                    scope,
240                    children,
241                    inputs.measured_item_cache,
242                    item_context,
243                );
244            }
245            None => {
246                let mut cache = inputs.measured_item_cache.borrow_mut();
247                cache.record_candidate_hit();
248                cache.record_exact_miss();
249                cache.remove(index);
250            }
251        }
252    } else {
253        let mut cache = inputs.measured_item_cache.borrow_mut();
254        cache.record_candidate_miss();
255        if stale_candidate {
256            cache.remove(index);
257        }
258    }
259
260    let Some(root_children) = subcompose_lazy_list_item(scope, index, key, inputs) else {
261        return LazyListMeasuredItem::new(index, key_slot_id, content_type, 1.0, 0.0);
262    };
263
264    let was_reused = scope.was_last_slot_reused().unwrap_or(false);
265    inputs.state.record_composition(was_reused);
266
267    let root_node_ids: SmallNodeVec = root_children
268        .iter()
269        .map(|child| child.node_id() as u64)
270        .collect();
271
272    let cache = inputs.measured_item_cache.borrow();
273    let has_cached_item = cache
274        .get(index, key_slot_id, content_type, &root_node_ids)
275        .is_some();
276    let mut remove_cache_entry = !has_cached_item && cache.contains_index(index);
277    drop(cache);
278
279    if has_cached_item {
280        if !scope.children_need_relayout(&root_children) {
281            let cache = inputs.measured_item_cache.borrow();
282            if let Some(cached) = cache.get(index, key_slot_id, content_type, &root_node_ids) {
283                scope.register_retained_measurements(&cached.retained_children);
284                let item = cached.item.clone();
285                drop(cache);
286                return item;
287            }
288            drop(cache);
289        }
290        remove_cache_entry = true;
291    }
292    if remove_cache_entry {
293        inputs.measured_item_cache.borrow_mut().remove(index);
294    }
295
296    let item = measure_lazy_list_children(
297        scope,
298        root_children,
299        inputs.measured_item_cache,
300        item_context,
301    );
302    crate::lazy_prefetch::record_lazy_item_cost(measure_start.elapsed());
303    item
304}
305
306fn subcompose_lazy_list_item(
307    scope: &mut SubcomposeMeasureScopeImpl<'_>,
308    index: usize,
309    key: cranpose_foundation::lazy::LazyLayoutKey,
310    inputs: &LazyListItemMeasureInputs<'_>,
311) -> Option<Vec<SubcomposeChild>> {
312    let key_slot_id = key.to_slot_id();
313    let item_identity = key.is_user_key().then_some(key_slot_id);
314    let item_content = inputs
315        .content
316        .with_interval(index, |local_index, interval| {
317            let content = Rc::clone(&interval.content);
318            move || {
319                crate::lazy_item::ProvideLazyItemKey(item_identity, || (content)(local_index));
320            }
321        })?;
322    Some(scope.subcompose(SlotId(key_slot_id), (), item_content))
323}
324
325fn keep_lazy_list_item(
326    scope: &mut SubcomposeMeasureScopeImpl<'_>,
327    index: usize,
328    inputs: &LazyListItemMeasureInputs<'_>,
329) {
330    let key = inputs.content.get_key(index);
331    scope.update_content_type(
332        SlotId(key.to_slot_id()),
333        inputs.content.get_content_type(index),
334    );
335    let _ = subcompose_lazy_list_item(scope, index, key, inputs);
336}
337
338fn lazy_list_child_constraints(is_vertical: bool, cross_axis_size: f32) -> Constraints {
339    if is_vertical {
340        Constraints {
341            min_width: 0.0,
342            max_width: cross_axis_size,
343            min_height: 0.0,
344            max_height: f32::INFINITY,
345        }
346    } else {
347        Constraints {
348            min_width: 0.0,
349            max_width: f32::INFINITY,
350            min_height: 0.0,
351            max_height: cross_axis_size,
352        }
353    }
354}
355fn register_visible_lazy_list_child_measurements(
356    scope: &mut SubcomposeMeasureScopeImpl<'_>,
357    visible_items: &[LazyListMeasuredItem],
358    is_vertical: bool,
359    cross_axis_size: f32,
360) {
361    let child_constraints = lazy_list_child_constraints(is_vertical, cross_axis_size);
362    scope.ensure_cached_measurement_node_ids(
363        visible_items
364            .iter()
365            .flat_map(|item| item.node_ids.iter())
366            .filter_map(|&node_id| NodeId::try_from(node_id).ok()),
367        child_constraints,
368    );
369}
370
371fn measure_lazy_list_children(
372    scope: &mut SubcomposeMeasureScopeImpl<'_>,
373    root_children: Vec<SubcomposeChild>,
374    measured_item_cache: &Rc<RefCell<LazyMeasuredItemCache>>,
375    context: LazyItemMeasureContext,
376) -> LazyListMeasuredItem {
377    let child_constraints =
378        lazy_list_child_constraints(context.is_vertical, context.cross_axis_size);
379
380    let mut total_main_size: f32 = 0.0;
381    let mut max_cross_size: f32 = 0.0;
382    let mut node_ids: SmallNodeVec = SmallVec::with_capacity(root_children.len());
383    let mut child_offsets: SmallOffsetVec = SmallVec::new();
384    let mut child_cross_axis_sizes: SmallOffsetVec = SmallVec::new();
385    let mut retained_children: SmallVec<[Rc<MeasuredNode>; 4]> =
386        SmallVec::with_capacity(root_children.len());
387
388    for child in root_children {
389        let (placeable, retained) = scope.measure_retained(child, child_constraints);
390        let size = retained.as_ref().map_or_else(
391            || Size {
392                width: placeable.width(),
393                height: placeable.height(),
394            },
395            |measured| measured.size(),
396        );
397        let (main, cross) = if context.is_vertical {
398            (size.height, size.width)
399        } else {
400            (size.width, size.height)
401        };
402
403        child_offsets.push(total_main_size);
404        child_cross_axis_sizes.push(cross);
405        node_ids.push(child.node_id() as u64);
406        if let Some(retained) = retained {
407            retained_children.push(retained);
408        }
409
410        total_main_size += main;
411        max_cross_size = max_cross_size.max(cross);
412    }
413
414    let main_axis_size = total_main_size.max(1.0);
415    let mut item = LazyListMeasuredItem::new(
416        context.index,
417        context.key_slot_id,
418        context.content_type,
419        main_axis_size,
420        max_cross_size,
421    );
422    item.node_ids = node_ids;
423    item.child_offsets = child_offsets;
424    item.child_cross_axis_sizes = child_cross_axis_sizes;
425
426    measured_item_cache
427        .borrow_mut()
428        .insert(item.clone(), retained_children);
429    let elapsed = context.measure_start.elapsed();
430    if cranpose_core::env_flag!("CRANPOSE_LAZY_ITEM_TELEMETRY") {
431        log::warn!(
432            "[lazy-item-telemetry] index={} children={} main={:.2} cross={:.2} elapsed_ms={:.2}",
433            context.index,
434            item.node_ids.len(),
435            item.main_axis_size,
436            item.cross_axis_size,
437            elapsed.as_secs_f64() * 1000.0
438        );
439    }
440    measured_item_cache.borrow_mut().record_uncached_measure();
441    item
442}
443
444/// Recycles the active slots of items before `first_measured_index`, found
445/// among the items `near` the viewport, where every slot a forward scroll
446/// skips was active.
447fn recycle_forward_skipped_active_slots(
448    scope: &mut SubcomposeMeasureScopeImpl<'_>,
449    content: &LazyListIntervalContent,
450    near: &std::ops::Range<usize>,
451    first_measured_index: usize,
452    scroll_delta: f32,
453) -> bool {
454    if scroll_delta >= -0.001 || first_measured_index == 0 {
455        return false;
456    }
457
458    scope.recycle_active_slots_where(|slot_id| {
459        content
460            .get_index_by_slot_id_in_range(slot_id.raw(), near.clone())
461            .is_some_and(|index| index < first_measured_index)
462    });
463    true
464}
465
466/// Internal helper to create a lazy list measure policy.
467struct LazyListMeasureContext<'a> {
468    state: &'a LazyListState,
469    config: &'a LazyListMeasureConfig,
470    measured_item_cache: &'a Rc<RefCell<LazyMeasuredItemCache>>,
471    overscroll: &'a OverscrollEffect,
472}
473
474fn measure_lazy_list_internal(
475    scope: &mut SubcomposeMeasureScopeImpl<'_>,
476    constraints: Constraints,
477    is_vertical: bool,
478    content: &LazyListIntervalContent,
479    context: LazyListMeasureContext<'_>,
480) -> MeasureResult {
481    let state = context.state;
482    let config = context.config;
483    let measured_item_cache = context.measured_item_cache;
484    let overscroll = context.overscroll;
485    let raw_viewport_size = if is_vertical {
486        constraints.max_height
487    } else {
488        constraints.max_width
489    };
490    let cross_axis_size = if is_vertical {
491        constraints.max_width
492    } else {
493        constraints.max_height
494    };
495
496    let items_count = content.item_count();
497    let retained_reusable_slots = EXPENSIVE_RETAINED_REUSABLE_SLOTS;
498    scope.set_reusable_pool_limits(retained_reusable_slots, retained_reusable_slots);
499    measured_item_cache
500        .borrow_mut()
501        .begin_pass(scope, is_vertical, cross_axis_size);
502    let near = state.nearest_range();
503    if items_count > 0 {
504        state.update_scroll_position_if_item_moved(items_count, |slot_id| {
505            content.get_index_by_slot_id_in_range(slot_id, near.clone())
506        });
507    }
508
509    let scroll_delta_for_direction = state.peek_scroll_delta();
510    let item_measure_inputs = LazyListItemMeasureInputs {
511        is_vertical,
512        cross_axis_size,
513        content,
514        state,
515        measured_item_cache,
516    };
517
518    let focused_item = measure_focused_lazy_item(scope, &item_measure_inputs);
519    let node_id = scope.root_id();
520    let place_beyond = scope.focused_slot().is_some();
521
522    let mut result = measure_lazy_viewport(
523        LazyListItems {
524            scope: &mut *scope,
525            inputs: &item_measure_inputs,
526            near,
527            scroll_delta: scroll_delta_for_direction,
528            skipped_slots_recycled: false,
529            place_beyond,
530            beyond: None,
531        },
532        config,
533        raw_viewport_size,
534        node_id,
535    );
536    if let Some(item) = focused_item {
537        place_focused_lazy_item(&mut result, item, &item_measure_inputs, config.spacing);
538    }
539    register_visible_lazy_list_child_measurements(
540        scope,
541        &result.visible_items,
542        is_vertical,
543        cross_axis_size,
544    );
545    log_lazy_cache_telemetry(&result, measured_item_cache);
546    let effective_viewport_size = result.viewport_size;
547    overscroll.set_dimension(effective_viewport_size);
548
549    state.cache_item_sizes(
550        result
551            .visible_items
552            .iter()
553            .map(|item| (item.index, item.main_axis_size)),
554    );
555    let truly_visible_count = result
556        .visible_items
557        .iter()
558        .filter(|item| {
559            let item_end = item.offset + item.main_axis_size;
560            item.offset < effective_viewport_size && item_end > 0.0
561        })
562        .count();
563    let in_pool = scope.reusable_slots_count();
564    state.update_stats(truly_visible_count, in_pool);
565
566    let resolve_main_axis = |content_size: f32, min: f32, max: f32| {
567        if max.is_finite() {
568            content_size.clamp(min, max)
569        } else {
570            content_size.min(effective_viewport_size).max(min)
571        }
572    };
573
574    let width = if is_vertical {
575        cross_axis_size
576    } else {
577        resolve_main_axis(
578            result.total_content_size,
579            constraints.min_width,
580            constraints.max_width,
581        )
582    };
583    let height = if is_vertical {
584        resolve_main_axis(
585            result.total_content_size,
586            constraints.min_height,
587            constraints.max_height,
588        )
589    } else {
590        cross_axis_size
591    };
592
593    let density = scope.density();
594    scope.layout_with_placement_builder(width, height, |placements| {
595        push_lazy_list_placements(
596            placements,
597            density,
598            &result.visible_items,
599            items_count,
600            is_vertical,
601            effective_viewport_size,
602            config,
603            cross_axis_size,
604        );
605        let bounce = overscroll.offset();
606        for placement in placements.iter_mut() {
607            if is_vertical {
608                placement.y += bounce;
609            } else {
610                placement.x += bounce;
611            }
612        }
613    })
614}
615
616struct LazyListItems<'s, 'a, 'i> {
617    scope: &'s mut SubcomposeMeasureScopeImpl<'a>,
618    inputs: &'i LazyListItemMeasureInputs<'i>,
619    near: std::ops::Range<usize>,
620    scroll_delta: f32,
621    skipped_slots_recycled: bool,
622    place_beyond: bool,
623    beyond: Option<BeyondBoundsComposition>,
624}
625
626impl LazyListItems<'_, '_, '_> {
627    fn composed(&self, index: usize) -> bool {
628        self.scope
629            .slot_is_active(SlotId(self.inputs.content.get_key(index).to_slot_id()))
630    }
631}
632
633impl LazyItemSource for LazyListItems<'_, '_, '_> {
634    fn measure(&mut self, index: usize) -> LazyListMeasuredItem {
635        if !self.skipped_slots_recycled
636            && recycle_forward_skipped_active_slots(
637                self.scope,
638                self.inputs.content,
639                &self.near,
640                index,
641                self.scroll_delta,
642            )
643        {
644            self.skipped_slots_recycled = true;
645        }
646        // An item an idle pass composes afresh has its modifier slices built
647        // before the frame it enters the screen in.
648        let fresh =
649            self.beyond.as_ref().is_some_and(|beyond| beyond.idle_pass) && !self.composed(index);
650        let item = measure_lazy_list_item(self.scope, index, self.inputs);
651        if fresh {
652            crate::lazy_prefetch::note_prefetched_item(&item.node_ids);
653        }
654        item
655    }
656
657    fn keep_beyond(&mut self, index: usize, distance: f32) -> BeyondItem {
658        let composed = self.composed(index);
659        if let Some(beyond) = self.beyond.as_mut()
660            && !beyond.admits(composed, distance)
661        {
662            return BeyondItem::Declined;
663        }
664        if self.place_beyond {
665            return BeyondItem::Placed(self.measure(index));
666        }
667        if composed {
668            keep_lazy_list_item(self.scope, index, self.inputs);
669        } else {
670            self.measure(index);
671        }
672        BeyondItem::Kept
673    }
674}
675
676/// Measures the list's viewport. While it scrolls, and in an idle prefetch
677/// pass, the items beyond it compose as [`BeyondBoundsComposition`] decides,
678/// and an item left for later asks for a pass for the list `node_id`. So do
679/// they at rest where the shell runs idle prefetch passes, unless the list
680/// holds the focus and places them.
681fn measure_lazy_viewport(
682    mut items: LazyListItems<'_, '_, '_>,
683    config: &LazyListMeasureConfig,
684    viewport_size: f32,
685    node_id: NodeId,
686) -> LazyListMeasureResult {
687    let inputs = items.inputs;
688    let items_count = inputs.content.item_count();
689    let idle_pass = crate::lazy_prefetch::in_lazy_prefetch_pass();
690    if idle_pass {
691        inputs.state.hold_scroll_window();
692    }
693    let scrolling = items.scroll_delta.abs() > 0.001;
694    let passes_run = crate::lazy_prefetch::idle_prefetch_passes_run();
695    if idle_pass {
696        items.beyond = Some(BeyondBoundsComposition::new(true, f32::INFINITY));
697    } else if scrolling || (passes_run && !items.place_beyond) {
698        let reach = if passes_run {
699            items.scroll_delta.abs() * REACH_FRAMES
700        } else {
701            f32::INFINITY
702        };
703        items.beyond = Some(BeyondBoundsComposition::new(false, reach));
704    }
705    let result = measure_lazy_list_with_beyond_bounds_policy(
706        items_count,
707        inputs.state,
708        viewport_size,
709        inputs.cross_axis_size,
710        config,
711        &mut items,
712    );
713    if items.beyond.is_some_and(|beyond| beyond.wants_prefetch) {
714        crate::lazy_prefetch::request_lazy_prefetch(node_id);
715    }
716    result
717}
718
719/// Which items beyond the viewport a list composes, in order away from it.
720/// Composed items stay composed. A frame composes new ones that start within
721/// its reach until enough composed items lie ahead, counting its own; the
722/// next is left for an idle prefetch pass, which composes one.
723#[derive(Clone, Copy)]
724struct BeyondBoundsComposition {
725    idle_pass: bool,
726    ready: usize,
727    frontier: usize,
728    /// How far past the viewport a new item may start for a frame to compose
729    /// it. Where idle prefetch passes run, the distance its scroll covers in
730    /// [`REACH_FRAMES`] frames, none at rest: the passes compose the rest.
731    reach: f32,
732    wants_prefetch: bool,
733}
734
735impl BeyondBoundsComposition {
736    fn new(idle_pass: bool, reach: f32) -> Self {
737        Self {
738            idle_pass,
739            ready: 0,
740            frontier: if idle_pass {
741                IDLE_PREFETCH_ITEMS_PER_PASS
742            } else {
743                IDLE_PREFETCH_READY_BEYOND_BOUNDS_ITEMS
744            },
745            reach,
746            wants_prefetch: false,
747        }
748    }
749
750    fn admits(&mut self, composed: bool, distance: f32) -> bool {
751        if composed {
752            self.ready += 1;
753            return true;
754        }
755        let deferred = !self.idle_pass
756            && (self.ready >= IDLE_PREFETCH_READY_BEYOND_BOUNDS_ITEMS || distance >= self.reach);
757        if deferred || self.frontier == 0 {
758            self.wants_prefetch = true;
759            return false;
760        }
761        self.frontier -= 1;
762        self.ready += 1;
763        true
764    }
765}
766
767fn measure_focused_lazy_item(
768    scope: &mut SubcomposeMeasureScopeImpl<'_>,
769    inputs: &LazyListItemMeasureInputs<'_>,
770) -> Option<LazyListMeasuredItem> {
771    let slot = scope.focused_slot()?.raw();
772    let content = inputs.content;
773    let last_measured = inputs.measured_item_cache.borrow().focused_index(slot);
774    let found = last_measured
775        .filter(|&index| {
776            index < content.item_count() && content.get_key(index).to_slot_id() == slot
777        })
778        .or_else(|| content.get_index_by_slot_id_in_range(slot, inputs.state.nearest_range()))
779        .or_else(|| content.get_index_by_slot_id(slot));
780    let Some(index) = found else {
781        let _ = cranpose_core::run_in_mutable_snapshot(|| crate::FocusManager.clear_focus());
782        return None;
783    };
784    inputs
785        .measured_item_cache
786        .borrow_mut()
787        .remember_focused(slot, index);
788    Some(measure_lazy_list_item(scope, index, inputs))
789}
790
791fn place_focused_lazy_item(
792    result: &mut LazyListMeasureResult,
793    mut item: LazyListMeasuredItem,
794    inputs: &LazyListItemMeasureInputs<'_>,
795    spacing: f32,
796) {
797    let Err(position) = result
798        .visible_items
799        .binary_search_by_key(&item.index, |entry| entry.index)
800    else {
801        return;
802    };
803    let Some(anchor) = result.visible_items.first() else {
804        return;
805    };
806    let start = item.index.min(anchor.index);
807    let end = item.index.max(anchor.index);
808    let estimate = anchor.main_axis_size;
809    let mut distance = (end - start) as f32 * (estimate + spacing);
810    for (&index, cached) in &inputs.measured_item_cache.borrow().entries {
811        if (start..end).contains(&index)
812            && cached.item.key == inputs.content.get_key(index).to_slot_id()
813        {
814            distance += cached.item.main_axis_size - estimate;
815        }
816    }
817    item.offset = anchor.offset
818        + if item.index < anchor.index {
819            -distance
820        } else {
821            distance
822        };
823    result.visible_items.insert(position, item);
824}
825
826fn bind_layout_invalidation_callback(
827    state: LazyListState,
828    overscroll: OverscrollEffect,
829    list_state_id: usize,
830    node_id: NodeId,
831) {
832    let callback_owner =
833        cranpose_core::remember(|| Rc::new(RefCell::new(None::<u64>))).with(Clone::clone);
834    let app_context_id = crate::render_state::current_app_context_id();
835    let callback_id = state.try_register_layout_callback(
836        node_id,
837        Rc::new(move || {
838            let _ = crate::render_state::enter_app_context_by_id(app_context_id, || {
839                crate::schedule_measure_repass(node_id);
840            });
841        }),
842    );
843
844    if let Some(previous_id) = callback_owner.replace(callback_id)
845        && Some(previous_id) != callback_id
846    {
847        state.remove_invalidate_callback(previous_id);
848    }
849
850    let overscroll_callback_owner =
851        cranpose_core::remember(|| Rc::new(RefCell::new(None::<u64>))).with(Clone::clone);
852    let overscroll_callback_id = overscroll.add_invalidate_callback(Box::new(move || {
853        let _ = crate::render_state::enter_app_context_by_id(app_context_id, || {
854            crate::schedule_measure_repass(node_id);
855            crate::schedule_semantics_invalidation(node_id);
856        });
857    }));
858    if let Some(previous_id) = overscroll_callback_owner.replace(Some(overscroll_callback_id)) {
859        overscroll.remove_invalidate_callback(previous_id);
860    }
861
862    cranpose_core::DisposableEffect(
863        (list_state_id, node_id, callback_id, overscroll_callback_id),
864        move |scope| {
865            scope.on_dispose(move || {
866                if let Some(callback_id) = callback_id {
867                    state.remove_invalidate_callback(callback_id);
868                }
869                overscroll.remove_invalidate_callback(overscroll_callback_id);
870            })
871        },
872    );
873}
874
875#[derive(Clone)]
876struct LazyListContentHandle(Rc<LazyListIntervalContent>);
877
878impl LazyListContentHandle {
879    fn new(content: LazyListIntervalContent) -> Self {
880        Self(Rc::new(content))
881    }
882
883    fn empty() -> Self {
884        Self::new(LazyListIntervalContent::new())
885    }
886
887    fn content(&self) -> &LazyListIntervalContent {
888        self.0.as_ref()
889    }
890}
891
892impl PartialEq for LazyListContentHandle {
893    fn eq(&self, other: &Self) -> bool {
894        Rc::ptr_eq(&self.0, &other.0)
895    }
896}
897
898#[derive(Default)]
899struct LazyMeasuredItemCache {
900    is_vertical: bool,
901    cross_axis_bits: u32,
902    telemetry: LazyCacheTelemetry,
903    entries: HashMap<usize, CachedLazyMeasuredItem>,
904    focused: Option<(u64, usize)>,
905}
906
907struct CachedLazyMeasuredItem {
908    item: LazyListMeasuredItem,
909    retained_children: SmallVec<[Rc<MeasuredNode>; 4]>,
910}
911
912#[derive(Clone, Copy, Default)]
913struct LazyCacheTelemetry {
914    candidate_hits: usize,
915    candidate_misses: usize,
916    exact_reuses: usize,
917    exact_misses: usize,
918    dirty_children: usize,
919    uncached_measures: usize,
920}
921
922impl LazyCacheTelemetry {
923    fn has_events(self) -> bool {
924        self.candidate_hits > 0
925            || self.candidate_misses > 0
926            || self.exact_reuses > 0
927            || self.exact_misses > 0
928            || self.dirty_children > 0
929            || self.uncached_measures > 0
930    }
931}
932
933impl LazyMeasuredItemCache {
934    /// The index the item in `slot` was last measured at while focused, the
935    /// first place to look for it: a focused item scrolled far from the
936    /// viewport is found there without scanning the list.
937    fn focused_index(&self, slot: u64) -> Option<usize> {
938        self.focused
939            .filter(|(focused, _)| *focused == slot)
940            .map(|(_, index)| index)
941    }
942
943    fn remember_focused(&mut self, slot: u64, index: usize) {
944        self.focused = Some((slot, index));
945    }
946
947    fn retain_constraint_scope(&mut self, is_vertical: bool, cross_axis_size: f32) {
948        let cross_axis_bits = normalized_axis_bits(cross_axis_size);
949        if self.entries.is_empty() {
950            self.is_vertical = is_vertical;
951            self.cross_axis_bits = cross_axis_bits;
952            return;
953        }
954        if self.is_vertical != is_vertical || self.cross_axis_bits != cross_axis_bits {
955            self.clear();
956            self.is_vertical = is_vertical;
957            self.cross_axis_bits = cross_axis_bits;
958        }
959    }
960
961    fn clear(&mut self) {
962        self.entries.clear();
963    }
964
965    /// Drops what a new measure pass cannot reuse: everything, when the
966    /// list's axis or cross size changed, and otherwise the items whose slots
967    /// `scope` no longer holds.
968    fn begin_pass(
969        &mut self,
970        scope: &SubcomposeMeasureScopeImpl<'_>,
971        is_vertical: bool,
972        cross_axis_size: f32,
973    ) {
974        self.retain_constraint_scope(is_vertical, cross_axis_size);
975        self.retain_retained_slots(|slot_id| scope.slot_is_retained(slot_id));
976    }
977
978    /// Keeps only the items whose slots the subcompose state still holds.
979    /// An item whose composition was disposed can never be reused, and its
980    /// entry would keep the item's whole measured tree alive: an endless list
981    /// scrolled past thousands of items kept them all.
982    fn retain_retained_slots(&mut self, is_retained: impl Fn(SlotId) -> bool) {
983        self.entries
984            .retain(|_, cached| is_retained(SlotId(cached.item.key)));
985    }
986
987    fn get(
988        &self,
989        index: usize,
990        key: u64,
991        content_type: Option<u64>,
992        node_ids: &SmallNodeVec,
993    ) -> Option<&CachedLazyMeasuredItem> {
994        let cached = self.entries.get(&index)?;
995        if cached.item.key != key
996            || cached.item.content_type != content_type
997            || cached.item.node_ids != *node_ids
998            || cached.retained_children.len() != cached.item.node_ids.len()
999        {
1000            return None;
1001        }
1002        Some(cached)
1003    }
1004
1005    fn contains_index(&self, index: usize) -> bool {
1006        self.entries.contains_key(&index)
1007    }
1008
1009    fn candidate(
1010        &self,
1011        index: usize,
1012        key: u64,
1013        content_type: Option<u64>,
1014    ) -> Option<&CachedLazyMeasuredItem> {
1015        let cached = self.entries.get(&index)?;
1016        if cached.item.key != key
1017            || cached.item.content_type != content_type
1018            || cached.retained_children.len() != cached.item.node_ids.len()
1019        {
1020            return None;
1021        }
1022        Some(cached)
1023    }
1024
1025    fn remove(&mut self, index: usize) {
1026        self.entries.remove(&index);
1027    }
1028
1029    fn insert(
1030        &mut self,
1031        item: LazyListMeasuredItem,
1032        retained_children: SmallVec<[Rc<MeasuredNode>; 4]>,
1033    ) {
1034        let index = item.index;
1035        let cached = CachedLazyMeasuredItem {
1036            item,
1037            retained_children,
1038        };
1039        self.entries.insert(index, cached);
1040    }
1041
1042    fn record_candidate_hit(&mut self) {
1043        self.telemetry.candidate_hits += 1;
1044    }
1045
1046    fn record_candidate_miss(&mut self) {
1047        self.telemetry.candidate_misses += 1;
1048    }
1049
1050    fn record_exact_reuse(&mut self) {
1051        self.telemetry.exact_reuses += 1;
1052    }
1053
1054    fn record_exact_miss(&mut self) {
1055        self.telemetry.exact_misses += 1;
1056    }
1057
1058    fn record_dirty_children(&mut self) {
1059        self.telemetry.dirty_children += 1;
1060    }
1061
1062    fn take_telemetry(&mut self) -> LazyCacheTelemetry {
1063        std::mem::take(&mut self.telemetry)
1064    }
1065
1066    fn record_uncached_measure(&mut self) {
1067        self.telemetry.uncached_measures += 1;
1068    }
1069}
1070
1071fn log_lazy_cache_telemetry(
1072    result: &LazyListMeasureResult,
1073    measured_item_cache: &Rc<RefCell<LazyMeasuredItemCache>>,
1074) {
1075    if !cranpose_core::env_flag!("CRANPOSE_LAZY_CACHE_TELEMETRY") {
1076        return;
1077    }
1078
1079    let telemetry = measured_item_cache.borrow_mut().take_telemetry();
1080    if !telemetry.has_events() {
1081        return;
1082    }
1083
1084    let message = format!(
1085        "[lazy-cache-telemetry] first={} offset={:.2} visible={} candidate_hits={} candidate_misses={} exact_reuses={} exact_misses={} dirty_children={} uncached_measures={} cache_entries={}",
1086        result.first_visible_item_index,
1087        result.first_visible_item_scroll_offset,
1088        result.visible_items.len(),
1089        telemetry.candidate_hits,
1090        telemetry.candidate_misses,
1091        telemetry.exact_reuses,
1092        telemetry.exact_misses,
1093        telemetry.dirty_children,
1094        telemetry.uncached_measures,
1095        measured_item_cache.borrow().entries.len(),
1096    );
1097    log::warn!("{message}");
1098    #[cfg(test)]
1099    eprintln!("{message}");
1100}
1101
1102fn normalized_axis_bits(size: f32) -> u32 {
1103    if size.is_finite() && size >= 0.0 {
1104        size.to_bits()
1105    } else {
1106        f32::INFINITY.to_bits()
1107    }
1108}
1109
1110#[expect(clippy::too_many_arguments)]
1111fn push_lazy_list_placements(
1112    placements: &mut Vec<Placement>,
1113    density: f32,
1114    visible_items: &[LazyListMeasuredItem],
1115    items_count: usize,
1116    is_vertical: bool,
1117    viewport_size: f32,
1118    config: &LazyListMeasureConfig,
1119    cross_axis_size: f32,
1120) {
1121    use cranpose_ui_layout::Arrangement;
1122
1123    placements.clear();
1124    placements.reserve(visible_items.iter().map(|item| item.node_ids.len()).sum());
1125
1126    let arrangement = if is_vertical {
1127        config
1128            .vertical_arrangement
1129            .unwrap_or(LinearArrangement::Start)
1130    } else {
1131        config
1132            .horizontal_arrangement
1133            .unwrap_or(LinearArrangement::Start)
1134    };
1135
1136    let spacing = config.spacing;
1137    let total_item_size: f32 = visible_items.iter().map(|i| i.main_axis_size).sum::<f32>()
1138        + (items_count.saturating_sub(1) as f32) * spacing;
1139    let available_main_axis =
1140        (viewport_size - config.before_content_padding - config.after_content_padding).max(0.0);
1141    let has_spare_space =
1142        total_item_size < available_main_axis && visible_items.len() == items_count;
1143    let should_apply_arrangement = has_spare_space
1144        && !matches!(
1145            arrangement,
1146            LinearArrangement::Start | LinearArrangement::SpacedBy(_)
1147        );
1148
1149    if should_apply_arrangement {
1150        let content_offset = config.before_content_padding;
1151
1152        let sizes: SmallVec<[f32; 32]> = visible_items.iter().map(|i| i.main_axis_size).collect();
1153        let mut positions: SmallVec<[f32; 32]> = SmallVec::from_elem(0.0, sizes.len());
1154        arrangement.arrange(density, available_main_axis, &sizes, &mut positions);
1155
1156        for (item, &pos) in visible_items.iter().zip(positions.iter()) {
1157            push_lazy_item_placements(
1158                placements,
1159                item,
1160                content_offset + pos,
1161                is_vertical,
1162                viewport_size,
1163                cross_axis_size,
1164                config,
1165            );
1166        }
1167    } else {
1168        for item in visible_items {
1169            push_lazy_item_placements(
1170                placements,
1171                item,
1172                item.offset,
1173                is_vertical,
1174                viewport_size,
1175                cross_axis_size,
1176                config,
1177            );
1178        }
1179    }
1180}
1181
1182fn push_lazy_item_placements(
1183    placements: &mut Vec<Placement>,
1184    item: &LazyListMeasuredItem,
1185    offset: f32,
1186    is_vertical: bool,
1187    viewport_size: f32,
1188    cross_axis_size: f32,
1189    config: &LazyListMeasureConfig,
1190) {
1191    let start = if config.reverse_layout {
1192        viewport_size - offset - item.main_axis_size
1193    } else {
1194        offset
1195    };
1196    for (index, (&node_id, &offset)) in item.node_ids.iter().zip(&item.child_offsets).enumerate() {
1197        let main = start + offset;
1198        let (mut x, y) = if is_vertical {
1199            (0.0, main)
1200        } else {
1201            (main, 0.0)
1202        };
1203        if config.is_rtl {
1204            x = if is_vertical {
1205                cross_axis_size
1206                    - item
1207                        .child_cross_axis_sizes
1208                        .get(index)
1209                        .copied()
1210                        .unwrap_or(item.cross_axis_size)
1211            } else {
1212                let end = item
1213                    .child_offsets
1214                    .get(index + 1)
1215                    .copied()
1216                    .unwrap_or(item.main_axis_size);
1217                viewport_size - x - (end - offset)
1218            };
1219        }
1220        placements.push(Placement::new(node_id as NodeId, x, y, 0));
1221    }
1222}
1223
1224fn lazy_list_state_identity(state: &LazyListState) -> usize {
1225    let state_ptr = state.inner_ptr();
1226    debug_assert!(
1227        !state_ptr.is_null(),
1228        "lazy list identity requires a live LazyListState"
1229    );
1230    state_ptr as usize
1231}
1232
1233fn lazy_list_state_only_recomposition(state: &LazyListState) -> bool {
1234    cranpose_core::current_recompose_scope_invalidated_only_by(state.reactive_state_ids())
1235        .unwrap_or(false)
1236}
1237
1238/// Internal implementation for LazyColumn that takes pre-built content.
1239///
1240/// Users should prefer the DSL-based [`LazyColumn`] function instead.
1241fn LazyColumnImpl(
1242    modifier: Modifier,
1243    state: LazyListState,
1244    spec: LazyColumnSpec,
1245    content: LazyListContentHandle,
1246) -> NodeId {
1247    use std::cell::RefCell;
1248
1249    let content_cell =
1250        cranpose_core::remember(|| Rc::new(RefCell::new(LazyListContentHandle::empty())))
1251            .with(Clone::clone);
1252
1253    let refresh_content = !lazy_list_state_only_recomposition(&state);
1254    if refresh_content {
1255        *content_cell.borrow_mut() = content;
1256    }
1257    let caller_modifier_changed = super::layout::caller_modifier_changed(&modifier);
1258
1259    let composed_density = crate::density::density();
1260    let config = LazyListMeasureConfig {
1261        is_vertical: true,
1262        is_rtl: crate::layout_direction().is_rtl(),
1263        reverse_layout: spec.reverse_layout,
1264        before_content_padding: spec.content_padding_top,
1265        after_content_padding: spec.content_padding_bottom,
1266        spacing: spec
1267            .vertical_arrangement
1268            .spacing(composed_density.density()),
1269        beyond_bounds_item_count: spec.beyond_bounds_item_count,
1270        vertical_arrangement: Some(spec.vertical_arrangement),
1271        horizontal_arrangement: None,
1272    };
1273    let config_cell =
1274        cranpose_core::remember(|| Rc::new(RefCell::new(config.clone()))).with(Clone::clone);
1275    let config_changed = {
1276        let mut current = config_cell.borrow_mut();
1277        let changed = *current != config;
1278        if changed {
1279            *current = config;
1280        }
1281        changed
1282    };
1283    let measured_item_cache =
1284        cranpose_core::remember(|| Rc::new(RefCell::new(LazyMeasuredItemCache::default())))
1285            .with(Clone::clone);
1286    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1287        state_identity: lazy_list_state_identity(&state),
1288        is_vertical: true,
1289        reverse_scrolling: spec.reverse_layout,
1290    });
1291    let overscroll = motion_context.overscroll();
1292
1293    let measured_item_cache_for_policy = measured_item_cache.clone();
1294    let overscroll_for_policy = overscroll.clone();
1295    let policy: Rc<MeasurePolicy> = cranpose_core::remember(move || {
1296        let config_ref = config_cell.clone();
1297        let content_ref = content_cell.clone();
1298        let measured_item_cache = measured_item_cache_for_policy.clone();
1299        let overscroll = overscroll_for_policy.clone();
1300        let policy: Rc<MeasurePolicy> = Rc::new(
1301            move |scope: &mut SubcomposeMeasureScopeImpl<'_>, constraints: Constraints| {
1302                let content = content_ref.borrow();
1303                let config = config_ref.borrow().clone();
1304                measure_lazy_list_internal(
1305                    scope,
1306                    constraints,
1307                    true,
1308                    content.content(),
1309                    LazyListMeasureContext {
1310                        state: &state,
1311                        config: &config,
1312                        measured_item_cache: &measured_item_cache,
1313                        overscroll: &overscroll,
1314                    },
1315                )
1316            },
1317        );
1318        policy
1319    })
1320    .with(|p| p.clone());
1321    let list_state_id = lazy_list_state_identity(&state);
1322
1323    let scroll_modifier = modifier.clip_to_bounds().lazy_vertical_scroll_with_context(
1324        state,
1325        spec.reverse_layout,
1326        (spec.content_padding_top, spec.content_padding_bottom),
1327        motion_context,
1328    );
1329
1330    let node_id = cranpose_core::with_current_composer(|composer| {
1331        composer.with_key(&(list_state_id, "LazyColumnNode"), |composer| {
1332            composer.emit_node({
1333                let scroll_modifier = scroll_modifier.clone();
1334                let policy = Rc::clone(&policy);
1335                move || SubcomposeLayoutNode::with_content_type_policy(scroll_modifier, policy)
1336            })
1337        })
1338    });
1339    let captured_context =
1340        cranpose_core::with_current_composer(cranpose_core::Composer::capture_composition_context);
1341    if let Err(err) = cranpose_core::with_node_mut(node_id, |node: &mut SubcomposeLayoutNode| {
1342        let inputs_changed =
1343            refresh_content || config_changed || !node.modifier().structural_eq(&scroll_modifier);
1344        if inputs_changed || caller_modifier_changed {
1345            node.set_modifier(scroll_modifier.clone());
1346        }
1347        node.set_measure_policy(Rc::clone(&policy));
1348        node.set_captured_context(captured_context);
1349        node.set_density(composed_density);
1350        node.set_layout_direction(crate::layout_direction());
1351        if inputs_changed {
1352            measured_item_cache.borrow_mut().clear();
1353            node.invalidate_subcomposition();
1354        }
1355    }) {
1356        debug_assert!(false, "failed to update LazyColumn node: {err}");
1357    }
1358    bind_layout_invalidation_callback(state, overscroll, list_state_id, node_id);
1359
1360    node_id
1361}
1362
1363/// Internal implementation for LazyRow that takes pre-built content.
1364///
1365/// Users should prefer the DSL-based [`LazyRow`] function instead.
1366fn LazyRowImpl(
1367    modifier: Modifier,
1368    state: LazyListState,
1369    spec: LazyRowSpec,
1370    content: LazyListContentHandle,
1371) -> NodeId {
1372    use std::cell::RefCell;
1373
1374    let content_cell =
1375        cranpose_core::remember(|| Rc::new(RefCell::new(LazyListContentHandle::empty())))
1376            .with(Clone::clone);
1377
1378    let refresh_content = !lazy_list_state_only_recomposition(&state);
1379    if refresh_content {
1380        *content_cell.borrow_mut() = content;
1381    }
1382    let caller_modifier_changed = super::layout::caller_modifier_changed(&modifier);
1383
1384    let composed_density = crate::density::density();
1385    let is_rtl = crate::layout_direction().is_rtl();
1386    let reverse_scrolling = spec.reverse_layout ^ is_rtl;
1387    let config = LazyListMeasureConfig {
1388        is_vertical: false,
1389        is_rtl,
1390        reverse_layout: spec.reverse_layout,
1391        before_content_padding: spec.content_padding_start,
1392        after_content_padding: spec.content_padding_end,
1393        spacing: spec
1394            .horizontal_arrangement
1395            .spacing(composed_density.density()),
1396        beyond_bounds_item_count: spec.beyond_bounds_item_count,
1397        vertical_arrangement: None,
1398        horizontal_arrangement: Some(spec.horizontal_arrangement),
1399    };
1400    let config_cell =
1401        cranpose_core::remember(|| Rc::new(RefCell::new(config.clone()))).with(Clone::clone);
1402    let config_changed = {
1403        let mut current = config_cell.borrow_mut();
1404        let changed = *current != config;
1405        if changed {
1406            *current = config;
1407        }
1408        changed
1409    };
1410    let measured_item_cache =
1411        cranpose_core::remember(|| Rc::new(RefCell::new(LazyMeasuredItemCache::default())))
1412            .with(Clone::clone);
1413    let motion_context = scroll_motion_context_for_key(ScrollMotionContextKey::LazyList {
1414        state_identity: lazy_list_state_identity(&state),
1415        is_vertical: false,
1416        reverse_scrolling,
1417    });
1418    let overscroll = motion_context.overscroll();
1419
1420    let measured_item_cache_for_policy = measured_item_cache.clone();
1421    let overscroll_for_policy = overscroll.clone();
1422    let policy: Rc<MeasurePolicy> = cranpose_core::remember(move || {
1423        let config_ref = config_cell.clone();
1424        let content_ref = content_cell.clone();
1425        let measured_item_cache = measured_item_cache_for_policy.clone();
1426        let overscroll = overscroll_for_policy.clone();
1427        let policy: Rc<MeasurePolicy> = Rc::new(
1428            move |scope: &mut SubcomposeMeasureScopeImpl<'_>, constraints: Constraints| {
1429                let content = content_ref.borrow();
1430                let config = config_ref.borrow().clone();
1431                measure_lazy_list_internal(
1432                    scope,
1433                    constraints,
1434                    false,
1435                    content.content(),
1436                    LazyListMeasureContext {
1437                        state: &state,
1438                        config: &config,
1439                        measured_item_cache: &measured_item_cache,
1440                        overscroll: &overscroll,
1441                    },
1442                )
1443            },
1444        );
1445        policy
1446    })
1447    .with(|p| p.clone());
1448    let list_state_id = lazy_list_state_identity(&state);
1449
1450    let scroll_modifier = modifier
1451        .clip_to_bounds()
1452        .lazy_horizontal_scroll_with_context(
1453            state,
1454            reverse_scrolling,
1455            (spec.content_padding_start, spec.content_padding_end),
1456            motion_context,
1457        );
1458
1459    let node_id = cranpose_core::with_current_composer(|composer| {
1460        composer.with_key(&(list_state_id, "LazyRowNode"), |composer| {
1461            composer.emit_node({
1462                let scroll_modifier = scroll_modifier.clone();
1463                let policy = Rc::clone(&policy);
1464                move || SubcomposeLayoutNode::with_content_type_policy(scroll_modifier, policy)
1465            })
1466        })
1467    });
1468    let captured_context =
1469        cranpose_core::with_current_composer(cranpose_core::Composer::capture_composition_context);
1470    if let Err(err) = cranpose_core::with_node_mut(node_id, |node: &mut SubcomposeLayoutNode| {
1471        let inputs_changed =
1472            refresh_content || config_changed || !node.modifier().structural_eq(&scroll_modifier);
1473        if inputs_changed || caller_modifier_changed {
1474            node.set_modifier(scroll_modifier.clone());
1475        }
1476        node.set_measure_policy(Rc::clone(&policy));
1477        node.set_captured_context(captured_context);
1478        node.set_density(composed_density);
1479        node.set_layout_direction(crate::layout_direction());
1480        if inputs_changed {
1481            measured_item_cache.borrow_mut().clear();
1482            node.invalidate_subcomposition();
1483        }
1484    }) {
1485        debug_assert!(false, "failed to update LazyRow node: {err}");
1486    }
1487    bind_layout_invalidation_callback(state, overscroll, list_state_id, node_id);
1488
1489    node_id
1490}
1491
1492#[composable]
1493fn LazyColumnNode(
1494    modifier: Modifier,
1495    state: LazyListState,
1496    spec: LazyColumnSpec,
1497    content: LazyListContentHandle,
1498) -> NodeId {
1499    cranpose_core::debug_label_current_scope("LazyColumnNode");
1500
1501    let viewport = cranpose_core::remember(|| {
1502        Rc::new(Cell::new(cranpose_ui_graphics::WindowCoordinates::default()))
1503    })
1504    .with(Rc::clone);
1505    let responder = {
1506        let viewport = Rc::clone(&viewport);
1507        cranpose_core::remember(move || {
1508            let viewport = Rc::clone(&viewport);
1509            crate::bring_into_view::BringIntoViewResponder::new(move |caret, ime_bottom| {
1510                let vp = viewport.get();
1511                if vp.size.width <= 0.0 || vp.size.height <= 0.0 {
1512                    return;
1513                }
1514                let delta =
1515                    crate::bring_into_view::local_scroll_delta_to_reveal(caret, vp, ime_bottom);
1516                if delta.abs() > 0.5 {
1517                    state.dispatch_scroll_delta(-delta);
1518                }
1519            })
1520        })
1521        .with(|r| r.clone())
1522    };
1523    let modifier = modifier.report_window_coordinates(Rc::clone(&viewport));
1524
1525    let mut node: Option<NodeId> = None;
1526    {
1527        let node_slot = &mut node;
1528        cranpose_core::CompositionLocalProvider(
1529            [crate::bring_into_view::local_bring_into_view_responder().provides(Some(responder))],
1530            move || {
1531                *node_slot = Some(LazyColumnImpl(modifier, state, spec, content));
1532            },
1533        );
1534    }
1535    node.expect("LazyColumnImpl emits a node")
1536}
1537
1538#[composable]
1539fn LazyRowNode(
1540    modifier: Modifier,
1541    state: LazyListState,
1542    spec: LazyRowSpec,
1543    content: LazyListContentHandle,
1544) -> NodeId {
1545    cranpose_core::debug_label_current_scope("LazyRowNode");
1546    LazyRowImpl(modifier, state, spec, content)
1547}
1548
1549/// Composes and measures the rows needed for a vertical viewport. The content
1550/// closure declares rows through [`LazyListScope`](cranpose_foundation::lazy::LazyListScope).
1551/// Use stable keys for item identity across changes and content types for distinct row shapes.
1552/// The state retains the scroll position; the spec sets item space and content insets.
1553///
1554/// # Example
1555///
1556/// ```rust
1557/// use cranpose_ui::*;
1558///
1559/// #[composable]
1560/// fn Messages() {
1561///     let state = rememberLazyListState();
1562///     LazyColumn(
1563///         Modifier::empty().fill_max_size(),
1564///         state,
1565///         LazyColumnSpec::default(),
1566///         |scope| {
1567///             scope.items(LazyItems::new(1000), |index| {
1568///                 Text(
1569///                     format!("Message {index}"),
1570///                     Modifier::empty().padding(16.0),
1571///                     TextStyle::default(),
1572///                 );
1573///             });
1574///         },
1575///     );
1576/// }
1577/// ```
1578pub fn LazyColumn<F>(
1579    modifier: Modifier,
1580    state: LazyListState,
1581    spec: LazyColumnSpec,
1582    content: F,
1583) -> NodeId
1584where
1585    F: FnOnce(&mut LazyListIntervalContent),
1586{
1587    let mut interval_content = LazyListIntervalContent::new();
1588    content(&mut interval_content);
1589    LazyColumnNode(
1590        modifier,
1591        state,
1592        spec,
1593        LazyListContentHandle::new(interval_content),
1594    )
1595}
1596
1597/// Composes and measures the items needed for a horizontal viewport.
1598/// Use [`LazyColumn`] for vertical lists.
1599///
1600/// # Example
1601///
1602/// ```rust
1603/// use cranpose_ui::*;
1604///
1605/// #[composable]
1606/// fn Choices() {
1607///     let state = rememberLazyListState();
1608///     LazyRow(Modifier::empty(), state, LazyRowSpec::default(), |scope| {
1609///         scope.items(LazyItems::new(10), |index| {
1610///             Text(
1611///                 format!("Choice {index}"),
1612///                 Modifier::empty().padding(8.0),
1613///                 TextStyle::default(),
1614///             );
1615///         });
1616///     });
1617/// }
1618/// ```
1619pub fn LazyRow<F>(modifier: Modifier, state: LazyListState, spec: LazyRowSpec, content: F) -> NodeId
1620where
1621    F: FnOnce(&mut LazyListIntervalContent),
1622{
1623    let mut interval_content = LazyListIntervalContent::new();
1624    content(&mut interval_content);
1625    LazyRowNode(
1626        modifier,
1627        state,
1628        spec,
1629        LazyListContentHandle::new(interval_content),
1630    )
1631}
1632
1633#[cfg(test)]
1634#[path = "tests/lazy_list_tests.rs"]
1635mod tests;