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 build_layer_node_for_test(
875        snapshot: BuildNodeSnapshot,
876        scale: f32,
877        has_external_backdrop_input: bool,
878    ) -> LayerNode {
879        let app_context = cranpose_ui::AppContext::new();
880        app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
881    }
882
883    fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
884        let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
885            vec![DrawPrimitive::Rect {
886                rect: Rect {
887                    x: 3.0,
888                    y: 4.0,
889                    width: 20.0,
890                    height: 8.0,
891                },
892                brush: Brush::solid(Color::WHITE),
893            }]
894        }));
895
896        let child = BuildNodeSnapshot {
897            node_id: 2,
898            placement: Point { x: 11.0, y: 7.0 },
899            size: Size {
900                width: 40.0,
901                height: 20.0,
902            },
903            content_offset: Point::default(),
904            motion_context_animated: false,
905            translated_content_context: false,
906            measured_max_width: None,
907            resolved_modifiers: ResolvedModifiers::default(),
908            draw_commands: vec![child_command],
909            click_actions: vec![],
910            pointer_inputs: vec![],
911            clip_to_bounds: false,
912            annotated_text: None,
913            text_style: None,
914            text_layout_options: None,
915            graphics_layer: None,
916            children: vec![],
917        };
918
919        BuildNodeSnapshot {
920            node_id: 1,
921            placement: Point::default(),
922            size: Size {
923                width: 80.0,
924                height: 50.0,
925            },
926            content_offset: Point::default(),
927            motion_context_animated: false,
928            translated_content_context: false,
929            measured_max_width: None,
930            resolved_modifiers: ResolvedModifiers::default(),
931            draw_commands: vec![],
932            click_actions: vec![],
933            pointer_inputs: vec![],
934            clip_to_bounds: false,
935            annotated_text: None,
936            text_style: None,
937            text_layout_options: None,
938            graphics_layer: Some(GraphicsLayer {
939                translation_x: tx,
940                ..GraphicsLayer::default()
941            }),
942            children: vec![child],
943        }
944    }
945
946    #[test]
947    fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
948        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
949        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
950
951        let RenderNode::Layer(static_child) = &static_graph.children[0] else {
952            panic!("expected child layer");
953        };
954        let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
955            panic!("expected child layer");
956        };
957        let RenderNode::Primitive(static_draw) = &static_child.children[0] else {
958            panic!("expected draw primitive");
959        };
960        let PrimitiveNode::Draw(static_draw) = &static_draw.node else {
961            panic!("expected draw primitive");
962        };
963        let RenderNode::Primitive(moved_draw) = &moved_child.children[0] else {
964            panic!("expected draw primitive");
965        };
966        let PrimitiveNode::Draw(moved_draw) = &moved_draw.node else {
967            panic!("expected draw primitive");
968        };
969
970        assert_ne!(
971            static_graph.transform_to_parent, moved_graph.transform_to_parent,
972            "parent transform should encode translation"
973        );
974        assert_eq!(
975            static_draw, moved_draw,
976            "child local primitive geometry must stay stable under parent translation"
977        );
978    }
979
980    #[test]
981    fn stored_content_hash_ignores_parent_translation() {
982        let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
983        let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
984
985        assert_eq!(
986            static_graph.target_content_hash(),
987            moved_graph.target_content_hash(),
988            "parent rigid motion must not invalidate the subtree content hash"
989        );
990    }
991
992    #[test]
993    fn parent_content_offset_is_encoded_in_child_transform() {
994        let child = BuildNodeSnapshot {
995            node_id: 2,
996            placement: Point { x: 11.0, y: 7.0 },
997            size: Size {
998                width: 40.0,
999                height: 20.0,
1000            },
1001            content_offset: Point::default(),
1002            motion_context_animated: false,
1003            translated_content_context: false,
1004            measured_max_width: None,
1005            resolved_modifiers: ResolvedModifiers::default(),
1006            draw_commands: vec![],
1007            click_actions: vec![],
1008            pointer_inputs: vec![],
1009            clip_to_bounds: false,
1010            annotated_text: None,
1011            text_style: None,
1012            text_layout_options: None,
1013            graphics_layer: None,
1014            children: vec![],
1015        };
1016
1017        let parent = BuildNodeSnapshot {
1018            node_id: 1,
1019            placement: Point::default(),
1020            size: Size {
1021                width: 80.0,
1022                height: 50.0,
1023            },
1024            content_offset: Point { x: 13.0, y: -9.0 },
1025            motion_context_animated: false,
1026            translated_content_context: false,
1027            measured_max_width: None,
1028            resolved_modifiers: ResolvedModifiers::default(),
1029            draw_commands: vec![],
1030            click_actions: vec![],
1031            pointer_inputs: vec![],
1032            clip_to_bounds: false,
1033            annotated_text: None,
1034            text_style: None,
1035            text_layout_options: None,
1036            graphics_layer: None,
1037            children: vec![child],
1038        };
1039
1040        let graph = build_layer_node_for_test(parent, 1.0, false);
1041        let RenderNode::Layer(child) = &graph.children[0] else {
1042            panic!("expected child layer");
1043        };
1044
1045        let top_left = child.transform_to_parent.map_point(Point::default());
1046        assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1047    }
1048
1049    #[test]
1050    fn rounded_clip_to_bounds_injects_per_corner_mask_effect() {
1051        let layer = graphics_layer_with_shaped_clip(
1052            GraphicsLayer::default(),
1053            true,
1054            Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
1055            Rect {
1056                x: 0.0,
1057                y: 0.0,
1058                width: 100.0,
1059                height: 40.0,
1060            },
1061        );
1062
1063        let Some(RenderEffect::Shader { shader }) = layer.render_effect else {
1064            panic!("rounded clip must emit a shader mask");
1065        };
1066        let uniforms = shader.uniforms();
1067        assert_eq!(uniforms[0], 100.0);
1068        assert_eq!(uniforms[1], 40.0);
1069        assert_eq!(uniforms[2], ROUNDED_CLIP_EDGE_FEATHER);
1070        assert_eq!(uniforms[3], 4.0);
1071        assert_eq!(uniforms[4], 8.0);
1072        assert_eq!(uniforms[5], 12.0);
1073        assert_eq!(uniforms[6], 16.0);
1074    }
1075
1076    #[test]
1077    fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
1078        let existing = RenderEffect::blur(3.0);
1079        let layer = graphics_layer_with_shaped_clip(
1080            GraphicsLayer {
1081                render_effect: Some(existing.clone()),
1082                ..GraphicsLayer::default()
1083            },
1084            true,
1085            Some(RoundedCornerShape::uniform(10.0)),
1086            Rect {
1087                x: 0.0,
1088                y: 0.0,
1089                width: 100.0,
1090                height: 40.0,
1091            },
1092        );
1093
1094        let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
1095            panic!("existing effect should chain into rounded clip mask");
1096        };
1097        assert_eq!(*first, existing);
1098        assert!(
1099            matches!(*second, RenderEffect::Shader { .. }),
1100            "rounded mask must be the outer effect"
1101        );
1102    }
1103
1104    #[test]
1105    fn rounded_corners_clip_to_bounds_builds_graph_mask_from_modifier_chain() {
1106        let mut composition = cranpose_ui::run_test_composition(|| {
1107            cranpose_ui::Box(
1108                Modifier::empty()
1109                    .width(100.0)
1110                    .height(40.0)
1111                    .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
1112                    .clip_to_bounds(),
1113                cranpose_ui::BoxSpec::default(),
1114                || {
1115                    Text("rounded child", Modifier::empty(), TextStyle::default());
1116                },
1117            );
1118        });
1119
1120        let root = composition.root().expect("rounded clip root");
1121        let handle = composition.runtime_handle();
1122        let mut applier = composition.applier_mut();
1123        applier.set_runtime_handle(handle);
1124        applier
1125            .compute_layout(
1126                root,
1127                Size {
1128                    width: 160.0,
1129                    height: 100.0,
1130                },
1131            )
1132            .expect("rounded clip layout");
1133        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1134        applier.clear_runtime_handle();
1135
1136        assert!(
1137            graph_has_runtime_shader_effect(&graph.root),
1138            "rounded_corners().clip_to_bounds() must build a shaped mask effect for descendants"
1139        );
1140    }
1141
1142    #[test]
1143    fn update_graph_from_applier_replaces_dirty_child_layer() {
1144        let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1145            Rc::new(RefCell::new(None));
1146        let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1147        let state_holder_for_comp = state_holder.clone();
1148        let child_id_holder_for_comp = child_id_holder.clone();
1149
1150        let mut composition = cranpose_ui::run_test_composition(move || {
1151            let label = cranpose_core::useState(|| "before".to_string());
1152            *state_holder_for_comp.borrow_mut() = Some(label);
1153            let child_id_holder_for_content = child_id_holder_for_comp.clone();
1154            cranpose_ui::Box(
1155                Modifier::empty().size_points(240.0, 80.0),
1156                cranpose_ui::BoxSpec::default(),
1157                move || {
1158                    let child_id = Text(label, Modifier::empty(), TextStyle::default());
1159                    *child_id_holder_for_content.borrow_mut() = Some(child_id);
1160                    Text("stable", Modifier::empty(), TextStyle::default());
1161                },
1162            );
1163        });
1164
1165        let root = composition.root().expect("composition root");
1166        let viewport = Size {
1167            width: 240.0,
1168            height: 80.0,
1169        };
1170        let handle = composition.runtime_handle();
1171        let mut applier = composition.applier_mut();
1172        applier.set_runtime_handle(handle);
1173        applier
1174            .compute_layout(root, viewport)
1175            .expect("initial layout");
1176        let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1177        let child_id = child_id_holder
1178            .borrow()
1179            .expect("text child id should be captured");
1180        let initial_transform = find_layer_by_node_id(&graph.root, child_id)
1181            .expect("text child layer")
1182            .transform_to_parent;
1183        applier.clear_runtime_handle();
1184        drop(applier);
1185
1186        let label = state_holder
1187            .borrow()
1188            .as_ref()
1189            .copied()
1190            .expect("label state should be captured");
1191        label.set_value("after".to_string());
1192        composition
1193            .process_invalid_scopes()
1194            .expect("text recomposition");
1195
1196        let handle = composition.runtime_handle();
1197        let mut applier = composition.applier_mut();
1198        applier.set_runtime_handle(handle);
1199        applier
1200            .compute_layout(root, viewport)
1201            .expect("updated layout");
1202        let child_id = child_id_holder
1203            .borrow()
1204            .expect("text child id should remain captured");
1205
1206        assert!(
1207            update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
1208            "dirty child should be replaceable from retained applier state"
1209        );
1210        applier.clear_runtime_handle();
1211
1212        let mut labels = Vec::new();
1213        collect_text_labels(&graph.root, &mut labels);
1214        assert!(
1215            labels.iter().any(|label| label == "after"),
1216            "updated graph should contain refreshed child text, got {labels:?}"
1217        );
1218        assert!(
1219            !labels.iter().any(|label| label == "before"),
1220            "updated graph should not retain stale child text, got {labels:?}"
1221        );
1222        assert!(
1223            labels.iter().any(|label| label == "stable"),
1224            "sibling content should remain present, got {labels:?}"
1225        );
1226        assert_eq!(
1227            find_layer_by_node_id(&graph.root, child_id)
1228                .expect("updated text child layer")
1229                .transform_to_parent,
1230            initial_transform,
1231            "draw-only child replacement must preserve the retained parent placement transform"
1232        );
1233    }
1234
1235    #[test]
1236    fn overlay_draw_commands_are_tagged_after_children() {
1237        let child = BuildNodeSnapshot {
1238            node_id: 2,
1239            placement: Point { x: 4.0, y: 5.0 },
1240            size: Size {
1241                width: 20.0,
1242                height: 10.0,
1243            },
1244            content_offset: Point::default(),
1245            motion_context_animated: false,
1246            translated_content_context: false,
1247            measured_max_width: None,
1248            resolved_modifiers: ResolvedModifiers::default(),
1249            draw_commands: vec![],
1250            click_actions: vec![],
1251            pointer_inputs: vec![],
1252            clip_to_bounds: false,
1253            annotated_text: None,
1254            text_style: None,
1255            text_layout_options: None,
1256            graphics_layer: None,
1257            children: vec![],
1258        };
1259        let behind = DrawCommand::Behind(Rc::new(|_size: Size| {
1260            vec![cranpose_ui_graphics::DrawPrimitive::Rect {
1261                rect: Rect {
1262                    x: 1.0,
1263                    y: 2.0,
1264                    width: 8.0,
1265                    height: 6.0,
1266                },
1267                brush: Brush::solid(Color::WHITE),
1268            }]
1269        }));
1270        let overlay = DrawCommand::Overlay(Rc::new(|_size: Size| {
1271            vec![cranpose_ui_graphics::DrawPrimitive::Rect {
1272                rect: Rect {
1273                    x: 3.0,
1274                    y: 1.0,
1275                    width: 5.0,
1276                    height: 4.0,
1277                },
1278                brush: Brush::solid(Color::BLACK),
1279            }]
1280        }));
1281
1282        let parent = BuildNodeSnapshot {
1283            node_id: 1,
1284            placement: Point::default(),
1285            size: Size {
1286                width: 80.0,
1287                height: 50.0,
1288            },
1289            content_offset: Point::default(),
1290            motion_context_animated: false,
1291            translated_content_context: false,
1292            measured_max_width: None,
1293            resolved_modifiers: ResolvedModifiers::default(),
1294            draw_commands: vec![behind, overlay],
1295            click_actions: vec![],
1296            pointer_inputs: vec![],
1297            clip_to_bounds: false,
1298            annotated_text: None,
1299            text_style: None,
1300            text_layout_options: None,
1301            graphics_layer: None,
1302            children: vec![child],
1303        };
1304
1305        let graph = build_layer_node_for_test(parent, 1.0, false);
1306        let RenderNode::Primitive(behind) = &graph.children[0] else {
1307            panic!("expected before-children primitive");
1308        };
1309        let RenderNode::Layer(_) = &graph.children[1] else {
1310            panic!("expected child layer");
1311        };
1312        let RenderNode::Primitive(overlay) = &graph.children[2] else {
1313            panic!("expected after-children primitive");
1314        };
1315
1316        assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
1317        assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
1318    }
1319
1320    #[test]
1321    fn stored_content_hash_changes_when_child_transform_changes() {
1322        let child = BuildNodeSnapshot {
1323            node_id: 2,
1324            placement: Point { x: 4.0, y: 5.0 },
1325            size: Size {
1326                width: 20.0,
1327                height: 10.0,
1328            },
1329            content_offset: Point::default(),
1330            motion_context_animated: false,
1331            translated_content_context: false,
1332            measured_max_width: None,
1333            resolved_modifiers: ResolvedModifiers::default(),
1334            draw_commands: vec![],
1335            click_actions: vec![],
1336            pointer_inputs: vec![],
1337            clip_to_bounds: false,
1338            annotated_text: None,
1339            text_style: None,
1340            text_layout_options: None,
1341            graphics_layer: None,
1342            children: vec![],
1343        };
1344        let mut moved_child = child.clone();
1345        moved_child.placement.x += 7.0;
1346
1347        let parent = BuildNodeSnapshot {
1348            node_id: 1,
1349            placement: Point::default(),
1350            size: Size {
1351                width: 80.0,
1352                height: 50.0,
1353            },
1354            content_offset: Point::default(),
1355            motion_context_animated: false,
1356            translated_content_context: false,
1357            measured_max_width: None,
1358            resolved_modifiers: ResolvedModifiers::default(),
1359            draw_commands: vec![],
1360            click_actions: vec![],
1361            pointer_inputs: vec![],
1362            clip_to_bounds: false,
1363            annotated_text: None,
1364            text_style: None,
1365            text_layout_options: None,
1366            graphics_layer: None,
1367            children: vec![child],
1368        };
1369        let moved_parent = BuildNodeSnapshot {
1370            children: vec![moved_child],
1371            ..parent.clone()
1372        };
1373
1374        let static_graph = build_layer_node_for_test(parent, 1.0, false);
1375        let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
1376
1377        assert_ne!(
1378            static_graph.target_content_hash(),
1379            moved_graph.target_content_hash(),
1380            "moving a child within the parent must invalidate the parent subtree hash"
1381        );
1382    }
1383
1384    #[test]
1385    fn stored_effect_hash_tracks_local_effect_only() {
1386        let base = BuildNodeSnapshot {
1387            node_id: 1,
1388            placement: Point::default(),
1389            size: Size {
1390                width: 80.0,
1391                height: 50.0,
1392            },
1393            content_offset: Point::default(),
1394            motion_context_animated: false,
1395            translated_content_context: false,
1396            measured_max_width: None,
1397            resolved_modifiers: ResolvedModifiers::default(),
1398            draw_commands: vec![],
1399            click_actions: vec![],
1400            pointer_inputs: vec![],
1401            clip_to_bounds: false,
1402            annotated_text: None,
1403            text_style: None,
1404            text_layout_options: None,
1405            graphics_layer: None,
1406            children: vec![],
1407        };
1408        let mut effected = base.clone();
1409        effected.graphics_layer = Some(GraphicsLayer {
1410            render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
1411            ..GraphicsLayer::default()
1412        });
1413
1414        let base_graph = build_layer_node_for_test(base, 1.0, false);
1415        let effected_graph = build_layer_node_for_test(effected, 1.0, false);
1416
1417        assert_eq!(
1418            base_graph.target_content_hash(),
1419            effected_graph.target_content_hash(),
1420            "post-processing effect parameters belong to the effect hash, not the content hash"
1421        );
1422        assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
1423    }
1424
1425    #[test]
1426    fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
1427        let mut text_style = TextStyle::default();
1428        text_style.paragraph_style.text_align = TextAlign::Start;
1429        text_style.paragraph_style.text_direction = TextDirection::Rtl;
1430        text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
1431
1432        let snapshot = BuildNodeSnapshot {
1433            node_id: 1,
1434            placement: Point::default(),
1435            size: Size {
1436                width: 180.0,
1437                height: 48.0,
1438            },
1439            content_offset: Point::default(),
1440            motion_context_animated: false,
1441            translated_content_context: false,
1442            measured_max_width: Some(180.0),
1443            resolved_modifiers: ResolvedModifiers::default(),
1444            draw_commands: vec![],
1445            click_actions: vec![],
1446            pointer_inputs: vec![],
1447            clip_to_bounds: false,
1448            annotated_text: Some(AnnotatedString::from("rtl")),
1449            text_style: Some(text_style),
1450            text_layout_options: Some(cranpose_ui::TextLayoutOptions {
1451                overflow: cranpose_ui::TextOverflow::Clip,
1452                ..Default::default()
1453            }),
1454            graphics_layer: None,
1455            children: vec![],
1456        };
1457
1458        let graph = build_layer_node_for_test(snapshot, 1.0, false);
1459        let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
1460            panic!("expected text primitive");
1461        };
1462        let PrimitiveNode::Text(text) = &text_primitive.node else {
1463            panic!("expected text primitive");
1464        };
1465        let clip = text
1466            .clip
1467            .expect("clipped overflow should produce a clip rect");
1468
1469        assert!(
1470            text.rect.x > 0.0,
1471            "RTL start alignment should shift the text rect within the available width"
1472        );
1473        assert!(
1474            clip.y < text.rect.y,
1475            "baseline shift must expand the clip upward so superscript glyphs are preserved"
1476        );
1477        assert!(
1478            clip.intersect(text.rect).is_some(),
1479            "the clip rect must intersect the shifted text draw rect"
1480        );
1481    }
1482
1483    #[test]
1484    fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
1485        let child = BuildNodeSnapshot {
1486            node_id: 2,
1487            placement: Point { x: 11.0, y: 7.0 },
1488            size: Size {
1489                width: 120.0,
1490                height: 32.0,
1491            },
1492            content_offset: Point::default(),
1493            motion_context_animated: false,
1494            translated_content_context: false,
1495            measured_max_width: Some(120.0),
1496            resolved_modifiers: ResolvedModifiers::default(),
1497            draw_commands: vec![],
1498            click_actions: vec![],
1499            pointer_inputs: vec![],
1500            clip_to_bounds: false,
1501            annotated_text: Some(AnnotatedString::from("scrolling")),
1502            text_style: Some(TextStyle::default()),
1503            text_layout_options: None,
1504            graphics_layer: None,
1505            children: vec![],
1506        };
1507        let parent = BuildNodeSnapshot {
1508            node_id: 1,
1509            placement: Point::default(),
1510            size: Size {
1511                width: 160.0,
1512                height: 64.0,
1513            },
1514            content_offset: Point { x: 0.0, y: -18.5 },
1515            motion_context_animated: false,
1516            translated_content_context: true,
1517            measured_max_width: None,
1518            resolved_modifiers: ResolvedModifiers::default(),
1519            draw_commands: vec![],
1520            click_actions: vec![],
1521            pointer_inputs: vec![],
1522            clip_to_bounds: false,
1523            annotated_text: None,
1524            text_style: None,
1525            text_layout_options: None,
1526            graphics_layer: None,
1527            children: vec![child],
1528        };
1529
1530        let graph = build_layer_node_for_test(parent, 1.0, false);
1531        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1532            panic!("expected child layer");
1533        };
1534        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1535            panic!("expected text primitive");
1536        };
1537        let PrimitiveNode::Text(text) = &text_primitive.node else {
1538            panic!("expected text primitive");
1539        };
1540
1541        assert_eq!(text.text_style.paragraph_style.text_motion, None);
1542        assert!(!child_layer.motion_context_animated);
1543    }
1544
1545    #[test]
1546    fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
1547        let child = BuildNodeSnapshot {
1548            node_id: 2,
1549            placement: Point { x: 11.0, y: 7.0 },
1550            size: Size {
1551                width: 120.0,
1552                height: 32.0,
1553            },
1554            content_offset: Point::default(),
1555            motion_context_animated: false,
1556            translated_content_context: false,
1557            measured_max_width: Some(120.0),
1558            resolved_modifiers: ResolvedModifiers::default(),
1559            draw_commands: vec![],
1560            click_actions: vec![],
1561            pointer_inputs: vec![],
1562            clip_to_bounds: false,
1563            annotated_text: Some(AnnotatedString::from("scrolling")),
1564            text_style: Some(TextStyle::default()),
1565            text_layout_options: None,
1566            graphics_layer: None,
1567            children: vec![],
1568        };
1569        let parent = BuildNodeSnapshot {
1570            node_id: 1,
1571            placement: Point::default(),
1572            size: Size {
1573                width: 160.0,
1574                height: 64.0,
1575            },
1576            content_offset: Point { x: 0.0, y: -18.0 },
1577            motion_context_animated: false,
1578            translated_content_context: false,
1579            measured_max_width: None,
1580            resolved_modifiers: ResolvedModifiers::default(),
1581            draw_commands: vec![],
1582            click_actions: vec![],
1583            pointer_inputs: vec![],
1584            clip_to_bounds: false,
1585            annotated_text: None,
1586            text_style: None,
1587            text_layout_options: None,
1588            graphics_layer: None,
1589            children: vec![child],
1590        };
1591
1592        let graph = build_layer_node_for_test(parent, 1.0, false);
1593        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1594            panic!("expected child layer");
1595        };
1596        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1597            panic!("expected text primitive");
1598        };
1599        let PrimitiveNode::Text(text) = &text_primitive.node else {
1600            panic!("expected text primitive");
1601        };
1602
1603        assert_eq!(
1604            text.text_style.paragraph_style.text_motion, None,
1605            "content_offset alone must not force text onto the translated-content motion path"
1606        );
1607        assert!(!child_layer.motion_context_animated);
1608    }
1609
1610    #[test]
1611    fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
1612        let child = BuildNodeSnapshot {
1613            node_id: 2,
1614            placement: Point { x: 11.0, y: 7.0 },
1615            size: Size {
1616                width: 120.0,
1617                height: 32.0,
1618            },
1619            content_offset: Point::default(),
1620            motion_context_animated: false,
1621            translated_content_context: false,
1622            measured_max_width: Some(120.0),
1623            resolved_modifiers: ResolvedModifiers::default(),
1624            draw_commands: vec![],
1625            click_actions: vec![],
1626            pointer_inputs: vec![],
1627            clip_to_bounds: false,
1628            annotated_text: Some(AnnotatedString::from("shadow")),
1629            text_style: Some(TextStyle::from_span_style(SpanStyle {
1630                shadow: Some(cranpose_ui::text::Shadow {
1631                    color: Color::BLACK,
1632                    offset: Point::new(1.0, 2.0),
1633                    blur_radius: 3.0,
1634                }),
1635                ..SpanStyle::default()
1636            })),
1637            text_layout_options: None,
1638            graphics_layer: None,
1639            children: vec![],
1640        };
1641        let parent = BuildNodeSnapshot {
1642            node_id: 1,
1643            placement: Point::default(),
1644            size: Size {
1645                width: 160.0,
1646                height: 64.0,
1647            },
1648            content_offset: Point { x: 0.0, y: -18.5 },
1649            motion_context_animated: false,
1650            translated_content_context: true,
1651            measured_max_width: None,
1652            resolved_modifiers: ResolvedModifiers::default(),
1653            draw_commands: vec![],
1654            click_actions: vec![],
1655            pointer_inputs: vec![],
1656            clip_to_bounds: false,
1657            annotated_text: None,
1658            text_style: None,
1659            text_layout_options: None,
1660            graphics_layer: None,
1661            children: vec![child],
1662        };
1663
1664        let graph = build_layer_node_for_test(parent, 1.0, false);
1665        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1666            panic!("expected child layer");
1667        };
1668        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1669            panic!("expected text primitive");
1670        };
1671        let PrimitiveNode::Text(text) = &text_primitive.node else {
1672            panic!("expected text primitive");
1673        };
1674
1675        assert_eq!(text.text_style.paragraph_style.text_motion, None);
1676    }
1677
1678    #[test]
1679    fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
1680        let child = BuildNodeSnapshot {
1681            node_id: 2,
1682            placement: Point { x: 11.0, y: 7.0 },
1683            size: Size {
1684                width: 120.0,
1685                height: 32.0,
1686            },
1687            content_offset: Point::default(),
1688            motion_context_animated: false,
1689            translated_content_context: false,
1690            measured_max_width: Some(120.0),
1691            resolved_modifiers: ResolvedModifiers::default(),
1692            draw_commands: vec![],
1693            click_actions: vec![],
1694            pointer_inputs: vec![],
1695            clip_to_bounds: false,
1696            annotated_text: Some(AnnotatedString::from("lazy")),
1697            text_style: Some(TextStyle::default()),
1698            text_layout_options: None,
1699            graphics_layer: None,
1700            children: vec![],
1701        };
1702        let parent = BuildNodeSnapshot {
1703            node_id: 1,
1704            placement: Point::default(),
1705            size: Size {
1706                width: 160.0,
1707                height: 64.0,
1708            },
1709            content_offset: Point::default(),
1710            motion_context_animated: true,
1711            translated_content_context: false,
1712            measured_max_width: None,
1713            resolved_modifiers: ResolvedModifiers::default(),
1714            draw_commands: vec![],
1715            click_actions: vec![],
1716            pointer_inputs: vec![],
1717            clip_to_bounds: false,
1718            annotated_text: None,
1719            text_style: None,
1720            text_layout_options: None,
1721            graphics_layer: None,
1722            children: vec![child],
1723        };
1724
1725        let graph = build_layer_node_for_test(parent, 1.0, false);
1726        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1727            panic!("expected child layer");
1728        };
1729        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1730            panic!("expected text primitive");
1731        };
1732        let PrimitiveNode::Text(text) = &text_primitive.node else {
1733            panic!("expected text primitive");
1734        };
1735
1736        assert_eq!(text.text_style.paragraph_style.text_motion, None);
1737        assert!(graph.motion_context_animated);
1738        assert!(child_layer.motion_context_animated);
1739    }
1740
1741    #[test]
1742    fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
1743        let mut composition = cranpose_ui::run_test_composition(|| {
1744            let list_state = remember_lazy_list_state();
1745            LazyColumn(
1746                Modifier::empty(),
1747                list_state,
1748                LazyColumnSpec::default(),
1749                |scope| {
1750                    scope.item(Some(0), None, || {
1751                        Text("LazyMotion", Modifier::empty(), TextStyle::default());
1752                    });
1753                },
1754            );
1755        });
1756
1757        let root = composition.root().expect("lazy column root");
1758        let handle = composition.runtime_handle();
1759        let mut applier = composition.applier_mut();
1760        applier.set_runtime_handle(handle);
1761        let _ = applier
1762            .compute_layout(
1763                root,
1764                Size {
1765                    width: 240.0,
1766                    height: 240.0,
1767                },
1768            )
1769            .expect("lazy column layout");
1770        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
1771        applier.clear_runtime_handle();
1772
1773        assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
1774    }
1775
1776    #[test]
1777    fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
1778        use std::cell::RefCell;
1779        use std::rc::Rc;
1780
1781        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1782        let state_holder_for_comp = state_holder.clone();
1783        let mut composition = cranpose_ui::run_test_composition(move || {
1784            let list_state = remember_lazy_list_state();
1785            *state_holder_for_comp.borrow_mut() = Some(list_state);
1786            LazyColumn(
1787                Modifier::empty().height(120.0),
1788                list_state,
1789                LazyColumnSpec::default(),
1790                |scope| {
1791                    scope.items(
1792                        8,
1793                        None::<fn(usize) -> u64>,
1794                        None::<fn(usize) -> u64>,
1795                        |index| {
1796                            Text(
1797                                format!("LazyMotion {index}"),
1798                                Modifier::empty().padding(4.0),
1799                                TextStyle::default(),
1800                            );
1801                        },
1802                    );
1803                },
1804            );
1805        });
1806
1807        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
1808        list_state.scroll_to_item(3, 0.0);
1809
1810        let root = composition.root().expect("lazy column root");
1811        let handle = composition.runtime_handle();
1812        let mut applier = composition.applier_mut();
1813        applier.set_runtime_handle(handle);
1814        let _ = applier
1815            .compute_layout(
1816                root,
1817                Size {
1818                    width: 240.0,
1819                    height: 240.0,
1820                },
1821            )
1822            .expect("lazy column layout");
1823        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
1824        let active_children = applier
1825            .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
1826            .expect("lazy column should be subcompose");
1827        let child_debug: Vec<String> = active_children
1828            .iter()
1829            .map(|&child_id| {
1830                if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
1831                    format!(
1832                        "layout#{child_id} placed={} text={:?} children={:?}",
1833                        node.layout_state().is_placed,
1834                        node.modifier_slices_snapshot()
1835                            .text_content()
1836                            .map(str::to_string),
1837                        node.children.clone()
1838                    )
1839                }) {
1840                    summary
1841                } else if let Ok(summary) =
1842                    applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
1843                        format!(
1844                            "subcompose#{child_id} placed={} active_children={:?}",
1845                            node.layout_state().is_placed,
1846                            node.active_children()
1847                        )
1848                    })
1849                {
1850                    summary
1851                } else {
1852                    format!("missing#{child_id}")
1853                }
1854            })
1855            .collect();
1856        applier.clear_runtime_handle();
1857
1858        let first_index = list_state.first_visible_item_index();
1859        assert!(
1860            first_index > 0,
1861            "lazy list should move away from origin before graph building, observed first_index={first_index}"
1862        );
1863        let mut labels = Vec::new();
1864        collect_text_labels(&graph.root, &mut labels);
1865        assert_eq!(
1866            find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
1867            Some(None),
1868            "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
1869            labels,
1870            active_children,
1871            child_debug
1872        );
1873    }
1874
1875    #[test]
1876    fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
1877        use std::cell::RefCell;
1878        use std::rc::Rc;
1879
1880        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
1881        let state_holder_for_comp = state_holder.clone();
1882        let mut composition = cranpose_ui::run_test_composition(move || {
1883            let list_state = remember_lazy_list_state();
1884            *state_holder_for_comp.borrow_mut() = Some(list_state);
1885            LazyColumn(
1886                Modifier::empty().height(120.0),
1887                list_state,
1888                LazyColumnSpec::default(),
1889                |scope| {
1890                    scope.items(
1891                        8,
1892                        None::<fn(usize) -> u64>,
1893                        None::<fn(usize) -> u64>,
1894                        |index| {
1895                            Text(
1896                                format!("LazySnap {index}"),
1897                                Modifier::empty().padding(4.0),
1898                                TextStyle::default(),
1899                            );
1900                        },
1901                    );
1902                },
1903            );
1904        });
1905
1906        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
1907        list_state.scroll_to_item(2, 7.5);
1908
1909        let root = composition.root().expect("lazy column root");
1910        let handle = composition.runtime_handle();
1911        let mut applier = composition.applier_mut();
1912        applier.set_runtime_handle(handle);
1913        let _ = applier
1914            .compute_layout(
1915                root,
1916                Size {
1917                    width: 240.0,
1918                    height: 240.0,
1919                },
1920            )
1921            .expect("lazy column layout");
1922        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
1923        applier.clear_runtime_handle();
1924
1925        let layout_info = list_state.layout_info();
1926        let first_visible_offset = layout_info
1927            .visible_items_info
1928            .first()
1929            .expect("lazy layout should expose visible item info")
1930            .offset;
1931        let snap_offset = find_translated_content_offset(&graph.root)
1932            .expect("lazy list graph should include translated content context");
1933
1934        assert!(
1935            (snap_offset.y - first_visible_offset).abs() <= 0.001,
1936            "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
1937        );
1938    }
1939
1940    #[test]
1941    fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
1942        let child = BuildNodeSnapshot {
1943            node_id: 2,
1944            placement: Point { x: 11.0, y: 7.0 },
1945            size: Size {
1946                width: 120.0,
1947                height: 32.0,
1948            },
1949            content_offset: Point::default(),
1950            motion_context_animated: false,
1951            translated_content_context: false,
1952            measured_max_width: Some(120.0),
1953            resolved_modifiers: ResolvedModifiers::default(),
1954            draw_commands: vec![],
1955            click_actions: vec![],
1956            pointer_inputs: vec![],
1957            clip_to_bounds: false,
1958            annotated_text: Some(AnnotatedString::from("static")),
1959            text_style: Some(TextStyle::from_paragraph_style(
1960                cranpose_ui::text::ParagraphStyle {
1961                    text_motion: Some(TextMotion::Static),
1962                    ..Default::default()
1963                },
1964            )),
1965            text_layout_options: None,
1966            graphics_layer: None,
1967            children: vec![],
1968        };
1969        let parent = BuildNodeSnapshot {
1970            node_id: 1,
1971            placement: Point::default(),
1972            size: Size {
1973                width: 160.0,
1974                height: 64.0,
1975            },
1976            content_offset: Point { x: 0.0, y: -18.5 },
1977            motion_context_animated: false,
1978            translated_content_context: true,
1979            measured_max_width: None,
1980            resolved_modifiers: ResolvedModifiers::default(),
1981            draw_commands: vec![],
1982            click_actions: vec![],
1983            pointer_inputs: vec![],
1984            clip_to_bounds: false,
1985            annotated_text: None,
1986            text_style: None,
1987            text_layout_options: None,
1988            graphics_layer: None,
1989            children: vec![child],
1990        };
1991
1992        let graph = build_layer_node_for_test(parent, 1.0, false);
1993        let RenderNode::Layer(child_layer) = &graph.children[0] else {
1994            panic!("expected child layer");
1995        };
1996        let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
1997            panic!("expected text primitive");
1998        };
1999        let PrimitiveNode::Text(text) = &text_primitive.node else {
2000            panic!("expected text primitive");
2001        };
2002
2003        assert_eq!(
2004            text.text_style.paragraph_style.text_motion,
2005            Some(TextMotion::Static),
2006            "explicit text motion must win over inherited scrolling motion context"
2007        );
2008    }
2009}