1use std::rc::Rc;
2
3use cranpose_core::{MemoryApplier, NodeId};
4use cranpose_ui::text::AnnotatedString;
5use cranpose_ui::text::{resolve_text_direction, TextAlign, TextStyle};
6use cranpose_ui::{
7 prepare_text_layout, DrawCommand, LayoutBox, LayoutNode, ModifierNodeSlices, Point, Rect,
8 ResolvedModifiers, Size, SubcomposeLayoutNode, TextLayoutOptions, TextOverflow,
9};
10use cranpose_ui_graphics::{
11 rounded_corner_alpha_mask_effect, CompositingStrategy, GraphicsLayer, RoundedCornerShape,
12};
13
14use crate::graph::{
15 CachePolicy, DrawPrimitiveNode, HitTestNode, IsolationReasons, LayerNode, PrimitiveEntry,
16 PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode, TextPrimitiveNode,
17};
18use crate::layer_transform::layer_transform_to_parent;
19use crate::raster_cache::LayerRasterCacheHashes;
20use crate::style_shared::{primitives_for_placement, DrawPlacement};
21
22const TEXT_CLIP_PAD: f32 = 1.0;
23const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
24
25#[derive(Clone)]
26struct BuildNodeSnapshot {
27 node_id: NodeId,
28 placement: Point,
29 size: Size,
30 content_offset: Point,
31 motion_context_animated: bool,
32 translated_content_context: bool,
33 measured_max_width: Option<f32>,
34 resolved_modifiers: ResolvedModifiers,
35 draw_commands: Vec<DrawCommand>,
36 click_actions: Vec<Rc<dyn Fn(Point)>>,
37 pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
38 clip_to_bounds: bool,
39 annotated_text: Option<AnnotatedString>,
40 text_style: Option<TextStyle>,
41 text_layout_options: Option<TextLayoutOptions>,
42 graphics_layer: Option<GraphicsLayer>,
43 children: Vec<Self>,
44}
45
46struct SnapshotNodeData {
47 layout_state: cranpose_ui::widgets::LayoutState,
48 modifier_slices: Rc<ModifierNodeSlices>,
49 resolved_modifiers: ResolvedModifiers,
50 children: Vec<NodeId>,
51}
52
53pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
54 let root_snapshot = layout_box_to_snapshot(root, None);
55 RenderGraph {
56 root: build_layer_node(root_snapshot, scale, false),
57 }
58}
59
60pub fn build_graph_from_applier(
61 applier: &mut MemoryApplier,
62 root: NodeId,
63 scale: f32,
64) -> Option<RenderGraph> {
65 Some(RenderGraph {
66 root: build_layer_node_from_applier(applier, root, scale, false)?,
67 })
68}
69
70fn build_layer_node(
71 snapshot: BuildNodeSnapshot,
72 _root_scale: f32,
73 inherited_motion_context_animated: bool,
74) -> LayerNode {
75 build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
76}
77
78fn build_layer_node_internal(
79 snapshot: BuildNodeSnapshot,
80 inherited_motion_context_animated: bool,
81 inherited_translated_content_context: bool,
82) -> LayerNode {
83 let BuildNodeSnapshot {
84 node_id,
85 placement,
86 size,
87 content_offset,
88 motion_context_animated,
89 translated_content_context,
90 measured_max_width,
91 resolved_modifiers,
92 draw_commands,
93 click_actions,
94 pointer_inputs,
95 clip_to_bounds,
96 annotated_text,
97 text_style,
98 text_layout_options,
99 graphics_layer,
100 children: child_snapshots,
101 } = snapshot;
102 let local_bounds = Rect {
103 x: 0.0,
104 y: 0.0,
105 width: size.width,
106 height: size.height,
107 };
108 let graphics_layer = graphics_layer.unwrap_or_default();
109 let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
110 let isolation = isolation_reasons(&graphics_layer);
111 let cache_policy = if isolation.has_any() {
112 CachePolicy::Auto
113 } else {
114 CachePolicy::None
115 };
116 let shadow_clip = clip_to_bounds.then_some(local_bounds);
117 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
118 shape: None,
119 click_actions,
120 pointer_inputs,
121 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
122 });
123
124 let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
125 let child_translated_content_context =
126 inherited_translated_content_context || translated_content_context;
127
128 let mut children = draw_nodes(
129 &draw_commands,
130 DrawPlacement::Behind,
131 size,
132 PrimitivePhase::BeforeChildren,
133 );
134 if let Some(text) = text_node_from_parts(TextNodeParts {
135 node_id,
136 local_bounds,
137 measured_max_width,
138 resolved_modifiers: &resolved_modifiers,
139 annotated_text: annotated_text.as_ref(),
140 text_style: text_style.as_ref(),
141 text_layout_options,
142 modifier_slices: None,
143 }) {
144 children.push(RenderNode::Primitive(PrimitiveEntry {
145 phase: PrimitivePhase::BeforeChildren,
146 node: PrimitiveNode::Text(Box::new(text)),
147 }));
148 }
149 let child_motion_context_animated = node_motion_context_animated;
150 for child in child_snapshots {
151 let mut child_layer = build_layer_node_internal(
152 child,
153 child_motion_context_animated,
154 child_translated_content_context,
155 );
156 if content_offset != Point::default() {
157 child_layer.transform_to_parent =
158 child_layer
159 .transform_to_parent
160 .then(ProjectiveTransform::translation(
161 content_offset.x,
162 content_offset.y,
163 ));
164 }
165 children.push(RenderNode::Layer(Box::new(child_layer)));
166 }
167 children.extend(draw_nodes(
168 &draw_commands,
169 DrawPlacement::Overlay,
170 size,
171 PrimitivePhase::AfterChildren,
172 ));
173 let has_hit_targets = hit_test.is_some()
174 || children.iter().any(|child| match child {
175 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
176 RenderNode::Primitive(_) => false,
177 });
178
179 LayerNode {
180 node_id: Some(node_id),
181 local_bounds,
182 transform_to_parent,
183 motion_context_animated: node_motion_context_animated,
184 translated_content_context,
185 translated_content_offset: if translated_content_context {
186 content_offset
187 } else {
188 Point::default()
189 },
190 graphics_layer,
191 clip_to_bounds,
192 shadow_clip,
193 hit_test,
194 has_hit_targets,
195 isolation,
196 cache_policy,
197 cache_hashes: LayerRasterCacheHashes::default(),
198 cache_hashes_valid: false,
199 children,
200 }
201}
202
203fn build_layer_node_from_applier(
204 applier: &mut MemoryApplier,
205 node_id: NodeId,
206 _root_scale: f32,
207 inherited_motion_context_animated: bool,
208) -> Option<LayerNode> {
209 build_layer_node_from_applier_internal(
210 applier,
211 node_id,
212 inherited_motion_context_animated,
213 false,
214 )
215}
216
217fn build_layer_node_from_applier_internal(
218 applier: &mut MemoryApplier,
219 node_id: NodeId,
220 inherited_motion_context_animated: bool,
221 inherited_translated_content_context: bool,
222) -> Option<LayerNode> {
223 if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
224 let state = node.layout_state();
225 let children = node.children.clone();
226 let modifier_slices = node.modifier_slices_snapshot();
227 SnapshotNodeData {
228 layout_state: state,
229 modifier_slices,
230 resolved_modifiers: node.resolved_modifiers(),
231 children,
232 }
233 }) {
234 return build_layer_node_from_data(
235 applier,
236 node_id,
237 data,
238 inherited_motion_context_animated,
239 inherited_translated_content_context,
240 );
241 }
242
243 if let Ok(data) = applier.with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
244 let state = node.layout_state();
245 let children = node.active_children();
246 let modifier_slices = node.modifier_slices_snapshot();
247 SnapshotNodeData {
248 layout_state: state,
249 modifier_slices,
250 resolved_modifiers: node.resolved_modifiers(),
251 children,
252 }
253 }) {
254 return build_layer_node_from_data(
255 applier,
256 node_id,
257 data,
258 inherited_motion_context_animated,
259 inherited_translated_content_context,
260 );
261 }
262
263 None
264}
265
266fn build_layer_node_from_data(
267 applier: &mut MemoryApplier,
268 node_id: NodeId,
269 data: SnapshotNodeData,
270 inherited_motion_context_animated: bool,
271 inherited_translated_content_context: bool,
272) -> Option<LayerNode> {
273 let SnapshotNodeData {
274 layout_state,
275 modifier_slices,
276 resolved_modifiers,
277 children,
278 } = data;
279 if !layout_state.is_placed {
280 return None;
281 }
282
283 let local_bounds = Rect {
284 x: 0.0,
285 y: 0.0,
286 width: layout_state.size.width,
287 height: layout_state.size.height,
288 };
289 let clip_to_bounds = modifier_slices.clip_to_bounds();
290 let graphics_layer = graphics_layer_with_shaped_clip(
291 modifier_slices.graphics_layer().unwrap_or_default(),
292 clip_to_bounds,
293 modifier_slices.corner_shape(),
294 local_bounds,
295 );
296 let transform_to_parent =
297 layer_transform_to_parent(local_bounds, layout_state.position, &graphics_layer);
298 let isolation = isolation_reasons(&graphics_layer);
299 let cache_policy = if isolation.has_any() {
300 CachePolicy::Auto
301 } else {
302 CachePolicy::None
303 };
304 let click_actions = modifier_slices.click_handlers();
305 let pointer_inputs = modifier_slices.pointer_inputs();
306 let shadow_clip = clip_to_bounds.then_some(local_bounds);
307 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
308 shape: None,
309 click_actions: click_actions.to_vec(),
310 pointer_inputs: pointer_inputs.to_vec(),
311 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
312 });
313
314 let node_motion_context_animated =
315 inherited_motion_context_animated || modifier_slices.motion_context_animated();
316 let local_translated_content_context = modifier_slices.translated_content_context();
317 let local_translated_content_offset = modifier_slices
318 .translated_content_offset()
319 .unwrap_or(layout_state.content_offset);
320 let child_translated_content_context =
321 inherited_translated_content_context || local_translated_content_context;
322
323 let mut render_children = draw_nodes(
324 modifier_slices.draw_commands(),
325 DrawPlacement::Behind,
326 layout_state.size,
327 PrimitivePhase::BeforeChildren,
328 );
329 if let Some(text) = text_node_from_parts(TextNodeParts {
330 node_id,
331 local_bounds,
332 measured_max_width: layout_state
333 .measurement_constraints
334 .max_width
335 .is_finite()
336 .then_some(layout_state.measurement_constraints.max_width),
337 resolved_modifiers: &resolved_modifiers,
338 annotated_text: modifier_slices.annotated_text(),
339 text_style: modifier_slices.text_style(),
340 text_layout_options: modifier_slices.text_layout_options(),
341 modifier_slices: Some(modifier_slices.as_ref()),
342 }) {
343 render_children.push(RenderNode::Primitive(PrimitiveEntry {
344 phase: PrimitivePhase::BeforeChildren,
345 node: PrimitiveNode::Text(Box::new(text)),
346 }));
347 }
348 let child_motion_context_animated = node_motion_context_animated;
349 for child_id in children {
350 let Some(mut child_layer) = build_layer_node_from_applier_internal(
351 applier,
352 child_id,
353 child_motion_context_animated,
354 child_translated_content_context,
355 ) else {
356 continue;
357 };
358 if layout_state.content_offset != Point::default() {
359 child_layer.transform_to_parent =
360 child_layer
361 .transform_to_parent
362 .then(ProjectiveTransform::translation(
363 layout_state.content_offset.x,
364 layout_state.content_offset.y,
365 ));
366 }
367 render_children.push(RenderNode::Layer(Box::new(child_layer)));
368 }
369 render_children.extend(draw_nodes(
370 modifier_slices.draw_commands(),
371 DrawPlacement::Overlay,
372 layout_state.size,
373 PrimitivePhase::AfterChildren,
374 ));
375 let has_hit_targets = hit_test.is_some()
376 || render_children.iter().any(|child| match child {
377 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
378 RenderNode::Primitive(_) => false,
379 });
380
381 let layer = LayerNode {
382 node_id: Some(node_id),
383 local_bounds,
384 transform_to_parent,
385 motion_context_animated: node_motion_context_animated,
386 translated_content_context: local_translated_content_context,
387 translated_content_offset: if local_translated_content_context {
388 local_translated_content_offset
389 } else {
390 Point::default()
391 },
392 graphics_layer,
393 clip_to_bounds,
394 shadow_clip,
395 hit_test,
396 has_hit_targets,
397 isolation,
398 cache_policy,
399 cache_hashes: LayerRasterCacheHashes::default(),
400 cache_hashes_valid: false,
401 children: render_children,
402 };
403 Some(layer)
404}
405
406fn draw_nodes(
407 commands: &[DrawCommand],
408 placement: DrawPlacement,
409 size: Size,
410 phase: PrimitivePhase,
411) -> Vec<RenderNode> {
412 let mut nodes = Vec::new();
413 for command in commands {
414 for primitive in primitives_for_placement(command, placement, size) {
415 nodes.push(RenderNode::Primitive(PrimitiveEntry {
416 phase,
417 node: PrimitiveNode::Draw(DrawPrimitiveNode {
418 primitive,
419 clip: None,
420 }),
421 }));
422 }
423 }
424 nodes
425}
426
427struct TextNodeParts<'a> {
428 node_id: NodeId,
429 local_bounds: Rect,
430 measured_max_width: Option<f32>,
431 resolved_modifiers: &'a ResolvedModifiers,
432 annotated_text: Option<&'a AnnotatedString>,
433 text_style: Option<&'a TextStyle>,
434 text_layout_options: Option<TextLayoutOptions>,
435 modifier_slices: Option<&'a ModifierNodeSlices>,
436}
437
438fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
439 let TextNodeParts {
440 node_id,
441 local_bounds,
442 measured_max_width,
443 resolved_modifiers,
444 annotated_text,
445 text_style,
446 text_layout_options,
447 modifier_slices,
448 } = parts;
449 let value = annotated_text?;
450 let default_text_style = TextStyle::default();
451 let text_style = text_style.cloned().unwrap_or(default_text_style);
452 let options = text_layout_options.unwrap_or_default().normalized();
453 let padding = resolved_modifiers.padding();
454 let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
455 if content_width <= 0.0 {
456 return None;
457 }
458
459 let measure_width =
460 resolve_text_measure_width(content_width, padding, measured_max_width, options);
461 let max_width = Some(measure_width).filter(|width| width.is_finite() && *width > 0.0);
462 let prepared = modifier_slices
463 .and_then(|slices| slices.prepare_text_layout(max_width))
464 .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
465 let visual_style = prepared.visual_style.clone();
466 let draw_width = if options.overflow == TextOverflow::Visible {
467 prepared.metrics.width
468 } else {
469 content_width
470 };
471 let alignment_offset = resolve_text_horizontal_offset(
472 &text_style,
473 prepared.text.text.as_str(),
474 content_width,
475 prepared.metrics.width,
476 );
477 let rect = Rect {
478 x: padding.left + alignment_offset,
479 y: padding.top,
480 width: draw_width,
481 height: prepared.metrics.height,
482 };
483 let text_bounds = Rect {
484 x: padding.left,
485 y: padding.top,
486 width: content_width,
487 height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
488 };
489 let font_size = visual_style.resolve_font_size(14.0);
490 let expanded_bounds =
491 expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
492 let clip = if options.overflow == TextOverflow::Visible {
493 None
494 } else {
495 Some(pad_clip_rect(expanded_bounds))
496 };
497
498 Some(TextPrimitiveNode {
499 node_id,
500 rect,
501 text: prepared.text,
502 text_style: visual_style,
503 font_size,
504 layout_options: options,
505 clip,
506 })
507}
508
509fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
510 let placement = parent
511 .map(|parent_box| Point {
512 x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
513 y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
514 })
515 .unwrap_or_default();
516 let mut children = Vec::with_capacity(node.children.len());
517 for child in &node.children {
518 children.push(layout_box_to_snapshot(child, Some(node)));
519 }
520 let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
521 let graphics_layer = graphics_layer_with_shaped_clip(
522 base_graphics_layer.clone().unwrap_or_default(),
523 node.node_data.modifier_slices.clip_to_bounds(),
524 node.node_data.modifier_slices.corner_shape(),
525 Rect {
526 x: 0.0,
527 y: 0.0,
528 width: node.rect.width,
529 height: node.rect.height,
530 },
531 );
532 let has_graphics_layer =
533 base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
534
535 BuildNodeSnapshot {
536 node_id: node.node_id,
537 placement,
538 size: Size {
539 width: node.rect.width,
540 height: node.rect.height,
541 },
542 content_offset: node.content_offset,
543 motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
544 translated_content_context: node.node_data.modifier_slices.translated_content_context(),
545 measured_max_width: None,
546 resolved_modifiers: node.node_data.resolved_modifiers,
547 draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
548 click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
549 pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
550 clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
551 annotated_text: node.node_data.modifier_slices.annotated_string(),
552 text_style: node.node_data.modifier_slices.text_style().cloned(),
553 text_layout_options: node.node_data.modifier_slices.text_layout_options(),
554 graphics_layer: has_graphics_layer.then_some(graphics_layer),
555 children,
556 }
557}
558
559fn graphics_layer_with_shaped_clip(
560 mut graphics_layer: GraphicsLayer,
561 clip_to_bounds: bool,
562 corner_shape: Option<RoundedCornerShape>,
563 local_bounds: Rect,
564) -> GraphicsLayer {
565 if !clip_to_bounds {
566 return graphics_layer;
567 }
568
569 let Some(corner_shape) = corner_shape else {
570 return graphics_layer;
571 };
572 let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
573 if radii.top_left <= f32::EPSILON
574 && radii.top_right <= f32::EPSILON
575 && radii.bottom_right <= f32::EPSILON
576 && radii.bottom_left <= f32::EPSILON
577 {
578 return graphics_layer;
579 }
580
581 let rounded_clip = rounded_corner_alpha_mask_effect(
582 local_bounds.width,
583 local_bounds.height,
584 radii,
585 ROUNDED_CLIP_EDGE_FEATHER,
586 );
587 graphics_layer.render_effect = Some(match graphics_layer.render_effect.take() {
588 Some(existing) => existing.then(rounded_clip),
589 None => rounded_clip,
590 });
591 graphics_layer
592}
593
594fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
595 IsolationReasons {
596 explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
597 effect: layer.render_effect.is_some(),
598 backdrop: layer.backdrop_effect.is_some(),
599 group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
600 && layer.alpha < 1.0,
601 blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
602 }
603}
604
605fn pad_clip_rect(rect: Rect) -> Rect {
606 Rect {
607 x: rect.x - TEXT_CLIP_PAD,
608 y: rect.y - TEXT_CLIP_PAD,
609 width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
610 height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
611 }
612}
613
614fn expand_text_bounds_for_baseline_shift(
615 text_bounds: Rect,
616 text_style: &TextStyle,
617 font_size: f32,
618) -> Rect {
619 let baseline_shift_px = text_style
620 .span_style
621 .baseline_shift
622 .filter(|shift| shift.is_specified())
623 .map(|shift| -(shift.0 * font_size))
624 .unwrap_or(0.0);
625 if baseline_shift_px == 0.0 {
626 return text_bounds;
627 }
628
629 if baseline_shift_px < 0.0 {
630 Rect {
631 x: text_bounds.x,
632 y: text_bounds.y + baseline_shift_px,
633 width: text_bounds.width,
634 height: (text_bounds.height - baseline_shift_px).max(0.0),
635 }
636 } else {
637 Rect {
638 x: text_bounds.x,
639 y: text_bounds.y,
640 width: text_bounds.width,
641 height: (text_bounds.height + baseline_shift_px).max(0.0),
642 }
643 }
644}
645
646fn resolve_text_measure_width(
647 content_width: f32,
648 padding: cranpose_ui::EdgeInsets,
649 measured_max_width: Option<f32>,
650 options: TextLayoutOptions,
651) -> f32 {
652 let available = measured_max_width
653 .map(|max_width| (max_width - padding.left - padding.right).max(0.0))
654 .unwrap_or(content_width);
655 if options.soft_wrap || options.max_lines != 1 || options.overflow == TextOverflow::Clip {
656 available.min(content_width)
657 } else {
658 content_width
659 }
660}
661
662fn resolve_text_horizontal_offset(
663 text_style: &TextStyle,
664 text: &str,
665 content_width: f32,
666 measured_width: f32,
667) -> f32 {
668 let remaining = (content_width - measured_width).max(0.0);
669 let paragraph_style = &text_style.paragraph_style;
670 let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
671 match paragraph_style.text_align {
672 TextAlign::Center => remaining * 0.5,
673 TextAlign::End | TextAlign::Right => remaining,
674 TextAlign::Start | TextAlign::Left | TextAlign::Justify => {
675 if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
676 remaining
677 } else {
678 0.0
679 }
680 }
681 TextAlign::Unspecified => {
682 if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
683 remaining
684 } else {
685 0.0
686 }
687 }
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use std::rc::Rc;
694
695 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
696 use cranpose_ui::text::{
697 AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
698 };
699 use cranpose_ui::{
700 Color, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec, Modifier, Point, Rect,
701 ResolvedModifiers, RoundedCornerShape, Size, Text, TextStyle,
702 };
703 use cranpose_ui_graphics::{Brush, DrawPrimitive, GraphicsLayer, RenderEffect};
704
705 use super::*;
706
707 fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
708 for child in &layer.children {
709 match child {
710 RenderNode::Primitive(primitive) => {
711 let PrimitiveNode::Text(text) = &primitive.node else {
712 continue;
713 };
714 if text.text.text == label {
715 return Some(text.text_style.paragraph_style.text_motion);
716 }
717 }
718 RenderNode::Layer(child_layer) => {
719 if let Some(motion) = find_text_motion(child_layer, label) {
720 return Some(motion);
721 }
722 }
723 }
724 }
725
726 None
727 }
728
729 fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
730 for child in &layer.children {
731 match child {
732 RenderNode::Primitive(primitive) => {
733 let PrimitiveNode::Text(text) = &primitive.node else {
734 continue;
735 };
736 labels.push(text.text.text.clone());
737 }
738 RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
739 }
740 }
741 }
742
743 fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
744 if layer.translated_content_context {
745 return Some(layer.translated_content_offset);
746 }
747 for child in &layer.children {
748 if let RenderNode::Layer(child_layer) = child {
749 if let Some(offset) = find_translated_content_offset(child_layer) {
750 return Some(offset);
751 }
752 }
753 }
754 None
755 }
756
757 fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
758 layer
759 .graphics_layer
760 .render_effect
761 .as_ref()
762 .is_some_and(RenderEffect::contains_runtime_shader)
763 || layer.children.iter().any(|child| match child {
764 RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
765 RenderNode::Primitive(_) => false,
766 })
767 }
768
769 fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
770 let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
771 vec![DrawPrimitive::Rect {
772 rect: Rect {
773 x: 3.0,
774 y: 4.0,
775 width: 20.0,
776 height: 8.0,
777 },
778 brush: Brush::solid(Color::WHITE),
779 }]
780 }));
781
782 let child = BuildNodeSnapshot {
783 node_id: 2,
784 placement: Point { x: 11.0, y: 7.0 },
785 size: Size {
786 width: 40.0,
787 height: 20.0,
788 },
789 content_offset: Point::default(),
790 motion_context_animated: false,
791 translated_content_context: false,
792 measured_max_width: None,
793 resolved_modifiers: ResolvedModifiers::default(),
794 draw_commands: vec![child_command],
795 click_actions: vec![],
796 pointer_inputs: vec![],
797 clip_to_bounds: false,
798 annotated_text: None,
799 text_style: None,
800 text_layout_options: None,
801 graphics_layer: None,
802 children: vec![],
803 };
804
805 BuildNodeSnapshot {
806 node_id: 1,
807 placement: Point::default(),
808 size: Size {
809 width: 80.0,
810 height: 50.0,
811 },
812 content_offset: Point::default(),
813 motion_context_animated: false,
814 translated_content_context: false,
815 measured_max_width: None,
816 resolved_modifiers: ResolvedModifiers::default(),
817 draw_commands: vec![],
818 click_actions: vec![],
819 pointer_inputs: vec![],
820 clip_to_bounds: false,
821 annotated_text: None,
822 text_style: None,
823 text_layout_options: None,
824 graphics_layer: Some(GraphicsLayer {
825 translation_x: tx,
826 ..GraphicsLayer::default()
827 }),
828 children: vec![child],
829 }
830 }
831
832 #[test]
833 fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
834 let static_graph = build_layer_node(snapshot_with_translation(0.0), 1.0, false);
835 let moved_graph = build_layer_node(snapshot_with_translation(23.5), 1.0, false);
836
837 let RenderNode::Layer(static_child) = &static_graph.children[0] else {
838 panic!("expected child layer");
839 };
840 let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
841 panic!("expected child layer");
842 };
843 let RenderNode::Primitive(static_draw) = &static_child.children[0] else {
844 panic!("expected draw primitive");
845 };
846 let PrimitiveNode::Draw(static_draw) = &static_draw.node else {
847 panic!("expected draw primitive");
848 };
849 let RenderNode::Primitive(moved_draw) = &moved_child.children[0] else {
850 panic!("expected draw primitive");
851 };
852 let PrimitiveNode::Draw(moved_draw) = &moved_draw.node else {
853 panic!("expected draw primitive");
854 };
855
856 assert_ne!(
857 static_graph.transform_to_parent, moved_graph.transform_to_parent,
858 "parent transform should encode translation"
859 );
860 assert_eq!(
861 static_draw, moved_draw,
862 "child local primitive geometry must stay stable under parent translation"
863 );
864 }
865
866 #[test]
867 fn stored_content_hash_ignores_parent_translation() {
868 let static_graph = build_layer_node(snapshot_with_translation(0.0), 1.0, false);
869 let moved_graph = build_layer_node(snapshot_with_translation(23.5), 1.0, false);
870
871 assert_eq!(
872 static_graph.target_content_hash(),
873 moved_graph.target_content_hash(),
874 "parent rigid motion must not invalidate the subtree content hash"
875 );
876 }
877
878 #[test]
879 fn parent_content_offset_is_encoded_in_child_transform() {
880 let child = BuildNodeSnapshot {
881 node_id: 2,
882 placement: Point { x: 11.0, y: 7.0 },
883 size: Size {
884 width: 40.0,
885 height: 20.0,
886 },
887 content_offset: Point::default(),
888 motion_context_animated: false,
889 translated_content_context: false,
890 measured_max_width: None,
891 resolved_modifiers: ResolvedModifiers::default(),
892 draw_commands: vec![],
893 click_actions: vec![],
894 pointer_inputs: vec![],
895 clip_to_bounds: false,
896 annotated_text: None,
897 text_style: None,
898 text_layout_options: None,
899 graphics_layer: None,
900 children: vec![],
901 };
902
903 let parent = BuildNodeSnapshot {
904 node_id: 1,
905 placement: Point::default(),
906 size: Size {
907 width: 80.0,
908 height: 50.0,
909 },
910 content_offset: Point { x: 13.0, y: -9.0 },
911 motion_context_animated: false,
912 translated_content_context: false,
913 measured_max_width: None,
914 resolved_modifiers: ResolvedModifiers::default(),
915 draw_commands: vec![],
916 click_actions: vec![],
917 pointer_inputs: vec![],
918 clip_to_bounds: false,
919 annotated_text: None,
920 text_style: None,
921 text_layout_options: None,
922 graphics_layer: None,
923 children: vec![child],
924 };
925
926 let graph = build_layer_node(parent, 1.0, false);
927 let RenderNode::Layer(child) = &graph.children[0] else {
928 panic!("expected child layer");
929 };
930
931 let top_left = child.transform_to_parent.map_point(Point::default());
932 assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
933 }
934
935 #[test]
936 fn rounded_clip_to_bounds_injects_per_corner_mask_effect() {
937 let layer = graphics_layer_with_shaped_clip(
938 GraphicsLayer::default(),
939 true,
940 Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
941 Rect {
942 x: 0.0,
943 y: 0.0,
944 width: 100.0,
945 height: 40.0,
946 },
947 );
948
949 let Some(RenderEffect::Shader { shader }) = layer.render_effect else {
950 panic!("rounded clip must emit a shader mask");
951 };
952 let uniforms = shader.uniforms();
953 assert_eq!(uniforms[0], 100.0);
954 assert_eq!(uniforms[1], 40.0);
955 assert_eq!(uniforms[2], ROUNDED_CLIP_EDGE_FEATHER);
956 assert_eq!(uniforms[3], 4.0);
957 assert_eq!(uniforms[4], 8.0);
958 assert_eq!(uniforms[5], 12.0);
959 assert_eq!(uniforms[6], 16.0);
960 }
961
962 #[test]
963 fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
964 let existing = RenderEffect::blur(3.0);
965 let layer = graphics_layer_with_shaped_clip(
966 GraphicsLayer {
967 render_effect: Some(existing.clone()),
968 ..GraphicsLayer::default()
969 },
970 true,
971 Some(RoundedCornerShape::uniform(10.0)),
972 Rect {
973 x: 0.0,
974 y: 0.0,
975 width: 100.0,
976 height: 40.0,
977 },
978 );
979
980 let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
981 panic!("existing effect should chain into rounded clip mask");
982 };
983 assert_eq!(*first, existing);
984 assert!(
985 matches!(*second, RenderEffect::Shader { .. }),
986 "rounded mask must be the outer effect"
987 );
988 }
989
990 #[test]
991 fn rounded_corners_clip_to_bounds_builds_graph_mask_from_modifier_chain() {
992 let mut composition = cranpose_ui::run_test_composition(|| {
993 cranpose_ui::Box(
994 Modifier::empty()
995 .width(100.0)
996 .height(40.0)
997 .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
998 .clip_to_bounds(),
999 cranpose_ui::BoxSpec::default(),
1000 || {
1001 Text("rounded child", Modifier::empty(), TextStyle::default());
1002 },
1003 );
1004 });
1005
1006 let root = composition.root().expect("rounded clip root");
1007 let handle = composition.runtime_handle();
1008 let mut applier = composition.applier_mut();
1009 applier.set_runtime_handle(handle);
1010 applier
1011 .compute_layout(
1012 root,
1013 Size {
1014 width: 160.0,
1015 height: 100.0,
1016 },
1017 )
1018 .expect("rounded clip layout");
1019 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1020 applier.clear_runtime_handle();
1021
1022 assert!(
1023 graph_has_runtime_shader_effect(&graph.root),
1024 "rounded_corners().clip_to_bounds() must build a shaped mask effect for descendants"
1025 );
1026 }
1027
1028 #[test]
1029 fn overlay_draw_commands_are_tagged_after_children() {
1030 let child = BuildNodeSnapshot {
1031 node_id: 2,
1032 placement: Point { x: 4.0, y: 5.0 },
1033 size: Size {
1034 width: 20.0,
1035 height: 10.0,
1036 },
1037 content_offset: Point::default(),
1038 motion_context_animated: false,
1039 translated_content_context: false,
1040 measured_max_width: None,
1041 resolved_modifiers: ResolvedModifiers::default(),
1042 draw_commands: vec![],
1043 click_actions: vec![],
1044 pointer_inputs: vec![],
1045 clip_to_bounds: false,
1046 annotated_text: None,
1047 text_style: None,
1048 text_layout_options: None,
1049 graphics_layer: None,
1050 children: vec![],
1051 };
1052 let behind = DrawCommand::Behind(Rc::new(|_size: Size| {
1053 vec![cranpose_ui_graphics::DrawPrimitive::Rect {
1054 rect: Rect {
1055 x: 1.0,
1056 y: 2.0,
1057 width: 8.0,
1058 height: 6.0,
1059 },
1060 brush: Brush::solid(Color::WHITE),
1061 }]
1062 }));
1063 let overlay = DrawCommand::Overlay(Rc::new(|_size: Size| {
1064 vec![cranpose_ui_graphics::DrawPrimitive::Rect {
1065 rect: Rect {
1066 x: 3.0,
1067 y: 1.0,
1068 width: 5.0,
1069 height: 4.0,
1070 },
1071 brush: Brush::solid(Color::BLACK),
1072 }]
1073 }));
1074
1075 let parent = BuildNodeSnapshot {
1076 node_id: 1,
1077 placement: Point::default(),
1078 size: Size {
1079 width: 80.0,
1080 height: 50.0,
1081 },
1082 content_offset: Point::default(),
1083 motion_context_animated: false,
1084 translated_content_context: false,
1085 measured_max_width: None,
1086 resolved_modifiers: ResolvedModifiers::default(),
1087 draw_commands: vec![behind, overlay],
1088 click_actions: vec![],
1089 pointer_inputs: vec![],
1090 clip_to_bounds: false,
1091 annotated_text: None,
1092 text_style: None,
1093 text_layout_options: None,
1094 graphics_layer: None,
1095 children: vec![child],
1096 };
1097
1098 let graph = build_layer_node(parent, 1.0, false);
1099 let RenderNode::Primitive(behind) = &graph.children[0] else {
1100 panic!("expected before-children primitive");
1101 };
1102 let RenderNode::Layer(_) = &graph.children[1] else {
1103 panic!("expected child layer");
1104 };
1105 let RenderNode::Primitive(overlay) = &graph.children[2] else {
1106 panic!("expected after-children primitive");
1107 };
1108
1109 assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
1110 assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
1111 }
1112
1113 #[test]
1114 fn stored_content_hash_changes_when_child_transform_changes() {
1115 let child = BuildNodeSnapshot {
1116 node_id: 2,
1117 placement: Point { x: 4.0, y: 5.0 },
1118 size: Size {
1119 width: 20.0,
1120 height: 10.0,
1121 },
1122 content_offset: Point::default(),
1123 motion_context_animated: false,
1124 translated_content_context: false,
1125 measured_max_width: None,
1126 resolved_modifiers: ResolvedModifiers::default(),
1127 draw_commands: vec![],
1128 click_actions: vec![],
1129 pointer_inputs: vec![],
1130 clip_to_bounds: false,
1131 annotated_text: None,
1132 text_style: None,
1133 text_layout_options: None,
1134 graphics_layer: None,
1135 children: vec![],
1136 };
1137 let mut moved_child = child.clone();
1138 moved_child.placement.x += 7.0;
1139
1140 let parent = BuildNodeSnapshot {
1141 node_id: 1,
1142 placement: Point::default(),
1143 size: Size {
1144 width: 80.0,
1145 height: 50.0,
1146 },
1147 content_offset: Point::default(),
1148 motion_context_animated: false,
1149 translated_content_context: false,
1150 measured_max_width: None,
1151 resolved_modifiers: ResolvedModifiers::default(),
1152 draw_commands: vec![],
1153 click_actions: vec![],
1154 pointer_inputs: vec![],
1155 clip_to_bounds: false,
1156 annotated_text: None,
1157 text_style: None,
1158 text_layout_options: None,
1159 graphics_layer: None,
1160 children: vec![child],
1161 };
1162 let moved_parent = BuildNodeSnapshot {
1163 children: vec![moved_child],
1164 ..parent.clone()
1165 };
1166
1167 let static_graph = build_layer_node(parent, 1.0, false);
1168 let moved_graph = build_layer_node(moved_parent, 1.0, false);
1169
1170 assert_ne!(
1171 static_graph.target_content_hash(),
1172 moved_graph.target_content_hash(),
1173 "moving a child within the parent must invalidate the parent subtree hash"
1174 );
1175 }
1176
1177 #[test]
1178 fn stored_effect_hash_tracks_local_effect_only() {
1179 let base = BuildNodeSnapshot {
1180 node_id: 1,
1181 placement: Point::default(),
1182 size: Size {
1183 width: 80.0,
1184 height: 50.0,
1185 },
1186 content_offset: Point::default(),
1187 motion_context_animated: false,
1188 translated_content_context: false,
1189 measured_max_width: None,
1190 resolved_modifiers: ResolvedModifiers::default(),
1191 draw_commands: vec![],
1192 click_actions: vec![],
1193 pointer_inputs: vec![],
1194 clip_to_bounds: false,
1195 annotated_text: None,
1196 text_style: None,
1197 text_layout_options: None,
1198 graphics_layer: None,
1199 children: vec![],
1200 };
1201 let mut effected = base.clone();
1202 effected.graphics_layer = Some(GraphicsLayer {
1203 render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
1204 ..GraphicsLayer::default()
1205 });
1206
1207 let base_graph = build_layer_node(base, 1.0, false);
1208 let effected_graph = build_layer_node(effected, 1.0, false);
1209
1210 assert_eq!(
1211 base_graph.target_content_hash(),
1212 effected_graph.target_content_hash(),
1213 "post-processing effect parameters belong to the effect hash, not the content hash"
1214 );
1215 assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
1216 }
1217
1218 #[test]
1219 fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
1220 let mut text_style = TextStyle::default();
1221 text_style.paragraph_style.text_align = TextAlign::Start;
1222 text_style.paragraph_style.text_direction = TextDirection::Rtl;
1223 text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
1224
1225 let snapshot = BuildNodeSnapshot {
1226 node_id: 1,
1227 placement: Point::default(),
1228 size: Size {
1229 width: 180.0,
1230 height: 48.0,
1231 },
1232 content_offset: Point::default(),
1233 motion_context_animated: false,
1234 translated_content_context: false,
1235 measured_max_width: Some(180.0),
1236 resolved_modifiers: ResolvedModifiers::default(),
1237 draw_commands: vec![],
1238 click_actions: vec![],
1239 pointer_inputs: vec![],
1240 clip_to_bounds: false,
1241 annotated_text: Some(AnnotatedString::from("rtl")),
1242 text_style: Some(text_style),
1243 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
1244 overflow: cranpose_ui::TextOverflow::Clip,
1245 ..Default::default()
1246 }),
1247 graphics_layer: None,
1248 children: vec![],
1249 };
1250
1251 let graph = build_layer_node(snapshot, 1.0, false);
1252 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
1253 panic!("expected text primitive");
1254 };
1255 let PrimitiveNode::Text(text) = &text_primitive.node else {
1256 panic!("expected text primitive");
1257 };
1258 let clip = text
1259 .clip
1260 .expect("clipped overflow should produce a clip rect");
1261
1262 assert!(
1263 text.rect.x > 0.0,
1264 "RTL start alignment should shift the text rect within the available width"
1265 );
1266 assert!(
1267 clip.y < text.rect.y,
1268 "baseline shift must expand the clip upward so superscript glyphs are preserved"
1269 );
1270 assert!(
1271 clip.intersect(text.rect).is_some(),
1272 "the clip rect must intersect the shifted text draw rect"
1273 );
1274 }
1275
1276 #[test]
1277 fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
1278 let child = BuildNodeSnapshot {
1279 node_id: 2,
1280 placement: Point { x: 11.0, y: 7.0 },
1281 size: Size {
1282 width: 120.0,
1283 height: 32.0,
1284 },
1285 content_offset: Point::default(),
1286 motion_context_animated: false,
1287 translated_content_context: false,
1288 measured_max_width: Some(120.0),
1289 resolved_modifiers: ResolvedModifiers::default(),
1290 draw_commands: vec![],
1291 click_actions: vec![],
1292 pointer_inputs: vec![],
1293 clip_to_bounds: false,
1294 annotated_text: Some(AnnotatedString::from("scrolling")),
1295 text_style: Some(TextStyle::default()),
1296 text_layout_options: None,
1297 graphics_layer: None,
1298 children: vec![],
1299 };
1300 let parent = BuildNodeSnapshot {
1301 node_id: 1,
1302 placement: Point::default(),
1303 size: Size {
1304 width: 160.0,
1305 height: 64.0,
1306 },
1307 content_offset: Point { x: 0.0, y: -18.5 },
1308 motion_context_animated: false,
1309 translated_content_context: true,
1310 measured_max_width: None,
1311 resolved_modifiers: ResolvedModifiers::default(),
1312 draw_commands: vec![],
1313 click_actions: vec![],
1314 pointer_inputs: vec![],
1315 clip_to_bounds: false,
1316 annotated_text: None,
1317 text_style: None,
1318 text_layout_options: None,
1319 graphics_layer: None,
1320 children: vec![child],
1321 };
1322
1323 let graph = build_layer_node(parent, 1.0, false);
1324 let RenderNode::Layer(child_layer) = &graph.children[0] else {
1325 panic!("expected child layer");
1326 };
1327 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1328 panic!("expected text primitive");
1329 };
1330 let PrimitiveNode::Text(text) = &text_primitive.node else {
1331 panic!("expected text primitive");
1332 };
1333
1334 assert_eq!(text.text_style.paragraph_style.text_motion, None);
1335 assert!(!child_layer.motion_context_animated);
1336 }
1337
1338 #[test]
1339 fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
1340 let child = BuildNodeSnapshot {
1341 node_id: 2,
1342 placement: Point { x: 11.0, y: 7.0 },
1343 size: Size {
1344 width: 120.0,
1345 height: 32.0,
1346 },
1347 content_offset: Point::default(),
1348 motion_context_animated: false,
1349 translated_content_context: false,
1350 measured_max_width: Some(120.0),
1351 resolved_modifiers: ResolvedModifiers::default(),
1352 draw_commands: vec![],
1353 click_actions: vec![],
1354 pointer_inputs: vec![],
1355 clip_to_bounds: false,
1356 annotated_text: Some(AnnotatedString::from("scrolling")),
1357 text_style: Some(TextStyle::default()),
1358 text_layout_options: None,
1359 graphics_layer: None,
1360 children: vec![],
1361 };
1362 let parent = BuildNodeSnapshot {
1363 node_id: 1,
1364 placement: Point::default(),
1365 size: Size {
1366 width: 160.0,
1367 height: 64.0,
1368 },
1369 content_offset: Point { x: 0.0, y: -18.0 },
1370 motion_context_animated: false,
1371 translated_content_context: false,
1372 measured_max_width: None,
1373 resolved_modifiers: ResolvedModifiers::default(),
1374 draw_commands: vec![],
1375 click_actions: vec![],
1376 pointer_inputs: vec![],
1377 clip_to_bounds: false,
1378 annotated_text: None,
1379 text_style: None,
1380 text_layout_options: None,
1381 graphics_layer: None,
1382 children: vec![child],
1383 };
1384
1385 let graph = build_layer_node(parent, 1.0, false);
1386 let RenderNode::Layer(child_layer) = &graph.children[0] else {
1387 panic!("expected child layer");
1388 };
1389 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1390 panic!("expected text primitive");
1391 };
1392 let PrimitiveNode::Text(text) = &text_primitive.node else {
1393 panic!("expected text primitive");
1394 };
1395
1396 assert_eq!(
1397 text.text_style.paragraph_style.text_motion, None,
1398 "content_offset alone must not force text onto the translated-content motion path"
1399 );
1400 assert!(!child_layer.motion_context_animated);
1401 }
1402
1403 #[test]
1404 fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
1405 let child = BuildNodeSnapshot {
1406 node_id: 2,
1407 placement: Point { x: 11.0, y: 7.0 },
1408 size: Size {
1409 width: 120.0,
1410 height: 32.0,
1411 },
1412 content_offset: Point::default(),
1413 motion_context_animated: false,
1414 translated_content_context: false,
1415 measured_max_width: Some(120.0),
1416 resolved_modifiers: ResolvedModifiers::default(),
1417 draw_commands: vec![],
1418 click_actions: vec![],
1419 pointer_inputs: vec![],
1420 clip_to_bounds: false,
1421 annotated_text: Some(AnnotatedString::from("shadow")),
1422 text_style: Some(TextStyle::from_span_style(SpanStyle {
1423 shadow: Some(cranpose_ui::text::Shadow {
1424 color: Color::BLACK,
1425 offset: Point::new(1.0, 2.0),
1426 blur_radius: 3.0,
1427 }),
1428 ..SpanStyle::default()
1429 })),
1430 text_layout_options: None,
1431 graphics_layer: None,
1432 children: vec![],
1433 };
1434 let parent = BuildNodeSnapshot {
1435 node_id: 1,
1436 placement: Point::default(),
1437 size: Size {
1438 width: 160.0,
1439 height: 64.0,
1440 },
1441 content_offset: Point { x: 0.0, y: -18.5 },
1442 motion_context_animated: false,
1443 translated_content_context: true,
1444 measured_max_width: None,
1445 resolved_modifiers: ResolvedModifiers::default(),
1446 draw_commands: vec![],
1447 click_actions: vec![],
1448 pointer_inputs: vec![],
1449 clip_to_bounds: false,
1450 annotated_text: None,
1451 text_style: None,
1452 text_layout_options: None,
1453 graphics_layer: None,
1454 children: vec![child],
1455 };
1456
1457 let graph = build_layer_node(parent, 1.0, false);
1458 let RenderNode::Layer(child_layer) = &graph.children[0] else {
1459 panic!("expected child layer");
1460 };
1461 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1462 panic!("expected text primitive");
1463 };
1464 let PrimitiveNode::Text(text) = &text_primitive.node else {
1465 panic!("expected text primitive");
1466 };
1467
1468 assert_eq!(text.text_style.paragraph_style.text_motion, None);
1469 }
1470
1471 #[test]
1472 fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
1473 let child = BuildNodeSnapshot {
1474 node_id: 2,
1475 placement: Point { x: 11.0, y: 7.0 },
1476 size: Size {
1477 width: 120.0,
1478 height: 32.0,
1479 },
1480 content_offset: Point::default(),
1481 motion_context_animated: false,
1482 translated_content_context: false,
1483 measured_max_width: Some(120.0),
1484 resolved_modifiers: ResolvedModifiers::default(),
1485 draw_commands: vec![],
1486 click_actions: vec![],
1487 pointer_inputs: vec![],
1488 clip_to_bounds: false,
1489 annotated_text: Some(AnnotatedString::from("lazy")),
1490 text_style: Some(TextStyle::default()),
1491 text_layout_options: None,
1492 graphics_layer: None,
1493 children: vec![],
1494 };
1495 let parent = BuildNodeSnapshot {
1496 node_id: 1,
1497 placement: Point::default(),
1498 size: Size {
1499 width: 160.0,
1500 height: 64.0,
1501 },
1502 content_offset: Point::default(),
1503 motion_context_animated: true,
1504 translated_content_context: false,
1505 measured_max_width: None,
1506 resolved_modifiers: ResolvedModifiers::default(),
1507 draw_commands: vec![],
1508 click_actions: vec![],
1509 pointer_inputs: vec![],
1510 clip_to_bounds: false,
1511 annotated_text: None,
1512 text_style: None,
1513 text_layout_options: None,
1514 graphics_layer: None,
1515 children: vec![child],
1516 };
1517
1518 let graph = build_layer_node(parent, 1.0, false);
1519 let RenderNode::Layer(child_layer) = &graph.children[0] else {
1520 panic!("expected child layer");
1521 };
1522 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1523 panic!("expected text primitive");
1524 };
1525 let PrimitiveNode::Text(text) = &text_primitive.node else {
1526 panic!("expected text primitive");
1527 };
1528
1529 assert_eq!(text.text_style.paragraph_style.text_motion, None);
1530 assert!(graph.motion_context_animated);
1531 assert!(child_layer.motion_context_animated);
1532 }
1533
1534 #[test]
1535 fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
1536 let mut composition = cranpose_ui::run_test_composition(|| {
1537 let list_state = remember_lazy_list_state();
1538 LazyColumn(
1539 Modifier::empty(),
1540 list_state,
1541 LazyColumnSpec::default(),
1542 |scope| {
1543 scope.item(Some(0), None, || {
1544 Text("LazyMotion", Modifier::empty(), TextStyle::default());
1545 });
1546 },
1547 );
1548 });
1549
1550 let root = composition.root().expect("lazy column root");
1551 let handle = composition.runtime_handle();
1552 let mut applier = composition.applier_mut();
1553 applier.set_runtime_handle(handle);
1554 let _ = applier
1555 .compute_layout(
1556 root,
1557 Size {
1558 width: 240.0,
1559 height: 240.0,
1560 },
1561 )
1562 .expect("lazy column layout");
1563 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
1564 applier.clear_runtime_handle();
1565
1566 assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
1567 }
1568
1569 #[test]
1570 fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
1571 use std::cell::RefCell;
1572 use std::rc::Rc;
1573
1574 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1575 let state_holder_for_comp = state_holder.clone();
1576 let mut composition = cranpose_ui::run_test_composition(move || {
1577 let list_state = remember_lazy_list_state();
1578 *state_holder_for_comp.borrow_mut() = Some(list_state);
1579 LazyColumn(
1580 Modifier::empty().height(120.0),
1581 list_state,
1582 LazyColumnSpec::default(),
1583 |scope| {
1584 scope.items(
1585 8,
1586 None::<fn(usize) -> u64>,
1587 None::<fn(usize) -> u64>,
1588 |index| {
1589 Text(
1590 format!("LazyMotion {index}"),
1591 Modifier::empty().padding(4.0),
1592 TextStyle::default(),
1593 );
1594 },
1595 );
1596 },
1597 );
1598 });
1599
1600 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
1601 list_state.scroll_to_item(3, 0.0);
1602
1603 let root = composition.root().expect("lazy column root");
1604 let handle = composition.runtime_handle();
1605 let mut applier = composition.applier_mut();
1606 applier.set_runtime_handle(handle);
1607 let _ = applier
1608 .compute_layout(
1609 root,
1610 Size {
1611 width: 240.0,
1612 height: 240.0,
1613 },
1614 )
1615 .expect("lazy column layout");
1616 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
1617 let active_children = applier
1618 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
1619 .expect("lazy column should be subcompose");
1620 let child_debug: Vec<String> = active_children
1621 .iter()
1622 .map(|&child_id| {
1623 if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
1624 format!(
1625 "layout#{child_id} placed={} text={:?} children={:?}",
1626 node.layout_state().is_placed,
1627 node.modifier_slices_snapshot()
1628 .text_content()
1629 .map(str::to_string),
1630 node.children.clone()
1631 )
1632 }) {
1633 summary
1634 } else if let Ok(summary) =
1635 applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
1636 format!(
1637 "subcompose#{child_id} placed={} active_children={:?}",
1638 node.layout_state().is_placed,
1639 node.active_children()
1640 )
1641 })
1642 {
1643 summary
1644 } else {
1645 format!("missing#{child_id}")
1646 }
1647 })
1648 .collect();
1649 applier.clear_runtime_handle();
1650
1651 let first_index = list_state.first_visible_item_index();
1652 assert!(
1653 first_index > 0,
1654 "lazy list should move away from origin before graph building, observed first_index={first_index}"
1655 );
1656 let mut labels = Vec::new();
1657 collect_text_labels(&graph.root, &mut labels);
1658 assert_eq!(
1659 find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
1660 Some(None),
1661 "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
1662 labels,
1663 active_children,
1664 child_debug
1665 );
1666 }
1667
1668 #[test]
1669 fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
1670 use std::cell::RefCell;
1671 use std::rc::Rc;
1672
1673 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1674 let state_holder_for_comp = state_holder.clone();
1675 let mut composition = cranpose_ui::run_test_composition(move || {
1676 let list_state = remember_lazy_list_state();
1677 *state_holder_for_comp.borrow_mut() = Some(list_state);
1678 LazyColumn(
1679 Modifier::empty().height(120.0),
1680 list_state,
1681 LazyColumnSpec::default(),
1682 |scope| {
1683 scope.items(
1684 8,
1685 None::<fn(usize) -> u64>,
1686 None::<fn(usize) -> u64>,
1687 |index| {
1688 Text(
1689 format!("LazySnap {index}"),
1690 Modifier::empty().padding(4.0),
1691 TextStyle::default(),
1692 );
1693 },
1694 );
1695 },
1696 );
1697 });
1698
1699 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
1700 list_state.scroll_to_item(2, 7.5);
1701
1702 let root = composition.root().expect("lazy column root");
1703 let handle = composition.runtime_handle();
1704 let mut applier = composition.applier_mut();
1705 applier.set_runtime_handle(handle);
1706 let _ = applier
1707 .compute_layout(
1708 root,
1709 Size {
1710 width: 240.0,
1711 height: 240.0,
1712 },
1713 )
1714 .expect("lazy column layout");
1715 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
1716 applier.clear_runtime_handle();
1717
1718 let layout_info = list_state.layout_info();
1719 let first_visible_offset = layout_info
1720 .visible_items_info
1721 .first()
1722 .expect("lazy layout should expose visible item info")
1723 .offset;
1724 let snap_offset = find_translated_content_offset(&graph.root)
1725 .expect("lazy list graph should include translated content context");
1726
1727 assert!(
1728 (snap_offset.y - first_visible_offset).abs() <= 0.001,
1729 "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
1730 );
1731 }
1732
1733 #[test]
1734 fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
1735 let child = BuildNodeSnapshot {
1736 node_id: 2,
1737 placement: Point { x: 11.0, y: 7.0 },
1738 size: Size {
1739 width: 120.0,
1740 height: 32.0,
1741 },
1742 content_offset: Point::default(),
1743 motion_context_animated: false,
1744 translated_content_context: false,
1745 measured_max_width: Some(120.0),
1746 resolved_modifiers: ResolvedModifiers::default(),
1747 draw_commands: vec![],
1748 click_actions: vec![],
1749 pointer_inputs: vec![],
1750 clip_to_bounds: false,
1751 annotated_text: Some(AnnotatedString::from("static")),
1752 text_style: Some(TextStyle::from_paragraph_style(
1753 cranpose_ui::text::ParagraphStyle {
1754 text_motion: Some(TextMotion::Static),
1755 ..Default::default()
1756 },
1757 )),
1758 text_layout_options: None,
1759 graphics_layer: None,
1760 children: vec![],
1761 };
1762 let parent = BuildNodeSnapshot {
1763 node_id: 1,
1764 placement: Point::default(),
1765 size: Size {
1766 width: 160.0,
1767 height: 64.0,
1768 },
1769 content_offset: Point { x: 0.0, y: -18.5 },
1770 motion_context_animated: false,
1771 translated_content_context: true,
1772 measured_max_width: None,
1773 resolved_modifiers: ResolvedModifiers::default(),
1774 draw_commands: vec![],
1775 click_actions: vec![],
1776 pointer_inputs: vec![],
1777 clip_to_bounds: false,
1778 annotated_text: None,
1779 text_style: None,
1780 text_layout_options: None,
1781 graphics_layer: None,
1782 children: vec![child],
1783 };
1784
1785 let graph = build_layer_node(parent, 1.0, false);
1786 let RenderNode::Layer(child_layer) = &graph.children[0] else {
1787 panic!("expected child layer");
1788 };
1789 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1790 panic!("expected text primitive");
1791 };
1792 let PrimitiveNode::Text(text) = &text_primitive.node else {
1793 panic!("expected text primitive");
1794 };
1795
1796 assert_eq!(
1797 text.text_style.paragraph_style.text_motion,
1798 Some(TextMotion::Static),
1799 "explicit text motion must win over inherited scrolling motion context"
1800 );
1801 }
1802}