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
1329/// How much of the slack a `TextAlign` puts *before* the text: 0 at the start
1330/// edge, 0.5 centred, 1 at the end edge.
1331///
1332/// Split out because the same fraction has to be applied twice and by two
1333/// different pieces of code. Compose aligns a paragraph **line by line** —
1334/// `TextAlign.Center` centres each line in the paragraph's width, it does not
1335/// centre the paragraph's box in its parent — so the block offset computed
1336/// here and the per-line offset the rasteriser applies inside the block are
1337/// two halves of one rule. They telescope: block at `(box - block) * f`, line
1338/// at `(block - line) * f`, which sums to `(box - line) * f`, exactly the
1339/// offset Compose gives that line. Getting one without the other leaves every
1340/// wrapped continuation line start-aligned under a centred first line.
1341pub fn text_align_fraction(text_style: &TextStyle, text: &str) -> f32 {
1342    let paragraph_style = &text_style.paragraph_style;
1343    let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
1344    let rtl = direction == cranpose_ui::text::ResolvedTextDirection::Rtl;
1345    match paragraph_style.text_align {
1346        TextAlign::Center => 0.5,
1347        TextAlign::End | TextAlign::Right => 1.0,
1348        // `Left` follows the direction here rather than being absolute. That
1349        // is not what Compose means by `TextAlign.Left`, but it is what this
1350        // function has always done and no Wear screen is RTL; changing it is a
1351        // separate question from where a wrapped line starts.
1352        TextAlign::Start | TextAlign::Left | TextAlign::Justify | TextAlign::Unspecified => {
1353            if rtl {
1354                1.0
1355            } else {
1356                0.0
1357            }
1358        }
1359    }
1360}
1361
1362fn resolve_text_horizontal_offset(
1363    text_style: &TextStyle,
1364    text: &str,
1365    content_width: f32,
1366    measured_width: f32,
1367) -> f32 {
1368    let remaining = (content_width - measured_width).max(0.0);
1369    remaining * text_align_fraction(text_style, text)
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use std::cell::RefCell;
1375    use std::rc::Rc;
1376
1377    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1378    use cranpose_ui::text::{
1379        AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
1380    };
1381    use cranpose_ui::{
1382        Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
1383        LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
1384        ScrollState, Size, Spacer, Text, TextStyle,
1385    };
1386    use cranpose_ui_graphics::{
1387        Brush, DrawPrimitive, DrawScope as _, DrawScopeDefault, GraphicsLayer, RenderEffect,
1388    };
1389
1390    use super::*;
1391
1392    fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
1393        for child in &layer.children {
1394            match child {
1395                RenderNode::Primitive(primitive) => {
1396                    let PrimitiveNode::Text(text) = &primitive.node else {
1397                        continue;
1398                    };
1399                    if text.text.text == label {
1400                        return Some(text.text_style.paragraph_style.text_motion);
1401                    }
1402                }
1403                RenderNode::Layer(child_layer) => {
1404                    if let Some(motion) = find_text_motion(child_layer, label) {
1405                        return Some(motion);
1406                    }
1407                }
1408                RenderNode::DrawRun(_) => {}
1409            }
1410        }
1411
1412        None
1413    }
1414
1415    fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
1416        for child in &layer.children {
1417            match child {
1418                RenderNode::Primitive(primitive) => {
1419                    let PrimitiveNode::Text(text) = &primitive.node else {
1420                        continue;
1421                    };
1422                    labels.push(text.text.text.clone());
1423                }
1424                RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
1425                RenderNode::DrawRun(_) => {}
1426            }
1427        }
1428    }
1429
1430    fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
1431        fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
1432            for child in &layer.children {
1433                match child {
1434                    RenderNode::Primitive(primitive) => {
1435                        let PrimitiveNode::Text(text) = &primitive.node else {
1436                            continue;
1437                        };
1438                        if text.text.text == label {
1439                            let quad = transform.map_rect(text.rect);
1440                            let top = quad
1441                                .iter()
1442                                .map(|point| point[1])
1443                                .fold(f32::INFINITY, f32::min);
1444                            return top.is_finite().then_some(top);
1445                        }
1446                    }
1447                    RenderNode::Layer(child_layer) => {
1448                        let child_transform = child_layer.transform_to_parent.then(transform);
1449                        if let Some(top) = search(child_layer, label, child_transform) {
1450                            return Some(top);
1451                        }
1452                    }
1453                    RenderNode::DrawRun(_) => {}
1454                }
1455            }
1456            None
1457        }
1458
1459        search(layer, label, ProjectiveTransform::identity())
1460    }
1461
1462    fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
1463        if layer.node_id == Some(node_id) {
1464            return Some(layer);
1465        }
1466        layer.children.iter().find_map(|child| match child {
1467            RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
1468            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1469        })
1470    }
1471
1472    fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
1473        fn search(
1474            layer: &LayerNode,
1475            node_id: NodeId,
1476            transform: ProjectiveTransform,
1477        ) -> Option<Point> {
1478            if layer.node_id == Some(node_id) {
1479                return Some(transform.map_point(Point::default()));
1480            }
1481            layer.children.iter().find_map(|child| match child {
1482                RenderNode::Layer(child_layer) => search(
1483                    child_layer,
1484                    node_id,
1485                    child_layer.transform_to_parent.then(transform),
1486                ),
1487                RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1488            })
1489        }
1490
1491        search(layer, node_id, ProjectiveTransform::identity())
1492    }
1493
1494    fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1495        if layer.translated_content_context {
1496            return Some(layer.translated_content_offset);
1497        }
1498        for child in &layer.children {
1499            if let RenderNode::Layer(child_layer) = child {
1500                if let Some(offset) = find_translated_content_offset(child_layer) {
1501                    return Some(offset);
1502                }
1503            }
1504        }
1505        None
1506    }
1507
1508    fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1509        layer
1510            .graphics_layer
1511            .render_effect
1512            .as_ref()
1513            .is_some_and(RenderEffect::contains_runtime_shader)
1514            || layer.children.iter().any(|child| match child {
1515                RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1516                RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1517            })
1518    }
1519
1520    fn build_layer_node_for_test(
1521        snapshot: BuildNodeSnapshot,
1522        scale: f32,
1523        has_external_backdrop_input: bool,
1524    ) -> LayerNode {
1525        let app_context = cranpose_ui::AppContext::new();
1526        app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1527    }
1528
1529    fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1530        let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1531            scope.push_recorded(vec![DrawPrimitive::Rect {
1532                rect: Rect {
1533                    x: 3.0,
1534                    y: 4.0,
1535                    width: 20.0,
1536                    height: 8.0,
1537                },
1538                brush: Brush::solid(Color::WHITE),
1539                stroke: None,
1540            }]);
1541        }));
1542
1543        let child = BuildNodeSnapshot {
1544            node_id: 2,
1545            placement: Point { x: 11.0, y: 7.0 },
1546            size: Size {
1547                width: 40.0,
1548                height: 20.0,
1549            },
1550            content_offset: Point::default(),
1551            motion_context_animated: false,
1552            translated_content_context: false,
1553            measured_max_width: None,
1554            resolved_modifiers: ResolvedModifiers::default(),
1555            draw_commands: vec![child_command],
1556            click_actions: vec![],
1557            pointer_inputs: vec![],
1558            clip_to_bounds: false,
1559            annotated_text: None,
1560            text_style: None,
1561            text_layout_options: None,
1562            text_pan: None,
1563            graphics_layer: None,
1564            children: vec![],
1565        };
1566
1567        BuildNodeSnapshot {
1568            node_id: 1,
1569            placement: Point::default(),
1570            size: Size {
1571                width: 80.0,
1572                height: 50.0,
1573            },
1574            content_offset: Point::default(),
1575            motion_context_animated: false,
1576            translated_content_context: false,
1577            measured_max_width: None,
1578            resolved_modifiers: ResolvedModifiers::default(),
1579            draw_commands: vec![],
1580            click_actions: vec![],
1581            pointer_inputs: vec![],
1582            clip_to_bounds: false,
1583            annotated_text: None,
1584            text_style: None,
1585            text_layout_options: None,
1586            text_pan: None,
1587            graphics_layer: Some(GraphicsLayer {
1588                translation_x: tx,
1589                ..GraphicsLayer::default()
1590            }),
1591            children: vec![child],
1592        }
1593    }
1594
1595    #[test]
1596    fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1597        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1598        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1599
1600        let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1601            panic!("expected child layer");
1602        };
1603        let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1604            panic!("expected child layer");
1605        };
1606        let RenderNode::DrawRun(static_run) = &static_child.children[0] else {
1607            panic!("expected draw run");
1608        };
1609        let static_draw = &static_run.primitives[0];
1610        let RenderNode::DrawRun(moved_run) = &moved_child.children[0] else {
1611            panic!("expected draw run");
1612        };
1613        let moved_draw = &moved_run.primitives[0];
1614
1615        assert_ne!(
1616            static_graph.transform_to_parent, moved_graph.transform_to_parent,
1617            "parent transform should encode translation"
1618        );
1619        assert_eq!(
1620            static_draw, moved_draw,
1621            "child local primitive geometry must stay stable under parent translation"
1622        );
1623    }
1624
1625    #[test]
1626    fn stored_content_hash_ignores_parent_translation() {
1627        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1628        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1629
1630        assert_eq!(
1631            static_graph.target_content_hash(),
1632            moved_graph.target_content_hash(),
1633            "parent rigid motion must not invalidate the subtree content hash"
1634        );
1635    }
1636
1637    #[test]
1638    fn parent_content_offset_is_encoded_in_child_transform() {
1639        let child = BuildNodeSnapshot {
1640            node_id: 2,
1641            placement: Point { x: 11.0, y: 7.0 },
1642            size: Size {
1643                width: 40.0,
1644                height: 20.0,
1645            },
1646            content_offset: Point::default(),
1647            motion_context_animated: false,
1648            translated_content_context: false,
1649            measured_max_width: None,
1650            resolved_modifiers: ResolvedModifiers::default(),
1651            draw_commands: vec![],
1652            click_actions: vec![],
1653            pointer_inputs: vec![],
1654            clip_to_bounds: false,
1655            annotated_text: None,
1656            text_style: None,
1657            text_layout_options: None,
1658            text_pan: None,
1659            graphics_layer: None,
1660            children: vec![],
1661        };
1662
1663        let parent = BuildNodeSnapshot {
1664            node_id: 1,
1665            placement: Point::default(),
1666            size: Size {
1667                width: 80.0,
1668                height: 50.0,
1669            },
1670            content_offset: Point { x: 13.0, y: -9.0 },
1671            motion_context_animated: false,
1672            translated_content_context: false,
1673            measured_max_width: None,
1674            resolved_modifiers: ResolvedModifiers::default(),
1675            draw_commands: vec![],
1676            click_actions: vec![],
1677            pointer_inputs: vec![],
1678            clip_to_bounds: false,
1679            annotated_text: None,
1680            text_style: None,
1681            text_layout_options: None,
1682            text_pan: None,
1683            graphics_layer: None,
1684            children: vec![child],
1685        };
1686
1687        let graph = build_layer_node_for_test(parent, 1.0, false);
1688        let RenderNode::Layer(child) = &graph.children[0] else {
1689            panic!("expected child layer");
1690        };
1691
1692        let top_left = child.transform_to_parent.map_point(Point::default());
1693        assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1694    }
1695
1696    #[test]
1697    fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1698        fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1699            let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1700                scope.push_recorded(vec![DrawPrimitive::Rect {
1701                    rect: Rect {
1702                        x: 3.0,
1703                        y: 4.0,
1704                        width: 20.0,
1705                        height: 8.0,
1706                    },
1707                    brush: Brush::solid(Color::WHITE),
1708                    stroke: None,
1709                }]);
1710            }));
1711
1712            let child = BuildNodeSnapshot {
1713                node_id: 2,
1714                placement: Point { x: 11.0, y: 7.0 },
1715                size: Size {
1716                    width: 40.0,
1717                    height: 20.0,
1718                },
1719                content_offset: Point::default(),
1720                motion_context_animated: false,
1721                translated_content_context: false,
1722                measured_max_width: None,
1723                resolved_modifiers: ResolvedModifiers::default(),
1724                draw_commands: vec![child_command],
1725                click_actions: vec![],
1726                pointer_inputs: vec![],
1727                clip_to_bounds: false,
1728                annotated_text: None,
1729                text_style: None,
1730                text_layout_options: None,
1731                text_pan: None,
1732                graphics_layer: None,
1733                children: vec![],
1734            };
1735
1736            BuildNodeSnapshot {
1737                node_id: 1,
1738                placement: Point::default(),
1739                size: Size {
1740                    width: 80.0,
1741                    height: 50.0,
1742                },
1743                content_offset: offset,
1744                motion_context_animated,
1745                translated_content_context: true,
1746                measured_max_width: None,
1747                resolved_modifiers: ResolvedModifiers::default(),
1748                draw_commands: vec![],
1749                click_actions: vec![],
1750                pointer_inputs: vec![],
1751                clip_to_bounds: false,
1752                annotated_text: None,
1753                text_style: None,
1754                text_layout_options: None,
1755                text_pan: None,
1756                graphics_layer: None,
1757                children: vec![child],
1758            }
1759        }
1760
1761        let base = build_layer_node_for_test(
1762            parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
1763            1.0,
1764            false,
1765        );
1766        let moved = build_layer_node_for_test(
1767            parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
1768            1.0,
1769            false,
1770        );
1771        let rested = build_layer_node_for_test(
1772            parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
1773            1.0,
1774            false,
1775        );
1776
1777        let RenderNode::Layer(base_child) = &base.children[0] else {
1778            panic!("expected child layer");
1779        };
1780        let RenderNode::Layer(moved_child) = &moved.children[0] else {
1781            panic!("expected child layer");
1782        };
1783
1784        assert_ne!(
1785            base_child.transform_to_parent.map_point(Point::default()),
1786            moved_child.transform_to_parent.map_point(Point::default()),
1787            "scroll offset still has to move child content visually"
1788        );
1789        assert_eq!(
1790            base_child.target_content_hash(),
1791            moved_child.target_content_hash(),
1792            "child source content identity stays stable when only the parent scroll offset changes"
1793        );
1794        assert_ne!(
1795            base.target_content_hash(),
1796            moved.target_content_hash(),
1797            "a full-surface cache of the scroll viewport must include the scroll offset"
1798        );
1799        assert_ne!(
1800            base.target_content_hash(),
1801            rested.target_content_hash(),
1802            "full-surface cache keys must include active scroll motion policy"
1803        );
1804    }
1805
1806    #[test]
1807    fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
1808        let layer = graphics_layer_with_shaped_clip(
1809            GraphicsLayer::default(),
1810            true,
1811            Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
1812            Rect {
1813                x: 0.0,
1814                y: 0.0,
1815                width: 100.0,
1816                height: 40.0,
1817            },
1818        );
1819
1820        assert!(layer.clip);
1821        assert!(layer.render_effect.is_none());
1822        let LayerShape::Rounded(shape) = layer.shape else {
1823            panic!("rounded clip must be recorded as layer shape");
1824        };
1825        assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
1826        assert!(isolation_reasons(&layer).shape_clip);
1827    }
1828
1829    #[test]
1830    fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
1831        let existing = RenderEffect::blur(3.0);
1832        let layer = graphics_layer_with_shaped_clip(
1833            GraphicsLayer {
1834                render_effect: Some(existing.clone()),
1835                ..GraphicsLayer::default()
1836            },
1837            true,
1838            Some(RoundedCornerShape::uniform(10.0)),
1839            Rect {
1840                x: 0.0,
1841                y: 0.0,
1842                width: 100.0,
1843                height: 40.0,
1844            },
1845        );
1846
1847        let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
1848            panic!("existing effect should chain into rounded clip mask");
1849        };
1850        assert_eq!(*first, existing);
1851        assert!(
1852            matches!(*second, RenderEffect::Shader { .. }),
1853            "rounded mask must be the outer effect"
1854        );
1855    }
1856
1857    #[test]
1858    fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
1859        let mut composition = cranpose_ui::run_test_composition(|| {
1860            cranpose_ui::Box(
1861                Modifier::empty()
1862                    .width(100.0)
1863                    .height(40.0)
1864                    .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
1865                    .clip_to_bounds(),
1866                cranpose_ui::BoxSpec::default(),
1867                || {
1868                    Text("rounded child", Modifier::empty(), TextStyle::default());
1869                },
1870            );
1871        });
1872
1873        let root = composition.root().expect("rounded clip root");
1874        let handle = composition.runtime_handle();
1875        let mut applier = composition.applier_mut();
1876        applier.set_runtime_handle(handle);
1877        applier
1878            .compute_layout(
1879                root,
1880                Size {
1881                    width: 160.0,
1882                    height: 100.0,
1883                },
1884            )
1885            .expect("rounded clip layout");
1886        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1887        applier.clear_runtime_handle();
1888
1889        let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
1890        assert!(rounded_layer.graphics_layer.clip);
1891        assert!(matches!(
1892            rounded_layer.graphics_layer.shape,
1893            LayerShape::Rounded(_)
1894        ));
1895        assert!(rounded_layer.graphics_layer.render_effect.is_none());
1896        assert!(rounded_layer.isolation.shape_clip);
1897        assert!(
1898            !graph_has_runtime_shader_effect(&graph.root),
1899            "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
1900        );
1901    }
1902
1903    #[test]
1904    fn update_graph_from_applier_replaces_dirty_child_layer() {
1905        let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1906            Rc::new(RefCell::new(None));
1907        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1908        let state_holder_for_comp = state_holder.clone();
1909        let child_id_holder_for_comp = child_id_holder.clone();
1910
1911        let mut composition = cranpose_ui::run_test_composition(move || {
1912            let label = cranpose_core::useState(|| "before".to_string());
1913            *state_holder_for_comp.borrow_mut() = Some(label);
1914            let child_id_holder_for_content = child_id_holder_for_comp.clone();
1915            cranpose_ui::Box(
1916                Modifier::empty().size_points(240.0, 80.0),
1917                cranpose_ui::BoxSpec::default(),
1918                move || {
1919                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
1920                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
1921                    Text("stable", Modifier::empty(), TextStyle::default());
1922                },
1923            );
1924        });
1925
1926        let root = composition.root().expect("composition root");
1927        let viewport = Size {
1928            width: 240.0,
1929            height: 80.0,
1930        };
1931        let handle = composition.runtime_handle();
1932        let mut applier = composition.applier_mut();
1933        applier.set_runtime_handle(handle);
1934        applier
1935            .compute_layout(root, viewport)
1936            .expect("initial layout");
1937        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1938        let child_id = child_id_holder
1939            .borrow()
1940            .expect("text child id should be captured");
1941        let initial_transform = find_layer_by_node_id(&graph.root, child_id)
1942            .expect("text child layer")
1943            .transform_to_parent;
1944        applier.clear_runtime_handle();
1945        drop(applier);
1946
1947        let label = state_holder
1948            .borrow()
1949            .as_ref()
1950            .copied()
1951            .expect("label state should be captured");
1952        label.set_value("after".to_string());
1953        composition
1954            .process_invalid_scopes()
1955            .expect("text recomposition");
1956
1957        let handle = composition.runtime_handle();
1958        let mut applier = composition.applier_mut();
1959        applier.set_runtime_handle(handle);
1960        applier
1961            .compute_layout(root, viewport)
1962            .expect("updated layout");
1963        let child_id = child_id_holder
1964            .borrow()
1965            .expect("text child id should remain captured");
1966
1967        assert!(
1968            update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
1969            "dirty child should be replaceable from retained applier state"
1970        );
1971        applier.clear_runtime_handle();
1972
1973        let mut labels = Vec::new();
1974        collect_text_labels(&graph.root, &mut labels);
1975        assert!(
1976            labels.iter().any(|label| label == "after"),
1977            "updated graph should contain refreshed child text, got {labels:?}"
1978        );
1979        assert!(
1980            !labels.iter().any(|label| label == "before"),
1981            "updated graph should not retain stale child text, got {labels:?}"
1982        );
1983        assert!(
1984            labels.iter().any(|label| label == "stable"),
1985            "sibling content should remain present, got {labels:?}"
1986        );
1987        assert_eq!(
1988            find_layer_by_node_id(&graph.root, child_id)
1989                .expect("updated text child layer")
1990                .transform_to_parent,
1991            initial_transform,
1992            "draw-only child replacement must preserve the retained parent placement transform"
1993        );
1994    }
1995
1996    /// The per-frame scene build must publish a node's LIVE composited window
1997    /// rect into its `report_window_rect` sink — even when the layout tree is
1998    /// NOT built (`build_layout_tree: false`, exactly how the app runtime
1999    /// measures). This is the mechanism both bug 2 (a scroll container's
2000    /// `BringIntoViewResponder` viewport rect) and bug 3 (a text field's live
2001    /// `node_origin`, which anchors the overlay selection-handle / menu popups)
2002    /// rely on, since the layout `place` pass never runs in the runtime.
2003    #[test]
2004    fn scene_build_publishes_live_window_rect_without_layout_tree() {
2005        use cranpose_ui::{measure_layout_with_options, Box, BoxSpec, MeasureLayoutOptions};
2006        use std::cell::Cell;
2007
2008        let spacer_before = 120.0_f32;
2009        let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
2010            x: 0.0,
2011            y: 0.0,
2012            width: 0.0,
2013            height: 0.0,
2014        }));
2015        let sink_for_comp = sink.clone();
2016        let mut composition = cranpose_ui::run_test_composition(move || {
2017            let sink = sink_for_comp.clone();
2018            Column(
2019                Modifier::empty().size_points(200.0, 400.0),
2020                ColumnSpec::default(),
2021                move || {
2022                    Spacer(Size {
2023                        width: 200.0,
2024                        height: spacer_before,
2025                    });
2026                    Box(
2027                        Modifier::empty()
2028                            .size_points(200.0, 50.0)
2029                            .report_window_rect(sink.clone()),
2030                        BoxSpec::default(),
2031                        || {},
2032                    );
2033                },
2034            );
2035        });
2036
2037        let root = composition.root().expect("composition root");
2038        let viewport = Size {
2039            width: 200.0,
2040            height: 400.0,
2041        };
2042        let handle = composition.runtime_handle();
2043        let mut applier = composition.applier_mut();
2044        applier.set_runtime_handle(handle);
2045        // Measure like the runtime: DO NOT build the layout tree, so the layout
2046        // `place` pass never writes the sink. Only the scene build can.
2047        measure_layout_with_options(
2048            &mut applier,
2049            root,
2050            viewport,
2051            MeasureLayoutOptions {
2052                collect_semantics: false,
2053                build_layout_tree: false,
2054            },
2055        )
2056        .expect("layout");
2057        // Sanity: nothing has written the sink yet.
2058        assert_eq!(
2059            sink.get().height,
2060            0.0,
2061            "sink must start empty (place disabled)"
2062        );
2063
2064        let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
2065        applier.clear_runtime_handle();
2066
2067        let rect = sink.get();
2068        assert!(
2069            (rect.y - spacer_before).abs() < 0.5,
2070            "scene build must publish the box's live window-y (below the {spacer_before}px \
2071             spacer), got {}",
2072            rect.y
2073        );
2074        assert!(
2075            rect.width > 0.0 && rect.height > 0.0,
2076            "scene build must publish a non-empty window rect, got {rect:?}"
2077        );
2078    }
2079
2080    #[test]
2081    fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
2082        let mut graph = RenderGraph {
2083            root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
2084        };
2085        let mut applier = MemoryApplier::new();
2086
2087        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
2088
2089        assert_eq!(
2090            report,
2091            GraphUpdateReport {
2092                applied: false,
2093                hit_graph_dirty: true,
2094            },
2095            "dirty child graph updates must not report success when the replacement cannot be rebuilt"
2096        );
2097    }
2098
2099    #[test]
2100    fn update_graph_from_applier_refreshes_scroll_content_offset() {
2101        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2102        let scroll_holder_for_comp = scroll_holder.clone();
2103
2104        let mut composition = cranpose_ui::run_test_composition(move || {
2105            let scroll_state =
2106                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2107            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2108            Column(
2109                Modifier::empty()
2110                    .size_points(240.0, 120.0)
2111                    .vertical_scroll(scroll_state, false),
2112                ColumnSpec::default(),
2113                || {
2114                    Text("scroll top", Modifier::empty(), TextStyle::default());
2115                    Spacer(Size {
2116                        width: 0.0,
2117                        height: 160.0,
2118                    });
2119                    Text("scroll target", Modifier::empty(), TextStyle::default());
2120                },
2121            );
2122        });
2123
2124        let root = composition.root().expect("composition root");
2125        let viewport = Size {
2126            width: 240.0,
2127            height: 120.0,
2128        };
2129        let handle = composition.runtime_handle();
2130        let mut applier = composition.applier_mut();
2131        applier.set_runtime_handle(handle);
2132        applier
2133            .compute_layout(root, viewport)
2134            .expect("initial scroll layout");
2135        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2136        let initial_target_top =
2137            find_text_top(&graph.root, "scroll target").expect("initial target text");
2138        applier.clear_runtime_handle();
2139        drop(applier);
2140
2141        let scroll_state = scroll_holder
2142            .borrow()
2143            .as_ref()
2144            .cloned()
2145            .expect("scroll state should be captured");
2146        let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
2147        assert!(consumed_scroll > 0.0, "test scroll must be consumed");
2148        let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
2149        assert!(
2150            !dirty_nodes.is_empty(),
2151            "scroll state invalidation must schedule scoped layout graph update"
2152        );
2153
2154        let handle = composition.runtime_handle();
2155        let mut applier = composition.applier_mut();
2156        applier.set_runtime_handle(handle);
2157        applier
2158            .compute_layout(root, viewport)
2159            .expect("scrolled layout");
2160        let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
2161        applier.clear_runtime_handle();
2162
2163        assert!(report.applied, "scroll graph update should apply in place");
2164        let updated_target_top =
2165            find_text_top(&graph.root, "scroll target").expect("updated target text");
2166        assert!(
2167            updated_target_top < initial_target_top - consumed_scroll * 0.75,
2168            "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
2169        );
2170    }
2171
2172    #[test]
2173    fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
2174        let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2175            Rc::new(RefCell::new(None));
2176        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2177        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2178        let label_holder_for_comp = label_holder.clone();
2179        let scroll_holder_for_comp = scroll_holder.clone();
2180        let child_id_holder_for_comp = child_id_holder.clone();
2181
2182        let mut composition = cranpose_ui::run_test_composition(move || {
2183            let label = cranpose_core::useState(|| "scrolled child before".to_string());
2184            let scroll_state =
2185                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2186            *label_holder_for_comp.borrow_mut() = Some(label);
2187            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2188            let child_id_holder_for_content = child_id_holder_for_comp.clone();
2189            Column(
2190                Modifier::empty()
2191                    .size_points(260.0, 90.0)
2192                    .vertical_scroll(scroll_state, false),
2193                ColumnSpec::default(),
2194                move || {
2195                    Spacer(Size {
2196                        width: 0.0,
2197                        height: 24.0,
2198                    });
2199                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
2200                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
2201                    Spacer(Size {
2202                        width: 0.0,
2203                        height: 220.0,
2204                    });
2205                },
2206            );
2207        });
2208
2209        let root = composition.root().expect("composition root");
2210        let viewport = Size {
2211            width: 260.0,
2212            height: 90.0,
2213        };
2214        let handle = composition.runtime_handle();
2215        let mut applier = composition.applier_mut();
2216        applier.set_runtime_handle(handle);
2217        applier
2218            .compute_layout(root, viewport)
2219            .expect("initial layout");
2220        applier.clear_runtime_handle();
2221        drop(applier);
2222
2223        let scroll_state = scroll_holder
2224            .borrow()
2225            .as_ref()
2226            .cloned()
2227            .expect("scroll state should be captured");
2228        assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
2229
2230        let handle = composition.runtime_handle();
2231        let mut applier = composition.applier_mut();
2232        applier.set_runtime_handle(handle);
2233        applier
2234            .compute_layout(root, viewport)
2235            .expect("scrolled layout");
2236        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2237        let child_id = child_id_holder
2238            .borrow()
2239            .expect("text child id should be captured");
2240        let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
2241            .expect("scrolled child layer")
2242            .transform_to_parent;
2243        applier.clear_runtime_handle();
2244        drop(applier);
2245
2246        let label = label_holder
2247            .borrow()
2248            .as_ref()
2249            .copied()
2250            .expect("label state should be captured");
2251        label.set_value("scrolled child after".to_string());
2252        composition
2253            .process_invalid_scopes()
2254            .expect("text recomposition");
2255
2256        let handle = composition.runtime_handle();
2257        let mut applier = composition.applier_mut();
2258        applier.set_runtime_handle(handle);
2259        applier
2260            .compute_layout(root, viewport)
2261            .expect("updated scrolled layout");
2262        let child_id = child_id_holder
2263            .borrow()
2264            .expect("text child id should remain captured");
2265        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
2266        applier.clear_runtime_handle();
2267
2268        assert!(report.applied, "dirty child graph update should apply");
2269        let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
2270        assert_eq!(
2271            updated.transform_to_parent, scrolled_transform,
2272            "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
2273        );
2274        let mut labels = Vec::new();
2275        collect_text_labels(&graph.root, &mut labels);
2276        assert!(
2277            labels.iter().any(|label| label == "scrolled child after"),
2278            "updated graph should contain refreshed text, got {labels:?}"
2279        );
2280    }
2281
2282    #[test]
2283    fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
2284        let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2285            Rc::new(RefCell::new(None));
2286        let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2287        let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2288        let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2289        let alpha_holder_for_comp = alpha_holder.clone();
2290        let scroll_holder_for_comp = scroll_holder.clone();
2291        let underlay_id_holder_for_comp = underlay_id_holder.clone();
2292        let overlay_id_holder_for_comp = overlay_id_holder.clone();
2293
2294        let mut composition = cranpose_ui::run_test_composition(move || {
2295            let alpha = cranpose_core::useState(|| 1.0f32);
2296            let scroll_state =
2297                cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2298            *alpha_holder_for_comp.borrow_mut() = Some(alpha);
2299            *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2300            let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
2301            let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
2302            Column(
2303                Modifier::empty()
2304                    .size_points(260.0, 120.0)
2305                    .vertical_scroll(scroll_state, false),
2306                ColumnSpec::default(),
2307                move || {
2308                    Spacer(Size {
2309                        width: 0.0,
2310                        height: 180.0,
2311                    });
2312                    cranpose_ui::Box(
2313                        Modifier::empty().size_points(188.0, 88.0),
2314                        cranpose_ui::BoxSpec::default(),
2315                        {
2316                            let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
2317                            let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
2318                            move || {
2319                                let underlay_id = cranpose_ui::Box(
2320                                    Modifier::empty().size_points(188.0, 88.0),
2321                                    cranpose_ui::BoxSpec::default(),
2322                                    || {
2323                                        Text(
2324                                            "UNDERLAY CONTENT",
2325                                            Modifier::empty().absolute_offset(12.0, 8.0),
2326                                            TextStyle::default(),
2327                                        );
2328                                    },
2329                                );
2330                                *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
2331                                let overlay_id = cranpose_ui::Box(
2332                                    Modifier::empty().size_points(188.0, 88.0).graphics_layer(
2333                                        move || GraphicsLayer {
2334                                            alpha: alpha.get(),
2335                                            ..GraphicsLayer::default()
2336                                        },
2337                                    ),
2338                                    cranpose_ui::BoxSpec::default(),
2339                                    || {
2340                                        Text(
2341                                            "TOP LAYER",
2342                                            Modifier::empty().absolute_offset(74.0, 39.6),
2343                                            TextStyle::default(),
2344                                        );
2345                                    },
2346                                );
2347                                *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
2348                            }
2349                        },
2350                    );
2351                    Spacer(Size {
2352                        width: 0.0,
2353                        height: 280.0,
2354                    });
2355                },
2356            );
2357        });
2358
2359        let root = composition.root().expect("composition root");
2360        let viewport = Size {
2361            width: 260.0,
2362            height: 120.0,
2363        };
2364        let handle = composition.runtime_handle();
2365        let mut applier = composition.applier_mut();
2366        applier.set_runtime_handle(handle);
2367        applier
2368            .compute_layout(root, viewport)
2369            .expect("initial layout");
2370        applier.clear_runtime_handle();
2371        drop(applier);
2372
2373        let scroll_state = scroll_holder
2374            .borrow()
2375            .as_ref()
2376            .cloned()
2377            .expect("scroll state should be captured");
2378        assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
2379
2380        let handle = composition.runtime_handle();
2381        let mut applier = composition.applier_mut();
2382        applier.set_runtime_handle(handle);
2383        applier
2384            .compute_layout(root, viewport)
2385            .expect("scrolled layout");
2386        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2387        applier.clear_runtime_handle();
2388        drop(applier);
2389
2390        let underlay_id = underlay_id_holder
2391            .borrow()
2392            .expect("underlay id should be captured");
2393        let overlay_id = overlay_id_holder
2394            .borrow()
2395            .expect("overlay id should be captured");
2396        let scrolled_underlay_origin =
2397            find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
2398        let scrolled_overlay_origin =
2399            find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
2400        assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
2401
2402        let alpha = alpha_holder
2403            .borrow()
2404            .as_ref()
2405            .copied()
2406            .expect("alpha state should be captured");
2407        alpha.set_value(0.35);
2408
2409        let handle = composition.runtime_handle();
2410        let mut applier = composition.applier_mut();
2411        applier.set_runtime_handle(handle);
2412        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
2413        applier.clear_runtime_handle();
2414
2415        assert!(report.applied, "dirty overlay graph update should apply");
2416        let updated_underlay_origin =
2417            find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
2418        let updated_overlay_origin =
2419            find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
2420        assert_eq!(
2421            updated_underlay_origin, scrolled_underlay_origin,
2422            "stable underlay must keep its scrolled origin"
2423        );
2424        assert_eq!(
2425            updated_overlay_origin, updated_underlay_origin,
2426            "dirty overlay graphics layer must stay aligned with its stable underlay"
2427        );
2428    }
2429
2430    #[test]
2431    fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
2432        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2433            Rc::new(RefCell::new(None));
2434        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2435        let offset_holder_for_comp = offset_holder.clone();
2436        let node_id_holder_for_comp = node_id_holder.clone();
2437
2438        let mut composition = cranpose_ui::run_test_composition(move || {
2439            let offset = cranpose_core::useState(|| 0.0f32);
2440            *offset_holder_for_comp.borrow_mut() = Some(offset);
2441            let node_id = cranpose_ui::Box(
2442                Modifier::empty()
2443                    .size_points(40.0, 20.0)
2444                    .graphics_layer(move || GraphicsLayer {
2445                        translation_x: offset.get(),
2446                        ..GraphicsLayer::default()
2447                    }),
2448                cranpose_ui::BoxSpec::default(),
2449                || {},
2450            );
2451            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2452        });
2453
2454        let root = composition.root().expect("composition root");
2455        let viewport = Size {
2456            width: 120.0,
2457            height: 80.0,
2458        };
2459        let handle = composition.runtime_handle();
2460        let mut applier = composition.applier_mut();
2461        applier.set_runtime_handle(handle);
2462        applier
2463            .compute_layout(root, viewport)
2464            .expect("initial layout");
2465        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2466        let node_id = node_id_holder
2467            .borrow()
2468            .expect("graphics layer node id should be captured");
2469        let initial_origin = find_layer_by_node_id(&graph.root, node_id)
2470            .expect("initial graphics layer")
2471            .transform_to_parent
2472            .map_point(Point::default());
2473        applier.clear_runtime_handle();
2474        drop(applier);
2475
2476        let offset = offset_holder
2477            .borrow()
2478            .as_ref()
2479            .copied()
2480            .expect("offset state should be captured");
2481        offset.set_value(32.0);
2482
2483        let handle = composition.runtime_handle();
2484        let mut applier = composition.applier_mut();
2485        applier.set_runtime_handle(handle);
2486        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2487        assert!(
2488            report.applied,
2489            "dirty graphics layer should be replaceable from retained applier state"
2490        );
2491        assert!(
2492            !report.hit_graph_dirty,
2493            "a moved visual-only layer should not force hit graph refresh"
2494        );
2495        applier.clear_runtime_handle();
2496
2497        let updated_origin = find_layer_by_node_id(&graph.root, node_id)
2498            .expect("updated graphics layer")
2499            .transform_to_parent
2500            .map_point(Point::default());
2501        assert!(
2502            (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
2503            "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
2504        );
2505    }
2506
2507    #[test]
2508    fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
2509        let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2510            Rc::new(RefCell::new(None));
2511        let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2512        let offset_holder_for_comp = offset_holder.clone();
2513        let node_id_holder_for_comp = node_id_holder.clone();
2514
2515        let mut composition = cranpose_ui::run_test_composition(move || {
2516            let offset = cranpose_core::useState(|| 0.0f32);
2517            *offset_holder_for_comp.borrow_mut() = Some(offset);
2518            let node_id = cranpose_ui::Box(
2519                Modifier::empty()
2520                    .size_points(40.0, 20.0)
2521                    .graphics_layer(move || GraphicsLayer {
2522                        translation_x: offset.get(),
2523                        ..GraphicsLayer::default()
2524                    })
2525                    .clickable(|_| {}),
2526                cranpose_ui::BoxSpec::default(),
2527                || {},
2528            );
2529            *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2530        });
2531
2532        let root = composition.root().expect("composition root");
2533        let viewport = Size {
2534            width: 120.0,
2535            height: 80.0,
2536        };
2537        let handle = composition.runtime_handle();
2538        let mut applier = composition.applier_mut();
2539        applier.set_runtime_handle(handle);
2540        applier
2541            .compute_layout(root, viewport)
2542            .expect("initial layout");
2543        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2544        let node_id = node_id_holder
2545            .borrow()
2546            .expect("graphics layer node id should be captured");
2547        applier.clear_runtime_handle();
2548        drop(applier);
2549
2550        let offset = offset_holder
2551            .borrow()
2552            .as_ref()
2553            .copied()
2554            .expect("offset state should be captured");
2555        offset.set_value(32.0);
2556
2557        let handle = composition.runtime_handle();
2558        let mut applier = composition.applier_mut();
2559        applier.set_runtime_handle(handle);
2560        let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2561        applier.clear_runtime_handle();
2562
2563        assert!(
2564            report.applied,
2565            "dirty clickable graphics layer should be replaceable from retained applier state"
2566        );
2567        assert!(
2568            report.hit_graph_dirty,
2569            "moved clickable layers must refresh hit geometry"
2570        );
2571    }
2572
2573    #[test]
2574    fn overlay_draw_commands_are_tagged_after_children() {
2575        let child = BuildNodeSnapshot {
2576            node_id: 2,
2577            placement: Point { x: 4.0, y: 5.0 },
2578            size: Size {
2579                width: 20.0,
2580                height: 10.0,
2581            },
2582            content_offset: Point::default(),
2583            motion_context_animated: false,
2584            translated_content_context: false,
2585            measured_max_width: None,
2586            resolved_modifiers: ResolvedModifiers::default(),
2587            draw_commands: vec![],
2588            click_actions: vec![],
2589            pointer_inputs: vec![],
2590            clip_to_bounds: false,
2591            annotated_text: None,
2592            text_style: None,
2593            text_layout_options: None,
2594            text_pan: None,
2595            graphics_layer: None,
2596            children: vec![],
2597        };
2598        let behind = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
2599            scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2600                rect: Rect {
2601                    x: 1.0,
2602                    y: 2.0,
2603                    width: 8.0,
2604                    height: 6.0,
2605                },
2606                brush: Brush::solid(Color::WHITE),
2607                stroke: None,
2608            }]);
2609        }));
2610        let overlay = DrawCommand::Overlay(Rc::new(|scope: &mut DrawScopeDefault| {
2611            scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2612                rect: Rect {
2613                    x: 3.0,
2614                    y: 1.0,
2615                    width: 5.0,
2616                    height: 4.0,
2617                },
2618                brush: Brush::solid(Color::BLACK),
2619                stroke: None,
2620            }]);
2621        }));
2622
2623        let parent = BuildNodeSnapshot {
2624            node_id: 1,
2625            placement: Point::default(),
2626            size: Size {
2627                width: 80.0,
2628                height: 50.0,
2629            },
2630            content_offset: Point::default(),
2631            motion_context_animated: false,
2632            translated_content_context: false,
2633            measured_max_width: None,
2634            resolved_modifiers: ResolvedModifiers::default(),
2635            draw_commands: vec![behind, overlay],
2636            click_actions: vec![],
2637            pointer_inputs: vec![],
2638            clip_to_bounds: false,
2639            annotated_text: None,
2640            text_style: None,
2641            text_layout_options: None,
2642            text_pan: None,
2643            graphics_layer: None,
2644            children: vec![child],
2645        };
2646
2647        let graph = build_layer_node_for_test(parent, 1.0, false);
2648        let RenderNode::DrawRun(behind) = &graph.children[0] else {
2649            panic!("expected before-children draw run");
2650        };
2651        let RenderNode::Layer(_) = &graph.children[1] else {
2652            panic!("expected child layer");
2653        };
2654        let RenderNode::DrawRun(overlay) = &graph.children[2] else {
2655            panic!("expected after-children draw run");
2656        };
2657
2658        assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
2659        assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
2660    }
2661
2662    /// The recording registry's whole contract: a command re-recording on a
2663    /// rebuild reuses the buffer of a recording the graph has let go of, and
2664    /// never writes through one anything else still shares.
2665    #[test]
2666    fn command_recordings_reuse_buffers_across_rebuilds() {
2667        let snapshot = || BuildNodeSnapshot {
2668            node_id: 7001,
2669            placement: Point::default(),
2670            size: Size {
2671                width: 40.0,
2672                height: 20.0,
2673            },
2674            content_offset: Point::default(),
2675            motion_context_animated: false,
2676            translated_content_context: false,
2677            measured_max_width: None,
2678            resolved_modifiers: ResolvedModifiers::default(),
2679            draw_commands: vec![DrawCommand::Behind(Rc::new(
2680                |scope: &mut DrawScopeDefault| {
2681                    scope.draw_rect_at(
2682                        Rect {
2683                            x: 1.0,
2684                            y: 2.0,
2685                            width: 8.0,
2686                            height: 6.0,
2687                        },
2688                        Brush::solid(Color::WHITE),
2689                    );
2690                },
2691            ))],
2692            click_actions: vec![],
2693            pointer_inputs: vec![],
2694            clip_to_bounds: false,
2695            annotated_text: None,
2696            text_style: None,
2697            text_layout_options: None,
2698            text_pan: None,
2699            graphics_layer: None,
2700            children: vec![],
2701        };
2702        fn run_of(layer: &LayerNode) -> &DrawRunNode {
2703            let RenderNode::DrawRun(run) = &layer.children[0] else {
2704                panic!("expected draw run");
2705            };
2706            run
2707        }
2708
2709        let graph_a = build_layer_node_for_test(snapshot(), 1.0, false);
2710        let ptr_a = run_of(&graph_a).primitives.as_ptr();
2711
2712        // Graph A is still alive, so its buffer must not be lent out.
2713        let graph_b = build_layer_node_for_test(snapshot(), 1.0, false);
2714        let ptr_b = run_of(&graph_b).primitives.as_ptr();
2715        assert_ne!(
2716            ptr_a, ptr_b,
2717            "a buffer a live graph shares must never be recorded into"
2718        );
2719        assert_eq!(
2720            run_of(&graph_a).primitives,
2721            run_of(&graph_b).primitives,
2722            "re-recording must reproduce the recording"
2723        );
2724
2725        // With graph A gone, its buffer is the registry's to lend again. The
2726        // registry still holds a handle, so the allocator cannot have
2727        // recycled this address: pointer equality here is reuse, not luck.
2728        drop(graph_a);
2729        let graph_c = build_layer_node_for_test(snapshot(), 1.0, false);
2730        assert_eq!(
2731            run_of(&graph_c).primitives.as_ptr(),
2732            ptr_a,
2733            "the released buffer must be reused for the next recording"
2734        );
2735
2736        // A recording shared outside the graph (renderer caches, tests)
2737        // keeps its buffer out of circulation even after the node drops.
2738        let held = std::rc::Rc::clone(&run_of(&graph_c).primitives);
2739        drop(graph_c);
2740        let graph_d = build_layer_node_for_test(snapshot(), 1.0, false);
2741        let ptr_d = run_of(&graph_d).primitives.as_ptr();
2742        assert_ne!(ptr_d, held.as_ptr());
2743        assert_ne!(ptr_d, run_of(&graph_b).primitives.as_ptr());
2744    }
2745
2746    #[test]
2747    fn stored_content_hash_changes_when_child_transform_changes() {
2748        let child = BuildNodeSnapshot {
2749            node_id: 2,
2750            placement: Point { x: 4.0, y: 5.0 },
2751            size: Size {
2752                width: 20.0,
2753                height: 10.0,
2754            },
2755            content_offset: Point::default(),
2756            motion_context_animated: false,
2757            translated_content_context: false,
2758            measured_max_width: None,
2759            resolved_modifiers: ResolvedModifiers::default(),
2760            draw_commands: vec![],
2761            click_actions: vec![],
2762            pointer_inputs: vec![],
2763            clip_to_bounds: false,
2764            annotated_text: None,
2765            text_style: None,
2766            text_layout_options: None,
2767            text_pan: None,
2768            graphics_layer: None,
2769            children: vec![],
2770        };
2771        let mut moved_child = child.clone();
2772        moved_child.placement.x += 7.0;
2773
2774        let parent = BuildNodeSnapshot {
2775            node_id: 1,
2776            placement: Point::default(),
2777            size: Size {
2778                width: 80.0,
2779                height: 50.0,
2780            },
2781            content_offset: Point::default(),
2782            motion_context_animated: false,
2783            translated_content_context: false,
2784            measured_max_width: None,
2785            resolved_modifiers: ResolvedModifiers::default(),
2786            draw_commands: vec![],
2787            click_actions: vec![],
2788            pointer_inputs: vec![],
2789            clip_to_bounds: false,
2790            annotated_text: None,
2791            text_style: None,
2792            text_layout_options: None,
2793            text_pan: None,
2794            graphics_layer: None,
2795            children: vec![child],
2796        };
2797        let moved_parent = BuildNodeSnapshot {
2798            children: vec![moved_child],
2799            ..parent.clone()
2800        };
2801
2802        let static_graph = build_layer_node_for_test(parent, 1.0, false);
2803        let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
2804
2805        assert_ne!(
2806            static_graph.target_content_hash(),
2807            moved_graph.target_content_hash(),
2808            "moving a child within the parent must invalidate the parent subtree hash"
2809        );
2810    }
2811
2812    #[test]
2813    fn stored_effect_hash_tracks_local_effect_only() {
2814        let base = BuildNodeSnapshot {
2815            node_id: 1,
2816            placement: Point::default(),
2817            size: Size {
2818                width: 80.0,
2819                height: 50.0,
2820            },
2821            content_offset: Point::default(),
2822            motion_context_animated: false,
2823            translated_content_context: false,
2824            measured_max_width: None,
2825            resolved_modifiers: ResolvedModifiers::default(),
2826            draw_commands: vec![],
2827            click_actions: vec![],
2828            pointer_inputs: vec![],
2829            clip_to_bounds: false,
2830            annotated_text: None,
2831            text_style: None,
2832            text_layout_options: None,
2833            text_pan: None,
2834            graphics_layer: None,
2835            children: vec![],
2836        };
2837        let mut effected = base.clone();
2838        effected.graphics_layer = Some(GraphicsLayer {
2839            render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
2840            ..GraphicsLayer::default()
2841        });
2842
2843        let base_graph = build_layer_node_for_test(base, 1.0, false);
2844        let effected_graph = build_layer_node_for_test(effected, 1.0, false);
2845
2846        assert_eq!(
2847            base_graph.target_content_hash(),
2848            effected_graph.target_content_hash(),
2849            "post-processing effect parameters belong to the effect hash, not the content hash"
2850        );
2851        assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
2852    }
2853
2854    #[test]
2855    fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
2856        let mut text_style = TextStyle::default();
2857        text_style.paragraph_style.text_align = TextAlign::Start;
2858        text_style.paragraph_style.text_direction = TextDirection::Rtl;
2859        text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
2860
2861        let snapshot = BuildNodeSnapshot {
2862            node_id: 1,
2863            placement: Point::default(),
2864            size: Size {
2865                width: 180.0,
2866                height: 48.0,
2867            },
2868            content_offset: Point::default(),
2869            motion_context_animated: false,
2870            translated_content_context: false,
2871            measured_max_width: Some(180.0),
2872            resolved_modifiers: ResolvedModifiers::default(),
2873            draw_commands: vec![],
2874            click_actions: vec![],
2875            pointer_inputs: vec![],
2876            clip_to_bounds: false,
2877            annotated_text: Some(AnnotatedString::from("rtl")),
2878            text_style: Some(text_style),
2879            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2880                overflow: cranpose_ui::TextOverflow::Clip,
2881                ..Default::default()
2882            }),
2883            text_pan: None,
2884            graphics_layer: None,
2885            children: vec![],
2886        };
2887
2888        let graph = build_layer_node_for_test(snapshot, 1.0, false);
2889        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2890            panic!("expected text primitive");
2891        };
2892        let PrimitiveNode::Text(text) = &text_primitive.node else {
2893            panic!("expected text primitive");
2894        };
2895        let clip = text
2896            .clip
2897            .expect("clipped overflow should produce a clip rect");
2898
2899        assert!(
2900            text.rect.x > 0.0,
2901            "RTL start alignment should shift the text rect within the available width"
2902        );
2903        assert!(
2904            clip.y < text.rect.y,
2905            "baseline shift must expand the clip upward so superscript glyphs are preserved"
2906        );
2907        assert!(
2908            clip.intersect(text.rect).is_some(),
2909            "the clip rect must intersect the shifted text draw rect"
2910        );
2911    }
2912
2913    #[test]
2914    fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
2915        let snapshot = BuildNodeSnapshot {
2916            node_id: 1,
2917            placement: Point::default(),
2918            size: Size {
2919                width: 320.0,
2920                height: 48.0,
2921            },
2922            content_offset: Point::default(),
2923            motion_context_animated: false,
2924            translated_content_context: false,
2925            measured_max_width: Some(320.0),
2926            resolved_modifiers: ResolvedModifiers::default(),
2927            draw_commands: vec![],
2928            click_actions: vec![],
2929            pointer_inputs: vec![],
2930            clip_to_bounds: false,
2931            annotated_text: Some(AnnotatedString::from("short")),
2932            text_style: Some(TextStyle::default()),
2933            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2934                overflow: cranpose_ui::TextOverflow::Clip,
2935                ..Default::default()
2936            }),
2937            text_pan: None,
2938            graphics_layer: None,
2939            children: vec![],
2940        };
2941
2942        let graph = build_layer_node_for_test(snapshot, 1.0, false);
2943        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2944            panic!("expected text primitive");
2945        };
2946        let PrimitiveNode::Text(text) = &text_primitive.node else {
2947            panic!("expected text primitive");
2948        };
2949        let clip = text.clip.expect("clipped text should keep a clip rect");
2950
2951        assert!(
2952            text.rect.width < 320.0,
2953            "text raster bounds should track measured glyph width instead of full content width"
2954        );
2955        assert_eq!(
2956            clip.width, 322.0,
2957            "text clip should still preserve the full content box plus clip padding"
2958        );
2959    }
2960
2961    /// Single-line text fields provide a pan resolver: the glyphs must be
2962    /// laid out unconstrained (no wrapping), shifted left by the pan offset,
2963    /// and clipped to the field bounds.
2964    #[test]
2965    fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
2966        let pan_offset = 25.0_f32;
2967        let field_width = 80.0_f32;
2968        let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
2969        let viewports = resolved_viewports.clone();
2970        let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
2971            node_id: 1,
2972            placement: Point::default(),
2973            size: Size {
2974                width: field_width,
2975                height: 24.0,
2976            },
2977            content_offset: Point::default(),
2978            motion_context_animated: false,
2979            translated_content_context: false,
2980            measured_max_width: Some(field_width),
2981            resolved_modifiers: ResolvedModifiers::default(),
2982            draw_commands: vec![],
2983            click_actions: vec![],
2984            pointer_inputs: vec![],
2985            clip_to_bounds: false,
2986            annotated_text: Some(AnnotatedString::from(
2987                "a very long single line of text that cannot fit",
2988            )),
2989            text_style: Some(TextStyle::default()),
2990            text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
2991            text_pan,
2992            graphics_layer: None,
2993            children: vec![],
2994        };
2995
2996        let text_node = |snapshot: BuildNodeSnapshot| {
2997            let graph = build_layer_node_for_test(snapshot, 1.0, false);
2998            let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2999                panic!("expected text primitive");
3000            };
3001            let PrimitiveNode::Text(text) = &text_primitive.node else {
3002                panic!("expected text primitive");
3003            };
3004            (**text).clone()
3005        };
3006
3007        let unpanned = text_node(make_snapshot(None));
3008        let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
3009            viewports.borrow_mut().push(viewport);
3010            pan_offset
3011        }))));
3012
3013        assert_eq!(
3014            resolved_viewports.borrow().as_slice(),
3015            &[field_width],
3016            "the pan resolver must receive the content viewport width"
3017        );
3018        assert_eq!(
3019            panned.rect.x, -pan_offset,
3020            "text glyphs must shift left by the pan offset"
3021        );
3022        assert!(
3023            panned.rect.width > field_width,
3024            "panned single-line text must be laid out unconstrained, got {}",
3025            panned.rect.width
3026        );
3027        assert!(
3028            panned.rect.width >= unpanned.rect.width,
3029            "unconstrained layout must not be narrower than wrapped layout"
3030        );
3031        assert!(
3032            panned.rect.height <= unpanned.rect.height,
3033            "single-line layout must not wrap onto extra lines"
3034        );
3035        let clip = panned
3036            .clip
3037            .expect("panned text field must clip to field bounds");
3038        assert!(
3039            clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
3040            "clip must not extend past the field bounds, got {clip:?}"
3041        );
3042    }
3043
3044    #[test]
3045    fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
3046        let child = BuildNodeSnapshot {
3047            node_id: 2,
3048            placement: Point { x: 11.0, y: 7.0 },
3049            size: Size {
3050                width: 120.0,
3051                height: 32.0,
3052            },
3053            content_offset: Point::default(),
3054            motion_context_animated: false,
3055            translated_content_context: false,
3056            measured_max_width: Some(120.0),
3057            resolved_modifiers: ResolvedModifiers::default(),
3058            draw_commands: vec![],
3059            click_actions: vec![],
3060            pointer_inputs: vec![],
3061            clip_to_bounds: false,
3062            annotated_text: Some(AnnotatedString::from("scrolling")),
3063            text_style: Some(TextStyle::default()),
3064            text_layout_options: None,
3065            text_pan: None,
3066            graphics_layer: None,
3067            children: vec![],
3068        };
3069        let parent = BuildNodeSnapshot {
3070            node_id: 1,
3071            placement: Point::default(),
3072            size: Size {
3073                width: 160.0,
3074                height: 64.0,
3075            },
3076            content_offset: Point { x: 0.0, y: -18.5 },
3077            motion_context_animated: false,
3078            translated_content_context: true,
3079            measured_max_width: None,
3080            resolved_modifiers: ResolvedModifiers::default(),
3081            draw_commands: vec![],
3082            click_actions: vec![],
3083            pointer_inputs: vec![],
3084            clip_to_bounds: false,
3085            annotated_text: None,
3086            text_style: None,
3087            text_layout_options: None,
3088            text_pan: None,
3089            graphics_layer: None,
3090            children: vec![child],
3091        };
3092
3093        let graph = build_layer_node_for_test(parent, 1.0, false);
3094        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3095            panic!("expected child layer");
3096        };
3097        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3098            panic!("expected text primitive");
3099        };
3100        let PrimitiveNode::Text(text) = &text_primitive.node else {
3101            panic!("expected text primitive");
3102        };
3103
3104        assert_eq!(text.text_style.paragraph_style.text_motion, None);
3105        assert!(!child_layer.motion_context_animated);
3106    }
3107
3108    #[test]
3109    fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
3110        let child = BuildNodeSnapshot {
3111            node_id: 2,
3112            placement: Point { x: 11.0, y: 7.0 },
3113            size: Size {
3114                width: 120.0,
3115                height: 32.0,
3116            },
3117            content_offset: Point::default(),
3118            motion_context_animated: false,
3119            translated_content_context: false,
3120            measured_max_width: Some(120.0),
3121            resolved_modifiers: ResolvedModifiers::default(),
3122            draw_commands: vec![],
3123            click_actions: vec![],
3124            pointer_inputs: vec![],
3125            clip_to_bounds: false,
3126            annotated_text: Some(AnnotatedString::from("scrolling")),
3127            text_style: Some(TextStyle::default()),
3128            text_layout_options: None,
3129            text_pan: None,
3130            graphics_layer: None,
3131            children: vec![],
3132        };
3133        let parent = BuildNodeSnapshot {
3134            node_id: 1,
3135            placement: Point::default(),
3136            size: Size {
3137                width: 160.0,
3138                height: 64.0,
3139            },
3140            content_offset: Point { x: 0.0, y: -18.0 },
3141            motion_context_animated: false,
3142            translated_content_context: false,
3143            measured_max_width: None,
3144            resolved_modifiers: ResolvedModifiers::default(),
3145            draw_commands: vec![],
3146            click_actions: vec![],
3147            pointer_inputs: vec![],
3148            clip_to_bounds: false,
3149            annotated_text: None,
3150            text_style: None,
3151            text_layout_options: None,
3152            text_pan: None,
3153            graphics_layer: None,
3154            children: vec![child],
3155        };
3156
3157        let graph = build_layer_node_for_test(parent, 1.0, false);
3158        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3159            panic!("expected child layer");
3160        };
3161        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3162            panic!("expected text primitive");
3163        };
3164        let PrimitiveNode::Text(text) = &text_primitive.node else {
3165            panic!("expected text primitive");
3166        };
3167
3168        assert_eq!(
3169            text.text_style.paragraph_style.text_motion, None,
3170            "content_offset alone must not force text onto the translated-content motion path"
3171        );
3172        assert!(!child_layer.motion_context_animated);
3173    }
3174
3175    #[test]
3176    fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
3177        let child = BuildNodeSnapshot {
3178            node_id: 2,
3179            placement: Point { x: 11.0, y: 7.0 },
3180            size: Size {
3181                width: 120.0,
3182                height: 32.0,
3183            },
3184            content_offset: Point::default(),
3185            motion_context_animated: false,
3186            translated_content_context: false,
3187            measured_max_width: Some(120.0),
3188            resolved_modifiers: ResolvedModifiers::default(),
3189            draw_commands: vec![],
3190            click_actions: vec![],
3191            pointer_inputs: vec![],
3192            clip_to_bounds: false,
3193            annotated_text: Some(AnnotatedString::from("shadow")),
3194            text_style: Some(TextStyle::from_span_style(SpanStyle {
3195                shadow: Some(cranpose_ui::text::Shadow {
3196                    color: Color::BLACK,
3197                    offset: Point::new(1.0, 2.0),
3198                    blur_radius: 3.0,
3199                }),
3200                ..SpanStyle::default()
3201            })),
3202            text_layout_options: None,
3203            text_pan: None,
3204            graphics_layer: None,
3205            children: vec![],
3206        };
3207        let parent = BuildNodeSnapshot {
3208            node_id: 1,
3209            placement: Point::default(),
3210            size: Size {
3211                width: 160.0,
3212                height: 64.0,
3213            },
3214            content_offset: Point { x: 0.0, y: -18.5 },
3215            motion_context_animated: false,
3216            translated_content_context: true,
3217            measured_max_width: None,
3218            resolved_modifiers: ResolvedModifiers::default(),
3219            draw_commands: vec![],
3220            click_actions: vec![],
3221            pointer_inputs: vec![],
3222            clip_to_bounds: false,
3223            annotated_text: None,
3224            text_style: None,
3225            text_layout_options: None,
3226            text_pan: None,
3227            graphics_layer: None,
3228            children: vec![child],
3229        };
3230
3231        let graph = build_layer_node_for_test(parent, 1.0, false);
3232        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3233            panic!("expected child layer");
3234        };
3235        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3236            panic!("expected text primitive");
3237        };
3238        let PrimitiveNode::Text(text) = &text_primitive.node else {
3239            panic!("expected text primitive");
3240        };
3241
3242        assert_eq!(text.text_style.paragraph_style.text_motion, None);
3243    }
3244
3245    #[test]
3246    fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
3247        let child = BuildNodeSnapshot {
3248            node_id: 2,
3249            placement: Point { x: 11.0, y: 7.0 },
3250            size: Size {
3251                width: 120.0,
3252                height: 32.0,
3253            },
3254            content_offset: Point::default(),
3255            motion_context_animated: false,
3256            translated_content_context: false,
3257            measured_max_width: Some(120.0),
3258            resolved_modifiers: ResolvedModifiers::default(),
3259            draw_commands: vec![],
3260            click_actions: vec![],
3261            pointer_inputs: vec![],
3262            clip_to_bounds: false,
3263            annotated_text: Some(AnnotatedString::from("lazy")),
3264            text_style: Some(TextStyle::default()),
3265            text_layout_options: None,
3266            text_pan: None,
3267            graphics_layer: None,
3268            children: vec![],
3269        };
3270        let parent = BuildNodeSnapshot {
3271            node_id: 1,
3272            placement: Point::default(),
3273            size: Size {
3274                width: 160.0,
3275                height: 64.0,
3276            },
3277            content_offset: Point::default(),
3278            motion_context_animated: true,
3279            translated_content_context: false,
3280            measured_max_width: None,
3281            resolved_modifiers: ResolvedModifiers::default(),
3282            draw_commands: vec![],
3283            click_actions: vec![],
3284            pointer_inputs: vec![],
3285            clip_to_bounds: false,
3286            annotated_text: None,
3287            text_style: None,
3288            text_layout_options: None,
3289            text_pan: None,
3290            graphics_layer: None,
3291            children: vec![child],
3292        };
3293
3294        let graph = build_layer_node_for_test(parent, 1.0, false);
3295        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3296            panic!("expected child layer");
3297        };
3298        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3299            panic!("expected text primitive");
3300        };
3301        let PrimitiveNode::Text(text) = &text_primitive.node else {
3302            panic!("expected text primitive");
3303        };
3304
3305        assert_eq!(text.text_style.paragraph_style.text_motion, None);
3306        assert!(graph.motion_context_animated);
3307        assert!(child_layer.motion_context_animated);
3308    }
3309
3310    #[test]
3311    fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
3312        let mut composition = cranpose_ui::run_test_composition(|| {
3313            let list_state = remember_lazy_list_state();
3314            LazyColumn(
3315                Modifier::empty(),
3316                list_state,
3317                LazyColumnSpec::default(),
3318                |scope| {
3319                    scope.item(Some(0), None, || {
3320                        Text("LazyMotion", Modifier::empty(), TextStyle::default());
3321                    });
3322                },
3323            );
3324        });
3325
3326        let root = composition.root().expect("lazy column root");
3327        let handle = composition.runtime_handle();
3328        let mut applier = composition.applier_mut();
3329        applier.set_runtime_handle(handle);
3330        let _ = applier
3331            .compute_layout(
3332                root,
3333                Size {
3334                    width: 240.0,
3335                    height: 240.0,
3336                },
3337            )
3338            .expect("lazy column layout");
3339        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3340        applier.clear_runtime_handle();
3341
3342        assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
3343    }
3344
3345    #[test]
3346    fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
3347        use std::cell::RefCell;
3348        use std::rc::Rc;
3349
3350        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3351        let state_holder_for_comp = state_holder.clone();
3352        let mut composition = cranpose_ui::run_test_composition(move || {
3353            let list_state = remember_lazy_list_state();
3354            *state_holder_for_comp.borrow_mut() = Some(list_state);
3355            LazyColumn(
3356                Modifier::empty().height(120.0),
3357                list_state,
3358                LazyColumnSpec::default(),
3359                |scope| {
3360                    scope.items(
3361                        8,
3362                        None::<fn(usize) -> u64>,
3363                        None::<fn(usize) -> u64>,
3364                        |index| {
3365                            Text(
3366                                format!("LazyMotion {index}"),
3367                                Modifier::empty().padding(4.0),
3368                                TextStyle::default(),
3369                            );
3370                        },
3371                    );
3372                },
3373            );
3374        });
3375
3376        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3377        list_state.scroll_to_item(3, 0.0);
3378
3379        let root = composition.root().expect("lazy column root");
3380        let handle = composition.runtime_handle();
3381        let mut applier = composition.applier_mut();
3382        applier.set_runtime_handle(handle);
3383        let _ = applier
3384            .compute_layout(
3385                root,
3386                Size {
3387                    width: 240.0,
3388                    height: 240.0,
3389                },
3390            )
3391            .expect("lazy column layout");
3392        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3393        let active_children = applier
3394            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3395            .expect("lazy column should be subcompose");
3396        let child_debug: Vec<String> = active_children
3397            .iter()
3398            .map(|&child_id| {
3399                if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
3400                    format!(
3401                        "layout#{child_id} placed={} text={:?} children={:?}",
3402                        node.layout_state().is_placed,
3403                        node.modifier_slices_snapshot()
3404                            .text_content()
3405                            .map(str::to_string),
3406                        node.children.clone()
3407                    )
3408                }) {
3409                    summary
3410                } else if let Ok(summary) =
3411                    applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
3412                        format!(
3413                            "subcompose#{child_id} placed={} active_children={:?}",
3414                            node.layout_state().is_placed,
3415                            node.active_children()
3416                        )
3417                    })
3418                {
3419                    summary
3420                } else {
3421                    format!("missing#{child_id}")
3422                }
3423            })
3424            .collect();
3425        applier.clear_runtime_handle();
3426
3427        let first_index = list_state.first_visible_item_index();
3428        assert!(
3429            first_index > 0,
3430            "lazy list should move away from origin before graph building, observed first_index={first_index}"
3431        );
3432        let mut labels = Vec::new();
3433        collect_text_labels(&graph.root, &mut labels);
3434        assert_eq!(
3435            find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
3436            Some(None),
3437            "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
3438            labels,
3439            active_children,
3440            child_debug
3441        );
3442    }
3443
3444    #[test]
3445    fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
3446        use std::cell::RefCell;
3447        use std::rc::Rc;
3448
3449        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3450        let state_holder_for_comp = state_holder.clone();
3451        let mut composition = cranpose_ui::run_test_composition(move || {
3452            let list_state = remember_lazy_list_state();
3453            *state_holder_for_comp.borrow_mut() = Some(list_state);
3454            let mut spec =
3455                LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
3456            spec.beyond_bounds_item_count = 0;
3457            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
3458                scope.items(
3459                    12,
3460                    None::<fn(usize) -> u64>,
3461                    None::<fn(usize) -> u64>,
3462                    |index| {
3463                        Text(
3464                            format!("WarmRow {index}"),
3465                            Modifier::empty().height(32.0),
3466                            TextStyle::default(),
3467                        );
3468                    },
3469                );
3470            });
3471        });
3472
3473        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3474        list_state.scroll_to_item(4, 0.0);
3475
3476        let root = composition.root().expect("lazy column root");
3477        let handle = composition.runtime_handle();
3478        let mut applier = composition.applier_mut();
3479        applier.set_runtime_handle(handle);
3480        let _ = applier
3481            .compute_layout(
3482                root,
3483                Size {
3484                    width: 240.0,
3485                    height: 240.0,
3486                },
3487            )
3488            .expect("lazy column layout");
3489        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3490        let active_children = applier
3491            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3492            .expect("lazy column should be subcompose");
3493        applier.clear_runtime_handle();
3494
3495        let visible_indices: Vec<_> = list_state
3496            .layout_info()
3497            .visible_items_info
3498            .iter()
3499            .map(|item| item.index)
3500            .collect();
3501        let mut labels = Vec::new();
3502        collect_text_labels(&graph.root, &mut labels);
3503
3504        assert_eq!(
3505            visible_indices,
3506            vec![4, 5, 6],
3507            "test setup expects exactly three viewport-visible rows"
3508        );
3509        assert!(
3510            labels.iter().any(|label| label == "WarmRow 7"),
3511            "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
3512        );
3513    }
3514
3515    #[test]
3516    fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
3517        use std::cell::RefCell;
3518        use std::rc::Rc;
3519
3520        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3521        let state_holder_for_comp = state_holder.clone();
3522        let mut composition = cranpose_ui::run_test_composition(move || {
3523            let list_state = remember_lazy_list_state();
3524            *state_holder_for_comp.borrow_mut() = Some(list_state);
3525            LazyColumn(
3526                Modifier::empty().height(120.0),
3527                list_state,
3528                LazyColumnSpec::default(),
3529                |scope| {
3530                    scope.items(
3531                        8,
3532                        None::<fn(usize) -> u64>,
3533                        None::<fn(usize) -> u64>,
3534                        |index| {
3535                            Text(
3536                                format!("LazySnap {index}"),
3537                                Modifier::empty().padding(4.0),
3538                                TextStyle::default(),
3539                            );
3540                        },
3541                    );
3542                },
3543            );
3544        });
3545
3546        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3547        list_state.scroll_to_item(2, 7.5);
3548
3549        let root = composition.root().expect("lazy column root");
3550        let handle = composition.runtime_handle();
3551        let mut applier = composition.applier_mut();
3552        applier.set_runtime_handle(handle);
3553        let _ = applier
3554            .compute_layout(
3555                root,
3556                Size {
3557                    width: 240.0,
3558                    height: 240.0,
3559                },
3560            )
3561            .expect("lazy column layout");
3562        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3563        applier.clear_runtime_handle();
3564
3565        let layout_info = list_state.layout_info();
3566        let first_visible_offset = layout_info
3567            .visible_items_info
3568            .first()
3569            .expect("lazy layout should expose visible item info")
3570            .offset;
3571        let snap_offset = find_translated_content_offset(&graph.root)
3572            .expect("lazy list graph should include translated content context");
3573
3574        assert!(
3575            (snap_offset.y - first_visible_offset).abs() <= 0.001,
3576            "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
3577        );
3578    }
3579
3580    #[test]
3581    fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
3582        let child = BuildNodeSnapshot {
3583            node_id: 2,
3584            placement: Point { x: 11.0, y: 7.0 },
3585            size: Size {
3586                width: 120.0,
3587                height: 32.0,
3588            },
3589            content_offset: Point::default(),
3590            motion_context_animated: false,
3591            translated_content_context: false,
3592            measured_max_width: Some(120.0),
3593            resolved_modifiers: ResolvedModifiers::default(),
3594            draw_commands: vec![],
3595            click_actions: vec![],
3596            pointer_inputs: vec![],
3597            clip_to_bounds: false,
3598            annotated_text: Some(AnnotatedString::from("static")),
3599            text_style: Some(TextStyle::from_paragraph_style(
3600                cranpose_ui::text::ParagraphStyle {
3601                    text_motion: Some(TextMotion::Static),
3602                    ..Default::default()
3603                },
3604            )),
3605            text_layout_options: None,
3606            text_pan: None,
3607            graphics_layer: None,
3608            children: vec![],
3609        };
3610        let parent = BuildNodeSnapshot {
3611            node_id: 1,
3612            placement: Point::default(),
3613            size: Size {
3614                width: 160.0,
3615                height: 64.0,
3616            },
3617            content_offset: Point { x: 0.0, y: -18.5 },
3618            motion_context_animated: false,
3619            translated_content_context: true,
3620            measured_max_width: None,
3621            resolved_modifiers: ResolvedModifiers::default(),
3622            draw_commands: vec![],
3623            click_actions: vec![],
3624            pointer_inputs: vec![],
3625            clip_to_bounds: false,
3626            annotated_text: None,
3627            text_style: None,
3628            text_layout_options: None,
3629            text_pan: None,
3630            graphics_layer: None,
3631            children: vec![child],
3632        };
3633
3634        let graph = build_layer_node_for_test(parent, 1.0, false);
3635        let RenderNode::Layer(child_layer) = &graph.children[0] else {
3636            panic!("expected child layer");
3637        };
3638        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3639            panic!("expected text primitive");
3640        };
3641        let PrimitiveNode::Text(text) = &text_primitive.node else {
3642            panic!("expected text primitive");
3643        };
3644
3645        assert_eq!(
3646            text.text_style.paragraph_style.text_motion,
3647            Some(TextMotion::Static),
3648            "explicit text motion must win over inherited scrolling motion context"
3649        );
3650    }
3651
3652    /// A wrapping paragraph must PAINT the height it MEASURED, so the sibling
3653    /// the column placed after it is not drawn over.
3654    ///
3655    /// Regression. A `Text` without `fill_max_width` is placed at its own
3656    /// `metrics.width` — the widest line it wrapped into. The paint pass then
3657    /// re-wrapped at that placed width, and because the widest line is exactly
3658    /// the one that fills the limit, measuring it against itself pushed its
3659    /// last word onto a new line: measured 6 lines, painted 7. The extra line
3660    /// was clipped away (silent truncation) and it ran past the next sibling's
3661    /// box, which the column had placed from the 6-line height.
3662    ///
3663    /// Asserts RENDERED GEOMETRY, not `resolve_text_measure_width`'s return
3664    /// value: the two pipelines already had unit tests for the correct rule and
3665    /// shipped this anyway, because those tests exercised a `#[cfg(test)]`
3666    /// replica rather than the scene builder that paints.
3667    ///
3668    /// Driven by the REAL font backend (`SoftwareTextMeasurer`, the measurer
3669    /// `WgpuRenderer::attach_app_context_services` installs). A stub measurer
3670    /// cannot show this: the defect lives in the disagreement between two wrap
3671    /// widths, and a stub that returns the same answer for both hides it by
3672    /// construction. The string is mixed Latin/Cyrillic because that is what
3673    /// the reporting app puts in these paragraphs.
3674    #[test]
3675    fn wrapped_paragraph_paints_the_height_it_measured() {
3676        const BODY: &str = "fed back картица scored fp32 износ once paper fed Vision dropped \
3677             fed widest the strip mask prompt mask threshold Vision on датум instance mask \
3678             износ Apple";
3679        const FOLLOWING: &str = "FOLLOWING SIBLING";
3680
3681        let app_context = cranpose_ui::AppContext::new();
3682        app_context.enter(|| {
3683            cranpose_ui::text::set_text_measurer(
3684                crate::software_text_raster::SoftwareTextMeasurer::from_fonts_or_default(&[], 8192),
3685            );
3686            let mut composition = cranpose_ui::run_test_composition(move || {
3687                Column(
3688                    Modifier::empty().fill_max_width(),
3689                    ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
3690                    move || {
3691                        Text(BODY.to_string(), Modifier::empty(), TextStyle::default());
3692                        Text(
3693                            FOLLOWING.to_string(),
3694                            Modifier::empty(),
3695                            TextStyle::default(),
3696                        );
3697                    },
3698                );
3699            });
3700
3701            let root = composition.root().expect("composition root");
3702            let handle = composition.runtime_handle();
3703            let mut applier = composition.applier_mut();
3704            applier.set_runtime_handle(handle);
3705            let layout = applier
3706                .compute_layout(
3707                    root,
3708                    Size {
3709                        width: 245.0,
3710                        height: 900.0,
3711                    },
3712                )
3713                .expect("layout");
3714
3715            fn find_box<'a>(node: &'a LayoutBox, value: &str) -> Option<&'a LayoutBox> {
3716                if node
3717                    .node_data
3718                    .modifier_slices()
3719                    .text_content()
3720                    .is_some_and(|text| text == value)
3721                {
3722                    return Some(node);
3723                }
3724                node.children
3725                    .iter()
3726                    .find_map(|child| find_box(child, value))
3727            }
3728            let body_box = find_box(layout.root(), BODY).expect("measured paragraph box");
3729            let following_box = find_box(layout.root(), FOLLOWING).expect("measured sibling box");
3730            let measured_height = body_box.rect.height;
3731            let following_top = following_box.rect.y;
3732            assert!(
3733                measured_height > 60.0,
3734                "test setup expects a genuinely multi-line paragraph, got {measured_height}"
3735            );
3736            assert!(
3737                body_box.rect.width < 245.0,
3738                "test setup expects the node to be placed at its own measured width, \
3739                 not the full constraint, got {}",
3740                body_box.rect.width
3741            );
3742
3743            let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("render graph");
3744            applier.clear_runtime_handle();
3745
3746            // The painted string carries the wrap points as newlines, so it is
3747            // compared with whitespace stripped rather than verbatim.
3748            fn squashed(value: &str) -> String {
3749                value.chars().filter(|c| !c.is_whitespace()).collect()
3750            }
3751            fn find_text<'a>(layer: &'a LayerNode, value: &str) -> Option<&'a TextPrimitiveNode> {
3752                for child in &layer.children {
3753                    match child {
3754                        RenderNode::Primitive(primitive) => {
3755                            if let PrimitiveNode::Text(text) = &primitive.node {
3756                                if squashed(&text.text.text) == squashed(value) {
3757                                    return Some(text);
3758                                }
3759                            }
3760                        }
3761                        RenderNode::Layer(child_layer) => {
3762                            if let Some(found) = find_text(child_layer, value) {
3763                                return Some(found);
3764                            }
3765                        }
3766                        RenderNode::DrawRun(_) => {}
3767                    }
3768                }
3769                None
3770            }
3771            let painted = find_text(&graph.root, BODY).expect("painted paragraph");
3772
3773            assert!(
3774                (painted.rect.height - measured_height).abs() < 0.5,
3775                "paragraph painted {:.2} tall into a box layout measured at {:.2} \
3776                 (painted rect {:?})",
3777                painted.rect.height,
3778                measured_height,
3779                painted.rect
3780            );
3781            assert!(
3782                painted.rect.y + painted.rect.height <= following_top + 0.5,
3783                "painted paragraph bottom {:.2} runs past the following sibling placed at \
3784                 {:.2}",
3785                painted.rect.y + painted.rect.height,
3786                following_top
3787            );
3788        });
3789    }
3790
3791    #[test]
3792    fn retained_slot_confirmations_are_live_only_under_their_generation() {
3793        let command = DrawCommandId {
3794            node_id: 990_101,
3795            command_index: 0,
3796            placement: DrawPlacement::Behind,
3797        };
3798        set_retained_feed_epoch(Some(7));
3799        confirm_retained_slot(command, 3, 7);
3800        assert!(retained_slot_confirmed(command, 3));
3801        // A new epoch (renderer swap, device loss) makes the word stale.
3802        set_retained_feed_epoch(Some(8));
3803        assert!(!retained_slot_confirmed(command, 3));
3804        // No declared epoch means no consumer: never confirmed.
3805        set_retained_feed_epoch(None);
3806        assert!(!retained_slot_confirmed(command, 3));
3807        // Back on the stored generation the buffer is live again.
3808        set_retained_feed_epoch(Some(7));
3809        assert!(retained_slot_confirmed(command, 3));
3810        revoke_retained_slot(command, 3);
3811        assert!(!retained_slot_confirmed(command, 3));
3812        set_retained_feed_epoch(None);
3813        clear_retained_slot_confirmations();
3814    }
3815
3816    /// Draws enough similar arcs for the replay verifier to engage
3817    /// (`MIN_REPLAY_COMMAND_RECORDS`) and to carve several segments —
3818    /// partial arcs, because a chain anchor must pin rotation and circles
3819    /// cannot. Static across frames, so every segment verifies under the
3820    /// identity transform from the first replay frame on.
3821    fn record_sweep_test_rings(scope: &mut DrawScopeDefault) {
3822        let count = 600usize;
3823        let sweep = std::f32::consts::TAU / count as f32 * 0.8;
3824        for i in 0..count {
3825            let start = i as f32 * (std::f32::consts::TAU / count as f32);
3826            scope.draw_annular_sector(
3827                Brush::solid(cranpose_ui_graphics::Color(0.2, 0.4, 0.6, 1.0)),
3828                cranpose_ui_graphics::Point::new(204.0, 204.0),
3829                140.0,
3830                150.0,
3831                start,
3832                sweep,
3833            );
3834        }
3835    }
3836
3837    /// The FLIP of the old sweep-exemption test: with the frame owning a
3838    /// pinned handle to the exact recording it was built from, the idle
3839    /// sweep is pure capacity management again — a live confirmation no
3840    /// longer pins the registry slot, the sweep drops it anyway, and the
3841    /// frame's bypassed spans still rematerialize byte-identically from the
3842    /// handle the frame owns. Sweeping can categorically never sever a
3843    /// frame's rematerialization source.
3844    #[test]
3845    fn recording_sweep_cannot_sever_a_frames_fallback() {
3846        let command = DrawCommandId {
3847            node_id: 990_102,
3848            command_index: 0,
3849            placement: DrawPlacement::Behind,
3850        };
3851        set_retained_feed_epoch(Some(41));
3852        for slot in 0..64 {
3853            confirm_retained_slot(command, slot, 41);
3854        }
3855
3856        // The production seam order, driven by hand: acquire buffers,
3857        // record, verify, finish with the confirmed slots bypassed, publish
3858        // — and the frame keeps the published recording handle, exactly as
3859        // `draw_nodes` attaches it.
3860        let mut state = cranpose_ui_graphics::CommandReplayState::default();
3861        let mut published = None;
3862        for _frame in 0..4 {
3863            let (recording, storage, _) = acquire_recording(command);
3864            let mut scope = DrawScopeDefault::with_recording(
3865                cranpose_ui_graphics::Size::new(408.0, 408.0),
3866                None,
3867                recording,
3868                storage,
3869            );
3870            record_sweep_test_rings(&mut scope);
3871            let outcome = state.advance(scope.recorded());
3872            let center = state.center();
3873            let (finished, frame) = scope.finish_replay(center, outcome, &mut |slot| {
3874                retained_slot_confirmed(command, slot)
3875            });
3876            let (primitives, fallback) =
3877                publish_recording(command, finished.recording, finished.primitives, None);
3878            let frame = frame.map(|mut frame| {
3879                frame.fallback = Some(fallback.clone());
3880                frame
3881            });
3882            published = Some((primitives, fallback, frame));
3883        }
3884        let (_primitives, fallback, frame) = published.expect("four frames published");
3885        let frame = frame.expect("the replay must produce a frame with retained spans");
3886        let bypassed: Vec<(u32, u32)> = frame
3887            .spans
3888            .iter()
3889            .filter_map(|span| match span {
3890                cranpose_ui_graphics::FrameSpan::Retained {
3891                    capture: false,
3892                    range,
3893                    tape_range,
3894                    ..
3895                } if range.1 <= range.0 => Some(*tape_range),
3896                _ => None,
3897            })
3898            .collect();
3899        assert!(
3900            !bypassed.is_empty(),
3901            "confirmed slots must actually have bypassed materialization"
3902        );
3903        let expected: Vec<Vec<DrawPrimitive>> = bypassed
3904            .iter()
3905            .map(|tape_range| {
3906                fallback
3907                    .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
3908                    .expect("a frame-consistent tape range must materialize")
3909            })
3910            .collect();
3911
3912        // 1024 builds without re-recording: both sweeps (512, 1024) run with
3913        // the slot idle far past the 64-build window, confirmations still
3914        // live. The slot must be GONE — capacity management owes the frame
3915        // nothing anymore.
3916        for _ in 0..1024 {
3917            bump_recording_generation();
3918        }
3919        assert!(
3920            COMMAND_RECORDINGS.with(|map| !map.borrow().contains_key(&command)),
3921            "the sweep must stay pure capacity management: a live confirmation \
3922             no longer pins the registry slot"
3923        );
3924
3925        // The categorical property: the frame's own handle survives any
3926        // sweep, and its bypassed spans rematerialize byte-identically.
3927        for (tape_range, expected) in bypassed.iter().zip(&expected) {
3928            let after = fallback
3929                .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
3930                .expect("the frame-owned recording must outlive the sweep");
3931            assert_eq!(
3932                &after, expected,
3933                "post-sweep rematerialization must be byte-identical"
3934            );
3935        }
3936        set_retained_feed_epoch(None);
3937        clear_retained_slot_confirmations();
3938    }
3939
3940    /// [`command_recordings_reuse_buffers_across_rebuilds`] for the compact
3941    /// recording pair: a graph frame owns last build's recording (its
3942    /// `fallback`) while this build records, so steady-state publishes
3943    /// ping-pong between exactly two allocations — `Rc::try_unwrap`
3944    /// succeeds at every acquisition after warmup and no recording buffer
3945    /// is ever reallocated — and a recording a live frame still shares is
3946    /// never written through.
3947    #[test]
3948    fn command_recordings_reuse_recording_buffers_across_rebuilds() {
3949        let command = DrawCommandId {
3950            node_id: 990_103,
3951            command_index: 0,
3952            placement: DrawPlacement::Behind,
3953        };
3954        // Simulates the installed graph: it holds the newest build's
3955        // handles, and the previous build's drop when it is replaced.
3956        let mut held = None;
3957        let mut ptrs = Vec::new();
3958        for _build in 0..8 {
3959            let (recording, storage, _) = acquire_recording(command);
3960            let mut scope = DrawScopeDefault::with_recording(
3961                cranpose_ui_graphics::Size::new(64.0, 64.0),
3962                None,
3963                recording,
3964                storage,
3965            );
3966            scope.draw_rect_at(
3967                Rect {
3968                    x: 4.0,
3969                    y: 4.0,
3970                    width: 16.0,
3971                    height: 8.0,
3972                },
3973                Brush::solid(Color::WHITE),
3974            );
3975            let finished = scope.finish();
3976            let (primitives, recording) =
3977                publish_recording(command, finished.recording, finished.primitives, None);
3978            ptrs.push(recording.tape_ptr());
3979            held = Some((primitives, recording));
3980        }
3981        drop(held);
3982        // Every buffer in play stays alive for the whole loop (registry pair
3983        // or in-flight scope), so pointer equality here is reuse, not an
3984        // allocator recycling a freed address.
3985        for build in 2..8 {
3986            assert_eq!(
3987                ptrs[build],
3988                ptrs[build - 2],
3989                "steady-state publishes must ping-pong between the pair's \
3990                 buffers (build {build} allocated)"
3991            );
3992        }
3993        assert_ne!(
3994            ptrs[6], ptrs[7],
3995            "a recording a live frame still shares must never be recorded into"
3996        );
3997    }
3998}