Skip to main content

cranpose_render_common/
scene_builder.rs

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