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            continue;
1246        }
1247        let (shared, published_recording) = publish_recording(id, recording, primitives, replay);
1248        if stale_transition {
1249            // Save this build's emission in re-emittable form — or clear a
1250            // previous save when this build emitted no replay frame, so a
1251            // command that stops emitting does not pin its last frame's
1252            // buffers. The save is what the NEXT build's collapse frame
1253            // may serve; a build where the emission was itself served
1254            // stale never reaches this point, which is the other half of
1255            // the one-frame staleness cap.
1256            let saved = frame.as_ref().and_then(|frame| {
1257                RETAINED_FEED_EPOCH
1258                    .with(std::cell::Cell::get)
1259                    .map(|epoch| SavedReplayEmission {
1260                        spans: sanitized_replay_spans(&frame.spans),
1261                        center: frame.center,
1262                        primitives: shared.clone(),
1263                        recording: published_recording.clone(),
1264                        epoch,
1265                        generation: RECORDING_GENERATION.with(std::cell::Cell::get),
1266                    })
1267            });
1268            store_saved_emission(id, saved);
1269        }
1270        if shared.is_empty() && !has_replay_spans {
1271            continue;
1272        }
1273        // The frame owns a pinned handle to the exact recording it was
1274        // built from: its bypassed spans' rematerialization source travels
1275        // WITH the frame, so rendering never has to look it up through the
1276        // sweepable ambient registry.
1277        let frame = frame.map(|mut frame| {
1278            frame.fallback = Some(published_recording);
1279            frame
1280        });
1281        // The recorded vector rides into the graph whole: a single canvas
1282        // command can carry thousands of primitives, and wrapping each in its
1283        // own node moved every one of them an extra time each frame.
1284        nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_replayed(
1285            phase,
1286            Some(id),
1287            shared,
1288            frame.map(Box::new),
1289        )));
1290    }
1291    nodes
1292}
1293
1294/// The real [`draw_nodes`] path — acquire, record, verify, publish, and
1295/// the stale-transition save/serve — exposed so integration tests can
1296/// drive a command through the production seam build by build. Each call
1297/// is one build: the recording generation advances exactly as
1298/// [`build_graph_from_layout_tree`] and friends advance it.
1299#[doc(hidden)]
1300pub fn draw_command_nodes_for_tests(
1301    node_id: NodeId,
1302    commands: &[DrawCommand],
1303    placement: DrawPlacement,
1304    size: Size,
1305    phase: PrimitivePhase,
1306) -> Vec<RenderNode> {
1307    bump_recording_generation();
1308    draw_nodes(node_id, commands, placement, size, phase)
1309}
1310
1311struct TextNodeParts<'a> {
1312    node_id: NodeId,
1313    local_bounds: Rect,
1314    measured_max_width: Option<f32>,
1315    resolved_modifiers: &'a ResolvedModifiers,
1316    annotated_text: Option<&'a AnnotatedString>,
1317    text_style: Option<&'a TextStyle>,
1318    text_layout_options: Option<TextLayoutOptions>,
1319    text_pan: Option<TextPanResolver>,
1320    modifier_slices: Option<&'a ModifierNodeSlices>,
1321}
1322
1323fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
1324    let TextNodeParts {
1325        node_id,
1326        local_bounds,
1327        measured_max_width,
1328        resolved_modifiers,
1329        annotated_text,
1330        text_style,
1331        text_layout_options,
1332        text_pan,
1333        modifier_slices,
1334    } = parts;
1335    let value = annotated_text?;
1336    let default_text_style = TextStyle::default();
1337    let text_style = text_style.cloned().unwrap_or(default_text_style);
1338    let options = text_layout_options.unwrap_or_default().normalized();
1339    let padding = resolved_modifiers.padding();
1340    let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
1341    if content_width <= 0.0 {
1342        return None;
1343    }
1344
1345    // Single-line text fields pan horizontally to keep the cursor visible:
1346    // the text is laid out unconstrained (no wrapping), shifted left by the
1347    // pan offset, and clipped to the field bounds.
1348    let pan_offset = text_pan
1349        .as_ref()
1350        .map(|resolve| resolve(content_width))
1351        .unwrap_or(0.0);
1352    let pans_horizontally = text_pan.is_some();
1353
1354    let max_width = if pans_horizontally {
1355        None
1356    } else {
1357        let measure_width =
1358            resolve_text_measure_width(content_width, padding, measured_max_width, options);
1359        Some(measure_width).filter(|width| width.is_finite() && *width > 0.0)
1360    };
1361    let prepared = modifier_slices
1362        .and_then(|slices| slices.prepare_text_layout(max_width))
1363        .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
1364    let visual_style = prepared.visual_style.clone();
1365    let measured_draw_width = prepared.metrics.width.max(0.0);
1366    let draw_width = if options.overflow == TextOverflow::Visible || pans_horizontally {
1367        measured_draw_width
1368    } else {
1369        measured_draw_width.min(content_width)
1370    };
1371    let alignment_offset = resolve_text_horizontal_offset(
1372        &text_style,
1373        prepared.text.text.as_str(),
1374        content_width,
1375        prepared.metrics.width,
1376    );
1377    let rect = Rect {
1378        x: padding.left + alignment_offset - pan_offset,
1379        y: padding.top,
1380        width: draw_width,
1381        height: prepared.metrics.height,
1382    };
1383    let text_bounds = Rect {
1384        x: padding.left,
1385        y: padding.top,
1386        width: content_width,
1387        height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
1388    };
1389    let font_size = visual_style.resolve_font_size(14.0);
1390    let expanded_bounds =
1391        expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
1392    let clip = if options.overflow == TextOverflow::Visible && !pans_horizontally {
1393        None
1394    } else {
1395        Some(pad_clip_rect(expanded_bounds))
1396    };
1397
1398    Some(TextPrimitiveNode {
1399        node_id,
1400        rect,
1401        text: std::rc::Rc::new(prepared.text),
1402        text_style: visual_style,
1403        font_size,
1404        layout_options: options,
1405        clip,
1406    })
1407}
1408
1409fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
1410    let placement = parent
1411        .map(|parent_box| Point {
1412            x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
1413            y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
1414        })
1415        .unwrap_or_default();
1416    let mut children = Vec::with_capacity(node.children.len());
1417    for child in &node.children {
1418        children.push(layout_box_to_snapshot(child, Some(node)));
1419    }
1420    let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
1421    let graphics_layer = graphics_layer_with_shaped_clip(
1422        base_graphics_layer.clone().unwrap_or_default(),
1423        node.node_data.modifier_slices.clip_to_bounds(),
1424        node.node_data.modifier_slices.corner_shape(),
1425        Rect {
1426            x: 0.0,
1427            y: 0.0,
1428            width: node.rect.width,
1429            height: node.rect.height,
1430        },
1431    );
1432    let has_graphics_layer =
1433        base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
1434
1435    BuildNodeSnapshot {
1436        node_id: node.node_id,
1437        placement,
1438        size: Size {
1439            width: node.rect.width,
1440            height: node.rect.height,
1441        },
1442        content_offset: node.content_offset,
1443        motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
1444        translated_content_context: node.node_data.modifier_slices.translated_content_context(),
1445        measured_max_width: None,
1446        resolved_modifiers: node.node_data.resolved_modifiers,
1447        draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
1448        click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
1449        pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
1450        clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
1451        annotated_text: node.node_data.modifier_slices.annotated_string(),
1452        text_style: node.node_data.modifier_slices.text_style().cloned(),
1453        text_layout_options: node.node_data.modifier_slices.text_layout_options(),
1454        text_pan: node.node_data.modifier_slices.text_pan_resolver(),
1455        graphics_layer: has_graphics_layer.then_some(graphics_layer),
1456        children,
1457    }
1458}
1459
1460fn graphics_layer_with_shaped_clip(
1461    mut graphics_layer: GraphicsLayer,
1462    clip_to_bounds: bool,
1463    corner_shape: Option<RoundedCornerShape>,
1464    local_bounds: Rect,
1465) -> GraphicsLayer {
1466    if !clip_to_bounds {
1467        return graphics_layer;
1468    }
1469
1470    let Some(corner_shape) = corner_shape else {
1471        return graphics_layer;
1472    };
1473    let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
1474    if radii.top_left <= f32::EPSILON
1475        && radii.top_right <= f32::EPSILON
1476        && radii.bottom_right <= f32::EPSILON
1477        && radii.bottom_left <= f32::EPSILON
1478    {
1479        return graphics_layer;
1480    }
1481
1482    if let Some(existing) = graphics_layer.render_effect.take() {
1483        let rounded_clip = rounded_corner_alpha_mask_effect(
1484            local_bounds.width,
1485            local_bounds.height,
1486            radii,
1487            ROUNDED_CLIP_EDGE_FEATHER,
1488        );
1489        graphics_layer.render_effect = Some(existing.then(rounded_clip));
1490    } else {
1491        graphics_layer.shape = LayerShape::Rounded(corner_shape);
1492        graphics_layer.clip = true;
1493    }
1494    graphics_layer
1495}
1496
1497fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
1498    IsolationReasons {
1499        explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
1500        shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
1501        effect: layer.render_effect.is_some(),
1502        backdrop: layer.backdrop_effect.is_some(),
1503        group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
1504            && layer.alpha < 1.0,
1505        blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
1506    }
1507}
1508
1509fn pad_clip_rect(rect: Rect) -> Rect {
1510    Rect {
1511        x: rect.x - TEXT_CLIP_PAD,
1512        y: rect.y - TEXT_CLIP_PAD,
1513        width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
1514        height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
1515    }
1516}
1517
1518fn expand_text_bounds_for_baseline_shift(
1519    text_bounds: Rect,
1520    text_style: &TextStyle,
1521    font_size: f32,
1522) -> Rect {
1523    let baseline_shift_px = text_style
1524        .span_style
1525        .baseline_shift
1526        .filter(|shift| shift.is_specified())
1527        .map(|shift| -(shift.0 * font_size))
1528        .unwrap_or(0.0);
1529    if baseline_shift_px == 0.0 {
1530        return text_bounds;
1531    }
1532
1533    if baseline_shift_px < 0.0 {
1534        Rect {
1535            x: text_bounds.x,
1536            y: text_bounds.y + baseline_shift_px,
1537            width: text_bounds.width,
1538            height: (text_bounds.height - baseline_shift_px).max(0.0),
1539        }
1540    } else {
1541        Rect {
1542            x: text_bounds.x,
1543            y: text_bounds.y,
1544            width: text_bounds.width,
1545            height: (text_bounds.height + baseline_shift_px).max(0.0),
1546        }
1547    }
1548}
1549
1550/// The width the paint pass must lay this paragraph out at.
1551///
1552/// **It is the width LAYOUT wrapped at, not the width the node ended up.** A
1553/// `Text` without `fill_max_width` is placed at its own `metrics.width` — the
1554/// widest line it produced — which is by construction NARROWER than the
1555/// constraint it wrapped under. Re-wrapping at that narrower width is not the
1556/// no-op it looks like: the widest line is the one that exactly fills the
1557/// limit, so measuring it against itself puts its last word over the edge and
1558/// the paragraph gains a line. Measured against the real font backend
1559/// (`SoftwareTextMeasurer`, the one the wgpu renderer installs), that fires on
1560/// 46% of multi-line paragraphs — the block then paints a line taller than the
1561/// box layout reserved for it, its last line is clipped away, and every
1562/// following sibling has been placed as if that line did not exist.
1563///
1564/// So an unlimited soft-wrapping clip paragraph keeps the measurement width
1565/// even when the node came out narrower — `may_expand_to_avoid_synthetic_wrap`.
1566/// The modes that deliberately re-fit (no soft wrap, a finite `max_lines`, or
1567/// an ellipsis budget) still take the node's own width, because for those the
1568/// node width IS the fitting constraint.
1569///
1570/// This is the shared implementation. It exists because the wgpu and pixels
1571/// pipelines each grew a private copy WITH this rule and its contract tests,
1572/// while the scene builder — the copy that the retained render graph actually
1573/// runs — kept a plain `available.min(content_width)`. The two private copies
1574/// were reachable only from their own tests. One function now, so the tests
1575/// guard the code that runs.
1576pub fn resolve_text_measure_width(
1577    content_width: f32,
1578    padding: cranpose_ui::EdgeInsets,
1579    measured_max_width: Option<f32>,
1580    options: TextLayoutOptions,
1581) -> f32 {
1582    let width = content_width.max(0.0);
1583    if let Some(max_width) = measured_max_width.filter(|w| w.is_finite() && *w > 0.0) {
1584        let measured_content_width = (max_width - padding.left - padding.right).max(0.0);
1585        if measured_content_width <= width {
1586            return measured_content_width;
1587        }
1588
1589        let may_expand_to_avoid_synthetic_wrap = options.soft_wrap
1590            && options.max_lines == usize::MAX
1591            && options.overflow == TextOverflow::Clip;
1592        if may_expand_to_avoid_synthetic_wrap {
1593            return measured_content_width;
1594        }
1595    }
1596    width
1597}
1598
1599/// How much of the slack a `TextAlign` puts *before* the text: 0 at the start
1600/// edge, 0.5 centred, 1 at the end edge.
1601///
1602/// Split out because the same fraction has to be applied twice and by two
1603/// different pieces of code. Compose aligns a paragraph **line by line** —
1604/// `TextAlign.Center` centres each line in the paragraph's width, it does not
1605/// centre the paragraph's box in its parent — so the block offset computed
1606/// here and the per-line offset the rasteriser applies inside the block are
1607/// two halves of one rule. They telescope: block at `(box - block) * f`, line
1608/// at `(block - line) * f`, which sums to `(box - line) * f`, exactly the
1609/// offset Compose gives that line. Getting one without the other leaves every
1610/// wrapped continuation line start-aligned under a centred first line.
1611pub fn text_align_fraction(text_style: &TextStyle, text: &str) -> f32 {
1612    let paragraph_style = &text_style.paragraph_style;
1613    let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
1614    let rtl = direction == cranpose_ui::text::ResolvedTextDirection::Rtl;
1615    match paragraph_style.text_align {
1616        TextAlign::Center => 0.5,
1617        TextAlign::End | TextAlign::Right => 1.0,
1618        // `Left` follows the direction here rather than being absolute. That
1619        // is not what Compose means by `TextAlign.Left`, but it is what this
1620        // function has always done and no Wear screen is RTL; changing it is a
1621        // separate question from where a wrapped line starts.
1622        TextAlign::Start | TextAlign::Left | TextAlign::Justify | TextAlign::Unspecified => {
1623            if rtl {
1624                1.0
1625            } else {
1626                0.0
1627            }
1628        }
1629    }
1630}
1631
1632fn resolve_text_horizontal_offset(
1633    text_style: &TextStyle,
1634    text: &str,
1635    content_width: f32,
1636    measured_width: f32,
1637) -> f32 {
1638    let remaining = (content_width - measured_width).max(0.0);
1639    remaining * text_align_fraction(text_style, text)
1640}
1641
1642#[cfg(test)]
1643mod tests {
1644    use std::cell::RefCell;
1645    use std::rc::Rc;
1646
1647    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1648    use cranpose_ui::text::{
1649        AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
1650    };
1651    use cranpose_ui::{
1652        Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
1653        LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
1654        ScrollState, Size, Spacer, Text, TextStyle,
1655    };
1656    use cranpose_ui_graphics::{
1657        Brush, DrawPrimitive, DrawScope as _, DrawScopeDefault, GraphicsLayer, RenderEffect,
1658    };
1659
1660    use super::*;
1661
1662    fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
1663        for child in &layer.children {
1664            match child {
1665                RenderNode::Primitive(primitive) => {
1666                    let PrimitiveNode::Text(text) = &primitive.node else {
1667                        continue;
1668                    };
1669                    if text.text.text == label {
1670                        return Some(text.text_style.paragraph_style.text_motion);
1671                    }
1672                }
1673                RenderNode::Layer(child_layer) => {
1674                    if let Some(motion) = find_text_motion(child_layer, label) {
1675                        return Some(motion);
1676                    }
1677                }
1678                RenderNode::DrawRun(_) => {}
1679            }
1680        }
1681
1682        None
1683    }
1684
1685    fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
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                    labels.push(text.text.text.clone());
1693                }
1694                RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
1695                RenderNode::DrawRun(_) => {}
1696            }
1697        }
1698    }
1699
1700    fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
1701        fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
1702            for child in &layer.children {
1703                match child {
1704                    RenderNode::Primitive(primitive) => {
1705                        let PrimitiveNode::Text(text) = &primitive.node else {
1706                            continue;
1707                        };
1708                        if text.text.text == label {
1709                            let quad = transform.map_rect(text.rect);
1710                            let top = quad
1711                                .iter()
1712                                .map(|point| point[1])
1713                                .fold(f32::INFINITY, f32::min);
1714                            return top.is_finite().then_some(top);
1715                        }
1716                    }
1717                    RenderNode::Layer(child_layer) => {
1718                        let child_transform = child_layer.transform_to_parent.then(transform);
1719                        if let Some(top) = search(child_layer, label, child_transform) {
1720                            return Some(top);
1721                        }
1722                    }
1723                    RenderNode::DrawRun(_) => {}
1724                }
1725            }
1726            None
1727        }
1728
1729        search(layer, label, ProjectiveTransform::identity())
1730    }
1731
1732    fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
1733        if layer.node_id == Some(node_id) {
1734            return Some(layer);
1735        }
1736        layer.children.iter().find_map(|child| match child {
1737            RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
1738            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1739        })
1740    }
1741
1742    fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
1743        fn search(
1744            layer: &LayerNode,
1745            node_id: NodeId,
1746            transform: ProjectiveTransform,
1747        ) -> Option<Point> {
1748            if layer.node_id == Some(node_id) {
1749                return Some(transform.map_point(Point::default()));
1750            }
1751            layer.children.iter().find_map(|child| match child {
1752                RenderNode::Layer(child_layer) => search(
1753                    child_layer,
1754                    node_id,
1755                    child_layer.transform_to_parent.then(transform),
1756                ),
1757                RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1758            })
1759        }
1760
1761        search(layer, node_id, ProjectiveTransform::identity())
1762    }
1763
1764    fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1765        if layer.translated_content_context {
1766            return Some(layer.translated_content_offset);
1767        }
1768        for child in &layer.children {
1769            if let RenderNode::Layer(child_layer) = child {
1770                if let Some(offset) = find_translated_content_offset(child_layer) {
1771                    return Some(offset);
1772                }
1773            }
1774        }
1775        None
1776    }
1777
1778    fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1779        layer
1780            .graphics_layer
1781            .render_effect
1782            .as_ref()
1783            .is_some_and(RenderEffect::contains_runtime_shader)
1784            || layer.children.iter().any(|child| match child {
1785                RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1786                RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1787            })
1788    }
1789
1790    fn build_layer_node_for_test(
1791        snapshot: BuildNodeSnapshot,
1792        scale: f32,
1793        has_external_backdrop_input: bool,
1794    ) -> LayerNode {
1795        let app_context = cranpose_ui::AppContext::new();
1796        app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1797    }
1798
1799    fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1800        let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1801            scope.push_recorded(vec![DrawPrimitive::Rect {
1802                rect: Rect {
1803                    x: 3.0,
1804                    y: 4.0,
1805                    width: 20.0,
1806                    height: 8.0,
1807                },
1808                brush: Brush::solid(Color::WHITE),
1809                stroke: None,
1810            }]);
1811        }));
1812
1813        let child = BuildNodeSnapshot {
1814            node_id: 2,
1815            placement: Point { x: 11.0, y: 7.0 },
1816            size: Size {
1817                width: 40.0,
1818                height: 20.0,
1819            },
1820            content_offset: Point::default(),
1821            motion_context_animated: false,
1822            translated_content_context: false,
1823            measured_max_width: None,
1824            resolved_modifiers: ResolvedModifiers::default(),
1825            draw_commands: vec![child_command],
1826            click_actions: vec![],
1827            pointer_inputs: vec![],
1828            clip_to_bounds: false,
1829            annotated_text: None,
1830            text_style: None,
1831            text_layout_options: None,
1832            text_pan: None,
1833            graphics_layer: None,
1834            children: vec![],
1835        };
1836
1837        BuildNodeSnapshot {
1838            node_id: 1,
1839            placement: Point::default(),
1840            size: Size {
1841                width: 80.0,
1842                height: 50.0,
1843            },
1844            content_offset: Point::default(),
1845            motion_context_animated: false,
1846            translated_content_context: false,
1847            measured_max_width: None,
1848            resolved_modifiers: ResolvedModifiers::default(),
1849            draw_commands: vec![],
1850            click_actions: vec![],
1851            pointer_inputs: vec![],
1852            clip_to_bounds: false,
1853            annotated_text: None,
1854            text_style: None,
1855            text_layout_options: None,
1856            text_pan: None,
1857            graphics_layer: Some(GraphicsLayer {
1858                translation_x: tx,
1859                ..GraphicsLayer::default()
1860            }),
1861            children: vec![child],
1862        }
1863    }
1864
1865    #[test]
1866    fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1867        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1868        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1869
1870        let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1871            panic!("expected child layer");
1872        };
1873        let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1874            panic!("expected child layer");
1875        };
1876        let RenderNode::DrawRun(static_run) = &static_child.children[0] else {
1877            panic!("expected draw run");
1878        };
1879        let static_draw = &static_run.primitives[0];
1880        let RenderNode::DrawRun(moved_run) = &moved_child.children[0] else {
1881            panic!("expected draw run");
1882        };
1883        let moved_draw = &moved_run.primitives[0];
1884
1885        assert_ne!(
1886            static_graph.transform_to_parent, moved_graph.transform_to_parent,
1887            "parent transform should encode translation"
1888        );
1889        assert_eq!(
1890            static_draw, moved_draw,
1891            "child local primitive geometry must stay stable under parent translation"
1892        );
1893    }
1894
1895    #[test]
1896    fn stored_content_hash_ignores_parent_translation() {
1897        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1898        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1899
1900        assert_eq!(
1901            static_graph.target_content_hash(),
1902            moved_graph.target_content_hash(),
1903            "parent rigid motion must not invalidate the subtree content hash"
1904        );
1905    }
1906
1907    #[test]
1908    fn parent_content_offset_is_encoded_in_child_transform() {
1909        let child = BuildNodeSnapshot {
1910            node_id: 2,
1911            placement: Point { x: 11.0, y: 7.0 },
1912            size: Size {
1913                width: 40.0,
1914                height: 20.0,
1915            },
1916            content_offset: Point::default(),
1917            motion_context_animated: false,
1918            translated_content_context: false,
1919            measured_max_width: None,
1920            resolved_modifiers: ResolvedModifiers::default(),
1921            draw_commands: vec![],
1922            click_actions: vec![],
1923            pointer_inputs: vec![],
1924            clip_to_bounds: false,
1925            annotated_text: None,
1926            text_style: None,
1927            text_layout_options: None,
1928            text_pan: None,
1929            graphics_layer: None,
1930            children: vec![],
1931        };
1932
1933        let parent = BuildNodeSnapshot {
1934            node_id: 1,
1935            placement: Point::default(),
1936            size: Size {
1937                width: 80.0,
1938                height: 50.0,
1939            },
1940            content_offset: Point { x: 13.0, y: -9.0 },
1941            motion_context_animated: false,
1942            translated_content_context: false,
1943            measured_max_width: None,
1944            resolved_modifiers: ResolvedModifiers::default(),
1945            draw_commands: vec![],
1946            click_actions: vec![],
1947            pointer_inputs: vec![],
1948            clip_to_bounds: false,
1949            annotated_text: None,
1950            text_style: None,
1951            text_layout_options: None,
1952            text_pan: None,
1953            graphics_layer: None,
1954            children: vec![child],
1955        };
1956
1957        let graph = build_layer_node_for_test(parent, 1.0, false);
1958        let RenderNode::Layer(child) = &graph.children[0] else {
1959            panic!("expected child layer");
1960        };
1961
1962        let top_left = child.transform_to_parent.map_point(Point::default());
1963        assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1964    }
1965
1966    #[test]
1967    fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1968        fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1969            let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1970                scope.push_recorded(vec![DrawPrimitive::Rect {
1971                    rect: Rect {
1972                        x: 3.0,
1973                        y: 4.0,
1974                        width: 20.0,
1975                        height: 8.0,
1976                    },
1977                    brush: Brush::solid(Color::WHITE),
1978                    stroke: None,
1979                }]);
1980            }));
1981
1982            let child = BuildNodeSnapshot {
1983                node_id: 2,
1984                placement: Point { x: 11.0, y: 7.0 },
1985                size: Size {
1986                    width: 40.0,
1987                    height: 20.0,
1988                },
1989                content_offset: Point::default(),
1990                motion_context_animated: false,
1991                translated_content_context: false,
1992                measured_max_width: None,
1993                resolved_modifiers: ResolvedModifiers::default(),
1994                draw_commands: vec![child_command],
1995                click_actions: vec![],
1996                pointer_inputs: vec![],
1997                clip_to_bounds: false,
1998                annotated_text: None,
1999                text_style: None,
2000                text_layout_options: None,
2001                text_pan: None,
2002                graphics_layer: None,
2003                children: vec![],
2004            };
2005
2006            BuildNodeSnapshot {
2007                node_id: 1,
2008                placement: Point::default(),
2009                size: Size {
2010                    width: 80.0,
2011                    height: 50.0,
2012                },
2013                content_offset: offset,
2014                motion_context_animated,
2015                translated_content_context: true,
2016                measured_max_width: None,
2017                resolved_modifiers: ResolvedModifiers::default(),
2018                draw_commands: vec![],
2019                click_actions: vec![],
2020                pointer_inputs: vec![],
2021                clip_to_bounds: false,
2022                annotated_text: None,
2023                text_style: None,
2024                text_layout_options: None,
2025                text_pan: None,
2026                graphics_layer: None,
2027                children: vec![child],
2028            }
2029        }
2030
2031        let base = build_layer_node_for_test(
2032            parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
2033            1.0,
2034            false,
2035        );
2036        let moved = build_layer_node_for_test(
2037            parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
2038            1.0,
2039            false,
2040        );
2041        let rested = build_layer_node_for_test(
2042            parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
2043            1.0,
2044            false,
2045        );
2046
2047        let RenderNode::Layer(base_child) = &base.children[0] else {
2048            panic!("expected child layer");
2049        };
2050        let RenderNode::Layer(moved_child) = &moved.children[0] else {
2051            panic!("expected child layer");
2052        };
2053
2054        assert_ne!(
2055            base_child.transform_to_parent.map_point(Point::default()),
2056            moved_child.transform_to_parent.map_point(Point::default()),
2057            "scroll offset still has to move child content visually"
2058        );
2059        assert_eq!(
2060            base_child.target_content_hash(),
2061            moved_child.target_content_hash(),
2062            "child source content identity stays stable when only the parent scroll offset changes"
2063        );
2064        assert_ne!(
2065            base.target_content_hash(),
2066            moved.target_content_hash(),
2067            "a full-surface cache of the scroll viewport must include the scroll offset"
2068        );
2069        assert_ne!(
2070            base.target_content_hash(),
2071            rested.target_content_hash(),
2072            "full-surface cache keys must include active scroll motion policy"
2073        );
2074    }
2075
2076    #[test]
2077    fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
2078        let layer = graphics_layer_with_shaped_clip(
2079            GraphicsLayer::default(),
2080            true,
2081            Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
2082            Rect {
2083                x: 0.0,
2084                y: 0.0,
2085                width: 100.0,
2086                height: 40.0,
2087            },
2088        );
2089
2090        assert!(layer.clip);
2091        assert!(layer.render_effect.is_none());
2092        let LayerShape::Rounded(shape) = layer.shape else {
2093            panic!("rounded clip must be recorded as layer shape");
2094        };
2095        assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
2096        assert!(isolation_reasons(&layer).shape_clip);
2097    }
2098
2099    #[test]
2100    fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
2101        let existing = RenderEffect::blur(3.0);
2102        let layer = graphics_layer_with_shaped_clip(
2103            GraphicsLayer {
2104                render_effect: Some(existing.clone()),
2105                ..GraphicsLayer::default()
2106            },
2107            true,
2108            Some(RoundedCornerShape::uniform(10.0)),
2109            Rect {
2110                x: 0.0,
2111                y: 0.0,
2112                width: 100.0,
2113                height: 40.0,
2114            },
2115        );
2116
2117        let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
2118            panic!("existing effect should chain into rounded clip mask");
2119        };
2120        assert_eq!(*first, existing);
2121        assert!(
2122            matches!(*second, RenderEffect::Shader { .. }),
2123            "rounded mask must be the outer effect"
2124        );
2125    }
2126
2127    #[test]
2128    fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
2129        let mut composition = cranpose_ui::run_test_composition(|| {
2130            cranpose_ui::Box(
2131                Modifier::empty()
2132                    .width(100.0)
2133                    .height(40.0)
2134                    .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
2135                    .clip_to_bounds(),
2136                cranpose_ui::BoxSpec::default(),
2137                || {
2138                    Text("rounded child", Modifier::empty(), TextStyle::default());
2139                },
2140            );
2141        });
2142
2143        let root = composition.root().expect("rounded clip root");
2144        let handle = composition.runtime_handle();
2145        let mut applier = composition.applier_mut();
2146        applier.set_runtime_handle(handle);
2147        applier
2148            .compute_layout(
2149                root,
2150                Size {
2151                    width: 160.0,
2152                    height: 100.0,
2153                },
2154            )
2155            .expect("rounded clip layout");
2156        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
2157        applier.clear_runtime_handle();
2158
2159        let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
2160        assert!(rounded_layer.graphics_layer.clip);
2161        assert!(matches!(
2162            rounded_layer.graphics_layer.shape,
2163            LayerShape::Rounded(_)
2164        ));
2165        assert!(rounded_layer.graphics_layer.render_effect.is_none());
2166        assert!(rounded_layer.isolation.shape_clip);
2167        assert!(
2168            !graph_has_runtime_shader_effect(&graph.root),
2169            "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
2170        );
2171    }
2172
2173    #[test]
2174    fn update_graph_from_applier_replaces_dirty_child_layer() {
2175        let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2176            Rc::new(RefCell::new(None));
2177        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2178        let state_holder_for_comp = state_holder.clone();
2179        let child_id_holder_for_comp = child_id_holder.clone();
2180
2181        let mut composition = cranpose_ui::run_test_composition(move || {
2182            let label = cranpose_core::useState(|| "before".to_string());
2183            *state_holder_for_comp.borrow_mut() = Some(label);
2184            let child_id_holder_for_content = child_id_holder_for_comp.clone();
2185            cranpose_ui::Box(
2186                Modifier::empty().size_points(240.0, 80.0),
2187                cranpose_ui::BoxSpec::default(),
2188                move || {
2189                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
2190                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
2191                    Text("stable", Modifier::empty(), TextStyle::default());
2192                },
2193            );
2194        });
2195
2196        let root = composition.root().expect("composition root");
2197        let viewport = Size {
2198            width: 240.0,
2199            height: 80.0,
2200        };
2201        let handle = composition.runtime_handle();
2202        let mut applier = composition.applier_mut();
2203        applier.set_runtime_handle(handle);
2204        applier
2205            .compute_layout(root, viewport)
2206            .expect("initial layout");
2207        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2208        let child_id = child_id_holder
2209            .borrow()
2210            .expect("text child id should be captured");
2211        let initial_transform = find_layer_by_node_id(&graph.root, child_id)
2212            .expect("text child layer")
2213            .transform_to_parent;
2214        applier.clear_runtime_handle();
2215        drop(applier);
2216
2217        let label = state_holder
2218            .borrow()
2219            .as_ref()
2220            .copied()
2221            .expect("label state should be captured");
2222        label.set_value("after".to_string());
2223        composition
2224            .process_invalid_scopes()
2225            .expect("text recomposition");
2226
2227        let handle = composition.runtime_handle();
2228        let mut applier = composition.applier_mut();
2229        applier.set_runtime_handle(handle);
2230        applier
2231            .compute_layout(root, viewport)
2232            .expect("updated layout");
2233        let child_id = child_id_holder
2234            .borrow()
2235            .expect("text child id should remain captured");
2236
2237        assert!(
2238            update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
2239            "dirty child should be replaceable from retained applier state"
2240        );
2241        applier.clear_runtime_handle();
2242
2243        let mut labels = Vec::new();
2244        collect_text_labels(&graph.root, &mut labels);
2245        assert!(
2246            labels.iter().any(|label| label == "after"),
2247            "updated graph should contain refreshed child text, got {labels:?}"
2248        );
2249        assert!(
2250            !labels.iter().any(|label| label == "before"),
2251            "updated graph should not retain stale child text, got {labels:?}"
2252        );
2253        assert!(
2254            labels.iter().any(|label| label == "stable"),
2255            "sibling content should remain present, got {labels:?}"
2256        );
2257        assert_eq!(
2258            find_layer_by_node_id(&graph.root, child_id)
2259                .expect("updated text child layer")
2260                .transform_to_parent,
2261            initial_transform,
2262            "draw-only child replacement must preserve the retained parent placement transform"
2263        );
2264    }
2265
2266    fn assert_same_cache_hash_state(dirty_road: &LayerNode, full_road: &LayerNode, path: &str) {
2267        assert_eq!(
2268            dirty_road.node_id, full_road.node_id,
2269            "tree shape must match at {path}"
2270        );
2271        assert_eq!(
2272            dirty_road.cache_hashes_valid, full_road.cache_hashes_valid,
2273            "hash validity at {path} (node {:?})",
2274            dirty_road.node_id
2275        );
2276        if full_road.cache_hashes_valid {
2277            assert_eq!(
2278                dirty_road.cache_hashes, full_road.cache_hashes,
2279                "stored hashes at {path} (node {:?})",
2280                dirty_road.node_id
2281            );
2282        }
2283        assert_eq!(
2284            dirty_road.target_content_hash(),
2285            full_road.target_content_hash(),
2286            "target content hash at {path} (node {:?})",
2287            dirty_road.node_id
2288        );
2289        assert_eq!(
2290            dirty_road.children.len(),
2291            full_road.children.len(),
2292            "child count at {path}"
2293        );
2294        for (index, (dirty_child, full_child)) in dirty_road
2295            .children
2296            .iter()
2297            .zip(full_road.children.iter())
2298            .enumerate()
2299        {
2300            if let (RenderNode::Layer(dirty_child), RenderNode::Layer(full_child)) =
2301                (dirty_child, full_child)
2302            {
2303                assert_same_cache_hash_state(dirty_child, full_child, &format!("{path}/{index}"));
2304            }
2305        }
2306    }
2307
2308    fn assert_dirty_hash_road_matches_full_walk(graph: &RenderGraph) {
2309        let mut full_road = graph.root.clone();
2310        full_road.recompute_raster_cache_hashes();
2311        assert_same_cache_hash_state(&graph.root, &full_road, "root");
2312    }
2313
2314    #[test]
2315    fn dirty_update_leaves_the_hashes_a_full_walk_leaves() {
2316        let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2317            Rc::new(RefCell::new(None));
2318        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2319        let label_holder_for_comp = label_holder.clone();
2320        let child_id_holder_for_comp = child_id_holder.clone();
2321
2322        let mut composition = cranpose_ui::run_test_composition(move || {
2323            let label = cranpose_core::useState(|| "before".to_string());
2324            *label_holder_for_comp.borrow_mut() = Some(label);
2325            let child_id_holder_for_content = child_id_holder_for_comp.clone();
2326            Column(
2327                Modifier::empty().size_points(240.0, 200.0),
2328                ColumnSpec::default(),
2329                move || {
2330                    cranpose_ui::Box(
2331                        Modifier::empty()
2332                            .size_points(240.0, 80.0)
2333                            .graphics_layer(|| GraphicsLayer {
2334                                alpha: 0.5,
2335                                ..GraphicsLayer::default()
2336                            }),
2337                        cranpose_ui::BoxSpec::default(),
2338                        {
2339                            let child_id_holder_for_box = child_id_holder_for_content.clone();
2340                            move || {
2341                                let child_id = Text(label, Modifier::empty(), TextStyle::default());
2342                                *child_id_holder_for_box.borrow_mut() = Some(child_id);
2343                            }
2344                        },
2345                    );
2346                    cranpose_ui::Box(
2347                        Modifier::empty()
2348                            .size_points(240.0, 80.0)
2349                            .graphics_layer(|| GraphicsLayer {
2350                                alpha: 0.75,
2351                                ..GraphicsLayer::default()
2352                            }),
2353                        cranpose_ui::BoxSpec::default(),
2354                        || {
2355                            Text("stable", Modifier::empty(), TextStyle::default());
2356                        },
2357                    );
2358                },
2359            );
2360        });
2361
2362        let root = composition.root().expect("composition root");
2363        let viewport = Size {
2364            width: 240.0,
2365            height: 200.0,
2366        };
2367        let handle = composition.runtime_handle();
2368        let mut applier = composition.applier_mut();
2369        applier.set_runtime_handle(handle);
2370        applier
2371            .compute_layout(root, viewport)
2372            .expect("initial layout");
2373        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2374        graph.root.recompute_raster_cache_hashes();
2375        applier.clear_runtime_handle();
2376        drop(applier);
2377        assert_dirty_hash_road_matches_full_walk(&graph);
2378
2379        let label = label_holder
2380            .borrow()
2381            .as_ref()
2382            .copied()
2383            .expect("label state should be captured");
2384        label.set_value("after".to_string());
2385        composition
2386            .process_invalid_scopes()
2387            .expect("text recomposition");
2388
2389        let handle = composition.runtime_handle();
2390        let mut applier = composition.applier_mut();
2391        applier.set_runtime_handle(handle);
2392        applier
2393            .compute_layout(root, viewport)
2394            .expect("updated layout");
2395        let child_id = child_id_holder
2396            .borrow()
2397            .expect("text child id should be captured");
2398        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
2399        applier.clear_runtime_handle();
2400
2401        assert!(report.applied, "dirty child update should apply in place");
2402        assert_dirty_hash_road_matches_full_walk(&graph);
2403    }
2404
2405    #[test]
2406    fn dirty_update_with_a_new_row_leaves_the_hashes_a_full_walk_leaves() {
2407        let rows_holder: Rc<RefCell<Option<cranpose_core::MutableState<usize>>>> =
2408            Rc::new(RefCell::new(None));
2409        let column_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2410        let rows_holder_for_comp = rows_holder.clone();
2411        let column_id_holder_for_comp = column_id_holder.clone();
2412
2413        let mut composition = cranpose_ui::run_test_composition(move || {
2414            let rows = cranpose_core::useState(|| 2usize);
2415            *rows_holder_for_comp.borrow_mut() = Some(rows);
2416            let column_id_holder_for_content = column_id_holder_for_comp.clone();
2417            cranpose_ui::Box(
2418                Modifier::empty()
2419                    .size_points(240.0, 240.0)
2420                    .graphics_layer(|| GraphicsLayer {
2421                        alpha: 0.5,
2422                        ..GraphicsLayer::default()
2423                    }),
2424                cranpose_ui::BoxSpec::default(),
2425                move || {
2426                    let column_id = Column(
2427                        Modifier::empty().size_points(240.0, 240.0),
2428                        ColumnSpec::default(),
2429                        move || {
2430                            for index in 0..rows.get() {
2431                                Text(
2432                                    format!("row {index}"),
2433                                    Modifier::empty(),
2434                                    TextStyle::default(),
2435                                );
2436                            }
2437                        },
2438                    );
2439                    *column_id_holder_for_content.borrow_mut() = Some(column_id);
2440                },
2441            );
2442        });
2443
2444        let root = composition.root().expect("composition root");
2445        let viewport = Size {
2446            width: 240.0,
2447            height: 240.0,
2448        };
2449        let handle = composition.runtime_handle();
2450        let mut applier = composition.applier_mut();
2451        applier.set_runtime_handle(handle);
2452        applier
2453            .compute_layout(root, viewport)
2454            .expect("initial layout");
2455        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2456        graph.root.recompute_raster_cache_hashes();
2457        applier.clear_runtime_handle();
2458        drop(applier);
2459
2460        let rows = rows_holder
2461            .borrow()
2462            .as_ref()
2463            .copied()
2464            .expect("row count state should be captured");
2465        rows.set_value(3);
2466        composition
2467            .process_invalid_scopes()
2468            .expect("row recomposition");
2469
2470        let handle = composition.runtime_handle();
2471        let mut applier = composition.applier_mut();
2472        applier.set_runtime_handle(handle);
2473        applier
2474            .compute_layout(root, viewport)
2475            .expect("updated layout");
2476        let column_id = column_id_holder.borrow().expect("column id");
2477        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[column_id], 1.0);
2478        applier.clear_runtime_handle();
2479
2480        assert!(report.applied, "structural update should apply in place");
2481        let mut labels = Vec::new();
2482        collect_text_labels(&graph.root, &mut labels);
2483        assert!(
2484            labels.iter().any(|label| label == "row 2"),
2485            "the new row must be in the patched graph, got {labels:?}"
2486        );
2487        assert_dirty_hash_road_matches_full_walk(&graph);
2488    }
2489
2490    /// The per-frame scene build must publish a node's LIVE composited window
2491    /// rect into its `report_window_rect` sink — even when the layout tree is
2492    /// NOT built (`build_layout_tree: false`, exactly how the app runtime
2493    /// measures). This is the mechanism both bug 2 (a scroll container's
2494    /// `BringIntoViewResponder` viewport rect) and bug 3 (a text field's live
2495    /// `node_origin`, which anchors the overlay selection-handle / menu popups)
2496    /// rely on, since the layout `place` pass never runs in the runtime.
2497    #[test]
2498    fn scene_build_publishes_live_window_rect_without_layout_tree() {
2499        use cranpose_ui::{measure_layout_with_options, Box, BoxSpec, MeasureLayoutOptions};
2500        use std::cell::Cell;
2501
2502        let spacer_before = 120.0_f32;
2503        let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
2504            x: 0.0,
2505            y: 0.0,
2506            width: 0.0,
2507            height: 0.0,
2508        }));
2509        let sink_for_comp = sink.clone();
2510        let mut composition = cranpose_ui::run_test_composition(move || {
2511            let sink = sink_for_comp.clone();
2512            Column(
2513                Modifier::empty().size_points(200.0, 400.0),
2514                ColumnSpec::default(),
2515                move || {
2516                    Spacer(Size {
2517                        width: 200.0,
2518                        height: spacer_before,
2519                    });
2520                    Box(
2521                        Modifier::empty()
2522                            .size_points(200.0, 50.0)
2523                            .report_window_rect(sink.clone()),
2524                        BoxSpec::default(),
2525                        || {},
2526                    );
2527                },
2528            );
2529        });
2530
2531        let root = composition.root().expect("composition root");
2532        let viewport = Size {
2533            width: 200.0,
2534            height: 400.0,
2535        };
2536        let handle = composition.runtime_handle();
2537        let mut applier = composition.applier_mut();
2538        applier.set_runtime_handle(handle);
2539        // Measure like the runtime: DO NOT build the layout tree, so the layout
2540        // `place` pass never writes the sink. Only the scene build can.
2541        measure_layout_with_options(
2542            &mut applier,
2543            root,
2544            viewport,
2545            MeasureLayoutOptions {
2546                collect_semantics: false,
2547                build_layout_tree: false,
2548            },
2549        )
2550        .expect("layout");
2551        // Sanity: nothing has written the sink yet.
2552        assert_eq!(
2553            sink.get().height,
2554            0.0,
2555            "sink must start empty (place disabled)"
2556        );
2557
2558        let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
2559        applier.clear_runtime_handle();
2560
2561        let rect = sink.get();
2562        assert!(
2563            (rect.y - spacer_before).abs() < 0.5,
2564            "scene build must publish the box's live window-y (below the {spacer_before}px \
2565             spacer), got {}",
2566            rect.y
2567        );
2568        assert!(
2569            rect.width > 0.0 && rect.height > 0.0,
2570            "scene build must publish a non-empty window rect, got {rect:?}"
2571        );
2572    }
2573
2574    #[test]
2575    fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
2576        let mut graph = RenderGraph {
2577            root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
2578        };
2579        let mut applier = MemoryApplier::new();
2580
2581        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
2582
2583        assert_eq!(
2584            report,
2585            GraphUpdateReport {
2586                applied: false,
2587                hit_graph_dirty: true,
2588            },
2589            "dirty child graph updates must not report success when the replacement cannot be rebuilt"
2590        );
2591    }
2592
2593    #[test]
2594    fn scrolled_list_under_a_composited_layer_keeps_the_hashes_a_full_walk_leaves() {
2595        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2596        let scroll_holder_for_comp = scroll_holder.clone();
2597
2598        let mut composition = cranpose_ui::run_test_composition(move || {
2599            let scroll_state =
2600                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2601            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2602            cranpose_ui::Box(
2603                Modifier::empty()
2604                    .size_points(240.0, 320.0)
2605                    .graphics_layer(|| GraphicsLayer {
2606                        alpha: 0.6,
2607                        ..GraphicsLayer::default()
2608                    }),
2609                cranpose_ui::BoxSpec::default(),
2610                move || {
2611                    Column(
2612                        Modifier::empty()
2613                            .size_points(240.0, 320.0)
2614                            .vertical_scroll(scroll_state.clone(), false),
2615                        ColumnSpec::default(),
2616                        || {
2617                            for index in 0..12usize {
2618                                cranpose_ui::Box(
2619                                    Modifier::empty().size_points(240.0, 60.0).graphics_layer(
2620                                        || GraphicsLayer {
2621                                            alpha: 0.8,
2622                                            ..GraphicsLayer::default()
2623                                        },
2624                                    ),
2625                                    cranpose_ui::BoxSpec::default(),
2626                                    move || {
2627                                        Text(
2628                                            format!("row {index}"),
2629                                            Modifier::empty(),
2630                                            TextStyle::default(),
2631                                        );
2632                                    },
2633                                );
2634                            }
2635                        },
2636                    );
2637                },
2638            );
2639        });
2640
2641        let root = composition.root().expect("composition root");
2642        let viewport = Size {
2643            width: 240.0,
2644            height: 320.0,
2645        };
2646        let handle = composition.runtime_handle();
2647        let mut applier = composition.applier_mut();
2648        applier.set_runtime_handle(handle);
2649        applier
2650            .compute_layout(root, viewport)
2651            .expect("initial scroll layout");
2652        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2653        graph.root.recompute_raster_cache_hashes();
2654        applier.clear_runtime_handle();
2655        drop(applier);
2656
2657        let scroll_state = scroll_holder
2658            .borrow()
2659            .as_ref()
2660            .cloned()
2661            .expect("scroll state should be captured");
2662        assert!(
2663            scroll_state.dispatch_raw_delta(96.0) > 0.0,
2664            "test scroll must be consumed"
2665        );
2666        let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
2667        assert!(
2668            !dirty_nodes.is_empty(),
2669            "a scroll must schedule a scoped scene update"
2670        );
2671
2672        let handle = composition.runtime_handle();
2673        let mut applier = composition.applier_mut();
2674        applier.set_runtime_handle(handle);
2675        applier
2676            .compute_layout(root, viewport)
2677            .expect("scrolled layout");
2678        let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
2679        applier.clear_runtime_handle();
2680
2681        assert!(report.applied, "scroll update should apply in place");
2682        assert_dirty_hash_road_matches_full_walk(&graph);
2683    }
2684
2685    #[test]
2686    fn update_graph_from_applier_refreshes_scroll_content_offset() {
2687        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2688        let scroll_holder_for_comp = scroll_holder.clone();
2689
2690        let mut composition = cranpose_ui::run_test_composition(move || {
2691            let scroll_state =
2692                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2693            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2694            Column(
2695                Modifier::empty()
2696                    .size_points(240.0, 120.0)
2697                    .vertical_scroll(scroll_state, false),
2698                ColumnSpec::default(),
2699                || {
2700                    Text("scroll top", Modifier::empty(), TextStyle::default());
2701                    Spacer(Size {
2702                        width: 0.0,
2703                        height: 160.0,
2704                    });
2705                    Text("scroll target", Modifier::empty(), TextStyle::default());
2706                },
2707            );
2708        });
2709
2710        let root = composition.root().expect("composition root");
2711        let viewport = Size {
2712            width: 240.0,
2713            height: 120.0,
2714        };
2715        let handle = composition.runtime_handle();
2716        let mut applier = composition.applier_mut();
2717        applier.set_runtime_handle(handle);
2718        applier
2719            .compute_layout(root, viewport)
2720            .expect("initial scroll layout");
2721        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2722        graph.root.recompute_raster_cache_hashes();
2723        let initial_target_top =
2724            find_text_top(&graph.root, "scroll target").expect("initial target text");
2725        applier.clear_runtime_handle();
2726        drop(applier);
2727
2728        let scroll_state = scroll_holder
2729            .borrow()
2730            .as_ref()
2731            .cloned()
2732            .expect("scroll state should be captured");
2733        let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
2734        assert!(consumed_scroll > 0.0, "test scroll must be consumed");
2735        let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
2736        assert!(
2737            !dirty_nodes.is_empty(),
2738            "scroll state invalidation must schedule scoped layout graph update"
2739        );
2740
2741        let handle = composition.runtime_handle();
2742        let mut applier = composition.applier_mut();
2743        applier.set_runtime_handle(handle);
2744        applier
2745            .compute_layout(root, viewport)
2746            .expect("scrolled layout");
2747        let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
2748        applier.clear_runtime_handle();
2749
2750        assert!(report.applied, "scroll graph update should apply in place");
2751        let updated_target_top =
2752            find_text_top(&graph.root, "scroll target").expect("updated target text");
2753        assert!(
2754            updated_target_top < initial_target_top - consumed_scroll * 0.75,
2755            "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
2756        );
2757        assert_dirty_hash_road_matches_full_walk(&graph);
2758    }
2759
2760    #[test]
2761    fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
2762        let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2763            Rc::new(RefCell::new(None));
2764        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2765        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2766        let label_holder_for_comp = label_holder.clone();
2767        let scroll_holder_for_comp = scroll_holder.clone();
2768        let child_id_holder_for_comp = child_id_holder.clone();
2769
2770        let mut composition = cranpose_ui::run_test_composition(move || {
2771            let label = cranpose_core::useState(|| "scrolled child before".to_string());
2772            let scroll_state =
2773                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2774            *label_holder_for_comp.borrow_mut() = Some(label);
2775            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2776            let child_id_holder_for_content = child_id_holder_for_comp.clone();
2777            Column(
2778                Modifier::empty()
2779                    .size_points(260.0, 90.0)
2780                    .vertical_scroll(scroll_state, false),
2781                ColumnSpec::default(),
2782                move || {
2783                    Spacer(Size {
2784                        width: 0.0,
2785                        height: 24.0,
2786                    });
2787                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
2788                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
2789                    Spacer(Size {
2790                        width: 0.0,
2791                        height: 220.0,
2792                    });
2793                },
2794            );
2795        });
2796
2797        let root = composition.root().expect("composition root");
2798        let viewport = Size {
2799            width: 260.0,
2800            height: 90.0,
2801        };
2802        let handle = composition.runtime_handle();
2803        let mut applier = composition.applier_mut();
2804        applier.set_runtime_handle(handle);
2805        applier
2806            .compute_layout(root, viewport)
2807            .expect("initial layout");
2808        applier.clear_runtime_handle();
2809        drop(applier);
2810
2811        let scroll_state = scroll_holder
2812            .borrow()
2813            .as_ref()
2814            .cloned()
2815            .expect("scroll state should be captured");
2816        assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
2817
2818        let handle = composition.runtime_handle();
2819        let mut applier = composition.applier_mut();
2820        applier.set_runtime_handle(handle);
2821        applier
2822            .compute_layout(root, viewport)
2823            .expect("scrolled layout");
2824        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2825        let child_id = child_id_holder
2826            .borrow()
2827            .expect("text child id should be captured");
2828        let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
2829            .expect("scrolled child layer")
2830            .transform_to_parent;
2831        applier.clear_runtime_handle();
2832        drop(applier);
2833
2834        let label = label_holder
2835            .borrow()
2836            .as_ref()
2837            .copied()
2838            .expect("label state should be captured");
2839        label.set_value("scrolled child after".to_string());
2840        composition
2841            .process_invalid_scopes()
2842            .expect("text recomposition");
2843
2844        let handle = composition.runtime_handle();
2845        let mut applier = composition.applier_mut();
2846        applier.set_runtime_handle(handle);
2847        applier
2848            .compute_layout(root, viewport)
2849            .expect("updated scrolled layout");
2850        let child_id = child_id_holder
2851            .borrow()
2852            .expect("text child id should remain captured");
2853        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
2854        applier.clear_runtime_handle();
2855
2856        assert!(report.applied, "dirty child graph update should apply");
2857        let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
2858        assert_eq!(
2859            updated.transform_to_parent, scrolled_transform,
2860            "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
2861        );
2862        let mut labels = Vec::new();
2863        collect_text_labels(&graph.root, &mut labels);
2864        assert!(
2865            labels.iter().any(|label| label == "scrolled child after"),
2866            "updated graph should contain refreshed text, got {labels:?}"
2867        );
2868    }
2869
2870    #[test]
2871    fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
2872        let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2873            Rc::new(RefCell::new(None));
2874        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2875        let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2876        let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2877        let alpha_holder_for_comp = alpha_holder.clone();
2878        let scroll_holder_for_comp = scroll_holder.clone();
2879        let underlay_id_holder_for_comp = underlay_id_holder.clone();
2880        let overlay_id_holder_for_comp = overlay_id_holder.clone();
2881
2882        let mut composition = cranpose_ui::run_test_composition(move || {
2883            let alpha = cranpose_core::useState(|| 1.0f32);
2884            let scroll_state =
2885                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2886            *alpha_holder_for_comp.borrow_mut() = Some(alpha);
2887            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2888            let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
2889            let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
2890            Column(
2891                Modifier::empty()
2892                    .size_points(260.0, 120.0)
2893                    .vertical_scroll(scroll_state, false),
2894                ColumnSpec::default(),
2895                move || {
2896                    Spacer(Size {
2897                        width: 0.0,
2898                        height: 180.0,
2899                    });
2900                    cranpose_ui::Box(
2901                        Modifier::empty().size_points(188.0, 88.0),
2902                        cranpose_ui::BoxSpec::default(),
2903                        {
2904                            let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
2905                            let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
2906                            move || {
2907                                let underlay_id = cranpose_ui::Box(
2908                                    Modifier::empty().size_points(188.0, 88.0),
2909                                    cranpose_ui::BoxSpec::default(),
2910                                    || {
2911                                        Text(
2912                                            "UNDERLAY CONTENT",
2913                                            Modifier::empty().absolute_offset(12.0, 8.0),
2914                                            TextStyle::default(),
2915                                        );
2916                                    },
2917                                );
2918                                *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
2919                                let overlay_id = cranpose_ui::Box(
2920                                    Modifier::empty().size_points(188.0, 88.0).graphics_layer(
2921                                        move || GraphicsLayer {
2922                                            alpha: alpha.get(),
2923                                            ..GraphicsLayer::default()
2924                                        },
2925                                    ),
2926                                    cranpose_ui::BoxSpec::default(),
2927                                    || {
2928                                        Text(
2929                                            "TOP LAYER",
2930                                            Modifier::empty().absolute_offset(74.0, 39.6),
2931                                            TextStyle::default(),
2932                                        );
2933                                    },
2934                                );
2935                                *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
2936                            }
2937                        },
2938                    );
2939                    Spacer(Size {
2940                        width: 0.0,
2941                        height: 280.0,
2942                    });
2943                },
2944            );
2945        });
2946
2947        let root = composition.root().expect("composition root");
2948        let viewport = Size {
2949            width: 260.0,
2950            height: 120.0,
2951        };
2952        let handle = composition.runtime_handle();
2953        let mut applier = composition.applier_mut();
2954        applier.set_runtime_handle(handle);
2955        applier
2956            .compute_layout(root, viewport)
2957            .expect("initial layout");
2958        applier.clear_runtime_handle();
2959        drop(applier);
2960
2961        let scroll_state = scroll_holder
2962            .borrow()
2963            .as_ref()
2964            .cloned()
2965            .expect("scroll state should be captured");
2966        assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
2967
2968        let handle = composition.runtime_handle();
2969        let mut applier = composition.applier_mut();
2970        applier.set_runtime_handle(handle);
2971        applier
2972            .compute_layout(root, viewport)
2973            .expect("scrolled layout");
2974        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2975        applier.clear_runtime_handle();
2976        drop(applier);
2977
2978        let underlay_id = underlay_id_holder
2979            .borrow()
2980            .expect("underlay id should be captured");
2981        let overlay_id = overlay_id_holder
2982            .borrow()
2983            .expect("overlay id should be captured");
2984        let scrolled_underlay_origin =
2985            find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
2986        let scrolled_overlay_origin =
2987            find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
2988        assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
2989
2990        let alpha = alpha_holder
2991            .borrow()
2992            .as_ref()
2993            .copied()
2994            .expect("alpha state should be captured");
2995        alpha.set_value(0.35);
2996
2997        let handle = composition.runtime_handle();
2998        let mut applier = composition.applier_mut();
2999        applier.set_runtime_handle(handle);
3000        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
3001        applier.clear_runtime_handle();
3002
3003        assert!(report.applied, "dirty overlay graph update should apply");
3004        let updated_underlay_origin =
3005            find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
3006        let updated_overlay_origin =
3007            find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
3008        assert_eq!(
3009            updated_underlay_origin, scrolled_underlay_origin,
3010            "stable underlay must keep its scrolled origin"
3011        );
3012        assert_eq!(
3013            updated_overlay_origin, updated_underlay_origin,
3014            "dirty overlay graphics layer must stay aligned with its stable underlay"
3015        );
3016    }
3017
3018    #[test]
3019    fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
3020        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
3021            Rc::new(RefCell::new(None));
3022        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3023        let offset_holder_for_comp = offset_holder.clone();
3024        let node_id_holder_for_comp = node_id_holder.clone();
3025
3026        let mut composition = cranpose_ui::run_test_composition(move || {
3027            let offset = cranpose_core::useState(|| 0.0f32);
3028            *offset_holder_for_comp.borrow_mut() = Some(offset);
3029            let node_id = cranpose_ui::Box(
3030                Modifier::empty()
3031                    .size_points(40.0, 20.0)
3032                    .graphics_layer(move || GraphicsLayer {
3033                        translation_x: offset.get(),
3034                        ..GraphicsLayer::default()
3035                    }),
3036                cranpose_ui::BoxSpec::default(),
3037                || {},
3038            );
3039            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
3040        });
3041
3042        let root = composition.root().expect("composition root");
3043        let viewport = Size {
3044            width: 120.0,
3045            height: 80.0,
3046        };
3047        let handle = composition.runtime_handle();
3048        let mut applier = composition.applier_mut();
3049        applier.set_runtime_handle(handle);
3050        applier
3051            .compute_layout(root, viewport)
3052            .expect("initial layout");
3053        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3054        let node_id = node_id_holder
3055            .borrow()
3056            .expect("graphics layer node id should be captured");
3057        let initial_origin = find_layer_by_node_id(&graph.root, node_id)
3058            .expect("initial graphics layer")
3059            .transform_to_parent
3060            .map_point(Point::default());
3061        applier.clear_runtime_handle();
3062        drop(applier);
3063
3064        let offset = offset_holder
3065            .borrow()
3066            .as_ref()
3067            .copied()
3068            .expect("offset state should be captured");
3069        offset.set_value(32.0);
3070
3071        let handle = composition.runtime_handle();
3072        let mut applier = composition.applier_mut();
3073        applier.set_runtime_handle(handle);
3074        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
3075        assert!(
3076            report.applied,
3077            "dirty graphics layer should be replaceable from retained applier state"
3078        );
3079        assert!(
3080            !report.hit_graph_dirty,
3081            "a moved visual-only layer should not force hit graph refresh"
3082        );
3083        applier.clear_runtime_handle();
3084
3085        let updated_origin = find_layer_by_node_id(&graph.root, node_id)
3086            .expect("updated graphics layer")
3087            .transform_to_parent
3088            .map_point(Point::default());
3089        assert!(
3090            (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
3091            "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
3092        );
3093    }
3094
3095    #[test]
3096    fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
3097        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
3098            Rc::new(RefCell::new(None));
3099        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3100        let offset_holder_for_comp = offset_holder.clone();
3101        let node_id_holder_for_comp = node_id_holder.clone();
3102
3103        let mut composition = cranpose_ui::run_test_composition(move || {
3104            let offset = cranpose_core::useState(|| 0.0f32);
3105            *offset_holder_for_comp.borrow_mut() = Some(offset);
3106            let node_id = cranpose_ui::Box(
3107                Modifier::empty()
3108                    .size_points(40.0, 20.0)
3109                    .graphics_layer(move || GraphicsLayer {
3110                        translation_x: offset.get(),
3111                        ..GraphicsLayer::default()
3112                    })
3113                    .clickable(|_| {}),
3114                cranpose_ui::BoxSpec::default(),
3115                || {},
3116            );
3117            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
3118        });
3119
3120        let root = composition.root().expect("composition root");
3121        let viewport = Size {
3122            width: 120.0,
3123            height: 80.0,
3124        };
3125        let handle = composition.runtime_handle();
3126        let mut applier = composition.applier_mut();
3127        applier.set_runtime_handle(handle);
3128        applier
3129            .compute_layout(root, viewport)
3130            .expect("initial layout");
3131        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3132        let node_id = node_id_holder
3133            .borrow()
3134            .expect("graphics layer node id should be captured");
3135        applier.clear_runtime_handle();
3136        drop(applier);
3137
3138        let offset = offset_holder
3139            .borrow()
3140            .as_ref()
3141            .copied()
3142            .expect("offset state should be captured");
3143        offset.set_value(32.0);
3144
3145        let handle = composition.runtime_handle();
3146        let mut applier = composition.applier_mut();
3147        applier.set_runtime_handle(handle);
3148        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
3149        applier.clear_runtime_handle();
3150
3151        assert!(
3152            report.applied,
3153            "dirty clickable graphics layer should be replaceable from retained applier state"
3154        );
3155        assert!(
3156            report.hit_graph_dirty,
3157            "moved clickable layers must refresh hit geometry"
3158        );
3159    }
3160
3161    #[test]
3162    fn overlay_draw_commands_are_tagged_after_children() {
3163        let child = BuildNodeSnapshot {
3164            node_id: 2,
3165            placement: Point { x: 4.0, y: 5.0 },
3166            size: Size {
3167                width: 20.0,
3168                height: 10.0,
3169            },
3170            content_offset: Point::default(),
3171            motion_context_animated: false,
3172            translated_content_context: false,
3173            measured_max_width: None,
3174            resolved_modifiers: ResolvedModifiers::default(),
3175            draw_commands: vec![],
3176            click_actions: vec![],
3177            pointer_inputs: vec![],
3178            clip_to_bounds: false,
3179            annotated_text: None,
3180            text_style: None,
3181            text_layout_options: None,
3182            text_pan: None,
3183            graphics_layer: None,
3184            children: vec![],
3185        };
3186        let behind = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
3187            scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
3188                rect: Rect {
3189                    x: 1.0,
3190                    y: 2.0,
3191                    width: 8.0,
3192                    height: 6.0,
3193                },
3194                brush: Brush::solid(Color::WHITE),
3195                stroke: None,
3196            }]);
3197        }));
3198        let overlay = DrawCommand::Overlay(Rc::new(|scope: &mut DrawScopeDefault| {
3199            scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
3200                rect: Rect {
3201                    x: 3.0,
3202                    y: 1.0,
3203                    width: 5.0,
3204                    height: 4.0,
3205                },
3206                brush: Brush::solid(Color::BLACK),
3207                stroke: None,
3208            }]);
3209        }));
3210
3211        let parent = BuildNodeSnapshot {
3212            node_id: 1,
3213            placement: Point::default(),
3214            size: Size {
3215                width: 80.0,
3216                height: 50.0,
3217            },
3218            content_offset: Point::default(),
3219            motion_context_animated: false,
3220            translated_content_context: false,
3221            measured_max_width: None,
3222            resolved_modifiers: ResolvedModifiers::default(),
3223            draw_commands: vec![behind, overlay],
3224            click_actions: vec![],
3225            pointer_inputs: vec![],
3226            clip_to_bounds: false,
3227            annotated_text: None,
3228            text_style: None,
3229            text_layout_options: None,
3230            text_pan: None,
3231            graphics_layer: None,
3232            children: vec![child],
3233        };
3234
3235        let graph = build_layer_node_for_test(parent, 1.0, false);
3236        let RenderNode::DrawRun(behind) = &graph.children[0] else {
3237            panic!("expected before-children draw run");
3238        };
3239        let RenderNode::Layer(_) = &graph.children[1] else {
3240            panic!("expected child layer");
3241        };
3242        let RenderNode::DrawRun(overlay) = &graph.children[2] else {
3243            panic!("expected after-children draw run");
3244        };
3245
3246        assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
3247        assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
3248    }
3249
3250    /// The recording registry's whole contract: a command re-recording on a
3251    /// rebuild reuses the buffer of a recording the graph has let go of, and
3252    /// never writes through one anything else still shares.
3253    #[test]
3254    fn command_recordings_reuse_buffers_across_rebuilds() {
3255        let snapshot = || BuildNodeSnapshot {
3256            node_id: 7001,
3257            placement: Point::default(),
3258            size: Size {
3259                width: 40.0,
3260                height: 20.0,
3261            },
3262            content_offset: Point::default(),
3263            motion_context_animated: false,
3264            translated_content_context: false,
3265            measured_max_width: None,
3266            resolved_modifiers: ResolvedModifiers::default(),
3267            draw_commands: vec![DrawCommand::Behind(Rc::new(
3268                |scope: &mut DrawScopeDefault| {
3269                    scope.draw_rect_at(
3270                        Rect {
3271                            x: 1.0,
3272                            y: 2.0,
3273                            width: 8.0,
3274                            height: 6.0,
3275                        },
3276                        Brush::solid(Color::WHITE),
3277                    );
3278                },
3279            ))],
3280            click_actions: vec![],
3281            pointer_inputs: vec![],
3282            clip_to_bounds: false,
3283            annotated_text: None,
3284            text_style: None,
3285            text_layout_options: None,
3286            text_pan: None,
3287            graphics_layer: None,
3288            children: vec![],
3289        };
3290        fn run_of(layer: &LayerNode) -> &DrawRunNode {
3291            let RenderNode::DrawRun(run) = &layer.children[0] else {
3292                panic!("expected draw run");
3293            };
3294            run
3295        }
3296
3297        let graph_a = build_layer_node_for_test(snapshot(), 1.0, false);
3298        let ptr_a = run_of(&graph_a).primitives.as_ptr();
3299
3300        // Graph A is still alive, so its buffer must not be lent out.
3301        let graph_b = build_layer_node_for_test(snapshot(), 1.0, false);
3302        let ptr_b = run_of(&graph_b).primitives.as_ptr();
3303        assert_ne!(
3304            ptr_a, ptr_b,
3305            "a buffer a live graph shares must never be recorded into"
3306        );
3307        assert_eq!(
3308            run_of(&graph_a).primitives,
3309            run_of(&graph_b).primitives,
3310            "re-recording must reproduce the recording"
3311        );
3312
3313        // With graph A gone, its buffer is the registry's to lend again. The
3314        // registry still holds a handle, so the allocator cannot have
3315        // recycled this address: pointer equality here is reuse, not luck.
3316        drop(graph_a);
3317        let graph_c = build_layer_node_for_test(snapshot(), 1.0, false);
3318        assert_eq!(
3319            run_of(&graph_c).primitives.as_ptr(),
3320            ptr_a,
3321            "the released buffer must be reused for the next recording"
3322        );
3323
3324        // A recording shared outside the graph (renderer caches, tests)
3325        // keeps its buffer out of circulation even after the node drops.
3326        let held = std::rc::Rc::clone(&run_of(&graph_c).primitives);
3327        drop(graph_c);
3328        let graph_d = build_layer_node_for_test(snapshot(), 1.0, false);
3329        let ptr_d = run_of(&graph_d).primitives.as_ptr();
3330        assert_ne!(ptr_d, held.as_ptr());
3331        assert_ne!(ptr_d, run_of(&graph_b).primitives.as_ptr());
3332    }
3333
3334    #[test]
3335    fn stored_content_hash_changes_when_child_transform_changes() {
3336        let child = BuildNodeSnapshot {
3337            node_id: 2,
3338            placement: Point { x: 4.0, y: 5.0 },
3339            size: Size {
3340                width: 20.0,
3341                height: 10.0,
3342            },
3343            content_offset: Point::default(),
3344            motion_context_animated: false,
3345            translated_content_context: false,
3346            measured_max_width: None,
3347            resolved_modifiers: ResolvedModifiers::default(),
3348            draw_commands: vec![],
3349            click_actions: vec![],
3350            pointer_inputs: vec![],
3351            clip_to_bounds: false,
3352            annotated_text: None,
3353            text_style: None,
3354            text_layout_options: None,
3355            text_pan: None,
3356            graphics_layer: None,
3357            children: vec![],
3358        };
3359        let mut moved_child = child.clone();
3360        moved_child.placement.x += 7.0;
3361
3362        let parent = BuildNodeSnapshot {
3363            node_id: 1,
3364            placement: Point::default(),
3365            size: Size {
3366                width: 80.0,
3367                height: 50.0,
3368            },
3369            content_offset: Point::default(),
3370            motion_context_animated: false,
3371            translated_content_context: false,
3372            measured_max_width: None,
3373            resolved_modifiers: ResolvedModifiers::default(),
3374            draw_commands: vec![],
3375            click_actions: vec![],
3376            pointer_inputs: vec![],
3377            clip_to_bounds: false,
3378            annotated_text: None,
3379            text_style: None,
3380            text_layout_options: None,
3381            text_pan: None,
3382            graphics_layer: None,
3383            children: vec![child],
3384        };
3385        let moved_parent = BuildNodeSnapshot {
3386            children: vec![moved_child],
3387            ..parent.clone()
3388        };
3389
3390        let static_graph = build_layer_node_for_test(parent, 1.0, false);
3391        let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
3392
3393        assert_ne!(
3394            static_graph.target_content_hash(),
3395            moved_graph.target_content_hash(),
3396            "moving a child within the parent must invalidate the parent subtree hash"
3397        );
3398    }
3399
3400    #[test]
3401    fn stored_effect_hash_tracks_local_effect_only() {
3402        let base = BuildNodeSnapshot {
3403            node_id: 1,
3404            placement: Point::default(),
3405            size: Size {
3406                width: 80.0,
3407                height: 50.0,
3408            },
3409            content_offset: Point::default(),
3410            motion_context_animated: false,
3411            translated_content_context: false,
3412            measured_max_width: None,
3413            resolved_modifiers: ResolvedModifiers::default(),
3414            draw_commands: vec![],
3415            click_actions: vec![],
3416            pointer_inputs: vec![],
3417            clip_to_bounds: false,
3418            annotated_text: None,
3419            text_style: None,
3420            text_layout_options: None,
3421            text_pan: None,
3422            graphics_layer: None,
3423            children: vec![],
3424        };
3425        let mut effected = base.clone();
3426        effected.graphics_layer = Some(GraphicsLayer {
3427            render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
3428            ..GraphicsLayer::default()
3429        });
3430
3431        let base_graph = build_layer_node_for_test(base, 1.0, false);
3432        let effected_graph = build_layer_node_for_test(effected, 1.0, false);
3433
3434        assert_eq!(
3435            base_graph.target_content_hash(),
3436            effected_graph.target_content_hash(),
3437            "post-processing effect parameters belong to the effect hash, not the content hash"
3438        );
3439        assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
3440    }
3441
3442    #[test]
3443    fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
3444        let mut text_style = TextStyle::default();
3445        text_style.paragraph_style.text_align = TextAlign::Start;
3446        text_style.paragraph_style.text_direction = TextDirection::Rtl;
3447        text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
3448
3449        let snapshot = BuildNodeSnapshot {
3450            node_id: 1,
3451            placement: Point::default(),
3452            size: Size {
3453                width: 180.0,
3454                height: 48.0,
3455            },
3456            content_offset: Point::default(),
3457            motion_context_animated: false,
3458            translated_content_context: false,
3459            measured_max_width: Some(180.0),
3460            resolved_modifiers: ResolvedModifiers::default(),
3461            draw_commands: vec![],
3462            click_actions: vec![],
3463            pointer_inputs: vec![],
3464            clip_to_bounds: false,
3465            annotated_text: Some(AnnotatedString::from("rtl")),
3466            text_style: Some(text_style),
3467            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
3468                overflow: cranpose_ui::TextOverflow::Clip,
3469                ..Default::default()
3470            }),
3471            text_pan: None,
3472            graphics_layer: None,
3473            children: vec![],
3474        };
3475
3476        let graph = build_layer_node_for_test(snapshot, 1.0, false);
3477        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
3478            panic!("expected text primitive");
3479        };
3480        let PrimitiveNode::Text(text) = &text_primitive.node else {
3481            panic!("expected text primitive");
3482        };
3483        let clip = text
3484            .clip
3485            .expect("clipped overflow should produce a clip rect");
3486
3487        assert!(
3488            text.rect.x > 0.0,
3489            "RTL start alignment should shift the text rect within the available width"
3490        );
3491        assert!(
3492            clip.y < text.rect.y,
3493            "baseline shift must expand the clip upward so superscript glyphs are preserved"
3494        );
3495        assert!(
3496            clip.intersect(text.rect).is_some(),
3497            "the clip rect must intersect the shifted text draw rect"
3498        );
3499    }
3500
3501    #[test]
3502    fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
3503        let snapshot = BuildNodeSnapshot {
3504            node_id: 1,
3505            placement: Point::default(),
3506            size: Size {
3507                width: 320.0,
3508                height: 48.0,
3509            },
3510            content_offset: Point::default(),
3511            motion_context_animated: false,
3512            translated_content_context: false,
3513            measured_max_width: Some(320.0),
3514            resolved_modifiers: ResolvedModifiers::default(),
3515            draw_commands: vec![],
3516            click_actions: vec![],
3517            pointer_inputs: vec![],
3518            clip_to_bounds: false,
3519            annotated_text: Some(AnnotatedString::from("short")),
3520            text_style: Some(TextStyle::default()),
3521            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
3522                overflow: cranpose_ui::TextOverflow::Clip,
3523                ..Default::default()
3524            }),
3525            text_pan: None,
3526            graphics_layer: None,
3527            children: vec![],
3528        };
3529
3530        let graph = build_layer_node_for_test(snapshot, 1.0, false);
3531        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
3532            panic!("expected text primitive");
3533        };
3534        let PrimitiveNode::Text(text) = &text_primitive.node else {
3535            panic!("expected text primitive");
3536        };
3537        let clip = text.clip.expect("clipped text should keep a clip rect");
3538
3539        assert!(
3540            text.rect.width < 320.0,
3541            "text raster bounds should track measured glyph width instead of full content width"
3542        );
3543        assert_eq!(
3544            clip.width, 322.0,
3545            "text clip should still preserve the full content box plus clip padding"
3546        );
3547    }
3548
3549    /// Single-line text fields provide a pan resolver: the glyphs must be
3550    /// laid out unconstrained (no wrapping), shifted left by the pan offset,
3551    /// and clipped to the field bounds.
3552    #[test]
3553    fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
3554        let pan_offset = 25.0_f32;
3555        let field_width = 80.0_f32;
3556        let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
3557        let viewports = resolved_viewports.clone();
3558        let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
3559            node_id: 1,
3560            placement: Point::default(),
3561            size: Size {
3562                width: field_width,
3563                height: 24.0,
3564            },
3565            content_offset: Point::default(),
3566            motion_context_animated: false,
3567            translated_content_context: false,
3568            measured_max_width: Some(field_width),
3569            resolved_modifiers: ResolvedModifiers::default(),
3570            draw_commands: vec![],
3571            click_actions: vec![],
3572            pointer_inputs: vec![],
3573            clip_to_bounds: false,
3574            annotated_text: Some(AnnotatedString::from(
3575                "a very long single line of text that cannot fit",
3576            )),
3577            text_style: Some(TextStyle::default()),
3578            text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
3579            text_pan,
3580            graphics_layer: None,
3581            children: vec![],
3582        };
3583
3584        let text_node = |snapshot: BuildNodeSnapshot| {
3585            let graph = build_layer_node_for_test(snapshot, 1.0, false);
3586            let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
3587                panic!("expected text primitive");
3588            };
3589            let PrimitiveNode::Text(text) = &text_primitive.node else {
3590                panic!("expected text primitive");
3591            };
3592            (**text).clone()
3593        };
3594
3595        let unpanned = text_node(make_snapshot(None));
3596        let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
3597            viewports.borrow_mut().push(viewport);
3598            pan_offset
3599        }))));
3600
3601        assert_eq!(
3602            resolved_viewports.borrow().as_slice(),
3603            &[field_width],
3604            "the pan resolver must receive the content viewport width"
3605        );
3606        assert_eq!(
3607            panned.rect.x, -pan_offset,
3608            "text glyphs must shift left by the pan offset"
3609        );
3610        assert!(
3611            panned.rect.width > field_width,
3612            "panned single-line text must be laid out unconstrained, got {}",
3613            panned.rect.width
3614        );
3615        assert!(
3616            panned.rect.width >= unpanned.rect.width,
3617            "unconstrained layout must not be narrower than wrapped layout"
3618        );
3619        assert!(
3620            panned.rect.height <= unpanned.rect.height,
3621            "single-line layout must not wrap onto extra lines"
3622        );
3623        let clip = panned
3624            .clip
3625            .expect("panned text field must clip to field bounds");
3626        assert!(
3627            clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
3628            "clip must not extend past the field bounds, got {clip:?}"
3629        );
3630    }
3631
3632    #[test]
3633    fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
3634        let child = BuildNodeSnapshot {
3635            node_id: 2,
3636            placement: Point { x: 11.0, y: 7.0 },
3637            size: Size {
3638                width: 120.0,
3639                height: 32.0,
3640            },
3641            content_offset: Point::default(),
3642            motion_context_animated: false,
3643            translated_content_context: false,
3644            measured_max_width: Some(120.0),
3645            resolved_modifiers: ResolvedModifiers::default(),
3646            draw_commands: vec![],
3647            click_actions: vec![],
3648            pointer_inputs: vec![],
3649            clip_to_bounds: false,
3650            annotated_text: Some(AnnotatedString::from("scrolling")),
3651            text_style: Some(TextStyle::default()),
3652            text_layout_options: None,
3653            text_pan: None,
3654            graphics_layer: None,
3655            children: vec![],
3656        };
3657        let parent = BuildNodeSnapshot {
3658            node_id: 1,
3659            placement: Point::default(),
3660            size: Size {
3661                width: 160.0,
3662                height: 64.0,
3663            },
3664            content_offset: Point { x: 0.0, y: -18.5 },
3665            motion_context_animated: false,
3666            translated_content_context: true,
3667            measured_max_width: None,
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: None,
3674            text_style: None,
3675            text_layout_options: None,
3676            text_pan: None,
3677            graphics_layer: None,
3678            children: vec![child],
3679        };
3680
3681        let graph = build_layer_node_for_test(parent, 1.0, false);
3682        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3683            panic!("expected child layer");
3684        };
3685        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3686            panic!("expected text primitive");
3687        };
3688        let PrimitiveNode::Text(text) = &text_primitive.node else {
3689            panic!("expected text primitive");
3690        };
3691
3692        assert_eq!(text.text_style.paragraph_style.text_motion, None);
3693        assert!(!child_layer.motion_context_animated);
3694    }
3695
3696    #[test]
3697    fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
3698        let child = BuildNodeSnapshot {
3699            node_id: 2,
3700            placement: Point { x: 11.0, y: 7.0 },
3701            size: Size {
3702                width: 120.0,
3703                height: 32.0,
3704            },
3705            content_offset: Point::default(),
3706            motion_context_animated: false,
3707            translated_content_context: false,
3708            measured_max_width: Some(120.0),
3709            resolved_modifiers: ResolvedModifiers::default(),
3710            draw_commands: vec![],
3711            click_actions: vec![],
3712            pointer_inputs: vec![],
3713            clip_to_bounds: false,
3714            annotated_text: Some(AnnotatedString::from("scrolling")),
3715            text_style: Some(TextStyle::default()),
3716            text_layout_options: None,
3717            text_pan: None,
3718            graphics_layer: None,
3719            children: vec![],
3720        };
3721        let parent = BuildNodeSnapshot {
3722            node_id: 1,
3723            placement: Point::default(),
3724            size: Size {
3725                width: 160.0,
3726                height: 64.0,
3727            },
3728            content_offset: Point { x: 0.0, y: -18.0 },
3729            motion_context_animated: false,
3730            translated_content_context: false,
3731            measured_max_width: None,
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: None,
3738            text_style: None,
3739            text_layout_options: None,
3740            text_pan: None,
3741            graphics_layer: None,
3742            children: vec![child],
3743        };
3744
3745        let graph = build_layer_node_for_test(parent, 1.0, false);
3746        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3747            panic!("expected child layer");
3748        };
3749        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3750            panic!("expected text primitive");
3751        };
3752        let PrimitiveNode::Text(text) = &text_primitive.node else {
3753            panic!("expected text primitive");
3754        };
3755
3756        assert_eq!(
3757            text.text_style.paragraph_style.text_motion, None,
3758            "content_offset alone must not force text onto the translated-content motion path"
3759        );
3760        assert!(!child_layer.motion_context_animated);
3761    }
3762
3763    #[test]
3764    fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
3765        let child = BuildNodeSnapshot {
3766            node_id: 2,
3767            placement: Point { x: 11.0, y: 7.0 },
3768            size: Size {
3769                width: 120.0,
3770                height: 32.0,
3771            },
3772            content_offset: Point::default(),
3773            motion_context_animated: false,
3774            translated_content_context: false,
3775            measured_max_width: Some(120.0),
3776            resolved_modifiers: ResolvedModifiers::default(),
3777            draw_commands: vec![],
3778            click_actions: vec![],
3779            pointer_inputs: vec![],
3780            clip_to_bounds: false,
3781            annotated_text: Some(AnnotatedString::from("shadow")),
3782            text_style: Some(TextStyle::from_span_style(SpanStyle {
3783                shadow: Some(cranpose_ui::text::Shadow {
3784                    color: Color::BLACK,
3785                    offset: Point::new(1.0, 2.0),
3786                    blur_radius: 3.0,
3787                }),
3788                ..SpanStyle::default()
3789            })),
3790            text_layout_options: None,
3791            text_pan: None,
3792            graphics_layer: None,
3793            children: vec![],
3794        };
3795        let parent = BuildNodeSnapshot {
3796            node_id: 1,
3797            placement: Point::default(),
3798            size: Size {
3799                width: 160.0,
3800                height: 64.0,
3801            },
3802            content_offset: Point { x: 0.0, y: -18.5 },
3803            motion_context_animated: false,
3804            translated_content_context: true,
3805            measured_max_width: None,
3806            resolved_modifiers: ResolvedModifiers::default(),
3807            draw_commands: vec![],
3808            click_actions: vec![],
3809            pointer_inputs: vec![],
3810            clip_to_bounds: false,
3811            annotated_text: None,
3812            text_style: None,
3813            text_layout_options: None,
3814            text_pan: None,
3815            graphics_layer: None,
3816            children: vec![child],
3817        };
3818
3819        let graph = build_layer_node_for_test(parent, 1.0, false);
3820        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3821            panic!("expected child layer");
3822        };
3823        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3824            panic!("expected text primitive");
3825        };
3826        let PrimitiveNode::Text(text) = &text_primitive.node else {
3827            panic!("expected text primitive");
3828        };
3829
3830        assert_eq!(text.text_style.paragraph_style.text_motion, None);
3831    }
3832
3833    #[test]
3834    fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
3835        let child = BuildNodeSnapshot {
3836            node_id: 2,
3837            placement: Point { x: 11.0, y: 7.0 },
3838            size: Size {
3839                width: 120.0,
3840                height: 32.0,
3841            },
3842            content_offset: Point::default(),
3843            motion_context_animated: false,
3844            translated_content_context: false,
3845            measured_max_width: Some(120.0),
3846            resolved_modifiers: ResolvedModifiers::default(),
3847            draw_commands: vec![],
3848            click_actions: vec![],
3849            pointer_inputs: vec![],
3850            clip_to_bounds: false,
3851            annotated_text: Some(AnnotatedString::from("lazy")),
3852            text_style: Some(TextStyle::default()),
3853            text_layout_options: None,
3854            text_pan: None,
3855            graphics_layer: None,
3856            children: vec![],
3857        };
3858        let parent = BuildNodeSnapshot {
3859            node_id: 1,
3860            placement: Point::default(),
3861            size: Size {
3862                width: 160.0,
3863                height: 64.0,
3864            },
3865            content_offset: Point::default(),
3866            motion_context_animated: true,
3867            translated_content_context: false,
3868            measured_max_width: None,
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: None,
3875            text_style: None,
3876            text_layout_options: None,
3877            text_pan: None,
3878            graphics_layer: None,
3879            children: vec![child],
3880        };
3881
3882        let graph = build_layer_node_for_test(parent, 1.0, false);
3883        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3884            panic!("expected child layer");
3885        };
3886        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3887            panic!("expected text primitive");
3888        };
3889        let PrimitiveNode::Text(text) = &text_primitive.node else {
3890            panic!("expected text primitive");
3891        };
3892
3893        assert_eq!(text.text_style.paragraph_style.text_motion, None);
3894        assert!(graph.motion_context_animated);
3895        assert!(child_layer.motion_context_animated);
3896    }
3897
3898    #[test]
3899    fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
3900        let mut composition = cranpose_ui::run_test_composition(|| {
3901            let list_state = remember_lazy_list_state();
3902            LazyColumn(
3903                Modifier::empty(),
3904                list_state,
3905                LazyColumnSpec::default(),
3906                |scope| {
3907                    scope.item(Some(0), None, || {
3908                        Text("LazyMotion", Modifier::empty(), TextStyle::default());
3909                    });
3910                },
3911            );
3912        });
3913
3914        let root = composition.root().expect("lazy column root");
3915        let handle = composition.runtime_handle();
3916        let mut applier = composition.applier_mut();
3917        applier.set_runtime_handle(handle);
3918        let _ = applier
3919            .compute_layout(
3920                root,
3921                Size {
3922                    width: 240.0,
3923                    height: 240.0,
3924                },
3925            )
3926            .expect("lazy column layout");
3927        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3928        applier.clear_runtime_handle();
3929
3930        assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
3931    }
3932
3933    #[test]
3934    fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
3935        use std::cell::RefCell;
3936        use std::rc::Rc;
3937
3938        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3939        let state_holder_for_comp = state_holder.clone();
3940        let mut composition = cranpose_ui::run_test_composition(move || {
3941            let list_state = remember_lazy_list_state();
3942            *state_holder_for_comp.borrow_mut() = Some(list_state);
3943            LazyColumn(
3944                Modifier::empty().height(120.0),
3945                list_state,
3946                LazyColumnSpec::default(),
3947                |scope| {
3948                    scope.items(
3949                        8,
3950                        None::<fn(usize) -> u64>,
3951                        None::<fn(usize) -> u64>,
3952                        |index| {
3953                            Text(
3954                                format!("LazyMotion {index}"),
3955                                Modifier::empty().padding(4.0),
3956                                TextStyle::default(),
3957                            );
3958                        },
3959                    );
3960                },
3961            );
3962        });
3963
3964        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3965        list_state.scroll_to_item(3, 0.0);
3966
3967        let root = composition.root().expect("lazy column root");
3968        let handle = composition.runtime_handle();
3969        let mut applier = composition.applier_mut();
3970        applier.set_runtime_handle(handle);
3971        let _ = applier
3972            .compute_layout(
3973                root,
3974                Size {
3975                    width: 240.0,
3976                    height: 240.0,
3977                },
3978            )
3979            .expect("lazy column layout");
3980        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3981        let active_children = applier
3982            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3983            .expect("lazy column should be subcompose");
3984        let child_debug: Vec<String> = active_children
3985            .iter()
3986            .map(|&child_id| {
3987                if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
3988                    format!(
3989                        "layout#{child_id} placed={} text={:?} children={:?}",
3990                        node.layout_state().is_placed,
3991                        node.modifier_slices_snapshot()
3992                            .text_content()
3993                            .map(str::to_string),
3994                        node.children.clone()
3995                    )
3996                }) {
3997                    summary
3998                } else if let Ok(summary) =
3999                    applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
4000                        format!(
4001                            "subcompose#{child_id} placed={} active_children={:?}",
4002                            node.layout_state().is_placed,
4003                            node.active_children()
4004                        )
4005                    })
4006                {
4007                    summary
4008                } else {
4009                    format!("missing#{child_id}")
4010                }
4011            })
4012            .collect();
4013        applier.clear_runtime_handle();
4014
4015        let first_index = list_state.first_visible_item_index();
4016        assert!(
4017            first_index > 0,
4018            "lazy list should move away from origin before graph building, observed first_index={first_index}"
4019        );
4020        let mut labels = Vec::new();
4021        collect_text_labels(&graph.root, &mut labels);
4022        assert_eq!(
4023            find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
4024            Some(None),
4025            "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
4026            labels,
4027            active_children,
4028            child_debug
4029        );
4030    }
4031
4032    #[test]
4033    fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
4034        use std::cell::RefCell;
4035        use std::rc::Rc;
4036
4037        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
4038        let state_holder_for_comp = state_holder.clone();
4039        let mut composition = cranpose_ui::run_test_composition(move || {
4040            let list_state = remember_lazy_list_state();
4041            *state_holder_for_comp.borrow_mut() = Some(list_state);
4042            let mut spec =
4043                LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
4044            spec.beyond_bounds_item_count = 0;
4045            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
4046                scope.items(
4047                    12,
4048                    None::<fn(usize) -> u64>,
4049                    None::<fn(usize) -> u64>,
4050                    |index| {
4051                        Text(
4052                            format!("WarmRow {index}"),
4053                            Modifier::empty().height(32.0),
4054                            TextStyle::default(),
4055                        );
4056                    },
4057                );
4058            });
4059        });
4060
4061        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
4062        list_state.scroll_to_item(4, 0.0);
4063
4064        let root = composition.root().expect("lazy column root");
4065        let handle = composition.runtime_handle();
4066        let mut applier = composition.applier_mut();
4067        applier.set_runtime_handle(handle);
4068        let _ = applier
4069            .compute_layout(
4070                root,
4071                Size {
4072                    width: 240.0,
4073                    height: 240.0,
4074                },
4075            )
4076            .expect("lazy column layout");
4077        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4078        let active_children = applier
4079            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
4080            .expect("lazy column should be subcompose");
4081        applier.clear_runtime_handle();
4082
4083        let visible_indices: Vec<_> = list_state
4084            .layout_info()
4085            .visible_items_info
4086            .iter()
4087            .map(|item| item.index)
4088            .collect();
4089        let mut labels = Vec::new();
4090        collect_text_labels(&graph.root, &mut labels);
4091
4092        assert_eq!(
4093            visible_indices,
4094            vec![4, 5, 6],
4095            "test setup expects exactly three viewport-visible rows"
4096        );
4097        assert!(
4098            labels.iter().any(|label| label == "WarmRow 7"),
4099            "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
4100        );
4101    }
4102
4103    #[test]
4104    fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
4105        use std::cell::RefCell;
4106        use std::rc::Rc;
4107
4108        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
4109        let state_holder_for_comp = state_holder.clone();
4110        let mut composition = cranpose_ui::run_test_composition(move || {
4111            let list_state = remember_lazy_list_state();
4112            *state_holder_for_comp.borrow_mut() = Some(list_state);
4113            LazyColumn(
4114                Modifier::empty().height(120.0),
4115                list_state,
4116                LazyColumnSpec::default(),
4117                |scope| {
4118                    scope.items(
4119                        8,
4120                        None::<fn(usize) -> u64>,
4121                        None::<fn(usize) -> u64>,
4122                        |index| {
4123                            Text(
4124                                format!("LazySnap {index}"),
4125                                Modifier::empty().padding(4.0),
4126                                TextStyle::default(),
4127                            );
4128                        },
4129                    );
4130                },
4131            );
4132        });
4133
4134        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
4135        list_state.scroll_to_item(2, 7.5);
4136
4137        let root = composition.root().expect("lazy column root");
4138        let handle = composition.runtime_handle();
4139        let mut applier = composition.applier_mut();
4140        applier.set_runtime_handle(handle);
4141        let _ = applier
4142            .compute_layout(
4143                root,
4144                Size {
4145                    width: 240.0,
4146                    height: 240.0,
4147                },
4148            )
4149            .expect("lazy column layout");
4150        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4151        applier.clear_runtime_handle();
4152
4153        let layout_info = list_state.layout_info();
4154        let first_visible_offset = layout_info
4155            .visible_items_info
4156            .first()
4157            .expect("lazy layout should expose visible item info")
4158            .offset;
4159        let snap_offset = find_translated_content_offset(&graph.root)
4160            .expect("lazy list graph should include translated content context");
4161
4162        assert!(
4163            (snap_offset.y - first_visible_offset).abs() <= 0.001,
4164            "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
4165        );
4166    }
4167
4168    #[test]
4169    fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
4170        let child = BuildNodeSnapshot {
4171            node_id: 2,
4172            placement: Point { x: 11.0, y: 7.0 },
4173            size: Size {
4174                width: 120.0,
4175                height: 32.0,
4176            },
4177            content_offset: Point::default(),
4178            motion_context_animated: false,
4179            translated_content_context: false,
4180            measured_max_width: Some(120.0),
4181            resolved_modifiers: ResolvedModifiers::default(),
4182            draw_commands: vec![],
4183            click_actions: vec![],
4184            pointer_inputs: vec![],
4185            clip_to_bounds: false,
4186            annotated_text: Some(AnnotatedString::from("static")),
4187            text_style: Some(TextStyle::from_paragraph_style(
4188                cranpose_ui::text::ParagraphStyle {
4189                    text_motion: Some(TextMotion::Static),
4190                    ..Default::default()
4191                },
4192            )),
4193            text_layout_options: None,
4194            text_pan: None,
4195            graphics_layer: None,
4196            children: vec![],
4197        };
4198        let parent = BuildNodeSnapshot {
4199            node_id: 1,
4200            placement: Point::default(),
4201            size: Size {
4202                width: 160.0,
4203                height: 64.0,
4204            },
4205            content_offset: Point { x: 0.0, y: -18.5 },
4206            motion_context_animated: false,
4207            translated_content_context: true,
4208            measured_max_width: None,
4209            resolved_modifiers: ResolvedModifiers::default(),
4210            draw_commands: vec![],
4211            click_actions: vec![],
4212            pointer_inputs: vec![],
4213            clip_to_bounds: false,
4214            annotated_text: None,
4215            text_style: None,
4216            text_layout_options: None,
4217            text_pan: None,
4218            graphics_layer: None,
4219            children: vec![child],
4220        };
4221
4222        let graph = build_layer_node_for_test(parent, 1.0, false);
4223        let RenderNode::Layer(child_layer) = &graph.children[0] else {
4224            panic!("expected child layer");
4225        };
4226        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4227            panic!("expected text primitive");
4228        };
4229        let PrimitiveNode::Text(text) = &text_primitive.node else {
4230            panic!("expected text primitive");
4231        };
4232
4233        assert_eq!(
4234            text.text_style.paragraph_style.text_motion,
4235            Some(TextMotion::Static),
4236            "explicit text motion must win over inherited scrolling motion context"
4237        );
4238    }
4239
4240    /// A wrapping paragraph must PAINT the height it MEASURED, so the sibling
4241    /// the column placed after it is not drawn over.
4242    ///
4243    /// Regression. A `Text` without `fill_max_width` is placed at its own
4244    /// `metrics.width` — the widest line it wrapped into. The paint pass then
4245    /// re-wrapped at that placed width, and because the widest line is exactly
4246    /// the one that fills the limit, measuring it against itself pushed its
4247    /// last word onto a new line: measured 6 lines, painted 7. The extra line
4248    /// was clipped away (silent truncation) and it ran past the next sibling's
4249    /// box, which the column had placed from the 6-line height.
4250    ///
4251    /// Asserts RENDERED GEOMETRY, not `resolve_text_measure_width`'s return
4252    /// value: the two pipelines already had unit tests for the correct rule and
4253    /// shipped this anyway, because those tests exercised a `#[cfg(test)]`
4254    /// replica rather than the scene builder that paints.
4255    ///
4256    /// Driven by the REAL font backend (`SoftwareTextMeasurer`, the measurer
4257    /// `WgpuRenderer::attach_app_context_services` installs). A stub measurer
4258    /// cannot show this: the defect lives in the disagreement between two wrap
4259    /// widths, and a stub that returns the same answer for both hides it by
4260    /// construction. The string is mixed Latin/Cyrillic because that is what
4261    /// the reporting app puts in these paragraphs.
4262    #[test]
4263    fn wrapped_paragraph_paints_the_height_it_measured() {
4264        const BODY: &str = "fed back картица scored fp32 износ once paper fed Vision dropped \
4265             fed widest the strip mask prompt mask threshold Vision on датум instance mask \
4266             износ Apple";
4267        const FOLLOWING: &str = "FOLLOWING SIBLING";
4268
4269        let app_context = cranpose_ui::AppContext::new();
4270        app_context.enter(|| {
4271            cranpose_ui::text::set_text_measurer(
4272                crate::software_text_raster::SoftwareTextMeasurer::from_fonts_or_default(&[], 8192),
4273            );
4274            let mut composition = cranpose_ui::run_test_composition(move || {
4275                Column(
4276                    Modifier::empty().fill_max_width(),
4277                    ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
4278                    move || {
4279                        Text(BODY.to_string(), Modifier::empty(), TextStyle::default());
4280                        Text(
4281                            FOLLOWING.to_string(),
4282                            Modifier::empty(),
4283                            TextStyle::default(),
4284                        );
4285                    },
4286                );
4287            });
4288
4289            let root = composition.root().expect("composition root");
4290            let handle = composition.runtime_handle();
4291            let mut applier = composition.applier_mut();
4292            applier.set_runtime_handle(handle);
4293            let layout = applier
4294                .compute_layout(
4295                    root,
4296                    Size {
4297                        width: 245.0,
4298                        height: 900.0,
4299                    },
4300                )
4301                .expect("layout");
4302
4303            fn find_box<'a>(node: &'a LayoutBox, value: &str) -> Option<&'a LayoutBox> {
4304                if node
4305                    .node_data
4306                    .modifier_slices()
4307                    .text_content()
4308                    .is_some_and(|text| text == value)
4309                {
4310                    return Some(node);
4311                }
4312                node.children
4313                    .iter()
4314                    .find_map(|child| find_box(child, value))
4315            }
4316            let body_box = find_box(layout.root(), BODY).expect("measured paragraph box");
4317            let following_box = find_box(layout.root(), FOLLOWING).expect("measured sibling box");
4318            let measured_height = body_box.rect.height;
4319            let following_top = following_box.rect.y;
4320            assert!(
4321                measured_height > 60.0,
4322                "test setup expects a genuinely multi-line paragraph, got {measured_height}"
4323            );
4324            assert!(
4325                body_box.rect.width < 245.0,
4326                "test setup expects the node to be placed at its own measured width, \
4327                 not the full constraint, got {}",
4328                body_box.rect.width
4329            );
4330
4331            let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("render graph");
4332            applier.clear_runtime_handle();
4333
4334            // The painted string carries the wrap points as newlines, so it is
4335            // compared with whitespace stripped rather than verbatim.
4336            fn squashed(value: &str) -> String {
4337                value.chars().filter(|c| !c.is_whitespace()).collect()
4338            }
4339            fn find_text<'a>(layer: &'a LayerNode, value: &str) -> Option<&'a TextPrimitiveNode> {
4340                for child in &layer.children {
4341                    match child {
4342                        RenderNode::Primitive(primitive) => {
4343                            if let PrimitiveNode::Text(text) = &primitive.node {
4344                                if squashed(&text.text.text) == squashed(value) {
4345                                    return Some(text);
4346                                }
4347                            }
4348                        }
4349                        RenderNode::Layer(child_layer) => {
4350                            if let Some(found) = find_text(child_layer, value) {
4351                                return Some(found);
4352                            }
4353                        }
4354                        RenderNode::DrawRun(_) => {}
4355                    }
4356                }
4357                None
4358            }
4359            let painted = find_text(&graph.root, BODY).expect("painted paragraph");
4360
4361            assert!(
4362                (painted.rect.height - measured_height).abs() < 0.5,
4363                "paragraph painted {:.2} tall into a box layout measured at {:.2} \
4364                 (painted rect {:?})",
4365                painted.rect.height,
4366                measured_height,
4367                painted.rect
4368            );
4369            assert!(
4370                painted.rect.y + painted.rect.height <= following_top + 0.5,
4371                "painted paragraph bottom {:.2} runs past the following sibling placed at \
4372                 {:.2}",
4373                painted.rect.y + painted.rect.height,
4374                following_top
4375            );
4376        });
4377    }
4378
4379    #[test]
4380    fn retained_slot_confirmations_are_live_only_under_their_generation() {
4381        let command = DrawCommandId {
4382            node_id: 990_101,
4383            command_index: 0,
4384            placement: DrawPlacement::Behind,
4385        };
4386        set_retained_feed_epoch(Some(7));
4387        confirm_retained_slot(command, 3, 7);
4388        assert!(retained_slot_confirmed(command, 3));
4389        // A new epoch (renderer swap, device loss) makes the word stale.
4390        set_retained_feed_epoch(Some(8));
4391        assert!(!retained_slot_confirmed(command, 3));
4392        // No declared epoch means no consumer: never confirmed.
4393        set_retained_feed_epoch(None);
4394        assert!(!retained_slot_confirmed(command, 3));
4395        // Back on the stored generation the buffer is live again.
4396        set_retained_feed_epoch(Some(7));
4397        assert!(retained_slot_confirmed(command, 3));
4398        revoke_retained_slot(command, 3);
4399        assert!(!retained_slot_confirmed(command, 3));
4400        set_retained_feed_epoch(None);
4401        clear_retained_slot_confirmations();
4402    }
4403
4404    /// Draws enough similar arcs for the replay verifier to engage
4405    /// (`MIN_REPLAY_COMMAND_RECORDS`) and to carve several segments —
4406    /// partial arcs, because a chain anchor must pin rotation and circles
4407    /// cannot. Static across frames, so every segment verifies under the
4408    /// identity transform from the first replay frame on.
4409    fn record_sweep_test_rings(scope: &mut DrawScopeDefault) {
4410        let count = 600usize;
4411        let sweep = std::f32::consts::TAU / count as f32 * 0.8;
4412        for i in 0..count {
4413            let start = i as f32 * (std::f32::consts::TAU / count as f32);
4414            scope.draw_annular_sector(
4415                Brush::solid(cranpose_ui_graphics::Color(0.2, 0.4, 0.6, 1.0)),
4416                cranpose_ui_graphics::Point::new(204.0, 204.0),
4417                140.0,
4418                150.0,
4419                start,
4420                sweep,
4421            );
4422        }
4423    }
4424
4425    /// The FLIP of the old sweep-exemption test: with the frame owning a
4426    /// pinned handle to the exact recording it was built from, the idle
4427    /// sweep is pure capacity management again — a live confirmation no
4428    /// longer pins the registry slot, the sweep drops it anyway, and the
4429    /// frame's bypassed spans still rematerialize byte-identically from the
4430    /// handle the frame owns. Sweeping can categorically never sever a
4431    /// frame's rematerialization source.
4432    #[test]
4433    fn recording_sweep_cannot_sever_a_frames_fallback() {
4434        let command = DrawCommandId {
4435            node_id: 990_102,
4436            command_index: 0,
4437            placement: DrawPlacement::Behind,
4438        };
4439        set_retained_feed_epoch(Some(41));
4440        for slot in 0..64 {
4441            confirm_retained_slot(command, slot, 41);
4442        }
4443
4444        // The production seam order, driven by hand: acquire buffers,
4445        // record, verify, finish with the confirmed slots bypassed, publish
4446        // — and the frame keeps the published recording handle, exactly as
4447        // `draw_nodes` attaches it.
4448        let mut state = cranpose_ui_graphics::CommandReplayState::default();
4449        let mut published = None;
4450        for _frame in 0..4 {
4451            let (recording, storage, _) = acquire_recording(command);
4452            let mut scope = DrawScopeDefault::with_recording(
4453                cranpose_ui_graphics::Size::new(408.0, 408.0),
4454                None,
4455                recording,
4456                storage,
4457            );
4458            record_sweep_test_rings(&mut scope);
4459            let outcome = state.advance(scope.recorded());
4460            let center = state.center();
4461            let (finished, frame) = scope.finish_replay(center, outcome, &mut |slot| {
4462                retained_slot_confirmed(command, slot)
4463            });
4464            let (primitives, fallback) =
4465                publish_recording(command, finished.recording, finished.primitives, None);
4466            let frame = frame.map(|mut frame| {
4467                frame.fallback = Some(fallback.clone());
4468                frame
4469            });
4470            published = Some((primitives, fallback, frame));
4471        }
4472        let (_primitives, fallback, frame) = published.expect("four frames published");
4473        let frame = frame.expect("the replay must produce a frame with retained spans");
4474        let bypassed: Vec<(u32, u32)> = frame
4475            .spans
4476            .iter()
4477            .filter_map(|span| match span {
4478                cranpose_ui_graphics::FrameSpan::Retained {
4479                    capture: false,
4480                    range,
4481                    tape_range,
4482                    ..
4483                } if range.1 <= range.0 => Some(*tape_range),
4484                _ => None,
4485            })
4486            .collect();
4487        assert!(
4488            !bypassed.is_empty(),
4489            "confirmed slots must actually have bypassed materialization"
4490        );
4491        let expected: Vec<Vec<DrawPrimitive>> = bypassed
4492            .iter()
4493            .map(|tape_range| {
4494                fallback
4495                    .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
4496                    .expect("a frame-consistent tape range must materialize")
4497            })
4498            .collect();
4499
4500        // 1024 builds without re-recording: both sweeps (512, 1024) run with
4501        // the slot idle far past the 64-build window, confirmations still
4502        // live. The slot must be GONE — capacity management owes the frame
4503        // nothing anymore.
4504        for _ in 0..1024 {
4505            bump_recording_generation();
4506        }
4507        assert!(
4508            COMMAND_RECORDINGS.with(|map| !map.borrow().contains_key(&command)),
4509            "the sweep must stay pure capacity management: a live confirmation \
4510             no longer pins the registry slot"
4511        );
4512
4513        // The categorical property: the frame's own handle survives any
4514        // sweep, and its bypassed spans rematerialize byte-identically.
4515        for (tape_range, expected) in bypassed.iter().zip(&expected) {
4516            let after = fallback
4517                .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
4518                .expect("the frame-owned recording must outlive the sweep");
4519            assert_eq!(
4520                &after, expected,
4521                "post-sweep rematerialization must be byte-identical"
4522            );
4523        }
4524        set_retained_feed_epoch(None);
4525        clear_retained_slot_confirmations();
4526    }
4527
4528    /// [`command_recordings_reuse_buffers_across_rebuilds`] for the compact
4529    /// recording pair: a graph frame owns last build's recording (its
4530    /// `fallback`) while this build records, so steady-state publishes
4531    /// ping-pong between exactly two allocations — `Rc::try_unwrap`
4532    /// succeeds at every acquisition after warmup and no recording buffer
4533    /// is ever reallocated — and a recording a live frame still shares is
4534    /// never written through.
4535    #[test]
4536    fn command_recordings_reuse_recording_buffers_across_rebuilds() {
4537        let command = DrawCommandId {
4538            node_id: 990_103,
4539            command_index: 0,
4540            placement: DrawPlacement::Behind,
4541        };
4542        // Simulates the installed graph: it holds the newest build's
4543        // handles, and the previous build's drop when it is replaced.
4544        let mut held = None;
4545        let mut ptrs = Vec::new();
4546        for _build in 0..8 {
4547            let (recording, storage, _) = acquire_recording(command);
4548            let mut scope = DrawScopeDefault::with_recording(
4549                cranpose_ui_graphics::Size::new(64.0, 64.0),
4550                None,
4551                recording,
4552                storage,
4553            );
4554            scope.draw_rect_at(
4555                Rect {
4556                    x: 4.0,
4557                    y: 4.0,
4558                    width: 16.0,
4559                    height: 8.0,
4560                },
4561                Brush::solid(Color::WHITE),
4562            );
4563            let finished = scope.finish();
4564            let (primitives, recording) =
4565                publish_recording(command, finished.recording, finished.primitives, None);
4566            ptrs.push(recording.tape_ptr());
4567            held = Some((primitives, recording));
4568        }
4569        drop(held);
4570        // Every buffer in play stays alive for the whole loop (registry pair
4571        // or in-flight scope), so pointer equality here is reuse, not an
4572        // allocator recycling a freed address.
4573        for build in 2..8 {
4574            assert_eq!(
4575                ptrs[build],
4576                ptrs[build - 2],
4577                "steady-state publishes must ping-pong between the pair's \
4578                 buffers (build {build} allocated)"
4579            );
4580        }
4581        assert_ne!(
4582            ptrs[6], ptrs[7],
4583            "a recording a live frame still shares must never be recorded into"
4584        );
4585    }
4586
4587    #[test]
4588    fn sanitized_spans_drop_recolors_and_downgrade_captures() {
4589        use cranpose_ui_graphics::{FrameSpan, RecordTransform};
4590        let bounds = Rect {
4591            x: 1.0,
4592            y: 2.0,
4593            width: 3.0,
4594            height: 4.0,
4595        };
4596        let spans = vec![
4597            FrameSpan::Dynamic { range: (0, 5) },
4598            FrameSpan::Retained {
4599                slot: 7,
4600                capture: true,
4601                slot_offset: 0,
4602                range: (5, 105),
4603                tape_range: (5, 105),
4604                transform: RecordTransform::IDENTITY,
4605                recolors: Vec::new(),
4606                bounds,
4607            },
4608            FrameSpan::Retained {
4609                slot: 8,
4610                capture: false,
4611                slot_offset: 3,
4612                range: (105, 205),
4613                tape_range: (110, 210),
4614                transform: RecordTransform {
4615                    scale: 0.999,
4616                    angle: 0.05,
4617                },
4618                recolors: vec![(4, cranpose_ui_graphics::Color(1.0, 0.5, 0.2, 1.0))],
4619                bounds,
4620            },
4621        ];
4622        let sanitized = sanitized_replay_spans(&spans);
4623        // The dynamic span passes through untouched.
4624        assert_eq!(sanitized[0], FrameSpan::Dynamic { range: (0, 5) });
4625        // The capture span becomes a plain dynamic draw of the SAME
4626        // materialized range: re-emitting it must redraw its pixels
4627        // without re-queuing a capture.
4628        assert_eq!(sanitized[1], FrameSpan::Dynamic { range: (5, 105) });
4629        // The retained span keeps its identity and transform but sheds its
4630        // recolors: the renderer's slot paint is persistent, so the
4631        // previous frame's absolute writes already show them.
4632        match &sanitized[2] {
4633            FrameSpan::Retained {
4634                slot,
4635                capture,
4636                slot_offset,
4637                range,
4638                tape_range,
4639                transform,
4640                recolors,
4641                bounds: sanitized_bounds,
4642            } => {
4643                assert_eq!((*slot, *capture, *slot_offset), (8, false, 3));
4644                assert_eq!((*range, *tape_range), ((105, 205), (110, 210)));
4645                assert_eq!(transform.angle, 0.05);
4646                assert!(recolors.is_empty(), "recolors must be emptied");
4647                assert_eq!(*sanitized_bounds, bounds);
4648            }
4649            other => panic!("expected a retained span, got {other:?}"),
4650        }
4651    }
4652
4653    /// The one-frame staleness cap, at the registry seam it lives on: a
4654    /// saved emission serves on the IMMEDIATELY following build only, and
4655    /// serving consumes it — so two consecutive collapses can produce at
4656    /// most one stale frame, with no counter anywhere.
4657    #[test]
4658    fn a_saved_emission_serves_the_next_build_once() {
4659        let command = DrawCommandId {
4660            node_id: 990_303,
4661            command_index: 0,
4662            placement: DrawPlacement::Behind,
4663        };
4664        set_retained_feed_epoch(Some(77));
4665        // Materialize the slot the save writes into.
4666        publish_recording(
4667            command,
4668            cranpose_ui_graphics::CommandRecording::default(),
4669            Vec::new(),
4670            None,
4671        );
4672        let saved = || SavedReplayEmission {
4673            spans: vec![cranpose_ui_graphics::FrameSpan::Dynamic { range: (0, 3) }],
4674            center: cranpose_ui_graphics::Point::new(204.0, 204.0),
4675            primitives: Rc::new(Vec::new()),
4676            recording: Rc::new(cranpose_ui_graphics::CommandRecording::default()),
4677            epoch: 77,
4678            generation: RECORDING_GENERATION.with(std::cell::Cell::get),
4679        };
4680
4681        // Saved this build: not servable within the SAME build...
4682        store_saved_emission(command, Some(saved()));
4683        assert!(!saved_emission_available(command));
4684        // ...servable on the next...
4685        bump_recording_generation();
4686        assert!(saved_emission_available(command));
4687        // ...and expired one build later: only the immediately following
4688        // build may re-emit, so a served frame is never more than one
4689        // frame stale.
4690        bump_recording_generation();
4691        assert!(!saved_emission_available(command));
4692
4693        // A fresh save served on time is CONSUMED by the take: the second
4694        // of two consecutive collapse builds finds nothing to serve.
4695        store_saved_emission(command, Some(saved()));
4696        bump_recording_generation();
4697        assert!(saved_emission_available(command));
4698        assert!(take_saved_emission(command).is_some());
4699        assert!(
4700            !saved_emission_available(command),
4701            "a second serve of one emission must be unconstructible"
4702        );
4703        assert!(take_saved_emission(command).is_none());
4704
4705        // A dead slot universe invalidates the save wholesale.
4706        store_saved_emission(command, Some(saved()));
4707        bump_recording_generation();
4708        set_retained_feed_epoch(Some(78));
4709        assert!(!saved_emission_available(command));
4710        set_retained_feed_epoch(None);
4711        assert!(!saved_emission_available(command));
4712        set_retained_feed_epoch(Some(77));
4713        assert!(saved_emission_available(command));
4714        set_retained_feed_epoch(None);
4715    }
4716}