1#![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#[derive(Clone, Debug, PartialEq)]
52pub struct LazyColumnSpec {
53 pub vertical_arrangement: LinearArrangement,
55 pub content_padding_top: f32,
57 pub content_padding_bottom: f32,
59 pub beyond_bounds_item_count: usize,
62 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 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#[derive(Clone, Debug, PartialEq)]
109pub struct LazyRowSpec {
110 pub horizontal_arrangement: LinearArrangement,
112 pub content_padding_start: f32,
114 pub content_padding_end: f32,
116 pub beyond_bounds_item_count: usize,
118 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 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
403struct 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
945fn 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
1057fn 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
1176fn 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
1360pub 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
1441pub 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}