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