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