Skip to main content

wallr_core/animation/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::Path;
4
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct AnimationSpec {
7    pub name: String,
8    /// Total transition duration. Every publishable package must set this.
9    #[serde(default)]
10    pub duration: Option<String>,
11    #[serde(default)]
12    pub effects: Vec<Effect>,
13    #[serde(default)]
14    pub timeline: Option<Vec<TimelineEntry>>,
15    #[serde(default)]
16    pub variables: HashMap<String, f64>,
17    /// Parent package references, merged from base to child.
18    #[serde(default)]
19    pub extends: Vec<String>,
20    #[serde(default)]
21    pub custom_effects: HashMap<String, crate::custom_effects::CustomEffect>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct TimelineEntry {
26    pub at: String,
27    #[serde(default)]
28    pub duration: Option<String>,
29    #[serde(flatten)]
30    pub effect: Effect,
31}
32
33#[derive(Debug, Clone, Serialize, PartialEq)]
34#[serde(rename_all = "snake_case")]
35pub enum Effect {
36    Fade(FadeParams),
37    Blur(BlurParams),
38    Wipe(WipeParams),
39    Slide(SlideParams),
40    Zoom(ZoomParams),
41    Pixelate(PixelateParams),
42    Ripple(RippleParams),
43    Dissolve(DissolveParams),
44    Wave(WaveParams),
45    Grow(GrowParams),
46    Outer(OuterParams),
47    Shader(ShaderParams),
48}
49
50impl<'de> Deserialize<'de> for Effect {
51    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
52    where
53        D: serde::Deserializer<'de>,
54    {
55        struct EffectVisitor;
56
57        impl<'de> serde::de::Visitor<'de> for EffectVisitor {
58            type Value = Effect;
59
60            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
61                formatter.write_str("a string or a map representing an effect")
62            }
63
64            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
65            where
66                E: serde::de::Error,
67            {
68                match value {
69                    "fade" => Ok(Effect::Fade(FadeParams::default())),
70                    "blur" => Ok(Effect::Blur(BlurParams::default())),
71                    "wipe" => Ok(Effect::Wipe(WipeParams::default())),
72                    "slide" => Ok(Effect::Slide(SlideParams::default())),
73                    "zoom" => Ok(Effect::Zoom(ZoomParams::default())),
74                    "pixelate" => Ok(Effect::Pixelate(PixelateParams::default())),
75                    "ripple" => Ok(Effect::Ripple(RippleParams::default())),
76                    "dissolve" => Ok(Effect::Dissolve(DissolveParams::default())),
77                    "wave" => Ok(Effect::Wave(WaveParams::default())),
78                    "grow" => Ok(Effect::Grow(GrowParams::default())),
79                    "outer" => Ok(Effect::Outer(OuterParams::default())),
80                    _ => Err(E::custom(format!("unknown effect type: {}", value))),
81                }
82            }
83
84            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
85            where
86                A: serde::de::MapAccess<'de>,
87            {
88                let key: String = map
89                    .next_key()?
90                    .ok_or_else(|| serde::de::Error::custom("expected a key in Effect map"))?;
91
92                match key.as_str() {
93                    "fade" => Ok(Effect::Fade(map.next_value()?)),
94                    "blur" => Ok(Effect::Blur(map.next_value()?)),
95                    "wipe" => Ok(Effect::Wipe(map.next_value()?)),
96                    "slide" => Ok(Effect::Slide(map.next_value()?)),
97                    "zoom" => Ok(Effect::Zoom(map.next_value()?)),
98                    "pixelate" => Ok(Effect::Pixelate(map.next_value()?)),
99                    "ripple" => Ok(Effect::Ripple(map.next_value()?)),
100                    "dissolve" => Ok(Effect::Dissolve(map.next_value()?)),
101                    "wave" => Ok(Effect::Wave(map.next_value()?)),
102                    "grow" => Ok(Effect::Grow(map.next_value()?)),
103                    "outer" => Ok(Effect::Outer(map.next_value()?)),
104                    "shader" => Ok(Effect::Shader(map.next_value()?)),
105                    _ => Err(serde::de::Error::custom(format!(
106                        "unknown effect type: {}",
107                        key
108                    ))),
109                }
110            }
111        }
112
113        deserializer.deserialize_any(EffectVisitor)
114    }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118pub struct FadeParams {
119    #[serde(default)]
120    pub from: f32,
121    #[serde(default = "default_one")]
122    pub to: f32,
123    #[serde(default)]
124    pub easing: Easing,
125}
126
127impl Default for FadeParams {
128    fn default() -> Self {
129        Self {
130            from: 0.0,
131            to: 1.0,
132            easing: Easing::EaseInOut,
133        }
134    }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138pub struct BlurParams {
139    #[serde(default = "default_blur_from")]
140    pub from: f32,
141    #[serde(default)]
142    pub to: f32,
143    #[serde(default)]
144    pub easing: Easing,
145}
146
147impl Default for BlurParams {
148    fn default() -> Self {
149        Self {
150            from: 20.0,
151            to: 0.0,
152            easing: Easing::EaseInOut,
153        }
154    }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
158pub struct WipeParams {
159    #[serde(default)]
160    pub direction: WipeDirection,
161    #[serde(default = "default_wipe_softness")]
162    pub softness: f32,
163    #[serde(default)]
164    pub angle: Option<f32>,
165    #[serde(default)]
166    pub easing: Easing,
167}
168
169impl Default for WipeParams {
170    fn default() -> Self {
171        Self {
172            direction: WipeDirection::Left,
173            softness: 0.12,
174            angle: None,
175            easing: Easing::EaseInOut,
176        }
177    }
178}
179
180#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
181#[serde(rename_all = "snake_case")]
182pub enum WipeDirection {
183    #[default]
184    Left,
185    Right,
186    Up,
187    Down,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
191pub struct SlideParams {
192    #[serde(default)]
193    pub direction: SlideDirection,
194    #[serde(default)]
195    pub easing: Easing,
196}
197
198impl Default for SlideParams {
199    fn default() -> Self {
200        Self {
201            direction: SlideDirection::Left,
202            easing: Easing::EaseInOut,
203        }
204    }
205}
206
207#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
208#[serde(rename_all = "snake_case")]
209pub enum SlideDirection {
210    #[default]
211    Left,
212    Right,
213    Up,
214    Down,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
218pub struct ZoomParams {
219    #[serde(default = "default_zoom_from")]
220    pub from: f32,
221    #[serde(default = "default_one")]
222    pub to: f32,
223    #[serde(default)]
224    pub origin: Origin,
225    #[serde(default)]
226    pub easing: Easing,
227}
228
229impl Default for ZoomParams {
230    fn default() -> Self {
231        Self {
232            from: 1.08,
233            to: 1.0,
234            origin: Origin::Center,
235            easing: Easing::EaseInOut,
236        }
237    }
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
241#[serde(rename_all = "snake_case")]
242pub enum Origin {
243    #[default]
244    Center,
245    Cursor,
246    Custom(f32, f32),
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250pub struct PixelateParams {
251    #[serde(default = "default_pixelate_from")]
252    pub from: f32,
253    #[serde(default = "default_one")]
254    pub to: f32,
255    #[serde(default)]
256    pub easing: Easing,
257}
258
259impl Default for PixelateParams {
260    fn default() -> Self {
261        Self {
262            from: 64.0,
263            to: 1.0,
264            easing: Easing::EaseInOut,
265        }
266    }
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
270pub struct RippleParams {
271    #[serde(default)]
272    pub origin: Origin,
273    #[serde(default = "default_frequency")]
274    pub frequency: f32,
275    #[serde(default = "default_amplitude")]
276    pub amplitude: f32,
277    #[serde(default = "default_speed")]
278    pub speed: f32,
279    #[serde(default)]
280    pub easing: Easing,
281}
282
283impl Default for RippleParams {
284    fn default() -> Self {
285        Self {
286            origin: Origin::Center,
287            frequency: 12.0,
288            amplitude: 0.03,
289            speed: 5.0,
290            easing: Easing::EaseInOut,
291        }
292    }
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct DissolveParams {
297    #[serde(default = "default_dissolve_scale")]
298    pub scale: f32,
299    #[serde(default = "default_softness")]
300    pub softness: f32,
301    #[serde(default)]
302    pub easing: Easing,
303}
304
305impl Default for DissolveParams {
306    fn default() -> Self {
307        Self {
308            scale: 4.0,
309            softness: 0.05,
310            easing: Easing::EaseInOut,
311        }
312    }
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
316pub struct WaveParams {
317    #[serde(default = "default_wave_frequency")]
318    pub frequency: f32,
319    #[serde(default = "default_wave_amplitude")]
320    pub amplitude: f32,
321    #[serde(default)]
322    pub angle: Option<f32>,
323    #[serde(default)]
324    pub easing: Easing,
325}
326
327impl Default for WaveParams {
328    fn default() -> Self {
329        Self {
330            frequency: 3.0,
331            amplitude: 0.05,
332            angle: None,
333            easing: Easing::EaseInOut,
334        }
335    }
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
339pub struct GrowParams {
340    #[serde(default)]
341    pub origin: Origin,
342    #[serde(default)]
343    pub easing: Easing,
344}
345
346impl Default for GrowParams {
347    fn default() -> Self {
348        Self {
349            origin: Origin::Center,
350            easing: Easing::EaseInOut,
351        }
352    }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
356pub struct OuterParams {
357    #[serde(default)]
358    pub origin: Origin,
359    #[serde(default)]
360    pub easing: Easing,
361}
362
363impl Default for OuterParams {
364    fn default() -> Self {
365        Self {
366            origin: Origin::Center,
367            easing: Easing::EaseInOut,
368        }
369    }
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
373pub struct ShaderParams {
374    pub file: String,
375    #[serde(default)]
376    pub uniforms: HashMap<String, f64>,
377}
378
379#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, clap::ValueEnum)]
380#[serde(rename_all = "snake_case")]
381#[clap(rename_all = "snake_case")]
382pub enum Easing {
383    Linear,
384    #[serde(alias = "ease-in")]
385    EaseIn,
386    #[serde(alias = "ease-out")]
387    EaseOut,
388    #[serde(alias = "ease-in-out")]
389    #[default]
390    EaseInOut,
391    Emphatic,
392    Spring,
393}
394
395fn default_one() -> f32 {
396    1.0
397}
398fn default_zoom_from() -> f32 {
399    1.08
400}
401fn default_blur_from() -> f32 {
402    20.0
403}
404fn default_softness() -> f32 {
405    0.05
406}
407
408fn default_wipe_softness() -> f32 {
409    0.12
410}
411fn default_pixelate_from() -> f32 {
412    64.0
413}
414fn default_frequency() -> f32 {
415    12.0
416}
417fn default_amplitude() -> f32 {
418    0.03
419}
420fn default_speed() -> f32 {
421    5.0
422}
423fn default_dissolve_scale() -> f32 {
424    4.0
425}
426fn default_wave_frequency() -> f32 {
427    3.0
428}
429fn default_wave_amplitude() -> f32 {
430    0.05
431}
432
433#[repr(C)]
434#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
435pub struct EffectUniforms {
436    pub effect_type: u32,
437    pub progress: f32,
438    pub param_a: f32,
439    pub param_b: f32,
440    pub param_c: f32,
441    pub param_d: f32,
442    pub origin: [f32; 2],
443    pub direction: [f32; 2],
444    pub easing: u32,
445}
446
447#[derive(Debug, thiserror::Error)]
448pub enum AnimationError {
449    #[error("failed to read animation file: {0}")]
450    ReadError(#[from] std::io::Error),
451    #[error("failed to parse animation YAML: {0}")]
452    ParseError(#[from] serde_yaml::Error),
453    #[error("invalid effect: {0}")]
454    InvalidEffect(String),
455    #[error("invalid timeline: {0}")]
456    InvalidTimeline(String),
457    #[error("unresolved variable: {0}")]
458    UnresolvedVariable(String),
459    #[error("invalid duration: {0}")]
460    InvalidDuration(String),
461    #[error("shader error: {0}")]
462    ShaderError(String),
463}
464
465pub fn load_animation(path: &Path) -> Result<AnimationSpec, AnimationError> {
466    let content = std::fs::read_to_string(path)?;
467    parse_animation_yaml(&content)
468}
469
470/// Parse an animation YAML document and expand `${name}` numeric variables.
471pub fn parse_animation_yaml(content: &str) -> Result<AnimationSpec, AnimationError> {
472    let mut value: serde_yaml::Value = serde_yaml::from_str(content)?;
473    let variables = value
474        .get("variables")
475        .and_then(serde_yaml::Value::as_mapping)
476        .cloned()
477        .unwrap_or_default();
478    fn expand(value: &mut serde_yaml::Value, variables: &serde_yaml::Mapping) {
479        match value {
480            serde_yaml::Value::Mapping(map) => {
481                for child in map.values_mut() {
482                    expand(child, variables);
483                }
484            }
485            serde_yaml::Value::Sequence(items) => {
486                for child in items {
487                    expand(child, variables);
488                }
489            }
490            serde_yaml::Value::String(text) if text.starts_with("${") && text.ends_with('}') => {
491                let key = &text[2..text.len() - 1];
492                if let Some(replacement) = variables.get(serde_yaml::Value::String(key.to_string()))
493                {
494                    *value = replacement.clone();
495                }
496            }
497            _ => {}
498        }
499    }
500    expand(&mut value, &variables);
501    serde_yaml::from_value(value).map_err(AnimationError::from)
502}
503
504pub fn validate_animation(spec: &AnimationSpec) -> Result<(), Vec<AnimationError>> {
505    let mut errors = Vec::new();
506    if spec.name.is_empty() {
507        errors.push(AnimationError::InvalidEffect(
508            "Animation name cannot be empty".to_string(),
509        ));
510    }
511    if spec.duration.is_none() {
512        errors.push(AnimationError::InvalidDuration(
513            "duration is required; add e.g. duration: 800ms".to_string(),
514        ));
515    } else if let Some(duration) = &spec.duration
516        && crate::config::parse_duration(duration).is_err()
517    {
518        errors.push(AnimationError::InvalidDuration(duration.clone()));
519    }
520    if let Some(timeline) = &spec.timeline {
521        for entry in timeline {
522            if crate::config::parse_duration(&entry.at).is_err() {
523                errors.push(AnimationError::InvalidTimeline(format!(
524                    "invalid at: {}",
525                    entry.at
526                )));
527            }
528            if let Some(duration) = &entry.duration
529                && crate::config::parse_duration(duration).is_err()
530            {
531                errors.push(AnimationError::InvalidTimeline(format!(
532                    "invalid duration: {duration}"
533                )));
534            }
535        }
536    }
537    for (name, custom) in &spec.custom_effects {
538        if let Err(error) = crate::custom_effects::transpile(name, custom) {
539            errors.push(AnimationError::ShaderError(format!(
540                "custom effect {name}: {error}"
541            )));
542        }
543    }
544    if spec.effects.is_empty() && spec.timeline.is_none() {
545        errors.push(AnimationError::InvalidTimeline(
546            "Animation must contain at least one effect or timeline entry".to_string(),
547        ));
548    }
549    if errors.is_empty() {
550        Ok(())
551    } else {
552        Err(errors)
553    }
554}
555
556pub fn compute_effect_uniforms(effect: &Effect, progress: f32) -> EffectUniforms {
557    let progress = progress.clamp(0.0, 1.0);
558    let easing_index = |e: &Easing| match e {
559        Easing::Linear => 0,
560        Easing::EaseIn => 1,
561        Easing::EaseOut => 2,
562        Easing::EaseInOut => 3,
563        Easing::Emphatic => 4,
564        Easing::Spring => 5,
565    };
566    match effect {
567        Effect::Fade(params) => EffectUniforms {
568            effect_type: 0,
569            progress,
570            param_a: params.from,
571            param_b: params.to,
572            param_c: 0.0,
573            param_d: 0.0,
574            origin: [0.5, 0.5],
575            direction: [0.0, 0.0],
576            easing: easing_index(&params.easing),
577        },
578        Effect::Blur(params) => EffectUniforms {
579            effect_type: 1,
580            progress,
581            param_a: params.from,
582            param_b: params.to,
583            param_c: 0.0,
584            param_d: 0.0,
585            origin: [0.5, 0.5],
586            direction: [0.0, 0.0],
587            easing: easing_index(&params.easing),
588        },
589        Effect::Wipe(params) => {
590            let (dir_vec, origin) = if let Some(angle_deg) = params.angle {
591                let rad = angle_deg.to_radians();
592                (
593                    [rad.cos(), rad.sin()],
594                    [0.5 + 0.5 * rad.cos(), 0.5 - 0.5 * rad.sin()],
595                )
596            } else {
597                match params.direction {
598                    WipeDirection::Left => ([-1.0, 0.0], [0.0, 0.5]),
599                    WipeDirection::Right => ([1.0, 0.0], [1.0, 0.5]),
600                    WipeDirection::Up => ([0.0, 1.0], [0.5, 0.0]),
601                    WipeDirection::Down => ([0.0, -1.0], [0.5, 1.0]),
602                }
603            };
604            EffectUniforms {
605                effect_type: 2,
606                progress,
607                param_a: params.softness,
608                param_b: 0.0,
609                param_c: 0.0,
610                param_d: 0.0,
611                origin,
612                direction: dir_vec,
613                easing: easing_index(&params.easing),
614            }
615        }
616        Effect::Slide(params) => {
617            let (dir_vec, origin) = match params.direction {
618                SlideDirection::Left => ([-1.0, 0.0], [0.0, 0.5]),
619                SlideDirection::Right => ([1.0, 0.0], [1.0, 0.5]),
620                SlideDirection::Up => ([0.0, 1.0], [0.5, 0.0]),
621                SlideDirection::Down => ([0.0, -1.0], [0.5, 1.0]),
622            };
623            EffectUniforms {
624                effect_type: 3,
625                progress,
626                param_a: 0.0,
627                param_b: 0.0,
628                param_c: 0.0,
629                param_d: 0.0,
630                origin,
631                direction: dir_vec,
632                easing: easing_index(&params.easing),
633            }
634        }
635        Effect::Zoom(params) => {
636            let orig = match params.origin {
637                Origin::Center | Origin::Cursor => [0.5, 0.5],
638                Origin::Custom(x, y) => [x, y],
639            };
640            EffectUniforms {
641                effect_type: 4,
642                progress,
643                param_a: params.from,
644                param_b: params.to,
645                param_c: 0.0,
646                param_d: 0.0,
647                origin: orig,
648                direction: [0.0, 0.0],
649                easing: easing_index(&params.easing),
650            }
651        }
652        Effect::Pixelate(params) => EffectUniforms {
653            effect_type: 5,
654            progress,
655            param_a: params.from,
656            param_b: params.to,
657            param_c: 0.0,
658            param_d: 0.0,
659            origin: [0.5, 0.5],
660            direction: [0.0, 0.0],
661            easing: easing_index(&params.easing),
662        },
663        Effect::Ripple(params) => {
664            let orig = match params.origin {
665                Origin::Center | Origin::Cursor => [0.5, 0.5],
666                Origin::Custom(x, y) => [x, y],
667            };
668            EffectUniforms {
669                effect_type: 6,
670                progress,
671                param_a: params.frequency,
672                param_b: params.amplitude,
673                param_c: params.speed,
674                param_d: 0.0,
675                origin: orig,
676                direction: [0.0, 0.0],
677                easing: easing_index(&params.easing),
678            }
679        }
680        Effect::Dissolve(params) => EffectUniforms {
681            effect_type: 7,
682            progress,
683            param_a: params.scale,
684            param_b: params.softness,
685            param_c: 0.0,
686            param_d: 0.0,
687            origin: [0.5, 0.5],
688            direction: [0.0, 0.0],
689            easing: easing_index(&params.easing),
690        },
691        Effect::Wave(params) => {
692            let (dir_vec, origin) = if let Some(angle_deg) = params.angle {
693                let rad = angle_deg.to_radians();
694                (
695                    [rad.cos(), rad.sin()],
696                    [0.5 + 0.5 * rad.cos(), 0.5 - 0.5 * rad.sin()],
697                )
698            } else {
699                ([0.0, 0.0], [0.5, 0.5])
700            };
701            EffectUniforms {
702                effect_type: 9,
703                progress,
704                param_a: params.frequency,
705                param_b: params.amplitude,
706                param_c: 0.0,
707                param_d: 0.0,
708                origin,
709                direction: dir_vec,
710                easing: easing_index(&params.easing),
711            }
712        }
713        Effect::Grow(params) => {
714            let orig = match params.origin {
715                Origin::Center | Origin::Cursor => [0.5, 0.5],
716                Origin::Custom(x, y) => [x, y],
717            };
718            EffectUniforms {
719                effect_type: 10,
720                progress,
721                param_a: 0.0,
722                param_b: 0.0,
723                param_c: 0.0,
724                param_d: 0.0,
725                origin: orig,
726                direction: [0.0, 0.0],
727                easing: easing_index(&params.easing),
728            }
729        }
730        Effect::Outer(params) => {
731            let orig = match params.origin {
732                Origin::Center | Origin::Cursor => [0.5, 0.5],
733                Origin::Custom(x, y) => [x, y],
734            };
735            EffectUniforms {
736                effect_type: 11,
737                progress,
738                param_a: 0.0,
739                param_b: 0.0,
740                param_c: 0.0,
741                param_d: 0.0,
742                origin: orig,
743                direction: [0.0, 0.0],
744                easing: easing_index(&params.easing),
745            }
746        }
747        Effect::Shader(params) => EffectUniforms {
748            effect_type: 8,
749            progress,
750            param_a: params.uniforms.get("strength").copied().unwrap_or(0.0) as f32,
751            param_b: 0.0,
752            param_c: 0.0,
753            param_d: 0.0,
754            origin: [0.5, 0.5],
755            direction: [0.0, 0.0],
756            easing: 3,
757        },
758    }
759}
760
761/// Parse an effect name into its default `Effect`.
762/// Accepts Wallr names plus the directional aliases familiar from awww:
763/// `simple`, `left`, `right`, `top`, `bottom`, `center`, `any`, and `random`.
764pub fn effect_from_name(name: &str) -> Option<Effect> {
765    let seed = || {
766        std::time::SystemTime::now()
767            .duration_since(std::time::UNIX_EPOCH)
768            .unwrap_or_default()
769            .subsec_nanos()
770    };
771    Some(match name {
772        "simple" => Effect::Fade(FadeParams::default()),
773        "fade" => Effect::Fade(FadeParams::default()),
774        "blur" => Effect::Blur(BlurParams::default()),
775        "wipe" => Effect::Wipe(WipeParams::default()),
776        "slide" => Effect::Slide(SlideParams::default()),
777        "left" => Effect::Slide(SlideParams {
778            direction: SlideDirection::Left,
779            ..SlideParams::default()
780        }),
781        "right" => Effect::Slide(SlideParams {
782            direction: SlideDirection::Right,
783            ..SlideParams::default()
784        }),
785        "top" => Effect::Slide(SlideParams {
786            direction: SlideDirection::Up,
787            ..SlideParams::default()
788        }),
789        "bottom" => Effect::Slide(SlideParams {
790            direction: SlideDirection::Down,
791            ..SlideParams::default()
792        }),
793        "zoom" => Effect::Zoom(ZoomParams::default()),
794        "pixelate" => Effect::Pixelate(PixelateParams::default()),
795        "ripple" => Effect::Ripple(RippleParams::default()),
796        "dissolve" => Effect::Dissolve(DissolveParams::default()),
797        "wave" => Effect::Wave(WaveParams::default()),
798        "grow" => Effect::Grow(GrowParams::default()),
799        "center" => Effect::Grow(GrowParams::default()),
800        "outer" => Effect::Outer(OuterParams::default()),
801        "any" => {
802            let value = seed();
803            let origin = Origin::Custom(
804                (value % 1000) as f32 / 1000.0,
805                ((value / 1000) % 1000) as f32 / 1000.0,
806            );
807            if value % 2 == 0 {
808                Effect::Grow(GrowParams {
809                    origin,
810                    ..GrowParams::default()
811                })
812            } else {
813                Effect::Outer(OuterParams {
814                    origin,
815                    ..OuterParams::default()
816                })
817            }
818        }
819        "random" => match seed() % 5 {
820            0 => Effect::Fade(FadeParams::default()),
821            1 => Effect::Slide(SlideParams::default()),
822            2 => Effect::Wave(WaveParams::default()),
823            3 => Effect::Grow(GrowParams::default()),
824            _ => Effect::Outer(OuterParams::default()),
825        },
826        _ => return None,
827    })
828}
829
830pub fn effect_names() -> &'static [&'static str] {
831    &[
832        "simple", "fade", "blur", "wipe", "slide", "left", "right", "top", "bottom", "zoom",
833        "pixelate", "ripple", "dissolve", "wave", "grow", "center", "outer", "any", "random",
834    ]
835}
836
837#[derive(Debug, Clone, Default)]
838pub struct EffectOverrides {
839    pub origin: Option<(f32, f32)>,
840    pub origin_preset: Option<String>,
841    pub direction: Option<[f32; 2]>,
842    pub angle: Option<f32>,
843    pub easing: Option<Easing>,
844    pub from: Option<f32>,
845    pub to: Option<f32>,
846    pub frequency: Option<f32>,
847    pub amplitude: Option<f32>,
848    pub speed: Option<f32>,
849    pub softness: Option<f32>,
850    pub scale: Option<f32>,
851}
852
853fn origin_from_preset(preset: &str) -> (f32, f32) {
854    match preset {
855        "top_left" => (0.0, 0.0),
856        "top" => (0.5, 0.0),
857        "top_right" => (1.0, 0.0),
858        "left" => (0.0, 0.5),
859        "center" => (0.5, 0.5),
860        "right" => (1.0, 0.5),
861        "bottom_left" => (0.0, 1.0),
862        "bottom" => (0.5, 1.0),
863        "bottom_right" => (1.0, 1.0),
864        _ => (0.5, 0.5),
865    }
866}
867
868/// Apply CLI-style overrides to an effect, mutating it in place.
869pub fn apply_effect_overrides(effect: &mut Effect, o: &EffectOverrides) {
870    let origin = o
871        .origin
872        .or_else(|| o.origin_preset.as_ref().map(|p| origin_from_preset(p)));
873
874    match effect {
875        Effect::Fade(p) => {
876            if let Some(v) = o.from {
877                p.from = v;
878            }
879            if let Some(v) = o.to {
880                p.to = v;
881            }
882            if let Some(e) = o.easing {
883                p.easing = e;
884            }
885        }
886        Effect::Blur(p) => {
887            if let Some(v) = o.from {
888                p.from = v;
889            }
890            if let Some(v) = o.to {
891                p.to = v;
892            }
893            if let Some(e) = o.easing {
894                p.easing = e;
895            }
896        }
897        Effect::Wipe(p) => {
898            if let Some(s) = o.softness {
899                p.softness = s;
900            }
901            if let Some(a) = o.angle {
902                p.angle = Some(a);
903            }
904            if let Some(e) = o.easing {
905                p.easing = e;
906            }
907            if let Some(d) = o.direction {
908                p.direction = match d {
909                    [-1.0, 0.0] => WipeDirection::Left,
910                    [1.0, 0.0] => WipeDirection::Right,
911                    [0.0, 1.0] => WipeDirection::Up,
912                    [0.0, -1.0] => WipeDirection::Down,
913                    _ => p.direction,
914                };
915            }
916        }
917        Effect::Slide(p) => {
918            if let Some(e) = o.easing {
919                p.easing = e;
920            }
921            if let Some(d) = o.direction {
922                p.direction = match d {
923                    [-1.0, 0.0] => SlideDirection::Left,
924                    [1.0, 0.0] => SlideDirection::Right,
925                    [0.0, 1.0] => SlideDirection::Up,
926                    [0.0, -1.0] => SlideDirection::Down,
927                    _ => p.direction,
928                };
929            }
930        }
931        Effect::Zoom(p) => {
932            if let Some(v) = o.from {
933                p.from = v;
934            }
935            if let Some(v) = o.to {
936                p.to = v;
937            }
938            if let Some((x, y)) = origin {
939                p.origin = Origin::Custom(x, y);
940            }
941            if let Some(e) = o.easing {
942                p.easing = e;
943            }
944        }
945        Effect::Pixelate(p) => {
946            if let Some(v) = o.from {
947                p.from = v;
948            }
949            if let Some(v) = o.to {
950                p.to = v;
951            }
952            if let Some(e) = o.easing {
953                p.easing = e;
954            }
955        }
956        Effect::Ripple(p) => {
957            if let Some(v) = o.frequency {
958                p.frequency = v;
959            }
960            if let Some(v) = o.amplitude {
961                p.amplitude = v;
962            }
963            if let Some(v) = o.speed {
964                p.speed = v;
965            }
966            if let Some((x, y)) = origin {
967                p.origin = Origin::Custom(x, y);
968            }
969            if let Some(e) = o.easing {
970                p.easing = e;
971            }
972        }
973        Effect::Dissolve(p) => {
974            if let Some(v) = o.scale {
975                p.scale = v;
976            }
977            if let Some(v) = o.softness {
978                p.softness = v;
979            }
980            if let Some(e) = o.easing {
981                p.easing = e;
982            }
983        }
984        Effect::Wave(p) => {
985            if let Some(v) = o.frequency {
986                p.frequency = v;
987            }
988            if let Some(v) = o.amplitude {
989                p.amplitude = v;
990            }
991            if let Some(a) = o.angle {
992                p.angle = Some(a);
993            }
994            if let Some(e) = o.easing {
995                p.easing = e;
996            }
997        }
998        Effect::Grow(p) => {
999            if let Some((x, y)) = origin {
1000                p.origin = Origin::Custom(x, y);
1001            }
1002            if let Some(e) = o.easing {
1003                p.easing = e;
1004            }
1005        }
1006        Effect::Outer(p) => {
1007            if let Some((x, y)) = origin {
1008                p.origin = Origin::Custom(x, y);
1009            }
1010            if let Some(e) = o.easing {
1011                p.easing = e;
1012            }
1013        }
1014        Effect::Shader(_) => {}
1015    }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021
1022    #[test]
1023    fn package_duration_is_required() {
1024        let spec = AnimationSpec {
1025            name: "missing-duration".into(),
1026            effects: vec![Effect::Fade(FadeParams::default())],
1027            ..Default::default()
1028        };
1029        assert!(validate_animation(&spec).is_err());
1030    }
1031
1032    #[test]
1033    fn numeric_variables_are_expanded_before_deserialization() {
1034        let spec = parse_animation_yaml("name: vars\nduration: 1s\nvariables: {amount: 12}\neffects:\n  - blur: {from: \"${amount}\", to: 0}\n").expect("variable package should parse");
1035        match &spec.effects[0] {
1036            Effect::Blur(params) => assert_eq!(params.from, 12.0),
1037            _ => panic!("expected blur"),
1038        }
1039    }
1040
1041    #[test]
1042    fn ranged_effects_keep_both_endpoints_for_the_shader() {
1043        let fade = Effect::Fade(FadeParams {
1044            from: 0.2,
1045            to: 0.9,
1046            easing: Easing::EaseOut,
1047        });
1048        let start = compute_effect_uniforms(&fade, 0.0);
1049        let end = compute_effect_uniforms(&fade, 1.0);
1050        assert_eq!((start.param_a, start.param_b), (0.2, 0.9));
1051        assert_eq!((end.param_a, end.param_b), (0.2, 0.9));
1052    }
1053
1054    #[test]
1055    fn awww_direction_aliases_resolve_to_typed_effects() {
1056        assert!(matches!(effect_from_name("simple"), Some(Effect::Fade(_))));
1057        assert!(matches!(effect_from_name("left"), Some(Effect::Slide(_))));
1058        assert!(matches!(effect_from_name("right"), Some(Effect::Slide(_))));
1059        assert!(matches!(effect_from_name("center"), Some(Effect::Grow(_))));
1060        assert!(matches!(
1061            effect_from_name("any"),
1062            Some(Effect::Grow(_)) | Some(Effect::Outer(_))
1063        ));
1064        assert!(effect_from_name("random").is_some());
1065    }
1066}