Skip to main content

takumi_css/style/properties/
animation.rs

1use crate::style::{ToCss, properties::write_css_string, unexpected_token};
2use std::{borrow::Cow, fmt, vec::Vec};
3
4use cssparser::{BasicParseErrorKind, Parser, Token, match_ignore_ascii_case};
5use typed_builder::TypedBuilder;
6
7use crate::style::{
8  CssDescriptorKind, CssSyntaxKind, CssToken, FromCss, MakeComputed, ParseResult,
9  declare_enum_from_css_impl, next_is_comma, tw::TailwindPropertyParser,
10};
11
12/// Represents a CSS animation time value stored in milliseconds.
13#[derive(Debug, Clone, Copy, PartialEq, Default)]
14pub struct AnimationTime {
15  /// Milliseconds represented by this time value.
16  pub milliseconds: f32,
17}
18
19impl AnimationTime {
20  /// Creates a time value from milliseconds.
21  pub const fn from_milliseconds(milliseconds: f32) -> Self {
22    Self { milliseconds }
23  }
24}
25
26impl MakeComputed for AnimationTime {}
27
28impl<'i> FromCss<'i> for AnimationTime {
29  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
30    let location = input.current_source_location();
31    let token = input.next()?;
32
33    match token {
34      Token::Dimension { value, unit, .. } => match_ignore_ascii_case! {unit.as_ref(),
35        "ms" => Ok(Self::from_milliseconds(*value)),
36        "s" => Ok(Self::from_milliseconds(*value * 1000.0)),
37        _ => Err(unexpected_token!(location, token)),
38      },
39      Token::Number { value, .. } if *value == 0.0 => Ok(Self::from_milliseconds(0.0)),
40      _ => Err(unexpected_token!(location, token)),
41    }
42  }
43
44  const VALID_TOKENS: &'static [CssToken] = &[CssToken::Syntax(CssSyntaxKind::Time)];
45}
46
47/// Parsed value for one `animation-name`.
48pub type AnimationName = Option<String>;
49
50/// Parsed values for `animation-name`.
51pub type AnimationNames = Box<[AnimationName]>;
52
53impl MakeComputed for AnimationNames {}
54
55impl<'i> FromCss<'i> for AnimationNames {
56  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
57    input
58      .parse_comma_separated(AnimationName::from_css)
59      .map(Vec::into_boxed_slice)
60  }
61
62  const VALID_TOKENS: &'static [CssToken] = &[
63    CssToken::Keyword("none"),
64    CssToken::Syntax(CssSyntaxKind::CustomIdent),
65    CssToken::Syntax(CssSyntaxKind::String),
66  ];
67}
68
69/// Parsed values for `animation-duration` and `animation-delay`.
70pub type AnimationDurations = Box<[AnimationTime]>;
71
72impl<'i> FromCss<'i> for AnimationDurations {
73  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
74    input
75      .parse_comma_separated(AnimationTime::from_css)
76      .map(Vec::into_boxed_slice)
77  }
78
79  const VALID_TOKENS: &'static [CssToken] = AnimationTime::VALID_TOKENS;
80}
81
82/// Supported CSS timing functions for animations.
83#[derive(Debug, Clone, Copy, PartialEq, Default)]
84#[non_exhaustive]
85pub enum AnimationTimingFunction {
86  /// Uses linear interpolation.
87  Linear,
88  /// Uses the CSS `ease` curve.
89  #[default]
90  Ease,
91  /// Uses the CSS `ease-in` curve.
92  EaseIn,
93  /// Uses the CSS `ease-out` curve.
94  EaseOut,
95  /// Uses the CSS `ease-in-out` curve.
96  EaseInOut,
97  /// Uses the CSS `step-start` timing function.
98  StepStart,
99  /// Uses the CSS `step-end` timing function.
100  StepEnd,
101  /// Uses a stepped timing function with an explicit position.
102  Steps(u32, StepPosition),
103  /// Uses a custom cubic bezier timing curve.
104  CubicBezier(f32, f32, f32, f32),
105}
106
107impl MakeComputed for AnimationTimingFunction {}
108
109/// Supported step positions for CSS stepped easing functions.
110#[derive(Debug, Clone, Copy, PartialEq)]
111#[non_exhaustive]
112pub enum StepPosition {
113  /// Jumps at the start of each step interval.
114  Start,
115  /// Jumps at the end of each step interval.
116  End,
117}
118
119impl<'i> FromCss<'i> for AnimationTimingFunction {
120  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
121    if let Ok(function) = input.try_parse(parse_timing_keyword) {
122      return Ok(function);
123    }
124
125    if let Ok(function) = input.try_parse(parse_steps_function) {
126      return Ok(function);
127    }
128
129    input.expect_function_matching("cubic-bezier")?;
130    input.parse_nested_block(|input| {
131      let x1 = expect_number(input)?;
132      input.expect_comma()?;
133      let y1 = expect_number(input)?;
134      input.expect_comma()?;
135      let x2 = expect_number(input)?;
136      input.expect_comma()?;
137      let y2 = expect_number(input)?;
138
139      if !(0.0..=1.0).contains(&x1) || !(0.0..=1.0).contains(&x2) {
140        return Err(input.new_error(BasicParseErrorKind::QualifiedRuleInvalid));
141      }
142
143      Ok(Self::CubicBezier(x1, y1, x2, y2))
144    })
145  }
146
147  const VALID_TOKENS: &'static [CssToken] = &[
148    CssToken::Keyword("linear"),
149    CssToken::Keyword("ease"),
150    CssToken::Keyword("ease-in"),
151    CssToken::Keyword("ease-out"),
152    CssToken::Keyword("ease-in-out"),
153    CssToken::Keyword("step-start"),
154    CssToken::Keyword("step-end"),
155    CssToken::Descriptor(CssDescriptorKind::StepsFn),
156    CssToken::Descriptor(CssDescriptorKind::CubicBezierFn),
157  ];
158}
159
160/// Parsed values for `animation-timing-function`.
161pub type AnimationTimingFunctions = Box<[AnimationTimingFunction]>;
162
163impl<'i> FromCss<'i> for AnimationTimingFunctions {
164  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
165    input
166      .parse_comma_separated(AnimationTimingFunction::from_css)
167      .map(Vec::into_boxed_slice)
168  }
169
170  const VALID_TOKENS: &'static [CssToken] = AnimationTimingFunction::VALID_TOKENS;
171}
172
173/// Supported values for `animation-iteration-count`.
174#[derive(Debug, Clone, Copy, PartialEq)]
175#[non_exhaustive]
176pub enum AnimationIterationCount {
177  /// A finite iteration count.
178  Number(f32),
179  /// Repeats forever.
180  Infinite,
181}
182
183impl Default for AnimationIterationCount {
184  fn default() -> Self {
185    Self::Number(1.0)
186  }
187}
188
189impl MakeComputed for AnimationIterationCount {}
190
191impl<'i> FromCss<'i> for AnimationIterationCount {
192  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
193    if input
194      .try_parse(|parser| parser.expect_ident_matching("infinite"))
195      .is_ok()
196    {
197      return Ok(Self::Infinite);
198    }
199
200    let value = expect_number(input)?;
201    if value < 0.0 {
202      return Err(input.new_error(BasicParseErrorKind::QualifiedRuleInvalid));
203    }
204
205    Ok(Self::Number(value))
206  }
207
208  const VALID_TOKENS: &'static [CssToken] = &[
209    CssToken::Syntax(CssSyntaxKind::Number),
210    CssToken::Keyword("infinite"),
211  ];
212}
213
214/// Parsed values for `animation-iteration-count`.
215pub type AnimationIterationCounts = Box<[AnimationIterationCount]>;
216
217impl<'i> FromCss<'i> for AnimationIterationCounts {
218  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
219    input
220      .parse_comma_separated(AnimationIterationCount::from_css)
221      .map(Vec::into_boxed_slice)
222  }
223
224  const VALID_TOKENS: &'static [CssToken] = AnimationIterationCount::VALID_TOKENS;
225}
226
227/// Supported values for `animation-direction`.
228#[derive(Debug, Clone, Copy, PartialEq, Default)]
229#[non_exhaustive]
230pub enum AnimationDirection {
231  #[default]
232  /// Plays from the first keyframe to the last keyframe.
233  Normal,
234  /// Plays from the last keyframe to the first keyframe.
235  Reverse,
236  /// Alternates between forward and reverse playback.
237  Alternate,
238  /// Alternates between reverse and forward playback.
239  AlternateReverse,
240}
241
242declare_enum_from_css_impl!(
243  AnimationDirection,
244  "normal" => AnimationDirection::Normal,
245  "reverse" => AnimationDirection::Reverse,
246  "alternate" => AnimationDirection::Alternate,
247  "alternate-reverse" => AnimationDirection::AlternateReverse,
248);
249
250/// Parsed values for `animation-direction`.
251pub type AnimationDirections = Box<[AnimationDirection]>;
252
253impl<'i> FromCss<'i> for AnimationDirections {
254  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
255    input
256      .parse_comma_separated(AnimationDirection::from_css)
257      .map(Vec::into_boxed_slice)
258  }
259
260  const VALID_TOKENS: &'static [CssToken] = AnimationDirection::VALID_TOKENS;
261}
262
263/// Supported values for `animation-fill-mode`.
264#[derive(Debug, Clone, Copy, PartialEq, Default)]
265#[non_exhaustive]
266pub enum AnimationFillMode {
267  #[default]
268  /// Does not apply keyframe values outside the active interval.
269  None,
270  /// Keeps the final keyframe value after completion.
271  Forwards,
272  /// Applies the starting keyframe value during delay.
273  Backwards,
274  /// Applies both backwards and forwards fill behavior.
275  Both,
276}
277
278declare_enum_from_css_impl!(
279  AnimationFillMode,
280  "none" => AnimationFillMode::None,
281  "forwards" => AnimationFillMode::Forwards,
282  "backwards" => AnimationFillMode::Backwards,
283  "both" => AnimationFillMode::Both,
284);
285
286/// Parsed values for `animation-fill-mode`.
287pub type AnimationFillModes = Box<[AnimationFillMode]>;
288
289impl<'i> FromCss<'i> for AnimationFillModes {
290  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
291    input
292      .parse_comma_separated(AnimationFillMode::from_css)
293      .map(Vec::into_boxed_slice)
294  }
295
296  const VALID_TOKENS: &'static [CssToken] = AnimationFillMode::VALID_TOKENS;
297}
298
299/// Supported values for `animation-play-state`.
300#[derive(Debug, Clone, Copy, PartialEq, Default)]
301#[non_exhaustive]
302pub enum AnimationPlayState {
303  #[default]
304  /// The animation is actively progressing with time.
305  Running,
306  /// The animation is frozen and does not advance.
307  Paused,
308}
309
310declare_enum_from_css_impl!(
311  AnimationPlayState,
312  "running" => AnimationPlayState::Running,
313  "paused" => AnimationPlayState::Paused,
314);
315
316/// Parsed values for `animation-play-state`.
317pub type AnimationPlayStates = Box<[AnimationPlayState]>;
318
319impl<'i> FromCss<'i> for AnimationPlayStates {
320  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
321    input
322      .parse_comma_separated(AnimationPlayState::from_css)
323      .map(Vec::into_boxed_slice)
324  }
325
326  const VALID_TOKENS: &'static [CssToken] = AnimationPlayState::VALID_TOKENS;
327}
328
329/// Parsed value for one `animation` shorthand item.
330#[derive(Debug, Clone, PartialEq, Default, TypedBuilder)]
331#[builder(field_defaults(default))]
332#[non_exhaustive]
333pub struct Animation {
334  /// Parsed `animation-duration`.
335  pub duration: AnimationTime,
336  /// Parsed `animation-delay`.
337  pub delay: AnimationTime,
338  /// Parsed `animation-timing-function`.
339  pub timing_function: AnimationTimingFunction,
340  /// Parsed `animation-iteration-count`.
341  pub iteration_count: AnimationIterationCount,
342  /// Parsed `animation-direction`.
343  pub direction: AnimationDirection,
344  /// Parsed `animation-fill-mode`.
345  pub fill_mode: AnimationFillMode,
346  /// Parsed `animation-play-state`.
347  pub play_state: AnimationPlayState,
348  /// Parsed `animation-name`, with `None` representing the CSS `none` keyword.
349  #[builder(setter(strip_option))]
350  pub name: Option<String>,
351}
352
353impl MakeComputed for Animation {}
354
355impl<'i> FromCss<'i> for Animation {
356  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
357    let mut animation = Self::default();
358    let mut time_count = 0;
359
360    while !input.is_exhausted() && !next_is_comma(input) {
361      if let Ok(value) = input.try_parse(AnimationTime::from_css) {
362        match time_count {
363          0 => animation.duration = value,
364          1 => animation.delay = value,
365          _ => return Err(input.new_error_for_next_token()),
366        }
367        time_count += 1;
368        continue;
369      }
370
371      if let Ok(value) = input.try_parse(AnimationTimingFunction::from_css) {
372        animation.timing_function = value;
373        continue;
374      }
375
376      if let Ok(value) = input.try_parse(AnimationIterationCount::from_css) {
377        animation.iteration_count = value;
378        continue;
379      }
380
381      if let Ok(value) = input.try_parse(AnimationDirection::from_css) {
382        animation.direction = value;
383        continue;
384      }
385
386      if let Ok(value) = input.try_parse(AnimationFillMode::from_css) {
387        animation.fill_mode = value;
388        continue;
389      }
390
391      if let Ok(value) = input.try_parse(AnimationPlayState::from_css) {
392        animation.play_state = value;
393        continue;
394      }
395
396      if let Ok(value) = input.try_parse(AnimationName::from_css) {
397        animation.name = value;
398        continue;
399      }
400
401      return Err(input.new_error_for_next_token());
402    }
403
404    Ok(animation)
405  }
406
407  const VALID_TOKENS: &'static [CssToken] = Animations::VALID_TOKENS;
408}
409
410/// Parsed values for the `animation` shorthand.
411pub type Animations = Box<[Animation]>;
412
413impl<'i> FromCss<'i> for Animations {
414  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
415    Ok(
416      input
417        .parse_comma_separated(Animation::from_css)?
418        .into_boxed_slice(),
419    )
420  }
421
422  const VALID_TOKENS: &'static [CssToken] = &[
423    CssToken::Syntax(CssSyntaxKind::Time),
424    CssToken::Syntax(CssSyntaxKind::EasingFunction),
425    CssToken::Syntax(CssSyntaxKind::Number),
426    CssToken::Keyword("infinite"),
427    CssToken::Keyword("normal"),
428    CssToken::Keyword("reverse"),
429    CssToken::Keyword("alternate"),
430    CssToken::Keyword("alternate-reverse"),
431    CssToken::Keyword("none"),
432    CssToken::Keyword("forwards"),
433    CssToken::Keyword("backwards"),
434    CssToken::Keyword("both"),
435    CssToken::Keyword("running"),
436    CssToken::Keyword("paused"),
437    CssToken::Syntax(CssSyntaxKind::CustomIdent),
438    CssToken::Syntax(CssSyntaxKind::String),
439  ];
440}
441
442impl TailwindPropertyParser for Animations {
443  fn parse_tw(token: &str) -> Option<Self> {
444    match_ignore_ascii_case! {token,
445      "none" => Some(Box::from([Animation::default()])),
446      "spin" => Some(Box::from([Animation {
447        duration: AnimationTime::from_milliseconds(1000.0),
448        timing_function: AnimationTimingFunction::Linear,
449        iteration_count: AnimationIterationCount::Infinite,
450        name: Some("spin".to_string()),
451        ..Animation::default()
452      }])),
453      "ping" => Some(Box::from([Animation {
454        duration: AnimationTime::from_milliseconds(1000.0),
455        timing_function: AnimationTimingFunction::CubicBezier(0.0, 0.0, 0.2, 1.0),
456        iteration_count: AnimationIterationCount::Infinite,
457        name: Some("ping".to_string()),
458        ..Animation::default()
459      }])),
460      "pulse" => Some(Box::from([Animation {
461        duration: AnimationTime::from_milliseconds(2000.0),
462        timing_function: AnimationTimingFunction::CubicBezier(0.4, 0.0, 0.6, 1.0),
463        iteration_count: AnimationIterationCount::Infinite,
464        name: Some("pulse".to_string()),
465        ..Animation::default()
466      }])),
467      "bounce" => Some(Box::from([Animation {
468        duration: AnimationTime::from_milliseconds(1000.0),
469        iteration_count: AnimationIterationCount::Infinite,
470        name: Some("bounce".to_string()),
471        ..Animation::default()
472      }])),
473      _ => None,
474    }
475  }
476
477  fn parse_tw_with_arbitrary(token: &str) -> Option<Self> {
478    if let Some(value) = token
479      .strip_prefix('[')
480      .and_then(|value| value.strip_suffix(']'))
481    {
482      let value = if value.contains('_') {
483        Cow::Owned(value.replace('_', " "))
484      } else {
485        Cow::Borrowed(value)
486      };
487
488      return Self::from_str(&value).ok();
489    }
490
491    Self::parse_tw(token)
492  }
493}
494
495fn parse_timing_keyword<'i>(
496  input: &mut Parser<'i, '_>,
497) -> ParseResult<'i, AnimationTimingFunction> {
498  let location = input.current_source_location();
499  let token = input.next()?;
500  let Token::Ident(ident) = token else {
501    return Err(unexpected_token!(AnimationTimingFunction, location, token));
502  };
503
504  match_ignore_ascii_case! {ident,
505    "linear" => Ok(AnimationTimingFunction::Linear),
506    "ease" => Ok(AnimationTimingFunction::Ease),
507    "ease-in" => Ok(AnimationTimingFunction::EaseIn),
508    "ease-out" => Ok(AnimationTimingFunction::EaseOut),
509    "ease-in-out" => Ok(AnimationTimingFunction::EaseInOut),
510    "step-start" => Ok(AnimationTimingFunction::StepStart),
511    "step-end" => Ok(AnimationTimingFunction::StepEnd),
512    _ => Err(unexpected_token!(AnimationTimingFunction, location, token)),
513  }
514}
515
516fn parse_step_position<'i>(input: &mut Parser<'i, '_>) -> ParseResult<'i, StepPosition> {
517  let location = input.current_source_location();
518  let token = input.next()?;
519  let Token::Ident(ident) = token else {
520    return Err(unexpected_token!(AnimationTimingFunction, location, token));
521  };
522
523  match_ignore_ascii_case! {ident,
524    "start" => Ok(StepPosition::Start),
525    "end" => Ok(StepPosition::End),
526    _ => Err(unexpected_token!(AnimationTimingFunction, location, token)),
527  }
528}
529
530fn parse_steps_function<'i>(
531  input: &mut Parser<'i, '_>,
532) -> ParseResult<'i, AnimationTimingFunction> {
533  input.expect_function_matching("steps")?;
534  input.parse_nested_block(|input| {
535    let count = input.expect_integer()?;
536    if count <= 0 {
537      return Err(input.new_error(BasicParseErrorKind::QualifiedRuleInvalid));
538    }
539
540    input.expect_comma()?;
541    let position = parse_step_position(input)?;
542    Ok(AnimationTimingFunction::Steps(count as u32, position))
543  })
544}
545
546fn expect_number<'i>(input: &mut Parser<'i, '_>) -> ParseResult<'i, f32> {
547  let location = input.current_source_location();
548  let token = input.next()?;
549  let Token::Number { value, .. } = token else {
550    return Err(unexpected_token!(AnimationTime, location, token));
551  };
552  Ok(*value)
553}
554
555pub(crate) fn repeated_list_value<T: Clone>(values: &[T], index: usize, default: T) -> T {
556  if values.is_empty() {
557    return default;
558  }
559
560  values[index % values.len()].clone()
561}
562
563pub(crate) fn timing_function_at(
564  values: &AnimationTimingFunctions,
565  index: usize,
566) -> AnimationTimingFunction {
567  repeated_list_value(values, index, AnimationTimingFunction::default())
568}
569
570pub(crate) fn time_at(
571  values: &AnimationDurations,
572  index: usize,
573  default: AnimationTime,
574) -> AnimationTime {
575  repeated_list_value(values, index, default)
576}
577
578pub(crate) fn iteration_count_at(
579  values: &AnimationIterationCounts,
580  index: usize,
581) -> AnimationIterationCount {
582  repeated_list_value(values, index, AnimationIterationCount::default())
583}
584
585pub(crate) fn direction_at(values: &AnimationDirections, index: usize) -> AnimationDirection {
586  repeated_list_value(values, index, AnimationDirection::default())
587}
588
589pub(crate) fn fill_mode_at(values: &AnimationFillModes, index: usize) -> AnimationFillMode {
590  repeated_list_value(values, index, AnimationFillMode::default())
591}
592
593fn cubic_bezier_sample(x1: f32, y1: f32, x2: f32, y2: f32, progress: f32) -> f32 {
594  fn sample_curve(a: f32, b: f32, c: f32, t: f32) -> f32 {
595    ((a * t + b) * t + c) * t
596  }
597
598  fn sample_derivative(a: f32, b: f32, c: f32, t: f32) -> f32 {
599    (3.0 * a * t + 2.0 * b) * t + c
600  }
601
602  let cx = 3.0 * x1;
603  let bx = 3.0 * (x2 - x1) - cx;
604  let ax = 1.0 - cx - bx;
605  let cy = 3.0 * y1;
606  let by = 3.0 * (y2 - y1) - cy;
607  let ay = 1.0 - cy - by;
608
609  let mut t = progress.clamp(0.0, 1.0);
610  for _ in 0..6 {
611    let x = sample_curve(ax, bx, cx, t) - progress;
612    let derivative = sample_derivative(ax, bx, cx, t);
613    if derivative.abs() < f32::EPSILON {
614      break;
615    }
616    t = (t - x / derivative).clamp(0.0, 1.0);
617  }
618
619  sample_curve(ay, by, cy, t)
620}
621
622fn steps_sample(step_count: u32, position: StepPosition, progress: f32) -> f32 {
623  let step_count = step_count as f32;
624  let progress = progress.clamp(0.0, 1.0);
625
626  match position {
627    StepPosition::Start => (((progress * step_count).floor() + 1.0).min(step_count)) / step_count,
628    StepPosition::End => ((progress * step_count).floor()) / step_count,
629  }
630}
631
632pub(crate) fn apply_timing_function(function: &AnimationTimingFunction, progress: f32) -> f32 {
633  match function {
634    AnimationTimingFunction::Linear => progress,
635    AnimationTimingFunction::Ease => cubic_bezier_sample(0.25, 0.1, 0.25, 1.0, progress),
636    AnimationTimingFunction::EaseIn => cubic_bezier_sample(0.42, 0.0, 1.0, 1.0, progress),
637    AnimationTimingFunction::EaseOut => cubic_bezier_sample(0.0, 0.0, 0.58, 1.0, progress),
638    AnimationTimingFunction::EaseInOut => cubic_bezier_sample(0.42, 0.0, 0.58, 1.0, progress),
639    AnimationTimingFunction::StepStart => steps_sample(1, StepPosition::Start, progress),
640    AnimationTimingFunction::StepEnd => steps_sample(1, StepPosition::End, progress),
641    AnimationTimingFunction::Steps(count, position) => steps_sample(*count, *position, progress),
642    AnimationTimingFunction::CubicBezier(x1, y1, x2, y2) => {
643      cubic_bezier_sample(*x1, *y1, *x2, *y2, progress)
644    }
645  }
646}
647
648impl ToCss for AnimationTime {
649  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
650    write!(dest, "{}ms", self.milliseconds)
651  }
652}
653
654impl ToCss for StepPosition {
655  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
656    match self {
657      Self::Start => dest.write_str("start"),
658      Self::End => dest.write_str("end"),
659    }
660  }
661}
662
663impl ToCss for AnimationTimingFunction {
664  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
665    match self {
666      Self::Linear => dest.write_str("linear"),
667      Self::Ease => dest.write_str("ease"),
668      Self::EaseIn => dest.write_str("ease-in"),
669      Self::EaseOut => dest.write_str("ease-out"),
670      Self::EaseInOut => dest.write_str("ease-in-out"),
671      Self::StepStart => dest.write_str("step-start"),
672      Self::StepEnd => dest.write_str("step-end"),
673      Self::Steps(count, position) => {
674        dest.write_str("steps(")?;
675        write!(dest, "{}", count)?;
676        dest.write_str(", ")?;
677        position.to_css(dest)?;
678        dest.write_char(')')
679      }
680      Self::CubicBezier(x1, y1, x2, y2) => write!(dest, "cubic-bezier({x1}, {y1}, {x2}, {y2})"),
681    }
682  }
683}
684
685impl ToCss for AnimationIterationCount {
686  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
687    match self {
688      Self::Number(n) => write!(dest, "{}", n),
689      Self::Infinite => dest.write_str("infinite"),
690    }
691  }
692}
693
694impl ToCss for Animation {
695  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
696    if let Some(ref name) = self.name {
697      let needs_quoting = name
698        .chars()
699        .any(|c| !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-'));
700      if needs_quoting {
701        write_css_string(dest, name)?;
702      } else {
703        dest.write_str(name)?;
704      }
705    } else {
706      dest.write_str("none")?;
707    }
708    dest.write_char(' ')?;
709    self.duration.to_css(dest)?;
710    dest.write_char(' ')?;
711    self.timing_function.to_css(dest)?;
712    dest.write_char(' ')?;
713    self.delay.to_css(dest)?;
714    dest.write_char(' ')?;
715    self.iteration_count.to_css(dest)?;
716    dest.write_char(' ')?;
717    self.direction.to_css(dest)?;
718    dest.write_char(' ')?;
719    self.fill_mode.to_css(dest)?;
720    dest.write_char(' ')?;
721    self.play_state.to_css(dest)
722  }
723}
724
725#[cfg(test)]
726mod tests {
727  use std::assert_matches;
728
729  use super::*;
730
731  #[test]
732  fn parse_animation_time() {
733    assert_eq!(
734      AnimationTime::from_str("150ms"),
735      Ok(AnimationTime::from_milliseconds(150.0))
736    );
737    assert_eq!(
738      AnimationTime::from_str("2s"),
739      Ok(AnimationTime::from_milliseconds(2000.0))
740    );
741  }
742
743  #[test]
744  fn parse_animation_names() {
745    assert_matches!(
746      AnimationNames::from_str("fade, slide"),
747      Ok(names) if names.as_ref() == [Some("fade".to_string()), Some("slide".to_string())]
748    );
749  }
750
751  #[test]
752  fn parse_quoted_animation_names() {
753    assert_matches!(
754      AnimationNames::from_str("\"fade\", slide"),
755      Ok(names) if names.as_ref() == [Some("fade".to_string()), Some("slide".to_string())]
756    );
757  }
758
759  #[test]
760  fn parse_animation_names_with_none_entry() {
761    assert_matches!(
762      AnimationNames::from_str("none, slide"),
763      Ok(names) if names.as_ref() == [None, Some("slide".to_string())]
764    );
765  }
766
767  #[test]
768  fn parse_steps_timing_functions() {
769    assert_eq!(
770      AnimationTimingFunction::from_str("step-start"),
771      Ok(AnimationTimingFunction::StepStart)
772    );
773    assert_eq!(
774      AnimationTimingFunction::from_str("step-end"),
775      Ok(AnimationTimingFunction::StepEnd)
776    );
777    assert_eq!(
778      AnimationTimingFunction::from_str("steps(4, end)"),
779      Ok(AnimationTimingFunction::Steps(4, StepPosition::End))
780    );
781  }
782
783  #[test]
784  fn reject_invalid_cubic_bezier_x_coordinates() {
785    assert!(AnimationTimingFunction::from_str("cubic-bezier(-0.1, 0, 0.2, 1)").is_err());
786    assert!(AnimationTimingFunction::from_str("cubic-bezier(0.1, 0, 1.2, 1)").is_err());
787  }
788
789  #[test]
790  fn reject_negative_animation_iteration_count() {
791    assert!(AnimationIterationCount::from_str("-1").is_err());
792  }
793
794  #[test]
795  fn cubic_bezier_preserves_overshoot() {
796    let Ok(function) = AnimationTimingFunction::from_str("cubic-bezier(0.68, -0.6, 0.32, 1.6)")
797    else {
798      return;
799    };
800
801    let early = apply_timing_function(&function, 0.2);
802    let late = apply_timing_function(&function, 0.8);
803
804    assert!(early < 0.0, "expected negative overshoot, got {early}");
805    assert!(late > 1.0, "expected positive overshoot, got {late}");
806  }
807
808  #[test]
809  fn repeated_list_value_wraps() {
810    let values = [AnimationDirection::Normal, AnimationDirection::Reverse].into();
811    assert_eq!(direction_at(&values, 2), AnimationDirection::Normal);
812  }
813
814  #[test]
815  fn parse_animation_shorthand() {
816    assert_eq!(
817      Animations::from_str("fade 1s ease-in 200ms 2 alternate both paused"),
818      Ok(Box::from([Animation {
819        duration: AnimationTime::from_milliseconds(1000.0),
820        delay: AnimationTime::from_milliseconds(200.0),
821        timing_function: AnimationTimingFunction::EaseIn,
822        iteration_count: AnimationIterationCount::Number(2.0),
823        direction: AnimationDirection::Alternate,
824        fill_mode: AnimationFillMode::Both,
825        play_state: AnimationPlayState::Paused,
826        name: Some("fade".to_string()),
827      }]))
828    );
829  }
830
831  #[test]
832  fn parse_animation_shorthand_with_quoted_name() {
833    assert_eq!(
834      Animations::from_str("\"fade\" 1s linear"),
835      Ok(Box::from([Animation {
836        duration: AnimationTime::from_milliseconds(1000.0),
837        timing_function: AnimationTimingFunction::Linear,
838        name: Some("fade".to_string()),
839        ..Animation::default()
840      }]))
841    );
842  }
843
844  #[test]
845  fn parse_multiple_animation_shorthand_values() {
846    assert_eq!(
847      Animations::from_str("fade 1s linear, 2s slide"),
848      Ok(Box::from([
849        Animation {
850          duration: AnimationTime::from_milliseconds(1000.0),
851          timing_function: AnimationTimingFunction::Linear,
852          name: Some("fade".to_string()),
853          ..Animation::default()
854        },
855        Animation {
856          duration: AnimationTime::from_milliseconds(2000.0),
857          name: Some("slide".to_string()),
858          ..Animation::default()
859        },
860      ]))
861    );
862  }
863
864  #[test]
865  fn parse_tailwind_animation_preset() {
866    assert_eq!(
867      Animations::parse_tw("spin"),
868      Some(Box::from([Animation {
869        duration: AnimationTime::from_milliseconds(1000.0),
870        timing_function: AnimationTimingFunction::Linear,
871        iteration_count: AnimationIterationCount::Infinite,
872        name: Some("spin".to_string()),
873        ..Animation::default()
874      }]))
875    );
876  }
877
878  #[test]
879  fn parse_tailwind_animation_arbitrary_value() {
880    assert_eq!(
881      Animations::parse_tw_with_arbitrary("[wiggle_1s_ease-in-out_infinite]"),
882      Some(Box::from([Animation {
883        duration: AnimationTime::from_milliseconds(1000.0),
884        timing_function: AnimationTimingFunction::EaseInOut,
885        iteration_count: AnimationIterationCount::Infinite,
886        name: Some("wiggle".to_string()),
887        ..Animation::default()
888      }]))
889    );
890  }
891}