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