1use 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
19pub 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#[derive(Clone, Debug, PartialEq)]
31pub struct LazyListMeasureConfig {
32 pub is_vertical: bool,
34 pub is_rtl: bool,
36
37 pub reverse_layout: bool,
42
43 pub before_content_padding: f32,
45
46 pub after_content_padding: f32,
48
49 pub spacing: f32,
51
52 pub beyond_bounds_item_count: usize,
55
56 pub vertical_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
59
60 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
81pub enum BeyondItem {
83 Kept,
85 Placed(LazyListMeasuredItem),
88 Declined,
90}
91
92pub trait LazyItemSource {
94 fn measure(&mut self, index: usize) -> LazyListMeasuredItem;
96
97 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
119pub 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
160pub 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
462const MAX_UNBOUNDED_REALIZED_ITEMS: usize = 10_000;
465
466fn 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
537fn 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 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;