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