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