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