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
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::style::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(
289 crate::protocol::style::LayerCache::Always | crate::protocol::style::LayerCache::Never
290 )
291 );
292 if forced && !ineligible_element {
293 reasons |= PromotionReasons::FORCED;
294 }
295 let filtered = props
305 .all_styles()
306 .any(|s| s.filter.as_ref().is_some_and(|chain| !chain.0.is_empty()));
307 if filtered && !ineligible_element {
308 reasons |= PromotionReasons::FILTER;
309 }
310 let transformed3d = props.all_styles().any(|s| s.transform3d.is_some());
314 if transformed3d && !ineligible_element {
315 reasons |= PromotionReasons::TRANSFORM3D;
316 }
317 let backdrop = props
322 .all_styles()
323 .any(|s| s.backdrop_filter.as_ref().is_some_and(|c| !c.0.is_empty()));
324 if backdrop && !ineligible_element {
325 reasons |= PromotionReasons::BACKDROP;
326 }
327 PromotionReasons(reasons)
328}
329
330pub fn evaluate_layer_promotions(
337 mut commands: Commands,
338 mut bridge: ResMut<crate::bridge::JsBridge>,
339 mut registry: ResMut<LayersRegistry>,
340 assets: Res<AssetServer>,
341 mut ui_assets: crate::reconcile::UiAssets,
342 mut style_variants: Query<&mut crate::bridge::StyleVariants>,
343) {
344 registry
347 .layers
348 .retain(|id, meta| bridge.nodes.get(id) == Some(&meta.entity));
349
350 if bridge.layer_dirty.is_empty() {
351 return;
352 }
353 let dirty: Vec<NodeId> = bridge.layer_dirty.drain().collect();
354 for id in dirty {
355 let Some(&entity) = bridge.nodes.get(&id) else {
356 continue; };
358 let reasons = match bridge.props_cache.get(&id) {
359 Some(props) => promotion_reasons(
360 props,
361 bridge.children_of(id).count(),
362 bridge.text_styles.contains_key(&id) || bridge.is_detached_root(id),
366 ),
367 None => PromotionReasons::default(),
368 };
369 let was_promoted = bridge.promoted_layers.contains(&id);
370 if !reasons.is_empty() {
371 let alpha = bridge
374 .props_cache
375 .get(&id)
376 .and_then(|p| p.style.as_ref())
377 .and_then(|s| s.opacity.static_val())
378 .unwrap_or(1.0);
379 let cache_policy = bridge
380 .props_cache
381 .get(&id)
382 .and_then(|p| p.style.as_ref())
383 .and_then(|s| s.cache)
384 .unwrap_or_default();
385 commands
386 .entity(entity)
387 .insert((PromotedLayer { reasons }, LayerGroupAlpha(alpha)));
388 bridge.promoted_layers.insert(id);
389 let row = registry.layers.entry(id).or_insert(LayerMeta {
392 node: id,
393 entity,
394 reasons,
395 group_alpha: alpha,
396 capture_rect: None,
397 depth: 1,
398 repaints: 0,
399 cached: false,
400 cache_policy,
401 });
402 row.entity = entity;
403 row.reasons = reasons;
404 row.group_alpha = alpha;
405 row.cache_policy = cache_policy;
406 if !was_promoted && let Some(props) = bridge.props_cache.get(&id) {
407 crate::reconcile::reapply_opacity_outputs(
410 &mut commands,
411 entity,
412 props,
413 true,
414 bridge.foreign_images.contains(&id),
415 &assets,
416 &mut ui_assets,
417 &mut style_variants,
418 );
419 }
420 } else if was_promoted {
421 commands.entity(entity).remove::<(
425 PromotedLayer,
426 LayerGroupAlpha,
427 LayerCaptureRect,
428 crate::filters::ResolvedFilterChain,
429 crate::filters::ResolvedBackdropChain,
430 transform3d::LayerTransform3d,
431 transform3d::LayerTransform3dMatrix,
432 )>();
433 bridge.promoted_layers.remove(&id);
434 registry.layers.remove(&id);
435 if let Some(props) = bridge.props_cache.get(&id) {
436 crate::reconcile::reapply_opacity_outputs(
438 &mut commands,
439 entity,
440 props,
441 false,
442 bridge.foreign_images.contains(&id),
443 &assets,
444 &mut ui_assets,
445 &mut style_variants,
446 );
447 }
448 }
449 }
450}
451
452#[allow(clippy::too_many_arguments, clippy::type_complexity)]
462pub fn sync_layer_geometry(
463 mut commands: Commands,
464 roots: Query<
465 (
466 Entity,
467 &ComputedNode,
468 &UiGlobalTransform,
469 &crate::bridge::RNode,
470 &LayerGroupAlpha,
471 Option<&crate::filters::ResolvedFilterChain>,
472 Option<&crate::filters::FilterInput>,
473 Option<&crate::filters::ResolvedBackdropChain>,
474 ),
475 With<PromotedLayer>,
476 >,
477 root_markers: Query<(), With<PromotedLayer>>,
478 children: Query<&Children>,
479 parents: Query<&ChildOf>,
480 existing_rects: Query<&LayerCaptureRect>,
481 geometry: Query<(&ComputedNode, &UiGlobalTransform)>,
482 mut membership: ResMut<LayerMembership>,
483 mut registry: ResMut<LayersRegistry>,
484 mut repaints: ResMut<LayerRepaintState>,
485 mut warned_bleeds: Local<HashMap<Entity, (LayerCaptureRect, LayerCaptureRect)>>,
490) {
491 membership.node_to_layer.clear();
492 membership.enclosing.clear();
493 repaints.geo_hashes.clear();
496 let mut frame_rects: HashMap<Entity, LayerCaptureRect> = HashMap::default();
500 let mut bleed_candidates: Vec<(Entity, NodeId, u32, String)> = Vec::new();
504 for (root, computed, transform, rnode, alpha, chain, filter_input, backdrop_chain) in &roots {
505 let row = registry.layers.get_mut(&rnode.0);
506 if let Some(row) = &row {
507 debug_assert_eq!(row.entity, root);
508 }
509 let size = computed.size();
510 if size.x <= 0.5 || size.y <= 0.5 {
511 if let Some(row) = row {
515 row.capture_rect = None;
516 }
517 continue;
518 }
519 let min = transform.translation - size * 0.5;
523 let mut rect = LayerCaptureRect {
524 min,
525 size: UVec2::new(size.x.ceil() as u32, size.y.ceil() as u32),
526 outset: 0,
527 };
528 if rect.size.x == 0 || rect.size.y == 0 {
529 if let Some(row) = row {
530 row.capture_rect = None;
531 }
532 continue;
533 }
534 let content_outset = chain.map_or(0, |c| crate::filters::quantize_outset(c.outset_px));
544 let backdrop_outset =
545 backdrop_chain.map_or(0, |c| crate::filters::quantize_outset(c.0.outset_px));
546 let outset = content_outset.max(backdrop_outset);
547 if outset > 0 {
548 rect.min -= Vec2::splat(outset as f32);
549 rect.size += UVec2::splat(2 * outset);
550 rect.outset = outset;
551 }
552 if content_outset > 0 {
557 let value = filter_input
558 .and_then(|i| i.0.0.first())
559 .map_or_else(|| "filter".to_owned(), |u| u.name.clone());
560 bleed_candidates.push((root, rnode.0, content_outset, value));
561 }
562 frame_rects.insert(root, rect);
563 if existing_rects.get(root) != Ok(&rect) {
564 commands.entity(root).insert(rect);
565 }
566 if let Some(row) = row {
573 let display_min = IVec2::new(rect.min.x.round() as i32, rect.min.y.round() as i32);
574 row.capture_rect = Some(IRect::from_corners(
575 display_min,
576 display_min + rect.size.as_ivec2(),
577 ));
578 row.group_alpha = alpha.0;
579 }
580 let mut hash = GEO_HASH_SEED;
589 fold_geo_i32(&mut hash, rect.size.x as i32);
593 fold_geo_i32(&mut hash, rect.size.y as i32);
594 mark_subtree(
595 root,
596 root,
597 root,
598 transform.translation,
599 &children,
600 &root_markers,
601 &geometry,
602 &mut hash,
603 &mut membership.node_to_layer,
604 );
605 repaints.geo_hashes.insert(root, hash);
606 let mut enclosing = None;
609 let mut depth = 1u32;
610 let mut cursor = root;
611 while let Ok(parent) = parents.get(cursor) {
612 cursor = parent.parent();
613 if root_markers.contains(cursor) {
614 if enclosing.is_none() {
615 enclosing = Some(cursor);
616 }
617 depth += 1;
618 }
619 }
620 membership.enclosing.insert(root, enclosing);
621 if let Some(row) = registry.layers.get_mut(&rnode.0) {
622 row.depth = depth;
623 }
624 }
625 let candidate_roots: HashSet<Entity> = bleed_candidates.iter().map(|(r, ..)| *r).collect();
638 warned_bleeds.retain(|e, _| candidate_roots.contains(e));
639 for (root, node, outset, value) in bleed_candidates {
640 let outer = match membership.enclosing.get(&root) {
641 Some(&Some(outer)) => outer,
642 _ => {
643 warned_bleeds.remove(&root);
644 continue;
645 }
646 };
647 let (Some(&inner_rect), Some(&outer_rect)) =
648 (frame_rects.get(&root), frame_rects.get(&outer))
649 else {
650 warned_bleeds.remove(&root);
651 continue;
652 };
653 let inner_max = inner_rect.min + inner_rect.size.as_vec2();
654 let outer_max = outer_rect.min + outer_rect.size.as_vec2();
655 let mut sides: Vec<&str> = Vec::new();
656 if inner_rect.min.x < outer_rect.min.x {
657 sides.push("left");
658 }
659 if inner_rect.min.y < outer_rect.min.y {
660 sides.push("top");
661 }
662 if inner_max.x > outer_max.x {
663 sides.push("right");
664 }
665 if inner_max.y > outer_max.y {
666 sides.push("bottom");
667 }
668 if sides.is_empty() {
669 warned_bleeds.remove(&root);
670 continue;
671 }
672 let pair = (inner_rect, outer_rect);
673 if warned_bleeds.get(&root) == Some(&pair) {
674 continue;
675 }
676 warned_bleeds.insert(root, pair);
677 let _scope = crate::diag::node_scope(node);
678 crate::diag::report(
679 "filterBleed",
680 &value,
681 &format!(
682 "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",
683 sides.join("/")
684 ),
685 );
686 }
687}
688
689#[allow(clippy::too_many_arguments)]
690fn mark_subtree(
691 node: Entity,
692 layer: Entity,
693 dfs_root: Entity,
694 root_translation: Vec2,
695 children: &Query<&Children>,
696 roots: &Query<(), With<PromotedLayer>>,
697 geometry: &Query<(&ComputedNode, &UiGlobalTransform)>,
698 hash: &mut u64,
699 map: &mut HashMap<Entity, Entity>,
700) {
701 if layer == dfs_root
707 && let Ok((computed, transform)) = geometry.get(node)
708 {
709 fold_member_geometry(hash, root_translation, transform, computed);
710 }
711 let layer = if roots.contains(node) { node } else { layer };
715 map.insert(node, layer);
716 if let Ok(kids) = children.get(node) {
717 for &kid in kids {
718 mark_subtree(
719 kid,
720 layer,
721 dfs_root,
722 root_translation,
723 children,
724 roots,
725 geometry,
726 hash,
727 map,
728 );
729 }
730 }
731}
732
733const GEO_HASH_SEED: u64 = 0xcbf29ce484222325;
735
736fn fold_geo_i32(hash: &mut u64, v: i32) {
737 for b in v.to_le_bytes() {
738 *hash = (*hash ^ b as u64).wrapping_mul(0x100000001b3);
739 }
740}
741
742pub fn fold_member_geometry(
750 hash: &mut u64,
751 root_translation: Vec2,
752 transform: &UiGlobalTransform,
753 computed: &ComputedNode,
754) {
755 let rel = transform.translation - root_translation;
756 fold_geo_i32(hash, (rel.x * 64.0).round() as i32);
757 fold_geo_i32(hash, (rel.y * 64.0).round() as i32);
758 let m = transform.matrix2;
759 fold_geo_i32(hash, (m.x_axis.x * 1024.0).round() as i32);
760 fold_geo_i32(hash, (m.x_axis.y * 1024.0).round() as i32);
761 fold_geo_i32(hash, (m.y_axis.x * 1024.0).round() as i32);
762 fold_geo_i32(hash, (m.y_axis.y * 1024.0).round() as i32);
763 let size = computed.size();
764 fold_geo_i32(hash, (size.x * 64.0).round() as i32);
765 fold_geo_i32(hash, (size.y * 64.0).round() as i32);
766}
767
768pub fn watch_layer_image_assets(
773 mut events: MessageReader<AssetEvent<Image>>,
774 images: Query<(Entity, &bevy::ui::widget::ImageNode)>,
775 registry: Res<LayersRegistry>,
776 mut dirt: ResMut<LayerContentDirt>,
777) {
778 if registry.layers.is_empty() {
779 events.clear();
780 return;
781 }
782 let mut touched: Vec<AssetId<Image>> = Vec::new();
783 for event in events.read() {
784 match event {
785 AssetEvent::LoadedWithDependencies { id } | AssetEvent::Modified { id } => {
786 touched.push(*id);
787 }
788 _ => {}
789 }
790 }
791 if touched.is_empty() {
792 return;
793 }
794 for (entity, image) in &images {
795 if touched.contains(&image.image.id()) {
796 dirt.nodes.push(entity);
797 }
798 }
799}
800
801pub fn resolve_layer_repaints(
808 mut dirt: ResMut<LayerContentDirt>,
809 mut state: ResMut<LayerRepaintState>,
810 membership: Res<LayerMembership>,
811 mut registry: ResMut<LayersRegistry>,
812 bridge: Option<Res<crate::bridge::JsBridge>>,
813 reshaped: Query<Entity, Changed<bevy::text::TextLayoutInfo>>,
814 focus: Query<&crate::bridge::FocusState>,
815) {
816 let state = &mut *state;
817 state.dirty.clear();
818
819 for e in dirt.nodes.drain(..) {
821 if let Some(&layer) = membership.node_to_layer.get(&e) {
822 state.dirty.insert(layer);
823 }
824 }
825 for e in dirt.composite_only.drain(..) {
829 let layer = membership.node_to_layer.get(&e).copied().unwrap_or(e);
830 if let Some(&Some(outer)) = membership.enclosing.get(&layer) {
831 state.dirty.insert(outer);
832 }
833 }
834 for e in &reshaped {
837 if let Some(&layer) = membership.node_to_layer.get(&e) {
838 state.dirty.insert(layer);
839 }
840 }
841 if let Some(bridge) = bridge {
844 for id in &bridge.editable_inputs {
845 if let Some(&e) = bridge.nodes.get(id)
846 && focus.get(e).is_ok_and(|f| f.0)
847 && let Some(&layer) = membership.node_to_layer.get(&e)
848 {
849 state.dirty.insert(layer);
850 }
851 }
852 }
853 for (&root, hash) in &state.geo_hashes {
856 if state.prev_hashes.get(&root) != Some(hash) {
857 state.dirty.insert(root);
858 }
859 }
860 std::mem::swap(&mut state.prev_hashes, &mut state.geo_hashes);
861 for meta in registry.layers.values() {
866 if meta.cache_policy == crate::protocol::style::LayerCache::Never {
867 state.dirty.insert(meta.entity);
868 }
869 }
870 let seeds: Vec<Entity> = state.dirty.iter().copied().collect();
873 for mut layer in seeds {
874 while let Some(&Some(outer)) = membership.enclosing.get(&layer) {
875 if !state.dirty.insert(outer) {
876 break; }
878 layer = outer;
879 }
880 }
881 for meta in registry.layers.values_mut() {
883 let dirty = state.dirty.contains(&meta.entity);
884 meta.cached = !dirty;
885 if dirty {
886 meta.repaints += 1;
887 }
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894 use crate::bridge::JsBridge;
895 use crate::protocol::{NodeId, op::Op, outbound::Outbound, props::Props};
896 use bevy::ui::BackgroundColor;
897
898 fn props(json: serde_json::Value) -> Props {
899 serde_json::from_value(json).expect("valid props")
900 }
901
902 #[test]
906 fn promotion_reasons_matrix() {
907 let promoted = |p: &Props, kids: usize, ineligible: bool| {
908 !promotion_reasons(p, kids, ineligible).is_empty()
909 };
910 let base = props(serde_json::json!({ "style": { "opacity": 0.5 } }));
911 assert!(promoted(&base, 1, false));
912 let one = props(serde_json::json!({ "style": { "opacity": 1.0 } }));
915 assert!(promoted(&one, 1, false));
916 assert!(!promoted(&base, 0, false));
918 let plain = props(serde_json::json!({ "style": { "width": 10 } }));
920 assert!(!promoted(&plain, 3, false));
921 let opted_out =
923 props(serde_json::json!({ "style": { "opacity": 0.5, "groupAlpha": false } }));
924 assert!(!promoted(&opted_out, 1, false));
925 let hover_only = props(serde_json::json!({
927 "style": { "width": 10 },
928 "hoverStyle": { "opacity": 0.8 },
929 }));
930 assert!(promoted(&hover_only, 1, false));
931 let animated = props(serde_json::json!({
933 "style": { "opacity": { "animated": { "id": 1 } } },
934 }));
935 assert!(promoted(&animated, 1, false));
936 assert!(!promoted(&base, 1, true));
938
939 let forced = props(serde_json::json!({ "style": { "cache": "always" } }));
942 assert_eq!(
943 promotion_reasons(&forced, 0, false).0,
944 PromotionReasons::FORCED
945 );
946 let forced_opted_out = props(serde_json::json!({
947 "style": { "cache": "always", "opacity": 0.5, "groupAlpha": false }
948 }));
949 assert_eq!(
950 promotion_reasons(&forced_opted_out, 1, false).0,
951 PromotionReasons::FORCED
952 );
953 let both = props(serde_json::json!({
955 "style": { "cache": "always", "opacity": 0.5 }
956 }));
957 assert_eq!(
958 promotion_reasons(&both, 1, false).0,
959 PromotionReasons::FORCED | PromotionReasons::OPACITY
960 );
961 let auto = props(serde_json::json!({ "style": { "cache": "auto" } }));
963 assert!(!promoted(&auto, 1, false));
964 assert!(!promoted(&forced, 1, true));
966
967 let filtered = props(serde_json::json!({ "style": { "filter": { "name": "blur" } } }));
971 assert_eq!(
972 promotion_reasons(&filtered, 0, false).0,
973 PromotionReasons::FILTER
974 );
975 let empty_chain = props(serde_json::json!({ "style": { "filter": [] } }));
977 assert!(!promoted(&empty_chain, 1, false));
978 let filter_and_opacity = props(serde_json::json!({
980 "style": { "filter": { "name": "blur" }, "opacity": 0.5 }
981 }));
982 assert_eq!(
983 promotion_reasons(&filter_and_opacity, 1, false).0,
984 PromotionReasons::FILTER | PromotionReasons::OPACITY
985 );
986 assert!(!promoted(&filtered, 0, true));
988 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
993 let variant_filter = props(serde_json::json!({
994 "style": { "width": 10 },
995 (variant): { "filter": { "name": "blur" } },
996 }));
997 assert_eq!(
998 promotion_reasons(&variant_filter, 0, false).0,
999 PromotionReasons::FILTER,
1000 "{variant}-only filter promotes eagerly"
1001 );
1002 }
1003 let empty_variant = props(serde_json::json!({
1005 "style": { "width": 10 },
1006 "hoverStyle": { "filter": [] },
1007 }));
1008 assert!(!promoted(&empty_variant, 1, false));
1009
1010 let transformed = props(serde_json::json!({
1014 "style": { "transform3d": { "rotateY": 45 } }
1015 }));
1016 assert_eq!(
1017 promotion_reasons(&transformed, 0, false).0,
1018 PromotionReasons::TRANSFORM3D
1019 );
1020 let identity_3d = props(serde_json::json!({ "style": { "transform3d": {} } }));
1021 assert_eq!(
1022 promotion_reasons(&identity_3d, 0, false).0,
1023 PromotionReasons::TRANSFORM3D
1024 );
1025 let opted_out = props(serde_json::json!({
1027 "style": { "transform3d": {}, "groupAlpha": false }
1028 }));
1029 assert!(promoted(&opted_out, 0, false));
1030 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1033 let variant_3d = props(serde_json::json!({
1034 "style": { "width": 10 },
1035 (variant): { "transform3d": { "rotateX": 10 } },
1036 }));
1037 assert_eq!(
1038 promotion_reasons(&variant_3d, 0, false).0,
1039 PromotionReasons::TRANSFORM3D,
1040 "{variant}-only transform3d promotes eagerly"
1041 );
1042 }
1043 assert!(!promoted(&transformed, 0, true));
1044
1045 let backdrop =
1049 props(serde_json::json!({ "style": { "backdropFilter": { "name": "blur" } } }));
1050 assert_eq!(
1051 promotion_reasons(&backdrop, 0, false).0,
1052 PromotionReasons::BACKDROP
1053 );
1054 let empty_backdrop = props(serde_json::json!({ "style": { "backdropFilter": [] } }));
1055 assert!(!promoted(&empty_backdrop, 1, false));
1056 let both_chains = props(serde_json::json!({
1058 "style": { "backdropFilter": { "name": "blur" }, "filter": { "name": "sepia" } }
1059 }));
1060 assert_eq!(
1061 promotion_reasons(&both_chains, 0, false).0,
1062 PromotionReasons::BACKDROP | PromotionReasons::FILTER
1063 );
1064 for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1065 let variant_backdrop = props(serde_json::json!({
1066 "style": { "width": 10 },
1067 (variant): { "backdropFilter": { "name": "blur" } },
1068 }));
1069 assert_eq!(
1070 promotion_reasons(&variant_backdrop, 0, false).0,
1071 PromotionReasons::BACKDROP,
1072 "{variant}-only backdropFilter promotes eagerly"
1073 );
1074 }
1075 assert!(!promoted(&backdrop, 0, true));
1076
1077 let plain = props(serde_json::json!({ "style": { "width": 10 } }));
1079 assert!(!promoted(&plain, 1, false));
1080 }
1081
1082 fn layer_app() -> (bevy::app::App, crossbeam_channel::Sender<Vec<Op>>) {
1085 use bevy::app::App;
1086 let mut app = App::new();
1087 app.add_plugins((MinimalPlugins, AssetPlugin::default()));
1088 app.init_asset::<Image>();
1089 app.init_asset::<bevy::image::TextureAtlasLayout>();
1090 app.init_resource::<crate::plugin::Fonts>();
1091 app.init_resource::<crate::reconcile::OpApplyStats>();
1092 app.init_resource::<crate::ui_map::AtlasLayoutCache>();
1093 app.init_resource::<LayersRegistry>();
1094 app.init_resource::<LayerMembership>();
1095
1096 let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
1097 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
1098 std::mem::forget(out_rx);
1099 let root = app.world_mut().spawn_empty().id();
1100 app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
1101 app.add_systems(
1102 Update,
1103 (
1104 crate::reconcile::apply_js_ops,
1105 evaluate_layer_promotions.after(crate::reconcile::apply_js_ops),
1106 ),
1107 );
1108 (app, ops_tx)
1109 }
1110
1111 fn create(id: NodeId, json: serde_json::Value) -> Op {
1112 Op::Create {
1113 id,
1114 kind: "node".into(),
1115 props: Box::new(props(json)),
1116 text: None,
1117 }
1118 }
1119
1120 fn update(id: NodeId, json: serde_json::Value, style_unset: &[&str]) -> Op {
1121 Op::Update {
1122 id,
1123 props: Box::new(props(json)),
1124 unset: vec![],
1125 style_unset: style_unset.iter().map(|s| s.to_string()).collect(),
1126 }
1127 }
1128
1129 fn entity_of(app: &bevy::app::App, id: NodeId) -> Entity {
1130 *app.world().resource::<JsBridge>().nodes.get(&id).unwrap()
1131 }
1132
1133 #[test]
1138 fn promotion_lifecycle_and_fold_handoff() {
1139 let (mut app, ops_tx) = layer_app();
1140 ops_tx
1141 .send(vec![
1142 create(
1143 1,
1144 serde_json::json!({
1145 "style": { "opacity": 0.5, "backgroundColor": "#ff0000" }
1146 }),
1147 ),
1148 create(2, serde_json::json!({})),
1149 Op::Append {
1150 parent: 1,
1151 child: 2,
1152 },
1153 ])
1154 .unwrap();
1155 app.update();
1156
1157 let e = entity_of(&app, 1);
1158 assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
1159 assert_eq!(
1160 app.world().get::<LayerGroupAlpha>(e),
1161 Some(&LayerGroupAlpha(0.5))
1162 );
1163 let registry = app.world().resource::<LayersRegistry>();
1164 assert_eq!(registry.layers.len(), 1);
1165 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::OPACITY);
1166 let bg = app.world().get::<BackgroundColor>(e).unwrap();
1169 assert_eq!(bg.0.alpha(), 1.0, "promoted bg keeps its own alpha");
1170
1171 ops_tx
1173 .send(vec![update(
1174 1,
1175 serde_json::json!({ "style": { "groupAlpha": false } }),
1176 &[],
1177 )])
1178 .unwrap();
1179 app.update();
1180 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1181 assert!(app.world().get::<LayerGroupAlpha>(e).is_none());
1182 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1183 let bg = app.world().get::<BackgroundColor>(e).unwrap();
1184 assert_eq!(bg.0.alpha(), 0.5, "demoted bg re-bakes the fold");
1185
1186 ops_tx
1188 .send(vec![update(1, serde_json::json!({}), &["groupAlpha"])])
1189 .unwrap();
1190 app.update();
1191 let e1 = entity_of(&app, 1);
1192 assert!(app.world().get::<PromotedLayer>(e1).is_some());
1193 ops_tx
1194 .send(vec![Op::Remove {
1195 parent: 1,
1196 child: 2,
1197 }])
1198 .unwrap();
1199 app.update();
1200 assert!(
1201 app.world().get::<PromotedLayer>(e1).is_none(),
1202 "no children → demoted"
1203 );
1204 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1205 }
1206
1207 #[test]
1210 fn forced_cache_lifecycle() {
1211 let (mut app, ops_tx) = layer_app();
1212 ops_tx
1213 .send(vec![create(
1214 1,
1215 serde_json::json!({ "style": { "cache": "always" } }),
1216 )])
1217 .unwrap();
1218 app.update();
1219 let e = entity_of(&app, 1);
1220 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1221 assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
1222 assert_eq!(
1224 app.world().get::<LayerGroupAlpha>(e),
1225 Some(&LayerGroupAlpha(1.0))
1226 );
1227
1228 ops_tx
1229 .send(vec![update(1, serde_json::json!({}), &["cache"])])
1230 .unwrap();
1231 app.update();
1232 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1233 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1234 }
1235
1236 #[test]
1241 fn never_cache_lifecycle_and_policy() {
1242 use crate::protocol::style::LayerCache;
1243 let (mut app, ops_tx) = layer_app();
1244 ops_tx
1245 .send(vec![create(
1246 1,
1247 serde_json::json!({ "style": { "cache": "never" } }),
1248 )])
1249 .unwrap();
1250 app.update();
1251 let e = entity_of(&app, 1);
1252 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1253 assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
1254 let row = app.world().resource::<LayersRegistry>().layers[&1];
1255 assert_eq!(row.cache_policy, LayerCache::Never);
1256
1257 ops_tx
1259 .send(vec![update(
1260 1,
1261 serde_json::json!({ "style": { "cache": "always" } }),
1262 &[],
1263 )])
1264 .unwrap();
1265 app.update();
1266 assert!(
1267 app.world().get::<PromotedLayer>(e).is_some(),
1268 "stays promoted"
1269 );
1270 let row = app.world().resource::<LayersRegistry>().layers[&1];
1271 assert_eq!(row.cache_policy, LayerCache::Always);
1272
1273 ops_tx
1274 .send(vec![update(1, serde_json::json!({}), &["cache"])])
1275 .unwrap();
1276 app.update();
1277 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1278 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1279 }
1280
1281 #[test]
1284 fn filter_promotion_lifecycle() {
1285 let (mut app, ops_tx) = layer_app();
1286 ops_tx
1287 .send(vec![create(
1288 1,
1289 serde_json::json!({ "style": { "filter": { "name": "grayscale" } } }),
1290 )])
1291 .unwrap();
1292 app.update();
1293 let e = entity_of(&app, 1);
1294 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1295 assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
1296 assert_eq!(
1298 app.world().get::<LayerGroupAlpha>(e),
1299 Some(&LayerGroupAlpha(1.0))
1300 );
1301 let registry = app.world().resource::<LayersRegistry>();
1302 assert_eq!(registry.layers.len(), 1);
1303 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::FILTER);
1304
1305 ops_tx
1306 .send(vec![update(1, serde_json::json!({}), &["filter"])])
1307 .unwrap();
1308 app.update();
1309 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1310 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1311 }
1312
1313 #[test]
1317 fn backdrop_promotion_lifecycle() {
1318 let (mut app, ops_tx) = layer_app();
1319 ops_tx
1320 .send(vec![create(
1321 1,
1322 serde_json::json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
1323 )])
1324 .unwrap();
1325 app.update();
1326 let e = entity_of(&app, 1);
1327 let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1328 assert_eq!(promoted.reasons.0, PromotionReasons::BACKDROP);
1329 assert_eq!(
1330 app.world().get::<LayerGroupAlpha>(e),
1331 Some(&LayerGroupAlpha(1.0))
1332 );
1333 let registry = app.world().resource::<LayersRegistry>();
1334 assert_eq!(registry.layers.len(), 1);
1335 assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::BACKDROP);
1336
1337 ops_tx
1338 .send(vec![update(1, serde_json::json!({}), &["backdropFilter"])])
1339 .unwrap();
1340 app.update();
1341 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1342 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1343 }
1344
1345 #[test]
1351 fn hover_filter_promotes_eagerly_from_creation() {
1352 let (mut app, ops_tx) = layer_app();
1353 ops_tx
1354 .send(vec![create(
1355 1,
1356 serde_json::json!({
1357 "style": { "width": 10 },
1358 "hoverStyle": { "filter": { "name": "blur", "params": { "radius": 8 } } },
1359 }),
1360 )])
1361 .unwrap();
1362 app.update();
1363 let e = entity_of(&app, 1);
1364 let promoted = app
1365 .world()
1366 .get::<PromotedLayer>(e)
1367 .expect("promoted before any hover");
1368 assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
1369 assert_eq!(
1370 app.world().resource::<LayersRegistry>().layers[&1]
1371 .reasons
1372 .0,
1373 PromotionReasons::FILTER
1374 );
1375
1376 ops_tx
1378 .send(vec![Op::Update {
1379 id: 1,
1380 props: Box::new(props(serde_json::json!({}))),
1381 unset: vec!["hoverStyle".into()],
1382 style_unset: vec![],
1383 }])
1384 .unwrap();
1385 app.update();
1386 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1387 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1388 }
1389
1390 #[test]
1395 fn repaint_resolution_and_propagation() {
1396 let mut world = World::new();
1397 world.init_resource::<LayerContentDirt>();
1398 world.init_resource::<LayerRepaintState>();
1399 world.init_resource::<LayersRegistry>();
1400
1401 let outer = world.spawn_empty().id();
1402 let inner = world.spawn_empty().id();
1403 let member = world.spawn_empty().id(); let outer_member = world.spawn_empty().id(); let mut membership = LayerMembership::default();
1406 membership.node_to_layer.insert(outer, outer);
1407 membership.node_to_layer.insert(inner, inner);
1408 membership.node_to_layer.insert(member, inner);
1409 membership.node_to_layer.insert(outer_member, outer);
1410 membership.enclosing.insert(outer, None);
1411 membership.enclosing.insert(inner, Some(outer));
1412 world.insert_resource(membership);
1413
1414 let mut schedule = Schedule::default();
1415 schedule.add_systems(resolve_layer_repaints);
1416 let mut run = |world: &mut World| {
1417 schedule.run(world);
1418 world.resource::<LayerRepaintState>().dirty.clone()
1419 };
1420
1421 {
1423 let mut state = world.resource_mut::<LayerRepaintState>();
1424 state.geo_hashes.insert(outer, 1);
1425 state.geo_hashes.insert(inner, 2);
1426 }
1427 let dirty = run(&mut world);
1428 assert!(
1429 dirty.contains(&outer) && dirty.contains(&inner),
1430 "{dirty:?}"
1431 );
1432
1433 {
1435 let mut state = world.resource_mut::<LayerRepaintState>();
1436 state.geo_hashes.insert(outer, 1);
1437 state.geo_hashes.insert(inner, 2);
1438 }
1439 assert!(run(&mut world).is_empty());
1440
1441 {
1443 let mut state = world.resource_mut::<LayerRepaintState>();
1444 state.geo_hashes.insert(outer, 1);
1445 state.geo_hashes.insert(inner, 2);
1446 world.resource_mut::<LayerContentDirt>().nodes.push(member);
1447 }
1448 let dirty = run(&mut world);
1449 assert!(dirty.contains(&inner) && dirty.contains(&outer));
1450
1451 {
1454 let mut state = world.resource_mut::<LayerRepaintState>();
1455 state.geo_hashes.insert(outer, 1);
1456 state.geo_hashes.insert(inner, 2);
1457 world
1458 .resource_mut::<LayerContentDirt>()
1459 .composite_only
1460 .push(inner);
1461 }
1462 let dirty = run(&mut world);
1463 assert!(
1464 dirty.contains(&outer) && !dirty.contains(&inner),
1465 "{dirty:?}"
1466 );
1467
1468 {
1470 let mut state = world.resource_mut::<LayerRepaintState>();
1471 state.geo_hashes.insert(outer, 1);
1472 state.geo_hashes.insert(inner, 2);
1473 world
1474 .resource_mut::<LayerContentDirt>()
1475 .composite_only
1476 .push(outer);
1477 }
1478 assert!(run(&mut world).is_empty());
1479
1480 {
1482 let mut state = world.resource_mut::<LayerRepaintState>();
1483 state.geo_hashes.insert(outer, 1);
1484 state.geo_hashes.insert(inner, 3);
1485 }
1486 let dirty = run(&mut world);
1487 assert!(dirty.contains(&inner) && dirty.contains(&outer));
1488
1489 {
1491 let mut state = world.resource_mut::<LayerRepaintState>();
1492 state.geo_hashes.insert(outer, 1);
1493 state.geo_hashes.insert(inner, 3);
1494 world
1495 .resource_mut::<LayerContentDirt>()
1496 .nodes
1497 .push(outer_member);
1498 }
1499 let dirty = run(&mut world);
1500 assert!(dirty.contains(&outer) && !dirty.contains(&inner));
1501 }
1502
1503 #[test]
1507 fn never_policy_repaints_every_frame() {
1508 use crate::protocol::style::LayerCache;
1509 let mut world = World::new();
1510 world.init_resource::<LayerContentDirt>();
1511 world.init_resource::<LayerRepaintState>();
1512 world.init_resource::<LayersRegistry>();
1513
1514 let outer = world.spawn_empty().id();
1515 let inner = world.spawn_empty().id(); let other = world.spawn_empty().id(); let mut membership = LayerMembership::default();
1518 membership.node_to_layer.insert(outer, outer);
1519 membership.node_to_layer.insert(inner, inner);
1520 membership.node_to_layer.insert(other, other);
1521 membership.enclosing.insert(outer, None);
1522 membership.enclosing.insert(inner, Some(outer));
1523 membership.enclosing.insert(other, None);
1524 world.insert_resource(membership);
1525
1526 let meta = |node: NodeId, entity: Entity, policy: LayerCache| LayerMeta {
1527 node,
1528 entity,
1529 reasons: PromotionReasons(PromotionReasons::FORCED),
1530 group_alpha: 1.0,
1531 capture_rect: None,
1532 depth: 1,
1533 repaints: 0,
1534 cached: false,
1535 cache_policy: policy,
1536 };
1537 {
1538 let mut registry = world.resource_mut::<LayersRegistry>();
1539 registry.layers.insert(1, meta(1, outer, LayerCache::Auto));
1540 registry.layers.insert(2, meta(2, inner, LayerCache::Never));
1541 registry
1542 .layers
1543 .insert(3, meta(3, other, LayerCache::Always));
1544 }
1545
1546 let mut schedule = Schedule::default();
1547 schedule.add_systems(resolve_layer_repaints);
1548 for frame in 0..2 {
1549 schedule.run(&mut world);
1550 let state = world.resource::<LayerRepaintState>();
1551 assert!(
1552 state.dirty.contains(&inner) && state.dirty.contains(&outer),
1553 "frame {frame}: {:?}",
1554 state.dirty
1555 );
1556 assert!(!state.dirty.contains(&other), "frame {frame}");
1557 }
1558 let registry = world.resource::<LayersRegistry>();
1559 assert_eq!(registry.layers[&2].repaints, 2);
1560 assert!(!registry.layers[&2].cached);
1561 assert!(registry.layers[&3].cached);
1562 }
1563
1564 #[test]
1568 fn geometry_fold_translation_invariance() {
1569 use bevy::math::Affine2;
1570
1571 let node = |size: Vec2, pos: Vec2| {
1572 let computed = ComputedNode {
1573 size,
1574 ..Default::default()
1575 };
1576 (
1577 computed,
1578 UiGlobalTransform::from(Affine2::from_translation(pos)),
1579 )
1580 };
1581 let fold = |members: &[(ComputedNode, UiGlobalTransform)], root: Vec2| {
1582 let mut hash = GEO_HASH_SEED;
1583 for (computed, transform) in members {
1584 fold_member_geometry(&mut hash, root, transform, computed);
1585 }
1586 hash
1587 };
1588
1589 let members = [
1590 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1591 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
1592 ];
1593 let base = fold(&members, Vec2::new(10.0, 20.0));
1594
1595 let delta = Vec2::new(123.4, -56.78);
1598 let shifted = [
1599 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0) + delta),
1600 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0) + delta),
1601 ];
1602 assert_eq!(base, fold(&shifted, Vec2::new(10.0, 20.0) + delta));
1603
1604 let moved = [
1606 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1607 node(Vec2::new(30.0, 30.0), Vec2::new(41.0, 25.0)),
1608 ];
1609 assert_ne!(base, fold(&moved, Vec2::new(10.0, 20.0)));
1610
1611 let resized = [
1613 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1614 node(Vec2::new(31.0, 30.0), Vec2::new(40.0, 25.0)),
1615 ];
1616 assert_ne!(base, fold(&resized, Vec2::new(10.0, 20.0)));
1617
1618 let mut scaled = [
1620 node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1621 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
1622 ];
1623 scaled[1].1 = UiGlobalTransform::from(Affine2::from_scale_angle_translation(
1624 Vec2::splat(1.5),
1625 0.0,
1626 Vec2::new(40.0, 25.0),
1627 ));
1628 assert_ne!(base, fold(&scaled, Vec2::new(10.0, 20.0)));
1629
1630 let noisy = [
1632 node(Vec2::new(100.0, 50.0), Vec2::new(10.0 + 1e-4, 20.0)),
1633 node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0 - 1e-4)),
1634 ];
1635 assert_eq!(base, fold(&noisy, Vec2::new(10.0, 20.0)));
1636 }
1637
1638 fn geometry_world() -> (World, Schedule) {
1644 let mut world = World::new();
1645 world.init_resource::<LayerMembership>();
1646 world.init_resource::<LayersRegistry>();
1647 world.init_resource::<LayerRepaintState>();
1648 world.init_resource::<LayerContentDirt>();
1649 let mut schedule = Schedule::default();
1650 schedule.add_systems((sync_layer_geometry, resolve_layer_repaints).chain());
1651 (world, schedule)
1652 }
1653
1654 fn spawn_layer_root(world: &mut World, id: NodeId, size: Vec2, center: Vec2) -> Entity {
1658 world
1659 .spawn((
1660 ComputedNode {
1661 size,
1662 ..Default::default()
1663 },
1664 UiGlobalTransform::from(bevy::math::Affine2::from_translation(center)),
1665 crate::bridge::RNode(id),
1666 LayerGroupAlpha(1.0),
1667 PromotedLayer {
1668 reasons: PromotionReasons(PromotionReasons::FILTER),
1669 },
1670 ))
1671 .id()
1672 }
1673
1674 fn filter_outset(world: &mut World, e: Entity, outset_px: u32) {
1675 world
1676 .entity_mut(e)
1677 .insert(crate::filters::ResolvedFilterChain {
1678 outset_px,
1679 ..Default::default()
1680 });
1681 }
1682
1683 fn backdrop_outset(world: &mut World, e: Entity, outset_px: u32) {
1684 world
1685 .entity_mut(e)
1686 .insert(crate::filters::ResolvedBackdropChain(
1687 crate::filters::ResolvedFilterChain {
1688 outset_px,
1689 ..Default::default()
1690 },
1691 ));
1692 }
1693
1694 #[test]
1699 fn outset_inflates_rect_by_quantized_margin() {
1700 let (mut world, mut schedule) = geometry_world();
1701 let size = Vec2::new(100.0, 60.0);
1702 let center = Vec2::new(50.0, 30.0);
1703 let plain = spawn_layer_root(&mut world, 1, size, center);
1704 let blurred = spawn_layer_root(&mut world, 2, size, center);
1705 filter_outset(&mut world, blurred, 12); let big = spawn_layer_root(&mut world, 3, size, center);
1707 filter_outset(&mut world, big, 60); schedule.run(&mut world);
1709
1710 let base = *world.get::<LayerCaptureRect>(plain).expect("baseline rect");
1711 assert_eq!(base.min, Vec2::ZERO);
1712 assert_eq!(base.size, UVec2::new(100, 60));
1713 let rect = *world.get::<LayerCaptureRect>(blurred).expect("rect");
1715 assert_eq!(rect.min, base.min - Vec2::splat(16.0));
1716 assert_eq!(rect.size, base.size + UVec2::splat(32));
1717 let rect = *world.get::<LayerCaptureRect>(big).expect("rect");
1719 assert_eq!(rect.min, base.min - Vec2::splat(64.0));
1720 assert_eq!(rect.size, base.size + UVec2::splat(128));
1721 }
1722
1723 #[test]
1728 fn backdrop_outset_inflates_rect_and_maxes_with_content() {
1729 let (mut world, mut schedule) = geometry_world();
1730 let size = Vec2::new(100.0, 60.0);
1731 let center = Vec2::new(50.0, 30.0);
1732 let plain = spawn_layer_root(&mut world, 1, size, center);
1733 let frosted = spawn_layer_root(&mut world, 2, size, center);
1734 backdrop_outset(&mut world, frosted, 12); let both = spawn_layer_root(&mut world, 3, size, center);
1736 filter_outset(&mut world, both, 4); backdrop_outset(&mut world, both, 40); schedule.run(&mut world);
1739
1740 let base = *world.get::<LayerCaptureRect>(plain).expect("baseline");
1741 assert_eq!(base.outset, 0);
1742 let rect = *world.get::<LayerCaptureRect>(frosted).expect("rect");
1743 assert_eq!(rect.min, base.min - Vec2::splat(16.0));
1744 assert_eq!(rect.size, base.size + UVec2::splat(32));
1745 assert_eq!(rect.outset, 16);
1746 let rect = *world.get::<LayerCaptureRect>(both).expect("rect");
1747 assert_eq!(rect.min, base.min - Vec2::splat(48.0));
1748 assert_eq!(rect.size, base.size + UVec2::splat(96));
1749 assert_eq!(rect.outset, 48);
1750 }
1751
1752 #[test]
1756 fn outset_within_quantize_step_holds_rect_and_cache() {
1757 let (mut world, mut schedule) = geometry_world();
1758 let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
1759 filter_outset(&mut world, e, 12);
1760 schedule.run(&mut world);
1761 assert!(
1762 world.resource::<LayerRepaintState>().dirty.contains(&e),
1763 "first frame repaints"
1764 );
1765 let before = *world.get::<LayerCaptureRect>(e).expect("rect");
1766
1767 filter_outset(&mut world, e, 14); schedule.run(&mut world);
1769 assert_eq!(*world.get::<LayerCaptureRect>(e).expect("rect"), before);
1770 assert!(
1771 world.resource::<LayerRepaintState>().dirty.is_empty(),
1772 "no repaint within a quantize step"
1773 );
1774 }
1775
1776 #[test]
1780 fn outset_crossing_quantize_step_recaptures() {
1781 let (mut world, mut schedule) = geometry_world();
1782 let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
1783 filter_outset(&mut world, e, 14); schedule.run(&mut world);
1785 schedule.run(&mut world); assert!(world.resource::<LayerRepaintState>().dirty.is_empty());
1787 let before = *world.get::<LayerCaptureRect>(e).expect("rect");
1788
1789 filter_outset(&mut world, e, 18); schedule.run(&mut world);
1791 let after = *world.get::<LayerCaptureRect>(e).expect("rect");
1792 assert_eq!(after.min, before.min - Vec2::splat(16.0));
1793 assert_eq!(after.size, before.size + UVec2::splat(32));
1794 assert!(
1795 world.resource::<LayerRepaintState>().dirty.contains(&e),
1796 "step crossing re-captures"
1797 );
1798 }
1799
1800 #[test]
1803 fn zero_content_size_stays_inactive_despite_outset() {
1804 let (mut world, mut schedule) = geometry_world();
1805 let e = spawn_layer_root(&mut world, 1, Vec2::ZERO, Vec2::ZERO);
1806 filter_outset(&mut world, e, 60);
1807 schedule.run(&mut world);
1808 assert!(
1809 world.get::<LayerCaptureRect>(e).is_none(),
1810 "zero content size stays inactive"
1811 );
1812 }
1813
1814 #[cfg(all(feature = "devtools", debug_assertions))]
1819 #[test]
1820 fn nested_filter_bleed_warns_when_clipped() {
1821 let _lock = crate::diag::test_lock();
1822 crate::diag::arm_runtime();
1823 let _ = crate::diag::take_runtime_warnings();
1824
1825 let (mut world, mut schedule) = geometry_world();
1826 let outer = spawn_layer_root(&mut world, 1, Vec2::splat(200.0), Vec2::splat(100.0));
1828 let inner = spawn_layer_root(&mut world, 2, Vec2::splat(100.0), Vec2::splat(100.0));
1830 world.entity_mut(inner).insert((
1831 ChildOf(outer),
1832 crate::filters::FilterInput(crate::filters::FilterChain(vec![
1833 crate::filters::FilterUse {
1834 name: "blur".into(),
1835 params: Default::default(),
1836 },
1837 ])),
1838 ));
1839 filter_outset(&mut world, inner, 12);
1841 schedule.run(&mut world);
1842 let bleeds = |warns: Vec<crate::diag::RuntimeWarning>| -> Vec<_> {
1843 warns
1844 .into_iter()
1845 .filter(|w| w.kind == "filterBleed")
1846 .collect()
1847 };
1848 assert!(
1849 bleeds(crate::diag::take_runtime_warnings()).is_empty(),
1850 "contained bleed does not warn"
1851 );
1852
1853 filter_outset(&mut world, inner, 60);
1855 schedule.run(&mut world);
1856 let warns = bleeds(crate::diag::take_runtime_warnings());
1857 assert_eq!(warns.len(), 1, "{warns:?}");
1858 assert_eq!(warns[0].node, Some(2));
1859 assert_eq!(warns[0].value, "blur");
1860 for side in ["left", "top", "right", "bottom"] {
1861 assert!(warns[0].message.contains(side), "{}", warns[0].message);
1862 }
1863
1864 schedule.run(&mut world);
1866 assert!(
1867 bleeds(crate::diag::take_runtime_warnings()).is_empty(),
1868 "unchanged bleed is not re-reported"
1869 );
1870 }
1871
1872 #[test]
1875 fn removal_prunes_registry() {
1876 let (mut app, ops_tx) = layer_app();
1877 ops_tx
1878 .send(vec![
1879 create(1, serde_json::json!({})),
1880 create(2, serde_json::json!({ "style": { "opacity": 0.3 } })),
1881 create(3, serde_json::json!({})),
1882 Op::Append {
1883 parent: 1,
1884 child: 2,
1885 },
1886 Op::Append {
1887 parent: 2,
1888 child: 3,
1889 },
1890 ])
1891 .unwrap();
1892 app.update();
1893 assert_eq!(app.world().resource::<LayersRegistry>().layers.len(), 1);
1894
1895 ops_tx
1896 .send(vec![Op::Remove {
1897 parent: 1,
1898 child: 2,
1899 }])
1900 .unwrap();
1901 app.update();
1902 assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1903 assert!(
1904 app.world()
1905 .resource::<JsBridge>()
1906 .promoted_layers
1907 .is_empty()
1908 );
1909 }
1910}