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