Skip to main content

cranpose_render_common/
scene_builder.rs

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}