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