Skip to main content

cranpose_render_common/
scene_builder.rs

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