1use animato_core::Easing;
4use animato_spring::SpringConfig;
5use dioxus::prelude::{Signal, use_effect, use_signal};
6
7#[derive(Clone, Debug, Default, PartialEq)]
9pub struct AnimatedStyle {
10 pub opacity: Option<f32>,
12 pub transform: Option<String>,
14 pub scale: Option<f32>,
16 pub translate_x: Option<f32>,
18 pub translate_y: Option<f32>,
20 pub rotate: Option<f32>,
22 pub skew_x: Option<f32>,
24 pub skew_y: Option<f32>,
26 pub blur: Option<f32>,
28 pub background_color: Option<[f32; 4]>,
30 pub border_radius: Option<f32>,
32 pub width: Option<f32>,
34 pub height: Option<f32>,
36 pub clip_path: Option<String>,
38 pub custom: Vec<(String, String)>,
40}
41
42impl AnimatedStyle {
43 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn opacity(mut self, value: f32) -> Self {
50 self.opacity = Some(value.clamp(0.0, 1.0));
51 self
52 }
53
54 pub fn translate(mut self, x: f32, y: f32) -> Self {
56 self.translate_x = Some(crate::finite_or(x, 0.0));
57 self.translate_y = Some(crate::finite_or(y, 0.0));
58 self
59 }
60
61 pub fn scale(mut self, value: f32) -> Self {
63 self.scale = Some(crate::finite_or(value, 1.0).max(0.0));
64 self
65 }
66
67 pub fn rotate(mut self, degrees: f32) -> Self {
69 self.rotate = Some(crate::finite_or(degrees, 0.0));
70 self
71 }
72
73 pub fn blur(mut self, px: f32) -> Self {
75 self.blur = Some(crate::finite_or(px, 0.0).max(0.0));
76 self
77 }
78
79 pub fn width(mut self, px: f32) -> Self {
81 self.width = Some(crate::finite_or(px, 0.0).max(0.0));
82 self
83 }
84
85 pub fn height(mut self, px: f32) -> Self {
87 self.height = Some(crate::finite_or(px, 0.0).max(0.0));
88 self
89 }
90
91 pub fn background_color(mut self, rgba: [f32; 4]) -> Self {
93 self.background_color = Some(rgba.map(|v| v.clamp(0.0, 1.0)));
94 self
95 }
96
97 pub fn border_radius(mut self, px: f32) -> Self {
99 self.border_radius = Some(crate::finite_or(px, 0.0).max(0.0));
100 self
101 }
102
103 pub fn clip_path(mut self, value: impl Into<String>) -> Self {
105 self.clip_path = Some(value.into());
106 self
107 }
108
109 pub fn transform(mut self, value: impl Into<String>) -> Self {
111 self.transform = Some(value.into());
112 self
113 }
114
115 pub fn custom(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
117 self.custom.push((name.into(), value.into()));
118 self
119 }
120
121 pub fn interpolate(&self, other: &Self, t: f32) -> Self {
123 let t = t.clamp(0.0, 1.0);
124 Self {
125 opacity: lerp_option(self.opacity, other.opacity, t),
126 transform: choose_string(self.transform.as_ref(), other.transform.as_ref(), t),
127 scale: lerp_option(self.scale, other.scale, t),
128 translate_x: lerp_option(self.translate_x, other.translate_x, t),
129 translate_y: lerp_option(self.translate_y, other.translate_y, t),
130 rotate: lerp_option(self.rotate, other.rotate, t),
131 skew_x: lerp_option(self.skew_x, other.skew_x, t),
132 skew_y: lerp_option(self.skew_y, other.skew_y, t),
133 blur: lerp_option(self.blur, other.blur, t),
134 background_color: lerp_color(self.background_color, other.background_color, t),
135 border_radius: lerp_option(self.border_radius, other.border_radius, t),
136 width: lerp_option(self.width, other.width, t),
137 height: lerp_option(self.height, other.height, t),
138 clip_path: choose_string(self.clip_path.as_ref(), other.clip_path.as_ref(), t),
139 custom: if t >= 1.0 {
140 other.custom.clone()
141 } else {
142 self.custom.clone()
143 },
144 }
145 }
146
147 pub fn to_css(&self) -> String {
149 let mut style = String::new();
150
151 if let Some(opacity) = self.opacity {
152 push_prop(&mut style, "opacity", &format_num(opacity));
153 }
154
155 let transform = self.transform_string();
156 if !transform.is_empty() {
157 push_prop(&mut style, "transform", &transform);
158 }
159
160 if let Some(blur) = self.blur {
161 push_prop(&mut style, "filter", &format!("blur({})", format_px(blur)));
162 }
163 if let Some(color) = self.background_color {
164 push_prop(&mut style, "background-color", &rgba_to_css(color));
165 }
166 if let Some(radius) = self.border_radius {
167 push_prop(&mut style, "border-radius", &format_px(radius));
168 }
169 if let Some(width) = self.width {
170 push_prop(&mut style, "width", &format_px(width));
171 }
172 if let Some(height) = self.height {
173 push_prop(&mut style, "height", &format_px(height));
174 }
175 if let Some(clip_path) = &self.clip_path {
176 push_prop(&mut style, "clip-path", clip_path);
177 }
178 for (name, value) in &self.custom {
179 push_prop(&mut style, name, value);
180 }
181
182 style
183 }
184
185 pub fn transform_string(&self) -> String {
187 let mut parts = Vec::new();
188 if let Some(x) = self.translate_x {
189 let y = self.translate_y.unwrap_or(0.0);
190 parts.push(format!("translate({},{})", format_px(x), format_px(y)));
191 } else if let Some(y) = self.translate_y {
192 parts.push(format!("translateY({})", format_px(y)));
193 }
194 if let Some(scale) = self.scale {
195 parts.push(format!("scale({})", format_num(scale)));
196 }
197 if let Some(rotate) = self.rotate {
198 parts.push(format!("rotate({}deg)", format_num(rotate)));
199 }
200 if let Some(skew_x) = self.skew_x {
201 parts.push(format!("skewX({}deg)", format_num(skew_x)));
202 }
203 if let Some(skew_y) = self.skew_y {
204 parts.push(format!("skewY({}deg)", format_num(skew_y)));
205 }
206 if let Some(raw) = &self.transform {
207 parts.push(raw.clone());
208 }
209 parts.join(" ")
210 }
211}
212
213pub fn css_tween(
215 from: AnimatedStyle,
216 to: AnimatedStyle,
217 duration: f32,
218 easing: Easing,
219) -> Signal<String> {
220 let initial = from.to_css();
221 let style = use_signal(move || initial);
222 let (progress, _handle) = crate::hooks::use_tween(0.0_f32, 1.0, move |builder| {
223 builder.duration(duration.max(0.0)).easing(easing)
224 });
225
226 use_effect(move || {
227 let p = crate::read_signal(progress);
228 crate::set_signal(style, from.interpolate(&to, p).to_css());
229 });
230
231 style
232}
233
234pub fn css_spring(target: AnimatedStyle, config: SpringConfig) -> Signal<String> {
236 let style = use_signal(String::new);
237 let (progress, handle) = crate::hooks::use_spring(0.0_f32, config);
238 handle.set_target(1.0);
239
240 use_effect(move || {
241 let p = crate::read_signal(progress).clamp(0.0, 1.0);
242 crate::set_signal(
243 style,
244 AnimatedStyle::default().interpolate(&target, p).to_css(),
245 );
246 });
247
248 style
249}
250
251fn lerp_option(a: Option<f32>, b: Option<f32>, t: f32) -> Option<f32> {
252 match (a, b) {
253 (Some(a), Some(b)) => Some(a + (b - a) * t),
254 (Some(a), None) => Some(a),
255 (None, Some(b)) => Some(b * t),
256 (None, None) => None,
257 }
258}
259
260fn lerp_color(a: Option<[f32; 4]>, b: Option<[f32; 4]>, t: f32) -> Option<[f32; 4]> {
261 match (a, b) {
262 (Some(a), Some(b)) => Some([
263 a[0] + (b[0] - a[0]) * t,
264 a[1] + (b[1] - a[1]) * t,
265 a[2] + (b[2] - a[2]) * t,
266 a[3] + (b[3] - a[3]) * t,
267 ]),
268 (Some(a), None) => Some(a),
269 (None, Some(b)) => Some([b[0] * t, b[1] * t, b[2] * t, b[3] * t]),
270 (None, None) => None,
271 }
272}
273
274fn choose_string(a: Option<&String>, b: Option<&String>, t: f32) -> Option<String> {
275 match (a, b) {
276 (_, Some(b)) if t >= 1.0 => Some(b.clone()),
277 (Some(a), _) => Some(a.clone()),
278 (None, Some(b)) => Some(b.clone()),
279 (None, None) => None,
280 }
281}
282
283fn push_prop(style: &mut String, name: &str, value: &str) {
284 if !style.is_empty() {
285 style.push(' ');
286 }
287 style.push_str(name);
288 style.push(':');
289 style.push_str(value);
290 style.push(';');
291}
292
293fn format_px(value: f32) -> String {
294 format!("{}px", format_num(value))
295}
296
297fn format_num(value: f32) -> String {
298 let value = crate::finite_or(value, 0.0);
299 let rounded = (value * 1000.0).round() / 1000.0;
300 if (rounded - rounded.round()).abs() < 0.0001 {
301 format!("{}", rounded.round() as i32)
302 } else {
303 format!("{rounded:.3}")
304 .trim_end_matches('0')
305 .trim_end_matches('.')
306 .to_owned()
307 }
308}
309
310fn rgba_to_css(color: [f32; 4]) -> String {
311 let r = (color[0].clamp(0.0, 1.0) * 255.0).round() as u8;
312 let g = (color[1].clamp(0.0, 1.0) * 255.0).round() as u8;
313 let b = (color[2].clamp(0.0, 1.0) * 255.0).round() as u8;
314 let a = format_num(color[3].clamp(0.0, 1.0));
315 format!("rgba({r},{g},{b},{a})")
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use animato_core::Easing;
322 use animato_spring::SpringConfig;
323 use dioxus::prelude::*;
324 use std::cell::RefCell;
325
326 thread_local! {
327 static CSS_TWEEN_CAPTURE: RefCell<Option<Signal<String>>> = const { RefCell::new(None) };
328 static CSS_SPRING_CAPTURE: RefCell<Option<Signal<String>>> = const { RefCell::new(None) };
329 }
330
331 #[allow(non_snake_case)]
332 fn CssTweenApp() -> Element {
333 let style = css_tween(
334 AnimatedStyle::new().opacity(0.0),
335 AnimatedStyle::new()
336 .opacity(1.0)
337 .translate(10.0, 0.0)
338 .blur(2.0),
339 0.2,
340 Easing::Linear,
341 );
342 CSS_TWEEN_CAPTURE.with(|slot| *slot.borrow_mut() = Some(style));
343
344 rsx! { div {} }
345 }
346
347 #[allow(non_snake_case)]
348 fn CssSpringApp() -> Element {
349 let style = css_spring(
350 AnimatedStyle::new()
351 .opacity(1.0)
352 .scale(1.25)
353 .border_radius(8.0),
354 SpringConfig::snappy(),
355 );
356 CSS_SPRING_CAPTURE.with(|slot| *slot.borrow_mut() = Some(style));
357
358 rsx! { div {} }
359 }
360
361 #[test]
362 fn style_formats_transform_and_color() {
363 let css = AnimatedStyle::new()
364 .opacity(0.5)
365 .translate(10.0, 20.0)
366 .scale(1.25)
367 .background_color([1.0, 0.0, 0.5, 0.75])
368 .to_css();
369
370 assert!(css.contains("opacity:0.5;"));
371 assert!(css.contains("translate(10px,20px)"));
372 assert!(css.contains("rgba(255,0,128,0.75)"));
373 }
374
375 #[test]
376 fn interpolation_blends_numeric_properties() {
377 let from = AnimatedStyle::new().opacity(0.0).translate(0.0, 10.0);
378 let to = AnimatedStyle::new().opacity(1.0).translate(20.0, 30.0);
379 let mid = from.interpolate(&to, 0.5);
380
381 assert_eq!(mid.opacity, Some(0.5));
382 assert_eq!(mid.translate_x, Some(10.0));
383 assert_eq!(mid.translate_y, Some(20.0));
384 }
385
386 #[test]
387 fn style_formats_all_supported_properties_and_clamps_inputs() {
388 let mut style = AnimatedStyle::new()
389 .opacity(2.0)
390 .translate(f32::NAN, 12.3456)
391 .scale(-1.0)
392 .rotate(f32::INFINITY)
393 .blur(-4.0)
394 .width(-100.0)
395 .height(42.25)
396 .background_color([2.0, -1.0, 0.25, 1.5])
397 .border_radius(-3.0)
398 .clip_path("inset(0)")
399 .transform("translateZ(0)")
400 .custom("pointer-events", "none");
401 style.skew_x = Some(15.0);
402 style.skew_y = Some(-10.0);
403
404 let transform = style.transform_string();
405 assert!(transform.contains("translate(0px,12.346px)"));
406 assert!(transform.contains("scale(0)"));
407 assert!(transform.contains("rotate(0deg)"));
408 assert!(transform.contains("skewX(15deg)"));
409 assert!(transform.contains("skewY(-10deg)"));
410 assert!(transform.contains("translateZ(0)"));
411
412 let css = style.to_css();
413 assert!(css.contains("opacity:1;"));
414 assert!(css.contains("filter:blur(0px);"));
415 assert!(css.contains("background-color:rgba(255,0,64,1);"));
416 assert!(css.contains("border-radius:0px;"));
417 assert!(css.contains("width:0px;"));
418 assert!(css.contains("height:42.25px;"));
419 assert!(css.contains("clip-path:inset(0);"));
420 assert!(css.contains("pointer-events:none;"));
421 }
422
423 #[test]
424 fn interpolation_handles_missing_values_strings_colors_and_custom_props() {
425 let from = AnimatedStyle::new()
426 .opacity(0.8)
427 .transform("scale(2)")
428 .background_color([1.0, 0.0, 0.0, 1.0])
429 .clip_path("circle(20%)")
430 .custom("left", "0px");
431 let to = AnimatedStyle::new()
432 .scale(2.0)
433 .blur(10.0)
434 .background_color([0.0, 0.0, 1.0, 0.5])
435 .clip_path("circle(80%)")
436 .custom("left", "10px");
437
438 let mid = from.interpolate(&to, 0.5);
439 assert_eq!(mid.opacity, Some(0.8));
440 assert_eq!(mid.scale, Some(1.0));
441 assert_eq!(mid.blur, Some(5.0));
442 assert_eq!(mid.background_color, Some([0.5, 0.0, 0.5, 0.75]));
443 assert_eq!(mid.transform.as_deref(), Some("scale(2)"));
444 assert_eq!(mid.clip_path.as_deref(), Some("circle(20%)"));
445 assert_eq!(mid.custom, vec![("left".to_owned(), "0px".to_owned())]);
446
447 let end = from.interpolate(&to, 1.0);
448 assert_eq!(end.transform.as_deref(), Some("scale(2)"));
449 assert_eq!(end.clip_path.as_deref(), Some("circle(80%)"));
450 assert_eq!(end.custom, vec![("left".to_owned(), "10px".to_owned())]);
451
452 let from_only = from.interpolate(&AnimatedStyle::new(), 0.5);
453 assert_eq!(from_only.background_color, Some([1.0, 0.0, 0.0, 1.0]));
454 }
455
456 #[test]
457 fn css_hooks_return_stable_style_signals() {
458 CSS_TWEEN_CAPTURE.with(|slot| *slot.borrow_mut() = None);
459 let mut tween_dom = VirtualDom::new(CssTweenApp);
460 tween_dom.rebuild_in_place();
461 let tween_style = CSS_TWEEN_CAPTURE.with(|slot| {
462 slot.borrow()
463 .as_ref()
464 .copied()
465 .expect("css tween signal captured")
466 });
467 assert!(crate::read_signal(tween_style).contains("opacity:0;"));
468
469 CSS_SPRING_CAPTURE.with(|slot| *slot.borrow_mut() = None);
470 let mut spring_dom = VirtualDom::new(CssSpringApp);
471 spring_dom.rebuild_in_place();
472 let spring_style = CSS_SPRING_CAPTURE.with(|slot| {
473 slot.borrow()
474 .as_ref()
475 .copied()
476 .expect("css spring signal captured")
477 });
478 let spring_css = crate::read_signal(spring_style);
479 assert!(spring_css.is_empty() || spring_css.contains("opacity:"));
480 }
481}