use super::*;
use crate::composable;
use crate::layout::LayoutBox;
use crate::modifier::{Modifier, Size};
use crate::renderer::{HeadlessRenderer, RenderOp};
use crate::subcompose_layout::{Constraints, SubcomposeLayoutNode};
use crate::text::TextStyle;
use crate::widgets::nodes::LayoutNode;
use crate::widgets::{
BoxWithConstraints, Column, ColumnSpec, DynamicTextSource, LazyColumn, LazyColumnSpec, Row,
RowSpec, Spacer, Text,
};
use crate::{run_test_composition, LayoutEngine};
use cranpose_core::{
self, location_key, Applier, Composer, Composition, ConcreteApplierHost, MemoryApplier, NodeId,
Phase, SlotTable, SlotsHost, SnapshotStateObserver, State,
};
use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
use cranpose_ui_layout::{HorizontalAlignment, LinearArrangement, VerticalAlignment};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
thread_local! {
static COUNTER_ROW_INVOCATIONS: Cell<usize> = const { Cell::new(0) };
static COUNTER_TEXT_ID: RefCell<Option<NodeId>> = const { RefCell::new(None) };
static CONDITIONAL_LAZY_LIST_STATE: RefCell<Option<LazyListState>> = const { RefCell::new(None) };
static CONDITIONAL_LAZY_LIST_NODE_ID: RefCell<Option<NodeId>> = const { RefCell::new(None) };
}
fn prepare_measure_composer(
slots: &mut SlotTable,
applier: &mut MemoryApplier,
handle: &cranpose_core::RuntimeHandle,
root: Option<NodeId>,
) -> (
Composer,
Rc<SlotsHost>,
Rc<ConcreteApplierHost<MemoryApplier>>,
) {
let slots_host = Rc::new(SlotsHost::new(std::mem::take(slots)));
let applier_host = Rc::new(ConcreteApplierHost::new(std::mem::replace(
applier,
MemoryApplier::new(),
)));
let observer = SnapshotStateObserver::new(|callback| callback());
let composer = Composer::new(
Rc::clone(&slots_host),
applier_host.clone(),
handle.clone(),
observer,
root,
);
(composer, slots_host, applier_host)
}
fn restore_measure_composer(
slots: &mut SlotTable,
applier: &mut MemoryApplier,
slots_host: Rc<SlotsHost>,
applier_host: Rc<ConcreteApplierHost<MemoryApplier>>,
) {
*slots = slots_host
.into_table()
.expect("restore primitive test slots");
*applier = Rc::try_unwrap(applier_host)
.unwrap_or_else(|_| panic!("applier host still has outstanding references"))
.into_inner();
}
fn run_subcompose_measure(
slots: &mut SlotTable,
applier: &mut MemoryApplier,
handle: &cranpose_core::RuntimeHandle,
node_id: NodeId,
constraints: Constraints,
) {
let (composer, slots_host, applier_host) =
prepare_measure_composer(slots, applier, handle, Some(node_id));
composer.enter_phase(Phase::Measure);
let node_handle = {
let mut applier_ref = applier_host.borrow_typed();
let node = applier_ref.get_mut(node_id).expect("node available");
let typed = node
.as_any_mut()
.downcast_mut::<SubcomposeLayoutNode>()
.expect("subcompose layout node");
typed.handle()
};
let measurer = Box::new(|_child_id: NodeId, _constraints: Constraints| Size::default());
let error = Rc::new(RefCell::new(None));
node_handle
.measure(&composer, node_id, constraints, measurer, &error)
.expect("measure succeeds");
assert!(
error.borrow().is_none(),
"unexpected subcompose measure error"
);
drop(composer);
restore_measure_composer(slots, applier, slots_host, applier_host);
}
#[test]
fn row_with_alignment_updates_node_fields() {
let mut composition = run_test_composition(|| {
Row(
Modifier::empty(),
RowSpec::new()
.horizontal_arrangement(LinearArrangement::SpaceBetween)
.vertical_alignment(VerticalAlignment::Bottom),
|| {},
);
});
let root = composition.root().expect("row root");
composition
.applier_mut()
.with_node::<LayoutNode, _>(root, |node| {
assert!(!node.children.is_empty() || node.children.is_empty());
})
.expect("layout node available");
}
#[test]
fn column_with_alignment_updates_node_fields() {
let mut composition = run_test_composition(|| {
Column(
Modifier::empty(),
ColumnSpec::new()
.vertical_arrangement(LinearArrangement::SpaceEvenly)
.horizontal_alignment(HorizontalAlignment::End),
|| {},
);
});
let root = composition.root().expect("column root");
composition
.applier_mut()
.with_node::<LayoutNode, _>(root, |node| {
assert!(!node.children.is_empty() || node.children.is_empty());
})
.expect("layout node available");
}
fn measure_subcompose_node(
composition: &mut Composition<MemoryApplier>,
slots: &mut SlotTable,
handle: &cranpose_core::RuntimeHandle,
root: NodeId,
constraints: Constraints,
) {
let mut applier_guard = composition.applier_mut();
let mut temp_applier = std::mem::take(&mut *applier_guard);
run_subcompose_measure(slots, &mut temp_applier, handle, root, constraints);
*applier_guard = temp_applier;
}
fn capture_subcompose_child_constraints(
composition: &mut Composition<MemoryApplier>,
slots: &mut SlotTable,
handle: &cranpose_core::RuntimeHandle,
root: NodeId,
constraints: Constraints,
) -> Vec<Constraints> {
let mut applier_guard = composition.applier_mut();
let mut temp_applier = std::mem::take(&mut *applier_guard);
let (composer, slots_host, applier_host) =
prepare_measure_composer(slots, &mut temp_applier, handle, Some(root));
composer.enter_phase(Phase::Measure);
let node_handle = {
let mut applier_ref = applier_host.borrow_typed();
let node = applier_ref.get_mut(root).expect("node available");
let typed = node
.as_any_mut()
.downcast_mut::<SubcomposeLayoutNode>()
.expect("subcompose layout node");
typed.handle()
};
let captured = Rc::new(RefCell::new(Vec::new()));
let captured_handle = Rc::clone(&captured);
let error = Rc::new(RefCell::new(None));
let measurer = Box::new(move |_child_id: NodeId, child_constraints: Constraints| {
captured_handle.borrow_mut().push(child_constraints);
Size::default()
});
node_handle
.measure(&composer, root, constraints, measurer, &error)
.expect("measure succeeds");
assert!(
error.borrow().is_none(),
"unexpected subcompose measure error"
);
drop(composer);
restore_measure_composer(slots, &mut temp_applier, slots_host, applier_host);
*applier_guard = temp_applier;
let captured_constraints = captured.borrow().clone();
captured_constraints
}
fn render_texts(
composition: &mut Composition<MemoryApplier>,
root: NodeId,
size: Size,
) -> Vec<String> {
let handle = composition.runtime_handle();
let mut applier = composition.applier_mut();
applier.set_runtime_handle(handle);
let layout = applier.compute_layout(root, size).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 { value, .. } => Some(value.clone()),
_ => None,
})
.collect()
}
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();
}
fn assert_box_with_constraints_branch_toggle<F>(
initial_text: &'static str,
toggled_text: &'static str,
restore_message: &'static str,
mut content: F,
) where
F: FnMut(cranpose_core::MutableState<bool>) + 'static,
{
let mut composition = Composition::new(MemoryApplier::new());
let runtime = composition.runtime_handle();
let show_thread = cranpose_core::MutableState::with_runtime(false, runtime.clone());
let render_state = show_thread;
composition
.render(location_key(file!(), line!(), column!()), move || {
content(render_state);
})
.expect("initial render");
let root = composition.root().expect("root node");
let size = Size {
width: 200.0,
height: 200.0,
};
let texts = render_texts(&mut composition, root, size);
assert!(texts.iter().any(|text| text == "Header"));
assert!(texts.iter().any(|text| text == initial_text));
show_thread.set_value(true);
composition
.process_invalid_scopes()
.expect("switch to alternate branch");
let texts = render_texts(&mut composition, root, size);
assert!(texts.iter().any(|text| text == toggled_text));
assert!(!texts.iter().any(|text| text == initial_text));
show_thread.set_value(false);
composition
.process_invalid_scopes()
.expect("restore original branch");
let texts = render_texts(&mut composition, root, size);
assert!(
texts.iter().any(|text| text == initial_text),
"{restore_message}, got {texts:?}",
);
assert!(!texts.iter().any(|text| text == toggled_text));
}
#[composable]
fn CounterRow(label: &'static str, count: State<i32>) -> NodeId {
COUNTER_ROW_INVOCATIONS.with(|calls| calls.set(calls.get() + 1));
Column(Modifier::empty(), ColumnSpec::default(), move || {
Text(label, Modifier::empty(), TextStyle::default());
let count_for_text = count;
let text_id = Text(
DynamicTextSource::new(move || {
Rc::new(crate::text::AnnotatedString::from(format!(
"Count = {}",
count_for_text.value()
)))
}),
Modifier::empty(),
TextStyle::default(),
);
COUNTER_TEXT_ID.with(|slot| *slot.borrow_mut() = Some(text_id));
})
}
#[composable]
fn PrimitiveStoriesBranch() {
Text("Stories", Modifier::empty(), TextStyle::default());
}
#[composable]
fn PrimitiveThreadBranch() {
Text("Thread", Modifier::empty(), TextStyle::default());
}
#[composable]
fn PrimitiveStoriesPaneLike(modifier: Modifier) {
Column(modifier, ColumnSpec::default(), move || {
Text("Top stories", Modifier::empty(), TextStyle::default());
Text("Story body", Modifier::empty(), TextStyle::default());
});
}
#[composable]
fn PrimitiveThreadPaneLike(modifier: Modifier) {
Column(modifier, ColumnSpec::default(), move || {
Text("Back", Modifier::empty(), TextStyle::default());
Text("Thread body", Modifier::empty(), TextStyle::default());
});
}
#[composable]
fn PrimitiveHeaderWithOptionalBack(show_back: bool) {
Row(
Modifier::empty().fill_max_width(),
RowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
if show_back {
Text("Back", Modifier::empty(), TextStyle::default());
}
Text("Header", Modifier::empty(), TextStyle::default());
},
);
}
#[composable]
fn PrimitiveStoriesListBranch(list_state: LazyListState) {
LazyColumn(
Modifier::empty().fill_max_width().weight(1.0),
list_state,
LazyColumnSpec::default(),
|scope| {
scope.item(Some(0), None, || {
Text("Stories", Modifier::empty(), TextStyle::default());
});
},
);
}
#[composable]
fn PrimitiveScrollReactiveStoriesBranch(list_state: LazyListState) {
CONDITIONAL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = Some(list_state);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
Text(
format!("First visible {}", list_state.first_visible_item_index()),
Modifier::empty(),
TextStyle::default(),
);
let node_id = LazyColumn(
Modifier::empty().fill_max_width().weight(1.0),
list_state,
LazyColumnSpec::default(),
|scope| {
scope.items(
40,
None::<fn(usize) -> u64>,
None::<fn(usize) -> u64>,
|index| {
Text(
format!("Item {}", index),
Modifier::empty(),
TextStyle::default(),
);
},
);
},
);
CONDITIONAL_LAZY_LIST_NODE_ID.with(|slot| {
*slot.borrow_mut() = Some(node_id);
});
},
);
}
#[composable]
fn PrimitiveStoriesPaneWithIndicator(modifier: Modifier, list_state: LazyListState) {
Column(
modifier,
ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
Text("Top stories", Modifier::empty(), TextStyle::default());
Text("40 loaded of 40", Modifier::empty(), TextStyle::default());
Row(
Modifier::empty().fill_max_width().weight(1.0),
RowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
Column(
Modifier::empty().weight(1.0).fill_max_height(),
ColumnSpec::default(),
move || {
let node_id = LazyColumn(
Modifier::empty().fill_max_size(),
list_state,
LazyColumnSpec::default(),
|scope| {
scope.items(
40,
None::<fn(usize) -> u64>,
None::<fn(usize) -> u64>,
|index| {
Text(
format!("Item {}", index),
Modifier::empty(),
TextStyle::default(),
);
},
);
},
);
CONDITIONAL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = Some(list_state);
});
CONDITIONAL_LAZY_LIST_NODE_ID.with(|slot| {
*slot.borrow_mut() = Some(node_id);
});
},
);
Column(
Modifier::empty().width(28.0).fill_max_height(),
ColumnSpec::default(),
move || {
Text(
format!("At {}", list_state.first_visible_item_index()),
Modifier::empty(),
TextStyle::default(),
);
},
);
},
);
},
);
}
#[composable]
fn PrimitiveStoriesPaneWithDynamicStatus(
modifier: Modifier,
list_state: LazyListState,
loaded_count: cranpose_core::MutableState<usize>,
) {
Column(
modifier,
ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
Text("Top stories", Modifier::empty(), TextStyle::default());
Text(
format!("{} loaded of 60", loaded_count.value()),
Modifier::empty(),
TextStyle::default(),
);
Row(
Modifier::empty().fill_max_width().weight(1.0),
RowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
Column(
Modifier::empty().weight(1.0).fill_max_height(),
ColumnSpec::default(),
move || {
let node_id = LazyColumn(
Modifier::empty().fill_max_size(),
list_state,
LazyColumnSpec::default(),
|scope| {
scope.items(
40,
None::<fn(usize) -> u64>,
None::<fn(usize) -> u64>,
|index| {
Text(
format!("Item {}", index),
Modifier::empty(),
TextStyle::default(),
);
},
);
},
);
CONDITIONAL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = Some(list_state);
});
CONDITIONAL_LAZY_LIST_NODE_ID.with(|slot| {
*slot.borrow_mut() = Some(node_id);
});
},
);
Column(
Modifier::empty().width(28.0).fill_max_height(),
ColumnSpec::default(),
move || {
Text(
format!("At {}", list_state.first_visible_item_index()),
Modifier::empty(),
TextStyle::default(),
);
},
);
},
);
},
);
}
fn captured_conditional_lazy_list_state() -> LazyListState {
CONDITIONAL_LAZY_LIST_STATE
.with(|slot| (*slot.borrow()).expect("conditional lazy list state should be captured"))
}
fn captured_conditional_lazy_list_node_id() -> NodeId {
CONDITIONAL_LAZY_LIST_NODE_ID
.with(|slot| (*slot.borrow()).expect("conditional lazy list node id should be captured"))
}
fn node_generation(composition: &mut Composition<MemoryApplier>, node_id: NodeId) -> u32 {
composition.applier_mut().node_generation(node_id)
}
fn settle_scroll_recomposition(
composition: &mut Composition<MemoryApplier>,
root: NodeId,
size: Size,
) {
measure_root(composition, root, size);
while composition
.process_invalid_scopes()
.expect("scroll-driven recomposition should succeed")
{
measure_root(composition, root, size);
}
measure_root(composition, root, size);
}
#[test]
fn layout_column_produces_expected_measurements() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let text_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let text_id_capture = Rc::clone(&text_id);
composition
.render(key, move || {
let text_id_capture = Rc::clone(&text_id_capture);
Column(
Modifier::empty().padding(10.0),
ColumnSpec::default(),
move || {
let id = Text("Hello", Modifier::empty(), TextStyle::default());
*text_id_capture.borrow_mut() = Some(id);
Spacer(Size {
width: 0.0,
height: 30.0,
});
},
);
})
.expect("initial render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 200.0,
height: 200.0,
},
)
.expect("compute layout");
let root_layout = layout_tree.root().clone();
let text_metrics = crate::text::measure_text(
&crate::text::AnnotatedString::from("Hello"),
&TextStyle::default(),
);
let expected_root_width = text_metrics.width + 20.0;
let expected_root_height = text_metrics.height + 30.0 + 20.0;
assert!((root_layout.rect.width - expected_root_width).abs() < 1e-3);
assert!((root_layout.rect.height - expected_root_height).abs() < 1e-3);
assert_eq!(root_layout.children.len(), 2);
let text_layout = &root_layout.children[0];
assert_eq!(
text_layout.node_id,
text_id.borrow().as_ref().copied().expect("text node id")
);
assert!((text_layout.rect.x - 10.0).abs() < 1e-3);
assert!((text_layout.rect.y - 10.0).abs() < 1e-3);
assert!((text_layout.rect.width - text_metrics.width).abs() < 1e-3);
assert!((text_layout.rect.height - text_metrics.height).abs() < 1e-3);
}
#[test]
fn modifier_offset_translates_layout() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let text_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let text_id_capture = Rc::clone(&text_id);
composition
.render(key, move || {
let text_id_capture = Rc::clone(&text_id_capture);
Column(
Modifier::empty().padding(10.0),
ColumnSpec::default(),
move || {
*text_id_capture.borrow_mut() = Some(Text(
"Hello",
Modifier::empty().offset(5.0, 7.5),
TextStyle::default(),
));
},
);
})
.expect("initial render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 200.0,
height: 200.0,
},
)
.expect("compute layout");
let root_layout = layout_tree.root().clone();
assert_eq!(root_layout.children.len(), 1);
let text_layout = &root_layout.children[0];
assert_eq!(
text_layout.node_id,
text_id.borrow().as_ref().copied().expect("text node id")
);
assert!((text_layout.rect.x - 15.0).abs() < 1e-3);
assert!((text_layout.rect.y - 17.5).abs() < 1e-3);
}
#[test]
fn box_with_constraints_composes_different_content() {
let mut composition = Composition::new(MemoryApplier::new());
let record = Rc::new(RefCell::new(Vec::new()));
let record_capture = Rc::clone(&record);
composition
.render(location_key(file!(), line!(), column!()), || {
BoxWithConstraints(Modifier::empty(), {
let record_capture = Rc::clone(&record_capture);
move |scope| {
let label = if scope.max_width().0 > 100.0 {
"wide"
} else {
"narrow"
};
record_capture.borrow_mut().push(label.to_string());
Text(label, Modifier::empty(), TextStyle::default());
}
});
})
.expect("render succeeds");
let root = composition.root().expect("root node");
let handle = composition.runtime_handle();
let mut slots = SlotTable::default();
measure_subcompose_node(
&mut composition,
&mut slots,
&handle,
root,
Constraints::tight(200.0, 100.0),
);
assert_eq!(record.borrow().as_slice(), ["wide"]);
measure_subcompose_node(
&mut composition,
&mut slots,
&handle,
root,
Constraints::tight(80.0, 50.0),
);
assert_eq!(record.borrow().as_slice(), ["wide", "narrow"]);
}
#[test]
fn box_with_constraints_reacts_to_constraint_changes() {
let mut composition = Composition::new(MemoryApplier::new());
let invocations = Rc::new(Cell::new(0));
let invocations_capture = Rc::clone(&invocations);
composition
.render(location_key(file!(), line!(), column!()), || {
BoxWithConstraints(Modifier::empty(), {
let invocations_capture = Rc::clone(&invocations_capture);
move |scope| {
let _ = scope.max_width();
invocations_capture.set(invocations_capture.get() + 1);
Text("child", Modifier::empty(), TextStyle::default());
}
});
})
.expect("render succeeds");
let root = composition.root().expect("root node");
let handle = composition.runtime_handle();
let mut slots = SlotTable::default();
for width in [120.0, 60.0] {
let constraints = Constraints::tight(width, 40.0);
measure_subcompose_node(&mut composition, &mut slots, &handle, root, constraints);
}
assert_eq!(invocations.get(), 2);
}
#[test]
fn box_with_constraints_reinstalls_slot_root_callback_after_recompose() {
let mut composition = Composition::new(MemoryApplier::new());
let runtime = composition.runtime_handle();
let value = cranpose_core::MutableState::with_runtime(0_i32, runtime);
composition
.render(location_key(file!(), line!(), column!()), || {
BoxWithConstraints(Modifier::empty().fill_max_size(), move |_scope| {
let current = value.value();
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Text(
format!("Value {current}"),
Modifier::empty(),
TextStyle::default(),
);
},
);
});
})
.expect("initial render");
let root = composition.root().expect("root node");
let size = Size {
width: 160.0,
height: 120.0,
};
let texts = render_texts(&mut composition, root, size);
assert!(texts.iter().any(|text| text == "Value 0"));
value.set_value(1);
while composition
.process_invalid_scopes()
.expect("first box-with-constraints recomposition")
{
let _ = render_texts(&mut composition, root, size);
}
let texts = render_texts(&mut composition, root, size);
assert!(
texts.iter().any(|text| text == "Value 1"),
"first box-with-constraints recomposition should refresh content, got {texts:?}",
);
value.set_value(2);
while composition
.process_invalid_scopes()
.expect("second box-with-constraints recomposition")
{
let _ = render_texts(&mut composition, root, size);
}
let texts = render_texts(&mut composition, root, size);
assert!(
texts.iter().any(|text| text == "Value 2"),
"second box-with-constraints recomposition should still refresh content, got {texts:?}",
);
}
#[test]
fn box_with_constraints_measures_children_with_finite_constraints() {
let mut composition = Composition::new(MemoryApplier::new());
let recorded = Rc::new(RefCell::new(Vec::new()));
composition
.render(location_key(file!(), line!(), column!()), || {
BoxWithConstraints(Modifier::empty(), move |_scope| {
Text("child", Modifier::empty(), TextStyle::default());
});
})
.expect("render succeeds");
let root = composition.root().expect("root node");
let handle = composition.runtime_handle();
let mut slots = SlotTable::default();
let captured = capture_subcompose_child_constraints(
&mut composition,
&mut slots,
&handle,
root,
Constraints::tight(120.0, 80.0),
);
*recorded.borrow_mut() = captured;
assert_eq!(
recorded.borrow().as_slice(),
&[Constraints {
min_width: 0.0,
max_width: 120.0,
min_height: 0.0,
max_height: 80.0,
}]
);
}
#[test]
fn box_with_constraints_restores_conditional_branch_after_toggle() {
assert_box_with_constraints_branch_toggle(
"Stories",
"Thread",
"restored branch should render again",
move |show_thread| {
BoxWithConstraints(Modifier::empty().fill_max_size(), move |_scope| {
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Text("Header", Modifier::empty(), TextStyle::default());
if show_thread.value() {
Text("Thread", Modifier::empty(), TextStyle::default());
} else {
Text("Stories", Modifier::empty(), TextStyle::default());
}
},
);
});
},
);
}
#[test]
fn box_with_constraints_restores_conditional_composable_branch_after_toggle() {
assert_box_with_constraints_branch_toggle(
"Stories",
"Thread",
"restored composable branch should render again",
move |show_thread| {
BoxWithConstraints(Modifier::empty().fill_max_size(), move |_scope| {
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Text("Header", Modifier::empty(), TextStyle::default());
if show_thread.value() {
PrimitiveThreadBranch();
} else {
PrimitiveStoriesBranch();
}
},
);
});
},
);
}
#[test]
fn box_with_constraints_restores_conditional_lazy_list_branch_after_toggle() {
assert_box_with_constraints_branch_toggle(
"Stories",
"Thread",
"restored lazy-list branch should render again",
move |show_thread| {
let list_state = remember_lazy_list_state();
BoxWithConstraints(Modifier::empty().fill_max_size(), move |_scope| {
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Text("Header", Modifier::empty(), TextStyle::default());
if show_thread.value() {
PrimitiveThreadBranch();
} else {
PrimitiveStoriesListBranch(list_state);
}
},
);
});
},
);
}
#[test]
fn box_with_constraints_restored_lazy_list_branch_keeps_host_generation_during_scroll() {
let mut composition = Composition::new(MemoryApplier::new());
let runtime = composition.runtime_handle();
let selected_story = cranpose_core::MutableState::with_runtime(None::<u64>, runtime.clone());
CONDITIONAL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = None;
});
CONDITIONAL_LAZY_LIST_NODE_ID.with(|slot| {
*slot.borrow_mut() = None;
});
composition
.render(location_key(file!(), line!(), column!()), || {
let list_state = remember_lazy_list_state();
BoxWithConstraints(Modifier::empty().fill_max_size(), move |_scope| {
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Text("Header", Modifier::empty(), TextStyle::default());
let single_pane_key = selected_story.value();
cranpose_core::with_key(&single_pane_key, move || {
if single_pane_key.is_some() {
PrimitiveThreadPaneLike(
Modifier::empty().fill_max_width().weight(1.0),
);
} else {
PrimitiveScrollReactiveStoriesBranch(list_state);
}
});
},
);
});
})
.expect("initial render");
let root = composition.root().expect("root node");
let size = Size {
width: 260.0,
height: 320.0,
};
settle_scroll_recomposition(&mut composition, root, size);
let list_state = captured_conditional_lazy_list_state();
let fresh_node_id = captured_conditional_lazy_list_node_id();
let fresh_generation = node_generation(&mut composition, fresh_node_id);
list_state.dispatch_scroll_delta(-120.0);
settle_scroll_recomposition(&mut composition, root, size);
let fresh_after_node_id = captured_conditional_lazy_list_node_id();
let fresh_after_generation = node_generation(&mut composition, fresh_after_node_id);
assert_eq!(
(fresh_after_node_id, fresh_after_generation),
(fresh_node_id, fresh_generation),
"fresh scroll should not recreate the lazy list host"
);
selected_story.set_value(Some(1));
while composition
.process_invalid_scopes()
.expect("switch to thread branch")
{
settle_scroll_recomposition(&mut composition, root, size);
}
settle_scroll_recomposition(&mut composition, root, size);
selected_story.set_value(None);
while composition
.process_invalid_scopes()
.expect("restore stories branch")
{
settle_scroll_recomposition(&mut composition, root, size);
}
settle_scroll_recomposition(&mut composition, root, size);
let restored_node_id = captured_conditional_lazy_list_node_id();
let restored_generation = node_generation(&mut composition, restored_node_id);
let mut seen = Vec::new();
for _ in 0..4 {
list_state.dispatch_scroll_delta(-120.0);
settle_scroll_recomposition(&mut composition, root, size);
let node_id = captured_conditional_lazy_list_node_id();
let generation = node_generation(&mut composition, node_id);
seen.push((node_id, generation));
}
assert_eq!(
seen,
vec![(restored_node_id, restored_generation); seen.len()],
"restored lazy-list host changed across scroll-driven recompositions; restored=({restored_node_id}, {restored_generation}) seen={seen:?}",
);
}
#[test]
fn box_with_constraints_restored_weighted_lazy_list_branch_keeps_host_generation_during_scroll() {
let mut composition = Composition::new(MemoryApplier::new());
let runtime = composition.runtime_handle();
let selected_story = cranpose_core::MutableState::with_runtime(None::<u64>, runtime.clone());
CONDITIONAL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = None;
});
CONDITIONAL_LAZY_LIST_NODE_ID.with(|slot| {
*slot.borrow_mut() = None;
});
composition
.render(location_key(file!(), line!(), column!()), || {
let list_state = remember_lazy_list_state();
BoxWithConstraints(Modifier::empty().fill_max_size(), move |_scope| {
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(12.0)),
move || {
Text("Header", Modifier::empty(), TextStyle::default());
let single_pane_key = selected_story.value();
cranpose_core::with_key(&single_pane_key, move || {
if single_pane_key.is_some() {
PrimitiveThreadPaneLike(
Modifier::empty().fill_max_width().weight(1.0),
);
} else {
PrimitiveStoriesPaneWithIndicator(
Modifier::empty().fill_max_width().weight(1.0),
list_state,
);
}
});
},
);
});
})
.expect("initial render");
let root = composition.root().expect("root node");
let size = Size {
width: 260.0,
height: 360.0,
};
settle_scroll_recomposition(&mut composition, root, size);
let list_state = captured_conditional_lazy_list_state();
let fresh_node_id = captured_conditional_lazy_list_node_id();
let fresh_generation = node_generation(&mut composition, fresh_node_id);
list_state.dispatch_scroll_delta(-120.0);
settle_scroll_recomposition(&mut composition, root, size);
let fresh_after_node_id = captured_conditional_lazy_list_node_id();
let fresh_after_generation = node_generation(&mut composition, fresh_after_node_id);
assert_eq!(
(fresh_after_node_id, fresh_after_generation),
(fresh_node_id, fresh_generation),
"fresh weighted scroll should not recreate the lazy list host"
);
selected_story.set_value(Some(1));
while composition
.process_invalid_scopes()
.expect("switch to thread branch")
{
settle_scroll_recomposition(&mut composition, root, size);
}
settle_scroll_recomposition(&mut composition, root, size);
selected_story.set_value(None);
while composition
.process_invalid_scopes()
.expect("restore stories branch")
{
settle_scroll_recomposition(&mut composition, root, size);
}
settle_scroll_recomposition(&mut composition, root, size);
let restored_node_id = captured_conditional_lazy_list_node_id();
let restored_generation = node_generation(&mut composition, restored_node_id);
let mut seen = Vec::new();
for _ in 0..4 {
list_state.dispatch_scroll_delta(-120.0);
settle_scroll_recomposition(&mut composition, root, size);
let node_id = captured_conditional_lazy_list_node_id();
let generation = node_generation(&mut composition, node_id);
seen.push((node_id, generation));
}
assert_eq!(
seen,
vec![(restored_node_id, restored_generation); seen.len()],
"restored weighted lazy-list host changed across scroll-driven recompositions; restored=({restored_node_id}, {restored_generation}) seen={seen:?}",
);
}
#[test]
fn box_with_constraints_restored_weighted_lazy_list_branch_keeps_host_during_status_recompose() {
let mut composition = Composition::new(MemoryApplier::new());
let runtime = composition.runtime_handle();
let selected_story = cranpose_core::MutableState::with_runtime(None::<u64>, runtime.clone());
let loaded_count = cranpose_core::MutableState::with_runtime(20usize, runtime.clone());
CONDITIONAL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = None;
});
CONDITIONAL_LAZY_LIST_NODE_ID.with(|slot| {
*slot.borrow_mut() = None;
});
composition
.render(location_key(file!(), line!(), column!()), || {
let list_state = remember_lazy_list_state();
BoxWithConstraints(Modifier::empty().fill_max_size(), move |_scope| {
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(12.0)),
move || {
Text("Header", Modifier::empty(), TextStyle::default());
let single_pane_key = selected_story.value();
cranpose_core::with_key(&single_pane_key, move || {
if single_pane_key.is_some() {
PrimitiveThreadPaneLike(
Modifier::empty().fill_max_width().weight(1.0),
);
} else {
PrimitiveStoriesPaneWithDynamicStatus(
Modifier::empty().fill_max_width().weight(1.0),
list_state,
loaded_count,
);
}
});
},
);
});
})
.expect("initial render");
let root = composition.root().expect("root node");
let size = Size {
width: 260.0,
height: 360.0,
};
settle_scroll_recomposition(&mut composition, root, size);
selected_story.set_value(Some(1));
while composition
.process_invalid_scopes()
.expect("switch to thread branch")
{
settle_scroll_recomposition(&mut composition, root, size);
}
settle_scroll_recomposition(&mut composition, root, size);
selected_story.set_value(None);
while composition
.process_invalid_scopes()
.expect("restore stories branch")
{
settle_scroll_recomposition(&mut composition, root, size);
}
settle_scroll_recomposition(&mut composition, root, size);
let restored_node_id = captured_conditional_lazy_list_node_id();
let restored_generation = node_generation(&mut composition, restored_node_id);
loaded_count.set_value(40);
settle_scroll_recomposition(&mut composition, root, size);
let after_node_id = captured_conditional_lazy_list_node_id();
let after_generation = node_generation(&mut composition, after_node_id);
assert_eq!(
(after_node_id, after_generation),
(restored_node_id, restored_generation),
"restored weighted lazy-list host changed during a plain status-text recomposition; restored=({restored_node_id}, {restored_generation}) after=({after_node_id}, {after_generation})",
);
}
#[test]
fn box_with_constraints_restored_weighted_branch_after_header_toggle_keeps_host_during_scroll() {
let mut composition = Composition::new(MemoryApplier::new());
let runtime = composition.runtime_handle();
let selected_story = cranpose_core::MutableState::with_runtime(None::<u64>, runtime.clone());
CONDITIONAL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = None;
});
CONDITIONAL_LAZY_LIST_NODE_ID.with(|slot| {
*slot.borrow_mut() = None;
});
composition
.render(location_key(file!(), line!(), column!()), || {
let list_state = remember_lazy_list_state();
BoxWithConstraints(Modifier::empty().fill_max_size(), move |_scope| {
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(12.0)),
move || {
PrimitiveHeaderWithOptionalBack(selected_story.value().is_some());
let single_pane_key = selected_story.value();
cranpose_core::with_key(&single_pane_key, move || {
if single_pane_key.is_some() {
PrimitiveThreadPaneLike(
Modifier::empty().fill_max_width().weight(1.0),
);
} else {
PrimitiveStoriesPaneWithIndicator(
Modifier::empty().fill_max_width().weight(1.0),
list_state,
);
}
});
},
);
});
})
.expect("initial render");
let root = composition.root().expect("root node");
let size = Size {
width: 260.0,
height: 360.0,
};
settle_scroll_recomposition(&mut composition, root, size);
let list_state = captured_conditional_lazy_list_state();
let fresh_node_id = captured_conditional_lazy_list_node_id();
let fresh_generation = node_generation(&mut composition, fresh_node_id);
list_state.dispatch_scroll_delta(-120.0);
settle_scroll_recomposition(&mut composition, root, size);
assert_eq!(
(
captured_conditional_lazy_list_node_id(),
node_generation(&mut composition, captured_conditional_lazy_list_node_id())
),
(fresh_node_id, fresh_generation),
"fresh scroll should not recreate the lazy list host",
);
selected_story.set_value(Some(1));
while composition
.process_invalid_scopes()
.expect("switch to thread branch")
{
settle_scroll_recomposition(&mut composition, root, size);
}
settle_scroll_recomposition(&mut composition, root, size);
selected_story.set_value(None);
while composition
.process_invalid_scopes()
.expect("restore stories branch")
{
settle_scroll_recomposition(&mut composition, root, size);
}
settle_scroll_recomposition(&mut composition, root, size);
let restored_node_id = captured_conditional_lazy_list_node_id();
let restored_generation = node_generation(&mut composition, restored_node_id);
let mut seen = Vec::new();
for _ in 0..4 {
list_state.dispatch_scroll_delta(-120.0);
settle_scroll_recomposition(&mut composition, root, size);
let node_id = captured_conditional_lazy_list_node_id();
let generation = node_generation(&mut composition, node_id);
seen.push((node_id, generation));
}
assert_eq!(
seen,
vec![(restored_node_id, restored_generation); seen.len()],
"restored weighted branch after header toggle changed host across scroll-driven recompositions; restored=({restored_node_id}, {restored_generation}) seen={seen:?}",
);
}
#[test]
fn box_with_constraints_restores_weighted_layout_branch_after_toggle() {
assert_box_with_constraints_branch_toggle(
"Top stories",
"Thread body",
"restored weighted layout branch should render again",
move |show_thread| {
BoxWithConstraints(Modifier::empty().fill_max_size(), move |_scope| {
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Text("Header", Modifier::empty(), TextStyle::default());
if show_thread.value() {
PrimitiveThreadPaneLike(Modifier::empty().fill_max_width().weight(1.0));
} else {
PrimitiveStoriesPaneLike(
Modifier::empty().fill_max_width().weight(1.0),
);
}
},
);
});
},
);
}
#[test]
fn test_fill_max_width_respects_parent_bounds() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let column_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let row_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let column_id_render = Rc::clone(&column_id);
let row_id_render = Rc::clone(&row_id);
composition
.render(key, move || {
let column_capture = Rc::clone(&column_id_render);
let row_capture = Rc::clone(&row_id_render);
*column_capture.borrow_mut() = Some(Column(
Modifier::empty()
.fill_max_width()
.then(Modifier::empty().padding(20.0)),
ColumnSpec::default(),
move || {
let row_inner = Rc::clone(&row_capture);
*row_inner.borrow_mut() = Some(Row(
Modifier::empty()
.fill_max_width()
.then(Modifier::empty().padding(8.0)),
RowSpec::default(),
move || {
Text(
"Button 1",
Modifier::empty().padding(4.0),
TextStyle::default(),
);
Text(
"Button 2",
Modifier::empty().padding(4.0),
TextStyle::default(),
);
},
));
},
));
})
.expect("initial render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 800.0,
height: 600.0,
},
)
.expect("compute layout");
let root_layout = layout_tree.root();
fn find_layout(node: &LayoutBox, target: NodeId) -> Option<&LayoutBox> {
if node.node_id == target {
return Some(node);
}
node.children
.iter()
.find_map(|child| find_layout(child, target))
}
let column_node_id = column_id
.borrow()
.as_ref()
.copied()
.expect("column node id");
let row_node_id = row_id.borrow().as_ref().copied().expect("row node id");
let column_layout = find_layout(root_layout, column_node_id).expect("column layout");
let row_layout = find_layout(root_layout, row_node_id).expect("row layout");
println!("\n=== Layout Debug ===");
println!(
"Root: x={}, y={}, width={}, height={}",
root_layout.rect.x, root_layout.rect.y, root_layout.rect.width, root_layout.rect.height
);
println!(
"Column: x={}, y={}, width={}, height={}",
column_layout.rect.x,
column_layout.rect.y,
column_layout.rect.width,
column_layout.rect.height
);
println!(
"Row: x={}, y={}, width={}, height={}",
row_layout.rect.x, row_layout.rect.y, row_layout.rect.width, row_layout.rect.height
);
println!(
"Column inner width (after padding): {}",
column_layout.rect.width - 40.0
);
println!(
"Row right edge: {}",
row_layout.rect.x + row_layout.rect.width
);
println!(
"Column right inner edge: {}",
column_layout.rect.x + column_layout.rect.width - 20.0
);
const EPSILON: f32 = 0.001;
assert!(
(row_layout.rect.width - 760.0).abs() < EPSILON,
"Row should be 760px wide (Column inner width): actual={}",
row_layout.rect.width
);
let row_right = row_layout.rect.x + row_layout.rect.width;
let column_right_inner = column_layout.rect.x + column_layout.rect.width - 20.0;
assert!(
row_right <= column_right_inner + EPSILON,
"Row overflows Column: Row right edge={} > Column inner right={}",
row_right,
column_right_inner
);
}
#[test]
fn test_fill_max_width_with_background_and_double_padding() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let outer_column_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let inner_column_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let row_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let outer_column_render = Rc::clone(&outer_column_id);
let inner_column_render = Rc::clone(&inner_column_id);
let row_render = Rc::clone(&row_id);
composition
.render(key, move || {
let outer_capture = Rc::clone(&outer_column_render);
let inner_capture = Rc::clone(&inner_column_render);
let row_capture = Rc::clone(&row_render);
*outer_capture.borrow_mut() = Some(Column(
Modifier::empty().padding(32.0),
ColumnSpec::default(),
move || {
let inner_cap2 = Rc::clone(&inner_capture);
let row_cap2 = Rc::clone(&row_capture);
*inner_cap2.borrow_mut() = Some(Column(
Modifier::empty().width(360.0),
ColumnSpec::default(),
move || {
let row_cap3 = Rc::clone(&row_cap2);
*row_cap3.borrow_mut() = Some(Row(
Modifier::empty()
.fill_max_width()
.then(Modifier::empty().padding(8.0))
.then(
Modifier::empty()
.background(crate::Color(0.1, 0.1, 0.15, 0.6)),
)
.then(Modifier::empty().padding(8.0)),
RowSpec::default(),
move || {
Text(
"OK",
Modifier::empty().padding(4.0),
TextStyle::default(),
);
Text(
"Cancel",
Modifier::empty().padding(4.0),
TextStyle::default(),
);
},
));
},
));
},
));
})
.expect("initial render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 800.0,
height: 600.0,
},
)
.expect("compute layout");
let root_layout = layout_tree.root();
fn find_layout(node: &LayoutBox, target: NodeId) -> Option<&LayoutBox> {
if node.node_id == target {
return Some(node);
}
node.children
.iter()
.find_map(|child| find_layout(child, target))
}
let outer_column_node = outer_column_id
.borrow()
.as_ref()
.copied()
.expect("outer column");
let inner_column_node = inner_column_id
.borrow()
.as_ref()
.copied()
.expect("inner column");
let row_node = row_id.borrow().as_ref().copied().expect("row");
let outer_layout = find_layout(root_layout, outer_column_node).expect("outer column layout");
let inner_layout = find_layout(root_layout, inner_column_node).expect("inner column layout");
let row_layout = find_layout(root_layout, row_node).expect("row layout");
println!("\n=== Counter App Structure Test ===");
println!("Window: 800px");
println!(
"Outer Column (padding 32): x={}, width={}",
outer_layout.rect.x, outer_layout.rect.width
);
println!(
"Inner Column (width 360): x={}, width={}",
inner_layout.rect.x, inner_layout.rect.width
);
println!(
"Row (fill_max_width): x={}, width={}",
row_layout.rect.x, row_layout.rect.width
);
println!(
"Row right edge: {}",
row_layout.rect.x + row_layout.rect.width
);
println!(
"Inner Column right edge: {}",
inner_layout.rect.x + inner_layout.rect.width
);
const EPSILON: f32 = 0.001;
assert!(
(inner_layout.rect.width - 360.0).abs() < EPSILON,
"Inner Column should be 360px: got {}",
inner_layout.rect.width
);
assert!(
(row_layout.rect.width - 360.0).abs() < EPSILON,
"Row should be 360px (Inner Column width): got {}",
row_layout.rect.width
);
let row_right = row_layout.rect.x + row_layout.rect.width;
let column_right = inner_layout.rect.x + inner_layout.rect.width;
assert!(
row_right <= column_right + EPSILON,
"Row overflows Inner Column: row_right={} > column_right={}",
row_right,
column_right
);
}
#[test]
fn fill_max_width_tracks_bounded_parent_width() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let outer_column_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let inner_column_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let row_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let outer_render = Rc::clone(&outer_column_id);
let inner_render = Rc::clone(&inner_column_id);
let row_render = Rc::clone(&row_id);
composition
.render(key, move || {
let outer_cap = Rc::clone(&outer_render);
let inner_cap = Rc::clone(&inner_render);
let row_cap = Rc::clone(&row_render);
*outer_cap.borrow_mut() = Some(Column(
Modifier::empty(),
ColumnSpec::default(),
move || {
let inner_cap2 = Rc::clone(&inner_cap);
let row_cap2 = Rc::clone(&row_cap);
*inner_cap2.borrow_mut() = Some(Column(
Modifier::empty(),
ColumnSpec::default(),
move || {
let row_cap3 = Rc::clone(&row_cap2);
*row_cap3.borrow_mut() = Some(Row(
Modifier::empty().fill_max_width(),
RowSpec::default(),
move || {
Spacer(Size {
width: 100.0,
height: 20.0,
});
Spacer(Size {
width: 100.0,
height: 20.0,
});
},
));
},
));
},
));
})
.expect("initial render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 800.0,
height: 600.0,
},
)
.expect("compute layout");
let root_layout = layout_tree.root();
fn find_layout(node: &LayoutBox, target: NodeId) -> Option<&LayoutBox> {
if node.node_id == target {
return Some(node);
}
node.children
.iter()
.find_map(|child| find_layout(child, target))
}
let outer_node = outer_column_id.borrow().as_ref().copied().expect("outer");
let inner_node = inner_column_id.borrow().as_ref().copied().expect("inner");
let row_node = row_id.borrow().as_ref().copied().expect("row");
let outer_layout = find_layout(root_layout, outer_node).expect("outer layout");
let inner_layout = find_layout(root_layout, inner_node).expect("inner layout");
let row_layout = find_layout(root_layout, row_node).expect("row layout");
println!("\n=== Fill Propagation Test ===");
println!("Window: 800px");
println!("Row content: 200px (100 + 100)");
println!(
"Outer Column (should wrap): width={}",
outer_layout.rect.width
);
println!(
"Inner Column (should wrap): width={}",
inner_layout.rect.width
);
println!("Row (fill_max_width): width={}", row_layout.rect.width);
const EPSILON: f32 = 0.001;
assert!(
(outer_layout.rect.width - 800.0).abs() < EPSILON,
"Outer Column should respect the bounded parent width (800px), got {}",
outer_layout.rect.width
);
assert!(
(inner_layout.rect.width - 800.0).abs() < EPSILON,
"Inner Column should respect the bounded parent width (800px), got {}",
inner_layout.rect.width
);
assert!(
(row_layout.rect.width - 800.0).abs() < EPSILON,
"Row should fill the bounded parent width (800px), got {}",
row_layout.rect.width
);
}
#[test]
fn wrap_column_with_fill_child_uses_bounded_width() {
const EPSILON: f32 = 1e-3;
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let column_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let row_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let first_chip_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let second_chip_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let column_capture = Rc::clone(&column_id);
let row_capture = Rc::clone(&row_id);
let first_chip_capture = Rc::clone(&first_chip_id);
let second_chip_capture = Rc::clone(&second_chip_id);
composition
.render(key, move || {
let row_capture = Rc::clone(&row_capture);
let first_chip_capture = Rc::clone(&first_chip_capture);
let second_chip_capture = Rc::clone(&second_chip_capture);
*column_capture.borrow_mut() = Some(Column(
Modifier::empty().padding(10.0),
ColumnSpec::default(),
move || {
let row_inner = Rc::clone(&row_capture);
let first_inner = Rc::clone(&first_chip_capture);
let second_inner = Rc::clone(&second_chip_capture);
*row_inner.borrow_mut() = Some(Row(
Modifier::empty().fill_max_width(),
RowSpec::default(),
move || {
*first_inner.borrow_mut() = Some(Spacer(Size {
width: 80.0,
height: 24.0,
}));
*second_inner.borrow_mut() = Some(Spacer(Size {
width: 40.0,
height: 24.0,
}));
},
));
},
));
})
.expect("initial render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 640.0,
height: 480.0,
},
)
.expect("compute layout");
fn find_layout(node: &LayoutBox, target: NodeId) -> Option<&LayoutBox> {
if node.node_id == target {
return Some(node);
}
node.children
.iter()
.find_map(|child| find_layout(child, target))
}
let root_layout = layout_tree.root();
let column_layout = find_layout(
root_layout,
column_id
.borrow()
.as_ref()
.copied()
.expect("column node id"),
)
.expect("column layout");
let row_layout = find_layout(
root_layout,
row_id.borrow().as_ref().copied().expect("row node id"),
)
.expect("row layout");
let first_chip_layout = find_layout(
root_layout,
first_chip_id
.borrow()
.as_ref()
.copied()
.expect("first chip id"),
)
.expect("first chip layout");
let second_chip_layout = find_layout(
root_layout,
second_chip_id
.borrow()
.as_ref()
.copied()
.expect("second chip id"),
)
.expect("second chip layout");
assert!(
(column_layout.rect.width - 640.0).abs() < EPSILON,
"Column width should match the bounded width (640px), got {:.3}",
column_layout.rect.width
);
assert!(
(row_layout.rect.width - 620.0).abs() < EPSILON,
"Row width should fill the column's inner width (620px), got {:.3}",
row_layout.rect.width
);
assert!(
(row_layout.rect.x - 10.0).abs() < EPSILON,
"Row x expected 10px from column padding, got {:.3}",
row_layout.rect.x
);
assert!(
(first_chip_layout.rect.width - 80.0).abs() < EPSILON,
"First chip width expected 80px, got {:.3}",
first_chip_layout.rect.width
);
assert!(
(second_chip_layout.rect.width - 40.0).abs() < EPSILON,
"Second chip width expected 40px, got {:.3}",
second_chip_layout.rect.width
);
}
#[test]
fn fill_child_respects_explicit_parent_width() {
const EPSILON: f32 = 1e-3;
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let column_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let row_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let column_capture = Rc::clone(&column_id);
let row_capture = Rc::clone(&row_id);
composition
.render(key, move || {
let row_capture = Rc::clone(&row_capture);
*column_capture.borrow_mut() = Some(Column(
Modifier::empty().width(200.0),
ColumnSpec::default(),
move || {
let row_inner = Rc::clone(&row_capture);
*row_inner.borrow_mut() = Some(Row(
Modifier::empty().fill_max_width(),
RowSpec::default(),
move || {
Spacer(Size {
width: 60.0,
height: 32.0,
});
Spacer(Size {
width: 40.0,
height: 32.0,
});
},
));
},
));
})
.expect("initial render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 480.0,
height: 320.0,
},
)
.expect("compute layout");
fn find_layout(node: &LayoutBox, target: NodeId) -> Option<&LayoutBox> {
if node.node_id == target {
return Some(node);
}
node.children
.iter()
.find_map(|child| find_layout(child, target))
}
let root_layout = layout_tree.root();
let column_layout = find_layout(
root_layout,
column_id
.borrow()
.as_ref()
.copied()
.expect("column node id"),
)
.expect("column layout");
let row_layout = find_layout(
root_layout,
row_id.borrow().as_ref().copied().expect("row node id"),
)
.expect("row layout");
assert!(
(column_layout.rect.width - 200.0).abs() < EPSILON,
"Column width expected 200px, got {:.3}",
column_layout.rect.width
);
assert!(
(row_layout.rect.width - 200.0).abs() < EPSILON,
"Row width expected to expand to parent width (200px), got {:.3}",
row_layout.rect.width
);
assert!(
(row_layout.rect.height - 32.0).abs() < EPSILON,
"Row height expected to match spacer height (32px), got {:.3}",
row_layout.rect.height
);
}
#[test]
fn fill_max_height_child_clamps_to_parent() {
const EPSILON: f32 = 1e-3;
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let row_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let fill_column_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let leaf_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let row_capture = Rc::clone(&row_id);
let fill_column_capture = Rc::clone(&fill_column_id);
let leaf_capture = Rc::clone(&leaf_id);
composition
.render(key, move || {
let fill_column_capture = Rc::clone(&fill_column_capture);
let leaf_capture = Rc::clone(&leaf_capture);
*row_capture.borrow_mut() = Some(Row(
Modifier::empty().height(180.0),
RowSpec::default(),
move || {
let fill_column_inner = Rc::clone(&fill_column_capture);
let leaf_inner = Rc::clone(&leaf_capture);
*fill_column_inner.borrow_mut() = Some(Column(
Modifier::empty().fill_max_height(),
ColumnSpec::default(),
move || {
*leaf_inner.borrow_mut() = Some(Spacer(Size {
width: 60.0,
height: 40.0,
}));
},
));
},
));
})
.expect("initial render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 400.0,
height: 240.0,
},
)
.expect("compute layout");
fn find_layout(node: &LayoutBox, target: NodeId) -> Option<&LayoutBox> {
if node.node_id == target {
return Some(node);
}
node.children
.iter()
.find_map(|child| find_layout(child, target))
}
let root_layout = layout_tree.root();
let row_layout = find_layout(
root_layout,
row_id.borrow().as_ref().copied().expect("row id"),
)
.expect("row layout");
let fill_column_layout = find_layout(
root_layout,
fill_column_id
.borrow()
.as_ref()
.copied()
.expect("fill column id"),
)
.expect("fill column layout");
let leaf_layout = find_layout(
root_layout,
leaf_id.borrow().as_ref().copied().expect("leaf id"),
)
.expect("leaf layout");
assert!(
(row_layout.rect.height - 180.0).abs() < EPSILON,
"Row height expected 180px, got {:.3}",
row_layout.rect.height
);
assert!(
(fill_column_layout.rect.height - 180.0).abs() < EPSILON,
"Fill column height expected to clamp to parent (180px), got {:.3}",
fill_column_layout.rect.height
);
assert!(
(leaf_layout.rect.height - 40.0).abs() < EPSILON,
"Leaf spacer height expected 40px, got {:.3}",
leaf_layout.rect.height
);
}
#[test]
fn modifier_chain_text_with_padding() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let text_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let text_id_render = Rc::clone(&text_id);
composition
.render(key, move || {
*text_id_render.borrow_mut() = Some(Text(
"Hello",
Modifier::empty().padding(10.0),
TextStyle::default(),
));
})
.expect("render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 640.0,
height: 480.0,
},
)
.expect("compute layout");
let text_node_id = text_id.borrow().expect("text node id");
let text_layout = find_node_layout(layout_tree.root(), text_node_id).expect("text layout");
assert!(
text_layout.rect.width > 35.0 && text_layout.rect.width < 100.0,
"Text with padding width should be text_width + 20, got {:.3}",
text_layout.rect.width
);
assert!(
text_layout.rect.height > 16.0 && text_layout.rect.height < 50.0,
"Text with padding height should be text_height + 20, got {:.3}",
text_layout.rect.height
);
}
#[test]
fn modifier_chain_size_enforcement() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let spacer_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let spacer_id_render = Rc::clone(&spacer_id);
composition
.render(key, move || {
*spacer_id_render.borrow_mut() = Some(Box(
Modifier::empty().size(Size {
width: 50.0,
height: 50.0,
}),
BoxSpec::default(),
|| {
Spacer(Size {
width: 100.0,
height: 100.0,
});
},
));
})
.expect("render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 640.0,
height: 480.0,
},
)
.expect("compute layout");
let spacer_node_id = spacer_id.borrow().expect("spacer node id");
let spacer_layout =
find_node_layout(layout_tree.root(), spacer_node_id).expect("spacer layout");
const EPSILON: f32 = 1e-3;
assert!(
(spacer_layout.rect.width - 50.0).abs() < EPSILON,
"Size modifier should enforce width=50, got {:.3}",
spacer_layout.rect.width
);
assert!(
(spacer_layout.rect.height - 50.0).abs() < EPSILON,
"Size modifier should enforce height=50, got {:.3}",
spacer_layout.rect.height
);
}
#[test]
fn modifier_chain_padding_then_size() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let node_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let node_id_render = Rc::clone(&node_id);
composition
.render(key, move || {
*node_id_render.borrow_mut() = Some(Box(
Modifier::empty().padding(10.0).size(Size {
width: 100.0,
height: 80.0,
}),
BoxSpec::default(),
|| {
Spacer(Size {
width: 200.0,
height: 150.0,
});
},
));
})
.expect("render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 640.0,
height: 480.0,
},
)
.expect("compute layout");
let layout_node_id = node_id.borrow().expect("node id");
let layout = find_node_layout(layout_tree.root(), layout_node_id).expect("layout");
const EPSILON: f32 = 1e-3;
assert!(
(layout.rect.width - 120.0).abs() < EPSILON,
"padding(10).size(100, 80) should give width=120 (100+20), got {:.3}",
layout.rect.width
);
assert!(
(layout.rect.height - 100.0).abs() < EPSILON,
"padding(10).size(100, 80) should give height=100 (80+20), got {:.3}",
layout.rect.height
);
}
#[test]
fn modifier_chain_size_then_padding() {
let mut composition = Composition::new(MemoryApplier::new());
let key = location_key(file!(), line!(), column!());
let node_id: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
let node_id_render = Rc::clone(&node_id);
composition
.render(key, move || {
*node_id_render.borrow_mut() = Some(Box(
Modifier::empty()
.size(Size {
width: 100.0,
height: 80.0,
})
.padding(10.0),
BoxSpec::default(),
|| {
Spacer(Size {
width: 200.0,
height: 150.0,
});
},
));
})
.expect("render");
let root = composition.root().expect("root node");
let layout_tree = composition
.applier_mut()
.compute_layout(
root,
Size {
width: 640.0,
height: 480.0,
},
)
.expect("compute layout");
let layout_node_id = node_id.borrow().expect("node id");
let layout = find_node_layout(layout_tree.root(), layout_node_id).expect("layout");
const EPSILON: f32 = 1e-3;
assert!(
(layout.rect.width - 100.0).abs() < EPSILON,
"size(100, 80).padding(10) should give width=100, got {:.3}",
layout.rect.width
);
assert!(
(layout.rect.height - 80.0).abs() < EPSILON,
"size(100, 80).padding(10) should give height=80, got {:.3}",
layout.rect.height
);
}
fn find_node_layout(tree: &LayoutBox, target: NodeId) -> Option<LayoutBox> {
if tree.node_id == target {
return Some(tree.clone());
}
for child in &tree.children {
if let Some(layout) = find_node_layout(child, target) {
return Some(layout);
}
}
None
}