1use std::{cell::RefCell, cmp::Reverse, collections::BinaryHeap, rc::Rc};
13
14use cranpose_core::{MutableState, NodeId, StateId};
15use cranpose_macros::composable;
16
17use super::{
18 diagnostics,
19 nearest_range::NearestRangeState,
20 prefetch::{PrefetchScheduler, PrefetchStrategy},
21};
22
23const MAX_PENDING_SCROLL_DELTA: f32 = 2000.0;
24const ITEM_SIZE_CACHE_CAPACITY: usize = 8192;
25
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub(crate) struct LazyListMeasureStateSnapshot {
28 pub(crate) first_visible_item_index: usize,
29 pub(crate) first_visible_item_scroll_offset: f32,
30 pub(crate) pending_scroll_delta: f32,
31 pub(crate) pending_scroll_to: Option<(usize, f32)>,
32 pub(crate) average_item_size: f32,
33}
34
35#[derive(Clone, Debug, Default, PartialEq)]
39pub struct LazyLayoutStats {
40 pub items_in_use: usize,
42
43 pub items_in_pool: usize,
45
46 pub total_composed: usize,
48
49 pub reuse_count: usize,
51}
52
53#[derive(Clone, Copy)]
61pub struct LazyListScrollPosition {
62 index: MutableState<usize>,
63 scroll_offset: MutableState<f32>,
64 inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
65}
66
67struct ScrollPositionInner {
69 current_index: usize,
70 current_scroll_offset: f32,
71 last_known_first_item_key: Option<u64>,
72 nearest_range_state: NearestRangeState,
73}
74
75impl LazyListScrollPosition {
76 fn is_alive(&self) -> bool {
77 self.index.is_alive() && self.scroll_offset.is_alive() && self.inner.is_alive()
78 }
79
80 fn current_index(&self) -> usize {
81 self.inner
82 .try_with(|rc| rc.borrow().current_index)
83 .unwrap_or(0)
84 }
85
86 fn current_scroll_offset(&self) -> f32 {
87 self.inner
88 .try_with(|rc| rc.borrow().current_scroll_offset)
89 .unwrap_or(0.0)
90 }
91
92 pub fn index(&self) -> usize {
94 if !self.index.is_alive() || !self.inner.is_alive() {
95 return 0;
96 }
97 self.index.subscribe_current_scope_only();
98 self.current_index()
99 }
100
101 pub fn scroll_offset(&self) -> f32 {
103 if !self.scroll_offset.is_alive() || !self.inner.is_alive() {
104 return 0.0;
105 }
106 self.scroll_offset.subscribe_current_scope_only();
107 self.current_scroll_offset()
108 }
109
110 pub(crate) fn update_from_measure_result(
111 &self,
112 first_visible_index: usize,
113 first_visible_scroll_offset: f32,
114 first_visible_item_key: Option<u64>,
115 ) {
116 if !self.is_alive() {
117 return;
118 }
119 self.inner.with(|rc| {
120 let mut inner = rc.borrow_mut();
121 inner.current_index = first_visible_index;
122 inner.current_scroll_offset = first_visible_scroll_offset;
123 inner.last_known_first_item_key = first_visible_item_key;
124 inner.nearest_range_state.update(first_visible_index);
125 });
126
127 if self.index.get_non_reactive() != first_visible_index {
128 self.index.set(first_visible_index);
129 }
130 if (self.scroll_offset.get_non_reactive() - first_visible_scroll_offset).abs() > 0.001 {
131 self.scroll_offset.set(first_visible_scroll_offset);
132 }
133 }
134
135 pub(crate) fn request_position_and_forget_last_known_key(
136 &self,
137 index: usize,
138 scroll_offset: f32,
139 ) {
140 if !self.is_alive() {
141 return;
142 }
143 self.inner.with(|rc| {
144 let mut inner = rc.borrow_mut();
145 inner.current_index = index;
146 inner.current_scroll_offset = scroll_offset;
147 inner.last_known_first_item_key = None;
148 inner.nearest_range_state.update(index);
149 });
150
151 if self.index.get_non_reactive() != index {
152 self.index.set(index);
153 }
154 if (self.scroll_offset.get_non_reactive() - scroll_offset).abs() > 0.001 {
155 self.scroll_offset.set(scroll_offset);
156 }
157 }
158
159 pub(crate) fn update_if_first_item_moved<F>(
160 &self,
161 new_item_count: usize,
162 find_by_key: F,
163 ) -> usize
164 where
165 F: Fn(u64) -> Option<usize>,
166 {
167 if !self.index.is_alive() || !self.inner.is_alive() {
168 return 0;
169 }
170
171 let current_index = self.current_index();
172 let last_key = self
173 .inner
174 .try_with(|rc| rc.borrow().last_known_first_item_key)
175 .flatten();
176
177 let new_index = match last_key {
178 None => current_index.min(new_item_count.saturating_sub(1)),
179 Some(key) => find_by_key(key)
180 .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
181 };
182
183 if current_index != new_index {
184 self.inner.with(|rc| {
185 let mut inner = rc.borrow_mut();
186 inner.current_index = new_index;
187 inner.nearest_range_state.update(new_index);
188 });
189 self.index.set(new_index);
190 }
191 new_index
192 }
193
194 pub fn nearest_range(&self) -> std::ops::Range<usize> {
196 self.inner
197 .try_with(|rc| rc.borrow().nearest_range_state.range())
198 .unwrap_or(0..0)
199 }
200}
201
202#[derive(Clone, Copy)]
233pub struct LazyListState {
234 scroll_position: LazyListScrollPosition,
235 can_scroll_forward_state: MutableState<bool>,
236 can_scroll_backward_state: MutableState<bool>,
237 stats_state: MutableState<LazyLayoutStats>,
238 inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
239}
240
241impl PartialEq for LazyListState {
242 fn eq(&self, other: &Self) -> bool {
243 self.inner == other.inner
244 }
245}
246
247#[derive(Clone, Copy)]
248struct CachedItemSize {
249 size: f32,
250 last_used: u64,
251}
252
253struct LazyListStateInner {
255 scroll_to_be_consumed: f32,
256
257 pending_scroll_to_index: Option<(usize, f32)>,
258
259 layout_info: LazyListLayoutInfo,
260 current_can_scroll_forward: bool,
261 current_can_scroll_backward: bool,
262
263 invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
264 next_callback_id: u64,
265
266 layout_invalidation_callback_id: Option<u64>,
267 layout_invalidation_node_id: Option<NodeId>,
268
269 total_composed: usize,
270 reuse_count: usize,
271
272 item_size_cache: std::collections::HashMap<usize, CachedItemSize>,
273 item_size_eviction_queue: BinaryHeap<Reverse<(u64, usize)>>,
274 item_size_clock: u64,
275
276 average_item_size: f32,
277 total_measured_items: usize,
278 next_measure_cycle_id: u64,
279 next_item_measure_pass_id: u64,
280
281 prefetch_scheduler: PrefetchScheduler,
282
283 prefetch_strategy: PrefetchStrategy,
284
285 last_scroll_direction: f32,
286}
287
288#[composable]
303#[track_caller]
304pub fn rememberLazyListState() -> LazyListState {
305 rememberLazyListStateWithPosition(0, 0.0)
306}
307
308#[composable]
312pub fn rememberLazyListStateWithPosition(
313 initial_first_visible_item_index: usize,
314 initial_first_visible_item_scroll_offset: f32,
315) -> LazyListState {
316 cranpose_core::remember(move || {
317 LazyListState::new(
318 initial_first_visible_item_index,
319 initial_first_visible_item_scroll_offset,
320 )
321 })
322 .with(|state| *state)
323}
324
325impl LazyListState {
326 pub fn new(
332 initial_first_visible_item_index: usize,
333 initial_first_visible_item_scroll_offset: f32,
334 ) -> Self {
335 LazyListState {
336 scroll_position: LazyListScrollPosition {
337 index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
338 scroll_offset: cranpose_core::mutableStateOf(
339 initial_first_visible_item_scroll_offset,
340 ),
341 inner: cranpose_core::mutableStateOfNeverEqual(Rc::new(RefCell::new(
342 ScrollPositionInner {
343 current_index: initial_first_visible_item_index,
344 current_scroll_offset: initial_first_visible_item_scroll_offset,
345 last_known_first_item_key: None,
346 nearest_range_state: NearestRangeState::new(
347 initial_first_visible_item_index,
348 ),
349 },
350 ))),
351 },
352 can_scroll_forward_state: cranpose_core::mutableStateOf(false),
353 can_scroll_backward_state: cranpose_core::mutableStateOf(false),
354 stats_state: cranpose_core::mutableStateOf(LazyLayoutStats::default()),
355 inner: cranpose_core::mutableStateOfNeverEqual(Rc::new(RefCell::new(
356 LazyListStateInner {
357 scroll_to_be_consumed: 0.0,
358 pending_scroll_to_index: None,
359 layout_info: LazyListLayoutInfo::default(),
360 current_can_scroll_forward: false,
361 current_can_scroll_backward: false,
362 invalidate_callbacks: Vec::new(),
363 next_callback_id: 1,
364 layout_invalidation_callback_id: None,
365 layout_invalidation_node_id: None,
366 total_composed: 0,
367 reuse_count: 0,
368 item_size_cache: std::collections::HashMap::new(),
369 item_size_eviction_queue: BinaryHeap::new(),
370 item_size_clock: 0,
371 average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
372 total_measured_items: 0,
373 next_measure_cycle_id: 1,
374 next_item_measure_pass_id: 1,
375 prefetch_scheduler: PrefetchScheduler::new(),
376 prefetch_strategy: PrefetchStrategy::default(),
377 last_scroll_direction: 0.0,
378 },
379 ))),
380 }
381 }
382
383 pub fn inner_ptr(&self) -> *const () {
388 self.inner
389 .try_with(|rc| Rc::as_ptr(rc) as *const ())
390 .unwrap_or(std::ptr::null())
391 }
392
393 pub fn first_visible_item_index(&self) -> usize {
398 self.scroll_position.index()
399 }
400
401 pub fn total_items_count(&self) -> usize {
403 self.inner
404 .with(|rc| rc.borrow().layout_info.total_items_count)
405 }
406
407 pub fn first_visible_item_index_non_reactive(&self) -> usize {
412 self.scroll_position.current_index()
413 }
414
415 pub fn first_visible_item_scroll_offset(&self) -> f32 {
421 self.scroll_position.scroll_offset()
422 }
423
424 pub fn first_visible_item_scroll_offset_non_reactive(&self) -> f32 {
429 self.scroll_position.current_scroll_offset()
430 }
431
432 #[doc(hidden)]
433 pub fn reactive_state_ids(&self) -> [StateId; 5] {
434 [
435 self.scroll_position.index.runtime_state_id(),
436 self.scroll_position.scroll_offset.runtime_state_id(),
437 self.can_scroll_forward_state.runtime_state_id(),
438 self.can_scroll_backward_state.runtime_state_id(),
439 self.stats_state.runtime_state_id(),
440 ]
441 }
442
443 pub fn layout_info(&self) -> LazyListLayoutInfo {
445 self.inner
446 .try_with(|rc| rc.borrow().layout_info.clone())
447 .unwrap_or_default()
448 }
449
450 pub fn stats(&self) -> LazyLayoutStats {
456 if !self.stats_state.is_alive() || !self.inner.is_alive() {
457 return LazyLayoutStats::default();
458 }
459 let reactive = self.stats_state.get();
460 let (total_composed, reuse_count) = self.inner.with(|rc| {
461 let inner = rc.borrow();
462 (inner.total_composed, inner.reuse_count)
463 });
464 LazyLayoutStats {
465 items_in_use: reactive.items_in_use,
466 items_in_pool: reactive.items_in_pool,
467 total_composed,
468 reuse_count,
469 }
470 }
471
472 pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
477 if !self.stats_state.is_alive() || !self.inner.is_alive() {
478 return;
479 }
480
481 let current = self.stats_state.get_non_reactive();
482
483 let should_update_reactive = if items_in_use > current.items_in_use {
484 true
485 } else if items_in_use < current.items_in_use {
486 current.items_in_use - items_in_use > 1
487 } else {
488 false
489 };
490
491 if should_update_reactive {
492 self.stats_state.set(LazyLayoutStats {
493 items_in_use,
494 items_in_pool,
495 ..current
496 });
497 }
498 }
499
500 pub fn record_composition(&self, was_reused: bool) {
505 if !self.inner.is_alive() {
506 return;
507 }
508 self.inner.with(|rc| {
509 let mut inner = rc.borrow_mut();
510 inner.total_composed += 1;
511 if was_reused {
512 inner.reuse_count += 1;
513 }
514 });
515 }
516
517 pub fn record_scroll_direction(&self, delta: f32) {
523 if delta.abs() > 0.001 {
524 if !self.inner.is_alive() {
525 return;
526 }
527 self.inner.with(|rc| {
528 rc.borrow_mut().last_scroll_direction = -delta.signum();
529 });
530 }
531 }
532
533 pub fn update_prefetch_queue(
536 &self,
537 first_visible_index: usize,
538 last_visible_index: usize,
539 total_items: usize,
540 ) {
541 if !self.inner.is_alive() {
542 return;
543 }
544 self.inner.with(|rc| {
545 let mut inner = rc.borrow_mut();
546 let direction = inner.last_scroll_direction;
547 let strategy = inner.prefetch_strategy.clone();
548 inner.prefetch_scheduler.update(
549 first_visible_index,
550 last_visible_index,
551 total_items,
552 direction,
553 &strategy,
554 );
555 });
556 }
557
558 pub fn take_prefetch_indices(&self) -> Vec<usize> {
561 self.inner
562 .try_with(|rc| {
563 let mut inner = rc.borrow_mut();
564 let mut indices = Vec::new();
565 while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
566 indices.push(idx);
567 }
568 indices
569 })
570 .unwrap_or_default()
571 }
572
573 pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
579 if !self.inner.is_alive() {
580 return;
581 }
582 if diagnostics::telemetry_enabled() {
583 log::warn!(
584 "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
585 index,
586 scroll_offset
587 );
588 }
589 self.inner.with(|rc| {
590 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
591 });
592
593 self.scroll_position
594 .request_position_and_forget_last_known_key(index, scroll_offset);
595
596 self.invalidate();
597 }
598
599 pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
610 if !self.inner.is_alive() {
611 return 0.0;
612 }
613 let has_scroll_bounds = self
614 .inner
615 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
616 let pushing_forward = delta < -0.001;
617 let pushing_backward = delta > 0.001;
618 let can_scroll_forward =
619 self.can_scroll_forward_state.is_alive() && self.can_scroll_forward_non_reactive();
620 let can_scroll_backward =
621 self.can_scroll_backward_state.is_alive() && self.can_scroll_backward_non_reactive();
622 let blocked_by_bounds = has_scroll_bounds
623 && ((pushing_forward && !can_scroll_forward)
624 || (pushing_backward && !can_scroll_backward));
625
626 if blocked_by_bounds {
627 let should_invalidate = self.inner.with(|rc| {
628 let mut inner = rc.borrow_mut();
629 let pending_before = inner.scroll_to_be_consumed;
630 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
631 inner.scroll_to_be_consumed = 0.0;
632 }
633 if diagnostics::telemetry_enabled() {
634 log::warn!(
635 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
636 delta,
637 pending_before,
638 inner.scroll_to_be_consumed
639 );
640 }
641 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
642 });
643 if should_invalidate {
644 self.invalidate();
645 }
646 return 0.0;
647 }
648
649 let mut accepted_delta = 0.0f32;
650 let should_invalidate = self.inner.with(|rc| {
651 let mut inner = rc.borrow_mut();
652 accepted_delta = delta;
653 let pending_before = inner.scroll_to_be_consumed;
654 let pending = inner.scroll_to_be_consumed;
655 let reverse_input = pending.abs() > 0.001
656 && delta.abs() > 0.001
657 && pending.signum() != delta.signum();
658 if reverse_input {
659 if diagnostics::telemetry_enabled() {
660 log::warn!(
661 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
662 pending,
663 delta
664 );
665 }
666 inner.scroll_to_be_consumed = delta;
667 } else {
668 inner.scroll_to_be_consumed += delta;
669 }
670 inner.scroll_to_be_consumed = inner
671 .scroll_to_be_consumed
672 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
673 if diagnostics::telemetry_enabled() {
674 log::warn!(
675 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
676 delta,
677 inner.scroll_to_be_consumed
678 );
679 }
680 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
681 });
682 if should_invalidate {
683 self.invalidate();
684 }
685 accepted_delta
686 }
687
688 pub fn peek_scroll_delta(&self) -> f32 {
695 self.inner
696 .try_with(|rc| rc.borrow().scroll_to_be_consumed)
697 .unwrap_or(0.0)
698 }
699
700 pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
701 let (pending_scroll_delta, pending_scroll_to, average_item_size) = self
702 .inner
703 .try_with(|rc| {
704 let mut inner = rc.borrow_mut();
705 let pending_scroll_to = inner.pending_scroll_to_index.take();
706 let pending_scroll_delta = inner.scroll_to_be_consumed;
707 inner.scroll_to_be_consumed = 0.0;
708 (
709 pending_scroll_delta,
710 pending_scroll_to,
711 inner.average_item_size,
712 )
713 })
714 .unwrap_or((0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
715
716 LazyListMeasureStateSnapshot {
717 first_visible_item_index: self.scroll_position.current_index(),
718 first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
719 pending_scroll_delta,
720 pending_scroll_to,
721 average_item_size,
722 }
723 }
724
725 pub(crate) fn next_measure_cycle_id(&self) -> u64 {
726 self.inner
727 .try_with(|rc| {
728 let mut inner = rc.borrow_mut();
729 let id = inner.next_measure_cycle_id;
730 inner.next_measure_cycle_id = inner.next_measure_cycle_id.saturating_add(1);
731 id
732 })
733 .unwrap_or(0)
734 }
735
736 pub(crate) fn next_item_measure_pass_id(&self) -> u64 {
737 self.inner
738 .try_with(|rc| {
739 let mut inner = rc.borrow_mut();
740 let id = inner.next_item_measure_pass_id;
741 inner.next_item_measure_pass_id = inner.next_item_measure_pass_id.saturating_add(1);
742 id
743 })
744 .unwrap_or(0)
745 }
746
747 fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
748 inner.total_measured_items += 1;
749 let n = inner.total_measured_items as f32;
750 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
751 }
752
753 fn next_item_size_cache_tick(inner: &mut LazyListStateInner) -> u64 {
754 inner.item_size_clock = inner.item_size_clock.saturating_add(1);
755 inner.item_size_clock
756 }
757
758 fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
759 use std::collections::hash_map::Entry;
760
761 let tick = Self::next_item_size_cache_tick(inner);
762 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
763 entry.insert(CachedItemSize {
764 size,
765 last_used: tick,
766 });
767 Self::push_item_size_cache_ticket(inner, tick, index);
768 return false;
769 }
770
771 if inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
772 Self::evict_one_item_size(inner);
773 }
774
775 inner.item_size_cache.insert(
776 index,
777 CachedItemSize {
778 size,
779 last_used: tick,
780 },
781 );
782 Self::push_item_size_cache_ticket(inner, tick, index);
783 true
784 }
785
786 fn push_item_size_cache_ticket(inner: &mut LazyListStateInner, last_used: u64, index: usize) {
787 inner
788 .item_size_eviction_queue
789 .push(Reverse((last_used, index)));
790 let compact_limit = inner
791 .item_size_cache
792 .len()
793 .saturating_mul(4)
794 .max(ITEM_SIZE_CACHE_CAPACITY);
795 if inner.item_size_eviction_queue.len() > compact_limit {
796 Self::rebuild_item_size_eviction_queue(inner);
797 }
798 }
799
800 fn rebuild_item_size_eviction_queue(inner: &mut LazyListStateInner) {
801 inner.item_size_eviction_queue = inner
802 .item_size_cache
803 .iter()
804 .map(|(index, item)| Reverse((item.last_used, *index)))
805 .collect();
806 }
807
808 fn evict_one_item_size(inner: &mut LazyListStateInner) {
809 while let Some(Reverse((last_used, index))) = inner.item_size_eviction_queue.pop() {
810 let Some(current) = inner.item_size_cache.get(&index) else {
811 continue;
812 };
813 if current.last_used != last_used {
814 continue;
815 }
816 inner.item_size_cache.remove(&index);
817 return;
818 }
819 }
820
821 pub fn cache_item_size(&self, index: usize, size: f32) {
823 if !self.inner.is_alive() {
824 return;
825 }
826 self.inner.with(|rc| {
827 let mut inner = rc.borrow_mut();
828 if Self::insert_item_size(&mut inner, index, size) {
829 Self::record_item_size_sample(&mut inner, size);
830 }
831 });
832 }
833
834 pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
836 where
837 I: IntoIterator<Item = (usize, f32)>,
838 {
839 if !self.inner.is_alive() {
840 return super::DEFAULT_ITEM_SIZE_ESTIMATE;
841 }
842
843 self.inner.with(|rc| {
844 let mut inner = rc.borrow_mut();
845 for (index, size) in sizes {
846 if Self::insert_item_size(&mut inner, index, size) {
847 Self::record_item_size_sample(&mut inner, size);
848 }
849 }
850 inner.average_item_size
851 })
852 }
853
854 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
856 self.inner
857 .try_with(|rc| {
858 let mut inner = rc.borrow_mut();
859 let tick = Self::next_item_size_cache_tick(&mut inner);
860 let item = inner.item_size_cache.get_mut(&index)?;
861 item.last_used = tick;
862 let size = item.size;
863 Self::push_item_size_cache_ticket(&mut inner, tick, index);
864 Some(size)
865 })
866 .flatten()
867 }
868
869 pub fn average_item_size(&self) -> f32 {
871 self.inner
872 .try_with(|rc| rc.borrow().average_item_size)
873 .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
874 }
875
876 pub fn nearest_range(&self) -> std::ops::Range<usize> {
878 self.scroll_position.nearest_range()
879 }
880
881 pub(crate) fn update_scroll_position(
882 &self,
883 first_visible_item_index: usize,
884 first_visible_item_scroll_offset: f32,
885 ) {
886 self.scroll_position.update_from_measure_result(
887 first_visible_item_index,
888 first_visible_item_scroll_offset,
889 None,
890 );
891 }
892
893 pub(crate) fn update_scroll_position_with_key(
894 &self,
895 first_visible_item_index: usize,
896 first_visible_item_scroll_offset: f32,
897 first_visible_item_key: u64,
898 ) {
899 self.scroll_position.update_from_measure_result(
900 first_visible_item_index,
901 first_visible_item_scroll_offset,
902 Some(first_visible_item_key),
903 );
904 }
905
906 pub fn update_scroll_position_if_item_moved<F>(
914 &self,
915 new_item_count: usize,
916 get_index_by_key: F,
917 ) -> usize
918 where
919 F: Fn(u64) -> Option<usize>,
920 {
921 self.scroll_position
922 .update_if_first_item_moved(new_item_count, get_index_by_key)
923 }
924
925 pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
926 if !self.inner.is_alive() {
927 return;
928 }
929 self.inner.with(|rc| {
930 let mut inner = rc.borrow_mut();
931 info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
932 inner.layout_info = info;
933 });
934 }
935
936 pub fn can_scroll_forward(&self) -> bool {
941 if !self.can_scroll_forward_state.is_alive() {
942 return false;
943 }
944 self.can_scroll_forward_state.subscribe_current_scope_only();
945 self.can_scroll_forward_non_reactive()
946 }
947
948 pub fn can_scroll_forward_non_reactive(&self) -> bool {
950 if !self.can_scroll_forward_state.is_alive() {
951 return false;
952 }
953 self.inner
954 .try_with(|rc| rc.borrow().current_can_scroll_forward)
955 .unwrap_or(false)
956 }
957
958 pub fn can_scroll_backward(&self) -> bool {
963 if !self.can_scroll_backward_state.is_alive() {
964 return false;
965 }
966 self.can_scroll_backward_state
967 .subscribe_current_scope_only();
968 self.can_scroll_backward_non_reactive()
969 }
970
971 pub fn can_scroll_backward_non_reactive(&self) -> bool {
973 if !self.can_scroll_backward_state.is_alive() {
974 return false;
975 }
976 self.inner
977 .try_with(|rc| rc.borrow().current_can_scroll_backward)
978 .unwrap_or(false)
979 }
980
981 pub(crate) fn update_scroll_bounds(&self) {
982 if !self.inner.is_alive()
983 || !self.can_scroll_forward_state.is_alive()
984 || !self.can_scroll_backward_state.is_alive()
985 {
986 return;
987 }
988 let can_forward = self.inner.with(|rc| {
989 let inner = rc.borrow();
990 let info = &inner.layout_info;
991 let viewport_end = info.viewport_size - info.after_content_padding;
992 if let Some(last_visible) = info.visible_items_info.last() {
993 last_visible.index < info.total_items_count.saturating_sub(1)
994 || (last_visible.offset + last_visible.size) > viewport_end
995 } else {
996 false
997 }
998 });
999
1000 let can_backward = self.scroll_position.current_index() > 0
1001 || self.scroll_position.current_scroll_offset() > 0.0;
1002
1003 self.inner.with(|rc| {
1004 let mut inner = rc.borrow_mut();
1005 inner.current_can_scroll_forward = can_forward;
1006 inner.current_can_scroll_backward = can_backward;
1007 });
1008
1009 if self.can_scroll_forward_state.get_non_reactive() != can_forward {
1010 self.can_scroll_forward_state.set(can_forward);
1011 }
1012 if self.can_scroll_backward_state.get_non_reactive() != can_backward {
1013 self.can_scroll_backward_state.set(can_backward);
1014 }
1015 }
1016
1017 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
1019 if !self.inner.is_alive() {
1020 return 0;
1021 }
1022 self.inner.with(|rc| {
1023 let mut inner = rc.borrow_mut();
1024 let id = inner.next_callback_id;
1025 inner.next_callback_id += 1;
1026 inner.invalidate_callbacks.push((id, callback));
1027 id
1028 })
1029 }
1030
1031 pub fn try_register_layout_callback(
1039 &self,
1040 node_id: NodeId,
1041 callback: Rc<dyn Fn()>,
1042 ) -> Option<u64> {
1043 if !self.inner.is_alive() {
1044 return None;
1045 }
1046 self.inner.with(|rc| {
1047 let mut inner = rc.borrow_mut();
1048 if let Some(existing_id) = inner.layout_invalidation_callback_id {
1049 inner
1050 .invalidate_callbacks
1051 .retain(|(cb_id, _)| *cb_id != existing_id);
1052 }
1053 let id = inner.next_callback_id;
1054 inner.next_callback_id += 1;
1055 inner.invalidate_callbacks.push((id, callback));
1056 inner.layout_invalidation_callback_id = Some(id);
1057 inner.layout_invalidation_node_id = Some(node_id);
1058 Some(id)
1059 })
1060 }
1061
1062 pub fn remove_invalidate_callback(&self, id: u64) {
1064 if !self.inner.is_alive() {
1065 return;
1066 }
1067 self.inner.with(|rc| {
1068 let mut inner = rc.borrow_mut();
1069 inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1070 if inner.layout_invalidation_callback_id == Some(id) {
1071 inner.layout_invalidation_callback_id = None;
1072 inner.layout_invalidation_node_id = None;
1073 }
1074 });
1075 }
1076
1077 fn invalidate(&self) {
1078 if !self.inner.is_alive() {
1079 return;
1080 }
1081 let callbacks: Vec<_> = self.inner.with(|rc| {
1082 rc.borrow()
1083 .invalidate_callbacks
1084 .iter()
1085 .map(|(_, cb)| Rc::clone(cb))
1086 .collect()
1087 });
1088
1089 for callback in callbacks {
1090 callback();
1091 }
1092 }
1093}
1094
1095#[derive(Clone, Default, Debug)]
1097pub struct LazyListLayoutInfo {
1098 pub visible_items_info: Vec<LazyListItemInfo>,
1100
1101 pub total_items_count: usize,
1103
1104 pub raw_viewport_size: f32,
1106
1107 pub is_infinite_viewport: bool,
1109
1110 pub viewport_size: f32,
1112
1113 pub viewport_start_offset: f32,
1115
1116 pub viewport_end_offset: f32,
1118
1119 pub before_content_padding: f32,
1121
1122 pub after_content_padding: f32,
1124
1125 pub snap_anchor_offset: f32,
1127
1128 pub reverse_layout: bool,
1130}
1131
1132#[derive(Clone, Debug)]
1134pub struct LazyListItemInfo {
1135 pub index: usize,
1137
1138 pub key: u64,
1140
1141 pub offset: f32,
1143
1144 pub size: f32,
1146}
1147
1148fn continuous_snap_anchor_offset(
1149 previous: &LazyListLayoutInfo,
1150 current: &LazyListLayoutInfo,
1151) -> f32 {
1152 let Some(first_current) = current.visible_items_info.first() else {
1153 return 0.0;
1154 };
1155
1156 for current_item in ¤t.visible_items_info {
1157 if let Some(previous_item) = previous
1158 .visible_items_info
1159 .iter()
1160 .find(|item| item.key == current_item.key)
1161 {
1162 let previous_offset = snap_anchor_item_offset(previous, previous_item);
1163 let current_offset = snap_anchor_item_offset(current, current_item);
1164 return previous.snap_anchor_offset + current_offset - previous_offset;
1165 }
1166 }
1167
1168 snap_anchor_item_offset(current, first_current)
1169}
1170
1171fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1172 if info.reverse_layout {
1173 info.viewport_size - item.offset - item.size
1174 } else {
1175 item.offset
1176 }
1177}
1178
1179#[cfg(test)]
1180pub mod test_helpers {
1181 use std::sync::Arc;
1182
1183 use cranpose_core::{DefaultScheduler, Runtime};
1184
1185 use super::*;
1186
1187 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1188 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1189 f()
1190 }
1191
1192 pub fn new_lazy_list_state() -> LazyListState {
1193 new_lazy_list_state_with_position(0, 0.0)
1194 }
1195
1196 pub fn new_lazy_list_state_with_position(
1199 initial_first_visible_item_index: usize,
1200 initial_first_visible_item_scroll_offset: f32,
1201 ) -> LazyListState {
1202 LazyListState::new(
1203 initial_first_visible_item_index,
1204 initial_first_visible_item_scroll_offset,
1205 )
1206 }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use std::{cell::Cell, rc::Rc};
1212
1213 use cranpose_core::{Composition, MemoryApplier, location_key};
1214
1215 use super::{
1216 LazyListItemInfo, LazyListLayoutInfo, LazyListState,
1217 test_helpers::{new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime},
1218 };
1219
1220 fn set_scroll_bounds(state: &LazyListState, can_forward: bool, can_backward: bool) {
1221 state.can_scroll_forward_state.set(can_forward);
1222 state.can_scroll_backward_state.set(can_backward);
1223 state.inner.with(|rc| {
1224 let mut inner = rc.borrow_mut();
1225 inner.current_can_scroll_forward = can_forward;
1226 inner.current_can_scroll_backward = can_backward;
1227 });
1228 }
1229
1230 fn enable_bidirectional_scroll(state: &LazyListState) {
1231 set_scroll_bounds(state, true, true);
1232 }
1233
1234 fn mark_scroll_bounds_known(state: &LazyListState) {
1235 state.update_layout_info(LazyListLayoutInfo {
1236 total_items_count: 10,
1237 ..Default::default()
1238 });
1239 }
1240
1241 fn visible_item(index: usize, offset: f32, size: f32) -> LazyListItemInfo {
1242 LazyListItemInfo {
1243 index,
1244 key: index as u64,
1245 offset,
1246 size,
1247 }
1248 }
1249
1250 #[test]
1251 fn lazy_measure_telemetry_ids_are_state_owned() {
1252 with_test_runtime(|| {
1253 let first = new_lazy_list_state();
1254 let second = new_lazy_list_state();
1255
1256 assert_eq!(first.next_measure_cycle_id(), 1);
1257 assert_eq!(first.next_measure_cycle_id(), 2);
1258 assert_eq!(second.next_measure_cycle_id(), 1);
1259
1260 assert_eq!(first.next_item_measure_pass_id(), 1);
1261 assert_eq!(first.next_item_measure_pass_id(), 2);
1262 assert_eq!(second.next_item_measure_pass_id(), 1);
1263 });
1264 }
1265
1266 #[test]
1267 fn measure_result_updates_retained_and_reactive_scroll_position() {
1268 with_test_runtime(|| {
1269 let state = new_lazy_list_state();
1270
1271 state.update_scroll_position_with_key(8, 17.5, 123);
1272
1273 assert_eq!(state.scroll_position.index.get_non_reactive(), 8);
1274 assert!((state.scroll_position.scroll_offset.get_non_reactive() - 17.5).abs() < 0.001);
1275 assert_eq!(state.first_visible_item_index_non_reactive(), 8);
1276 assert!((state.first_visible_item_scroll_offset_non_reactive() - 17.5).abs() < 0.001);
1277 });
1278 }
1279
1280 #[test]
1281 fn update_scroll_bounds_updates_retained_and_reactive_capabilities() {
1282 with_test_runtime(|| {
1283 let state = new_lazy_list_state();
1284
1285 state.update_layout_info(LazyListLayoutInfo {
1286 visible_items_info: vec![visible_item(0, 0.0, 40.0), visible_item(1, 40.0, 40.0)],
1287 total_items_count: 10,
1288 viewport_size: 80.0,
1289 ..Default::default()
1290 });
1291 state.update_scroll_bounds();
1292
1293 assert!(state.can_scroll_forward_state.get_non_reactive());
1294 assert!(!state.can_scroll_backward_state.get_non_reactive());
1295 assert!(state.can_scroll_forward_non_reactive());
1296 assert!(!state.can_scroll_backward_non_reactive());
1297
1298 state.update_scroll_position(3, 2.0);
1299 state.update_scroll_bounds();
1300
1301 assert!(state.can_scroll_backward_state.get_non_reactive());
1302 assert!(state.can_scroll_backward_non_reactive());
1303 });
1304 }
1305
1306 #[test]
1307 fn layout_info_snap_anchor_tracks_common_item_offset_delta() {
1308 let previous = LazyListLayoutInfo {
1309 visible_items_info: vec![visible_item(15, -31.4, 30.0), visible_item(16, 4.6, 30.0)],
1310 snap_anchor_offset: -31.4,
1311 ..Default::default()
1312 };
1313 let current = LazyListLayoutInfo {
1314 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1315 ..Default::default()
1316 };
1317
1318 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1319
1320 assert!((anchor + 32.4).abs() <= 0.001);
1321 }
1322
1323 #[test]
1324 fn layout_info_snap_anchor_uses_reverse_visual_item_offset() {
1325 let previous = LazyListLayoutInfo {
1326 visible_items_info: vec![visible_item(15, 31.4, 30.0), visible_item(16, 67.4, 30.0)],
1327 snap_anchor_offset: 58.6,
1328 viewport_size: 120.0,
1329 reverse_layout: true,
1330 ..Default::default()
1331 };
1332 let current = LazyListLayoutInfo {
1333 visible_items_info: vec![visible_item(16, 68.4, 30.0), visible_item(17, 104.4, 30.0)],
1334 viewport_size: 120.0,
1335 reverse_layout: true,
1336 ..Default::default()
1337 };
1338
1339 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1340
1341 assert!((anchor - 57.6).abs() <= 0.001);
1342 }
1343
1344 #[test]
1345 fn update_layout_info_keeps_snap_anchor_continuous_when_first_visible_item_changes() {
1346 with_test_runtime(|| {
1347 let state = new_lazy_list_state();
1348 state.update_layout_info(LazyListLayoutInfo {
1349 visible_items_info: vec![
1350 visible_item(15, -31.4, 30.0),
1351 visible_item(16, 4.6, 30.0),
1352 ],
1353 ..Default::default()
1354 });
1355
1356 state.update_layout_info(LazyListLayoutInfo {
1357 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1358 ..Default::default()
1359 });
1360
1361 let info = state.layout_info();
1362 assert!((info.snap_anchor_offset + 32.4).abs() <= 0.001);
1363 });
1364 }
1365
1366 #[test]
1367 fn dispatch_scroll_delta_accumulates_same_direction() {
1368 with_test_runtime(|| {
1369 let state = new_lazy_list_state();
1370 enable_bidirectional_scroll(&state);
1371
1372 state.dispatch_scroll_delta(-12.0);
1373 state.dispatch_scroll_delta(-8.0);
1374
1375 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1376 let snapshot = state.begin_measure_pass();
1377 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1378 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1379 });
1380 }
1381
1382 #[test]
1383 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1384 with_test_runtime(|| {
1385 let state = new_lazy_list_state();
1386 enable_bidirectional_scroll(&state);
1387
1388 state.dispatch_scroll_delta(-120.0);
1389 state.dispatch_scroll_delta(-30.0);
1390 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1391
1392 state.dispatch_scroll_delta(18.0);
1393
1394 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1395 let snapshot = state.begin_measure_pass();
1396 assert!((snapshot.pending_scroll_delta - 18.0).abs() < 0.001);
1397 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1398 });
1399 }
1400
1401 #[test]
1402 fn dispatch_scroll_delta_clamps_pending_backlog() {
1403 with_test_runtime(|| {
1404 let state = new_lazy_list_state();
1405 enable_bidirectional_scroll(&state);
1406
1407 state.dispatch_scroll_delta(-1_500.0);
1408 state.dispatch_scroll_delta(-1_500.0);
1409 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1410
1411 state.dispatch_scroll_delta(3_000.0);
1412 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1413 });
1414 }
1415
1416 #[test]
1417 fn begin_measure_pass_consumes_large_pending_scroll_delta_coherently() {
1418 with_test_runtime(|| {
1419 let state = new_lazy_list_state();
1420 enable_bidirectional_scroll(&state);
1421 let invalidations = Rc::new(Cell::new(0u32));
1422 let invalidations_clone = Rc::clone(&invalidations);
1423 state.add_invalidate_callback(Rc::new(move || {
1424 invalidations_clone.set(invalidations_clone.get() + 1);
1425 }));
1426
1427 state.dispatch_scroll_delta(-1_000.0);
1428 assert!((state.peek_scroll_delta() + 1_000.0).abs() < 0.001);
1429
1430 let first = state.begin_measure_pass();
1431 assert!(
1432 (first.pending_scroll_delta + 1_000.0).abs() < 0.001,
1433 "first pass should consume the whole coherent scroll input"
1434 );
1435 assert!(
1436 state.peek_scroll_delta().abs() < 0.001,
1437 "measure pass should not retain a synthetic scroll backlog"
1438 );
1439 assert_eq!(
1440 invalidations.get(),
1441 1,
1442 "dispatch should request layout once; consuming scroll should not schedule follow-up frames"
1443 );
1444
1445 let second = state.begin_measure_pass();
1446 assert!(
1447 second.pending_scroll_delta.abs() < 0.001,
1448 "second pass should not receive synthetic remainder"
1449 );
1450 });
1451 }
1452
1453 #[test]
1454 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1455 with_test_runtime(|| {
1456 let state = new_lazy_list_state();
1457 enable_bidirectional_scroll(&state);
1458 let invalidations = Rc::new(Cell::new(0u32));
1459 let invalidations_clone = Rc::clone(&invalidations);
1460 state.add_invalidate_callback(Rc::new(move || {
1461 invalidations_clone.set(invalidations_clone.get() + 1);
1462 }));
1463
1464 state.dispatch_scroll_delta(-3_000.0);
1465 assert_eq!(invalidations.get(), 1);
1466 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1467
1468 state.dispatch_scroll_delta(-100.0);
1469 assert_eq!(invalidations.get(), 1);
1470
1471 state.dispatch_scroll_delta(100.0);
1472 assert_eq!(invalidations.get(), 2);
1473 });
1474 }
1475
1476 #[test]
1477 fn begin_measure_pass_takes_coherent_snapshot_and_consumes_pending_inputs() {
1478 with_test_runtime(|| {
1479 let state = new_lazy_list_state_with_position(3, 12.0);
1480 state.dispatch_scroll_delta(-20.0);
1481 state.inner.with(|rc| {
1482 rc.borrow_mut().pending_scroll_to_index = Some((8, 4.0));
1483 });
1484
1485 let snapshot = state.begin_measure_pass();
1486
1487 assert_eq!(snapshot.first_visible_item_index, 3);
1488 assert!((snapshot.first_visible_item_scroll_offset - 12.0).abs() < 0.001);
1489 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1490 assert_eq!(snapshot.pending_scroll_to, Some((8, 4.0)));
1491 assert_eq!(state.peek_scroll_delta(), 0.0);
1492 assert_eq!(state.begin_measure_pass().pending_scroll_to, None);
1493 });
1494 }
1495
1496 #[test]
1497 fn item_size_cache_refresh_keeps_recent_entry_and_evicts_oldest_live_entry() {
1498 with_test_runtime(|| {
1499 let state = new_lazy_list_state();
1500 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1501 state.cache_item_size(index, index as f32 + 10.0);
1502 }
1503
1504 state.cache_item_size(0, 999.0);
1505 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1506
1507 assert_eq!(state.get_cached_size(0), Some(999.0));
1508 assert_eq!(state.get_cached_size(1), None);
1509 assert_eq!(
1510 state.get_cached_size(super::ITEM_SIZE_CACHE_CAPACITY),
1511 Some(123.0),
1512 );
1513 });
1514 }
1515
1516 #[test]
1517 fn item_size_cache_read_promotes_entry_for_large_scroll_reuse() {
1518 with_test_runtime(|| {
1519 let state = new_lazy_list_state();
1520 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1521 state.cache_item_size(index, index as f32 + 10.0);
1522 }
1523
1524 assert_eq!(state.get_cached_size(0), Some(10.0));
1525 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1526
1527 assert_eq!(state.get_cached_size(0), Some(10.0));
1528 assert_eq!(state.get_cached_size(1), None);
1529 let cache_len = state
1530 .inner
1531 .try_with(|rc| rc.borrow().item_size_cache.len())
1532 .unwrap_or(0);
1533 assert_eq!(cache_len, super::ITEM_SIZE_CACHE_CAPACITY);
1534 });
1535 }
1536
1537 #[test]
1538 fn item_size_cache_promotion_queue_stays_bounded_under_hot_reuse() {
1539 with_test_runtime(|| {
1540 let state = new_lazy_list_state();
1541 state.cache_item_size(0, 32.0);
1542
1543 for _ in 0..super::ITEM_SIZE_CACHE_CAPACITY * 8 {
1544 assert_eq!(state.get_cached_size(0), Some(32.0));
1545 }
1546
1547 let (cache_len, queue_len) = state
1548 .inner
1549 .try_with(|rc| {
1550 let inner = rc.borrow();
1551 (
1552 inner.item_size_cache.len(),
1553 inner.item_size_eviction_queue.len(),
1554 )
1555 })
1556 .unwrap_or((0, 0));
1557 assert_eq!(cache_len, 1);
1558 assert!(
1559 queue_len <= super::ITEM_SIZE_CACHE_CAPACITY,
1560 "stale promotion tickets must be compacted, got {queue_len}"
1561 );
1562 });
1563 }
1564
1565 #[test]
1566 fn cache_item_sizes_updates_average_only_for_new_entries() {
1567 with_test_runtime(|| {
1568 let state = new_lazy_list_state();
1569
1570 let average = state.cache_item_sizes([(0, 10.0), (1, 20.0), (0, 12.0)]);
1571
1572 assert_eq!(state.get_cached_size(0), Some(12.0));
1573 assert_eq!(state.get_cached_size(1), Some(20.0));
1574 assert!((average - 15.0).abs() < 0.001);
1575 });
1576 }
1577
1578 #[test]
1579 fn layout_callback_can_be_registered_again_after_removal() {
1580 with_test_runtime(|| {
1581 let state = new_lazy_list_state();
1582 let first_node: cranpose_core::NodeId = 1;
1583 let second_node: cranpose_core::NodeId = 2;
1584
1585 let first_id = state
1586 .try_register_layout_callback(first_node, Rc::new(|| {}))
1587 .expect("first layout callback should register");
1588 let duplicate_id = state
1589 .try_register_layout_callback(first_node, Rc::new(|| {}))
1590 .expect("duplicate register should replace with a fresh callback id");
1591 assert_eq!(
1592 state
1593 .inner
1594 .with(|rc| rc.borrow().layout_invalidation_callback_id),
1595 Some(duplicate_id),
1596 "duplicate registration should become the active callback",
1597 );
1598 assert_ne!(
1599 first_id, duplicate_id,
1600 "duplicate registration should replace the old callback id",
1601 );
1602
1603 state.remove_invalidate_callback(first_id);
1604
1605 let second_id = state
1606 .try_register_layout_callback(second_node, Rc::new(|| {}))
1607 .expect("layout callback should register again after removal");
1608 assert_ne!(first_id, second_id);
1609 });
1610 }
1611
1612 #[test]
1613 fn layout_callback_rebinds_when_node_id_changes() {
1614 with_test_runtime(|| {
1615 let state = new_lazy_list_state();
1616 let first_node: cranpose_core::NodeId = 11;
1617 let second_node: cranpose_core::NodeId = 22;
1618
1619 let first_id = state
1620 .try_register_layout_callback(first_node, Rc::new(|| {}))
1621 .expect("first layout callback should register");
1622
1623 let second_id = state
1624 .try_register_layout_callback(second_node, Rc::new(|| {}))
1625 .expect("layout callback should rebind to a new node");
1626
1627 assert_ne!(first_id, second_id);
1628 });
1629 }
1630
1631 #[test]
1632 fn stale_layout_callback_disposer_cannot_remove_replaced_same_node_callback() {
1633 with_test_runtime(|| {
1634 let state = new_lazy_list_state();
1635 let node_id: cranpose_core::NodeId = 7;
1636 let first_hits = Rc::new(Cell::new(0u32));
1637 let second_hits = Rc::new(Cell::new(0u32));
1638
1639 let first_id = state
1640 .try_register_layout_callback(
1641 node_id,
1642 Rc::new({
1643 let first_hits = Rc::clone(&first_hits);
1644 move || first_hits.set(first_hits.get() + 1)
1645 }),
1646 )
1647 .expect("first layout callback should register");
1648
1649 let second_id = state
1650 .try_register_layout_callback(
1651 node_id,
1652 Rc::new({
1653 let second_hits = Rc::clone(&second_hits);
1654 move || second_hits.set(second_hits.get() + 1)
1655 }),
1656 )
1657 .expect("same-node registration should replace the active callback");
1658
1659 assert_ne!(first_id, second_id);
1660
1661 state.remove_invalidate_callback(first_id);
1662 state.dispatch_scroll_delta(-12.0);
1663
1664 assert_eq!(
1665 first_hits.get(),
1666 0,
1667 "replaced callback should not be invoked after removal",
1668 );
1669 assert_eq!(
1670 second_hits.get(),
1671 1,
1672 "active callback should survive stale disposer cleanup",
1673 );
1674 });
1675 }
1676
1677 #[test]
1678 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1679 with_test_runtime(|| {
1680 let state = new_lazy_list_state();
1681 mark_scroll_bounds_known(&state);
1682 set_scroll_bounds(&state, false, true);
1683
1684 let consumed = state.dispatch_scroll_delta(-24.0);
1685
1686 assert_eq!(consumed, 0.0);
1687 assert_eq!(state.peek_scroll_delta(), 0.0);
1688 });
1689 }
1690
1691 #[test]
1692 fn equality_does_not_deref_released_inner_state() {
1693 let mut composition = Composition::new(MemoryApplier::new());
1694 let key = location_key(file!(), line!(), column!());
1695
1696 let mut first = None;
1697 composition
1698 .render(key, || {
1699 first = Some(super::rememberLazyListState());
1700 })
1701 .expect("initial render");
1702 let first = first.expect("first lazy state");
1703
1704 composition
1705 .render(key, || {})
1706 .expect("dispose first lazy state");
1707 assert!(
1708 !first.inner.is_alive(),
1709 "expected first lazy state to be released after disposal"
1710 );
1711
1712 let mut second = None;
1713 composition
1714 .render(key, || {
1715 second = Some(super::rememberLazyListState());
1716 })
1717 .expect("second render");
1718 let second = second.expect("second lazy state");
1719
1720 assert!(
1721 first != second,
1722 "released lazy state handle must compare by identity without panicking"
1723 );
1724 }
1725
1726 #[test]
1727 fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1728 let mut composition = Composition::new(MemoryApplier::new());
1729 let key = location_key(file!(), line!(), column!());
1730
1731 let mut released = None;
1732 composition
1733 .render(key, || {
1734 released = Some(super::rememberLazyListState());
1735 })
1736 .expect("initial render");
1737 let released = released.expect("lazy list state");
1738
1739 composition
1740 .render(key, || {})
1741 .expect("dispose lazy list state");
1742 assert!(
1743 !released.inner.is_alive(),
1744 "expected lazy list state to be released after disposal"
1745 );
1746
1747 assert_eq!(released.first_visible_item_index(), 0);
1748 assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1749 assert_eq!(released.nearest_range(), 0..0);
1750 assert_eq!(
1751 released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1752 0
1753 );
1754 released.update_scroll_position(3, 12.0);
1755 released.update_scroll_position_with_key(3, 12.0, 42);
1756 released.update_scroll_bounds();
1757 }
1758
1759 #[test]
1760 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1761 with_test_runtime(|| {
1762 let state = new_lazy_list_state();
1763 mark_scroll_bounds_known(&state);
1764 enable_bidirectional_scroll(&state);
1765 state.dispatch_scroll_delta(-300.0);
1766 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1767
1768 set_scroll_bounds(&state, false, true);
1769
1770 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1771 assert_eq!(blocked_consumed, 0.0);
1772 assert_eq!(state.peek_scroll_delta(), 0.0);
1773
1774 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1775 assert_eq!(reverse_consumed, 12.0);
1776 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1777 });
1778 }
1779
1780 #[test]
1781 fn negative_scroll_delta_prefetches_forward_items() {
1782 with_test_runtime(|| {
1783 let state = new_lazy_list_state();
1784 state.dispatch_scroll_delta(-24.0);
1785 state.record_scroll_direction(state.peek_scroll_delta());
1786 state.update_prefetch_queue(10, 15, 100);
1787
1788 assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1789 });
1790 }
1791}