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, DrawCommandId, DrawRunNode, HitTestNode, IsolationReasons, LayerNode,
19    PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode,
20    TextPrimitiveNode,
21};
22use crate::layer_transform::layer_transform_to_parent;
23use crate::raster_cache::LayerRasterCacheHashes;
24use crate::style_shared::{primitives_for_placement_verified, DrawPlacement};
25
26const TEXT_CLIP_PAD: f32 = 1.0;
27const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
28
29#[derive(Clone)]
30struct BuildNodeSnapshot {
31    node_id: NodeId,
32    placement: Point,
33    size: Size,
34    content_offset: Point,
35    motion_context_animated: bool,
36    translated_content_context: bool,
37    measured_max_width: Option<f32>,
38    resolved_modifiers: ResolvedModifiers,
39    draw_commands: Vec<DrawCommand>,
40    click_actions: Vec<Rc<dyn Fn(Point)>>,
41    pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
42    clip_to_bounds: bool,
43    annotated_text: Option<AnnotatedString>,
44    text_style: Option<TextStyle>,
45    text_layout_options: Option<TextLayoutOptions>,
46    text_pan: Option<TextPanResolver>,
47    graphics_layer: Option<GraphicsLayer>,
48    children: Vec<Self>,
49}
50
51struct SnapshotNodeData {
52    layout_state: cranpose_ui::widgets::LayoutState,
53    modifier_slices: Rc<ModifierNodeSlices>,
54    resolved_modifiers: ResolvedModifiers,
55    children: Vec<NodeId>,
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub struct GraphUpdateReport {
60    pub applied: bool,
61    pub hit_graph_dirty: bool,
62}
63
64pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
65    bump_recording_generation();
66    let root_snapshot = layout_box_to_snapshot(root, None);
67    RenderGraph {
68        root: build_layer_node(root_snapshot, scale, false),
69    }
70}
71
72pub fn build_graph_from_applier(
73    applier: &mut MemoryApplier,
74    root: NodeId,
75    scale: f32,
76) -> Option<RenderGraph> {
77    bump_recording_generation();
78    Some(RenderGraph {
79        root: build_layer_node_from_applier(applier, root, scale, false)?,
80    })
81}
82
83pub fn update_graph_from_applier(
84    applier: &mut MemoryApplier,
85    graph: &mut RenderGraph,
86    dirty_nodes: &[NodeId],
87    scale: f32,
88) -> bool {
89    update_graph_from_applier_report(applier, graph, dirty_nodes, scale).applied
90}
91
92pub fn update_graph_from_applier_report(
93    applier: &mut MemoryApplier,
94    graph: &mut RenderGraph,
95    dirty_nodes: &[NodeId],
96    scale: f32,
97) -> GraphUpdateReport {
98    if dirty_nodes.is_empty() {
99        return GraphUpdateReport {
100            applied: true,
101            hit_graph_dirty: false,
102        };
103    }
104    bump_recording_generation();
105
106    if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
107        eprintln!("[scene-update-diag] dirty={dirty_nodes:?}");
108    }
109
110    let mut remaining_dirty_nodes = dirty_nodes.iter().copied().collect::<HashSet<_>>();
111    if let Some(root_id) = graph.root.node_id {
112        if remaining_dirty_nodes.contains(&root_id) {
113            let Some(root) = build_layer_node_from_applier(applier, root_id, scale, false) else {
114                return GraphUpdateReport {
115                    applied: false,
116                    hit_graph_dirty: true,
117                };
118            };
119            let hit_graph_dirty = layer_hit_graph_state_dirty(&graph.root, &root);
120            graph.root = root;
121            graph.root.recompute_raster_cache_hashes();
122            return GraphUpdateReport {
123                applied: true,
124                hit_graph_dirty,
125            };
126        }
127    }
128
129    let inherited_translated_content_context = graph.root.translated_content_context;
130    let report = match replace_dirty_layers_from_applier(
131        applier,
132        &mut graph.root,
133        &mut remaining_dirty_nodes,
134        inherited_translated_content_context,
135    ) {
136        Some(report) => report,
137        None => {
138            return GraphUpdateReport {
139                applied: false,
140                hit_graph_dirty: true,
141            };
142        }
143    };
144
145    if !remaining_dirty_nodes.is_empty() {
146        return GraphUpdateReport {
147            applied: false,
148            hit_graph_dirty: true,
149        };
150    }
151
152    if report.updated {
153        graph.root.recompute_raster_cache_hashes();
154    }
155    GraphUpdateReport {
156        applied: true,
157        hit_graph_dirty: report.hit_graph_dirty,
158    }
159}
160
161#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
162struct ReplaceDirtyLayersReport {
163    updated: bool,
164    hit_graph_dirty: bool,
165}
166
167fn replace_dirty_layers_from_applier(
168    applier: &mut MemoryApplier,
169    parent: &mut LayerNode,
170    dirty_nodes: &mut HashSet<NodeId>,
171    inherited_translated_content_context: bool,
172) -> Option<ReplaceDirtyLayersReport> {
173    if dirty_nodes.is_empty() {
174        return Some(ReplaceDirtyLayersReport::default());
175    }
176
177    let child_inherited_translated_content_context =
178        inherited_translated_content_context || parent.translated_content_context;
179    let mut report = ReplaceDirtyLayersReport::default();
180
181    for child in &mut parent.children {
182        let RenderNode::Layer(child_layer) = child else {
183            continue;
184        };
185
186        if child_layer
187            .node_id
188            .is_some_and(|node_id| dirty_nodes.remove(&node_id))
189        {
190            let mut replacement = build_layer_node_from_applier_internal(
191                applier,
192                child_layer
193                    .node_id
194                    .expect("dirty layer must have a node id"),
195                parent.motion_context_animated,
196                child_inherited_translated_content_context,
197                // Resolve live window origins in the dirty subtree from the (clean)
198                // parent's remembered child origin, so a `BasicTextField`'s
199                // `node_origin` — and thus its overlay selection-handle / menu
200                // `Popup`s — stay glued to the glyphs during a fling (which
201                // rebuilds only the scrolling subtree, not the whole tree).
202                Some(AbsOrigin {
203                    content_origin: parent.scene_children_origin,
204                    layer_translation: parent.scene_children_layer_translation,
205                }),
206            )?;
207            if parent.content_offset != Point::default() {
208                replacement.transform_to_parent =
209                    replacement
210                        .transform_to_parent
211                        .then(ProjectiveTransform::translation(
212                            parent.content_offset.x,
213                            parent.content_offset.y,
214                        ));
215            }
216            report.hit_graph_dirty |= layer_hit_graph_state_dirty(child_layer, &replacement);
217            remove_dirty_descendants(&replacement, dirty_nodes);
218            **child_layer = replacement;
219            report.updated = true;
220            continue;
221        }
222
223        let child_report = replace_dirty_layers_from_applier(
224            applier,
225            child_layer,
226            dirty_nodes,
227            child_inherited_translated_content_context,
228        )?;
229        report.updated |= child_report.updated;
230        report.hit_graph_dirty |= child_report.hit_graph_dirty;
231    }
232
233    if report.updated {
234        parent.has_hit_targets = parent.hit_test.is_some()
235            || parent.children.iter().any(|child| match child {
236                RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
237                RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
238            });
239    }
240
241    Some(report)
242}
243
244fn layer_hit_graph_state_dirty(previous: &LayerNode, replacement: &LayerNode) -> bool {
245    if previous.hit_test.is_some() || replacement.hit_test.is_some() {
246        return true;
247    }
248
249    if !(previous.has_hit_targets || replacement.has_hit_targets) {
250        return false;
251    }
252
253    previous.has_hit_targets != replacement.has_hit_targets
254        || previous.local_bounds != replacement.local_bounds
255        || previous.transform_to_parent != replacement.transform_to_parent
256        || previous.clip_rect() != replacement.clip_rect()
257        || previous.graphics_layer.shape != replacement.graphics_layer.shape
258}
259
260fn remove_dirty_descendants(layer: &LayerNode, dirty_nodes: &mut HashSet<NodeId>) {
261    for child in &layer.children {
262        let RenderNode::Layer(child_layer) = child else {
263            continue;
264        };
265        if let Some(node_id) = child_layer.node_id {
266            dirty_nodes.remove(&node_id);
267        }
268        remove_dirty_descendants(child_layer, dirty_nodes);
269    }
270}
271
272fn build_layer_node(
273    snapshot: BuildNodeSnapshot,
274    _root_scale: f32,
275    inherited_motion_context_animated: bool,
276) -> LayerNode {
277    build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
278}
279
280fn build_layer_node_internal(
281    snapshot: BuildNodeSnapshot,
282    inherited_motion_context_animated: bool,
283    inherited_translated_content_context: bool,
284) -> LayerNode {
285    let BuildNodeSnapshot {
286        node_id,
287        placement,
288        size,
289        content_offset,
290        motion_context_animated,
291        translated_content_context,
292        measured_max_width,
293        resolved_modifiers,
294        draw_commands,
295        click_actions,
296        pointer_inputs,
297        clip_to_bounds,
298        annotated_text,
299        text_style,
300        text_layout_options,
301        text_pan,
302        graphics_layer,
303        children: child_snapshots,
304    } = snapshot;
305    let local_bounds = Rect {
306        x: 0.0,
307        y: 0.0,
308        width: size.width,
309        height: size.height,
310    };
311    let graphics_layer = graphics_layer.unwrap_or_default();
312    let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
313    let isolation = isolation_reasons(&graphics_layer);
314    let cache_policy = if isolation.has_any() {
315        CachePolicy::Auto
316    } else {
317        CachePolicy::None
318    };
319    let shadow_clip = clip_to_bounds.then_some(local_bounds);
320    let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
321        shape: None,
322        click_actions,
323        pointer_inputs,
324        clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
325    });
326
327    let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
328    let child_translated_content_context =
329        inherited_translated_content_context || translated_content_context;
330
331    let mut children = draw_nodes(
332        node_id,
333        &draw_commands,
334        DrawPlacement::Behind,
335        size,
336        PrimitivePhase::BeforeChildren,
337    );
338    if let Some(text) = text_node_from_parts(TextNodeParts {
339        node_id,
340        local_bounds,
341        measured_max_width,
342        resolved_modifiers: &resolved_modifiers,
343        annotated_text: annotated_text.as_ref(),
344        text_style: text_style.as_ref(),
345        text_layout_options,
346        text_pan,
347        modifier_slices: None,
348    }) {
349        children.push(RenderNode::Primitive(PrimitiveEntry {
350            phase: PrimitivePhase::BeforeChildren,
351            node: PrimitiveNode::Text(Box::new(text)),
352        }));
353    }
354    let child_motion_context_animated = node_motion_context_animated;
355    for child in child_snapshots {
356        let mut child_layer = build_layer_node_internal(
357            child,
358            child_motion_context_animated,
359            child_translated_content_context,
360        );
361        if content_offset != Point::default() {
362            child_layer.transform_to_parent =
363                child_layer
364                    .transform_to_parent
365                    .then(ProjectiveTransform::translation(
366                        content_offset.x,
367                        content_offset.y,
368                    ));
369        }
370        children.push(RenderNode::Layer(Box::new(child_layer)));
371    }
372    children.extend(draw_nodes(
373        node_id,
374        &draw_commands,
375        DrawPlacement::Overlay,
376        size,
377        PrimitivePhase::AfterChildren,
378    ));
379    let has_hit_targets = hit_test.is_some()
380        || children.iter().any(|child| match child {
381            RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
382            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
383        });
384
385    LayerNode {
386        node_id: Some(node_id),
387        local_bounds,
388        transform_to_parent,
389        content_offset,
390        motion_context_animated: node_motion_context_animated,
391        translated_content_context,
392        translated_content_offset: if translated_content_context {
393            content_offset
394        } else {
395            Point::default()
396        },
397        // The `LayoutBox` snapshot path does not carry live window origins (the
398        // app runtime uses the applier path instead), so leave these at the
399        // identity — origin sinks in this path are written by the layout `place`
400        // pass.
401        scene_children_origin: Point::default(),
402        scene_children_layer_translation: Point::default(),
403        graphics_layer,
404        clip_to_bounds,
405        shadow_clip,
406        hit_test,
407        has_hit_targets,
408        isolation,
409        cache_policy,
410        cache_hashes: LayerRasterCacheHashes::default(),
411        cache_hashes_valid: false,
412        children,
413    }
414}
415
416/// The composited window-space origin accumulated for a node while walking the
417/// render graph. `content_origin` is where this node's own top-left sits before
418/// its graphics-layer translation; `layer_translation` is the accumulated
419/// ancestor graphics-layer translation. Mirrors the layout `place` pass, but is
420/// computed during the per-frame scene build (which is the only pass that runs
421/// in the app runtime — `build_layout_tree` is disabled there) so a scrolling
422/// field's window-origin / a scroll container's viewport-rect sinks stay live
423/// even during a fling (no re-layout, no pointer events). `None` in the partial
424/// (dirty-subtree) rebuild path, where the ancestor origin is not known — the
425/// sinks then keep their last full-build value rather than being corrupted.
426#[derive(Clone, Copy)]
427struct AbsOrigin {
428    content_origin: Point,
429    layer_translation: Point,
430}
431
432impl AbsOrigin {
433    const ROOT: AbsOrigin = AbsOrigin {
434        content_origin: Point { x: 0.0, y: 0.0 },
435        layer_translation: Point { x: 0.0, y: 0.0 },
436    };
437}
438
439fn build_layer_node_from_applier(
440    applier: &mut MemoryApplier,
441    node_id: NodeId,
442    _root_scale: f32,
443    inherited_motion_context_animated: bool,
444) -> Option<LayerNode> {
445    build_layer_node_from_applier_internal(
446        applier,
447        node_id,
448        inherited_motion_context_animated,
449        false,
450        Some(AbsOrigin::ROOT),
451    )
452}
453
454fn build_layer_node_from_applier_internal(
455    applier: &mut MemoryApplier,
456    node_id: NodeId,
457    inherited_motion_context_animated: bool,
458    inherited_translated_content_context: bool,
459    parent_abs: Option<AbsOrigin>,
460) -> Option<LayerNode> {
461    if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
462        let state = node.layout_state();
463        let children = node.children.clone();
464        let modifier_slices = node.modifier_slices_snapshot();
465        SnapshotNodeData {
466            layout_state: state,
467            modifier_slices,
468            resolved_modifiers: node.resolved_modifiers(),
469            children,
470        }
471    }) {
472        return build_layer_node_from_data(
473            applier,
474            node_id,
475            data,
476            inherited_motion_context_animated,
477            inherited_translated_content_context,
478            parent_abs,
479        );
480    }
481
482    if let Ok(data) = applier.with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
483        let state = node.layout_state();
484        let children = node.active_children();
485        let modifier_slices = node.modifier_slices_snapshot();
486        SnapshotNodeData {
487            layout_state: state,
488            modifier_slices,
489            resolved_modifiers: node.resolved_modifiers(),
490            children,
491        }
492    }) {
493        return build_layer_node_from_data(
494            applier,
495            node_id,
496            data,
497            inherited_motion_context_animated,
498            inherited_translated_content_context,
499            parent_abs,
500        );
501    }
502
503    None
504}
505
506fn build_layer_node_from_data(
507    applier: &mut MemoryApplier,
508    node_id: NodeId,
509    data: SnapshotNodeData,
510    inherited_motion_context_animated: bool,
511    inherited_translated_content_context: bool,
512    parent_abs: Option<AbsOrigin>,
513) -> Option<LayerNode> {
514    let SnapshotNodeData {
515        layout_state,
516        modifier_slices,
517        resolved_modifiers,
518        children,
519    } = data;
520    if !layout_state.is_placed {
521        return None;
522    }
523
524    let local_bounds = Rect {
525        x: 0.0,
526        y: 0.0,
527        width: layout_state.size.width,
528        height: layout_state.size.height,
529    };
530    if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
531        eprintln!(
532            "[scene-update-diag] build layer node={node_id:?} size=({:.2},{:.2}) pos=({:.2},{:.2})",
533            layout_state.size.width,
534            layout_state.size.height,
535            layout_state.position.x,
536            layout_state.position.y,
537        );
538    }
539    let clip_to_bounds = modifier_slices.clip_to_bounds();
540    let graphics_layer = graphics_layer_with_shaped_clip(
541        modifier_slices.graphics_layer().unwrap_or_default(),
542        clip_to_bounds,
543        modifier_slices.corner_shape(),
544        local_bounds,
545    );
546    let transform_to_parent =
547        layer_transform_to_parent(local_bounds, layout_state.position, &graphics_layer);
548    let isolation = isolation_reasons(&graphics_layer);
549    let cache_policy = if isolation.has_any() {
550        CachePolicy::Auto
551    } else {
552        CachePolicy::None
553    };
554    let click_actions = modifier_slices.click_handlers();
555    let pointer_inputs = modifier_slices.pointer_inputs();
556    let shadow_clip = clip_to_bounds.then_some(local_bounds);
557    let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
558        shape: None,
559        click_actions: click_actions.to_vec(),
560        pointer_inputs: pointer_inputs.to_vec(),
561        clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
562    });
563
564    // Publish this node's resolved size to its `pointer_input` handlers, so
565    // `PointerInputScope::size()` reports the node's real dimensions — the same
566    // box the events dispatched to those handlers are made local to. This is the
567    // only pass that runs in the app runtime, and it runs before the frame's
568    // pointer dispatch, so handlers see the current size (and track resizes)
569    // whether or not an event has arrived yet.
570    modifier_slices.publish_pointer_input_size(layout_state.size);
571
572    let node_motion_context_animated =
573        inherited_motion_context_animated || modifier_slices.motion_context_animated();
574    let local_translated_content_context = modifier_slices.translated_content_context();
575    let local_translated_content_offset = modifier_slices
576        .translated_content_offset()
577        .unwrap_or(layout_state.content_offset);
578    let child_translated_content_context =
579        inherited_translated_content_context || local_translated_content_context;
580
581    // Publish this node's LIVE composited window origin during the per-frame
582    // scene build. This is the only pass that runs in the app runtime
583    // (`build_layout_tree` is disabled there), and it uses `local_translated_
584    // content_offset` — the SAME live scroll/graphics-layer translation the
585    // renderer applies to the content — so:
586    //  * a `BasicTextField`'s `node_origin` (read by its draw closure to anchor
587    //    the overlay selection-handle / context-menu `Popup`s) tracks the field
588    //    through a fling, even though the in-content caret/highlight already
589    //    follow via the layer transform; and
590    //  * a scroll container's viewport rect is known for its
591    //    `BringIntoViewResponder`.
592    // `parent_abs` is `None` in the partial (dirty-subtree) rebuild path where
593    // the ancestor origin is unknown; the sinks then keep their last full-build
594    // value instead of being written wrong.
595    let this_abs = parent_abs.map(|parent| {
596        let (tx, ty) = modifier_slices
597            .graphics_layer()
598            .map(|layer| (layer.translation_x, layer.translation_y))
599            .unwrap_or((0.0, 0.0));
600        let top_left = Point {
601            x: parent.content_origin.x + layout_state.position.x,
602            y: parent.content_origin.y + layout_state.position.y,
603        };
604        let layer_translation = Point {
605            x: parent.layer_translation.x + tx,
606            y: parent.layer_translation.y + ty,
607        };
608        (top_left, layer_translation)
609    });
610    if let Some((top_left, layer_translation)) = this_abs {
611        let window_origin = Point {
612            x: top_left.x + layer_translation.x,
613            y: top_left.y + layer_translation.y,
614        };
615        if let Some(sink) = modifier_slices.text_field_window_origin() {
616            sink.set(window_origin);
617        }
618        if let Some(sink) = modifier_slices.viewport_window_rect() {
619            sink.set(Rect {
620                x: window_origin.x,
621                y: window_origin.y,
622                width: layout_state.size.width,
623                height: layout_state.size.height,
624            });
625        }
626    }
627    // Children inherit this node's content origin plus its `content_offset` —
628    // the SAME translation this build applies to child layer transforms below
629    // (a `LazyColumn`/`vertical_scroll` bakes the live scroll into its children's
630    // placement, so this tracks the scroll frame-to-frame). Using the layout
631    // content offset (not the snap-anchor `translated_content_offset`, which is
632    // a raster pixel-snap detail) keeps `node_origin` exactly on the rendered
633    // glyphs, so the overlay handle/menu `Popup`s stay glued to the text.
634    let child_abs = this_abs.map(|(top_left, layer_translation)| AbsOrigin {
635        content_origin: Point {
636            x: top_left.x + layout_state.content_offset.x,
637            y: top_left.y + layout_state.content_offset.y,
638        },
639        layer_translation,
640    });
641
642    let mut render_children = draw_nodes(
643        node_id,
644        modifier_slices.draw_commands(),
645        DrawPlacement::Behind,
646        layout_state.size,
647        PrimitivePhase::BeforeChildren,
648    );
649    if let Some(text) = text_node_from_parts(TextNodeParts {
650        node_id,
651        local_bounds,
652        measured_max_width: layout_state
653            .measurement_constraints
654            .max_width
655            .is_finite()
656            .then_some(layout_state.measurement_constraints.max_width),
657        resolved_modifiers: &resolved_modifiers,
658        annotated_text: modifier_slices.annotated_text(),
659        text_style: modifier_slices.text_style(),
660        text_layout_options: modifier_slices.text_layout_options(),
661        text_pan: modifier_slices.text_pan_resolver(),
662        modifier_slices: Some(modifier_slices.as_ref()),
663    }) {
664        render_children.push(RenderNode::Primitive(PrimitiveEntry {
665            phase: PrimitivePhase::BeforeChildren,
666            node: PrimitiveNode::Text(Box::new(text)),
667        }));
668    }
669    let child_motion_context_animated = node_motion_context_animated;
670    for child_id in children {
671        let Some(mut child_layer) = build_layer_node_from_applier_internal(
672            applier,
673            child_id,
674            child_motion_context_animated,
675            child_translated_content_context,
676            child_abs,
677        ) else {
678            continue;
679        };
680        if layout_state.content_offset != Point::default() {
681            child_layer.transform_to_parent =
682                child_layer
683                    .transform_to_parent
684                    .then(ProjectiveTransform::translation(
685                        layout_state.content_offset.x,
686                        layout_state.content_offset.y,
687                    ));
688        }
689        render_children.push(RenderNode::Layer(Box::new(child_layer)));
690    }
691    render_children.extend(draw_nodes(
692        node_id,
693        modifier_slices.draw_commands(),
694        DrawPlacement::Overlay,
695        layout_state.size,
696        PrimitivePhase::AfterChildren,
697    ));
698    let has_hit_targets = hit_test.is_some()
699        || render_children.iter().any(|child| match child {
700            RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
701            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
702        });
703
704    let layer = LayerNode {
705        node_id: Some(node_id),
706        local_bounds,
707        transform_to_parent,
708        content_offset: layout_state.content_offset,
709        motion_context_animated: node_motion_context_animated,
710        translated_content_context: local_translated_content_context,
711        translated_content_offset: if local_translated_content_context {
712            local_translated_content_offset
713        } else {
714            Point::default()
715        },
716        // Remember where this layer places its children so a later partial
717        // rebuild of a dirty descendant subtree can resolve live window origins
718        // without re-walking from the root (see `AbsOrigin`).
719        scene_children_origin: child_abs.map(|c| c.content_origin).unwrap_or_default(),
720        scene_children_layer_translation: child_abs
721            .map(|c| c.layer_translation)
722            .unwrap_or_default(),
723        graphics_layer,
724        clip_to_bounds,
725        shadow_clip,
726        hit_test,
727        has_hit_targets,
728        isolation,
729        cache_policy,
730        cache_hashes: LayerRasterCacheHashes::default(),
731        cache_hashes_valid: false,
732        children: render_children,
733    };
734    Some(layer)
735}
736
737/// Reusable per-command recording buffers, keyed by the command's stable
738/// identity. The graph shares each primitive vector AND each compact
739/// recording (`Rc`) and still holds last frame's buffers while this frame
740/// records, so each command keeps two of each: the frame-before-last's is
741/// the free one, and steady-state re-recording ping-pongs between the two
742/// with no buffer allocation. A handle a live graph node still shares is
743/// never written through — reuse requires sole ownership, checked at
744/// acquisition.
745struct RecorderSlot {
746    generation: u64,
747    handles: [Option<Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>>; 2],
748    /// The command's compact recording buffers (tape + typed stores), under
749    /// the same double-buffer discipline as `handles`: the graph frame owns
750    /// a handle to the exact recording it was built from (its bypassed
751    /// spans' only rematerialization source — see
752    /// [`cranpose_ui_graphics::CommandReplayFrame::fallback`]), so a
753    /// recording a live frame still shares is never written through, and
754    /// steady-state re-recording ping-pongs between the pair with no buffer
755    /// allocation.
756    recordings: [Option<Rc<cranpose_ui_graphics::CommandRecording>>; 2],
757    /// Per-command similarity verification state: the retained snapshot and
758    /// its segments. Advances every time the command re-records while a
759    /// retained feed is active.
760    replay: cranpose_ui_graphics::CommandReplayState,
761    /// The feed epoch `replay` was built under; `None` before the first
762    /// verified recording. See [`set_retained_feed_epoch`].
763    replay_epoch: Option<u64>,
764    /// The previous build's emission in re-emittable form, for the
765    /// stale-transition serve (see [`SavedReplayEmission`]). `None` unless
766    /// the flag is on and the last build emitted a replay frame.
767    saved_emission: Option<SavedReplayEmission>,
768}
769
770/// One command's emitted frame, saved so the NEXT build can re-emit it
771/// byte-for-byte if its verification collapses — the stale-transition
772/// serve. A collapse frame otherwise re-materializes and re-encodes the
773/// whole tape at once (22-75 ms against a ~17k-record scene on a
774/// watch-class core); re-emitting the previous frame's output costs one
775/// frame of lag on the collapsing command instead. Valid only when exactly
776/// one build has passed since the save (`generation`) under the same
777/// renderer slot universe (`epoch`); the serve TAKES the emission, so two
778/// consecutive stale frames are unconstructible — the one-frame cap is
779/// structural, not a counter.
780///
781/// In the steady state the `Rc`s alias the registry's live handles, so a
782/// save is two refcount bumps plus one small sanitized span vector — never
783/// a copy of the primitives or the tape.
784struct SavedReplayEmission {
785    /// The frame's primitive-space spans, sanitized for re-emission by
786    /// [`sanitized_replay_spans`]: recolors emptied, capture spans
787    /// downgraded to dynamic draws.
788    spans: Vec<cranpose_ui_graphics::FrameSpan>,
789    /// The similarity pivot the spans' transforms rotate and scale about.
790    center: cranpose_ui_graphics::Point,
791    /// The emission's materialized primitives — the exact vector the
792    /// spans' `range`s address.
793    primitives: Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>,
794    /// The emission's published recording — the exact tape the spans'
795    /// `tape_range`s address, re-attached as the re-emitted frame's
796    /// `fallback` so bypassed spans keep their rematerialization source.
797    recording: Rc<cranpose_ui_graphics::CommandRecording>,
798    /// Retained feed epoch at save: a different epoch means the renderer's
799    /// slot universe died, and the spans' slot references with it.
800    epoch: u64,
801    /// [`RECORDING_GENERATION`] at save. Serving requires
802    /// `generation + 1 == current`: only the immediately following build
803    /// may re-emit, so a served frame is never more than one frame stale.
804    generation: u64,
805}
806
807/// Kill switch for the stale-transition serve, default OFF: set
808/// `CRANPOSE_STALE_TRANSITION` (to anything but `0` or empty) to let a
809/// command's replay collapse frame re-emit the previous build's emission
810/// instead of re-materializing its whole tape. Gates BOTH the save and the
811/// serve, so OFF is today's behavior byte-for-byte with zero saved-state
812/// cost. Read fresh (not cached) so an A/B comparison can flip it
813/// mid-process, exactly like the renderer's `CRANPOSE_COMMAND_FEED`; one
814/// environment lookup per build is noise against the multi-vsync frame
815/// this exists to remove.
816fn stale_transition_enabled() -> bool {
817    matches!(
818        std::env::var("CRANPOSE_STALE_TRANSITION").as_deref(),
819        Ok(value) if !value.is_empty() && value != "0"
820    )
821}
822
823/// Frame spans prepared for re-emission on a later build. Two rewrites,
824/// both required for the re-emitted frame to redraw the previous frame's
825/// pixels exactly:
826///
827/// - Recolors are emptied. The renderer's slot paint is a patched mirror
828///   of absolute color writes, so the previous frame's recolors still
829///   stand; re-sending them would be redundant patch traffic, and a
830///   re-emission with them emptied redraws the same colors by
831///   construction.
832/// - Capture spans (`capture: true`) become plain dynamic draws over the
833///   same materialized range. Their content was already offered for
834///   capture when the frame first rendered; a re-emission must draw the
835///   same pixels — capture frames render their content ordinarily, so a
836///   dynamic draw of the same primitives is byte-identical — without
837///   re-queuing a capture under a recorder state that has since moved on.
838fn sanitized_replay_spans(
839    spans: &[cranpose_ui_graphics::FrameSpan],
840) -> Vec<cranpose_ui_graphics::FrameSpan> {
841    use cranpose_ui_graphics::FrameSpan;
842    spans
843        .iter()
844        .map(|span| match span {
845            FrameSpan::Retained {
846                capture: true,
847                range,
848                ..
849            } => FrameSpan::Dynamic { range: *range },
850            FrameSpan::Retained {
851                slot,
852                capture: false,
853                slot_offset,
854                range,
855                tape_range,
856                transform,
857                recolors: _,
858                bounds,
859            } => FrameSpan::Retained {
860                slot: *slot,
861                capture: false,
862                slot_offset: *slot_offset,
863                range: *range,
864                tape_range: *tape_range,
865                transform: *transform,
866                recolors: Vec::new(),
867                bounds: *bounds,
868            },
869            FrameSpan::Dynamic { range } => FrameSpan::Dynamic { range: *range },
870        })
871        .collect()
872}
873
874/// Whether `id` holds a saved emission the CURRENT build may serve: saved
875/// exactly one recording generation ago, under the currently declared feed
876/// epoch. Anything else — older, consumed by a previous serve, from a dead
877/// slot universe, or no feed at all — is not servable, which is what caps
878/// staleness at one frame structurally.
879fn saved_emission_available(id: DrawCommandId) -> bool {
880    let Some(epoch) = RETAINED_FEED_EPOCH.with(std::cell::Cell::get) else {
881        return false;
882    };
883    let generation = RECORDING_GENERATION.with(std::cell::Cell::get);
884    COMMAND_RECORDINGS.with(|map| {
885        map.borrow()
886            .get(&id)
887            .and_then(|slot| slot.saved_emission.as_ref())
888            .is_some_and(|saved| {
889                saved.generation.wrapping_add(1) == generation && saved.epoch == epoch
890            })
891    })
892}
893
894/// Takes `id`'s saved emission for serving. Consuming it (rather than
895/// cloning) is deliberate: with the emission gone, a second collapse on
896/// the very next build finds nothing to serve and pays the ordinary
897/// collapse — the one-frame staleness cap needs no counter.
898fn take_saved_emission(id: DrawCommandId) -> Option<SavedReplayEmission> {
899    COMMAND_RECORDINGS.with(|map| {
900        map.borrow_mut()
901            .get_mut(&id)
902            .and_then(|slot| slot.saved_emission.take())
903    })
904}
905
906/// Stores (or clears, with `None`) `id`'s saved emission. Clearing on
907/// frame-less builds matters: without it a command that stops emitting
908/// replay frames would pin its last emission's primitives and tape
909/// indefinitely.
910fn store_saved_emission(id: DrawCommandId, saved: Option<SavedReplayEmission>) {
911    COMMAND_RECORDINGS.with(|map| {
912        if let Some(slot) = map.borrow_mut().get_mut(&id) {
913            slot.saved_emission = saved;
914        }
915    });
916}
917
918thread_local! {
919    static COMMAND_RECORDINGS: std::cell::RefCell<
920        std::collections::HashMap<DrawCommandId, RecorderSlot, cranpose_ui_graphics::FxBuildHasher>,
921    > = std::cell::RefCell::new(std::collections::HashMap::default());
922    static RECORDING_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
923    static RETAINED_FEED_EPOCH: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
924}
925
926/// Declares that the renderer consuming graphs built on this thread retains
927/// draw-run spans by identity, so scene building should verify command
928/// recordings and attach [`cranpose_ui_graphics::CommandReplayFrame`]s to
929/// the runs it builds. The epoch names the renderer's retained-slot
930/// universe: bumping it (device loss, scale change — anything that dropped
931/// slots wholesale) resets every command's verification state, so no frame
932/// ever references a slot from a dead universe. Renderers that draw every
933/// primitive (pixels) never call this and never pay for verification.
934pub fn set_retained_feed_epoch(epoch: Option<u64>) {
935    RETAINED_FEED_EPOCH.with(|cell| cell.set(epoch));
936}
937
938thread_local! {
939    static CONFIRMED_RETAINED_SLOTS: std::cell::RefCell<
940        std::collections::HashMap<(DrawCommandId, u32), u64, cranpose_ui_graphics::FxBuildHasher>,
941    > = std::cell::RefCell::new(std::collections::HashMap::default());
942}
943
944/// The renderer's word that it holds a live retained buffer for this
945/// (command, slot) identity, stamped with the retained-feed generation the
946/// buffer was captured under. Only confirmed spans may skip materialization:
947/// an unconfirmed span's primitives are the renderer's only way to draw it
948/// in the same frame. The stamp is what keeps the word LIVE: a confirmation
949/// from a dead slot universe (renderer replaced, device lost, root-scale
950/// change) stops matching the epoch declared for the build and bypass fails
951/// closed instead of trusting a buffer that no longer exists.
952pub fn confirm_retained_slot(command: DrawCommandId, slot: u32, generation: u64) {
953    CONFIRMED_RETAINED_SLOTS.with(|map| {
954        map.borrow_mut().insert((command, slot), generation);
955    });
956}
957
958/// The renderer released this identity's buffer (aged out, replaced):
959/// spans referencing it must materialize again.
960pub fn revoke_retained_slot(command: DrawCommandId, slot: u32) {
961    CONFIRMED_RETAINED_SLOTS.with(|map| {
962        map.borrow_mut().remove(&(command, slot));
963    });
964}
965
966/// Wholesale retire: every confirmation dies with the slots.
967pub fn clear_retained_slot_confirmations() {
968    CONFIRMED_RETAINED_SLOTS.with(|map| map.borrow_mut().clear());
969}
970
971/// Whether the renderer has confirmed a live retained buffer for this
972/// identity UNDER THE EPOCH DECLARED FOR THIS BUILD (readable by tests
973/// driving the recorder by hand). No declared epoch means no consumer for
974/// bypassed spans, so nothing may skip materialization; a stored generation
975/// from another epoch is a buffer of a dead slot universe.
976pub fn retained_slot_confirmed(command: DrawCommandId, slot: u32) -> bool {
977    let Some(epoch) = RETAINED_FEED_EPOCH.with(std::cell::Cell::get) else {
978        return false;
979    };
980    CONFIRMED_RETAINED_SLOTS.with(|map| map.borrow().get(&(command, slot)) == Some(&epoch))
981}
982
983thread_local! {
984    static VERIFY_EXECUTOR: std::cell::Cell<
985        Option<&'static dyn cranpose_ui_graphics::VerifyExecutor>,
986    > = const { std::cell::Cell::new(None) };
987}
988
989/// Lends the renderer's frame worker pool to command verification: while
990/// set, segment commits at a record boundary run across the pool's lanes
991/// instead of on the build thread alone. `None` (the default, and the wasm
992/// state) keeps verification serial.
993pub fn set_verify_executor(pool: Option<&'static dyn cranpose_ui_graphics::VerifyExecutor>) {
994    VERIFY_EXECUTOR.with(|cell| cell.set(pool));
995}
996
997/// The executor lent by [`set_verify_executor`], if any.
998pub fn verify_executor() -> Option<&'static dyn cranpose_ui_graphics::VerifyExecutor> {
999    VERIFY_EXECUTOR.with(|cell| cell.get())
1000}
1001
1002/// Test hook: drops every command recording on this thread, severing every
1003/// AMBIENT rematerialization source. Frame-owned fallbacks
1004/// ([`cranpose_ui_graphics::CommandReplayFrame::fallback`]) are unaffected
1005/// by construction — which is exactly what the fail-closed tests prove.
1006#[doc(hidden)]
1007pub fn clear_command_recordings_for_tests() {
1008    COMMAND_RECORDINGS.with(|map| map.borrow_mut().clear());
1009}
1010
1011/// Called once per graph build/update. The sweep is pure capacity
1012/// management: it drops slots whose commands stopped recording long ago
1013/// (screen navigated away). A slot's shared `handles` and `recordings` only
1014/// die here if no graph frame shares them — a frame that still needs its
1015/// recording (bypassed spans) owns its own handle
1016/// ([`cranpose_ui_graphics::CommandReplayFrame::fallback`]), so nothing the
1017/// sweep does can ever remove a frame's rematerialization source.
1018/// Clean-but-live commands losing their slot merely re-earn capacity if
1019/// they ever re-record.
1020fn bump_recording_generation() {
1021    let generation = RECORDING_GENERATION.with(|cell| {
1022        let next = cell.get().wrapping_add(1);
1023        cell.set(next);
1024        next
1025    });
1026    if generation.is_multiple_of(512) {
1027        COMMAND_RECORDINGS.with(|map| {
1028            map.borrow_mut()
1029                .retain(|_, slot| generation.wrapping_sub(slot.generation) <= 64);
1030        });
1031    }
1032}
1033
1034fn acquire_recording(
1035    id: DrawCommandId,
1036) -> (
1037    cranpose_ui_graphics::CommandRecording,
1038    Vec<cranpose_ui_graphics::DrawPrimitive>,
1039    Option<cranpose_ui_graphics::CommandReplayState>,
1040) {
1041    let feed_epoch = RETAINED_FEED_EPOCH.with(std::cell::Cell::get);
1042    COMMAND_RECORDINGS.with(|map| {
1043        let mut map = map.borrow_mut();
1044        let Some(slot) = map.get_mut(&id) else {
1045            return (
1046                cranpose_ui_graphics::CommandRecording::default(),
1047                Vec::new(),
1048                feed_epoch.map(|_| cranpose_ui_graphics::CommandReplayState::default()),
1049            );
1050        };
1051        // First recording of the pair the registry solely owns; one a live
1052        // graph frame still shares (its `fallback`) is never written
1053        // through. `DrawScopeDefault` clears the contents on construction,
1054        // so only the capacity survives the unwrap.
1055        let mut recording = cranpose_ui_graphics::CommandRecording::default();
1056        for shared in &mut slot.recordings {
1057            if shared
1058                .as_ref()
1059                .is_some_and(|shared| Rc::strong_count(shared) == 1)
1060            {
1061                let shared = shared.take().expect("checked some above");
1062                recording = Rc::try_unwrap(shared).expect("sole owner checked above");
1063                break;
1064            }
1065        }
1066        // A state from another slot universe (renderer dropped its retained
1067        // slots wholesale) restarts from scratch — its slot ids reference
1068        // buffers that no longer exist.
1069        let replay = feed_epoch.map(|epoch| {
1070            if slot.replay_epoch == Some(epoch) {
1071                std::mem::take(&mut slot.replay)
1072            } else {
1073                cranpose_ui_graphics::CommandReplayState::default()
1074            }
1075        });
1076        for handle in &mut slot.handles {
1077            if handle
1078                .as_ref()
1079                .is_some_and(|shared| Rc::strong_count(shared) == 1)
1080            {
1081                let shared = handle.take().expect("checked some above");
1082                let storage = Rc::try_unwrap(shared).expect("sole owner checked above");
1083                return (recording, storage, replay);
1084            }
1085        }
1086        (recording, Vec::new(), replay)
1087    })
1088}
1089
1090/// Publishes the command's finished frame into the registry and returns the
1091/// shared handles the graph rides: the materialized primitives and the
1092/// recording they came from. The recording MOVES into its handle — the
1093/// multi-thousand-record tape is never cloned — and the frame that keeps
1094/// the returned handle owns its rematerialization source outright, immune
1095/// to anything the registry does afterwards.
1096fn publish_recording(
1097    id: DrawCommandId,
1098    recording: cranpose_ui_graphics::CommandRecording,
1099    primitives: Vec<cranpose_ui_graphics::DrawPrimitive>,
1100    replay: Option<cranpose_ui_graphics::CommandReplayState>,
1101) -> (
1102    Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>,
1103    Rc<cranpose_ui_graphics::CommandRecording>,
1104) {
1105    let shared = Rc::new(primitives);
1106    let recording = Rc::new(recording);
1107    COMMAND_RECORDINGS.with(|map| {
1108        let mut map = map.borrow_mut();
1109        let generation = RECORDING_GENERATION.with(std::cell::Cell::get);
1110        let slot = map.entry(id).or_insert_with(|| RecorderSlot {
1111            generation,
1112            handles: [None, None],
1113            recordings: [None, None],
1114            replay: cranpose_ui_graphics::CommandReplayState::default(),
1115            replay_epoch: None,
1116            saved_emission: None,
1117        });
1118        slot.generation = generation;
1119        if let Some(replay) = replay {
1120            slot.replay = replay;
1121            slot.replay_epoch = RETAINED_FEED_EPOCH.with(std::cell::Cell::get);
1122        }
1123        // Newest first; the displaced oldest handle drops out of the
1124        // registry, and its buffer lives on only while a graph node holds it.
1125        slot.recordings[1] = slot.recordings[0].take();
1126        slot.recordings[0] = Some(recording.clone());
1127        slot.handles[1] = slot.handles[0].take();
1128        slot.handles[0] = Some(shared.clone());
1129    });
1130    (shared, recording)
1131}
1132
1133fn draw_nodes(
1134    node_id: NodeId,
1135    commands: &[DrawCommand],
1136    placement: DrawPlacement,
1137    size: Size,
1138    phase: PrimitivePhase,
1139) -> Vec<RenderNode> {
1140    let mut nodes = Vec::new();
1141    let stale_transition = stale_transition_enabled();
1142    for (command_index, command) in commands.iter().enumerate() {
1143        let id = DrawCommandId {
1144            node_id,
1145            command_index: command_index as u32,
1146            placement,
1147        };
1148        let (recording, storage, mut replay) = acquire_recording(id);
1149        let stale_available = stale_transition && replay.is_some() && saved_emission_available(id);
1150        let mut ctx = replay
1151            .as_mut()
1152            .map(|state| crate::style_shared::CommandReplayContext {
1153                state,
1154                stale_available,
1155                serve_stale: false,
1156            });
1157        let (primitives, recording, frame) = primitives_for_placement_verified(
1158            command,
1159            placement,
1160            size,
1161            recording,
1162            storage,
1163            &mut ctx,
1164            Some(id),
1165        );
1166        if ctx.is_some_and(|ctx| ctx.serve_stale) {
1167            // The stale-transition serve: verification collapsed out of its
1168            // capture, nothing was materialized, and the node re-emits the
1169            // PREVIOUS build's emission byte-for-byte — same primitives,
1170            // same sanitized spans, same fallback recording. Publishing
1171            // still happens first: the (empty) recording and storage
1172            // buffers return to the registry for the command's steady-state
1173            // ping-pong, and the advanced replay state is stored back so
1174            // re-convergence proceeds on the next builds.
1175            publish_recording(id, recording, primitives, replay);
1176            if let Some(saved) = take_saved_emission(id) {
1177                let frame = cranpose_ui_graphics::CommandReplayFrame {
1178                    center: saved.center,
1179                    spans: saved.spans,
1180                    fallback: Some(saved.recording),
1181                };
1182                nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_replayed(
1183                    phase,
1184                    Some(id),
1185                    saved.primitives,
1186                    Some(Box::new(frame)),
1187                )));
1188            } else {
1189                // Unreachable: `stale_available` was read from this same
1190                // thread-local slot within this build and nothing between
1191                // consumes it. Fail soft — one frame without this command —
1192                // rather than panic in a renderer.
1193                debug_assert!(false, "serve_stale without a saved emission");
1194            }
1195            continue;
1196        }
1197        // A bypassed span leaves no primitives behind, so emptiness alone no
1198        // longer means the command drew nothing in this placement.
1199        let has_replay_spans = frame.as_ref().is_some_and(|frame| !frame.spans.is_empty());
1200        // An empty recording with no earned capacity is what a command's
1201        // mismatched placement pass produces every rebuild; keeping those out
1202        // of the registry halves its population. An empty recording WITH
1203        // capacity is still published so the buffer waits for the frame this
1204        // command draws again.
1205        if primitives.is_empty() && primitives.capacity() == 0 && !has_replay_spans {
1206            continue;
1207        }
1208        let (shared, published_recording) = publish_recording(id, recording, primitives, replay);
1209        if stale_transition {
1210            // Save this build's emission in re-emittable form — or clear a
1211            // previous save when this build emitted no replay frame, so a
1212            // command that stops emitting does not pin its last frame's
1213            // buffers. The save is what the NEXT build's collapse frame
1214            // may serve; a build where the emission was itself served
1215            // stale never reaches this point, which is the other half of
1216            // the one-frame staleness cap.
1217            let saved = frame.as_ref().and_then(|frame| {
1218                RETAINED_FEED_EPOCH
1219                    .with(std::cell::Cell::get)
1220                    .map(|epoch| SavedReplayEmission {
1221                        spans: sanitized_replay_spans(&frame.spans),
1222                        center: frame.center,
1223                        primitives: shared.clone(),
1224                        recording: published_recording.clone(),
1225                        epoch,
1226                        generation: RECORDING_GENERATION.with(std::cell::Cell::get),
1227                    })
1228            });
1229            store_saved_emission(id, saved);
1230        }
1231        if shared.is_empty() && !has_replay_spans {
1232            continue;
1233        }
1234        // The frame owns a pinned handle to the exact recording it was
1235        // built from: its bypassed spans' rematerialization source travels
1236        // WITH the frame, so rendering never has to look it up through the
1237        // sweepable ambient registry.
1238        let frame = frame.map(|mut frame| {
1239            frame.fallback = Some(published_recording);
1240            frame
1241        });
1242        // The recorded vector rides into the graph whole: a single canvas
1243        // command can carry thousands of primitives, and wrapping each in its
1244        // own node moved every one of them an extra time each frame.
1245        nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_replayed(
1246            phase,
1247            Some(id),
1248            shared,
1249            frame.map(Box::new),
1250        )));
1251    }
1252    nodes
1253}
1254
1255/// The real [`draw_nodes`] path — acquire, record, verify, publish, and
1256/// the stale-transition save/serve — exposed so integration tests can
1257/// drive a command through the production seam build by build. Each call
1258/// is one build: the recording generation advances exactly as
1259/// [`build_graph_from_layout_tree`] and friends advance it.
1260#[doc(hidden)]
1261pub fn draw_command_nodes_for_tests(
1262    node_id: NodeId,
1263    commands: &[DrawCommand],
1264    placement: DrawPlacement,
1265    size: Size,
1266    phase: PrimitivePhase,
1267) -> Vec<RenderNode> {
1268    bump_recording_generation();
1269    draw_nodes(node_id, commands, placement, size, phase)
1270}
1271
1272struct TextNodeParts<'a> {
1273    node_id: NodeId,
1274    local_bounds: Rect,
1275    measured_max_width: Option<f32>,
1276    resolved_modifiers: &'a ResolvedModifiers,
1277    annotated_text: Option<&'a AnnotatedString>,
1278    text_style: Option<&'a TextStyle>,
1279    text_layout_options: Option<TextLayoutOptions>,
1280    text_pan: Option<TextPanResolver>,
1281    modifier_slices: Option<&'a ModifierNodeSlices>,
1282}
1283
1284fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
1285    let TextNodeParts {
1286        node_id,
1287        local_bounds,
1288        measured_max_width,
1289        resolved_modifiers,
1290        annotated_text,
1291        text_style,
1292        text_layout_options,
1293        text_pan,
1294        modifier_slices,
1295    } = parts;
1296    let value = annotated_text?;
1297    let default_text_style = TextStyle::default();
1298    let text_style = text_style.cloned().unwrap_or(default_text_style);
1299    let options = text_layout_options.unwrap_or_default().normalized();
1300    let padding = resolved_modifiers.padding();
1301    let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
1302    if content_width <= 0.0 {
1303        return None;
1304    }
1305
1306    // Single-line text fields pan horizontally to keep the cursor visible:
1307    // the text is laid out unconstrained (no wrapping), shifted left by the
1308    // pan offset, and clipped to the field bounds.
1309    let pan_offset = text_pan
1310        .as_ref()
1311        .map(|resolve| resolve(content_width))
1312        .unwrap_or(0.0);
1313    let pans_horizontally = text_pan.is_some();
1314
1315    let max_width = if pans_horizontally {
1316        None
1317    } else {
1318        let measure_width =
1319            resolve_text_measure_width(content_width, padding, measured_max_width, options);
1320        Some(measure_width).filter(|width| width.is_finite() && *width > 0.0)
1321    };
1322    let prepared = modifier_slices
1323        .and_then(|slices| slices.prepare_text_layout(max_width))
1324        .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
1325    let visual_style = prepared.visual_style.clone();
1326    let measured_draw_width = prepared.metrics.width.max(0.0);
1327    let draw_width = if options.overflow == TextOverflow::Visible || pans_horizontally {
1328        measured_draw_width
1329    } else {
1330        measured_draw_width.min(content_width)
1331    };
1332    let alignment_offset = resolve_text_horizontal_offset(
1333        &text_style,
1334        prepared.text.text.as_str(),
1335        content_width,
1336        prepared.metrics.width,
1337    );
1338    let rect = Rect {
1339        x: padding.left + alignment_offset - pan_offset,
1340        y: padding.top,
1341        width: draw_width,
1342        height: prepared.metrics.height,
1343    };
1344    let text_bounds = Rect {
1345        x: padding.left,
1346        y: padding.top,
1347        width: content_width,
1348        height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
1349    };
1350    let font_size = visual_style.resolve_font_size(14.0);
1351    let expanded_bounds =
1352        expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
1353    let clip = if options.overflow == TextOverflow::Visible && !pans_horizontally {
1354        None
1355    } else {
1356        Some(pad_clip_rect(expanded_bounds))
1357    };
1358
1359    Some(TextPrimitiveNode {
1360        node_id,
1361        rect,
1362        text: std::rc::Rc::new(prepared.text),
1363        text_style: visual_style,
1364        font_size,
1365        layout_options: options,
1366        clip,
1367    })
1368}
1369
1370fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
1371    let placement = parent
1372        .map(|parent_box| Point {
1373            x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
1374            y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
1375        })
1376        .unwrap_or_default();
1377    let mut children = Vec::with_capacity(node.children.len());
1378    for child in &node.children {
1379        children.push(layout_box_to_snapshot(child, Some(node)));
1380    }
1381    let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
1382    let graphics_layer = graphics_layer_with_shaped_clip(
1383        base_graphics_layer.clone().unwrap_or_default(),
1384        node.node_data.modifier_slices.clip_to_bounds(),
1385        node.node_data.modifier_slices.corner_shape(),
1386        Rect {
1387            x: 0.0,
1388            y: 0.0,
1389            width: node.rect.width,
1390            height: node.rect.height,
1391        },
1392    );
1393    let has_graphics_layer =
1394        base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
1395
1396    BuildNodeSnapshot {
1397        node_id: node.node_id,
1398        placement,
1399        size: Size {
1400            width: node.rect.width,
1401            height: node.rect.height,
1402        },
1403        content_offset: node.content_offset,
1404        motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
1405        translated_content_context: node.node_data.modifier_slices.translated_content_context(),
1406        measured_max_width: None,
1407        resolved_modifiers: node.node_data.resolved_modifiers,
1408        draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
1409        click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
1410        pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
1411        clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
1412        annotated_text: node.node_data.modifier_slices.annotated_string(),
1413        text_style: node.node_data.modifier_slices.text_style().cloned(),
1414        text_layout_options: node.node_data.modifier_slices.text_layout_options(),
1415        text_pan: node.node_data.modifier_slices.text_pan_resolver(),
1416        graphics_layer: has_graphics_layer.then_some(graphics_layer),
1417        children,
1418    }
1419}
1420
1421fn graphics_layer_with_shaped_clip(
1422    mut graphics_layer: GraphicsLayer,
1423    clip_to_bounds: bool,
1424    corner_shape: Option<RoundedCornerShape>,
1425    local_bounds: Rect,
1426) -> GraphicsLayer {
1427    if !clip_to_bounds {
1428        return graphics_layer;
1429    }
1430
1431    let Some(corner_shape) = corner_shape else {
1432        return graphics_layer;
1433    };
1434    let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
1435    if radii.top_left <= f32::EPSILON
1436        && radii.top_right <= f32::EPSILON
1437        && radii.bottom_right <= f32::EPSILON
1438        && radii.bottom_left <= f32::EPSILON
1439    {
1440        return graphics_layer;
1441    }
1442
1443    if let Some(existing) = graphics_layer.render_effect.take() {
1444        let rounded_clip = rounded_corner_alpha_mask_effect(
1445            local_bounds.width,
1446            local_bounds.height,
1447            radii,
1448            ROUNDED_CLIP_EDGE_FEATHER,
1449        );
1450        graphics_layer.render_effect = Some(existing.then(rounded_clip));
1451    } else {
1452        graphics_layer.shape = LayerShape::Rounded(corner_shape);
1453        graphics_layer.clip = true;
1454    }
1455    graphics_layer
1456}
1457
1458fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
1459    IsolationReasons {
1460        explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
1461        shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
1462        effect: layer.render_effect.is_some(),
1463        backdrop: layer.backdrop_effect.is_some(),
1464        group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
1465            && layer.alpha < 1.0,
1466        blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
1467    }
1468}
1469
1470fn pad_clip_rect(rect: Rect) -> Rect {
1471    Rect {
1472        x: rect.x - TEXT_CLIP_PAD,
1473        y: rect.y - TEXT_CLIP_PAD,
1474        width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
1475        height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
1476    }
1477}
1478
1479fn expand_text_bounds_for_baseline_shift(
1480    text_bounds: Rect,
1481    text_style: &TextStyle,
1482    font_size: f32,
1483) -> Rect {
1484    let baseline_shift_px = text_style
1485        .span_style
1486        .baseline_shift
1487        .filter(|shift| shift.is_specified())
1488        .map(|shift| -(shift.0 * font_size))
1489        .unwrap_or(0.0);
1490    if baseline_shift_px == 0.0 {
1491        return text_bounds;
1492    }
1493
1494    if baseline_shift_px < 0.0 {
1495        Rect {
1496            x: text_bounds.x,
1497            y: text_bounds.y + baseline_shift_px,
1498            width: text_bounds.width,
1499            height: (text_bounds.height - baseline_shift_px).max(0.0),
1500        }
1501    } else {
1502        Rect {
1503            x: text_bounds.x,
1504            y: text_bounds.y,
1505            width: text_bounds.width,
1506            height: (text_bounds.height + baseline_shift_px).max(0.0),
1507        }
1508    }
1509}
1510
1511/// The width the paint pass must lay this paragraph out at.
1512///
1513/// **It is the width LAYOUT wrapped at, not the width the node ended up.** A
1514/// `Text` without `fill_max_width` is placed at its own `metrics.width` — the
1515/// widest line it produced — which is by construction NARROWER than the
1516/// constraint it wrapped under. Re-wrapping at that narrower width is not the
1517/// no-op it looks like: the widest line is the one that exactly fills the
1518/// limit, so measuring it against itself puts its last word over the edge and
1519/// the paragraph gains a line. Measured against the real font backend
1520/// (`SoftwareTextMeasurer`, the one the wgpu renderer installs), that fires on
1521/// 46% of multi-line paragraphs — the block then paints a line taller than the
1522/// box layout reserved for it, its last line is clipped away, and every
1523/// following sibling has been placed as if that line did not exist.
1524///
1525/// So an unlimited soft-wrapping clip paragraph keeps the measurement width
1526/// even when the node came out narrower — `may_expand_to_avoid_synthetic_wrap`.
1527/// The modes that deliberately re-fit (no soft wrap, a finite `max_lines`, or
1528/// an ellipsis budget) still take the node's own width, because for those the
1529/// node width IS the fitting constraint.
1530///
1531/// This is the shared implementation. It exists because the wgpu and pixels
1532/// pipelines each grew a private copy WITH this rule and its contract tests,
1533/// while the scene builder — the copy that the retained render graph actually
1534/// runs — kept a plain `available.min(content_width)`. The two private copies
1535/// were reachable only from their own tests. One function now, so the tests
1536/// guard the code that runs.
1537pub fn resolve_text_measure_width(
1538    content_width: f32,
1539    padding: cranpose_ui::EdgeInsets,
1540    measured_max_width: Option<f32>,
1541    options: TextLayoutOptions,
1542) -> f32 {
1543    let width = content_width.max(0.0);
1544    if let Some(max_width) = measured_max_width.filter(|w| w.is_finite() && *w > 0.0) {
1545        let measured_content_width = (max_width - padding.left - padding.right).max(0.0);
1546        if measured_content_width <= width {
1547            return measured_content_width;
1548        }
1549
1550        let may_expand_to_avoid_synthetic_wrap = options.soft_wrap
1551            && options.max_lines == usize::MAX
1552            && options.overflow == TextOverflow::Clip;
1553        if may_expand_to_avoid_synthetic_wrap {
1554            return measured_content_width;
1555        }
1556    }
1557    width
1558}
1559
1560/// How much of the slack a `TextAlign` puts *before* the text: 0 at the start
1561/// edge, 0.5 centred, 1 at the end edge.
1562///
1563/// Split out because the same fraction has to be applied twice and by two
1564/// different pieces of code. Compose aligns a paragraph **line by line** —
1565/// `TextAlign.Center` centres each line in the paragraph's width, it does not
1566/// centre the paragraph's box in its parent — so the block offset computed
1567/// here and the per-line offset the rasteriser applies inside the block are
1568/// two halves of one rule. They telescope: block at `(box - block) * f`, line
1569/// at `(block - line) * f`, which sums to `(box - line) * f`, exactly the
1570/// offset Compose gives that line. Getting one without the other leaves every
1571/// wrapped continuation line start-aligned under a centred first line.
1572pub fn text_align_fraction(text_style: &TextStyle, text: &str) -> f32 {
1573    let paragraph_style = &text_style.paragraph_style;
1574    let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
1575    let rtl = direction == cranpose_ui::text::ResolvedTextDirection::Rtl;
1576    match paragraph_style.text_align {
1577        TextAlign::Center => 0.5,
1578        TextAlign::End | TextAlign::Right => 1.0,
1579        // `Left` follows the direction here rather than being absolute. That
1580        // is not what Compose means by `TextAlign.Left`, but it is what this
1581        // function has always done and no Wear screen is RTL; changing it is a
1582        // separate question from where a wrapped line starts.
1583        TextAlign::Start | TextAlign::Left | TextAlign::Justify | TextAlign::Unspecified => {
1584            if rtl {
1585                1.0
1586            } else {
1587                0.0
1588            }
1589        }
1590    }
1591}
1592
1593fn resolve_text_horizontal_offset(
1594    text_style: &TextStyle,
1595    text: &str,
1596    content_width: f32,
1597    measured_width: f32,
1598) -> f32 {
1599    let remaining = (content_width - measured_width).max(0.0);
1600    remaining * text_align_fraction(text_style, text)
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605    use std::cell::RefCell;
1606    use std::rc::Rc;
1607
1608    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1609    use cranpose_ui::text::{
1610        AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
1611    };
1612    use cranpose_ui::{
1613        Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
1614        LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
1615        ScrollState, Size, Spacer, Text, TextStyle,
1616    };
1617    use cranpose_ui_graphics::{
1618        Brush, DrawPrimitive, DrawScope as _, DrawScopeDefault, GraphicsLayer, RenderEffect,
1619    };
1620
1621    use super::*;
1622
1623    fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
1624        for child in &layer.children {
1625            match child {
1626                RenderNode::Primitive(primitive) => {
1627                    let PrimitiveNode::Text(text) = &primitive.node else {
1628                        continue;
1629                    };
1630                    if text.text.text == label {
1631                        return Some(text.text_style.paragraph_style.text_motion);
1632                    }
1633                }
1634                RenderNode::Layer(child_layer) => {
1635                    if let Some(motion) = find_text_motion(child_layer, label) {
1636                        return Some(motion);
1637                    }
1638                }
1639                RenderNode::DrawRun(_) => {}
1640            }
1641        }
1642
1643        None
1644    }
1645
1646    fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
1647        for child in &layer.children {
1648            match child {
1649                RenderNode::Primitive(primitive) => {
1650                    let PrimitiveNode::Text(text) = &primitive.node else {
1651                        continue;
1652                    };
1653                    labels.push(text.text.text.clone());
1654                }
1655                RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
1656                RenderNode::DrawRun(_) => {}
1657            }
1658        }
1659    }
1660
1661    fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
1662        fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
1663            for child in &layer.children {
1664                match child {
1665                    RenderNode::Primitive(primitive) => {
1666                        let PrimitiveNode::Text(text) = &primitive.node else {
1667                            continue;
1668                        };
1669                        if text.text.text == label {
1670                            let quad = transform.map_rect(text.rect);
1671                            let top = quad
1672                                .iter()
1673                                .map(|point| point[1])
1674                                .fold(f32::INFINITY, f32::min);
1675                            return top.is_finite().then_some(top);
1676                        }
1677                    }
1678                    RenderNode::Layer(child_layer) => {
1679                        let child_transform = child_layer.transform_to_parent.then(transform);
1680                        if let Some(top) = search(child_layer, label, child_transform) {
1681                            return Some(top);
1682                        }
1683                    }
1684                    RenderNode::DrawRun(_) => {}
1685                }
1686            }
1687            None
1688        }
1689
1690        search(layer, label, ProjectiveTransform::identity())
1691    }
1692
1693    fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
1694        if layer.node_id == Some(node_id) {
1695            return Some(layer);
1696        }
1697        layer.children.iter().find_map(|child| match child {
1698            RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
1699            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1700        })
1701    }
1702
1703    fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
1704        fn search(
1705            layer: &LayerNode,
1706            node_id: NodeId,
1707            transform: ProjectiveTransform,
1708        ) -> Option<Point> {
1709            if layer.node_id == Some(node_id) {
1710                return Some(transform.map_point(Point::default()));
1711            }
1712            layer.children.iter().find_map(|child| match child {
1713                RenderNode::Layer(child_layer) => search(
1714                    child_layer,
1715                    node_id,
1716                    child_layer.transform_to_parent.then(transform),
1717                ),
1718                RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1719            })
1720        }
1721
1722        search(layer, node_id, ProjectiveTransform::identity())
1723    }
1724
1725    fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1726        if layer.translated_content_context {
1727            return Some(layer.translated_content_offset);
1728        }
1729        for child in &layer.children {
1730            if let RenderNode::Layer(child_layer) = child {
1731                if let Some(offset) = find_translated_content_offset(child_layer) {
1732                    return Some(offset);
1733                }
1734            }
1735        }
1736        None
1737    }
1738
1739    fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1740        layer
1741            .graphics_layer
1742            .render_effect
1743            .as_ref()
1744            .is_some_and(RenderEffect::contains_runtime_shader)
1745            || layer.children.iter().any(|child| match child {
1746                RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1747                RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1748            })
1749    }
1750
1751    fn build_layer_node_for_test(
1752        snapshot: BuildNodeSnapshot,
1753        scale: f32,
1754        has_external_backdrop_input: bool,
1755    ) -> LayerNode {
1756        let app_context = cranpose_ui::AppContext::new();
1757        app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1758    }
1759
1760    fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1761        let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1762            scope.push_recorded(vec![DrawPrimitive::Rect {
1763                rect: Rect {
1764                    x: 3.0,
1765                    y: 4.0,
1766                    width: 20.0,
1767                    height: 8.0,
1768                },
1769                brush: Brush::solid(Color::WHITE),
1770                stroke: None,
1771            }]);
1772        }));
1773
1774        let child = BuildNodeSnapshot {
1775            node_id: 2,
1776            placement: Point { x: 11.0, y: 7.0 },
1777            size: Size {
1778                width: 40.0,
1779                height: 20.0,
1780            },
1781            content_offset: Point::default(),
1782            motion_context_animated: false,
1783            translated_content_context: false,
1784            measured_max_width: None,
1785            resolved_modifiers: ResolvedModifiers::default(),
1786            draw_commands: vec![child_command],
1787            click_actions: vec![],
1788            pointer_inputs: vec![],
1789            clip_to_bounds: false,
1790            annotated_text: None,
1791            text_style: None,
1792            text_layout_options: None,
1793            text_pan: None,
1794            graphics_layer: None,
1795            children: vec![],
1796        };
1797
1798        BuildNodeSnapshot {
1799            node_id: 1,
1800            placement: Point::default(),
1801            size: Size {
1802                width: 80.0,
1803                height: 50.0,
1804            },
1805            content_offset: Point::default(),
1806            motion_context_animated: false,
1807            translated_content_context: false,
1808            measured_max_width: None,
1809            resolved_modifiers: ResolvedModifiers::default(),
1810            draw_commands: vec![],
1811            click_actions: vec![],
1812            pointer_inputs: vec![],
1813            clip_to_bounds: false,
1814            annotated_text: None,
1815            text_style: None,
1816            text_layout_options: None,
1817            text_pan: None,
1818            graphics_layer: Some(GraphicsLayer {
1819                translation_x: tx,
1820                ..GraphicsLayer::default()
1821            }),
1822            children: vec![child],
1823        }
1824    }
1825
1826    #[test]
1827    fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1828        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1829        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1830
1831        let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1832            panic!("expected child layer");
1833        };
1834        let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1835            panic!("expected child layer");
1836        };
1837        let RenderNode::DrawRun(static_run) = &static_child.children[0] else {
1838            panic!("expected draw run");
1839        };
1840        let static_draw = &static_run.primitives[0];
1841        let RenderNode::DrawRun(moved_run) = &moved_child.children[0] else {
1842            panic!("expected draw run");
1843        };
1844        let moved_draw = &moved_run.primitives[0];
1845
1846        assert_ne!(
1847            static_graph.transform_to_parent, moved_graph.transform_to_parent,
1848            "parent transform should encode translation"
1849        );
1850        assert_eq!(
1851            static_draw, moved_draw,
1852            "child local primitive geometry must stay stable under parent translation"
1853        );
1854    }
1855
1856    #[test]
1857    fn stored_content_hash_ignores_parent_translation() {
1858        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1859        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1860
1861        assert_eq!(
1862            static_graph.target_content_hash(),
1863            moved_graph.target_content_hash(),
1864            "parent rigid motion must not invalidate the subtree content hash"
1865        );
1866    }
1867
1868    #[test]
1869    fn parent_content_offset_is_encoded_in_child_transform() {
1870        let child = BuildNodeSnapshot {
1871            node_id: 2,
1872            placement: Point { x: 11.0, y: 7.0 },
1873            size: Size {
1874                width: 40.0,
1875                height: 20.0,
1876            },
1877            content_offset: Point::default(),
1878            motion_context_animated: false,
1879            translated_content_context: false,
1880            measured_max_width: None,
1881            resolved_modifiers: ResolvedModifiers::default(),
1882            draw_commands: vec![],
1883            click_actions: vec![],
1884            pointer_inputs: vec![],
1885            clip_to_bounds: false,
1886            annotated_text: None,
1887            text_style: None,
1888            text_layout_options: None,
1889            text_pan: None,
1890            graphics_layer: None,
1891            children: vec![],
1892        };
1893
1894        let parent = BuildNodeSnapshot {
1895            node_id: 1,
1896            placement: Point::default(),
1897            size: Size {
1898                width: 80.0,
1899                height: 50.0,
1900            },
1901            content_offset: Point { x: 13.0, y: -9.0 },
1902            motion_context_animated: false,
1903            translated_content_context: false,
1904            measured_max_width: None,
1905            resolved_modifiers: ResolvedModifiers::default(),
1906            draw_commands: vec![],
1907            click_actions: vec![],
1908            pointer_inputs: vec![],
1909            clip_to_bounds: false,
1910            annotated_text: None,
1911            text_style: None,
1912            text_layout_options: None,
1913            text_pan: None,
1914            graphics_layer: None,
1915            children: vec![child],
1916        };
1917
1918        let graph = build_layer_node_for_test(parent, 1.0, false);
1919        let RenderNode::Layer(child) = &graph.children[0] else {
1920            panic!("expected child layer");
1921        };
1922
1923        let top_left = child.transform_to_parent.map_point(Point::default());
1924        assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1925    }
1926
1927    #[test]
1928    fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1929        fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1930            let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1931                scope.push_recorded(vec![DrawPrimitive::Rect {
1932                    rect: Rect {
1933                        x: 3.0,
1934                        y: 4.0,
1935                        width: 20.0,
1936                        height: 8.0,
1937                    },
1938                    brush: Brush::solid(Color::WHITE),
1939                    stroke: None,
1940                }]);
1941            }));
1942
1943            let child = BuildNodeSnapshot {
1944                node_id: 2,
1945                placement: Point { x: 11.0, y: 7.0 },
1946                size: Size {
1947                    width: 40.0,
1948                    height: 20.0,
1949                },
1950                content_offset: Point::default(),
1951                motion_context_animated: false,
1952                translated_content_context: false,
1953                measured_max_width: None,
1954                resolved_modifiers: ResolvedModifiers::default(),
1955                draw_commands: vec![child_command],
1956                click_actions: vec![],
1957                pointer_inputs: vec![],
1958                clip_to_bounds: false,
1959                annotated_text: None,
1960                text_style: None,
1961                text_layout_options: None,
1962                text_pan: None,
1963                graphics_layer: None,
1964                children: vec![],
1965            };
1966
1967            BuildNodeSnapshot {
1968                node_id: 1,
1969                placement: Point::default(),
1970                size: Size {
1971                    width: 80.0,
1972                    height: 50.0,
1973                },
1974                content_offset: offset,
1975                motion_context_animated,
1976                translated_content_context: true,
1977                measured_max_width: None,
1978                resolved_modifiers: ResolvedModifiers::default(),
1979                draw_commands: vec![],
1980                click_actions: vec![],
1981                pointer_inputs: vec![],
1982                clip_to_bounds: false,
1983                annotated_text: None,
1984                text_style: None,
1985                text_layout_options: None,
1986                text_pan: None,
1987                graphics_layer: None,
1988                children: vec![child],
1989            }
1990        }
1991
1992        let base = build_layer_node_for_test(
1993            parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
1994            1.0,
1995            false,
1996        );
1997        let moved = build_layer_node_for_test(
1998            parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
1999            1.0,
2000            false,
2001        );
2002        let rested = build_layer_node_for_test(
2003            parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
2004            1.0,
2005            false,
2006        );
2007
2008        let RenderNode::Layer(base_child) = &base.children[0] else {
2009            panic!("expected child layer");
2010        };
2011        let RenderNode::Layer(moved_child) = &moved.children[0] else {
2012            panic!("expected child layer");
2013        };
2014
2015        assert_ne!(
2016            base_child.transform_to_parent.map_point(Point::default()),
2017            moved_child.transform_to_parent.map_point(Point::default()),
2018            "scroll offset still has to move child content visually"
2019        );
2020        assert_eq!(
2021            base_child.target_content_hash(),
2022            moved_child.target_content_hash(),
2023            "child source content identity stays stable when only the parent scroll offset changes"
2024        );
2025        assert_ne!(
2026            base.target_content_hash(),
2027            moved.target_content_hash(),
2028            "a full-surface cache of the scroll viewport must include the scroll offset"
2029        );
2030        assert_ne!(
2031            base.target_content_hash(),
2032            rested.target_content_hash(),
2033            "full-surface cache keys must include active scroll motion policy"
2034        );
2035    }
2036
2037    #[test]
2038    fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
2039        let layer = graphics_layer_with_shaped_clip(
2040            GraphicsLayer::default(),
2041            true,
2042            Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
2043            Rect {
2044                x: 0.0,
2045                y: 0.0,
2046                width: 100.0,
2047                height: 40.0,
2048            },
2049        );
2050
2051        assert!(layer.clip);
2052        assert!(layer.render_effect.is_none());
2053        let LayerShape::Rounded(shape) = layer.shape else {
2054            panic!("rounded clip must be recorded as layer shape");
2055        };
2056        assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
2057        assert!(isolation_reasons(&layer).shape_clip);
2058    }
2059
2060    #[test]
2061    fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
2062        let existing = RenderEffect::blur(3.0);
2063        let layer = graphics_layer_with_shaped_clip(
2064            GraphicsLayer {
2065                render_effect: Some(existing.clone()),
2066                ..GraphicsLayer::default()
2067            },
2068            true,
2069            Some(RoundedCornerShape::uniform(10.0)),
2070            Rect {
2071                x: 0.0,
2072                y: 0.0,
2073                width: 100.0,
2074                height: 40.0,
2075            },
2076        );
2077
2078        let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
2079            panic!("existing effect should chain into rounded clip mask");
2080        };
2081        assert_eq!(*first, existing);
2082        assert!(
2083            matches!(*second, RenderEffect::Shader { .. }),
2084            "rounded mask must be the outer effect"
2085        );
2086    }
2087
2088    #[test]
2089    fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
2090        let mut composition = cranpose_ui::run_test_composition(|| {
2091            cranpose_ui::Box(
2092                Modifier::empty()
2093                    .width(100.0)
2094                    .height(40.0)
2095                    .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
2096                    .clip_to_bounds(),
2097                cranpose_ui::BoxSpec::default(),
2098                || {
2099                    Text("rounded child", Modifier::empty(), TextStyle::default());
2100                },
2101            );
2102        });
2103
2104        let root = composition.root().expect("rounded clip root");
2105        let handle = composition.runtime_handle();
2106        let mut applier = composition.applier_mut();
2107        applier.set_runtime_handle(handle);
2108        applier
2109            .compute_layout(
2110                root,
2111                Size {
2112                    width: 160.0,
2113                    height: 100.0,
2114                },
2115            )
2116            .expect("rounded clip layout");
2117        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
2118        applier.clear_runtime_handle();
2119
2120        let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
2121        assert!(rounded_layer.graphics_layer.clip);
2122        assert!(matches!(
2123            rounded_layer.graphics_layer.shape,
2124            LayerShape::Rounded(_)
2125        ));
2126        assert!(rounded_layer.graphics_layer.render_effect.is_none());
2127        assert!(rounded_layer.isolation.shape_clip);
2128        assert!(
2129            !graph_has_runtime_shader_effect(&graph.root),
2130            "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
2131        );
2132    }
2133
2134    #[test]
2135    fn update_graph_from_applier_replaces_dirty_child_layer() {
2136        let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2137            Rc::new(RefCell::new(None));
2138        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2139        let state_holder_for_comp = state_holder.clone();
2140        let child_id_holder_for_comp = child_id_holder.clone();
2141
2142        let mut composition = cranpose_ui::run_test_composition(move || {
2143            let label = cranpose_core::useState(|| "before".to_string());
2144            *state_holder_for_comp.borrow_mut() = Some(label);
2145            let child_id_holder_for_content = child_id_holder_for_comp.clone();
2146            cranpose_ui::Box(
2147                Modifier::empty().size_points(240.0, 80.0),
2148                cranpose_ui::BoxSpec::default(),
2149                move || {
2150                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
2151                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
2152                    Text("stable", Modifier::empty(), TextStyle::default());
2153                },
2154            );
2155        });
2156
2157        let root = composition.root().expect("composition root");
2158        let viewport = Size {
2159            width: 240.0,
2160            height: 80.0,
2161        };
2162        let handle = composition.runtime_handle();
2163        let mut applier = composition.applier_mut();
2164        applier.set_runtime_handle(handle);
2165        applier
2166            .compute_layout(root, viewport)
2167            .expect("initial layout");
2168        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2169        let child_id = child_id_holder
2170            .borrow()
2171            .expect("text child id should be captured");
2172        let initial_transform = find_layer_by_node_id(&graph.root, child_id)
2173            .expect("text child layer")
2174            .transform_to_parent;
2175        applier.clear_runtime_handle();
2176        drop(applier);
2177
2178        let label = state_holder
2179            .borrow()
2180            .as_ref()
2181            .copied()
2182            .expect("label state should be captured");
2183        label.set_value("after".to_string());
2184        composition
2185            .process_invalid_scopes()
2186            .expect("text recomposition");
2187
2188        let handle = composition.runtime_handle();
2189        let mut applier = composition.applier_mut();
2190        applier.set_runtime_handle(handle);
2191        applier
2192            .compute_layout(root, viewport)
2193            .expect("updated layout");
2194        let child_id = child_id_holder
2195            .borrow()
2196            .expect("text child id should remain captured");
2197
2198        assert!(
2199            update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
2200            "dirty child should be replaceable from retained applier state"
2201        );
2202        applier.clear_runtime_handle();
2203
2204        let mut labels = Vec::new();
2205        collect_text_labels(&graph.root, &mut labels);
2206        assert!(
2207            labels.iter().any(|label| label == "after"),
2208            "updated graph should contain refreshed child text, got {labels:?}"
2209        );
2210        assert!(
2211            !labels.iter().any(|label| label == "before"),
2212            "updated graph should not retain stale child text, got {labels:?}"
2213        );
2214        assert!(
2215            labels.iter().any(|label| label == "stable"),
2216            "sibling content should remain present, got {labels:?}"
2217        );
2218        assert_eq!(
2219            find_layer_by_node_id(&graph.root, child_id)
2220                .expect("updated text child layer")
2221                .transform_to_parent,
2222            initial_transform,
2223            "draw-only child replacement must preserve the retained parent placement transform"
2224        );
2225    }
2226
2227    /// The per-frame scene build must publish a node's LIVE composited window
2228    /// rect into its `report_window_rect` sink — even when the layout tree is
2229    /// NOT built (`build_layout_tree: false`, exactly how the app runtime
2230    /// measures). This is the mechanism both bug 2 (a scroll container's
2231    /// `BringIntoViewResponder` viewport rect) and bug 3 (a text field's live
2232    /// `node_origin`, which anchors the overlay selection-handle / menu popups)
2233    /// rely on, since the layout `place` pass never runs in the runtime.
2234    #[test]
2235    fn scene_build_publishes_live_window_rect_without_layout_tree() {
2236        use cranpose_ui::{measure_layout_with_options, Box, BoxSpec, MeasureLayoutOptions};
2237        use std::cell::Cell;
2238
2239        let spacer_before = 120.0_f32;
2240        let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
2241            x: 0.0,
2242            y: 0.0,
2243            width: 0.0,
2244            height: 0.0,
2245        }));
2246        let sink_for_comp = sink.clone();
2247        let mut composition = cranpose_ui::run_test_composition(move || {
2248            let sink = sink_for_comp.clone();
2249            Column(
2250                Modifier::empty().size_points(200.0, 400.0),
2251                ColumnSpec::default(),
2252                move || {
2253                    Spacer(Size {
2254                        width: 200.0,
2255                        height: spacer_before,
2256                    });
2257                    Box(
2258                        Modifier::empty()
2259                            .size_points(200.0, 50.0)
2260                            .report_window_rect(sink.clone()),
2261                        BoxSpec::default(),
2262                        || {},
2263                    );
2264                },
2265            );
2266        });
2267
2268        let root = composition.root().expect("composition root");
2269        let viewport = Size {
2270            width: 200.0,
2271            height: 400.0,
2272        };
2273        let handle = composition.runtime_handle();
2274        let mut applier = composition.applier_mut();
2275        applier.set_runtime_handle(handle);
2276        // Measure like the runtime: DO NOT build the layout tree, so the layout
2277        // `place` pass never writes the sink. Only the scene build can.
2278        measure_layout_with_options(
2279            &mut applier,
2280            root,
2281            viewport,
2282            MeasureLayoutOptions {
2283                collect_semantics: false,
2284                build_layout_tree: false,
2285            },
2286        )
2287        .expect("layout");
2288        // Sanity: nothing has written the sink yet.
2289        assert_eq!(
2290            sink.get().height,
2291            0.0,
2292            "sink must start empty (place disabled)"
2293        );
2294
2295        let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
2296        applier.clear_runtime_handle();
2297
2298        let rect = sink.get();
2299        assert!(
2300            (rect.y - spacer_before).abs() < 0.5,
2301            "scene build must publish the box's live window-y (below the {spacer_before}px \
2302             spacer), got {}",
2303            rect.y
2304        );
2305        assert!(
2306            rect.width > 0.0 && rect.height > 0.0,
2307            "scene build must publish a non-empty window rect, got {rect:?}"
2308        );
2309    }
2310
2311    #[test]
2312    fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
2313        let mut graph = RenderGraph {
2314            root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
2315        };
2316        let mut applier = MemoryApplier::new();
2317
2318        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
2319
2320        assert_eq!(
2321            report,
2322            GraphUpdateReport {
2323                applied: false,
2324                hit_graph_dirty: true,
2325            },
2326            "dirty child graph updates must not report success when the replacement cannot be rebuilt"
2327        );
2328    }
2329
2330    #[test]
2331    fn update_graph_from_applier_refreshes_scroll_content_offset() {
2332        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2333        let scroll_holder_for_comp = scroll_holder.clone();
2334
2335        let mut composition = cranpose_ui::run_test_composition(move || {
2336            let scroll_state =
2337                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2338            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2339            Column(
2340                Modifier::empty()
2341                    .size_points(240.0, 120.0)
2342                    .vertical_scroll(scroll_state, false),
2343                ColumnSpec::default(),
2344                || {
2345                    Text("scroll top", Modifier::empty(), TextStyle::default());
2346                    Spacer(Size {
2347                        width: 0.0,
2348                        height: 160.0,
2349                    });
2350                    Text("scroll target", Modifier::empty(), TextStyle::default());
2351                },
2352            );
2353        });
2354
2355        let root = composition.root().expect("composition root");
2356        let viewport = Size {
2357            width: 240.0,
2358            height: 120.0,
2359        };
2360        let handle = composition.runtime_handle();
2361        let mut applier = composition.applier_mut();
2362        applier.set_runtime_handle(handle);
2363        applier
2364            .compute_layout(root, viewport)
2365            .expect("initial scroll layout");
2366        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2367        let initial_target_top =
2368            find_text_top(&graph.root, "scroll target").expect("initial target text");
2369        applier.clear_runtime_handle();
2370        drop(applier);
2371
2372        let scroll_state = scroll_holder
2373            .borrow()
2374            .as_ref()
2375            .cloned()
2376            .expect("scroll state should be captured");
2377        let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
2378        assert!(consumed_scroll > 0.0, "test scroll must be consumed");
2379        let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
2380        assert!(
2381            !dirty_nodes.is_empty(),
2382            "scroll state invalidation must schedule scoped layout graph update"
2383        );
2384
2385        let handle = composition.runtime_handle();
2386        let mut applier = composition.applier_mut();
2387        applier.set_runtime_handle(handle);
2388        applier
2389            .compute_layout(root, viewport)
2390            .expect("scrolled layout");
2391        let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
2392        applier.clear_runtime_handle();
2393
2394        assert!(report.applied, "scroll graph update should apply in place");
2395        let updated_target_top =
2396            find_text_top(&graph.root, "scroll target").expect("updated target text");
2397        assert!(
2398            updated_target_top < initial_target_top - consumed_scroll * 0.75,
2399            "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
2400        );
2401    }
2402
2403    #[test]
2404    fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
2405        let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2406            Rc::new(RefCell::new(None));
2407        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2408        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2409        let label_holder_for_comp = label_holder.clone();
2410        let scroll_holder_for_comp = scroll_holder.clone();
2411        let child_id_holder_for_comp = child_id_holder.clone();
2412
2413        let mut composition = cranpose_ui::run_test_composition(move || {
2414            let label = cranpose_core::useState(|| "scrolled child before".to_string());
2415            let scroll_state =
2416                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2417            *label_holder_for_comp.borrow_mut() = Some(label);
2418            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2419            let child_id_holder_for_content = child_id_holder_for_comp.clone();
2420            Column(
2421                Modifier::empty()
2422                    .size_points(260.0, 90.0)
2423                    .vertical_scroll(scroll_state, false),
2424                ColumnSpec::default(),
2425                move || {
2426                    Spacer(Size {
2427                        width: 0.0,
2428                        height: 24.0,
2429                    });
2430                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
2431                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
2432                    Spacer(Size {
2433                        width: 0.0,
2434                        height: 220.0,
2435                    });
2436                },
2437            );
2438        });
2439
2440        let root = composition.root().expect("composition root");
2441        let viewport = Size {
2442            width: 260.0,
2443            height: 90.0,
2444        };
2445        let handle = composition.runtime_handle();
2446        let mut applier = composition.applier_mut();
2447        applier.set_runtime_handle(handle);
2448        applier
2449            .compute_layout(root, viewport)
2450            .expect("initial layout");
2451        applier.clear_runtime_handle();
2452        drop(applier);
2453
2454        let scroll_state = scroll_holder
2455            .borrow()
2456            .as_ref()
2457            .cloned()
2458            .expect("scroll state should be captured");
2459        assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
2460
2461        let handle = composition.runtime_handle();
2462        let mut applier = composition.applier_mut();
2463        applier.set_runtime_handle(handle);
2464        applier
2465            .compute_layout(root, viewport)
2466            .expect("scrolled layout");
2467        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2468        let child_id = child_id_holder
2469            .borrow()
2470            .expect("text child id should be captured");
2471        let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
2472            .expect("scrolled child layer")
2473            .transform_to_parent;
2474        applier.clear_runtime_handle();
2475        drop(applier);
2476
2477        let label = label_holder
2478            .borrow()
2479            .as_ref()
2480            .copied()
2481            .expect("label state should be captured");
2482        label.set_value("scrolled child after".to_string());
2483        composition
2484            .process_invalid_scopes()
2485            .expect("text recomposition");
2486
2487        let handle = composition.runtime_handle();
2488        let mut applier = composition.applier_mut();
2489        applier.set_runtime_handle(handle);
2490        applier
2491            .compute_layout(root, viewport)
2492            .expect("updated scrolled layout");
2493        let child_id = child_id_holder
2494            .borrow()
2495            .expect("text child id should remain captured");
2496        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
2497        applier.clear_runtime_handle();
2498
2499        assert!(report.applied, "dirty child graph update should apply");
2500        let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
2501        assert_eq!(
2502            updated.transform_to_parent, scrolled_transform,
2503            "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
2504        );
2505        let mut labels = Vec::new();
2506        collect_text_labels(&graph.root, &mut labels);
2507        assert!(
2508            labels.iter().any(|label| label == "scrolled child after"),
2509            "updated graph should contain refreshed text, got {labels:?}"
2510        );
2511    }
2512
2513    #[test]
2514    fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
2515        let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2516            Rc::new(RefCell::new(None));
2517        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2518        let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2519        let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2520        let alpha_holder_for_comp = alpha_holder.clone();
2521        let scroll_holder_for_comp = scroll_holder.clone();
2522        let underlay_id_holder_for_comp = underlay_id_holder.clone();
2523        let overlay_id_holder_for_comp = overlay_id_holder.clone();
2524
2525        let mut composition = cranpose_ui::run_test_composition(move || {
2526            let alpha = cranpose_core::useState(|| 1.0f32);
2527            let scroll_state =
2528                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2529            *alpha_holder_for_comp.borrow_mut() = Some(alpha);
2530            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2531            let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
2532            let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
2533            Column(
2534                Modifier::empty()
2535                    .size_points(260.0, 120.0)
2536                    .vertical_scroll(scroll_state, false),
2537                ColumnSpec::default(),
2538                move || {
2539                    Spacer(Size {
2540                        width: 0.0,
2541                        height: 180.0,
2542                    });
2543                    cranpose_ui::Box(
2544                        Modifier::empty().size_points(188.0, 88.0),
2545                        cranpose_ui::BoxSpec::default(),
2546                        {
2547                            let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
2548                            let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
2549                            move || {
2550                                let underlay_id = cranpose_ui::Box(
2551                                    Modifier::empty().size_points(188.0, 88.0),
2552                                    cranpose_ui::BoxSpec::default(),
2553                                    || {
2554                                        Text(
2555                                            "UNDERLAY CONTENT",
2556                                            Modifier::empty().absolute_offset(12.0, 8.0),
2557                                            TextStyle::default(),
2558                                        );
2559                                    },
2560                                );
2561                                *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
2562                                let overlay_id = cranpose_ui::Box(
2563                                    Modifier::empty().size_points(188.0, 88.0).graphics_layer(
2564                                        move || GraphicsLayer {
2565                                            alpha: alpha.get(),
2566                                            ..GraphicsLayer::default()
2567                                        },
2568                                    ),
2569                                    cranpose_ui::BoxSpec::default(),
2570                                    || {
2571                                        Text(
2572                                            "TOP LAYER",
2573                                            Modifier::empty().absolute_offset(74.0, 39.6),
2574                                            TextStyle::default(),
2575                                        );
2576                                    },
2577                                );
2578                                *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
2579                            }
2580                        },
2581                    );
2582                    Spacer(Size {
2583                        width: 0.0,
2584                        height: 280.0,
2585                    });
2586                },
2587            );
2588        });
2589
2590        let root = composition.root().expect("composition root");
2591        let viewport = Size {
2592            width: 260.0,
2593            height: 120.0,
2594        };
2595        let handle = composition.runtime_handle();
2596        let mut applier = composition.applier_mut();
2597        applier.set_runtime_handle(handle);
2598        applier
2599            .compute_layout(root, viewport)
2600            .expect("initial layout");
2601        applier.clear_runtime_handle();
2602        drop(applier);
2603
2604        let scroll_state = scroll_holder
2605            .borrow()
2606            .as_ref()
2607            .cloned()
2608            .expect("scroll state should be captured");
2609        assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
2610
2611        let handle = composition.runtime_handle();
2612        let mut applier = composition.applier_mut();
2613        applier.set_runtime_handle(handle);
2614        applier
2615            .compute_layout(root, viewport)
2616            .expect("scrolled layout");
2617        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2618        applier.clear_runtime_handle();
2619        drop(applier);
2620
2621        let underlay_id = underlay_id_holder
2622            .borrow()
2623            .expect("underlay id should be captured");
2624        let overlay_id = overlay_id_holder
2625            .borrow()
2626            .expect("overlay id should be captured");
2627        let scrolled_underlay_origin =
2628            find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
2629        let scrolled_overlay_origin =
2630            find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
2631        assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
2632
2633        let alpha = alpha_holder
2634            .borrow()
2635            .as_ref()
2636            .copied()
2637            .expect("alpha state should be captured");
2638        alpha.set_value(0.35);
2639
2640        let handle = composition.runtime_handle();
2641        let mut applier = composition.applier_mut();
2642        applier.set_runtime_handle(handle);
2643        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
2644        applier.clear_runtime_handle();
2645
2646        assert!(report.applied, "dirty overlay graph update should apply");
2647        let updated_underlay_origin =
2648            find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
2649        let updated_overlay_origin =
2650            find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
2651        assert_eq!(
2652            updated_underlay_origin, scrolled_underlay_origin,
2653            "stable underlay must keep its scrolled origin"
2654        );
2655        assert_eq!(
2656            updated_overlay_origin, updated_underlay_origin,
2657            "dirty overlay graphics layer must stay aligned with its stable underlay"
2658        );
2659    }
2660
2661    #[test]
2662    fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
2663        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2664            Rc::new(RefCell::new(None));
2665        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2666        let offset_holder_for_comp = offset_holder.clone();
2667        let node_id_holder_for_comp = node_id_holder.clone();
2668
2669        let mut composition = cranpose_ui::run_test_composition(move || {
2670            let offset = cranpose_core::useState(|| 0.0f32);
2671            *offset_holder_for_comp.borrow_mut() = Some(offset);
2672            let node_id = cranpose_ui::Box(
2673                Modifier::empty()
2674                    .size_points(40.0, 20.0)
2675                    .graphics_layer(move || GraphicsLayer {
2676                        translation_x: offset.get(),
2677                        ..GraphicsLayer::default()
2678                    }),
2679                cranpose_ui::BoxSpec::default(),
2680                || {},
2681            );
2682            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2683        });
2684
2685        let root = composition.root().expect("composition root");
2686        let viewport = Size {
2687            width: 120.0,
2688            height: 80.0,
2689        };
2690        let handle = composition.runtime_handle();
2691        let mut applier = composition.applier_mut();
2692        applier.set_runtime_handle(handle);
2693        applier
2694            .compute_layout(root, viewport)
2695            .expect("initial layout");
2696        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2697        let node_id = node_id_holder
2698            .borrow()
2699            .expect("graphics layer node id should be captured");
2700        let initial_origin = find_layer_by_node_id(&graph.root, node_id)
2701            .expect("initial graphics layer")
2702            .transform_to_parent
2703            .map_point(Point::default());
2704        applier.clear_runtime_handle();
2705        drop(applier);
2706
2707        let offset = offset_holder
2708            .borrow()
2709            .as_ref()
2710            .copied()
2711            .expect("offset state should be captured");
2712        offset.set_value(32.0);
2713
2714        let handle = composition.runtime_handle();
2715        let mut applier = composition.applier_mut();
2716        applier.set_runtime_handle(handle);
2717        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2718        assert!(
2719            report.applied,
2720            "dirty graphics layer should be replaceable from retained applier state"
2721        );
2722        assert!(
2723            !report.hit_graph_dirty,
2724            "a moved visual-only layer should not force hit graph refresh"
2725        );
2726        applier.clear_runtime_handle();
2727
2728        let updated_origin = find_layer_by_node_id(&graph.root, node_id)
2729            .expect("updated graphics layer")
2730            .transform_to_parent
2731            .map_point(Point::default());
2732        assert!(
2733            (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
2734            "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
2735        );
2736    }
2737
2738    #[test]
2739    fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
2740        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2741            Rc::new(RefCell::new(None));
2742        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2743        let offset_holder_for_comp = offset_holder.clone();
2744        let node_id_holder_for_comp = node_id_holder.clone();
2745
2746        let mut composition = cranpose_ui::run_test_composition(move || {
2747            let offset = cranpose_core::useState(|| 0.0f32);
2748            *offset_holder_for_comp.borrow_mut() = Some(offset);
2749            let node_id = cranpose_ui::Box(
2750                Modifier::empty()
2751                    .size_points(40.0, 20.0)
2752                    .graphics_layer(move || GraphicsLayer {
2753                        translation_x: offset.get(),
2754                        ..GraphicsLayer::default()
2755                    })
2756                    .clickable(|_| {}),
2757                cranpose_ui::BoxSpec::default(),
2758                || {},
2759            );
2760            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2761        });
2762
2763        let root = composition.root().expect("composition root");
2764        let viewport = Size {
2765            width: 120.0,
2766            height: 80.0,
2767        };
2768        let handle = composition.runtime_handle();
2769        let mut applier = composition.applier_mut();
2770        applier.set_runtime_handle(handle);
2771        applier
2772            .compute_layout(root, viewport)
2773            .expect("initial layout");
2774        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2775        let node_id = node_id_holder
2776            .borrow()
2777            .expect("graphics layer node id should be captured");
2778        applier.clear_runtime_handle();
2779        drop(applier);
2780
2781        let offset = offset_holder
2782            .borrow()
2783            .as_ref()
2784            .copied()
2785            .expect("offset state should be captured");
2786        offset.set_value(32.0);
2787
2788        let handle = composition.runtime_handle();
2789        let mut applier = composition.applier_mut();
2790        applier.set_runtime_handle(handle);
2791        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2792        applier.clear_runtime_handle();
2793
2794        assert!(
2795            report.applied,
2796            "dirty clickable graphics layer should be replaceable from retained applier state"
2797        );
2798        assert!(
2799            report.hit_graph_dirty,
2800            "moved clickable layers must refresh hit geometry"
2801        );
2802    }
2803
2804    #[test]
2805    fn overlay_draw_commands_are_tagged_after_children() {
2806        let child = BuildNodeSnapshot {
2807            node_id: 2,
2808            placement: Point { x: 4.0, y: 5.0 },
2809            size: Size {
2810                width: 20.0,
2811                height: 10.0,
2812            },
2813            content_offset: Point::default(),
2814            motion_context_animated: false,
2815            translated_content_context: false,
2816            measured_max_width: None,
2817            resolved_modifiers: ResolvedModifiers::default(),
2818            draw_commands: vec![],
2819            click_actions: vec![],
2820            pointer_inputs: vec![],
2821            clip_to_bounds: false,
2822            annotated_text: None,
2823            text_style: None,
2824            text_layout_options: None,
2825            text_pan: None,
2826            graphics_layer: None,
2827            children: vec![],
2828        };
2829        let behind = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
2830            scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2831                rect: Rect {
2832                    x: 1.0,
2833                    y: 2.0,
2834                    width: 8.0,
2835                    height: 6.0,
2836                },
2837                brush: Brush::solid(Color::WHITE),
2838                stroke: None,
2839            }]);
2840        }));
2841        let overlay = DrawCommand::Overlay(Rc::new(|scope: &mut DrawScopeDefault| {
2842            scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2843                rect: Rect {
2844                    x: 3.0,
2845                    y: 1.0,
2846                    width: 5.0,
2847                    height: 4.0,
2848                },
2849                brush: Brush::solid(Color::BLACK),
2850                stroke: None,
2851            }]);
2852        }));
2853
2854        let parent = BuildNodeSnapshot {
2855            node_id: 1,
2856            placement: Point::default(),
2857            size: Size {
2858                width: 80.0,
2859                height: 50.0,
2860            },
2861            content_offset: Point::default(),
2862            motion_context_animated: false,
2863            translated_content_context: false,
2864            measured_max_width: None,
2865            resolved_modifiers: ResolvedModifiers::default(),
2866            draw_commands: vec![behind, overlay],
2867            click_actions: vec![],
2868            pointer_inputs: vec![],
2869            clip_to_bounds: false,
2870            annotated_text: None,
2871            text_style: None,
2872            text_layout_options: None,
2873            text_pan: None,
2874            graphics_layer: None,
2875            children: vec![child],
2876        };
2877
2878        let graph = build_layer_node_for_test(parent, 1.0, false);
2879        let RenderNode::DrawRun(behind) = &graph.children[0] else {
2880            panic!("expected before-children draw run");
2881        };
2882        let RenderNode::Layer(_) = &graph.children[1] else {
2883            panic!("expected child layer");
2884        };
2885        let RenderNode::DrawRun(overlay) = &graph.children[2] else {
2886            panic!("expected after-children draw run");
2887        };
2888
2889        assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
2890        assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
2891    }
2892
2893    /// The recording registry's whole contract: a command re-recording on a
2894    /// rebuild reuses the buffer of a recording the graph has let go of, and
2895    /// never writes through one anything else still shares.
2896    #[test]
2897    fn command_recordings_reuse_buffers_across_rebuilds() {
2898        let snapshot = || BuildNodeSnapshot {
2899            node_id: 7001,
2900            placement: Point::default(),
2901            size: Size {
2902                width: 40.0,
2903                height: 20.0,
2904            },
2905            content_offset: Point::default(),
2906            motion_context_animated: false,
2907            translated_content_context: false,
2908            measured_max_width: None,
2909            resolved_modifiers: ResolvedModifiers::default(),
2910            draw_commands: vec![DrawCommand::Behind(Rc::new(
2911                |scope: &mut DrawScopeDefault| {
2912                    scope.draw_rect_at(
2913                        Rect {
2914                            x: 1.0,
2915                            y: 2.0,
2916                            width: 8.0,
2917                            height: 6.0,
2918                        },
2919                        Brush::solid(Color::WHITE),
2920                    );
2921                },
2922            ))],
2923            click_actions: vec![],
2924            pointer_inputs: vec![],
2925            clip_to_bounds: false,
2926            annotated_text: None,
2927            text_style: None,
2928            text_layout_options: None,
2929            text_pan: None,
2930            graphics_layer: None,
2931            children: vec![],
2932        };
2933        fn run_of(layer: &LayerNode) -> &DrawRunNode {
2934            let RenderNode::DrawRun(run) = &layer.children[0] else {
2935                panic!("expected draw run");
2936            };
2937            run
2938        }
2939
2940        let graph_a = build_layer_node_for_test(snapshot(), 1.0, false);
2941        let ptr_a = run_of(&graph_a).primitives.as_ptr();
2942
2943        // Graph A is still alive, so its buffer must not be lent out.
2944        let graph_b = build_layer_node_for_test(snapshot(), 1.0, false);
2945        let ptr_b = run_of(&graph_b).primitives.as_ptr();
2946        assert_ne!(
2947            ptr_a, ptr_b,
2948            "a buffer a live graph shares must never be recorded into"
2949        );
2950        assert_eq!(
2951            run_of(&graph_a).primitives,
2952            run_of(&graph_b).primitives,
2953            "re-recording must reproduce the recording"
2954        );
2955
2956        // With graph A gone, its buffer is the registry's to lend again. The
2957        // registry still holds a handle, so the allocator cannot have
2958        // recycled this address: pointer equality here is reuse, not luck.
2959        drop(graph_a);
2960        let graph_c = build_layer_node_for_test(snapshot(), 1.0, false);
2961        assert_eq!(
2962            run_of(&graph_c).primitives.as_ptr(),
2963            ptr_a,
2964            "the released buffer must be reused for the next recording"
2965        );
2966
2967        // A recording shared outside the graph (renderer caches, tests)
2968        // keeps its buffer out of circulation even after the node drops.
2969        let held = std::rc::Rc::clone(&run_of(&graph_c).primitives);
2970        drop(graph_c);
2971        let graph_d = build_layer_node_for_test(snapshot(), 1.0, false);
2972        let ptr_d = run_of(&graph_d).primitives.as_ptr();
2973        assert_ne!(ptr_d, held.as_ptr());
2974        assert_ne!(ptr_d, run_of(&graph_b).primitives.as_ptr());
2975    }
2976
2977    #[test]
2978    fn stored_content_hash_changes_when_child_transform_changes() {
2979        let child = BuildNodeSnapshot {
2980            node_id: 2,
2981            placement: Point { x: 4.0, y: 5.0 },
2982            size: Size {
2983                width: 20.0,
2984                height: 10.0,
2985            },
2986            content_offset: Point::default(),
2987            motion_context_animated: false,
2988            translated_content_context: false,
2989            measured_max_width: None,
2990            resolved_modifiers: ResolvedModifiers::default(),
2991            draw_commands: vec![],
2992            click_actions: vec![],
2993            pointer_inputs: vec![],
2994            clip_to_bounds: false,
2995            annotated_text: None,
2996            text_style: None,
2997            text_layout_options: None,
2998            text_pan: None,
2999            graphics_layer: None,
3000            children: vec![],
3001        };
3002        let mut moved_child = child.clone();
3003        moved_child.placement.x += 7.0;
3004
3005        let parent = BuildNodeSnapshot {
3006            node_id: 1,
3007            placement: Point::default(),
3008            size: Size {
3009                width: 80.0,
3010                height: 50.0,
3011            },
3012            content_offset: Point::default(),
3013            motion_context_animated: false,
3014            translated_content_context: false,
3015            measured_max_width: None,
3016            resolved_modifiers: ResolvedModifiers::default(),
3017            draw_commands: vec![],
3018            click_actions: vec![],
3019            pointer_inputs: vec![],
3020            clip_to_bounds: false,
3021            annotated_text: None,
3022            text_style: None,
3023            text_layout_options: None,
3024            text_pan: None,
3025            graphics_layer: None,
3026            children: vec![child],
3027        };
3028        let moved_parent = BuildNodeSnapshot {
3029            children: vec![moved_child],
3030            ..parent.clone()
3031        };
3032
3033        let static_graph = build_layer_node_for_test(parent, 1.0, false);
3034        let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
3035
3036        assert_ne!(
3037            static_graph.target_content_hash(),
3038            moved_graph.target_content_hash(),
3039            "moving a child within the parent must invalidate the parent subtree hash"
3040        );
3041    }
3042
3043    #[test]
3044    fn stored_effect_hash_tracks_local_effect_only() {
3045        let base = BuildNodeSnapshot {
3046            node_id: 1,
3047            placement: Point::default(),
3048            size: Size {
3049                width: 80.0,
3050                height: 50.0,
3051            },
3052            content_offset: Point::default(),
3053            motion_context_animated: false,
3054            translated_content_context: false,
3055            measured_max_width: None,
3056            resolved_modifiers: ResolvedModifiers::default(),
3057            draw_commands: vec![],
3058            click_actions: vec![],
3059            pointer_inputs: vec![],
3060            clip_to_bounds: false,
3061            annotated_text: None,
3062            text_style: None,
3063            text_layout_options: None,
3064            text_pan: None,
3065            graphics_layer: None,
3066            children: vec![],
3067        };
3068        let mut effected = base.clone();
3069        effected.graphics_layer = Some(GraphicsLayer {
3070            render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
3071            ..GraphicsLayer::default()
3072        });
3073
3074        let base_graph = build_layer_node_for_test(base, 1.0, false);
3075        let effected_graph = build_layer_node_for_test(effected, 1.0, false);
3076
3077        assert_eq!(
3078            base_graph.target_content_hash(),
3079            effected_graph.target_content_hash(),
3080            "post-processing effect parameters belong to the effect hash, not the content hash"
3081        );
3082        assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
3083    }
3084
3085    #[test]
3086    fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
3087        let mut text_style = TextStyle::default();
3088        text_style.paragraph_style.text_align = TextAlign::Start;
3089        text_style.paragraph_style.text_direction = TextDirection::Rtl;
3090        text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
3091
3092        let snapshot = BuildNodeSnapshot {
3093            node_id: 1,
3094            placement: Point::default(),
3095            size: Size {
3096                width: 180.0,
3097                height: 48.0,
3098            },
3099            content_offset: Point::default(),
3100            motion_context_animated: false,
3101            translated_content_context: false,
3102            measured_max_width: Some(180.0),
3103            resolved_modifiers: ResolvedModifiers::default(),
3104            draw_commands: vec![],
3105            click_actions: vec![],
3106            pointer_inputs: vec![],
3107            clip_to_bounds: false,
3108            annotated_text: Some(AnnotatedString::from("rtl")),
3109            text_style: Some(text_style),
3110            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
3111                overflow: cranpose_ui::TextOverflow::Clip,
3112                ..Default::default()
3113            }),
3114            text_pan: None,
3115            graphics_layer: None,
3116            children: vec![],
3117        };
3118
3119        let graph = build_layer_node_for_test(snapshot, 1.0, false);
3120        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
3121            panic!("expected text primitive");
3122        };
3123        let PrimitiveNode::Text(text) = &text_primitive.node else {
3124            panic!("expected text primitive");
3125        };
3126        let clip = text
3127            .clip
3128            .expect("clipped overflow should produce a clip rect");
3129
3130        assert!(
3131            text.rect.x > 0.0,
3132            "RTL start alignment should shift the text rect within the available width"
3133        );
3134        assert!(
3135            clip.y < text.rect.y,
3136            "baseline shift must expand the clip upward so superscript glyphs are preserved"
3137        );
3138        assert!(
3139            clip.intersect(text.rect).is_some(),
3140            "the clip rect must intersect the shifted text draw rect"
3141        );
3142    }
3143
3144    #[test]
3145    fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
3146        let snapshot = BuildNodeSnapshot {
3147            node_id: 1,
3148            placement: Point::default(),
3149            size: Size {
3150                width: 320.0,
3151                height: 48.0,
3152            },
3153            content_offset: Point::default(),
3154            motion_context_animated: false,
3155            translated_content_context: false,
3156            measured_max_width: Some(320.0),
3157            resolved_modifiers: ResolvedModifiers::default(),
3158            draw_commands: vec![],
3159            click_actions: vec![],
3160            pointer_inputs: vec![],
3161            clip_to_bounds: false,
3162            annotated_text: Some(AnnotatedString::from("short")),
3163            text_style: Some(TextStyle::default()),
3164            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
3165                overflow: cranpose_ui::TextOverflow::Clip,
3166                ..Default::default()
3167            }),
3168            text_pan: None,
3169            graphics_layer: None,
3170            children: vec![],
3171        };
3172
3173        let graph = build_layer_node_for_test(snapshot, 1.0, false);
3174        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
3175            panic!("expected text primitive");
3176        };
3177        let PrimitiveNode::Text(text) = &text_primitive.node else {
3178            panic!("expected text primitive");
3179        };
3180        let clip = text.clip.expect("clipped text should keep a clip rect");
3181
3182        assert!(
3183            text.rect.width < 320.0,
3184            "text raster bounds should track measured glyph width instead of full content width"
3185        );
3186        assert_eq!(
3187            clip.width, 322.0,
3188            "text clip should still preserve the full content box plus clip padding"
3189        );
3190    }
3191
3192    /// Single-line text fields provide a pan resolver: the glyphs must be
3193    /// laid out unconstrained (no wrapping), shifted left by the pan offset,
3194    /// and clipped to the field bounds.
3195    #[test]
3196    fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
3197        let pan_offset = 25.0_f32;
3198        let field_width = 80.0_f32;
3199        let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
3200        let viewports = resolved_viewports.clone();
3201        let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
3202            node_id: 1,
3203            placement: Point::default(),
3204            size: Size {
3205                width: field_width,
3206                height: 24.0,
3207            },
3208            content_offset: Point::default(),
3209            motion_context_animated: false,
3210            translated_content_context: false,
3211            measured_max_width: Some(field_width),
3212            resolved_modifiers: ResolvedModifiers::default(),
3213            draw_commands: vec![],
3214            click_actions: vec![],
3215            pointer_inputs: vec![],
3216            clip_to_bounds: false,
3217            annotated_text: Some(AnnotatedString::from(
3218                "a very long single line of text that cannot fit",
3219            )),
3220            text_style: Some(TextStyle::default()),
3221            text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
3222            text_pan,
3223            graphics_layer: None,
3224            children: vec![],
3225        };
3226
3227        let text_node = |snapshot: BuildNodeSnapshot| {
3228            let graph = build_layer_node_for_test(snapshot, 1.0, false);
3229            let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
3230                panic!("expected text primitive");
3231            };
3232            let PrimitiveNode::Text(text) = &text_primitive.node else {
3233                panic!("expected text primitive");
3234            };
3235            (**text).clone()
3236        };
3237
3238        let unpanned = text_node(make_snapshot(None));
3239        let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
3240            viewports.borrow_mut().push(viewport);
3241            pan_offset
3242        }))));
3243
3244        assert_eq!(
3245            resolved_viewports.borrow().as_slice(),
3246            &[field_width],
3247            "the pan resolver must receive the content viewport width"
3248        );
3249        assert_eq!(
3250            panned.rect.x, -pan_offset,
3251            "text glyphs must shift left by the pan offset"
3252        );
3253        assert!(
3254            panned.rect.width > field_width,
3255            "panned single-line text must be laid out unconstrained, got {}",
3256            panned.rect.width
3257        );
3258        assert!(
3259            panned.rect.width >= unpanned.rect.width,
3260            "unconstrained layout must not be narrower than wrapped layout"
3261        );
3262        assert!(
3263            panned.rect.height <= unpanned.rect.height,
3264            "single-line layout must not wrap onto extra lines"
3265        );
3266        let clip = panned
3267            .clip
3268            .expect("panned text field must clip to field bounds");
3269        assert!(
3270            clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
3271            "clip must not extend past the field bounds, got {clip:?}"
3272        );
3273    }
3274
3275    #[test]
3276    fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
3277        let child = BuildNodeSnapshot {
3278            node_id: 2,
3279            placement: Point { x: 11.0, y: 7.0 },
3280            size: Size {
3281                width: 120.0,
3282                height: 32.0,
3283            },
3284            content_offset: Point::default(),
3285            motion_context_animated: false,
3286            translated_content_context: false,
3287            measured_max_width: Some(120.0),
3288            resolved_modifiers: ResolvedModifiers::default(),
3289            draw_commands: vec![],
3290            click_actions: vec![],
3291            pointer_inputs: vec![],
3292            clip_to_bounds: false,
3293            annotated_text: Some(AnnotatedString::from("scrolling")),
3294            text_style: Some(TextStyle::default()),
3295            text_layout_options: None,
3296            text_pan: None,
3297            graphics_layer: None,
3298            children: vec![],
3299        };
3300        let parent = BuildNodeSnapshot {
3301            node_id: 1,
3302            placement: Point::default(),
3303            size: Size {
3304                width: 160.0,
3305                height: 64.0,
3306            },
3307            content_offset: Point { x: 0.0, y: -18.5 },
3308            motion_context_animated: false,
3309            translated_content_context: true,
3310            measured_max_width: None,
3311            resolved_modifiers: ResolvedModifiers::default(),
3312            draw_commands: vec![],
3313            click_actions: vec![],
3314            pointer_inputs: vec![],
3315            clip_to_bounds: false,
3316            annotated_text: None,
3317            text_style: None,
3318            text_layout_options: None,
3319            text_pan: None,
3320            graphics_layer: None,
3321            children: vec![child],
3322        };
3323
3324        let graph = build_layer_node_for_test(parent, 1.0, false);
3325        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3326            panic!("expected child layer");
3327        };
3328        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3329            panic!("expected text primitive");
3330        };
3331        let PrimitiveNode::Text(text) = &text_primitive.node else {
3332            panic!("expected text primitive");
3333        };
3334
3335        assert_eq!(text.text_style.paragraph_style.text_motion, None);
3336        assert!(!child_layer.motion_context_animated);
3337    }
3338
3339    #[test]
3340    fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
3341        let child = BuildNodeSnapshot {
3342            node_id: 2,
3343            placement: Point { x: 11.0, y: 7.0 },
3344            size: Size {
3345                width: 120.0,
3346                height: 32.0,
3347            },
3348            content_offset: Point::default(),
3349            motion_context_animated: false,
3350            translated_content_context: false,
3351            measured_max_width: Some(120.0),
3352            resolved_modifiers: ResolvedModifiers::default(),
3353            draw_commands: vec![],
3354            click_actions: vec![],
3355            pointer_inputs: vec![],
3356            clip_to_bounds: false,
3357            annotated_text: Some(AnnotatedString::from("scrolling")),
3358            text_style: Some(TextStyle::default()),
3359            text_layout_options: None,
3360            text_pan: None,
3361            graphics_layer: None,
3362            children: vec![],
3363        };
3364        let parent = BuildNodeSnapshot {
3365            node_id: 1,
3366            placement: Point::default(),
3367            size: Size {
3368                width: 160.0,
3369                height: 64.0,
3370            },
3371            content_offset: Point { x: 0.0, y: -18.0 },
3372            motion_context_animated: false,
3373            translated_content_context: false,
3374            measured_max_width: None,
3375            resolved_modifiers: ResolvedModifiers::default(),
3376            draw_commands: vec![],
3377            click_actions: vec![],
3378            pointer_inputs: vec![],
3379            clip_to_bounds: false,
3380            annotated_text: None,
3381            text_style: None,
3382            text_layout_options: None,
3383            text_pan: None,
3384            graphics_layer: None,
3385            children: vec![child],
3386        };
3387
3388        let graph = build_layer_node_for_test(parent, 1.0, false);
3389        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3390            panic!("expected child layer");
3391        };
3392        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3393            panic!("expected text primitive");
3394        };
3395        let PrimitiveNode::Text(text) = &text_primitive.node else {
3396            panic!("expected text primitive");
3397        };
3398
3399        assert_eq!(
3400            text.text_style.paragraph_style.text_motion, None,
3401            "content_offset alone must not force text onto the translated-content motion path"
3402        );
3403        assert!(!child_layer.motion_context_animated);
3404    }
3405
3406    #[test]
3407    fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
3408        let child = BuildNodeSnapshot {
3409            node_id: 2,
3410            placement: Point { x: 11.0, y: 7.0 },
3411            size: Size {
3412                width: 120.0,
3413                height: 32.0,
3414            },
3415            content_offset: Point::default(),
3416            motion_context_animated: false,
3417            translated_content_context: false,
3418            measured_max_width: Some(120.0),
3419            resolved_modifiers: ResolvedModifiers::default(),
3420            draw_commands: vec![],
3421            click_actions: vec![],
3422            pointer_inputs: vec![],
3423            clip_to_bounds: false,
3424            annotated_text: Some(AnnotatedString::from("shadow")),
3425            text_style: Some(TextStyle::from_span_style(SpanStyle {
3426                shadow: Some(cranpose_ui::text::Shadow {
3427                    color: Color::BLACK,
3428                    offset: Point::new(1.0, 2.0),
3429                    blur_radius: 3.0,
3430                }),
3431                ..SpanStyle::default()
3432            })),
3433            text_layout_options: None,
3434            text_pan: None,
3435            graphics_layer: None,
3436            children: vec![],
3437        };
3438        let parent = BuildNodeSnapshot {
3439            node_id: 1,
3440            placement: Point::default(),
3441            size: Size {
3442                width: 160.0,
3443                height: 64.0,
3444            },
3445            content_offset: Point { x: 0.0, y: -18.5 },
3446            motion_context_animated: false,
3447            translated_content_context: true,
3448            measured_max_width: None,
3449            resolved_modifiers: ResolvedModifiers::default(),
3450            draw_commands: vec![],
3451            click_actions: vec![],
3452            pointer_inputs: vec![],
3453            clip_to_bounds: false,
3454            annotated_text: None,
3455            text_style: None,
3456            text_layout_options: None,
3457            text_pan: None,
3458            graphics_layer: None,
3459            children: vec![child],
3460        };
3461
3462        let graph = build_layer_node_for_test(parent, 1.0, false);
3463        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3464            panic!("expected child layer");
3465        };
3466        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3467            panic!("expected text primitive");
3468        };
3469        let PrimitiveNode::Text(text) = &text_primitive.node else {
3470            panic!("expected text primitive");
3471        };
3472
3473        assert_eq!(text.text_style.paragraph_style.text_motion, None);
3474    }
3475
3476    #[test]
3477    fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
3478        let child = BuildNodeSnapshot {
3479            node_id: 2,
3480            placement: Point { x: 11.0, y: 7.0 },
3481            size: Size {
3482                width: 120.0,
3483                height: 32.0,
3484            },
3485            content_offset: Point::default(),
3486            motion_context_animated: false,
3487            translated_content_context: false,
3488            measured_max_width: Some(120.0),
3489            resolved_modifiers: ResolvedModifiers::default(),
3490            draw_commands: vec![],
3491            click_actions: vec![],
3492            pointer_inputs: vec![],
3493            clip_to_bounds: false,
3494            annotated_text: Some(AnnotatedString::from("lazy")),
3495            text_style: Some(TextStyle::default()),
3496            text_layout_options: None,
3497            text_pan: None,
3498            graphics_layer: None,
3499            children: vec![],
3500        };
3501        let parent = BuildNodeSnapshot {
3502            node_id: 1,
3503            placement: Point::default(),
3504            size: Size {
3505                width: 160.0,
3506                height: 64.0,
3507            },
3508            content_offset: Point::default(),
3509            motion_context_animated: true,
3510            translated_content_context: false,
3511            measured_max_width: None,
3512            resolved_modifiers: ResolvedModifiers::default(),
3513            draw_commands: vec![],
3514            click_actions: vec![],
3515            pointer_inputs: vec![],
3516            clip_to_bounds: false,
3517            annotated_text: None,
3518            text_style: None,
3519            text_layout_options: None,
3520            text_pan: None,
3521            graphics_layer: None,
3522            children: vec![child],
3523        };
3524
3525        let graph = build_layer_node_for_test(parent, 1.0, false);
3526        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3527            panic!("expected child layer");
3528        };
3529        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3530            panic!("expected text primitive");
3531        };
3532        let PrimitiveNode::Text(text) = &text_primitive.node else {
3533            panic!("expected text primitive");
3534        };
3535
3536        assert_eq!(text.text_style.paragraph_style.text_motion, None);
3537        assert!(graph.motion_context_animated);
3538        assert!(child_layer.motion_context_animated);
3539    }
3540
3541    #[test]
3542    fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
3543        let mut composition = cranpose_ui::run_test_composition(|| {
3544            let list_state = remember_lazy_list_state();
3545            LazyColumn(
3546                Modifier::empty(),
3547                list_state,
3548                LazyColumnSpec::default(),
3549                |scope| {
3550                    scope.item(Some(0), None, || {
3551                        Text("LazyMotion", Modifier::empty(), TextStyle::default());
3552                    });
3553                },
3554            );
3555        });
3556
3557        let root = composition.root().expect("lazy column root");
3558        let handle = composition.runtime_handle();
3559        let mut applier = composition.applier_mut();
3560        applier.set_runtime_handle(handle);
3561        let _ = applier
3562            .compute_layout(
3563                root,
3564                Size {
3565                    width: 240.0,
3566                    height: 240.0,
3567                },
3568            )
3569            .expect("lazy column layout");
3570        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3571        applier.clear_runtime_handle();
3572
3573        assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
3574    }
3575
3576    #[test]
3577    fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
3578        use std::cell::RefCell;
3579        use std::rc::Rc;
3580
3581        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3582        let state_holder_for_comp = state_holder.clone();
3583        let mut composition = cranpose_ui::run_test_composition(move || {
3584            let list_state = remember_lazy_list_state();
3585            *state_holder_for_comp.borrow_mut() = Some(list_state);
3586            LazyColumn(
3587                Modifier::empty().height(120.0),
3588                list_state,
3589                LazyColumnSpec::default(),
3590                |scope| {
3591                    scope.items(
3592                        8,
3593                        None::<fn(usize) -> u64>,
3594                        None::<fn(usize) -> u64>,
3595                        |index| {
3596                            Text(
3597                                format!("LazyMotion {index}"),
3598                                Modifier::empty().padding(4.0),
3599                                TextStyle::default(),
3600                            );
3601                        },
3602                    );
3603                },
3604            );
3605        });
3606
3607        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3608        list_state.scroll_to_item(3, 0.0);
3609
3610        let root = composition.root().expect("lazy column root");
3611        let handle = composition.runtime_handle();
3612        let mut applier = composition.applier_mut();
3613        applier.set_runtime_handle(handle);
3614        let _ = applier
3615            .compute_layout(
3616                root,
3617                Size {
3618                    width: 240.0,
3619                    height: 240.0,
3620                },
3621            )
3622            .expect("lazy column layout");
3623        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3624        let active_children = applier
3625            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3626            .expect("lazy column should be subcompose");
3627        let child_debug: Vec<String> = active_children
3628            .iter()
3629            .map(|&child_id| {
3630                if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
3631                    format!(
3632                        "layout#{child_id} placed={} text={:?} children={:?}",
3633                        node.layout_state().is_placed,
3634                        node.modifier_slices_snapshot()
3635                            .text_content()
3636                            .map(str::to_string),
3637                        node.children.clone()
3638                    )
3639                }) {
3640                    summary
3641                } else if let Ok(summary) =
3642                    applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
3643                        format!(
3644                            "subcompose#{child_id} placed={} active_children={:?}",
3645                            node.layout_state().is_placed,
3646                            node.active_children()
3647                        )
3648                    })
3649                {
3650                    summary
3651                } else {
3652                    format!("missing#{child_id}")
3653                }
3654            })
3655            .collect();
3656        applier.clear_runtime_handle();
3657
3658        let first_index = list_state.first_visible_item_index();
3659        assert!(
3660            first_index > 0,
3661            "lazy list should move away from origin before graph building, observed first_index={first_index}"
3662        );
3663        let mut labels = Vec::new();
3664        collect_text_labels(&graph.root, &mut labels);
3665        assert_eq!(
3666            find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
3667            Some(None),
3668            "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
3669            labels,
3670            active_children,
3671            child_debug
3672        );
3673    }
3674
3675    #[test]
3676    fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
3677        use std::cell::RefCell;
3678        use std::rc::Rc;
3679
3680        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3681        let state_holder_for_comp = state_holder.clone();
3682        let mut composition = cranpose_ui::run_test_composition(move || {
3683            let list_state = remember_lazy_list_state();
3684            *state_holder_for_comp.borrow_mut() = Some(list_state);
3685            let mut spec =
3686                LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
3687            spec.beyond_bounds_item_count = 0;
3688            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
3689                scope.items(
3690                    12,
3691                    None::<fn(usize) -> u64>,
3692                    None::<fn(usize) -> u64>,
3693                    |index| {
3694                        Text(
3695                            format!("WarmRow {index}"),
3696                            Modifier::empty().height(32.0),
3697                            TextStyle::default(),
3698                        );
3699                    },
3700                );
3701            });
3702        });
3703
3704        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3705        list_state.scroll_to_item(4, 0.0);
3706
3707        let root = composition.root().expect("lazy column root");
3708        let handle = composition.runtime_handle();
3709        let mut applier = composition.applier_mut();
3710        applier.set_runtime_handle(handle);
3711        let _ = applier
3712            .compute_layout(
3713                root,
3714                Size {
3715                    width: 240.0,
3716                    height: 240.0,
3717                },
3718            )
3719            .expect("lazy column layout");
3720        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3721        let active_children = applier
3722            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3723            .expect("lazy column should be subcompose");
3724        applier.clear_runtime_handle();
3725
3726        let visible_indices: Vec<_> = list_state
3727            .layout_info()
3728            .visible_items_info
3729            .iter()
3730            .map(|item| item.index)
3731            .collect();
3732        let mut labels = Vec::new();
3733        collect_text_labels(&graph.root, &mut labels);
3734
3735        assert_eq!(
3736            visible_indices,
3737            vec![4, 5, 6],
3738            "test setup expects exactly three viewport-visible rows"
3739        );
3740        assert!(
3741            labels.iter().any(|label| label == "WarmRow 7"),
3742            "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
3743        );
3744    }
3745
3746    #[test]
3747    fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
3748        use std::cell::RefCell;
3749        use std::rc::Rc;
3750
3751        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3752        let state_holder_for_comp = state_holder.clone();
3753        let mut composition = cranpose_ui::run_test_composition(move || {
3754            let list_state = remember_lazy_list_state();
3755            *state_holder_for_comp.borrow_mut() = Some(list_state);
3756            LazyColumn(
3757                Modifier::empty().height(120.0),
3758                list_state,
3759                LazyColumnSpec::default(),
3760                |scope| {
3761                    scope.items(
3762                        8,
3763                        None::<fn(usize) -> u64>,
3764                        None::<fn(usize) -> u64>,
3765                        |index| {
3766                            Text(
3767                                format!("LazySnap {index}"),
3768                                Modifier::empty().padding(4.0),
3769                                TextStyle::default(),
3770                            );
3771                        },
3772                    );
3773                },
3774            );
3775        });
3776
3777        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3778        list_state.scroll_to_item(2, 7.5);
3779
3780        let root = composition.root().expect("lazy column root");
3781        let handle = composition.runtime_handle();
3782        let mut applier = composition.applier_mut();
3783        applier.set_runtime_handle(handle);
3784        let _ = applier
3785            .compute_layout(
3786                root,
3787                Size {
3788                    width: 240.0,
3789                    height: 240.0,
3790                },
3791            )
3792            .expect("lazy column layout");
3793        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3794        applier.clear_runtime_handle();
3795
3796        let layout_info = list_state.layout_info();
3797        let first_visible_offset = layout_info
3798            .visible_items_info
3799            .first()
3800            .expect("lazy layout should expose visible item info")
3801            .offset;
3802        let snap_offset = find_translated_content_offset(&graph.root)
3803            .expect("lazy list graph should include translated content context");
3804
3805        assert!(
3806            (snap_offset.y - first_visible_offset).abs() <= 0.001,
3807            "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
3808        );
3809    }
3810
3811    #[test]
3812    fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
3813        let child = BuildNodeSnapshot {
3814            node_id: 2,
3815            placement: Point { x: 11.0, y: 7.0 },
3816            size: Size {
3817                width: 120.0,
3818                height: 32.0,
3819            },
3820            content_offset: Point::default(),
3821            motion_context_animated: false,
3822            translated_content_context: false,
3823            measured_max_width: Some(120.0),
3824            resolved_modifiers: ResolvedModifiers::default(),
3825            draw_commands: vec![],
3826            click_actions: vec![],
3827            pointer_inputs: vec![],
3828            clip_to_bounds: false,
3829            annotated_text: Some(AnnotatedString::from("static")),
3830            text_style: Some(TextStyle::from_paragraph_style(
3831                cranpose_ui::text::ParagraphStyle {
3832                    text_motion: Some(TextMotion::Static),
3833                    ..Default::default()
3834                },
3835            )),
3836            text_layout_options: None,
3837            text_pan: None,
3838            graphics_layer: None,
3839            children: vec![],
3840        };
3841        let parent = BuildNodeSnapshot {
3842            node_id: 1,
3843            placement: Point::default(),
3844            size: Size {
3845                width: 160.0,
3846                height: 64.0,
3847            },
3848            content_offset: Point { x: 0.0, y: -18.5 },
3849            motion_context_animated: false,
3850            translated_content_context: true,
3851            measured_max_width: None,
3852            resolved_modifiers: ResolvedModifiers::default(),
3853            draw_commands: vec![],
3854            click_actions: vec![],
3855            pointer_inputs: vec![],
3856            clip_to_bounds: false,
3857            annotated_text: None,
3858            text_style: None,
3859            text_layout_options: None,
3860            text_pan: None,
3861            graphics_layer: None,
3862            children: vec![child],
3863        };
3864
3865        let graph = build_layer_node_for_test(parent, 1.0, false);
3866        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3867            panic!("expected child layer");
3868        };
3869        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3870            panic!("expected text primitive");
3871        };
3872        let PrimitiveNode::Text(text) = &text_primitive.node else {
3873            panic!("expected text primitive");
3874        };
3875
3876        assert_eq!(
3877            text.text_style.paragraph_style.text_motion,
3878            Some(TextMotion::Static),
3879            "explicit text motion must win over inherited scrolling motion context"
3880        );
3881    }
3882
3883    /// A wrapping paragraph must PAINT the height it MEASURED, so the sibling
3884    /// the column placed after it is not drawn over.
3885    ///
3886    /// Regression. A `Text` without `fill_max_width` is placed at its own
3887    /// `metrics.width` — the widest line it wrapped into. The paint pass then
3888    /// re-wrapped at that placed width, and because the widest line is exactly
3889    /// the one that fills the limit, measuring it against itself pushed its
3890    /// last word onto a new line: measured 6 lines, painted 7. The extra line
3891    /// was clipped away (silent truncation) and it ran past the next sibling's
3892    /// box, which the column had placed from the 6-line height.
3893    ///
3894    /// Asserts RENDERED GEOMETRY, not `resolve_text_measure_width`'s return
3895    /// value: the two pipelines already had unit tests for the correct rule and
3896    /// shipped this anyway, because those tests exercised a `#[cfg(test)]`
3897    /// replica rather than the scene builder that paints.
3898    ///
3899    /// Driven by the REAL font backend (`SoftwareTextMeasurer`, the measurer
3900    /// `WgpuRenderer::attach_app_context_services` installs). A stub measurer
3901    /// cannot show this: the defect lives in the disagreement between two wrap
3902    /// widths, and a stub that returns the same answer for both hides it by
3903    /// construction. The string is mixed Latin/Cyrillic because that is what
3904    /// the reporting app puts in these paragraphs.
3905    #[test]
3906    fn wrapped_paragraph_paints_the_height_it_measured() {
3907        const BODY: &str = "fed back картица scored fp32 износ once paper fed Vision dropped \
3908             fed widest the strip mask prompt mask threshold Vision on датум instance mask \
3909             износ Apple";
3910        const FOLLOWING: &str = "FOLLOWING SIBLING";
3911
3912        let app_context = cranpose_ui::AppContext::new();
3913        app_context.enter(|| {
3914            cranpose_ui::text::set_text_measurer(
3915                crate::software_text_raster::SoftwareTextMeasurer::from_fonts_or_default(&[], 8192),
3916            );
3917            let mut composition = cranpose_ui::run_test_composition(move || {
3918                Column(
3919                    Modifier::empty().fill_max_width(),
3920                    ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
3921                    move || {
3922                        Text(BODY.to_string(), Modifier::empty(), TextStyle::default());
3923                        Text(
3924                            FOLLOWING.to_string(),
3925                            Modifier::empty(),
3926                            TextStyle::default(),
3927                        );
3928                    },
3929                );
3930            });
3931
3932            let root = composition.root().expect("composition root");
3933            let handle = composition.runtime_handle();
3934            let mut applier = composition.applier_mut();
3935            applier.set_runtime_handle(handle);
3936            let layout = applier
3937                .compute_layout(
3938                    root,
3939                    Size {
3940                        width: 245.0,
3941                        height: 900.0,
3942                    },
3943                )
3944                .expect("layout");
3945
3946            fn find_box<'a>(node: &'a LayoutBox, value: &str) -> Option<&'a LayoutBox> {
3947                if node
3948                    .node_data
3949                    .modifier_slices()
3950                    .text_content()
3951                    .is_some_and(|text| text == value)
3952                {
3953                    return Some(node);
3954                }
3955                node.children
3956                    .iter()
3957                    .find_map(|child| find_box(child, value))
3958            }
3959            let body_box = find_box(layout.root(), BODY).expect("measured paragraph box");
3960            let following_box = find_box(layout.root(), FOLLOWING).expect("measured sibling box");
3961            let measured_height = body_box.rect.height;
3962            let following_top = following_box.rect.y;
3963            assert!(
3964                measured_height > 60.0,
3965                "test setup expects a genuinely multi-line paragraph, got {measured_height}"
3966            );
3967            assert!(
3968                body_box.rect.width < 245.0,
3969                "test setup expects the node to be placed at its own measured width, \
3970                 not the full constraint, got {}",
3971                body_box.rect.width
3972            );
3973
3974            let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("render graph");
3975            applier.clear_runtime_handle();
3976
3977            // The painted string carries the wrap points as newlines, so it is
3978            // compared with whitespace stripped rather than verbatim.
3979            fn squashed(value: &str) -> String {
3980                value.chars().filter(|c| !c.is_whitespace()).collect()
3981            }
3982            fn find_text<'a>(layer: &'a LayerNode, value: &str) -> Option<&'a TextPrimitiveNode> {
3983                for child in &layer.children {
3984                    match child {
3985                        RenderNode::Primitive(primitive) => {
3986                            if let PrimitiveNode::Text(text) = &primitive.node {
3987                                if squashed(&text.text.text) == squashed(value) {
3988                                    return Some(text);
3989                                }
3990                            }
3991                        }
3992                        RenderNode::Layer(child_layer) => {
3993                            if let Some(found) = find_text(child_layer, value) {
3994                                return Some(found);
3995                            }
3996                        }
3997                        RenderNode::DrawRun(_) => {}
3998                    }
3999                }
4000                None
4001            }
4002            let painted = find_text(&graph.root, BODY).expect("painted paragraph");
4003
4004            assert!(
4005                (painted.rect.height - measured_height).abs() < 0.5,
4006                "paragraph painted {:.2} tall into a box layout measured at {:.2} \
4007                 (painted rect {:?})",
4008                painted.rect.height,
4009                measured_height,
4010                painted.rect
4011            );
4012            assert!(
4013                painted.rect.y + painted.rect.height <= following_top + 0.5,
4014                "painted paragraph bottom {:.2} runs past the following sibling placed at \
4015                 {:.2}",
4016                painted.rect.y + painted.rect.height,
4017                following_top
4018            );
4019        });
4020    }
4021
4022    #[test]
4023    fn retained_slot_confirmations_are_live_only_under_their_generation() {
4024        let command = DrawCommandId {
4025            node_id: 990_101,
4026            command_index: 0,
4027            placement: DrawPlacement::Behind,
4028        };
4029        set_retained_feed_epoch(Some(7));
4030        confirm_retained_slot(command, 3, 7);
4031        assert!(retained_slot_confirmed(command, 3));
4032        // A new epoch (renderer swap, device loss) makes the word stale.
4033        set_retained_feed_epoch(Some(8));
4034        assert!(!retained_slot_confirmed(command, 3));
4035        // No declared epoch means no consumer: never confirmed.
4036        set_retained_feed_epoch(None);
4037        assert!(!retained_slot_confirmed(command, 3));
4038        // Back on the stored generation the buffer is live again.
4039        set_retained_feed_epoch(Some(7));
4040        assert!(retained_slot_confirmed(command, 3));
4041        revoke_retained_slot(command, 3);
4042        assert!(!retained_slot_confirmed(command, 3));
4043        set_retained_feed_epoch(None);
4044        clear_retained_slot_confirmations();
4045    }
4046
4047    /// Draws enough similar arcs for the replay verifier to engage
4048    /// (`MIN_REPLAY_COMMAND_RECORDS`) and to carve several segments —
4049    /// partial arcs, because a chain anchor must pin rotation and circles
4050    /// cannot. Static across frames, so every segment verifies under the
4051    /// identity transform from the first replay frame on.
4052    fn record_sweep_test_rings(scope: &mut DrawScopeDefault) {
4053        let count = 600usize;
4054        let sweep = std::f32::consts::TAU / count as f32 * 0.8;
4055        for i in 0..count {
4056            let start = i as f32 * (std::f32::consts::TAU / count as f32);
4057            scope.draw_annular_sector(
4058                Brush::solid(cranpose_ui_graphics::Color(0.2, 0.4, 0.6, 1.0)),
4059                cranpose_ui_graphics::Point::new(204.0, 204.0),
4060                140.0,
4061                150.0,
4062                start,
4063                sweep,
4064            );
4065        }
4066    }
4067
4068    /// The FLIP of the old sweep-exemption test: with the frame owning a
4069    /// pinned handle to the exact recording it was built from, the idle
4070    /// sweep is pure capacity management again — a live confirmation no
4071    /// longer pins the registry slot, the sweep drops it anyway, and the
4072    /// frame's bypassed spans still rematerialize byte-identically from the
4073    /// handle the frame owns. Sweeping can categorically never sever a
4074    /// frame's rematerialization source.
4075    #[test]
4076    fn recording_sweep_cannot_sever_a_frames_fallback() {
4077        let command = DrawCommandId {
4078            node_id: 990_102,
4079            command_index: 0,
4080            placement: DrawPlacement::Behind,
4081        };
4082        set_retained_feed_epoch(Some(41));
4083        for slot in 0..64 {
4084            confirm_retained_slot(command, slot, 41);
4085        }
4086
4087        // The production seam order, driven by hand: acquire buffers,
4088        // record, verify, finish with the confirmed slots bypassed, publish
4089        // — and the frame keeps the published recording handle, exactly as
4090        // `draw_nodes` attaches it.
4091        let mut state = cranpose_ui_graphics::CommandReplayState::default();
4092        let mut published = None;
4093        for _frame in 0..4 {
4094            let (recording, storage, _) = acquire_recording(command);
4095            let mut scope = DrawScopeDefault::with_recording(
4096                cranpose_ui_graphics::Size::new(408.0, 408.0),
4097                None,
4098                recording,
4099                storage,
4100            );
4101            record_sweep_test_rings(&mut scope);
4102            let outcome = state.advance(scope.recorded());
4103            let center = state.center();
4104            let (finished, frame) = scope.finish_replay(center, outcome, &mut |slot| {
4105                retained_slot_confirmed(command, slot)
4106            });
4107            let (primitives, fallback) =
4108                publish_recording(command, finished.recording, finished.primitives, None);
4109            let frame = frame.map(|mut frame| {
4110                frame.fallback = Some(fallback.clone());
4111                frame
4112            });
4113            published = Some((primitives, fallback, frame));
4114        }
4115        let (_primitives, fallback, frame) = published.expect("four frames published");
4116        let frame = frame.expect("the replay must produce a frame with retained spans");
4117        let bypassed: Vec<(u32, u32)> = frame
4118            .spans
4119            .iter()
4120            .filter_map(|span| match span {
4121                cranpose_ui_graphics::FrameSpan::Retained {
4122                    capture: false,
4123                    range,
4124                    tape_range,
4125                    ..
4126                } if range.1 <= range.0 => Some(*tape_range),
4127                _ => None,
4128            })
4129            .collect();
4130        assert!(
4131            !bypassed.is_empty(),
4132            "confirmed slots must actually have bypassed materialization"
4133        );
4134        let expected: Vec<Vec<DrawPrimitive>> = bypassed
4135            .iter()
4136            .map(|tape_range| {
4137                fallback
4138                    .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
4139                    .expect("a frame-consistent tape range must materialize")
4140            })
4141            .collect();
4142
4143        // 1024 builds without re-recording: both sweeps (512, 1024) run with
4144        // the slot idle far past the 64-build window, confirmations still
4145        // live. The slot must be GONE — capacity management owes the frame
4146        // nothing anymore.
4147        for _ in 0..1024 {
4148            bump_recording_generation();
4149        }
4150        assert!(
4151            COMMAND_RECORDINGS.with(|map| !map.borrow().contains_key(&command)),
4152            "the sweep must stay pure capacity management: a live confirmation \
4153             no longer pins the registry slot"
4154        );
4155
4156        // The categorical property: the frame's own handle survives any
4157        // sweep, and its bypassed spans rematerialize byte-identically.
4158        for (tape_range, expected) in bypassed.iter().zip(&expected) {
4159            let after = fallback
4160                .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
4161                .expect("the frame-owned recording must outlive the sweep");
4162            assert_eq!(
4163                &after, expected,
4164                "post-sweep rematerialization must be byte-identical"
4165            );
4166        }
4167        set_retained_feed_epoch(None);
4168        clear_retained_slot_confirmations();
4169    }
4170
4171    /// [`command_recordings_reuse_buffers_across_rebuilds`] for the compact
4172    /// recording pair: a graph frame owns last build's recording (its
4173    /// `fallback`) while this build records, so steady-state publishes
4174    /// ping-pong between exactly two allocations — `Rc::try_unwrap`
4175    /// succeeds at every acquisition after warmup and no recording buffer
4176    /// is ever reallocated — and a recording a live frame still shares is
4177    /// never written through.
4178    #[test]
4179    fn command_recordings_reuse_recording_buffers_across_rebuilds() {
4180        let command = DrawCommandId {
4181            node_id: 990_103,
4182            command_index: 0,
4183            placement: DrawPlacement::Behind,
4184        };
4185        // Simulates the installed graph: it holds the newest build's
4186        // handles, and the previous build's drop when it is replaced.
4187        let mut held = None;
4188        let mut ptrs = Vec::new();
4189        for _build in 0..8 {
4190            let (recording, storage, _) = acquire_recording(command);
4191            let mut scope = DrawScopeDefault::with_recording(
4192                cranpose_ui_graphics::Size::new(64.0, 64.0),
4193                None,
4194                recording,
4195                storage,
4196            );
4197            scope.draw_rect_at(
4198                Rect {
4199                    x: 4.0,
4200                    y: 4.0,
4201                    width: 16.0,
4202                    height: 8.0,
4203                },
4204                Brush::solid(Color::WHITE),
4205            );
4206            let finished = scope.finish();
4207            let (primitives, recording) =
4208                publish_recording(command, finished.recording, finished.primitives, None);
4209            ptrs.push(recording.tape_ptr());
4210            held = Some((primitives, recording));
4211        }
4212        drop(held);
4213        // Every buffer in play stays alive for the whole loop (registry pair
4214        // or in-flight scope), so pointer equality here is reuse, not an
4215        // allocator recycling a freed address.
4216        for build in 2..8 {
4217            assert_eq!(
4218                ptrs[build],
4219                ptrs[build - 2],
4220                "steady-state publishes must ping-pong between the pair's \
4221                 buffers (build {build} allocated)"
4222            );
4223        }
4224        assert_ne!(
4225            ptrs[6], ptrs[7],
4226            "a recording a live frame still shares must never be recorded into"
4227        );
4228    }
4229
4230    #[test]
4231    fn sanitized_spans_drop_recolors_and_downgrade_captures() {
4232        use cranpose_ui_graphics::{FrameSpan, RecordTransform};
4233        let bounds = Rect {
4234            x: 1.0,
4235            y: 2.0,
4236            width: 3.0,
4237            height: 4.0,
4238        };
4239        let spans = vec![
4240            FrameSpan::Dynamic { range: (0, 5) },
4241            FrameSpan::Retained {
4242                slot: 7,
4243                capture: true,
4244                slot_offset: 0,
4245                range: (5, 105),
4246                tape_range: (5, 105),
4247                transform: RecordTransform::IDENTITY,
4248                recolors: Vec::new(),
4249                bounds,
4250            },
4251            FrameSpan::Retained {
4252                slot: 8,
4253                capture: false,
4254                slot_offset: 3,
4255                range: (105, 205),
4256                tape_range: (110, 210),
4257                transform: RecordTransform {
4258                    scale: 0.999,
4259                    angle: 0.05,
4260                },
4261                recolors: vec![(4, cranpose_ui_graphics::Color(1.0, 0.5, 0.2, 1.0))],
4262                bounds,
4263            },
4264        ];
4265        let sanitized = sanitized_replay_spans(&spans);
4266        // The dynamic span passes through untouched.
4267        assert_eq!(sanitized[0], FrameSpan::Dynamic { range: (0, 5) });
4268        // The capture span becomes a plain dynamic draw of the SAME
4269        // materialized range: re-emitting it must redraw its pixels
4270        // without re-queuing a capture.
4271        assert_eq!(sanitized[1], FrameSpan::Dynamic { range: (5, 105) });
4272        // The retained span keeps its identity and transform but sheds its
4273        // recolors: the renderer's slot paint is persistent, so the
4274        // previous frame's absolute writes already show them.
4275        match &sanitized[2] {
4276            FrameSpan::Retained {
4277                slot,
4278                capture,
4279                slot_offset,
4280                range,
4281                tape_range,
4282                transform,
4283                recolors,
4284                bounds: sanitized_bounds,
4285            } => {
4286                assert_eq!((*slot, *capture, *slot_offset), (8, false, 3));
4287                assert_eq!((*range, *tape_range), ((105, 205), (110, 210)));
4288                assert_eq!(transform.angle, 0.05);
4289                assert!(recolors.is_empty(), "recolors must be emptied");
4290                assert_eq!(*sanitized_bounds, bounds);
4291            }
4292            other => panic!("expected a retained span, got {other:?}"),
4293        }
4294    }
4295
4296    /// The one-frame staleness cap, at the registry seam it lives on: a
4297    /// saved emission serves on the IMMEDIATELY following build only, and
4298    /// serving consumes it — so two consecutive collapses can produce at
4299    /// most one stale frame, with no counter anywhere.
4300    #[test]
4301    fn a_saved_emission_serves_the_next_build_once() {
4302        let command = DrawCommandId {
4303            node_id: 990_303,
4304            command_index: 0,
4305            placement: DrawPlacement::Behind,
4306        };
4307        set_retained_feed_epoch(Some(77));
4308        // Materialize the slot the save writes into.
4309        publish_recording(
4310            command,
4311            cranpose_ui_graphics::CommandRecording::default(),
4312            Vec::new(),
4313            None,
4314        );
4315        let saved = || SavedReplayEmission {
4316            spans: vec![cranpose_ui_graphics::FrameSpan::Dynamic { range: (0, 3) }],
4317            center: cranpose_ui_graphics::Point::new(204.0, 204.0),
4318            primitives: Rc::new(Vec::new()),
4319            recording: Rc::new(cranpose_ui_graphics::CommandRecording::default()),
4320            epoch: 77,
4321            generation: RECORDING_GENERATION.with(std::cell::Cell::get),
4322        };
4323
4324        // Saved this build: not servable within the SAME build...
4325        store_saved_emission(command, Some(saved()));
4326        assert!(!saved_emission_available(command));
4327        // ...servable on the next...
4328        bump_recording_generation();
4329        assert!(saved_emission_available(command));
4330        // ...and expired one build later: only the immediately following
4331        // build may re-emit, so a served frame is never more than one
4332        // frame stale.
4333        bump_recording_generation();
4334        assert!(!saved_emission_available(command));
4335
4336        // A fresh save served on time is CONSUMED by the take: the second
4337        // of two consecutive collapse builds finds nothing to serve.
4338        store_saved_emission(command, Some(saved()));
4339        bump_recording_generation();
4340        assert!(saved_emission_available(command));
4341        assert!(take_saved_emission(command).is_some());
4342        assert!(
4343            !saved_emission_available(command),
4344            "a second serve of one emission must be unconstructible"
4345        );
4346        assert!(take_saved_emission(command).is_none());
4347
4348        // A dead slot universe invalidates the save wholesale.
4349        store_saved_emission(command, Some(saved()));
4350        bump_recording_generation();
4351        set_retained_feed_epoch(Some(78));
4352        assert!(!saved_emission_available(command));
4353        set_retained_feed_epoch(None);
4354        assert!(!saved_emission_available(command));
4355        set_retained_feed_epoch(Some(77));
4356        assert!(saved_emission_available(command));
4357        set_retained_feed_epoch(None);
4358    }
4359}