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
1329pub fn text_align_fraction(text_style: &TextStyle, text: &str) -> f32 {
1342 let paragraph_style = &text_style.paragraph_style;
1343 let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
1344 let rtl = direction == cranpose_ui::text::ResolvedTextDirection::Rtl;
1345 match paragraph_style.text_align {
1346 TextAlign::Center => 0.5,
1347 TextAlign::End | TextAlign::Right => 1.0,
1348 TextAlign::Start | TextAlign::Left | TextAlign::Justify | TextAlign::Unspecified => {
1353 if rtl {
1354 1.0
1355 } else {
1356 0.0
1357 }
1358 }
1359 }
1360}
1361
1362fn resolve_text_horizontal_offset(
1363 text_style: &TextStyle,
1364 text: &str,
1365 content_width: f32,
1366 measured_width: f32,
1367) -> f32 {
1368 let remaining = (content_width - measured_width).max(0.0);
1369 remaining * text_align_fraction(text_style, text)
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use std::cell::RefCell;
1375 use std::rc::Rc;
1376
1377 use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
1378 use cranpose_ui::text::{
1379 AnnotatedString, BaselineShift, SpanStyle, TextAlign, TextDirection, TextMotion,
1380 };
1381 use cranpose_ui::{
1382 Color, Column, ColumnSpec, DrawCommand, LayoutEngine, LazyColumn, LazyColumnSpec,
1383 LinearArrangement, Modifier, Point, Rect, ResolvedModifiers, RoundedCornerShape,
1384 ScrollState, Size, Spacer, Text, TextStyle,
1385 };
1386 use cranpose_ui_graphics::{
1387 Brush, DrawPrimitive, DrawScope as _, DrawScopeDefault, GraphicsLayer, RenderEffect,
1388 };
1389
1390 use super::*;
1391
1392 fn find_text_motion(layer: &LayerNode, label: &str) -> Option<Option<TextMotion>> {
1393 for child in &layer.children {
1394 match child {
1395 RenderNode::Primitive(primitive) => {
1396 let PrimitiveNode::Text(text) = &primitive.node else {
1397 continue;
1398 };
1399 if text.text.text == label {
1400 return Some(text.text_style.paragraph_style.text_motion);
1401 }
1402 }
1403 RenderNode::Layer(child_layer) => {
1404 if let Some(motion) = find_text_motion(child_layer, label) {
1405 return Some(motion);
1406 }
1407 }
1408 RenderNode::DrawRun(_) => {}
1409 }
1410 }
1411
1412 None
1413 }
1414
1415 fn collect_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
1416 for child in &layer.children {
1417 match child {
1418 RenderNode::Primitive(primitive) => {
1419 let PrimitiveNode::Text(text) = &primitive.node else {
1420 continue;
1421 };
1422 labels.push(text.text.text.clone());
1423 }
1424 RenderNode::Layer(child_layer) => collect_text_labels(child_layer, labels),
1425 RenderNode::DrawRun(_) => {}
1426 }
1427 }
1428 }
1429
1430 fn find_text_top(layer: &LayerNode, label: &str) -> Option<f32> {
1431 fn search(layer: &LayerNode, label: &str, transform: ProjectiveTransform) -> Option<f32> {
1432 for child in &layer.children {
1433 match child {
1434 RenderNode::Primitive(primitive) => {
1435 let PrimitiveNode::Text(text) = &primitive.node else {
1436 continue;
1437 };
1438 if text.text.text == label {
1439 let quad = transform.map_rect(text.rect);
1440 let top = quad
1441 .iter()
1442 .map(|point| point[1])
1443 .fold(f32::INFINITY, f32::min);
1444 return top.is_finite().then_some(top);
1445 }
1446 }
1447 RenderNode::Layer(child_layer) => {
1448 let child_transform = child_layer.transform_to_parent.then(transform);
1449 if let Some(top) = search(child_layer, label, child_transform) {
1450 return Some(top);
1451 }
1452 }
1453 RenderNode::DrawRun(_) => {}
1454 }
1455 }
1456 None
1457 }
1458
1459 search(layer, label, ProjectiveTransform::identity())
1460 }
1461
1462 fn find_layer_by_node_id(layer: &LayerNode, node_id: NodeId) -> Option<&LayerNode> {
1463 if layer.node_id == Some(node_id) {
1464 return Some(layer);
1465 }
1466 layer.children.iter().find_map(|child| match child {
1467 RenderNode::Layer(child_layer) => find_layer_by_node_id(child_layer, node_id),
1468 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1469 })
1470 }
1471
1472 fn find_layer_origin(layer: &LayerNode, node_id: NodeId) -> Option<Point> {
1473 fn search(
1474 layer: &LayerNode,
1475 node_id: NodeId,
1476 transform: ProjectiveTransform,
1477 ) -> Option<Point> {
1478 if layer.node_id == Some(node_id) {
1479 return Some(transform.map_point(Point::default()));
1480 }
1481 layer.children.iter().find_map(|child| match child {
1482 RenderNode::Layer(child_layer) => search(
1483 child_layer,
1484 node_id,
1485 child_layer.transform_to_parent.then(transform),
1486 ),
1487 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => None,
1488 })
1489 }
1490
1491 search(layer, node_id, ProjectiveTransform::identity())
1492 }
1493
1494 fn find_translated_content_offset(layer: &LayerNode) -> Option<Point> {
1495 if layer.translated_content_context {
1496 return Some(layer.translated_content_offset);
1497 }
1498 for child in &layer.children {
1499 if let RenderNode::Layer(child_layer) = child {
1500 if let Some(offset) = find_translated_content_offset(child_layer) {
1501 return Some(offset);
1502 }
1503 }
1504 }
1505 None
1506 }
1507
1508 fn graph_has_runtime_shader_effect(layer: &LayerNode) -> bool {
1509 layer
1510 .graphics_layer
1511 .render_effect
1512 .as_ref()
1513 .is_some_and(RenderEffect::contains_runtime_shader)
1514 || layer.children.iter().any(|child| match child {
1515 RenderNode::Layer(child_layer) => graph_has_runtime_shader_effect(child_layer),
1516 RenderNode::Primitive(_) | RenderNode::DrawRun(_) => false,
1517 })
1518 }
1519
1520 fn build_layer_node_for_test(
1521 snapshot: BuildNodeSnapshot,
1522 scale: f32,
1523 has_external_backdrop_input: bool,
1524 ) -> LayerNode {
1525 let app_context = cranpose_ui::AppContext::new();
1526 app_context.enter(|| build_layer_node(snapshot, scale, has_external_backdrop_input))
1527 }
1528
1529 fn snapshot_with_translation(tx: f32) -> BuildNodeSnapshot {
1530 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1531 scope.push_recorded(vec![DrawPrimitive::Rect {
1532 rect: Rect {
1533 x: 3.0,
1534 y: 4.0,
1535 width: 20.0,
1536 height: 8.0,
1537 },
1538 brush: Brush::solid(Color::WHITE),
1539 stroke: None,
1540 }]);
1541 }));
1542
1543 let child = BuildNodeSnapshot {
1544 node_id: 2,
1545 placement: Point { x: 11.0, y: 7.0 },
1546 size: Size {
1547 width: 40.0,
1548 height: 20.0,
1549 },
1550 content_offset: Point::default(),
1551 motion_context_animated: false,
1552 translated_content_context: false,
1553 measured_max_width: None,
1554 resolved_modifiers: ResolvedModifiers::default(),
1555 draw_commands: vec![child_command],
1556 click_actions: vec![],
1557 pointer_inputs: vec![],
1558 clip_to_bounds: false,
1559 annotated_text: None,
1560 text_style: None,
1561 text_layout_options: None,
1562 text_pan: None,
1563 graphics_layer: None,
1564 children: vec![],
1565 };
1566
1567 BuildNodeSnapshot {
1568 node_id: 1,
1569 placement: Point::default(),
1570 size: Size {
1571 width: 80.0,
1572 height: 50.0,
1573 },
1574 content_offset: Point::default(),
1575 motion_context_animated: false,
1576 translated_content_context: false,
1577 measured_max_width: None,
1578 resolved_modifiers: ResolvedModifiers::default(),
1579 draw_commands: vec![],
1580 click_actions: vec![],
1581 pointer_inputs: vec![],
1582 clip_to_bounds: false,
1583 annotated_text: None,
1584 text_style: None,
1585 text_layout_options: None,
1586 text_pan: None,
1587 graphics_layer: Some(GraphicsLayer {
1588 translation_x: tx,
1589 ..GraphicsLayer::default()
1590 }),
1591 children: vec![child],
1592 }
1593 }
1594
1595 #[test]
1596 fn parent_translation_changes_layer_transform_but_not_child_local_geometry() {
1597 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1598 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1599
1600 let RenderNode::Layer(static_child) = &static_graph.children[0] else {
1601 panic!("expected child layer");
1602 };
1603 let RenderNode::Layer(moved_child) = &moved_graph.children[0] else {
1604 panic!("expected child layer");
1605 };
1606 let RenderNode::DrawRun(static_run) = &static_child.children[0] else {
1607 panic!("expected draw run");
1608 };
1609 let static_draw = &static_run.primitives[0];
1610 let RenderNode::DrawRun(moved_run) = &moved_child.children[0] else {
1611 panic!("expected draw run");
1612 };
1613 let moved_draw = &moved_run.primitives[0];
1614
1615 assert_ne!(
1616 static_graph.transform_to_parent, moved_graph.transform_to_parent,
1617 "parent transform should encode translation"
1618 );
1619 assert_eq!(
1620 static_draw, moved_draw,
1621 "child local primitive geometry must stay stable under parent translation"
1622 );
1623 }
1624
1625 #[test]
1626 fn stored_content_hash_ignores_parent_translation() {
1627 let static_graph = build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false);
1628 let moved_graph = build_layer_node_for_test(snapshot_with_translation(23.5), 1.0, false);
1629
1630 assert_eq!(
1631 static_graph.target_content_hash(),
1632 moved_graph.target_content_hash(),
1633 "parent rigid motion must not invalidate the subtree content hash"
1634 );
1635 }
1636
1637 #[test]
1638 fn parent_content_offset_is_encoded_in_child_transform() {
1639 let child = BuildNodeSnapshot {
1640 node_id: 2,
1641 placement: Point { x: 11.0, y: 7.0 },
1642 size: Size {
1643 width: 40.0,
1644 height: 20.0,
1645 },
1646 content_offset: Point::default(),
1647 motion_context_animated: false,
1648 translated_content_context: false,
1649 measured_max_width: None,
1650 resolved_modifiers: ResolvedModifiers::default(),
1651 draw_commands: vec![],
1652 click_actions: vec![],
1653 pointer_inputs: vec![],
1654 clip_to_bounds: false,
1655 annotated_text: None,
1656 text_style: None,
1657 text_layout_options: None,
1658 text_pan: None,
1659 graphics_layer: None,
1660 children: vec![],
1661 };
1662
1663 let parent = BuildNodeSnapshot {
1664 node_id: 1,
1665 placement: Point::default(),
1666 size: Size {
1667 width: 80.0,
1668 height: 50.0,
1669 },
1670 content_offset: Point { x: 13.0, y: -9.0 },
1671 motion_context_animated: false,
1672 translated_content_context: false,
1673 measured_max_width: None,
1674 resolved_modifiers: ResolvedModifiers::default(),
1675 draw_commands: vec![],
1676 click_actions: vec![],
1677 pointer_inputs: vec![],
1678 clip_to_bounds: false,
1679 annotated_text: None,
1680 text_style: None,
1681 text_layout_options: None,
1682 text_pan: None,
1683 graphics_layer: None,
1684 children: vec![child],
1685 };
1686
1687 let graph = build_layer_node_for_test(parent, 1.0, false);
1688 let RenderNode::Layer(child) = &graph.children[0] else {
1689 panic!("expected child layer");
1690 };
1691
1692 let top_left = child.transform_to_parent.map_point(Point::default());
1693 assert_eq!(top_left, Point { x: 24.0, y: -2.0 });
1694 }
1695
1696 #[test]
1697 fn translated_content_offset_changes_visual_position_and_full_surface_hash() {
1698 fn parent_with_offset(offset: Point, motion_context_animated: bool) -> BuildNodeSnapshot {
1699 let child_command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
1700 scope.push_recorded(vec![DrawPrimitive::Rect {
1701 rect: Rect {
1702 x: 3.0,
1703 y: 4.0,
1704 width: 20.0,
1705 height: 8.0,
1706 },
1707 brush: Brush::solid(Color::WHITE),
1708 stroke: None,
1709 }]);
1710 }));
1711
1712 let child = BuildNodeSnapshot {
1713 node_id: 2,
1714 placement: Point { x: 11.0, y: 7.0 },
1715 size: Size {
1716 width: 40.0,
1717 height: 20.0,
1718 },
1719 content_offset: Point::default(),
1720 motion_context_animated: false,
1721 translated_content_context: false,
1722 measured_max_width: None,
1723 resolved_modifiers: ResolvedModifiers::default(),
1724 draw_commands: vec![child_command],
1725 click_actions: vec![],
1726 pointer_inputs: vec![],
1727 clip_to_bounds: false,
1728 annotated_text: None,
1729 text_style: None,
1730 text_layout_options: None,
1731 text_pan: None,
1732 graphics_layer: None,
1733 children: vec![],
1734 };
1735
1736 BuildNodeSnapshot {
1737 node_id: 1,
1738 placement: Point::default(),
1739 size: Size {
1740 width: 80.0,
1741 height: 50.0,
1742 },
1743 content_offset: offset,
1744 motion_context_animated,
1745 translated_content_context: true,
1746 measured_max_width: None,
1747 resolved_modifiers: ResolvedModifiers::default(),
1748 draw_commands: vec![],
1749 click_actions: vec![],
1750 pointer_inputs: vec![],
1751 clip_to_bounds: false,
1752 annotated_text: None,
1753 text_style: None,
1754 text_layout_options: None,
1755 text_pan: None,
1756 graphics_layer: None,
1757 children: vec![child],
1758 }
1759 }
1760
1761 let base = build_layer_node_for_test(
1762 parent_with_offset(Point { x: 0.0, y: -18.0 }, true),
1763 1.0,
1764 false,
1765 );
1766 let moved = build_layer_node_for_test(
1767 parent_with_offset(Point { x: 0.0, y: -32.0 }, true),
1768 1.0,
1769 false,
1770 );
1771 let rested = build_layer_node_for_test(
1772 parent_with_offset(Point { x: 0.0, y: -18.0 }, false),
1773 1.0,
1774 false,
1775 );
1776
1777 let RenderNode::Layer(base_child) = &base.children[0] else {
1778 panic!("expected child layer");
1779 };
1780 let RenderNode::Layer(moved_child) = &moved.children[0] else {
1781 panic!("expected child layer");
1782 };
1783
1784 assert_ne!(
1785 base_child.transform_to_parent.map_point(Point::default()),
1786 moved_child.transform_to_parent.map_point(Point::default()),
1787 "scroll offset still has to move child content visually"
1788 );
1789 assert_eq!(
1790 base_child.target_content_hash(),
1791 moved_child.target_content_hash(),
1792 "child source content identity stays stable when only the parent scroll offset changes"
1793 );
1794 assert_ne!(
1795 base.target_content_hash(),
1796 moved.target_content_hash(),
1797 "a full-surface cache of the scroll viewport must include the scroll offset"
1798 );
1799 assert_ne!(
1800 base.target_content_hash(),
1801 rested.target_content_hash(),
1802 "full-surface cache keys must include active scroll motion policy"
1803 );
1804 }
1805
1806 #[test]
1807 fn rounded_clip_to_bounds_records_shape_clip_without_runtime_shader() {
1808 let layer = graphics_layer_with_shaped_clip(
1809 GraphicsLayer::default(),
1810 true,
1811 Some(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0)),
1812 Rect {
1813 x: 0.0,
1814 y: 0.0,
1815 width: 100.0,
1816 height: 40.0,
1817 },
1818 );
1819
1820 assert!(layer.clip);
1821 assert!(layer.render_effect.is_none());
1822 let LayerShape::Rounded(shape) = layer.shape else {
1823 panic!("rounded clip must be recorded as layer shape");
1824 };
1825 assert_eq!(shape, RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0));
1826 assert!(isolation_reasons(&layer).shape_clip);
1827 }
1828
1829 #[test]
1830 fn rounded_clip_to_bounds_keeps_existing_effect_inside_mask() {
1831 let existing = RenderEffect::blur(3.0);
1832 let layer = graphics_layer_with_shaped_clip(
1833 GraphicsLayer {
1834 render_effect: Some(existing.clone()),
1835 ..GraphicsLayer::default()
1836 },
1837 true,
1838 Some(RoundedCornerShape::uniform(10.0)),
1839 Rect {
1840 x: 0.0,
1841 y: 0.0,
1842 width: 100.0,
1843 height: 40.0,
1844 },
1845 );
1846
1847 let Some(RenderEffect::Chain { first, second }) = layer.render_effect else {
1848 panic!("existing effect should chain into rounded clip mask");
1849 };
1850 assert_eq!(*first, existing);
1851 assert!(
1852 matches!(*second, RenderEffect::Shader { .. }),
1853 "rounded mask must be the outer effect"
1854 );
1855 }
1856
1857 #[test]
1858 fn rounded_corners_clip_to_bounds_builds_graph_shape_clip_from_modifier_chain() {
1859 let mut composition = cranpose_ui::run_test_composition(|| {
1860 cranpose_ui::Box(
1861 Modifier::empty()
1862 .width(100.0)
1863 .height(40.0)
1864 .rounded_corner_shape(RoundedCornerShape::new(4.0, 8.0, 12.0, 16.0))
1865 .clip_to_bounds(),
1866 cranpose_ui::BoxSpec::default(),
1867 || {
1868 Text("rounded child", Modifier::empty(), TextStyle::default());
1869 },
1870 );
1871 });
1872
1873 let root = composition.root().expect("rounded clip root");
1874 let handle = composition.runtime_handle();
1875 let mut applier = composition.applier_mut();
1876 applier.set_runtime_handle(handle);
1877 applier
1878 .compute_layout(
1879 root,
1880 Size {
1881 width: 160.0,
1882 height: 100.0,
1883 },
1884 )
1885 .expect("rounded clip layout");
1886 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("rounded clip graph");
1887 applier.clear_runtime_handle();
1888
1889 let rounded_layer = find_layer_by_node_id(&graph.root, root).expect("rounded layer");
1890 assert!(rounded_layer.graphics_layer.clip);
1891 assert!(matches!(
1892 rounded_layer.graphics_layer.shape,
1893 LayerShape::Rounded(_)
1894 ));
1895 assert!(rounded_layer.graphics_layer.render_effect.is_none());
1896 assert!(rounded_layer.isolation.shape_clip);
1897 assert!(
1898 !graph_has_runtime_shader_effect(&graph.root),
1899 "simple rounded_corners().clip_to_bounds() must not become a runtime shader effect"
1900 );
1901 }
1902
1903 #[test]
1904 fn update_graph_from_applier_replaces_dirty_child_layer() {
1905 let state_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
1906 Rc::new(RefCell::new(None));
1907 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
1908 let state_holder_for_comp = state_holder.clone();
1909 let child_id_holder_for_comp = child_id_holder.clone();
1910
1911 let mut composition = cranpose_ui::run_test_composition(move || {
1912 let label = cranpose_core::useState(|| "before".to_string());
1913 *state_holder_for_comp.borrow_mut() = Some(label);
1914 let child_id_holder_for_content = child_id_holder_for_comp.clone();
1915 cranpose_ui::Box(
1916 Modifier::empty().size_points(240.0, 80.0),
1917 cranpose_ui::BoxSpec::default(),
1918 move || {
1919 let child_id = Text(label, Modifier::empty(), TextStyle::default());
1920 *child_id_holder_for_content.borrow_mut() = Some(child_id);
1921 Text("stable", Modifier::empty(), TextStyle::default());
1922 },
1923 );
1924 });
1925
1926 let root = composition.root().expect("composition root");
1927 let viewport = Size {
1928 width: 240.0,
1929 height: 80.0,
1930 };
1931 let handle = composition.runtime_handle();
1932 let mut applier = composition.applier_mut();
1933 applier.set_runtime_handle(handle);
1934 applier
1935 .compute_layout(root, viewport)
1936 .expect("initial layout");
1937 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
1938 let child_id = child_id_holder
1939 .borrow()
1940 .expect("text child id should be captured");
1941 let initial_transform = find_layer_by_node_id(&graph.root, child_id)
1942 .expect("text child layer")
1943 .transform_to_parent;
1944 applier.clear_runtime_handle();
1945 drop(applier);
1946
1947 let label = state_holder
1948 .borrow()
1949 .as_ref()
1950 .copied()
1951 .expect("label state should be captured");
1952 label.set_value("after".to_string());
1953 composition
1954 .process_invalid_scopes()
1955 .expect("text recomposition");
1956
1957 let handle = composition.runtime_handle();
1958 let mut applier = composition.applier_mut();
1959 applier.set_runtime_handle(handle);
1960 applier
1961 .compute_layout(root, viewport)
1962 .expect("updated layout");
1963 let child_id = child_id_holder
1964 .borrow()
1965 .expect("text child id should remain captured");
1966
1967 assert!(
1968 update_graph_from_applier(&mut applier, &mut graph, &[child_id], 1.0),
1969 "dirty child should be replaceable from retained applier state"
1970 );
1971 applier.clear_runtime_handle();
1972
1973 let mut labels = Vec::new();
1974 collect_text_labels(&graph.root, &mut labels);
1975 assert!(
1976 labels.iter().any(|label| label == "after"),
1977 "updated graph should contain refreshed child text, got {labels:?}"
1978 );
1979 assert!(
1980 !labels.iter().any(|label| label == "before"),
1981 "updated graph should not retain stale child text, got {labels:?}"
1982 );
1983 assert!(
1984 labels.iter().any(|label| label == "stable"),
1985 "sibling content should remain present, got {labels:?}"
1986 );
1987 assert_eq!(
1988 find_layer_by_node_id(&graph.root, child_id)
1989 .expect("updated text child layer")
1990 .transform_to_parent,
1991 initial_transform,
1992 "draw-only child replacement must preserve the retained parent placement transform"
1993 );
1994 }
1995
1996 #[test]
2004 fn scene_build_publishes_live_window_rect_without_layout_tree() {
2005 use cranpose_ui::{measure_layout_with_options, Box, BoxSpec, MeasureLayoutOptions};
2006 use std::cell::Cell;
2007
2008 let spacer_before = 120.0_f32;
2009 let sink: Rc<Cell<Rect>> = Rc::new(Cell::new(Rect {
2010 x: 0.0,
2011 y: 0.0,
2012 width: 0.0,
2013 height: 0.0,
2014 }));
2015 let sink_for_comp = sink.clone();
2016 let mut composition = cranpose_ui::run_test_composition(move || {
2017 let sink = sink_for_comp.clone();
2018 Column(
2019 Modifier::empty().size_points(200.0, 400.0),
2020 ColumnSpec::default(),
2021 move || {
2022 Spacer(Size {
2023 width: 200.0,
2024 height: spacer_before,
2025 });
2026 Box(
2027 Modifier::empty()
2028 .size_points(200.0, 50.0)
2029 .report_window_rect(sink.clone()),
2030 BoxSpec::default(),
2031 || {},
2032 );
2033 },
2034 );
2035 });
2036
2037 let root = composition.root().expect("composition root");
2038 let viewport = Size {
2039 width: 200.0,
2040 height: 400.0,
2041 };
2042 let handle = composition.runtime_handle();
2043 let mut applier = composition.applier_mut();
2044 applier.set_runtime_handle(handle);
2045 measure_layout_with_options(
2048 &mut applier,
2049 root,
2050 viewport,
2051 MeasureLayoutOptions {
2052 collect_semantics: false,
2053 build_layout_tree: false,
2054 },
2055 )
2056 .expect("layout");
2057 assert_eq!(
2059 sink.get().height,
2060 0.0,
2061 "sink must start empty (place disabled)"
2062 );
2063
2064 let _graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scene graph");
2065 applier.clear_runtime_handle();
2066
2067 let rect = sink.get();
2068 assert!(
2069 (rect.y - spacer_before).abs() < 0.5,
2070 "scene build must publish the box's live window-y (below the {spacer_before}px \
2071 spacer), got {}",
2072 rect.y
2073 );
2074 assert!(
2075 rect.width > 0.0 && rect.height > 0.0,
2076 "scene build must publish a non-empty window rect, got {rect:?}"
2077 );
2078 }
2079
2080 #[test]
2081 fn update_graph_from_applier_reports_failed_dirty_child_rebuild() {
2082 let mut graph = RenderGraph {
2083 root: build_layer_node_for_test(snapshot_with_translation(0.0), 1.0, false),
2084 };
2085 let mut applier = MemoryApplier::new();
2086
2087 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[2], 1.0);
2088
2089 assert_eq!(
2090 report,
2091 GraphUpdateReport {
2092 applied: false,
2093 hit_graph_dirty: true,
2094 },
2095 "dirty child graph updates must not report success when the replacement cannot be rebuilt"
2096 );
2097 }
2098
2099 #[test]
2100 fn update_graph_from_applier_refreshes_scroll_content_offset() {
2101 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2102 let scroll_holder_for_comp = scroll_holder.clone();
2103
2104 let mut composition = cranpose_ui::run_test_composition(move || {
2105 let scroll_state =
2106 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2107 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2108 Column(
2109 Modifier::empty()
2110 .size_points(240.0, 120.0)
2111 .vertical_scroll(scroll_state, false),
2112 ColumnSpec::default(),
2113 || {
2114 Text("scroll top", Modifier::empty(), TextStyle::default());
2115 Spacer(Size {
2116 width: 0.0,
2117 height: 160.0,
2118 });
2119 Text("scroll target", Modifier::empty(), TextStyle::default());
2120 },
2121 );
2122 });
2123
2124 let root = composition.root().expect("composition root");
2125 let viewport = Size {
2126 width: 240.0,
2127 height: 120.0,
2128 };
2129 let handle = composition.runtime_handle();
2130 let mut applier = composition.applier_mut();
2131 applier.set_runtime_handle(handle);
2132 applier
2133 .compute_layout(root, viewport)
2134 .expect("initial scroll layout");
2135 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2136 let initial_target_top =
2137 find_text_top(&graph.root, "scroll target").expect("initial target text");
2138 applier.clear_runtime_handle();
2139 drop(applier);
2140
2141 let scroll_state = scroll_holder
2142 .borrow()
2143 .as_ref()
2144 .cloned()
2145 .expect("scroll state should be captured");
2146 let consumed_scroll = scroll_state.dispatch_raw_delta(96.0);
2147 assert!(consumed_scroll > 0.0, "test scroll must be consumed");
2148 let dirty_nodes = cranpose_ui::pending_layout_repass_nodes_snapshot();
2149 assert!(
2150 !dirty_nodes.is_empty(),
2151 "scroll state invalidation must schedule scoped layout graph update"
2152 );
2153
2154 let handle = composition.runtime_handle();
2155 let mut applier = composition.applier_mut();
2156 applier.set_runtime_handle(handle);
2157 applier
2158 .compute_layout(root, viewport)
2159 .expect("scrolled layout");
2160 let report = update_graph_from_applier_report(&mut applier, &mut graph, &dirty_nodes, 1.0);
2161 applier.clear_runtime_handle();
2162
2163 assert!(report.applied, "scroll graph update should apply in place");
2164 let updated_target_top =
2165 find_text_top(&graph.root, "scroll target").expect("updated target text");
2166 assert!(
2167 updated_target_top < initial_target_top - consumed_scroll * 0.75,
2168 "partial graph update must refresh scroll content offset: initial_y={initial_target_top} updated_y={updated_target_top} dirty_nodes={dirty_nodes:?}"
2169 );
2170 }
2171
2172 #[test]
2173 fn update_graph_from_applier_keeps_parent_content_offset_for_dirty_scroll_child() {
2174 let label_holder: Rc<RefCell<Option<cranpose_core::MutableState<String>>>> =
2175 Rc::new(RefCell::new(None));
2176 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2177 let child_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2178 let label_holder_for_comp = label_holder.clone();
2179 let scroll_holder_for_comp = scroll_holder.clone();
2180 let child_id_holder_for_comp = child_id_holder.clone();
2181
2182 let mut composition = cranpose_ui::run_test_composition(move || {
2183 let label = cranpose_core::useState(|| "scrolled child before".to_string());
2184 let scroll_state =
2185 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2186 *label_holder_for_comp.borrow_mut() = Some(label);
2187 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2188 let child_id_holder_for_content = child_id_holder_for_comp.clone();
2189 Column(
2190 Modifier::empty()
2191 .size_points(260.0, 90.0)
2192 .vertical_scroll(scroll_state, false),
2193 ColumnSpec::default(),
2194 move || {
2195 Spacer(Size {
2196 width: 0.0,
2197 height: 24.0,
2198 });
2199 let child_id = Text(label, Modifier::empty(), TextStyle::default());
2200 *child_id_holder_for_content.borrow_mut() = Some(child_id);
2201 Spacer(Size {
2202 width: 0.0,
2203 height: 220.0,
2204 });
2205 },
2206 );
2207 });
2208
2209 let root = composition.root().expect("composition root");
2210 let viewport = Size {
2211 width: 260.0,
2212 height: 90.0,
2213 };
2214 let handle = composition.runtime_handle();
2215 let mut applier = composition.applier_mut();
2216 applier.set_runtime_handle(handle);
2217 applier
2218 .compute_layout(root, viewport)
2219 .expect("initial layout");
2220 applier.clear_runtime_handle();
2221 drop(applier);
2222
2223 let scroll_state = scroll_holder
2224 .borrow()
2225 .as_ref()
2226 .cloned()
2227 .expect("scroll state should be captured");
2228 assert!(scroll_state.dispatch_raw_delta(36.0) > 0.0);
2229
2230 let handle = composition.runtime_handle();
2231 let mut applier = composition.applier_mut();
2232 applier.set_runtime_handle(handle);
2233 applier
2234 .compute_layout(root, viewport)
2235 .expect("scrolled layout");
2236 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2237 let child_id = child_id_holder
2238 .borrow()
2239 .expect("text child id should be captured");
2240 let scrolled_transform = find_layer_by_node_id(&graph.root, child_id)
2241 .expect("scrolled child layer")
2242 .transform_to_parent;
2243 applier.clear_runtime_handle();
2244 drop(applier);
2245
2246 let label = label_holder
2247 .borrow()
2248 .as_ref()
2249 .copied()
2250 .expect("label state should be captured");
2251 label.set_value("scrolled child after".to_string());
2252 composition
2253 .process_invalid_scopes()
2254 .expect("text recomposition");
2255
2256 let handle = composition.runtime_handle();
2257 let mut applier = composition.applier_mut();
2258 applier.set_runtime_handle(handle);
2259 applier
2260 .compute_layout(root, viewport)
2261 .expect("updated scrolled layout");
2262 let child_id = child_id_holder
2263 .borrow()
2264 .expect("text child id should remain captured");
2265 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[child_id], 1.0);
2266 applier.clear_runtime_handle();
2267
2268 assert!(report.applied, "dirty child graph update should apply");
2269 let updated = find_layer_by_node_id(&graph.root, child_id).expect("updated child layer");
2270 assert_eq!(
2271 updated.transform_to_parent, scrolled_transform,
2272 "dirty child replacement inside a scrolled parent must keep the parent's content-offset transform"
2273 );
2274 let mut labels = Vec::new();
2275 collect_text_labels(&graph.root, &mut labels);
2276 assert!(
2277 labels.iter().any(|label| label == "scrolled child after"),
2278 "updated graph should contain refreshed text, got {labels:?}"
2279 );
2280 }
2281
2282 #[test]
2283 fn dirty_scrolled_overlay_graphics_layer_stays_aligned_with_underlay() {
2284 let alpha_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2285 Rc::new(RefCell::new(None));
2286 let scroll_holder: Rc<RefCell<Option<ScrollState>>> = Rc::new(RefCell::new(None));
2287 let underlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2288 let overlay_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2289 let alpha_holder_for_comp = alpha_holder.clone();
2290 let scroll_holder_for_comp = scroll_holder.clone();
2291 let underlay_id_holder_for_comp = underlay_id_holder.clone();
2292 let overlay_id_holder_for_comp = overlay_id_holder.clone();
2293
2294 let mut composition = cranpose_ui::run_test_composition(move || {
2295 let alpha = cranpose_core::useState(|| 1.0f32);
2296 let scroll_state =
2297 cranpose_core::remember(|| ScrollState::new(0.0)).with(|state| state.clone());
2298 *alpha_holder_for_comp.borrow_mut() = Some(alpha);
2299 *scroll_holder_for_comp.borrow_mut() = Some(scroll_state.clone());
2300 let underlay_id_holder_for_content = underlay_id_holder_for_comp.clone();
2301 let overlay_id_holder_for_content = overlay_id_holder_for_comp.clone();
2302 Column(
2303 Modifier::empty()
2304 .size_points(260.0, 120.0)
2305 .vertical_scroll(scroll_state, false),
2306 ColumnSpec::default(),
2307 move || {
2308 Spacer(Size {
2309 width: 0.0,
2310 height: 180.0,
2311 });
2312 cranpose_ui::Box(
2313 Modifier::empty().size_points(188.0, 88.0),
2314 cranpose_ui::BoxSpec::default(),
2315 {
2316 let underlay_id_holder_for_box = underlay_id_holder_for_content.clone();
2317 let overlay_id_holder_for_box = overlay_id_holder_for_content.clone();
2318 move || {
2319 let underlay_id = cranpose_ui::Box(
2320 Modifier::empty().size_points(188.0, 88.0),
2321 cranpose_ui::BoxSpec::default(),
2322 || {
2323 Text(
2324 "UNDERLAY CONTENT",
2325 Modifier::empty().absolute_offset(12.0, 8.0),
2326 TextStyle::default(),
2327 );
2328 },
2329 );
2330 *underlay_id_holder_for_box.borrow_mut() = Some(underlay_id);
2331 let overlay_id = cranpose_ui::Box(
2332 Modifier::empty().size_points(188.0, 88.0).graphics_layer(
2333 move || GraphicsLayer {
2334 alpha: alpha.get(),
2335 ..GraphicsLayer::default()
2336 },
2337 ),
2338 cranpose_ui::BoxSpec::default(),
2339 || {
2340 Text(
2341 "TOP LAYER",
2342 Modifier::empty().absolute_offset(74.0, 39.6),
2343 TextStyle::default(),
2344 );
2345 },
2346 );
2347 *overlay_id_holder_for_box.borrow_mut() = Some(overlay_id);
2348 }
2349 },
2350 );
2351 Spacer(Size {
2352 width: 0.0,
2353 height: 280.0,
2354 });
2355 },
2356 );
2357 });
2358
2359 let root = composition.root().expect("composition root");
2360 let viewport = Size {
2361 width: 260.0,
2362 height: 120.0,
2363 };
2364 let handle = composition.runtime_handle();
2365 let mut applier = composition.applier_mut();
2366 applier.set_runtime_handle(handle);
2367 applier
2368 .compute_layout(root, viewport)
2369 .expect("initial layout");
2370 applier.clear_runtime_handle();
2371 drop(applier);
2372
2373 let scroll_state = scroll_holder
2374 .borrow()
2375 .as_ref()
2376 .cloned()
2377 .expect("scroll state should be captured");
2378 assert!(scroll_state.dispatch_raw_delta(96.0) > 0.0);
2379
2380 let handle = composition.runtime_handle();
2381 let mut applier = composition.applier_mut();
2382 applier.set_runtime_handle(handle);
2383 applier
2384 .compute_layout(root, viewport)
2385 .expect("scrolled layout");
2386 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("scrolled graph");
2387 applier.clear_runtime_handle();
2388 drop(applier);
2389
2390 let underlay_id = underlay_id_holder
2391 .borrow()
2392 .expect("underlay id should be captured");
2393 let overlay_id = overlay_id_holder
2394 .borrow()
2395 .expect("overlay id should be captured");
2396 let scrolled_underlay_origin =
2397 find_layer_origin(&graph.root, underlay_id).expect("underlay origin");
2398 let scrolled_overlay_origin =
2399 find_layer_origin(&graph.root, overlay_id).expect("overlay origin");
2400 assert_eq!(scrolled_underlay_origin, scrolled_overlay_origin);
2401
2402 let alpha = alpha_holder
2403 .borrow()
2404 .as_ref()
2405 .copied()
2406 .expect("alpha state should be captured");
2407 alpha.set_value(0.35);
2408
2409 let handle = composition.runtime_handle();
2410 let mut applier = composition.applier_mut();
2411 applier.set_runtime_handle(handle);
2412 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[overlay_id], 1.0);
2413 applier.clear_runtime_handle();
2414
2415 assert!(report.applied, "dirty overlay graph update should apply");
2416 let updated_underlay_origin =
2417 find_layer_origin(&graph.root, underlay_id).expect("updated underlay origin");
2418 let updated_overlay_origin =
2419 find_layer_origin(&graph.root, overlay_id).expect("updated overlay origin");
2420 assert_eq!(
2421 updated_underlay_origin, scrolled_underlay_origin,
2422 "stable underlay must keep its scrolled origin"
2423 );
2424 assert_eq!(
2425 updated_overlay_origin, updated_underlay_origin,
2426 "dirty overlay graphics layer must stay aligned with its stable underlay"
2427 );
2428 }
2429
2430 #[test]
2431 fn update_graph_from_applier_refreshes_dirty_graphics_layer_transform() {
2432 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2433 Rc::new(RefCell::new(None));
2434 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2435 let offset_holder_for_comp = offset_holder.clone();
2436 let node_id_holder_for_comp = node_id_holder.clone();
2437
2438 let mut composition = cranpose_ui::run_test_composition(move || {
2439 let offset = cranpose_core::useState(|| 0.0f32);
2440 *offset_holder_for_comp.borrow_mut() = Some(offset);
2441 let node_id = cranpose_ui::Box(
2442 Modifier::empty()
2443 .size_points(40.0, 20.0)
2444 .graphics_layer(move || GraphicsLayer {
2445 translation_x: offset.get(),
2446 ..GraphicsLayer::default()
2447 }),
2448 cranpose_ui::BoxSpec::default(),
2449 || {},
2450 );
2451 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2452 });
2453
2454 let root = composition.root().expect("composition root");
2455 let viewport = Size {
2456 width: 120.0,
2457 height: 80.0,
2458 };
2459 let handle = composition.runtime_handle();
2460 let mut applier = composition.applier_mut();
2461 applier.set_runtime_handle(handle);
2462 applier
2463 .compute_layout(root, viewport)
2464 .expect("initial layout");
2465 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2466 let node_id = node_id_holder
2467 .borrow()
2468 .expect("graphics layer node id should be captured");
2469 let initial_origin = find_layer_by_node_id(&graph.root, node_id)
2470 .expect("initial graphics layer")
2471 .transform_to_parent
2472 .map_point(Point::default());
2473 applier.clear_runtime_handle();
2474 drop(applier);
2475
2476 let offset = offset_holder
2477 .borrow()
2478 .as_ref()
2479 .copied()
2480 .expect("offset state should be captured");
2481 offset.set_value(32.0);
2482
2483 let handle = composition.runtime_handle();
2484 let mut applier = composition.applier_mut();
2485 applier.set_runtime_handle(handle);
2486 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2487 assert!(
2488 report.applied,
2489 "dirty graphics layer should be replaceable from retained applier state"
2490 );
2491 assert!(
2492 !report.hit_graph_dirty,
2493 "a moved visual-only layer should not force hit graph refresh"
2494 );
2495 applier.clear_runtime_handle();
2496
2497 let updated_origin = find_layer_by_node_id(&graph.root, node_id)
2498 .expect("updated graphics layer")
2499 .transform_to_parent
2500 .map_point(Point::default());
2501 assert!(
2502 (updated_origin.x - (initial_origin.x + 32.0)).abs() < 0.1,
2503 "scoped graph update must refresh graphics-layer translation: initial={initial_origin:?} updated={updated_origin:?}"
2504 );
2505 }
2506
2507 #[test]
2508 fn update_graph_from_applier_reports_hit_dirty_for_moved_clickable_layer() {
2509 let offset_holder: Rc<RefCell<Option<cranpose_core::MutableState<f32>>>> =
2510 Rc::new(RefCell::new(None));
2511 let node_id_holder: Rc<RefCell<Option<NodeId>>> = Rc::new(RefCell::new(None));
2512 let offset_holder_for_comp = offset_holder.clone();
2513 let node_id_holder_for_comp = node_id_holder.clone();
2514
2515 let mut composition = cranpose_ui::run_test_composition(move || {
2516 let offset = cranpose_core::useState(|| 0.0f32);
2517 *offset_holder_for_comp.borrow_mut() = Some(offset);
2518 let node_id = cranpose_ui::Box(
2519 Modifier::empty()
2520 .size_points(40.0, 20.0)
2521 .graphics_layer(move || GraphicsLayer {
2522 translation_x: offset.get(),
2523 ..GraphicsLayer::default()
2524 })
2525 .clickable(|_| {}),
2526 cranpose_ui::BoxSpec::default(),
2527 || {},
2528 );
2529 *node_id_holder_for_comp.borrow_mut() = Some(node_id);
2530 });
2531
2532 let root = composition.root().expect("composition root");
2533 let viewport = Size {
2534 width: 120.0,
2535 height: 80.0,
2536 };
2537 let handle = composition.runtime_handle();
2538 let mut applier = composition.applier_mut();
2539 applier.set_runtime_handle(handle);
2540 applier
2541 .compute_layout(root, viewport)
2542 .expect("initial layout");
2543 let mut graph = build_graph_from_applier(&mut applier, root, 1.0).expect("initial graph");
2544 let node_id = node_id_holder
2545 .borrow()
2546 .expect("graphics layer node id should be captured");
2547 applier.clear_runtime_handle();
2548 drop(applier);
2549
2550 let offset = offset_holder
2551 .borrow()
2552 .as_ref()
2553 .copied()
2554 .expect("offset state should be captured");
2555 offset.set_value(32.0);
2556
2557 let handle = composition.runtime_handle();
2558 let mut applier = composition.applier_mut();
2559 applier.set_runtime_handle(handle);
2560 let report = update_graph_from_applier_report(&mut applier, &mut graph, &[node_id], 1.0);
2561 applier.clear_runtime_handle();
2562
2563 assert!(
2564 report.applied,
2565 "dirty clickable graphics layer should be replaceable from retained applier state"
2566 );
2567 assert!(
2568 report.hit_graph_dirty,
2569 "moved clickable layers must refresh hit geometry"
2570 );
2571 }
2572
2573 #[test]
2574 fn overlay_draw_commands_are_tagged_after_children() {
2575 let child = BuildNodeSnapshot {
2576 node_id: 2,
2577 placement: Point { x: 4.0, y: 5.0 },
2578 size: Size {
2579 width: 20.0,
2580 height: 10.0,
2581 },
2582 content_offset: Point::default(),
2583 motion_context_animated: false,
2584 translated_content_context: false,
2585 measured_max_width: None,
2586 resolved_modifiers: ResolvedModifiers::default(),
2587 draw_commands: vec![],
2588 click_actions: vec![],
2589 pointer_inputs: vec![],
2590 clip_to_bounds: false,
2591 annotated_text: None,
2592 text_style: None,
2593 text_layout_options: None,
2594 text_pan: None,
2595 graphics_layer: None,
2596 children: vec![],
2597 };
2598 let behind = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
2599 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2600 rect: Rect {
2601 x: 1.0,
2602 y: 2.0,
2603 width: 8.0,
2604 height: 6.0,
2605 },
2606 brush: Brush::solid(Color::WHITE),
2607 stroke: None,
2608 }]);
2609 }));
2610 let overlay = DrawCommand::Overlay(Rc::new(|scope: &mut DrawScopeDefault| {
2611 scope.push_recorded(vec![cranpose_ui_graphics::DrawPrimitive::Rect {
2612 rect: Rect {
2613 x: 3.0,
2614 y: 1.0,
2615 width: 5.0,
2616 height: 4.0,
2617 },
2618 brush: Brush::solid(Color::BLACK),
2619 stroke: None,
2620 }]);
2621 }));
2622
2623 let parent = BuildNodeSnapshot {
2624 node_id: 1,
2625 placement: Point::default(),
2626 size: Size {
2627 width: 80.0,
2628 height: 50.0,
2629 },
2630 content_offset: Point::default(),
2631 motion_context_animated: false,
2632 translated_content_context: false,
2633 measured_max_width: None,
2634 resolved_modifiers: ResolvedModifiers::default(),
2635 draw_commands: vec![behind, overlay],
2636 click_actions: vec![],
2637 pointer_inputs: vec![],
2638 clip_to_bounds: false,
2639 annotated_text: None,
2640 text_style: None,
2641 text_layout_options: None,
2642 text_pan: None,
2643 graphics_layer: None,
2644 children: vec![child],
2645 };
2646
2647 let graph = build_layer_node_for_test(parent, 1.0, false);
2648 let RenderNode::DrawRun(behind) = &graph.children[0] else {
2649 panic!("expected before-children draw run");
2650 };
2651 let RenderNode::Layer(_) = &graph.children[1] else {
2652 panic!("expected child layer");
2653 };
2654 let RenderNode::DrawRun(overlay) = &graph.children[2] else {
2655 panic!("expected after-children draw run");
2656 };
2657
2658 assert_eq!(behind.phase, PrimitivePhase::BeforeChildren);
2659 assert_eq!(overlay.phase, PrimitivePhase::AfterChildren);
2660 }
2661
2662 #[test]
2666 fn command_recordings_reuse_buffers_across_rebuilds() {
2667 let snapshot = || BuildNodeSnapshot {
2668 node_id: 7001,
2669 placement: Point::default(),
2670 size: Size {
2671 width: 40.0,
2672 height: 20.0,
2673 },
2674 content_offset: Point::default(),
2675 motion_context_animated: false,
2676 translated_content_context: false,
2677 measured_max_width: None,
2678 resolved_modifiers: ResolvedModifiers::default(),
2679 draw_commands: vec![DrawCommand::Behind(Rc::new(
2680 |scope: &mut DrawScopeDefault| {
2681 scope.draw_rect_at(
2682 Rect {
2683 x: 1.0,
2684 y: 2.0,
2685 width: 8.0,
2686 height: 6.0,
2687 },
2688 Brush::solid(Color::WHITE),
2689 );
2690 },
2691 ))],
2692 click_actions: vec![],
2693 pointer_inputs: vec![],
2694 clip_to_bounds: false,
2695 annotated_text: None,
2696 text_style: None,
2697 text_layout_options: None,
2698 text_pan: None,
2699 graphics_layer: None,
2700 children: vec![],
2701 };
2702 fn run_of(layer: &LayerNode) -> &DrawRunNode {
2703 let RenderNode::DrawRun(run) = &layer.children[0] else {
2704 panic!("expected draw run");
2705 };
2706 run
2707 }
2708
2709 let graph_a = build_layer_node_for_test(snapshot(), 1.0, false);
2710 let ptr_a = run_of(&graph_a).primitives.as_ptr();
2711
2712 let graph_b = build_layer_node_for_test(snapshot(), 1.0, false);
2714 let ptr_b = run_of(&graph_b).primitives.as_ptr();
2715 assert_ne!(
2716 ptr_a, ptr_b,
2717 "a buffer a live graph shares must never be recorded into"
2718 );
2719 assert_eq!(
2720 run_of(&graph_a).primitives,
2721 run_of(&graph_b).primitives,
2722 "re-recording must reproduce the recording"
2723 );
2724
2725 drop(graph_a);
2729 let graph_c = build_layer_node_for_test(snapshot(), 1.0, false);
2730 assert_eq!(
2731 run_of(&graph_c).primitives.as_ptr(),
2732 ptr_a,
2733 "the released buffer must be reused for the next recording"
2734 );
2735
2736 let held = std::rc::Rc::clone(&run_of(&graph_c).primitives);
2739 drop(graph_c);
2740 let graph_d = build_layer_node_for_test(snapshot(), 1.0, false);
2741 let ptr_d = run_of(&graph_d).primitives.as_ptr();
2742 assert_ne!(ptr_d, held.as_ptr());
2743 assert_ne!(ptr_d, run_of(&graph_b).primitives.as_ptr());
2744 }
2745
2746 #[test]
2747 fn stored_content_hash_changes_when_child_transform_changes() {
2748 let child = BuildNodeSnapshot {
2749 node_id: 2,
2750 placement: Point { x: 4.0, y: 5.0 },
2751 size: Size {
2752 width: 20.0,
2753 height: 10.0,
2754 },
2755 content_offset: Point::default(),
2756 motion_context_animated: false,
2757 translated_content_context: false,
2758 measured_max_width: None,
2759 resolved_modifiers: ResolvedModifiers::default(),
2760 draw_commands: vec![],
2761 click_actions: vec![],
2762 pointer_inputs: vec![],
2763 clip_to_bounds: false,
2764 annotated_text: None,
2765 text_style: None,
2766 text_layout_options: None,
2767 text_pan: None,
2768 graphics_layer: None,
2769 children: vec![],
2770 };
2771 let mut moved_child = child.clone();
2772 moved_child.placement.x += 7.0;
2773
2774 let parent = BuildNodeSnapshot {
2775 node_id: 1,
2776 placement: Point::default(),
2777 size: Size {
2778 width: 80.0,
2779 height: 50.0,
2780 },
2781 content_offset: Point::default(),
2782 motion_context_animated: false,
2783 translated_content_context: false,
2784 measured_max_width: None,
2785 resolved_modifiers: ResolvedModifiers::default(),
2786 draw_commands: vec![],
2787 click_actions: vec![],
2788 pointer_inputs: vec![],
2789 clip_to_bounds: false,
2790 annotated_text: None,
2791 text_style: None,
2792 text_layout_options: None,
2793 text_pan: None,
2794 graphics_layer: None,
2795 children: vec![child],
2796 };
2797 let moved_parent = BuildNodeSnapshot {
2798 children: vec![moved_child],
2799 ..parent.clone()
2800 };
2801
2802 let static_graph = build_layer_node_for_test(parent, 1.0, false);
2803 let moved_graph = build_layer_node_for_test(moved_parent, 1.0, false);
2804
2805 assert_ne!(
2806 static_graph.target_content_hash(),
2807 moved_graph.target_content_hash(),
2808 "moving a child within the parent must invalidate the parent subtree hash"
2809 );
2810 }
2811
2812 #[test]
2813 fn stored_effect_hash_tracks_local_effect_only() {
2814 let base = BuildNodeSnapshot {
2815 node_id: 1,
2816 placement: Point::default(),
2817 size: Size {
2818 width: 80.0,
2819 height: 50.0,
2820 },
2821 content_offset: Point::default(),
2822 motion_context_animated: false,
2823 translated_content_context: false,
2824 measured_max_width: None,
2825 resolved_modifiers: ResolvedModifiers::default(),
2826 draw_commands: vec![],
2827 click_actions: vec![],
2828 pointer_inputs: vec![],
2829 clip_to_bounds: false,
2830 annotated_text: None,
2831 text_style: None,
2832 text_layout_options: None,
2833 text_pan: None,
2834 graphics_layer: None,
2835 children: vec![],
2836 };
2837 let mut effected = base.clone();
2838 effected.graphics_layer = Some(GraphicsLayer {
2839 render_effect: Some(cranpose_ui_graphics::RenderEffect::blur(6.0)),
2840 ..GraphicsLayer::default()
2841 });
2842
2843 let base_graph = build_layer_node_for_test(base, 1.0, false);
2844 let effected_graph = build_layer_node_for_test(effected, 1.0, false);
2845
2846 assert_eq!(
2847 base_graph.target_content_hash(),
2848 effected_graph.target_content_hash(),
2849 "post-processing effect parameters belong to the effect hash, not the content hash"
2850 );
2851 assert_ne!(base_graph.effect_hash(), effected_graph.effect_hash());
2852 }
2853
2854 #[test]
2855 fn text_node_preserves_rtl_alignment_clip_and_baseline_shift() {
2856 let mut text_style = TextStyle::default();
2857 text_style.paragraph_style.text_align = TextAlign::Start;
2858 text_style.paragraph_style.text_direction = TextDirection::Rtl;
2859 text_style.span_style.baseline_shift = Some(BaselineShift::SUPERSCRIPT);
2860
2861 let snapshot = BuildNodeSnapshot {
2862 node_id: 1,
2863 placement: Point::default(),
2864 size: Size {
2865 width: 180.0,
2866 height: 48.0,
2867 },
2868 content_offset: Point::default(),
2869 motion_context_animated: false,
2870 translated_content_context: false,
2871 measured_max_width: Some(180.0),
2872 resolved_modifiers: ResolvedModifiers::default(),
2873 draw_commands: vec![],
2874 click_actions: vec![],
2875 pointer_inputs: vec![],
2876 clip_to_bounds: false,
2877 annotated_text: Some(AnnotatedString::from("rtl")),
2878 text_style: Some(text_style),
2879 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2880 overflow: cranpose_ui::TextOverflow::Clip,
2881 ..Default::default()
2882 }),
2883 text_pan: None,
2884 graphics_layer: None,
2885 children: vec![],
2886 };
2887
2888 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2889 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2890 panic!("expected text primitive");
2891 };
2892 let PrimitiveNode::Text(text) = &text_primitive.node else {
2893 panic!("expected text primitive");
2894 };
2895 let clip = text
2896 .clip
2897 .expect("clipped overflow should produce a clip rect");
2898
2899 assert!(
2900 text.rect.x > 0.0,
2901 "RTL start alignment should shift the text rect within the available width"
2902 );
2903 assert!(
2904 clip.y < text.rect.y,
2905 "baseline shift must expand the clip upward so superscript glyphs are preserved"
2906 );
2907 assert!(
2908 clip.intersect(text.rect).is_some(),
2909 "the clip rect must intersect the shifted text draw rect"
2910 );
2911 }
2912
2913 #[test]
2914 fn clipped_text_node_raster_bounds_use_measured_text_width_not_full_box() {
2915 let snapshot = BuildNodeSnapshot {
2916 node_id: 1,
2917 placement: Point::default(),
2918 size: Size {
2919 width: 320.0,
2920 height: 48.0,
2921 },
2922 content_offset: Point::default(),
2923 motion_context_animated: false,
2924 translated_content_context: false,
2925 measured_max_width: Some(320.0),
2926 resolved_modifiers: ResolvedModifiers::default(),
2927 draw_commands: vec![],
2928 click_actions: vec![],
2929 pointer_inputs: vec![],
2930 clip_to_bounds: false,
2931 annotated_text: Some(AnnotatedString::from("short")),
2932 text_style: Some(TextStyle::default()),
2933 text_layout_options: Some(cranpose_ui::TextLayoutOptions {
2934 overflow: cranpose_ui::TextOverflow::Clip,
2935 ..Default::default()
2936 }),
2937 text_pan: None,
2938 graphics_layer: None,
2939 children: vec![],
2940 };
2941
2942 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2943 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2944 panic!("expected text primitive");
2945 };
2946 let PrimitiveNode::Text(text) = &text_primitive.node else {
2947 panic!("expected text primitive");
2948 };
2949 let clip = text.clip.expect("clipped text should keep a clip rect");
2950
2951 assert!(
2952 text.rect.width < 320.0,
2953 "text raster bounds should track measured glyph width instead of full content width"
2954 );
2955 assert_eq!(
2956 clip.width, 322.0,
2957 "text clip should still preserve the full content box plus clip padding"
2958 );
2959 }
2960
2961 #[test]
2965 fn text_field_pan_shifts_glyphs_and_clips_to_field_bounds() {
2966 let pan_offset = 25.0_f32;
2967 let field_width = 80.0_f32;
2968 let resolved_viewports = Rc::new(std::cell::RefCell::new(Vec::new()));
2969 let viewports = resolved_viewports.clone();
2970 let make_snapshot = |text_pan: Option<cranpose_ui::TextPanResolver>| BuildNodeSnapshot {
2971 node_id: 1,
2972 placement: Point::default(),
2973 size: Size {
2974 width: field_width,
2975 height: 24.0,
2976 },
2977 content_offset: Point::default(),
2978 motion_context_animated: false,
2979 translated_content_context: false,
2980 measured_max_width: Some(field_width),
2981 resolved_modifiers: ResolvedModifiers::default(),
2982 draw_commands: vec![],
2983 click_actions: vec![],
2984 pointer_inputs: vec![],
2985 clip_to_bounds: false,
2986 annotated_text: Some(AnnotatedString::from(
2987 "a very long single line of text that cannot fit",
2988 )),
2989 text_style: Some(TextStyle::default()),
2990 text_layout_options: Some(cranpose_ui::TextLayoutOptions::default()),
2991 text_pan,
2992 graphics_layer: None,
2993 children: vec![],
2994 };
2995
2996 let text_node = |snapshot: BuildNodeSnapshot| {
2997 let graph = build_layer_node_for_test(snapshot, 1.0, false);
2998 let RenderNode::Primitive(text_primitive) = &graph.children[0] else {
2999 panic!("expected text primitive");
3000 };
3001 let PrimitiveNode::Text(text) = &text_primitive.node else {
3002 panic!("expected text primitive");
3003 };
3004 (**text).clone()
3005 };
3006
3007 let unpanned = text_node(make_snapshot(None));
3008 let panned = text_node(make_snapshot(Some(Rc::new(move |viewport| {
3009 viewports.borrow_mut().push(viewport);
3010 pan_offset
3011 }))));
3012
3013 assert_eq!(
3014 resolved_viewports.borrow().as_slice(),
3015 &[field_width],
3016 "the pan resolver must receive the content viewport width"
3017 );
3018 assert_eq!(
3019 panned.rect.x, -pan_offset,
3020 "text glyphs must shift left by the pan offset"
3021 );
3022 assert!(
3023 panned.rect.width > field_width,
3024 "panned single-line text must be laid out unconstrained, got {}",
3025 panned.rect.width
3026 );
3027 assert!(
3028 panned.rect.width >= unpanned.rect.width,
3029 "unconstrained layout must not be narrower than wrapped layout"
3030 );
3031 assert!(
3032 panned.rect.height <= unpanned.rect.height,
3033 "single-line layout must not wrap onto extra lines"
3034 );
3035 let clip = panned
3036 .clip
3037 .expect("panned text field must clip to field bounds");
3038 assert!(
3039 clip.x + clip.width <= field_width + TEXT_CLIP_PAD + f32::EPSILON,
3040 "clip must not extend past the field bounds, got {clip:?}"
3041 );
3042 }
3043
3044 #[test]
3045 fn translated_content_context_preserves_descendant_text_motion_when_unspecified() {
3046 let child = BuildNodeSnapshot {
3047 node_id: 2,
3048 placement: Point { x: 11.0, y: 7.0 },
3049 size: Size {
3050 width: 120.0,
3051 height: 32.0,
3052 },
3053 content_offset: Point::default(),
3054 motion_context_animated: false,
3055 translated_content_context: false,
3056 measured_max_width: Some(120.0),
3057 resolved_modifiers: ResolvedModifiers::default(),
3058 draw_commands: vec![],
3059 click_actions: vec![],
3060 pointer_inputs: vec![],
3061 clip_to_bounds: false,
3062 annotated_text: Some(AnnotatedString::from("scrolling")),
3063 text_style: Some(TextStyle::default()),
3064 text_layout_options: None,
3065 text_pan: None,
3066 graphics_layer: None,
3067 children: vec![],
3068 };
3069 let parent = BuildNodeSnapshot {
3070 node_id: 1,
3071 placement: Point::default(),
3072 size: Size {
3073 width: 160.0,
3074 height: 64.0,
3075 },
3076 content_offset: Point { x: 0.0, y: -18.5 },
3077 motion_context_animated: false,
3078 translated_content_context: true,
3079 measured_max_width: None,
3080 resolved_modifiers: ResolvedModifiers::default(),
3081 draw_commands: vec![],
3082 click_actions: vec![],
3083 pointer_inputs: vec![],
3084 clip_to_bounds: false,
3085 annotated_text: None,
3086 text_style: None,
3087 text_layout_options: None,
3088 text_pan: None,
3089 graphics_layer: None,
3090 children: vec![child],
3091 };
3092
3093 let graph = build_layer_node_for_test(parent, 1.0, false);
3094 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3095 panic!("expected child layer");
3096 };
3097 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3098 panic!("expected text primitive");
3099 };
3100 let PrimitiveNode::Text(text) = &text_primitive.node else {
3101 panic!("expected text primitive");
3102 };
3103
3104 assert_eq!(text.text_style.paragraph_style.text_motion, None);
3105 assert!(!child_layer.motion_context_animated);
3106 }
3107
3108 #[test]
3109 fn content_offset_without_translated_context_keeps_descendant_text_unspecified() {
3110 let child = BuildNodeSnapshot {
3111 node_id: 2,
3112 placement: Point { x: 11.0, y: 7.0 },
3113 size: Size {
3114 width: 120.0,
3115 height: 32.0,
3116 },
3117 content_offset: Point::default(),
3118 motion_context_animated: false,
3119 translated_content_context: false,
3120 measured_max_width: Some(120.0),
3121 resolved_modifiers: ResolvedModifiers::default(),
3122 draw_commands: vec![],
3123 click_actions: vec![],
3124 pointer_inputs: vec![],
3125 clip_to_bounds: false,
3126 annotated_text: Some(AnnotatedString::from("scrolling")),
3127 text_style: Some(TextStyle::default()),
3128 text_layout_options: None,
3129 text_pan: None,
3130 graphics_layer: None,
3131 children: vec![],
3132 };
3133 let parent = BuildNodeSnapshot {
3134 node_id: 1,
3135 placement: Point::default(),
3136 size: Size {
3137 width: 160.0,
3138 height: 64.0,
3139 },
3140 content_offset: Point { x: 0.0, y: -18.0 },
3141 motion_context_animated: false,
3142 translated_content_context: false,
3143 measured_max_width: None,
3144 resolved_modifiers: ResolvedModifiers::default(),
3145 draw_commands: vec![],
3146 click_actions: vec![],
3147 pointer_inputs: vec![],
3148 clip_to_bounds: false,
3149 annotated_text: None,
3150 text_style: None,
3151 text_layout_options: None,
3152 text_pan: None,
3153 graphics_layer: None,
3154 children: vec![child],
3155 };
3156
3157 let graph = build_layer_node_for_test(parent, 1.0, false);
3158 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3159 panic!("expected child layer");
3160 };
3161 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3162 panic!("expected text primitive");
3163 };
3164 let PrimitiveNode::Text(text) = &text_primitive.node else {
3165 panic!("expected text primitive");
3166 };
3167
3168 assert_eq!(
3169 text.text_style.paragraph_style.text_motion, None,
3170 "content_offset alone must not force text onto the translated-content motion path"
3171 );
3172 assert!(!child_layer.motion_context_animated);
3173 }
3174
3175 #[test]
3176 fn translated_content_context_preserves_effectful_text_motion_when_unspecified() {
3177 let child = BuildNodeSnapshot {
3178 node_id: 2,
3179 placement: Point { x: 11.0, y: 7.0 },
3180 size: Size {
3181 width: 120.0,
3182 height: 32.0,
3183 },
3184 content_offset: Point::default(),
3185 motion_context_animated: false,
3186 translated_content_context: false,
3187 measured_max_width: Some(120.0),
3188 resolved_modifiers: ResolvedModifiers::default(),
3189 draw_commands: vec![],
3190 click_actions: vec![],
3191 pointer_inputs: vec![],
3192 clip_to_bounds: false,
3193 annotated_text: Some(AnnotatedString::from("shadow")),
3194 text_style: Some(TextStyle::from_span_style(SpanStyle {
3195 shadow: Some(cranpose_ui::text::Shadow {
3196 color: Color::BLACK,
3197 offset: Point::new(1.0, 2.0),
3198 blur_radius: 3.0,
3199 }),
3200 ..SpanStyle::default()
3201 })),
3202 text_layout_options: None,
3203 text_pan: None,
3204 graphics_layer: None,
3205 children: vec![],
3206 };
3207 let parent = BuildNodeSnapshot {
3208 node_id: 1,
3209 placement: Point::default(),
3210 size: Size {
3211 width: 160.0,
3212 height: 64.0,
3213 },
3214 content_offset: Point { x: 0.0, y: -18.5 },
3215 motion_context_animated: false,
3216 translated_content_context: true,
3217 measured_max_width: None,
3218 resolved_modifiers: ResolvedModifiers::default(),
3219 draw_commands: vec![],
3220 click_actions: vec![],
3221 pointer_inputs: vec![],
3222 clip_to_bounds: false,
3223 annotated_text: None,
3224 text_style: None,
3225 text_layout_options: None,
3226 text_pan: None,
3227 graphics_layer: None,
3228 children: vec![child],
3229 };
3230
3231 let graph = build_layer_node_for_test(parent, 1.0, false);
3232 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3233 panic!("expected child layer");
3234 };
3235 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3236 panic!("expected text primitive");
3237 };
3238 let PrimitiveNode::Text(text) = &text_primitive.node else {
3239 panic!("expected text primitive");
3240 };
3241
3242 assert_eq!(text.text_style.paragraph_style.text_motion, None);
3243 }
3244
3245 #[test]
3246 fn animated_motion_marker_preserves_descendant_text_motion_when_unspecified() {
3247 let child = BuildNodeSnapshot {
3248 node_id: 2,
3249 placement: Point { x: 11.0, y: 7.0 },
3250 size: Size {
3251 width: 120.0,
3252 height: 32.0,
3253 },
3254 content_offset: Point::default(),
3255 motion_context_animated: false,
3256 translated_content_context: false,
3257 measured_max_width: Some(120.0),
3258 resolved_modifiers: ResolvedModifiers::default(),
3259 draw_commands: vec![],
3260 click_actions: vec![],
3261 pointer_inputs: vec![],
3262 clip_to_bounds: false,
3263 annotated_text: Some(AnnotatedString::from("lazy")),
3264 text_style: Some(TextStyle::default()),
3265 text_layout_options: None,
3266 text_pan: None,
3267 graphics_layer: None,
3268 children: vec![],
3269 };
3270 let parent = BuildNodeSnapshot {
3271 node_id: 1,
3272 placement: Point::default(),
3273 size: Size {
3274 width: 160.0,
3275 height: 64.0,
3276 },
3277 content_offset: Point::default(),
3278 motion_context_animated: true,
3279 translated_content_context: false,
3280 measured_max_width: None,
3281 resolved_modifiers: ResolvedModifiers::default(),
3282 draw_commands: vec![],
3283 click_actions: vec![],
3284 pointer_inputs: vec![],
3285 clip_to_bounds: false,
3286 annotated_text: None,
3287 text_style: None,
3288 text_layout_options: None,
3289 text_pan: None,
3290 graphics_layer: None,
3291 children: vec![child],
3292 };
3293
3294 let graph = build_layer_node_for_test(parent, 1.0, false);
3295 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3296 panic!("expected child layer");
3297 };
3298 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3299 panic!("expected text primitive");
3300 };
3301 let PrimitiveNode::Text(text) = &text_primitive.node else {
3302 panic!("expected text primitive");
3303 };
3304
3305 assert_eq!(text.text_style.paragraph_style.text_motion, None);
3306 assert!(graph.motion_context_animated);
3307 assert!(child_layer.motion_context_animated);
3308 }
3309
3310 #[test]
3311 fn lazy_column_item_text_keeps_unspecified_motion_at_origin() {
3312 let mut composition = cranpose_ui::run_test_composition(|| {
3313 let list_state = remember_lazy_list_state();
3314 LazyColumn(
3315 Modifier::empty(),
3316 list_state,
3317 LazyColumnSpec::default(),
3318 |scope| {
3319 scope.item(Some(0), None, || {
3320 Text("LazyMotion", Modifier::empty(), TextStyle::default());
3321 });
3322 },
3323 );
3324 });
3325
3326 let root = composition.root().expect("lazy column root");
3327 let handle = composition.runtime_handle();
3328 let mut applier = composition.applier_mut();
3329 applier.set_runtime_handle(handle);
3330 let _ = applier
3331 .compute_layout(
3332 root,
3333 Size {
3334 width: 240.0,
3335 height: 240.0,
3336 },
3337 )
3338 .expect("lazy column layout");
3339 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3340 applier.clear_runtime_handle();
3341
3342 assert_eq!(find_text_motion(&graph.root, "LazyMotion"), Some(None));
3343 }
3344
3345 #[test]
3346 fn scrolled_lazy_column_item_text_keeps_unspecified_motion_at_rest() {
3347 use std::cell::RefCell;
3348 use std::rc::Rc;
3349
3350 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3351 let state_holder_for_comp = state_holder.clone();
3352 let mut composition = cranpose_ui::run_test_composition(move || {
3353 let list_state = remember_lazy_list_state();
3354 *state_holder_for_comp.borrow_mut() = Some(list_state);
3355 LazyColumn(
3356 Modifier::empty().height(120.0),
3357 list_state,
3358 LazyColumnSpec::default(),
3359 |scope| {
3360 scope.items(
3361 8,
3362 None::<fn(usize) -> u64>,
3363 None::<fn(usize) -> u64>,
3364 |index| {
3365 Text(
3366 format!("LazyMotion {index}"),
3367 Modifier::empty().padding(4.0),
3368 TextStyle::default(),
3369 );
3370 },
3371 );
3372 },
3373 );
3374 });
3375
3376 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3377 list_state.scroll_to_item(3, 0.0);
3378
3379 let root = composition.root().expect("lazy column root");
3380 let handle = composition.runtime_handle();
3381 let mut applier = composition.applier_mut();
3382 applier.set_runtime_handle(handle);
3383 let _ = applier
3384 .compute_layout(
3385 root,
3386 Size {
3387 width: 240.0,
3388 height: 240.0,
3389 },
3390 )
3391 .expect("lazy column layout");
3392 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3393 let active_children = applier
3394 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3395 .expect("lazy column should be subcompose");
3396 let child_debug: Vec<String> = active_children
3397 .iter()
3398 .map(|&child_id| {
3399 if let Ok(summary) = applier.with_node::<LayoutNode, _>(child_id, |node| {
3400 format!(
3401 "layout#{child_id} placed={} text={:?} children={:?}",
3402 node.layout_state().is_placed,
3403 node.modifier_slices_snapshot()
3404 .text_content()
3405 .map(str::to_string),
3406 node.children.clone()
3407 )
3408 }) {
3409 summary
3410 } else if let Ok(summary) =
3411 applier.with_node::<SubcomposeLayoutNode, _>(child_id, |node| {
3412 format!(
3413 "subcompose#{child_id} placed={} active_children={:?}",
3414 node.layout_state().is_placed,
3415 node.active_children()
3416 )
3417 })
3418 {
3419 summary
3420 } else {
3421 format!("missing#{child_id}")
3422 }
3423 })
3424 .collect();
3425 applier.clear_runtime_handle();
3426
3427 let first_index = list_state.first_visible_item_index();
3428 assert!(
3429 first_index > 0,
3430 "lazy list should move away from origin before graph building, observed first_index={first_index}"
3431 );
3432 let mut labels = Vec::new();
3433 collect_text_labels(&graph.root, &mut labels);
3434 assert_eq!(
3435 find_text_motion(&graph.root, &format!("LazyMotion {first_index}")),
3436 Some(None),
3437 "graph labels after scroll: {:?}, active_children={:?}, child_debug={:?}",
3438 labels,
3439 active_children,
3440 child_debug
3441 );
3442 }
3443
3444 #[test]
3445 fn scrolled_lazy_column_render_graph_keeps_beyond_bound_text_rows() {
3446 use std::cell::RefCell;
3447 use std::rc::Rc;
3448
3449 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3450 let state_holder_for_comp = state_holder.clone();
3451 let mut composition = cranpose_ui::run_test_composition(move || {
3452 let list_state = remember_lazy_list_state();
3453 *state_holder_for_comp.borrow_mut() = Some(list_state);
3454 let mut spec =
3455 LazyColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(6.0));
3456 spec.beyond_bounds_item_count = 0;
3457 LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
3458 scope.items(
3459 12,
3460 None::<fn(usize) -> u64>,
3461 None::<fn(usize) -> u64>,
3462 |index| {
3463 Text(
3464 format!("WarmRow {index}"),
3465 Modifier::empty().height(32.0),
3466 TextStyle::default(),
3467 );
3468 },
3469 );
3470 });
3471 });
3472
3473 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3474 list_state.scroll_to_item(4, 0.0);
3475
3476 let root = composition.root().expect("lazy column root");
3477 let handle = composition.runtime_handle();
3478 let mut applier = composition.applier_mut();
3479 applier.set_runtime_handle(handle);
3480 let _ = applier
3481 .compute_layout(
3482 root,
3483 Size {
3484 width: 240.0,
3485 height: 240.0,
3486 },
3487 )
3488 .expect("lazy column layout");
3489 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3490 let active_children = applier
3491 .with_node::<SubcomposeLayoutNode, _>(root, |node| node.active_children())
3492 .expect("lazy column should be subcompose");
3493 applier.clear_runtime_handle();
3494
3495 let visible_indices: Vec<_> = list_state
3496 .layout_info()
3497 .visible_items_info
3498 .iter()
3499 .map(|item| item.index)
3500 .collect();
3501 let mut labels = Vec::new();
3502 collect_text_labels(&graph.root, &mut labels);
3503
3504 assert_eq!(
3505 visible_indices,
3506 vec![4, 5, 6],
3507 "test setup expects exactly three viewport-visible rows"
3508 );
3509 assert!(
3510 labels.iter().any(|label| label == "WarmRow 7"),
3511 "render graph must retain at least one after-bound text row for glyph prewarm; labels={labels:?}, active_children={active_children:?}"
3512 );
3513 }
3514
3515 #[test]
3516 fn scrolled_lazy_column_uses_visible_item_offset_as_snap_anchor_offset() {
3517 use std::cell::RefCell;
3518 use std::rc::Rc;
3519
3520 let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
3521 let state_holder_for_comp = state_holder.clone();
3522 let mut composition = cranpose_ui::run_test_composition(move || {
3523 let list_state = remember_lazy_list_state();
3524 *state_holder_for_comp.borrow_mut() = Some(list_state);
3525 LazyColumn(
3526 Modifier::empty().height(120.0),
3527 list_state,
3528 LazyColumnSpec::default(),
3529 |scope| {
3530 scope.items(
3531 8,
3532 None::<fn(usize) -> u64>,
3533 None::<fn(usize) -> u64>,
3534 |index| {
3535 Text(
3536 format!("LazySnap {index}"),
3537 Modifier::empty().padding(4.0),
3538 TextStyle::default(),
3539 );
3540 },
3541 );
3542 },
3543 );
3544 });
3545
3546 let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
3547 list_state.scroll_to_item(2, 7.5);
3548
3549 let root = composition.root().expect("lazy column root");
3550 let handle = composition.runtime_handle();
3551 let mut applier = composition.applier_mut();
3552 applier.set_runtime_handle(handle);
3553 let _ = applier
3554 .compute_layout(
3555 root,
3556 Size {
3557 width: 240.0,
3558 height: 240.0,
3559 },
3560 )
3561 .expect("lazy column layout");
3562 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
3563 applier.clear_runtime_handle();
3564
3565 let layout_info = list_state.layout_info();
3566 let first_visible_offset = layout_info
3567 .visible_items_info
3568 .first()
3569 .expect("lazy layout should expose visible item info")
3570 .offset;
3571 let snap_offset = find_translated_content_offset(&graph.root)
3572 .expect("lazy list graph should include translated content context");
3573
3574 assert!(
3575 (snap_offset.y - first_visible_offset).abs() <= 0.001,
3576 "lazy snap offset must follow the visible content origin; snap_offset={snap_offset:?} first_visible_offset={first_visible_offset}"
3577 );
3578 }
3579
3580 #[test]
3581 fn explicit_static_text_motion_is_preserved_under_scrolling_context() {
3582 let child = BuildNodeSnapshot {
3583 node_id: 2,
3584 placement: Point { x: 11.0, y: 7.0 },
3585 size: Size {
3586 width: 120.0,
3587 height: 32.0,
3588 },
3589 content_offset: Point::default(),
3590 motion_context_animated: false,
3591 translated_content_context: false,
3592 measured_max_width: Some(120.0),
3593 resolved_modifiers: ResolvedModifiers::default(),
3594 draw_commands: vec![],
3595 click_actions: vec![],
3596 pointer_inputs: vec![],
3597 clip_to_bounds: false,
3598 annotated_text: Some(AnnotatedString::from("static")),
3599 text_style: Some(TextStyle::from_paragraph_style(
3600 cranpose_ui::text::ParagraphStyle {
3601 text_motion: Some(TextMotion::Static),
3602 ..Default::default()
3603 },
3604 )),
3605 text_layout_options: None,
3606 text_pan: None,
3607 graphics_layer: None,
3608 children: vec![],
3609 };
3610 let parent = BuildNodeSnapshot {
3611 node_id: 1,
3612 placement: Point::default(),
3613 size: Size {
3614 width: 160.0,
3615 height: 64.0,
3616 },
3617 content_offset: Point { x: 0.0, y: -18.5 },
3618 motion_context_animated: false,
3619 translated_content_context: true,
3620 measured_max_width: None,
3621 resolved_modifiers: ResolvedModifiers::default(),
3622 draw_commands: vec![],
3623 click_actions: vec![],
3624 pointer_inputs: vec![],
3625 clip_to_bounds: false,
3626 annotated_text: None,
3627 text_style: None,
3628 text_layout_options: None,
3629 text_pan: None,
3630 graphics_layer: None,
3631 children: vec![child],
3632 };
3633
3634 let graph = build_layer_node_for_test(parent, 1.0, false);
3635 let RenderNode::Layer(child_layer) = &graph.children[0] else {
3636 panic!("expected child layer");
3637 };
3638 let RenderNode::Primitive(text_primitive) = &child_layer.children[0] else {
3639 panic!("expected text primitive");
3640 };
3641 let PrimitiveNode::Text(text) = &text_primitive.node else {
3642 panic!("expected text primitive");
3643 };
3644
3645 assert_eq!(
3646 text.text_style.paragraph_style.text_motion,
3647 Some(TextMotion::Static),
3648 "explicit text motion must win over inherited scrolling motion context"
3649 );
3650 }
3651
3652 #[test]
3675 fn wrapped_paragraph_paints_the_height_it_measured() {
3676 const BODY: &str = "fed back картица scored fp32 износ once paper fed Vision dropped \
3677 fed widest the strip mask prompt mask threshold Vision on датум instance mask \
3678 износ Apple";
3679 const FOLLOWING: &str = "FOLLOWING SIBLING";
3680
3681 let app_context = cranpose_ui::AppContext::new();
3682 app_context.enter(|| {
3683 cranpose_ui::text::set_text_measurer(
3684 crate::software_text_raster::SoftwareTextMeasurer::from_fonts_or_default(&[], 8192),
3685 );
3686 let mut composition = cranpose_ui::run_test_composition(move || {
3687 Column(
3688 Modifier::empty().fill_max_width(),
3689 ColumnSpec::new().vertical_arrangement(LinearArrangement::SpacedBy(8.0)),
3690 move || {
3691 Text(BODY.to_string(), Modifier::empty(), TextStyle::default());
3692 Text(
3693 FOLLOWING.to_string(),
3694 Modifier::empty(),
3695 TextStyle::default(),
3696 );
3697 },
3698 );
3699 });
3700
3701 let root = composition.root().expect("composition root");
3702 let handle = composition.runtime_handle();
3703 let mut applier = composition.applier_mut();
3704 applier.set_runtime_handle(handle);
3705 let layout = applier
3706 .compute_layout(
3707 root,
3708 Size {
3709 width: 245.0,
3710 height: 900.0,
3711 },
3712 )
3713 .expect("layout");
3714
3715 fn find_box<'a>(node: &'a LayoutBox, value: &str) -> Option<&'a LayoutBox> {
3716 if node
3717 .node_data
3718 .modifier_slices()
3719 .text_content()
3720 .is_some_and(|text| text == value)
3721 {
3722 return Some(node);
3723 }
3724 node.children
3725 .iter()
3726 .find_map(|child| find_box(child, value))
3727 }
3728 let body_box = find_box(layout.root(), BODY).expect("measured paragraph box");
3729 let following_box = find_box(layout.root(), FOLLOWING).expect("measured sibling box");
3730 let measured_height = body_box.rect.height;
3731 let following_top = following_box.rect.y;
3732 assert!(
3733 measured_height > 60.0,
3734 "test setup expects a genuinely multi-line paragraph, got {measured_height}"
3735 );
3736 assert!(
3737 body_box.rect.width < 245.0,
3738 "test setup expects the node to be placed at its own measured width, \
3739 not the full constraint, got {}",
3740 body_box.rect.width
3741 );
3742
3743 let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("render graph");
3744 applier.clear_runtime_handle();
3745
3746 fn squashed(value: &str) -> String {
3749 value.chars().filter(|c| !c.is_whitespace()).collect()
3750 }
3751 fn find_text<'a>(layer: &'a LayerNode, value: &str) -> Option<&'a TextPrimitiveNode> {
3752 for child in &layer.children {
3753 match child {
3754 RenderNode::Primitive(primitive) => {
3755 if let PrimitiveNode::Text(text) = &primitive.node {
3756 if squashed(&text.text.text) == squashed(value) {
3757 return Some(text);
3758 }
3759 }
3760 }
3761 RenderNode::Layer(child_layer) => {
3762 if let Some(found) = find_text(child_layer, value) {
3763 return Some(found);
3764 }
3765 }
3766 RenderNode::DrawRun(_) => {}
3767 }
3768 }
3769 None
3770 }
3771 let painted = find_text(&graph.root, BODY).expect("painted paragraph");
3772
3773 assert!(
3774 (painted.rect.height - measured_height).abs() < 0.5,
3775 "paragraph painted {:.2} tall into a box layout measured at {:.2} \
3776 (painted rect {:?})",
3777 painted.rect.height,
3778 measured_height,
3779 painted.rect
3780 );
3781 assert!(
3782 painted.rect.y + painted.rect.height <= following_top + 0.5,
3783 "painted paragraph bottom {:.2} runs past the following sibling placed at \
3784 {:.2}",
3785 painted.rect.y + painted.rect.height,
3786 following_top
3787 );
3788 });
3789 }
3790
3791 #[test]
3792 fn retained_slot_confirmations_are_live_only_under_their_generation() {
3793 let command = DrawCommandId {
3794 node_id: 990_101,
3795 command_index: 0,
3796 placement: DrawPlacement::Behind,
3797 };
3798 set_retained_feed_epoch(Some(7));
3799 confirm_retained_slot(command, 3, 7);
3800 assert!(retained_slot_confirmed(command, 3));
3801 set_retained_feed_epoch(Some(8));
3803 assert!(!retained_slot_confirmed(command, 3));
3804 set_retained_feed_epoch(None);
3806 assert!(!retained_slot_confirmed(command, 3));
3807 set_retained_feed_epoch(Some(7));
3809 assert!(retained_slot_confirmed(command, 3));
3810 revoke_retained_slot(command, 3);
3811 assert!(!retained_slot_confirmed(command, 3));
3812 set_retained_feed_epoch(None);
3813 clear_retained_slot_confirmations();
3814 }
3815
3816 fn record_sweep_test_rings(scope: &mut DrawScopeDefault) {
3822 let count = 600usize;
3823 let sweep = std::f32::consts::TAU / count as f32 * 0.8;
3824 for i in 0..count {
3825 let start = i as f32 * (std::f32::consts::TAU / count as f32);
3826 scope.draw_annular_sector(
3827 Brush::solid(cranpose_ui_graphics::Color(0.2, 0.4, 0.6, 1.0)),
3828 cranpose_ui_graphics::Point::new(204.0, 204.0),
3829 140.0,
3830 150.0,
3831 start,
3832 sweep,
3833 );
3834 }
3835 }
3836
3837 #[test]
3845 fn recording_sweep_cannot_sever_a_frames_fallback() {
3846 let command = DrawCommandId {
3847 node_id: 990_102,
3848 command_index: 0,
3849 placement: DrawPlacement::Behind,
3850 };
3851 set_retained_feed_epoch(Some(41));
3852 for slot in 0..64 {
3853 confirm_retained_slot(command, slot, 41);
3854 }
3855
3856 let mut state = cranpose_ui_graphics::CommandReplayState::default();
3861 let mut published = None;
3862 for _frame in 0..4 {
3863 let (recording, storage, _) = acquire_recording(command);
3864 let mut scope = DrawScopeDefault::with_recording(
3865 cranpose_ui_graphics::Size::new(408.0, 408.0),
3866 None,
3867 recording,
3868 storage,
3869 );
3870 record_sweep_test_rings(&mut scope);
3871 let outcome = state.advance(scope.recorded());
3872 let center = state.center();
3873 let (finished, frame) = scope.finish_replay(center, outcome, &mut |slot| {
3874 retained_slot_confirmed(command, slot)
3875 });
3876 let (primitives, fallback) =
3877 publish_recording(command, finished.recording, finished.primitives, None);
3878 let frame = frame.map(|mut frame| {
3879 frame.fallback = Some(fallback.clone());
3880 frame
3881 });
3882 published = Some((primitives, fallback, frame));
3883 }
3884 let (_primitives, fallback, frame) = published.expect("four frames published");
3885 let frame = frame.expect("the replay must produce a frame with retained spans");
3886 let bypassed: Vec<(u32, u32)> = frame
3887 .spans
3888 .iter()
3889 .filter_map(|span| match span {
3890 cranpose_ui_graphics::FrameSpan::Retained {
3891 capture: false,
3892 range,
3893 tape_range,
3894 ..
3895 } if range.1 <= range.0 => Some(*tape_range),
3896 _ => None,
3897 })
3898 .collect();
3899 assert!(
3900 !bypassed.is_empty(),
3901 "confirmed slots must actually have bypassed materialization"
3902 );
3903 let expected: Vec<Vec<DrawPrimitive>> = bypassed
3904 .iter()
3905 .map(|tape_range| {
3906 fallback
3907 .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
3908 .expect("a frame-consistent tape range must materialize")
3909 })
3910 .collect();
3911
3912 for _ in 0..1024 {
3917 bump_recording_generation();
3918 }
3919 assert!(
3920 COMMAND_RECORDINGS.with(|map| !map.borrow().contains_key(&command)),
3921 "the sweep must stay pure capacity management: a live confirmation \
3922 no longer pins the registry slot"
3923 );
3924
3925 for (tape_range, expected) in bypassed.iter().zip(&expected) {
3928 let after = fallback
3929 .materialize_range(tape_range.0 as usize, tape_range.1 as usize)
3930 .expect("the frame-owned recording must outlive the sweep");
3931 assert_eq!(
3932 &after, expected,
3933 "post-sweep rematerialization must be byte-identical"
3934 );
3935 }
3936 set_retained_feed_epoch(None);
3937 clear_retained_slot_confirmations();
3938 }
3939
3940 #[test]
3948 fn command_recordings_reuse_recording_buffers_across_rebuilds() {
3949 let command = DrawCommandId {
3950 node_id: 990_103,
3951 command_index: 0,
3952 placement: DrawPlacement::Behind,
3953 };
3954 let mut held = None;
3957 let mut ptrs = Vec::new();
3958 for _build in 0..8 {
3959 let (recording, storage, _) = acquire_recording(command);
3960 let mut scope = DrawScopeDefault::with_recording(
3961 cranpose_ui_graphics::Size::new(64.0, 64.0),
3962 None,
3963 recording,
3964 storage,
3965 );
3966 scope.draw_rect_at(
3967 Rect {
3968 x: 4.0,
3969 y: 4.0,
3970 width: 16.0,
3971 height: 8.0,
3972 },
3973 Brush::solid(Color::WHITE),
3974 );
3975 let finished = scope.finish();
3976 let (primitives, recording) =
3977 publish_recording(command, finished.recording, finished.primitives, None);
3978 ptrs.push(recording.tape_ptr());
3979 held = Some((primitives, recording));
3980 }
3981 drop(held);
3982 for build in 2..8 {
3986 assert_eq!(
3987 ptrs[build],
3988 ptrs[build - 2],
3989 "steady-state publishes must ping-pong between the pair's \
3990 buffers (build {build} allocated)"
3991 );
3992 }
3993 assert_ne!(
3994 ptrs[6], ptrs[7],
3995 "a recording a live frame still shares must never be recorded into"
3996 );
3997 }
3998}