core_animation/animation_builder.rs
1//! GPU-accelerated animations using `CABasicAnimation`.
2//!
3//! Animations run on the compositor thread, not the main thread.
4//! No manual loop required - just configure and show.
5//!
6//! # Quick Start
7//!
8//! ```ignore
9//! let layer = CAShapeLayerBuilder::new()
10//! .path(circle_path)
11//! .fill_color(Color::RED)
12//! .animate("pulse", KeyPath::TransformScale, |a| {
13//! a.values(0.85, 1.15)
14//! .duration(800.millis())
15//! .easing(Easing::InOut)
16//! .autoreverses()
17//! .repeat(Repeat::Forever)
18//! })
19//! .build();
20//!
21//! window.container().add_sublayer(&layer);
22//! window.show_for(10.seconds()); // No animation loop needed
23//! ```
24//!
25//! # Builder API
26//!
27//! ## `CABasicAnimationBuilder`
28//!
29//! Configures a from→to animation on a single property.
30//!
31//! | Method | Description |
32//! |--------|-------------|
33//! | `.values(from, to)` | Numeric from/to values (f64) |
34//! | `.values_point(from, to)` | CGPoint from/to |
35//! | `.values_color(from, to)` | Color from/to |
36//! | `.duration(Duration)` | Animation cycle duration |
37//! | `.easing(Easing)` | Timing curve (default: `InOut`) |
38//! | `.autoreverses()` | Ping-pong animation |
39//! | `.repeat(Repeat)` | Repeat behavior (default: `Once`) |
40//! | `.phase_offset(f64)` | Start at fraction of cycle (0.0-1.0) |
41//!
42//! ## `KeyPath`
43//!
44//! Property to animate. Common paths:
45//!
46//! | Constant | Animates |
47//! |----------|----------|
48//! | `TransformScale` | Uniform scale (0.0 = invisible, 1.0 = normal) |
49//! | `TransformScaleX` | Horizontal scale |
50//! | `TransformScaleY` | Vertical scale |
51//! | `TransformRotation` | Z-axis rotation (radians) |
52//! | `Opacity` | Alpha (0.0 = transparent, 1.0 = opaque) |
53//! | `Position` | Layer center point (CGPoint) |
54//! | `PositionX` | Horizontal position |
55//! | `PositionY` | Vertical position |
56//! | `BackgroundColor` | Fill color (CGColor) |
57//! | `CornerRadius` | Corner rounding |
58//! | `BorderWidth` | Border thickness |
59//!
60//! ## `Easing`
61//!
62//! Timing curves for animation interpolation:
63//!
64//! | Variant | Behavior |
65//! |---------|----------|
66//! | `Linear` | Constant speed |
67//! | `In` | Slow start, fast end |
68//! | `Out` | Fast start, slow end |
69//! | `InOut` | Slow start and end (default) |
70//!
71//! ## `Repeat`
72//!
73//! How many times the animation plays:
74//!
75//! | Variant | Behavior |
76//! |---------|----------|
77//! | `Once` | Play once, hold final value (default) |
78//! | `Times(n)` | Play n times, hold final value |
79//! | `Forever` | Loop indefinitely |
80//!
81//! # Default Behaviors
82//!
83//! **Values persist after animation ends.** Unlike raw `CABasicAnimation`,
84//! this builder defaults to `fillMode = forwards` and `removedOnCompletion = false`.
85//! The layer stays at the final animated value.
86//!
87//! To opt-in to snap-back behavior:
88//!
89//! ```ignore
90//! a.values(0.0, 1.0)
91//! .remove_on_completion() // Snap back to original value
92//! ```
93//!
94//! # Integration with Layer Builders
95//!
96//! The `.animate()` method is available on:
97//! - `CALayerBuilder`
98//! - `CAShapeLayerBuilder`
99//!
100//! Multiple animations can be added to a single layer:
101//!
102//! ```ignore
103//! CAShapeLayerBuilder::new()
104//! .fill_color(Color::RED)
105//! .animate("pulse", KeyPath::TransformScale, |a| {
106//! a.values(0.9, 1.1).duration(500.millis()).repeat(Repeat::Forever)
107//! })
108//! .animate("fade", KeyPath::Opacity, |a| {
109//! a.values(1.0, 0.7).duration(1.seconds()).repeat(Repeat::Forever)
110//! })
111//! .build()
112//! ```
113//!
114//! # Standalone Usage
115//!
116//! Animations can also be created and added manually:
117//!
118//! ```ignore
119//! let anim = CABasicAnimationBuilder::new(KeyPath::TransformScale)
120//! .values(0.85, 1.15)
121//! .duration(800.millis())
122//! .autoreverses()
123//! .repeat(Repeat::Forever)
124//! .build();
125//!
126//! layer.add_animation(&anim, "pulse");
127//! ```
128//!
129//! # Future: Other Animation Types
130//!
131//! The closure pattern allows different builders for different animation types:
132//!
133//! ```ignore
134//! // Keyframe animation (multiple values)
135//! .animate_keyframes("bounce", KeyPath::PositionY, |a| {
136//! a.values([100.0, 50.0, 80.0, 60.0, 70.0])
137//! .key_times([0.0, 0.3, 0.5, 0.7, 1.0])
138//! })
139//!
140//! // Spring animation (physics-based)
141//! .animate_spring("snap", KeyPath::Position, |a| {
142//! a.to_point(200.0, 200.0)
143//! .damping(10.0)
144//! .stiffness(100.0)
145//! })
146//! ```
147//!
148//! The user-facing API pattern remains consistent; only the builder inside
149//! the closure changes.
150
151use std::time::Duration;
152
153use objc2::rc::Retained;
154use objc2_foundation::{NSNumber, NSString};
155use objc2_quartz_core::{
156 kCAFillModeForwards, kCAMediaTimingFunctionEaseIn, kCAMediaTimingFunctionEaseInEaseOut,
157 kCAMediaTimingFunctionEaseOut, kCAMediaTimingFunctionLinear, CABasicAnimation, CAMediaTiming,
158 CAMediaTimingFunction,
159};
160
161/// Property key path for animation targets.
162///
163/// Each variant maps to a Core Animation key path string that identifies
164/// which property of a `CALayer` to animate.
165///
166/// # Examples
167///
168/// ```ignore
169/// // Animate scale
170/// let anim = CABasicAnimationBuilder::new(KeyPath::TransformScale)
171/// .values(0.5, 1.0)
172/// .build();
173///
174/// // Animate position
175/// let anim = CABasicAnimationBuilder::new(KeyPath::PositionX)
176/// .values(0.0, 100.0)
177/// .build();
178/// ```
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
180pub enum KeyPath {
181 /// Uniform scale transform (`transform.scale`).
182 /// Value range: 0.0 = invisible, 1.0 = normal size.
183 TransformScale,
184 /// Horizontal scale transform (`transform.scale.x`).
185 TransformScaleX,
186 /// Vertical scale transform (`transform.scale.y`).
187 TransformScaleY,
188 /// Z-axis rotation transform (`transform.rotation.z`).
189 /// Value is in radians.
190 TransformRotation,
191 /// Layer opacity (`opacity`).
192 /// Value range: 0.0 = transparent, 1.0 = opaque.
193 Opacity,
194 /// Layer center position (`position`).
195 /// Value type: CGPoint.
196 Position,
197 /// Horizontal position (`position.x`).
198 PositionX,
199 /// Vertical position (`position.y`).
200 PositionY,
201 /// Layer background color (`backgroundColor`).
202 /// Value type: CGColor.
203 BackgroundColor,
204 /// Corner radius (`cornerRadius`).
205 CornerRadius,
206 /// Border width (`borderWidth`).
207 BorderWidth,
208 /// Border color (`borderColor`).
209 /// Value type: CGColor.
210 BorderColor,
211 /// Shadow opacity (`shadowOpacity`).
212 ShadowOpacity,
213 /// Shadow radius (`shadowRadius`).
214 ShadowRadius,
215 /// Shadow offset (`shadowOffset`).
216 /// Value type: CGSize.
217 ShadowOffset,
218 /// Bounds rectangle (`bounds`).
219 /// Value type: CGRect.
220 Bounds,
221 /// Custom key path string.
222 Custom(&'static str),
223}
224
225impl KeyPath {
226 /// Returns the Core Animation key path string for this property.
227 ///
228 /// # Examples
229 ///
230 /// ```ignore
231 /// assert_eq!(KeyPath::TransformScale.as_str(), "transform.scale");
232 /// assert_eq!(KeyPath::Opacity.as_str(), "opacity");
233 /// ```
234 #[must_use]
235 pub const fn as_str(&self) -> &'static str {
236 match self {
237 KeyPath::TransformScale => "transform.scale",
238 KeyPath::TransformScaleX => "transform.scale.x",
239 KeyPath::TransformScaleY => "transform.scale.y",
240 KeyPath::TransformRotation => "transform.rotation.z",
241 KeyPath::Opacity => "opacity",
242 KeyPath::Position => "position",
243 KeyPath::PositionX => "position.x",
244 KeyPath::PositionY => "position.y",
245 KeyPath::BackgroundColor => "backgroundColor",
246 KeyPath::CornerRadius => "cornerRadius",
247 KeyPath::BorderWidth => "borderWidth",
248 KeyPath::BorderColor => "borderColor",
249 KeyPath::ShadowOpacity => "shadowOpacity",
250 KeyPath::ShadowRadius => "shadowRadius",
251 KeyPath::ShadowOffset => "shadowOffset",
252 KeyPath::Bounds => "bounds",
253 KeyPath::Custom(s) => s,
254 }
255 }
256
257 /// Creates an `NSString` for this key path.
258 ///
259 /// This is used internally when constructing `CABasicAnimation`.
260 fn to_nsstring(self) -> Retained<NSString> {
261 NSString::from_str(self.as_str())
262 }
263}
264
265/// Timing curve for animation interpolation.
266///
267/// Controls how the animation progresses over time. The default is `InOut`
268/// which provides smooth acceleration and deceleration.
269///
270/// # Examples
271///
272/// ```ignore
273/// // Linear motion (constant speed)
274/// builder.easing(Easing::Linear)
275///
276/// // Smooth start and end (default)
277/// builder.easing(Easing::InOut)
278/// ```
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
280pub enum Easing {
281 /// Constant speed throughout the animation.
282 Linear,
283 /// Slow start, accelerating to full speed.
284 In,
285 /// Fast start, decelerating to a stop.
286 Out,
287 /// Slow start and end with acceleration in the middle (default).
288 #[default]
289 InOut,
290}
291
292impl Easing {
293 /// Creates the corresponding `CAMediaTimingFunction` for this easing curve.
294 fn to_timing_function(self) -> Retained<CAMediaTimingFunction> {
295 // SAFETY: The timing function name constants are valid extern statics
296 // that are always available on macOS.
297 let name = unsafe {
298 match self {
299 Easing::Linear => kCAMediaTimingFunctionLinear,
300 Easing::In => kCAMediaTimingFunctionEaseIn,
301 Easing::Out => kCAMediaTimingFunctionEaseOut,
302 Easing::InOut => kCAMediaTimingFunctionEaseInEaseOut,
303 }
304 };
305 CAMediaTimingFunction::functionWithName(name)
306 }
307}
308
309/// Repeat behavior for animations.
310///
311/// Controls how many times the animation plays before stopping.
312///
313/// # Examples
314///
315/// ```ignore
316/// // Play once and hold (default)
317/// builder.repeat(Repeat::Once)
318///
319/// // Play 3 times
320/// builder.repeat(Repeat::Times(3))
321///
322/// // Loop forever
323/// builder.repeat(Repeat::Forever)
324/// ```
325#[derive(Debug, Clone, Copy, PartialEq, Default)]
326pub enum Repeat {
327 /// Play the animation once and hold the final value (default).
328 #[default]
329 Once,
330 /// Play the animation a specific number of times.
331 Times(u32),
332 /// Loop the animation indefinitely.
333 Forever,
334}
335
336impl Repeat {
337 /// Returns the `repeatCount` value for Core Animation.
338 ///
339 /// - `Once` returns 1.0 (play once)
340 /// - `Times(n)` returns n as f32
341 /// - `Forever` returns `f32::INFINITY`
342 fn to_repeat_count(self) -> f32 {
343 match self {
344 Repeat::Once => 1.0,
345 Repeat::Times(n) => n as f32,
346 Repeat::Forever => f32::INFINITY,
347 }
348 }
349}
350
351/// Builder for configuring `CABasicAnimation` instances.
352///
353/// Creates from→to animations on a single property. The builder uses
354/// sensible defaults:
355/// - `fillMode = forwards` (values persist after animation)
356/// - `removedOnCompletion = false` (animation stays attached)
357/// - `easing = InOut` (smooth acceleration/deceleration)
358/// - `repeat = Once` (play once)
359///
360/// # Examples
361///
362/// ```ignore
363/// // Simple pulse animation
364/// let anim = CABasicAnimationBuilder::new(KeyPath::TransformScale)
365/// .values(0.85, 1.15)
366/// .duration(800.millis())
367/// .autoreverses()
368/// .repeat(Repeat::Forever)
369/// .build();
370///
371/// layer.addAnimation_forKey(&anim, Some(ns_string!("pulse")));
372/// ```
373///
374/// # Default Behaviors
375///
376/// By default, the animation's final value persists on the layer after
377/// completion. This differs from raw `CABasicAnimation` which snaps back
378/// to the original value. Call `.remove_on_completion()` to opt-in to
379/// snap-back behavior.
380pub struct CABasicAnimationBuilder {
381 key_path: KeyPath,
382 from_value: Option<f64>,
383 to_value: Option<f64>,
384 duration: Duration,
385 easing: Easing,
386 autoreverses: bool,
387 repeat: Repeat,
388 phase_offset: f64,
389 remove_on_completion: bool,
390}
391
392impl CABasicAnimationBuilder {
393 /// Creates a new animation builder for the specified property.
394 ///
395 /// # Arguments
396 ///
397 /// * `key_path` - The property to animate (e.g., `KeyPath::TransformScale`)
398 ///
399 /// # Examples
400 ///
401 /// ```ignore
402 /// let builder = CABasicAnimationBuilder::new(KeyPath::Opacity);
403 /// ```
404 #[must_use]
405 pub fn new(key_path: KeyPath) -> Self {
406 Self {
407 key_path,
408 from_value: None,
409 to_value: None,
410 duration: Duration::from_millis(250),
411 easing: Easing::default(),
412 autoreverses: false,
413 repeat: Repeat::default(),
414 phase_offset: 0.0,
415 remove_on_completion: false,
416 }
417 }
418
419 /// Sets the from and to values for the animation.
420 ///
421 /// The values are interpreted based on the key path:
422 /// - Scale properties: 0.0 = invisible, 1.0 = normal
423 /// - Opacity: 0.0 = transparent, 1.0 = opaque
424 /// - Rotation: radians
425 /// - Position: points
426 ///
427 /// # Arguments
428 ///
429 /// * `from` - Starting value
430 /// * `to` - Ending value
431 ///
432 /// # Examples
433 ///
434 /// ```ignore
435 /// // Scale from 85% to 115%
436 /// builder.values(0.85, 1.15)
437 ///
438 /// // Fade from fully opaque to 70% opacity
439 /// builder.values(1.0, 0.7)
440 /// ```
441 #[must_use]
442 pub fn values(mut self, from: f64, to: f64) -> Self {
443 self.from_value = Some(from);
444 self.to_value = Some(to);
445 self
446 }
447
448 /// Sets the duration of one animation cycle.
449 ///
450 /// # Arguments
451 ///
452 /// * `duration` - The time for one complete animation cycle
453 ///
454 /// # Examples
455 ///
456 /// ```ignore
457 /// use std::time::Duration;
458 ///
459 /// builder.duration(Duration::from_millis(800))
460 ///
461 /// // Or with DurationExt:
462 /// builder.duration(800.millis())
463 /// builder.duration(1.5.seconds())
464 /// ```
465 #[must_use]
466 pub fn duration(mut self, duration: Duration) -> Self {
467 self.duration = duration;
468 self
469 }
470
471 /// Sets the easing curve for the animation.
472 ///
473 /// The default is `Easing::InOut` which provides smooth acceleration
474 /// at the start and deceleration at the end.
475 ///
476 /// # Arguments
477 ///
478 /// * `easing` - The timing curve to use
479 ///
480 /// # Examples
481 ///
482 /// ```ignore
483 /// // Constant speed
484 /// builder.easing(Easing::Linear)
485 ///
486 /// // Quick start, slow end
487 /// builder.easing(Easing::Out)
488 /// ```
489 #[must_use]
490 pub fn easing(mut self, easing: Easing) -> Self {
491 self.easing = easing;
492 self
493 }
494
495 /// Enables ping-pong animation (play forward then backward).
496 ///
497 /// When combined with `repeat(Repeat::Forever)`, creates a smooth
498 /// oscillating animation.
499 ///
500 /// # Examples
501 ///
502 /// ```ignore
503 /// // Pulse that smoothly grows and shrinks
504 /// builder
505 /// .values(0.9, 1.1)
506 /// .autoreverses()
507 /// .repeat(Repeat::Forever)
508 /// ```
509 #[must_use]
510 pub fn autoreverses(mut self) -> Self {
511 self.autoreverses = true;
512 self
513 }
514
515 /// Sets the repeat behavior for the animation.
516 ///
517 /// # Arguments
518 ///
519 /// * `repeat` - How many times to play the animation
520 ///
521 /// # Examples
522 ///
523 /// ```ignore
524 /// // Play 3 times
525 /// builder.repeat(Repeat::Times(3))
526 ///
527 /// // Loop forever
528 /// builder.repeat(Repeat::Forever)
529 /// ```
530 #[must_use]
531 pub fn repeat(mut self, repeat: Repeat) -> Self {
532 self.repeat = repeat;
533 self
534 }
535
536 /// Sets the phase offset (starting point within the animation cycle).
537 ///
538 /// This allows multiple animations to be out of phase with each other,
539 /// creating interesting visual effects.
540 ///
541 /// # Arguments
542 ///
543 /// * `offset` - Fraction of the cycle to skip (0.0 to 1.0).
544 /// - 0.0 = start at beginning (default)
545 /// - 0.5 = start at midpoint
546 /// - 1.0 = start at end (same as beginning for looping animations)
547 ///
548 /// # Examples
549 ///
550 /// ```ignore
551 /// // Two circles pulsing out of phase
552 /// let anim1 = builder.clone().phase_offset(0.0).build();
553 /// let anim2 = builder.clone().phase_offset(0.5).build();
554 /// ```
555 #[must_use]
556 pub fn phase_offset(mut self, offset: f64) -> Self {
557 self.phase_offset = offset;
558 self
559 }
560
561 /// Opts in to snap-back behavior (remove animation on completion).
562 ///
563 /// By default, the animation's final value persists on the layer.
564 /// Calling this method causes the layer to snap back to its original
565 /// value when the animation completes.
566 ///
567 /// # Examples
568 ///
569 /// ```ignore
570 /// // Animation snaps back to original value when done
571 /// builder
572 /// .values(0.0, 1.0)
573 /// .repeat(Repeat::Times(3))
574 /// .remove_on_completion()
575 /// ```
576 #[must_use]
577 pub fn remove_on_completion(mut self) -> Self {
578 self.remove_on_completion = true;
579 self
580 }
581
582 /// Builds and returns the configured `CABasicAnimation`.
583 ///
584 /// # Returns
585 ///
586 /// A retained `CABasicAnimation` ready to be added to a layer.
587 ///
588 /// # Examples
589 ///
590 /// ```ignore
591 /// let anim = CABasicAnimationBuilder::new(KeyPath::TransformScale)
592 /// .values(0.85, 1.15)
593 /// .duration(800.millis())
594 /// .autoreverses()
595 /// .repeat(Repeat::Forever)
596 /// .build();
597 ///
598 /// layer.addAnimation_forKey(&anim, Some(ns_string!("pulse")));
599 /// ```
600 #[must_use]
601 pub fn build(self) -> Retained<CABasicAnimation> {
602 let key_path_str = self.key_path.to_nsstring();
603 let anim = CABasicAnimation::animationWithKeyPath(Some(&key_path_str));
604
605 // Set from/to values if provided
606 if let Some(from) = self.from_value {
607 let from_number = NSNumber::new_f64(from);
608 // SAFETY: NSNumber is a valid object type for fromValue
609 unsafe {
610 anim.setFromValue(Some(&from_number));
611 }
612 }
613 if let Some(to) = self.to_value {
614 let to_number = NSNumber::new_f64(to);
615 // SAFETY: NSNumber is a valid object type for toValue
616 unsafe {
617 anim.setToValue(Some(&to_number));
618 }
619 }
620
621 // Set timing properties (from CAMediaTiming trait)
622 let duration_secs = self.duration.as_secs_f64();
623 anim.setDuration(duration_secs);
624 anim.setAutoreverses(self.autoreverses);
625 anim.setRepeatCount(self.repeat.to_repeat_count());
626
627 // Set phase offset as timeOffset
628 // For autoreverses, multiply by 2 because the full cycle is forward + backward
629 if self.phase_offset > 0.0 {
630 let cycle_duration = if self.autoreverses {
631 duration_secs * 2.0
632 } else {
633 duration_secs
634 };
635 anim.setTimeOffset(self.phase_offset * cycle_duration);
636 }
637
638 // Set timing function (easing)
639 let timing_function = self.easing.to_timing_function();
640 anim.setTimingFunction(Some(&timing_function));
641
642 // Set fill mode and removedOnCompletion for value persistence
643 if self.remove_on_completion {
644 anim.setRemovedOnCompletion(true);
645 // Default fill mode is fine for removal
646 } else {
647 anim.setRemovedOnCompletion(false);
648 // SAFETY: kCAFillModeForwards is a valid extern static
649 anim.setFillMode(unsafe { kCAFillModeForwards });
650 }
651
652 anim
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 #[test]
661 fn test_key_path_as_str() {
662 assert_eq!(KeyPath::TransformScale.as_str(), "transform.scale");
663 assert_eq!(KeyPath::TransformScaleX.as_str(), "transform.scale.x");
664 assert_eq!(KeyPath::TransformScaleY.as_str(), "transform.scale.y");
665 assert_eq!(KeyPath::TransformRotation.as_str(), "transform.rotation.z");
666 assert_eq!(KeyPath::Opacity.as_str(), "opacity");
667 assert_eq!(KeyPath::Position.as_str(), "position");
668 assert_eq!(KeyPath::PositionX.as_str(), "position.x");
669 assert_eq!(KeyPath::PositionY.as_str(), "position.y");
670 assert_eq!(KeyPath::BackgroundColor.as_str(), "backgroundColor");
671 assert_eq!(KeyPath::CornerRadius.as_str(), "cornerRadius");
672 assert_eq!(KeyPath::BorderWidth.as_str(), "borderWidth");
673 assert_eq!(KeyPath::BorderColor.as_str(), "borderColor");
674 assert_eq!(KeyPath::ShadowOpacity.as_str(), "shadowOpacity");
675 assert_eq!(KeyPath::ShadowRadius.as_str(), "shadowRadius");
676 assert_eq!(KeyPath::ShadowOffset.as_str(), "shadowOffset");
677 assert_eq!(KeyPath::Bounds.as_str(), "bounds");
678 assert_eq!(KeyPath::Custom("custom.path").as_str(), "custom.path");
679 }
680
681 #[test]
682 fn test_easing_default() {
683 assert_eq!(Easing::default(), Easing::InOut);
684 }
685
686 #[test]
687 fn test_repeat_default() {
688 assert_eq!(Repeat::default(), Repeat::Once);
689 }
690
691 #[test]
692 fn test_repeat_to_count() {
693 assert_eq!(Repeat::Once.to_repeat_count(), 1.0);
694 assert_eq!(Repeat::Times(5).to_repeat_count(), 5.0);
695 assert!(Repeat::Forever.to_repeat_count().is_infinite());
696 }
697
698 #[test]
699 fn test_builder_defaults() {
700 let builder = CABasicAnimationBuilder::new(KeyPath::Opacity);
701 assert_eq!(builder.key_path, KeyPath::Opacity);
702 assert_eq!(builder.from_value, None);
703 assert_eq!(builder.to_value, None);
704 assert_eq!(builder.duration, Duration::from_millis(250));
705 assert_eq!(builder.easing, Easing::InOut);
706 assert!(!builder.autoreverses);
707 assert_eq!(builder.repeat, Repeat::Once);
708 assert_eq!(builder.phase_offset, 0.0);
709 assert!(!builder.remove_on_completion);
710 }
711
712 #[test]
713 fn test_builder_chaining() {
714 let builder = CABasicAnimationBuilder::new(KeyPath::TransformScale)
715 .values(0.5, 1.5)
716 .duration(Duration::from_secs(1))
717 .easing(Easing::Linear)
718 .autoreverses()
719 .repeat(Repeat::Forever)
720 .phase_offset(0.25)
721 .remove_on_completion();
722
723 assert_eq!(builder.key_path, KeyPath::TransformScale);
724 assert_eq!(builder.from_value, Some(0.5));
725 assert_eq!(builder.to_value, Some(1.5));
726 assert_eq!(builder.duration, Duration::from_secs(1));
727 assert_eq!(builder.easing, Easing::Linear);
728 assert!(builder.autoreverses);
729 assert_eq!(builder.repeat, Repeat::Forever);
730 assert_eq!(builder.phase_offset, 0.25);
731 assert!(builder.remove_on_completion);
732 }
733}