Skip to main content

guise/anim/
macros.rs

1//! Declarative macros for motion, in the shape of the layout ones.
2//!
3//! [`motion!`](crate::motion) is to an animation what
4//! [`style!`](crate::style) is to a box: a block of declarations instead of a
5//! chain of setters, so the timing and the tweens read as one thing.
6//!
7//! ```ignore
8//! use guise::prelude::*;
9//!
10//! div().child(card).animate("card", motion! {
11//!     duration: 420;
12//!     ease: out back;
13//!     opacity: 0 => 1;
14//!     y: 12 => 0;
15//! })
16//! ```
17//!
18//! [`sequence!`](crate::sequence) is the variadic one — the same job `col!`
19//! does for children, for motions on a clock:
20//!
21//! ```ignore
22//! sequence![
23//!     slide_out,
24//!     rel(-140) => drop_down,      // overlapping the tail
25//!     with(0) => tint,             // alongside the previous
26//! ]
27//! ```
28//!
29//! Both return the builder, so anything the block does not cover still
30//! chains: `motion! { … }.repeat(3)`.
31
32/// A [`Motion`](crate::anim::Motion) as a block of declarations.
33///
34/// Timing first, tweens after — though the order is yours; the only rule is
35/// that `enter:` / `exit:` must come first, because they pick the
36/// constructor rather than chain onto it.
37///
38/// ```ignore
39/// motion! {
40///     enter: slide_up 24;     // a preset, optionally with its distance
41///     duration: 420;          // ms
42///     delay: 80;
43///     end_delay: 120;
44///     ease: out back;         // direction + curve
45///     repeat: forever;        // or `once`, or a count
46///     alternate;              // bare flags
47///     reversed;
48///     margins;                // offsets as margins, for a pinned element
49///
50///     opacity: 0 => 1;        // prop: from => to
51///     y: 0 => [-30, 0];       // or a list of legs
52///     bg: color!("#111") => color!("#333");
53/// }
54/// ```
55///
56/// **Easings**: `linear`, `spring`, `steps(4)`, or a direction and a curve —
57/// `in`/`out`/`in_out` over `quad`, `cubic`, `quart`, `quint`, `sine`,
58/// `expo`, `circ`, `back`, `elastic`, `bounce`. Any [`Easing`](crate::Easing)
59/// expression works too.
60///
61/// **Presets**: `fade`, `slide_up`, `slide_down`, `slide_left`,
62/// `slide_right`.
63///
64/// **Props**: `opacity`, `x`, `y`, `w`/`width`, `h`/`height`, `mt`/`mr`/`mb`/
65/// `ml`, `pt`/`pr`/`pb`/`pl`, `radius`, `border_width`, `gap`, `font_size`,
66/// `bg`/`background`, `border_color`, `color`, `rotate`, `scale`, and
67/// `custom("name")` for a number that is not a style at all. Numbers are px
68/// (or degrees, or a multiplier); colours are any `Into<Hsla>`.
69#[macro_export]
70macro_rules! motion {
71    ( enter : $kind:ident $distance:expr ; $($rest:tt)* ) => {
72        $crate::__motion!(
73            @m $crate::anim::Motion::enter_from($crate::__kind!($kind), $distance as f32)
74            ; $($rest)*
75        )
76    };
77    ( enter : $kind:ident ; $($rest:tt)* ) => {
78        $crate::__motion!(@m $crate::anim::Motion::enter($crate::__kind!($kind)) ; $($rest)*)
79    };
80    ( exit : $kind:ident $distance:expr ; $($rest:tt)* ) => {
81        $crate::__motion!(
82            @m $crate::anim::Motion::exit_to($crate::__kind!($kind), $distance as f32)
83            ; $($rest)*
84        )
85    };
86    ( exit : $kind:ident ; $($rest:tt)* ) => {
87        $crate::__motion!(@m $crate::anim::Motion::exit($crate::__kind!($kind)) ; $($rest)*)
88    };
89    ( $($decls:tt)* ) => {
90        $crate::__motion!(@m $crate::anim::Motion::new() ; $($decls)*)
91    };
92}
93
94/// A [`Sequence`](crate::anim::Sequence) of motions on one clock.
95///
96/// A bare motion lands after everything before it. To place one anywhere
97/// else, put the position in front of it:
98///
99/// ```ignore
100/// sequence![
101///     fade_in,
102///     rel(-120) => slide_up,              // 120ms before the end so far
103///     with(0) => tint,                    // alongside the previous entry
104///     abs(600) => flash,                  // from the sequence's own start
105///     label("settled", 50) => ripple,     // 50ms after a placed label
106/// ]
107/// ```
108///
109/// The position goes first because a Rust macro cannot read anything but
110/// `,`, `;` or `=>` after an expression — and `=>` reads like a timeline
111/// anyway. Labels are placed with `Sequence::label`, so a sequence that uses
112/// them starts from the builder.
113#[macro_export]
114macro_rules! sequence {
115    ( $($items:tt)* ) => {
116        // The trailing comma the muncher relies on. A list that already had
117        // one ends in `,,`, which the terminal arm eats.
118        $crate::__sequence!(@s $crate::anim::Sequence::new() ; $($items)* ,)
119    };
120}
121
122#[macro_export]
123#[doc(hidden)]
124macro_rules! __motion {
125    (@m $m:expr ;) => { $m };
126
127    // --- timing ---
128    (@m $m:expr ; duration : $v:expr ; $($r:tt)*) => {
129        $crate::__motion!(@m $m.duration($v as f32) ; $($r)*)
130    };
131    (@m $m:expr ; delay : $v:expr ; $($r:tt)*) => {
132        $crate::__motion!(@m $m.delay($v as f32) ; $($r)*)
133    };
134    (@m $m:expr ; end_delay : $v:expr ; $($r:tt)*) => {
135        $crate::__motion!(@m $m.end_delay($v as f32) ; $($r)*)
136    };
137
138    // --- easing: keyword forms before the expression fallback, which would
139    //     otherwise try to parse `out back` and fail without backtracking ---
140    (@m $m:expr ; ease : linear ; $($r:tt)*) => {
141        $crate::__motion!(@m $m.ease($crate::Easing::Linear) ; $($r)*)
142    };
143    (@m $m:expr ; ease : spring ; $($r:tt)*) => {
144        $crate::__motion!(@m $m.ease($crate::Easing::Spring($crate::Spring::default())) ; $($r)*)
145    };
146    (@m $m:expr ; ease : steps($n:expr) ; $($r:tt)*) => {
147        $crate::__motion!(@m $m.ease($crate::Easing::Steps($n as u32)) ; $($r)*)
148    };
149    (@m $m:expr ; ease : in_out $c:ident ; $($r:tt)*) => {
150        $crate::__motion!(@m $m.ease($crate::Easing::InOut($crate::__curve!($c))) ; $($r)*)
151    };
152    (@m $m:expr ; ease : in $c:ident ; $($r:tt)*) => {
153        $crate::__motion!(@m $m.ease($crate::Easing::In($crate::__curve!($c))) ; $($r)*)
154    };
155    (@m $m:expr ; ease : out $c:ident ; $($r:tt)*) => {
156        $crate::__motion!(@m $m.ease($crate::Easing::Out($crate::__curve!($c))) ; $($r)*)
157    };
158    (@m $m:expr ; ease : $e:expr ; $($r:tt)*) => {
159        $crate::__motion!(@m $m.ease($e) ; $($r)*)
160    };
161
162    // --- repetition ---
163    (@m $m:expr ; repeat : forever ; $($r:tt)*) => {
164        $crate::__motion!(@m $m.repeat_forever() ; $($r)*)
165    };
166    (@m $m:expr ; repeat : once ; $($r:tt)*) => {
167        $crate::__motion!(@m $m.repeat(1) ; $($r)*)
168    };
169    (@m $m:expr ; repeat : $n:expr ; $($r:tt)*) => {
170        $crate::__motion!(@m $m.repeat($n as u32) ; $($r)*)
171    };
172    (@m $m:expr ; alternate ; $($r:tt)*) => {
173        $crate::__motion!(@m $m.alternate(true) ; $($r)*)
174    };
175    (@m $m:expr ; reversed ; $($r:tt)*) => {
176        $crate::__motion!(@m $m.reversed(true) ; $($r)*)
177    };
178    (@m $m:expr ; margins ; $($r:tt)*) => {
179        $crate::__motion!(@m $m.as_margins() ; $($r)*)
180    };
181
182    // --- tracks: `custom(..)` first (it is not an ident), then the list form,
183    //     since `[a, b]` is also a perfectly good expression ---
184    (@m $m:expr ; custom($n:literal) : $from:expr => [ $($k:expr),* $(,)? ] ; $($r:tt)*) => {
185        $crate::__motion!(@m $m.keyframes($crate::Prop::Custom($n), $from, [$($k),*]) ; $($r)*)
186    };
187    (@m $m:expr ; custom($n:literal) : $from:expr => $to:expr ; $($r:tt)*) => {
188        $crate::__motion!(@m $m.tween($crate::Prop::Custom($n), $from, $to) ; $($r)*)
189    };
190    (@m $m:expr ; $p:ident : $from:expr => [ $($k:expr),* $(,)? ] ; $($r:tt)*) => {
191        $crate::__motion!(@m $m.keyframes($crate::__prop!($p), $from, [$($k),*]) ; $($r)*)
192    };
193    (@m $m:expr ; $p:ident : $from:expr => $to:expr ; $($r:tt)*) => {
194        $crate::__motion!(@m $m.tween($crate::__prop!($p), $from, $to) ; $($r)*)
195    };
196}
197
198#[macro_export]
199#[doc(hidden)]
200macro_rules! __sequence {
201    (@s $s:expr ;) => { $s };
202    (@s $s:expr ; ,) => { $s };
203
204    (@s $s:expr ; rel($v:expr) => $m:expr , $($r:tt)*) => {
205        $crate::__sequence!(@s $s.add_at($m, $crate::At::Rel($v as f32)) ; $($r)*)
206    };
207    (@s $s:expr ; abs($v:expr) => $m:expr , $($r:tt)*) => {
208        $crate::__sequence!(@s $s.add_at($m, $crate::At::Abs($v as f32)) ; $($r)*)
209    };
210    (@s $s:expr ; with($v:expr) => $m:expr , $($r:tt)*) => {
211        $crate::__sequence!(@s $s.add_at($m, $crate::At::With($v as f32)) ; $($r)*)
212    };
213    (@s $s:expr ; label($n:expr, $v:expr) => $m:expr , $($r:tt)*) => {
214        $crate::__sequence!(@s $s.add_at($m, $crate::At::Label($n.into(), $v as f32)) ; $($r)*)
215    };
216    (@s $s:expr ; $m:expr , $($r:tt)*) => {
217        $crate::__sequence!(@s $s.add($m) ; $($r)*)
218    };
219}
220
221/// The [`TransitionKind`](crate::TransitionKind) a preset word names.
222#[macro_export]
223#[doc(hidden)]
224macro_rules! __kind {
225  (fade) => {
226    $crate::TransitionKind::Fade
227  };
228  (slide_up) => {
229    $crate::TransitionKind::SlideUp
230  };
231  (slide_down) => {
232    $crate::TransitionKind::SlideDown
233  };
234  (slide_left) => {
235    $crate::TransitionKind::SlideLeft
236  };
237  (slide_right) => {
238    $crate::TransitionKind::SlideRight
239  };
240}
241
242/// The [`Curve`](crate::Curve) a shape word names.
243#[macro_export]
244#[doc(hidden)]
245macro_rules! __curve {
246  (quad) => {
247    $crate::Curve::Quad
248  };
249  (cubic) => {
250    $crate::Curve::Cubic
251  };
252  (quart) => {
253    $crate::Curve::Quart
254  };
255  (quint) => {
256    $crate::Curve::Quint
257  };
258  (sine) => {
259    $crate::Curve::Sine
260  };
261  (expo) => {
262    $crate::Curve::Expo
263  };
264  (circ) => {
265    $crate::Curve::Circ
266  };
267  (back) => {
268    $crate::Curve::Back
269  };
270  (elastic) => {
271    $crate::Curve::Elastic
272  };
273  (bounce) => {
274    $crate::Curve::Bounce
275  };
276}
277
278/// The [`Prop`](crate::Prop) a declaration name refers to.
279#[macro_export]
280#[doc(hidden)]
281macro_rules! __prop {
282  (opacity) => {
283    $crate::Prop::Opacity
284  };
285  (x) => {
286    $crate::Prop::X
287  };
288  (y) => {
289    $crate::Prop::Y
290  };
291  (w) => {
292    $crate::Prop::Width
293  };
294  (width) => {
295    $crate::Prop::Width
296  };
297  (h) => {
298    $crate::Prop::Height
299  };
300  (height) => {
301    $crate::Prop::Height
302  };
303  (mt) => {
304    $crate::Prop::MarginTop
305  };
306  (mr) => {
307    $crate::Prop::MarginRight
308  };
309  (mb) => {
310    $crate::Prop::MarginBottom
311  };
312  (ml) => {
313    $crate::Prop::MarginLeft
314  };
315  (pt) => {
316    $crate::Prop::PadTop
317  };
318  (pr) => {
319    $crate::Prop::PadRight
320  };
321  (pb) => {
322    $crate::Prop::PadBottom
323  };
324  (pl) => {
325    $crate::Prop::PadLeft
326  };
327  (radius) => {
328    $crate::Prop::Radius
329  };
330  (border_width) => {
331    $crate::Prop::BorderWidth
332  };
333  (gap) => {
334    $crate::Prop::Gap
335  };
336  (font_size) => {
337    $crate::Prop::FontSize
338  };
339  (bg) => {
340    $crate::Prop::Background
341  };
342  (background) => {
343    $crate::Prop::Background
344  };
345  (border_color) => {
346    $crate::Prop::BorderColor
347  };
348  (color) => {
349    $crate::Prop::TextColor
350  };
351  (rotate) => {
352    $crate::Prop::Rotate
353  };
354  (scale) => {
355    $crate::Prop::Scale
356  };
357  (custom($n:literal)) => {
358    $crate::Prop::Custom($n)
359  };
360}
361
362#[cfg(test)]
363mod tests {
364  use crate::anim::{Loop, Motion, Prop, Sequence};
365  use crate::Easing;
366
367  /// A `#[macro_export]` macro is only type-checked where it is invoked, so
368  /// every arm needs a call site somewhere.
369  #[test]
370  fn every_declaration_expands() {
371    let m = motion! {
372        duration: 420;
373        delay: 80;
374        end_delay: 120;
375        ease: out back;
376        repeat: 3;
377        alternate;
378        reversed;
379        margins;
380        opacity: 0 => 1;
381        radius: 6 => 24;
382    };
383    assert_eq!(m.duration, 420.0);
384    assert_eq!(m.delay, 80.0);
385    assert_eq!(m.end_delay, 120.0);
386    assert_eq!(m.ease, Easing::Out(crate::Curve::Back));
387    assert_eq!(m.loops, Loop::Times(3));
388    assert!(m.alternate && m.reversed);
389  }
390
391  #[test]
392  fn every_easing_spelling_expands() {
393    let curves = [
394      motion! { ease: linear; },
395      motion! { ease: spring; },
396      motion! { ease: steps(4); },
397      motion! { ease: in quad; },
398      motion! { ease: out cubic; },
399      motion! { ease: in_out sine; },
400      motion! { ease: Easing::CubicBezier(0.25, 0.1, 0.25, 1.0); },
401      motion! { ease: in quart; },
402      motion! { ease: out quint; },
403      motion! { ease: in expo; },
404      motion! { ease: out circ; },
405      motion! { ease: out elastic; },
406      motion! { ease: out bounce; },
407    ];
408    assert_eq!(curves[0].ease, Easing::Linear);
409    assert_eq!(curves[2].ease, Easing::Steps(4));
410    assert_eq!(curves[4].ease, Easing::Out(crate::Curve::Cubic));
411  }
412
413  #[test]
414  fn presets_pick_the_constructor() {
415    let m = motion! { enter: slide_up; duration: 300; };
416    assert_eq!(m.duration, 300.0);
417    assert_eq!(m.sample(0.0).number(Prop::Opacity), Some(0.0));
418    assert_eq!(m.sample(300.0).number(Prop::Y), Some(0.0));
419
420    // With a distance, and the exit twin.
421    let far = motion! { enter: slide_left 24; };
422    assert_eq!(far.sample(0.0).number(Prop::X), Some(24.0));
423    let out = motion! { exit: fade; };
424    assert_eq!(out.sample(0.0).number(Prop::Opacity), Some(1.0));
425    let _ = motion! { exit: slide_down 12; };
426    let _ = motion! { enter: fade; };
427    let _ = motion! { enter: slide_right; };
428  }
429
430  #[test]
431  fn a_leg_list_takes_values_or_keyframes() {
432    let plain = motion! {
433        duration: 300;
434        ease: linear;
435        y: 0 => [-30, 0];
436    };
437    assert_eq!(plain.iteration_ms(), 300.0);
438    assert_eq!(plain.sample(150.0).number(Prop::Y), Some(-30.0));
439
440    // The same track, with a leg that sets its own time.
441    let timed = motion! {
442        duration: 300;
443        ease: linear;
444        y: 0 => [crate::Keyframe::to(-30.0).duration(100.0), crate::Keyframe::to(0.0)];
445    };
446    assert_eq!(timed.sample(100.0).number(Prop::Y), Some(-30.0));
447  }
448
449  #[test]
450  fn colours_tween_through_the_macro() {
451    let m = motion! {
452        duration: 200;
453        bg: color!("#111111") => color!("#333333");
454        color: color!(teal) => color!(orchid);
455        border_color: color!("#000000") => color!("#ffffff");
456    };
457    assert!(m.sample(0.0).color(Prop::Background).is_some());
458    assert!(m.sample(200.0).color(Prop::TextColor).is_some());
459  }
460
461  #[test]
462  fn every_prop_word_maps() {
463    let m = motion! {
464        opacity: 0 => 1;
465        x: 0 => 1;
466        y: 0 => 1;
467        w: 0 => 1;
468        width: 0 => 1;
469        h: 0 => 1;
470        height: 0 => 1;
471        mt: 0 => 1;
472        mr: 0 => 1;
473        mb: 0 => 1;
474        ml: 0 => 1;
475        pt: 0 => 1;
476        pr: 0 => 1;
477        pb: 0 => 1;
478        pl: 0 => 1;
479        radius: 0 => 1;
480        border_width: 0 => 1;
481        gap: 0 => 1;
482        font_size: 0 => 1;
483        rotate: 0 => 1;
484        scale: 0 => 1;
485        custom("progress"): 0 => 1;
486        custom("legs"): 0 => [2, 4];
487    };
488    // `w`/`width` and `h`/`height` are the same track written twice.
489    assert_eq!(m.tracks.len(), 23);
490    assert_eq!(m.sample(m.total_ms()).number(Prop::Rotate), Some(1.0));
491    assert_eq!(
492      m.sample(m.total_ms()).number(Prop::Custom("progress")),
493      Some(1.0)
494    );
495    assert_eq!(
496      m.sample(m.total_ms()).number(Prop::Custom("legs")),
497      Some(4.0)
498    );
499  }
500
501  /// Tailor's macro flavour prints floats as `16.`, so that exact spelling
502  /// has to parse — the generator and the macro are one contract.
503  #[test]
504  fn the_spellings_tailor_generates_all_parse() {
505    let m = motion! {
506        enter: slide_up 16.;
507        duration: 400.;
508        delay: 60.;
509        ease: in_out sine;
510        repeat: forever;
511        alternate;
512        margins;
513    };
514    assert_eq!(m.duration, 400.0);
515    assert_eq!(m.delay, 60.0);
516    assert_eq!(m.loops, Loop::Forever);
517    // `margins` moved the slide off the inset.
518    assert_eq!(m.sample(0.0).number(Prop::MarginTop), Some(16.0));
519    let _ = motion! { enter: fade; duration: 260.; ease: out cubic; };
520  }
521
522  #[test]
523  fn repeat_words_and_counts() {
524    assert_eq!(motion! { repeat: once; }.loops, Loop::Times(1));
525    assert_eq!(motion! { repeat: forever; }.loops, Loop::Forever);
526    assert_eq!(motion! { repeat: 5; }.loops, Loop::Times(5));
527  }
528
529  #[test]
530  fn an_empty_block_is_a_default_motion() {
531    assert_eq!(motion! {}, Motion::new());
532  }
533
534  #[test]
535  fn the_block_still_chains() {
536    // Timing with no track to spend it on is a zero-length motion, so the
537    // chained setters go on something that actually moves.
538    let m = motion! { duration: 100; opacity: 0 => 1; }
539      .repeat(2)
540      .alternate(true);
541    assert_eq!(m.total_ms(), 200.0);
542  }
543
544  fn leg(from: f32, to: f32) -> Motion {
545    motion! { duration: 100; ease: linear; x: from => to; }
546  }
547
548  #[test]
549  fn sequences_queue_and_place() {
550    let s = sequence![leg(0.0, 10.0), leg(10.0, 20.0)];
551    assert_eq!(s.len(), 2);
552    assert_eq!(s.iteration_ms(), 200.0);
553
554    let overlapped = sequence![
555        leg(0.0, 10.0),
556        rel(-50) => leg(10.0, 20.0),
557        with(0) => leg(20.0, 30.0),
558        abs(400) => leg(30.0, 40.0),
559    ];
560    assert_eq!(overlapped.len(), 4);
561    assert_eq!(overlapped.iteration_ms(), 500.0);
562  }
563
564  #[test]
565  fn a_label_anchors_a_sequence_entry() {
566    let placed = Sequence::new()
567      .add(leg(0.0, 10.0))
568      .label("settled", crate::At::End);
569    let s = sequence![label("settled", 50) => leg(10.0, 20.0)];
570    // The macro can only read labels a builder placed, so the two halves
571    // meet here rather than inside one block.
572    assert_eq!(s.len(), 1);
573    assert_eq!(
574      placed.resolve(&crate::At::Label("settled".into(), 0.0)),
575      100.0
576    );
577  }
578
579  #[test]
580  fn a_trailing_comma_is_fine_either_way() {
581    assert_eq!(sequence![leg(0.0, 1.0)].len(), 1);
582    assert_eq!(sequence![leg(0.0, 1.0),].len(), 1);
583    assert_eq!(sequence![].len(), 0);
584  }
585}