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 layout_info(&self) -> LazyListLayoutInfo {
411 self.inner
412 .try_with(|rc| rc.borrow().layout_info.clone())
413 .unwrap_or_default()
414 }
415
416 pub fn stats(&self) -> LazyLayoutStats {
422 if !self.stats_state.is_alive() || !self.inner.is_alive() {
423 return LazyLayoutStats::default();
424 }
425 let reactive = self.stats_state.get();
427 let (total_composed, reuse_count) = self.inner.with(|rc| {
428 let inner = rc.borrow();
429 (inner.total_composed, inner.reuse_count)
430 });
431 LazyLayoutStats {
432 items_in_use: reactive.items_in_use,
433 items_in_pool: reactive.items_in_pool,
434 total_composed,
435 reuse_count,
436 }
437 }
438
439 pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
444 if !self.stats_state.is_alive() || !self.inner.is_alive() {
445 return;
446 }
447
448 let current = self.stats_state.get_non_reactive();
449
450 let should_update_reactive = if items_in_use > current.items_in_use {
459 true
461 } else if items_in_use < current.items_in_use {
462 current.items_in_use - items_in_use > 1
464 } else {
465 false
466 };
467
468 if should_update_reactive {
469 self.stats_state.set(LazyLayoutStats {
470 items_in_use,
471 items_in_pool,
472 ..current
473 });
474 }
475 }
478
479 pub fn record_composition(&self, was_reused: bool) {
484 if !self.inner.is_alive() {
485 return;
486 }
487 self.inner.with(|rc| {
488 let mut inner = rc.borrow_mut();
489 inner.total_composed += 1;
490 if was_reused {
491 inner.reuse_count += 1;
492 }
493 });
494 }
495
496 pub fn record_scroll_direction(&self, delta: f32) {
502 if delta.abs() > 0.001 {
503 if !self.inner.is_alive() {
504 return;
505 }
506 self.inner.with(|rc| {
507 rc.borrow_mut().last_scroll_direction = -delta.signum();
508 });
509 }
510 }
511
512 pub fn update_prefetch_queue(
515 &self,
516 first_visible_index: usize,
517 last_visible_index: usize,
518 total_items: usize,
519 ) {
520 if !self.inner.is_alive() {
521 return;
522 }
523 self.inner.with(|rc| {
524 let mut inner = rc.borrow_mut();
525 let direction = inner.last_scroll_direction;
526 let strategy = inner.prefetch_strategy.clone();
527 inner.prefetch_scheduler.update(
528 first_visible_index,
529 last_visible_index,
530 total_items,
531 direction,
532 &strategy,
533 );
534 });
535 }
536
537 pub fn take_prefetch_indices(&self) -> Vec<usize> {
540 self.inner
541 .try_with(|rc| {
542 let mut inner = rc.borrow_mut();
543 let mut indices = Vec::new();
544 while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
545 indices.push(idx);
546 }
547 indices
548 })
549 .unwrap_or_default()
550 }
551
552 pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
558 if !self.inner.is_alive() {
559 return;
560 }
561 if lazy_measure_telemetry_enabled() {
562 log::warn!(
563 "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
564 index,
565 scroll_offset
566 );
567 }
568 self.inner.with(|rc| {
570 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
571 });
572
573 self.scroll_position
575 .request_position_and_forget_last_known_key(index, scroll_offset);
576
577 self.invalidate();
578 }
579
580 pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
588 if !self.inner.is_alive() {
591 return 0.0;
592 }
593 let has_scroll_bounds = self
594 .inner
595 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
596 let pushing_forward = delta < -0.001;
597 let pushing_backward = delta > 0.001;
598 let blocked_by_bounds = has_scroll_bounds
599 && ((pushing_forward && !self.can_scroll_forward())
600 || (pushing_backward && !self.can_scroll_backward()));
601
602 if blocked_by_bounds {
603 let should_invalidate = self.inner.with(|rc| {
604 let mut inner = rc.borrow_mut();
605 let pending_before = inner.scroll_to_be_consumed;
606 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
608 inner.scroll_to_be_consumed = 0.0;
609 }
610 if lazy_measure_telemetry_enabled() {
611 log::warn!(
612 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
613 delta,
614 pending_before,
615 inner.scroll_to_be_consumed
616 );
617 }
618 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
619 });
620 if should_invalidate {
621 self.invalidate();
622 }
623 return 0.0;
624 }
625
626 let should_invalidate = self.inner.with(|rc| {
627 let mut inner = rc.borrow_mut();
628 let pending_before = inner.scroll_to_be_consumed;
629 let pending = inner.scroll_to_be_consumed;
630 let reverse_input = pending.abs() > 0.001
631 && delta.abs() > 0.001
632 && pending.signum() != delta.signum();
633 if reverse_input {
634 if lazy_measure_telemetry_enabled() {
635 log::warn!(
636 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
637 pending,
638 delta
639 );
640 }
641 inner.scroll_to_be_consumed = delta;
645 } else {
646 inner.scroll_to_be_consumed += delta;
647 }
648 inner.scroll_to_be_consumed = inner
649 .scroll_to_be_consumed
650 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
651 if lazy_measure_telemetry_enabled() {
652 log::warn!(
653 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
654 delta,
655 inner.scroll_to_be_consumed
656 );
657 }
658 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
659 });
660 if should_invalidate {
661 self.invalidate();
662 }
663 delta }
665
666 pub(crate) fn consume_scroll_delta(&self) -> f32 {
670 self.inner
671 .try_with(|rc| {
672 let mut inner = rc.borrow_mut();
673 let delta = inner.scroll_to_be_consumed;
674 inner.scroll_to_be_consumed = 0.0;
675 delta
676 })
677 .unwrap_or(0.0)
678 }
679
680 pub fn peek_scroll_delta(&self) -> f32 {
687 self.inner
688 .try_with(|rc| rc.borrow().scroll_to_be_consumed)
689 .unwrap_or(0.0)
690 }
691
692 pub(crate) fn consume_scroll_to_index(&self) -> Option<(usize, f32)> {
696 self.inner
697 .try_with(|rc| rc.borrow_mut().pending_scroll_to_index.take())
698 .flatten()
699 }
700
701 pub fn cache_item_size(&self, index: usize, size: f32) {
711 use std::collections::hash_map::Entry;
712 if !self.inner.is_alive() {
713 return;
714 }
715 self.inner.with(|rc| {
716 let mut inner = rc.borrow_mut();
717 const MAX_CACHE_SIZE: usize = 100;
718
719 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
721 entry.insert(size);
723 if let Some(pos) = inner.item_size_lru.iter().position(|&k| k == index) {
725 inner.item_size_lru.remove(pos);
726 }
727 inner.item_size_lru.push_back(index);
728 return;
729 }
730
731 while inner.item_size_cache.len() >= MAX_CACHE_SIZE {
733 if let Some(oldest) = inner.item_size_lru.pop_front() {
734 if inner.item_size_cache.remove(&oldest).is_some() {
736 break; }
738 } else {
739 break; }
741 }
742
743 inner.item_size_cache.insert(index, size);
745 inner.item_size_lru.push_back(index);
746
747 inner.total_measured_items += 1;
749 let n = inner.total_measured_items as f32;
750 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
751 });
752 }
753
754 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
756 self.inner
757 .try_with(|rc| rc.borrow().item_size_cache.get(&index).copied())
758 .flatten()
759 }
760
761 pub fn average_item_size(&self) -> f32 {
763 self.inner
764 .try_with(|rc| rc.borrow().average_item_size)
765 .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
766 }
767
768 pub fn nearest_range(&self) -> std::ops::Range<usize> {
770 self.scroll_position.nearest_range()
772 }
773
774 pub(crate) fn update_scroll_position(
778 &self,
779 first_visible_item_index: usize,
780 first_visible_item_scroll_offset: f32,
781 ) {
782 self.scroll_position.update_from_measure_result(
783 first_visible_item_index,
784 first_visible_item_scroll_offset,
785 None,
786 );
787 }
788
789 pub(crate) fn update_scroll_position_with_key(
793 &self,
794 first_visible_item_index: usize,
795 first_visible_item_scroll_offset: f32,
796 first_visible_item_key: u64,
797 ) {
798 self.scroll_position.update_from_measure_result(
799 first_visible_item_index,
800 first_visible_item_scroll_offset,
801 Some(first_visible_item_key),
802 );
803 }
804
805 pub fn update_scroll_position_if_item_moved<F>(
813 &self,
814 new_item_count: usize,
815 get_index_by_key: F,
816 ) -> usize
817 where
818 F: Fn(u64) -> Option<usize>,
819 {
820 self.scroll_position
822 .update_if_first_item_moved(new_item_count, get_index_by_key)
823 }
824
825 pub(crate) fn update_layout_info(&self, info: LazyListLayoutInfo) {
827 if !self.inner.is_alive() {
828 return;
829 }
830 self.inner.with(|rc| rc.borrow_mut().layout_info = info);
831 }
832
833 pub fn can_scroll_forward(&self) -> bool {
838 if !self.can_scroll_forward_state.is_alive() {
839 return false;
840 }
841 self.can_scroll_forward_state.get()
842 }
843
844 pub fn can_scroll_backward(&self) -> bool {
849 if !self.can_scroll_backward_state.is_alive() {
850 return false;
851 }
852 self.can_scroll_backward_state.get()
853 }
854
855 pub(crate) fn update_scroll_bounds(&self) {
859 if !self.inner.is_alive()
860 || !self.can_scroll_forward_state.is_alive()
861 || !self.can_scroll_backward_state.is_alive()
862 {
863 return;
864 }
865 let can_forward = self.inner.with(|rc| {
867 let inner = rc.borrow();
868 let info = &inner.layout_info;
869 let viewport_end = info.viewport_size - info.after_content_padding;
872 if let Some(last_visible) = info.visible_items_info.last() {
873 last_visible.index < info.total_items_count.saturating_sub(1)
874 || (last_visible.offset + last_visible.size) > viewport_end
875 } else {
876 false
877 }
878 });
879
880 let can_backward = self.scroll_position.current_index() > 0
882 || self.scroll_position.current_scroll_offset() > 0.0;
883
884 if self.can_scroll_forward_state.get_non_reactive() != can_forward {
886 self.can_scroll_forward_state.set(can_forward);
887 }
888 if self.can_scroll_backward_state.get_non_reactive() != can_backward {
889 self.can_scroll_backward_state.set(can_backward);
890 }
891 }
892
893 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
895 if !self.inner.is_alive() {
896 return 0;
897 }
898 self.inner.with(|rc| {
899 let mut inner = rc.borrow_mut();
900 let id = inner.next_callback_id;
901 inner.next_callback_id += 1;
902 inner.invalidate_callbacks.push((id, callback));
903 id
904 })
905 }
906
907 pub fn try_register_layout_callback(&self, callback: Rc<dyn Fn()>) -> bool {
912 if !self.inner.is_alive() {
913 return false;
914 }
915 self.inner.with(|rc| {
916 let mut inner = rc.borrow_mut();
917 if inner.has_layout_invalidation_callback {
918 return false;
919 }
920 inner.has_layout_invalidation_callback = true;
921 let id = inner.next_callback_id;
922 inner.next_callback_id += 1;
923 inner.invalidate_callbacks.push((id, callback));
924 true
925 })
926 }
927
928 pub fn remove_invalidate_callback(&self, id: u64) {
930 if !self.inner.is_alive() {
931 return;
932 }
933 self.inner.with(|rc| {
934 rc.borrow_mut()
935 .invalidate_callbacks
936 .retain(|(cb_id, _)| *cb_id != id);
937 });
938 }
939
940 fn invalidate(&self) {
941 if !self.inner.is_alive() {
942 return;
943 }
944 let callbacks: Vec<_> = self.inner.with(|rc| {
947 rc.borrow()
948 .invalidate_callbacks
949 .iter()
950 .map(|(_, cb)| Rc::clone(cb))
951 .collect()
952 });
953
954 for callback in callbacks {
955 callback();
956 }
957 }
958}
959
960#[derive(Clone, Default, Debug)]
962pub struct LazyListLayoutInfo {
963 pub visible_items_info: Vec<LazyListItemInfo>,
965
966 pub total_items_count: usize,
968
969 pub raw_viewport_size: f32,
971
972 pub is_infinite_viewport: bool,
974
975 pub viewport_size: f32,
977
978 pub viewport_start_offset: f32,
980
981 pub viewport_end_offset: f32,
983
984 pub before_content_padding: f32,
986
987 pub after_content_padding: f32,
989}
990
991#[derive(Clone, Debug)]
993pub struct LazyListItemInfo {
994 pub index: usize,
996
997 pub key: u64,
999
1000 pub offset: f32,
1002
1003 pub size: f32,
1005}
1006
1007#[cfg(test)]
1009pub mod test_helpers {
1010 use super::*;
1011 use cranpose_core::{DefaultScheduler, Runtime};
1012 use std::sync::Arc;
1013
1014 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1017 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1018 f()
1019 }
1020
1021 pub fn new_lazy_list_state() -> LazyListState {
1024 new_lazy_list_state_with_position(0, 0.0)
1025 }
1026
1027 pub fn new_lazy_list_state_with_position(
1030 initial_first_visible_item_index: usize,
1031 initial_first_visible_item_scroll_offset: f32,
1032 ) -> LazyListState {
1033 let scroll_position = LazyListScrollPosition {
1035 index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
1036 scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
1037 inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
1038 last_known_first_item_key: None,
1039 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
1040 }))),
1041 };
1042
1043 let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
1045 scroll_to_be_consumed: 0.0,
1046 pending_scroll_to_index: None,
1047 layout_info: LazyListLayoutInfo::default(),
1048 invalidate_callbacks: Vec::new(),
1049 next_callback_id: 1,
1050 has_layout_invalidation_callback: false,
1051 total_composed: 0,
1052 reuse_count: 0,
1053 item_size_cache: std::collections::HashMap::new(),
1054 item_size_lru: std::collections::VecDeque::new(),
1055 average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
1056 total_measured_items: 0,
1057 prefetch_scheduler: PrefetchScheduler::new(),
1058 prefetch_strategy: PrefetchStrategy::default(),
1059 last_scroll_direction: 0.0,
1060 })));
1061
1062 let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
1064 let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
1065 let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
1066
1067 LazyListState {
1068 scroll_position,
1069 can_scroll_forward_state,
1070 can_scroll_backward_state,
1071 stats_state,
1072 inner,
1073 }
1074 }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::test_helpers::{new_lazy_list_state, with_test_runtime};
1080 use super::{LazyListLayoutInfo, LazyListState};
1081 use cranpose_core::{location_key, Composition, MemoryApplier};
1082 use std::cell::Cell;
1083 use std::rc::Rc;
1084
1085 fn enable_bidirectional_scroll(state: &LazyListState) {
1086 state.can_scroll_forward_state.set(true);
1087 state.can_scroll_backward_state.set(true);
1088 }
1089
1090 fn mark_scroll_bounds_known(state: &LazyListState) {
1091 state.update_layout_info(LazyListLayoutInfo {
1092 total_items_count: 10,
1093 ..Default::default()
1094 });
1095 }
1096
1097 #[test]
1098 fn dispatch_scroll_delta_accumulates_same_direction() {
1099 with_test_runtime(|| {
1100 let state = new_lazy_list_state();
1101 enable_bidirectional_scroll(&state);
1102
1103 state.dispatch_scroll_delta(-12.0);
1104 state.dispatch_scroll_delta(-8.0);
1105
1106 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1107 assert!((state.consume_scroll_delta() + 20.0).abs() < 0.001);
1108 assert_eq!(state.consume_scroll_delta(), 0.0);
1109 });
1110 }
1111
1112 #[test]
1113 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1114 with_test_runtime(|| {
1115 let state = new_lazy_list_state();
1116 enable_bidirectional_scroll(&state);
1117
1118 state.dispatch_scroll_delta(-120.0);
1119 state.dispatch_scroll_delta(-30.0);
1120 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1121
1122 state.dispatch_scroll_delta(18.0);
1123
1124 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1125 assert!((state.consume_scroll_delta() - 18.0).abs() < 0.001);
1126 assert_eq!(state.consume_scroll_delta(), 0.0);
1127 });
1128 }
1129
1130 #[test]
1131 fn dispatch_scroll_delta_clamps_pending_backlog() {
1132 with_test_runtime(|| {
1133 let state = new_lazy_list_state();
1134 enable_bidirectional_scroll(&state);
1135
1136 state.dispatch_scroll_delta(-1_500.0);
1137 state.dispatch_scroll_delta(-1_500.0);
1138 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1139
1140 state.dispatch_scroll_delta(3_000.0);
1141 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1142 });
1143 }
1144
1145 #[test]
1146 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1147 with_test_runtime(|| {
1148 let state = new_lazy_list_state();
1149 enable_bidirectional_scroll(&state);
1150 let invalidations = Rc::new(Cell::new(0u32));
1151 let invalidations_clone = Rc::clone(&invalidations);
1152 state.add_invalidate_callback(Rc::new(move || {
1153 invalidations_clone.set(invalidations_clone.get() + 1);
1154 }));
1155
1156 state.dispatch_scroll_delta(-3_000.0);
1157 assert_eq!(invalidations.get(), 1);
1158 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1159
1160 state.dispatch_scroll_delta(-100.0);
1162 assert_eq!(invalidations.get(), 1);
1163
1164 state.dispatch_scroll_delta(100.0);
1166 assert_eq!(invalidations.get(), 2);
1167 });
1168 }
1169
1170 #[test]
1171 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1172 with_test_runtime(|| {
1173 let state = new_lazy_list_state();
1174 mark_scroll_bounds_known(&state);
1175 state.can_scroll_forward_state.set(false);
1176 state.can_scroll_backward_state.set(true);
1177
1178 let consumed = state.dispatch_scroll_delta(-24.0);
1179
1180 assert_eq!(consumed, 0.0);
1181 assert_eq!(state.peek_scroll_delta(), 0.0);
1182 });
1183 }
1184
1185 #[test]
1186 fn equality_does_not_deref_released_inner_state() {
1187 let mut composition = Composition::new(MemoryApplier::new());
1188 let key = location_key(file!(), line!(), column!());
1189
1190 let mut first = None;
1191 composition
1192 .render(key, || {
1193 first = Some(super::remember_lazy_list_state());
1194 })
1195 .expect("initial render");
1196 let first = first.expect("first lazy state");
1197
1198 composition
1199 .render(key, || {})
1200 .expect("dispose first lazy state");
1201 assert!(
1202 !first.inner.is_alive(),
1203 "expected first lazy state to be released after disposal"
1204 );
1205
1206 let mut second = None;
1207 composition
1208 .render(key, || {
1209 second = Some(super::remember_lazy_list_state());
1210 })
1211 .expect("second render");
1212 let second = second.expect("second lazy state");
1213
1214 assert!(
1215 first != second,
1216 "released lazy state handle must compare by identity without panicking"
1217 );
1218 }
1219
1220 #[test]
1221 fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1222 let mut composition = Composition::new(MemoryApplier::new());
1223 let key = location_key(file!(), line!(), column!());
1224
1225 let mut released = None;
1226 composition
1227 .render(key, || {
1228 released = Some(super::remember_lazy_list_state());
1229 })
1230 .expect("initial render");
1231 let released = released.expect("lazy list state");
1232
1233 composition
1234 .render(key, || {})
1235 .expect("dispose lazy list state");
1236 assert!(
1237 !released.inner.is_alive(),
1238 "expected lazy list state to be released after disposal"
1239 );
1240
1241 assert_eq!(released.first_visible_item_index(), 0);
1242 assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1243 assert_eq!(released.nearest_range(), 0..0);
1244 assert_eq!(
1245 released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1246 0
1247 );
1248 released.update_scroll_position(3, 12.0);
1249 released.update_scroll_position_with_key(3, 12.0, 42);
1250 released.update_scroll_bounds();
1251 }
1252
1253 #[test]
1254 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1255 with_test_runtime(|| {
1256 let state = new_lazy_list_state();
1257 mark_scroll_bounds_known(&state);
1258 enable_bidirectional_scroll(&state);
1259 state.dispatch_scroll_delta(-300.0);
1260 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1261
1262 state.can_scroll_forward_state.set(false);
1263
1264 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1265 assert_eq!(blocked_consumed, 0.0);
1266 assert_eq!(state.peek_scroll_delta(), 0.0);
1267
1268 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1269 assert_eq!(reverse_consumed, 12.0);
1270 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1271 });
1272 }
1273
1274 #[test]
1275 fn negative_scroll_delta_prefetches_forward_items() {
1276 with_test_runtime(|| {
1277 let state = new_lazy_list_state();
1278 state.dispatch_scroll_delta(-24.0);
1279 state.record_scroll_direction(state.peek_scroll_delta());
1280 state.update_prefetch_queue(10, 15, 100);
1281
1282 assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1283 });
1284 }
1285}