Skip to main content

rustmotion_core/css/
animation.rs

1//! Bridge from the legacy `AnimatedProperties` (Flutter-style) to CSS-style
2//! overrides on `CssStyle`. Used by the new pipeline so animations resolved
3//! through `animator::resolve_props_for_effects` flow into `transform`,
4//! `opacity`, `filter`, etc. before layout/paint.
5//!
6//! This is a transitional module: once all animation surfaces are CSS-native,
7//! the animator can produce `CssStyle` overrides directly and this bridge
8//! disappears.
9
10use crate::css::style::CssStyle;
11use crate::css::style::{FilterFn, Size, TransformFn};
12use crate::css::units::{Length, LengthPercentage};
13use crate::engine::animator::AnimatedProperties;
14
15/// Apply the resolved animation properties as a partial CSS override on top
16/// of an existing `CssStyle`. Only properties that the animator actually
17/// touched (vs. their `Default` sentinels) are written.
18///
19/// Order matters: the resulting `transform` list mirrors the legacy paint
20/// order (translate → scale → rotate → 3D rotate). Matching `transform-origin`
21/// is the box centre, which the paint pass already defaults to.
22pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) {
23    // ---- transform list ----
24    let mut tx: Vec<TransformFn> = Vec::new();
25    if props.translate_x != 0.0 || props.translate_y != 0.0 {
26        tx.push(TransformFn::Translate {
27            x: LengthPercentage::Px(props.translate_x),
28            y: LengthPercentage::Px(props.translate_y),
29        });
30    }
31    let sx = props.scale_x;
32    let sy = props.scale_y;
33    if (sx - 1.0).abs() > 1e-4 || (sy - 1.0).abs() > 1e-4 {
34        tx.push(TransformFn::Scale { x: sx, y: sy });
35    }
36    if props.rotation.abs() > 1e-3 {
37        tx.push(TransformFn::Rotate {
38            deg: props.rotation,
39        });
40    }
41    if props.rotate_x.abs() > 1e-3 {
42        tx.push(TransformFn::RotateX {
43            deg: props.rotate_x,
44        });
45    }
46    if props.rotate_y.abs() > 1e-3 {
47        tx.push(TransformFn::RotateY {
48            deg: props.rotate_y,
49        });
50    }
51    if !tx.is_empty() {
52        // Append rather than replace so a CSS-defined transform composes with
53        // the animation-derived one (CSS transforms are post-multiplied).
54        match css.transform.as_mut() {
55            Some(existing) => existing.extend(tx),
56            None => css.transform = Some(tx),
57        }
58    }
59
60    // ---- opacity ----
61    // Only write if it differs from the default (1.0). The animator sets
62    // `opacity = 1.0` as default, so any other value is meaningful.
63    if (props.opacity - 1.0).abs() > 1e-4 {
64        let base = css.opacity.unwrap_or(1.0);
65        css.opacity = Some(base * props.opacity);
66    }
67
68    // ---- filter (blur + glow) ----
69    let mut filters: Vec<FilterFn> = Vec::new();
70    if props.blur > 0.0 {
71        filters.push(FilterFn::Blur {
72            radius: Length::Px(props.blur),
73        });
74    }
75    if props.glow_radius > 0.0 && props.glow_intensity > 0.0 {
76        filters.push(FilterFn::DropShadow {
77            offset_x: Length::Px(0.0),
78            offset_y: Length::Px(0.0),
79            blur: Some(Length::Px(props.glow_radius)),
80            color: None,
81        });
82    }
83    if !filters.is_empty() {
84        match css.filter.as_mut() {
85            Some(existing) => existing.extend(filters),
86            None => css.filter = Some(filters),
87        }
88    }
89
90    // ---- perspective ----
91    if props.perspective > 0.0 {
92        css.perspective = Some(Length::Px(props.perspective));
93    }
94
95    // ---- box size ----
96    // An animated `width`/`height` has to reach taffy, not just the painter:
97    // resizing a card is a *layout* change (its children reflow inside the new
98    // box), which is what separates it from a `scale` transform stretching the
99    // pixels it already had. The animator's sentinel for "never animated" is
100    // -1.0 (see `AnimatedProperties::default`), so a 0 is a real, authored 0.
101    // The layout pass runs per frame, so writing here is enough.
102    if props.width >= 0.0 {
103        css.width = Some(Size::Length(LengthPercentage::Px(props.width)));
104    }
105    if props.height >= 0.0 {
106        css.height = Some(Size::Length(LengthPercentage::Px(props.height)));
107    }
108
109    // Note: `border_radius`, `font_size`, `gap`, `padding`, `stroke_width`,
110    // `shadow_blur`, `draw_progress`, `motion_progress`, `visible_chars*`,
111    // `char_animation`, `color` are NOT translated to CSS here. Those are
112    // component-internal animations and remain accessible through the legacy
113    // props on the dispatcher path. They will move into CSS once each
114    // component's painter is migrated.
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn translate_props_become_transform_translate() {
123        let mut css = CssStyle::default();
124        let props = AnimatedProperties {
125            translate_x: 10.0,
126            translate_y: -5.0,
127            ..AnimatedProperties::default()
128        };
129        apply_animated_props(&mut css, &props);
130
131        let tx = css.transform.expect("transform list created");
132        assert_eq!(tx.len(), 1);
133        match &tx[0] {
134            TransformFn::Translate { x, y } => {
135                assert!(matches!(x, LengthPercentage::Px(v) if (*v - 10.0).abs() < 1e-6));
136                assert!(matches!(y, LengthPercentage::Px(v) if (*v + 5.0).abs() < 1e-6));
137            }
138            other => panic!("expected Translate, got {:?}", other),
139        }
140    }
141
142    #[test]
143    fn opacity_is_multiplied_with_existing_css_opacity() {
144        let mut css = CssStyle {
145            opacity: Some(0.5),
146            ..CssStyle::default()
147        };
148        let props = AnimatedProperties {
149            opacity: 0.5,
150            ..AnimatedProperties::default()
151        };
152        apply_animated_props(&mut css, &props);
153        assert!((css.opacity.unwrap() - 0.25).abs() < 1e-6);
154    }
155
156    #[test]
157    fn no_op_props_leave_css_untouched() {
158        let mut css = CssStyle::default();
159        let props = AnimatedProperties::default();
160        apply_animated_props(&mut css, &props);
161        assert!(css.transform.is_none());
162        assert!(css.opacity.is_none());
163        assert!(css.filter.is_none());
164        assert!(css.perspective.is_none());
165    }
166
167    /// `motion_path` (`engine::animator::apply_motion_paths`) writes its
168    /// resolved position into `translate_x`/`translate_y` and its
169    /// tangent-derived orientation into `rotation` — the exact same fields
170    /// `orbit`/presets already write. This is the unit-level half of the
171    /// proof that a `motion_path` excursion reaches `css.transform` (and
172    /// therefore `--strict-anim`'s viewport check, which folds
173    /// `css.transform`): shape an `AnimatedProperties` the way that effect
174    /// would, at a moment where it has both moved *and* turned, and confirm
175    /// both land in the transform list in the documented translate→rotate
176    /// order — no new bridge, no separate channel.
177    #[test]
178    fn motion_path_shaped_translate_and_rotation_compose_into_one_transform_list() {
179        let mut css = CssStyle::default();
180        let props = AnimatedProperties {
181            translate_x: 120.0,
182            translate_y: -40.0,
183            rotation: 33.5,
184            ..AnimatedProperties::default()
185        };
186        apply_animated_props(&mut css, &props);
187
188        let tx = css.transform.expect("transform list created");
189        assert_eq!(tx.len(), 2, "expected translate + rotate, got {:?}", tx);
190        match &tx[0] {
191            TransformFn::Translate { x, y } => {
192                assert!(matches!(x, LengthPercentage::Px(v) if (*v - 120.0).abs() < 1e-6));
193                assert!(matches!(y, LengthPercentage::Px(v) if (*v + 40.0).abs() < 1e-6));
194            }
195            other => panic!("expected Translate first, got {:?}", other),
196        }
197        match &tx[1] {
198            TransformFn::Rotate { deg } => assert!((*deg - 33.5).abs() < 1e-6),
199            other => panic!("expected Rotate second, got {:?}", other),
200        }
201    }
202
203    #[test]
204    fn blur_and_glow_compose_into_filter_list() {
205        let mut css = CssStyle::default();
206        let props = AnimatedProperties {
207            blur: 4.0,
208            glow_radius: 8.0,
209            glow_intensity: 1.0,
210            ..AnimatedProperties::default()
211        };
212        apply_animated_props(&mut css, &props);
213
214        let filters = css.filter.expect("filter list created");
215        assert_eq!(filters.len(), 2);
216        assert!(matches!(filters[0], FilterFn::Blur { .. }));
217        assert!(matches!(filters[1], FilterFn::DropShadow { .. }));
218    }
219}