Skip to main content

rustmotion_core/css/
animation.rs

1use crate::css::style::CssStyle;
2use crate::css::style::{BorderRadius, ClipPath, Edges, FilterFn, Gap, Size, TransformFn};
3use crate::css::units::{Length, LengthPercentage};
4use crate::engine::animator::AnimatedProperties;
5
6pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) {
7    let mut tx: Vec<TransformFn> = Vec::new();
8    if props.translate_x != 0.0 || props.translate_y != 0.0 {
9        tx.push(TransformFn::Translate {
10            x: LengthPercentage::Px(props.translate_x),
11            y: LengthPercentage::Px(props.translate_y),
12        });
13    }
14    let sx = props.scale_x;
15    let sy = props.scale_y;
16    if (sx - 1.0).abs() > 1e-4 || (sy - 1.0).abs() > 1e-4 {
17        tx.push(TransformFn::Scale { x: sx, y: sy });
18    }
19    if props.rotation.abs() > 1e-3 {
20        tx.push(TransformFn::Rotate {
21            deg: props.rotation,
22        });
23    }
24    if props.rotate_x.abs() > 1e-3 {
25        tx.push(TransformFn::RotateX {
26            deg: props.rotate_x,
27        });
28    }
29    if props.rotate_y.abs() > 1e-3 {
30        tx.push(TransformFn::RotateY {
31            deg: props.rotate_y,
32        });
33    }
34    if !tx.is_empty() {
35        match css.transform.as_mut() {
36            Some(existing) => existing.extend(tx),
37            None => css.transform = Some(tx),
38        }
39    }
40
41    if (props.opacity - 1.0).abs() > 1e-4 {
42        css.opacity = Some(props.opacity);
43    }
44
45    let mut filters: Vec<FilterFn> = Vec::new();
46    if props.blur > 0.0 {
47        filters.push(FilterFn::Blur {
48            radius: Some(Length::Px(props.blur)),
49            radius_x: None,
50            radius_y: None,
51        });
52    }
53    if props.glow_radius > 0.0 && props.glow_intensity > 0.0 {
54        filters.push(FilterFn::DropShadow {
55            offset_x: Length::Px(0.0),
56            offset_y: Length::Px(0.0),
57            blur: Some(Length::Px(props.glow_radius)),
58            color: None,
59        });
60    }
61    if !filters.is_empty() {
62        match css.filter.as_mut() {
63            Some(existing) => existing.extend(filters),
64            None => css.filter = Some(filters),
65        }
66    }
67
68    if props.perspective > 0.0 {
69        css.perspective = Some(Length::Px(props.perspective));
70    }
71
72    if props.width >= 0.0 {
73        css.width = Some(Size::Length(LengthPercentage::Px(props.width)));
74    }
75    if props.height >= 0.0 {
76        css.height = Some(Size::Length(LengthPercentage::Px(props.height)));
77    }
78
79    if props.border_radius >= 0.0 {
80        css.border_radius = Some(BorderRadius::Uniform(LengthPercentage::Px(
81            props.border_radius,
82        )));
83    }
84
85    if props.clip_path_progress >= 0.0 {
86        if let Some(ClipPath::Morph { progress, .. }) = css.clip_path.as_mut() {
87            *progress = props.clip_path_progress;
88        }
89    }
90
91    if props.font_size >= 0.0 {
92        css.font_size = Some(Length::Px(props.font_size));
93    }
94
95    if props.letter_spacing.is_finite() {
96        css.letter_spacing = Some(Length::Px(props.letter_spacing));
97    }
98
99    if props.gap >= 0.0 {
100        css.gap = Some(Gap::Uniform(LengthPercentage::Px(props.gap)));
101    }
102
103    if props.padding >= 0.0 {
104        css.padding = Some(Edges::Uniform(LengthPercentage::Px(props.padding)));
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn translate_props_become_transform_translate() {
114        let mut css = CssStyle::default();
115        let props = AnimatedProperties {
116            translate_x: 10.0,
117            translate_y: -5.0,
118            ..AnimatedProperties::default()
119        };
120        apply_animated_props(&mut css, &props);
121
122        let tx = css.transform.expect("transform list created");
123        assert_eq!(tx.len(), 1);
124        match &tx[0] {
125            TransformFn::Translate { x, y } => {
126                assert!(matches!(x, LengthPercentage::Px(v) if (*v - 10.0).abs() < 1e-6));
127                assert!(matches!(y, LengthPercentage::Px(v) if (*v + 5.0).abs() < 1e-6));
128            }
129            other => panic!("expected Translate, got {:?}", other),
130        }
131    }
132
133    #[test]
134    fn an_active_opacity_animation_replaces_the_declared_static_opacity() {
135        let mut css = CssStyle {
136            opacity: Some(0.5),
137            ..CssStyle::default()
138        };
139        let props = AnimatedProperties {
140            opacity: 0.5,
141            ..AnimatedProperties::default()
142        };
143        apply_animated_props(&mut css, &props);
144        assert!(
145            (css.opacity.unwrap() - 0.5).abs() < 1e-6,
146            "an animation touching opacity must overwrite the static declaration outright, the \
147             same last-value-written rule every other animated property in this function \
148             follows — not multiply against it, got {:?}",
149            css.opacity
150        );
151    }
152
153    #[test]
154    fn a_static_opacity_of_zero_is_overridden_by_an_active_fade_in_animation() {
155        let mut css = CssStyle {
156            opacity: Some(0.0),
157            ..CssStyle::default()
158        };
159        let props = AnimatedProperties {
160            opacity: 0.8,
161            ..AnimatedProperties::default()
162        };
163        apply_animated_props(&mut css, &props);
164        assert!(
165            (css.opacity.unwrap() - 0.8).abs() < 1e-6,
166            "a declared opacity: 0 authored as the fade-in's own starting point must not cancel \
167             the animation for the rest of its run (issue #430); got {:?}",
168            css.opacity
169        );
170    }
171
172    #[test]
173    fn no_op_props_leave_css_untouched() {
174        let mut css = CssStyle::default();
175        let props = AnimatedProperties::default();
176        apply_animated_props(&mut css, &props);
177        assert!(css.transform.is_none());
178        assert!(css.opacity.is_none());
179        assert!(css.filter.is_none());
180        assert!(css.perspective.is_none());
181    }
182
183    #[test]
184    fn motion_path_shaped_translate_and_rotation_compose_into_one_transform_list() {
185        let mut css = CssStyle::default();
186        let props = AnimatedProperties {
187            translate_x: 120.0,
188            translate_y: -40.0,
189            rotation: 33.5,
190            ..AnimatedProperties::default()
191        };
192        apply_animated_props(&mut css, &props);
193
194        let tx = css.transform.expect("transform list created");
195        assert_eq!(tx.len(), 2, "expected translate + rotate, got {:?}", tx);
196        match &tx[0] {
197            TransformFn::Translate { x, y } => {
198                assert!(matches!(x, LengthPercentage::Px(v) if (*v - 120.0).abs() < 1e-6));
199                assert!(matches!(y, LengthPercentage::Px(v) if (*v + 40.0).abs() < 1e-6));
200            }
201            other => panic!("expected Translate first, got {:?}", other),
202        }
203        match &tx[1] {
204            TransformFn::Rotate { deg } => assert!((*deg - 33.5).abs() < 1e-6),
205            other => panic!("expected Rotate second, got {:?}", other),
206        }
207    }
208
209    #[test]
210    fn animated_border_radius_reaches_the_css_style() {
211        let mut css = CssStyle::default();
212        let props = AnimatedProperties {
213            border_radius: 100.0,
214            ..AnimatedProperties::default()
215        };
216        apply_animated_props(&mut css, &props);
217
218        match css.border_radius {
219            Some(BorderRadius::Uniform(LengthPercentage::Px(v))) => {
220                assert!((v - 100.0).abs() < 1e-6)
221            }
222            other => panic!("expected a uniform 100px border-radius, got {other:?}"),
223        }
224    }
225
226    #[test]
227    fn animated_clip_path_progress_writes_onto_the_morph_variant() {
228        let mut css = CssStyle {
229            clip_path: Some(ClipPath::Morph {
230                from: Box::new(ClipPath::Circle {
231                    radius: LengthPercentage::Px(10.0),
232                    origin: None,
233                }),
234                to: Box::new(ClipPath::Circle {
235                    radius: LengthPercentage::Px(50.0),
236                    origin: None,
237                }),
238                via: Vec::new(),
239                progress: 0.0,
240            }),
241            ..CssStyle::default()
242        };
243        let props = AnimatedProperties {
244            clip_path_progress: 0.75,
245            ..AnimatedProperties::default()
246        };
247        apply_animated_props(&mut css, &props);
248
249        match css.clip_path {
250            Some(ClipPath::Morph { progress, .. }) => {
251                assert!((progress - 0.75).abs() < 1e-6)
252            }
253            other => panic!("expected the morph's progress to be updated, got {other:?}"),
254        }
255    }
256
257    #[test]
258    fn no_clip_path_progress_leaves_a_non_morph_clip_path_untouched() {
259        let mut css = CssStyle {
260            clip_path: Some(ClipPath::Circle {
261                radius: LengthPercentage::Px(10.0),
262                origin: None,
263            }),
264            ..CssStyle::default()
265        };
266        let props = AnimatedProperties {
267            clip_path_progress: 0.5,
268            ..AnimatedProperties::default()
269        };
270        apply_animated_props(&mut css, &props);
271
272        assert_eq!(
273            css.clip_path,
274            Some(ClipPath::Circle {
275                radius: LengthPercentage::Px(10.0),
276                origin: None,
277            }),
278            "a static (non-morph) clip-path must not be mutated by clip_path_progress"
279        );
280    }
281
282    #[test]
283    fn blur_and_glow_compose_into_filter_list() {
284        let mut css = CssStyle::default();
285        let props = AnimatedProperties {
286            blur: 4.0,
287            glow_radius: 8.0,
288            glow_intensity: 1.0,
289            ..AnimatedProperties::default()
290        };
291        apply_animated_props(&mut css, &props);
292
293        let filters = css.filter.expect("filter list created");
294        assert_eq!(filters.len(), 2);
295        assert!(matches!(filters[0], FilterFn::Blur { .. }));
296        assert!(matches!(filters[1], FilterFn::DropShadow { .. }));
297    }
298
299    #[test]
300    fn an_animation_touching_only_font_size_reaches_the_css_style() {
301        let mut css = CssStyle::default();
302        let props = AnimatedProperties {
303            font_size: 120.0,
304            ..AnimatedProperties::default()
305        };
306        apply_animated_props(&mut css, &props);
307        assert_eq!(css.font_size, Some(Length::Px(120.0)));
308    }
309
310    #[test]
311    fn an_animation_touching_only_letter_spacing_reaches_the_css_style() {
312        let mut css = CssStyle::default();
313        let props = AnimatedProperties {
314            letter_spacing: 6.0,
315            ..AnimatedProperties::default()
316        };
317        apply_animated_props(&mut css, &props);
318        assert_eq!(css.letter_spacing, Some(Length::Px(6.0)));
319    }
320
321    #[test]
322    fn an_untouched_letter_spacing_nan_resting_value_leaves_css_untouched() {
323        let mut css = CssStyle::default();
324        let props = AnimatedProperties::default();
325        apply_animated_props(&mut css, &props);
326        assert!(
327            css.letter_spacing.is_none(),
328            "letter_spacing's resting value is NaN precisely so 'not animated' can be told apart \
329             from 'animated to zero'; it must not be written as a css value"
330        );
331    }
332
333    #[test]
334    fn an_animation_touching_only_gap_reaches_the_css_style() {
335        let mut css = CssStyle::default();
336        let props = AnimatedProperties {
337            gap: 24.0,
338            ..AnimatedProperties::default()
339        };
340        apply_animated_props(&mut css, &props);
341        assert_eq!(css.gap, Some(Gap::Uniform(LengthPercentage::Px(24.0))));
342    }
343
344    #[test]
345    fn an_animation_touching_only_padding_reaches_the_css_style() {
346        let mut css = CssStyle::default();
347        let props = AnimatedProperties {
348            padding: 32.0,
349            ..AnimatedProperties::default()
350        };
351        apply_animated_props(&mut css, &props);
352        assert_eq!(
353            css.padding,
354            Some(Edges::Uniform(LengthPercentage::Px(32.0)))
355        );
356    }
357}