Skip to main content

cranpose_render_common/
scene_builder.rs

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