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)]
65pub struct LazyListScrollPosition {
66 index: MutableState<usize>,
68 scroll_offset: MutableState<f32>,
70 inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
72}
73
74struct ScrollPositionInner {
76 current_index: usize,
78 current_scroll_offset: f32,
80 last_known_first_item_key: Option<u64>,
83 nearest_range_state: NearestRangeState,
85}
86
87impl LazyListScrollPosition {
88 fn is_alive(&self) -> bool {
89 self.index.is_alive() && self.scroll_offset.is_alive() && self.inner.is_alive()
90 }
91
92 fn current_index(&self) -> usize {
93 self.inner
94 .try_with(|rc| rc.borrow().current_index)
95 .unwrap_or(0)
96 }
97
98 fn current_scroll_offset(&self) -> f32 {
99 self.inner
100 .try_with(|rc| rc.borrow().current_scroll_offset)
101 .unwrap_or(0.0)
102 }
103
104 pub fn index(&self) -> usize {
106 if !self.index.is_alive() || !self.inner.is_alive() {
107 return 0;
108 }
109 self.index.subscribe_current_scope_only();
110 self.current_index()
111 }
112
113 pub fn scroll_offset(&self) -> f32 {
115 if !self.scroll_offset.is_alive() || !self.inner.is_alive() {
116 return 0.0;
117 }
118 self.scroll_offset.subscribe_current_scope_only();
119 self.current_scroll_offset()
120 }
121
122 pub(crate) fn update_from_measure_result(
124 &self,
125 first_visible_index: usize,
126 first_visible_scroll_offset: f32,
127 first_visible_item_key: Option<u64>,
128 ) {
129 if !self.is_alive() {
130 return;
131 }
132 self.inner.with(|rc| {
134 let mut inner = rc.borrow_mut();
135 inner.current_index = first_visible_index;
136 inner.current_scroll_offset = first_visible_scroll_offset;
137 inner.last_known_first_item_key = first_visible_item_key;
138 inner.nearest_range_state.update(first_visible_index);
139 });
140
141 if self.index.get_non_reactive() != first_visible_index {
142 self.index.set(first_visible_index);
143 }
144 if (self.scroll_offset.get_non_reactive() - first_visible_scroll_offset).abs() > 0.001 {
145 self.scroll_offset.set(first_visible_scroll_offset);
146 }
147 }
148
149 pub(crate) fn request_position_and_forget_last_known_key(
152 &self,
153 index: usize,
154 scroll_offset: f32,
155 ) {
156 if !self.is_alive() {
157 return;
158 }
159 self.inner.with(|rc| {
160 let mut inner = rc.borrow_mut();
161 inner.current_index = index;
162 inner.current_scroll_offset = scroll_offset;
163 inner.last_known_first_item_key = None;
164 inner.nearest_range_state.update(index);
165 });
166
167 if self.index.get_non_reactive() != index {
168 self.index.set(index);
169 }
170 if (self.scroll_offset.get_non_reactive() - scroll_offset).abs() > 0.001 {
171 self.scroll_offset.set(scroll_offset);
172 }
173 }
174
175 pub(crate) fn update_if_first_item_moved<F>(
178 &self,
179 new_item_count: usize,
180 find_by_key: F,
181 ) -> usize
182 where
183 F: Fn(u64) -> Option<usize>,
184 {
185 if !self.index.is_alive() || !self.inner.is_alive() {
186 return 0;
187 }
188
189 let current_index = self.current_index();
190 let last_key = self
191 .inner
192 .try_with(|rc| rc.borrow().last_known_first_item_key)
193 .flatten();
194
195 let new_index = match last_key {
196 None => current_index.min(new_item_count.saturating_sub(1)),
197 Some(key) => find_by_key(key)
198 .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
199 };
200
201 if current_index != new_index {
202 self.inner.with(|rc| {
203 let mut inner = rc.borrow_mut();
204 inner.current_index = new_index;
205 inner.nearest_range_state.update(new_index);
206 });
207 self.index.set(new_index);
208 }
209 new_index
210 }
211
212 pub fn nearest_range(&self) -> std::ops::Range<usize> {
214 self.inner
215 .try_with(|rc| rc.borrow().nearest_range_state.range())
216 .unwrap_or(0..0)
217 }
218}
219
220#[derive(Clone, Copy)]
255pub struct LazyListState {
256 scroll_position: LazyListScrollPosition,
258 can_scroll_forward_state: MutableState<bool>,
260 can_scroll_backward_state: MutableState<bool>,
262 stats_state: MutableState<LazyLayoutStats>,
265 inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
267}
268
269impl PartialEq for LazyListState {
273 fn eq(&self, other: &Self) -> bool {
274 self.inner == other.inner
275 }
276}
277
278#[derive(Clone, Copy)]
279struct CachedItemSize {
280 size: f32,
281 last_used: u64,
282}
283
284struct LazyListStateInner {
286 scroll_to_be_consumed: f32,
288
289 pending_scroll_to_index: Option<(usize, f32)>,
291
292 layout_info: LazyListLayoutInfo,
294 current_can_scroll_forward: bool,
295 current_can_scroll_backward: bool,
296
297 invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
299 next_callback_id: u64,
300
301 layout_invalidation_callback_id: Option<u64>,
305 layout_invalidation_node_id: Option<NodeId>,
306
307 total_composed: usize,
309 reuse_count: usize,
310
311 item_size_cache: std::collections::HashMap<usize, CachedItemSize>,
313 item_size_eviction_queue: BinaryHeap<Reverse<(u64, usize)>>,
314 item_size_clock: u64,
315
316 average_item_size: f32,
318 total_measured_items: usize,
319 next_measure_cycle_id: u64,
320 next_item_measure_pass_id: u64,
321
322 prefetch_scheduler: PrefetchScheduler,
324
325 prefetch_strategy: PrefetchStrategy,
327
328 last_scroll_direction: f32,
330}
331
332#[composable]
347#[track_caller]
348pub fn rememberLazyListState() -> LazyListState {
349 rememberLazyListStateWithPosition(0, 0.0)
350}
351
352#[composable]
356pub fn rememberLazyListStateWithPosition(
357 initial_first_visible_item_index: usize,
358 initial_first_visible_item_scroll_offset: f32,
359) -> LazyListState {
360 let scroll_position = LazyListScrollPosition {
362 index: cranpose_core::rememberMutableStateOf(|| initial_first_visible_item_index),
363 scroll_offset: cranpose_core::rememberMutableStateOf(|| {
364 initial_first_visible_item_scroll_offset
365 }),
366 inner: cranpose_core::rememberMutableStateOfNeverEqual(|| {
367 Rc::new(RefCell::new(ScrollPositionInner {
368 current_index: initial_first_visible_item_index,
369 current_scroll_offset: initial_first_visible_item_scroll_offset,
370 last_known_first_item_key: None,
371 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
372 }))
373 }),
374 };
375
376 let inner = cranpose_core::rememberMutableStateOfNeverEqual(|| {
378 Rc::new(RefCell::new(LazyListStateInner {
379 scroll_to_be_consumed: 0.0,
380 pending_scroll_to_index: None,
381 layout_info: LazyListLayoutInfo::default(),
382 current_can_scroll_forward: false,
383 current_can_scroll_backward: false,
384 invalidate_callbacks: Vec::new(),
385 next_callback_id: 1,
386 layout_invalidation_callback_id: None,
387 layout_invalidation_node_id: None,
388 total_composed: 0,
389 reuse_count: 0,
390 item_size_cache: std::collections::HashMap::new(),
391 item_size_eviction_queue: BinaryHeap::new(),
392 item_size_clock: 0,
393 average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
394 total_measured_items: 0,
395 next_measure_cycle_id: 1,
396 next_item_measure_pass_id: 1,
397 prefetch_scheduler: PrefetchScheduler::new(),
398 prefetch_strategy: PrefetchStrategy::default(),
399 last_scroll_direction: 0.0,
400 }))
401 });
402
403 let can_scroll_forward_state = cranpose_core::rememberMutableStateOf(|| false);
405 let can_scroll_backward_state = cranpose_core::rememberMutableStateOf(|| false);
406 let stats_state = cranpose_core::rememberMutableStateOf(LazyLayoutStats::default);
407
408 LazyListState {
409 scroll_position,
410 can_scroll_forward_state,
411 can_scroll_backward_state,
412 stats_state,
413 inner,
414 }
415}
416
417impl LazyListState {
418 pub fn inner_ptr(&self) -> *const () {
423 self.inner
424 .try_with(|rc| Rc::as_ptr(rc) as *const ())
425 .unwrap_or(std::ptr::null())
426 }
427
428 pub fn first_visible_item_index(&self) -> usize {
433 self.scroll_position.index()
435 }
436
437 pub fn first_visible_item_index_non_reactive(&self) -> usize {
442 self.scroll_position.current_index()
443 }
444
445 pub fn first_visible_item_scroll_offset(&self) -> f32 {
451 self.scroll_position.scroll_offset()
453 }
454
455 pub fn first_visible_item_scroll_offset_non_reactive(&self) -> f32 {
460 self.scroll_position.current_scroll_offset()
461 }
462
463 #[doc(hidden)]
464 pub fn reactive_state_ids(&self) -> [StateId; 5] {
465 [
466 self.scroll_position.index.runtime_state_id(),
467 self.scroll_position.scroll_offset.runtime_state_id(),
468 self.can_scroll_forward_state.runtime_state_id(),
469 self.can_scroll_backward_state.runtime_state_id(),
470 self.stats_state.runtime_state_id(),
471 ]
472 }
473
474 pub fn layout_info(&self) -> LazyListLayoutInfo {
476 self.inner
477 .try_with(|rc| rc.borrow().layout_info.clone())
478 .unwrap_or_default()
479 }
480
481 pub fn stats(&self) -> LazyLayoutStats {
487 if !self.stats_state.is_alive() || !self.inner.is_alive() {
488 return LazyLayoutStats::default();
489 }
490 let reactive = self.stats_state.get();
492 let (total_composed, reuse_count) = self.inner.with(|rc| {
493 let inner = rc.borrow();
494 (inner.total_composed, inner.reuse_count)
495 });
496 LazyLayoutStats {
497 items_in_use: reactive.items_in_use,
498 items_in_pool: reactive.items_in_pool,
499 total_composed,
500 reuse_count,
501 }
502 }
503
504 pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
509 if !self.stats_state.is_alive() || !self.inner.is_alive() {
510 return;
511 }
512
513 let current = self.stats_state.get_non_reactive();
514
515 let should_update_reactive = if items_in_use > current.items_in_use {
524 true
526 } else if items_in_use < current.items_in_use {
527 current.items_in_use - items_in_use > 1
529 } else {
530 false
531 };
532
533 if should_update_reactive {
534 self.stats_state.set(LazyLayoutStats {
535 items_in_use,
536 items_in_pool,
537 ..current
538 });
539 }
540 }
543
544 pub fn record_composition(&self, was_reused: bool) {
549 if !self.inner.is_alive() {
550 return;
551 }
552 self.inner.with(|rc| {
553 let mut inner = rc.borrow_mut();
554 inner.total_composed += 1;
555 if was_reused {
556 inner.reuse_count += 1;
557 }
558 });
559 }
560
561 pub fn record_scroll_direction(&self, delta: f32) {
567 if delta.abs() > 0.001 {
568 if !self.inner.is_alive() {
569 return;
570 }
571 self.inner.with(|rc| {
572 rc.borrow_mut().last_scroll_direction = -delta.signum();
573 });
574 }
575 }
576
577 pub fn update_prefetch_queue(
580 &self,
581 first_visible_index: usize,
582 last_visible_index: usize,
583 total_items: usize,
584 ) {
585 if !self.inner.is_alive() {
586 return;
587 }
588 self.inner.with(|rc| {
589 let mut inner = rc.borrow_mut();
590 let direction = inner.last_scroll_direction;
591 let strategy = inner.prefetch_strategy.clone();
592 inner.prefetch_scheduler.update(
593 first_visible_index,
594 last_visible_index,
595 total_items,
596 direction,
597 &strategy,
598 );
599 });
600 }
601
602 pub fn take_prefetch_indices(&self) -> Vec<usize> {
605 self.inner
606 .try_with(|rc| {
607 let mut inner = rc.borrow_mut();
608 let mut indices = Vec::new();
609 while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
610 indices.push(idx);
611 }
612 indices
613 })
614 .unwrap_or_default()
615 }
616
617 pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
623 if !self.inner.is_alive() {
624 return;
625 }
626 if diagnostics::telemetry_enabled() {
627 log::warn!(
628 "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
629 index,
630 scroll_offset
631 );
632 }
633 self.inner.with(|rc| {
635 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
636 });
637
638 self.scroll_position
640 .request_position_and_forget_last_known_key(index, scroll_offset);
641
642 self.invalidate();
643 }
644
645 pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
656 if !self.inner.is_alive() {
659 return 0.0;
660 }
661 let has_scroll_bounds = self
662 .inner
663 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
664 let pushing_forward = delta < -0.001;
665 let pushing_backward = delta > 0.001;
666 let can_scroll_forward =
667 self.can_scroll_forward_state.is_alive() && self.can_scroll_forward_non_reactive();
668 let can_scroll_backward =
669 self.can_scroll_backward_state.is_alive() && self.can_scroll_backward_non_reactive();
670 let blocked_by_bounds = has_scroll_bounds
671 && ((pushing_forward && !can_scroll_forward)
672 || (pushing_backward && !can_scroll_backward));
673
674 if blocked_by_bounds {
675 let should_invalidate = self.inner.with(|rc| {
676 let mut inner = rc.borrow_mut();
677 let pending_before = inner.scroll_to_be_consumed;
678 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
680 inner.scroll_to_be_consumed = 0.0;
681 }
682 if diagnostics::telemetry_enabled() {
683 log::warn!(
684 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
685 delta,
686 pending_before,
687 inner.scroll_to_be_consumed
688 );
689 }
690 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
691 });
692 if should_invalidate {
693 self.invalidate();
694 }
695 return 0.0;
696 }
697
698 let mut accepted_delta = 0.0f32;
699 let should_invalidate = self.inner.with(|rc| {
700 let mut inner = rc.borrow_mut();
701 accepted_delta = delta;
702 let pending_before = inner.scroll_to_be_consumed;
703 let pending = inner.scroll_to_be_consumed;
704 let reverse_input = pending.abs() > 0.001
705 && delta.abs() > 0.001
706 && pending.signum() != delta.signum();
707 if reverse_input {
708 if diagnostics::telemetry_enabled() {
709 log::warn!(
710 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
711 pending,
712 delta
713 );
714 }
715 inner.scroll_to_be_consumed = delta;
719 } else {
720 inner.scroll_to_be_consumed += delta;
721 }
722 inner.scroll_to_be_consumed = inner
723 .scroll_to_be_consumed
724 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
725 if diagnostics::telemetry_enabled() {
726 log::warn!(
727 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
728 delta,
729 inner.scroll_to_be_consumed
730 );
731 }
732 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
733 });
734 if should_invalidate {
735 self.invalidate();
736 }
737 accepted_delta
738 }
739
740 pub fn peek_scroll_delta(&self) -> f32 {
747 self.inner
748 .try_with(|rc| rc.borrow().scroll_to_be_consumed)
749 .unwrap_or(0.0)
750 }
751
752 pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
753 let (pending_scroll_delta, pending_scroll_to, average_item_size) = self
754 .inner
755 .try_with(|rc| {
756 let mut inner = rc.borrow_mut();
757 let pending_scroll_to = inner.pending_scroll_to_index.take();
758 let pending_scroll_delta = inner.scroll_to_be_consumed;
759 inner.scroll_to_be_consumed = 0.0;
760 (
761 pending_scroll_delta,
762 pending_scroll_to,
763 inner.average_item_size,
764 )
765 })
766 .unwrap_or((0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
767
768 LazyListMeasureStateSnapshot {
769 first_visible_item_index: self.scroll_position.current_index(),
770 first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
771 pending_scroll_delta,
772 pending_scroll_to,
773 average_item_size,
774 }
775 }
776
777 pub(crate) fn next_measure_cycle_id(&self) -> u64 {
778 self.inner
779 .try_with(|rc| {
780 let mut inner = rc.borrow_mut();
781 let id = inner.next_measure_cycle_id;
782 inner.next_measure_cycle_id = inner.next_measure_cycle_id.saturating_add(1);
783 id
784 })
785 .unwrap_or(0)
786 }
787
788 pub(crate) fn next_item_measure_pass_id(&self) -> u64 {
789 self.inner
790 .try_with(|rc| {
791 let mut inner = rc.borrow_mut();
792 let id = inner.next_item_measure_pass_id;
793 inner.next_item_measure_pass_id = inner.next_item_measure_pass_id.saturating_add(1);
794 id
795 })
796 .unwrap_or(0)
797 }
798
799 fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
800 inner.total_measured_items += 1;
801 let n = inner.total_measured_items as f32;
802 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
803 }
804
805 fn next_item_size_cache_tick(inner: &mut LazyListStateInner) -> u64 {
806 inner.item_size_clock = inner.item_size_clock.saturating_add(1);
807 inner.item_size_clock
808 }
809
810 fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
811 use std::collections::hash_map::Entry;
812
813 let tick = Self::next_item_size_cache_tick(inner);
814 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
815 entry.insert(CachedItemSize {
816 size,
817 last_used: tick,
818 });
819 Self::push_item_size_cache_ticket(inner, tick, index);
820 return false;
821 }
822
823 if inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
824 Self::evict_one_item_size(inner);
825 }
826
827 inner.item_size_cache.insert(
828 index,
829 CachedItemSize {
830 size,
831 last_used: tick,
832 },
833 );
834 Self::push_item_size_cache_ticket(inner, tick, index);
835 true
836 }
837
838 fn push_item_size_cache_ticket(inner: &mut LazyListStateInner, last_used: u64, index: usize) {
839 inner
840 .item_size_eviction_queue
841 .push(Reverse((last_used, index)));
842 let compact_limit = inner
843 .item_size_cache
844 .len()
845 .saturating_mul(4)
846 .max(ITEM_SIZE_CACHE_CAPACITY);
847 if inner.item_size_eviction_queue.len() > compact_limit {
848 Self::rebuild_item_size_eviction_queue(inner);
849 }
850 }
851
852 fn rebuild_item_size_eviction_queue(inner: &mut LazyListStateInner) {
853 inner.item_size_eviction_queue = inner
854 .item_size_cache
855 .iter()
856 .map(|(index, item)| Reverse((item.last_used, *index)))
857 .collect();
858 }
859
860 fn evict_one_item_size(inner: &mut LazyListStateInner) {
861 while let Some(Reverse((last_used, index))) = inner.item_size_eviction_queue.pop() {
862 let Some(current) = inner.item_size_cache.get(&index) else {
863 continue;
864 };
865 if current.last_used != last_used {
866 continue;
867 }
868 inner.item_size_cache.remove(&index);
869 return;
870 }
871 }
872
873 pub fn cache_item_size(&self, index: usize, size: f32) {
875 if !self.inner.is_alive() {
876 return;
877 }
878 self.inner.with(|rc| {
879 let mut inner = rc.borrow_mut();
880 if Self::insert_item_size(&mut inner, index, size) {
881 Self::record_item_size_sample(&mut inner, size);
882 }
883 });
884 }
885
886 pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
888 where
889 I: IntoIterator<Item = (usize, f32)>,
890 {
891 if !self.inner.is_alive() {
892 return super::DEFAULT_ITEM_SIZE_ESTIMATE;
893 }
894
895 self.inner.with(|rc| {
896 let mut inner = rc.borrow_mut();
897 for (index, size) in sizes {
898 if Self::insert_item_size(&mut inner, index, size) {
899 Self::record_item_size_sample(&mut inner, size);
900 }
901 }
902 inner.average_item_size
903 })
904 }
905
906 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
908 self.inner
909 .try_with(|rc| {
910 let mut inner = rc.borrow_mut();
911 let tick = Self::next_item_size_cache_tick(&mut inner);
912 let item = inner.item_size_cache.get_mut(&index)?;
913 item.last_used = tick;
914 let size = item.size;
915 Self::push_item_size_cache_ticket(&mut inner, tick, index);
916 Some(size)
917 })
918 .flatten()
919 }
920
921 pub fn average_item_size(&self) -> f32 {
923 self.inner
924 .try_with(|rc| rc.borrow().average_item_size)
925 .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
926 }
927
928 pub fn nearest_range(&self) -> std::ops::Range<usize> {
930 self.scroll_position.nearest_range()
932 }
933
934 pub(crate) fn update_scroll_position(
938 &self,
939 first_visible_item_index: usize,
940 first_visible_item_scroll_offset: f32,
941 ) {
942 self.scroll_position.update_from_measure_result(
943 first_visible_item_index,
944 first_visible_item_scroll_offset,
945 None,
946 );
947 }
948
949 pub(crate) fn update_scroll_position_with_key(
953 &self,
954 first_visible_item_index: usize,
955 first_visible_item_scroll_offset: f32,
956 first_visible_item_key: u64,
957 ) {
958 self.scroll_position.update_from_measure_result(
959 first_visible_item_index,
960 first_visible_item_scroll_offset,
961 Some(first_visible_item_key),
962 );
963 }
964
965 pub fn update_scroll_position_if_item_moved<F>(
973 &self,
974 new_item_count: usize,
975 get_index_by_key: F,
976 ) -> usize
977 where
978 F: Fn(u64) -> Option<usize>,
979 {
980 self.scroll_position
982 .update_if_first_item_moved(new_item_count, get_index_by_key)
983 }
984
985 pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
987 if !self.inner.is_alive() {
988 return;
989 }
990 self.inner.with(|rc| {
991 let mut inner = rc.borrow_mut();
992 info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
993 inner.layout_info = info;
994 });
995 }
996
997 pub fn can_scroll_forward(&self) -> bool {
1002 if !self.can_scroll_forward_state.is_alive() {
1003 return false;
1004 }
1005 self.can_scroll_forward_state.subscribe_current_scope_only();
1006 self.can_scroll_forward_non_reactive()
1007 }
1008
1009 pub fn can_scroll_forward_non_reactive(&self) -> bool {
1011 if !self.can_scroll_forward_state.is_alive() {
1012 return false;
1013 }
1014 self.inner
1015 .try_with(|rc| rc.borrow().current_can_scroll_forward)
1016 .unwrap_or(false)
1017 }
1018
1019 pub fn can_scroll_backward(&self) -> bool {
1024 if !self.can_scroll_backward_state.is_alive() {
1025 return false;
1026 }
1027 self.can_scroll_backward_state
1028 .subscribe_current_scope_only();
1029 self.can_scroll_backward_non_reactive()
1030 }
1031
1032 pub fn can_scroll_backward_non_reactive(&self) -> bool {
1034 if !self.can_scroll_backward_state.is_alive() {
1035 return false;
1036 }
1037 self.inner
1038 .try_with(|rc| rc.borrow().current_can_scroll_backward)
1039 .unwrap_or(false)
1040 }
1041
1042 pub(crate) fn update_scroll_bounds(&self) {
1046 if !self.inner.is_alive()
1047 || !self.can_scroll_forward_state.is_alive()
1048 || !self.can_scroll_backward_state.is_alive()
1049 {
1050 return;
1051 }
1052 let can_forward = self.inner.with(|rc| {
1054 let inner = rc.borrow();
1055 let info = &inner.layout_info;
1056 let viewport_end = info.viewport_size - info.after_content_padding;
1059 if let Some(last_visible) = info.visible_items_info.last() {
1060 last_visible.index < info.total_items_count.saturating_sub(1)
1061 || (last_visible.offset + last_visible.size) > viewport_end
1062 } else {
1063 false
1064 }
1065 });
1066
1067 let can_backward = self.scroll_position.current_index() > 0
1069 || self.scroll_position.current_scroll_offset() > 0.0;
1070
1071 self.inner.with(|rc| {
1072 let mut inner = rc.borrow_mut();
1073 inner.current_can_scroll_forward = can_forward;
1074 inner.current_can_scroll_backward = can_backward;
1075 });
1076
1077 if self.can_scroll_forward_state.get_non_reactive() != can_forward {
1078 self.can_scroll_forward_state.set(can_forward);
1079 }
1080 if self.can_scroll_backward_state.get_non_reactive() != can_backward {
1081 self.can_scroll_backward_state.set(can_backward);
1082 }
1083 }
1084
1085 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
1087 if !self.inner.is_alive() {
1088 return 0;
1089 }
1090 self.inner.with(|rc| {
1091 let mut inner = rc.borrow_mut();
1092 let id = inner.next_callback_id;
1093 inner.next_callback_id += 1;
1094 inner.invalidate_callbacks.push((id, callback));
1095 id
1096 })
1097 }
1098
1099 pub fn try_register_layout_callback(
1107 &self,
1108 node_id: NodeId,
1109 callback: Rc<dyn Fn()>,
1110 ) -> Option<u64> {
1111 if !self.inner.is_alive() {
1112 return None;
1113 }
1114 self.inner.with(|rc| {
1115 let mut inner = rc.borrow_mut();
1116 if let Some(existing_id) = inner.layout_invalidation_callback_id {
1117 inner
1118 .invalidate_callbacks
1119 .retain(|(cb_id, _)| *cb_id != existing_id);
1120 }
1121 let id = inner.next_callback_id;
1122 inner.next_callback_id += 1;
1123 inner.invalidate_callbacks.push((id, callback));
1124 inner.layout_invalidation_callback_id = Some(id);
1125 inner.layout_invalidation_node_id = Some(node_id);
1126 Some(id)
1127 })
1128 }
1129
1130 pub fn remove_invalidate_callback(&self, id: u64) {
1132 if !self.inner.is_alive() {
1133 return;
1134 }
1135 self.inner.with(|rc| {
1136 let mut inner = rc.borrow_mut();
1137 inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1138 if inner.layout_invalidation_callback_id == Some(id) {
1139 inner.layout_invalidation_callback_id = None;
1140 inner.layout_invalidation_node_id = None;
1141 }
1142 });
1143 }
1144
1145 fn invalidate(&self) {
1146 if !self.inner.is_alive() {
1147 return;
1148 }
1149 let callbacks: Vec<_> = self.inner.with(|rc| {
1152 rc.borrow()
1153 .invalidate_callbacks
1154 .iter()
1155 .map(|(_, cb)| Rc::clone(cb))
1156 .collect()
1157 });
1158
1159 for callback in callbacks {
1160 callback();
1161 }
1162 }
1163}
1164
1165#[derive(Clone, Default, Debug)]
1167pub struct LazyListLayoutInfo {
1168 pub visible_items_info: Vec<LazyListItemInfo>,
1170
1171 pub total_items_count: usize,
1173
1174 pub raw_viewport_size: f32,
1176
1177 pub is_infinite_viewport: bool,
1179
1180 pub viewport_size: f32,
1182
1183 pub viewport_start_offset: f32,
1185
1186 pub viewport_end_offset: f32,
1188
1189 pub before_content_padding: f32,
1191
1192 pub after_content_padding: f32,
1194
1195 pub snap_anchor_offset: f32,
1197
1198 pub reverse_layout: bool,
1200}
1201
1202#[derive(Clone, Debug)]
1204pub struct LazyListItemInfo {
1205 pub index: usize,
1207
1208 pub key: u64,
1210
1211 pub offset: f32,
1213
1214 pub size: f32,
1216}
1217
1218fn continuous_snap_anchor_offset(
1219 previous: &LazyListLayoutInfo,
1220 current: &LazyListLayoutInfo,
1221) -> f32 {
1222 let Some(first_current) = current.visible_items_info.first() else {
1223 return 0.0;
1224 };
1225
1226 for current_item in ¤t.visible_items_info {
1227 if let Some(previous_item) = previous
1228 .visible_items_info
1229 .iter()
1230 .find(|item| item.key == current_item.key)
1231 {
1232 let previous_offset = snap_anchor_item_offset(previous, previous_item);
1233 let current_offset = snap_anchor_item_offset(current, current_item);
1234 return previous.snap_anchor_offset + current_offset - previous_offset;
1235 }
1236 }
1237
1238 snap_anchor_item_offset(current, first_current)
1239}
1240
1241fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1242 if info.reverse_layout {
1243 info.viewport_size - item.offset - item.size
1244 } else {
1245 item.offset
1246 }
1247}
1248
1249#[cfg(test)]
1251pub mod test_helpers {
1252 use std::sync::Arc;
1253
1254 use cranpose_core::{DefaultScheduler, Runtime};
1255
1256 use super::*;
1257
1258 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1261 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1262 f()
1263 }
1264
1265 pub fn new_lazy_list_state() -> LazyListState {
1268 new_lazy_list_state_with_position(0, 0.0)
1269 }
1270
1271 pub fn new_lazy_list_state_with_position(
1274 initial_first_visible_item_index: usize,
1275 initial_first_visible_item_scroll_offset: f32,
1276 ) -> LazyListState {
1277 let scroll_position = LazyListScrollPosition {
1279 index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
1280 scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
1281 inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
1282 current_index: initial_first_visible_item_index,
1283 current_scroll_offset: initial_first_visible_item_scroll_offset,
1284 last_known_first_item_key: None,
1285 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
1286 }))),
1287 };
1288
1289 let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
1291 scroll_to_be_consumed: 0.0,
1292 pending_scroll_to_index: None,
1293 layout_info: LazyListLayoutInfo::default(),
1294 current_can_scroll_forward: false,
1295 current_can_scroll_backward: false,
1296 invalidate_callbacks: Vec::new(),
1297 next_callback_id: 1,
1298 layout_invalidation_callback_id: None,
1299 layout_invalidation_node_id: None,
1300 total_composed: 0,
1301 reuse_count: 0,
1302 item_size_cache: std::collections::HashMap::new(),
1303 item_size_eviction_queue: BinaryHeap::new(),
1304 item_size_clock: 0,
1305 average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
1306 total_measured_items: 0,
1307 next_measure_cycle_id: 1,
1308 next_item_measure_pass_id: 1,
1309 prefetch_scheduler: PrefetchScheduler::new(),
1310 prefetch_strategy: PrefetchStrategy::default(),
1311 last_scroll_direction: 0.0,
1312 })));
1313
1314 let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
1316 let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
1317 let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
1318
1319 LazyListState {
1320 scroll_position,
1321 can_scroll_forward_state,
1322 can_scroll_backward_state,
1323 stats_state,
1324 inner,
1325 }
1326 }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use std::{cell::Cell, rc::Rc};
1332
1333 use cranpose_core::{Composition, MemoryApplier, location_key};
1334
1335 use super::{
1336 LazyListItemInfo, LazyListLayoutInfo, LazyListState,
1337 test_helpers::{new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime},
1338 };
1339
1340 fn set_scroll_bounds(state: &LazyListState, can_forward: bool, can_backward: bool) {
1341 state.can_scroll_forward_state.set(can_forward);
1342 state.can_scroll_backward_state.set(can_backward);
1343 state.inner.with(|rc| {
1344 let mut inner = rc.borrow_mut();
1345 inner.current_can_scroll_forward = can_forward;
1346 inner.current_can_scroll_backward = can_backward;
1347 });
1348 }
1349
1350 fn enable_bidirectional_scroll(state: &LazyListState) {
1351 set_scroll_bounds(state, true, true);
1352 }
1353
1354 fn mark_scroll_bounds_known(state: &LazyListState) {
1355 state.update_layout_info(LazyListLayoutInfo {
1356 total_items_count: 10,
1357 ..Default::default()
1358 });
1359 }
1360
1361 fn visible_item(index: usize, offset: f32, size: f32) -> LazyListItemInfo {
1362 LazyListItemInfo {
1363 index,
1364 key: index as u64,
1365 offset,
1366 size,
1367 }
1368 }
1369
1370 #[test]
1371 fn lazy_measure_telemetry_ids_are_state_owned() {
1372 with_test_runtime(|| {
1373 let first = new_lazy_list_state();
1374 let second = new_lazy_list_state();
1375
1376 assert_eq!(first.next_measure_cycle_id(), 1);
1377 assert_eq!(first.next_measure_cycle_id(), 2);
1378 assert_eq!(second.next_measure_cycle_id(), 1);
1379
1380 assert_eq!(first.next_item_measure_pass_id(), 1);
1381 assert_eq!(first.next_item_measure_pass_id(), 2);
1382 assert_eq!(second.next_item_measure_pass_id(), 1);
1383 });
1384 }
1385
1386 #[test]
1387 fn measure_result_updates_retained_and_reactive_scroll_position() {
1388 with_test_runtime(|| {
1389 let state = new_lazy_list_state();
1390
1391 state.update_scroll_position_with_key(8, 17.5, 123);
1392
1393 assert_eq!(state.scroll_position.index.get_non_reactive(), 8);
1394 assert!((state.scroll_position.scroll_offset.get_non_reactive() - 17.5).abs() < 0.001);
1395 assert_eq!(state.first_visible_item_index_non_reactive(), 8);
1396 assert!((state.first_visible_item_scroll_offset_non_reactive() - 17.5).abs() < 0.001);
1397 });
1398 }
1399
1400 #[test]
1401 fn update_scroll_bounds_updates_retained_and_reactive_capabilities() {
1402 with_test_runtime(|| {
1403 let state = new_lazy_list_state();
1404
1405 state.update_layout_info(LazyListLayoutInfo {
1406 visible_items_info: vec![visible_item(0, 0.0, 40.0), visible_item(1, 40.0, 40.0)],
1407 total_items_count: 10,
1408 viewport_size: 80.0,
1409 ..Default::default()
1410 });
1411 state.update_scroll_bounds();
1412
1413 assert!(state.can_scroll_forward_state.get_non_reactive());
1414 assert!(!state.can_scroll_backward_state.get_non_reactive());
1415 assert!(state.can_scroll_forward_non_reactive());
1416 assert!(!state.can_scroll_backward_non_reactive());
1417
1418 state.update_scroll_position(3, 2.0);
1419 state.update_scroll_bounds();
1420
1421 assert!(state.can_scroll_backward_state.get_non_reactive());
1422 assert!(state.can_scroll_backward_non_reactive());
1423 });
1424 }
1425
1426 #[test]
1427 fn layout_info_snap_anchor_tracks_common_item_offset_delta() {
1428 let previous = LazyListLayoutInfo {
1429 visible_items_info: vec![visible_item(15, -31.4, 30.0), visible_item(16, 4.6, 30.0)],
1430 snap_anchor_offset: -31.4,
1431 ..Default::default()
1432 };
1433 let current = LazyListLayoutInfo {
1434 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1435 ..Default::default()
1436 };
1437
1438 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1439
1440 assert!((anchor + 32.4).abs() <= 0.001);
1441 }
1442
1443 #[test]
1444 fn layout_info_snap_anchor_uses_reverse_visual_item_offset() {
1445 let previous = LazyListLayoutInfo {
1446 visible_items_info: vec![visible_item(15, 31.4, 30.0), visible_item(16, 67.4, 30.0)],
1447 snap_anchor_offset: 58.6,
1448 viewport_size: 120.0,
1449 reverse_layout: true,
1450 ..Default::default()
1451 };
1452 let current = LazyListLayoutInfo {
1453 visible_items_info: vec![visible_item(16, 68.4, 30.0), visible_item(17, 104.4, 30.0)],
1454 viewport_size: 120.0,
1455 reverse_layout: true,
1456 ..Default::default()
1457 };
1458
1459 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1460
1461 assert!((anchor - 57.6).abs() <= 0.001);
1462 }
1463
1464 #[test]
1465 fn update_layout_info_keeps_snap_anchor_continuous_when_first_visible_item_changes() {
1466 with_test_runtime(|| {
1467 let state = new_lazy_list_state();
1468 state.update_layout_info(LazyListLayoutInfo {
1469 visible_items_info: vec![
1470 visible_item(15, -31.4, 30.0),
1471 visible_item(16, 4.6, 30.0),
1472 ],
1473 ..Default::default()
1474 });
1475
1476 state.update_layout_info(LazyListLayoutInfo {
1477 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1478 ..Default::default()
1479 });
1480
1481 let info = state.layout_info();
1482 assert!((info.snap_anchor_offset + 32.4).abs() <= 0.001);
1483 });
1484 }
1485
1486 #[test]
1487 fn dispatch_scroll_delta_accumulates_same_direction() {
1488 with_test_runtime(|| {
1489 let state = new_lazy_list_state();
1490 enable_bidirectional_scroll(&state);
1491
1492 state.dispatch_scroll_delta(-12.0);
1493 state.dispatch_scroll_delta(-8.0);
1494
1495 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1496 let snapshot = state.begin_measure_pass();
1497 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1498 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1499 });
1500 }
1501
1502 #[test]
1503 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1504 with_test_runtime(|| {
1505 let state = new_lazy_list_state();
1506 enable_bidirectional_scroll(&state);
1507
1508 state.dispatch_scroll_delta(-120.0);
1509 state.dispatch_scroll_delta(-30.0);
1510 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1511
1512 state.dispatch_scroll_delta(18.0);
1513
1514 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1515 let snapshot = state.begin_measure_pass();
1516 assert!((snapshot.pending_scroll_delta - 18.0).abs() < 0.001);
1517 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1518 });
1519 }
1520
1521 #[test]
1522 fn dispatch_scroll_delta_clamps_pending_backlog() {
1523 with_test_runtime(|| {
1524 let state = new_lazy_list_state();
1525 enable_bidirectional_scroll(&state);
1526
1527 state.dispatch_scroll_delta(-1_500.0);
1528 state.dispatch_scroll_delta(-1_500.0);
1529 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1530
1531 state.dispatch_scroll_delta(3_000.0);
1532 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1533 });
1534 }
1535
1536 #[test]
1537 fn begin_measure_pass_consumes_large_pending_scroll_delta_coherently() {
1538 with_test_runtime(|| {
1539 let state = new_lazy_list_state();
1540 enable_bidirectional_scroll(&state);
1541 let invalidations = Rc::new(Cell::new(0u32));
1542 let invalidations_clone = Rc::clone(&invalidations);
1543 state.add_invalidate_callback(Rc::new(move || {
1544 invalidations_clone.set(invalidations_clone.get() + 1);
1545 }));
1546
1547 state.dispatch_scroll_delta(-1_000.0);
1548 assert!((state.peek_scroll_delta() + 1_000.0).abs() < 0.001);
1549
1550 let first = state.begin_measure_pass();
1551 assert!(
1552 (first.pending_scroll_delta + 1_000.0).abs() < 0.001,
1553 "first pass should consume the whole coherent scroll input"
1554 );
1555 assert!(
1556 state.peek_scroll_delta().abs() < 0.001,
1557 "measure pass should not retain a synthetic scroll backlog"
1558 );
1559 assert_eq!(
1560 invalidations.get(),
1561 1,
1562 "dispatch should request layout once; consuming scroll should not schedule follow-up frames"
1563 );
1564
1565 let second = state.begin_measure_pass();
1566 assert!(
1567 second.pending_scroll_delta.abs() < 0.001,
1568 "second pass should not receive synthetic remainder"
1569 );
1570 });
1571 }
1572
1573 #[test]
1574 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1575 with_test_runtime(|| {
1576 let state = new_lazy_list_state();
1577 enable_bidirectional_scroll(&state);
1578 let invalidations = Rc::new(Cell::new(0u32));
1579 let invalidations_clone = Rc::clone(&invalidations);
1580 state.add_invalidate_callback(Rc::new(move || {
1581 invalidations_clone.set(invalidations_clone.get() + 1);
1582 }));
1583
1584 state.dispatch_scroll_delta(-3_000.0);
1585 assert_eq!(invalidations.get(), 1);
1586 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1587
1588 state.dispatch_scroll_delta(-100.0);
1590 assert_eq!(invalidations.get(), 1);
1591
1592 state.dispatch_scroll_delta(100.0);
1594 assert_eq!(invalidations.get(), 2);
1595 });
1596 }
1597
1598 #[test]
1599 fn begin_measure_pass_takes_coherent_snapshot_and_consumes_pending_inputs() {
1600 with_test_runtime(|| {
1601 let state = new_lazy_list_state_with_position(3, 12.0);
1602 state.dispatch_scroll_delta(-20.0);
1603 state.inner.with(|rc| {
1604 rc.borrow_mut().pending_scroll_to_index = Some((8, 4.0));
1605 });
1606
1607 let snapshot = state.begin_measure_pass();
1608
1609 assert_eq!(snapshot.first_visible_item_index, 3);
1610 assert!((snapshot.first_visible_item_scroll_offset - 12.0).abs() < 0.001);
1611 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1612 assert_eq!(snapshot.pending_scroll_to, Some((8, 4.0)));
1613 assert_eq!(state.peek_scroll_delta(), 0.0);
1614 assert_eq!(state.begin_measure_pass().pending_scroll_to, None);
1615 });
1616 }
1617
1618 #[test]
1619 fn item_size_cache_refresh_keeps_recent_entry_and_evicts_oldest_live_entry() {
1620 with_test_runtime(|| {
1621 let state = new_lazy_list_state();
1622 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1623 state.cache_item_size(index, index as f32 + 10.0);
1624 }
1625
1626 state.cache_item_size(0, 999.0);
1627 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1628
1629 assert_eq!(state.get_cached_size(0), Some(999.0));
1630 assert_eq!(state.get_cached_size(1), None);
1631 assert_eq!(
1632 state.get_cached_size(super::ITEM_SIZE_CACHE_CAPACITY),
1633 Some(123.0),
1634 );
1635 });
1636 }
1637
1638 #[test]
1639 fn item_size_cache_read_promotes_entry_for_large_scroll_reuse() {
1640 with_test_runtime(|| {
1641 let state = new_lazy_list_state();
1642 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1643 state.cache_item_size(index, index as f32 + 10.0);
1644 }
1645
1646 assert_eq!(state.get_cached_size(0), Some(10.0));
1647 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1648
1649 assert_eq!(state.get_cached_size(0), Some(10.0));
1650 assert_eq!(state.get_cached_size(1), None);
1651 let cache_len = state
1652 .inner
1653 .try_with(|rc| rc.borrow().item_size_cache.len())
1654 .unwrap_or(0);
1655 assert_eq!(cache_len, super::ITEM_SIZE_CACHE_CAPACITY);
1656 });
1657 }
1658
1659 #[test]
1660 fn item_size_cache_promotion_queue_stays_bounded_under_hot_reuse() {
1661 with_test_runtime(|| {
1662 let state = new_lazy_list_state();
1663 state.cache_item_size(0, 32.0);
1664
1665 for _ in 0..super::ITEM_SIZE_CACHE_CAPACITY * 8 {
1666 assert_eq!(state.get_cached_size(0), Some(32.0));
1667 }
1668
1669 let (cache_len, queue_len) = state
1670 .inner
1671 .try_with(|rc| {
1672 let inner = rc.borrow();
1673 (
1674 inner.item_size_cache.len(),
1675 inner.item_size_eviction_queue.len(),
1676 )
1677 })
1678 .unwrap_or((0, 0));
1679 assert_eq!(cache_len, 1);
1680 assert!(
1681 queue_len <= super::ITEM_SIZE_CACHE_CAPACITY,
1682 "stale promotion tickets must be compacted, got {queue_len}"
1683 );
1684 });
1685 }
1686
1687 #[test]
1688 fn cache_item_sizes_updates_average_only_for_new_entries() {
1689 with_test_runtime(|| {
1690 let state = new_lazy_list_state();
1691
1692 let average = state.cache_item_sizes([(0, 10.0), (1, 20.0), (0, 12.0)]);
1693
1694 assert_eq!(state.get_cached_size(0), Some(12.0));
1695 assert_eq!(state.get_cached_size(1), Some(20.0));
1696 assert!((average - 15.0).abs() < 0.001);
1697 });
1698 }
1699
1700 #[test]
1701 fn layout_callback_can_be_registered_again_after_removal() {
1702 with_test_runtime(|| {
1703 let state = new_lazy_list_state();
1704 let first_node: cranpose_core::NodeId = 1;
1705 let second_node: cranpose_core::NodeId = 2;
1706
1707 let first_id = state
1708 .try_register_layout_callback(first_node, Rc::new(|| {}))
1709 .expect("first layout callback should register");
1710 let duplicate_id = state
1711 .try_register_layout_callback(first_node, Rc::new(|| {}))
1712 .expect("duplicate register should replace with a fresh callback id");
1713 assert_eq!(
1714 state
1715 .inner
1716 .with(|rc| rc.borrow().layout_invalidation_callback_id),
1717 Some(duplicate_id),
1718 "duplicate registration should become the active callback",
1719 );
1720 assert_ne!(
1721 first_id, duplicate_id,
1722 "duplicate registration should replace the old callback id",
1723 );
1724
1725 state.remove_invalidate_callback(first_id);
1726
1727 let second_id = state
1728 .try_register_layout_callback(second_node, Rc::new(|| {}))
1729 .expect("layout callback should register again after removal");
1730 assert_ne!(first_id, second_id);
1731 });
1732 }
1733
1734 #[test]
1735 fn layout_callback_rebinds_when_node_id_changes() {
1736 with_test_runtime(|| {
1737 let state = new_lazy_list_state();
1738 let first_node: cranpose_core::NodeId = 11;
1739 let second_node: cranpose_core::NodeId = 22;
1740
1741 let first_id = state
1742 .try_register_layout_callback(first_node, Rc::new(|| {}))
1743 .expect("first layout callback should register");
1744
1745 let second_id = state
1746 .try_register_layout_callback(second_node, Rc::new(|| {}))
1747 .expect("layout callback should rebind to a new node");
1748
1749 assert_ne!(first_id, second_id);
1750 });
1751 }
1752
1753 #[test]
1754 fn stale_layout_callback_disposer_cannot_remove_replaced_same_node_callback() {
1755 with_test_runtime(|| {
1756 let state = new_lazy_list_state();
1757 let node_id: cranpose_core::NodeId = 7;
1758 let first_hits = Rc::new(Cell::new(0u32));
1759 let second_hits = Rc::new(Cell::new(0u32));
1760
1761 let first_id = state
1762 .try_register_layout_callback(
1763 node_id,
1764 Rc::new({
1765 let first_hits = Rc::clone(&first_hits);
1766 move || first_hits.set(first_hits.get() + 1)
1767 }),
1768 )
1769 .expect("first layout callback should register");
1770
1771 let second_id = state
1772 .try_register_layout_callback(
1773 node_id,
1774 Rc::new({
1775 let second_hits = Rc::clone(&second_hits);
1776 move || second_hits.set(second_hits.get() + 1)
1777 }),
1778 )
1779 .expect("same-node registration should replace the active callback");
1780
1781 assert_ne!(first_id, second_id);
1782
1783 state.remove_invalidate_callback(first_id);
1784 state.dispatch_scroll_delta(-12.0);
1785
1786 assert_eq!(
1787 first_hits.get(),
1788 0,
1789 "replaced callback should not be invoked after removal",
1790 );
1791 assert_eq!(
1792 second_hits.get(),
1793 1,
1794 "active callback should survive stale disposer cleanup",
1795 );
1796 });
1797 }
1798
1799 #[test]
1800 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1801 with_test_runtime(|| {
1802 let state = new_lazy_list_state();
1803 mark_scroll_bounds_known(&state);
1804 set_scroll_bounds(&state, false, true);
1805
1806 let consumed = state.dispatch_scroll_delta(-24.0);
1807
1808 assert_eq!(consumed, 0.0);
1809 assert_eq!(state.peek_scroll_delta(), 0.0);
1810 });
1811 }
1812
1813 #[test]
1814 fn equality_does_not_deref_released_inner_state() {
1815 let mut composition = Composition::new(MemoryApplier::new());
1816 let key = location_key(file!(), line!(), column!());
1817
1818 let mut first = None;
1819 composition
1820 .render(key, || {
1821 first = Some(super::rememberLazyListState());
1822 })
1823 .expect("initial render");
1824 let first = first.expect("first lazy state");
1825
1826 composition
1827 .render(key, || {})
1828 .expect("dispose first lazy state");
1829 assert!(
1830 !first.inner.is_alive(),
1831 "expected first lazy state to be released after disposal"
1832 );
1833
1834 let mut second = None;
1835 composition
1836 .render(key, || {
1837 second = Some(super::rememberLazyListState());
1838 })
1839 .expect("second render");
1840 let second = second.expect("second lazy state");
1841
1842 assert!(
1843 first != second,
1844 "released lazy state handle must compare by identity without panicking"
1845 );
1846 }
1847
1848 #[test]
1849 fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1850 let mut composition = Composition::new(MemoryApplier::new());
1851 let key = location_key(file!(), line!(), column!());
1852
1853 let mut released = None;
1854 composition
1855 .render(key, || {
1856 released = Some(super::rememberLazyListState());
1857 })
1858 .expect("initial render");
1859 let released = released.expect("lazy list state");
1860
1861 composition
1862 .render(key, || {})
1863 .expect("dispose lazy list state");
1864 assert!(
1865 !released.inner.is_alive(),
1866 "expected lazy list state to be released after disposal"
1867 );
1868
1869 assert_eq!(released.first_visible_item_index(), 0);
1870 assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1871 assert_eq!(released.nearest_range(), 0..0);
1872 assert_eq!(
1873 released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1874 0
1875 );
1876 released.update_scroll_position(3, 12.0);
1877 released.update_scroll_position_with_key(3, 12.0, 42);
1878 released.update_scroll_bounds();
1879 }
1880
1881 #[test]
1882 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1883 with_test_runtime(|| {
1884 let state = new_lazy_list_state();
1885 mark_scroll_bounds_known(&state);
1886 enable_bidirectional_scroll(&state);
1887 state.dispatch_scroll_delta(-300.0);
1888 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1889
1890 set_scroll_bounds(&state, false, true);
1891
1892 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1893 assert_eq!(blocked_consumed, 0.0);
1894 assert_eq!(state.peek_scroll_delta(), 0.0);
1895
1896 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1897 assert_eq!(reverse_consumed, 12.0);
1898 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1899 });
1900 }
1901
1902 #[test]
1903 fn negative_scroll_delta_prefetches_forward_items() {
1904 with_test_runtime(|| {
1905 let state = new_lazy_list_state();
1906 state.dispatch_scroll_delta(-24.0);
1907 state.record_scroll_direction(state.peek_scroll_delta());
1908 state.update_prefetch_queue(10, 15, 100);
1909
1910 assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1911 });
1912 }
1913}