Skip to main content

cranpose_render_common/
scene_builder.rs

1use std::rc::Rc;
2
3use cranpose_core::{MemoryApplier, NodeId};
4use cranpose_ui::text::AnnotatedString;
5use cranpose_ui::text::{resolve_text_direction, TextAlign, TextStyle};
6use cranpose_ui::{
7    prepare_text_layout, DrawCommand, LayoutBox, LayoutNode, ModifierNodeSlices, Point, Rect,
8    ResolvedModifiers, Size, SubcomposeLayoutNode, TextLayoutOptions, TextOverflow,
9};
10use cranpose_ui_graphics::{
11    rounded_corner_alpha_mask_effect, CompositingStrategy, GraphicsLayer, RoundedCornerShape,
12};
13
14use crate::graph::{
15    CachePolicy, DrawPrimitiveNode, HitTestNode, IsolationReasons, LayerNode, PrimitiveEntry,
16    PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode, TextPrimitiveNode,
17};
18use crate::layer_transform::layer_transform_to_parent;
19use crate::raster_cache::LayerRasterCacheHashes;
20use crate::style_shared::{primitives_for_placement, DrawPlacement};
21
22const TEXT_CLIP_PAD: f32 = 1.0;
23const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
24
25#[derive(Clone)]
26struct BuildNodeSnapshot {
27    node_id: NodeId,
28    placement: Point,
29    size: Size,
30    content_offset: Point,
31    motion_context_animated: bool,
32    translated_content_context: bool,
33    measured_max_width: Option<f32>,
34    resolved_modifiers: ResolvedModifiers,
35    draw_commands: Vec<DrawCommand>,
36    click_actions: Vec<Rc<dyn Fn(Point)>>,
37    pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
38    clip_to_bounds: bool,
39    annotated_text: Option<AnnotatedString>,
40    text_style: Option<TextStyle>,
41    text_layout_options: Option<TextLayoutOptions>,
42    graphics_layer: Option<GraphicsLayer>,
43    children: Vec<Self>,
44}
45
46struct SnapshotNodeData {
47    layout_state: cranpose_ui::widgets::LayoutState,
48    modifier_slices: Rc<ModifierNodeSlices>,
49    resolved_modifiers: ResolvedModifiers,
50    children: Vec<NodeId>,
51}
52
53pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
54    let root_snapshot = layout_box_to_snapshot(root, None);
55    RenderGraph {
56        root: build_layer_node(root_snapshot, scale, false),
57    }
58}
59
60pub fn build_graph_from_applier(
61    applier: &mut MemoryApplier,
62    root: NodeId,
63    scale: f32,
64) -> Option<RenderGraph> {
65    Some(RenderGraph {
66        root: build_layer_node_from_applier(applier, root, scale, false)?,
67    })
68}
69
70pub fn update_graph_from_applier(
71    applier: &mut MemoryApplier,
72    graph: &mut RenderGraph,
73    dirty_nodes: &[NodeId],
74    scale: f32,
75) -> bool {
76    if dirty_nodes.is_empty() {
77        return true;
78    }
79
80    let mut updated = false;
81    for &node_id in dirty_nodes {
82        if graph.root.node_id == Some(node_id) {
83            let Some(root) = build_layer_node_from_applier(applier, node_id, scale, false) else {
84                return false;
85            };
86            graph.root = root;
87            updated = true;
88            continue;
89        }
90
91        let inherited_translated_content_context = graph.root.translated_content_context;
92        match replace_layer_from_applier(
93            applier,
94            &mut graph.root,
95            node_id,
96            inherited_translated_content_context,
97        ) {
98            Some(true) => updated = true,
99            Some(false) => return false,
100            None => return false,
101        }
102    }
103
104    if updated {
105        graph.root.recompute_raster_cache_hashes();
106    }
107    true
108}
109
110fn replace_layer_from_applier(
111    applier: &mut MemoryApplier,
112    parent: &mut LayerNode,
113    node_id: NodeId,
114    inherited_translated_content_context: bool,
115) -> Option<bool> {
116    let child_inherited_translated_content_context =
117        inherited_translated_content_context || parent.translated_content_context;
118
119    for child in &mut parent.children {
120        let RenderNode::Layer(child_layer) = child else {
121            continue;
122        };
123
124        if child_layer.node_id == Some(node_id) {
125            let old_transform = child_layer.transform_to_parent;
126            let Some(mut replacement) = build_layer_node_from_applier_internal(
127                applier,
128                node_id,
129                parent.motion_context_animated,
130                child_inherited_translated_content_context,
131            ) else {
132                return Some(false);
133            };
134            replacement.transform_to_parent = old_transform;
135            **child_layer = replacement;
136            parent.has_hit_targets = parent.hit_test.is_some()
137                || parent.children.iter().any(|child| match child {
138                    RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
139                    RenderNode::Primitive(_) => false,
140                });
141            return Some(true);
142        }
143
144        if let Some(updated) = replace_layer_from_applier(
145            applier,
146            child_layer,
147            node_id,
148            child_inherited_translated_content_context,
149        ) {
150            if updated {
151                parent.has_hit_targets = parent.hit_test.is_some()
152                    || parent.children.iter().any(|child| match child {
153                        RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
154                        RenderNode::Primitive(_) => false,
155                    });
156            }
157            return Some(updated);
158        }
159    }
160
161    None
162}
163
164fn build_layer_node(
165    snapshot: BuildNodeSnapshot,
166    _root_scale: f32,
167    inherited_motion_context_animated: bool,
168) -> LayerNode {
169    build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
170}
171
172fn build_layer_node_internal(
173    snapshot: BuildNodeSnapshot,
174    inherited_motion_context_animated: bool,
175    inherited_translated_content_context: bool,
176) -> LayerNode {
177    let BuildNodeSnapshot {
178        node_id,
179        placement,
180        size,
181        content_offset,
182        motion_context_animated,
183        translated_content_context,
184        measured_max_width,
185        resolved_modifiers,
186        draw_commands,
187        click_actions,
188        pointer_inputs,
189        clip_to_bounds,
190        annotated_text,
191        text_style,
192        text_layout_options,
193        graphics_layer,
194        children: child_snapshots,
195    } = snapshot;
196    let local_bounds = Rect {
197        x: 0.0,
198        y: 0.0,
199        width: size.width,
200        height: size.height,
201    };
202    let graphics_layer = graphics_layer.unwrap_or_default();
203    let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
204    let isolation = isolation_reasons(&graphics_layer);
205    let cache_policy = if isolation.has_any() {
206        CachePolicy::Auto
207    } else {
208        CachePolicy::None
209    };
210    let shadow_clip = clip_to_bounds.then_some(local_bounds);
211    let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
212        shape: None,
213        click_actions,
214        pointer_inputs,
215        clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
216    });
217
218    let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
219    let child_translated_content_context =
220        inherited_translated_content_context || translated_content_context;
221
222    let mut children = draw_nodes(
223        &draw_commands,
224        DrawPlacement::Behind,
225        size,
226        PrimitivePhase::BeforeChildren,
227    );
228    if let Some(text) = text_node_from_parts(TextNodeParts {
229        node_id,
230        local_bounds,
231        measured_max_width,
232        resolved_modifiers: &resolved_modifiers,
233        annotated_text: annotated_text.as_ref(),
234        text_style: text_style.as_ref(),
235        text_layout_options,
236        modifier_slices: None,
237    }) {
238        children.push(RenderNode::Primitive(PrimitiveEntry {
239            phase: PrimitivePhase::BeforeChildren,
240            node: PrimitiveNode::Text(Box::new(text)),
241        }));
242    }
243    let child_motion_context_animated = node_motion_context_animated;
244    for child in child_snapshots {
245        let mut child_layer = build_layer_node_internal(
246            child,
247            child_motion_context_animated,
248            child_translated_content_context,
249        );
250        if content_offset != Point::default() {
251            child_layer.transform_to_parent =
252                child_layer
253                    .transform_to_parent
254                    .then(ProjectiveTransform::translation(
255                        content_offset.x,
256                        content_offset.y,
257                    ));
258        }
259        children.push(RenderNode::Layer(Box::new(child_layer)));
260    }
261    children.extend(draw_nodes(
262        &draw_commands,
263        DrawPlacement::Overlay,
264        size,
265        PrimitivePhase::AfterChildren,
266    ));
267    let has_hit_targets = hit_test.is_some()
268        || children.iter().any(|child| match child {
269            RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
270            RenderNode::Primitive(_) => false,
271        });
272
273    LayerNode {
274        node_id: Some(node_id),
275        local_bounds,
276        transform_to_parent,
277        motion_context_animated: node_motion_context_animated,
278        translated_content_context,
279        translated_content_offset: if translated_content_context {
280            content_offset
281        } else {
282            Point::default()
283        },
284        graphics_layer,
285        clip_to_bounds,
286        shadow_clip,
287        hit_test,
288        has_hit_targets,
289        isolation,
290        cache_policy,
291        cache_hashes: LayerRasterCacheHashes::default(),
292        cache_hashes_valid: false,
293        children,
294    }
295}
296
297fn build_layer_node_from_applier(
298    applier: &mut MemoryApplier,
299    node_id: NodeId,
300    _root_scale: f32,
301    inherited_motion_context_animated: bool,
302) -> Option<LayerNode> {
303    build_layer_node_from_applier_internal(
304        applier,
305        node_id,
306        inherited_motion_context_animated,
307        false,
308    )
309}
310
311fn build_layer_node_from_applier_internal(
312    applier: &mut MemoryApplier,
313    node_id: NodeId,
314    inherited_motion_context_animated: bool,
315    inherited_translated_content_context: bool,
316) -> Option<LayerNode> {
317    if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
318        let state = node.layout_state();
319        let children = node.children.clone();
320        let modifier_slices = node.modifier_slices_snapshot();
321        SnapshotNodeData {
322            layout_state: state,
323            modifier_slices,
324            resolved_modifiers: node.resolved_modifiers(),
325            children,
326        }
327    }) {
328        return build_layer_node_from_data(
329            applier,
330            node_id,
331            data,
332            inherited_motion_context_animated,
333            inherited_translated_content_context,
334        );
335    }
336
337    if let Ok(data) = applier.with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
338        let state = node.layout_state();
339        let children = node.active_children();
340        let modifier_slices = node.modifier_slices_snapshot();
341        SnapshotNodeData {
342            layout_state: state,
343            modifier_slices,
344            resolved_modifiers: node.resolved_modifiers(),
345            children,
346        }
347    }) {
348        return build_layer_node_from_data(
349            applier,
350            node_id,
351            data,
352            inherited_motion_context_animated,
353            inherited_translated_content_context,
354        );
355    }
356
357    None
358}
359
360fn build_layer_node_from_data(
361    applier: &mut MemoryApplier,
362    node_id: NodeId,
363    data: SnapshotNodeData,
364    inherited_motion_context_animated: bool,
365    inherited_translated_content_context: bool,
366) -> Option<LayerNode> {
367    let SnapshotNodeData {
368        layout_state,
369        modifier_slices,
370        resolved_modifiers,
371        children,
372    } = data;
373    if !layout_state.is_placed {
374        return None;
375    }
376
377    let local_bounds = Rect {
378        x: 0.0,
379        y: 0.0,
380        width: layout_state.size.width,
381        height: layout_state.size.height,
382    };
383    let clip_to_bounds = modifier_slices.clip_to_bounds();
384    let graphics_layer = graphics_layer_with_shaped_clip(
385        modifier_slices.graphics_layer().unwrap_or_default(),
386        clip_to_bounds,
387        modifier_slices.corner_shape(),
388        local_bounds,
389    );
390    let transform_to_parent =
391        layer_transform_to_parent(local_bounds, layout_state.position, &graphics_layer);
392    let isolation = isolation_reasons(&graphics_layer);
393    let cache_policy = if isolation.has_any() {
394        CachePolicy::Auto
395    } else {
396        CachePolicy::None
397    };
398    let click_actions = modifier_slices.click_handlers();
399    let pointer_inputs = modifier_slices.pointer_inputs();
400    let shadow_clip = clip_to_bounds.then_some(local_bounds);
401    let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
402        shape: None,
403        click_actions: click_actions.to_vec(),
404        pointer_inputs: pointer_inputs.to_vec(),
405        clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
406    });
407
408    let node_motion_context_animated =
409        inherited_motion_context_animated || modifier_slices.motion_context_animated();
410    let local_translated_content_context = modifier_slices.translated_content_context();
411    let local_translated_content_offset = modifier_slices
412        .translated_content_offset()
413        .unwrap_or(layout_state.content_offset);
414    let child_translated_content_context =
415        inherited_translated_content_context || local_translated_content_context;
416
417    let mut render_children = draw_nodes(
418        modifier_slices.draw_commands(),
419        DrawPlacement::Behind,
420        layout_state.size,
421        PrimitivePhase::BeforeChildren,
422    );
423    if let Some(text) = text_node_from_parts(TextNodeParts {
424        node_id,
425        local_bounds,
426        measured_max_width: layout_state
427            .measurement_constraints
428            .max_width
429            .is_finite()
430            .then_some(layout_state.measurement_constraints.max_width),
431        resolved_modifiers: &resolved_modifiers,
432        annotated_text: modifier_slices.annotated_text(),
433        text_style: modifier_slices.text_style(),
434        text_layout_options: modifier_slices.text_layout_options(),
435        modifier_slices: Some(modifier_slices.as_ref()),
436    }) {
437        render_children.push(RenderNode::Primitive(PrimitiveEntry {
438            phase: PrimitivePhase::BeforeChildren,
439            node: PrimitiveNode::Text(Box::new(text)),
440        }));
441    }
442    let child_motion_context_animated = node_motion_context_animated;
443    for child_id in children {
444        let Some(mut child_layer) = build_layer_node_from_applier_internal(
445            applier,
446            child_id,
447            child_motion_context_animated,
448            child_translated_content_context,
449        ) else {
450            continue;
451        };
452        if layout_state.content_offset != Point::default() {
453            child_layer.transform_to_parent =
454                child_layer
455                    .transform_to_parent
456                    .then(ProjectiveTransform::translation(
457                        layout_state.content_offset.x,
458                        layout_state.content_offset.y,
459                    ));
460        }
461        render_children.push(RenderNode::Layer(Box::new(child_layer)));
462    }
463    render_children.extend(draw_nodes(
464        modifier_slices.draw_commands(),
465        DrawPlacement::Overlay,
466        layout_state.size,
467        PrimitivePhase::AfterChildren,
468    ));
469    let has_hit_targets = hit_test.is_some()
470        || render_children.iter().any(|child| match child {
471            RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
472            RenderNode::Primitive(_) => false,
473        });
474
475    let layer = LayerNode {
476        node_id: Some(node_id),
477        local_bounds,
478        transform_to_parent,
479        motion_context_animated: node_motion_context_animated,
480        translated_content_context: local_translated_content_context,
481        translated_content_offset: if local_translated_content_context {
482            local_translated_content_offset
483        } else {
484            Point::default()
485        },
486        graphics_layer,
487        clip_to_bounds,
488        shadow_clip,
489        hit_test,
490        has_hit_targets,
491        isolation,
492        cache_policy,
493        cache_hashes: LayerRasterCacheHashes::default(),
494        cache_hashes_valid: false,
495        children: render_children,
496    };
497    Some(layer)
498}
499
500fn draw_nodes(
501    commands: &[DrawCommand],
502    placement: DrawPlacement,
503    size: Size,
504    phase: PrimitivePhase,
505) -> Vec<RenderNode> {
506    let mut nodes = Vec::new();
507    for command in commands {
508        for primitive in primitives_for_placement(command, placement, size) {
509            nodes.push(RenderNode::Primitive(PrimitiveEntry {
510                phase,
511                node: PrimitiveNode::Draw(DrawPrimitiveNode {
512                    primitive,
513                    clip: None,
514                }),
515            }));
516        }
517    }
518    nodes
519}
520
521struct TextNodeParts<'a> {
522    node_id: NodeId,
523    local_bounds: Rect,
524    measured_max_width: Option<f32>,
525    resolved_modifiers: &'a ResolvedModifiers,
526    annotated_text: Option<&'a AnnotatedString>,
527    text_style: Option<&'a TextStyle>,
528    text_layout_options: Option<TextLayoutOptions>,
529    modifier_slices: Option<&'a ModifierNodeSlices>,
530}
531
532fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
533    let TextNodeParts {
534        node_id,
535        local_bounds,
536        measured_max_width,
537        resolved_modifiers,
538        annotated_text,
539        text_style,
540        text_layout_options,
541        modifier_slices,
542    } = parts;
543    let value = annotated_text?;
544    let default_text_style = TextStyle::default();
545    let text_style = text_style.cloned().unwrap_or(default_text_style);
546    let options = text_layout_options.unwrap_or_default().normalized();
547    let padding = resolved_modifiers.padding();
548    let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
549    if content_width <= 0.0 {
550        return None;
551    }
552
553    let measure_width =
554        resolve_text_measure_width(content_width, padding, measured_max_width, options);
555    let max_width = Some(measure_width).filter(|width| width.is_finite() && *width > 0.0);
556    let prepared = modifier_slices
557        .and_then(|slices| slices.prepare_text_layout(max_width))
558        .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
559    let visual_style = prepared.visual_style.clone();
560    let draw_width = if options.overflow == TextOverflow::Visible {
561        prepared.metrics.width
562    } else {
563        content_width
564    };
565    let alignment_offset = resolve_text_horizontal_offset(
566        &text_style,
567        prepared.text.text.as_str(),
568        content_width,
569        prepared.metrics.width,
570    );
571    let rect = Rect {
572        x: padding.left + alignment_offset,
573        y: padding.top,
574        width: draw_width,
575        height: prepared.metrics.height,
576    };
577    let text_bounds = Rect {
578        x: padding.left,
579        y: padding.top,
580        width: content_width,
581        height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
582    };
583    let font_size = visual_style.resolve_font_size(14.0);
584    let expanded_bounds =
585        expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
586    let clip = if options.overflow == TextOverflow::Visible {
587        None
588    } else {
589        Some(pad_clip_rect(expanded_bounds))
590    };
591
592    Some(TextPrimitiveNode {
593        node_id,
594        rect,
595        text: prepared.text,
596        text_style: visual_style,
597        font_size,
598        layout_options: options,
599        clip,
600    })
601}
602
603fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
604    let placement = parent
605        .map(|parent_box| Point {
606            x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
607            y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
608        })
609        .unwrap_or_default();
610    let mut children = Vec::with_capacity(node.children.len());
611    for child in &node.children {
612        children.push(layout_box_to_snapshot(child, Some(node)));
613    }
614    let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
615    let graphics_layer = graphics_layer_with_shaped_clip(
616        base_graphics_layer.clone().unwrap_or_default(),
617        node.node_data.modifier_slices.clip_to_bounds(),
618        node.node_data.modifier_slices.corner_shape(),
619        Rect {
620            x: 0.0,
621            y: 0.0,
622            width: node.rect.width,
623            height: node.rect.height,
624        },
625    );
626    let has_graphics_layer =
627        base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
628
629    BuildNodeSnapshot {
630        node_id: node.node_id,
631        placement,
632        size: Size {
633            width: node.rect.width,
634            height: node.rect.height,
635        },
636        content_offset: node.content_offset,
637        motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
638        translated_content_context: node.node_data.modifier_slices.translated_content_context(),
639        measured_max_width: None,
640        resolved_modifiers: node.node_data.resolved_modifiers,
641        draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
642        click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
643        pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
644        clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
645        annotated_text: node.node_data.modifier_slices.annotated_string(),
646        text_style: node.node_data.modifier_slices.text_style().cloned(),
647        text_layout_options: node.node_data.modifier_slices.text_layout_options(),
648        graphics_layer: has_graphics_layer.then_some(graphics_layer),
649        children,
650    }
651}
652
653fn graphics_layer_with_shaped_clip(
654    mut graphics_layer: GraphicsLayer,
655    clip_to_bounds: bool,
656    corner_shape: Option<RoundedCornerShape>,
657    local_bounds: Rect,
658) -> GraphicsLayer {
659    if !clip_to_bounds {
660        return graphics_layer;
661    }
662
663    let Some(corner_shape) = corner_shape else {
664        return graphics_layer;
665    };
666    let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
667    if radii.top_left <= f32::EPSILON
668        && radii.top_right <= f32::EPSILON
669        && radii.bottom_right <= f32::EPSILON
670        && radii.bottom_left <= f32::EPSILON
671    {
672        return graphics_layer;
673    }
674
675    let rounded_clip = rounded_corner_alpha_mask_effect(
676        local_bounds.width,
677        local_bounds.height,
678        radii,
679        ROUNDED_CLIP_EDGE_FEATHER,
680    );
681    graphics_layer.render_effect = Some(match graphics_layer.render_effect.take() {
682        Some(existing) => existing.then(rounded_clip),
683        None => rounded_clip,
684    });
685    graphics_layer
686}
687
688fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
689    IsolationReasons {
690        explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
691        effect: layer.render_effect.is_some(),
692        backdrop: layer.backdrop_effect.is_some(),
693        group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
694            && layer.alpha < 1.0,
695        blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
696    }
697}
698
699fn pad_clip_rect(rect: Rect) -> Rect {
700    Rect {
701        x: rect.x - TEXT_CLIP_PAD,
702        y: rect.y - TEXT_CLIP_PAD,
703        width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
704        height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
705    }
706}
707
708fn expand_text_bounds_for_baseline_shift(
709    text_bounds: Rect,
710    text_style: &TextStyle,
711    font_size: f32,
712) -> Rect {
713    let baseline_shift_px = text_style
714        .span_style
715        .baseline_shift
716        .filter(|shift| shift.is_specified())
717        .map(|shift| -(shift.0 * font_size))
718        .unwrap_or(0.0);
719    if baseline_shift_px == 0.0 {
720        return text_bounds;
721    }
722
723    if baseline_shift_px < 0.0 {
724        Rect {
725            x: text_bounds.x,
726            y: text_bounds.y + baseline_shift_px,
727            width: text_bounds.width,
728            height: (text_bounds.height - baseline_shift_px).max(0.0),
729        }
730    } else {
731        Rect {
732            x: text_bounds.x,
733            y: text_bounds.y,
734            width: text_bounds.width,
735            height: (text_bounds.height + baseline_shift_px).max(0.0),
736        }
737    }
738}
739
740fn resolve_text_measure_width(
741    content_width: f32,
742    padding: cranpose_ui::EdgeInsets,
743    measured_max_width: Option<f32>,
744    options: TextLayoutOptions,
745) -> f32 {
746    let available = measured_max_width
747        .map(|max_width| (max_width - padding.left - padding.right).max(0.0))
748        .unwrap_or(content_width);
749    if options.soft_wrap || options.max_lines != 1 || options.overflow == TextOverflow::Clip {
750        available.min(content_width)
751    } else {
752        content_width
753    }
754}
755
756fn resolve_text_horizontal_offset(
757    text_style: &TextStyle,
758    text: &str,
759    content_width: f32,
760    measured_width: f32,
761) -> f32 {
762    let remaining = (content_width - measured_width).max(0.0);
763    let paragraph_style = &text_style.paragraph_style;
764    let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
765    match paragraph_style.text_align {
766        TextAlign::Center => remaining * 0.5,
767        TextAlign::End | TextAlign::Right => remaining,
768        TextAlign::Start | TextAlign::Left | TextAlign::Justify => {
769            if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
770                remaining
771            } else {
772                0.0
773            }
774        }
775        TextAlign::Unspecified => {
776            if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
777                remaining
778            } else {
779                0.0
780            }
781        }
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use std::cell::RefCell;
788    use std::rc::Rc;
789
790    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
791    use cranpose_ui::text::{
792        AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
793    };
794    use cranpose_ui::{
795        Color, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec, Modifier, Point, Rect,
796        ResolvedModifiers, RoundedCornerShape, Size, Text, TextStyle,
797    };
798    use cranpose_ui_graphics::{Brush, DrawPrimitive, GraphicsLayer, RenderEffect};
799
800    use super::*;
801
802    fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
803        for child in &layer.children {
804            match child {
805                RenderNode::Primitive(primitive) => {
806                    let PrimitiveNode::Text(text) = &primitive.node else {
807                        continue;
808                    };
809                    if text.text.text == label {
810                        return Some(text.text_style.paragraph_style.text_motion);
811                    }
812                }
813                RenderNode::Layer(child_layer) => {
814                    if let Some(motion) = find_text_motion(child_layer, label) {
815                        return Some(motion);
816                    }
817                }
818            }
819        }
820
821        None
822    }
823
824    fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
825        for child in &layer.children {
826            match child {
827                RenderNode::Primitive(primitive) => {
828                    let PrimitiveNode::Text(text) = &primitive.node else {
829                        continue;
830                    };
831                    labels.push(text.text.text.clone());
832                }
833                RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
834            }
835        }
836    }
837
838    fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
839        if layer.node_id == Some(node_id) {
840            return Some(layer);
841        }
842        layer.children.iter().find_map(|child| match child {
843            RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
844            RenderNode::Primitive(_) => None,
845        })
846    }
847
848    fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
849        if layer.translated_content_context {
850            return Some(layer.translated_content_offset);
851        }
852        for child in &layer.children {
853            if let RenderNode::Layer(child_layer) = child {
854                if let Some(offset) = find_translated_content_offset(child_layer) {
855                    return Some(offset);
856                }
857            }
858        }
859        None
860    }
861
862    fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
863        layer
864            .graphics_layer
865            .render_effect
866            .as_ref()
867            .is_some_and(RenderEffect::contains_runtime_shader)
868            || layer.children.iter().any(|child| match child {
869                RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
870                RenderNode::Primitive(_) => false,
871            })
872    }
873
874    fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
875        let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
876            vec![DrawPrimitive::Rect {
877                rect: Rect {
878                    x: 3.0,
879                    y: 4.0,
880                    width: 20.0,
881                    height: 8.0,
882                },
883                brush: Brush::solid(Color::WHITE),
884            }]
885        }));
886
887        let child = BuildNodeSnapshot {
888            node_id: 2,
889            placement: Point { x: 11.0, y: 7.0 },
890            size: Size {
891                width: 40.0,
892                height: 20.0,
893            },
894            content_offset: Point::default(),
895            motion_context_animated: false,
896            translated_content_context: false,
897            measured_max_width: None,
898            resolved_modifiers: ResolvedModifiers::default(),
899            draw_commands: vec![child_command],
900            click_actions: vec![],
901            pointer_inputs: vec![],
902            clip_to_bounds: false,
903            annotated_text: None,
904            text_style: None,
905            text_layout_options: None,
906            graphics_layer: None,
907            children: vec![],
908        };
909
910        BuildNodeSnapshot {
911            node_id: 1,
912            placement: Point::default(),
913            size: Size {
914                width: 80.0,
915                height: 50.0,
916            },
917            content_offset: Point::default(),
918            motion_context_animated: false,
919            translated_content_context: false,
920            measured_max_width: None,
921            resolved_modifiers: ResolvedModifiers::default(),
922            draw_commands: vec![],
923            click_actions: vec![],
924            pointer_inputs: vec![],
925            clip_to_bounds: false,
926            annotated_text: None,
927            text_style: None,
928            text_layout_options: None,
929            graphics_layer: Some(GraphicsLayer {
930                translation_x: tx,
931                ..GraphicsLayer::default()
932            }),
933            children: vec![child],
934        }
935    }
936
937    #[test]
938    fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
939        let static_graph = build_layer_node(snapshot_with_translation(0.0), 1.0, false);
940        let moved_graph = build_layer_node(snapshot_with_translation(23.5), 1.0, false);
941
942        let RenderNode::Layer(static_child) = &static_graph.children[0] else {
943            panic!("expected child layer");
944        };
945        let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
946            panic!("expected child layer");
947        };
948        let RenderNode::Primitive(static_draw) = &static_child.children[0] else {
949            panic!("expected draw primitive");
950        };
951        let PrimitiveNode::Draw(static_draw) = &static_draw.node else {
952            panic!("expected draw primitive");
953        };
954        let RenderNode::Primitive(moved_draw) = &moved_child.children[0] else {
955            panic!("expected draw primitive");
956        };
957        let PrimitiveNode::Draw(moved_draw) = &moved_draw.node else {
958            panic!("expected draw primitive");
959        };
960
961        assert_ne!(
962            static_graph.transform_to_parent, moved_graph.transform_to_parent,
963            "parent transform should encode translation"
964        );
965        assert_eq!(
966            static_draw, moved_draw,
967            "child local primitive geometry must stay stable under parent translation"
968        );
969    }
970
971    #[test]
972    fn stored_content_hash_ignores_parent_translation() {
973        let static_graph = build_layer_node(snapshot_with_translation(0.0), 1.0, false);
974        let moved_graph = build_layer_node(snapshot_with_translation(23.5), 1.0, false);
975
976        assert_eq!(
977            static_graph.target_content_hash(),
978            moved_graph.target_content_hash(),
979            "parent rigid motion must not invalidate the subtree content hash"
980        );
981    }
982
983    #[test]
984    fn parent_content_offset_is_encoded_in_child_transform() {
985        let child = BuildNodeSnapshot {
986            node_id: 2,
987            placement: Point { x: 11.0, y: 7.0 },
988            size: Size {
989                width: 40.0,
990                height: 20.0,
991            },
992            content_offset: Point::default(),
993            motion_context_animated: false,
994            translated_content_context: false,
995            measured_max_width: None,
996            resolved_modifiers: ResolvedModifiers::default(),
997            draw_commands: vec![],
998            click_actions: vec![],
999            pointer_inputs: vec![],
1000            clip_to_bounds: false,
1001            annotated_text: None,
1002            text_style: None,
1003            text_layout_options: None,
1004            graphics_layer: None,
1005            children: vec![],
1006        };
1007
1008        let parent = BuildNodeSnapshot {
1009            node_id: 1,
1010            placement: Point::default(),
1011            size: Size {
1012                width: 80.0,
1013                height: 50.0,
1014            },
1015            content_offset: Point { x: 13.0, y: -9.0 },
1016            motion_context_animated: false,
1017            translated_content_context: false,
1018            measured_max_width: None,
1019            resolved_modifiers: ResolvedModifiers::default(),
1020            draw_commands: vec![],
1021            click_actions: vec![],
1022            pointer_inputs: vec![],
1023            clip_to_bounds: false,
1024            annotated_text: None,
1025            text_style: None,
1026            text_layout_options: None,
1027            graphics_layer: None,
1028            children: vec![child],
1029        };
1030
1031        let graph = build_layer_node(parent, 1.0, false);
1032        let RenderNode::Layer(child) = &graph.children[0] else {
1033            panic!("expected child layer");
1034        };
1035
1036        let top_left = child.transform_to_parent.map_point(Point::default());
1037        assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1038    }
1039
1040    #[test]
1041    fn rounded_clip_to_bounds_injects_per_corner_mask_effect() {
1042        let layer = graphics_layer_with_shaped_clip(
1043            GraphicsLayer::default(),
1044            true,
1045            Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
1046            Rect {
1047                x: 0.0,
1048                y: 0.0,
1049                width: 100.0,
1050                height: 40.0,
1051            },
1052        );
1053
1054        let Some(RenderEffect::Shader { shader }) = layer.render_effect else {
1055            panic!("rounded clip must emit a shader mask");
1056        };
1057        let uniforms = shader.uniforms();
1058        assert_eq!(uniforms[0], 100.0);
1059        assert_eq!(uniforms[1], 40.0);
1060        assert_eq!(uniforms[2], ROUNDED_CLIP_EDGE_FEATHER);
1061        assert_eq!(uniforms[3], 4.0);
1062        assert_eq!(uniforms[4], 8.0);
1063        assert_eq!(uniforms[5], 12.0);
1064        assert_eq!(uniforms[6], 16.0);
1065    }
1066
1067    #[test]
1068    fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
1069        let existing = RenderEffect::blur(3.0);
1070        let layer = graphics_layer_with_shaped_clip(
1071            GraphicsLayer {
1072                render_effect: Some(existing.clone()),
1073                ..GraphicsLayer::default()
1074            },
1075            true,
1076            Some(RoundedCornerShape::uniform(10.0)),
1077            Rect {
1078                x: 0.0,
1079                y: 0.0,
1080                width: 100.0,
1081                height: 40.0,
1082            },
1083        );
1084
1085        let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
1086            panic!("existing effect should chain into rounded clip mask");
1087        };
1088        assert_eq!(*first, existing);
1089        assert!(
1090            matches!(*second, RenderEffect::Shader { .. }),
1091            "rounded mask must be the outer effect"
1092        );
1093    }
1094
1095    #[test]
1096    fn rounded_corners_clip_to_bounds_builds_graph_mask_from_modifier_chain() {
1097        let mut composition = cranpose_ui::run_test_composition(|| {
1098            cranpose_ui::Box(
1099                Modifier::empty()
1100                    .width(100.0)
1101                    .height(40.0)
1102                    .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
1103                    .clip_to_bounds(),
1104                cranpose_ui::BoxSpec::default(),
1105                || {
1106                    Text("rounded child", Modifier::empty(), TextStyle::default());
1107                },
1108            );
1109        });
1110
1111        let root = composition.root().expect("rounded clip root");
1112        let handle = composition.runtime_handle();
1113        let mut applier = composition.applier_mut();
1114        applier.set_runtime_handle(handle);
1115        applier
1116            .compute_layout(
1117                root,
1118                Size {
1119                    width: 160.0,
1120                    height: 100.0,
1121                },
1122            )
1123            .expect("rounded clip layout");
1124        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1125        applier.clear_runtime_handle();
1126
1127        assert!(
1128            graph_has_runtime_shader_effect(&graph.root),
1129            "rounded_corners().clip_to_bounds() must build a shaped mask effect for descendants"
1130        );
1131    }
1132
1133    #[test]
1134    fn update_graph_from_applier_replaces_dirty_child_layer() {
1135        let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1136            Rc::new(RefCell::new(None));
1137        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1138        let state_holder_for_comp = state_holder.clone();
1139        let child_id_holder_for_comp = child_id_holder.clone();
1140
1141        let mut composition = cranpose_ui::run_test_composition(move || {
1142            let label = cranpose_core::useState(|| "before".to_string());
1143            *state_holder_for_comp.borrow_mut() = Some(label);
1144            let child_id_holder_for_content = child_id_holder_for_comp.clone();
1145            cranpose_ui::Box(
1146                Modifier::empty().size_points(240.0, 80.0),
1147                cranpose_ui::BoxSpec::default(),
1148                move || {
1149                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
1150                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
1151                    Text("stable", Modifier::empty(), TextStyle::default());
1152                },
1153            );
1154        });
1155
1156        let root = composition.root().expect("composition root");
1157        let viewport = Size {
1158            width: 240.0,
1159            height: 80.0,
1160        };
1161        let handle = composition.runtime_handle();
1162        let mut applier = composition.applier_mut();
1163        applier.set_runtime_handle(handle);
1164        applier
1165            .compute_layout(root, viewport)
1166            .expect("initial layout");
1167        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1168        let child_id = child_id_holder
1169            .borrow()
1170            .expect("text child id should be captured");
1171        let initial_transform = find_layer_by_node_id(&graph.root, child_id)
1172            .expect("text child layer")
1173            .transform_to_parent;
1174        applier.clear_runtime_handle();
1175        drop(applier);
1176
1177        let label = state_holder
1178            .borrow()
1179            .as_ref()
1180            .copied()
1181            .expect("label state should be captured");
1182        label.set_value("after".to_string());
1183        composition
1184            .process_invalid_scopes()
1185            .expect("text recomposition");
1186
1187        let handle = composition.runtime_handle();
1188        let mut applier = composition.applier_mut();
1189        applier.set_runtime_handle(handle);
1190        applier
1191            .compute_layout(root, viewport)
1192            .expect("updated layout");
1193        let child_id = child_id_holder
1194            .borrow()
1195            .expect("text child id should remain captured");
1196
1197        assert!(
1198            update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
1199            "dirty child should be replaceable from retained applier state"
1200        );
1201        applier.clear_runtime_handle();
1202
1203        let mut labels = Vec::new();
1204        collect_text_labels(&graph.root, &mut labels);
1205        assert!(
1206            labels.iter().any(|label| label == "after"),
1207            "updated graph should contain refreshed child text, got {labels:?}"
1208        );
1209        assert!(
1210            !labels.iter().any(|label| label == "before"),
1211            "updated graph should not retain stale child text, got {labels:?}"
1212        );
1213        assert!(
1214            labels.iter().any(|label| label == "stable"),
1215            "sibling content should remain present, got {labels:?}"
1216        );
1217        assert_eq!(
1218            find_layer_by_node_id(&graph.root, child_id)
1219                .expect("updated text child layer")
1220                .transform_to_parent,
1221            initial_transform,
1222            "draw-only child replacement must preserve the retained parent placement transform"
1223        );
1224    }
1225
1226    #[test]
1227    fn overlay_draw_commands_are_tagged_after_children() {
1228        let child = BuildNodeSnapshot {
1229            node_id: 2,
1230            placement: Point { x: 4.0, y: 5.0 },
1231            size: Size {
1232                width: 20.0,
1233                height: 10.0,
1234            },
1235            content_offset: Point::default(),
1236            motion_context_animated: false,
1237            translated_content_context: false,
1238            measured_max_width: None,
1239            resolved_modifiers: ResolvedModifiers::default(),
1240            draw_commands: vec![],
1241            click_actions: vec![],
1242            pointer_inputs: vec![],
1243            clip_to_bounds: false,
1244            annotated_text: None,
1245            text_style: None,
1246            text_layout_options: None,
1247            graphics_layer: None,
1248            children: vec![],
1249        };
1250        let behind = DrawCommand::Behind(Rc::new(|_size: Size| {
1251            vec![cranpose_ui_graphics::DrawPrimitive::Rect {
1252                rect: Rect {
1253                    x: 1.0,
1254                    y: 2.0,
1255                    width: 8.0,
1256                    height: 6.0,
1257                },
1258                brush: Brush::solid(Color::WHITE),
1259            }]
1260        }));
1261        let overlay = DrawCommand::Overlay(Rc::new(|_size: Size| {
1262            vec![cranpose_ui_graphics::DrawPrimitive::Rect {
1263                rect: Rect {
1264                    x: 3.0,
1265                    y: 1.0,
1266                    width: 5.0,
1267                    height: 4.0,
1268                },
1269                brush: Brush::solid(Color::BLACK),
1270            }]
1271        }));
1272
1273        let parent = BuildNodeSnapshot {
1274            node_id: 1,
1275            placement: Point::default(),
1276            size: Size {
1277                width: 80.0,
1278                height: 50.0,
1279            },
1280            content_offset: Point::default(),
1281            motion_context_animated: false,
1282            translated_content_context: false,
1283            measured_max_width: None,
1284            resolved_modifiers: ResolvedModifiers::default(),
1285            draw_commands: vec![behind, overlay],
1286            click_actions: vec![],
1287            pointer_inputs: vec![],
1288            clip_to_bounds: false,
1289            annotated_text: None,
1290            text_style: None,
1291            text_layout_options: None,
1292            graphics_layer: None,
1293            children: vec![child],
1294        };
1295
1296        let graph = build_layer_node(parent, 1.0, false);
1297        let RenderNode::Primitive(behind) = &graph.children[0] else {
1298            panic!("expected before-children primitive");
1299        };
1300        let RenderNode::Layer(_) = &graph.children[1] else {
1301            panic!("expected child layer");
1302        };
1303        let RenderNode::Primitive(overlay) = &graph.children[2] else {
1304            panic!("expected after-children primitive");
1305        };
1306
1307        assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
1308        assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
1309    }
1310
1311    #[test]
1312    fn stored_content_hash_changes_when_child_transform_changes() {
1313        let child = BuildNodeSnapshot {
1314            node_id: 2,
1315            placement: Point { x: 4.0, y: 5.0 },
1316            size: Size {
1317                width: 20.0,
1318                height: 10.0,
1319            },
1320            content_offset: Point::default(),
1321            motion_context_animated: false,
1322            translated_content_context: false,
1323            measured_max_width: None,
1324            resolved_modifiers: ResolvedModifiers::default(),
1325            draw_commands: vec![],
1326            click_actions: vec![],
1327            pointer_inputs: vec![],
1328            clip_to_bounds: false,
1329            annotated_text: None,
1330            text_style: None,
1331            text_layout_options: None,
1332            graphics_layer: None,
1333            children: vec![],
1334        };
1335        let mut moved_child = child.clone();
1336        moved_child.placement.x += 7.0;
1337
1338        let parent = BuildNodeSnapshot {
1339            node_id: 1,
1340            placement: Point::default(),
1341            size: Size {
1342                width: 80.0,
1343                height: 50.0,
1344            },
1345            content_offset: Point::default(),
1346            motion_context_animated: false,
1347            translated_content_context: false,
1348            measured_max_width: None,
1349            resolved_modifiers: ResolvedModifiers::default(),
1350            draw_commands: vec![],
1351            click_actions: vec![],
1352            pointer_inputs: vec![],
1353            clip_to_bounds: false,
1354            annotated_text: None,
1355            text_style: None,
1356            text_layout_options: None,
1357            graphics_layer: None,
1358            children: vec![child],
1359        };
1360        let moved_parent = BuildNodeSnapshot {
1361            children: vec![moved_child],
1362            ..parent.clone()
1363        };
1364
1365        let static_graph = build_layer_node(parent, 1.0, false);
1366        let moved_graph = build_layer_node(moved_parent, 1.0, false);
1367
1368        assert_ne!(
1369            static_graph.target_content_hash(),
1370            moved_graph.target_content_hash(),
1371            "moving a child within the parent must invalidate the parent subtree hash"
1372        );
1373    }
1374
1375    #[test]
1376    fn stored_effect_hash_tracks_local_effect_only() {
1377        let base = BuildNodeSnapshot {
1378            node_id: 1,
1379            placement: Point::default(),
1380            size: Size {
1381                width: 80.0,
1382                height: 50.0,
1383            },
1384            content_offset: Point::default(),
1385            motion_context_animated: false,
1386            translated_content_context: false,
1387            measured_max_width: None,
1388            resolved_modifiers: ResolvedModifiers::default(),
1389            draw_commands: vec![],
1390            click_actions: vec![],
1391            pointer_inputs: vec![],
1392            clip_to_bounds: false,
1393            annotated_text: None,
1394            text_style: None,
1395            text_layout_options: None,
1396            graphics_layer: None,
1397            children: vec![],
1398        };
1399        let mut effected = base.clone();
1400        effected.graphics_layer = Some(GraphicsLayer {
1401            render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
1402            ..GraphicsLayer::default()
1403        });
1404
1405        let base_graph = build_layer_node(base, 1.0, false);
1406        let effected_graph = build_layer_node(effected, 1.0, false);
1407
1408        assert_eq!(
1409            base_graph.target_content_hash(),
1410            effected_graph.target_content_hash(),
1411            "post-processing effect parameters belong to the effect hash, not the content hash"
1412        );
1413        assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
1414    }
1415
1416    #[test]
1417    fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
1418        let mut text_style = TextStyle::default();
1419        text_style.paragraph_style.text_align = TextAlign::Start;
1420        text_style.paragraph_style.text_direction = TextDirection::Rtl;
1421        text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
1422
1423        let snapshot = BuildNodeSnapshot {
1424            node_id: 1,
1425            placement: Point::default(),
1426            size: Size {
1427                width: 180.0,
1428                height: 48.0,
1429            },
1430            content_offset: Point::default(),
1431            motion_context_animated: false,
1432            translated_content_context: false,
1433            measured_max_width: Some(180.0),
1434            resolved_modifiers: ResolvedModifiers::default(),
1435            draw_commands: vec![],
1436            click_actions: vec![],
1437            pointer_inputs: vec![],
1438            clip_to_bounds: false,
1439            annotated_text: Some(AnnotatedString::from("rtl")),
1440            text_style: Some(text_style),
1441            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
1442                overflow: cranpose_ui::TextOverflow::Clip,
1443                ..Default::default()
1444            }),
1445            graphics_layer: None,
1446            children: vec![],
1447        };
1448
1449        let graph = build_layer_node(snapshot, 1.0, false);
1450        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
1451            panic!("expected text primitive");
1452        };
1453        let PrimitiveNode::Text(text) = &text_primitive.node else {
1454            panic!("expected text primitive");
1455        };
1456        let clip = text
1457            .clip
1458            .expect("clipped overflow should produce a clip rect");
1459
1460        assert!(
1461            text.rect.x > 0.0,
1462            "RTL start alignment should shift the text rect within the available width"
1463        );
1464        assert!(
1465            clip.y < text.rect.y,
1466            "baseline shift must expand the clip upward so superscript glyphs are preserved"
1467        );
1468        assert!(
1469            clip.intersect(text.rect).is_some(),
1470            "the clip rect must intersect the shifted text draw rect"
1471        );
1472    }
1473
1474    #[test]
1475    fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
1476        let child = BuildNodeSnapshot {
1477            node_id: 2,
1478            placement: Point { x: 11.0, y: 7.0 },
1479            size: Size {
1480                width: 120.0,
1481                height: 32.0,
1482            },
1483            content_offset: Point::default(),
1484            motion_context_animated: false,
1485            translated_content_context: false,
1486            measured_max_width: Some(120.0),
1487            resolved_modifiers: ResolvedModifiers::default(),
1488            draw_commands: vec![],
1489            click_actions: vec![],
1490            pointer_inputs: vec![],
1491            clip_to_bounds: false,
1492            annotated_text: Some(AnnotatedString::from("scrolling")),
1493            text_style: Some(TextStyle::default()),
1494            text_layout_options: None,
1495            graphics_layer: None,
1496            children: vec![],
1497        };
1498        let parent = BuildNodeSnapshot {
1499            node_id: 1,
1500            placement: Point::default(),
1501            size: Size {
1502                width: 160.0,
1503                height: 64.0,
1504            },
1505            content_offset: Point { x: 0.0, y: -18.5 },
1506            motion_context_animated: false,
1507            translated_content_context: true,
1508            measured_max_width: None,
1509            resolved_modifiers: ResolvedModifiers::default(),
1510            draw_commands: vec![],
1511            click_actions: vec![],
1512            pointer_inputs: vec![],
1513            clip_to_bounds: false,
1514            annotated_text: None,
1515            text_style: None,
1516            text_layout_options: None,
1517            graphics_layer: None,
1518            children: vec![child],
1519        };
1520
1521        let graph = build_layer_node(parent, 1.0, false);
1522        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1523            panic!("expected child layer");
1524        };
1525        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1526            panic!("expected text primitive");
1527        };
1528        let PrimitiveNode::Text(text) = &text_primitive.node else {
1529            panic!("expected text primitive");
1530        };
1531
1532        assert_eq!(text.text_style.paragraph_style.text_motion, None);
1533        assert!(!child_layer.motion_context_animated);
1534    }
1535
1536    #[test]
1537    fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
1538        let child = BuildNodeSnapshot {
1539            node_id: 2,
1540            placement: Point { x: 11.0, y: 7.0 },
1541            size: Size {
1542                width: 120.0,
1543                height: 32.0,
1544            },
1545            content_offset: Point::default(),
1546            motion_context_animated: false,
1547            translated_content_context: false,
1548            measured_max_width: Some(120.0),
1549            resolved_modifiers: ResolvedModifiers::default(),
1550            draw_commands: vec![],
1551            click_actions: vec![],
1552            pointer_inputs: vec![],
1553            clip_to_bounds: false,
1554            annotated_text: Some(AnnotatedString::from("scrolling")),
1555            text_style: Some(TextStyle::default()),
1556            text_layout_options: None,
1557            graphics_layer: None,
1558            children: vec![],
1559        };
1560        let parent = BuildNodeSnapshot {
1561            node_id: 1,
1562            placement: Point::default(),
1563            size: Size {
1564                width: 160.0,
1565                height: 64.0,
1566            },
1567            content_offset: Point { x: 0.0, y: -18.0 },
1568            motion_context_animated: false,
1569            translated_content_context: false,
1570            measured_max_width: None,
1571            resolved_modifiers: ResolvedModifiers::default(),
1572            draw_commands: vec![],
1573            click_actions: vec![],
1574            pointer_inputs: vec![],
1575            clip_to_bounds: false,
1576            annotated_text: None,
1577            text_style: None,
1578            text_layout_options: None,
1579            graphics_layer: None,
1580            children: vec![child],
1581        };
1582
1583        let graph = build_layer_node(parent, 1.0, false);
1584        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1585            panic!("expected child layer");
1586        };
1587        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1588            panic!("expected text primitive");
1589        };
1590        let PrimitiveNode::Text(text) = &text_primitive.node else {
1591            panic!("expected text primitive");
1592        };
1593
1594        assert_eq!(
1595            text.text_style.paragraph_style.text_motion, None,
1596            "content_offset alone must not force text onto the translated-content motion path"
1597        );
1598        assert!(!child_layer.motion_context_animated);
1599    }
1600
1601    #[test]
1602    fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
1603        let child = BuildNodeSnapshot {
1604            node_id: 2,
1605            placement: Point { x: 11.0, y: 7.0 },
1606            size: Size {
1607                width: 120.0,
1608                height: 32.0,
1609            },
1610            content_offset: Point::default(),
1611            motion_context_animated: false,
1612            translated_content_context: false,
1613            measured_max_width: Some(120.0),
1614            resolved_modifiers: ResolvedModifiers::default(),
1615            draw_commands: vec![],
1616            click_actions: vec![],
1617            pointer_inputs: vec![],
1618            clip_to_bounds: false,
1619            annotated_text: Some(AnnotatedString::from("shadow")),
1620            text_style: Some(TextStyle::from_span_style(SpanStyle {
1621                shadow: Some(cranpose_ui::text::Shadow {
1622                    color: Color::BLACK,
1623                    offset: Point::new(1.0, 2.0),
1624                    blur_radius: 3.0,
1625                }),
1626                ..SpanStyle::default()
1627            })),
1628            text_layout_options: None,
1629            graphics_layer: None,
1630            children: vec![],
1631        };
1632        let parent = BuildNodeSnapshot {
1633            node_id: 1,
1634            placement: Point::default(),
1635            size: Size {
1636                width: 160.0,
1637                height: 64.0,
1638            },
1639            content_offset: Point { x: 0.0, y: -18.5 },
1640            motion_context_animated: false,
1641            translated_content_context: true,
1642            measured_max_width: None,
1643            resolved_modifiers: ResolvedModifiers::default(),
1644            draw_commands: vec![],
1645            click_actions: vec![],
1646            pointer_inputs: vec![],
1647            clip_to_bounds: false,
1648            annotated_text: None,
1649            text_style: None,
1650            text_layout_options: None,
1651            graphics_layer: None,
1652            children: vec![child],
1653        };
1654
1655        let graph = build_layer_node(parent, 1.0, false);
1656        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1657            panic!("expected child layer");
1658        };
1659        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1660            panic!("expected text primitive");
1661        };
1662        let PrimitiveNode::Text(text) = &text_primitive.node else {
1663            panic!("expected text primitive");
1664        };
1665
1666        assert_eq!(text.text_style.paragraph_style.text_motion, None);
1667    }
1668
1669    #[test]
1670    fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
1671        let child = BuildNodeSnapshot {
1672            node_id: 2,
1673            placement: Point { x: 11.0, y: 7.0 },
1674            size: Size {
1675                width: 120.0,
1676                height: 32.0,
1677            },
1678            content_offset: Point::default(),
1679            motion_context_animated: false,
1680            translated_content_context: false,
1681            measured_max_width: Some(120.0),
1682            resolved_modifiers: ResolvedModifiers::default(),
1683            draw_commands: vec![],
1684            click_actions: vec![],
1685            pointer_inputs: vec![],
1686            clip_to_bounds: false,
1687            annotated_text: Some(AnnotatedString::from("lazy")),
1688            text_style: Some(TextStyle::default()),
1689            text_layout_options: None,
1690            graphics_layer: None,
1691            children: vec![],
1692        };
1693        let parent = BuildNodeSnapshot {
1694            node_id: 1,
1695            placement: Point::default(),
1696            size: Size {
1697                width: 160.0,
1698                height: 64.0,
1699            },
1700            content_offset: Point::default(),
1701            motion_context_animated: true,
1702            translated_content_context: false,
1703            measured_max_width: None,
1704            resolved_modifiers: ResolvedModifiers::default(),
1705            draw_commands: vec![],
1706            click_actions: vec![],
1707            pointer_inputs: vec![],
1708            clip_to_bounds: false,
1709            annotated_text: None,
1710            text_style: None,
1711            text_layout_options: None,
1712            graphics_layer: None,
1713            children: vec![child],
1714        };
1715
1716        let graph = build_layer_node(parent, 1.0, false);
1717        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1718            panic!("expected child layer");
1719        };
1720        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1721            panic!("expected text primitive");
1722        };
1723        let PrimitiveNode::Text(text) = &text_primitive.node else {
1724            panic!("expected text primitive");
1725        };
1726
1727        assert_eq!(text.text_style.paragraph_style.text_motion, None);
1728        assert!(graph.motion_context_animated);
1729        assert!(child_layer.motion_context_animated);
1730    }
1731
1732    #[test]
1733    fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
1734        let mut composition = cranpose_ui::run_test_composition(|| {
1735            let list_state = remember_lazy_list_state();
1736            LazyColumn(
1737                Modifier::empty(),
1738                list_state,
1739                LazyColumnSpec::default(),
1740                |scope| {
1741                    scope.item(Some(0), None, || {
1742                        Text("LazyMotion", Modifier::empty(), TextStyle::default());
1743                    });
1744                },
1745            );
1746        });
1747
1748        let root = composition.root().expect("lazy column root");
1749        let handle = composition.runtime_handle();
1750        let mut applier = composition.applier_mut();
1751        applier.set_runtime_handle(handle);
1752        let _ = applier
1753            .compute_layout(
1754                root,
1755                Size {
1756                    width: 240.0,
1757                    height: 240.0,
1758                },
1759            )
1760            .expect("lazy column layout");
1761        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
1762        applier.clear_runtime_handle();
1763
1764        assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
1765    }
1766
1767    #[test]
1768    fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
1769        use std::cell::RefCell;
1770        use std::rc::Rc;
1771
1772        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1773        let state_holder_for_comp = state_holder.clone();
1774        let mut composition = cranpose_ui::run_test_composition(move || {
1775            let list_state = remember_lazy_list_state();
1776            *state_holder_for_comp.borrow_mut() = Some(list_state);
1777            LazyColumn(
1778                Modifier::empty().height(120.0),
1779                list_state,
1780                LazyColumnSpec::default(),
1781                |scope| {
1782                    scope.items(
1783                        8,
1784                        None::<fn(usize) -> u64>,
1785                        None::<fn(usize) -> u64>,
1786                        |index| {
1787                            Text(
1788                                format!("LazyMotion {index}"),
1789                                Modifier::empty().padding(4.0),
1790                                TextStyle::default(),
1791                            );
1792                        },
1793                    );
1794                },
1795            );
1796        });
1797
1798        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
1799        list_state.scroll_to_item(3, 0.0);
1800
1801        let root = composition.root().expect("lazy column root");
1802        let handle = composition.runtime_handle();
1803        let mut applier = composition.applier_mut();
1804        applier.set_runtime_handle(handle);
1805        let _ = applier
1806            .compute_layout(
1807                root,
1808                Size {
1809                    width: 240.0,
1810                    height: 240.0,
1811                },
1812            )
1813            .expect("lazy column layout");
1814        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
1815        let active_children = applier
1816            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
1817            .expect("lazy column should be subcompose");
1818        let child_debug: Vec<String> = active_children
1819            .iter()
1820            .map(|&child_id| {
1821                if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
1822                    format!(
1823                        "layout#{child_id} placed={} text={:?} children={:?}",
1824                        node.layout_state().is_placed,
1825                        node.modifier_slices_snapshot()
1826                            .text_content()
1827                            .map(str::to_string),
1828                        node.children.clone()
1829                    )
1830                }) {
1831                    summary
1832                } else if let Ok(summary) =
1833                    applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
1834                        format!(
1835                            "subcompose#{child_id} placed={} active_children={:?}",
1836                            node.layout_state().is_placed,
1837                            node.active_children()
1838                        )
1839                    })
1840                {
1841                    summary
1842                } else {
1843                    format!("missing#{child_id}")
1844                }
1845            })
1846            .collect();
1847        applier.clear_runtime_handle();
1848
1849        let first_index = list_state.first_visible_item_index();
1850        assert!(
1851            first_index > 0,
1852            "lazy list should move away from origin before graph building, observed first_index={first_index}"
1853        );
1854        let mut labels = Vec::new();
1855        collect_text_labels(&graph.root, &mut labels);
1856        assert_eq!(
1857            find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
1858            Some(None),
1859            "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
1860            labels,
1861            active_children,
1862            child_debug
1863        );
1864    }
1865
1866    #[test]
1867    fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
1868        use std::cell::RefCell;
1869        use std::rc::Rc;
1870
1871        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1872        let state_holder_for_comp = state_holder.clone();
1873        let mut composition = cranpose_ui::run_test_composition(move || {
1874            let list_state = remember_lazy_list_state();
1875            *state_holder_for_comp.borrow_mut() = Some(list_state);
1876            LazyColumn(
1877                Modifier::empty().height(120.0),
1878                list_state,
1879                LazyColumnSpec::default(),
1880                |scope| {
1881                    scope.items(
1882                        8,
1883                        None::<fn(usize) -> u64>,
1884                        None::<fn(usize) -> u64>,
1885                        |index| {
1886                            Text(
1887                                format!("LazySnap {index}"),
1888                                Modifier::empty().padding(4.0),
1889                                TextStyle::default(),
1890                            );
1891                        },
1892                    );
1893                },
1894            );
1895        });
1896
1897        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
1898        list_state.scroll_to_item(2, 7.5);
1899
1900        let root = composition.root().expect("lazy column root");
1901        let handle = composition.runtime_handle();
1902        let mut applier = composition.applier_mut();
1903        applier.set_runtime_handle(handle);
1904        let _ = applier
1905            .compute_layout(
1906                root,
1907                Size {
1908                    width: 240.0,
1909                    height: 240.0,
1910                },
1911            )
1912            .expect("lazy column layout");
1913        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
1914        applier.clear_runtime_handle();
1915
1916        let layout_info = list_state.layout_info();
1917        let first_visible_offset = layout_info
1918            .visible_items_info
1919            .first()
1920            .expect("lazy layout should expose visible item info")
1921            .offset;
1922        let snap_offset = find_translated_content_offset(&graph.root)
1923            .expect("lazy list graph should include translated content context");
1924
1925        assert!(
1926            (snap_offset.y - first_visible_offset).abs() <= 0.001,
1927            "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
1928        );
1929    }
1930
1931    #[test]
1932    fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
1933        let child = BuildNodeSnapshot {
1934            node_id: 2,
1935            placement: Point { x: 11.0, y: 7.0 },
1936            size: Size {
1937                width: 120.0,
1938                height: 32.0,
1939            },
1940            content_offset: Point::default(),
1941            motion_context_animated: false,
1942            translated_content_context: false,
1943            measured_max_width: Some(120.0),
1944            resolved_modifiers: ResolvedModifiers::default(),
1945            draw_commands: vec![],
1946            click_actions: vec![],
1947            pointer_inputs: vec![],
1948            clip_to_bounds: false,
1949            annotated_text: Some(AnnotatedString::from("static")),
1950            text_style: Some(TextStyle::from_paragraph_style(
1951                cranpose_ui::text::ParagraphStyle {
1952                    text_motion: Some(TextMotion::Static),
1953                    ..Default::default()
1954                },
1955            )),
1956            text_layout_options: None,
1957            graphics_layer: None,
1958            children: vec![],
1959        };
1960        let parent = BuildNodeSnapshot {
1961            node_id: 1,
1962            placement: Point::default(),
1963            size: Size {
1964                width: 160.0,
1965                height: 64.0,
1966            },
1967            content_offset: Point { x: 0.0, y: -18.5 },
1968            motion_context_animated: false,
1969            translated_content_context: true,
1970            measured_max_width: None,
1971            resolved_modifiers: ResolvedModifiers::default(),
1972            draw_commands: vec![],
1973            click_actions: vec![],
1974            pointer_inputs: vec![],
1975            clip_to_bounds: false,
1976            annotated_text: None,
1977            text_style: None,
1978            text_layout_options: None,
1979            graphics_layer: None,
1980            children: vec![child],
1981        };
1982
1983        let graph = build_layer_node(parent, 1.0, false);
1984        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1985            panic!("expected child layer");
1986        };
1987        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1988            panic!("expected text primitive");
1989        };
1990        let PrimitiveNode::Text(text) = &text_primitive.node else {
1991            panic!("expected text primitive");
1992        };
1993
1994        assert_eq!(
1995            text.text_style.paragraph_style.text_motion,
1996            Some(TextMotion::Static),
1997            "explicit text motion must win over inherited scrolling motion context"
1998        );
1999    }
2000}