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