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