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 TextPanResolver,
11};
12use cranpose_ui_graphics::{
13 rounded_corner_alpha_mask_effect, CompositingStrategy, GraphicsLayer, LayerShape,
14 RoundedCornerShape,
15};
16
17use crate::graph::{
18 CachePolicy, DrawCommandId, DrawRunNode, HitTestNode, IsolationReasons, LayerNode,
19 PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode,
20 TextPrimitiveNode,
21};
22use crate::layer_transform::layer_transform_to_parent;
23use crate::raster_cache::LayerRasterCacheHashes;
24use crate::style_shared::{primitives_for_placement_verified, DrawPlacement};
25
26const TEXT_CLIP_PAD: f32 = 1.0;
27const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
28
29#[derive(Clone)]
30struct BuildNodeSnapshot {
31 node_id: NodeId,
32 placement: Point,
33 size: Size,
34 content_offset: Point,
35 motion_context_animated: bool,
36 translated_content_context: bool,
37 measured_max_width: Option<f32>,
38 resolved_modifiers: ResolvedModifiers,
39 draw_commands: Vec<DrawCommand>,
40 click_actions: Vec<Rc<dyn Fn(Point)>>,
41 pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
42 clip_to_bounds: bool,
43 annotated_text: Option<AnnotatedString>,
44 text_style: Option<TextStyle>,
45 text_layout_options: Option<TextLayoutOptions>,
46 text_pan: Option<TextPanResolver>,
47 graphics_layer: Option<GraphicsLayer>,
48 children: Vec<Self>,
49}
50
51struct SnapshotNodeData {
52 layout_state: cranpose_ui::widgets::LayoutState,
53 modifier_slices: Rc<ModifierNodeSlices>,
54 resolved_modifiers: ResolvedModifiers,
55 children: Vec<NodeId>,
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub struct GraphUpdateReport {
60 pub applied: bool,
61 pub hit_graph_dirty: bool,
62}
63
64pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
65 bump_recording_generation();
66 let root_snapshot = layout_box_to_snapshot(root, None);
67 RenderGraph {
68 root: build_layer_node(root_snapshot, scale, false),
69 }
70}
71
72pub fn build_graph_from_applier(
73 applier: &mut MemoryApplier,
74 root: NodeId,
75 scale: f32,
76) -> Option<RenderGraph> {
77 bump_recording_generation();
78 Some(RenderGraph {
79 root: build_layer_node_from_applier(applier, root, scale, false)?,
80 })
81}
82
83pub fn update_graph_from_applier(
84 applier: &mut MemoryApplier,
85 graph: &mut RenderGraph,
86 dirty_nodes: &[NodeId],
87 scale: f32,
88) -> bool {
89 update_graph_from_applier_report(applier, graph, dirty_nodes, scale).applied
90}
91
92pub fn update_graph_from_applier_report(
93 applier: &mut MemoryApplier,
94 graph: &mut RenderGraph,
95 dirty_nodes: &[NodeId],
96 scale: f32,
97) -> GraphUpdateReport {
98 if dirty_nodes.is_empty() {
99 return GraphUpdateReport {
100 applied: true,
101 hit_graph_dirty: false,
102 };
103 }
104 bump_recording_generation();
105
106 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
107 eprintln!("[scene-update-diag] dirty={dirty_nodes:?}");
108 }
109
110 let mut remaining_dirty_nodes = dirty_nodes.iter().copied().collect::<HashSet<_>>();
111 if let Some(root_id) = graph.root.node_id {
112 if remaining_dirty_nodes.contains(&root_id) {
113 let Some(root) = build_layer_node_from_applier(applier, root_id, scale, false) else {
114 return GraphUpdateReport {
115 applied: false,
116 hit_graph_dirty: true,
117 };
118 };
119 let hit_graph_dirty = layer_hit_graph_state_dirty(&graph.root, &root);
120 graph.root = root;
121 graph.root.recompute_raster_cache_hashes();
122 return GraphUpdateReport {
123 applied: true,
124 hit_graph_dirty,
125 };
126 }
127 }
128
129 let inherited_translated_content_context = graph.root.translated_content_context;
130 let report = match replace_dirty_layers_from_applier(
131 applier,
132 &mut graph.root,
133 &mut remaining_dirty_nodes,
134 inherited_translated_content_context,
135 ) {
136 Some(report) => report,
137 None => {
138 return GraphUpdateReport {
139 applied: false,
140 hit_graph_dirty: true,
141 };
142 }
143 };
144
145 if !remaining_dirty_nodes.is_empty() {
146 return GraphUpdateReport {
147 applied: false,
148 hit_graph_dirty: true,
149 };
150 }
151
152 if report.updated {
153 graph.root.recompute_raster_cache_hashes();
154 }
155 GraphUpdateReport {
156 applied: true,
157 hit_graph_dirty: report.hit_graph_dirty,
158 }
159}
160
161#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
162struct ReplaceDirtyLayersReport {
163 updated: bool,
164 hit_graph_dirty: bool,
165}
166
167fn replace_dirty_layers_from_applier(
168 applier: &mut MemoryApplier,
169 parent: &mut LayerNode,
170 dirty_nodes: &mut HashSet<NodeId>,
171 inherited_translated_content_context: bool,
172) -> Option<ReplaceDirtyLayersReport> {
173 if dirty_nodes.is_empty() {
174 return Some(ReplaceDirtyLayersReport::default());
175 }
176
177 let child_inherited_translated_content_context =
178 inherited_translated_content_context || parent.translated_content_context;
179 let mut report = ReplaceDirtyLayersReport::default();
180
181 for child in &mut parent.children {
182 let RenderNode::Layer(child_layer) = child else {
183 continue;
184 };
185
186 if child_layer
187 .node_id
188 .is_some_and(|node_id| dirty_nodes.remove(&node_id))
189 {
190 let mut replacement = build_layer_node_from_applier_internal(
191 applier,
192 child_layer
193 .node_id
194 .expect("dirty layer must have a node id"),
195 parent.motion_context_animated,
196 child_inherited_translated_content_context,
197 Some(AbsOrigin {
203 content_origin: parent.scene_children_origin,
204 layer_translation: parent.scene_children_layer_translation,
205 }),
206 )?;
207 if parent.content_offset != Point::default() {
208 replacement.transform_to_parent =
209 replacement
210 .transform_to_parent
211 .then(ProjectiveTransform::translation(
212 parent.content_offset.x,
213 parent.content_offset.y,
214 ));
215 }
216 report.hit_graph_dirty |= layer_hit_graph_state_dirty(child_layer, &replacement);
217 remove_dirty_descendants(&replacement, dirty_nodes);
218 **child_layer = replacement;
219 report.updated = true;
220 continue;
221 }
222
223 let child_report = replace_dirty_layers_from_applier(
224 applier,
225 child_layer,
226 dirty_nodes,
227 child_inherited_translated_content_context,
228 )?;
229 report.updated |= child_report.updated;
230 report.hit_graph_dirty |= child_report.hit_graph_dirty;
231 }
232
233 if report.updated {
234 parent.has_hit_targets = parent.hit_test.is_some()
235 || parent.children.iter().any(|child| match child {
236 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
237 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
238 });
239 }
240
241 Some(report)
242}
243
244fn layer_hit_graph_state_dirty(previous: &LayerNode, replacement: &LayerNode) -> bool {
245 if previous.hit_test.is_some() || replacement.hit_test.is_some() {
246 return true;
247 }
248
249 if !(previous.has_hit_targets || replacement.has_hit_targets) {
250 return false;
251 }
252
253 previous.has_hit_targets != replacement.has_hit_targets
254 || previous.local_bounds != replacement.local_bounds
255 || previous.transform_to_parent != replacement.transform_to_parent
256 || previous.clip_rect() != replacement.clip_rect()
257 || previous.graphics_layer.shape != replacement.graphics_layer.shape
258}
259
260fn remove_dirty_descendants(layer: &LayerNode, dirty_nodes: &mut HashSet<NodeId>) {
261 for child in &layer.children {
262 let RenderNode::Layer(child_layer) = child else {
263 continue;
264 };
265 if let Some(node_id) = child_layer.node_id {
266 dirty_nodes.remove(&node_id);
267 }
268 remove_dirty_descendants(child_layer, dirty_nodes);
269 }
270}
271
272fn build_layer_node(
273 snapshot: BuildNodeSnapshot,
274 _root_scale: f32,
275 inherited_motion_context_animated: bool,
276) -> LayerNode {
277 build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
278}
279
280fn build_layer_node_internal(
281 snapshot: BuildNodeSnapshot,
282 inherited_motion_context_animated: bool,
283 inherited_translated_content_context: bool,
284) -> LayerNode {
285 let BuildNodeSnapshot {
286 node_id,
287 placement,
288 size,
289 content_offset,
290 motion_context_animated,
291 translated_content_context,
292 measured_max_width,
293 resolved_modifiers,
294 draw_commands,
295 click_actions,
296 pointer_inputs,
297 clip_to_bounds,
298 annotated_text,
299 text_style,
300 text_layout_options,
301 text_pan,
302 graphics_layer,
303 children: child_snapshots,
304 } = snapshot;
305 let local_bounds = Rect {
306 x: 0.0,
307 y: 0.0,
308 width: size.width,
309 height: size.height,
310 };
311 let graphics_layer = graphics_layer.unwrap_or_default();
312 let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
313 let isolation = isolation_reasons(&graphics_layer);
314 let cache_policy = if isolation.has_any() {
315 CachePolicy::Auto
316 } else {
317 CachePolicy::None
318 };
319 let shadow_clip = clip_to_bounds.then_some(local_bounds);
320 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
321 shape: None,
322 click_actions,
323 pointer_inputs,
324 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
325 });
326
327 let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
328 let child_translated_content_context =
329 inherited_translated_content_context || translated_content_context;
330
331 let mut children = draw_nodes(
332 node_id,
333 &draw_commands,
334 DrawPlacement::Behind,
335 size,
336 PrimitivePhase::BeforeChildren,
337 );
338 if let Some(text) = text_node_from_parts(TextNodeParts {
339 node_id,
340 local_bounds,
341 measured_max_width,
342 resolved_modifiers: &resolved_modifiers,
343 annotated_text: annotated_text.as_ref(),
344 text_style: text_style.as_ref(),
345 text_layout_options,
346 text_pan,
347 modifier_slices: None,
348 }) {
349 children.push(RenderNode::Primitive(PrimitiveEntry {
350 phase: PrimitivePhase::BeforeChildren,
351 node: PrimitiveNode::Text(Box::new(text)),
352 }));
353 }
354 let child_motion_context_animated = node_motion_context_animated;
355 for child in child_snapshots {
356 let mut child_layer = build_layer_node_internal(
357 child,
358 child_motion_context_animated,
359 child_translated_content_context,
360 );
361 if content_offset != Point::default() {
362 child_layer.transform_to_parent =
363 child_layer
364 .transform_to_parent
365 .then(ProjectiveTransform::translation(
366 content_offset.x,
367 content_offset.y,
368 ));
369 }
370 children.push(RenderNode::Layer(Box::new(child_layer)));
371 }
372 children.extend(draw_nodes(
373 node_id,
374 &draw_commands,
375 DrawPlacement::Overlay,
376 size,
377 PrimitivePhase::AfterChildren,
378 ));
379 let has_hit_targets = hit_test.is_some()
380 || children.iter().any(|child| match child {
381 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
382 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
383 });
384
385 LayerNode {
386 node_id: Some(node_id),
387 local_bounds,
388 transform_to_parent,
389 content_offset,
390 motion_context_animated: node_motion_context_animated,
391 translated_content_context,
392 translated_content_offset: if translated_content_context {
393 content_offset
394 } else {
395 Point::default()
396 },
397 scene_children_origin: Point::default(),
402 scene_children_layer_translation: Point::default(),
403 graphics_layer,
404 clip_to_bounds,
405 shadow_clip,
406 hit_test,
407 has_hit_targets,
408 isolation,
409 cache_policy,
410 cache_hashes: LayerRasterCacheHashes::default(),
411 cache_hashes_valid: false,
412 children,
413 }
414}
415
416#[derive(Clone, Copy)]
427struct AbsOrigin {
428 content_origin: Point,
429 layer_translation: Point,
430}
431
432impl AbsOrigin {
433 const ROOT: AbsOrigin = AbsOrigin {
434 content_origin: Point { x: 0.0, y: 0.0 },
435 layer_translation: Point { x: 0.0, y: 0.0 },
436 };
437}
438
439fn build_layer_node_from_applier(
440 applier: &mut MemoryApplier,
441 node_id: NodeId,
442 _root_scale: f32,
443 inherited_motion_context_animated: bool,
444) -> Option<LayerNode> {
445 build_layer_node_from_applier_internal(
446 applier,
447 node_id,
448 inherited_motion_context_animated,
449 false,
450 Some(AbsOrigin::ROOT),
451 )
452}
453
454fn build_layer_node_from_applier_internal(
455 applier: &mut MemoryApplier,
456 node_id: NodeId,
457 inherited_motion_context_animated: bool,
458 inherited_translated_content_context: bool,
459 parent_abs: Option<AbsOrigin>,
460) -> Option<LayerNode> {
461 if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
462 let state = node.layout_state();
463 let children = node.children.clone();
464 let modifier_slices = node.modifier_slices_snapshot();
465 SnapshotNodeData {
466 layout_state: state,
467 modifier_slices,
468 resolved_modifiers: node.resolved_modifiers(),
469 children,
470 }
471 }) {
472 return build_layer_node_from_data(
473 applier,
474 node_id,
475 data,
476 inherited_motion_context_animated,
477 inherited_translated_content_context,
478 parent_abs,
479 );
480 }
481
482 if let Ok(data) = applier.with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
483 let state = node.layout_state();
484 let children = node.active_children();
485 let modifier_slices = node.modifier_slices_snapshot();
486 SnapshotNodeData {
487 layout_state: state,
488 modifier_slices,
489 resolved_modifiers: node.resolved_modifiers(),
490 children,
491 }
492 }) {
493 return build_layer_node_from_data(
494 applier,
495 node_id,
496 data,
497 inherited_motion_context_animated,
498 inherited_translated_content_context,
499 parent_abs,
500 );
501 }
502
503 None
504}
505
506fn build_layer_node_from_data(
507 applier: &mut MemoryApplier,
508 node_id: NodeId,
509 data: SnapshotNodeData,
510 inherited_motion_context_animated: bool,
511 inherited_translated_content_context: bool,
512 parent_abs: Option<AbsOrigin>,
513) -> Option<LayerNode> {
514 let SnapshotNodeData {
515 layout_state,
516 modifier_slices,
517 resolved_modifiers,
518 children,
519 } = data;
520 if !layout_state.is_placed {
521 return None;
522 }
523
524 let local_bounds = Rect {
525 x: 0.0,
526 y: 0.0,
527 width: layout_state.size.width,
528 height: layout_state.size.height,
529 };
530 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
531 eprintln!(
532 "[scene-update-diag] build layer node={node_id:?} size=({:.2},{:.2}) pos=({:.2},{:.2})",
533 layout_state.size.width,
534 layout_state.size.height,
535 layout_state.position.x,
536 layout_state.position.y,
537 );
538 }
539 let clip_to_bounds = modifier_slices.clip_to_bounds();
540 let graphics_layer = graphics_layer_with_shaped_clip(
541 modifier_slices.graphics_layer().unwrap_or_default(),
542 clip_to_bounds,
543 modifier_slices.corner_shape(),
544 local_bounds,
545 );
546 let transform_to_parent =
547 layer_transform_to_parent(local_bounds, layout_state.position, &graphics_layer);
548 let isolation = isolation_reasons(&graphics_layer);
549 let cache_policy = if isolation.has_any() {
550 CachePolicy::Auto
551 } else {
552 CachePolicy::None
553 };
554 let click_actions = modifier_slices.click_handlers();
555 let pointer_inputs = modifier_slices.pointer_inputs();
556 let shadow_clip = clip_to_bounds.then_some(local_bounds);
557 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
558 shape: None,
559 click_actions: click_actions.to_vec(),
560 pointer_inputs: pointer_inputs.to_vec(),
561 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
562 });
563
564 modifier_slices.publish_pointer_input_size(layout_state.size);
571
572 let node_motion_context_animated =
573 inherited_motion_context_animated || modifier_slices.motion_context_animated();
574 let local_translated_content_context = modifier_slices.translated_content_context();
575 let local_translated_content_offset = modifier_slices
576 .translated_content_offset()
577 .unwrap_or(layout_state.content_offset);
578 let child_translated_content_context =
579 inherited_translated_content_context || local_translated_content_context;
580
581 let this_abs = parent_abs.map(|parent| {
596 let (tx, ty) = modifier_slices
597 .graphics_layer()
598 .map(|layer| (layer.translation_x, layer.translation_y))
599 .unwrap_or((0.0, 0.0));
600 let top_left = Point {
601 x: parent.content_origin.x + layout_state.position.x,
602 y: parent.content_origin.y + layout_state.position.y,
603 };
604 let layer_translation = Point {
605 x: parent.layer_translation.x + tx,
606 y: parent.layer_translation.y + ty,
607 };
608 (top_left, layer_translation)
609 });
610 if let Some((top_left, layer_translation)) = this_abs {
611 let window_origin = Point {
612 x: top_left.x + layer_translation.x,
613 y: top_left.y + layer_translation.y,
614 };
615 if let Some(sink) = modifier_slices.text_field_window_origin() {
616 sink.set(window_origin);
617 }
618 if let Some(sink) = modifier_slices.viewport_window_rect() {
619 sink.set(Rect {
620 x: window_origin.x,
621 y: window_origin.y,
622 width: layout_state.size.width,
623 height: layout_state.size.height,
624 });
625 }
626 }
627 let child_abs = this_abs.map(|(top_left, layer_translation)| AbsOrigin {
635 content_origin: Point {
636 x: top_left.x + layout_state.content_offset.x,
637 y: top_left.y + layout_state.content_offset.y,
638 },
639 layer_translation,
640 });
641
642 let mut render_children = draw_nodes(
643 node_id,
644 modifier_slices.draw_commands(),
645 DrawPlacement::Behind,
646 layout_state.size,
647 PrimitivePhase::BeforeChildren,
648 );
649 if let Some(text) = text_node_from_parts(TextNodeParts {
650 node_id,
651 local_bounds,
652 measured_max_width: layout_state
653 .measurement_constraints
654 .max_width
655 .is_finite()
656 .then_some(layout_state.measurement_constraints.max_width),
657 resolved_modifiers: &resolved_modifiers,
658 annotated_text: modifier_slices.annotated_text(),
659 text_style: modifier_slices.text_style(),
660 text_layout_options: modifier_slices.text_layout_options(),
661 text_pan: modifier_slices.text_pan_resolver(),
662 modifier_slices: Some(modifier_slices.as_ref()),
663 }) {
664 render_children.push(RenderNode::Primitive(PrimitiveEntry {
665 phase: PrimitivePhase::BeforeChildren,
666 node: PrimitiveNode::Text(Box::new(text)),
667 }));
668 }
669 let child_motion_context_animated = node_motion_context_animated;
670 for child_id in children {
671 let Some(mut child_layer) = build_layer_node_from_applier_internal(
672 applier,
673 child_id,
674 child_motion_context_animated,
675 child_translated_content_context,
676 child_abs,
677 ) else {
678 continue;
679 };
680 if layout_state.content_offset != Point::default() {
681 child_layer.transform_to_parent =
682 child_layer
683 .transform_to_parent
684 .then(ProjectiveTransform::translation(
685 layout_state.content_offset.x,
686 layout_state.content_offset.y,
687 ));
688 }
689 render_children.push(RenderNode::Layer(Box::new(child_layer)));
690 }
691 render_children.extend(draw_nodes(
692 node_id,
693 modifier_slices.draw_commands(),
694 DrawPlacement::Overlay,
695 layout_state.size,
696 PrimitivePhase::AfterChildren,
697 ));
698 let has_hit_targets = hit_test.is_some()
699 || render_children.iter().any(|child| match child {
700 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
701 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
702 });
703
704 let layer = LayerNode {
705 node_id: Some(node_id),
706 local_bounds,
707 transform_to_parent,
708 content_offset: layout_state.content_offset,
709 motion_context_animated: node_motion_context_animated,
710 translated_content_context: local_translated_content_context,
711 translated_content_offset: if local_translated_content_context {
712 local_translated_content_offset
713 } else {
714 Point::default()
715 },
716 scene_children_origin: child_abs.map(|c| c.content_origin).unwrap_or_default(),
720 scene_children_layer_translation: child_abs
721 .map(|c| c.layer_translation)
722 .unwrap_or_default(),
723 graphics_layer,
724 clip_to_bounds,
725 shadow_clip,
726 hit_test,
727 has_hit_targets,
728 isolation,
729 cache_policy,
730 cache_hashes: LayerRasterCacheHashes::default(),
731 cache_hashes_valid: false,
732 children: render_children,
733 };
734 Some(layer)
735}
736
737struct RecorderSlot {
746 generation: u64,
747 handles: [Option<Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>>; 2],
748 recordings: [Option<Rc<cranpose_ui_graphics::CommandRecording>>; 2],
757 replay: cranpose_ui_graphics::CommandReplayState,
761 replay_epoch: Option<u64>,
764 saved_emission: Option<SavedReplayEmission>,
768}
769
770struct SavedReplayEmission {
785 spans: Vec<cranpose_ui_graphics::FrameSpan>,
789 center: cranpose_ui_graphics::Point,
791 primitives: Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>,
794 recording: Rc<cranpose_ui_graphics::CommandRecording>,
798 epoch: u64,
801 generation: u64,
805}
806
807fn stale_transition_enabled() -> bool {
817 matches!(
818 std::env::var("CRANPOSE_STALE_TRANSITION").as_deref(),
819 Ok(value) if !value.is_empty() && value != "0"
820 )
821}
822
823fn sanitized_replay_spans(
839 spans: &[cranpose_ui_graphics::FrameSpan],
840) -> Vec<cranpose_ui_graphics::FrameSpan> {
841 use cranpose_ui_graphics::FrameSpan;
842 spans
843 .iter()
844 .map(|span| match span {
845 FrameSpan::Retained {
846 capture: true,
847 range,
848 ..
849 } => FrameSpan::Dynamic { range: *range },
850 FrameSpan::Retained {
851 slot,
852 capture: false,
853 slot_offset,
854 range,
855 tape_range,
856 transform,
857 recolors: _,
858 bounds,
859 } => FrameSpan::Retained {
860 slot: *slot,
861 capture: false,
862 slot_offset: *slot_offset,
863 range: *range,
864 tape_range: *tape_range,
865 transform: *transform,
866 recolors: Vec::new(),
867 bounds: *bounds,
868 },
869 FrameSpan::Dynamic { range } => FrameSpan::Dynamic { range: *range },
870 })
871 .collect()
872}
873
874fn saved_emission_available(id: DrawCommandId) -> bool {
880 let Some(epoch) = RETAINED_FEED_EPOCH.with(std::cell::Cell::get) else {
881 return false;
882 };
883 let generation = RECORDING_GENERATION.with(std::cell::Cell::get);
884 COMMAND_RECORDINGS.with(|map| {
885 map.borrow()
886 .get(&id)
887 .and_then(|slot| slot.saved_emission.as_ref())
888 .is_some_and(|saved| {
889 saved.generation.wrapping_add(1) == generation && saved.epoch == epoch
890 })
891 })
892}
893
894fn take_saved_emission(id: DrawCommandId) -> Option<SavedReplayEmission> {
899 COMMAND_RECORDINGS.with(|map| {
900 map.borrow_mut()
901 .get_mut(&id)
902 .and_then(|slot| slot.saved_emission.take())
903 })
904}
905
906fn store_saved_emission(id: DrawCommandId, saved: Option<SavedReplayEmission>) {
911 COMMAND_RECORDINGS.with(|map| {
912 if let Some(slot) = map.borrow_mut().get_mut(&id) {
913 slot.saved_emission = saved;
914 }
915 });
916}
917
918thread_local! {
919 static COMMAND_RECORDINGS: std::cell::RefCell<
920 std::collections::HashMap<DrawCommandId, RecorderSlot, cranpose_ui_graphics::FxBuildHasher>,
921 > = std::cell::RefCell::new(std::collections::HashMap::default());
922 static RECORDING_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
923 static RETAINED_FEED_EPOCH: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
924}
925
926pub fn set_retained_feed_epoch(epoch: Option<u64>) {
935 RETAINED_FEED_EPOCH.with(|cell| cell.set(epoch));
936}
937
938thread_local! {
939 static CONFIRMED_RETAINED_SLOTS: std::cell::RefCell<
940 std::collections::HashMap<(DrawCommandId, u32), u64, cranpose_ui_graphics::FxBuildHasher>,
941 > = std::cell::RefCell::new(std::collections::HashMap::default());
942}
943
944pub fn confirm_retained_slot(command: DrawCommandId, slot: u32, generation: u64) {
953 CONFIRMED_RETAINED_SLOTS.with(|map| {
954 map.borrow_mut().insert((command, slot), generation);
955 });
956}
957
958pub fn revoke_retained_slot(command: DrawCommandId, slot: u32) {
961 CONFIRMED_RETAINED_SLOTS.with(|map| {
962 map.borrow_mut().remove(&(command, slot));
963 });
964}
965
966pub fn clear_retained_slot_confirmations() {
968 CONFIRMED_RETAINED_SLOTS.with(|map| map.borrow_mut().clear());
969}
970
971pub fn retained_slot_confirmed(command: DrawCommandId, slot: u32) -> bool {
977 let Some(epoch) = RETAINED_FEED_EPOCH.with(std::cell::Cell::get) else {
978 return false;
979 };
980 CONFIRMED_RETAINED_SLOTS.with(|map| map.borrow().get(&(command, slot)) == Some(&epoch))
981}
982
983thread_local! {
984 static VERIFY_EXECUTOR: std::cell::Cell<
985 Option<&'static dyn cranpose_ui_graphics::VerifyExecutor>,
986 > = const { std::cell::Cell::new(None) };
987}
988
989pub fn set_verify_executor(pool: Option<&'static dyn cranpose_ui_graphics::VerifyExecutor>) {
994 VERIFY_EXECUTOR.with(|cell| cell.set(pool));
995}
996
997pub fn verify_executor() -> Option<&'static dyn cranpose_ui_graphics::VerifyExecutor> {
999 VERIFY_EXECUTOR.with(|cell| cell.get())
1000}
1001
1002#[doc(hidden)]
1007pub fn clear_command_recordings_for_tests() {
1008 COMMAND_RECORDINGS.with(|map| map.borrow_mut().clear());
1009}
1010
1011fn bump_recording_generation() {
1021 let generation = RECORDING_GENERATION.with(|cell| {
1022 let next = cell.get().wrapping_add(1);
1023 cell.set(next);
1024 next
1025 });
1026 if generation.is_multiple_of(512) {
1027 COMMAND_RECORDINGS.with(|map| {
1028 map.borrow_mut()
1029 .retain(|_, slot| generation.wrapping_sub(slot.generation) <= 64);
1030 });
1031 }
1032}
1033
1034fn acquire_recording(
1035 id: DrawCommandId,
1036) -> (
1037 cranpose_ui_graphics::CommandRecording,
1038 Vec<cranpose_ui_graphics::DrawPrimitive>,
1039 Option<cranpose_ui_graphics::CommandReplayState>,
1040) {
1041 let feed_epoch = RETAINED_FEED_EPOCH.with(std::cell::Cell::get);
1042 COMMAND_RECORDINGS.with(|map| {
1043 let mut map = map.borrow_mut();
1044 let Some(slot) = map.get_mut(&id) else {
1045 return (
1046 cranpose_ui_graphics::CommandRecording::default(),
1047 Vec::new(),
1048 feed_epoch.map(|_| cranpose_ui_graphics::CommandReplayState::default()),
1049 );
1050 };
1051 let mut recording = cranpose_ui_graphics::CommandRecording::default();
1056 for shared in &mut slot.recordings {
1057 if shared
1058 .as_ref()
1059 .is_some_and(|shared| Rc::strong_count(shared) == 1)
1060 {
1061 let shared = shared.take().expect("checked some above");
1062 recording = Rc::try_unwrap(shared).expect("sole owner checked above");
1063 break;
1064 }
1065 }
1066 let replay = feed_epoch.map(|epoch| {
1070 if slot.replay_epoch == Some(epoch) {
1071 std::mem::take(&mut slot.replay)
1072 } else {
1073 cranpose_ui_graphics::CommandReplayState::default()
1074 }
1075 });
1076 for handle in &mut slot.handles {
1077 if handle
1078 .as_ref()
1079 .is_some_and(|shared| Rc::strong_count(shared) == 1)
1080 {
1081 let shared = handle.take().expect("checked some above");
1082 let storage = Rc::try_unwrap(shared).expect("sole owner checked above");
1083 return (recording, storage, replay);
1084 }
1085 }
1086 (recording, Vec::new(), replay)
1087 })
1088}
1089
1090fn publish_recording(
1097 id: DrawCommandId,
1098 recording: cranpose_ui_graphics::CommandRecording,
1099 primitives: Vec<cranpose_ui_graphics::DrawPrimitive>,
1100 replay: Option<cranpose_ui_graphics::CommandReplayState>,
1101) -> (
1102 Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>,
1103 Rc<cranpose_ui_graphics::CommandRecording>,
1104) {
1105 let shared = Rc::new(primitives);
1106 let recording = Rc::new(recording);
1107 COMMAND_RECORDINGS.with(|map| {
1108 let mut map = map.borrow_mut();
1109 let generation = RECORDING_GENERATION.with(std::cell::Cell::get);
1110 let slot = map.entry(id).or_insert_with(|| RecorderSlot {
1111 generation,
1112 handles: [None, None],
1113 recordings: [None, None],
1114 replay: cranpose_ui_graphics::CommandReplayState::default(),
1115 replay_epoch: None,
1116 saved_emission: None,
1117 });
1118 slot.generation = generation;
1119 if let Some(replay) = replay {
1120 slot.replay = replay;
1121 slot.replay_epoch = RETAINED_FEED_EPOCH.with(std::cell::Cell::get);
1122 }
1123 slot.recordings[1] = slot.recordings[0].take();
1126 slot.recordings[0] = Some(recording.clone());
1127 slot.handles[1] = slot.handles[0].take();
1128 slot.handles[0] = Some(shared.clone());
1129 });
1130 (shared, recording)
1131}
1132
1133fn draw_nodes(
1134 node_id: NodeId,
1135 commands: &[DrawCommand],
1136 placement: DrawPlacement,
1137 size: Size,
1138 phase: PrimitivePhase,
1139) -> Vec<RenderNode> {
1140 let mut nodes = Vec::new();
1141 let stale_transition = stale_transition_enabled();
1142 for (command_index, command) in commands.iter().enumerate() {
1143 let id = DrawCommandId {
1144 node_id,
1145 command_index: command_index as u32,
1146 placement,
1147 };
1148 let (recording, storage, mut replay) = acquire_recording(id);
1149 let stale_available = stale_transition && replay.is_some() && saved_emission_available(id);
1150 let mut ctx = replay
1151 .as_mut()
1152 .map(|state| crate::style_shared::CommandReplayContext {
1153 state,
1154 stale_available,
1155 serve_stale: false,
1156 });
1157 let (primitives, recording, frame) = primitives_for_placement_verified(
1158 command,
1159 placement,
1160 size,
1161 recording,
1162 storage,
1163 &mut ctx,
1164 Some(id),
1165 );
1166 if ctx.is_some_and(|ctx| ctx.serve_stale) {
1167 publish_recording(id, recording, primitives, replay);
1176 if let Some(saved) = take_saved_emission(id) {
1177 let frame = cranpose_ui_graphics::CommandReplayFrame {
1178 center: saved.center,
1179 spans: saved.spans,
1180 fallback: Some(saved.recording),
1181 };
1182 nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_replayed(
1183 phase,
1184 Some(id),
1185 saved.primitives,
1186 Some(Box::new(frame)),
1187 )));
1188 } else {
1189 debug_assert!(false, "serve_stale without a saved emission");
1194 }
1195 continue;
1196 }
1197 let has_replay_spans = frame.as_ref().is_some_and(|frame| !frame.spans.is_empty());
1200 if primitives.is_empty() && primitives.capacity() == 0 && !has_replay_spans {
1206 continue;
1207 }
1208 let (shared, published_recording) = publish_recording(id, recording, primitives, replay);
1209 if stale_transition {
1210 let saved = frame.as_ref().and_then(|frame| {
1218 RETAINED_FEED_EPOCH
1219 .with(std::cell::Cell::get)
1220 .map(|epoch| SavedReplayEmission {
1221 spans: sanitized_replay_spans(&frame.spans),
1222 center: frame.center,
1223 primitives: shared.clone(),
1224 recording: published_recording.clone(),
1225 epoch,
1226 generation: RECORDING_GENERATION.with(std::cell::Cell::get),
1227 })
1228 });
1229 store_saved_emission(id, saved);
1230 }
1231 if shared.is_empty() && !has_replay_spans {
1232 continue;
1233 }
1234 let frame = frame.map(|mut frame| {
1239 frame.fallback = Some(published_recording);
1240 frame
1241 });
1242 nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_replayed(
1246 phase,
1247 Some(id),
1248 shared,
1249 frame.map(Box::new),
1250 )));
1251 }
1252 nodes
1253}
1254
1255#[doc(hidden)]
1261pub fn draw_command_nodes_for_tests(
1262 node_id: NodeId,
1263 commands: &[DrawCommand],
1264 placement: DrawPlacement,
1265 size: Size,
1266 phase: PrimitivePhase,
1267) -> Vec<RenderNode> {
1268 bump_recording_generation();
1269 draw_nodes(node_id, commands, placement, size, phase)
1270}
1271
1272struct TextNodeParts<'a> {
1273 node_id: NodeId,
1274 local_bounds: Rect,
1275 measured_max_width: Option<f32>,
1276 resolved_modifiers: &'a ResolvedModifiers,
1277 annotated_text: Option<&'a AnnotatedString>,
1278 text_style: Option<&'a TextStyle>,
1279 text_layout_options: Option<TextLayoutOptions>,
1280 text_pan: Option<TextPanResolver>,
1281 modifier_slices: Option<&'a ModifierNodeSlices>,
1282}
1283
1284fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
1285 let TextNodeParts {
1286 node_id,
1287 local_bounds,
1288 measured_max_width,
1289 resolved_modifiers,
1290 annotated_text,
1291 text_style,
1292 text_layout_options,
1293 text_pan,
1294 modifier_slices,
1295 } = parts;
1296 let value = annotated_text?;
1297 let default_text_style = TextStyle::default();
1298 let text_style = text_style.cloned().unwrap_or(default_text_style);
1299 let options = text_layout_options.unwrap_or_default().normalized();
1300 let padding = resolved_modifiers.padding();
1301 let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
1302 if content_width <= 0.0 {
1303 return None;
1304 }
1305
1306 let pan_offset = text_pan
1310 .as_ref()
1311 .map(|resolve| resolve(content_width))
1312 .unwrap_or(0.0);
1313 let pans_horizontally = text_pan.is_some();
1314
1315 let max_width = if pans_horizontally {
1316 None
1317 } else {
1318 let measure_width =
1319 resolve_text_measure_width(content_width, padding, measured_max_width, options);
1320 Some(measure_width).filter(|width| width.is_finite() && *width > 0.0)
1321 };
1322 let prepared = modifier_slices
1323 .and_then(|slices| slices.prepare_text_layout(max_width))
1324 .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
1325 let visual_style = prepared.visual_style.clone();
1326 let measured_draw_width = prepared.metrics.width.max(0.0);
1327 let draw_width = if options.overflow == TextOverflow::Visible || pans_horizontally {
1328 measured_draw_width
1329 } else {
1330 measured_draw_width.min(content_width)
1331 };
1332 let alignment_offset = resolve_text_horizontal_offset(
1333 &text_style,
1334 prepared.text.text.as_str(),
1335 content_width,
1336 prepared.metrics.width,
1337 );
1338 let rect = Rect {
1339 x: padding.left + alignment_offset - pan_offset,
1340 y: padding.top,
1341 width: draw_width,
1342 height: prepared.metrics.height,
1343 };
1344 let text_bounds = Rect {
1345 x: padding.left,
1346 y: padding.top,
1347 width: content_width,
1348 height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
1349 };
1350 let font_size = visual_style.resolve_font_size(14.0);
1351 let expanded_bounds =
1352 expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
1353 let clip = if options.overflow == TextOverflow::Visible && !pans_horizontally {
1354 None
1355 } else {
1356 Some(pad_clip_rect(expanded_bounds))
1357 };
1358
1359 Some(TextPrimitiveNode {
1360 node_id,
1361 rect,
1362 text: std::rc::Rc::new(prepared.text),
1363 text_style: visual_style,
1364 font_size,
1365 layout_options: options,
1366 clip,
1367 })
1368}
1369
1370fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
1371 let placement = parent
1372 .map(|parent_box| Point {
1373 x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
1374 y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
1375 })
1376 .unwrap_or_default();
1377 let mut children = Vec::with_capacity(node.children.len());
1378 for child in &node.children {
1379 children.push(layout_box_to_snapshot(child, Some(node)));
1380 }
1381 let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
1382 let graphics_layer = graphics_layer_with_shaped_clip(
1383 base_graphics_layer.clone().unwrap_or_default(),
1384 node.node_data.modifier_slices.clip_to_bounds(),
1385 node.node_data.modifier_slices.corner_shape(),
1386 Rect {
1387 x: 0.0,
1388 y: 0.0,
1389 width: node.rect.width,
1390 height: node.rect.height,
1391 },
1392 );
1393 let has_graphics_layer =
1394 base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
1395
1396 BuildNodeSnapshot {
1397 node_id: node.node_id,
1398 placement,
1399 size: Size {
1400 width: node.rect.width,
1401 height: node.rect.height,
1402 },
1403 content_offset: node.content_offset,
1404 motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
1405 translated_content_context: node.node_data.modifier_slices.translated_content_context(),
1406 measured_max_width: None,
1407 resolved_modifiers: node.node_data.resolved_modifiers,
1408 draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
1409 click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
1410 pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
1411 clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
1412 annotated_text: node.node_data.modifier_slices.annotated_string(),
1413 text_style: node.node_data.modifier_slices.text_style().cloned(),
1414 text_layout_options: node.node_data.modifier_slices.text_layout_options(),
1415 text_pan: node.node_data.modifier_slices.text_pan_resolver(),
1416 graphics_layer: has_graphics_layer.then_some(graphics_layer),
1417 children,
1418 }
1419}
1420
1421fn graphics_layer_with_shaped_clip(
1422 mut graphics_layer: GraphicsLayer,
1423 clip_to_bounds: bool,
1424 corner_shape: Option<RoundedCornerShape>,
1425 local_bounds: Rect,
1426) -> GraphicsLayer {
1427 if !clip_to_bounds {
1428 return graphics_layer;
1429 }
1430
1431 let Some(corner_shape) = corner_shape else {
1432 return graphics_layer;
1433 };
1434 let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
1435 if radii.top_left <= f32::EPSILON
1436 && radii.top_right <= f32::EPSILON
1437 && radii.bottom_right <= f32::EPSILON
1438 && radii.bottom_left <= f32::EPSILON
1439 {
1440 return graphics_layer;
1441 }
1442
1443 if let Some(existing) = graphics_layer.render_effect.take() {
1444 let rounded_clip = rounded_corner_alpha_mask_effect(
1445 local_bounds.width,
1446 local_bounds.height,
1447 radii,
1448 ROUNDED_CLIP_EDGE_FEATHER,
1449 );
1450 graphics_layer.render_effect = Some(existing.then(rounded_clip));
1451 } else {
1452 graphics_layer.shape = LayerShape::Rounded(corner_shape);
1453 graphics_layer.clip = true;
1454 }
1455 graphics_layer
1456}
1457
1458fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
1459 IsolationReasons {
1460 explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
1461 shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
1462 effect: layer.render_effect.is_some(),
1463 backdrop: layer.backdrop_effect.is_some(),
1464 group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
1465 && layer.alpha < 1.0,
1466 blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
1467 }
1468}
1469
1470fn pad_clip_rect(rect: Rect) -> Rect {
1471 Rect {
1472 x: rect.x - TEXT_CLIP_PAD,
1473 y: rect.y - TEXT_CLIP_PAD,
1474 width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
1475 height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
1476 }
1477}
1478
1479fn expand_text_bounds_for_baseline_shift(
1480 text_bounds: Rect,
1481 text_style: &TextStyle,
1482 font_size: f32,
1483) -> Rect {
1484 let baseline_shift_px = text_style
1485 .span_style
1486 .baseline_shift
1487 .filter(|shift| shift.is_specified())
1488 .map(|shift| -(shift.0 * font_size))
1489 .unwrap_or(0.0);
1490 if baseline_shift_px == 0.0 {
1491 return text_bounds;
1492 }
1493
1494 if baseline_shift_px < 0.0 {
1495 Rect {
1496 x: text_bounds.x,
1497 y: text_bounds.y + baseline_shift_px,
1498 width: text_bounds.width,
1499 height: (text_bounds.height - baseline_shift_px).max(0.0),
1500 }
1501 } else {
1502 Rect {
1503 x: text_bounds.x,
1504 y: text_bounds.y,
1505 width: text_bounds.width,
1506 height: (text_bounds.height + baseline_shift_px).max(0.0),
1507 }
1508 }
1509}
1510
1511pub fn resolve_text_measure_width(
1538 content_width: f32,
1539 padding: cranpose_ui::EdgeInsets,
1540 measured_max_width: Option<f32>,
1541 options: TextLayoutOptions,
1542) -> f32 {
1543 let width = content_width.max(0.0);
1544 if let Some(max_width) = measured_max_width.filter(|w| w.is_finite() && *w > 0.0) {
1545 let measured_content_width = (max_width - padding.left - padding.right).max(0.0);
1546 if measured_content_width <= width {
1547 return measured_content_width;
1548 }
1549
1550 let may_expand_to_avoid_synthetic_wrap = options.soft_wrap
1551 && options.max_lines == usize::MAX
1552 && options.overflow == TextOverflow::Clip;
1553 if may_expand_to_avoid_synthetic_wrap {
1554 return measured_content_width;
1555 }
1556 }
1557 width
1558}
1559
1560pub fn text_align_fraction(text_style: &TextStyle, text: &str) -> f32 {
1573 let paragraph_style = &text_style.paragraph_style;
1574 let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
1575 let rtl = direction == cranpose_ui::text::ResolvedTextDirection::Rtl;
1576 match paragraph_style.text_align {
1577 TextAlign::Center => 0.5,
1578 TextAlign::End | TextAlign::Right => 1.0,
1579 TextAlign::Start | TextAlign::Left | TextAlign::Justify | TextAlign::Unspecified => {
1584 if rtl {
1585 1.0
1586 } else {
1587 0.0
1588 }
1589 }
1590 }
1591}
1592
1593fn resolve_text_horizontal_offset(
1594 text_style: &TextStyle,
1595 text: &str,
1596 content_width: f32,
1597 measured_width: f32,
1598) -> f32 {
1599 let remaining = (content_width - measured_width).max(0.0);
1600 remaining * text_align_fraction(text_style, text)
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605 use std::cell::RefCell;
1606 use std::rc::Rc;
1607
1608 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1609 use cranpose_ui::text::{
1610 AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
1611 };
1612 use cranpose_ui::{
1613 Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
1614 LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
1615 ScrollState, Size, Spacer, Text, TextStyle,
1616 };
1617 use cranpose_ui_graphics::{
1618 Brush, DrawPrimitive, DrawScope as _, DrawScopeDefault, GraphicsLayer, RenderEffect,
1619 };
1620
1621 use super::*;
1622
1623 fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
1624 for child in &layer.children {
1625 match child {
1626 RenderNode::Primitive(primitive) => {
1627 let PrimitiveNode::Text(text) = &primitive.node else {
1628 continue;
1629 };
1630 if text.text.text == label {
1631 return Some(text.text_style.paragraph_style.text_motion);
1632 }
1633 }
1634 RenderNode::Layer(child_layer) => {
1635 if let Some(motion) = find_text_motion(child_layer, label) {
1636 return Some(motion);
1637 }
1638 }
1639 RenderNode::DrawRun(_) => {}
1640 }
1641 }
1642
1643 None
1644 }
1645
1646 fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
1647 for child in &layer.children {
1648 match child {
1649 RenderNode::Primitive(primitive) => {
1650 let PrimitiveNode::Text(text) = &primitive.node else {
1651 continue;
1652 };
1653 labels.push(text.text.text.clone());
1654 }
1655 RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
1656 RenderNode::DrawRun(_) => {}
1657 }
1658 }
1659 }
1660
1661 fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
1662 fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
1663 for child in &layer.children {
1664 match child {
1665 RenderNode::Primitive(primitive) => {
1666 let PrimitiveNode::Text(text) = &primitive.node else {
1667 continue;
1668 };
1669 if text.text.text == label {
1670 let quad = transform.map_rect(text.rect);
1671 let top = quad
1672 .iter()
1673 .map(|point| point[1])
1674 .fold(f32::INFINITY, f32::min);
1675 return top.is_finite().then_some(top);
1676 }
1677 }
1678 RenderNode::Layer(child_layer) => {
1679 let child_transform = child_layer.transform_to_parent.then(transform);
1680 if let Some(top) = search(child_layer, label, child_transform) {
1681 return Some(top);
1682 }
1683 }
1684 RenderNode::DrawRun(_) => {}
1685 }
1686 }
1687 None
1688 }
1689
1690 search(layer, label, ProjectiveTransform::identity())
1691 }
1692
1693 fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
1694 if layer.node_id == Some(node_id) {
1695 return Some(layer);
1696 }
1697 layer.children.iter().find_map(|child| match child {
1698 RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
1699 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1700 })
1701 }
1702
1703 fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
1704 fn search(
1705 layer: &LayerNode,
1706 node_id: NodeId,
1707 transform: ProjectiveTransform,
1708 ) -> Option<Point> {
1709 if layer.node_id == Some(node_id) {
1710 return Some(transform.map_point(Point::default()));
1711 }
1712 layer.children.iter().find_map(|child| match child {
1713 RenderNode::Layer(child_layer) => search(
1714 child_layer,
1715 node_id,
1716 child_layer.transform_to_parent.then(transform),
1717 ),
1718 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1719 })
1720 }
1721
1722 search(layer, node_id, ProjectiveTransform::identity())
1723 }
1724
1725 fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1726 if layer.translated_content_context {
1727 return Some(layer.translated_content_offset);
1728 }
1729 for child in &layer.children {
1730 if let RenderNode::Layer(child_layer) = child {
1731 if let Some(offset) = find_translated_content_offset(child_layer) {
1732 return Some(offset);
1733 }
1734 }
1735 }
1736 None
1737 }
1738
1739 fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1740 layer
1741 .graphics_layer
1742 .render_effect
1743 .as_ref()
1744 .is_some_and(RenderEffect::contains_runtime_shader)
1745 || layer.children.iter().any(|child| match child {
1746 RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1747 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1748 })
1749 }
1750
1751 fn build_layer_node_for_test(
1752 snapshot: BuildNodeSnapshot,
1753 scale: f32,
1754 has_external_backdrop_input: bool,
1755 ) -> LayerNode {
1756 let app_context = cranpose_ui::AppContext::new();
1757 app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1758 }
1759
1760 fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1761 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1762 scope.push_recorded(vec![DrawPrimitive::Rect {
1763 rect: Rect {
1764 x: 3.0,
1765 y: 4.0,
1766 width: 20.0,
1767 height: 8.0,
1768 },
1769 brush: Brush::solid(Color::WHITE),
1770 stroke: None,
1771 }]);
1772 }));
1773
1774 let child = BuildNodeSnapshot {
1775 node_id: 2,
1776 placement: Point { x: 11.0, y: 7.0 },
1777 size: Size {
1778 width: 40.0,
1779 height: 20.0,
1780 },
1781 content_offset: Point::default(),
1782 motion_context_animated: false,
1783 translated_content_context: false,
1784 measured_max_width: None,
1785 resolved_modifiers: ResolvedModifiers::default(),
1786 draw_commands: vec![child_command],
1787 click_actions: vec![],
1788 pointer_inputs: vec![],
1789 clip_to_bounds: false,
1790 annotated_text: None,
1791 text_style: None,
1792 text_layout_options: None,
1793 text_pan: None,
1794 graphics_layer: None,
1795 children: vec![],
1796 };
1797
1798 BuildNodeSnapshot {
1799 node_id: 1,
1800 placement: Point::default(),
1801 size: Size {
1802 width: 80.0,
1803 height: 50.0,
1804 },
1805 content_offset: Point::default(),
1806 motion_context_animated: false,
1807 translated_content_context: false,
1808 measured_max_width: None,
1809 resolved_modifiers: ResolvedModifiers::default(),
1810 draw_commands: vec![],
1811 click_actions: vec![],
1812 pointer_inputs: vec![],
1813 clip_to_bounds: false,
1814 annotated_text: None,
1815 text_style: None,
1816 text_layout_options: None,
1817 text_pan: None,
1818 graphics_layer: Some(GraphicsLayer {
1819 translation_x: tx,
1820 ..GraphicsLayer::default()
1821 }),
1822 children: vec![child],
1823 }
1824 }
1825
1826 #[test]
1827 fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1828 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1829 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1830
1831 let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1832 panic!("expected child layer");
1833 };
1834 let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1835 panic!("expected child layer");
1836 };
1837 let RenderNode::DrawRun(static_run) = &static_child.children[0] else {
1838 panic!("expected draw run");
1839 };
1840 let static_draw = &static_run.primitives[0];
1841 let RenderNode::DrawRun(moved_run) = &moved_child.children[0] else {
1842 panic!("expected draw run");
1843 };
1844 let moved_draw = &moved_run.primitives[0];
1845
1846 assert_ne!(
1847 static_graph.transform_to_parent, moved_graph.transform_to_parent,
1848 "parent transform should encode translation"
1849 );
1850 assert_eq!(
1851 static_draw, moved_draw,
1852 "child local primitive geometry must stay stable under parent translation"
1853 );
1854 }
1855
1856 #[test]
1857 fn stored_content_hash_ignores_parent_translation() {
1858 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1859 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1860
1861 assert_eq!(
1862 static_graph.target_content_hash(),
1863 moved_graph.target_content_hash(),
1864 "parent rigid motion must not invalidate the subtree content hash"
1865 );
1866 }
1867
1868 #[test]
1869 fn parent_content_offset_is_encoded_in_child_transform() {
1870 let child = BuildNodeSnapshot {
1871 node_id: 2,
1872 placement: Point { x: 11.0, y: 7.0 },
1873 size: Size {
1874 width: 40.0,
1875 height: 20.0,
1876 },
1877 content_offset: Point::default(),
1878 motion_context_animated: false,
1879 translated_content_context: false,
1880 measured_max_width: None,
1881 resolved_modifiers: ResolvedModifiers::default(),
1882 draw_commands: vec![],
1883 click_actions: vec![],
1884 pointer_inputs: vec![],
1885 clip_to_bounds: false,
1886 annotated_text: None,
1887 text_style: None,
1888 text_layout_options: None,
1889 text_pan: None,
1890 graphics_layer: None,
1891 children: vec![],
1892 };
1893
1894 let parent = BuildNodeSnapshot {
1895 node_id: 1,
1896 placement: Point::default(),
1897 size: Size {
1898 width: 80.0,
1899 height: 50.0,
1900 },
1901 content_offset: Point { x: 13.0, y: -9.0 },
1902 motion_context_animated: false,
1903 translated_content_context: false,
1904 measured_max_width: None,
1905 resolved_modifiers: ResolvedModifiers::default(),
1906 draw_commands: vec![],
1907 click_actions: vec![],
1908 pointer_inputs: vec![],
1909 clip_to_bounds: false,
1910 annotated_text: None,
1911 text_style: None,
1912 text_layout_options: None,
1913 text_pan: None,
1914 graphics_layer: None,
1915 children: vec![child],
1916 };
1917
1918 let graph = build_layer_node_for_test(parent, 1.0, false);
1919 let RenderNode::Layer(child) = &graph.children[0] else {
1920 panic!("expected child layer");
1921 };
1922
1923 let top_left = child.transform_to_parent.map_point(Point::default());
1924 assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1925 }
1926
1927 #[test]
1928 fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1929 fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1930 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1931 scope.push_recorded(vec![DrawPrimitive::Rect {
1932 rect: Rect {
1933 x: 3.0,
1934 y: 4.0,
1935 width: 20.0,
1936 height: 8.0,
1937 },
1938 brush: Brush::solid(Color::WHITE),
1939 stroke: None,
1940 }]);
1941 }));
1942
1943 let child = BuildNodeSnapshot {
1944 node_id: 2,
1945 placement: Point { x: 11.0, y: 7.0 },
1946 size: Size {
1947 width: 40.0,
1948 height: 20.0,
1949 },
1950 content_offset: Point::default(),
1951 motion_context_animated: false,
1952 translated_content_context: false,
1953 measured_max_width: None,
1954 resolved_modifiers: ResolvedModifiers::default(),
1955 draw_commands: vec![child_command],
1956 click_actions: vec![],
1957 pointer_inputs: vec![],
1958 clip_to_bounds: false,
1959 annotated_text: None,
1960 text_style: None,
1961 text_layout_options: None,
1962 text_pan: None,
1963 graphics_layer: None,
1964 children: vec![],
1965 };
1966
1967 BuildNodeSnapshot {
1968 node_id: 1,
1969 placement: Point::default(),
1970 size: Size {
1971 width: 80.0,
1972 height: 50.0,
1973 },
1974 content_offset: offset,
1975 motion_context_animated,
1976 translated_content_context: true,
1977 measured_max_width: None,
1978 resolved_modifiers: ResolvedModifiers::default(),
1979 draw_commands: vec![],
1980 click_actions: vec![],
1981 pointer_inputs: vec![],
1982 clip_to_bounds: false,
1983 annotated_text: None,
1984 text_style: None,
1985 text_layout_options: None,
1986 text_pan: None,
1987 graphics_layer: None,
1988 children: vec![child],
1989 }
1990 }
1991
1992 let base = build_layer_node_for_test(
1993 parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
1994 1.0,
1995 false,
1996 );
1997 let moved = build_layer_node_for_test(
1998 parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
1999 1.0,
2000 false,
2001 );
2002 let rested = build_layer_node_for_test(
2003 parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
2004 1.0,
2005 false,
2006 );
2007
2008 let RenderNode::Layer(base_child) = &base.children[0] else {
2009 panic!("expected child layer");
2010 };
2011 let RenderNode::Layer(moved_child) = &moved.children[0] else {
2012 panic!("expected child layer");
2013 };
2014
2015 assert_ne!(
2016 base_child.transform_to_parent.map_point(Point::default()),
2017 moved_child.transform_to_parent.map_point(Point::default()),
2018 "scroll offset still has to move child content visually"
2019 );
2020 assert_eq!(
2021 base_child.target_content_hash(),
2022 moved_child.target_content_hash(),
2023 "child source content identity stays stable when only the parent scroll offset changes"
2024 );
2025 assert_ne!(
2026 base.target_content_hash(),
2027 moved.target_content_hash(),
2028 "a full-surface cache of the scroll viewport must include the scroll offset"
2029 );
2030 assert_ne!(
2031 base.target_content_hash(),
2032 rested.target_content_hash(),
2033 "full-surface cache keys must include active scroll motion policy"
2034 );
2035 }
2036
2037 #[test]
2038 fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
2039 let layer = graphics_layer_with_shaped_clip(
2040 GraphicsLayer::default(),
2041 true,
2042 Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
2043 Rect {
2044 x: 0.0,
2045 y: 0.0,
2046 width: 100.0,
2047 height: 40.0,
2048 },
2049 );
2050
2051 assert!(layer.clip);
2052 assert!(layer.render_effect.is_none());
2053 let LayerShape::Rounded(shape) = layer.shape else {
2054 panic!("rounded clip must be recorded as layer shape");
2055 };
2056 assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
2057 assert!(isolation_reasons(&layer).shape_clip);
2058 }
2059
2060 #[test]
2061 fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
2062 let existing = RenderEffect::blur(3.0);
2063 let layer = graphics_layer_with_shaped_clip(
2064 GraphicsLayer {
2065 render_effect: Some(existing.clone()),
2066 ..GraphicsLayer::default()
2067 },
2068 true,
2069 Some(RoundedCornerShape::uniform(10.0)),
2070 Rect {
2071 x: 0.0,
2072 y: 0.0,
2073 width: 100.0,
2074 height: 40.0,
2075 },
2076 );
2077
2078 let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
2079 panic!("existing effect should chain into rounded clip mask");
2080 };
2081 assert_eq!(*first, existing);
2082 assert!(
2083 matches!(*second, RenderEffect::Shader { .. }),
2084 "rounded mask must be the outer effect"
2085 );
2086 }
2087
2088 #[test]
2089 fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
2090 let mut composition = cranpose_ui::run_test_composition(|| {
2091 cranpose_ui::Box(
2092 Modifier::empty()
2093 .width(100.0)
2094 .height(40.0)
2095 .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
2096 .clip_to_bounds(),
2097 cranpose_ui::BoxSpec::default(),
2098 || {
2099 Text("rounded child", Modifier::empty(), TextStyle::default());
2100 },
2101 );
2102 });
2103
2104 let root = composition.root().expect("rounded clip root");
2105 let handle = composition.runtime_handle();
2106 let mut applier = composition.applier_mut();
2107 applier.set_runtime_handle(handle);
2108 applier
2109 .compute_layout(
2110 root,
2111 Size {
2112 width: 160.0,
2113 height: 100.0,
2114 },
2115 )
2116 .expect("rounded clip layout");
2117 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
2118 applier.clear_runtime_handle();
2119
2120 let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
2121 assert!(rounded_layer.graphics_layer.clip);
2122 assert!(matches!(
2123 rounded_layer.graphics_layer.shape,
2124 LayerShape::Rounded(_)
2125 ));
2126 assert!(rounded_layer.graphics_layer.render_effect.is_none());
2127 assert!(rounded_layer.isolation.shape_clip);
2128 assert!(
2129 !graph_has_runtime_shader_effect(&graph.root),
2130 "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
2131 );
2132 }
2133
2134 #[test]
2135 fn update_graph_from_applier_replaces_dirty_child_layer() {
2136 let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2137 Rc::new(RefCell::new(None));
2138 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2139 let state_holder_for_comp = state_holder.clone();
2140 let child_id_holder_for_comp = child_id_holder.clone();
2141
2142 let mut composition = cranpose_ui::run_test_composition(move || {
2143 let label = cranpose_core::useState(|| "before".to_string());
2144 *state_holder_for_comp.borrow_mut() = Some(label);
2145 let child_id_holder_for_content = child_id_holder_for_comp.clone();
2146 cranpose_ui::Box(
2147 Modifier::empty().size_points(240.0, 80.0),
2148 cranpose_ui::BoxSpec::default(),
2149 move || {
2150 let child_id = Text(label, Modifier::empty(), TextStyle::default());
2151 *child_id_holder_for_content.borrow_mut() = Some(child_id);
2152 Text("stable", Modifier::empty(), TextStyle::default());
2153 },
2154 );
2155 });
2156
2157 let root = composition.root().expect("composition root");
2158 let viewport = Size {
2159 width: 240.0,
2160 height: 80.0,
2161 };
2162 let handle = composition.runtime_handle();
2163 let mut applier = composition.applier_mut();
2164 applier.set_runtime_handle(handle);
2165 applier
2166 .compute_layout(root, viewport)
2167 .expect("initial layout");
2168 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2169 let child_id = child_id_holder
2170 .borrow()
2171 .expect("text child id should be captured");
2172 let initial_transform = find_layer_by_node_id(&graph.root, child_id)
2173 .expect("text child layer")
2174 .transform_to_parent;
2175 applier.clear_runtime_handle();
2176 drop(applier);
2177
2178 let label = state_holder
2179 .borrow()
2180 .as_ref()
2181 .copied()
2182 .expect("label state should be captured");
2183 label.set_value("after".to_string());
2184 composition
2185 .process_invalid_scopes()
2186 .expect("text recomposition");
2187
2188 let handle = composition.runtime_handle();
2189 let mut applier = composition.applier_mut();
2190 applier.set_runtime_handle(handle);
2191 applier
2192 .compute_layout(root, viewport)
2193 .expect("updated layout");
2194 let child_id = child_id_holder
2195 .borrow()
2196 .expect("text child id should remain captured");
2197
2198 assert!(
2199 update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
2200 "dirty child should be replaceable from retained applier state"
2201 );
2202 applier.clear_runtime_handle();
2203
2204 let mut labels = Vec::new();
2205 collect_text_labels(&graph.root, &mut labels);
2206 assert!(
2207 labels.iter().any(|label| label == "after"),
2208 "updated graph should contain refreshed child text, got {labels:?}"
2209 );
2210 assert!(
2211 !labels.iter().any(|label| label == "before"),
2212 "updated graph should not retain stale child text, got {labels:?}"
2213 );
2214 assert!(
2215 labels.iter().any(|label| label == "stable"),
2216 "sibling content should remain present, got {labels:?}"
2217 );
2218 assert_eq!(
2219 find_layer_by_node_id(&graph.root, child_id)
2220 .expect("updated text child layer")
2221 .transform_to_parent,
2222 initial_transform,
2223 "draw-only child replacement must preserve the retained parent placement transform"
2224 );
2225 }
2226
2227 #[test]
2235 fn scene_build_publishes_live_window_rect_without_layout_tree() {
2236 use cranpose_ui::{measure_layout_with_options, Box, BoxSpec, MeasureLayoutOptions};
2237 use std::cell::Cell;
2238
2239 let spacer_before = 120.0_f32;
2240 let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
2241 x: 0.0,
2242 y: 0.0,
2243 width: 0.0,
2244 height: 0.0,
2245 }));
2246 let sink_for_comp = sink.clone();
2247 let mut composition = cranpose_ui::run_test_composition(move || {
2248 let sink = sink_for_comp.clone();
2249 Column(
2250 Modifier::empty().size_points(200.0, 400.0),
2251 ColumnSpec::default(),
2252 move || {
2253 Spacer(Size {
2254 width: 200.0,
2255 height: spacer_before,
2256 });
2257 Box(
2258 Modifier::empty()
2259 .size_points(200.0, 50.0)
2260 .report_window_rect(sink.clone()),
2261 BoxSpec::default(),
2262 || {},
2263 );
2264 },
2265 );
2266 });
2267
2268 let root = composition.root().expect("composition root");
2269 let viewport = Size {
2270 width: 200.0,
2271 height: 400.0,
2272 };
2273 let handle = composition.runtime_handle();
2274 let mut applier = composition.applier_mut();
2275 applier.set_runtime_handle(handle);
2276 measure_layout_with_options(
2279 &mut applier,
2280 root,
2281 viewport,
2282 MeasureLayoutOptions {
2283 collect_semantics: false,
2284 build_layout_tree: false,
2285 },
2286 )
2287 .expect("layout");
2288 assert_eq!(
2290 sink.get().height,
2291 0.0,
2292 "sink must start empty (place disabled)"
2293 );
2294
2295 let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
2296 applier.clear_runtime_handle();
2297
2298 let rect = sink.get();
2299 assert!(
2300 (rect.y - spacer_before).abs() < 0.5,
2301 "scene build must publish the box's live window-y (below the {spacer_before}px \
2302 spacer), got {}",
2303 rect.y
2304 );
2305 assert!(
2306 rect.width > 0.0 && rect.height > 0.0,
2307 "scene build must publish a non-empty window rect, got {rect:?}"
2308 );
2309 }
2310
2311 #[test]
2312 fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
2313 let mut graph = RenderGraph {
2314 root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
2315 };
2316 let mut applier = MemoryApplier::new();
2317
2318 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
2319
2320 assert_eq!(
2321 report,
2322 GraphUpdateReport {
2323 applied: false,
2324 hit_graph_dirty: true,
2325 },
2326 "dirty child graph updates must not report success when the replacement cannot be rebuilt"
2327 );
2328 }
2329
2330 #[test]
2331 fn update_graph_from_applier_refreshes_scroll_content_offset() {
2332 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2333 let scroll_holder_for_comp = scroll_holder.clone();
2334
2335 let mut composition = cranpose_ui::run_test_composition(move || {
2336 let scroll_state =
2337 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2338 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2339 Column(
2340 Modifier::empty()
2341 .size_points(240.0, 120.0)
2342 .vertical_scroll(scroll_state, false),
2343 ColumnSpec::default(),
2344 || {
2345 Text("scroll top", Modifier::empty(), TextStyle::default());
2346 Spacer(Size {
2347 width: 0.0,
2348 height: 160.0,
2349 });
2350 Text("scroll target", Modifier::empty(), TextStyle::default());
2351 },
2352 );
2353 });
2354
2355 let root = composition.root().expect("composition root");
2356 let viewport = Size {
2357 width: 240.0,
2358 height: 120.0,
2359 };
2360 let handle = composition.runtime_handle();
2361 let mut applier = composition.applier_mut();
2362 applier.set_runtime_handle(handle);
2363 applier
2364 .compute_layout(root, viewport)
2365 .expect("initial scroll layout");
2366 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2367 let initial_target_top =
2368 find_text_top(&graph.root, "scroll target").expect("initial target text");
2369 applier.clear_runtime_handle();
2370 drop(applier);
2371
2372 let scroll_state = scroll_holder
2373 .borrow()
2374 .as_ref()
2375 .cloned()
2376 .expect("scroll state should be captured");
2377 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
2378 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
2379 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
2380 assert!(
2381 !dirty_nodes.is_empty(),
2382 "scroll state invalidation must schedule scoped layout graph update"
2383 );
2384
2385 let handle = composition.runtime_handle();
2386 let mut applier = composition.applier_mut();
2387 applier.set_runtime_handle(handle);
2388 applier
2389 .compute_layout(root, viewport)
2390 .expect("scrolled layout");
2391 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
2392 applier.clear_runtime_handle();
2393
2394 assert!(report.applied, "scroll graph update should apply in place");
2395 let updated_target_top =
2396 find_text_top(&graph.root, "scroll target").expect("updated target text");
2397 assert!(
2398 updated_target_top < initial_target_top - consumed_scroll * 0.75,
2399 "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
2400 );
2401 }
2402
2403 #[test]
2404 fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
2405 let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2406 Rc::new(RefCell::new(None));
2407 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2408 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2409 let label_holder_for_comp = label_holder.clone();
2410 let scroll_holder_for_comp = scroll_holder.clone();
2411 let child_id_holder_for_comp = child_id_holder.clone();
2412
2413 let mut composition = cranpose_ui::run_test_composition(move || {
2414 let label = cranpose_core::useState(|| "scrolled child before".to_string());
2415 let scroll_state =
2416 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2417 *label_holder_for_comp.borrow_mut() = Some(label);
2418 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2419 let child_id_holder_for_content = child_id_holder_for_comp.clone();
2420 Column(
2421 Modifier::empty()
2422 .size_points(260.0, 90.0)
2423 .vertical_scroll(scroll_state, false),
2424 ColumnSpec::default(),
2425 move || {
2426 Spacer(Size {
2427 width: 0.0,
2428 height: 24.0,
2429 });
2430 let child_id = Text(label, Modifier::empty(), TextStyle::default());
2431 *child_id_holder_for_content.borrow_mut() = Some(child_id);
2432 Spacer(Size {
2433 width: 0.0,
2434 height: 220.0,
2435 });
2436 },
2437 );
2438 });
2439
2440 let root = composition.root().expect("composition root");
2441 let viewport = Size {
2442 width: 260.0,
2443 height: 90.0,
2444 };
2445 let handle = composition.runtime_handle();
2446 let mut applier = composition.applier_mut();
2447 applier.set_runtime_handle(handle);
2448 applier
2449 .compute_layout(root, viewport)
2450 .expect("initial layout");
2451 applier.clear_runtime_handle();
2452 drop(applier);
2453
2454 let scroll_state = scroll_holder
2455 .borrow()
2456 .as_ref()
2457 .cloned()
2458 .expect("scroll state should be captured");
2459 assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
2460
2461 let handle = composition.runtime_handle();
2462 let mut applier = composition.applier_mut();
2463 applier.set_runtime_handle(handle);
2464 applier
2465 .compute_layout(root, viewport)
2466 .expect("scrolled layout");
2467 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2468 let child_id = child_id_holder
2469 .borrow()
2470 .expect("text child id should be captured");
2471 let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
2472 .expect("scrolled child layer")
2473 .transform_to_parent;
2474 applier.clear_runtime_handle();
2475 drop(applier);
2476
2477 let label = label_holder
2478 .borrow()
2479 .as_ref()
2480 .copied()
2481 .expect("label state should be captured");
2482 label.set_value("scrolled child after".to_string());
2483 composition
2484 .process_invalid_scopes()
2485 .expect("text recomposition");
2486
2487 let handle = composition.runtime_handle();
2488 let mut applier = composition.applier_mut();
2489 applier.set_runtime_handle(handle);
2490 applier
2491 .compute_layout(root, viewport)
2492 .expect("updated scrolled layout");
2493 let child_id = child_id_holder
2494 .borrow()
2495 .expect("text child id should remain captured");
2496 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
2497 applier.clear_runtime_handle();
2498
2499 assert!(report.applied, "dirty child graph update should apply");
2500 let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
2501 assert_eq!(
2502 updated.transform_to_parent, scrolled_transform,
2503 "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
2504 );
2505 let mut labels = Vec::new();
2506 collect_text_labels(&graph.root, &mut labels);
2507 assert!(
2508 labels.iter().any(|label| label == "scrolled child after"),
2509 "updated graph should contain refreshed text, got {labels:?}"
2510 );
2511 }
2512
2513 #[test]
2514 fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
2515 let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2516 Rc::new(RefCell::new(None));
2517 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2518 let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2519 let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2520 let alpha_holder_for_comp = alpha_holder.clone();
2521 let scroll_holder_for_comp = scroll_holder.clone();
2522 let underlay_id_holder_for_comp = underlay_id_holder.clone();
2523 let overlay_id_holder_for_comp = overlay_id_holder.clone();
2524
2525 let mut composition = cranpose_ui::run_test_composition(move || {
2526 let alpha = cranpose_core::useState(|| 1.0f32);
2527 let scroll_state =
2528 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2529 *alpha_holder_for_comp.borrow_mut() = Some(alpha);
2530 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2531 let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
2532 let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
2533 Column(
2534 Modifier::empty()
2535 .size_points(260.0, 120.0)
2536 .vertical_scroll(scroll_state, false),
2537 ColumnSpec::default(),
2538 move || {
2539 Spacer(Size {
2540 width: 0.0,
2541 height: 180.0,
2542 });
2543 cranpose_ui::Box(
2544 Modifier::empty().size_points(188.0, 88.0),
2545 cranpose_ui::BoxSpec::default(),
2546 {
2547 let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
2548 let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
2549 move || {
2550 let underlay_id = cranpose_ui::Box(
2551 Modifier::empty().size_points(188.0, 88.0),
2552 cranpose_ui::BoxSpec::default(),
2553 || {
2554 Text(
2555 "UNDERLAY CONTENT",
2556 Modifier::empty().absolute_offset(12.0, 8.0),
2557 TextStyle::default(),
2558 );
2559 },
2560 );
2561 *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
2562 let overlay_id = cranpose_ui::Box(
2563 Modifier::empty().size_points(188.0, 88.0).graphics_layer(
2564 move || GraphicsLayer {
2565 alpha: alpha.get(),
2566 ..GraphicsLayer::default()
2567 },
2568 ),
2569 cranpose_ui::BoxSpec::default(),
2570 || {
2571 Text(
2572 "TOP LAYER",
2573 Modifier::empty().absolute_offset(74.0, 39.6),
2574 TextStyle::default(),
2575 );
2576 },
2577 );
2578 *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
2579 }
2580 },
2581 );
2582 Spacer(Size {
2583 width: 0.0,
2584 height: 280.0,
2585 });
2586 },
2587 );
2588 });
2589
2590 let root = composition.root().expect("composition root");
2591 let viewport = Size {
2592 width: 260.0,
2593 height: 120.0,
2594 };
2595 let handle = composition.runtime_handle();
2596 let mut applier = composition.applier_mut();
2597 applier.set_runtime_handle(handle);
2598 applier
2599 .compute_layout(root, viewport)
2600 .expect("initial layout");
2601 applier.clear_runtime_handle();
2602 drop(applier);
2603
2604 let scroll_state = scroll_holder
2605 .borrow()
2606 .as_ref()
2607 .cloned()
2608 .expect("scroll state should be captured");
2609 assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
2610
2611 let handle = composition.runtime_handle();
2612 let mut applier = composition.applier_mut();
2613 applier.set_runtime_handle(handle);
2614 applier
2615 .compute_layout(root, viewport)
2616 .expect("scrolled layout");
2617 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2618 applier.clear_runtime_handle();
2619 drop(applier);
2620
2621 let underlay_id = underlay_id_holder
2622 .borrow()
2623 .expect("underlay id should be captured");
2624 let overlay_id = overlay_id_holder
2625 .borrow()
2626 .expect("overlay id should be captured");
2627 let scrolled_underlay_origin =
2628 find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
2629 let scrolled_overlay_origin =
2630 find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
2631 assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
2632
2633 let alpha = alpha_holder
2634 .borrow()
2635 .as_ref()
2636 .copied()
2637 .expect("alpha state should be captured");
2638 alpha.set_value(0.35);
2639
2640 let handle = composition.runtime_handle();
2641 let mut applier = composition.applier_mut();
2642 applier.set_runtime_handle(handle);
2643 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
2644 applier.clear_runtime_handle();
2645
2646 assert!(report.applied, "dirty overlay graph update should apply");
2647 let updated_underlay_origin =
2648 find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
2649 let updated_overlay_origin =
2650 find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
2651 assert_eq!(
2652 updated_underlay_origin, scrolled_underlay_origin,
2653 "stable underlay must keep its scrolled origin"
2654 );
2655 assert_eq!(
2656 updated_overlay_origin, updated_underlay_origin,
2657 "dirty overlay graphics layer must stay aligned with its stable underlay"
2658 );
2659 }
2660
2661 #[test]
2662 fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
2663 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2664 Rc::new(RefCell::new(None));
2665 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2666 let offset_holder_for_comp = offset_holder.clone();
2667 let node_id_holder_for_comp = node_id_holder.clone();
2668
2669 let mut composition = cranpose_ui::run_test_composition(move || {
2670 let offset = cranpose_core::useState(|| 0.0f32);
2671 *offset_holder_for_comp.borrow_mut() = Some(offset);
2672 let node_id = cranpose_ui::Box(
2673 Modifier::empty()
2674 .size_points(40.0, 20.0)
2675 .graphics_layer(move || GraphicsLayer {
2676 translation_x: offset.get(),
2677 ..GraphicsLayer::default()
2678 }),
2679 cranpose_ui::BoxSpec::default(),
2680 || {},
2681 );
2682 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2683 });
2684
2685 let root = composition.root().expect("composition root");
2686 let viewport = Size {
2687 width: 120.0,
2688 height: 80.0,
2689 };
2690 let handle = composition.runtime_handle();
2691 let mut applier = composition.applier_mut();
2692 applier.set_runtime_handle(handle);
2693 applier
2694 .compute_layout(root, viewport)
2695 .expect("initial layout");
2696 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2697 let node_id = node_id_holder
2698 .borrow()
2699 .expect("graphics layer node id should be captured");
2700 let initial_origin = find_layer_by_node_id(&graph.root, node_id)
2701 .expect("initial graphics layer")
2702 .transform_to_parent
2703 .map_point(Point::default());
2704 applier.clear_runtime_handle();
2705 drop(applier);
2706
2707 let offset = offset_holder
2708 .borrow()
2709 .as_ref()
2710 .copied()
2711 .expect("offset state should be captured");
2712 offset.set_value(32.0);
2713
2714 let handle = composition.runtime_handle();
2715 let mut applier = composition.applier_mut();
2716 applier.set_runtime_handle(handle);
2717 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2718 assert!(
2719 report.applied,
2720 "dirty graphics layer should be replaceable from retained applier state"
2721 );
2722 assert!(
2723 !report.hit_graph_dirty,
2724 "a moved visual-only layer should not force hit graph refresh"
2725 );
2726 applier.clear_runtime_handle();
2727
2728 let updated_origin = find_layer_by_node_id(&graph.root, node_id)
2729 .expect("updated graphics layer")
2730 .transform_to_parent
2731 .map_point(Point::default());
2732 assert!(
2733 (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
2734 "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
2735 );
2736 }
2737
2738 #[test]
2739 fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
2740 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2741 Rc::new(RefCell::new(None));
2742 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2743 let offset_holder_for_comp = offset_holder.clone();
2744 let node_id_holder_for_comp = node_id_holder.clone();
2745
2746 let mut composition = cranpose_ui::run_test_composition(move || {
2747 let offset = cranpose_core::useState(|| 0.0f32);
2748 *offset_holder_for_comp.borrow_mut() = Some(offset);
2749 let node_id = cranpose_ui::Box(
2750 Modifier::empty()
2751 .size_points(40.0, 20.0)
2752 .graphics_layer(move || GraphicsLayer {
2753 translation_x: offset.get(),
2754 ..GraphicsLayer::default()
2755 })
2756 .clickable(|_| {}),
2757 cranpose_ui::BoxSpec::default(),
2758 || {},
2759 );
2760 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2761 });
2762
2763 let root = composition.root().expect("composition root");
2764 let viewport = Size {
2765 width: 120.0,
2766 height: 80.0,
2767 };
2768 let handle = composition.runtime_handle();
2769 let mut applier = composition.applier_mut();
2770 applier.set_runtime_handle(handle);
2771 applier
2772 .compute_layout(root, viewport)
2773 .expect("initial layout");
2774 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2775 let node_id = node_id_holder
2776 .borrow()
2777 .expect("graphics layer node id should be captured");
2778 applier.clear_runtime_handle();
2779 drop(applier);
2780
2781 let offset = offset_holder
2782 .borrow()
2783 .as_ref()
2784 .copied()
2785 .expect("offset state should be captured");
2786 offset.set_value(32.0);
2787
2788 let handle = composition.runtime_handle();
2789 let mut applier = composition.applier_mut();
2790 applier.set_runtime_handle(handle);
2791 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2792 applier.clear_runtime_handle();
2793
2794 assert!(
2795 report.applied,
2796 "dirty clickable graphics layer should be replaceable from retained applier state"
2797 );
2798 assert!(
2799 report.hit_graph_dirty,
2800 "moved clickable layers must refresh hit geometry"
2801 );
2802 }
2803
2804 #[test]
2805 fn overlay_draw_commands_are_tagged_after_children() {
2806 let child = BuildNodeSnapshot {
2807 node_id: 2,
2808 placement: Point { x: 4.0, y: 5.0 },
2809 size: Size {
2810 width: 20.0,
2811 height: 10.0,
2812 },
2813 content_offset: Point::default(),
2814 motion_context_animated: false,
2815 translated_content_context: false,
2816 measured_max_width: None,
2817 resolved_modifiers: ResolvedModifiers::default(),
2818 draw_commands: vec![],
2819 click_actions: vec![],
2820 pointer_inputs: vec![],
2821 clip_to_bounds: false,
2822 annotated_text: None,
2823 text_style: None,
2824 text_layout_options: None,
2825 text_pan: None,
2826 graphics_layer: None,
2827 children: vec![],
2828 };
2829 let behind = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
2830 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2831 rect: Rect {
2832 x: 1.0,
2833 y: 2.0,
2834 width: 8.0,
2835 height: 6.0,
2836 },
2837 brush: Brush::solid(Color::WHITE),
2838 stroke: None,
2839 }]);
2840 }));
2841 let overlay = DrawCommand::Overlay(Rc::new(|scope: &mut DrawScopeDefault| {
2842 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2843 rect: Rect {
2844 x: 3.0,
2845 y: 1.0,
2846 width: 5.0,
2847 height: 4.0,
2848 },
2849 brush: Brush::solid(Color::BLACK),
2850 stroke: None,
2851 }]);
2852 }));
2853
2854 let parent = BuildNodeSnapshot {
2855 node_id: 1,
2856 placement: Point::default(),
2857 size: Size {
2858 width: 80.0,
2859 height: 50.0,
2860 },
2861 content_offset: Point::default(),
2862 motion_context_animated: false,
2863 translated_content_context: false,
2864 measured_max_width: None,
2865 resolved_modifiers: ResolvedModifiers::default(),
2866 draw_commands: vec![behind, overlay],
2867 click_actions: vec![],
2868 pointer_inputs: vec![],
2869 clip_to_bounds: false,
2870 annotated_text: None,
2871 text_style: None,
2872 text_layout_options: None,
2873 text_pan: None,
2874 graphics_layer: None,
2875 children: vec![child],
2876 };
2877
2878 let graph = build_layer_node_for_test(parent, 1.0, false);
2879 let RenderNode::DrawRun(behind) = &graph.children[0] else {
2880 panic!("expected before-children draw run");
2881 };
2882 let RenderNode::Layer(_) = &graph.children[1] else {
2883 panic!("expected child layer");
2884 };
2885 let RenderNode::DrawRun(overlay) = &graph.children[2] else {
2886 panic!("expected after-children draw run");
2887 };
2888
2889 assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
2890 assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
2891 }
2892
2893 #[test]
2897 fn command_recordings_reuse_buffers_across_rebuilds() {
2898 let snapshot = || BuildNodeSnapshot {
2899 node_id: 7001,
2900 placement: Point::default(),
2901 size: Size {
2902 width: 40.0,
2903 height: 20.0,
2904 },
2905 content_offset: Point::default(),
2906 motion_context_animated: false,
2907 translated_content_context: false,
2908 measured_max_width: None,
2909 resolved_modifiers: ResolvedModifiers::default(),
2910 draw_commands: vec![DrawCommand::Behind(Rc::new(
2911 |scope: &mut DrawScopeDefault| {
2912 scope.draw_rect_at(
2913 Rect {
2914 x: 1.0,
2915 y: 2.0,
2916 width: 8.0,
2917 height: 6.0,
2918 },
2919 Brush::solid(Color::WHITE),
2920 );
2921 },
2922 ))],
2923 click_actions: vec![],
2924 pointer_inputs: vec![],
2925 clip_to_bounds: false,
2926 annotated_text: None,
2927 text_style: None,
2928 text_layout_options: None,
2929 text_pan: None,
2930 graphics_layer: None,
2931 children: vec![],
2932 };
2933 fn run_of(layer: &LayerNode) -> &DrawRunNode {
2934 let RenderNode::DrawRun(run) = &layer.children[0] else {
2935 panic!("expected draw run");
2936 };
2937 run
2938 }
2939
2940 let graph_a = build_layer_node_for_test(snapshot(), 1.0, false);
2941 let ptr_a = run_of(&graph_a).primitives.as_ptr();
2942
2943 let graph_b = build_layer_node_for_test(snapshot(), 1.0, false);
2945 let ptr_b = run_of(&graph_b).primitives.as_ptr();
2946 assert_ne!(
2947 ptr_a, ptr_b,
2948 "a buffer a live graph shares must never be recorded into"
2949 );
2950 assert_eq!(
2951 run_of(&graph_a).primitives,
2952 run_of(&graph_b).primitives,
2953 "re-recording must reproduce the recording"
2954 );
2955
2956 drop(graph_a);
2960 let graph_c = build_layer_node_for_test(snapshot(), 1.0, false);
2961 assert_eq!(
2962 run_of(&graph_c).primitives.as_ptr(),
2963 ptr_a,
2964 "the released buffer must be reused for the next recording"
2965 );
2966
2967 let held = std::rc::Rc::clone(&run_of(&graph_c).primitives);
2970 drop(graph_c);
2971 let graph_d = build_layer_node_for_test(snapshot(), 1.0, false);
2972 let ptr_d = run_of(&graph_d).primitives.as_ptr();
2973 assert_ne!(ptr_d, held.as_ptr());
2974 assert_ne!(ptr_d, run_of(&graph_b).primitives.as_ptr());
2975 }
2976
2977 #[test]
2978 fn stored_content_hash_changes_when_child_transform_changes() {
2979 let child = BuildNodeSnapshot {
2980 node_id: 2,
2981 placement: Point { x: 4.0, y: 5.0 },
2982 size: Size {
2983 width: 20.0,
2984 height: 10.0,
2985 },
2986 content_offset: Point::default(),
2987 motion_context_animated: false,
2988 translated_content_context: false,
2989 measured_max_width: None,
2990 resolved_modifiers: ResolvedModifiers::default(),
2991 draw_commands: vec![],
2992 click_actions: vec![],
2993 pointer_inputs: vec![],
2994 clip_to_bounds: false,
2995 annotated_text: None,
2996 text_style: None,
2997 text_layout_options: None,
2998 text_pan: None,
2999 graphics_layer: None,
3000 children: vec![],
3001 };
3002 let mut moved_child = child.clone();
3003 moved_child.placement.x += 7.0;
3004
3005 let parent = BuildNodeSnapshot {
3006 node_id: 1,
3007 placement: Point::default(),
3008 size: Size {
3009 width: 80.0,
3010 height: 50.0,
3011 },
3012 content_offset: Point::default(),
3013 motion_context_animated: false,
3014 translated_content_context: false,
3015 measured_max_width: None,
3016 resolved_modifiers: ResolvedModifiers::default(),
3017 draw_commands: vec![],
3018 click_actions: vec![],
3019 pointer_inputs: vec![],
3020 clip_to_bounds: false,
3021 annotated_text: None,
3022 text_style: None,
3023 text_layout_options: None,
3024 text_pan: None,
3025 graphics_layer: None,
3026 children: vec![child],
3027 };
3028 let moved_parent = BuildNodeSnapshot {
3029 children: vec![moved_child],
3030 ..parent.clone()
3031 };
3032
3033 let static_graph = build_layer_node_for_test(parent, 1.0, false);
3034 let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
3035
3036 assert_ne!(
3037 static_graph.target_content_hash(),
3038 moved_graph.target_content_hash(),
3039 "moving a child within the parent must invalidate the parent subtree hash"
3040 );
3041 }
3042
3043 #[test]
3044 fn stored_effect_hash_tracks_local_effect_only() {
3045 let base = BuildNodeSnapshot {
3046 node_id: 1,
3047 placement: Point::default(),
3048 size: Size {
3049 width: 80.0,
3050 height: 50.0,
3051 },
3052 content_offset: Point::default(),
3053 motion_context_animated: false,
3054 translated_content_context: false,
3055 measured_max_width: None,
3056 resolved_modifiers: ResolvedModifiers::default(),
3057 draw_commands: vec![],
3058 click_actions: vec![],
3059 pointer_inputs: vec![],
3060 clip_to_bounds: false,
3061 annotated_text: None,
3062 text_style: None,
3063 text_layout_options: None,
3064 text_pan: None,
3065 graphics_layer: None,
3066 children: vec![],
3067 };
3068 let mut effected = base.clone();
3069 effected.graphics_layer = Some(GraphicsLayer {
3070 render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
3071 ..GraphicsLayer::default()
3072 });
3073
3074 let base_graph = build_layer_node_for_test(base, 1.0, false);
3075 let effected_graph = build_layer_node_for_test(effected, 1.0, false);
3076
3077 assert_eq!(
3078 base_graph.target_content_hash(),
3079 effected_graph.target_content_hash(),
3080 "post-processing effect parameters belong to the effect hash, not the content hash"
3081 );
3082 assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
3083 }
3084
3085 #[test]
3086 fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
3087 let mut text_style = TextStyle::default();
3088 text_style.paragraph_style.text_align = TextAlign::Start;
3089 text_style.paragraph_style.text_direction = TextDirection::Rtl;
3090 text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
3091
3092 let snapshot = BuildNodeSnapshot {
3093 node_id: 1,
3094 placement: Point::default(),
3095 size: Size {
3096 width: 180.0,
3097 height: 48.0,
3098 },
3099 content_offset: Point::default(),
3100 motion_context_animated: false,
3101 translated_content_context: false,
3102 measured_max_width: Some(180.0),
3103 resolved_modifiers: ResolvedModifiers::default(),
3104 draw_commands: vec![],
3105 click_actions: vec![],
3106 pointer_inputs: vec![],
3107 clip_to_bounds: false,
3108 annotated_text: Some(AnnotatedString::from("rtl")),
3109 text_style: Some(text_style),
3110 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
3111 overflow: cranpose_ui::TextOverflow::Clip,
3112 ..Default::default()
3113 }),
3114 text_pan: None,
3115 graphics_layer: None,
3116 children: vec![],
3117 };
3118
3119 let graph = build_layer_node_for_test(snapshot, 1.0, false);
3120 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
3121 panic!("expected text primitive");
3122 };
3123 let PrimitiveNode::Text(text) = &text_primitive.node else {
3124 panic!("expected text primitive");
3125 };
3126 let clip = text
3127 .clip
3128 .expect("clipped overflow should produce a clip rect");
3129
3130 assert!(
3131 text.rect.x > 0.0,
3132 "RTL start alignment should shift the text rect within the available width"
3133 );
3134 assert!(
3135 clip.y < text.rect.y,
3136 "baseline shift must expand the clip upward so superscript glyphs are preserved"
3137 );
3138 assert!(
3139 clip.intersect(text.rect).is_some(),
3140 "the clip rect must intersect the shifted text draw rect"
3141 );
3142 }
3143
3144 #[test]
3145 fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
3146 let snapshot = BuildNodeSnapshot {
3147 node_id: 1,
3148 placement: Point::default(),
3149 size: Size {
3150 width: 320.0,
3151 height: 48.0,
3152 },
3153 content_offset: Point::default(),
3154 motion_context_animated: false,
3155 translated_content_context: false,
3156 measured_max_width: Some(320.0),
3157 resolved_modifiers: ResolvedModifiers::default(),
3158 draw_commands: vec![],
3159 click_actions: vec![],
3160 pointer_inputs: vec![],
3161 clip_to_bounds: false,
3162 annotated_text: Some(AnnotatedString::from("short")),
3163 text_style: Some(TextStyle::default()),
3164 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
3165 overflow: cranpose_ui::TextOverflow::Clip,
3166 ..Default::default()
3167 }),
3168 text_pan: None,
3169 graphics_layer: None,
3170 children: vec![],
3171 };
3172
3173 let graph = build_layer_node_for_test(snapshot, 1.0, false);
3174 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
3175 panic!("expected text primitive");
3176 };
3177 let PrimitiveNode::Text(text) = &text_primitive.node else {
3178 panic!("expected text primitive");
3179 };
3180 let clip = text.clip.expect("clipped text should keep a clip rect");
3181
3182 assert!(
3183 text.rect.width < 320.0,
3184 "text raster bounds should track measured glyph width instead of full content width"
3185 );
3186 assert_eq!(
3187 clip.width, 322.0,
3188 "text clip should still preserve the full content box plus clip padding"
3189 );
3190 }
3191
3192 #[test]
3196 fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
3197 let pan_offset = 25.0_f32;
3198 let field_width = 80.0_f32;
3199 let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
3200 let viewports = resolved_viewports.clone();
3201 let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
3202 node_id: 1,
3203 placement: Point::default(),
3204 size: Size {
3205 width: field_width,
3206 height: 24.0,
3207 },
3208 content_offset: Point::default(),
3209 motion_context_animated: false,
3210 translated_content_context: false,
3211 measured_max_width: Some(field_width),
3212 resolved_modifiers: ResolvedModifiers::default(),
3213 draw_commands: vec![],
3214 click_actions: vec![],
3215 pointer_inputs: vec![],
3216 clip_to_bounds: false,
3217 annotated_text: Some(AnnotatedString::from(
3218 "a very long single line of text that cannot fit",
3219 )),
3220 text_style: Some(TextStyle::default()),
3221 text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
3222 text_pan,
3223 graphics_layer: None,
3224 children: vec![],
3225 };
3226
3227 let text_node = |snapshot: BuildNodeSnapshot| {
3228 let graph = build_layer_node_for_test(snapshot, 1.0, false);
3229 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
3230 panic!("expected text primitive");
3231 };
3232 let PrimitiveNode::Text(text) = &text_primitive.node else {
3233 panic!("expected text primitive");
3234 };
3235 (**text).clone()
3236 };
3237
3238 let unpanned = text_node(make_snapshot(None));
3239 let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
3240 viewports.borrow_mut().push(viewport);
3241 pan_offset
3242 }))));
3243
3244 assert_eq!(
3245 resolved_viewports.borrow().as_slice(),
3246 &[field_width],
3247 "the pan resolver must receive the content viewport width"
3248 );
3249 assert_eq!(
3250 panned.rect.x, -pan_offset,
3251 "text glyphs must shift left by the pan offset"
3252 );
3253 assert!(
3254 panned.rect.width > field_width,
3255 "panned single-line text must be laid out unconstrained, got {}",
3256 panned.rect.width
3257 );
3258 assert!(
3259 panned.rect.width >= unpanned.rect.width,
3260 "unconstrained layout must not be narrower than wrapped layout"
3261 );
3262 assert!(
3263 panned.rect.height <= unpanned.rect.height,
3264 "single-line layout must not wrap onto extra lines"
3265 );
3266 let clip = panned
3267 .clip
3268 .expect("panned text field must clip to field bounds");
3269 assert!(
3270 clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
3271 "clip must not extend past the field bounds, got {clip:?}"
3272 );
3273 }
3274
3275 #[test]
3276 fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
3277 let child = BuildNodeSnapshot {
3278 node_id: 2,
3279 placement: Point { x: 11.0, y: 7.0 },
3280 size: Size {
3281 width: 120.0,
3282 height: 32.0,
3283 },
3284 content_offset: Point::default(),
3285 motion_context_animated: false,
3286 translated_content_context: false,
3287 measured_max_width: Some(120.0),
3288 resolved_modifiers: ResolvedModifiers::default(),
3289 draw_commands: vec![],
3290 click_actions: vec![],
3291 pointer_inputs: vec![],
3292 clip_to_bounds: false,
3293 annotated_text: Some(AnnotatedString::from("scrolling")),
3294 text_style: Some(TextStyle::default()),
3295 text_layout_options: None,
3296 text_pan: None,
3297 graphics_layer: None,
3298 children: vec![],
3299 };
3300 let parent = BuildNodeSnapshot {
3301 node_id: 1,
3302 placement: Point::default(),
3303 size: Size {
3304 width: 160.0,
3305 height: 64.0,
3306 },
3307 content_offset: Point { x: 0.0, y: -18.5 },
3308 motion_context_animated: false,
3309 translated_content_context: true,
3310 measured_max_width: None,
3311 resolved_modifiers: ResolvedModifiers::default(),
3312 draw_commands: vec![],
3313 click_actions: vec![],
3314 pointer_inputs: vec![],
3315 clip_to_bounds: false,
3316 annotated_text: None,
3317 text_style: None,
3318 text_layout_options: None,
3319 text_pan: None,
3320 graphics_layer: None,
3321 children: vec![child],
3322 };
3323
3324 let graph = build_layer_node_for_test(parent, 1.0, false);
3325 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3326 panic!("expected child layer");
3327 };
3328 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3329 panic!("expected text primitive");
3330 };
3331 let PrimitiveNode::Text(text) = &text_primitive.node else {
3332 panic!("expected text primitive");
3333 };
3334
3335 assert_eq!(text.text_style.paragraph_style.text_motion, None);
3336 assert!(!child_layer.motion_context_animated);
3337 }
3338
3339 #[test]
3340 fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
3341 let child = BuildNodeSnapshot {
3342 node_id: 2,
3343 placement: Point { x: 11.0, y: 7.0 },
3344 size: Size {
3345 width: 120.0,
3346 height: 32.0,
3347 },
3348 content_offset: Point::default(),
3349 motion_context_animated: false,
3350 translated_content_context: false,
3351 measured_max_width: Some(120.0),
3352 resolved_modifiers: ResolvedModifiers::default(),
3353 draw_commands: vec![],
3354 click_actions: vec![],
3355 pointer_inputs: vec![],
3356 clip_to_bounds: false,
3357 annotated_text: Some(AnnotatedString::from("scrolling")),
3358 text_style: Some(TextStyle::default()),
3359 text_layout_options: None,
3360 text_pan: None,
3361 graphics_layer: None,
3362 children: vec![],
3363 };
3364 let parent = BuildNodeSnapshot {
3365 node_id: 1,
3366 placement: Point::default(),
3367 size: Size {
3368 width: 160.0,
3369 height: 64.0,
3370 },
3371 content_offset: Point { x: 0.0, y: -18.0 },
3372 motion_context_animated: false,
3373 translated_content_context: false,
3374 measured_max_width: None,
3375 resolved_modifiers: ResolvedModifiers::default(),
3376 draw_commands: vec![],
3377 click_actions: vec![],
3378 pointer_inputs: vec![],
3379 clip_to_bounds: false,
3380 annotated_text: None,
3381 text_style: None,
3382 text_layout_options: None,
3383 text_pan: None,
3384 graphics_layer: None,
3385 children: vec![child],
3386 };
3387
3388 let graph = build_layer_node_for_test(parent, 1.0, false);
3389 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3390 panic!("expected child layer");
3391 };
3392 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3393 panic!("expected text primitive");
3394 };
3395 let PrimitiveNode::Text(text) = &text_primitive.node else {
3396 panic!("expected text primitive");
3397 };
3398
3399 assert_eq!(
3400 text.text_style.paragraph_style.text_motion, None,
3401 "content_offset alone must not force text onto the translated-content motion path"
3402 );
3403 assert!(!child_layer.motion_context_animated);
3404 }
3405
3406 #[test]
3407 fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
3408 let child = BuildNodeSnapshot {
3409 node_id: 2,
3410 placement: Point { x: 11.0, y: 7.0 },
3411 size: Size {
3412 width: 120.0,
3413 height: 32.0,
3414 },
3415 content_offset: Point::default(),
3416 motion_context_animated: false,
3417 translated_content_context: false,
3418 measured_max_width: Some(120.0),
3419 resolved_modifiers: ResolvedModifiers::default(),
3420 draw_commands: vec![],
3421 click_actions: vec![],
3422 pointer_inputs: vec![],
3423 clip_to_bounds: false,
3424 annotated_text: Some(AnnotatedString::from("shadow")),
3425 text_style: Some(TextStyle::from_span_style(SpanStyle {
3426 shadow: Some(cranpose_ui::text::Shadow {
3427 color: Color::BLACK,
3428 offset: Point::new(1.0, 2.0),
3429 blur_radius: 3.0,
3430 }),
3431 ..SpanStyle::default()
3432 })),
3433 text_layout_options: None,
3434 text_pan: None,
3435 graphics_layer: None,
3436 children: vec![],
3437 };
3438 let parent = BuildNodeSnapshot {
3439 node_id: 1,
3440 placement: Point::default(),
3441 size: Size {
3442 width: 160.0,
3443 height: 64.0,
3444 },
3445 content_offset: Point { x: 0.0, y: -18.5 },
3446 motion_context_animated: false,
3447 translated_content_context: true,
3448 measured_max_width: None,
3449 resolved_modifiers: ResolvedModifiers::default(),
3450 draw_commands: vec![],
3451 click_actions: vec![],
3452 pointer_inputs: vec![],
3453 clip_to_bounds: false,
3454 annotated_text: None,
3455 text_style: None,
3456 text_layout_options: None,
3457 text_pan: None,
3458 graphics_layer: None,
3459 children: vec![child],
3460 };
3461
3462 let graph = build_layer_node_for_test(parent, 1.0, false);
3463 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3464 panic!("expected child layer");
3465 };
3466 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3467 panic!("expected text primitive");
3468 };
3469 let PrimitiveNode::Text(text) = &text_primitive.node else {
3470 panic!("expected text primitive");
3471 };
3472
3473 assert_eq!(text.text_style.paragraph_style.text_motion, None);
3474 }
3475
3476 #[test]
3477 fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
3478 let child = BuildNodeSnapshot {
3479 node_id: 2,
3480 placement: Point { x: 11.0, y: 7.0 },
3481 size: Size {
3482 width: 120.0,
3483 height: 32.0,
3484 },
3485 content_offset: Point::default(),
3486 motion_context_animated: false,
3487 translated_content_context: false,
3488 measured_max_width: Some(120.0),
3489 resolved_modifiers: ResolvedModifiers::default(),
3490 draw_commands: vec![],
3491 click_actions: vec![],
3492 pointer_inputs: vec![],
3493 clip_to_bounds: false,
3494 annotated_text: Some(AnnotatedString::from("lazy")),
3495 text_style: Some(TextStyle::default()),
3496 text_layout_options: None,
3497 text_pan: None,
3498 graphics_layer: None,
3499 children: vec![],
3500 };
3501 let parent = BuildNodeSnapshot {
3502 node_id: 1,
3503 placement: Point::default(),
3504 size: Size {
3505 width: 160.0,
3506 height: 64.0,
3507 },
3508 content_offset: Point::default(),
3509 motion_context_animated: true,
3510 translated_content_context: false,
3511 measured_max_width: None,
3512 resolved_modifiers: ResolvedModifiers::default(),
3513 draw_commands: vec![],
3514 click_actions: vec![],
3515 pointer_inputs: vec![],
3516 clip_to_bounds: false,
3517 annotated_text: None,
3518 text_style: None,
3519 text_layout_options: None,
3520 text_pan: None,
3521 graphics_layer: None,
3522 children: vec![child],
3523 };
3524
3525 let graph = build_layer_node_for_test(parent, 1.0, false);
3526 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3527 panic!("expected child layer");
3528 };
3529 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3530 panic!("expected text primitive");
3531 };
3532 let PrimitiveNode::Text(text) = &text_primitive.node else {
3533 panic!("expected text primitive");
3534 };
3535
3536 assert_eq!(text.text_style.paragraph_style.text_motion, None);
3537 assert!(graph.motion_context_animated);
3538 assert!(child_layer.motion_context_animated);
3539 }
3540
3541 #[test]
3542 fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
3543 let mut composition = cranpose_ui::run_test_composition(|| {
3544 let list_state = remember_lazy_list_state();
3545 LazyColumn(
3546 Modifier::empty(),
3547 list_state,
3548 LazyColumnSpec::default(),
3549 |scope| {
3550 scope.item(Some(0), None, || {
3551 Text("LazyMotion", Modifier::empty(), TextStyle::default());
3552 });
3553 },
3554 );
3555 });
3556
3557 let root = composition.root().expect("lazy column root");
3558 let handle = composition.runtime_handle();
3559 let mut applier = composition.applier_mut();
3560 applier.set_runtime_handle(handle);
3561 let _ = applier
3562 .compute_layout(
3563 root,
3564 Size {
3565 width: 240.0,
3566 height: 240.0,
3567 },
3568 )
3569 .expect("lazy column layout");
3570 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3571 applier.clear_runtime_handle();
3572
3573 assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
3574 }
3575
3576 #[test]
3577 fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
3578 use std::cell::RefCell;
3579 use std::rc::Rc;
3580
3581 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3582 let state_holder_for_comp = state_holder.clone();
3583 let mut composition = cranpose_ui::run_test_composition(move || {
3584 let list_state = remember_lazy_list_state();
3585 *state_holder_for_comp.borrow_mut() = Some(list_state);
3586 LazyColumn(
3587 Modifier::empty().height(120.0),
3588 list_state,
3589 LazyColumnSpec::default(),
3590 |scope| {
3591 scope.items(
3592 8,
3593 None::<fn(usize) -> u64>,
3594 None::<fn(usize) -> u64>,
3595 |index| {
3596 Text(
3597 format!("LazyMotion {index}"),
3598 Modifier::empty().padding(4.0),
3599 TextStyle::default(),
3600 );
3601 },
3602 );
3603 },
3604 );
3605 });
3606
3607 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3608 list_state.scroll_to_item(3, 0.0);
3609
3610 let root = composition.root().expect("lazy column root");
3611 let handle = composition.runtime_handle();
3612 let mut applier = composition.applier_mut();
3613 applier.set_runtime_handle(handle);
3614 let _ = applier
3615 .compute_layout(
3616 root,
3617 Size {
3618 width: 240.0,
3619 height: 240.0,
3620 },
3621 )
3622 .expect("lazy column layout");
3623 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3624 let active_children = applier
3625 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3626 .expect("lazy column should be subcompose");
3627 let child_debug: Vec<String> = active_children
3628 .iter()
3629 .map(|&child_id| {
3630 if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
3631 format!(
3632 "layout#{child_id} placed={} text={:?} children={:?}",
3633 node.layout_state().is_placed,
3634 node.modifier_slices_snapshot()
3635 .text_content()
3636 .map(str::to_string),
3637 node.children.clone()
3638 )
3639 }) {
3640 summary
3641 } else if let Ok(summary) =
3642 applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
3643 format!(
3644 "subcompose#{child_id} placed={} active_children={:?}",
3645 node.layout_state().is_placed,
3646 node.active_children()
3647 )
3648 })
3649 {
3650 summary
3651 } else {
3652 format!("missing#{child_id}")
3653 }
3654 })
3655 .collect();
3656 applier.clear_runtime_handle();
3657
3658 let first_index = list_state.first_visible_item_index();
3659 assert!(
3660 first_index > 0,
3661 "lazy list should move away from origin before graph building, observed first_index={first_index}"
3662 );
3663 let mut labels = Vec::new();
3664 collect_text_labels(&graph.root, &mut labels);
3665 assert_eq!(
3666 find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
3667 Some(None),
3668 "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
3669 labels,
3670 active_children,
3671 child_debug
3672 );
3673 }
3674
3675 #[test]
3676 fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
3677 use std::cell::RefCell;
3678 use std::rc::Rc;
3679
3680 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3681 let state_holder_for_comp = state_holder.clone();
3682 let mut composition = cranpose_ui::run_test_composition(move || {
3683 let list_state = remember_lazy_list_state();
3684 *state_holder_for_comp.borrow_mut() = Some(list_state);
3685 let mut spec =
3686 LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
3687 spec.beyond_bounds_item_count = 0;
3688 LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
3689 scope.items(
3690 12,
3691 None::<fn(usize) -> u64>,
3692 None::<fn(usize) -> u64>,
3693 |index| {
3694 Text(
3695 format!("WarmRow {index}"),
3696 Modifier::empty().height(32.0),
3697 TextStyle::default(),
3698 );
3699 },
3700 );
3701 });
3702 });
3703
3704 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3705 list_state.scroll_to_item(4, 0.0);
3706
3707 let root = composition.root().expect("lazy column root");
3708 let handle = composition.runtime_handle();
3709 let mut applier = composition.applier_mut();
3710 applier.set_runtime_handle(handle);
3711 let _ = applier
3712 .compute_layout(
3713 root,
3714 Size {
3715 width: 240.0,
3716 height: 240.0,
3717 },
3718 )
3719 .expect("lazy column layout");
3720 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3721 let active_children = applier
3722 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3723 .expect("lazy column should be subcompose");
3724 applier.clear_runtime_handle();
3725
3726 let visible_indices: Vec<_> = list_state
3727 .layout_info()
3728 .visible_items_info
3729 .iter()
3730 .map(|item| item.index)
3731 .collect();
3732 let mut labels = Vec::new();
3733 collect_text_labels(&graph.root, &mut labels);
3734
3735 assert_eq!(
3736 visible_indices,
3737 vec![4, 5, 6],
3738 "test setup expects exactly three viewport-visible rows"
3739 );
3740 assert!(
3741 labels.iter().any(|label| label == "WarmRow 7"),
3742 "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
3743 );
3744 }
3745
3746 #[test]
3747 fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
3748 use std::cell::RefCell;
3749 use std::rc::Rc;
3750
3751 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3752 let state_holder_for_comp = state_holder.clone();
3753 let mut composition = cranpose_ui::run_test_composition(move || {
3754 let list_state = remember_lazy_list_state();
3755 *state_holder_for_comp.borrow_mut() = Some(list_state);
3756 LazyColumn(
3757 Modifier::empty().height(120.0),
3758 list_state,
3759 LazyColumnSpec::default(),
3760 |scope| {
3761 scope.items(
3762 8,
3763 None::<fn(usize) -> u64>,
3764 None::<fn(usize) -> u64>,
3765 |index| {
3766 Text(
3767 format!("LazySnap {index}"),
3768 Modifier::empty().padding(4.0),
3769 TextStyle::default(),
3770 );
3771 },
3772 );
3773 },
3774 );
3775 });
3776
3777 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3778 list_state.scroll_to_item(2, 7.5);
3779
3780 let root = composition.root().expect("lazy column root");
3781 let handle = composition.runtime_handle();
3782 let mut applier = composition.applier_mut();
3783 applier.set_runtime_handle(handle);
3784 let _ = applier
3785 .compute_layout(
3786 root,
3787 Size {
3788 width: 240.0,
3789 height: 240.0,
3790 },
3791 )
3792 .expect("lazy column layout");
3793 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3794 applier.clear_runtime_handle();
3795
3796 let layout_info = list_state.layout_info();
3797 let first_visible_offset = layout_info
3798 .visible_items_info
3799 .first()
3800 .expect("lazy layout should expose visible item info")
3801 .offset;
3802 let snap_offset = find_translated_content_offset(&graph.root)
3803 .expect("lazy list graph should include translated content context");
3804
3805 assert!(
3806 (snap_offset.y - first_visible_offset).abs() <= 0.001,
3807 "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
3808 );
3809 }
3810
3811 #[test]
3812 fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
3813 let child = BuildNodeSnapshot {
3814 node_id: 2,
3815 placement: Point { x: 11.0, y: 7.0 },
3816 size: Size {
3817 width: 120.0,
3818 height: 32.0,
3819 },
3820 content_offset: Point::default(),
3821 motion_context_animated: false,
3822 translated_content_context: false,
3823 measured_max_width: Some(120.0),
3824 resolved_modifiers: ResolvedModifiers::default(),
3825 draw_commands: vec![],
3826 click_actions: vec![],
3827 pointer_inputs: vec![],
3828 clip_to_bounds: false,
3829 annotated_text: Some(AnnotatedString::from("static")),
3830 text_style: Some(TextStyle::from_paragraph_style(
3831 cranpose_ui::text::ParagraphStyle {
3832 text_motion: Some(TextMotion::Static),
3833 ..Default::default()
3834 },
3835 )),
3836 text_layout_options: None,
3837 text_pan: None,
3838 graphics_layer: None,
3839 children: vec![],
3840 };
3841 let parent = BuildNodeSnapshot {
3842 node_id: 1,
3843 placement: Point::default(),
3844 size: Size {
3845 width: 160.0,
3846 height: 64.0,
3847 },
3848 content_offset: Point { x: 0.0, y: -18.5 },
3849 motion_context_animated: false,
3850 translated_content_context: true,
3851 measured_max_width: None,
3852 resolved_modifiers: ResolvedModifiers::default(),
3853 draw_commands: vec![],
3854 click_actions: vec![],
3855 pointer_inputs: vec![],
3856 clip_to_bounds: false,
3857 annotated_text: None,
3858 text_style: None,
3859 text_layout_options: None,
3860 text_pan: None,
3861 graphics_layer: None,
3862 children: vec![child],
3863 };
3864
3865 let graph = build_layer_node_for_test(parent, 1.0, false);
3866 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3867 panic!("expected child layer");
3868 };
3869 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3870 panic!("expected text primitive");
3871 };
3872 let PrimitiveNode::Text(text) = &text_primitive.node else {
3873 panic!("expected text primitive");
3874 };
3875
3876 assert_eq!(
3877 text.text_style.paragraph_style.text_motion,
3878 Some(TextMotion::Static),
3879 "explicit text motion must win over inherited scrolling motion context"
3880 );
3881 }
3882
3883 #[test]
3906 fn wrapped_paragraph_paints_the_height_it_measured() {
3907 const BODY: &str = "fed back картица scored fp32 износ once paper fed Vision dropped \
3908 fed widest the strip mask prompt mask threshold Vision on датум instance mask \
3909 износ Apple";
3910 const FOLLOWING: &str = "FOLLOWING SIBLING";
3911
3912 let app_context = cranpose_ui::AppContext::new();
3913 app_context.enter(|| {
3914 cranpose_ui::text::set_text_measurer(
3915 crate::software_text_raster::SoftwareTextMeasurer::from_fonts_or_default(&[], 8192),
3916 );
3917 let mut composition = cranpose_ui::run_test_composition(move || {
3918 Column(
3919 Modifier::empty().fill_max_width(),
3920 ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
3921 move || {
3922 Text(BODY.to_string(), Modifier::empty(), TextStyle::default());
3923 Text(
3924 FOLLOWING.to_string(),
3925 Modifier::empty(),
3926 TextStyle::default(),
3927 );
3928 },
3929 );
3930 });
3931
3932 let root = composition.root().expect("composition root");
3933 let handle = composition.runtime_handle();
3934 let mut applier = composition.applier_mut();
3935 applier.set_runtime_handle(handle);
3936 let layout = applier
3937 .compute_layout(
3938 root,
3939 Size {
3940 width: 245.0,
3941 height: 900.0,
3942 },
3943 )
3944 .expect("layout");
3945
3946 fn find_box<'a>(node: &'a LayoutBox, value: &str) -> Option<&'a LayoutBox> {
3947 if node
3948 .node_data
3949 .modifier_slices()
3950 .text_content()
3951 .is_some_and(|text| text == value)
3952 {
3953 return Some(node);
3954 }
3955 node.children
3956 .iter()
3957 .find_map(|child| find_box(child, value))
3958 }
3959 let body_box = find_box(layout.root(), BODY).expect("measured paragraph box");
3960 let following_box = find_box(layout.root(), FOLLOWING).expect("measured sibling box");
3961 let measured_height = body_box.rect.height;
3962 let following_top = following_box.rect.y;
3963 assert!(
3964 measured_height > 60.0,
3965 "test setup expects a genuinely multi-line paragraph, got {measured_height}"
3966 );
3967 assert!(
3968 body_box.rect.width < 245.0,
3969 "test setup expects the node to be placed at its own measured width, \
3970 not the full constraint, got {}",
3971 body_box.rect.width
3972 );
3973
3974 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("render graph");
3975 applier.clear_runtime_handle();
3976
3977 fn squashed(value: &str) -> String {
3980 value.chars().filter(|c| !c.is_whitespace()).collect()
3981 }
3982 fn find_text<'a>(layer: &'a LayerNode, value: &str) -> Option<&'a TextPrimitiveNode> {
3983 for child in &layer.children {
3984 match child {
3985 RenderNode::Primitive(primitive) => {
3986 if let PrimitiveNode::Text(text) = &primitive.node {
3987 if squashed(&text.text.text) == squashed(value) {
3988 return Some(text);
3989 }
3990 }
3991 }
3992 RenderNode::Layer(child_layer) => {
3993 if let Some(found) = find_text(child_layer, value) {
3994 return Some(found);
3995 }
3996 }
3997 RenderNode::DrawRun(_) => {}
3998 }
3999 }
4000 None
4001 }
4002 let painted = find_text(&graph.root, BODY).expect("painted paragraph");
4003
4004 assert!(
4005 (painted.rect.height - measured_height).abs() < 0.5,
4006 "paragraph painted {:.2} tall into a box layout measured at {:.2} \
4007 (painted rect {:?})",
4008 painted.rect.height,
4009 measured_height,
4010 painted.rect
4011 );
4012 assert!(
4013 painted.rect.y + painted.rect.height <= following_top + 0.5,
4014 "painted paragraph bottom {:.2} runs past the following sibling placed at \
4015 {:.2}",
4016 painted.rect.y + painted.rect.height,
4017 following_top
4018 );
4019 });
4020 }
4021
4022 #[test]
4023 fn retained_slot_confirmations_are_live_only_under_their_generation() {
4024 let command = DrawCommandId {
4025 node_id: 990_101,
4026 command_index: 0,
4027 placement: DrawPlacement::Behind,
4028 };
4029 set_retained_feed_epoch(Some(7));
4030 confirm_retained_slot(command, 3, 7);
4031 assert!(retained_slot_confirmed(command, 3));
4032 set_retained_feed_epoch(Some(8));
4034 assert!(!retained_slot_confirmed(command, 3));
4035 set_retained_feed_epoch(None);
4037 assert!(!retained_slot_confirmed(command, 3));
4038 set_retained_feed_epoch(Some(7));
4040 assert!(retained_slot_confirmed(command, 3));
4041 revoke_retained_slot(command, 3);
4042 assert!(!retained_slot_confirmed(command, 3));
4043 set_retained_feed_epoch(None);
4044 clear_retained_slot_confirmations();
4045 }
4046
4047 fn record_sweep_test_rings(scope: &mut DrawScopeDefault) {
4053 let count = 600usize;
4054 let sweep = std::f32::consts::TAU / count as f32 * 0.8;
4055 for i in 0..count {
4056 let start = i as f32 * (std::f32::consts::TAU / count as f32);
4057 scope.draw_annular_sector(
4058 Brush::solid(cranpose_ui_graphics::Color(0.2, 0.4, 0.6, 1.0)),
4059 cranpose_ui_graphics::Point::new(204.0, 204.0),
4060 140.0,
4061 150.0,
4062 start,
4063 sweep,
4064 );
4065 }
4066 }
4067
4068 #[test]
4076 fn recording_sweep_cannot_sever_a_frames_fallback() {
4077 let command = DrawCommandId {
4078 node_id: 990_102,
4079 command_index: 0,
4080 placement: DrawPlacement::Behind,
4081 };
4082 set_retained_feed_epoch(Some(41));
4083 for slot in 0..64 {
4084 confirm_retained_slot(command, slot, 41);
4085 }
4086
4087 let mut state = cranpose_ui_graphics::CommandReplayState::default();
4092 let mut published = None;
4093 for _frame in 0..4 {
4094 let (recording, storage, _) = acquire_recording(command);
4095 let mut scope = DrawScopeDefault::with_recording(
4096 cranpose_ui_graphics::Size::new(408.0, 408.0),
4097 None,
4098 recording,
4099 storage,
4100 );
4101 record_sweep_test_rings(&mut scope);
4102 let outcome = state.advance(scope.recorded());
4103 let center = state.center();
4104 let (finished, frame) = scope.finish_replay(center, outcome, &mut |slot| {
4105 retained_slot_confirmed(command, slot)
4106 });
4107 let (primitives, fallback) =
4108 publish_recording(command, finished.recording, finished.primitives, None);
4109 let frame = frame.map(|mut frame| {
4110 frame.fallback = Some(fallback.clone());
4111 frame
4112 });
4113 published = Some((primitives, fallback, frame));
4114 }
4115 let (_primitives, fallback, frame) = published.expect("four frames published");
4116 let frame = frame.expect("the replay must produce a frame with retained spans");
4117 let bypassed: Vec<(u32, u32)> = frame
4118 .spans
4119 .iter()
4120 .filter_map(|span| match span {
4121 cranpose_ui_graphics::FrameSpan::Retained {
4122 capture: false,
4123 range,
4124 tape_range,
4125 ..
4126 } if range.1 <= range.0 => Some(*tape_range),
4127 _ => None,
4128 })
4129 .collect();
4130 assert!(
4131 !bypassed.is_empty(),
4132 "confirmed slots must actually have bypassed materialization"
4133 );
4134 let expected: Vec<Vec<DrawPrimitive>> = bypassed
4135 .iter()
4136 .map(|tape_range| {
4137 fallback
4138 .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
4139 .expect("a frame-consistent tape range must materialize")
4140 })
4141 .collect();
4142
4143 for _ in 0..1024 {
4148 bump_recording_generation();
4149 }
4150 assert!(
4151 COMMAND_RECORDINGS.with(|map| !map.borrow().contains_key(&command)),
4152 "the sweep must stay pure capacity management: a live confirmation \
4153 no longer pins the registry slot"
4154 );
4155
4156 for (tape_range, expected) in bypassed.iter().zip(&expected) {
4159 let after = fallback
4160 .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
4161 .expect("the frame-owned recording must outlive the sweep");
4162 assert_eq!(
4163 &after, expected,
4164 "post-sweep rematerialization must be byte-identical"
4165 );
4166 }
4167 set_retained_feed_epoch(None);
4168 clear_retained_slot_confirmations();
4169 }
4170
4171 #[test]
4179 fn command_recordings_reuse_recording_buffers_across_rebuilds() {
4180 let command = DrawCommandId {
4181 node_id: 990_103,
4182 command_index: 0,
4183 placement: DrawPlacement::Behind,
4184 };
4185 let mut held = None;
4188 let mut ptrs = Vec::new();
4189 for _build in 0..8 {
4190 let (recording, storage, _) = acquire_recording(command);
4191 let mut scope = DrawScopeDefault::with_recording(
4192 cranpose_ui_graphics::Size::new(64.0, 64.0),
4193 None,
4194 recording,
4195 storage,
4196 );
4197 scope.draw_rect_at(
4198 Rect {
4199 x: 4.0,
4200 y: 4.0,
4201 width: 16.0,
4202 height: 8.0,
4203 },
4204 Brush::solid(Color::WHITE),
4205 );
4206 let finished = scope.finish();
4207 let (primitives, recording) =
4208 publish_recording(command, finished.recording, finished.primitives, None);
4209 ptrs.push(recording.tape_ptr());
4210 held = Some((primitives, recording));
4211 }
4212 drop(held);
4213 for build in 2..8 {
4217 assert_eq!(
4218 ptrs[build],
4219 ptrs[build - 2],
4220 "steady-state publishes must ping-pong between the pair's \
4221 buffers (build {build} allocated)"
4222 );
4223 }
4224 assert_ne!(
4225 ptrs[6], ptrs[7],
4226 "a recording a live frame still shares must never be recorded into"
4227 );
4228 }
4229
4230 #[test]
4231 fn sanitized_spans_drop_recolors_and_downgrade_captures() {
4232 use cranpose_ui_graphics::{FrameSpan, RecordTransform};
4233 let bounds = Rect {
4234 x: 1.0,
4235 y: 2.0,
4236 width: 3.0,
4237 height: 4.0,
4238 };
4239 let spans = vec![
4240 FrameSpan::Dynamic { range: (0, 5) },
4241 FrameSpan::Retained {
4242 slot: 7,
4243 capture: true,
4244 slot_offset: 0,
4245 range: (5, 105),
4246 tape_range: (5, 105),
4247 transform: RecordTransform::IDENTITY,
4248 recolors: Vec::new(),
4249 bounds,
4250 },
4251 FrameSpan::Retained {
4252 slot: 8,
4253 capture: false,
4254 slot_offset: 3,
4255 range: (105, 205),
4256 tape_range: (110, 210),
4257 transform: RecordTransform {
4258 scale: 0.999,
4259 angle: 0.05,
4260 },
4261 recolors: vec![(4, cranpose_ui_graphics::Color(1.0, 0.5, 0.2, 1.0))],
4262 bounds,
4263 },
4264 ];
4265 let sanitized = sanitized_replay_spans(&spans);
4266 assert_eq!(sanitized[0], FrameSpan::Dynamic { range: (0, 5) });
4268 assert_eq!(sanitized[1], FrameSpan::Dynamic { range: (5, 105) });
4272 match &sanitized[2] {
4276 FrameSpan::Retained {
4277 slot,
4278 capture,
4279 slot_offset,
4280 range,
4281 tape_range,
4282 transform,
4283 recolors,
4284 bounds: sanitized_bounds,
4285 } => {
4286 assert_eq!((*slot, *capture, *slot_offset), (8, false, 3));
4287 assert_eq!((*range, *tape_range), ((105, 205), (110, 210)));
4288 assert_eq!(transform.angle, 0.05);
4289 assert!(recolors.is_empty(), "recolors must be emptied");
4290 assert_eq!(*sanitized_bounds, bounds);
4291 }
4292 other => panic!("expected a retained span, got {other:?}"),
4293 }
4294 }
4295
4296 #[test]
4301 fn a_saved_emission_serves_the_next_build_once() {
4302 let command = DrawCommandId {
4303 node_id: 990_303,
4304 command_index: 0,
4305 placement: DrawPlacement::Behind,
4306 };
4307 set_retained_feed_epoch(Some(77));
4308 publish_recording(
4310 command,
4311 cranpose_ui_graphics::CommandRecording::default(),
4312 Vec::new(),
4313 None,
4314 );
4315 let saved = || SavedReplayEmission {
4316 spans: vec![cranpose_ui_graphics::FrameSpan::Dynamic { range: (0, 3) }],
4317 center: cranpose_ui_graphics::Point::new(204.0, 204.0),
4318 primitives: Rc::new(Vec::new()),
4319 recording: Rc::new(cranpose_ui_graphics::CommandRecording::default()),
4320 epoch: 77,
4321 generation: RECORDING_GENERATION.with(std::cell::Cell::get),
4322 };
4323
4324 store_saved_emission(command, Some(saved()));
4326 assert!(!saved_emission_available(command));
4327 bump_recording_generation();
4329 assert!(saved_emission_available(command));
4330 bump_recording_generation();
4334 assert!(!saved_emission_available(command));
4335
4336 store_saved_emission(command, Some(saved()));
4339 bump_recording_generation();
4340 assert!(saved_emission_available(command));
4341 assert!(take_saved_emission(command).is_some());
4342 assert!(
4343 !saved_emission_available(command),
4344 "a second serve of one emission must be unconstructible"
4345 );
4346 assert!(take_saved_emission(command).is_none());
4347
4348 store_saved_emission(command, Some(saved()));
4350 bump_recording_generation();
4351 set_retained_feed_epoch(Some(78));
4352 assert!(!saved_emission_available(command));
4353 set_retained_feed_epoch(None);
4354 assert!(!saved_emission_available(command));
4355 set_retained_feed_epoch(Some(77));
4356 assert!(saved_emission_available(command));
4357 set_retained_feed_epoch(None);
4358 }
4359}