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    if viewport.is_infinite() {
185        // Unbounded main axis (e.g. a LazyColumn nested in a vertical_scroll
186        // container): there is no viewport to virtualize against, so realize
187        // every item and report the true content extent. The previous
188        // average-size-derived pseudo viewport (avg * 20) truncated the
189        // reported height below the real content and, once a single tall item
190        // raised the average past the total content, flipped
191        // can_scroll_forward to false partway through — hard-stopping scroll
192        // gestures while content was still below the fold.
193        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    // 2. Resolve and normalize scroll position
205    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    // Backward scroll: use measured sizes to avoid sticky boundaries when estimates are wrong.
218    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    // 3. Measure items (visible + beyond-bounds buffer)
260    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    // 4. Adjust bounds (clamp at start/end)
292    let adjuster = BoundsAdjuster::new(config, items_count, effective_viewport_size);
293    adjuster.clamp(&mut visible_items);
294
295    // 5. Calculate total content size and finalize result
296    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    // Update scroll position - find actual first visible item
304    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    // Update state with key for scroll position stability
346    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    // Update reactive scroll bounds from layout info
403    state.update_scroll_bounds();
404
405    // Determine scroll capability
406    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
424/// Safety cap for the number of items realized when the viewport is
425/// unbounded. Mirrors `MAX_VISIBLE_ITEMS_SAFETY` in the item measurer.
426const MAX_UNBOUNDED_REALIZED_ITEMS: usize = 10_000;
427
428/// Measures a lazy list whose main-axis viewport is unbounded/infinite.
429///
430/// Without a finite viewport there is nothing to virtualize against: every
431/// item is realized sequentially from the start and the list reports its true
432/// content extent. Scrolling is delegated entirely to the enclosing
433/// scrollable, so the internal scroll position is pinned to the origin and
434/// both scroll capabilities are reported as exhausted (which also stops the
435/// scroll gesture detector from capturing drags that the outer scrollable
436/// needs).
437fn 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    // The list itself does not scroll in this configuration.
471    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
499/// Estimates total content size based on measured items.
500///
501/// Uses the average size of measured items to estimate the total.
502/// Falls back to state's running average if no items are currently measured.
503fn 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    // Use measured items' average if available, otherwise use state's accumulated average
514    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            // 10 items of 50px each, viewport of 200px should show 4+ items
742            let result = measure_lazy_list(10, &state, 200.0, 300.0, &config, |i| {
743                create_test_item(i, 50.0)
744            });
745
746            // Should have visible items plus beyond-bounds buffer
747            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    /// Regression tests for real-device geometry: a phone viewport of 2280
1090    /// physical px at density 2.75 is a *fractional* dp viewport (829.0909),
1091    /// and item heights that are whole physical px are fractional dp. The
1092    /// last item must stay exactly reachable — no truncation of the
1093    /// scrollable range and no off-by-one in can_scroll_forward.
1094    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; // 829.0909 dp
1098            let items = 40usize;
1099            let item_size = 177.0 / density; // 64.3636 dp
1100            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            // And the way back must open up again.
1133            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        // Slow finger drag: ~15 dp consumed per measured frame.
1143        assert_end_reachable_with_step(15.0);
1144    }
1145
1146    #[test]
1147    fn fractional_density_fling_reaches_exact_end() {
1148        // Fling-scale deltas: ~128 dp per frame (8000 dp/s at 16ms frames).
1149        assert_end_reachable_with_step(128.0);
1150    }
1151
1152    /// Coordinator repro: ONE item substantially TALLER than the viewport.
1153    /// Max scroll must be derived from the measured pixel extent of the item,
1154    /// not from item count/index granularity: viewport 600, single item 3000
1155    /// => maximum scroll offset 2400, reachable through repeated finger drags.
1156    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        // Not just the final clamp: every drag must make progress while the
1218        // tall item still has content below the fold (hard-stop regression).
1219        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            // total content 3200, viewport 600 => max scroll 2600 inside item 1
1254            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            // 4 + 50 + 10 + 800 + 10 + 50 + 6 = 930
1287            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}