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 fn validate_entry(
38 name: &str,
39 is_morph: bool,
40 _passes: &[super::ResolvedFilterPass],
41 ) -> Result<(), String> {
42 if is_morph {
43 return Err(format!(
44 "morph filter {name:?} cannot be used in a `backdropFilter` chain — it is a \
45 `morphFilter` name"
46 ));
47 }
48 Ok(())
49 }
50}
51
52#[derive(Component, Debug, Clone, Default)]
59pub struct ResolvedBackdropChain(pub ResolvedFilterChain);
60
61impl ResolvedChain for ResolvedBackdropChain {
62 fn from_inner(inner: ResolvedFilterChain) -> Self {
63 Self(inner)
64 }
65 fn inner(&self) -> &ResolvedFilterChain {
66 &self.0
67 }
68 fn inner_mut(&mut self) -> &mut ResolvedFilterChain {
69 &mut self.0
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use serde_json::json;
76
77 use super::super::test_util::{
78 anim_app, create, drain_dirt, ease_app, entity_of, resolve_app, tick, update,
79 };
80 use super::*;
81 use crate::layer::{LayerContentDirt, PromotedLayer};
82
83 #[cfg(all(feature = "devtools", debug_assertions))]
87 #[test]
88 fn morph_filter_in_backdrop_chain_warns_and_skips() {
89 let _lock = crate::diag::test_lock();
90 crate::diag::arm_runtime();
91 let _ = crate::diag::take_runtime_warnings();
92
93 let (mut app, ops_tx) = resolve_app();
94 ops_tx
95 .send(vec![create(
96 11,
97 json!({ "style": { "backdropFilter": { "name": "crossfade" } } }),
98 )])
99 .unwrap();
100 app.update();
101 let e = entity_of(&app, 11);
102 assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
103 assert!(
104 app.world().get::<ResolvedBackdropChain>(e).is_none(),
105 "morph entry skipped, no chain"
106 );
107
108 let warns: Vec<_> = crate::diag::take_runtime_warnings()
109 .into_iter()
110 .filter(|w| w.node == Some(11))
111 .collect();
112 assert_eq!(warns.len(), 1, "{warns:?}");
113 assert_eq!(warns[0].kind, "backdropFilterParams");
114 assert!(
115 warns[0].message.contains("`morphFilter` name"),
116 "{}",
117 warns[0].message
118 );
119 }
120
121 #[test]
127 fn backdrop_create_attaches_forced_always_dirty_chain() {
128 let (mut app, ops_tx) = resolve_app();
129 ops_tx
130 .send(vec![create(
131 1,
132 json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
133 )])
134 .unwrap();
135 app.update();
136 let e = entity_of(&app, 1);
137 assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
138 assert!(
139 app.world().get::<BackdropInput>(e).is_some(),
140 "input stamped"
141 );
142 let chain = app
143 .world()
144 .get::<ResolvedBackdropChain>(e)
145 .expect("backdrop chain resolved");
146 assert_eq!(chain.0.version, 1);
147 assert_eq!(chain.0.passes.len(), 1);
148 assert_eq!(chain.0.passes[0].params[0].w, 1.0, "full grayscale");
149 assert!(chain.0.always_dirty, "backdrop chains are forced live");
150 assert!(
151 app.world()
152 .get::<super::super::ResolvedFilterChain>(e)
153 .is_none(),
154 "content channel untouched"
155 );
156 }
157
158 #[test]
162 fn backdrop_param_update_is_composite_only() {
163 let (mut app, ops_tx) = resolve_app();
164 ops_tx
165 .send(vec![create(
166 1,
167 json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
168 )])
169 .unwrap();
170 app.update();
171 let e = entity_of(&app, 1);
172 drain_dirt(&mut app);
173
174 ops_tx
175 .send(vec![update(
176 1,
177 json!({ "style": { "backdropFilter": {
178 "name": "grayscale", "params": { "amount": 0.5 }
179 } } }),
180 &[],
181 )])
182 .unwrap();
183 app.update();
184 let chain = app.world().get::<ResolvedBackdropChain>(e).expect("chain");
185 assert_eq!(chain.0.version, 2);
186 assert_eq!(chain.0.passes[0].params[0].w, 0.5);
187 let dirt = app.world().resource::<LayerContentDirt>();
188 assert!(dirt.composite_only.contains(&e), "{dirt:?}");
189 assert!(!dirt.nodes.contains(&e), "{dirt:?}");
190 }
191
192 #[cfg(all(feature = "devtools", debug_assertions))]
196 #[test]
197 fn backdrop_validation_warns_with_backdrop_kinds() {
198 let _lock = crate::diag::test_lock();
199 crate::diag::arm_runtime();
200 let _ = crate::diag::take_runtime_warnings();
201
202 let (mut app, ops_tx) = resolve_app();
203 ops_tx
204 .send(vec![create(
205 7,
206 json!({ "style": { "backdropFilter": [
207 { "name": "nope" },
208 { "name": "blur", "params": { "radius": "50%" } },
209 { "name": "sepia" }
210 ] } }),
211 )])
212 .unwrap();
213 app.update();
214 let e = entity_of(&app, 7);
215 let chain = app.world().get::<ResolvedBackdropChain>(e).expect("chain");
216 assert_eq!(chain.0.passes.len(), 1, "only sepia survives");
217
218 let mut kinds: Vec<_> = crate::diag::take_runtime_warnings()
219 .into_iter()
220 .filter(|w| w.node == Some(7))
221 .map(|w| w.kind)
222 .collect();
223 kinds.sort();
224 assert_eq!(kinds, ["backdropFilterParams", "backdropFilterUnknown"]);
225 }
226
227 #[test]
231 fn unset_removes_chain_while_promoted_and_on_demote() {
232 let (mut app, ops_tx) = resolve_app();
233 ops_tx
234 .send(vec![create(
235 1,
236 json!({ "style": {
237 "cache": "always",
238 "backdropFilter": { "name": "grayscale" }
239 } }),
240 )])
241 .unwrap();
242 app.update();
243 let e = entity_of(&app, 1);
244 assert!(app.world().get::<ResolvedBackdropChain>(e).is_some());
245
246 ops_tx
247 .send(vec![update(1, json!({}), &["backdropFilter"])])
248 .unwrap();
249 app.update();
250 assert!(
251 app.world().get::<PromotedLayer>(e).is_some(),
252 "cache: always still holds the layer"
253 );
254 assert!(app.world().get::<BackdropInput>(e).is_none(), "input gone");
255 assert!(
256 app.world().get::<ResolvedBackdropChain>(e).is_none(),
257 "chain cleaned up while still promoted"
258 );
259
260 ops_tx
261 .send(vec![update(
262 1,
263 json!({ "style": { "backdropFilter": { "name": "sepia" } } }),
264 &["cache"],
265 )])
266 .unwrap();
267 app.update();
268 assert!(app.world().get::<ResolvedBackdropChain>(e).is_some());
269 ops_tx
270 .send(vec![update(1, json!({}), &["backdropFilter"])])
271 .unwrap();
272 app.update();
273 assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
274 assert!(
275 app.world().get::<ResolvedBackdropChain>(e).is_none(),
276 "demote removes the resolved chain"
277 );
278 }
279
280 #[test]
285 fn backdrop_transition_eases_and_settles_exactly() {
286 let (mut app, ops_tx) = ease_app();
287 ops_tx
288 .send(vec![create(
289 1,
290 json!({ "style": {
291 "backdropFilter": { "name": "grayscale", "params": { "amount": 0.0 } },
292 "transition": { "backdropFilter": { "duration": 1000, "easing": "linear" } },
293 } }),
294 )])
295 .unwrap();
296 app.update();
297 let e = entity_of(&app, 1);
298 assert_eq!(
299 app.world()
300 .get::<ResolvedBackdropChain>(e)
301 .unwrap()
302 .0
303 .passes[0]
304 .params[0]
305 .w,
306 0.0,
307 "mount snaps, no fade-in"
308 );
309 drain_dirt(&mut app);
310
311 ops_tx
312 .send(vec![update(
313 1,
314 json!({ "style": { "backdropFilter": {
315 "name": "grayscale", "params": { "amount": 1.0 }
316 } } }),
317 &[],
318 )])
319 .unwrap();
320 tick(&mut app, 0.5);
321 {
322 let chain = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
323 let w = chain.passes[0].params[0].w;
324 assert!(w > 0.0 && w < 1.0, "mid-ease: {w}");
325 assert!(chain.always_dirty, "forced-live survives the ease write");
326 let dirt = app.world().resource::<LayerContentDirt>();
327 assert!(dirt.composite_only.contains(&e), "{dirt:?}");
328 assert!(!dirt.nodes.contains(&e), "{dirt:?}");
329 }
330
331 tick(&mut app, 0.6);
332 let settled_version = {
333 let world = app.world();
334 let chain = &world.get::<ResolvedBackdropChain>(e).unwrap().0;
335 let expected =
336 (world.resource::<crate::filters::FilterRegistry>().entries["grayscale"].resolve)(
337 &json!({ "amount": 1.0 }),
338 world.resource::<bevy::asset::AssetServer>(),
339 )
340 .unwrap();
341 assert_eq!(chain.passes, expected, "settles on the resolver's output");
342 chain.version
343 };
344 drain_dirt(&mut app);
345 tick(&mut app, 0.25);
346 assert_eq!(
347 app.world()
348 .get::<ResolvedBackdropChain>(e)
349 .unwrap()
350 .0
351 .version,
352 settled_version,
353 "settled: no more churn"
354 );
355 }
356
357 #[test]
360 fn filter_and_backdrop_channels_ease_independently() {
361 let (mut app, ops_tx) = ease_app();
362 ops_tx
363 .send(vec![create(
364 1,
365 json!({ "style": {
366 "filter": { "name": "grayscale", "params": { "amount": 0.0 } },
367 "backdropFilter": { "name": "sepia", "params": { "amount": 0.0 } },
368 "transition": { "all": { "duration": 1000, "easing": "linear" } },
369 } }),
370 )])
371 .unwrap();
372 app.update();
373 let e = entity_of(&app, 1);
374 drain_dirt(&mut app);
375
376 ops_tx
378 .send(vec![update(
379 1,
380 json!({ "style": { "backdropFilter": {
381 "name": "sepia", "params": { "amount": 1.0 }
382 } } }),
383 &[],
384 )])
385 .unwrap();
386 tick(&mut app, 0.5);
387 let backdrop = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
388 let w = backdrop.passes[0].params[1].x;
389 assert!(w > 0.0 && w < 1.0, "backdrop mid-ease: {w}");
390 let content = app
391 .world()
392 .get::<crate::filters::ResolvedFilterChain>(e)
393 .unwrap();
394 assert_eq!(content.passes[0].params[0].w, 0.0, "content untouched");
395 assert_eq!(content.version, 1, "content version quiet");
396 }
397
398 #[test]
402 fn backdrop_transition_to_unset_snaps() {
403 let (mut app, ops_tx) = ease_app();
404 ops_tx
405 .send(vec![create(
406 1,
407 json!({ "style": {
408 "backdropFilter": { "name": "grayscale" },
409 "transition": { "backdropFilter": { "duration": 1000 } },
410 } }),
411 )])
412 .unwrap();
413 app.update();
414 let e = entity_of(&app, 1);
415 ops_tx
416 .send(vec![update(1, json!({}), &["backdropFilter"])])
417 .unwrap();
418 tick(&mut app, 0.1);
419 assert!(
420 app.world().get::<ResolvedBackdropChain>(e).is_none(),
421 "unset demotes + removes — snap, no ease"
422 );
423 tick(&mut app, 0.1);
424 assert!(app.world().get::<ResolvedBackdropChain>(e).is_none());
425 }
426
427 #[test]
433 fn backdrop_param_binding_follows_shared_value_through_pipeline() {
434 let (mut app, ops_tx, anim_tx) = anim_app();
435 anim_tx
436 .send(crate::animations::AnimationCommand::Set { id: 1, value: 4.0 })
437 .unwrap();
438 ops_tx
439 .send(vec![create(
440 1,
441 json!({
442 "style": {
443 "filter": { "name": "blur", "params": { "radius": 10 } },
444 "backdropFilter": { "name": "blur",
445 "params": { "radius": { "animated": { "id": 1 } } } },
446 },
447 }),
448 )])
449 .unwrap();
450 app.update();
451 let e = entity_of(&app, 1);
452 {
453 let chain = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
454 assert_eq!(chain.passes.len(), 2, "blur expands to H+V");
455 assert_eq!(chain.passes[0].params[0].x, 4.0, "H radius driven");
456 assert_eq!(chain.passes[1].params[0].x, 4.0, "V radius driven");
457 let content = app
458 .world()
459 .get::<crate::filters::ResolvedFilterChain>(e)
460 .unwrap();
461 assert_eq!(
462 content.passes[0].params[0].x, 10.0,
463 "content chain keeps its static radius"
464 );
465 }
466
467 drain_dirt(&mut app);
468 anim_tx
469 .send(crate::animations::AnimationCommand::Set { id: 1, value: 6.0 })
470 .unwrap();
471 tick(&mut app, 0.016);
472 {
473 let chain = &app.world().get::<ResolvedBackdropChain>(e).unwrap().0;
474 assert_eq!(chain.passes[0].params[0].x, 6.0);
475 assert_eq!(chain.passes[1].params[0].x, 6.0);
476 }
477 let dirt = app.world().resource::<LayerContentDirt>();
478 assert!(dirt.composite_only.contains(&e), "{dirt:?}");
479 assert!(!dirt.nodes.contains(&e), "never capture dirt: {dirt:?}");
480 }
481}