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