rustmotion-core 0.8.1

Core types, traits, and rendering utilities for rustmotion
Documentation
use crate::css::style::CssStyle;
use crate::css::style::{BorderRadius, ClipPath, Edges, FilterFn, Gap, Size, TransformFn};
use crate::css::units::{Length, LengthPercentage};
use crate::engine::animator::AnimatedProperties;

pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) {
    let mut tx: Vec<TransformFn> = Vec::new();
    if props.translate_x != 0.0 || props.translate_y != 0.0 {
        tx.push(TransformFn::Translate {
            x: LengthPercentage::Px(props.translate_x),
            y: LengthPercentage::Px(props.translate_y),
        });
    }
    let sx = props.scale_x;
    let sy = props.scale_y;
    if (sx - 1.0).abs() > 1e-4 || (sy - 1.0).abs() > 1e-4 {
        tx.push(TransformFn::Scale { x: sx, y: sy });
    }
    if props.rotation.abs() > 1e-3 {
        tx.push(TransformFn::Rotate {
            deg: props.rotation,
        });
    }
    if props.rotate_x.abs() > 1e-3 {
        tx.push(TransformFn::RotateX {
            deg: props.rotate_x,
        });
    }
    if props.rotate_y.abs() > 1e-3 {
        tx.push(TransformFn::RotateY {
            deg: props.rotate_y,
        });
    }
    if !tx.is_empty() {
        match css.transform.as_mut() {
            Some(existing) => existing.extend(tx),
            None => css.transform = Some(tx),
        }
    }

    if (props.opacity - 1.0).abs() > 1e-4 {
        css.opacity = Some(props.opacity);
    }

    let mut filters: Vec<FilterFn> = Vec::new();
    if props.blur > 0.0 {
        filters.push(FilterFn::Blur {
            radius: Some(Length::Px(props.blur)),
            radius_x: None,
            radius_y: None,
        });
    }
    if props.glow_radius > 0.0 && props.glow_intensity > 0.0 {
        filters.push(FilterFn::DropShadow {
            offset_x: Length::Px(0.0),
            offset_y: Length::Px(0.0),
            blur: Some(Length::Px(props.glow_radius)),
            color: None,
        });
    }
    if !filters.is_empty() {
        match css.filter.as_mut() {
            Some(existing) => existing.extend(filters),
            None => css.filter = Some(filters),
        }
    }

    if props.perspective > 0.0 {
        css.perspective = Some(Length::Px(props.perspective));
    }

    if props.width >= 0.0 {
        css.width = Some(Size::Length(LengthPercentage::Px(props.width)));
    }
    if props.height >= 0.0 {
        css.height = Some(Size::Length(LengthPercentage::Px(props.height)));
    }

    if props.border_radius >= 0.0 {
        css.border_radius = Some(BorderRadius::Uniform(LengthPercentage::Px(
            props.border_radius,
        )));
    }

    if props.clip_path_progress >= 0.0 {
        if let Some(ClipPath::Morph { progress, .. }) = css.clip_path.as_mut() {
            *progress = props.clip_path_progress;
        }
    }

    if props.font_size >= 0.0 {
        css.font_size = Some(Length::Px(props.font_size));
    }

    if props.letter_spacing.is_finite() {
        css.letter_spacing = Some(Length::Px(props.letter_spacing));
    }

    if props.gap >= 0.0 {
        css.gap = Some(Gap::Uniform(LengthPercentage::Px(props.gap)));
    }

    if props.padding >= 0.0 {
        css.padding = Some(Edges::Uniform(LengthPercentage::Px(props.padding)));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn translate_props_become_transform_translate() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties {
            translate_x: 10.0,
            translate_y: -5.0,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);

        let tx = css.transform.expect("transform list created");
        assert_eq!(tx.len(), 1);
        match &tx[0] {
            TransformFn::Translate { x, y } => {
                assert!(matches!(x, LengthPercentage::Px(v) if (*v - 10.0).abs() < 1e-6));
                assert!(matches!(y, LengthPercentage::Px(v) if (*v + 5.0).abs() < 1e-6));
            }
            other => panic!("expected Translate, got {:?}", other),
        }
    }

    #[test]
    fn an_active_opacity_animation_replaces_the_declared_static_opacity() {
        let mut css = CssStyle {
            opacity: Some(0.5),
            ..CssStyle::default()
        };
        let props = AnimatedProperties {
            opacity: 0.5,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);
        assert!(
            (css.opacity.unwrap() - 0.5).abs() < 1e-6,
            "an animation touching opacity must overwrite the static declaration outright, the \
             same last-value-written rule every other animated property in this function \
             follows — not multiply against it, got {:?}",
            css.opacity
        );
    }

    #[test]
    fn a_static_opacity_of_zero_is_overridden_by_an_active_fade_in_animation() {
        let mut css = CssStyle {
            opacity: Some(0.0),
            ..CssStyle::default()
        };
        let props = AnimatedProperties {
            opacity: 0.8,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);
        assert!(
            (css.opacity.unwrap() - 0.8).abs() < 1e-6,
            "a declared opacity: 0 authored as the fade-in's own starting point must not cancel \
             the animation for the rest of its run (issue #430); got {:?}",
            css.opacity
        );
    }

    #[test]
    fn no_op_props_leave_css_untouched() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties::default();
        apply_animated_props(&mut css, &props);
        assert!(css.transform.is_none());
        assert!(css.opacity.is_none());
        assert!(css.filter.is_none());
        assert!(css.perspective.is_none());
    }

    #[test]
    fn motion_path_shaped_translate_and_rotation_compose_into_one_transform_list() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties {
            translate_x: 120.0,
            translate_y: -40.0,
            rotation: 33.5,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);

        let tx = css.transform.expect("transform list created");
        assert_eq!(tx.len(), 2, "expected translate + rotate, got {:?}", tx);
        match &tx[0] {
            TransformFn::Translate { x, y } => {
                assert!(matches!(x, LengthPercentage::Px(v) if (*v - 120.0).abs() < 1e-6));
                assert!(matches!(y, LengthPercentage::Px(v) if (*v + 40.0).abs() < 1e-6));
            }
            other => panic!("expected Translate first, got {:?}", other),
        }
        match &tx[1] {
            TransformFn::Rotate { deg } => assert!((*deg - 33.5).abs() < 1e-6),
            other => panic!("expected Rotate second, got {:?}", other),
        }
    }

    #[test]
    fn animated_border_radius_reaches_the_css_style() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties {
            border_radius: 100.0,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);

        match css.border_radius {
            Some(BorderRadius::Uniform(LengthPercentage::Px(v))) => {
                assert!((v - 100.0).abs() < 1e-6)
            }
            other => panic!("expected a uniform 100px border-radius, got {other:?}"),
        }
    }

    #[test]
    fn animated_clip_path_progress_writes_onto_the_morph_variant() {
        let mut css = CssStyle {
            clip_path: Some(ClipPath::Morph {
                from: Box::new(ClipPath::Circle {
                    radius: LengthPercentage::Px(10.0),
                    origin: None,
                }),
                to: Box::new(ClipPath::Circle {
                    radius: LengthPercentage::Px(50.0),
                    origin: None,
                }),
                via: Vec::new(),
                progress: 0.0,
            }),
            ..CssStyle::default()
        };
        let props = AnimatedProperties {
            clip_path_progress: 0.75,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);

        match css.clip_path {
            Some(ClipPath::Morph { progress, .. }) => {
                assert!((progress - 0.75).abs() < 1e-6)
            }
            other => panic!("expected the morph's progress to be updated, got {other:?}"),
        }
    }

    #[test]
    fn no_clip_path_progress_leaves_a_non_morph_clip_path_untouched() {
        let mut css = CssStyle {
            clip_path: Some(ClipPath::Circle {
                radius: LengthPercentage::Px(10.0),
                origin: None,
            }),
            ..CssStyle::default()
        };
        let props = AnimatedProperties {
            clip_path_progress: 0.5,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);

        assert_eq!(
            css.clip_path,
            Some(ClipPath::Circle {
                radius: LengthPercentage::Px(10.0),
                origin: None,
            }),
            "a static (non-morph) clip-path must not be mutated by clip_path_progress"
        );
    }

    #[test]
    fn blur_and_glow_compose_into_filter_list() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties {
            blur: 4.0,
            glow_radius: 8.0,
            glow_intensity: 1.0,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);

        let filters = css.filter.expect("filter list created");
        assert_eq!(filters.len(), 2);
        assert!(matches!(filters[0], FilterFn::Blur { .. }));
        assert!(matches!(filters[1], FilterFn::DropShadow { .. }));
    }

    #[test]
    fn an_animation_touching_only_font_size_reaches_the_css_style() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties {
            font_size: 120.0,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);
        assert_eq!(css.font_size, Some(Length::Px(120.0)));
    }

    #[test]
    fn an_animation_touching_only_letter_spacing_reaches_the_css_style() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties {
            letter_spacing: 6.0,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);
        assert_eq!(css.letter_spacing, Some(Length::Px(6.0)));
    }

    #[test]
    fn an_untouched_letter_spacing_nan_resting_value_leaves_css_untouched() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties::default();
        apply_animated_props(&mut css, &props);
        assert!(
            css.letter_spacing.is_none(),
            "letter_spacing's resting value is NaN precisely so 'not animated' can be told apart \
             from 'animated to zero'; it must not be written as a css value"
        );
    }

    #[test]
    fn an_animation_touching_only_gap_reaches_the_css_style() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties {
            gap: 24.0,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);
        assert_eq!(css.gap, Some(Gap::Uniform(LengthPercentage::Px(24.0))));
    }

    #[test]
    fn an_animation_touching_only_padding_reaches_the_css_style() {
        let mut css = CssStyle::default();
        let props = AnimatedProperties {
            padding: 32.0,
            ..AnimatedProperties::default()
        };
        apply_animated_props(&mut css, &props);
        assert_eq!(
            css.padding,
            Some(Edges::Uniform(LengthPercentage::Px(32.0)))
        );
    }
}