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