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}