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