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