Skip to main content

cranpose_render_common/
scene_builder.rs

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