1use std::{collections::HashSet, rc::Rc};
2
3use cranpose_core::{MemoryApplier, NodeId};
4use cranpose_ui::{
5 DrawCommand, LayoutBox, LayoutNode, ModifierNodeSlices, Point, Rect, ResolvedModifiers, Size,
6 SubcomposeLayoutNode, TextLayoutOptions, TextOverflow, TextPanResolver, prepare_text_layout,
7 text::{AnnotatedString, TextAlign, TextStyle, resolve_text_direction},
8};
9use cranpose_ui_graphics::{
10 CommandRecording, CompositingStrategy, GraphicsLayer, LayerShape, RoundedCornerShape,
11 rounded_corner_alpha_mask_effect,
12};
13
14use crate::{
15 graph::{
16 CachePolicy, DrawCommandId, DrawRunNode, HitTestNode, IsolationReasons, LayerNode,
17 PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph,
18 RenderNode, TextPrimitiveNode,
19 },
20 layer_transform::layer_transform_to_parent,
21 raster_cache::LayerRasterCacheHashes,
22 style_shared::{DrawPlacement, recording_for_placement_reusing},
23};
24
25const TEXT_CLIP_PAD: f32 = 1.0;
26const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
27
28#[derive(Clone, Default)]
29struct BuildNodeSnapshot {
30 node_id: NodeId,
31 placement: Point,
32 size: Size,
33 content_offset: Point,
34 motion_context_animated: bool,
35 translated_content_context: bool,
36 has_own_origin_sinks: bool,
37 measured_max_width: Option<f32>,
38 resolved_modifiers: ResolvedModifiers,
39 draw_commands: Vec<DrawCommand>,
40 outer_draw_command_count: usize,
41 click_actions: Vec<Rc<dyn Fn(Point)>>,
42 pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
43 clip_to_bounds: bool,
44 annotated_text: Option<AnnotatedString>,
45 text_style: Option<TextStyle>,
46 text_layout_options: Option<TextLayoutOptions>,
47 text_pan: Option<TextPanResolver>,
48 graphics_layer: Option<GraphicsLayer>,
49 children: Vec<Self>,
50}
51
52struct SnapshotNodeData {
53 layout_state: cranpose_ui::widgets::LayoutState,
54 modifier_slices: Rc<ModifierNodeSlices>,
55 resolved_modifiers: ResolvedModifiers,
56 children: Vec<NodeId>,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum GraphRebuildReason {
69 RootLayerUnavailable,
71 DirtyLayerUnavailable,
73 UnmatchedDirtyNodes(usize),
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum GraphUpdate {
82 Patched,
83 NeedsRebuild(GraphRebuildReason),
84}
85
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub struct GraphUpdateReport {
88 pub update: GraphUpdate,
89 pub hit_graph_dirty: bool,
90}
91
92impl GraphUpdateReport {
93 pub fn applied(self) -> bool {
94 matches!(self.update, GraphUpdate::Patched)
95 }
96
97 pub fn rebuild_reason(self) -> Option<GraphRebuildReason> {
98 match self.update {
99 GraphUpdate::Patched => None,
100 GraphUpdate::NeedsRebuild(reason) => Some(reason),
101 }
102 }
103}
104
105#[cfg(test)]
106thread_local! {
107 static LOWERED_LAYER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
108}
109
110fn note_layer_lowered() {
111 #[cfg(test)]
112 LOWERED_LAYER_COUNT.with(|count| count.set(count.get() + 1));
113}
114
115#[cfg(test)]
116fn reset_lowered_layer_count() {
117 LOWERED_LAYER_COUNT.with(|count| count.set(0));
118}
119
120#[cfg(test)]
121fn lowered_layer_count() -> usize {
122 LOWERED_LAYER_COUNT.with(std::cell::Cell::get)
123}
124
125pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
126 bump_recording_generation();
127 let root_snapshot = layout_box_to_snapshot(root, None);
128 RenderGraph {
129 root: build_layer_node(root_snapshot, scale, false),
130 }
131}
132
133pub fn build_graph_from_applier(
134 applier: &mut MemoryApplier,
135 root: NodeId,
136 scale: f32,
137) -> Option<RenderGraph> {
138 bump_recording_generation();
139 Some(RenderGraph {
140 root: build_layer_node_from_applier(applier, root, scale, false)?,
141 })
142}
143
144pub fn update_graph_from_applier(
145 applier: &mut MemoryApplier,
146 graph: &mut RenderGraph,
147 dirty_nodes: &[NodeId],
148 scale: f32,
149) -> bool {
150 update_graph_from_applier_report(applier, graph, dirty_nodes, scale).applied()
151}
152
153pub fn update_graph_from_applier_report(
154 applier: &mut MemoryApplier,
155 graph: &mut RenderGraph,
156 dirty_nodes: &[NodeId],
157 scale: f32,
158) -> GraphUpdateReport {
159 let mut changed_nodes = Vec::new();
160 update_graph_from_applier_report_into(applier, graph, dirty_nodes, scale, &mut changed_nodes)
161}
162
163pub fn update_graph_from_applier_report_into(
164 applier: &mut MemoryApplier,
165 graph: &mut RenderGraph,
166 dirty_nodes: &[NodeId],
167 scale: f32,
168 changed_nodes: &mut Vec<NodeId>,
169) -> GraphUpdateReport {
170 let report = update_graph_from_applier_report_into_inner(
171 applier,
172 graph,
173 dirty_nodes,
174 scale,
175 changed_nodes,
176 );
177 if let GraphUpdate::NeedsRebuild(reason) = report.update
178 && cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG")
179 {
180 eprintln!(
181 "[scene-update-diag] scoped update abandoned, whole scene rebuilt: {reason:?} dirty={}",
182 dirty_nodes.len()
183 );
184 }
185 report
186}
187
188fn update_graph_from_applier_report_into_inner(
189 applier: &mut MemoryApplier,
190 graph: &mut RenderGraph,
191 dirty_nodes: &[NodeId],
192 scale: f32,
193 changed_nodes: &mut Vec<NodeId>,
194) -> GraphUpdateReport {
195 if dirty_nodes.is_empty() {
196 return GraphUpdateReport {
197 update: GraphUpdate::Patched,
198 hit_graph_dirty: false,
199 };
200 }
201 bump_recording_generation();
202
203 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
204 eprintln!("[scene-update-diag] dirty={dirty_nodes:?}");
205 }
206
207 let mut remaining_dirty_nodes = dirty_nodes.iter().copied().collect::<HashSet<_>>();
208 if let Some(root_id) = graph.root.node_id
209 && remaining_dirty_nodes.contains(&root_id)
210 {
211 remaining_dirty_nodes.remove(&root_id);
212 if try_translate_scrolled_layer(
213 applier,
214 &mut graph.root,
215 &mut remaining_dirty_nodes,
216 changed_nodes,
217 TranslateAncestorContext {
218 ancestor_hashed: false,
219 inherited_translated_content_context: false,
220 parent_content_offset: Point::default(),
221 parent_abs: AbsOrigin::ROOT,
222 },
223 ) {
224 if remaining_dirty_nodes.is_empty() {
225 return GraphUpdateReport {
226 update: GraphUpdate::Patched,
227 hit_graph_dirty: true,
228 };
229 }
230 let inherited = graph.root.translated_content_context;
231 let walked = replace_dirty_layers_from_applier(
232 applier,
233 &mut graph.root,
234 &mut remaining_dirty_nodes,
235 inherited,
236 false,
237 changed_nodes,
238 );
239 return GraphUpdateReport {
240 update: classify_walk(walked.is_some(), &remaining_dirty_nodes),
241 hit_graph_dirty: true,
242 };
243 }
244 let Some(root) = build_layer_node_from_applier(applier, root_id, scale, false) else {
245 return GraphUpdateReport {
246 update: GraphUpdate::NeedsRebuild(GraphRebuildReason::RootLayerUnavailable),
247 hit_graph_dirty: true,
248 };
249 };
250 let hit_graph_dirty = layer_hit_graph_state_dirty(&graph.root, &root);
251 collect_layer_node_ids(&graph.root, changed_nodes);
252 graph.root = root;
253 graph.root.recompute_raster_cache_hashes();
254 collect_layer_node_ids(&graph.root, changed_nodes);
255 return GraphUpdateReport {
256 update: GraphUpdate::Patched,
257 hit_graph_dirty,
258 };
259 }
260
261 let inherited_translated_content_context = graph.root.translated_content_context;
262 let report = match replace_dirty_layers_from_applier(
263 applier,
264 &mut graph.root,
265 &mut remaining_dirty_nodes,
266 inherited_translated_content_context,
267 false,
268 changed_nodes,
269 ) {
270 Some(report) => report,
271 None => {
272 return GraphUpdateReport {
273 update: GraphUpdate::NeedsRebuild(GraphRebuildReason::DirtyLayerUnavailable),
274 hit_graph_dirty: true,
275 };
276 }
277 };
278
279 match classify_walk(true, &remaining_dirty_nodes) {
280 GraphUpdate::Patched => GraphUpdateReport {
281 update: GraphUpdate::Patched,
282 hit_graph_dirty: report.hit_graph_dirty,
283 },
284 update => GraphUpdateReport {
285 update,
286 hit_graph_dirty: true,
287 },
288 }
289}
290
291fn classify_walk(walked: bool, remaining_dirty_nodes: &HashSet<NodeId>) -> GraphUpdate {
292 if !walked {
293 GraphUpdate::NeedsRebuild(GraphRebuildReason::DirtyLayerUnavailable)
294 } else if !remaining_dirty_nodes.is_empty() {
295 GraphUpdate::NeedsRebuild(GraphRebuildReason::UnmatchedDirtyNodes(
296 remaining_dirty_nodes.len(),
297 ))
298 } else {
299 GraphUpdate::Patched
300 }
301}
302
303#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
304struct ReplaceDirtyLayersReport {
305 updated: bool,
306 hit_graph_dirty: bool,
307}
308
309fn replace_dirty_layers_from_applier(
310 applier: &mut MemoryApplier,
311 parent: &mut LayerNode,
312 dirty_nodes: &mut HashSet<NodeId>,
313 inherited_translated_content_context: bool,
314 ancestor_hashed: bool,
315 changed_nodes: &mut Vec<NodeId>,
316) -> Option<ReplaceDirtyLayersReport> {
317 if dirty_nodes.is_empty() {
318 return Some(ReplaceDirtyLayersReport::default());
319 }
320
321 let child_inherited_translated_content_context =
322 inherited_translated_content_context || parent.translated_content_context;
323 let child_ancestor_hashed =
324 crate::graph_hash::layer_children_ancestor_hashed(parent, ancestor_hashed);
325 let mut report = ReplaceDirtyLayersReport::default();
326
327 for child in &mut parent.children {
328 let RenderNode::Layer(child_layer) = child else {
329 continue;
330 };
331
332 if layer_identity(child_layer).is_some_and(|node_id| dirty_nodes.remove(&node_id)) {
333 if try_translate_scrolled_layer(
334 applier,
335 child_layer,
336 dirty_nodes,
337 changed_nodes,
338 TranslateAncestorContext {
339 ancestor_hashed: child_ancestor_hashed,
340 inherited_translated_content_context:
341 child_inherited_translated_content_context,
342 parent_content_offset: parent.content_offset,
343 parent_abs: AbsOrigin {
344 content_origin: parent.scene_children_origin,
345 layer_translation: parent.scene_children_layer_translation,
346 },
347 },
348 ) {
349 report.hit_graph_dirty = true;
350 report.updated = true;
351 let child_report = replace_dirty_layers_from_applier(
352 applier,
353 child_layer,
354 dirty_nodes,
355 child_inherited_translated_content_context,
356 child_ancestor_hashed,
357 changed_nodes,
358 )?;
359 report.hit_graph_dirty |= child_report.hit_graph_dirty;
360 continue;
361 }
362 let mut replacement = build_layer_node_from_applier_internal(
363 applier,
364 layer_identity(child_layer).expect("dirty layer must have a node id"),
365 parent.motion_context_animated,
366 child_inherited_translated_content_context,
367 Some(AbsOrigin {
368 content_origin: parent.scene_children_origin,
369 layer_translation: parent.scene_children_layer_translation,
370 }),
371 )?;
372 if parent.content_offset != Point::default() {
373 replacement.transform_to_parent =
374 replacement
375 .transform_to_parent
376 .then(ProjectiveTransform::translation(
377 parent.content_offset.x,
378 parent.content_offset.y,
379 ));
380 }
381 report.hit_graph_dirty |= layer_hit_graph_state_dirty(child_layer, &replacement);
382 remove_dirty_descendants(&replacement, dirty_nodes);
383 collect_layer_node_ids(child_layer, changed_nodes);
384 **child_layer = replacement;
385 collect_layer_node_ids(child_layer, changed_nodes);
386 crate::graph_hash::recompute_layer_raster_cache_hashes_under(
387 child_layer,
388 child_ancestor_hashed,
389 );
390 report.updated = true;
391 continue;
392 }
393
394 let child_report = replace_dirty_layers_from_applier(
395 applier,
396 child_layer,
397 dirty_nodes,
398 child_inherited_translated_content_context,
399 child_ancestor_hashed,
400 changed_nodes,
401 )?;
402 report.updated |= child_report.updated;
403 report.hit_graph_dirty |= child_report.hit_graph_dirty;
404 }
405
406 if report.updated {
407 parent.has_hit_targets = parent.hit_test.is_some()
408 || parent.children.iter().any(|child| match child {
409 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
410 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
411 });
412 crate::graph_hash::refresh_layer_own_raster_cache_hashes(parent, ancestor_hashed);
413 if let Some(node_id) = parent.node_id {
414 changed_nodes.push(node_id);
415 }
416 }
417
418 Some(report)
419}
420
421fn translate_bail(reason: &str) -> bool {
422 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
423 eprintln!("[scene-update-diag] translate bail: {reason}");
424 }
425 false
426}
427
428#[derive(Clone, Copy)]
429struct TranslateAncestorContext {
430 ancestor_hashed: bool,
431 inherited_translated_content_context: bool,
432 parent_content_offset: Point,
433 parent_abs: AbsOrigin,
434}
435
436fn try_translate_scrolled_layer(
437 applier: &mut MemoryApplier,
438 container: &mut LayerNode,
439 dirty_nodes: &mut HashSet<NodeId>,
440 changed_nodes: &mut Vec<NodeId>,
441 ancestors: TranslateAncestorContext,
442) -> bool {
443 let TranslateAncestorContext {
444 ancestor_hashed: container_ancestor_hashed,
445 inherited_translated_content_context,
446 parent_content_offset,
447 parent_abs,
448 } = ancestors;
449 if cranpose_core::env_flag!("CRANPOSE_DISABLE_SCROLL_TRANSLATE") {
450 return translate_bail("fast path disabled by ablation switch");
451 }
452 let Some(node_id) = container.node_id else {
453 return translate_bail("no node id");
454 };
455 if container
456 .children
457 .iter()
458 .any(|child| !matches!(child, RenderNode::Layer(_)))
459 {
460 return translate_bail("container has own primitive children");
461 }
462 let Some(data) = snapshot_node_data(applier, node_id) else {
463 return translate_bail("container snapshot read failed");
464 };
465 let SnapshotNodeData {
466 layout_state,
467 modifier_slices,
468 resolved_modifiers: _,
469 children: fresh_children,
470 } = data;
471 if !layout_state.is_placed()
472 || layout_state.size().width != container.local_bounds.width
473 || layout_state.size().height != container.local_bounds.height
474 {
475 return translate_bail("container unplaced or resized");
476 }
477 if !modifier_slices.draw_commands().is_empty()
478 || modifier_slices.annotated_text().is_some()
479 || modifier_slices.translated_content_context() != container.translated_content_context
480 {
481 return translate_bail("container draw/text/translated-context changed");
482 }
483 let clip_to_bounds = modifier_slices.clip_to_bounds();
484 if clip_to_bounds != container.clip_to_bounds {
485 return translate_bail("container clip changed");
486 }
487 let graphics_layer = graphics_layer_with_shaped_clip(
488 modifier_slices.graphics_layer().unwrap_or_default(),
489 clip_to_bounds,
490 modifier_slices.corner_shape(),
491 container.local_bounds,
492 );
493 if graphics_layer != container.graphics_layer {
494 return translate_bail("container graphics layer changed");
495 }
496 let mut old_ids = Vec::with_capacity(container.children.len());
497 for child in &container.children {
498 let RenderNode::Layer(layer) = child else {
499 return translate_bail("non-layer child");
500 };
501 let Some(child_id) = layer_identity(layer) else {
502 return translate_bail("child without node id");
503 };
504 old_ids.push(child_id);
505 }
506 let old_index_by_id: std::collections::HashMap<NodeId, usize> = old_ids
507 .iter()
508 .enumerate()
509 .map(|(index, id)| (*id, index))
510 .collect();
511 let mut placed_fresh = Vec::with_capacity(fresh_children.len());
512 for child_id in &fresh_children {
513 let state = applier
514 .with_node::<LayoutNode, _>(*child_id, |node| node.layout_state())
515 .or_else(|_| {
516 applier.with_node::<SubcomposeLayoutNode, _>(*child_id, |node| node.layout_state())
517 });
518 let Ok(state) = state else {
519 continue;
520 };
521 if !state.is_placed() {
522 continue;
523 }
524 placed_fresh.push((*child_id, state));
525 }
526 let fresh_id_set: HashSet<NodeId> = placed_fresh.iter().map(|(id, _)| *id).collect();
527 for (child_id, state) in &placed_fresh {
528 let Some(&old_index) = old_index_by_id.get(child_id) else {
529 continue;
530 };
531 let RenderNode::Layer(layer) = &container.children[old_index] else {
532 return false;
533 };
534 if dirty_nodes.contains(child_id) {
535 continue;
536 }
537 if layer.has_origin_sinks {
538 return translate_bail("child subtree publishes window origins");
539 }
540 if state.size().width != layer.local_bounds.width
541 || state.size().height != layer.local_bounds.height
542 {
543 return translate_bail("child resized");
544 }
545 }
546
547 let content_offset = layout_state.content_offset;
548 let (translation_x, translation_y) = modifier_slices
549 .graphics_layer()
550 .map(|layer| (layer.translation_x, layer.translation_y))
551 .unwrap_or((0.0, 0.0));
552 let top_left = Point {
553 x: parent_abs.content_origin.x + layout_state.position().x,
554 y: parent_abs.content_origin.y + layout_state.position().y,
555 };
556 let layer_translation = Point {
557 x: parent_abs.layer_translation.x + translation_x,
558 y: parent_abs.layer_translation.y + translation_y,
559 };
560 let window_origin = Point {
561 x: top_left.x + layer_translation.x,
562 y: top_left.y + layer_translation.y,
563 };
564 let child_origin = Point {
565 x: top_left.x + content_offset.x,
566 y: top_left.y + content_offset.y,
567 };
568 let translation_delta = Point {
569 x: layer_translation.x - container.scene_children_layer_translation.x,
570 y: layer_translation.y - container.scene_children_layer_translation.y,
571 };
572
573 let child_inherited_translated_content_context =
574 inherited_translated_content_context || container.translated_content_context;
575 let children_ancestor_hashed =
576 crate::graph_hash::layer_children_ancestor_hashed(container, container_ancestor_hashed);
577 let mut entering: std::collections::HashMap<NodeId, LayerNode> =
578 std::collections::HashMap::new();
579 for (child_id, _) in &placed_fresh {
580 if old_index_by_id.contains_key(child_id) {
581 continue;
582 }
583 let Some(mut lowered) = build_layer_node_from_applier_internal(
584 applier,
585 *child_id,
586 container.motion_context_animated,
587 child_inherited_translated_content_context,
588 Some(AbsOrigin {
589 content_origin: child_origin,
590 layer_translation,
591 }),
592 ) else {
593 continue;
594 };
595 if content_offset != Point::default() {
596 lowered.transform_to_parent =
597 lowered
598 .transform_to_parent
599 .then(ProjectiveTransform::translation(
600 content_offset.x,
601 content_offset.y,
602 ));
603 }
604 crate::graph_hash::recompute_layer_raster_cache_hashes_under(
605 &mut lowered,
606 children_ancestor_hashed,
607 );
608 entering.insert(*child_id, lowered);
609 }
610
611 let mut transform = layer_transform_to_parent(
612 container.local_bounds,
613 layout_state.position(),
614 &graphics_layer,
615 );
616 if parent_content_offset != Point::default() {
617 transform = transform.then(ProjectiveTransform::translation(
618 parent_content_offset.x,
619 parent_content_offset.y,
620 ));
621 }
622 container.transform_to_parent = transform;
623 container.content_offset = content_offset;
624 if container.translated_content_context {
625 container.translated_content_offset = modifier_slices
626 .translated_content_offset()
627 .unwrap_or(content_offset);
628 }
629
630 if let Some(sink) = modifier_slices.text_field_window_origin() {
631 sink.set(window_origin);
632 }
633 if let Some(sink) = modifier_slices.viewport_window_rect() {
634 sink.set(Rect {
635 x: window_origin.x,
636 y: window_origin.y,
637 width: layout_state.size().width,
638 height: layout_state.size().height,
639 });
640 }
641 container.scene_children_origin = child_origin;
642 container.scene_children_layer_translation = layer_translation;
643
644 let mut old_by_id: std::collections::HashMap<NodeId, Box<LayerNode>> =
645 std::collections::HashMap::new();
646 for child in container.children.drain(..) {
647 let RenderNode::Layer(layer) = child else {
648 continue;
649 };
650 let child_id = layer_identity(&layer).expect("checked above");
651 if fresh_id_set.contains(&child_id) {
652 old_by_id.insert(child_id, layer);
653 } else {
654 collect_layer_node_ids(&layer, changed_nodes);
655 }
656 }
657 let mut new_children = Vec::with_capacity(placed_fresh.len());
658 for (child_id, state) in &placed_fresh {
659 if let Some(mut layer) = old_by_id.remove(child_id) {
660 if !dirty_nodes.contains(child_id) {
661 let mut child_transform = layer_transform_to_parent(
662 layer.local_bounds,
663 state.position(),
664 &layer.graphics_layer,
665 );
666 if content_offset != Point::default() {
667 child_transform = child_transform.then(ProjectiveTransform::translation(
668 content_offset.x,
669 content_offset.y,
670 ));
671 }
672 layer.transform_to_parent = child_transform;
673 let new_children_origin = Point {
674 x: child_origin.x + state.position().x + layer.content_offset.x,
675 y: child_origin.y + state.position().y + layer.content_offset.y,
676 };
677 let origin_delta = Point {
678 x: new_children_origin.x - layer.scene_children_origin.x,
679 y: new_children_origin.y - layer.scene_children_origin.y,
680 };
681 offset_scene_origins(&mut layer, origin_delta, translation_delta);
682 if let Some(moved_id) = layer_identity(&layer) {
683 changed_nodes.push(moved_id);
684 }
685 }
686 new_children.push(RenderNode::Layer(layer));
687 } else if let Some(lowered) = entering.remove(child_id) {
688 dirty_nodes.remove(child_id);
689 remove_dirty_descendants(&lowered, dirty_nodes);
690 collect_layer_node_ids(&lowered, changed_nodes);
691 new_children.push(RenderNode::Layer(Box::new(lowered)));
692 }
693 }
694 container.children = new_children;
695
696 container.has_hit_targets = container.hit_test.is_some()
697 || container.children.iter().any(|child| match child {
698 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
699 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
700 });
701 container.has_origin_sinks = modifier_slices_have_origin_sinks(&modifier_slices)
702 || container.children.iter().any(|child| match child {
703 RenderNode::Layer(child_layer) => child_layer.has_origin_sinks,
704 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
705 });
706
707 crate::graph_hash::refresh_layer_own_raster_cache_hashes(container, container_ancestor_hashed);
708 changed_nodes.push(node_id);
709 true
710}
711
712fn offset_scene_origins(layer: &mut LayerNode, origin_delta: Point, translation_delta: Point) {
713 layer.scene_children_origin.x += origin_delta.x;
714 layer.scene_children_origin.y += origin_delta.y;
715 layer.scene_children_layer_translation.x += translation_delta.x;
716 layer.scene_children_layer_translation.y += translation_delta.y;
717 for child in &mut layer.children {
718 if let RenderNode::Layer(child_layer) = child {
719 offset_scene_origins(child_layer, origin_delta, translation_delta);
720 }
721 }
722}
723
724fn layer_hit_graph_state_dirty(previous: &LayerNode, replacement: &LayerNode) -> bool {
725 if previous.hit_test.is_some() || replacement.hit_test.is_some() {
726 return true;
727 }
728
729 if !(previous.has_hit_targets || replacement.has_hit_targets) {
730 return false;
731 }
732
733 previous.has_hit_targets != replacement.has_hit_targets
734 || previous.local_bounds != replacement.local_bounds
735 || previous.transform_to_parent != replacement.transform_to_parent
736 || previous.clip_rect() != replacement.clip_rect()
737 || previous.graphics_layer.shape != replacement.graphics_layer.shape
738}
739
740fn collect_layer_node_ids(layer: &LayerNode, out: &mut Vec<NodeId>) {
741 if let Some(node_id) = layer.node_id {
742 out.push(node_id);
743 }
744 for child in &layer.children {
745 if let RenderNode::Layer(child_layer) = child {
746 collect_layer_node_ids(child_layer, out);
747 }
748 }
749}
750
751fn remove_dirty_descendants(layer: &LayerNode, dirty_nodes: &mut HashSet<NodeId>) {
752 for child in &layer.children {
753 let RenderNode::Layer(child_layer) = child else {
754 continue;
755 };
756 if let Some(node_id) = child_layer.node_id {
757 dirty_nodes.remove(&node_id);
758 }
759 remove_dirty_descendants(child_layer, dirty_nodes);
760 }
761}
762
763fn build_layer_node(
764 snapshot: BuildNodeSnapshot,
765 _root_scale: f32,
766 inherited_motion_context_animated: bool,
767) -> LayerNode {
768 build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
769}
770
771fn build_layer_node_internal(
772 snapshot: BuildNodeSnapshot,
773 inherited_motion_context_animated: bool,
774 inherited_translated_content_context: bool,
775) -> LayerNode {
776 let BuildNodeSnapshot {
777 node_id,
778 placement,
779 size,
780 content_offset,
781 motion_context_animated,
782 translated_content_context,
783 has_own_origin_sinks,
784 measured_max_width,
785 resolved_modifiers,
786 draw_commands,
787 outer_draw_command_count,
788 click_actions,
789 pointer_inputs,
790 clip_to_bounds,
791 annotated_text,
792 text_style,
793 text_layout_options,
794 text_pan,
795 graphics_layer,
796 children: child_snapshots,
797 } = snapshot;
798 let outer = outer_draws(node_id, &draw_commands, outer_draw_command_count, size);
799 let layer_draw_commands = &draw_commands[outer_draw_command_count..];
800 let local_bounds = Rect {
801 x: 0.0,
802 y: 0.0,
803 width: size.width,
804 height: size.height,
805 };
806 let graphics_layer = graphics_layer.unwrap_or_default();
807 let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
808 let isolation = isolation_reasons(&graphics_layer);
809 let cache_policy = if isolation.has_any() {
810 CachePolicy::Auto
811 } else {
812 CachePolicy::None
813 };
814 let shadow_clip = clip_to_bounds.then_some(local_bounds);
815 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
816 shape: None,
817 click_actions,
818 pointer_inputs,
819 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
820 });
821
822 let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
823 let child_translated_content_context =
824 inherited_translated_content_context || translated_content_context;
825
826 let mut children = draw_nodes(
827 node_id,
828 layer_draw_commands,
829 outer_draw_command_count,
830 DrawPlacement::Behind,
831 size,
832 PrimitivePhase::BeforeChildren,
833 );
834 if let Some(text) = text_node_from_parts(TextNodeParts {
835 node_id,
836 local_bounds,
837 measured_max_width,
838 resolved_modifiers: &resolved_modifiers,
839 annotated_text: annotated_text.as_ref(),
840 text_style: text_style.as_ref(),
841 text_layout_options,
842 text_pan,
843 modifier_slices: None,
844 }) {
845 children.push(RenderNode::Primitive(PrimitiveEntry {
846 phase: PrimitivePhase::BeforeChildren,
847 node: PrimitiveNode::Text(Box::new(text)),
848 }));
849 }
850 let child_motion_context_animated = node_motion_context_animated;
851 for child in child_snapshots {
852 let mut child_layer = build_layer_node_internal(
853 child,
854 child_motion_context_animated,
855 child_translated_content_context,
856 );
857 if content_offset != Point::default() {
858 child_layer.transform_to_parent =
859 child_layer
860 .transform_to_parent
861 .then(ProjectiveTransform::translation(
862 content_offset.x,
863 content_offset.y,
864 ));
865 }
866 children.push(RenderNode::Layer(Box::new(child_layer)));
867 }
868 children.extend(draw_nodes(
869 node_id,
870 layer_draw_commands,
871 outer_draw_command_count,
872 DrawPlacement::Overlay,
873 size,
874 PrimitivePhase::AfterChildren,
875 ));
876 let has_hit_targets = hit_test.is_some()
877 || children.iter().any(|child| match child {
878 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
879 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
880 });
881 let has_origin_sinks = has_own_origin_sinks
882 || children.iter().any(|child| match child {
883 RenderNode::Layer(child_layer) => child_layer.has_origin_sinks,
884 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
885 });
886
887 let layer = LayerNode {
888 node_id: Some(node_id),
889 wraps: None,
890 local_bounds,
891 transform_to_parent,
892 content_offset,
893 motion_context_animated: node_motion_context_animated,
894 translated_content_context,
895 translated_content_offset: if translated_content_context {
896 content_offset
897 } else {
898 Point::default()
899 },
900 scene_children_origin: Point::default(),
901 scene_children_layer_translation: Point::default(),
902 graphics_layer,
903 clip_to_bounds,
904 shadow_clip,
905 hit_test,
906 has_hit_targets,
907 has_origin_sinks,
908 isolation,
909 cache_policy,
910 cache_hashes: LayerRasterCacheHashes::default(),
911 cache_hashes_valid: false,
912 children,
913 };
914 finish_layer(layer, placement, outer)
915}
916
917#[derive(Clone, Copy)]
918struct AbsOrigin {
919 content_origin: Point,
920 layer_translation: Point,
921}
922
923impl AbsOrigin {
924 const ROOT: AbsOrigin = AbsOrigin {
925 content_origin: Point { x: 0.0, y: 0.0 },
926 layer_translation: Point { x: 0.0, y: 0.0 },
927 };
928}
929
930fn build_layer_node_from_applier(
931 applier: &mut MemoryApplier,
932 node_id: NodeId,
933 _root_scale: f32,
934 inherited_motion_context_animated: bool,
935) -> Option<LayerNode> {
936 build_layer_node_from_applier_internal(
937 applier,
938 node_id,
939 inherited_motion_context_animated,
940 false,
941 Some(AbsOrigin::ROOT),
942 )
943}
944
945fn snapshot_node_data(applier: &mut MemoryApplier, node_id: NodeId) -> Option<SnapshotNodeData> {
946 if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
947 let state = node.layout_state();
948 let children = node.children.clone();
949 let modifier_slices = node.modifier_slices_snapshot();
950 SnapshotNodeData {
951 layout_state: state,
952 modifier_slices,
953 resolved_modifiers: node.resolved_modifiers(),
954 children,
955 }
956 }) {
957 return Some(data);
958 }
959
960 applier
961 .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
962 let state = node.layout_state();
963 let children = node.active_children();
964 let modifier_slices = node.modifier_slices_snapshot();
965 SnapshotNodeData {
966 layout_state: state,
967 modifier_slices,
968 resolved_modifiers: node.resolved_modifiers(),
969 children,
970 }
971 })
972 .ok()
973}
974
975fn build_layer_node_from_applier_internal(
976 applier: &mut MemoryApplier,
977 node_id: NodeId,
978 inherited_motion_context_animated: bool,
979 inherited_translated_content_context: bool,
980 parent_abs: Option<AbsOrigin>,
981) -> Option<LayerNode> {
982 let data = snapshot_node_data(applier, node_id)?;
983 build_layer_node_from_data(
984 applier,
985 node_id,
986 data,
987 inherited_motion_context_animated,
988 inherited_translated_content_context,
989 parent_abs,
990 )
991}
992
993fn build_layer_node_from_data(
994 applier: &mut MemoryApplier,
995 node_id: NodeId,
996 data: SnapshotNodeData,
997 inherited_motion_context_animated: bool,
998 inherited_translated_content_context: bool,
999 parent_abs: Option<AbsOrigin>,
1000) -> Option<LayerNode> {
1001 note_layer_lowered();
1002 let SnapshotNodeData {
1003 layout_state,
1004 modifier_slices,
1005 resolved_modifiers,
1006 children,
1007 } = data;
1008 if !layout_state.is_placed() {
1009 return None;
1010 }
1011
1012 let local_bounds = Rect {
1013 x: 0.0,
1014 y: 0.0,
1015 width: layout_state.size().width,
1016 height: layout_state.size().height,
1017 };
1018 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
1019 eprintln!(
1020 "[scene-update-diag] build layer node={node_id:?} size=({:.2},{:.2}) pos=({:.2},{:.2})",
1021 layout_state.size().width,
1022 layout_state.size().height,
1023 layout_state.position().x,
1024 layout_state.position().y,
1025 );
1026 }
1027 let clip_to_bounds = modifier_slices.clip_to_bounds();
1028 let graphics_layer = graphics_layer_with_shaped_clip(
1029 modifier_slices.graphics_layer().unwrap_or_default(),
1030 clip_to_bounds,
1031 modifier_slices.corner_shape(),
1032 local_bounds,
1033 );
1034 let transform_to_parent =
1035 layer_transform_to_parent(local_bounds, layout_state.position(), &graphics_layer);
1036 let isolation = isolation_reasons(&graphics_layer);
1037 let cache_policy = if isolation.has_any() {
1038 CachePolicy::Auto
1039 } else {
1040 CachePolicy::None
1041 };
1042 let click_actions = modifier_slices.click_handlers();
1043 let pointer_inputs = modifier_slices.pointer_inputs();
1044 let shadow_clip = clip_to_bounds.then_some(local_bounds);
1045 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
1046 shape: None,
1047 click_actions: click_actions.to_vec(),
1048 pointer_inputs: pointer_inputs.to_vec(),
1049 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
1050 });
1051
1052 modifier_slices.publish_pointer_input_size(layout_state.size());
1053
1054 let node_motion_context_animated =
1055 inherited_motion_context_animated || modifier_slices.motion_context_animated();
1056 let local_translated_content_context = modifier_slices.translated_content_context();
1057 let local_translated_content_offset = modifier_slices
1058 .translated_content_offset()
1059 .unwrap_or(layout_state.content_offset);
1060 let child_translated_content_context =
1061 inherited_translated_content_context || local_translated_content_context;
1062
1063 let this_abs = parent_abs.map(|parent| {
1064 let (tx, ty) = modifier_slices
1065 .graphics_layer()
1066 .map(|layer| (layer.translation_x, layer.translation_y))
1067 .unwrap_or((0.0, 0.0));
1068 let top_left = Point {
1069 x: parent.content_origin.x + layout_state.position().x,
1070 y: parent.content_origin.y + layout_state.position().y,
1071 };
1072 let layer_translation = Point {
1073 x: parent.layer_translation.x + tx,
1074 y: parent.layer_translation.y + ty,
1075 };
1076 (top_left, layer_translation)
1077 });
1078 if let Some((top_left, layer_translation)) = this_abs {
1079 let window_origin = Point {
1080 x: top_left.x + layer_translation.x,
1081 y: top_left.y + layer_translation.y,
1082 };
1083 if let Some(sink) = modifier_slices.text_field_window_origin() {
1084 sink.set(window_origin);
1085 }
1086 if let Some(sink) = modifier_slices.viewport_window_rect() {
1087 sink.set(Rect {
1088 x: window_origin.x,
1089 y: window_origin.y,
1090 width: layout_state.size().width,
1091 height: layout_state.size().height,
1092 });
1093 }
1094 }
1095 let child_abs = this_abs.map(|(top_left, layer_translation)| AbsOrigin {
1096 content_origin: Point {
1097 x: top_left.x + layout_state.content_offset.x,
1098 y: top_left.y + layout_state.content_offset.y,
1099 },
1100 layer_translation,
1101 });
1102
1103 let outer_draw_command_count = modifier_slices.outer_draw_command_count();
1104 let outer = outer_draws(
1105 node_id,
1106 modifier_slices.draw_commands(),
1107 outer_draw_command_count,
1108 layout_state.size(),
1109 );
1110 let layer_draw_commands = &modifier_slices.draw_commands()[outer_draw_command_count..];
1111 let mut render_children = draw_nodes(
1112 node_id,
1113 layer_draw_commands,
1114 outer_draw_command_count,
1115 DrawPlacement::Behind,
1116 layout_state.size(),
1117 PrimitivePhase::BeforeChildren,
1118 );
1119 if let Some(text) = text_node_from_parts(TextNodeParts {
1120 node_id,
1121 local_bounds,
1122 measured_max_width: layout_state
1123 .measurement_constraints
1124 .max_width
1125 .is_finite()
1126 .then_some(layout_state.measurement_constraints.max_width),
1127 resolved_modifiers: &resolved_modifiers,
1128 annotated_text: modifier_slices.annotated_text(),
1129 text_style: modifier_slices.text_style(),
1130 text_layout_options: modifier_slices.text_layout_options(),
1131 text_pan: modifier_slices.text_pan_resolver(),
1132 modifier_slices: Some(modifier_slices.as_ref()),
1133 }) {
1134 render_children.push(RenderNode::Primitive(PrimitiveEntry {
1135 phase: PrimitivePhase::BeforeChildren,
1136 node: PrimitiveNode::Text(Box::new(text)),
1137 }));
1138 }
1139 let child_motion_context_animated = node_motion_context_animated;
1140 for child_id in children {
1141 let Some(mut child_layer) = build_layer_node_from_applier_internal(
1142 applier,
1143 child_id,
1144 child_motion_context_animated,
1145 child_translated_content_context,
1146 child_abs,
1147 ) else {
1148 continue;
1149 };
1150 if layout_state.content_offset != Point::default() {
1151 child_layer.transform_to_parent =
1152 child_layer
1153 .transform_to_parent
1154 .then(ProjectiveTransform::translation(
1155 layout_state.content_offset.x,
1156 layout_state.content_offset.y,
1157 ));
1158 }
1159 render_children.push(RenderNode::Layer(Box::new(child_layer)));
1160 }
1161 render_children.extend(draw_nodes(
1162 node_id,
1163 layer_draw_commands,
1164 outer_draw_command_count,
1165 DrawPlacement::Overlay,
1166 layout_state.size(),
1167 PrimitivePhase::AfterChildren,
1168 ));
1169 let has_hit_targets = hit_test.is_some()
1170 || render_children.iter().any(|child| match child {
1171 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
1172 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1173 });
1174 let has_origin_sinks = modifier_slices_have_origin_sinks(&modifier_slices)
1175 || render_children.iter().any(|child| match child {
1176 RenderNode::Layer(child_layer) => child_layer.has_origin_sinks,
1177 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1178 });
1179
1180 let layer = LayerNode {
1181 node_id: Some(node_id),
1182 wraps: None,
1183 local_bounds,
1184 transform_to_parent,
1185 content_offset: layout_state.content_offset,
1186 motion_context_animated: node_motion_context_animated,
1187 translated_content_context: local_translated_content_context,
1188 translated_content_offset: if local_translated_content_context {
1189 local_translated_content_offset
1190 } else {
1191 Point::default()
1192 },
1193 scene_children_origin: child_abs.map(|c| c.content_origin).unwrap_or_default(),
1194 scene_children_layer_translation: child_abs
1195 .map(|c| c.layer_translation)
1196 .unwrap_or_default(),
1197 graphics_layer,
1198 clip_to_bounds,
1199 shadow_clip,
1200 hit_test,
1201 has_hit_targets,
1202 has_origin_sinks,
1203 isolation,
1204 cache_policy,
1205 cache_hashes: LayerRasterCacheHashes::default(),
1206 cache_hashes_valid: false,
1207 children: render_children,
1208 };
1209 Some(finish_layer(layer, layout_state.position(), outer))
1210}
1211
1212struct RecorderSlot {
1213 generation: u64,
1214 handles: [Option<Rc<CommandRecording>>; 2],
1215}
1216
1217thread_local! {
1218 static COMMAND_RECORDINGS: std::cell::RefCell<
1219 std::collections::HashMap<DrawCommandId, RecorderSlot, cranpose_ui_graphics::FxBuildHasher>,
1220 > = std::cell::RefCell::new(std::collections::HashMap::default());
1221 static RECORDING_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1222}
1223
1224#[doc(hidden)]
1225pub fn clear_command_recordings_for_tests() {
1226 COMMAND_RECORDINGS.with(|map| map.borrow_mut().clear());
1227}
1228
1229fn bump_recording_generation() {
1230 let generation = RECORDING_GENERATION.with(|cell| {
1231 let next = cell.get().wrapping_add(1);
1232 cell.set(next);
1233 next
1234 });
1235 if generation.is_multiple_of(512) {
1236 COMMAND_RECORDINGS.with(|map| {
1237 map.borrow_mut()
1238 .retain(|_, slot| generation.wrapping_sub(slot.generation) <= 64);
1239 });
1240 }
1241}
1242
1243fn acquire_storage(id: DrawCommandId) -> CommandRecording {
1247 COMMAND_RECORDINGS.with(|map| {
1248 let mut map = map.borrow_mut();
1249 let Some(slot) = map.get_mut(&id) else {
1250 return CommandRecording::default();
1251 };
1252 for handle in &mut slot.handles {
1253 if handle
1254 .as_ref()
1255 .is_some_and(|shared| Rc::strong_count(shared) == 1)
1256 {
1257 let shared = handle.take().expect("checked some above");
1258 return Rc::try_unwrap(shared).expect("sole owner checked above");
1259 }
1260 }
1261 CommandRecording::default()
1262 })
1263}
1264
1265fn publish_recording(id: DrawCommandId, recording: CommandRecording) -> Rc<CommandRecording> {
1266 let shared = Rc::new(recording);
1267 COMMAND_RECORDINGS.with(|map| {
1268 let mut map = map.borrow_mut();
1269 let generation = RECORDING_GENERATION.with(std::cell::Cell::get);
1270 let slot = map.entry(id).or_insert_with(|| RecorderSlot {
1271 generation,
1272 handles: [None, None],
1273 });
1274 slot.generation = generation;
1275 slot.handles[1] = slot.handles[0].take();
1276 slot.handles[0] = Some(shared.clone());
1277 });
1278 shared
1279}
1280
1281fn draw_nodes(
1282 node_id: NodeId,
1283 commands: &[DrawCommand],
1284 first_command_index: usize,
1285 placement: DrawPlacement,
1286 size: Size,
1287 phase: PrimitivePhase,
1288) -> Vec<RenderNode> {
1289 let mut nodes = Vec::new();
1290 for (command_index, command) in commands.iter().enumerate() {
1291 let id = DrawCommandId {
1292 node_id,
1293 command_index: (first_command_index + command_index) as u32,
1294 placement,
1295 };
1296 let storage = acquire_storage(id);
1297 let Some((recording, segments)) =
1298 recording_for_placement_reusing(command, placement, size, storage)
1299 else {
1300 retain_empty_draw_command(&mut nodes, phase, id, placement, command);
1301 continue;
1302 };
1303 let shared = publish_recording(id, recording);
1304 if shared.is_empty_in(&segments) {
1305 retain_empty_draw_command(&mut nodes, phase, id, placement, command);
1306 continue;
1307 }
1308 nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_shared(
1309 phase,
1310 Some(id),
1311 shared,
1312 segments,
1313 )));
1314 }
1315 nodes
1316}
1317
1318fn retain_empty_draw_command(
1319 nodes: &mut Vec<RenderNode>,
1320 phase: PrimitivePhase,
1321 id: DrawCommandId,
1322 placement: DrawPlacement,
1323 command: &DrawCommand,
1324) {
1325 if matches!(
1326 (placement, command),
1327 (DrawPlacement::Behind, DrawCommand::Behind(_))
1328 | (DrawPlacement::Overlay, DrawCommand::Overlay(_))
1329 | (_, DrawCommand::WithContent(_))
1330 ) {
1331 nodes.push(RenderNode::DrawRun(DrawRunNode::for_command(
1332 phase,
1333 Some(id),
1334 Vec::new(),
1335 )));
1336 }
1337}
1338
1339#[doc(hidden)]
1340pub fn draw_command_nodes_for_tests(
1341 node_id: NodeId,
1342 commands: &[DrawCommand],
1343 placement: DrawPlacement,
1344 size: Size,
1345 phase: PrimitivePhase,
1346) -> Vec<RenderNode> {
1347 bump_recording_generation();
1348 draw_nodes(node_id, commands, 0, placement, size, phase)
1349}
1350
1351struct OuterDraws {
1352 behind: Vec<RenderNode>,
1353 overlay: Vec<RenderNode>,
1354}
1355
1356fn outer_draws(
1357 node_id: NodeId,
1358 draw_commands: &[DrawCommand],
1359 outer_draw_command_count: usize,
1360 size: Size,
1361) -> Option<OuterDraws> {
1362 (outer_draw_command_count > 0).then(|| {
1363 let commands = &draw_commands[..outer_draw_command_count];
1364 OuterDraws {
1365 behind: draw_nodes(
1366 node_id,
1367 commands,
1368 0,
1369 DrawPlacement::Behind,
1370 size,
1371 PrimitivePhase::BeforeChildren,
1372 ),
1373 overlay: draw_nodes(
1374 node_id,
1375 commands,
1376 0,
1377 DrawPlacement::Overlay,
1378 size,
1379 PrimitivePhase::AfterChildren,
1380 ),
1381 }
1382 })
1383}
1384
1385fn finish_layer(layer: LayerNode, placement: Point, outer: Option<OuterDraws>) -> LayerNode {
1386 match outer {
1387 Some(outer) => wrap_layer_with_outer_draws(layer, placement, outer),
1388 None => layer,
1389 }
1390}
1391
1392fn wrap_layer_with_outer_draws(
1393 mut layer: LayerNode,
1394 placement: Point,
1395 outer: OuterDraws,
1396) -> LayerNode {
1397 let local_bounds = layer.local_bounds;
1398 layer.transform_to_parent =
1399 layer_transform_to_parent(local_bounds, Point::default(), &layer.graphics_layer);
1400 let wrapper = LayerNode {
1401 wraps: layer.node_id,
1402 local_bounds,
1403 transform_to_parent: layer_transform_to_parent(
1404 local_bounds,
1405 placement,
1406 &GraphicsLayer::default(),
1407 ),
1408 scene_children_origin: Point {
1409 x: layer.scene_children_origin.x - layer.content_offset.x,
1410 y: layer.scene_children_origin.y - layer.content_offset.y,
1411 },
1412 scene_children_layer_translation: Point {
1413 x: layer.scene_children_layer_translation.x - layer.graphics_layer.translation_x,
1414 y: layer.scene_children_layer_translation.y - layer.graphics_layer.translation_y,
1415 },
1416 motion_context_animated: layer.motion_context_animated,
1417 has_hit_targets: layer.has_hit_targets,
1418 has_origin_sinks: layer.has_origin_sinks,
1419 ..Default::default()
1420 };
1421 let mut children = outer.behind;
1422 children.push(RenderNode::Layer(Box::new(layer)));
1423 children.extend(outer.overlay);
1424 LayerNode {
1425 children,
1426 ..wrapper
1427 }
1428}
1429
1430fn layer_identity(layer: &LayerNode) -> Option<NodeId> {
1431 layer.node_id.or(layer.wraps)
1432}
1433
1434struct TextNodeParts<'a> {
1435 node_id: NodeId,
1436 local_bounds: Rect,
1437 measured_max_width: Option<f32>,
1438 resolved_modifiers: &'a ResolvedModifiers,
1439 annotated_text: Option<&'a AnnotatedString>,
1440 text_style: Option<&'a TextStyle>,
1441 text_layout_options: Option<TextLayoutOptions>,
1442 text_pan: Option<TextPanResolver>,
1443 modifier_slices: Option<&'a ModifierNodeSlices>,
1444}
1445
1446fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
1447 let TextNodeParts {
1448 node_id,
1449 local_bounds,
1450 measured_max_width,
1451 resolved_modifiers,
1452 annotated_text,
1453 text_style,
1454 text_layout_options,
1455 text_pan,
1456 modifier_slices,
1457 } = parts;
1458 let value = annotated_text?;
1459 let default_text_style = TextStyle::default();
1460 let text_style = text_style.cloned().unwrap_or(default_text_style);
1461 let options = text_layout_options.unwrap_or_default().normalized();
1462 let padding = resolved_modifiers.padding();
1463 let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
1464 if content_width <= 0.0 {
1465 return None;
1466 }
1467
1468 let pan_offset = text_pan
1469 .as_ref()
1470 .map(|resolve| resolve(content_width))
1471 .unwrap_or(0.0);
1472 let pans_horizontally = text_pan.is_some();
1473
1474 let max_width = if pans_horizontally {
1475 None
1476 } else {
1477 let measure_width =
1478 resolve_text_measure_width(content_width, padding, measured_max_width, options);
1479 Some(measure_width).filter(|width| width.is_finite() && *width > 0.0)
1480 };
1481 let prepared = modifier_slices
1482 .and_then(|slices| slices.prepare_text_layout(max_width))
1483 .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
1484 let visual_style = prepared.visual_style.clone();
1485 let measured_draw_width = prepared.metrics.width.max(0.0);
1486 let draw_width = if options.overflow == TextOverflow::Visible || pans_horizontally {
1487 measured_draw_width
1488 } else {
1489 measured_draw_width.min(content_width)
1490 };
1491 let alignment_offset = resolve_text_horizontal_offset(
1492 &text_style,
1493 prepared.text.text.as_str(),
1494 content_width,
1495 prepared.metrics.width,
1496 );
1497 let rect = Rect {
1498 x: padding.left + alignment_offset - pan_offset,
1499 y: padding.top,
1500 width: draw_width,
1501 height: prepared.metrics.height,
1502 };
1503 let text_bounds = Rect {
1504 x: padding.left,
1505 y: padding.top,
1506 width: content_width,
1507 height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
1508 };
1509 let font_size = visual_style.resolve_font_size(14.0);
1510 let expanded_bounds =
1511 expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
1512 let clip = if options.overflow == TextOverflow::Visible && !pans_horizontally {
1513 None
1514 } else {
1515 Some(pad_clip_rect(expanded_bounds))
1516 };
1517
1518 Some(TextPrimitiveNode {
1519 node_id,
1520 rect,
1521 text: std::rc::Rc::new(prepared.text),
1522 text_style: visual_style,
1523 font_size,
1524 layout_options: options,
1525 clip,
1526 })
1527}
1528
1529fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
1530 let placement = parent
1531 .map(|parent_box| Point {
1532 x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
1533 y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
1534 })
1535 .unwrap_or_default();
1536 let mut children = Vec::with_capacity(node.children.len());
1537 for child in &node.children {
1538 children.push(layout_box_to_snapshot(child, Some(node)));
1539 }
1540 let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
1541 let graphics_layer = graphics_layer_with_shaped_clip(
1542 base_graphics_layer.clone().unwrap_or_default(),
1543 node.node_data.modifier_slices.clip_to_bounds(),
1544 node.node_data.modifier_slices.corner_shape(),
1545 Rect {
1546 x: 0.0,
1547 y: 0.0,
1548 width: node.rect.width,
1549 height: node.rect.height,
1550 },
1551 );
1552 let has_graphics_layer =
1553 base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
1554
1555 BuildNodeSnapshot {
1556 node_id: node.node_id,
1557 placement,
1558 size: Size {
1559 width: node.rect.width,
1560 height: node.rect.height,
1561 },
1562 content_offset: node.content_offset,
1563 motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
1564 translated_content_context: node.node_data.modifier_slices.translated_content_context(),
1565 has_own_origin_sinks: modifier_slices_have_origin_sinks(&node.node_data.modifier_slices),
1566 measured_max_width: None,
1567 resolved_modifiers: node.node_data.resolved_modifiers,
1568 draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
1569 outer_draw_command_count: node.node_data.modifier_slices.outer_draw_command_count(),
1570 click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
1571 pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
1572 clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
1573 annotated_text: node.node_data.modifier_slices.annotated_string(),
1574 text_style: node.node_data.modifier_slices.text_style().cloned(),
1575 text_layout_options: node.node_data.modifier_slices.text_layout_options(),
1576 text_pan: node.node_data.modifier_slices.text_pan_resolver(),
1577 graphics_layer: has_graphics_layer.then_some(graphics_layer),
1578 children,
1579 }
1580}
1581
1582fn modifier_slices_have_origin_sinks(slices: &ModifierNodeSlices) -> bool {
1583 slices.text_field_window_origin().is_some() || slices.viewport_window_rect().is_some()
1584}
1585
1586fn graphics_layer_with_shaped_clip(
1587 mut graphics_layer: GraphicsLayer,
1588 clip_to_bounds: bool,
1589 corner_shape: Option<RoundedCornerShape>,
1590 local_bounds: Rect,
1591) -> GraphicsLayer {
1592 if !clip_to_bounds {
1593 return graphics_layer;
1594 }
1595
1596 let Some(corner_shape) = corner_shape else {
1597 return graphics_layer;
1598 };
1599 let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
1600 if radii.top_left <= f32::EPSILON
1601 && radii.top_right <= f32::EPSILON
1602 && radii.bottom_right <= f32::EPSILON
1603 && radii.bottom_left <= f32::EPSILON
1604 {
1605 return graphics_layer;
1606 }
1607
1608 if let Some(existing) = graphics_layer.render_effect.take() {
1609 let rounded_clip = rounded_corner_alpha_mask_effect(
1610 local_bounds.width,
1611 local_bounds.height,
1612 radii,
1613 ROUNDED_CLIP_EDGE_FEATHER,
1614 );
1615 graphics_layer.render_effect = Some(existing.then(rounded_clip));
1616 } else {
1617 graphics_layer.shape = LayerShape::Rounded(corner_shape);
1618 graphics_layer.clip = true;
1619 }
1620 graphics_layer
1621}
1622
1623fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
1624 IsolationReasons {
1625 explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
1626 shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
1627 effect: layer.render_effect.is_some(),
1628 backdrop: layer.backdrop_effect.is_some(),
1629 group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
1630 && layer.alpha < 1.0,
1631 blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
1632 }
1633}
1634
1635fn pad_clip_rect(rect: Rect) -> Rect {
1636 Rect {
1637 x: rect.x - TEXT_CLIP_PAD,
1638 y: rect.y - TEXT_CLIP_PAD,
1639 width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
1640 height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
1641 }
1642}
1643
1644pub fn expand_text_bounds_for_baseline_shift(
1645 text_bounds: Rect,
1646 text_style: &TextStyle,
1647 font_size: f32,
1648) -> Rect {
1649 let baseline_shift_px = text_style
1650 .span_style
1651 .baseline_shift
1652 .filter(|shift| shift.is_specified())
1653 .map(|shift| -(shift.0 * font_size))
1654 .unwrap_or(0.0);
1655 if baseline_shift_px == 0.0 {
1656 return text_bounds;
1657 }
1658
1659 if baseline_shift_px < 0.0 {
1660 Rect {
1661 x: text_bounds.x,
1662 y: text_bounds.y + baseline_shift_px,
1663 width: text_bounds.width,
1664 height: (text_bounds.height - baseline_shift_px).max(0.0),
1665 }
1666 } else {
1667 Rect {
1668 x: text_bounds.x,
1669 y: text_bounds.y,
1670 width: text_bounds.width,
1671 height: (text_bounds.height + baseline_shift_px).max(0.0),
1672 }
1673 }
1674}
1675
1676pub fn resolve_text_measure_width(
1703 content_width: f32,
1704 padding: cranpose_ui::EdgeInsets,
1705 measured_max_width: Option<f32>,
1706 options: TextLayoutOptions,
1707) -> f32 {
1708 let width = content_width.max(0.0);
1709 if let Some(max_width) = measured_max_width.filter(|w| w.is_finite() && *w > 0.0) {
1710 let measured_content_width = (max_width - padding.left - padding.right).max(0.0);
1711 if measured_content_width <= width {
1712 return measured_content_width;
1713 }
1714
1715 let may_expand_to_avoid_synthetic_wrap = options.soft_wrap
1716 && options.max_lines == usize::MAX
1717 && options.overflow == TextOverflow::Clip;
1718 if may_expand_to_avoid_synthetic_wrap {
1719 return measured_content_width;
1720 }
1721 }
1722 width
1723}
1724
1725pub fn text_align_fraction(text_style: &TextStyle, text: &str) -> f32 {
1738 let paragraph_style = &text_style.paragraph_style;
1739 let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
1740 let rtl = direction == cranpose_ui::text::ResolvedTextDirection::Rtl;
1741 match paragraph_style.text_align {
1742 TextAlign::Center => 0.5,
1743 TextAlign::End | TextAlign::Right => 1.0,
1744 TextAlign::Start | TextAlign::Left | TextAlign::Justify | TextAlign::Unspecified => {
1745 if rtl {
1746 1.0
1747 } else {
1748 0.0
1749 }
1750 }
1751 }
1752}
1753
1754fn resolve_text_horizontal_offset(
1755 text_style: &TextStyle,
1756 text: &str,
1757 content_width: f32,
1758 measured_width: f32,
1759) -> f32 {
1760 let remaining = (content_width - measured_width).max(0.0);
1761 remaining * text_align_fraction(text_style, text)
1762}
1763
1764#[cfg(test)]
1765mod tests {
1766 use std::{cell::RefCell, rc::Rc};
1767
1768 use cranpose_foundation::lazy::{LazyListScope, LazyListState, rememberLazyListState};
1769 use cranpose_ui::{
1770 Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
1771 LinearArrangement, Modifier, Point, Rect, RoundedCornerShape, ScrollState, Size, Spacer,
1772 Text, TextStyle,
1773 text::{AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion},
1774 };
1775 use cranpose_ui_graphics::{
1776 Brush, DrawPrimitive, DrawScope as _, DrawScopeDefault, GraphicsLayer, RenderEffect,
1777 };
1778
1779 use super::*;
1780
1781 fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
1782 for child in &layer.children {
1783 match child {
1784 RenderNode::Primitive(primitive) => {
1785 let PrimitiveNode::Text(text) = &primitive.node else {
1786 continue;
1787 };
1788 if text.text.text == label {
1789 return Some(text.text_style.paragraph_style.text_motion);
1790 }
1791 }
1792 RenderNode::Layer(child_layer) => {
1793 if let Some(motion) = find_text_motion(child_layer, label) {
1794 return Some(motion);
1795 }
1796 }
1797 RenderNode::DrawRun(_) => {}
1798 }
1799 }
1800
1801 None
1802 }
1803
1804 fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
1805 for child in &layer.children {
1806 match child {
1807 RenderNode::Primitive(primitive) => {
1808 let PrimitiveNode::Text(text) = &primitive.node else {
1809 continue;
1810 };
1811 labels.push(text.text.text.clone());
1812 }
1813 RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
1814 RenderNode::DrawRun(_) => {}
1815 }
1816 }
1817 }
1818
1819 fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
1820 fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
1821 for child in &layer.children {
1822 match child {
1823 RenderNode::Primitive(primitive) => {
1824 let PrimitiveNode::Text(text) = &primitive.node else {
1825 continue;
1826 };
1827 if text.text.text == label {
1828 let quad = transform.map_rect(text.rect);
1829 let top = quad
1830 .iter()
1831 .map(|point| point[1])
1832 .fold(f32::INFINITY, f32::min);
1833 return top.is_finite().then_some(top);
1834 }
1835 }
1836 RenderNode::Layer(child_layer) => {
1837 let child_transform = child_layer.transform_to_parent.then(transform);
1838 if let Some(top) = search(child_layer, label, child_transform) {
1839 return Some(top);
1840 }
1841 }
1842 RenderNode::DrawRun(_) => {}
1843 }
1844 }
1845 None
1846 }
1847
1848 search(layer, label, ProjectiveTransform::identity())
1849 }
1850
1851 fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
1852 if layer.node_id == Some(node_id) {
1853 return Some(layer);
1854 }
1855 layer.children.iter().find_map(|child| match child {
1856 RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
1857 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1858 })
1859 }
1860
1861 fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
1862 fn search(
1863 layer: &LayerNode,
1864 node_id: NodeId,
1865 transform: ProjectiveTransform,
1866 ) -> Option<Point> {
1867 if layer.node_id == Some(node_id) {
1868 return Some(transform.map_point(Point::default()));
1869 }
1870 layer.children.iter().find_map(|child| match child {
1871 RenderNode::Layer(child_layer) => search(
1872 child_layer,
1873 node_id,
1874 child_layer.transform_to_parent.then(transform),
1875 ),
1876 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1877 })
1878 }
1879
1880 search(layer, node_id, ProjectiveTransform::identity())
1881 }
1882
1883 fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1884 if layer.translated_content_context {
1885 return Some(layer.translated_content_offset);
1886 }
1887 for child in &layer.children {
1888 if let RenderNode::Layer(child_layer) = child
1889 && let Some(offset) = find_translated_content_offset(child_layer)
1890 {
1891 return Some(offset);
1892 }
1893 }
1894 None
1895 }
1896
1897 fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1898 layer
1899 .graphics_layer
1900 .render_effect
1901 .as_ref()
1902 .is_some_and(RenderEffect::contains_runtime_shader)
1903 || layer.children.iter().any(|child| match child {
1904 RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1905 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1906 })
1907 }
1908
1909 fn build_layer_node_for_test(
1910 snapshot: BuildNodeSnapshot,
1911 scale: f32,
1912 has_external_backdrop_input: bool,
1913 ) -> LayerNode {
1914 let app_context = cranpose_ui::AppContext::new();
1915 app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1916 }
1917
1918 fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1919 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1920 scope.push_recorded(vec![DrawPrimitive::Rect {
1921 rect: Rect {
1922 x: 3.0,
1923 y: 4.0,
1924 width: 20.0,
1925 height: 8.0,
1926 },
1927 brush: Brush::solid(Color::WHITE),
1928 stroke: None,
1929 }]);
1930 }));
1931
1932 let child = BuildNodeSnapshot {
1933 node_id: 2,
1934 placement: Point { x: 11.0, y: 7.0 },
1935 size: Size {
1936 width: 40.0,
1937 height: 20.0,
1938 },
1939 draw_commands: vec![child_command],
1940 ..Default::default()
1941 };
1942
1943 BuildNodeSnapshot {
1944 node_id: 1,
1945 size: Size {
1946 width: 80.0,
1947 height: 50.0,
1948 },
1949 graphics_layer: Some(GraphicsLayer {
1950 translation_x: tx,
1951 ..GraphicsLayer::default()
1952 }),
1953 children: vec![child],
1954 ..Default::default()
1955 }
1956 }
1957
1958 #[test]
1959 fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1960 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1961 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1962
1963 let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1964 panic!("expected child layer");
1965 };
1966 let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1967 panic!("expected child layer");
1968 };
1969 let RenderNode::DrawRun(static_run) = &static_child.children[0] else {
1970 panic!("expected draw run");
1971 };
1972 let static_draw = static_run.primitives().next().expect("a recorded draw");
1973 let RenderNode::DrawRun(moved_run) = &moved_child.children[0] else {
1974 panic!("expected draw run");
1975 };
1976 let moved_draw = moved_run.primitives().next().expect("a recorded draw");
1977
1978 assert_ne!(
1979 static_graph.transform_to_parent, moved_graph.transform_to_parent,
1980 "parent transform should encode translation"
1981 );
1982 assert_eq!(
1983 static_draw, moved_draw,
1984 "child local primitive geometry must stay stable under parent translation"
1985 );
1986 }
1987
1988 #[test]
1989 fn stored_content_hash_ignores_parent_translation() {
1990 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1991 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1992
1993 assert_eq!(
1994 static_graph.target_content_hash(),
1995 moved_graph.target_content_hash(),
1996 "parent rigid motion must not invalidate the subtree content hash"
1997 );
1998 }
1999
2000 #[test]
2001 fn parent_content_offset_is_encoded_in_child_transform() {
2002 let child = BuildNodeSnapshot {
2003 node_id: 2,
2004 placement: Point { x: 11.0, y: 7.0 },
2005 size: Size {
2006 width: 40.0,
2007 height: 20.0,
2008 },
2009 ..Default::default()
2010 };
2011
2012 let parent = BuildNodeSnapshot {
2013 node_id: 1,
2014 size: Size {
2015 width: 80.0,
2016 height: 50.0,
2017 },
2018 content_offset: Point { x: 13.0, y: -9.0 },
2019 children: vec![child],
2020 ..Default::default()
2021 };
2022
2023 let graph = build_layer_node_for_test(parent, 1.0, false);
2024 let RenderNode::Layer(child) = &graph.children[0] else {
2025 panic!("expected child layer");
2026 };
2027
2028 let top_left = child.transform_to_parent.map_point(Point::default());
2029 assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
2030 }
2031
2032 #[test]
2033 fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
2034 fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
2035 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
2036 scope.push_recorded(vec![DrawPrimitive::Rect {
2037 rect: Rect {
2038 x: 3.0,
2039 y: 4.0,
2040 width: 20.0,
2041 height: 8.0,
2042 },
2043 brush: Brush::solid(Color::WHITE),
2044 stroke: None,
2045 }]);
2046 }));
2047
2048 let child = BuildNodeSnapshot {
2049 node_id: 2,
2050 placement: Point { x: 11.0, y: 7.0 },
2051 size: Size {
2052 width: 40.0,
2053 height: 20.0,
2054 },
2055 draw_commands: vec![child_command],
2056 ..Default::default()
2057 };
2058
2059 BuildNodeSnapshot {
2060 node_id: 1,
2061 size: Size {
2062 width: 80.0,
2063 height: 50.0,
2064 },
2065 content_offset: offset,
2066 motion_context_animated,
2067 translated_content_context: true,
2068 children: vec![child],
2069 ..Default::default()
2070 }
2071 }
2072
2073 let base = build_layer_node_for_test(
2074 parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
2075 1.0,
2076 false,
2077 );
2078 let moved = build_layer_node_for_test(
2079 parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
2080 1.0,
2081 false,
2082 );
2083 let rested = build_layer_node_for_test(
2084 parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
2085 1.0,
2086 false,
2087 );
2088
2089 let RenderNode::Layer(base_child) = &base.children[0] else {
2090 panic!("expected child layer");
2091 };
2092 let RenderNode::Layer(moved_child) = &moved.children[0] else {
2093 panic!("expected child layer");
2094 };
2095
2096 assert_ne!(
2097 base_child.transform_to_parent.map_point(Point::default()),
2098 moved_child.transform_to_parent.map_point(Point::default()),
2099 "scroll offset still has to move child content visually"
2100 );
2101 assert_eq!(
2102 base_child.target_content_hash(),
2103 moved_child.target_content_hash(),
2104 "child source content identity stays stable when only the parent scroll offset changes"
2105 );
2106 assert_ne!(
2107 base.target_content_hash(),
2108 moved.target_content_hash(),
2109 "a full-surface cache of the scroll viewport must include the scroll offset"
2110 );
2111 assert_ne!(
2112 base.target_content_hash(),
2113 rested.target_content_hash(),
2114 "full-surface cache keys must include active scroll motion policy"
2115 );
2116 }
2117
2118 #[test]
2119 fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
2120 let layer = graphics_layer_with_shaped_clip(
2121 GraphicsLayer::default(),
2122 true,
2123 Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
2124 Rect {
2125 x: 0.0,
2126 y: 0.0,
2127 width: 100.0,
2128 height: 40.0,
2129 },
2130 );
2131
2132 assert!(layer.clip);
2133 assert!(layer.render_effect.is_none());
2134 let LayerShape::Rounded(shape) = layer.shape else {
2135 panic!("rounded clip must be recorded as layer shape");
2136 };
2137 assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
2138 assert!(isolation_reasons(&layer).shape_clip);
2139 }
2140
2141 #[test]
2142 fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
2143 let existing = RenderEffect::blur(3.0);
2144 let layer = graphics_layer_with_shaped_clip(
2145 GraphicsLayer {
2146 render_effect: Some(existing.clone()),
2147 ..GraphicsLayer::default()
2148 },
2149 true,
2150 Some(RoundedCornerShape::uniform(10.0)),
2151 Rect {
2152 x: 0.0,
2153 y: 0.0,
2154 width: 100.0,
2155 height: 40.0,
2156 },
2157 );
2158
2159 let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
2160 panic!("existing effect should chain into rounded clip mask");
2161 };
2162 assert_eq!(*first, existing);
2163 assert!(
2164 matches!(*second, RenderEffect::Shader { .. }),
2165 "rounded mask must be the outer effect"
2166 );
2167 }
2168
2169 #[test]
2170 fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
2171 let mut composition = cranpose_ui::run_test_composition(|| {
2172 cranpose_ui::Box(
2173 Modifier::empty()
2174 .width(100.0)
2175 .height(40.0)
2176 .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
2177 .clip_to_bounds(),
2178 cranpose_ui::BoxSpec::default(),
2179 || {
2180 Text("rounded child", Modifier::empty(), TextStyle::default());
2181 },
2182 );
2183 });
2184
2185 let root = composition.root().expect("rounded clip root");
2186 let handle = composition.runtime_handle();
2187 let mut applier = composition.applier_mut();
2188 applier.set_runtime_handle(handle);
2189 applier
2190 .compute_layout(
2191 root,
2192 Size {
2193 width: 160.0,
2194 height: 100.0,
2195 },
2196 )
2197 .expect("rounded clip layout");
2198 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
2199 applier.clear_runtime_handle();
2200
2201 let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
2202 assert!(rounded_layer.graphics_layer.clip);
2203 assert!(matches!(
2204 rounded_layer.graphics_layer.shape,
2205 LayerShape::Rounded(_)
2206 ));
2207 assert!(rounded_layer.graphics_layer.render_effect.is_none());
2208 assert!(rounded_layer.isolation.shape_clip);
2209 assert!(
2210 !graph_has_runtime_shader_effect(&graph.root),
2211 "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
2212 );
2213 }
2214
2215 fn wrapped_card_composition(
2216 label: Rc<RefCell<Option<cranpose_core::MutableState<String>>>>,
2217 card_id: Rc<RefCell<Option<NodeId>>>,
2218 ) -> cranpose_ui::TestComposition {
2219 cranpose_ui::run_test_composition(move || {
2220 let text = cranpose_core::rememberMutableStateOf(|| "before".to_string());
2221 *label.borrow_mut() = Some(text);
2222 let card_id = card_id.clone();
2223 cranpose_ui::Box(
2224 Modifier::empty().size_points(240.0, 120.0),
2225 cranpose_ui::BoxSpec::default(),
2226 move || {
2227 let shape = cranpose_ui::LayerShape::Rounded(
2228 cranpose_ui::RoundedCornerShape::uniform(12.0),
2229 );
2230 let id = cranpose_ui::Box(
2231 Modifier::empty()
2232 .offset(20.0, 30.0)
2233 .size_points(100.0, 40.0)
2234 .drop_shadow(shape, |scope| scope.radius = 6.0)
2235 .graphics_layer(move || GraphicsLayer {
2236 shape,
2237 clip: true,
2238 translation_x: 5.0,
2239 ..Default::default()
2240 })
2241 .background(cranpose_ui::Color(0.2, 0.4, 0.8, 1.0)),
2242 cranpose_ui::BoxSpec::default(),
2243 move || {
2244 Text(text, Modifier::empty(), TextStyle::default());
2245 },
2246 );
2247 *card_id.borrow_mut() = Some(id);
2248 },
2249 );
2250 })
2251 }
2252
2253 fn wrapper_and_card(root: &LayerNode, card_id: NodeId) -> (&LayerNode, &LayerNode) {
2254 let wrapper = root
2255 .children
2256 .iter()
2257 .find_map(|child| match child {
2258 RenderNode::Layer(layer) if layer.wraps == Some(card_id) => Some(layer.as_ref()),
2259 _ => None,
2260 })
2261 .expect("the card's outer shadow wraps its clipped layer");
2262 let card = find_layer_by_node_id(wrapper, card_id).expect("card layer inside the wrapper");
2263 (wrapper, card)
2264 }
2265
2266 #[test]
2267 fn a_draw_before_the_graphics_layer_wraps_the_clipped_layer_in_the_parents_space() {
2268 let label = Rc::new(RefCell::new(None));
2269 let card_id = Rc::new(RefCell::new(None));
2270 let mut composition = wrapped_card_composition(label, card_id.clone());
2271 let root = composition.root().expect("composition root");
2272 let handle = composition.runtime_handle();
2273 let mut applier = composition.applier_mut();
2274 applier.set_runtime_handle(handle);
2275 applier
2276 .compute_layout(
2277 root,
2278 Size {
2279 width: 240.0,
2280 height: 120.0,
2281 },
2282 )
2283 .expect("layout");
2284 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("graph");
2285 applier.clear_runtime_handle();
2286 let card_id = card_id.borrow().expect("card id");
2287 let (wrapper, card) = wrapper_and_card(&graph.root, card_id);
2288
2289 assert_eq!(wrapper.node_id, None);
2290 assert!(!wrapper.graphics_layer.clip);
2291 assert_eq!(
2292 wrapper.transform_to_parent.map_point(Point::default()),
2293 Point { x: 20.0, y: 30.0 },
2294 "the wrapper carries the layout placement alone"
2295 );
2296 assert_eq!(
2297 card.transform_to_parent.map_point(Point::default()),
2298 Point { x: 5.0, y: 0.0 },
2299 "the clipped layer keeps only its own graphics-layer transform"
2300 );
2301 assert!(card.graphics_layer.clip);
2302 let [shadow, RenderNode::Layer(_)] = wrapper.children.as_slice() else {
2303 panic!(
2304 "wrapper children must be the outer shadow then the card, got {} children",
2305 wrapper.children.len()
2306 );
2307 };
2308 let shadow_is_outer = match shadow {
2309 RenderNode::DrawRun(run) => run.summary.has_shadow && !run.summary.has_non_shadow,
2310 RenderNode::Primitive(entry) => matches!(
2311 &entry.node,
2312 PrimitiveNode::Draw(draw) if matches!(draw.primitive, DrawPrimitive::Shadow(_))
2313 ),
2314 RenderNode::Layer(_) => false,
2315 };
2316 assert!(shadow_is_outer, "the outer draw is the shadow alone");
2317 assert!(
2318 card.children.iter().any(|child| match child {
2319 RenderNode::DrawRun(run) => run.summary.has_non_shadow,
2320 RenderNode::Primitive(entry) => matches!(
2321 &entry.node,
2322 PrimitiveNode::Draw(draw)
2323 if !matches!(draw.primitive, DrawPrimitive::Shadow(_))
2324 ),
2325 RenderNode::Layer(_) => false,
2326 }),
2327 "the background chained after the layer stays inside it"
2328 );
2329 }
2330
2331 #[test]
2332 fn a_dirty_wrapped_node_is_rebuilt_as_one_wrapper() {
2333 let label = Rc::new(RefCell::new(None));
2334 let card_id = Rc::new(RefCell::new(None));
2335 let mut composition = wrapped_card_composition(label.clone(), card_id.clone());
2336 let root = composition.root().expect("composition root");
2337 let viewport = Size {
2338 width: 240.0,
2339 height: 120.0,
2340 };
2341 let handle = composition.runtime_handle();
2342 let mut applier = composition.applier_mut();
2343 applier.set_runtime_handle(handle);
2344 applier.compute_layout(root, viewport).expect("layout");
2345 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("graph");
2346 applier.clear_runtime_handle();
2347 drop(applier);
2348 let card_id = card_id.borrow().expect("card id");
2349
2350 let text = label.borrow().as_ref().copied().expect("label state");
2351 text.set_value("after".to_string());
2352 composition
2353 .process_invalid_scopes()
2354 .expect("text recomposition");
2355 let handle = composition.runtime_handle();
2356 let mut applier = composition.applier_mut();
2357 applier.set_runtime_handle(handle);
2358 applier.compute_layout(root, viewport).expect("layout");
2359 assert!(update_graph_from_applier(
2360 &mut applier,
2361 &mut graph,
2362 &[card_id],
2363 1.0
2364 ));
2365 applier.clear_runtime_handle();
2366
2367 let (wrapper, card) = wrapper_and_card(&graph.root, card_id);
2368 assert_eq!(
2369 wrapper.transform_to_parent.map_point(Point::default()),
2370 Point { x: 20.0, y: 30.0 }
2371 );
2372 assert!(
2373 !card.children.iter().any(|child| matches!(
2374 child,
2375 RenderNode::Layer(layer) if layer.wraps.is_some()
2376 )),
2377 "rebuilding the wrapped node must replace its wrapper, not nest a second one"
2378 );
2379 let mut labels = Vec::new();
2380 collect_text_labels(&graph.root, &mut labels);
2381 assert_eq!(labels, vec!["after".to_string()]);
2382 }
2383
2384 #[test]
2385 fn update_graph_from_applier_replaces_dirty_child_layer() {
2386 let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2387 Rc::new(RefCell::new(None));
2388 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2389 let state_holder_for_comp = state_holder.clone();
2390 let child_id_holder_for_comp = child_id_holder.clone();
2391
2392 let mut composition = cranpose_ui::run_test_composition(move || {
2393 let label = cranpose_core::rememberMutableStateOf(|| "before".to_string());
2394 *state_holder_for_comp.borrow_mut() = Some(label);
2395 let child_id_holder_for_content = child_id_holder_for_comp.clone();
2396 cranpose_ui::Box(
2397 Modifier::empty().size_points(240.0, 80.0),
2398 cranpose_ui::BoxSpec::default(),
2399 move || {
2400 let child_id = Text(label, Modifier::empty(), TextStyle::default());
2401 *child_id_holder_for_content.borrow_mut() = Some(child_id);
2402 Text("stable", Modifier::empty(), TextStyle::default());
2403 },
2404 );
2405 });
2406
2407 let root = composition.root().expect("composition root");
2408 let viewport = Size {
2409 width: 240.0,
2410 height: 80.0,
2411 };
2412 let handle = composition.runtime_handle();
2413 let mut applier = composition.applier_mut();
2414 applier.set_runtime_handle(handle);
2415 applier
2416 .compute_layout(root, viewport)
2417 .expect("initial layout");
2418 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2419 let child_id = child_id_holder
2420 .borrow()
2421 .expect("text child id should be captured");
2422 let initial_transform = find_layer_by_node_id(&graph.root, child_id)
2423 .expect("text child layer")
2424 .transform_to_parent;
2425 applier.clear_runtime_handle();
2426 drop(applier);
2427
2428 let label = state_holder
2429 .borrow()
2430 .as_ref()
2431 .copied()
2432 .expect("label state should be captured");
2433 label.set_value("after".to_string());
2434 composition
2435 .process_invalid_scopes()
2436 .expect("text recomposition");
2437
2438 let handle = composition.runtime_handle();
2439 let mut applier = composition.applier_mut();
2440 applier.set_runtime_handle(handle);
2441 applier
2442 .compute_layout(root, viewport)
2443 .expect("updated layout");
2444 let child_id = child_id_holder
2445 .borrow()
2446 .expect("text child id should remain captured");
2447
2448 assert!(
2449 update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
2450 "dirty child should be replaceable from retained applier state"
2451 );
2452 applier.clear_runtime_handle();
2453
2454 let mut labels = Vec::new();
2455 collect_text_labels(&graph.root, &mut labels);
2456 assert!(
2457 labels.iter().any(|label| label == "after"),
2458 "updated graph should contain refreshed child text, got {labels:?}"
2459 );
2460 assert!(
2461 !labels.iter().any(|label| label == "before"),
2462 "updated graph should not retain stale child text, got {labels:?}"
2463 );
2464 assert!(
2465 labels.iter().any(|label| label == "stable"),
2466 "sibling content should remain present, got {labels:?}"
2467 );
2468 assert_eq!(
2469 find_layer_by_node_id(&graph.root, child_id)
2470 .expect("updated text child layer")
2471 .transform_to_parent,
2472 initial_transform,
2473 "draw-only child replacement must preserve the retained parent placement transform"
2474 );
2475 }
2476
2477 fn assert_same_cache_hash_state(dirty_road: &LayerNode, full_road: &LayerNode, path: &str) {
2478 assert_eq!(
2479 dirty_road.node_id, full_road.node_id,
2480 "tree shape must match at {path}"
2481 );
2482 assert_eq!(
2483 dirty_road.cache_hashes_valid, full_road.cache_hashes_valid,
2484 "hash validity at {path} (node {:?})",
2485 dirty_road.node_id
2486 );
2487 if full_road.cache_hashes_valid {
2488 assert_eq!(
2489 dirty_road.cache_hashes, full_road.cache_hashes,
2490 "stored hashes at {path} (node {:?})",
2491 dirty_road.node_id
2492 );
2493 }
2494 assert_eq!(
2495 dirty_road.target_content_hash(),
2496 full_road.target_content_hash(),
2497 "target content hash at {path} (node {:?})",
2498 dirty_road.node_id
2499 );
2500 assert_eq!(
2501 dirty_road.children.len(),
2502 full_road.children.len(),
2503 "child count at {path}"
2504 );
2505 for (index, (dirty_child, full_child)) in dirty_road
2506 .children
2507 .iter()
2508 .zip(full_road.children.iter())
2509 .enumerate()
2510 {
2511 if let (RenderNode::Layer(dirty_child), RenderNode::Layer(full_child)) =
2512 (dirty_child, full_child)
2513 {
2514 assert_same_cache_hash_state(dirty_child, full_child, &format!("{path}/{index}"));
2515 }
2516 }
2517 }
2518
2519 fn assert_dirty_hash_road_matches_full_walk(graph: &RenderGraph) {
2520 let mut full_road = graph.root.clone();
2521 full_road.recompute_raster_cache_hashes();
2522 assert_same_cache_hash_state(&graph.root, &full_road, "root");
2523 }
2524
2525 #[test]
2526 fn dirty_update_leaves_the_hashes_a_full_walk_leaves() {
2527 let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2528 Rc::new(RefCell::new(None));
2529 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2530 let label_holder_for_comp = label_holder.clone();
2531 let child_id_holder_for_comp = child_id_holder.clone();
2532
2533 let mut composition = cranpose_ui::run_test_composition(move || {
2534 let label = cranpose_core::rememberMutableStateOf(|| "before".to_string());
2535 *label_holder_for_comp.borrow_mut() = Some(label);
2536 let child_id_holder_for_content = child_id_holder_for_comp.clone();
2537 Column(
2538 Modifier::empty().size_points(240.0, 200.0),
2539 ColumnSpec::default(),
2540 move || {
2541 cranpose_ui::Box(
2542 Modifier::empty()
2543 .size_points(240.0, 80.0)
2544 .graphics_layer(|| GraphicsLayer {
2545 alpha: 0.5,
2546 ..GraphicsLayer::default()
2547 }),
2548 cranpose_ui::BoxSpec::default(),
2549 {
2550 let child_id_holder_for_box = child_id_holder_for_content.clone();
2551 move || {
2552 let child_id = Text(label, Modifier::empty(), TextStyle::default());
2553 *child_id_holder_for_box.borrow_mut() = Some(child_id);
2554 }
2555 },
2556 );
2557 cranpose_ui::Box(
2558 Modifier::empty()
2559 .size_points(240.0, 80.0)
2560 .graphics_layer(|| GraphicsLayer {
2561 alpha: 0.75,
2562 ..GraphicsLayer::default()
2563 }),
2564 cranpose_ui::BoxSpec::default(),
2565 || {
2566 Text("stable", Modifier::empty(), TextStyle::default());
2567 },
2568 );
2569 },
2570 );
2571 });
2572
2573 let root = composition.root().expect("composition root");
2574 let viewport = Size {
2575 width: 240.0,
2576 height: 200.0,
2577 };
2578 let handle = composition.runtime_handle();
2579 let mut applier = composition.applier_mut();
2580 applier.set_runtime_handle(handle);
2581 applier
2582 .compute_layout(root, viewport)
2583 .expect("initial layout");
2584 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2585 graph.root.recompute_raster_cache_hashes();
2586 applier.clear_runtime_handle();
2587 drop(applier);
2588 assert_dirty_hash_road_matches_full_walk(&graph);
2589
2590 let label = label_holder
2591 .borrow()
2592 .as_ref()
2593 .copied()
2594 .expect("label state should be captured");
2595 label.set_value("after".to_string());
2596 composition
2597 .process_invalid_scopes()
2598 .expect("text recomposition");
2599
2600 let handle = composition.runtime_handle();
2601 let mut applier = composition.applier_mut();
2602 applier.set_runtime_handle(handle);
2603 applier
2604 .compute_layout(root, viewport)
2605 .expect("updated layout");
2606 let child_id = child_id_holder
2607 .borrow()
2608 .expect("text child id should be captured");
2609 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
2610 applier.clear_runtime_handle();
2611
2612 assert!(
2613 report.applied(),
2614 "dirty child update should apply in place, got {:?}",
2615 report.update
2616 );
2617 assert_dirty_hash_road_matches_full_walk(&graph);
2618 }
2619
2620 #[test]
2621 fn dirty_update_with_a_new_row_leaves_the_hashes_a_full_walk_leaves() {
2622 let rows_holder: Rc<RefCell<Option<cranpose_core::MutableState<usize>>>> =
2623 Rc::new(RefCell::new(None));
2624 let column_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2625 let rows_holder_for_comp = rows_holder.clone();
2626 let column_id_holder_for_comp = column_id_holder.clone();
2627
2628 let mut composition = cranpose_ui::run_test_composition(move || {
2629 let rows = cranpose_core::rememberMutableStateOf(|| 2usize);
2630 *rows_holder_for_comp.borrow_mut() = Some(rows);
2631 let column_id_holder_for_content = column_id_holder_for_comp.clone();
2632 cranpose_ui::Box(
2633 Modifier::empty()
2634 .size_points(240.0, 240.0)
2635 .graphics_layer(|| GraphicsLayer {
2636 alpha: 0.5,
2637 ..GraphicsLayer::default()
2638 }),
2639 cranpose_ui::BoxSpec::default(),
2640 move || {
2641 let column_id = Column(
2642 Modifier::empty().size_points(240.0, 240.0),
2643 ColumnSpec::default(),
2644 move || {
2645 for index in 0..rows.get() {
2646 Text(
2647 format!("row {index}"),
2648 Modifier::empty(),
2649 TextStyle::default(),
2650 );
2651 }
2652 },
2653 );
2654 *column_id_holder_for_content.borrow_mut() = Some(column_id);
2655 },
2656 );
2657 });
2658
2659 let root = composition.root().expect("composition root");
2660 let viewport = Size {
2661 width: 240.0,
2662 height: 240.0,
2663 };
2664 let handle = composition.runtime_handle();
2665 let mut applier = composition.applier_mut();
2666 applier.set_runtime_handle(handle);
2667 applier
2668 .compute_layout(root, viewport)
2669 .expect("initial layout");
2670 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2671 graph.root.recompute_raster_cache_hashes();
2672 applier.clear_runtime_handle();
2673 drop(applier);
2674
2675 let rows = rows_holder
2676 .borrow()
2677 .as_ref()
2678 .copied()
2679 .expect("row count state should be captured");
2680 rows.set_value(3);
2681 composition
2682 .process_invalid_scopes()
2683 .expect("row recomposition");
2684
2685 let handle = composition.runtime_handle();
2686 let mut applier = composition.applier_mut();
2687 applier.set_runtime_handle(handle);
2688 applier
2689 .compute_layout(root, viewport)
2690 .expect("updated layout");
2691 let column_id = column_id_holder.borrow().expect("column id");
2692 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[column_id], 1.0);
2693 applier.clear_runtime_handle();
2694
2695 assert!(
2696 report.applied(),
2697 "structural update should apply in place, got {:?}",
2698 report.update
2699 );
2700 let mut labels = Vec::new();
2701 collect_text_labels(&graph.root, &mut labels);
2702 assert!(
2703 labels.iter().any(|label| label == "row 2"),
2704 "the new row must be in the patched graph, got {labels:?}"
2705 );
2706 assert_dirty_hash_road_matches_full_walk(&graph);
2707 }
2708
2709 #[test]
2710 fn scene_build_publishes_live_window_rect_without_layout_tree() {
2711 use std::cell::Cell;
2712
2713 use cranpose_ui::{Box, BoxSpec, MeasureLayoutOptions, measure_layout_with_options};
2714
2715 let spacer_before = 120.0_f32;
2716 let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
2717 x: 0.0,
2718 y: 0.0,
2719 width: 0.0,
2720 height: 0.0,
2721 }));
2722 let sink_for_comp = sink.clone();
2723 let mut composition = cranpose_ui::run_test_composition(move || {
2724 let sink = sink_for_comp.clone();
2725 Column(
2726 Modifier::empty().size_points(200.0, 400.0),
2727 ColumnSpec::default(),
2728 move || {
2729 Spacer(Size {
2730 width: 200.0,
2731 height: spacer_before,
2732 });
2733 Box(
2734 Modifier::empty()
2735 .size_points(200.0, 50.0)
2736 .report_window_rect(sink.clone()),
2737 BoxSpec::default(),
2738 || {},
2739 );
2740 },
2741 );
2742 });
2743
2744 let root = composition.root().expect("composition root");
2745 let viewport = Size {
2746 width: 200.0,
2747 height: 400.0,
2748 };
2749 let handle = composition.runtime_handle();
2750 let mut applier = composition.applier_mut();
2751 applier.set_runtime_handle(handle);
2752 measure_layout_with_options(
2753 &mut applier,
2754 root,
2755 viewport,
2756 MeasureLayoutOptions {
2757 collect_semantics: false,
2758 build_layout_tree: false,
2759 },
2760 )
2761 .expect("layout");
2762 assert_eq!(
2763 sink.get().height,
2764 0.0,
2765 "sink must start empty (place disabled)"
2766 );
2767
2768 let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
2769 applier.clear_runtime_handle();
2770
2771 let rect = sink.get();
2772 assert!(
2773 (rect.y - spacer_before).abs() < 0.5,
2774 "scene build must publish the box's live window-y (below the {spacer_before}px \
2775 spacer), got {}",
2776 rect.y
2777 );
2778 assert!(
2779 rect.width > 0.0 && rect.height > 0.0,
2780 "scene build must publish a non-empty window rect, got {rect:?}"
2781 );
2782 }
2783
2784 #[test]
2785 fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
2786 let mut graph = RenderGraph {
2787 root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
2788 };
2789 let mut applier = MemoryApplier::new();
2790
2791 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
2792
2793 assert_eq!(
2794 report,
2795 GraphUpdateReport {
2796 update: GraphUpdate::NeedsRebuild(GraphRebuildReason::DirtyLayerUnavailable),
2797 hit_graph_dirty: true,
2798 },
2799 "dirty child graph updates must not report success when the replacement cannot be rebuilt"
2800 );
2801 }
2802
2803 #[test]
2804 fn scrolled_list_under_a_composited_layer_keeps_the_hashes_a_full_walk_leaves() {
2805 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2806 let scroll_holder_for_comp = scroll_holder.clone();
2807
2808 let mut composition = cranpose_ui::run_test_composition(move || {
2809 let scroll_state =
2810 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
2811 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
2812 cranpose_ui::Box(
2813 Modifier::empty()
2814 .size_points(240.0, 320.0)
2815 .graphics_layer(|| GraphicsLayer {
2816 alpha: 0.6,
2817 ..GraphicsLayer::default()
2818 }),
2819 cranpose_ui::BoxSpec::default(),
2820 move || {
2821 Column(
2822 Modifier::empty()
2823 .size_points(240.0, 320.0)
2824 .vertical_scroll(scroll_state, false),
2825 ColumnSpec::default(),
2826 || {
2827 for index in 0..12usize {
2828 cranpose_ui::Box(
2829 Modifier::empty().size_points(240.0, 60.0).graphics_layer(
2830 || GraphicsLayer {
2831 alpha: 0.8,
2832 ..GraphicsLayer::default()
2833 },
2834 ),
2835 cranpose_ui::BoxSpec::default(),
2836 move || {
2837 Text(
2838 format!("row {index}"),
2839 Modifier::empty(),
2840 TextStyle::default(),
2841 );
2842 },
2843 );
2844 }
2845 },
2846 );
2847 },
2848 );
2849 });
2850
2851 let root = composition.root().expect("composition root");
2852 let viewport = Size {
2853 width: 240.0,
2854 height: 320.0,
2855 };
2856 let handle = composition.runtime_handle();
2857 let mut applier = composition.applier_mut();
2858 applier.set_runtime_handle(handle);
2859 applier
2860 .compute_layout(root, viewport)
2861 .expect("initial scroll layout");
2862 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2863 graph.root.recompute_raster_cache_hashes();
2864 applier.clear_runtime_handle();
2865 drop(applier);
2866
2867 let scroll_state = scroll_holder
2868 .borrow()
2869 .as_ref()
2870 .cloned()
2871 .expect("scroll state should be captured");
2872 assert!(
2873 scroll_state.dispatch_raw_delta(96.0) > 0.0,
2874 "test scroll must be consumed"
2875 );
2876 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
2877 assert!(
2878 !dirty_nodes.is_empty(),
2879 "a scroll must schedule a scoped scene update"
2880 );
2881
2882 let handle = composition.runtime_handle();
2883 let mut applier = composition.applier_mut();
2884 applier.set_runtime_handle(handle);
2885 applier
2886 .compute_layout(root, viewport)
2887 .expect("scrolled layout");
2888 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
2889 applier.clear_runtime_handle();
2890
2891 assert!(
2892 report.applied(),
2893 "scroll update should apply in place, got {:?}",
2894 report.update
2895 );
2896 assert_dirty_hash_road_matches_full_walk(&graph);
2897 }
2898
2899 #[test]
2900 fn update_graph_from_applier_refreshes_scroll_content_offset() {
2901 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2902 let scroll_holder_for_comp = scroll_holder.clone();
2903
2904 let mut composition = cranpose_ui::run_test_composition(move || {
2905 let scroll_state =
2906 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
2907 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
2908 Column(
2909 Modifier::empty()
2910 .size_points(240.0, 120.0)
2911 .vertical_scroll(scroll_state, false),
2912 ColumnSpec::default(),
2913 || {
2914 Text("scroll top", Modifier::empty(), TextStyle::default());
2915 Spacer(Size {
2916 width: 0.0,
2917 height: 160.0,
2918 });
2919 Text("scroll target", Modifier::empty(), TextStyle::default());
2920 },
2921 );
2922 });
2923
2924 let root = composition.root().expect("composition root");
2925 let viewport = Size {
2926 width: 240.0,
2927 height: 120.0,
2928 };
2929 let handle = composition.runtime_handle();
2930 let mut applier = composition.applier_mut();
2931 applier.set_runtime_handle(handle);
2932 applier
2933 .compute_layout(root, viewport)
2934 .expect("initial scroll layout");
2935 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2936 graph.root.recompute_raster_cache_hashes();
2937 let initial_target_top =
2938 find_text_top(&graph.root, "scroll target").expect("initial target text");
2939 applier.clear_runtime_handle();
2940 drop(applier);
2941
2942 let scroll_state = scroll_holder
2943 .borrow()
2944 .as_ref()
2945 .cloned()
2946 .expect("scroll state should be captured");
2947 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
2948 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
2949 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
2950 assert!(
2951 !dirty_nodes.is_empty(),
2952 "scroll state invalidation must schedule scoped layout graph update"
2953 );
2954
2955 let handle = composition.runtime_handle();
2956 let mut applier = composition.applier_mut();
2957 applier.set_runtime_handle(handle);
2958 applier
2959 .compute_layout(root, viewport)
2960 .expect("scrolled layout");
2961 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
2962 applier.clear_runtime_handle();
2963
2964 assert!(
2965 report.applied(),
2966 "scroll graph update should apply in place, got {:?}",
2967 report.update
2968 );
2969 let updated_target_top =
2970 find_text_top(&graph.root, "scroll target").expect("updated target text");
2971 assert!(
2972 updated_target_top < initial_target_top - consumed_scroll * 0.75,
2973 "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
2974 );
2975 assert_dirty_hash_road_matches_full_walk(&graph);
2976 }
2977
2978 #[test]
2979 fn an_overmarked_ancestor_chain_still_translates_instead_of_relowering() {
2980 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2981 let scroll_holder_for_comp = scroll_holder.clone();
2982
2983 let mut composition = cranpose_ui::run_test_composition(move || {
2984 let scroll_state =
2985 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
2986 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
2987 cranpose_ui::Box(
2988 Modifier::empty().size_points(240.0, 320.0),
2989 cranpose_ui::BoxSpec::default(),
2990 move || {
2991 Column(
2992 Modifier::empty()
2993 .size_points(240.0, 320.0)
2994 .vertical_scroll(scroll_state, false),
2995 ColumnSpec::default(),
2996 || {
2997 for index in 0..12usize {
2998 cranpose_ui::Box(
2999 Modifier::empty()
3000 .size_points(240.0, 60.0)
3001 .background(Color(0.9, 0.9, 0.92, 1.0)),
3002 cranpose_ui::BoxSpec::default(),
3003 move || {
3004 Text(
3005 format!("row {index}"),
3006 Modifier::empty(),
3007 TextStyle::default(),
3008 );
3009 },
3010 );
3011 }
3012 },
3013 );
3014 },
3015 );
3016 });
3017
3018 let root = composition.root().expect("composition root");
3019 let viewport = Size {
3020 width: 240.0,
3021 height: 320.0,
3022 };
3023 let handle = composition.runtime_handle();
3024 let mut applier = composition.applier_mut();
3025 applier.set_runtime_handle(handle);
3026 applier
3027 .compute_layout(root, viewport)
3028 .expect("initial scroll layout");
3029 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3030 graph.root.recompute_raster_cache_hashes();
3031 let initial_row_top = find_text_top(&graph.root, "row 3").expect("initial row text");
3032 applier.clear_runtime_handle();
3033 drop(applier);
3034
3035 let scroll_state = scroll_holder
3036 .borrow()
3037 .as_ref()
3038 .cloned()
3039 .expect("scroll state should be captured");
3040 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
3041 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
3042 let mut dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3043 dirty_nodes.push(graph.root.node_id.expect("root id"));
3044 let mut cursor = &graph.root;
3045 while let Some(RenderNode::Layer(child)) = cursor
3046 .children
3047 .iter()
3048 .find(|child| matches!(child, RenderNode::Layer(_)))
3049 {
3050 let chain = child.node_id.expect("chain node id");
3051 if dirty_nodes.contains(&chain) {
3052 break;
3053 }
3054 dirty_nodes.push(chain);
3055 cursor = child;
3056 }
3057
3058 let handle = composition.runtime_handle();
3059 let mut applier = composition.applier_mut();
3060 applier.set_runtime_handle(handle);
3061 applier
3062 .compute_layout(root, viewport)
3063 .expect("scrolled layout");
3064 reset_lowered_layer_count();
3065 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3066 applier.clear_runtime_handle();
3067
3068 assert!(
3069 report.applied(),
3070 "chain update should apply in place, got {:?}",
3071 report.update
3072 );
3073 let lowered = lowered_layer_count();
3074 assert_eq!(
3075 lowered, 0,
3076 "an over-marked ancestor chain over a pure scroll must still \
3077 translate; {lowered} layers were rebuilt (dirty={dirty_nodes:?})"
3078 );
3079 let updated_row_top = find_text_top(&graph.root, "row 3").expect("updated row text");
3080 assert!(
3081 updated_row_top < initial_row_top - consumed_scroll * 0.75,
3082 "the translation must land through the chain: initial_y={initial_row_top} updated_y={updated_row_top}"
3083 );
3084 assert_dirty_hash_road_matches_full_walk(&graph);
3085 }
3086
3087 #[test]
3088 fn a_scrolled_container_translates_clean_children_instead_of_relowering() {
3089 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3090 let scroll_holder_for_comp = scroll_holder.clone();
3091
3092 let mut composition = cranpose_ui::run_test_composition(move || {
3093 let scroll_state =
3094 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3095 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3096 Column(
3097 Modifier::empty()
3098 .size_points(240.0, 320.0)
3099 .vertical_scroll(scroll_state, false),
3100 ColumnSpec::default(),
3101 || {
3102 for index in 0..12usize {
3103 cranpose_ui::Box(
3104 Modifier::empty()
3105 .size_points(240.0, 60.0)
3106 .background(Color(0.9, 0.9, 0.92, 1.0)),
3107 cranpose_ui::BoxSpec::default(),
3108 move || {
3109 Text(
3110 format!("row {index}"),
3111 Modifier::empty(),
3112 TextStyle::default(),
3113 );
3114 },
3115 );
3116 }
3117 },
3118 );
3119 });
3120
3121 let root = composition.root().expect("composition root");
3122 let viewport = Size {
3123 width: 240.0,
3124 height: 320.0,
3125 };
3126 let handle = composition.runtime_handle();
3127 let mut applier = composition.applier_mut();
3128 applier.set_runtime_handle(handle);
3129 applier
3130 .compute_layout(root, viewport)
3131 .expect("initial scroll layout");
3132 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3133 graph.root.recompute_raster_cache_hashes();
3134 let initial_row_top = find_text_top(&graph.root, "row 3").expect("initial row text");
3135 applier.clear_runtime_handle();
3136 drop(applier);
3137
3138 let scroll_state = scroll_holder
3139 .borrow()
3140 .as_ref()
3141 .cloned()
3142 .expect("scroll state should be captured");
3143 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
3144 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
3145 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3146 assert!(
3147 !dirty_nodes.is_empty(),
3148 "a scroll must schedule a scoped scene update"
3149 );
3150
3151 let handle = composition.runtime_handle();
3152 let mut applier = composition.applier_mut();
3153 applier.set_runtime_handle(handle);
3154 applier
3155 .compute_layout(root, viewport)
3156 .expect("scrolled layout");
3157 reset_lowered_layer_count();
3158 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3159 applier.clear_runtime_handle();
3160
3161 assert!(
3162 report.applied(),
3163 "scroll update should apply in place, got {:?}",
3164 report.update
3165 );
3166 let lowered = lowered_layer_count();
3167 assert_eq!(
3168 lowered, 0,
3169 "a pure scroll of clean children must translate the retained \
3170 subtrees, not re-lower them; {lowered} layers were rebuilt"
3171 );
3172 let updated_row_top = find_text_top(&graph.root, "row 3").expect("updated row text");
3173 assert!(
3174 updated_row_top < initial_row_top - consumed_scroll * 0.75,
3175 "the translation must actually land: initial_y={initial_row_top} updated_y={updated_row_top}"
3176 );
3177 assert_dirty_hash_road_matches_full_walk(&graph);
3178 }
3179
3180 #[test]
3181 fn a_scrolled_container_translates_rows_that_carry_outer_shadows() {
3182 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3183 let scroll_holder_for_comp = scroll_holder.clone();
3184 let mut composition = cranpose_ui::run_test_composition(move || {
3185 let scroll_state =
3186 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3187 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3188 Column(
3189 Modifier::empty()
3190 .size_points(240.0, 320.0)
3191 .vertical_scroll(scroll_state, false),
3192 ColumnSpec::default(),
3193 || {
3194 for index in 0..12usize {
3195 let shape = cranpose_ui::LayerShape::Rounded(
3196 cranpose_ui::RoundedCornerShape::uniform(12.0),
3197 );
3198 cranpose_ui::Box(
3199 Modifier::empty()
3200 .size_points(240.0, 60.0)
3201 .drop_shadow(shape, |scope| scope.radius = 6.0)
3202 .graphics_layer(move || GraphicsLayer {
3203 shape,
3204 clip: true,
3205 ..Default::default()
3206 }),
3207 cranpose_ui::BoxSpec::default(),
3208 move || {
3209 Text(
3210 format!("row {index}"),
3211 Modifier::empty(),
3212 TextStyle::default(),
3213 );
3214 },
3215 );
3216 }
3217 },
3218 );
3219 });
3220
3221 let root = composition.root().expect("composition root");
3222 let viewport = Size {
3223 width: 240.0,
3224 height: 320.0,
3225 };
3226 let handle = composition.runtime_handle();
3227 let mut applier = composition.applier_mut();
3228 applier.set_runtime_handle(handle);
3229 applier.compute_layout(root, viewport).expect("layout");
3230 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("graph");
3231 graph.root.recompute_raster_cache_hashes();
3232 let initial_row_top = find_text_top(&graph.root, "row 3").expect("row text");
3233 applier.clear_runtime_handle();
3234 drop(applier);
3235
3236 let scroll_state = scroll_holder
3237 .borrow()
3238 .as_ref()
3239 .cloned()
3240 .expect("scroll state should be captured");
3241 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
3242 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3243 let handle = composition.runtime_handle();
3244 let mut applier = composition.applier_mut();
3245 applier.set_runtime_handle(handle);
3246 applier
3247 .compute_layout(root, viewport)
3248 .expect("scrolled layout");
3249 reset_lowered_layer_count();
3250 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3251 applier.clear_runtime_handle();
3252
3253 assert!(report.applied(), "got {:?}", report.update);
3254 assert_eq!(
3255 lowered_layer_count(),
3256 0,
3257 "rows wrapped in their outer shadow must translate like plain rows"
3258 );
3259 let updated_row_top = find_text_top(&graph.root, "row 3").expect("row text");
3260 assert!(updated_row_top < initial_row_top - consumed_scroll * 0.75);
3261 let wrappers = graph
3262 .root
3263 .children
3264 .iter()
3265 .filter(|child| matches!(child, RenderNode::Layer(layer) if layer.wraps.is_some()))
3266 .count();
3267 assert_eq!(wrappers, 12, "every row keeps exactly one wrapper");
3268 assert_dirty_hash_road_matches_full_walk(&graph);
3269 }
3270
3271 #[test]
3272 fn a_sliding_lazy_window_lowers_only_the_entering_rows() {
3273 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3274 let state_holder_for_comp = state_holder.clone();
3275 let mut composition = cranpose_ui::run_test_composition(move || {
3276 let list_state = rememberLazyListState();
3277 *state_holder_for_comp.borrow_mut() = Some(list_state);
3278 LazyColumn(
3279 Modifier::empty().size_points(240.0, 320.0),
3280 list_state,
3281 LazyColumnSpec::default(),
3282 |scope| {
3283 scope.items(60, |index| {
3284 cranpose_ui::Box(
3285 Modifier::empty()
3286 .size_points(240.0, 60.0)
3287 .background(Color(0.9, 0.9, 0.92, 1.0)),
3288 cranpose_ui::BoxSpec::default(),
3289 move || {
3290 Text(
3291 format!("row {index}"),
3292 Modifier::empty(),
3293 TextStyle::default(),
3294 );
3295 },
3296 );
3297 });
3298 },
3299 );
3300 });
3301
3302 let root = composition.root().expect("composition root");
3303 let viewport = Size {
3304 width: 240.0,
3305 height: 320.0,
3306 };
3307 let handle = composition.runtime_handle();
3308 let mut applier = composition.applier_mut();
3309 applier.set_runtime_handle(handle);
3310 applier
3311 .compute_layout(root, viewport)
3312 .expect("initial lazy layout");
3313 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3314 graph.root.recompute_raster_cache_hashes();
3315 let _ = applier.take_structural_change_parents_attached_to(root);
3316 let initial_row_top = find_text_top(&graph.root, "row 4").expect("initial row text");
3317 applier.clear_runtime_handle();
3318 drop(applier);
3319
3320 let list_state = (*state_holder.borrow()).expect("list state should be captured");
3321 let consumed = list_state.dispatch_scroll_delta(-96.0);
3322 assert!(consumed != 0.0, "the lazy scroll must consume the delta");
3323 let mut dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3324 dirty_nodes.extend(cranpose_ui::pending_measure_repass_nodes_snapshot());
3325
3326 let handle = composition.runtime_handle();
3327 let mut applier = composition.applier_mut();
3328 applier.set_runtime_handle(handle);
3329 applier
3330 .compute_layout(root, viewport)
3331 .expect("scrolled lazy layout");
3332 dirty_nodes.extend(applier.take_structural_change_parents_attached_to(root));
3333 dirty_nodes.sort_unstable();
3334 dirty_nodes.dedup();
3335 assert!(
3336 !dirty_nodes.is_empty(),
3337 "a lazy scroll must mark the list dirty"
3338 );
3339
3340 reset_lowered_layer_count();
3341 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3342 applier.clear_runtime_handle();
3343
3344 assert!(
3345 report.applied(),
3346 "the boundary frame must apply in place, got {:?}",
3347 report.update
3348 );
3349 let lowered = lowered_layer_count();
3350 assert!(
3351 lowered > 0,
3352 "rows crossed the window boundary; the entering subtrees must lower"
3353 );
3354 assert!(
3355 lowered <= 8,
3356 "only the entering rows may lower on a boundary frame; \
3357 {lowered} layers were rebuilt (dirty={dirty_nodes:?})"
3358 );
3359 let updated_row_top = find_text_top(&graph.root, "row 4").expect("updated row text");
3360 assert!(
3361 updated_row_top < initial_row_top - 60.0,
3362 "the retained rows must move with the scroll: \
3363 initial_y={initial_row_top} updated_y={updated_row_top}"
3364 );
3365 assert_dirty_hash_road_matches_full_walk(&graph);
3366 }
3367
3368 fn collect_text_tops(layer: &LayerNode) -> std::collections::BTreeMap<String, i64> {
3369 fn walk(
3370 layer: &LayerNode,
3371 transform: ProjectiveTransform,
3372 out: &mut std::collections::BTreeMap<String, i64>,
3373 ) {
3374 for child in &layer.children {
3375 match child {
3376 RenderNode::Primitive(primitive) => {
3377 if let PrimitiveNode::Text(text) = &primitive.node {
3378 let quad = transform.map_rect(text.rect);
3379 let top = quad
3380 .iter()
3381 .map(|point| point[1])
3382 .fold(f32::INFINITY, f32::min);
3383 if top.is_finite() {
3384 out.insert(text.text.text.clone(), (top * 10.0).round() as i64);
3385 }
3386 }
3387 }
3388 RenderNode::Layer(child_layer) => {
3389 let child_transform = child_layer.transform_to_parent.then(transform);
3390 walk(child_layer, child_transform, out);
3391 }
3392 RenderNode::DrawRun(_) => {}
3393 }
3394 }
3395 }
3396 let mut out = std::collections::BTreeMap::new();
3397 walk(layer, ProjectiveTransform::identity(), &mut out);
3398 out
3399 }
3400
3401 #[test]
3402 fn a_lazy_jump_of_any_distance_patches_to_what_a_fresh_build_shows() {
3403 for delta in [-30.0f32, -60.0, -96.0, -180.0, -300.0] {
3404 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3405 let state_holder_for_comp = state_holder.clone();
3406 let mut composition = cranpose_ui::run_test_composition(move || {
3407 let list_state = rememberLazyListState();
3408 *state_holder_for_comp.borrow_mut() = Some(list_state);
3409 LazyColumn(
3410 Modifier::empty().size_points(240.0, 320.0),
3411 list_state,
3412 LazyColumnSpec::default(),
3413 |scope| {
3414 scope.items(60, |index| {
3415 cranpose_ui::Box(
3416 Modifier::empty()
3417 .size_points(240.0, 60.0)
3418 .background(Color(0.9, 0.9, 0.92, 1.0)),
3419 cranpose_ui::BoxSpec::default(),
3420 move || {
3421 Text(
3422 format!("row {index}"),
3423 Modifier::empty(),
3424 TextStyle::default(),
3425 );
3426 },
3427 );
3428 });
3429 },
3430 );
3431 });
3432
3433 let root = composition.root().expect("composition root");
3434 let viewport = Size {
3435 width: 240.0,
3436 height: 320.0,
3437 };
3438 let handle = composition.runtime_handle();
3439 let mut applier = composition.applier_mut();
3440 applier.set_runtime_handle(handle);
3441 applier
3442 .compute_layout(root, viewport)
3443 .expect("initial lazy layout");
3444 let mut graph =
3445 build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3446 graph.root.recompute_raster_cache_hashes();
3447 let _ = applier.take_structural_change_parents_attached_to(root);
3448 applier.clear_runtime_handle();
3449 drop(applier);
3450
3451 let list_state = (*state_holder.borrow()).expect("list state should be captured");
3452 let consumed = list_state.dispatch_scroll_delta(delta);
3453 assert!(consumed != 0.0, "delta {delta}: the scroll must consume");
3454 let mut dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3455 dirty_nodes.extend(cranpose_ui::pending_measure_repass_nodes_snapshot());
3456
3457 let handle = composition.runtime_handle();
3458 let mut applier = composition.applier_mut();
3459 applier.set_runtime_handle(handle);
3460 applier
3461 .compute_layout(root, viewport)
3462 .expect("scrolled lazy layout");
3463 dirty_nodes.extend(applier.take_structural_change_parents_attached_to(root));
3464 dirty_nodes.sort_unstable();
3465 dirty_nodes.dedup();
3466 reset_lowered_layer_count();
3467 let report =
3468 update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3469 assert!(report.applied(), "delta {delta}: boundary frame must apply");
3470 if delta == -30.0 {
3471 assert_eq!(
3472 lowered_layer_count(),
3473 0,
3474 "a buffer-absorbed scroll must not lower any layer"
3475 );
3476 }
3477
3478 let fresh =
3479 build_graph_from_applier(&mut applier, root, 1.0).expect("fresh comparison graph");
3480 applier.clear_runtime_handle();
3481
3482 let patched_texts = collect_text_tops(&graph.root);
3483 let fresh_texts = collect_text_tops(&fresh.root);
3484 assert_eq!(
3485 patched_texts, fresh_texts,
3486 "delta {delta}: the patched scene must show exactly what a \
3487 fresh build shows (dirty={dirty_nodes:?})"
3488 );
3489 }
3490 }
3491
3492 #[test]
3493 fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
3494 let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
3495 Rc::new(RefCell::new(None));
3496 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3497 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3498 let label_holder_for_comp = label_holder.clone();
3499 let scroll_holder_for_comp = scroll_holder.clone();
3500 let child_id_holder_for_comp = child_id_holder.clone();
3501
3502 let mut composition = cranpose_ui::run_test_composition(move || {
3503 let label =
3504 cranpose_core::rememberMutableStateOf(|| "scrolled child before".to_string());
3505 let scroll_state =
3506 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3507 *label_holder_for_comp.borrow_mut() = Some(label);
3508 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3509 let child_id_holder_for_content = child_id_holder_for_comp.clone();
3510 Column(
3511 Modifier::empty()
3512 .size_points(260.0, 90.0)
3513 .vertical_scroll(scroll_state, false),
3514 ColumnSpec::default(),
3515 move || {
3516 Spacer(Size {
3517 width: 0.0,
3518 height: 24.0,
3519 });
3520 let child_id = Text(label, Modifier::empty(), TextStyle::default());
3521 *child_id_holder_for_content.borrow_mut() = Some(child_id);
3522 Spacer(Size {
3523 width: 0.0,
3524 height: 220.0,
3525 });
3526 },
3527 );
3528 });
3529
3530 let root = composition.root().expect("composition root");
3531 let viewport = Size {
3532 width: 260.0,
3533 height: 90.0,
3534 };
3535 let handle = composition.runtime_handle();
3536 let mut applier = composition.applier_mut();
3537 applier.set_runtime_handle(handle);
3538 applier
3539 .compute_layout(root, viewport)
3540 .expect("initial layout");
3541 applier.clear_runtime_handle();
3542 drop(applier);
3543
3544 let scroll_state = scroll_holder
3545 .borrow()
3546 .as_ref()
3547 .cloned()
3548 .expect("scroll state should be captured");
3549 assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
3550
3551 let handle = composition.runtime_handle();
3552 let mut applier = composition.applier_mut();
3553 applier.set_runtime_handle(handle);
3554 applier
3555 .compute_layout(root, viewport)
3556 .expect("scrolled layout");
3557 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
3558 let child_id = child_id_holder
3559 .borrow()
3560 .expect("text child id should be captured");
3561 let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
3562 .expect("scrolled child layer")
3563 .transform_to_parent;
3564 applier.clear_runtime_handle();
3565 drop(applier);
3566
3567 let label = label_holder
3568 .borrow()
3569 .as_ref()
3570 .copied()
3571 .expect("label state should be captured");
3572 label.set_value("scrolled child after".to_string());
3573 composition
3574 .process_invalid_scopes()
3575 .expect("text recomposition");
3576
3577 let handle = composition.runtime_handle();
3578 let mut applier = composition.applier_mut();
3579 applier.set_runtime_handle(handle);
3580 applier
3581 .compute_layout(root, viewport)
3582 .expect("updated scrolled layout");
3583 let child_id = child_id_holder
3584 .borrow()
3585 .expect("text child id should remain captured");
3586 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
3587 applier.clear_runtime_handle();
3588
3589 assert!(
3590 report.applied(),
3591 "dirty child graph update should apply, got {:?}",
3592 report.update
3593 );
3594 let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
3595 assert_eq!(
3596 updated.transform_to_parent, scrolled_transform,
3597 "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
3598 );
3599 let mut labels = Vec::new();
3600 collect_text_labels(&graph.root, &mut labels);
3601 assert!(
3602 labels.iter().any(|label| label == "scrolled child after"),
3603 "updated graph should contain refreshed text, got {labels:?}"
3604 );
3605 }
3606
3607 #[test]
3608 fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
3609 let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
3610 Rc::new(RefCell::new(None));
3611 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3612 let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3613 let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3614 let alpha_holder_for_comp = alpha_holder.clone();
3615 let scroll_holder_for_comp = scroll_holder.clone();
3616 let underlay_id_holder_for_comp = underlay_id_holder.clone();
3617 let overlay_id_holder_for_comp = overlay_id_holder.clone();
3618
3619 let mut composition = cranpose_ui::run_test_composition(move || {
3620 let alpha = cranpose_core::rememberMutableStateOf(|| 1.0f32);
3621 let scroll_state =
3622 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3623 *alpha_holder_for_comp.borrow_mut() = Some(alpha);
3624 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3625 let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
3626 let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
3627 Column(
3628 Modifier::empty()
3629 .size_points(260.0, 120.0)
3630 .vertical_scroll(scroll_state, false),
3631 ColumnSpec::default(),
3632 move || {
3633 Spacer(Size {
3634 width: 0.0,
3635 height: 180.0,
3636 });
3637 cranpose_ui::Box(
3638 Modifier::empty().size_points(188.0, 88.0),
3639 cranpose_ui::BoxSpec::default(),
3640 {
3641 let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
3642 let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
3643 move || {
3644 let underlay_id = cranpose_ui::Box(
3645 Modifier::empty().size_points(188.0, 88.0),
3646 cranpose_ui::BoxSpec::default(),
3647 || {
3648 Text(
3649 "UNDERLAY CONTENT",
3650 Modifier::empty().absolute_offset(12.0, 8.0),
3651 TextStyle::default(),
3652 );
3653 },
3654 );
3655 *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
3656 let overlay_id = cranpose_ui::Box(
3657 Modifier::empty().size_points(188.0, 88.0).graphics_layer(
3658 move || GraphicsLayer {
3659 alpha: alpha.get(),
3660 ..GraphicsLayer::default()
3661 },
3662 ),
3663 cranpose_ui::BoxSpec::default(),
3664 || {
3665 Text(
3666 "TOP LAYER",
3667 Modifier::empty().absolute_offset(74.0, 39.6),
3668 TextStyle::default(),
3669 );
3670 },
3671 );
3672 *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
3673 }
3674 },
3675 );
3676 Spacer(Size {
3677 width: 0.0,
3678 height: 280.0,
3679 });
3680 },
3681 );
3682 });
3683
3684 let root = composition.root().expect("composition root");
3685 let viewport = Size {
3686 width: 260.0,
3687 height: 120.0,
3688 };
3689 let handle = composition.runtime_handle();
3690 let mut applier = composition.applier_mut();
3691 applier.set_runtime_handle(handle);
3692 applier
3693 .compute_layout(root, viewport)
3694 .expect("initial layout");
3695 applier.clear_runtime_handle();
3696 drop(applier);
3697
3698 let scroll_state = scroll_holder
3699 .borrow()
3700 .as_ref()
3701 .cloned()
3702 .expect("scroll state should be captured");
3703 assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
3704
3705 let handle = composition.runtime_handle();
3706 let mut applier = composition.applier_mut();
3707 applier.set_runtime_handle(handle);
3708 applier
3709 .compute_layout(root, viewport)
3710 .expect("scrolled layout");
3711 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
3712 applier.clear_runtime_handle();
3713 drop(applier);
3714
3715 let underlay_id = underlay_id_holder
3716 .borrow()
3717 .expect("underlay id should be captured");
3718 let overlay_id = overlay_id_holder
3719 .borrow()
3720 .expect("overlay id should be captured");
3721 let scrolled_underlay_origin =
3722 find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
3723 let scrolled_overlay_origin =
3724 find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
3725 assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
3726
3727 let alpha = alpha_holder
3728 .borrow()
3729 .as_ref()
3730 .copied()
3731 .expect("alpha state should be captured");
3732 alpha.set_value(0.35);
3733
3734 let handle = composition.runtime_handle();
3735 let mut applier = composition.applier_mut();
3736 applier.set_runtime_handle(handle);
3737 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
3738 applier.clear_runtime_handle();
3739
3740 assert!(
3741 report.applied(),
3742 "dirty overlay graph update should apply, got {:?}",
3743 report.update
3744 );
3745 let updated_underlay_origin =
3746 find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
3747 let updated_overlay_origin =
3748 find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
3749 assert_eq!(
3750 updated_underlay_origin, scrolled_underlay_origin,
3751 "stable underlay must keep its scrolled origin"
3752 );
3753 assert_eq!(
3754 updated_overlay_origin, updated_underlay_origin,
3755 "dirty overlay graphics layer must stay aligned with its stable underlay"
3756 );
3757 }
3758
3759 #[test]
3760 fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
3761 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
3762 Rc::new(RefCell::new(None));
3763 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3764 let offset_holder_for_comp = offset_holder.clone();
3765 let node_id_holder_for_comp = node_id_holder.clone();
3766
3767 let mut composition = cranpose_ui::run_test_composition(move || {
3768 let offset = cranpose_core::rememberMutableStateOf(|| 0.0f32);
3769 *offset_holder_for_comp.borrow_mut() = Some(offset);
3770 let node_id = cranpose_ui::Box(
3771 Modifier::empty()
3772 .size_points(40.0, 20.0)
3773 .graphics_layer(move || GraphicsLayer {
3774 translation_x: offset.get(),
3775 ..GraphicsLayer::default()
3776 }),
3777 cranpose_ui::BoxSpec::default(),
3778 || {},
3779 );
3780 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
3781 });
3782
3783 let root = composition.root().expect("composition root");
3784 let viewport = Size {
3785 width: 120.0,
3786 height: 80.0,
3787 };
3788 let handle = composition.runtime_handle();
3789 let mut applier = composition.applier_mut();
3790 applier.set_runtime_handle(handle);
3791 applier
3792 .compute_layout(root, viewport)
3793 .expect("initial layout");
3794 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3795 let node_id = node_id_holder
3796 .borrow()
3797 .expect("graphics layer node id should be captured");
3798 let initial_origin = find_layer_by_node_id(&graph.root, node_id)
3799 .expect("initial graphics layer")
3800 .transform_to_parent
3801 .map_point(Point::default());
3802 applier.clear_runtime_handle();
3803 drop(applier);
3804
3805 let offset = offset_holder
3806 .borrow()
3807 .as_ref()
3808 .copied()
3809 .expect("offset state should be captured");
3810 offset.set_value(32.0);
3811
3812 let handle = composition.runtime_handle();
3813 let mut applier = composition.applier_mut();
3814 applier.set_runtime_handle(handle);
3815 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
3816 assert!(
3817 report.applied(),
3818 "dirty graphics layer should be replaceable from retained applier state, got {:?}",
3819 report.update
3820 );
3821 assert!(
3822 !report.hit_graph_dirty,
3823 "a moved visual-only layer should not force hit graph refresh"
3824 );
3825 applier.clear_runtime_handle();
3826
3827 let updated_origin = find_layer_by_node_id(&graph.root, node_id)
3828 .expect("updated graphics layer")
3829 .transform_to_parent
3830 .map_point(Point::default());
3831 assert!(
3832 (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
3833 "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
3834 );
3835 }
3836
3837 #[test]
3838 fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
3839 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
3840 Rc::new(RefCell::new(None));
3841 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3842 let offset_holder_for_comp = offset_holder.clone();
3843 let node_id_holder_for_comp = node_id_holder.clone();
3844
3845 let mut composition = cranpose_ui::run_test_composition(move || {
3846 let offset = cranpose_core::rememberMutableStateOf(|| 0.0f32);
3847 *offset_holder_for_comp.borrow_mut() = Some(offset);
3848 let node_id = cranpose_ui::Box(
3849 Modifier::empty()
3850 .size_points(40.0, 20.0)
3851 .graphics_layer(move || GraphicsLayer {
3852 translation_x: offset.get(),
3853 ..GraphicsLayer::default()
3854 })
3855 .clickable(|_| {}),
3856 cranpose_ui::BoxSpec::default(),
3857 || {},
3858 );
3859 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
3860 });
3861
3862 let root = composition.root().expect("composition root");
3863 let viewport = Size {
3864 width: 120.0,
3865 height: 80.0,
3866 };
3867 let handle = composition.runtime_handle();
3868 let mut applier = composition.applier_mut();
3869 applier.set_runtime_handle(handle);
3870 applier
3871 .compute_layout(root, viewport)
3872 .expect("initial layout");
3873 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3874 let node_id = node_id_holder
3875 .borrow()
3876 .expect("graphics layer node id should be captured");
3877 applier.clear_runtime_handle();
3878 drop(applier);
3879
3880 let offset = offset_holder
3881 .borrow()
3882 .as_ref()
3883 .copied()
3884 .expect("offset state should be captured");
3885 offset.set_value(32.0);
3886
3887 let handle = composition.runtime_handle();
3888 let mut applier = composition.applier_mut();
3889 applier.set_runtime_handle(handle);
3890 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
3891 applier.clear_runtime_handle();
3892
3893 assert!(
3894 report.applied(),
3895 "dirty clickable graphics layer should be replaceable from retained applier state, got {:?}",
3896 report.update
3897 );
3898 assert!(
3899 report.hit_graph_dirty,
3900 "moved clickable layers must refresh hit geometry"
3901 );
3902 }
3903
3904 #[test]
3905 fn overlay_draw_commands_are_tagged_after_children() {
3906 let child = BuildNodeSnapshot {
3907 node_id: 2,
3908 placement: Point { x: 4.0, y: 5.0 },
3909 size: Size {
3910 width: 20.0,
3911 height: 10.0,
3912 },
3913 ..Default::default()
3914 };
3915 let behind = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
3916 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
3917 rect: Rect {
3918 x: 1.0,
3919 y: 2.0,
3920 width: 8.0,
3921 height: 6.0,
3922 },
3923 brush: Brush::solid(Color::WHITE),
3924 stroke: None,
3925 }]);
3926 }));
3927 let overlay = DrawCommand::Overlay(Rc::new(|scope: &mut DrawScopeDefault| {
3928 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
3929 rect: Rect {
3930 x: 3.0,
3931 y: 1.0,
3932 width: 5.0,
3933 height: 4.0,
3934 },
3935 brush: Brush::solid(Color::BLACK),
3936 stroke: None,
3937 }]);
3938 }));
3939
3940 let parent = BuildNodeSnapshot {
3941 node_id: 1,
3942 size: Size {
3943 width: 80.0,
3944 height: 50.0,
3945 },
3946 draw_commands: vec![behind, overlay],
3947 children: vec![child],
3948 ..Default::default()
3949 };
3950
3951 let graph = build_layer_node_for_test(parent, 1.0, false);
3952 let RenderNode::DrawRun(behind) = &graph.children[0] else {
3953 panic!("expected before-children draw run");
3954 };
3955 let RenderNode::Layer(_) = &graph.children[1] else {
3956 panic!("expected child layer");
3957 };
3958 let RenderNode::DrawRun(overlay) = &graph.children[2] else {
3959 panic!("expected after-children draw run");
3960 };
3961
3962 assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
3963 assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
3964 }
3965
3966 #[test]
3967 fn command_recordings_reuse_buffers_across_rebuilds() {
3968 let snapshot = || BuildNodeSnapshot {
3969 node_id: 7001,
3970 size: Size {
3971 width: 40.0,
3972 height: 20.0,
3973 },
3974 draw_commands: vec![DrawCommand::Behind(Rc::new(
3975 |scope: &mut DrawScopeDefault| {
3976 scope.draw_rect_at(
3977 Rect {
3978 x: 1.0,
3979 y: 2.0,
3980 width: 8.0,
3981 height: 6.0,
3982 },
3983 Brush::solid(Color::WHITE),
3984 );
3985 },
3986 ))],
3987 ..Default::default()
3988 };
3989 fn run_of(layer: &LayerNode) -> &DrawRunNode {
3990 let RenderNode::DrawRun(run) = &layer.children[0] else {
3991 panic!("expected draw run");
3992 };
3993 run
3994 }
3995
3996 let graph_a = build_layer_node_for_test(snapshot(), 1.0, false);
3997 let ptr_a = run_of(&graph_a).recording.shapes().bodies().as_ptr();
3998
3999 let graph_b = build_layer_node_for_test(snapshot(), 1.0, false);
4000 let ptr_b = run_of(&graph_b).recording.shapes().bodies().as_ptr();
4001 assert_ne!(
4002 ptr_a, ptr_b,
4003 "a buffer a live graph shares must never be recorded into"
4004 );
4005 assert_eq!(
4006 run_of(&graph_a).recording,
4007 run_of(&graph_b).recording,
4008 "re-recording must reproduce the recording"
4009 );
4010
4011 drop(graph_a);
4012 let graph_c = build_layer_node_for_test(snapshot(), 1.0, false);
4013 assert_eq!(
4014 run_of(&graph_c).recording.shapes().bodies().as_ptr(),
4015 ptr_a,
4016 "the released buffer must be reused for the next recording"
4017 );
4018
4019 let held = std::rc::Rc::clone(&run_of(&graph_c).recording);
4020 drop(graph_c);
4021 let graph_d = build_layer_node_for_test(snapshot(), 1.0, false);
4022 let ptr_d = run_of(&graph_d).recording.shapes().bodies().as_ptr();
4023 assert_ne!(ptr_d, held.shapes().bodies().as_ptr());
4024 assert_ne!(ptr_d, run_of(&graph_b).recording.shapes().bodies().as_ptr());
4025 }
4026
4027 #[test]
4028 fn stored_content_hash_changes_when_child_transform_changes() {
4029 let child = BuildNodeSnapshot {
4030 node_id: 2,
4031 placement: Point { x: 4.0, y: 5.0 },
4032 size: Size {
4033 width: 20.0,
4034 height: 10.0,
4035 },
4036 ..Default::default()
4037 };
4038 let mut moved_child = child.clone();
4039 moved_child.placement.x += 7.0;
4040
4041 let parent = BuildNodeSnapshot {
4042 node_id: 1,
4043 size: Size {
4044 width: 80.0,
4045 height: 50.0,
4046 },
4047 children: vec![child],
4048 ..Default::default()
4049 };
4050 let moved_parent = BuildNodeSnapshot {
4051 children: vec![moved_child],
4052 ..parent.clone()
4053 };
4054
4055 let static_graph = build_layer_node_for_test(parent, 1.0, false);
4056 let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
4057
4058 assert_ne!(
4059 static_graph.target_content_hash(),
4060 moved_graph.target_content_hash(),
4061 "moving a child within the parent must invalidate the parent subtree hash"
4062 );
4063 }
4064
4065 #[test]
4066 fn stored_effect_hash_tracks_local_effect_only() {
4067 let base = BuildNodeSnapshot {
4068 node_id: 1,
4069 size: Size {
4070 width: 80.0,
4071 height: 50.0,
4072 },
4073 ..Default::default()
4074 };
4075 let mut effected = base.clone();
4076 effected.graphics_layer = Some(GraphicsLayer {
4077 render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
4078 ..GraphicsLayer::default()
4079 });
4080
4081 let base_graph = build_layer_node_for_test(base, 1.0, false);
4082 let effected_graph = build_layer_node_for_test(effected, 1.0, false);
4083
4084 assert_eq!(
4085 base_graph.target_content_hash(),
4086 effected_graph.target_content_hash(),
4087 "post-processing effect parameters belong to the effect hash, not the content hash"
4088 );
4089 assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
4090 }
4091
4092 #[test]
4093 fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
4094 let mut text_style = TextStyle::default();
4095 text_style.paragraph_style.text_align = TextAlign::Start;
4096 text_style.paragraph_style.text_direction = TextDirection::Rtl;
4097 text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
4098
4099 let snapshot = BuildNodeSnapshot {
4100 node_id: 1,
4101 size: Size {
4102 width: 180.0,
4103 height: 48.0,
4104 },
4105 measured_max_width: Some(180.0),
4106 annotated_text: Some(AnnotatedString::from("rtl")),
4107 text_style: Some(text_style),
4108 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
4109 overflow: cranpose_ui::TextOverflow::Clip,
4110 ..Default::default()
4111 }),
4112 ..Default::default()
4113 };
4114
4115 let graph = build_layer_node_for_test(snapshot, 1.0, false);
4116 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
4117 panic!("expected text primitive");
4118 };
4119 let PrimitiveNode::Text(text) = &text_primitive.node else {
4120 panic!("expected text primitive");
4121 };
4122 let clip = text
4123 .clip
4124 .expect("clipped overflow should produce a clip rect");
4125
4126 assert!(
4127 text.rect.x > 0.0,
4128 "RTL start alignment should shift the text rect within the available width"
4129 );
4130 assert!(
4131 clip.y < text.rect.y,
4132 "baseline shift must expand the clip upward so superscript glyphs are preserved"
4133 );
4134 assert!(
4135 clip.intersect(text.rect).is_some(),
4136 "the clip rect must intersect the shifted text draw rect"
4137 );
4138 }
4139
4140 #[test]
4141 fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
4142 let snapshot = BuildNodeSnapshot {
4143 node_id: 1,
4144 size: Size {
4145 width: 320.0,
4146 height: 48.0,
4147 },
4148 measured_max_width: Some(320.0),
4149 annotated_text: Some(AnnotatedString::from("short")),
4150 text_style: Some(TextStyle::default()),
4151 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
4152 overflow: cranpose_ui::TextOverflow::Clip,
4153 ..Default::default()
4154 }),
4155 ..Default::default()
4156 };
4157
4158 let graph = build_layer_node_for_test(snapshot, 1.0, false);
4159 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
4160 panic!("expected text primitive");
4161 };
4162 let PrimitiveNode::Text(text) = &text_primitive.node else {
4163 panic!("expected text primitive");
4164 };
4165 let clip = text.clip.expect("clipped text should keep a clip rect");
4166
4167 assert!(
4168 text.rect.width < 320.0,
4169 "text raster bounds should track measured glyph width instead of full content width"
4170 );
4171 assert_eq!(
4172 clip.width, 322.0,
4173 "text clip should still preserve the full content box plus clip padding"
4174 );
4175 }
4176
4177 #[test]
4178 fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
4179 let pan_offset = 25.0_f32;
4180 let field_width = 80.0_f32;
4181 let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
4182 let viewports = resolved_viewports.clone();
4183 let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
4184 node_id: 1,
4185 size: Size {
4186 width: field_width,
4187 height: 24.0,
4188 },
4189 measured_max_width: Some(field_width),
4190 annotated_text: Some(AnnotatedString::from(
4191 "a very long single line of text that cannot fit",
4192 )),
4193 text_style: Some(TextStyle::default()),
4194 text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
4195 text_pan,
4196 ..Default::default()
4197 };
4198
4199 let text_node = |snapshot: BuildNodeSnapshot| {
4200 let graph = build_layer_node_for_test(snapshot, 1.0, false);
4201 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
4202 panic!("expected text primitive");
4203 };
4204 let PrimitiveNode::Text(text) = &text_primitive.node else {
4205 panic!("expected text primitive");
4206 };
4207 (**text).clone()
4208 };
4209
4210 let unpanned = text_node(make_snapshot(None));
4211 let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
4212 viewports.borrow_mut().push(viewport);
4213 pan_offset
4214 }))));
4215
4216 assert_eq!(
4217 resolved_viewports.borrow().as_slice(),
4218 &[field_width],
4219 "the pan resolver must receive the content viewport width"
4220 );
4221 assert_eq!(
4222 panned.rect.x, -pan_offset,
4223 "text glyphs must shift left by the pan offset"
4224 );
4225 assert!(
4226 panned.rect.width > field_width,
4227 "panned single-line text must be laid out unconstrained, got {}",
4228 panned.rect.width
4229 );
4230 assert!(
4231 panned.rect.width >= unpanned.rect.width,
4232 "unconstrained layout must not be narrower than wrapped layout"
4233 );
4234 assert!(
4235 panned.rect.height <= unpanned.rect.height,
4236 "single-line layout must not wrap onto extra lines"
4237 );
4238 let clip = panned
4239 .clip
4240 .expect("panned text field must clip to field bounds");
4241 assert!(
4242 clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
4243 "clip must not extend past the field bounds, got {clip:?}"
4244 );
4245 }
4246
4247 #[test]
4248 fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
4249 let child = BuildNodeSnapshot {
4250 node_id: 2,
4251 placement: Point { x: 11.0, y: 7.0 },
4252 size: Size {
4253 width: 120.0,
4254 height: 32.0,
4255 },
4256 measured_max_width: Some(120.0),
4257 annotated_text: Some(AnnotatedString::from("scrolling")),
4258 text_style: Some(TextStyle::default()),
4259 ..Default::default()
4260 };
4261 let parent = BuildNodeSnapshot {
4262 node_id: 1,
4263 size: Size {
4264 width: 160.0,
4265 height: 64.0,
4266 },
4267 content_offset: Point { x: 0.0, y: -18.5 },
4268 translated_content_context: true,
4269 children: vec![child],
4270 ..Default::default()
4271 };
4272
4273 let graph = build_layer_node_for_test(parent, 1.0, false);
4274 let RenderNode::Layer(child_layer) = &graph.children[0] else {
4275 panic!("expected child layer");
4276 };
4277 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4278 panic!("expected text primitive");
4279 };
4280 let PrimitiveNode::Text(text) = &text_primitive.node else {
4281 panic!("expected text primitive");
4282 };
4283
4284 assert_eq!(text.text_style.paragraph_style.text_motion, None);
4285 assert!(!child_layer.motion_context_animated);
4286 }
4287
4288 #[test]
4289 fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
4290 let child = BuildNodeSnapshot {
4291 node_id: 2,
4292 placement: Point { x: 11.0, y: 7.0 },
4293 size: Size {
4294 width: 120.0,
4295 height: 32.0,
4296 },
4297 measured_max_width: Some(120.0),
4298 annotated_text: Some(AnnotatedString::from("scrolling")),
4299 text_style: Some(TextStyle::default()),
4300 ..Default::default()
4301 };
4302 let parent = BuildNodeSnapshot {
4303 node_id: 1,
4304 size: Size {
4305 width: 160.0,
4306 height: 64.0,
4307 },
4308 content_offset: Point { x: 0.0, y: -18.0 },
4309 children: vec![child],
4310 ..Default::default()
4311 };
4312
4313 let graph = build_layer_node_for_test(parent, 1.0, false);
4314 let RenderNode::Layer(child_layer) = &graph.children[0] else {
4315 panic!("expected child layer");
4316 };
4317 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4318 panic!("expected text primitive");
4319 };
4320 let PrimitiveNode::Text(text) = &text_primitive.node else {
4321 panic!("expected text primitive");
4322 };
4323
4324 assert_eq!(
4325 text.text_style.paragraph_style.text_motion, None,
4326 "content_offset alone must not force text onto the translated-content motion path"
4327 );
4328 assert!(!child_layer.motion_context_animated);
4329 }
4330
4331 #[test]
4332 fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
4333 let child = BuildNodeSnapshot {
4334 node_id: 2,
4335 placement: Point { x: 11.0, y: 7.0 },
4336 size: Size {
4337 width: 120.0,
4338 height: 32.0,
4339 },
4340 measured_max_width: Some(120.0),
4341 annotated_text: Some(AnnotatedString::from("shadow")),
4342 text_style: Some(TextStyle::from_span_style(SpanStyle {
4343 shadow: Some(cranpose_ui::text::Shadow {
4344 color: Color::BLACK,
4345 offset: Point::new(1.0, 2.0),
4346 blur_radius: 3.0,
4347 }),
4348 ..SpanStyle::default()
4349 })),
4350 ..Default::default()
4351 };
4352 let parent = BuildNodeSnapshot {
4353 node_id: 1,
4354 size: Size {
4355 width: 160.0,
4356 height: 64.0,
4357 },
4358 content_offset: Point { x: 0.0, y: -18.5 },
4359 translated_content_context: true,
4360 children: vec![child],
4361 ..Default::default()
4362 };
4363
4364 let graph = build_layer_node_for_test(parent, 1.0, false);
4365 let RenderNode::Layer(child_layer) = &graph.children[0] else {
4366 panic!("expected child layer");
4367 };
4368 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4369 panic!("expected text primitive");
4370 };
4371 let PrimitiveNode::Text(text) = &text_primitive.node else {
4372 panic!("expected text primitive");
4373 };
4374
4375 assert_eq!(text.text_style.paragraph_style.text_motion, None);
4376 }
4377
4378 #[test]
4379 fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
4380 let child = BuildNodeSnapshot {
4381 node_id: 2,
4382 placement: Point { x: 11.0, y: 7.0 },
4383 size: Size {
4384 width: 120.0,
4385 height: 32.0,
4386 },
4387 measured_max_width: Some(120.0),
4388 annotated_text: Some(AnnotatedString::from("lazy")),
4389 text_style: Some(TextStyle::default()),
4390 ..Default::default()
4391 };
4392 let parent = BuildNodeSnapshot {
4393 node_id: 1,
4394 size: Size {
4395 width: 160.0,
4396 height: 64.0,
4397 },
4398 motion_context_animated: true,
4399 children: vec![child],
4400 ..Default::default()
4401 };
4402
4403 let graph = build_layer_node_for_test(parent, 1.0, false);
4404 let RenderNode::Layer(child_layer) = &graph.children[0] else {
4405 panic!("expected child layer");
4406 };
4407 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4408 panic!("expected text primitive");
4409 };
4410 let PrimitiveNode::Text(text) = &text_primitive.node else {
4411 panic!("expected text primitive");
4412 };
4413
4414 assert_eq!(text.text_style.paragraph_style.text_motion, None);
4415 assert!(graph.motion_context_animated);
4416 assert!(child_layer.motion_context_animated);
4417 }
4418
4419 #[test]
4420 fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
4421 let mut composition = cranpose_ui::run_test_composition(|| {
4422 let list_state = rememberLazyListState();
4423 LazyColumn(
4424 Modifier::empty(),
4425 list_state,
4426 LazyColumnSpec::default(),
4427 |scope| {
4428 scope.item_keyed(Some(0), None, || {
4429 Text("LazyMotion", Modifier::empty(), TextStyle::default());
4430 });
4431 },
4432 );
4433 });
4434
4435 let root = composition.root().expect("lazy column root");
4436 let handle = composition.runtime_handle();
4437 let mut applier = composition.applier_mut();
4438 applier.set_runtime_handle(handle);
4439 let _ = applier
4440 .compute_layout(
4441 root,
4442 Size {
4443 width: 240.0,
4444 height: 240.0,
4445 },
4446 )
4447 .expect("lazy column layout");
4448 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4449 applier.clear_runtime_handle();
4450
4451 assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
4452 }
4453
4454 #[test]
4455 fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
4456 use std::{cell::RefCell, rc::Rc};
4457
4458 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
4459 let state_holder_for_comp = state_holder.clone();
4460 let mut composition = cranpose_ui::run_test_composition(move || {
4461 let list_state = rememberLazyListState();
4462 *state_holder_for_comp.borrow_mut() = Some(list_state);
4463 LazyColumn(
4464 Modifier::empty().height(120.0),
4465 list_state,
4466 LazyColumnSpec::default(),
4467 |scope| {
4468 scope.items(8, |index| {
4469 Text(
4470 format!("LazyMotion {index}"),
4471 Modifier::empty().padding(4.0),
4472 TextStyle::default(),
4473 );
4474 });
4475 },
4476 );
4477 });
4478
4479 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
4480 list_state.scroll_to_item(3, 0.0);
4481
4482 let root = composition.root().expect("lazy column root");
4483 let handle = composition.runtime_handle();
4484 let mut applier = composition.applier_mut();
4485 applier.set_runtime_handle(handle);
4486 let _ = applier
4487 .compute_layout(
4488 root,
4489 Size {
4490 width: 240.0,
4491 height: 240.0,
4492 },
4493 )
4494 .expect("lazy column layout");
4495 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4496 let active_children = applier
4497 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
4498 .expect("lazy column should be subcompose");
4499 let child_debug: Vec<String> = active_children
4500 .iter()
4501 .map(|&child_id| {
4502 if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
4503 format!(
4504 "layout#{child_id} placed={} text={:?} children={:?}",
4505 node.layout_state().is_placed(),
4506 node.modifier_slices_snapshot()
4507 .text_content()
4508 .map(str::to_string),
4509 node.children.clone()
4510 )
4511 }) {
4512 summary
4513 } else if let Ok(summary) =
4514 applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
4515 format!(
4516 "subcompose#{child_id} placed={} active_children={:?}",
4517 node.layout_state().is_placed(),
4518 node.active_children()
4519 )
4520 })
4521 {
4522 summary
4523 } else {
4524 format!("missing#{child_id}")
4525 }
4526 })
4527 .collect();
4528 applier.clear_runtime_handle();
4529
4530 let first_index = list_state.first_visible_item_index();
4531 assert!(
4532 first_index > 0,
4533 "lazy list should move away from origin before graph building, observed first_index={first_index}"
4534 );
4535 let mut labels = Vec::new();
4536 collect_text_labels(&graph.root, &mut labels);
4537 assert_eq!(
4538 find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
4539 Some(None),
4540 "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
4541 labels,
4542 active_children,
4543 child_debug
4544 );
4545 }
4546
4547 #[test]
4548 fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
4549 use std::{cell::RefCell, rc::Rc};
4550
4551 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
4552 let state_holder_for_comp = state_holder.clone();
4553 let mut composition = cranpose_ui::run_test_composition(move || {
4554 let list_state = rememberLazyListState();
4555 *state_holder_for_comp.borrow_mut() = Some(list_state);
4556 let mut spec =
4557 LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
4558 spec.beyond_bounds_item_count = 0;
4559 LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
4560 scope.items(12, |index| {
4561 Text(
4562 format!("WarmRow {index}"),
4563 Modifier::empty().height(32.0),
4564 TextStyle::default(),
4565 );
4566 });
4567 });
4568 });
4569
4570 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
4571 list_state.scroll_to_item(4, 0.0);
4572
4573 let root = composition.root().expect("lazy column root");
4574 let handle = composition.runtime_handle();
4575 let mut applier = composition.applier_mut();
4576 applier.set_runtime_handle(handle);
4577 let _ = applier
4578 .compute_layout(
4579 root,
4580 Size {
4581 width: 240.0,
4582 height: 240.0,
4583 },
4584 )
4585 .expect("lazy column layout");
4586 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4587 let active_children = applier
4588 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
4589 .expect("lazy column should be subcompose");
4590 applier.clear_runtime_handle();
4591
4592 let visible_indices: Vec<_> = list_state
4593 .layout_info()
4594 .visible_items_info
4595 .iter()
4596 .map(|item| item.index)
4597 .collect();
4598 let mut labels = Vec::new();
4599 collect_text_labels(&graph.root, &mut labels);
4600
4601 assert_eq!(
4602 visible_indices,
4603 vec![4, 5, 6],
4604 "test setup expects exactly three viewport-visible rows"
4605 );
4606 assert!(
4607 labels.iter().any(|label| label == "WarmRow 7"),
4608 "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
4609 );
4610 }
4611
4612 #[test]
4613 fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
4614 use std::{cell::RefCell, rc::Rc};
4615
4616 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
4617 let state_holder_for_comp = state_holder.clone();
4618 let mut composition = cranpose_ui::run_test_composition(move || {
4619 let list_state = rememberLazyListState();
4620 *state_holder_for_comp.borrow_mut() = Some(list_state);
4621 LazyColumn(
4622 Modifier::empty().height(120.0),
4623 list_state,
4624 LazyColumnSpec::default(),
4625 |scope| {
4626 scope.items(8, |index| {
4627 Text(
4628 format!("LazySnap {index}"),
4629 Modifier::empty().padding(4.0),
4630 TextStyle::default(),
4631 );
4632 });
4633 },
4634 );
4635 });
4636
4637 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
4638 list_state.scroll_to_item(2, 7.5);
4639
4640 let root = composition.root().expect("lazy column root");
4641 let handle = composition.runtime_handle();
4642 let mut applier = composition.applier_mut();
4643 applier.set_runtime_handle(handle);
4644 let _ = applier
4645 .compute_layout(
4646 root,
4647 Size {
4648 width: 240.0,
4649 height: 240.0,
4650 },
4651 )
4652 .expect("lazy column layout");
4653 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4654 applier.clear_runtime_handle();
4655
4656 let layout_info = list_state.layout_info();
4657 let first_visible_offset = layout_info
4658 .visible_items_info
4659 .first()
4660 .expect("lazy layout should expose visible item info")
4661 .offset;
4662 let snap_offset = find_translated_content_offset(&graph.root)
4663 .expect("lazy list graph should include translated content context");
4664
4665 assert!(
4666 (snap_offset.y - first_visible_offset).abs() <= 0.001,
4667 "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
4668 );
4669 }
4670
4671 #[test]
4672 fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
4673 let child = BuildNodeSnapshot {
4674 node_id: 2,
4675 placement: Point { x: 11.0, y: 7.0 },
4676 size: Size {
4677 width: 120.0,
4678 height: 32.0,
4679 },
4680 measured_max_width: Some(120.0),
4681 annotated_text: Some(AnnotatedString::from("static")),
4682 text_style: Some(TextStyle::from_paragraph_style(
4683 cranpose_ui::text::ParagraphStyle {
4684 text_motion: Some(TextMotion::Static),
4685 ..Default::default()
4686 },
4687 )),
4688 ..Default::default()
4689 };
4690 let parent = BuildNodeSnapshot {
4691 node_id: 1,
4692 size: Size {
4693 width: 160.0,
4694 height: 64.0,
4695 },
4696 content_offset: Point { x: 0.0, y: -18.5 },
4697 translated_content_context: true,
4698 children: vec![child],
4699 ..Default::default()
4700 };
4701
4702 let graph = build_layer_node_for_test(parent, 1.0, false);
4703 let RenderNode::Layer(child_layer) = &graph.children[0] else {
4704 panic!("expected child layer");
4705 };
4706 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4707 panic!("expected text primitive");
4708 };
4709 let PrimitiveNode::Text(text) = &text_primitive.node else {
4710 panic!("expected text primitive");
4711 };
4712
4713 assert_eq!(
4714 text.text_style.paragraph_style.text_motion,
4715 Some(TextMotion::Static),
4716 "explicit text motion must win over inherited scrolling motion context"
4717 );
4718 }
4719
4720 #[test]
4721 fn wrapped_paragraph_paints_the_height_it_measured() {
4722 const BODY: &str = "fed back картица scored fp32 износ once paper fed Vision dropped \
4723 fed widest the strip mask prompt mask threshold Vision on датум instance mask \
4724 износ Apple";
4725 const FOLLOWING: &str = "FOLLOWING SIBLING";
4726
4727 let app_context = cranpose_ui::AppContext::new();
4728 app_context.enter(|| {
4729 cranpose_ui::text::set_text_measurer(
4730 crate::software_text_raster::SoftwareTextMeasurer::from_fonts_or_default(&[], 8192),
4731 );
4732 let mut composition = cranpose_ui::run_test_composition(move || {
4733 Column(
4734 Modifier::empty().fill_max_width(),
4735 ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
4736 move || {
4737 Text(BODY.to_string(), Modifier::empty(), TextStyle::default());
4738 Text(
4739 FOLLOWING.to_string(),
4740 Modifier::empty(),
4741 TextStyle::default(),
4742 );
4743 },
4744 );
4745 });
4746
4747 let root = composition.root().expect("composition root");
4748 let handle = composition.runtime_handle();
4749 let mut applier = composition.applier_mut();
4750 applier.set_runtime_handle(handle);
4751 let layout = applier
4752 .compute_layout(
4753 root,
4754 Size {
4755 width: 245.0,
4756 height: 900.0,
4757 },
4758 )
4759 .expect("layout");
4760
4761 fn find_box<'a>(node: &'a LayoutBox, value: &str) -> Option<&'a LayoutBox> {
4762 if node
4763 .node_data
4764 .modifier_slices()
4765 .text_content()
4766 .is_some_and(|text| text == value)
4767 {
4768 return Some(node);
4769 }
4770 node.children
4771 .iter()
4772 .find_map(|child| find_box(child, value))
4773 }
4774 let body_box = find_box(layout.root(), BODY).expect("measured paragraph box");
4775 let following_box = find_box(layout.root(), FOLLOWING).expect("measured sibling box");
4776 let measured_height = body_box.rect.height;
4777 let following_top = following_box.rect.y;
4778 assert!(
4779 measured_height > 60.0,
4780 "test setup expects a genuinely multi-line paragraph, got {measured_height}"
4781 );
4782 assert!(
4783 body_box.rect.width < 245.0,
4784 "test setup expects the node to be placed at its own measured width, \
4785 not the full constraint, got {}",
4786 body_box.rect.width
4787 );
4788
4789 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("render graph");
4790 applier.clear_runtime_handle();
4791
4792 fn squashed(value: &str) -> String {
4793 value.chars().filter(|c| !c.is_whitespace()).collect()
4794 }
4795 fn find_text<'a>(layer: &'a LayerNode, value: &str) -> Option<&'a TextPrimitiveNode> {
4796 for child in &layer.children {
4797 match child {
4798 RenderNode::Primitive(primitive) => {
4799 if let PrimitiveNode::Text(text) = &primitive.node
4800 && squashed(&text.text.text) == squashed(value)
4801 {
4802 return Some(text);
4803 }
4804 }
4805 RenderNode::Layer(child_layer) => {
4806 if let Some(found) = find_text(child_layer, value) {
4807 return Some(found);
4808 }
4809 }
4810 RenderNode::DrawRun(_) => {}
4811 }
4812 }
4813 None
4814 }
4815 let painted = find_text(&graph.root, BODY).expect("painted paragraph");
4816
4817 assert!(
4818 (painted.rect.height - measured_height).abs() < 0.5,
4819 "paragraph painted {:.2} tall into a box layout measured at {:.2} \
4820 (painted rect {:?})",
4821 painted.rect.height,
4822 measured_height,
4823 painted.rect
4824 );
4825 assert!(
4826 painted.rect.y + painted.rect.height <= following_top + 0.5,
4827 "painted paragraph bottom {:.2} runs past the following sibling placed at \
4828 {:.2}",
4829 painted.rect.y + painted.rect.height,
4830 following_top
4831 );
4832 });
4833 }
4834}