1use bevy::platform::collections::{HashMap, HashSet};
43use bevy::prelude::*;
44use bevy::ui::{ComputedNode, UiGlobalTransform};
45
46use crate::protocol::{AnimatableField, NodeId, 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
86 pub fn is_empty(self) -> bool {
87 self.0 == 0
88 }
89}
90
91#[derive(Component, Debug, Clone, Copy)]
94pub struct PromotedLayer {
95 pub reasons: PromotionReasons,
96}
97
98#[derive(Component, Debug, Clone, Copy, PartialEq)]
102pub struct LayerGroupAlpha(pub f32);
103
104#[derive(Component, Debug, Clone, Copy, PartialEq)]
121pub struct LayerCaptureRect {
122 pub min: Vec2,
126 pub size: UVec2,
128 pub outset: u32,
135}
136
137#[derive(Resource, Debug, Default)]
145pub struct LayerMembership {
146 pub node_to_layer: HashMap<Entity, Entity>,
148 pub enclosing: HashMap<Entity, Option<Entity>>,
152}
153
154#[derive(Debug, Clone, Copy)]
159pub struct LayerMeta {
160 pub node: NodeId,
161 pub entity: Entity,
162 pub reasons: PromotionReasons,
163 pub group_alpha: f32,
165 pub capture_rect: Option<IRect>,
173 pub depth: u32,
175 pub repaints: u64,
177 pub cached: bool,
179 pub cache_policy: crate::protocol::LayerCache,
184}
185
186#[derive(Resource, Debug, Default)]
191pub struct LayersRegistry {
192 pub layers: HashMap<NodeId, LayerMeta>,
193}
194
195#[derive(Resource, Debug, Default)]
201pub struct LayerContentDirt {
202 pub nodes: Vec<Entity>,
205 pub composite_only: Vec<Entity>,
211}
212
213#[derive(Resource, Debug, Default)]
218pub struct LayerRepaintState {
219 pub dirty: HashSet<Entity>,
221 pub geo_hashes: HashMap<Entity, u64>,
224 prev_hashes: HashMap<Entity, u64>,
225}
226
227pub fn mark_content_dirty(ec: &mut EntityCommands) {
233 ec.queue(|mut e: bevy::ecs::world::EntityWorldMut| {
234 let id = e.id();
235 e.world_scope(|w| {
236 if let Some(mut dirt) = w.get_resource_mut::<LayerContentDirt>() {
237 dirt.nodes.push(id);
238 }
239 });
240 });
241}
242
243pub fn promotion_reasons(
267 props: &Props,
268 child_count: usize,
269 ineligible_element: bool,
270) -> PromotionReasons {
271 let opacity_present = props.all_styles().any(|s| s.opacity.is_some());
275 let group_gate = props.style.as_ref().and_then(|s| s.group_alpha) != Some(false);
276
277 let mut reasons = 0;
278 if opacity_present && group_gate && child_count >= 1 && !ineligible_element {
279 reasons |= PromotionReasons::OPACITY;
280 }
281 let forced = matches!(
287 props.style.as_ref().and_then(|s| s.cache),
288 Some(crate::protocol::LayerCache::Always | crate::protocol::LayerCache::Never)
289 );
290 if forced && !ineligible_element {
291 reasons |= PromotionReasons::FORCED;
292 }
293 let filtered = props
303 .all_styles()
304 .any(|s| s.filter.as_ref().is_some_and(|chain| !chain.0.is_empty()));
305 if filtered && !ineligible_element {
306 reasons |= PromotionReasons::FILTER;
307 }
308 let transformed3d = props.all_styles().any(|s| s.transform3d.is_some());
312 if transformed3d && !ineligible_element {
313 reasons |= PromotionReasons::TRANSFORM3D;
314 }
315 let backdrop = props
320 .all_styles()
321 .any(|s| s.backdrop_filter.as_ref().is_some_and(|c| !c.0.is_empty()));
322 if backdrop && !ineligible_element {
323 reasons |= PromotionReasons::BACKDROP;
324 }
325 PromotionReasons(reasons)
326}
327
328pub fn evaluate_layer_promotions(
335 mut commands: Commands,
336 mut bridge: ResMut<crate::bridge::JsBridge>,
337 mut registry: ResMut<LayersRegistry>,
338 assets: Res<AssetServer>,
339 mut ui_assets: crate::reconcile::UiAssets,
340 mut style_variants: Query<&mut crate::bridge::StyleVariants>,
341) {
342 registry
345 .layers
346 .retain(|id, meta| bridge.nodes.get(id) == Some(&meta.entity));
347
348 if bridge.layer_dirty.is_empty() {
349 return;
350 }
351 let dirty: Vec<NodeId> = bridge.layer_dirty.drain().collect();
352 for id in dirty {
353 let Some(&entity) = bridge.nodes.get(&id) else {
354 continue; };
356 let reasons = match bridge.props_cache.get(&id) {
357 Some(props) => promotion_reasons(
358 props,
359 bridge.children_of(id).count(),
360 bridge.text_styles.contains_key(&id) || bridge.is_detached_root(id),
364 ),
365 None => PromotionReasons::default(),
366 };
367 let was_promoted = bridge.promoted_layers.contains(&id);
368 if !reasons.is_empty() {
369 let alpha = bridge
372 .props_cache
373 .get(&id)
374 .and_then(|p| p.style.as_ref())
375 .and_then(|s| s.opacity.static_val())
376 .unwrap_or(1.0);
377 let cache_policy = bridge
378 .props_cache
379 .get(&id)
380 .and_then(|p| p.style.as_ref())
381 .and_then(|s| s.cache)
382 .unwrap_or_default();
383 commands
384 .entity(entity)
385 .insert((PromotedLayer { reasons }, LayerGroupAlpha(alpha)));
386 bridge.promoted_layers.insert(id);
387 let row = registry.layers.entry(id).or_insert(LayerMeta {
390 node: id,
391 entity,
392 reasons,
393 group_alpha: alpha,
394 capture_rect: None,
395 depth: 1,
396 repaints: 0,
397 cached: false,
398 cache_policy,
399 });
400 row.entity = entity;
401 row.reasons = reasons;
402 row.group_alpha = alpha;
403 row.cache_policy = cache_policy;
404 if !was_promoted && let Some(props) = bridge.props_cache.get(&id) {
405 crate::reconcile::reapply_opacity_outputs(
408 &mut commands,
409 entity,
410 props,
411 true,
412 &assets,
413 &mut ui_assets,
414 &mut style_variants,
415 );
416 }
417 } else if was_promoted {
418 commands.entity(entity).remove::<(
422 PromotedLayer,
423 LayerGroupAlpha,
424 LayerCaptureRect,
425 crate::filters::ResolvedFilterChain,
426 crate::filters::ResolvedBackdropChain,
427 transform3d::LayerTransform3d,
428 transform3d::LayerTransform3dMatrix,
429 )>();
430 bridge.promoted_layers.remove(&id);
431 registry.layers.remove(&id);
432 if let Some(props) = bridge.props_cache.get(&id) {
433 crate::reconcile::reapply_opacity_outputs(
435 &mut commands,
436 entity,
437 props,
438 false,
439 &assets,
440 &mut ui_assets,
441 &mut style_variants,
442 );
443 }
444 }
445 }
446}
447
448#[allow(clippy::too_many_arguments, clippy::type_complexity)]
458pub fn sync_layer_geometry(
459 mut commands: Commands,
460 roots: Query<
461 (
462 Entity,
463 &ComputedNode,
464 &UiGlobalTransform,
465 &crate::bridge::RNode,
466 &LayerGroupAlpha,
467 Option<&crate::filters::ResolvedFilterChain>,
468 Option<&crate::filters::FilterInput>,
469 Option<&crate::filters::ResolvedBackdropChain>,
470 ),
471 With<PromotedLayer>,
472 >,
473 root_markers: Query<(), With<PromotedLayer>>,
474 children: Query<&Children>,
475 parents: Query<&ChildOf>,
476 existing_rects: Query<&LayerCaptureRect>,
477 geometry: Query<(&ComputedNode, &UiGlobalTransform)>,
478 mut membership: ResMut<LayerMembership>,
479 mut registry: ResMut<LayersRegistry>,
480 mut repaints: ResMut<LayerRepaintState>,
481 mut warned_bleeds: Local<HashMap<Entity, (LayerCaptureRect, LayerCaptureRect)>>,
486) {
487 membership.node_to_layer.clear();
488 membership.enclosing.clear();
489 repaints.geo_hashes.clear();
492 let mut frame_rects: HashMap<Entity, LayerCaptureRect> = HashMap::default();
496 let mut bleed_candidates: Vec<(Entity, NodeId, u32, String)> = Vec::new();
500 for (root, computed, transform, rnode, alpha, chain, filter_input, backdrop_chain) in &roots {
501 let row = registry.layers.get_mut(&rnode.0);
502 if let Some(row) = &row {
503 debug_assert_eq!(row.entity, root);
504 }
505 let size = computed.size();
506 if size.x <= 0.5 || size.y <= 0.5 {
507 if let Some(row) = row {
511 row.capture_rect = None;
512 }
513 continue;
514 }
515 let min = transform.translation - size * 0.5;
519 let mut rect = LayerCaptureRect {
520 min,
521 size: UVec2::new(size.x.ceil() as u32, size.y.ceil() as u32),
522 outset: 0,
523 };
524 if rect.size.x == 0 || rect.size.y == 0 {
525 if let Some(row) = row {
526 row.capture_rect = None;
527 }
528 continue;
529 }
530 let content_outset = chain.map_or(0, |c| crate::filters::quantize_outset(c.outset_px));
540 let backdrop_outset =
541 backdrop_chain.map_or(0, |c| crate::filters::quantize_outset(c.0.outset_px));
542 let outset = content_outset.max(backdrop_outset);
543 if outset > 0 {
544 rect.min -= Vec2::splat(outset as f32);
545 rect.size += UVec2::splat(2 * outset);
546 rect.outset = outset;
547 }
548 if content_outset > 0 {
553 let value = filter_input
554 .and_then(|i| i.0.0.first())
555 .map_or_else(|| "filter".to_owned(), |u| u.name.clone());
556 bleed_candidates.push((root, rnode.0, content_outset, value));
557 }
558 frame_rects.insert(root, rect);
559 if existing_rects.get(root) != Ok(&rect) {
560 commands.entity(root).insert(rect);
561 }
562 if let Some(row) = row {
569 let display_min = IVec2::new(rect.min.x.round() as i32, rect.min.y.round() as i32);
570 row.capture_rect = Some(IRect::from_corners(
571 display_min,
572 display_min + rect.size.as_ivec2(),
573 ));
574 row.group_alpha = alpha.0;
575 }
576 let mut hash = GEO_HASH_SEED;
585 fold_geo_i32(&mut hash, rect.size.x as i32);
589 fold_geo_i32(&mut hash, rect.size.y as i32);
590 mark_subtree(
591 root,
592 root,
593 root,
594 transform.translation,
595 &children,
596 &root_markers,
597 &geometry,
598 &mut hash,
599 &mut membership.node_to_layer,
600 );
601 repaints.geo_hashes.insert(root, hash);
602 let mut enclosing = None;
605 let mut depth = 1u32;
606 let mut cursor = root;
607 while let Ok(parent) = parents.get(cursor) {
608 cursor = parent.parent();
609 if root_markers.contains(cursor) {
610 if enclosing.is_none() {
611 enclosing = Some(cursor);
612 }
613 depth += 1;
614 }
615 }
616 membership.enclosing.insert(root, enclosing);
617 if let Some(row) = registry.layers.get_mut(&rnode.0) {
618 row.depth = depth;
619 }
620 }
621 let candidate_roots: HashSet<Entity> = bleed_candidates.iter().map(|(r, ..)| *r).collect();
634 warned_bleeds.retain(|e, _| candidate_roots.contains(e));
635 for (root, node, outset, value) in bleed_candidates {
636 let outer = match membership.enclosing.get(&root) {
637 Some(&Some(outer)) => outer,
638 _ => {
639 warned_bleeds.remove(&root);
640 continue;
641 }
642 };
643 let (Some(&inner_rect), Some(&outer_rect)) =
644 (frame_rects.get(&root), frame_rects.get(&outer))
645 else {
646 warned_bleeds.remove(&root);
647 continue;
648 };
649 let inner_max = inner_rect.min + inner_rect.size.as_vec2();
650 let outer_max = outer_rect.min + outer_rect.size.as_vec2();
651 let mut sides: Vec<&str> = Vec::new();
652 if inner_rect.min.x < outer_rect.min.x {
653 sides.push("left");
654 }
655 if inner_rect.min.y < outer_rect.min.y {
656 sides.push("top");
657 }
658 if inner_max.x > outer_max.x {
659 sides.push("right");
660 }
661 if inner_max.y > outer_max.y {
662 sides.push("bottom");
663 }
664 if sides.is_empty() {
665 warned_bleeds.remove(&root);
666 continue;
667 }
668 let pair = (inner_rect, outer_rect);
669 if warned_bleeds.get(&root) == Some(&pair) {
670 continue;
671 }
672 warned_bleeds.insert(root, pair);
673 let _scope = crate::diag::node_scope(node);
674 crate::diag::report(
675 "filterBleed",
676 &value,
677 &format!(
678 "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",
679 sides.join("/")
680 ),
681 );
682 }
683}
684
685#[allow(clippy::too_many_arguments)]
686fn mark_subtree(
687 node: Entity,
688 layer: Entity,
689 dfs_root: Entity,
690 root_translation: Vec2,
691 children: &Query<&Children>,
692 roots: &Query<(), With<PromotedLayer>>,
693 geometry: &Query<(&ComputedNode, &UiGlobalTransform)>,
694 hash: &mut u64,
695 map: &mut HashMap<Entity, Entity>,
696) {
697 if layer == dfs_root
703 && let Ok((computed, transform)) = geometry.get(node)
704 {
705 fold_member_geometry(hash, root_translation, transform, computed);
706 }
707 let layer = if roots.contains(node) { node } else { layer };
711 map.insert(node, layer);
712 if let Ok(kids) = children.get(node) {
713 for &kid in kids {
714 mark_subtree(
715 kid,
716 layer,
717 dfs_root,
718 root_translation,
719 children,
720 roots,
721 geometry,
722 hash,
723 map,
724 );
725 }
726 }
727}
728
729const GEO_HASH_SEED: u64 = 0xcbf29ce484222325;
731
732fn fold_geo_i32(hash: &mut u64, v: i32) {
733 for b in v.to_le_bytes() {
734 *hash = (*hash ^ b as u64).wrapping_mul(0x100000001b3);
735 }
736}
737
738pub fn fold_member_geometry(
746 hash: &mut u64,
747 root_translation: Vec2,
748 transform: &UiGlobalTransform,
749 computed: &ComputedNode,
750) {
751 let rel = transform.translation - root_translation;
752 fold_geo_i32(hash, (rel.x * 64.0).round() as i32);
753 fold_geo_i32(hash, (rel.y * 64.0).round() as i32);
754 let m = transform.matrix2;
755 fold_geo_i32(hash, (m.x_axis.x * 1024.0).round() as i32);
756 fold_geo_i32(hash, (m.x_axis.y * 1024.0).round() as i32);
757 fold_geo_i32(hash, (m.y_axis.x * 1024.0).round() as i32);
758 fold_geo_i32(hash, (m.y_axis.y * 1024.0).round() as i32);
759 let size = computed.size();
760 fold_geo_i32(hash, (size.x * 64.0).round() as i32);
761 fold_geo_i32(hash, (size.y * 64.0).round() as i32);
762}
763
764pub fn watch_layer_image_assets(
769 mut events: MessageReader<AssetEvent<Image>>,
770 images: Query<(Entity, &bevy::ui::widget::ImageNode)>,
771 registry: Res<LayersRegistry>,
772 mut dirt: ResMut<LayerContentDirt>,
773) {
774 if registry.layers.is_empty() {
775 events.clear();
776 return;
777 }
778 let mut touched: Vec<AssetId<Image>> = Vec::new();
779 for event in events.read() {
780 match event {
781 AssetEvent::LoadedWithDependencies { id } | AssetEvent::Modified { id } => {
782 touched.push(*id);
783 }
784 _ => {}
785 }
786 }
787 if touched.is_empty() {
788 return;
789 }
790 for (entity, image) in &images {
791 if touched.contains(&image.image.id()) {
792 dirt.nodes.push(entity);
793 }
794 }
795}
796
797pub fn resolve_layer_repaints(
804 mut dirt: ResMut<LayerContentDirt>,
805 mut state: ResMut<LayerRepaintState>,
806 membership: Res<LayerMembership>,
807 mut registry: ResMut<LayersRegistry>,
808 bridge: Option<Res<crate::bridge::JsBridge>>,
809 reshaped: Query<Entity, Changed<bevy::text::TextLayoutInfo>>,
810 focus: Query<&crate::bridge::FocusState>,
811) {
812 let state = &mut *state;
813 state.dirty.clear();
814
815 for e in dirt.nodes.drain(..) {
817 if let Some(&layer) = membership.node_to_layer.get(&e) {
818 state.dirty.insert(layer);
819 }
820 }
821 for e in dirt.composite_only.drain(..) {
825 let layer = membership.node_to_layer.get(&e).copied().unwrap_or(e);
826 if let Some(&Some(outer)) = membership.enclosing.get(&layer) {
827 state.dirty.insert(outer);
828 }
829 }
830 for e in &reshaped {
833 if let Some(&layer) = membership.node_to_layer.get(&e) {
834 state.dirty.insert(layer);
835 }
836 }
837 if let Some(bridge) = bridge {
840 for id in &bridge.editable_inputs {
841 if let Some(&e) = bridge.nodes.get(id)
842 && focus.get(e).is_ok_and(|f| f.0)
843 && let Some(&layer) = membership.node_to_layer.get(&e)
844 {
845 state.dirty.insert(layer);
846 }
847 }
848 }
849 for (&root, hash) in &state.geo_hashes {
852 if state.prev_hashes.get(&root) != Some(hash) {
853 state.dirty.insert(root);
854 }
855 }
856 std::mem::swap(&mut state.prev_hashes, &mut state.geo_hashes);
857 for meta in registry.layers.values() {
862 if meta.cache_policy == crate::protocol::LayerCache::Never {
863 state.dirty.insert(meta.entity);
864 }
865 }
866 let seeds: Vec<Entity> = state.dirty.iter().copied().collect();
869 for mut layer in seeds {
870 while let Some(&Some(outer)) = membership.enclosing.get(&layer) {
871 if !state.dirty.insert(outer) {
872 break; }
874 layer = outer;
875 }
876 }
877 for meta in registry.layers.values_mut() {
879 let dirty = state.dirty.contains(&meta.entity);
880 meta.cached = !dirty;
881 if dirty {
882 meta.repaints += 1;
883 }
884 }
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890 use crate::bridge::JsBridge;
891 use crate::protocol::{NodeId, Op, Outbound, Props};
892 use bevy::ui::BackgroundColor;
893
894 fn props(json: serde_json::Value) -> Props {
895 serde_json::from_value(json).expect("valid props")
896 }
897
898 #[test]
902 fn promotion_reasons_matrix() {
903 let promoted = |p: &Props, kids: usize, ineligible: bool| {
904 !promotion_reasons(p, kids, ineligible).is_empty()
905 };
906 let base = props(serde_json::json!({ "style": { "opacity": 0.5 } }));
907 assert!(promoted(&base, 1, false));
908 let one = props(serde_json::json!({ "style": { "opacity": 1.0 } }));
911 assert!(promoted(&one, 1, false));
912 assert!(!promoted(&base, 0, false));
914 let plain = props(serde_json::json!({ "style": { "width": 10 } }));
916 assert!(!promoted(&plain, 3, false));
917 let opted_out =
919 props(serde_json::json!({ "style": { "opacity": 0.5, "groupAlpha": false } }));
920 assert!(!promoted(&opted_out, 1, false));
921 let hover_only = props(serde_json::json!({
923 "style": { "width": 10 },
924 "hoverStyle": { "opacity": 0.8 },
925 }));
926 assert!(promoted(&hover_only, 1, false));
927 let animated = props(serde_json::json!({
929 "style": { "opacity": { "animated": { "id": 1 } } },
930 }));
931 assert!(promoted(&animated, 1, false));
932 assert!(!promoted(&base, 1, true));
934
935 let forced = props(serde_json::json!({ "style": { "cache": "always" } }));
938 assert_eq!(
939 promotion_reasons(&forced, 0, false).0,
940 PromotionReasons::FORCED
941 );
942 let forced_opted_out = props(serde_json::json!({
943 "style": { "cache": "always", "opacity": 0.5, "groupAlpha": false }
944 }));
945 assert_eq!(
946 promotion_reasons(&forced_opted_out, 1, false).0,
947 PromotionReasons::FORCED
948 );
949 let both = props(serde_json::json!({
951 "style": { "cache": "always", "opacity": 0.5 }
952 }));
953 assert_eq!(
954 promotion_reasons(&both, 1, false).0,
955 PromotionReasons::FORCED | PromotionReasons::OPACITY
956 );
957 let auto = props(serde_json::json!({ "style": { "cache": "auto" } }));
959 assert!(!promoted(&auto, 1, false));
960 assert!(!promoted(&forced, 1, true));
962
963 let filtered = props(serde_json::json!({ "style": { "filter": { "name": "blur" } } }));
967 assert_eq!(
968 promotion_reasons(&filtered, 0, false).0,
969 PromotionReasons::FILTER
970 );
971 let empty_chain = props(serde_json::json!({ "style": { "filter": [] } }));
973 assert!(!promoted(&empty_chain, 1, false));
974 let filter_and_opacity = props(serde_json::json!({
976 "style": { "filter": { "name": "blur" }, "opacity": 0.5 }
977 }));
978 assert_eq!(
979 promotion_reasons(&filter_and_opacity, 1, false).0,
980 PromotionReasons::FILTER | PromotionReasons::OPACITY
981 );
982 assert!(!promoted(&filtered, 0, true));
984 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
989 let variant_filter = props(serde_json::json!({
990 "style": { "width": 10 },
991 (variant): { "filter": { "name": "blur" } },
992 }));
993 assert_eq!(
994 promotion_reasons(&variant_filter, 0, false).0,
995 PromotionReasons::FILTER,
996 "{variant}-only filter promotes eagerly"
997 );
998 }
999 let empty_variant = props(serde_json::json!({
1001 "style": { "width": 10 },
1002 "hoverStyle": { "filter": [] },
1003 }));
1004 assert!(!promoted(&empty_variant, 1, false));
1005
1006 let transformed = props(serde_json::json!({
1010 "style": { "transform3d": { "rotateY": 45 } }
1011 }));
1012 assert_eq!(
1013 promotion_reasons(&transformed, 0, false).0,
1014 PromotionReasons::TRANSFORM3D
1015 );
1016 let identity_3d = props(serde_json::json!({ "style": { "transform3d": {} } }));
1017 assert_eq!(
1018 promotion_reasons(&identity_3d, 0, false).0,
1019 PromotionReasons::TRANSFORM3D
1020 );
1021 let opted_out = props(serde_json::json!({
1023 "style": { "transform3d": {}, "groupAlpha": false }
1024 }));
1025 assert!(promoted(&opted_out, 0, false));
1026 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1029 let variant_3d = props(serde_json::json!({
1030 "style": { "width": 10 },
1031 (variant): { "transform3d": { "rotateX": 10 } },
1032 }));
1033 assert_eq!(
1034 promotion_reasons(&variant_3d, 0, false).0,
1035 PromotionReasons::TRANSFORM3D,
1036 "{variant}-only transform3d promotes eagerly"
1037 );
1038 }
1039 assert!(!promoted(&transformed, 0, true));
1040
1041 let backdrop =
1045 props(serde_json::json!({ "style": { "backdropFilter": { "name": "blur" } } }));
1046 assert_eq!(
1047 promotion_reasons(&backdrop, 0, false).0,
1048 PromotionReasons::BACKDROP
1049 );
1050 let empty_backdrop = props(serde_json::json!({ "style": { "backdropFilter": [] } }));
1051 assert!(!promoted(&empty_backdrop, 1, false));
1052 let both_chains = props(serde_json::json!({
1054 "style": { "backdropFilter": { "name": "blur" }, "filter": { "name": "sepia" } }
1055 }));
1056 assert_eq!(
1057 promotion_reasons(&both_chains, 0, false).0,
1058 PromotionReasons::BACKDROP | PromotionReasons::FILTER
1059 );
1060 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1061 let variant_backdrop = props(serde_json::json!({
1062 "style": { "width": 10 },
1063 (variant): { "backdropFilter": { "name": "blur" } },
1064 }));
1065 assert_eq!(
1066 promotion_reasons(&variant_backdrop, 0, false).0,
1067 PromotionReasons::BACKDROP,
1068 "{variant}-only backdropFilter promotes eagerly"
1069 );
1070 }
1071 assert!(!promoted(&backdrop, 0, true));
1072
1073 let plain = props(serde_json::json!({ "style": { "width": 10 } }));
1075 assert!(!promoted(&plain, 1, false));
1076 }
1077
1078 fn layer_app() -> (bevy::app::App, crossbeam_channel::Sender<Vec<Op>>) {
1081 use bevy::app::App;
1082 let mut app = App::new();
1083 app.add_plugins((MinimalPlugins, AssetPlugin::default()));
1084 app.init_asset::<Image>();
1085 app.init_asset::<bevy::image::TextureAtlasLayout>();
1086 app.init_resource::<crate::plugin::Fonts>();
1087 app.init_resource::<crate::reconcile::OpApplyStats>();
1088 app.init_resource::<crate::ui_map::AtlasLayoutCache>();
1089 app.init_resource::<LayersRegistry>();
1090 app.init_resource::<LayerMembership>();
1091
1092 let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
1093 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
1094 std::mem::forget(out_rx);
1095 let root = app.world_mut().spawn_empty().id();
1096 app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
1097 app.add_systems(
1098 Update,
1099 (
1100 crate::reconcile::apply_js_ops,
1101 evaluate_layer_promotions.after(crate::reconcile::apply_js_ops),
1102 ),
1103 );
1104 (app, ops_tx)
1105 }
1106
1107 fn create(id: NodeId, json: serde_json::Value) -> Op {
1108 Op::Create {
1109 id,
1110 kind: "node".into(),
1111 props: props(json),
1112 text: None,
1113 }
1114 }
1115
1116 fn update(id: NodeId, json: serde_json::Value, style_unset: &[&str]) -> Op {
1117 Op::Update {
1118 id,
1119 props: props(json),
1120 unset: vec![],
1121 style_unset: style_unset.iter().map(|s| s.to_string()).collect(),
1122 }
1123 }
1124
1125 fn entity_of(app: &bevy::app::App, id: NodeId) -> Entity {
1126 *app.world().resource::<JsBridge>().nodes.get(&id).unwrap()
1127 }
1128
1129 #[test]
1134 fn promotion_lifecycle_and_fold_handoff() {
1135 let (mut app, ops_tx) = layer_app();
1136 ops_tx
1137 .send(vec![
1138 create(
1139 1,
1140 serde_json::json!({
1141 "style": { "opacity": 0.5, "backgroundColor": "#ff0000" }
1142 }),
1143 ),
1144 create(2, serde_json::json!({})),
1145 Op::Append {
1146 parent: 1,
1147 child: 2,
1148 },
1149 ])
1150 .unwrap();
1151 app.update();
1152
1153 let e = entity_of(&app, 1);
1154 assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
1155 assert_eq!(
1156 app.world().get::<LayerGroupAlpha>(e),
1157 Some(&LayerGroupAlpha(0.5))
1158 );
1159 let registry = app.world().resource::<LayersRegistry>();
1160 assert_eq!(registry.layers.len(), 1);
1161 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::OPACITY);
1162 let bg = app.world().get::<BackgroundColor>(e).unwrap();
1165 assert_eq!(bg.0.alpha(), 1.0, "promoted bg keeps its own alpha");
1166
1167 ops_tx
1169 .send(vec![update(
1170 1,
1171 serde_json::json!({ "style": { "groupAlpha": false } }),
1172 &[],
1173 )])
1174 .unwrap();
1175 app.update();
1176 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1177 assert!(app.world().get::<LayerGroupAlpha>(e).is_none());
1178 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1179 let bg = app.world().get::<BackgroundColor>(e).unwrap();
1180 assert_eq!(bg.0.alpha(), 0.5, "demoted bg re-bakes the fold");
1181
1182 ops_tx
1184 .send(vec![update(1, serde_json::json!({}), &["groupAlpha"])])
1185 .unwrap();
1186 app.update();
1187 let e1 = entity_of(&app, 1);
1188 assert!(app.world().get::<PromotedLayer>(e1).is_some());
1189 ops_tx
1190 .send(vec![Op::Remove {
1191 parent: 1,
1192 child: 2,
1193 }])
1194 .unwrap();
1195 app.update();
1196 assert!(
1197 app.world().get::<PromotedLayer>(e1).is_none(),
1198 "no children → demoted"
1199 );
1200 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1201 }
1202
1203 #[test]
1206 fn forced_cache_lifecycle() {
1207 let (mut app, ops_tx) = layer_app();
1208 ops_tx
1209 .send(vec![create(
1210 1,
1211 serde_json::json!({ "style": { "cache": "always" } }),
1212 )])
1213 .unwrap();
1214 app.update();
1215 let e = entity_of(&app, 1);
1216 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1217 assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
1218 assert_eq!(
1220 app.world().get::<LayerGroupAlpha>(e),
1221 Some(&LayerGroupAlpha(1.0))
1222 );
1223
1224 ops_tx
1225 .send(vec![update(1, serde_json::json!({}), &["cache"])])
1226 .unwrap();
1227 app.update();
1228 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1229 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1230 }
1231
1232 #[test]
1237 fn never_cache_lifecycle_and_policy() {
1238 use crate::protocol::LayerCache;
1239 let (mut app, ops_tx) = layer_app();
1240 ops_tx
1241 .send(vec![create(
1242 1,
1243 serde_json::json!({ "style": { "cache": "never" } }),
1244 )])
1245 .unwrap();
1246 app.update();
1247 let e = entity_of(&app, 1);
1248 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1249 assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
1250 let row = app.world().resource::<LayersRegistry>().layers[&1];
1251 assert_eq!(row.cache_policy, LayerCache::Never);
1252
1253 ops_tx
1255 .send(vec![update(
1256 1,
1257 serde_json::json!({ "style": { "cache": "always" } }),
1258 &[],
1259 )])
1260 .unwrap();
1261 app.update();
1262 assert!(
1263 app.world().get::<PromotedLayer>(e).is_some(),
1264 "stays promoted"
1265 );
1266 let row = app.world().resource::<LayersRegistry>().layers[&1];
1267 assert_eq!(row.cache_policy, LayerCache::Always);
1268
1269 ops_tx
1270 .send(vec![update(1, serde_json::json!({}), &["cache"])])
1271 .unwrap();
1272 app.update();
1273 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1274 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1275 }
1276
1277 #[test]
1280 fn filter_promotion_lifecycle() {
1281 let (mut app, ops_tx) = layer_app();
1282 ops_tx
1283 .send(vec![create(
1284 1,
1285 serde_json::json!({ "style": { "filter": { "name": "grayscale" } } }),
1286 )])
1287 .unwrap();
1288 app.update();
1289 let e = entity_of(&app, 1);
1290 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1291 assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
1292 assert_eq!(
1294 app.world().get::<LayerGroupAlpha>(e),
1295 Some(&LayerGroupAlpha(1.0))
1296 );
1297 let registry = app.world().resource::<LayersRegistry>();
1298 assert_eq!(registry.layers.len(), 1);
1299 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::FILTER);
1300
1301 ops_tx
1302 .send(vec![update(1, serde_json::json!({}), &["filter"])])
1303 .unwrap();
1304 app.update();
1305 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1306 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1307 }
1308
1309 #[test]
1313 fn backdrop_promotion_lifecycle() {
1314 let (mut app, ops_tx) = layer_app();
1315 ops_tx
1316 .send(vec![create(
1317 1,
1318 serde_json::json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
1319 )])
1320 .unwrap();
1321 app.update();
1322 let e = entity_of(&app, 1);
1323 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1324 assert_eq!(promoted.reasons.0, PromotionReasons::BACKDROP);
1325 assert_eq!(
1326 app.world().get::<LayerGroupAlpha>(e),
1327 Some(&LayerGroupAlpha(1.0))
1328 );
1329 let registry = app.world().resource::<LayersRegistry>();
1330 assert_eq!(registry.layers.len(), 1);
1331 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::BACKDROP);
1332
1333 ops_tx
1334 .send(vec![update(1, serde_json::json!({}), &["backdropFilter"])])
1335 .unwrap();
1336 app.update();
1337 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1338 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1339 }
1340
1341 #[test]
1347 fn hover_filter_promotes_eagerly_from_creation() {
1348 let (mut app, ops_tx) = layer_app();
1349 ops_tx
1350 .send(vec![create(
1351 1,
1352 serde_json::json!({
1353 "style": { "width": 10 },
1354 "hoverStyle": { "filter": { "name": "blur", "params": { "radius": 8 } } },
1355 }),
1356 )])
1357 .unwrap();
1358 app.update();
1359 let e = entity_of(&app, 1);
1360 let promoted = app
1361 .world()
1362 .get::<PromotedLayer>(e)
1363 .expect("promoted before any hover");
1364 assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
1365 assert_eq!(
1366 app.world().resource::<LayersRegistry>().layers[&1]
1367 .reasons
1368 .0,
1369 PromotionReasons::FILTER
1370 );
1371
1372 ops_tx
1374 .send(vec![Op::Update {
1375 id: 1,
1376 props: props(serde_json::json!({})),
1377 unset: vec!["hoverStyle".into()],
1378 style_unset: vec![],
1379 }])
1380 .unwrap();
1381 app.update();
1382 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1383 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1384 }
1385
1386 #[test]
1391 fn repaint_resolution_and_propagation() {
1392 let mut world = World::new();
1393 world.init_resource::<LayerContentDirt>();
1394 world.init_resource::<LayerRepaintState>();
1395 world.init_resource::<LayersRegistry>();
1396
1397 let outer = world.spawn_empty().id();
1398 let inner = world.spawn_empty().id();
1399 let member = world.spawn_empty().id(); let outer_member = world.spawn_empty().id(); let mut membership = LayerMembership::default();
1402 membership.node_to_layer.insert(outer, outer);
1403 membership.node_to_layer.insert(inner, inner);
1404 membership.node_to_layer.insert(member, inner);
1405 membership.node_to_layer.insert(outer_member, outer);
1406 membership.enclosing.insert(outer, None);
1407 membership.enclosing.insert(inner, Some(outer));
1408 world.insert_resource(membership);
1409
1410 let mut schedule = Schedule::default();
1411 schedule.add_systems(resolve_layer_repaints);
1412 let mut run = |world: &mut World| {
1413 schedule.run(world);
1414 world.resource::<LayerRepaintState>().dirty.clone()
1415 };
1416
1417 {
1419 let mut state = world.resource_mut::<LayerRepaintState>();
1420 state.geo_hashes.insert(outer, 1);
1421 state.geo_hashes.insert(inner, 2);
1422 }
1423 let dirty = run(&mut world);
1424 assert!(
1425 dirty.contains(&outer) && dirty.contains(&inner),
1426 "{dirty:?}"
1427 );
1428
1429 {
1431 let mut state = world.resource_mut::<LayerRepaintState>();
1432 state.geo_hashes.insert(outer, 1);
1433 state.geo_hashes.insert(inner, 2);
1434 }
1435 assert!(run(&mut world).is_empty());
1436
1437 {
1439 let mut state = world.resource_mut::<LayerRepaintState>();
1440 state.geo_hashes.insert(outer, 1);
1441 state.geo_hashes.insert(inner, 2);
1442 world.resource_mut::<LayerContentDirt>().nodes.push(member);
1443 }
1444 let dirty = run(&mut world);
1445 assert!(dirty.contains(&inner) && dirty.contains(&outer));
1446
1447 {
1450 let mut state = world.resource_mut::<LayerRepaintState>();
1451 state.geo_hashes.insert(outer, 1);
1452 state.geo_hashes.insert(inner, 2);
1453 world
1454 .resource_mut::<LayerContentDirt>()
1455 .composite_only
1456 .push(inner);
1457 }
1458 let dirty = run(&mut world);
1459 assert!(
1460 dirty.contains(&outer) && !dirty.contains(&inner),
1461 "{dirty:?}"
1462 );
1463
1464 {
1466 let mut state = world.resource_mut::<LayerRepaintState>();
1467 state.geo_hashes.insert(outer, 1);
1468 state.geo_hashes.insert(inner, 2);
1469 world
1470 .resource_mut::<LayerContentDirt>()
1471 .composite_only
1472 .push(outer);
1473 }
1474 assert!(run(&mut world).is_empty());
1475
1476 {
1478 let mut state = world.resource_mut::<LayerRepaintState>();
1479 state.geo_hashes.insert(outer, 1);
1480 state.geo_hashes.insert(inner, 3);
1481 }
1482 let dirty = run(&mut world);
1483 assert!(dirty.contains(&inner) && dirty.contains(&outer));
1484
1485 {
1487 let mut state = world.resource_mut::<LayerRepaintState>();
1488 state.geo_hashes.insert(outer, 1);
1489 state.geo_hashes.insert(inner, 3);
1490 world
1491 .resource_mut::<LayerContentDirt>()
1492 .nodes
1493 .push(outer_member);
1494 }
1495 let dirty = run(&mut world);
1496 assert!(dirty.contains(&outer) && !dirty.contains(&inner));
1497 }
1498
1499 #[test]
1503 fn never_policy_repaints_every_frame() {
1504 use crate::protocol::LayerCache;
1505 let mut world = World::new();
1506 world.init_resource::<LayerContentDirt>();
1507 world.init_resource::<LayerRepaintState>();
1508 world.init_resource::<LayersRegistry>();
1509
1510 let outer = world.spawn_empty().id();
1511 let inner = world.spawn_empty().id(); let other = world.spawn_empty().id(); let mut membership = LayerMembership::default();
1514 membership.node_to_layer.insert(outer, outer);
1515 membership.node_to_layer.insert(inner, inner);
1516 membership.node_to_layer.insert(other, other);
1517 membership.enclosing.insert(outer, None);
1518 membership.enclosing.insert(inner, Some(outer));
1519 membership.enclosing.insert(other, None);
1520 world.insert_resource(membership);
1521
1522 let meta = |node: NodeId, entity: Entity, policy: LayerCache| LayerMeta {
1523 node,
1524 entity,
1525 reasons: PromotionReasons(PromotionReasons::FORCED),
1526 group_alpha: 1.0,
1527 capture_rect: None,
1528 depth: 1,
1529 repaints: 0,
1530 cached: false,
1531 cache_policy: policy,
1532 };
1533 {
1534 let mut registry = world.resource_mut::<LayersRegistry>();
1535 registry.layers.insert(1, meta(1, outer, LayerCache::Auto));
1536 registry.layers.insert(2, meta(2, inner, LayerCache::Never));
1537 registry
1538 .layers
1539 .insert(3, meta(3, other, LayerCache::Always));
1540 }
1541
1542 let mut schedule = Schedule::default();
1543 schedule.add_systems(resolve_layer_repaints);
1544 for frame in 0..2 {
1545 schedule.run(&mut world);
1546 let state = world.resource::<LayerRepaintState>();
1547 assert!(
1548 state.dirty.contains(&inner) && state.dirty.contains(&outer),
1549 "frame {frame}: {:?}",
1550 state.dirty
1551 );
1552 assert!(!state.dirty.contains(&other), "frame {frame}");
1553 }
1554 let registry = world.resource::<LayersRegistry>();
1555 assert_eq!(registry.layers[&2].repaints, 2);
1556 assert!(!registry.layers[&2].cached);
1557 assert!(registry.layers[&3].cached);
1558 }
1559
1560 #[test]
1564 fn geometry_fold_translation_invariance() {
1565 use bevy::math::Affine2;
1566
1567 let node = |size: Vec2, pos: Vec2| {
1568 let computed = ComputedNode {
1569 size,
1570 ..Default::default()
1571 };
1572 (
1573 computed,
1574 UiGlobalTransform::from(Affine2::from_translation(pos)),
1575 )
1576 };
1577 let fold = |members: &[(ComputedNode, UiGlobalTransform)], root: Vec2| {
1578 let mut hash = GEO_HASH_SEED;
1579 for (computed, transform) in members {
1580 fold_member_geometry(&mut hash, root, transform, computed);
1581 }
1582 hash
1583 };
1584
1585 let members = [
1586 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1587 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
1588 ];
1589 let base = fold(&members, Vec2::new(10.0, 20.0));
1590
1591 let delta = Vec2::new(123.4, -56.78);
1594 let shifted = [
1595 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0) + delta),
1596 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0) + delta),
1597 ];
1598 assert_eq!(base, fold(&shifted, Vec2::new(10.0, 20.0) + delta));
1599
1600 let moved = [
1602 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1603 node(Vec2::new(30.0, 30.0), Vec2::new(41.0, 25.0)),
1604 ];
1605 assert_ne!(base, fold(&moved, Vec2::new(10.0, 20.0)));
1606
1607 let resized = [
1609 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1610 node(Vec2::new(31.0, 30.0), Vec2::new(40.0, 25.0)),
1611 ];
1612 assert_ne!(base, fold(&resized, Vec2::new(10.0, 20.0)));
1613
1614 let mut scaled = [
1616 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1617 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
1618 ];
1619 scaled[1].1 = UiGlobalTransform::from(Affine2::from_scale_angle_translation(
1620 Vec2::splat(1.5),
1621 0.0,
1622 Vec2::new(40.0, 25.0),
1623 ));
1624 assert_ne!(base, fold(&scaled, Vec2::new(10.0, 20.0)));
1625
1626 let noisy = [
1628 node(Vec2::new(100.0, 50.0), Vec2::new(10.0 + 1e-4, 20.0)),
1629 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0 - 1e-4)),
1630 ];
1631 assert_eq!(base, fold(&noisy, Vec2::new(10.0, 20.0)));
1632 }
1633
1634 fn geometry_world() -> (World, Schedule) {
1640 let mut world = World::new();
1641 world.init_resource::<LayerMembership>();
1642 world.init_resource::<LayersRegistry>();
1643 world.init_resource::<LayerRepaintState>();
1644 world.init_resource::<LayerContentDirt>();
1645 let mut schedule = Schedule::default();
1646 schedule.add_systems((sync_layer_geometry, resolve_layer_repaints).chain());
1647 (world, schedule)
1648 }
1649
1650 fn spawn_layer_root(world: &mut World, id: NodeId, size: Vec2, center: Vec2) -> Entity {
1654 world
1655 .spawn((
1656 ComputedNode {
1657 size,
1658 ..Default::default()
1659 },
1660 UiGlobalTransform::from(bevy::math::Affine2::from_translation(center)),
1661 crate::bridge::RNode(id),
1662 LayerGroupAlpha(1.0),
1663 PromotedLayer {
1664 reasons: PromotionReasons(PromotionReasons::FILTER),
1665 },
1666 ))
1667 .id()
1668 }
1669
1670 fn filter_outset(world: &mut World, e: Entity, outset_px: u32) {
1671 world
1672 .entity_mut(e)
1673 .insert(crate::filters::ResolvedFilterChain {
1674 outset_px,
1675 ..Default::default()
1676 });
1677 }
1678
1679 fn backdrop_outset(world: &mut World, e: Entity, outset_px: u32) {
1680 world
1681 .entity_mut(e)
1682 .insert(crate::filters::ResolvedBackdropChain(
1683 crate::filters::ResolvedFilterChain {
1684 outset_px,
1685 ..Default::default()
1686 },
1687 ));
1688 }
1689
1690 #[test]
1695 fn outset_inflates_rect_by_quantized_margin() {
1696 let (mut world, mut schedule) = geometry_world();
1697 let size = Vec2::new(100.0, 60.0);
1698 let center = Vec2::new(50.0, 30.0);
1699 let plain = spawn_layer_root(&mut world, 1, size, center);
1700 let blurred = spawn_layer_root(&mut world, 2, size, center);
1701 filter_outset(&mut world, blurred, 12); let big = spawn_layer_root(&mut world, 3, size, center);
1703 filter_outset(&mut world, big, 60); schedule.run(&mut world);
1705
1706 let base = *world.get::<LayerCaptureRect>(plain).expect("baseline rect");
1707 assert_eq!(base.min, Vec2::ZERO);
1708 assert_eq!(base.size, UVec2::new(100, 60));
1709 let rect = *world.get::<LayerCaptureRect>(blurred).expect("rect");
1711 assert_eq!(rect.min, base.min - Vec2::splat(16.0));
1712 assert_eq!(rect.size, base.size + UVec2::splat(32));
1713 let rect = *world.get::<LayerCaptureRect>(big).expect("rect");
1715 assert_eq!(rect.min, base.min - Vec2::splat(64.0));
1716 assert_eq!(rect.size, base.size + UVec2::splat(128));
1717 }
1718
1719 #[test]
1724 fn backdrop_outset_inflates_rect_and_maxes_with_content() {
1725 let (mut world, mut schedule) = geometry_world();
1726 let size = Vec2::new(100.0, 60.0);
1727 let center = Vec2::new(50.0, 30.0);
1728 let plain = spawn_layer_root(&mut world, 1, size, center);
1729 let frosted = spawn_layer_root(&mut world, 2, size, center);
1730 backdrop_outset(&mut world, frosted, 12); let both = spawn_layer_root(&mut world, 3, size, center);
1732 filter_outset(&mut world, both, 4); backdrop_outset(&mut world, both, 40); schedule.run(&mut world);
1735
1736 let base = *world.get::<LayerCaptureRect>(plain).expect("baseline");
1737 assert_eq!(base.outset, 0);
1738 let rect = *world.get::<LayerCaptureRect>(frosted).expect("rect");
1739 assert_eq!(rect.min, base.min - Vec2::splat(16.0));
1740 assert_eq!(rect.size, base.size + UVec2::splat(32));
1741 assert_eq!(rect.outset, 16);
1742 let rect = *world.get::<LayerCaptureRect>(both).expect("rect");
1743 assert_eq!(rect.min, base.min - Vec2::splat(48.0));
1744 assert_eq!(rect.size, base.size + UVec2::splat(96));
1745 assert_eq!(rect.outset, 48);
1746 }
1747
1748 #[test]
1752 fn outset_within_quantize_step_holds_rect_and_cache() {
1753 let (mut world, mut schedule) = geometry_world();
1754 let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
1755 filter_outset(&mut world, e, 12);
1756 schedule.run(&mut world);
1757 assert!(
1758 world.resource::<LayerRepaintState>().dirty.contains(&e),
1759 "first frame repaints"
1760 );
1761 let before = *world.get::<LayerCaptureRect>(e).expect("rect");
1762
1763 filter_outset(&mut world, e, 14); schedule.run(&mut world);
1765 assert_eq!(*world.get::<LayerCaptureRect>(e).expect("rect"), before);
1766 assert!(
1767 world.resource::<LayerRepaintState>().dirty.is_empty(),
1768 "no repaint within a quantize step"
1769 );
1770 }
1771
1772 #[test]
1776 fn outset_crossing_quantize_step_recaptures() {
1777 let (mut world, mut schedule) = geometry_world();
1778 let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
1779 filter_outset(&mut world, e, 14); schedule.run(&mut world);
1781 schedule.run(&mut world); assert!(world.resource::<LayerRepaintState>().dirty.is_empty());
1783 let before = *world.get::<LayerCaptureRect>(e).expect("rect");
1784
1785 filter_outset(&mut world, e, 18); schedule.run(&mut world);
1787 let after = *world.get::<LayerCaptureRect>(e).expect("rect");
1788 assert_eq!(after.min, before.min - Vec2::splat(16.0));
1789 assert_eq!(after.size, before.size + UVec2::splat(32));
1790 assert!(
1791 world.resource::<LayerRepaintState>().dirty.contains(&e),
1792 "step crossing re-captures"
1793 );
1794 }
1795
1796 #[test]
1799 fn zero_content_size_stays_inactive_despite_outset() {
1800 let (mut world, mut schedule) = geometry_world();
1801 let e = spawn_layer_root(&mut world, 1, Vec2::ZERO, Vec2::ZERO);
1802 filter_outset(&mut world, e, 60);
1803 schedule.run(&mut world);
1804 assert!(
1805 world.get::<LayerCaptureRect>(e).is_none(),
1806 "zero content size stays inactive"
1807 );
1808 }
1809
1810 #[cfg(all(feature = "devtools", debug_assertions))]
1815 #[test]
1816 fn nested_filter_bleed_warns_when_clipped() {
1817 let _lock = crate::diag::test_lock();
1818 crate::diag::arm_runtime();
1819 let _ = crate::diag::take_runtime_warnings();
1820
1821 let (mut world, mut schedule) = geometry_world();
1822 let outer = spawn_layer_root(&mut world, 1, Vec2::splat(200.0), Vec2::splat(100.0));
1824 let inner = spawn_layer_root(&mut world, 2, Vec2::splat(100.0), Vec2::splat(100.0));
1826 world.entity_mut(inner).insert((
1827 ChildOf(outer),
1828 crate::filters::FilterInput(crate::filters::FilterChain(vec![
1829 crate::filters::FilterUse {
1830 name: "blur".into(),
1831 params: Default::default(),
1832 },
1833 ])),
1834 ));
1835 filter_outset(&mut world, inner, 12);
1837 schedule.run(&mut world);
1838 let bleeds = |warns: Vec<crate::diag::RuntimeWarning>| -> Vec<_> {
1839 warns
1840 .into_iter()
1841 .filter(|w| w.kind == "filterBleed")
1842 .collect()
1843 };
1844 assert!(
1845 bleeds(crate::diag::take_runtime_warnings()).is_empty(),
1846 "contained bleed does not warn"
1847 );
1848
1849 filter_outset(&mut world, inner, 60);
1851 schedule.run(&mut world);
1852 let warns = bleeds(crate::diag::take_runtime_warnings());
1853 assert_eq!(warns.len(), 1, "{warns:?}");
1854 assert_eq!(warns[0].node, Some(2));
1855 assert_eq!(warns[0].value, "blur");
1856 for side in ["left", "top", "right", "bottom"] {
1857 assert!(warns[0].message.contains(side), "{}", warns[0].message);
1858 }
1859
1860 schedule.run(&mut world);
1862 assert!(
1863 bleeds(crate::diag::take_runtime_warnings()).is_empty(),
1864 "unchanged bleed is not re-reported"
1865 );
1866 }
1867
1868 #[test]
1871 fn removal_prunes_registry() {
1872 let (mut app, ops_tx) = layer_app();
1873 ops_tx
1874 .send(vec![
1875 create(1, serde_json::json!({})),
1876 create(2, serde_json::json!({ "style": { "opacity": 0.3 } })),
1877 create(3, serde_json::json!({})),
1878 Op::Append {
1879 parent: 1,
1880 child: 2,
1881 },
1882 Op::Append {
1883 parent: 2,
1884 child: 3,
1885 },
1886 ])
1887 .unwrap();
1888 app.update();
1889 assert_eq!(app.world().resource::<LayersRegistry>().layers.len(), 1);
1890
1891 ops_tx
1892 .send(vec![Op::Remove {
1893 parent: 1,
1894 child: 2,
1895 }])
1896 .unwrap();
1897 app.update();
1898 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1899 assert!(
1900 app.world()
1901 .resource::<JsBridge>()
1902 .promoted_layers
1903 .is_empty()
1904 );
1905 }
1906}