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