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={index} offset={scroll_offset:.2}"
585 );
586 }
587 self.inner.with(|rc| {
588 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
589 });
590
591 self.scroll_position
592 .request_position_and_forget_last_known_key(index, scroll_offset);
593
594 self.invalidate();
595 }
596
597 pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
608 if !self.inner.is_alive() {
609 return 0.0;
610 }
611 let has_scroll_bounds = self
612 .inner
613 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
614 let pushing_forward = delta < -0.001;
615 let pushing_backward = delta > 0.001;
616 let can_scroll_forward =
617 self.can_scroll_forward_state.is_alive() && self.can_scroll_forward_non_reactive();
618 let can_scroll_backward =
619 self.can_scroll_backward_state.is_alive() && self.can_scroll_backward_non_reactive();
620 let blocked_by_bounds = has_scroll_bounds
621 && ((pushing_forward && !can_scroll_forward)
622 || (pushing_backward && !can_scroll_backward));
623
624 if blocked_by_bounds {
625 let should_invalidate = self.inner.with(|rc| {
626 let mut inner = rc.borrow_mut();
627 let pending_before = inner.scroll_to_be_consumed;
628 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
629 inner.scroll_to_be_consumed = 0.0;
630 }
631 if diagnostics::telemetry_enabled() {
632 log::warn!(
633 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
634 delta,
635 pending_before,
636 inner.scroll_to_be_consumed
637 );
638 }
639 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
640 });
641 if should_invalidate {
642 self.invalidate();
643 }
644 return 0.0;
645 }
646
647 let mut accepted_delta = 0.0f32;
648 let should_invalidate = self.inner.with(|rc| {
649 let mut inner = rc.borrow_mut();
650 accepted_delta = delta;
651 let pending_before = inner.scroll_to_be_consumed;
652 let pending = inner.scroll_to_be_consumed;
653 let reverse_input = pending.abs() > 0.001
654 && delta.abs() > 0.001
655 && pending.signum() != delta.signum();
656 if reverse_input {
657 if diagnostics::telemetry_enabled() {
658 log::warn!(
659 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={pending:.2} new_delta={delta:.2}"
660 );
661 }
662 inner.scroll_to_be_consumed = delta;
663 } else {
664 inner.scroll_to_be_consumed += delta;
665 }
666 inner.scroll_to_be_consumed = inner
667 .scroll_to_be_consumed
668 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
669 if diagnostics::telemetry_enabled() {
670 log::warn!(
671 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
672 delta,
673 inner.scroll_to_be_consumed
674 );
675 }
676 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
677 });
678 if should_invalidate {
679 self.invalidate();
680 }
681 accepted_delta
682 }
683
684 pub fn peek_scroll_delta(&self) -> f32 {
691 self.inner
692 .try_with(|rc| rc.borrow().scroll_to_be_consumed)
693 .unwrap_or(0.0)
694 }
695
696 pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
697 let (pending_scroll_delta, pending_scroll_to, average_item_size) = self
698 .inner
699 .try_with(|rc| {
700 let mut inner = rc.borrow_mut();
701 let pending_scroll_to = inner.pending_scroll_to_index.take();
702 let pending_scroll_delta = inner.scroll_to_be_consumed;
703 inner.scroll_to_be_consumed = 0.0;
704 (
705 pending_scroll_delta,
706 pending_scroll_to,
707 inner.average_item_size,
708 )
709 })
710 .unwrap_or((0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
711
712 LazyListMeasureStateSnapshot {
713 first_visible_item_index: self.scroll_position.current_index(),
714 first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
715 pending_scroll_delta,
716 pending_scroll_to,
717 average_item_size,
718 }
719 }
720
721 pub(crate) fn next_measure_cycle_id(&self) -> u64 {
722 self.inner
723 .try_with(|rc| {
724 let mut inner = rc.borrow_mut();
725 let id = inner.next_measure_cycle_id;
726 inner.next_measure_cycle_id = inner.next_measure_cycle_id.saturating_add(1);
727 id
728 })
729 .unwrap_or(0)
730 }
731
732 pub(crate) fn next_item_measure_pass_id(&self) -> u64 {
733 self.inner
734 .try_with(|rc| {
735 let mut inner = rc.borrow_mut();
736 let id = inner.next_item_measure_pass_id;
737 inner.next_item_measure_pass_id = inner.next_item_measure_pass_id.saturating_add(1);
738 id
739 })
740 .unwrap_or(0)
741 }
742
743 fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
744 inner.total_measured_items += 1;
745 let n = inner.total_measured_items as f32;
746 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
747 }
748
749 fn next_item_size_cache_tick(inner: &mut LazyListStateInner) -> u64 {
750 inner.item_size_clock = inner.item_size_clock.saturating_add(1);
751 inner.item_size_clock
752 }
753
754 fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
755 use std::collections::hash_map::Entry;
756
757 let tick = Self::next_item_size_cache_tick(inner);
758 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
759 entry.insert(CachedItemSize {
760 size,
761 last_used: tick,
762 });
763 Self::push_item_size_cache_ticket(inner, tick, index);
764 return false;
765 }
766
767 if inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
768 Self::evict_one_item_size(inner);
769 }
770
771 inner.item_size_cache.insert(
772 index,
773 CachedItemSize {
774 size,
775 last_used: tick,
776 },
777 );
778 Self::push_item_size_cache_ticket(inner, tick, index);
779 true
780 }
781
782 fn push_item_size_cache_ticket(inner: &mut LazyListStateInner, last_used: u64, index: usize) {
783 inner
784 .item_size_eviction_queue
785 .push(Reverse((last_used, index)));
786 let compact_limit = inner
787 .item_size_cache
788 .len()
789 .saturating_mul(4)
790 .max(ITEM_SIZE_CACHE_CAPACITY);
791 if inner.item_size_eviction_queue.len() > compact_limit {
792 Self::rebuild_item_size_eviction_queue(inner);
793 }
794 }
795
796 fn rebuild_item_size_eviction_queue(inner: &mut LazyListStateInner) {
797 inner.item_size_eviction_queue = inner
798 .item_size_cache
799 .iter()
800 .map(|(index, item)| Reverse((item.last_used, *index)))
801 .collect();
802 }
803
804 fn evict_one_item_size(inner: &mut LazyListStateInner) {
805 while let Some(Reverse((last_used, index))) = inner.item_size_eviction_queue.pop() {
806 let Some(current) = inner.item_size_cache.get(&index) else {
807 continue;
808 };
809 if current.last_used != last_used {
810 continue;
811 }
812 inner.item_size_cache.remove(&index);
813 return;
814 }
815 }
816
817 pub fn cache_item_size(&self, index: usize, size: f32) {
819 if !self.inner.is_alive() {
820 return;
821 }
822 self.inner.with(|rc| {
823 let mut inner = rc.borrow_mut();
824 if Self::insert_item_size(&mut inner, index, size) {
825 Self::record_item_size_sample(&mut inner, size);
826 }
827 });
828 }
829
830 pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
832 where
833 I: IntoIterator<Item = (usize, f32)>,
834 {
835 if !self.inner.is_alive() {
836 return super::DEFAULT_ITEM_SIZE_ESTIMATE;
837 }
838
839 self.inner.with(|rc| {
840 let mut inner = rc.borrow_mut();
841 for (index, size) in sizes {
842 if Self::insert_item_size(&mut inner, index, size) {
843 Self::record_item_size_sample(&mut inner, size);
844 }
845 }
846 inner.average_item_size
847 })
848 }
849
850 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
852 self.inner
853 .try_with(|rc| {
854 let mut inner = rc.borrow_mut();
855 let tick = Self::next_item_size_cache_tick(&mut inner);
856 let item = inner.item_size_cache.get_mut(&index)?;
857 item.last_used = tick;
858 let size = item.size;
859 Self::push_item_size_cache_ticket(&mut inner, tick, index);
860 Some(size)
861 })
862 .flatten()
863 }
864
865 pub fn average_item_size(&self) -> f32 {
867 self.inner
868 .try_with(|rc| rc.borrow().average_item_size)
869 .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
870 }
871
872 pub fn nearest_range(&self) -> std::ops::Range<usize> {
874 self.scroll_position.nearest_range()
875 }
876
877 pub(crate) fn update_scroll_position(
878 &self,
879 first_visible_item_index: usize,
880 first_visible_item_scroll_offset: f32,
881 ) {
882 self.scroll_position.update_from_measure_result(
883 first_visible_item_index,
884 first_visible_item_scroll_offset,
885 None,
886 );
887 }
888
889 pub(crate) fn update_scroll_position_with_key(
890 &self,
891 first_visible_item_index: usize,
892 first_visible_item_scroll_offset: f32,
893 first_visible_item_key: u64,
894 ) {
895 self.scroll_position.update_from_measure_result(
896 first_visible_item_index,
897 first_visible_item_scroll_offset,
898 Some(first_visible_item_key),
899 );
900 }
901
902 pub fn update_scroll_position_if_item_moved<F>(
910 &self,
911 new_item_count: usize,
912 get_index_by_key: F,
913 ) -> usize
914 where
915 F: Fn(u64) -> Option<usize>,
916 {
917 self.scroll_position
918 .update_if_first_item_moved(new_item_count, get_index_by_key)
919 }
920
921 pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
922 if !self.inner.is_alive() {
923 return;
924 }
925 self.inner.with(|rc| {
926 let mut inner = rc.borrow_mut();
927 info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
928 inner.layout_info = info;
929 });
930 }
931
932 pub fn can_scroll_forward(&self) -> bool {
937 if !self.can_scroll_forward_state.is_alive() {
938 return false;
939 }
940 self.can_scroll_forward_state.subscribe_current_scope_only();
941 self.can_scroll_forward_non_reactive()
942 }
943
944 pub fn can_scroll_forward_non_reactive(&self) -> bool {
946 if !self.can_scroll_forward_state.is_alive() {
947 return false;
948 }
949 self.inner
950 .try_with(|rc| rc.borrow().current_can_scroll_forward)
951 .unwrap_or(false)
952 }
953
954 pub fn can_scroll_backward(&self) -> bool {
959 if !self.can_scroll_backward_state.is_alive() {
960 return false;
961 }
962 self.can_scroll_backward_state
963 .subscribe_current_scope_only();
964 self.can_scroll_backward_non_reactive()
965 }
966
967 pub fn can_scroll_backward_non_reactive(&self) -> bool {
969 if !self.can_scroll_backward_state.is_alive() {
970 return false;
971 }
972 self.inner
973 .try_with(|rc| rc.borrow().current_can_scroll_backward)
974 .unwrap_or(false)
975 }
976
977 pub(crate) fn update_scroll_bounds(&self) {
978 if !self.inner.is_alive()
979 || !self.can_scroll_forward_state.is_alive()
980 || !self.can_scroll_backward_state.is_alive()
981 {
982 return;
983 }
984 let can_forward = self.inner.with(|rc| {
985 let inner = rc.borrow();
986 let info = &inner.layout_info;
987 let viewport_end = info.viewport_size - info.after_content_padding;
988 if let Some(last_visible) = info.visible_items_info.last() {
989 last_visible.index < info.total_items_count.saturating_sub(1)
990 || (last_visible.offset + last_visible.size) > viewport_end
991 } else {
992 false
993 }
994 });
995
996 let can_backward = self.scroll_position.current_index() > 0
997 || self.scroll_position.current_scroll_offset() > 0.0;
998
999 self.inner.with(|rc| {
1000 let mut inner = rc.borrow_mut();
1001 inner.current_can_scroll_forward = can_forward;
1002 inner.current_can_scroll_backward = can_backward;
1003 });
1004
1005 if self.can_scroll_forward_state.get_non_reactive() != can_forward {
1006 self.can_scroll_forward_state.set(can_forward);
1007 }
1008 if self.can_scroll_backward_state.get_non_reactive() != can_backward {
1009 self.can_scroll_backward_state.set(can_backward);
1010 }
1011 }
1012
1013 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
1015 if !self.inner.is_alive() {
1016 return 0;
1017 }
1018 self.inner.with(|rc| {
1019 let mut inner = rc.borrow_mut();
1020 let id = inner.next_callback_id;
1021 inner.next_callback_id += 1;
1022 inner.invalidate_callbacks.push((id, callback));
1023 id
1024 })
1025 }
1026
1027 pub fn try_register_layout_callback(
1035 &self,
1036 node_id: NodeId,
1037 callback: Rc<dyn Fn()>,
1038 ) -> Option<u64> {
1039 if !self.inner.is_alive() {
1040 return None;
1041 }
1042 self.inner.with(|rc| {
1043 let mut inner = rc.borrow_mut();
1044 if let Some(existing_id) = inner.layout_invalidation_callback_id {
1045 inner
1046 .invalidate_callbacks
1047 .retain(|(cb_id, _)| *cb_id != existing_id);
1048 }
1049 let id = inner.next_callback_id;
1050 inner.next_callback_id += 1;
1051 inner.invalidate_callbacks.push((id, callback));
1052 inner.layout_invalidation_callback_id = Some(id);
1053 inner.layout_invalidation_node_id = Some(node_id);
1054 Some(id)
1055 })
1056 }
1057
1058 pub fn remove_invalidate_callback(&self, id: u64) {
1060 if !self.inner.is_alive() {
1061 return;
1062 }
1063 self.inner.with(|rc| {
1064 let mut inner = rc.borrow_mut();
1065 inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1066 if inner.layout_invalidation_callback_id == Some(id) {
1067 inner.layout_invalidation_callback_id = None;
1068 inner.layout_invalidation_node_id = None;
1069 }
1070 });
1071 }
1072
1073 fn invalidate(&self) {
1074 if !self.inner.is_alive() {
1075 return;
1076 }
1077 let callbacks: Vec<_> = self.inner.with(|rc| {
1078 rc.borrow()
1079 .invalidate_callbacks
1080 .iter()
1081 .map(|(_, cb)| Rc::clone(cb))
1082 .collect()
1083 });
1084
1085 for callback in callbacks {
1086 callback();
1087 }
1088 }
1089}
1090
1091#[derive(Clone, Default, Debug)]
1093pub struct LazyListLayoutInfo {
1094 pub visible_items_info: Vec<LazyListItemInfo>,
1096
1097 pub total_items_count: usize,
1099
1100 pub raw_viewport_size: f32,
1102
1103 pub is_infinite_viewport: bool,
1105
1106 pub viewport_size: f32,
1108
1109 pub viewport_start_offset: f32,
1111
1112 pub viewport_end_offset: f32,
1114
1115 pub before_content_padding: f32,
1117
1118 pub after_content_padding: f32,
1120
1121 pub snap_anchor_offset: f32,
1123
1124 pub reverse_layout: bool,
1126}
1127
1128#[derive(Clone, Debug)]
1130pub struct LazyListItemInfo {
1131 pub index: usize,
1133
1134 pub key: u64,
1136
1137 pub offset: f32,
1139
1140 pub size: f32,
1142}
1143
1144fn continuous_snap_anchor_offset(
1145 previous: &LazyListLayoutInfo,
1146 current: &LazyListLayoutInfo,
1147) -> f32 {
1148 let Some(first_current) = current.visible_items_info.first() else {
1149 return 0.0;
1150 };
1151
1152 for current_item in ¤t.visible_items_info {
1153 if let Some(previous_item) = previous
1154 .visible_items_info
1155 .iter()
1156 .find(|item| item.key == current_item.key)
1157 {
1158 let previous_offset = snap_anchor_item_offset(previous, previous_item);
1159 let current_offset = snap_anchor_item_offset(current, current_item);
1160 return previous.snap_anchor_offset + current_offset - previous_offset;
1161 }
1162 }
1163
1164 snap_anchor_item_offset(current, first_current)
1165}
1166
1167fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1168 if info.reverse_layout {
1169 info.viewport_size - item.offset - item.size
1170 } else {
1171 item.offset
1172 }
1173}
1174
1175#[cfg(test)]
1176pub mod test_helpers {
1177 use std::sync::Arc;
1178
1179 use cranpose_core::{DefaultScheduler, Runtime};
1180
1181 use super::*;
1182
1183 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1184 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1185 f()
1186 }
1187
1188 pub fn new_lazy_list_state() -> LazyListState {
1189 new_lazy_list_state_with_position(0, 0.0)
1190 }
1191
1192 pub fn new_lazy_list_state_with_position(
1195 initial_first_visible_item_index: usize,
1196 initial_first_visible_item_scroll_offset: f32,
1197 ) -> LazyListState {
1198 LazyListState::new(
1199 initial_first_visible_item_index,
1200 initial_first_visible_item_scroll_offset,
1201 )
1202 }
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use std::{cell::Cell, rc::Rc};
1208
1209 use cranpose_core::{Composition, MemoryApplier, location_key};
1210
1211 use super::{
1212 LazyListItemInfo, LazyListLayoutInfo, LazyListState,
1213 test_helpers::{new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime},
1214 };
1215
1216 fn set_scroll_bounds(state: &LazyListState, can_forward: bool, can_backward: bool) {
1217 state.can_scroll_forward_state.set(can_forward);
1218 state.can_scroll_backward_state.set(can_backward);
1219 state.inner.with(|rc| {
1220 let mut inner = rc.borrow_mut();
1221 inner.current_can_scroll_forward = can_forward;
1222 inner.current_can_scroll_backward = can_backward;
1223 });
1224 }
1225
1226 fn enable_bidirectional_scroll(state: &LazyListState) {
1227 set_scroll_bounds(state, true, true);
1228 }
1229
1230 fn mark_scroll_bounds_known(state: &LazyListState) {
1231 state.update_layout_info(LazyListLayoutInfo {
1232 total_items_count: 10,
1233 ..Default::default()
1234 });
1235 }
1236
1237 fn visible_item(index: usize, offset: f32, size: f32) -> LazyListItemInfo {
1238 LazyListItemInfo {
1239 index,
1240 key: index as u64,
1241 offset,
1242 size,
1243 }
1244 }
1245
1246 #[test]
1247 fn lazy_measure_telemetry_ids_are_state_owned() {
1248 with_test_runtime(|| {
1249 let first = new_lazy_list_state();
1250 let second = new_lazy_list_state();
1251
1252 assert_eq!(first.next_measure_cycle_id(), 1);
1253 assert_eq!(first.next_measure_cycle_id(), 2);
1254 assert_eq!(second.next_measure_cycle_id(), 1);
1255
1256 assert_eq!(first.next_item_measure_pass_id(), 1);
1257 assert_eq!(first.next_item_measure_pass_id(), 2);
1258 assert_eq!(second.next_item_measure_pass_id(), 1);
1259 });
1260 }
1261
1262 #[test]
1263 fn measure_result_updates_retained_and_reactive_scroll_position() {
1264 with_test_runtime(|| {
1265 let state = new_lazy_list_state();
1266
1267 state.update_scroll_position_with_key(8, 17.5, 123);
1268
1269 assert_eq!(state.scroll_position.index.get_non_reactive(), 8);
1270 assert!((state.scroll_position.scroll_offset.get_non_reactive() - 17.5).abs() < 0.001);
1271 assert_eq!(state.first_visible_item_index_non_reactive(), 8);
1272 assert!((state.first_visible_item_scroll_offset_non_reactive() - 17.5).abs() < 0.001);
1273 });
1274 }
1275
1276 #[test]
1277 fn update_scroll_bounds_updates_retained_and_reactive_capabilities() {
1278 with_test_runtime(|| {
1279 let state = new_lazy_list_state();
1280
1281 state.update_layout_info(LazyListLayoutInfo {
1282 visible_items_info: vec![visible_item(0, 0.0, 40.0), visible_item(1, 40.0, 40.0)],
1283 total_items_count: 10,
1284 viewport_size: 80.0,
1285 ..Default::default()
1286 });
1287 state.update_scroll_bounds();
1288
1289 assert!(state.can_scroll_forward_state.get_non_reactive());
1290 assert!(!state.can_scroll_backward_state.get_non_reactive());
1291 assert!(state.can_scroll_forward_non_reactive());
1292 assert!(!state.can_scroll_backward_non_reactive());
1293
1294 state.update_scroll_position(3, 2.0);
1295 state.update_scroll_bounds();
1296
1297 assert!(state.can_scroll_backward_state.get_non_reactive());
1298 assert!(state.can_scroll_backward_non_reactive());
1299 });
1300 }
1301
1302 #[test]
1303 fn layout_info_snap_anchor_tracks_common_item_offset_delta() {
1304 let previous = LazyListLayoutInfo {
1305 visible_items_info: vec![visible_item(15, -31.4, 30.0), visible_item(16, 4.6, 30.0)],
1306 snap_anchor_offset: -31.4,
1307 ..Default::default()
1308 };
1309 let current = LazyListLayoutInfo {
1310 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1311 ..Default::default()
1312 };
1313
1314 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1315
1316 assert!((anchor + 32.4).abs() <= 0.001);
1317 }
1318
1319 #[test]
1320 fn layout_info_snap_anchor_uses_reverse_visual_item_offset() {
1321 let previous = LazyListLayoutInfo {
1322 visible_items_info: vec![visible_item(15, 31.4, 30.0), visible_item(16, 67.4, 30.0)],
1323 snap_anchor_offset: 58.6,
1324 viewport_size: 120.0,
1325 reverse_layout: true,
1326 ..Default::default()
1327 };
1328 let current = LazyListLayoutInfo {
1329 visible_items_info: vec![visible_item(16, 68.4, 30.0), visible_item(17, 104.4, 30.0)],
1330 viewport_size: 120.0,
1331 reverse_layout: true,
1332 ..Default::default()
1333 };
1334
1335 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1336
1337 assert!((anchor - 57.6).abs() <= 0.001);
1338 }
1339
1340 #[test]
1341 fn update_layout_info_keeps_snap_anchor_continuous_when_first_visible_item_changes() {
1342 with_test_runtime(|| {
1343 let state = new_lazy_list_state();
1344 state.update_layout_info(LazyListLayoutInfo {
1345 visible_items_info: vec![
1346 visible_item(15, -31.4, 30.0),
1347 visible_item(16, 4.6, 30.0),
1348 ],
1349 ..Default::default()
1350 });
1351
1352 state.update_layout_info(LazyListLayoutInfo {
1353 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1354 ..Default::default()
1355 });
1356
1357 let info = state.layout_info();
1358 assert!((info.snap_anchor_offset + 32.4).abs() <= 0.001);
1359 });
1360 }
1361
1362 #[test]
1363 fn dispatch_scroll_delta_accumulates_same_direction() {
1364 with_test_runtime(|| {
1365 let state = new_lazy_list_state();
1366 enable_bidirectional_scroll(&state);
1367
1368 state.dispatch_scroll_delta(-12.0);
1369 state.dispatch_scroll_delta(-8.0);
1370
1371 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1372 let snapshot = state.begin_measure_pass();
1373 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1374 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1375 });
1376 }
1377
1378 #[test]
1379 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1380 with_test_runtime(|| {
1381 let state = new_lazy_list_state();
1382 enable_bidirectional_scroll(&state);
1383
1384 state.dispatch_scroll_delta(-120.0);
1385 state.dispatch_scroll_delta(-30.0);
1386 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1387
1388 state.dispatch_scroll_delta(18.0);
1389
1390 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1391 let snapshot = state.begin_measure_pass();
1392 assert!((snapshot.pending_scroll_delta - 18.0).abs() < 0.001);
1393 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1394 });
1395 }
1396
1397 #[test]
1398 fn dispatch_scroll_delta_clamps_pending_backlog() {
1399 with_test_runtime(|| {
1400 let state = new_lazy_list_state();
1401 enable_bidirectional_scroll(&state);
1402
1403 state.dispatch_scroll_delta(-1_500.0);
1404 state.dispatch_scroll_delta(-1_500.0);
1405 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1406
1407 state.dispatch_scroll_delta(3_000.0);
1408 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1409 });
1410 }
1411
1412 #[test]
1413 fn begin_measure_pass_consumes_large_pending_scroll_delta_coherently() {
1414 with_test_runtime(|| {
1415 let state = new_lazy_list_state();
1416 enable_bidirectional_scroll(&state);
1417 let invalidations = Rc::new(Cell::new(0u32));
1418 let invalidations_clone = Rc::clone(&invalidations);
1419 state.add_invalidate_callback(Rc::new(move || {
1420 invalidations_clone.set(invalidations_clone.get() + 1);
1421 }));
1422
1423 state.dispatch_scroll_delta(-1_000.0);
1424 assert!((state.peek_scroll_delta() + 1_000.0).abs() < 0.001);
1425
1426 let first = state.begin_measure_pass();
1427 assert!(
1428 (first.pending_scroll_delta + 1_000.0).abs() < 0.001,
1429 "first pass should consume the whole coherent scroll input"
1430 );
1431 assert!(
1432 state.peek_scroll_delta().abs() < 0.001,
1433 "measure pass should not retain a synthetic scroll backlog"
1434 );
1435 assert_eq!(
1436 invalidations.get(),
1437 1,
1438 "dispatch should request layout once; consuming scroll should not schedule follow-up frames"
1439 );
1440
1441 let second = state.begin_measure_pass();
1442 assert!(
1443 second.pending_scroll_delta.abs() < 0.001,
1444 "second pass should not receive synthetic remainder"
1445 );
1446 });
1447 }
1448
1449 #[test]
1450 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1451 with_test_runtime(|| {
1452 let state = new_lazy_list_state();
1453 enable_bidirectional_scroll(&state);
1454 let invalidations = Rc::new(Cell::new(0u32));
1455 let invalidations_clone = Rc::clone(&invalidations);
1456 state.add_invalidate_callback(Rc::new(move || {
1457 invalidations_clone.set(invalidations_clone.get() + 1);
1458 }));
1459
1460 state.dispatch_scroll_delta(-3_000.0);
1461 assert_eq!(invalidations.get(), 1);
1462 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1463
1464 state.dispatch_scroll_delta(-100.0);
1465 assert_eq!(invalidations.get(), 1);
1466
1467 state.dispatch_scroll_delta(100.0);
1468 assert_eq!(invalidations.get(), 2);
1469 });
1470 }
1471
1472 #[test]
1473 fn begin_measure_pass_takes_coherent_snapshot_and_consumes_pending_inputs() {
1474 with_test_runtime(|| {
1475 let state = new_lazy_list_state_with_position(3, 12.0);
1476 state.dispatch_scroll_delta(-20.0);
1477 state.inner.with(|rc| {
1478 rc.borrow_mut().pending_scroll_to_index = Some((8, 4.0));
1479 });
1480
1481 let snapshot = state.begin_measure_pass();
1482
1483 assert_eq!(snapshot.first_visible_item_index, 3);
1484 assert!((snapshot.first_visible_item_scroll_offset - 12.0).abs() < 0.001);
1485 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1486 assert_eq!(snapshot.pending_scroll_to, Some((8, 4.0)));
1487 assert_eq!(state.peek_scroll_delta(), 0.0);
1488 assert_eq!(state.begin_measure_pass().pending_scroll_to, None);
1489 });
1490 }
1491
1492 #[test]
1493 fn item_size_cache_refresh_keeps_recent_entry_and_evicts_oldest_live_entry() {
1494 with_test_runtime(|| {
1495 let state = new_lazy_list_state();
1496 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1497 state.cache_item_size(index, index as f32 + 10.0);
1498 }
1499
1500 state.cache_item_size(0, 999.0);
1501 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1502
1503 assert_eq!(state.get_cached_size(0), Some(999.0));
1504 assert_eq!(state.get_cached_size(1), None);
1505 assert_eq!(
1506 state.get_cached_size(super::ITEM_SIZE_CACHE_CAPACITY),
1507 Some(123.0),
1508 );
1509 });
1510 }
1511
1512 #[test]
1513 fn item_size_cache_read_promotes_entry_for_large_scroll_reuse() {
1514 with_test_runtime(|| {
1515 let state = new_lazy_list_state();
1516 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1517 state.cache_item_size(index, index as f32 + 10.0);
1518 }
1519
1520 assert_eq!(state.get_cached_size(0), Some(10.0));
1521 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1522
1523 assert_eq!(state.get_cached_size(0), Some(10.0));
1524 assert_eq!(state.get_cached_size(1), None);
1525 let cache_len = state
1526 .inner
1527 .try_with(|rc| rc.borrow().item_size_cache.len())
1528 .unwrap_or(0);
1529 assert_eq!(cache_len, super::ITEM_SIZE_CACHE_CAPACITY);
1530 });
1531 }
1532
1533 #[test]
1534 fn item_size_cache_promotion_queue_stays_bounded_under_hot_reuse() {
1535 with_test_runtime(|| {
1536 let state = new_lazy_list_state();
1537 state.cache_item_size(0, 32.0);
1538
1539 for _ in 0..super::ITEM_SIZE_CACHE_CAPACITY * 8 {
1540 assert_eq!(state.get_cached_size(0), Some(32.0));
1541 }
1542
1543 let (cache_len, queue_len) = state
1544 .inner
1545 .try_with(|rc| {
1546 let inner = rc.borrow();
1547 (
1548 inner.item_size_cache.len(),
1549 inner.item_size_eviction_queue.len(),
1550 )
1551 })
1552 .unwrap_or((0, 0));
1553 assert_eq!(cache_len, 1);
1554 assert!(
1555 queue_len <= super::ITEM_SIZE_CACHE_CAPACITY,
1556 "stale promotion tickets must be compacted, got {queue_len}"
1557 );
1558 });
1559 }
1560
1561 #[test]
1562 fn cache_item_sizes_updates_average_only_for_new_entries() {
1563 with_test_runtime(|| {
1564 let state = new_lazy_list_state();
1565
1566 let average = state.cache_item_sizes([(0, 10.0), (1, 20.0), (0, 12.0)]);
1567
1568 assert_eq!(state.get_cached_size(0), Some(12.0));
1569 assert_eq!(state.get_cached_size(1), Some(20.0));
1570 assert!((average - 15.0).abs() < 0.001);
1571 });
1572 }
1573
1574 #[test]
1575 fn layout_callback_can_be_registered_again_after_removal() {
1576 with_test_runtime(|| {
1577 let state = new_lazy_list_state();
1578 let first_node: cranpose_core::NodeId = 1;
1579 let second_node: cranpose_core::NodeId = 2;
1580
1581 let first_id = state
1582 .try_register_layout_callback(first_node, Rc::new(|| {}))
1583 .expect("first layout callback should register");
1584 let duplicate_id = state
1585 .try_register_layout_callback(first_node, Rc::new(|| {}))
1586 .expect("duplicate register should replace with a fresh callback id");
1587 assert_eq!(
1588 state
1589 .inner
1590 .with(|rc| rc.borrow().layout_invalidation_callback_id),
1591 Some(duplicate_id),
1592 "duplicate registration should become the active callback",
1593 );
1594 assert_ne!(
1595 first_id, duplicate_id,
1596 "duplicate registration should replace the old callback id",
1597 );
1598
1599 state.remove_invalidate_callback(first_id);
1600
1601 let second_id = state
1602 .try_register_layout_callback(second_node, Rc::new(|| {}))
1603 .expect("layout callback should register again after removal");
1604 assert_ne!(first_id, second_id);
1605 });
1606 }
1607
1608 #[test]
1609 fn layout_callback_rebinds_when_node_id_changes() {
1610 with_test_runtime(|| {
1611 let state = new_lazy_list_state();
1612 let first_node: cranpose_core::NodeId = 11;
1613 let second_node: cranpose_core::NodeId = 22;
1614
1615 let first_id = state
1616 .try_register_layout_callback(first_node, Rc::new(|| {}))
1617 .expect("first layout callback should register");
1618
1619 let second_id = state
1620 .try_register_layout_callback(second_node, Rc::new(|| {}))
1621 .expect("layout callback should rebind to a new node");
1622
1623 assert_ne!(first_id, second_id);
1624 });
1625 }
1626
1627 #[test]
1628 fn stale_layout_callback_disposer_cannot_remove_replaced_same_node_callback() {
1629 with_test_runtime(|| {
1630 let state = new_lazy_list_state();
1631 let node_id: cranpose_core::NodeId = 7;
1632 let first_hits = Rc::new(Cell::new(0u32));
1633 let second_hits = Rc::new(Cell::new(0u32));
1634
1635 let first_id = state
1636 .try_register_layout_callback(
1637 node_id,
1638 Rc::new({
1639 let first_hits = Rc::clone(&first_hits);
1640 move || first_hits.set(first_hits.get() + 1)
1641 }),
1642 )
1643 .expect("first layout callback should register");
1644
1645 let second_id = state
1646 .try_register_layout_callback(
1647 node_id,
1648 Rc::new({
1649 let second_hits = Rc::clone(&second_hits);
1650 move || second_hits.set(second_hits.get() + 1)
1651 }),
1652 )
1653 .expect("same-node registration should replace the active callback");
1654
1655 assert_ne!(first_id, second_id);
1656
1657 state.remove_invalidate_callback(first_id);
1658 state.dispatch_scroll_delta(-12.0);
1659
1660 assert_eq!(
1661 first_hits.get(),
1662 0,
1663 "replaced callback should not be invoked after removal",
1664 );
1665 assert_eq!(
1666 second_hits.get(),
1667 1,
1668 "active callback should survive stale disposer cleanup",
1669 );
1670 });
1671 }
1672
1673 #[test]
1674 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1675 with_test_runtime(|| {
1676 let state = new_lazy_list_state();
1677 mark_scroll_bounds_known(&state);
1678 set_scroll_bounds(&state, false, true);
1679
1680 let consumed = state.dispatch_scroll_delta(-24.0);
1681
1682 assert_eq!(consumed, 0.0);
1683 assert_eq!(state.peek_scroll_delta(), 0.0);
1684 });
1685 }
1686
1687 #[test]
1688 fn equality_does_not_deref_released_inner_state() {
1689 let mut composition = Composition::new(MemoryApplier::new());
1690 let key = location_key(file!(), line!(), column!());
1691
1692 let mut first = None;
1693 composition
1694 .render(key, || {
1695 first = Some(super::rememberLazyListState());
1696 })
1697 .expect("initial render");
1698 let first = first.expect("first lazy state");
1699
1700 composition
1701 .render(key, || {})
1702 .expect("dispose first lazy state");
1703 assert!(
1704 !first.inner.is_alive(),
1705 "expected first lazy state to be released after disposal"
1706 );
1707
1708 let mut second = None;
1709 composition
1710 .render(key, || {
1711 second = Some(super::rememberLazyListState());
1712 })
1713 .expect("second render");
1714 let second = second.expect("second lazy state");
1715
1716 assert!(
1717 first != second,
1718 "released lazy state handle must compare by identity without panicking"
1719 );
1720 }
1721
1722 #[test]
1723 fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1724 let mut composition = Composition::new(MemoryApplier::new());
1725 let key = location_key(file!(), line!(), column!());
1726
1727 let mut released = None;
1728 composition
1729 .render(key, || {
1730 released = Some(super::rememberLazyListState());
1731 })
1732 .expect("initial render");
1733 let released = released.expect("lazy list state");
1734
1735 composition
1736 .render(key, || {})
1737 .expect("dispose lazy list state");
1738 assert!(
1739 !released.inner.is_alive(),
1740 "expected lazy list state to be released after disposal"
1741 );
1742
1743 assert_eq!(released.first_visible_item_index(), 0);
1744 assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1745 assert_eq!(released.nearest_range(), 0..0);
1746 assert_eq!(
1747 released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1748 0
1749 );
1750 released.update_scroll_position(3, 12.0);
1751 released.update_scroll_position_with_key(3, 12.0, 42);
1752 released.update_scroll_bounds();
1753 }
1754
1755 #[test]
1756 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1757 with_test_runtime(|| {
1758 let state = new_lazy_list_state();
1759 mark_scroll_bounds_known(&state);
1760 enable_bidirectional_scroll(&state);
1761 state.dispatch_scroll_delta(-300.0);
1762 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1763
1764 set_scroll_bounds(&state, false, true);
1765
1766 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1767 assert_eq!(blocked_consumed, 0.0);
1768 assert_eq!(state.peek_scroll_delta(), 0.0);
1769
1770 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1771 assert_eq!(reverse_consumed, 12.0);
1772 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1773 });
1774 }
1775
1776 #[test]
1777 fn negative_scroll_delta_prefetches_forward_items() {
1778 with_test_runtime(|| {
1779 let state = new_lazy_list_state();
1780 state.dispatch_scroll_delta(-24.0);
1781 state.record_scroll_direction(state.peek_scroll_delta());
1782 state.update_prefetch_queue(10, 15, 100);
1783
1784 assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1785 });
1786 }
1787}