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