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