Skip to main content

cranpose_foundation/lazy/
lazy_list_measure.rs

1//! Core measurement algorithm for lazy lists.
2//!
3//! This module implements the virtualized measurement logic that determines
4//! which items should be composed and measured based on the current scroll
5//! position and viewport size.
6
7use 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
16/// Default estimated item size for scroll calculations.
17/// Used when no measured sizes are cached.
18/// 48.0 is a common list item height (Material Design list tile).
19pub 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/// Configuration for lazy list measurement.
27#[derive(Clone, Debug, PartialEq)]
28pub struct LazyListMeasureConfig {
29    /// Whether the list is vertical (true) or horizontal (false).
30    pub is_vertical: bool,
31
32    /// Whether layout is reversed (items laid out from bottom/right to top/left).
33    ///
34    /// The measurement logic operates in a "start-to-end" coordinate system.
35    /// This flag is used during placement to reverse the coordinates.
36    pub reverse_layout: bool,
37
38    /// Content padding before the first item.
39    pub before_content_padding: f32,
40
41    /// Content padding after the last item.
42    pub after_content_padding: f32,
43
44    /// Spacing between items.
45    pub spacing: f32,
46
47    /// Number of items to keep composed beyond visible bounds.
48    /// Default is 2 items before and after.
49    pub beyond_bounds_item_count: usize,
50
51    /// Vertical arrangement for distributing items.
52    /// Used when `is_vertical` is true.
53    pub vertical_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
54
55    /// Horizontal arrangement for distributing items.
56    /// Used when `is_vertical` is false.
57    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
75/// Measures a lazy list and returns the items to compose/place.
76///
77/// This is the core algorithm that determines virtualization behavior:
78/// 1. Handle pending scroll-to-item requests
79/// 2. Apply scroll delta to current position
80/// 3. Determine which items are visible in the viewport
81/// 4. Compose and measure only those items (+ beyond bounds buffer)
82/// 5. Calculate placements and total content size
83///
84/// # Arguments
85/// * `items_count` - Total number of items in the list
86/// * `state` - Current scroll state
87/// * `viewport_size` - Size of the viewport in main axis
88/// * `cross_axis_size` - Size of the viewport in cross axis
89/// * `config` - Measurement configuration
90/// * `measure_item` - Callback to compose and measure an item at given index
91///
92/// # Returns
93/// A [`LazyListMeasureResult`] containing the items to place.
94pub 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    // reverse_layout is handled during placement (create_lazy_list_placements)
133    // The measurement logic remains synonymous with "start" being the anchor edge
134
135    // Handle empty list - reset scroll position to 0
136    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    // Handle zero/negative viewport - preserve existing scroll state
156    // This can happen during collapsed states or measurement passes
157    if viewport_size <= 0.0 {
158        // Don't reset scroll position - just clear layout info
159        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    // 1. Viewport handling - detect and handle infinite viewports
179    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    // 2. Resolve and normalize scroll position
188    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    // Backward scroll: use measured sizes to avoid sticky boundaries when estimates are wrong.
201    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    // 3. Measure items (visible + beyond-bounds buffer)
243    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    // 4. Adjust bounds (clamp at start/end)
275    let adjuster = BoundsAdjuster::new(config, items_count, effective_viewport_size);
276    adjuster.clamp(&mut visible_items);
277
278    // 5. Calculate total content size and finalize result
279    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    // Update scroll position - find actual first visible item
287    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    // Update state with key for scroll position stability
329    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    // Update reactive scroll bounds from layout info
386    state.update_scroll_bounds();
387
388    // Determine scroll capability
389    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
407/// Estimates total content size based on measured items.
408///
409/// Uses the average size of measured items to estimate the total.
410/// Falls back to state's running average if no items are currently measured.
411fn 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    // Use measured items' average if available, otherwise use state's accumulated average
422    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            // 10 items of 50px each, viewport of 200px should show 4+ items
650            let result = measure_lazy_list(10, &state, 200.0, 300.0, &config, |i| {
651                create_test_item(i, 50.0)
652            });
653
654            // Should have visible items plus beyond-bounds buffer
655            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}