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