1use std::collections::HashSet;
2use std::rc::Rc;
3
4use cranpose_core::{MemoryApplier, NodeId};
5use cranpose_ui::text::AnnotatedString;
6use cranpose_ui::text::{resolve_text_direction, TextAlign, TextStyle};
7use cranpose_ui::{
8 prepare_text_layout, DrawCommand, LayoutBox, LayoutNode, ModifierNodeSlices, Point, Rect,
9 ResolvedModifiers, Size, SubcomposeLayoutNode, TextLayoutOptions, TextOverflow,
10 TextPanResolver,
11};
12use cranpose_ui_graphics::{
13 rounded_corner_alpha_mask_effect, CompositingStrategy, GraphicsLayer, LayerShape,
14 RoundedCornerShape,
15};
16
17use crate::graph::{
18 CachePolicy, DrawPrimitiveNode, HitTestNode, IsolationReasons, LayerNode, PrimitiveEntry,
19 PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode, TextPrimitiveNode,
20};
21use crate::layer_transform::layer_transform_to_parent;
22use crate::raster_cache::LayerRasterCacheHashes;
23use crate::style_shared::{primitives_for_placement, DrawPlacement};
24
25const TEXT_CLIP_PAD: f32 = 1.0;
26const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
27
28#[derive(Clone)]
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 measured_max_width: Option<f32>,
37 resolved_modifiers: ResolvedModifiers,
38 draw_commands: Vec<DrawCommand>,
39 click_actions: Vec<Rc<dyn Fn(Point)>>,
40 pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
41 clip_to_bounds: bool,
42 annotated_text: Option<AnnotatedString>,
43 text_style: Option<TextStyle>,
44 text_layout_options: Option<TextLayoutOptions>,
45 text_pan: Option<TextPanResolver>,
46 graphics_layer: Option<GraphicsLayer>,
47 children: Vec<Self>,
48}
49
50struct SnapshotNodeData {
51 layout_state: cranpose_ui::widgets::LayoutState,
52 modifier_slices: Rc<ModifierNodeSlices>,
53 resolved_modifiers: ResolvedModifiers,
54 children: Vec<NodeId>,
55}
56
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
58pub struct GraphUpdateReport {
59 pub applied: bool,
60 pub hit_graph_dirty: bool,
61}
62
63pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
64 let root_snapshot = layout_box_to_snapshot(root, None);
65 RenderGraph {
66 root: build_layer_node(root_snapshot, scale, false),
67 }
68}
69
70pub fn build_graph_from_applier(
71 applier: &mut MemoryApplier,
72 root: NodeId,
73 scale: f32,
74) -> Option<RenderGraph> {
75 Some(RenderGraph {
76 root: build_layer_node_from_applier(applier, root, scale, false)?,
77 })
78}
79
80pub fn update_graph_from_applier(
81 applier: &mut MemoryApplier,
82 graph: &mut RenderGraph,
83 dirty_nodes: &[NodeId],
84 scale: f32,
85) -> bool {
86 update_graph_from_applier_report(applier, graph, dirty_nodes, scale).applied
87}
88
89pub fn update_graph_from_applier_report(
90 applier: &mut MemoryApplier,
91 graph: &mut RenderGraph,
92 dirty_nodes: &[NodeId],
93 scale: f32,
94) -> GraphUpdateReport {
95 if dirty_nodes.is_empty() {
96 return GraphUpdateReport {
97 applied: true,
98 hit_graph_dirty: false,
99 };
100 }
101
102 if std::env::var_os("CRANPOSE_SCENE_UPDATE_DIAG").is_some() {
103 eprintln!("[scene-update-diag] dirty={dirty_nodes:?}");
104 }
105
106 let mut remaining_dirty_nodes = dirty_nodes.iter().copied().collect::<HashSet<_>>();
107 if let Some(root_id) = graph.root.node_id {
108 if remaining_dirty_nodes.contains(&root_id) {
109 let Some(root) = build_layer_node_from_applier(applier, root_id, scale, false) else {
110 return GraphUpdateReport {
111 applied: false,
112 hit_graph_dirty: true,
113 };
114 };
115 let hit_graph_dirty = layer_hit_graph_state_dirty(&graph.root, &root);
116 graph.root = root;
117 graph.root.recompute_raster_cache_hashes();
118 return GraphUpdateReport {
119 applied: true,
120 hit_graph_dirty,
121 };
122 }
123 }
124
125 let inherited_translated_content_context = graph.root.translated_content_context;
126 let report = match replace_dirty_layers_from_applier(
127 applier,
128 &mut graph.root,
129 &mut remaining_dirty_nodes,
130 inherited_translated_content_context,
131 ) {
132 Some(report) => report,
133 None => {
134 return GraphUpdateReport {
135 applied: false,
136 hit_graph_dirty: true,
137 };
138 }
139 };
140
141 if !remaining_dirty_nodes.is_empty() {
142 return GraphUpdateReport {
143 applied: false,
144 hit_graph_dirty: true,
145 };
146 }
147
148 if report.updated {
149 graph.root.recompute_raster_cache_hashes();
150 }
151 GraphUpdateReport {
152 applied: true,
153 hit_graph_dirty: report.hit_graph_dirty,
154 }
155}
156
157#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
158struct ReplaceDirtyLayersReport {
159 updated: bool,
160 hit_graph_dirty: bool,
161}
162
163fn replace_dirty_layers_from_applier(
164 applier: &mut MemoryApplier,
165 parent: &mut LayerNode,
166 dirty_nodes: &mut HashSet<NodeId>,
167 inherited_translated_content_context: bool,
168) -> Option<ReplaceDirtyLayersReport> {
169 if dirty_nodes.is_empty() {
170 return Some(ReplaceDirtyLayersReport::default());
171 }
172
173 let child_inherited_translated_content_context =
174 inherited_translated_content_context || parent.translated_content_context;
175 let mut report = ReplaceDirtyLayersReport::default();
176
177 for child in &mut parent.children {
178 let RenderNode::Layer(child_layer) = child else {
179 continue;
180 };
181
182 if child_layer
183 .node_id
184 .is_some_and(|node_id| dirty_nodes.remove(&node_id))
185 {
186 let mut replacement = build_layer_node_from_applier_internal(
187 applier,
188 child_layer
189 .node_id
190 .expect("dirty layer must have a node id"),
191 parent.motion_context_animated,
192 child_inherited_translated_content_context,
193 Some(AbsOrigin {
199 content_origin: parent.scene_children_origin,
200 layer_translation: parent.scene_children_layer_translation,
201 }),
202 )?;
203 if parent.content_offset != Point::default() {
204 replacement.transform_to_parent =
205 replacement
206 .transform_to_parent
207 .then(ProjectiveTransform::translation(
208 parent.content_offset.x,
209 parent.content_offset.y,
210 ));
211 }
212 report.hit_graph_dirty |= layer_hit_graph_state_dirty(child_layer, &replacement);
213 remove_dirty_descendants(&replacement, dirty_nodes);
214 **child_layer = replacement;
215 report.updated = true;
216 continue;
217 }
218
219 let child_report = replace_dirty_layers_from_applier(
220 applier,
221 child_layer,
222 dirty_nodes,
223 child_inherited_translated_content_context,
224 )?;
225 report.updated |= child_report.updated;
226 report.hit_graph_dirty |= child_report.hit_graph_dirty;
227 }
228
229 if report.updated {
230 parent.has_hit_targets = parent.hit_test.is_some()
231 || parent.children.iter().any(|child| match child {
232 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
233 RenderNode::Primitive(_) => false,
234 });
235 }
236
237 Some(report)
238}
239
240fn layer_hit_graph_state_dirty(previous: &LayerNode, replacement: &LayerNode) -> bool {
241 if previous.hit_test.is_some() || replacement.hit_test.is_some() {
242 return true;
243 }
244
245 if !(previous.has_hit_targets || replacement.has_hit_targets) {
246 return false;
247 }
248
249 previous.has_hit_targets != replacement.has_hit_targets
250 || previous.local_bounds != replacement.local_bounds
251 || previous.transform_to_parent != replacement.transform_to_parent
252 || previous.clip_rect() != replacement.clip_rect()
253 || previous.graphics_layer.shape != replacement.graphics_layer.shape
254}
255
256fn remove_dirty_descendants(layer: &LayerNode, dirty_nodes: &mut HashSet<NodeId>) {
257 for child in &layer.children {
258 let RenderNode::Layer(child_layer) = child else {
259 continue;
260 };
261 if let Some(node_id) = child_layer.node_id {
262 dirty_nodes.remove(&node_id);
263 }
264 remove_dirty_descendants(child_layer, dirty_nodes);
265 }
266}
267
268fn build_layer_node(
269 snapshot: BuildNodeSnapshot,
270 _root_scale: f32,
271 inherited_motion_context_animated: bool,
272) -> LayerNode {
273 build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
274}
275
276fn build_layer_node_internal(
277 snapshot: BuildNodeSnapshot,
278 inherited_motion_context_animated: bool,
279 inherited_translated_content_context: bool,
280) -> LayerNode {
281 let BuildNodeSnapshot {
282 node_id,
283 placement,
284 size,
285 content_offset,
286 motion_context_animated,
287 translated_content_context,
288 measured_max_width,
289 resolved_modifiers,
290 draw_commands,
291 click_actions,
292 pointer_inputs,
293 clip_to_bounds,
294 annotated_text,
295 text_style,
296 text_layout_options,
297 text_pan,
298 graphics_layer,
299 children: child_snapshots,
300 } = snapshot;
301 let local_bounds = Rect {
302 x: 0.0,
303 y: 0.0,
304 width: size.width,
305 height: size.height,
306 };
307 let graphics_layer = graphics_layer.unwrap_or_default();
308 let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
309 let isolation = isolation_reasons(&graphics_layer);
310 let cache_policy = if isolation.has_any() {
311 CachePolicy::Auto
312 } else {
313 CachePolicy::None
314 };
315 let shadow_clip = clip_to_bounds.then_some(local_bounds);
316 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
317 shape: None,
318 click_actions,
319 pointer_inputs,
320 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
321 });
322
323 let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
324 let child_translated_content_context =
325 inherited_translated_content_context || translated_content_context;
326
327 let mut children = draw_nodes(
328 &draw_commands,
329 DrawPlacement::Behind,
330 size,
331 PrimitivePhase::BeforeChildren,
332 );
333 if let Some(text) = text_node_from_parts(TextNodeParts {
334 node_id,
335 local_bounds,
336 measured_max_width,
337 resolved_modifiers: &resolved_modifiers,
338 annotated_text: annotated_text.as_ref(),
339 text_style: text_style.as_ref(),
340 text_layout_options,
341 text_pan,
342 modifier_slices: None,
343 }) {
344 children.push(RenderNode::Primitive(PrimitiveEntry {
345 phase: PrimitivePhase::BeforeChildren,
346 node: PrimitiveNode::Text(Box::new(text)),
347 }));
348 }
349 let child_motion_context_animated = node_motion_context_animated;
350 for child in child_snapshots {
351 let mut child_layer = build_layer_node_internal(
352 child,
353 child_motion_context_animated,
354 child_translated_content_context,
355 );
356 if content_offset != Point::default() {
357 child_layer.transform_to_parent =
358 child_layer
359 .transform_to_parent
360 .then(ProjectiveTransform::translation(
361 content_offset.x,
362 content_offset.y,
363 ));
364 }
365 children.push(RenderNode::Layer(Box::new(child_layer)));
366 }
367 children.extend(draw_nodes(
368 &draw_commands,
369 DrawPlacement::Overlay,
370 size,
371 PrimitivePhase::AfterChildren,
372 ));
373 let has_hit_targets = hit_test.is_some()
374 || children.iter().any(|child| match child {
375 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
376 RenderNode::Primitive(_) => false,
377 });
378
379 LayerNode {
380 node_id: Some(node_id),
381 local_bounds,
382 transform_to_parent,
383 content_offset,
384 motion_context_animated: node_motion_context_animated,
385 translated_content_context,
386 translated_content_offset: if translated_content_context {
387 content_offset
388 } else {
389 Point::default()
390 },
391 scene_children_origin: Point::default(),
396 scene_children_layer_translation: Point::default(),
397 graphics_layer,
398 clip_to_bounds,
399 shadow_clip,
400 hit_test,
401 has_hit_targets,
402 isolation,
403 cache_policy,
404 cache_hashes: LayerRasterCacheHashes::default(),
405 cache_hashes_valid: false,
406 children,
407 }
408}
409
410#[derive(Clone, Copy)]
421struct AbsOrigin {
422 content_origin: Point,
423 layer_translation: Point,
424}
425
426impl AbsOrigin {
427 const ROOT: AbsOrigin = AbsOrigin {
428 content_origin: Point { x: 0.0, y: 0.0 },
429 layer_translation: Point { x: 0.0, y: 0.0 },
430 };
431}
432
433fn build_layer_node_from_applier(
434 applier: &mut MemoryApplier,
435 node_id: NodeId,
436 _root_scale: f32,
437 inherited_motion_context_animated: bool,
438) -> Option<LayerNode> {
439 build_layer_node_from_applier_internal(
440 applier,
441 node_id,
442 inherited_motion_context_animated,
443 false,
444 Some(AbsOrigin::ROOT),
445 )
446}
447
448fn build_layer_node_from_applier_internal(
449 applier: &mut MemoryApplier,
450 node_id: NodeId,
451 inherited_motion_context_animated: bool,
452 inherited_translated_content_context: bool,
453 parent_abs: Option<AbsOrigin>,
454) -> Option<LayerNode> {
455 if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
456 let state = node.layout_state();
457 let children = node.children.clone();
458 let modifier_slices = node.modifier_slices_snapshot();
459 SnapshotNodeData {
460 layout_state: state,
461 modifier_slices,
462 resolved_modifiers: node.resolved_modifiers(),
463 children,
464 }
465 }) {
466 return build_layer_node_from_data(
467 applier,
468 node_id,
469 data,
470 inherited_motion_context_animated,
471 inherited_translated_content_context,
472 parent_abs,
473 );
474 }
475
476 if let Ok(data) = applier.with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
477 let state = node.layout_state();
478 let children = node.active_children();
479 let modifier_slices = node.modifier_slices_snapshot();
480 SnapshotNodeData {
481 layout_state: state,
482 modifier_slices,
483 resolved_modifiers: node.resolved_modifiers(),
484 children,
485 }
486 }) {
487 return build_layer_node_from_data(
488 applier,
489 node_id,
490 data,
491 inherited_motion_context_animated,
492 inherited_translated_content_context,
493 parent_abs,
494 );
495 }
496
497 None
498}
499
500fn build_layer_node_from_data(
501 applier: &mut MemoryApplier,
502 node_id: NodeId,
503 data: SnapshotNodeData,
504 inherited_motion_context_animated: bool,
505 inherited_translated_content_context: bool,
506 parent_abs: Option<AbsOrigin>,
507) -> Option<LayerNode> {
508 let SnapshotNodeData {
509 layout_state,
510 modifier_slices,
511 resolved_modifiers,
512 children,
513 } = data;
514 if !layout_state.is_placed {
515 return None;
516 }
517
518 let local_bounds = Rect {
519 x: 0.0,
520 y: 0.0,
521 width: layout_state.size.width,
522 height: layout_state.size.height,
523 };
524 if std::env::var_os("CRANPOSE_SCENE_UPDATE_DIAG").is_some() {
525 eprintln!(
526 "[scene-update-diag] build layer node={node_id:?} size=({:.2},{:.2}) pos=({:.2},{:.2})",
527 layout_state.size.width,
528 layout_state.size.height,
529 layout_state.position.x,
530 layout_state.position.y,
531 );
532 }
533 let clip_to_bounds = modifier_slices.clip_to_bounds();
534 let graphics_layer = graphics_layer_with_shaped_clip(
535 modifier_slices.graphics_layer().unwrap_or_default(),
536 clip_to_bounds,
537 modifier_slices.corner_shape(),
538 local_bounds,
539 );
540 let transform_to_parent =
541 layer_transform_to_parent(local_bounds, layout_state.position, &graphics_layer);
542 let isolation = isolation_reasons(&graphics_layer);
543 let cache_policy = if isolation.has_any() {
544 CachePolicy::Auto
545 } else {
546 CachePolicy::None
547 };
548 let click_actions = modifier_slices.click_handlers();
549 let pointer_inputs = modifier_slices.pointer_inputs();
550 let shadow_clip = clip_to_bounds.then_some(local_bounds);
551 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
552 shape: None,
553 click_actions: click_actions.to_vec(),
554 pointer_inputs: pointer_inputs.to_vec(),
555 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
556 });
557
558 let node_motion_context_animated =
559 inherited_motion_context_animated || modifier_slices.motion_context_animated();
560 let local_translated_content_context = modifier_slices.translated_content_context();
561 let local_translated_content_offset = modifier_slices
562 .translated_content_offset()
563 .unwrap_or(layout_state.content_offset);
564 let child_translated_content_context =
565 inherited_translated_content_context || local_translated_content_context;
566
567 let this_abs = parent_abs.map(|parent| {
582 let (tx, ty) = modifier_slices
583 .graphics_layer()
584 .map(|layer| (layer.translation_x, layer.translation_y))
585 .unwrap_or((0.0, 0.0));
586 let top_left = Point {
587 x: parent.content_origin.x + layout_state.position.x,
588 y: parent.content_origin.y + layout_state.position.y,
589 };
590 let layer_translation = Point {
591 x: parent.layer_translation.x + tx,
592 y: parent.layer_translation.y + ty,
593 };
594 (top_left, layer_translation)
595 });
596 if let Some((top_left, layer_translation)) = this_abs {
597 let window_origin = Point {
598 x: top_left.x + layer_translation.x,
599 y: top_left.y + layer_translation.y,
600 };
601 if let Some(sink) = modifier_slices.text_field_window_origin() {
602 sink.set(window_origin);
603 }
604 if let Some(sink) = modifier_slices.viewport_window_rect() {
605 sink.set(Rect {
606 x: window_origin.x,
607 y: window_origin.y,
608 width: layout_state.size.width,
609 height: layout_state.size.height,
610 });
611 }
612 }
613 let child_abs = this_abs.map(|(top_left, layer_translation)| AbsOrigin {
621 content_origin: Point {
622 x: top_left.x + layout_state.content_offset.x,
623 y: top_left.y + layout_state.content_offset.y,
624 },
625 layer_translation,
626 });
627
628 let mut render_children = draw_nodes(
629 modifier_slices.draw_commands(),
630 DrawPlacement::Behind,
631 layout_state.size,
632 PrimitivePhase::BeforeChildren,
633 );
634 if let Some(text) = text_node_from_parts(TextNodeParts {
635 node_id,
636 local_bounds,
637 measured_max_width: layout_state
638 .measurement_constraints
639 .max_width
640 .is_finite()
641 .then_some(layout_state.measurement_constraints.max_width),
642 resolved_modifiers: &resolved_modifiers,
643 annotated_text: modifier_slices.annotated_text(),
644 text_style: modifier_slices.text_style(),
645 text_layout_options: modifier_slices.text_layout_options(),
646 text_pan: modifier_slices.text_pan_resolver(),
647 modifier_slices: Some(modifier_slices.as_ref()),
648 }) {
649 render_children.push(RenderNode::Primitive(PrimitiveEntry {
650 phase: PrimitivePhase::BeforeChildren,
651 node: PrimitiveNode::Text(Box::new(text)),
652 }));
653 }
654 let child_motion_context_animated = node_motion_context_animated;
655 for child_id in children {
656 let Some(mut child_layer) = build_layer_node_from_applier_internal(
657 applier,
658 child_id,
659 child_motion_context_animated,
660 child_translated_content_context,
661 child_abs,
662 ) else {
663 continue;
664 };
665 if layout_state.content_offset != Point::default() {
666 child_layer.transform_to_parent =
667 child_layer
668 .transform_to_parent
669 .then(ProjectiveTransform::translation(
670 layout_state.content_offset.x,
671 layout_state.content_offset.y,
672 ));
673 }
674 render_children.push(RenderNode::Layer(Box::new(child_layer)));
675 }
676 render_children.extend(draw_nodes(
677 modifier_slices.draw_commands(),
678 DrawPlacement::Overlay,
679 layout_state.size,
680 PrimitivePhase::AfterChildren,
681 ));
682 let has_hit_targets = hit_test.is_some()
683 || render_children.iter().any(|child| match child {
684 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
685 RenderNode::Primitive(_) => false,
686 });
687
688 let layer = LayerNode {
689 node_id: Some(node_id),
690 local_bounds,
691 transform_to_parent,
692 content_offset: layout_state.content_offset,
693 motion_context_animated: node_motion_context_animated,
694 translated_content_context: local_translated_content_context,
695 translated_content_offset: if local_translated_content_context {
696 local_translated_content_offset
697 } else {
698 Point::default()
699 },
700 scene_children_origin: child_abs.map(|c| c.content_origin).unwrap_or_default(),
704 scene_children_layer_translation: child_abs
705 .map(|c| c.layer_translation)
706 .unwrap_or_default(),
707 graphics_layer,
708 clip_to_bounds,
709 shadow_clip,
710 hit_test,
711 has_hit_targets,
712 isolation,
713 cache_policy,
714 cache_hashes: LayerRasterCacheHashes::default(),
715 cache_hashes_valid: false,
716 children: render_children,
717 };
718 Some(layer)
719}
720
721fn draw_nodes(
722 commands: &[DrawCommand],
723 placement: DrawPlacement,
724 size: Size,
725 phase: PrimitivePhase,
726) -> Vec<RenderNode> {
727 let mut nodes = Vec::new();
728 for command in commands {
729 for primitive in primitives_for_placement(command, placement, size) {
730 nodes.push(RenderNode::Primitive(PrimitiveEntry {
731 phase,
732 node: PrimitiveNode::Draw(DrawPrimitiveNode {
733 primitive,
734 clip: None,
735 }),
736 }));
737 }
738 }
739 nodes
740}
741
742struct TextNodeParts<'a> {
743 node_id: NodeId,
744 local_bounds: Rect,
745 measured_max_width: Option<f32>,
746 resolved_modifiers: &'a ResolvedModifiers,
747 annotated_text: Option<&'a AnnotatedString>,
748 text_style: Option<&'a TextStyle>,
749 text_layout_options: Option<TextLayoutOptions>,
750 text_pan: Option<TextPanResolver>,
751 modifier_slices: Option<&'a ModifierNodeSlices>,
752}
753
754fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
755 let TextNodeParts {
756 node_id,
757 local_bounds,
758 measured_max_width,
759 resolved_modifiers,
760 annotated_text,
761 text_style,
762 text_layout_options,
763 text_pan,
764 modifier_slices,
765 } = parts;
766 let value = annotated_text?;
767 let default_text_style = TextStyle::default();
768 let text_style = text_style.cloned().unwrap_or(default_text_style);
769 let options = text_layout_options.unwrap_or_default().normalized();
770 let padding = resolved_modifiers.padding();
771 let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
772 if content_width <= 0.0 {
773 return None;
774 }
775
776 let pan_offset = text_pan
780 .as_ref()
781 .map(|resolve| resolve(content_width))
782 .unwrap_or(0.0);
783 let pans_horizontally = text_pan.is_some();
784
785 let max_width = if pans_horizontally {
786 None
787 } else {
788 let measure_width =
789 resolve_text_measure_width(content_width, padding, measured_max_width, options);
790 Some(measure_width).filter(|width| width.is_finite() && *width > 0.0)
791 };
792 let prepared = modifier_slices
793 .and_then(|slices| slices.prepare_text_layout(max_width))
794 .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
795 let visual_style = prepared.visual_style.clone();
796 let measured_draw_width = prepared.metrics.width.max(0.0);
797 let draw_width = if options.overflow == TextOverflow::Visible || pans_horizontally {
798 measured_draw_width
799 } else {
800 measured_draw_width.min(content_width)
801 };
802 let alignment_offset = resolve_text_horizontal_offset(
803 &text_style,
804 prepared.text.text.as_str(),
805 content_width,
806 prepared.metrics.width,
807 );
808 let rect = Rect {
809 x: padding.left + alignment_offset - pan_offset,
810 y: padding.top,
811 width: draw_width,
812 height: prepared.metrics.height,
813 };
814 let text_bounds = Rect {
815 x: padding.left,
816 y: padding.top,
817 width: content_width,
818 height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
819 };
820 let font_size = visual_style.resolve_font_size(14.0);
821 let expanded_bounds =
822 expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
823 let clip = if options.overflow == TextOverflow::Visible && !pans_horizontally {
824 None
825 } else {
826 Some(pad_clip_rect(expanded_bounds))
827 };
828
829 Some(TextPrimitiveNode {
830 node_id,
831 rect,
832 text: prepared.text,
833 text_style: visual_style,
834 font_size,
835 layout_options: options,
836 clip,
837 })
838}
839
840fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
841 let placement = parent
842 .map(|parent_box| Point {
843 x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
844 y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
845 })
846 .unwrap_or_default();
847 let mut children = Vec::with_capacity(node.children.len());
848 for child in &node.children {
849 children.push(layout_box_to_snapshot(child, Some(node)));
850 }
851 let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
852 let graphics_layer = graphics_layer_with_shaped_clip(
853 base_graphics_layer.clone().unwrap_or_default(),
854 node.node_data.modifier_slices.clip_to_bounds(),
855 node.node_data.modifier_slices.corner_shape(),
856 Rect {
857 x: 0.0,
858 y: 0.0,
859 width: node.rect.width,
860 height: node.rect.height,
861 },
862 );
863 let has_graphics_layer =
864 base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
865
866 BuildNodeSnapshot {
867 node_id: node.node_id,
868 placement,
869 size: Size {
870 width: node.rect.width,
871 height: node.rect.height,
872 },
873 content_offset: node.content_offset,
874 motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
875 translated_content_context: node.node_data.modifier_slices.translated_content_context(),
876 measured_max_width: None,
877 resolved_modifiers: node.node_data.resolved_modifiers,
878 draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
879 click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
880 pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
881 clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
882 annotated_text: node.node_data.modifier_slices.annotated_string(),
883 text_style: node.node_data.modifier_slices.text_style().cloned(),
884 text_layout_options: node.node_data.modifier_slices.text_layout_options(),
885 text_pan: node.node_data.modifier_slices.text_pan_resolver(),
886 graphics_layer: has_graphics_layer.then_some(graphics_layer),
887 children,
888 }
889}
890
891fn graphics_layer_with_shaped_clip(
892 mut graphics_layer: GraphicsLayer,
893 clip_to_bounds: bool,
894 corner_shape: Option<RoundedCornerShape>,
895 local_bounds: Rect,
896) -> GraphicsLayer {
897 if !clip_to_bounds {
898 return graphics_layer;
899 }
900
901 let Some(corner_shape) = corner_shape else {
902 return graphics_layer;
903 };
904 let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
905 if radii.top_left <= f32::EPSILON
906 && radii.top_right <= f32::EPSILON
907 && radii.bottom_right <= f32::EPSILON
908 && radii.bottom_left <= f32::EPSILON
909 {
910 return graphics_layer;
911 }
912
913 if let Some(existing) = graphics_layer.render_effect.take() {
914 let rounded_clip = rounded_corner_alpha_mask_effect(
915 local_bounds.width,
916 local_bounds.height,
917 radii,
918 ROUNDED_CLIP_EDGE_FEATHER,
919 );
920 graphics_layer.render_effect = Some(existing.then(rounded_clip));
921 } else {
922 graphics_layer.shape = LayerShape::Rounded(corner_shape);
923 graphics_layer.clip = true;
924 }
925 graphics_layer
926}
927
928fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
929 IsolationReasons {
930 explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
931 shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
932 effect: layer.render_effect.is_some(),
933 backdrop: layer.backdrop_effect.is_some(),
934 group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
935 && layer.alpha < 1.0,
936 blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
937 }
938}
939
940fn pad_clip_rect(rect: Rect) -> Rect {
941 Rect {
942 x: rect.x - TEXT_CLIP_PAD,
943 y: rect.y - TEXT_CLIP_PAD,
944 width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
945 height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
946 }
947}
948
949fn expand_text_bounds_for_baseline_shift(
950 text_bounds: Rect,
951 text_style: &TextStyle,
952 font_size: f32,
953) -> Rect {
954 let baseline_shift_px = text_style
955 .span_style
956 .baseline_shift
957 .filter(|shift| shift.is_specified())
958 .map(|shift| -(shift.0 * font_size))
959 .unwrap_or(0.0);
960 if baseline_shift_px == 0.0 {
961 return text_bounds;
962 }
963
964 if baseline_shift_px < 0.0 {
965 Rect {
966 x: text_bounds.x,
967 y: text_bounds.y + baseline_shift_px,
968 width: text_bounds.width,
969 height: (text_bounds.height - baseline_shift_px).max(0.0),
970 }
971 } else {
972 Rect {
973 x: text_bounds.x,
974 y: text_bounds.y,
975 width: text_bounds.width,
976 height: (text_bounds.height + baseline_shift_px).max(0.0),
977 }
978 }
979}
980
981fn resolve_text_measure_width(
982 content_width: f32,
983 padding: cranpose_ui::EdgeInsets,
984 measured_max_width: Option<f32>,
985 options: TextLayoutOptions,
986) -> f32 {
987 let available = measured_max_width
988 .map(|max_width| (max_width - padding.left - padding.right).max(0.0))
989 .unwrap_or(content_width);
990 if options.soft_wrap || options.max_lines != 1 || options.overflow == TextOverflow::Clip {
991 available.min(content_width)
992 } else {
993 content_width
994 }
995}
996
997fn resolve_text_horizontal_offset(
998 text_style: &TextStyle,
999 text: &str,
1000 content_width: f32,
1001 measured_width: f32,
1002) -> f32 {
1003 let remaining = (content_width - measured_width).max(0.0);
1004 let paragraph_style = &text_style.paragraph_style;
1005 let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
1006 match paragraph_style.text_align {
1007 TextAlign::Center => remaining * 0.5,
1008 TextAlign::End | TextAlign::Right => remaining,
1009 TextAlign::Start | TextAlign::Left | TextAlign::Justify => {
1010 if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
1011 remaining
1012 } else {
1013 0.0
1014 }
1015 }
1016 TextAlign::Unspecified => {
1017 if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
1018 remaining
1019 } else {
1020 0.0
1021 }
1022 }
1023 }
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use std::cell::RefCell;
1029 use std::rc::Rc;
1030
1031 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1032 use cranpose_ui::text::{
1033 AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
1034 };
1035 use cranpose_ui::{
1036 Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
1037 LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
1038 ScrollState, Size, Spacer, Text, TextStyle,
1039 };
1040 use cranpose_ui_graphics::{Brush, DrawPrimitive, GraphicsLayer, RenderEffect};
1041
1042 use super::*;
1043
1044 fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
1045 for child in &layer.children {
1046 match child {
1047 RenderNode::Primitive(primitive) => {
1048 let PrimitiveNode::Text(text) = &primitive.node else {
1049 continue;
1050 };
1051 if text.text.text == label {
1052 return Some(text.text_style.paragraph_style.text_motion);
1053 }
1054 }
1055 RenderNode::Layer(child_layer) => {
1056 if let Some(motion) = find_text_motion(child_layer, label) {
1057 return Some(motion);
1058 }
1059 }
1060 }
1061 }
1062
1063 None
1064 }
1065
1066 fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
1067 for child in &layer.children {
1068 match child {
1069 RenderNode::Primitive(primitive) => {
1070 let PrimitiveNode::Text(text) = &primitive.node else {
1071 continue;
1072 };
1073 labels.push(text.text.text.clone());
1074 }
1075 RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
1076 }
1077 }
1078 }
1079
1080 fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
1081 fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
1082 for child in &layer.children {
1083 match child {
1084 RenderNode::Primitive(primitive) => {
1085 let PrimitiveNode::Text(text) = &primitive.node else {
1086 continue;
1087 };
1088 if text.text.text == label {
1089 let quad = transform.map_rect(text.rect);
1090 let top = quad
1091 .iter()
1092 .map(|point| point[1])
1093 .fold(f32::INFINITY, f32::min);
1094 return top.is_finite().then_some(top);
1095 }
1096 }
1097 RenderNode::Layer(child_layer) => {
1098 let child_transform = child_layer.transform_to_parent.then(transform);
1099 if let Some(top) = search(child_layer, label, child_transform) {
1100 return Some(top);
1101 }
1102 }
1103 }
1104 }
1105 None
1106 }
1107
1108 search(layer, label, ProjectiveTransform::identity())
1109 }
1110
1111 fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
1112 if layer.node_id == Some(node_id) {
1113 return Some(layer);
1114 }
1115 layer.children.iter().find_map(|child| match child {
1116 RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
1117 RenderNode::Primitive(_) => None,
1118 })
1119 }
1120
1121 fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
1122 fn search(
1123 layer: &LayerNode,
1124 node_id: NodeId,
1125 transform: ProjectiveTransform,
1126 ) -> Option<Point> {
1127 if layer.node_id == Some(node_id) {
1128 return Some(transform.map_point(Point::default()));
1129 }
1130 layer.children.iter().find_map(|child| match child {
1131 RenderNode::Layer(child_layer) => search(
1132 child_layer,
1133 node_id,
1134 child_layer.transform_to_parent.then(transform),
1135 ),
1136 RenderNode::Primitive(_) => None,
1137 })
1138 }
1139
1140 search(layer, node_id, ProjectiveTransform::identity())
1141 }
1142
1143 fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1144 if layer.translated_content_context {
1145 return Some(layer.translated_content_offset);
1146 }
1147 for child in &layer.children {
1148 if let RenderNode::Layer(child_layer) = child {
1149 if let Some(offset) = find_translated_content_offset(child_layer) {
1150 return Some(offset);
1151 }
1152 }
1153 }
1154 None
1155 }
1156
1157 fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1158 layer
1159 .graphics_layer
1160 .render_effect
1161 .as_ref()
1162 .is_some_and(RenderEffect::contains_runtime_shader)
1163 || layer.children.iter().any(|child| match child {
1164 RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1165 RenderNode::Primitive(_) => false,
1166 })
1167 }
1168
1169 fn build_layer_node_for_test(
1170 snapshot: BuildNodeSnapshot,
1171 scale: f32,
1172 has_external_backdrop_input: bool,
1173 ) -> LayerNode {
1174 let app_context = cranpose_ui::AppContext::new();
1175 app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1176 }
1177
1178 fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1179 let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
1180 vec![DrawPrimitive::Rect {
1181 rect: Rect {
1182 x: 3.0,
1183 y: 4.0,
1184 width: 20.0,
1185 height: 8.0,
1186 },
1187 brush: Brush::solid(Color::WHITE),
1188 }]
1189 }));
1190
1191 let child = BuildNodeSnapshot {
1192 node_id: 2,
1193 placement: Point { x: 11.0, y: 7.0 },
1194 size: Size {
1195 width: 40.0,
1196 height: 20.0,
1197 },
1198 content_offset: Point::default(),
1199 motion_context_animated: false,
1200 translated_content_context: false,
1201 measured_max_width: None,
1202 resolved_modifiers: ResolvedModifiers::default(),
1203 draw_commands: vec![child_command],
1204 click_actions: vec![],
1205 pointer_inputs: vec![],
1206 clip_to_bounds: false,
1207 annotated_text: None,
1208 text_style: None,
1209 text_layout_options: None,
1210 text_pan: None,
1211 graphics_layer: None,
1212 children: vec![],
1213 };
1214
1215 BuildNodeSnapshot {
1216 node_id: 1,
1217 placement: Point::default(),
1218 size: Size {
1219 width: 80.0,
1220 height: 50.0,
1221 },
1222 content_offset: Point::default(),
1223 motion_context_animated: false,
1224 translated_content_context: false,
1225 measured_max_width: None,
1226 resolved_modifiers: ResolvedModifiers::default(),
1227 draw_commands: vec![],
1228 click_actions: vec![],
1229 pointer_inputs: vec![],
1230 clip_to_bounds: false,
1231 annotated_text: None,
1232 text_style: None,
1233 text_layout_options: None,
1234 text_pan: None,
1235 graphics_layer: Some(GraphicsLayer {
1236 translation_x: tx,
1237 ..GraphicsLayer::default()
1238 }),
1239 children: vec![child],
1240 }
1241 }
1242
1243 #[test]
1244 fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1245 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1246 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1247
1248 let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1249 panic!("expected child layer");
1250 };
1251 let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1252 panic!("expected child layer");
1253 };
1254 let RenderNode::Primitive(static_draw) = &static_child.children[0] else {
1255 panic!("expected draw primitive");
1256 };
1257 let PrimitiveNode::Draw(static_draw) = &static_draw.node else {
1258 panic!("expected draw primitive");
1259 };
1260 let RenderNode::Primitive(moved_draw) = &moved_child.children[0] else {
1261 panic!("expected draw primitive");
1262 };
1263 let PrimitiveNode::Draw(moved_draw) = &moved_draw.node else {
1264 panic!("expected draw primitive");
1265 };
1266
1267 assert_ne!(
1268 static_graph.transform_to_parent, moved_graph.transform_to_parent,
1269 "parent transform should encode translation"
1270 );
1271 assert_eq!(
1272 static_draw, moved_draw,
1273 "child local primitive geometry must stay stable under parent translation"
1274 );
1275 }
1276
1277 #[test]
1278 fn stored_content_hash_ignores_parent_translation() {
1279 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1280 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1281
1282 assert_eq!(
1283 static_graph.target_content_hash(),
1284 moved_graph.target_content_hash(),
1285 "parent rigid motion must not invalidate the subtree content hash"
1286 );
1287 }
1288
1289 #[test]
1290 fn parent_content_offset_is_encoded_in_child_transform() {
1291 let child = BuildNodeSnapshot {
1292 node_id: 2,
1293 placement: Point { x: 11.0, y: 7.0 },
1294 size: Size {
1295 width: 40.0,
1296 height: 20.0,
1297 },
1298 content_offset: Point::default(),
1299 motion_context_animated: false,
1300 translated_content_context: false,
1301 measured_max_width: None,
1302 resolved_modifiers: ResolvedModifiers::default(),
1303 draw_commands: vec![],
1304 click_actions: vec![],
1305 pointer_inputs: vec![],
1306 clip_to_bounds: false,
1307 annotated_text: None,
1308 text_style: None,
1309 text_layout_options: None,
1310 text_pan: None,
1311 graphics_layer: None,
1312 children: vec![],
1313 };
1314
1315 let parent = BuildNodeSnapshot {
1316 node_id: 1,
1317 placement: Point::default(),
1318 size: Size {
1319 width: 80.0,
1320 height: 50.0,
1321 },
1322 content_offset: Point { x: 13.0, y: -9.0 },
1323 motion_context_animated: false,
1324 translated_content_context: false,
1325 measured_max_width: None,
1326 resolved_modifiers: ResolvedModifiers::default(),
1327 draw_commands: vec![],
1328 click_actions: vec![],
1329 pointer_inputs: vec![],
1330 clip_to_bounds: false,
1331 annotated_text: None,
1332 text_style: None,
1333 text_layout_options: None,
1334 text_pan: None,
1335 graphics_layer: None,
1336 children: vec![child],
1337 };
1338
1339 let graph = build_layer_node_for_test(parent, 1.0, false);
1340 let RenderNode::Layer(child) = &graph.children[0] else {
1341 panic!("expected child layer");
1342 };
1343
1344 let top_left = child.transform_to_parent.map_point(Point::default());
1345 assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1346 }
1347
1348 #[test]
1349 fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1350 fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1351 let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
1352 vec![DrawPrimitive::Rect {
1353 rect: Rect {
1354 x: 3.0,
1355 y: 4.0,
1356 width: 20.0,
1357 height: 8.0,
1358 },
1359 brush: Brush::solid(Color::WHITE),
1360 }]
1361 }));
1362
1363 let child = BuildNodeSnapshot {
1364 node_id: 2,
1365 placement: Point { x: 11.0, y: 7.0 },
1366 size: Size {
1367 width: 40.0,
1368 height: 20.0,
1369 },
1370 content_offset: Point::default(),
1371 motion_context_animated: false,
1372 translated_content_context: false,
1373 measured_max_width: None,
1374 resolved_modifiers: ResolvedModifiers::default(),
1375 draw_commands: vec![child_command],
1376 click_actions: vec![],
1377 pointer_inputs: vec![],
1378 clip_to_bounds: false,
1379 annotated_text: None,
1380 text_style: None,
1381 text_layout_options: None,
1382 text_pan: None,
1383 graphics_layer: None,
1384 children: vec![],
1385 };
1386
1387 BuildNodeSnapshot {
1388 node_id: 1,
1389 placement: Point::default(),
1390 size: Size {
1391 width: 80.0,
1392 height: 50.0,
1393 },
1394 content_offset: offset,
1395 motion_context_animated,
1396 translated_content_context: true,
1397 measured_max_width: None,
1398 resolved_modifiers: ResolvedModifiers::default(),
1399 draw_commands: vec![],
1400 click_actions: vec![],
1401 pointer_inputs: vec![],
1402 clip_to_bounds: false,
1403 annotated_text: None,
1404 text_style: None,
1405 text_layout_options: None,
1406 text_pan: None,
1407 graphics_layer: None,
1408 children: vec![child],
1409 }
1410 }
1411
1412 let base = build_layer_node_for_test(
1413 parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
1414 1.0,
1415 false,
1416 );
1417 let moved = build_layer_node_for_test(
1418 parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
1419 1.0,
1420 false,
1421 );
1422 let rested = build_layer_node_for_test(
1423 parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
1424 1.0,
1425 false,
1426 );
1427
1428 let RenderNode::Layer(base_child) = &base.children[0] else {
1429 panic!("expected child layer");
1430 };
1431 let RenderNode::Layer(moved_child) = &moved.children[0] else {
1432 panic!("expected child layer");
1433 };
1434
1435 assert_ne!(
1436 base_child.transform_to_parent.map_point(Point::default()),
1437 moved_child.transform_to_parent.map_point(Point::default()),
1438 "scroll offset still has to move child content visually"
1439 );
1440 assert_eq!(
1441 base_child.target_content_hash(),
1442 moved_child.target_content_hash(),
1443 "child source content identity stays stable when only the parent scroll offset changes"
1444 );
1445 assert_ne!(
1446 base.target_content_hash(),
1447 moved.target_content_hash(),
1448 "a full-surface cache of the scroll viewport must include the scroll offset"
1449 );
1450 assert_ne!(
1451 base.target_content_hash(),
1452 rested.target_content_hash(),
1453 "full-surface cache keys must include active scroll motion policy"
1454 );
1455 }
1456
1457 #[test]
1458 fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
1459 let layer = graphics_layer_with_shaped_clip(
1460 GraphicsLayer::default(),
1461 true,
1462 Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
1463 Rect {
1464 x: 0.0,
1465 y: 0.0,
1466 width: 100.0,
1467 height: 40.0,
1468 },
1469 );
1470
1471 assert!(layer.clip);
1472 assert!(layer.render_effect.is_none());
1473 let LayerShape::Rounded(shape) = layer.shape else {
1474 panic!("rounded clip must be recorded as layer shape");
1475 };
1476 assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
1477 assert!(isolation_reasons(&layer).shape_clip);
1478 }
1479
1480 #[test]
1481 fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
1482 let existing = RenderEffect::blur(3.0);
1483 let layer = graphics_layer_with_shaped_clip(
1484 GraphicsLayer {
1485 render_effect: Some(existing.clone()),
1486 ..GraphicsLayer::default()
1487 },
1488 true,
1489 Some(RoundedCornerShape::uniform(10.0)),
1490 Rect {
1491 x: 0.0,
1492 y: 0.0,
1493 width: 100.0,
1494 height: 40.0,
1495 },
1496 );
1497
1498 let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
1499 panic!("existing effect should chain into rounded clip mask");
1500 };
1501 assert_eq!(*first, existing);
1502 assert!(
1503 matches!(*second, RenderEffect::Shader { .. }),
1504 "rounded mask must be the outer effect"
1505 );
1506 }
1507
1508 #[test]
1509 fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
1510 let mut composition = cranpose_ui::run_test_composition(|| {
1511 cranpose_ui::Box(
1512 Modifier::empty()
1513 .width(100.0)
1514 .height(40.0)
1515 .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
1516 .clip_to_bounds(),
1517 cranpose_ui::BoxSpec::default(),
1518 || {
1519 Text("rounded child", Modifier::empty(), TextStyle::default());
1520 },
1521 );
1522 });
1523
1524 let root = composition.root().expect("rounded clip root");
1525 let handle = composition.runtime_handle();
1526 let mut applier = composition.applier_mut();
1527 applier.set_runtime_handle(handle);
1528 applier
1529 .compute_layout(
1530 root,
1531 Size {
1532 width: 160.0,
1533 height: 100.0,
1534 },
1535 )
1536 .expect("rounded clip layout");
1537 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1538 applier.clear_runtime_handle();
1539
1540 let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
1541 assert!(rounded_layer.graphics_layer.clip);
1542 assert!(matches!(
1543 rounded_layer.graphics_layer.shape,
1544 LayerShape::Rounded(_)
1545 ));
1546 assert!(rounded_layer.graphics_layer.render_effect.is_none());
1547 assert!(rounded_layer.isolation.shape_clip);
1548 assert!(
1549 !graph_has_runtime_shader_effect(&graph.root),
1550 "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
1551 );
1552 }
1553
1554 #[test]
1555 fn update_graph_from_applier_replaces_dirty_child_layer() {
1556 let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1557 Rc::new(RefCell::new(None));
1558 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1559 let state_holder_for_comp = state_holder.clone();
1560 let child_id_holder_for_comp = child_id_holder.clone();
1561
1562 let mut composition = cranpose_ui::run_test_composition(move || {
1563 let label = cranpose_core::useState(|| "before".to_string());
1564 *state_holder_for_comp.borrow_mut() = Some(label);
1565 let child_id_holder_for_content = child_id_holder_for_comp.clone();
1566 cranpose_ui::Box(
1567 Modifier::empty().size_points(240.0, 80.0),
1568 cranpose_ui::BoxSpec::default(),
1569 move || {
1570 let child_id = Text(label, Modifier::empty(), TextStyle::default());
1571 *child_id_holder_for_content.borrow_mut() = Some(child_id);
1572 Text("stable", Modifier::empty(), TextStyle::default());
1573 },
1574 );
1575 });
1576
1577 let root = composition.root().expect("composition root");
1578 let viewport = Size {
1579 width: 240.0,
1580 height: 80.0,
1581 };
1582 let handle = composition.runtime_handle();
1583 let mut applier = composition.applier_mut();
1584 applier.set_runtime_handle(handle);
1585 applier
1586 .compute_layout(root, viewport)
1587 .expect("initial layout");
1588 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1589 let child_id = child_id_holder
1590 .borrow()
1591 .expect("text child id should be captured");
1592 let initial_transform = find_layer_by_node_id(&graph.root, child_id)
1593 .expect("text child layer")
1594 .transform_to_parent;
1595 applier.clear_runtime_handle();
1596 drop(applier);
1597
1598 let label = state_holder
1599 .borrow()
1600 .as_ref()
1601 .copied()
1602 .expect("label state should be captured");
1603 label.set_value("after".to_string());
1604 composition
1605 .process_invalid_scopes()
1606 .expect("text recomposition");
1607
1608 let handle = composition.runtime_handle();
1609 let mut applier = composition.applier_mut();
1610 applier.set_runtime_handle(handle);
1611 applier
1612 .compute_layout(root, viewport)
1613 .expect("updated layout");
1614 let child_id = child_id_holder
1615 .borrow()
1616 .expect("text child id should remain captured");
1617
1618 assert!(
1619 update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
1620 "dirty child should be replaceable from retained applier state"
1621 );
1622 applier.clear_runtime_handle();
1623
1624 let mut labels = Vec::new();
1625 collect_text_labels(&graph.root, &mut labels);
1626 assert!(
1627 labels.iter().any(|label| label == "after"),
1628 "updated graph should contain refreshed child text, got {labels:?}"
1629 );
1630 assert!(
1631 !labels.iter().any(|label| label == "before"),
1632 "updated graph should not retain stale child text, got {labels:?}"
1633 );
1634 assert!(
1635 labels.iter().any(|label| label == "stable"),
1636 "sibling content should remain present, got {labels:?}"
1637 );
1638 assert_eq!(
1639 find_layer_by_node_id(&graph.root, child_id)
1640 .expect("updated text child layer")
1641 .transform_to_parent,
1642 initial_transform,
1643 "draw-only child replacement must preserve the retained parent placement transform"
1644 );
1645 }
1646
1647 #[test]
1655 fn scene_build_publishes_live_window_rect_without_layout_tree() {
1656 use cranpose_ui::{measure_layout_with_options, Box, BoxSpec, MeasureLayoutOptions};
1657 use std::cell::Cell;
1658
1659 let spacer_before = 120.0_f32;
1660 let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
1661 x: 0.0,
1662 y: 0.0,
1663 width: 0.0,
1664 height: 0.0,
1665 }));
1666 let sink_for_comp = sink.clone();
1667 let mut composition = cranpose_ui::run_test_composition(move || {
1668 let sink = sink_for_comp.clone();
1669 Column(
1670 Modifier::empty().size_points(200.0, 400.0),
1671 ColumnSpec::default(),
1672 move || {
1673 Spacer(Size {
1674 width: 200.0,
1675 height: spacer_before,
1676 });
1677 Box(
1678 Modifier::empty()
1679 .size_points(200.0, 50.0)
1680 .report_window_rect(sink.clone()),
1681 BoxSpec::default(),
1682 || {},
1683 );
1684 },
1685 );
1686 });
1687
1688 let root = composition.root().expect("composition root");
1689 let viewport = Size {
1690 width: 200.0,
1691 height: 400.0,
1692 };
1693 let handle = composition.runtime_handle();
1694 let mut applier = composition.applier_mut();
1695 applier.set_runtime_handle(handle);
1696 measure_layout_with_options(
1699 &mut applier,
1700 root,
1701 viewport,
1702 MeasureLayoutOptions {
1703 collect_semantics: false,
1704 build_layout_tree: false,
1705 },
1706 )
1707 .expect("layout");
1708 assert_eq!(
1710 sink.get().height,
1711 0.0,
1712 "sink must start empty (place disabled)"
1713 );
1714
1715 let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
1716 applier.clear_runtime_handle();
1717
1718 let rect = sink.get();
1719 assert!(
1720 (rect.y - spacer_before).abs() < 0.5,
1721 "scene build must publish the box's live window-y (below the {spacer_before}px \
1722 spacer), got {}",
1723 rect.y
1724 );
1725 assert!(
1726 rect.width > 0.0 && rect.height > 0.0,
1727 "scene build must publish a non-empty window rect, got {rect:?}"
1728 );
1729 }
1730
1731 #[test]
1732 fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
1733 let mut graph = RenderGraph {
1734 root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
1735 };
1736 let mut applier = MemoryApplier::new();
1737
1738 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
1739
1740 assert_eq!(
1741 report,
1742 GraphUpdateReport {
1743 applied: false,
1744 hit_graph_dirty: true,
1745 },
1746 "dirty child graph updates must not report success when the replacement cannot be rebuilt"
1747 );
1748 }
1749
1750 #[test]
1751 fn update_graph_from_applier_refreshes_scroll_content_offset() {
1752 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1753 let scroll_holder_for_comp = scroll_holder.clone();
1754
1755 let mut composition = cranpose_ui::run_test_composition(move || {
1756 let scroll_state =
1757 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1758 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1759 Column(
1760 Modifier::empty()
1761 .size_points(240.0, 120.0)
1762 .vertical_scroll(scroll_state, false),
1763 ColumnSpec::default(),
1764 || {
1765 Text("scroll top", Modifier::empty(), TextStyle::default());
1766 Spacer(Size {
1767 width: 0.0,
1768 height: 160.0,
1769 });
1770 Text("scroll target", Modifier::empty(), TextStyle::default());
1771 },
1772 );
1773 });
1774
1775 let root = composition.root().expect("composition root");
1776 let viewport = Size {
1777 width: 240.0,
1778 height: 120.0,
1779 };
1780 let handle = composition.runtime_handle();
1781 let mut applier = composition.applier_mut();
1782 applier.set_runtime_handle(handle);
1783 applier
1784 .compute_layout(root, viewport)
1785 .expect("initial scroll layout");
1786 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1787 let initial_target_top =
1788 find_text_top(&graph.root, "scroll target").expect("initial target text");
1789 applier.clear_runtime_handle();
1790 drop(applier);
1791
1792 let scroll_state = scroll_holder
1793 .borrow()
1794 .as_ref()
1795 .cloned()
1796 .expect("scroll state should be captured");
1797 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
1798 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
1799 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
1800 assert!(
1801 !dirty_nodes.is_empty(),
1802 "scroll state invalidation must schedule scoped layout graph update"
1803 );
1804
1805 let handle = composition.runtime_handle();
1806 let mut applier = composition.applier_mut();
1807 applier.set_runtime_handle(handle);
1808 applier
1809 .compute_layout(root, viewport)
1810 .expect("scrolled layout");
1811 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
1812 applier.clear_runtime_handle();
1813
1814 assert!(report.applied, "scroll graph update should apply in place");
1815 let updated_target_top =
1816 find_text_top(&graph.root, "scroll target").expect("updated target text");
1817 assert!(
1818 updated_target_top < initial_target_top - consumed_scroll * 0.75,
1819 "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
1820 );
1821 }
1822
1823 #[test]
1824 fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
1825 let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1826 Rc::new(RefCell::new(None));
1827 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1828 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1829 let label_holder_for_comp = label_holder.clone();
1830 let scroll_holder_for_comp = scroll_holder.clone();
1831 let child_id_holder_for_comp = child_id_holder.clone();
1832
1833 let mut composition = cranpose_ui::run_test_composition(move || {
1834 let label = cranpose_core::useState(|| "scrolled child before".to_string());
1835 let scroll_state =
1836 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1837 *label_holder_for_comp.borrow_mut() = Some(label);
1838 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1839 let child_id_holder_for_content = child_id_holder_for_comp.clone();
1840 Column(
1841 Modifier::empty()
1842 .size_points(260.0, 90.0)
1843 .vertical_scroll(scroll_state, false),
1844 ColumnSpec::default(),
1845 move || {
1846 Spacer(Size {
1847 width: 0.0,
1848 height: 24.0,
1849 });
1850 let child_id = Text(label, Modifier::empty(), TextStyle::default());
1851 *child_id_holder_for_content.borrow_mut() = Some(child_id);
1852 Spacer(Size {
1853 width: 0.0,
1854 height: 220.0,
1855 });
1856 },
1857 );
1858 });
1859
1860 let root = composition.root().expect("composition root");
1861 let viewport = Size {
1862 width: 260.0,
1863 height: 90.0,
1864 };
1865 let handle = composition.runtime_handle();
1866 let mut applier = composition.applier_mut();
1867 applier.set_runtime_handle(handle);
1868 applier
1869 .compute_layout(root, viewport)
1870 .expect("initial layout");
1871 applier.clear_runtime_handle();
1872 drop(applier);
1873
1874 let scroll_state = scroll_holder
1875 .borrow()
1876 .as_ref()
1877 .cloned()
1878 .expect("scroll state should be captured");
1879 assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
1880
1881 let handle = composition.runtime_handle();
1882 let mut applier = composition.applier_mut();
1883 applier.set_runtime_handle(handle);
1884 applier
1885 .compute_layout(root, viewport)
1886 .expect("scrolled layout");
1887 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
1888 let child_id = child_id_holder
1889 .borrow()
1890 .expect("text child id should be captured");
1891 let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
1892 .expect("scrolled child layer")
1893 .transform_to_parent;
1894 applier.clear_runtime_handle();
1895 drop(applier);
1896
1897 let label = label_holder
1898 .borrow()
1899 .as_ref()
1900 .copied()
1901 .expect("label state should be captured");
1902 label.set_value("scrolled child after".to_string());
1903 composition
1904 .process_invalid_scopes()
1905 .expect("text recomposition");
1906
1907 let handle = composition.runtime_handle();
1908 let mut applier = composition.applier_mut();
1909 applier.set_runtime_handle(handle);
1910 applier
1911 .compute_layout(root, viewport)
1912 .expect("updated scrolled layout");
1913 let child_id = child_id_holder
1914 .borrow()
1915 .expect("text child id should remain captured");
1916 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
1917 applier.clear_runtime_handle();
1918
1919 assert!(report.applied, "dirty child graph update should apply");
1920 let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
1921 assert_eq!(
1922 updated.transform_to_parent, scrolled_transform,
1923 "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
1924 );
1925 let mut labels = Vec::new();
1926 collect_text_labels(&graph.root, &mut labels);
1927 assert!(
1928 labels.iter().any(|label| label == "scrolled child after"),
1929 "updated graph should contain refreshed text, got {labels:?}"
1930 );
1931 }
1932
1933 #[test]
1934 fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
1935 let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
1936 Rc::new(RefCell::new(None));
1937 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1938 let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1939 let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1940 let alpha_holder_for_comp = alpha_holder.clone();
1941 let scroll_holder_for_comp = scroll_holder.clone();
1942 let underlay_id_holder_for_comp = underlay_id_holder.clone();
1943 let overlay_id_holder_for_comp = overlay_id_holder.clone();
1944
1945 let mut composition = cranpose_ui::run_test_composition(move || {
1946 let alpha = cranpose_core::useState(|| 1.0f32);
1947 let scroll_state =
1948 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1949 *alpha_holder_for_comp.borrow_mut() = Some(alpha);
1950 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1951 let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
1952 let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
1953 Column(
1954 Modifier::empty()
1955 .size_points(260.0, 120.0)
1956 .vertical_scroll(scroll_state, false),
1957 ColumnSpec::default(),
1958 move || {
1959 Spacer(Size {
1960 width: 0.0,
1961 height: 180.0,
1962 });
1963 cranpose_ui::Box(
1964 Modifier::empty().size_points(188.0, 88.0),
1965 cranpose_ui::BoxSpec::default(),
1966 {
1967 let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
1968 let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
1969 move || {
1970 let underlay_id = cranpose_ui::Box(
1971 Modifier::empty().size_points(188.0, 88.0),
1972 cranpose_ui::BoxSpec::default(),
1973 || {
1974 Text(
1975 "UNDERLAY CONTENT",
1976 Modifier::empty().absolute_offset(12.0, 8.0),
1977 TextStyle::default(),
1978 );
1979 },
1980 );
1981 *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
1982 let overlay_id = cranpose_ui::Box(
1983 Modifier::empty().size_points(188.0, 88.0).graphics_layer(
1984 move || GraphicsLayer {
1985 alpha: alpha.get(),
1986 ..GraphicsLayer::default()
1987 },
1988 ),
1989 cranpose_ui::BoxSpec::default(),
1990 || {
1991 Text(
1992 "TOP LAYER",
1993 Modifier::empty().absolute_offset(74.0, 39.6),
1994 TextStyle::default(),
1995 );
1996 },
1997 );
1998 *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
1999 }
2000 },
2001 );
2002 Spacer(Size {
2003 width: 0.0,
2004 height: 280.0,
2005 });
2006 },
2007 );
2008 });
2009
2010 let root = composition.root().expect("composition root");
2011 let viewport = Size {
2012 width: 260.0,
2013 height: 120.0,
2014 };
2015 let handle = composition.runtime_handle();
2016 let mut applier = composition.applier_mut();
2017 applier.set_runtime_handle(handle);
2018 applier
2019 .compute_layout(root, viewport)
2020 .expect("initial layout");
2021 applier.clear_runtime_handle();
2022 drop(applier);
2023
2024 let scroll_state = scroll_holder
2025 .borrow()
2026 .as_ref()
2027 .cloned()
2028 .expect("scroll state should be captured");
2029 assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
2030
2031 let handle = composition.runtime_handle();
2032 let mut applier = composition.applier_mut();
2033 applier.set_runtime_handle(handle);
2034 applier
2035 .compute_layout(root, viewport)
2036 .expect("scrolled layout");
2037 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2038 applier.clear_runtime_handle();
2039 drop(applier);
2040
2041 let underlay_id = underlay_id_holder
2042 .borrow()
2043 .expect("underlay id should be captured");
2044 let overlay_id = overlay_id_holder
2045 .borrow()
2046 .expect("overlay id should be captured");
2047 let scrolled_underlay_origin =
2048 find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
2049 let scrolled_overlay_origin =
2050 find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
2051 assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
2052
2053 let alpha = alpha_holder
2054 .borrow()
2055 .as_ref()
2056 .copied()
2057 .expect("alpha state should be captured");
2058 alpha.set_value(0.35);
2059
2060 let handle = composition.runtime_handle();
2061 let mut applier = composition.applier_mut();
2062 applier.set_runtime_handle(handle);
2063 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
2064 applier.clear_runtime_handle();
2065
2066 assert!(report.applied, "dirty overlay graph update should apply");
2067 let updated_underlay_origin =
2068 find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
2069 let updated_overlay_origin =
2070 find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
2071 assert_eq!(
2072 updated_underlay_origin, scrolled_underlay_origin,
2073 "stable underlay must keep its scrolled origin"
2074 );
2075 assert_eq!(
2076 updated_overlay_origin, updated_underlay_origin,
2077 "dirty overlay graphics layer must stay aligned with its stable underlay"
2078 );
2079 }
2080
2081 #[test]
2082 fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
2083 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2084 Rc::new(RefCell::new(None));
2085 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2086 let offset_holder_for_comp = offset_holder.clone();
2087 let node_id_holder_for_comp = node_id_holder.clone();
2088
2089 let mut composition = cranpose_ui::run_test_composition(move || {
2090 let offset = cranpose_core::useState(|| 0.0f32);
2091 *offset_holder_for_comp.borrow_mut() = Some(offset);
2092 let node_id = cranpose_ui::Box(
2093 Modifier::empty()
2094 .size_points(40.0, 20.0)
2095 .graphics_layer(move || GraphicsLayer {
2096 translation_x: offset.get(),
2097 ..GraphicsLayer::default()
2098 }),
2099 cranpose_ui::BoxSpec::default(),
2100 || {},
2101 );
2102 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2103 });
2104
2105 let root = composition.root().expect("composition root");
2106 let viewport = Size {
2107 width: 120.0,
2108 height: 80.0,
2109 };
2110 let handle = composition.runtime_handle();
2111 let mut applier = composition.applier_mut();
2112 applier.set_runtime_handle(handle);
2113 applier
2114 .compute_layout(root, viewport)
2115 .expect("initial layout");
2116 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2117 let node_id = node_id_holder
2118 .borrow()
2119 .expect("graphics layer node id should be captured");
2120 let initial_origin = find_layer_by_node_id(&graph.root, node_id)
2121 .expect("initial graphics layer")
2122 .transform_to_parent
2123 .map_point(Point::default());
2124 applier.clear_runtime_handle();
2125 drop(applier);
2126
2127 let offset = offset_holder
2128 .borrow()
2129 .as_ref()
2130 .copied()
2131 .expect("offset state should be captured");
2132 offset.set_value(32.0);
2133
2134 let handle = composition.runtime_handle();
2135 let mut applier = composition.applier_mut();
2136 applier.set_runtime_handle(handle);
2137 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2138 assert!(
2139 report.applied,
2140 "dirty graphics layer should be replaceable from retained applier state"
2141 );
2142 assert!(
2143 !report.hit_graph_dirty,
2144 "a moved visual-only layer should not force hit graph refresh"
2145 );
2146 applier.clear_runtime_handle();
2147
2148 let updated_origin = find_layer_by_node_id(&graph.root, node_id)
2149 .expect("updated graphics layer")
2150 .transform_to_parent
2151 .map_point(Point::default());
2152 assert!(
2153 (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
2154 "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
2155 );
2156 }
2157
2158 #[test]
2159 fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
2160 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2161 Rc::new(RefCell::new(None));
2162 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2163 let offset_holder_for_comp = offset_holder.clone();
2164 let node_id_holder_for_comp = node_id_holder.clone();
2165
2166 let mut composition = cranpose_ui::run_test_composition(move || {
2167 let offset = cranpose_core::useState(|| 0.0f32);
2168 *offset_holder_for_comp.borrow_mut() = Some(offset);
2169 let node_id = cranpose_ui::Box(
2170 Modifier::empty()
2171 .size_points(40.0, 20.0)
2172 .graphics_layer(move || GraphicsLayer {
2173 translation_x: offset.get(),
2174 ..GraphicsLayer::default()
2175 })
2176 .clickable(|_| {}),
2177 cranpose_ui::BoxSpec::default(),
2178 || {},
2179 );
2180 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2181 });
2182
2183 let root = composition.root().expect("composition root");
2184 let viewport = Size {
2185 width: 120.0,
2186 height: 80.0,
2187 };
2188 let handle = composition.runtime_handle();
2189 let mut applier = composition.applier_mut();
2190 applier.set_runtime_handle(handle);
2191 applier
2192 .compute_layout(root, viewport)
2193 .expect("initial layout");
2194 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2195 let node_id = node_id_holder
2196 .borrow()
2197 .expect("graphics layer node id should be captured");
2198 applier.clear_runtime_handle();
2199 drop(applier);
2200
2201 let offset = offset_holder
2202 .borrow()
2203 .as_ref()
2204 .copied()
2205 .expect("offset state should be captured");
2206 offset.set_value(32.0);
2207
2208 let handle = composition.runtime_handle();
2209 let mut applier = composition.applier_mut();
2210 applier.set_runtime_handle(handle);
2211 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2212 applier.clear_runtime_handle();
2213
2214 assert!(
2215 report.applied,
2216 "dirty clickable graphics layer should be replaceable from retained applier state"
2217 );
2218 assert!(
2219 report.hit_graph_dirty,
2220 "moved clickable layers must refresh hit geometry"
2221 );
2222 }
2223
2224 #[test]
2225 fn overlay_draw_commands_are_tagged_after_children() {
2226 let child = BuildNodeSnapshot {
2227 node_id: 2,
2228 placement: Point { x: 4.0, y: 5.0 },
2229 size: Size {
2230 width: 20.0,
2231 height: 10.0,
2232 },
2233 content_offset: Point::default(),
2234 motion_context_animated: false,
2235 translated_content_context: false,
2236 measured_max_width: None,
2237 resolved_modifiers: ResolvedModifiers::default(),
2238 draw_commands: vec![],
2239 click_actions: vec![],
2240 pointer_inputs: vec![],
2241 clip_to_bounds: false,
2242 annotated_text: None,
2243 text_style: None,
2244 text_layout_options: None,
2245 text_pan: None,
2246 graphics_layer: None,
2247 children: vec![],
2248 };
2249 let behind = DrawCommand::Behind(Rc::new(|_size: Size| {
2250 vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2251 rect: Rect {
2252 x: 1.0,
2253 y: 2.0,
2254 width: 8.0,
2255 height: 6.0,
2256 },
2257 brush: Brush::solid(Color::WHITE),
2258 }]
2259 }));
2260 let overlay = DrawCommand::Overlay(Rc::new(|_size: Size| {
2261 vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2262 rect: Rect {
2263 x: 3.0,
2264 y: 1.0,
2265 width: 5.0,
2266 height: 4.0,
2267 },
2268 brush: Brush::solid(Color::BLACK),
2269 }]
2270 }));
2271
2272 let parent = BuildNodeSnapshot {
2273 node_id: 1,
2274 placement: Point::default(),
2275 size: Size {
2276 width: 80.0,
2277 height: 50.0,
2278 },
2279 content_offset: Point::default(),
2280 motion_context_animated: false,
2281 translated_content_context: false,
2282 measured_max_width: None,
2283 resolved_modifiers: ResolvedModifiers::default(),
2284 draw_commands: vec![behind, overlay],
2285 click_actions: vec![],
2286 pointer_inputs: vec![],
2287 clip_to_bounds: false,
2288 annotated_text: None,
2289 text_style: None,
2290 text_layout_options: None,
2291 text_pan: None,
2292 graphics_layer: None,
2293 children: vec![child],
2294 };
2295
2296 let graph = build_layer_node_for_test(parent, 1.0, false);
2297 let RenderNode::Primitive(behind) = &graph.children[0] else {
2298 panic!("expected before-children primitive");
2299 };
2300 let RenderNode::Layer(_) = &graph.children[1] else {
2301 panic!("expected child layer");
2302 };
2303 let RenderNode::Primitive(overlay) = &graph.children[2] else {
2304 panic!("expected after-children primitive");
2305 };
2306
2307 assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
2308 assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
2309 }
2310
2311 #[test]
2312 fn stored_content_hash_changes_when_child_transform_changes() {
2313 let child = BuildNodeSnapshot {
2314 node_id: 2,
2315 placement: Point { x: 4.0, y: 5.0 },
2316 size: Size {
2317 width: 20.0,
2318 height: 10.0,
2319 },
2320 content_offset: Point::default(),
2321 motion_context_animated: false,
2322 translated_content_context: false,
2323 measured_max_width: None,
2324 resolved_modifiers: ResolvedModifiers::default(),
2325 draw_commands: vec![],
2326 click_actions: vec![],
2327 pointer_inputs: vec![],
2328 clip_to_bounds: false,
2329 annotated_text: None,
2330 text_style: None,
2331 text_layout_options: None,
2332 text_pan: None,
2333 graphics_layer: None,
2334 children: vec![],
2335 };
2336 let mut moved_child = child.clone();
2337 moved_child.placement.x += 7.0;
2338
2339 let parent = BuildNodeSnapshot {
2340 node_id: 1,
2341 placement: Point::default(),
2342 size: Size {
2343 width: 80.0,
2344 height: 50.0,
2345 },
2346 content_offset: Point::default(),
2347 motion_context_animated: false,
2348 translated_content_context: false,
2349 measured_max_width: None,
2350 resolved_modifiers: ResolvedModifiers::default(),
2351 draw_commands: vec![],
2352 click_actions: vec![],
2353 pointer_inputs: vec![],
2354 clip_to_bounds: false,
2355 annotated_text: None,
2356 text_style: None,
2357 text_layout_options: None,
2358 text_pan: None,
2359 graphics_layer: None,
2360 children: vec![child],
2361 };
2362 let moved_parent = BuildNodeSnapshot {
2363 children: vec![moved_child],
2364 ..parent.clone()
2365 };
2366
2367 let static_graph = build_layer_node_for_test(parent, 1.0, false);
2368 let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
2369
2370 assert_ne!(
2371 static_graph.target_content_hash(),
2372 moved_graph.target_content_hash(),
2373 "moving a child within the parent must invalidate the parent subtree hash"
2374 );
2375 }
2376
2377 #[test]
2378 fn stored_effect_hash_tracks_local_effect_only() {
2379 let base = BuildNodeSnapshot {
2380 node_id: 1,
2381 placement: Point::default(),
2382 size: Size {
2383 width: 80.0,
2384 height: 50.0,
2385 },
2386 content_offset: Point::default(),
2387 motion_context_animated: false,
2388 translated_content_context: false,
2389 measured_max_width: None,
2390 resolved_modifiers: ResolvedModifiers::default(),
2391 draw_commands: vec![],
2392 click_actions: vec![],
2393 pointer_inputs: vec![],
2394 clip_to_bounds: false,
2395 annotated_text: None,
2396 text_style: None,
2397 text_layout_options: None,
2398 text_pan: None,
2399 graphics_layer: None,
2400 children: vec![],
2401 };
2402 let mut effected = base.clone();
2403 effected.graphics_layer = Some(GraphicsLayer {
2404 render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
2405 ..GraphicsLayer::default()
2406 });
2407
2408 let base_graph = build_layer_node_for_test(base, 1.0, false);
2409 let effected_graph = build_layer_node_for_test(effected, 1.0, false);
2410
2411 assert_eq!(
2412 base_graph.target_content_hash(),
2413 effected_graph.target_content_hash(),
2414 "post-processing effect parameters belong to the effect hash, not the content hash"
2415 );
2416 assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
2417 }
2418
2419 #[test]
2420 fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
2421 let mut text_style = TextStyle::default();
2422 text_style.paragraph_style.text_align = TextAlign::Start;
2423 text_style.paragraph_style.text_direction = TextDirection::Rtl;
2424 text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
2425
2426 let snapshot = BuildNodeSnapshot {
2427 node_id: 1,
2428 placement: Point::default(),
2429 size: Size {
2430 width: 180.0,
2431 height: 48.0,
2432 },
2433 content_offset: Point::default(),
2434 motion_context_animated: false,
2435 translated_content_context: false,
2436 measured_max_width: Some(180.0),
2437 resolved_modifiers: ResolvedModifiers::default(),
2438 draw_commands: vec![],
2439 click_actions: vec![],
2440 pointer_inputs: vec![],
2441 clip_to_bounds: false,
2442 annotated_text: Some(AnnotatedString::from("rtl")),
2443 text_style: Some(text_style),
2444 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2445 overflow: cranpose_ui::TextOverflow::Clip,
2446 ..Default::default()
2447 }),
2448 text_pan: None,
2449 graphics_layer: None,
2450 children: vec![],
2451 };
2452
2453 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2454 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2455 panic!("expected text primitive");
2456 };
2457 let PrimitiveNode::Text(text) = &text_primitive.node else {
2458 panic!("expected text primitive");
2459 };
2460 let clip = text
2461 .clip
2462 .expect("clipped overflow should produce a clip rect");
2463
2464 assert!(
2465 text.rect.x > 0.0,
2466 "RTL start alignment should shift the text rect within the available width"
2467 );
2468 assert!(
2469 clip.y < text.rect.y,
2470 "baseline shift must expand the clip upward so superscript glyphs are preserved"
2471 );
2472 assert!(
2473 clip.intersect(text.rect).is_some(),
2474 "the clip rect must intersect the shifted text draw rect"
2475 );
2476 }
2477
2478 #[test]
2479 fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
2480 let snapshot = BuildNodeSnapshot {
2481 node_id: 1,
2482 placement: Point::default(),
2483 size: Size {
2484 width: 320.0,
2485 height: 48.0,
2486 },
2487 content_offset: Point::default(),
2488 motion_context_animated: false,
2489 translated_content_context: false,
2490 measured_max_width: Some(320.0),
2491 resolved_modifiers: ResolvedModifiers::default(),
2492 draw_commands: vec![],
2493 click_actions: vec![],
2494 pointer_inputs: vec![],
2495 clip_to_bounds: false,
2496 annotated_text: Some(AnnotatedString::from("short")),
2497 text_style: Some(TextStyle::default()),
2498 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2499 overflow: cranpose_ui::TextOverflow::Clip,
2500 ..Default::default()
2501 }),
2502 text_pan: None,
2503 graphics_layer: None,
2504 children: vec![],
2505 };
2506
2507 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2508 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2509 panic!("expected text primitive");
2510 };
2511 let PrimitiveNode::Text(text) = &text_primitive.node else {
2512 panic!("expected text primitive");
2513 };
2514 let clip = text.clip.expect("clipped text should keep a clip rect");
2515
2516 assert!(
2517 text.rect.width < 320.0,
2518 "text raster bounds should track measured glyph width instead of full content width"
2519 );
2520 assert_eq!(
2521 clip.width, 322.0,
2522 "text clip should still preserve the full content box plus clip padding"
2523 );
2524 }
2525
2526 #[test]
2530 fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
2531 let pan_offset = 25.0_f32;
2532 let field_width = 80.0_f32;
2533 let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
2534 let viewports = resolved_viewports.clone();
2535 let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
2536 node_id: 1,
2537 placement: Point::default(),
2538 size: Size {
2539 width: field_width,
2540 height: 24.0,
2541 },
2542 content_offset: Point::default(),
2543 motion_context_animated: false,
2544 translated_content_context: false,
2545 measured_max_width: Some(field_width),
2546 resolved_modifiers: ResolvedModifiers::default(),
2547 draw_commands: vec![],
2548 click_actions: vec![],
2549 pointer_inputs: vec![],
2550 clip_to_bounds: false,
2551 annotated_text: Some(AnnotatedString::from(
2552 "a very long single line of text that cannot fit",
2553 )),
2554 text_style: Some(TextStyle::default()),
2555 text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
2556 text_pan,
2557 graphics_layer: None,
2558 children: vec![],
2559 };
2560
2561 let text_node = |snapshot: BuildNodeSnapshot| {
2562 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2563 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2564 panic!("expected text primitive");
2565 };
2566 let PrimitiveNode::Text(text) = &text_primitive.node else {
2567 panic!("expected text primitive");
2568 };
2569 (**text).clone()
2570 };
2571
2572 let unpanned = text_node(make_snapshot(None));
2573 let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
2574 viewports.borrow_mut().push(viewport);
2575 pan_offset
2576 }))));
2577
2578 assert_eq!(
2579 resolved_viewports.borrow().as_slice(),
2580 &[field_width],
2581 "the pan resolver must receive the content viewport width"
2582 );
2583 assert_eq!(
2584 panned.rect.x, -pan_offset,
2585 "text glyphs must shift left by the pan offset"
2586 );
2587 assert!(
2588 panned.rect.width > field_width,
2589 "panned single-line text must be laid out unconstrained, got {}",
2590 panned.rect.width
2591 );
2592 assert!(
2593 panned.rect.width >= unpanned.rect.width,
2594 "unconstrained layout must not be narrower than wrapped layout"
2595 );
2596 assert!(
2597 panned.rect.height <= unpanned.rect.height,
2598 "single-line layout must not wrap onto extra lines"
2599 );
2600 let clip = panned
2601 .clip
2602 .expect("panned text field must clip to field bounds");
2603 assert!(
2604 clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
2605 "clip must not extend past the field bounds, got {clip:?}"
2606 );
2607 }
2608
2609 #[test]
2610 fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
2611 let child = BuildNodeSnapshot {
2612 node_id: 2,
2613 placement: Point { x: 11.0, y: 7.0 },
2614 size: Size {
2615 width: 120.0,
2616 height: 32.0,
2617 },
2618 content_offset: Point::default(),
2619 motion_context_animated: false,
2620 translated_content_context: false,
2621 measured_max_width: Some(120.0),
2622 resolved_modifiers: ResolvedModifiers::default(),
2623 draw_commands: vec![],
2624 click_actions: vec![],
2625 pointer_inputs: vec![],
2626 clip_to_bounds: false,
2627 annotated_text: Some(AnnotatedString::from("scrolling")),
2628 text_style: Some(TextStyle::default()),
2629 text_layout_options: None,
2630 text_pan: None,
2631 graphics_layer: None,
2632 children: vec![],
2633 };
2634 let parent = BuildNodeSnapshot {
2635 node_id: 1,
2636 placement: Point::default(),
2637 size: Size {
2638 width: 160.0,
2639 height: 64.0,
2640 },
2641 content_offset: Point { x: 0.0, y: -18.5 },
2642 motion_context_animated: false,
2643 translated_content_context: true,
2644 measured_max_width: None,
2645 resolved_modifiers: ResolvedModifiers::default(),
2646 draw_commands: vec![],
2647 click_actions: vec![],
2648 pointer_inputs: vec![],
2649 clip_to_bounds: false,
2650 annotated_text: None,
2651 text_style: None,
2652 text_layout_options: None,
2653 text_pan: None,
2654 graphics_layer: None,
2655 children: vec![child],
2656 };
2657
2658 let graph = build_layer_node_for_test(parent, 1.0, false);
2659 let RenderNode::Layer(child_layer) = &graph.children[0] else {
2660 panic!("expected child layer");
2661 };
2662 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2663 panic!("expected text primitive");
2664 };
2665 let PrimitiveNode::Text(text) = &text_primitive.node else {
2666 panic!("expected text primitive");
2667 };
2668
2669 assert_eq!(text.text_style.paragraph_style.text_motion, None);
2670 assert!(!child_layer.motion_context_animated);
2671 }
2672
2673 #[test]
2674 fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
2675 let child = BuildNodeSnapshot {
2676 node_id: 2,
2677 placement: Point { x: 11.0, y: 7.0 },
2678 size: Size {
2679 width: 120.0,
2680 height: 32.0,
2681 },
2682 content_offset: Point::default(),
2683 motion_context_animated: false,
2684 translated_content_context: false,
2685 measured_max_width: Some(120.0),
2686 resolved_modifiers: ResolvedModifiers::default(),
2687 draw_commands: vec![],
2688 click_actions: vec![],
2689 pointer_inputs: vec![],
2690 clip_to_bounds: false,
2691 annotated_text: Some(AnnotatedString::from("scrolling")),
2692 text_style: Some(TextStyle::default()),
2693 text_layout_options: None,
2694 text_pan: None,
2695 graphics_layer: None,
2696 children: vec![],
2697 };
2698 let parent = BuildNodeSnapshot {
2699 node_id: 1,
2700 placement: Point::default(),
2701 size: Size {
2702 width: 160.0,
2703 height: 64.0,
2704 },
2705 content_offset: Point { x: 0.0, y: -18.0 },
2706 motion_context_animated: false,
2707 translated_content_context: false,
2708 measured_max_width: None,
2709 resolved_modifiers: ResolvedModifiers::default(),
2710 draw_commands: vec![],
2711 click_actions: vec![],
2712 pointer_inputs: vec![],
2713 clip_to_bounds: false,
2714 annotated_text: None,
2715 text_style: None,
2716 text_layout_options: None,
2717 text_pan: None,
2718 graphics_layer: None,
2719 children: vec![child],
2720 };
2721
2722 let graph = build_layer_node_for_test(parent, 1.0, false);
2723 let RenderNode::Layer(child_layer) = &graph.children[0] else {
2724 panic!("expected child layer");
2725 };
2726 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2727 panic!("expected text primitive");
2728 };
2729 let PrimitiveNode::Text(text) = &text_primitive.node else {
2730 panic!("expected text primitive");
2731 };
2732
2733 assert_eq!(
2734 text.text_style.paragraph_style.text_motion, None,
2735 "content_offset alone must not force text onto the translated-content motion path"
2736 );
2737 assert!(!child_layer.motion_context_animated);
2738 }
2739
2740 #[test]
2741 fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
2742 let child = BuildNodeSnapshot {
2743 node_id: 2,
2744 placement: Point { x: 11.0, y: 7.0 },
2745 size: Size {
2746 width: 120.0,
2747 height: 32.0,
2748 },
2749 content_offset: Point::default(),
2750 motion_context_animated: false,
2751 translated_content_context: false,
2752 measured_max_width: Some(120.0),
2753 resolved_modifiers: ResolvedModifiers::default(),
2754 draw_commands: vec![],
2755 click_actions: vec![],
2756 pointer_inputs: vec![],
2757 clip_to_bounds: false,
2758 annotated_text: Some(AnnotatedString::from("shadow")),
2759 text_style: Some(TextStyle::from_span_style(SpanStyle {
2760 shadow: Some(cranpose_ui::text::Shadow {
2761 color: Color::BLACK,
2762 offset: Point::new(1.0, 2.0),
2763 blur_radius: 3.0,
2764 }),
2765 ..SpanStyle::default()
2766 })),
2767 text_layout_options: None,
2768 text_pan: None,
2769 graphics_layer: None,
2770 children: vec![],
2771 };
2772 let parent = BuildNodeSnapshot {
2773 node_id: 1,
2774 placement: Point::default(),
2775 size: Size {
2776 width: 160.0,
2777 height: 64.0,
2778 },
2779 content_offset: Point { x: 0.0, y: -18.5 },
2780 motion_context_animated: false,
2781 translated_content_context: true,
2782 measured_max_width: None,
2783 resolved_modifiers: ResolvedModifiers::default(),
2784 draw_commands: vec![],
2785 click_actions: vec![],
2786 pointer_inputs: vec![],
2787 clip_to_bounds: false,
2788 annotated_text: None,
2789 text_style: None,
2790 text_layout_options: None,
2791 text_pan: None,
2792 graphics_layer: None,
2793 children: vec![child],
2794 };
2795
2796 let graph = build_layer_node_for_test(parent, 1.0, false);
2797 let RenderNode::Layer(child_layer) = &graph.children[0] else {
2798 panic!("expected child layer");
2799 };
2800 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2801 panic!("expected text primitive");
2802 };
2803 let PrimitiveNode::Text(text) = &text_primitive.node else {
2804 panic!("expected text primitive");
2805 };
2806
2807 assert_eq!(text.text_style.paragraph_style.text_motion, None);
2808 }
2809
2810 #[test]
2811 fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
2812 let child = BuildNodeSnapshot {
2813 node_id: 2,
2814 placement: Point { x: 11.0, y: 7.0 },
2815 size: Size {
2816 width: 120.0,
2817 height: 32.0,
2818 },
2819 content_offset: Point::default(),
2820 motion_context_animated: false,
2821 translated_content_context: false,
2822 measured_max_width: Some(120.0),
2823 resolved_modifiers: ResolvedModifiers::default(),
2824 draw_commands: vec![],
2825 click_actions: vec![],
2826 pointer_inputs: vec![],
2827 clip_to_bounds: false,
2828 annotated_text: Some(AnnotatedString::from("lazy")),
2829 text_style: Some(TextStyle::default()),
2830 text_layout_options: None,
2831 text_pan: None,
2832 graphics_layer: None,
2833 children: vec![],
2834 };
2835 let parent = BuildNodeSnapshot {
2836 node_id: 1,
2837 placement: Point::default(),
2838 size: Size {
2839 width: 160.0,
2840 height: 64.0,
2841 },
2842 content_offset: Point::default(),
2843 motion_context_animated: true,
2844 translated_content_context: false,
2845 measured_max_width: None,
2846 resolved_modifiers: ResolvedModifiers::default(),
2847 draw_commands: vec![],
2848 click_actions: vec![],
2849 pointer_inputs: vec![],
2850 clip_to_bounds: false,
2851 annotated_text: None,
2852 text_style: None,
2853 text_layout_options: None,
2854 text_pan: None,
2855 graphics_layer: None,
2856 children: vec![child],
2857 };
2858
2859 let graph = build_layer_node_for_test(parent, 1.0, false);
2860 let RenderNode::Layer(child_layer) = &graph.children[0] else {
2861 panic!("expected child layer");
2862 };
2863 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2864 panic!("expected text primitive");
2865 };
2866 let PrimitiveNode::Text(text) = &text_primitive.node else {
2867 panic!("expected text primitive");
2868 };
2869
2870 assert_eq!(text.text_style.paragraph_style.text_motion, None);
2871 assert!(graph.motion_context_animated);
2872 assert!(child_layer.motion_context_animated);
2873 }
2874
2875 #[test]
2876 fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
2877 let mut composition = cranpose_ui::run_test_composition(|| {
2878 let list_state = remember_lazy_list_state();
2879 LazyColumn(
2880 Modifier::empty(),
2881 list_state,
2882 LazyColumnSpec::default(),
2883 |scope| {
2884 scope.item(Some(0), None, || {
2885 Text("LazyMotion", Modifier::empty(), TextStyle::default());
2886 });
2887 },
2888 );
2889 });
2890
2891 let root = composition.root().expect("lazy column root");
2892 let handle = composition.runtime_handle();
2893 let mut applier = composition.applier_mut();
2894 applier.set_runtime_handle(handle);
2895 let _ = applier
2896 .compute_layout(
2897 root,
2898 Size {
2899 width: 240.0,
2900 height: 240.0,
2901 },
2902 )
2903 .expect("lazy column layout");
2904 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2905 applier.clear_runtime_handle();
2906
2907 assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
2908 }
2909
2910 #[test]
2911 fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
2912 use std::cell::RefCell;
2913 use std::rc::Rc;
2914
2915 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
2916 let state_holder_for_comp = state_holder.clone();
2917 let mut composition = cranpose_ui::run_test_composition(move || {
2918 let list_state = remember_lazy_list_state();
2919 *state_holder_for_comp.borrow_mut() = Some(list_state);
2920 LazyColumn(
2921 Modifier::empty().height(120.0),
2922 list_state,
2923 LazyColumnSpec::default(),
2924 |scope| {
2925 scope.items(
2926 8,
2927 None::<fn(usize) -> u64>,
2928 None::<fn(usize) -> u64>,
2929 |index| {
2930 Text(
2931 format!("LazyMotion {index}"),
2932 Modifier::empty().padding(4.0),
2933 TextStyle::default(),
2934 );
2935 },
2936 );
2937 },
2938 );
2939 });
2940
2941 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
2942 list_state.scroll_to_item(3, 0.0);
2943
2944 let root = composition.root().expect("lazy column root");
2945 let handle = composition.runtime_handle();
2946 let mut applier = composition.applier_mut();
2947 applier.set_runtime_handle(handle);
2948 let _ = applier
2949 .compute_layout(
2950 root,
2951 Size {
2952 width: 240.0,
2953 height: 240.0,
2954 },
2955 )
2956 .expect("lazy column layout");
2957 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2958 let active_children = applier
2959 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
2960 .expect("lazy column should be subcompose");
2961 let child_debug: Vec<String> = active_children
2962 .iter()
2963 .map(|&child_id| {
2964 if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
2965 format!(
2966 "layout#{child_id} placed={} text={:?} children={:?}",
2967 node.layout_state().is_placed,
2968 node.modifier_slices_snapshot()
2969 .text_content()
2970 .map(str::to_string),
2971 node.children.clone()
2972 )
2973 }) {
2974 summary
2975 } else if let Ok(summary) =
2976 applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
2977 format!(
2978 "subcompose#{child_id} placed={} active_children={:?}",
2979 node.layout_state().is_placed,
2980 node.active_children()
2981 )
2982 })
2983 {
2984 summary
2985 } else {
2986 format!("missing#{child_id}")
2987 }
2988 })
2989 .collect();
2990 applier.clear_runtime_handle();
2991
2992 let first_index = list_state.first_visible_item_index();
2993 assert!(
2994 first_index > 0,
2995 "lazy list should move away from origin before graph building, observed first_index={first_index}"
2996 );
2997 let mut labels = Vec::new();
2998 collect_text_labels(&graph.root, &mut labels);
2999 assert_eq!(
3000 find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
3001 Some(None),
3002 "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
3003 labels,
3004 active_children,
3005 child_debug
3006 );
3007 }
3008
3009 #[test]
3010 fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
3011 use std::cell::RefCell;
3012 use std::rc::Rc;
3013
3014 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3015 let state_holder_for_comp = state_holder.clone();
3016 let mut composition = cranpose_ui::run_test_composition(move || {
3017 let list_state = remember_lazy_list_state();
3018 *state_holder_for_comp.borrow_mut() = Some(list_state);
3019 let mut spec =
3020 LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
3021 spec.beyond_bounds_item_count = 0;
3022 LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
3023 scope.items(
3024 12,
3025 None::<fn(usize) -> u64>,
3026 None::<fn(usize) -> u64>,
3027 |index| {
3028 Text(
3029 format!("WarmRow {index}"),
3030 Modifier::empty().height(32.0),
3031 TextStyle::default(),
3032 );
3033 },
3034 );
3035 });
3036 });
3037
3038 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3039 list_state.scroll_to_item(4, 0.0);
3040
3041 let root = composition.root().expect("lazy column root");
3042 let handle = composition.runtime_handle();
3043 let mut applier = composition.applier_mut();
3044 applier.set_runtime_handle(handle);
3045 let _ = applier
3046 .compute_layout(
3047 root,
3048 Size {
3049 width: 240.0,
3050 height: 240.0,
3051 },
3052 )
3053 .expect("lazy column layout");
3054 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3055 let active_children = applier
3056 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3057 .expect("lazy column should be subcompose");
3058 applier.clear_runtime_handle();
3059
3060 let visible_indices: Vec<_> = list_state
3061 .layout_info()
3062 .visible_items_info
3063 .iter()
3064 .map(|item| item.index)
3065 .collect();
3066 let mut labels = Vec::new();
3067 collect_text_labels(&graph.root, &mut labels);
3068
3069 assert_eq!(
3070 visible_indices,
3071 vec![4, 5, 6],
3072 "test setup expects exactly three viewport-visible rows"
3073 );
3074 assert!(
3075 labels.iter().any(|label| label == "WarmRow 7"),
3076 "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
3077 );
3078 }
3079
3080 #[test]
3081 fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
3082 use std::cell::RefCell;
3083 use std::rc::Rc;
3084
3085 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3086 let state_holder_for_comp = state_holder.clone();
3087 let mut composition = cranpose_ui::run_test_composition(move || {
3088 let list_state = remember_lazy_list_state();
3089 *state_holder_for_comp.borrow_mut() = Some(list_state);
3090 LazyColumn(
3091 Modifier::empty().height(120.0),
3092 list_state,
3093 LazyColumnSpec::default(),
3094 |scope| {
3095 scope.items(
3096 8,
3097 None::<fn(usize) -> u64>,
3098 None::<fn(usize) -> u64>,
3099 |index| {
3100 Text(
3101 format!("LazySnap {index}"),
3102 Modifier::empty().padding(4.0),
3103 TextStyle::default(),
3104 );
3105 },
3106 );
3107 },
3108 );
3109 });
3110
3111 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3112 list_state.scroll_to_item(2, 7.5);
3113
3114 let root = composition.root().expect("lazy column root");
3115 let handle = composition.runtime_handle();
3116 let mut applier = composition.applier_mut();
3117 applier.set_runtime_handle(handle);
3118 let _ = applier
3119 .compute_layout(
3120 root,
3121 Size {
3122 width: 240.0,
3123 height: 240.0,
3124 },
3125 )
3126 .expect("lazy column layout");
3127 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3128 applier.clear_runtime_handle();
3129
3130 let layout_info = list_state.layout_info();
3131 let first_visible_offset = layout_info
3132 .visible_items_info
3133 .first()
3134 .expect("lazy layout should expose visible item info")
3135 .offset;
3136 let snap_offset = find_translated_content_offset(&graph.root)
3137 .expect("lazy list graph should include translated content context");
3138
3139 assert!(
3140 (snap_offset.y - first_visible_offset).abs() <= 0.001,
3141 "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
3142 );
3143 }
3144
3145 #[test]
3146 fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
3147 let child = BuildNodeSnapshot {
3148 node_id: 2,
3149 placement: Point { x: 11.0, y: 7.0 },
3150 size: Size {
3151 width: 120.0,
3152 height: 32.0,
3153 },
3154 content_offset: Point::default(),
3155 motion_context_animated: false,
3156 translated_content_context: false,
3157 measured_max_width: Some(120.0),
3158 resolved_modifiers: ResolvedModifiers::default(),
3159 draw_commands: vec![],
3160 click_actions: vec![],
3161 pointer_inputs: vec![],
3162 clip_to_bounds: false,
3163 annotated_text: Some(AnnotatedString::from("static")),
3164 text_style: Some(TextStyle::from_paragraph_style(
3165 cranpose_ui::text::ParagraphStyle {
3166 text_motion: Some(TextMotion::Static),
3167 ..Default::default()
3168 },
3169 )),
3170 text_layout_options: None,
3171 text_pan: None,
3172 graphics_layer: None,
3173 children: vec![],
3174 };
3175 let parent = BuildNodeSnapshot {
3176 node_id: 1,
3177 placement: Point::default(),
3178 size: Size {
3179 width: 160.0,
3180 height: 64.0,
3181 },
3182 content_offset: Point { x: 0.0, y: -18.5 },
3183 motion_context_animated: false,
3184 translated_content_context: true,
3185 measured_max_width: None,
3186 resolved_modifiers: ResolvedModifiers::default(),
3187 draw_commands: vec![],
3188 click_actions: vec![],
3189 pointer_inputs: vec![],
3190 clip_to_bounds: false,
3191 annotated_text: None,
3192 text_style: None,
3193 text_layout_options: None,
3194 text_pan: None,
3195 graphics_layer: None,
3196 children: vec![child],
3197 };
3198
3199 let graph = build_layer_node_for_test(parent, 1.0, false);
3200 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3201 panic!("expected child layer");
3202 };
3203 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3204 panic!("expected text primitive");
3205 };
3206 let PrimitiveNode::Text(text) = &text_primitive.node else {
3207 panic!("expected text primitive");
3208 };
3209
3210 assert_eq!(
3211 text.text_style.paragraph_style.text_motion,
3212 Some(TextMotion::Static),
3213 "explicit text motion must win over inherited scrolling motion context"
3214 );
3215 }
3216}