Skip to main content

cranpose_render_common/
scene_builder.rs

1use std::rc::Rc;
2
3use cranpose_core::{MemoryApplier, Node, NodeId, collections::map::HashSet};
4use cranpose_ui::{
5    DrawCommand, LayoutBox, LayoutNode, ModifierNodeSlices, Point, Rect, ResolvedModifiers, Size,
6    SubcomposeLayoutNode, TextLayoutOptions, TextOverflow, TextPanResolver, prepare_text_layout,
7    text::{AnnotatedString, TextAlign, TextStyle, resolve_text_direction},
8};
9use cranpose_ui_graphics::{
10    CommandRecording, CompositingStrategy, GraphicsLayer, LayerShape, PointerIcon,
11    RoundedCornerShape, rounded_corner_alpha_mask_effect,
12};
13use smallvec::SmallVec;
14
15use crate::{
16    graph::{
17        CachePolicy, DrawCommandId, DrawRunNode, HitTestNode, IsolationReasons, LayerNode,
18        PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph,
19        RenderNode, TextPrimitiveNode,
20    },
21    layer_transform::layer_transform_to_parent,
22    raster_cache::LayerRasterCacheHashes,
23    style_shared::{DrawPlacement, recording_for_placement_reusing},
24};
25
26const TEXT_CLIP_PAD: f32 = 1.0;
27const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
28
29#[derive(Clone, Default)]
30struct BuildNodeSnapshot {
31    node_id: NodeId,
32    placement: Point,
33    size: Size,
34    content_offset: Point,
35    motion_context_animated: bool,
36    translated_content_context: bool,
37    has_own_origin_sinks: bool,
38    measured_max_width: Option<f32>,
39    resolved_modifiers: ResolvedModifiers,
40    draw_commands: Vec<DrawCommand>,
41    outer_draw_command_count: usize,
42    click_actions: Vec<Rc<dyn Fn(Point)>>,
43    pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
44    pointer_icon: Option<PointerIcon>,
45    clip_to_bounds: bool,
46    annotated_text: Option<AnnotatedString>,
47    text_style: Option<TextStyle>,
48    text_layout_options: Option<TextLayoutOptions>,
49    text_pan: Option<TextPanResolver>,
50    graphics_layer: Option<GraphicsLayer>,
51    children: Vec<Self>,
52}
53
54struct SnapshotNodeData {
55    layout_state: cranpose_ui::widgets::LayoutState,
56    modifier_slices: Rc<ModifierNodeSlices>,
57    resolved_modifiers: ResolvedModifiers,
58    children: SmallVec<[NodeId; 8]>,
59    window_root: bool,
60}
61
62/// Why a scoped scene update could not be applied, forcing the caller to throw
63/// the render graph away and build it again from the applier.
64///
65/// The reason has to travel with the outcome because the shell picks the
66/// scoped path from the shape of the dirty set, before this code runs, and
67/// logs that choice. A frame that chose the scoped path and then rebuilt the
68/// whole scene is the expensive case, and in the log it reads exactly like a
69/// cheap patch -- so the fallback says so itself.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum GraphRebuildReason {
72    /// The root was dirty and its replacement layer could not be built.
73    RootLayerUnavailable,
74    /// A dirty subtree's replacement layer could not be built.
75    DirtyLayerUnavailable,
76    /// This many dirty nodes own no layer in the render graph, so the scoped
77    /// walk never reached them. A node enters the graph only as a `LayerNode`;
78    /// a dirty node that never produced one -- or whose layer left the graph
79    /// this frame -- cannot be patched in place.
80    UnmatchedDirtyNodes(usize),
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum GraphUpdate {
85    Patched,
86    NeedsRebuild(GraphRebuildReason),
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct GraphUpdateReport {
91    pub update: GraphUpdate,
92    pub hit_graph_dirty: bool,
93}
94
95impl GraphUpdateReport {
96    pub fn applied(self) -> bool {
97        matches!(self.update, GraphUpdate::Patched)
98    }
99
100    pub fn rebuild_reason(self) -> Option<GraphRebuildReason> {
101        match self.update {
102            GraphUpdate::Patched => None,
103            GraphUpdate::NeedsRebuild(reason) => Some(reason),
104        }
105    }
106}
107
108#[cfg(test)]
109thread_local! {
110    static LOWERED_LAYER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
111}
112
113fn note_layer_lowered() {
114    #[cfg(test)]
115    LOWERED_LAYER_COUNT.with(|count| count.set(count.get() + 1));
116}
117
118#[cfg(test)]
119fn reset_lowered_layer_count() {
120    LOWERED_LAYER_COUNT.with(|count| count.set(0));
121}
122
123#[cfg(test)]
124fn lowered_layer_count() -> usize {
125    LOWERED_LAYER_COUNT.with(std::cell::Cell::get)
126}
127
128pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
129    bump_recording_generation();
130    let root_snapshot = layout_box_to_snapshot(root, None);
131    RenderGraph {
132        root: build_layer_node(root_snapshot, scale, false),
133    }
134}
135
136pub fn build_graph_from_applier(
137    applier: &mut MemoryApplier,
138    root: NodeId,
139    scale: f32,
140) -> Option<RenderGraph> {
141    bump_recording_generation();
142    Some(RenderGraph {
143        root: build_layer_node_from_applier(applier, root, scale, false)?,
144    })
145}
146
147pub fn update_graph_from_applier(
148    applier: &mut MemoryApplier,
149    graph: &mut RenderGraph,
150    dirty_nodes: &[NodeId],
151    scale: f32,
152) -> bool {
153    update_graph_from_applier_report(applier, graph, dirty_nodes, scale).applied()
154}
155
156pub fn update_graph_from_applier_report(
157    applier: &mut MemoryApplier,
158    graph: &mut RenderGraph,
159    dirty_nodes: &[NodeId],
160    scale: f32,
161) -> GraphUpdateReport {
162    let mut changed_nodes = Vec::new();
163    update_graph_from_applier_report_into(applier, graph, dirty_nodes, scale, &mut changed_nodes)
164}
165
166pub fn update_graph_from_applier_report_into(
167    applier: &mut MemoryApplier,
168    graph: &mut RenderGraph,
169    dirty_nodes: &[NodeId],
170    scale: f32,
171    changed_nodes: &mut Vec<NodeId>,
172) -> GraphUpdateReport {
173    let report = update_graph_from_applier_report_into_inner(
174        applier,
175        graph,
176        dirty_nodes,
177        scale,
178        changed_nodes,
179    );
180    if let GraphUpdate::NeedsRebuild(reason) = report.update
181        && cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG")
182    {
183        eprintln!(
184            "[scene-update-diag] scoped update abandoned, whole scene rebuilt: {reason:?} dirty={}",
185            dirty_nodes.len()
186        );
187    }
188    report
189}
190
191fn update_graph_from_applier_report_into_inner(
192    applier: &mut MemoryApplier,
193    graph: &mut RenderGraph,
194    dirty_nodes: &[NodeId],
195    scale: f32,
196    changed_nodes: &mut Vec<NodeId>,
197) -> GraphUpdateReport {
198    if dirty_nodes.is_empty() {
199        return GraphUpdateReport {
200            update: GraphUpdate::Patched,
201            hit_graph_dirty: false,
202        };
203    }
204    bump_recording_generation();
205
206    if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
207        eprintln!("[scene-update-diag] dirty={dirty_nodes:?}");
208    }
209
210    let mut remaining_dirty_nodes = dirty_nodes.iter().copied().collect::<HashSet<_>>();
211    if let Some(root_id) = layer_identity(&graph.root)
212        && remaining_dirty_nodes.contains(&root_id)
213    {
214        remaining_dirty_nodes.remove(&root_id);
215        if try_translate_scrolled_layer(
216            applier,
217            &mut graph.root,
218            &mut remaining_dirty_nodes,
219            changed_nodes,
220            TranslateAncestorContext {
221                inherited_motion_context_animated: false,
222                ancestor_hashed: false,
223                inherited_translated_content_context: false,
224                parent_content_offset: Point::default(),
225                parent_abs: AbsOrigin::ROOT,
226            },
227        ) {
228            if remaining_dirty_nodes.is_empty() {
229                return GraphUpdateReport {
230                    update: GraphUpdate::Patched,
231                    hit_graph_dirty: true,
232                };
233            }
234            let inherited = graph.root.translated_content_context;
235            let walked = replace_dirty_layers_from_applier(
236                applier,
237                &mut graph.root,
238                &mut remaining_dirty_nodes,
239                inherited,
240                false,
241                changed_nodes,
242            );
243            return GraphUpdateReport {
244                update: classify_walk(walked.is_some(), &remaining_dirty_nodes),
245                hit_graph_dirty: true,
246            };
247        }
248        let Some(root) = build_layer_node_from_applier(applier, root_id, scale, false) else {
249            return GraphUpdateReport {
250                update: GraphUpdate::NeedsRebuild(GraphRebuildReason::RootLayerUnavailable),
251                hit_graph_dirty: true,
252            };
253        };
254        let hit_graph_dirty = layer_hit_graph_state_dirty(&graph.root, &root);
255        collect_layer_node_ids(&graph.root, changed_nodes);
256        graph.root = root;
257        graph.root.recompute_raster_cache_hashes();
258        collect_layer_node_ids(&graph.root, changed_nodes);
259        return GraphUpdateReport {
260            update: GraphUpdate::Patched,
261            hit_graph_dirty,
262        };
263    }
264
265    let inherited_translated_content_context = graph.root.translated_content_context;
266    let report = match replace_dirty_layers_from_applier(
267        applier,
268        &mut graph.root,
269        &mut remaining_dirty_nodes,
270        inherited_translated_content_context,
271        false,
272        changed_nodes,
273    ) {
274        Some(report) => report,
275        None => {
276            return GraphUpdateReport {
277                update: GraphUpdate::NeedsRebuild(GraphRebuildReason::DirtyLayerUnavailable),
278                hit_graph_dirty: true,
279            };
280        }
281    };
282
283    match classify_walk(true, &remaining_dirty_nodes) {
284        GraphUpdate::Patched => GraphUpdateReport {
285            update: GraphUpdate::Patched,
286            hit_graph_dirty: report.hit_graph_dirty,
287        },
288        update => GraphUpdateReport {
289            update,
290            hit_graph_dirty: true,
291        },
292    }
293}
294
295fn classify_walk(walked: bool, remaining_dirty_nodes: &HashSet<NodeId>) -> GraphUpdate {
296    if !walked {
297        GraphUpdate::NeedsRebuild(GraphRebuildReason::DirtyLayerUnavailable)
298    } else if !remaining_dirty_nodes.is_empty() {
299        GraphUpdate::NeedsRebuild(GraphRebuildReason::UnmatchedDirtyNodes(
300            remaining_dirty_nodes.len(),
301        ))
302    } else {
303        GraphUpdate::Patched
304    }
305}
306
307#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
308struct ReplaceDirtyLayersReport {
309    updated: bool,
310    hit_graph_dirty: bool,
311}
312
313fn replace_dirty_layers_from_applier(
314    applier: &mut MemoryApplier,
315    parent: &mut LayerNode,
316    dirty_nodes: &mut HashSet<NodeId>,
317    inherited_translated_content_context: bool,
318    ancestor_hashed: bool,
319    changed_nodes: &mut Vec<NodeId>,
320) -> Option<ReplaceDirtyLayersReport> {
321    if dirty_nodes.is_empty() {
322        return Some(ReplaceDirtyLayersReport::default());
323    }
324
325    let child_inherited_translated_content_context =
326        inherited_translated_content_context || parent.translated_content_context;
327    let child_ancestor_hashed =
328        crate::graph_hash::layer_children_ancestor_hashed(parent, ancestor_hashed);
329    let mut report = ReplaceDirtyLayersReport::default();
330
331    for child in &mut parent.children {
332        let RenderNode::Layer(child_layer) = child else {
333            continue;
334        };
335
336        if layer_identity(child_layer).is_some_and(|node_id| dirty_nodes.remove(&node_id)) {
337            if try_translate_scrolled_layer(
338                applier,
339                child_layer,
340                dirty_nodes,
341                changed_nodes,
342                TranslateAncestorContext {
343                    inherited_motion_context_animated: parent.motion_context_animated,
344                    ancestor_hashed: child_ancestor_hashed,
345                    inherited_translated_content_context:
346                        child_inherited_translated_content_context,
347                    parent_content_offset: parent.content_offset,
348                    parent_abs: AbsOrigin {
349                        content_origin: parent.scene_children_origin,
350                        layer_translation: parent.scene_children_layer_translation,
351                    },
352                },
353            ) {
354                report.hit_graph_dirty = true;
355                report.updated = true;
356                let child_report = replace_dirty_layers_from_applier(
357                    applier,
358                    child_layer,
359                    dirty_nodes,
360                    child_inherited_translated_content_context,
361                    child_ancestor_hashed,
362                    changed_nodes,
363                )?;
364                report.hit_graph_dirty |= child_report.hit_graph_dirty;
365                continue;
366            }
367            let mut replacement = build_layer_node_from_applier_internal(
368                applier,
369                layer_identity(child_layer).expect("dirty layer must have a node id"),
370                parent.motion_context_animated,
371                child_inherited_translated_content_context,
372                Some(AbsOrigin {
373                    content_origin: parent.scene_children_origin,
374                    layer_translation: parent.scene_children_layer_translation,
375                }),
376            )?;
377            if parent.content_offset != Point::default() {
378                replacement.transform_to_parent =
379                    replacement
380                        .transform_to_parent
381                        .then(ProjectiveTransform::translation(
382                            parent.content_offset.x,
383                            parent.content_offset.y,
384                        ));
385            }
386            report.hit_graph_dirty |= layer_hit_graph_state_dirty(child_layer, &replacement);
387            remove_dirty_descendants(&replacement, dirty_nodes);
388            collect_layer_node_ids(child_layer, changed_nodes);
389            **child_layer = replacement;
390            collect_layer_node_ids(child_layer, changed_nodes);
391            crate::graph_hash::recompute_layer_raster_cache_hashes_under(
392                child_layer,
393                child_ancestor_hashed,
394            );
395            report.updated = true;
396            continue;
397        }
398
399        let child_report = replace_dirty_layers_from_applier(
400            applier,
401            child_layer,
402            dirty_nodes,
403            child_inherited_translated_content_context,
404            child_ancestor_hashed,
405            changed_nodes,
406        )?;
407        report.updated |= child_report.updated;
408        report.hit_graph_dirty |= child_report.hit_graph_dirty;
409    }
410
411    if report.updated {
412        parent.has_hit_targets = parent.hit_test.is_some()
413            || parent.children.iter().any(|child| match child {
414                RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
415                RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
416            });
417        crate::graph_hash::refresh_layer_own_raster_cache_hashes(parent, ancestor_hashed);
418        if let Some(node_id) = parent.node_id {
419            changed_nodes.push(node_id);
420        }
421    }
422
423    Some(report)
424}
425
426fn translate_bail(reason: &str) -> bool {
427    if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
428        eprintln!("[scene-update-diag] translate bail: {reason}");
429    }
430    false
431}
432
433#[derive(Clone, Copy)]
434struct TranslateAncestorContext {
435    inherited_motion_context_animated: bool,
436    ancestor_hashed: bool,
437    inherited_translated_content_context: bool,
438    parent_content_offset: Point,
439    parent_abs: AbsOrigin,
440}
441
442fn try_translate_scrolled_layer(
443    applier: &mut MemoryApplier,
444    container: &mut LayerNode,
445    dirty_nodes: &mut HashSet<NodeId>,
446    changed_nodes: &mut Vec<NodeId>,
447    ancestors: TranslateAncestorContext,
448) -> bool {
449    let Some(node_id) = layer_identity(container) else {
450        return translate_bail("no node id");
451    };
452    let Some(data) = snapshot_node_data(applier, node_id) else {
453        return translate_bail("container snapshot read failed");
454    };
455    if container.wraps.is_none() {
456        return translate_layer_from_data(
457            applier,
458            container,
459            dirty_nodes,
460            changed_nodes,
461            ancestors,
462            data,
463            false,
464        );
465    }
466    let outer_count = data.modifier_slices.outer_draw_command_count();
467    if outer_count == 0 {
468        return translate_bail("outer draws removed");
469    }
470    let size = data.layout_state.size();
471    let placement = data.layout_state.position();
472    let slices = Rc::clone(&data.modifier_slices);
473    let inner_ancestors = TranslateAncestorContext {
474        ancestor_hashed: crate::graph_hash::layer_children_ancestor_hashed(
475            container,
476            ancestors.ancestor_hashed,
477        ),
478        ..ancestors
479    };
480    let Some(inner) = container.children.iter_mut().find_map(|child| match child {
481        RenderNode::Layer(layer) if layer.node_id == Some(node_id) => Some(layer),
482        _ => None,
483    }) else {
484        return translate_bail("wrapped layer missing");
485    };
486    if !translate_layer_from_data(
487        applier,
488        inner,
489        dirty_nodes,
490        changed_nodes,
491        inner_ancestors,
492        data,
493        true,
494    ) {
495        return false;
496    }
497    let layer = std::mem::take(inner.as_mut());
498    let outer = outer_draws(node_id, slices.draw_commands(), outer_count, size)
499        .expect("outer command count is nonzero");
500    *container = wrap_layer_with_outer_draws(layer, placement, outer);
501    if ancestors.parent_content_offset != Point::default() {
502        container.transform_to_parent =
503            container
504                .transform_to_parent
505                .then(ProjectiveTransform::translation(
506                    ancestors.parent_content_offset.x,
507                    ancestors.parent_content_offset.y,
508                ));
509    }
510    for child in &mut container.children {
511        if let RenderNode::Layer(layer) = child {
512            crate::graph_hash::refresh_layer_own_raster_cache_hashes(
513                layer,
514                inner_ancestors.ancestor_hashed,
515            );
516        }
517    }
518    crate::graph_hash::refresh_layer_own_raster_cache_hashes(container, ancestors.ancestor_hashed);
519    true
520}
521
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
584struct TranslatedChildren {
585    placed_fresh: SmallVec<[(NodeId, cranpose_ui::widgets::LayoutState); 8]>,
586    children_unchanged: bool,
587    old_index_by_id: std::collections::HashMap<NodeId, usize>,
588}
589
590fn translated_children(
591    applier: &mut MemoryApplier,
592    container: &LayerNode,
593    dirty_nodes: &HashSet<NodeId>,
594    fresh_children: &[NodeId],
595) -> Result<TranslatedChildren, &'static str> {
596    let mut placed_fresh = SmallVec::<[_; 8]>::with_capacity(fresh_children.len());
597    for child_id in fresh_children {
598        let state = applier
599            .with_node::<LayoutNode, _>(*child_id, |node| node.layout_state())
600            .or_else(|_| {
601                applier.with_node::<SubcomposeLayoutNode, _>(*child_id, |node| node.layout_state())
602            });
603        let Ok(state) = state else {
604            continue;
605        };
606        if !state.is_placed() {
607            continue;
608        }
609        placed_fresh.push((*child_id, state));
610    }
611    let children_unchanged = container.children.len() == placed_fresh.len()
612        && container
613            .children
614            .iter()
615            .zip(&placed_fresh)
616            .all(|(child, (id, _))| {
617                matches!(child, RenderNode::Layer(layer) if layer_identity(layer) == Some(*id))
618            });
619    let old_index_by_id = if children_unchanged {
620        std::collections::HashMap::new()
621    } else {
622        let Some(index): Option<std::collections::HashMap<NodeId, usize>> = container
623            .children
624            .iter()
625            .enumerate()
626            .map(|(index, child)| match child {
627                RenderNode::Layer(layer) => layer_identity(layer).map(|id| (id, index)),
628                _ => None,
629            })
630            .collect()
631        else {
632            return Err("child without node id");
633        };
634        index
635    };
636    check_retained_children(
637        container,
638        dirty_nodes,
639        &placed_fresh,
640        children_unchanged,
641        &old_index_by_id,
642    )?;
643    Ok(TranslatedChildren {
644        placed_fresh,
645        children_unchanged,
646        old_index_by_id,
647    })
648}
649
650fn check_retained_children(
651    container: &LayerNode,
652    dirty_nodes: &HashSet<NodeId>,
653    placed_fresh: &[(NodeId, cranpose_ui::widgets::LayoutState)],
654    children_unchanged: bool,
655    old_index_by_id: &std::collections::HashMap<NodeId, usize>,
656) -> Result<(), &'static str> {
657    for (fresh_index, (child_id, state)) in placed_fresh.iter().enumerate() {
658        let old_index = if children_unchanged {
659            fresh_index
660        } else if let Some(index) = old_index_by_id.get(child_id) {
661            *index
662        } else {
663            continue;
664        };
665        let RenderNode::Layer(layer) = &container.children[old_index] else {
666            return Err("retained child slot is not a layer");
667        };
668        if dirty_nodes.contains(child_id) {
669            continue;
670        }
671        if layer.has_origin_sinks {
672            return Err("child subtree publishes window origins");
673        }
674        if state.size().width != layer.local_bounds.width
675            || state.size().height != layer.local_bounds.height
676        {
677            return Err("child resized");
678        }
679    }
680    Ok(())
681}
682
683#[derive(Clone, Copy)]
684struct TranslateGeometry {
685    content_offset: Point,
686    layer_translation: Point,
687    window_origin: Point,
688    child_origin: Point,
689    translation_delta: Point,
690}
691
692impl TranslateGeometry {
693    fn new(
694        container: &LayerNode,
695        layout_state: &cranpose_ui::widgets::LayoutState,
696        graphics_layer: &GraphicsLayer,
697        parent_abs: AbsOrigin,
698    ) -> Self {
699        let content_offset = layout_state.content_offset;
700        let top_left = Point {
701            x: parent_abs.content_origin.x + layout_state.position().x,
702            y: parent_abs.content_origin.y + layout_state.position().y,
703        };
704        let layer_translation = Point {
705            x: parent_abs.layer_translation.x + graphics_layer.translation_x,
706            y: parent_abs.layer_translation.y + graphics_layer.translation_y,
707        };
708        Self {
709            content_offset,
710            layer_translation,
711            window_origin: Point {
712                x: top_left.x + layer_translation.x,
713                y: top_left.y + layer_translation.y,
714            },
715            child_origin: Point {
716                x: top_left.x + content_offset.x,
717                y: top_left.y + content_offset.y,
718            },
719            translation_delta: Point {
720                x: layer_translation.x - container.scene_children_layer_translation.x,
721                y: layer_translation.y - container.scene_children_layer_translation.y,
722            },
723        }
724    }
725}
726
727fn build_entering_children(
728    applier: &mut MemoryApplier,
729    container: &LayerNode,
730    placed_fresh: &[(NodeId, cranpose_ui::widgets::LayoutState)],
731    retained: (bool, &std::collections::HashMap<NodeId, usize>),
732    geometry: TranslateGeometry,
733    inherited: (bool, bool),
734) -> std::collections::HashMap<NodeId, LayerNode> {
735    let (children_unchanged, old_index_by_id) = retained;
736    let (child_inherited_translated_content_context, children_ancestor_hashed) = inherited;
737    let mut entering: std::collections::HashMap<NodeId, LayerNode> =
738        std::collections::HashMap::new();
739    for (child_id, _) in placed_fresh {
740        if children_unchanged || old_index_by_id.contains_key(child_id) {
741            continue;
742        }
743        let Some(mut lowered) = build_layer_node_from_applier_internal(
744            applier,
745            *child_id,
746            container.motion_context_animated,
747            child_inherited_translated_content_context,
748            Some(AbsOrigin {
749                content_origin: geometry.child_origin,
750                layer_translation: geometry.layer_translation,
751            }),
752        ) else {
753            continue;
754        };
755        if geometry.content_offset != Point::default() {
756            lowered.transform_to_parent =
757                lowered
758                    .transform_to_parent
759                    .then(ProjectiveTransform::translation(
760                        geometry.content_offset.x,
761                        geometry.content_offset.y,
762                    ));
763        }
764        crate::graph_hash::recompute_layer_raster_cache_hashes_under(
765            &mut lowered,
766            children_ancestor_hashed,
767        );
768        entering.insert(*child_id, lowered);
769    }
770    entering
771}
772
773fn apply_translated_container_state(
774    container: &mut LayerNode,
775    modifier_slices: &ModifierNodeSlices,
776    layout_state: &cranpose_ui::widgets::LayoutState,
777    graphics_layer: &GraphicsLayer,
778    parent_content_offset: Point,
779    geometry: TranslateGeometry,
780) {
781    let mut transform = layer_transform_to_parent(
782        container.local_bounds,
783        layout_state.position(),
784        graphics_layer,
785    );
786    if parent_content_offset != Point::default() {
787        transform = transform.then(ProjectiveTransform::translation(
788            parent_content_offset.x,
789            parent_content_offset.y,
790        ));
791    }
792    container.transform_to_parent = transform;
793    container.content_offset = geometry.content_offset;
794    if container.translated_content_context {
795        container.translated_content_offset = modifier_slices
796            .translated_content_offset()
797            .unwrap_or(geometry.content_offset);
798    }
799    if let Some(sink) = modifier_slices.text_field_window_origin() {
800        sink.set(geometry.window_origin);
801    }
802    if let Some(sink) = modifier_slices.viewport_window_rect() {
803        sink.set(Rect {
804            x: geometry.window_origin.x,
805            y: geometry.window_origin.y,
806            width: layout_state.size().width,
807            height: layout_state.size().height,
808        });
809    }
810    container.scene_children_origin = geometry.child_origin;
811    container.scene_children_layer_translation = geometry.layer_translation;
812}
813
814fn reconcile_translated_children(
815    container: &mut LayerNode,
816    dirty_nodes: &mut HashSet<NodeId>,
817    changed_nodes: &mut Vec<NodeId>,
818    placed_fresh: &[(NodeId, cranpose_ui::widgets::LayoutState)],
819    children_unchanged: bool,
820    entering: &mut std::collections::HashMap<NodeId, LayerNode>,
821    geometry: TranslateGeometry,
822) {
823    if children_unchanged {
824        for (child, (child_id, state)) in container.children.iter_mut().zip(placed_fresh) {
825            let RenderNode::Layer(layer) = child else {
826                unreachable!("retained child identities were checked");
827            };
828            if !dirty_nodes.contains(child_id) {
829                translate_retained_child(
830                    layer,
831                    state,
832                    geometry.content_offset,
833                    geometry.child_origin,
834                    geometry.translation_delta,
835                );
836                changed_nodes.push(*child_id);
837            }
838        }
839        return;
840    }
841    let fresh_id_set: HashSet<NodeId> = placed_fresh.iter().map(|(id, _)| *id).collect();
842    let mut old_by_id: std::collections::HashMap<NodeId, Box<LayerNode>> =
843        std::collections::HashMap::new();
844    for child in container.children.drain(..) {
845        let RenderNode::Layer(layer) = child else {
846            continue;
847        };
848        let child_id = layer_identity(&layer).expect("checked above");
849        if fresh_id_set.contains(&child_id) {
850            old_by_id.insert(child_id, layer);
851        } else {
852            collect_layer_node_ids(&layer, changed_nodes);
853        }
854    }
855    let mut new_children = Vec::with_capacity(placed_fresh.len());
856    for (child_id, state) in placed_fresh {
857        if let Some(mut layer) = old_by_id.remove(child_id) {
858            if !dirty_nodes.contains(child_id) {
859                translate_retained_child(
860                    &mut layer,
861                    state,
862                    geometry.content_offset,
863                    geometry.child_origin,
864                    geometry.translation_delta,
865                );
866                changed_nodes.push(*child_id);
867            }
868            new_children.push(RenderNode::Layer(layer));
869        } else if let Some(lowered) = entering.remove(child_id) {
870            dirty_nodes.remove(child_id);
871            remove_dirty_descendants(&lowered, dirty_nodes);
872            collect_layer_node_ids(&lowered, changed_nodes);
873            new_children.push(RenderNode::Layer(Box::new(lowered)));
874        }
875    }
876    container.children = new_children;
877}
878
879fn translate_layer_from_data(
880    applier: &mut MemoryApplier,
881    container: &mut LayerNode,
882    dirty_nodes: &mut HashSet<NodeId>,
883    changed_nodes: &mut Vec<NodeId>,
884    ancestors: TranslateAncestorContext,
885    data: SnapshotNodeData,
886    wrapped: bool,
887) -> bool {
888    let TranslateAncestorContext {
889        inherited_motion_context_animated,
890        ancestor_hashed: container_ancestor_hashed,
891        inherited_translated_content_context,
892        parent_content_offset,
893        parent_abs,
894    } = ancestors;
895    let SnapshotNodeData {
896        layout_state,
897        modifier_slices,
898        resolved_modifiers: _,
899        children: fresh_children,
900        window_root,
901    } = data;
902    let layout_state = if window_root {
903        layout_state.at_origin()
904    } else {
905        layout_state
906    };
907    let container_plan = match translated_container(
908        container,
909        &layout_state,
910        &modifier_slices,
911        inherited_motion_context_animated,
912        wrapped,
913    ) {
914        Ok(plan) => plan,
915        Err(reason) => return translate_bail(reason),
916    };
917    let TranslatedContainer {
918        node_id,
919        clip_to_bounds,
920        graphics_layer,
921    } = container_plan;
922    let child_plan = match translated_children(applier, container, dirty_nodes, &fresh_children) {
923        Ok(plan) => plan,
924        Err(reason) => return translate_bail(reason),
925    };
926    let TranslatedChildren {
927        placed_fresh,
928        children_unchanged,
929        old_index_by_id,
930    } = child_plan;
931
932    let geometry = TranslateGeometry::new(container, &layout_state, &graphics_layer, parent_abs);
933    let child_inherited_translated_content_context =
934        inherited_translated_content_context || container.translated_content_context;
935    let children_ancestor_hashed =
936        crate::graph_hash::layer_children_ancestor_hashed(container, container_ancestor_hashed);
937    let mut entering = build_entering_children(
938        applier,
939        container,
940        &placed_fresh,
941        (children_unchanged, &old_index_by_id),
942        geometry,
943        (
944            child_inherited_translated_content_context,
945            children_ancestor_hashed,
946        ),
947    );
948
949    apply_translated_container_state(
950        container,
951        &modifier_slices,
952        &layout_state,
953        &graphics_layer,
954        parent_content_offset,
955        geometry,
956    );
957
958    reconcile_translated_children(
959        container,
960        dirty_nodes,
961        changed_nodes,
962        &placed_fresh,
963        children_unchanged,
964        &mut entering,
965        geometry,
966    );
967    modifier_slices.publish_pointer_input_size(layout_state.size());
968    container.hit_test = hit_test_from_slices(
969        &modifier_slices,
970        container.local_bounds,
971        clip_to_bounds || graphics_layer.clip,
972    );
973
974    container.has_hit_targets = container.hit_test.is_some()
975        || container.children.iter().any(|child| match child {
976            RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
977            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
978        });
979    container.has_origin_sinks = modifier_slices_have_origin_sinks(&modifier_slices)
980        || container.children.iter().any(|child| match child {
981            RenderNode::Layer(child_layer) => child_layer.has_origin_sinks,
982            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
983        });
984
985    crate::graph_hash::refresh_layer_own_raster_cache_hashes(container, container_ancestor_hashed);
986    changed_nodes.push(node_id);
987    true
988}
989
990fn translate_retained_child(
991    layer: &mut LayerNode,
992    state: &cranpose_ui::widgets::LayoutState,
993    content_offset: Point,
994    child_origin: Point,
995    translation_delta: Point,
996) {
997    let mut child_transform =
998        layer_transform_to_parent(layer.local_bounds, state.position(), &layer.graphics_layer);
999    if content_offset != Point::default() {
1000        child_transform = child_transform.then(ProjectiveTransform::translation(
1001            content_offset.x,
1002            content_offset.y,
1003        ));
1004    }
1005    layer.transform_to_parent = child_transform;
1006    let new_children_origin = Point {
1007        x: child_origin.x + state.position().x + layer.content_offset.x,
1008        y: child_origin.y + state.position().y + layer.content_offset.y,
1009    };
1010    let origin_delta = Point {
1011        x: new_children_origin.x - layer.scene_children_origin.x,
1012        y: new_children_origin.y - layer.scene_children_origin.y,
1013    };
1014    offset_scene_origins(layer, origin_delta, translation_delta);
1015}
1016
1017fn offset_scene_origins(layer: &mut LayerNode, origin_delta: Point, translation_delta: Point) {
1018    layer.scene_children_origin.x += origin_delta.x;
1019    layer.scene_children_origin.y += origin_delta.y;
1020    layer.scene_children_layer_translation.x += translation_delta.x;
1021    layer.scene_children_layer_translation.y += translation_delta.y;
1022    for child in &mut layer.children {
1023        if let RenderNode::Layer(child_layer) = child {
1024            offset_scene_origins(child_layer, origin_delta, translation_delta);
1025        }
1026    }
1027}
1028
1029fn layer_hit_graph_state_dirty(previous: &LayerNode, replacement: &LayerNode) -> bool {
1030    if previous.hit_test.is_some() || replacement.hit_test.is_some() {
1031        return true;
1032    }
1033
1034    if !(previous.has_hit_targets || replacement.has_hit_targets) {
1035        return false;
1036    }
1037
1038    previous.has_hit_targets != replacement.has_hit_targets
1039        || previous.local_bounds != replacement.local_bounds
1040        || previous.transform_to_parent != replacement.transform_to_parent
1041        || previous.clip_rect() != replacement.clip_rect()
1042        || previous.graphics_layer.shape != replacement.graphics_layer.shape
1043}
1044
1045fn collect_layer_node_ids(layer: &LayerNode, out: &mut Vec<NodeId>) {
1046    if let Some(node_id) = layer.node_id {
1047        out.push(node_id);
1048    }
1049    for child in &layer.children {
1050        if let RenderNode::Layer(child_layer) = child {
1051            collect_layer_node_ids(child_layer, out);
1052        }
1053    }
1054}
1055
1056fn remove_dirty_descendants(layer: &LayerNode, dirty_nodes: &mut HashSet<NodeId>) {
1057    for child in &layer.children {
1058        let RenderNode::Layer(child_layer) = child else {
1059            continue;
1060        };
1061        if let Some(node_id) = child_layer.node_id {
1062            dirty_nodes.remove(&node_id);
1063        }
1064        remove_dirty_descendants(child_layer, dirty_nodes);
1065    }
1066}
1067
1068fn build_layer_node(
1069    snapshot: BuildNodeSnapshot,
1070    _root_scale: f32,
1071    inherited_motion_context_animated: bool,
1072) -> LayerNode {
1073    build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
1074}
1075
1076fn build_layer_node_internal(
1077    snapshot: BuildNodeSnapshot,
1078    inherited_motion_context_animated: bool,
1079    inherited_translated_content_context: bool,
1080) -> LayerNode {
1081    let BuildNodeSnapshot {
1082        node_id,
1083        placement,
1084        size,
1085        content_offset,
1086        motion_context_animated,
1087        translated_content_context,
1088        has_own_origin_sinks,
1089        measured_max_width,
1090        resolved_modifiers,
1091        draw_commands,
1092        outer_draw_command_count,
1093        click_actions,
1094        pointer_inputs,
1095        pointer_icon,
1096        clip_to_bounds,
1097        annotated_text,
1098        text_style,
1099        text_layout_options,
1100        text_pan,
1101        graphics_layer,
1102        children: child_snapshots,
1103    } = snapshot;
1104    let outer = outer_draws(node_id, &draw_commands, outer_draw_command_count, size);
1105    let layer_draw_commands = &draw_commands[outer_draw_command_count..];
1106    let local_bounds = Rect {
1107        x: 0.0,
1108        y: 0.0,
1109        width: size.width,
1110        height: size.height,
1111    };
1112    let graphics_layer = graphics_layer.unwrap_or_default();
1113    let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
1114    let isolation = isolation_reasons(&graphics_layer);
1115    let cache_policy = if isolation.has_any() {
1116        CachePolicy::Auto
1117    } else {
1118        CachePolicy::None
1119    };
1120    let shadow_clip = clip_to_bounds.then_some(local_bounds);
1121    let hit_test = (!click_actions.is_empty()
1122        || !pointer_inputs.is_empty()
1123        || pointer_icon.is_some())
1124    .then(|| HitTestNode {
1125        shape: None,
1126        click_actions,
1127        pointer_inputs,
1128        pointer_icon,
1129        clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
1130    });
1131
1132    let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
1133    let child_translated_content_context =
1134        inherited_translated_content_context || translated_content_context;
1135
1136    let mut children = Vec::with_capacity(layer_node_capacity(
1137        layer_draw_commands,
1138        child_snapshots.len(),
1139        annotated_text.is_some(),
1140    ));
1141    append_draw_nodes(
1142        &mut children,
1143        node_id,
1144        layer_draw_commands,
1145        outer_draw_command_count,
1146        DrawPlacement::Behind,
1147        size,
1148        PrimitivePhase::BeforeChildren,
1149    );
1150    if let Some(text) = text_node_from_parts(TextNodeParts {
1151        node_id,
1152        local_bounds,
1153        measured_max_width,
1154        resolved_modifiers: &resolved_modifiers,
1155        annotated_text: annotated_text.as_ref(),
1156        text_style: text_style.as_ref(),
1157        text_layout_options,
1158        text_pan,
1159        modifier_slices: None,
1160    }) {
1161        children.push(RenderNode::Primitive(PrimitiveEntry {
1162            phase: PrimitivePhase::BeforeChildren,
1163            node: PrimitiveNode::Text(Box::new(text)),
1164        }));
1165    }
1166    let child_motion_context_animated = node_motion_context_animated;
1167    for child in child_snapshots {
1168        let mut child_layer = build_layer_node_internal(
1169            child,
1170            child_motion_context_animated,
1171            child_translated_content_context,
1172        );
1173        if content_offset != Point::default() {
1174            child_layer.transform_to_parent =
1175                child_layer
1176                    .transform_to_parent
1177                    .then(ProjectiveTransform::translation(
1178                        content_offset.x,
1179                        content_offset.y,
1180                    ));
1181        }
1182        children.push(RenderNode::Layer(Box::new(child_layer)));
1183    }
1184    append_draw_nodes(
1185        &mut children,
1186        node_id,
1187        layer_draw_commands,
1188        outer_draw_command_count,
1189        DrawPlacement::Overlay,
1190        size,
1191        PrimitivePhase::AfterChildren,
1192    );
1193    let has_hit_targets = hit_test.is_some()
1194        || children.iter().any(|child| match child {
1195            RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
1196            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1197        });
1198    let has_origin_sinks = has_own_origin_sinks
1199        || children.iter().any(|child| match child {
1200            RenderNode::Layer(child_layer) => child_layer.has_origin_sinks,
1201            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1202        });
1203
1204    let layer = LayerNode {
1205        node_id: Some(node_id),
1206        wraps: None,
1207        local_bounds,
1208        transform_to_parent,
1209        content_offset,
1210        motion_context_animated: node_motion_context_animated,
1211        translated_content_context,
1212        translated_content_offset: if translated_content_context {
1213            content_offset
1214        } else {
1215            Point::default()
1216        },
1217        scene_children_origin: Point::default(),
1218        scene_children_layer_translation: Point::default(),
1219        graphics_layer,
1220        clip_to_bounds,
1221        shadow_clip,
1222        hit_test,
1223        has_hit_targets,
1224        has_origin_sinks,
1225        isolation,
1226        cache_policy,
1227        cache_hashes: LayerRasterCacheHashes::default(),
1228        cache_hashes_valid: false,
1229        children,
1230    };
1231    finish_layer(layer, placement, outer)
1232}
1233
1234#[derive(Clone, Copy)]
1235struct AbsOrigin {
1236    content_origin: Point,
1237    layer_translation: Point,
1238}
1239
1240impl AbsOrigin {
1241    const ROOT: AbsOrigin = AbsOrigin {
1242        content_origin: Point { x: 0.0, y: 0.0 },
1243        layer_translation: Point { x: 0.0, y: 0.0 },
1244    };
1245}
1246
1247fn build_layer_node_from_applier(
1248    applier: &mut MemoryApplier,
1249    node_id: NodeId,
1250    _root_scale: f32,
1251    inherited_motion_context_animated: bool,
1252) -> Option<LayerNode> {
1253    let mut data = snapshot_node_data(applier, node_id)?;
1254    if data.window_root {
1255        data.layout_state = data.layout_state.at_origin();
1256    }
1257    build_layer_node_from_data(
1258        applier,
1259        node_id,
1260        data,
1261        inherited_motion_context_animated,
1262        false,
1263        Some(AbsOrigin::ROOT),
1264    )
1265}
1266
1267fn snapshot_node_data(applier: &mut MemoryApplier, node_id: NodeId) -> Option<SnapshotNodeData> {
1268    if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
1269        let state = node.layout_state();
1270        let mut children = SmallVec::new();
1271        node.collect_children_into(&mut children);
1272        let modifier_slices = node.modifier_slices_snapshot();
1273        SnapshotNodeData {
1274            layout_state: state,
1275            modifier_slices,
1276            resolved_modifiers: node.resolved_modifiers(),
1277            children,
1278            window_root: node.is_window_root(),
1279        }
1280    }) {
1281        return Some(data);
1282    }
1283
1284    applier
1285        .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
1286            let state = node.layout_state();
1287            let mut children = SmallVec::new();
1288            node.collect_children_into(&mut children);
1289            let modifier_slices = node.modifier_slices_snapshot();
1290            SnapshotNodeData {
1291                layout_state: state,
1292                modifier_slices,
1293                resolved_modifiers: node.resolved_modifiers(),
1294                children,
1295                window_root: false,
1296            }
1297        })
1298        .ok()
1299}
1300
1301fn build_layer_node_from_applier_internal(
1302    applier: &mut MemoryApplier,
1303    node_id: NodeId,
1304    inherited_motion_context_animated: bool,
1305    inherited_translated_content_context: bool,
1306    parent_abs: Option<AbsOrigin>,
1307) -> Option<LayerNode> {
1308    let data = snapshot_node_data(applier, node_id)?;
1309    if data.window_root {
1310        return None;
1311    }
1312    build_layer_node_from_data(
1313        applier,
1314        node_id,
1315        data,
1316        inherited_motion_context_animated,
1317        inherited_translated_content_context,
1318        parent_abs,
1319    )
1320}
1321
1322fn hit_test_from_slices(
1323    slices: &ModifierNodeSlices,
1324    bounds: Rect,
1325    clip: bool,
1326) -> Option<HitTestNode> {
1327    let click_actions = slices.click_handlers();
1328    let pointer_inputs = slices.pointer_inputs();
1329    let pointer_icon = slices.pointer_icon();
1330    (!click_actions.is_empty() || !pointer_inputs.is_empty() || pointer_icon.is_some()).then(|| {
1331        HitTestNode {
1332            shape: None,
1333            click_actions: click_actions.to_vec(),
1334            pointer_inputs: pointer_inputs.to_vec(),
1335            pointer_icon: pointer_icon.cloned(),
1336            clip: clip.then_some(bounds),
1337        }
1338    })
1339}
1340
1341fn build_layer_node_from_data(
1342    applier: &mut MemoryApplier,
1343    node_id: NodeId,
1344    data: SnapshotNodeData,
1345    inherited_motion_context_animated: bool,
1346    inherited_translated_content_context: bool,
1347    parent_abs: Option<AbsOrigin>,
1348) -> Option<LayerNode> {
1349    note_layer_lowered();
1350    let SnapshotNodeData {
1351        layout_state,
1352        modifier_slices,
1353        resolved_modifiers,
1354        children,
1355        window_root: _,
1356    } = data;
1357    if !layout_state.is_placed() {
1358        return None;
1359    }
1360
1361    let local_bounds = Rect {
1362        x: 0.0,
1363        y: 0.0,
1364        width: layout_state.size().width,
1365        height: layout_state.size().height,
1366    };
1367    if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
1368        eprintln!(
1369            "[scene-update-diag] build layer node={node_id:?} size=({:.2},{:.2}) pos=({:.2},{:.2})",
1370            layout_state.size().width,
1371            layout_state.size().height,
1372            layout_state.position().x,
1373            layout_state.position().y,
1374        );
1375    }
1376    let clip_to_bounds = modifier_slices.clip_to_bounds();
1377    let graphics_layer = graphics_layer_with_shaped_clip(
1378        modifier_slices.graphics_layer().unwrap_or_default(),
1379        clip_to_bounds,
1380        modifier_slices.corner_shape(),
1381        local_bounds,
1382    );
1383    let transform_to_parent =
1384        layer_transform_to_parent(local_bounds, layout_state.position(), &graphics_layer);
1385    let isolation = isolation_reasons(&graphics_layer);
1386    let cache_policy = if isolation.has_any() {
1387        CachePolicy::Auto
1388    } else {
1389        CachePolicy::None
1390    };
1391    let shadow_clip = clip_to_bounds.then_some(local_bounds);
1392    let hit_test = hit_test_from_slices(
1393        &modifier_slices,
1394        local_bounds,
1395        clip_to_bounds || graphics_layer.clip,
1396    );
1397
1398    modifier_slices.publish_pointer_input_size(layout_state.size());
1399
1400    let node_motion_context_animated =
1401        inherited_motion_context_animated || modifier_slices.motion_context_animated();
1402    let local_translated_content_context = modifier_slices.translated_content_context();
1403    let local_translated_content_offset = modifier_slices
1404        .translated_content_offset()
1405        .unwrap_or(layout_state.content_offset);
1406    let child_translated_content_context =
1407        inherited_translated_content_context || local_translated_content_context;
1408
1409    let this_abs = parent_abs.map(|parent| {
1410        let top_left = Point {
1411            x: parent.content_origin.x + layout_state.position().x,
1412            y: parent.content_origin.y + layout_state.position().y,
1413        };
1414        let layer_translation = Point {
1415            x: parent.layer_translation.x + graphics_layer.translation_x,
1416            y: parent.layer_translation.y + graphics_layer.translation_y,
1417        };
1418        (top_left, layer_translation)
1419    });
1420    if let Some((top_left, layer_translation)) = this_abs {
1421        let window_origin = Point {
1422            x: top_left.x + layer_translation.x,
1423            y: top_left.y + layer_translation.y,
1424        };
1425        if let Some(sink) = modifier_slices.text_field_window_origin() {
1426            sink.set(window_origin);
1427        }
1428        if let Some(sink) = modifier_slices.viewport_window_rect() {
1429            sink.set(Rect {
1430                x: window_origin.x,
1431                y: window_origin.y,
1432                width: layout_state.size().width,
1433                height: layout_state.size().height,
1434            });
1435        }
1436    }
1437    let child_abs = this_abs.map(|(top_left, layer_translation)| AbsOrigin {
1438        content_origin: Point {
1439            x: top_left.x + layout_state.content_offset.x,
1440            y: top_left.y + layout_state.content_offset.y,
1441        },
1442        layer_translation,
1443    });
1444
1445    let outer_draw_command_count = modifier_slices.outer_draw_command_count();
1446    let outer = outer_draws(
1447        node_id,
1448        modifier_slices.draw_commands(),
1449        outer_draw_command_count,
1450        layout_state.size(),
1451    );
1452    let layer_draw_commands = &modifier_slices.draw_commands()[outer_draw_command_count..];
1453    let mut render_children = Vec::with_capacity(layer_node_capacity(
1454        layer_draw_commands,
1455        children.len(),
1456        modifier_slices.annotated_text().is_some(),
1457    ));
1458    append_draw_nodes(
1459        &mut render_children,
1460        node_id,
1461        layer_draw_commands,
1462        outer_draw_command_count,
1463        DrawPlacement::Behind,
1464        layout_state.size(),
1465        PrimitivePhase::BeforeChildren,
1466    );
1467    if let Some(text) = text_node_from_parts(TextNodeParts {
1468        node_id,
1469        local_bounds,
1470        measured_max_width: layout_state
1471            .measurement_constraints
1472            .max_width
1473            .is_finite()
1474            .then_some(layout_state.measurement_constraints.max_width),
1475        resolved_modifiers: &resolved_modifiers,
1476        annotated_text: modifier_slices.annotated_text(),
1477        text_style: modifier_slices.text_style(),
1478        text_layout_options: modifier_slices.text_layout_options(),
1479        text_pan: modifier_slices.text_pan_resolver(),
1480        modifier_slices: Some(modifier_slices.as_ref()),
1481    }) {
1482        render_children.push(RenderNode::Primitive(PrimitiveEntry {
1483            phase: PrimitivePhase::BeforeChildren,
1484            node: PrimitiveNode::Text(Box::new(text)),
1485        }));
1486    }
1487    let child_motion_context_animated = node_motion_context_animated;
1488    for child_id in children {
1489        let Some(mut child_layer) = build_layer_node_from_applier_internal(
1490            applier,
1491            child_id,
1492            child_motion_context_animated,
1493            child_translated_content_context,
1494            child_abs,
1495        ) else {
1496            continue;
1497        };
1498        if layout_state.content_offset != Point::default() {
1499            child_layer.transform_to_parent =
1500                child_layer
1501                    .transform_to_parent
1502                    .then(ProjectiveTransform::translation(
1503                        layout_state.content_offset.x,
1504                        layout_state.content_offset.y,
1505                    ));
1506        }
1507        render_children.push(RenderNode::Layer(Box::new(child_layer)));
1508    }
1509    append_draw_nodes(
1510        &mut render_children,
1511        node_id,
1512        layer_draw_commands,
1513        outer_draw_command_count,
1514        DrawPlacement::Overlay,
1515        layout_state.size(),
1516        PrimitivePhase::AfterChildren,
1517    );
1518    let has_hit_targets = hit_test.is_some()
1519        || render_children.iter().any(|child| match child {
1520            RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
1521            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1522        });
1523    let has_origin_sinks = modifier_slices_have_origin_sinks(&modifier_slices)
1524        || render_children.iter().any(|child| match child {
1525            RenderNode::Layer(child_layer) => child_layer.has_origin_sinks,
1526            RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1527        });
1528
1529    let layer = LayerNode {
1530        node_id: Some(node_id),
1531        wraps: None,
1532        local_bounds,
1533        transform_to_parent,
1534        content_offset: layout_state.content_offset,
1535        motion_context_animated: node_motion_context_animated,
1536        translated_content_context: local_translated_content_context,
1537        translated_content_offset: if local_translated_content_context {
1538            local_translated_content_offset
1539        } else {
1540            Point::default()
1541        },
1542        scene_children_origin: child_abs.map(|c| c.content_origin).unwrap_or_default(),
1543        scene_children_layer_translation: child_abs
1544            .map(|c| c.layer_translation)
1545            .unwrap_or_default(),
1546        graphics_layer,
1547        clip_to_bounds,
1548        shadow_clip,
1549        hit_test,
1550        has_hit_targets,
1551        has_origin_sinks,
1552        isolation,
1553        cache_policy,
1554        cache_hashes: LayerRasterCacheHashes::default(),
1555        cache_hashes_valid: false,
1556        children: render_children,
1557    };
1558    Some(finish_layer(layer, layout_state.position(), outer))
1559}
1560
1561struct RecorderSlot {
1562    generation: u64,
1563    handles: [Option<Rc<CommandRecording>>; 2],
1564}
1565
1566thread_local! {
1567    static COMMAND_RECORDINGS: std::cell::RefCell<
1568        std::collections::HashMap<DrawCommandId, RecorderSlot, cranpose_ui_graphics::FxBuildHasher>,
1569    > = std::cell::RefCell::new(std::collections::HashMap::default());
1570    static RECORDING_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1571}
1572
1573#[doc(hidden)]
1574pub fn clear_command_recordings_for_tests() {
1575    COMMAND_RECORDINGS.with(|map| map.borrow_mut().clear());
1576}
1577
1578fn bump_recording_generation() {
1579    let generation = RECORDING_GENERATION.with(|cell| {
1580        let next = cell.get().wrapping_add(1);
1581        cell.set(next);
1582        next
1583    });
1584    if generation.is_multiple_of(512) {
1585        COMMAND_RECORDINGS.with(|map| {
1586            map.borrow_mut()
1587                .retain(|_, slot| generation.wrapping_sub(slot.generation) <= 64);
1588        });
1589    }
1590}
1591
1592fn acquire_storage(id: DrawCommandId) -> CommandRecording {
1593    COMMAND_RECORDINGS.with(|map| {
1594        let mut map = map.borrow_mut();
1595        let Some(slot) = map.get_mut(&id) else {
1596            return CommandRecording::default();
1597        };
1598        for handle in &mut slot.handles {
1599            if handle
1600                .as_ref()
1601                .is_some_and(|shared| Rc::strong_count(shared) == 1)
1602            {
1603                let shared = handle.take().expect("checked some above");
1604                return Rc::try_unwrap(shared).expect("sole owner checked above");
1605            }
1606        }
1607        CommandRecording::default()
1608    })
1609}
1610
1611fn publish_recording(id: DrawCommandId, recording: CommandRecording) -> Rc<CommandRecording> {
1612    let shared = Rc::new(recording);
1613    COMMAND_RECORDINGS.with(|map| {
1614        let mut map = map.borrow_mut();
1615        let generation = RECORDING_GENERATION.with(std::cell::Cell::get);
1616        let slot = map.entry(id).or_insert_with(|| RecorderSlot {
1617            generation,
1618            handles: [None, None],
1619        });
1620        slot.generation = generation;
1621        slot.handles[1] = slot.handles[0].take();
1622        slot.handles[0] = Some(shared.clone());
1623    });
1624    shared
1625}
1626
1627fn layer_node_capacity(commands: &[DrawCommand], children: usize, has_text: bool) -> usize {
1628    children
1629        + usize::from(has_text)
1630        + commands.len()
1631        + commands
1632            .iter()
1633            .filter(|command| matches!(command, DrawCommand::WithContent(_)))
1634            .count()
1635}
1636
1637fn draw_nodes(
1638    node_id: NodeId,
1639    commands: &[DrawCommand],
1640    first_command_index: usize,
1641    placement: DrawPlacement,
1642    size: Size,
1643    phase: PrimitivePhase,
1644) -> Vec<RenderNode> {
1645    let mut nodes = Vec::new();
1646    append_draw_nodes(
1647        &mut nodes,
1648        node_id,
1649        commands,
1650        first_command_index,
1651        placement,
1652        size,
1653        phase,
1654    );
1655    nodes
1656}
1657
1658fn append_draw_nodes(
1659    nodes: &mut Vec<RenderNode>,
1660    node_id: NodeId,
1661    commands: &[DrawCommand],
1662    first_command_index: usize,
1663    placement: DrawPlacement,
1664    size: Size,
1665    phase: PrimitivePhase,
1666) {
1667    for (command_index, command) in commands.iter().enumerate() {
1668        let id = DrawCommandId {
1669            node_id,
1670            command_index: (first_command_index + command_index) as u32,
1671            placement,
1672        };
1673        let Some((recording, segments)) =
1674            recording_for_placement_reusing(command, placement, size, || acquire_storage(id))
1675        else {
1676            retain_empty_draw_command(nodes, phase, id, placement, command);
1677            continue;
1678        };
1679        let shared = publish_recording(id, recording);
1680        if shared.is_empty_in(&segments) {
1681            retain_empty_draw_command(nodes, phase, id, placement, command);
1682            continue;
1683        }
1684        nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_shared(
1685            phase,
1686            Some(id),
1687            shared,
1688            segments,
1689        )));
1690    }
1691}
1692
1693fn retain_empty_draw_command(
1694    nodes: &mut Vec<RenderNode>,
1695    phase: PrimitivePhase,
1696    id: DrawCommandId,
1697    placement: DrawPlacement,
1698    command: &DrawCommand,
1699) {
1700    if matches!(
1701        (placement, command),
1702        (DrawPlacement::Behind, DrawCommand::Behind(_))
1703            | (DrawPlacement::Overlay, DrawCommand::Overlay(_))
1704            | (_, DrawCommand::WithContent(_))
1705    ) {
1706        nodes.push(RenderNode::DrawRun(DrawRunNode::for_command(
1707            phase,
1708            Some(id),
1709            Vec::new(),
1710        )));
1711    }
1712}
1713
1714#[doc(hidden)]
1715pub fn draw_command_nodes_for_tests(
1716    node_id: NodeId,
1717    commands: &[DrawCommand],
1718    placement: DrawPlacement,
1719    size: Size,
1720    phase: PrimitivePhase,
1721) -> Vec<RenderNode> {
1722    bump_recording_generation();
1723    draw_nodes(node_id, commands, 0, placement, size, phase)
1724}
1725
1726struct OuterDraws {
1727    behind: Vec<RenderNode>,
1728    overlay: Vec<RenderNode>,
1729}
1730
1731fn outer_draws(
1732    node_id: NodeId,
1733    draw_commands: &[DrawCommand],
1734    outer_draw_command_count: usize,
1735    size: Size,
1736) -> Option<OuterDraws> {
1737    (outer_draw_command_count > 0).then(|| {
1738        let commands = &draw_commands[..outer_draw_command_count];
1739        OuterDraws {
1740            behind: draw_nodes(
1741                node_id,
1742                commands,
1743                0,
1744                DrawPlacement::Behind,
1745                size,
1746                PrimitivePhase::BeforeChildren,
1747            ),
1748            overlay: draw_nodes(
1749                node_id,
1750                commands,
1751                0,
1752                DrawPlacement::Overlay,
1753                size,
1754                PrimitivePhase::AfterChildren,
1755            ),
1756        }
1757    })
1758}
1759
1760fn finish_layer(layer: LayerNode, placement: Point, outer: Option<OuterDraws>) -> LayerNode {
1761    match outer {
1762        Some(outer) => wrap_layer_with_outer_draws(layer, placement, outer),
1763        None => layer,
1764    }
1765}
1766
1767fn wrap_layer_with_outer_draws(
1768    mut layer: LayerNode,
1769    placement: Point,
1770    outer: OuterDraws,
1771) -> LayerNode {
1772    let local_bounds = layer.local_bounds;
1773    layer.transform_to_parent =
1774        layer_transform_to_parent(local_bounds, Point::default(), &layer.graphics_layer);
1775    let wrapper = LayerNode {
1776        wraps: layer.node_id,
1777        local_bounds,
1778        transform_to_parent: layer_transform_to_parent(
1779            local_bounds,
1780            placement,
1781            &GraphicsLayer::default(),
1782        ),
1783        scene_children_origin: Point {
1784            x: layer.scene_children_origin.x - layer.content_offset.x,
1785            y: layer.scene_children_origin.y - layer.content_offset.y,
1786        },
1787        scene_children_layer_translation: Point {
1788            x: layer.scene_children_layer_translation.x - layer.graphics_layer.translation_x,
1789            y: layer.scene_children_layer_translation.y - layer.graphics_layer.translation_y,
1790        },
1791        motion_context_animated: layer.motion_context_animated,
1792        has_hit_targets: layer.has_hit_targets,
1793        has_origin_sinks: layer.has_origin_sinks,
1794        ..Default::default()
1795    };
1796    let mut children = outer.behind;
1797    children.push(RenderNode::Layer(Box::new(layer)));
1798    children.extend(outer.overlay);
1799    LayerNode {
1800        children,
1801        ..wrapper
1802    }
1803}
1804
1805fn layer_identity(layer: &LayerNode) -> Option<NodeId> {
1806    layer.node_id.or(layer.wraps)
1807}
1808
1809struct TextNodeParts<'a> {
1810    node_id: NodeId,
1811    local_bounds: Rect,
1812    measured_max_width: Option<f32>,
1813    resolved_modifiers: &'a ResolvedModifiers,
1814    annotated_text: Option<&'a AnnotatedString>,
1815    text_style: Option<&'a TextStyle>,
1816    text_layout_options: Option<TextLayoutOptions>,
1817    text_pan: Option<TextPanResolver>,
1818    modifier_slices: Option<&'a ModifierNodeSlices>,
1819}
1820
1821fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
1822    let TextNodeParts {
1823        node_id,
1824        local_bounds,
1825        measured_max_width,
1826        resolved_modifiers,
1827        annotated_text,
1828        text_style,
1829        text_layout_options,
1830        text_pan,
1831        modifier_slices,
1832    } = parts;
1833    let value = annotated_text?;
1834    let default_text_style = TextStyle::default();
1835    let text_style = text_style.cloned().unwrap_or(default_text_style);
1836    let options = text_layout_options.unwrap_or_default().normalized();
1837    let padding = resolved_modifiers.padding();
1838    let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
1839    if content_width <= 0.0 {
1840        return None;
1841    }
1842
1843    let pan_offset = text_pan
1844        .as_ref()
1845        .map(|resolve| resolve(content_width))
1846        .unwrap_or(0.0);
1847    let pans_horizontally = text_pan.is_some();
1848
1849    let max_width = if pans_horizontally {
1850        None
1851    } else {
1852        let measure_width =
1853            resolve_text_measure_width(content_width, padding, measured_max_width, options);
1854        Some(measure_width).filter(|width| width.is_finite() && *width > 0.0)
1855    };
1856    let prepared = modifier_slices
1857        .and_then(|slices| slices.prepare_text_layout(max_width))
1858        .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
1859    let visual_style = prepared.visual_style.clone();
1860    let measured_draw_width = prepared.metrics.width.max(0.0);
1861    let draw_width = if options.overflow == TextOverflow::Visible || pans_horizontally {
1862        measured_draw_width
1863    } else {
1864        measured_draw_width.min(content_width)
1865    };
1866    let alignment_offset = resolve_text_horizontal_offset(
1867        &text_style,
1868        prepared.text.text.as_str(),
1869        content_width,
1870        prepared.metrics.width,
1871    );
1872    let rect = Rect {
1873        x: padding.left + alignment_offset - pan_offset,
1874        y: padding.top,
1875        width: draw_width,
1876        height: prepared.metrics.height,
1877    };
1878    let text_bounds = Rect {
1879        x: padding.left,
1880        y: padding.top,
1881        width: content_width,
1882        height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
1883    };
1884    let font_size = visual_style.resolve_font_size(14.0);
1885    let expanded_bounds =
1886        expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
1887    let clip = if options.overflow == TextOverflow::Visible && !pans_horizontally {
1888        None
1889    } else {
1890        Some(pad_clip_rect(expanded_bounds))
1891    };
1892
1893    Some(TextPrimitiveNode {
1894        node_id,
1895        rect,
1896        text: prepared.text,
1897        text_style: visual_style,
1898        font_size,
1899        layout_options: options,
1900        clip,
1901    })
1902}
1903
1904fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
1905    let placement = parent
1906        .map(|parent_box| Point {
1907            x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
1908            y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
1909        })
1910        .unwrap_or_default();
1911    let mut children = Vec::with_capacity(node.children.len());
1912    for child in &node.children {
1913        children.push(layout_box_to_snapshot(child, Some(node)));
1914    }
1915    let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
1916    let graphics_layer = graphics_layer_with_shaped_clip(
1917        base_graphics_layer.clone().unwrap_or_default(),
1918        node.node_data.modifier_slices.clip_to_bounds(),
1919        node.node_data.modifier_slices.corner_shape(),
1920        Rect {
1921            x: 0.0,
1922            y: 0.0,
1923            width: node.rect.width,
1924            height: node.rect.height,
1925        },
1926    );
1927    let has_graphics_layer =
1928        base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
1929
1930    BuildNodeSnapshot {
1931        node_id: node.node_id,
1932        placement,
1933        size: Size {
1934            width: node.rect.width,
1935            height: node.rect.height,
1936        },
1937        content_offset: node.content_offset,
1938        motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
1939        translated_content_context: node.node_data.modifier_slices.translated_content_context(),
1940        has_own_origin_sinks: modifier_slices_have_origin_sinks(&node.node_data.modifier_slices),
1941        measured_max_width: None,
1942        resolved_modifiers: node.node_data.resolved_modifiers,
1943        draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
1944        outer_draw_command_count: node.node_data.modifier_slices.outer_draw_command_count(),
1945        click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
1946        pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
1947        pointer_icon: node.node_data.modifier_slices.pointer_icon().cloned(),
1948        clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
1949        annotated_text: node.node_data.modifier_slices.annotated_string(),
1950        text_style: node.node_data.modifier_slices.text_style().cloned(),
1951        text_layout_options: node.node_data.modifier_slices.text_layout_options(),
1952        text_pan: node.node_data.modifier_slices.text_pan_resolver(),
1953        graphics_layer: has_graphics_layer.then_some(graphics_layer),
1954        children,
1955    }
1956}
1957
1958fn modifier_slices_have_origin_sinks(slices: &ModifierNodeSlices) -> bool {
1959    slices.text_field_window_origin().is_some() || slices.viewport_window_rect().is_some()
1960}
1961
1962fn graphics_layer_with_shaped_clip(
1963    mut graphics_layer: GraphicsLayer,
1964    clip_to_bounds: bool,
1965    corner_shape: Option<RoundedCornerShape>,
1966    local_bounds: Rect,
1967) -> GraphicsLayer {
1968    if !clip_to_bounds {
1969        return graphics_layer;
1970    }
1971
1972    let Some(corner_shape) = corner_shape else {
1973        return graphics_layer;
1974    };
1975    let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
1976    if radii.top_left <= f32::EPSILON
1977        && radii.top_right <= f32::EPSILON
1978        && radii.bottom_right <= f32::EPSILON
1979        && radii.bottom_left <= f32::EPSILON
1980    {
1981        return graphics_layer;
1982    }
1983
1984    if let Some(existing) = graphics_layer.render_effect.take() {
1985        let rounded_clip = rounded_corner_alpha_mask_effect(
1986            local_bounds.width,
1987            local_bounds.height,
1988            radii,
1989            ROUNDED_CLIP_EDGE_FEATHER,
1990        );
1991        graphics_layer.render_effect = Some(existing.then(rounded_clip));
1992    } else {
1993        graphics_layer.shape = LayerShape::Rounded(corner_shape);
1994        graphics_layer.clip = true;
1995    }
1996    graphics_layer
1997}
1998
1999fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
2000    IsolationReasons {
2001        explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
2002        shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
2003        effect: layer.render_effect.is_some(),
2004        backdrop: layer.backdrop_effect.is_some(),
2005        group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
2006            && layer.alpha < 1.0,
2007        blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
2008    }
2009}
2010
2011fn pad_clip_rect(rect: Rect) -> Rect {
2012    Rect {
2013        x: rect.x - TEXT_CLIP_PAD,
2014        y: rect.y - TEXT_CLIP_PAD,
2015        width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
2016        height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
2017    }
2018}
2019
2020pub fn expand_text_bounds_for_baseline_shift(
2021    text_bounds: Rect,
2022    text_style: &TextStyle,
2023    font_size: f32,
2024) -> Rect {
2025    let baseline_shift_px = text_style
2026        .span_style
2027        .baseline_shift
2028        .filter(|shift| shift.is_specified())
2029        .map(|shift| -(shift.0 * font_size))
2030        .unwrap_or(0.0);
2031    if baseline_shift_px == 0.0 {
2032        return text_bounds;
2033    }
2034
2035    if baseline_shift_px < 0.0 {
2036        Rect {
2037            x: text_bounds.x,
2038            y: text_bounds.y + baseline_shift_px,
2039            width: text_bounds.width,
2040            height: (text_bounds.height - baseline_shift_px).max(0.0),
2041        }
2042    } else {
2043        Rect {
2044            x: text_bounds.x,
2045            y: text_bounds.y,
2046            width: text_bounds.width,
2047            height: (text_bounds.height + baseline_shift_px).max(0.0),
2048        }
2049    }
2050}
2051
2052/// The width the paint pass must lay this paragraph out at.
2053///
2054/// **It is the width LAYOUT wrapped at, not the width the node ended up.** A
2055/// `Text` without `fill_max_width` is placed at its own `metrics.width` — the
2056/// widest line it produced — which is by construction NARROWER than the
2057/// constraint it wrapped under. Re-wrapping at that narrower width is not the
2058/// no-op it looks like: the widest line is the one that exactly fills the
2059/// limit, so measuring it against itself puts its last word over the edge and
2060/// the paragraph gains a line. Measured against the real font backend
2061/// (`SoftwareTextMeasurer`, the one the wgpu renderer installs), that fires on
2062/// 46% of multi-line paragraphs — the block then paints a line taller than the
2063/// box layout reserved for it, its last line is clipped away, and every
2064/// following sibling has been placed as if that line did not exist.
2065///
2066/// So an unlimited soft-wrapping clip paragraph keeps the measurement width
2067/// even when the node came out narrower — `may_expand_to_avoid_synthetic_wrap`.
2068/// The modes that deliberately re-fit (no soft wrap, a finite `max_lines`, or
2069/// an ellipsis budget) still take the node's own width, because for those the
2070/// node width IS the fitting constraint.
2071///
2072/// This is the shared implementation. It exists because the wgpu and pixels
2073/// pipelines each grew a private copy WITH this rule and its contract tests,
2074/// while the scene builder — the copy that the retained render graph actually
2075/// runs — kept a plain `available.min(content_width)`. The two private copies
2076/// were reachable only from their own tests. One function now, so the tests
2077/// guard the code that runs.
2078pub fn resolve_text_measure_width(
2079    content_width: f32,
2080    padding: cranpose_ui::EdgeInsets,
2081    measured_max_width: Option<f32>,
2082    options: TextLayoutOptions,
2083) -> f32 {
2084    let width = content_width.max(0.0);
2085    if let Some(max_width) = measured_max_width.filter(|w| w.is_finite() && *w > 0.0) {
2086        let measured_content_width = (max_width - padding.left - padding.right).max(0.0);
2087        if measured_content_width <= width {
2088            return measured_content_width;
2089        }
2090
2091        let may_expand_to_avoid_synthetic_wrap = options.soft_wrap
2092            && options.max_lines == usize::MAX
2093            && options.overflow == TextOverflow::Clip;
2094        if may_expand_to_avoid_synthetic_wrap {
2095            return measured_content_width;
2096        }
2097    }
2098    width
2099}
2100
2101/// How much of the slack a `TextAlign` puts *before* the text: 0 at the start
2102/// edge, 0.5 centred, 1 at the end edge.
2103///
2104/// Split out because the same fraction has to be applied twice and by two
2105/// different pieces of code. Compose aligns a paragraph **line by line** —
2106/// `TextAlign.Center` centres each line in the paragraph's width, it does not
2107/// centre the paragraph's box in its parent — so the block offset computed
2108/// here and the per-line offset the rasteriser applies inside the block are
2109/// two halves of one rule. They telescope: block at `(box - block) * f`, line
2110/// at `(block - line) * f`, which sums to `(box - line) * f`, exactly the
2111/// offset Compose gives that line. Getting one without the other leaves every
2112/// wrapped continuation line start-aligned under a centred first line.
2113pub fn text_align_fraction(text_style: &TextStyle, text: &str) -> f32 {
2114    let paragraph_style = &text_style.paragraph_style;
2115    let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
2116    let rtl = direction == cranpose_ui::text::ResolvedTextDirection::Rtl;
2117    match paragraph_style.text_align {
2118        TextAlign::Center => 0.5,
2119        TextAlign::End | TextAlign::Right => 1.0,
2120        TextAlign::Start | TextAlign::Left | TextAlign::Justify | TextAlign::Unspecified => {
2121            if rtl {
2122                1.0
2123            } else {
2124                0.0
2125            }
2126        }
2127    }
2128}
2129
2130fn resolve_text_horizontal_offset(
2131    text_style: &TextStyle,
2132    text: &str,
2133    content_width: f32,
2134    measured_width: f32,
2135) -> f32 {
2136    let remaining = (content_width - measured_width).max(0.0);
2137    remaining * text_align_fraction(text_style, text)
2138}
2139
2140#[cfg(test)]
2141#[path = "tests/scene_builder_tests.rs"]
2142mod tests;