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