1use std::collections::HashSet;
2use std::rc::Rc;
3
4use cranpose_core::{MemoryApplier, NodeId};
5use cranpose_ui::text::AnnotatedString;
6use cranpose_ui::text::{resolve_text_direction, TextAlign, TextStyle};
7use cranpose_ui::{
8 prepare_text_layout, DrawCommand, LayoutBox, LayoutNode, ModifierNodeSlices, Point, Rect,
9 ResolvedModifiers, Size, SubcomposeLayoutNode, TextLayoutOptions, TextOverflow,
10};
11use cranpose_ui_graphics::{
12 rounded_corner_alpha_mask_effect, CompositingStrategy, GraphicsLayer, LayerShape,
13 RoundedCornerShape,
14};
15
16use crate::graph::{
17 CachePolicy, DrawPrimitiveNode, HitTestNode, IsolationReasons, LayerNode, PrimitiveEntry,
18 PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode, TextPrimitiveNode,
19};
20use crate::layer_transform::layer_transform_to_parent;
21use crate::raster_cache::LayerRasterCacheHashes;
22use crate::style_shared::{primitives_for_placement, DrawPlacement};
23
24const TEXT_CLIP_PAD: f32 = 1.0;
25const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
26
27#[derive(Clone)]
28struct BuildNodeSnapshot {
29 node_id: NodeId,
30 placement: Point,
31 size: Size,
32 content_offset: Point,
33 motion_context_animated: bool,
34 translated_content_context: bool,
35 measured_max_width: Option<f32>,
36 resolved_modifiers: ResolvedModifiers,
37 draw_commands: Vec<DrawCommand>,
38 click_actions: Vec<Rc<dyn Fn(Point)>>,
39 pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
40 clip_to_bounds: bool,
41 annotated_text: Option<AnnotatedString>,
42 text_style: Option<TextStyle>,
43 text_layout_options: Option<TextLayoutOptions>,
44 graphics_layer: Option<GraphicsLayer>,
45 children: Vec<Self>,
46}
47
48struct SnapshotNodeData {
49 layout_state: cranpose_ui::widgets::LayoutState,
50 modifier_slices: Rc<ModifierNodeSlices>,
51 resolved_modifiers: ResolvedModifiers,
52 children: Vec<NodeId>,
53}
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56pub struct GraphUpdateReport {
57 pub applied: bool,
58 pub hit_graph_dirty: bool,
59}
60
61pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
62 let root_snapshot = layout_box_to_snapshot(root, None);
63 RenderGraph {
64 root: build_layer_node(root_snapshot, scale, false),
65 }
66}
67
68pub fn build_graph_from_applier(
69 applier: &mut MemoryApplier,
70 root: NodeId,
71 scale: f32,
72) -> Option<RenderGraph> {
73 Some(RenderGraph {
74 root: build_layer_node_from_applier(applier, root, scale, false)?,
75 })
76}
77
78pub fn update_graph_from_applier(
79 applier: &mut MemoryApplier,
80 graph: &mut RenderGraph,
81 dirty_nodes: &[NodeId],
82 scale: f32,
83) -> bool {
84 update_graph_from_applier_report(applier, graph, dirty_nodes, scale).applied
85}
86
87pub fn update_graph_from_applier_report(
88 applier: &mut MemoryApplier,
89 graph: &mut RenderGraph,
90 dirty_nodes: &[NodeId],
91 scale: f32,
92) -> GraphUpdateReport {
93 if dirty_nodes.is_empty() {
94 return GraphUpdateReport {
95 applied: true,
96 hit_graph_dirty: false,
97 };
98 }
99
100 let mut remaining_dirty_nodes = dirty_nodes.iter().copied().collect::<HashSet<_>>();
101 if let Some(root_id) = graph.root.node_id {
102 if remaining_dirty_nodes.contains(&root_id) {
103 let Some(root) = build_layer_node_from_applier(applier, root_id, scale, false) else {
104 return GraphUpdateReport {
105 applied: false,
106 hit_graph_dirty: true,
107 };
108 };
109 let hit_graph_dirty = layer_hit_graph_state_dirty(&graph.root, &root);
110 graph.root = root;
111 graph.root.recompute_raster_cache_hashes();
112 return GraphUpdateReport {
113 applied: true,
114 hit_graph_dirty,
115 };
116 }
117 }
118
119 let inherited_translated_content_context = graph.root.translated_content_context;
120 let report = match replace_dirty_layers_from_applier(
121 applier,
122 &mut graph.root,
123 &mut remaining_dirty_nodes,
124 inherited_translated_content_context,
125 ) {
126 Some(report) => report,
127 None => {
128 return GraphUpdateReport {
129 applied: false,
130 hit_graph_dirty: true,
131 };
132 }
133 };
134
135 if !remaining_dirty_nodes.is_empty() {
136 return GraphUpdateReport {
137 applied: false,
138 hit_graph_dirty: true,
139 };
140 }
141
142 if report.updated {
143 graph.root.recompute_raster_cache_hashes();
144 }
145 GraphUpdateReport {
146 applied: true,
147 hit_graph_dirty: report.hit_graph_dirty,
148 }
149}
150
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152struct ReplaceDirtyLayersReport {
153 updated: bool,
154 hit_graph_dirty: bool,
155}
156
157fn replace_dirty_layers_from_applier(
158 applier: &mut MemoryApplier,
159 parent: &mut LayerNode,
160 dirty_nodes: &mut HashSet<NodeId>,
161 inherited_translated_content_context: bool,
162) -> Option<ReplaceDirtyLayersReport> {
163 if dirty_nodes.is_empty() {
164 return Some(ReplaceDirtyLayersReport::default());
165 }
166
167 let child_inherited_translated_content_context =
168 inherited_translated_content_context || parent.translated_content_context;
169 let mut report = ReplaceDirtyLayersReport::default();
170
171 for child in &mut parent.children {
172 let RenderNode::Layer(child_layer) = child else {
173 continue;
174 };
175
176 if child_layer
177 .node_id
178 .is_some_and(|node_id| dirty_nodes.remove(&node_id))
179 {
180 let mut replacement = build_layer_node_from_applier_internal(
181 applier,
182 child_layer
183 .node_id
184 .expect("dirty layer must have a node id"),
185 parent.motion_context_animated,
186 child_inherited_translated_content_context,
187 )?;
188 if parent.content_offset != Point::default() {
189 replacement.transform_to_parent =
190 replacement
191 .transform_to_parent
192 .then(ProjectiveTransform::translation(
193 parent.content_offset.x,
194 parent.content_offset.y,
195 ));
196 }
197 report.hit_graph_dirty |= layer_hit_graph_state_dirty(child_layer, &replacement);
198 remove_dirty_descendants(&replacement, dirty_nodes);
199 **child_layer = replacement;
200 report.updated = true;
201 continue;
202 }
203
204 match replace_dirty_layers_from_applier(
205 applier,
206 child_layer,
207 dirty_nodes,
208 child_inherited_translated_content_context,
209 ) {
210 Some(child_report) => {
211 report.updated |= child_report.updated;
212 report.hit_graph_dirty |= child_report.hit_graph_dirty;
213 }
214 None => return None,
215 }
216 }
217
218 if report.updated {
219 parent.has_hit_targets = parent.hit_test.is_some()
220 || parent.children.iter().any(|child| match child {
221 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
222 RenderNode::Primitive(_) => false,
223 });
224 }
225
226 Some(report)
227}
228
229fn layer_hit_graph_state_dirty(previous: &LayerNode, replacement: &LayerNode) -> bool {
230 if previous.hit_test.is_some() || replacement.hit_test.is_some() {
231 return true;
232 }
233
234 if !(previous.has_hit_targets || replacement.has_hit_targets) {
235 return false;
236 }
237
238 previous.has_hit_targets != replacement.has_hit_targets
239 || previous.local_bounds != replacement.local_bounds
240 || previous.transform_to_parent != replacement.transform_to_parent
241 || previous.clip_rect() != replacement.clip_rect()
242 || previous.graphics_layer.shape != replacement.graphics_layer.shape
243}
244
245fn remove_dirty_descendants(layer: &LayerNode, dirty_nodes: &mut HashSet<NodeId>) {
246 for child in &layer.children {
247 let RenderNode::Layer(child_layer) = child else {
248 continue;
249 };
250 if let Some(node_id) = child_layer.node_id {
251 dirty_nodes.remove(&node_id);
252 }
253 remove_dirty_descendants(child_layer, dirty_nodes);
254 }
255}
256
257fn build_layer_node(
258 snapshot: BuildNodeSnapshot,
259 _root_scale: f32,
260 inherited_motion_context_animated: bool,
261) -> LayerNode {
262 build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
263}
264
265fn build_layer_node_internal(
266 snapshot: BuildNodeSnapshot,
267 inherited_motion_context_animated: bool,
268 inherited_translated_content_context: bool,
269) -> LayerNode {
270 let BuildNodeSnapshot {
271 node_id,
272 placement,
273 size,
274 content_offset,
275 motion_context_animated,
276 translated_content_context,
277 measured_max_width,
278 resolved_modifiers,
279 draw_commands,
280 click_actions,
281 pointer_inputs,
282 clip_to_bounds,
283 annotated_text,
284 text_style,
285 text_layout_options,
286 graphics_layer,
287 children: child_snapshots,
288 } = snapshot;
289 let local_bounds = Rect {
290 x: 0.0,
291 y: 0.0,
292 width: size.width,
293 height: size.height,
294 };
295 let graphics_layer = graphics_layer.unwrap_or_default();
296 let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
297 let isolation = isolation_reasons(&graphics_layer);
298 let cache_policy = if isolation.has_any() {
299 CachePolicy::Auto
300 } else {
301 CachePolicy::None
302 };
303 let shadow_clip = clip_to_bounds.then_some(local_bounds);
304 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
305 shape: None,
306 click_actions,
307 pointer_inputs,
308 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
309 });
310
311 let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
312 let child_translated_content_context =
313 inherited_translated_content_context || translated_content_context;
314
315 let mut children = draw_nodes(
316 &draw_commands,
317 DrawPlacement::Behind,
318 size,
319 PrimitivePhase::BeforeChildren,
320 );
321 if let Some(text) = text_node_from_parts(TextNodeParts {
322 node_id,
323 local_bounds,
324 measured_max_width,
325 resolved_modifiers: &resolved_modifiers,
326 annotated_text: annotated_text.as_ref(),
327 text_style: text_style.as_ref(),
328 text_layout_options,
329 modifier_slices: None,
330 }) {
331 children.push(RenderNode::Primitive(PrimitiveEntry {
332 phase: PrimitivePhase::BeforeChildren,
333 node: PrimitiveNode::Text(Box::new(text)),
334 }));
335 }
336 let child_motion_context_animated = node_motion_context_animated;
337 for child in child_snapshots {
338 let mut child_layer = build_layer_node_internal(
339 child,
340 child_motion_context_animated,
341 child_translated_content_context,
342 );
343 if content_offset != Point::default() {
344 child_layer.transform_to_parent =
345 child_layer
346 .transform_to_parent
347 .then(ProjectiveTransform::translation(
348 content_offset.x,
349 content_offset.y,
350 ));
351 }
352 children.push(RenderNode::Layer(Box::new(child_layer)));
353 }
354 children.extend(draw_nodes(
355 &draw_commands,
356 DrawPlacement::Overlay,
357 size,
358 PrimitivePhase::AfterChildren,
359 ));
360 let has_hit_targets = hit_test.is_some()
361 || children.iter().any(|child| match child {
362 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
363 RenderNode::Primitive(_) => false,
364 });
365
366 LayerNode {
367 node_id: Some(node_id),
368 local_bounds,
369 transform_to_parent,
370 content_offset,
371 motion_context_animated: node_motion_context_animated,
372 translated_content_context,
373 translated_content_offset: if translated_content_context {
374 content_offset
375 } else {
376 Point::default()
377 },
378 graphics_layer,
379 clip_to_bounds,
380 shadow_clip,
381 hit_test,
382 has_hit_targets,
383 isolation,
384 cache_policy,
385 cache_hashes: LayerRasterCacheHashes::default(),
386 cache_hashes_valid: false,
387 children,
388 }
389}
390
391fn build_layer_node_from_applier(
392 applier: &mut MemoryApplier,
393 node_id: NodeId,
394 _root_scale: f32,
395 inherited_motion_context_animated: bool,
396) -> Option<LayerNode> {
397 build_layer_node_from_applier_internal(
398 applier,
399 node_id,
400 inherited_motion_context_animated,
401 false,
402 )
403}
404
405fn build_layer_node_from_applier_internal(
406 applier: &mut MemoryApplier,
407 node_id: NodeId,
408 inherited_motion_context_animated: bool,
409 inherited_translated_content_context: bool,
410) -> Option<LayerNode> {
411 if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
412 let state = node.layout_state();
413 let children = node.children.clone();
414 let modifier_slices = node.modifier_slices_snapshot();
415 SnapshotNodeData {
416 layout_state: state,
417 modifier_slices,
418 resolved_modifiers: node.resolved_modifiers(),
419 children,
420 }
421 }) {
422 return build_layer_node_from_data(
423 applier,
424 node_id,
425 data,
426 inherited_motion_context_animated,
427 inherited_translated_content_context,
428 );
429 }
430
431 if let Ok(data) = applier.with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
432 let state = node.layout_state();
433 let children = node.active_children();
434 let modifier_slices = node.modifier_slices_snapshot();
435 SnapshotNodeData {
436 layout_state: state,
437 modifier_slices,
438 resolved_modifiers: node.resolved_modifiers(),
439 children,
440 }
441 }) {
442 return build_layer_node_from_data(
443 applier,
444 node_id,
445 data,
446 inherited_motion_context_animated,
447 inherited_translated_content_context,
448 );
449 }
450
451 None
452}
453
454fn build_layer_node_from_data(
455 applier: &mut MemoryApplier,
456 node_id: NodeId,
457 data: SnapshotNodeData,
458 inherited_motion_context_animated: bool,
459 inherited_translated_content_context: bool,
460) -> Option<LayerNode> {
461 let SnapshotNodeData {
462 layout_state,
463 modifier_slices,
464 resolved_modifiers,
465 children,
466 } = data;
467 if !layout_state.is_placed {
468 return None;
469 }
470
471 let local_bounds = Rect {
472 x: 0.0,
473 y: 0.0,
474 width: layout_state.size.width,
475 height: layout_state.size.height,
476 };
477 let clip_to_bounds = modifier_slices.clip_to_bounds();
478 let graphics_layer = graphics_layer_with_shaped_clip(
479 modifier_slices.graphics_layer().unwrap_or_default(),
480 clip_to_bounds,
481 modifier_slices.corner_shape(),
482 local_bounds,
483 );
484 let transform_to_parent =
485 layer_transform_to_parent(local_bounds, layout_state.position, &graphics_layer);
486 let isolation = isolation_reasons(&graphics_layer);
487 let cache_policy = if isolation.has_any() {
488 CachePolicy::Auto
489 } else {
490 CachePolicy::None
491 };
492 let click_actions = modifier_slices.click_handlers();
493 let pointer_inputs = modifier_slices.pointer_inputs();
494 let shadow_clip = clip_to_bounds.then_some(local_bounds);
495 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
496 shape: None,
497 click_actions: click_actions.to_vec(),
498 pointer_inputs: pointer_inputs.to_vec(),
499 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
500 });
501
502 let node_motion_context_animated =
503 inherited_motion_context_animated || modifier_slices.motion_context_animated();
504 let local_translated_content_context = modifier_slices.translated_content_context();
505 let local_translated_content_offset = modifier_slices
506 .translated_content_offset()
507 .unwrap_or(layout_state.content_offset);
508 let child_translated_content_context =
509 inherited_translated_content_context || local_translated_content_context;
510
511 let mut render_children = draw_nodes(
512 modifier_slices.draw_commands(),
513 DrawPlacement::Behind,
514 layout_state.size,
515 PrimitivePhase::BeforeChildren,
516 );
517 if let Some(text) = text_node_from_parts(TextNodeParts {
518 node_id,
519 local_bounds,
520 measured_max_width: layout_state
521 .measurement_constraints
522 .max_width
523 .is_finite()
524 .then_some(layout_state.measurement_constraints.max_width),
525 resolved_modifiers: &resolved_modifiers,
526 annotated_text: modifier_slices.annotated_text(),
527 text_style: modifier_slices.text_style(),
528 text_layout_options: modifier_slices.text_layout_options(),
529 modifier_slices: Some(modifier_slices.as_ref()),
530 }) {
531 render_children.push(RenderNode::Primitive(PrimitiveEntry {
532 phase: PrimitivePhase::BeforeChildren,
533 node: PrimitiveNode::Text(Box::new(text)),
534 }));
535 }
536 let child_motion_context_animated = node_motion_context_animated;
537 for child_id in children {
538 let Some(mut child_layer) = build_layer_node_from_applier_internal(
539 applier,
540 child_id,
541 child_motion_context_animated,
542 child_translated_content_context,
543 ) else {
544 continue;
545 };
546 if layout_state.content_offset != Point::default() {
547 child_layer.transform_to_parent =
548 child_layer
549 .transform_to_parent
550 .then(ProjectiveTransform::translation(
551 layout_state.content_offset.x,
552 layout_state.content_offset.y,
553 ));
554 }
555 render_children.push(RenderNode::Layer(Box::new(child_layer)));
556 }
557 render_children.extend(draw_nodes(
558 modifier_slices.draw_commands(),
559 DrawPlacement::Overlay,
560 layout_state.size,
561 PrimitivePhase::AfterChildren,
562 ));
563 let has_hit_targets = hit_test.is_some()
564 || render_children.iter().any(|child| match child {
565 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
566 RenderNode::Primitive(_) => false,
567 });
568
569 let layer = LayerNode {
570 node_id: Some(node_id),
571 local_bounds,
572 transform_to_parent,
573 content_offset: layout_state.content_offset,
574 motion_context_animated: node_motion_context_animated,
575 translated_content_context: local_translated_content_context,
576 translated_content_offset: if local_translated_content_context {
577 local_translated_content_offset
578 } else {
579 Point::default()
580 },
581 graphics_layer,
582 clip_to_bounds,
583 shadow_clip,
584 hit_test,
585 has_hit_targets,
586 isolation,
587 cache_policy,
588 cache_hashes: LayerRasterCacheHashes::default(),
589 cache_hashes_valid: false,
590 children: render_children,
591 };
592 Some(layer)
593}
594
595fn draw_nodes(
596 commands: &[DrawCommand],
597 placement: DrawPlacement,
598 size: Size,
599 phase: PrimitivePhase,
600) -> Vec<RenderNode> {
601 let mut nodes = Vec::new();
602 for command in commands {
603 for primitive in primitives_for_placement(command, placement, size) {
604 nodes.push(RenderNode::Primitive(PrimitiveEntry {
605 phase,
606 node: PrimitiveNode::Draw(DrawPrimitiveNode {
607 primitive,
608 clip: None,
609 }),
610 }));
611 }
612 }
613 nodes
614}
615
616struct TextNodeParts<'a> {
617 node_id: NodeId,
618 local_bounds: Rect,
619 measured_max_width: Option<f32>,
620 resolved_modifiers: &'a ResolvedModifiers,
621 annotated_text: Option<&'a AnnotatedString>,
622 text_style: Option<&'a TextStyle>,
623 text_layout_options: Option<TextLayoutOptions>,
624 modifier_slices: Option<&'a ModifierNodeSlices>,
625}
626
627fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
628 let TextNodeParts {
629 node_id,
630 local_bounds,
631 measured_max_width,
632 resolved_modifiers,
633 annotated_text,
634 text_style,
635 text_layout_options,
636 modifier_slices,
637 } = parts;
638 let value = annotated_text?;
639 let default_text_style = TextStyle::default();
640 let text_style = text_style.cloned().unwrap_or(default_text_style);
641 let options = text_layout_options.unwrap_or_default().normalized();
642 let padding = resolved_modifiers.padding();
643 let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
644 if content_width <= 0.0 {
645 return None;
646 }
647
648 let measure_width =
649 resolve_text_measure_width(content_width, padding, measured_max_width, options);
650 let max_width = Some(measure_width).filter(|width| width.is_finite() && *width > 0.0);
651 let prepared = modifier_slices
652 .and_then(|slices| slices.prepare_text_layout(max_width))
653 .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
654 let visual_style = prepared.visual_style.clone();
655 let measured_draw_width = prepared.metrics.width.max(0.0);
656 let draw_width = if options.overflow == TextOverflow::Visible {
657 measured_draw_width
658 } else {
659 measured_draw_width.min(content_width)
660 };
661 let alignment_offset = resolve_text_horizontal_offset(
662 &text_style,
663 prepared.text.text.as_str(),
664 content_width,
665 prepared.metrics.width,
666 );
667 let rect = Rect {
668 x: padding.left + alignment_offset,
669 y: padding.top,
670 width: draw_width,
671 height: prepared.metrics.height,
672 };
673 let text_bounds = Rect {
674 x: padding.left,
675 y: padding.top,
676 width: content_width,
677 height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
678 };
679 let font_size = visual_style.resolve_font_size(14.0);
680 let expanded_bounds =
681 expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
682 let clip = if options.overflow == TextOverflow::Visible {
683 None
684 } else {
685 Some(pad_clip_rect(expanded_bounds))
686 };
687
688 Some(TextPrimitiveNode {
689 node_id,
690 rect,
691 text: prepared.text,
692 text_style: visual_style,
693 font_size,
694 layout_options: options,
695 clip,
696 })
697}
698
699fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
700 let placement = parent
701 .map(|parent_box| Point {
702 x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
703 y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
704 })
705 .unwrap_or_default();
706 let mut children = Vec::with_capacity(node.children.len());
707 for child in &node.children {
708 children.push(layout_box_to_snapshot(child, Some(node)));
709 }
710 let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
711 let graphics_layer = graphics_layer_with_shaped_clip(
712 base_graphics_layer.clone().unwrap_or_default(),
713 node.node_data.modifier_slices.clip_to_bounds(),
714 node.node_data.modifier_slices.corner_shape(),
715 Rect {
716 x: 0.0,
717 y: 0.0,
718 width: node.rect.width,
719 height: node.rect.height,
720 },
721 );
722 let has_graphics_layer =
723 base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
724
725 BuildNodeSnapshot {
726 node_id: node.node_id,
727 placement,
728 size: Size {
729 width: node.rect.width,
730 height: node.rect.height,
731 },
732 content_offset: node.content_offset,
733 motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
734 translated_content_context: node.node_data.modifier_slices.translated_content_context(),
735 measured_max_width: None,
736 resolved_modifiers: node.node_data.resolved_modifiers,
737 draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
738 click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
739 pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
740 clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
741 annotated_text: node.node_data.modifier_slices.annotated_string(),
742 text_style: node.node_data.modifier_slices.text_style().cloned(),
743 text_layout_options: node.node_data.modifier_slices.text_layout_options(),
744 graphics_layer: has_graphics_layer.then_some(graphics_layer),
745 children,
746 }
747}
748
749fn graphics_layer_with_shaped_clip(
750 mut graphics_layer: GraphicsLayer,
751 clip_to_bounds: bool,
752 corner_shape: Option<RoundedCornerShape>,
753 local_bounds: Rect,
754) -> GraphicsLayer {
755 if !clip_to_bounds {
756 return graphics_layer;
757 }
758
759 let Some(corner_shape) = corner_shape else {
760 return graphics_layer;
761 };
762 let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
763 if radii.top_left <= f32::EPSILON
764 && radii.top_right <= f32::EPSILON
765 && radii.bottom_right <= f32::EPSILON
766 && radii.bottom_left <= f32::EPSILON
767 {
768 return graphics_layer;
769 }
770
771 if let Some(existing) = graphics_layer.render_effect.take() {
772 let rounded_clip = rounded_corner_alpha_mask_effect(
773 local_bounds.width,
774 local_bounds.height,
775 radii,
776 ROUNDED_CLIP_EDGE_FEATHER,
777 );
778 graphics_layer.render_effect = Some(existing.then(rounded_clip));
779 } else {
780 graphics_layer.shape = LayerShape::Rounded(corner_shape);
781 graphics_layer.clip = true;
782 }
783 graphics_layer
784}
785
786fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
787 IsolationReasons {
788 explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
789 shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
790 effect: layer.render_effect.is_some(),
791 backdrop: layer.backdrop_effect.is_some(),
792 group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
793 && layer.alpha < 1.0,
794 blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
795 }
796}
797
798fn pad_clip_rect(rect: Rect) -> Rect {
799 Rect {
800 x: rect.x - TEXT_CLIP_PAD,
801 y: rect.y - TEXT_CLIP_PAD,
802 width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
803 height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
804 }
805}
806
807fn expand_text_bounds_for_baseline_shift(
808 text_bounds: Rect,
809 text_style: &TextStyle,
810 font_size: f32,
811) -> Rect {
812 let baseline_shift_px = text_style
813 .span_style
814 .baseline_shift
815 .filter(|shift| shift.is_specified())
816 .map(|shift| -(shift.0 * font_size))
817 .unwrap_or(0.0);
818 if baseline_shift_px == 0.0 {
819 return text_bounds;
820 }
821
822 if baseline_shift_px < 0.0 {
823 Rect {
824 x: text_bounds.x,
825 y: text_bounds.y + baseline_shift_px,
826 width: text_bounds.width,
827 height: (text_bounds.height - baseline_shift_px).max(0.0),
828 }
829 } else {
830 Rect {
831 x: text_bounds.x,
832 y: text_bounds.y,
833 width: text_bounds.width,
834 height: (text_bounds.height + baseline_shift_px).max(0.0),
835 }
836 }
837}
838
839fn resolve_text_measure_width(
840 content_width: f32,
841 padding: cranpose_ui::EdgeInsets,
842 measured_max_width: Option<f32>,
843 options: TextLayoutOptions,
844) -> f32 {
845 let available = measured_max_width
846 .map(|max_width| (max_width - padding.left - padding.right).max(0.0))
847 .unwrap_or(content_width);
848 if options.soft_wrap || options.max_lines != 1 || options.overflow == TextOverflow::Clip {
849 available.min(content_width)
850 } else {
851 content_width
852 }
853}
854
855fn resolve_text_horizontal_offset(
856 text_style: &TextStyle,
857 text: &str,
858 content_width: f32,
859 measured_width: f32,
860) -> f32 {
861 let remaining = (content_width - measured_width).max(0.0);
862 let paragraph_style = &text_style.paragraph_style;
863 let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
864 match paragraph_style.text_align {
865 TextAlign::Center => remaining * 0.5,
866 TextAlign::End | TextAlign::Right => remaining,
867 TextAlign::Start | TextAlign::Left | TextAlign::Justify => {
868 if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
869 remaining
870 } else {
871 0.0
872 }
873 }
874 TextAlign::Unspecified => {
875 if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
876 remaining
877 } else {
878 0.0
879 }
880 }
881 }
882}
883
884#[cfg(test)]
885mod tests {
886 use std::cell::RefCell;
887 use std::rc::Rc;
888
889 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
890 use cranpose_ui::text::{
891 AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
892 };
893 use cranpose_ui::{
894 Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
895 LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
896 ScrollState, Size, Spacer, Text, TextStyle,
897 };
898 use cranpose_ui_graphics::{Brush, DrawPrimitive, GraphicsLayer, RenderEffect};
899
900 use super::*;
901
902 fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
903 for child in &layer.children {
904 match child {
905 RenderNode::Primitive(primitive) => {
906 let PrimitiveNode::Text(text) = &primitive.node else {
907 continue;
908 };
909 if text.text.text == label {
910 return Some(text.text_style.paragraph_style.text_motion);
911 }
912 }
913 RenderNode::Layer(child_layer) => {
914 if let Some(motion) = find_text_motion(child_layer, label) {
915 return Some(motion);
916 }
917 }
918 }
919 }
920
921 None
922 }
923
924 fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
925 for child in &layer.children {
926 match child {
927 RenderNode::Primitive(primitive) => {
928 let PrimitiveNode::Text(text) = &primitive.node else {
929 continue;
930 };
931 labels.push(text.text.text.clone());
932 }
933 RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
934 }
935 }
936 }
937
938 fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
939 fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
940 for child in &layer.children {
941 match child {
942 RenderNode::Primitive(primitive) => {
943 let PrimitiveNode::Text(text) = &primitive.node else {
944 continue;
945 };
946 if text.text.text == label {
947 let quad = transform.map_rect(text.rect);
948 let top = quad
949 .iter()
950 .map(|point| point[1])
951 .fold(f32::INFINITY, f32::min);
952 return top.is_finite().then_some(top);
953 }
954 }
955 RenderNode::Layer(child_layer) => {
956 let child_transform = child_layer.transform_to_parent.then(transform);
957 if let Some(top) = search(child_layer, label, child_transform) {
958 return Some(top);
959 }
960 }
961 }
962 }
963 None
964 }
965
966 search(layer, label, ProjectiveTransform::identity())
967 }
968
969 fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
970 if layer.node_id == Some(node_id) {
971 return Some(layer);
972 }
973 layer.children.iter().find_map(|child| match child {
974 RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
975 RenderNode::Primitive(_) => None,
976 })
977 }
978
979 fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
980 fn search(
981 layer: &LayerNode,
982 node_id: NodeId,
983 transform: ProjectiveTransform,
984 ) -> Option<Point> {
985 if layer.node_id == Some(node_id) {
986 return Some(transform.map_point(Point::default()));
987 }
988 layer.children.iter().find_map(|child| match child {
989 RenderNode::Layer(child_layer) => search(
990 child_layer,
991 node_id,
992 child_layer.transform_to_parent.then(transform),
993 ),
994 RenderNode::Primitive(_) => None,
995 })
996 }
997
998 search(layer, node_id, ProjectiveTransform::identity())
999 }
1000
1001 fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1002 if layer.translated_content_context {
1003 return Some(layer.translated_content_offset);
1004 }
1005 for child in &layer.children {
1006 if let RenderNode::Layer(child_layer) = child {
1007 if let Some(offset) = find_translated_content_offset(child_layer) {
1008 return Some(offset);
1009 }
1010 }
1011 }
1012 None
1013 }
1014
1015 fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1016 layer
1017 .graphics_layer
1018 .render_effect
1019 .as_ref()
1020 .is_some_and(RenderEffect::contains_runtime_shader)
1021 || layer.children.iter().any(|child| match child {
1022 RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1023 RenderNode::Primitive(_) => false,
1024 })
1025 }
1026
1027 fn build_layer_node_for_test(
1028 snapshot: BuildNodeSnapshot,
1029 scale: f32,
1030 has_external_backdrop_input: bool,
1031 ) -> LayerNode {
1032 let app_context = cranpose_ui::AppContext::new();
1033 app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1034 }
1035
1036 fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1037 let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
1038 vec![DrawPrimitive::Rect {
1039 rect: Rect {
1040 x: 3.0,
1041 y: 4.0,
1042 width: 20.0,
1043 height: 8.0,
1044 },
1045 brush: Brush::solid(Color::WHITE),
1046 }]
1047 }));
1048
1049 let child = BuildNodeSnapshot {
1050 node_id: 2,
1051 placement: Point { x: 11.0, y: 7.0 },
1052 size: Size {
1053 width: 40.0,
1054 height: 20.0,
1055 },
1056 content_offset: Point::default(),
1057 motion_context_animated: false,
1058 translated_content_context: false,
1059 measured_max_width: None,
1060 resolved_modifiers: ResolvedModifiers::default(),
1061 draw_commands: vec![child_command],
1062 click_actions: vec![],
1063 pointer_inputs: vec![],
1064 clip_to_bounds: false,
1065 annotated_text: None,
1066 text_style: None,
1067 text_layout_options: None,
1068 graphics_layer: None,
1069 children: vec![],
1070 };
1071
1072 BuildNodeSnapshot {
1073 node_id: 1,
1074 placement: Point::default(),
1075 size: Size {
1076 width: 80.0,
1077 height: 50.0,
1078 },
1079 content_offset: Point::default(),
1080 motion_context_animated: false,
1081 translated_content_context: false,
1082 measured_max_width: None,
1083 resolved_modifiers: ResolvedModifiers::default(),
1084 draw_commands: vec![],
1085 click_actions: vec![],
1086 pointer_inputs: vec![],
1087 clip_to_bounds: false,
1088 annotated_text: None,
1089 text_style: None,
1090 text_layout_options: None,
1091 graphics_layer: Some(GraphicsLayer {
1092 translation_x: tx,
1093 ..GraphicsLayer::default()
1094 }),
1095 children: vec![child],
1096 }
1097 }
1098
1099 #[test]
1100 fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1101 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1102 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1103
1104 let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1105 panic!("expected child layer");
1106 };
1107 let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1108 panic!("expected child layer");
1109 };
1110 let RenderNode::Primitive(static_draw) = &static_child.children[0] else {
1111 panic!("expected draw primitive");
1112 };
1113 let PrimitiveNode::Draw(static_draw) = &static_draw.node else {
1114 panic!("expected draw primitive");
1115 };
1116 let RenderNode::Primitive(moved_draw) = &moved_child.children[0] else {
1117 panic!("expected draw primitive");
1118 };
1119 let PrimitiveNode::Draw(moved_draw) = &moved_draw.node else {
1120 panic!("expected draw primitive");
1121 };
1122
1123 assert_ne!(
1124 static_graph.transform_to_parent, moved_graph.transform_to_parent,
1125 "parent transform should encode translation"
1126 );
1127 assert_eq!(
1128 static_draw, moved_draw,
1129 "child local primitive geometry must stay stable under parent translation"
1130 );
1131 }
1132
1133 #[test]
1134 fn stored_content_hash_ignores_parent_translation() {
1135 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1136 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1137
1138 assert_eq!(
1139 static_graph.target_content_hash(),
1140 moved_graph.target_content_hash(),
1141 "parent rigid motion must not invalidate the subtree content hash"
1142 );
1143 }
1144
1145 #[test]
1146 fn parent_content_offset_is_encoded_in_child_transform() {
1147 let child = BuildNodeSnapshot {
1148 node_id: 2,
1149 placement: Point { x: 11.0, y: 7.0 },
1150 size: Size {
1151 width: 40.0,
1152 height: 20.0,
1153 },
1154 content_offset: Point::default(),
1155 motion_context_animated: false,
1156 translated_content_context: false,
1157 measured_max_width: None,
1158 resolved_modifiers: ResolvedModifiers::default(),
1159 draw_commands: vec![],
1160 click_actions: vec![],
1161 pointer_inputs: vec![],
1162 clip_to_bounds: false,
1163 annotated_text: None,
1164 text_style: None,
1165 text_layout_options: None,
1166 graphics_layer: None,
1167 children: vec![],
1168 };
1169
1170 let parent = BuildNodeSnapshot {
1171 node_id: 1,
1172 placement: Point::default(),
1173 size: Size {
1174 width: 80.0,
1175 height: 50.0,
1176 },
1177 content_offset: Point { x: 13.0, y: -9.0 },
1178 motion_context_animated: false,
1179 translated_content_context: false,
1180 measured_max_width: None,
1181 resolved_modifiers: ResolvedModifiers::default(),
1182 draw_commands: vec![],
1183 click_actions: vec![],
1184 pointer_inputs: vec![],
1185 clip_to_bounds: false,
1186 annotated_text: None,
1187 text_style: None,
1188 text_layout_options: None,
1189 graphics_layer: None,
1190 children: vec![child],
1191 };
1192
1193 let graph = build_layer_node_for_test(parent, 1.0, false);
1194 let RenderNode::Layer(child) = &graph.children[0] else {
1195 panic!("expected child layer");
1196 };
1197
1198 let top_left = child.transform_to_parent.map_point(Point::default());
1199 assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1200 }
1201
1202 #[test]
1203 fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1204 fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1205 let child_command = DrawCommand::Behind(Rc::new(|_size: Size| {
1206 vec![DrawPrimitive::Rect {
1207 rect: Rect {
1208 x: 3.0,
1209 y: 4.0,
1210 width: 20.0,
1211 height: 8.0,
1212 },
1213 brush: Brush::solid(Color::WHITE),
1214 }]
1215 }));
1216
1217 let child = BuildNodeSnapshot {
1218 node_id: 2,
1219 placement: Point { x: 11.0, y: 7.0 },
1220 size: Size {
1221 width: 40.0,
1222 height: 20.0,
1223 },
1224 content_offset: Point::default(),
1225 motion_context_animated: false,
1226 translated_content_context: false,
1227 measured_max_width: None,
1228 resolved_modifiers: ResolvedModifiers::default(),
1229 draw_commands: vec![child_command],
1230 click_actions: vec![],
1231 pointer_inputs: vec![],
1232 clip_to_bounds: false,
1233 annotated_text: None,
1234 text_style: None,
1235 text_layout_options: None,
1236 graphics_layer: None,
1237 children: vec![],
1238 };
1239
1240 BuildNodeSnapshot {
1241 node_id: 1,
1242 placement: Point::default(),
1243 size: Size {
1244 width: 80.0,
1245 height: 50.0,
1246 },
1247 content_offset: offset,
1248 motion_context_animated,
1249 translated_content_context: true,
1250 measured_max_width: None,
1251 resolved_modifiers: ResolvedModifiers::default(),
1252 draw_commands: vec![],
1253 click_actions: vec![],
1254 pointer_inputs: vec![],
1255 clip_to_bounds: false,
1256 annotated_text: None,
1257 text_style: None,
1258 text_layout_options: None,
1259 graphics_layer: None,
1260 children: vec![child],
1261 }
1262 }
1263
1264 let base = build_layer_node_for_test(
1265 parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
1266 1.0,
1267 false,
1268 );
1269 let moved = build_layer_node_for_test(
1270 parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
1271 1.0,
1272 false,
1273 );
1274 let rested = build_layer_node_for_test(
1275 parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
1276 1.0,
1277 false,
1278 );
1279
1280 let RenderNode::Layer(base_child) = &base.children[0] else {
1281 panic!("expected child layer");
1282 };
1283 let RenderNode::Layer(moved_child) = &moved.children[0] else {
1284 panic!("expected child layer");
1285 };
1286
1287 assert_ne!(
1288 base_child.transform_to_parent.map_point(Point::default()),
1289 moved_child.transform_to_parent.map_point(Point::default()),
1290 "scroll offset still has to move child content visually"
1291 );
1292 assert_eq!(
1293 base_child.target_content_hash(),
1294 moved_child.target_content_hash(),
1295 "child source content identity stays stable when only the parent scroll offset changes"
1296 );
1297 assert_ne!(
1298 base.target_content_hash(),
1299 moved.target_content_hash(),
1300 "a full-surface cache of the scroll viewport must include the scroll offset"
1301 );
1302 assert_ne!(
1303 base.target_content_hash(),
1304 rested.target_content_hash(),
1305 "full-surface cache keys must include active scroll motion policy"
1306 );
1307 }
1308
1309 #[test]
1310 fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
1311 let layer = graphics_layer_with_shaped_clip(
1312 GraphicsLayer::default(),
1313 true,
1314 Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
1315 Rect {
1316 x: 0.0,
1317 y: 0.0,
1318 width: 100.0,
1319 height: 40.0,
1320 },
1321 );
1322
1323 assert!(layer.clip);
1324 assert!(layer.render_effect.is_none());
1325 let LayerShape::Rounded(shape) = layer.shape else {
1326 panic!("rounded clip must be recorded as layer shape");
1327 };
1328 assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
1329 assert!(isolation_reasons(&layer).shape_clip);
1330 }
1331
1332 #[test]
1333 fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
1334 let existing = RenderEffect::blur(3.0);
1335 let layer = graphics_layer_with_shaped_clip(
1336 GraphicsLayer {
1337 render_effect: Some(existing.clone()),
1338 ..GraphicsLayer::default()
1339 },
1340 true,
1341 Some(RoundedCornerShape::uniform(10.0)),
1342 Rect {
1343 x: 0.0,
1344 y: 0.0,
1345 width: 100.0,
1346 height: 40.0,
1347 },
1348 );
1349
1350 let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
1351 panic!("existing effect should chain into rounded clip mask");
1352 };
1353 assert_eq!(*first, existing);
1354 assert!(
1355 matches!(*second, RenderEffect::Shader { .. }),
1356 "rounded mask must be the outer effect"
1357 );
1358 }
1359
1360 #[test]
1361 fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
1362 let mut composition = cranpose_ui::run_test_composition(|| {
1363 cranpose_ui::Box(
1364 Modifier::empty()
1365 .width(100.0)
1366 .height(40.0)
1367 .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
1368 .clip_to_bounds(),
1369 cranpose_ui::BoxSpec::default(),
1370 || {
1371 Text("rounded child", Modifier::empty(), TextStyle::default());
1372 },
1373 );
1374 });
1375
1376 let root = composition.root().expect("rounded clip root");
1377 let handle = composition.runtime_handle();
1378 let mut applier = composition.applier_mut();
1379 applier.set_runtime_handle(handle);
1380 applier
1381 .compute_layout(
1382 root,
1383 Size {
1384 width: 160.0,
1385 height: 100.0,
1386 },
1387 )
1388 .expect("rounded clip layout");
1389 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1390 applier.clear_runtime_handle();
1391
1392 let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
1393 assert!(rounded_layer.graphics_layer.clip);
1394 assert!(matches!(
1395 rounded_layer.graphics_layer.shape,
1396 LayerShape::Rounded(_)
1397 ));
1398 assert!(rounded_layer.graphics_layer.render_effect.is_none());
1399 assert!(rounded_layer.isolation.shape_clip);
1400 assert!(
1401 !graph_has_runtime_shader_effect(&graph.root),
1402 "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
1403 );
1404 }
1405
1406 #[test]
1407 fn update_graph_from_applier_replaces_dirty_child_layer() {
1408 let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1409 Rc::new(RefCell::new(None));
1410 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1411 let state_holder_for_comp = state_holder.clone();
1412 let child_id_holder_for_comp = child_id_holder.clone();
1413
1414 let mut composition = cranpose_ui::run_test_composition(move || {
1415 let label = cranpose_core::useState(|| "before".to_string());
1416 *state_holder_for_comp.borrow_mut() = Some(label);
1417 let child_id_holder_for_content = child_id_holder_for_comp.clone();
1418 cranpose_ui::Box(
1419 Modifier::empty().size_points(240.0, 80.0),
1420 cranpose_ui::BoxSpec::default(),
1421 move || {
1422 let child_id = Text(label, Modifier::empty(), TextStyle::default());
1423 *child_id_holder_for_content.borrow_mut() = Some(child_id);
1424 Text("stable", Modifier::empty(), TextStyle::default());
1425 },
1426 );
1427 });
1428
1429 let root = composition.root().expect("composition root");
1430 let viewport = Size {
1431 width: 240.0,
1432 height: 80.0,
1433 };
1434 let handle = composition.runtime_handle();
1435 let mut applier = composition.applier_mut();
1436 applier.set_runtime_handle(handle);
1437 applier
1438 .compute_layout(root, viewport)
1439 .expect("initial layout");
1440 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1441 let child_id = child_id_holder
1442 .borrow()
1443 .expect("text child id should be captured");
1444 let initial_transform = find_layer_by_node_id(&graph.root, child_id)
1445 .expect("text child layer")
1446 .transform_to_parent;
1447 applier.clear_runtime_handle();
1448 drop(applier);
1449
1450 let label = state_holder
1451 .borrow()
1452 .as_ref()
1453 .copied()
1454 .expect("label state should be captured");
1455 label.set_value("after".to_string());
1456 composition
1457 .process_invalid_scopes()
1458 .expect("text recomposition");
1459
1460 let handle = composition.runtime_handle();
1461 let mut applier = composition.applier_mut();
1462 applier.set_runtime_handle(handle);
1463 applier
1464 .compute_layout(root, viewport)
1465 .expect("updated layout");
1466 let child_id = child_id_holder
1467 .borrow()
1468 .expect("text child id should remain captured");
1469
1470 assert!(
1471 update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
1472 "dirty child should be replaceable from retained applier state"
1473 );
1474 applier.clear_runtime_handle();
1475
1476 let mut labels = Vec::new();
1477 collect_text_labels(&graph.root, &mut labels);
1478 assert!(
1479 labels.iter().any(|label| label == "after"),
1480 "updated graph should contain refreshed child text, got {labels:?}"
1481 );
1482 assert!(
1483 !labels.iter().any(|label| label == "before"),
1484 "updated graph should not retain stale child text, got {labels:?}"
1485 );
1486 assert!(
1487 labels.iter().any(|label| label == "stable"),
1488 "sibling content should remain present, got {labels:?}"
1489 );
1490 assert_eq!(
1491 find_layer_by_node_id(&graph.root, child_id)
1492 .expect("updated text child layer")
1493 .transform_to_parent,
1494 initial_transform,
1495 "draw-only child replacement must preserve the retained parent placement transform"
1496 );
1497 }
1498
1499 #[test]
1500 fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
1501 let mut graph = RenderGraph {
1502 root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
1503 };
1504 let mut applier = MemoryApplier::new();
1505
1506 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
1507
1508 assert_eq!(
1509 report,
1510 GraphUpdateReport {
1511 applied: false,
1512 hit_graph_dirty: true,
1513 },
1514 "dirty child graph updates must not report success when the replacement cannot be rebuilt"
1515 );
1516 }
1517
1518 #[test]
1519 fn update_graph_from_applier_refreshes_scroll_content_offset() {
1520 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1521 let scroll_holder_for_comp = scroll_holder.clone();
1522
1523 let mut composition = cranpose_ui::run_test_composition(move || {
1524 let scroll_state =
1525 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1526 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1527 Column(
1528 Modifier::empty()
1529 .size_points(240.0, 120.0)
1530 .vertical_scroll(scroll_state, false),
1531 ColumnSpec::default(),
1532 || {
1533 Text("scroll top", Modifier::empty(), TextStyle::default());
1534 Spacer(Size {
1535 width: 0.0,
1536 height: 160.0,
1537 });
1538 Text("scroll target", Modifier::empty(), TextStyle::default());
1539 },
1540 );
1541 });
1542
1543 let root = composition.root().expect("composition root");
1544 let viewport = Size {
1545 width: 240.0,
1546 height: 120.0,
1547 };
1548 let handle = composition.runtime_handle();
1549 let mut applier = composition.applier_mut();
1550 applier.set_runtime_handle(handle);
1551 applier
1552 .compute_layout(root, viewport)
1553 .expect("initial scroll layout");
1554 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1555 let initial_target_top =
1556 find_text_top(&graph.root, "scroll target").expect("initial target text");
1557 applier.clear_runtime_handle();
1558 drop(applier);
1559
1560 let scroll_state = scroll_holder
1561 .borrow()
1562 .as_ref()
1563 .cloned()
1564 .expect("scroll state should be captured");
1565 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
1566 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
1567 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
1568 assert!(
1569 !dirty_nodes.is_empty(),
1570 "scroll state invalidation must schedule scoped layout graph update"
1571 );
1572
1573 let handle = composition.runtime_handle();
1574 let mut applier = composition.applier_mut();
1575 applier.set_runtime_handle(handle);
1576 applier
1577 .compute_layout(root, viewport)
1578 .expect("scrolled layout");
1579 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
1580 applier.clear_runtime_handle();
1581
1582 assert!(report.applied, "scroll graph update should apply in place");
1583 let updated_target_top =
1584 find_text_top(&graph.root, "scroll target").expect("updated target text");
1585 assert!(
1586 updated_target_top < initial_target_top - consumed_scroll * 0.75,
1587 "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
1588 );
1589 }
1590
1591 #[test]
1592 fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
1593 let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1594 Rc::new(RefCell::new(None));
1595 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1596 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1597 let label_holder_for_comp = label_holder.clone();
1598 let scroll_holder_for_comp = scroll_holder.clone();
1599 let child_id_holder_for_comp = child_id_holder.clone();
1600
1601 let mut composition = cranpose_ui::run_test_composition(move || {
1602 let label = cranpose_core::useState(|| "scrolled child before".to_string());
1603 let scroll_state =
1604 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1605 *label_holder_for_comp.borrow_mut() = Some(label);
1606 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1607 let child_id_holder_for_content = child_id_holder_for_comp.clone();
1608 Column(
1609 Modifier::empty()
1610 .size_points(260.0, 90.0)
1611 .vertical_scroll(scroll_state, false),
1612 ColumnSpec::default(),
1613 move || {
1614 Spacer(Size {
1615 width: 0.0,
1616 height: 24.0,
1617 });
1618 let child_id = Text(label, Modifier::empty(), TextStyle::default());
1619 *child_id_holder_for_content.borrow_mut() = Some(child_id);
1620 Spacer(Size {
1621 width: 0.0,
1622 height: 220.0,
1623 });
1624 },
1625 );
1626 });
1627
1628 let root = composition.root().expect("composition root");
1629 let viewport = Size {
1630 width: 260.0,
1631 height: 90.0,
1632 };
1633 let handle = composition.runtime_handle();
1634 let mut applier = composition.applier_mut();
1635 applier.set_runtime_handle(handle);
1636 applier
1637 .compute_layout(root, viewport)
1638 .expect("initial layout");
1639 applier.clear_runtime_handle();
1640 drop(applier);
1641
1642 let scroll_state = scroll_holder
1643 .borrow()
1644 .as_ref()
1645 .cloned()
1646 .expect("scroll state should be captured");
1647 assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
1648
1649 let handle = composition.runtime_handle();
1650 let mut applier = composition.applier_mut();
1651 applier.set_runtime_handle(handle);
1652 applier
1653 .compute_layout(root, viewport)
1654 .expect("scrolled layout");
1655 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
1656 let child_id = child_id_holder
1657 .borrow()
1658 .expect("text child id should be captured");
1659 let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
1660 .expect("scrolled child layer")
1661 .transform_to_parent;
1662 applier.clear_runtime_handle();
1663 drop(applier);
1664
1665 let label = label_holder
1666 .borrow()
1667 .as_ref()
1668 .copied()
1669 .expect("label state should be captured");
1670 label.set_value("scrolled child after".to_string());
1671 composition
1672 .process_invalid_scopes()
1673 .expect("text recomposition");
1674
1675 let handle = composition.runtime_handle();
1676 let mut applier = composition.applier_mut();
1677 applier.set_runtime_handle(handle);
1678 applier
1679 .compute_layout(root, viewport)
1680 .expect("updated scrolled layout");
1681 let child_id = child_id_holder
1682 .borrow()
1683 .expect("text child id should remain captured");
1684 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
1685 applier.clear_runtime_handle();
1686
1687 assert!(report.applied, "dirty child graph update should apply");
1688 let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
1689 assert_eq!(
1690 updated.transform_to_parent, scrolled_transform,
1691 "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
1692 );
1693 let mut labels = Vec::new();
1694 collect_text_labels(&graph.root, &mut labels);
1695 assert!(
1696 labels.iter().any(|label| label == "scrolled child after"),
1697 "updated graph should contain refreshed text, got {labels:?}"
1698 );
1699 }
1700
1701 #[test]
1702 fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
1703 let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
1704 Rc::new(RefCell::new(None));
1705 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
1706 let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1707 let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1708 let alpha_holder_for_comp = alpha_holder.clone();
1709 let scroll_holder_for_comp = scroll_holder.clone();
1710 let underlay_id_holder_for_comp = underlay_id_holder.clone();
1711 let overlay_id_holder_for_comp = overlay_id_holder.clone();
1712
1713 let mut composition = cranpose_ui::run_test_composition(move || {
1714 let alpha = cranpose_core::useState(|| 1.0f32);
1715 let scroll_state =
1716 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
1717 *alpha_holder_for_comp.borrow_mut() = Some(alpha);
1718 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
1719 let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
1720 let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
1721 Column(
1722 Modifier::empty()
1723 .size_points(260.0, 120.0)
1724 .vertical_scroll(scroll_state, false),
1725 ColumnSpec::default(),
1726 move || {
1727 Spacer(Size {
1728 width: 0.0,
1729 height: 180.0,
1730 });
1731 cranpose_ui::Box(
1732 Modifier::empty().size_points(188.0, 88.0),
1733 cranpose_ui::BoxSpec::default(),
1734 {
1735 let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
1736 let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
1737 move || {
1738 let underlay_id = cranpose_ui::Box(
1739 Modifier::empty().size_points(188.0, 88.0),
1740 cranpose_ui::BoxSpec::default(),
1741 || {
1742 Text(
1743 "UNDERLAY CONTENT",
1744 Modifier::empty().absolute_offset(12.0, 8.0),
1745 TextStyle::default(),
1746 );
1747 },
1748 );
1749 *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
1750 let overlay_id = cranpose_ui::Box(
1751 Modifier::empty().size_points(188.0, 88.0).graphics_layer(
1752 move || GraphicsLayer {
1753 alpha: alpha.get(),
1754 ..GraphicsLayer::default()
1755 },
1756 ),
1757 cranpose_ui::BoxSpec::default(),
1758 || {
1759 Text(
1760 "TOP LAYER",
1761 Modifier::empty().absolute_offset(74.0, 39.6),
1762 TextStyle::default(),
1763 );
1764 },
1765 );
1766 *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
1767 }
1768 },
1769 );
1770 Spacer(Size {
1771 width: 0.0,
1772 height: 280.0,
1773 });
1774 },
1775 );
1776 });
1777
1778 let root = composition.root().expect("composition root");
1779 let viewport = Size {
1780 width: 260.0,
1781 height: 120.0,
1782 };
1783 let handle = composition.runtime_handle();
1784 let mut applier = composition.applier_mut();
1785 applier.set_runtime_handle(handle);
1786 applier
1787 .compute_layout(root, viewport)
1788 .expect("initial layout");
1789 applier.clear_runtime_handle();
1790 drop(applier);
1791
1792 let scroll_state = scroll_holder
1793 .borrow()
1794 .as_ref()
1795 .cloned()
1796 .expect("scroll state should be captured");
1797 assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
1798
1799 let handle = composition.runtime_handle();
1800 let mut applier = composition.applier_mut();
1801 applier.set_runtime_handle(handle);
1802 applier
1803 .compute_layout(root, viewport)
1804 .expect("scrolled layout");
1805 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
1806 applier.clear_runtime_handle();
1807 drop(applier);
1808
1809 let underlay_id = underlay_id_holder
1810 .borrow()
1811 .expect("underlay id should be captured");
1812 let overlay_id = overlay_id_holder
1813 .borrow()
1814 .expect("overlay id should be captured");
1815 let scrolled_underlay_origin =
1816 find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
1817 let scrolled_overlay_origin =
1818 find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
1819 assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
1820
1821 let alpha = alpha_holder
1822 .borrow()
1823 .as_ref()
1824 .copied()
1825 .expect("alpha state should be captured");
1826 alpha.set_value(0.35);
1827
1828 let handle = composition.runtime_handle();
1829 let mut applier = composition.applier_mut();
1830 applier.set_runtime_handle(handle);
1831 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
1832 applier.clear_runtime_handle();
1833
1834 assert!(report.applied, "dirty overlay graph update should apply");
1835 let updated_underlay_origin =
1836 find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
1837 let updated_overlay_origin =
1838 find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
1839 assert_eq!(
1840 updated_underlay_origin, scrolled_underlay_origin,
1841 "stable underlay must keep its scrolled origin"
1842 );
1843 assert_eq!(
1844 updated_overlay_origin, updated_underlay_origin,
1845 "dirty overlay graphics layer must stay aligned with its stable underlay"
1846 );
1847 }
1848
1849 #[test]
1850 fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
1851 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
1852 Rc::new(RefCell::new(None));
1853 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1854 let offset_holder_for_comp = offset_holder.clone();
1855 let node_id_holder_for_comp = node_id_holder.clone();
1856
1857 let mut composition = cranpose_ui::run_test_composition(move || {
1858 let offset = cranpose_core::useState(|| 0.0f32);
1859 *offset_holder_for_comp.borrow_mut() = Some(offset);
1860 let node_id = cranpose_ui::Box(
1861 Modifier::empty()
1862 .size_points(40.0, 20.0)
1863 .graphics_layer(move || GraphicsLayer {
1864 translation_x: offset.get(),
1865 ..GraphicsLayer::default()
1866 }),
1867 cranpose_ui::BoxSpec::default(),
1868 || {},
1869 );
1870 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
1871 });
1872
1873 let root = composition.root().expect("composition root");
1874 let viewport = Size {
1875 width: 120.0,
1876 height: 80.0,
1877 };
1878 let handle = composition.runtime_handle();
1879 let mut applier = composition.applier_mut();
1880 applier.set_runtime_handle(handle);
1881 applier
1882 .compute_layout(root, viewport)
1883 .expect("initial layout");
1884 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1885 let node_id = node_id_holder
1886 .borrow()
1887 .expect("graphics layer node id should be captured");
1888 let initial_origin = find_layer_by_node_id(&graph.root, node_id)
1889 .expect("initial graphics layer")
1890 .transform_to_parent
1891 .map_point(Point::default());
1892 applier.clear_runtime_handle();
1893 drop(applier);
1894
1895 let offset = offset_holder
1896 .borrow()
1897 .as_ref()
1898 .copied()
1899 .expect("offset state should be captured");
1900 offset.set_value(32.0);
1901
1902 let handle = composition.runtime_handle();
1903 let mut applier = composition.applier_mut();
1904 applier.set_runtime_handle(handle);
1905 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
1906 assert!(
1907 report.applied,
1908 "dirty graphics layer should be replaceable from retained applier state"
1909 );
1910 assert!(
1911 !report.hit_graph_dirty,
1912 "a moved visual-only layer should not force hit graph refresh"
1913 );
1914 applier.clear_runtime_handle();
1915
1916 let updated_origin = find_layer_by_node_id(&graph.root, node_id)
1917 .expect("updated graphics layer")
1918 .transform_to_parent
1919 .map_point(Point::default());
1920 assert!(
1921 (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
1922 "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
1923 );
1924 }
1925
1926 #[test]
1927 fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
1928 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
1929 Rc::new(RefCell::new(None));
1930 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1931 let offset_holder_for_comp = offset_holder.clone();
1932 let node_id_holder_for_comp = node_id_holder.clone();
1933
1934 let mut composition = cranpose_ui::run_test_composition(move || {
1935 let offset = cranpose_core::useState(|| 0.0f32);
1936 *offset_holder_for_comp.borrow_mut() = Some(offset);
1937 let node_id = cranpose_ui::Box(
1938 Modifier::empty()
1939 .size_points(40.0, 20.0)
1940 .graphics_layer(move || GraphicsLayer {
1941 translation_x: offset.get(),
1942 ..GraphicsLayer::default()
1943 })
1944 .clickable(|_| {}),
1945 cranpose_ui::BoxSpec::default(),
1946 || {},
1947 );
1948 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
1949 });
1950
1951 let root = composition.root().expect("composition root");
1952 let viewport = Size {
1953 width: 120.0,
1954 height: 80.0,
1955 };
1956 let handle = composition.runtime_handle();
1957 let mut applier = composition.applier_mut();
1958 applier.set_runtime_handle(handle);
1959 applier
1960 .compute_layout(root, viewport)
1961 .expect("initial layout");
1962 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1963 let node_id = node_id_holder
1964 .borrow()
1965 .expect("graphics layer node id should be captured");
1966 applier.clear_runtime_handle();
1967 drop(applier);
1968
1969 let offset = offset_holder
1970 .borrow()
1971 .as_ref()
1972 .copied()
1973 .expect("offset state should be captured");
1974 offset.set_value(32.0);
1975
1976 let handle = composition.runtime_handle();
1977 let mut applier = composition.applier_mut();
1978 applier.set_runtime_handle(handle);
1979 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
1980 applier.clear_runtime_handle();
1981
1982 assert!(
1983 report.applied,
1984 "dirty clickable graphics layer should be replaceable from retained applier state"
1985 );
1986 assert!(
1987 report.hit_graph_dirty,
1988 "moved clickable layers must refresh hit geometry"
1989 );
1990 }
1991
1992 #[test]
1993 fn overlay_draw_commands_are_tagged_after_children() {
1994 let child = BuildNodeSnapshot {
1995 node_id: 2,
1996 placement: Point { x: 4.0, y: 5.0 },
1997 size: Size {
1998 width: 20.0,
1999 height: 10.0,
2000 },
2001 content_offset: Point::default(),
2002 motion_context_animated: false,
2003 translated_content_context: false,
2004 measured_max_width: None,
2005 resolved_modifiers: ResolvedModifiers::default(),
2006 draw_commands: vec![],
2007 click_actions: vec![],
2008 pointer_inputs: vec![],
2009 clip_to_bounds: false,
2010 annotated_text: None,
2011 text_style: None,
2012 text_layout_options: None,
2013 graphics_layer: None,
2014 children: vec![],
2015 };
2016 let behind = DrawCommand::Behind(Rc::new(|_size: Size| {
2017 vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2018 rect: Rect {
2019 x: 1.0,
2020 y: 2.0,
2021 width: 8.0,
2022 height: 6.0,
2023 },
2024 brush: Brush::solid(Color::WHITE),
2025 }]
2026 }));
2027 let overlay = DrawCommand::Overlay(Rc::new(|_size: Size| {
2028 vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2029 rect: Rect {
2030 x: 3.0,
2031 y: 1.0,
2032 width: 5.0,
2033 height: 4.0,
2034 },
2035 brush: Brush::solid(Color::BLACK),
2036 }]
2037 }));
2038
2039 let parent = BuildNodeSnapshot {
2040 node_id: 1,
2041 placement: Point::default(),
2042 size: Size {
2043 width: 80.0,
2044 height: 50.0,
2045 },
2046 content_offset: Point::default(),
2047 motion_context_animated: false,
2048 translated_content_context: false,
2049 measured_max_width: None,
2050 resolved_modifiers: ResolvedModifiers::default(),
2051 draw_commands: vec![behind, overlay],
2052 click_actions: vec![],
2053 pointer_inputs: vec![],
2054 clip_to_bounds: false,
2055 annotated_text: None,
2056 text_style: None,
2057 text_layout_options: None,
2058 graphics_layer: None,
2059 children: vec![child],
2060 };
2061
2062 let graph = build_layer_node_for_test(parent, 1.0, false);
2063 let RenderNode::Primitive(behind) = &graph.children[0] else {
2064 panic!("expected before-children primitive");
2065 };
2066 let RenderNode::Layer(_) = &graph.children[1] else {
2067 panic!("expected child layer");
2068 };
2069 let RenderNode::Primitive(overlay) = &graph.children[2] else {
2070 panic!("expected after-children primitive");
2071 };
2072
2073 assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
2074 assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
2075 }
2076
2077 #[test]
2078 fn stored_content_hash_changes_when_child_transform_changes() {
2079 let child = BuildNodeSnapshot {
2080 node_id: 2,
2081 placement: Point { x: 4.0, y: 5.0 },
2082 size: Size {
2083 width: 20.0,
2084 height: 10.0,
2085 },
2086 content_offset: Point::default(),
2087 motion_context_animated: false,
2088 translated_content_context: false,
2089 measured_max_width: None,
2090 resolved_modifiers: ResolvedModifiers::default(),
2091 draw_commands: vec![],
2092 click_actions: vec![],
2093 pointer_inputs: vec![],
2094 clip_to_bounds: false,
2095 annotated_text: None,
2096 text_style: None,
2097 text_layout_options: None,
2098 graphics_layer: None,
2099 children: vec![],
2100 };
2101 let mut moved_child = child.clone();
2102 moved_child.placement.x += 7.0;
2103
2104 let parent = BuildNodeSnapshot {
2105 node_id: 1,
2106 placement: Point::default(),
2107 size: Size {
2108 width: 80.0,
2109 height: 50.0,
2110 },
2111 content_offset: Point::default(),
2112 motion_context_animated: false,
2113 translated_content_context: false,
2114 measured_max_width: None,
2115 resolved_modifiers: ResolvedModifiers::default(),
2116 draw_commands: vec![],
2117 click_actions: vec![],
2118 pointer_inputs: vec![],
2119 clip_to_bounds: false,
2120 annotated_text: None,
2121 text_style: None,
2122 text_layout_options: None,
2123 graphics_layer: None,
2124 children: vec![child],
2125 };
2126 let moved_parent = BuildNodeSnapshot {
2127 children: vec![moved_child],
2128 ..parent.clone()
2129 };
2130
2131 let static_graph = build_layer_node_for_test(parent, 1.0, false);
2132 let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
2133
2134 assert_ne!(
2135 static_graph.target_content_hash(),
2136 moved_graph.target_content_hash(),
2137 "moving a child within the parent must invalidate the parent subtree hash"
2138 );
2139 }
2140
2141 #[test]
2142 fn stored_effect_hash_tracks_local_effect_only() {
2143 let base = BuildNodeSnapshot {
2144 node_id: 1,
2145 placement: Point::default(),
2146 size: Size {
2147 width: 80.0,
2148 height: 50.0,
2149 },
2150 content_offset: Point::default(),
2151 motion_context_animated: false,
2152 translated_content_context: false,
2153 measured_max_width: None,
2154 resolved_modifiers: ResolvedModifiers::default(),
2155 draw_commands: vec![],
2156 click_actions: vec![],
2157 pointer_inputs: vec![],
2158 clip_to_bounds: false,
2159 annotated_text: None,
2160 text_style: None,
2161 text_layout_options: None,
2162 graphics_layer: None,
2163 children: vec![],
2164 };
2165 let mut effected = base.clone();
2166 effected.graphics_layer = Some(GraphicsLayer {
2167 render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
2168 ..GraphicsLayer::default()
2169 });
2170
2171 let base_graph = build_layer_node_for_test(base, 1.0, false);
2172 let effected_graph = build_layer_node_for_test(effected, 1.0, false);
2173
2174 assert_eq!(
2175 base_graph.target_content_hash(),
2176 effected_graph.target_content_hash(),
2177 "post-processing effect parameters belong to the effect hash, not the content hash"
2178 );
2179 assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
2180 }
2181
2182 #[test]
2183 fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
2184 let mut text_style = TextStyle::default();
2185 text_style.paragraph_style.text_align = TextAlign::Start;
2186 text_style.paragraph_style.text_direction = TextDirection::Rtl;
2187 text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
2188
2189 let snapshot = BuildNodeSnapshot {
2190 node_id: 1,
2191 placement: Point::default(),
2192 size: Size {
2193 width: 180.0,
2194 height: 48.0,
2195 },
2196 content_offset: Point::default(),
2197 motion_context_animated: false,
2198 translated_content_context: false,
2199 measured_max_width: Some(180.0),
2200 resolved_modifiers: ResolvedModifiers::default(),
2201 draw_commands: vec![],
2202 click_actions: vec![],
2203 pointer_inputs: vec![],
2204 clip_to_bounds: false,
2205 annotated_text: Some(AnnotatedString::from("rtl")),
2206 text_style: Some(text_style),
2207 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2208 overflow: cranpose_ui::TextOverflow::Clip,
2209 ..Default::default()
2210 }),
2211 graphics_layer: None,
2212 children: vec![],
2213 };
2214
2215 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2216 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2217 panic!("expected text primitive");
2218 };
2219 let PrimitiveNode::Text(text) = &text_primitive.node else {
2220 panic!("expected text primitive");
2221 };
2222 let clip = text
2223 .clip
2224 .expect("clipped overflow should produce a clip rect");
2225
2226 assert!(
2227 text.rect.x > 0.0,
2228 "RTL start alignment should shift the text rect within the available width"
2229 );
2230 assert!(
2231 clip.y < text.rect.y,
2232 "baseline shift must expand the clip upward so superscript glyphs are preserved"
2233 );
2234 assert!(
2235 clip.intersect(text.rect).is_some(),
2236 "the clip rect must intersect the shifted text draw rect"
2237 );
2238 }
2239
2240 #[test]
2241 fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
2242 let snapshot = BuildNodeSnapshot {
2243 node_id: 1,
2244 placement: Point::default(),
2245 size: Size {
2246 width: 320.0,
2247 height: 48.0,
2248 },
2249 content_offset: Point::default(),
2250 motion_context_animated: false,
2251 translated_content_context: false,
2252 measured_max_width: Some(320.0),
2253 resolved_modifiers: ResolvedModifiers::default(),
2254 draw_commands: vec![],
2255 click_actions: vec![],
2256 pointer_inputs: vec![],
2257 clip_to_bounds: false,
2258 annotated_text: Some(AnnotatedString::from("short")),
2259 text_style: Some(TextStyle::default()),
2260 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2261 overflow: cranpose_ui::TextOverflow::Clip,
2262 ..Default::default()
2263 }),
2264 graphics_layer: None,
2265 children: vec![],
2266 };
2267
2268 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2269 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2270 panic!("expected text primitive");
2271 };
2272 let PrimitiveNode::Text(text) = &text_primitive.node else {
2273 panic!("expected text primitive");
2274 };
2275 let clip = text.clip.expect("clipped text should keep a clip rect");
2276
2277 assert!(
2278 text.rect.width < 320.0,
2279 "text raster bounds should track measured glyph width instead of full content width"
2280 );
2281 assert_eq!(
2282 clip.width, 322.0,
2283 "text clip should still preserve the full content box plus clip padding"
2284 );
2285 }
2286
2287 #[test]
2288 fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
2289 let child = BuildNodeSnapshot {
2290 node_id: 2,
2291 placement: Point { x: 11.0, y: 7.0 },
2292 size: Size {
2293 width: 120.0,
2294 height: 32.0,
2295 },
2296 content_offset: Point::default(),
2297 motion_context_animated: false,
2298 translated_content_context: false,
2299 measured_max_width: Some(120.0),
2300 resolved_modifiers: ResolvedModifiers::default(),
2301 draw_commands: vec![],
2302 click_actions: vec![],
2303 pointer_inputs: vec![],
2304 clip_to_bounds: false,
2305 annotated_text: Some(AnnotatedString::from("scrolling")),
2306 text_style: Some(TextStyle::default()),
2307 text_layout_options: None,
2308 graphics_layer: None,
2309 children: vec![],
2310 };
2311 let parent = BuildNodeSnapshot {
2312 node_id: 1,
2313 placement: Point::default(),
2314 size: Size {
2315 width: 160.0,
2316 height: 64.0,
2317 },
2318 content_offset: Point { x: 0.0, y: -18.5 },
2319 motion_context_animated: false,
2320 translated_content_context: true,
2321 measured_max_width: None,
2322 resolved_modifiers: ResolvedModifiers::default(),
2323 draw_commands: vec![],
2324 click_actions: vec![],
2325 pointer_inputs: vec![],
2326 clip_to_bounds: false,
2327 annotated_text: None,
2328 text_style: None,
2329 text_layout_options: None,
2330 graphics_layer: None,
2331 children: vec![child],
2332 };
2333
2334 let graph = build_layer_node_for_test(parent, 1.0, false);
2335 let RenderNode::Layer(child_layer) = &graph.children[0] else {
2336 panic!("expected child layer");
2337 };
2338 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2339 panic!("expected text primitive");
2340 };
2341 let PrimitiveNode::Text(text) = &text_primitive.node else {
2342 panic!("expected text primitive");
2343 };
2344
2345 assert_eq!(text.text_style.paragraph_style.text_motion, None);
2346 assert!(!child_layer.motion_context_animated);
2347 }
2348
2349 #[test]
2350 fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
2351 let child = BuildNodeSnapshot {
2352 node_id: 2,
2353 placement: Point { x: 11.0, y: 7.0 },
2354 size: Size {
2355 width: 120.0,
2356 height: 32.0,
2357 },
2358 content_offset: Point::default(),
2359 motion_context_animated: false,
2360 translated_content_context: false,
2361 measured_max_width: Some(120.0),
2362 resolved_modifiers: ResolvedModifiers::default(),
2363 draw_commands: vec![],
2364 click_actions: vec![],
2365 pointer_inputs: vec![],
2366 clip_to_bounds: false,
2367 annotated_text: Some(AnnotatedString::from("scrolling")),
2368 text_style: Some(TextStyle::default()),
2369 text_layout_options: None,
2370 graphics_layer: None,
2371 children: vec![],
2372 };
2373 let parent = BuildNodeSnapshot {
2374 node_id: 1,
2375 placement: Point::default(),
2376 size: Size {
2377 width: 160.0,
2378 height: 64.0,
2379 },
2380 content_offset: Point { x: 0.0, y: -18.0 },
2381 motion_context_animated: false,
2382 translated_content_context: false,
2383 measured_max_width: None,
2384 resolved_modifiers: ResolvedModifiers::default(),
2385 draw_commands: vec![],
2386 click_actions: vec![],
2387 pointer_inputs: vec![],
2388 clip_to_bounds: false,
2389 annotated_text: None,
2390 text_style: None,
2391 text_layout_options: None,
2392 graphics_layer: None,
2393 children: vec![child],
2394 };
2395
2396 let graph = build_layer_node_for_test(parent, 1.0, false);
2397 let RenderNode::Layer(child_layer) = &graph.children[0] else {
2398 panic!("expected child layer");
2399 };
2400 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2401 panic!("expected text primitive");
2402 };
2403 let PrimitiveNode::Text(text) = &text_primitive.node else {
2404 panic!("expected text primitive");
2405 };
2406
2407 assert_eq!(
2408 text.text_style.paragraph_style.text_motion, None,
2409 "content_offset alone must not force text onto the translated-content motion path"
2410 );
2411 assert!(!child_layer.motion_context_animated);
2412 }
2413
2414 #[test]
2415 fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
2416 let child = BuildNodeSnapshot {
2417 node_id: 2,
2418 placement: Point { x: 11.0, y: 7.0 },
2419 size: Size {
2420 width: 120.0,
2421 height: 32.0,
2422 },
2423 content_offset: Point::default(),
2424 motion_context_animated: false,
2425 translated_content_context: false,
2426 measured_max_width: Some(120.0),
2427 resolved_modifiers: ResolvedModifiers::default(),
2428 draw_commands: vec![],
2429 click_actions: vec![],
2430 pointer_inputs: vec![],
2431 clip_to_bounds: false,
2432 annotated_text: Some(AnnotatedString::from("shadow")),
2433 text_style: Some(TextStyle::from_span_style(SpanStyle {
2434 shadow: Some(cranpose_ui::text::Shadow {
2435 color: Color::BLACK,
2436 offset: Point::new(1.0, 2.0),
2437 blur_radius: 3.0,
2438 }),
2439 ..SpanStyle::default()
2440 })),
2441 text_layout_options: None,
2442 graphics_layer: None,
2443 children: vec![],
2444 };
2445 let parent = BuildNodeSnapshot {
2446 node_id: 1,
2447 placement: Point::default(),
2448 size: Size {
2449 width: 160.0,
2450 height: 64.0,
2451 },
2452 content_offset: Point { x: 0.0, y: -18.5 },
2453 motion_context_animated: false,
2454 translated_content_context: true,
2455 measured_max_width: None,
2456 resolved_modifiers: ResolvedModifiers::default(),
2457 draw_commands: vec![],
2458 click_actions: vec![],
2459 pointer_inputs: vec![],
2460 clip_to_bounds: false,
2461 annotated_text: None,
2462 text_style: None,
2463 text_layout_options: None,
2464 graphics_layer: None,
2465 children: vec![child],
2466 };
2467
2468 let graph = build_layer_node_for_test(parent, 1.0, false);
2469 let RenderNode::Layer(child_layer) = &graph.children[0] else {
2470 panic!("expected child layer");
2471 };
2472 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2473 panic!("expected text primitive");
2474 };
2475 let PrimitiveNode::Text(text) = &text_primitive.node else {
2476 panic!("expected text primitive");
2477 };
2478
2479 assert_eq!(text.text_style.paragraph_style.text_motion, None);
2480 }
2481
2482 #[test]
2483 fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
2484 let child = BuildNodeSnapshot {
2485 node_id: 2,
2486 placement: Point { x: 11.0, y: 7.0 },
2487 size: Size {
2488 width: 120.0,
2489 height: 32.0,
2490 },
2491 content_offset: Point::default(),
2492 motion_context_animated: false,
2493 translated_content_context: false,
2494 measured_max_width: Some(120.0),
2495 resolved_modifiers: ResolvedModifiers::default(),
2496 draw_commands: vec![],
2497 click_actions: vec![],
2498 pointer_inputs: vec![],
2499 clip_to_bounds: false,
2500 annotated_text: Some(AnnotatedString::from("lazy")),
2501 text_style: Some(TextStyle::default()),
2502 text_layout_options: None,
2503 graphics_layer: None,
2504 children: vec![],
2505 };
2506 let parent = BuildNodeSnapshot {
2507 node_id: 1,
2508 placement: Point::default(),
2509 size: Size {
2510 width: 160.0,
2511 height: 64.0,
2512 },
2513 content_offset: Point::default(),
2514 motion_context_animated: true,
2515 translated_content_context: false,
2516 measured_max_width: None,
2517 resolved_modifiers: ResolvedModifiers::default(),
2518 draw_commands: vec![],
2519 click_actions: vec![],
2520 pointer_inputs: vec![],
2521 clip_to_bounds: false,
2522 annotated_text: None,
2523 text_style: None,
2524 text_layout_options: None,
2525 graphics_layer: None,
2526 children: vec![child],
2527 };
2528
2529 let graph = build_layer_node_for_test(parent, 1.0, false);
2530 let RenderNode::Layer(child_layer) = &graph.children[0] else {
2531 panic!("expected child layer");
2532 };
2533 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2534 panic!("expected text primitive");
2535 };
2536 let PrimitiveNode::Text(text) = &text_primitive.node else {
2537 panic!("expected text primitive");
2538 };
2539
2540 assert_eq!(text.text_style.paragraph_style.text_motion, None);
2541 assert!(graph.motion_context_animated);
2542 assert!(child_layer.motion_context_animated);
2543 }
2544
2545 #[test]
2546 fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
2547 let mut composition = cranpose_ui::run_test_composition(|| {
2548 let list_state = remember_lazy_list_state();
2549 LazyColumn(
2550 Modifier::empty(),
2551 list_state,
2552 LazyColumnSpec::default(),
2553 |scope| {
2554 scope.item(Some(0), None, || {
2555 Text("LazyMotion", Modifier::empty(), TextStyle::default());
2556 });
2557 },
2558 );
2559 });
2560
2561 let root = composition.root().expect("lazy column root");
2562 let handle = composition.runtime_handle();
2563 let mut applier = composition.applier_mut();
2564 applier.set_runtime_handle(handle);
2565 let _ = applier
2566 .compute_layout(
2567 root,
2568 Size {
2569 width: 240.0,
2570 height: 240.0,
2571 },
2572 )
2573 .expect("lazy column layout");
2574 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2575 applier.clear_runtime_handle();
2576
2577 assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
2578 }
2579
2580 #[test]
2581 fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
2582 use std::cell::RefCell;
2583 use std::rc::Rc;
2584
2585 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
2586 let state_holder_for_comp = state_holder.clone();
2587 let mut composition = cranpose_ui::run_test_composition(move || {
2588 let list_state = remember_lazy_list_state();
2589 *state_holder_for_comp.borrow_mut() = Some(list_state);
2590 LazyColumn(
2591 Modifier::empty().height(120.0),
2592 list_state,
2593 LazyColumnSpec::default(),
2594 |scope| {
2595 scope.items(
2596 8,
2597 None::<fn(usize) -> u64>,
2598 None::<fn(usize) -> u64>,
2599 |index| {
2600 Text(
2601 format!("LazyMotion {index}"),
2602 Modifier::empty().padding(4.0),
2603 TextStyle::default(),
2604 );
2605 },
2606 );
2607 },
2608 );
2609 });
2610
2611 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
2612 list_state.scroll_to_item(3, 0.0);
2613
2614 let root = composition.root().expect("lazy column root");
2615 let handle = composition.runtime_handle();
2616 let mut applier = composition.applier_mut();
2617 applier.set_runtime_handle(handle);
2618 let _ = applier
2619 .compute_layout(
2620 root,
2621 Size {
2622 width: 240.0,
2623 height: 240.0,
2624 },
2625 )
2626 .expect("lazy column layout");
2627 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2628 let active_children = applier
2629 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
2630 .expect("lazy column should be subcompose");
2631 let child_debug: Vec<String> = active_children
2632 .iter()
2633 .map(|&child_id| {
2634 if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
2635 format!(
2636 "layout#{child_id} placed={} text={:?} children={:?}",
2637 node.layout_state().is_placed,
2638 node.modifier_slices_snapshot()
2639 .text_content()
2640 .map(str::to_string),
2641 node.children.clone()
2642 )
2643 }) {
2644 summary
2645 } else if let Ok(summary) =
2646 applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
2647 format!(
2648 "subcompose#{child_id} placed={} active_children={:?}",
2649 node.layout_state().is_placed,
2650 node.active_children()
2651 )
2652 })
2653 {
2654 summary
2655 } else {
2656 format!("missing#{child_id}")
2657 }
2658 })
2659 .collect();
2660 applier.clear_runtime_handle();
2661
2662 let first_index = list_state.first_visible_item_index();
2663 assert!(
2664 first_index > 0,
2665 "lazy list should move away from origin before graph building, observed first_index={first_index}"
2666 );
2667 let mut labels = Vec::new();
2668 collect_text_labels(&graph.root, &mut labels);
2669 assert_eq!(
2670 find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
2671 Some(None),
2672 "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
2673 labels,
2674 active_children,
2675 child_debug
2676 );
2677 }
2678
2679 #[test]
2680 fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
2681 use std::cell::RefCell;
2682 use std::rc::Rc;
2683
2684 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
2685 let state_holder_for_comp = state_holder.clone();
2686 let mut composition = cranpose_ui::run_test_composition(move || {
2687 let list_state = remember_lazy_list_state();
2688 *state_holder_for_comp.borrow_mut() = Some(list_state);
2689 let mut spec =
2690 LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
2691 spec.beyond_bounds_item_count = 0;
2692 LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
2693 scope.items(
2694 12,
2695 None::<fn(usize) -> u64>,
2696 None::<fn(usize) -> u64>,
2697 |index| {
2698 Text(
2699 format!("WarmRow {index}"),
2700 Modifier::empty().height(32.0),
2701 TextStyle::default(),
2702 );
2703 },
2704 );
2705 });
2706 });
2707
2708 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
2709 list_state.scroll_to_item(4, 0.0);
2710
2711 let root = composition.root().expect("lazy column root");
2712 let handle = composition.runtime_handle();
2713 let mut applier = composition.applier_mut();
2714 applier.set_runtime_handle(handle);
2715 let _ = applier
2716 .compute_layout(
2717 root,
2718 Size {
2719 width: 240.0,
2720 height: 240.0,
2721 },
2722 )
2723 .expect("lazy column layout");
2724 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2725 let active_children = applier
2726 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
2727 .expect("lazy column should be subcompose");
2728 applier.clear_runtime_handle();
2729
2730 let visible_indices: Vec<_> = list_state
2731 .layout_info()
2732 .visible_items_info
2733 .iter()
2734 .map(|item| item.index)
2735 .collect();
2736 let mut labels = Vec::new();
2737 collect_text_labels(&graph.root, &mut labels);
2738
2739 assert_eq!(
2740 visible_indices,
2741 vec![4, 5, 6],
2742 "test setup expects exactly three viewport-visible rows"
2743 );
2744 assert!(
2745 labels.iter().any(|label| label == "WarmRow 7"),
2746 "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
2747 );
2748 }
2749
2750 #[test]
2751 fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
2752 use std::cell::RefCell;
2753 use std::rc::Rc;
2754
2755 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
2756 let state_holder_for_comp = state_holder.clone();
2757 let mut composition = cranpose_ui::run_test_composition(move || {
2758 let list_state = remember_lazy_list_state();
2759 *state_holder_for_comp.borrow_mut() = Some(list_state);
2760 LazyColumn(
2761 Modifier::empty().height(120.0),
2762 list_state,
2763 LazyColumnSpec::default(),
2764 |scope| {
2765 scope.items(
2766 8,
2767 None::<fn(usize) -> u64>,
2768 None::<fn(usize) -> u64>,
2769 |index| {
2770 Text(
2771 format!("LazySnap {index}"),
2772 Modifier::empty().padding(4.0),
2773 TextStyle::default(),
2774 );
2775 },
2776 );
2777 },
2778 );
2779 });
2780
2781 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
2782 list_state.scroll_to_item(2, 7.5);
2783
2784 let root = composition.root().expect("lazy column root");
2785 let handle = composition.runtime_handle();
2786 let mut applier = composition.applier_mut();
2787 applier.set_runtime_handle(handle);
2788 let _ = applier
2789 .compute_layout(
2790 root,
2791 Size {
2792 width: 240.0,
2793 height: 240.0,
2794 },
2795 )
2796 .expect("lazy column layout");
2797 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
2798 applier.clear_runtime_handle();
2799
2800 let layout_info = list_state.layout_info();
2801 let first_visible_offset = layout_info
2802 .visible_items_info
2803 .first()
2804 .expect("lazy layout should expose visible item info")
2805 .offset;
2806 let snap_offset = find_translated_content_offset(&graph.root)
2807 .expect("lazy list graph should include translated content context");
2808
2809 assert!(
2810 (snap_offset.y - first_visible_offset).abs() <= 0.001,
2811 "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
2812 );
2813 }
2814
2815 #[test]
2816 fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
2817 let child = BuildNodeSnapshot {
2818 node_id: 2,
2819 placement: Point { x: 11.0, y: 7.0 },
2820 size: Size {
2821 width: 120.0,
2822 height: 32.0,
2823 },
2824 content_offset: Point::default(),
2825 motion_context_animated: false,
2826 translated_content_context: false,
2827 measured_max_width: Some(120.0),
2828 resolved_modifiers: ResolvedModifiers::default(),
2829 draw_commands: vec![],
2830 click_actions: vec![],
2831 pointer_inputs: vec![],
2832 clip_to_bounds: false,
2833 annotated_text: Some(AnnotatedString::from("static")),
2834 text_style: Some(TextStyle::from_paragraph_style(
2835 cranpose_ui::text::ParagraphStyle {
2836 text_motion: Some(TextMotion::Static),
2837 ..Default::default()
2838 },
2839 )),
2840 text_layout_options: None,
2841 graphics_layer: None,
2842 children: vec![],
2843 };
2844 let parent = BuildNodeSnapshot {
2845 node_id: 1,
2846 placement: Point::default(),
2847 size: Size {
2848 width: 160.0,
2849 height: 64.0,
2850 },
2851 content_offset: Point { x: 0.0, y: -18.5 },
2852 motion_context_animated: false,
2853 translated_content_context: true,
2854 measured_max_width: None,
2855 resolved_modifiers: ResolvedModifiers::default(),
2856 draw_commands: vec![],
2857 click_actions: vec![],
2858 pointer_inputs: vec![],
2859 clip_to_bounds: false,
2860 annotated_text: None,
2861 text_style: None,
2862 text_layout_options: None,
2863 graphics_layer: None,
2864 children: vec![child],
2865 };
2866
2867 let graph = build_layer_node_for_test(parent, 1.0, false);
2868 let RenderNode::Layer(child_layer) = &graph.children[0] else {
2869 panic!("expected child layer");
2870 };
2871 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
2872 panic!("expected text primitive");
2873 };
2874 let PrimitiveNode::Text(text) = &text_primitive.node else {
2875 panic!("expected text primitive");
2876 };
2877
2878 assert_eq!(
2879 text.text_style.paragraph_style.text_motion,
2880 Some(TextMotion::Static),
2881 "explicit text motion must win over inherited scrolling motion context"
2882 );
2883 }
2884}