Skip to main content

cranpose_render_common/
scene_builder.rs

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