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