1#![allow(non_snake_case)]
7#![allow(dead_code)] use 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
30pub 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#[derive(Clone, Debug, PartialEq)]
48pub struct LazyColumnSpec {
49 pub vertical_arrangement: LinearArrangement,
51 pub content_padding_top: f32,
53 pub content_padding_bottom: f32,
55 pub beyond_bounds_item_count: usize,
58 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 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#[derive(Clone, Debug, PartialEq)]
105pub struct LazyRowSpec {
106 pub horizontal_arrangement: LinearArrangement,
108 pub content_padding_start: f32,
110 pub content_padding_end: f32,
112 pub beyond_bounds_item_count: usize,
114 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 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
396fn 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 if items_count > 0 {
426 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 }
436
437 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 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 state.cache_item_sizes(
525 result
526 .visible_items
527 .iter()
528 .map(|item| (item.index, item.main_axis_size)),
529 );
530 let truly_visible_count = result
532 .visible_items
533 .iter()
534 .filter(|item| {
535 let item_end = item.offset + item.main_axis_size;
537 item.offset < effective_viewport_size && item_end > 0.0
538 })
539 .count();
540 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 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
844fn 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 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 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 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 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 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
967fn LazyColumnImpl(
971 modifier: Modifier,
972 state: LazyListState,
973 spec: LazyColumnSpec,
974 content: LazyListContentHandle,
975) -> NodeId {
976 use std::cell::RefCell;
977
978 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 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 let scroll_modifier = modifier
1043 .clip_to_bounds()
1044 .lazy_vertical_scroll(state, spec.reverse_layout);
1045
1046 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
1074fn LazyRowImpl(
1078 modifier: Modifier,
1079 state: LazyListState,
1080 spec: LazyRowSpec,
1081 content: LazyListContentHandle,
1082) -> NodeId {
1083 use std::cell::RefCell;
1084
1085 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 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 let scroll_modifier = modifier
1148 .clip_to_bounds()
1149 .lazy_horizontal_scroll(state, spec.reverse_layout);
1150
1151 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
1201pub 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
1282pub 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}