1use bevy::platform::collections::{HashMap, HashSet};
43use bevy::prelude::*;
44use bevy::ui::{ComputedNode, UiGlobalTransform};
45
46use crate::protocol::{NodeId, animatable::AnimatableField, props::Props};
47
48pub mod clip;
49pub mod pick3d;
50pub mod render;
51pub mod transform3d;
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct PromotionReasons(pub u32);
57
58impl PromotionReasons {
59 pub const OPACITY: u32 = 1 << 0;
61 pub const FILTER: u32 = 1 << 2;
67 pub const FORCED: u32 = 1 << 4;
72 pub const TRANSFORM3D: u32 = 1 << 1;
79 pub const BACKDROP: u32 = 1 << 3;
85 pub const MORPH: u32 = 1 << 5;
91
92 pub fn is_empty(self) -> bool {
93 self.0 == 0
94 }
95}
96
97#[derive(Component, Debug, Clone, Copy)]
100pub struct PromotedLayer {
101 pub reasons: PromotionReasons,
102}
103
104#[derive(Component, Debug, Clone, Copy, PartialEq)]
108pub struct LayerGroupAlpha(pub f32);
109
110#[derive(Component, Debug, Clone, Copy, PartialEq)]
127pub struct LayerCaptureRect {
128 pub min: Vec2,
132 pub size: UVec2,
134 pub outset: u32,
141}
142
143#[derive(Resource, Debug, Default)]
151pub struct LayerMembership {
152 pub node_to_layer: HashMap<Entity, Entity>,
154 pub enclosing: HashMap<Entity, Option<Entity>>,
158}
159
160#[derive(Debug, Clone, Copy)]
165pub struct LayerMeta {
166 pub node: NodeId,
167 pub entity: Entity,
168 pub reasons: PromotionReasons,
169 pub group_alpha: f32,
171 pub capture_rect: Option<IRect>,
179 pub depth: u32,
181 pub repaints: u64,
183 pub cached: bool,
185 pub cache_policy: crate::protocol::style::LayerCache,
190}
191
192#[derive(Resource, Debug, Default)]
197pub struct LayersRegistry {
198 pub layers: HashMap<NodeId, LayerMeta>,
199}
200
201#[derive(Resource, Debug, Default)]
207pub struct LayerContentDirt {
208 pub nodes: Vec<Entity>,
211 pub composite_only: Vec<Entity>,
217}
218
219#[derive(Resource, Debug, Default)]
224pub struct LayerRepaintState {
225 pub dirty: HashSet<Entity>,
227 pub geo_hashes: HashMap<Entity, u64>,
230 prev_hashes: HashMap<Entity, u64>,
231}
232
233pub fn mark_content_dirty(ec: &mut EntityCommands) {
239 ec.queue(|mut e: bevy::ecs::world::EntityWorldMut| {
240 let id = e.id();
241 e.world_scope(|w| {
242 if let Some(mut dirt) = w.get_resource_mut::<LayerContentDirt>() {
243 dirt.nodes.push(id);
244 }
245 });
246 });
247}
248
249pub fn promotion_reasons(
273 props: &Props,
274 child_count: usize,
275 ineligible_element: bool,
276) -> PromotionReasons {
277 let opacity_present = props.all_styles().any(|s| s.opacity.is_some());
281 let group_gate = props.style.as_ref().and_then(|s| s.group_alpha) != Some(false);
282
283 let mut reasons = 0;
284 if opacity_present && group_gate && child_count >= 1 && !ineligible_element {
285 reasons |= PromotionReasons::OPACITY;
286 }
287 let forced = matches!(
293 props.style.as_ref().and_then(|s| s.cache),
294 Some(
295 crate::protocol::style::LayerCache::Always | crate::protocol::style::LayerCache::Never
296 )
297 );
298 if forced && !ineligible_element {
299 reasons |= PromotionReasons::FORCED;
300 }
301 let filtered = props
311 .all_styles()
312 .any(|s| s.filter.as_ref().is_some_and(|chain| !chain.0.is_empty()));
313 if filtered && !ineligible_element {
314 reasons |= PromotionReasons::FILTER;
315 }
316 let transformed3d = props.all_styles().any(|s| s.transform3d.is_some());
320 if transformed3d && !ineligible_element {
321 reasons |= PromotionReasons::TRANSFORM3D;
322 }
323 let backdrop = props
328 .all_styles()
329 .any(|s| s.backdrop_filter.as_ref().is_some_and(|c| !c.0.is_empty()));
330 if backdrop && !ineligible_element {
331 reasons |= PromotionReasons::BACKDROP;
332 }
333 let morph = props.all_styles().any(|s| s.morph_filter.is_some());
338 if morph && !ineligible_element {
339 reasons |= PromotionReasons::MORPH;
340 }
341 PromotionReasons(reasons)
342}
343
344pub fn evaluate_layer_promotions(
351 mut commands: Commands,
352 mut bridge: ResMut<crate::bridge::JsBridge>,
353 mut registry: ResMut<LayersRegistry>,
354 assets: Res<AssetServer>,
355 mut ui_assets: crate::reconcile::UiAssets,
356 mut style_variants: Query<&mut crate::bridge::StyleVariants>,
357) {
358 registry
361 .layers
362 .retain(|id, meta| bridge.nodes.get(id) == Some(&meta.entity));
363
364 if bridge.layer_dirty.is_empty() {
365 return;
366 }
367 let dirty: Vec<NodeId> = bridge.layer_dirty.drain().collect();
368 for id in dirty {
369 let Some(&entity) = bridge.nodes.get(&id) else {
370 continue; };
372 let reasons = match bridge.props_cache.get(&id) {
373 Some(props) => promotion_reasons(
374 props,
375 bridge.children_of(id).count(),
376 bridge.text_styles.contains_key(&id) || bridge.is_detached_root(id),
380 ),
381 None => PromotionReasons::default(),
382 };
383 let was_promoted = bridge.promoted_layers.contains(&id);
384 if !reasons.is_empty() {
385 let alpha = bridge
388 .props_cache
389 .get(&id)
390 .and_then(|p| p.style.as_ref())
391 .and_then(|s| s.opacity.static_val())
392 .unwrap_or(1.0);
393 let cache_policy = bridge
394 .props_cache
395 .get(&id)
396 .and_then(|p| p.style.as_ref())
397 .and_then(|s| s.cache)
398 .unwrap_or_default();
399 commands
400 .entity(entity)
401 .insert((PromotedLayer { reasons }, LayerGroupAlpha(alpha)));
402 bridge.promoted_layers.insert(id);
403 let row = registry.layers.entry(id).or_insert(LayerMeta {
406 node: id,
407 entity,
408 reasons,
409 group_alpha: alpha,
410 capture_rect: None,
411 depth: 1,
412 repaints: 0,
413 cached: false,
414 cache_policy,
415 });
416 row.entity = entity;
417 row.reasons = reasons;
418 row.group_alpha = alpha;
419 row.cache_policy = cache_policy;
420 if !was_promoted && let Some(props) = bridge.props_cache.get(&id) {
421 crate::reconcile::reapply_opacity_outputs(
424 &mut commands,
425 entity,
426 props,
427 true,
428 bridge.foreign_images.contains(&id),
429 &assets,
430 &mut ui_assets,
431 &mut style_variants,
432 );
433 }
434 } else if was_promoted {
435 commands.entity(entity).remove::<(
439 PromotedLayer,
440 LayerGroupAlpha,
441 LayerCaptureRect,
442 crate::filters::ResolvedFilterChain,
443 crate::filters::ResolvedBackdropChain,
444 crate::filters::ResolvedMorphChain,
445 crate::filters::MorphState,
446 transform3d::LayerTransform3d,
447 transform3d::LayerTransform3dMatrix,
448 )>();
449 bridge.promoted_layers.remove(&id);
450 registry.layers.remove(&id);
451 if let Some(props) = bridge.props_cache.get(&id) {
452 crate::reconcile::reapply_opacity_outputs(
454 &mut commands,
455 entity,
456 props,
457 false,
458 bridge.foreign_images.contains(&id),
459 &assets,
460 &mut ui_assets,
461 &mut style_variants,
462 );
463 }
464 }
465 }
466}
467
468#[allow(clippy::too_many_arguments, clippy::type_complexity)]
478pub fn sync_layer_geometry(
479 mut commands: Commands,
480 roots: Query<
481 (
482 Entity,
483 &ComputedNode,
484 &UiGlobalTransform,
485 &crate::bridge::RNode,
486 &LayerGroupAlpha,
487 Option<&crate::filters::ResolvedFilterChain>,
488 Option<&crate::filters::FilterInput>,
489 Option<&crate::filters::ResolvedBackdropChain>,
490 ),
491 With<PromotedLayer>,
492 >,
493 root_markers: Query<(), With<PromotedLayer>>,
494 children: Query<&Children>,
495 parents: Query<&ChildOf>,
496 existing_rects: Query<&LayerCaptureRect>,
497 geometry: Query<(&ComputedNode, &UiGlobalTransform)>,
498 mut membership: ResMut<LayerMembership>,
499 mut registry: ResMut<LayersRegistry>,
500 mut repaints: ResMut<LayerRepaintState>,
501 mut warned_bleeds: Local<HashMap<Entity, (LayerCaptureRect, LayerCaptureRect)>>,
506) {
507 membership.node_to_layer.clear();
508 membership.enclosing.clear();
509 repaints.geo_hashes.clear();
512 let mut frame_rects: HashMap<Entity, LayerCaptureRect> = HashMap::default();
516 let mut bleed_candidates: Vec<(Entity, NodeId, u32, String)> = Vec::new();
520 for (root, computed, transform, rnode, alpha, chain, filter_input, backdrop_chain) in &roots {
521 let row = registry.layers.get_mut(&rnode.0);
522 if let Some(row) = &row {
523 debug_assert_eq!(row.entity, root);
524 }
525 let size = computed.size();
526 if size.x <= 0.5 || size.y <= 0.5 {
527 if let Some(row) = row {
531 row.capture_rect = None;
532 }
533 continue;
534 }
535 let min = transform.translation - size * 0.5;
539 let mut rect = LayerCaptureRect {
540 min,
541 size: UVec2::new(size.x.ceil() as u32, size.y.ceil() as u32),
542 outset: 0,
543 };
544 if rect.size.x == 0 || rect.size.y == 0 {
545 if let Some(row) = row {
546 row.capture_rect = None;
547 }
548 continue;
549 }
550 let content_outset = chain.map_or(0, |c| crate::filters::quantize_outset(c.outset_px));
560 let backdrop_outset =
561 backdrop_chain.map_or(0, |c| crate::filters::quantize_outset(c.0.outset_px));
562 let outset = content_outset.max(backdrop_outset);
563 if outset > 0 {
564 rect.min -= Vec2::splat(outset as f32);
565 rect.size += UVec2::splat(2 * outset);
566 rect.outset = outset;
567 }
568 if content_outset > 0 {
573 let value = filter_input
574 .and_then(|i| i.0.0.first())
575 .map_or_else(|| "filter".to_owned(), |u| u.name.clone());
576 bleed_candidates.push((root, rnode.0, content_outset, value));
577 }
578 frame_rects.insert(root, rect);
579 if existing_rects.get(root) != Ok(&rect) {
580 commands.entity(root).insert(rect);
581 }
582 if let Some(row) = row {
589 let display_min = IVec2::new(rect.min.x.round() as i32, rect.min.y.round() as i32);
590 row.capture_rect = Some(IRect::from_corners(
591 display_min,
592 display_min + rect.size.as_ivec2(),
593 ));
594 row.group_alpha = alpha.0;
595 }
596 let mut hash = GEO_HASH_SEED;
605 fold_geo_i32(&mut hash, rect.size.x as i32);
609 fold_geo_i32(&mut hash, rect.size.y as i32);
610 mark_subtree(
611 root,
612 root,
613 root,
614 transform.translation,
615 &children,
616 &root_markers,
617 &geometry,
618 &mut hash,
619 &mut membership.node_to_layer,
620 );
621 repaints.geo_hashes.insert(root, hash);
622 let mut enclosing = None;
625 let mut depth = 1u32;
626 let mut cursor = root;
627 while let Ok(parent) = parents.get(cursor) {
628 cursor = parent.parent();
629 if root_markers.contains(cursor) {
630 if enclosing.is_none() {
631 enclosing = Some(cursor);
632 }
633 depth += 1;
634 }
635 }
636 membership.enclosing.insert(root, enclosing);
637 if let Some(row) = registry.layers.get_mut(&rnode.0) {
638 row.depth = depth;
639 }
640 }
641 let candidate_roots: HashSet<Entity> = bleed_candidates.iter().map(|(r, ..)| *r).collect();
654 warned_bleeds.retain(|e, _| candidate_roots.contains(e));
655 for (root, node, outset, value) in bleed_candidates {
656 let outer = match membership.enclosing.get(&root) {
657 Some(&Some(outer)) => outer,
658 _ => {
659 warned_bleeds.remove(&root);
660 continue;
661 }
662 };
663 let (Some(&inner_rect), Some(&outer_rect)) =
664 (frame_rects.get(&root), frame_rects.get(&outer))
665 else {
666 warned_bleeds.remove(&root);
667 continue;
668 };
669 let inner_max = inner_rect.min + inner_rect.size.as_vec2();
670 let outer_max = outer_rect.min + outer_rect.size.as_vec2();
671 let mut sides: Vec<&str> = Vec::new();
672 if inner_rect.min.x < outer_rect.min.x {
673 sides.push("left");
674 }
675 if inner_rect.min.y < outer_rect.min.y {
676 sides.push("top");
677 }
678 if inner_max.x > outer_max.x {
679 sides.push("right");
680 }
681 if inner_max.y > outer_max.y {
682 sides.push("bottom");
683 }
684 if sides.is_empty() {
685 warned_bleeds.remove(&root);
686 continue;
687 }
688 let pair = (inner_rect, outer_rect);
689 if warned_bleeds.get(&root) == Some(&pair) {
690 continue;
691 }
692 warned_bleeds.insert(root, pair);
693 let _scope = crate::diag::node_scope(node);
694 crate::diag::report(
695 "filterBleed",
696 &value,
697 &format!(
698 "filter outset ({outset}px) bleeds past the enclosing promoted layer's capture on the {} side and is clipped there — leave ≥{outset}px between this node and that ancestor's edge, or avoid nesting it under a promoted layer",
699 sides.join("/")
700 ),
701 );
702 }
703}
704
705#[allow(clippy::too_many_arguments)]
706fn mark_subtree(
707 node: Entity,
708 layer: Entity,
709 dfs_root: Entity,
710 root_translation: Vec2,
711 children: &Query<&Children>,
712 roots: &Query<(), With<PromotedLayer>>,
713 geometry: &Query<(&ComputedNode, &UiGlobalTransform)>,
714 hash: &mut u64,
715 map: &mut HashMap<Entity, Entity>,
716) {
717 if layer == dfs_root
723 && let Ok((computed, transform)) = geometry.get(node)
724 {
725 fold_member_geometry(hash, root_translation, transform, computed);
726 }
727 let layer = if roots.contains(node) { node } else { layer };
731 map.insert(node, layer);
732 if let Ok(kids) = children.get(node) {
733 for &kid in kids {
734 mark_subtree(
735 kid,
736 layer,
737 dfs_root,
738 root_translation,
739 children,
740 roots,
741 geometry,
742 hash,
743 map,
744 );
745 }
746 }
747}
748
749const GEO_HASH_SEED: u64 = 0xcbf29ce484222325;
751
752fn fold_geo_i32(hash: &mut u64, v: i32) {
753 for b in v.to_le_bytes() {
754 *hash = (*hash ^ b as u64).wrapping_mul(0x100000001b3);
755 }
756}
757
758pub fn fold_member_geometry(
766 hash: &mut u64,
767 root_translation: Vec2,
768 transform: &UiGlobalTransform,
769 computed: &ComputedNode,
770) {
771 let rel = transform.translation - root_translation;
772 fold_geo_i32(hash, (rel.x * 64.0).round() as i32);
773 fold_geo_i32(hash, (rel.y * 64.0).round() as i32);
774 let m = transform.matrix2;
775 fold_geo_i32(hash, (m.x_axis.x * 1024.0).round() as i32);
776 fold_geo_i32(hash, (m.x_axis.y * 1024.0).round() as i32);
777 fold_geo_i32(hash, (m.y_axis.x * 1024.0).round() as i32);
778 fold_geo_i32(hash, (m.y_axis.y * 1024.0).round() as i32);
779 let size = computed.size();
780 fold_geo_i32(hash, (size.x * 64.0).round() as i32);
781 fold_geo_i32(hash, (size.y * 64.0).round() as i32);
782}
783
784pub fn watch_layer_image_assets(
789 mut events: MessageReader<AssetEvent<Image>>,
790 images: Query<(Entity, &bevy::ui::widget::ImageNode)>,
791 registry: Res<LayersRegistry>,
792 mut dirt: ResMut<LayerContentDirt>,
793) {
794 if registry.layers.is_empty() {
795 events.clear();
796 return;
797 }
798 let mut touched: Vec<AssetId<Image>> = Vec::new();
799 for event in events.read() {
800 match event {
801 AssetEvent::LoadedWithDependencies { id } | AssetEvent::Modified { id } => {
802 touched.push(*id);
803 }
804 _ => {}
805 }
806 }
807 if touched.is_empty() {
808 return;
809 }
810 for (entity, image) in &images {
811 if touched.contains(&image.image.id()) {
812 dirt.nodes.push(entity);
813 }
814 }
815}
816
817pub fn resolve_layer_repaints(
824 mut dirt: ResMut<LayerContentDirt>,
825 mut state: ResMut<LayerRepaintState>,
826 membership: Res<LayerMembership>,
827 mut registry: ResMut<LayersRegistry>,
828 bridge: Option<Res<crate::bridge::JsBridge>>,
829 reshaped: Query<Entity, Changed<bevy::text::TextLayoutInfo>>,
830 focus: Query<&crate::bridge::FocusState>,
831) {
832 let state = &mut *state;
833 state.dirty.clear();
834
835 for e in dirt.nodes.drain(..) {
837 if let Some(&layer) = membership.node_to_layer.get(&e) {
838 state.dirty.insert(layer);
839 }
840 }
841 for e in dirt.composite_only.drain(..) {
845 let layer = membership.node_to_layer.get(&e).copied().unwrap_or(e);
846 if let Some(&Some(outer)) = membership.enclosing.get(&layer) {
847 state.dirty.insert(outer);
848 }
849 }
850 for e in &reshaped {
853 if let Some(&layer) = membership.node_to_layer.get(&e) {
854 state.dirty.insert(layer);
855 }
856 }
857 if let Some(bridge) = bridge {
860 for id in &bridge.editable_inputs {
861 if let Some(&e) = bridge.nodes.get(id)
862 && focus.get(e).is_ok_and(|f| f.0)
863 && let Some(&layer) = membership.node_to_layer.get(&e)
864 {
865 state.dirty.insert(layer);
866 }
867 }
868 }
869 for (&root, hash) in &state.geo_hashes {
872 if state.prev_hashes.get(&root) != Some(hash) {
873 state.dirty.insert(root);
874 }
875 }
876 std::mem::swap(&mut state.prev_hashes, &mut state.geo_hashes);
877 for meta in registry.layers.values() {
882 if meta.cache_policy == crate::protocol::style::LayerCache::Never {
883 state.dirty.insert(meta.entity);
884 }
885 }
886 let seeds: Vec<Entity> = state.dirty.iter().copied().collect();
889 for mut layer in seeds {
890 while let Some(&Some(outer)) = membership.enclosing.get(&layer) {
891 if !state.dirty.insert(outer) {
892 break; }
894 layer = outer;
895 }
896 }
897 for meta in registry.layers.values_mut() {
899 let dirty = state.dirty.contains(&meta.entity);
900 meta.cached = !dirty;
901 if dirty {
902 meta.repaints += 1;
903 }
904 }
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910 use crate::bridge::JsBridge;
911 use crate::protocol::{NodeId, op::Op, outbound::Outbound, props::Props};
912 use bevy::ui::BackgroundColor;
913
914 fn props(json: serde_json::Value) -> Props {
915 serde_json::from_value(json).expect("valid props")
916 }
917
918 #[test]
922 fn promotion_reasons_matrix() {
923 let promoted = |p: &Props, kids: usize, ineligible: bool| {
924 !promotion_reasons(p, kids, ineligible).is_empty()
925 };
926 let base = props(serde_json::json!({ "style": { "opacity": 0.5 } }));
927 assert!(promoted(&base, 1, false));
928 let one = props(serde_json::json!({ "style": { "opacity": 1.0 } }));
931 assert!(promoted(&one, 1, false));
932 assert!(!promoted(&base, 0, false));
934 let plain = props(serde_json::json!({ "style": { "width": 10 } }));
936 assert!(!promoted(&plain, 3, false));
937 let opted_out =
939 props(serde_json::json!({ "style": { "opacity": 0.5, "groupAlpha": false } }));
940 assert!(!promoted(&opted_out, 1, false));
941 let hover_only = props(serde_json::json!({
943 "style": { "width": 10 },
944 "hoverStyle": { "opacity": 0.8 },
945 }));
946 assert!(promoted(&hover_only, 1, false));
947 let animated = props(serde_json::json!({
949 "style": { "opacity": { "animated": { "id": 1 } } },
950 }));
951 assert!(promoted(&animated, 1, false));
952 assert!(!promoted(&base, 1, true));
954
955 let forced = props(serde_json::json!({ "style": { "cache": "always" } }));
958 assert_eq!(
959 promotion_reasons(&forced, 0, false).0,
960 PromotionReasons::FORCED
961 );
962 let forced_opted_out = props(serde_json::json!({
963 "style": { "cache": "always", "opacity": 0.5, "groupAlpha": false }
964 }));
965 assert_eq!(
966 promotion_reasons(&forced_opted_out, 1, false).0,
967 PromotionReasons::FORCED
968 );
969 let both = props(serde_json::json!({
971 "style": { "cache": "always", "opacity": 0.5 }
972 }));
973 assert_eq!(
974 promotion_reasons(&both, 1, false).0,
975 PromotionReasons::FORCED | PromotionReasons::OPACITY
976 );
977 let auto = props(serde_json::json!({ "style": { "cache": "auto" } }));
979 assert!(!promoted(&auto, 1, false));
980 assert!(!promoted(&forced, 1, true));
982
983 let filtered = props(serde_json::json!({ "style": { "filter": { "name": "blur" } } }));
987 assert_eq!(
988 promotion_reasons(&filtered, 0, false).0,
989 PromotionReasons::FILTER
990 );
991 let empty_chain = props(serde_json::json!({ "style": { "filter": [] } }));
993 assert!(!promoted(&empty_chain, 1, false));
994 let filter_and_opacity = props(serde_json::json!({
996 "style": { "filter": { "name": "blur" }, "opacity": 0.5 }
997 }));
998 assert_eq!(
999 promotion_reasons(&filter_and_opacity, 1, false).0,
1000 PromotionReasons::FILTER | PromotionReasons::OPACITY
1001 );
1002 assert!(!promoted(&filtered, 0, true));
1004 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1009 let variant_filter = props(serde_json::json!({
1010 "style": { "width": 10 },
1011 (variant): { "filter": { "name": "blur" } },
1012 }));
1013 assert_eq!(
1014 promotion_reasons(&variant_filter, 0, false).0,
1015 PromotionReasons::FILTER,
1016 "{variant}-only filter promotes eagerly"
1017 );
1018 }
1019 let empty_variant = props(serde_json::json!({
1021 "style": { "width": 10 },
1022 "hoverStyle": { "filter": [] },
1023 }));
1024 assert!(!promoted(&empty_variant, 1, false));
1025
1026 let transformed = props(serde_json::json!({
1030 "style": { "transform3d": { "rotateY": 45 } }
1031 }));
1032 assert_eq!(
1033 promotion_reasons(&transformed, 0, false).0,
1034 PromotionReasons::TRANSFORM3D
1035 );
1036 let identity_3d = props(serde_json::json!({ "style": { "transform3d": {} } }));
1037 assert_eq!(
1038 promotion_reasons(&identity_3d, 0, false).0,
1039 PromotionReasons::TRANSFORM3D
1040 );
1041 let opted_out = props(serde_json::json!({
1043 "style": { "transform3d": {}, "groupAlpha": false }
1044 }));
1045 assert!(promoted(&opted_out, 0, false));
1046 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1049 let variant_3d = props(serde_json::json!({
1050 "style": { "width": 10 },
1051 (variant): { "transform3d": { "rotateX": 10 } },
1052 }));
1053 assert_eq!(
1054 promotion_reasons(&variant_3d, 0, false).0,
1055 PromotionReasons::TRANSFORM3D,
1056 "{variant}-only transform3d promotes eagerly"
1057 );
1058 }
1059 assert!(!promoted(&transformed, 0, true));
1060
1061 let backdrop =
1065 props(serde_json::json!({ "style": { "backdropFilter": { "name": "blur" } } }));
1066 assert_eq!(
1067 promotion_reasons(&backdrop, 0, false).0,
1068 PromotionReasons::BACKDROP
1069 );
1070 let empty_backdrop = props(serde_json::json!({ "style": { "backdropFilter": [] } }));
1071 assert!(!promoted(&empty_backdrop, 1, false));
1072 let both_chains = props(serde_json::json!({
1074 "style": { "backdropFilter": { "name": "blur" }, "filter": { "name": "sepia" } }
1075 }));
1076 assert_eq!(
1077 promotion_reasons(&both_chains, 0, false).0,
1078 PromotionReasons::BACKDROP | PromotionReasons::FILTER
1079 );
1080 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1081 let variant_backdrop = props(serde_json::json!({
1082 "style": { "width": 10 },
1083 (variant): { "backdropFilter": { "name": "blur" } },
1084 }));
1085 assert_eq!(
1086 promotion_reasons(&variant_backdrop, 0, false).0,
1087 PromotionReasons::BACKDROP,
1088 "{variant}-only backdropFilter promotes eagerly"
1089 );
1090 }
1091 assert!(!promoted(&backdrop, 0, true));
1092
1093 let morph = props(serde_json::json!({
1098 "style": { "morphFilter": { "key": "a", "name": "crossfade" } }
1099 }));
1100 assert_eq!(
1101 promotion_reasons(&morph, 0, false).0,
1102 PromotionReasons::MORPH
1103 );
1104 let morph_and_filter = props(serde_json::json!({
1106 "style": {
1107 "morphFilter": { "key": "a", "name": "crossfade" },
1108 "filter": { "name": "sepia" }
1109 }
1110 }));
1111 assert_eq!(
1112 promotion_reasons(&morph_and_filter, 0, false).0,
1113 PromotionReasons::MORPH | PromotionReasons::FILTER
1114 );
1115 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1116 let variant_morph = props(serde_json::json!({
1117 "style": { "width": 10 },
1118 (variant): { "morphFilter": { "key": "a", "name": "crossfade" } },
1119 }));
1120 assert_eq!(
1121 promotion_reasons(&variant_morph, 0, false).0,
1122 PromotionReasons::MORPH,
1123 "{variant}-only morphFilter promotes eagerly"
1124 );
1125 }
1126 assert!(!promoted(&morph, 0, true));
1127
1128 let plain = props(serde_json::json!({ "style": { "width": 10 } }));
1130 assert!(!promoted(&plain, 1, false));
1131 }
1132
1133 fn layer_app() -> (bevy::app::App, crossbeam_channel::Sender<Vec<Op>>) {
1136 use bevy::app::App;
1137 let mut app = App::new();
1138 app.add_plugins((MinimalPlugins, AssetPlugin::default()));
1139 app.init_asset::<Image>();
1140 app.init_asset::<bevy::image::TextureAtlasLayout>();
1141 app.init_resource::<crate::plugin::Fonts>();
1142 app.init_resource::<crate::reconcile::OpApplyStats>();
1143 app.init_resource::<crate::ui_map::AtlasLayoutCache>();
1144 app.init_resource::<LayersRegistry>();
1145 app.init_resource::<LayerMembership>();
1146
1147 let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
1148 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
1149 std::mem::forget(out_rx);
1150 let root = app.world_mut().spawn_empty().id();
1151 app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
1152 app.add_systems(
1153 Update,
1154 (
1155 crate::reconcile::apply_js_ops,
1156 evaluate_layer_promotions.after(crate::reconcile::apply_js_ops),
1157 ),
1158 );
1159 (app, ops_tx)
1160 }
1161
1162 fn create(id: NodeId, json: serde_json::Value) -> Op {
1163 Op::Create {
1164 id,
1165 kind: "node".into(),
1166 props: Box::new(props(json)),
1167 text: None,
1168 }
1169 }
1170
1171 fn update(id: NodeId, json: serde_json::Value, style_unset: &[&str]) -> Op {
1172 Op::Update {
1173 id,
1174 props: Box::new(props(json)),
1175 unset: vec![],
1176 style_unset: style_unset.iter().map(|s| s.to_string()).collect(),
1177 }
1178 }
1179
1180 fn entity_of(app: &bevy::app::App, id: NodeId) -> Entity {
1181 *app.world().resource::<JsBridge>().nodes.get(&id).unwrap()
1182 }
1183
1184 #[test]
1189 fn promotion_lifecycle_and_fold_handoff() {
1190 let (mut app, ops_tx) = layer_app();
1191 ops_tx
1192 .send(vec![
1193 create(
1194 1,
1195 serde_json::json!({
1196 "style": { "opacity": 0.5, "backgroundColor": "#ff0000" }
1197 }),
1198 ),
1199 create(2, serde_json::json!({})),
1200 Op::Append {
1201 parent: 1,
1202 child: 2,
1203 },
1204 ])
1205 .unwrap();
1206 app.update();
1207
1208 let e = entity_of(&app, 1);
1209 assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
1210 assert_eq!(
1211 app.world().get::<LayerGroupAlpha>(e),
1212 Some(&LayerGroupAlpha(0.5))
1213 );
1214 let registry = app.world().resource::<LayersRegistry>();
1215 assert_eq!(registry.layers.len(), 1);
1216 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::OPACITY);
1217 let bg = app.world().get::<BackgroundColor>(e).unwrap();
1220 assert_eq!(bg.0.alpha(), 1.0, "promoted bg keeps its own alpha");
1221
1222 ops_tx
1224 .send(vec![update(
1225 1,
1226 serde_json::json!({ "style": { "groupAlpha": false } }),
1227 &[],
1228 )])
1229 .unwrap();
1230 app.update();
1231 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1232 assert!(app.world().get::<LayerGroupAlpha>(e).is_none());
1233 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1234 let bg = app.world().get::<BackgroundColor>(e).unwrap();
1235 assert_eq!(bg.0.alpha(), 0.5, "demoted bg re-bakes the fold");
1236
1237 ops_tx
1239 .send(vec![update(1, serde_json::json!({}), &["groupAlpha"])])
1240 .unwrap();
1241 app.update();
1242 let e1 = entity_of(&app, 1);
1243 assert!(app.world().get::<PromotedLayer>(e1).is_some());
1244 ops_tx
1245 .send(vec![Op::Remove {
1246 parent: 1,
1247 child: 2,
1248 }])
1249 .unwrap();
1250 app.update();
1251 assert!(
1252 app.world().get::<PromotedLayer>(e1).is_none(),
1253 "no children → demoted"
1254 );
1255 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1256 }
1257
1258 #[test]
1261 fn forced_cache_lifecycle() {
1262 let (mut app, ops_tx) = layer_app();
1263 ops_tx
1264 .send(vec![create(
1265 1,
1266 serde_json::json!({ "style": { "cache": "always" } }),
1267 )])
1268 .unwrap();
1269 app.update();
1270 let e = entity_of(&app, 1);
1271 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1272 assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
1273 assert_eq!(
1275 app.world().get::<LayerGroupAlpha>(e),
1276 Some(&LayerGroupAlpha(1.0))
1277 );
1278
1279 ops_tx
1280 .send(vec![update(1, serde_json::json!({}), &["cache"])])
1281 .unwrap();
1282 app.update();
1283 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1284 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1285 }
1286
1287 #[test]
1292 fn never_cache_lifecycle_and_policy() {
1293 use crate::protocol::style::LayerCache;
1294 let (mut app, ops_tx) = layer_app();
1295 ops_tx
1296 .send(vec![create(
1297 1,
1298 serde_json::json!({ "style": { "cache": "never" } }),
1299 )])
1300 .unwrap();
1301 app.update();
1302 let e = entity_of(&app, 1);
1303 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1304 assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
1305 let row = app.world().resource::<LayersRegistry>().layers[&1];
1306 assert_eq!(row.cache_policy, LayerCache::Never);
1307
1308 ops_tx
1310 .send(vec![update(
1311 1,
1312 serde_json::json!({ "style": { "cache": "always" } }),
1313 &[],
1314 )])
1315 .unwrap();
1316 app.update();
1317 assert!(
1318 app.world().get::<PromotedLayer>(e).is_some(),
1319 "stays promoted"
1320 );
1321 let row = app.world().resource::<LayersRegistry>().layers[&1];
1322 assert_eq!(row.cache_policy, LayerCache::Always);
1323
1324 ops_tx
1325 .send(vec![update(1, serde_json::json!({}), &["cache"])])
1326 .unwrap();
1327 app.update();
1328 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1329 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1330 }
1331
1332 #[test]
1335 fn filter_promotion_lifecycle() {
1336 let (mut app, ops_tx) = layer_app();
1337 ops_tx
1338 .send(vec![create(
1339 1,
1340 serde_json::json!({ "style": { "filter": { "name": "grayscale" } } }),
1341 )])
1342 .unwrap();
1343 app.update();
1344 let e = entity_of(&app, 1);
1345 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1346 assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
1347 assert_eq!(
1349 app.world().get::<LayerGroupAlpha>(e),
1350 Some(&LayerGroupAlpha(1.0))
1351 );
1352 let registry = app.world().resource::<LayersRegistry>();
1353 assert_eq!(registry.layers.len(), 1);
1354 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::FILTER);
1355
1356 ops_tx
1357 .send(vec![update(1, serde_json::json!({}), &["filter"])])
1358 .unwrap();
1359 app.update();
1360 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1361 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1362 }
1363
1364 #[test]
1368 fn backdrop_promotion_lifecycle() {
1369 let (mut app, ops_tx) = layer_app();
1370 ops_tx
1371 .send(vec![create(
1372 1,
1373 serde_json::json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
1374 )])
1375 .unwrap();
1376 app.update();
1377 let e = entity_of(&app, 1);
1378 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1379 assert_eq!(promoted.reasons.0, PromotionReasons::BACKDROP);
1380 assert_eq!(
1381 app.world().get::<LayerGroupAlpha>(e),
1382 Some(&LayerGroupAlpha(1.0))
1383 );
1384 let registry = app.world().resource::<LayersRegistry>();
1385 assert_eq!(registry.layers.len(), 1);
1386 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::BACKDROP);
1387
1388 ops_tx
1389 .send(vec![update(1, serde_json::json!({}), &["backdropFilter"])])
1390 .unwrap();
1391 app.update();
1392 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1393 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1394 }
1395
1396 #[test]
1401 fn morph_promotion_lifecycle() {
1402 let (mut app, ops_tx) = layer_app();
1403 ops_tx
1404 .send(vec![create(
1405 1,
1406 serde_json::json!({ "style": {
1407 "morphFilter": { "key": "a", "name": "crossfade" }
1408 } }),
1409 )])
1410 .unwrap();
1411 app.update();
1412 let e = entity_of(&app, 1);
1413 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1414 assert_eq!(promoted.reasons.0, PromotionReasons::MORPH);
1415 assert_eq!(
1416 app.world().get::<LayerGroupAlpha>(e),
1417 Some(&LayerGroupAlpha(1.0))
1418 );
1419 let registry = app.world().resource::<LayersRegistry>();
1420 assert_eq!(registry.layers.len(), 1);
1421 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::MORPH);
1422 ops_tx
1424 .send(vec![update(
1425 1,
1426 serde_json::json!({ "style": {
1427 "morphFilter": { "key": "b", "name": "crossfade" }
1428 } }),
1429 &[],
1430 )])
1431 .unwrap();
1432 app.update();
1433 assert!(app.world().get::<PromotedLayer>(e).is_some());
1434
1435 ops_tx
1436 .send(vec![update(1, serde_json::json!({}), &["morphFilter"])])
1437 .unwrap();
1438 app.update();
1439 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1440 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1441 }
1442
1443 #[test]
1449 fn hover_filter_promotes_eagerly_from_creation() {
1450 let (mut app, ops_tx) = layer_app();
1451 ops_tx
1452 .send(vec![create(
1453 1,
1454 serde_json::json!({
1455 "style": { "width": 10 },
1456 "hoverStyle": { "filter": { "name": "blur", "params": { "radius": 8 } } },
1457 }),
1458 )])
1459 .unwrap();
1460 app.update();
1461 let e = entity_of(&app, 1);
1462 let promoted = app
1463 .world()
1464 .get::<PromotedLayer>(e)
1465 .expect("promoted before any hover");
1466 assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
1467 assert_eq!(
1468 app.world().resource::<LayersRegistry>().layers[&1]
1469 .reasons
1470 .0,
1471 PromotionReasons::FILTER
1472 );
1473
1474 ops_tx
1476 .send(vec![Op::Update {
1477 id: 1,
1478 props: Box::new(props(serde_json::json!({}))),
1479 unset: vec!["hoverStyle".into()],
1480 style_unset: vec![],
1481 }])
1482 .unwrap();
1483 app.update();
1484 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1485 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1486 }
1487
1488 #[test]
1493 fn repaint_resolution_and_propagation() {
1494 let mut world = World::new();
1495 world.init_resource::<LayerContentDirt>();
1496 world.init_resource::<LayerRepaintState>();
1497 world.init_resource::<LayersRegistry>();
1498
1499 let outer = world.spawn_empty().id();
1500 let inner = world.spawn_empty().id();
1501 let member = world.spawn_empty().id(); let outer_member = world.spawn_empty().id(); let mut membership = LayerMembership::default();
1504 membership.node_to_layer.insert(outer, outer);
1505 membership.node_to_layer.insert(inner, inner);
1506 membership.node_to_layer.insert(member, inner);
1507 membership.node_to_layer.insert(outer_member, outer);
1508 membership.enclosing.insert(outer, None);
1509 membership.enclosing.insert(inner, Some(outer));
1510 world.insert_resource(membership);
1511
1512 let mut schedule = Schedule::default();
1513 schedule.add_systems(resolve_layer_repaints);
1514 let mut run = |world: &mut World| {
1515 schedule.run(world);
1516 world.resource::<LayerRepaintState>().dirty.clone()
1517 };
1518
1519 {
1521 let mut state = world.resource_mut::<LayerRepaintState>();
1522 state.geo_hashes.insert(outer, 1);
1523 state.geo_hashes.insert(inner, 2);
1524 }
1525 let dirty = run(&mut world);
1526 assert!(
1527 dirty.contains(&outer) && dirty.contains(&inner),
1528 "{dirty:?}"
1529 );
1530
1531 {
1533 let mut state = world.resource_mut::<LayerRepaintState>();
1534 state.geo_hashes.insert(outer, 1);
1535 state.geo_hashes.insert(inner, 2);
1536 }
1537 assert!(run(&mut world).is_empty());
1538
1539 {
1541 let mut state = world.resource_mut::<LayerRepaintState>();
1542 state.geo_hashes.insert(outer, 1);
1543 state.geo_hashes.insert(inner, 2);
1544 world.resource_mut::<LayerContentDirt>().nodes.push(member);
1545 }
1546 let dirty = run(&mut world);
1547 assert!(dirty.contains(&inner) && dirty.contains(&outer));
1548
1549 {
1552 let mut state = world.resource_mut::<LayerRepaintState>();
1553 state.geo_hashes.insert(outer, 1);
1554 state.geo_hashes.insert(inner, 2);
1555 world
1556 .resource_mut::<LayerContentDirt>()
1557 .composite_only
1558 .push(inner);
1559 }
1560 let dirty = run(&mut world);
1561 assert!(
1562 dirty.contains(&outer) && !dirty.contains(&inner),
1563 "{dirty:?}"
1564 );
1565
1566 {
1568 let mut state = world.resource_mut::<LayerRepaintState>();
1569 state.geo_hashes.insert(outer, 1);
1570 state.geo_hashes.insert(inner, 2);
1571 world
1572 .resource_mut::<LayerContentDirt>()
1573 .composite_only
1574 .push(outer);
1575 }
1576 assert!(run(&mut world).is_empty());
1577
1578 {
1580 let mut state = world.resource_mut::<LayerRepaintState>();
1581 state.geo_hashes.insert(outer, 1);
1582 state.geo_hashes.insert(inner, 3);
1583 }
1584 let dirty = run(&mut world);
1585 assert!(dirty.contains(&inner) && dirty.contains(&outer));
1586
1587 {
1589 let mut state = world.resource_mut::<LayerRepaintState>();
1590 state.geo_hashes.insert(outer, 1);
1591 state.geo_hashes.insert(inner, 3);
1592 world
1593 .resource_mut::<LayerContentDirt>()
1594 .nodes
1595 .push(outer_member);
1596 }
1597 let dirty = run(&mut world);
1598 assert!(dirty.contains(&outer) && !dirty.contains(&inner));
1599 }
1600
1601 #[test]
1605 fn never_policy_repaints_every_frame() {
1606 use crate::protocol::style::LayerCache;
1607 let mut world = World::new();
1608 world.init_resource::<LayerContentDirt>();
1609 world.init_resource::<LayerRepaintState>();
1610 world.init_resource::<LayersRegistry>();
1611
1612 let outer = world.spawn_empty().id();
1613 let inner = world.spawn_empty().id(); let other = world.spawn_empty().id(); let mut membership = LayerMembership::default();
1616 membership.node_to_layer.insert(outer, outer);
1617 membership.node_to_layer.insert(inner, inner);
1618 membership.node_to_layer.insert(other, other);
1619 membership.enclosing.insert(outer, None);
1620 membership.enclosing.insert(inner, Some(outer));
1621 membership.enclosing.insert(other, None);
1622 world.insert_resource(membership);
1623
1624 let meta = |node: NodeId, entity: Entity, policy: LayerCache| LayerMeta {
1625 node,
1626 entity,
1627 reasons: PromotionReasons(PromotionReasons::FORCED),
1628 group_alpha: 1.0,
1629 capture_rect: None,
1630 depth: 1,
1631 repaints: 0,
1632 cached: false,
1633 cache_policy: policy,
1634 };
1635 {
1636 let mut registry = world.resource_mut::<LayersRegistry>();
1637 registry.layers.insert(1, meta(1, outer, LayerCache::Auto));
1638 registry.layers.insert(2, meta(2, inner, LayerCache::Never));
1639 registry
1640 .layers
1641 .insert(3, meta(3, other, LayerCache::Always));
1642 }
1643
1644 let mut schedule = Schedule::default();
1645 schedule.add_systems(resolve_layer_repaints);
1646 for frame in 0..2 {
1647 schedule.run(&mut world);
1648 let state = world.resource::<LayerRepaintState>();
1649 assert!(
1650 state.dirty.contains(&inner) && state.dirty.contains(&outer),
1651 "frame {frame}: {:?}",
1652 state.dirty
1653 );
1654 assert!(!state.dirty.contains(&other), "frame {frame}");
1655 }
1656 let registry = world.resource::<LayersRegistry>();
1657 assert_eq!(registry.layers[&2].repaints, 2);
1658 assert!(!registry.layers[&2].cached);
1659 assert!(registry.layers[&3].cached);
1660 }
1661
1662 #[test]
1666 fn geometry_fold_translation_invariance() {
1667 use bevy::math::Affine2;
1668
1669 let node = |size: Vec2, pos: Vec2| {
1670 let computed = ComputedNode {
1671 size,
1672 ..Default::default()
1673 };
1674 (
1675 computed,
1676 UiGlobalTransform::from(Affine2::from_translation(pos)),
1677 )
1678 };
1679 let fold = |members: &[(ComputedNode, UiGlobalTransform)], root: Vec2| {
1680 let mut hash = GEO_HASH_SEED;
1681 for (computed, transform) in members {
1682 fold_member_geometry(&mut hash, root, transform, computed);
1683 }
1684 hash
1685 };
1686
1687 let members = [
1688 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1689 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
1690 ];
1691 let base = fold(&members, Vec2::new(10.0, 20.0));
1692
1693 let delta = Vec2::new(123.4, -56.78);
1696 let shifted = [
1697 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0) + delta),
1698 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0) + delta),
1699 ];
1700 assert_eq!(base, fold(&shifted, Vec2::new(10.0, 20.0) + delta));
1701
1702 let moved = [
1704 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1705 node(Vec2::new(30.0, 30.0), Vec2::new(41.0, 25.0)),
1706 ];
1707 assert_ne!(base, fold(&moved, Vec2::new(10.0, 20.0)));
1708
1709 let resized = [
1711 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1712 node(Vec2::new(31.0, 30.0), Vec2::new(40.0, 25.0)),
1713 ];
1714 assert_ne!(base, fold(&resized, Vec2::new(10.0, 20.0)));
1715
1716 let mut scaled = [
1718 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1719 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
1720 ];
1721 scaled[1].1 = UiGlobalTransform::from(Affine2::from_scale_angle_translation(
1722 Vec2::splat(1.5),
1723 0.0,
1724 Vec2::new(40.0, 25.0),
1725 ));
1726 assert_ne!(base, fold(&scaled, Vec2::new(10.0, 20.0)));
1727
1728 let noisy = [
1730 node(Vec2::new(100.0, 50.0), Vec2::new(10.0 + 1e-4, 20.0)),
1731 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0 - 1e-4)),
1732 ];
1733 assert_eq!(base, fold(&noisy, Vec2::new(10.0, 20.0)));
1734 }
1735
1736 fn geometry_world() -> (World, Schedule) {
1742 let mut world = World::new();
1743 world.init_resource::<LayerMembership>();
1744 world.init_resource::<LayersRegistry>();
1745 world.init_resource::<LayerRepaintState>();
1746 world.init_resource::<LayerContentDirt>();
1747 let mut schedule = Schedule::default();
1748 schedule.add_systems((sync_layer_geometry, resolve_layer_repaints).chain());
1749 (world, schedule)
1750 }
1751
1752 fn spawn_layer_root(world: &mut World, id: NodeId, size: Vec2, center: Vec2) -> Entity {
1756 world
1757 .spawn((
1758 ComputedNode {
1759 size,
1760 ..Default::default()
1761 },
1762 UiGlobalTransform::from(bevy::math::Affine2::from_translation(center)),
1763 crate::bridge::RNode(id),
1764 LayerGroupAlpha(1.0),
1765 PromotedLayer {
1766 reasons: PromotionReasons(PromotionReasons::FILTER),
1767 },
1768 ))
1769 .id()
1770 }
1771
1772 fn filter_outset(world: &mut World, e: Entity, outset_px: u32) {
1773 world
1774 .entity_mut(e)
1775 .insert(crate::filters::ResolvedFilterChain {
1776 outset_px,
1777 ..Default::default()
1778 });
1779 }
1780
1781 fn backdrop_outset(world: &mut World, e: Entity, outset_px: u32) {
1782 world
1783 .entity_mut(e)
1784 .insert(crate::filters::ResolvedBackdropChain(
1785 crate::filters::ResolvedFilterChain {
1786 outset_px,
1787 ..Default::default()
1788 },
1789 ));
1790 }
1791
1792 #[test]
1797 fn outset_inflates_rect_by_quantized_margin() {
1798 let (mut world, mut schedule) = geometry_world();
1799 let size = Vec2::new(100.0, 60.0);
1800 let center = Vec2::new(50.0, 30.0);
1801 let plain = spawn_layer_root(&mut world, 1, size, center);
1802 let blurred = spawn_layer_root(&mut world, 2, size, center);
1803 filter_outset(&mut world, blurred, 12); let big = spawn_layer_root(&mut world, 3, size, center);
1805 filter_outset(&mut world, big, 60); schedule.run(&mut world);
1807
1808 let base = *world.get::<LayerCaptureRect>(plain).expect("baseline rect");
1809 assert_eq!(base.min, Vec2::ZERO);
1810 assert_eq!(base.size, UVec2::new(100, 60));
1811 let rect = *world.get::<LayerCaptureRect>(blurred).expect("rect");
1813 assert_eq!(rect.min, base.min - Vec2::splat(16.0));
1814 assert_eq!(rect.size, base.size + UVec2::splat(32));
1815 let rect = *world.get::<LayerCaptureRect>(big).expect("rect");
1817 assert_eq!(rect.min, base.min - Vec2::splat(64.0));
1818 assert_eq!(rect.size, base.size + UVec2::splat(128));
1819 }
1820
1821 #[test]
1826 fn backdrop_outset_inflates_rect_and_maxes_with_content() {
1827 let (mut world, mut schedule) = geometry_world();
1828 let size = Vec2::new(100.0, 60.0);
1829 let center = Vec2::new(50.0, 30.0);
1830 let plain = spawn_layer_root(&mut world, 1, size, center);
1831 let frosted = spawn_layer_root(&mut world, 2, size, center);
1832 backdrop_outset(&mut world, frosted, 12); let both = spawn_layer_root(&mut world, 3, size, center);
1834 filter_outset(&mut world, both, 4); backdrop_outset(&mut world, both, 40); schedule.run(&mut world);
1837
1838 let base = *world.get::<LayerCaptureRect>(plain).expect("baseline");
1839 assert_eq!(base.outset, 0);
1840 let rect = *world.get::<LayerCaptureRect>(frosted).expect("rect");
1841 assert_eq!(rect.min, base.min - Vec2::splat(16.0));
1842 assert_eq!(rect.size, base.size + UVec2::splat(32));
1843 assert_eq!(rect.outset, 16);
1844 let rect = *world.get::<LayerCaptureRect>(both).expect("rect");
1845 assert_eq!(rect.min, base.min - Vec2::splat(48.0));
1846 assert_eq!(rect.size, base.size + UVec2::splat(96));
1847 assert_eq!(rect.outset, 48);
1848 }
1849
1850 #[test]
1855 fn active_morph_keeps_layout_rect() {
1856 let (mut world, mut schedule) = geometry_world();
1857 let root = spawn_layer_root(&mut world, 1, Vec2::new(80.0, 40.0), Vec2::new(60.0, 30.0));
1859 world.entity_mut(root).insert(crate::filters::MorphState {
1860 active: true,
1861 progress: 0.3,
1862 freeze_seq: 1,
1863 });
1864 schedule.run(&mut world);
1865 let rect = *world.get::<LayerCaptureRect>(root).expect("rect");
1866 assert_eq!(rect.min, Vec2::new(20.0, 10.0), "rect is the node's box");
1867 assert_eq!(rect.size, UVec2::new(80, 40));
1868
1869 schedule.run(&mut world);
1871 assert!(
1872 !world.resource::<LayerRepaintState>().dirty.contains(&root),
1873 "an active morph must not re-capture every frame"
1874 );
1875
1876 world.entity_mut(root).insert(UiGlobalTransform::from(
1879 bevy::math::Affine2::from_translation(Vec2::new(60.0, 130.0)),
1880 ));
1881 schedule.run(&mut world);
1882 let rect = *world.get::<LayerCaptureRect>(root).expect("rect");
1883 assert_eq!(rect.min, Vec2::new(20.0, 110.0), "rect follows layout");
1884 assert!(
1885 !world.resource::<LayerRepaintState>().dirty.contains(&root),
1886 "a scrolled mid-morph layer must stay a capture-cache hit"
1887 );
1888 }
1889
1890 #[test]
1894 fn outset_within_quantize_step_holds_rect_and_cache() {
1895 let (mut world, mut schedule) = geometry_world();
1896 let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
1897 filter_outset(&mut world, e, 12);
1898 schedule.run(&mut world);
1899 assert!(
1900 world.resource::<LayerRepaintState>().dirty.contains(&e),
1901 "first frame repaints"
1902 );
1903 let before = *world.get::<LayerCaptureRect>(e).expect("rect");
1904
1905 filter_outset(&mut world, e, 14); schedule.run(&mut world);
1907 assert_eq!(*world.get::<LayerCaptureRect>(e).expect("rect"), before);
1908 assert!(
1909 world.resource::<LayerRepaintState>().dirty.is_empty(),
1910 "no repaint within a quantize step"
1911 );
1912 }
1913
1914 #[test]
1918 fn outset_crossing_quantize_step_recaptures() {
1919 let (mut world, mut schedule) = geometry_world();
1920 let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
1921 filter_outset(&mut world, e, 14); schedule.run(&mut world);
1923 schedule.run(&mut world); assert!(world.resource::<LayerRepaintState>().dirty.is_empty());
1925 let before = *world.get::<LayerCaptureRect>(e).expect("rect");
1926
1927 filter_outset(&mut world, e, 18); schedule.run(&mut world);
1929 let after = *world.get::<LayerCaptureRect>(e).expect("rect");
1930 assert_eq!(after.min, before.min - Vec2::splat(16.0));
1931 assert_eq!(after.size, before.size + UVec2::splat(32));
1932 assert!(
1933 world.resource::<LayerRepaintState>().dirty.contains(&e),
1934 "step crossing re-captures"
1935 );
1936 }
1937
1938 #[test]
1941 fn zero_content_size_stays_inactive_despite_outset() {
1942 let (mut world, mut schedule) = geometry_world();
1943 let e = spawn_layer_root(&mut world, 1, Vec2::ZERO, Vec2::ZERO);
1944 filter_outset(&mut world, e, 60);
1945 schedule.run(&mut world);
1946 assert!(
1947 world.get::<LayerCaptureRect>(e).is_none(),
1948 "zero content size stays inactive"
1949 );
1950 }
1951
1952 #[cfg(all(feature = "devtools", debug_assertions))]
1957 #[test]
1958 fn nested_filter_bleed_warns_when_clipped() {
1959 let _lock = crate::diag::test_lock();
1960 crate::diag::arm_runtime();
1961 let _ = crate::diag::take_runtime_warnings();
1962
1963 let (mut world, mut schedule) = geometry_world();
1964 let outer = spawn_layer_root(&mut world, 1, Vec2::splat(200.0), Vec2::splat(100.0));
1966 let inner = spawn_layer_root(&mut world, 2, Vec2::splat(100.0), Vec2::splat(100.0));
1968 world.entity_mut(inner).insert((
1969 ChildOf(outer),
1970 crate::filters::FilterInput(crate::filters::FilterChain(vec![
1971 crate::filters::FilterUse {
1972 name: "blur".into(),
1973 params: Default::default(),
1974 },
1975 ])),
1976 ));
1977 filter_outset(&mut world, inner, 12);
1979 schedule.run(&mut world);
1980 let bleeds = |warns: Vec<crate::diag::RuntimeWarning>| -> Vec<_> {
1981 warns
1982 .into_iter()
1983 .filter(|w| w.kind == "filterBleed")
1984 .collect()
1985 };
1986 assert!(
1987 bleeds(crate::diag::take_runtime_warnings()).is_empty(),
1988 "contained bleed does not warn"
1989 );
1990
1991 filter_outset(&mut world, inner, 60);
1993 schedule.run(&mut world);
1994 let warns = bleeds(crate::diag::take_runtime_warnings());
1995 assert_eq!(warns.len(), 1, "{warns:?}");
1996 assert_eq!(warns[0].node, Some(2));
1997 assert_eq!(warns[0].value, "blur");
1998 for side in ["left", "top", "right", "bottom"] {
1999 assert!(warns[0].message.contains(side), "{}", warns[0].message);
2000 }
2001
2002 schedule.run(&mut world);
2004 assert!(
2005 bleeds(crate::diag::take_runtime_warnings()).is_empty(),
2006 "unchanged bleed is not re-reported"
2007 );
2008 }
2009
2010 #[test]
2013 fn removal_prunes_registry() {
2014 let (mut app, ops_tx) = layer_app();
2015 ops_tx
2016 .send(vec![
2017 create(1, serde_json::json!({})),
2018 create(2, serde_json::json!({ "style": { "opacity": 0.3 } })),
2019 create(3, serde_json::json!({})),
2020 Op::Append {
2021 parent: 1,
2022 child: 2,
2023 },
2024 Op::Append {
2025 parent: 2,
2026 child: 3,
2027 },
2028 ])
2029 .unwrap();
2030 app.update();
2031 assert_eq!(app.world().resource::<LayersRegistry>().layers.len(), 1);
2032
2033 ops_tx
2034 .send(vec![Op::Remove {
2035 parent: 1,
2036 child: 2,
2037 }])
2038 .unwrap();
2039 app.update();
2040 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
2041 assert!(
2042 app.world()
2043 .resource::<JsBridge>()
2044 .promoted_layers
2045 .is_empty()
2046 );
2047 }
2048}