1use bevy::prelude::*;
18
19use super::resolve::{ChainInput, ResolvedChain, ResolvedFilterChain};
20use super::wire::FilterChain;
21
22#[derive(Component, Debug, Clone, Default, PartialEq)]
28pub struct BackdropInput(pub FilterChain);
29
30impl ChainInput for BackdropInput {
31 const KIND_UNKNOWN: &'static str = "backdropFilterUnknown";
32 const KIND_PARAMS: &'static str = "backdropFilterParams";
33 const FORCE_ALWAYS_DIRTY: bool = true;
34 fn chain(&self) -> &FilterChain {
35 &self.0
36 }
37}
38
39#[derive(Component, Debug, Clone, Default)]
46pub struct ResolvedBackdropChain(pub ResolvedFilterChain);
47
48impl ResolvedChain for ResolvedBackdropChain {
49 fn from_inner(inner: ResolvedFilterChain) -> Self {
50 Self(inner)
51 }
52 fn inner(&self) -> &ResolvedFilterChain {
53 &self.0
54 }
55 fn inner_mut(&mut self) -> &mut ResolvedFilterChain {
56 &mut self.0
57 }
58}
59
60#[cfg(test)]
61mod tests {
62 use serde_json::json;
63
64 use super::super::test_util::{
65 anim_app, create, drain_dirt, ease_app, entity_of, resolve_app, tick, update,
66 };
67 use super::*;
68 use crate::layer::{LayerContentDirt, PromotedLayer};
69
70 #[test]
76 fn backdrop_create_attaches_forced_always_dirty_chain() {
77 let (mut app, ops_tx) = resolve_app();
78 ops_tx
79 .send(vec![create(
80 1,
81 json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
82 )])
83 .unwrap();
84 app.update();
85 let e = entity_of(&app, 1);
86 assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
87 assert!(
88 app.world().get::<BackdropInput>(e).is_some(),
89 "input stamped"
90 );
91 let chain = app
92 .world()
93 .get::<ResolvedBackdropChain>(e)
94 .expect("backdrop chain resolved");
95 assert_eq!(chain.0.version, 1);
96 assert_eq!(chain.0.passes.len(), 1);
97 assert_eq!(chain.0.passes[0].params[0].w, 1.0, "full grayscale");
98 assert!(chain.0.always_dirty, "backdrop chains are forced live");
99 assert!(
100 app.world()
101 .get::<super::super::ResolvedFilterChain>(e)
102 .is_none(),
103 "content channel untouched"
104 );
105 }
106
107 #[test]
111 fn backdrop_param_update_is_composite_only() {
112 let (mut app, ops_tx) = resolve_app();
113 ops_tx
114 .send(vec![create(
115 1,
116 json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
117 )])
118 .unwrap();
119 app.update();
120 let e = entity_of(&app, 1);
121 drain_dirt(&mut app);
122
123 ops_tx
124 .send(vec![update(
125 1,
126 json!({ "style": { "backdropFilter": {
127 "name": "grayscale", "params": { "amount": 0.5 }
128 } } }),
129 &[],
130 )])
131 .unwrap();
132 app.update();
133 let chain = app.world().get::<ResolvedBackdropChain>(e).expect("chain");
134 assert_eq!(chain.0.version, 2);
135 assert_eq!(chain.0.passes[0].params[0].w, 0.5);
136 let dirt = app.world().resource::<LayerContentDirt>();
137 assert!(dirt.composite_only.contains(&e), "{dirt:?}");
138 assert!(!dirt.nodes.contains(&e), "{dirt:?}");
139 }
140
141 #[cfg(all(feature = "devtools", debug_assertions))]
145 #[test]
146 fn backdrop_validation_warns_with_backdrop_kinds() {
147 let _lock = crate::diag::test_lock();
148 crate::diag::arm_runtime();
149 let _ = crate::diag::take_runtime_warnings();
150
151 let (mut app, ops_tx) = resolve_app();
152 ops_tx
153 .send(vec![create(
154 7,
155 json!({ "style": { "backdropFilter": [
156 { "name": "nope" },
157 { "name": "blur", "params": { "radius": "50%" } },
158 { "name": "sepia" }
159 ] } }),
160 )])
161 .unwrap();
162 app.update();
163 let e = entity_of(&app, 7);
164 let chain = app.world().get::<ResolvedBackdropChain>(e).expect("chain");
165 assert_eq!(chain.0.passes.len(), 1, "only sepia survives");
166
167 let mut kinds: Vec<_> = crate::diag::take_runtime_warnings()
168 .into_iter()
169 .filter(|w| w.node == Some(7))
170 .map(|w| w.kind)
171 .collect();
172 kinds.sort();
173 assert_eq!(kinds, ["backdropFilterParams", "backdropFilterUnknown"]);
174 }
175
176 #[test]
180 fn unset_removes_chain_while_promoted_and_on_demote() {
181 let (mut app, ops_tx) = resolve_app();
182 ops_tx
183 .send(vec![create(
184 1,
185 json!({ "style": {
186 "cache": "always",
187 "backdropFilter": { "name": "grayscale" }
188 } }),
189 )])
190 .unwrap();
191 app.update();
192 let e = entity_of(&app, 1);
193 assert!(app.world().get::<ResolvedBackdropChain>(e).is_some());
194
195 ops_tx
196 .send(vec![update(1, json!({}), &["backdropFilter"])])
197 .unwrap();
198 app.update();
199 assert!(
200 app.world().get::<PromotedLayer>(e).is_some(),
201 "cache: always still holds the layer"
202 );
203 assert!(app.world().get::<BackdropInput>(e).is_none(), "input gone");
204 assert!(
205 app.world().get::<ResolvedBackdropChain>(e).is_none(),
206 "chain cleaned up while still promoted"
207 );
208
209 ops_tx
210 .send(vec![update(
211 1,
212 json!({ "style": { "backdropFilter": { "name": "sepia" } } }),
213 &["cache"],
214 )])
215 .unwrap();
216 app.update();
217 assert!(app.world().get::<ResolvedBackdropChain>(e).is_some());
218 ops_tx
219 .send(vec![update(1, json!({}), &["backdropFilter"])])
220 .unwrap();
221 app.update();
222 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
223 assert!(
224 app.world().get::<ResolvedBackdropChain>(e).is_none(),
225 "demote removes the resolved chain"
226 );
227 }
228
229 #[test]
234 fn backdrop_transition_eases_and_settles_exactly() {
235 let (mut app, ops_tx) = ease_app();
236 ops_tx
237 .send(vec![create(
238 1,
239 json!({ "style": {
240 "backdropFilter": { "name": "grayscale", "params": { "amount": 0.0 } },
241 "transition": { "backdropFilter": { "duration": 1000, "easing": "linear" } },
242 } }),
243 )])
244 .unwrap();
245 app.update();
246 let e = entity_of(&app, 1);
247 assert_eq!(
248 app.world()
249 .get::<ResolvedBackdropChain>(e)
250 .unwrap()
251 .0
252 .passes[0]
253 .params[0]
254 .w,
255 0.0,
256 "mount snaps, no fade-in"
257 );
258 drain_dirt(&mut app);
259
260 ops_tx
261 .send(vec![update(
262 1,
263 json!({ "style": { "backdropFilter": {
264 "name": "grayscale", "params": { "amount": 1.0 }
265 } } }),
266 &[],
267 )])
268 .unwrap();
269 tick(&mut app, 0.5);
270 {
271 let chain = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
272 let w = chain.passes[0].params[0].w;
273 assert!(w > 0.0 && w < 1.0, "mid-ease: {w}");
274 assert!(chain.always_dirty, "forced-live survives the ease write");
275 let dirt = app.world().resource::<LayerContentDirt>();
276 assert!(dirt.composite_only.contains(&e), "{dirt:?}");
277 assert!(!dirt.nodes.contains(&e), "{dirt:?}");
278 }
279
280 tick(&mut app, 0.6);
281 let settled_version = {
282 let world = app.world();
283 let chain = &world.get::<ResolvedBackdropChain>(e).unwrap().0;
284 let expected =
285 (world.resource::<crate::filters::FilterRegistry>().entries["grayscale"].resolve)(
286 &json!({ "amount": 1.0 }),
287 world.resource::<bevy::asset::AssetServer>(),
288 )
289 .unwrap();
290 assert_eq!(chain.passes, expected, "settles on the resolver's output");
291 chain.version
292 };
293 drain_dirt(&mut app);
294 tick(&mut app, 0.25);
295 assert_eq!(
296 app.world()
297 .get::<ResolvedBackdropChain>(e)
298 .unwrap()
299 .0
300 .version,
301 settled_version,
302 "settled: no more churn"
303 );
304 }
305
306 #[test]
309 fn filter_and_backdrop_channels_ease_independently() {
310 let (mut app, ops_tx) = ease_app();
311 ops_tx
312 .send(vec![create(
313 1,
314 json!({ "style": {
315 "filter": { "name": "grayscale", "params": { "amount": 0.0 } },
316 "backdropFilter": { "name": "sepia", "params": { "amount": 0.0 } },
317 "transition": { "all": { "duration": 1000, "easing": "linear" } },
318 } }),
319 )])
320 .unwrap();
321 app.update();
322 let e = entity_of(&app, 1);
323 drain_dirt(&mut app);
324
325 ops_tx
327 .send(vec![update(
328 1,
329 json!({ "style": { "backdropFilter": {
330 "name": "sepia", "params": { "amount": 1.0 }
331 } } }),
332 &[],
333 )])
334 .unwrap();
335 tick(&mut app, 0.5);
336 let backdrop = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
337 let w = backdrop.passes[0].params[1].x;
338 assert!(w > 0.0 && w < 1.0, "backdrop mid-ease: {w}");
339 let content = app
340 .world()
341 .get::<crate::filters::ResolvedFilterChain>(e)
342 .unwrap();
343 assert_eq!(content.passes[0].params[0].w, 0.0, "content untouched");
344 assert_eq!(content.version, 1, "content version quiet");
345 }
346
347 #[test]
351 fn backdrop_transition_to_unset_snaps() {
352 let (mut app, ops_tx) = ease_app();
353 ops_tx
354 .send(vec![create(
355 1,
356 json!({ "style": {
357 "backdropFilter": { "name": "grayscale" },
358 "transition": { "backdropFilter": { "duration": 1000 } },
359 } }),
360 )])
361 .unwrap();
362 app.update();
363 let e = entity_of(&app, 1);
364 ops_tx
365 .send(vec![update(1, json!({}), &["backdropFilter"])])
366 .unwrap();
367 tick(&mut app, 0.1);
368 assert!(
369 app.world().get::<ResolvedBackdropChain>(e).is_none(),
370 "unset demotes + removes — snap, no ease"
371 );
372 tick(&mut app, 0.1);
373 assert!(app.world().get::<ResolvedBackdropChain>(e).is_none());
374 }
375
376 #[test]
382 fn backdrop_param_binding_follows_shared_value_through_pipeline() {
383 let (mut app, ops_tx, anim_tx) = anim_app();
384 anim_tx
385 .send(crate::animations::AnimationCommand::Set { id: 1, value: 4.0 })
386 .unwrap();
387 ops_tx
388 .send(vec![create(
389 1,
390 json!({
391 "style": {
392 "filter": { "name": "blur", "params": { "radius": 10 } },
393 "backdropFilter": { "name": "blur",
394 "params": { "radius": { "animated": { "id": 1 } } } },
395 },
396 }),
397 )])
398 .unwrap();
399 app.update();
400 let e = entity_of(&app, 1);
401 {
402 let chain = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
403 assert_eq!(chain.passes.len(), 2, "blur expands to H+V");
404 assert_eq!(chain.passes[0].params[0].x, 4.0, "H radius driven");
405 assert_eq!(chain.passes[1].params[0].x, 4.0, "V radius driven");
406 let content = app
407 .world()
408 .get::<crate::filters::ResolvedFilterChain>(e)
409 .unwrap();
410 assert_eq!(
411 content.passes[0].params[0].x, 10.0,
412 "content chain keeps its static radius"
413 );
414 }
415
416 drain_dirt(&mut app);
417 anim_tx
418 .send(crate::animations::AnimationCommand::Set { id: 1, value: 6.0 })
419 .unwrap();
420 tick(&mut app, 0.016);
421 {
422 let chain = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
423 assert_eq!(chain.passes[0].params[0].x, 6.0);
424 assert_eq!(chain.passes[1].params[0].x, 6.0);
425 }
426 let dirt = app.world().resource::<LayerContentDirt>();
427 assert!(dirt.composite_only.contains(&e), "{dirt:?}");
428 assert!(!dirt.nodes.contains(&e), "never capture dirt: {dirt:?}");
429 }
430}