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