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