Skip to main content

cranpose_render_common/
scene_builder.rs

1use std::collections::HashSet;
2use std::rc::Rc;
3
4use cranpose_core::{MemoryApplier, NodeId};
5use cranpose_ui::text::AnnotatedString;
6use cranpose_ui::text::{resolve_text_direction, TextAlign, TextStyle};
7use cranpose_ui::{
8    prepare_text_layout, DrawCommand, LayoutBox, LayoutNode, ModifierNodeSlices, Point, Rect,
9    ResolvedModifiers, Size, SubcomposeLayoutNode, TextLayoutOptions, TextOverflow,
10};
11use cranpose_ui_graphics::{
12    rounded_corner_alpha_mask_effect, CompositingStrategy, GraphicsLayer, LayerShape,
13    RoundedCornerShape,
14};
15
16use crate::graph::{
17    CachePolicy, DrawPrimitiveNode, HitTestNode, IsolationReasons, LayerNode, PrimitiveEntry,
18    PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode, TextPrimitiveNode,
19};
20use crate::layer_transform::layer_transform_to_parent;
21use crate::raster_cache::LayerRasterCacheHashes;
22use crate::style_shared::{primitives_for_placement, DrawPlacement};
23
24const TEXT_CLIP_PAD: f32 = 1.0;
25const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
26
27#[derive(Clone)]
28struct BuildNodeSnapshot {
29    node_id: NodeId,
30    placement: Point,
31    size: Size,
32    content_offset: Point,
33    motion_context_animated: bool,
34    translated_content_context: bool,
35    measured_max_width: Option<f32>,
36    resolved_modifiers: ResolvedModifiers,
37    draw_commands: Vec<DrawCommand>,
38    click_actions: Vec<Rc<dyn Fn(Point)>>,
39    pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
40    clip_to_bounds: bool,
41    annotated_text: Option<AnnotatedString>,
42    text_style: Option<TextStyle>,
43    text_layout_options: Option<TextLayoutOptions>,
44    graphics_layer: Option<GraphicsLayer>,
45    children: Vec<Self>,
46}
47
48struct SnapshotNodeData {
49    layout_state: cranpose_ui::widgets::LayoutState,
50    modifier_slices: Rc<ModifierNodeSlices>,
51    resolved_modifiers: ResolvedModifiers,
52    children: Vec<NodeId>,
53}
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56pub struct GraphUpdateReport {
57    pub applied: bool,
58    pub hit_graph_dirty: bool,
59}
60
61pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
62    let root_snapshot = layout_box_to_snapshot(root, None);
63    RenderGraph {
64        root: build_layer_node(root_snapshot, scale, false),
65    }
66}
67
68pub fn build_graph_from_applier(
69    applier: &mut MemoryApplier,
70    root: NodeId,
71    scale: f32,
72) -> Option<RenderGraph> {
73    Some(RenderGraph {
74        root: build_layer_node_from_applier(applier, root, scale, false)?,
75    })
76}
77
78pub fn update_graph_from_applier(
79    applier: &mut MemoryApplier,
80    graph: &mut RenderGraph,
81    dirty_nodes: &[NodeId],
82    scale: f32,
83) -> bool {
84    update_graph_from_applier_report(applier, graph, dirty_nodes, scale).applied
85}
86
87pub fn update_graph_from_applier_report(
88    applier: &mut MemoryApplier,
89    graph: &mut RenderGraph,
90    dirty_nodes: &[NodeId],
91    scale: f32,
92) -> GraphUpdateReport {
93    if dirty_nodes.is_empty() {
94        return GraphUpdateReport {
95            applied: true,
96            hit_graph_dirty: false,
97        };
98    }
99
100    let mut remaining_dirty_nodes = dirty_nodes.iter().copied().collect::<HashSet<_>>();
101    if let Some(root_id) = graph.root.node_id {
102        if remaining_dirty_nodes.contains(&root_id) {
103            let Some(root) = build_layer_node_from_applier(applier, root_id, scale, false) else {
104                return GraphUpdateReport {
105                    applied: false,
106                    hit_graph_dirty: true,
107                };
108            };
109            let hit_graph_dirty = layer_hit_graph_state_dirty(&graph.root, &root);
110            graph.root = root;
111            graph.root.recompute_raster_cache_hashes();
112            return GraphUpdateReport {
113                applied: true,
114                hit_graph_dirty,
115            };
116        }
117    }
118
119    let inherited_translated_content_context = graph.root.translated_content_context;
120    let report = match replace_dirty_layers_from_applier(
121        applier,
122        &mut graph.root,
123        &mut remaining_dirty_nodes,
124        inherited_translated_content_context,
125    ) {
126        Some(report) => report,
127        None => {
128            return GraphUpdateReport {
129                applied: false,
130                hit_graph_dirty: true,
131            };
132        }
133    };
134
135    if !remaining_dirty_nodes.is_empty() {
136        return GraphUpdateReport {
137            applied: false,
138            hit_graph_dirty: true,
139        };
140    }
141
142    if report.updated {
143        graph.root.recompute_raster_cache_hashes();
144    }
145    GraphUpdateReport {
146        applied: true,
147        hit_graph_dirty: report.hit_graph_dirty,
148    }
149}
150
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152struct ReplaceDirtyLayersReport {
153    updated: bool,
154    hit_graph_dirty: bool,
155}
156
157fn replace_dirty_layers_from_applier(
158    applier: &mut MemoryApplier,
159    parent: &mut LayerNode,
160    dirty_nodes: &mut HashSet<NodeId>,
161    inherited_translated_content_context: bool,
162) -> Option<ReplaceDirtyLayersReport> {
163    if dirty_nodes.is_empty() {
164        return Some(ReplaceDirtyLayersReport::default());
165    }
166
167    let child_inherited_translated_content_context =
168        inherited_translated_content_context || parent.translated_content_context;
169    let mut report = ReplaceDirtyLayersReport::default();
170
171    for child in &mut parent.children {
172        let RenderNode::Layer(child_layer) = child else {
173            continue;
174        };
175
176        if child_layer
177            .node_id
178            .is_some_and(|node_id| dirty_nodes.remove(&node_id))
179        {
180            let mut replacement = build_layer_node_from_applier_internal(
181                applier,
182                child_layer
183                    .node_id
184                    .expect("dirty layer must have a node id"),
185                parent.motion_context_animated,
186                child_inherited_translated_content_context,
187            )?;
188            if parent.content_offset != Point::default() {
189                replacement.transform_to_parent =
190                    replacement
191                        .transform_to_parent
192                        .then(ProjectiveTransform::translation(
193                            parent.content_offset.x,
194                            parent.content_offset.y,
195                        ));
196            }
197            report.hit_graph_dirty |= layer_hit_graph_state_dirty(child_layer, &replacement);
198            remove_dirty_descendants(&replacement, dirty_nodes);
199            **child_layer = replacement;
200            report.updated = true;
201            continue;
202        }
203
204        match replace_dirty_layers_from_applier(
205            applier,
206            child_layer,
207            dirty_nodes,
208            child_inherited_translated_content_context,
209        ) {
210            Some(child_report) => {
211                report.updated |= child_report.updated;
212                report.hit_graph_dirty |= child_report.hit_graph_dirty;
213            }
214            None => return None,
215        }
216    }
217
218    if report.updated {
219        parent.has_hit_targets = parent.hit_test.is_some()
220            || parent.children.iter().any(|child| match child {
221                RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
222                RenderNode::Primitive(_) => false,
223            });
224    }
225
226    Some(report)
227}
228
229fn layer_hit_graph_state_dirty(previous: &LayerNode, replacement: &LayerNode) -> bool {
230    if previous.hit_test.is_some() || replacement.hit_test.is_some() {
231        return true;
232    }
233
234    if !(previous.has_hit_targets || replacement.has_hit_targets) {
235        return false;
236    }
237
238    previous.has_hit_targets != replacement.has_hit_targets
239        || previous.local_bounds != replacement.local_bounds
240        || previous.transform_to_parent != replacement.transform_to_parent
241        || previous.clip_rect() != replacement.clip_rect()
242        || previous.graphics_layer.shape != replacement.graphics_layer.shape
243}
244
245fn remove_dirty_descendants(layer: &LayerNode, dirty_nodes: &mut HashSet<NodeId>) {
246    for child in &layer.children {
247        let RenderNode::Layer(child_layer) = child else {
248            continue;
249        };
250        if let Some(node_id) = child_layer.node_id {
251            dirty_nodes.remove(&node_id);
252        }
253        remove_dirty_descendants(child_layer, dirty_nodes);
254    }
255}
256
257fn build_layer_node(
258    snapshot: BuildNodeSnapshot,
259    _root_scale: f32,
260    inherited_motion_context_animated: bool,
261) -> LayerNode {
262    build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
263}
264
265fn build_layer_node_internal(
266    snapshot: BuildNodeSnapshot,
267    inherited_motion_context_animated: bool,
268    inherited_translated_content_context: bool,
269) -> LayerNode {
270    let BuildNodeSnapshot {
271        node_id,
272        placement,
273        size,
274        content_offset,
275        motion_context_animated,
276        translated_content_context,
277        measured_max_width,
278        resolved_modifiers,
279        draw_commands,
280        click_actions,
281        pointer_inputs,
282        clip_to_bounds,
283        annotated_text,
284        text_style,
285        text_layout_options,
286        graphics_layer,
287        children: child_snapshots,
288    } = snapshot;
289    let local_bounds = Rect {
290        x: 0.0,
291        y: 0.0,
292        width: size.width,
293        height: size.height,
294    };
295    let graphics_layer = graphics_layer.unwrap_or_default();
296    let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
297    let isolation = isolation_reasons(&graphics_layer);
298    let cache_policy = if isolation.has_any() {
299        CachePolicy::Auto
300    } else {
301        CachePolicy::None
302    };
303    let shadow_clip = clip_to_bounds.then_some(local_bounds);
304    let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
305        shape: None,
306        click_actions,
307        pointer_inputs,
308        clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
309    });
310
311    let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
312    let child_translated_content_context =
313        inherited_translated_content_context || translated_content_context;
314
315    let mut children = draw_nodes(
316        &draw_commands,
317        DrawPlacement::Behind,
318        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,
325        resolved_modifiers: &resolved_modifiers,
326        annotated_text: annotated_text.as_ref(),
327        text_style: text_style.as_ref(),
328        text_layout_options,
329        modifier_slices: None,
330    }) {
331        children.push(RenderNode::Primitive(PrimitiveEntry {
332            phase: PrimitivePhase::BeforeChildren,
333            node: PrimitiveNode::Text(Box::new(text)),
334        }));
335    }
336    let child_motion_context_animated = node_motion_context_animated;
337    for child in child_snapshots {
338        let mut child_layer = build_layer_node_internal(
339            child,
340            child_motion_context_animated,
341            child_translated_content_context,
342        );
343        if content_offset != Point::default() {
344            child_layer.transform_to_parent =
345                child_layer
346                    .transform_to_parent
347                    .then(ProjectiveTransform::translation(
348                        content_offset.x,
349                        content_offset.y,
350                    ));
351        }
352        children.push(RenderNode::Layer(Box::new(child_layer)));
353    }
354    children.extend(draw_nodes(
355        &draw_commands,
356        DrawPlacement::Overlay,
357        size,
358        PrimitivePhase::AfterChildren,
359    ));
360    let has_hit_targets = hit_test.is_some()
361        || children.iter().any(|child| match child {
362            RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
363            RenderNode::Primitive(_) => false,
364        });
365
366    LayerNode {
367        node_id: Some(node_id),
368        local_bounds,
369        transform_to_parent,
370        content_offset,
371        motion_context_animated: node_motion_context_animated,
372        translated_content_context,
373        translated_content_offset: if translated_content_context {
374            content_offset
375        } else {
376            Point::default()
377        },
378        graphics_layer,
379        clip_to_bounds,
380        shadow_clip,
381        hit_test,
382        has_hit_targets,
383        isolation,
384        cache_policy,
385        cache_hashes: LayerRasterCacheHashes::default(),
386        cache_hashes_valid: false,
387        children,
388    }
389}
390
391fn build_layer_node_from_applier(
392    applier: &mut MemoryApplier,
393    node_id: NodeId,
394    _root_scale: f32,
395    inherited_motion_context_animated: bool,
396) -> Option<LayerNode> {
397    build_layer_node_from_applier_internal(
398        applier,
399        node_id,
400        inherited_motion_context_animated,
401        false,
402    )
403}
404
405fn build_layer_node_from_applier_internal(
406    applier: &mut MemoryApplier,
407    node_id: NodeId,
408    inherited_motion_context_animated: bool,
409    inherited_translated_content_context: bool,
410) -> Option<LayerNode> {
411    if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
412        let state = node.layout_state();
413        let children = node.children.clone();
414        let modifier_slices = node.modifier_slices_snapshot();
415        SnapshotNodeData {
416            layout_state: state,
417            modifier_slices,
418            resolved_modifiers: node.resolved_modifiers(),
419            children,
420        }
421    }) {
422        return build_layer_node_from_data(
423            applier,
424            node_id,
425            data,
426            inherited_motion_context_animated,
427            inherited_translated_content_context,
428        );
429    }
430
431    if let Ok(data) = applier.with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
432        let state = node.layout_state();
433        let children = node.active_children();
434        let modifier_slices = node.modifier_slices_snapshot();
435        SnapshotNodeData {
436            layout_state: state,
437            modifier_slices,
438            resolved_modifiers: node.resolved_modifiers(),
439            children,
440        }
441    }) {
442        return build_layer_node_from_data(
443            applier,
444            node_id,
445            data,
446            inherited_motion_context_animated,
447            inherited_translated_content_context,
448        );
449    }
450
451    None
452}
453
454fn build_layer_node_from_data(
455    applier: &mut MemoryApplier,
456    node_id: NodeId,
457    data: SnapshotNodeData,
458    inherited_motion_context_animated: bool,
459    inherited_translated_content_context: bool,
460) -> Option<LayerNode> {
461    let SnapshotNodeData {
462        layout_state,
463        modifier_slices,
464        resolved_modifiers,
465        children,
466    } = data;
467    if !layout_state.is_placed {
468        return None;
469    }
470
471    let local_bounds = Rect {
472        x: 0.0,
473        y: 0.0,
474        width: layout_state.size.width,
475        height: layout_state.size.height,
476    };
477    let clip_to_bounds = modifier_slices.clip_to_bounds();
478    let graphics_layer = graphics_layer_with_shaped_clip(
479        modifier_slices.graphics_layer().unwrap_or_default(),
480        clip_to_bounds,
481        modifier_slices.corner_shape(),
482        local_bounds,
483    );
484    let transform_to_parent =
485        layer_transform_to_parent(local_bounds, layout_state.position, &graphics_layer);
486    let isolation = isolation_reasons(&graphics_layer);
487    let cache_policy = if isolation.has_any() {
488        CachePolicy::Auto
489    } else {
490        CachePolicy::None
491    };
492    let click_actions = modifier_slices.click_handlers();
493    let pointer_inputs = modifier_slices.pointer_inputs();
494    let shadow_clip = clip_to_bounds.then_some(local_bounds);
495    let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
496        shape: None,
497        click_actions: click_actions.to_vec(),
498        pointer_inputs: pointer_inputs.to_vec(),
499        clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
500    });
501
502    let node_motion_context_animated =
503        inherited_motion_context_animated || modifier_slices.motion_context_animated();
504    let local_translated_content_context = modifier_slices.translated_content_context();
505    let local_translated_content_offset = modifier_slices
506        .translated_content_offset()
507        .unwrap_or(layout_state.content_offset);
508    let child_translated_content_context =
509        inherited_translated_content_context || local_translated_content_context;
510
511    let mut render_children = draw_nodes(
512        modifier_slices.draw_commands(),
513        DrawPlacement::Behind,
514        layout_state.size,
515        PrimitivePhase::BeforeChildren,
516    );
517    if let Some(text) = text_node_from_parts(TextNodeParts {
518        node_id,
519        local_bounds,
520        measured_max_width: layout_state
521            .measurement_constraints
522            .max_width
523            .is_finite()
524            .then_some(layout_state.measurement_constraints.max_width),
525        resolved_modifiers: &resolved_modifiers,
526        annotated_text: modifier_slices.annotated_text(),
527        text_style: modifier_slices.text_style(),
528        text_layout_options: modifier_slices.text_layout_options(),
529        modifier_slices: Some(modifier_slices.as_ref()),
530    }) {
531        render_children.push(RenderNode::Primitive(PrimitiveEntry {
532            phase: PrimitivePhase::BeforeChildren,
533            node: PrimitiveNode::Text(Box::new(text)),
534        }));
535    }
536    let child_motion_context_animated = node_motion_context_animated;
537    for child_id in children {
538        let Some(mut child_layer) = build_layer_node_from_applier_internal(
539            applier,
540            child_id,
541            child_motion_context_animated,
542            child_translated_content_context,
543        ) else {
544            continue;
545        };
546        if layout_state.content_offset != Point::default() {
547            child_layer.transform_to_parent =
548                child_layer
549                    .transform_to_parent
550                    .then(ProjectiveTransform::translation(
551                        layout_state.content_offset.x,
552                        layout_state.content_offset.y,
553                    ));
554        }
555        render_children.push(RenderNode::Layer(Box::new(child_layer)));
556    }
557    render_children.extend(draw_nodes(
558        modifier_slices.draw_commands(),
559        DrawPlacement::Overlay,
560        layout_state.size,
561        PrimitivePhase::AfterChildren,
562    ));
563    let has_hit_targets = hit_test.is_some()
564        || render_children.iter().any(|child| match child {
565            RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
566            RenderNode::Primitive(_) => false,
567        });
568
569    let layer = LayerNode {
570        node_id: Some(node_id),
571        local_bounds,
572        transform_to_parent,
573        content_offset: layout_state.content_offset,
574        motion_context_animated: node_motion_context_animated,
575        translated_content_context: local_translated_content_context,
576        translated_content_offset: if local_translated_content_context {
577            local_translated_content_offset
578        } else {
579            Point::default()
580        },
581        graphics_layer,
582        clip_to_bounds,
583        shadow_clip,
584        hit_test,
585        has_hit_targets,
586        isolation,
587        cache_policy,
588        cache_hashes: LayerRasterCacheHashes::default(),
589        cache_hashes_valid: false,
590        children: render_children,
591    };
592    Some(layer)
593}
594
595fn draw_nodes(
596    commands: &[DrawCommand],
597    placement: DrawPlacement,
598    size: Size,
599    phase: PrimitivePhase,
600) -> Vec<RenderNode> {
601    let mut nodes = Vec::new();
602    for command in commands {
603        for primitive in primitives_for_placement(command, placement, size) {
604            nodes.push(RenderNode::Primitive(PrimitiveEntry {
605                phase,
606                node: PrimitiveNode::Draw(DrawPrimitiveNode {
607                    primitive,
608                    clip: None,
609                }),
610            }));
611        }
612    }
613    nodes
614}
615
616struct TextNodeParts<'a> {
617    node_id: NodeId,
618    local_bounds: Rect,
619    measured_max_width: Option<f32>,
620    resolved_modifiers: &'a ResolvedModifiers,
621    annotated_text: Option<&'a AnnotatedString>,
622    text_style: Option<&'a TextStyle>,
623    text_layout_options: Option<TextLayoutOptions>,
624    modifier_slices: Option<&'a ModifierNodeSlices>,
625}
626
627fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
628    let TextNodeParts {
629        node_id,
630        local_bounds,
631        measured_max_width,
632        resolved_modifiers,
633        annotated_text,
634        text_style,
635        text_layout_options,
636        modifier_slices,
637    } = parts;
638    let value = annotated_text?;
639    let default_text_style = TextStyle::default();
640    let text_style = text_style.cloned().unwrap_or(default_text_style);
641    let options = text_layout_options.unwrap_or_default().normalized();
642    let padding = resolved_modifiers.padding();
643    let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
644    if content_width <= 0.0 {
645        return None;
646    }
647
648    let measure_width =
649        resolve_text_measure_width(content_width, padding, measured_max_width, options);
650    let max_width = Some(measure_width).filter(|width| width.is_finite() && *width > 0.0);
651    let prepared = modifier_slices
652        .and_then(|slices| slices.prepare_text_layout(max_width))
653        .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
654    let visual_style = prepared.visual_style.clone();
655    let measured_draw_width = prepared.metrics.width.max(0.0);
656    let draw_width = if options.overflow == TextOverflow::Visible {
657        measured_draw_width
658    } else {
659        measured_draw_width.min(content_width)
660    };
661    let alignment_offset = resolve_text_horizontal_offset(
662        &text_style,
663        prepared.text.text.as_str(),
664        content_width,
665        prepared.metrics.width,
666    );
667    let rect = Rect {
668        x: padding.left + alignment_offset,
669        y: padding.top,
670        width: draw_width,
671        height: prepared.metrics.height,
672    };
673    let text_bounds = Rect {
674        x: padding.left,
675        y: padding.top,
676        width: content_width,
677        height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
678    };
679    let font_size = visual_style.resolve_font_size(14.0);
680    let expanded_bounds =
681        expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
682    let clip = if options.overflow == TextOverflow::Visible {
683        None
684    } else {
685        Some(pad_clip_rect(expanded_bounds))
686    };
687
688    Some(TextPrimitiveNode {
689        node_id,
690        rect,
691        text: prepared.text,
692        text_style: visual_style,
693        font_size,
694        layout_options: options,
695        clip,
696    })
697}
698
699fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
700    let placement = parent
701        .map(|parent_box| Point {
702            x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
703            y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
704        })
705        .unwrap_or_default();
706    let mut children = Vec::with_capacity(node.children.len());
707    for child in &node.children {
708        children.push(layout_box_to_snapshot(child, Some(node)));
709    }
710    let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
711    let graphics_layer = graphics_layer_with_shaped_clip(
712        base_graphics_layer.clone().unwrap_or_default(),
713        node.node_data.modifier_slices.clip_to_bounds(),
714        node.node_data.modifier_slices.corner_shape(),
715        Rect {
716            x: 0.0,
717            y: 0.0,
718            width: node.rect.width,
719            height: node.rect.height,
720        },
721    );
722    let has_graphics_layer =
723        base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
724
725    BuildNodeSnapshot {
726        node_id: node.node_id,
727        placement,
728        size: Size {
729            width: node.rect.width,
730            height: node.rect.height,
731        },
732        content_offset: node.content_offset,
733        motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
734        translated_content_context: node.node_data.modifier_slices.translated_content_context(),
735        measured_max_width: None,
736        resolved_modifiers: node.node_data.resolved_modifiers,
737        draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
738        click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
739        pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
740        clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
741        annotated_text: node.node_data.modifier_slices.annotated_string(),
742        text_style: node.node_data.modifier_slices.text_style().cloned(),
743        text_layout_options: node.node_data.modifier_slices.text_layout_options(),
744        graphics_layer: has_graphics_layer.then_some(graphics_layer),
745        children,
746    }
747}
748
749fn graphics_layer_with_shaped_clip(
750    mut graphics_layer: GraphicsLayer,
751    clip_to_bounds: bool,
752    corner_shape: Option<RoundedCornerShape>,
753    local_bounds: Rect,
754) -> GraphicsLayer {
755    if !clip_to_bounds {
756        return graphics_layer;
757    }
758
759    let Some(corner_shape) = corner_shape else {
760        return graphics_layer;
761    };
762    let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
763    if radii.top_left <= f32::EPSILON
764        && radii.top_right <= f32::EPSILON
765        && radii.bottom_right <= f32::EPSILON
766        && radii.bottom_left <= f32::EPSILON
767    {
768        return graphics_layer;
769    }
770
771    if let Some(existing) = graphics_layer.render_effect.take() {
772        let rounded_clip = rounded_corner_alpha_mask_effect(
773            local_bounds.width,
774            local_bounds.height,
775            radii,
776            ROUNDED_CLIP_EDGE_FEATHER,
777        );
778        graphics_layer.render_effect = Some(existing.then(rounded_clip));
779    } else {
780        graphics_layer.shape = LayerShape::Rounded(corner_shape);
781        graphics_layer.clip = true;
782    }
783    graphics_layer
784}
785
786fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
787    IsolationReasons {
788        explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
789        shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
790        effect: layer.render_effect.is_some(),
791        backdrop: layer.backdrop_effect.is_some(),
792        group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
793            && layer.alpha < 1.0,
794        blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
795    }
796}
797
798fn pad_clip_rect(rect: Rect) -> Rect {
799    Rect {
800        x: rect.x - TEXT_CLIP_PAD,
801        y: rect.y - TEXT_CLIP_PAD,
802        width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
803        height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
804    }
805}
806
807fn expand_text_bounds_for_baseline_shift(
808    text_bounds: Rect,
809    text_style: &TextStyle,
810    font_size: f32,
811) -> Rect {
812    let baseline_shift_px = text_style
813        .span_style
814        .baseline_shift
815        .filter(|shift| shift.is_specified())
816        .map(|shift| -(shift.0 * font_size))
817        .unwrap_or(0.0);
818    if baseline_shift_px == 0.0 {
819        return text_bounds;
820    }
821
822    if baseline_shift_px < 0.0 {
823        Rect {
824            x: text_bounds.x,
825            y: text_bounds.y + baseline_shift_px,
826            width: text_bounds.width,
827            height: (text_bounds.height - baseline_shift_px).max(0.0),
828        }
829    } else {
830        Rect {
831            x: text_bounds.x,
832            y: text_bounds.y,
833            width: text_bounds.width,
834            height: (text_bounds.height + baseline_shift_px).max(0.0),
835        }
836    }
837}
838
839fn resolve_text_measure_width(
840    content_width: f32,
841    padding: cranpose_ui::EdgeInsets,
842    measured_max_width: Option<f32>,
843    options: TextLayoutOptions,
844) -> f32 {
845    let available = measured_max_width
846        .map(|max_width| (max_width - padding.left - padding.right).max(0.0))
847        .unwrap_or(content_width);
848    if options.soft_wrap || options.max_lines != 1 || options.overflow == TextOverflow::Clip {
849        available.min(content_width)
850    } else {
851        content_width
852    }
853}
854
855fn resolve_text_horizontal_offset(
856    text_style: &TextStyle,
857    text: &str,
858    content_width: f32,
859    measured_width: f32,
860) -> f32 {
861    let remaining = (content_width - measured_width).max(0.0);
862    let paragraph_style = &text_style.paragraph_style;
863    let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
864    match paragraph_style.text_align {
865        TextAlign::Center => remaining * 0.5,
866        TextAlign::End | TextAlign::Right => remaining,
867        TextAlign::Start | TextAlign::Left | TextAlign::Justify => {
868            if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
869                remaining
870            } else {
871                0.0
872            }
873        }
874        TextAlign::Unspecified => {
875            if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
876                remaining
877            } else {
878                0.0
879            }
880        }
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use std::cell::RefCell;
887    use std::rc::Rc;
888
889    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
890    use cranpose_ui::text::{
891        AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
892    };
893    use cranpose_ui::{
894        Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
895        LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
896        ScrollState, Size, Spacer, Text, TextStyle,
897    };
898    use cranpose_ui_graphics::{Brush, DrawPrimitive, GraphicsLayer, RenderEffect};
899
900    use super::*;
901
902    fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
903        for child in &layer.children {
904            match child {
905                RenderNode::Primitive(primitive) => {
906                    let PrimitiveNode::Text(text) = &primitive.node else {
907                        continue;
908                    };
909                    if text.text.text == label {
910                        return Some(text.text_style.paragraph_style.text_motion);
911                    }
912                }
913                RenderNode::Layer(child_layer) => {
914                    if let Some(motion) = find_text_motion(child_layer, label) {
915                        return Some(motion);
916                    }
917                }
918            }
919        }
920
921        None
922    }
923
924    fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
925        for child in &layer.children {
926            match child {
927                RenderNode::Primitive(primitive) => {
928                    let PrimitiveNode::Text(text) = &primitive.node else {
929                        continue;
930                    };
931                    labels.push(text.text.text.clone());
932                }
933                RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
934            }
935        }
936    }
937
938    fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
939        fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
940            for child in &layer.children {
941                match child {
942                    RenderNode::Primitive(primitive) => {
943                        let PrimitiveNode::Text(text) = &primitive.node else {
944                            continue;
945                        };
946                        if text.text.text == label {
947                            let quad = transform.map_rect(text.rect);
948                            let top = quad
949                                .iter()
950                                .map(|point| point[1])
951                                .fold(f32::INFINITY, f32::min);
952                            return top.is_finite().then_some(top);
953                        }
954                    }
955                    RenderNode::Layer(child_layer) => {
956                        let child_transform = child_layer.transform_to_parent.then(transform);
957                        if let Some(top) = search(child_layer, label, child_transform) {
958                            return Some(top);
959                        }
960                    }
961                }
962            }
963            None
964        }
965
966        search(layer, label, ProjectiveTransform::identity())
967    }
968
969    fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
970        if layer.node_id == Some(node_id) {
971            return Some(layer);
972        }
973        layer.children.iter().find_map(|child| match child {
974            RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
975            RenderNode::Primitive(_) => None,
976        })
977    }
978
979    fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
980        fn search(
981            layer: &LayerNode,
982            node_id: NodeId,
983            transform: ProjectiveTransform,
984        ) -> Option<Point> {
985            if layer.node_id == Some(node_id) {
986                return Some(transform.map_point(Point::default()));
987            }
988            layer.children.iter().find_map(|child| match child {
989                RenderNode::Layer(child_layer) => search(
990                    child_layer,
991                    node_id,
992                    child_layer.transform_to_parent.then(transform),
993                ),
994                RenderNode::Primitive(_) => None,
995            })
996        }
997
998        search(layer, node_id, ProjectiveTransform::identity())
999    }
1000
1001    fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1002        if layer.translated_content_context {
1003            return Some(layer.translated_content_offset);
1004        }
1005        for child in &layer.children {
1006            if let RenderNode::Layer(child_layer) = child {
1007                if let Some(offset) = find_translated_content_offset(child_layer) {
1008                    return Some(offset);
1009                }
1010            }
1011        }
1012        None
1013    }
1014
1015    fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1016        layer
1017            .graphics_layer
1018            .render_effect
1019            .as_ref()
1020            .is_some_and(RenderEffect::contains_runtime_shader)
1021            || layer.children.iter().any(|child| match child {
1022                RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1023                RenderNode::Primitive(_) => false,
1024            })
1025    }
1026
1027    fn build_layer_node_for_test(
1028        snapshot: BuildNodeSnapshot,
1029        scale: f32,
1030        has_external_backdrop_input: bool,
1031    ) -> LayerNode {
1032        let app_context = cranpose_ui::AppContext::new();
1033        app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1034    }
1035
1036    fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1037        let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
1038            vec![DrawPrimitive::Rect {
1039                rect: Rect {
1040                    x: 3.0,
1041                    y: 4.0,
1042                    width: 20.0,
1043                    height: 8.0,
1044                },
1045                brush: Brush::solid(Color::WHITE),
1046            }]
1047        }));
1048
1049        let child = BuildNodeSnapshot {
1050            node_id: 2,
1051            placement: Point { x: 11.0, y: 7.0 },
1052            size: Size {
1053                width: 40.0,
1054                height: 20.0,
1055            },
1056            content_offset: Point::default(),
1057            motion_context_animated: false,
1058            translated_content_context: false,
1059            measured_max_width: None,
1060            resolved_modifiers: ResolvedModifiers::default(),
1061            draw_commands: vec![child_command],
1062            click_actions: vec![],
1063            pointer_inputs: vec![],
1064            clip_to_bounds: false,
1065            annotated_text: None,
1066            text_style: None,
1067            text_layout_options: None,
1068            graphics_layer: None,
1069            children: vec![],
1070        };
1071
1072        BuildNodeSnapshot {
1073            node_id: 1,
1074            placement: Point::default(),
1075            size: Size {
1076                width: 80.0,
1077                height: 50.0,
1078            },
1079            content_offset: Point::default(),
1080            motion_context_animated: false,
1081            translated_content_context: false,
1082            measured_max_width: None,
1083            resolved_modifiers: ResolvedModifiers::default(),
1084            draw_commands: vec![],
1085            click_actions: vec![],
1086            pointer_inputs: vec![],
1087            clip_to_bounds: false,
1088            annotated_text: None,
1089            text_style: None,
1090            text_layout_options: None,
1091            graphics_layer: Some(GraphicsLayer {
1092                translation_x: tx,
1093                ..GraphicsLayer::default()
1094            }),
1095            children: vec![child],
1096        }
1097    }
1098
1099    #[test]
1100    fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1101        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1102        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1103
1104        let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1105            panic!("expected child layer");
1106        };
1107        let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1108            panic!("expected child layer");
1109        };
1110        let RenderNode::Primitive(static_draw) = &static_child.children[0] else {
1111            panic!("expected draw primitive");
1112        };
1113        let PrimitiveNode::Draw(static_draw) = &static_draw.node else {
1114            panic!("expected draw primitive");
1115        };
1116        let RenderNode::Primitive(moved_draw) = &moved_child.children[0] else {
1117            panic!("expected draw primitive");
1118        };
1119        let PrimitiveNode::Draw(moved_draw) = &moved_draw.node else {
1120            panic!("expected draw primitive");
1121        };
1122
1123        assert_ne!(
1124            static_graph.transform_to_parent, moved_graph.transform_to_parent,
1125            "parent transform should encode translation"
1126        );
1127        assert_eq!(
1128            static_draw, moved_draw,
1129            "child local primitive geometry must stay stable under parent translation"
1130        );
1131    }
1132
1133    #[test]
1134    fn stored_content_hash_ignores_parent_translation() {
1135        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1136        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1137
1138        assert_eq!(
1139            static_graph.target_content_hash(),
1140            moved_graph.target_content_hash(),
1141            "parent rigid motion must not invalidate the subtree content hash"
1142        );
1143    }
1144
1145    #[test]
1146    fn parent_content_offset_is_encoded_in_child_transform() {
1147        let child = BuildNodeSnapshot {
1148            node_id: 2,
1149            placement: Point { x: 11.0, y: 7.0 },
1150            size: Size {
1151                width: 40.0,
1152                height: 20.0,
1153            },
1154            content_offset: Point::default(),
1155            motion_context_animated: false,
1156            translated_content_context: false,
1157            measured_max_width: None,
1158            resolved_modifiers: ResolvedModifiers::default(),
1159            draw_commands: vec![],
1160            click_actions: vec![],
1161            pointer_inputs: vec![],
1162            clip_to_bounds: false,
1163            annotated_text: None,
1164            text_style: None,
1165            text_layout_options: None,
1166            graphics_layer: None,
1167            children: vec![],
1168        };
1169
1170        let parent = BuildNodeSnapshot {
1171            node_id: 1,
1172            placement: Point::default(),
1173            size: Size {
1174                width: 80.0,
1175                height: 50.0,
1176            },
1177            content_offset: Point { x: 13.0, y: -9.0 },
1178            motion_context_animated: false,
1179            translated_content_context: false,
1180            measured_max_width: None,
1181            resolved_modifiers: ResolvedModifiers::default(),
1182            draw_commands: vec![],
1183            click_actions: vec![],
1184            pointer_inputs: vec![],
1185            clip_to_bounds: false,
1186            annotated_text: None,
1187            text_style: None,
1188            text_layout_options: None,
1189            graphics_layer: None,
1190            children: vec![child],
1191        };
1192
1193        let graph = build_layer_node_for_test(parent, 1.0, false);
1194        let RenderNode::Layer(child) = &graph.children[0] else {
1195            panic!("expected child layer");
1196        };
1197
1198        let top_left = child.transform_to_parent.map_point(Point::default());
1199        assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1200    }
1201
1202    #[test]
1203    fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1204        fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1205            let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
1206                vec![DrawPrimitive::Rect {
1207                    rect: Rect {
1208                        x: 3.0,
1209                        y: 4.0,
1210                        width: 20.0,
1211                        height: 8.0,
1212                    },
1213                    brush: Brush::solid(Color::WHITE),
1214                }]
1215            }));
1216
1217            let child = BuildNodeSnapshot {
1218                node_id: 2,
1219                placement: Point { x: 11.0, y: 7.0 },
1220                size: Size {
1221                    width: 40.0,
1222                    height: 20.0,
1223                },
1224                content_offset: Point::default(),
1225                motion_context_animated: false,
1226                translated_content_context: false,
1227                measured_max_width: None,
1228                resolved_modifiers: ResolvedModifiers::default(),
1229                draw_commands: vec![child_command],
1230                click_actions: vec![],
1231                pointer_inputs: vec![],
1232                clip_to_bounds: false,
1233                annotated_text: None,
1234                text_style: None,
1235                text_layout_options: None,
1236                graphics_layer: None,
1237                children: vec![],
1238            };
1239
1240            BuildNodeSnapshot {
1241                node_id: 1,
1242                placement: Point::default(),
1243                size: Size {
1244                    width: 80.0,
1245                    height: 50.0,
1246                },
1247                content_offset: offset,
1248                motion_context_animated,
1249                translated_content_context: true,
1250                measured_max_width: None,
1251                resolved_modifiers: ResolvedModifiers::default(),
1252                draw_commands: vec![],
1253                click_actions: vec![],
1254                pointer_inputs: vec![],
1255                clip_to_bounds: false,
1256                annotated_text: None,
1257                text_style: None,
1258                text_layout_options: None,
1259                graphics_layer: None,
1260                children: vec![child],
1261            }
1262        }
1263
1264        let base = build_layer_node_for_test(
1265            parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
1266            1.0,
1267            false,
1268        );
1269        let moved = build_layer_node_for_test(
1270            parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
1271            1.0,
1272            false,
1273        );
1274        let rested = build_layer_node_for_test(
1275            parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
1276            1.0,
1277            false,
1278        );
1279
1280        let RenderNode::Layer(base_child) = &base.children[0] else {
1281            panic!("expected child layer");
1282        };
1283        let RenderNode::Layer(moved_child) = &moved.children[0] else {
1284            panic!("expected child layer");
1285        };
1286
1287        assert_ne!(
1288            base_child.transform_to_parent.map_point(Point::default()),
1289            moved_child.transform_to_parent.map_point(Point::default()),
1290            "scroll offset still has to move child content visually"
1291        );
1292        assert_eq!(
1293            base_child.target_content_hash(),
1294            moved_child.target_content_hash(),
1295            "child source content identity stays stable when only the parent scroll offset changes"
1296        );
1297        assert_ne!(
1298            base.target_content_hash(),
1299            moved.target_content_hash(),
1300            "a full-surface cache of the scroll viewport must include the scroll offset"
1301        );
1302        assert_ne!(
1303            base.target_content_hash(),
1304            rested.target_content_hash(),
1305            "full-surface cache keys must include active scroll motion policy"
1306        );
1307    }
1308
1309    #[test]
1310    fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
1311        let layer = graphics_layer_with_shaped_clip(
1312            GraphicsLayer::default(),
1313            true,
1314            Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
1315            Rect {
1316                x: 0.0,
1317                y: 0.0,
1318                width: 100.0,
1319                height: 40.0,
1320            },
1321        );
1322
1323        assert!(layer.clip);
1324        assert!(layer.render_effect.is_none());
1325        let LayerShape::Rounded(shape) = layer.shape else {
1326            panic!("rounded clip must be recorded as layer shape");
1327        };
1328        assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
1329        assert!(isolation_reasons(&layer).shape_clip);
1330    }
1331
1332    #[test]
1333    fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
1334        let existing = RenderEffect::blur(3.0);
1335        let layer = graphics_layer_with_shaped_clip(
1336            GraphicsLayer {
1337                render_effect: Some(existing.clone()),
1338                ..GraphicsLayer::default()
1339            },
1340            true,
1341            Some(RoundedCornerShape::uniform(10.0)),
1342            Rect {
1343                x: 0.0,
1344                y: 0.0,
1345                width: 100.0,
1346                height: 40.0,
1347            },
1348        );
1349
1350        let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
1351            panic!("existing effect should chain into rounded clip mask");
1352        };
1353        assert_eq!(*first, existing);
1354        assert!(
1355            matches!(*second, RenderEffect::Shader { .. }),
1356            "rounded mask must be the outer effect"
1357        );
1358    }
1359
1360    #[test]
1361    fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
1362        let mut composition = cranpose_ui::run_test_composition(|| {
1363            cranpose_ui::Box(
1364                Modifier::empty()
1365                    .width(100.0)
1366                    .height(40.0)
1367                    .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
1368                    .clip_to_bounds(),
1369                cranpose_ui::BoxSpec::default(),
1370                || {
1371                    Text("rounded child", Modifier::empty(), TextStyle::default());
1372                },
1373            );
1374        });
1375
1376        let root = composition.root().expect("rounded clip root");
1377        let handle = composition.runtime_handle();
1378        let mut applier = composition.applier_mut();
1379        applier.set_runtime_handle(handle);
1380        applier
1381            .compute_layout(
1382                root,
1383                Size {
1384                    width: 160.0,
1385                    height: 100.0,
1386                },
1387            )
1388            .expect("rounded clip layout");
1389        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1390        applier.clear_runtime_handle();
1391
1392        let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
1393        assert!(rounded_layer.graphics_layer.clip);
1394        assert!(matches!(
1395            rounded_layer.graphics_layer.shape,
1396            LayerShape::Rounded(_)
1397        ));
1398        assert!(rounded_layer.graphics_layer.render_effect.is_none());
1399        assert!(rounded_layer.isolation.shape_clip);
1400        assert!(
1401            !graph_has_runtime_shader_effect(&graph.root),
1402            "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
1403        );
1404    }
1405
1406    #[test]
1407    fn update_graph_from_applier_replaces_dirty_child_layer() {
1408        let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1409            Rc::new(RefCell::new(None));
1410        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1411        let state_holder_for_comp = state_holder.clone();
1412        let child_id_holder_for_comp = child_id_holder.clone();
1413
1414        let mut composition = cranpose_ui::run_test_composition(move || {
1415            let label = cranpose_core::useState(|| "before".to_string());
1416            *state_holder_for_comp.borrow_mut() = Some(label);
1417            let child_id_holder_for_content = child_id_holder_for_comp.clone();
1418            cranpose_ui::Box(
1419                Modifier::empty().size_points(240.0, 80.0),
1420                cranpose_ui::BoxSpec::default(),
1421                move || {
1422                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
1423                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
1424                    Text("stable", Modifier::empty(), TextStyle::default());
1425                },
1426            );
1427        });
1428
1429        let root = composition.root().expect("composition root");
1430        let viewport = Size {
1431            width: 240.0,
1432            height: 80.0,
1433        };
1434        let handle = composition.runtime_handle();
1435        let mut applier = composition.applier_mut();
1436        applier.set_runtime_handle(handle);
1437        applier
1438            .compute_layout(root, viewport)
1439            .expect("initial layout");
1440        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1441        let child_id = child_id_holder
1442            .borrow()
1443            .expect("text child id should be captured");
1444        let initial_transform = find_layer_by_node_id(&graph.root, child_id)
1445            .expect("text child layer")
1446            .transform_to_parent;
1447        applier.clear_runtime_handle();
1448        drop(applier);
1449
1450        let label = state_holder
1451            .borrow()
1452            .as_ref()
1453            .copied()
1454            .expect("label state should be captured");
1455        label.set_value("after".to_string());
1456        composition
1457            .process_invalid_scopes()
1458            .expect("text recomposition");
1459
1460        let handle = composition.runtime_handle();
1461        let mut applier = composition.applier_mut();
1462        applier.set_runtime_handle(handle);
1463        applier
1464            .compute_layout(root, viewport)
1465            .expect("updated layout");
1466        let child_id = child_id_holder
1467            .borrow()
1468            .expect("text child id should remain captured");
1469
1470        assert!(
1471            update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
1472            "dirty child should be replaceable from retained applier state"
1473        );
1474        applier.clear_runtime_handle();
1475
1476        let mut labels = Vec::new();
1477        collect_text_labels(&graph.root, &mut labels);
1478        assert!(
1479            labels.iter().any(|label| label == "after"),
1480            "updated graph should contain refreshed child text, got {labels:?}"
1481        );
1482        assert!(
1483            !labels.iter().any(|label| label == "before"),
1484            "updated graph should not retain stale child text, got {labels:?}"
1485        );
1486        assert!(
1487            labels.iter().any(|label| label == "stable"),
1488            "sibling content should remain present, got {labels:?}"
1489        );
1490        assert_eq!(
1491            find_layer_by_node_id(&graph.root, child_id)
1492                .expect("updated text child layer")
1493                .transform_to_parent,
1494            initial_transform,
1495            "draw-only child replacement must preserve the retained parent placement transform"
1496        );
1497    }
1498
1499    #[test]
1500    fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
1501        let mut graph = RenderGraph {
1502            root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
1503        };
1504        let mut applier = MemoryApplier::new();
1505
1506        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
1507
1508        assert_eq!(
1509            report,
1510            GraphUpdateReport {
1511                applied: false,
1512                hit_graph_dirty: true,
1513            },
1514            "dirty child graph updates must not report success when the replacement cannot be rebuilt"
1515        );
1516    }
1517
1518    #[test]
1519    fn update_graph_from_applier_refreshes_scroll_content_offset() {
1520        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1521        let scroll_holder_for_comp = scroll_holder.clone();
1522
1523        let mut composition = cranpose_ui::run_test_composition(move || {
1524            let scroll_state =
1525                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1526            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1527            Column(
1528                Modifier::empty()
1529                    .size_points(240.0, 120.0)
1530                    .vertical_scroll(scroll_state, false),
1531                ColumnSpec::default(),
1532                || {
1533                    Text("scroll top", Modifier::empty(), TextStyle::default());
1534                    Spacer(Size {
1535                        width: 0.0,
1536                        height: 160.0,
1537                    });
1538                    Text("scroll target", Modifier::empty(), TextStyle::default());
1539                },
1540            );
1541        });
1542
1543        let root = composition.root().expect("composition root");
1544        let viewport = Size {
1545            width: 240.0,
1546            height: 120.0,
1547        };
1548        let handle = composition.runtime_handle();
1549        let mut applier = composition.applier_mut();
1550        applier.set_runtime_handle(handle);
1551        applier
1552            .compute_layout(root, viewport)
1553            .expect("initial scroll layout");
1554        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1555        let initial_target_top =
1556            find_text_top(&graph.root, "scroll target").expect("initial target text");
1557        applier.clear_runtime_handle();
1558        drop(applier);
1559
1560        let scroll_state = scroll_holder
1561            .borrow()
1562            .as_ref()
1563            .cloned()
1564            .expect("scroll state should be captured");
1565        let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
1566        assert!(consumed_scroll > 0.0, "test scroll must be consumed");
1567        let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
1568        assert!(
1569            !dirty_nodes.is_empty(),
1570            "scroll state invalidation must schedule scoped layout graph update"
1571        );
1572
1573        let handle = composition.runtime_handle();
1574        let mut applier = composition.applier_mut();
1575        applier.set_runtime_handle(handle);
1576        applier
1577            .compute_layout(root, viewport)
1578            .expect("scrolled layout");
1579        let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
1580        applier.clear_runtime_handle();
1581
1582        assert!(report.applied, "scroll graph update should apply in place");
1583        let updated_target_top =
1584            find_text_top(&graph.root, "scroll target").expect("updated target text");
1585        assert!(
1586            updated_target_top < initial_target_top - consumed_scroll * 0.75,
1587            "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
1588        );
1589    }
1590
1591    #[test]
1592    fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
1593        let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1594            Rc::new(RefCell::new(None));
1595        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1596        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1597        let label_holder_for_comp = label_holder.clone();
1598        let scroll_holder_for_comp = scroll_holder.clone();
1599        let child_id_holder_for_comp = child_id_holder.clone();
1600
1601        let mut composition = cranpose_ui::run_test_composition(move || {
1602            let label = cranpose_core::useState(|| "scrolled child before".to_string());
1603            let scroll_state =
1604                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1605            *label_holder_for_comp.borrow_mut() = Some(label);
1606            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1607            let child_id_holder_for_content = child_id_holder_for_comp.clone();
1608            Column(
1609                Modifier::empty()
1610                    .size_points(260.0, 90.0)
1611                    .vertical_scroll(scroll_state, false),
1612                ColumnSpec::default(),
1613                move || {
1614                    Spacer(Size {
1615                        width: 0.0,
1616                        height: 24.0,
1617                    });
1618                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
1619                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
1620                    Spacer(Size {
1621                        width: 0.0,
1622                        height: 220.0,
1623                    });
1624                },
1625            );
1626        });
1627
1628        let root = composition.root().expect("composition root");
1629        let viewport = Size {
1630            width: 260.0,
1631            height: 90.0,
1632        };
1633        let handle = composition.runtime_handle();
1634        let mut applier = composition.applier_mut();
1635        applier.set_runtime_handle(handle);
1636        applier
1637            .compute_layout(root, viewport)
1638            .expect("initial layout");
1639        applier.clear_runtime_handle();
1640        drop(applier);
1641
1642        let scroll_state = scroll_holder
1643            .borrow()
1644            .as_ref()
1645            .cloned()
1646            .expect("scroll state should be captured");
1647        assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
1648
1649        let handle = composition.runtime_handle();
1650        let mut applier = composition.applier_mut();
1651        applier.set_runtime_handle(handle);
1652        applier
1653            .compute_layout(root, viewport)
1654            .expect("scrolled layout");
1655        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
1656        let child_id = child_id_holder
1657            .borrow()
1658            .expect("text child id should be captured");
1659        let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
1660            .expect("scrolled child layer")
1661            .transform_to_parent;
1662        applier.clear_runtime_handle();
1663        drop(applier);
1664
1665        let label = label_holder
1666            .borrow()
1667            .as_ref()
1668            .copied()
1669            .expect("label state should be captured");
1670        label.set_value("scrolled child after".to_string());
1671        composition
1672            .process_invalid_scopes()
1673            .expect("text recomposition");
1674
1675        let handle = composition.runtime_handle();
1676        let mut applier = composition.applier_mut();
1677        applier.set_runtime_handle(handle);
1678        applier
1679            .compute_layout(root, viewport)
1680            .expect("updated scrolled layout");
1681        let child_id = child_id_holder
1682            .borrow()
1683            .expect("text child id should remain captured");
1684        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
1685        applier.clear_runtime_handle();
1686
1687        assert!(report.applied, "dirty child graph update should apply");
1688        let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
1689        assert_eq!(
1690            updated.transform_to_parent, scrolled_transform,
1691            "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
1692        );
1693        let mut labels = Vec::new();
1694        collect_text_labels(&graph.root, &mut labels);
1695        assert!(
1696            labels.iter().any(|label| label == "scrolled child after"),
1697            "updated graph should contain refreshed text, got {labels:?}"
1698        );
1699    }
1700
1701    #[test]
1702    fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
1703        let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
1704            Rc::new(RefCell::new(None));
1705        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1706        let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1707        let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1708        let alpha_holder_for_comp = alpha_holder.clone();
1709        let scroll_holder_for_comp = scroll_holder.clone();
1710        let underlay_id_holder_for_comp = underlay_id_holder.clone();
1711        let overlay_id_holder_for_comp = overlay_id_holder.clone();
1712
1713        let mut composition = cranpose_ui::run_test_composition(move || {
1714            let alpha = cranpose_core::useState(|| 1.0f32);
1715            let scroll_state =
1716                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1717            *alpha_holder_for_comp.borrow_mut() = Some(alpha);
1718            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1719            let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
1720            let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
1721            Column(
1722                Modifier::empty()
1723                    .size_points(260.0, 120.0)
1724                    .vertical_scroll(scroll_state, false),
1725                ColumnSpec::default(),
1726                move || {
1727                    Spacer(Size {
1728                        width: 0.0,
1729                        height: 180.0,
1730                    });
1731                    cranpose_ui::Box(
1732                        Modifier::empty().size_points(188.0, 88.0),
1733                        cranpose_ui::BoxSpec::default(),
1734                        {
1735                            let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
1736                            let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
1737                            move || {
1738                                let underlay_id = cranpose_ui::Box(
1739                                    Modifier::empty().size_points(188.0, 88.0),
1740                                    cranpose_ui::BoxSpec::default(),
1741                                    || {
1742                                        Text(
1743                                            "UNDERLAY CONTENT",
1744                                            Modifier::empty().absolute_offset(12.0, 8.0),
1745                                            TextStyle::default(),
1746                                        );
1747                                    },
1748                                );
1749                                *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
1750                                let overlay_id = cranpose_ui::Box(
1751                                    Modifier::empty().size_points(188.0, 88.0).graphics_layer(
1752                                        move || GraphicsLayer {
1753                                            alpha: alpha.get(),
1754                                            ..GraphicsLayer::default()
1755                                        },
1756                                    ),
1757                                    cranpose_ui::BoxSpec::default(),
1758                                    || {
1759                                        Text(
1760                                            "TOP LAYER",
1761                                            Modifier::empty().absolute_offset(74.0, 39.6),
1762                                            TextStyle::default(),
1763                                        );
1764                                    },
1765                                );
1766                                *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
1767                            }
1768                        },
1769                    );
1770                    Spacer(Size {
1771                        width: 0.0,
1772                        height: 280.0,
1773                    });
1774                },
1775            );
1776        });
1777
1778        let root = composition.root().expect("composition root");
1779        let viewport = Size {
1780            width: 260.0,
1781            height: 120.0,
1782        };
1783        let handle = composition.runtime_handle();
1784        let mut applier = composition.applier_mut();
1785        applier.set_runtime_handle(handle);
1786        applier
1787            .compute_layout(root, viewport)
1788            .expect("initial layout");
1789        applier.clear_runtime_handle();
1790        drop(applier);
1791
1792        let scroll_state = scroll_holder
1793            .borrow()
1794            .as_ref()
1795            .cloned()
1796            .expect("scroll state should be captured");
1797        assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
1798
1799        let handle = composition.runtime_handle();
1800        let mut applier = composition.applier_mut();
1801        applier.set_runtime_handle(handle);
1802        applier
1803            .compute_layout(root, viewport)
1804            .expect("scrolled layout");
1805        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
1806        applier.clear_runtime_handle();
1807        drop(applier);
1808
1809        let underlay_id = underlay_id_holder
1810            .borrow()
1811            .expect("underlay id should be captured");
1812        let overlay_id = overlay_id_holder
1813            .borrow()
1814            .expect("overlay id should be captured");
1815        let scrolled_underlay_origin =
1816            find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
1817        let scrolled_overlay_origin =
1818            find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
1819        assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
1820
1821        let alpha = alpha_holder
1822            .borrow()
1823            .as_ref()
1824            .copied()
1825            .expect("alpha state should be captured");
1826        alpha.set_value(0.35);
1827
1828        let handle = composition.runtime_handle();
1829        let mut applier = composition.applier_mut();
1830        applier.set_runtime_handle(handle);
1831        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
1832        applier.clear_runtime_handle();
1833
1834        assert!(report.applied, "dirty overlay graph update should apply");
1835        let updated_underlay_origin =
1836            find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
1837        let updated_overlay_origin =
1838            find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
1839        assert_eq!(
1840            updated_underlay_origin, scrolled_underlay_origin,
1841            "stable underlay must keep its scrolled origin"
1842        );
1843        assert_eq!(
1844            updated_overlay_origin, updated_underlay_origin,
1845            "dirty overlay graphics layer must stay aligned with its stable underlay"
1846        );
1847    }
1848
1849    #[test]
1850    fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
1851        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
1852            Rc::new(RefCell::new(None));
1853        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1854        let offset_holder_for_comp = offset_holder.clone();
1855        let node_id_holder_for_comp = node_id_holder.clone();
1856
1857        let mut composition = cranpose_ui::run_test_composition(move || {
1858            let offset = cranpose_core::useState(|| 0.0f32);
1859            *offset_holder_for_comp.borrow_mut() = Some(offset);
1860            let node_id = cranpose_ui::Box(
1861                Modifier::empty()
1862                    .size_points(40.0, 20.0)
1863                    .graphics_layer(move || GraphicsLayer {
1864                        translation_x: offset.get(),
1865                        ..GraphicsLayer::default()
1866                    }),
1867                cranpose_ui::BoxSpec::default(),
1868                || {},
1869            );
1870            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
1871        });
1872
1873        let root = composition.root().expect("composition root");
1874        let viewport = Size {
1875            width: 120.0,
1876            height: 80.0,
1877        };
1878        let handle = composition.runtime_handle();
1879        let mut applier = composition.applier_mut();
1880        applier.set_runtime_handle(handle);
1881        applier
1882            .compute_layout(root, viewport)
1883            .expect("initial layout");
1884        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1885        let node_id = node_id_holder
1886            .borrow()
1887            .expect("graphics layer node id should be captured");
1888        let initial_origin = find_layer_by_node_id(&graph.root, node_id)
1889            .expect("initial graphics layer")
1890            .transform_to_parent
1891            .map_point(Point::default());
1892        applier.clear_runtime_handle();
1893        drop(applier);
1894
1895        let offset = offset_holder
1896            .borrow()
1897            .as_ref()
1898            .copied()
1899            .expect("offset state should be captured");
1900        offset.set_value(32.0);
1901
1902        let handle = composition.runtime_handle();
1903        let mut applier = composition.applier_mut();
1904        applier.set_runtime_handle(handle);
1905        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
1906        assert!(
1907            report.applied,
1908            "dirty graphics layer should be replaceable from retained applier state"
1909        );
1910        assert!(
1911            !report.hit_graph_dirty,
1912            "a moved visual-only layer should not force hit graph refresh"
1913        );
1914        applier.clear_runtime_handle();
1915
1916        let updated_origin = find_layer_by_node_id(&graph.root, node_id)
1917            .expect("updated graphics layer")
1918            .transform_to_parent
1919            .map_point(Point::default());
1920        assert!(
1921            (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
1922            "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
1923        );
1924    }
1925
1926    #[test]
1927    fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
1928        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
1929            Rc::new(RefCell::new(None));
1930        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1931        let offset_holder_for_comp = offset_holder.clone();
1932        let node_id_holder_for_comp = node_id_holder.clone();
1933
1934        let mut composition = cranpose_ui::run_test_composition(move || {
1935            let offset = cranpose_core::useState(|| 0.0f32);
1936            *offset_holder_for_comp.borrow_mut() = Some(offset);
1937            let node_id = cranpose_ui::Box(
1938                Modifier::empty()
1939                    .size_points(40.0, 20.0)
1940                    .graphics_layer(move || GraphicsLayer {
1941                        translation_x: offset.get(),
1942                        ..GraphicsLayer::default()
1943                    })
1944                    .clickable(|_| {}),
1945                cranpose_ui::BoxSpec::default(),
1946                || {},
1947            );
1948            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
1949        });
1950
1951        let root = composition.root().expect("composition root");
1952        let viewport = Size {
1953            width: 120.0,
1954            height: 80.0,
1955        };
1956        let handle = composition.runtime_handle();
1957        let mut applier = composition.applier_mut();
1958        applier.set_runtime_handle(handle);
1959        applier
1960            .compute_layout(root, viewport)
1961            .expect("initial layout");
1962        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1963        let node_id = node_id_holder
1964            .borrow()
1965            .expect("graphics layer node id should be captured");
1966        applier.clear_runtime_handle();
1967        drop(applier);
1968
1969        let offset = offset_holder
1970            .borrow()
1971            .as_ref()
1972            .copied()
1973            .expect("offset state should be captured");
1974        offset.set_value(32.0);
1975
1976        let handle = composition.runtime_handle();
1977        let mut applier = composition.applier_mut();
1978        applier.set_runtime_handle(handle);
1979        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
1980        applier.clear_runtime_handle();
1981
1982        assert!(
1983            report.applied,
1984            "dirty clickable graphics layer should be replaceable from retained applier state"
1985        );
1986        assert!(
1987            report.hit_graph_dirty,
1988            "moved clickable layers must refresh hit geometry"
1989        );
1990    }
1991
1992    #[test]
1993    fn overlay_draw_commands_are_tagged_after_children() {
1994        let child = BuildNodeSnapshot {
1995            node_id: 2,
1996            placement: Point { x: 4.0, y: 5.0 },
1997            size: Size {
1998                width: 20.0,
1999                height: 10.0,
2000            },
2001            content_offset: Point::default(),
2002            motion_context_animated: false,
2003            translated_content_context: false,
2004            measured_max_width: None,
2005            resolved_modifiers: ResolvedModifiers::default(),
2006            draw_commands: vec![],
2007            click_actions: vec![],
2008            pointer_inputs: vec![],
2009            clip_to_bounds: false,
2010            annotated_text: None,
2011            text_style: None,
2012            text_layout_options: None,
2013            graphics_layer: None,
2014            children: vec![],
2015        };
2016        let behind = DrawCommand::Behind(Rc::new(|_size: Size| {
2017            vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2018                rect: Rect {
2019                    x: 1.0,
2020                    y: 2.0,
2021                    width: 8.0,
2022                    height: 6.0,
2023                },
2024                brush: Brush::solid(Color::WHITE),
2025            }]
2026        }));
2027        let overlay = DrawCommand::Overlay(Rc::new(|_size: Size| {
2028            vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2029                rect: Rect {
2030                    x: 3.0,
2031                    y: 1.0,
2032                    width: 5.0,
2033                    height: 4.0,
2034                },
2035                brush: Brush::solid(Color::BLACK),
2036            }]
2037        }));
2038
2039        let parent = BuildNodeSnapshot {
2040            node_id: 1,
2041            placement: Point::default(),
2042            size: Size {
2043                width: 80.0,
2044                height: 50.0,
2045            },
2046            content_offset: Point::default(),
2047            motion_context_animated: false,
2048            translated_content_context: false,
2049            measured_max_width: None,
2050            resolved_modifiers: ResolvedModifiers::default(),
2051            draw_commands: vec![behind, overlay],
2052            click_actions: vec![],
2053            pointer_inputs: vec![],
2054            clip_to_bounds: false,
2055            annotated_text: None,
2056            text_style: None,
2057            text_layout_options: None,
2058            graphics_layer: None,
2059            children: vec![child],
2060        };
2061
2062        let graph = build_layer_node_for_test(parent, 1.0, false);
2063        let RenderNode::Primitive(behind) = &graph.children[0] else {
2064            panic!("expected before-children primitive");
2065        };
2066        let RenderNode::Layer(_) = &graph.children[1] else {
2067            panic!("expected child layer");
2068        };
2069        let RenderNode::Primitive(overlay) = &graph.children[2] else {
2070            panic!("expected after-children primitive");
2071        };
2072
2073        assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
2074        assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
2075    }
2076
2077    #[test]
2078    fn stored_content_hash_changes_when_child_transform_changes() {
2079        let child = BuildNodeSnapshot {
2080            node_id: 2,
2081            placement: Point { x: 4.0, y: 5.0 },
2082            size: Size {
2083                width: 20.0,
2084                height: 10.0,
2085            },
2086            content_offset: Point::default(),
2087            motion_context_animated: false,
2088            translated_content_context: false,
2089            measured_max_width: None,
2090            resolved_modifiers: ResolvedModifiers::default(),
2091            draw_commands: vec![],
2092            click_actions: vec![],
2093            pointer_inputs: vec![],
2094            clip_to_bounds: false,
2095            annotated_text: None,
2096            text_style: None,
2097            text_layout_options: None,
2098            graphics_layer: None,
2099            children: vec![],
2100        };
2101        let mut moved_child = child.clone();
2102        moved_child.placement.x += 7.0;
2103
2104        let parent = BuildNodeSnapshot {
2105            node_id: 1,
2106            placement: Point::default(),
2107            size: Size {
2108                width: 80.0,
2109                height: 50.0,
2110            },
2111            content_offset: Point::default(),
2112            motion_context_animated: false,
2113            translated_content_context: false,
2114            measured_max_width: None,
2115            resolved_modifiers: ResolvedModifiers::default(),
2116            draw_commands: vec![],
2117            click_actions: vec![],
2118            pointer_inputs: vec![],
2119            clip_to_bounds: false,
2120            annotated_text: None,
2121            text_style: None,
2122            text_layout_options: None,
2123            graphics_layer: None,
2124            children: vec![child],
2125        };
2126        let moved_parent = BuildNodeSnapshot {
2127            children: vec![moved_child],
2128            ..parent.clone()
2129        };
2130
2131        let static_graph = build_layer_node_for_test(parent, 1.0, false);
2132        let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
2133
2134        assert_ne!(
2135            static_graph.target_content_hash(),
2136            moved_graph.target_content_hash(),
2137            "moving a child within the parent must invalidate the parent subtree hash"
2138        );
2139    }
2140
2141    #[test]
2142    fn stored_effect_hash_tracks_local_effect_only() {
2143        let base = BuildNodeSnapshot {
2144            node_id: 1,
2145            placement: Point::default(),
2146            size: Size {
2147                width: 80.0,
2148                height: 50.0,
2149            },
2150            content_offset: Point::default(),
2151            motion_context_animated: false,
2152            translated_content_context: false,
2153            measured_max_width: None,
2154            resolved_modifiers: ResolvedModifiers::default(),
2155            draw_commands: vec![],
2156            click_actions: vec![],
2157            pointer_inputs: vec![],
2158            clip_to_bounds: false,
2159            annotated_text: None,
2160            text_style: None,
2161            text_layout_options: None,
2162            graphics_layer: None,
2163            children: vec![],
2164        };
2165        let mut effected = base.clone();
2166        effected.graphics_layer = Some(GraphicsLayer {
2167            render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
2168            ..GraphicsLayer::default()
2169        });
2170
2171        let base_graph = build_layer_node_for_test(base, 1.0, false);
2172        let effected_graph = build_layer_node_for_test(effected, 1.0, false);
2173
2174        assert_eq!(
2175            base_graph.target_content_hash(),
2176            effected_graph.target_content_hash(),
2177            "post-processing effect parameters belong to the effect hash, not the content hash"
2178        );
2179        assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
2180    }
2181
2182    #[test]
2183    fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
2184        let mut text_style = TextStyle::default();
2185        text_style.paragraph_style.text_align = TextAlign::Start;
2186        text_style.paragraph_style.text_direction = TextDirection::Rtl;
2187        text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
2188
2189        let snapshot = BuildNodeSnapshot {
2190            node_id: 1,
2191            placement: Point::default(),
2192            size: Size {
2193                width: 180.0,
2194                height: 48.0,
2195            },
2196            content_offset: Point::default(),
2197            motion_context_animated: false,
2198            translated_content_context: false,
2199            measured_max_width: Some(180.0),
2200            resolved_modifiers: ResolvedModifiers::default(),
2201            draw_commands: vec![],
2202            click_actions: vec![],
2203            pointer_inputs: vec![],
2204            clip_to_bounds: false,
2205            annotated_text: Some(AnnotatedString::from("rtl")),
2206            text_style: Some(text_style),
2207            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2208                overflow: cranpose_ui::TextOverflow::Clip,
2209                ..Default::default()
2210            }),
2211            graphics_layer: None,
2212            children: vec![],
2213        };
2214
2215        let graph = build_layer_node_for_test(snapshot, 1.0, false);
2216        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2217            panic!("expected text primitive");
2218        };
2219        let PrimitiveNode::Text(text) = &text_primitive.node else {
2220            panic!("expected text primitive");
2221        };
2222        let clip = text
2223            .clip
2224            .expect("clipped overflow should produce a clip rect");
2225
2226        assert!(
2227            text.rect.x > 0.0,
2228            "RTL start alignment should shift the text rect within the available width"
2229        );
2230        assert!(
2231            clip.y < text.rect.y,
2232            "baseline shift must expand the clip upward so superscript glyphs are preserved"
2233        );
2234        assert!(
2235            clip.intersect(text.rect).is_some(),
2236            "the clip rect must intersect the shifted text draw rect"
2237        );
2238    }
2239
2240    #[test]
2241    fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
2242        let snapshot = BuildNodeSnapshot {
2243            node_id: 1,
2244            placement: Point::default(),
2245            size: Size {
2246                width: 320.0,
2247                height: 48.0,
2248            },
2249            content_offset: Point::default(),
2250            motion_context_animated: false,
2251            translated_content_context: false,
2252            measured_max_width: Some(320.0),
2253            resolved_modifiers: ResolvedModifiers::default(),
2254            draw_commands: vec![],
2255            click_actions: vec![],
2256            pointer_inputs: vec![],
2257            clip_to_bounds: false,
2258            annotated_text: Some(AnnotatedString::from("short")),
2259            text_style: Some(TextStyle::default()),
2260            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2261                overflow: cranpose_ui::TextOverflow::Clip,
2262                ..Default::default()
2263            }),
2264            graphics_layer: None,
2265            children: vec![],
2266        };
2267
2268        let graph = build_layer_node_for_test(snapshot, 1.0, false);
2269        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2270            panic!("expected text primitive");
2271        };
2272        let PrimitiveNode::Text(text) = &text_primitive.node else {
2273            panic!("expected text primitive");
2274        };
2275        let clip = text.clip.expect("clipped text should keep a clip rect");
2276
2277        assert!(
2278            text.rect.width < 320.0,
2279            "text raster bounds should track measured glyph width instead of full content width"
2280        );
2281        assert_eq!(
2282            clip.width, 322.0,
2283            "text clip should still preserve the full content box plus clip padding"
2284        );
2285    }
2286
2287    #[test]
2288    fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
2289        let child = BuildNodeSnapshot {
2290            node_id: 2,
2291            placement: Point { x: 11.0, y: 7.0 },
2292            size: Size {
2293                width: 120.0,
2294                height: 32.0,
2295            },
2296            content_offset: Point::default(),
2297            motion_context_animated: false,
2298            translated_content_context: false,
2299            measured_max_width: Some(120.0),
2300            resolved_modifiers: ResolvedModifiers::default(),
2301            draw_commands: vec![],
2302            click_actions: vec![],
2303            pointer_inputs: vec![],
2304            clip_to_bounds: false,
2305            annotated_text: Some(AnnotatedString::from("scrolling")),
2306            text_style: Some(TextStyle::default()),
2307            text_layout_options: None,
2308            graphics_layer: None,
2309            children: vec![],
2310        };
2311        let parent = BuildNodeSnapshot {
2312            node_id: 1,
2313            placement: Point::default(),
2314            size: Size {
2315                width: 160.0,
2316                height: 64.0,
2317            },
2318            content_offset: Point { x: 0.0, y: -18.5 },
2319            motion_context_animated: false,
2320            translated_content_context: true,
2321            measured_max_width: None,
2322            resolved_modifiers: ResolvedModifiers::default(),
2323            draw_commands: vec![],
2324            click_actions: vec![],
2325            pointer_inputs: vec![],
2326            clip_to_bounds: false,
2327            annotated_text: None,
2328            text_style: None,
2329            text_layout_options: None,
2330            graphics_layer: None,
2331            children: vec![child],
2332        };
2333
2334        let graph = build_layer_node_for_test(parent, 1.0, false);
2335        let RenderNode::Layer(child_layer) = &graph.children[0] else {
2336            panic!("expected child layer");
2337        };
2338        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2339            panic!("expected text primitive");
2340        };
2341        let PrimitiveNode::Text(text) = &text_primitive.node else {
2342            panic!("expected text primitive");
2343        };
2344
2345        assert_eq!(text.text_style.paragraph_style.text_motion, None);
2346        assert!(!child_layer.motion_context_animated);
2347    }
2348
2349    #[test]
2350    fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
2351        let child = BuildNodeSnapshot {
2352            node_id: 2,
2353            placement: Point { x: 11.0, y: 7.0 },
2354            size: Size {
2355                width: 120.0,
2356                height: 32.0,
2357            },
2358            content_offset: Point::default(),
2359            motion_context_animated: false,
2360            translated_content_context: false,
2361            measured_max_width: Some(120.0),
2362            resolved_modifiers: ResolvedModifiers::default(),
2363            draw_commands: vec![],
2364            click_actions: vec![],
2365            pointer_inputs: vec![],
2366            clip_to_bounds: false,
2367            annotated_text: Some(AnnotatedString::from("scrolling")),
2368            text_style: Some(TextStyle::default()),
2369            text_layout_options: None,
2370            graphics_layer: None,
2371            children: vec![],
2372        };
2373        let parent = BuildNodeSnapshot {
2374            node_id: 1,
2375            placement: Point::default(),
2376            size: Size {
2377                width: 160.0,
2378                height: 64.0,
2379            },
2380            content_offset: Point { x: 0.0, y: -18.0 },
2381            motion_context_animated: false,
2382            translated_content_context: false,
2383            measured_max_width: None,
2384            resolved_modifiers: ResolvedModifiers::default(),
2385            draw_commands: vec![],
2386            click_actions: vec![],
2387            pointer_inputs: vec![],
2388            clip_to_bounds: false,
2389            annotated_text: None,
2390            text_style: None,
2391            text_layout_options: None,
2392            graphics_layer: None,
2393            children: vec![child],
2394        };
2395
2396        let graph = build_layer_node_for_test(parent, 1.0, false);
2397        let RenderNode::Layer(child_layer) = &graph.children[0] else {
2398            panic!("expected child layer");
2399        };
2400        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2401            panic!("expected text primitive");
2402        };
2403        let PrimitiveNode::Text(text) = &text_primitive.node else {
2404            panic!("expected text primitive");
2405        };
2406
2407        assert_eq!(
2408            text.text_style.paragraph_style.text_motion, None,
2409            "content_offset alone must not force text onto the translated-content motion path"
2410        );
2411        assert!(!child_layer.motion_context_animated);
2412    }
2413
2414    #[test]
2415    fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
2416        let child = BuildNodeSnapshot {
2417            node_id: 2,
2418            placement: Point { x: 11.0, y: 7.0 },
2419            size: Size {
2420                width: 120.0,
2421                height: 32.0,
2422            },
2423            content_offset: Point::default(),
2424            motion_context_animated: false,
2425            translated_content_context: false,
2426            measured_max_width: Some(120.0),
2427            resolved_modifiers: ResolvedModifiers::default(),
2428            draw_commands: vec![],
2429            click_actions: vec![],
2430            pointer_inputs: vec![],
2431            clip_to_bounds: false,
2432            annotated_text: Some(AnnotatedString::from("shadow")),
2433            text_style: Some(TextStyle::from_span_style(SpanStyle {
2434                shadow: Some(cranpose_ui::text::Shadow {
2435                    color: Color::BLACK,
2436                    offset: Point::new(1.0, 2.0),
2437                    blur_radius: 3.0,
2438                }),
2439                ..SpanStyle::default()
2440            })),
2441            text_layout_options: None,
2442            graphics_layer: None,
2443            children: vec![],
2444        };
2445        let parent = BuildNodeSnapshot {
2446            node_id: 1,
2447            placement: Point::default(),
2448            size: Size {
2449                width: 160.0,
2450                height: 64.0,
2451            },
2452            content_offset: Point { x: 0.0, y: -18.5 },
2453            motion_context_animated: false,
2454            translated_content_context: true,
2455            measured_max_width: None,
2456            resolved_modifiers: ResolvedModifiers::default(),
2457            draw_commands: vec![],
2458            click_actions: vec![],
2459            pointer_inputs: vec![],
2460            clip_to_bounds: false,
2461            annotated_text: None,
2462            text_style: None,
2463            text_layout_options: None,
2464            graphics_layer: None,
2465            children: vec![child],
2466        };
2467
2468        let graph = build_layer_node_for_test(parent, 1.0, false);
2469        let RenderNode::Layer(child_layer) = &graph.children[0] else {
2470            panic!("expected child layer");
2471        };
2472        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2473            panic!("expected text primitive");
2474        };
2475        let PrimitiveNode::Text(text) = &text_primitive.node else {
2476            panic!("expected text primitive");
2477        };
2478
2479        assert_eq!(text.text_style.paragraph_style.text_motion, None);
2480    }
2481
2482    #[test]
2483    fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
2484        let child = BuildNodeSnapshot {
2485            node_id: 2,
2486            placement: Point { x: 11.0, y: 7.0 },
2487            size: Size {
2488                width: 120.0,
2489                height: 32.0,
2490            },
2491            content_offset: Point::default(),
2492            motion_context_animated: false,
2493            translated_content_context: false,
2494            measured_max_width: Some(120.0),
2495            resolved_modifiers: ResolvedModifiers::default(),
2496            draw_commands: vec![],
2497            click_actions: vec![],
2498            pointer_inputs: vec![],
2499            clip_to_bounds: false,
2500            annotated_text: Some(AnnotatedString::from("lazy")),
2501            text_style: Some(TextStyle::default()),
2502            text_layout_options: None,
2503            graphics_layer: None,
2504            children: vec![],
2505        };
2506        let parent = BuildNodeSnapshot {
2507            node_id: 1,
2508            placement: Point::default(),
2509            size: Size {
2510                width: 160.0,
2511                height: 64.0,
2512            },
2513            content_offset: Point::default(),
2514            motion_context_animated: true,
2515            translated_content_context: false,
2516            measured_max_width: None,
2517            resolved_modifiers: ResolvedModifiers::default(),
2518            draw_commands: vec![],
2519            click_actions: vec![],
2520            pointer_inputs: vec![],
2521            clip_to_bounds: false,
2522            annotated_text: None,
2523            text_style: None,
2524            text_layout_options: None,
2525            graphics_layer: None,
2526            children: vec![child],
2527        };
2528
2529        let graph = build_layer_node_for_test(parent, 1.0, false);
2530        let RenderNode::Layer(child_layer) = &graph.children[0] else {
2531            panic!("expected child layer");
2532        };
2533        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2534            panic!("expected text primitive");
2535        };
2536        let PrimitiveNode::Text(text) = &text_primitive.node else {
2537            panic!("expected text primitive");
2538        };
2539
2540        assert_eq!(text.text_style.paragraph_style.text_motion, None);
2541        assert!(graph.motion_context_animated);
2542        assert!(child_layer.motion_context_animated);
2543    }
2544
2545    #[test]
2546    fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
2547        let mut composition = cranpose_ui::run_test_composition(|| {
2548            let list_state = remember_lazy_list_state();
2549            LazyColumn(
2550                Modifier::empty(),
2551                list_state,
2552                LazyColumnSpec::default(),
2553                |scope| {
2554                    scope.item(Some(0), None, || {
2555                        Text("LazyMotion", Modifier::empty(), TextStyle::default());
2556                    });
2557                },
2558            );
2559        });
2560
2561        let root = composition.root().expect("lazy column root");
2562        let handle = composition.runtime_handle();
2563        let mut applier = composition.applier_mut();
2564        applier.set_runtime_handle(handle);
2565        let _ = applier
2566            .compute_layout(
2567                root,
2568                Size {
2569                    width: 240.0,
2570                    height: 240.0,
2571                },
2572            )
2573            .expect("lazy column layout");
2574        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2575        applier.clear_runtime_handle();
2576
2577        assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
2578    }
2579
2580    #[test]
2581    fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
2582        use std::cell::RefCell;
2583        use std::rc::Rc;
2584
2585        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
2586        let state_holder_for_comp = state_holder.clone();
2587        let mut composition = cranpose_ui::run_test_composition(move || {
2588            let list_state = remember_lazy_list_state();
2589            *state_holder_for_comp.borrow_mut() = Some(list_state);
2590            LazyColumn(
2591                Modifier::empty().height(120.0),
2592                list_state,
2593                LazyColumnSpec::default(),
2594                |scope| {
2595                    scope.items(
2596                        8,
2597                        None::<fn(usize) -> u64>,
2598                        None::<fn(usize) -> u64>,
2599                        |index| {
2600                            Text(
2601                                format!("LazyMotion {index}"),
2602                                Modifier::empty().padding(4.0),
2603                                TextStyle::default(),
2604                            );
2605                        },
2606                    );
2607                },
2608            );
2609        });
2610
2611        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
2612        list_state.scroll_to_item(3, 0.0);
2613
2614        let root = composition.root().expect("lazy column root");
2615        let handle = composition.runtime_handle();
2616        let mut applier = composition.applier_mut();
2617        applier.set_runtime_handle(handle);
2618        let _ = applier
2619            .compute_layout(
2620                root,
2621                Size {
2622                    width: 240.0,
2623                    height: 240.0,
2624                },
2625            )
2626            .expect("lazy column layout");
2627        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2628        let active_children = applier
2629            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
2630            .expect("lazy column should be subcompose");
2631        let child_debug: Vec<String> = active_children
2632            .iter()
2633            .map(|&child_id| {
2634                if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
2635                    format!(
2636                        "layout#{child_id} placed={} text={:?} children={:?}",
2637                        node.layout_state().is_placed,
2638                        node.modifier_slices_snapshot()
2639                            .text_content()
2640                            .map(str::to_string),
2641                        node.children.clone()
2642                    )
2643                }) {
2644                    summary
2645                } else if let Ok(summary) =
2646                    applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
2647                        format!(
2648                            "subcompose#{child_id} placed={} active_children={:?}",
2649                            node.layout_state().is_placed,
2650                            node.active_children()
2651                        )
2652                    })
2653                {
2654                    summary
2655                } else {
2656                    format!("missing#{child_id}")
2657                }
2658            })
2659            .collect();
2660        applier.clear_runtime_handle();
2661
2662        let first_index = list_state.first_visible_item_index();
2663        assert!(
2664            first_index > 0,
2665            "lazy list should move away from origin before graph building, observed first_index={first_index}"
2666        );
2667        let mut labels = Vec::new();
2668        collect_text_labels(&graph.root, &mut labels);
2669        assert_eq!(
2670            find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
2671            Some(None),
2672            "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
2673            labels,
2674            active_children,
2675            child_debug
2676        );
2677    }
2678
2679    #[test]
2680    fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
2681        use std::cell::RefCell;
2682        use std::rc::Rc;
2683
2684        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
2685        let state_holder_for_comp = state_holder.clone();
2686        let mut composition = cranpose_ui::run_test_composition(move || {
2687            let list_state = remember_lazy_list_state();
2688            *state_holder_for_comp.borrow_mut() = Some(list_state);
2689            let mut spec =
2690                LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
2691            spec.beyond_bounds_item_count = 0;
2692            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
2693                scope.items(
2694                    12,
2695                    None::<fn(usize) -> u64>,
2696                    None::<fn(usize) -> u64>,
2697                    |index| {
2698                        Text(
2699                            format!("WarmRow {index}"),
2700                            Modifier::empty().height(32.0),
2701                            TextStyle::default(),
2702                        );
2703                    },
2704                );
2705            });
2706        });
2707
2708        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
2709        list_state.scroll_to_item(4, 0.0);
2710
2711        let root = composition.root().expect("lazy column root");
2712        let handle = composition.runtime_handle();
2713        let mut applier = composition.applier_mut();
2714        applier.set_runtime_handle(handle);
2715        let _ = applier
2716            .compute_layout(
2717                root,
2718                Size {
2719                    width: 240.0,
2720                    height: 240.0,
2721                },
2722            )
2723            .expect("lazy column layout");
2724        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2725        let active_children = applier
2726            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
2727            .expect("lazy column should be subcompose");
2728        applier.clear_runtime_handle();
2729
2730        let visible_indices: Vec<_> = list_state
2731            .layout_info()
2732            .visible_items_info
2733            .iter()
2734            .map(|item| item.index)
2735            .collect();
2736        let mut labels = Vec::new();
2737        collect_text_labels(&graph.root, &mut labels);
2738
2739        assert_eq!(
2740            visible_indices,
2741            vec![4, 5, 6],
2742            "test setup expects exactly three viewport-visible rows"
2743        );
2744        assert!(
2745            labels.iter().any(|label| label == "WarmRow 7"),
2746            "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
2747        );
2748    }
2749
2750    #[test]
2751    fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
2752        use std::cell::RefCell;
2753        use std::rc::Rc;
2754
2755        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
2756        let state_holder_for_comp = state_holder.clone();
2757        let mut composition = cranpose_ui::run_test_composition(move || {
2758            let list_state = remember_lazy_list_state();
2759            *state_holder_for_comp.borrow_mut() = Some(list_state);
2760            LazyColumn(
2761                Modifier::empty().height(120.0),
2762                list_state,
2763                LazyColumnSpec::default(),
2764                |scope| {
2765                    scope.items(
2766                        8,
2767                        None::<fn(usize) -> u64>,
2768                        None::<fn(usize) -> u64>,
2769                        |index| {
2770                            Text(
2771                                format!("LazySnap {index}"),
2772                                Modifier::empty().padding(4.0),
2773                                TextStyle::default(),
2774                            );
2775                        },
2776                    );
2777                },
2778            );
2779        });
2780
2781        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
2782        list_state.scroll_to_item(2, 7.5);
2783
2784        let root = composition.root().expect("lazy column root");
2785        let handle = composition.runtime_handle();
2786        let mut applier = composition.applier_mut();
2787        applier.set_runtime_handle(handle);
2788        let _ = applier
2789            .compute_layout(
2790                root,
2791                Size {
2792                    width: 240.0,
2793                    height: 240.0,
2794                },
2795            )
2796            .expect("lazy column layout");
2797        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2798        applier.clear_runtime_handle();
2799
2800        let layout_info = list_state.layout_info();
2801        let first_visible_offset = layout_info
2802            .visible_items_info
2803            .first()
2804            .expect("lazy layout should expose visible item info")
2805            .offset;
2806        let snap_offset = find_translated_content_offset(&graph.root)
2807            .expect("lazy list graph should include translated content context");
2808
2809        assert!(
2810            (snap_offset.y - first_visible_offset).abs() <= 0.001,
2811            "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
2812        );
2813    }
2814
2815    #[test]
2816    fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
2817        let child = BuildNodeSnapshot {
2818            node_id: 2,
2819            placement: Point { x: 11.0, y: 7.0 },
2820            size: Size {
2821                width: 120.0,
2822                height: 32.0,
2823            },
2824            content_offset: Point::default(),
2825            motion_context_animated: false,
2826            translated_content_context: false,
2827            measured_max_width: Some(120.0),
2828            resolved_modifiers: ResolvedModifiers::default(),
2829            draw_commands: vec![],
2830            click_actions: vec![],
2831            pointer_inputs: vec![],
2832            clip_to_bounds: false,
2833            annotated_text: Some(AnnotatedString::from("static")),
2834            text_style: Some(TextStyle::from_paragraph_style(
2835                cranpose_ui::text::ParagraphStyle {
2836                    text_motion: Some(TextMotion::Static),
2837                    ..Default::default()
2838                },
2839            )),
2840            text_layout_options: None,
2841            graphics_layer: None,
2842            children: vec![],
2843        };
2844        let parent = BuildNodeSnapshot {
2845            node_id: 1,
2846            placement: Point::default(),
2847            size: Size {
2848                width: 160.0,
2849                height: 64.0,
2850            },
2851            content_offset: Point { x: 0.0, y: -18.5 },
2852            motion_context_animated: false,
2853            translated_content_context: true,
2854            measured_max_width: None,
2855            resolved_modifiers: ResolvedModifiers::default(),
2856            draw_commands: vec![],
2857            click_actions: vec![],
2858            pointer_inputs: vec![],
2859            clip_to_bounds: false,
2860            annotated_text: None,
2861            text_style: None,
2862            text_layout_options: None,
2863            graphics_layer: None,
2864            children: vec![child],
2865        };
2866
2867        let graph = build_layer_node_for_test(parent, 1.0, false);
2868        let RenderNode::Layer(child_layer) = &graph.children[0] else {
2869            panic!("expected child layer");
2870        };
2871        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2872            panic!("expected text primitive");
2873        };
2874        let PrimitiveNode::Text(text) = &text_primitive.node else {
2875            panic!("expected text primitive");
2876        };
2877
2878        assert_eq!(
2879            text.text_style.paragraph_style.text_motion,
2880            Some(TextMotion::Static),
2881            "explicit text motion must win over inherited scrolling motion context"
2882        );
2883    }
2884}