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
981fn resolve_text_measure_width(
982    content_width: f32,
983    padding: cranpose_ui::EdgeInsets,
984    measured_max_width: Option<f32>,
985    options: TextLayoutOptions,
986) -> f32 {
987    let available = measured_max_width
988        .map(|max_width| (max_width - padding.left - padding.right).max(0.0))
989        .unwrap_or(content_width);
990    if options.soft_wrap || options.max_lines != 1 || options.overflow == TextOverflow::Clip {
991        available.min(content_width)
992    } else {
993        content_width
994    }
995}
996
997fn resolve_text_horizontal_offset(
998    text_style: &TextStyle,
999    text: &str,
1000    content_width: f32,
1001    measured_width: f32,
1002) -> f32 {
1003    let remaining = (content_width - measured_width).max(0.0);
1004    let paragraph_style = &text_style.paragraph_style;
1005    let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
1006    match paragraph_style.text_align {
1007        TextAlign::Center => remaining * 0.5,
1008        TextAlign::End | TextAlign::Right => remaining,
1009        TextAlign::Start | TextAlign::Left | TextAlign::Justify => {
1010            if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
1011                remaining
1012            } else {
1013                0.0
1014            }
1015        }
1016        TextAlign::Unspecified => {
1017            if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
1018                remaining
1019            } else {
1020                0.0
1021            }
1022        }
1023    }
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use std::cell::RefCell;
1029    use std::rc::Rc;
1030
1031    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1032    use cranpose_ui::text::{
1033        AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
1034    };
1035    use cranpose_ui::{
1036        Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
1037        LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
1038        ScrollState, Size, Spacer, Text, TextStyle,
1039    };
1040    use cranpose_ui_graphics::{Brush, DrawPrimitive, GraphicsLayer, RenderEffect};
1041
1042    use super::*;
1043
1044    fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
1045        for child in &layer.children {
1046            match child {
1047                RenderNode::Primitive(primitive) => {
1048                    let PrimitiveNode::Text(text) = &primitive.node else {
1049                        continue;
1050                    };
1051                    if text.text.text == label {
1052                        return Some(text.text_style.paragraph_style.text_motion);
1053                    }
1054                }
1055                RenderNode::Layer(child_layer) => {
1056                    if let Some(motion) = find_text_motion(child_layer, label) {
1057                        return Some(motion);
1058                    }
1059                }
1060            }
1061        }
1062
1063        None
1064    }
1065
1066    fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
1067        for child in &layer.children {
1068            match child {
1069                RenderNode::Primitive(primitive) => {
1070                    let PrimitiveNode::Text(text) = &primitive.node else {
1071                        continue;
1072                    };
1073                    labels.push(text.text.text.clone());
1074                }
1075                RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
1076            }
1077        }
1078    }
1079
1080    fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
1081        fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
1082            for child in &layer.children {
1083                match child {
1084                    RenderNode::Primitive(primitive) => {
1085                        let PrimitiveNode::Text(text) = &primitive.node else {
1086                            continue;
1087                        };
1088                        if text.text.text == label {
1089                            let quad = transform.map_rect(text.rect);
1090                            let top = quad
1091                                .iter()
1092                                .map(|point| point[1])
1093                                .fold(f32::INFINITY, f32::min);
1094                            return top.is_finite().then_some(top);
1095                        }
1096                    }
1097                    RenderNode::Layer(child_layer) => {
1098                        let child_transform = child_layer.transform_to_parent.then(transform);
1099                        if let Some(top) = search(child_layer, label, child_transform) {
1100                            return Some(top);
1101                        }
1102                    }
1103                }
1104            }
1105            None
1106        }
1107
1108        search(layer, label, ProjectiveTransform::identity())
1109    }
1110
1111    fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
1112        if layer.node_id == Some(node_id) {
1113            return Some(layer);
1114        }
1115        layer.children.iter().find_map(|child| match child {
1116            RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
1117            RenderNode::Primitive(_) => None,
1118        })
1119    }
1120
1121    fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
1122        fn search(
1123            layer: &LayerNode,
1124            node_id: NodeId,
1125            transform: ProjectiveTransform,
1126        ) -> Option<Point> {
1127            if layer.node_id == Some(node_id) {
1128                return Some(transform.map_point(Point::default()));
1129            }
1130            layer.children.iter().find_map(|child| match child {
1131                RenderNode::Layer(child_layer) => search(
1132                    child_layer,
1133                    node_id,
1134                    child_layer.transform_to_parent.then(transform),
1135                ),
1136                RenderNode::Primitive(_) => None,
1137            })
1138        }
1139
1140        search(layer, node_id, ProjectiveTransform::identity())
1141    }
1142
1143    fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1144        if layer.translated_content_context {
1145            return Some(layer.translated_content_offset);
1146        }
1147        for child in &layer.children {
1148            if let RenderNode::Layer(child_layer) = child {
1149                if let Some(offset) = find_translated_content_offset(child_layer) {
1150                    return Some(offset);
1151                }
1152            }
1153        }
1154        None
1155    }
1156
1157    fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1158        layer
1159            .graphics_layer
1160            .render_effect
1161            .as_ref()
1162            .is_some_and(RenderEffect::contains_runtime_shader)
1163            || layer.children.iter().any(|child| match child {
1164                RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1165                RenderNode::Primitive(_) => false,
1166            })
1167    }
1168
1169    fn build_layer_node_for_test(
1170        snapshot: BuildNodeSnapshot,
1171        scale: f32,
1172        has_external_backdrop_input: bool,
1173    ) -> LayerNode {
1174        let app_context = cranpose_ui::AppContext::new();
1175        app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1176    }
1177
1178    fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1179        let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
1180            vec![DrawPrimitive::Rect {
1181                rect: Rect {
1182                    x: 3.0,
1183                    y: 4.0,
1184                    width: 20.0,
1185                    height: 8.0,
1186                },
1187                brush: Brush::solid(Color::WHITE),
1188            }]
1189        }));
1190
1191        let child = BuildNodeSnapshot {
1192            node_id: 2,
1193            placement: Point { x: 11.0, y: 7.0 },
1194            size: Size {
1195                width: 40.0,
1196                height: 20.0,
1197            },
1198            content_offset: Point::default(),
1199            motion_context_animated: false,
1200            translated_content_context: false,
1201            measured_max_width: None,
1202            resolved_modifiers: ResolvedModifiers::default(),
1203            draw_commands: vec![child_command],
1204            click_actions: vec![],
1205            pointer_inputs: vec![],
1206            clip_to_bounds: false,
1207            annotated_text: None,
1208            text_style: None,
1209            text_layout_options: None,
1210            text_pan: None,
1211            graphics_layer: None,
1212            children: vec![],
1213        };
1214
1215        BuildNodeSnapshot {
1216            node_id: 1,
1217            placement: Point::default(),
1218            size: Size {
1219                width: 80.0,
1220                height: 50.0,
1221            },
1222            content_offset: Point::default(),
1223            motion_context_animated: false,
1224            translated_content_context: false,
1225            measured_max_width: None,
1226            resolved_modifiers: ResolvedModifiers::default(),
1227            draw_commands: vec![],
1228            click_actions: vec![],
1229            pointer_inputs: vec![],
1230            clip_to_bounds: false,
1231            annotated_text: None,
1232            text_style: None,
1233            text_layout_options: None,
1234            text_pan: None,
1235            graphics_layer: Some(GraphicsLayer {
1236                translation_x: tx,
1237                ..GraphicsLayer::default()
1238            }),
1239            children: vec![child],
1240        }
1241    }
1242
1243    #[test]
1244    fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1245        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1246        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1247
1248        let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1249            panic!("expected child layer");
1250        };
1251        let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1252            panic!("expected child layer");
1253        };
1254        let RenderNode::Primitive(static_draw) = &static_child.children[0] else {
1255            panic!("expected draw primitive");
1256        };
1257        let PrimitiveNode::Draw(static_draw) = &static_draw.node else {
1258            panic!("expected draw primitive");
1259        };
1260        let RenderNode::Primitive(moved_draw) = &moved_child.children[0] else {
1261            panic!("expected draw primitive");
1262        };
1263        let PrimitiveNode::Draw(moved_draw) = &moved_draw.node else {
1264            panic!("expected draw primitive");
1265        };
1266
1267        assert_ne!(
1268            static_graph.transform_to_parent, moved_graph.transform_to_parent,
1269            "parent transform should encode translation"
1270        );
1271        assert_eq!(
1272            static_draw, moved_draw,
1273            "child local primitive geometry must stay stable under parent translation"
1274        );
1275    }
1276
1277    #[test]
1278    fn stored_content_hash_ignores_parent_translation() {
1279        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1280        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1281
1282        assert_eq!(
1283            static_graph.target_content_hash(),
1284            moved_graph.target_content_hash(),
1285            "parent rigid motion must not invalidate the subtree content hash"
1286        );
1287    }
1288
1289    #[test]
1290    fn parent_content_offset_is_encoded_in_child_transform() {
1291        let child = BuildNodeSnapshot {
1292            node_id: 2,
1293            placement: Point { x: 11.0, y: 7.0 },
1294            size: Size {
1295                width: 40.0,
1296                height: 20.0,
1297            },
1298            content_offset: Point::default(),
1299            motion_context_animated: false,
1300            translated_content_context: false,
1301            measured_max_width: None,
1302            resolved_modifiers: ResolvedModifiers::default(),
1303            draw_commands: vec![],
1304            click_actions: vec![],
1305            pointer_inputs: vec![],
1306            clip_to_bounds: false,
1307            annotated_text: None,
1308            text_style: None,
1309            text_layout_options: None,
1310            text_pan: None,
1311            graphics_layer: None,
1312            children: vec![],
1313        };
1314
1315        let parent = BuildNodeSnapshot {
1316            node_id: 1,
1317            placement: Point::default(),
1318            size: Size {
1319                width: 80.0,
1320                height: 50.0,
1321            },
1322            content_offset: Point { x: 13.0, y: -9.0 },
1323            motion_context_animated: false,
1324            translated_content_context: false,
1325            measured_max_width: None,
1326            resolved_modifiers: ResolvedModifiers::default(),
1327            draw_commands: vec![],
1328            click_actions: vec![],
1329            pointer_inputs: vec![],
1330            clip_to_bounds: false,
1331            annotated_text: None,
1332            text_style: None,
1333            text_layout_options: None,
1334            text_pan: None,
1335            graphics_layer: None,
1336            children: vec![child],
1337        };
1338
1339        let graph = build_layer_node_for_test(parent, 1.0, false);
1340        let RenderNode::Layer(child) = &graph.children[0] else {
1341            panic!("expected child layer");
1342        };
1343
1344        let top_left = child.transform_to_parent.map_point(Point::default());
1345        assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1346    }
1347
1348    #[test]
1349    fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1350        fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1351            let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
1352                vec![DrawPrimitive::Rect {
1353                    rect: Rect {
1354                        x: 3.0,
1355                        y: 4.0,
1356                        width: 20.0,
1357                        height: 8.0,
1358                    },
1359                    brush: Brush::solid(Color::WHITE),
1360                }]
1361            }));
1362
1363            let child = BuildNodeSnapshot {
1364                node_id: 2,
1365                placement: Point { x: 11.0, y: 7.0 },
1366                size: Size {
1367                    width: 40.0,
1368                    height: 20.0,
1369                },
1370                content_offset: Point::default(),
1371                motion_context_animated: false,
1372                translated_content_context: false,
1373                measured_max_width: None,
1374                resolved_modifiers: ResolvedModifiers::default(),
1375                draw_commands: vec![child_command],
1376                click_actions: vec![],
1377                pointer_inputs: vec![],
1378                clip_to_bounds: false,
1379                annotated_text: None,
1380                text_style: None,
1381                text_layout_options: None,
1382                text_pan: None,
1383                graphics_layer: None,
1384                children: vec![],
1385            };
1386
1387            BuildNodeSnapshot {
1388                node_id: 1,
1389                placement: Point::default(),
1390                size: Size {
1391                    width: 80.0,
1392                    height: 50.0,
1393                },
1394                content_offset: offset,
1395                motion_context_animated,
1396                translated_content_context: true,
1397                measured_max_width: None,
1398                resolved_modifiers: ResolvedModifiers::default(),
1399                draw_commands: vec![],
1400                click_actions: vec![],
1401                pointer_inputs: vec![],
1402                clip_to_bounds: false,
1403                annotated_text: None,
1404                text_style: None,
1405                text_layout_options: None,
1406                text_pan: None,
1407                graphics_layer: None,
1408                children: vec![child],
1409            }
1410        }
1411
1412        let base = build_layer_node_for_test(
1413            parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
1414            1.0,
1415            false,
1416        );
1417        let moved = build_layer_node_for_test(
1418            parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
1419            1.0,
1420            false,
1421        );
1422        let rested = build_layer_node_for_test(
1423            parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
1424            1.0,
1425            false,
1426        );
1427
1428        let RenderNode::Layer(base_child) = &base.children[0] else {
1429            panic!("expected child layer");
1430        };
1431        let RenderNode::Layer(moved_child) = &moved.children[0] else {
1432            panic!("expected child layer");
1433        };
1434
1435        assert_ne!(
1436            base_child.transform_to_parent.map_point(Point::default()),
1437            moved_child.transform_to_parent.map_point(Point::default()),
1438            "scroll offset still has to move child content visually"
1439        );
1440        assert_eq!(
1441            base_child.target_content_hash(),
1442            moved_child.target_content_hash(),
1443            "child source content identity stays stable when only the parent scroll offset changes"
1444        );
1445        assert_ne!(
1446            base.target_content_hash(),
1447            moved.target_content_hash(),
1448            "a full-surface cache of the scroll viewport must include the scroll offset"
1449        );
1450        assert_ne!(
1451            base.target_content_hash(),
1452            rested.target_content_hash(),
1453            "full-surface cache keys must include active scroll motion policy"
1454        );
1455    }
1456
1457    #[test]
1458    fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
1459        let layer = graphics_layer_with_shaped_clip(
1460            GraphicsLayer::default(),
1461            true,
1462            Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
1463            Rect {
1464                x: 0.0,
1465                y: 0.0,
1466                width: 100.0,
1467                height: 40.0,
1468            },
1469        );
1470
1471        assert!(layer.clip);
1472        assert!(layer.render_effect.is_none());
1473        let LayerShape::Rounded(shape) = layer.shape else {
1474            panic!("rounded clip must be recorded as layer shape");
1475        };
1476        assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
1477        assert!(isolation_reasons(&layer).shape_clip);
1478    }
1479
1480    #[test]
1481    fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
1482        let existing = RenderEffect::blur(3.0);
1483        let layer = graphics_layer_with_shaped_clip(
1484            GraphicsLayer {
1485                render_effect: Some(existing.clone()),
1486                ..GraphicsLayer::default()
1487            },
1488            true,
1489            Some(RoundedCornerShape::uniform(10.0)),
1490            Rect {
1491                x: 0.0,
1492                y: 0.0,
1493                width: 100.0,
1494                height: 40.0,
1495            },
1496        );
1497
1498        let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
1499            panic!("existing effect should chain into rounded clip mask");
1500        };
1501        assert_eq!(*first, existing);
1502        assert!(
1503            matches!(*second, RenderEffect::Shader { .. }),
1504            "rounded mask must be the outer effect"
1505        );
1506    }
1507
1508    #[test]
1509    fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
1510        let mut composition = cranpose_ui::run_test_composition(|| {
1511            cranpose_ui::Box(
1512                Modifier::empty()
1513                    .width(100.0)
1514                    .height(40.0)
1515                    .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
1516                    .clip_to_bounds(),
1517                cranpose_ui::BoxSpec::default(),
1518                || {
1519                    Text("rounded child", Modifier::empty(), TextStyle::default());
1520                },
1521            );
1522        });
1523
1524        let root = composition.root().expect("rounded clip root");
1525        let handle = composition.runtime_handle();
1526        let mut applier = composition.applier_mut();
1527        applier.set_runtime_handle(handle);
1528        applier
1529            .compute_layout(
1530                root,
1531                Size {
1532                    width: 160.0,
1533                    height: 100.0,
1534                },
1535            )
1536            .expect("rounded clip layout");
1537        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1538        applier.clear_runtime_handle();
1539
1540        let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
1541        assert!(rounded_layer.graphics_layer.clip);
1542        assert!(matches!(
1543            rounded_layer.graphics_layer.shape,
1544            LayerShape::Rounded(_)
1545        ));
1546        assert!(rounded_layer.graphics_layer.render_effect.is_none());
1547        assert!(rounded_layer.isolation.shape_clip);
1548        assert!(
1549            !graph_has_runtime_shader_effect(&graph.root),
1550            "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
1551        );
1552    }
1553
1554    #[test]
1555    fn update_graph_from_applier_replaces_dirty_child_layer() {
1556        let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1557            Rc::new(RefCell::new(None));
1558        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1559        let state_holder_for_comp = state_holder.clone();
1560        let child_id_holder_for_comp = child_id_holder.clone();
1561
1562        let mut composition = cranpose_ui::run_test_composition(move || {
1563            let label = cranpose_core::useState(|| "before".to_string());
1564            *state_holder_for_comp.borrow_mut() = Some(label);
1565            let child_id_holder_for_content = child_id_holder_for_comp.clone();
1566            cranpose_ui::Box(
1567                Modifier::empty().size_points(240.0, 80.0),
1568                cranpose_ui::BoxSpec::default(),
1569                move || {
1570                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
1571                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
1572                    Text("stable", Modifier::empty(), TextStyle::default());
1573                },
1574            );
1575        });
1576
1577        let root = composition.root().expect("composition root");
1578        let viewport = Size {
1579            width: 240.0,
1580            height: 80.0,
1581        };
1582        let handle = composition.runtime_handle();
1583        let mut applier = composition.applier_mut();
1584        applier.set_runtime_handle(handle);
1585        applier
1586            .compute_layout(root, viewport)
1587            .expect("initial layout");
1588        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1589        let child_id = child_id_holder
1590            .borrow()
1591            .expect("text child id should be captured");
1592        let initial_transform = find_layer_by_node_id(&graph.root, child_id)
1593            .expect("text child layer")
1594            .transform_to_parent;
1595        applier.clear_runtime_handle();
1596        drop(applier);
1597
1598        let label = state_holder
1599            .borrow()
1600            .as_ref()
1601            .copied()
1602            .expect("label state should be captured");
1603        label.set_value("after".to_string());
1604        composition
1605            .process_invalid_scopes()
1606            .expect("text recomposition");
1607
1608        let handle = composition.runtime_handle();
1609        let mut applier = composition.applier_mut();
1610        applier.set_runtime_handle(handle);
1611        applier
1612            .compute_layout(root, viewport)
1613            .expect("updated layout");
1614        let child_id = child_id_holder
1615            .borrow()
1616            .expect("text child id should remain captured");
1617
1618        assert!(
1619            update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
1620            "dirty child should be replaceable from retained applier state"
1621        );
1622        applier.clear_runtime_handle();
1623
1624        let mut labels = Vec::new();
1625        collect_text_labels(&graph.root, &mut labels);
1626        assert!(
1627            labels.iter().any(|label| label == "after"),
1628            "updated graph should contain refreshed child text, got {labels:?}"
1629        );
1630        assert!(
1631            !labels.iter().any(|label| label == "before"),
1632            "updated graph should not retain stale child text, got {labels:?}"
1633        );
1634        assert!(
1635            labels.iter().any(|label| label == "stable"),
1636            "sibling content should remain present, got {labels:?}"
1637        );
1638        assert_eq!(
1639            find_layer_by_node_id(&graph.root, child_id)
1640                .expect("updated text child layer")
1641                .transform_to_parent,
1642            initial_transform,
1643            "draw-only child replacement must preserve the retained parent placement transform"
1644        );
1645    }
1646
1647    /// The per-frame scene build must publish a node's LIVE composited window
1648    /// rect into its `report_window_rect` sink — even when the layout tree is
1649    /// NOT built (`build_layout_tree: false`, exactly how the app runtime
1650    /// measures). This is the mechanism both bug 2 (a scroll container's
1651    /// `BringIntoViewResponder` viewport rect) and bug 3 (a text field's live
1652    /// `node_origin`, which anchors the overlay selection-handle / menu popups)
1653    /// rely on, since the layout `place` pass never runs in the runtime.
1654    #[test]
1655    fn scene_build_publishes_live_window_rect_without_layout_tree() {
1656        use cranpose_ui::{measure_layout_with_options, Box, BoxSpec, MeasureLayoutOptions};
1657        use std::cell::Cell;
1658
1659        let spacer_before = 120.0_f32;
1660        let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
1661            x: 0.0,
1662            y: 0.0,
1663            width: 0.0,
1664            height: 0.0,
1665        }));
1666        let sink_for_comp = sink.clone();
1667        let mut composition = cranpose_ui::run_test_composition(move || {
1668            let sink = sink_for_comp.clone();
1669            Column(
1670                Modifier::empty().size_points(200.0, 400.0),
1671                ColumnSpec::default(),
1672                move || {
1673                    Spacer(Size {
1674                        width: 200.0,
1675                        height: spacer_before,
1676                    });
1677                    Box(
1678                        Modifier::empty()
1679                            .size_points(200.0, 50.0)
1680                            .report_window_rect(sink.clone()),
1681                        BoxSpec::default(),
1682                        || {},
1683                    );
1684                },
1685            );
1686        });
1687
1688        let root = composition.root().expect("composition root");
1689        let viewport = Size {
1690            width: 200.0,
1691            height: 400.0,
1692        };
1693        let handle = composition.runtime_handle();
1694        let mut applier = composition.applier_mut();
1695        applier.set_runtime_handle(handle);
1696        // Measure like the runtime: DO NOT build the layout tree, so the layout
1697        // `place` pass never writes the sink. Only the scene build can.
1698        measure_layout_with_options(
1699            &mut applier,
1700            root,
1701            viewport,
1702            MeasureLayoutOptions {
1703                collect_semantics: false,
1704                build_layout_tree: false,
1705            },
1706        )
1707        .expect("layout");
1708        // Sanity: nothing has written the sink yet.
1709        assert_eq!(
1710            sink.get().height,
1711            0.0,
1712            "sink must start empty (place disabled)"
1713        );
1714
1715        let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
1716        applier.clear_runtime_handle();
1717
1718        let rect = sink.get();
1719        assert!(
1720            (rect.y - spacer_before).abs() < 0.5,
1721            "scene build must publish the box's live window-y (below the {spacer_before}px \
1722             spacer), got {}",
1723            rect.y
1724        );
1725        assert!(
1726            rect.width > 0.0 && rect.height > 0.0,
1727            "scene build must publish a non-empty window rect, got {rect:?}"
1728        );
1729    }
1730
1731    #[test]
1732    fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
1733        let mut graph = RenderGraph {
1734            root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
1735        };
1736        let mut applier = MemoryApplier::new();
1737
1738        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
1739
1740        assert_eq!(
1741            report,
1742            GraphUpdateReport {
1743                applied: false,
1744                hit_graph_dirty: true,
1745            },
1746            "dirty child graph updates must not report success when the replacement cannot be rebuilt"
1747        );
1748    }
1749
1750    #[test]
1751    fn update_graph_from_applier_refreshes_scroll_content_offset() {
1752        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1753        let scroll_holder_for_comp = scroll_holder.clone();
1754
1755        let mut composition = cranpose_ui::run_test_composition(move || {
1756            let scroll_state =
1757                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1758            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1759            Column(
1760                Modifier::empty()
1761                    .size_points(240.0, 120.0)
1762                    .vertical_scroll(scroll_state, false),
1763                ColumnSpec::default(),
1764                || {
1765                    Text("scroll top", Modifier::empty(), TextStyle::default());
1766                    Spacer(Size {
1767                        width: 0.0,
1768                        height: 160.0,
1769                    });
1770                    Text("scroll target", Modifier::empty(), TextStyle::default());
1771                },
1772            );
1773        });
1774
1775        let root = composition.root().expect("composition root");
1776        let viewport = Size {
1777            width: 240.0,
1778            height: 120.0,
1779        };
1780        let handle = composition.runtime_handle();
1781        let mut applier = composition.applier_mut();
1782        applier.set_runtime_handle(handle);
1783        applier
1784            .compute_layout(root, viewport)
1785            .expect("initial scroll layout");
1786        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1787        let initial_target_top =
1788            find_text_top(&graph.root, "scroll target").expect("initial target text");
1789        applier.clear_runtime_handle();
1790        drop(applier);
1791
1792        let scroll_state = scroll_holder
1793            .borrow()
1794            .as_ref()
1795            .cloned()
1796            .expect("scroll state should be captured");
1797        let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
1798        assert!(consumed_scroll > 0.0, "test scroll must be consumed");
1799        let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
1800        assert!(
1801            !dirty_nodes.is_empty(),
1802            "scroll state invalidation must schedule scoped layout graph update"
1803        );
1804
1805        let handle = composition.runtime_handle();
1806        let mut applier = composition.applier_mut();
1807        applier.set_runtime_handle(handle);
1808        applier
1809            .compute_layout(root, viewport)
1810            .expect("scrolled layout");
1811        let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
1812        applier.clear_runtime_handle();
1813
1814        assert!(report.applied, "scroll graph update should apply in place");
1815        let updated_target_top =
1816            find_text_top(&graph.root, "scroll target").expect("updated target text");
1817        assert!(
1818            updated_target_top < initial_target_top - consumed_scroll * 0.75,
1819            "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
1820        );
1821    }
1822
1823    #[test]
1824    fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
1825        let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1826            Rc::new(RefCell::new(None));
1827        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1828        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1829        let label_holder_for_comp = label_holder.clone();
1830        let scroll_holder_for_comp = scroll_holder.clone();
1831        let child_id_holder_for_comp = child_id_holder.clone();
1832
1833        let mut composition = cranpose_ui::run_test_composition(move || {
1834            let label = cranpose_core::useState(|| "scrolled child before".to_string());
1835            let scroll_state =
1836                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1837            *label_holder_for_comp.borrow_mut() = Some(label);
1838            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1839            let child_id_holder_for_content = child_id_holder_for_comp.clone();
1840            Column(
1841                Modifier::empty()
1842                    .size_points(260.0, 90.0)
1843                    .vertical_scroll(scroll_state, false),
1844                ColumnSpec::default(),
1845                move || {
1846                    Spacer(Size {
1847                        width: 0.0,
1848                        height: 24.0,
1849                    });
1850                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
1851                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
1852                    Spacer(Size {
1853                        width: 0.0,
1854                        height: 220.0,
1855                    });
1856                },
1857            );
1858        });
1859
1860        let root = composition.root().expect("composition root");
1861        let viewport = Size {
1862            width: 260.0,
1863            height: 90.0,
1864        };
1865        let handle = composition.runtime_handle();
1866        let mut applier = composition.applier_mut();
1867        applier.set_runtime_handle(handle);
1868        applier
1869            .compute_layout(root, viewport)
1870            .expect("initial layout");
1871        applier.clear_runtime_handle();
1872        drop(applier);
1873
1874        let scroll_state = scroll_holder
1875            .borrow()
1876            .as_ref()
1877            .cloned()
1878            .expect("scroll state should be captured");
1879        assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
1880
1881        let handle = composition.runtime_handle();
1882        let mut applier = composition.applier_mut();
1883        applier.set_runtime_handle(handle);
1884        applier
1885            .compute_layout(root, viewport)
1886            .expect("scrolled layout");
1887        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
1888        let child_id = child_id_holder
1889            .borrow()
1890            .expect("text child id should be captured");
1891        let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
1892            .expect("scrolled child layer")
1893            .transform_to_parent;
1894        applier.clear_runtime_handle();
1895        drop(applier);
1896
1897        let label = label_holder
1898            .borrow()
1899            .as_ref()
1900            .copied()
1901            .expect("label state should be captured");
1902        label.set_value("scrolled child after".to_string());
1903        composition
1904            .process_invalid_scopes()
1905            .expect("text recomposition");
1906
1907        let handle = composition.runtime_handle();
1908        let mut applier = composition.applier_mut();
1909        applier.set_runtime_handle(handle);
1910        applier
1911            .compute_layout(root, viewport)
1912            .expect("updated scrolled layout");
1913        let child_id = child_id_holder
1914            .borrow()
1915            .expect("text child id should remain captured");
1916        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
1917        applier.clear_runtime_handle();
1918
1919        assert!(report.applied, "dirty child graph update should apply");
1920        let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
1921        assert_eq!(
1922            updated.transform_to_parent, scrolled_transform,
1923            "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
1924        );
1925        let mut labels = Vec::new();
1926        collect_text_labels(&graph.root, &mut labels);
1927        assert!(
1928            labels.iter().any(|label| label == "scrolled child after"),
1929            "updated graph should contain refreshed text, got {labels:?}"
1930        );
1931    }
1932
1933    #[test]
1934    fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
1935        let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
1936            Rc::new(RefCell::new(None));
1937        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1938        let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1939        let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1940        let alpha_holder_for_comp = alpha_holder.clone();
1941        let scroll_holder_for_comp = scroll_holder.clone();
1942        let underlay_id_holder_for_comp = underlay_id_holder.clone();
1943        let overlay_id_holder_for_comp = overlay_id_holder.clone();
1944
1945        let mut composition = cranpose_ui::run_test_composition(move || {
1946            let alpha = cranpose_core::useState(|| 1.0f32);
1947            let scroll_state =
1948                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1949            *alpha_holder_for_comp.borrow_mut() = Some(alpha);
1950            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1951            let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
1952            let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
1953            Column(
1954                Modifier::empty()
1955                    .size_points(260.0, 120.0)
1956                    .vertical_scroll(scroll_state, false),
1957                ColumnSpec::default(),
1958                move || {
1959                    Spacer(Size {
1960                        width: 0.0,
1961                        height: 180.0,
1962                    });
1963                    cranpose_ui::Box(
1964                        Modifier::empty().size_points(188.0, 88.0),
1965                        cranpose_ui::BoxSpec::default(),
1966                        {
1967                            let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
1968                            let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
1969                            move || {
1970                                let underlay_id = cranpose_ui::Box(
1971                                    Modifier::empty().size_points(188.0, 88.0),
1972                                    cranpose_ui::BoxSpec::default(),
1973                                    || {
1974                                        Text(
1975                                            "UNDERLAY CONTENT",
1976                                            Modifier::empty().absolute_offset(12.0, 8.0),
1977                                            TextStyle::default(),
1978                                        );
1979                                    },
1980                                );
1981                                *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
1982                                let overlay_id = cranpose_ui::Box(
1983                                    Modifier::empty().size_points(188.0, 88.0).graphics_layer(
1984                                        move || GraphicsLayer {
1985                                            alpha: alpha.get(),
1986                                            ..GraphicsLayer::default()
1987                                        },
1988                                    ),
1989                                    cranpose_ui::BoxSpec::default(),
1990                                    || {
1991                                        Text(
1992                                            "TOP LAYER",
1993                                            Modifier::empty().absolute_offset(74.0, 39.6),
1994                                            TextStyle::default(),
1995                                        );
1996                                    },
1997                                );
1998                                *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
1999                            }
2000                        },
2001                    );
2002                    Spacer(Size {
2003                        width: 0.0,
2004                        height: 280.0,
2005                    });
2006                },
2007            );
2008        });
2009
2010        let root = composition.root().expect("composition root");
2011        let viewport = Size {
2012            width: 260.0,
2013            height: 120.0,
2014        };
2015        let handle = composition.runtime_handle();
2016        let mut applier = composition.applier_mut();
2017        applier.set_runtime_handle(handle);
2018        applier
2019            .compute_layout(root, viewport)
2020            .expect("initial layout");
2021        applier.clear_runtime_handle();
2022        drop(applier);
2023
2024        let scroll_state = scroll_holder
2025            .borrow()
2026            .as_ref()
2027            .cloned()
2028            .expect("scroll state should be captured");
2029        assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
2030
2031        let handle = composition.runtime_handle();
2032        let mut applier = composition.applier_mut();
2033        applier.set_runtime_handle(handle);
2034        applier
2035            .compute_layout(root, viewport)
2036            .expect("scrolled layout");
2037        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2038        applier.clear_runtime_handle();
2039        drop(applier);
2040
2041        let underlay_id = underlay_id_holder
2042            .borrow()
2043            .expect("underlay id should be captured");
2044        let overlay_id = overlay_id_holder
2045            .borrow()
2046            .expect("overlay id should be captured");
2047        let scrolled_underlay_origin =
2048            find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
2049        let scrolled_overlay_origin =
2050            find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
2051        assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
2052
2053        let alpha = alpha_holder
2054            .borrow()
2055            .as_ref()
2056            .copied()
2057            .expect("alpha state should be captured");
2058        alpha.set_value(0.35);
2059
2060        let handle = composition.runtime_handle();
2061        let mut applier = composition.applier_mut();
2062        applier.set_runtime_handle(handle);
2063        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
2064        applier.clear_runtime_handle();
2065
2066        assert!(report.applied, "dirty overlay graph update should apply");
2067        let updated_underlay_origin =
2068            find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
2069        let updated_overlay_origin =
2070            find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
2071        assert_eq!(
2072            updated_underlay_origin, scrolled_underlay_origin,
2073            "stable underlay must keep its scrolled origin"
2074        );
2075        assert_eq!(
2076            updated_overlay_origin, updated_underlay_origin,
2077            "dirty overlay graphics layer must stay aligned with its stable underlay"
2078        );
2079    }
2080
2081    #[test]
2082    fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
2083        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2084            Rc::new(RefCell::new(None));
2085        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2086        let offset_holder_for_comp = offset_holder.clone();
2087        let node_id_holder_for_comp = node_id_holder.clone();
2088
2089        let mut composition = cranpose_ui::run_test_composition(move || {
2090            let offset = cranpose_core::useState(|| 0.0f32);
2091            *offset_holder_for_comp.borrow_mut() = Some(offset);
2092            let node_id = cranpose_ui::Box(
2093                Modifier::empty()
2094                    .size_points(40.0, 20.0)
2095                    .graphics_layer(move || GraphicsLayer {
2096                        translation_x: offset.get(),
2097                        ..GraphicsLayer::default()
2098                    }),
2099                cranpose_ui::BoxSpec::default(),
2100                || {},
2101            );
2102            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2103        });
2104
2105        let root = composition.root().expect("composition root");
2106        let viewport = Size {
2107            width: 120.0,
2108            height: 80.0,
2109        };
2110        let handle = composition.runtime_handle();
2111        let mut applier = composition.applier_mut();
2112        applier.set_runtime_handle(handle);
2113        applier
2114            .compute_layout(root, viewport)
2115            .expect("initial layout");
2116        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2117        let node_id = node_id_holder
2118            .borrow()
2119            .expect("graphics layer node id should be captured");
2120        let initial_origin = find_layer_by_node_id(&graph.root, node_id)
2121            .expect("initial graphics layer")
2122            .transform_to_parent
2123            .map_point(Point::default());
2124        applier.clear_runtime_handle();
2125        drop(applier);
2126
2127        let offset = offset_holder
2128            .borrow()
2129            .as_ref()
2130            .copied()
2131            .expect("offset state should be captured");
2132        offset.set_value(32.0);
2133
2134        let handle = composition.runtime_handle();
2135        let mut applier = composition.applier_mut();
2136        applier.set_runtime_handle(handle);
2137        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2138        assert!(
2139            report.applied,
2140            "dirty graphics layer should be replaceable from retained applier state"
2141        );
2142        assert!(
2143            !report.hit_graph_dirty,
2144            "a moved visual-only layer should not force hit graph refresh"
2145        );
2146        applier.clear_runtime_handle();
2147
2148        let updated_origin = find_layer_by_node_id(&graph.root, node_id)
2149            .expect("updated graphics layer")
2150            .transform_to_parent
2151            .map_point(Point::default());
2152        assert!(
2153            (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
2154            "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
2155        );
2156    }
2157
2158    #[test]
2159    fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
2160        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2161            Rc::new(RefCell::new(None));
2162        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2163        let offset_holder_for_comp = offset_holder.clone();
2164        let node_id_holder_for_comp = node_id_holder.clone();
2165
2166        let mut composition = cranpose_ui::run_test_composition(move || {
2167            let offset = cranpose_core::useState(|| 0.0f32);
2168            *offset_holder_for_comp.borrow_mut() = Some(offset);
2169            let node_id = cranpose_ui::Box(
2170                Modifier::empty()
2171                    .size_points(40.0, 20.0)
2172                    .graphics_layer(move || GraphicsLayer {
2173                        translation_x: offset.get(),
2174                        ..GraphicsLayer::default()
2175                    })
2176                    .clickable(|_| {}),
2177                cranpose_ui::BoxSpec::default(),
2178                || {},
2179            );
2180            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2181        });
2182
2183        let root = composition.root().expect("composition root");
2184        let viewport = Size {
2185            width: 120.0,
2186            height: 80.0,
2187        };
2188        let handle = composition.runtime_handle();
2189        let mut applier = composition.applier_mut();
2190        applier.set_runtime_handle(handle);
2191        applier
2192            .compute_layout(root, viewport)
2193            .expect("initial layout");
2194        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2195        let node_id = node_id_holder
2196            .borrow()
2197            .expect("graphics layer node id should be captured");
2198        applier.clear_runtime_handle();
2199        drop(applier);
2200
2201        let offset = offset_holder
2202            .borrow()
2203            .as_ref()
2204            .copied()
2205            .expect("offset state should be captured");
2206        offset.set_value(32.0);
2207
2208        let handle = composition.runtime_handle();
2209        let mut applier = composition.applier_mut();
2210        applier.set_runtime_handle(handle);
2211        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2212        applier.clear_runtime_handle();
2213
2214        assert!(
2215            report.applied,
2216            "dirty clickable graphics layer should be replaceable from retained applier state"
2217        );
2218        assert!(
2219            report.hit_graph_dirty,
2220            "moved clickable layers must refresh hit geometry"
2221        );
2222    }
2223
2224    #[test]
2225    fn overlay_draw_commands_are_tagged_after_children() {
2226        let child = BuildNodeSnapshot {
2227            node_id: 2,
2228            placement: Point { x: 4.0, y: 5.0 },
2229            size: Size {
2230                width: 20.0,
2231                height: 10.0,
2232            },
2233            content_offset: Point::default(),
2234            motion_context_animated: false,
2235            translated_content_context: false,
2236            measured_max_width: None,
2237            resolved_modifiers: ResolvedModifiers::default(),
2238            draw_commands: vec![],
2239            click_actions: vec![],
2240            pointer_inputs: vec![],
2241            clip_to_bounds: false,
2242            annotated_text: None,
2243            text_style: None,
2244            text_layout_options: None,
2245            text_pan: None,
2246            graphics_layer: None,
2247            children: vec![],
2248        };
2249        let behind = DrawCommand::Behind(Rc::new(|_size: Size| {
2250            vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2251                rect: Rect {
2252                    x: 1.0,
2253                    y: 2.0,
2254                    width: 8.0,
2255                    height: 6.0,
2256                },
2257                brush: Brush::solid(Color::WHITE),
2258            }]
2259        }));
2260        let overlay = DrawCommand::Overlay(Rc::new(|_size: Size| {
2261            vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2262                rect: Rect {
2263                    x: 3.0,
2264                    y: 1.0,
2265                    width: 5.0,
2266                    height: 4.0,
2267                },
2268                brush: Brush::solid(Color::BLACK),
2269            }]
2270        }));
2271
2272        let parent = BuildNodeSnapshot {
2273            node_id: 1,
2274            placement: Point::default(),
2275            size: Size {
2276                width: 80.0,
2277                height: 50.0,
2278            },
2279            content_offset: Point::default(),
2280            motion_context_animated: false,
2281            translated_content_context: false,
2282            measured_max_width: None,
2283            resolved_modifiers: ResolvedModifiers::default(),
2284            draw_commands: vec![behind, overlay],
2285            click_actions: vec![],
2286            pointer_inputs: vec![],
2287            clip_to_bounds: false,
2288            annotated_text: None,
2289            text_style: None,
2290            text_layout_options: None,
2291            text_pan: None,
2292            graphics_layer: None,
2293            children: vec![child],
2294        };
2295
2296        let graph = build_layer_node_for_test(parent, 1.0, false);
2297        let RenderNode::Primitive(behind) = &graph.children[0] else {
2298            panic!("expected before-children primitive");
2299        };
2300        let RenderNode::Layer(_) = &graph.children[1] else {
2301            panic!("expected child layer");
2302        };
2303        let RenderNode::Primitive(overlay) = &graph.children[2] else {
2304            panic!("expected after-children primitive");
2305        };
2306
2307        assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
2308        assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
2309    }
2310
2311    #[test]
2312    fn stored_content_hash_changes_when_child_transform_changes() {
2313        let child = BuildNodeSnapshot {
2314            node_id: 2,
2315            placement: Point { x: 4.0, y: 5.0 },
2316            size: Size {
2317                width: 20.0,
2318                height: 10.0,
2319            },
2320            content_offset: Point::default(),
2321            motion_context_animated: false,
2322            translated_content_context: false,
2323            measured_max_width: None,
2324            resolved_modifiers: ResolvedModifiers::default(),
2325            draw_commands: vec![],
2326            click_actions: vec![],
2327            pointer_inputs: vec![],
2328            clip_to_bounds: false,
2329            annotated_text: None,
2330            text_style: None,
2331            text_layout_options: None,
2332            text_pan: None,
2333            graphics_layer: None,
2334            children: vec![],
2335        };
2336        let mut moved_child = child.clone();
2337        moved_child.placement.x += 7.0;
2338
2339        let parent = BuildNodeSnapshot {
2340            node_id: 1,
2341            placement: Point::default(),
2342            size: Size {
2343                width: 80.0,
2344                height: 50.0,
2345            },
2346            content_offset: Point::default(),
2347            motion_context_animated: false,
2348            translated_content_context: false,
2349            measured_max_width: None,
2350            resolved_modifiers: ResolvedModifiers::default(),
2351            draw_commands: vec![],
2352            click_actions: vec![],
2353            pointer_inputs: vec![],
2354            clip_to_bounds: false,
2355            annotated_text: None,
2356            text_style: None,
2357            text_layout_options: None,
2358            text_pan: None,
2359            graphics_layer: None,
2360            children: vec![child],
2361        };
2362        let moved_parent = BuildNodeSnapshot {
2363            children: vec![moved_child],
2364            ..parent.clone()
2365        };
2366
2367        let static_graph = build_layer_node_for_test(parent, 1.0, false);
2368        let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
2369
2370        assert_ne!(
2371            static_graph.target_content_hash(),
2372            moved_graph.target_content_hash(),
2373            "moving a child within the parent must invalidate the parent subtree hash"
2374        );
2375    }
2376
2377    #[test]
2378    fn stored_effect_hash_tracks_local_effect_only() {
2379        let base = BuildNodeSnapshot {
2380            node_id: 1,
2381            placement: Point::default(),
2382            size: Size {
2383                width: 80.0,
2384                height: 50.0,
2385            },
2386            content_offset: Point::default(),
2387            motion_context_animated: false,
2388            translated_content_context: false,
2389            measured_max_width: None,
2390            resolved_modifiers: ResolvedModifiers::default(),
2391            draw_commands: vec![],
2392            click_actions: vec![],
2393            pointer_inputs: vec![],
2394            clip_to_bounds: false,
2395            annotated_text: None,
2396            text_style: None,
2397            text_layout_options: None,
2398            text_pan: None,
2399            graphics_layer: None,
2400            children: vec![],
2401        };
2402        let mut effected = base.clone();
2403        effected.graphics_layer = Some(GraphicsLayer {
2404            render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
2405            ..GraphicsLayer::default()
2406        });
2407
2408        let base_graph = build_layer_node_for_test(base, 1.0, false);
2409        let effected_graph = build_layer_node_for_test(effected, 1.0, false);
2410
2411        assert_eq!(
2412            base_graph.target_content_hash(),
2413            effected_graph.target_content_hash(),
2414            "post-processing effect parameters belong to the effect hash, not the content hash"
2415        );
2416        assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
2417    }
2418
2419    #[test]
2420    fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
2421        let mut text_style = TextStyle::default();
2422        text_style.paragraph_style.text_align = TextAlign::Start;
2423        text_style.paragraph_style.text_direction = TextDirection::Rtl;
2424        text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
2425
2426        let snapshot = BuildNodeSnapshot {
2427            node_id: 1,
2428            placement: Point::default(),
2429            size: Size {
2430                width: 180.0,
2431                height: 48.0,
2432            },
2433            content_offset: Point::default(),
2434            motion_context_animated: false,
2435            translated_content_context: false,
2436            measured_max_width: Some(180.0),
2437            resolved_modifiers: ResolvedModifiers::default(),
2438            draw_commands: vec![],
2439            click_actions: vec![],
2440            pointer_inputs: vec![],
2441            clip_to_bounds: false,
2442            annotated_text: Some(AnnotatedString::from("rtl")),
2443            text_style: Some(text_style),
2444            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2445                overflow: cranpose_ui::TextOverflow::Clip,
2446                ..Default::default()
2447            }),
2448            text_pan: None,
2449            graphics_layer: None,
2450            children: vec![],
2451        };
2452
2453        let graph = build_layer_node_for_test(snapshot, 1.0, false);
2454        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2455            panic!("expected text primitive");
2456        };
2457        let PrimitiveNode::Text(text) = &text_primitive.node else {
2458            panic!("expected text primitive");
2459        };
2460        let clip = text
2461            .clip
2462            .expect("clipped overflow should produce a clip rect");
2463
2464        assert!(
2465            text.rect.x > 0.0,
2466            "RTL start alignment should shift the text rect within the available width"
2467        );
2468        assert!(
2469            clip.y < text.rect.y,
2470            "baseline shift must expand the clip upward so superscript glyphs are preserved"
2471        );
2472        assert!(
2473            clip.intersect(text.rect).is_some(),
2474            "the clip rect must intersect the shifted text draw rect"
2475        );
2476    }
2477
2478    #[test]
2479    fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
2480        let snapshot = BuildNodeSnapshot {
2481            node_id: 1,
2482            placement: Point::default(),
2483            size: Size {
2484                width: 320.0,
2485                height: 48.0,
2486            },
2487            content_offset: Point::default(),
2488            motion_context_animated: false,
2489            translated_content_context: false,
2490            measured_max_width: Some(320.0),
2491            resolved_modifiers: ResolvedModifiers::default(),
2492            draw_commands: vec![],
2493            click_actions: vec![],
2494            pointer_inputs: vec![],
2495            clip_to_bounds: false,
2496            annotated_text: Some(AnnotatedString::from("short")),
2497            text_style: Some(TextStyle::default()),
2498            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2499                overflow: cranpose_ui::TextOverflow::Clip,
2500                ..Default::default()
2501            }),
2502            text_pan: None,
2503            graphics_layer: None,
2504            children: vec![],
2505        };
2506
2507        let graph = build_layer_node_for_test(snapshot, 1.0, false);
2508        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2509            panic!("expected text primitive");
2510        };
2511        let PrimitiveNode::Text(text) = &text_primitive.node else {
2512            panic!("expected text primitive");
2513        };
2514        let clip = text.clip.expect("clipped text should keep a clip rect");
2515
2516        assert!(
2517            text.rect.width < 320.0,
2518            "text raster bounds should track measured glyph width instead of full content width"
2519        );
2520        assert_eq!(
2521            clip.width, 322.0,
2522            "text clip should still preserve the full content box plus clip padding"
2523        );
2524    }
2525
2526    /// Single-line text fields provide a pan resolver: the glyphs must be
2527    /// laid out unconstrained (no wrapping), shifted left by the pan offset,
2528    /// and clipped to the field bounds.
2529    #[test]
2530    fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
2531        let pan_offset = 25.0_f32;
2532        let field_width = 80.0_f32;
2533        let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
2534        let viewports = resolved_viewports.clone();
2535        let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
2536            node_id: 1,
2537            placement: Point::default(),
2538            size: Size {
2539                width: field_width,
2540                height: 24.0,
2541            },
2542            content_offset: Point::default(),
2543            motion_context_animated: false,
2544            translated_content_context: false,
2545            measured_max_width: Some(field_width),
2546            resolved_modifiers: ResolvedModifiers::default(),
2547            draw_commands: vec![],
2548            click_actions: vec![],
2549            pointer_inputs: vec![],
2550            clip_to_bounds: false,
2551            annotated_text: Some(AnnotatedString::from(
2552                "a very long single line of text that cannot fit",
2553            )),
2554            text_style: Some(TextStyle::default()),
2555            text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
2556            text_pan,
2557            graphics_layer: None,
2558            children: vec![],
2559        };
2560
2561        let text_node = |snapshot: BuildNodeSnapshot| {
2562            let graph = build_layer_node_for_test(snapshot, 1.0, false);
2563            let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2564                panic!("expected text primitive");
2565            };
2566            let PrimitiveNode::Text(text) = &text_primitive.node else {
2567                panic!("expected text primitive");
2568            };
2569            (**text).clone()
2570        };
2571
2572        let unpanned = text_node(make_snapshot(None));
2573        let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
2574            viewports.borrow_mut().push(viewport);
2575            pan_offset
2576        }))));
2577
2578        assert_eq!(
2579            resolved_viewports.borrow().as_slice(),
2580            &[field_width],
2581            "the pan resolver must receive the content viewport width"
2582        );
2583        assert_eq!(
2584            panned.rect.x, -pan_offset,
2585            "text glyphs must shift left by the pan offset"
2586        );
2587        assert!(
2588            panned.rect.width > field_width,
2589            "panned single-line text must be laid out unconstrained, got {}",
2590            panned.rect.width
2591        );
2592        assert!(
2593            panned.rect.width >= unpanned.rect.width,
2594            "unconstrained layout must not be narrower than wrapped layout"
2595        );
2596        assert!(
2597            panned.rect.height <= unpanned.rect.height,
2598            "single-line layout must not wrap onto extra lines"
2599        );
2600        let clip = panned
2601            .clip
2602            .expect("panned text field must clip to field bounds");
2603        assert!(
2604            clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
2605            "clip must not extend past the field bounds, got {clip:?}"
2606        );
2607    }
2608
2609    #[test]
2610    fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
2611        let child = BuildNodeSnapshot {
2612            node_id: 2,
2613            placement: Point { x: 11.0, y: 7.0 },
2614            size: Size {
2615                width: 120.0,
2616                height: 32.0,
2617            },
2618            content_offset: Point::default(),
2619            motion_context_animated: false,
2620            translated_content_context: false,
2621            measured_max_width: Some(120.0),
2622            resolved_modifiers: ResolvedModifiers::default(),
2623            draw_commands: vec![],
2624            click_actions: vec![],
2625            pointer_inputs: vec![],
2626            clip_to_bounds: false,
2627            annotated_text: Some(AnnotatedString::from("scrolling")),
2628            text_style: Some(TextStyle::default()),
2629            text_layout_options: None,
2630            text_pan: None,
2631            graphics_layer: None,
2632            children: vec![],
2633        };
2634        let parent = BuildNodeSnapshot {
2635            node_id: 1,
2636            placement: Point::default(),
2637            size: Size {
2638                width: 160.0,
2639                height: 64.0,
2640            },
2641            content_offset: Point { x: 0.0, y: -18.5 },
2642            motion_context_animated: false,
2643            translated_content_context: true,
2644            measured_max_width: None,
2645            resolved_modifiers: ResolvedModifiers::default(),
2646            draw_commands: vec![],
2647            click_actions: vec![],
2648            pointer_inputs: vec![],
2649            clip_to_bounds: false,
2650            annotated_text: None,
2651            text_style: None,
2652            text_layout_options: None,
2653            text_pan: None,
2654            graphics_layer: None,
2655            children: vec![child],
2656        };
2657
2658        let graph = build_layer_node_for_test(parent, 1.0, false);
2659        let RenderNode::Layer(child_layer) = &graph.children[0] else {
2660            panic!("expected child layer");
2661        };
2662        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2663            panic!("expected text primitive");
2664        };
2665        let PrimitiveNode::Text(text) = &text_primitive.node else {
2666            panic!("expected text primitive");
2667        };
2668
2669        assert_eq!(text.text_style.paragraph_style.text_motion, None);
2670        assert!(!child_layer.motion_context_animated);
2671    }
2672
2673    #[test]
2674    fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
2675        let child = BuildNodeSnapshot {
2676            node_id: 2,
2677            placement: Point { x: 11.0, y: 7.0 },
2678            size: Size {
2679                width: 120.0,
2680                height: 32.0,
2681            },
2682            content_offset: Point::default(),
2683            motion_context_animated: false,
2684            translated_content_context: false,
2685            measured_max_width: Some(120.0),
2686            resolved_modifiers: ResolvedModifiers::default(),
2687            draw_commands: vec![],
2688            click_actions: vec![],
2689            pointer_inputs: vec![],
2690            clip_to_bounds: false,
2691            annotated_text: Some(AnnotatedString::from("scrolling")),
2692            text_style: Some(TextStyle::default()),
2693            text_layout_options: None,
2694            text_pan: None,
2695            graphics_layer: None,
2696            children: vec![],
2697        };
2698        let parent = BuildNodeSnapshot {
2699            node_id: 1,
2700            placement: Point::default(),
2701            size: Size {
2702                width: 160.0,
2703                height: 64.0,
2704            },
2705            content_offset: Point { x: 0.0, y: -18.0 },
2706            motion_context_animated: false,
2707            translated_content_context: false,
2708            measured_max_width: None,
2709            resolved_modifiers: ResolvedModifiers::default(),
2710            draw_commands: vec![],
2711            click_actions: vec![],
2712            pointer_inputs: vec![],
2713            clip_to_bounds: false,
2714            annotated_text: None,
2715            text_style: None,
2716            text_layout_options: None,
2717            text_pan: None,
2718            graphics_layer: None,
2719            children: vec![child],
2720        };
2721
2722        let graph = build_layer_node_for_test(parent, 1.0, false);
2723        let RenderNode::Layer(child_layer) = &graph.children[0] else {
2724            panic!("expected child layer");
2725        };
2726        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2727            panic!("expected text primitive");
2728        };
2729        let PrimitiveNode::Text(text) = &text_primitive.node else {
2730            panic!("expected text primitive");
2731        };
2732
2733        assert_eq!(
2734            text.text_style.paragraph_style.text_motion, None,
2735            "content_offset alone must not force text onto the translated-content motion path"
2736        );
2737        assert!(!child_layer.motion_context_animated);
2738    }
2739
2740    #[test]
2741    fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
2742        let child = BuildNodeSnapshot {
2743            node_id: 2,
2744            placement: Point { x: 11.0, y: 7.0 },
2745            size: Size {
2746                width: 120.0,
2747                height: 32.0,
2748            },
2749            content_offset: Point::default(),
2750            motion_context_animated: false,
2751            translated_content_context: false,
2752            measured_max_width: Some(120.0),
2753            resolved_modifiers: ResolvedModifiers::default(),
2754            draw_commands: vec![],
2755            click_actions: vec![],
2756            pointer_inputs: vec![],
2757            clip_to_bounds: false,
2758            annotated_text: Some(AnnotatedString::from("shadow")),
2759            text_style: Some(TextStyle::from_span_style(SpanStyle {
2760                shadow: Some(cranpose_ui::text::Shadow {
2761                    color: Color::BLACK,
2762                    offset: Point::new(1.0, 2.0),
2763                    blur_radius: 3.0,
2764                }),
2765                ..SpanStyle::default()
2766            })),
2767            text_layout_options: None,
2768            text_pan: None,
2769            graphics_layer: None,
2770            children: vec![],
2771        };
2772        let parent = BuildNodeSnapshot {
2773            node_id: 1,
2774            placement: Point::default(),
2775            size: Size {
2776                width: 160.0,
2777                height: 64.0,
2778            },
2779            content_offset: Point { x: 0.0, y: -18.5 },
2780            motion_context_animated: false,
2781            translated_content_context: true,
2782            measured_max_width: None,
2783            resolved_modifiers: ResolvedModifiers::default(),
2784            draw_commands: vec![],
2785            click_actions: vec![],
2786            pointer_inputs: vec![],
2787            clip_to_bounds: false,
2788            annotated_text: None,
2789            text_style: None,
2790            text_layout_options: None,
2791            text_pan: None,
2792            graphics_layer: None,
2793            children: vec![child],
2794        };
2795
2796        let graph = build_layer_node_for_test(parent, 1.0, false);
2797        let RenderNode::Layer(child_layer) = &graph.children[0] else {
2798            panic!("expected child layer");
2799        };
2800        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2801            panic!("expected text primitive");
2802        };
2803        let PrimitiveNode::Text(text) = &text_primitive.node else {
2804            panic!("expected text primitive");
2805        };
2806
2807        assert_eq!(text.text_style.paragraph_style.text_motion, None);
2808    }
2809
2810    #[test]
2811    fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
2812        let child = BuildNodeSnapshot {
2813            node_id: 2,
2814            placement: Point { x: 11.0, y: 7.0 },
2815            size: Size {
2816                width: 120.0,
2817                height: 32.0,
2818            },
2819            content_offset: Point::default(),
2820            motion_context_animated: false,
2821            translated_content_context: false,
2822            measured_max_width: Some(120.0),
2823            resolved_modifiers: ResolvedModifiers::default(),
2824            draw_commands: vec![],
2825            click_actions: vec![],
2826            pointer_inputs: vec![],
2827            clip_to_bounds: false,
2828            annotated_text: Some(AnnotatedString::from("lazy")),
2829            text_style: Some(TextStyle::default()),
2830            text_layout_options: None,
2831            text_pan: None,
2832            graphics_layer: None,
2833            children: vec![],
2834        };
2835        let parent = BuildNodeSnapshot {
2836            node_id: 1,
2837            placement: Point::default(),
2838            size: Size {
2839                width: 160.0,
2840                height: 64.0,
2841            },
2842            content_offset: Point::default(),
2843            motion_context_animated: true,
2844            translated_content_context: false,
2845            measured_max_width: None,
2846            resolved_modifiers: ResolvedModifiers::default(),
2847            draw_commands: vec![],
2848            click_actions: vec![],
2849            pointer_inputs: vec![],
2850            clip_to_bounds: false,
2851            annotated_text: None,
2852            text_style: None,
2853            text_layout_options: None,
2854            text_pan: None,
2855            graphics_layer: None,
2856            children: vec![child],
2857        };
2858
2859        let graph = build_layer_node_for_test(parent, 1.0, false);
2860        let RenderNode::Layer(child_layer) = &graph.children[0] else {
2861            panic!("expected child layer");
2862        };
2863        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2864            panic!("expected text primitive");
2865        };
2866        let PrimitiveNode::Text(text) = &text_primitive.node else {
2867            panic!("expected text primitive");
2868        };
2869
2870        assert_eq!(text.text_style.paragraph_style.text_motion, None);
2871        assert!(graph.motion_context_animated);
2872        assert!(child_layer.motion_context_animated);
2873    }
2874
2875    #[test]
2876    fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
2877        let mut composition = cranpose_ui::run_test_composition(|| {
2878            let list_state = remember_lazy_list_state();
2879            LazyColumn(
2880                Modifier::empty(),
2881                list_state,
2882                LazyColumnSpec::default(),
2883                |scope| {
2884                    scope.item(Some(0), None, || {
2885                        Text("LazyMotion", Modifier::empty(), TextStyle::default());
2886                    });
2887                },
2888            );
2889        });
2890
2891        let root = composition.root().expect("lazy column root");
2892        let handle = composition.runtime_handle();
2893        let mut applier = composition.applier_mut();
2894        applier.set_runtime_handle(handle);
2895        let _ = applier
2896            .compute_layout(
2897                root,
2898                Size {
2899                    width: 240.0,
2900                    height: 240.0,
2901                },
2902            )
2903            .expect("lazy column layout");
2904        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2905        applier.clear_runtime_handle();
2906
2907        assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
2908    }
2909
2910    #[test]
2911    fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
2912        use std::cell::RefCell;
2913        use std::rc::Rc;
2914
2915        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
2916        let state_holder_for_comp = state_holder.clone();
2917        let mut composition = cranpose_ui::run_test_composition(move || {
2918            let list_state = remember_lazy_list_state();
2919            *state_holder_for_comp.borrow_mut() = Some(list_state);
2920            LazyColumn(
2921                Modifier::empty().height(120.0),
2922                list_state,
2923                LazyColumnSpec::default(),
2924                |scope| {
2925                    scope.items(
2926                        8,
2927                        None::<fn(usize) -> u64>,
2928                        None::<fn(usize) -> u64>,
2929                        |index| {
2930                            Text(
2931                                format!("LazyMotion {index}"),
2932                                Modifier::empty().padding(4.0),
2933                                TextStyle::default(),
2934                            );
2935                        },
2936                    );
2937                },
2938            );
2939        });
2940
2941        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
2942        list_state.scroll_to_item(3, 0.0);
2943
2944        let root = composition.root().expect("lazy column root");
2945        let handle = composition.runtime_handle();
2946        let mut applier = composition.applier_mut();
2947        applier.set_runtime_handle(handle);
2948        let _ = applier
2949            .compute_layout(
2950                root,
2951                Size {
2952                    width: 240.0,
2953                    height: 240.0,
2954                },
2955            )
2956            .expect("lazy column layout");
2957        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2958        let active_children = applier
2959            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
2960            .expect("lazy column should be subcompose");
2961        let child_debug: Vec<String> = active_children
2962            .iter()
2963            .map(|&child_id| {
2964                if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
2965                    format!(
2966                        "layout#{child_id} placed={} text={:?} children={:?}",
2967                        node.layout_state().is_placed,
2968                        node.modifier_slices_snapshot()
2969                            .text_content()
2970                            .map(str::to_string),
2971                        node.children.clone()
2972                    )
2973                }) {
2974                    summary
2975                } else if let Ok(summary) =
2976                    applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
2977                        format!(
2978                            "subcompose#{child_id} placed={} active_children={:?}",
2979                            node.layout_state().is_placed,
2980                            node.active_children()
2981                        )
2982                    })
2983                {
2984                    summary
2985                } else {
2986                    format!("missing#{child_id}")
2987                }
2988            })
2989            .collect();
2990        applier.clear_runtime_handle();
2991
2992        let first_index = list_state.first_visible_item_index();
2993        assert!(
2994            first_index > 0,
2995            "lazy list should move away from origin before graph building, observed first_index={first_index}"
2996        );
2997        let mut labels = Vec::new();
2998        collect_text_labels(&graph.root, &mut labels);
2999        assert_eq!(
3000            find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
3001            Some(None),
3002            "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
3003            labels,
3004            active_children,
3005            child_debug
3006        );
3007    }
3008
3009    #[test]
3010    fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
3011        use std::cell::RefCell;
3012        use std::rc::Rc;
3013
3014        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3015        let state_holder_for_comp = state_holder.clone();
3016        let mut composition = cranpose_ui::run_test_composition(move || {
3017            let list_state = remember_lazy_list_state();
3018            *state_holder_for_comp.borrow_mut() = Some(list_state);
3019            let mut spec =
3020                LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
3021            spec.beyond_bounds_item_count = 0;
3022            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
3023                scope.items(
3024                    12,
3025                    None::<fn(usize) -> u64>,
3026                    None::<fn(usize) -> u64>,
3027                    |index| {
3028                        Text(
3029                            format!("WarmRow {index}"),
3030                            Modifier::empty().height(32.0),
3031                            TextStyle::default(),
3032                        );
3033                    },
3034                );
3035            });
3036        });
3037
3038        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3039        list_state.scroll_to_item(4, 0.0);
3040
3041        let root = composition.root().expect("lazy column root");
3042        let handle = composition.runtime_handle();
3043        let mut applier = composition.applier_mut();
3044        applier.set_runtime_handle(handle);
3045        let _ = applier
3046            .compute_layout(
3047                root,
3048                Size {
3049                    width: 240.0,
3050                    height: 240.0,
3051                },
3052            )
3053            .expect("lazy column layout");
3054        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3055        let active_children = applier
3056            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3057            .expect("lazy column should be subcompose");
3058        applier.clear_runtime_handle();
3059
3060        let visible_indices: Vec<_> = list_state
3061            .layout_info()
3062            .visible_items_info
3063            .iter()
3064            .map(|item| item.index)
3065            .collect();
3066        let mut labels = Vec::new();
3067        collect_text_labels(&graph.root, &mut labels);
3068
3069        assert_eq!(
3070            visible_indices,
3071            vec![4, 5, 6],
3072            "test setup expects exactly three viewport-visible rows"
3073        );
3074        assert!(
3075            labels.iter().any(|label| label == "WarmRow 7"),
3076            "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
3077        );
3078    }
3079
3080    #[test]
3081    fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
3082        use std::cell::RefCell;
3083        use std::rc::Rc;
3084
3085        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3086        let state_holder_for_comp = state_holder.clone();
3087        let mut composition = cranpose_ui::run_test_composition(move || {
3088            let list_state = remember_lazy_list_state();
3089            *state_holder_for_comp.borrow_mut() = Some(list_state);
3090            LazyColumn(
3091                Modifier::empty().height(120.0),
3092                list_state,
3093                LazyColumnSpec::default(),
3094                |scope| {
3095                    scope.items(
3096                        8,
3097                        None::<fn(usize) -> u64>,
3098                        None::<fn(usize) -> u64>,
3099                        |index| {
3100                            Text(
3101                                format!("LazySnap {index}"),
3102                                Modifier::empty().padding(4.0),
3103                                TextStyle::default(),
3104                            );
3105                        },
3106                    );
3107                },
3108            );
3109        });
3110
3111        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3112        list_state.scroll_to_item(2, 7.5);
3113
3114        let root = composition.root().expect("lazy column root");
3115        let handle = composition.runtime_handle();
3116        let mut applier = composition.applier_mut();
3117        applier.set_runtime_handle(handle);
3118        let _ = applier
3119            .compute_layout(
3120                root,
3121                Size {
3122                    width: 240.0,
3123                    height: 240.0,
3124                },
3125            )
3126            .expect("lazy column layout");
3127        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3128        applier.clear_runtime_handle();
3129
3130        let layout_info = list_state.layout_info();
3131        let first_visible_offset = layout_info
3132            .visible_items_info
3133            .first()
3134            .expect("lazy layout should expose visible item info")
3135            .offset;
3136        let snap_offset = find_translated_content_offset(&graph.root)
3137            .expect("lazy list graph should include translated content context");
3138
3139        assert!(
3140            (snap_offset.y - first_visible_offset).abs() <= 0.001,
3141            "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
3142        );
3143    }
3144
3145    #[test]
3146    fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
3147        let child = BuildNodeSnapshot {
3148            node_id: 2,
3149            placement: Point { x: 11.0, y: 7.0 },
3150            size: Size {
3151                width: 120.0,
3152                height: 32.0,
3153            },
3154            content_offset: Point::default(),
3155            motion_context_animated: false,
3156            translated_content_context: false,
3157            measured_max_width: Some(120.0),
3158            resolved_modifiers: ResolvedModifiers::default(),
3159            draw_commands: vec![],
3160            click_actions: vec![],
3161            pointer_inputs: vec![],
3162            clip_to_bounds: false,
3163            annotated_text: Some(AnnotatedString::from("static")),
3164            text_style: Some(TextStyle::from_paragraph_style(
3165                cranpose_ui::text::ParagraphStyle {
3166                    text_motion: Some(TextMotion::Static),
3167                    ..Default::default()
3168                },
3169            )),
3170            text_layout_options: None,
3171            text_pan: None,
3172            graphics_layer: None,
3173            children: vec![],
3174        };
3175        let parent = BuildNodeSnapshot {
3176            node_id: 1,
3177            placement: Point::default(),
3178            size: Size {
3179                width: 160.0,
3180                height: 64.0,
3181            },
3182            content_offset: Point { x: 0.0, y: -18.5 },
3183            motion_context_animated: false,
3184            translated_content_context: true,
3185            measured_max_width: None,
3186            resolved_modifiers: ResolvedModifiers::default(),
3187            draw_commands: vec![],
3188            click_actions: vec![],
3189            pointer_inputs: vec![],
3190            clip_to_bounds: false,
3191            annotated_text: None,
3192            text_style: None,
3193            text_layout_options: None,
3194            text_pan: None,
3195            graphics_layer: None,
3196            children: vec![child],
3197        };
3198
3199        let graph = build_layer_node_for_test(parent, 1.0, false);
3200        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3201            panic!("expected child layer");
3202        };
3203        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3204            panic!("expected text primitive");
3205        };
3206        let PrimitiveNode::Text(text) = &text_primitive.node else {
3207            panic!("expected text primitive");
3208        };
3209
3210        assert_eq!(
3211            text.text_style.paragraph_style.text_motion,
3212            Some(TextMotion::Static),
3213            "explicit text motion must win over inherited scrolling motion context"
3214        );
3215    }
3216}