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