1use super::bounds_adjuster::BoundsAdjuster;
8use super::diagnostics;
9use super::item_measurer::{AlwaysMeasureBeyond, BeyondBoundsMeasurePolicy, ItemMeasurer};
10use super::lazy_list_measured_item::{LazyListMeasureResult, LazyListMeasuredItem};
11use super::lazy_list_state::{LazyListLayoutInfo, LazyListState};
12use super::scroll_position_resolver::ScrollPositionResolver;
13use super::viewport::ViewportHandler;
14use std::collections::VecDeque;
15
16pub const DEFAULT_ITEM_SIZE_ESTIMATE: f32 = 48.0;
20const MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS: usize = 8;
21const MIN_ADAPTIVE_SCROLL_DELTA_ITEMS: f32 = 1.5;
22const MIN_ACTIVE_SCROLL_WHEEL_BEYOND_BOUNDS_ITEMS: usize = 2;
23const MIN_ACTIVE_SCROLL_FAST_BEYOND_BOUNDS_ITEMS: usize = MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS;
24const MIN_IDLE_WARM_BEYOND_BOUNDS_ITEMS: usize = 4;
25
26#[derive(Clone, Debug, PartialEq)]
28pub struct LazyListMeasureConfig {
29 pub is_vertical: bool,
31
32 pub reverse_layout: bool,
37
38 pub before_content_padding: f32,
40
41 pub after_content_padding: f32,
43
44 pub spacing: f32,
46
47 pub beyond_bounds_item_count: usize,
50
51 pub vertical_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
54
55 pub horizontal_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
58}
59
60impl Default for LazyListMeasureConfig {
61 fn default() -> Self {
62 Self {
63 is_vertical: true,
64 reverse_layout: false,
65 before_content_padding: 0.0,
66 after_content_padding: 0.0,
67 spacing: 0.0,
68 beyond_bounds_item_count: 2,
69 vertical_arrangement: None,
70 horizontal_arrangement: None,
71 }
72 }
73}
74
75pub fn measure_lazy_list<F>(
95 items_count: usize,
96 state: &LazyListState,
97 viewport_size: f32,
98 _cross_axis_size: f32,
99 config: &LazyListMeasureConfig,
100 measure_item: F,
101) -> LazyListMeasureResult
102where
103 F: FnMut(usize) -> LazyListMeasuredItem,
104{
105 measure_lazy_list_with_beyond_bounds_policy(
106 items_count,
107 state,
108 viewport_size,
109 _cross_axis_size,
110 config,
111 measure_item,
112 AlwaysMeasureBeyond,
113 )
114}
115
116pub fn measure_lazy_list_with_beyond_bounds_policy<F, B>(
117 items_count: usize,
118 state: &LazyListState,
119 viewport_size: f32,
120 _cross_axis_size: f32,
121 config: &LazyListMeasureConfig,
122 mut measure_item: F,
123 beyond_bounds_policy: B,
124) -> LazyListMeasureResult
125where
126 F: FnMut(usize) -> LazyListMeasuredItem,
127 B: BeyondBoundsMeasurePolicy,
128{
129 let raw_viewport_size = viewport_size;
130 let is_infinite_viewport = raw_viewport_size.is_infinite();
131
132 if items_count == 0 {
137 state.update_scroll_position(0, 0.0);
138 state.update_layout_info(LazyListLayoutInfo {
139 visible_items_info: Vec::new(),
140 total_items_count: 0,
141 raw_viewport_size,
142 is_infinite_viewport,
143 viewport_size,
144 viewport_start_offset: config.before_content_padding,
145 viewport_end_offset: config.after_content_padding,
146 before_content_padding: config.before_content_padding,
147 after_content_padding: config.after_content_padding,
148 snap_anchor_offset: 0.0,
149 reverse_layout: config.reverse_layout,
150 });
151 state.update_scroll_bounds();
152 return LazyListMeasureResult::default();
153 }
154
155 if viewport_size <= 0.0 {
158 state.update_layout_info(LazyListLayoutInfo {
160 visible_items_info: Vec::new(),
161 total_items_count: items_count,
162 raw_viewport_size,
163 is_infinite_viewport,
164 viewport_size,
165 viewport_start_offset: config.before_content_padding,
166 viewport_end_offset: config.after_content_padding,
167 before_content_padding: config.before_content_padding,
168 after_content_padding: config.after_content_padding,
169 snap_anchor_offset: 0.0,
170 reverse_layout: config.reverse_layout,
171 });
172 state.update_scroll_bounds();
173 return LazyListMeasureResult::default();
174 }
175
176 let measure_state = state.begin_measure_pass();
177
178 let viewport = ViewportHandler::new(
180 viewport_size,
181 measure_state.average_item_size,
182 config.spacing,
183 );
184 let effective_viewport_size = viewport.effective_size();
185 let is_infinite_viewport = viewport.is_infinite();
186
187 let pending_scroll_delta = measure_state.pending_scroll_delta;
189 let resolver = ScrollPositionResolver::new(
190 state,
191 measure_state,
192 config,
193 items_count,
194 effective_viewport_size,
195 );
196 let (mut first_index, mut first_offset) = resolver.apply_pending_scroll_delta();
197
198 let mut pre_measured = Vec::new();
199
200 if first_offset < 0.0 && first_index > 0 {
202 (first_index, first_offset) = resolver.normalize_backward_jump(first_index, first_offset);
203 while first_offset < 0.0 && first_index > 0 {
204 first_index -= 1;
205 let item = measure_item(first_index);
206 first_offset += item.main_axis_size + config.spacing;
207 pre_measured.push(item);
208 }
209 pre_measured.reverse();
210 }
211
212 first_index = first_index.min(items_count.saturating_sub(1));
213 first_offset = first_offset.max(0.0);
214 (first_index, first_offset) = resolver.normalize_forward_with_cache(first_index, first_offset);
215 let item_extent_at = |index: usize, item_size: f32| {
216 let spacing_after = if index + 1 < items_count {
217 config.spacing
218 } else {
219 0.0
220 };
221 item_size + spacing_after
222 };
223 let mut offset_known_within_current_item = state
224 .get_cached_size(first_index)
225 .map(|size| first_offset + 0.001 < item_extent_at(first_index, size))
226 .unwrap_or(false);
227
228 if !offset_known_within_current_item && first_offset > 0.0 && first_index < items_count {
229 let item = measure_item(first_index);
230 let item_extent = item_extent_at(first_index, item.main_axis_size);
231
232 if first_offset + 0.001 < item_extent {
233 pre_measured.push(item);
234 offset_known_within_current_item = true;
235 }
236 }
237
238 if !offset_known_within_current_item {
239 (first_index, first_offset) = resolver.normalize_forward(first_index, first_offset);
240 }
241
242 let pre_measured_queue = VecDeque::from(pre_measured);
244 let telemetry_enabled = diagnostics::telemetry_enabled();
245 let adaptive_beyond_bounds = adaptive_scroll_beyond_bounds_item_count(
246 config,
247 pending_scroll_delta,
248 measure_state.average_item_size,
249 );
250 let guaranteed_beyond_bounds = adaptive_beyond_bounds;
251 let mut measurer = ItemMeasurer::new(
252 &mut measure_item,
253 config,
254 items_count,
255 effective_viewport_size,
256 measure_state.average_item_size,
257 pre_measured_queue,
258 )
259 .with_beyond_bounds_item_count(adaptive_beyond_bounds)
260 .with_guaranteed_after_beyond_bounds_item_count(guaranteed_beyond_bounds)
261 .with_include_before_beyond_bounds(pending_scroll_delta >= -0.001)
262 .with_beyond_bounds_measure_policy(beyond_bounds_policy)
263 .with_telemetry_pass_id(telemetry_enabled.then(|| state.next_item_measure_pass_id()));
264 let measurement_pass = measurer.measure_all(first_index, first_offset);
265 let measurement_start_index = measurement_pass.start_index;
266 let measurement_start_offset = measurement_pass.start_offset;
267 let measurement_next_index = measurement_pass.next_index;
268 let measurement_next_offset = measurement_pass.next_offset;
269 let measurement_measured_visible_items = measurement_pass.measured_visible_items;
270 let measurement_hit_time_budget = measurement_pass.hit_time_budget;
271 let measurement_viewport_filled = measurement_pass.viewport_filled;
272 let mut visible_items = measurement_pass.items;
273
274 let adjuster = BoundsAdjuster::new(config, items_count, effective_viewport_size);
276 adjuster.clamp(&mut visible_items);
277
278 let total_content_size = estimate_total_content_size(
280 items_count,
281 &visible_items,
282 config,
283 measure_state.average_item_size,
284 );
285
286 let viewport_end = effective_viewport_size - config.after_content_padding;
288 let item_end_with_spacing = |item: &LazyListMeasuredItem| {
289 let spacing_after = if item.index + 1 < items_count {
290 config.spacing
291 } else {
292 0.0
293 };
294 item.offset + item.main_axis_size + spacing_after
295 };
296 let actual_first_visible = visible_items
297 .iter()
298 .find(|item| item_end_with_spacing(item) > config.before_content_padding);
299
300 let unresolved_pass = measurement_hit_time_budget
301 && !measurement_viewport_filled
302 && actual_first_visible.is_none();
303
304 let (final_first_index, final_scroll_offset) = if let Some(first) = actual_first_visible {
305 let offset = config.before_content_padding - first.offset;
306 (first.index, offset.max(0.0))
307 } else if unresolved_pass {
308 if pending_scroll_delta > 0.001 {
309 let preserved_offset =
310 (config.before_content_padding - measurement_start_offset).max(0.0);
311 (measurement_start_index, preserved_offset)
312 } else {
313 let next_index = measurement_next_index.min(items_count.saturating_sub(1));
314 if next_index + 1 >= items_count {
315 (next_index, 0.0)
316 } else {
317 let next_offset =
318 (config.before_content_padding - measurement_next_offset).max(0.0);
319 (next_index, next_offset)
320 }
321 }
322 } else if !visible_items.is_empty() {
323 (visible_items[0].index, 0.0)
324 } else {
325 (0, 0.0)
326 };
327
328 if let Some(first) = actual_first_visible {
330 state.update_scroll_position_with_key(final_first_index, final_scroll_offset, first.key);
331 } else if !visible_items.is_empty() && !unresolved_pass {
332 state.update_scroll_position_with_key(
333 final_first_index,
334 final_scroll_offset,
335 visible_items[0].key,
336 );
337 } else {
338 state.update_scroll_position(final_first_index, final_scroll_offset);
339 }
340
341 if telemetry_enabled {
342 let cycle_id = state.next_measure_cycle_id();
343 log::warn!(
344 "[lazy-measure-telemetry] cycle={} items_count={} average_item_size={:.2} viewport_size={:.2} total_content_size={:.2} input_first_index={} input_first_offset={:.2} normalized_first_index={} normalized_first_offset={:.2} final_first_index={} final_first_offset={:.2} measured_visible={} total_measured={} unresolved_pass={} actual_first_visible={} timed_out={} viewport_filled={}",
345 cycle_id,
346 items_count,
347 measure_state.average_item_size,
348 effective_viewport_size,
349 total_content_size,
350 first_index,
351 first_offset,
352 measurement_start_index,
353 config.before_content_padding - measurement_start_offset,
354 final_first_index,
355 final_scroll_offset,
356 measurement_measured_visible_items,
357 visible_items.len(),
358 unresolved_pass,
359 actual_first_visible.is_some(),
360 measurement_hit_time_budget,
361 measurement_viewport_filled
362 );
363 }
364 state.update_layout_info(LazyListLayoutInfo {
365 visible_items_info: visible_items
366 .iter()
367 .filter(|item| {
368 let item_end = item_end_with_spacing(item);
369 item_end > config.before_content_padding && item.offset < viewport_end
370 })
371 .map(|i| i.to_item_info())
372 .collect(),
373 total_items_count: items_count,
374 raw_viewport_size,
375 is_infinite_viewport,
376 viewport_size: effective_viewport_size,
377 viewport_start_offset: config.before_content_padding,
378 viewport_end_offset: config.after_content_padding,
379 before_content_padding: config.before_content_padding,
380 after_content_padding: config.after_content_padding,
381 snap_anchor_offset: 0.0,
382 reverse_layout: config.reverse_layout,
383 });
384
385 state.update_scroll_bounds();
387
388 let can_scroll_backward = final_first_index > 0 || final_scroll_offset > 0.0;
390 let can_scroll_forward = if let Some(last) = visible_items.last() {
391 last.index < items_count - 1 || (last.offset + last.main_axis_size) > viewport_end
392 } else {
393 false
394 };
395
396 LazyListMeasureResult {
397 visible_items,
398 first_visible_item_index: final_first_index,
399 first_visible_item_scroll_offset: final_scroll_offset,
400 viewport_size: effective_viewport_size,
401 total_content_size,
402 can_scroll_forward,
403 can_scroll_backward,
404 }
405}
406
407fn estimate_total_content_size(
412 items_count: usize,
413 measured_items: &[LazyListMeasuredItem],
414 config: &LazyListMeasureConfig,
415 state_average_size: f32,
416) -> f32 {
417 if items_count == 0 {
418 return 0.0;
419 }
420
421 let avg_size = if !measured_items.is_empty() {
423 let total_measured_size: f32 = measured_items.iter().map(|i| i.main_axis_size).sum();
424 total_measured_size / measured_items.len() as f32
425 } else {
426 state_average_size
427 };
428
429 config.before_content_padding + (avg_size + config.spacing) * items_count as f32
430 - config.spacing
431 + config.after_content_padding
432}
433
434fn adaptive_scroll_beyond_bounds_item_count(
435 config: &LazyListMeasureConfig,
436 pending_scroll_delta: f32,
437 average_item_size: f32,
438) -> usize {
439 let base_count = config.beyond_bounds_item_count;
440 if pending_scroll_delta.abs() <= 0.001 {
441 return if base_count == 0 {
442 MIN_IDLE_WARM_BEYOND_BOUNDS_ITEMS
443 } else {
444 base_count
445 };
446 }
447 let item_extent = if average_item_size.is_finite() && average_item_size > 0.0 {
448 average_item_size
449 } else {
450 DEFAULT_ITEM_SIZE_ESTIMATE
451 } + config.spacing.max(0.0);
452 let item_extent = item_extent.max(1.0);
453 let delta_items = pending_scroll_delta.abs() / item_extent;
454 if delta_items < MIN_ADAPTIVE_SCROLL_DELTA_ITEMS {
455 return base_count.max(MIN_ACTIVE_SCROLL_WHEEL_BEYOND_BOUNDS_ITEMS);
456 }
457
458 let adaptive_count = delta_items.ceil() as usize;
459 base_count
460 .max(MIN_ACTIVE_SCROLL_FAST_BEYOND_BOUNDS_ITEMS)
461 .max(adaptive_count.min(MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS))
462}
463
464#[cfg(test)]
465mod tests {
466 use super::super::lazy_list_state::test_helpers::{
467 new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime,
468 };
469 use super::*;
470
471 fn create_test_item(index: usize, size: f32) -> LazyListMeasuredItem {
472 LazyListMeasuredItem::new(index, index as u64, None, size, 100.0)
473 }
474
475 #[test]
476 fn lazy_list_measure_config_defaults_to_two_beyond_bounds_items() {
477 let config = LazyListMeasureConfig::default();
478
479 assert_eq!(config.beyond_bounds_item_count, 2);
480 }
481
482 #[test]
483 fn active_scroll_guarantees_forward_warm_rows_for_single_wheel_ticks() {
484 let config = LazyListMeasureConfig {
485 beyond_bounds_item_count: 0,
486 spacing: 4.0,
487 ..Default::default()
488 };
489
490 assert_eq!(
491 adaptive_scroll_beyond_bounds_item_count(&config, -40.0, 48.0),
492 2,
493 "single wheel ticks should not force the full fast-scroll warm window"
494 );
495 }
496
497 #[test]
498 fn default_single_wheel_scroll_uses_configured_markdown_warm_window() {
499 let config = LazyListMeasureConfig {
500 beyond_bounds_item_count: 2,
501 spacing: 8.0,
502 ..Default::default()
503 };
504
505 assert_eq!(
506 adaptive_scroll_beyond_bounds_item_count(&config, -40.0, 120.0),
507 2,
508 "small Markdown wheel ticks must not measure eight cached text rows every frame"
509 );
510 }
511
512 #[test]
513 fn idle_measurement_warms_a_small_forward_window() {
514 let config = LazyListMeasureConfig {
515 beyond_bounds_item_count: 0,
516 spacing: 4.0,
517 ..Default::default()
518 };
519
520 let adaptive = adaptive_scroll_beyond_bounds_item_count(&config, 0.0, 48.0);
521
522 assert_eq!(adaptive, MIN_IDLE_WARM_BEYOND_BOUNDS_ITEMS);
523 }
524
525 #[test]
526 fn active_scroll_guarantees_forward_warm_rows_when_configured_buffer_is_zero() {
527 let config = LazyListMeasureConfig {
528 beyond_bounds_item_count: 0,
529 spacing: 4.0,
530 ..Default::default()
531 };
532
533 let adaptive = adaptive_scroll_beyond_bounds_item_count(&config, -620.0, 48.0);
534
535 assert_eq!(adaptive, MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS);
536 }
537
538 #[test]
539 fn adaptive_scroll_beyond_bounds_warms_fast_wheel_scroll_window() {
540 let config = LazyListMeasureConfig {
541 beyond_bounds_item_count: 0,
542 spacing: 4.0,
543 ..Default::default()
544 };
545
546 assert_eq!(
547 adaptive_scroll_beyond_bounds_item_count(&config, -620.0, 48.0),
548 MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS
549 );
550 }
551
552 #[test]
553 fn adaptive_scroll_beyond_bounds_never_shrinks_configured_buffer() {
554 let config = LazyListMeasureConfig {
555 beyond_bounds_item_count: 12,
556 spacing: 4.0,
557 ..Default::default()
558 };
559
560 assert_eq!(
561 adaptive_scroll_beyond_bounds_item_count(&config, -620.0, 48.0),
562 12
563 );
564 }
565
566 fn exact_scroll_position(
567 item_sizes: &[f32],
568 spacing: f32,
569 viewport_size: f32,
570 deltas: &[f32],
571 ) -> Vec<(usize, f32)> {
572 let total_content = item_sizes
573 .iter()
574 .enumerate()
575 .map(|(index, size)| {
576 let spacing_after = if index + 1 < item_sizes.len() {
577 spacing
578 } else {
579 0.0
580 };
581 size + spacing_after
582 })
583 .sum::<f32>();
584 let max_scroll = (total_content - viewport_size).max(0.0);
585 let mut scroll = 0.0f32;
586 let mut positions = Vec::with_capacity(deltas.len());
587
588 for delta in deltas {
589 scroll = (scroll - delta).clamp(0.0, max_scroll);
590
591 let mut remaining = scroll;
592 let mut index = 0usize;
593 while index + 1 < item_sizes.len() {
594 let spacing_after = if index + 1 < item_sizes.len() {
595 spacing
596 } else {
597 0.0
598 };
599 let extent = item_sizes[index] + spacing_after;
600 if remaining < extent {
601 break;
602 }
603 remaining -= extent;
604 index += 1;
605 }
606 positions.push((index, remaining));
607 }
608
609 positions
610 }
611
612 #[test]
613 fn test_measure_empty_list() {
614 with_test_runtime(|| {
615 let state = new_lazy_list_state();
616 let config = LazyListMeasureConfig::default();
617
618 let result = measure_lazy_list(0, &state, 500.0, 300.0, &config, |_| {
619 panic!("Should not measure any items");
620 });
621
622 assert!(result.visible_items.is_empty());
623 });
624 }
625
626 #[test]
627 fn test_measure_single_item() {
628 with_test_runtime(|| {
629 let state = new_lazy_list_state();
630 let config = LazyListMeasureConfig::default();
631
632 let result = measure_lazy_list(1, &state, 500.0, 300.0, &config, |i| {
633 create_test_item(i, 50.0)
634 });
635
636 assert_eq!(result.visible_items.len(), 1);
637 assert_eq!(result.visible_items[0].index, 0);
638 assert!(!result.can_scroll_forward);
639 assert!(!result.can_scroll_backward);
640 });
641 }
642
643 #[test]
644 fn test_measure_fills_viewport() {
645 with_test_runtime(|| {
646 let state = new_lazy_list_state();
647 let config = LazyListMeasureConfig::default();
648
649 let result = measure_lazy_list(10, &state, 200.0, 300.0, &config, |i| {
651 create_test_item(i, 50.0)
652 });
653
654 assert!(result.visible_items.len() >= 4);
656 assert!(result.can_scroll_forward);
657 assert!(!result.can_scroll_backward);
658 });
659 }
660
661 #[test]
662 fn test_measure_with_scroll_offset() {
663 with_test_runtime(|| {
664 let state = new_lazy_list_state_with_position(3, 25.0);
665 let config = LazyListMeasureConfig::default();
666
667 let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
668 create_test_item(i, 50.0)
669 });
670
671 assert_eq!(result.first_visible_item_index, 3);
672 assert!(result.can_scroll_forward);
673 assert!(result.can_scroll_backward);
674 });
675 }
676
677 #[test]
678 fn test_backward_scroll_uses_measured_size() {
679 with_test_runtime(|| {
680 let state = new_lazy_list_state_with_position(1, 0.0);
681 state.dispatch_scroll_delta(1.0);
682 let config = LazyListMeasureConfig::default();
683
684 let result = measure_lazy_list(2, &state, 100.0, 300.0, &config, |i| {
685 if i == 0 {
686 create_test_item(i, 10.0)
687 } else {
688 create_test_item(i, 100.0)
689 }
690 });
691
692 assert_eq!(result.first_visible_item_index, 0);
693 assert!((result.first_visible_item_scroll_offset - 9.0).abs() < 0.001);
694 });
695 }
696
697 #[test]
698 fn test_backward_scroll_with_spacing_preserves_offset_gap() {
699 with_test_runtime(|| {
700 let state = new_lazy_list_state_with_position(1, 0.0);
701 let config = LazyListMeasureConfig {
702 spacing: 4.0,
703 ..Default::default()
704 };
705 state.dispatch_scroll_delta(2.0);
706
707 let result = measure_lazy_list(2, &state, 40.0, 300.0, &config, |i| {
708 create_test_item(i, 50.0)
709 });
710
711 assert_eq!(result.first_visible_item_index, 0);
712 assert!((result.first_visible_item_scroll_offset - 52.0).abs() < 0.001);
713 });
714 }
715
716 #[test]
717 fn test_scroll_to_item() {
718 with_test_runtime(|| {
719 let state = new_lazy_list_state();
720 state.scroll_to_item(5, 0.0);
721
722 let config = LazyListMeasureConfig::default();
723 let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
724 create_test_item(i, 50.0)
725 });
726
727 assert_eq!(result.first_visible_item_index, 5);
728 });
729 }
730
731 #[test]
732 fn test_time_budget_fills_visible_viewport_and_keeps_configured_beyond_bounds() {
733 with_test_runtime(|| {
734 let state = new_lazy_list_state_with_position(100, 5_000.0);
735 let config = LazyListMeasureConfig::default();
736
737 let result = measure_lazy_list(10_000, &state, 100.0, 300.0, &config, |i| {
738 std::thread::sleep(std::time::Duration::from_millis(8));
739 create_test_item(i, 10.0)
740 });
741
742 assert_eq!(
743 result.first_visible_item_index, 212,
744 "time-budgeted pass should report the first item that actually reaches the viewport"
745 );
746 assert!(
747 (result.first_visible_item_scroll_offset - 4.0).abs() < 1.0,
748 "expected actual visible offset to be preserved"
749 );
750 assert_eq!(
751 result.visible_items.first().map(|item| item.index),
752 Some(200),
753 "measurement should keep the configured leading retained items"
754 );
755 assert_eq!(
756 result.visible_items.last().map(|item| item.index),
757 Some(224),
758 "measurement should keep the configured trailing retained items"
759 );
760 assert!(
761 result
762 .visible_items
763 .last()
764 .is_some_and(|item| item.offset + item.main_axis_size >= 100.0),
765 "visible measurement must fill the viewport before honoring the time budget"
766 );
767 });
768 }
769
770 #[test]
771 fn test_time_budgeted_reverse_scroll_does_not_backtrack() {
772 with_test_runtime(|| {
773 let state = new_lazy_list_state();
774 let config = LazyListMeasureConfig {
775 spacing: 8.0,
776 ..Default::default()
777 };
778 let item_sizes: Vec<f32> = (0..512usize)
779 .map(|index| match index % 7 {
780 0 => 44.0,
781 1 => 60.0,
782 2 => 220.0,
783 3 => 72.0,
784 4 => 96.0,
785 5 => 156.0,
786 _ => 52.0,
787 })
788 .collect();
789
790 let mut result =
791 measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |index| {
792 std::thread::sleep(std::time::Duration::from_millis(55));
793 create_test_item(index, item_sizes[index])
794 });
795 assert_eq!(result.first_visible_item_index, 0);
796
797 for _ in 0..4 {
798 state.dispatch_scroll_delta(-320.0);
799 result =
800 measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |index| {
801 std::thread::sleep(std::time::Duration::from_millis(55));
802 create_test_item(index, item_sizes[index])
803 });
804 }
805
806 assert!(
807 result.first_visible_item_index > 0,
808 "expected to advance after forward time-budgeted scrolls"
809 );
810
811 let mut last_index = result.first_visible_item_index;
812 for step in 0..4 {
813 state.dispatch_scroll_delta(80.0);
814 result =
815 measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |index| {
816 std::thread::sleep(std::time::Duration::from_millis(55));
817 create_test_item(index, item_sizes[index])
818 });
819 assert!(
820 result.first_visible_item_index <= last_index,
821 "reverse time-budgeted step {step} backtracked from index {last_index} to {}",
822 result.first_visible_item_index
823 );
824 last_index = result.first_visible_item_index;
825 }
826 });
827 }
828
829 #[test]
830 fn test_backward_scroll_does_not_advance_first_visible_index_for_variable_items() {
831 with_test_runtime(|| {
832 let state = new_lazy_list_state();
833 let config = LazyListMeasureConfig {
834 spacing: 8.0,
835 ..Default::default()
836 };
837 let item_sizes = [48.0, 56.0, 64.0, 72.0, 80.0];
838 let measure_item =
839 |index: usize| create_test_item(index, item_sizes[index % item_sizes.len()]);
840
841 let mut result = measure_lazy_list(200, &state, 260.0, 300.0, &config, measure_item);
842 assert_eq!(result.first_visible_item_index, 0);
843
844 for _ in 0..28 {
845 state.dispatch_scroll_delta(-32.0);
846 result = measure_lazy_list(200, &state, 260.0, 300.0, &config, measure_item);
847 }
848
849 assert!(
850 result.first_visible_item_index >= 12,
851 "expected to scroll well into the list before reversing, got index={}",
852 result.first_visible_item_index
853 );
854
855 let mut last_index = result.first_visible_item_index;
856 for step in 0..24 {
857 state.dispatch_scroll_delta(12.0);
858 result = measure_lazy_list(200, &state, 260.0, 300.0, &config, measure_item);
859 assert!(
860 result.first_visible_item_index <= last_index,
861 "backward step {step} advanced from index {last_index} to {}",
862 result.first_visible_item_index
863 );
864 last_index = result.first_visible_item_index;
865 }
866 });
867 }
868
869 #[test]
870 fn test_stored_offset_inside_tall_item_does_not_skip_forward_without_pending_scroll() {
871 with_test_runtime(|| {
872 let state = new_lazy_list_state_with_position(0, 900.0);
873 let config = LazyListMeasureConfig {
874 spacing: 8.0,
875 ..Default::default()
876 };
877 let item_sizes: Vec<f32> = (0..32usize)
878 .map(|index| if index == 0 { 1_200.0 } else { 64.0 })
879 .collect();
880
881 let result = measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |i| {
882 create_test_item(i, item_sizes[i])
883 });
884
885 assert_eq!(
886 result.first_visible_item_index, 0,
887 "stored in-item offset must not be turned into an average-size forward jump"
888 );
889 assert!(
890 (result.first_visible_item_scroll_offset - 900.0).abs() < 0.01,
891 "expected to preserve the stored in-item scroll offset"
892 );
893 });
894 }
895
896 #[test]
897 fn test_large_offset_inside_cached_tall_item_does_not_skip_forward_without_forward_scroll() {
898 with_test_runtime(|| {
899 let state = new_lazy_list_state_with_position(20, 900.0);
900 let config = LazyListMeasureConfig {
901 spacing: 8.0,
902 ..Default::default()
903 };
904 for index in 0..20 {
905 state.cache_item_size(index, 60.0 + (index % 3) as f32 * 8.0);
906 }
907 state.cache_item_size(20, 1_200.0);
908
909 let item_sizes: Vec<f32> = (0..64usize)
910 .map(|index| {
911 if index == 20 {
912 1_200.0
913 } else {
914 60.0 + (index % 3) as f32 * 8.0
915 }
916 })
917 .collect();
918
919 let result = measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |i| {
920 create_test_item(i, item_sizes[i])
921 });
922
923 assert_eq!(
924 result.first_visible_item_index, 20,
925 "offset within a tall cached item must not be interpreted as skipping to later average-sized items"
926 );
927 assert!(
928 (result.first_visible_item_scroll_offset - 900.0).abs() < 0.01,
929 "expected to preserve in-item offset inside the tall cached item"
930 );
931 });
932 }
933
934 #[test]
935 fn test_matches_exact_model_for_variable_item_reverse_scrolls() {
936 with_test_runtime(|| {
937 let state = new_lazy_list_state();
938 let config = LazyListMeasureConfig {
939 spacing: 8.0,
940 ..Default::default()
941 };
942 let viewport_size = 260.0;
943 let item_sizes: Vec<f32> = (0..240usize)
944 .map(|index| match index % 9 {
945 0 => 32.0,
946 1 => 48.0,
947 2 => 240.0,
948 3 => 56.0,
949 4 => 72.0,
950 5 => 180.0,
951 6 => 40.0,
952 7 => 96.0,
953 _ => 56.0,
954 })
955 .collect();
956 let deltas = [
957 -180.0, -180.0, -220.0, -150.0, -240.0, -120.0, -160.0, 60.0, 60.0, 80.0, -96.0,
958 -96.0, 44.0, 44.0, 44.0, -140.0, -140.0, 72.0, 72.0, 72.0, 72.0,
959 ];
960 let expected =
961 exact_scroll_position(&item_sizes, config.spacing, viewport_size, &deltas);
962
963 for (step, (delta, (expected_index, expected_offset))) in
964 deltas.iter().zip(expected.iter()).enumerate()
965 {
966 state.dispatch_scroll_delta(*delta);
967 let mut result;
968 loop {
969 result = measure_lazy_list(
970 item_sizes.len(),
971 &state,
972 viewport_size,
973 320.0,
974 &config,
975 |index| create_test_item(index, item_sizes[index]),
976 );
977 if state.peek_scroll_delta().abs() <= 0.001 {
978 break;
979 }
980 }
981
982 assert_eq!(
983 result.first_visible_item_index, *expected_index,
984 "step {step} delta={delta} expected first index {} but got {}",
985 expected_index, result.first_visible_item_index
986 );
987 assert!(
988 (result.first_visible_item_scroll_offset - *expected_offset).abs() < 0.01,
989 "step {step} delta={delta} expected offset {:.2} but got {:.2}",
990 expected_offset,
991 result.first_visible_item_scroll_offset
992 );
993 }
994 });
995 }
996}