1use std::collections::HashSet;
2use std::rc::Rc;
3
4use cranpose_core::{MemoryApplier, NodeId};
5use cranpose_ui::text::AnnotatedString;
6use cranpose_ui::text::{resolve_text_direction, TextAlign, TextStyle};
7use cranpose_ui::{
8 prepare_text_layout, DrawCommand, LayoutBox, LayoutNode, ModifierNodeSlices, Point, Rect,
9 ResolvedModifiers, Size, SubcomposeLayoutNode, TextLayoutOptions, TextOverflow,
10 TextPanResolver,
11};
12use cranpose_ui_graphics::{
13 rounded_corner_alpha_mask_effect, CompositingStrategy, GraphicsLayer, LayerShape,
14 RoundedCornerShape,
15};
16
17use crate::graph::{
18 CachePolicy, DrawCommandId, DrawRunNode, HitTestNode, IsolationReasons, LayerNode,
19 PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform, RenderGraph, RenderNode,
20 TextPrimitiveNode,
21};
22use crate::layer_transform::layer_transform_to_parent;
23use crate::raster_cache::LayerRasterCacheHashes;
24use crate::style_shared::{primitives_for_placement_verified, DrawPlacement};
25
26const TEXT_CLIP_PAD: f32 = 1.0;
27const ROUNDED_CLIP_EDGE_FEATHER: f32 = 1.0;
28
29#[derive(Clone)]
30struct BuildNodeSnapshot {
31 node_id: NodeId,
32 placement: Point,
33 size: Size,
34 content_offset: Point,
35 motion_context_animated: bool,
36 translated_content_context: bool,
37 measured_max_width: Option<f32>,
38 resolved_modifiers: ResolvedModifiers,
39 draw_commands: Vec<DrawCommand>,
40 click_actions: Vec<Rc<dyn Fn(Point)>>,
41 pointer_inputs: Vec<Rc<dyn Fn(cranpose_foundation::PointerEvent)>>,
42 clip_to_bounds: bool,
43 annotated_text: Option<AnnotatedString>,
44 text_style: Option<TextStyle>,
45 text_layout_options: Option<TextLayoutOptions>,
46 text_pan: Option<TextPanResolver>,
47 graphics_layer: Option<GraphicsLayer>,
48 children: Vec<Self>,
49}
50
51struct SnapshotNodeData {
52 layout_state: cranpose_ui::widgets::LayoutState,
53 modifier_slices: Rc<ModifierNodeSlices>,
54 resolved_modifiers: ResolvedModifiers,
55 children: Vec<NodeId>,
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub struct GraphUpdateReport {
60 pub applied: bool,
61 pub hit_graph_dirty: bool,
62}
63
64pub fn build_graph_from_layout_tree(root: &LayoutBox, scale: f32) -> RenderGraph {
65 bump_recording_generation();
66 let root_snapshot = layout_box_to_snapshot(root, None);
67 RenderGraph {
68 root: build_layer_node(root_snapshot, scale, false),
69 }
70}
71
72pub fn build_graph_from_applier(
73 applier: &mut MemoryApplier,
74 root: NodeId,
75 scale: f32,
76) -> Option<RenderGraph> {
77 bump_recording_generation();
78 Some(RenderGraph {
79 root: build_layer_node_from_applier(applier, root, scale, false)?,
80 })
81}
82
83pub fn update_graph_from_applier(
84 applier: &mut MemoryApplier,
85 graph: &mut RenderGraph,
86 dirty_nodes: &[NodeId],
87 scale: f32,
88) -> bool {
89 update_graph_from_applier_report(applier, graph, dirty_nodes, scale).applied
90}
91
92pub fn update_graph_from_applier_report(
93 applier: &mut MemoryApplier,
94 graph: &mut RenderGraph,
95 dirty_nodes: &[NodeId],
96 scale: f32,
97) -> GraphUpdateReport {
98 if dirty_nodes.is_empty() {
99 return GraphUpdateReport {
100 applied: true,
101 hit_graph_dirty: false,
102 };
103 }
104 bump_recording_generation();
105
106 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
107 eprintln!("[scene-update-diag] dirty={dirty_nodes:?}");
108 }
109
110 let mut remaining_dirty_nodes = dirty_nodes.iter().copied().collect::<HashSet<_>>();
111 if let Some(root_id) = graph.root.node_id {
112 if remaining_dirty_nodes.contains(&root_id) {
113 let Some(root) = build_layer_node_from_applier(applier, root_id, scale, false) else {
114 return GraphUpdateReport {
115 applied: false,
116 hit_graph_dirty: true,
117 };
118 };
119 let hit_graph_dirty = layer_hit_graph_state_dirty(&graph.root, &root);
120 graph.root = root;
121 graph.root.recompute_raster_cache_hashes();
122 return GraphUpdateReport {
123 applied: true,
124 hit_graph_dirty,
125 };
126 }
127 }
128
129 let inherited_translated_content_context = graph.root.translated_content_context;
130 let report = match replace_dirty_layers_from_applier(
131 applier,
132 &mut graph.root,
133 &mut remaining_dirty_nodes,
134 inherited_translated_content_context,
135 ) {
136 Some(report) => report,
137 None => {
138 return GraphUpdateReport {
139 applied: false,
140 hit_graph_dirty: true,
141 };
142 }
143 };
144
145 if !remaining_dirty_nodes.is_empty() {
146 return GraphUpdateReport {
147 applied: false,
148 hit_graph_dirty: true,
149 };
150 }
151
152 if report.updated {
153 graph.root.recompute_raster_cache_hashes();
154 }
155 GraphUpdateReport {
156 applied: true,
157 hit_graph_dirty: report.hit_graph_dirty,
158 }
159}
160
161#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
162struct ReplaceDirtyLayersReport {
163 updated: bool,
164 hit_graph_dirty: bool,
165}
166
167fn replace_dirty_layers_from_applier(
168 applier: &mut MemoryApplier,
169 parent: &mut LayerNode,
170 dirty_nodes: &mut HashSet<NodeId>,
171 inherited_translated_content_context: bool,
172) -> Option<ReplaceDirtyLayersReport> {
173 if dirty_nodes.is_empty() {
174 return Some(ReplaceDirtyLayersReport::default());
175 }
176
177 let child_inherited_translated_content_context =
178 inherited_translated_content_context || parent.translated_content_context;
179 let mut report = ReplaceDirtyLayersReport::default();
180
181 for child in &mut parent.children {
182 let RenderNode::Layer(child_layer) = child else {
183 continue;
184 };
185
186 if child_layer
187 .node_id
188 .is_some_and(|node_id| dirty_nodes.remove(&node_id))
189 {
190 let mut replacement = build_layer_node_from_applier_internal(
191 applier,
192 child_layer
193 .node_id
194 .expect("dirty layer must have a node id"),
195 parent.motion_context_animated,
196 child_inherited_translated_content_context,
197 Some(AbsOrigin {
203 content_origin: parent.scene_children_origin,
204 layer_translation: parent.scene_children_layer_translation,
205 }),
206 )?;
207 if parent.content_offset != Point::default() {
208 replacement.transform_to_parent =
209 replacement
210 .transform_to_parent
211 .then(ProjectiveTransform::translation(
212 parent.content_offset.x,
213 parent.content_offset.y,
214 ));
215 }
216 report.hit_graph_dirty |= layer_hit_graph_state_dirty(child_layer, &replacement);
217 remove_dirty_descendants(&replacement, dirty_nodes);
218 **child_layer = replacement;
219 report.updated = true;
220 continue;
221 }
222
223 let child_report = replace_dirty_layers_from_applier(
224 applier,
225 child_layer,
226 dirty_nodes,
227 child_inherited_translated_content_context,
228 )?;
229 report.updated |= child_report.updated;
230 report.hit_graph_dirty |= child_report.hit_graph_dirty;
231 }
232
233 if report.updated {
234 parent.has_hit_targets = parent.hit_test.is_some()
235 || parent.children.iter().any(|child| match child {
236 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
237 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
238 });
239 }
240
241 Some(report)
242}
243
244fn layer_hit_graph_state_dirty(previous: &LayerNode, replacement: &LayerNode) -> bool {
245 if previous.hit_test.is_some() || replacement.hit_test.is_some() {
246 return true;
247 }
248
249 if !(previous.has_hit_targets || replacement.has_hit_targets) {
250 return false;
251 }
252
253 previous.has_hit_targets != replacement.has_hit_targets
254 || previous.local_bounds != replacement.local_bounds
255 || previous.transform_to_parent != replacement.transform_to_parent
256 || previous.clip_rect() != replacement.clip_rect()
257 || previous.graphics_layer.shape != replacement.graphics_layer.shape
258}
259
260fn remove_dirty_descendants(layer: &LayerNode, dirty_nodes: &mut HashSet<NodeId>) {
261 for child in &layer.children {
262 let RenderNode::Layer(child_layer) = child else {
263 continue;
264 };
265 if let Some(node_id) = child_layer.node_id {
266 dirty_nodes.remove(&node_id);
267 }
268 remove_dirty_descendants(child_layer, dirty_nodes);
269 }
270}
271
272fn build_layer_node(
273 snapshot: BuildNodeSnapshot,
274 _root_scale: f32,
275 inherited_motion_context_animated: bool,
276) -> LayerNode {
277 build_layer_node_internal(snapshot, inherited_motion_context_animated, false)
278}
279
280fn build_layer_node_internal(
281 snapshot: BuildNodeSnapshot,
282 inherited_motion_context_animated: bool,
283 inherited_translated_content_context: bool,
284) -> LayerNode {
285 let BuildNodeSnapshot {
286 node_id,
287 placement,
288 size,
289 content_offset,
290 motion_context_animated,
291 translated_content_context,
292 measured_max_width,
293 resolved_modifiers,
294 draw_commands,
295 click_actions,
296 pointer_inputs,
297 clip_to_bounds,
298 annotated_text,
299 text_style,
300 text_layout_options,
301 text_pan,
302 graphics_layer,
303 children: child_snapshots,
304 } = snapshot;
305 let local_bounds = Rect {
306 x: 0.0,
307 y: 0.0,
308 width: size.width,
309 height: size.height,
310 };
311 let graphics_layer = graphics_layer.unwrap_or_default();
312 let transform_to_parent = layer_transform_to_parent(local_bounds, placement, &graphics_layer);
313 let isolation = isolation_reasons(&graphics_layer);
314 let cache_policy = if isolation.has_any() {
315 CachePolicy::Auto
316 } else {
317 CachePolicy::None
318 };
319 let shadow_clip = clip_to_bounds.then_some(local_bounds);
320 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
321 shape: None,
322 click_actions,
323 pointer_inputs,
324 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
325 });
326
327 let node_motion_context_animated = inherited_motion_context_animated || motion_context_animated;
328 let child_translated_content_context =
329 inherited_translated_content_context || translated_content_context;
330
331 let mut children = draw_nodes(
332 node_id,
333 &draw_commands,
334 DrawPlacement::Behind,
335 size,
336 PrimitivePhase::BeforeChildren,
337 );
338 if let Some(text) = text_node_from_parts(TextNodeParts {
339 node_id,
340 local_bounds,
341 measured_max_width,
342 resolved_modifiers: &resolved_modifiers,
343 annotated_text: annotated_text.as_ref(),
344 text_style: text_style.as_ref(),
345 text_layout_options,
346 text_pan,
347 modifier_slices: None,
348 }) {
349 children.push(RenderNode::Primitive(PrimitiveEntry {
350 phase: PrimitivePhase::BeforeChildren,
351 node: PrimitiveNode::Text(Box::new(text)),
352 }));
353 }
354 let child_motion_context_animated = node_motion_context_animated;
355 for child in child_snapshots {
356 let mut child_layer = build_layer_node_internal(
357 child,
358 child_motion_context_animated,
359 child_translated_content_context,
360 );
361 if content_offset != Point::default() {
362 child_layer.transform_to_parent =
363 child_layer
364 .transform_to_parent
365 .then(ProjectiveTransform::translation(
366 content_offset.x,
367 content_offset.y,
368 ));
369 }
370 children.push(RenderNode::Layer(Box::new(child_layer)));
371 }
372 children.extend(draw_nodes(
373 node_id,
374 &draw_commands,
375 DrawPlacement::Overlay,
376 size,
377 PrimitivePhase::AfterChildren,
378 ));
379 let has_hit_targets = hit_test.is_some()
380 || children.iter().any(|child| match child {
381 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
382 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
383 });
384
385 LayerNode {
386 node_id: Some(node_id),
387 local_bounds,
388 transform_to_parent,
389 content_offset,
390 motion_context_animated: node_motion_context_animated,
391 translated_content_context,
392 translated_content_offset: if translated_content_context {
393 content_offset
394 } else {
395 Point::default()
396 },
397 scene_children_origin: Point::default(),
402 scene_children_layer_translation: Point::default(),
403 graphics_layer,
404 clip_to_bounds,
405 shadow_clip,
406 hit_test,
407 has_hit_targets,
408 isolation,
409 cache_policy,
410 cache_hashes: LayerRasterCacheHashes::default(),
411 cache_hashes_valid: false,
412 children,
413 }
414}
415
416#[derive(Clone, Copy)]
427struct AbsOrigin {
428 content_origin: Point,
429 layer_translation: Point,
430}
431
432impl AbsOrigin {
433 const ROOT: AbsOrigin = AbsOrigin {
434 content_origin: Point { x: 0.0, y: 0.0 },
435 layer_translation: Point { x: 0.0, y: 0.0 },
436 };
437}
438
439fn build_layer_node_from_applier(
440 applier: &mut MemoryApplier,
441 node_id: NodeId,
442 _root_scale: f32,
443 inherited_motion_context_animated: bool,
444) -> Option<LayerNode> {
445 build_layer_node_from_applier_internal(
446 applier,
447 node_id,
448 inherited_motion_context_animated,
449 false,
450 Some(AbsOrigin::ROOT),
451 )
452}
453
454fn build_layer_node_from_applier_internal(
455 applier: &mut MemoryApplier,
456 node_id: NodeId,
457 inherited_motion_context_animated: bool,
458 inherited_translated_content_context: bool,
459 parent_abs: Option<AbsOrigin>,
460) -> Option<LayerNode> {
461 if let Ok(data) = applier.with_node::<LayoutNode, _>(node_id, |node| {
462 let state = node.layout_state();
463 let children = node.children.clone();
464 let modifier_slices = node.modifier_slices_snapshot();
465 SnapshotNodeData {
466 layout_state: state,
467 modifier_slices,
468 resolved_modifiers: node.resolved_modifiers(),
469 children,
470 }
471 }) {
472 return build_layer_node_from_data(
473 applier,
474 node_id,
475 data,
476 inherited_motion_context_animated,
477 inherited_translated_content_context,
478 parent_abs,
479 );
480 }
481
482 if let Ok(data) = applier.with_node::<SubcomposeLayoutNode, _>(node_id, |node| {
483 let state = node.layout_state();
484 let children = node.active_children();
485 let modifier_slices = node.modifier_slices_snapshot();
486 SnapshotNodeData {
487 layout_state: state,
488 modifier_slices,
489 resolved_modifiers: node.resolved_modifiers(),
490 children,
491 }
492 }) {
493 return build_layer_node_from_data(
494 applier,
495 node_id,
496 data,
497 inherited_motion_context_animated,
498 inherited_translated_content_context,
499 parent_abs,
500 );
501 }
502
503 None
504}
505
506fn build_layer_node_from_data(
507 applier: &mut MemoryApplier,
508 node_id: NodeId,
509 data: SnapshotNodeData,
510 inherited_motion_context_animated: bool,
511 inherited_translated_content_context: bool,
512 parent_abs: Option<AbsOrigin>,
513) -> Option<LayerNode> {
514 let SnapshotNodeData {
515 layout_state,
516 modifier_slices,
517 resolved_modifiers,
518 children,
519 } = data;
520 if !layout_state.is_placed {
521 return None;
522 }
523
524 let local_bounds = Rect {
525 x: 0.0,
526 y: 0.0,
527 width: layout_state.size.width,
528 height: layout_state.size.height,
529 };
530 if cranpose_core::env_flag!("CRANPOSE_SCENE_UPDATE_DIAG") {
531 eprintln!(
532 "[scene-update-diag] build layer node={node_id:?} size=({:.2},{:.2}) pos=({:.2},{:.2})",
533 layout_state.size.width,
534 layout_state.size.height,
535 layout_state.position.x,
536 layout_state.position.y,
537 );
538 }
539 let clip_to_bounds = modifier_slices.clip_to_bounds();
540 let graphics_layer = graphics_layer_with_shaped_clip(
541 modifier_slices.graphics_layer().unwrap_or_default(),
542 clip_to_bounds,
543 modifier_slices.corner_shape(),
544 local_bounds,
545 );
546 let transform_to_parent =
547 layer_transform_to_parent(local_bounds, layout_state.position, &graphics_layer);
548 let isolation = isolation_reasons(&graphics_layer);
549 let cache_policy = if isolation.has_any() {
550 CachePolicy::Auto
551 } else {
552 CachePolicy::None
553 };
554 let click_actions = modifier_slices.click_handlers();
555 let pointer_inputs = modifier_slices.pointer_inputs();
556 let shadow_clip = clip_to_bounds.then_some(local_bounds);
557 let hit_test = (!click_actions.is_empty() || !pointer_inputs.is_empty()).then(|| HitTestNode {
558 shape: None,
559 click_actions: click_actions.to_vec(),
560 pointer_inputs: pointer_inputs.to_vec(),
561 clip: (clip_to_bounds || graphics_layer.clip).then_some(local_bounds),
562 });
563
564 modifier_slices.publish_pointer_input_size(layout_state.size);
571
572 let node_motion_context_animated =
573 inherited_motion_context_animated || modifier_slices.motion_context_animated();
574 let local_translated_content_context = modifier_slices.translated_content_context();
575 let local_translated_content_offset = modifier_slices
576 .translated_content_offset()
577 .unwrap_or(layout_state.content_offset);
578 let child_translated_content_context =
579 inherited_translated_content_context || local_translated_content_context;
580
581 let this_abs = parent_abs.map(|parent| {
596 let (tx, ty) = modifier_slices
597 .graphics_layer()
598 .map(|layer| (layer.translation_x, layer.translation_y))
599 .unwrap_or((0.0, 0.0));
600 let top_left = Point {
601 x: parent.content_origin.x + layout_state.position.x,
602 y: parent.content_origin.y + layout_state.position.y,
603 };
604 let layer_translation = Point {
605 x: parent.layer_translation.x + tx,
606 y: parent.layer_translation.y + ty,
607 };
608 (top_left, layer_translation)
609 });
610 if let Some((top_left, layer_translation)) = this_abs {
611 let window_origin = Point {
612 x: top_left.x + layer_translation.x,
613 y: top_left.y + layer_translation.y,
614 };
615 if let Some(sink) = modifier_slices.text_field_window_origin() {
616 sink.set(window_origin);
617 }
618 if let Some(sink) = modifier_slices.viewport_window_rect() {
619 sink.set(Rect {
620 x: window_origin.x,
621 y: window_origin.y,
622 width: layout_state.size.width,
623 height: layout_state.size.height,
624 });
625 }
626 }
627 let child_abs = this_abs.map(|(top_left, layer_translation)| AbsOrigin {
635 content_origin: Point {
636 x: top_left.x + layout_state.content_offset.x,
637 y: top_left.y + layout_state.content_offset.y,
638 },
639 layer_translation,
640 });
641
642 let mut render_children = draw_nodes(
643 node_id,
644 modifier_slices.draw_commands(),
645 DrawPlacement::Behind,
646 layout_state.size,
647 PrimitivePhase::BeforeChildren,
648 );
649 if let Some(text) = text_node_from_parts(TextNodeParts {
650 node_id,
651 local_bounds,
652 measured_max_width: layout_state
653 .measurement_constraints
654 .max_width
655 .is_finite()
656 .then_some(layout_state.measurement_constraints.max_width),
657 resolved_modifiers: &resolved_modifiers,
658 annotated_text: modifier_slices.annotated_text(),
659 text_style: modifier_slices.text_style(),
660 text_layout_options: modifier_slices.text_layout_options(),
661 text_pan: modifier_slices.text_pan_resolver(),
662 modifier_slices: Some(modifier_slices.as_ref()),
663 }) {
664 render_children.push(RenderNode::Primitive(PrimitiveEntry {
665 phase: PrimitivePhase::BeforeChildren,
666 node: PrimitiveNode::Text(Box::new(text)),
667 }));
668 }
669 let child_motion_context_animated = node_motion_context_animated;
670 for child_id in children {
671 let Some(mut child_layer) = build_layer_node_from_applier_internal(
672 applier,
673 child_id,
674 child_motion_context_animated,
675 child_translated_content_context,
676 child_abs,
677 ) else {
678 continue;
679 };
680 if layout_state.content_offset != Point::default() {
681 child_layer.transform_to_parent =
682 child_layer
683 .transform_to_parent
684 .then(ProjectiveTransform::translation(
685 layout_state.content_offset.x,
686 layout_state.content_offset.y,
687 ));
688 }
689 render_children.push(RenderNode::Layer(Box::new(child_layer)));
690 }
691 render_children.extend(draw_nodes(
692 node_id,
693 modifier_slices.draw_commands(),
694 DrawPlacement::Overlay,
695 layout_state.size,
696 PrimitivePhase::AfterChildren,
697 ));
698 let has_hit_targets = hit_test.is_some()
699 || render_children.iter().any(|child| match child {
700 RenderNode::Layer(child_layer) => child_layer.has_hit_targets,
701 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
702 });
703
704 let layer = LayerNode {
705 node_id: Some(node_id),
706 local_bounds,
707 transform_to_parent,
708 content_offset: layout_state.content_offset,
709 motion_context_animated: node_motion_context_animated,
710 translated_content_context: local_translated_content_context,
711 translated_content_offset: if local_translated_content_context {
712 local_translated_content_offset
713 } else {
714 Point::default()
715 },
716 scene_children_origin: child_abs.map(|c| c.content_origin).unwrap_or_default(),
720 scene_children_layer_translation: child_abs
721 .map(|c| c.layer_translation)
722 .unwrap_or_default(),
723 graphics_layer,
724 clip_to_bounds,
725 shadow_clip,
726 hit_test,
727 has_hit_targets,
728 isolation,
729 cache_policy,
730 cache_hashes: LayerRasterCacheHashes::default(),
731 cache_hashes_valid: false,
732 children: render_children,
733 };
734 Some(layer)
735}
736
737struct RecorderSlot {
746 generation: u64,
747 handles: [Option<Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>>; 2],
748 recordings: [Option<Rc<cranpose_ui_graphics::CommandRecording>>; 2],
757 replay: cranpose_ui_graphics::CommandReplayState,
761 replay_epoch: Option<u64>,
764}
765
766thread_local! {
767 static COMMAND_RECORDINGS: std::cell::RefCell<
768 std::collections::HashMap<DrawCommandId, RecorderSlot, cranpose_ui_graphics::FxBuildHasher>,
769 > = std::cell::RefCell::new(std::collections::HashMap::default());
770 static RECORDING_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
771 static RETAINED_FEED_EPOCH: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
772}
773
774pub fn set_retained_feed_epoch(epoch: Option<u64>) {
783 RETAINED_FEED_EPOCH.with(|cell| cell.set(epoch));
784}
785
786thread_local! {
787 static CONFIRMED_RETAINED_SLOTS: std::cell::RefCell<
788 std::collections::HashMap<(DrawCommandId, u32), u64, cranpose_ui_graphics::FxBuildHasher>,
789 > = std::cell::RefCell::new(std::collections::HashMap::default());
790}
791
792pub fn confirm_retained_slot(command: DrawCommandId, slot: u32, generation: u64) {
801 CONFIRMED_RETAINED_SLOTS.with(|map| {
802 map.borrow_mut().insert((command, slot), generation);
803 });
804}
805
806pub fn revoke_retained_slot(command: DrawCommandId, slot: u32) {
809 CONFIRMED_RETAINED_SLOTS.with(|map| {
810 map.borrow_mut().remove(&(command, slot));
811 });
812}
813
814pub fn clear_retained_slot_confirmations() {
816 CONFIRMED_RETAINED_SLOTS.with(|map| map.borrow_mut().clear());
817}
818
819pub fn retained_slot_confirmed(command: DrawCommandId, slot: u32) -> bool {
825 let Some(epoch) = RETAINED_FEED_EPOCH.with(std::cell::Cell::get) else {
826 return false;
827 };
828 CONFIRMED_RETAINED_SLOTS.with(|map| map.borrow().get(&(command, slot)) == Some(&epoch))
829}
830
831thread_local! {
832 static VERIFY_EXECUTOR: std::cell::Cell<
833 Option<&'static dyn cranpose_ui_graphics::VerifyExecutor>,
834 > = const { std::cell::Cell::new(None) };
835}
836
837pub fn set_verify_executor(pool: Option<&'static dyn cranpose_ui_graphics::VerifyExecutor>) {
842 VERIFY_EXECUTOR.with(|cell| cell.set(pool));
843}
844
845pub fn verify_executor() -> Option<&'static dyn cranpose_ui_graphics::VerifyExecutor> {
847 VERIFY_EXECUTOR.with(|cell| cell.get())
848}
849
850#[doc(hidden)]
855pub fn clear_command_recordings_for_tests() {
856 COMMAND_RECORDINGS.with(|map| map.borrow_mut().clear());
857}
858
859fn bump_recording_generation() {
869 let generation = RECORDING_GENERATION.with(|cell| {
870 let next = cell.get().wrapping_add(1);
871 cell.set(next);
872 next
873 });
874 if generation.is_multiple_of(512) {
875 COMMAND_RECORDINGS.with(|map| {
876 map.borrow_mut()
877 .retain(|_, slot| generation.wrapping_sub(slot.generation) <= 64);
878 });
879 }
880}
881
882fn acquire_recording(
883 id: DrawCommandId,
884) -> (
885 cranpose_ui_graphics::CommandRecording,
886 Vec<cranpose_ui_graphics::DrawPrimitive>,
887 Option<cranpose_ui_graphics::CommandReplayState>,
888) {
889 let feed_epoch = RETAINED_FEED_EPOCH.with(std::cell::Cell::get);
890 COMMAND_RECORDINGS.with(|map| {
891 let mut map = map.borrow_mut();
892 let Some(slot) = map.get_mut(&id) else {
893 return (
894 cranpose_ui_graphics::CommandRecording::default(),
895 Vec::new(),
896 feed_epoch.map(|_| cranpose_ui_graphics::CommandReplayState::default()),
897 );
898 };
899 let mut recording = cranpose_ui_graphics::CommandRecording::default();
904 for shared in &mut slot.recordings {
905 if shared
906 .as_ref()
907 .is_some_and(|shared| Rc::strong_count(shared) == 1)
908 {
909 let shared = shared.take().expect("checked some above");
910 recording = Rc::try_unwrap(shared).expect("sole owner checked above");
911 break;
912 }
913 }
914 let replay = feed_epoch.map(|epoch| {
918 if slot.replay_epoch == Some(epoch) {
919 std::mem::take(&mut slot.replay)
920 } else {
921 cranpose_ui_graphics::CommandReplayState::default()
922 }
923 });
924 for handle in &mut slot.handles {
925 if handle
926 .as_ref()
927 .is_some_and(|shared| Rc::strong_count(shared) == 1)
928 {
929 let shared = handle.take().expect("checked some above");
930 let storage = Rc::try_unwrap(shared).expect("sole owner checked above");
931 return (recording, storage, replay);
932 }
933 }
934 (recording, Vec::new(), replay)
935 })
936}
937
938fn publish_recording(
945 id: DrawCommandId,
946 recording: cranpose_ui_graphics::CommandRecording,
947 primitives: Vec<cranpose_ui_graphics::DrawPrimitive>,
948 replay: Option<cranpose_ui_graphics::CommandReplayState>,
949) -> (
950 Rc<Vec<cranpose_ui_graphics::DrawPrimitive>>,
951 Rc<cranpose_ui_graphics::CommandRecording>,
952) {
953 let shared = Rc::new(primitives);
954 let recording = Rc::new(recording);
955 COMMAND_RECORDINGS.with(|map| {
956 let mut map = map.borrow_mut();
957 let generation = RECORDING_GENERATION.with(std::cell::Cell::get);
958 let slot = map.entry(id).or_insert_with(|| RecorderSlot {
959 generation,
960 handles: [None, None],
961 recordings: [None, None],
962 replay: cranpose_ui_graphics::CommandReplayState::default(),
963 replay_epoch: None,
964 });
965 slot.generation = generation;
966 if let Some(replay) = replay {
967 slot.replay = replay;
968 slot.replay_epoch = RETAINED_FEED_EPOCH.with(std::cell::Cell::get);
969 }
970 slot.recordings[1] = slot.recordings[0].take();
973 slot.recordings[0] = Some(recording.clone());
974 slot.handles[1] = slot.handles[0].take();
975 slot.handles[0] = Some(shared.clone());
976 });
977 (shared, recording)
978}
979
980fn draw_nodes(
981 node_id: NodeId,
982 commands: &[DrawCommand],
983 placement: DrawPlacement,
984 size: Size,
985 phase: PrimitivePhase,
986) -> Vec<RenderNode> {
987 let mut nodes = Vec::new();
988 for (command_index, command) in commands.iter().enumerate() {
989 let id = DrawCommandId {
990 node_id,
991 command_index: command_index as u32,
992 placement,
993 };
994 let (recording, storage, mut replay) = acquire_recording(id);
995 let mut replay_ref = replay.as_mut();
996 let (primitives, recording, frame) = primitives_for_placement_verified(
997 command,
998 placement,
999 size,
1000 recording,
1001 storage,
1002 &mut replay_ref,
1003 Some(id),
1004 );
1005 let has_replay_spans = frame.as_ref().is_some_and(|frame| !frame.spans.is_empty());
1008 if primitives.is_empty() && primitives.capacity() == 0 && !has_replay_spans {
1014 continue;
1015 }
1016 let (shared, published_recording) = publish_recording(id, recording, primitives, replay);
1017 if shared.is_empty() && !has_replay_spans {
1018 continue;
1019 }
1020 let frame = frame.map(|mut frame| {
1025 frame.fallback = Some(published_recording);
1026 frame
1027 });
1028 nodes.push(RenderNode::DrawRun(DrawRunNode::for_command_replayed(
1032 phase,
1033 Some(id),
1034 shared,
1035 frame.map(Box::new),
1036 )));
1037 }
1038 nodes
1039}
1040
1041struct TextNodeParts<'a> {
1042 node_id: NodeId,
1043 local_bounds: Rect,
1044 measured_max_width: Option<f32>,
1045 resolved_modifiers: &'a ResolvedModifiers,
1046 annotated_text: Option<&'a AnnotatedString>,
1047 text_style: Option<&'a TextStyle>,
1048 text_layout_options: Option<TextLayoutOptions>,
1049 text_pan: Option<TextPanResolver>,
1050 modifier_slices: Option<&'a ModifierNodeSlices>,
1051}
1052
1053fn text_node_from_parts(parts: TextNodeParts<'_>) -> Option<TextPrimitiveNode> {
1054 let TextNodeParts {
1055 node_id,
1056 local_bounds,
1057 measured_max_width,
1058 resolved_modifiers,
1059 annotated_text,
1060 text_style,
1061 text_layout_options,
1062 text_pan,
1063 modifier_slices,
1064 } = parts;
1065 let value = annotated_text?;
1066 let default_text_style = TextStyle::default();
1067 let text_style = text_style.cloned().unwrap_or(default_text_style);
1068 let options = text_layout_options.unwrap_or_default().normalized();
1069 let padding = resolved_modifiers.padding();
1070 let content_width = (local_bounds.width - padding.left - padding.right).max(0.0);
1071 if content_width <= 0.0 {
1072 return None;
1073 }
1074
1075 let pan_offset = text_pan
1079 .as_ref()
1080 .map(|resolve| resolve(content_width))
1081 .unwrap_or(0.0);
1082 let pans_horizontally = text_pan.is_some();
1083
1084 let max_width = if pans_horizontally {
1085 None
1086 } else {
1087 let measure_width =
1088 resolve_text_measure_width(content_width, padding, measured_max_width, options);
1089 Some(measure_width).filter(|width| width.is_finite() && *width > 0.0)
1090 };
1091 let prepared = modifier_slices
1092 .and_then(|slices| slices.prepare_text_layout(max_width))
1093 .unwrap_or_else(|| prepare_text_layout(value, &text_style, options, max_width));
1094 let visual_style = prepared.visual_style.clone();
1095 let measured_draw_width = prepared.metrics.width.max(0.0);
1096 let draw_width = if options.overflow == TextOverflow::Visible || pans_horizontally {
1097 measured_draw_width
1098 } else {
1099 measured_draw_width.min(content_width)
1100 };
1101 let alignment_offset = resolve_text_horizontal_offset(
1102 &text_style,
1103 prepared.text.text.as_str(),
1104 content_width,
1105 prepared.metrics.width,
1106 );
1107 let rect = Rect {
1108 x: padding.left + alignment_offset - pan_offset,
1109 y: padding.top,
1110 width: draw_width,
1111 height: prepared.metrics.height,
1112 };
1113 let text_bounds = Rect {
1114 x: padding.left,
1115 y: padding.top,
1116 width: content_width,
1117 height: (local_bounds.height - padding.top - padding.bottom).max(0.0),
1118 };
1119 let font_size = visual_style.resolve_font_size(14.0);
1120 let expanded_bounds =
1121 expand_text_bounds_for_baseline_shift(text_bounds, &visual_style, font_size);
1122 let clip = if options.overflow == TextOverflow::Visible && !pans_horizontally {
1123 None
1124 } else {
1125 Some(pad_clip_rect(expanded_bounds))
1126 };
1127
1128 Some(TextPrimitiveNode {
1129 node_id,
1130 rect,
1131 text: std::rc::Rc::new(prepared.text),
1132 text_style: visual_style,
1133 font_size,
1134 layout_options: options,
1135 clip,
1136 })
1137}
1138
1139fn layout_box_to_snapshot(node: &LayoutBox, parent: Option<&LayoutBox>) -> BuildNodeSnapshot {
1140 let placement = parent
1141 .map(|parent_box| Point {
1142 x: node.rect.x - parent_box.rect.x - parent_box.content_offset.x,
1143 y: node.rect.y - parent_box.rect.y - parent_box.content_offset.y,
1144 })
1145 .unwrap_or_default();
1146 let mut children = Vec::with_capacity(node.children.len());
1147 for child in &node.children {
1148 children.push(layout_box_to_snapshot(child, Some(node)));
1149 }
1150 let base_graphics_layer = node.node_data.modifier_slices.graphics_layer();
1151 let graphics_layer = graphics_layer_with_shaped_clip(
1152 base_graphics_layer.clone().unwrap_or_default(),
1153 node.node_data.modifier_slices.clip_to_bounds(),
1154 node.node_data.modifier_slices.corner_shape(),
1155 Rect {
1156 x: 0.0,
1157 y: 0.0,
1158 width: node.rect.width,
1159 height: node.rect.height,
1160 },
1161 );
1162 let has_graphics_layer =
1163 base_graphics_layer.is_some() || graphics_layer.render_effect.is_some();
1164
1165 BuildNodeSnapshot {
1166 node_id: node.node_id,
1167 placement,
1168 size: Size {
1169 width: node.rect.width,
1170 height: node.rect.height,
1171 },
1172 content_offset: node.content_offset,
1173 motion_context_animated: node.node_data.modifier_slices.motion_context_animated(),
1174 translated_content_context: node.node_data.modifier_slices.translated_content_context(),
1175 measured_max_width: None,
1176 resolved_modifiers: node.node_data.resolved_modifiers,
1177 draw_commands: node.node_data.modifier_slices.draw_commands().to_vec(),
1178 click_actions: node.node_data.modifier_slices.click_handlers().to_vec(),
1179 pointer_inputs: node.node_data.modifier_slices.pointer_inputs().to_vec(),
1180 clip_to_bounds: node.node_data.modifier_slices.clip_to_bounds(),
1181 annotated_text: node.node_data.modifier_slices.annotated_string(),
1182 text_style: node.node_data.modifier_slices.text_style().cloned(),
1183 text_layout_options: node.node_data.modifier_slices.text_layout_options(),
1184 text_pan: node.node_data.modifier_slices.text_pan_resolver(),
1185 graphics_layer: has_graphics_layer.then_some(graphics_layer),
1186 children,
1187 }
1188}
1189
1190fn graphics_layer_with_shaped_clip(
1191 mut graphics_layer: GraphicsLayer,
1192 clip_to_bounds: bool,
1193 corner_shape: Option<RoundedCornerShape>,
1194 local_bounds: Rect,
1195) -> GraphicsLayer {
1196 if !clip_to_bounds {
1197 return graphics_layer;
1198 }
1199
1200 let Some(corner_shape) = corner_shape else {
1201 return graphics_layer;
1202 };
1203 let radii = corner_shape.resolve(local_bounds.width, local_bounds.height);
1204 if radii.top_left <= f32::EPSILON
1205 && radii.top_right <= f32::EPSILON
1206 && radii.bottom_right <= f32::EPSILON
1207 && radii.bottom_left <= f32::EPSILON
1208 {
1209 return graphics_layer;
1210 }
1211
1212 if let Some(existing) = graphics_layer.render_effect.take() {
1213 let rounded_clip = rounded_corner_alpha_mask_effect(
1214 local_bounds.width,
1215 local_bounds.height,
1216 radii,
1217 ROUNDED_CLIP_EDGE_FEATHER,
1218 );
1219 graphics_layer.render_effect = Some(existing.then(rounded_clip));
1220 } else {
1221 graphics_layer.shape = LayerShape::Rounded(corner_shape);
1222 graphics_layer.clip = true;
1223 }
1224 graphics_layer
1225}
1226
1227fn isolation_reasons(layer: &GraphicsLayer) -> IsolationReasons {
1228 IsolationReasons {
1229 explicit_offscreen: layer.compositing_strategy == CompositingStrategy::Offscreen,
1230 shape_clip: layer.clip && !matches!(layer.shape, LayerShape::Rectangle),
1231 effect: layer.render_effect.is_some(),
1232 backdrop: layer.backdrop_effect.is_some(),
1233 group_opacity: layer.compositing_strategy != CompositingStrategy::ModulateAlpha
1234 && layer.alpha < 1.0,
1235 blend_mode: layer.blend_mode != cranpose_ui::BlendMode::SrcOver,
1236 }
1237}
1238
1239fn pad_clip_rect(rect: Rect) -> Rect {
1240 Rect {
1241 x: rect.x - TEXT_CLIP_PAD,
1242 y: rect.y - TEXT_CLIP_PAD,
1243 width: (rect.width + TEXT_CLIP_PAD * 2.0).max(0.0),
1244 height: (rect.height + TEXT_CLIP_PAD * 2.0).max(0.0),
1245 }
1246}
1247
1248fn expand_text_bounds_for_baseline_shift(
1249 text_bounds: Rect,
1250 text_style: &TextStyle,
1251 font_size: f32,
1252) -> Rect {
1253 let baseline_shift_px = text_style
1254 .span_style
1255 .baseline_shift
1256 .filter(|shift| shift.is_specified())
1257 .map(|shift| -(shift.0 * font_size))
1258 .unwrap_or(0.0);
1259 if baseline_shift_px == 0.0 {
1260 return text_bounds;
1261 }
1262
1263 if baseline_shift_px < 0.0 {
1264 Rect {
1265 x: text_bounds.x,
1266 y: text_bounds.y + baseline_shift_px,
1267 width: text_bounds.width,
1268 height: (text_bounds.height - baseline_shift_px).max(0.0),
1269 }
1270 } else {
1271 Rect {
1272 x: text_bounds.x,
1273 y: text_bounds.y,
1274 width: text_bounds.width,
1275 height: (text_bounds.height + baseline_shift_px).max(0.0),
1276 }
1277 }
1278}
1279
1280pub fn resolve_text_measure_width(
1307 content_width: f32,
1308 padding: cranpose_ui::EdgeInsets,
1309 measured_max_width: Option<f32>,
1310 options: TextLayoutOptions,
1311) -> f32 {
1312 let width = content_width.max(0.0);
1313 if let Some(max_width) = measured_max_width.filter(|w| w.is_finite() && *w > 0.0) {
1314 let measured_content_width = (max_width - padding.left - padding.right).max(0.0);
1315 if measured_content_width <= width {
1316 return measured_content_width;
1317 }
1318
1319 let may_expand_to_avoid_synthetic_wrap = options.soft_wrap
1320 && options.max_lines == usize::MAX
1321 && options.overflow == TextOverflow::Clip;
1322 if may_expand_to_avoid_synthetic_wrap {
1323 return measured_content_width;
1324 }
1325 }
1326 width
1327}
1328
1329fn resolve_text_horizontal_offset(
1330 text_style: &TextStyle,
1331 text: &str,
1332 content_width: f32,
1333 measured_width: f32,
1334) -> f32 {
1335 let remaining = (content_width - measured_width).max(0.0);
1336 let paragraph_style = &text_style.paragraph_style;
1337 let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
1338 match paragraph_style.text_align {
1339 TextAlign::Center => remaining * 0.5,
1340 TextAlign::End | TextAlign::Right => remaining,
1341 TextAlign::Start | TextAlign::Left | TextAlign::Justify => {
1342 if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
1343 remaining
1344 } else {
1345 0.0
1346 }
1347 }
1348 TextAlign::Unspecified => {
1349 if direction == cranpose_ui::text::ResolvedTextDirection::Rtl {
1350 remaining
1351 } else {
1352 0.0
1353 }
1354 }
1355 }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use std::cell::RefCell;
1361 use std::rc::Rc;
1362
1363 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1364 use cranpose_ui::text::{
1365 AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
1366 };
1367 use cranpose_ui::{
1368 Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
1369 LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
1370 ScrollState, Size, Spacer, Text, TextStyle,
1371 };
1372 use cranpose_ui_graphics::{
1373 Brush, DrawPrimitive, DrawScope as _, DrawScopeDefault, GraphicsLayer, RenderEffect,
1374 };
1375
1376 use super::*;
1377
1378 fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
1379 for child in &layer.children {
1380 match child {
1381 RenderNode::Primitive(primitive) => {
1382 let PrimitiveNode::Text(text) = &primitive.node else {
1383 continue;
1384 };
1385 if text.text.text == label {
1386 return Some(text.text_style.paragraph_style.text_motion);
1387 }
1388 }
1389 RenderNode::Layer(child_layer) => {
1390 if let Some(motion) = find_text_motion(child_layer, label) {
1391 return Some(motion);
1392 }
1393 }
1394 RenderNode::DrawRun(_) => {}
1395 }
1396 }
1397
1398 None
1399 }
1400
1401 fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
1402 for child in &layer.children {
1403 match child {
1404 RenderNode::Primitive(primitive) => {
1405 let PrimitiveNode::Text(text) = &primitive.node else {
1406 continue;
1407 };
1408 labels.push(text.text.text.clone());
1409 }
1410 RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
1411 RenderNode::DrawRun(_) => {}
1412 }
1413 }
1414 }
1415
1416 fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
1417 fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
1418 for child in &layer.children {
1419 match child {
1420 RenderNode::Primitive(primitive) => {
1421 let PrimitiveNode::Text(text) = &primitive.node else {
1422 continue;
1423 };
1424 if text.text.text == label {
1425 let quad = transform.map_rect(text.rect);
1426 let top = quad
1427 .iter()
1428 .map(|point| point[1])
1429 .fold(f32::INFINITY, f32::min);
1430 return top.is_finite().then_some(top);
1431 }
1432 }
1433 RenderNode::Layer(child_layer) => {
1434 let child_transform = child_layer.transform_to_parent.then(transform);
1435 if let Some(top) = search(child_layer, label, child_transform) {
1436 return Some(top);
1437 }
1438 }
1439 RenderNode::DrawRun(_) => {}
1440 }
1441 }
1442 None
1443 }
1444
1445 search(layer, label, ProjectiveTransform::identity())
1446 }
1447
1448 fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
1449 if layer.node_id == Some(node_id) {
1450 return Some(layer);
1451 }
1452 layer.children.iter().find_map(|child| match child {
1453 RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
1454 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1455 })
1456 }
1457
1458 fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
1459 fn search(
1460 layer: &LayerNode,
1461 node_id: NodeId,
1462 transform: ProjectiveTransform,
1463 ) -> Option<Point> {
1464 if layer.node_id == Some(node_id) {
1465 return Some(transform.map_point(Point::default()));
1466 }
1467 layer.children.iter().find_map(|child| match child {
1468 RenderNode::Layer(child_layer) => search(
1469 child_layer,
1470 node_id,
1471 child_layer.transform_to_parent.then(transform),
1472 ),
1473 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1474 })
1475 }
1476
1477 search(layer, node_id, ProjectiveTransform::identity())
1478 }
1479
1480 fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1481 if layer.translated_content_context {
1482 return Some(layer.translated_content_offset);
1483 }
1484 for child in &layer.children {
1485 if let RenderNode::Layer(child_layer) = child {
1486 if let Some(offset) = find_translated_content_offset(child_layer) {
1487 return Some(offset);
1488 }
1489 }
1490 }
1491 None
1492 }
1493
1494 fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1495 layer
1496 .graphics_layer
1497 .render_effect
1498 .as_ref()
1499 .is_some_and(RenderEffect::contains_runtime_shader)
1500 || layer.children.iter().any(|child| match child {
1501 RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1502 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1503 })
1504 }
1505
1506 fn build_layer_node_for_test(
1507 snapshot: BuildNodeSnapshot,
1508 scale: f32,
1509 has_external_backdrop_input: bool,
1510 ) -> LayerNode {
1511 let app_context = cranpose_ui::AppContext::new();
1512 app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1513 }
1514
1515 fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1516 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1517 scope.push_recorded(vec![DrawPrimitive::Rect {
1518 rect: Rect {
1519 x: 3.0,
1520 y: 4.0,
1521 width: 20.0,
1522 height: 8.0,
1523 },
1524 brush: Brush::solid(Color::WHITE),
1525 stroke: None,
1526 }]);
1527 }));
1528
1529 let child = BuildNodeSnapshot {
1530 node_id: 2,
1531 placement: Point { x: 11.0, y: 7.0 },
1532 size: Size {
1533 width: 40.0,
1534 height: 20.0,
1535 },
1536 content_offset: Point::default(),
1537 motion_context_animated: false,
1538 translated_content_context: false,
1539 measured_max_width: None,
1540 resolved_modifiers: ResolvedModifiers::default(),
1541 draw_commands: vec![child_command],
1542 click_actions: vec![],
1543 pointer_inputs: vec![],
1544 clip_to_bounds: false,
1545 annotated_text: None,
1546 text_style: None,
1547 text_layout_options: None,
1548 text_pan: None,
1549 graphics_layer: None,
1550 children: vec![],
1551 };
1552
1553 BuildNodeSnapshot {
1554 node_id: 1,
1555 placement: Point::default(),
1556 size: Size {
1557 width: 80.0,
1558 height: 50.0,
1559 },
1560 content_offset: Point::default(),
1561 motion_context_animated: false,
1562 translated_content_context: false,
1563 measured_max_width: None,
1564 resolved_modifiers: ResolvedModifiers::default(),
1565 draw_commands: vec![],
1566 click_actions: vec![],
1567 pointer_inputs: vec![],
1568 clip_to_bounds: false,
1569 annotated_text: None,
1570 text_style: None,
1571 text_layout_options: None,
1572 text_pan: None,
1573 graphics_layer: Some(GraphicsLayer {
1574 translation_x: tx,
1575 ..GraphicsLayer::default()
1576 }),
1577 children: vec![child],
1578 }
1579 }
1580
1581 #[test]
1582 fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1583 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1584 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1585
1586 let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1587 panic!("expected child layer");
1588 };
1589 let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1590 panic!("expected child layer");
1591 };
1592 let RenderNode::DrawRun(static_run) = &static_child.children[0] else {
1593 panic!("expected draw run");
1594 };
1595 let static_draw = &static_run.primitives[0];
1596 let RenderNode::DrawRun(moved_run) = &moved_child.children[0] else {
1597 panic!("expected draw run");
1598 };
1599 let moved_draw = &moved_run.primitives[0];
1600
1601 assert_ne!(
1602 static_graph.transform_to_parent, moved_graph.transform_to_parent,
1603 "parent transform should encode translation"
1604 );
1605 assert_eq!(
1606 static_draw, moved_draw,
1607 "child local primitive geometry must stay stable under parent translation"
1608 );
1609 }
1610
1611 #[test]
1612 fn stored_content_hash_ignores_parent_translation() {
1613 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1614 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1615
1616 assert_eq!(
1617 static_graph.target_content_hash(),
1618 moved_graph.target_content_hash(),
1619 "parent rigid motion must not invalidate the subtree content hash"
1620 );
1621 }
1622
1623 #[test]
1624 fn parent_content_offset_is_encoded_in_child_transform() {
1625 let child = BuildNodeSnapshot {
1626 node_id: 2,
1627 placement: Point { x: 11.0, y: 7.0 },
1628 size: Size {
1629 width: 40.0,
1630 height: 20.0,
1631 },
1632 content_offset: Point::default(),
1633 motion_context_animated: false,
1634 translated_content_context: false,
1635 measured_max_width: None,
1636 resolved_modifiers: ResolvedModifiers::default(),
1637 draw_commands: vec![],
1638 click_actions: vec![],
1639 pointer_inputs: vec![],
1640 clip_to_bounds: false,
1641 annotated_text: None,
1642 text_style: None,
1643 text_layout_options: None,
1644 text_pan: None,
1645 graphics_layer: None,
1646 children: vec![],
1647 };
1648
1649 let parent = BuildNodeSnapshot {
1650 node_id: 1,
1651 placement: Point::default(),
1652 size: Size {
1653 width: 80.0,
1654 height: 50.0,
1655 },
1656 content_offset: Point { x: 13.0, y: -9.0 },
1657 motion_context_animated: false,
1658 translated_content_context: false,
1659 measured_max_width: None,
1660 resolved_modifiers: ResolvedModifiers::default(),
1661 draw_commands: vec![],
1662 click_actions: vec![],
1663 pointer_inputs: vec![],
1664 clip_to_bounds: false,
1665 annotated_text: None,
1666 text_style: None,
1667 text_layout_options: None,
1668 text_pan: None,
1669 graphics_layer: None,
1670 children: vec![child],
1671 };
1672
1673 let graph = build_layer_node_for_test(parent, 1.0, false);
1674 let RenderNode::Layer(child) = &graph.children[0] else {
1675 panic!("expected child layer");
1676 };
1677
1678 let top_left = child.transform_to_parent.map_point(Point::default());
1679 assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1680 }
1681
1682 #[test]
1683 fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1684 fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1685 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1686 scope.push_recorded(vec![DrawPrimitive::Rect {
1687 rect: Rect {
1688 x: 3.0,
1689 y: 4.0,
1690 width: 20.0,
1691 height: 8.0,
1692 },
1693 brush: Brush::solid(Color::WHITE),
1694 stroke: None,
1695 }]);
1696 }));
1697
1698 let child = BuildNodeSnapshot {
1699 node_id: 2,
1700 placement: Point { x: 11.0, y: 7.0 },
1701 size: Size {
1702 width: 40.0,
1703 height: 20.0,
1704 },
1705 content_offset: Point::default(),
1706 motion_context_animated: false,
1707 translated_content_context: false,
1708 measured_max_width: None,
1709 resolved_modifiers: ResolvedModifiers::default(),
1710 draw_commands: vec![child_command],
1711 click_actions: vec![],
1712 pointer_inputs: vec![],
1713 clip_to_bounds: false,
1714 annotated_text: None,
1715 text_style: None,
1716 text_layout_options: None,
1717 text_pan: None,
1718 graphics_layer: None,
1719 children: vec![],
1720 };
1721
1722 BuildNodeSnapshot {
1723 node_id: 1,
1724 placement: Point::default(),
1725 size: Size {
1726 width: 80.0,
1727 height: 50.0,
1728 },
1729 content_offset: offset,
1730 motion_context_animated,
1731 translated_content_context: true,
1732 measured_max_width: None,
1733 resolved_modifiers: ResolvedModifiers::default(),
1734 draw_commands: vec![],
1735 click_actions: vec![],
1736 pointer_inputs: vec![],
1737 clip_to_bounds: false,
1738 annotated_text: None,
1739 text_style: None,
1740 text_layout_options: None,
1741 text_pan: None,
1742 graphics_layer: None,
1743 children: vec![child],
1744 }
1745 }
1746
1747 let base = build_layer_node_for_test(
1748 parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
1749 1.0,
1750 false,
1751 );
1752 let moved = build_layer_node_for_test(
1753 parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
1754 1.0,
1755 false,
1756 );
1757 let rested = build_layer_node_for_test(
1758 parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
1759 1.0,
1760 false,
1761 );
1762
1763 let RenderNode::Layer(base_child) = &base.children[0] else {
1764 panic!("expected child layer");
1765 };
1766 let RenderNode::Layer(moved_child) = &moved.children[0] else {
1767 panic!("expected child layer");
1768 };
1769
1770 assert_ne!(
1771 base_child.transform_to_parent.map_point(Point::default()),
1772 moved_child.transform_to_parent.map_point(Point::default()),
1773 "scroll offset still has to move child content visually"
1774 );
1775 assert_eq!(
1776 base_child.target_content_hash(),
1777 moved_child.target_content_hash(),
1778 "child source content identity stays stable when only the parent scroll offset changes"
1779 );
1780 assert_ne!(
1781 base.target_content_hash(),
1782 moved.target_content_hash(),
1783 "a full-surface cache of the scroll viewport must include the scroll offset"
1784 );
1785 assert_ne!(
1786 base.target_content_hash(),
1787 rested.target_content_hash(),
1788 "full-surface cache keys must include active scroll motion policy"
1789 );
1790 }
1791
1792 #[test]
1793 fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
1794 let layer = graphics_layer_with_shaped_clip(
1795 GraphicsLayer::default(),
1796 true,
1797 Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
1798 Rect {
1799 x: 0.0,
1800 y: 0.0,
1801 width: 100.0,
1802 height: 40.0,
1803 },
1804 );
1805
1806 assert!(layer.clip);
1807 assert!(layer.render_effect.is_none());
1808 let LayerShape::Rounded(shape) = layer.shape else {
1809 panic!("rounded clip must be recorded as layer shape");
1810 };
1811 assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
1812 assert!(isolation_reasons(&layer).shape_clip);
1813 }
1814
1815 #[test]
1816 fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
1817 let existing = RenderEffect::blur(3.0);
1818 let layer = graphics_layer_with_shaped_clip(
1819 GraphicsLayer {
1820 render_effect: Some(existing.clone()),
1821 ..GraphicsLayer::default()
1822 },
1823 true,
1824 Some(RoundedCornerShape::uniform(10.0)),
1825 Rect {
1826 x: 0.0,
1827 y: 0.0,
1828 width: 100.0,
1829 height: 40.0,
1830 },
1831 );
1832
1833 let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
1834 panic!("existing effect should chain into rounded clip mask");
1835 };
1836 assert_eq!(*first, existing);
1837 assert!(
1838 matches!(*second, RenderEffect::Shader { .. }),
1839 "rounded mask must be the outer effect"
1840 );
1841 }
1842
1843 #[test]
1844 fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
1845 let mut composition = cranpose_ui::run_test_composition(|| {
1846 cranpose_ui::Box(
1847 Modifier::empty()
1848 .width(100.0)
1849 .height(40.0)
1850 .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
1851 .clip_to_bounds(),
1852 cranpose_ui::BoxSpec::default(),
1853 || {
1854 Text("rounded child", Modifier::empty(), TextStyle::default());
1855 },
1856 );
1857 });
1858
1859 let root = composition.root().expect("rounded clip root");
1860 let handle = composition.runtime_handle();
1861 let mut applier = composition.applier_mut();
1862 applier.set_runtime_handle(handle);
1863 applier
1864 .compute_layout(
1865 root,
1866 Size {
1867 width: 160.0,
1868 height: 100.0,
1869 },
1870 )
1871 .expect("rounded clip layout");
1872 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1873 applier.clear_runtime_handle();
1874
1875 let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
1876 assert!(rounded_layer.graphics_layer.clip);
1877 assert!(matches!(
1878 rounded_layer.graphics_layer.shape,
1879 LayerShape::Rounded(_)
1880 ));
1881 assert!(rounded_layer.graphics_layer.render_effect.is_none());
1882 assert!(rounded_layer.isolation.shape_clip);
1883 assert!(
1884 !graph_has_runtime_shader_effect(&graph.root),
1885 "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
1886 );
1887 }
1888
1889 #[test]
1890 fn update_graph_from_applier_replaces_dirty_child_layer() {
1891 let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1892 Rc::new(RefCell::new(None));
1893 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1894 let state_holder_for_comp = state_holder.clone();
1895 let child_id_holder_for_comp = child_id_holder.clone();
1896
1897 let mut composition = cranpose_ui::run_test_composition(move || {
1898 let label = cranpose_core::useState(|| "before".to_string());
1899 *state_holder_for_comp.borrow_mut() = Some(label);
1900 let child_id_holder_for_content = child_id_holder_for_comp.clone();
1901 cranpose_ui::Box(
1902 Modifier::empty().size_points(240.0, 80.0),
1903 cranpose_ui::BoxSpec::default(),
1904 move || {
1905 let child_id = Text(label, Modifier::empty(), TextStyle::default());
1906 *child_id_holder_for_content.borrow_mut() = Some(child_id);
1907 Text("stable", Modifier::empty(), TextStyle::default());
1908 },
1909 );
1910 });
1911
1912 let root = composition.root().expect("composition root");
1913 let viewport = Size {
1914 width: 240.0,
1915 height: 80.0,
1916 };
1917 let handle = composition.runtime_handle();
1918 let mut applier = composition.applier_mut();
1919 applier.set_runtime_handle(handle);
1920 applier
1921 .compute_layout(root, viewport)
1922 .expect("initial layout");
1923 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1924 let child_id = child_id_holder
1925 .borrow()
1926 .expect("text child id should be captured");
1927 let initial_transform = find_layer_by_node_id(&graph.root, child_id)
1928 .expect("text child layer")
1929 .transform_to_parent;
1930 applier.clear_runtime_handle();
1931 drop(applier);
1932
1933 let label = state_holder
1934 .borrow()
1935 .as_ref()
1936 .copied()
1937 .expect("label state should be captured");
1938 label.set_value("after".to_string());
1939 composition
1940 .process_invalid_scopes()
1941 .expect("text recomposition");
1942
1943 let handle = composition.runtime_handle();
1944 let mut applier = composition.applier_mut();
1945 applier.set_runtime_handle(handle);
1946 applier
1947 .compute_layout(root, viewport)
1948 .expect("updated layout");
1949 let child_id = child_id_holder
1950 .borrow()
1951 .expect("text child id should remain captured");
1952
1953 assert!(
1954 update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
1955 "dirty child should be replaceable from retained applier state"
1956 );
1957 applier.clear_runtime_handle();
1958
1959 let mut labels = Vec::new();
1960 collect_text_labels(&graph.root, &mut labels);
1961 assert!(
1962 labels.iter().any(|label| label == "after"),
1963 "updated graph should contain refreshed child text, got {labels:?}"
1964 );
1965 assert!(
1966 !labels.iter().any(|label| label == "before"),
1967 "updated graph should not retain stale child text, got {labels:?}"
1968 );
1969 assert!(
1970 labels.iter().any(|label| label == "stable"),
1971 "sibling content should remain present, got {labels:?}"
1972 );
1973 assert_eq!(
1974 find_layer_by_node_id(&graph.root, child_id)
1975 .expect("updated text child layer")
1976 .transform_to_parent,
1977 initial_transform,
1978 "draw-only child replacement must preserve the retained parent placement transform"
1979 );
1980 }
1981
1982 #[test]
1990 fn scene_build_publishes_live_window_rect_without_layout_tree() {
1991 use cranpose_ui::{measure_layout_with_options, Box, BoxSpec, MeasureLayoutOptions};
1992 use std::cell::Cell;
1993
1994 let spacer_before = 120.0_f32;
1995 let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
1996 x: 0.0,
1997 y: 0.0,
1998 width: 0.0,
1999 height: 0.0,
2000 }));
2001 let sink_for_comp = sink.clone();
2002 let mut composition = cranpose_ui::run_test_composition(move || {
2003 let sink = sink_for_comp.clone();
2004 Column(
2005 Modifier::empty().size_points(200.0, 400.0),
2006 ColumnSpec::default(),
2007 move || {
2008 Spacer(Size {
2009 width: 200.0,
2010 height: spacer_before,
2011 });
2012 Box(
2013 Modifier::empty()
2014 .size_points(200.0, 50.0)
2015 .report_window_rect(sink.clone()),
2016 BoxSpec::default(),
2017 || {},
2018 );
2019 },
2020 );
2021 });
2022
2023 let root = composition.root().expect("composition root");
2024 let viewport = Size {
2025 width: 200.0,
2026 height: 400.0,
2027 };
2028 let handle = composition.runtime_handle();
2029 let mut applier = composition.applier_mut();
2030 applier.set_runtime_handle(handle);
2031 measure_layout_with_options(
2034 &mut applier,
2035 root,
2036 viewport,
2037 MeasureLayoutOptions {
2038 collect_semantics: false,
2039 build_layout_tree: false,
2040 },
2041 )
2042 .expect("layout");
2043 assert_eq!(
2045 sink.get().height,
2046 0.0,
2047 "sink must start empty (place disabled)"
2048 );
2049
2050 let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
2051 applier.clear_runtime_handle();
2052
2053 let rect = sink.get();
2054 assert!(
2055 (rect.y - spacer_before).abs() < 0.5,
2056 "scene build must publish the box's live window-y (below the {spacer_before}px \
2057 spacer), got {}",
2058 rect.y
2059 );
2060 assert!(
2061 rect.width > 0.0 && rect.height > 0.0,
2062 "scene build must publish a non-empty window rect, got {rect:?}"
2063 );
2064 }
2065
2066 #[test]
2067 fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
2068 let mut graph = RenderGraph {
2069 root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
2070 };
2071 let mut applier = MemoryApplier::new();
2072
2073 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
2074
2075 assert_eq!(
2076 report,
2077 GraphUpdateReport {
2078 applied: false,
2079 hit_graph_dirty: true,
2080 },
2081 "dirty child graph updates must not report success when the replacement cannot be rebuilt"
2082 );
2083 }
2084
2085 #[test]
2086 fn update_graph_from_applier_refreshes_scroll_content_offset() {
2087 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2088 let scroll_holder_for_comp = scroll_holder.clone();
2089
2090 let mut composition = cranpose_ui::run_test_composition(move || {
2091 let scroll_state =
2092 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2093 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2094 Column(
2095 Modifier::empty()
2096 .size_points(240.0, 120.0)
2097 .vertical_scroll(scroll_state, false),
2098 ColumnSpec::default(),
2099 || {
2100 Text("scroll top", Modifier::empty(), TextStyle::default());
2101 Spacer(Size {
2102 width: 0.0,
2103 height: 160.0,
2104 });
2105 Text("scroll target", Modifier::empty(), TextStyle::default());
2106 },
2107 );
2108 });
2109
2110 let root = composition.root().expect("composition root");
2111 let viewport = Size {
2112 width: 240.0,
2113 height: 120.0,
2114 };
2115 let handle = composition.runtime_handle();
2116 let mut applier = composition.applier_mut();
2117 applier.set_runtime_handle(handle);
2118 applier
2119 .compute_layout(root, viewport)
2120 .expect("initial scroll layout");
2121 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2122 let initial_target_top =
2123 find_text_top(&graph.root, "scroll target").expect("initial target text");
2124 applier.clear_runtime_handle();
2125 drop(applier);
2126
2127 let scroll_state = scroll_holder
2128 .borrow()
2129 .as_ref()
2130 .cloned()
2131 .expect("scroll state should be captured");
2132 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
2133 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
2134 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
2135 assert!(
2136 !dirty_nodes.is_empty(),
2137 "scroll state invalidation must schedule scoped layout graph update"
2138 );
2139
2140 let handle = composition.runtime_handle();
2141 let mut applier = composition.applier_mut();
2142 applier.set_runtime_handle(handle);
2143 applier
2144 .compute_layout(root, viewport)
2145 .expect("scrolled layout");
2146 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
2147 applier.clear_runtime_handle();
2148
2149 assert!(report.applied, "scroll graph update should apply in place");
2150 let updated_target_top =
2151 find_text_top(&graph.root, "scroll target").expect("updated target text");
2152 assert!(
2153 updated_target_top < initial_target_top - consumed_scroll * 0.75,
2154 "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
2155 );
2156 }
2157
2158 #[test]
2159 fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
2160 let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2161 Rc::new(RefCell::new(None));
2162 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2163 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2164 let label_holder_for_comp = label_holder.clone();
2165 let scroll_holder_for_comp = scroll_holder.clone();
2166 let child_id_holder_for_comp = child_id_holder.clone();
2167
2168 let mut composition = cranpose_ui::run_test_composition(move || {
2169 let label = cranpose_core::useState(|| "scrolled child before".to_string());
2170 let scroll_state =
2171 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2172 *label_holder_for_comp.borrow_mut() = Some(label);
2173 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2174 let child_id_holder_for_content = child_id_holder_for_comp.clone();
2175 Column(
2176 Modifier::empty()
2177 .size_points(260.0, 90.0)
2178 .vertical_scroll(scroll_state, false),
2179 ColumnSpec::default(),
2180 move || {
2181 Spacer(Size {
2182 width: 0.0,
2183 height: 24.0,
2184 });
2185 let child_id = Text(label, Modifier::empty(), TextStyle::default());
2186 *child_id_holder_for_content.borrow_mut() = Some(child_id);
2187 Spacer(Size {
2188 width: 0.0,
2189 height: 220.0,
2190 });
2191 },
2192 );
2193 });
2194
2195 let root = composition.root().expect("composition root");
2196 let viewport = Size {
2197 width: 260.0,
2198 height: 90.0,
2199 };
2200 let handle = composition.runtime_handle();
2201 let mut applier = composition.applier_mut();
2202 applier.set_runtime_handle(handle);
2203 applier
2204 .compute_layout(root, viewport)
2205 .expect("initial layout");
2206 applier.clear_runtime_handle();
2207 drop(applier);
2208
2209 let scroll_state = scroll_holder
2210 .borrow()
2211 .as_ref()
2212 .cloned()
2213 .expect("scroll state should be captured");
2214 assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
2215
2216 let handle = composition.runtime_handle();
2217 let mut applier = composition.applier_mut();
2218 applier.set_runtime_handle(handle);
2219 applier
2220 .compute_layout(root, viewport)
2221 .expect("scrolled layout");
2222 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2223 let child_id = child_id_holder
2224 .borrow()
2225 .expect("text child id should be captured");
2226 let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
2227 .expect("scrolled child layer")
2228 .transform_to_parent;
2229 applier.clear_runtime_handle();
2230 drop(applier);
2231
2232 let label = label_holder
2233 .borrow()
2234 .as_ref()
2235 .copied()
2236 .expect("label state should be captured");
2237 label.set_value("scrolled child after".to_string());
2238 composition
2239 .process_invalid_scopes()
2240 .expect("text recomposition");
2241
2242 let handle = composition.runtime_handle();
2243 let mut applier = composition.applier_mut();
2244 applier.set_runtime_handle(handle);
2245 applier
2246 .compute_layout(root, viewport)
2247 .expect("updated scrolled layout");
2248 let child_id = child_id_holder
2249 .borrow()
2250 .expect("text child id should remain captured");
2251 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
2252 applier.clear_runtime_handle();
2253
2254 assert!(report.applied, "dirty child graph update should apply");
2255 let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
2256 assert_eq!(
2257 updated.transform_to_parent, scrolled_transform,
2258 "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
2259 );
2260 let mut labels = Vec::new();
2261 collect_text_labels(&graph.root, &mut labels);
2262 assert!(
2263 labels.iter().any(|label| label == "scrolled child after"),
2264 "updated graph should contain refreshed text, got {labels:?}"
2265 );
2266 }
2267
2268 #[test]
2269 fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
2270 let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2271 Rc::new(RefCell::new(None));
2272 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2273 let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2274 let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2275 let alpha_holder_for_comp = alpha_holder.clone();
2276 let scroll_holder_for_comp = scroll_holder.clone();
2277 let underlay_id_holder_for_comp = underlay_id_holder.clone();
2278 let overlay_id_holder_for_comp = overlay_id_holder.clone();
2279
2280 let mut composition = cranpose_ui::run_test_composition(move || {
2281 let alpha = cranpose_core::useState(|| 1.0f32);
2282 let scroll_state =
2283 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2284 *alpha_holder_for_comp.borrow_mut() = Some(alpha);
2285 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2286 let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
2287 let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
2288 Column(
2289 Modifier::empty()
2290 .size_points(260.0, 120.0)
2291 .vertical_scroll(scroll_state, false),
2292 ColumnSpec::default(),
2293 move || {
2294 Spacer(Size {
2295 width: 0.0,
2296 height: 180.0,
2297 });
2298 cranpose_ui::Box(
2299 Modifier::empty().size_points(188.0, 88.0),
2300 cranpose_ui::BoxSpec::default(),
2301 {
2302 let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
2303 let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
2304 move || {
2305 let underlay_id = cranpose_ui::Box(
2306 Modifier::empty().size_points(188.0, 88.0),
2307 cranpose_ui::BoxSpec::default(),
2308 || {
2309 Text(
2310 "UNDERLAY CONTENT",
2311 Modifier::empty().absolute_offset(12.0, 8.0),
2312 TextStyle::default(),
2313 );
2314 },
2315 );
2316 *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
2317 let overlay_id = cranpose_ui::Box(
2318 Modifier::empty().size_points(188.0, 88.0).graphics_layer(
2319 move || GraphicsLayer {
2320 alpha: alpha.get(),
2321 ..GraphicsLayer::default()
2322 },
2323 ),
2324 cranpose_ui::BoxSpec::default(),
2325 || {
2326 Text(
2327 "TOP LAYER",
2328 Modifier::empty().absolute_offset(74.0, 39.6),
2329 TextStyle::default(),
2330 );
2331 },
2332 );
2333 *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
2334 }
2335 },
2336 );
2337 Spacer(Size {
2338 width: 0.0,
2339 height: 280.0,
2340 });
2341 },
2342 );
2343 });
2344
2345 let root = composition.root().expect("composition root");
2346 let viewport = Size {
2347 width: 260.0,
2348 height: 120.0,
2349 };
2350 let handle = composition.runtime_handle();
2351 let mut applier = composition.applier_mut();
2352 applier.set_runtime_handle(handle);
2353 applier
2354 .compute_layout(root, viewport)
2355 .expect("initial layout");
2356 applier.clear_runtime_handle();
2357 drop(applier);
2358
2359 let scroll_state = scroll_holder
2360 .borrow()
2361 .as_ref()
2362 .cloned()
2363 .expect("scroll state should be captured");
2364 assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
2365
2366 let handle = composition.runtime_handle();
2367 let mut applier = composition.applier_mut();
2368 applier.set_runtime_handle(handle);
2369 applier
2370 .compute_layout(root, viewport)
2371 .expect("scrolled layout");
2372 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2373 applier.clear_runtime_handle();
2374 drop(applier);
2375
2376 let underlay_id = underlay_id_holder
2377 .borrow()
2378 .expect("underlay id should be captured");
2379 let overlay_id = overlay_id_holder
2380 .borrow()
2381 .expect("overlay id should be captured");
2382 let scrolled_underlay_origin =
2383 find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
2384 let scrolled_overlay_origin =
2385 find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
2386 assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
2387
2388 let alpha = alpha_holder
2389 .borrow()
2390 .as_ref()
2391 .copied()
2392 .expect("alpha state should be captured");
2393 alpha.set_value(0.35);
2394
2395 let handle = composition.runtime_handle();
2396 let mut applier = composition.applier_mut();
2397 applier.set_runtime_handle(handle);
2398 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
2399 applier.clear_runtime_handle();
2400
2401 assert!(report.applied, "dirty overlay graph update should apply");
2402 let updated_underlay_origin =
2403 find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
2404 let updated_overlay_origin =
2405 find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
2406 assert_eq!(
2407 updated_underlay_origin, scrolled_underlay_origin,
2408 "stable underlay must keep its scrolled origin"
2409 );
2410 assert_eq!(
2411 updated_overlay_origin, updated_underlay_origin,
2412 "dirty overlay graphics layer must stay aligned with its stable underlay"
2413 );
2414 }
2415
2416 #[test]
2417 fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
2418 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2419 Rc::new(RefCell::new(None));
2420 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2421 let offset_holder_for_comp = offset_holder.clone();
2422 let node_id_holder_for_comp = node_id_holder.clone();
2423
2424 let mut composition = cranpose_ui::run_test_composition(move || {
2425 let offset = cranpose_core::useState(|| 0.0f32);
2426 *offset_holder_for_comp.borrow_mut() = Some(offset);
2427 let node_id = cranpose_ui::Box(
2428 Modifier::empty()
2429 .size_points(40.0, 20.0)
2430 .graphics_layer(move || GraphicsLayer {
2431 translation_x: offset.get(),
2432 ..GraphicsLayer::default()
2433 }),
2434 cranpose_ui::BoxSpec::default(),
2435 || {},
2436 );
2437 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2438 });
2439
2440 let root = composition.root().expect("composition root");
2441 let viewport = Size {
2442 width: 120.0,
2443 height: 80.0,
2444 };
2445 let handle = composition.runtime_handle();
2446 let mut applier = composition.applier_mut();
2447 applier.set_runtime_handle(handle);
2448 applier
2449 .compute_layout(root, viewport)
2450 .expect("initial layout");
2451 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2452 let node_id = node_id_holder
2453 .borrow()
2454 .expect("graphics layer node id should be captured");
2455 let initial_origin = find_layer_by_node_id(&graph.root, node_id)
2456 .expect("initial graphics layer")
2457 .transform_to_parent
2458 .map_point(Point::default());
2459 applier.clear_runtime_handle();
2460 drop(applier);
2461
2462 let offset = offset_holder
2463 .borrow()
2464 .as_ref()
2465 .copied()
2466 .expect("offset state should be captured");
2467 offset.set_value(32.0);
2468
2469 let handle = composition.runtime_handle();
2470 let mut applier = composition.applier_mut();
2471 applier.set_runtime_handle(handle);
2472 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2473 assert!(
2474 report.applied,
2475 "dirty graphics layer should be replaceable from retained applier state"
2476 );
2477 assert!(
2478 !report.hit_graph_dirty,
2479 "a moved visual-only layer should not force hit graph refresh"
2480 );
2481 applier.clear_runtime_handle();
2482
2483 let updated_origin = find_layer_by_node_id(&graph.root, node_id)
2484 .expect("updated graphics layer")
2485 .transform_to_parent
2486 .map_point(Point::default());
2487 assert!(
2488 (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
2489 "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
2495 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2496 Rc::new(RefCell::new(None));
2497 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2498 let offset_holder_for_comp = offset_holder.clone();
2499 let node_id_holder_for_comp = node_id_holder.clone();
2500
2501 let mut composition = cranpose_ui::run_test_composition(move || {
2502 let offset = cranpose_core::useState(|| 0.0f32);
2503 *offset_holder_for_comp.borrow_mut() = Some(offset);
2504 let node_id = cranpose_ui::Box(
2505 Modifier::empty()
2506 .size_points(40.0, 20.0)
2507 .graphics_layer(move || GraphicsLayer {
2508 translation_x: offset.get(),
2509 ..GraphicsLayer::default()
2510 })
2511 .clickable(|_| {}),
2512 cranpose_ui::BoxSpec::default(),
2513 || {},
2514 );
2515 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2516 });
2517
2518 let root = composition.root().expect("composition root");
2519 let viewport = Size {
2520 width: 120.0,
2521 height: 80.0,
2522 };
2523 let handle = composition.runtime_handle();
2524 let mut applier = composition.applier_mut();
2525 applier.set_runtime_handle(handle);
2526 applier
2527 .compute_layout(root, viewport)
2528 .expect("initial layout");
2529 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2530 let node_id = node_id_holder
2531 .borrow()
2532 .expect("graphics layer node id should be captured");
2533 applier.clear_runtime_handle();
2534 drop(applier);
2535
2536 let offset = offset_holder
2537 .borrow()
2538 .as_ref()
2539 .copied()
2540 .expect("offset state should be captured");
2541 offset.set_value(32.0);
2542
2543 let handle = composition.runtime_handle();
2544 let mut applier = composition.applier_mut();
2545 applier.set_runtime_handle(handle);
2546 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2547 applier.clear_runtime_handle();
2548
2549 assert!(
2550 report.applied,
2551 "dirty clickable graphics layer should be replaceable from retained applier state"
2552 );
2553 assert!(
2554 report.hit_graph_dirty,
2555 "moved clickable layers must refresh hit geometry"
2556 );
2557 }
2558
2559 #[test]
2560 fn overlay_draw_commands_are_tagged_after_children() {
2561 let child = BuildNodeSnapshot {
2562 node_id: 2,
2563 placement: Point { x: 4.0, y: 5.0 },
2564 size: Size {
2565 width: 20.0,
2566 height: 10.0,
2567 },
2568 content_offset: Point::default(),
2569 motion_context_animated: false,
2570 translated_content_context: false,
2571 measured_max_width: None,
2572 resolved_modifiers: ResolvedModifiers::default(),
2573 draw_commands: vec![],
2574 click_actions: vec![],
2575 pointer_inputs: vec![],
2576 clip_to_bounds: false,
2577 annotated_text: None,
2578 text_style: None,
2579 text_layout_options: None,
2580 text_pan: None,
2581 graphics_layer: None,
2582 children: vec![],
2583 };
2584 let behind = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
2585 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2586 rect: Rect {
2587 x: 1.0,
2588 y: 2.0,
2589 width: 8.0,
2590 height: 6.0,
2591 },
2592 brush: Brush::solid(Color::WHITE),
2593 stroke: None,
2594 }]);
2595 }));
2596 let overlay = DrawCommand::Overlay(Rc::new(|scope: &mut DrawScopeDefault| {
2597 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2598 rect: Rect {
2599 x: 3.0,
2600 y: 1.0,
2601 width: 5.0,
2602 height: 4.0,
2603 },
2604 brush: Brush::solid(Color::BLACK),
2605 stroke: None,
2606 }]);
2607 }));
2608
2609 let parent = BuildNodeSnapshot {
2610 node_id: 1,
2611 placement: Point::default(),
2612 size: Size {
2613 width: 80.0,
2614 height: 50.0,
2615 },
2616 content_offset: Point::default(),
2617 motion_context_animated: false,
2618 translated_content_context: false,
2619 measured_max_width: None,
2620 resolved_modifiers: ResolvedModifiers::default(),
2621 draw_commands: vec![behind, overlay],
2622 click_actions: vec![],
2623 pointer_inputs: vec![],
2624 clip_to_bounds: false,
2625 annotated_text: None,
2626 text_style: None,
2627 text_layout_options: None,
2628 text_pan: None,
2629 graphics_layer: None,
2630 children: vec![child],
2631 };
2632
2633 let graph = build_layer_node_for_test(parent, 1.0, false);
2634 let RenderNode::DrawRun(behind) = &graph.children[0] else {
2635 panic!("expected before-children draw run");
2636 };
2637 let RenderNode::Layer(_) = &graph.children[1] else {
2638 panic!("expected child layer");
2639 };
2640 let RenderNode::DrawRun(overlay) = &graph.children[2] else {
2641 panic!("expected after-children draw run");
2642 };
2643
2644 assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
2645 assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
2646 }
2647
2648 #[test]
2652 fn command_recordings_reuse_buffers_across_rebuilds() {
2653 let snapshot = || BuildNodeSnapshot {
2654 node_id: 7001,
2655 placement: Point::default(),
2656 size: Size {
2657 width: 40.0,
2658 height: 20.0,
2659 },
2660 content_offset: Point::default(),
2661 motion_context_animated: false,
2662 translated_content_context: false,
2663 measured_max_width: None,
2664 resolved_modifiers: ResolvedModifiers::default(),
2665 draw_commands: vec![DrawCommand::Behind(Rc::new(
2666 |scope: &mut DrawScopeDefault| {
2667 scope.draw_rect_at(
2668 Rect {
2669 x: 1.0,
2670 y: 2.0,
2671 width: 8.0,
2672 height: 6.0,
2673 },
2674 Brush::solid(Color::WHITE),
2675 );
2676 },
2677 ))],
2678 click_actions: vec![],
2679 pointer_inputs: vec![],
2680 clip_to_bounds: false,
2681 annotated_text: None,
2682 text_style: None,
2683 text_layout_options: None,
2684 text_pan: None,
2685 graphics_layer: None,
2686 children: vec![],
2687 };
2688 fn run_of(layer: &LayerNode) -> &DrawRunNode {
2689 let RenderNode::DrawRun(run) = &layer.children[0] else {
2690 panic!("expected draw run");
2691 };
2692 run
2693 }
2694
2695 let graph_a = build_layer_node_for_test(snapshot(), 1.0, false);
2696 let ptr_a = run_of(&graph_a).primitives.as_ptr();
2697
2698 let graph_b = build_layer_node_for_test(snapshot(), 1.0, false);
2700 let ptr_b = run_of(&graph_b).primitives.as_ptr();
2701 assert_ne!(
2702 ptr_a, ptr_b,
2703 "a buffer a live graph shares must never be recorded into"
2704 );
2705 assert_eq!(
2706 run_of(&graph_a).primitives,
2707 run_of(&graph_b).primitives,
2708 "re-recording must reproduce the recording"
2709 );
2710
2711 drop(graph_a);
2715 let graph_c = build_layer_node_for_test(snapshot(), 1.0, false);
2716 assert_eq!(
2717 run_of(&graph_c).primitives.as_ptr(),
2718 ptr_a,
2719 "the released buffer must be reused for the next recording"
2720 );
2721
2722 let held = std::rc::Rc::clone(&run_of(&graph_c).primitives);
2725 drop(graph_c);
2726 let graph_d = build_layer_node_for_test(snapshot(), 1.0, false);
2727 let ptr_d = run_of(&graph_d).primitives.as_ptr();
2728 assert_ne!(ptr_d, held.as_ptr());
2729 assert_ne!(ptr_d, run_of(&graph_b).primitives.as_ptr());
2730 }
2731
2732 #[test]
2733 fn stored_content_hash_changes_when_child_transform_changes() {
2734 let child = BuildNodeSnapshot {
2735 node_id: 2,
2736 placement: Point { x: 4.0, y: 5.0 },
2737 size: Size {
2738 width: 20.0,
2739 height: 10.0,
2740 },
2741 content_offset: Point::default(),
2742 motion_context_animated: false,
2743 translated_content_context: false,
2744 measured_max_width: None,
2745 resolved_modifiers: ResolvedModifiers::default(),
2746 draw_commands: vec![],
2747 click_actions: vec![],
2748 pointer_inputs: vec![],
2749 clip_to_bounds: false,
2750 annotated_text: None,
2751 text_style: None,
2752 text_layout_options: None,
2753 text_pan: None,
2754 graphics_layer: None,
2755 children: vec![],
2756 };
2757 let mut moved_child = child.clone();
2758 moved_child.placement.x += 7.0;
2759
2760 let parent = BuildNodeSnapshot {
2761 node_id: 1,
2762 placement: Point::default(),
2763 size: Size {
2764 width: 80.0,
2765 height: 50.0,
2766 },
2767 content_offset: Point::default(),
2768 motion_context_animated: false,
2769 translated_content_context: false,
2770 measured_max_width: None,
2771 resolved_modifiers: ResolvedModifiers::default(),
2772 draw_commands: vec![],
2773 click_actions: vec![],
2774 pointer_inputs: vec![],
2775 clip_to_bounds: false,
2776 annotated_text: None,
2777 text_style: None,
2778 text_layout_options: None,
2779 text_pan: None,
2780 graphics_layer: None,
2781 children: vec![child],
2782 };
2783 let moved_parent = BuildNodeSnapshot {
2784 children: vec![moved_child],
2785 ..parent.clone()
2786 };
2787
2788 let static_graph = build_layer_node_for_test(parent, 1.0, false);
2789 let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
2790
2791 assert_ne!(
2792 static_graph.target_content_hash(),
2793 moved_graph.target_content_hash(),
2794 "moving a child within the parent must invalidate the parent subtree hash"
2795 );
2796 }
2797
2798 #[test]
2799 fn stored_effect_hash_tracks_local_effect_only() {
2800 let base = BuildNodeSnapshot {
2801 node_id: 1,
2802 placement: Point::default(),
2803 size: Size {
2804 width: 80.0,
2805 height: 50.0,
2806 },
2807 content_offset: Point::default(),
2808 motion_context_animated: false,
2809 translated_content_context: false,
2810 measured_max_width: None,
2811 resolved_modifiers: ResolvedModifiers::default(),
2812 draw_commands: vec![],
2813 click_actions: vec![],
2814 pointer_inputs: vec![],
2815 clip_to_bounds: false,
2816 annotated_text: None,
2817 text_style: None,
2818 text_layout_options: None,
2819 text_pan: None,
2820 graphics_layer: None,
2821 children: vec![],
2822 };
2823 let mut effected = base.clone();
2824 effected.graphics_layer = Some(GraphicsLayer {
2825 render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
2826 ..GraphicsLayer::default()
2827 });
2828
2829 let base_graph = build_layer_node_for_test(base, 1.0, false);
2830 let effected_graph = build_layer_node_for_test(effected, 1.0, false);
2831
2832 assert_eq!(
2833 base_graph.target_content_hash(),
2834 effected_graph.target_content_hash(),
2835 "post-processing effect parameters belong to the effect hash, not the content hash"
2836 );
2837 assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
2838 }
2839
2840 #[test]
2841 fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
2842 let mut text_style = TextStyle::default();
2843 text_style.paragraph_style.text_align = TextAlign::Start;
2844 text_style.paragraph_style.text_direction = TextDirection::Rtl;
2845 text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
2846
2847 let snapshot = BuildNodeSnapshot {
2848 node_id: 1,
2849 placement: Point::default(),
2850 size: Size {
2851 width: 180.0,
2852 height: 48.0,
2853 },
2854 content_offset: Point::default(),
2855 motion_context_animated: false,
2856 translated_content_context: false,
2857 measured_max_width: Some(180.0),
2858 resolved_modifiers: ResolvedModifiers::default(),
2859 draw_commands: vec![],
2860 click_actions: vec![],
2861 pointer_inputs: vec![],
2862 clip_to_bounds: false,
2863 annotated_text: Some(AnnotatedString::from("rtl")),
2864 text_style: Some(text_style),
2865 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2866 overflow: cranpose_ui::TextOverflow::Clip,
2867 ..Default::default()
2868 }),
2869 text_pan: None,
2870 graphics_layer: None,
2871 children: vec![],
2872 };
2873
2874 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2875 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2876 panic!("expected text primitive");
2877 };
2878 let PrimitiveNode::Text(text) = &text_primitive.node else {
2879 panic!("expected text primitive");
2880 };
2881 let clip = text
2882 .clip
2883 .expect("clipped overflow should produce a clip rect");
2884
2885 assert!(
2886 text.rect.x > 0.0,
2887 "RTL start alignment should shift the text rect within the available width"
2888 );
2889 assert!(
2890 clip.y < text.rect.y,
2891 "baseline shift must expand the clip upward so superscript glyphs are preserved"
2892 );
2893 assert!(
2894 clip.intersect(text.rect).is_some(),
2895 "the clip rect must intersect the shifted text draw rect"
2896 );
2897 }
2898
2899 #[test]
2900 fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
2901 let snapshot = BuildNodeSnapshot {
2902 node_id: 1,
2903 placement: Point::default(),
2904 size: Size {
2905 width: 320.0,
2906 height: 48.0,
2907 },
2908 content_offset: Point::default(),
2909 motion_context_animated: false,
2910 translated_content_context: false,
2911 measured_max_width: Some(320.0),
2912 resolved_modifiers: ResolvedModifiers::default(),
2913 draw_commands: vec![],
2914 click_actions: vec![],
2915 pointer_inputs: vec![],
2916 clip_to_bounds: false,
2917 annotated_text: Some(AnnotatedString::from("short")),
2918 text_style: Some(TextStyle::default()),
2919 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2920 overflow: cranpose_ui::TextOverflow::Clip,
2921 ..Default::default()
2922 }),
2923 text_pan: None,
2924 graphics_layer: None,
2925 children: vec![],
2926 };
2927
2928 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2929 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2930 panic!("expected text primitive");
2931 };
2932 let PrimitiveNode::Text(text) = &text_primitive.node else {
2933 panic!("expected text primitive");
2934 };
2935 let clip = text.clip.expect("clipped text should keep a clip rect");
2936
2937 assert!(
2938 text.rect.width < 320.0,
2939 "text raster bounds should track measured glyph width instead of full content width"
2940 );
2941 assert_eq!(
2942 clip.width, 322.0,
2943 "text clip should still preserve the full content box plus clip padding"
2944 );
2945 }
2946
2947 #[test]
2951 fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
2952 let pan_offset = 25.0_f32;
2953 let field_width = 80.0_f32;
2954 let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
2955 let viewports = resolved_viewports.clone();
2956 let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
2957 node_id: 1,
2958 placement: Point::default(),
2959 size: Size {
2960 width: field_width,
2961 height: 24.0,
2962 },
2963 content_offset: Point::default(),
2964 motion_context_animated: false,
2965 translated_content_context: false,
2966 measured_max_width: Some(field_width),
2967 resolved_modifiers: ResolvedModifiers::default(),
2968 draw_commands: vec![],
2969 click_actions: vec![],
2970 pointer_inputs: vec![],
2971 clip_to_bounds: false,
2972 annotated_text: Some(AnnotatedString::from(
2973 "a very long single line of text that cannot fit",
2974 )),
2975 text_style: Some(TextStyle::default()),
2976 text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
2977 text_pan,
2978 graphics_layer: None,
2979 children: vec![],
2980 };
2981
2982 let text_node = |snapshot: BuildNodeSnapshot| {
2983 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2984 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2985 panic!("expected text primitive");
2986 };
2987 let PrimitiveNode::Text(text) = &text_primitive.node else {
2988 panic!("expected text primitive");
2989 };
2990 (**text).clone()
2991 };
2992
2993 let unpanned = text_node(make_snapshot(None));
2994 let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
2995 viewports.borrow_mut().push(viewport);
2996 pan_offset
2997 }))));
2998
2999 assert_eq!(
3000 resolved_viewports.borrow().as_slice(),
3001 &[field_width],
3002 "the pan resolver must receive the content viewport width"
3003 );
3004 assert_eq!(
3005 panned.rect.x, -pan_offset,
3006 "text glyphs must shift left by the pan offset"
3007 );
3008 assert!(
3009 panned.rect.width > field_width,
3010 "panned single-line text must be laid out unconstrained, got {}",
3011 panned.rect.width
3012 );
3013 assert!(
3014 panned.rect.width >= unpanned.rect.width,
3015 "unconstrained layout must not be narrower than wrapped layout"
3016 );
3017 assert!(
3018 panned.rect.height <= unpanned.rect.height,
3019 "single-line layout must not wrap onto extra lines"
3020 );
3021 let clip = panned
3022 .clip
3023 .expect("panned text field must clip to field bounds");
3024 assert!(
3025 clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
3026 "clip must not extend past the field bounds, got {clip:?}"
3027 );
3028 }
3029
3030 #[test]
3031 fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
3032 let child = BuildNodeSnapshot {
3033 node_id: 2,
3034 placement: Point { x: 11.0, y: 7.0 },
3035 size: Size {
3036 width: 120.0,
3037 height: 32.0,
3038 },
3039 content_offset: Point::default(),
3040 motion_context_animated: false,
3041 translated_content_context: false,
3042 measured_max_width: Some(120.0),
3043 resolved_modifiers: ResolvedModifiers::default(),
3044 draw_commands: vec![],
3045 click_actions: vec![],
3046 pointer_inputs: vec![],
3047 clip_to_bounds: false,
3048 annotated_text: Some(AnnotatedString::from("scrolling")),
3049 text_style: Some(TextStyle::default()),
3050 text_layout_options: None,
3051 text_pan: None,
3052 graphics_layer: None,
3053 children: vec![],
3054 };
3055 let parent = BuildNodeSnapshot {
3056 node_id: 1,
3057 placement: Point::default(),
3058 size: Size {
3059 width: 160.0,
3060 height: 64.0,
3061 },
3062 content_offset: Point { x: 0.0, y: -18.5 },
3063 motion_context_animated: false,
3064 translated_content_context: true,
3065 measured_max_width: None,
3066 resolved_modifiers: ResolvedModifiers::default(),
3067 draw_commands: vec![],
3068 click_actions: vec![],
3069 pointer_inputs: vec![],
3070 clip_to_bounds: false,
3071 annotated_text: None,
3072 text_style: None,
3073 text_layout_options: None,
3074 text_pan: None,
3075 graphics_layer: None,
3076 children: vec![child],
3077 };
3078
3079 let graph = build_layer_node_for_test(parent, 1.0, false);
3080 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3081 panic!("expected child layer");
3082 };
3083 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3084 panic!("expected text primitive");
3085 };
3086 let PrimitiveNode::Text(text) = &text_primitive.node else {
3087 panic!("expected text primitive");
3088 };
3089
3090 assert_eq!(text.text_style.paragraph_style.text_motion, None);
3091 assert!(!child_layer.motion_context_animated);
3092 }
3093
3094 #[test]
3095 fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
3096 let child = BuildNodeSnapshot {
3097 node_id: 2,
3098 placement: Point { x: 11.0, y: 7.0 },
3099 size: Size {
3100 width: 120.0,
3101 height: 32.0,
3102 },
3103 content_offset: Point::default(),
3104 motion_context_animated: false,
3105 translated_content_context: false,
3106 measured_max_width: Some(120.0),
3107 resolved_modifiers: ResolvedModifiers::default(),
3108 draw_commands: vec![],
3109 click_actions: vec![],
3110 pointer_inputs: vec![],
3111 clip_to_bounds: false,
3112 annotated_text: Some(AnnotatedString::from("scrolling")),
3113 text_style: Some(TextStyle::default()),
3114 text_layout_options: None,
3115 text_pan: None,
3116 graphics_layer: None,
3117 children: vec![],
3118 };
3119 let parent = BuildNodeSnapshot {
3120 node_id: 1,
3121 placement: Point::default(),
3122 size: Size {
3123 width: 160.0,
3124 height: 64.0,
3125 },
3126 content_offset: Point { x: 0.0, y: -18.0 },
3127 motion_context_animated: false,
3128 translated_content_context: false,
3129 measured_max_width: None,
3130 resolved_modifiers: ResolvedModifiers::default(),
3131 draw_commands: vec![],
3132 click_actions: vec![],
3133 pointer_inputs: vec![],
3134 clip_to_bounds: false,
3135 annotated_text: None,
3136 text_style: None,
3137 text_layout_options: None,
3138 text_pan: None,
3139 graphics_layer: None,
3140 children: vec![child],
3141 };
3142
3143 let graph = build_layer_node_for_test(parent, 1.0, false);
3144 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3145 panic!("expected child layer");
3146 };
3147 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3148 panic!("expected text primitive");
3149 };
3150 let PrimitiveNode::Text(text) = &text_primitive.node else {
3151 panic!("expected text primitive");
3152 };
3153
3154 assert_eq!(
3155 text.text_style.paragraph_style.text_motion, None,
3156 "content_offset alone must not force text onto the translated-content motion path"
3157 );
3158 assert!(!child_layer.motion_context_animated);
3159 }
3160
3161 #[test]
3162 fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
3163 let child = BuildNodeSnapshot {
3164 node_id: 2,
3165 placement: Point { x: 11.0, y: 7.0 },
3166 size: Size {
3167 width: 120.0,
3168 height: 32.0,
3169 },
3170 content_offset: Point::default(),
3171 motion_context_animated: false,
3172 translated_content_context: false,
3173 measured_max_width: Some(120.0),
3174 resolved_modifiers: ResolvedModifiers::default(),
3175 draw_commands: vec![],
3176 click_actions: vec![],
3177 pointer_inputs: vec![],
3178 clip_to_bounds: false,
3179 annotated_text: Some(AnnotatedString::from("shadow")),
3180 text_style: Some(TextStyle::from_span_style(SpanStyle {
3181 shadow: Some(cranpose_ui::text::Shadow {
3182 color: Color::BLACK,
3183 offset: Point::new(1.0, 2.0),
3184 blur_radius: 3.0,
3185 }),
3186 ..SpanStyle::default()
3187 })),
3188 text_layout_options: None,
3189 text_pan: None,
3190 graphics_layer: None,
3191 children: vec![],
3192 };
3193 let parent = BuildNodeSnapshot {
3194 node_id: 1,
3195 placement: Point::default(),
3196 size: Size {
3197 width: 160.0,
3198 height: 64.0,
3199 },
3200 content_offset: Point { x: 0.0, y: -18.5 },
3201 motion_context_animated: false,
3202 translated_content_context: true,
3203 measured_max_width: None,
3204 resolved_modifiers: ResolvedModifiers::default(),
3205 draw_commands: vec![],
3206 click_actions: vec![],
3207 pointer_inputs: vec![],
3208 clip_to_bounds: false,
3209 annotated_text: None,
3210 text_style: None,
3211 text_layout_options: None,
3212 text_pan: None,
3213 graphics_layer: None,
3214 children: vec![child],
3215 };
3216
3217 let graph = build_layer_node_for_test(parent, 1.0, false);
3218 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3219 panic!("expected child layer");
3220 };
3221 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3222 panic!("expected text primitive");
3223 };
3224 let PrimitiveNode::Text(text) = &text_primitive.node else {
3225 panic!("expected text primitive");
3226 };
3227
3228 assert_eq!(text.text_style.paragraph_style.text_motion, None);
3229 }
3230
3231 #[test]
3232 fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
3233 let child = BuildNodeSnapshot {
3234 node_id: 2,
3235 placement: Point { x: 11.0, y: 7.0 },
3236 size: Size {
3237 width: 120.0,
3238 height: 32.0,
3239 },
3240 content_offset: Point::default(),
3241 motion_context_animated: false,
3242 translated_content_context: false,
3243 measured_max_width: Some(120.0),
3244 resolved_modifiers: ResolvedModifiers::default(),
3245 draw_commands: vec![],
3246 click_actions: vec![],
3247 pointer_inputs: vec![],
3248 clip_to_bounds: false,
3249 annotated_text: Some(AnnotatedString::from("lazy")),
3250 text_style: Some(TextStyle::default()),
3251 text_layout_options: None,
3252 text_pan: None,
3253 graphics_layer: None,
3254 children: vec![],
3255 };
3256 let parent = BuildNodeSnapshot {
3257 node_id: 1,
3258 placement: Point::default(),
3259 size: Size {
3260 width: 160.0,
3261 height: 64.0,
3262 },
3263 content_offset: Point::default(),
3264 motion_context_animated: true,
3265 translated_content_context: false,
3266 measured_max_width: None,
3267 resolved_modifiers: ResolvedModifiers::default(),
3268 draw_commands: vec![],
3269 click_actions: vec![],
3270 pointer_inputs: vec![],
3271 clip_to_bounds: false,
3272 annotated_text: None,
3273 text_style: None,
3274 text_layout_options: None,
3275 text_pan: None,
3276 graphics_layer: None,
3277 children: vec![child],
3278 };
3279
3280 let graph = build_layer_node_for_test(parent, 1.0, false);
3281 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3282 panic!("expected child layer");
3283 };
3284 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3285 panic!("expected text primitive");
3286 };
3287 let PrimitiveNode::Text(text) = &text_primitive.node else {
3288 panic!("expected text primitive");
3289 };
3290
3291 assert_eq!(text.text_style.paragraph_style.text_motion, None);
3292 assert!(graph.motion_context_animated);
3293 assert!(child_layer.motion_context_animated);
3294 }
3295
3296 #[test]
3297 fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
3298 let mut composition = cranpose_ui::run_test_composition(|| {
3299 let list_state = remember_lazy_list_state();
3300 LazyColumn(
3301 Modifier::empty(),
3302 list_state,
3303 LazyColumnSpec::default(),
3304 |scope| {
3305 scope.item(Some(0), None, || {
3306 Text("LazyMotion", Modifier::empty(), TextStyle::default());
3307 });
3308 },
3309 );
3310 });
3311
3312 let root = composition.root().expect("lazy column root");
3313 let handle = composition.runtime_handle();
3314 let mut applier = composition.applier_mut();
3315 applier.set_runtime_handle(handle);
3316 let _ = applier
3317 .compute_layout(
3318 root,
3319 Size {
3320 width: 240.0,
3321 height: 240.0,
3322 },
3323 )
3324 .expect("lazy column layout");
3325 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3326 applier.clear_runtime_handle();
3327
3328 assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
3329 }
3330
3331 #[test]
3332 fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
3333 use std::cell::RefCell;
3334 use std::rc::Rc;
3335
3336 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3337 let state_holder_for_comp = state_holder.clone();
3338 let mut composition = cranpose_ui::run_test_composition(move || {
3339 let list_state = remember_lazy_list_state();
3340 *state_holder_for_comp.borrow_mut() = Some(list_state);
3341 LazyColumn(
3342 Modifier::empty().height(120.0),
3343 list_state,
3344 LazyColumnSpec::default(),
3345 |scope| {
3346 scope.items(
3347 8,
3348 None::<fn(usize) -> u64>,
3349 None::<fn(usize) -> u64>,
3350 |index| {
3351 Text(
3352 format!("LazyMotion {index}"),
3353 Modifier::empty().padding(4.0),
3354 TextStyle::default(),
3355 );
3356 },
3357 );
3358 },
3359 );
3360 });
3361
3362 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3363 list_state.scroll_to_item(3, 0.0);
3364
3365 let root = composition.root().expect("lazy column root");
3366 let handle = composition.runtime_handle();
3367 let mut applier = composition.applier_mut();
3368 applier.set_runtime_handle(handle);
3369 let _ = applier
3370 .compute_layout(
3371 root,
3372 Size {
3373 width: 240.0,
3374 height: 240.0,
3375 },
3376 )
3377 .expect("lazy column layout");
3378 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3379 let active_children = applier
3380 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3381 .expect("lazy column should be subcompose");
3382 let child_debug: Vec<String> = active_children
3383 .iter()
3384 .map(|&child_id| {
3385 if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
3386 format!(
3387 "layout#{child_id} placed={} text={:?} children={:?}",
3388 node.layout_state().is_placed,
3389 node.modifier_slices_snapshot()
3390 .text_content()
3391 .map(str::to_string),
3392 node.children.clone()
3393 )
3394 }) {
3395 summary
3396 } else if let Ok(summary) =
3397 applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
3398 format!(
3399 "subcompose#{child_id} placed={} active_children={:?}",
3400 node.layout_state().is_placed,
3401 node.active_children()
3402 )
3403 })
3404 {
3405 summary
3406 } else {
3407 format!("missing#{child_id}")
3408 }
3409 })
3410 .collect();
3411 applier.clear_runtime_handle();
3412
3413 let first_index = list_state.first_visible_item_index();
3414 assert!(
3415 first_index > 0,
3416 "lazy list should move away from origin before graph building, observed first_index={first_index}"
3417 );
3418 let mut labels = Vec::new();
3419 collect_text_labels(&graph.root, &mut labels);
3420 assert_eq!(
3421 find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
3422 Some(None),
3423 "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
3424 labels,
3425 active_children,
3426 child_debug
3427 );
3428 }
3429
3430 #[test]
3431 fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
3432 use std::cell::RefCell;
3433 use std::rc::Rc;
3434
3435 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3436 let state_holder_for_comp = state_holder.clone();
3437 let mut composition = cranpose_ui::run_test_composition(move || {
3438 let list_state = remember_lazy_list_state();
3439 *state_holder_for_comp.borrow_mut() = Some(list_state);
3440 let mut spec =
3441 LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
3442 spec.beyond_bounds_item_count = 0;
3443 LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
3444 scope.items(
3445 12,
3446 None::<fn(usize) -> u64>,
3447 None::<fn(usize) -> u64>,
3448 |index| {
3449 Text(
3450 format!("WarmRow {index}"),
3451 Modifier::empty().height(32.0),
3452 TextStyle::default(),
3453 );
3454 },
3455 );
3456 });
3457 });
3458
3459 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3460 list_state.scroll_to_item(4, 0.0);
3461
3462 let root = composition.root().expect("lazy column root");
3463 let handle = composition.runtime_handle();
3464 let mut applier = composition.applier_mut();
3465 applier.set_runtime_handle(handle);
3466 let _ = applier
3467 .compute_layout(
3468 root,
3469 Size {
3470 width: 240.0,
3471 height: 240.0,
3472 },
3473 )
3474 .expect("lazy column layout");
3475 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3476 let active_children = applier
3477 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3478 .expect("lazy column should be subcompose");
3479 applier.clear_runtime_handle();
3480
3481 let visible_indices: Vec<_> = list_state
3482 .layout_info()
3483 .visible_items_info
3484 .iter()
3485 .map(|item| item.index)
3486 .collect();
3487 let mut labels = Vec::new();
3488 collect_text_labels(&graph.root, &mut labels);
3489
3490 assert_eq!(
3491 visible_indices,
3492 vec![4, 5, 6],
3493 "test setup expects exactly three viewport-visible rows"
3494 );
3495 assert!(
3496 labels.iter().any(|label| label == "WarmRow 7"),
3497 "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
3498 );
3499 }
3500
3501 #[test]
3502 fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
3503 use std::cell::RefCell;
3504 use std::rc::Rc;
3505
3506 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3507 let state_holder_for_comp = state_holder.clone();
3508 let mut composition = cranpose_ui::run_test_composition(move || {
3509 let list_state = remember_lazy_list_state();
3510 *state_holder_for_comp.borrow_mut() = Some(list_state);
3511 LazyColumn(
3512 Modifier::empty().height(120.0),
3513 list_state,
3514 LazyColumnSpec::default(),
3515 |scope| {
3516 scope.items(
3517 8,
3518 None::<fn(usize) -> u64>,
3519 None::<fn(usize) -> u64>,
3520 |index| {
3521 Text(
3522 format!("LazySnap {index}"),
3523 Modifier::empty().padding(4.0),
3524 TextStyle::default(),
3525 );
3526 },
3527 );
3528 },
3529 );
3530 });
3531
3532 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3533 list_state.scroll_to_item(2, 7.5);
3534
3535 let root = composition.root().expect("lazy column root");
3536 let handle = composition.runtime_handle();
3537 let mut applier = composition.applier_mut();
3538 applier.set_runtime_handle(handle);
3539 let _ = applier
3540 .compute_layout(
3541 root,
3542 Size {
3543 width: 240.0,
3544 height: 240.0,
3545 },
3546 )
3547 .expect("lazy column layout");
3548 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3549 applier.clear_runtime_handle();
3550
3551 let layout_info = list_state.layout_info();
3552 let first_visible_offset = layout_info
3553 .visible_items_info
3554 .first()
3555 .expect("lazy layout should expose visible item info")
3556 .offset;
3557 let snap_offset = find_translated_content_offset(&graph.root)
3558 .expect("lazy list graph should include translated content context");
3559
3560 assert!(
3561 (snap_offset.y - first_visible_offset).abs() <= 0.001,
3562 "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
3563 );
3564 }
3565
3566 #[test]
3567 fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
3568 let child = BuildNodeSnapshot {
3569 node_id: 2,
3570 placement: Point { x: 11.0, y: 7.0 },
3571 size: Size {
3572 width: 120.0,
3573 height: 32.0,
3574 },
3575 content_offset: Point::default(),
3576 motion_context_animated: false,
3577 translated_content_context: false,
3578 measured_max_width: Some(120.0),
3579 resolved_modifiers: ResolvedModifiers::default(),
3580 draw_commands: vec![],
3581 click_actions: vec![],
3582 pointer_inputs: vec![],
3583 clip_to_bounds: false,
3584 annotated_text: Some(AnnotatedString::from("static")),
3585 text_style: Some(TextStyle::from_paragraph_style(
3586 cranpose_ui::text::ParagraphStyle {
3587 text_motion: Some(TextMotion::Static),
3588 ..Default::default()
3589 },
3590 )),
3591 text_layout_options: None,
3592 text_pan: None,
3593 graphics_layer: None,
3594 children: vec![],
3595 };
3596 let parent = BuildNodeSnapshot {
3597 node_id: 1,
3598 placement: Point::default(),
3599 size: Size {
3600 width: 160.0,
3601 height: 64.0,
3602 },
3603 content_offset: Point { x: 0.0, y: -18.5 },
3604 motion_context_animated: false,
3605 translated_content_context: true,
3606 measured_max_width: None,
3607 resolved_modifiers: ResolvedModifiers::default(),
3608 draw_commands: vec![],
3609 click_actions: vec![],
3610 pointer_inputs: vec![],
3611 clip_to_bounds: false,
3612 annotated_text: None,
3613 text_style: None,
3614 text_layout_options: None,
3615 text_pan: None,
3616 graphics_layer: None,
3617 children: vec![child],
3618 };
3619
3620 let graph = build_layer_node_for_test(parent, 1.0, false);
3621 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3622 panic!("expected child layer");
3623 };
3624 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3625 panic!("expected text primitive");
3626 };
3627 let PrimitiveNode::Text(text) = &text_primitive.node else {
3628 panic!("expected text primitive");
3629 };
3630
3631 assert_eq!(
3632 text.text_style.paragraph_style.text_motion,
3633 Some(TextMotion::Static),
3634 "explicit text motion must win over inherited scrolling motion context"
3635 );
3636 }
3637
3638 #[test]
3661 fn wrapped_paragraph_paints_the_height_it_measured() {
3662 const BODY: &str = "fed back картица scored fp32 износ once paper fed Vision dropped \
3663 fed widest the strip mask prompt mask threshold Vision on датум instance mask \
3664 износ Apple";
3665 const FOLLOWING: &str = "FOLLOWING SIBLING";
3666
3667 let app_context = cranpose_ui::AppContext::new();
3668 app_context.enter(|| {
3669 cranpose_ui::text::set_text_measurer(
3670 crate::software_text_raster::SoftwareTextMeasurer::from_fonts_or_default(&[], 8192),
3671 );
3672 let mut composition = cranpose_ui::run_test_composition(move || {
3673 Column(
3674 Modifier::empty().fill_max_width(),
3675 ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
3676 move || {
3677 Text(BODY.to_string(), Modifier::empty(), TextStyle::default());
3678 Text(
3679 FOLLOWING.to_string(),
3680 Modifier::empty(),
3681 TextStyle::default(),
3682 );
3683 },
3684 );
3685 });
3686
3687 let root = composition.root().expect("composition root");
3688 let handle = composition.runtime_handle();
3689 let mut applier = composition.applier_mut();
3690 applier.set_runtime_handle(handle);
3691 let layout = applier
3692 .compute_layout(
3693 root,
3694 Size {
3695 width: 245.0,
3696 height: 900.0,
3697 },
3698 )
3699 .expect("layout");
3700
3701 fn find_box<'a>(node: &'a LayoutBox, value: &str) -> Option<&'a LayoutBox> {
3702 if node
3703 .node_data
3704 .modifier_slices()
3705 .text_content()
3706 .is_some_and(|text| text == value)
3707 {
3708 return Some(node);
3709 }
3710 node.children
3711 .iter()
3712 .find_map(|child| find_box(child, value))
3713 }
3714 let body_box = find_box(layout.root(), BODY).expect("measured paragraph box");
3715 let following_box = find_box(layout.root(), FOLLOWING).expect("measured sibling box");
3716 let measured_height = body_box.rect.height;
3717 let following_top = following_box.rect.y;
3718 assert!(
3719 measured_height > 60.0,
3720 "test setup expects a genuinely multi-line paragraph, got {measured_height}"
3721 );
3722 assert!(
3723 body_box.rect.width < 245.0,
3724 "test setup expects the node to be placed at its own measured width, \
3725 not the full constraint, got {}",
3726 body_box.rect.width
3727 );
3728
3729 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("render graph");
3730 applier.clear_runtime_handle();
3731
3732 fn squashed(value: &str) -> String {
3735 value.chars().filter(|c| !c.is_whitespace()).collect()
3736 }
3737 fn find_text<'a>(layer: &'a LayerNode, value: &str) -> Option<&'a TextPrimitiveNode> {
3738 for child in &layer.children {
3739 match child {
3740 RenderNode::Primitive(primitive) => {
3741 if let PrimitiveNode::Text(text) = &primitive.node {
3742 if squashed(&text.text.text) == squashed(value) {
3743 return Some(text);
3744 }
3745 }
3746 }
3747 RenderNode::Layer(child_layer) => {
3748 if let Some(found) = find_text(child_layer, value) {
3749 return Some(found);
3750 }
3751 }
3752 RenderNode::DrawRun(_) => {}
3753 }
3754 }
3755 None
3756 }
3757 let painted = find_text(&graph.root, BODY).expect("painted paragraph");
3758
3759 assert!(
3760 (painted.rect.height - measured_height).abs() < 0.5,
3761 "paragraph painted {:.2} tall into a box layout measured at {:.2} \
3762 (painted rect {:?})",
3763 painted.rect.height,
3764 measured_height,
3765 painted.rect
3766 );
3767 assert!(
3768 painted.rect.y + painted.rect.height <= following_top + 0.5,
3769 "painted paragraph bottom {:.2} runs past the following sibling placed at \
3770 {:.2}",
3771 painted.rect.y + painted.rect.height,
3772 following_top
3773 );
3774 });
3775 }
3776
3777 #[test]
3778 fn retained_slot_confirmations_are_live_only_under_their_generation() {
3779 let command = DrawCommandId {
3780 node_id: 990_101,
3781 command_index: 0,
3782 placement: DrawPlacement::Behind,
3783 };
3784 set_retained_feed_epoch(Some(7));
3785 confirm_retained_slot(command, 3, 7);
3786 assert!(retained_slot_confirmed(command, 3));
3787 set_retained_feed_epoch(Some(8));
3789 assert!(!retained_slot_confirmed(command, 3));
3790 set_retained_feed_epoch(None);
3792 assert!(!retained_slot_confirmed(command, 3));
3793 set_retained_feed_epoch(Some(7));
3795 assert!(retained_slot_confirmed(command, 3));
3796 revoke_retained_slot(command, 3);
3797 assert!(!retained_slot_confirmed(command, 3));
3798 set_retained_feed_epoch(None);
3799 clear_retained_slot_confirmations();
3800 }
3801
3802 fn record_sweep_test_rings(scope: &mut DrawScopeDefault) {
3808 let count = 600usize;
3809 let sweep = std::f32::consts::TAU / count as f32 * 0.8;
3810 for i in 0..count {
3811 let start = i as f32 * (std::f32::consts::TAU / count as f32);
3812 scope.draw_annular_sector(
3813 Brush::solid(cranpose_ui_graphics::Color(0.2, 0.4, 0.6, 1.0)),
3814 cranpose_ui_graphics::Point::new(204.0, 204.0),
3815 140.0,
3816 150.0,
3817 start,
3818 sweep,
3819 );
3820 }
3821 }
3822
3823 #[test]
3831 fn recording_sweep_cannot_sever_a_frames_fallback() {
3832 let command = DrawCommandId {
3833 node_id: 990_102,
3834 command_index: 0,
3835 placement: DrawPlacement::Behind,
3836 };
3837 set_retained_feed_epoch(Some(41));
3838 for slot in 0..64 {
3839 confirm_retained_slot(command, slot, 41);
3840 }
3841
3842 let mut state = cranpose_ui_graphics::CommandReplayState::default();
3847 let mut published = None;
3848 for _frame in 0..4 {
3849 let (recording, storage, _) = acquire_recording(command);
3850 let mut scope = DrawScopeDefault::with_recording(
3851 cranpose_ui_graphics::Size::new(408.0, 408.0),
3852 None,
3853 recording,
3854 storage,
3855 );
3856 record_sweep_test_rings(&mut scope);
3857 let outcome = state.advance(scope.recorded());
3858 let center = state.center();
3859 let (finished, frame) = scope.finish_replay(center, outcome, &mut |slot| {
3860 retained_slot_confirmed(command, slot)
3861 });
3862 let (primitives, fallback) =
3863 publish_recording(command, finished.recording, finished.primitives, None);
3864 let frame = frame.map(|mut frame| {
3865 frame.fallback = Some(fallback.clone());
3866 frame
3867 });
3868 published = Some((primitives, fallback, frame));
3869 }
3870 let (_primitives, fallback, frame) = published.expect("four frames published");
3871 let frame = frame.expect("the replay must produce a frame with retained spans");
3872 let bypassed: Vec<(u32, u32)> = frame
3873 .spans
3874 .iter()
3875 .filter_map(|span| match span {
3876 cranpose_ui_graphics::FrameSpan::Retained {
3877 capture: false,
3878 range,
3879 tape_range,
3880 ..
3881 } if range.1 <= range.0 => Some(*tape_range),
3882 _ => None,
3883 })
3884 .collect();
3885 assert!(
3886 !bypassed.is_empty(),
3887 "confirmed slots must actually have bypassed materialization"
3888 );
3889 let expected: Vec<Vec<DrawPrimitive>> = bypassed
3890 .iter()
3891 .map(|tape_range| {
3892 fallback
3893 .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
3894 .expect("a frame-consistent tape range must materialize")
3895 })
3896 .collect();
3897
3898 for _ in 0..1024 {
3903 bump_recording_generation();
3904 }
3905 assert!(
3906 COMMAND_RECORDINGS.with(|map| !map.borrow().contains_key(&command)),
3907 "the sweep must stay pure capacity management: a live confirmation \
3908 no longer pins the registry slot"
3909 );
3910
3911 for (tape_range, expected) in bypassed.iter().zip(&expected) {
3914 let after = fallback
3915 .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
3916 .expect("the frame-owned recording must outlive the sweep");
3917 assert_eq!(
3918 &after, expected,
3919 "post-sweep rematerialization must be byte-identical"
3920 );
3921 }
3922 set_retained_feed_epoch(None);
3923 clear_retained_slot_confirmations();
3924 }
3925
3926 #[test]
3934 fn command_recordings_reuse_recording_buffers_across_rebuilds() {
3935 let command = DrawCommandId {
3936 node_id: 990_103,
3937 command_index: 0,
3938 placement: DrawPlacement::Behind,
3939 };
3940 let mut held = None;
3943 let mut ptrs = Vec::new();
3944 for _build in 0..8 {
3945 let (recording, storage, _) = acquire_recording(command);
3946 let mut scope = DrawScopeDefault::with_recording(
3947 cranpose_ui_graphics::Size::new(64.0, 64.0),
3948 None,
3949 recording,
3950 storage,
3951 );
3952 scope.draw_rect_at(
3953 Rect {
3954 x: 4.0,
3955 y: 4.0,
3956 width: 16.0,
3957 height: 8.0,
3958 },
3959 Brush::solid(Color::WHITE),
3960 );
3961 let finished = scope.finish();
3962 let (primitives, recording) =
3963 publish_recording(command, finished.recording, finished.primitives, None);
3964 ptrs.push(recording.tape_ptr());
3965 held = Some((primitives, recording));
3966 }
3967 drop(held);
3968 for build in 2..8 {
3972 assert_eq!(
3973 ptrs[build],
3974 ptrs[build - 2],
3975 "steady-state publishes must ping-pong between the pair's \
3976 buffers (build {build} allocated)"
3977 );
3978 }
3979 assert_ne!(
3980 ptrs[6], ptrs[7],
3981 "a recording a live frame still shares must never be recorded into"
3982 );
3983 }
3984}