Skip to main content

takumi_core/style/properties/
animation.rs

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