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