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