use super::bounds_adjuster::BoundsAdjuster;
use super::item_measurer::ItemMeasurer;
use super::lazy_list_measured_item::{LazyListMeasureResult, LazyListMeasuredItem};
use super::lazy_list_state::{LazyListLayoutInfo, LazyListState};
use super::scroll_position_resolver::ScrollPositionResolver;
use super::viewport::ViewportHandler;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
pub const DEFAULT_ITEM_SIZE_ESTIMATE: f32 = 48.0;
static LAZY_MEASURE_TELEMETRY_ENABLED: OnceLock<bool> = OnceLock::new();
static LAZY_MEASURE_CYCLE_COUNTER: AtomicU64 = AtomicU64::new(1);
fn lazy_measure_telemetry_enabled() -> bool {
*LAZY_MEASURE_TELEMETRY_ENABLED
.get_or_init(|| std::env::var_os("CRANPOSE_LAZY_MEASURE_TELEMETRY").is_some())
}
#[derive(Clone, Debug)]
pub struct LazyListMeasureConfig {
pub is_vertical: bool,
pub reverse_layout: bool,
pub before_content_padding: f32,
pub after_content_padding: f32,
pub spacing: f32,
pub beyond_bounds_item_count: usize,
pub vertical_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
pub horizontal_arrangement: Option<cranpose_ui_layout::LinearArrangement>,
}
impl Default for LazyListMeasureConfig {
fn default() -> Self {
Self {
is_vertical: true,
reverse_layout: false,
before_content_padding: 0.0,
after_content_padding: 0.0,
spacing: 0.0,
beyond_bounds_item_count: 2,
vertical_arrangement: None,
horizontal_arrangement: None,
}
}
}
pub fn measure_lazy_list<F>(
items_count: usize,
state: &LazyListState,
viewport_size: f32,
_cross_axis_size: f32,
config: &LazyListMeasureConfig,
mut measure_item: F,
) -> LazyListMeasureResult
where
F: FnMut(usize) -> LazyListMeasuredItem,
{
let raw_viewport_size = viewport_size;
let is_infinite_viewport = raw_viewport_size.is_infinite();
if items_count == 0 {
state.update_scroll_position(0, 0.0);
state.update_layout_info(LazyListLayoutInfo {
visible_items_info: Vec::new(),
total_items_count: 0,
raw_viewport_size,
is_infinite_viewport,
viewport_size,
viewport_start_offset: config.before_content_padding,
viewport_end_offset: config.after_content_padding,
before_content_padding: config.before_content_padding,
after_content_padding: config.after_content_padding,
});
state.update_scroll_bounds();
return LazyListMeasureResult::default();
}
if viewport_size <= 0.0 {
state.update_layout_info(LazyListLayoutInfo {
visible_items_info: Vec::new(),
total_items_count: items_count,
raw_viewport_size,
is_infinite_viewport,
viewport_size,
viewport_start_offset: config.before_content_padding,
viewport_end_offset: config.after_content_padding,
before_content_padding: config.before_content_padding,
after_content_padding: config.after_content_padding,
});
state.update_scroll_bounds();
return LazyListMeasureResult::default();
}
let measure_state = state.begin_measure_pass();
let viewport = ViewportHandler::new(
viewport_size,
measure_state.average_item_size,
config.spacing,
);
let effective_viewport_size = viewport.effective_size();
let is_infinite_viewport = viewport.is_infinite();
let pending_scroll_delta = measure_state.pending_scroll_delta;
let resolver = ScrollPositionResolver::new(
state,
measure_state,
config,
items_count,
effective_viewport_size,
);
let (mut first_index, mut first_offset) = resolver.apply_pending_scroll_delta();
let mut pre_measured = Vec::new();
if first_offset < 0.0 && first_index > 0 {
(first_index, first_offset) = resolver.normalize_backward_jump(first_index, first_offset);
while first_offset < 0.0 && first_index > 0 {
first_index -= 1;
let item = measure_item(first_index);
first_offset += item.main_axis_size + config.spacing;
pre_measured.push(item);
}
pre_measured.reverse();
}
first_index = first_index.min(items_count.saturating_sub(1));
first_offset = first_offset.max(0.0);
(first_index, first_offset) = resolver.normalize_forward_with_cache(first_index, first_offset);
let item_extent_at = |index: usize, item_size: f32| {
let spacing_after = if index + 1 < items_count {
config.spacing
} else {
0.0
};
item_size + spacing_after
};
let mut offset_known_within_current_item = state
.get_cached_size(first_index)
.map(|size| first_offset + 0.001 < item_extent_at(first_index, size))
.unwrap_or(false);
if !offset_known_within_current_item && first_offset > 0.0 && first_index < items_count {
let item = measure_item(first_index);
let item_extent = item_extent_at(first_index, item.main_axis_size);
if first_offset + 0.001 < item_extent {
pre_measured.push(item);
offset_known_within_current_item = true;
}
}
if !offset_known_within_current_item {
(first_index, first_offset) = resolver.normalize_forward(first_index, first_offset);
}
let pre_measured_queue = VecDeque::from(pre_measured);
let mut measurer = ItemMeasurer::new(
&mut measure_item,
config,
items_count,
effective_viewport_size,
measure_state.average_item_size,
pre_measured_queue,
);
let measurement_pass = measurer.measure_all(first_index, first_offset);
let measurement_start_index = measurement_pass.start_index;
let measurement_start_offset = measurement_pass.start_offset;
let measurement_next_index = measurement_pass.next_index;
let measurement_next_offset = measurement_pass.next_offset;
let measurement_measured_visible_items = measurement_pass.measured_visible_items;
let measurement_hit_time_budget = measurement_pass.hit_time_budget;
let measurement_viewport_filled = measurement_pass.viewport_filled;
let mut visible_items = measurement_pass.items;
let adjuster = BoundsAdjuster::new(config, items_count, effective_viewport_size);
adjuster.clamp(&mut visible_items);
let total_content_size = estimate_total_content_size(
items_count,
&visible_items,
config,
measure_state.average_item_size,
);
let viewport_end = effective_viewport_size - config.after_content_padding;
let item_end_with_spacing = |item: &LazyListMeasuredItem| {
let spacing_after = if item.index + 1 < items_count {
config.spacing
} else {
0.0
};
item.offset + item.main_axis_size + spacing_after
};
let actual_first_visible = visible_items
.iter()
.find(|item| item_end_with_spacing(item) > config.before_content_padding);
let unresolved_pass = measurement_hit_time_budget
&& !measurement_viewport_filled
&& actual_first_visible.is_none();
let (final_first_index, final_scroll_offset) = if let Some(first) = actual_first_visible {
let offset = config.before_content_padding - first.offset;
(first.index, offset.max(0.0))
} else if unresolved_pass {
if pending_scroll_delta > 0.001 {
let preserved_offset =
(config.before_content_padding - measurement_start_offset).max(0.0);
(measurement_start_index, preserved_offset)
} else {
let next_index = measurement_next_index.min(items_count.saturating_sub(1));
if next_index + 1 >= items_count {
(next_index, 0.0)
} else {
let next_offset =
(config.before_content_padding - measurement_next_offset).max(0.0);
(next_index, next_offset)
}
}
} else if !visible_items.is_empty() {
(visible_items[0].index, 0.0)
} else {
(0, 0.0)
};
if let Some(first) = actual_first_visible {
state.update_scroll_position_with_key(final_first_index, final_scroll_offset, first.key);
} else if !visible_items.is_empty() && !unresolved_pass {
state.update_scroll_position_with_key(
final_first_index,
final_scroll_offset,
visible_items[0].key,
);
} else {
state.update_scroll_position(final_first_index, final_scroll_offset);
}
if lazy_measure_telemetry_enabled() {
let cycle_id = LAZY_MEASURE_CYCLE_COUNTER.fetch_add(1, Ordering::Relaxed);
log::warn!(
"[lazy-measure-telemetry] cycle={} 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={}",
cycle_id,
first_index,
first_offset,
measurement_start_index,
config.before_content_padding - measurement_start_offset,
final_first_index,
final_scroll_offset,
measurement_measured_visible_items,
visible_items.len(),
unresolved_pass,
actual_first_visible.is_some(),
measurement_hit_time_budget,
measurement_viewport_filled
);
}
state.update_layout_info(LazyListLayoutInfo {
visible_items_info: visible_items
.iter()
.filter(|item| {
let item_end = item_end_with_spacing(item);
item_end > config.before_content_padding && item.offset < viewport_end
})
.map(|i| i.to_item_info())
.collect(),
total_items_count: items_count,
raw_viewport_size,
is_infinite_viewport,
viewport_size: effective_viewport_size,
viewport_start_offset: config.before_content_padding,
viewport_end_offset: config.after_content_padding,
before_content_padding: config.before_content_padding,
after_content_padding: config.after_content_padding,
});
state.update_scroll_bounds();
let can_scroll_backward = final_first_index > 0 || final_scroll_offset > 0.0;
let can_scroll_forward = if let Some(last) = visible_items.last() {
last.index < items_count - 1 || (last.offset + last.main_axis_size) > viewport_end
} else {
false
};
LazyListMeasureResult {
visible_items,
first_visible_item_index: final_first_index,
first_visible_item_scroll_offset: final_scroll_offset,
viewport_size: effective_viewport_size,
total_content_size,
can_scroll_forward,
can_scroll_backward,
}
}
fn estimate_total_content_size(
items_count: usize,
measured_items: &[LazyListMeasuredItem],
config: &LazyListMeasureConfig,
state_average_size: f32,
) -> f32 {
if items_count == 0 {
return 0.0;
}
let avg_size = if !measured_items.is_empty() {
let total_measured_size: f32 = measured_items.iter().map(|i| i.main_axis_size).sum();
total_measured_size / measured_items.len() as f32
} else {
state_average_size
};
config.before_content_padding + (avg_size + config.spacing) * items_count as f32
- config.spacing
+ config.after_content_padding
}
#[cfg(test)]
mod tests {
use super::super::lazy_list_state::test_helpers::{
new_lazy_list_state, new_lazy_list_state_with_position, with_test_runtime,
};
use super::*;
fn create_test_item(index: usize, size: f32) -> LazyListMeasuredItem {
LazyListMeasuredItem::new(index, index as u64, None, size, 100.0)
}
#[test]
fn lazy_list_measure_config_defaults_to_two_beyond_bounds_items() {
let config = LazyListMeasureConfig::default();
assert_eq!(config.beyond_bounds_item_count, 2);
}
fn exact_scroll_position(
item_sizes: &[f32],
spacing: f32,
viewport_size: f32,
deltas: &[f32],
) -> Vec<(usize, f32)> {
let total_content = item_sizes
.iter()
.enumerate()
.map(|(index, size)| {
let spacing_after = if index + 1 < item_sizes.len() {
spacing
} else {
0.0
};
size + spacing_after
})
.sum::<f32>();
let max_scroll = (total_content - viewport_size).max(0.0);
let mut scroll = 0.0f32;
let mut positions = Vec::with_capacity(deltas.len());
for delta in deltas {
scroll = (scroll - delta).clamp(0.0, max_scroll);
let mut remaining = scroll;
let mut index = 0usize;
while index + 1 < item_sizes.len() {
let spacing_after = if index + 1 < item_sizes.len() {
spacing
} else {
0.0
};
let extent = item_sizes[index] + spacing_after;
if remaining < extent {
break;
}
remaining -= extent;
index += 1;
}
positions.push((index, remaining));
}
positions
}
#[test]
fn test_measure_empty_list() {
with_test_runtime(|| {
let state = new_lazy_list_state();
let config = LazyListMeasureConfig::default();
let result = measure_lazy_list(0, &state, 500.0, 300.0, &config, |_| {
panic!("Should not measure any items");
});
assert!(result.visible_items.is_empty());
});
}
#[test]
fn test_measure_single_item() {
with_test_runtime(|| {
let state = new_lazy_list_state();
let config = LazyListMeasureConfig::default();
let result = measure_lazy_list(1, &state, 500.0, 300.0, &config, |i| {
create_test_item(i, 50.0)
});
assert_eq!(result.visible_items.len(), 1);
assert_eq!(result.visible_items[0].index, 0);
assert!(!result.can_scroll_forward);
assert!(!result.can_scroll_backward);
});
}
#[test]
fn test_measure_fills_viewport() {
with_test_runtime(|| {
let state = new_lazy_list_state();
let config = LazyListMeasureConfig::default();
let result = measure_lazy_list(10, &state, 200.0, 300.0, &config, |i| {
create_test_item(i, 50.0)
});
assert!(result.visible_items.len() >= 4);
assert!(result.can_scroll_forward);
assert!(!result.can_scroll_backward);
});
}
#[test]
fn test_measure_with_scroll_offset() {
with_test_runtime(|| {
let state = new_lazy_list_state_with_position(3, 25.0);
let config = LazyListMeasureConfig::default();
let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
create_test_item(i, 50.0)
});
assert_eq!(result.first_visible_item_index, 3);
assert!(result.can_scroll_forward);
assert!(result.can_scroll_backward);
});
}
#[test]
fn test_backward_scroll_uses_measured_size() {
with_test_runtime(|| {
let state = new_lazy_list_state_with_position(1, 0.0);
state.dispatch_scroll_delta(1.0);
let config = LazyListMeasureConfig::default();
let result = measure_lazy_list(2, &state, 100.0, 300.0, &config, |i| {
if i == 0 {
create_test_item(i, 10.0)
} else {
create_test_item(i, 100.0)
}
});
assert_eq!(result.first_visible_item_index, 0);
assert!((result.first_visible_item_scroll_offset - 9.0).abs() < 0.001);
});
}
#[test]
fn test_backward_scroll_with_spacing_preserves_offset_gap() {
with_test_runtime(|| {
let state = new_lazy_list_state_with_position(1, 0.0);
let config = LazyListMeasureConfig {
spacing: 4.0,
..Default::default()
};
state.dispatch_scroll_delta(2.0);
let result = measure_lazy_list(2, &state, 40.0, 300.0, &config, |i| {
create_test_item(i, 50.0)
});
assert_eq!(result.first_visible_item_index, 0);
assert!((result.first_visible_item_scroll_offset - 52.0).abs() < 0.001);
});
}
#[test]
fn test_scroll_to_item() {
with_test_runtime(|| {
let state = new_lazy_list_state();
state.scroll_to_item(5, 0.0);
let config = LazyListMeasureConfig::default();
let result = measure_lazy_list(20, &state, 200.0, 300.0, &config, |i| {
create_test_item(i, 50.0)
});
assert_eq!(result.first_visible_item_index, 5);
});
}
#[test]
fn test_time_budget_progresses_when_visible_item_not_reached() {
with_test_runtime(|| {
let state = new_lazy_list_state_with_position(100, 5_000.0);
let config = LazyListMeasureConfig::default();
let result = measure_lazy_list(10_000, &state, 100.0, 300.0, &config, |i| {
std::thread::sleep(std::time::Duration::from_millis(55));
create_test_item(i, 10.0)
});
assert_eq!(
result.first_visible_item_index, 203,
"time-budgeted pass should advance to the next unresolved index"
);
assert!(
(result.first_visible_item_scroll_offset - 94.0).abs() < 1.0,
"expected unresolved offset progress to be preserved"
);
});
}
#[test]
fn test_time_budgeted_reverse_scroll_does_not_backtrack() {
with_test_runtime(|| {
let state = new_lazy_list_state();
let config = LazyListMeasureConfig {
spacing: 8.0,
..Default::default()
};
let item_sizes: Vec<f32> = (0..512usize)
.map(|index| match index % 7 {
0 => 44.0,
1 => 60.0,
2 => 220.0,
3 => 72.0,
4 => 96.0,
5 => 156.0,
_ => 52.0,
})
.collect();
let mut result =
measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |index| {
std::thread::sleep(std::time::Duration::from_millis(55));
create_test_item(index, item_sizes[index])
});
assert_eq!(result.first_visible_item_index, 0);
for _ in 0..4 {
state.dispatch_scroll_delta(-320.0);
result =
measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |index| {
std::thread::sleep(std::time::Duration::from_millis(55));
create_test_item(index, item_sizes[index])
});
}
assert!(
result.first_visible_item_index > 0,
"expected to advance after forward time-budgeted scrolls"
);
let mut last_index = result.first_visible_item_index;
for step in 0..4 {
state.dispatch_scroll_delta(80.0);
result =
measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |index| {
std::thread::sleep(std::time::Duration::from_millis(55));
create_test_item(index, item_sizes[index])
});
assert!(
result.first_visible_item_index <= last_index,
"reverse time-budgeted step {step} backtracked from index {last_index} to {}",
result.first_visible_item_index
);
last_index = result.first_visible_item_index;
}
});
}
#[test]
fn test_backward_scroll_does_not_advance_first_visible_index_for_variable_items() {
with_test_runtime(|| {
let state = new_lazy_list_state();
let config = LazyListMeasureConfig {
spacing: 8.0,
..Default::default()
};
let item_sizes = [48.0, 56.0, 64.0, 72.0, 80.0];
let measure_item =
|index: usize| create_test_item(index, item_sizes[index % item_sizes.len()]);
let mut result = measure_lazy_list(200, &state, 260.0, 300.0, &config, measure_item);
assert_eq!(result.first_visible_item_index, 0);
for _ in 0..28 {
state.dispatch_scroll_delta(-32.0);
result = measure_lazy_list(200, &state, 260.0, 300.0, &config, measure_item);
}
assert!(
result.first_visible_item_index >= 12,
"expected to scroll well into the list before reversing, got index={}",
result.first_visible_item_index
);
let mut last_index = result.first_visible_item_index;
for step in 0..24 {
state.dispatch_scroll_delta(12.0);
result = measure_lazy_list(200, &state, 260.0, 300.0, &config, measure_item);
assert!(
result.first_visible_item_index <= last_index,
"backward step {step} advanced from index {last_index} to {}",
result.first_visible_item_index
);
last_index = result.first_visible_item_index;
}
});
}
#[test]
fn test_stored_offset_inside_tall_item_does_not_skip_forward_without_pending_scroll() {
with_test_runtime(|| {
let state = new_lazy_list_state_with_position(0, 900.0);
let config = LazyListMeasureConfig {
spacing: 8.0,
..Default::default()
};
let item_sizes: Vec<f32> = (0..32usize)
.map(|index| if index == 0 { 1_200.0 } else { 64.0 })
.collect();
let result = measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |i| {
create_test_item(i, item_sizes[i])
});
assert_eq!(
result.first_visible_item_index, 0,
"stored in-item offset must not be turned into an average-size forward jump"
);
assert!(
(result.first_visible_item_scroll_offset - 900.0).abs() < 0.01,
"expected to preserve the stored in-item scroll offset"
);
});
}
#[test]
fn test_large_offset_inside_cached_tall_item_does_not_skip_forward_without_forward_scroll() {
with_test_runtime(|| {
let state = new_lazy_list_state_with_position(20, 900.0);
let config = LazyListMeasureConfig {
spacing: 8.0,
..Default::default()
};
for index in 0..20 {
state.cache_item_size(index, 60.0 + (index % 3) as f32 * 8.0);
}
state.cache_item_size(20, 1_200.0);
let item_sizes: Vec<f32> = (0..64usize)
.map(|index| {
if index == 20 {
1_200.0
} else {
60.0 + (index % 3) as f32 * 8.0
}
})
.collect();
let result = measure_lazy_list(item_sizes.len(), &state, 260.0, 320.0, &config, |i| {
create_test_item(i, item_sizes[i])
});
assert_eq!(
result.first_visible_item_index, 20,
"offset within a tall cached item must not be interpreted as skipping to later average-sized items"
);
assert!(
(result.first_visible_item_scroll_offset - 900.0).abs() < 0.01,
"expected to preserve in-item offset inside the tall cached item"
);
});
}
#[test]
fn test_matches_exact_model_for_variable_item_reverse_scrolls() {
with_test_runtime(|| {
let state = new_lazy_list_state();
let config = LazyListMeasureConfig {
spacing: 8.0,
..Default::default()
};
let viewport_size = 260.0;
let item_sizes: Vec<f32> = (0..240usize)
.map(|index| match index % 9 {
0 => 32.0,
1 => 48.0,
2 => 240.0,
3 => 56.0,
4 => 72.0,
5 => 180.0,
6 => 40.0,
7 => 96.0,
_ => 56.0,
})
.collect();
let deltas = [
-180.0, -180.0, -220.0, -150.0, -240.0, -120.0, -160.0, 60.0, 60.0, 80.0, -96.0,
-96.0, 44.0, 44.0, 44.0, -140.0, -140.0, 72.0, 72.0, 72.0, 72.0,
];
let expected =
exact_scroll_position(&item_sizes, config.spacing, viewport_size, &deltas);
for (step, (delta, (expected_index, expected_offset))) in
deltas.iter().zip(expected.iter()).enumerate()
{
state.dispatch_scroll_delta(*delta);
let result = measure_lazy_list(
item_sizes.len(),
&state,
viewport_size,
320.0,
&config,
|index| create_test_item(index, item_sizes[index]),
);
assert_eq!(
result.first_visible_item_index, *expected_index,
"step {step} delta={delta} expected first index {} but got {}",
expected_index, result.first_visible_item_index
);
assert!(
(result.first_visible_item_scroll_offset - *expected_offset).abs() < 0.01,
"step {step} delta={delta} expected offset {:.2} but got {:.2}",
expected_offset,
result.first_visible_item_scroll_offset
);
}
});
}
}