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