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