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