use super::*;
use cranpose_core::{
__launched_effect_async_impl as launched_effect_async_impl, compositionLocalOf, location_key,
useState, CompositionLocal, CompositionLocalProvider, MutableState,
};
use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
use cranpose_foundation::{PointerEvent, PointerEventKind};
use cranpose_macros::composable;
use cranpose_ui::{
Alignment, BlendMode, Box, BoxSpec, Brush, Button, ButtonSpec, Color, Column, ColumnSpec,
CornerRadii, HeadlessRenderer, IntrinsicSize, LazyColumn, LazyColumnSpec, LinearArrangement,
Modifier, PointerInputScope, Rect, RenderOp, Row, RowSpec, ScrollState, Size, Spacer, Text,
TextStyle, VerticalAlignment,
};
use cranpose_ui_graphics::{
CompositingStrategy, DrawPrimitive, GraphicsLayer, Point, RenderEffect, RoundedCornerShape,
RuntimeShader,
};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::Duration;
fn test_guard() -> MutexGuard<'static, ()> {
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
match TEST_LOCK.get_or_init(|| Mutex::new(())).lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn reset_public_render_state_for_test() {}
fn layout_tree_texts(tree: &cranpose_ui::LayoutTree) -> Vec<String> {
fn collect(node: &cranpose_ui::LayoutBox, out: &mut Vec<String>) {
if let Some(text) = node.node_data.modifier_slices().text_content() {
out.push(text.to_string());
}
for child in &node.children {
collect(child, out);
}
}
let mut texts = Vec::new();
collect(tree.root(), &mut texts);
texts
}
fn semantics_tree_descriptions(tree: &cranpose_ui::SemanticsTree) -> Vec<String> {
fn collect(node: &cranpose_ui::SemanticsNode, out: &mut Vec<String>) {
if let Some(description) = &node.description {
out.push(description.clone());
}
for child in &node.children {
collect(child, out);
}
}
let mut descriptions = Vec::new();
collect(tree.root(), &mut descriptions);
descriptions
}
fn find_layout_box_with_text<'a>(
node: &'a cranpose_ui::LayoutBox,
text: &str,
) -> Option<&'a cranpose_ui::LayoutBox> {
if node.node_data.modifier_slices().text_content() == Some(text) {
return Some(node);
}
node.children
.iter()
.find_map(|child| find_layout_box_with_text(child, text))
}
fn click_text<R>(shell: &mut AppShell<R>, text: &str)
where
R: Renderer,
R::Error: std::fmt::Debug,
{
shell.update();
let layout_tree = shell.layout_tree().expect("layout tree available");
let node = find_layout_box_with_text(layout_tree.root(), text)
.unwrap_or_else(|| panic!("text {text:?} not found in layout tree"));
let center_x = node.rect.x + node.rect.width * 0.5;
let center_y = node.rect.y + node.rect.height * 0.5;
assert!(
shell.set_cursor(center_x, center_y),
"set_cursor should hover a hit target for {text:?}"
);
shell.update();
assert!(shell.pointer_pressed(), "pointer down should hit {text:?}");
shell.update();
assert!(
shell.pointer_released(),
"pointer up should dispatch to {text:?}"
);
shell.update();
}
fn click_text_like_robot<R>(shell: &mut AppShell<R>, text: &str)
where
R: Renderer,
R::Error: std::fmt::Debug,
{
shell.update();
let layout_tree = shell.layout_tree().expect("layout tree available");
let node = find_layout_box_with_text(layout_tree.root(), text)
.unwrap_or_else(|| panic!("text {text:?} not found in layout tree"));
let center_x = node.rect.x + node.rect.width * 0.5;
let center_y = node.rect.y + node.rect.height * 0.5;
assert!(
shell.set_cursor(center_x, center_y),
"set_cursor should hover a hit target for {text:?}"
);
assert!(shell.pointer_pressed(), "pointer down should hit {text:?}");
shell.update();
assert!(
shell.pointer_released(),
"pointer up should dispatch to {text:?}"
);
shell.update();
}
fn pump_like_robot<R>(shell: &mut AppShell<R>)
where
R: Renderer,
R::Error: std::fmt::Debug,
{
if shell.needs_redraw() || shell.has_active_animations() {
shell.update();
}
}
fn live_slot_count(slots: &[cranpose_core::SlotDebugEntry]) -> usize {
slots.len()
}
thread_local! {
static APP_SHELL_LAZY_LIST_STATE: RefCell<Option<LazyListState>> = const { RefCell::new(None) };
}
thread_local! {
static APP_SHELL_FRAME_TIME_RECORDS: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
}
thread_local! {
static APP_SHELL_CONTINUOUS_FRAME_COUNT: Cell<u32> = const { Cell::new(0) };
}
thread_local! {
static APP_SHELL_INITIAL_DENSITIES: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
}
thread_local! {
static APP_SHELL_WHEEL_SCROLL_STATE: RefCell<Option<ScrollState>> = const { RefCell::new(None) };
}
#[composable]
#[allow(non_snake_case)]
fn AppShellCaptureInitialDensity() {
APP_SHELL_INITIAL_DENSITIES.with(|densities| {
densities.borrow_mut().push(cranpose_ui::current_density());
});
}
#[composable]
#[allow(non_snake_case)]
fn AppShellScrollIndicatorLazyList() {
let list_state = remember_lazy_list_state();
APP_SHELL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = Some(list_state);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Text(
format!("First visible {}", list_state.first_visible_item_index()),
Modifier::empty(),
TextStyle::default(),
);
LazyColumn(
Modifier::empty().fill_max_width().weight(1.0),
list_state,
LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
|scope| {
scope.items(
80,
None::<fn(usize) -> u64>,
None::<fn(usize) -> u64>,
|index| {
Text(
format!("Row {}", index),
Modifier::empty().height(48.0),
TextStyle::default(),
);
},
);
},
);
},
);
}
#[composable]
#[allow(non_snake_case)]
fn AppShellChildFirstVisible(list_state: LazyListState) {
Text(
format!(
"Child first visible {}",
list_state.first_visible_item_index()
),
Modifier::empty(),
TextStyle::default(),
);
}
#[composable]
#[allow(non_snake_case)]
fn AppShellChildStats(list_state: LazyListState) {
let stats = list_state.stats();
Text(
format!("Child visible {}", stats.items_in_use),
Modifier::empty(),
TextStyle::default(),
);
}
#[composable]
#[allow(non_snake_case)]
fn AppShellSiblingIndicatorsLazyList() {
let list_state = remember_lazy_list_state();
APP_SHELL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = Some(list_state);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
AppShellChildStats(list_state);
AppShellChildFirstVisible(list_state);
LazyColumn(
Modifier::empty().fill_max_width().weight(1.0),
list_state,
LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
|scope| {
scope.items(
80,
None::<fn(usize) -> u64>,
None::<fn(usize) -> u64>,
|index| {
Text(
format!("Row {}", index),
Modifier::empty().height(48.0),
TextStyle::default(),
);
},
);
},
);
},
);
}
#[composable]
#[allow(non_snake_case)]
fn AppShellVariableHeightSiblingIndicatorsLazyList() {
let list_state = remember_lazy_list_state();
APP_SHELL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = Some(list_state);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
AppShellChildStats(list_state);
AppShellChildFirstVisible(list_state);
LazyColumn(
Modifier::empty().fill_max_width().weight(1.0),
list_state,
LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
|scope| {
scope.items(
100,
None::<fn(usize) -> u64>,
None::<fn(usize) -> u64>,
|index| {
Text(
format!("Row {}", index),
Modifier::empty().height(48.0 + (index % 5) as f32 * 8.0),
TextStyle::default(),
);
},
);
},
);
},
);
}
#[composable]
#[allow(non_snake_case)]
fn AppShellLifecycleCountDisplay(count: MutableState<usize>) {
Text(
format!("Lifecycle count {}", count.get()),
Modifier::empty(),
TextStyle::default(),
);
}
#[composable]
#[allow(non_snake_case)]
fn AppShellLifecycleListItem(index: usize, count: MutableState<usize>) {
cranpose_core::DisposableEffect!(index, move |_| {
count.update(|current| *current += 1);
cranpose_core::DisposableEffectResult::new(|| {})
});
Text(
format!("Row {}", index),
Modifier::empty().height(48.0 + (index % 5) as f32 * 8.0),
TextStyle::default(),
);
}
#[composable]
#[allow(non_snake_case)]
fn AppShellLifecycleIndicatorsLazyList() {
let list_state = remember_lazy_list_state();
APP_SHELL_LAZY_LIST_STATE.with(|slot| {
*slot.borrow_mut() = Some(list_state);
});
let lifecycle_count = cranpose_core::useState(|| 0usize);
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
AppShellLifecycleCountDisplay(lifecycle_count);
AppShellChildStats(list_state);
AppShellChildFirstVisible(list_state);
let lifecycle_count_for_items = lifecycle_count;
LazyColumn(
Modifier::empty().fill_max_width().weight(1.0),
list_state,
LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
move |scope| {
let lifecycle_count = lifecycle_count_for_items;
scope.items(
100,
None::<fn(usize) -> u64>,
None::<fn(usize) -> u64>,
move |index| {
AppShellLifecycleListItem(index, lifecycle_count);
},
);
},
);
},
);
}
thread_local! {
static APP_SHELL_ACTIVE_TAB_STATE: RefCell<Option<MutableState<i32>>> = const { RefCell::new(None) };
static APP_SHELL_COUNTER_STATE: RefCell<Option<MutableState<i32>>> = const { RefCell::new(None) };
static FRAME_STABLE_HANDLER_MODE: RefCell<Option<MutableState<bool>>> = const { RefCell::new(None) };
static FRAME_STABLE_RENDERED_CLICKS: RefCell<Option<MutableState<i32>>> = const { RefCell::new(None) };
static FRAME_STABLE_PENDING_CLICKS: RefCell<Option<MutableState<i32>>> = const { RefCell::new(None) };
static ROOT_RENDER_TEST_INVALIDATED: Cell<bool> = const { Cell::new(false) };
}
#[composable]
#[allow(non_snake_case)]
fn AppShellKeyedSiblingIndicatorsRoot() {
let active = cranpose_core::useState(|| 0i32);
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| {
*slot.borrow_mut() = Some(active);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Text(
format!("Tab {}", active.get()),
Modifier::empty(),
TextStyle::default(),
);
cranpose_core::with_key(&active.get(), || {
AppShellSiblingIndicatorsLazyList();
});
},
);
}
#[composable]
#[allow(non_snake_case)]
fn AppShellSwitchingKeyedLazyListRoot() {
let active = cranpose_core::useState(|| 0i32);
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| {
*slot.borrow_mut() = Some(active);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
cranpose_core::with_key(&active.get(), || {
if active.get() == 0 {
Text("Counter branch", Modifier::empty(), TextStyle::default());
} else {
AppShellSiblingIndicatorsLazyList();
}
});
},
);
}
fn app_shell_local_count() -> CompositionLocal<i32> {
thread_local! {
static LOCAL: RefCell<Option<CompositionLocal<i32>>> = const { RefCell::new(None) };
}
LOCAL.with(|cell| {
let mut cell = cell.borrow_mut();
if cell.is_none() {
*cell = Some(compositionLocalOf(|| 0));
}
cell.as_ref()
.expect("app shell local count initialized")
.clone()
})
}
#[composable]
fn callbackless_root_render_probe(render_count: Rc<Cell<usize>>) {
let root_trigger = useState(|| false);
render_count.set(render_count.get() + 1);
cranpose_core::with_key(&"root-render-probe", || {
let _ = root_trigger.value();
});
Text(
format!("Render {}", render_count.get()),
Modifier::empty(),
TextStyle::default(),
);
cranpose_core::SideEffect(move || {
let already_invalidated = ROOT_RENDER_TEST_INVALIDATED.with(|flag| flag.replace(true));
if already_invalidated {
return;
}
root_trigger.set_value(true);
});
}
#[composable]
#[allow(non_snake_case)]
fn AppShellAnimatedLazyItem() {
let list_state = remember_lazy_list_state();
LazyColumn(
Modifier::empty().fill_max_width().height(120.0),
list_state,
LazyColumnSpec::default(),
|scope| {
scope.item(Some(0), None, || {
let pulse = useState(|| 0u32);
let run_token = useState(|| 0u64);
let current_run_token = run_token.value();
let pulse_for_effect = pulse;
launched_effect_async_impl(
location_key(file!(), line!(), column!()),
current_run_token,
move |scope| {
let pulse = pulse_for_effect;
Box::pin(async move {
if current_run_token == 0 {
return;
}
let clock = scope.runtime().frame_clock();
while scope.is_active() {
let _ = clock.next_frame().await;
if !scope.is_active() {
break;
}
pulse.set_value(pulse.get_non_reactive().wrapping_add(1));
}
})
},
);
cranpose_core::SideEffect(move || {
if run_token.value() == 0 {
run_token.set_value(1);
}
});
let pulse_value = pulse.value();
Text(
format!("Lazy Pulse: {pulse_value}"),
Modifier::empty().height(24.0),
TextStyle::default(),
);
});
},
);
}
fn first_semantics_description_with_prefix(
shell: &mut AppShell<TestRenderer>,
prefix: &str,
) -> Option<String> {
semantics_tree_descriptions(shell.semantics_tree()?)
.into_iter()
.find(|description| description.starts_with(prefix))
}
#[derive(Default, Clone)]
struct TestHitTarget;
impl HitTestTarget for TestHitTarget {
fn dispatch(&self, _event: PointerEvent) {}
fn node_id(&self) -> cranpose_core::NodeId {
0
}
}
#[derive(Default)]
struct TestScene;
impl RenderScene for TestScene {
type HitTarget = TestHitTarget;
fn clear(&mut self) {}
fn hit_test(&self, _x: f32, _y: f32) -> Vec<Self::HitTarget> {
vec![]
}
fn find_target(&self, _node_id: cranpose_core::NodeId) -> Option<Self::HitTarget> {
None
}
}
#[derive(Clone)]
struct RecordingHitTarget {
node_id: cranpose_core::NodeId,
consume: bool,
events: Rc<RefCell<Vec<PointerEvent>>>,
capture_path: Vec<cranpose_core::NodeId>,
}
impl HitTestTarget for RecordingHitTarget {
fn dispatch(&self, event: PointerEvent) {
self.events.borrow_mut().push(event.clone());
if self.consume {
event.consume();
}
}
fn node_id(&self) -> cranpose_core::NodeId {
self.node_id
}
fn capture_path(&self) -> Vec<cranpose_core::NodeId> {
self.capture_path.clone()
}
}
#[derive(Default)]
struct RecordingScene {
hits: Vec<RecordingHitTarget>,
hit_node_ids: Option<Vec<cranpose_core::NodeId>>,
}
impl RecordingScene {
fn with_hits(hits: Vec<RecordingHitTarget>) -> Self {
Self {
hits,
hit_node_ids: None,
}
}
fn with_hit_node_ids(
hits: Vec<RecordingHitTarget>,
hit_node_ids: Vec<cranpose_core::NodeId>,
) -> Self {
Self {
hits,
hit_node_ids: Some(hit_node_ids),
}
}
}
impl RenderScene for RecordingScene {
type HitTarget = RecordingHitTarget;
fn clear(&mut self) {}
fn hit_test(&self, _x: f32, _y: f32) -> Vec<Self::HitTarget> {
if let Some(hit_node_ids) = &self.hit_node_ids {
return self
.hits
.iter()
.filter(|target| hit_node_ids.contains(&target.node_id))
.cloned()
.collect();
}
self.hits.clone()
}
fn find_target(&self, node_id: cranpose_core::NodeId) -> Option<Self::HitTarget> {
self.hits
.iter()
.find(|target| target.node_id == node_id)
.cloned()
}
}
#[derive(Clone)]
struct AppContextProbeHitTarget {
node_id: cranpose_core::NodeId,
densities: Rc<RefCell<Vec<f32>>>,
}
impl HitTestTarget for AppContextProbeHitTarget {
fn dispatch(&self, _event: PointerEvent) {
self.densities
.borrow_mut()
.push(cranpose_ui::current_density());
}
fn node_id(&self) -> cranpose_core::NodeId {
self.node_id
}
fn capture_path(&self) -> Vec<cranpose_core::NodeId> {
vec![self.node_id]
}
}
struct AppContextProbeScene {
target: AppContextProbeHitTarget,
}
impl RenderScene for AppContextProbeScene {
type HitTarget = AppContextProbeHitTarget;
fn clear(&mut self) {}
fn hit_test(&self, _x: f32, _y: f32) -> Vec<Self::HitTarget> {
vec![self.target.clone()]
}
fn find_target(&self, node_id: cranpose_core::NodeId) -> Option<Self::HitTarget> {
(node_id == self.target.node_id).then(|| self.target.clone())
}
}
struct AppContextProbeRenderer {
scene: AppContextProbeScene,
}
impl Renderer for AppContextProbeRenderer {
type Scene = AppContextProbeScene;
type Error = ();
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
_layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
_applier: &mut cranpose_core::MemoryApplier,
_root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
}
#[derive(Clone)]
struct MutableRecordingScene {
hits: Rc<RefCell<Vec<RecordingHitTarget>>>,
}
impl MutableRecordingScene {
fn new(hits: Rc<RefCell<Vec<RecordingHitTarget>>>) -> Self {
Self { hits }
}
}
impl RenderScene for MutableRecordingScene {
type HitTarget = RecordingHitTarget;
fn clear(&mut self) {}
fn hit_test(&self, _x: f32, _y: f32) -> Vec<Self::HitTarget> {
self.hits.borrow().clone()
}
fn find_target(&self, node_id: cranpose_core::NodeId) -> Option<Self::HitTarget> {
self.hits
.borrow()
.iter()
.find(|target| target.node_id == node_id)
.cloned()
}
}
struct FixedWidthTextMeasurer(f32);
impl cranpose_ui::TextMeasurer for FixedWidthTextMeasurer {
fn measure(
&self,
_text: &cranpose_ui::text::AnnotatedString,
_style: &cranpose_ui::TextStyle,
) -> cranpose_ui::TextMetrics {
cranpose_ui::TextMetrics {
width: self.0,
height: 1.0,
line_height: 1.0,
line_count: 1,
}
}
fn get_offset_for_position(
&self,
_text: &cranpose_ui::text::AnnotatedString,
_style: &cranpose_ui::TextStyle,
_x: f32,
_y: f32,
) -> usize {
0
}
fn get_cursor_x_for_offset(
&self,
_text: &cranpose_ui::text::AnnotatedString,
_style: &cranpose_ui::TextStyle,
_offset: usize,
) -> f32 {
self.0
}
fn layout(
&self,
text: &cranpose_ui::text::AnnotatedString,
_style: &cranpose_ui::TextStyle,
) -> cranpose_ui::TextLayoutResult {
cranpose_ui::TextLayoutResult::monospaced(&text.text, self.0, 1.0)
}
}
struct CountingFixedWidthTextMeasurer {
width: f32,
measure_calls: Rc<Cell<usize>>,
}
impl CountingFixedWidthTextMeasurer {
fn new(width: f32, measure_calls: Rc<Cell<usize>>) -> Self {
Self {
width,
measure_calls,
}
}
}
impl cranpose_ui::TextMeasurer for CountingFixedWidthTextMeasurer {
fn measure(
&self,
text: &cranpose_ui::text::AnnotatedString,
style: &cranpose_ui::TextStyle,
) -> cranpose_ui::TextMetrics {
self.measure_calls.set(self.measure_calls.get() + 1);
FixedWidthTextMeasurer(self.width).measure(text, style)
}
fn get_offset_for_position(
&self,
text: &cranpose_ui::text::AnnotatedString,
style: &cranpose_ui::TextStyle,
x: f32,
y: f32,
) -> usize {
FixedWidthTextMeasurer(self.width).get_offset_for_position(text, style, x, y)
}
fn get_cursor_x_for_offset(
&self,
text: &cranpose_ui::text::AnnotatedString,
style: &cranpose_ui::TextStyle,
offset: usize,
) -> f32 {
FixedWidthTextMeasurer(self.width).get_cursor_x_for_offset(text, style, offset)
}
fn layout(
&self,
text: &cranpose_ui::text::AnnotatedString,
style: &cranpose_ui::TextStyle,
) -> cranpose_ui::TextLayoutResult {
FixedWidthTextMeasurer(self.width).layout(text, style)
}
}
#[derive(Default)]
struct TestRenderer {
scene: TestScene,
text_width: Option<f32>,
text_measure_calls: Option<Rc<Cell<usize>>>,
}
impl TestRenderer {
fn with_text_width(width: f32) -> Self {
Self {
scene: TestScene,
text_width: Some(width),
text_measure_calls: None,
}
}
fn with_counting_text_width(width: f32, measure_calls: Rc<Cell<usize>>) -> Self {
Self {
scene: TestScene,
text_width: Some(width),
text_measure_calls: Some(measure_calls),
}
}
}
impl Renderer for TestRenderer {
type Scene = TestScene;
type Error = ();
fn attach_app_context_services(&mut self, app_context: &cranpose_ui::AppContext) {
if let Some(text_width) = self.text_width {
if let Some(measure_calls) = self.text_measure_calls.clone() {
app_context.set_text_measurer(CountingFixedWidthTextMeasurer::new(
text_width,
measure_calls,
));
} else {
app_context.set_text_measurer(FixedWidthTextMeasurer(text_width));
}
}
}
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
_layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
_applier: &mut cranpose_core::MemoryApplier,
_root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
}
struct WarmupRenderer {
scene: TestScene,
needs_warmup: Rc<Cell<bool>>,
}
impl Renderer for WarmupRenderer {
type Scene = TestScene;
type Error = ();
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
_layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
_applier: &mut cranpose_core::MemoryApplier,
_root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
fn needs_frame_warmup(&self) -> bool {
self.needs_warmup.get()
}
}
#[test]
fn renderer_warmup_keeps_frame_schedule_until_renderer_clears_it() {
let _guard = test_guard();
let needs_warmup = Rc::new(Cell::new(true));
let mut shell = AppShell::new(
WarmupRenderer {
scene: TestScene,
needs_warmup: Rc::clone(&needs_warmup),
},
location_key(file!(), line!(), column!()),
|| {},
);
shell.update();
assert!(shell.needs_redraw());
let warmup_schedule = shell.frame_schedule();
assert!(
!warmup_schedule.needs_update,
"renderer warmup requests a frame, not UI update work"
);
assert!(warmup_schedule.needs_frame);
needs_warmup.set(false);
shell.update();
assert!(!shell.needs_redraw());
assert!(!shell.frame_schedule().needs_frame);
}
#[test]
fn two_app_shells_do_not_share_density_or_render_invalidations() {
let _guard = test_guard();
reset_public_render_state_for_test();
let mut first = AppShell::new(
TestRenderer::default(),
location_key(file!(), line!(), column!()),
|| {},
);
let mut second = AppShell::new(
TestRenderer::default(),
location_key(file!(), line!(), column!()),
|| {},
);
first.update();
second.update();
assert!(!first.needs_redraw());
assert!(!second.needs_redraw());
first.set_density(2.0);
assert_eq!(first.debug_current_density(), 2.0);
assert_eq!(second.debug_current_density(), 1.0);
assert!(first.needs_redraw());
assert!(first.frame_schedule().needs_frame);
assert!(!second.needs_redraw());
assert!(!second.frame_schedule().needs_frame);
first.update();
assert!(!first.needs_redraw());
assert!(!first.frame_schedule().needs_frame);
first.set_frame_pacing_mode(FramePacingMode::Hard60);
assert!(first.needs_redraw());
assert!(first.frame_schedule().needs_frame);
assert!(!second.needs_redraw());
}
#[test]
fn app_shell_initial_composition_uses_constructor_density() {
let _guard = test_guard();
APP_SHELL_INITIAL_DENSITIES.with(|densities| densities.borrow_mut().clear());
let shell = AppShell::new_with_size_and_density(
TestRenderer::default(),
location_key(file!(), line!(), column!()),
AppShellCaptureInitialDensity,
(1600, 900),
(800.0, 450.0),
2.0,
);
assert_eq!(shell.debug_current_density(), 2.0);
let observed = APP_SHELL_INITIAL_DENSITIES.with(|densities| densities.borrow().clone());
assert!(
!observed.is_empty(),
"initial composition should read density during shell construction"
);
assert!(
observed.iter().all(|density| *density == 2.0),
"initial composition densities should all use constructor density, got {observed:?}"
);
}
#[test]
fn app_shell_scene_rebuilds_after_backdrop_effect_state_change() {
let _guard = test_guard();
APP_SHELL_BACKDROP_RADIUS_STATE.with(|slot| {
slot.borrow_mut().take();
});
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_backdrop_radius_content,
);
shell.update();
let initial_radii = graph_backdrop_blur_radii(
&shell
.scene()
.graph
.as_ref()
.expect("initial graph should be built")
.root,
);
assert_eq!(initial_radii, vec![0.0]);
let radius = APP_SHELL_BACKDROP_RADIUS_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("radius state should be registered");
radius.set_value(18.0);
shell.update();
let updated_radii = graph_backdrop_blur_radii(
&shell
.scene()
.graph
.as_ref()
.expect("updated graph should be built")
.root,
);
assert_eq!(updated_radii, vec![18.0]);
}
#[test]
fn two_app_shells_do_not_share_fps_stats() {
let _guard = test_guard();
reset_public_render_state_for_test();
let mut first = AppShell::new(
TestRenderer::default(),
location_key(file!(), line!(), column!()),
|| {},
);
let second = AppShell::new(
TestRenderer::default(),
location_key(file!(), line!(), column!()),
|| {},
);
let first_start = first.fps_stats().frame_count;
let second_start = second.fps_stats().frame_count;
first.record_presented_frame_for_test(16_000_000, 18_000_000);
first.record_presented_frame_for_test(32_000_000, 34_000_000);
assert_eq!(first.fps_stats().frame_count, first_start + 2);
assert_eq!(second.fps_stats().frame_count, second_start);
}
#[test]
fn app_shell_idle_updates_do_not_advance_presented_frame_stats() {
let _guard = test_guard();
reset_public_render_state_for_test();
let mut shell = AppShell::new(
TestRenderer::default(),
location_key(file!(), line!(), column!()),
|| {},
);
let frame_count = shell.fps_stats().frame_count;
shell.update_at_frame_time_nanos(16_000_000);
shell.update_at_frame_time_nanos(32_000_000);
assert_eq!(
shell.fps_stats().frame_count,
frame_count,
"AppShell update work is not a presented redraw and must not mutate FPS stats"
);
}
#[test]
fn two_app_shells_do_not_share_text_measurers() {
let _guard = test_guard();
reset_public_render_state_for_test();
let first = AppShell::new(
TestRenderer::with_text_width(11.0),
location_key(file!(), line!(), column!()),
|| {},
);
let second = AppShell::new(
TestRenderer::with_text_width(29.0),
location_key(file!(), line!(), column!()),
|| {},
);
let text = cranpose_ui::text::AnnotatedString::from("same text");
let style = cranpose_ui::TextStyle::default();
let first_width =
first.debug_enter_app_context(|| cranpose_ui::measure_text(&text, &style).width);
let second_width =
second.debug_enter_app_context(|| cranpose_ui::measure_text(&text, &style).width);
let first_width_again =
first.debug_enter_app_context(|| cranpose_ui::measure_text(&text, &style).width);
assert_eq!(first_width, 11.0);
assert_eq!(second_width, 29.0);
assert_eq!(first_width_again, 11.0);
}
#[test]
fn two_app_shells_have_independent_text_caches() {
let _guard = test_guard();
reset_public_render_state_for_test();
let first_calls = Rc::new(Cell::new(0));
let second_calls = Rc::new(Cell::new(0));
let first = AppShell::new(
TestRenderer::with_counting_text_width(11.0, Rc::clone(&first_calls)),
location_key(file!(), line!(), column!()),
|| {},
);
let second = AppShell::new(
TestRenderer::with_counting_text_width(29.0, Rc::clone(&second_calls)),
location_key(file!(), line!(), column!()),
|| {},
);
let text = cranpose_ui::text::AnnotatedString::from("same cached text");
let style = cranpose_ui::TextStyle::default();
let first_width =
first.debug_enter_app_context(|| cranpose_ui::measure_text(&text, &style).width);
let first_width_again =
first.debug_enter_app_context(|| cranpose_ui::measure_text(&text, &style).width);
let second_width =
second.debug_enter_app_context(|| cranpose_ui::measure_text(&text, &style).width);
let second_width_again =
second.debug_enter_app_context(|| cranpose_ui::measure_text(&text, &style).width);
let first_width_after_second =
first.debug_enter_app_context(|| cranpose_ui::measure_text(&text, &style).width);
assert_eq!(first_width, 11.0);
assert_eq!(first_width_again, 11.0);
assert_eq!(second_width, 29.0);
assert_eq!(second_width_again, 29.0);
assert_eq!(first_width_after_second, 11.0);
assert_eq!(
first_calls.get(),
1,
"first shell should reuse its own text cache and not be invalidated by measuring in the second shell"
);
assert_eq!(
second_calls.get(),
1,
"second shell should populate and reuse an independent text cache"
);
}
#[test]
fn pointer_dispatch_enters_shell_app_context() {
let _guard = test_guard();
reset_public_render_state_for_test();
let densities = Rc::new(RefCell::new(Vec::new()));
let scene = AppContextProbeScene {
target: AppContextProbeHitTarget {
node_id: 1,
densities: densities.clone(),
},
};
let renderer = AppContextProbeRenderer { scene };
let mut shell = AppShell::new(renderer, location_key(file!(), line!(), column!()), || {});
shell.set_density(2.5);
assert!(shell.set_cursor(12.0, 24.0));
let observed = densities.borrow();
assert!(
!observed.is_empty(),
"pointer dispatch should reach the probe target"
);
assert!(
observed
.iter()
.all(|density| density.to_bits() == 2.5f32.to_bits()),
"pointer dispatch observed densities {observed:?}",
);
}
struct ScrollDispatchRenderer {
scene: RecordingScene,
}
impl ScrollDispatchRenderer {
fn new(scene: RecordingScene) -> Self {
Self { scene }
}
}
struct MutableRecordingRenderer {
scene: MutableRecordingScene,
}
impl MutableRecordingRenderer {
fn new(scene: MutableRecordingScene) -> Self {
Self { scene }
}
}
impl Renderer for MutableRecordingRenderer {
type Scene = MutableRecordingScene;
type Error = ();
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
_layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
_applier: &mut cranpose_core::MemoryApplier,
_root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
}
impl Renderer for ScrollDispatchRenderer {
type Scene = RecordingScene;
type Error = ();
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
_layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
_applier: &mut cranpose_core::MemoryApplier,
_root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
Ok(())
}
}
#[derive(Default)]
struct RecordingRenderer {
scene: TestScene,
last_scene: Option<cranpose_ui::RecordedRenderScene>,
}
impl Renderer for RecordingRenderer {
type Scene = TestScene;
type Error = ();
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
let renderer = HeadlessRenderer::new();
self.last_scene = Some(renderer.render(layout_tree));
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
applier: &mut cranpose_core::MemoryApplier,
root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
let renderer = HeadlessRenderer::new();
self.last_scene = Some(renderer.render_from_applier(applier, root));
Ok(())
}
}
struct CountingRenderer {
scene: TestScene,
rebuilds: Rc<Cell<usize>>,
overlay_texts: Rc<RefCell<Vec<String>>>,
}
impl CountingRenderer {
fn new(rebuilds: Rc<Cell<usize>>) -> Self {
Self::with_overlay_texts(rebuilds, Rc::new(RefCell::new(Vec::new())))
}
fn with_overlay_texts(
rebuilds: Rc<Cell<usize>>,
overlay_texts: Rc<RefCell<Vec<String>>>,
) -> Self {
Self {
scene: TestScene,
rebuilds,
overlay_texts,
}
}
}
impl Renderer for CountingRenderer {
type Scene = TestScene;
type Error = ();
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
_layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
self.rebuilds.set(self.rebuilds.get() + 1);
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
_applier: &mut cranpose_core::MemoryApplier,
_root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
self.rebuilds.set(self.rebuilds.get() + 1);
Ok(())
}
fn draw_dev_overlay(&mut self, text: &str, _viewport: Size) {
self.overlay_texts.borrow_mut().push(text.to_string());
}
}
struct ScopedUpdateCountingRenderer {
scene: cranpose_render_common::graph_scene::Scene,
rebuilds: Rc<Cell<usize>>,
updates: Rc<Cell<usize>>,
visual_updates: Rc<Cell<usize>>,
last_dirty_nodes: Rc<RefCell<Vec<cranpose_core::NodeId>>>,
}
impl ScopedUpdateCountingRenderer {
fn new(
rebuilds: Rc<Cell<usize>>,
updates: Rc<Cell<usize>>,
last_dirty_nodes: Rc<RefCell<Vec<cranpose_core::NodeId>>>,
) -> Self {
Self::with_visual_updates(rebuilds, updates, Rc::new(Cell::new(0)), last_dirty_nodes)
}
fn with_visual_updates(
rebuilds: Rc<Cell<usize>>,
updates: Rc<Cell<usize>>,
visual_updates: Rc<Cell<usize>>,
last_dirty_nodes: Rc<RefCell<Vec<cranpose_core::NodeId>>>,
) -> Self {
Self {
scene: cranpose_render_common::graph_scene::Scene::default(),
rebuilds,
updates,
visual_updates,
last_dirty_nodes,
}
}
}
impl Renderer for ScopedUpdateCountingRenderer {
type Scene = cranpose_render_common::graph_scene::Scene;
type Error = ();
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
self.rebuilds.set(self.rebuilds.get() + 1);
self.scene.clear();
let graph = cranpose_render_common::scene_builder::build_graph_from_layout_tree(
layout_tree.root(),
1.0,
);
self.scene.replace_graph(graph);
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
applier: &mut cranpose_core::MemoryApplier,
root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
self.rebuilds.set(self.rebuilds.get() + 1);
self.scene.clear();
if let Some(graph) =
cranpose_render_common::scene_builder::build_graph_from_applier(applier, root, 1.0)
{
self.scene.replace_graph(graph);
}
Ok(())
}
fn update_scene_from_applier(
&mut self,
applier: &mut cranpose_core::MemoryApplier,
root: cranpose_core::NodeId,
_viewport: Size,
dirty_nodes: &[cranpose_core::NodeId],
) -> Result<(), Self::Error> {
self.updates.set(self.updates.get() + 1);
*self.last_dirty_nodes.borrow_mut() = dirty_nodes.to_vec();
let updated = self.scene.graph.as_mut().is_some_and(|graph| {
cranpose_render_common::scene_builder::update_graph_from_applier(
applier,
graph,
dirty_nodes,
1.0,
)
});
if !updated {
self.scene.clear();
if let Some(graph) =
cranpose_render_common::scene_builder::build_graph_from_applier(applier, root, 1.0)
{
self.scene.replace_graph(graph);
}
}
Ok(())
}
fn update_visual_scene_from_applier(
&mut self,
applier: &mut cranpose_core::MemoryApplier,
root: cranpose_core::NodeId,
_viewport: Size,
dirty_nodes: &[cranpose_core::NodeId],
) -> Result<(), Self::Error> {
self.visual_updates.set(self.visual_updates.get() + 1);
*self.last_dirty_nodes.borrow_mut() = dirty_nodes.to_vec();
let updated = self.scene.graph.as_mut().is_some_and(|graph| {
cranpose_render_common::scene_builder::update_graph_from_applier(
applier,
graph,
dirty_nodes,
1.0,
)
});
if !updated {
self.scene.clear();
if let Some(graph) =
cranpose_render_common::scene_builder::build_graph_from_applier(applier, root, 1.0)
{
self.scene.replace_graph(graph);
}
}
Ok(())
}
}
#[derive(Default)]
struct HitGraphRenderer {
scene: cranpose_render_common::graph_scene::Scene,
}
fn collect_graph_hits(
layer: &cranpose_render_common::graph::LayerNode,
parent_transform: cranpose_render_common::graph::ProjectiveTransform,
scene: &mut cranpose_render_common::graph_scene::Scene,
parent_hit_clip: Option<Rect>,
) {
struct SceneHitSink<'a> {
scene: &'a mut cranpose_render_common::graph_scene::Scene,
}
impl cranpose_render_common::hit_graph::HitGraphSink for SceneHitSink<'_> {
fn push_hit(
&mut self,
node_id: cranpose_core::NodeId,
capture_path: &[cranpose_core::NodeId],
geometry: cranpose_render_common::graph_scene::HitGeometry,
shape: Option<cranpose_ui_graphics::RoundedCornerShape>,
click_actions: &[Rc<dyn Fn(Point)>],
pointer_inputs: &[Rc<dyn Fn(PointerEvent)>],
) {
self.scene.push_hit(
node_id,
capture_path.to_vec(),
geometry,
shape,
click_actions
.iter()
.cloned()
.map(cranpose_render_common::graph_scene::ClickAction::WithPoint)
.collect(),
pointer_inputs.to_vec(),
);
}
}
let mut sink = SceneHitSink { scene };
cranpose_render_common::hit_graph::collect_hits_from_graph(
layer,
parent_transform,
&mut sink,
parent_hit_clip,
);
}
impl Renderer for HitGraphRenderer {
type Scene = cranpose_render_common::graph_scene::Scene;
type Error = ();
fn scene(&self) -> &Self::Scene {
&self.scene
}
fn scene_mut(&mut self) -> &mut Self::Scene {
&mut self.scene
}
fn rebuild_scene(
&mut self,
layout_tree: &LayoutTree,
_viewport: Size,
) -> Result<(), Self::Error> {
self.scene.clear();
let graph = cranpose_render_common::scene_builder::build_graph_from_layout_tree(
layout_tree.root(),
1.0,
);
collect_graph_hits(
&graph.root,
cranpose_render_common::graph::ProjectiveTransform::identity(),
&mut self.scene,
None,
);
self.scene.replace_graph(graph);
Ok(())
}
fn rebuild_scene_from_applier(
&mut self,
applier: &mut cranpose_core::MemoryApplier,
root: cranpose_core::NodeId,
_viewport: Size,
) -> Result<(), Self::Error> {
self.scene.clear();
if let Some(graph) =
cranpose_render_common::scene_builder::build_graph_from_applier(applier, root, 1.0)
{
collect_graph_hits(
&graph.root,
cranpose_render_common::graph::ProjectiveTransform::identity(),
&mut self.scene,
None,
);
self.scene.replace_graph(graph);
}
Ok(())
}
}
thread_local! {
static APP_SHELL_BACKDROP_RADIUS_STATE: RefCell<Option<MutableState<f32>>> =
const { RefCell::new(None) };
}
#[composable]
fn app_shell_backdrop_radius_content() {
let radius = useState(|| 0.0f32);
APP_SHELL_BACKDROP_RADIUS_STATE.with(|slot| {
*slot.borrow_mut() = Some(radius);
});
Box(
Modifier::empty()
.size_points(128.0, 96.0)
.draw_behind(|scope| {
scope.draw_rect(Brush::solid(Color::from_rgba_u8(30, 40, 60, 255)));
}),
BoxSpec::default(),
move || {
Box(
Modifier::empty()
.absolute_offset(16.0, 16.0)
.size_points(96.0, 56.0)
.graphics_layer_value(GraphicsLayer {
render_effect: Some(RenderEffect::blur(0.0)),
..GraphicsLayer::default()
}),
BoxSpec::default(),
move || {
Box(
Modifier::empty()
.absolute_offset(40.0, 12.0)
.size_points(42.0, 30.0)
.backdrop_effect(RenderEffect::blur(radius.get())),
BoxSpec::default(),
|| {},
);
},
);
},
);
}
fn graph_backdrop_blur_radii(layer: &cranpose_render_common::graph::LayerNode) -> Vec<f32> {
fn collect(layer: &cranpose_render_common::graph::LayerNode, out: &mut Vec<f32>) {
if let Some(RenderEffect::Blur { radius_x, .. }) = &layer.graphics_layer.backdrop_effect {
out.push(*radius_x);
}
for child in &layer.children {
if let cranpose_render_common::graph::RenderNode::Layer(child_layer) = child {
collect(child_layer, out);
}
}
}
let mut radii = Vec::new();
collect(layer, &mut radii);
radii
}
#[composable]
fn tabbed_progress_content() {
let progress = useState(|| 0.6f32);
let active_tab = useState(|| 0i32);
let progress_effect = progress;
let active_effect = active_tab;
launched_effect_async_impl(
location_key(file!(), line!(), column!()),
(),
move |scope| {
let progress = progress_effect;
let active_tab = active_effect;
Box::pin(async move {
let clock = scope.runtime().frame_clock();
let mut phase: u32 = 0;
while scope.is_active() {
let _ = clock.next_frame().await;
if !scope.is_active() {
break;
}
match phase % 3 {
0 => {
progress.set_value(0.0);
active_tab.set_value(1);
}
1 => {
progress.set_value(0.85);
}
_ => {
active_tab.set_value(0);
}
}
phase = phase.wrapping_add(1);
}
})
},
);
Column(
Modifier::empty().padding(8.0),
ColumnSpec::default(),
move || {
Text(
format!("Progress {:.2}", progress.value()),
Modifier::empty().padding(2.0),
TextStyle::default(),
);
let progress_for_branch = progress;
let active_for_branch = active_tab;
Row(
Modifier::empty()
.padding(2.0)
.then(Modifier::empty().height(12.0)),
RowSpec::default(),
move || {
if active_for_branch.value() == 0 && progress_for_branch.value() > 0.0 {
let progress_for_bar = progress_for_branch;
Row(
Modifier::empty()
.width(160.0 * progress_for_bar.value())
.then(Modifier::empty().height(12.0)),
RowSpec::default(),
move || {
let _ = progress_for_bar.value();
},
);
}
},
);
},
);
}
#[composable]
fn empty_content() {}
#[composable]
fn one_shot_frame_request_content() {
let phase = useState(|| 0i32);
let phase_state = phase;
launched_effect_async_impl(
location_key(file!(), line!(), column!()),
(),
move |scope| {
let phase = phase_state;
Box::pin(async move {
let clock = scope.runtime().frame_clock();
let _ = clock.next_frame().await;
phase.set_value(1);
})
},
);
Text(
if phase.value() == 0 {
"Waiting For Frame"
} else {
"Frame Applied"
},
Modifier::empty(),
TextStyle::default(),
);
}
#[composable]
fn continuous_frame_request_tab() {
let tick = useState(|| 0u32);
let tick_state = tick;
launched_effect_async_impl(
location_key(file!(), line!(), column!()),
(),
move |scope| {
let tick = tick_state;
Box::pin(async move {
let clock = scope.runtime().frame_clock();
while scope.is_active() {
let _ = clock.next_frame().await;
if !scope.is_active() {
break;
}
APP_SHELL_CONTINUOUS_FRAME_COUNT
.with(|count| count.set(count.get().saturating_add(1)));
tick.update(|value| *value = value.wrapping_add(1));
}
})
},
);
Text(
format!("Animated tick {}", tick.value()),
Modifier::empty(),
TextStyle::default(),
);
}
#[composable]
fn app_shell_continuous_then_static_tab_host() {
let active = useState(|| 0i32);
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| {
*slot.borrow_mut() = Some(active);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
if active.value() == 0 {
continuous_frame_request_tab();
} else {
Text("Static tab", Modifier::empty(), TextStyle::default());
}
},
);
}
#[composable]
fn frame_time_recorder_content() {
launched_effect_async_impl(
location_key(file!(), line!(), column!()),
(),
move |scope| {
Box::pin(async move {
let clock = scope.runtime().frame_clock();
let first = clock.next_frame().await;
APP_SHELL_FRAME_TIME_RECORDS.with(|records| records.borrow_mut().push(first));
let second = clock.next_frame().await;
APP_SHELL_FRAME_TIME_RECORDS.with(|records| records.borrow_mut().push(second));
})
},
);
Text(
"Frame Time Recorder",
Modifier::empty(),
TextStyle::default(),
);
}
#[composable]
fn frame_stable_pointer_handler_content() {
let use_pending_handler = useState(|| false);
let rendered_clicks = useState(|| 0i32);
let pending_clicks = useState(|| 0i32);
FRAME_STABLE_HANDLER_MODE.with(|slot| {
*slot.borrow_mut() = Some(use_pending_handler);
});
FRAME_STABLE_RENDERED_CLICKS.with(|slot| {
*slot.borrow_mut() = Some(rendered_clicks);
});
FRAME_STABLE_PENDING_CLICKS.with(|slot| {
*slot.borrow_mut() = Some(pending_clicks);
});
let pending_handler = use_pending_handler.value();
let rendered_clicks_state = rendered_clicks;
let pending_clicks_state = pending_clicks;
Button(
Modifier::empty().padding(8.0),
ButtonSpec::default(),
move || {
if pending_handler {
pending_clicks_state.set_value(pending_clicks_state.value() + 1);
} else {
rendered_clicks_state.set_value(rendered_clicks_state.value() + 1);
}
},
move || {
Text(
if pending_handler {
"Pending Handler"
} else {
"Rendered Handler"
},
Modifier::empty(),
TextStyle::default(),
);
},
);
}
#[composable]
fn box_content() {
Box(
Modifier::empty().size(Size {
width: 24.0,
height: 24.0,
}),
BoxSpec::default(),
|| {},
);
}
#[composable]
fn semantics_content() {
Text(
"Semantics",
Modifier::empty().semantics(|config| {
config.content_description = Some("Semantics".into());
}),
TextStyle::default(),
);
}
#[composable]
fn nested_branch_content() {
Column(Modifier::empty(), ColumnSpec::default(), || {
Box(
Modifier::empty().size(Size {
width: 40.0,
height: 20.0,
}),
BoxSpec::default(),
|| {
Box(
Modifier::empty().size(Size {
width: 10.0,
height: 10.0,
}),
BoxSpec::default(),
|| {},
);
},
);
Box(
Modifier::empty().size(Size {
width: 50.0,
height: 20.0,
}),
BoxSpec::default(),
|| {
Box(
Modifier::empty().size(Size {
width: 11.0,
height: 11.0,
}),
BoxSpec::default(),
|| {},
);
},
);
});
}
#[composable]
fn draw_width_app(width_state: cranpose_core::MutableState<f32>) {
Box(
Modifier::empty()
.size(Size {
width: 200.0,
height: 40.0,
})
.draw_behind({
let width = width_state.get();
move |scope| {
scope.draw_rect_at(
Rect {
x: 0.0,
y: 0.0,
width,
height: 10.0,
},
Brush::solid(Color(0.9, 0.1, 0.1, 1.0)),
);
}
}),
BoxSpec::default(),
|| {},
);
}
fn draw_observed_width_app(width_state: cranpose_core::MutableState<f32>) {
Box(
Modifier::empty()
.size(Size {
width: 200.0,
height: 40.0,
})
.draw_behind(move |scope| {
let width = width_state.get();
scope.draw_rect_at(
Rect {
x: 0.0,
y: 0.0,
width,
height: 10.0,
},
Brush::solid(Color(0.2, 0.7, 0.3, 1.0)),
);
}),
BoxSpec::default(),
|| {},
);
}
#[composable]
fn graphics_layer_observed_offset_app(offset_state: cranpose_core::MutableState<f32>) {
Box(
Modifier::empty()
.size(Size {
width: 200.0,
height: 40.0,
})
.graphics_layer({
move || GraphicsLayer {
translation_x: offset_state.get(),
..Default::default()
}
})
.draw_behind(|scope| {
scope.draw_rect_at(
Rect {
x: 0.0,
y: 0.0,
width: 40.0,
height: 20.0,
},
Brush::solid(Color(0.4, 0.5, 0.9, 1.0)),
);
}),
BoxSpec::default(),
|| {},
);
}
#[composable]
fn graphics_layer_composed_offset_app(offset_state: cranpose_core::MutableState<f32>) {
let offset = offset_state.get();
Box(
Modifier::empty()
.size(Size {
width: 200.0,
height: 40.0,
})
.graphics_layer(move || GraphicsLayer {
translation_x: offset,
..Default::default()
})
.draw_behind(|scope| {
scope.draw_rect_at(
Rect {
x: 0.0,
y: 0.0,
width: 40.0,
height: 20.0,
},
Brush::solid(Color(0.4, 0.5, 0.9, 1.0)),
);
}),
BoxSpec::default(),
|| {},
);
}
fn shader_rect_like_effect(seed: usize, time: f32, intensity: f32) -> RenderEffect {
let mut shader = RuntimeShader::new("shader-rect-scoped-update-test");
shader.set_float(0, seed as f32);
shader.set_float(1, time);
shader.set_float(2, intensity);
RenderEffect::runtime_shader(shader)
}
#[composable]
fn shader_rect_like_effect_layers_app(
time_state: cranpose_core::MutableState<f32>,
intensity_state: cranpose_core::MutableState<f32>,
) {
let time = time_state.get();
let intensity = intensity_state.get();
Column(
Modifier::empty().fill_max_width(),
ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
for row in 0..2 {
Row(
Modifier::empty().fill_max_width(),
RowSpec::default(),
move || {
for col in 0..2 {
let seed = row * 2 + col;
let effect = shader_rect_like_effect(seed, time, intensity);
Box(
Modifier::empty()
.size(Size {
width: 96.0,
height: 48.0,
})
.graphics_layer({
let effect = effect.clone();
move || GraphicsLayer {
render_effect: Some(effect.clone()),
compositing_strategy: CompositingStrategy::Offscreen,
..Default::default()
}
})
.draw_behind(move |scope| {
scope.draw_rect_at(
Rect {
x: 0.0,
y: 0.0,
width: 96.0,
height: 48.0,
},
Brush::solid(Color(
0.1 + seed as f32 * 0.05,
0.2,
0.4,
1.0,
)),
);
}),
BoxSpec::new().content_alignment(Alignment::CENTER),
move || {
Text(
format!("Shader {seed}"),
Modifier::empty(),
TextStyle::default(),
);
},
);
}
},
);
}
},
);
}
#[composable]
fn shader_rect_like_lazy_effect_layers_app(
time_state: cranpose_core::MutableState<f32>,
intensity_state: cranpose_core::MutableState<f32>,
) {
Column(
Modifier::empty().fill_max_width(),
ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
for row in 0..2 {
Row(
Modifier::empty().fill_max_width(),
RowSpec::default(),
move || {
for col in 0..2 {
let seed = row * 2 + col;
Box(
Modifier::empty()
.size(Size {
width: 96.0,
height: 48.0,
})
.graphics_layer(move || {
let effect = shader_rect_like_effect(
seed,
time_state.get(),
intensity_state.get(),
);
GraphicsLayer {
render_effect: Some(effect),
compositing_strategy: CompositingStrategy::Offscreen,
..Default::default()
}
})
.draw_behind(move |scope| {
scope.draw_rect_at(
Rect {
x: 0.0,
y: 0.0,
width: 96.0,
height: 48.0,
},
Brush::solid(Color(
0.1 + seed as f32 * 0.05,
0.2,
0.4,
1.0,
)),
);
}),
BoxSpec::new().content_alignment(Alignment::CENTER),
move || {
Text(
format!("Shader {seed}"),
Modifier::empty(),
TextStyle::default(),
);
},
);
}
},
);
}
},
);
}
#[composable]
fn graphics_layer_observed_point_app(position_state: cranpose_core::MutableState<Point>) {
Box(
Modifier::empty()
.size(Size {
width: 200.0,
height: 40.0,
})
.graphics_layer({
move || {
let position = position_state.get();
GraphicsLayer {
translation_x: position.x,
translation_y: position.y,
..Default::default()
}
}
})
.draw_behind(|scope| {
scope.draw_rect_at(
Rect {
x: 0.0,
y: 0.0,
width: 40.0,
height: 20.0,
},
Brush::solid(Color(0.4, 0.5, 0.9, 1.0)),
);
}),
BoxSpec::default(),
|| {},
);
}
#[composable]
fn pointer_driven_graphics_layer_point_app(position_state: cranpose_core::MutableState<Point>) {
Box(
Modifier::empty()
.size(Size {
width: 320.0,
height: 220.0,
})
.background(Color(0.08, 0.10, 0.14, 1.0)),
BoxSpec::default(),
move || {
Box(
Modifier::empty()
.size(Size {
width: 100.0,
height: 80.0,
})
.graphics_layer({
move || {
let position = position_state.get();
GraphicsLayer {
translation_x: position.x,
translation_y: position.y,
..Default::default()
}
}
})
.draw_behind(|scope| {
scope.draw_rect_at(
Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 80.0,
},
Brush::solid(Color(0.4, 0.5, 0.9, 1.0)),
);
})
.pointer_input((), {
move |scope: PointerInputScope| async move {
scope
.await_pointer_event_scope(|await_scope| async move {
loop {
let event = await_scope.await_pointer_event().await;
match event.kind {
PointerEventKind::Down | PointerEventKind::Move => {
position_state.set(event.position);
event.consume();
}
PointerEventKind::Up
| PointerEventKind::Cancel
| PointerEventKind::Scroll
| PointerEventKind::Enter
| PointerEventKind::Exit => {}
}
}
})
.await;
}
}),
BoxSpec::default(),
|| {},
);
},
);
}
#[derive(Default)]
struct TextFieldDispatchProbe {
pasted_text: RefCell<Option<String>>,
cut_in_event_handler: Cell<bool>,
cut_in_applied_snapshot: Cell<bool>,
paste_in_event_handler: Cell<bool>,
paste_in_applied_snapshot: Cell<bool>,
preedit_text: RefCell<Option<(String, Option<(usize, usize)>)>>,
preedit_in_event_handler: Cell<bool>,
preedit_in_applied_snapshot: Cell<bool>,
last_delete: Cell<Option<(usize, usize)>>,
delete_in_event_handler: Cell<bool>,
delete_in_applied_snapshot: Cell<bool>,
}
impl cranpose_ui::text_field_focus::FocusedTextFieldHandler for TextFieldDispatchProbe {
fn handle_key(&self, _event: &cranpose_ui::KeyEvent) -> bool {
false
}
fn insert_text(&self, text: &str) {
self.pasted_text.replace(Some(text.to_string()));
self.paste_in_event_handler
.set(cranpose_core::in_event_handler());
self.paste_in_applied_snapshot
.set(cranpose_core::in_applied_snapshot());
}
fn delete_surrounding(&self, before_bytes: usize, after_bytes: usize) {
self.last_delete.set(Some((before_bytes, after_bytes)));
self.delete_in_event_handler
.set(cranpose_core::in_event_handler());
self.delete_in_applied_snapshot
.set(cranpose_core::in_applied_snapshot());
}
fn copy_selection(&self) -> Option<String> {
None
}
fn cut_selection(&self) -> Option<String> {
self.cut_in_event_handler
.set(cranpose_core::in_event_handler());
self.cut_in_applied_snapshot
.set(cranpose_core::in_applied_snapshot());
Some("cut text".to_string())
}
fn set_composition(&self, text: &str, cursor: Option<(usize, usize)>) {
self.preedit_text.replace(Some((text.to_string(), cursor)));
self.preedit_in_event_handler
.set(cranpose_core::in_event_handler());
self.preedit_in_applied_snapshot
.set(cranpose_core::in_applied_snapshot());
}
}
#[test]
fn layout_recovers_after_tab_switching_updates() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, || {
tabbed_progress_content()
});
let mut baseline_live_slots = None;
let mut peak_live_slots = 0usize;
for frame in 0..200 {
shell.update();
assert!(
shell.layout_tree().is_some(),
"layout_tree should remain available after update cycle {frame}"
);
let live_slots = live_slot_count(&shell.debug_slot_entries());
baseline_live_slots.get_or_insert(live_slots);
peak_live_slots = peak_live_slots.max(live_slots);
}
let baseline_live_slots = baseline_live_slots.expect("baseline live slot count");
assert!(
peak_live_slots <= baseline_live_slots + 64,
"tabbed progress updates leaked live slots: baseline={baseline_live_slots} peak={peak_live_slots}",
);
}
#[test]
fn ime_delete_surrounding_marks_dirty() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, empty_content);
shell.update();
assert!(!shell.needs_redraw());
let focus_flag = Rc::new(RefCell::new(false));
let handler = Rc::new(TextFieldDispatchProbe::default());
shell.debug_enter_app_context(|| {
cranpose_ui::text_field_focus::request_focus(Rc::clone(&focus_flag), handler.clone());
});
assert!(shell.on_ime_delete_surrounding(2, 1));
assert_eq!(handler.last_delete.get(), Some((2, 1)));
assert!(shell.needs_redraw());
shell.debug_enter_app_context(cranpose_ui::text_field_focus::clear_focus);
}
#[test]
fn text_mutation_platform_events_run_inside_event_and_applied_snapshot_scopes() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, empty_content);
shell.update();
let focus_flag = Rc::new(RefCell::new(false));
let handler = Rc::new(TextFieldDispatchProbe::default());
shell.debug_enter_app_context(|| {
cranpose_ui::text_field_focus::request_focus(Rc::clone(&focus_flag), handler.clone());
});
assert!(shell.on_paste("hello"));
assert_eq!(handler.pasted_text.borrow().as_deref(), Some("hello"));
assert!(handler.paste_in_event_handler.get());
assert!(handler.paste_in_applied_snapshot.get());
assert_eq!(shell.on_cut().as_deref(), Some("cut text"));
assert!(handler.cut_in_event_handler.get());
assert!(handler.cut_in_applied_snapshot.get());
assert!(shell.on_ime_preedit("preedit", Some((1, 4))));
assert_eq!(
handler.preedit_text.borrow().as_ref(),
Some(&("preedit".to_string(), Some((1, 4))))
);
assert!(handler.preedit_in_event_handler.get());
assert!(handler.preedit_in_applied_snapshot.get());
assert!(shell.on_ime_delete_surrounding(2, 1));
assert_eq!(handler.last_delete.get(), Some((2, 1)));
assert!(handler.delete_in_event_handler.get());
assert!(handler.delete_in_applied_snapshot.get());
shell.debug_enter_app_context(cranpose_ui::text_field_focus::clear_focus);
}
#[test]
fn pending_layout_request_skips_clean_tree_without_forcing_measure() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, || {
Text(
"steady".to_string(),
Modifier::empty(),
TextStyle::default(),
);
});
shell.scene_dirty = false;
shell.layout_requested = true;
shell.force_layout_pass = false;
shell.run_layout_phase();
assert!(
!shell.scene_dirty,
"clean trees should not trigger a fresh layout pass when the request is not forced",
);
assert!(!shell.layout_requested);
assert!(!shell.force_layout_pass);
}
#[test]
fn pointer_scrolled_dispatches_to_hovered_targets_and_respects_consumption() {
let _guard = test_guard();
let consumed_events = Rc::new(RefCell::new(Vec::new()));
let skipped_events = Rc::new(RefCell::new(Vec::new()));
let scene = RecordingScene::with_hits(vec![
RecordingHitTarget {
node_id: 1,
consume: true,
events: consumed_events.clone(),
capture_path: vec![1],
},
RecordingHitTarget {
node_id: 2,
consume: false,
events: skipped_events.clone(),
capture_path: vec![2],
},
]);
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(ScrollDispatchRenderer::new(scene), root_key, empty_content);
shell.set_cursor(20.0, 30.0);
consumed_events.borrow_mut().clear();
skipped_events.borrow_mut().clear();
let consumed = shell.pointer_scrolled(12.0, -18.0);
assert!(consumed, "wheel dispatch should report consumption");
assert_eq!(consumed_events.borrow().len(), 1);
assert_eq!(skipped_events.borrow().len(), 0);
let events = consumed_events.borrow();
let event = events.first().expect("expected scroll event");
assert_eq!(event.kind, PointerEventKind::Scroll);
assert_eq!(event.scroll_delta, Point { x: 12.0, y: -18.0 });
assert_eq!(event.global_position, Point { x: 20.0, y: 30.0 });
}
#[test]
fn pointer_scrolled_dispatches_capture_path_ancestors() {
let _guard = test_guard();
let child_events = Rc::new(RefCell::new(Vec::new()));
let ancestor_events = Rc::new(RefCell::new(Vec::new()));
let scene = RecordingScene::with_hit_node_ids(
vec![
RecordingHitTarget {
node_id: 1,
consume: false,
events: child_events.clone(),
capture_path: vec![1, 99],
},
RecordingHitTarget {
node_id: 99,
consume: true,
events: ancestor_events.clone(),
capture_path: vec![99],
},
],
vec![1],
);
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(ScrollDispatchRenderer::new(scene), root_key, empty_content);
shell.set_cursor(20.0, 30.0);
child_events.borrow_mut().clear();
ancestor_events.borrow_mut().clear();
let consumed = shell.pointer_scrolled(0.0, -42.0);
assert!(
consumed,
"wheel dispatch should report ancestor scroll consumption"
);
assert_eq!(child_events.borrow().len(), 1);
assert_eq!(
ancestor_events.borrow().len(),
1,
"scroll must bubble through capture-path ancestors"
);
}
#[test]
fn captured_gesture_cancels_when_original_targets_disappear() {
let _guard = test_guard();
let first_target_events = Rc::new(RefCell::new(Vec::new()));
let rebound_target_events = Rc::new(RefCell::new(Vec::new()));
let active_hits = Rc::new(RefCell::new(vec![RecordingHitTarget {
node_id: 1,
consume: false,
events: first_target_events.clone(),
capture_path: vec![1],
}]));
let root_key = location_key(file!(), line!(), column!());
let scene = MutableRecordingScene::new(active_hits.clone());
let mut shell = AppShell::new(
MutableRecordingRenderer::new(scene),
root_key,
empty_content,
);
shell.set_cursor(10.0, 10.0);
first_target_events.borrow_mut().clear();
assert!(
shell.pointer_pressed(),
"down should hit the original target"
);
assert_eq!(
first_target_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Down]
);
*active_hits.borrow_mut() = vec![RecordingHitTarget {
node_id: 2,
consume: false,
events: rebound_target_events.clone(),
capture_path: vec![2],
}];
assert!(
!shell.set_cursor(30.0, 30.0),
"move should cancel when no live captured node survives"
);
assert!(
!shell.pointer_released(),
"up should not replay detached handlers after the captured node disappears"
);
assert_eq!(
first_target_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Down]
);
assert!(
rebound_target_events.borrow().is_empty(),
"captured gesture must not retarget to a different live node"
);
}
#[test]
fn captured_gesture_continues_on_render_supplied_ancestor_when_child_disappears() {
let _guard = test_guard();
let child_events = Rc::new(RefCell::new(Vec::new()));
let ancestor_events = Rc::new(RefCell::new(Vec::new()));
let unrelated_events = Rc::new(RefCell::new(Vec::new()));
let active_hits = Rc::new(RefCell::new(vec![RecordingHitTarget {
node_id: 1,
consume: false,
events: child_events.clone(),
capture_path: vec![1, 99],
}]));
let root_key = location_key(file!(), line!(), column!());
let scene = MutableRecordingScene::new(active_hits.clone());
let mut shell = AppShell::new(
MutableRecordingRenderer::new(scene),
root_key,
empty_content,
);
shell.set_cursor(10.0, 10.0);
child_events.borrow_mut().clear();
ancestor_events.borrow_mut().clear();
unrelated_events.borrow_mut().clear();
assert!(
shell.pointer_pressed(),
"down should hit the original child target"
);
assert_eq!(
child_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Down]
);
*active_hits.borrow_mut() = vec![
RecordingHitTarget {
node_id: 99,
consume: false,
events: ancestor_events.clone(),
capture_path: vec![99],
},
RecordingHitTarget {
node_id: 2,
consume: false,
events: unrelated_events.clone(),
capture_path: vec![2],
},
];
assert!(
shell.set_cursor(30.0, 30.0),
"move should continue on the captured ancestor target"
);
assert!(
shell.pointer_released(),
"up should dispatch to the surviving ancestor target"
);
assert_eq!(
ancestor_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Move, PointerEventKind::Up]
);
assert!(
unrelated_events.borrow().is_empty(),
"captured gesture must not retarget to unrelated fresh hits"
);
}
#[test]
fn consumed_pointer_down_only_captures_targets_that_received_the_down_event() {
let _guard = test_guard();
let top_events = Rc::new(RefCell::new(Vec::new()));
let lower_events = Rc::new(RefCell::new(Vec::new()));
let active_hits = Rc::new(RefCell::new(vec![
RecordingHitTarget {
node_id: 1,
consume: true,
events: top_events.clone(),
capture_path: vec![1],
},
RecordingHitTarget {
node_id: 2,
consume: false,
events: lower_events.clone(),
capture_path: vec![2],
},
]));
let root_key = location_key(file!(), line!(), column!());
let scene = MutableRecordingScene::new(active_hits);
let mut shell = AppShell::new(
MutableRecordingRenderer::new(scene),
root_key,
empty_content,
);
assert!(
shell.set_cursor(10.0, 10.0),
"hover should find both overlapping hits"
);
top_events.borrow_mut().clear();
lower_events.borrow_mut().clear();
assert!(
shell.pointer_pressed(),
"pointer down should dispatch to the top-most hit"
);
assert_eq!(
top_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Down]
);
assert!(
lower_events.borrow().is_empty(),
"covered hit must not receive Down once the top hit consumes it",
);
top_events.borrow_mut().clear();
lower_events.borrow_mut().clear();
assert!(
shell.set_cursor(20.0, 20.0),
"drag move should stay on the captured gesture path",
);
assert!(
shell.pointer_released(),
"pointer up should resolve through the captured gesture path",
);
assert_eq!(
top_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Move, PointerEventKind::Up]
);
assert!(
lower_events.borrow().is_empty(),
"targets that never received Down must not receive Move or Up follow-ups",
);
}
#[test]
fn captured_gesture_preserves_hit_order_when_paths_share_an_ancestor() {
let _guard = test_guard();
let first_child_events = Rc::new(RefCell::new(Vec::new()));
let second_child_events = Rc::new(RefCell::new(Vec::new()));
let shared_ancestor_events = Rc::new(RefCell::new(Vec::new()));
let active_hits = Rc::new(RefCell::new(vec![
RecordingHitTarget {
node_id: 1,
consume: false,
events: first_child_events.clone(),
capture_path: vec![1, 99],
},
RecordingHitTarget {
node_id: 2,
consume: false,
events: second_child_events.clone(),
capture_path: vec![2, 99],
},
]));
let root_key = location_key(file!(), line!(), column!());
let scene = MutableRecordingScene::new(active_hits.clone());
let mut shell = AppShell::new(
MutableRecordingRenderer::new(scene),
root_key,
empty_content,
);
shell.set_cursor(10.0, 10.0);
assert!(
shell.pointer_pressed(),
"down should record both overlapping hits"
);
first_child_events.borrow_mut().clear();
second_child_events.borrow_mut().clear();
shared_ancestor_events.borrow_mut().clear();
*active_hits.borrow_mut() = vec![
RecordingHitTarget {
node_id: 1,
consume: false,
events: first_child_events.clone(),
capture_path: vec![1, 99],
},
RecordingHitTarget {
node_id: 2,
consume: false,
events: second_child_events.clone(),
capture_path: vec![2, 99],
},
RecordingHitTarget {
node_id: 99,
consume: true,
events: shared_ancestor_events.clone(),
capture_path: vec![99],
},
];
assert!(
shell.set_cursor(20.0, 20.0),
"move should resolve the live capture tree"
);
assert!(
shell.pointer_released(),
"up should dispatch through the merged capture tree"
);
assert_eq!(
first_child_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Move, PointerEventKind::Up]
);
assert_eq!(
second_child_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Move, PointerEventKind::Up]
);
assert_eq!(
shared_ancestor_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Move, PointerEventKind::Up]
);
}
#[test]
fn captured_gesture_release_reaches_ancestor_even_when_child_consumes() {
let _guard = test_guard();
let child_events = Rc::new(RefCell::new(Vec::new()));
let ancestor_events = Rc::new(RefCell::new(Vec::new()));
let active_hits = Rc::new(RefCell::new(vec![RecordingHitTarget {
node_id: 1,
consume: true,
events: child_events.clone(),
capture_path: vec![1, 99],
}]));
let root_key = location_key(file!(), line!(), column!());
let scene = MutableRecordingScene::new(active_hits.clone());
let mut shell = AppShell::new(
MutableRecordingRenderer::new(scene),
root_key,
empty_content,
);
shell.set_cursor(10.0, 10.0);
assert!(shell.pointer_pressed(), "down should hit the child target");
child_events.borrow_mut().clear();
*active_hits.borrow_mut() = vec![
RecordingHitTarget {
node_id: 1,
consume: true,
events: child_events.clone(),
capture_path: vec![1, 99],
},
RecordingHitTarget {
node_id: 99,
consume: false,
events: ancestor_events.clone(),
capture_path: vec![99],
},
];
assert!(
shell.pointer_released(),
"up should resolve the captured path"
);
assert_eq!(
child_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Up]
);
assert_eq!(
ancestor_events
.borrow()
.iter()
.map(|event| event.kind)
.collect::<Vec<_>>(),
vec![PointerEventKind::Up]
);
}
#[test]
fn pointer_scrolled_returns_false_without_hit_targets() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, empty_content);
shell.set_cursor(5.0, 7.0);
assert!(
!shell.pointer_scrolled(0.0, 32.0),
"wheel dispatch should return false when no handlers are hit"
);
}
#[test]
fn pointer_scrolled_reaches_real_vertical_scroll_modifier() {
let _guard = test_guard();
APP_SHELL_WHEEL_SCROLL_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_wheel_scroll_probe,
);
shell.set_buffer_size(320, 240);
shell.set_viewport(320.0, 240.0);
shell.update();
let scroll_state = APP_SHELL_WHEEL_SCROLL_STATE
.with(|slot| slot.borrow().as_ref().cloned())
.expect("wheel scroll probe should expose its scroll state");
assert_eq!(scroll_state.value_non_reactive(), 0.0);
assert!(shell.set_cursor(80.0, 80.0), "probe should be hit-testable");
assert!(
shell.pointer_scrolled(0.0, -120.0),
"wheel event should be consumed by the scroll modifier"
);
shell.update();
assert!(
scroll_state.value_non_reactive() > 0.0,
"wheel event did not update vertical_scroll state"
);
assert!(
!shell.needs_redraw(),
"wheel scroll must not leave a redraw tail after the frame that applied the scroll"
);
}
#[test]
fn wheel_scroll_updates_vertical_scroll_layout_tree_offset() {
let _guard = test_guard();
APP_SHELL_WHEEL_SCROLL_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_wheel_scroll_probe,
);
shell.set_buffer_size(320, 240);
shell.set_viewport(320.0, 240.0);
shell.update();
let initial_bottom_y = find_layout_box_with_text(
shell.layout_tree().expect("initial layout tree").root(),
"Wheel scroll probe bottom",
)
.expect("bottom text in initial layout tree")
.rect
.y;
assert!(shell.set_cursor(80.0, 80.0), "probe should be hit-testable");
assert!(
shell.pointer_scrolled(0.0, -120.0),
"wheel event should be consumed by the scroll modifier"
);
shell.update();
let scroll_offset = APP_SHELL_WHEEL_SCROLL_STATE
.with(|slot| slot.borrow().as_ref().map(ScrollState::value_non_reactive))
.expect("wheel scroll probe should expose its scroll state");
let scrolled_bottom_y = find_layout_box_with_text(
shell.layout_tree().expect("scrolled layout tree").root(),
"Wheel scroll probe bottom",
)
.expect("bottom text in scrolled layout tree")
.rect
.y;
assert!(
scroll_offset > 0.0,
"wheel event should change the scroll state before checking layout"
);
assert!(
scrolled_bottom_y < initial_bottom_y - scroll_offset * 0.5,
"scroll layout tree did not move with scroll offset: initial_y={initial_bottom_y} scrolled_y={scrolled_bottom_y} scroll_offset={scroll_offset}"
);
}
#[test]
fn consumed_child_drag_does_not_scroll_parent_vertical_scroll() {
let _guard = test_guard();
APP_SHELL_WHEEL_SCROLL_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_consumed_child_drag_scroll_probe,
);
shell.set_buffer_size(320, 240);
shell.set_viewport(320.0, 240.0);
shell.update();
let scroll_state = APP_SHELL_WHEEL_SCROLL_STATE
.with(|slot| slot.borrow().as_ref().cloned())
.expect("drag scroll probe should expose its scroll state");
assert_eq!(scroll_state.value_non_reactive(), 0.0);
assert!(shell.set_cursor(32.0, 32.0), "child should be hit-testable");
assert!(shell.pointer_pressed(), "child should receive pointer down");
assert!(
shell.set_cursor(32.0, 96.0),
"child drag should be delivered"
);
assert!(
shell.pointer_released(),
"captured child should receive pointer up"
);
shell.update();
assert_eq!(
scroll_state.value_non_reactive(),
0.0,
"parent vertical_scroll must ignore a drag consumed by a child pointer handler"
);
}
#[test]
fn pointer_scrolled_reaches_horizontal_scroll_under_clickable_child() {
let _guard = test_guard();
APP_SHELL_WHEEL_SCROLL_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_horizontal_clickable_wheel_scroll_probe,
);
shell.set_buffer_size(220, 120);
shell.set_viewport(220.0, 120.0);
shell.update();
let scroll_state = APP_SHELL_WHEEL_SCROLL_STATE
.with(|slot| slot.borrow().as_ref().cloned())
.expect("horizontal wheel probe should expose its scroll state");
assert_eq!(scroll_state.value_non_reactive(), 0.0);
assert!(
shell.set_cursor(72.0, 32.0),
"clickable child in horizontal scroll row should be hit-testable"
);
assert!(
shell.pointer_scrolled(-120.0, 0.0),
"horizontal wheel event should be consumed by the parent scroll modifier"
);
shell.update();
assert!(
scroll_state.value_non_reactive() > 0.0,
"horizontal wheel over a clickable child did not advance scroll state"
);
}
#[test]
fn pointer_scrolled_reaches_real_lazy_column_modifier() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
AppShellScrollIndicatorLazyList,
);
shell.set_buffer_size(320, 240);
shell.set_viewport(320.0, 240.0);
shell.update();
let list_state = APP_SHELL_LAZY_LIST_STATE
.with(|slot| *slot.borrow())
.expect("lazy wheel probe should expose its list state");
assert_eq!(list_state.first_visible_item_index_non_reactive(), 0);
assert_eq!(
list_state.first_visible_item_scroll_offset_non_reactive(),
0.0
);
assert!(
shell.set_cursor(80.0, 120.0),
"lazy list should be hit-testable"
);
assert!(
shell.pointer_scrolled(0.0, -120.0),
"wheel event should be consumed by the lazy scroll modifier"
);
shell.update();
let moved_index = list_state.first_visible_item_index_non_reactive() > 0;
let moved_offset = list_state.first_visible_item_scroll_offset_non_reactive() > 0.0;
assert!(
moved_index || moved_offset,
"wheel event did not update LazyListState"
);
assert!(
!shell.needs_redraw(),
"lazy wheel scroll must not leave a redraw tail after the frame that applied the scroll"
);
}
#[test]
fn pointer_drag_reaches_real_lazy_column_modifier() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
AppShellScrollIndicatorLazyList,
);
shell.set_buffer_size(320, 240);
shell.set_viewport(320.0, 240.0);
shell.update();
let list_state = APP_SHELL_LAZY_LIST_STATE
.with(|slot| *slot.borrow())
.expect("lazy drag probe should expose its list state");
assert_eq!(list_state.first_visible_item_index_non_reactive(), 0);
assert_eq!(
list_state.first_visible_item_scroll_offset_non_reactive(),
0.0
);
assert!(
shell.set_cursor(80.0, 180.0),
"lazy list should be hit-testable before drag"
);
assert!(
shell.pointer_pressed(),
"lazy list should receive pointer down"
);
assert!(
shell.set_cursor(80.0, 130.0),
"first held move should stay on the captured lazy list path"
);
assert!(
shell.set_cursor(80.0, 70.0),
"second held move should scroll through the captured lazy list path"
);
shell.update();
assert!(
shell.pointer_released(),
"lazy list should receive pointer release on the captured path"
);
shell.update();
let moved_index = list_state.first_visible_item_index_non_reactive() > 0;
let moved_offset = list_state.first_visible_item_scroll_offset_non_reactive() > 0.0;
assert!(
moved_index || moved_offset,
"held pointer drag did not update LazyListState"
);
let layout_tree = shell
.layout_tree()
.expect("layout tree should be available after lazy drag");
let layout_texts = layout_tree_texts(layout_tree);
if moved_index {
assert!(
!layout_texts.iter().any(|text| text == "Row 0"),
"retained lazy layout did not render the shifted item window after user drag: {layout_texts:?}"
);
}
}
#[test]
fn lazy_column_scroll_observer_recomposition_settles_to_idle() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
AppShellScrollIndicatorLazyList,
);
shell.set_buffer_size(320, 240);
shell.set_viewport(320.0, 240.0);
shell.update();
assert!(shell.set_cursor(80.0, 120.0));
assert!(shell.pointer_scrolled(0.0, -120.0));
shell.update();
let mut final_schedule = shell.frame_schedule();
for _ in 0..8 {
if !final_schedule.needs_update && !final_schedule.needs_frame {
break;
}
shell.update();
final_schedule = shell.frame_schedule();
}
assert!(
!final_schedule.needs_update && !final_schedule.needs_frame,
"lazy scroll observer invalidation did not settle: needs_update={} needs_frame={}",
final_schedule.needs_update,
final_schedule.needs_frame,
);
}
#[test]
fn pointer_dispatch_uses_rendered_frame_handlers_when_recomposition_is_pending() {
let _guard = test_guard();
FRAME_STABLE_HANDLER_MODE.with(|slot| slot.borrow_mut().take());
FRAME_STABLE_RENDERED_CLICKS.with(|slot| slot.borrow_mut().take());
FRAME_STABLE_PENDING_CLICKS.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
frame_stable_pointer_handler_content,
);
shell.update();
let layout_tree = shell.layout_tree().expect("layout tree available");
let button = find_layout_box_with_text(layout_tree.root(), "Rendered Handler")
.expect("rendered handler button in layout tree");
let center_x = button.rect.x + button.rect.width * 0.5;
let center_y = button.rect.y + button.rect.height * 0.5;
FRAME_STABLE_HANDLER_MODE.with(|slot| {
slot.borrow()
.as_ref()
.expect("handler mode state")
.set_value(true);
});
assert!(
shell.set_cursor(center_x, center_y),
"hover should still resolve against the rendered frame"
);
assert!(
shell.pointer_pressed(),
"pointer down should hit the rendered frame target"
);
assert!(
shell.pointer_released(),
"pointer up should complete the gesture"
);
let rendered_clicks = FRAME_STABLE_RENDERED_CLICKS.with(|slot| {
slot.borrow()
.as_ref()
.expect("rendered click counter")
.get()
});
let pending_clicks = FRAME_STABLE_PENDING_CLICKS
.with(|slot| slot.borrow().as_ref().expect("pending click counter").get());
assert_eq!(
rendered_clicks, 1,
"pointer dispatch must stay on the frame the user actually saw"
);
assert_eq!(
pending_clicks, 0,
"pending recomposition handlers must not replace the rendered-frame handler before dispatch"
);
}
#[test]
fn draw_repass_updates_render_data_without_layout() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let state_holder_for_app = Rc::clone(&state_holder);
let mut shell = AppShell::new(RecordingRenderer::default(), root_key, move || {
let width_state = useState(|| 24.0f32);
*state_holder_for_app.borrow_mut() = Some(width_state);
draw_width_app(width_state);
});
shell.update();
assert!(
shell.layout_tree().is_some(),
"layout tree should be available when a caller requests a snapshot"
);
let initial_scene = shell
.renderer
.last_scene
.as_ref()
.expect("expected initial render scene");
let initial_width = find_rect_width(initial_scene, Color(0.9, 0.1, 0.1, 1.0))
.expect("expected initial draw rect");
let width_state = state_holder
.borrow()
.as_ref()
.cloned()
.expect("width state should be captured");
width_state.set(120.0);
let app_context = Rc::clone(&shell.app_context);
app_context.enter(|| {
shell
.composition
.process_invalid_scopes()
.expect("recompose after width change");
});
shell.run_render_phase();
assert!(
shell.layout_tree.is_some(),
"draw-only refresh should keep the retained layout tree available"
);
let updated_scene = shell
.renderer
.last_scene
.as_ref()
.expect("expected updated render scene");
let updated_width = find_rect_width(updated_scene, Color(0.9, 0.1, 0.1, 1.0))
.expect("expected updated draw rect");
assert_ne!(initial_width, updated_width, "draw width should update");
assert!(
(updated_width - 120.0).abs() < 0.1,
"updated width should reflect latest state"
);
}
#[test]
fn draw_state_reads_schedule_draw_repass_without_composition_read() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let state_holder_for_app = Rc::clone(&state_holder);
let mut shell = AppShell::new(RecordingRenderer::default(), root_key, move || {
let width_state = useState(|| 24.0f32);
*state_holder_for_app.borrow_mut() = Some(width_state);
draw_observed_width_app(width_state);
});
shell.update();
let initial_scene = shell
.renderer
.last_scene
.as_ref()
.expect("expected initial render scene");
let initial_width = find_rect_width(initial_scene, Color(0.2, 0.7, 0.3, 1.0))
.expect("expected initial observed draw rect");
let width_state = state_holder
.borrow()
.as_ref()
.cloned()
.expect("width state should be captured");
width_state.set(120.0);
shell.update();
let updated_scene = shell
.renderer
.last_scene
.as_ref()
.expect("expected updated render scene");
let updated_width = find_rect_width(updated_scene, Color(0.2, 0.7, 0.3, 1.0))
.expect("expected updated observed draw rect");
assert_ne!(
initial_width, updated_width,
"state reads inside draw closures must invalidate draw output"
);
assert!(
(updated_width - 120.0).abs() < 0.1,
"updated draw width should reflect latest state-only read"
);
}
#[test]
fn draw_only_repass_uses_scoped_renderer_update() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let state_holder_for_app = Rc::clone(&state_holder);
let rebuilds = Rc::new(Cell::new(0));
let updates = Rc::new(Cell::new(0));
let visual_updates = Rc::new(Cell::new(0));
let last_dirty_nodes = Rc::new(RefCell::new(Vec::new()));
let mut shell = AppShell::new(
ScopedUpdateCountingRenderer::with_visual_updates(
Rc::clone(&rebuilds),
Rc::clone(&updates),
Rc::clone(&visual_updates),
Rc::clone(&last_dirty_nodes),
),
root_key,
move || {
let width_state = useState(|| 24.0f32);
*state_holder_for_app.borrow_mut() = Some(width_state);
draw_observed_width_app(width_state);
},
);
shell.update();
rebuilds.set(0);
updates.set(0);
visual_updates.set(0);
last_dirty_nodes.borrow_mut().clear();
let width_state = state_holder
.borrow()
.as_ref()
.cloned()
.expect("width state should be captured");
width_state.set(120.0);
shell.update();
assert_eq!(
updates.get(),
0,
"draw-only repass should not refresh hit data"
);
assert_eq!(
visual_updates.get(),
1,
"draw-only repass should call the visual scoped renderer update"
);
assert_eq!(
rebuilds.get(),
0,
"draw-only repass should not rebuild the full scene"
);
assert!(
!last_dirty_nodes.borrow().is_empty(),
"scoped renderer update should receive dirty node ids"
);
}
#[test]
fn draw_only_scene_dirty_repass_uses_visual_scoped_renderer_update() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let state_holder_for_app = Rc::clone(&state_holder);
let rebuilds = Rc::new(Cell::new(0));
let updates = Rc::new(Cell::new(0));
let visual_updates = Rc::new(Cell::new(0));
let last_dirty_nodes = Rc::new(RefCell::new(Vec::new()));
let mut shell = AppShell::new(
ScopedUpdateCountingRenderer::with_visual_updates(
Rc::clone(&rebuilds),
Rc::clone(&updates),
Rc::clone(&visual_updates),
Rc::clone(&last_dirty_nodes),
),
root_key,
move || {
let width_state = useState(|| 24.0f32);
*state_holder_for_app.borrow_mut() = Some(width_state);
draw_observed_width_app(width_state);
},
);
shell.update();
rebuilds.set(0);
updates.set(0);
visual_updates.set(0);
last_dirty_nodes.borrow_mut().clear();
let width_state = state_holder
.borrow()
.as_ref()
.cloned()
.expect("width state should be captured");
width_state.set(120.0);
shell.scene_dirty = true;
shell.update();
assert_eq!(
updates.get(),
0,
"draw-only scene dirtiness should not refresh hit data"
);
assert_eq!(
visual_updates.get(),
1,
"draw-only scene dirtiness should use the visual scoped renderer update"
);
assert_eq!(
rebuilds.get(),
0,
"draw-only scene dirtiness should not rebuild the full scene"
);
assert!(
!last_dirty_nodes.borrow().is_empty(),
"visual scoped update should receive dirty node ids"
);
}
#[test]
fn lazy_column_scroll_repass_uses_scoped_renderer_update_without_stale_rows() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let rebuilds = Rc::new(Cell::new(0));
let updates = Rc::new(Cell::new(0));
let visual_updates = Rc::new(Cell::new(0));
let last_dirty_nodes = Rc::new(RefCell::new(Vec::new()));
let mut shell = AppShell::new(
ScopedUpdateCountingRenderer::with_visual_updates(
Rc::clone(&rebuilds),
Rc::clone(&updates),
Rc::clone(&visual_updates),
Rc::clone(&last_dirty_nodes),
),
root_key,
AppShellScrollIndicatorLazyList,
);
shell.set_buffer_size(320, 240);
shell.set_viewport(320.0, 240.0);
shell.update();
let list_state = APP_SHELL_LAZY_LIST_STATE
.with(|slot| *slot.borrow())
.expect("lazy scroll probe should expose its list state");
let initial_labels = graph_scene_text_values(shell.renderer.scene());
let initial_rows = row_label_indices(&initial_labels);
assert!(
initial_rows.contains(&0),
"initial graph should contain row zero before scrolling: {initial_labels:?}"
);
rebuilds.set(0);
updates.set(0);
visual_updates.set(0);
last_dirty_nodes.borrow_mut().clear();
assert!(
list_state.dispatch_scroll_delta(-120.0).abs() > 0.0,
"lazy scroll state should consume the programmatic scroll delta"
);
shell.update();
assert_eq!(
visual_updates.get(),
0,
"lazy-list layout repasses must refresh hit data"
);
assert_eq!(
updates.get() + rebuilds.get(),
1,
"lazy-list layout repasses should perform exactly one renderer scene refresh"
);
if updates.get() > 0 {
assert!(
!last_dirty_nodes.borrow().is_empty(),
"scoped lazy-list update should pass dirty layout node ids"
);
}
let first_visible = list_state.first_visible_item_index_non_reactive();
let updated_labels = graph_scene_text_values(shell.renderer.scene());
let updated_rows = row_label_indices(&updated_labels);
assert!(
first_visible > 0,
"test scroll should move the retained lazy-list first visible index"
);
assert!(
!updated_rows.contains(&0),
"partial lazy-list update must not retain stale row zero after scrolling: labels={updated_labels:?}"
);
assert!(
updated_rows.windows(2).all(|window| window[1] == window[0] + 1),
"partial lazy-list update should keep a consecutive visible row window: rows={updated_rows:?}, labels={updated_labels:?}"
);
assert!(
!shell.needs_redraw(),
"lazy-list scroll must not leave a redraw tail after the applied frame"
);
}
#[test]
fn graphics_layer_state_repass_does_not_recompose() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let state_holder_for_app = Rc::clone(&state_holder);
let rebuilds = Rc::new(Cell::new(0));
let updates = Rc::new(Cell::new(0));
let visual_updates = Rc::new(Cell::new(0));
let last_dirty_nodes = Rc::new(RefCell::new(Vec::new()));
let mut shell = AppShell::new(
ScopedUpdateCountingRenderer::with_visual_updates(
Rc::clone(&rebuilds),
Rc::clone(&updates),
Rc::clone(&visual_updates),
Rc::clone(&last_dirty_nodes),
),
root_key,
move || {
let offset_state = useState(|| 10.0f32);
*state_holder_for_app.borrow_mut() = Some(offset_state);
graphics_layer_observed_offset_app(offset_state);
},
);
shell.update();
let initial_recompositions = shell.fps_stats().recompositions;
rebuilds.set(0);
updates.set(0);
visual_updates.set(0);
last_dirty_nodes.borrow_mut().clear();
let offset_state = state_holder
.borrow()
.as_ref()
.cloned()
.expect("offset state should be captured");
offset_state.set(48.0);
shell.update();
assert_eq!(
shell.fps_stats().recompositions,
initial_recompositions,
"graphics-layer-only state changes must not invalidate composition"
);
assert_eq!(
updates.get(),
0,
"graphics-layer-only state changes should not use the hit-refresh update path"
);
assert_eq!(
visual_updates.get(),
1,
"graphics-layer-only state changes should use visual scoped renderer update"
);
assert_eq!(
rebuilds.get(),
0,
"graphics-layer-only state changes should not rebuild the full scene"
);
assert!(
!last_dirty_nodes.borrow().is_empty(),
"graphics-layer-only state changes should carry dirty node ids"
);
}
#[test]
fn recomposed_graphics_layer_update_uses_scoped_renderer_update() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let state_holder_for_app = Rc::clone(&state_holder);
let rebuilds = Rc::new(Cell::new(0));
let updates = Rc::new(Cell::new(0));
let last_dirty_nodes = Rc::new(RefCell::new(Vec::new()));
let mut shell = AppShell::new(
ScopedUpdateCountingRenderer::new(
Rc::clone(&rebuilds),
Rc::clone(&updates),
Rc::clone(&last_dirty_nodes),
),
root_key,
move || {
let offset_state = useState(|| 10.0f32);
*state_holder_for_app.borrow_mut() = Some(offset_state);
graphics_layer_composed_offset_app(offset_state);
},
);
shell.update();
let initial_recompositions = shell.fps_stats().recompositions;
rebuilds.set(0);
updates.set(0);
last_dirty_nodes.borrow_mut().clear();
let offset_state = state_holder
.borrow()
.as_ref()
.cloned()
.expect("offset state should be captured");
offset_state.set(48.0);
shell.update();
assert!(
shell.fps_stats().recompositions > initial_recompositions,
"composition-read graphics-layer state changes should still recompose the owning scope"
);
assert_eq!(
updates.get(),
1,
"recomposed graphics-layer changes should use scoped renderer update"
);
assert_eq!(
rebuilds.get(),
0,
"recomposed graphics-layer changes should not rebuild the full scene"
);
assert!(
!last_dirty_nodes.borrow().is_empty(),
"recomposed graphics-layer changes should carry dirty node ids"
);
}
#[test]
fn recomposed_shader_effect_layers_use_scoped_renderer_update() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let time_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let intensity_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let time_holder_for_app = Rc::clone(&time_holder);
let intensity_holder_for_app = Rc::clone(&intensity_holder);
let rebuilds = Rc::new(Cell::new(0));
let updates = Rc::new(Cell::new(0));
let visual_updates = Rc::new(Cell::new(0));
let last_dirty_nodes = Rc::new(RefCell::new(Vec::new()));
let mut shell = AppShell::new(
ScopedUpdateCountingRenderer::with_visual_updates(
Rc::clone(&rebuilds),
Rc::clone(&updates),
Rc::clone(&visual_updates),
Rc::clone(&last_dirty_nodes),
),
root_key,
move || {
let time_state = useState(|| 0.0f32);
let intensity_state = useState(|| 1.0f32);
*time_holder_for_app.borrow_mut() = Some(time_state);
*intensity_holder_for_app.borrow_mut() = Some(intensity_state);
shader_rect_like_effect_layers_app(time_state, intensity_state);
},
);
shell.update();
let initial_recompositions = shell.fps_stats().recompositions;
rebuilds.set(0);
updates.set(0);
visual_updates.set(0);
last_dirty_nodes.borrow_mut().clear();
let time_state = time_holder
.borrow()
.as_ref()
.cloned()
.expect("time state should be captured");
let intensity_state = intensity_holder
.borrow()
.as_ref()
.cloned()
.expect("intensity state should be captured");
time_state.set(0.25);
intensity_state.set(1.5);
shell.update();
assert!(
shell.fps_stats().recompositions > initial_recompositions,
"shader-effect payload state changes should recompose the owning scope"
);
assert_eq!(
updates.get(),
1,
"shader-effect payload changes should use one scoped renderer update"
);
assert_eq!(
rebuilds.get(),
0,
"shader-effect payload changes should not rebuild the full scene"
);
assert!(
last_dirty_nodes.borrow().len() >= 4,
"all changed shader layer nodes should be delivered to the scoped renderer update"
);
}
#[test]
fn lazy_shader_effect_layers_use_draw_repass_without_recomposition() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let time_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let intensity_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
Rc::new(RefCell::new(None));
let time_holder_for_app = Rc::clone(&time_holder);
let intensity_holder_for_app = Rc::clone(&intensity_holder);
let rebuilds = Rc::new(Cell::new(0));
let updates = Rc::new(Cell::new(0));
let visual_updates = Rc::new(Cell::new(0));
let last_dirty_nodes = Rc::new(RefCell::new(Vec::new()));
let mut shell = AppShell::new(
ScopedUpdateCountingRenderer::with_visual_updates(
Rc::clone(&rebuilds),
Rc::clone(&updates),
Rc::clone(&visual_updates),
Rc::clone(&last_dirty_nodes),
),
root_key,
move || {
let time_state = useState(|| 0.0f32);
let intensity_state = useState(|| 1.0f32);
*time_holder_for_app.borrow_mut() = Some(time_state);
*intensity_holder_for_app.borrow_mut() = Some(intensity_state);
shader_rect_like_lazy_effect_layers_app(time_state, intensity_state);
},
);
shell.update();
let initial_recompositions = shell.fps_stats().recompositions;
rebuilds.set(0);
updates.set(0);
visual_updates.set(0);
last_dirty_nodes.borrow_mut().clear();
let time_state = time_holder
.borrow()
.as_ref()
.cloned()
.expect("time state should be captured");
let intensity_state = intensity_holder
.borrow()
.as_ref()
.cloned()
.expect("intensity state should be captured");
time_state.set(0.25);
intensity_state.set(1.5);
shell.update();
assert_eq!(
shell.fps_stats().recompositions,
initial_recompositions,
"lazy shader-effect payload state changes must not recompose"
);
assert_eq!(
updates.get(),
0,
"lazy shader-effect payload changes should not use the hit-refresh update path"
);
assert_eq!(
visual_updates.get(),
1,
"lazy shader-effect payload changes should use one visual scoped renderer update"
);
assert_eq!(
rebuilds.get(),
0,
"lazy shader-effect payload changes should not rebuild the full scene"
);
assert!(
last_dirty_nodes.borrow().len() >= 4,
"all changed shader layer nodes should be delivered to the scoped renderer update"
);
}
#[test]
fn graphics_layer_point_state_repass_does_not_recompose() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<Point>>>> =
Rc::new(RefCell::new(None));
let state_holder_for_app = Rc::clone(&state_holder);
let rebuilds = Rc::new(Cell::new(0));
let updates = Rc::new(Cell::new(0));
let visual_updates = Rc::new(Cell::new(0));
let last_dirty_nodes = Rc::new(RefCell::new(Vec::new()));
let mut shell = AppShell::new(
ScopedUpdateCountingRenderer::with_visual_updates(
Rc::clone(&rebuilds),
Rc::clone(&updates),
Rc::clone(&visual_updates),
Rc::clone(&last_dirty_nodes),
),
root_key,
move || {
let position_state = useState(|| Point { x: 10.0, y: 20.0 });
*state_holder_for_app.borrow_mut() = Some(position_state);
graphics_layer_observed_point_app(position_state);
},
);
shell.update();
let initial_recompositions = shell.fps_stats().recompositions;
rebuilds.set(0);
updates.set(0);
visual_updates.set(0);
last_dirty_nodes.borrow_mut().clear();
let position_state = state_holder
.borrow()
.as_ref()
.cloned()
.expect("position state should be captured");
position_state.set(Point { x: 48.0, y: 64.0 });
shell.update();
assert_eq!(
shell.fps_stats().recompositions,
initial_recompositions,
"graphics-layer point state changes must not invalidate composition"
);
assert_eq!(
updates.get(),
0,
"graphics-layer point state changes should not use the hit-refresh update path"
);
assert_eq!(
visual_updates.get(),
1,
"graphics-layer point state changes should use visual scoped renderer update"
);
assert_eq!(
rebuilds.get(),
0,
"graphics-layer point state changes should not rebuild the full scene"
);
assert!(
!last_dirty_nodes.borrow().is_empty(),
"scoped renderer update should receive the graphics-layer node id"
);
}
#[test]
fn pointer_driven_graphics_layer_point_state_does_not_recompose() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<Point>>>> =
Rc::new(RefCell::new(None));
let state_holder_for_app = Rc::clone(&state_holder);
let mut shell = AppShell::new(HitGraphRenderer::default(), root_key, move || {
let position_state = useState(Point::default);
*state_holder_for_app.borrow_mut() = Some(position_state);
pointer_driven_graphics_layer_point_app(position_state);
});
shell.update();
let initial_recompositions = shell.fps_stats().recompositions;
assert!(
shell.set_cursor(20.0, 20.0),
"initial hover should hit the draggable"
);
assert!(
shell.pointer_pressed(),
"pointer down should hit the draggable"
);
shell.update();
for point in [
Point { x: 28.0, y: 26.0 },
Point { x: 36.0, y: 32.0 },
Point { x: 44.0, y: 38.0 },
] {
assert!(
shell.set_cursor(point.x, point.y),
"drag move should dispatch through the captured hit path"
);
shell.update();
}
assert!(
shell.pointer_released(),
"pointer release should dispatch through the captured hit path"
);
shell.update();
assert_eq!(
shell.fps_stats().recompositions,
initial_recompositions,
"pointer-driven graphics-layer state changes must not invalidate composition"
);
}
#[test]
fn active_pointer_gesture_keeps_frame_schedule_until_release() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(HitGraphRenderer::default(), root_key, || {
let position_state = useState(Point::default);
pointer_driven_graphics_layer_point_app(position_state);
});
shell.update();
assert!(!shell.frame_schedule().needs_frame);
assert!(shell.set_cursor(20.0, 20.0));
assert!(shell.pointer_pressed());
assert!(shell.has_active_pointer_gesture());
assert!(shell.frame_schedule().needs_frame);
shell.update();
assert!(
shell.frame_schedule().needs_frame,
"an active pointer gesture should keep the frame driver awake after the dirty frame is consumed"
);
assert!(shell.set_cursor(44.0, 38.0));
shell.update();
assert!(shell.frame_schedule().needs_frame);
assert!(shell.pointer_released());
shell.update();
assert!(!shell.has_active_pointer_gesture());
assert!(!shell.frame_schedule().needs_frame);
}
#[composable]
#[allow(non_snake_case)]
fn AbsoluteOffsetStackedTextRows(start: MutableState<i32>) {
Box(
Modifier::empty()
.size_points(260.0, 220.0)
.background(Color(0.02, 0.03, 0.05, 1.0)),
BoxSpec::default(),
move || {
for row in 0..14 {
Text(
format!("Row {:02}", start.get() + row),
Modifier::empty()
.size_points(220.0, 14.0)
.absolute_offset(12.0, 10.0 + row as f32 * 14.0),
TextStyle::default(),
);
}
},
);
}
fn rendered_text_values(scene: &cranpose_ui::RecordedRenderScene) -> Vec<String> {
scene
.operations()
.iter()
.filter_map(|op| match op {
RenderOp::Text { value, .. } => Some(value.clone()),
_ => None,
})
.collect()
}
fn graph_scene_text_values(scene: &cranpose_render_common::graph_scene::Scene) -> Vec<String> {
fn collect(layer: &cranpose_render_common::graph::LayerNode, out: &mut Vec<String>) {
for child in &layer.children {
match child {
cranpose_render_common::graph::RenderNode::Layer(child_layer) => {
collect(child_layer, out);
}
cranpose_render_common::graph::RenderNode::Primitive(entry) => {
if let cranpose_render_common::graph::PrimitiveNode::Text(text) = &entry.node {
out.push(text.text.text.clone());
}
}
}
}
}
let mut values = Vec::new();
if let Some(graph) = &scene.graph {
collect(&graph.root, &mut values);
}
values
}
fn row_label_indices(values: &[String]) -> Vec<usize> {
values
.iter()
.filter_map(|value| {
value
.strip_prefix("Row ")
.and_then(|index| index.parse::<usize>().ok())
})
.collect()
}
#[test]
fn absolute_offset_text_rows_redraw_after_state_only_change() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let state_holder: Rc<RefCell<Option<MutableState<i32>>>> = Rc::new(RefCell::new(None));
let state_holder_for_app = Rc::clone(&state_holder);
let mut shell = AppShell::new(RecordingRenderer::default(), root_key, move || {
let start = useState(|| 0i32);
*state_holder_for_app.borrow_mut() = Some(start);
AbsoluteOffsetStackedTextRows(start);
});
shell.update();
let initial_scene = shell
.renderer
.last_scene
.as_ref()
.expect("expected initial render scene");
let initial_texts = rendered_text_values(initial_scene);
assert!(initial_texts.iter().any(|text| text == "Row 00"));
assert!(!initial_texts.iter().any(|text| text == "Row 30"));
let start = state_holder
.borrow()
.as_ref()
.cloned()
.expect("start state should be captured");
start.set(30);
shell.update();
let updated_scene = shell
.renderer
.last_scene
.as_ref()
.expect("expected updated render scene");
let updated_texts = rendered_text_values(updated_scene);
assert!(
updated_texts.iter().any(|text| text == "Row 30"),
"render scene should contain recomposed absolute-offset text rows, got {updated_texts:?}"
);
assert!(
!updated_texts.iter().any(|text| text == "Row 00"),
"render scene must not keep stale absolute-offset text rows, got {updated_texts:?}"
);
}
#[test]
fn app_shell_new_drains_root_render_requests_before_first_frame() {
let _guard = test_guard();
ROOT_RENDER_TEST_INVALIDATED.with(|flag| flag.set(false));
let root_key = location_key(file!(), line!(), column!());
let render_count = Rc::new(Cell::new(0));
let render_count_for_app = Rc::clone(&render_count);
let mut shell = AppShell::new(TestRenderer::default(), root_key, move || {
callbackless_root_render_probe(Rc::clone(&render_count_for_app));
});
assert_eq!(
render_count.get(),
2,
"AppShell::new must replay pending root renders before publishing the first frame"
);
assert!(
!shell.composition.take_root_render_request(),
"initial shell setup should not leave a pending root render request behind"
);
let texts = layout_tree_texts(shell.layout_tree().expect("layout tree available"));
assert!(
texts.iter().any(|text| text == "Render 2"),
"initial frame should reflect the replayed root render, got {texts:?}"
);
}
#[test]
fn render_invalidation_without_scene_changes_rebuilds_scene() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let rebuilds = Rc::new(Cell::new(0));
let mut shell = AppShell::new(
CountingRenderer::new(Rc::clone(&rebuilds)),
root_key,
box_content,
);
shell.update();
rebuilds.set(0);
let app_context = Rc::clone(&shell.app_context);
app_context.enter(cranpose_ui::request_render_invalidation);
assert!(
shell.debug_enter_app_context(cranpose_ui::peek_render_invalidation),
"test setup must install a render invalidation"
);
let result = shell.run_render_phase();
assert_eq!(
rebuilds.get(),
1,
"pure render invalidation should rebuild scene for render-only updates"
);
assert!(
result.visual_changed,
"pure render invalidation should report visual work"
);
}
#[test]
fn first_update_after_construction_reports_no_visual_work() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let rebuilds = Rc::new(Cell::new(0));
let mut shell = AppShell::new(
CountingRenderer::new(Rc::clone(&rebuilds)),
root_key,
box_content,
);
assert_eq!(
rebuilds.get(),
1,
"construction must build the scene exactly once"
);
let first = shell.update();
assert!(
!first.visual_changed,
"the first post-construction update finds a clean tree and reports no visual work"
);
assert_eq!(
rebuilds.get(),
1,
"the first update must not rebuild the already-built scene"
);
}
#[test]
fn clean_frame_reports_no_visual_work_with_dev_overlay_enabled() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let rebuilds = Rc::new(Cell::new(0));
let mut shell = AppShell::new(
CountingRenderer::new(Rc::clone(&rebuilds)),
root_key,
box_content,
);
let options = DevOptions {
fps_counter: true,
..Default::default()
};
shell.set_dev_options(options);
assert!(
shell.debug_enter_app_context(cranpose_ui::peek_render_invalidation),
"dev option changes must request a renderer update"
);
let overlay_result = shell.update();
assert!(
overlay_result.visual_changed,
"enabling the dev overlay must produce one visual scene update"
);
rebuilds.set(0);
let clean_result = shell.update();
assert!(
!clean_result.visual_changed,
"clean app-shell frames must not be presented as visual work"
);
assert_eq!(
rebuilds.get(),
0,
"clean frames must not rebuild the scene just to refresh FPS text"
);
assert!(
!shell.needs_redraw(),
"the overlay must not keep the shell dirty after a clean frame"
);
}
#[test]
fn dev_overlay_reuses_text_inside_refresh_window_and_updates_after_it() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let rebuilds = Rc::new(Cell::new(0));
let overlay_texts = Rc::new(RefCell::new(Vec::new()));
let mut shell = AppShell::new(
CountingRenderer::with_overlay_texts(Rc::clone(&rebuilds), Rc::clone(&overlay_texts)),
root_key,
box_content,
);
let options = DevOptions {
fps_counter: true,
frame_pacing_controls: true,
..Default::default()
};
shell.set_dev_options(options);
shell.update();
overlay_texts.borrow_mut().clear();
shell.reset_fps_stats();
shell.record_presented_frame_for_test(0, 1_000_000);
shell.record_presented_frame_for_test(20_000_000, 21_000_000);
shell.debug_enter_app_context(cranpose_ui::request_render_invalidation);
shell.update();
let slow_overlay = overlay_texts
.borrow()
.last()
.expect("slow overlay text should be drawn")
.clone();
assert!(
slow_overlay.starts_with("50 FPS"),
"test setup should draw the slow frame history first: {slow_overlay}"
);
overlay_texts.borrow_mut().clear();
for index in 1..=60u64 {
let started = 20_000_000 + index * 4_000_000;
shell.record_presented_frame_for_test(started, started + 1_000_000);
}
shell.debug_enter_app_context(cranpose_ui::request_render_invalidation);
shell.update();
let cached_overlay = overlay_texts
.borrow()
.last()
.expect("cached overlay text should be drawn")
.clone();
assert_eq!(
cached_overlay, slow_overlay,
"the dev overlay must not rebuild dynamic FPS text on every visual frame"
);
overlay_texts.borrow_mut().clear();
shell.dev_overlay_last_refresh = Some(web_time::Instant::now() - Duration::from_millis(300));
shell.debug_enter_app_context(cranpose_ui::request_render_invalidation);
shell.update();
let fast_overlay = overlay_texts
.borrow()
.last()
.expect("refreshed overlay text should be drawn")
.clone();
assert!(
fast_overlay.starts_with("250 FPS"),
"the dev overlay must report current FPS stats after the refresh window: slow={slow_overlay:?} fast={fast_overlay:?}"
);
}
#[test]
fn pointer_invalidation_without_scene_changes_skips_scene_rebuild() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let rebuilds = Rc::new(Cell::new(0));
let mut shell = AppShell::new(
CountingRenderer::new(Rc::clone(&rebuilds)),
root_key,
box_content,
);
shell.update();
rebuilds.set(0);
let root = shell.composition.root().expect("expected composition root");
shell
.composition
.applier_mut()
.with_node::<LayoutNode, _>(root, |node| {
node.mark_needs_pointer_pass();
})
.expect("expected layout root node");
let app_context = Rc::clone(&shell.app_context);
app_context.enter(|| {
cranpose_ui::schedule_pointer_repass(root);
cranpose_ui::request_pointer_invalidation();
});
shell.process_frame();
assert_eq!(
rebuilds.get(),
0,
"pure pointer invalidation should refresh dispatch state without rebuilding the visual scene"
);
let needs_pointer_pass = shell
.composition
.applier_mut()
.with_node::<LayoutNode, _>(root, |node| node.needs_pointer_pass())
.expect("expected layout root node");
assert!(
!needs_pointer_pass,
"pointer dispatch queue should clear the node dirty flag"
);
}
#[test]
fn focus_invalidation_without_scene_changes_skips_rebuild() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let rebuilds = Rc::new(Cell::new(0));
let mut shell = AppShell::new(
CountingRenderer::new(Rc::clone(&rebuilds)),
root_key,
box_content,
);
shell.update();
rebuilds.set(0);
let root = shell.composition.root().expect("expected composition root");
shell
.composition
.applier_mut()
.with_node::<LayoutNode, _>(root, |node| {
node.mark_needs_focus_sync();
})
.expect("expected layout root node");
let app_context = Rc::clone(&shell.app_context);
app_context.enter(|| {
cranpose_ui::schedule_focus_invalidation(root);
cranpose_ui::request_focus_invalidation();
});
shell.process_frame();
assert_eq!(
rebuilds.get(),
0,
"pure focus invalidation should reuse the retained scene"
);
let needs_focus_sync = shell
.composition
.applier_mut()
.with_node::<LayoutNode, _>(root, |node| node.needs_focus_sync())
.expect("expected layout root node");
assert!(
!needs_focus_sync,
"focus dispatch queue should clear the node dirty flag"
);
}
#[test]
fn semantics_collection_is_opt_in_for_app_shell() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, semantics_content);
assert!(
shell.semantics_tree().is_none(),
"app shell should skip semantics work until a consumer is enabled"
);
shell.set_semantics_enabled(true);
shell.process_frame();
assert!(
shell.semantics_tree().is_some(),
"enabling semantics should rebuild the tree on the next frame"
);
shell.set_semantics_enabled(false);
assert!(
shell.semantics_tree().is_none(),
"disabling semantics should drop the cached tree"
);
}
#[test]
fn lazy_item_animation_updates_semantics_after_app_shell_frame() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, AppShellAnimatedLazyItem);
shell.set_semantics_enabled(true);
shell.update_at_frame_time_nanos(0);
let initial = first_semantics_description_with_prefix(&mut shell, "Lazy Pulse:")
.expect("initial lazy pulse semantics");
for frame in 1..80 {
shell.update_at_frame_time_nanos(frame * 16_666_667);
let current = first_semantics_description_with_prefix(&mut shell, "Lazy Pulse:")
.expect("lazy pulse semantics after frame");
if current != initial {
return;
}
}
panic!("lazy item animation semantics stayed frozen at {initial}");
}
#[test]
fn layout_tree_snapshot_is_built_on_demand() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, semantics_content);
assert!(
shell.layout_tree.is_none(),
"layout should render from retained node state without eagerly caching a LayoutTree"
);
assert!(
shell.layout_tree().is_some(),
"debug and robot callers should still be able to request a LayoutTree snapshot"
);
assert!(
shell.layout_tree.is_some(),
"requested LayoutTree snapshot should be cached until the next layout pass"
);
}
#[test]
fn scroll_to_item_updates_indicator_in_layout_tree_and_semantics_tree() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, || {
AppShellScrollIndicatorLazyList();
});
shell.set_semantics_enabled(true);
shell.update();
let initial_layout_texts = layout_tree_texts(shell.layout_tree().expect("layout tree"));
assert!(
initial_layout_texts
.iter()
.any(|text| text == "First visible 0"),
"expected initial layout text, got {initial_layout_texts:?}"
);
let initial_semantics = semantics_tree_descriptions(shell.semantics_tree().expect("semantics"));
assert!(
initial_semantics
.iter()
.any(|text| text == "First visible 0"),
"expected initial semantics text, got {initial_semantics:?}"
);
let list_state = APP_SHELL_LAZY_LIST_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("lazy list state registered");
list_state.scroll_to_item(20, 0.0);
for _ in 0..8 {
if !shell.needs_redraw() && !shell.has_active_animations() {
break;
}
shell.update();
}
let layout_texts = layout_tree_texts(shell.layout_tree().expect("layout tree"));
let semantics = semantics_tree_descriptions(shell.semantics_tree().expect("semantics"));
assert!(
layout_texts.iter().any(|text| text == "First visible 20"),
"expected layout tree text to reflect scroll target, got {layout_texts:?}"
);
assert!(
semantics.iter().any(|text| text == "First visible 20"),
"expected semantics tree text to reflect scroll target, got {semantics:?}"
);
}
#[test]
fn scroll_to_item_updates_first_visible_when_sibling_stats_scope_is_present() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, || {
AppShellSiblingIndicatorsLazyList();
});
shell.update();
let initial_layout_texts = layout_tree_texts(shell.layout_tree().expect("layout tree"));
assert!(
initial_layout_texts
.iter()
.any(|text| text == "Child first visible 0"),
"expected initial child indicator text, got {initial_layout_texts:?}"
);
let list_state = APP_SHELL_LAZY_LIST_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("lazy list state registered");
list_state.scroll_to_item(20, 0.0);
for _ in 0..8 {
if !shell.needs_redraw() && !shell.has_active_animations() {
break;
}
shell.update();
}
let layout_texts = layout_tree_texts(shell.layout_tree().expect("layout tree"));
assert!(
layout_texts
.iter()
.any(|text| text == "Child first visible 20"),
"expected child indicator text to reflect scroll target with sibling stats scope present, got {layout_texts:?}"
);
}
#[test]
fn scroll_to_item_updates_first_visible_under_callbackless_with_key_parent() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, || {
AppShellKeyedSiblingIndicatorsRoot();
});
shell.update();
let list_state = APP_SHELL_LAZY_LIST_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("lazy list state registered");
list_state.scroll_to_item(20, 0.0);
for _ in 0..8 {
if !shell.needs_redraw() && !shell.has_active_animations() {
break;
}
shell.update();
}
let layout_texts = layout_tree_texts(shell.layout_tree().expect("layout tree"));
assert!(
layout_texts
.iter()
.any(|text| text == "Child first visible 20"),
"expected child indicator text to reflect scroll target under keyed callbackless parent, got {layout_texts:?}"
);
}
#[test]
fn scroll_to_item_updates_first_visible_after_switching_to_keyed_lazy_list_branch() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, || {
AppShellSwitchingKeyedLazyListRoot();
});
shell.update();
let active = APP_SHELL_ACTIVE_TAB_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("active branch state registered");
active.set(1);
for _ in 0..8 {
if !shell.needs_redraw() && !shell.has_active_animations() {
break;
}
shell.update();
}
let list_state = APP_SHELL_LAZY_LIST_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("lazy list state registered after branch switch");
list_state.scroll_to_item(20, 0.0);
for _ in 0..8 {
if !shell.needs_redraw() && !shell.has_active_animations() {
break;
}
shell.update();
}
let layout_texts = layout_tree_texts(shell.layout_tree().expect("layout tree"));
assert!(
layout_texts
.iter()
.any(|text| text == "Child first visible 20"),
"expected child indicator text to reflect scroll target after keyed branch switch, got {layout_texts:?}"
);
}
#[test]
fn scroll_to_item_updates_first_visible_when_variable_height_stats_also_change() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, || {
AppShellVariableHeightSiblingIndicatorsLazyList();
});
shell.update();
let initial_layout_texts = layout_tree_texts(shell.layout_tree().expect("layout tree"));
assert!(
initial_layout_texts
.iter()
.any(|text| text == "Child first visible 0"),
"expected initial child indicator text, got {initial_layout_texts:?}"
);
let list_state = APP_SHELL_LAZY_LIST_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("lazy list state registered");
list_state.scroll_to_item(50, 0.0);
for _ in 0..8 {
if !shell.needs_redraw() && !shell.has_active_animations() {
break;
}
shell.update();
}
let layout_texts = layout_tree_texts(shell.layout_tree().expect("layout tree"));
assert!(
layout_texts
.iter()
.any(|text| text == "Child first visible 50"),
"expected child indicator text to reflect scroll target when stats sibling also changes, got {layout_texts:?}"
);
}
#[test]
fn scroll_to_item_updates_first_visible_when_scroll_also_composes_lifecycle_items() {
let _guard = test_guard();
APP_SHELL_LAZY_LIST_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, || {
AppShellLifecycleIndicatorsLazyList();
});
shell.update();
let list_state = APP_SHELL_LAZY_LIST_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("lazy list state registered");
list_state.scroll_to_item(50, 0.0);
for _ in 0..12 {
if !shell.needs_redraw() && !shell.has_active_animations() {
break;
}
shell.update();
}
let layout_texts = layout_tree_texts(shell.layout_tree().expect("layout tree"));
assert!(
layout_texts
.iter()
.any(|text| text == "Child first visible 50"),
"expected child indicator text to reflect scroll target when item composition also invalidates sibling state, got {layout_texts:?}"
);
}
#[test]
fn draw_refresh_scope_only_contains_dirty_ancestors() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, nested_branch_content);
shell.update();
let layout_tree = shell.layout_tree().expect("expected layout tree");
let root = node_id_at_path(layout_tree.root(), &[]);
let left = node_id_at_path(layout_tree.root(), &[0]);
let left_leaf = node_id_at_path(layout_tree.root(), &[0, 0]);
let right = node_id_at_path(layout_tree.root(), &[1]);
let right_leaf = node_id_at_path(layout_tree.root(), &[1, 0]);
let dirty_nodes = HashSet::from([left_leaf]);
let refresh_scope = {
let mut applier = shell.composition.applier_mut();
build_draw_refresh_scope(&mut applier, &dirty_nodes)
};
assert!(refresh_scope.contains(&root));
assert!(refresh_scope.contains(&left));
assert!(refresh_scope.contains(&left_leaf));
assert!(!refresh_scope.contains(&right));
assert!(!refresh_scope.contains(&right_leaf));
}
#[test]
fn layout_bounds_index_matches_cached_layout_tree() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(TestRenderer::default(), root_key, nested_branch_content);
shell.update();
assert!(
shell.layout_tree().is_some(),
"query helpers should be able to request a measured layout tree"
);
let cached_tree_ptr = shell
.layout_tree
.as_ref()
.map(|tree| tree as *const cranpose_ui::LayoutTree)
.expect("expected retained layout tree");
let (root_id, root_bounds, left_leaf_id, left_leaf_bounds, right_id, right_bounds) = {
let layout_tree = shell
.layout_tree
.as_ref()
.expect("expected cached layout tree");
let root = layout_box_at_path(layout_tree.root(), &[]);
let left_leaf = layout_box_at_path(layout_tree.root(), &[0, 0]);
let right = layout_box_at_path(layout_tree.root(), &[1]);
(
root.node_id,
(root.rect.x, root.rect.y, root.rect.width, root.rect.height),
left_leaf.node_id,
(
left_leaf.rect.x,
left_leaf.rect.y,
left_leaf.rect.width,
left_leaf.rect.height,
),
right.node_id,
(
right.rect.x,
right.rect.y,
right.rect.width,
right.rect.height,
),
)
};
assert_eq!(
shell.root_layout_size(),
Some((root_bounds.2, root_bounds.3))
);
assert_eq!(
shell
.layout_tree
.as_ref()
.map(|tree| tree as *const cranpose_ui::LayoutTree),
Some(cached_tree_ptr),
"root_layout_size should reuse the retained layout tree cache",
);
assert_eq!(shell.node_layout_bounds(root_id), Some(root_bounds));
assert_eq!(
shell.node_layout_bounds(left_leaf_id),
Some(left_leaf_bounds)
);
assert_eq!(shell.node_layout_bounds(right_id), Some(right_bounds));
assert_eq!(
shell
.layout_tree
.as_ref()
.map(|tree| tree as *const cranpose_ui::LayoutTree),
Some(cached_tree_ptr),
"layout bound queries should not rebuild the layout tree",
);
}
#[composable]
fn app_shell_scrollable_test_tab(label: &'static str) {
let scroll_state =
cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
Column(
Modifier::empty()
.fill_max_size()
.vertical_scroll(scroll_state, false),
ColumnSpec::default(),
move || {
Text(label, Modifier::empty().padding(8.0), TextStyle::default());
Text(
"Scrollable content",
Modifier::empty().padding(8.0),
TextStyle::default(),
);
},
);
}
#[composable]
fn app_shell_wheel_scroll_probe() {
let scroll_state =
cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
APP_SHELL_WHEEL_SCROLL_STATE.with(|slot| {
*slot.borrow_mut() = Some(scroll_state.clone());
});
Column(
Modifier::empty()
.fill_max_size()
.vertical_scroll(scroll_state, false),
ColumnSpec::default(),
move || {
Text(
"Wheel scroll probe top",
Modifier::empty(),
TextStyle::default(),
);
Spacer(Size {
width: 0.0,
height: 900.0,
});
Text(
"Wheel scroll probe bottom",
Modifier::empty(),
TextStyle::default(),
);
},
);
}
#[composable]
fn app_shell_consumed_child_drag_scroll_probe() {
let scroll_state =
cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
APP_SHELL_WHEEL_SCROLL_STATE.with(|slot| {
*slot.borrow_mut() = Some(scroll_state.clone());
});
Column(
Modifier::empty()
.fill_max_size()
.vertical_scroll(scroll_state, false),
ColumnSpec::default(),
move || {
Box(
Modifier::empty()
.size(Size {
width: 180.0,
height: 120.0,
})
.pointer_input((), move |scope: PointerInputScope| async move {
scope
.await_pointer_event_scope(|await_scope| async move {
loop {
let event = await_scope.await_pointer_event().await;
match event.kind {
PointerEventKind::Down | PointerEventKind::Move => {
event.consume();
}
PointerEventKind::Up
| PointerEventKind::Cancel
| PointerEventKind::Scroll
| PointerEventKind::Enter
| PointerEventKind::Exit => {}
}
}
})
.await;
}),
BoxSpec::default(),
|| {},
);
Spacer(Size {
width: 0.0,
height: 900.0,
});
Text(
"Consumed child drag bottom",
Modifier::empty(),
TextStyle::default(),
);
},
);
}
#[composable]
fn app_shell_horizontal_clickable_wheel_scroll_probe() {
let scroll_state =
cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
APP_SHELL_WHEEL_SCROLL_STATE.with(|slot| {
*slot.borrow_mut() = Some(scroll_state.clone());
});
Row(
Modifier::empty()
.fill_max_width()
.height(72.0)
.clip_to_bounds()
.horizontal_scroll(scroll_state, false),
RowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
for index in 0..8 {
Button(
Modifier::empty().width(112.0).height(48.0),
ButtonSpec::default(),
|| {},
move || {
Text(
format!("Tab {index}"),
Modifier::empty(),
TextStyle::default(),
);
},
);
}
},
);
}
#[composable]
fn app_shell_scrollable_wrapper(content: impl FnMut() + 'static) {
let scroll_state =
cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
Column(
Modifier::empty()
.fill_max_size()
.vertical_scroll(scroll_state, false),
ColumnSpec::default(),
content,
);
}
#[composable]
fn app_shell_counter_test_tab(counter: MutableState<i32>) {
Column(Modifier::empty(), ColumnSpec::default(), move || {
Text(
"Counter Tab",
Modifier::empty().padding(8.0),
TextStyle::default(),
);
Text(
format!("Counter value {}", counter.value()),
Modifier::empty().padding(8.0),
TextStyle::default(),
);
});
}
#[composable]
fn app_shell_interactive_counter_test_tab(counter: MutableState<i32>) {
let pointer_position = useState(Point::default);
let pointer_down = useState(|| false);
let is_even = counter.value() % 2 == 0;
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
cranpose_core::with_key(&is_even, || {
Text(
if is_even {
"Counter even"
} else {
"Counter odd"
},
Modifier::empty().padding(8.0),
TextStyle::default(),
);
});
Box(
Modifier::empty()
.fill_max_width()
.height(220.0)
.background(Color(0.12, 0.18, 0.28, 1.0))
.pointer_input((), {
move |scope: PointerInputScope| async move {
scope
.await_pointer_event_scope(|await_scope| async move {
loop {
let event = await_scope.await_pointer_event().await;
match event.kind {
PointerEventKind::Down => pointer_down.set(true),
PointerEventKind::Up | PointerEventKind::Cancel => {
pointer_down.set(false)
}
PointerEventKind::Move => {
pointer_position.set(event.position);
}
PointerEventKind::Scroll
| PointerEventKind::Enter
| PointerEventKind::Exit => {}
}
}
})
.await;
}
})
.padding(12.0),
BoxSpec::default(),
move || {
Column(Modifier::empty(), ColumnSpec::default(), move || {
Text(
format!(
"Pointer {:.1},{:.1} down={}",
pointer_position.value().x,
pointer_position.value().y,
pointer_down.value()
),
Modifier::empty().padding(8.0),
TextStyle::default(),
);
Text(
format!("Counter value {}", counter.value()),
Modifier::empty().padding(8.0),
TextStyle::default(),
);
Button(
Modifier::empty()
.background(Color(0.25, 0.45, 0.75, 1.0))
.padding(12.0),
ButtonSpec::default(),
move || {
counter.set_value(counter.value() + 1);
},
|| {
Text(
"Increment",
Modifier::empty().padding(6.0),
TextStyle::default(),
);
},
);
});
},
);
},
);
}
#[composable]
fn app_shell_effect_test_tab() {
let request_counter = useState(|| 0u64);
let status = useState(|| "Idle".to_string());
let request_key = request_counter.get();
launched_effect_async_impl(
location_key(file!(), line!(), column!()),
request_key,
move |_scope| {
let status = status;
Box::pin(async move {
if request_key == 0 {
return;
}
status.set_value(format!("Request {}", request_key));
})
},
);
let status_text = status.get();
Column(
Modifier::empty().fill_max_size().vertical_scroll(
cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone()),
false,
),
ColumnSpec::default(),
move || {
Text(
"Web Fetch Marker",
Modifier::empty().padding(8.0),
TextStyle::default(),
);
Text(
format!("Effect status {status_text}"),
Modifier::empty().padding(8.0),
TextStyle::default(),
);
},
);
}
#[composable]
fn app_shell_composition_local_test_tab() {
let local = app_shell_local_count();
let counter = useState(|| 7i32);
let provided = counter.get();
Column(
Modifier::empty().fill_max_size().vertical_scroll(
cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone()),
false,
),
ColumnSpec::default(),
move || {
Text(
"Composition Local Marker",
Modifier::empty().padding(8.0),
TextStyle::default(),
);
CompositionLocalProvider(vec![local.provides(provided)], || {
Text(
format!("READING local {}", local.current()),
Modifier::empty().padding(8.0),
TextStyle::default(),
);
});
},
);
}
#[composable]
fn app_shell_mixed_scrollable_tab_host() {
let active_tab = useState(|| 3i32);
let counter = useState(|| 0i32);
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| {
*slot.borrow_mut() = Some(active_tab);
});
APP_SHELL_COUNTER_STATE.with(|slot| {
*slot.borrow_mut() = Some(counter);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Row(Modifier::empty(), RowSpec::default(), || {
Text("Tab A", Modifier::empty(), TextStyle::default());
Text("Tab B", Modifier::empty(), TextStyle::default());
Text("Tab C", Modifier::empty(), TextStyle::default());
});
Box(
Modifier::empty().fill_max_width().weight(1.0),
BoxSpec::default(),
move || {
let active = active_tab.value();
cranpose_core::with_key(&active, || match active {
0 => app_shell_counter_test_tab(counter),
1 => app_shell_composition_local_test_tab(),
2 => app_shell_effect_test_tab(),
_ => app_shell_scrollable_test_tab("Async Marker"),
});
},
);
},
);
}
#[composable]
fn app_shell_interactive_tab_host() {
let active_tab = useState(|| 0i32);
let counter = useState(|| 0i32);
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| {
*slot.borrow_mut() = Some(active_tab);
});
APP_SHELL_COUNTER_STATE.with(|slot| {
*slot.borrow_mut() = Some(counter);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Row(Modifier::empty(), RowSpec::default(), || {
Text("Counter", Modifier::empty(), TextStyle::default());
Text("CompositionLocal", Modifier::empty(), TextStyle::default());
Text("WebFetch", Modifier::empty(), TextStyle::default());
});
Box(
Modifier::empty().fill_max_width().weight(1.0),
BoxSpec::default(),
move || {
let active = active_tab.value();
cranpose_core::with_key(&active, || {
app_shell_scrollable_wrapper(move || match active {
0 => app_shell_interactive_counter_test_tab(counter),
1 => app_shell_composition_local_test_tab(),
_ => app_shell_effect_test_tab(),
});
});
},
);
},
);
}
#[composable]
fn app_shell_interactive_clickable_tab_host() {
let active_tab = useState(|| 0i32);
let counter = useState(|| 0i32);
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
let counter_tab = active_tab;
let composition_local_tab = active_tab;
let web_fetch_tab = active_tab;
Row(Modifier::empty(), RowSpec::default(), move || {
Button(
Modifier::empty().padding(8.0),
ButtonSpec::default(),
move || counter_tab.set_value(0),
|| {
Text("Counter App", Modifier::empty(), TextStyle::default());
},
);
Button(
Modifier::empty().padding(8.0),
ButtonSpec::default(),
move || composition_local_tab.set_value(1),
|| {
Text(
"CompositionLocal Test",
Modifier::empty(),
TextStyle::default(),
);
},
);
Button(
Modifier::empty().padding(8.0),
ButtonSpec::default(),
move || web_fetch_tab.set_value(2),
|| {
Text("Web Fetch", Modifier::empty(), TextStyle::default());
},
);
});
Box(
Modifier::empty().fill_max_width().weight(1.0),
BoxSpec::default(),
move || {
let active = active_tab.value();
cranpose_core::with_key(&active, || {
app_shell_scrollable_wrapper(move || match active {
0 => app_shell_interactive_counter_test_tab(counter),
1 => app_shell_composition_local_test_tab(),
_ => app_shell_effect_test_tab(),
});
});
},
);
},
);
}
#[composable]
fn app_shell_demo_like_counter_tab() {
let counter = useState(|| 0i32);
let wave_state = useState(|| 0.35f32);
let pointer_position = useState(Point::default);
let pointer_down = useState(|| false);
let pointer = pointer_position.get();
APP_SHELL_COUNTER_STATE.with(|slot| {
*slot.borrow_mut() = Some(counter);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Box(Modifier::empty(), BoxSpec::default(), move || {
Column(
Modifier::empty()
.padding(32.0)
.rounded_corners(24.0)
.draw_behind(move |scope| {
let phase = wave_state.value();
scope.draw_round_rect(
Brush::linear_gradient(vec![
Color(
0.12 + phase * 0.2,
0.10,
0.24 + (1.0 - phase) * 0.3,
1.0,
),
Color(
0.08,
0.16 + (1.0 - phase) * 0.3,
0.26 + phase * 0.2,
1.0,
),
]),
cranpose_ui::CornerRadii::uniform(24.0),
);
})
.padding(20.0),
ColumnSpec::default(),
move || {
Text(
format!("Counter: {}", counter.get()),
Modifier::empty()
.padding(8.0)
.background(Color(0.0, 0.0, 0.0, 0.35))
.rounded_corners(12.0),
TextStyle::default(),
);
Column(
Modifier::empty()
.rounded_corners(20.0)
.draw_with_cache(|cache| {
cache.on_draw_behind(|scope| {
scope.draw_round_rect(
Brush::solid(Color(0.16, 0.18, 0.26, 0.95)),
cranpose_ui::CornerRadii::uniform(20.0),
);
});
})
.draw_with_content({
let position = pointer_position.get();
let pressed = pointer_down.get();
move |scope| {
let intensity = if pressed { 0.45 } else { 0.25 };
scope.draw_round_rect(
Brush::radial_gradient(
vec![
Color(0.4, 0.6, 1.0, intensity),
Color(0.2, 0.3, 0.6, 0.0),
],
position,
120.0,
),
cranpose_ui::CornerRadii::uniform(20.0),
);
}
})
.pointer_input((), {
move |scope: PointerInputScope| async move {
scope
.await_pointer_event_scope(|await_scope| async move {
loop {
let event =
await_scope.await_pointer_event().await;
match event.kind {
PointerEventKind::Down => {
pointer_down.set(true)
}
PointerEventKind::Up
| PointerEventKind::Cancel => {
pointer_down.set(false)
}
PointerEventKind::Move => {
pointer_position.set(event.position);
}
PointerEventKind::Scroll
| PointerEventKind::Enter
| PointerEventKind::Exit => {}
}
}
})
.await;
}
})
.padding(16.0),
ColumnSpec::default(),
move || {
Text(
format!("Pointer: ({:.1}, {:.1})", pointer.x, pointer.y),
Modifier::empty()
.padding(8.0)
.background(Color(0.1, 0.1, 0.15, 0.6))
.rounded_corners(12.0)
.padding(8.0),
TextStyle::default(),
);
Row(
Modifier::empty()
.padding(8.0)
.rounded_corners(12.0)
.background(Color(0.1, 0.1, 0.15, 0.6))
.padding(8.0),
RowSpec::default(),
move || {
Button(
Modifier::empty()
.rounded_corners(16.0)
.draw_with_cache(|cache| {
cache.on_draw_behind(|scope| {
scope.draw_round_rect(
Brush::linear_gradient(vec![
Color(0.2, 0.45, 0.9, 1.0),
Color(0.15, 0.3, 0.65, 1.0),
]),
cranpose_ui::CornerRadii::uniform(16.0),
);
});
})
.padding(12.0),
ButtonSpec::default(),
move || counter.set(counter.get() + 1),
|| {
Text(
"Increment",
Modifier::empty().padding(6.0),
TextStyle::default(),
);
},
);
},
);
},
);
},
);
});
},
);
}
#[composable]
fn app_shell_demo_like_clickable_tab_host() {
let active_tab = useState(|| 0i32);
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
let counter_tab = active_tab;
let composition_local_tab = active_tab;
Row(Modifier::empty(), RowSpec::default(), move || {
Button(
Modifier::empty().padding(8.0),
ButtonSpec::default(),
move || counter_tab.set_value(0),
|| {
Text("Counter App", Modifier::empty(), TextStyle::default());
},
);
Button(
Modifier::empty().padding(8.0),
ButtonSpec::default(),
move || composition_local_tab.set_value(1),
|| {
Text(
"CompositionLocal Test",
Modifier::empty(),
TextStyle::default(),
);
},
);
});
Box(
Modifier::empty().fill_max_width().weight(1.0),
BoxSpec::default(),
move || {
let active = active_tab.value();
cranpose_core::with_key(&active, || {
app_shell_scrollable_wrapper(move || match active {
0 => app_shell_demo_like_counter_tab(),
_ => app_shell_composition_local_test_tab(),
});
});
},
);
},
);
}
#[composable]
fn app_shell_actual_like_counter_tab() {
cranpose_core::debug_label_current_scope("actual_like_counter_tab");
let counter = useState(|| 0i32);
let wave_state = useState(|| 0.35f32);
let pointer_position = useState(Point::default);
let pointer_down = useState(|| false);
let async_message = useState(|| "Tap \"Fetch async value\" to run background work".to_string());
let fetch_request = useState(|| 0u64);
let pointer = pointer_position.get();
let is_even = counter.get() % 2 == 0;
APP_SHELL_COUNTER_STATE.with(|slot| {
*slot.borrow_mut() = Some(counter);
});
Column(
Modifier::empty().fill_max_size().padding(24.0),
ColumnSpec::default(),
move || {
cranpose_core::with_key(&is_even, move || {
if is_even {
Text(
"if counter % 2 == 0",
Modifier::empty().padding(8.0),
TextStyle::default(),
);
} else {
Text(
"if counter % 2 != 0",
Modifier::empty().padding(8.0),
TextStyle::default(),
);
}
});
Text(
"Cranpose Playground",
Modifier::empty()
.padding(12.0)
.then(
Modifier::empty()
.rounded_corner_shape(RoundedCornerShape::new(16.0, 24.0, 16.0, 24.0)),
)
.draw_with_content(|scope| {
scope.draw_round_rect(
Brush::solid(Color(1.0, 1.0, 1.0, 0.1)),
CornerRadii::uniform(20.0),
);
}),
TextStyle::default(),
);
Row(
Modifier::empty().fill_max_width().padding(8.0),
RowSpec::new()
.horizontal_arrangement(LinearArrangement::SpacedBy(12.0))
.vertical_alignment(VerticalAlignment::CenterVertically),
move || {
Text(
format!("Counter: {}", counter.get()),
Modifier::empty()
.padding(8.0)
.then(Modifier::empty().background(Color(0.0, 0.0, 0.0, 0.35)))
.rounded_corners(12.0),
TextStyle::default(),
);
Text(
"Wave layer-only animation",
Modifier::empty()
.padding(8.0)
.then(Modifier::empty().background(Color(0.35, 0.55, 0.9, 0.5)))
.rounded_corners(12.0)
.graphics_layer(move || {
let wave_value = wave_state.value();
GraphicsLayer {
alpha: 0.7 + wave_value * 0.3,
scale: 0.85 + wave_value * 0.3,
translation_y: (wave_value - 0.5) * 12.0,
..Default::default()
}
}),
TextStyle::default(),
);
},
);
Column(
Modifier::empty()
.rounded_corners(20.0)
.draw_with_cache(|cache| {
cache.on_draw_behind(|scope| {
scope.draw_round_rect(
Brush::solid(Color(0.16, 0.18, 0.26, 0.95)),
CornerRadii::uniform(20.0),
);
});
})
.draw_with_content({
let position = pointer_position.get();
let pressed = pointer_down.get();
move |scope| {
let intensity = if pressed { 0.45 } else { 0.25 };
scope.draw_round_rect(
Brush::radial_gradient(
vec![
Color(0.4, 0.6, 1.0, intensity),
Color(0.2, 0.3, 0.6, 0.0),
],
position,
120.0,
),
CornerRadii::uniform(20.0),
);
}
})
.pointer_input((), {
move |scope: PointerInputScope| async move {
scope
.await_pointer_event_scope(|await_scope| async move {
loop {
let event = await_scope.await_pointer_event().await;
match event.kind {
PointerEventKind::Down => pointer_down.set(true),
PointerEventKind::Up | PointerEventKind::Cancel => {
pointer_down.set(false)
}
PointerEventKind::Move => {
pointer_position.set(Point {
x: event.position.x,
y: event.position.y,
});
}
PointerEventKind::Scroll
| PointerEventKind::Enter
| PointerEventKind::Exit => {}
}
}
})
.await;
}
})
.padding(16.0),
ColumnSpec::default(),
move || {
Text(
format!("Pointer: ({:.1}, {:.1})", pointer.x, pointer.y),
Modifier::empty()
.padding(8.0)
.background(Color(0.1, 0.1, 0.15, 0.6))
.rounded_corners(12.0)
.padding(8.0),
TextStyle::default(),
);
Row(
Modifier::empty()
.padding(8.0)
.rounded_corners(12.0)
.background(Color(0.1, 0.1, 0.15, 0.6))
.padding(8.0),
RowSpec::new()
.horizontal_arrangement(LinearArrangement::SpacedBy(8.0))
.vertical_alignment(VerticalAlignment::CenterVertically),
|| {
for (label, color) in [
("OK", Color(0.3, 0.5, 0.2, 1.0)),
("Cancel", Color(0.5, 0.3, 0.2, 1.0)),
("Long Button Text", Color(0.2, 0.3, 0.5, 1.0)),
] {
Button(
Modifier::empty()
.width_intrinsic(IntrinsicSize::Max)
.rounded_corners(12.0)
.draw_behind(move |scope| {
scope.draw_round_rect(
Brush::solid(color),
CornerRadii::uniform(12.0),
);
})
.padding(10.0),
ButtonSpec::default(),
|| {},
move || {
Text(
label,
Modifier::empty().padding(4.0),
TextStyle::default(),
);
},
);
}
},
);
Row(
Modifier::empty().padding(8.0),
RowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(12.0)),
move || {
Button(
Modifier::empty()
.rounded_corners(16.0)
.draw_with_cache(|cache| {
cache.on_draw_behind(|scope| {
scope.draw_round_rect(
Brush::linear_gradient(vec![
Color(0.2, 0.45, 0.9, 1.0),
Color(0.15, 0.3, 0.65, 1.0),
]),
CornerRadii::uniform(16.0),
);
});
})
.padding(12.0),
ButtonSpec::default(),
move || counter.set(counter.get() + 1),
|| {
Text(
"Increment",
Modifier::empty().padding(6.0),
TextStyle::default(),
);
},
);
Button(
Modifier::empty()
.rounded_corners(16.0)
.draw_behind(|scope| {
scope.draw_round_rect(
Brush::solid(Color(0.4, 0.18, 0.3, 1.0)),
CornerRadii::uniform(16.0),
);
})
.padding(12.0),
ButtonSpec::default(),
move || counter.set(counter.get() - 1),
|| {
Text(
"Decrement",
Modifier::empty().padding(6.0),
TextStyle::default(),
);
},
);
},
);
Text(
async_message.get(),
Modifier::empty()
.padding(10.0)
.background(Color(0.1, 0.18, 0.32, 0.6))
.rounded_corners(14.0),
TextStyle::default(),
);
Button(
Modifier::empty()
.rounded_corners(16.0)
.draw_with_cache(|cache| {
cache.on_draw_behind(|scope| {
scope.draw_round_rect(
Brush::linear_gradient(vec![
Color(0.15, 0.35, 0.85, 1.0),
Color(0.08, 0.2, 0.55, 1.0),
]),
CornerRadii::uniform(16.0),
);
});
})
.padding(12.0),
ButtonSpec::default(),
{
move || {
async_message
.set(format!("Background fetch #{}", fetch_request.get() + 1));
fetch_request.update(|value| *value += 1);
}
},
|| {
Text(
"Fetch async value",
Modifier::empty().padding(6.0),
TextStyle::default(),
);
},
);
},
);
},
);
}
#[composable]
fn app_shell_actual_like_clickable_tab_host() {
let active_tab = useState(|| 0i32);
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
let counter_tab = active_tab;
let composition_local_tab = active_tab;
Row(Modifier::empty(), RowSpec::default(), move || {
Button(
Modifier::empty().padding(8.0),
ButtonSpec::default(),
move || counter_tab.set_value(0),
|| {
Text("Counter App", Modifier::empty(), TextStyle::default());
},
);
Button(
Modifier::empty().padding(8.0),
ButtonSpec::default(),
move || composition_local_tab.set_value(1),
|| {
Text(
"CompositionLocal Test",
Modifier::empty(),
TextStyle::default(),
);
},
);
});
Box(
Modifier::empty()
.fill_max_width()
.weight(1.0)
.graphics_layer(|| GraphicsLayer {
blend_mode: BlendMode::SrcOver,
..Default::default()
}),
BoxSpec::default(),
move || {
let active = active_tab.value();
cranpose_core::with_key(&active, || {
app_shell_scrollable_wrapper(move || match active {
0 => app_shell_actual_like_counter_tab(),
_ => app_shell_composition_local_test_tab(),
});
});
},
);
},
);
}
#[composable]
fn app_shell_many_tabs_clickable_host() {
cranpose_core::debug_label_current_scope("many_tabs_host");
const TAB_LABELS: [&str; 18] = [
"Counter App",
"CompositionLocal Test",
"Async Runtime",
"Animations",
"Web Fetch",
"Text Input",
"Layout",
"Modifiers Showcase",
"Lazy List",
"Mineswapper",
"Hacker News",
"Images",
"Text",
"Winamp",
"Xkcd",
"Shaders",
"Shader Rect",
"Markdown Viewer",
];
let active_tab = useState(|| 0i32);
#[composable]
fn tab_button(index: i32, label: &'static str, active_tab: MutableState<i32>) {
let _ = label;
cranpose_core::debug_label_current_scope("many_tabs_tab_button");
let is_active = active_tab.get() == index;
Button(
Modifier::empty()
.rounded_corners(12.0)
.draw_behind(move |scope| {
scope.draw_round_rect(
Brush::solid(if is_active {
Color(0.2, 0.45, 0.9, 1.0)
} else {
Color(0.3, 0.3, 0.3, 0.5)
}),
CornerRadii::uniform(12.0),
);
})
.padding(10.0),
ButtonSpec::default(),
move || {
if active_tab.get() != index {
active_tab.set_value(index);
}
},
move || {
Text(label, Modifier::empty().padding(4.0), TextStyle::default());
},
);
}
Column(
Modifier::empty().fill_max_size().padding(20.0),
ColumnSpec::default(),
move || {
let tabs_scroll_state =
cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
Row(
Modifier::empty()
.fill_max_width()
.padding(8.0)
.clip_to_bounds()
.horizontal_scroll(tabs_scroll_state, false),
RowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
for (index, label) in TAB_LABELS.iter().enumerate() {
tab_button(index as i32, label, active_tab);
}
},
);
Box(
Modifier::empty().fill_max_width().weight(1.0),
BoxSpec::default(),
move || {
let active = active_tab.get();
cranpose_core::with_key(&active, || {
cranpose_core::debug_label_current_scope("many_tabs_tab_content");
app_shell_scrollable_wrapper(move || match active {
0 => app_shell_actual_like_counter_tab(),
1 => app_shell_composition_local_test_tab(),
_ => {
Text(
format!("Tab {}", active),
Modifier::empty().padding(8.0),
TextStyle::default(),
);
}
});
});
},
);
},
);
}
fn app_shell_many_tab_requires_scroll(tab: i32) -> bool {
!matches!(tab, 10 | 8 | 13 | 17)
}
#[composable]
fn app_shell_many_tabs_precise_tab_button(
index: i32,
label: &'static str,
active_tab: MutableState<i32>,
) {
let is_active = active_tab.get() == index;
Button(
Modifier::empty()
.rounded_corners(12.0)
.draw_behind(move |scope| {
scope.draw_round_rect(
Brush::solid(if is_active {
Color(0.2, 0.45, 0.9, 1.0)
} else {
Color(0.3, 0.3, 0.3, 0.5)
}),
CornerRadii::uniform(12.0),
);
})
.padding(10.0),
ButtonSpec::default(),
move || {
if active_tab.get() != index {
active_tab.set_value(index);
}
},
move || {
Text(label, Modifier::empty().padding(4.0), TextStyle::default());
},
);
}
#[composable]
fn app_shell_many_tabs_precise_tab_bar(active_tab: MutableState<i32>) {
const TAB_LABELS: [&str; 18] = [
"Counter App",
"CompositionLocal Test",
"Async Runtime",
"Animations",
"Web Fetch",
"Text Input",
"Layout",
"Modifiers Showcase",
"Lazy List",
"Mineswapper",
"Hacker News",
"Images",
"Text",
"Winamp",
"Xkcd",
"Shaders",
"Shader Rect",
"Markdown Viewer",
];
let tabs_scroll_state =
cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
Row(
Modifier::empty()
.fill_max_width()
.padding(8.0)
.clip_to_bounds()
.horizontal_scroll(tabs_scroll_state, false),
RowSpec::new().horizontal_arrangement(LinearArrangement::SpacedBy(8.0)),
move || {
for (index, label) in TAB_LABELS.iter().enumerate() {
app_shell_many_tabs_precise_tab_button(index as i32, label, active_tab);
}
},
);
}
#[composable]
fn app_shell_many_tabs_precise_render_active(active: i32) {
match active {
0 => app_shell_actual_like_counter_tab(),
1 => app_shell_composition_local_test_tab(),
_ => {
Text(
format!("Tab {}", active),
Modifier::empty().padding(8.0),
TextStyle::default(),
);
}
}
}
#[composable]
fn app_shell_many_tabs_precise_tab_content(active_tab: MutableState<i32>, modifier: Modifier) {
let active = active_tab.get();
Box(modifier.clip_to_bounds(), BoxSpec::default(), move || {
cranpose_core::with_key(&active, || {
if app_shell_many_tab_requires_scroll(active) {
app_shell_scrollable_wrapper(move || {
app_shell_many_tabs_precise_render_active(active)
});
} else {
app_shell_many_tabs_precise_render_active(active);
}
});
});
}
#[composable]
fn app_shell_many_tabs_precise_clickable_host() {
let active_tab = useState(|| 0i32);
Column(
Modifier::empty().fill_max_size().padding(20.0),
ColumnSpec::default(),
move || {
app_shell_many_tabs_precise_tab_bar(active_tab);
Box(
Modifier::empty().fill_max_width().weight(1.0),
BoxSpec::default(),
move || {
app_shell_many_tabs_precise_tab_content(
active_tab,
Modifier::empty().fill_max_width().weight(1.0),
);
},
);
},
);
}
#[composable]
fn app_shell_animated_draw_counter_tab() {
let phase_state = useState(|| 0.35f32);
Column(
Modifier::empty()
.fill_max_size()
.padding(16.0)
.draw_behind(move |scope| {
let phase = phase_state.value();
scope.draw_round_rect(
Brush::solid(Color(0.15 + phase * 0.2, 0.2, 0.35, 1.0)),
cranpose_ui::CornerRadii::uniform(12.0),
);
}),
ColumnSpec::default(),
move || {
Text(
"Animated Draw Counter",
Modifier::empty().padding(8.0),
TextStyle::default(),
);
Button(
Modifier::empty().padding(8.0),
ButtonSpec::default(),
move || phase_state.set_value(0.8),
|| {
Text("Drive wave", Modifier::empty(), TextStyle::default());
},
);
},
);
}
fn app_shell_animated_draw_tab_host() {
let active_tab = useState(|| 0i32);
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| {
*slot.borrow_mut() = Some(active_tab);
});
Column(
Modifier::empty().fill_max_size(),
ColumnSpec::default(),
move || {
Box(
Modifier::empty().fill_max_width().weight(1.0),
BoxSpec::default(),
move || {
let active = active_tab.value();
cranpose_core::with_key(&active, || {
app_shell_scrollable_wrapper(move || match active {
0 => app_shell_animated_draw_counter_tab(),
_ => app_shell_composition_local_test_tab(),
});
});
},
);
},
);
}
#[test]
fn semantics_enabled_shell_keeps_scrollable_tab_content_after_mixed_switches() {
let _guard = test_guard();
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| slot.borrow_mut().take());
APP_SHELL_COUNTER_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
TestRenderer::default(),
root_key,
app_shell_mixed_scrollable_tab_host,
);
shell.set_semantics_enabled(true);
shell.update();
let active_tab = APP_SHELL_ACTIVE_TAB_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("active tab state registered");
active_tab.set_value(0);
shell.update();
let counter_texts = layout_tree_texts(shell.layout_tree().expect("counter layout tree"));
assert!(
counter_texts
.iter()
.any(|text| text.contains("Counter value 0")),
"counter tab content missing after first switch: {counter_texts:?}",
);
active_tab.set_value(1);
shell.update();
let first_scrollable =
layout_tree_texts(shell.layout_tree().expect("first scrollable layout tree"));
assert!(
first_scrollable
.iter()
.any(|text| text.contains("Composition Local Marker")),
"first scrollable tab content missing: {first_scrollable:?}",
);
assert!(
first_scrollable
.iter()
.any(|text| text.contains("READING local 7")),
"composition local content missing after switch: {first_scrollable:?}",
);
active_tab.set_value(2);
shell.update();
let second_scrollable =
layout_tree_texts(shell.layout_tree().expect("second scrollable layout tree"));
assert!(
second_scrollable
.iter()
.any(|text| text.contains("Web Fetch Marker")),
"second scrollable tab content missing after mixed switches: {second_scrollable:?}",
);
assert!(
second_scrollable
.iter()
.any(|text| text.contains("Effect status Idle")),
"effect tab content missing after mixed switches: {second_scrollable:?}",
);
active_tab.set_value(1);
shell.update();
let restored_local = layout_tree_texts(
shell
.layout_tree()
.expect("restored composition local layout tree"),
);
assert!(
restored_local
.iter()
.any(|text| text.contains("Composition Local Marker")),
"composition local tab content missing after revisit: {restored_local:?}",
);
active_tab.set_value(2);
shell.update();
let restored_effect =
layout_tree_texts(shell.layout_tree().expect("restored effect layout tree"));
assert!(
restored_effect
.iter()
.any(|text| text.contains("Web Fetch Marker")),
"effect tab content missing after revisit: {restored_effect:?}",
);
let counter_state = APP_SHELL_COUNTER_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("counter state registered");
active_tab.set_value(0);
shell.update();
counter_state.set_value(1);
shell.update();
let updated_counter =
layout_tree_texts(shell.layout_tree().expect("updated counter layout tree"));
assert!(
updated_counter
.iter()
.any(|text| text.contains("Counter value 1")),
"counter tab did not update after tab walk: {updated_counter:?}",
);
}
#[test]
fn headless_shell_counter_click_updates_after_restored_scrollable_tab_walk() {
let _guard = test_guard();
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| slot.borrow_mut().take());
APP_SHELL_COUNTER_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_interactive_tab_host,
);
shell.set_semantics_enabled(true);
shell.update();
let active_tab = APP_SHELL_ACTIVE_TAB_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("active tab state registered");
let counter_state = APP_SHELL_COUNTER_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("counter state registered");
assert_eq!(counter_state.value(), 0, "counter should start at zero");
active_tab.set_value(1);
shell.update();
let texts = layout_tree_texts(shell.layout_tree().expect("composition local layout"));
assert!(
texts
.iter()
.any(|text| text.contains("Composition Local Marker")),
"composition local content missing after first switch: {texts:?}",
);
active_tab.set_value(2);
shell.update();
let texts = layout_tree_texts(shell.layout_tree().expect("web fetch layout"));
assert!(
texts.iter().any(|text| text.contains("Web Fetch Marker")),
"web fetch content missing after first visit: {texts:?}",
);
active_tab.set_value(1);
shell.update();
let texts = layout_tree_texts(shell.layout_tree().expect("restored local layout"));
assert!(
texts.iter().any(|text| text.contains("READING local 7")),
"composition local content missing after revisit: {texts:?}",
);
active_tab.set_value(0);
shell.update();
let texts = layout_tree_texts(shell.layout_tree().expect("counter layout"));
assert!(
texts.iter().any(|text| text.contains("Counter value 0")),
"counter tab content missing after restore: {texts:?}",
);
let interactive_box = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Pointer 0.0,0.0 down=false",
)
.expect("pointer text in layout tree");
let start_x = interactive_box.rect.x + interactive_box.rect.width * 0.5;
let start_y = interactive_box.rect.y + 80.0;
for step in 0..12 {
let x = start_x + step as f32 * 4.0;
let y = start_y + step as f32 * 6.0;
let _ = shell.set_cursor(x, y);
shell.update();
}
click_text(&mut shell, "Increment");
let texts = layout_tree_texts(shell.layout_tree().expect("updated counter layout"));
assert_eq!(
counter_state.value(),
1,
"button click should mutate the counter state; texts={texts:?}",
);
assert!(
texts.iter().any(|text| text.contains("Counter value 1")),
"counter text did not update after restored tab walk click: {texts:?}",
);
}
#[test]
fn headless_shell_counter_click_updates_after_tab_button_roundtrip() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_interactive_clickable_tab_host,
);
shell.set_semantics_enabled(true);
shell.update();
click_text(&mut shell, "CompositionLocal Test");
let texts = layout_tree_texts(shell.layout_tree().expect("composition local layout"));
assert!(
texts
.iter()
.any(|text| text.contains("Composition Local Marker")),
"composition local content missing after tab click: {texts:?}",
);
click_text(&mut shell, "Counter App");
let texts = layout_tree_texts(shell.layout_tree().expect("counter layout"));
assert!(
texts.iter().any(|text| text.contains("Counter value 0")),
"counter content missing after return click: {texts:?}",
);
let interactive_box = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Pointer 0.0,0.0 down=false",
)
.expect("pointer text in layout tree");
let start_x = interactive_box.rect.x + interactive_box.rect.width * 0.5;
let start_y = interactive_box.rect.y + 80.0;
for step in 0..12 {
let x = start_x + step as f32 * 4.0;
let y = start_y + step as f32 * 6.0;
let _ = shell.set_cursor(x, y);
shell.update();
}
click_text(&mut shell, "Increment");
let texts = layout_tree_texts(shell.layout_tree().expect("updated counter layout"));
assert!(
texts.iter().any(|text| text.contains("Counter value 1")),
"counter text did not update after tab button roundtrip click: {texts:?}",
);
}
#[test]
fn parent_pointer_listener_releases_after_child_button_click_recomposes() {
let _guard = test_guard();
APP_SHELL_COUNTER_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_interactive_clickable_tab_host,
);
shell.set_semantics_enabled(true);
shell.update();
let increment = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Increment",
)
.expect("increment button in layout tree");
let button_x = increment.rect.x + increment.rect.width * 0.5;
let button_y = increment.rect.y + increment.rect.height * 0.5;
assert!(shell.set_cursor(button_x, button_y));
shell.update();
assert!(shell.pointer_pressed(), "pointer down should hit increment");
shell.update();
let pressed_texts = layout_tree_texts(shell.layout_tree().expect("pressed counter layout"));
assert!(
pressed_texts.iter().any(|text| text.contains("down=true")),
"parent pointer listener did not observe Down before child click: {pressed_texts:?}",
);
assert!(
shell.pointer_released(),
"pointer up should reach captured child and parent targets"
);
shell.update();
let released_texts = layout_tree_texts(shell.layout_tree().expect("released counter layout"));
assert!(
released_texts
.iter()
.any(|text| text.contains("Counter value 1")),
"button click did not update counter after release: {released_texts:?}",
);
assert!(
released_texts
.iter()
.any(|text| text.contains("down=false")),
"parent pointer listener stayed pressed after child button release: {released_texts:?}",
);
}
#[test]
fn headless_shell_render_graph_survives_restored_draw_state() {
let _guard = test_guard();
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_animated_draw_tab_host,
);
shell.update();
let active_tab = APP_SHELL_ACTIVE_TAB_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("active tab state registered");
active_tab.set_value(1);
shell.update();
active_tab.set_value(0);
shell.update();
let texts = layout_tree_texts(shell.layout_tree().expect("animated draw layout"));
assert!(
texts
.iter()
.any(|text| text.contains("Animated Draw Counter")),
"animated draw counter content missing after restore: {texts:?}",
);
}
#[test]
fn headless_shell_demo_like_counter_click_updates_after_tab_roundtrip() {
let _guard = test_guard();
APP_SHELL_COUNTER_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_demo_like_clickable_tab_host,
);
shell.set_semantics_enabled(true);
shell.update();
click_text(&mut shell, "CompositionLocal Test");
let texts = layout_tree_texts(shell.layout_tree().expect("composition local layout"));
assert!(
texts
.iter()
.any(|text| text.contains("Composition Local Marker")),
"composition local content missing after tab click: {texts:?}",
);
click_text(&mut shell, "Counter App");
let texts = layout_tree_texts(shell.layout_tree().expect("counter layout"));
assert!(
texts.iter().any(|text| text.contains("Counter: 0")),
"counter content missing after return click: {texts:?}",
);
let pointer_text = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Pointer: (0.0, 0.0)",
)
.expect("pointer text in layout tree");
let start_x = pointer_text.rect.x + pointer_text.rect.width * 0.5;
let start_y = pointer_text.rect.y + 80.0;
for step in 0..12 {
let x = start_x + step as f32 * 4.0;
let y = start_y + step as f32 * 6.0;
let _ = shell.set_cursor(x, y);
shell.update();
}
click_text(&mut shell, "Increment");
let texts = layout_tree_texts(shell.layout_tree().expect("updated counter layout"));
assert!(
texts.iter().any(|text| text.contains("Counter: 1")),
"demo-like counter text did not update after restored tab walk click: {texts:?}",
);
}
#[test]
fn headless_shell_demo_like_counter_click_updates_with_robot_style_pumps() {
let _guard = test_guard();
APP_SHELL_COUNTER_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_demo_like_clickable_tab_host,
);
shell.set_semantics_enabled(true);
shell.update();
click_text(&mut shell, "CompositionLocal Test");
click_text(&mut shell, "Counter App");
let pointer_text = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Pointer: (0.0, 0.0)",
)
.expect("pointer text in layout tree");
let start_x = pointer_text.rect.x + pointer_text.rect.width * 0.5;
let start_y = pointer_text.rect.y + 80.0;
for step in 0..12 {
let x = start_x + step as f32 * 4.0;
let y = start_y + step as f32 * 6.0;
let _ = shell.set_cursor(x, y);
pump_like_robot(&mut shell);
}
let increment = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Increment",
)
.expect("increment button in layout tree");
let button_x = increment.rect.x + increment.rect.width * 0.5;
let button_y = increment.rect.y + increment.rect.height * 0.5;
let _ = shell.set_cursor(button_x, button_y);
pump_like_robot(&mut shell);
assert!(shell.pointer_pressed(), "pointer down should hit increment");
pump_like_robot(&mut shell);
assert!(shell.pointer_released(), "pointer up should hit increment");
pump_like_robot(&mut shell);
let texts = layout_tree_texts(shell.layout_tree().expect("updated counter layout"));
assert!(
texts.iter().any(|text| text.contains("Counter: 1")),
"demo-like counter text did not update with robot-style pumping: {texts:?}",
);
}
#[test]
fn headless_shell_actual_like_counter_click_updates_with_robot_click_order() {
let _guard = test_guard();
APP_SHELL_COUNTER_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_actual_like_clickable_tab_host,
);
shell.set_semantics_enabled(true);
shell.update();
click_text_like_robot(&mut shell, "CompositionLocal Test");
click_text_like_robot(&mut shell, "Counter App");
let pointer_text = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Pointer: (0.0, 0.0)",
)
.expect("pointer text in layout tree");
let start_x = pointer_text.rect.x + pointer_text.rect.width * 0.5;
let start_y = pointer_text.rect.y + 80.0;
for step in 0..20 {
let progress = step as f32 / 19.0;
let x = start_x + (80.0 - start_x) * progress;
let y = start_y + (230.0 - start_y) * progress;
let _ = shell.set_cursor(x, y);
pump_like_robot(&mut shell);
}
let increment = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Increment",
)
.expect("increment button in layout tree");
let button_x = increment.rect.x + increment.rect.width * 0.5;
let button_y = increment.rect.y + increment.rect.height * 0.5;
let _ = shell.set_cursor(button_x, button_y);
pump_like_robot(&mut shell);
assert!(shell.set_cursor(button_x, button_y));
assert!(shell.pointer_pressed(), "pointer down should hit increment");
pump_like_robot(&mut shell);
assert!(shell.pointer_released(), "pointer up should hit increment");
pump_like_robot(&mut shell);
let texts = layout_tree_texts(shell.layout_tree().expect("updated counter layout"));
assert!(
texts.iter().any(|text| text.contains("Counter: 1")),
"actual-like counter text did not update after robot-style click order: {texts:?}",
);
}
#[test]
fn app_shell_single_frame_callback_returns_to_idle() {
let _guard = test_guard();
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
one_shot_frame_request_content,
);
let mut saw_applied_frame = false;
for _ in 0..8 {
pump_like_robot(&mut shell);
let texts = layout_tree_texts(shell.layout_tree().expect("layout tree available"));
if texts.iter().any(|text| text == "Frame Applied") {
saw_applied_frame = true;
break;
}
}
assert!(
saw_applied_frame,
"one-shot frame callback never completed: {:?}",
layout_tree_texts(shell.layout_tree().expect("layout tree available"))
);
for _ in 0..3 {
pump_like_robot(&mut shell);
}
assert!(
!shell.has_active_animations(),
"shell remained active after the one-shot frame callback completed"
);
assert!(
!shell.needs_redraw(),
"shell kept requesting redraw after the one-shot frame callback completed"
);
}
#[test]
fn app_shell_hidden_frame_loop_tab_returns_to_idle() {
let _guard = test_guard();
APP_SHELL_ACTIVE_TAB_STATE.with(|slot| slot.borrow_mut().take());
APP_SHELL_CONTINUOUS_FRAME_COUNT.with(|count| count.set(0));
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_continuous_then_static_tab_host,
);
for _ in 0..4 {
pump_like_robot(&mut shell);
}
let active = APP_SHELL_ACTIVE_TAB_STATE
.with(|slot| slot.borrow().as_ref().copied())
.expect("active tab registered");
let animated_frames = APP_SHELL_CONTINUOUS_FRAME_COUNT.with(Cell::get);
assert!(
animated_frames > 0,
"test setup did not drive the animated tab"
);
active.set_value(1);
shell.update();
let texts = layout_tree_texts(shell.layout_tree().expect("static tab layout"));
assert!(
texts.iter().any(|text| text == "Static tab"),
"static tab did not render after switching away: {texts:?}"
);
let after_switch_frame = APP_SHELL_CONTINUOUS_FRAME_COUNT.with(Cell::get);
for _ in 0..4 {
pump_like_robot(&mut shell);
}
let after_idle_pumps = APP_SHELL_CONTINUOUS_FRAME_COUNT.with(Cell::get);
assert_eq!(
after_idle_pumps, after_switch_frame,
"hidden frame loop kept advancing after the tab was removed"
);
assert!(
!shell.has_active_animations(),
"hidden frame loop kept AppShell active"
);
assert!(
!shell.needs_redraw(),
"hidden frame loop kept requesting redraw"
);
assert!(
!shell.frame_schedule().needs_frame,
"idle shell must not schedule a platform frame after hidden frame loops settle"
);
}
#[test]
fn app_shell_manual_frame_interval_advances_frame_clock_monotonically() {
let _guard = test_guard();
APP_SHELL_FRAME_TIME_RECORDS.with(|records| records.borrow_mut().clear());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
frame_time_recorder_content,
);
for _ in 0..4 {
shell.update_after_frame_interval(Duration::from_nanos(16_666_667));
}
let records = APP_SHELL_FRAME_TIME_RECORDS.with(|records| records.borrow().clone());
assert_eq!(records.len(), 2, "expected two recorded frame callbacks");
assert!(
records[1] > records[0],
"manual frame pumping must advance animation time monotonically: {records:?}"
);
}
#[test]
fn headless_shell_many_tabs_counter_click_updates_with_robot_click_order() {
let _guard = test_guard();
APP_SHELL_COUNTER_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_many_tabs_clickable_host,
);
shell.set_semantics_enabled(true);
shell.update();
click_text_like_robot(&mut shell, "CompositionLocal Test");
click_text_like_robot(&mut shell, "Counter App");
let pointer_text = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Pointer: (0.0, 0.0)",
)
.expect("pointer text in layout tree");
let start_x = pointer_text.rect.x + pointer_text.rect.width * 0.5;
let start_y = pointer_text.rect.y + 80.0;
for step in 0..20 {
let progress = step as f32 / 19.0;
let x = start_x + (80.0 - start_x) * progress;
let y = start_y + (230.0 - start_y) * progress;
let _ = shell.set_cursor(x, y);
pump_like_robot(&mut shell);
}
let increment = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Increment",
)
.expect("increment button in layout tree");
let button_x = increment.rect.x + increment.rect.width * 0.5;
let button_y = increment.rect.y + increment.rect.height * 0.5;
let _ = shell.set_cursor(button_x, button_y);
pump_like_robot(&mut shell);
assert!(shell.set_cursor(button_x, button_y));
assert!(shell.pointer_pressed(), "pointer down should hit increment");
pump_like_robot(&mut shell);
assert!(shell.pointer_released(), "pointer up should hit increment");
pump_like_robot(&mut shell);
let texts = layout_tree_texts(shell.layout_tree().expect("updated counter layout"));
assert!(
texts.iter().any(|text| text.contains("Counter: 1")),
"many-tab counter text did not update after robot-style click order: {texts:?}",
);
}
#[test]
fn headless_shell_many_tabs_precise_counter_click_updates_with_robot_click_order() {
let _guard = test_guard();
APP_SHELL_COUNTER_STATE.with(|slot| slot.borrow_mut().take());
let root_key = location_key(file!(), line!(), column!());
let mut shell = AppShell::new(
HitGraphRenderer::default(),
root_key,
app_shell_many_tabs_precise_clickable_host,
);
shell.set_semantics_enabled(true);
shell.update();
click_text_like_robot(&mut shell, "CompositionLocal Test");
click_text_like_robot(&mut shell, "Counter App");
let pointer_text = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Pointer: (0.0, 0.0)",
)
.expect("pointer text in layout tree");
let start_x = pointer_text.rect.x + pointer_text.rect.width * 0.5;
let start_y = pointer_text.rect.y + 80.0;
for step in 0..20 {
let progress = step as f32 / 19.0;
let x = start_x + (80.0 - start_x) * progress;
let y = start_y + (230.0 - start_y) * progress;
let _ = shell.set_cursor(x, y);
pump_like_robot(&mut shell);
}
let increment = find_layout_box_with_text(
shell.layout_tree().expect("counter layout tree").root(),
"Increment",
)
.expect("increment button in layout tree");
let button_x = increment.rect.x + increment.rect.width * 0.5;
let button_y = increment.rect.y + increment.rect.height * 0.5;
let _ = shell.set_cursor(button_x, button_y);
pump_like_robot(&mut shell);
assert!(shell.set_cursor(button_x, button_y));
assert!(shell.pointer_pressed(), "pointer down should hit increment");
pump_like_robot(&mut shell);
assert!(shell.pointer_released(), "pointer up should hit increment");
pump_like_robot(&mut shell);
let texts = layout_tree_texts(shell.layout_tree().expect("updated counter layout"));
assert!(
texts.iter().any(|text| text.contains("Counter: 1")),
"many-tab precise counter text did not update after robot-style click order: {texts:?}",
);
}
fn layout_box_at_path<'a>(
layout: &'a cranpose_ui::LayoutBox,
path: &[usize],
) -> &'a cranpose_ui::LayoutBox {
let mut current = layout;
for &index in path {
current = current
.children
.get(index)
.expect("expected layout child at path");
}
current
}
fn node_id_at_path(layout: &cranpose_ui::LayoutBox, path: &[usize]) -> cranpose_core::NodeId {
layout_box_at_path(layout, path).node_id
}
fn find_rect_width(scene: &cranpose_ui::RecordedRenderScene, color: Color) -> Option<f32> {
for op in scene.operations() {
if let RenderOp::Primitive {
primitive: DrawPrimitive::Rect { rect, brush },
..
} = op
{
if *brush == Brush::solid(color) {
return Some(rect.width);
}
}
}
None
}