Skip to main content

cranpose_render_common/
scene_builder.rs

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