use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use cranpose_core::{Composition, MemoryApplier, MutableState, NodeId, location_key};
use cranpose_foundation::lazy::{LazyListScope, LazyListState, rememberLazyListState};
use cranpose_ui_graphics::Size as ViewportSize;
use super::*;
#[test]
fn lazy_row_unbounded_width_matches_effective_viewport() {
let captured_state = Rc::new(RefCell::new(None));
let mut composition = run_test_composition({
let captured_state = Rc::clone(&captured_state);
move || {
let list_state = rememberLazyListState();
*captured_state.borrow_mut() = Some(list_state);
LazyRow(
Modifier::empty(),
list_state,
LazyRowSpec::default(),
|scope| {
scope.items(100, |_| {
Spacer(Size {
width: 100.0,
height: 0.0,
});
});
},
);
}
});
let root = composition.root().expect("lazy row root");
let handle = composition.runtime_handle();
let measurements = {
let mut applier = composition.applier_mut();
applier.set_runtime_handle(handle);
let result = measure_layout(
&mut applier,
root,
ViewportSize {
width: f32::INFINITY,
height: 320.0,
},
)
.expect("layout measurement");
applier.clear_runtime_handle();
result
};
let list_state = (*captured_state.borrow()).expect("state captured");
let expected_width = list_state.layout_info().viewport_size;
let actual_width = measurements.root_size().width;
assert!(actual_width.is_finite());
assert!(
(actual_width - expected_width).abs() < 0.01,
"expected lazy row width to match effective viewport {expected_width}, got {actual_width}"
);
}
#[composable]
#[allow(non_snake_case)]
fn GrowingLazyRow(
item_count: MutableState<usize>,
captured_state: Rc<RefCell<Option<LazyListState>>>,
call_count: Rc<Cell<usize>>,
) {
let list_state = rememberLazyListState();
*captured_state.borrow_mut() = Some(list_state);
let count = item_count.value();
call_count.set(call_count.get() + 1);
Column(Modifier::empty(), ColumnSpec::default(), move || {
Text(
format!("Count {count}"),
Modifier::empty(),
TextStyle::default(),
);
LazyRow(
Modifier::empty().height(120.0),
list_state,
LazyRowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
|scope| {
scope.items(count, |index| {
Row(
Modifier::empty().height(48.0).width(96.0),
RowSpec::default(),
move || {
Text(
format!("Item {}", index),
Modifier::empty(),
TextStyle::default(),
);
},
);
});
},
);
});
}
#[test]
fn lazy_row_updates_scroll_bounds_when_item_count_grows_without_scrolling() {
let _app_context = crate::render_state::app_context_test_scope();
let mut composition = Composition::new(MemoryApplier::new());
let runtime = composition.runtime_handle();
let item_count = MutableState::with_runtime(2usize, runtime.clone());
let captured_state = Rc::new(RefCell::new(None));
let call_count = Rc::new(Cell::new(0));
let key = location_key(file!(), line!(), column!());
composition
.render(key, {
let captured_state = Rc::clone(&captured_state);
let call_count = Rc::clone(&call_count);
move || {
GrowingLazyRow(
item_count,
Rc::clone(&captured_state),
Rc::clone(&call_count),
);
}
})
.expect("initial render");
let root = composition.root().expect("lazy row root");
let viewport = Size {
width: 260.0,
height: 320.0,
};
measure_root(&mut composition, root, viewport);
let list_state = (*captured_state.borrow()).expect("state captured");
assert_eq!(list_state.layout_info().total_items_count, 2);
assert!(
!list_state.can_scroll_forward(),
"initial short row should not scroll"
);
item_count.set(24);
let mut recomposed = false;
while composition
.process_invalid_scopes()
.expect("recompose after item count growth")
{
recomposed = true;
}
assert!(
recomposed,
"expected composition to re-run after item count growth"
);
assert!(
call_count.get() >= 2,
"expected LazyRow parent composable to execute again after item count growth"
);
measure_root(&mut composition, root, viewport);
assert_eq!(list_state.layout_info().total_items_count, 24);
assert!(
list_state.can_scroll_forward(),
"lazy row should become scrollable when item count grows beyond viewport"
);
let texts = render_texts(&mut composition, root);
assert!(
texts.iter().any(|text| text == "Count 24"),
"expected parent composition to observe the grown item count"
);
}
#[composable]
#[allow(non_snake_case)]
fn HorizontalScrollIndicatorLazyRow(captured_state: Rc<RefCell<Option<LazyListState>>>) {
let list_state = rememberLazyListState();
*captured_state.borrow_mut() = Some(list_state);
Column(Modifier::empty(), ColumnSpec::default(), move || {
Text(
format!("First visible {}", list_state.first_visible_item_index()),
Modifier::empty(),
TextStyle::default(),
);
Text(
format!("Can scroll back {}", list_state.can_scroll_backward()),
Modifier::empty(),
TextStyle::default(),
);
LazyRow(
Modifier::empty().fill_max_height().width(240.0),
list_state,
LazyRowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
|scope| {
scope.items(80, |index| {
Text(
format!("Item {}", index),
Modifier::empty().width(48.0),
TextStyle::default(),
);
});
},
);
});
}
#[test]
fn lazy_row_gesture_scroll_moves_rendered_items_along_the_horizontal_axis() {
let _app_context = crate::render_state::app_context_test_scope();
let mut composition = Composition::new(MemoryApplier::new());
let captured_state = Rc::new(RefCell::new(None));
composition
.render(location_key(file!(), line!(), column!()), {
let captured_state = Rc::clone(&captured_state);
move || {
HorizontalScrollIndicatorLazyRow(Rc::clone(&captured_state));
}
})
.expect("initial render");
let root = composition.root().expect("lazy row root");
let viewport = Size {
width: 320.0,
height: 320.0,
};
measure_root(&mut composition, root, viewport);
let initial_texts = render_texts(&mut composition, root);
assert!(
initial_texts.iter().any(|text| text == "First visible 0"),
"test must subscribe a composition scope to the scroll position"
);
assert!(
initial_texts
.iter()
.any(|text| text == "Can scroll back false"),
"test must subscribe a composition scope to lazy scroll bounds"
);
let initial_records = render_text_records(&mut composition, root);
let initial_item_0_x = text_x(&initial_records, "Item 0");
let initial_item_0_y = text_y(&initial_records, "Item 0");
let list_state = (*captured_state.borrow()).expect("state captured");
list_state.dispatch_scroll_delta(-40.0);
measure_root(&mut composition, root, viewport);
assert!(
list_state.can_scroll_backward_non_reactive(),
"gesture scroll should update retained lazy scroll bounds"
);
assert!(
composition
.process_invalid_scopes()
.expect("gesture scroll invalidation processing"),
"gesture scroll must recompose scopes that observe public scroll position"
);
measure_root(&mut composition, root, viewport);
let scrolled_texts = render_texts(&mut composition, root);
assert!(
scrolled_texts
.iter()
.any(|text| text == "Can scroll back true"),
"scroll observer text did not track gesture-updated backward capability; got {scrolled_texts:?}"
);
let scrolled_records = render_text_records(&mut composition, root);
let scrolled_item_0_x = text_x(&scrolled_records, "Item 0");
let scrolled_item_0_y = text_y(&scrolled_records, "Item 0");
assert!(
(initial_item_0_x - scrolled_item_0_x - 40.0).abs() < 0.5,
"scrolling a lazy row should move its rendered items along x by the scroll delta: \
initial_x={initial_item_0_x}, scrolled_x={scrolled_item_0_x}"
);
assert!(
(initial_item_0_y - scrolled_item_0_y).abs() < 0.01,
"scrolling a lazy row must not move its rendered items along y: \
initial_y={initial_item_0_y}, scrolled_y={scrolled_item_0_y}"
);
}
#[derive(Debug)]
struct RenderedText {
value: String,
x: f32,
y: f32,
}
fn render_texts(composition: &mut Composition<MemoryApplier>, root: NodeId) -> Vec<String> {
render_text_records(composition, root)
.into_iter()
.map(|record| record.value)
.collect()
}
fn render_text_records(
composition: &mut Composition<MemoryApplier>,
root: NodeId,
) -> Vec<RenderedText> {
let handle = composition.runtime_handle();
let mut applier = composition.applier_mut();
applier.set_runtime_handle(handle);
let layout = applier
.compute_layout(
root,
Size {
width: 320.0,
height: 320.0,
},
)
.expect("layout");
applier.clear_runtime_handle();
let renderer = HeadlessRenderer::new();
let scene = renderer.render(&layout);
scene
.operations()
.iter()
.filter_map(|op| match op {
RenderOp::Text { rect, value, .. } => Some(RenderedText {
value: value.clone(),
x: rect.x,
y: rect.y,
}),
_ => None,
})
.collect()
}
fn text_x(records: &[RenderedText], value: &str) -> f32 {
records
.iter()
.find(|record| record.value == value)
.unwrap_or_else(|| panic!("expected rendered text {value:?}, got {records:?}"))
.x
}
fn text_y(records: &[RenderedText], value: &str) -> f32 {
records
.iter()
.find(|record| record.value == value)
.unwrap_or_else(|| panic!("expected rendered text {value:?}, got {records:?}"))
.y
}
fn measure_root(composition: &mut Composition<MemoryApplier>, root: NodeId, size: Size) {
let handle = composition.runtime_handle();
let mut applier = composition.applier_mut();
applier.set_runtime_handle(handle);
let _ = applier.compute_layout(root, size).expect("layout");
applier.clear_runtime_handle();
}