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