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 std::collections::VecDeque;
8
9use super::{
10    bounds_adjuster::BoundsAdjuster,
11    diagnostics,
12    item_measurer::ItemMeasurer,
13    lazy_list_measured_item::{LazyListMeasureResult, LazyListMeasuredItem},
14    lazy_list_state::{LazyListLayoutInfo, LazyListState},
15    scroll_position_resolver::ScrollPositionResolver,
16    viewport::ViewportHandler,
17};
18
19/// Default estimated item size for scroll calculations.
20/// Used when no measured sizes are cached.
21/// 48.0 is a common list item height (Material Design list tile).
22pub const DEFAULT_ITEM_SIZE_ESTIMATE: f32 = 48.0;
23const MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS: usize = 8;
24const MIN_ADAPTIVE_SCROLL_DELTA_ITEMS: f32 = 1.5;
25const MIN_ACTIVE_SCROLL_WHEEL_BEYOND_BOUNDS_ITEMS: usize = 2;
26const MIN_ACTIVE_SCROLL_FAST_BEYOND_BOUNDS_ITEMS: usize = MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS;
27const MIN_IDLE_WARM_BEYOND_BOUNDS_ITEMS: usize = 4;
28
29/// Configuration for lazy list measurement.
30#[derive(Clone, Debug, PartialEq)]
31pub struct LazyListMeasureConfig {
32    /// Whether the list is vertical (true) or horizontal (false).
33    pub is_vertical: bool,
34    /// Whether horizontal placement resolves start from the right edge.
35    pub is_rtl: bool,
36
37    /// Whether layout is reversed (items laid out from bottom/right to top/left).
38    ///
39    /// The measurement logic operates in a "start-to-end" coordinate system.
40    /// This flag is used during placement to reverse the coordinates.
41    pub reverse_layout: bool,
42
43    /// Content padding before the first item.
44    pub before_content_padding: f32,
45
46    /// Content padding after the last item.
47    pub after_content_padding: f32,
48
49    /// Spacing between items.
50    pub spacing: f32,
51
52    /// Number of items to keep composed beyond visible bounds.
53    /// Default is 2 items before and after.
54    pub beyond_bounds_item_count: usize,
55
56    /// Vertical arrangement for distributing items.
57    /// Used when `is_vertical` is true.
58    pub vertical_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
59
60    /// Horizontal arrangement for distributing items.
61    /// Used when `is_vertical` is false.
62    pub horizontal_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
63}
64
65impl Default for LazyListMeasureConfig {
66    fn default() -> Self {
67        Self {
68            is_vertical: true,
69            is_rtl: false,
70            reverse_layout: false,
71            before_content_padding: 0.0,
72            after_content_padding: 0.0,
73            spacing: 0.0,
74            beyond_bounds_item_count: 2,
75            vertical_arrangement: None,
76            horizontal_arrangement: None,
77        }
78    }
79}
80
81/// What a lazy list measure pass does with an item beyond its viewport.
82pub enum BeyondItem {
83    /// The item stays composed without being placed.
84    Kept,
85    /// The item is placed beside the viewport's items, where a focus search
86    /// can move into it.
87    Placed(LazyListMeasuredItem),
88    /// The item is not kept, and neither is any further item on that side.
89    Declined,
90}
91
92/// Where a lazy list measure pass gets its items.
93pub trait LazyItemSource {
94    /// Composes and measures the item at `index`, which the pass places.
95    fn measure(&mut self, index: usize) -> LazyListMeasuredItem;
96
97    /// Keeps the item at `index`, which lies beyond the viewport, composed
98    /// ahead of a scroll. A source places either every beyond item it keeps
99    /// in a pass or none.
100    fn keep_beyond(&mut self, index: usize) -> BeyondItem;
101}
102
103struct MeasureEveryItem<F>(F);
104
105impl<F> LazyItemSource for MeasureEveryItem<F>
106where
107    F: FnMut(usize) -> LazyListMeasuredItem,
108{
109    fn measure(&mut self, index: usize) -> LazyListMeasuredItem {
110        (self.0)(index)
111    }
112
113    fn keep_beyond(&mut self, index: usize) -> BeyondItem {
114        (self.0)(index);
115        BeyondItem::Kept
116    }
117}
118
119/// Measures a lazy list and returns the items to compose/place.
120///
121/// This is the core algorithm that determines virtualization behavior:
122/// 1. Handle pending scroll-to-item requests
123/// 2. Apply scroll delta to current position
124/// 3. Determine which items are visible in the viewport
125/// 4. Compose and measure only those items, and keep the beyond-bounds
126///    buffer composed without placing it
127/// 5. Calculate placements and total content size
128///
129/// # Arguments
130/// * `items_count` - Total number of items in the list
131/// * `state` - Current scroll state
132/// * `viewport_size` - Size of the viewport in main axis
133/// * `cross_axis_size` - Size of the viewport in cross axis
134/// * `config` - Measurement configuration
135/// * `measure_item` - Callback to compose and measure an item at given index
136///
137/// # Returns
138/// A [`LazyListMeasureResult`] containing the items to place.
139pub fn measure_lazy_list<F>(
140    items_count: usize,
141    state: &LazyListState,
142    viewport_size: f32,
143    _cross_axis_size: f32,
144    config: &LazyListMeasureConfig,
145    measure_item: F,
146) -> LazyListMeasureResult
147where
148    F: FnMut(usize) -> LazyListMeasuredItem,
149{
150    measure_lazy_list_with_beyond_bounds_policy(
151        items_count,
152        state,
153        viewport_size,
154        _cross_axis_size,
155        config,
156        &mut MeasureEveryItem(measure_item),
157    )
158}
159
160/// [`measure_lazy_list`] with the items taken from `source`, which decides
161/// how the items beyond the viewport are kept.
162pub fn measure_lazy_list_with_beyond_bounds_policy<S>(
163    items_count: usize,
164    state: &LazyListState,
165    viewport_size: f32,
166    _cross_axis_size: f32,
167    config: &LazyListMeasureConfig,
168    source: &mut S,
169) -> LazyListMeasureResult
170where
171    S: LazyItemSource,
172{
173    let raw_viewport_size = viewport_size;
174    let is_infinite_viewport = raw_viewport_size.is_infinite();
175
176    if items_count == 0 {
177        state.update_scroll_position(0, 0.0);
178        state.update_layout_info(LazyListLayoutInfo {
179            visible_items_info: Vec::new(),
180            total_items_count: 0,
181            raw_viewport_size,
182            is_infinite_viewport,
183            viewport_size,
184            viewport_start_offset: 0.0,
185            viewport_end_offset: viewport_size,
186            before_content_padding: config.before_content_padding,
187            after_content_padding: config.after_content_padding,
188            snap_anchor_offset: 0.0,
189            reverse_layout: config.reverse_layout,
190        });
191        state.update_scroll_bounds();
192        return LazyListMeasureResult::default();
193    }
194
195    if viewport_size <= 0.0 {
196        state.update_layout_info(LazyListLayoutInfo {
197            visible_items_info: Vec::new(),
198            total_items_count: items_count,
199            raw_viewport_size,
200            is_infinite_viewport,
201            viewport_size,
202            viewport_start_offset: 0.0,
203            viewport_end_offset: viewport_size.max(0.0),
204            before_content_padding: config.before_content_padding,
205            after_content_padding: config.after_content_padding,
206            snap_anchor_offset: 0.0,
207            reverse_layout: config.reverse_layout,
208        });
209        state.update_scroll_bounds();
210        return LazyListMeasureResult::default();
211    }
212
213    let measure_state = state.begin_measure_pass();
214
215    let viewport = ViewportHandler::new(
216        viewport_size,
217        measure_state.average_item_size,
218        config.spacing,
219    );
220    if viewport.is_infinite() {
221        return measure_unbounded_lazy_list(items_count, state, raw_viewport_size, config, source);
222    }
223    let effective_viewport_size = viewport.effective_size();
224    let is_infinite_viewport = viewport.is_infinite();
225
226    let pending_scroll_delta = measure_state.pending_scroll_delta;
227    let window_scroll_delta = measure_state.window_scroll_delta;
228    let resolver = ScrollPositionResolver::new(
229        state,
230        measure_state,
231        config,
232        items_count,
233        effective_viewport_size,
234    );
235    let (mut first_index, mut first_offset) = resolver.apply_pending_scroll_delta();
236
237    let mut pre_measured = Vec::new();
238
239    if first_offset < 0.0 && first_index > 0 {
240        (first_index, first_offset) = resolver.normalize_backward_jump(first_index, first_offset);
241        while first_offset < 0.0 && first_index > 0 {
242            first_index -= 1;
243            let item = source.measure(first_index);
244            first_offset += item.main_axis_size + config.spacing;
245            if first_index == 0 {
246                first_offset += config.before_content_padding;
247            }
248            pre_measured.push(item);
249        }
250        pre_measured.reverse();
251    }
252
253    first_index = first_index.min(items_count.saturating_sub(1));
254    first_offset = first_offset.max(0.0);
255    (first_index, first_offset) = resolver.normalize_forward_with_cache(first_index, first_offset);
256    let item_extent_at = |index: usize, item_size: f32| {
257        let leading_padding = if index == 0 {
258            config.before_content_padding
259        } else {
260            0.0
261        };
262        let spacing_after = if index + 1 < items_count {
263            config.spacing
264        } else {
265            0.0
266        };
267        leading_padding + item_size + spacing_after
268    };
269    let mut offset_known_within_current_item = state
270        .get_cached_size(first_index)
271        .is_some_and(|size| first_offset + 0.001 < item_extent_at(first_index, size));
272
273    if !offset_known_within_current_item && first_offset > 0.0 && first_index < items_count {
274        let item = source.measure(first_index);
275        let item_extent = item_extent_at(first_index, item.main_axis_size);
276
277        if first_offset + 0.001 < item_extent {
278            pre_measured.push(item);
279            offset_known_within_current_item = true;
280        }
281    }
282
283    if !offset_known_within_current_item {
284        (first_index, first_offset) = resolver.normalize_forward(first_index, first_offset);
285    }
286
287    let pre_measured_queue = VecDeque::from(pre_measured);
288    let telemetry_enabled = diagnostics::telemetry_enabled();
289    let adaptive_beyond_bounds = adaptive_scroll_beyond_bounds_item_count(
290        config,
291        window_scroll_delta,
292        measure_state.average_item_size,
293    );
294    let guaranteed_beyond_bounds = adaptive_beyond_bounds;
295    let mut measurer = ItemMeasurer::new(
296        source,
297        config,
298        items_count,
299        effective_viewport_size,
300        measure_state.average_item_size,
301        pre_measured_queue,
302    )
303    .with_beyond_bounds_item_count(adaptive_beyond_bounds)
304    .with_guaranteed_after_beyond_bounds_item_count(guaranteed_beyond_bounds)
305    .with_include_before_beyond_bounds(window_scroll_delta >= -0.001)
306    .with_telemetry_pass_id(telemetry_enabled.then(|| state.next_item_measure_pass_id()));
307    let measurement_pass = measurer.measure_all(first_index, first_offset);
308    let measurement_start_index = measurement_pass.start_index;
309    let measurement_start_offset = measurement_pass.start_offset;
310    let measurement_next_index = measurement_pass.next_index;
311    let measurement_next_offset = measurement_pass.next_offset;
312    let measurement_measured_visible_items = measurement_pass.measured_visible_items;
313    let measurement_hit_time_budget = measurement_pass.hit_time_budget;
314    let measurement_viewport_filled = measurement_pass.viewport_filled;
315    let mut visible_items = measurement_pass.items;
316
317    let adjuster = BoundsAdjuster::new(config, items_count, effective_viewport_size);
318    adjuster.clamp(&mut visible_items);
319
320    let total_content_size = estimate_total_content_size(
321        items_count,
322        &visible_items,
323        config,
324        measure_state.average_item_size,
325    );
326
327    let viewport_start = 0.0;
328    let viewport_end = effective_viewport_size;
329    let item_end_with_spacing = |item: &LazyListMeasuredItem| {
330        let spacing_after = if item.index + 1 < items_count {
331            config.spacing
332        } else {
333            0.0
334        };
335        item.offset + item.main_axis_size + spacing_after
336    };
337    let actual_first_visible = visible_items
338        .iter()
339        .find(|item| item_end_with_spacing(item) > viewport_start);
340
341    let unresolved_pass = measurement_hit_time_budget
342        && !measurement_viewport_filled
343        && actual_first_visible.is_none();
344
345    let (final_first_index, final_scroll_offset) = if let Some(first) = actual_first_visible {
346        let leading_padding = if first.index == 0 {
347            config.before_content_padding
348        } else {
349            0.0
350        };
351        let offset = leading_padding - first.offset;
352        (first.index, offset.max(0.0))
353    } else if unresolved_pass {
354        if pending_scroll_delta > 0.001 {
355            let leading_padding = if measurement_start_index == 0 {
356                config.before_content_padding
357            } else {
358                0.0
359            };
360            let preserved_offset = (leading_padding - measurement_start_offset).max(0.0);
361            (measurement_start_index, preserved_offset)
362        } else {
363            let next_index = measurement_next_index.min(items_count.saturating_sub(1));
364            if next_index + 1 >= items_count {
365                (next_index, 0.0)
366            } else {
367                let leading_padding = if next_index == 0 {
368                    config.before_content_padding
369                } else {
370                    0.0
371                };
372                let next_offset = (leading_padding - measurement_next_offset).max(0.0);
373                (next_index, next_offset)
374            }
375        }
376    } else if !visible_items.is_empty() {
377        (visible_items[0].index, 0.0)
378    } else {
379        (0, 0.0)
380    };
381
382    if let Some(first) = actual_first_visible {
383        state.update_scroll_position_with_key(final_first_index, final_scroll_offset, first.key);
384    } else if !visible_items.is_empty() && !unresolved_pass {
385        state.update_scroll_position_with_key(
386            final_first_index,
387            final_scroll_offset,
388            visible_items[0].key,
389        );
390    } else {
391        state.update_scroll_position(final_first_index, final_scroll_offset);
392    }
393
394    if telemetry_enabled {
395        let cycle_id = state.next_measure_cycle_id();
396        log::warn!(
397            "[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={}",
398            cycle_id,
399            items_count,
400            measure_state.average_item_size,
401            effective_viewport_size,
402            total_content_size,
403            first_index,
404            first_offset,
405            measurement_start_index,
406            if measurement_start_index == 0 {
407                config.before_content_padding - measurement_start_offset
408            } else {
409                -measurement_start_offset
410            },
411            final_first_index,
412            final_scroll_offset,
413            measurement_measured_visible_items,
414            visible_items.len(),
415            unresolved_pass,
416            actual_first_visible.is_some(),
417            measurement_hit_time_budget,
418            measurement_viewport_filled
419        );
420    }
421    state.update_layout_info(LazyListLayoutInfo {
422        visible_items_info: visible_items
423            .iter()
424            .filter(|item| {
425                let item_end = item_end_with_spacing(item);
426                item_end > viewport_start && item.offset < viewport_end
427            })
428            .map(super::lazy_list_measured_item::LazyListMeasuredItem::to_item_info)
429            .collect(),
430        total_items_count: items_count,
431        raw_viewport_size,
432        is_infinite_viewport,
433        viewport_size: effective_viewport_size,
434        viewport_start_offset: viewport_start,
435        viewport_end_offset: viewport_end,
436        before_content_padding: config.before_content_padding,
437        after_content_padding: config.after_content_padding,
438        snap_anchor_offset: 0.0,
439        reverse_layout: config.reverse_layout,
440    });
441
442    state.update_scroll_bounds();
443
444    let can_scroll_backward = final_first_index > 0 || final_scroll_offset > 0.0;
445    let can_scroll_forward = if let Some(last) = visible_items.last() {
446        last.index < items_count - 1 || (last.offset + last.main_axis_size) > viewport_end
447    } else {
448        false
449    };
450
451    LazyListMeasureResult {
452        visible_items,
453        first_visible_item_index: final_first_index,
454        first_visible_item_scroll_offset: final_scroll_offset,
455        viewport_size: effective_viewport_size,
456        total_content_size,
457        can_scroll_forward,
458        can_scroll_backward,
459    }
460}
461
462/// Safety cap for the number of items realized when the viewport is
463/// unbounded. Mirrors `MAX_VISIBLE_ITEMS_SAFETY` in the item measurer.
464const MAX_UNBOUNDED_REALIZED_ITEMS: usize = 10_000;
465
466/// Measures a lazy list whose main-axis viewport is unbounded/infinite.
467///
468/// Without a finite viewport there is nothing to virtualize against: every
469/// item is realized sequentially from the start and the list reports its true
470/// content extent. Scrolling is delegated entirely to the enclosing
471/// scrollable, so the internal scroll position is pinned to the origin and
472/// both scroll capabilities are reported as exhausted (which also stops the
473/// scroll gesture detector from capturing drags that the outer scrollable
474/// needs).
475fn measure_unbounded_lazy_list<S>(
476    items_count: usize,
477    state: &LazyListState,
478    raw_viewport_size: f32,
479    config: &LazyListMeasureConfig,
480    source: &mut S,
481) -> LazyListMeasureResult
482where
483    S: LazyItemSource,
484{
485    let realized_count = items_count.min(MAX_UNBOUNDED_REALIZED_ITEMS);
486    if realized_count < items_count {
487        log::warn!(
488            "LazyList: unbounded viewport with {items_count} items; realizing only the first {realized_count}. \
489             Wrap the list in a constrained container to restore virtualization."
490        );
491    }
492
493    let mut visible_items = Vec::with_capacity(realized_count);
494    let mut offset = config.before_content_padding;
495    for index in 0..realized_count {
496        let mut item = source.measure(index);
497        item.offset = offset;
498        offset += item.main_axis_size;
499        if index + 1 < items_count {
500            offset += config.spacing;
501        }
502        visible_items.push(item);
503    }
504    let content_extent = offset + config.after_content_padding;
505
506    state.update_scroll_position(0, 0.0);
507    state.update_layout_info(LazyListLayoutInfo {
508        visible_items_info: visible_items
509            .iter()
510            .map(super::lazy_list_measured_item::LazyListMeasuredItem::to_item_info)
511            .collect(),
512        total_items_count: items_count,
513        raw_viewport_size,
514        is_infinite_viewport: true,
515        viewport_size: content_extent,
516        viewport_start_offset: 0.0,
517        viewport_end_offset: content_extent,
518        before_content_padding: config.before_content_padding,
519        after_content_padding: config.after_content_padding,
520        snap_anchor_offset: 0.0,
521        reverse_layout: config.reverse_layout,
522    });
523    state.update_scroll_bounds();
524
525    let can_scroll_forward = realized_count < items_count;
526    LazyListMeasureResult {
527        visible_items,
528        first_visible_item_index: 0,
529        first_visible_item_scroll_offset: 0.0,
530        viewport_size: content_extent,
531        total_content_size: content_extent,
532        can_scroll_forward,
533        can_scroll_backward: false,
534    }
535}
536
537/// Estimates total content size based on measured items.
538///
539/// Uses the average size of measured items to estimate the total.
540/// Falls back to state's running average if no items are currently measured.
541fn estimate_total_content_size(
542    items_count: usize,
543    measured_items: &[LazyListMeasuredItem],
544    config: &LazyListMeasureConfig,
545    state_average_size: f32,
546) -> f32 {
547    if items_count == 0 {
548        return 0.0;
549    }
550
551    let avg_size = if !measured_items.is_empty() {
552        let total_measured_size: f32 = measured_items.iter().map(|i| i.main_axis_size).sum();
553        total_measured_size / measured_items.len() as f32
554    } else {
555        state_average_size
556    };
557
558    config.before_content_padding + (avg_size + config.spacing) * items_count as f32
559        - config.spacing
560        + config.after_content_padding
561}
562
563fn adaptive_scroll_beyond_bounds_item_count(
564    config: &LazyListMeasureConfig,
565    pending_scroll_delta: f32,
566    average_item_size: f32,
567) -> usize {
568    let base_count = config.beyond_bounds_item_count;
569    if pending_scroll_delta.abs() <= 0.001 {
570        return if base_count == 0 {
571            MIN_IDLE_WARM_BEYOND_BOUNDS_ITEMS
572        } else {
573            base_count
574        };
575    }
576    let item_extent = if average_item_size.is_finite() && average_item_size > 0.0 {
577        average_item_size
578    } else {
579        DEFAULT_ITEM_SIZE_ESTIMATE
580    } + config.spacing.max(0.0);
581    let item_extent = item_extent.max(1.0);
582    let delta_items = pending_scroll_delta.abs() / item_extent;
583    if delta_items < MIN_ADAPTIVE_SCROLL_DELTA_ITEMS {
584        // Past the items a frame keeps ready, the window holds as many as the
585        // scroll brings into view per frame, so a frame that finds enough
586        // ready leaves the next ones to a pass between frames.
587        return base_count.max(MIN_ACTIVE_SCROLL_WHEEL_BEYOND_BOUNDS_ITEMS)
588            + delta_items.ceil() as usize;
589    }
590
591    let adaptive_count = delta_items.ceil() as usize;
592    base_count
593        .max(MIN_ACTIVE_SCROLL_FAST_BEYOND_BOUNDS_ITEMS)
594        .max(adaptive_count.min(MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS))
595}
596
597#[cfg(test)]
598#[path = "tests/lazy_list_measure_tests.rs"]
599mod tests;