use bevy::platform::collections::{HashMap, HashSet};
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use crate::protocol::{NodeId, animatable::AnimatableField, props::Props};
pub mod clip;
pub mod pick3d;
pub mod render;
pub mod transform3d;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PromotionReasons(pub u32);
impl PromotionReasons {
pub const OPACITY: u32 = 1 << 0;
pub const FILTER: u32 = 1 << 2;
pub const FORCED: u32 = 1 << 4;
pub const TRANSFORM3D: u32 = 1 << 1;
pub const BACKDROP: u32 = 1 << 3;
pub fn is_empty(self) -> bool {
self.0 == 0
}
}
#[derive(Component, Debug, Clone, Copy)]
pub struct PromotedLayer {
pub reasons: PromotionReasons,
}
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct LayerGroupAlpha(pub f32);
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct LayerCaptureRect {
pub min: Vec2,
pub size: UVec2,
pub outset: u32,
}
#[derive(Resource, Debug, Default)]
pub struct LayerMembership {
pub node_to_layer: HashMap<Entity, Entity>,
pub enclosing: HashMap<Entity, Option<Entity>>,
}
#[derive(Debug, Clone, Copy)]
pub struct LayerMeta {
pub node: NodeId,
pub entity: Entity,
pub reasons: PromotionReasons,
pub group_alpha: f32,
pub capture_rect: Option<IRect>,
pub depth: u32,
pub repaints: u64,
pub cached: bool,
pub cache_policy: crate::protocol::style::LayerCache,
}
#[derive(Resource, Debug, Default)]
pub struct LayersRegistry {
pub layers: HashMap<NodeId, LayerMeta>,
}
#[derive(Resource, Debug, Default)]
pub struct LayerContentDirt {
pub nodes: Vec<Entity>,
pub composite_only: Vec<Entity>,
}
#[derive(Resource, Debug, Default)]
pub struct LayerRepaintState {
pub dirty: HashSet<Entity>,
pub geo_hashes: HashMap<Entity, u64>,
prev_hashes: HashMap<Entity, u64>,
}
pub fn mark_content_dirty(ec: &mut EntityCommands) {
ec.queue(|mut e: bevy::ecs::world::EntityWorldMut| {
let id = e.id();
e.world_scope(|w| {
if let Some(mut dirt) = w.get_resource_mut::<LayerContentDirt>() {
dirt.nodes.push(id);
}
});
});
}
pub fn promotion_reasons(
props: &Props,
child_count: usize,
ineligible_element: bool,
) -> PromotionReasons {
let opacity_present = props.all_styles().any(|s| s.opacity.is_some());
let group_gate = props.style.as_ref().and_then(|s| s.group_alpha) != Some(false);
let mut reasons = 0;
if opacity_present && group_gate && child_count >= 1 && !ineligible_element {
reasons |= PromotionReasons::OPACITY;
}
let forced = matches!(
props.style.as_ref().and_then(|s| s.cache),
Some(
crate::protocol::style::LayerCache::Always | crate::protocol::style::LayerCache::Never
)
);
if forced && !ineligible_element {
reasons |= PromotionReasons::FORCED;
}
let filtered = props
.all_styles()
.any(|s| s.filter.as_ref().is_some_and(|chain| !chain.0.is_empty()));
if filtered && !ineligible_element {
reasons |= PromotionReasons::FILTER;
}
let transformed3d = props.all_styles().any(|s| s.transform3d.is_some());
if transformed3d && !ineligible_element {
reasons |= PromotionReasons::TRANSFORM3D;
}
let backdrop = props
.all_styles()
.any(|s| s.backdrop_filter.as_ref().is_some_and(|c| !c.0.is_empty()));
if backdrop && !ineligible_element {
reasons |= PromotionReasons::BACKDROP;
}
PromotionReasons(reasons)
}
pub fn evaluate_layer_promotions(
mut commands: Commands,
mut bridge: ResMut<crate::bridge::JsBridge>,
mut registry: ResMut<LayersRegistry>,
assets: Res<AssetServer>,
mut ui_assets: crate::reconcile::UiAssets,
mut style_variants: Query<&mut crate::bridge::StyleVariants>,
) {
registry
.layers
.retain(|id, meta| bridge.nodes.get(id) == Some(&meta.entity));
if bridge.layer_dirty.is_empty() {
return;
}
let dirty: Vec<NodeId> = bridge.layer_dirty.drain().collect();
for id in dirty {
let Some(&entity) = bridge.nodes.get(&id) else {
continue; };
let reasons = match bridge.props_cache.get(&id) {
Some(props) => promotion_reasons(
props,
bridge.children_of(id).count(),
bridge.text_styles.contains_key(&id) || bridge.is_detached_root(id),
),
None => PromotionReasons::default(),
};
let was_promoted = bridge.promoted_layers.contains(&id);
if !reasons.is_empty() {
let alpha = bridge
.props_cache
.get(&id)
.and_then(|p| p.style.as_ref())
.and_then(|s| s.opacity.static_val())
.unwrap_or(1.0);
let cache_policy = bridge
.props_cache
.get(&id)
.and_then(|p| p.style.as_ref())
.and_then(|s| s.cache)
.unwrap_or_default();
commands
.entity(entity)
.insert((PromotedLayer { reasons }, LayerGroupAlpha(alpha)));
bridge.promoted_layers.insert(id);
let row = registry.layers.entry(id).or_insert(LayerMeta {
node: id,
entity,
reasons,
group_alpha: alpha,
capture_rect: None,
depth: 1,
repaints: 0,
cached: false,
cache_policy,
});
row.entity = entity;
row.reasons = reasons;
row.group_alpha = alpha;
row.cache_policy = cache_policy;
if !was_promoted && let Some(props) = bridge.props_cache.get(&id) {
crate::reconcile::reapply_opacity_outputs(
&mut commands,
entity,
props,
true,
bridge.foreign_images.contains(&id),
&assets,
&mut ui_assets,
&mut style_variants,
);
}
} else if was_promoted {
commands.entity(entity).remove::<(
PromotedLayer,
LayerGroupAlpha,
LayerCaptureRect,
crate::filters::ResolvedFilterChain,
crate::filters::ResolvedBackdropChain,
transform3d::LayerTransform3d,
transform3d::LayerTransform3dMatrix,
)>();
bridge.promoted_layers.remove(&id);
registry.layers.remove(&id);
if let Some(props) = bridge.props_cache.get(&id) {
crate::reconcile::reapply_opacity_outputs(
&mut commands,
entity,
props,
false,
bridge.foreign_images.contains(&id),
&assets,
&mut ui_assets,
&mut style_variants,
);
}
}
}
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn sync_layer_geometry(
mut commands: Commands,
roots: Query<
(
Entity,
&ComputedNode,
&UiGlobalTransform,
&crate::bridge::RNode,
&LayerGroupAlpha,
Option<&crate::filters::ResolvedFilterChain>,
Option<&crate::filters::FilterInput>,
Option<&crate::filters::ResolvedBackdropChain>,
),
With<PromotedLayer>,
>,
root_markers: Query<(), With<PromotedLayer>>,
children: Query<&Children>,
parents: Query<&ChildOf>,
existing_rects: Query<&LayerCaptureRect>,
geometry: Query<(&ComputedNode, &UiGlobalTransform)>,
mut membership: ResMut<LayerMembership>,
mut registry: ResMut<LayersRegistry>,
mut repaints: ResMut<LayerRepaintState>,
mut warned_bleeds: Local<HashMap<Entity, (LayerCaptureRect, LayerCaptureRect)>>,
) {
membership.node_to_layer.clear();
membership.enclosing.clear();
repaints.geo_hashes.clear();
let mut frame_rects: HashMap<Entity, LayerCaptureRect> = HashMap::default();
let mut bleed_candidates: Vec<(Entity, NodeId, u32, String)> = Vec::new();
for (root, computed, transform, rnode, alpha, chain, filter_input, backdrop_chain) in &roots {
let row = registry.layers.get_mut(&rnode.0);
if let Some(row) = &row {
debug_assert_eq!(row.entity, root);
}
let size = computed.size();
if size.x <= 0.5 || size.y <= 0.5 {
if let Some(row) = row {
row.capture_rect = None;
}
continue;
}
let min = transform.translation - size * 0.5;
let mut rect = LayerCaptureRect {
min,
size: UVec2::new(size.x.ceil() as u32, size.y.ceil() as u32),
outset: 0,
};
if rect.size.x == 0 || rect.size.y == 0 {
if let Some(row) = row {
row.capture_rect = None;
}
continue;
}
let content_outset = chain.map_or(0, |c| crate::filters::quantize_outset(c.outset_px));
let backdrop_outset =
backdrop_chain.map_or(0, |c| crate::filters::quantize_outset(c.0.outset_px));
let outset = content_outset.max(backdrop_outset);
if outset > 0 {
rect.min -= Vec2::splat(outset as f32);
rect.size += UVec2::splat(2 * outset);
rect.outset = outset;
}
if content_outset > 0 {
let value = filter_input
.and_then(|i| i.0.0.first())
.map_or_else(|| "filter".to_owned(), |u| u.name.clone());
bleed_candidates.push((root, rnode.0, content_outset, value));
}
frame_rects.insert(root, rect);
if existing_rects.get(root) != Ok(&rect) {
commands.entity(root).insert(rect);
}
if let Some(row) = row {
let display_min = IVec2::new(rect.min.x.round() as i32, rect.min.y.round() as i32);
row.capture_rect = Some(IRect::from_corners(
display_min,
display_min + rect.size.as_ivec2(),
));
row.group_alpha = alpha.0;
}
let mut hash = GEO_HASH_SEED;
fold_geo_i32(&mut hash, rect.size.x as i32);
fold_geo_i32(&mut hash, rect.size.y as i32);
mark_subtree(
root,
root,
root,
transform.translation,
&children,
&root_markers,
&geometry,
&mut hash,
&mut membership.node_to_layer,
);
repaints.geo_hashes.insert(root, hash);
let mut enclosing = None;
let mut depth = 1u32;
let mut cursor = root;
while let Ok(parent) = parents.get(cursor) {
cursor = parent.parent();
if root_markers.contains(cursor) {
if enclosing.is_none() {
enclosing = Some(cursor);
}
depth += 1;
}
}
membership.enclosing.insert(root, enclosing);
if let Some(row) = registry.layers.get_mut(&rnode.0) {
row.depth = depth;
}
}
let candidate_roots: HashSet<Entity> = bleed_candidates.iter().map(|(r, ..)| *r).collect();
warned_bleeds.retain(|e, _| candidate_roots.contains(e));
for (root, node, outset, value) in bleed_candidates {
let outer = match membership.enclosing.get(&root) {
Some(&Some(outer)) => outer,
_ => {
warned_bleeds.remove(&root);
continue;
}
};
let (Some(&inner_rect), Some(&outer_rect)) =
(frame_rects.get(&root), frame_rects.get(&outer))
else {
warned_bleeds.remove(&root);
continue;
};
let inner_max = inner_rect.min + inner_rect.size.as_vec2();
let outer_max = outer_rect.min + outer_rect.size.as_vec2();
let mut sides: Vec<&str> = Vec::new();
if inner_rect.min.x < outer_rect.min.x {
sides.push("left");
}
if inner_rect.min.y < outer_rect.min.y {
sides.push("top");
}
if inner_max.x > outer_max.x {
sides.push("right");
}
if inner_max.y > outer_max.y {
sides.push("bottom");
}
if sides.is_empty() {
warned_bleeds.remove(&root);
continue;
}
let pair = (inner_rect, outer_rect);
if warned_bleeds.get(&root) == Some(&pair) {
continue;
}
warned_bleeds.insert(root, pair);
let _scope = crate::diag::node_scope(node);
crate::diag::report(
"filterBleed",
&value,
&format!(
"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",
sides.join("/")
),
);
}
}
#[allow(clippy::too_many_arguments)]
fn mark_subtree(
node: Entity,
layer: Entity,
dfs_root: Entity,
root_translation: Vec2,
children: &Query<&Children>,
roots: &Query<(), With<PromotedLayer>>,
geometry: &Query<(&ComputedNode, &UiGlobalTransform)>,
hash: &mut u64,
map: &mut HashMap<Entity, Entity>,
) {
if layer == dfs_root
&& let Ok((computed, transform)) = geometry.get(node)
{
fold_member_geometry(hash, root_translation, transform, computed);
}
let layer = if roots.contains(node) { node } else { layer };
map.insert(node, layer);
if let Ok(kids) = children.get(node) {
for &kid in kids {
mark_subtree(
kid,
layer,
dfs_root,
root_translation,
children,
roots,
geometry,
hash,
map,
);
}
}
}
const GEO_HASH_SEED: u64 = 0xcbf29ce484222325;
fn fold_geo_i32(hash: &mut u64, v: i32) {
for b in v.to_le_bytes() {
*hash = (*hash ^ b as u64).wrapping_mul(0x100000001b3);
}
}
pub fn fold_member_geometry(
hash: &mut u64,
root_translation: Vec2,
transform: &UiGlobalTransform,
computed: &ComputedNode,
) {
let rel = transform.translation - root_translation;
fold_geo_i32(hash, (rel.x * 64.0).round() as i32);
fold_geo_i32(hash, (rel.y * 64.0).round() as i32);
let m = transform.matrix2;
fold_geo_i32(hash, (m.x_axis.x * 1024.0).round() as i32);
fold_geo_i32(hash, (m.x_axis.y * 1024.0).round() as i32);
fold_geo_i32(hash, (m.y_axis.x * 1024.0).round() as i32);
fold_geo_i32(hash, (m.y_axis.y * 1024.0).round() as i32);
let size = computed.size();
fold_geo_i32(hash, (size.x * 64.0).round() as i32);
fold_geo_i32(hash, (size.y * 64.0).round() as i32);
}
pub fn watch_layer_image_assets(
mut events: MessageReader<AssetEvent<Image>>,
images: Query<(Entity, &bevy::ui::widget::ImageNode)>,
registry: Res<LayersRegistry>,
mut dirt: ResMut<LayerContentDirt>,
) {
if registry.layers.is_empty() {
events.clear();
return;
}
let mut touched: Vec<AssetId<Image>> = Vec::new();
for event in events.read() {
match event {
AssetEvent::LoadedWithDependencies { id } | AssetEvent::Modified { id } => {
touched.push(*id);
}
_ => {}
}
}
if touched.is_empty() {
return;
}
for (entity, image) in &images {
if touched.contains(&image.image.id()) {
dirt.nodes.push(entity);
}
}
}
pub fn resolve_layer_repaints(
mut dirt: ResMut<LayerContentDirt>,
mut state: ResMut<LayerRepaintState>,
membership: Res<LayerMembership>,
mut registry: ResMut<LayersRegistry>,
bridge: Option<Res<crate::bridge::JsBridge>>,
reshaped: Query<Entity, Changed<bevy::text::TextLayoutInfo>>,
focus: Query<&crate::bridge::FocusState>,
) {
let state = &mut *state;
state.dirty.clear();
for e in dirt.nodes.drain(..) {
if let Some(&layer) = membership.node_to_layer.get(&e) {
state.dirty.insert(layer);
}
}
for e in dirt.composite_only.drain(..) {
let layer = membership.node_to_layer.get(&e).copied().unwrap_or(e);
if let Some(&Some(outer)) = membership.enclosing.get(&layer) {
state.dirty.insert(outer);
}
}
for e in &reshaped {
if let Some(&layer) = membership.node_to_layer.get(&e) {
state.dirty.insert(layer);
}
}
if let Some(bridge) = bridge {
for id in &bridge.editable_inputs {
if let Some(&e) = bridge.nodes.get(id)
&& focus.get(e).is_ok_and(|f| f.0)
&& let Some(&layer) = membership.node_to_layer.get(&e)
{
state.dirty.insert(layer);
}
}
}
for (&root, hash) in &state.geo_hashes {
if state.prev_hashes.get(&root) != Some(hash) {
state.dirty.insert(root);
}
}
std::mem::swap(&mut state.prev_hashes, &mut state.geo_hashes);
for meta in registry.layers.values() {
if meta.cache_policy == crate::protocol::style::LayerCache::Never {
state.dirty.insert(meta.entity);
}
}
let seeds: Vec<Entity> = state.dirty.iter().copied().collect();
for mut layer in seeds {
while let Some(&Some(outer)) = membership.enclosing.get(&layer) {
if !state.dirty.insert(outer) {
break; }
layer = outer;
}
}
for meta in registry.layers.values_mut() {
let dirty = state.dirty.contains(&meta.entity);
meta.cached = !dirty;
if dirty {
meta.repaints += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::JsBridge;
use crate::protocol::{NodeId, op::Op, outbound::Outbound, props::Props};
use bevy::ui::BackgroundColor;
fn props(json: serde_json::Value) -> Props {
serde_json::from_value(json).expect("valid props")
}
#[test]
fn promotion_reasons_matrix() {
let promoted = |p: &Props, kids: usize, ineligible: bool| {
!promotion_reasons(p, kids, ineligible).is_empty()
};
let base = props(serde_json::json!({ "style": { "opacity": 0.5 } }));
assert!(promoted(&base, 1, false));
let one = props(serde_json::json!({ "style": { "opacity": 1.0 } }));
assert!(promoted(&one, 1, false));
assert!(!promoted(&base, 0, false));
let plain = props(serde_json::json!({ "style": { "width": 10 } }));
assert!(!promoted(&plain, 3, false));
let opted_out =
props(serde_json::json!({ "style": { "opacity": 0.5, "groupAlpha": false } }));
assert!(!promoted(&opted_out, 1, false));
let hover_only = props(serde_json::json!({
"style": { "width": 10 },
"hoverStyle": { "opacity": 0.8 },
}));
assert!(promoted(&hover_only, 1, false));
let animated = props(serde_json::json!({
"style": { "opacity": { "animated": { "id": 1 } } },
}));
assert!(promoted(&animated, 1, false));
assert!(!promoted(&base, 1, true));
let forced = props(serde_json::json!({ "style": { "cache": "always" } }));
assert_eq!(
promotion_reasons(&forced, 0, false).0,
PromotionReasons::FORCED
);
let forced_opted_out = props(serde_json::json!({
"style": { "cache": "always", "opacity": 0.5, "groupAlpha": false }
}));
assert_eq!(
promotion_reasons(&forced_opted_out, 1, false).0,
PromotionReasons::FORCED
);
let both = props(serde_json::json!({
"style": { "cache": "always", "opacity": 0.5 }
}));
assert_eq!(
promotion_reasons(&both, 1, false).0,
PromotionReasons::FORCED | PromotionReasons::OPACITY
);
let auto = props(serde_json::json!({ "style": { "cache": "auto" } }));
assert!(!promoted(&auto, 1, false));
assert!(!promoted(&forced, 1, true));
let filtered = props(serde_json::json!({ "style": { "filter": { "name": "blur" } } }));
assert_eq!(
promotion_reasons(&filtered, 0, false).0,
PromotionReasons::FILTER
);
let empty_chain = props(serde_json::json!({ "style": { "filter": [] } }));
assert!(!promoted(&empty_chain, 1, false));
let filter_and_opacity = props(serde_json::json!({
"style": { "filter": { "name": "blur" }, "opacity": 0.5 }
}));
assert_eq!(
promotion_reasons(&filter_and_opacity, 1, false).0,
PromotionReasons::FILTER | PromotionReasons::OPACITY
);
assert!(!promoted(&filtered, 0, true));
for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
let variant_filter = props(serde_json::json!({
"style": { "width": 10 },
(variant): { "filter": { "name": "blur" } },
}));
assert_eq!(
promotion_reasons(&variant_filter, 0, false).0,
PromotionReasons::FILTER,
"{variant}-only filter promotes eagerly"
);
}
let empty_variant = props(serde_json::json!({
"style": { "width": 10 },
"hoverStyle": { "filter": [] },
}));
assert!(!promoted(&empty_variant, 1, false));
let transformed = props(serde_json::json!({
"style": { "transform3d": { "rotateY": 45 } }
}));
assert_eq!(
promotion_reasons(&transformed, 0, false).0,
PromotionReasons::TRANSFORM3D
);
let identity_3d = props(serde_json::json!({ "style": { "transform3d": {} } }));
assert_eq!(
promotion_reasons(&identity_3d, 0, false).0,
PromotionReasons::TRANSFORM3D
);
let opted_out = props(serde_json::json!({
"style": { "transform3d": {}, "groupAlpha": false }
}));
assert!(promoted(&opted_out, 0, false));
for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
let variant_3d = props(serde_json::json!({
"style": { "width": 10 },
(variant): { "transform3d": { "rotateX": 10 } },
}));
assert_eq!(
promotion_reasons(&variant_3d, 0, false).0,
PromotionReasons::TRANSFORM3D,
"{variant}-only transform3d promotes eagerly"
);
}
assert!(!promoted(&transformed, 0, true));
let backdrop =
props(serde_json::json!({ "style": { "backdropFilter": { "name": "blur" } } }));
assert_eq!(
promotion_reasons(&backdrop, 0, false).0,
PromotionReasons::BACKDROP
);
let empty_backdrop = props(serde_json::json!({ "style": { "backdropFilter": [] } }));
assert!(!promoted(&empty_backdrop, 1, false));
let both_chains = props(serde_json::json!({
"style": { "backdropFilter": { "name": "blur" }, "filter": { "name": "sepia" } }
}));
assert_eq!(
promotion_reasons(&both_chains, 0, false).0,
PromotionReasons::BACKDROP | PromotionReasons::FILTER
);
for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
let variant_backdrop = props(serde_json::json!({
"style": { "width": 10 },
(variant): { "backdropFilter": { "name": "blur" } },
}));
assert_eq!(
promotion_reasons(&variant_backdrop, 0, false).0,
PromotionReasons::BACKDROP,
"{variant}-only backdropFilter promotes eagerly"
);
}
assert!(!promoted(&backdrop, 0, true));
let plain = props(serde_json::json!({ "style": { "width": 10 } }));
assert!(!promoted(&plain, 1, false));
}
fn layer_app() -> (bevy::app::App, crossbeam_channel::Sender<Vec<Op>>) {
use bevy::app::App;
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
app.init_asset::<Image>();
app.init_asset::<bevy::image::TextureAtlasLayout>();
app.init_resource::<crate::plugin::Fonts>();
app.init_resource::<crate::reconcile::OpApplyStats>();
app.init_resource::<crate::ui_map::AtlasLayoutCache>();
app.init_resource::<LayersRegistry>();
app.init_resource::<LayerMembership>();
let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
std::mem::forget(out_rx);
let root = app.world_mut().spawn_empty().id();
app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
app.add_systems(
Update,
(
crate::reconcile::apply_js_ops,
evaluate_layer_promotions.after(crate::reconcile::apply_js_ops),
),
);
(app, ops_tx)
}
fn create(id: NodeId, json: serde_json::Value) -> Op {
Op::Create {
id,
kind: "node".into(),
props: Box::new(props(json)),
text: None,
}
}
fn update(id: NodeId, json: serde_json::Value, style_unset: &[&str]) -> Op {
Op::Update {
id,
props: Box::new(props(json)),
unset: vec![],
style_unset: style_unset.iter().map(|s| s.to_string()).collect(),
}
}
fn entity_of(app: &bevy::app::App, id: NodeId) -> Entity {
*app.world().resource::<JsBridge>().nodes.get(&id).unwrap()
}
#[test]
fn promotion_lifecycle_and_fold_handoff() {
let (mut app, ops_tx) = layer_app();
ops_tx
.send(vec![
create(
1,
serde_json::json!({
"style": { "opacity": 0.5, "backgroundColor": "#ff0000" }
}),
),
create(2, serde_json::json!({})),
Op::Append {
parent: 1,
child: 2,
},
])
.unwrap();
app.update();
let e = entity_of(&app, 1);
assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
assert_eq!(
app.world().get::<LayerGroupAlpha>(e),
Some(&LayerGroupAlpha(0.5))
);
let registry = app.world().resource::<LayersRegistry>();
assert_eq!(registry.layers.len(), 1);
assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::OPACITY);
let bg = app.world().get::<BackgroundColor>(e).unwrap();
assert_eq!(bg.0.alpha(), 1.0, "promoted bg keeps its own alpha");
ops_tx
.send(vec![update(
1,
serde_json::json!({ "style": { "groupAlpha": false } }),
&[],
)])
.unwrap();
app.update();
assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
assert!(app.world().get::<LayerGroupAlpha>(e).is_none());
assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
let bg = app.world().get::<BackgroundColor>(e).unwrap();
assert_eq!(bg.0.alpha(), 0.5, "demoted bg re-bakes the fold");
ops_tx
.send(vec![update(1, serde_json::json!({}), &["groupAlpha"])])
.unwrap();
app.update();
let e1 = entity_of(&app, 1);
assert!(app.world().get::<PromotedLayer>(e1).is_some());
ops_tx
.send(vec![Op::Remove {
parent: 1,
child: 2,
}])
.unwrap();
app.update();
assert!(
app.world().get::<PromotedLayer>(e1).is_none(),
"no children → demoted"
);
assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
}
#[test]
fn forced_cache_lifecycle() {
let (mut app, ops_tx) = layer_app();
ops_tx
.send(vec![create(
1,
serde_json::json!({ "style": { "cache": "always" } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
assert_eq!(
app.world().get::<LayerGroupAlpha>(e),
Some(&LayerGroupAlpha(1.0))
);
ops_tx
.send(vec![update(1, serde_json::json!({}), &["cache"])])
.unwrap();
app.update();
assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
}
#[test]
fn never_cache_lifecycle_and_policy() {
use crate::protocol::style::LayerCache;
let (mut app, ops_tx) = layer_app();
ops_tx
.send(vec![create(
1,
serde_json::json!({ "style": { "cache": "never" } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
let row = app.world().resource::<LayersRegistry>().layers[&1];
assert_eq!(row.cache_policy, LayerCache::Never);
ops_tx
.send(vec![update(
1,
serde_json::json!({ "style": { "cache": "always" } }),
&[],
)])
.unwrap();
app.update();
assert!(
app.world().get::<PromotedLayer>(e).is_some(),
"stays promoted"
);
let row = app.world().resource::<LayersRegistry>().layers[&1];
assert_eq!(row.cache_policy, LayerCache::Always);
ops_tx
.send(vec![update(1, serde_json::json!({}), &["cache"])])
.unwrap();
app.update();
assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
}
#[test]
fn filter_promotion_lifecycle() {
let (mut app, ops_tx) = layer_app();
ops_tx
.send(vec![create(
1,
serde_json::json!({ "style": { "filter": { "name": "grayscale" } } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
assert_eq!(
app.world().get::<LayerGroupAlpha>(e),
Some(&LayerGroupAlpha(1.0))
);
let registry = app.world().resource::<LayersRegistry>();
assert_eq!(registry.layers.len(), 1);
assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::FILTER);
ops_tx
.send(vec![update(1, serde_json::json!({}), &["filter"])])
.unwrap();
app.update();
assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
}
#[test]
fn backdrop_promotion_lifecycle() {
let (mut app, ops_tx) = layer_app();
ops_tx
.send(vec![create(
1,
serde_json::json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
assert_eq!(promoted.reasons.0, PromotionReasons::BACKDROP);
assert_eq!(
app.world().get::<LayerGroupAlpha>(e),
Some(&LayerGroupAlpha(1.0))
);
let registry = app.world().resource::<LayersRegistry>();
assert_eq!(registry.layers.len(), 1);
assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::BACKDROP);
ops_tx
.send(vec![update(1, serde_json::json!({}), &["backdropFilter"])])
.unwrap();
app.update();
assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
}
#[test]
fn hover_filter_promotes_eagerly_from_creation() {
let (mut app, ops_tx) = layer_app();
ops_tx
.send(vec![create(
1,
serde_json::json!({
"style": { "width": 10 },
"hoverStyle": { "filter": { "name": "blur", "params": { "radius": 8 } } },
}),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
let promoted = app
.world()
.get::<PromotedLayer>(e)
.expect("promoted before any hover");
assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
assert_eq!(
app.world().resource::<LayersRegistry>().layers[&1]
.reasons
.0,
PromotionReasons::FILTER
);
ops_tx
.send(vec![Op::Update {
id: 1,
props: Box::new(props(serde_json::json!({}))),
unset: vec!["hoverStyle".into()],
style_unset: vec![],
}])
.unwrap();
app.update();
assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
}
#[test]
fn repaint_resolution_and_propagation() {
let mut world = World::new();
world.init_resource::<LayerContentDirt>();
world.init_resource::<LayerRepaintState>();
world.init_resource::<LayersRegistry>();
let outer = world.spawn_empty().id();
let inner = world.spawn_empty().id();
let member = world.spawn_empty().id(); let outer_member = world.spawn_empty().id(); let mut membership = LayerMembership::default();
membership.node_to_layer.insert(outer, outer);
membership.node_to_layer.insert(inner, inner);
membership.node_to_layer.insert(member, inner);
membership.node_to_layer.insert(outer_member, outer);
membership.enclosing.insert(outer, None);
membership.enclosing.insert(inner, Some(outer));
world.insert_resource(membership);
let mut schedule = Schedule::default();
schedule.add_systems(resolve_layer_repaints);
let mut run = |world: &mut World| {
schedule.run(world);
world.resource::<LayerRepaintState>().dirty.clone()
};
{
let mut state = world.resource_mut::<LayerRepaintState>();
state.geo_hashes.insert(outer, 1);
state.geo_hashes.insert(inner, 2);
}
let dirty = run(&mut world);
assert!(
dirty.contains(&outer) && dirty.contains(&inner),
"{dirty:?}"
);
{
let mut state = world.resource_mut::<LayerRepaintState>();
state.geo_hashes.insert(outer, 1);
state.geo_hashes.insert(inner, 2);
}
assert!(run(&mut world).is_empty());
{
let mut state = world.resource_mut::<LayerRepaintState>();
state.geo_hashes.insert(outer, 1);
state.geo_hashes.insert(inner, 2);
world.resource_mut::<LayerContentDirt>().nodes.push(member);
}
let dirty = run(&mut world);
assert!(dirty.contains(&inner) && dirty.contains(&outer));
{
let mut state = world.resource_mut::<LayerRepaintState>();
state.geo_hashes.insert(outer, 1);
state.geo_hashes.insert(inner, 2);
world
.resource_mut::<LayerContentDirt>()
.composite_only
.push(inner);
}
let dirty = run(&mut world);
assert!(
dirty.contains(&outer) && !dirty.contains(&inner),
"{dirty:?}"
);
{
let mut state = world.resource_mut::<LayerRepaintState>();
state.geo_hashes.insert(outer, 1);
state.geo_hashes.insert(inner, 2);
world
.resource_mut::<LayerContentDirt>()
.composite_only
.push(outer);
}
assert!(run(&mut world).is_empty());
{
let mut state = world.resource_mut::<LayerRepaintState>();
state.geo_hashes.insert(outer, 1);
state.geo_hashes.insert(inner, 3);
}
let dirty = run(&mut world);
assert!(dirty.contains(&inner) && dirty.contains(&outer));
{
let mut state = world.resource_mut::<LayerRepaintState>();
state.geo_hashes.insert(outer, 1);
state.geo_hashes.insert(inner, 3);
world
.resource_mut::<LayerContentDirt>()
.nodes
.push(outer_member);
}
let dirty = run(&mut world);
assert!(dirty.contains(&outer) && !dirty.contains(&inner));
}
#[test]
fn never_policy_repaints_every_frame() {
use crate::protocol::style::LayerCache;
let mut world = World::new();
world.init_resource::<LayerContentDirt>();
world.init_resource::<LayerRepaintState>();
world.init_resource::<LayersRegistry>();
let outer = world.spawn_empty().id();
let inner = world.spawn_empty().id(); let other = world.spawn_empty().id(); let mut membership = LayerMembership::default();
membership.node_to_layer.insert(outer, outer);
membership.node_to_layer.insert(inner, inner);
membership.node_to_layer.insert(other, other);
membership.enclosing.insert(outer, None);
membership.enclosing.insert(inner, Some(outer));
membership.enclosing.insert(other, None);
world.insert_resource(membership);
let meta = |node: NodeId, entity: Entity, policy: LayerCache| LayerMeta {
node,
entity,
reasons: PromotionReasons(PromotionReasons::FORCED),
group_alpha: 1.0,
capture_rect: None,
depth: 1,
repaints: 0,
cached: false,
cache_policy: policy,
};
{
let mut registry = world.resource_mut::<LayersRegistry>();
registry.layers.insert(1, meta(1, outer, LayerCache::Auto));
registry.layers.insert(2, meta(2, inner, LayerCache::Never));
registry
.layers
.insert(3, meta(3, other, LayerCache::Always));
}
let mut schedule = Schedule::default();
schedule.add_systems(resolve_layer_repaints);
for frame in 0..2 {
schedule.run(&mut world);
let state = world.resource::<LayerRepaintState>();
assert!(
state.dirty.contains(&inner) && state.dirty.contains(&outer),
"frame {frame}: {:?}",
state.dirty
);
assert!(!state.dirty.contains(&other), "frame {frame}");
}
let registry = world.resource::<LayersRegistry>();
assert_eq!(registry.layers[&2].repaints, 2);
assert!(!registry.layers[&2].cached);
assert!(registry.layers[&3].cached);
}
#[test]
fn geometry_fold_translation_invariance() {
use bevy::math::Affine2;
let node = |size: Vec2, pos: Vec2| {
let computed = ComputedNode {
size,
..Default::default()
};
(
computed,
UiGlobalTransform::from(Affine2::from_translation(pos)),
)
};
let fold = |members: &[(ComputedNode, UiGlobalTransform)], root: Vec2| {
let mut hash = GEO_HASH_SEED;
for (computed, transform) in members {
fold_member_geometry(&mut hash, root, transform, computed);
}
hash
};
let members = [
node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
];
let base = fold(&members, Vec2::new(10.0, 20.0));
let delta = Vec2::new(123.4, -56.78);
let shifted = [
node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0) + delta),
node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0) + delta),
];
assert_eq!(base, fold(&shifted, Vec2::new(10.0, 20.0) + delta));
let moved = [
node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
node(Vec2::new(30.0, 30.0), Vec2::new(41.0, 25.0)),
];
assert_ne!(base, fold(&moved, Vec2::new(10.0, 20.0)));
let resized = [
node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
node(Vec2::new(31.0, 30.0), Vec2::new(40.0, 25.0)),
];
assert_ne!(base, fold(&resized, Vec2::new(10.0, 20.0)));
let mut scaled = [
node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
];
scaled[1].1 = UiGlobalTransform::from(Affine2::from_scale_angle_translation(
Vec2::splat(1.5),
0.0,
Vec2::new(40.0, 25.0),
));
assert_ne!(base, fold(&scaled, Vec2::new(10.0, 20.0)));
let noisy = [
node(Vec2::new(100.0, 50.0), Vec2::new(10.0 + 1e-4, 20.0)),
node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0 - 1e-4)),
];
assert_eq!(base, fold(&noisy, Vec2::new(10.0, 20.0)));
}
fn geometry_world() -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<LayerMembership>();
world.init_resource::<LayersRegistry>();
world.init_resource::<LayerRepaintState>();
world.init_resource::<LayerContentDirt>();
let mut schedule = Schedule::default();
schedule.add_systems((sync_layer_geometry, resolve_layer_repaints).chain());
(world, schedule)
}
fn spawn_layer_root(world: &mut World, id: NodeId, size: Vec2, center: Vec2) -> Entity {
world
.spawn((
ComputedNode {
size,
..Default::default()
},
UiGlobalTransform::from(bevy::math::Affine2::from_translation(center)),
crate::bridge::RNode(id),
LayerGroupAlpha(1.0),
PromotedLayer {
reasons: PromotionReasons(PromotionReasons::FILTER),
},
))
.id()
}
fn filter_outset(world: &mut World, e: Entity, outset_px: u32) {
world
.entity_mut(e)
.insert(crate::filters::ResolvedFilterChain {
outset_px,
..Default::default()
});
}
fn backdrop_outset(world: &mut World, e: Entity, outset_px: u32) {
world
.entity_mut(e)
.insert(crate::filters::ResolvedBackdropChain(
crate::filters::ResolvedFilterChain {
outset_px,
..Default::default()
},
));
}
#[test]
fn outset_inflates_rect_by_quantized_margin() {
let (mut world, mut schedule) = geometry_world();
let size = Vec2::new(100.0, 60.0);
let center = Vec2::new(50.0, 30.0);
let plain = spawn_layer_root(&mut world, 1, size, center);
let blurred = spawn_layer_root(&mut world, 2, size, center);
filter_outset(&mut world, blurred, 12); let big = spawn_layer_root(&mut world, 3, size, center);
filter_outset(&mut world, big, 60); schedule.run(&mut world);
let base = *world.get::<LayerCaptureRect>(plain).expect("baseline rect");
assert_eq!(base.min, Vec2::ZERO);
assert_eq!(base.size, UVec2::new(100, 60));
let rect = *world.get::<LayerCaptureRect>(blurred).expect("rect");
assert_eq!(rect.min, base.min - Vec2::splat(16.0));
assert_eq!(rect.size, base.size + UVec2::splat(32));
let rect = *world.get::<LayerCaptureRect>(big).expect("rect");
assert_eq!(rect.min, base.min - Vec2::splat(64.0));
assert_eq!(rect.size, base.size + UVec2::splat(128));
}
#[test]
fn backdrop_outset_inflates_rect_and_maxes_with_content() {
let (mut world, mut schedule) = geometry_world();
let size = Vec2::new(100.0, 60.0);
let center = Vec2::new(50.0, 30.0);
let plain = spawn_layer_root(&mut world, 1, size, center);
let frosted = spawn_layer_root(&mut world, 2, size, center);
backdrop_outset(&mut world, frosted, 12); let both = spawn_layer_root(&mut world, 3, size, center);
filter_outset(&mut world, both, 4); backdrop_outset(&mut world, both, 40); schedule.run(&mut world);
let base = *world.get::<LayerCaptureRect>(plain).expect("baseline");
assert_eq!(base.outset, 0);
let rect = *world.get::<LayerCaptureRect>(frosted).expect("rect");
assert_eq!(rect.min, base.min - Vec2::splat(16.0));
assert_eq!(rect.size, base.size + UVec2::splat(32));
assert_eq!(rect.outset, 16);
let rect = *world.get::<LayerCaptureRect>(both).expect("rect");
assert_eq!(rect.min, base.min - Vec2::splat(48.0));
assert_eq!(rect.size, base.size + UVec2::splat(96));
assert_eq!(rect.outset, 48);
}
#[test]
fn outset_within_quantize_step_holds_rect_and_cache() {
let (mut world, mut schedule) = geometry_world();
let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
filter_outset(&mut world, e, 12);
schedule.run(&mut world);
assert!(
world.resource::<LayerRepaintState>().dirty.contains(&e),
"first frame repaints"
);
let before = *world.get::<LayerCaptureRect>(e).expect("rect");
filter_outset(&mut world, e, 14); schedule.run(&mut world);
assert_eq!(*world.get::<LayerCaptureRect>(e).expect("rect"), before);
assert!(
world.resource::<LayerRepaintState>().dirty.is_empty(),
"no repaint within a quantize step"
);
}
#[test]
fn outset_crossing_quantize_step_recaptures() {
let (mut world, mut schedule) = geometry_world();
let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
filter_outset(&mut world, e, 14); schedule.run(&mut world);
schedule.run(&mut world); assert!(world.resource::<LayerRepaintState>().dirty.is_empty());
let before = *world.get::<LayerCaptureRect>(e).expect("rect");
filter_outset(&mut world, e, 18); schedule.run(&mut world);
let after = *world.get::<LayerCaptureRect>(e).expect("rect");
assert_eq!(after.min, before.min - Vec2::splat(16.0));
assert_eq!(after.size, before.size + UVec2::splat(32));
assert!(
world.resource::<LayerRepaintState>().dirty.contains(&e),
"step crossing re-captures"
);
}
#[test]
fn zero_content_size_stays_inactive_despite_outset() {
let (mut world, mut schedule) = geometry_world();
let e = spawn_layer_root(&mut world, 1, Vec2::ZERO, Vec2::ZERO);
filter_outset(&mut world, e, 60);
schedule.run(&mut world);
assert!(
world.get::<LayerCaptureRect>(e).is_none(),
"zero content size stays inactive"
);
}
#[cfg(all(feature = "devtools", debug_assertions))]
#[test]
fn nested_filter_bleed_warns_when_clipped() {
let _lock = crate::diag::test_lock();
crate::diag::arm_runtime();
let _ = crate::diag::take_runtime_warnings();
let (mut world, mut schedule) = geometry_world();
let outer = spawn_layer_root(&mut world, 1, Vec2::splat(200.0), Vec2::splat(100.0));
let inner = spawn_layer_root(&mut world, 2, Vec2::splat(100.0), Vec2::splat(100.0));
world.entity_mut(inner).insert((
ChildOf(outer),
crate::filters::FilterInput(crate::filters::FilterChain(vec![
crate::filters::FilterUse {
name: "blur".into(),
params: Default::default(),
},
])),
));
filter_outset(&mut world, inner, 12);
schedule.run(&mut world);
let bleeds = |warns: Vec<crate::diag::RuntimeWarning>| -> Vec<_> {
warns
.into_iter()
.filter(|w| w.kind == "filterBleed")
.collect()
};
assert!(
bleeds(crate::diag::take_runtime_warnings()).is_empty(),
"contained bleed does not warn"
);
filter_outset(&mut world, inner, 60);
schedule.run(&mut world);
let warns = bleeds(crate::diag::take_runtime_warnings());
assert_eq!(warns.len(), 1, "{warns:?}");
assert_eq!(warns[0].node, Some(2));
assert_eq!(warns[0].value, "blur");
for side in ["left", "top", "right", "bottom"] {
assert!(warns[0].message.contains(side), "{}", warns[0].message);
}
schedule.run(&mut world);
assert!(
bleeds(crate::diag::take_runtime_warnings()).is_empty(),
"unchanged bleed is not re-reported"
);
}
#[test]
fn removal_prunes_registry() {
let (mut app, ops_tx) = layer_app();
ops_tx
.send(vec![
create(1, serde_json::json!({})),
create(2, serde_json::json!({ "style": { "opacity": 0.3 } })),
create(3, serde_json::json!({})),
Op::Append {
parent: 1,
child: 2,
},
Op::Append {
parent: 2,
child: 3,
},
])
.unwrap();
app.update();
assert_eq!(app.world().resource::<LayersRegistry>().layers.len(), 1);
ops_tx
.send(vec![Op::Remove {
parent: 1,
child: 2,
}])
.unwrap();
app.update();
assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
assert!(
app.world()
.resource::<JsBridge>()
.promoted_layers
.is_empty()
);
}
}