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 let has_scroll_bounds = self
528 .inner
529 .with(|rc| rc.borrow().layout_info.total_items_count > 0);
530 let pushing_forward = delta < -0.001;
531 let pushing_backward = delta > 0.001;
532 let blocked_by_bounds = has_scroll_bounds
533 && ((pushing_forward && !self.can_scroll_forward())
534 || (pushing_backward && !self.can_scroll_backward()));
535
536 if blocked_by_bounds {
537 let should_invalidate = self.inner.with(|rc| {
538 let mut inner = rc.borrow_mut();
539 let pending_before = inner.scroll_to_be_consumed;
540 if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
542 inner.scroll_to_be_consumed = 0.0;
543 }
544 if lazy_measure_telemetry_enabled() {
545 log::warn!(
546 "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
547 delta,
548 pending_before,
549 inner.scroll_to_be_consumed
550 );
551 }
552 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
553 });
554 if should_invalidate {
555 self.invalidate();
556 }
557 return 0.0;
558 }
559
560 let should_invalidate = self.inner.with(|rc| {
561 let mut inner = rc.borrow_mut();
562 let pending_before = inner.scroll_to_be_consumed;
563 let pending = inner.scroll_to_be_consumed;
564 let reverse_input = pending.abs() > 0.001
565 && delta.abs() > 0.001
566 && pending.signum() != delta.signum();
567 if reverse_input {
568 if lazy_measure_telemetry_enabled() {
569 log::warn!(
570 "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
571 pending,
572 delta
573 );
574 }
575 inner.scroll_to_be_consumed = delta;
579 } else {
580 inner.scroll_to_be_consumed += delta;
581 }
582 inner.scroll_to_be_consumed = inner
583 .scroll_to_be_consumed
584 .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
585 if lazy_measure_telemetry_enabled() {
586 log::warn!(
587 "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
588 delta,
589 inner.scroll_to_be_consumed
590 );
591 }
592 (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
593 });
594 if should_invalidate {
595 self.invalidate();
596 }
597 delta }
599
600 pub(crate) fn consume_scroll_delta(&self) -> f32 {
604 self.inner.with(|rc| {
605 let mut inner = rc.borrow_mut();
606 let delta = inner.scroll_to_be_consumed;
607 inner.scroll_to_be_consumed = 0.0;
608 delta
609 })
610 }
611
612 pub fn peek_scroll_delta(&self) -> f32 {
619 self.inner.with(|rc| rc.borrow().scroll_to_be_consumed)
620 }
621
622 pub(crate) fn consume_scroll_to_index(&self) -> Option<(usize, f32)> {
626 self.inner
627 .with(|rc| rc.borrow_mut().pending_scroll_to_index.take())
628 }
629
630 pub fn cache_item_size(&self, index: usize, size: f32) {
640 use std::collections::hash_map::Entry;
641 self.inner.with(|rc| {
642 let mut inner = rc.borrow_mut();
643 const MAX_CACHE_SIZE: usize = 100;
644
645 if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
647 entry.insert(size);
649 if let Some(pos) = inner.item_size_lru.iter().position(|&k| k == index) {
651 inner.item_size_lru.remove(pos);
652 }
653 inner.item_size_lru.push_back(index);
654 return;
655 }
656
657 while inner.item_size_cache.len() >= MAX_CACHE_SIZE {
659 if let Some(oldest) = inner.item_size_lru.pop_front() {
660 if inner.item_size_cache.remove(&oldest).is_some() {
662 break; }
664 } else {
665 break; }
667 }
668
669 inner.item_size_cache.insert(index, size);
671 inner.item_size_lru.push_back(index);
672
673 inner.total_measured_items += 1;
675 let n = inner.total_measured_items as f32;
676 inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
677 });
678 }
679
680 pub fn get_cached_size(&self, index: usize) -> Option<f32> {
682 self.inner
683 .with(|rc| rc.borrow().item_size_cache.get(&index).copied())
684 }
685
686 pub fn average_item_size(&self) -> f32 {
688 self.inner.with(|rc| rc.borrow().average_item_size)
689 }
690
691 pub fn nearest_range(&self) -> std::ops::Range<usize> {
693 self.scroll_position.nearest_range()
695 }
696
697 pub(crate) fn update_scroll_position(
701 &self,
702 first_visible_item_index: usize,
703 first_visible_item_scroll_offset: f32,
704 ) {
705 self.scroll_position.update_from_measure_result(
706 first_visible_item_index,
707 first_visible_item_scroll_offset,
708 None,
709 );
710 }
711
712 pub(crate) fn update_scroll_position_with_key(
716 &self,
717 first_visible_item_index: usize,
718 first_visible_item_scroll_offset: f32,
719 first_visible_item_key: u64,
720 ) {
721 self.scroll_position.update_from_measure_result(
722 first_visible_item_index,
723 first_visible_item_scroll_offset,
724 Some(first_visible_item_key),
725 );
726 }
727
728 pub fn update_scroll_position_if_item_moved<F>(
736 &self,
737 new_item_count: usize,
738 get_index_by_key: F,
739 ) -> usize
740 where
741 F: Fn(u64) -> Option<usize>,
742 {
743 self.scroll_position
745 .update_if_first_item_moved(new_item_count, get_index_by_key)
746 }
747
748 pub(crate) fn update_layout_info(&self, info: LazyListLayoutInfo) {
750 self.inner.with(|rc| rc.borrow_mut().layout_info = info);
751 }
752
753 pub fn can_scroll_forward(&self) -> bool {
758 self.can_scroll_forward_state.get()
759 }
760
761 pub fn can_scroll_backward(&self) -> bool {
766 self.can_scroll_backward_state.get()
767 }
768
769 pub(crate) fn update_scroll_bounds(&self) {
773 let can_forward = self.inner.with(|rc| {
775 let inner = rc.borrow();
776 let info = &inner.layout_info;
777 let viewport_end = info.viewport_size - info.after_content_padding;
780 if let Some(last_visible) = info.visible_items_info.last() {
781 last_visible.index < info.total_items_count.saturating_sub(1)
782 || (last_visible.offset + last_visible.size) > viewport_end
783 } else {
784 false
785 }
786 });
787
788 let can_backward =
790 self.scroll_position.index() > 0 || self.scroll_position.scroll_offset() > 0.0;
791
792 if self.can_scroll_forward_state.get() != can_forward {
794 self.can_scroll_forward_state.set(can_forward);
795 }
796 if self.can_scroll_backward_state.get() != can_backward {
797 self.can_scroll_backward_state.set(can_backward);
798 }
799 }
800
801 pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
803 self.inner.with(|rc| {
804 let mut inner = rc.borrow_mut();
805 let id = inner.next_callback_id;
806 inner.next_callback_id += 1;
807 inner.invalidate_callbacks.push((id, callback));
808 id
809 })
810 }
811
812 pub fn try_register_layout_callback(&self, callback: Rc<dyn Fn()>) -> bool {
817 self.inner.with(|rc| {
818 let mut inner = rc.borrow_mut();
819 if inner.has_layout_invalidation_callback {
820 return false;
821 }
822 inner.has_layout_invalidation_callback = true;
823 let id = inner.next_callback_id;
824 inner.next_callback_id += 1;
825 inner.invalidate_callbacks.push((id, callback));
826 true
827 })
828 }
829
830 pub fn remove_invalidate_callback(&self, id: u64) {
832 self.inner.with(|rc| {
833 rc.borrow_mut()
834 .invalidate_callbacks
835 .retain(|(cb_id, _)| *cb_id != id);
836 });
837 }
838
839 fn invalidate(&self) {
840 let callbacks: Vec<_> = self.inner.with(|rc| {
843 rc.borrow()
844 .invalidate_callbacks
845 .iter()
846 .map(|(_, cb)| Rc::clone(cb))
847 .collect()
848 });
849
850 for callback in callbacks {
851 callback();
852 }
853 }
854}
855
856#[derive(Clone, Default, Debug)]
858pub struct LazyListLayoutInfo {
859 pub visible_items_info: Vec<LazyListItemInfo>,
861
862 pub total_items_count: usize,
864
865 pub raw_viewport_size: f32,
867
868 pub is_infinite_viewport: bool,
870
871 pub viewport_size: f32,
873
874 pub viewport_start_offset: f32,
876
877 pub viewport_end_offset: f32,
879
880 pub before_content_padding: f32,
882
883 pub after_content_padding: f32,
885}
886
887#[derive(Clone, Debug)]
889pub struct LazyListItemInfo {
890 pub index: usize,
892
893 pub key: u64,
895
896 pub offset: f32,
898
899 pub size: f32,
901}
902
903#[cfg(test)]
905pub mod test_helpers {
906 use super::*;
907 use cranpose_core::{DefaultScheduler, Runtime};
908 use std::sync::Arc;
909
910 pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
913 let _runtime = Runtime::new(Arc::new(DefaultScheduler));
914 f()
915 }
916
917 pub fn new_lazy_list_state() -> LazyListState {
920 new_lazy_list_state_with_position(0, 0.0)
921 }
922
923 pub fn new_lazy_list_state_with_position(
926 initial_first_visible_item_index: usize,
927 initial_first_visible_item_scroll_offset: f32,
928 ) -> LazyListState {
929 let scroll_position = LazyListScrollPosition {
931 index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
932 scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
933 inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
934 last_known_first_item_key: None,
935 nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
936 }))),
937 };
938
939 let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
941 scroll_to_be_consumed: 0.0,
942 pending_scroll_to_index: None,
943 layout_info: LazyListLayoutInfo::default(),
944 invalidate_callbacks: Vec::new(),
945 next_callback_id: 1,
946 has_layout_invalidation_callback: false,
947 total_composed: 0,
948 reuse_count: 0,
949 item_size_cache: std::collections::HashMap::new(),
950 item_size_lru: std::collections::VecDeque::new(),
951 average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
952 total_measured_items: 0,
953 prefetch_scheduler: PrefetchScheduler::new(),
954 prefetch_strategy: PrefetchStrategy::default(),
955 last_scroll_direction: 0.0,
956 })));
957
958 let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
960 let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
961 let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
962
963 LazyListState {
964 scroll_position,
965 can_scroll_forward_state,
966 can_scroll_backward_state,
967 stats_state,
968 inner,
969 }
970 }
971}
972
973#[cfg(test)]
974mod tests {
975 use super::test_helpers::{new_lazy_list_state, with_test_runtime};
976 use super::{LazyListLayoutInfo, LazyListState};
977 use std::cell::Cell;
978 use std::rc::Rc;
979
980 fn enable_bidirectional_scroll(state: &LazyListState) {
981 state.can_scroll_forward_state.set(true);
982 state.can_scroll_backward_state.set(true);
983 }
984
985 fn mark_scroll_bounds_known(state: &LazyListState) {
986 state.update_layout_info(LazyListLayoutInfo {
987 total_items_count: 10,
988 ..Default::default()
989 });
990 }
991
992 #[test]
993 fn dispatch_scroll_delta_accumulates_same_direction() {
994 with_test_runtime(|| {
995 let state = new_lazy_list_state();
996 enable_bidirectional_scroll(&state);
997
998 state.dispatch_scroll_delta(-12.0);
999 state.dispatch_scroll_delta(-8.0);
1000
1001 assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1002 assert!((state.consume_scroll_delta() + 20.0).abs() < 0.001);
1003 assert_eq!(state.consume_scroll_delta(), 0.0);
1004 });
1005 }
1006
1007 #[test]
1008 fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1009 with_test_runtime(|| {
1010 let state = new_lazy_list_state();
1011 enable_bidirectional_scroll(&state);
1012
1013 state.dispatch_scroll_delta(-120.0);
1014 state.dispatch_scroll_delta(-30.0);
1015 assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1016
1017 state.dispatch_scroll_delta(18.0);
1018
1019 assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1020 assert!((state.consume_scroll_delta() - 18.0).abs() < 0.001);
1021 assert_eq!(state.consume_scroll_delta(), 0.0);
1022 });
1023 }
1024
1025 #[test]
1026 fn dispatch_scroll_delta_clamps_pending_backlog() {
1027 with_test_runtime(|| {
1028 let state = new_lazy_list_state();
1029 enable_bidirectional_scroll(&state);
1030
1031 state.dispatch_scroll_delta(-1_500.0);
1032 state.dispatch_scroll_delta(-1_500.0);
1033 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1034
1035 state.dispatch_scroll_delta(3_000.0);
1036 assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1037 });
1038 }
1039
1040 #[test]
1041 fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1042 with_test_runtime(|| {
1043 let state = new_lazy_list_state();
1044 enable_bidirectional_scroll(&state);
1045 let invalidations = Rc::new(Cell::new(0u32));
1046 let invalidations_clone = Rc::clone(&invalidations);
1047 state.add_invalidate_callback(Rc::new(move || {
1048 invalidations_clone.set(invalidations_clone.get() + 1);
1049 }));
1050
1051 state.dispatch_scroll_delta(-3_000.0);
1052 assert_eq!(invalidations.get(), 1);
1053 assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1054
1055 state.dispatch_scroll_delta(-100.0);
1057 assert_eq!(invalidations.get(), 1);
1058
1059 state.dispatch_scroll_delta(100.0);
1061 assert_eq!(invalidations.get(), 2);
1062 });
1063 }
1064
1065 #[test]
1066 fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1067 with_test_runtime(|| {
1068 let state = new_lazy_list_state();
1069 mark_scroll_bounds_known(&state);
1070 state.can_scroll_forward_state.set(false);
1071 state.can_scroll_backward_state.set(true);
1072
1073 let consumed = state.dispatch_scroll_delta(-24.0);
1074
1075 assert_eq!(consumed, 0.0);
1076 assert_eq!(state.peek_scroll_delta(), 0.0);
1077 });
1078 }
1079
1080 #[test]
1081 fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1082 with_test_runtime(|| {
1083 let state = new_lazy_list_state();
1084 mark_scroll_bounds_known(&state);
1085 enable_bidirectional_scroll(&state);
1086 state.dispatch_scroll_delta(-300.0);
1087 assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1088
1089 state.can_scroll_forward_state.set(false);
1090
1091 let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1092 assert_eq!(blocked_consumed, 0.0);
1093 assert_eq!(state.peek_scroll_delta(), 0.0);
1094
1095 let reverse_consumed = state.dispatch_scroll_delta(12.0);
1096 assert_eq!(reverse_consumed, 12.0);
1097 assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1098 });
1099 }
1100}