Skip to main content

bevy_react/filters/
morph.rs

1//! The `morphFilter` style: a view-transition-style morph between a node's
2//! old and new content.
3//!
4//! Wire shape: `{ key, name, params }` — a [`FilterUse`] plus a `key`. The
5//! morph triggers only when `key` changes: the layer's previous rendered
6//! appearance is frozen as a snapshot texture and the named two-input filter
7//! blends frozen → live content, driven by an engine-owned `progress`
8//! uniform eased 0→1 by the `transition: { morphFilter }` channel (with a
9//! built-in default duration when no spec is given — the one channel that
10//! animates without being asked).
11//!
12//! Decoding follows the warn-don't-abort convention (see [`super::wire`]): a
13//! malformed value warns into the decode sink (`morphFilterParams`) and the
14//! whole field degrades to `None`, never failing the containing `Style`.
15
16use bevy::prelude::*;
17use serde::{Deserialize, Deserializer};
18use serde_json::{Map, Value};
19
20use super::FilterUse;
21use super::registry::ResolvedFilterPass;
22use super::resolve::{ChainInput, ResolvedChain, ResolvedFilterChain};
23use super::wire::FilterChain;
24
25/// The decoded `morphFilter` style value: the morph identity `key` (a string
26/// or number — a change is what triggers the morph) plus the two-input filter
27/// to blend with, as a regular registry [`FilterUse`].
28#[derive(Debug, Clone, PartialEq)]
29pub struct MorphFilter {
30    /// Morph identity. Only meaningful relative to its previous value: a
31    /// change freezes the old appearance and starts the transition.
32    pub key: Value,
33    /// The two-input filter (registry name + raw params) blending
34    /// frozen → live.
35    pub filter: FilterUse,
36}
37
38/// `Style.morph_filter`'s deserializer: `null` → `None`, a malformed value
39/// warns (`morphFilterParams`) and degrades the whole field to `None`.
40pub(crate) fn de_morph_filter<'de, D: Deserializer<'de>>(
41    d: D,
42) -> Result<Option<MorphFilter>, D::Error> {
43    let v = Value::deserialize(d)?;
44    if v.is_null() {
45        return Ok(None);
46    }
47    Ok(morph_from_value(v))
48}
49
50fn morph_from_value(value: Value) -> Option<MorphFilter> {
51    let mut obj = match value {
52        Value::Object(obj) => obj,
53        other => {
54            warn_decode(
55                &other,
56                &format!("morphFilter must be a {{ key, name, params }} object, got {other}"),
57            );
58            return None;
59        }
60    };
61    let key = match obj.remove("key") {
62        Some(key @ (Value::String(_) | Value::Number(_))) => key,
63        Some(other) => {
64            warn_decode(
65                &other,
66                &format!("morphFilter key must be a string or number, got {other}"),
67            );
68            return None;
69        }
70        None => {
71            let entry = Value::Object(obj);
72            warn_decode(&entry, &format!("morphFilter {entry} is missing \"key\""));
73            return None;
74        }
75    };
76    let name = match obj.remove("name") {
77        Some(Value::String(name)) => name,
78        Some(other) => {
79            warn_decode(
80                &other,
81                &format!("morphFilter name must be a string, got {other}"),
82            );
83            return None;
84        }
85        None => {
86            let entry = Value::Object(obj);
87            warn_decode(&entry, &format!("morphFilter {entry} is missing \"name\""));
88            return None;
89        }
90    };
91    let params = match obj.remove("params") {
92        // Same null-leniency as the filter chain: `params: null` == absent.
93        None | Some(Value::Null) => Map::new(),
94        Some(Value::Object(params)) => params,
95        Some(other) => {
96            warn_decode(
97                &other,
98                &format!("morphFilter params must be an object, got {other}"),
99            );
100            return None;
101        }
102    };
103    Some(MorphFilter {
104        key,
105        filter: FilterUse { name, params },
106    })
107}
108
109fn warn_decode(value: &Value, message: &str) {
110    crate::protocol::decode_warn("morphFilterParams", &value.to_string(), message);
111}
112
113/// User params of a morph filter may occupy at most this many `Vec4` slots:
114/// the last two of [`MAX_FILTER_PARAM_VECS`](super::MAX_FILTER_PARAM_VECS)
115/// (= 8) are engine-reserved on a morph pass — `params[7].x` carries the
116/// eased progress (see the prelude's `morph_progress`; `params[6]` is
117/// reserved-unused spare).
118pub const MORPH_MAX_USER_PARAM_VECS: usize = 6;
119
120/// The wire `morphFilter` of a node, mirrored off the applied style by
121/// `crate::ui_map::apply_style_masked`'s MORPH arm — present iff the style
122/// carries the field. Same contract as [`FilterInput`](super::FilterInput):
123/// the style apply owns writes, the resolver only reads. The `key` rides
124/// outside the chain: the resolver never looks at it (a key-only change
125/// re-resolves to identical output, version-quiet) — retarget detection is
126/// the morph transition channel's job.
127#[derive(Component, Debug, Clone, Default, PartialEq)]
128pub struct MorphInput {
129    /// The morph's single [`FilterUse`] as a 1-entry chain, so the shared
130    /// [`resolve_chains`](super::resolve_chains) machinery applies unchanged.
131    pub chain: FilterChain,
132    /// The morph identity (string or number); a change triggers the morph.
133    pub key: Value,
134}
135
136impl ChainInput for MorphInput {
137    const KIND_UNKNOWN: &'static str = "morphFilterUnknown";
138    const KIND_PARAMS: &'static str = "morphFilterParams";
139    const FORCE_ALWAYS_DIRTY: bool = false;
140    fn chain(&self) -> &FilterChain {
141        &self.chain
142    }
143    /// The family check first (the actionable message — a regular filter
144    /// name here should say "not a morph filter", not leak a pass-count
145    /// symptom), then the v1 caps for registered morphs: exactly ONE pass
146    /// (multi-pass resolves have no defined snapshot-binding semantics as a
147    /// morph), and user params leaving the two engine-reserved `Vec4`s free.
148    fn validate_entry(
149        name: &str,
150        is_morph: bool,
151        passes: &[ResolvedFilterPass],
152    ) -> Result<(), String> {
153        if !is_morph {
154            return Err(format!(
155                "{name:?} is not a morph filter — register it with #[react_morph_filter] / \
156                 add_react_morph_filter"
157            ));
158        }
159        if passes.len() != 1 {
160            return Err(format!(
161                "a morph filter must resolve to exactly one pass, got {} — \
162                 multi-pass filters cannot be used as morphs",
163                passes.len()
164            ));
165        }
166        if passes[0].params.len() > MORPH_MAX_USER_PARAM_VECS {
167            return Err(format!(
168                "morph filter params occupy {} vec4 slots, over the cap of \
169                 {MORPH_MAX_USER_PARAM_VECS} (the last two are engine-reserved)",
170                passes[0].params.len()
171            ));
172        }
173        Ok(())
174    }
175}
176
177/// A node's fully resolved morph filter — a newtype over
178/// [`ResolvedFilterChain`] (always exactly one pass, per
179/// [`MorphInput::validate_entry`]) so extract, animations, and devtools reuse
180/// the inner type via `.0`. Attached to promoted roots by the morph
181/// [`resolve_chains`](super::resolve_chains) instance; absent when the entry
182/// is invalid (the morph degrades to a snap). Removed on demotion by
183/// `evaluate_layer_promotions`'s cleanup.
184#[derive(Component, Debug, Clone, Default)]
185pub struct ResolvedMorphChain(pub ResolvedFilterChain);
186
187impl ResolvedChain for ResolvedMorphChain {
188    fn from_inner(inner: ResolvedFilterChain) -> Self {
189        Self(inner)
190    }
191    fn inner(&self) -> &ResolvedFilterChain {
192        &self.0
193    }
194    fn inner_mut(&mut self) -> &mut ResolvedFilterChain {
195        &mut self.0
196    }
197}
198
199/// The live state of a node's morph transition, written by the morph channel
200/// in `crate::transition` (retarget on key change, per-frame progress) and
201/// read by render extraction (the freeze request + blend pass). Inserted on
202/// first activation; removed on demotion.
203///
204/// The frozen snapshot is anchored to the node's LAYOUT rect: the blend
205/// stretches it onto the current capture rect (0..1 UV over both textures),
206/// so scrolling or moving the node mid-flight carries both images together.
207/// A size change across the swap stretches the old appearance — handling
208/// that gracefully is the app's responsibility.
209#[derive(Component, Debug, Clone, Default, PartialEq)]
210pub struct MorphState {
211    /// A morph is in flight: the blend pass runs. Cleared one frame after
212    /// the runner settles (the settle frame renders at exactly
213    /// `progress = 1.0`, which the morph-shader identity contract makes
214    /// pixel-equal to no pass — so dropping the pass next frame can never
215    /// flash).
216    pub active: bool,
217    /// Engine-owned blend progress, `0.0..=1.0` (clamped — spring specs may
218    /// overshoot).
219    pub progress: f32,
220    /// Bumped on every retarget; the render side freezes the snapshot when it
221    /// sees a sequence it hasn't consumed yet.
222    pub freeze_seq: u64,
223}
224
225#[cfg(test)]
226mod resolve_tests {
227    use serde_json::json;
228
229    use super::super::test_util::{create, entity_of, resolve_app, update};
230    use super::*;
231    use crate::layer::PromotedLayer;
232
233    /// A `morphFilter` create promotes (MORPH reason) and resolves into a
234    /// single-pass [`ResolvedMorphChain`] on the root; the content/backdrop
235    /// channels stay untouched. The `key` rides the input, invisible to the
236    /// resolver.
237    #[test]
238    fn morph_create_attaches_resolved_chain() {
239        let (mut app, ops_tx) = resolve_app();
240        ops_tx
241            .send(vec![create(
242                1,
243                json!({ "style": { "morphFilter": {
244                    "key": "a", "name": "crossfade"
245                } } }),
246            )])
247            .unwrap();
248        app.update();
249        let e = entity_of(&app, 1);
250        assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
251        let input = app.world().get::<MorphInput>(e).expect("input stamped");
252        assert_eq!(input.key, json!("a"));
253        assert_eq!(input.chain.0.len(), 1);
254        let chain = app
255            .world()
256            .get::<ResolvedMorphChain>(e)
257            .expect("morph chain resolved");
258        assert_eq!(chain.0.version, 1);
259        assert_eq!(chain.0.passes.len(), 1);
260        assert!(!chain.0.always_dirty);
261        assert!(
262            app.world()
263                .get::<super::super::ResolvedFilterChain>(e)
264                .is_none(),
265            "content channel untouched"
266        );
267        assert!(
268            app.world()
269                .get::<super::super::ResolvedBackdropChain>(e)
270                .is_none(),
271            "backdrop channel untouched"
272        );
273
274        // A key-only change re-stamps the input but the resolved chain is
275        // identical — version-quiet (retargeting is the channel's job).
276        ops_tx
277            .send(vec![update(
278                1,
279                json!({ "style": { "morphFilter": {
280                    "key": "b", "name": "crossfade"
281                } } }),
282                &[],
283            )])
284            .unwrap();
285        app.update();
286        assert_eq!(app.world().get::<MorphInput>(e).unwrap().key, json!("b"));
287        assert_eq!(
288            app.world().get::<ResolvedMorphChain>(e).unwrap().0.version,
289            1,
290            "identical resolve output must not bump"
291        );
292    }
293
294    /// An unknown morph filter name warns under `morphFilterUnknown` and
295    /// attaches no chain — the node stays promoted (presence-based), the
296    /// morph degrades to a snap.
297    #[cfg(all(feature = "devtools", debug_assertions))]
298    #[test]
299    fn unknown_morph_name_warns_and_attaches_no_chain() {
300        let _lock = crate::diag::test_lock();
301        crate::diag::arm_runtime();
302        let _ = crate::diag::take_runtime_warnings();
303
304        let (mut app, ops_tx) = resolve_app();
305        ops_tx
306            .send(vec![create(
307                7,
308                json!({ "style": { "morphFilter": { "key": 1, "name": "nope" } } }),
309            )])
310            .unwrap();
311        app.update();
312        let e = entity_of(&app, 7);
313        assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
314        assert!(app.world().get::<ResolvedMorphChain>(e).is_none());
315
316        let warns: Vec<_> = crate::diag::take_runtime_warnings()
317            .into_iter()
318            .filter(|w| w.node == Some(7))
319            .collect();
320        assert_eq!(warns.len(), 1, "{warns:?}");
321        assert_eq!(warns[0].kind, "morphFilterUnknown");
322    }
323
324    /// The family split: a regular filter (blur) named as a `morphFilter` is
325    /// rejected with a `morphFilterParams` warn and no chain attaches —
326    /// while the same entry in a content `filter` chain on the same node
327    /// resolves fine (per-instance validation).
328    #[cfg(all(feature = "devtools", debug_assertions))]
329    #[test]
330    fn non_morph_filter_rejected_as_morph() {
331        let _lock = crate::diag::test_lock();
332        crate::diag::arm_runtime();
333        let _ = crate::diag::take_runtime_warnings();
334
335        let (mut app, ops_tx) = resolve_app();
336        ops_tx
337            .send(vec![create(
338                8,
339                json!({ "style": {
340                    "morphFilter": { "key": "a", "name": "blur" },
341                    "filter": { "name": "blur" }
342                } }),
343            )])
344            .unwrap();
345        app.update();
346        let e = entity_of(&app, 8);
347        assert!(
348            app.world().get::<ResolvedMorphChain>(e).is_none(),
349            "regular filter rejected as morph"
350        );
351        let content = app
352            .world()
353            .get::<super::super::ResolvedFilterChain>(e)
354            .expect("content chain unaffected by the morph cap");
355        assert_eq!(content.passes.len(), 2, "blur H+V");
356
357        let warns: Vec<_> = crate::diag::take_runtime_warnings()
358            .into_iter()
359            .filter(|w| w.node == Some(8))
360            .collect();
361        assert_eq!(warns.len(), 1, "{warns:?}");
362        assert_eq!(warns[0].kind, "morphFilterParams");
363        assert!(
364            warns[0].message.contains("not a morph filter"),
365            "{}",
366            warns[0].message
367        );
368    }
369
370    /// Direct coverage of the caps the family check now sits in front of
371    /// (unreachable via built-ins wire-side — a registered morph would need
372    /// a multi-pass `resolve` override or >6 param vecs to hit them).
373    #[test]
374    fn validate_entry_orders_family_then_pass_count_then_param_cap() {
375        let pass = || ResolvedFilterPass {
376            shader: Handle::default(),
377            params: Vec::new(),
378            layout: Vec::new().into(),
379            wire_index: 0,
380        };
381        let family = <MorphInput as ChainInput>::validate_entry("blur", false, &[pass()]);
382        assert!(family.unwrap_err().contains("not a morph filter"));
383
384        let two = <MorphInput as ChainInput>::validate_entry("x", true, &[pass(), pass()]);
385        assert!(two.unwrap_err().contains("exactly one pass"));
386
387        let mut fat = pass();
388        fat.params = vec![Vec4::ZERO; MORPH_MAX_USER_PARAM_VECS + 1];
389        let capped = <MorphInput as ChainInput>::validate_entry("x", true, &[fat]);
390        assert!(capped.unwrap_err().contains("over the cap"));
391
392        assert!(<MorphInput as ChainInput>::validate_entry("x", true, &[pass()]).is_ok());
393    }
394
395    /// Unsetting `morphFilter` on a node held promoted by another reason
396    /// removes the input + resolved chain (the `RemovedComponents` cleanup);
397    /// full demotion removes them too (the evaluator's cleanup).
398    #[test]
399    fn unset_removes_chain_while_promoted_and_on_demote() {
400        let (mut app, ops_tx) = resolve_app();
401        ops_tx
402            .send(vec![create(
403                1,
404                json!({ "style": {
405                    "cache": "always",
406                    "morphFilter": { "key": "a", "name": "crossfade" }
407                } }),
408            )])
409            .unwrap();
410        app.update();
411        let e = entity_of(&app, 1);
412        assert!(app.world().get::<ResolvedMorphChain>(e).is_some());
413
414        ops_tx
415            .send(vec![update(1, json!({}), &["morphFilter"])])
416            .unwrap();
417        app.update();
418        assert!(
419            app.world().get::<PromotedLayer>(e).is_some(),
420            "cache: always still holds the layer"
421        );
422        assert!(app.world().get::<MorphInput>(e).is_none(), "input gone");
423        assert!(
424            app.world().get::<ResolvedMorphChain>(e).is_none(),
425            "chain cleaned up while still promoted"
426        );
427
428        ops_tx
429            .send(vec![update(
430                1,
431                json!({ "style": { "morphFilter": { "key": "a", "name": "linearWipe" } } }),
432                &["cache"],
433            )])
434            .unwrap();
435        app.update();
436        assert!(app.world().get::<ResolvedMorphChain>(e).is_some());
437        ops_tx
438            .send(vec![update(1, json!({}), &["morphFilter"])])
439            .unwrap();
440        app.update();
441        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
442        assert!(
443            app.world().get::<ResolvedMorphChain>(e).is_none(),
444            "demote removes the resolved chain"
445        );
446        assert!(
447            app.world().get::<MorphState>(e).is_none(),
448            "demote removes the morph state"
449        );
450    }
451}
452
453#[cfg(test)]
454mod channel_tests {
455    use serde_json::json;
456
457    use super::super::test_util::{create, drain_dirt, ease_app, entity_of, tick, update};
458    use super::*;
459    use crate::layer::{LayerCaptureRect, LayerContentDirt};
460
461    /// Give the headless entity an on-screen rect (no layout/geometry systems
462    /// run in `ease_app`, so `LayerCaptureRect` never appears on its own).
463    fn stamp_rect(app: &mut bevy::app::App, e: Entity) {
464        app.world_mut().entity_mut(e).insert(LayerCaptureRect {
465            min: Vec2::new(10.0, 20.0),
466            size: UVec2::new(100, 50),
467            outset: 0,
468        });
469    }
470
471    /// The headline contract: a key change animates with NO `transition`
472    /// style at all (built-in 300ms default) — freeze frame at exactly 0.0
473    /// with capture dirt, eased middle, settle frame at exactly 1.0 still
474    /// active, deactivation the frame after.
475    #[test]
476    fn key_change_arms_with_default_duration() {
477        let (mut app, ops_tx) = ease_app();
478        ops_tx
479            .send(vec![create(
480                1,
481                json!({ "style": { "morphFilter": { "key": "a", "name": "crossfade" } } }),
482            )])
483            .unwrap();
484        app.update();
485        let e = entity_of(&app, 1);
486        assert!(
487            app.world().get::<MorphState>(e).is_none(),
488            "mount inserts no state"
489        );
490        stamp_rect(&mut app, e);
491        drain_dirt(&mut app);
492
493        ops_tx
494            .send(vec![update(
495                1,
496                json!({ "style": { "morphFilter": { "key": "b", "name": "crossfade" } } }),
497                &[],
498            )])
499            .unwrap();
500        tick(&mut app, 0.15);
501        {
502            let s = app.world().get::<MorphState>(e).expect("state inserted");
503            assert!(s.active);
504            assert_eq!(s.freeze_seq, 1);
505            assert_eq!(s.progress, 0.0, "freeze frame renders fully 'from'");
506            let dirt = app.world().resource::<LayerContentDirt>();
507            assert!(dirt.nodes.contains(&e), "freeze re-captures: {dirt:?}");
508        }
509
510        tick(&mut app, 0.15);
511        {
512            let s = app.world().get::<MorphState>(e).unwrap();
513            assert!(
514                s.progress > 0.0 && s.progress < 1.0,
515                "mid-ease: {}",
516                s.progress
517            );
518            assert!(s.active);
519        }
520
521        tick(&mut app, 0.3);
522        {
523            let s = app.world().get::<MorphState>(e).unwrap();
524            assert_eq!(s.progress, 1.0, "settle writes exactly 1.0");
525            assert!(s.active, "settle frame still renders the pass");
526        }
527
528        tick(&mut app, 0.016);
529        let s = app.world().get::<MorphState>(e).unwrap();
530        assert!(!s.active, "deactivated the frame after settle");
531        assert_eq!(s.progress, 1.0);
532    }
533
534    /// An explicit `transition: { morphFilter }` spec overrides the built-in
535    /// default (1s linear → exact halfway reading at 0.5s).
536    #[test]
537    fn explicit_morph_spec_wins() {
538        let (mut app, ops_tx) = ease_app();
539        ops_tx
540            .send(vec![create(
541                1,
542                json!({ "style": {
543                    "morphFilter": { "key": "a", "name": "crossfade" },
544                    "transition": { "morphFilter": { "duration": 1000, "easing": "linear" } },
545                } }),
546            )])
547            .unwrap();
548        app.update();
549        let e = entity_of(&app, 1);
550        stamp_rect(&mut app, e);
551
552        ops_tx
553            .send(vec![update(
554                1,
555                json!({ "style": {
556                    "morphFilter": { "key": "b", "name": "crossfade" },
557                    "transition": { "morphFilter": { "duration": 1000, "easing": "linear" } },
558                } }),
559                &[],
560            )])
561            .unwrap();
562        tick(&mut app, 0.25); // freeze frame (progress stays 0)
563        tick(&mut app, 0.5);
564        let s = app.world().get::<MorphState>(e).unwrap();
565        assert_eq!(s.progress, 0.5, "1s linear at 0.5s");
566    }
567
568    /// The mount rule: the first key ever seen never animates — and a key
569    /// change with nothing on screen yet (no capture rect) snaps too.
570    #[test]
571    fn mount_and_rectless_key_change_do_not_animate() {
572        let (mut app, ops_tx) = ease_app();
573        ops_tx
574            .send(vec![create(
575                1,
576                json!({ "style": { "morphFilter": { "key": "a", "name": "crossfade" } } }),
577            )])
578            .unwrap();
579        app.update();
580        let e = entity_of(&app, 1);
581        assert!(app.world().get::<MorphState>(e).is_none());
582
583        // Key change with NO LayerCaptureRect: nothing on screen to freeze.
584        ops_tx
585            .send(vec![update(
586                1,
587                json!({ "style": { "morphFilter": { "key": "b", "name": "crossfade" } } }),
588                &[],
589            )])
590            .unwrap();
591        tick(&mut app, 0.016);
592        assert!(
593            app.world().get::<MorphState>(e).is_none(),
594            "rectless key change snaps"
595        );
596        // And the NEXT key change (rect now present) does animate — the
597        // snapped key was adopted, not ignored.
598        stamp_rect(&mut app, e);
599        ops_tx
600            .send(vec![update(
601                1,
602                json!({ "style": { "morphFilter": { "key": "c", "name": "crossfade" } } }),
603                &[],
604            )])
605            .unwrap();
606        tick(&mut app, 0.016);
607        assert!(app.world().get::<MorphState>(e).is_some_and(|s| s.active));
608    }
609
610    /// An unresolved morph (unknown filter name) degrades key changes to
611    /// snaps — no state, no animation.
612    #[test]
613    fn unresolved_morph_key_change_snaps() {
614        let (mut app, ops_tx) = ease_app();
615        ops_tx
616            .send(vec![create(
617                1,
618                json!({ "style": { "morphFilter": { "key": "a", "name": "nope" } } }),
619            )])
620            .unwrap();
621        app.update();
622        let e = entity_of(&app, 1);
623        stamp_rect(&mut app, e);
624        ops_tx
625            .send(vec![update(
626                1,
627                json!({ "style": { "morphFilter": { "key": "b", "name": "nope" } } }),
628                &[],
629            )])
630            .unwrap();
631        tick(&mut app, 0.016);
632        assert!(app.world().get::<MorphState>(e).is_none());
633    }
634
635    /// The interrupt contract: a key change mid-flight bumps `freeze_seq`
636    /// (the render side re-freezes — the in-flight blended output) and
637    /// restarts progress from 0.
638    #[test]
639    fn key_rebump_mid_flight_bumps_freeze_seq_and_restarts() {
640        let (mut app, ops_tx) = ease_app();
641        ops_tx
642            .send(vec![create(
643                1,
644                json!({ "style": { "morphFilter": { "key": "a", "name": "crossfade" } } }),
645            )])
646            .unwrap();
647        app.update();
648        let e = entity_of(&app, 1);
649        stamp_rect(&mut app, e);
650
651        ops_tx
652            .send(vec![update(
653                1,
654                json!({ "style": { "morphFilter": { "key": "b", "name": "crossfade" } } }),
655                &[],
656            )])
657            .unwrap();
658        tick(&mut app, 0.15);
659        tick(&mut app, 0.1);
660        let mid = app.world().get::<MorphState>(e).unwrap().progress;
661        assert!(mid > 0.0 && mid < 1.0, "mid-flight: {mid}");
662
663        ops_tx
664            .send(vec![update(
665                1,
666                json!({ "style": { "morphFilter": { "key": "c", "name": "crossfade" } } }),
667                &[],
668            )])
669            .unwrap();
670        tick(&mut app, 0.016);
671        let s = app.world().get::<MorphState>(e).unwrap();
672        assert_eq!(s.freeze_seq, 2, "re-freeze requested");
673        assert_eq!(s.progress, 0.0, "restarted");
674        assert!(s.active);
675    }
676
677    /// Unsetting `morphFilter` mid-flight snaps: the runtime state is
678    /// removed with the input (the ui_map arm — with no `transition` style
679    /// the unset also tears down `TransitionState`, so nothing else could
680    /// deactivate it). The node here stays promoted via `cache`.
681    #[test]
682    fn unset_morph_mid_flight_snaps_and_deactivates() {
683        let (mut app, ops_tx) = ease_app();
684        ops_tx
685            .send(vec![create(
686                1,
687                json!({ "style": {
688                    "cache": "always",
689                    "morphFilter": { "key": "a", "name": "crossfade" }
690                } }),
691            )])
692            .unwrap();
693        app.update();
694        let e = entity_of(&app, 1);
695        stamp_rect(&mut app, e);
696        ops_tx
697            .send(vec![update(
698                1,
699                json!({ "style": { "morphFilter": { "key": "b", "name": "crossfade" } } }),
700                &[],
701            )])
702            .unwrap();
703        tick(&mut app, 0.1);
704        assert!(app.world().get::<MorphState>(e).is_some_and(|s| s.active));
705
706        ops_tx
707            .send(vec![update(1, json!({}), &["morphFilter"])])
708            .unwrap();
709        tick(&mut app, 0.016);
710        assert!(
711            app.world().get::<crate::layer::PromotedLayer>(e).is_some(),
712            "cache: always still holds the layer"
713        );
714        assert!(
715            app.world().get::<MorphState>(e).is_none(),
716            "unset removes the runtime state with the input"
717        );
718        assert!(app.world().get::<MorphInput>(e).is_none());
719    }
720
721    /// An inline `{ animated }` morph-param binding drives the resolved
722    /// morph's packed slot through the full pipeline (Angle slot: degrees →
723    /// packed radians, composite-only dirt), while the content/backdrop
724    /// chains stay untouched — and the binding does NOT park the morph
725    /// progress: a key change mid-binding still eases.
726    #[test]
727    fn morph_param_binding_drives_packed_slot_and_never_parks_progress() {
728        use super::super::test_util::anim_app;
729        let (mut app, ops_tx, anim_tx) = anim_app();
730        anim_tx
731            .send(crate::animations::AnimationCommand::Set { id: 1, value: 90.0 })
732            .unwrap();
733        ops_tx
734            .send(vec![create(
735                1,
736                json!({ "style": { "morphFilter": {
737                    "key": "a", "name": "linearWipe",
738                    "params": { "angle": { "animated": { "id": 1 } } }
739                } } }),
740            )])
741            .unwrap();
742        app.update();
743        let e = entity_of(&app, 1);
744        {
745            let chain = &app.world().get::<ResolvedMorphChain>(e).unwrap().0;
746            assert!(
747                (chain.passes[0].params[0].x - std::f32::consts::FRAC_PI_2).abs() < 1e-5,
748                "angle driven, degrees → packed radians: {}",
749                chain.passes[0].params[0].x
750            );
751        }
752
753        drain_dirt(&mut app);
754        anim_tx
755            .send(crate::animations::AnimationCommand::Set {
756                id: 1,
757                value: 180.0,
758            })
759            .unwrap();
760        tick(&mut app, 0.016);
761        {
762            let chain = &app.world().get::<ResolvedMorphChain>(e).unwrap().0;
763            assert!((chain.passes[0].params[0].x - std::f32::consts::PI).abs() < 1e-5);
764            let dirt = app.world().resource::<crate::layer::LayerContentDirt>();
765            assert!(dirt.composite_only.contains(&e), "{dirt:?}");
766            assert!(!dirt.nodes.contains(&e), "never capture dirt: {dirt:?}");
767        }
768
769        // A key change with the binding live still eases progress (the
770        // binding parks nothing).
771        stamp_rect(&mut app, e);
772        ops_tx
773            .send(vec![update(
774                1,
775                json!({ "style": { "morphFilter": {
776                    "key": "b", "name": "linearWipe",
777                    "params": { "angle": { "animated": { "id": 1 } } }
778                } } }),
779                &[],
780            )])
781            .unwrap();
782        tick(&mut app, 0.016);
783        tick(&mut app, 0.15);
784        let s = app.world().get::<MorphState>(e).expect("morph armed");
785        assert!(s.active);
786        assert!(
787            s.progress > 0.0 && s.progress < 1.0,
788            "progress eases despite the param binding: {}",
789            s.progress
790        );
791    }
792
793    /// A params-only delta (key unchanged) re-resolves the chain but never
794    /// restarts the progress — the morph triggers on `key` alone.
795    #[test]
796    fn params_change_without_key_change_does_not_restart() {
797        let (mut app, ops_tx) = ease_app();
798        ops_tx
799            .send(vec![create(
800                1,
801                json!({ "style": { "morphFilter": {
802                    "key": "a", "name": "linearWipe", "params": { "softness": 0.0 }
803                } } }),
804            )])
805            .unwrap();
806        app.update();
807        let e = entity_of(&app, 1);
808        stamp_rect(&mut app, e);
809
810        ops_tx
811            .send(vec![update(
812                1,
813                json!({ "style": { "morphFilter": {
814                    "key": "a", "name": "linearWipe", "params": { "softness": 40.0 }
815                } } }),
816                &[],
817            )])
818            .unwrap();
819        tick(&mut app, 0.016);
820        assert!(
821            app.world().get::<MorphState>(e).is_none(),
822            "no morph started"
823        );
824        let chain = app.world().get::<ResolvedMorphChain>(e).unwrap();
825        assert_eq!(chain.0.version, 2, "params snapped by the resolver");
826        // linearWipe packs `softness` (logical px) at params[0].y.
827        assert_eq!(chain.0.passes[0].params[0].y, 40.0);
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    fn decode(json: &str) -> Option<MorphFilter> {
836        #[derive(Deserialize)]
837        struct Holder {
838            #[serde(default, deserialize_with = "de_morph_filter")]
839            morph_filter: Option<MorphFilter>,
840        }
841        let h: Holder = serde_json::from_str(&format!(r#"{{"morph_filter":{json}}}"#))
842            .expect("morphFilter decode must not error");
843        h.morph_filter
844    }
845
846    #[test]
847    fn full_object_decodes() {
848        let m = decode(r#"{"key":"a","name":"linearWipe","params":{"angle":45}}"#)
849            .expect("decodes to Some");
850        assert_eq!(m.key, Value::from("a"));
851        assert_eq!(m.filter.name, "linearWipe");
852        assert_eq!(m.filter.params["angle"], serde_json::json!(45));
853    }
854
855    #[test]
856    fn numeric_key_and_absent_params_decode() {
857        let m = decode(r#"{"key":3,"name":"crossfade"}"#).expect("decodes to Some");
858        assert_eq!(m.key, Value::from(3));
859        assert!(m.filter.params.is_empty());
860        // Null-leniency: `params: null` == absent.
861        let m = decode(r#"{"key":3,"name":"crossfade","params":null}"#).expect("decodes to Some");
862        assert!(m.filter.params.is_empty());
863    }
864
865    /// Whole-value degradation: any malformed piece empties the field, so a
866    /// half-decoded morph can never run.
867    #[test]
868    fn malformed_values_degrade_to_none() {
869        assert_eq!(decode("42"), None); // not an object
870        assert_eq!(decode(r#"{"name":"crossfade"}"#), None); // missing key
871        assert_eq!(decode(r#"{"key":true,"name":"crossfade"}"#), None); // bad key type
872        assert_eq!(decode(r#"{"key":"a"}"#), None); // missing name
873        assert_eq!(decode(r#"{"key":"a","name":7}"#), None); // bad name
874        assert_eq!(decode(r#"{"key":"a","name":"crossfade","params":3}"#), None); // bad params
875        assert_eq!(decode("null"), None); // explicit null is a clean absent
876    }
877
878    /// Malformed values are mirrored into the devtools decode sink under the
879    /// `morphFilterParams` kind (sink is thread-local; parallel-safe).
880    #[cfg(all(feature = "devtools", debug_assertions))]
881    #[test]
882    fn malformed_values_report_decode_warnings() {
883        let _ = crate::diag::take_decode_warnings();
884        let _ = decode(r#"{"name":"crossfade"}"#);
885        let _ = decode("42");
886        let warns = crate::diag::take_decode_warnings();
887        assert_eq!(warns.len(), 2);
888        assert!(warns.iter().all(|w| w.kind == "morphFilterParams"));
889        // A clean decode (incl. explicit null) leaves the sink empty.
890        let _ = decode(r#"{"key":"a","name":"crossfade"}"#);
891        let _ = decode("null");
892        assert!(crate::diag::take_decode_warnings().is_empty());
893    }
894}