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