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