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