1use std::{collections::HashSet, rc::Rc};
2
3use cranpose_core::{MemoryApplier, NodeId};
4use cranpose_ui::{
5 DrawCommand, LayoutBox, LayoutNode, ModifierNodeSlices, Point, Rect, ResolvedModifiers, Size,
6 SubcomposeLayoutNode, TextLayoutOptions, TextOverflow, TextPanResolver, prepare_text_layout,
7 text::{AnnotatedString, TextAlign, TextStyle, resolve_text_direction},
8};
9use cranpose_ui_graphics::{
10 CompositingStrategy, GraphicsLayer, LayerShape, RoundedCornerShape,
11 rounded_corner_alpha_mask_effect,
12};
13
14use crate::{
15 graph::{
16 CachePolicy, DrawCommandId, DrawRunNode, HitTestNode, IsolationReasons, LayerNode,
17 PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph,
18 RenderNode, TextPrimitiveNode,
19 },
20 layer_transform::layer_transform_to_parent,
21 raster_cache::LayerRasterCacheHashes,
22 style_shared::{DrawPlacement, primitives_for_placement_verified},
23};
24
25const TEXT_CLIP_PAD: f32 = 1.0;
26const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
27
28#[derive(Clone)]
29struct BuildNodeSnapshot {
30 node_id: NodeId,
31 placement: Point,
32 size: Size,
33 content_offset: Point,
34 motion_context_animated: bool,
35 translated_content_context: bool,
36 has_own_origin_sinks: 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
64#[cfg(test)]
67thread_local! {
68 static LOWERED_LAYER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
69}
70
71fn note_layer_lowered() {
72 #[cfg(test)]
73 LOWERED_LAYER_COUNT.with(|count| count.set(count.get() + 1));
74}
75
76#[cfg(test)]
77fn reset_lowered_layer_count() {
78 LOWERED_LAYER_COUNT.with(|count| count.set(0));
79}
80
81#[cfg(test)]
82fn lowered_layer_count() -> usize {
83 LOWERED_LAYER_COUNT.with(std::cell::Cell::get)
84}
85
86pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
87 bump_recording_generation();
88 let root_snapshot = layout_box_to_snapshot(root, None);
89 RenderGraph {
90 root: build_layer_node(root_snapshot, scale, false),
91 }
92}
93
94pub fn build_graph_from_applier(
95 applier: &mut MemoryApplier,
96 root: NodeId,
97 scale: f32,
98) -> Option<RenderGraph> {
99 bump_recording_generation();
100 Some(RenderGraph {
101 root: build_layer_node_from_applier(applier, root, scale, false)?,
102 })
103}
104
105pub fn update_graph_from_applier(
106 applier: &mut MemoryApplier,
107 graph: &mut RenderGraph,
108 dirty_nodes: &[NodeId],
109 scale: f32,
110) -> bool {
111 update_graph_from_applier_report(applier, graph, dirty_nodes, scale).applied
112}
113
114pub fn update_graph_from_applier_report(
115 applier: &mut MemoryApplier,
116 graph: &mut RenderGraph,
117 dirty_nodes: &[NodeId],
118 scale: f32,
119) -> GraphUpdateReport {
120 let mut changed_nodes = Vec::new();
121 update_graph_from_applier_report_into(applier, graph, dirty_nodes, scale, &mut changed_nodes)
122}
123
124pub fn update_graph_from_applier_report_into(
125 applier: &mut MemoryApplier,
126 graph: &mut RenderGraph,
127 dirty_nodes: &[NodeId],
128 scale: f32,
129 changed_nodes: &mut Vec<NodeId>,
130) -> GraphUpdateReport {
131 if dirty_nodes.is_empty() {
132 return GraphUpdateReport {
133 applied: true,
134 hit_graph_dirty: false,
135 };
136 }
137 bump_recording_generation();
138
139 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
140 eprintln!("[scene-update-diag] dirty={dirty_nodes:?}");
141 }
142
143 let mut remaining_dirty_nodes = dirty_nodes.iter().copied().collect::<HashSet<_>>();
144 if let Some(root_id) = graph.root.node_id
145 && remaining_dirty_nodes.contains(&root_id)
146 {
147 remaining_dirty_nodes.remove(&root_id);
148 if try_translate_scrolled_layer(
149 applier,
150 &mut graph.root,
151 &mut remaining_dirty_nodes,
152 changed_nodes,
153 TranslateAncestorContext {
154 ancestor_hashed: false,
155 inherited_translated_content_context: false,
156 parent_content_offset: Point::default(),
157 parent_abs: AbsOrigin::ROOT,
158 },
159 ) {
160 if remaining_dirty_nodes.is_empty() {
161 return GraphUpdateReport {
162 applied: true,
163 hit_graph_dirty: true,
164 };
165 }
166 let inherited = graph.root.translated_content_context;
167 let walked = replace_dirty_layers_from_applier(
168 applier,
169 &mut graph.root,
170 &mut remaining_dirty_nodes,
171 inherited,
172 false,
173 changed_nodes,
174 );
175 let applied = walked.is_some() && remaining_dirty_nodes.is_empty();
176 return GraphUpdateReport {
177 applied,
178 hit_graph_dirty: true,
179 };
180 }
181 let Some(root) = build_layer_node_from_applier(applier, root_id, scale, false) else {
182 return GraphUpdateReport {
183 applied: false,
184 hit_graph_dirty: true,
185 };
186 };
187 let hit_graph_dirty = layer_hit_graph_state_dirty(&graph.root, &root);
188 collect_layer_node_ids(&graph.root, changed_nodes);
189 graph.root = root;
190 graph.root.recompute_raster_cache_hashes();
191 collect_layer_node_ids(&graph.root, changed_nodes);
192 return GraphUpdateReport {
193 applied: true,
194 hit_graph_dirty,
195 };
196 }
197
198 let inherited_translated_content_context = graph.root.translated_content_context;
199 let report = match replace_dirty_layers_from_applier(
200 applier,
201 &mut graph.root,
202 &mut remaining_dirty_nodes,
203 inherited_translated_content_context,
204 false,
205 changed_nodes,
206 ) {
207 Some(report) => report,
208 None => {
209 return GraphUpdateReport {
210 applied: false,
211 hit_graph_dirty: true,
212 };
213 }
214 };
215
216 if !remaining_dirty_nodes.is_empty() {
217 return GraphUpdateReport {
218 applied: false,
219 hit_graph_dirty: true,
220 };
221 }
222
223 GraphUpdateReport {
224 applied: true,
225 hit_graph_dirty: report.hit_graph_dirty,
226 }
227}
228
229#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
230struct ReplaceDirtyLayersReport {
231 updated: bool,
232 hit_graph_dirty: bool,
233}
234
235fn replace_dirty_layers_from_applier(
236 applier: &mut MemoryApplier,
237 parent: &mut LayerNode,
238 dirty_nodes: &mut HashSet<NodeId>,
239 inherited_translated_content_context: bool,
240 ancestor_hashed: bool,
241 changed_nodes: &mut Vec<NodeId>,
242) -> Option<ReplaceDirtyLayersReport> {
243 if dirty_nodes.is_empty() {
244 return Some(ReplaceDirtyLayersReport::default());
245 }
246
247 let child_inherited_translated_content_context =
248 inherited_translated_content_context || parent.translated_content_context;
249 let child_ancestor_hashed =
250 crate::graph_hash::layer_children_ancestor_hashed(parent, ancestor_hashed);
251 let mut report = ReplaceDirtyLayersReport::default();
252
253 for child in &mut parent.children {
254 let RenderNode::Layer(child_layer) = child else {
255 continue;
256 };
257
258 if child_layer
259 .node_id
260 .is_some_and(|node_id| dirty_nodes.remove(&node_id))
261 {
262 if try_translate_scrolled_layer(
263 applier,
264 child_layer,
265 dirty_nodes,
266 changed_nodes,
267 TranslateAncestorContext {
268 ancestor_hashed: child_ancestor_hashed,
269 inherited_translated_content_context:
270 child_inherited_translated_content_context,
271 parent_content_offset: parent.content_offset,
272 parent_abs: AbsOrigin {
273 content_origin: parent.scene_children_origin,
274 layer_translation: parent.scene_children_layer_translation,
275 },
276 },
277 ) {
278 report.hit_graph_dirty = true;
281 report.updated = true;
282 let child_report = replace_dirty_layers_from_applier(
286 applier,
287 child_layer,
288 dirty_nodes,
289 child_inherited_translated_content_context,
290 child_ancestor_hashed,
291 changed_nodes,
292 )?;
293 report.hit_graph_dirty |= child_report.hit_graph_dirty;
294 continue;
295 }
296 let mut replacement = build_layer_node_from_applier_internal(
297 applier,
298 child_layer
299 .node_id
300 .expect("dirty layer must have a node id"),
301 parent.motion_context_animated,
302 child_inherited_translated_content_context,
303 Some(AbsOrigin {
309 content_origin: parent.scene_children_origin,
310 layer_translation: parent.scene_children_layer_translation,
311 }),
312 )?;
313 if parent.content_offset != Point::default() {
314 replacement.transform_to_parent =
315 replacement
316 .transform_to_parent
317 .then(ProjectiveTransform::translation(
318 parent.content_offset.x,
319 parent.content_offset.y,
320 ));
321 }
322 report.hit_graph_dirty |= layer_hit_graph_state_dirty(child_layer, &replacement);
323 remove_dirty_descendants(&replacement, dirty_nodes);
324 collect_layer_node_ids(child_layer, changed_nodes);
325 **child_layer = replacement;
326 collect_layer_node_ids(child_layer, changed_nodes);
327 crate::graph_hash::recompute_layer_raster_cache_hashes_under(
328 child_layer,
329 child_ancestor_hashed,
330 );
331 report.updated = true;
332 continue;
333 }
334
335 let child_report = replace_dirty_layers_from_applier(
336 applier,
337 child_layer,
338 dirty_nodes,
339 child_inherited_translated_content_context,
340 child_ancestor_hashed,
341 changed_nodes,
342 )?;
343 report.updated |= child_report.updated;
344 report.hit_graph_dirty |= child_report.hit_graph_dirty;
345 }
346
347 if report.updated {
348 parent.has_hit_targets = parent.hit_test.is_some()
349 || parent.children.iter().any(|child| match child {
350 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
351 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
352 });
353 crate::graph_hash::refresh_layer_own_raster_cache_hashes(parent, ancestor_hashed);
354 if let Some(node_id) = parent.node_id {
355 changed_nodes.push(node_id);
356 }
357 }
358
359 Some(report)
360}
361
362fn translate_bail(reason: &str) -> bool {
373 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
374 eprintln!("[scene-update-diag] translate bail: {reason}");
375 }
376 false
377}
378
379#[derive(Clone, Copy)]
382struct TranslateAncestorContext {
383 ancestor_hashed: bool,
384 inherited_translated_content_context: bool,
385 parent_content_offset: Point,
386 parent_abs: AbsOrigin,
387}
388
389fn try_translate_scrolled_layer(
390 applier: &mut MemoryApplier,
391 container: &mut LayerNode,
392 dirty_nodes: &mut HashSet<NodeId>,
393 changed_nodes: &mut Vec<NodeId>,
394 ancestors: TranslateAncestorContext,
395) -> bool {
396 let TranslateAncestorContext {
397 ancestor_hashed: container_ancestor_hashed,
398 inherited_translated_content_context,
399 parent_content_offset,
400 parent_abs,
401 } = ancestors;
402 if cranpose_core::env_flag!("CRANPOSE_DISABLE_SCROLL_TRANSLATE") {
405 return translate_bail("fast path disabled by ablation switch");
406 }
407 let Some(node_id) = container.node_id else {
408 return translate_bail("no node id");
409 };
410 if container
411 .children
412 .iter()
413 .any(|child| !matches!(child, RenderNode::Layer(_)))
414 {
415 return translate_bail("container has own primitive children");
416 }
417 let Some(data) = snapshot_node_data(applier, node_id) else {
418 return translate_bail("container snapshot read failed");
419 };
420 let SnapshotNodeData {
421 layout_state,
422 modifier_slices,
423 resolved_modifiers: _,
424 children: fresh_children,
425 } = data;
426 if !layout_state.is_placed
427 || layout_state.size.width != container.local_bounds.width
428 || layout_state.size.height != container.local_bounds.height
429 {
430 return translate_bail("container unplaced or resized");
431 }
432 if !modifier_slices.draw_commands().is_empty()
433 || modifier_slices.annotated_text().is_some()
434 || modifier_slices.translated_content_context() != container.translated_content_context
435 {
436 return translate_bail("container draw/text/translated-context changed");
437 }
438 let clip_to_bounds = modifier_slices.clip_to_bounds();
439 if clip_to_bounds != container.clip_to_bounds {
440 return translate_bail("container clip changed");
441 }
442 let graphics_layer = graphics_layer_with_shaped_clip(
443 modifier_slices.graphics_layer().unwrap_or_default(),
444 clip_to_bounds,
445 modifier_slices.corner_shape(),
446 container.local_bounds,
447 );
448 if graphics_layer != container.graphics_layer {
449 return translate_bail("container graphics layer changed");
450 }
451 let mut old_ids = Vec::with_capacity(container.children.len());
460 for child in &container.children {
461 let RenderNode::Layer(layer) = child else {
462 return translate_bail("non-layer child");
463 };
464 let Some(child_id) = layer.node_id else {
465 return translate_bail("child without node id");
466 };
467 old_ids.push(child_id);
468 }
469 let old_index_by_id: std::collections::HashMap<NodeId, usize> = old_ids
470 .iter()
471 .enumerate()
472 .map(|(index, id)| (*id, index))
473 .collect();
474 let mut placed_fresh = Vec::with_capacity(fresh_children.len());
475 for child_id in &fresh_children {
476 let state = applier
477 .with_node::<LayoutNode, _>(*child_id, |node| node.layout_state())
478 .or_else(|_| {
479 applier.with_node::<SubcomposeLayoutNode, _>(*child_id, |node| node.layout_state())
480 });
481 let Ok(state) = state else {
482 continue;
483 };
484 if !state.is_placed {
485 continue;
486 }
487 placed_fresh.push((*child_id, state));
488 }
489 let fresh_id_set: HashSet<NodeId> = placed_fresh.iter().map(|(id, _)| *id).collect();
490 for (child_id, state) in &placed_fresh {
491 let Some(&old_index) = old_index_by_id.get(child_id) else {
492 continue;
494 };
495 let RenderNode::Layer(layer) = &container.children[old_index] else {
496 return false;
497 };
498 if dirty_nodes.contains(child_id) {
499 continue;
500 }
501 if layer.has_origin_sinks {
502 return translate_bail("child subtree publishes window origins");
503 }
504 if state.size.width != layer.local_bounds.width
505 || state.size.height != layer.local_bounds.height
506 {
507 return translate_bail("child resized");
508 }
509 }
510
511 let content_offset = layout_state.content_offset;
514 let (translation_x, translation_y) = modifier_slices
515 .graphics_layer()
516 .map(|layer| (layer.translation_x, layer.translation_y))
517 .unwrap_or((0.0, 0.0));
518 let top_left = Point {
519 x: parent_abs.content_origin.x + layout_state.position.x,
520 y: parent_abs.content_origin.y + layout_state.position.y,
521 };
522 let layer_translation = Point {
523 x: parent_abs.layer_translation.x + translation_x,
524 y: parent_abs.layer_translation.y + translation_y,
525 };
526 let window_origin = Point {
527 x: top_left.x + layer_translation.x,
528 y: top_left.y + layer_translation.y,
529 };
530 let child_origin = Point {
531 x: top_left.x + content_offset.x,
532 y: top_left.y + content_offset.y,
533 };
534 let translation_delta = Point {
535 x: layer_translation.x - container.scene_children_layer_translation.x,
536 y: layer_translation.y - container.scene_children_layer_translation.y,
537 };
538
539 let child_inherited_translated_content_context =
544 inherited_translated_content_context || container.translated_content_context;
545 let children_ancestor_hashed =
546 crate::graph_hash::layer_children_ancestor_hashed(container, container_ancestor_hashed);
547 let mut entering: std::collections::HashMap<NodeId, LayerNode> =
548 std::collections::HashMap::new();
549 for (child_id, _) in &placed_fresh {
550 if old_index_by_id.contains_key(child_id) {
551 continue;
552 }
553 let Some(mut lowered) = build_layer_node_from_applier_internal(
554 applier,
555 *child_id,
556 container.motion_context_animated,
557 child_inherited_translated_content_context,
558 Some(AbsOrigin {
559 content_origin: child_origin,
560 layer_translation,
561 }),
562 ) else {
563 continue;
565 };
566 if content_offset != Point::default() {
567 lowered.transform_to_parent =
568 lowered
569 .transform_to_parent
570 .then(ProjectiveTransform::translation(
571 content_offset.x,
572 content_offset.y,
573 ));
574 }
575 crate::graph_hash::recompute_layer_raster_cache_hashes_under(
576 &mut lowered,
577 children_ancestor_hashed,
578 );
579 entering.insert(*child_id, lowered);
580 }
581
582 let mut transform = layer_transform_to_parent(
585 container.local_bounds,
586 layout_state.position,
587 &graphics_layer,
588 );
589 if parent_content_offset != Point::default() {
590 transform = transform.then(ProjectiveTransform::translation(
591 parent_content_offset.x,
592 parent_content_offset.y,
593 ));
594 }
595 container.transform_to_parent = transform;
596 container.content_offset = content_offset;
597 if container.translated_content_context {
598 container.translated_content_offset = modifier_slices
599 .translated_content_offset()
600 .unwrap_or(content_offset);
601 }
602
603 if let Some(sink) = modifier_slices.text_field_window_origin() {
607 sink.set(window_origin);
608 }
609 if let Some(sink) = modifier_slices.viewport_window_rect() {
610 sink.set(Rect {
611 x: window_origin.x,
612 y: window_origin.y,
613 width: layout_state.size.width,
614 height: layout_state.size.height,
615 });
616 }
617 container.scene_children_origin = child_origin;
618 container.scene_children_layer_translation = layer_translation;
619
620 let mut old_by_id: std::collections::HashMap<NodeId, Box<LayerNode>> =
624 std::collections::HashMap::new();
625 for child in container.children.drain(..) {
626 let RenderNode::Layer(layer) = child else {
627 continue;
629 };
630 let child_id = layer.node_id.expect("checked above");
631 if fresh_id_set.contains(&child_id) {
632 old_by_id.insert(child_id, layer);
633 } else {
634 collect_layer_node_ids(&layer, changed_nodes);
637 }
638 }
639 let mut new_children = Vec::with_capacity(placed_fresh.len());
640 for (child_id, state) in &placed_fresh {
641 if let Some(mut layer) = old_by_id.remove(child_id) {
642 if !dirty_nodes.contains(child_id) {
643 let mut child_transform = layer_transform_to_parent(
644 layer.local_bounds,
645 state.position,
646 &layer.graphics_layer,
647 );
648 if content_offset != Point::default() {
649 child_transform = child_transform.then(ProjectiveTransform::translation(
650 content_offset.x,
651 content_offset.y,
652 ));
653 }
654 layer.transform_to_parent = child_transform;
655 let new_children_origin = Point {
656 x: child_origin.x + state.position.x + layer.content_offset.x,
657 y: child_origin.y + state.position.y + layer.content_offset.y,
658 };
659 let origin_delta = Point {
660 x: new_children_origin.x - layer.scene_children_origin.x,
661 y: new_children_origin.y - layer.scene_children_origin.y,
662 };
663 offset_scene_origins(&mut layer, origin_delta, translation_delta);
664 if let Some(moved_id) = layer.node_id {
673 changed_nodes.push(moved_id);
674 }
675 }
676 new_children.push(RenderNode::Layer(layer));
679 } else if let Some(lowered) = entering.remove(child_id) {
680 dirty_nodes.remove(child_id);
683 remove_dirty_descendants(&lowered, dirty_nodes);
684 collect_layer_node_ids(&lowered, changed_nodes);
685 new_children.push(RenderNode::Layer(Box::new(lowered)));
686 }
687 }
690 container.children = new_children;
691
692 container.has_hit_targets = container.hit_test.is_some()
694 || container.children.iter().any(|child| match child {
695 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
696 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
697 });
698 container.has_origin_sinks = modifier_slices_have_origin_sinks(&modifier_slices)
699 || container.children.iter().any(|child| match child {
700 RenderNode::Layer(child_layer) => child_layer.has_origin_sinks,
701 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
702 });
703
704 crate::graph_hash::refresh_layer_own_raster_cache_hashes(container, container_ancestor_hashed);
707 changed_nodes.push(node_id);
708 true
709}
710
711fn offset_scene_origins(layer: &mut LayerNode, origin_delta: Point, translation_delta: Point) {
715 layer.scene_children_origin.x += origin_delta.x;
716 layer.scene_children_origin.y += origin_delta.y;
717 layer.scene_children_layer_translation.x += translation_delta.x;
718 layer.scene_children_layer_translation.y += translation_delta.y;
719 for child in &mut layer.children {
720 if let RenderNode::Layer(child_layer) = child {
721 offset_scene_origins(child_layer, origin_delta, translation_delta);
722 }
723 }
724}
725
726fn layer_hit_graph_state_dirty(previous: &LayerNode, replacement: &LayerNode) -> bool {
727 if previous.hit_test.is_some() || replacement.hit_test.is_some() {
728 return true;
729 }
730
731 if !(previous.has_hit_targets || replacement.has_hit_targets) {
732 return false;
733 }
734
735 previous.has_hit_targets != replacement.has_hit_targets
736 || previous.local_bounds != replacement.local_bounds
737 || previous.transform_to_parent != replacement.transform_to_parent
738 || previous.clip_rect() != replacement.clip_rect()
739 || previous.graphics_layer.shape != replacement.graphics_layer.shape
740}
741
742fn collect_layer_node_ids(layer: &LayerNode, out: &mut Vec<NodeId>) {
743 if let Some(node_id) = layer.node_id {
744 out.push(node_id);
745 }
746 for child in &layer.children {
747 if let RenderNode::Layer(child_layer) = child {
748 collect_layer_node_ids(child_layer, out);
749 }
750 }
751}
752
753fn remove_dirty_descendants(layer: &LayerNode, dirty_nodes: &mut HashSet<NodeId>) {
754 for child in &layer.children {
755 let RenderNode::Layer(child_layer) = child else {
756 continue;
757 };
758 if let Some(node_id) = child_layer.node_id {
759 dirty_nodes.remove(&node_id);
760 }
761 remove_dirty_descendants(child_layer, dirty_nodes);
762 }
763}
764
765fn build_layer_node(
766 snapshot: BuildNodeSnapshot,
767 _root_scale: f32,
768 inherited_motion_context_animated: bool,
769) -> LayerNode {
770 build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
771}
772
773fn build_layer_node_internal(
774 snapshot: BuildNodeSnapshot,
775 inherited_motion_context_animated: bool,
776 inherited_translated_content_context: bool,
777) -> LayerNode {
778 let BuildNodeSnapshot {
779 node_id,
780 placement,
781 size,
782 content_offset,
783 motion_context_animated,
784 translated_content_context,
785 has_own_origin_sinks,
786 measured_max_width,
787 resolved_modifiers,
788 draw_commands,
789 click_actions,
790 pointer_inputs,
791 clip_to_bounds,
792 annotated_text,
793 text_style,
794 text_layout_options,
795 text_pan,
796 graphics_layer,
797 children: child_snapshots,
798 } = snapshot;
799 let local_bounds = Rect {
800 x: 0.0,
801 y: 0.0,
802 width: size.width,
803 height: size.height,
804 };
805 let graphics_layer = graphics_layer.unwrap_or_default();
806 let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
807 let isolation = isolation_reasons(&graphics_layer);
808 let cache_policy = if isolation.has_any() {
809 CachePolicy::Auto
810 } else {
811 CachePolicy::None
812 };
813 let shadow_clip = clip_to_bounds.then_some(local_bounds);
814 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
815 shape: None,
816 click_actions,
817 pointer_inputs,
818 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
819 });
820
821 let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
822 let child_translated_content_context =
823 inherited_translated_content_context || translated_content_context;
824
825 let mut children = draw_nodes(
826 node_id,
827 &draw_commands,
828 DrawPlacement::Behind,
829 size,
830 PrimitivePhase::BeforeChildren,
831 );
832 if let Some(text) = text_node_from_parts(TextNodeParts {
833 node_id,
834 local_bounds,
835 measured_max_width,
836 resolved_modifiers: &resolved_modifiers,
837 annotated_text: annotated_text.as_ref(),
838 text_style: text_style.as_ref(),
839 text_layout_options,
840 text_pan,
841 modifier_slices: None,
842 }) {
843 children.push(RenderNode::Primitive(PrimitiveEntry {
844 phase: PrimitivePhase::BeforeChildren,
845 node: PrimitiveNode::Text(Box::new(text)),
846 }));
847 }
848 let child_motion_context_animated = node_motion_context_animated;
849 for child in child_snapshots {
850 let mut child_layer = build_layer_node_internal(
851 child,
852 child_motion_context_animated,
853 child_translated_content_context,
854 );
855 if content_offset != Point::default() {
856 child_layer.transform_to_parent =
857 child_layer
858 .transform_to_parent
859 .then(ProjectiveTransform::translation(
860 content_offset.x,
861 content_offset.y,
862 ));
863 }
864 children.push(RenderNode::Layer(Box::new(child_layer)));
865 }
866 children.extend(draw_nodes(
867 node_id,
868 &draw_commands,
869 DrawPlacement::Overlay,
870 size,
871 PrimitivePhase::AfterChildren,
872 ));
873 let has_hit_targets = hit_test.is_some()
874 || children.iter().any(|child| match child {
875 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
876 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
877 });
878 let has_origin_sinks = has_own_origin_sinks
879 || children.iter().any(|child| match child {
880 RenderNode::Layer(child_layer) => child_layer.has_origin_sinks,
881 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
882 });
883
884 LayerNode {
885 node_id: Some(node_id),
886 local_bounds,
887 transform_to_parent,
888 content_offset,
889 motion_context_animated: node_motion_context_animated,
890 translated_content_context,
891 translated_content_offset: if translated_content_context {
892 content_offset
893 } else {
894 Point::default()
895 },
896 scene_children_origin: Point::default(),
901 scene_children_layer_translation: Point::default(),
902 graphics_layer,
903 clip_to_bounds,
904 shadow_clip,
905 hit_test,
906 has_hit_targets,
907 has_origin_sinks,
908 isolation,
909 cache_policy,
910 cache_hashes: LayerRasterCacheHashes::default(),
911 cache_hashes_valid: false,
912 children,
913 }
914}
915
916#[derive(Clone, Copy)]
927struct AbsOrigin {
928 content_origin: Point,
929 layer_translation: Point,
930}
931
932impl AbsOrigin {
933 const ROOT: AbsOrigin = AbsOrigin {
934 content_origin: Point { x: 0.0, y: 0.0 },
935 layer_translation: Point { x: 0.0, y: 0.0 },
936 };
937}
938
939fn build_layer_node_from_applier(
940 applier: &mut MemoryApplier,
941 node_id: NodeId,
942 _root_scale: f32,
943 inherited_motion_context_animated: bool,
944) -> Option<LayerNode> {
945 build_layer_node_from_applier_internal(
946 applier,
947 node_id,
948 inherited_motion_context_animated,
949 false,
950 Some(AbsOrigin::ROOT),
951 )
952}
953
954fn snapshot_node_data(applier: &mut MemoryApplier, node_id: NodeId) -> Option<SnapshotNodeData> {
955 if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
956 let state = node.layout_state();
957 let children = node.children.clone();
958 let modifier_slices = node.modifier_slices_snapshot();
959 SnapshotNodeData {
960 layout_state: state,
961 modifier_slices,
962 resolved_modifiers: node.resolved_modifiers(),
963 children,
964 }
965 }) {
966 return Some(data);
967 }
968
969 applier
970 .with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
971 let state = node.layout_state();
972 let children = node.active_children();
973 let modifier_slices = node.modifier_slices_snapshot();
974 SnapshotNodeData {
975 layout_state: state,
976 modifier_slices,
977 resolved_modifiers: node.resolved_modifiers(),
978 children,
979 }
980 })
981 .ok()
982}
983
984fn build_layer_node_from_applier_internal(
985 applier: &mut MemoryApplier,
986 node_id: NodeId,
987 inherited_motion_context_animated: bool,
988 inherited_translated_content_context: bool,
989 parent_abs: Option<AbsOrigin>,
990) -> Option<LayerNode> {
991 let data = snapshot_node_data(applier, node_id)?;
992 build_layer_node_from_data(
993 applier,
994 node_id,
995 data,
996 inherited_motion_context_animated,
997 inherited_translated_content_context,
998 parent_abs,
999 )
1000}
1001
1002fn build_layer_node_from_data(
1003 applier: &mut MemoryApplier,
1004 node_id: NodeId,
1005 data: SnapshotNodeData,
1006 inherited_motion_context_animated: bool,
1007 inherited_translated_content_context: bool,
1008 parent_abs: Option<AbsOrigin>,
1009) -> Option<LayerNode> {
1010 note_layer_lowered();
1011 let SnapshotNodeData {
1012 layout_state,
1013 modifier_slices,
1014 resolved_modifiers,
1015 children,
1016 } = data;
1017 if !layout_state.is_placed {
1018 return None;
1019 }
1020
1021 let local_bounds = Rect {
1022 x: 0.0,
1023 y: 0.0,
1024 width: layout_state.size.width,
1025 height: layout_state.size.height,
1026 };
1027 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
1028 eprintln!(
1029 "[scene-update-diag] build layer node={node_id:?} size=({:.2},{:.2}) pos=({:.2},{:.2})",
1030 layout_state.size.width,
1031 layout_state.size.height,
1032 layout_state.position.x,
1033 layout_state.position.y,
1034 );
1035 }
1036 let clip_to_bounds = modifier_slices.clip_to_bounds();
1037 let graphics_layer = graphics_layer_with_shaped_clip(
1038 modifier_slices.graphics_layer().unwrap_or_default(),
1039 clip_to_bounds,
1040 modifier_slices.corner_shape(),
1041 local_bounds,
1042 );
1043 let transform_to_parent =
1044 layer_transform_to_parent(local_bounds, layout_state.position, &graphics_layer);
1045 let isolation = isolation_reasons(&graphics_layer);
1046 let cache_policy = if isolation.has_any() {
1047 CachePolicy::Auto
1048 } else {
1049 CachePolicy::None
1050 };
1051 let click_actions = modifier_slices.click_handlers();
1052 let pointer_inputs = modifier_slices.pointer_inputs();
1053 let shadow_clip = clip_to_bounds.then_some(local_bounds);
1054 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
1055 shape: None,
1056 click_actions: click_actions.to_vec(),
1057 pointer_inputs: pointer_inputs.to_vec(),
1058 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
1059 });
1060
1061 modifier_slices.publish_pointer_input_size(layout_state.size);
1068
1069 let node_motion_context_animated =
1070 inherited_motion_context_animated || modifier_slices.motion_context_animated();
1071 let local_translated_content_context = modifier_slices.translated_content_context();
1072 let local_translated_content_offset = modifier_slices
1073 .translated_content_offset()
1074 .unwrap_or(layout_state.content_offset);
1075 let child_translated_content_context =
1076 inherited_translated_content_context || local_translated_content_context;
1077
1078 let this_abs = parent_abs.map(|parent| {
1093 let (tx, ty) = modifier_slices
1094 .graphics_layer()
1095 .map(|layer| (layer.translation_x, layer.translation_y))
1096 .unwrap_or((0.0, 0.0));
1097 let top_left = Point {
1098 x: parent.content_origin.x + layout_state.position.x,
1099 y: parent.content_origin.y + layout_state.position.y,
1100 };
1101 let layer_translation = Point {
1102 x: parent.layer_translation.x + tx,
1103 y: parent.layer_translation.y + ty,
1104 };
1105 (top_left, layer_translation)
1106 });
1107 if let Some((top_left, layer_translation)) = this_abs {
1108 let window_origin = Point {
1109 x: top_left.x + layer_translation.x,
1110 y: top_left.y + layer_translation.y,
1111 };
1112 if let Some(sink) = modifier_slices.text_field_window_origin() {
1113 sink.set(window_origin);
1114 }
1115 if let Some(sink) = modifier_slices.viewport_window_rect() {
1116 sink.set(Rect {
1117 x: window_origin.x,
1118 y: window_origin.y,
1119 width: layout_state.size.width,
1120 height: layout_state.size.height,
1121 });
1122 }
1123 }
1124 let child_abs = this_abs.map(|(top_left, layer_translation)| AbsOrigin {
1132 content_origin: Point {
1133 x: top_left.x + layout_state.content_offset.x,
1134 y: top_left.y + layout_state.content_offset.y,
1135 },
1136 layer_translation,
1137 });
1138
1139 let mut render_children = draw_nodes(
1140 node_id,
1141 modifier_slices.draw_commands(),
1142 DrawPlacement::Behind,
1143 layout_state.size,
1144 PrimitivePhase::BeforeChildren,
1145 );
1146 if let Some(text) = text_node_from_parts(TextNodeParts {
1147 node_id,
1148 local_bounds,
1149 measured_max_width: layout_state
1150 .measurement_constraints
1151 .max_width
1152 .is_finite()
1153 .then_some(layout_state.measurement_constraints.max_width),
1154 resolved_modifiers: &resolved_modifiers,
1155 annotated_text: modifier_slices.annotated_text(),
1156 text_style: modifier_slices.text_style(),
1157 text_layout_options: modifier_slices.text_layout_options(),
1158 text_pan: modifier_slices.text_pan_resolver(),
1159 modifier_slices: Some(modifier_slices.as_ref()),
1160 }) {
1161 render_children.push(RenderNode::Primitive(PrimitiveEntry {
1162 phase: PrimitivePhase::BeforeChildren,
1163 node: PrimitiveNode::Text(Box::new(text)),
1164 }));
1165 }
1166 let child_motion_context_animated = node_motion_context_animated;
1167 for child_id in children {
1168 let Some(mut child_layer) = build_layer_node_from_applier_internal(
1169 applier,
1170 child_id,
1171 child_motion_context_animated,
1172 child_translated_content_context,
1173 child_abs,
1174 ) else {
1175 continue;
1176 };
1177 if layout_state.content_offset != Point::default() {
1178 child_layer.transform_to_parent =
1179 child_layer
1180 .transform_to_parent
1181 .then(ProjectiveTransform::translation(
1182 layout_state.content_offset.x,
1183 layout_state.content_offset.y,
1184 ));
1185 }
1186 render_children.push(RenderNode::Layer(Box::new(child_layer)));
1187 }
1188 render_children.extend(draw_nodes(
1189 node_id,
1190 modifier_slices.draw_commands(),
1191 DrawPlacement::Overlay,
1192 layout_state.size,
1193 PrimitivePhase::AfterChildren,
1194 ));
1195 let has_hit_targets = hit_test.is_some()
1196 || render_children.iter().any(|child| match child {
1197 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
1198 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1199 });
1200 let has_origin_sinks = modifier_slices_have_origin_sinks(&modifier_slices)
1201 || render_children.iter().any(|child| match child {
1202 RenderNode::Layer(child_layer) => child_layer.has_origin_sinks,
1203 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1204 });
1205
1206 let layer = LayerNode {
1207 node_id: Some(node_id),
1208 local_bounds,
1209 transform_to_parent,
1210 content_offset: layout_state.content_offset,
1211 motion_context_animated: node_motion_context_animated,
1212 translated_content_context: local_translated_content_context,
1213 translated_content_offset: if local_translated_content_context {
1214 local_translated_content_offset
1215 } else {
1216 Point::default()
1217 },
1218 scene_children_origin: child_abs.map(|c| c.content_origin).unwrap_or_default(),
1222 scene_children_layer_translation: child_abs
1223 .map(|c| c.layer_translation)
1224 .unwrap_or_default(),
1225 graphics_layer,
1226 clip_to_bounds,
1227 shadow_clip,
1228 hit_test,
1229 has_hit_targets,
1230 has_origin_sinks,
1231 isolation,
1232 cache_policy,
1233 cache_hashes: LayerRasterCacheHashes::default(),
1234 cache_hashes_valid: false,
1235 children: render_children,
1236 };
1237 Some(layer)
1238}
1239
1240struct RecorderSlot {
1249 generation: u64,
1250 handles: [Option<Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>>; 2],
1251 recordings: [Option<Rc<cranpose_ui_graphics::CommandRecording>>; 2],
1260 replay: cranpose_ui_graphics::CommandReplayState,
1264 replay_epoch: Option<u64>,
1267 saved_emission: Option<SavedReplayEmission>,
1271}
1272
1273struct SavedReplayEmission {
1288 spans: Vec<cranpose_ui_graphics::FrameSpan>,
1292 center: cranpose_ui_graphics::Point,
1294 primitives: Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>,
1297 recording: Rc<cranpose_ui_graphics::CommandRecording>,
1301 epoch: u64,
1304 generation: u64,
1308}
1309
1310fn stale_transition_enabled() -> bool {
1320 matches!(
1321 crate::debug_toggles::debug_toggle("CRANPOSE_STALE_TRANSITION").as_deref(),
1322 Some(value) if !value.is_empty() && value != "0"
1323 )
1324}
1325
1326fn sanitized_replay_spans(
1342 spans: &[cranpose_ui_graphics::FrameSpan],
1343) -> Vec<cranpose_ui_graphics::FrameSpan> {
1344 use cranpose_ui_graphics::FrameSpan;
1345 spans
1346 .iter()
1347 .map(|span| match span {
1348 FrameSpan::Retained {
1349 capture: true,
1350 range,
1351 ..
1352 } => FrameSpan::Dynamic { range: *range },
1353 FrameSpan::Retained {
1354 slot,
1355 capture: false,
1356 slot_offset,
1357 range,
1358 tape_range,
1359 transform,
1360 recolors: _,
1361 bounds,
1362 } => FrameSpan::Retained {
1363 slot: *slot,
1364 capture: false,
1365 slot_offset: *slot_offset,
1366 range: *range,
1367 tape_range: *tape_range,
1368 transform: *transform,
1369 recolors: Vec::new(),
1370 bounds: *bounds,
1371 },
1372 FrameSpan::Dynamic { range } => FrameSpan::Dynamic { range: *range },
1373 })
1374 .collect()
1375}
1376
1377fn saved_emission_available(id: DrawCommandId) -> bool {
1383 let Some(epoch) = RETAINED_FEED_EPOCH.with(std::cell::Cell::get) else {
1384 return false;
1385 };
1386 let generation = RECORDING_GENERATION.with(std::cell::Cell::get);
1387 COMMAND_RECORDINGS.with(|map| {
1388 map.borrow()
1389 .get(&id)
1390 .and_then(|slot| slot.saved_emission.as_ref())
1391 .is_some_and(|saved| {
1392 saved.generation.wrapping_add(1) == generation && saved.epoch == epoch
1393 })
1394 })
1395}
1396
1397fn take_saved_emission(id: DrawCommandId) -> Option<SavedReplayEmission> {
1402 COMMAND_RECORDINGS.with(|map| {
1403 map.borrow_mut()
1404 .get_mut(&id)
1405 .and_then(|slot| slot.saved_emission.take())
1406 })
1407}
1408
1409fn store_saved_emission(id: DrawCommandId, saved: Option<SavedReplayEmission>) {
1414 COMMAND_RECORDINGS.with(|map| {
1415 if let Some(slot) = map.borrow_mut().get_mut(&id) {
1416 slot.saved_emission = saved;
1417 }
1418 });
1419}
1420
1421thread_local! {
1422 static COMMAND_RECORDINGS: std::cell::RefCell<
1423 std::collections::HashMap<DrawCommandId, RecorderSlot, cranpose_ui_graphics::FxBuildHasher>,
1424 > = std::cell::RefCell::new(std::collections::HashMap::default());
1425 static RECORDING_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1426 static RETAINED_FEED_EPOCH: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
1427}
1428
1429pub fn set_retained_feed_epoch(epoch: Option<u64>) {
1438 RETAINED_FEED_EPOCH.with(|cell| cell.set(epoch));
1439}
1440
1441thread_local! {
1442 static CONFIRMED_RETAINED_SLOTS: std::cell::RefCell<
1443 std::collections::HashMap<(DrawCommandId, u32), u64, cranpose_ui_graphics::FxBuildHasher>,
1444 > = std::cell::RefCell::new(std::collections::HashMap::default());
1445}
1446
1447pub fn confirm_retained_slot(command: DrawCommandId, slot: u32, generation: u64) {
1456 CONFIRMED_RETAINED_SLOTS.with(|map| {
1457 map.borrow_mut().insert((command, slot), generation);
1458 });
1459}
1460
1461pub fn revoke_retained_slot(command: DrawCommandId, slot: u32) {
1464 CONFIRMED_RETAINED_SLOTS.with(|map| {
1465 map.borrow_mut().remove(&(command, slot));
1466 });
1467}
1468
1469pub fn clear_retained_slot_confirmations() {
1471 CONFIRMED_RETAINED_SLOTS.with(|map| map.borrow_mut().clear());
1472}
1473
1474pub fn retained_slot_confirmed(command: DrawCommandId, slot: u32) -> bool {
1480 let Some(epoch) = RETAINED_FEED_EPOCH.with(std::cell::Cell::get) else {
1481 return false;
1482 };
1483 CONFIRMED_RETAINED_SLOTS.with(|map| map.borrow().get(&(command, slot)) == Some(&epoch))
1484}
1485
1486thread_local! {
1487 static VERIFY_EXECUTOR: std::cell::Cell<
1488 Option<&'static dyn cranpose_ui_graphics::VerifyExecutor>,
1489 > = const { std::cell::Cell::new(None) };
1490}
1491
1492pub fn set_verify_executor(pool: Option<&'static dyn cranpose_ui_graphics::VerifyExecutor>) {
1497 VERIFY_EXECUTOR.with(|cell| cell.set(pool));
1498}
1499
1500pub fn verify_executor() -> Option<&'static dyn cranpose_ui_graphics::VerifyExecutor> {
1502 VERIFY_EXECUTOR.with(|cell| cell.get())
1503}
1504
1505#[doc(hidden)]
1510pub fn clear_command_recordings_for_tests() {
1511 COMMAND_RECORDINGS.with(|map| map.borrow_mut().clear());
1512}
1513
1514fn bump_recording_generation() {
1524 let generation = RECORDING_GENERATION.with(|cell| {
1525 let next = cell.get().wrapping_add(1);
1526 cell.set(next);
1527 next
1528 });
1529 if generation.is_multiple_of(512) {
1530 COMMAND_RECORDINGS.with(|map| {
1531 map.borrow_mut()
1532 .retain(|_, slot| generation.wrapping_sub(slot.generation) <= 64);
1533 });
1534 }
1535}
1536
1537fn acquire_recording(
1538 id: DrawCommandId,
1539) -> (
1540 cranpose_ui_graphics::CommandRecording,
1541 Vec<cranpose_ui_graphics::DrawPrimitive>,
1542 Option<cranpose_ui_graphics::CommandReplayState>,
1543) {
1544 let feed_epoch = RETAINED_FEED_EPOCH.with(std::cell::Cell::get);
1545 COMMAND_RECORDINGS.with(|map| {
1546 let mut map = map.borrow_mut();
1547 let Some(slot) = map.get_mut(&id) else {
1548 return (
1549 cranpose_ui_graphics::CommandRecording::default(),
1550 Vec::new(),
1551 feed_epoch.map(|_| cranpose_ui_graphics::CommandReplayState::default()),
1552 );
1553 };
1554 let mut recording = cranpose_ui_graphics::CommandRecording::default();
1559 for shared in &mut slot.recordings {
1560 if shared
1561 .as_ref()
1562 .is_some_and(|shared| Rc::strong_count(shared) == 1)
1563 {
1564 let shared = shared.take().expect("checked some above");
1565 recording = Rc::try_unwrap(shared).expect("sole owner checked above");
1566 break;
1567 }
1568 }
1569 let replay = feed_epoch.map(|epoch| {
1573 if slot.replay_epoch == Some(epoch) {
1574 std::mem::take(&mut slot.replay)
1575 } else {
1576 cranpose_ui_graphics::CommandReplayState::default()
1577 }
1578 });
1579 for handle in &mut slot.handles {
1580 if handle
1581 .as_ref()
1582 .is_some_and(|shared| Rc::strong_count(shared) == 1)
1583 {
1584 let shared = handle.take().expect("checked some above");
1585 let storage = Rc::try_unwrap(shared).expect("sole owner checked above");
1586 return (recording, storage, replay);
1587 }
1588 }
1589 (recording, Vec::new(), replay)
1590 })
1591}
1592
1593fn publish_recording(
1600 id: DrawCommandId,
1601 recording: cranpose_ui_graphics::CommandRecording,
1602 primitives: Vec<cranpose_ui_graphics::DrawPrimitive>,
1603 replay: Option<cranpose_ui_graphics::CommandReplayState>,
1604) -> (
1605 Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>,
1606 Rc<cranpose_ui_graphics::CommandRecording>,
1607) {
1608 let shared = Rc::new(primitives);
1609 let recording = Rc::new(recording);
1610 COMMAND_RECORDINGS.with(|map| {
1611 let mut map = map.borrow_mut();
1612 let generation = RECORDING_GENERATION.with(std::cell::Cell::get);
1613 let slot = map.entry(id).or_insert_with(|| RecorderSlot {
1614 generation,
1615 handles: [None, None],
1616 recordings: [None, None],
1617 replay: cranpose_ui_graphics::CommandReplayState::default(),
1618 replay_epoch: None,
1619 saved_emission: None,
1620 });
1621 slot.generation = generation;
1622 if let Some(replay) = replay {
1623 slot.replay = replay;
1624 slot.replay_epoch = RETAINED_FEED_EPOCH.with(std::cell::Cell::get);
1625 }
1626 slot.recordings[1] = slot.recordings[0].take();
1629 slot.recordings[0] = Some(recording.clone());
1630 slot.handles[1] = slot.handles[0].take();
1631 slot.handles[0] = Some(shared.clone());
1632 });
1633 (shared, recording)
1634}
1635
1636fn draw_nodes(
1637 node_id: NodeId,
1638 commands: &[DrawCommand],
1639 placement: DrawPlacement,
1640 size: Size,
1641 phase: PrimitivePhase,
1642) -> Vec<RenderNode> {
1643 let mut nodes = Vec::new();
1644 let stale_transition = stale_transition_enabled();
1645 for (command_index, command) in commands.iter().enumerate() {
1646 let id = DrawCommandId {
1647 node_id,
1648 command_index: command_index as u32,
1649 placement,
1650 };
1651 let (recording, storage, mut replay) = acquire_recording(id);
1652 let stale_available = stale_transition && replay.is_some() && saved_emission_available(id);
1653 let mut ctx = replay
1654 .as_mut()
1655 .map(|state| crate::style_shared::CommandReplayContext {
1656 state,
1657 stale_available,
1658 serve_stale: false,
1659 });
1660 let (primitives, recording, frame) = primitives_for_placement_verified(
1661 command,
1662 placement,
1663 size,
1664 recording,
1665 storage,
1666 &mut ctx,
1667 Some(id),
1668 );
1669 if ctx.is_some_and(|ctx| ctx.serve_stale) {
1670 publish_recording(id, recording, primitives, replay);
1679 if let Some(saved) = take_saved_emission(id) {
1680 let frame = cranpose_ui_graphics::CommandReplayFrame {
1681 center: saved.center,
1682 spans: saved.spans,
1683 fallback: Some(saved.recording),
1684 };
1685 nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_replayed(
1686 phase,
1687 Some(id),
1688 saved.primitives,
1689 Some(Box::new(frame)),
1690 )));
1691 } else {
1692 debug_assert!(false, "serve_stale without a saved emission");
1697 }
1698 continue;
1699 }
1700 let has_replay_spans = frame.as_ref().is_some_and(|frame| !frame.spans.is_empty());
1703 if primitives.is_empty() && primitives.capacity() == 0 && !has_replay_spans {
1709 retain_empty_draw_command(&mut nodes, phase, id, placement, command);
1710 continue;
1711 }
1712 let (shared, published_recording) = publish_recording(id, recording, primitives, replay);
1713 if stale_transition {
1714 let saved = frame.as_ref().and_then(|frame| {
1722 RETAINED_FEED_EPOCH
1723 .with(std::cell::Cell::get)
1724 .map(|epoch| SavedReplayEmission {
1725 spans: sanitized_replay_spans(&frame.spans),
1726 center: frame.center,
1727 primitives: shared.clone(),
1728 recording: published_recording.clone(),
1729 epoch,
1730 generation: RECORDING_GENERATION.with(std::cell::Cell::get),
1731 })
1732 });
1733 store_saved_emission(id, saved);
1734 }
1735 if shared.is_empty() && !has_replay_spans {
1736 retain_empty_draw_command(&mut nodes, phase, id, placement, command);
1737 continue;
1738 }
1739 let frame = frame.map(|mut frame| {
1744 frame.fallback = Some(published_recording);
1745 frame
1746 });
1747 nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_replayed(
1751 phase,
1752 Some(id),
1753 shared,
1754 frame.map(Box::new),
1755 )));
1756 }
1757 nodes
1758}
1759
1760fn retain_empty_draw_command(
1761 nodes: &mut Vec<RenderNode>,
1762 phase: PrimitivePhase,
1763 id: DrawCommandId,
1764 placement: DrawPlacement,
1765 command: &DrawCommand,
1766) {
1767 if matches!(
1768 (placement, command),
1769 (DrawPlacement::Behind, DrawCommand::Behind(_))
1770 | (DrawPlacement::Overlay, DrawCommand::Overlay(_))
1771 | (_, DrawCommand::WithContent(_))
1772 ) {
1773 nodes.push(RenderNode::DrawRun(DrawRunNode::for_command(
1774 phase,
1775 Some(id),
1776 Vec::new(),
1777 )));
1778 }
1779}
1780
1781#[doc(hidden)]
1787pub fn draw_command_nodes_for_tests(
1788 node_id: NodeId,
1789 commands: &[DrawCommand],
1790 placement: DrawPlacement,
1791 size: Size,
1792 phase: PrimitivePhase,
1793) -> Vec<RenderNode> {
1794 bump_recording_generation();
1795 draw_nodes(node_id, commands, placement, size, phase)
1796}
1797
1798struct TextNodeParts<'a> {
1799 node_id: NodeId,
1800 local_bounds: Rect,
1801 measured_max_width: Option<f32>,
1802 resolved_modifiers: &'a ResolvedModifiers,
1803 annotated_text: Option<&'a AnnotatedString>,
1804 text_style: Option<&'a TextStyle>,
1805 text_layout_options: Option<TextLayoutOptions>,
1806 text_pan: Option<TextPanResolver>,
1807 modifier_slices: Option<&'a ModifierNodeSlices>,
1808}
1809
1810fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
1811 let TextNodeParts {
1812 node_id,
1813 local_bounds,
1814 measured_max_width,
1815 resolved_modifiers,
1816 annotated_text,
1817 text_style,
1818 text_layout_options,
1819 text_pan,
1820 modifier_slices,
1821 } = parts;
1822 let value = annotated_text?;
1823 let default_text_style = TextStyle::default();
1824 let text_style = text_style.cloned().unwrap_or(default_text_style);
1825 let options = text_layout_options.unwrap_or_default().normalized();
1826 let padding = resolved_modifiers.padding();
1827 let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
1828 if content_width <= 0.0 {
1829 return None;
1830 }
1831
1832 let pan_offset = text_pan
1836 .as_ref()
1837 .map(|resolve| resolve(content_width))
1838 .unwrap_or(0.0);
1839 let pans_horizontally = text_pan.is_some();
1840
1841 let max_width = if pans_horizontally {
1842 None
1843 } else {
1844 let measure_width =
1845 resolve_text_measure_width(content_width, padding, measured_max_width, options);
1846 Some(measure_width).filter(|width| width.is_finite() && *width > 0.0)
1847 };
1848 let prepared = modifier_slices
1849 .and_then(|slices| slices.prepare_text_layout(max_width))
1850 .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
1851 let visual_style = prepared.visual_style.clone();
1852 let measured_draw_width = prepared.metrics.width.max(0.0);
1853 let draw_width = if options.overflow == TextOverflow::Visible || pans_horizontally {
1854 measured_draw_width
1855 } else {
1856 measured_draw_width.min(content_width)
1857 };
1858 let alignment_offset = resolve_text_horizontal_offset(
1859 &text_style,
1860 prepared.text.text.as_str(),
1861 content_width,
1862 prepared.metrics.width,
1863 );
1864 let rect = Rect {
1865 x: padding.left + alignment_offset - pan_offset,
1866 y: padding.top,
1867 width: draw_width,
1868 height: prepared.metrics.height,
1869 };
1870 let text_bounds = Rect {
1871 x: padding.left,
1872 y: padding.top,
1873 width: content_width,
1874 height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
1875 };
1876 let font_size = visual_style.resolve_font_size(14.0);
1877 let expanded_bounds =
1878 expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
1879 let clip = if options.overflow == TextOverflow::Visible && !pans_horizontally {
1880 None
1881 } else {
1882 Some(pad_clip_rect(expanded_bounds))
1883 };
1884
1885 Some(TextPrimitiveNode {
1886 node_id,
1887 rect,
1888 text: std::rc::Rc::new(prepared.text),
1889 text_style: visual_style,
1890 font_size,
1891 layout_options: options,
1892 clip,
1893 })
1894}
1895
1896fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
1897 let placement = parent
1898 .map(|parent_box| Point {
1899 x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
1900 y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
1901 })
1902 .unwrap_or_default();
1903 let mut children = Vec::with_capacity(node.children.len());
1904 for child in &node.children {
1905 children.push(layout_box_to_snapshot(child, Some(node)));
1906 }
1907 let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
1908 let graphics_layer = graphics_layer_with_shaped_clip(
1909 base_graphics_layer.clone().unwrap_or_default(),
1910 node.node_data.modifier_slices.clip_to_bounds(),
1911 node.node_data.modifier_slices.corner_shape(),
1912 Rect {
1913 x: 0.0,
1914 y: 0.0,
1915 width: node.rect.width,
1916 height: node.rect.height,
1917 },
1918 );
1919 let has_graphics_layer =
1920 base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
1921
1922 BuildNodeSnapshot {
1923 node_id: node.node_id,
1924 placement,
1925 size: Size {
1926 width: node.rect.width,
1927 height: node.rect.height,
1928 },
1929 content_offset: node.content_offset,
1930 motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
1931 translated_content_context: node.node_data.modifier_slices.translated_content_context(),
1932 has_own_origin_sinks: modifier_slices_have_origin_sinks(&node.node_data.modifier_slices),
1933 measured_max_width: None,
1934 resolved_modifiers: node.node_data.resolved_modifiers,
1935 draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
1936 click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
1937 pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
1938 clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
1939 annotated_text: node.node_data.modifier_slices.annotated_string(),
1940 text_style: node.node_data.modifier_slices.text_style().cloned(),
1941 text_layout_options: node.node_data.modifier_slices.text_layout_options(),
1942 text_pan: node.node_data.modifier_slices.text_pan_resolver(),
1943 graphics_layer: has_graphics_layer.then_some(graphics_layer),
1944 children,
1945 }
1946}
1947
1948fn modifier_slices_have_origin_sinks(slices: &ModifierNodeSlices) -> bool {
1953 slices.text_field_window_origin().is_some() || slices.viewport_window_rect().is_some()
1954}
1955
1956fn graphics_layer_with_shaped_clip(
1957 mut graphics_layer: GraphicsLayer,
1958 clip_to_bounds: bool,
1959 corner_shape: Option<RoundedCornerShape>,
1960 local_bounds: Rect,
1961) -> GraphicsLayer {
1962 if !clip_to_bounds {
1963 return graphics_layer;
1964 }
1965
1966 let Some(corner_shape) = corner_shape else {
1967 return graphics_layer;
1968 };
1969 let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
1970 if radii.top_left <= f32::EPSILON
1971 && radii.top_right <= f32::EPSILON
1972 && radii.bottom_right <= f32::EPSILON
1973 && radii.bottom_left <= f32::EPSILON
1974 {
1975 return graphics_layer;
1976 }
1977
1978 if let Some(existing) = graphics_layer.render_effect.take() {
1979 let rounded_clip = rounded_corner_alpha_mask_effect(
1980 local_bounds.width,
1981 local_bounds.height,
1982 radii,
1983 ROUNDED_CLIP_EDGE_FEATHER,
1984 );
1985 graphics_layer.render_effect = Some(existing.then(rounded_clip));
1986 } else {
1987 graphics_layer.shape = LayerShape::Rounded(corner_shape);
1988 graphics_layer.clip = true;
1989 }
1990 graphics_layer
1991}
1992
1993fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
1994 IsolationReasons {
1995 explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
1996 shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
1997 effect: layer.render_effect.is_some(),
1998 backdrop: layer.backdrop_effect.is_some(),
1999 group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
2000 && layer.alpha < 1.0,
2001 blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
2002 }
2003}
2004
2005fn pad_clip_rect(rect: Rect) -> Rect {
2006 Rect {
2007 x: rect.x - TEXT_CLIP_PAD,
2008 y: rect.y - TEXT_CLIP_PAD,
2009 width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
2010 height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
2011 }
2012}
2013
2014fn expand_text_bounds_for_baseline_shift(
2015 text_bounds: Rect,
2016 text_style: &TextStyle,
2017 font_size: f32,
2018) -> Rect {
2019 let baseline_shift_px = text_style
2020 .span_style
2021 .baseline_shift
2022 .filter(|shift| shift.is_specified())
2023 .map(|shift| -(shift.0 * font_size))
2024 .unwrap_or(0.0);
2025 if baseline_shift_px == 0.0 {
2026 return text_bounds;
2027 }
2028
2029 if baseline_shift_px < 0.0 {
2030 Rect {
2031 x: text_bounds.x,
2032 y: text_bounds.y + baseline_shift_px,
2033 width: text_bounds.width,
2034 height: (text_bounds.height - baseline_shift_px).max(0.0),
2035 }
2036 } else {
2037 Rect {
2038 x: text_bounds.x,
2039 y: text_bounds.y,
2040 width: text_bounds.width,
2041 height: (text_bounds.height + baseline_shift_px).max(0.0),
2042 }
2043 }
2044}
2045
2046pub fn resolve_text_measure_width(
2073 content_width: f32,
2074 padding: cranpose_ui::EdgeInsets,
2075 measured_max_width: Option<f32>,
2076 options: TextLayoutOptions,
2077) -> f32 {
2078 let width = content_width.max(0.0);
2079 if let Some(max_width) = measured_max_width.filter(|w| w.is_finite() && *w > 0.0) {
2080 let measured_content_width = (max_width - padding.left - padding.right).max(0.0);
2081 if measured_content_width <= width {
2082 return measured_content_width;
2083 }
2084
2085 let may_expand_to_avoid_synthetic_wrap = options.soft_wrap
2086 && options.max_lines == usize::MAX
2087 && options.overflow == TextOverflow::Clip;
2088 if may_expand_to_avoid_synthetic_wrap {
2089 return measured_content_width;
2090 }
2091 }
2092 width
2093}
2094
2095pub fn text_align_fraction(text_style: &TextStyle, text: &str) -> f32 {
2108 let paragraph_style = &text_style.paragraph_style;
2109 let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
2110 let rtl = direction == cranpose_ui::text::ResolvedTextDirection::Rtl;
2111 match paragraph_style.text_align {
2112 TextAlign::Center => 0.5,
2113 TextAlign::End | TextAlign::Right => 1.0,
2114 TextAlign::Start | TextAlign::Left | TextAlign::Justify | TextAlign::Unspecified => {
2119 if rtl {
2120 1.0
2121 } else {
2122 0.0
2123 }
2124 }
2125 }
2126}
2127
2128fn resolve_text_horizontal_offset(
2129 text_style: &TextStyle,
2130 text: &str,
2131 content_width: f32,
2132 measured_width: f32,
2133) -> f32 {
2134 let remaining = (content_width - measured_width).max(0.0);
2135 remaining * text_align_fraction(text_style, text)
2136}
2137
2138#[cfg(test)]
2139mod tests {
2140 use std::{cell::RefCell, rc::Rc};
2141
2142 use cranpose_foundation::lazy::{LazyListScope, LazyListState, rememberLazyListState};
2143 use cranpose_ui::{
2144 Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
2145 LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
2146 ScrollState, Size, Spacer, Text, TextStyle,
2147 text::{AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion},
2148 };
2149 use cranpose_ui_graphics::{
2150 Brush, DrawPrimitive, DrawScope as _, DrawScopeDefault, GraphicsLayer, RenderEffect,
2151 };
2152
2153 use super::*;
2154
2155 fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
2156 for child in &layer.children {
2157 match child {
2158 RenderNode::Primitive(primitive) => {
2159 let PrimitiveNode::Text(text) = &primitive.node else {
2160 continue;
2161 };
2162 if text.text.text == label {
2163 return Some(text.text_style.paragraph_style.text_motion);
2164 }
2165 }
2166 RenderNode::Layer(child_layer) => {
2167 if let Some(motion) = find_text_motion(child_layer, label) {
2168 return Some(motion);
2169 }
2170 }
2171 RenderNode::DrawRun(_) => {}
2172 }
2173 }
2174
2175 None
2176 }
2177
2178 fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
2179 for child in &layer.children {
2180 match child {
2181 RenderNode::Primitive(primitive) => {
2182 let PrimitiveNode::Text(text) = &primitive.node else {
2183 continue;
2184 };
2185 labels.push(text.text.text.clone());
2186 }
2187 RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
2188 RenderNode::DrawRun(_) => {}
2189 }
2190 }
2191 }
2192
2193 fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
2194 fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
2195 for child in &layer.children {
2196 match child {
2197 RenderNode::Primitive(primitive) => {
2198 let PrimitiveNode::Text(text) = &primitive.node else {
2199 continue;
2200 };
2201 if text.text.text == label {
2202 let quad = transform.map_rect(text.rect);
2203 let top = quad
2204 .iter()
2205 .map(|point| point[1])
2206 .fold(f32::INFINITY, f32::min);
2207 return top.is_finite().then_some(top);
2208 }
2209 }
2210 RenderNode::Layer(child_layer) => {
2211 let child_transform = child_layer.transform_to_parent.then(transform);
2212 if let Some(top) = search(child_layer, label, child_transform) {
2213 return Some(top);
2214 }
2215 }
2216 RenderNode::DrawRun(_) => {}
2217 }
2218 }
2219 None
2220 }
2221
2222 search(layer, label, ProjectiveTransform::identity())
2223 }
2224
2225 fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
2226 if layer.node_id == Some(node_id) {
2227 return Some(layer);
2228 }
2229 layer.children.iter().find_map(|child| match child {
2230 RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
2231 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
2232 })
2233 }
2234
2235 fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
2236 fn search(
2237 layer: &LayerNode,
2238 node_id: NodeId,
2239 transform: ProjectiveTransform,
2240 ) -> Option<Point> {
2241 if layer.node_id == Some(node_id) {
2242 return Some(transform.map_point(Point::default()));
2243 }
2244 layer.children.iter().find_map(|child| match child {
2245 RenderNode::Layer(child_layer) => search(
2246 child_layer,
2247 node_id,
2248 child_layer.transform_to_parent.then(transform),
2249 ),
2250 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
2251 })
2252 }
2253
2254 search(layer, node_id, ProjectiveTransform::identity())
2255 }
2256
2257 fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
2258 if layer.translated_content_context {
2259 return Some(layer.translated_content_offset);
2260 }
2261 for child in &layer.children {
2262 if let RenderNode::Layer(child_layer) = child
2263 && let Some(offset) = find_translated_content_offset(child_layer)
2264 {
2265 return Some(offset);
2266 }
2267 }
2268 None
2269 }
2270
2271 fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
2272 layer
2273 .graphics_layer
2274 .render_effect
2275 .as_ref()
2276 .is_some_and(RenderEffect::contains_runtime_shader)
2277 || layer.children.iter().any(|child| match child {
2278 RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
2279 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
2280 })
2281 }
2282
2283 fn build_layer_node_for_test(
2284 snapshot: BuildNodeSnapshot,
2285 scale: f32,
2286 has_external_backdrop_input: bool,
2287 ) -> LayerNode {
2288 let app_context = cranpose_ui::AppContext::new();
2289 app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
2290 }
2291
2292 fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
2293 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
2294 scope.push_recorded(vec![DrawPrimitive::Rect {
2295 rect: Rect {
2296 x: 3.0,
2297 y: 4.0,
2298 width: 20.0,
2299 height: 8.0,
2300 },
2301 brush: Brush::solid(Color::WHITE),
2302 stroke: None,
2303 }]);
2304 }));
2305
2306 let child = BuildNodeSnapshot {
2307 node_id: 2,
2308 placement: Point { x: 11.0, y: 7.0 },
2309 size: Size {
2310 width: 40.0,
2311 height: 20.0,
2312 },
2313 content_offset: Point::default(),
2314 motion_context_animated: false,
2315 translated_content_context: false,
2316 has_own_origin_sinks: false,
2317 measured_max_width: None,
2318 resolved_modifiers: ResolvedModifiers::default(),
2319 draw_commands: vec![child_command],
2320 click_actions: vec![],
2321 pointer_inputs: vec![],
2322 clip_to_bounds: false,
2323 annotated_text: None,
2324 text_style: None,
2325 text_layout_options: None,
2326 text_pan: None,
2327 graphics_layer: None,
2328 children: vec![],
2329 };
2330
2331 BuildNodeSnapshot {
2332 node_id: 1,
2333 placement: Point::default(),
2334 size: Size {
2335 width: 80.0,
2336 height: 50.0,
2337 },
2338 content_offset: Point::default(),
2339 motion_context_animated: false,
2340 translated_content_context: false,
2341 has_own_origin_sinks: false,
2342 measured_max_width: None,
2343 resolved_modifiers: ResolvedModifiers::default(),
2344 draw_commands: vec![],
2345 click_actions: vec![],
2346 pointer_inputs: vec![],
2347 clip_to_bounds: false,
2348 annotated_text: None,
2349 text_style: None,
2350 text_layout_options: None,
2351 text_pan: None,
2352 graphics_layer: Some(GraphicsLayer {
2353 translation_x: tx,
2354 ..GraphicsLayer::default()
2355 }),
2356 children: vec![child],
2357 }
2358 }
2359
2360 #[test]
2361 fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
2362 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
2363 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
2364
2365 let RenderNode::Layer(static_child) = &static_graph.children[0] else {
2366 panic!("expected child layer");
2367 };
2368 let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
2369 panic!("expected child layer");
2370 };
2371 let RenderNode::DrawRun(static_run) = &static_child.children[0] else {
2372 panic!("expected draw run");
2373 };
2374 let static_draw = &static_run.primitives[0];
2375 let RenderNode::DrawRun(moved_run) = &moved_child.children[0] else {
2376 panic!("expected draw run");
2377 };
2378 let moved_draw = &moved_run.primitives[0];
2379
2380 assert_ne!(
2381 static_graph.transform_to_parent, moved_graph.transform_to_parent,
2382 "parent transform should encode translation"
2383 );
2384 assert_eq!(
2385 static_draw, moved_draw,
2386 "child local primitive geometry must stay stable under parent translation"
2387 );
2388 }
2389
2390 #[test]
2391 fn stored_content_hash_ignores_parent_translation() {
2392 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
2393 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
2394
2395 assert_eq!(
2396 static_graph.target_content_hash(),
2397 moved_graph.target_content_hash(),
2398 "parent rigid motion must not invalidate the subtree content hash"
2399 );
2400 }
2401
2402 #[test]
2403 fn parent_content_offset_is_encoded_in_child_transform() {
2404 let child = BuildNodeSnapshot {
2405 node_id: 2,
2406 placement: Point { x: 11.0, y: 7.0 },
2407 size: Size {
2408 width: 40.0,
2409 height: 20.0,
2410 },
2411 content_offset: Point::default(),
2412 motion_context_animated: false,
2413 translated_content_context: false,
2414 has_own_origin_sinks: false,
2415 measured_max_width: None,
2416 resolved_modifiers: ResolvedModifiers::default(),
2417 draw_commands: vec![],
2418 click_actions: vec![],
2419 pointer_inputs: vec![],
2420 clip_to_bounds: false,
2421 annotated_text: None,
2422 text_style: None,
2423 text_layout_options: None,
2424 text_pan: None,
2425 graphics_layer: None,
2426 children: vec![],
2427 };
2428
2429 let parent = BuildNodeSnapshot {
2430 node_id: 1,
2431 placement: Point::default(),
2432 size: Size {
2433 width: 80.0,
2434 height: 50.0,
2435 },
2436 content_offset: Point { x: 13.0, y: -9.0 },
2437 motion_context_animated: false,
2438 translated_content_context: false,
2439 has_own_origin_sinks: false,
2440 measured_max_width: None,
2441 resolved_modifiers: ResolvedModifiers::default(),
2442 draw_commands: vec![],
2443 click_actions: vec![],
2444 pointer_inputs: vec![],
2445 clip_to_bounds: false,
2446 annotated_text: None,
2447 text_style: None,
2448 text_layout_options: None,
2449 text_pan: None,
2450 graphics_layer: None,
2451 children: vec![child],
2452 };
2453
2454 let graph = build_layer_node_for_test(parent, 1.0, false);
2455 let RenderNode::Layer(child) = &graph.children[0] else {
2456 panic!("expected child layer");
2457 };
2458
2459 let top_left = child.transform_to_parent.map_point(Point::default());
2460 assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
2461 }
2462
2463 #[test]
2464 fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
2465 fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
2466 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
2467 scope.push_recorded(vec![DrawPrimitive::Rect {
2468 rect: Rect {
2469 x: 3.0,
2470 y: 4.0,
2471 width: 20.0,
2472 height: 8.0,
2473 },
2474 brush: Brush::solid(Color::WHITE),
2475 stroke: None,
2476 }]);
2477 }));
2478
2479 let child = BuildNodeSnapshot {
2480 node_id: 2,
2481 placement: Point { x: 11.0, y: 7.0 },
2482 size: Size {
2483 width: 40.0,
2484 height: 20.0,
2485 },
2486 content_offset: Point::default(),
2487 motion_context_animated: false,
2488 translated_content_context: false,
2489 has_own_origin_sinks: false,
2490 measured_max_width: None,
2491 resolved_modifiers: ResolvedModifiers::default(),
2492 draw_commands: vec![child_command],
2493 click_actions: vec![],
2494 pointer_inputs: vec![],
2495 clip_to_bounds: false,
2496 annotated_text: None,
2497 text_style: None,
2498 text_layout_options: None,
2499 text_pan: None,
2500 graphics_layer: None,
2501 children: vec![],
2502 };
2503
2504 BuildNodeSnapshot {
2505 node_id: 1,
2506 placement: Point::default(),
2507 size: Size {
2508 width: 80.0,
2509 height: 50.0,
2510 },
2511 content_offset: offset,
2512 motion_context_animated,
2513 translated_content_context: true,
2514 has_own_origin_sinks: false,
2515 measured_max_width: None,
2516 resolved_modifiers: ResolvedModifiers::default(),
2517 draw_commands: vec![],
2518 click_actions: vec![],
2519 pointer_inputs: vec![],
2520 clip_to_bounds: false,
2521 annotated_text: None,
2522 text_style: None,
2523 text_layout_options: None,
2524 text_pan: None,
2525 graphics_layer: None,
2526 children: vec![child],
2527 }
2528 }
2529
2530 let base = build_layer_node_for_test(
2531 parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
2532 1.0,
2533 false,
2534 );
2535 let moved = build_layer_node_for_test(
2536 parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
2537 1.0,
2538 false,
2539 );
2540 let rested = build_layer_node_for_test(
2541 parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
2542 1.0,
2543 false,
2544 );
2545
2546 let RenderNode::Layer(base_child) = &base.children[0] else {
2547 panic!("expected child layer");
2548 };
2549 let RenderNode::Layer(moved_child) = &moved.children[0] else {
2550 panic!("expected child layer");
2551 };
2552
2553 assert_ne!(
2554 base_child.transform_to_parent.map_point(Point::default()),
2555 moved_child.transform_to_parent.map_point(Point::default()),
2556 "scroll offset still has to move child content visually"
2557 );
2558 assert_eq!(
2559 base_child.target_content_hash(),
2560 moved_child.target_content_hash(),
2561 "child source content identity stays stable when only the parent scroll offset changes"
2562 );
2563 assert_ne!(
2564 base.target_content_hash(),
2565 moved.target_content_hash(),
2566 "a full-surface cache of the scroll viewport must include the scroll offset"
2567 );
2568 assert_ne!(
2569 base.target_content_hash(),
2570 rested.target_content_hash(),
2571 "full-surface cache keys must include active scroll motion policy"
2572 );
2573 }
2574
2575 #[test]
2576 fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
2577 let layer = graphics_layer_with_shaped_clip(
2578 GraphicsLayer::default(),
2579 true,
2580 Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
2581 Rect {
2582 x: 0.0,
2583 y: 0.0,
2584 width: 100.0,
2585 height: 40.0,
2586 },
2587 );
2588
2589 assert!(layer.clip);
2590 assert!(layer.render_effect.is_none());
2591 let LayerShape::Rounded(shape) = layer.shape else {
2592 panic!("rounded clip must be recorded as layer shape");
2593 };
2594 assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
2595 assert!(isolation_reasons(&layer).shape_clip);
2596 }
2597
2598 #[test]
2599 fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
2600 let existing = RenderEffect::blur(3.0);
2601 let layer = graphics_layer_with_shaped_clip(
2602 GraphicsLayer {
2603 render_effect: Some(existing.clone()),
2604 ..GraphicsLayer::default()
2605 },
2606 true,
2607 Some(RoundedCornerShape::uniform(10.0)),
2608 Rect {
2609 x: 0.0,
2610 y: 0.0,
2611 width: 100.0,
2612 height: 40.0,
2613 },
2614 );
2615
2616 let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
2617 panic!("existing effect should chain into rounded clip mask");
2618 };
2619 assert_eq!(*first, existing);
2620 assert!(
2621 matches!(*second, RenderEffect::Shader { .. }),
2622 "rounded mask must be the outer effect"
2623 );
2624 }
2625
2626 #[test]
2627 fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
2628 let mut composition = cranpose_ui::run_test_composition(|| {
2629 cranpose_ui::Box(
2630 Modifier::empty()
2631 .width(100.0)
2632 .height(40.0)
2633 .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
2634 .clip_to_bounds(),
2635 cranpose_ui::BoxSpec::default(),
2636 || {
2637 Text("rounded child", Modifier::empty(), TextStyle::default());
2638 },
2639 );
2640 });
2641
2642 let root = composition.root().expect("rounded clip root");
2643 let handle = composition.runtime_handle();
2644 let mut applier = composition.applier_mut();
2645 applier.set_runtime_handle(handle);
2646 applier
2647 .compute_layout(
2648 root,
2649 Size {
2650 width: 160.0,
2651 height: 100.0,
2652 },
2653 )
2654 .expect("rounded clip layout");
2655 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
2656 applier.clear_runtime_handle();
2657
2658 let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
2659 assert!(rounded_layer.graphics_layer.clip);
2660 assert!(matches!(
2661 rounded_layer.graphics_layer.shape,
2662 LayerShape::Rounded(_)
2663 ));
2664 assert!(rounded_layer.graphics_layer.render_effect.is_none());
2665 assert!(rounded_layer.isolation.shape_clip);
2666 assert!(
2667 !graph_has_runtime_shader_effect(&graph.root),
2668 "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
2669 );
2670 }
2671
2672 #[test]
2673 fn update_graph_from_applier_replaces_dirty_child_layer() {
2674 let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2675 Rc::new(RefCell::new(None));
2676 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2677 let state_holder_for_comp = state_holder.clone();
2678 let child_id_holder_for_comp = child_id_holder.clone();
2679
2680 let mut composition = cranpose_ui::run_test_composition(move || {
2681 let label = cranpose_core::rememberMutableStateOf(|| "before".to_string());
2682 *state_holder_for_comp.borrow_mut() = Some(label);
2683 let child_id_holder_for_content = child_id_holder_for_comp.clone();
2684 cranpose_ui::Box(
2685 Modifier::empty().size_points(240.0, 80.0),
2686 cranpose_ui::BoxSpec::default(),
2687 move || {
2688 let child_id = Text(label, Modifier::empty(), TextStyle::default());
2689 *child_id_holder_for_content.borrow_mut() = Some(child_id);
2690 Text("stable", Modifier::empty(), TextStyle::default());
2691 },
2692 );
2693 });
2694
2695 let root = composition.root().expect("composition root");
2696 let viewport = Size {
2697 width: 240.0,
2698 height: 80.0,
2699 };
2700 let handle = composition.runtime_handle();
2701 let mut applier = composition.applier_mut();
2702 applier.set_runtime_handle(handle);
2703 applier
2704 .compute_layout(root, viewport)
2705 .expect("initial layout");
2706 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2707 let child_id = child_id_holder
2708 .borrow()
2709 .expect("text child id should be captured");
2710 let initial_transform = find_layer_by_node_id(&graph.root, child_id)
2711 .expect("text child layer")
2712 .transform_to_parent;
2713 applier.clear_runtime_handle();
2714 drop(applier);
2715
2716 let label = state_holder
2717 .borrow()
2718 .as_ref()
2719 .copied()
2720 .expect("label state should be captured");
2721 label.set_value("after".to_string());
2722 composition
2723 .process_invalid_scopes()
2724 .expect("text recomposition");
2725
2726 let handle = composition.runtime_handle();
2727 let mut applier = composition.applier_mut();
2728 applier.set_runtime_handle(handle);
2729 applier
2730 .compute_layout(root, viewport)
2731 .expect("updated layout");
2732 let child_id = child_id_holder
2733 .borrow()
2734 .expect("text child id should remain captured");
2735
2736 assert!(
2737 update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
2738 "dirty child should be replaceable from retained applier state"
2739 );
2740 applier.clear_runtime_handle();
2741
2742 let mut labels = Vec::new();
2743 collect_text_labels(&graph.root, &mut labels);
2744 assert!(
2745 labels.iter().any(|label| label == "after"),
2746 "updated graph should contain refreshed child text, got {labels:?}"
2747 );
2748 assert!(
2749 !labels.iter().any(|label| label == "before"),
2750 "updated graph should not retain stale child text, got {labels:?}"
2751 );
2752 assert!(
2753 labels.iter().any(|label| label == "stable"),
2754 "sibling content should remain present, got {labels:?}"
2755 );
2756 assert_eq!(
2757 find_layer_by_node_id(&graph.root, child_id)
2758 .expect("updated text child layer")
2759 .transform_to_parent,
2760 initial_transform,
2761 "draw-only child replacement must preserve the retained parent placement transform"
2762 );
2763 }
2764
2765 fn assert_same_cache_hash_state(dirty_road: &LayerNode, full_road: &LayerNode, path: &str) {
2766 assert_eq!(
2767 dirty_road.node_id, full_road.node_id,
2768 "tree shape must match at {path}"
2769 );
2770 assert_eq!(
2771 dirty_road.cache_hashes_valid, full_road.cache_hashes_valid,
2772 "hash validity at {path} (node {:?})",
2773 dirty_road.node_id
2774 );
2775 if full_road.cache_hashes_valid {
2776 assert_eq!(
2777 dirty_road.cache_hashes, full_road.cache_hashes,
2778 "stored hashes at {path} (node {:?})",
2779 dirty_road.node_id
2780 );
2781 }
2782 assert_eq!(
2783 dirty_road.target_content_hash(),
2784 full_road.target_content_hash(),
2785 "target content hash at {path} (node {:?})",
2786 dirty_road.node_id
2787 );
2788 assert_eq!(
2789 dirty_road.children.len(),
2790 full_road.children.len(),
2791 "child count at {path}"
2792 );
2793 for (index, (dirty_child, full_child)) in dirty_road
2794 .children
2795 .iter()
2796 .zip(full_road.children.iter())
2797 .enumerate()
2798 {
2799 if let (RenderNode::Layer(dirty_child), RenderNode::Layer(full_child)) =
2800 (dirty_child, full_child)
2801 {
2802 assert_same_cache_hash_state(dirty_child, full_child, &format!("{path}/{index}"));
2803 }
2804 }
2805 }
2806
2807 fn assert_dirty_hash_road_matches_full_walk(graph: &RenderGraph) {
2808 let mut full_road = graph.root.clone();
2809 full_road.recompute_raster_cache_hashes();
2810 assert_same_cache_hash_state(&graph.root, &full_road, "root");
2811 }
2812
2813 #[test]
2814 fn dirty_update_leaves_the_hashes_a_full_walk_leaves() {
2815 let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2816 Rc::new(RefCell::new(None));
2817 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2818 let label_holder_for_comp = label_holder.clone();
2819 let child_id_holder_for_comp = child_id_holder.clone();
2820
2821 let mut composition = cranpose_ui::run_test_composition(move || {
2822 let label = cranpose_core::rememberMutableStateOf(|| "before".to_string());
2823 *label_holder_for_comp.borrow_mut() = Some(label);
2824 let child_id_holder_for_content = child_id_holder_for_comp.clone();
2825 Column(
2826 Modifier::empty().size_points(240.0, 200.0),
2827 ColumnSpec::default(),
2828 move || {
2829 cranpose_ui::Box(
2830 Modifier::empty()
2831 .size_points(240.0, 80.0)
2832 .graphics_layer(|| GraphicsLayer {
2833 alpha: 0.5,
2834 ..GraphicsLayer::default()
2835 }),
2836 cranpose_ui::BoxSpec::default(),
2837 {
2838 let child_id_holder_for_box = child_id_holder_for_content.clone();
2839 move || {
2840 let child_id = Text(label, Modifier::empty(), TextStyle::default());
2841 *child_id_holder_for_box.borrow_mut() = Some(child_id);
2842 }
2843 },
2844 );
2845 cranpose_ui::Box(
2846 Modifier::empty()
2847 .size_points(240.0, 80.0)
2848 .graphics_layer(|| GraphicsLayer {
2849 alpha: 0.75,
2850 ..GraphicsLayer::default()
2851 }),
2852 cranpose_ui::BoxSpec::default(),
2853 || {
2854 Text("stable", Modifier::empty(), TextStyle::default());
2855 },
2856 );
2857 },
2858 );
2859 });
2860
2861 let root = composition.root().expect("composition root");
2862 let viewport = Size {
2863 width: 240.0,
2864 height: 200.0,
2865 };
2866 let handle = composition.runtime_handle();
2867 let mut applier = composition.applier_mut();
2868 applier.set_runtime_handle(handle);
2869 applier
2870 .compute_layout(root, viewport)
2871 .expect("initial layout");
2872 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2873 graph.root.recompute_raster_cache_hashes();
2874 applier.clear_runtime_handle();
2875 drop(applier);
2876 assert_dirty_hash_road_matches_full_walk(&graph);
2877
2878 let label = label_holder
2879 .borrow()
2880 .as_ref()
2881 .copied()
2882 .expect("label state should be captured");
2883 label.set_value("after".to_string());
2884 composition
2885 .process_invalid_scopes()
2886 .expect("text recomposition");
2887
2888 let handle = composition.runtime_handle();
2889 let mut applier = composition.applier_mut();
2890 applier.set_runtime_handle(handle);
2891 applier
2892 .compute_layout(root, viewport)
2893 .expect("updated layout");
2894 let child_id = child_id_holder
2895 .borrow()
2896 .expect("text child id should be captured");
2897 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
2898 applier.clear_runtime_handle();
2899
2900 assert!(report.applied, "dirty child update should apply in place");
2901 assert_dirty_hash_road_matches_full_walk(&graph);
2902 }
2903
2904 #[test]
2905 fn dirty_update_with_a_new_row_leaves_the_hashes_a_full_walk_leaves() {
2906 let rows_holder: Rc<RefCell<Option<cranpose_core::MutableState<usize>>>> =
2907 Rc::new(RefCell::new(None));
2908 let column_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2909 let rows_holder_for_comp = rows_holder.clone();
2910 let column_id_holder_for_comp = column_id_holder.clone();
2911
2912 let mut composition = cranpose_ui::run_test_composition(move || {
2913 let rows = cranpose_core::rememberMutableStateOf(|| 2usize);
2914 *rows_holder_for_comp.borrow_mut() = Some(rows);
2915 let column_id_holder_for_content = column_id_holder_for_comp.clone();
2916 cranpose_ui::Box(
2917 Modifier::empty()
2918 .size_points(240.0, 240.0)
2919 .graphics_layer(|| GraphicsLayer {
2920 alpha: 0.5,
2921 ..GraphicsLayer::default()
2922 }),
2923 cranpose_ui::BoxSpec::default(),
2924 move || {
2925 let column_id = Column(
2926 Modifier::empty().size_points(240.0, 240.0),
2927 ColumnSpec::default(),
2928 move || {
2929 for index in 0..rows.get() {
2930 Text(
2931 format!("row {index}"),
2932 Modifier::empty(),
2933 TextStyle::default(),
2934 );
2935 }
2936 },
2937 );
2938 *column_id_holder_for_content.borrow_mut() = Some(column_id);
2939 },
2940 );
2941 });
2942
2943 let root = composition.root().expect("composition root");
2944 let viewport = Size {
2945 width: 240.0,
2946 height: 240.0,
2947 };
2948 let handle = composition.runtime_handle();
2949 let mut applier = composition.applier_mut();
2950 applier.set_runtime_handle(handle);
2951 applier
2952 .compute_layout(root, viewport)
2953 .expect("initial layout");
2954 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2955 graph.root.recompute_raster_cache_hashes();
2956 applier.clear_runtime_handle();
2957 drop(applier);
2958
2959 let rows = rows_holder
2960 .borrow()
2961 .as_ref()
2962 .copied()
2963 .expect("row count state should be captured");
2964 rows.set_value(3);
2965 composition
2966 .process_invalid_scopes()
2967 .expect("row recomposition");
2968
2969 let handle = composition.runtime_handle();
2970 let mut applier = composition.applier_mut();
2971 applier.set_runtime_handle(handle);
2972 applier
2973 .compute_layout(root, viewport)
2974 .expect("updated layout");
2975 let column_id = column_id_holder.borrow().expect("column id");
2976 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[column_id], 1.0);
2977 applier.clear_runtime_handle();
2978
2979 assert!(report.applied, "structural update should apply in place");
2980 let mut labels = Vec::new();
2981 collect_text_labels(&graph.root, &mut labels);
2982 assert!(
2983 labels.iter().any(|label| label == "row 2"),
2984 "the new row must be in the patched graph, got {labels:?}"
2985 );
2986 assert_dirty_hash_road_matches_full_walk(&graph);
2987 }
2988
2989 #[test]
2997 fn scene_build_publishes_live_window_rect_without_layout_tree() {
2998 use std::cell::Cell;
2999
3000 use cranpose_ui::{Box, BoxSpec, MeasureLayoutOptions, measure_layout_with_options};
3001
3002 let spacer_before = 120.0_f32;
3003 let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
3004 x: 0.0,
3005 y: 0.0,
3006 width: 0.0,
3007 height: 0.0,
3008 }));
3009 let sink_for_comp = sink.clone();
3010 let mut composition = cranpose_ui::run_test_composition(move || {
3011 let sink = sink_for_comp.clone();
3012 Column(
3013 Modifier::empty().size_points(200.0, 400.0),
3014 ColumnSpec::default(),
3015 move || {
3016 Spacer(Size {
3017 width: 200.0,
3018 height: spacer_before,
3019 });
3020 Box(
3021 Modifier::empty()
3022 .size_points(200.0, 50.0)
3023 .report_window_rect(sink.clone()),
3024 BoxSpec::default(),
3025 || {},
3026 );
3027 },
3028 );
3029 });
3030
3031 let root = composition.root().expect("composition root");
3032 let viewport = Size {
3033 width: 200.0,
3034 height: 400.0,
3035 };
3036 let handle = composition.runtime_handle();
3037 let mut applier = composition.applier_mut();
3038 applier.set_runtime_handle(handle);
3039 measure_layout_with_options(
3042 &mut applier,
3043 root,
3044 viewport,
3045 MeasureLayoutOptions {
3046 collect_semantics: false,
3047 build_layout_tree: false,
3048 },
3049 )
3050 .expect("layout");
3051 assert_eq!(
3053 sink.get().height,
3054 0.0,
3055 "sink must start empty (place disabled)"
3056 );
3057
3058 let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
3059 applier.clear_runtime_handle();
3060
3061 let rect = sink.get();
3062 assert!(
3063 (rect.y - spacer_before).abs() < 0.5,
3064 "scene build must publish the box's live window-y (below the {spacer_before}px \
3065 spacer), got {}",
3066 rect.y
3067 );
3068 assert!(
3069 rect.width > 0.0 && rect.height > 0.0,
3070 "scene build must publish a non-empty window rect, got {rect:?}"
3071 );
3072 }
3073
3074 #[test]
3075 fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
3076 let mut graph = RenderGraph {
3077 root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
3078 };
3079 let mut applier = MemoryApplier::new();
3080
3081 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
3082
3083 assert_eq!(
3084 report,
3085 GraphUpdateReport {
3086 applied: false,
3087 hit_graph_dirty: true,
3088 },
3089 "dirty child graph updates must not report success when the replacement cannot be rebuilt"
3090 );
3091 }
3092
3093 #[test]
3094 fn scrolled_list_under_a_composited_layer_keeps_the_hashes_a_full_walk_leaves() {
3095 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3096 let scroll_holder_for_comp = scroll_holder.clone();
3097
3098 let mut composition = cranpose_ui::run_test_composition(move || {
3099 let scroll_state =
3100 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3101 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3102 cranpose_ui::Box(
3103 Modifier::empty()
3104 .size_points(240.0, 320.0)
3105 .graphics_layer(|| GraphicsLayer {
3106 alpha: 0.6,
3107 ..GraphicsLayer::default()
3108 }),
3109 cranpose_ui::BoxSpec::default(),
3110 move || {
3111 Column(
3112 Modifier::empty()
3113 .size_points(240.0, 320.0)
3114 .vertical_scroll(scroll_state, false),
3115 ColumnSpec::default(),
3116 || {
3117 for index in 0..12usize {
3118 cranpose_ui::Box(
3119 Modifier::empty().size_points(240.0, 60.0).graphics_layer(
3120 || GraphicsLayer {
3121 alpha: 0.8,
3122 ..GraphicsLayer::default()
3123 },
3124 ),
3125 cranpose_ui::BoxSpec::default(),
3126 move || {
3127 Text(
3128 format!("row {index}"),
3129 Modifier::empty(),
3130 TextStyle::default(),
3131 );
3132 },
3133 );
3134 }
3135 },
3136 );
3137 },
3138 );
3139 });
3140
3141 let root = composition.root().expect("composition root");
3142 let viewport = Size {
3143 width: 240.0,
3144 height: 320.0,
3145 };
3146 let handle = composition.runtime_handle();
3147 let mut applier = composition.applier_mut();
3148 applier.set_runtime_handle(handle);
3149 applier
3150 .compute_layout(root, viewport)
3151 .expect("initial scroll layout");
3152 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3153 graph.root.recompute_raster_cache_hashes();
3154 applier.clear_runtime_handle();
3155 drop(applier);
3156
3157 let scroll_state = scroll_holder
3158 .borrow()
3159 .as_ref()
3160 .cloned()
3161 .expect("scroll state should be captured");
3162 assert!(
3163 scroll_state.dispatch_raw_delta(96.0) > 0.0,
3164 "test scroll must be consumed"
3165 );
3166 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3167 assert!(
3168 !dirty_nodes.is_empty(),
3169 "a scroll must schedule a scoped scene update"
3170 );
3171
3172 let handle = composition.runtime_handle();
3173 let mut applier = composition.applier_mut();
3174 applier.set_runtime_handle(handle);
3175 applier
3176 .compute_layout(root, viewport)
3177 .expect("scrolled layout");
3178 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3179 applier.clear_runtime_handle();
3180
3181 assert!(report.applied, "scroll update should apply in place");
3182 assert_dirty_hash_road_matches_full_walk(&graph);
3183 }
3184
3185 #[test]
3186 fn update_graph_from_applier_refreshes_scroll_content_offset() {
3187 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3188 let scroll_holder_for_comp = scroll_holder.clone();
3189
3190 let mut composition = cranpose_ui::run_test_composition(move || {
3191 let scroll_state =
3192 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3193 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3194 Column(
3195 Modifier::empty()
3196 .size_points(240.0, 120.0)
3197 .vertical_scroll(scroll_state, false),
3198 ColumnSpec::default(),
3199 || {
3200 Text("scroll top", Modifier::empty(), TextStyle::default());
3201 Spacer(Size {
3202 width: 0.0,
3203 height: 160.0,
3204 });
3205 Text("scroll target", Modifier::empty(), TextStyle::default());
3206 },
3207 );
3208 });
3209
3210 let root = composition.root().expect("composition root");
3211 let viewport = Size {
3212 width: 240.0,
3213 height: 120.0,
3214 };
3215 let handle = composition.runtime_handle();
3216 let mut applier = composition.applier_mut();
3217 applier.set_runtime_handle(handle);
3218 applier
3219 .compute_layout(root, viewport)
3220 .expect("initial scroll layout");
3221 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3222 graph.root.recompute_raster_cache_hashes();
3223 let initial_target_top =
3224 find_text_top(&graph.root, "scroll target").expect("initial target text");
3225 applier.clear_runtime_handle();
3226 drop(applier);
3227
3228 let scroll_state = scroll_holder
3229 .borrow()
3230 .as_ref()
3231 .cloned()
3232 .expect("scroll state should be captured");
3233 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
3234 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
3235 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3236 assert!(
3237 !dirty_nodes.is_empty(),
3238 "scroll state invalidation must schedule scoped layout graph update"
3239 );
3240
3241 let handle = composition.runtime_handle();
3242 let mut applier = composition.applier_mut();
3243 applier.set_runtime_handle(handle);
3244 applier
3245 .compute_layout(root, viewport)
3246 .expect("scrolled layout");
3247 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3248 applier.clear_runtime_handle();
3249
3250 assert!(report.applied, "scroll graph update should apply in place");
3251 let updated_target_top =
3252 find_text_top(&graph.root, "scroll target").expect("updated target text");
3253 assert!(
3254 updated_target_top < initial_target_top - consumed_scroll * 0.75,
3255 "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
3256 );
3257 assert_dirty_hash_road_matches_full_walk(&graph);
3258 }
3259
3260 #[test]
3267 fn an_overmarked_ancestor_chain_still_translates_instead_of_relowering() {
3268 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3269 let scroll_holder_for_comp = scroll_holder.clone();
3270
3271 let mut composition = cranpose_ui::run_test_composition(move || {
3272 let scroll_state =
3273 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3274 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3275 cranpose_ui::Box(
3276 Modifier::empty().size_points(240.0, 320.0),
3277 cranpose_ui::BoxSpec::default(),
3278 move || {
3279 Column(
3280 Modifier::empty()
3281 .size_points(240.0, 320.0)
3282 .vertical_scroll(scroll_state, false),
3283 ColumnSpec::default(),
3284 || {
3285 for index in 0..12usize {
3286 cranpose_ui::Box(
3287 Modifier::empty()
3288 .size_points(240.0, 60.0)
3289 .background(Color(0.9, 0.9, 0.92, 1.0)),
3290 cranpose_ui::BoxSpec::default(),
3291 move || {
3292 Text(
3293 format!("row {index}"),
3294 Modifier::empty(),
3295 TextStyle::default(),
3296 );
3297 },
3298 );
3299 }
3300 },
3301 );
3302 },
3303 );
3304 });
3305
3306 let root = composition.root().expect("composition root");
3307 let viewport = Size {
3308 width: 240.0,
3309 height: 320.0,
3310 };
3311 let handle = composition.runtime_handle();
3312 let mut applier = composition.applier_mut();
3313 applier.set_runtime_handle(handle);
3314 applier
3315 .compute_layout(root, viewport)
3316 .expect("initial scroll layout");
3317 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3318 graph.root.recompute_raster_cache_hashes();
3319 let initial_row_top = find_text_top(&graph.root, "row 3").expect("initial row text");
3320 applier.clear_runtime_handle();
3321 drop(applier);
3322
3323 let scroll_state = scroll_holder
3324 .borrow()
3325 .as_ref()
3326 .cloned()
3327 .expect("scroll state should be captured");
3328 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
3329 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
3330 let mut dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3333 dirty_nodes.push(graph.root.node_id.expect("root id"));
3334 let mut cursor = &graph.root;
3335 while let Some(RenderNode::Layer(child)) = cursor
3336 .children
3337 .iter()
3338 .find(|child| matches!(child, RenderNode::Layer(_)))
3339 {
3340 let chain = child.node_id.expect("chain node id");
3341 if dirty_nodes.contains(&chain) {
3342 break;
3343 }
3344 dirty_nodes.push(chain);
3345 cursor = child;
3346 }
3347
3348 let handle = composition.runtime_handle();
3349 let mut applier = composition.applier_mut();
3350 applier.set_runtime_handle(handle);
3351 applier
3352 .compute_layout(root, viewport)
3353 .expect("scrolled layout");
3354 reset_lowered_layer_count();
3355 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3356 applier.clear_runtime_handle();
3357
3358 assert!(report.applied, "chain update should apply in place");
3359 let lowered = lowered_layer_count();
3360 assert_eq!(
3361 lowered, 0,
3362 "an over-marked ancestor chain over a pure scroll must still \
3363 translate; {lowered} layers were rebuilt (dirty={dirty_nodes:?})"
3364 );
3365 let updated_row_top = find_text_top(&graph.root, "row 3").expect("updated row text");
3366 assert!(
3367 updated_row_top < initial_row_top - consumed_scroll * 0.75,
3368 "the translation must land through the chain: initial_y={initial_row_top} updated_y={updated_row_top}"
3369 );
3370 assert_dirty_hash_road_matches_full_walk(&graph);
3371 }
3372
3373 #[test]
3380 fn a_scrolled_container_translates_clean_children_instead_of_relowering() {
3381 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3382 let scroll_holder_for_comp = scroll_holder.clone();
3383
3384 let mut composition = cranpose_ui::run_test_composition(move || {
3385 let scroll_state =
3386 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3387 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3388 Column(
3389 Modifier::empty()
3390 .size_points(240.0, 320.0)
3391 .vertical_scroll(scroll_state, false),
3392 ColumnSpec::default(),
3393 || {
3394 for index in 0..12usize {
3395 cranpose_ui::Box(
3396 Modifier::empty()
3397 .size_points(240.0, 60.0)
3398 .background(Color(0.9, 0.9, 0.92, 1.0)),
3399 cranpose_ui::BoxSpec::default(),
3400 move || {
3401 Text(
3402 format!("row {index}"),
3403 Modifier::empty(),
3404 TextStyle::default(),
3405 );
3406 },
3407 );
3408 }
3409 },
3410 );
3411 });
3412
3413 let root = composition.root().expect("composition root");
3414 let viewport = Size {
3415 width: 240.0,
3416 height: 320.0,
3417 };
3418 let handle = composition.runtime_handle();
3419 let mut applier = composition.applier_mut();
3420 applier.set_runtime_handle(handle);
3421 applier
3422 .compute_layout(root, viewport)
3423 .expect("initial scroll layout");
3424 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3425 graph.root.recompute_raster_cache_hashes();
3426 let initial_row_top = find_text_top(&graph.root, "row 3").expect("initial row text");
3427 applier.clear_runtime_handle();
3428 drop(applier);
3429
3430 let scroll_state = scroll_holder
3431 .borrow()
3432 .as_ref()
3433 .cloned()
3434 .expect("scroll state should be captured");
3435 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
3436 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
3437 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3438 assert!(
3439 !dirty_nodes.is_empty(),
3440 "a scroll must schedule a scoped scene update"
3441 );
3442
3443 let handle = composition.runtime_handle();
3444 let mut applier = composition.applier_mut();
3445 applier.set_runtime_handle(handle);
3446 applier
3447 .compute_layout(root, viewport)
3448 .expect("scrolled layout");
3449 reset_lowered_layer_count();
3450 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3451 applier.clear_runtime_handle();
3452
3453 assert!(report.applied, "scroll update should apply in place");
3454 let lowered = lowered_layer_count();
3455 assert_eq!(
3456 lowered, 0,
3457 "a pure scroll of clean children must translate the retained \
3458 subtrees, not re-lower them; {lowered} layers were rebuilt"
3459 );
3460 let updated_row_top = find_text_top(&graph.root, "row 3").expect("updated row text");
3461 assert!(
3462 updated_row_top < initial_row_top - consumed_scroll * 0.75,
3463 "the translation must actually land: initial_y={initial_row_top} updated_y={updated_row_top}"
3464 );
3465 assert_dirty_hash_road_matches_full_walk(&graph);
3466 }
3467
3468 #[test]
3475 fn a_sliding_lazy_window_lowers_only_the_entering_rows() {
3476 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3477 let state_holder_for_comp = state_holder.clone();
3478 let mut composition = cranpose_ui::run_test_composition(move || {
3479 let list_state = rememberLazyListState();
3480 *state_holder_for_comp.borrow_mut() = Some(list_state);
3481 LazyColumn(
3482 Modifier::empty().size_points(240.0, 320.0),
3483 list_state,
3484 LazyColumnSpec::default(),
3485 |scope| {
3486 scope.items(60, |index| {
3487 cranpose_ui::Box(
3488 Modifier::empty()
3489 .size_points(240.0, 60.0)
3490 .background(Color(0.9, 0.9, 0.92, 1.0)),
3491 cranpose_ui::BoxSpec::default(),
3492 move || {
3493 Text(
3494 format!("row {index}"),
3495 Modifier::empty(),
3496 TextStyle::default(),
3497 );
3498 },
3499 );
3500 });
3501 },
3502 );
3503 });
3504
3505 let root = composition.root().expect("composition root");
3506 let viewport = Size {
3507 width: 240.0,
3508 height: 320.0,
3509 };
3510 let handle = composition.runtime_handle();
3511 let mut applier = composition.applier_mut();
3512 applier.set_runtime_handle(handle);
3513 applier
3514 .compute_layout(root, viewport)
3515 .expect("initial lazy layout");
3516 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3517 graph.root.recompute_raster_cache_hashes();
3518 let _ = applier.take_structural_change_parents_attached_to(root);
3521 let initial_row_top = find_text_top(&graph.root, "row 4").expect("initial row text");
3522 applier.clear_runtime_handle();
3523 drop(applier);
3524
3525 let list_state = (*state_holder.borrow()).expect("list state should be captured");
3526 let consumed = list_state.dispatch_scroll_delta(-96.0);
3527 assert!(consumed != 0.0, "the lazy scroll must consume the delta");
3528 let mut dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
3531 dirty_nodes.extend(cranpose_ui::pending_measure_repass_nodes_snapshot());
3532
3533 let handle = composition.runtime_handle();
3534 let mut applier = composition.applier_mut();
3535 applier.set_runtime_handle(handle);
3536 applier
3537 .compute_layout(root, viewport)
3538 .expect("scrolled lazy layout");
3539 dirty_nodes.extend(applier.take_structural_change_parents_attached_to(root));
3542 dirty_nodes.sort_unstable();
3543 dirty_nodes.dedup();
3544 assert!(
3545 !dirty_nodes.is_empty(),
3546 "a lazy scroll must mark the list dirty"
3547 );
3548
3549 reset_lowered_layer_count();
3550 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
3551 applier.clear_runtime_handle();
3552
3553 assert!(report.applied, "the boundary frame must apply in place");
3554 let lowered = lowered_layer_count();
3555 assert!(
3556 lowered > 0,
3557 "rows crossed the window boundary; the entering subtrees must lower"
3558 );
3559 assert!(
3560 lowered <= 8,
3561 "only the entering rows may lower on a boundary frame; \
3562 {lowered} layers were rebuilt (dirty={dirty_nodes:?})"
3563 );
3564 let updated_row_top = find_text_top(&graph.root, "row 4").expect("updated row text");
3565 assert!(
3566 updated_row_top < initial_row_top - 60.0,
3567 "the retained rows must move with the scroll: \
3568 initial_y={initial_row_top} updated_y={updated_row_top}"
3569 );
3570 assert_dirty_hash_road_matches_full_walk(&graph);
3571 }
3572
3573 #[test]
3574 fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
3575 let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
3576 Rc::new(RefCell::new(None));
3577 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3578 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3579 let label_holder_for_comp = label_holder.clone();
3580 let scroll_holder_for_comp = scroll_holder.clone();
3581 let child_id_holder_for_comp = child_id_holder.clone();
3582
3583 let mut composition = cranpose_ui::run_test_composition(move || {
3584 let label =
3585 cranpose_core::rememberMutableStateOf(|| "scrolled child before".to_string());
3586 let scroll_state =
3587 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3588 *label_holder_for_comp.borrow_mut() = Some(label);
3589 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3590 let child_id_holder_for_content = child_id_holder_for_comp.clone();
3591 Column(
3592 Modifier::empty()
3593 .size_points(260.0, 90.0)
3594 .vertical_scroll(scroll_state, false),
3595 ColumnSpec::default(),
3596 move || {
3597 Spacer(Size {
3598 width: 0.0,
3599 height: 24.0,
3600 });
3601 let child_id = Text(label, Modifier::empty(), TextStyle::default());
3602 *child_id_holder_for_content.borrow_mut() = Some(child_id);
3603 Spacer(Size {
3604 width: 0.0,
3605 height: 220.0,
3606 });
3607 },
3608 );
3609 });
3610
3611 let root = composition.root().expect("composition root");
3612 let viewport = Size {
3613 width: 260.0,
3614 height: 90.0,
3615 };
3616 let handle = composition.runtime_handle();
3617 let mut applier = composition.applier_mut();
3618 applier.set_runtime_handle(handle);
3619 applier
3620 .compute_layout(root, viewport)
3621 .expect("initial layout");
3622 applier.clear_runtime_handle();
3623 drop(applier);
3624
3625 let scroll_state = scroll_holder
3626 .borrow()
3627 .as_ref()
3628 .cloned()
3629 .expect("scroll state should be captured");
3630 assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
3631
3632 let handle = composition.runtime_handle();
3633 let mut applier = composition.applier_mut();
3634 applier.set_runtime_handle(handle);
3635 applier
3636 .compute_layout(root, viewport)
3637 .expect("scrolled layout");
3638 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
3639 let child_id = child_id_holder
3640 .borrow()
3641 .expect("text child id should be captured");
3642 let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
3643 .expect("scrolled child layer")
3644 .transform_to_parent;
3645 applier.clear_runtime_handle();
3646 drop(applier);
3647
3648 let label = label_holder
3649 .borrow()
3650 .as_ref()
3651 .copied()
3652 .expect("label state should be captured");
3653 label.set_value("scrolled child after".to_string());
3654 composition
3655 .process_invalid_scopes()
3656 .expect("text recomposition");
3657
3658 let handle = composition.runtime_handle();
3659 let mut applier = composition.applier_mut();
3660 applier.set_runtime_handle(handle);
3661 applier
3662 .compute_layout(root, viewport)
3663 .expect("updated scrolled layout");
3664 let child_id = child_id_holder
3665 .borrow()
3666 .expect("text child id should remain captured");
3667 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
3668 applier.clear_runtime_handle();
3669
3670 assert!(report.applied, "dirty child graph update should apply");
3671 let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
3672 assert_eq!(
3673 updated.transform_to_parent, scrolled_transform,
3674 "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
3675 );
3676 let mut labels = Vec::new();
3677 collect_text_labels(&graph.root, &mut labels);
3678 assert!(
3679 labels.iter().any(|label| label == "scrolled child after"),
3680 "updated graph should contain refreshed text, got {labels:?}"
3681 );
3682 }
3683
3684 #[test]
3685 fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
3686 let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
3687 Rc::new(RefCell::new(None));
3688 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
3689 let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3690 let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3691 let alpha_holder_for_comp = alpha_holder.clone();
3692 let scroll_holder_for_comp = scroll_holder.clone();
3693 let underlay_id_holder_for_comp = underlay_id_holder.clone();
3694 let overlay_id_holder_for_comp = overlay_id_holder.clone();
3695
3696 let mut composition = cranpose_ui::run_test_composition(move || {
3697 let alpha = cranpose_core::rememberMutableStateOf(|| 1.0f32);
3698 let scroll_state =
3699 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| *state);
3700 *alpha_holder_for_comp.borrow_mut() = Some(alpha);
3701 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state);
3702 let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
3703 let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
3704 Column(
3705 Modifier::empty()
3706 .size_points(260.0, 120.0)
3707 .vertical_scroll(scroll_state, false),
3708 ColumnSpec::default(),
3709 move || {
3710 Spacer(Size {
3711 width: 0.0,
3712 height: 180.0,
3713 });
3714 cranpose_ui::Box(
3715 Modifier::empty().size_points(188.0, 88.0),
3716 cranpose_ui::BoxSpec::default(),
3717 {
3718 let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
3719 let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
3720 move || {
3721 let underlay_id = cranpose_ui::Box(
3722 Modifier::empty().size_points(188.0, 88.0),
3723 cranpose_ui::BoxSpec::default(),
3724 || {
3725 Text(
3726 "UNDERLAY CONTENT",
3727 Modifier::empty().absolute_offset(12.0, 8.0),
3728 TextStyle::default(),
3729 );
3730 },
3731 );
3732 *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
3733 let overlay_id = cranpose_ui::Box(
3734 Modifier::empty().size_points(188.0, 88.0).graphics_layer(
3735 move || GraphicsLayer {
3736 alpha: alpha.get(),
3737 ..GraphicsLayer::default()
3738 },
3739 ),
3740 cranpose_ui::BoxSpec::default(),
3741 || {
3742 Text(
3743 "TOP LAYER",
3744 Modifier::empty().absolute_offset(74.0, 39.6),
3745 TextStyle::default(),
3746 );
3747 },
3748 );
3749 *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
3750 }
3751 },
3752 );
3753 Spacer(Size {
3754 width: 0.0,
3755 height: 280.0,
3756 });
3757 },
3758 );
3759 });
3760
3761 let root = composition.root().expect("composition root");
3762 let viewport = Size {
3763 width: 260.0,
3764 height: 120.0,
3765 };
3766 let handle = composition.runtime_handle();
3767 let mut applier = composition.applier_mut();
3768 applier.set_runtime_handle(handle);
3769 applier
3770 .compute_layout(root, viewport)
3771 .expect("initial layout");
3772 applier.clear_runtime_handle();
3773 drop(applier);
3774
3775 let scroll_state = scroll_holder
3776 .borrow()
3777 .as_ref()
3778 .cloned()
3779 .expect("scroll state should be captured");
3780 assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
3781
3782 let handle = composition.runtime_handle();
3783 let mut applier = composition.applier_mut();
3784 applier.set_runtime_handle(handle);
3785 applier
3786 .compute_layout(root, viewport)
3787 .expect("scrolled layout");
3788 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
3789 applier.clear_runtime_handle();
3790 drop(applier);
3791
3792 let underlay_id = underlay_id_holder
3793 .borrow()
3794 .expect("underlay id should be captured");
3795 let overlay_id = overlay_id_holder
3796 .borrow()
3797 .expect("overlay id should be captured");
3798 let scrolled_underlay_origin =
3799 find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
3800 let scrolled_overlay_origin =
3801 find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
3802 assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
3803
3804 let alpha = alpha_holder
3805 .borrow()
3806 .as_ref()
3807 .copied()
3808 .expect("alpha state should be captured");
3809 alpha.set_value(0.35);
3810
3811 let handle = composition.runtime_handle();
3812 let mut applier = composition.applier_mut();
3813 applier.set_runtime_handle(handle);
3814 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
3815 applier.clear_runtime_handle();
3816
3817 assert!(report.applied, "dirty overlay graph update should apply");
3818 let updated_underlay_origin =
3819 find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
3820 let updated_overlay_origin =
3821 find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
3822 assert_eq!(
3823 updated_underlay_origin, scrolled_underlay_origin,
3824 "stable underlay must keep its scrolled origin"
3825 );
3826 assert_eq!(
3827 updated_overlay_origin, updated_underlay_origin,
3828 "dirty overlay graphics layer must stay aligned with its stable underlay"
3829 );
3830 }
3831
3832 #[test]
3833 fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
3834 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
3835 Rc::new(RefCell::new(None));
3836 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3837 let offset_holder_for_comp = offset_holder.clone();
3838 let node_id_holder_for_comp = node_id_holder.clone();
3839
3840 let mut composition = cranpose_ui::run_test_composition(move || {
3841 let offset = cranpose_core::rememberMutableStateOf(|| 0.0f32);
3842 *offset_holder_for_comp.borrow_mut() = Some(offset);
3843 let node_id = cranpose_ui::Box(
3844 Modifier::empty()
3845 .size_points(40.0, 20.0)
3846 .graphics_layer(move || GraphicsLayer {
3847 translation_x: offset.get(),
3848 ..GraphicsLayer::default()
3849 }),
3850 cranpose_ui::BoxSpec::default(),
3851 || {},
3852 );
3853 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
3854 });
3855
3856 let root = composition.root().expect("composition root");
3857 let viewport = Size {
3858 width: 120.0,
3859 height: 80.0,
3860 };
3861 let handle = composition.runtime_handle();
3862 let mut applier = composition.applier_mut();
3863 applier.set_runtime_handle(handle);
3864 applier
3865 .compute_layout(root, viewport)
3866 .expect("initial layout");
3867 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3868 let node_id = node_id_holder
3869 .borrow()
3870 .expect("graphics layer node id should be captured");
3871 let initial_origin = find_layer_by_node_id(&graph.root, node_id)
3872 .expect("initial graphics layer")
3873 .transform_to_parent
3874 .map_point(Point::default());
3875 applier.clear_runtime_handle();
3876 drop(applier);
3877
3878 let offset = offset_holder
3879 .borrow()
3880 .as_ref()
3881 .copied()
3882 .expect("offset state should be captured");
3883 offset.set_value(32.0);
3884
3885 let handle = composition.runtime_handle();
3886 let mut applier = composition.applier_mut();
3887 applier.set_runtime_handle(handle);
3888 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
3889 assert!(
3890 report.applied,
3891 "dirty graphics layer should be replaceable from retained applier state"
3892 );
3893 assert!(
3894 !report.hit_graph_dirty,
3895 "a moved visual-only layer should not force hit graph refresh"
3896 );
3897 applier.clear_runtime_handle();
3898
3899 let updated_origin = find_layer_by_node_id(&graph.root, node_id)
3900 .expect("updated graphics layer")
3901 .transform_to_parent
3902 .map_point(Point::default());
3903 assert!(
3904 (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
3905 "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
3906 );
3907 }
3908
3909 #[test]
3910 fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
3911 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
3912 Rc::new(RefCell::new(None));
3913 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
3914 let offset_holder_for_comp = offset_holder.clone();
3915 let node_id_holder_for_comp = node_id_holder.clone();
3916
3917 let mut composition = cranpose_ui::run_test_composition(move || {
3918 let offset = cranpose_core::rememberMutableStateOf(|| 0.0f32);
3919 *offset_holder_for_comp.borrow_mut() = Some(offset);
3920 let node_id = cranpose_ui::Box(
3921 Modifier::empty()
3922 .size_points(40.0, 20.0)
3923 .graphics_layer(move || GraphicsLayer {
3924 translation_x: offset.get(),
3925 ..GraphicsLayer::default()
3926 })
3927 .clickable(|_| {}),
3928 cranpose_ui::BoxSpec::default(),
3929 || {},
3930 );
3931 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
3932 });
3933
3934 let root = composition.root().expect("composition root");
3935 let viewport = Size {
3936 width: 120.0,
3937 height: 80.0,
3938 };
3939 let handle = composition.runtime_handle();
3940 let mut applier = composition.applier_mut();
3941 applier.set_runtime_handle(handle);
3942 applier
3943 .compute_layout(root, viewport)
3944 .expect("initial layout");
3945 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
3946 let node_id = node_id_holder
3947 .borrow()
3948 .expect("graphics layer node id should be captured");
3949 applier.clear_runtime_handle();
3950 drop(applier);
3951
3952 let offset = offset_holder
3953 .borrow()
3954 .as_ref()
3955 .copied()
3956 .expect("offset state should be captured");
3957 offset.set_value(32.0);
3958
3959 let handle = composition.runtime_handle();
3960 let mut applier = composition.applier_mut();
3961 applier.set_runtime_handle(handle);
3962 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
3963 applier.clear_runtime_handle();
3964
3965 assert!(
3966 report.applied,
3967 "dirty clickable graphics layer should be replaceable from retained applier state"
3968 );
3969 assert!(
3970 report.hit_graph_dirty,
3971 "moved clickable layers must refresh hit geometry"
3972 );
3973 }
3974
3975 #[test]
3976 fn overlay_draw_commands_are_tagged_after_children() {
3977 let child = BuildNodeSnapshot {
3978 node_id: 2,
3979 placement: Point { x: 4.0, y: 5.0 },
3980 size: Size {
3981 width: 20.0,
3982 height: 10.0,
3983 },
3984 content_offset: Point::default(),
3985 motion_context_animated: false,
3986 translated_content_context: false,
3987 has_own_origin_sinks: false,
3988 measured_max_width: None,
3989 resolved_modifiers: ResolvedModifiers::default(),
3990 draw_commands: vec![],
3991 click_actions: vec![],
3992 pointer_inputs: vec![],
3993 clip_to_bounds: false,
3994 annotated_text: None,
3995 text_style: None,
3996 text_layout_options: None,
3997 text_pan: None,
3998 graphics_layer: None,
3999 children: vec![],
4000 };
4001 let behind = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
4002 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
4003 rect: Rect {
4004 x: 1.0,
4005 y: 2.0,
4006 width: 8.0,
4007 height: 6.0,
4008 },
4009 brush: Brush::solid(Color::WHITE),
4010 stroke: None,
4011 }]);
4012 }));
4013 let overlay = DrawCommand::Overlay(Rc::new(|scope: &mut DrawScopeDefault| {
4014 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
4015 rect: Rect {
4016 x: 3.0,
4017 y: 1.0,
4018 width: 5.0,
4019 height: 4.0,
4020 },
4021 brush: Brush::solid(Color::BLACK),
4022 stroke: None,
4023 }]);
4024 }));
4025
4026 let parent = BuildNodeSnapshot {
4027 node_id: 1,
4028 placement: Point::default(),
4029 size: Size {
4030 width: 80.0,
4031 height: 50.0,
4032 },
4033 content_offset: Point::default(),
4034 motion_context_animated: false,
4035 translated_content_context: false,
4036 has_own_origin_sinks: false,
4037 measured_max_width: None,
4038 resolved_modifiers: ResolvedModifiers::default(),
4039 draw_commands: vec![behind, overlay],
4040 click_actions: vec![],
4041 pointer_inputs: vec![],
4042 clip_to_bounds: false,
4043 annotated_text: None,
4044 text_style: None,
4045 text_layout_options: None,
4046 text_pan: None,
4047 graphics_layer: None,
4048 children: vec![child],
4049 };
4050
4051 let graph = build_layer_node_for_test(parent, 1.0, false);
4052 let RenderNode::DrawRun(behind) = &graph.children[0] else {
4053 panic!("expected before-children draw run");
4054 };
4055 let RenderNode::Layer(_) = &graph.children[1] else {
4056 panic!("expected child layer");
4057 };
4058 let RenderNode::DrawRun(overlay) = &graph.children[2] else {
4059 panic!("expected after-children draw run");
4060 };
4061
4062 assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
4063 assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
4064 }
4065
4066 #[test]
4070 fn command_recordings_reuse_buffers_across_rebuilds() {
4071 let snapshot = || BuildNodeSnapshot {
4072 node_id: 7001,
4073 placement: Point::default(),
4074 size: Size {
4075 width: 40.0,
4076 height: 20.0,
4077 },
4078 content_offset: Point::default(),
4079 motion_context_animated: false,
4080 translated_content_context: false,
4081 has_own_origin_sinks: false,
4082 measured_max_width: None,
4083 resolved_modifiers: ResolvedModifiers::default(),
4084 draw_commands: vec![DrawCommand::Behind(Rc::new(
4085 |scope: &mut DrawScopeDefault| {
4086 scope.draw_rect_at(
4087 Rect {
4088 x: 1.0,
4089 y: 2.0,
4090 width: 8.0,
4091 height: 6.0,
4092 },
4093 Brush::solid(Color::WHITE),
4094 );
4095 },
4096 ))],
4097 click_actions: vec![],
4098 pointer_inputs: vec![],
4099 clip_to_bounds: false,
4100 annotated_text: None,
4101 text_style: None,
4102 text_layout_options: None,
4103 text_pan: None,
4104 graphics_layer: None,
4105 children: vec![],
4106 };
4107 fn run_of(layer: &LayerNode) -> &DrawRunNode {
4108 let RenderNode::DrawRun(run) = &layer.children[0] else {
4109 panic!("expected draw run");
4110 };
4111 run
4112 }
4113
4114 let graph_a = build_layer_node_for_test(snapshot(), 1.0, false);
4115 let ptr_a = run_of(&graph_a).primitives.as_ptr();
4116
4117 let graph_b = build_layer_node_for_test(snapshot(), 1.0, false);
4119 let ptr_b = run_of(&graph_b).primitives.as_ptr();
4120 assert_ne!(
4121 ptr_a, ptr_b,
4122 "a buffer a live graph shares must never be recorded into"
4123 );
4124 assert_eq!(
4125 run_of(&graph_a).primitives,
4126 run_of(&graph_b).primitives,
4127 "re-recording must reproduce the recording"
4128 );
4129
4130 drop(graph_a);
4134 let graph_c = build_layer_node_for_test(snapshot(), 1.0, false);
4135 assert_eq!(
4136 run_of(&graph_c).primitives.as_ptr(),
4137 ptr_a,
4138 "the released buffer must be reused for the next recording"
4139 );
4140
4141 let held = std::rc::Rc::clone(&run_of(&graph_c).primitives);
4144 drop(graph_c);
4145 let graph_d = build_layer_node_for_test(snapshot(), 1.0, false);
4146 let ptr_d = run_of(&graph_d).primitives.as_ptr();
4147 assert_ne!(ptr_d, held.as_ptr());
4148 assert_ne!(ptr_d, run_of(&graph_b).primitives.as_ptr());
4149 }
4150
4151 #[test]
4152 fn stored_content_hash_changes_when_child_transform_changes() {
4153 let child = BuildNodeSnapshot {
4154 node_id: 2,
4155 placement: Point { x: 4.0, y: 5.0 },
4156 size: Size {
4157 width: 20.0,
4158 height: 10.0,
4159 },
4160 content_offset: Point::default(),
4161 motion_context_animated: false,
4162 translated_content_context: false,
4163 has_own_origin_sinks: false,
4164 measured_max_width: None,
4165 resolved_modifiers: ResolvedModifiers::default(),
4166 draw_commands: vec![],
4167 click_actions: vec![],
4168 pointer_inputs: vec![],
4169 clip_to_bounds: false,
4170 annotated_text: None,
4171 text_style: None,
4172 text_layout_options: None,
4173 text_pan: None,
4174 graphics_layer: None,
4175 children: vec![],
4176 };
4177 let mut moved_child = child.clone();
4178 moved_child.placement.x += 7.0;
4179
4180 let parent = BuildNodeSnapshot {
4181 node_id: 1,
4182 placement: Point::default(),
4183 size: Size {
4184 width: 80.0,
4185 height: 50.0,
4186 },
4187 content_offset: Point::default(),
4188 motion_context_animated: false,
4189 translated_content_context: false,
4190 has_own_origin_sinks: false,
4191 measured_max_width: None,
4192 resolved_modifiers: ResolvedModifiers::default(),
4193 draw_commands: vec![],
4194 click_actions: vec![],
4195 pointer_inputs: vec![],
4196 clip_to_bounds: false,
4197 annotated_text: None,
4198 text_style: None,
4199 text_layout_options: None,
4200 text_pan: None,
4201 graphics_layer: None,
4202 children: vec![child],
4203 };
4204 let moved_parent = BuildNodeSnapshot {
4205 children: vec![moved_child],
4206 ..parent.clone()
4207 };
4208
4209 let static_graph = build_layer_node_for_test(parent, 1.0, false);
4210 let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
4211
4212 assert_ne!(
4213 static_graph.target_content_hash(),
4214 moved_graph.target_content_hash(),
4215 "moving a child within the parent must invalidate the parent subtree hash"
4216 );
4217 }
4218
4219 #[test]
4220 fn stored_effect_hash_tracks_local_effect_only() {
4221 let base = BuildNodeSnapshot {
4222 node_id: 1,
4223 placement: Point::default(),
4224 size: Size {
4225 width: 80.0,
4226 height: 50.0,
4227 },
4228 content_offset: Point::default(),
4229 motion_context_animated: false,
4230 translated_content_context: false,
4231 has_own_origin_sinks: false,
4232 measured_max_width: None,
4233 resolved_modifiers: ResolvedModifiers::default(),
4234 draw_commands: vec![],
4235 click_actions: vec![],
4236 pointer_inputs: vec![],
4237 clip_to_bounds: false,
4238 annotated_text: None,
4239 text_style: None,
4240 text_layout_options: None,
4241 text_pan: None,
4242 graphics_layer: None,
4243 children: vec![],
4244 };
4245 let mut effected = base.clone();
4246 effected.graphics_layer = Some(GraphicsLayer {
4247 render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
4248 ..GraphicsLayer::default()
4249 });
4250
4251 let base_graph = build_layer_node_for_test(base, 1.0, false);
4252 let effected_graph = build_layer_node_for_test(effected, 1.0, false);
4253
4254 assert_eq!(
4255 base_graph.target_content_hash(),
4256 effected_graph.target_content_hash(),
4257 "post-processing effect parameters belong to the effect hash, not the content hash"
4258 );
4259 assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
4260 }
4261
4262 #[test]
4263 fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
4264 let mut text_style = TextStyle::default();
4265 text_style.paragraph_style.text_align = TextAlign::Start;
4266 text_style.paragraph_style.text_direction = TextDirection::Rtl;
4267 text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
4268
4269 let snapshot = BuildNodeSnapshot {
4270 node_id: 1,
4271 placement: Point::default(),
4272 size: Size {
4273 width: 180.0,
4274 height: 48.0,
4275 },
4276 content_offset: Point::default(),
4277 motion_context_animated: false,
4278 translated_content_context: false,
4279 has_own_origin_sinks: false,
4280 measured_max_width: Some(180.0),
4281 resolved_modifiers: ResolvedModifiers::default(),
4282 draw_commands: vec![],
4283 click_actions: vec![],
4284 pointer_inputs: vec![],
4285 clip_to_bounds: false,
4286 annotated_text: Some(AnnotatedString::from("rtl")),
4287 text_style: Some(text_style),
4288 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
4289 overflow: cranpose_ui::TextOverflow::Clip,
4290 ..Default::default()
4291 }),
4292 text_pan: None,
4293 graphics_layer: None,
4294 children: vec![],
4295 };
4296
4297 let graph = build_layer_node_for_test(snapshot, 1.0, false);
4298 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
4299 panic!("expected text primitive");
4300 };
4301 let PrimitiveNode::Text(text) = &text_primitive.node else {
4302 panic!("expected text primitive");
4303 };
4304 let clip = text
4305 .clip
4306 .expect("clipped overflow should produce a clip rect");
4307
4308 assert!(
4309 text.rect.x > 0.0,
4310 "RTL start alignment should shift the text rect within the available width"
4311 );
4312 assert!(
4313 clip.y < text.rect.y,
4314 "baseline shift must expand the clip upward so superscript glyphs are preserved"
4315 );
4316 assert!(
4317 clip.intersect(text.rect).is_some(),
4318 "the clip rect must intersect the shifted text draw rect"
4319 );
4320 }
4321
4322 #[test]
4323 fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
4324 let snapshot = BuildNodeSnapshot {
4325 node_id: 1,
4326 placement: Point::default(),
4327 size: Size {
4328 width: 320.0,
4329 height: 48.0,
4330 },
4331 content_offset: Point::default(),
4332 motion_context_animated: false,
4333 translated_content_context: false,
4334 has_own_origin_sinks: false,
4335 measured_max_width: Some(320.0),
4336 resolved_modifiers: ResolvedModifiers::default(),
4337 draw_commands: vec![],
4338 click_actions: vec![],
4339 pointer_inputs: vec![],
4340 clip_to_bounds: false,
4341 annotated_text: Some(AnnotatedString::from("short")),
4342 text_style: Some(TextStyle::default()),
4343 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
4344 overflow: cranpose_ui::TextOverflow::Clip,
4345 ..Default::default()
4346 }),
4347 text_pan: None,
4348 graphics_layer: None,
4349 children: vec![],
4350 };
4351
4352 let graph = build_layer_node_for_test(snapshot, 1.0, false);
4353 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
4354 panic!("expected text primitive");
4355 };
4356 let PrimitiveNode::Text(text) = &text_primitive.node else {
4357 panic!("expected text primitive");
4358 };
4359 let clip = text.clip.expect("clipped text should keep a clip rect");
4360
4361 assert!(
4362 text.rect.width < 320.0,
4363 "text raster bounds should track measured glyph width instead of full content width"
4364 );
4365 assert_eq!(
4366 clip.width, 322.0,
4367 "text clip should still preserve the full content box plus clip padding"
4368 );
4369 }
4370
4371 #[test]
4375 fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
4376 let pan_offset = 25.0_f32;
4377 let field_width = 80.0_f32;
4378 let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
4379 let viewports = resolved_viewports.clone();
4380 let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
4381 node_id: 1,
4382 placement: Point::default(),
4383 size: Size {
4384 width: field_width,
4385 height: 24.0,
4386 },
4387 content_offset: Point::default(),
4388 motion_context_animated: false,
4389 translated_content_context: false,
4390 has_own_origin_sinks: false,
4391 measured_max_width: Some(field_width),
4392 resolved_modifiers: ResolvedModifiers::default(),
4393 draw_commands: vec![],
4394 click_actions: vec![],
4395 pointer_inputs: vec![],
4396 clip_to_bounds: false,
4397 annotated_text: Some(AnnotatedString::from(
4398 "a very long single line of text that cannot fit",
4399 )),
4400 text_style: Some(TextStyle::default()),
4401 text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
4402 text_pan,
4403 graphics_layer: None,
4404 children: vec![],
4405 };
4406
4407 let text_node = |snapshot: BuildNodeSnapshot| {
4408 let graph = build_layer_node_for_test(snapshot, 1.0, false);
4409 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
4410 panic!("expected text primitive");
4411 };
4412 let PrimitiveNode::Text(text) = &text_primitive.node else {
4413 panic!("expected text primitive");
4414 };
4415 (**text).clone()
4416 };
4417
4418 let unpanned = text_node(make_snapshot(None));
4419 let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
4420 viewports.borrow_mut().push(viewport);
4421 pan_offset
4422 }))));
4423
4424 assert_eq!(
4425 resolved_viewports.borrow().as_slice(),
4426 &[field_width],
4427 "the pan resolver must receive the content viewport width"
4428 );
4429 assert_eq!(
4430 panned.rect.x, -pan_offset,
4431 "text glyphs must shift left by the pan offset"
4432 );
4433 assert!(
4434 panned.rect.width > field_width,
4435 "panned single-line text must be laid out unconstrained, got {}",
4436 panned.rect.width
4437 );
4438 assert!(
4439 panned.rect.width >= unpanned.rect.width,
4440 "unconstrained layout must not be narrower than wrapped layout"
4441 );
4442 assert!(
4443 panned.rect.height <= unpanned.rect.height,
4444 "single-line layout must not wrap onto extra lines"
4445 );
4446 let clip = panned
4447 .clip
4448 .expect("panned text field must clip to field bounds");
4449 assert!(
4450 clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
4451 "clip must not extend past the field bounds, got {clip:?}"
4452 );
4453 }
4454
4455 #[test]
4456 fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
4457 let child = BuildNodeSnapshot {
4458 node_id: 2,
4459 placement: Point { x: 11.0, y: 7.0 },
4460 size: Size {
4461 width: 120.0,
4462 height: 32.0,
4463 },
4464 content_offset: Point::default(),
4465 motion_context_animated: false,
4466 translated_content_context: false,
4467 has_own_origin_sinks: false,
4468 measured_max_width: Some(120.0),
4469 resolved_modifiers: ResolvedModifiers::default(),
4470 draw_commands: vec![],
4471 click_actions: vec![],
4472 pointer_inputs: vec![],
4473 clip_to_bounds: false,
4474 annotated_text: Some(AnnotatedString::from("scrolling")),
4475 text_style: Some(TextStyle::default()),
4476 text_layout_options: None,
4477 text_pan: None,
4478 graphics_layer: None,
4479 children: vec![],
4480 };
4481 let parent = BuildNodeSnapshot {
4482 node_id: 1,
4483 placement: Point::default(),
4484 size: Size {
4485 width: 160.0,
4486 height: 64.0,
4487 },
4488 content_offset: Point { x: 0.0, y: -18.5 },
4489 motion_context_animated: false,
4490 translated_content_context: true,
4491 has_own_origin_sinks: false,
4492 measured_max_width: None,
4493 resolved_modifiers: ResolvedModifiers::default(),
4494 draw_commands: vec![],
4495 click_actions: vec![],
4496 pointer_inputs: vec![],
4497 clip_to_bounds: false,
4498 annotated_text: None,
4499 text_style: None,
4500 text_layout_options: None,
4501 text_pan: None,
4502 graphics_layer: None,
4503 children: vec![child],
4504 };
4505
4506 let graph = build_layer_node_for_test(parent, 1.0, false);
4507 let RenderNode::Layer(child_layer) = &graph.children[0] else {
4508 panic!("expected child layer");
4509 };
4510 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4511 panic!("expected text primitive");
4512 };
4513 let PrimitiveNode::Text(text) = &text_primitive.node else {
4514 panic!("expected text primitive");
4515 };
4516
4517 assert_eq!(text.text_style.paragraph_style.text_motion, None);
4518 assert!(!child_layer.motion_context_animated);
4519 }
4520
4521 #[test]
4522 fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
4523 let child = BuildNodeSnapshot {
4524 node_id: 2,
4525 placement: Point { x: 11.0, y: 7.0 },
4526 size: Size {
4527 width: 120.0,
4528 height: 32.0,
4529 },
4530 content_offset: Point::default(),
4531 motion_context_animated: false,
4532 translated_content_context: false,
4533 has_own_origin_sinks: false,
4534 measured_max_width: Some(120.0),
4535 resolved_modifiers: ResolvedModifiers::default(),
4536 draw_commands: vec![],
4537 click_actions: vec![],
4538 pointer_inputs: vec![],
4539 clip_to_bounds: false,
4540 annotated_text: Some(AnnotatedString::from("scrolling")),
4541 text_style: Some(TextStyle::default()),
4542 text_layout_options: None,
4543 text_pan: None,
4544 graphics_layer: None,
4545 children: vec![],
4546 };
4547 let parent = BuildNodeSnapshot {
4548 node_id: 1,
4549 placement: Point::default(),
4550 size: Size {
4551 width: 160.0,
4552 height: 64.0,
4553 },
4554 content_offset: Point { x: 0.0, y: -18.0 },
4555 motion_context_animated: false,
4556 translated_content_context: false,
4557 has_own_origin_sinks: false,
4558 measured_max_width: None,
4559 resolved_modifiers: ResolvedModifiers::default(),
4560 draw_commands: vec![],
4561 click_actions: vec![],
4562 pointer_inputs: vec![],
4563 clip_to_bounds: false,
4564 annotated_text: None,
4565 text_style: None,
4566 text_layout_options: None,
4567 text_pan: None,
4568 graphics_layer: None,
4569 children: vec![child],
4570 };
4571
4572 let graph = build_layer_node_for_test(parent, 1.0, false);
4573 let RenderNode::Layer(child_layer) = &graph.children[0] else {
4574 panic!("expected child layer");
4575 };
4576 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4577 panic!("expected text primitive");
4578 };
4579 let PrimitiveNode::Text(text) = &text_primitive.node else {
4580 panic!("expected text primitive");
4581 };
4582
4583 assert_eq!(
4584 text.text_style.paragraph_style.text_motion, None,
4585 "content_offset alone must not force text onto the translated-content motion path"
4586 );
4587 assert!(!child_layer.motion_context_animated);
4588 }
4589
4590 #[test]
4591 fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
4592 let child = BuildNodeSnapshot {
4593 node_id: 2,
4594 placement: Point { x: 11.0, y: 7.0 },
4595 size: Size {
4596 width: 120.0,
4597 height: 32.0,
4598 },
4599 content_offset: Point::default(),
4600 motion_context_animated: false,
4601 translated_content_context: false,
4602 has_own_origin_sinks: false,
4603 measured_max_width: Some(120.0),
4604 resolved_modifiers: ResolvedModifiers::default(),
4605 draw_commands: vec![],
4606 click_actions: vec![],
4607 pointer_inputs: vec![],
4608 clip_to_bounds: false,
4609 annotated_text: Some(AnnotatedString::from("shadow")),
4610 text_style: Some(TextStyle::from_span_style(SpanStyle {
4611 shadow: Some(cranpose_ui::text::Shadow {
4612 color: Color::BLACK,
4613 offset: Point::new(1.0, 2.0),
4614 blur_radius: 3.0,
4615 }),
4616 ..SpanStyle::default()
4617 })),
4618 text_layout_options: None,
4619 text_pan: None,
4620 graphics_layer: None,
4621 children: vec![],
4622 };
4623 let parent = BuildNodeSnapshot {
4624 node_id: 1,
4625 placement: Point::default(),
4626 size: Size {
4627 width: 160.0,
4628 height: 64.0,
4629 },
4630 content_offset: Point { x: 0.0, y: -18.5 },
4631 motion_context_animated: false,
4632 translated_content_context: true,
4633 has_own_origin_sinks: false,
4634 measured_max_width: None,
4635 resolved_modifiers: ResolvedModifiers::default(),
4636 draw_commands: vec![],
4637 click_actions: vec![],
4638 pointer_inputs: vec![],
4639 clip_to_bounds: false,
4640 annotated_text: None,
4641 text_style: None,
4642 text_layout_options: None,
4643 text_pan: None,
4644 graphics_layer: None,
4645 children: vec![child],
4646 };
4647
4648 let graph = build_layer_node_for_test(parent, 1.0, false);
4649 let RenderNode::Layer(child_layer) = &graph.children[0] else {
4650 panic!("expected child layer");
4651 };
4652 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4653 panic!("expected text primitive");
4654 };
4655 let PrimitiveNode::Text(text) = &text_primitive.node else {
4656 panic!("expected text primitive");
4657 };
4658
4659 assert_eq!(text.text_style.paragraph_style.text_motion, None);
4660 }
4661
4662 #[test]
4663 fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
4664 let child = BuildNodeSnapshot {
4665 node_id: 2,
4666 placement: Point { x: 11.0, y: 7.0 },
4667 size: Size {
4668 width: 120.0,
4669 height: 32.0,
4670 },
4671 content_offset: Point::default(),
4672 motion_context_animated: false,
4673 translated_content_context: false,
4674 has_own_origin_sinks: false,
4675 measured_max_width: Some(120.0),
4676 resolved_modifiers: ResolvedModifiers::default(),
4677 draw_commands: vec![],
4678 click_actions: vec![],
4679 pointer_inputs: vec![],
4680 clip_to_bounds: false,
4681 annotated_text: Some(AnnotatedString::from("lazy")),
4682 text_style: Some(TextStyle::default()),
4683 text_layout_options: None,
4684 text_pan: None,
4685 graphics_layer: None,
4686 children: vec![],
4687 };
4688 let parent = BuildNodeSnapshot {
4689 node_id: 1,
4690 placement: Point::default(),
4691 size: Size {
4692 width: 160.0,
4693 height: 64.0,
4694 },
4695 content_offset: Point::default(),
4696 motion_context_animated: true,
4697 translated_content_context: false,
4698 has_own_origin_sinks: false,
4699 measured_max_width: None,
4700 resolved_modifiers: ResolvedModifiers::default(),
4701 draw_commands: vec![],
4702 click_actions: vec![],
4703 pointer_inputs: vec![],
4704 clip_to_bounds: false,
4705 annotated_text: None,
4706 text_style: None,
4707 text_layout_options: None,
4708 text_pan: None,
4709 graphics_layer: None,
4710 children: vec![child],
4711 };
4712
4713 let graph = build_layer_node_for_test(parent, 1.0, false);
4714 let RenderNode::Layer(child_layer) = &graph.children[0] else {
4715 panic!("expected child layer");
4716 };
4717 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
4718 panic!("expected text primitive");
4719 };
4720 let PrimitiveNode::Text(text) = &text_primitive.node else {
4721 panic!("expected text primitive");
4722 };
4723
4724 assert_eq!(text.text_style.paragraph_style.text_motion, None);
4725 assert!(graph.motion_context_animated);
4726 assert!(child_layer.motion_context_animated);
4727 }
4728
4729 #[test]
4730 fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
4731 let mut composition = cranpose_ui::run_test_composition(|| {
4732 let list_state = rememberLazyListState();
4733 LazyColumn(
4734 Modifier::empty(),
4735 list_state,
4736 LazyColumnSpec::default(),
4737 |scope| {
4738 scope.item_keyed(Some(0), None, || {
4739 Text("LazyMotion", Modifier::empty(), TextStyle::default());
4740 });
4741 },
4742 );
4743 });
4744
4745 let root = composition.root().expect("lazy column root");
4746 let handle = composition.runtime_handle();
4747 let mut applier = composition.applier_mut();
4748 applier.set_runtime_handle(handle);
4749 let _ = applier
4750 .compute_layout(
4751 root,
4752 Size {
4753 width: 240.0,
4754 height: 240.0,
4755 },
4756 )
4757 .expect("lazy column layout");
4758 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4759 applier.clear_runtime_handle();
4760
4761 assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
4762 }
4763
4764 #[test]
4765 fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
4766 use std::{cell::RefCell, rc::Rc};
4767
4768 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
4769 let state_holder_for_comp = state_holder.clone();
4770 let mut composition = cranpose_ui::run_test_composition(move || {
4771 let list_state = rememberLazyListState();
4772 *state_holder_for_comp.borrow_mut() = Some(list_state);
4773 LazyColumn(
4774 Modifier::empty().height(120.0),
4775 list_state,
4776 LazyColumnSpec::default(),
4777 |scope| {
4778 scope.items(8, |index| {
4779 Text(
4780 format!("LazyMotion {index}"),
4781 Modifier::empty().padding(4.0),
4782 TextStyle::default(),
4783 );
4784 });
4785 },
4786 );
4787 });
4788
4789 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
4790 list_state.scroll_to_item(3, 0.0);
4791
4792 let root = composition.root().expect("lazy column root");
4793 let handle = composition.runtime_handle();
4794 let mut applier = composition.applier_mut();
4795 applier.set_runtime_handle(handle);
4796 let _ = applier
4797 .compute_layout(
4798 root,
4799 Size {
4800 width: 240.0,
4801 height: 240.0,
4802 },
4803 )
4804 .expect("lazy column layout");
4805 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4806 let active_children = applier
4807 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
4808 .expect("lazy column should be subcompose");
4809 let child_debug: Vec<String> = active_children
4810 .iter()
4811 .map(|&child_id| {
4812 if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
4813 format!(
4814 "layout#{child_id} placed={} text={:?} children={:?}",
4815 node.layout_state().is_placed,
4816 node.modifier_slices_snapshot()
4817 .text_content()
4818 .map(str::to_string),
4819 node.children.clone()
4820 )
4821 }) {
4822 summary
4823 } else if let Ok(summary) =
4824 applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
4825 format!(
4826 "subcompose#{child_id} placed={} active_children={:?}",
4827 node.layout_state().is_placed,
4828 node.active_children()
4829 )
4830 })
4831 {
4832 summary
4833 } else {
4834 format!("missing#{child_id}")
4835 }
4836 })
4837 .collect();
4838 applier.clear_runtime_handle();
4839
4840 let first_index = list_state.first_visible_item_index();
4841 assert!(
4842 first_index > 0,
4843 "lazy list should move away from origin before graph building, observed first_index={first_index}"
4844 );
4845 let mut labels = Vec::new();
4846 collect_text_labels(&graph.root, &mut labels);
4847 assert_eq!(
4848 find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
4849 Some(None),
4850 "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
4851 labels,
4852 active_children,
4853 child_debug
4854 );
4855 }
4856
4857 #[test]
4858 fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
4859 use std::{cell::RefCell, rc::Rc};
4860
4861 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
4862 let state_holder_for_comp = state_holder.clone();
4863 let mut composition = cranpose_ui::run_test_composition(move || {
4864 let list_state = rememberLazyListState();
4865 *state_holder_for_comp.borrow_mut() = Some(list_state);
4866 let mut spec =
4867 LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
4868 spec.beyond_bounds_item_count = 0;
4869 LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
4870 scope.items(12, |index| {
4871 Text(
4872 format!("WarmRow {index}"),
4873 Modifier::empty().height(32.0),
4874 TextStyle::default(),
4875 );
4876 });
4877 });
4878 });
4879
4880 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
4881 list_state.scroll_to_item(4, 0.0);
4882
4883 let root = composition.root().expect("lazy column root");
4884 let handle = composition.runtime_handle();
4885 let mut applier = composition.applier_mut();
4886 applier.set_runtime_handle(handle);
4887 let _ = applier
4888 .compute_layout(
4889 root,
4890 Size {
4891 width: 240.0,
4892 height: 240.0,
4893 },
4894 )
4895 .expect("lazy column layout");
4896 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4897 let active_children = applier
4898 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
4899 .expect("lazy column should be subcompose");
4900 applier.clear_runtime_handle();
4901
4902 let visible_indices: Vec<_> = list_state
4903 .layout_info()
4904 .visible_items_info
4905 .iter()
4906 .map(|item| item.index)
4907 .collect();
4908 let mut labels = Vec::new();
4909 collect_text_labels(&graph.root, &mut labels);
4910
4911 assert_eq!(
4912 visible_indices,
4913 vec![4, 5, 6],
4914 "test setup expects exactly three viewport-visible rows"
4915 );
4916 assert!(
4917 labels.iter().any(|label| label == "WarmRow 7"),
4918 "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
4919 );
4920 }
4921
4922 #[test]
4923 fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
4924 use std::{cell::RefCell, rc::Rc};
4925
4926 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
4927 let state_holder_for_comp = state_holder.clone();
4928 let mut composition = cranpose_ui::run_test_composition(move || {
4929 let list_state = rememberLazyListState();
4930 *state_holder_for_comp.borrow_mut() = Some(list_state);
4931 LazyColumn(
4932 Modifier::empty().height(120.0),
4933 list_state,
4934 LazyColumnSpec::default(),
4935 |scope| {
4936 scope.items(8, |index| {
4937 Text(
4938 format!("LazySnap {index}"),
4939 Modifier::empty().padding(4.0),
4940 TextStyle::default(),
4941 );
4942 });
4943 },
4944 );
4945 });
4946
4947 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
4948 list_state.scroll_to_item(2, 7.5);
4949
4950 let root = composition.root().expect("lazy column root");
4951 let handle = composition.runtime_handle();
4952 let mut applier = composition.applier_mut();
4953 applier.set_runtime_handle(handle);
4954 let _ = applier
4955 .compute_layout(
4956 root,
4957 Size {
4958 width: 240.0,
4959 height: 240.0,
4960 },
4961 )
4962 .expect("lazy column layout");
4963 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
4964 applier.clear_runtime_handle();
4965
4966 let layout_info = list_state.layout_info();
4967 let first_visible_offset = layout_info
4968 .visible_items_info
4969 .first()
4970 .expect("lazy layout should expose visible item info")
4971 .offset;
4972 let snap_offset = find_translated_content_offset(&graph.root)
4973 .expect("lazy list graph should include translated content context");
4974
4975 assert!(
4976 (snap_offset.y - first_visible_offset).abs() <= 0.001,
4977 "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
4978 );
4979 }
4980
4981 #[test]
4982 fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
4983 let child = BuildNodeSnapshot {
4984 node_id: 2,
4985 placement: Point { x: 11.0, y: 7.0 },
4986 size: Size {
4987 width: 120.0,
4988 height: 32.0,
4989 },
4990 content_offset: Point::default(),
4991 motion_context_animated: false,
4992 translated_content_context: false,
4993 has_own_origin_sinks: false,
4994 measured_max_width: Some(120.0),
4995 resolved_modifiers: ResolvedModifiers::default(),
4996 draw_commands: vec![],
4997 click_actions: vec![],
4998 pointer_inputs: vec![],
4999 clip_to_bounds: false,
5000 annotated_text: Some(AnnotatedString::from("static")),
5001 text_style: Some(TextStyle::from_paragraph_style(
5002 cranpose_ui::text::ParagraphStyle {
5003 text_motion: Some(TextMotion::Static),
5004 ..Default::default()
5005 },
5006 )),
5007 text_layout_options: None,
5008 text_pan: None,
5009 graphics_layer: None,
5010 children: vec![],
5011 };
5012 let parent = BuildNodeSnapshot {
5013 node_id: 1,
5014 placement: Point::default(),
5015 size: Size {
5016 width: 160.0,
5017 height: 64.0,
5018 },
5019 content_offset: Point { x: 0.0, y: -18.5 },
5020 motion_context_animated: false,
5021 translated_content_context: true,
5022 has_own_origin_sinks: false,
5023 measured_max_width: None,
5024 resolved_modifiers: ResolvedModifiers::default(),
5025 draw_commands: vec![],
5026 click_actions: vec![],
5027 pointer_inputs: vec![],
5028 clip_to_bounds: false,
5029 annotated_text: None,
5030 text_style: None,
5031 text_layout_options: None,
5032 text_pan: None,
5033 graphics_layer: None,
5034 children: vec![child],
5035 };
5036
5037 let graph = build_layer_node_for_test(parent, 1.0, false);
5038 let RenderNode::Layer(child_layer) = &graph.children[0] else {
5039 panic!("expected child layer");
5040 };
5041 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
5042 panic!("expected text primitive");
5043 };
5044 let PrimitiveNode::Text(text) = &text_primitive.node else {
5045 panic!("expected text primitive");
5046 };
5047
5048 assert_eq!(
5049 text.text_style.paragraph_style.text_motion,
5050 Some(TextMotion::Static),
5051 "explicit text motion must win over inherited scrolling motion context"
5052 );
5053 }
5054
5055 #[test]
5078 fn wrapped_paragraph_paints_the_height_it_measured() {
5079 const BODY: &str = "fed back картица scored fp32 износ once paper fed Vision dropped \
5080 fed widest the strip mask prompt mask threshold Vision on датум instance mask \
5081 износ Apple";
5082 const FOLLOWING: &str = "FOLLOWING SIBLING";
5083
5084 let app_context = cranpose_ui::AppContext::new();
5085 app_context.enter(|| {
5086 cranpose_ui::text::set_text_measurer(
5087 crate::software_text_raster::SoftwareTextMeasurer::from_fonts_or_default(&[], 8192),
5088 );
5089 let mut composition = cranpose_ui::run_test_composition(move || {
5090 Column(
5091 Modifier::empty().fill_max_width(),
5092 ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
5093 move || {
5094 Text(BODY.to_string(), Modifier::empty(), TextStyle::default());
5095 Text(
5096 FOLLOWING.to_string(),
5097 Modifier::empty(),
5098 TextStyle::default(),
5099 );
5100 },
5101 );
5102 });
5103
5104 let root = composition.root().expect("composition root");
5105 let handle = composition.runtime_handle();
5106 let mut applier = composition.applier_mut();
5107 applier.set_runtime_handle(handle);
5108 let layout = applier
5109 .compute_layout(
5110 root,
5111 Size {
5112 width: 245.0,
5113 height: 900.0,
5114 },
5115 )
5116 .expect("layout");
5117
5118 fn find_box<'a>(node: &'a LayoutBox, value: &str) -> Option<&'a LayoutBox> {
5119 if node
5120 .node_data
5121 .modifier_slices()
5122 .text_content()
5123 .is_some_and(|text| text == value)
5124 {
5125 return Some(node);
5126 }
5127 node.children
5128 .iter()
5129 .find_map(|child| find_box(child, value))
5130 }
5131 let body_box = find_box(layout.root(), BODY).expect("measured paragraph box");
5132 let following_box = find_box(layout.root(), FOLLOWING).expect("measured sibling box");
5133 let measured_height = body_box.rect.height;
5134 let following_top = following_box.rect.y;
5135 assert!(
5136 measured_height > 60.0,
5137 "test setup expects a genuinely multi-line paragraph, got {measured_height}"
5138 );
5139 assert!(
5140 body_box.rect.width < 245.0,
5141 "test setup expects the node to be placed at its own measured width, \
5142 not the full constraint, got {}",
5143 body_box.rect.width
5144 );
5145
5146 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("render graph");
5147 applier.clear_runtime_handle();
5148
5149 fn squashed(value: &str) -> String {
5152 value.chars().filter(|c| !c.is_whitespace()).collect()
5153 }
5154 fn find_text<'a>(layer: &'a LayerNode, value: &str) -> Option<&'a TextPrimitiveNode> {
5155 for child in &layer.children {
5156 match child {
5157 RenderNode::Primitive(primitive) => {
5158 if let PrimitiveNode::Text(text) = &primitive.node
5159 && squashed(&text.text.text) == squashed(value)
5160 {
5161 return Some(text);
5162 }
5163 }
5164 RenderNode::Layer(child_layer) => {
5165 if let Some(found) = find_text(child_layer, value) {
5166 return Some(found);
5167 }
5168 }
5169 RenderNode::DrawRun(_) => {}
5170 }
5171 }
5172 None
5173 }
5174 let painted = find_text(&graph.root, BODY).expect("painted paragraph");
5175
5176 assert!(
5177 (painted.rect.height - measured_height).abs() < 0.5,
5178 "paragraph painted {:.2} tall into a box layout measured at {:.2} \
5179 (painted rect {:?})",
5180 painted.rect.height,
5181 measured_height,
5182 painted.rect
5183 );
5184 assert!(
5185 painted.rect.y + painted.rect.height <= following_top + 0.5,
5186 "painted paragraph bottom {:.2} runs past the following sibling placed at \
5187 {:.2}",
5188 painted.rect.y + painted.rect.height,
5189 following_top
5190 );
5191 });
5192 }
5193
5194 #[test]
5195 fn retained_slot_confirmations_are_live_only_under_their_generation() {
5196 let command = DrawCommandId {
5197 node_id: 990_101,
5198 command_index: 0,
5199 placement: DrawPlacement::Behind,
5200 };
5201 set_retained_feed_epoch(Some(7));
5202 confirm_retained_slot(command, 3, 7);
5203 assert!(retained_slot_confirmed(command, 3));
5204 set_retained_feed_epoch(Some(8));
5206 assert!(!retained_slot_confirmed(command, 3));
5207 set_retained_feed_epoch(None);
5209 assert!(!retained_slot_confirmed(command, 3));
5210 set_retained_feed_epoch(Some(7));
5212 assert!(retained_slot_confirmed(command, 3));
5213 revoke_retained_slot(command, 3);
5214 assert!(!retained_slot_confirmed(command, 3));
5215 set_retained_feed_epoch(None);
5216 clear_retained_slot_confirmations();
5217 }
5218
5219 fn record_sweep_test_rings(scope: &mut DrawScopeDefault) {
5225 let count = 600usize;
5226 let sweep = std::f32::consts::TAU / count as f32 * 0.8;
5227 for i in 0..count {
5228 let start = i as f32 * (std::f32::consts::TAU / count as f32);
5229 scope.draw_annular_sector(
5230 Brush::solid(cranpose_ui_graphics::Color(0.2, 0.4, 0.6, 1.0)),
5231 cranpose_ui_graphics::Point::new(204.0, 204.0),
5232 140.0,
5233 150.0,
5234 start,
5235 sweep,
5236 );
5237 }
5238 }
5239
5240 #[test]
5248 fn recording_sweep_cannot_sever_a_frames_fallback() {
5249 let command = DrawCommandId {
5250 node_id: 990_102,
5251 command_index: 0,
5252 placement: DrawPlacement::Behind,
5253 };
5254 set_retained_feed_epoch(Some(41));
5255 for slot in 0..64 {
5256 confirm_retained_slot(command, slot, 41);
5257 }
5258
5259 let mut state = cranpose_ui_graphics::CommandReplayState::default();
5264 let mut published = None;
5265 for _frame in 0..4 {
5266 let (recording, storage, _) = acquire_recording(command);
5267 let mut scope = DrawScopeDefault::with_recording(
5268 cranpose_ui_graphics::Size::new(408.0, 408.0),
5269 None,
5270 recording,
5271 storage,
5272 );
5273 record_sweep_test_rings(&mut scope);
5274 let outcome = state.advance(scope.recorded());
5275 let center = state.center();
5276 let (finished, frame) = scope.finish_replay(center, outcome, &mut |slot| {
5277 retained_slot_confirmed(command, slot)
5278 });
5279 let (primitives, fallback) =
5280 publish_recording(command, finished.recording, finished.primitives, None);
5281 let frame = frame.map(|mut frame| {
5282 frame.fallback = Some(fallback.clone());
5283 frame
5284 });
5285 published = Some((primitives, fallback, frame));
5286 }
5287 let (_primitives, fallback, frame) = published.expect("four frames published");
5288 let frame = frame.expect("the replay must produce a frame with retained spans");
5289 let bypassed: Vec<(u32, u32)> = frame
5290 .spans
5291 .iter()
5292 .filter_map(|span| match span {
5293 cranpose_ui_graphics::FrameSpan::Retained {
5294 capture: false,
5295 range,
5296 tape_range,
5297 ..
5298 } if range.1 <= range.0 => Some(*tape_range),
5299 _ => None,
5300 })
5301 .collect();
5302 assert!(
5303 !bypassed.is_empty(),
5304 "confirmed slots must actually have bypassed materialization"
5305 );
5306 let expected: Vec<Vec<DrawPrimitive>> = bypassed
5307 .iter()
5308 .map(|tape_range| {
5309 fallback
5310 .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
5311 .expect("a frame-consistent tape range must materialize")
5312 })
5313 .collect();
5314
5315 for _ in 0..1024 {
5320 bump_recording_generation();
5321 }
5322 assert!(
5323 COMMAND_RECORDINGS.with(|map| !map.borrow().contains_key(&command)),
5324 "the sweep must stay pure capacity management: a live confirmation \
5325 no longer pins the registry slot"
5326 );
5327
5328 for (tape_range, expected) in bypassed.iter().zip(&expected) {
5331 let after = fallback
5332 .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
5333 .expect("the frame-owned recording must outlive the sweep");
5334 assert_eq!(
5335 &after, expected,
5336 "post-sweep rematerialization must be byte-identical"
5337 );
5338 }
5339 set_retained_feed_epoch(None);
5340 clear_retained_slot_confirmations();
5341 }
5342
5343 #[test]
5351 fn command_recordings_reuse_recording_buffers_across_rebuilds() {
5352 let command = DrawCommandId {
5353 node_id: 990_103,
5354 command_index: 0,
5355 placement: DrawPlacement::Behind,
5356 };
5357 let mut held = None;
5360 let mut ptrs = Vec::new();
5361 for _build in 0..8 {
5362 let (recording, storage, _) = acquire_recording(command);
5363 let mut scope = DrawScopeDefault::with_recording(
5364 cranpose_ui_graphics::Size::new(64.0, 64.0),
5365 None,
5366 recording,
5367 storage,
5368 );
5369 scope.draw_rect_at(
5370 Rect {
5371 x: 4.0,
5372 y: 4.0,
5373 width: 16.0,
5374 height: 8.0,
5375 },
5376 Brush::solid(Color::WHITE),
5377 );
5378 let finished = scope.finish();
5379 let (primitives, recording) =
5380 publish_recording(command, finished.recording, finished.primitives, None);
5381 ptrs.push(recording.tape_ptr());
5382 held = Some((primitives, recording));
5383 }
5384 drop(held);
5385 for build in 2..8 {
5389 assert_eq!(
5390 ptrs[build],
5391 ptrs[build - 2],
5392 "steady-state publishes must ping-pong between the pair's \
5393 buffers (build {build} allocated)"
5394 );
5395 }
5396 assert_ne!(
5397 ptrs[6], ptrs[7],
5398 "a recording a live frame still shares must never be recorded into"
5399 );
5400 }
5401
5402 #[test]
5403 fn sanitized_spans_drop_recolors_and_downgrade_captures() {
5404 use cranpose_ui_graphics::{FrameSpan, RecordTransform};
5405 let bounds = Rect {
5406 x: 1.0,
5407 y: 2.0,
5408 width: 3.0,
5409 height: 4.0,
5410 };
5411 let spans = vec![
5412 FrameSpan::Dynamic { range: (0, 5) },
5413 FrameSpan::Retained {
5414 slot: 7,
5415 capture: true,
5416 slot_offset: 0,
5417 range: (5, 105),
5418 tape_range: (5, 105),
5419 transform: RecordTransform::IDENTITY,
5420 recolors: Vec::new(),
5421 bounds,
5422 },
5423 FrameSpan::Retained {
5424 slot: 8,
5425 capture: false,
5426 slot_offset: 3,
5427 range: (105, 205),
5428 tape_range: (110, 210),
5429 transform: RecordTransform {
5430 scale: 0.999,
5431 angle: 0.05,
5432 },
5433 recolors: vec![(4, cranpose_ui_graphics::Color(1.0, 0.5, 0.2, 1.0))],
5434 bounds,
5435 },
5436 ];
5437 let sanitized = sanitized_replay_spans(&spans);
5438 assert_eq!(sanitized[0], FrameSpan::Dynamic { range: (0, 5) });
5440 assert_eq!(sanitized[1], FrameSpan::Dynamic { range: (5, 105) });
5444 match &sanitized[2] {
5448 FrameSpan::Retained {
5449 slot,
5450 capture,
5451 slot_offset,
5452 range,
5453 tape_range,
5454 transform,
5455 recolors,
5456 bounds: sanitized_bounds,
5457 } => {
5458 assert_eq!((*slot, *capture, *slot_offset), (8, false, 3));
5459 assert_eq!((*range, *tape_range), ((105, 205), (110, 210)));
5460 assert_eq!(transform.angle, 0.05);
5461 assert!(recolors.is_empty(), "recolors must be emptied");
5462 assert_eq!(*sanitized_bounds, bounds);
5463 }
5464 other => panic!("expected a retained span, got {other:?}"),
5465 }
5466 }
5467
5468 #[test]
5473 fn a_saved_emission_serves_the_next_build_once() {
5474 let command = DrawCommandId {
5475 node_id: 990_303,
5476 command_index: 0,
5477 placement: DrawPlacement::Behind,
5478 };
5479 set_retained_feed_epoch(Some(77));
5480 publish_recording(
5482 command,
5483 cranpose_ui_graphics::CommandRecording::default(),
5484 Vec::new(),
5485 None,
5486 );
5487 let saved = || SavedReplayEmission {
5488 spans: vec![cranpose_ui_graphics::FrameSpan::Dynamic { range: (0, 3) }],
5489 center: cranpose_ui_graphics::Point::new(204.0, 204.0),
5490 primitives: Rc::new(Vec::new()),
5491 recording: Rc::new(cranpose_ui_graphics::CommandRecording::default()),
5492 epoch: 77,
5493 generation: RECORDING_GENERATION.with(std::cell::Cell::get),
5494 };
5495
5496 store_saved_emission(command, Some(saved()));
5498 assert!(!saved_emission_available(command));
5499 bump_recording_generation();
5501 assert!(saved_emission_available(command));
5502 bump_recording_generation();
5506 assert!(!saved_emission_available(command));
5507
5508 store_saved_emission(command, Some(saved()));
5511 bump_recording_generation();
5512 assert!(saved_emission_available(command));
5513 assert!(take_saved_emission(command).is_some());
5514 assert!(
5515 !saved_emission_available(command),
5516 "a second serve of one emission must be unconstructible"
5517 );
5518 assert!(take_saved_emission(command).is_none());
5519
5520 store_saved_emission(command, Some(saved()));
5522 bump_recording_generation();
5523 set_retained_feed_epoch(Some(78));
5524 assert!(!saved_emission_available(command));
5525 set_retained_feed_epoch(None);
5526 assert!(!saved_emission_available(command));
5527 set_retained_feed_epoch(Some(77));
5528 assert!(saved_emission_available(command));
5529 set_retained_feed_epoch(None);
5530 }
5531}