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::item_measurer::ItemMeasurer;
9use super::lazy_list_measured_item::{LazyListMeasureResult, LazyListMeasuredItem};
10use super::lazy_list_state::{LazyListLayoutInfo, LazyListState};
11use super::scroll_position_resolver::ScrollPositionResolver;
12use super::viewport::ViewportHandler;
13use std::collections::VecDeque;
14
15/// Default estimated item size for scroll calculations.
16/// Used when no measured sizes are cached.
17/// 48.0 is a common list item height (Material Design list tile).
18pub const DEFAULT_ITEM_SIZE_ESTIMATE: f32 = 48.0;
19
20/// Configuration for lazy list measurement.
21#[derive(Clone, Debug)]
22pub struct LazyListMeasureConfig {
23    /// Whether the list is vertical (true) or horizontal (false).
24    pub is_vertical: bool,
25
26    /// Whether layout is reversed (items laid out from bottom/right to top/left).
27    ///
28    /// The measurement logic operates in a "start-to-end" coordinate system.
29    /// This flag is used during placement to reverse the coordinates.
30    pub reverse_layout: bool,
31
32    /// Content padding before the first item.
33    pub before_content_padding: f32,
34
35    /// Content padding after the last item.
36    pub after_content_padding: f32,
37
38    /// Spacing between items.
39    pub spacing: f32,
40
41    /// Number of items to keep composed beyond visible bounds.
42    /// Default is 2 items before and after.
43    pub beyond_bounds_item_count: usize,
44
45    /// Vertical arrangement for distributing items.
46    /// Used when `is_vertical` is true.
47    pub vertical_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
48
49    /// Horizontal arrangement for distributing items.
50    /// Used when `is_vertical` is false.
51    pub horizontal_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
52}
53
54impl Default for LazyListMeasureConfig {
55    fn default() -> Self {
56        Self {
57            is_vertical: true,
58            reverse_layout: false,
59            before_content_padding: 0.0,
60            after_content_padding: 0.0,
61            spacing: 0.0,
62            beyond_bounds_item_count: 2,
63            vertical_arrangement: None,
64            horizontal_arrangement: None,
65        }
66    }
67}
68
69/// Measures a lazy list and returns the items to compose/place.
70///
71/// This is the core algorithm that determines virtualization behavior:
72/// 1. Handle pending scroll-to-item requests
73/// 2. Apply scroll delta to current position
74/// 3. Determine which items are visible in the viewport
75/// 4. Compose and measure only those items (+ beyond bounds buffer)
76/// 5. Calculate placements and total content size
77///
78/// # Arguments
79/// * `items_count` - Total number of items in the list
80/// * `state` - Current scroll state
81/// * `viewport_size` - Size of the viewport in main axis
82/// * `cross_axis_size` - Size of the viewport in cross axis
83/// * `config` - Measurement configuration
84/// * `measure_item` - Callback to compose and measure an item at given index
85///
86/// # Returns
87/// A [`LazyListMeasureResult`] containing the items to place.
88pub fn measure_lazy_list<F>(
89    items_count: usize,
90    state: &LazyListState,
91    viewport_size: f32,
92    _cross_axis_size: f32,
93    config: &LazyListMeasureConfig,
94    mut measure_item: F,
95) -> LazyListMeasureResult
96where
97    F: FnMut(usize) -> LazyListMeasuredItem,
98{
99    // reverse_layout is handled during placement (create_lazy_list_placements)
100    // The measurement logic remains synonymous with "start" being the anchor edge
101
102    // Handle empty list - reset scroll position to 0
103    if items_count == 0 {
104        state.update_scroll_position(0, 0.0);
105        state.update_layout_info(LazyListLayoutInfo {
106            visible_items_info: Vec::new(),
107            total_items_count: 0,
108            viewport_size,
109            viewport_start_offset: config.before_content_padding,
110            viewport_end_offset: config.after_content_padding,
111            before_content_padding: config.before_content_padding,
112            after_content_padding: config.after_content_padding,
113        });
114        state.update_scroll_bounds();
115        return LazyListMeasureResult::default();
116    }
117
118    // Handle zero/negative viewport - preserve existing scroll state
119    // This can happen during collapsed states or measurement passes
120    if viewport_size <= 0.0 {
121        // Don't reset scroll position - just clear layout info
122        state.update_layout_info(LazyListLayoutInfo {
123            visible_items_info: Vec::new(),
124            total_items_count: items_count,
125            viewport_size,
126            viewport_start_offset: config.before_content_padding,
127            viewport_end_offset: config.after_content_padding,
128            before_content_padding: config.before_content_padding,
129            after_content_padding: config.after_content_padding,
130        });
131        state.update_scroll_bounds();
132        return LazyListMeasureResult::default();
133    }
134
135    // 1. Viewport handling - detect and handle infinite viewports
136    let viewport = ViewportHandler::new(viewport_size, state.average_item_size(), config.spacing);
137    let effective_viewport_size = viewport.effective_size();
138
139    // 2. Resolve and normalize scroll position
140    let resolver = ScrollPositionResolver::new(state, config, items_count, effective_viewport_size);
141    let (mut first_index, mut first_offset) = resolver.apply_pending_scroll_delta();
142    let mut pre_measured = Vec::new();
143
144    // Backward scroll: use measured sizes to avoid sticky boundaries when estimates are wrong.
145    if first_offset < 0.0 && first_index > 0 {
146        (first_index, first_offset) = resolver.normalize_backward_jump(first_index, first_offset);
147        while first_offset < 0.0 && first_index > 0 {
148            first_index -= 1;
149            let item = measure_item(first_index);
150            first_offset += item.main_axis_size + config.spacing;
151            pre_measured.push(item);
152        }
153        pre_measured.reverse();
154    }
155
156    first_index = first_index.min(items_count.saturating_sub(1));
157    first_offset = first_offset.max(0.0);
158    (first_index, first_offset) = resolver.normalize_forward(first_index, first_offset);
159
160    // 3. Measure items (visible + beyond-bounds buffer)
161    let pre_measured_queue = VecDeque::from(pre_measured);
162    let mut measurer = ItemMeasurer::new(
163        &mut measure_item,
164        config,
165        items_count,
166        effective_viewport_size,
167        pre_measured_queue,
168    );
169    let mut visible_items = measurer.measure_all(first_index, first_offset);
170
171    // 4. Adjust bounds (clamp at start/end)
172    let adjuster = BoundsAdjuster::new(config, items_count, effective_viewport_size);
173    adjuster.clamp(&mut visible_items);
174
175    // 5. Calculate total content size and finalize result
176    let total_content_size = estimate_total_content_size(
177        items_count,
178        &visible_items,
179        config,
180        state.average_item_size(),
181    );
182
183    // Update scroll position - find actual first visible item
184    let viewport_end = effective_viewport_size - config.after_content_padding;
185    let item_end_with_spacing = |item: &LazyListMeasuredItem| {
186        let spacing_after = if item.index + 1 < items_count {
187            config.spacing
188        } else {
189            0.0
190        };
191        item.offset + item.main_axis_size + spacing_after
192    };
193    let actual_first_visible = visible_items
194        .iter()
195        .find(|item| item_end_with_spacing(item) > config.before_content_padding);
196
197    let (final_first_index, final_scroll_offset) = if let Some(first) = actual_first_visible {
198        let offset = config.before_content_padding - first.offset;
199        (first.index, offset.max(0.0))
200    } else if !visible_items.is_empty() {
201        (visible_items[0].index, 0.0)
202    } else {
203        (0, 0.0)
204    };
205
206    // Update state with key for scroll position stability
207    if let Some(first) = actual_first_visible {
208        state.update_scroll_position_with_key(final_first_index, final_scroll_offset, first.key);
209    } else if !visible_items.is_empty() {
210        state.update_scroll_position_with_key(
211            final_first_index,
212            final_scroll_offset,
213            visible_items[0].key,
214        );
215    } else {
216        state.update_scroll_position(final_first_index, final_scroll_offset);
217    }
218    state.update_layout_info(LazyListLayoutInfo {
219        visible_items_info: visible_items
220            .iter()
221            .filter(|item| {
222                let item_end = item_end_with_spacing(item);
223                item_end > config.before_content_padding && item.offset < viewport_end
224            })
225            .map(|i| i.to_item_info())
226            .collect(),
227        total_items_count: items_count,
228        viewport_size: effective_viewport_size,
229        viewport_start_offset: config.before_content_padding,
230        viewport_end_offset: config.after_content_padding,
231        before_content_padding: config.before_content_padding,
232        after_content_padding: config.after_content_padding,
233    });
234
235    // Update reactive scroll bounds from layout info
236    state.update_scroll_bounds();
237
238    // Determine scroll capability
239    let can_scroll_backward = final_first_index > 0 || final_scroll_offset > 0.0;
240    let can_scroll_forward = if let Some(last) = visible_items.last() {
241        last.index < items_count - 1 || (last.offset + last.main_axis_size) > viewport_end
242    } else {
243        false
244    };
245
246    LazyListMeasureResult {
247        visible_items,
248        first_visible_item_index: final_first_index,
249        first_visible_item_scroll_offset: final_scroll_offset,
250        viewport_size: effective_viewport_size,
251        total_content_size,
252        can_scroll_forward,
253        can_scroll_backward,
254    }
255}
256
257/// Estimates total content size based on measured items.
258///
259/// Uses the average size of measured items to estimate the total.
260/// Falls back to state's running average if no items are currently measured.
261fn estimate_total_content_size(
262    items_count: usize,
263    measured_items: &[LazyListMeasuredItem],
264    config: &LazyListMeasureConfig,
265    state_average_size: f32,
266) -> f32 {
267    if items_count == 0 {
268        return 0.0;
269    }
270
271    // Use measured items' average if available, otherwise use state's accumulated average
272    let avg_size = if !measured_items.is_empty() {
273        let total_measured_size: f32 = measured_items.iter().map(|i| i.main_axis_size).sum();
274        total_measured_size / measured_items.len() as f32
275    } else {
276        state_average_size
277    };
278
279    config.before_content_padding + (avg_size + config.spacing) * items_count as f32
280        - config.spacing
281        + config.after_content_padding
282}
283
284#[cfg(test)]
285mod tests {
286    use super::super::lazy_list_state::test_helpers::{
287        new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime,
288    };
289    use super::*;
290
291    fn create_test_item(index: usize, size: f32) -> LazyListMeasuredItem {
292        LazyListMeasuredItem::new(index, index as u64, None, size, 100.0)
293    }
294
295    #[test]
296    fn test_measure_empty_list() {
297        with_test_runtime(|| {
298            let state = new_lazy_list_state();
299            let config = LazyListMeasureConfig::default();
300
301            let result = measure_lazy_list(0, &state, 500.0, 300.0, &config, |_| {
302                panic!("Should not measure any items");
303            });
304
305            assert!(result.visible_items.is_empty());
306        });
307    }
308
309    #[test]
310    fn test_measure_single_item() {
311        with_test_runtime(|| {
312            let state = new_lazy_list_state();
313            let config = LazyListMeasureConfig::default();
314
315            let result = measure_lazy_list(1, &state, 500.0, 300.0, &config, |i| {
316                create_test_item(i, 50.0)
317            });
318
319            assert_eq!(result.visible_items.len(), 1);
320            assert_eq!(result.visible_items[0].index, 0);
321            assert!(!result.can_scroll_forward);
322            assert!(!result.can_scroll_backward);
323        });
324    }
325
326    #[test]
327    fn test_measure_fills_viewport() {
328        with_test_runtime(|| {
329            let state = new_lazy_list_state();
330            let config = LazyListMeasureConfig::default();
331
332            // 10 items of 50px each, viewport of 200px should show 4+ items
333            let result = measure_lazy_list(10, &state, 200.0, 300.0, &config, |i| {
334                create_test_item(i, 50.0)
335            });
336
337            // Should have visible items plus beyond-bounds buffer
338            assert!(result.visible_items.len() >= 4);
339            assert!(result.can_scroll_forward);
340            assert!(!result.can_scroll_backward);
341        });
342    }
343
344    #[test]
345    fn test_measure_with_scroll_offset() {
346        with_test_runtime(|| {
347            let state = new_lazy_list_state_with_position(3, 25.0);
348            let config = LazyListMeasureConfig::default();
349
350            let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
351                create_test_item(i, 50.0)
352            });
353
354            assert_eq!(result.first_visible_item_index, 3);
355            assert!(result.can_scroll_forward);
356            assert!(result.can_scroll_backward);
357        });
358    }
359
360    #[test]
361    fn test_backward_scroll_uses_measured_size() {
362        with_test_runtime(|| {
363            let state = new_lazy_list_state_with_position(1, 0.0);
364            state.dispatch_scroll_delta(1.0);
365            let config = LazyListMeasureConfig::default();
366
367            let result = measure_lazy_list(2, &state, 100.0, 300.0, &config, |i| {
368                if i == 0 {
369                    create_test_item(i, 10.0)
370                } else {
371                    create_test_item(i, 100.0)
372                }
373            });
374
375            assert_eq!(result.first_visible_item_index, 0);
376            assert!((result.first_visible_item_scroll_offset - 9.0).abs() < 0.001);
377        });
378    }
379
380    #[test]
381    fn test_backward_scroll_with_spacing_preserves_offset_gap() {
382        with_test_runtime(|| {
383            let state = new_lazy_list_state_with_position(1, 0.0);
384            let config = LazyListMeasureConfig {
385                spacing: 4.0,
386                ..Default::default()
387            };
388            state.dispatch_scroll_delta(2.0);
389
390            let result = measure_lazy_list(2, &state, 40.0, 300.0, &config, |i| {
391                create_test_item(i, 50.0)
392            });
393
394            assert_eq!(result.first_visible_item_index, 0);
395            assert!((result.first_visible_item_scroll_offset - 52.0).abs() < 0.001);
396        });
397    }
398
399    #[test]
400    fn test_scroll_to_item() {
401        with_test_runtime(|| {
402            let state = new_lazy_list_state();
403            state.scroll_to_item(5, 0.0);
404
405            let config = LazyListMeasureConfig::default();
406            let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
407                create_test_item(i, 50.0)
408            });
409
410            assert_eq!(result.first_visible_item_index, 5);
411        });
412    }
413}