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 pub fn index(&self) -> usize {
82 self.index.get()
83 }
84
85 pub fn scroll_offset(&self) -> f32 {
87 self.scroll_offset.get()
88 }
89
90 pub(crate) fn update_from_measure_result(
95 &self,
96 first_visible_index: usize,
97 first_visible_scroll_offset: f32,
98 first_visible_item_key: Option<u64>,
99 ) {
100 self.inner.with(|rc| {
102 let mut inner = rc.borrow_mut();
103 inner.last_known_first_item_key = first_visible_item_key;
104 inner.nearest_range_state.update(first_visible_index);
105 });
106
107 let old_index = self.index.get();
109 if old_index != first_visible_index {
110 self.index.set(first_visible_index);
111 }
112 let old_offset = self.scroll_offset.get();
113 if (old_offset - first_visible_scroll_offset).abs() > 0.001 {
114 self.scroll_offset.set(first_visible_scroll_offset);
115 }
116 }
117
118 pub(crate) fn request_position_and_forget_last_known_key(
121 &self,
122 index: usize,
123 scroll_offset: f32,
124 ) {
125 if self.index.get() != index {
127 self.index.set(index);
128 }
129 if (self.scroll_offset.get() - scroll_offset).abs() > 0.001 {
130 self.scroll_offset.set(scroll_offset);
131 }
132 self.inner.with(|rc| {
134 let mut inner = rc.borrow_mut();
135 inner.last_known_first_item_key = None;
136 inner.nearest_range_state.update(index);
137 });
138 }
139
140 pub(crate) fn update_if_first_item_moved<F>(
143 &self,
144 new_item_count: usize,
145 find_by_key: F,
146 ) -> usize
147 where
148 F: Fn(u64) -> Option<usize>,
149 {
150 let current_index = self.index.get();
151 let last_key = self.inner.with(|rc| rc.borrow().last_known_first_item_key);
152
153 let new_index = match last_key {
154 None => current_index.min(new_item_count.saturating_sub(1)),
155 Some(key) => find_by_key(key)
156 .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
157 };
158
159 if current_index != new_index {
160 self.index.set(new_index);
161 self.inner.with(|rc| {
162 rc.borrow_mut().nearest_range_state.update(new_index);
163 });
164 }
165 new_index
166 }
167
168 pub fn nearest_range(&self) -> std::ops::Range<usize> {
170 self.inner
171 .with(|rc| rc.borrow().nearest_range_state.range())
172 }
173}
174
175#[derive(Clone, Copy)]
210pub struct LazyListState {
211 scroll_position: LazyListScrollPosition,
213 can_scroll_forward_state: MutableState<bool>,
215 can_scroll_backward_state: MutableState<bool>,
217 stats_state: MutableState<LazyLayoutStats>,
220 inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
222}
223
224impl PartialEq for LazyListState {
227 fn eq(&self, other: &Self) -> bool {
228 std::ptr::eq(self.inner_ptr(), other.inner_ptr())
229 }
230}
231
232struct LazyListStateInner {
234 scroll_to_be_consumed: f32,
236
237 pending_scroll_to_index: Option<(usize, f32)>,
239
240 layout_info: LazyListLayoutInfo,
242
243 invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
245 next_callback_id: u64,
246
247 has_layout_invalidation_callback: bool,
250
251 total_composed: usize,
253 reuse_count: usize,
254
255 item_size_cache: std::collections::HashMap<usize, f32>,
257 item_size_lru: std::collections::VecDeque<usize>,
259
260 average_item_size: f32,
262 total_measured_items: usize,
263
264 prefetch_scheduler: PrefetchScheduler,
266
267 prefetch_strategy: PrefetchStrategy,
269
270 last_scroll_direction: f32,
272}
273
274#[composable]
289pub fn remember_lazy_list_state() -> LazyListState {
290 remember_lazy_list_state_with_position(0, 0.0)
291}
292
293#[composable]
297pub fn remember_lazy_list_state_with_position(
298 initial_first_visible_item_index: usize,
299 initial_first_visible_item_scroll_offset: f32,
300) -> LazyListState {
301 let scroll_position = LazyListScrollPosition {
303 index: cranpose_core::useState(|| initial_first_visible_item_index),
304 scroll_offset: cranpose_core::useState(|| initial_first_visible_item_scroll_offset),
305 inner: cranpose_core::useState(|| {
306 Rc::new(RefCell::new(ScrollPositionInner {
307 last_known_first_item_key: None,
308 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
309 }))
310 }),
311 };
312
313 let inner = cranpose_core::useState(|| {
315 Rc::new(RefCell::new(LazyListStateInner {
316 scroll_to_be_consumed: 0.0,
317 pending_scroll_to_index: None,
318 layout_info: LazyListLayoutInfo::default(),
319 invalidate_callbacks: Vec::new(),
320 next_callback_id: 1,
321 has_layout_invalidation_callback: false,
322 total_composed: 0,
323 reuse_count: 0,
324 item_size_cache: std::collections::HashMap::new(),
325 item_size_lru: std::collections::VecDeque::new(),
326 average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
327 total_measured_items: 0,
328 prefetch_scheduler: PrefetchScheduler::new(),
329 prefetch_strategy: PrefetchStrategy::default(),
330 last_scroll_direction: 0.0,
331 }))
332 });
333
334 let can_scroll_forward_state = cranpose_core::useState(|| false);
336 let can_scroll_backward_state = cranpose_core::useState(|| false);
337 let stats_state = cranpose_core::useState(LazyLayoutStats::default);
338
339 LazyListState {
340 scroll_position,
341 can_scroll_forward_state,
342 can_scroll_backward_state,
343 stats_state,
344 inner,
345 }
346}
347
348impl LazyListState {
349 pub fn inner_ptr(&self) -> *const () {
352 self.inner.with(|rc| Rc::as_ptr(rc) as *const ())
353 }
354
355 pub fn first_visible_item_index(&self) -> usize {
360 self.scroll_position.index()
362 }
363
364 pub fn first_visible_item_scroll_offset(&self) -> f32 {
370 self.scroll_position.scroll_offset()
372 }
373
374 pub fn layout_info(&self) -> LazyListLayoutInfo {
376 self.inner.with(|rc| rc.borrow().layout_info.clone())
377 }
378
379 pub fn stats(&self) -> LazyLayoutStats {
385 let reactive = self.stats_state.get();
387 let (total_composed, reuse_count) = self.inner.with(|rc| {
388 let inner = rc.borrow();
389 (inner.total_composed, inner.reuse_count)
390 });
391 LazyLayoutStats {
392 items_in_use: reactive.items_in_use,
393 items_in_pool: reactive.items_in_pool,
394 total_composed,
395 reuse_count,
396 }
397 }
398
399 pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
404 let current = self.stats_state.get();
405
406 let should_update_reactive = if items_in_use > current.items_in_use {
415 true
417 } else if items_in_use < current.items_in_use {
418 current.items_in_use - items_in_use > 1
420 } else {
421 false
422 };
423
424 if should_update_reactive {
425 self.stats_state.set(LazyLayoutStats {
426 items_in_use,
427 items_in_pool,
428 ..current
429 });
430 }
431 }
434
435 pub fn record_composition(&self, was_reused: bool) {
440 self.inner.with(|rc| {
441 let mut inner = rc.borrow_mut();
442 inner.total_composed += 1;
443 if was_reused {
444 inner.reuse_count += 1;
445 }
446 });
447 }
448
449 pub fn record_scroll_direction(&self, delta: f32) {
452 if delta.abs() > 0.001 {
453 self.inner.with(|rc| {
454 rc.borrow_mut().last_scroll_direction = delta.signum();
455 });
456 }
457 }
458
459 pub fn update_prefetch_queue(
462 &self,
463 first_visible_index: usize,
464 last_visible_index: usize,
465 total_items: usize,
466 ) {
467 self.inner.with(|rc| {
468 let mut inner = rc.borrow_mut();
469 let direction = inner.last_scroll_direction;
470 let strategy = inner.prefetch_strategy.clone();
471 inner.prefetch_scheduler.update(
472 first_visible_index,
473 last_visible_index,
474 total_items,
475 direction,
476 &strategy,
477 );
478 });
479 }
480
481 pub fn take_prefetch_indices(&self) -> Vec<usize> {
484 self.inner.with(|rc| {
485 let mut inner = rc.borrow_mut();
486 let mut indices = Vec::new();
487 while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
488 indices.push(idx);
489 }
490 indices
491 })
492 }
493
494 pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
500 if lazy_measure_telemetry_enabled() {
501 log::warn!(
502 "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
503 index,
504 scroll_offset
505 );
506 }
507 self.inner.with(|rc| {
509 rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
510 });
511
512 self.scroll_position
514 .request_position_and_forget_last_known_key(index, scroll_offset);
515
516 self.invalidate();
517 }
518
519 pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
527 if !self.inner.is_alive() {
530 return 0.0;
531 }
532 let has_scroll_bounds = self
533 .inner
534 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
535 let pushing_forward = delta < -0.001;
536 let pushing_backward = delta > 0.001;
537 let blocked_by_bounds = has_scroll_bounds
538 && ((pushing_forward && !self.can_scroll_forward())
539 || (pushing_backward && !self.can_scroll_backward()));
540
541 if blocked_by_bounds {
542 let should_invalidate = self.inner.with(|rc| {
543 let mut inner = rc.borrow_mut();
544 let pending_before = inner.scroll_to_be_consumed;
545 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
547 inner.scroll_to_be_consumed = 0.0;
548 }
549 if lazy_measure_telemetry_enabled() {
550 log::warn!(
551 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
552 delta,
553 pending_before,
554 inner.scroll_to_be_consumed
555 );
556 }
557 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
558 });
559 if should_invalidate {
560 self.invalidate();
561 }
562 return 0.0;
563 }
564
565 let should_invalidate = self.inner.with(|rc| {
566 let mut inner = rc.borrow_mut();
567 let pending_before = inner.scroll_to_be_consumed;
568 let pending = inner.scroll_to_be_consumed;
569 let reverse_input = pending.abs() > 0.001
570 && delta.abs() > 0.001
571 && pending.signum() != delta.signum();
572 if reverse_input {
573 if lazy_measure_telemetry_enabled() {
574 log::warn!(
575 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
576 pending,
577 delta
578 );
579 }
580 inner.scroll_to_be_consumed = delta;
584 } else {
585 inner.scroll_to_be_consumed += delta;
586 }
587 inner.scroll_to_be_consumed = inner
588 .scroll_to_be_consumed
589 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
590 if lazy_measure_telemetry_enabled() {
591 log::warn!(
592 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
593 delta,
594 inner.scroll_to_be_consumed
595 );
596 }
597 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
598 });
599 if should_invalidate {
600 self.invalidate();
601 }
602 delta }
604
605 pub(crate) fn consume_scroll_delta(&self) -> f32 {
609 self.inner.with(|rc| {
610 let mut inner = rc.borrow_mut();
611 let delta = inner.scroll_to_be_consumed;
612 inner.scroll_to_be_consumed = 0.0;
613 delta
614 })
615 }
616
617 pub fn peek_scroll_delta(&self) -> f32 {
624 self.inner.with(|rc| rc.borrow().scroll_to_be_consumed)
625 }
626
627 pub(crate) fn consume_scroll_to_index(&self) -> Option<(usize, f32)> {
631 self.inner
632 .with(|rc| rc.borrow_mut().pending_scroll_to_index.take())
633 }
634
635 pub fn cache_item_size(&self, index: usize, size: f32) {
645 use std::collections::hash_map::Entry;
646 self.inner.with(|rc| {
647 let mut inner = rc.borrow_mut();
648 const MAX_CACHE_SIZE: usize = 100;
649
650 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
652 entry.insert(size);
654 if let Some(pos) = inner.item_size_lru.iter().position(|&k| k == index) {
656 inner.item_size_lru.remove(pos);
657 }
658 inner.item_size_lru.push_back(index);
659 return;
660 }
661
662 while inner.item_size_cache.len() >= MAX_CACHE_SIZE {
664 if let Some(oldest) = inner.item_size_lru.pop_front() {
665 if inner.item_size_cache.remove(&oldest).is_some() {
667 break; }
669 } else {
670 break; }
672 }
673
674 inner.item_size_cache.insert(index, size);
676 inner.item_size_lru.push_back(index);
677
678 inner.total_measured_items += 1;
680 let n = inner.total_measured_items as f32;
681 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
682 });
683 }
684
685 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
687 self.inner
688 .with(|rc| rc.borrow().item_size_cache.get(&index).copied())
689 }
690
691 pub fn average_item_size(&self) -> f32 {
693 self.inner.with(|rc| rc.borrow().average_item_size)
694 }
695
696 pub fn nearest_range(&self) -> std::ops::Range<usize> {
698 self.scroll_position.nearest_range()
700 }
701
702 pub(crate) fn update_scroll_position(
706 &self,
707 first_visible_item_index: usize,
708 first_visible_item_scroll_offset: f32,
709 ) {
710 self.scroll_position.update_from_measure_result(
711 first_visible_item_index,
712 first_visible_item_scroll_offset,
713 None,
714 );
715 }
716
717 pub(crate) fn update_scroll_position_with_key(
721 &self,
722 first_visible_item_index: usize,
723 first_visible_item_scroll_offset: f32,
724 first_visible_item_key: u64,
725 ) {
726 self.scroll_position.update_from_measure_result(
727 first_visible_item_index,
728 first_visible_item_scroll_offset,
729 Some(first_visible_item_key),
730 );
731 }
732
733 pub fn update_scroll_position_if_item_moved<F>(
741 &self,
742 new_item_count: usize,
743 get_index_by_key: F,
744 ) -> usize
745 where
746 F: Fn(u64) -> Option<usize>,
747 {
748 self.scroll_position
750 .update_if_first_item_moved(new_item_count, get_index_by_key)
751 }
752
753 pub(crate) fn update_layout_info(&self, info: LazyListLayoutInfo) {
755 self.inner.with(|rc| rc.borrow_mut().layout_info = info);
756 }
757
758 pub fn can_scroll_forward(&self) -> bool {
763 self.can_scroll_forward_state.get()
764 }
765
766 pub fn can_scroll_backward(&self) -> bool {
771 self.can_scroll_backward_state.get()
772 }
773
774 pub(crate) fn update_scroll_bounds(&self) {
778 let can_forward = self.inner.with(|rc| {
780 let inner = rc.borrow();
781 let info = &inner.layout_info;
782 let viewport_end = info.viewport_size - info.after_content_padding;
785 if let Some(last_visible) = info.visible_items_info.last() {
786 last_visible.index < info.total_items_count.saturating_sub(1)
787 || (last_visible.offset + last_visible.size) > viewport_end
788 } else {
789 false
790 }
791 });
792
793 let can_backward =
795 self.scroll_position.index() > 0 || self.scroll_position.scroll_offset() > 0.0;
796
797 if self.can_scroll_forward_state.get() != can_forward {
799 self.can_scroll_forward_state.set(can_forward);
800 }
801 if self.can_scroll_backward_state.get() != can_backward {
802 self.can_scroll_backward_state.set(can_backward);
803 }
804 }
805
806 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
808 self.inner.with(|rc| {
809 let mut inner = rc.borrow_mut();
810 let id = inner.next_callback_id;
811 inner.next_callback_id += 1;
812 inner.invalidate_callbacks.push((id, callback));
813 id
814 })
815 }
816
817 pub fn try_register_layout_callback(&self, callback: Rc<dyn Fn()>) -> bool {
822 self.inner.with(|rc| {
823 let mut inner = rc.borrow_mut();
824 if inner.has_layout_invalidation_callback {
825 return false;
826 }
827 inner.has_layout_invalidation_callback = true;
828 let id = inner.next_callback_id;
829 inner.next_callback_id += 1;
830 inner.invalidate_callbacks.push((id, callback));
831 true
832 })
833 }
834
835 pub fn remove_invalidate_callback(&self, id: u64) {
837 self.inner.with(|rc| {
838 rc.borrow_mut()
839 .invalidate_callbacks
840 .retain(|(cb_id, _)| *cb_id != id);
841 });
842 }
843
844 fn invalidate(&self) {
845 if !self.inner.is_alive() {
846 return;
847 }
848 let callbacks: Vec<_> = self.inner.with(|rc| {
851 rc.borrow()
852 .invalidate_callbacks
853 .iter()
854 .map(|(_, cb)| Rc::clone(cb))
855 .collect()
856 });
857
858 for callback in callbacks {
859 callback();
860 }
861 }
862}
863
864#[derive(Clone, Default, Debug)]
866pub struct LazyListLayoutInfo {
867 pub visible_items_info: Vec<LazyListItemInfo>,
869
870 pub total_items_count: usize,
872
873 pub raw_viewport_size: f32,
875
876 pub is_infinite_viewport: bool,
878
879 pub viewport_size: f32,
881
882 pub viewport_start_offset: f32,
884
885 pub viewport_end_offset: f32,
887
888 pub before_content_padding: f32,
890
891 pub after_content_padding: f32,
893}
894
895#[derive(Clone, Debug)]
897pub struct LazyListItemInfo {
898 pub index: usize,
900
901 pub key: u64,
903
904 pub offset: f32,
906
907 pub size: f32,
909}
910
911#[cfg(test)]
913pub mod test_helpers {
914 use super::*;
915 use cranpose_core::{DefaultScheduler, Runtime};
916 use std::sync::Arc;
917
918 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
921 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
922 f()
923 }
924
925 pub fn new_lazy_list_state() -> LazyListState {
928 new_lazy_list_state_with_position(0, 0.0)
929 }
930
931 pub fn new_lazy_list_state_with_position(
934 initial_first_visible_item_index: usize,
935 initial_first_visible_item_scroll_offset: f32,
936 ) -> LazyListState {
937 let scroll_position = LazyListScrollPosition {
939 index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
940 scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
941 inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
942 last_known_first_item_key: None,
943 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
944 }))),
945 };
946
947 let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
949 scroll_to_be_consumed: 0.0,
950 pending_scroll_to_index: None,
951 layout_info: LazyListLayoutInfo::default(),
952 invalidate_callbacks: Vec::new(),
953 next_callback_id: 1,
954 has_layout_invalidation_callback: false,
955 total_composed: 0,
956 reuse_count: 0,
957 item_size_cache: std::collections::HashMap::new(),
958 item_size_lru: std::collections::VecDeque::new(),
959 average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
960 total_measured_items: 0,
961 prefetch_scheduler: PrefetchScheduler::new(),
962 prefetch_strategy: PrefetchStrategy::default(),
963 last_scroll_direction: 0.0,
964 })));
965
966 let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
968 let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
969 let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
970
971 LazyListState {
972 scroll_position,
973 can_scroll_forward_state,
974 can_scroll_backward_state,
975 stats_state,
976 inner,
977 }
978 }
979}
980
981#[cfg(test)]
982mod tests {
983 use super::test_helpers::{new_lazy_list_state, with_test_runtime};
984 use super::{LazyListLayoutInfo, LazyListState};
985 use std::cell::Cell;
986 use std::rc::Rc;
987
988 fn enable_bidirectional_scroll(state: &LazyListState) {
989 state.can_scroll_forward_state.set(true);
990 state.can_scroll_backward_state.set(true);
991 }
992
993 fn mark_scroll_bounds_known(state: &LazyListState) {
994 state.update_layout_info(LazyListLayoutInfo {
995 total_items_count: 10,
996 ..Default::default()
997 });
998 }
999
1000 #[test]
1001 fn dispatch_scroll_delta_accumulates_same_direction() {
1002 with_test_runtime(|| {
1003 let state = new_lazy_list_state();
1004 enable_bidirectional_scroll(&state);
1005
1006 state.dispatch_scroll_delta(-12.0);
1007 state.dispatch_scroll_delta(-8.0);
1008
1009 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1010 assert!((state.consume_scroll_delta() + 20.0).abs() < 0.001);
1011 assert_eq!(state.consume_scroll_delta(), 0.0);
1012 });
1013 }
1014
1015 #[test]
1016 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1017 with_test_runtime(|| {
1018 let state = new_lazy_list_state();
1019 enable_bidirectional_scroll(&state);
1020
1021 state.dispatch_scroll_delta(-120.0);
1022 state.dispatch_scroll_delta(-30.0);
1023 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1024
1025 state.dispatch_scroll_delta(18.0);
1026
1027 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1028 assert!((state.consume_scroll_delta() - 18.0).abs() < 0.001);
1029 assert_eq!(state.consume_scroll_delta(), 0.0);
1030 });
1031 }
1032
1033 #[test]
1034 fn dispatch_scroll_delta_clamps_pending_backlog() {
1035 with_test_runtime(|| {
1036 let state = new_lazy_list_state();
1037 enable_bidirectional_scroll(&state);
1038
1039 state.dispatch_scroll_delta(-1_500.0);
1040 state.dispatch_scroll_delta(-1_500.0);
1041 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1042
1043 state.dispatch_scroll_delta(3_000.0);
1044 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1045 });
1046 }
1047
1048 #[test]
1049 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1050 with_test_runtime(|| {
1051 let state = new_lazy_list_state();
1052 enable_bidirectional_scroll(&state);
1053 let invalidations = Rc::new(Cell::new(0u32));
1054 let invalidations_clone = Rc::clone(&invalidations);
1055 state.add_invalidate_callback(Rc::new(move || {
1056 invalidations_clone.set(invalidations_clone.get() + 1);
1057 }));
1058
1059 state.dispatch_scroll_delta(-3_000.0);
1060 assert_eq!(invalidations.get(), 1);
1061 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1062
1063 state.dispatch_scroll_delta(-100.0);
1065 assert_eq!(invalidations.get(), 1);
1066
1067 state.dispatch_scroll_delta(100.0);
1069 assert_eq!(invalidations.get(), 2);
1070 });
1071 }
1072
1073 #[test]
1074 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1075 with_test_runtime(|| {
1076 let state = new_lazy_list_state();
1077 mark_scroll_bounds_known(&state);
1078 state.can_scroll_forward_state.set(false);
1079 state.can_scroll_backward_state.set(true);
1080
1081 let consumed = state.dispatch_scroll_delta(-24.0);
1082
1083 assert_eq!(consumed, 0.0);
1084 assert_eq!(state.peek_scroll_delta(), 0.0);
1085 });
1086 }
1087
1088 #[test]
1089 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1090 with_test_runtime(|| {
1091 let state = new_lazy_list_state();
1092 mark_scroll_bounds_known(&state);
1093 enable_bidirectional_scroll(&state);
1094 state.dispatch_scroll_delta(-300.0);
1095 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1096
1097 state.can_scroll_forward_state.set(false);
1098
1099 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1100 assert_eq!(blocked_consumed, 0.0);
1101 assert_eq!(state.peek_scroll_delta(), 0.0);
1102
1103 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1104 assert_eq!(reverse_consumed, 12.0);
1105 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1106 });
1107 }
1108}