use std::{collections::VecDeque, convert::Infallible};
use cranpose_ui_layout::Axis;
use super::{
bounds_adjuster::BoundsAdjuster,
diagnostics,
item_measurer::{ItemMeasurePass, ItemMeasurer},
lazy_list_measured_item::{LazyListMeasureResult, LazyListMeasuredItem},
lazy_list_state::{LazyListLayoutInfo, LazyListMeasureStateSnapshot, LazyListState},
scroll_position_resolver::ScrollPositionResolver,
viewport::ViewportHandler,
};
pub const DEFAULT_ITEM_SIZE_ESTIMATE: f32 = 48.0;
const MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS: usize = 8;
const MIN_ADAPTIVE_SCROLL_DELTA_ITEMS: f32 = 1.5;
const MIN_ACTIVE_SCROLL_WHEEL_BEYOND_BOUNDS_ITEMS: usize = 2;
const MIN_ACTIVE_SCROLL_FAST_BEYOND_BOUNDS_ITEMS: usize = MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS;
const MIN_IDLE_WARM_BEYOND_BOUNDS_ITEMS: usize = 4;
#[derive(Clone, Debug, PartialEq)]
pub struct LazyListMeasureConfig {
pub axis: Axis,
pub is_rtl: 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 {
axis: Axis::Vertical,
is_rtl: false,
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 enum BeyondItem {
Kept,
Placed(LazyListMeasuredItem),
Declined,
}
pub trait LazyItemSource {
type Refusal;
fn measure(&mut self, index: usize) -> Result<LazyListMeasuredItem, Self::Refusal>;
fn keep_beyond(&mut self, index: usize, distance: f32) -> Result<BeyondItem, Self::Refusal>;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NeedsComposition;
struct MeasureEveryItem<F>(F);
impl<F> LazyItemSource for MeasureEveryItem<F>
where
F: FnMut(usize) -> LazyListMeasuredItem,
{
type Refusal = Infallible;
fn measure(&mut self, index: usize) -> Result<LazyListMeasuredItem, Infallible> {
Ok((self.0)(index))
}
fn keep_beyond(&mut self, index: usize, _distance: f32) -> Result<BeyondItem, Infallible> {
(self.0)(index);
Ok(BeyondItem::Kept)
}
}
pub fn measure_lazy_list<F>(
items_count: usize,
state: &LazyListState,
viewport_size: f32,
_cross_axis_size: f32,
config: &LazyListMeasureConfig,
measure_item: F,
) -> LazyListMeasureResult
where
F: FnMut(usize) -> LazyListMeasuredItem,
{
measure_lazy_list_with_beyond_bounds_policy(
items_count,
state,
viewport_size,
_cross_axis_size,
config,
&mut MeasureEveryItem(measure_item),
)
}
pub fn measure_lazy_list_with_beyond_bounds_policy<S>(
items_count: usize,
state: &LazyListState,
viewport_size: f32,
_cross_axis_size: f32,
config: &LazyListMeasureConfig,
source: &mut S,
) -> LazyListMeasureResult
where
S: LazyItemSource<Refusal = Infallible>,
{
if items_count == 0 || viewport_size <= 0.0 {
return publish_without_items(state, items_count, viewport_size, config);
}
let measure_state = state.begin_measure_pass();
let viewport = ViewportHandler::new(
viewport_size,
measure_state.average_item_size,
config.spacing,
);
if viewport.is_infinite() {
let Ok(result) =
measure_unbounded_lazy_list(items_count, state, viewport_size, config, source);
return result;
}
let Ok(list) = measure_items(
items_count,
state,
(viewport_size, viewport.effective_size()),
config,
measure_state,
source,
);
list.commit(state)
}
pub fn measure_lazy_list_from_retained<S>(
items_count: usize,
state: &LazyListState,
viewport_size: f32,
config: &LazyListMeasureConfig,
source: &mut S,
) -> Result<MeasuredLazyList, NeedsComposition>
where
S: LazyItemSource<Refusal = NeedsComposition>,
{
if items_count == 0 || viewport_size <= 0.0 || ViewportHandler::is_unbounded(viewport_size) {
return Err(NeedsComposition);
}
let measure_state = state.measure_pass_snapshot();
let viewport = ViewportHandler::new(
viewport_size,
measure_state.average_item_size,
config.spacing,
);
measure_items(
items_count,
state,
(viewport_size, viewport.effective_size()),
config,
measure_state,
source,
)
}
#[must_use]
pub struct MeasuredLazyList {
result: LazyListMeasureResult,
first_key: Option<u64>,
items_count: usize,
raw_viewport_size: f32,
padding: (f32, f32),
spacing: f32,
reverse_layout: bool,
telemetry: Option<String>,
}
impl MeasuredLazyList {
pub fn publish(self, state: &LazyListState) -> LazyListMeasureResult {
state.consume_measure_pass();
self.commit(state)
}
fn commit(self, state: &LazyListState) -> LazyListMeasureResult {
let result = self.result;
let (first_index, first_offset) = (
result.first_visible_item_index,
result.first_visible_item_scroll_offset,
);
match self.first_key {
Some(key) => state.update_scroll_position_with_key(first_index, first_offset, key),
None => state.update_scroll_position(first_index, first_offset),
}
if let Some(line) = self.telemetry {
let cycle_id = state.next_measure_cycle_id();
log::warn!("[lazy-measure-telemetry] cycle={cycle_id} {line}");
}
let viewport_end = result.viewport_size;
let mut visible_items_info = state.spare_items_info();
visible_items_info.extend(
result
.visible_items
.iter()
.filter(|item| {
let item_end = item_end_with_spacing(item, self.items_count, self.spacing);
item_end > 0.0 && item.offset < viewport_end
})
.map(|item| item.to_item_info(self.padding.0)),
);
state.update_layout_info(LazyListLayoutInfo {
visible_items_info,
total_items_count: self.items_count,
raw_viewport_size: self.raw_viewport_size,
is_infinite_viewport: false,
viewport_size: result.viewport_size,
viewport_start_offset: -self.padding.0,
viewport_end_offset: viewport_end - self.padding.0,
before_content_padding: self.padding.0,
after_content_padding: self.padding.1,
main_axis_item_spacing: self.spacing,
snap_anchor_offset: 0.0,
reverse_layout: self.reverse_layout,
});
state.update_scroll_bounds();
result
}
}
fn item_end_with_spacing(item: &LazyListMeasuredItem, items_count: usize, spacing: f32) -> f32 {
let spacing_after = if item.index + 1 < items_count {
spacing
} else {
0.0
};
item.offset + item.main_axis_size + spacing_after
}
fn measure_items<S>(
items_count: usize,
state: &LazyListState,
viewport_sizes: (f32, f32),
config: &LazyListMeasureConfig,
measure_state: LazyListMeasureStateSnapshot,
source: &mut S,
) -> Result<MeasuredLazyList, S::Refusal>
where
S: LazyItemSource,
{
let (raw_viewport_size, effective_viewport_size) = viewport_sizes;
let pending_scroll_delta = measure_state.pending_scroll_delta;
let window_scroll_delta = measure_state.window_scroll_delta;
let resolver = ScrollPositionResolver::new(
state,
measure_state,
config,
items_count,
effective_viewport_size,
);
let (first_index, first_offset, pre_measured) =
resolve_start(&resolver, state, config, items_count, source)?;
let telemetry_enabled = diagnostics::telemetry_enabled();
let adaptive_beyond_bounds = adaptive_scroll_beyond_bounds_item_count(
config,
window_scroll_delta,
measure_state.average_item_size,
);
let guaranteed_beyond_bounds = adaptive_beyond_bounds;
let mut measurer = ItemMeasurer::new(
source,
config,
items_count,
effective_viewport_size,
measure_state.average_item_size,
pre_measured,
)
.with_beyond_bounds_item_count(adaptive_beyond_bounds)
.with_guaranteed_after_beyond_bounds_item_count(guaranteed_beyond_bounds)
.with_include_before_beyond_bounds(window_scroll_delta >= -0.001)
.with_telemetry_pass_id(telemetry_enabled.then(|| state.next_item_measure_pass_id()));
let mut measurement_pass = measurer.measure_all(first_index, first_offset)?;
let mut visible_items = std::mem::take(&mut 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 position = final_position(
&visible_items,
&measurement_pass,
config,
items_count,
pending_scroll_delta,
);
let telemetry = telemetry_enabled.then(|| {
format!(
"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={}",
items_count,
measure_state.average_item_size,
effective_viewport_size,
total_content_size,
first_index,
first_offset,
measurement_pass.start_index,
config.before_content_padding - measurement_pass.start_offset,
position.index,
position.offset,
measurement_pass.measured_visible_items,
visible_items.len(),
position.unresolved,
position.found,
measurement_pass.hit_time_budget,
measurement_pass.viewport_filled
)
});
Ok(position.into_list(
(visible_items, items_count),
(raw_viewport_size, effective_viewport_size),
total_content_size,
config,
telemetry,
))
}
fn resolve_start<S>(
resolver: &ScrollPositionResolver<'_>,
state: &LazyListState,
config: &LazyListMeasureConfig,
items_count: usize,
source: &mut S,
) -> Result<(usize, f32, VecDeque<LazyListMeasuredItem>), S::Refusal>
where
S: LazyItemSource,
{
let (mut first_index, mut first_offset) = resolver.apply_pending_scroll_delta();
let mut pre_measured = VecDeque::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 = source.measure(first_index)?;
first_offset += item.main_axis_size + config.spacing;
pre_measured.push_front(item);
}
}
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)
.is_some_and(|size| first_offset + 0.001 < item_extent_at(first_index, size));
if !offset_known_within_current_item && first_offset > 0.0 && first_index < items_count {
let item = source.measure(first_index)?;
let item_extent = item_extent_at(first_index, item.main_axis_size);
if first_offset + 0.001 < item_extent {
pre_measured.push_back(item);
offset_known_within_current_item = true;
}
}
if !offset_known_within_current_item {
(first_index, first_offset) = resolver.normalize_forward(first_index, first_offset);
}
while first_offset < config.before_content_padding && first_index > 0 {
first_index -= 1;
let item = source.measure(first_index)?;
first_offset += item.main_axis_size + config.spacing;
pre_measured.push_front(item);
}
Ok((first_index, first_offset, pre_measured))
}
struct FinalPosition {
index: usize,
offset: f32,
key: Option<u64>,
found: bool,
unresolved: bool,
}
impl FinalPosition {
fn into_list(
self,
(visible_items, items_count): (Vec<LazyListMeasuredItem>, usize),
(raw_viewport_size, viewport_size): (f32, f32),
total_content_size: f32,
config: &LazyListMeasureConfig,
telemetry: Option<String>,
) -> MeasuredLazyList {
let can_scroll_backward = self.index > 0 || self.offset > 0.0;
let can_scroll_forward = visible_items.last().is_some_and(|last| {
last.index < items_count - 1 || (last.offset + last.main_axis_size) > viewport_size
});
MeasuredLazyList {
result: LazyListMeasureResult {
visible_items,
first_visible_item_index: self.index,
first_visible_item_scroll_offset: self.offset,
viewport_size,
total_content_size,
can_scroll_forward,
can_scroll_backward,
},
first_key: self.key,
items_count,
raw_viewport_size,
padding: (config.before_content_padding, config.after_content_padding),
spacing: config.spacing,
reverse_layout: config.reverse_layout,
telemetry,
}
}
}
fn final_position(
visible_items: &[LazyListMeasuredItem],
pass: &ItemMeasurePass,
config: &LazyListMeasureConfig,
items_count: usize,
pending_scroll_delta: f32,
) -> FinalPosition {
let before = config.before_content_padding;
let actual_first_visible = visible_items
.iter()
.find(|item| item_end_with_spacing(item, items_count, config.spacing) > before);
let unresolved =
pass.hit_time_budget && !pass.viewport_filled && actual_first_visible.is_none();
let (index, offset) = if let Some(first) = actual_first_visible {
(first.index, (before - first.offset).max(0.0))
} else if unresolved {
if pending_scroll_delta > 0.001 {
(pass.start_index, (before - pass.start_offset).max(0.0))
} else {
let next_index = pass.next_index.min(items_count.saturating_sub(1));
if next_index + 1 >= items_count {
(next_index, 0.0)
} else {
(next_index, (before - pass.next_offset).max(0.0))
}
}
} else if let Some(last) = visible_items.last() {
(last.index, (before - last.offset).max(0.0))
} else {
(0, 0.0)
};
let key = match actual_first_visible {
Some(first) => Some(first.key),
None => visible_items
.first()
.filter(|_| !unresolved)
.map(|item| item.key),
};
FinalPosition {
index,
offset,
key,
found: actual_first_visible.is_some(),
unresolved,
}
}
pub(super) fn publish_without_items(
state: &LazyListState,
items_count: usize,
viewport_size: f32,
config: &LazyListMeasureConfig,
) -> LazyListMeasureResult {
if items_count == 0 {
state.update_scroll_position(0, 0.0);
}
state.update_layout_info(LazyListLayoutInfo {
visible_items_info: Vec::new(),
total_items_count: items_count,
raw_viewport_size: viewport_size,
is_infinite_viewport: viewport_size.is_infinite(),
viewport_size,
viewport_start_offset: -config.before_content_padding,
viewport_end_offset: viewport_size.max(0.0) - config.before_content_padding,
before_content_padding: config.before_content_padding,
after_content_padding: config.after_content_padding,
main_axis_item_spacing: config.spacing,
snap_anchor_offset: 0.0,
reverse_layout: config.reverse_layout,
});
state.update_scroll_bounds();
LazyListMeasureResult::default()
}
pub(super) const MAX_UNBOUNDED_REALIZED_ITEMS: usize = 10_000;
fn measure_unbounded_lazy_list<S>(
items_count: usize,
state: &LazyListState,
raw_viewport_size: f32,
config: &LazyListMeasureConfig,
source: &mut S,
) -> Result<LazyListMeasureResult, S::Refusal>
where
S: LazyItemSource,
{
let realized_count = items_count.min(MAX_UNBOUNDED_REALIZED_ITEMS);
if realized_count < items_count {
log::warn!(
"LazyList: unbounded viewport with {items_count} items; realizing only the first {realized_count}. \
Wrap the list in a constrained container to restore virtualization."
);
}
let mut visible_items = Vec::with_capacity(realized_count);
let mut offset = config.before_content_padding;
for index in 0..realized_count {
let mut item = source.measure(index)?;
item.offset = offset;
offset += item.main_axis_size;
if index + 1 < items_count {
offset += config.spacing;
}
visible_items.push(item);
}
let content_extent = offset + config.after_content_padding;
state.update_scroll_position(0, 0.0);
let mut visible_items_info = state.spare_items_info();
visible_items_info.extend(
visible_items
.iter()
.map(|item| item.to_item_info(config.before_content_padding)),
);
state.update_layout_info(LazyListLayoutInfo {
visible_items_info,
total_items_count: items_count,
raw_viewport_size,
is_infinite_viewport: true,
viewport_size: content_extent,
viewport_start_offset: -config.before_content_padding,
viewport_end_offset: content_extent - config.before_content_padding,
before_content_padding: config.before_content_padding,
after_content_padding: config.after_content_padding,
main_axis_item_spacing: config.spacing,
snap_anchor_offset: 0.0,
reverse_layout: config.reverse_layout,
});
state.update_scroll_bounds();
let can_scroll_forward = realized_count < items_count;
Ok(LazyListMeasureResult {
visible_items,
first_visible_item_index: 0,
first_visible_item_scroll_offset: 0.0,
viewport_size: content_extent,
total_content_size: content_extent,
can_scroll_forward,
can_scroll_backward: false,
})
}
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
}
fn adaptive_scroll_beyond_bounds_item_count(
config: &LazyListMeasureConfig,
pending_scroll_delta: f32,
average_item_size: f32,
) -> usize {
let base_count = config.beyond_bounds_item_count;
if pending_scroll_delta.abs() <= 0.001 {
return if base_count == 0 {
MIN_IDLE_WARM_BEYOND_BOUNDS_ITEMS
} else {
base_count
};
}
let item_extent = if average_item_size.is_finite() && average_item_size > 0.0 {
average_item_size
} else {
DEFAULT_ITEM_SIZE_ESTIMATE
} + config.spacing.max(0.0);
let item_extent = item_extent.max(1.0);
let delta_items = pending_scroll_delta.abs() / item_extent;
if delta_items < MIN_ADAPTIVE_SCROLL_DELTA_ITEMS {
return base_count.max(MIN_ACTIVE_SCROLL_WHEEL_BEYOND_BOUNDS_ITEMS)
+ delta_items.ceil() as usize;
}
let adaptive_count = delta_items.ceil() as usize;
base_count
.max(MIN_ACTIVE_SCROLL_FAST_BEYOND_BOUNDS_ITEMS)
.max(adaptive_count.min(MAX_ADAPTIVE_SCROLL_BEYOND_BOUNDS_ITEMS))
}
#[cfg(test)]
#[path = "tests/lazy_list_measure_tests.rs"]
mod tests;