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