#![allow(non_snake_case)]
#![allow(dead_code)]
use std::cell::RefCell;
use std::rc::Rc;
use crate::composable;
use crate::modifier::Modifier;
use crate::subcompose_layout::{
MeasurePolicy, Placement, SubcomposeLayoutNode, SubcomposeLayoutScope, SubcomposeMeasureScope,
SubcomposeMeasureScopeImpl,
};
use cranpose_core::{NodeId, SlotId};
use cranpose_foundation::lazy::{
measure_lazy_list, LazyListIntervalContent, LazyListMeasureConfig, LazyListMeasuredItem,
LazyListState, SmallNodeVec, SmallOffsetVec, DEFAULT_ITEM_SIZE_ESTIMATE,
};
use cranpose_ui_layout::{Constraints, LinearArrangement, MeasureResult};
use smallvec::SmallVec;
pub use cranpose_foundation::lazy::{LazyListItemInfo, LazyListLayoutInfo};
#[derive(Clone, Debug, PartialEq)]
pub struct LazyColumnSpec {
pub vertical_arrangement: LinearArrangement,
pub content_padding_top: f32,
pub content_padding_bottom: f32,
pub beyond_bounds_item_count: usize,
pub reverse_layout: bool,
}
impl Default for LazyColumnSpec {
fn default() -> Self {
Self {
vertical_arrangement: LinearArrangement::Start,
content_padding_top: 0.0,
content_padding_bottom: 0.0,
beyond_bounds_item_count: 2,
reverse_layout: false,
}
}
}
impl LazyColumnSpec {
pub fn new() -> Self {
Self::default()
}
pub fn vertical_arrangement(mut self, arrangement: LinearArrangement) -> Self {
self.vertical_arrangement = arrangement;
self
}
pub fn content_padding(mut self, top: f32, bottom: f32) -> Self {
self.content_padding_top = top;
self.content_padding_bottom = bottom;
self
}
pub fn content_padding_all(mut self, padding: f32) -> Self {
self.content_padding_top = padding;
self.content_padding_bottom = padding;
self
}
pub fn reverse_layout(mut self, reverse: bool) -> Self {
self.reverse_layout = reverse;
self
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LazyRowSpec {
pub horizontal_arrangement: LinearArrangement,
pub content_padding_start: f32,
pub content_padding_end: f32,
pub beyond_bounds_item_count: usize,
pub reverse_layout: bool,
}
impl Default for LazyRowSpec {
fn default() -> Self {
Self {
horizontal_arrangement: LinearArrangement::Start,
content_padding_start: 0.0,
content_padding_end: 0.0,
beyond_bounds_item_count: 2,
reverse_layout: false,
}
}
}
impl LazyRowSpec {
pub fn new() -> Self {
Self::default()
}
pub fn horizontal_arrangement(mut self, arrangement: LinearArrangement) -> Self {
self.horizontal_arrangement = arrangement;
self
}
pub fn content_padding(mut self, start: f32, end: f32) -> Self {
self.content_padding_start = start;
self.content_padding_end = end;
self
}
pub fn content_padding_all(mut self, padding: f32) -> Self {
self.content_padding_start = padding;
self.content_padding_end = padding;
self
}
pub fn reverse_layout(mut self, reverse: bool) -> Self {
self.reverse_layout = reverse;
self
}
}
fn measure_lazy_list_internal(
scope: &mut SubcomposeMeasureScopeImpl<'_>,
constraints: Constraints,
is_vertical: bool,
content: &LazyListIntervalContent,
state: &LazyListState,
config: &LazyListMeasureConfig,
) -> MeasureResult {
let raw_viewport_size = if is_vertical {
constraints.max_height
} else {
constraints.max_width
};
let cross_axis_size = if is_vertical {
constraints.max_width
} else {
constraints.max_height
};
let items_count = content.item_count();
if items_count > 0 {
let range = state.nearest_range();
state.update_scroll_position_if_item_moved(items_count, |slot_id| {
content
.get_index_by_slot_id_in_range(slot_id, range.clone())
.or_else(|| content.get_index_by_slot_id(slot_id))
});
}
let measure_item = |index: usize| -> LazyListMeasuredItem {
let key = content.get_key(index);
let key_slot_id = key.to_slot_id();
let content_type = content.get_content_type(index);
let slot_id = SlotId(key_slot_id);
scope.update_content_type(slot_id, content_type);
let item_content = content
.with_interval(index, |local_index, interval| {
let content = Rc::clone(&interval.content);
move || (content)(local_index)
})
.expect("lazy list interval content must exist for measured item");
let children = scope.subcompose(slot_id, item_content);
let was_reused = scope.was_last_slot_reused().unwrap_or(false);
state.record_composition(was_reused);
let root_children = children;
let child_constraints = if is_vertical {
Constraints {
min_width: 0.0,
max_width: cross_axis_size,
min_height: 0.0,
max_height: f32::INFINITY,
}
} else {
Constraints {
min_width: 0.0,
max_width: f32::INFINITY,
min_height: 0.0,
max_height: cross_axis_size,
}
};
let mut total_main_size: f32 = 0.0;
let mut max_cross_size: f32 = 0.0;
let mut node_ids: SmallNodeVec = SmallVec::new();
let mut child_offsets: SmallOffsetVec = SmallVec::new();
for child in root_children {
let placeable = scope.measure(child, child_constraints);
let (main, cross) = if is_vertical {
(placeable.height(), placeable.width())
} else {
(placeable.width(), placeable.height())
};
child_offsets.push(total_main_size);
node_ids.push(child.node_id() as u64);
total_main_size += main;
max_cross_size = max_cross_size.max(cross);
}
let main_axis_size = total_main_size.max(1.0);
let mut item = LazyListMeasuredItem::new(
index,
key_slot_id,
content_type,
main_axis_size,
max_cross_size,
);
item.node_ids = node_ids;
item.child_offsets = child_offsets;
item
};
let scroll_delta_for_direction = state.peek_scroll_delta();
let result = measure_lazy_list(
items_count,
state,
raw_viewport_size,
cross_axis_size,
config,
measure_item,
);
let effective_viewport_size = result.viewport_size;
let average_size = state.cache_item_sizes(
result
.visible_items
.iter()
.map(|item| (item.index, item.main_axis_size)),
);
let truly_visible_count = result
.visible_items
.iter()
.filter(|item| {
let item_end = item.offset + item.main_axis_size;
item.offset < effective_viewport_size && item_end > 0.0
})
.count();
let in_pool = scope.reusable_slots_count();
state.update_stats(truly_visible_count, in_pool);
if !result.visible_items.is_empty() {
let first_visible = result.visible_items.first().map(|i| i.index).unwrap_or(0);
let last_visible = result.visible_items.last().map(|i| i.index).unwrap_or(0);
state.record_scroll_direction(scroll_delta_for_direction);
state.update_prefetch_queue(first_visible, last_visible, items_count);
let prefetch_indices = state.take_prefetch_indices();
for idx in prefetch_indices {
if idx < items_count {
let key = content.get_key(idx);
let key_slot_id = key.to_slot_id();
let content_type_prefetch = content.get_content_type(idx);
let slot_id = SlotId(key_slot_id);
scope.update_content_type(slot_id, content_type_prefetch);
let estimated_size = state
.get_cached_size(idx)
.unwrap_or(average_size.max(DEFAULT_ITEM_SIZE_ESTIMATE));
let prefetch_idx = idx;
let is_vertical = config.is_vertical;
let item_content = content
.with_interval(prefetch_idx, |local_index, interval| {
let content = Rc::clone(&interval.content);
move || (content)(local_index)
})
.expect("lazy list interval content must exist for prefetched item");
let _ = scope.subcompose_with_size(slot_id, item_content, move |_| {
if is_vertical {
crate::modifier::Size {
width: cross_axis_size,
height: estimated_size + config.spacing,
}
} else {
crate::modifier::Size {
width: estimated_size + config.spacing,
height: cross_axis_size,
}
}
});
}
}
}
let placements = create_lazy_list_placements(
&result.visible_items,
items_count,
is_vertical,
effective_viewport_size,
config,
);
let resolve_main_axis = |content_size: f32, min: f32, max: f32| {
if max.is_finite() {
content_size.clamp(min, max)
} else {
content_size.min(effective_viewport_size).max(min)
}
};
let width = if is_vertical {
cross_axis_size
} else {
resolve_main_axis(
result.total_content_size,
constraints.min_width,
constraints.max_width,
)
};
let height = if is_vertical {
resolve_main_axis(
result.total_content_size,
constraints.min_height,
constraints.max_height,
)
} else {
cross_axis_size
};
scope.layout(width, height, placements)
}
fn get_spacing(arrangement: LinearArrangement) -> f32 {
match arrangement {
LinearArrangement::SpacedBy(spacing) => spacing,
_ => 0.0,
}
}
fn bind_layout_invalidation_callback(state: LazyListState, list_state_id: usize, node_id: NodeId) {
let callback_owner =
cranpose_core::remember(|| Rc::new(RefCell::new(None::<u64>))).with(|cell| cell.clone());
let callback_id = state.try_register_layout_callback(
node_id,
Rc::new(move || {
crate::schedule_layout_repass(node_id);
}),
);
if let Some(previous_id) = callback_owner.replace(callback_id) {
if Some(previous_id) != callback_id {
state.remove_invalidate_callback(previous_id);
}
}
cranpose_core::DisposableEffect!((list_state_id, node_id, callback_id), move |scope| {
scope.on_dispose(move || {
if let Some(callback_id) = callback_id {
state.remove_invalidate_callback(callback_id);
}
})
});
}
#[derive(Clone)]
struct LazyListContentHandle(Rc<LazyListIntervalContent>);
impl LazyListContentHandle {
fn new(content: LazyListIntervalContent) -> Self {
Self(Rc::new(content))
}
fn empty() -> Self {
Self::new(LazyListIntervalContent::new())
}
fn content(&self) -> &LazyListIntervalContent {
self.0.as_ref()
}
}
impl PartialEq for LazyListContentHandle {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.0, &other.0)
}
}
fn create_lazy_list_placements(
visible_items: &[LazyListMeasuredItem],
items_count: usize,
is_vertical: bool,
viewport_size: f32,
config: &LazyListMeasureConfig,
) -> Vec<Placement> {
use cranpose_ui_layout::Arrangement;
let arrangement = if is_vertical {
config
.vertical_arrangement
.unwrap_or(LinearArrangement::Start)
} else {
config
.horizontal_arrangement
.unwrap_or(LinearArrangement::Start)
};
let spacing = get_spacing(arrangement);
let total_item_size: f32 = visible_items.iter().map(|i| i.main_axis_size).sum::<f32>()
+ (items_count.saturating_sub(1) as f32) * spacing;
let available_main_axis =
(viewport_size - config.before_content_padding - config.after_content_padding).max(0.0);
let has_spare_space =
total_item_size < available_main_axis && visible_items.len() == items_count;
let should_apply_arrangement = has_spare_space
&& !matches!(
arrangement,
LinearArrangement::Start | LinearArrangement::SpacedBy(_)
);
if should_apply_arrangement {
let content_offset = config.before_content_padding;
let sizes: Vec<f32> = visible_items.iter().map(|i| i.main_axis_size).collect();
let mut positions = vec![0.0; sizes.len()];
arrangement.arrange(available_main_axis, &sizes, &mut positions);
visible_items
.iter()
.zip(positions.iter())
.flat_map(|(item, &pos)| {
item.node_ids.iter().zip(item.child_offsets.iter()).map(
move |(&nid, &child_offset)| {
let node_id: NodeId = nid as NodeId;
let item_size = item.main_axis_size;
if is_vertical {
let y = if config.reverse_layout {
viewport_size - (content_offset + pos) - item_size + child_offset
} else {
content_offset + pos + child_offset
};
Placement::new(node_id, 0.0, y, 0)
} else {
let x = if config.reverse_layout {
viewport_size - (content_offset + pos) - item_size + child_offset
} else {
content_offset + pos + child_offset
};
Placement::new(node_id, x, 0.0, 0)
}
},
)
})
.collect()
} else {
visible_items
.iter()
.flat_map(|item| {
item.node_ids.iter().zip(item.child_offsets.iter()).map(
move |(&nid, &child_offset)| {
let node_id: NodeId = nid as NodeId;
let item_size = item.main_axis_size;
if is_vertical {
let y = if config.reverse_layout {
viewport_size - item.offset - item_size + child_offset
} else {
item.offset + child_offset
};
Placement::new(node_id, 0.0, y, 0)
} else {
let x = if config.reverse_layout {
viewport_size - item.offset - item_size + child_offset
} else {
item.offset + child_offset
};
Placement::new(node_id, x, 0.0, 0)
}
},
)
})
.collect()
}
}
fn lazy_list_state_identity(state: &LazyListState) -> usize {
let state_ptr = state.inner_ptr();
debug_assert!(
!state_ptr.is_null(),
"lazy list identity requires a live LazyListState"
);
state_ptr as usize
}
fn LazyColumnImpl(
modifier: Modifier,
state: LazyListState,
spec: LazyColumnSpec,
content: LazyListContentHandle,
) -> NodeId {
use std::cell::RefCell;
let content_cell =
cranpose_core::remember(|| Rc::new(RefCell::new(LazyListContentHandle::empty())))
.with(|cell| cell.clone());
*content_cell.borrow_mut() = content;
let config = LazyListMeasureConfig {
is_vertical: true,
reverse_layout: spec.reverse_layout,
before_content_padding: spec.content_padding_top,
after_content_padding: spec.content_padding_bottom,
spacing: get_spacing(spec.vertical_arrangement),
beyond_bounds_item_count: spec.beyond_bounds_item_count,
vertical_arrangement: Some(spec.vertical_arrangement),
horizontal_arrangement: None,
};
let config_cell =
cranpose_core::remember(|| Rc::new(RefCell::new(config.clone()))).with(|cell| cell.clone());
*config_cell.borrow_mut() = config;
let content_for_policy = content_cell.clone();
let policy: Rc<MeasurePolicy> = cranpose_core::remember(move || {
let config_ref = config_cell.clone();
let content_ref = content_for_policy.clone();
let policy: Rc<MeasurePolicy> = Rc::new(
move |scope: &mut SubcomposeMeasureScopeImpl<'_>, constraints: Constraints| {
let content = content_ref.borrow();
let config = config_ref.borrow().clone();
measure_lazy_list_internal(
scope,
constraints,
true,
content.content(),
&state,
&config,
)
},
);
policy
})
.with(|p| p.clone());
let list_state_id = lazy_list_state_identity(&state);
let scroll_modifier = modifier
.clip_to_bounds()
.lazy_vertical_scroll(state, spec.reverse_layout);
let node_id = cranpose_core::with_current_composer(|composer| {
composer.with_key(&(list_state_id, "LazyColumnNode"), |composer| {
composer.emit_node({
let scroll_modifier = scroll_modifier.clone();
let policy = Rc::clone(&policy);
move || SubcomposeLayoutNode::with_content_type_policy(scroll_modifier, policy)
})
})
});
if let Err(err) = cranpose_core::with_node_mut(node_id, |node: &mut SubcomposeLayoutNode| {
node.set_modifier(scroll_modifier.clone());
node.set_measure_policy(Rc::clone(&policy));
node.mark_needs_measure();
}) {
debug_assert!(false, "failed to update LazyColumn node: {err}");
}
cranpose_core::bubble_measure_dirty_in_composer(node_id);
bind_layout_invalidation_callback(state, list_state_id, node_id);
node_id
}
fn LazyRowImpl(
modifier: Modifier,
state: LazyListState,
spec: LazyRowSpec,
content: LazyListContentHandle,
) -> NodeId {
use std::cell::RefCell;
let content_cell =
cranpose_core::remember(|| Rc::new(RefCell::new(LazyListContentHandle::empty())))
.with(|cell| cell.clone());
*content_cell.borrow_mut() = content;
let config = LazyListMeasureConfig {
is_vertical: false,
reverse_layout: spec.reverse_layout,
before_content_padding: spec.content_padding_start,
after_content_padding: spec.content_padding_end,
spacing: get_spacing(spec.horizontal_arrangement),
beyond_bounds_item_count: spec.beyond_bounds_item_count,
vertical_arrangement: None,
horizontal_arrangement: Some(spec.horizontal_arrangement),
};
let config_cell =
cranpose_core::remember(|| Rc::new(RefCell::new(config.clone()))).with(|cell| cell.clone());
*config_cell.borrow_mut() = config;
let content_for_policy = content_cell.clone();
let policy: Rc<MeasurePolicy> = cranpose_core::remember(move || {
let config_ref = config_cell.clone();
let content_ref = content_for_policy.clone();
let policy: Rc<MeasurePolicy> = Rc::new(
move |scope: &mut SubcomposeMeasureScopeImpl<'_>, constraints: Constraints| {
let content = content_ref.borrow();
let config = config_ref.borrow().clone();
measure_lazy_list_internal(
scope,
constraints,
false,
content.content(),
&state,
&config,
)
},
);
policy
})
.with(|p| p.clone());
let list_state_id = lazy_list_state_identity(&state);
let scroll_modifier = modifier
.clip_to_bounds()
.lazy_horizontal_scroll(state, spec.reverse_layout);
let node_id = cranpose_core::with_current_composer(|composer| {
composer.with_key(&(list_state_id, "LazyRowNode"), |composer| {
composer.emit_node({
let scroll_modifier = scroll_modifier.clone();
let policy = Rc::clone(&policy);
move || SubcomposeLayoutNode::with_content_type_policy(scroll_modifier, policy)
})
})
});
if let Err(err) = cranpose_core::with_node_mut(node_id, |node: &mut SubcomposeLayoutNode| {
node.set_modifier(scroll_modifier.clone());
node.set_measure_policy(Rc::clone(&policy));
node.mark_needs_measure();
}) {
debug_assert!(false, "failed to update LazyRow node: {err}");
}
cranpose_core::bubble_measure_dirty_in_composer(node_id);
bind_layout_invalidation_callback(state, list_state_id, node_id);
node_id
}
#[composable]
fn LazyColumnNode(
modifier: Modifier,
state: LazyListState,
spec: LazyColumnSpec,
content: LazyListContentHandle,
) -> NodeId {
cranpose_core::debug_label_current_scope("LazyColumnNode");
LazyColumnImpl(modifier, state, spec, content)
}
#[composable]
fn LazyRowNode(
modifier: Modifier,
state: LazyListState,
spec: LazyRowSpec,
content: LazyListContentHandle,
) -> NodeId {
cranpose_core::debug_label_current_scope("LazyRowNode");
LazyRowImpl(modifier, state, spec, content)
}
pub fn LazyColumn<F>(
modifier: Modifier,
state: LazyListState,
spec: LazyColumnSpec,
content: F,
) -> NodeId
where
F: FnOnce(&mut LazyListIntervalContent),
{
let mut interval_content = LazyListIntervalContent::new();
content(&mut interval_content);
LazyColumnNode(
modifier,
state,
spec,
LazyListContentHandle::new(interval_content),
)
}
pub fn LazyRow<F>(modifier: Modifier, state: LazyListState, spec: LazyRowSpec, content: F) -> NodeId
where
F: FnOnce(&mut LazyListIntervalContent),
{
let mut interval_content = LazyListIntervalContent::new();
content(&mut interval_content);
LazyRowNode(
modifier,
state,
spec,
LazyListContentHandle::new(interval_content),
)
}
#[cfg(test)]
mod tests {
use super::*;
use cranpose_core::{location_key, Composition, MemoryApplier};
#[test]
fn test_lazy_column_spec_default() {
let spec = LazyColumnSpec::default();
assert_eq!(spec.vertical_arrangement, LinearArrangement::Start);
assert_eq!(spec.beyond_bounds_item_count, 2);
}
#[test]
fn test_lazy_column_spec_builder() {
let spec = LazyColumnSpec::new()
.vertical_arrangement(LinearArrangement::SpacedBy(8.0))
.content_padding(16.0, 16.0);
assert_eq!(spec.vertical_arrangement, LinearArrangement::SpacedBy(8.0));
assert_eq!(spec.content_padding_top, 16.0);
}
#[test]
fn test_lazy_row_spec_default() {
let spec = LazyRowSpec::default();
assert_eq!(spec.horizontal_arrangement, LinearArrangement::Start);
assert_eq!(spec.beyond_bounds_item_count, 2);
}
#[test]
fn test_get_spacing() {
assert_eq!(get_spacing(LinearArrangement::Start), 0.0);
assert_eq!(get_spacing(LinearArrangement::SpacedBy(12.0)), 12.0);
}
#[test]
fn test_content_padding_all() {
let spec = LazyColumnSpec::new().content_padding_all(24.0);
assert_eq!(spec.content_padding_top, 24.0);
assert_eq!(spec.content_padding_bottom, 24.0);
}
#[test]
fn lazy_list_state_identity_is_stable_for_copied_state() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let mut state = None;
composition
.render(key, || {
state = Some(cranpose_foundation::lazy::remember_lazy_list_state());
})
.expect("lazy list state render should succeed");
let state = state.expect("lazy list state should be captured");
let copied_state = state;
assert_ne!(state.inner_ptr(), std::ptr::null());
assert_eq!(
lazy_list_state_identity(&state),
lazy_list_state_identity(&copied_state)
);
}
}