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 if viewport.is_infinite() {
185 return measure_unbounded_lazy_list(
194 items_count,
195 state,
196 raw_viewport_size,
197 config,
198 &mut measure_item,
199 );
200 }
201 let effective_viewport_size = viewport.effective_size();
202 let is_infinite_viewport = viewport.is_infinite();
203
204 let pending_scroll_delta = measure_state.pending_scroll_delta;
206 let resolver = ScrollPositionResolver::new(
207 state,
208 measure_state,
209 config,
210 items_count,
211 effective_viewport_size,
212 );
213 let (mut first_index, mut first_offset) = resolver.apply_pending_scroll_delta();
214
215 let mut pre_measured = Vec::new();
216
217 if first_offset < 0.0 && first_index > 0 {
219 (first_index, first_offset) = resolver.normalize_backward_jump(first_index, first_offset);
220 while first_offset < 0.0 && first_index > 0 {
221 first_index -= 1;
222 let item = measure_item(first_index);
223 first_offset += item.main_axis_size + config.spacing;
224 pre_measured.push(item);
225 }
226 pre_measured.reverse();
227 }
228
229 first_index = first_index.min(items_count.saturating_sub(1));
230 first_offset = first_offset.max(0.0);
231 (first_index, first_offset) = resolver.normalize_forward_with_cache(first_index, first_offset);
232 let item_extent_at = |index: usize, item_size: f32| {
233 let spacing_after = if index + 1 < items_count {
234 config.spacing
235 } else {
236 0.0
237 };
238 item_size + spacing_after
239 };
240 let mut offset_known_within_current_item = state
241 .get_cached_size(first_index)
242 .map(|size| first_offset + 0.001 < item_extent_at(first_index, size))
243 .unwrap_or(false);
244
245 if !offset_known_within_current_item && first_offset > 0.0 && first_index < items_count {
246 let item = measure_item(first_index);
247 let item_extent = item_extent_at(first_index, item.main_axis_size);
248
249 if first_offset + 0.001 < item_extent {
250 pre_measured.push(item);
251 offset_known_within_current_item = true;
252 }
253 }
254
255 if !offset_known_within_current_item {
256 (first_index, first_offset) = resolver.normalize_forward(first_index, first_offset);
257 }
258
259 let pre_measured_queue = VecDeque::from(pre_measured);
261 let telemetry_enabled = diagnostics::telemetry_enabled();
262 let adaptive_beyond_bounds = adaptive_scroll_beyond_bounds_item_count(
263 config,
264 pending_scroll_delta,
265 measure_state.average_item_size,
266 );
267 let guaranteed_beyond_bounds = adaptive_beyond_bounds;
268 let mut measurer = ItemMeasurer::new(
269 &mut measure_item,
270 config,
271 items_count,
272 effective_viewport_size,
273 measure_state.average_item_size,
274 pre_measured_queue,
275 )
276 .with_beyond_bounds_item_count(adaptive_beyond_bounds)
277 .with_guaranteed_after_beyond_bounds_item_count(guaranteed_beyond_bounds)
278 .with_include_before_beyond_bounds(pending_scroll_delta >= -0.001)
279 .with_beyond_bounds_measure_policy(beyond_bounds_policy)
280 .with_telemetry_pass_id(telemetry_enabled.then(|| state.next_item_measure_pass_id()));
281 let measurement_pass = measurer.measure_all(first_index, first_offset);
282 let measurement_start_index = measurement_pass.start_index;
283 let measurement_start_offset = measurement_pass.start_offset;
284 let measurement_next_index = measurement_pass.next_index;
285 let measurement_next_offset = measurement_pass.next_offset;
286 let measurement_measured_visible_items = measurement_pass.measured_visible_items;
287 let measurement_hit_time_budget = measurement_pass.hit_time_budget;
288 let measurement_viewport_filled = measurement_pass.viewport_filled;
289 let mut visible_items = measurement_pass.items;
290
291 let adjuster = BoundsAdjuster::new(config, items_count, effective_viewport_size);
293 adjuster.clamp(&mut visible_items);
294
295 let total_content_size = estimate_total_content_size(
297 items_count,
298 &visible_items,
299 config,
300 measure_state.average_item_size,
301 );
302
303 let viewport_end = effective_viewport_size - config.after_content_padding;
305 let item_end_with_spacing = |item: &LazyListMeasuredItem| {
306 let spacing_after = if item.index + 1 < items_count {
307 config.spacing
308 } else {
309 0.0
310 };
311 item.offset + item.main_axis_size + spacing_after
312 };
313 let actual_first_visible = visible_items
314 .iter()
315 .find(|item| item_end_with_spacing(item) > config.before_content_padding);
316
317 let unresolved_pass = measurement_hit_time_budget
318 && !measurement_viewport_filled
319 && actual_first_visible.is_none();
320
321 let (final_first_index, final_scroll_offset) = if let Some(first) = actual_first_visible {
322 let offset = config.before_content_padding - first.offset;
323 (first.index, offset.max(0.0))
324 } else if unresolved_pass {
325 if pending_scroll_delta > 0.001 {
326 let preserved_offset =
327 (config.before_content_padding - measurement_start_offset).max(0.0);
328 (measurement_start_index, preserved_offset)
329 } else {
330 let next_index = measurement_next_index.min(items_count.saturating_sub(1));
331 if next_index + 1 >= items_count {
332 (next_index, 0.0)
333 } else {
334 let next_offset =
335 (config.before_content_padding - measurement_next_offset).max(0.0);
336 (next_index, next_offset)
337 }
338 }
339 } else if !visible_items.is_empty() {
340 (visible_items[0].index, 0.0)
341 } else {
342 (0, 0.0)
343 };
344
345 if let Some(first) = actual_first_visible {
347 state.update_scroll_position_with_key(final_first_index, final_scroll_offset, first.key);
348 } else if !visible_items.is_empty() && !unresolved_pass {
349 state.update_scroll_position_with_key(
350 final_first_index,
351 final_scroll_offset,
352 visible_items[0].key,
353 );
354 } else {
355 state.update_scroll_position(final_first_index, final_scroll_offset);
356 }
357
358 if telemetry_enabled {
359 let cycle_id = state.next_measure_cycle_id();
360 log::warn!(
361 "[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={}",
362 cycle_id,
363 items_count,
364 measure_state.average_item_size,
365 effective_viewport_size,
366 total_content_size,
367 first_index,
368 first_offset,
369 measurement_start_index,
370 config.before_content_padding - measurement_start_offset,
371 final_first_index,
372 final_scroll_offset,
373 measurement_measured_visible_items,
374 visible_items.len(),
375 unresolved_pass,
376 actual_first_visible.is_some(),
377 measurement_hit_time_budget,
378 measurement_viewport_filled
379 );
380 }
381 state.update_layout_info(LazyListLayoutInfo {
382 visible_items_info: visible_items
383 .iter()
384 .filter(|item| {
385 let item_end = item_end_with_spacing(item);
386 item_end > config.before_content_padding && item.offset < viewport_end
387 })
388 .map(|i| i.to_item_info())
389 .collect(),
390 total_items_count: items_count,
391 raw_viewport_size,
392 is_infinite_viewport,
393 viewport_size: effective_viewport_size,
394 viewport_start_offset: config.before_content_padding,
395 viewport_end_offset: config.after_content_padding,
396 before_content_padding: config.before_content_padding,
397 after_content_padding: config.after_content_padding,
398 snap_anchor_offset: 0.0,
399 reverse_layout: config.reverse_layout,
400 });
401
402 state.update_scroll_bounds();
404
405 let can_scroll_backward = final_first_index > 0 || final_scroll_offset > 0.0;
407 let can_scroll_forward = if let Some(last) = visible_items.last() {
408 last.index < items_count - 1 || (last.offset + last.main_axis_size) > viewport_end
409 } else {
410 false
411 };
412
413 LazyListMeasureResult {
414 visible_items,
415 first_visible_item_index: final_first_index,
416 first_visible_item_scroll_offset: final_scroll_offset,
417 viewport_size: effective_viewport_size,
418 total_content_size,
419 can_scroll_forward,
420 can_scroll_backward,
421 }
422}
423
424const MAX_UNBOUNDED_REALIZED_ITEMS: usize = 10_000;
427
428fn measure_unbounded_lazy_list<F>(
438 items_count: usize,
439 state: &LazyListState,
440 raw_viewport_size: f32,
441 config: &LazyListMeasureConfig,
442 measure_item: &mut F,
443) -> LazyListMeasureResult
444where
445 F: FnMut(usize) -> LazyListMeasuredItem,
446{
447 let realized_count = items_count.min(MAX_UNBOUNDED_REALIZED_ITEMS);
448 if realized_count < items_count {
449 log::warn!(
450 "LazyList: unbounded viewport with {} items; realizing only the first {}. \
451 Wrap the list in a constrained container to restore virtualization.",
452 items_count,
453 realized_count
454 );
455 }
456
457 let mut visible_items = Vec::with_capacity(realized_count);
458 let mut offset = config.before_content_padding;
459 for index in 0..realized_count {
460 let mut item = measure_item(index);
461 item.offset = offset;
462 offset += item.main_axis_size;
463 if index + 1 < items_count {
464 offset += config.spacing;
465 }
466 visible_items.push(item);
467 }
468 let content_extent = offset + config.after_content_padding;
469
470 state.update_scroll_position(0, 0.0);
472 state.update_layout_info(LazyListLayoutInfo {
473 visible_items_info: visible_items.iter().map(|i| i.to_item_info()).collect(),
474 total_items_count: items_count,
475 raw_viewport_size,
476 is_infinite_viewport: true,
477 viewport_size: content_extent,
478 viewport_start_offset: config.before_content_padding,
479 viewport_end_offset: config.after_content_padding,
480 before_content_padding: config.before_content_padding,
481 after_content_padding: config.after_content_padding,
482 snap_anchor_offset: 0.0,
483 reverse_layout: config.reverse_layout,
484 });
485 state.update_scroll_bounds();
486
487 let can_scroll_forward = realized_count < items_count;
488 LazyListMeasureResult {
489 visible_items,
490 first_visible_item_index: 0,
491 first_visible_item_scroll_offset: 0.0,
492 viewport_size: content_extent,
493 total_content_size: content_extent,
494 can_scroll_forward,
495 can_scroll_backward: false,
496 }
497}
498
499fn estimate_total_content_size(
504 items_count: usize,
505 measured_items: &[LazyListMeasuredItem],
506 config: &LazyListMeasureConfig,
507 state_average_size: f32,
508) -> f32 {
509 if items_count == 0 {
510 return 0.0;
511 }
512
513 let avg_size = if !measured_items.is_empty() {
515 let total_measured_size: f32 = measured_items.iter().map(|i| i.main_axis_size).sum();
516 total_measured_size / measured_items.len() as f32
517 } else {
518 state_average_size
519 };
520
521 config.before_content_padding + (avg_size + config.spacing) * items_count as f32
522 - config.spacing
523 + config.after_content_padding
524}
525
526fn adaptive_scroll_beyond_bounds_item_count(
527 config: &LazyListMeasureConfig,
528 pending_scroll_delta: f32,
529 average_item_size: f32,
530) -> usize {
531 let base_count = config.beyond_bounds_item_count;
532 if pending_scroll_delta.abs() <= 0.001 {
533 return if base_count == 0 {
534 MIN_IDLE_WARM_BEYOND_BOUNDS_ITEMS
535 } else {
536 base_count
537 };
538 }
539 let item_extent = if average_item_size.is_finite() && average_item_size > 0.0 {
540 average_item_size
541 } else {
542 DEFAULT_ITEM_SIZE_ESTIMATE
543 } + config.spacing.max(0.0);
544 let item_extent = item_extent.max(1.0);
545 let delta_items = pending_scroll_delta.abs() / item_extent;
546 if delta_items < MIN_ADAPTIVE_SCROLL_DELTA_ITEMS {
547 return base_count.max(MIN_ACTIVE_SCROLL_WHEEL_BEYOND_BOUNDS_ITEMS);
548 }
549
550 let adaptive_count = delta_items.ceil() as usize;
551 base_count
552 .max(MIN_ACTIVE_SCROLL_FAST_BEYOND_BOUNDS_ITEMS)
553 .max(adaptive_count.min(MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS))
554}
555
556#[cfg(test)]
557mod tests {
558 use super::super::lazy_list_state::test_helpers::{
559 new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime,
560 };
561 use super::*;
562
563 fn create_test_item(index: usize, size: f32) -> LazyListMeasuredItem {
564 LazyListMeasuredItem::new(index, index as u64, None, size, 100.0)
565 }
566
567 #[test]
568 fn lazy_list_measure_config_defaults_to_two_beyond_bounds_items() {
569 let config = LazyListMeasureConfig::default();
570
571 assert_eq!(config.beyond_bounds_item_count, 2);
572 }
573
574 #[test]
575 fn active_scroll_guarantees_forward_warm_rows_for_single_wheel_ticks() {
576 let config = LazyListMeasureConfig {
577 beyond_bounds_item_count: 0,
578 spacing: 4.0,
579 ..Default::default()
580 };
581
582 assert_eq!(
583 adaptive_scroll_beyond_bounds_item_count(&config, -40.0, 48.0),
584 2,
585 "single wheel ticks should not force the full fast-scroll warm window"
586 );
587 }
588
589 #[test]
590 fn default_single_wheel_scroll_uses_configured_markdown_warm_window() {
591 let config = LazyListMeasureConfig {
592 beyond_bounds_item_count: 2,
593 spacing: 8.0,
594 ..Default::default()
595 };
596
597 assert_eq!(
598 adaptive_scroll_beyond_bounds_item_count(&config, -40.0, 120.0),
599 2,
600 "small Markdown wheel ticks must not measure eight cached text rows every frame"
601 );
602 }
603
604 #[test]
605 fn idle_measurement_warms_a_small_forward_window() {
606 let config = LazyListMeasureConfig {
607 beyond_bounds_item_count: 0,
608 spacing: 4.0,
609 ..Default::default()
610 };
611
612 let adaptive = adaptive_scroll_beyond_bounds_item_count(&config, 0.0, 48.0);
613
614 assert_eq!(adaptive, MIN_IDLE_WARM_BEYOND_BOUNDS_ITEMS);
615 }
616
617 #[test]
618 fn active_scroll_guarantees_forward_warm_rows_when_configured_buffer_is_zero() {
619 let config = LazyListMeasureConfig {
620 beyond_bounds_item_count: 0,
621 spacing: 4.0,
622 ..Default::default()
623 };
624
625 let adaptive = adaptive_scroll_beyond_bounds_item_count(&config, -620.0, 48.0);
626
627 assert_eq!(adaptive, MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS);
628 }
629
630 #[test]
631 fn adaptive_scroll_beyond_bounds_warms_fast_wheel_scroll_window() {
632 let config = LazyListMeasureConfig {
633 beyond_bounds_item_count: 0,
634 spacing: 4.0,
635 ..Default::default()
636 };
637
638 assert_eq!(
639 adaptive_scroll_beyond_bounds_item_count(&config, -620.0, 48.0),
640 MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS
641 );
642 }
643
644 #[test]
645 fn adaptive_scroll_beyond_bounds_never_shrinks_configured_buffer() {
646 let config = LazyListMeasureConfig {
647 beyond_bounds_item_count: 12,
648 spacing: 4.0,
649 ..Default::default()
650 };
651
652 assert_eq!(
653 adaptive_scroll_beyond_bounds_item_count(&config, -620.0, 48.0),
654 12
655 );
656 }
657
658 fn exact_scroll_position(
659 item_sizes: &[f32],
660 spacing: f32,
661 viewport_size: f32,
662 deltas: &[f32],
663 ) -> Vec<(usize, f32)> {
664 let total_content = item_sizes
665 .iter()
666 .enumerate()
667 .map(|(index, size)| {
668 let spacing_after = if index + 1 < item_sizes.len() {
669 spacing
670 } else {
671 0.0
672 };
673 size + spacing_after
674 })
675 .sum::<f32>();
676 let max_scroll = (total_content - viewport_size).max(0.0);
677 let mut scroll = 0.0f32;
678 let mut positions = Vec::with_capacity(deltas.len());
679
680 for delta in deltas {
681 scroll = (scroll - delta).clamp(0.0, max_scroll);
682
683 let mut remaining = scroll;
684 let mut index = 0usize;
685 while index + 1 < item_sizes.len() {
686 let spacing_after = if index + 1 < item_sizes.len() {
687 spacing
688 } else {
689 0.0
690 };
691 let extent = item_sizes[index] + spacing_after;
692 if remaining < extent {
693 break;
694 }
695 remaining -= extent;
696 index += 1;
697 }
698 positions.push((index, remaining));
699 }
700
701 positions
702 }
703
704 #[test]
705 fn test_measure_empty_list() {
706 with_test_runtime(|| {
707 let state = new_lazy_list_state();
708 let config = LazyListMeasureConfig::default();
709
710 let result = measure_lazy_list(0, &state, 500.0, 300.0, &config, |_| {
711 panic!("Should not measure any items");
712 });
713
714 assert!(result.visible_items.is_empty());
715 });
716 }
717
718 #[test]
719 fn test_measure_single_item() {
720 with_test_runtime(|| {
721 let state = new_lazy_list_state();
722 let config = LazyListMeasureConfig::default();
723
724 let result = measure_lazy_list(1, &state, 500.0, 300.0, &config, |i| {
725 create_test_item(i, 50.0)
726 });
727
728 assert_eq!(result.visible_items.len(), 1);
729 assert_eq!(result.visible_items[0].index, 0);
730 assert!(!result.can_scroll_forward);
731 assert!(!result.can_scroll_backward);
732 });
733 }
734
735 #[test]
736 fn test_measure_fills_viewport() {
737 with_test_runtime(|| {
738 let state = new_lazy_list_state();
739 let config = LazyListMeasureConfig::default();
740
741 let result = measure_lazy_list(10, &state, 200.0, 300.0, &config, |i| {
743 create_test_item(i, 50.0)
744 });
745
746 assert!(result.visible_items.len() >= 4);
748 assert!(result.can_scroll_forward);
749 assert!(!result.can_scroll_backward);
750 });
751 }
752
753 #[test]
754 fn test_measure_with_scroll_offset() {
755 with_test_runtime(|| {
756 let state = new_lazy_list_state_with_position(3, 25.0);
757 let config = LazyListMeasureConfig::default();
758
759 let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
760 create_test_item(i, 50.0)
761 });
762
763 assert_eq!(result.first_visible_item_index, 3);
764 assert!(result.can_scroll_forward);
765 assert!(result.can_scroll_backward);
766 });
767 }
768
769 #[test]
770 fn test_backward_scroll_uses_measured_size() {
771 with_test_runtime(|| {
772 let state = new_lazy_list_state_with_position(1, 0.0);
773 state.dispatch_scroll_delta(1.0);
774 let config = LazyListMeasureConfig::default();
775
776 let result = measure_lazy_list(2, &state, 100.0, 300.0, &config, |i| {
777 if i == 0 {
778 create_test_item(i, 10.0)
779 } else {
780 create_test_item(i, 100.0)
781 }
782 });
783
784 assert_eq!(result.first_visible_item_index, 0);
785 assert!((result.first_visible_item_scroll_offset - 9.0).abs() < 0.001);
786 });
787 }
788
789 #[test]
790 fn test_backward_scroll_with_spacing_preserves_offset_gap() {
791 with_test_runtime(|| {
792 let state = new_lazy_list_state_with_position(1, 0.0);
793 let config = LazyListMeasureConfig {
794 spacing: 4.0,
795 ..Default::default()
796 };
797 state.dispatch_scroll_delta(2.0);
798
799 let result = measure_lazy_list(2, &state, 40.0, 300.0, &config, |i| {
800 create_test_item(i, 50.0)
801 });
802
803 assert_eq!(result.first_visible_item_index, 0);
804 assert!((result.first_visible_item_scroll_offset - 52.0).abs() < 0.001);
805 });
806 }
807
808 #[test]
809 fn test_scroll_to_item() {
810 with_test_runtime(|| {
811 let state = new_lazy_list_state();
812 state.scroll_to_item(5, 0.0);
813
814 let config = LazyListMeasureConfig::default();
815 let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
816 create_test_item(i, 50.0)
817 });
818
819 assert_eq!(result.first_visible_item_index, 5);
820 });
821 }
822
823 #[test]
824 fn test_time_budget_fills_visible_viewport_and_keeps_configured_beyond_bounds() {
825 with_test_runtime(|| {
826 let state = new_lazy_list_state_with_position(100, 5_000.0);
827 let config = LazyListMeasureConfig::default();
828
829 let result = measure_lazy_list(10_000, &state, 100.0, 300.0, &config, |i| {
830 std::thread::sleep(std::time::Duration::from_millis(8));
831 create_test_item(i, 10.0)
832 });
833
834 assert_eq!(
835 result.first_visible_item_index, 212,
836 "time-budgeted pass should report the first item that actually reaches the viewport"
837 );
838 assert!(
839 (result.first_visible_item_scroll_offset - 4.0).abs() < 1.0,
840 "expected actual visible offset to be preserved"
841 );
842 assert_eq!(
843 result.visible_items.first().map(|item| item.index),
844 Some(200),
845 "measurement should keep the configured leading retained items"
846 );
847 assert_eq!(
848 result.visible_items.last().map(|item| item.index),
849 Some(224),
850 "measurement should keep the configured trailing retained items"
851 );
852 assert!(
853 result
854 .visible_items
855 .last()
856 .is_some_and(|item| item.offset + item.main_axis_size >= 100.0),
857 "visible measurement must fill the viewport before honoring the time budget"
858 );
859 });
860 }
861
862 #[test]
863 fn test_time_budgeted_reverse_scroll_does_not_backtrack() {
864 with_test_runtime(|| {
865 let state = new_lazy_list_state();
866 let config = LazyListMeasureConfig {
867 spacing: 8.0,
868 ..Default::default()
869 };
870 let item_sizes: Vec<f32> = (0..512usize)
871 .map(|index| match index % 7 {
872 0 => 44.0,
873 1 => 60.0,
874 2 => 220.0,
875 3 => 72.0,
876 4 => 96.0,
877 5 => 156.0,
878 _ => 52.0,
879 })
880 .collect();
881
882 let mut result =
883 measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |index| {
884 std::thread::sleep(std::time::Duration::from_millis(55));
885 create_test_item(index, item_sizes[index])
886 });
887 assert_eq!(result.first_visible_item_index, 0);
888
889 for _ in 0..4 {
890 state.dispatch_scroll_delta(-320.0);
891 result =
892 measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |index| {
893 std::thread::sleep(std::time::Duration::from_millis(55));
894 create_test_item(index, item_sizes[index])
895 });
896 }
897
898 assert!(
899 result.first_visible_item_index > 0,
900 "expected to advance after forward time-budgeted scrolls"
901 );
902
903 let mut last_index = result.first_visible_item_index;
904 for step in 0..4 {
905 state.dispatch_scroll_delta(80.0);
906 result =
907 measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |index| {
908 std::thread::sleep(std::time::Duration::from_millis(55));
909 create_test_item(index, item_sizes[index])
910 });
911 assert!(
912 result.first_visible_item_index <= last_index,
913 "reverse time-budgeted step {step} backtracked from index {last_index} to {}",
914 result.first_visible_item_index
915 );
916 last_index = result.first_visible_item_index;
917 }
918 });
919 }
920
921 #[test]
922 fn test_backward_scroll_does_not_advance_first_visible_index_for_variable_items() {
923 with_test_runtime(|| {
924 let state = new_lazy_list_state();
925 let config = LazyListMeasureConfig {
926 spacing: 8.0,
927 ..Default::default()
928 };
929 let item_sizes = [48.0, 56.0, 64.0, 72.0, 80.0];
930 let measure_item =
931 |index: usize| create_test_item(index, item_sizes[index % item_sizes.len()]);
932
933 let mut result = measure_lazy_list(200, &state, 260.0, 300.0, &config, measure_item);
934 assert_eq!(result.first_visible_item_index, 0);
935
936 for _ in 0..28 {
937 state.dispatch_scroll_delta(-32.0);
938 result = measure_lazy_list(200, &state, 260.0, 300.0, &config, measure_item);
939 }
940
941 assert!(
942 result.first_visible_item_index >= 12,
943 "expected to scroll well into the list before reversing, got index={}",
944 result.first_visible_item_index
945 );
946
947 let mut last_index = result.first_visible_item_index;
948 for step in 0..24 {
949 state.dispatch_scroll_delta(12.0);
950 result = measure_lazy_list(200, &state, 260.0, 300.0, &config, measure_item);
951 assert!(
952 result.first_visible_item_index <= last_index,
953 "backward step {step} advanced from index {last_index} to {}",
954 result.first_visible_item_index
955 );
956 last_index = result.first_visible_item_index;
957 }
958 });
959 }
960
961 #[test]
962 fn test_stored_offset_inside_tall_item_does_not_skip_forward_without_pending_scroll() {
963 with_test_runtime(|| {
964 let state = new_lazy_list_state_with_position(0, 900.0);
965 let config = LazyListMeasureConfig {
966 spacing: 8.0,
967 ..Default::default()
968 };
969 let item_sizes: Vec<f32> = (0..32usize)
970 .map(|index| if index == 0 { 1_200.0 } else { 64.0 })
971 .collect();
972
973 let result = measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |i| {
974 create_test_item(i, item_sizes[i])
975 });
976
977 assert_eq!(
978 result.first_visible_item_index, 0,
979 "stored in-item offset must not be turned into an average-size forward jump"
980 );
981 assert!(
982 (result.first_visible_item_scroll_offset - 900.0).abs() < 0.01,
983 "expected to preserve the stored in-item scroll offset"
984 );
985 });
986 }
987
988 #[test]
989 fn test_large_offset_inside_cached_tall_item_does_not_skip_forward_without_forward_scroll() {
990 with_test_runtime(|| {
991 let state = new_lazy_list_state_with_position(20, 900.0);
992 let config = LazyListMeasureConfig {
993 spacing: 8.0,
994 ..Default::default()
995 };
996 for index in 0..20 {
997 state.cache_item_size(index, 60.0 + (index % 3) as f32 * 8.0);
998 }
999 state.cache_item_size(20, 1_200.0);
1000
1001 let item_sizes: Vec<f32> = (0..64usize)
1002 .map(|index| {
1003 if index == 20 {
1004 1_200.0
1005 } else {
1006 60.0 + (index % 3) as f32 * 8.0
1007 }
1008 })
1009 .collect();
1010
1011 let result = measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |i| {
1012 create_test_item(i, item_sizes[i])
1013 });
1014
1015 assert_eq!(
1016 result.first_visible_item_index, 20,
1017 "offset within a tall cached item must not be interpreted as skipping to later average-sized items"
1018 );
1019 assert!(
1020 (result.first_visible_item_scroll_offset - 900.0).abs() < 0.01,
1021 "expected to preserve in-item offset inside the tall cached item"
1022 );
1023 });
1024 }
1025
1026 #[test]
1027 fn test_matches_exact_model_for_variable_item_reverse_scrolls() {
1028 with_test_runtime(|| {
1029 let state = new_lazy_list_state();
1030 let config = LazyListMeasureConfig {
1031 spacing: 8.0,
1032 ..Default::default()
1033 };
1034 let viewport_size = 260.0;
1035 let item_sizes: Vec<f32> = (0..240usize)
1036 .map(|index| match index % 9 {
1037 0 => 32.0,
1038 1 => 48.0,
1039 2 => 240.0,
1040 3 => 56.0,
1041 4 => 72.0,
1042 5 => 180.0,
1043 6 => 40.0,
1044 7 => 96.0,
1045 _ => 56.0,
1046 })
1047 .collect();
1048 let deltas = [
1049 -180.0, -180.0, -220.0, -150.0, -240.0, -120.0, -160.0, 60.0, 60.0, 80.0, -96.0,
1050 -96.0, 44.0, 44.0, 44.0, -140.0, -140.0, 72.0, 72.0, 72.0, 72.0,
1051 ];
1052 let expected =
1053 exact_scroll_position(&item_sizes, config.spacing, viewport_size, &deltas);
1054
1055 for (step, (delta, (expected_index, expected_offset))) in
1056 deltas.iter().zip(expected.iter()).enumerate()
1057 {
1058 state.dispatch_scroll_delta(*delta);
1059 let mut result;
1060 loop {
1061 result = measure_lazy_list(
1062 item_sizes.len(),
1063 &state,
1064 viewport_size,
1065 320.0,
1066 &config,
1067 |index| create_test_item(index, item_sizes[index]),
1068 );
1069 if state.peek_scroll_delta().abs() <= 0.001 {
1070 break;
1071 }
1072 }
1073
1074 assert_eq!(
1075 result.first_visible_item_index, *expected_index,
1076 "step {step} delta={delta} expected first index {} but got {}",
1077 expected_index, result.first_visible_item_index
1078 );
1079 assert!(
1080 (result.first_visible_item_scroll_offset - *expected_offset).abs() < 0.01,
1081 "step {step} delta={delta} expected offset {:.2} but got {:.2}",
1082 expected_offset,
1083 result.first_visible_item_scroll_offset
1084 );
1085 }
1086 });
1087 }
1088
1089 fn assert_end_reachable_with_step(step_dp: f32) {
1095 with_test_runtime(|| {
1096 let density = 2.75_f32;
1097 let viewport = 2280.0 / density; let items = 40usize;
1099 let item_size = 177.0 / density; let config = LazyListMeasureConfig::default();
1101 let state = new_lazy_list_state();
1102
1103 let mut result = measure_lazy_list(items, &state, viewport, 300.0, &config, |i| {
1104 create_test_item(i, item_size)
1105 });
1106
1107 let mut frames = 0;
1108 while result.can_scroll_forward && frames < 10_000 {
1109 state.dispatch_scroll_delta(-step_dp);
1110 result = measure_lazy_list(items, &state, viewport, 300.0, &config, |i| {
1111 create_test_item(i, item_size)
1112 });
1113 frames += 1;
1114 }
1115
1116 assert!(
1117 !result.can_scroll_forward,
1118 "list must report the end as reached (step {step_dp} dp)"
1119 );
1120 let last = result.visible_items.last().expect("visible items at end");
1121 assert_eq!(
1122 last.index,
1123 items - 1,
1124 "last item must be reachable (step {step_dp} dp)"
1125 );
1126 let last_bottom = last.offset + last.main_axis_size;
1127 assert!(
1128 (last_bottom - viewport).abs() < 0.01,
1129 "last item bottom {last_bottom} must align exactly with the fractional \
1130 viewport end {viewport} (step {step_dp} dp)"
1131 );
1132 assert!(
1134 result.can_scroll_backward,
1135 "end position must allow scrolling back"
1136 );
1137 });
1138 }
1139
1140 #[test]
1141 fn fractional_density_drag_reaches_exact_end() {
1142 assert_end_reachable_with_step(15.0);
1144 }
1145
1146 #[test]
1147 fn fractional_density_fling_reaches_exact_end() {
1148 assert_end_reachable_with_step(128.0);
1150 }
1151
1152 fn drag_to_end_with_tall_items(
1157 item_sizes: &[f32],
1158 viewport: f32,
1159 drag_dp: f32,
1160 ) -> (usize, f32, bool) {
1161 let items = item_sizes.len();
1162 let config = LazyListMeasureConfig::default();
1163 let state = new_lazy_list_state();
1164
1165 let mut result = measure_lazy_list(items, &state, viewport, 300.0, &config, |i| {
1166 create_test_item(i, item_sizes[i])
1167 });
1168
1169 let mut frames = 0;
1170 while result.can_scroll_forward && frames < 10_000 {
1171 state.dispatch_scroll_delta(-drag_dp);
1172 result = measure_lazy_list(items, &state, viewport, 300.0, &config, |i| {
1173 create_test_item(i, item_sizes[i])
1174 });
1175 frames += 1;
1176 }
1177
1178 let last = result.visible_items.last().expect("visible items at end");
1179 let last_bottom = last.offset + last.main_axis_size;
1180 (last.index, last_bottom, !result.can_scroll_forward)
1181 }
1182
1183 #[test]
1184 fn single_item_taller_than_viewport_scrolls_to_its_bottom() {
1185 with_test_runtime(|| {
1186 let viewport = 600.0;
1187 let (last_index, last_bottom, reached_end) =
1188 drag_to_end_with_tall_items(&[3000.0], viewport, 280.0);
1189 assert!(reached_end, "list must eventually report the end");
1190 assert_eq!(last_index, 0);
1191 assert!(
1192 (last_bottom - viewport).abs() < 0.01,
1193 "single 3000-tall item in a 600 viewport must scroll a full 2400 so its \
1194 bottom aligns with the viewport end; item bottom ended at {last_bottom}"
1195 );
1196 });
1197 }
1198
1199 #[test]
1200 fn trailing_item_taller_than_viewport_scrolls_to_its_bottom() {
1201 with_test_runtime(|| {
1202 let viewport = 600.0;
1203 let (last_index, last_bottom, reached_end) =
1204 drag_to_end_with_tall_items(&[200.0, 3000.0], viewport, 280.0);
1205 assert!(reached_end, "list must eventually report the end");
1206 assert_eq!(last_index, 1);
1207 assert!(
1208 (last_bottom - viewport).abs() < 0.01,
1209 "trailing 3000-tall item must be scrollable until its bottom aligns with \
1210 the viewport end; item bottom ended at {last_bottom}"
1211 );
1212 });
1213 }
1214
1215 #[test]
1216 fn tall_item_scroll_position_advances_within_the_item() {
1217 with_test_runtime(|| {
1220 let viewport = 600.0;
1221 let config = LazyListMeasureConfig::default();
1222 let state = new_lazy_list_state();
1223 let sizes = [200.0f32, 3000.0];
1224
1225 let mut result = measure_lazy_list(2, &state, viewport, 300.0, &config, |i| {
1226 create_test_item(i, sizes[i])
1227 });
1228 let mut consumed_total = 0.0f32;
1229 for step in 0..20 {
1230 if !result.can_scroll_forward {
1231 break;
1232 }
1233 let before_index = result.first_visible_item_index;
1234 let before_offset = result.first_visible_item_scroll_offset;
1235 state.dispatch_scroll_delta(-280.0);
1236 result = measure_lazy_list(2, &state, viewport, 300.0, &config, |i| {
1237 create_test_item(i, sizes[i])
1238 });
1239 let advanced = result.first_visible_item_index > before_index
1240 || result.first_visible_item_scroll_offset > before_offset + 0.001;
1241 assert!(
1242 advanced || !result.can_scroll_forward,
1243 "drag step {step} made no progress: stuck at index {} offset {:.2} while \
1244 can_scroll_forward is still true",
1245 result.first_visible_item_index,
1246 result.first_visible_item_scroll_offset
1247 );
1248 consumed_total += 280.0;
1249 if consumed_total > 4000.0 {
1250 break;
1251 }
1252 }
1253 assert!(!result.can_scroll_forward, "end must be reachable");
1255 assert_eq!(result.first_visible_item_index, 1);
1256 assert!(
1257 (result.first_visible_item_scroll_offset - 2400.0).abs() < 0.01,
1258 "expected final in-item offset 2400 (item bottom at viewport end), got {:.2}",
1259 result.first_visible_item_scroll_offset
1260 );
1261 });
1262 }
1263
1264 #[test]
1265 fn unbounded_viewport_realizes_all_items_and_disables_inner_scroll() {
1266 with_test_runtime(|| {
1267 let state = new_lazy_list_state();
1268 let config = LazyListMeasureConfig {
1269 spacing: 10.0,
1270 before_content_padding: 4.0,
1271 after_content_padding: 6.0,
1272 ..Default::default()
1273 };
1274 let sizes = [50.0f32, 800.0, 50.0];
1275
1276 let result =
1277 measure_lazy_list(sizes.len(), &state, f32::INFINITY, 320.0, &config, |i| {
1278 create_test_item(i, sizes[i])
1279 });
1280
1281 assert_eq!(
1282 result.visible_items.len(),
1283 sizes.len(),
1284 "an unbounded viewport must realize every item"
1285 );
1286 assert!((result.total_content_size - 930.0).abs() < 0.01);
1288 assert!((result.viewport_size - 930.0).abs() < 0.01);
1289 assert!((result.visible_items[0].offset - 4.0).abs() < 0.01);
1290 assert!((result.visible_items[1].offset - 64.0).abs() < 0.01);
1291 assert!((result.visible_items[2].offset - 874.0).abs() < 0.01);
1292 assert!(!result.can_scroll_forward, "outer container owns scrolling");
1293 assert!(!result.can_scroll_backward);
1294 assert!(!state.can_scroll_forward_non_reactive());
1295 assert_eq!(result.first_visible_item_index, 0);
1296 assert_eq!(state.first_visible_item_index_non_reactive(), 0);
1297 assert!(state.layout_info().is_infinite_viewport);
1298 });
1299 }
1300}