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