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