1use std::{cell::RefCell, cmp::Reverse, collections::BinaryHeap, rc::Rc};
13
14use cranpose_core::{MutableState, NodeId, StateId};
15use cranpose_macros::composable;
16
17use super::{
18 diagnostics,
19 nearest_range::NearestRangeState,
20 prefetch::{PrefetchScheduler, PrefetchStrategy},
21};
22
23const MAX_PENDING_SCROLL_DELTA: f32 = 2000.0;
24const ITEM_SIZE_CACHE_CAPACITY: usize = 8192;
25
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub(crate) struct LazyListMeasureStateSnapshot {
28 pub(crate) first_visible_item_index: usize,
29 pub(crate) first_visible_item_scroll_offset: f32,
30 pub(crate) pending_scroll_delta: f32,
31 pub(crate) pending_scroll_to: Option<(usize, f32)>,
32 pub(crate) average_item_size: f32,
33}
34
35#[derive(Clone, Debug, Default, PartialEq)]
39pub struct LazyLayoutStats {
40 pub items_in_use: usize,
42
43 pub items_in_pool: usize,
45
46 pub total_composed: usize,
48
49 pub reuse_count: usize,
51}
52
53#[derive(Clone, Copy)]
61pub struct LazyListScrollPosition {
62 index: MutableState<usize>,
63 scroll_offset: MutableState<f32>,
64 inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
65}
66
67struct ScrollPositionInner {
69 current_index: usize,
70 current_scroll_offset: f32,
71 last_known_first_item_key: Option<u64>,
72 nearest_range_state: NearestRangeState,
73}
74
75impl LazyListScrollPosition {
76 fn is_alive(&self) -> bool {
77 self.index.is_alive() && self.scroll_offset.is_alive() && self.inner.is_alive()
78 }
79
80 fn current_index(&self) -> usize {
81 self.inner
82 .try_with(|rc| rc.borrow().current_index)
83 .unwrap_or(0)
84 }
85
86 fn current_scroll_offset(&self) -> f32 {
87 self.inner
88 .try_with(|rc| rc.borrow().current_scroll_offset)
89 .unwrap_or(0.0)
90 }
91
92 pub fn index(&self) -> usize {
94 if !self.index.is_alive() || !self.inner.is_alive() {
95 return 0;
96 }
97 self.index.subscribe_current_scope_only();
98 self.current_index()
99 }
100
101 pub fn scroll_offset(&self) -> f32 {
103 if !self.scroll_offset.is_alive() || !self.inner.is_alive() {
104 return 0.0;
105 }
106 self.scroll_offset.subscribe_current_scope_only();
107 self.current_scroll_offset()
108 }
109
110 pub(crate) fn update_from_measure_result(
111 &self,
112 first_visible_index: usize,
113 first_visible_scroll_offset: f32,
114 first_visible_item_key: Option<u64>,
115 ) {
116 if !self.is_alive() {
117 return;
118 }
119 self.inner.with(|rc| {
120 let mut inner = rc.borrow_mut();
121 inner.current_index = first_visible_index;
122 inner.current_scroll_offset = first_visible_scroll_offset;
123 inner.last_known_first_item_key = first_visible_item_key;
124 inner.nearest_range_state.update(first_visible_index);
125 });
126
127 if self.index.get_non_reactive() != first_visible_index {
128 self.index.set(first_visible_index);
129 }
130 if (self.scroll_offset.get_non_reactive() - first_visible_scroll_offset).abs() > 0.001 {
131 self.scroll_offset.set(first_visible_scroll_offset);
132 }
133 }
134
135 pub(crate) fn request_position_and_forget_last_known_key(
136 &self,
137 index: usize,
138 scroll_offset: f32,
139 ) {
140 if !self.is_alive() {
141 return;
142 }
143 self.inner.with(|rc| {
144 let mut inner = rc.borrow_mut();
145 inner.current_index = index;
146 inner.current_scroll_offset = scroll_offset;
147 inner.last_known_first_item_key = None;
148 inner.nearest_range_state.update(index);
149 });
150
151 if self.index.get_non_reactive() != index {
152 self.index.set(index);
153 }
154 if (self.scroll_offset.get_non_reactive() - scroll_offset).abs() > 0.001 {
155 self.scroll_offset.set(scroll_offset);
156 }
157 }
158
159 pub(crate) fn update_if_first_item_moved<F>(
160 &self,
161 new_item_count: usize,
162 find_by_key: F,
163 ) -> usize
164 where
165 F: Fn(u64) -> Option<usize>,
166 {
167 if !self.index.is_alive() || !self.inner.is_alive() {
168 return 0;
169 }
170
171 let current_index = self.current_index();
172 let last_key = self
173 .inner
174 .try_with(|rc| rc.borrow().last_known_first_item_key)
175 .flatten();
176
177 let new_index = match last_key {
178 None => current_index.min(new_item_count.saturating_sub(1)),
179 Some(key) => find_by_key(key)
180 .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
181 };
182
183 if current_index != new_index {
184 self.inner.with(|rc| {
185 let mut inner = rc.borrow_mut();
186 inner.current_index = new_index;
187 inner.nearest_range_state.update(new_index);
188 });
189 self.index.set(new_index);
190 }
191 new_index
192 }
193
194 pub fn nearest_range(&self) -> std::ops::Range<usize> {
196 self.inner
197 .try_with(|rc| rc.borrow().nearest_range_state.range())
198 .unwrap_or(0..0)
199 }
200}
201
202#[derive(Clone, Copy)]
233pub struct LazyListState {
234 scroll_position: LazyListScrollPosition,
235 can_scroll_forward_state: MutableState<bool>,
236 can_scroll_backward_state: MutableState<bool>,
237 stats_state: MutableState<LazyLayoutStats>,
238 inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
239}
240
241impl PartialEq for LazyListState {
242 fn eq(&self, other: &Self) -> bool {
243 self.inner == other.inner
244 }
245}
246
247#[derive(Clone, Copy)]
248struct CachedItemSize {
249 size: f32,
250 last_used: u64,
251}
252
253struct LazyListStateInner {
255 scroll_to_be_consumed: f32,
256
257 pending_scroll_to_index: Option<(usize, f32)>,
258
259 layout_info: LazyListLayoutInfo,
260 current_can_scroll_forward: bool,
261 current_can_scroll_backward: bool,
262
263 invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
264 next_callback_id: u64,
265
266 layout_invalidation_callback_id: Option<u64>,
267 layout_invalidation_node_id: Option<NodeId>,
268
269 total_composed: usize,
270 reuse_count: usize,
271
272 item_size_cache: std::collections::HashMap<usize, CachedItemSize>,
273 item_size_eviction_queue: BinaryHeap<Reverse<(u64, usize)>>,
274 item_size_clock: u64,
275
276 average_item_size: f32,
277 total_measured_items: usize,
278 next_measure_cycle_id: u64,
279 next_item_measure_pass_id: u64,
280
281 prefetch_scheduler: PrefetchScheduler,
282
283 prefetch_strategy: PrefetchStrategy,
284
285 last_scroll_direction: f32,
286}
287
288#[composable]
303#[track_caller]
304pub fn rememberLazyListState() -> LazyListState {
305 rememberLazyListStateWithPosition(0, 0.0)
306}
307
308#[composable]
312pub fn rememberLazyListStateWithPosition(
313 initial_first_visible_item_index: usize,
314 initial_first_visible_item_scroll_offset: f32,
315) -> LazyListState {
316 let scroll_position = LazyListScrollPosition {
317 index: cranpose_core::rememberMutableStateOf(|| initial_first_visible_item_index),
318 scroll_offset: cranpose_core::rememberMutableStateOf(|| {
319 initial_first_visible_item_scroll_offset
320 }),
321 inner: cranpose_core::rememberMutableStateOfNeverEqual(|| {
322 Rc::new(RefCell::new(ScrollPositionInner {
323 current_index: initial_first_visible_item_index,
324 current_scroll_offset: initial_first_visible_item_scroll_offset,
325 last_known_first_item_key: None,
326 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
327 }))
328 }),
329 };
330
331 let inner = cranpose_core::rememberMutableStateOfNeverEqual(|| {
332 Rc::new(RefCell::new(LazyListStateInner {
333 scroll_to_be_consumed: 0.0,
334 pending_scroll_to_index: None,
335 layout_info: LazyListLayoutInfo::default(),
336 current_can_scroll_forward: false,
337 current_can_scroll_backward: false,
338 invalidate_callbacks: Vec::new(),
339 next_callback_id: 1,
340 layout_invalidation_callback_id: None,
341 layout_invalidation_node_id: None,
342 total_composed: 0,
343 reuse_count: 0,
344 item_size_cache: std::collections::HashMap::new(),
345 item_size_eviction_queue: BinaryHeap::new(),
346 item_size_clock: 0,
347 average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
348 total_measured_items: 0,
349 next_measure_cycle_id: 1,
350 next_item_measure_pass_id: 1,
351 prefetch_scheduler: PrefetchScheduler::new(),
352 prefetch_strategy: PrefetchStrategy::default(),
353 last_scroll_direction: 0.0,
354 }))
355 });
356
357 let can_scroll_forward_state = cranpose_core::rememberMutableStateOf(|| false);
358 let can_scroll_backward_state = cranpose_core::rememberMutableStateOf(|| false);
359 let stats_state = cranpose_core::rememberMutableStateOf(LazyLayoutStats::default);
360
361 LazyListState {
362 scroll_position,
363 can_scroll_forward_state,
364 can_scroll_backward_state,
365 stats_state,
366 inner,
367 }
368}
369
370impl LazyListState {
371 pub fn inner_ptr(&self) -> *const () {
376 self.inner
377 .try_with(|rc| Rc::as_ptr(rc) as *const ())
378 .unwrap_or(std::ptr::null())
379 }
380
381 pub fn first_visible_item_index(&self) -> usize {
386 self.scroll_position.index()
387 }
388
389 pub fn total_items_count(&self) -> usize {
391 self.inner
392 .with(|rc| rc.borrow().layout_info.total_items_count)
393 }
394
395 pub fn first_visible_item_index_non_reactive(&self) -> usize {
400 self.scroll_position.current_index()
401 }
402
403 pub fn first_visible_item_scroll_offset(&self) -> f32 {
409 self.scroll_position.scroll_offset()
410 }
411
412 pub fn first_visible_item_scroll_offset_non_reactive(&self) -> f32 {
417 self.scroll_position.current_scroll_offset()
418 }
419
420 #[doc(hidden)]
421 pub fn reactive_state_ids(&self) -> [StateId; 5] {
422 [
423 self.scroll_position.index.runtime_state_id(),
424 self.scroll_position.scroll_offset.runtime_state_id(),
425 self.can_scroll_forward_state.runtime_state_id(),
426 self.can_scroll_backward_state.runtime_state_id(),
427 self.stats_state.runtime_state_id(),
428 ]
429 }
430
431 pub fn layout_info(&self) -> LazyListLayoutInfo {
433 self.inner
434 .try_with(|rc| rc.borrow().layout_info.clone())
435 .unwrap_or_default()
436 }
437
438 pub fn stats(&self) -> LazyLayoutStats {
444 if !self.stats_state.is_alive() || !self.inner.is_alive() {
445 return LazyLayoutStats::default();
446 }
447 let reactive = self.stats_state.get();
448 let (total_composed, reuse_count) = self.inner.with(|rc| {
449 let inner = rc.borrow();
450 (inner.total_composed, inner.reuse_count)
451 });
452 LazyLayoutStats {
453 items_in_use: reactive.items_in_use,
454 items_in_pool: reactive.items_in_pool,
455 total_composed,
456 reuse_count,
457 }
458 }
459
460 pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
465 if !self.stats_state.is_alive() || !self.inner.is_alive() {
466 return;
467 }
468
469 let current = self.stats_state.get_non_reactive();
470
471 let should_update_reactive = if items_in_use > current.items_in_use {
472 true
473 } else if items_in_use < current.items_in_use {
474 current.items_in_use - items_in_use > 1
475 } else {
476 false
477 };
478
479 if should_update_reactive {
480 self.stats_state.set(LazyLayoutStats {
481 items_in_use,
482 items_in_pool,
483 ..current
484 });
485 }
486 }
487
488 pub fn record_composition(&self, was_reused: bool) {
493 if !self.inner.is_alive() {
494 return;
495 }
496 self.inner.with(|rc| {
497 let mut inner = rc.borrow_mut();
498 inner.total_composed += 1;
499 if was_reused {
500 inner.reuse_count += 1;
501 }
502 });
503 }
504
505 pub fn record_scroll_direction(&self, delta: f32) {
511 if delta.abs() > 0.001 {
512 if !self.inner.is_alive() {
513 return;
514 }
515 self.inner.with(|rc| {
516 rc.borrow_mut().last_scroll_direction = -delta.signum();
517 });
518 }
519 }
520
521 pub fn update_prefetch_queue(
524 &self,
525 first_visible_index: usize,
526 last_visible_index: usize,
527 total_items: usize,
528 ) {
529 if !self.inner.is_alive() {
530 return;
531 }
532 self.inner.with(|rc| {
533 let mut inner = rc.borrow_mut();
534 let direction = inner.last_scroll_direction;
535 let strategy = inner.prefetch_strategy.clone();
536 inner.prefetch_scheduler.update(
537 first_visible_index,
538 last_visible_index,
539 total_items,
540 direction,
541 &strategy,
542 );
543 });
544 }
545
546 pub fn take_prefetch_indices(&self) -> Vec<usize> {
549 self.inner
550 .try_with(|rc| {
551 let mut inner = rc.borrow_mut();
552 let mut indices = Vec::new();
553 while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
554 indices.push(idx);
555 }
556 indices
557 })
558 .unwrap_or_default()
559 }
560
561 pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
567 if !self.inner.is_alive() {
568 return;
569 }
570 if diagnostics::telemetry_enabled() {
571 log::warn!(
572 "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
573 index,
574 scroll_offset
575 );
576 }
577 self.inner.with(|rc| {
578 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
579 });
580
581 self.scroll_position
582 .request_position_and_forget_last_known_key(index, scroll_offset);
583
584 self.invalidate();
585 }
586
587 pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
598 if !self.inner.is_alive() {
599 return 0.0;
600 }
601 let has_scroll_bounds = self
602 .inner
603 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
604 let pushing_forward = delta < -0.001;
605 let pushing_backward = delta > 0.001;
606 let can_scroll_forward =
607 self.can_scroll_forward_state.is_alive() && self.can_scroll_forward_non_reactive();
608 let can_scroll_backward =
609 self.can_scroll_backward_state.is_alive() && self.can_scroll_backward_non_reactive();
610 let blocked_by_bounds = has_scroll_bounds
611 && ((pushing_forward && !can_scroll_forward)
612 || (pushing_backward && !can_scroll_backward));
613
614 if blocked_by_bounds {
615 let should_invalidate = self.inner.with(|rc| {
616 let mut inner = rc.borrow_mut();
617 let pending_before = inner.scroll_to_be_consumed;
618 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
619 inner.scroll_to_be_consumed = 0.0;
620 }
621 if diagnostics::telemetry_enabled() {
622 log::warn!(
623 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
624 delta,
625 pending_before,
626 inner.scroll_to_be_consumed
627 );
628 }
629 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
630 });
631 if should_invalidate {
632 self.invalidate();
633 }
634 return 0.0;
635 }
636
637 let mut accepted_delta = 0.0f32;
638 let should_invalidate = self.inner.with(|rc| {
639 let mut inner = rc.borrow_mut();
640 accepted_delta = delta;
641 let pending_before = inner.scroll_to_be_consumed;
642 let pending = inner.scroll_to_be_consumed;
643 let reverse_input = pending.abs() > 0.001
644 && delta.abs() > 0.001
645 && pending.signum() != delta.signum();
646 if reverse_input {
647 if diagnostics::telemetry_enabled() {
648 log::warn!(
649 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
650 pending,
651 delta
652 );
653 }
654 inner.scroll_to_be_consumed = delta;
655 } else {
656 inner.scroll_to_be_consumed += delta;
657 }
658 inner.scroll_to_be_consumed = inner
659 .scroll_to_be_consumed
660 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
661 if diagnostics::telemetry_enabled() {
662 log::warn!(
663 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
664 delta,
665 inner.scroll_to_be_consumed
666 );
667 }
668 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
669 });
670 if should_invalidate {
671 self.invalidate();
672 }
673 accepted_delta
674 }
675
676 pub fn peek_scroll_delta(&self) -> f32 {
683 self.inner
684 .try_with(|rc| rc.borrow().scroll_to_be_consumed)
685 .unwrap_or(0.0)
686 }
687
688 pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
689 let (pending_scroll_delta, pending_scroll_to, average_item_size) = self
690 .inner
691 .try_with(|rc| {
692 let mut inner = rc.borrow_mut();
693 let pending_scroll_to = inner.pending_scroll_to_index.take();
694 let pending_scroll_delta = inner.scroll_to_be_consumed;
695 inner.scroll_to_be_consumed = 0.0;
696 (
697 pending_scroll_delta,
698 pending_scroll_to,
699 inner.average_item_size,
700 )
701 })
702 .unwrap_or((0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
703
704 LazyListMeasureStateSnapshot {
705 first_visible_item_index: self.scroll_position.current_index(),
706 first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
707 pending_scroll_delta,
708 pending_scroll_to,
709 average_item_size,
710 }
711 }
712
713 pub(crate) fn next_measure_cycle_id(&self) -> u64 {
714 self.inner
715 .try_with(|rc| {
716 let mut inner = rc.borrow_mut();
717 let id = inner.next_measure_cycle_id;
718 inner.next_measure_cycle_id = inner.next_measure_cycle_id.saturating_add(1);
719 id
720 })
721 .unwrap_or(0)
722 }
723
724 pub(crate) fn next_item_measure_pass_id(&self) -> u64 {
725 self.inner
726 .try_with(|rc| {
727 let mut inner = rc.borrow_mut();
728 let id = inner.next_item_measure_pass_id;
729 inner.next_item_measure_pass_id = inner.next_item_measure_pass_id.saturating_add(1);
730 id
731 })
732 .unwrap_or(0)
733 }
734
735 fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
736 inner.total_measured_items += 1;
737 let n = inner.total_measured_items as f32;
738 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
739 }
740
741 fn next_item_size_cache_tick(inner: &mut LazyListStateInner) -> u64 {
742 inner.item_size_clock = inner.item_size_clock.saturating_add(1);
743 inner.item_size_clock
744 }
745
746 fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
747 use std::collections::hash_map::Entry;
748
749 let tick = Self::next_item_size_cache_tick(inner);
750 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
751 entry.insert(CachedItemSize {
752 size,
753 last_used: tick,
754 });
755 Self::push_item_size_cache_ticket(inner, tick, index);
756 return false;
757 }
758
759 if inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
760 Self::evict_one_item_size(inner);
761 }
762
763 inner.item_size_cache.insert(
764 index,
765 CachedItemSize {
766 size,
767 last_used: tick,
768 },
769 );
770 Self::push_item_size_cache_ticket(inner, tick, index);
771 true
772 }
773
774 fn push_item_size_cache_ticket(inner: &mut LazyListStateInner, last_used: u64, index: usize) {
775 inner
776 .item_size_eviction_queue
777 .push(Reverse((last_used, index)));
778 let compact_limit = inner
779 .item_size_cache
780 .len()
781 .saturating_mul(4)
782 .max(ITEM_SIZE_CACHE_CAPACITY);
783 if inner.item_size_eviction_queue.len() > compact_limit {
784 Self::rebuild_item_size_eviction_queue(inner);
785 }
786 }
787
788 fn rebuild_item_size_eviction_queue(inner: &mut LazyListStateInner) {
789 inner.item_size_eviction_queue = inner
790 .item_size_cache
791 .iter()
792 .map(|(index, item)| Reverse((item.last_used, *index)))
793 .collect();
794 }
795
796 fn evict_one_item_size(inner: &mut LazyListStateInner) {
797 while let Some(Reverse((last_used, index))) = inner.item_size_eviction_queue.pop() {
798 let Some(current) = inner.item_size_cache.get(&index) else {
799 continue;
800 };
801 if current.last_used != last_used {
802 continue;
803 }
804 inner.item_size_cache.remove(&index);
805 return;
806 }
807 }
808
809 pub fn cache_item_size(&self, index: usize, size: f32) {
811 if !self.inner.is_alive() {
812 return;
813 }
814 self.inner.with(|rc| {
815 let mut inner = rc.borrow_mut();
816 if Self::insert_item_size(&mut inner, index, size) {
817 Self::record_item_size_sample(&mut inner, size);
818 }
819 });
820 }
821
822 pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
824 where
825 I: IntoIterator<Item = (usize, f32)>,
826 {
827 if !self.inner.is_alive() {
828 return super::DEFAULT_ITEM_SIZE_ESTIMATE;
829 }
830
831 self.inner.with(|rc| {
832 let mut inner = rc.borrow_mut();
833 for (index, size) in sizes {
834 if Self::insert_item_size(&mut inner, index, size) {
835 Self::record_item_size_sample(&mut inner, size);
836 }
837 }
838 inner.average_item_size
839 })
840 }
841
842 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
844 self.inner
845 .try_with(|rc| {
846 let mut inner = rc.borrow_mut();
847 let tick = Self::next_item_size_cache_tick(&mut inner);
848 let item = inner.item_size_cache.get_mut(&index)?;
849 item.last_used = tick;
850 let size = item.size;
851 Self::push_item_size_cache_ticket(&mut inner, tick, index);
852 Some(size)
853 })
854 .flatten()
855 }
856
857 pub fn average_item_size(&self) -> f32 {
859 self.inner
860 .try_with(|rc| rc.borrow().average_item_size)
861 .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
862 }
863
864 pub fn nearest_range(&self) -> std::ops::Range<usize> {
866 self.scroll_position.nearest_range()
867 }
868
869 pub(crate) fn update_scroll_position(
870 &self,
871 first_visible_item_index: usize,
872 first_visible_item_scroll_offset: f32,
873 ) {
874 self.scroll_position.update_from_measure_result(
875 first_visible_item_index,
876 first_visible_item_scroll_offset,
877 None,
878 );
879 }
880
881 pub(crate) fn update_scroll_position_with_key(
882 &self,
883 first_visible_item_index: usize,
884 first_visible_item_scroll_offset: f32,
885 first_visible_item_key: u64,
886 ) {
887 self.scroll_position.update_from_measure_result(
888 first_visible_item_index,
889 first_visible_item_scroll_offset,
890 Some(first_visible_item_key),
891 );
892 }
893
894 pub fn update_scroll_position_if_item_moved<F>(
902 &self,
903 new_item_count: usize,
904 get_index_by_key: F,
905 ) -> usize
906 where
907 F: Fn(u64) -> Option<usize>,
908 {
909 self.scroll_position
910 .update_if_first_item_moved(new_item_count, get_index_by_key)
911 }
912
913 pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
914 if !self.inner.is_alive() {
915 return;
916 }
917 self.inner.with(|rc| {
918 let mut inner = rc.borrow_mut();
919 info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
920 inner.layout_info = info;
921 });
922 }
923
924 pub fn can_scroll_forward(&self) -> bool {
929 if !self.can_scroll_forward_state.is_alive() {
930 return false;
931 }
932 self.can_scroll_forward_state.subscribe_current_scope_only();
933 self.can_scroll_forward_non_reactive()
934 }
935
936 pub fn can_scroll_forward_non_reactive(&self) -> bool {
938 if !self.can_scroll_forward_state.is_alive() {
939 return false;
940 }
941 self.inner
942 .try_with(|rc| rc.borrow().current_can_scroll_forward)
943 .unwrap_or(false)
944 }
945
946 pub fn can_scroll_backward(&self) -> bool {
951 if !self.can_scroll_backward_state.is_alive() {
952 return false;
953 }
954 self.can_scroll_backward_state
955 .subscribe_current_scope_only();
956 self.can_scroll_backward_non_reactive()
957 }
958
959 pub fn can_scroll_backward_non_reactive(&self) -> bool {
961 if !self.can_scroll_backward_state.is_alive() {
962 return false;
963 }
964 self.inner
965 .try_with(|rc| rc.borrow().current_can_scroll_backward)
966 .unwrap_or(false)
967 }
968
969 pub(crate) fn update_scroll_bounds(&self) {
970 if !self.inner.is_alive()
971 || !self.can_scroll_forward_state.is_alive()
972 || !self.can_scroll_backward_state.is_alive()
973 {
974 return;
975 }
976 let can_forward = self.inner.with(|rc| {
977 let inner = rc.borrow();
978 let info = &inner.layout_info;
979 let viewport_end = info.viewport_size - info.after_content_padding;
980 if let Some(last_visible) = info.visible_items_info.last() {
981 last_visible.index < info.total_items_count.saturating_sub(1)
982 || (last_visible.offset + last_visible.size) > viewport_end
983 } else {
984 false
985 }
986 });
987
988 let can_backward = self.scroll_position.current_index() > 0
989 || self.scroll_position.current_scroll_offset() > 0.0;
990
991 self.inner.with(|rc| {
992 let mut inner = rc.borrow_mut();
993 inner.current_can_scroll_forward = can_forward;
994 inner.current_can_scroll_backward = can_backward;
995 });
996
997 if self.can_scroll_forward_state.get_non_reactive() != can_forward {
998 self.can_scroll_forward_state.set(can_forward);
999 }
1000 if self.can_scroll_backward_state.get_non_reactive() != can_backward {
1001 self.can_scroll_backward_state.set(can_backward);
1002 }
1003 }
1004
1005 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
1007 if !self.inner.is_alive() {
1008 return 0;
1009 }
1010 self.inner.with(|rc| {
1011 let mut inner = rc.borrow_mut();
1012 let id = inner.next_callback_id;
1013 inner.next_callback_id += 1;
1014 inner.invalidate_callbacks.push((id, callback));
1015 id
1016 })
1017 }
1018
1019 pub fn try_register_layout_callback(
1027 &self,
1028 node_id: NodeId,
1029 callback: Rc<dyn Fn()>,
1030 ) -> Option<u64> {
1031 if !self.inner.is_alive() {
1032 return None;
1033 }
1034 self.inner.with(|rc| {
1035 let mut inner = rc.borrow_mut();
1036 if let Some(existing_id) = inner.layout_invalidation_callback_id {
1037 inner
1038 .invalidate_callbacks
1039 .retain(|(cb_id, _)| *cb_id != existing_id);
1040 }
1041 let id = inner.next_callback_id;
1042 inner.next_callback_id += 1;
1043 inner.invalidate_callbacks.push((id, callback));
1044 inner.layout_invalidation_callback_id = Some(id);
1045 inner.layout_invalidation_node_id = Some(node_id);
1046 Some(id)
1047 })
1048 }
1049
1050 pub fn remove_invalidate_callback(&self, id: u64) {
1052 if !self.inner.is_alive() {
1053 return;
1054 }
1055 self.inner.with(|rc| {
1056 let mut inner = rc.borrow_mut();
1057 inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1058 if inner.layout_invalidation_callback_id == Some(id) {
1059 inner.layout_invalidation_callback_id = None;
1060 inner.layout_invalidation_node_id = None;
1061 }
1062 });
1063 }
1064
1065 fn invalidate(&self) {
1066 if !self.inner.is_alive() {
1067 return;
1068 }
1069 let callbacks: Vec<_> = self.inner.with(|rc| {
1070 rc.borrow()
1071 .invalidate_callbacks
1072 .iter()
1073 .map(|(_, cb)| Rc::clone(cb))
1074 .collect()
1075 });
1076
1077 for callback in callbacks {
1078 callback();
1079 }
1080 }
1081}
1082
1083#[derive(Clone, Default, Debug)]
1085pub struct LazyListLayoutInfo {
1086 pub visible_items_info: Vec<LazyListItemInfo>,
1088
1089 pub total_items_count: usize,
1091
1092 pub raw_viewport_size: f32,
1094
1095 pub is_infinite_viewport: bool,
1097
1098 pub viewport_size: f32,
1100
1101 pub viewport_start_offset: f32,
1103
1104 pub viewport_end_offset: f32,
1106
1107 pub before_content_padding: f32,
1109
1110 pub after_content_padding: f32,
1112
1113 pub snap_anchor_offset: f32,
1115
1116 pub reverse_layout: bool,
1118}
1119
1120#[derive(Clone, Debug)]
1122pub struct LazyListItemInfo {
1123 pub index: usize,
1125
1126 pub key: u64,
1128
1129 pub offset: f32,
1131
1132 pub size: f32,
1134}
1135
1136fn continuous_snap_anchor_offset(
1137 previous: &LazyListLayoutInfo,
1138 current: &LazyListLayoutInfo,
1139) -> f32 {
1140 let Some(first_current) = current.visible_items_info.first() else {
1141 return 0.0;
1142 };
1143
1144 for current_item in ¤t.visible_items_info {
1145 if let Some(previous_item) = previous
1146 .visible_items_info
1147 .iter()
1148 .find(|item| item.key == current_item.key)
1149 {
1150 let previous_offset = snap_anchor_item_offset(previous, previous_item);
1151 let current_offset = snap_anchor_item_offset(current, current_item);
1152 return previous.snap_anchor_offset + current_offset - previous_offset;
1153 }
1154 }
1155
1156 snap_anchor_item_offset(current, first_current)
1157}
1158
1159fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1160 if info.reverse_layout {
1161 info.viewport_size - item.offset - item.size
1162 } else {
1163 item.offset
1164 }
1165}
1166
1167#[cfg(test)]
1168pub mod test_helpers {
1169 use std::sync::Arc;
1170
1171 use cranpose_core::{DefaultScheduler, Runtime};
1172
1173 use super::*;
1174
1175 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1176 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1177 f()
1178 }
1179
1180 pub fn new_lazy_list_state() -> LazyListState {
1181 new_lazy_list_state_with_position(0, 0.0)
1182 }
1183
1184 pub fn new_lazy_list_state_with_position(
1185 initial_first_visible_item_index: usize,
1186 initial_first_visible_item_scroll_offset: f32,
1187 ) -> LazyListState {
1188 let scroll_position = LazyListScrollPosition {
1189 index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
1190 scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
1191 inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
1192 current_index: initial_first_visible_item_index,
1193 current_scroll_offset: initial_first_visible_item_scroll_offset,
1194 last_known_first_item_key: None,
1195 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
1196 }))),
1197 };
1198
1199 let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
1200 scroll_to_be_consumed: 0.0,
1201 pending_scroll_to_index: None,
1202 layout_info: LazyListLayoutInfo::default(),
1203 current_can_scroll_forward: false,
1204 current_can_scroll_backward: false,
1205 invalidate_callbacks: Vec::new(),
1206 next_callback_id: 1,
1207 layout_invalidation_callback_id: None,
1208 layout_invalidation_node_id: None,
1209 total_composed: 0,
1210 reuse_count: 0,
1211 item_size_cache: std::collections::HashMap::new(),
1212 item_size_eviction_queue: BinaryHeap::new(),
1213 item_size_clock: 0,
1214 average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
1215 total_measured_items: 0,
1216 next_measure_cycle_id: 1,
1217 next_item_measure_pass_id: 1,
1218 prefetch_scheduler: PrefetchScheduler::new(),
1219 prefetch_strategy: PrefetchStrategy::default(),
1220 last_scroll_direction: 0.0,
1221 })));
1222
1223 let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
1224 let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
1225 let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
1226
1227 LazyListState {
1228 scroll_position,
1229 can_scroll_forward_state,
1230 can_scroll_backward_state,
1231 stats_state,
1232 inner,
1233 }
1234 }
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use std::{cell::Cell, rc::Rc};
1240
1241 use cranpose_core::{Composition, MemoryApplier, location_key};
1242
1243 use super::{
1244 LazyListItemInfo, LazyListLayoutInfo, LazyListState,
1245 test_helpers::{new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime},
1246 };
1247
1248 fn set_scroll_bounds(state: &LazyListState, can_forward: bool, can_backward: bool) {
1249 state.can_scroll_forward_state.set(can_forward);
1250 state.can_scroll_backward_state.set(can_backward);
1251 state.inner.with(|rc| {
1252 let mut inner = rc.borrow_mut();
1253 inner.current_can_scroll_forward = can_forward;
1254 inner.current_can_scroll_backward = can_backward;
1255 });
1256 }
1257
1258 fn enable_bidirectional_scroll(state: &LazyListState) {
1259 set_scroll_bounds(state, true, true);
1260 }
1261
1262 fn mark_scroll_bounds_known(state: &LazyListState) {
1263 state.update_layout_info(LazyListLayoutInfo {
1264 total_items_count: 10,
1265 ..Default::default()
1266 });
1267 }
1268
1269 fn visible_item(index: usize, offset: f32, size: f32) -> LazyListItemInfo {
1270 LazyListItemInfo {
1271 index,
1272 key: index as u64,
1273 offset,
1274 size,
1275 }
1276 }
1277
1278 #[test]
1279 fn lazy_measure_telemetry_ids_are_state_owned() {
1280 with_test_runtime(|| {
1281 let first = new_lazy_list_state();
1282 let second = new_lazy_list_state();
1283
1284 assert_eq!(first.next_measure_cycle_id(), 1);
1285 assert_eq!(first.next_measure_cycle_id(), 2);
1286 assert_eq!(second.next_measure_cycle_id(), 1);
1287
1288 assert_eq!(first.next_item_measure_pass_id(), 1);
1289 assert_eq!(first.next_item_measure_pass_id(), 2);
1290 assert_eq!(second.next_item_measure_pass_id(), 1);
1291 });
1292 }
1293
1294 #[test]
1295 fn measure_result_updates_retained_and_reactive_scroll_position() {
1296 with_test_runtime(|| {
1297 let state = new_lazy_list_state();
1298
1299 state.update_scroll_position_with_key(8, 17.5, 123);
1300
1301 assert_eq!(state.scroll_position.index.get_non_reactive(), 8);
1302 assert!((state.scroll_position.scroll_offset.get_non_reactive() - 17.5).abs() < 0.001);
1303 assert_eq!(state.first_visible_item_index_non_reactive(), 8);
1304 assert!((state.first_visible_item_scroll_offset_non_reactive() - 17.5).abs() < 0.001);
1305 });
1306 }
1307
1308 #[test]
1309 fn update_scroll_bounds_updates_retained_and_reactive_capabilities() {
1310 with_test_runtime(|| {
1311 let state = new_lazy_list_state();
1312
1313 state.update_layout_info(LazyListLayoutInfo {
1314 visible_items_info: vec![visible_item(0, 0.0, 40.0), visible_item(1, 40.0, 40.0)],
1315 total_items_count: 10,
1316 viewport_size: 80.0,
1317 ..Default::default()
1318 });
1319 state.update_scroll_bounds();
1320
1321 assert!(state.can_scroll_forward_state.get_non_reactive());
1322 assert!(!state.can_scroll_backward_state.get_non_reactive());
1323 assert!(state.can_scroll_forward_non_reactive());
1324 assert!(!state.can_scroll_backward_non_reactive());
1325
1326 state.update_scroll_position(3, 2.0);
1327 state.update_scroll_bounds();
1328
1329 assert!(state.can_scroll_backward_state.get_non_reactive());
1330 assert!(state.can_scroll_backward_non_reactive());
1331 });
1332 }
1333
1334 #[test]
1335 fn layout_info_snap_anchor_tracks_common_item_offset_delta() {
1336 let previous = LazyListLayoutInfo {
1337 visible_items_info: vec![visible_item(15, -31.4, 30.0), visible_item(16, 4.6, 30.0)],
1338 snap_anchor_offset: -31.4,
1339 ..Default::default()
1340 };
1341 let current = LazyListLayoutInfo {
1342 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1343 ..Default::default()
1344 };
1345
1346 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1347
1348 assert!((anchor + 32.4).abs() <= 0.001);
1349 }
1350
1351 #[test]
1352 fn layout_info_snap_anchor_uses_reverse_visual_item_offset() {
1353 let previous = LazyListLayoutInfo {
1354 visible_items_info: vec![visible_item(15, 31.4, 30.0), visible_item(16, 67.4, 30.0)],
1355 snap_anchor_offset: 58.6,
1356 viewport_size: 120.0,
1357 reverse_layout: true,
1358 ..Default::default()
1359 };
1360 let current = LazyListLayoutInfo {
1361 visible_items_info: vec![visible_item(16, 68.4, 30.0), visible_item(17, 104.4, 30.0)],
1362 viewport_size: 120.0,
1363 reverse_layout: true,
1364 ..Default::default()
1365 };
1366
1367 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1368
1369 assert!((anchor - 57.6).abs() <= 0.001);
1370 }
1371
1372 #[test]
1373 fn update_layout_info_keeps_snap_anchor_continuous_when_first_visible_item_changes() {
1374 with_test_runtime(|| {
1375 let state = new_lazy_list_state();
1376 state.update_layout_info(LazyListLayoutInfo {
1377 visible_items_info: vec![
1378 visible_item(15, -31.4, 30.0),
1379 visible_item(16, 4.6, 30.0),
1380 ],
1381 ..Default::default()
1382 });
1383
1384 state.update_layout_info(LazyListLayoutInfo {
1385 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1386 ..Default::default()
1387 });
1388
1389 let info = state.layout_info();
1390 assert!((info.snap_anchor_offset + 32.4).abs() <= 0.001);
1391 });
1392 }
1393
1394 #[test]
1395 fn dispatch_scroll_delta_accumulates_same_direction() {
1396 with_test_runtime(|| {
1397 let state = new_lazy_list_state();
1398 enable_bidirectional_scroll(&state);
1399
1400 state.dispatch_scroll_delta(-12.0);
1401 state.dispatch_scroll_delta(-8.0);
1402
1403 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1404 let snapshot = state.begin_measure_pass();
1405 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1406 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1407 });
1408 }
1409
1410 #[test]
1411 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1412 with_test_runtime(|| {
1413 let state = new_lazy_list_state();
1414 enable_bidirectional_scroll(&state);
1415
1416 state.dispatch_scroll_delta(-120.0);
1417 state.dispatch_scroll_delta(-30.0);
1418 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1419
1420 state.dispatch_scroll_delta(18.0);
1421
1422 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1423 let snapshot = state.begin_measure_pass();
1424 assert!((snapshot.pending_scroll_delta - 18.0).abs() < 0.001);
1425 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1426 });
1427 }
1428
1429 #[test]
1430 fn dispatch_scroll_delta_clamps_pending_backlog() {
1431 with_test_runtime(|| {
1432 let state = new_lazy_list_state();
1433 enable_bidirectional_scroll(&state);
1434
1435 state.dispatch_scroll_delta(-1_500.0);
1436 state.dispatch_scroll_delta(-1_500.0);
1437 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1438
1439 state.dispatch_scroll_delta(3_000.0);
1440 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1441 });
1442 }
1443
1444 #[test]
1445 fn begin_measure_pass_consumes_large_pending_scroll_delta_coherently() {
1446 with_test_runtime(|| {
1447 let state = new_lazy_list_state();
1448 enable_bidirectional_scroll(&state);
1449 let invalidations = Rc::new(Cell::new(0u32));
1450 let invalidations_clone = Rc::clone(&invalidations);
1451 state.add_invalidate_callback(Rc::new(move || {
1452 invalidations_clone.set(invalidations_clone.get() + 1);
1453 }));
1454
1455 state.dispatch_scroll_delta(-1_000.0);
1456 assert!((state.peek_scroll_delta() + 1_000.0).abs() < 0.001);
1457
1458 let first = state.begin_measure_pass();
1459 assert!(
1460 (first.pending_scroll_delta + 1_000.0).abs() < 0.001,
1461 "first pass should consume the whole coherent scroll input"
1462 );
1463 assert!(
1464 state.peek_scroll_delta().abs() < 0.001,
1465 "measure pass should not retain a synthetic scroll backlog"
1466 );
1467 assert_eq!(
1468 invalidations.get(),
1469 1,
1470 "dispatch should request layout once; consuming scroll should not schedule follow-up frames"
1471 );
1472
1473 let second = state.begin_measure_pass();
1474 assert!(
1475 second.pending_scroll_delta.abs() < 0.001,
1476 "second pass should not receive synthetic remainder"
1477 );
1478 });
1479 }
1480
1481 #[test]
1482 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1483 with_test_runtime(|| {
1484 let state = new_lazy_list_state();
1485 enable_bidirectional_scroll(&state);
1486 let invalidations = Rc::new(Cell::new(0u32));
1487 let invalidations_clone = Rc::clone(&invalidations);
1488 state.add_invalidate_callback(Rc::new(move || {
1489 invalidations_clone.set(invalidations_clone.get() + 1);
1490 }));
1491
1492 state.dispatch_scroll_delta(-3_000.0);
1493 assert_eq!(invalidations.get(), 1);
1494 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1495
1496 state.dispatch_scroll_delta(-100.0);
1497 assert_eq!(invalidations.get(), 1);
1498
1499 state.dispatch_scroll_delta(100.0);
1500 assert_eq!(invalidations.get(), 2);
1501 });
1502 }
1503
1504 #[test]
1505 fn begin_measure_pass_takes_coherent_snapshot_and_consumes_pending_inputs() {
1506 with_test_runtime(|| {
1507 let state = new_lazy_list_state_with_position(3, 12.0);
1508 state.dispatch_scroll_delta(-20.0);
1509 state.inner.with(|rc| {
1510 rc.borrow_mut().pending_scroll_to_index = Some((8, 4.0));
1511 });
1512
1513 let snapshot = state.begin_measure_pass();
1514
1515 assert_eq!(snapshot.first_visible_item_index, 3);
1516 assert!((snapshot.first_visible_item_scroll_offset - 12.0).abs() < 0.001);
1517 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1518 assert_eq!(snapshot.pending_scroll_to, Some((8, 4.0)));
1519 assert_eq!(state.peek_scroll_delta(), 0.0);
1520 assert_eq!(state.begin_measure_pass().pending_scroll_to, None);
1521 });
1522 }
1523
1524 #[test]
1525 fn item_size_cache_refresh_keeps_recent_entry_and_evicts_oldest_live_entry() {
1526 with_test_runtime(|| {
1527 let state = new_lazy_list_state();
1528 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1529 state.cache_item_size(index, index as f32 + 10.0);
1530 }
1531
1532 state.cache_item_size(0, 999.0);
1533 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1534
1535 assert_eq!(state.get_cached_size(0), Some(999.0));
1536 assert_eq!(state.get_cached_size(1), None);
1537 assert_eq!(
1538 state.get_cached_size(super::ITEM_SIZE_CACHE_CAPACITY),
1539 Some(123.0),
1540 );
1541 });
1542 }
1543
1544 #[test]
1545 fn item_size_cache_read_promotes_entry_for_large_scroll_reuse() {
1546 with_test_runtime(|| {
1547 let state = new_lazy_list_state();
1548 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1549 state.cache_item_size(index, index as f32 + 10.0);
1550 }
1551
1552 assert_eq!(state.get_cached_size(0), Some(10.0));
1553 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1554
1555 assert_eq!(state.get_cached_size(0), Some(10.0));
1556 assert_eq!(state.get_cached_size(1), None);
1557 let cache_len = state
1558 .inner
1559 .try_with(|rc| rc.borrow().item_size_cache.len())
1560 .unwrap_or(0);
1561 assert_eq!(cache_len, super::ITEM_SIZE_CACHE_CAPACITY);
1562 });
1563 }
1564
1565 #[test]
1566 fn item_size_cache_promotion_queue_stays_bounded_under_hot_reuse() {
1567 with_test_runtime(|| {
1568 let state = new_lazy_list_state();
1569 state.cache_item_size(0, 32.0);
1570
1571 for _ in 0..super::ITEM_SIZE_CACHE_CAPACITY * 8 {
1572 assert_eq!(state.get_cached_size(0), Some(32.0));
1573 }
1574
1575 let (cache_len, queue_len) = state
1576 .inner
1577 .try_with(|rc| {
1578 let inner = rc.borrow();
1579 (
1580 inner.item_size_cache.len(),
1581 inner.item_size_eviction_queue.len(),
1582 )
1583 })
1584 .unwrap_or((0, 0));
1585 assert_eq!(cache_len, 1);
1586 assert!(
1587 queue_len <= super::ITEM_SIZE_CACHE_CAPACITY,
1588 "stale promotion tickets must be compacted, got {queue_len}"
1589 );
1590 });
1591 }
1592
1593 #[test]
1594 fn cache_item_sizes_updates_average_only_for_new_entries() {
1595 with_test_runtime(|| {
1596 let state = new_lazy_list_state();
1597
1598 let average = state.cache_item_sizes([(0, 10.0), (1, 20.0), (0, 12.0)]);
1599
1600 assert_eq!(state.get_cached_size(0), Some(12.0));
1601 assert_eq!(state.get_cached_size(1), Some(20.0));
1602 assert!((average - 15.0).abs() < 0.001);
1603 });
1604 }
1605
1606 #[test]
1607 fn layout_callback_can_be_registered_again_after_removal() {
1608 with_test_runtime(|| {
1609 let state = new_lazy_list_state();
1610 let first_node: cranpose_core::NodeId = 1;
1611 let second_node: cranpose_core::NodeId = 2;
1612
1613 let first_id = state
1614 .try_register_layout_callback(first_node, Rc::new(|| {}))
1615 .expect("first layout callback should register");
1616 let duplicate_id = state
1617 .try_register_layout_callback(first_node, Rc::new(|| {}))
1618 .expect("duplicate register should replace with a fresh callback id");
1619 assert_eq!(
1620 state
1621 .inner
1622 .with(|rc| rc.borrow().layout_invalidation_callback_id),
1623 Some(duplicate_id),
1624 "duplicate registration should become the active callback",
1625 );
1626 assert_ne!(
1627 first_id, duplicate_id,
1628 "duplicate registration should replace the old callback id",
1629 );
1630
1631 state.remove_invalidate_callback(first_id);
1632
1633 let second_id = state
1634 .try_register_layout_callback(second_node, Rc::new(|| {}))
1635 .expect("layout callback should register again after removal");
1636 assert_ne!(first_id, second_id);
1637 });
1638 }
1639
1640 #[test]
1641 fn layout_callback_rebinds_when_node_id_changes() {
1642 with_test_runtime(|| {
1643 let state = new_lazy_list_state();
1644 let first_node: cranpose_core::NodeId = 11;
1645 let second_node: cranpose_core::NodeId = 22;
1646
1647 let first_id = state
1648 .try_register_layout_callback(first_node, Rc::new(|| {}))
1649 .expect("first layout callback should register");
1650
1651 let second_id = state
1652 .try_register_layout_callback(second_node, Rc::new(|| {}))
1653 .expect("layout callback should rebind to a new node");
1654
1655 assert_ne!(first_id, second_id);
1656 });
1657 }
1658
1659 #[test]
1660 fn stale_layout_callback_disposer_cannot_remove_replaced_same_node_callback() {
1661 with_test_runtime(|| {
1662 let state = new_lazy_list_state();
1663 let node_id: cranpose_core::NodeId = 7;
1664 let first_hits = Rc::new(Cell::new(0u32));
1665 let second_hits = Rc::new(Cell::new(0u32));
1666
1667 let first_id = state
1668 .try_register_layout_callback(
1669 node_id,
1670 Rc::new({
1671 let first_hits = Rc::clone(&first_hits);
1672 move || first_hits.set(first_hits.get() + 1)
1673 }),
1674 )
1675 .expect("first layout callback should register");
1676
1677 let second_id = state
1678 .try_register_layout_callback(
1679 node_id,
1680 Rc::new({
1681 let second_hits = Rc::clone(&second_hits);
1682 move || second_hits.set(second_hits.get() + 1)
1683 }),
1684 )
1685 .expect("same-node registration should replace the active callback");
1686
1687 assert_ne!(first_id, second_id);
1688
1689 state.remove_invalidate_callback(first_id);
1690 state.dispatch_scroll_delta(-12.0);
1691
1692 assert_eq!(
1693 first_hits.get(),
1694 0,
1695 "replaced callback should not be invoked after removal",
1696 );
1697 assert_eq!(
1698 second_hits.get(),
1699 1,
1700 "active callback should survive stale disposer cleanup",
1701 );
1702 });
1703 }
1704
1705 #[test]
1706 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1707 with_test_runtime(|| {
1708 let state = new_lazy_list_state();
1709 mark_scroll_bounds_known(&state);
1710 set_scroll_bounds(&state, false, true);
1711
1712 let consumed = state.dispatch_scroll_delta(-24.0);
1713
1714 assert_eq!(consumed, 0.0);
1715 assert_eq!(state.peek_scroll_delta(), 0.0);
1716 });
1717 }
1718
1719 #[test]
1720 fn equality_does_not_deref_released_inner_state() {
1721 let mut composition = Composition::new(MemoryApplier::new());
1722 let key = location_key(file!(), line!(), column!());
1723
1724 let mut first = None;
1725 composition
1726 .render(key, || {
1727 first = Some(super::rememberLazyListState());
1728 })
1729 .expect("initial render");
1730 let first = first.expect("first lazy state");
1731
1732 composition
1733 .render(key, || {})
1734 .expect("dispose first lazy state");
1735 assert!(
1736 !first.inner.is_alive(),
1737 "expected first lazy state to be released after disposal"
1738 );
1739
1740 let mut second = None;
1741 composition
1742 .render(key, || {
1743 second = Some(super::rememberLazyListState());
1744 })
1745 .expect("second render");
1746 let second = second.expect("second lazy state");
1747
1748 assert!(
1749 first != second,
1750 "released lazy state handle must compare by identity without panicking"
1751 );
1752 }
1753
1754 #[test]
1755 fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1756 let mut composition = Composition::new(MemoryApplier::new());
1757 let key = location_key(file!(), line!(), column!());
1758
1759 let mut released = None;
1760 composition
1761 .render(key, || {
1762 released = Some(super::rememberLazyListState());
1763 })
1764 .expect("initial render");
1765 let released = released.expect("lazy list state");
1766
1767 composition
1768 .render(key, || {})
1769 .expect("dispose lazy list state");
1770 assert!(
1771 !released.inner.is_alive(),
1772 "expected lazy list state to be released after disposal"
1773 );
1774
1775 assert_eq!(released.first_visible_item_index(), 0);
1776 assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1777 assert_eq!(released.nearest_range(), 0..0);
1778 assert_eq!(
1779 released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1780 0
1781 );
1782 released.update_scroll_position(3, 12.0);
1783 released.update_scroll_position_with_key(3, 12.0, 42);
1784 released.update_scroll_bounds();
1785 }
1786
1787 #[test]
1788 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1789 with_test_runtime(|| {
1790 let state = new_lazy_list_state();
1791 mark_scroll_bounds_known(&state);
1792 enable_bidirectional_scroll(&state);
1793 state.dispatch_scroll_delta(-300.0);
1794 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1795
1796 set_scroll_bounds(&state, false, true);
1797
1798 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1799 assert_eq!(blocked_consumed, 0.0);
1800 assert_eq!(state.peek_scroll_delta(), 0.0);
1801
1802 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1803 assert_eq!(reverse_consumed, 12.0);
1804 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1805 });
1806 }
1807
1808 #[test]
1809 fn negative_scroll_delta_prefetches_forward_items() {
1810 with_test_runtime(|| {
1811 let state = new_lazy_list_state();
1812 state.dispatch_scroll_delta(-24.0);
1813 state.record_scroll_direction(state.peek_scroll_delta());
1814 state.update_prefetch_queue(10, 15, 100);
1815
1816 assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1817 });
1818 }
1819}