1use std::cell::RefCell;
13use std::rc::Rc;
14use std::sync::OnceLock;
15
16use cranpose_core::MutableState;
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;
30
31#[derive(Clone, Debug, Default, PartialEq)]
35pub struct LazyLayoutStats {
36 pub items_in_use: usize,
38
39 pub items_in_pool: usize,
41
42 pub total_composed: usize,
44
45 pub reuse_count: usize,
47}
48
49#[derive(Clone, Copy)]
61pub struct LazyListScrollPosition {
62 index: MutableState<usize>,
64 scroll_offset: MutableState<f32>,
66 inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
68}
69
70struct ScrollPositionInner {
72 last_known_first_item_key: Option<u64>,
75 nearest_range_state: NearestRangeState,
77}
78
79impl LazyListScrollPosition {
80 fn is_alive(&self) -> bool {
81 self.index.is_alive() && self.scroll_offset.is_alive() && self.inner.is_alive()
82 }
83
84 fn current_index(&self) -> usize {
85 self.index.try_value().unwrap_or(0)
86 }
87
88 fn current_scroll_offset(&self) -> f32 {
89 self.scroll_offset.try_value().unwrap_or(0.0)
90 }
91
92 pub fn index(&self) -> usize {
94 if !self.index.is_alive() {
95 return 0;
96 }
97 self.index.get()
98 }
99
100 pub fn scroll_offset(&self) -> f32 {
102 if !self.scroll_offset.is_alive() {
103 return 0.0;
104 }
105 self.scroll_offset.get()
106 }
107
108 pub(crate) fn update_from_measure_result(
113 &self,
114 first_visible_index: usize,
115 first_visible_scroll_offset: f32,
116 first_visible_item_key: Option<u64>,
117 ) {
118 if !self.is_alive() {
119 return;
120 }
121 self.inner.with(|rc| {
123 let mut inner = rc.borrow_mut();
124 inner.last_known_first_item_key = first_visible_item_key;
125 inner.nearest_range_state.update(first_visible_index);
126 });
127
128 let old_index = self.index.get();
130 if old_index != first_visible_index {
131 self.index.set(first_visible_index);
132 }
133 let old_offset = self.scroll_offset.get();
134 if (old_offset - first_visible_scroll_offset).abs() > 0.001 {
135 self.scroll_offset.set(first_visible_scroll_offset);
136 }
137 }
138
139 pub(crate) fn request_position_and_forget_last_known_key(
142 &self,
143 index: usize,
144 scroll_offset: f32,
145 ) {
146 if !self.is_alive() {
147 return;
148 }
149 if self.index.get() != index {
151 self.index.set(index);
152 }
153 if (self.scroll_offset.get() - scroll_offset).abs() > 0.001 {
154 self.scroll_offset.set(scroll_offset);
155 }
156 self.inner.with(|rc| {
158 let mut inner = rc.borrow_mut();
159 inner.last_known_first_item_key = None;
160 inner.nearest_range_state.update(index);
161 });
162 }
163
164 pub(crate) fn update_if_first_item_moved<F>(
167 &self,
168 new_item_count: usize,
169 find_by_key: F,
170 ) -> usize
171 where
172 F: Fn(u64) -> Option<usize>,
173 {
174 if !self.index.is_alive() || !self.inner.is_alive() {
175 return 0;
176 }
177
178 let current_index = self.index.get_non_reactive();
179 let last_key = self
180 .inner
181 .try_with(|rc| rc.borrow().last_known_first_item_key)
182 .flatten();
183
184 let new_index = match last_key {
185 None => current_index.min(new_item_count.saturating_sub(1)),
186 Some(key) => find_by_key(key)
187 .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
188 };
189
190 if current_index != new_index {
191 self.index.set(new_index);
192 self.inner.with(|rc| {
193 rc.borrow_mut().nearest_range_state.update(new_index);
194 });
195 }
196 new_index
197 }
198
199 pub fn nearest_range(&self) -> std::ops::Range<usize> {
201 self.inner
202 .try_with(|rc| rc.borrow().nearest_range_state.range())
203 .unwrap_or(0..0)
204 }
205}
206
207#[derive(Clone, Copy)]
242pub struct LazyListState {
243 scroll_position: LazyListScrollPosition,
245 can_scroll_forward_state: MutableState<bool>,
247 can_scroll_backward_state: MutableState<bool>,
249 stats_state: MutableState<LazyLayoutStats>,
252 inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
254}
255
256impl PartialEq for LazyListState {
260 fn eq(&self, other: &Self) -> bool {
261 self.inner == other.inner
262 }
263}
264
265struct LazyListStateInner {
267 scroll_to_be_consumed: f32,
269
270 pending_scroll_to_index: Option<(usize, f32)>,
272
273 layout_info: LazyListLayoutInfo,
275
276 invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
278 next_callback_id: u64,
279
280 has_layout_invalidation_callback: bool,
283
284 total_composed: usize,
286 reuse_count: usize,
287
288 item_size_cache: std::collections::HashMap<usize, f32>,
290 item_size_lru: std::collections::VecDeque<usize>,
292
293 average_item_size: f32,
295 total_measured_items: usize,
296
297 prefetch_scheduler: PrefetchScheduler,
299
300 prefetch_strategy: PrefetchStrategy,
302
303 last_scroll_direction: f32,
305}
306
307#[composable]
322pub fn remember_lazy_list_state() -> LazyListState {
323 remember_lazy_list_state_with_position(0, 0.0)
324}
325
326#[composable]
330pub fn remember_lazy_list_state_with_position(
331 initial_first_visible_item_index: usize,
332 initial_first_visible_item_scroll_offset: f32,
333) -> LazyListState {
334 let scroll_position = LazyListScrollPosition {
336 index: cranpose_core::useState(|| initial_first_visible_item_index),
337 scroll_offset: cranpose_core::useState(|| initial_first_visible_item_scroll_offset),
338 inner: cranpose_core::useState(|| {
339 Rc::new(RefCell::new(ScrollPositionInner {
340 last_known_first_item_key: None,
341 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
342 }))
343 }),
344 };
345
346 let inner = cranpose_core::useState(|| {
348 Rc::new(RefCell::new(LazyListStateInner {
349 scroll_to_be_consumed: 0.0,
350 pending_scroll_to_index: None,
351 layout_info: LazyListLayoutInfo::default(),
352 invalidate_callbacks: Vec::new(),
353 next_callback_id: 1,
354 has_layout_invalidation_callback: false,
355 total_composed: 0,
356 reuse_count: 0,
357 item_size_cache: std::collections::HashMap::new(),
358 item_size_lru: std::collections::VecDeque::new(),
359 average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
360 total_measured_items: 0,
361 prefetch_scheduler: PrefetchScheduler::new(),
362 prefetch_strategy: PrefetchStrategy::default(),
363 last_scroll_direction: 0.0,
364 }))
365 });
366
367 let can_scroll_forward_state = cranpose_core::useState(|| false);
369 let can_scroll_backward_state = cranpose_core::useState(|| false);
370 let stats_state = cranpose_core::useState(LazyLayoutStats::default);
371
372 LazyListState {
373 scroll_position,
374 can_scroll_forward_state,
375 can_scroll_backward_state,
376 stats_state,
377 inner,
378 }
379}
380
381impl LazyListState {
382 pub fn inner_ptr(&self) -> *const () {
385 self.inner
386 .try_with(|rc| Rc::as_ptr(rc) as *const ())
387 .unwrap_or(std::ptr::null())
388 }
389
390 pub fn first_visible_item_index(&self) -> usize {
395 self.scroll_position.index()
397 }
398
399 pub fn first_visible_item_scroll_offset(&self) -> f32 {
405 self.scroll_position.scroll_offset()
407 }
408
409 pub fn is_scrolled_non_reactive(&self) -> bool {
412 self.scroll_position.current_index() > 0
413 || self.scroll_position.current_scroll_offset().abs() > 0.001
414 || self
415 .inner
416 .try_with(|rc| {
417 let inner = rc.borrow();
418 inner.scroll_to_be_consumed.abs() > 0.001
419 || inner
420 .pending_scroll_to_index
421 .is_some_and(|(index, offset)| index > 0 || offset.abs() > 0.001)
422 })
423 .unwrap_or(false)
424 }
425
426 pub fn layout_info(&self) -> LazyListLayoutInfo {
428 self.inner
429 .try_with(|rc| rc.borrow().layout_info.clone())
430 .unwrap_or_default()
431 }
432
433 pub fn stats(&self) -> LazyLayoutStats {
439 if !self.stats_state.is_alive() || !self.inner.is_alive() {
440 return LazyLayoutStats::default();
441 }
442 let reactive = self.stats_state.get();
444 let (total_composed, reuse_count) = self.inner.with(|rc| {
445 let inner = rc.borrow();
446 (inner.total_composed, inner.reuse_count)
447 });
448 LazyLayoutStats {
449 items_in_use: reactive.items_in_use,
450 items_in_pool: reactive.items_in_pool,
451 total_composed,
452 reuse_count,
453 }
454 }
455
456 pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
461 if !self.stats_state.is_alive() || !self.inner.is_alive() {
462 return;
463 }
464
465 let current = self.stats_state.get_non_reactive();
466
467 let should_update_reactive = if items_in_use > current.items_in_use {
476 true
478 } else if items_in_use < current.items_in_use {
479 current.items_in_use - items_in_use > 1
481 } else {
482 false
483 };
484
485 if should_update_reactive {
486 self.stats_state.set(LazyLayoutStats {
487 items_in_use,
488 items_in_pool,
489 ..current
490 });
491 }
492 }
495
496 pub fn record_composition(&self, was_reused: bool) {
501 if !self.inner.is_alive() {
502 return;
503 }
504 self.inner.with(|rc| {
505 let mut inner = rc.borrow_mut();
506 inner.total_composed += 1;
507 if was_reused {
508 inner.reuse_count += 1;
509 }
510 });
511 }
512
513 pub fn record_scroll_direction(&self, delta: f32) {
519 if delta.abs() > 0.001 {
520 if !self.inner.is_alive() {
521 return;
522 }
523 self.inner.with(|rc| {
524 rc.borrow_mut().last_scroll_direction = -delta.signum();
525 });
526 }
527 }
528
529 pub fn update_prefetch_queue(
532 &self,
533 first_visible_index: usize,
534 last_visible_index: usize,
535 total_items: usize,
536 ) {
537 if !self.inner.is_alive() {
538 return;
539 }
540 self.inner.with(|rc| {
541 let mut inner = rc.borrow_mut();
542 let direction = inner.last_scroll_direction;
543 let strategy = inner.prefetch_strategy.clone();
544 inner.prefetch_scheduler.update(
545 first_visible_index,
546 last_visible_index,
547 total_items,
548 direction,
549 &strategy,
550 );
551 });
552 }
553
554 pub fn take_prefetch_indices(&self) -> Vec<usize> {
557 self.inner
558 .try_with(|rc| {
559 let mut inner = rc.borrow_mut();
560 let mut indices = Vec::new();
561 while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
562 indices.push(idx);
563 }
564 indices
565 })
566 .unwrap_or_default()
567 }
568
569 pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
575 if !self.inner.is_alive() {
576 return;
577 }
578 if lazy_measure_telemetry_enabled() {
579 log::warn!(
580 "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
581 index,
582 scroll_offset
583 );
584 }
585 self.inner.with(|rc| {
587 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
588 });
589
590 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 {
605 if !self.inner.is_alive() {
608 return 0.0;
609 }
610 let has_scroll_bounds = self
611 .inner
612 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
613 let pushing_forward = delta < -0.001;
614 let pushing_backward = delta > 0.001;
615 let blocked_by_bounds = has_scroll_bounds
616 && ((pushing_forward && !self.can_scroll_forward())
617 || (pushing_backward && !self.can_scroll_backward()));
618
619 if blocked_by_bounds {
620 let should_invalidate = self.inner.with(|rc| {
621 let mut inner = rc.borrow_mut();
622 let pending_before = inner.scroll_to_be_consumed;
623 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
625 inner.scroll_to_be_consumed = 0.0;
626 }
627 if lazy_measure_telemetry_enabled() {
628 log::warn!(
629 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
630 delta,
631 pending_before,
632 inner.scroll_to_be_consumed
633 );
634 }
635 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
636 });
637 if should_invalidate {
638 self.invalidate();
639 }
640 return 0.0;
641 }
642
643 let should_invalidate = self.inner.with(|rc| {
644 let mut inner = rc.borrow_mut();
645 let pending_before = inner.scroll_to_be_consumed;
646 let pending = inner.scroll_to_be_consumed;
647 let reverse_input = pending.abs() > 0.001
648 && delta.abs() > 0.001
649 && pending.signum() != delta.signum();
650 if reverse_input {
651 if lazy_measure_telemetry_enabled() {
652 log::warn!(
653 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
654 pending,
655 delta
656 );
657 }
658 inner.scroll_to_be_consumed = delta;
662 } else {
663 inner.scroll_to_be_consumed += delta;
664 }
665 inner.scroll_to_be_consumed = inner
666 .scroll_to_be_consumed
667 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
668 if lazy_measure_telemetry_enabled() {
669 log::warn!(
670 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
671 delta,
672 inner.scroll_to_be_consumed
673 );
674 }
675 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
676 });
677 if should_invalidate {
678 self.invalidate();
679 }
680 delta }
682
683 pub(crate) fn consume_scroll_delta(&self) -> f32 {
687 self.inner
688 .try_with(|rc| {
689 let mut inner = rc.borrow_mut();
690 let delta = inner.scroll_to_be_consumed;
691 inner.scroll_to_be_consumed = 0.0;
692 delta
693 })
694 .unwrap_or(0.0)
695 }
696
697 pub fn peek_scroll_delta(&self) -> f32 {
704 self.inner
705 .try_with(|rc| rc.borrow().scroll_to_be_consumed)
706 .unwrap_or(0.0)
707 }
708
709 pub(crate) fn consume_scroll_to_index(&self) -> Option<(usize, f32)> {
713 self.inner
714 .try_with(|rc| rc.borrow_mut().pending_scroll_to_index.take())
715 .flatten()
716 }
717
718 pub fn cache_item_size(&self, index: usize, size: f32) {
728 use std::collections::hash_map::Entry;
729 if !self.inner.is_alive() {
730 return;
731 }
732 self.inner.with(|rc| {
733 let mut inner = rc.borrow_mut();
734 const MAX_CACHE_SIZE: usize = 100;
735
736 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
738 entry.insert(size);
740 if let Some(pos) = inner.item_size_lru.iter().position(|&k| k == index) {
742 inner.item_size_lru.remove(pos);
743 }
744 inner.item_size_lru.push_back(index);
745 return;
746 }
747
748 while inner.item_size_cache.len() >= MAX_CACHE_SIZE {
750 if let Some(oldest) = inner.item_size_lru.pop_front() {
751 if inner.item_size_cache.remove(&oldest).is_some() {
753 break; }
755 } else {
756 break; }
758 }
759
760 inner.item_size_cache.insert(index, size);
762 inner.item_size_lru.push_back(index);
763
764 inner.total_measured_items += 1;
766 let n = inner.total_measured_items as f32;
767 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
768 });
769 }
770
771 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
773 self.inner
774 .try_with(|rc| rc.borrow().item_size_cache.get(&index).copied())
775 .flatten()
776 }
777
778 pub fn average_item_size(&self) -> f32 {
780 self.inner
781 .try_with(|rc| rc.borrow().average_item_size)
782 .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
783 }
784
785 pub fn nearest_range(&self) -> std::ops::Range<usize> {
787 self.scroll_position.nearest_range()
789 }
790
791 pub(crate) fn update_scroll_position(
795 &self,
796 first_visible_item_index: usize,
797 first_visible_item_scroll_offset: f32,
798 ) {
799 self.scroll_position.update_from_measure_result(
800 first_visible_item_index,
801 first_visible_item_scroll_offset,
802 None,
803 );
804 }
805
806 pub(crate) fn update_scroll_position_with_key(
810 &self,
811 first_visible_item_index: usize,
812 first_visible_item_scroll_offset: f32,
813 first_visible_item_key: u64,
814 ) {
815 self.scroll_position.update_from_measure_result(
816 first_visible_item_index,
817 first_visible_item_scroll_offset,
818 Some(first_visible_item_key),
819 );
820 }
821
822 pub fn update_scroll_position_if_item_moved<F>(
830 &self,
831 new_item_count: usize,
832 get_index_by_key: F,
833 ) -> usize
834 where
835 F: Fn(u64) -> Option<usize>,
836 {
837 self.scroll_position
839 .update_if_first_item_moved(new_item_count, get_index_by_key)
840 }
841
842 pub(crate) fn update_layout_info(&self, info: LazyListLayoutInfo) {
844 if !self.inner.is_alive() {
845 return;
846 }
847 self.inner.with(|rc| rc.borrow_mut().layout_info = info);
848 }
849
850 pub fn can_scroll_forward(&self) -> bool {
855 if !self.can_scroll_forward_state.is_alive() {
856 return false;
857 }
858 self.can_scroll_forward_state.get()
859 }
860
861 pub fn can_scroll_backward(&self) -> bool {
866 if !self.can_scroll_backward_state.is_alive() {
867 return false;
868 }
869 self.can_scroll_backward_state.get()
870 }
871
872 pub(crate) fn update_scroll_bounds(&self) {
876 if !self.inner.is_alive()
877 || !self.can_scroll_forward_state.is_alive()
878 || !self.can_scroll_backward_state.is_alive()
879 {
880 return;
881 }
882 let can_forward = self.inner.with(|rc| {
884 let inner = rc.borrow();
885 let info = &inner.layout_info;
886 let viewport_end = info.viewport_size - info.after_content_padding;
889 if let Some(last_visible) = info.visible_items_info.last() {
890 last_visible.index < info.total_items_count.saturating_sub(1)
891 || (last_visible.offset + last_visible.size) > viewport_end
892 } else {
893 false
894 }
895 });
896
897 let can_backward = self.scroll_position.current_index() > 0
899 || self.scroll_position.current_scroll_offset() > 0.0;
900
901 if self.can_scroll_forward_state.get_non_reactive() != can_forward {
903 self.can_scroll_forward_state.set(can_forward);
904 }
905 if self.can_scroll_backward_state.get_non_reactive() != can_backward {
906 self.can_scroll_backward_state.set(can_backward);
907 }
908 }
909
910 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
912 if !self.inner.is_alive() {
913 return 0;
914 }
915 self.inner.with(|rc| {
916 let mut inner = rc.borrow_mut();
917 let id = inner.next_callback_id;
918 inner.next_callback_id += 1;
919 inner.invalidate_callbacks.push((id, callback));
920 id
921 })
922 }
923
924 pub fn try_register_layout_callback(&self, callback: Rc<dyn Fn()>) -> bool {
929 if !self.inner.is_alive() {
930 return false;
931 }
932 self.inner.with(|rc| {
933 let mut inner = rc.borrow_mut();
934 if inner.has_layout_invalidation_callback {
935 return false;
936 }
937 inner.has_layout_invalidation_callback = true;
938 let id = inner.next_callback_id;
939 inner.next_callback_id += 1;
940 inner.invalidate_callbacks.push((id, callback));
941 true
942 })
943 }
944
945 pub fn remove_invalidate_callback(&self, id: u64) {
947 if !self.inner.is_alive() {
948 return;
949 }
950 self.inner.with(|rc| {
951 rc.borrow_mut()
952 .invalidate_callbacks
953 .retain(|(cb_id, _)| *cb_id != id);
954 });
955 }
956
957 fn invalidate(&self) {
958 if !self.inner.is_alive() {
959 return;
960 }
961 let callbacks: Vec<_> = self.inner.with(|rc| {
964 rc.borrow()
965 .invalidate_callbacks
966 .iter()
967 .map(|(_, cb)| Rc::clone(cb))
968 .collect()
969 });
970
971 for callback in callbacks {
972 callback();
973 }
974 }
975}
976
977#[derive(Clone, Default, Debug)]
979pub struct LazyListLayoutInfo {
980 pub visible_items_info: Vec<LazyListItemInfo>,
982
983 pub total_items_count: usize,
985
986 pub raw_viewport_size: f32,
988
989 pub is_infinite_viewport: bool,
991
992 pub viewport_size: f32,
994
995 pub viewport_start_offset: f32,
997
998 pub viewport_end_offset: f32,
1000
1001 pub before_content_padding: f32,
1003
1004 pub after_content_padding: f32,
1006}
1007
1008#[derive(Clone, Debug)]
1010pub struct LazyListItemInfo {
1011 pub index: usize,
1013
1014 pub key: u64,
1016
1017 pub offset: f32,
1019
1020 pub size: f32,
1022}
1023
1024#[cfg(test)]
1026pub mod test_helpers {
1027 use super::*;
1028 use cranpose_core::{DefaultScheduler, Runtime};
1029 use std::sync::Arc;
1030
1031 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1034 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1035 f()
1036 }
1037
1038 pub fn new_lazy_list_state() -> LazyListState {
1041 new_lazy_list_state_with_position(0, 0.0)
1042 }
1043
1044 pub fn new_lazy_list_state_with_position(
1047 initial_first_visible_item_index: usize,
1048 initial_first_visible_item_scroll_offset: f32,
1049 ) -> LazyListState {
1050 let scroll_position = LazyListScrollPosition {
1052 index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
1053 scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
1054 inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
1055 last_known_first_item_key: None,
1056 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
1057 }))),
1058 };
1059
1060 let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
1062 scroll_to_be_consumed: 0.0,
1063 pending_scroll_to_index: None,
1064 layout_info: LazyListLayoutInfo::default(),
1065 invalidate_callbacks: Vec::new(),
1066 next_callback_id: 1,
1067 has_layout_invalidation_callback: false,
1068 total_composed: 0,
1069 reuse_count: 0,
1070 item_size_cache: std::collections::HashMap::new(),
1071 item_size_lru: std::collections::VecDeque::new(),
1072 average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
1073 total_measured_items: 0,
1074 prefetch_scheduler: PrefetchScheduler::new(),
1075 prefetch_strategy: PrefetchStrategy::default(),
1076 last_scroll_direction: 0.0,
1077 })));
1078
1079 let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
1081 let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
1082 let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
1083
1084 LazyListState {
1085 scroll_position,
1086 can_scroll_forward_state,
1087 can_scroll_backward_state,
1088 stats_state,
1089 inner,
1090 }
1091 }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096 use super::test_helpers::{new_lazy_list_state, with_test_runtime};
1097 use super::{LazyListLayoutInfo, LazyListState};
1098 use cranpose_core::{location_key, Composition, MemoryApplier};
1099 use std::cell::Cell;
1100 use std::rc::Rc;
1101
1102 fn enable_bidirectional_scroll(state: &LazyListState) {
1103 state.can_scroll_forward_state.set(true);
1104 state.can_scroll_backward_state.set(true);
1105 }
1106
1107 fn mark_scroll_bounds_known(state: &LazyListState) {
1108 state.update_layout_info(LazyListLayoutInfo {
1109 total_items_count: 10,
1110 ..Default::default()
1111 });
1112 }
1113
1114 #[test]
1115 fn dispatch_scroll_delta_accumulates_same_direction() {
1116 with_test_runtime(|| {
1117 let state = new_lazy_list_state();
1118 enable_bidirectional_scroll(&state);
1119
1120 state.dispatch_scroll_delta(-12.0);
1121 state.dispatch_scroll_delta(-8.0);
1122
1123 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1124 assert!((state.consume_scroll_delta() + 20.0).abs() < 0.001);
1125 assert_eq!(state.consume_scroll_delta(), 0.0);
1126 });
1127 }
1128
1129 #[test]
1130 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1131 with_test_runtime(|| {
1132 let state = new_lazy_list_state();
1133 enable_bidirectional_scroll(&state);
1134
1135 state.dispatch_scroll_delta(-120.0);
1136 state.dispatch_scroll_delta(-30.0);
1137 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1138
1139 state.dispatch_scroll_delta(18.0);
1140
1141 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1142 assert!((state.consume_scroll_delta() - 18.0).abs() < 0.001);
1143 assert_eq!(state.consume_scroll_delta(), 0.0);
1144 });
1145 }
1146
1147 #[test]
1148 fn dispatch_scroll_delta_clamps_pending_backlog() {
1149 with_test_runtime(|| {
1150 let state = new_lazy_list_state();
1151 enable_bidirectional_scroll(&state);
1152
1153 state.dispatch_scroll_delta(-1_500.0);
1154 state.dispatch_scroll_delta(-1_500.0);
1155 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1156
1157 state.dispatch_scroll_delta(3_000.0);
1158 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1159 });
1160 }
1161
1162 #[test]
1163 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1164 with_test_runtime(|| {
1165 let state = new_lazy_list_state();
1166 enable_bidirectional_scroll(&state);
1167 let invalidations = Rc::new(Cell::new(0u32));
1168 let invalidations_clone = Rc::clone(&invalidations);
1169 state.add_invalidate_callback(Rc::new(move || {
1170 invalidations_clone.set(invalidations_clone.get() + 1);
1171 }));
1172
1173 state.dispatch_scroll_delta(-3_000.0);
1174 assert_eq!(invalidations.get(), 1);
1175 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1176
1177 state.dispatch_scroll_delta(-100.0);
1179 assert_eq!(invalidations.get(), 1);
1180
1181 state.dispatch_scroll_delta(100.0);
1183 assert_eq!(invalidations.get(), 2);
1184 });
1185 }
1186
1187 #[test]
1188 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1189 with_test_runtime(|| {
1190 let state = new_lazy_list_state();
1191 mark_scroll_bounds_known(&state);
1192 state.can_scroll_forward_state.set(false);
1193 state.can_scroll_backward_state.set(true);
1194
1195 let consumed = state.dispatch_scroll_delta(-24.0);
1196
1197 assert_eq!(consumed, 0.0);
1198 assert_eq!(state.peek_scroll_delta(), 0.0);
1199 });
1200 }
1201
1202 #[test]
1203 fn equality_does_not_deref_released_inner_state() {
1204 let mut composition = Composition::new(MemoryApplier::new());
1205 let key = location_key(file!(), line!(), column!());
1206
1207 let mut first = None;
1208 composition
1209 .render(key, || {
1210 first = Some(super::remember_lazy_list_state());
1211 })
1212 .expect("initial render");
1213 let first = first.expect("first lazy state");
1214
1215 composition
1216 .render(key, || {})
1217 .expect("dispose first lazy state");
1218 assert!(
1219 !first.inner.is_alive(),
1220 "expected first lazy state to be released after disposal"
1221 );
1222
1223 let mut second = None;
1224 composition
1225 .render(key, || {
1226 second = Some(super::remember_lazy_list_state());
1227 })
1228 .expect("second render");
1229 let second = second.expect("second lazy state");
1230
1231 assert!(
1232 first != second,
1233 "released lazy state handle must compare by identity without panicking"
1234 );
1235 }
1236
1237 #[test]
1238 fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1239 let mut composition = Composition::new(MemoryApplier::new());
1240 let key = location_key(file!(), line!(), column!());
1241
1242 let mut released = None;
1243 composition
1244 .render(key, || {
1245 released = Some(super::remember_lazy_list_state());
1246 })
1247 .expect("initial render");
1248 let released = released.expect("lazy list state");
1249
1250 composition
1251 .render(key, || {})
1252 .expect("dispose lazy list state");
1253 assert!(
1254 !released.inner.is_alive(),
1255 "expected lazy list state to be released after disposal"
1256 );
1257
1258 assert_eq!(released.first_visible_item_index(), 0);
1259 assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1260 assert_eq!(released.nearest_range(), 0..0);
1261 assert_eq!(
1262 released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1263 0
1264 );
1265 released.update_scroll_position(3, 12.0);
1266 released.update_scroll_position_with_key(3, 12.0, 42);
1267 released.update_scroll_bounds();
1268 }
1269
1270 #[test]
1271 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1272 with_test_runtime(|| {
1273 let state = new_lazy_list_state();
1274 mark_scroll_bounds_known(&state);
1275 enable_bidirectional_scroll(&state);
1276 state.dispatch_scroll_delta(-300.0);
1277 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1278
1279 state.can_scroll_forward_state.set(false);
1280
1281 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1282 assert_eq!(blocked_consumed, 0.0);
1283 assert_eq!(state.peek_scroll_delta(), 0.0);
1284
1285 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1286 assert_eq!(reverse_consumed, 12.0);
1287 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1288 });
1289 }
1290
1291 #[test]
1292 fn negative_scroll_delta_prefetches_forward_items() {
1293 with_test_runtime(|| {
1294 let state = new_lazy_list_state();
1295 state.dispatch_scroll_delta(-24.0);
1296 state.record_scroll_direction(state.peek_scroll_delta());
1297 state.update_prefetch_queue(10, 15, 100);
1298
1299 assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1300 });
1301 }
1302}