1use std::cell::RefCell;
13use std::rc::Rc;
14use std::sync::OnceLock;
15
16use cranpose_core::{MutableState, NodeId};
17use cranpose_macros::composable;
18
19use super::nearest_range::NearestRangeState;
20use super::prefetch::{PrefetchScheduler, PrefetchStrategy};
21
22static LAZY_MEASURE_TELEMETRY_ENABLED: OnceLock<bool> = OnceLock::new();
23
24fn lazy_measure_telemetry_enabled() -> bool {
25 *LAZY_MEASURE_TELEMETRY_ENABLED
26 .get_or_init(|| std::env::var_os("CRANPOSE_LAZY_MEASURE_TELEMETRY").is_some())
27}
28
29const MAX_PENDING_SCROLL_DELTA: f32 = 2000.0;
30const ITEM_SIZE_CACHE_CAPACITY: usize = 100;
31
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub(crate) struct LazyListMeasureStateSnapshot {
34 pub(crate) first_visible_item_index: usize,
35 pub(crate) first_visible_item_scroll_offset: f32,
36 pub(crate) pending_scroll_delta: f32,
37 pub(crate) pending_scroll_to: Option<(usize, f32)>,
38 pub(crate) average_item_size: f32,
39}
40
41#[derive(Clone, Debug, Default, PartialEq)]
45pub struct LazyLayoutStats {
46 pub items_in_use: usize,
48
49 pub items_in_pool: usize,
51
52 pub total_composed: usize,
54
55 pub reuse_count: usize,
57}
58
59#[derive(Clone, Copy)]
71pub struct LazyListScrollPosition {
72 index: MutableState<usize>,
74 scroll_offset: MutableState<f32>,
76 inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
78}
79
80struct ScrollPositionInner {
82 last_known_first_item_key: Option<u64>,
85 nearest_range_state: NearestRangeState,
87}
88
89impl LazyListScrollPosition {
90 fn is_alive(&self) -> bool {
91 self.index.is_alive() && self.scroll_offset.is_alive() && self.inner.is_alive()
92 }
93
94 fn current_index(&self) -> usize {
95 self.index.try_value().unwrap_or(0)
96 }
97
98 fn current_scroll_offset(&self) -> f32 {
99 self.scroll_offset.try_value().unwrap_or(0.0)
100 }
101
102 pub fn index(&self) -> usize {
104 if !self.index.is_alive() {
105 return 0;
106 }
107 self.index.get()
108 }
109
110 pub fn scroll_offset(&self) -> f32 {
112 if !self.scroll_offset.is_alive() {
113 return 0.0;
114 }
115 self.scroll_offset.get()
116 }
117
118 pub(crate) fn update_from_measure_result(
123 &self,
124 first_visible_index: usize,
125 first_visible_scroll_offset: f32,
126 first_visible_item_key: Option<u64>,
127 ) {
128 if !self.is_alive() {
129 return;
130 }
131 self.inner.with(|rc| {
133 let mut inner = rc.borrow_mut();
134 inner.last_known_first_item_key = first_visible_item_key;
135 inner.nearest_range_state.update(first_visible_index);
136 });
137
138 let old_index = self.index.get_non_reactive();
140 if old_index != first_visible_index {
141 self.index.set(first_visible_index);
142 }
143 let old_offset = self.scroll_offset.get_non_reactive();
144 if (old_offset - first_visible_scroll_offset).abs() > 0.001 {
145 self.scroll_offset.set(first_visible_scroll_offset);
146 }
147 }
148
149 pub(crate) fn request_position_and_forget_last_known_key(
152 &self,
153 index: usize,
154 scroll_offset: f32,
155 ) {
156 if !self.is_alive() {
157 return;
158 }
159 if self.index.get_non_reactive() != index {
161 self.index.set(index);
162 }
163 if (self.scroll_offset.get_non_reactive() - scroll_offset).abs() > 0.001 {
164 self.scroll_offset.set(scroll_offset);
165 }
166 self.inner.with(|rc| {
168 let mut inner = rc.borrow_mut();
169 inner.last_known_first_item_key = None;
170 inner.nearest_range_state.update(index);
171 });
172 }
173
174 pub(crate) fn update_if_first_item_moved<F>(
177 &self,
178 new_item_count: usize,
179 find_by_key: F,
180 ) -> usize
181 where
182 F: Fn(u64) -> Option<usize>,
183 {
184 if !self.index.is_alive() || !self.inner.is_alive() {
185 return 0;
186 }
187
188 let current_index = self.index.get_non_reactive();
189 let last_key = self
190 .inner
191 .try_with(|rc| rc.borrow().last_known_first_item_key)
192 .flatten();
193
194 let new_index = match last_key {
195 None => current_index.min(new_item_count.saturating_sub(1)),
196 Some(key) => find_by_key(key)
197 .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
198 };
199
200 if current_index != new_index {
201 self.index.set(new_index);
202 self.inner.with(|rc| {
203 rc.borrow_mut().nearest_range_state.update(new_index);
204 });
205 }
206 new_index
207 }
208
209 pub fn nearest_range(&self) -> std::ops::Range<usize> {
211 self.inner
212 .try_with(|rc| rc.borrow().nearest_range_state.range())
213 .unwrap_or(0..0)
214 }
215}
216
217#[derive(Clone, Copy)]
252pub struct LazyListState {
253 scroll_position: LazyListScrollPosition,
255 can_scroll_forward_state: MutableState<bool>,
257 can_scroll_backward_state: MutableState<bool>,
259 stats_state: MutableState<LazyLayoutStats>,
262 inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
264}
265
266impl PartialEq for LazyListState {
270 fn eq(&self, other: &Self) -> bool {
271 self.inner == other.inner
272 }
273}
274
275struct LazyListStateInner {
277 scroll_to_be_consumed: f32,
279
280 pending_scroll_to_index: Option<(usize, f32)>,
282
283 layout_info: LazyListLayoutInfo,
285
286 invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
288 next_callback_id: u64,
289
290 layout_invalidation_callback_id: Option<u64>,
294 layout_invalidation_node_id: Option<NodeId>,
295
296 total_composed: usize,
298 reuse_count: usize,
299
300 item_size_cache: std::collections::HashMap<usize, f32>,
302 item_size_lru: std::collections::VecDeque<usize>,
303
304 average_item_size: f32,
306 total_measured_items: usize,
307
308 prefetch_scheduler: PrefetchScheduler,
310
311 prefetch_strategy: PrefetchStrategy,
313
314 last_scroll_direction: f32,
316}
317
318#[composable]
333pub fn remember_lazy_list_state() -> LazyListState {
334 remember_lazy_list_state_with_position(0, 0.0)
335}
336
337#[composable]
341pub fn remember_lazy_list_state_with_position(
342 initial_first_visible_item_index: usize,
343 initial_first_visible_item_scroll_offset: f32,
344) -> LazyListState {
345 let scroll_position = LazyListScrollPosition {
347 index: cranpose_core::useState(|| initial_first_visible_item_index),
348 scroll_offset: cranpose_core::useState(|| initial_first_visible_item_scroll_offset),
349 inner: cranpose_core::useState(|| {
350 Rc::new(RefCell::new(ScrollPositionInner {
351 last_known_first_item_key: None,
352 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
353 }))
354 }),
355 };
356
357 let inner = cranpose_core::useState(|| {
359 Rc::new(RefCell::new(LazyListStateInner {
360 scroll_to_be_consumed: 0.0,
361 pending_scroll_to_index: None,
362 layout_info: LazyListLayoutInfo::default(),
363 invalidate_callbacks: Vec::new(),
364 next_callback_id: 1,
365 layout_invalidation_callback_id: None,
366 layout_invalidation_node_id: None,
367 total_composed: 0,
368 reuse_count: 0,
369 item_size_cache: std::collections::HashMap::new(),
370 item_size_lru: std::collections::VecDeque::new(),
371 average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
372 total_measured_items: 0,
373 prefetch_scheduler: PrefetchScheduler::new(),
374 prefetch_strategy: PrefetchStrategy::default(),
375 last_scroll_direction: 0.0,
376 }))
377 });
378
379 let can_scroll_forward_state = cranpose_core::useState(|| false);
381 let can_scroll_backward_state = cranpose_core::useState(|| false);
382 let stats_state = cranpose_core::useState(LazyLayoutStats::default);
383
384 LazyListState {
385 scroll_position,
386 can_scroll_forward_state,
387 can_scroll_backward_state,
388 stats_state,
389 inner,
390 }
391}
392
393impl LazyListState {
394 pub fn inner_ptr(&self) -> *const () {
399 self.inner
400 .try_with(|rc| Rc::as_ptr(rc) as *const ())
401 .unwrap_or(std::ptr::null())
402 }
403
404 pub fn first_visible_item_index(&self) -> usize {
409 self.scroll_position.index()
411 }
412
413 pub fn first_visible_item_scroll_offset(&self) -> f32 {
419 self.scroll_position.scroll_offset()
421 }
422
423 pub fn is_scrolled_non_reactive(&self) -> bool {
426 self.scroll_position.current_index() > 0
427 || self.scroll_position.current_scroll_offset().abs() > 0.001
428 || self
429 .inner
430 .try_with(|rc| {
431 let inner = rc.borrow();
432 inner.scroll_to_be_consumed.abs() > 0.001
433 || inner
434 .pending_scroll_to_index
435 .is_some_and(|(index, offset)| index > 0 || offset.abs() > 0.001)
436 })
437 .unwrap_or(false)
438 }
439
440 pub fn layout_info(&self) -> LazyListLayoutInfo {
442 self.inner
443 .try_with(|rc| rc.borrow().layout_info.clone())
444 .unwrap_or_default()
445 }
446
447 pub fn stats(&self) -> LazyLayoutStats {
453 if !self.stats_state.is_alive() || !self.inner.is_alive() {
454 return LazyLayoutStats::default();
455 }
456 let reactive = self.stats_state.get();
458 let (total_composed, reuse_count) = self.inner.with(|rc| {
459 let inner = rc.borrow();
460 (inner.total_composed, inner.reuse_count)
461 });
462 LazyLayoutStats {
463 items_in_use: reactive.items_in_use,
464 items_in_pool: reactive.items_in_pool,
465 total_composed,
466 reuse_count,
467 }
468 }
469
470 pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
475 if !self.stats_state.is_alive() || !self.inner.is_alive() {
476 return;
477 }
478
479 let current = self.stats_state.get_non_reactive();
480
481 let should_update_reactive = if items_in_use > current.items_in_use {
490 true
492 } else if items_in_use < current.items_in_use {
493 current.items_in_use - items_in_use > 1
495 } else {
496 false
497 };
498
499 if should_update_reactive {
500 self.stats_state.set(LazyLayoutStats {
501 items_in_use,
502 items_in_pool,
503 ..current
504 });
505 }
506 }
509
510 pub fn record_composition(&self, was_reused: bool) {
515 if !self.inner.is_alive() {
516 return;
517 }
518 self.inner.with(|rc| {
519 let mut inner = rc.borrow_mut();
520 inner.total_composed += 1;
521 if was_reused {
522 inner.reuse_count += 1;
523 }
524 });
525 }
526
527 pub fn record_scroll_direction(&self, delta: f32) {
533 if delta.abs() > 0.001 {
534 if !self.inner.is_alive() {
535 return;
536 }
537 self.inner.with(|rc| {
538 rc.borrow_mut().last_scroll_direction = -delta.signum();
539 });
540 }
541 }
542
543 pub fn update_prefetch_queue(
546 &self,
547 first_visible_index: usize,
548 last_visible_index: usize,
549 total_items: usize,
550 ) {
551 if !self.inner.is_alive() {
552 return;
553 }
554 self.inner.with(|rc| {
555 let mut inner = rc.borrow_mut();
556 let direction = inner.last_scroll_direction;
557 let strategy = inner.prefetch_strategy.clone();
558 inner.prefetch_scheduler.update(
559 first_visible_index,
560 last_visible_index,
561 total_items,
562 direction,
563 &strategy,
564 );
565 });
566 }
567
568 pub fn take_prefetch_indices(&self) -> Vec<usize> {
571 self.inner
572 .try_with(|rc| {
573 let mut inner = rc.borrow_mut();
574 let mut indices = Vec::new();
575 while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
576 indices.push(idx);
577 }
578 indices
579 })
580 .unwrap_or_default()
581 }
582
583 pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
589 if !self.inner.is_alive() {
590 return;
591 }
592 if lazy_measure_telemetry_enabled() {
593 log::warn!(
594 "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
595 index,
596 scroll_offset
597 );
598 }
599 self.inner.with(|rc| {
601 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
602 });
603
604 self.scroll_position
606 .request_position_and_forget_last_known_key(index, scroll_offset);
607
608 self.invalidate();
609 }
610
611 pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
619 if !self.inner.is_alive() {
622 return 0.0;
623 }
624 let has_scroll_bounds = self
625 .inner
626 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
627 let pushing_forward = delta < -0.001;
628 let pushing_backward = delta > 0.001;
629 let can_scroll_forward = self.can_scroll_forward_state.is_alive()
630 && self.can_scroll_forward_state.get_non_reactive();
631 let can_scroll_backward = self.can_scroll_backward_state.is_alive()
632 && self.can_scroll_backward_state.get_non_reactive();
633 let blocked_by_bounds = has_scroll_bounds
634 && ((pushing_forward && !can_scroll_forward)
635 || (pushing_backward && !can_scroll_backward));
636
637 if blocked_by_bounds {
638 let should_invalidate = self.inner.with(|rc| {
639 let mut inner = rc.borrow_mut();
640 let pending_before = inner.scroll_to_be_consumed;
641 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
643 inner.scroll_to_be_consumed = 0.0;
644 }
645 if lazy_measure_telemetry_enabled() {
646 log::warn!(
647 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
648 delta,
649 pending_before,
650 inner.scroll_to_be_consumed
651 );
652 }
653 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
654 });
655 if should_invalidate {
656 self.invalidate();
657 }
658 return 0.0;
659 }
660
661 let should_invalidate = self.inner.with(|rc| {
662 let mut inner = rc.borrow_mut();
663 let pending_before = inner.scroll_to_be_consumed;
664 let pending = inner.scroll_to_be_consumed;
665 let reverse_input = pending.abs() > 0.001
666 && delta.abs() > 0.001
667 && pending.signum() != delta.signum();
668 if reverse_input {
669 if lazy_measure_telemetry_enabled() {
670 log::warn!(
671 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
672 pending,
673 delta
674 );
675 }
676 inner.scroll_to_be_consumed = delta;
680 } else {
681 inner.scroll_to_be_consumed += delta;
682 }
683 inner.scroll_to_be_consumed = inner
684 .scroll_to_be_consumed
685 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
686 if lazy_measure_telemetry_enabled() {
687 log::warn!(
688 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
689 delta,
690 inner.scroll_to_be_consumed
691 );
692 }
693 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
694 });
695 if should_invalidate {
696 self.invalidate();
697 }
698 delta }
700
701 pub fn peek_scroll_delta(&self) -> f32 {
708 self.inner
709 .try_with(|rc| rc.borrow().scroll_to_be_consumed)
710 .unwrap_or(0.0)
711 }
712
713 pub(crate) fn begin_measure_pass(&self) -> LazyListMeasureStateSnapshot {
714 let (pending_scroll_delta, pending_scroll_to, average_item_size) = self
715 .inner
716 .try_with(|rc| {
717 let mut inner = rc.borrow_mut();
718 let pending_scroll_delta = inner.scroll_to_be_consumed;
719 inner.scroll_to_be_consumed = 0.0;
720 let pending_scroll_to = inner.pending_scroll_to_index.take();
721 (
722 pending_scroll_delta,
723 pending_scroll_to,
724 inner.average_item_size,
725 )
726 })
727 .unwrap_or((0.0, None, super::DEFAULT_ITEM_SIZE_ESTIMATE));
728
729 LazyListMeasureStateSnapshot {
730 first_visible_item_index: self.scroll_position.current_index(),
731 first_visible_item_scroll_offset: self.scroll_position.current_scroll_offset(),
732 pending_scroll_delta,
733 pending_scroll_to,
734 average_item_size,
735 }
736 }
737
738 fn record_item_size_sample(inner: &mut LazyListStateInner, size: f32) {
739 inner.total_measured_items += 1;
740 let n = inner.total_measured_items as f32;
741 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
742 }
743
744 fn insert_item_size(inner: &mut LazyListStateInner, index: usize, size: f32) -> bool {
745 use std::collections::hash_map::Entry;
746
747 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
748 entry.insert(size);
749 if let Some(pos) = inner
750 .item_size_lru
751 .iter()
752 .position(|&cached| cached == index)
753 {
754 inner.item_size_lru.remove(pos);
755 }
756 inner.item_size_lru.push_back(index);
757 return false;
758 }
759
760 while inner.item_size_cache.len() >= ITEM_SIZE_CACHE_CAPACITY {
761 if let Some(oldest) = inner.item_size_lru.pop_front() {
762 if inner.item_size_cache.remove(&oldest).is_some() {
763 break;
764 }
765 } else {
766 break;
767 }
768 }
769
770 inner.item_size_cache.insert(index, size);
771 inner.item_size_lru.push_back(index);
772 true
773 }
774
775 pub fn cache_item_size(&self, index: usize, size: f32) {
777 if !self.inner.is_alive() {
778 return;
779 }
780 self.inner.with(|rc| {
781 let mut inner = rc.borrow_mut();
782 if Self::insert_item_size(&mut inner, index, size) {
783 Self::record_item_size_sample(&mut inner, size);
784 }
785 });
786 }
787
788 pub fn cache_item_sizes<I>(&self, sizes: I) -> f32
790 where
791 I: IntoIterator<Item = (usize, f32)>,
792 {
793 if !self.inner.is_alive() {
794 return super::DEFAULT_ITEM_SIZE_ESTIMATE;
795 }
796
797 self.inner.with(|rc| {
798 let mut inner = rc.borrow_mut();
799 for (index, size) in sizes {
800 if Self::insert_item_size(&mut inner, index, size) {
801 Self::record_item_size_sample(&mut inner, size);
802 }
803 }
804 inner.average_item_size
805 })
806 }
807
808 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
810 self.inner
811 .try_with(|rc| rc.borrow().item_size_cache.get(&index).copied())
812 .flatten()
813 }
814
815 pub fn average_item_size(&self) -> f32 {
817 self.inner
818 .try_with(|rc| rc.borrow().average_item_size)
819 .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
820 }
821
822 pub fn nearest_range(&self) -> std::ops::Range<usize> {
824 self.scroll_position.nearest_range()
826 }
827
828 pub(crate) fn update_scroll_position(
832 &self,
833 first_visible_item_index: usize,
834 first_visible_item_scroll_offset: f32,
835 ) {
836 self.scroll_position.update_from_measure_result(
837 first_visible_item_index,
838 first_visible_item_scroll_offset,
839 None,
840 );
841 }
842
843 pub(crate) fn update_scroll_position_with_key(
847 &self,
848 first_visible_item_index: usize,
849 first_visible_item_scroll_offset: f32,
850 first_visible_item_key: u64,
851 ) {
852 self.scroll_position.update_from_measure_result(
853 first_visible_item_index,
854 first_visible_item_scroll_offset,
855 Some(first_visible_item_key),
856 );
857 }
858
859 pub fn update_scroll_position_if_item_moved<F>(
867 &self,
868 new_item_count: usize,
869 get_index_by_key: F,
870 ) -> usize
871 where
872 F: Fn(u64) -> Option<usize>,
873 {
874 self.scroll_position
876 .update_if_first_item_moved(new_item_count, get_index_by_key)
877 }
878
879 pub(crate) fn update_layout_info(&self, mut info: LazyListLayoutInfo) {
881 if !self.inner.is_alive() {
882 return;
883 }
884 self.inner.with(|rc| {
885 let mut inner = rc.borrow_mut();
886 info.snap_anchor_offset = continuous_snap_anchor_offset(&inner.layout_info, &info);
887 inner.layout_info = info;
888 });
889 }
890
891 pub fn can_scroll_forward(&self) -> bool {
896 if !self.can_scroll_forward_state.is_alive() {
897 return false;
898 }
899 self.can_scroll_forward_state.get()
900 }
901
902 pub fn can_scroll_backward(&self) -> bool {
907 if !self.can_scroll_backward_state.is_alive() {
908 return false;
909 }
910 self.can_scroll_backward_state.get()
911 }
912
913 pub(crate) fn update_scroll_bounds(&self) {
917 if !self.inner.is_alive()
918 || !self.can_scroll_forward_state.is_alive()
919 || !self.can_scroll_backward_state.is_alive()
920 {
921 return;
922 }
923 let can_forward = self.inner.with(|rc| {
925 let inner = rc.borrow();
926 let info = &inner.layout_info;
927 let viewport_end = info.viewport_size - info.after_content_padding;
930 if let Some(last_visible) = info.visible_items_info.last() {
931 last_visible.index < info.total_items_count.saturating_sub(1)
932 || (last_visible.offset + last_visible.size) > viewport_end
933 } else {
934 false
935 }
936 });
937
938 let can_backward = self.scroll_position.current_index() > 0
940 || self.scroll_position.current_scroll_offset() > 0.0;
941
942 if self.can_scroll_forward_state.get_non_reactive() != can_forward {
944 self.can_scroll_forward_state.set(can_forward);
945 }
946 if self.can_scroll_backward_state.get_non_reactive() != can_backward {
947 self.can_scroll_backward_state.set(can_backward);
948 }
949 }
950
951 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
953 if !self.inner.is_alive() {
954 return 0;
955 }
956 self.inner.with(|rc| {
957 let mut inner = rc.borrow_mut();
958 let id = inner.next_callback_id;
959 inner.next_callback_id += 1;
960 inner.invalidate_callbacks.push((id, callback));
961 id
962 })
963 }
964
965 pub fn try_register_layout_callback(
973 &self,
974 node_id: NodeId,
975 callback: Rc<dyn Fn()>,
976 ) -> Option<u64> {
977 if !self.inner.is_alive() {
978 return None;
979 }
980 self.inner.with(|rc| {
981 let mut inner = rc.borrow_mut();
982 if let Some(existing_id) = inner.layout_invalidation_callback_id {
983 inner
984 .invalidate_callbacks
985 .retain(|(cb_id, _)| *cb_id != existing_id);
986 }
987 let id = inner.next_callback_id;
988 inner.next_callback_id += 1;
989 inner.invalidate_callbacks.push((id, callback));
990 inner.layout_invalidation_callback_id = Some(id);
991 inner.layout_invalidation_node_id = Some(node_id);
992 Some(id)
993 })
994 }
995
996 pub fn remove_invalidate_callback(&self, id: u64) {
998 if !self.inner.is_alive() {
999 return;
1000 }
1001 self.inner.with(|rc| {
1002 let mut inner = rc.borrow_mut();
1003 inner.invalidate_callbacks.retain(|(cb_id, _)| *cb_id != id);
1004 if inner.layout_invalidation_callback_id == Some(id) {
1005 inner.layout_invalidation_callback_id = None;
1006 inner.layout_invalidation_node_id = None;
1007 }
1008 });
1009 }
1010
1011 fn invalidate(&self) {
1012 if !self.inner.is_alive() {
1013 return;
1014 }
1015 let callbacks: Vec<_> = self.inner.with(|rc| {
1018 rc.borrow()
1019 .invalidate_callbacks
1020 .iter()
1021 .map(|(_, cb)| Rc::clone(cb))
1022 .collect()
1023 });
1024
1025 for callback in callbacks {
1026 callback();
1027 }
1028 }
1029}
1030
1031#[derive(Clone, Default, Debug)]
1033pub struct LazyListLayoutInfo {
1034 pub visible_items_info: Vec<LazyListItemInfo>,
1036
1037 pub total_items_count: usize,
1039
1040 pub raw_viewport_size: f32,
1042
1043 pub is_infinite_viewport: bool,
1045
1046 pub viewport_size: f32,
1048
1049 pub viewport_start_offset: f32,
1051
1052 pub viewport_end_offset: f32,
1054
1055 pub before_content_padding: f32,
1057
1058 pub after_content_padding: f32,
1060
1061 pub snap_anchor_offset: f32,
1063
1064 pub reverse_layout: bool,
1066}
1067
1068#[derive(Clone, Debug)]
1070pub struct LazyListItemInfo {
1071 pub index: usize,
1073
1074 pub key: u64,
1076
1077 pub offset: f32,
1079
1080 pub size: f32,
1082}
1083
1084fn continuous_snap_anchor_offset(
1085 previous: &LazyListLayoutInfo,
1086 current: &LazyListLayoutInfo,
1087) -> f32 {
1088 let Some(first_current) = current.visible_items_info.first() else {
1089 return 0.0;
1090 };
1091
1092 for current_item in ¤t.visible_items_info {
1093 if let Some(previous_item) = previous
1094 .visible_items_info
1095 .iter()
1096 .find(|item| item.key == current_item.key)
1097 {
1098 let previous_offset = snap_anchor_item_offset(previous, previous_item);
1099 let current_offset = snap_anchor_item_offset(current, current_item);
1100 return previous.snap_anchor_offset + current_offset - previous_offset;
1101 }
1102 }
1103
1104 snap_anchor_item_offset(current, first_current)
1105}
1106
1107fn snap_anchor_item_offset(info: &LazyListLayoutInfo, item: &LazyListItemInfo) -> f32 {
1108 if info.reverse_layout {
1109 info.viewport_size - item.offset - item.size
1110 } else {
1111 item.offset
1112 }
1113}
1114
1115#[cfg(test)]
1117pub mod test_helpers {
1118 use super::*;
1119 use cranpose_core::{DefaultScheduler, Runtime};
1120 use std::sync::Arc;
1121
1122 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1125 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1126 f()
1127 }
1128
1129 pub fn new_lazy_list_state() -> LazyListState {
1132 new_lazy_list_state_with_position(0, 0.0)
1133 }
1134
1135 pub fn new_lazy_list_state_with_position(
1138 initial_first_visible_item_index: usize,
1139 initial_first_visible_item_scroll_offset: f32,
1140 ) -> LazyListState {
1141 let scroll_position = LazyListScrollPosition {
1143 index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
1144 scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
1145 inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
1146 last_known_first_item_key: None,
1147 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
1148 }))),
1149 };
1150
1151 let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
1153 scroll_to_be_consumed: 0.0,
1154 pending_scroll_to_index: None,
1155 layout_info: LazyListLayoutInfo::default(),
1156 invalidate_callbacks: Vec::new(),
1157 next_callback_id: 1,
1158 layout_invalidation_callback_id: None,
1159 layout_invalidation_node_id: None,
1160 total_composed: 0,
1161 reuse_count: 0,
1162 item_size_cache: std::collections::HashMap::new(),
1163 item_size_lru: std::collections::VecDeque::new(),
1164 average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
1165 total_measured_items: 0,
1166 prefetch_scheduler: PrefetchScheduler::new(),
1167 prefetch_strategy: PrefetchStrategy::default(),
1168 last_scroll_direction: 0.0,
1169 })));
1170
1171 let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
1173 let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
1174 let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
1175
1176 LazyListState {
1177 scroll_position,
1178 can_scroll_forward_state,
1179 can_scroll_backward_state,
1180 stats_state,
1181 inner,
1182 }
1183 }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::test_helpers::{
1189 new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime,
1190 };
1191 use super::{LazyListItemInfo, LazyListLayoutInfo, LazyListState};
1192 use cranpose_core::{location_key, Composition, MemoryApplier};
1193 use std::cell::Cell;
1194 use std::rc::Rc;
1195
1196 fn enable_bidirectional_scroll(state: &LazyListState) {
1197 state.can_scroll_forward_state.set(true);
1198 state.can_scroll_backward_state.set(true);
1199 }
1200
1201 fn mark_scroll_bounds_known(state: &LazyListState) {
1202 state.update_layout_info(LazyListLayoutInfo {
1203 total_items_count: 10,
1204 ..Default::default()
1205 });
1206 }
1207
1208 fn visible_item(index: usize, offset: f32, size: f32) -> LazyListItemInfo {
1209 LazyListItemInfo {
1210 index,
1211 key: index as u64,
1212 offset,
1213 size,
1214 }
1215 }
1216
1217 #[test]
1218 fn layout_info_snap_anchor_tracks_common_item_offset_delta() {
1219 let previous = LazyListLayoutInfo {
1220 visible_items_info: vec![visible_item(15, -31.4, 30.0), visible_item(16, 4.6, 30.0)],
1221 snap_anchor_offset: -31.4,
1222 ..Default::default()
1223 };
1224 let current = LazyListLayoutInfo {
1225 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1226 ..Default::default()
1227 };
1228
1229 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1230
1231 assert!((anchor + 32.4).abs() <= 0.001);
1232 }
1233
1234 #[test]
1235 fn layout_info_snap_anchor_uses_reverse_visual_item_offset() {
1236 let previous = LazyListLayoutInfo {
1237 visible_items_info: vec![visible_item(15, 31.4, 30.0), visible_item(16, 67.4, 30.0)],
1238 snap_anchor_offset: 58.6,
1239 viewport_size: 120.0,
1240 reverse_layout: true,
1241 ..Default::default()
1242 };
1243 let current = LazyListLayoutInfo {
1244 visible_items_info: vec![visible_item(16, 68.4, 30.0), visible_item(17, 104.4, 30.0)],
1245 viewport_size: 120.0,
1246 reverse_layout: true,
1247 ..Default::default()
1248 };
1249
1250 let anchor = super::continuous_snap_anchor_offset(&previous, ¤t);
1251
1252 assert!((anchor - 57.6).abs() <= 0.001);
1253 }
1254
1255 #[test]
1256 fn update_layout_info_keeps_snap_anchor_continuous_when_first_visible_item_changes() {
1257 with_test_runtime(|| {
1258 let state = new_lazy_list_state();
1259 state.update_layout_info(LazyListLayoutInfo {
1260 visible_items_info: vec![
1261 visible_item(15, -31.4, 30.0),
1262 visible_item(16, 4.6, 30.0),
1263 ],
1264 ..Default::default()
1265 });
1266
1267 state.update_layout_info(LazyListLayoutInfo {
1268 visible_items_info: vec![visible_item(16, 3.6, 30.0), visible_item(17, 39.6, 30.0)],
1269 ..Default::default()
1270 });
1271
1272 let info = state.layout_info();
1273 assert!((info.snap_anchor_offset + 32.4).abs() <= 0.001);
1274 });
1275 }
1276
1277 #[test]
1278 fn dispatch_scroll_delta_accumulates_same_direction() {
1279 with_test_runtime(|| {
1280 let state = new_lazy_list_state();
1281 enable_bidirectional_scroll(&state);
1282
1283 state.dispatch_scroll_delta(-12.0);
1284 state.dispatch_scroll_delta(-8.0);
1285
1286 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1287 let snapshot = state.begin_measure_pass();
1288 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1289 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1290 });
1291 }
1292
1293 #[test]
1294 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1295 with_test_runtime(|| {
1296 let state = new_lazy_list_state();
1297 enable_bidirectional_scroll(&state);
1298
1299 state.dispatch_scroll_delta(-120.0);
1300 state.dispatch_scroll_delta(-30.0);
1301 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1302
1303 state.dispatch_scroll_delta(18.0);
1304
1305 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1306 let snapshot = state.begin_measure_pass();
1307 assert!((snapshot.pending_scroll_delta - 18.0).abs() < 0.001);
1308 assert_eq!(state.begin_measure_pass().pending_scroll_delta, 0.0);
1309 });
1310 }
1311
1312 #[test]
1313 fn dispatch_scroll_delta_clamps_pending_backlog() {
1314 with_test_runtime(|| {
1315 let state = new_lazy_list_state();
1316 enable_bidirectional_scroll(&state);
1317
1318 state.dispatch_scroll_delta(-1_500.0);
1319 state.dispatch_scroll_delta(-1_500.0);
1320 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1321
1322 state.dispatch_scroll_delta(3_000.0);
1323 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1324 });
1325 }
1326
1327 #[test]
1328 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1329 with_test_runtime(|| {
1330 let state = new_lazy_list_state();
1331 enable_bidirectional_scroll(&state);
1332 let invalidations = Rc::new(Cell::new(0u32));
1333 let invalidations_clone = Rc::clone(&invalidations);
1334 state.add_invalidate_callback(Rc::new(move || {
1335 invalidations_clone.set(invalidations_clone.get() + 1);
1336 }));
1337
1338 state.dispatch_scroll_delta(-3_000.0);
1339 assert_eq!(invalidations.get(), 1);
1340 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1341
1342 state.dispatch_scroll_delta(-100.0);
1344 assert_eq!(invalidations.get(), 1);
1345
1346 state.dispatch_scroll_delta(100.0);
1348 assert_eq!(invalidations.get(), 2);
1349 });
1350 }
1351
1352 #[test]
1353 fn begin_measure_pass_takes_coherent_snapshot_and_consumes_pending_inputs() {
1354 with_test_runtime(|| {
1355 let state = new_lazy_list_state_with_position(3, 12.0);
1356 state.dispatch_scroll_delta(-20.0);
1357 state.inner.with(|rc| {
1358 rc.borrow_mut().pending_scroll_to_index = Some((8, 4.0));
1359 });
1360
1361 let snapshot = state.begin_measure_pass();
1362
1363 assert_eq!(snapshot.first_visible_item_index, 3);
1364 assert!((snapshot.first_visible_item_scroll_offset - 12.0).abs() < 0.001);
1365 assert!((snapshot.pending_scroll_delta + 20.0).abs() < 0.001);
1366 assert_eq!(snapshot.pending_scroll_to, Some((8, 4.0)));
1367 assert_eq!(state.peek_scroll_delta(), 0.0);
1368 assert_eq!(state.begin_measure_pass().pending_scroll_to, None);
1369 });
1370 }
1371
1372 #[test]
1373 fn item_size_cache_refresh_keeps_recent_entry_and_evicts_oldest_live_entry() {
1374 with_test_runtime(|| {
1375 let state = new_lazy_list_state();
1376 for index in 0..super::ITEM_SIZE_CACHE_CAPACITY {
1377 state.cache_item_size(index, index as f32 + 10.0);
1378 }
1379
1380 state.cache_item_size(0, 999.0);
1381 state.cache_item_size(super::ITEM_SIZE_CACHE_CAPACITY, 123.0);
1382
1383 assert_eq!(state.get_cached_size(0), Some(999.0));
1384 assert_eq!(state.get_cached_size(1), None);
1385 assert_eq!(
1386 state.get_cached_size(super::ITEM_SIZE_CACHE_CAPACITY),
1387 Some(123.0),
1388 );
1389 });
1390 }
1391
1392 #[test]
1393 fn cache_item_sizes_updates_average_only_for_new_entries() {
1394 with_test_runtime(|| {
1395 let state = new_lazy_list_state();
1396
1397 let average = state.cache_item_sizes([(0, 10.0), (1, 20.0), (0, 12.0)]);
1398
1399 assert_eq!(state.get_cached_size(0), Some(12.0));
1400 assert_eq!(state.get_cached_size(1), Some(20.0));
1401 assert!((average - 15.0).abs() < 0.001);
1402 });
1403 }
1404
1405 #[test]
1406 fn layout_callback_can_be_registered_again_after_removal() {
1407 with_test_runtime(|| {
1408 let state = new_lazy_list_state();
1409 let first_node: cranpose_core::NodeId = 1;
1410 let second_node: cranpose_core::NodeId = 2;
1411
1412 let first_id = state
1413 .try_register_layout_callback(first_node, Rc::new(|| {}))
1414 .expect("first layout callback should register");
1415 let duplicate_id = state
1416 .try_register_layout_callback(first_node, Rc::new(|| {}))
1417 .expect("duplicate register should replace with a fresh callback id");
1418 assert_eq!(
1419 state
1420 .inner
1421 .with(|rc| rc.borrow().layout_invalidation_callback_id),
1422 Some(duplicate_id),
1423 "duplicate registration should become the active callback",
1424 );
1425 assert_ne!(
1426 first_id, duplicate_id,
1427 "duplicate registration should replace the old callback id",
1428 );
1429
1430 state.remove_invalidate_callback(first_id);
1431
1432 let second_id = state
1433 .try_register_layout_callback(second_node, Rc::new(|| {}))
1434 .expect("layout callback should register again after removal");
1435 assert_ne!(first_id, second_id);
1436 });
1437 }
1438
1439 #[test]
1440 fn layout_callback_rebinds_when_node_id_changes() {
1441 with_test_runtime(|| {
1442 let state = new_lazy_list_state();
1443 let first_node: cranpose_core::NodeId = 11;
1444 let second_node: cranpose_core::NodeId = 22;
1445
1446 let first_id = state
1447 .try_register_layout_callback(first_node, Rc::new(|| {}))
1448 .expect("first layout callback should register");
1449
1450 let second_id = state
1451 .try_register_layout_callback(second_node, Rc::new(|| {}))
1452 .expect("layout callback should rebind to a new node");
1453
1454 assert_ne!(first_id, second_id);
1455 });
1456 }
1457
1458 #[test]
1459 fn stale_layout_callback_disposer_cannot_remove_replaced_same_node_callback() {
1460 with_test_runtime(|| {
1461 let state = new_lazy_list_state();
1462 let node_id: cranpose_core::NodeId = 7;
1463 let first_hits = Rc::new(Cell::new(0u32));
1464 let second_hits = Rc::new(Cell::new(0u32));
1465
1466 let first_id = state
1467 .try_register_layout_callback(
1468 node_id,
1469 Rc::new({
1470 let first_hits = Rc::clone(&first_hits);
1471 move || first_hits.set(first_hits.get() + 1)
1472 }),
1473 )
1474 .expect("first layout callback should register");
1475
1476 let second_id = state
1477 .try_register_layout_callback(
1478 node_id,
1479 Rc::new({
1480 let second_hits = Rc::clone(&second_hits);
1481 move || second_hits.set(second_hits.get() + 1)
1482 }),
1483 )
1484 .expect("same-node registration should replace the active callback");
1485
1486 assert_ne!(first_id, second_id);
1487
1488 state.remove_invalidate_callback(first_id);
1489 state.dispatch_scroll_delta(-12.0);
1490
1491 assert_eq!(
1492 first_hits.get(),
1493 0,
1494 "replaced callback should not be invoked after removal",
1495 );
1496 assert_eq!(
1497 second_hits.get(),
1498 1,
1499 "active callback should survive stale disposer cleanup",
1500 );
1501 });
1502 }
1503
1504 #[test]
1505 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1506 with_test_runtime(|| {
1507 let state = new_lazy_list_state();
1508 mark_scroll_bounds_known(&state);
1509 state.can_scroll_forward_state.set(false);
1510 state.can_scroll_backward_state.set(true);
1511
1512 let consumed = state.dispatch_scroll_delta(-24.0);
1513
1514 assert_eq!(consumed, 0.0);
1515 assert_eq!(state.peek_scroll_delta(), 0.0);
1516 });
1517 }
1518
1519 #[test]
1520 fn equality_does_not_deref_released_inner_state() {
1521 let mut composition = Composition::new(MemoryApplier::new());
1522 let key = location_key(file!(), line!(), column!());
1523
1524 let mut first = None;
1525 composition
1526 .render(key, || {
1527 first = Some(super::remember_lazy_list_state());
1528 })
1529 .expect("initial render");
1530 let first = first.expect("first lazy state");
1531
1532 composition
1533 .render(key, || {})
1534 .expect("dispose first lazy state");
1535 assert!(
1536 !first.inner.is_alive(),
1537 "expected first lazy state to be released after disposal"
1538 );
1539
1540 let mut second = None;
1541 composition
1542 .render(key, || {
1543 second = Some(super::remember_lazy_list_state());
1544 })
1545 .expect("second render");
1546 let second = second.expect("second lazy state");
1547
1548 assert!(
1549 first != second,
1550 "released lazy state handle must compare by identity without panicking"
1551 );
1552 }
1553
1554 #[test]
1555 fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1556 let mut composition = Composition::new(MemoryApplier::new());
1557 let key = location_key(file!(), line!(), column!());
1558
1559 let mut released = None;
1560 composition
1561 .render(key, || {
1562 released = Some(super::remember_lazy_list_state());
1563 })
1564 .expect("initial render");
1565 let released = released.expect("lazy list state");
1566
1567 composition
1568 .render(key, || {})
1569 .expect("dispose lazy list state");
1570 assert!(
1571 !released.inner.is_alive(),
1572 "expected lazy list state to be released after disposal"
1573 );
1574
1575 assert_eq!(released.first_visible_item_index(), 0);
1576 assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1577 assert_eq!(released.nearest_range(), 0..0);
1578 assert_eq!(
1579 released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1580 0
1581 );
1582 released.update_scroll_position(3, 12.0);
1583 released.update_scroll_position_with_key(3, 12.0, 42);
1584 released.update_scroll_bounds();
1585 }
1586
1587 #[test]
1588 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1589 with_test_runtime(|| {
1590 let state = new_lazy_list_state();
1591 mark_scroll_bounds_known(&state);
1592 enable_bidirectional_scroll(&state);
1593 state.dispatch_scroll_delta(-300.0);
1594 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1595
1596 state.can_scroll_forward_state.set(false);
1597
1598 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1599 assert_eq!(blocked_consumed, 0.0);
1600 assert_eq!(state.peek_scroll_delta(), 0.0);
1601
1602 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1603 assert_eq!(reverse_consumed, 12.0);
1604 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1605 });
1606 }
1607
1608 #[test]
1609 fn negative_scroll_delta_prefetches_forward_items() {
1610 with_test_runtime(|| {
1611 let state = new_lazy_list_state();
1612 state.dispatch_scroll_delta(-24.0);
1613 state.record_scroll_direction(state.peek_scroll_delta());
1614 state.update_prefetch_queue(10, 15, 100);
1615
1616 assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1617 });
1618 }
1619}