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 first_visible_item_index_non_reactive(&self) -> usize {
394 self.scroll_position.current_index()
395 }
396
397 pub fn first_visible_item_scroll_offset(&self) -> f32 {
403 self.scroll_position.scroll_offset()
404 }
405
406 pub fn first_visible_item_scroll_offset_non_reactive(&self) -> f32 {
411 self.scroll_position.current_scroll_offset()
412 }
413
414 #[doc(hidden)]
415 pub fn reactive_state_ids(&self) -> [StateId; 5] {
416 [
417 self.scroll_position.index.runtime_state_id(),
418 self.scroll_position.scroll_offset.runtime_state_id(),
419 self.can_scroll_forward_state.runtime_state_id(),
420 self.can_scroll_backward_state.runtime_state_id(),
421 self.stats_state.runtime_state_id(),
422 ]
423 }
424
425 pub fn layout_info(&self) -> LazyListLayoutInfo {
427 self.inner
428 .try_with(|rc| rc.borrow().layout_info.clone())
429 .unwrap_or_default()
430 }
431
432 pub fn stats(&self) -> LazyLayoutStats {
438 if !self.stats_state.is_alive() || !self.inner.is_alive() {
439 return LazyLayoutStats::default();
440 }
441 let reactive = self.stats_state.get();
442 let (total_composed, reuse_count) = self.inner.with(|rc| {
443 let inner = rc.borrow();
444 (inner.total_composed, inner.reuse_count)
445 });
446 LazyLayoutStats {
447 items_in_use: reactive.items_in_use,
448 items_in_pool: reactive.items_in_pool,
449 total_composed,
450 reuse_count,
451 }
452 }
453
454 pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
459 if !self.stats_state.is_alive() || !self.inner.is_alive() {
460 return;
461 }
462
463 let current = self.stats_state.get_non_reactive();
464
465 let should_update_reactive = if items_in_use > current.items_in_use {
466 true
467 } else if items_in_use < current.items_in_use {
468 current.items_in_use - items_in_use > 1
469 } else {
470 false
471 };
472
473 if should_update_reactive {
474 self.stats_state.set(LazyLayoutStats {
475 items_in_use,
476 items_in_pool,
477 ..current
478 });
479 }
480 }
481
482 pub fn record_composition(&self, was_reused: bool) {
487 if !self.inner.is_alive() {
488 return;
489 }
490 self.inner.with(|rc| {
491 let mut inner = rc.borrow_mut();
492 inner.total_composed += 1;
493 if was_reused {
494 inner.reuse_count += 1;
495 }
496 });
497 }
498
499 pub fn record_scroll_direction(&self, delta: f32) {
505 if delta.abs() > 0.001 {
506 if !self.inner.is_alive() {
507 return;
508 }
509 self.inner.with(|rc| {
510 rc.borrow_mut().last_scroll_direction = -delta.signum();
511 });
512 }
513 }
514
515 pub fn update_prefetch_queue(
518 &self,
519 first_visible_index: usize,
520 last_visible_index: usize,
521 total_items: usize,
522 ) {
523 if !self.inner.is_alive() {
524 return;
525 }
526 self.inner.with(|rc| {
527 let mut inner = rc.borrow_mut();
528 let direction = inner.last_scroll_direction;
529 let strategy = inner.prefetch_strategy.clone();
530 inner.prefetch_scheduler.update(
531 first_visible_index,
532 last_visible_index,
533 total_items,
534 direction,
535 &strategy,
536 );
537 });
538 }
539
540 pub fn take_prefetch_indices(&self) -> Vec<usize> {
543 self.inner
544 .try_with(|rc| {
545 let mut inner = rc.borrow_mut();
546 let mut indices = Vec::new();
547 while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
548 indices.push(idx);
549 }
550 indices
551 })
552 .unwrap_or_default()
553 }
554
555 pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
561 if !self.inner.is_alive() {
562 return;
563 }
564 if diagnostics::telemetry_enabled() {
565 log::warn!(
566 "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
567 index,
568 scroll_offset
569 );
570 }
571 self.inner.with(|rc| {
572 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
573 });
574
575 self.scroll_position
576 .request_position_and_forget_last_known_key(index, scroll_offset);
577
578 self.invalidate();
579 }
580
581 pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
592 if !self.inner.is_alive() {
593 return 0.0;
594 }
595 let has_scroll_bounds = self
596 .inner
597 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
598 let pushing_forward = delta < -0.001;
599 let pushing_backward = delta > 0.001;
600 let can_scroll_forward =
601 self.can_scroll_forward_state.is_alive() && self.can_scroll_forward_non_reactive();
602 let can_scroll_backward =
603 self.can_scroll_backward_state.is_alive() && self.can_scroll_backward_non_reactive();
604 let blocked_by_bounds = has_scroll_bounds
605 && ((pushing_forward && !can_scroll_forward)
606 || (pushing_backward && !can_scroll_backward));
607
608 if blocked_by_bounds {
609 let should_invalidate = self.inner.with(|rc| {
610 let mut inner = rc.borrow_mut();
611 let pending_before = inner.scroll_to_be_consumed;
612 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
613 inner.scroll_to_be_consumed = 0.0;
614 }
615 if diagnostics::telemetry_enabled() {
616 log::warn!(
617 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
618 delta,
619 pending_before,
620 inner.scroll_to_be_consumed
621 );
622 }
623 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
624 });
625 if should_invalidate {
626 self.invalidate();
627 }
628 return 0.0;
629 }
630
631 let mut accepted_delta = 0.0f32;
632 let should_invalidate = self.inner.with(|rc| {
633 let mut inner = rc.borrow_mut();
634 accepted_delta = delta;
635 let pending_before = inner.scroll_to_be_consumed;
636 let pending = inner.scroll_to_be_consumed;
637 let reverse_input = pending.abs() > 0.001
638 && delta.abs() > 0.001
639 && pending.signum() != delta.signum();
640 if reverse_input {
641 if diagnostics::telemetry_enabled() {
642 log::warn!(
643 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
644 pending,
645 delta
646 );
647 }
648 inner.scroll_to_be_consumed = delta;
649 } else {
650 inner.scroll_to_be_consumed += delta;
651 }
652 inner.scroll_to_be_consumed = inner
653 .scroll_to_be_consumed
654 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
655 if diagnostics::telemetry_enabled() {
656 log::warn!(
657 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
658 delta,
659 inner.scroll_to_be_consumed
660 );
661 }
662 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
663 });
664 if should_invalidate {
665 self.invalidate();
666 }
667 accepted_delta
668 }
669
670 pub fn peek_scroll_delta(&self) -> f32 {
677 self.inner
678 .try_with(|rc| rc.borrow().scroll_to_be_consumed)
679 .unwrap_or(0.0)
680 }
681
682 pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
683 let (pending_scroll_delta, pending_scroll_to, average_item_size) = self
684 .inner
685 .try_with(|rc| {
686 let mut inner = rc.borrow_mut();
687 let pending_scroll_to = inner.pending_scroll_to_index.take();
688 let pending_scroll_delta = inner.scroll_to_be_consumed;
689 inner.scroll_to_be_consumed = 0.0;
690 (
691 pending_scroll_delta,
692 pending_scroll_to,
693 inner.average_item_size,
694 )
695 })
696 .unwrap_or((0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
697
698 LazyListMeasureStateSnapshot {
699 first_visible_item_index: self.scroll_position.current_index(),
700 first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
701 pending_scroll_delta,
702 pending_scroll_to,
703 average_item_size,
704 }
705 }
706
707 pub(crate) fn next_measure_cycle_id(&self) -> u64 {
708 self.inner
709 .try_with(|rc| {
710 let mut inner = rc.borrow_mut();
711 let id = inner.next_measure_cycle_id;
712 inner.next_measure_cycle_id = inner.next_measure_cycle_id.saturating_add(1);
713 id
714 })
715 .unwrap_or(0)
716 }
717
718 pub(crate) fn next_item_measure_pass_id(&self) -> u64 {
719 self.inner
720 .try_with(|rc| {
721 let mut inner = rc.borrow_mut();
722 let id = inner.next_item_measure_pass_id;
723 inner.next_item_measure_pass_id = inner.next_item_measure_pass_id.saturating_add(1);
724 id
725 })
726 .unwrap_or(0)
727 }
728
729 fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
730 inner.total_measured_items += 1;
731 let n = inner.total_measured_items as f32;
732 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
733 }
734
735 fn next_item_size_cache_tick(inner: &mut LazyListStateInner) -> u64 {
736 inner.item_size_clock = inner.item_size_clock.saturating_add(1);
737 inner.item_size_clock
738 }
739
740 fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
741 use std::collections::hash_map::Entry;
742
743 let tick = Self::next_item_size_cache_tick(inner);
744 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
745 entry.insert(CachedItemSize {
746 size,
747 last_used: tick,
748 });
749 Self::push_item_size_cache_ticket(inner, tick, index);
750 return false;
751 }
752
753 if inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
754 Self::evict_one_item_size(inner);
755 }
756
757 inner.item_size_cache.insert(
758 index,
759 CachedItemSize {
760 size,
761 last_used: tick,
762 },
763 );
764 Self::push_item_size_cache_ticket(inner, tick, index);
765 true
766 }
767
768 fn push_item_size_cache_ticket(inner: &mut LazyListStateInner, last_used: u64, index: usize) {
769 inner
770 .item_size_eviction_queue
771 .push(Reverse((last_used, index)));
772 let compact_limit = inner
773 .item_size_cache
774 .len()
775 .saturating_mul(4)
776 .max(ITEM_SIZE_CACHE_CAPACITY);
777 if inner.item_size_eviction_queue.len() > compact_limit {
778 Self::rebuild_item_size_eviction_queue(inner);
779 }
780 }
781
782 fn rebuild_item_size_eviction_queue(inner: &mut LazyListStateInner) {
783 inner.item_size_eviction_queue = inner
784 .item_size_cache
785 .iter()
786 .map(|(index, item)| Reverse((item.last_used, *index)))
787 .collect();
788 }
789
790 fn evict_one_item_size(inner: &mut LazyListStateInner) {
791 while let Some(Reverse((last_used, index))) = inner.item_size_eviction_queue.pop() {
792 let Some(current) = inner.item_size_cache.get(&index) else {
793 continue;
794 };
795 if current.last_used != last_used {
796 continue;
797 }
798 inner.item_size_cache.remove(&index);
799 return;
800 }
801 }
802
803 pub fn cache_item_size(&self, index: usize, size: f32) {
805 if !self.inner.is_alive() {
806 return;
807 }
808 self.inner.with(|rc| {
809 let mut inner = rc.borrow_mut();
810 if Self::insert_item_size(&mut inner, index, size) {
811 Self::record_item_size_sample(&mut inner, size);
812 }
813 });
814 }
815
816 pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
818 where
819 I: IntoIterator<Item = (usize, f32)>,
820 {
821 if !self.inner.is_alive() {
822 return super::DEFAULT_ITEM_SIZE_ESTIMATE;
823 }
824
825 self.inner.with(|rc| {
826 let mut inner = rc.borrow_mut();
827 for (index, size) in sizes {
828 if Self::insert_item_size(&mut inner, index, size) {
829 Self::record_item_size_sample(&mut inner, size);
830 }
831 }
832 inner.average_item_size
833 })
834 }
835
836 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
838 self.inner
839 .try_with(|rc| {
840 let mut inner = rc.borrow_mut();
841 let tick = Self::next_item_size_cache_tick(&mut inner);
842 let item = inner.item_size_cache.get_mut(&index)?;
843 item.last_used = tick;
844 let size = item.size;
845 Self::push_item_size_cache_ticket(&mut inner, tick, index);
846 Some(size)
847 })
848 .flatten()
849 }
850
851 pub fn average_item_size(&self) -> f32 {
853 self.inner
854 .try_with(|rc| rc.borrow().average_item_size)
855 .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
856 }
857
858 pub fn nearest_range(&self) -> std::ops::Range<usize> {
860 self.scroll_position.nearest_range()
861 }
862
863 pub(crate) fn update_scroll_position(
864 &self,
865 first_visible_item_index: usize,
866 first_visible_item_scroll_offset: f32,
867 ) {
868 self.scroll_position.update_from_measure_result(
869 first_visible_item_index,
870 first_visible_item_scroll_offset,
871 None,
872 );
873 }
874
875 pub(crate) fn update_scroll_position_with_key(
876 &self,
877 first_visible_item_index: usize,
878 first_visible_item_scroll_offset: f32,
879 first_visible_item_key: u64,
880 ) {
881 self.scroll_position.update_from_measure_result(
882 first_visible_item_index,
883 first_visible_item_scroll_offset,
884 Some(first_visible_item_key),
885 );
886 }
887
888 pub fn update_scroll_position_if_item_moved<F>(
896 &self,
897 new_item_count: usize,
898 get_index_by_key: F,
899 ) -> usize
900 where
901 F: Fn(u64) -> Option<usize>,
902 {
903 self.scroll_position
904 .update_if_first_item_moved(new_item_count, get_index_by_key)
905 }
906
907 pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
908 if !self.inner.is_alive() {
909 return;
910 }
911 self.inner.with(|rc| {
912 let mut inner = rc.borrow_mut();
913 info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
914 inner.layout_info = info;
915 });
916 }
917
918 pub fn can_scroll_forward(&self) -> bool {
923 if !self.can_scroll_forward_state.is_alive() {
924 return false;
925 }
926 self.can_scroll_forward_state.subscribe_current_scope_only();
927 self.can_scroll_forward_non_reactive()
928 }
929
930 pub fn can_scroll_forward_non_reactive(&self) -> bool {
932 if !self.can_scroll_forward_state.is_alive() {
933 return false;
934 }
935 self.inner
936 .try_with(|rc| rc.borrow().current_can_scroll_forward)
937 .unwrap_or(false)
938 }
939
940 pub fn can_scroll_backward(&self) -> bool {
945 if !self.can_scroll_backward_state.is_alive() {
946 return false;
947 }
948 self.can_scroll_backward_state
949 .subscribe_current_scope_only();
950 self.can_scroll_backward_non_reactive()
951 }
952
953 pub fn can_scroll_backward_non_reactive(&self) -> bool {
955 if !self.can_scroll_backward_state.is_alive() {
956 return false;
957 }
958 self.inner
959 .try_with(|rc| rc.borrow().current_can_scroll_backward)
960 .unwrap_or(false)
961 }
962
963 pub(crate) fn update_scroll_bounds(&self) {
964 if !self.inner.is_alive()
965 || !self.can_scroll_forward_state.is_alive()
966 || !self.can_scroll_backward_state.is_alive()
967 {
968 return;
969 }
970 let can_forward = self.inner.with(|rc| {
971 let inner = rc.borrow();
972 let info = &inner.layout_info;
973 let viewport_end = info.viewport_size - info.after_content_padding;
974 if let Some(last_visible) = info.visible_items_info.last() {
975 last_visible.index < info.total_items_count.saturating_sub(1)
976 || (last_visible.offset + last_visible.size) > viewport_end
977 } else {
978 false
979 }
980 });
981
982 let can_backward = self.scroll_position.current_index() > 0
983 || self.scroll_position.current_scroll_offset() > 0.0;
984
985 self.inner.with(|rc| {
986 let mut inner = rc.borrow_mut();
987 inner.current_can_scroll_forward = can_forward;
988 inner.current_can_scroll_backward = can_backward;
989 });
990
991 if self.can_scroll_forward_state.get_non_reactive() != can_forward {
992 self.can_scroll_forward_state.set(can_forward);
993 }
994 if self.can_scroll_backward_state.get_non_reactive() != can_backward {
995 self.can_scroll_backward_state.set(can_backward);
996 }
997 }
998
999 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
1001 if !self.inner.is_alive() {
1002 return 0;
1003 }
1004 self.inner.with(|rc| {
1005 let mut inner = rc.borrow_mut();
1006 let id = inner.next_callback_id;
1007 inner.next_callback_id += 1;
1008 inner.invalidate_callbacks.push((id, callback));
1009 id
1010 })
1011 }
1012
1013 pub fn try_register_layout_callback(
1021 &self,
1022 node_id: NodeId,
1023 callback: Rc<dyn Fn()>,
1024 ) -> Option<u64> {
1025 if !self.inner.is_alive() {
1026 return None;
1027 }
1028 self.inner.with(|rc| {
1029 let mut inner = rc.borrow_mut();
1030 if let Some(existing_id) = inner.layout_invalidation_callback_id {
1031 inner
1032 .invalidate_callbacks
1033 .retain(|(cb_id, _)| *cb_id != existing_id);
1034 }
1035 let id = inner.next_callback_id;
1036 inner.next_callback_id += 1;
1037 inner.invalidate_callbacks.push((id, callback));
1038 inner.layout_invalidation_callback_id = Some(id);
1039 inner.layout_invalidation_node_id = Some(node_id);
1040 Some(id)
1041 })
1042 }
1043
1044 pub fn remove_invalidate_callback(&self, id: u64) {
1046 if !self.inner.is_alive() {
1047 return;
1048 }
1049 self.inner.with(|rc| {
1050 let mut inner = rc.borrow_mut();
1051 inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1052 if inner.layout_invalidation_callback_id == Some(id) {
1053 inner.layout_invalidation_callback_id = None;
1054 inner.layout_invalidation_node_id = None;
1055 }
1056 });
1057 }
1058
1059 fn invalidate(&self) {
1060 if !self.inner.is_alive() {
1061 return;
1062 }
1063 let callbacks: Vec<_> = self.inner.with(|rc| {
1064 rc.borrow()
1065 .invalidate_callbacks
1066 .iter()
1067 .map(|(_, cb)| Rc::clone(cb))
1068 .collect()
1069 });
1070
1071 for callback in callbacks {
1072 callback();
1073 }
1074 }
1075}
1076
1077#[derive(Clone, Default, Debug)]
1079pub struct LazyListLayoutInfo {
1080 pub visible_items_info: Vec<LazyListItemInfo>,
1082
1083 pub total_items_count: usize,
1085
1086 pub raw_viewport_size: f32,
1088
1089 pub is_infinite_viewport: bool,
1091
1092 pub viewport_size: f32,
1094
1095 pub viewport_start_offset: f32,
1097
1098 pub viewport_end_offset: f32,
1100
1101 pub before_content_padding: f32,
1103
1104 pub after_content_padding: f32,
1106
1107 pub snap_anchor_offset: f32,
1109
1110 pub reverse_layout: bool,
1112}
1113
1114#[derive(Clone, Debug)]
1116pub struct LazyListItemInfo {
1117 pub index: usize,
1119
1120 pub key: u64,
1122
1123 pub offset: f32,
1125
1126 pub size: f32,
1128}
1129
1130fn continuous_snap_anchor_offset(
1131 previous: &LazyListLayoutInfo,
1132 current: &LazyListLayoutInfo,
1133) -> f32 {
1134 let Some(first_current) = current.visible_items_info.first() else {
1135 return 0.0;
1136 };
1137
1138 for current_item in ¤t.visible_items_info {
1139 if let Some(previous_item) = previous
1140 .visible_items_info
1141 .iter()
1142 .find(|item| item.key == current_item.key)
1143 {
1144 let previous_offset = snap_anchor_item_offset(previous, previous_item);
1145 let current_offset = snap_anchor_item_offset(current, current_item);
1146 return previous.snap_anchor_offset + current_offset - previous_offset;
1147 }
1148 }
1149
1150 snap_anchor_item_offset(current, first_current)
1151}
1152
1153fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1154 if info.reverse_layout {
1155 info.viewport_size - item.offset - item.size
1156 } else {
1157 item.offset
1158 }
1159}
1160
1161#[cfg(test)]
1162pub mod test_helpers {
1163 use std::sync::Arc;
1164
1165 use cranpose_core::{DefaultScheduler, Runtime};
1166
1167 use super::*;
1168
1169 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1170 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1171 f()
1172 }
1173
1174 pub fn new_lazy_list_state() -> LazyListState {
1175 new_lazy_list_state_with_position(0, 0.0)
1176 }
1177
1178 pub fn new_lazy_list_state_with_position(
1179 initial_first_visible_item_index: usize,
1180 initial_first_visible_item_scroll_offset: f32,
1181 ) -> LazyListState {
1182 let scroll_position = LazyListScrollPosition {
1183 index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
1184 scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
1185 inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
1186 current_index: initial_first_visible_item_index,
1187 current_scroll_offset: initial_first_visible_item_scroll_offset,
1188 last_known_first_item_key: None,
1189 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
1190 }))),
1191 };
1192
1193 let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
1194 scroll_to_be_consumed: 0.0,
1195 pending_scroll_to_index: None,
1196 layout_info: LazyListLayoutInfo::default(),
1197 current_can_scroll_forward: false,
1198 current_can_scroll_backward: false,
1199 invalidate_callbacks: Vec::new(),
1200 next_callback_id: 1,
1201 layout_invalidation_callback_id: None,
1202 layout_invalidation_node_id: None,
1203 total_composed: 0,
1204 reuse_count: 0,
1205 item_size_cache: std::collections::HashMap::new(),
1206 item_size_eviction_queue: BinaryHeap::new(),
1207 item_size_clock: 0,
1208 average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
1209 total_measured_items: 0,
1210 next_measure_cycle_id: 1,
1211 next_item_measure_pass_id: 1,
1212 prefetch_scheduler: PrefetchScheduler::new(),
1213 prefetch_strategy: PrefetchStrategy::default(),
1214 last_scroll_direction: 0.0,
1215 })));
1216
1217 let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
1218 let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
1219 let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
1220
1221 LazyListState {
1222 scroll_position,
1223 can_scroll_forward_state,
1224 can_scroll_backward_state,
1225 stats_state,
1226 inner,
1227 }
1228 }
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233 use std::{cell::Cell, rc::Rc};
1234
1235 use cranpose_core::{Composition, MemoryApplier, location_key};
1236
1237 use super::{
1238 LazyListItemInfo, LazyListLayoutInfo, LazyListState,
1239 test_helpers::{new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime},
1240 };
1241
1242 fn set_scroll_bounds(state: &LazyListState, can_forward: bool, can_backward: bool) {
1243 state.can_scroll_forward_state.set(can_forward);
1244 state.can_scroll_backward_state.set(can_backward);
1245 state.inner.with(|rc| {
1246 let mut inner = rc.borrow_mut();
1247 inner.current_can_scroll_forward = can_forward;
1248 inner.current_can_scroll_backward = can_backward;
1249 });
1250 }
1251
1252 fn enable_bidirectional_scroll(state: &LazyListState) {
1253 set_scroll_bounds(state, true, true);
1254 }
1255
1256 fn mark_scroll_bounds_known(state: &LazyListState) {
1257 state.update_layout_info(LazyListLayoutInfo {
1258 total_items_count: 10,
1259 ..Default::default()
1260 });
1261 }
1262
1263 fn visible_item(index: usize, offset: f32, size: f32) -> LazyListItemInfo {
1264 LazyListItemInfo {
1265 index,
1266 key: index as u64,
1267 offset,
1268 size,
1269 }
1270 }
1271
1272 #[test]
1273 fn lazy_measure_telemetry_ids_are_state_owned() {
1274 with_test_runtime(|| {
1275 let first = new_lazy_list_state();
1276 let second = new_lazy_list_state();
1277
1278 assert_eq!(first.next_measure_cycle_id(), 1);
1279 assert_eq!(first.next_measure_cycle_id(), 2);
1280 assert_eq!(second.next_measure_cycle_id(), 1);
1281
1282 assert_eq!(first.next_item_measure_pass_id(), 1);
1283 assert_eq!(first.next_item_measure_pass_id(), 2);
1284 assert_eq!(second.next_item_measure_pass_id(), 1);
1285 });
1286 }
1287
1288 #[test]
1289 fn measure_result_updates_retained_and_reactive_scroll_position() {
1290 with_test_runtime(|| {
1291 let state = new_lazy_list_state();
1292
1293 state.update_scroll_position_with_key(8, 17.5, 123);
1294
1295 assert_eq!(state.scroll_position.index.get_non_reactive(), 8);
1296 assert!((state.scroll_position.scroll_offset.get_non_reactive() - 17.5).abs() < 0.001);
1297 assert_eq!(state.first_visible_item_index_non_reactive(), 8);
1298 assert!((state.first_visible_item_scroll_offset_non_reactive() - 17.5).abs() < 0.001);
1299 });
1300 }
1301
1302 #[test]
1303 fn update_scroll_bounds_updates_retained_and_reactive_capabilities() {
1304 with_test_runtime(|| {
1305 let state = new_lazy_list_state();
1306
1307 state.update_layout_info(LazyListLayoutInfo {
1308 visible_items_info: vec![visible_item(0, 0.0, 40.0), visible_item(1, 40.0, 40.0)],
1309 total_items_count: 10,
1310 viewport_size: 80.0,
1311 ..Default::default()
1312 });
1313 state.update_scroll_bounds();
1314
1315 assert!(state.can_scroll_forward_state.get_non_reactive());
1316 assert!(!state.can_scroll_backward_state.get_non_reactive());
1317 assert!(state.can_scroll_forward_non_reactive());
1318 assert!(!state.can_scroll_backward_non_reactive());
1319
1320 state.update_scroll_position(3, 2.0);
1321 state.update_scroll_bounds();
1322
1323 assert!(state.can_scroll_backward_state.get_non_reactive());
1324 assert!(state.can_scroll_backward_non_reactive());
1325 });
1326 }
1327
1328 #[test]
1329 fn layout_info_snap_anchor_tracks_common_item_offset_delta() {
1330 let previous = LazyListLayoutInfo {
1331 visible_items_info: vec![visible_item(15, -31.4, 30.0), visible_item(16, 4.6, 30.0)],
1332 snap_anchor_offset: -31.4,
1333 ..Default::default()
1334 };
1335 let current = LazyListLayoutInfo {
1336 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1337 ..Default::default()
1338 };
1339
1340 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1341
1342 assert!((anchor + 32.4).abs() <= 0.001);
1343 }
1344
1345 #[test]
1346 fn layout_info_snap_anchor_uses_reverse_visual_item_offset() {
1347 let previous = LazyListLayoutInfo {
1348 visible_items_info: vec![visible_item(15, 31.4, 30.0), visible_item(16, 67.4, 30.0)],
1349 snap_anchor_offset: 58.6,
1350 viewport_size: 120.0,
1351 reverse_layout: true,
1352 ..Default::default()
1353 };
1354 let current = LazyListLayoutInfo {
1355 visible_items_info: vec![visible_item(16, 68.4, 30.0), visible_item(17, 104.4, 30.0)],
1356 viewport_size: 120.0,
1357 reverse_layout: true,
1358 ..Default::default()
1359 };
1360
1361 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1362
1363 assert!((anchor - 57.6).abs() <= 0.001);
1364 }
1365
1366 #[test]
1367 fn update_layout_info_keeps_snap_anchor_continuous_when_first_visible_item_changes() {
1368 with_test_runtime(|| {
1369 let state = new_lazy_list_state();
1370 state.update_layout_info(LazyListLayoutInfo {
1371 visible_items_info: vec![
1372 visible_item(15, -31.4, 30.0),
1373 visible_item(16, 4.6, 30.0),
1374 ],
1375 ..Default::default()
1376 });
1377
1378 state.update_layout_info(LazyListLayoutInfo {
1379 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1380 ..Default::default()
1381 });
1382
1383 let info = state.layout_info();
1384 assert!((info.snap_anchor_offset + 32.4).abs() <= 0.001);
1385 });
1386 }
1387
1388 #[test]
1389 fn dispatch_scroll_delta_accumulates_same_direction() {
1390 with_test_runtime(|| {
1391 let state = new_lazy_list_state();
1392 enable_bidirectional_scroll(&state);
1393
1394 state.dispatch_scroll_delta(-12.0);
1395 state.dispatch_scroll_delta(-8.0);
1396
1397 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1398 let snapshot = state.begin_measure_pass();
1399 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1400 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1401 });
1402 }
1403
1404 #[test]
1405 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1406 with_test_runtime(|| {
1407 let state = new_lazy_list_state();
1408 enable_bidirectional_scroll(&state);
1409
1410 state.dispatch_scroll_delta(-120.0);
1411 state.dispatch_scroll_delta(-30.0);
1412 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1413
1414 state.dispatch_scroll_delta(18.0);
1415
1416 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1417 let snapshot = state.begin_measure_pass();
1418 assert!((snapshot.pending_scroll_delta - 18.0).abs() < 0.001);
1419 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1420 });
1421 }
1422
1423 #[test]
1424 fn dispatch_scroll_delta_clamps_pending_backlog() {
1425 with_test_runtime(|| {
1426 let state = new_lazy_list_state();
1427 enable_bidirectional_scroll(&state);
1428
1429 state.dispatch_scroll_delta(-1_500.0);
1430 state.dispatch_scroll_delta(-1_500.0);
1431 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1432
1433 state.dispatch_scroll_delta(3_000.0);
1434 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1435 });
1436 }
1437
1438 #[test]
1439 fn begin_measure_pass_consumes_large_pending_scroll_delta_coherently() {
1440 with_test_runtime(|| {
1441 let state = new_lazy_list_state();
1442 enable_bidirectional_scroll(&state);
1443 let invalidations = Rc::new(Cell::new(0u32));
1444 let invalidations_clone = Rc::clone(&invalidations);
1445 state.add_invalidate_callback(Rc::new(move || {
1446 invalidations_clone.set(invalidations_clone.get() + 1);
1447 }));
1448
1449 state.dispatch_scroll_delta(-1_000.0);
1450 assert!((state.peek_scroll_delta() + 1_000.0).abs() < 0.001);
1451
1452 let first = state.begin_measure_pass();
1453 assert!(
1454 (first.pending_scroll_delta + 1_000.0).abs() < 0.001,
1455 "first pass should consume the whole coherent scroll input"
1456 );
1457 assert!(
1458 state.peek_scroll_delta().abs() < 0.001,
1459 "measure pass should not retain a synthetic scroll backlog"
1460 );
1461 assert_eq!(
1462 invalidations.get(),
1463 1,
1464 "dispatch should request layout once; consuming scroll should not schedule follow-up frames"
1465 );
1466
1467 let second = state.begin_measure_pass();
1468 assert!(
1469 second.pending_scroll_delta.abs() < 0.001,
1470 "second pass should not receive synthetic remainder"
1471 );
1472 });
1473 }
1474
1475 #[test]
1476 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1477 with_test_runtime(|| {
1478 let state = new_lazy_list_state();
1479 enable_bidirectional_scroll(&state);
1480 let invalidations = Rc::new(Cell::new(0u32));
1481 let invalidations_clone = Rc::clone(&invalidations);
1482 state.add_invalidate_callback(Rc::new(move || {
1483 invalidations_clone.set(invalidations_clone.get() + 1);
1484 }));
1485
1486 state.dispatch_scroll_delta(-3_000.0);
1487 assert_eq!(invalidations.get(), 1);
1488 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1489
1490 state.dispatch_scroll_delta(-100.0);
1491 assert_eq!(invalidations.get(), 1);
1492
1493 state.dispatch_scroll_delta(100.0);
1494 assert_eq!(invalidations.get(), 2);
1495 });
1496 }
1497
1498 #[test]
1499 fn begin_measure_pass_takes_coherent_snapshot_and_consumes_pending_inputs() {
1500 with_test_runtime(|| {
1501 let state = new_lazy_list_state_with_position(3, 12.0);
1502 state.dispatch_scroll_delta(-20.0);
1503 state.inner.with(|rc| {
1504 rc.borrow_mut().pending_scroll_to_index = Some((8, 4.0));
1505 });
1506
1507 let snapshot = state.begin_measure_pass();
1508
1509 assert_eq!(snapshot.first_visible_item_index, 3);
1510 assert!((snapshot.first_visible_item_scroll_offset - 12.0).abs() < 0.001);
1511 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1512 assert_eq!(snapshot.pending_scroll_to, Some((8, 4.0)));
1513 assert_eq!(state.peek_scroll_delta(), 0.0);
1514 assert_eq!(state.begin_measure_pass().pending_scroll_to, None);
1515 });
1516 }
1517
1518 #[test]
1519 fn item_size_cache_refresh_keeps_recent_entry_and_evicts_oldest_live_entry() {
1520 with_test_runtime(|| {
1521 let state = new_lazy_list_state();
1522 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1523 state.cache_item_size(index, index as f32 + 10.0);
1524 }
1525
1526 state.cache_item_size(0, 999.0);
1527 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1528
1529 assert_eq!(state.get_cached_size(0), Some(999.0));
1530 assert_eq!(state.get_cached_size(1), None);
1531 assert_eq!(
1532 state.get_cached_size(super::ITEM_SIZE_CACHE_CAPACITY),
1533 Some(123.0),
1534 );
1535 });
1536 }
1537
1538 #[test]
1539 fn item_size_cache_read_promotes_entry_for_large_scroll_reuse() {
1540 with_test_runtime(|| {
1541 let state = new_lazy_list_state();
1542 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1543 state.cache_item_size(index, index as f32 + 10.0);
1544 }
1545
1546 assert_eq!(state.get_cached_size(0), Some(10.0));
1547 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1548
1549 assert_eq!(state.get_cached_size(0), Some(10.0));
1550 assert_eq!(state.get_cached_size(1), None);
1551 let cache_len = state
1552 .inner
1553 .try_with(|rc| rc.borrow().item_size_cache.len())
1554 .unwrap_or(0);
1555 assert_eq!(cache_len, super::ITEM_SIZE_CACHE_CAPACITY);
1556 });
1557 }
1558
1559 #[test]
1560 fn item_size_cache_promotion_queue_stays_bounded_under_hot_reuse() {
1561 with_test_runtime(|| {
1562 let state = new_lazy_list_state();
1563 state.cache_item_size(0, 32.0);
1564
1565 for _ in 0..super::ITEM_SIZE_CACHE_CAPACITY * 8 {
1566 assert_eq!(state.get_cached_size(0), Some(32.0));
1567 }
1568
1569 let (cache_len, queue_len) = state
1570 .inner
1571 .try_with(|rc| {
1572 let inner = rc.borrow();
1573 (
1574 inner.item_size_cache.len(),
1575 inner.item_size_eviction_queue.len(),
1576 )
1577 })
1578 .unwrap_or((0, 0));
1579 assert_eq!(cache_len, 1);
1580 assert!(
1581 queue_len <= super::ITEM_SIZE_CACHE_CAPACITY,
1582 "stale promotion tickets must be compacted, got {queue_len}"
1583 );
1584 });
1585 }
1586
1587 #[test]
1588 fn cache_item_sizes_updates_average_only_for_new_entries() {
1589 with_test_runtime(|| {
1590 let state = new_lazy_list_state();
1591
1592 let average = state.cache_item_sizes([(0, 10.0), (1, 20.0), (0, 12.0)]);
1593
1594 assert_eq!(state.get_cached_size(0), Some(12.0));
1595 assert_eq!(state.get_cached_size(1), Some(20.0));
1596 assert!((average - 15.0).abs() < 0.001);
1597 });
1598 }
1599
1600 #[test]
1601 fn layout_callback_can_be_registered_again_after_removal() {
1602 with_test_runtime(|| {
1603 let state = new_lazy_list_state();
1604 let first_node: cranpose_core::NodeId = 1;
1605 let second_node: cranpose_core::NodeId = 2;
1606
1607 let first_id = state
1608 .try_register_layout_callback(first_node, Rc::new(|| {}))
1609 .expect("first layout callback should register");
1610 let duplicate_id = state
1611 .try_register_layout_callback(first_node, Rc::new(|| {}))
1612 .expect("duplicate register should replace with a fresh callback id");
1613 assert_eq!(
1614 state
1615 .inner
1616 .with(|rc| rc.borrow().layout_invalidation_callback_id),
1617 Some(duplicate_id),
1618 "duplicate registration should become the active callback",
1619 );
1620 assert_ne!(
1621 first_id, duplicate_id,
1622 "duplicate registration should replace the old callback id",
1623 );
1624
1625 state.remove_invalidate_callback(first_id);
1626
1627 let second_id = state
1628 .try_register_layout_callback(second_node, Rc::new(|| {}))
1629 .expect("layout callback should register again after removal");
1630 assert_ne!(first_id, second_id);
1631 });
1632 }
1633
1634 #[test]
1635 fn layout_callback_rebinds_when_node_id_changes() {
1636 with_test_runtime(|| {
1637 let state = new_lazy_list_state();
1638 let first_node: cranpose_core::NodeId = 11;
1639 let second_node: cranpose_core::NodeId = 22;
1640
1641 let first_id = state
1642 .try_register_layout_callback(first_node, Rc::new(|| {}))
1643 .expect("first layout callback should register");
1644
1645 let second_id = state
1646 .try_register_layout_callback(second_node, Rc::new(|| {}))
1647 .expect("layout callback should rebind to a new node");
1648
1649 assert_ne!(first_id, second_id);
1650 });
1651 }
1652
1653 #[test]
1654 fn stale_layout_callback_disposer_cannot_remove_replaced_same_node_callback() {
1655 with_test_runtime(|| {
1656 let state = new_lazy_list_state();
1657 let node_id: cranpose_core::NodeId = 7;
1658 let first_hits = Rc::new(Cell::new(0u32));
1659 let second_hits = Rc::new(Cell::new(0u32));
1660
1661 let first_id = state
1662 .try_register_layout_callback(
1663 node_id,
1664 Rc::new({
1665 let first_hits = Rc::clone(&first_hits);
1666 move || first_hits.set(first_hits.get() + 1)
1667 }),
1668 )
1669 .expect("first layout callback should register");
1670
1671 let second_id = state
1672 .try_register_layout_callback(
1673 node_id,
1674 Rc::new({
1675 let second_hits = Rc::clone(&second_hits);
1676 move || second_hits.set(second_hits.get() + 1)
1677 }),
1678 )
1679 .expect("same-node registration should replace the active callback");
1680
1681 assert_ne!(first_id, second_id);
1682
1683 state.remove_invalidate_callback(first_id);
1684 state.dispatch_scroll_delta(-12.0);
1685
1686 assert_eq!(
1687 first_hits.get(),
1688 0,
1689 "replaced callback should not be invoked after removal",
1690 );
1691 assert_eq!(
1692 second_hits.get(),
1693 1,
1694 "active callback should survive stale disposer cleanup",
1695 );
1696 });
1697 }
1698
1699 #[test]
1700 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1701 with_test_runtime(|| {
1702 let state = new_lazy_list_state();
1703 mark_scroll_bounds_known(&state);
1704 set_scroll_bounds(&state, false, true);
1705
1706 let consumed = state.dispatch_scroll_delta(-24.0);
1707
1708 assert_eq!(consumed, 0.0);
1709 assert_eq!(state.peek_scroll_delta(), 0.0);
1710 });
1711 }
1712
1713 #[test]
1714 fn equality_does_not_deref_released_inner_state() {
1715 let mut composition = Composition::new(MemoryApplier::new());
1716 let key = location_key(file!(), line!(), column!());
1717
1718 let mut first = None;
1719 composition
1720 .render(key, || {
1721 first = Some(super::rememberLazyListState());
1722 })
1723 .expect("initial render");
1724 let first = first.expect("first lazy state");
1725
1726 composition
1727 .render(key, || {})
1728 .expect("dispose first lazy state");
1729 assert!(
1730 !first.inner.is_alive(),
1731 "expected first lazy state to be released after disposal"
1732 );
1733
1734 let mut second = None;
1735 composition
1736 .render(key, || {
1737 second = Some(super::rememberLazyListState());
1738 })
1739 .expect("second render");
1740 let second = second.expect("second lazy state");
1741
1742 assert!(
1743 first != second,
1744 "released lazy state handle must compare by identity without panicking"
1745 );
1746 }
1747
1748 #[test]
1749 fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1750 let mut composition = Composition::new(MemoryApplier::new());
1751 let key = location_key(file!(), line!(), column!());
1752
1753 let mut released = None;
1754 composition
1755 .render(key, || {
1756 released = Some(super::rememberLazyListState());
1757 })
1758 .expect("initial render");
1759 let released = released.expect("lazy list state");
1760
1761 composition
1762 .render(key, || {})
1763 .expect("dispose lazy list state");
1764 assert!(
1765 !released.inner.is_alive(),
1766 "expected lazy list state to be released after disposal"
1767 );
1768
1769 assert_eq!(released.first_visible_item_index(), 0);
1770 assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1771 assert_eq!(released.nearest_range(), 0..0);
1772 assert_eq!(
1773 released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1774 0
1775 );
1776 released.update_scroll_position(3, 12.0);
1777 released.update_scroll_position_with_key(3, 12.0, 42);
1778 released.update_scroll_bounds();
1779 }
1780
1781 #[test]
1782 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1783 with_test_runtime(|| {
1784 let state = new_lazy_list_state();
1785 mark_scroll_bounds_known(&state);
1786 enable_bidirectional_scroll(&state);
1787 state.dispatch_scroll_delta(-300.0);
1788 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1789
1790 set_scroll_bounds(&state, false, true);
1791
1792 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1793 assert_eq!(blocked_consumed, 0.0);
1794 assert_eq!(state.peek_scroll_delta(), 0.0);
1795
1796 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1797 assert_eq!(reverse_consumed, 12.0);
1798 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1799 });
1800 }
1801
1802 #[test]
1803 fn negative_scroll_delta_prefetches_forward_items() {
1804 with_test_runtime(|| {
1805 let state = new_lazy_list_state();
1806 state.dispatch_scroll_delta(-24.0);
1807 state.record_scroll_direction(state.peek_scroll_delta());
1808 state.update_prefetch_queue(10, 15, 100);
1809
1810 assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1811 });
1812 }
1813}