core_animation/
shape_layer_builder.rs

1//! Builder for `CAShapeLayer` (vector shape rendering).
2
3use crate::animation_builder::{CABasicAnimationBuilder, KeyPath};
4use crate::color::Color;
5use objc2::rc::Retained;
6use objc2_core_foundation::{CFRetained, CGFloat, CGPoint, CGRect, CGSize};
7use objc2_core_graphics::{CGColor, CGPath};
8use objc2_foundation::NSString;
9use objc2_quartz_core::{CABasicAnimation, CAShapeLayer, CATransform3D};
10
11/// A pending animation to be applied when the layer is built.
12struct PendingAnimation {
13    name: String,
14    animation: Retained<CABasicAnimation>,
15}
16
17/// Builder for `CAShapeLayer`.
18///
19/// # Basic Usage
20///
21/// ```ignore
22/// let shape = CAShapeLayerBuilder::new()
23///     .path(circle_path)
24///     .fill_color(Color::RED)
25///     .stroke_color(Color::WHITE)
26///     .line_width(2.0)
27///     .build();
28/// ```
29///
30/// # With Animations
31///
32/// Animations can be added inline using the `.animate()` method:
33///
34/// ```ignore
35/// let shape = CAShapeLayerBuilder::new()
36///     .path(circle_path)
37///     .fill_color(Color::RED)
38///     .animate("pulse", KeyPath::TransformScale, |a| {
39///         a.values(0.85, 1.15)
40///             .duration(800.millis())
41///             .easing(Easing::InOut)
42///             .autoreverses()
43///             .repeat(Repeat::Forever)
44///     })
45///     .build();
46/// ```
47///
48/// Multiple animations can be added:
49///
50/// ```ignore
51/// CAShapeLayerBuilder::new()
52///     .fill_color(Color::RED)
53///     .animate("pulse", KeyPath::TransformScale, |a| {
54///         a.values(0.9, 1.1).duration(500.millis()).repeat(Repeat::Forever)
55///     })
56///     .animate("fade", KeyPath::Opacity, |a| {
57///         a.values(1.0, 0.7).duration(1.seconds()).repeat(Repeat::Forever)
58///     })
59///     .build()
60/// ```
61#[derive(Default)]
62pub struct CAShapeLayerBuilder {
63    bounds: Option<CGRect>,
64    position: Option<CGPoint>,
65    path: Option<CFRetained<CGPath>>,
66    fill_color: Option<CFRetained<CGColor>>,
67    stroke_color: Option<CFRetained<CGColor>>,
68    line_width: Option<CGFloat>,
69    transform: Option<CATransform3D>,
70    hidden: Option<bool>,
71    opacity: Option<f32>,
72    // Shadow properties
73    shadow_color: Option<CFRetained<CGColor>>,
74    shadow_offset: Option<(f64, f64)>,
75    shadow_radius: Option<f64>,
76    shadow_opacity: Option<f32>,
77    // Simple transform shortcuts
78    scale: Option<f64>,
79    rotation: Option<f64>,
80    translation: Option<(f64, f64)>,
81    animations: Vec<PendingAnimation>,
82}
83
84impl CAShapeLayerBuilder {
85    /// Creates a new builder with default values.
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    /// Sets the bounds rectangle.
91    pub fn bounds(mut self, bounds: CGRect) -> Self {
92        self.bounds = Some(bounds);
93        self
94    }
95
96    /// Sets the position in superlayer coordinates.
97    pub fn position(mut self, position: CGPoint) -> Self {
98        self.position = Some(position);
99        self
100    }
101
102    /// Sets the path to render.
103    pub fn path(mut self, path: CFRetained<CGPath>) -> Self {
104        self.path = Some(path);
105        self
106    }
107
108    /// Creates a perfect circle path and sets appropriate bounds.
109    ///
110    /// This is a convenience method that wraps `CGPath::with_ellipse_in_rect`
111    /// and automatically sets the layer's bounds to match the circle size.
112    ///
113    /// # Arguments
114    ///
115    /// * `diameter` - The diameter of the circle
116    ///
117    /// # Examples
118    ///
119    /// ```ignore
120    /// // Create a circle with 80pt diameter
121    /// let circle = CAShapeLayerBuilder::new()
122    ///     .circle(80.0)
123    ///     .position(CGPoint::new(100.0, 100.0))
124    ///     .fill_color(Color::CYAN)
125    ///     .build();
126    /// ```
127    ///
128    /// # Notes
129    ///
130    /// This method sets both the path and bounds. If you call `.bounds()` or
131    /// `.path()` after `.circle()`, those values will override what was set
132    /// by `.circle()`.
133    ///
134    /// # See Also
135    ///
136    /// Use [`ellipse`](Self::ellipse) for non-circular ellipses.
137    pub fn circle(mut self, diameter: CGFloat) -> Self {
138        let rect = CGRect::new(CGPoint::ZERO, CGSize::new(diameter, diameter));
139        let path = unsafe { CGPath::with_ellipse_in_rect(rect, std::ptr::null()) };
140        self.path = Some(path);
141        self.bounds = Some(rect);
142        self
143    }
144
145    /// Creates an ellipse path and sets appropriate bounds.
146    ///
147    /// This is a convenience method that wraps `CGPath::with_ellipse_in_rect`
148    /// and automatically sets the layer's bounds to match the ellipse size.
149    ///
150    /// # Arguments
151    ///
152    /// * `width` - The width of the ellipse bounding box
153    /// * `height` - The height of the ellipse bounding box
154    ///
155    /// # Examples
156    ///
157    /// ```ignore
158    /// // Create an ellipse (oval shape)
159    /// let ellipse = CAShapeLayerBuilder::new()
160    ///     .ellipse(100.0, 60.0)  // wider than tall
161    ///     .position(CGPoint::new(100.0, 100.0))
162    ///     .fill_color(Color::RED)
163    ///     .build();
164    /// ```
165    ///
166    /// # Notes
167    ///
168    /// This method sets both the path and bounds. If you call `.bounds()` or
169    /// `.path()` after `.ellipse()`, those values will override what was set
170    /// by `.ellipse()`.
171    ///
172    /// # See Also
173    ///
174    /// Use [`circle`](Self::circle) for perfect circles (same width and height).
175    pub fn ellipse(mut self, width: CGFloat, height: CGFloat) -> Self {
176        let rect = CGRect::new(CGPoint::ZERO, CGSize::new(width, height));
177        let path = unsafe { CGPath::with_ellipse_in_rect(rect, std::ptr::null()) };
178        self.path = Some(path);
179        self.bounds = Some(rect);
180        self
181    }
182
183    /// Sets the fill color.
184    ///
185    /// Accepts any type that implements `Into<CFRetained<CGColor>>`, including:
186    /// - `Color::RED`, `Color::rgb(1.0, 0.0, 0.0)`
187    /// - `CFRetained<CGColor>` directly
188    pub fn fill_color(mut self, color: impl Into<CFRetained<CGColor>>) -> Self {
189        self.fill_color = Some(color.into());
190        self
191    }
192
193    /// Sets the fill color from RGBA values (0.0–1.0).
194    pub fn fill_rgba(mut self, r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) -> Self {
195        self.fill_color = Some(Color::rgba(r, g, b, a).into());
196        self
197    }
198
199    /// Sets the stroke color.
200    ///
201    /// Accepts any type that implements `Into<CFRetained<CGColor>>`, including:
202    /// - `Color::WHITE`, `Color::rgb(1.0, 1.0, 1.0)`
203    /// - `CFRetained<CGColor>` directly
204    pub fn stroke_color(mut self, color: impl Into<CFRetained<CGColor>>) -> Self {
205        self.stroke_color = Some(color.into());
206        self
207    }
208
209    /// Sets the stroke color from RGBA values (0.0–1.0).
210    pub fn stroke_rgba(mut self, r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) -> Self {
211        self.stroke_color = Some(Color::rgba(r, g, b, a).into());
212        self
213    }
214
215    /// Sets the stroke line width.
216    pub fn line_width(mut self, width: CGFloat) -> Self {
217        self.line_width = Some(width);
218        self
219    }
220
221    /// Sets the 3D transform.
222    pub fn transform(mut self, transform: CATransform3D) -> Self {
223        self.transform = Some(transform);
224        self
225    }
226
227    /// Sets whether the layer is hidden.
228    pub fn hidden(mut self, hidden: bool) -> Self {
229        self.hidden = Some(hidden);
230        self
231    }
232
233    /// Sets the opacity (0.0-1.0).
234    pub fn opacity(mut self, opacity: f32) -> Self {
235        self.opacity = Some(opacity);
236        self
237    }
238
239    // ========================================================================
240    // Shadow properties
241    // ========================================================================
242
243    /// Sets the shadow color.
244    ///
245    /// # Examples
246    ///
247    /// ```ignore
248    /// CAShapeLayerBuilder::new()
249    ///     .shadow_color(Color::BLACK)
250    ///     .shadow_radius(10.0)
251    ///     .shadow_opacity(0.5)
252    ///     .build();
253    /// ```
254    pub fn shadow_color(mut self, color: impl Into<CFRetained<CGColor>>) -> Self {
255        self.shadow_color = Some(color.into());
256        self
257    }
258
259    /// Sets the shadow offset (dx, dy).
260    ///
261    /// Positive `dx` moves the shadow right, positive `dy` moves it down.
262    ///
263    /// # Examples
264    ///
265    /// ```ignore
266    /// CAShapeLayerBuilder::new()
267    ///     .shadow_offset(0.0, 4.0)  // Shadow below
268    ///     .shadow_radius(8.0)
269    ///     .shadow_opacity(0.3)
270    ///     .build();
271    /// ```
272    pub fn shadow_offset(mut self, dx: f64, dy: f64) -> Self {
273        self.shadow_offset = Some((dx, dy));
274        self
275    }
276
277    /// Sets the shadow blur radius.
278    ///
279    /// Larger values create a softer, more diffuse shadow.
280    ///
281    /// # Examples
282    ///
283    /// ```ignore
284    /// CAShapeLayerBuilder::new()
285    ///     .shadow_radius(15.0)  // Soft glow effect
286    ///     .shadow_opacity(0.7)
287    ///     .build();
288    /// ```
289    pub fn shadow_radius(mut self, radius: f64) -> Self {
290        self.shadow_radius = Some(radius);
291        self
292    }
293
294    /// Sets the shadow opacity (0.0 to 1.0).
295    ///
296    /// # Examples
297    ///
298    /// ```ignore
299    /// CAShapeLayerBuilder::new()
300    ///     .shadow_color(Color::CYAN)
301    ///     .shadow_radius(10.0)
302    ///     .shadow_opacity(0.8)  // Bright glow
303    ///     .build();
304    /// ```
305    pub fn shadow_opacity(mut self, opacity: f32) -> Self {
306        self.shadow_opacity = Some(opacity);
307        self
308    }
309
310    // ========================================================================
311    // Simple transform shortcuts
312    // ========================================================================
313
314    /// Sets a uniform scale transform.
315    ///
316    /// This is applied using `CATransform3D` internally.
317    ///
318    /// # Examples
319    ///
320    /// ```ignore
321    /// // Scale to 80% of original size
322    /// CAShapeLayerBuilder::new()
323    ///     .scale(0.8)
324    ///     .build();
325    /// ```
326    ///
327    /// # Notes
328    ///
329    /// When multiple transform shortcuts are set, they are composed in order:
330    /// scale → rotation → translation.
331    ///
332    /// If you also call `.transform()`, the explicit transform takes
333    /// precedence and `scale`/`rotation`/`translate` are ignored.
334    pub fn scale(mut self, scale: f64) -> Self {
335        self.scale = Some(scale);
336        self
337    }
338
339    /// Sets a z-axis rotation transform (in radians).
340    ///
341    /// This is applied using `CATransform3D` internally.
342    /// For degrees, use: `.rotation(45.0_f64.to_radians())`
343    ///
344    /// # Examples
345    ///
346    /// ```ignore
347    /// use std::f64::consts::PI;
348    ///
349    /// // Rotate 45 degrees
350    /// CAShapeLayerBuilder::new()
351    ///     .rotation(PI / 4.0)
352    ///     .build();
353    ///
354    /// // Or using to_radians()
355    /// CAShapeLayerBuilder::new()
356    ///     .rotation(45.0_f64.to_radians())
357    ///     .build();
358    /// ```
359    ///
360    /// # Notes
361    ///
362    /// When multiple transform shortcuts are set, they are composed in order:
363    /// scale → rotation → translation.
364    ///
365    /// If you also call `.transform()`, the explicit transform takes
366    /// precedence and `scale`/`rotation`/`translate` are ignored.
367    pub fn rotation(mut self, radians: f64) -> Self {
368        self.rotation = Some(radians);
369        self
370    }
371
372    /// Sets a translation transform (dx, dy).
373    ///
374    /// This is applied using `CATransform3D` internally.
375    ///
376    /// # Examples
377    ///
378    /// ```ignore
379    /// // Move 10 points right and 20 points up
380    /// CAShapeLayerBuilder::new()
381    ///     .translate(10.0, 20.0)
382    ///     .build();
383    /// ```
384    ///
385    /// # Notes
386    ///
387    /// When multiple transform shortcuts are set, they are composed in order:
388    /// scale → rotation → translation.
389    ///
390    /// If you also call `.transform()`, the explicit transform takes
391    /// precedence and `scale`/`rotation`/`translate` are ignored.
392    pub fn translate(mut self, dx: f64, dy: f64) -> Self {
393        self.translation = Some((dx, dy));
394        self
395    }
396
397    /// Adds an animation to be applied when the layer is built.
398    ///
399    /// The animation is configured using a closure that receives a
400    /// [`CABasicAnimationBuilder`] and returns the configured builder.
401    ///
402    /// # Arguments
403    ///
404    /// * `name` - A unique identifier for this animation (used as the animation key)
405    /// * `key_path` - The property to animate (e.g., [`KeyPath::TransformScale`])
406    /// * `configure` - A closure that configures the animation builder
407    ///
408    /// # Examples
409    ///
410    /// ```ignore
411    /// // Simple pulse animation
412    /// CAShapeLayerBuilder::new()
413    ///     .path(circle_path)
414    ///     .fill_color(Color::RED)
415    ///     .animate("pulse", KeyPath::TransformScale, |a| {
416    ///         a.values(0.85, 1.15)
417    ///             .duration(800.millis())
418    ///             .easing(Easing::InOut)
419    ///             .autoreverses()
420    ///             .repeat(Repeat::Forever)
421    ///     })
422    ///     .build();
423    ///
424    /// // Multiple animations on the same layer
425    /// CAShapeLayerBuilder::new()
426    ///     .fill_color(Color::BLUE)
427    ///     .animate("scale", KeyPath::TransformScale, |a| {
428    ///         a.values(0.9, 1.1).duration(500.millis()).repeat(Repeat::Forever)
429    ///     })
430    ///     .animate("fade", KeyPath::Opacity, |a| {
431    ///         a.values(1.0, 0.5).duration(1.seconds()).repeat(Repeat::Forever)
432    ///     })
433    ///     .build();
434    /// ```
435    pub fn animate<F>(mut self, name: impl Into<String>, key_path: KeyPath, configure: F) -> Self
436    where
437        F: FnOnce(CABasicAnimationBuilder) -> CABasicAnimationBuilder,
438    {
439        let builder = CABasicAnimationBuilder::new(key_path);
440        let animation = configure(builder).build();
441        self.animations.push(PendingAnimation {
442            name: name.into(),
443            animation,
444        });
445        self
446    }
447
448    /// Builds and returns the configured `CAShapeLayer`.
449    ///
450    /// All pending animations added via `.animate()` are applied to the layer.
451    pub fn build(self) -> Retained<CAShapeLayer> {
452        let layer = CAShapeLayer::new();
453
454        if let Some(bounds) = self.bounds {
455            layer.setBounds(bounds);
456        }
457        if let Some(position) = self.position {
458            layer.setPosition(position);
459        }
460        if let Some(ref path) = self.path {
461            layer.setPath(Some(&**path));
462        }
463        if let Some(ref color) = self.fill_color {
464            layer.setFillColor(Some(&**color));
465        }
466        if let Some(ref color) = self.stroke_color {
467            layer.setStrokeColor(Some(&**color));
468        }
469        if let Some(width) = self.line_width {
470            layer.setLineWidth(width);
471        }
472
473        // Transform handling: explicit transform takes precedence over shortcuts
474        if let Some(transform) = self.transform {
475            layer.setTransform(transform);
476        } else if self.scale.is_some() || self.rotation.is_some() || self.translation.is_some() {
477            // Compose transforms in order: scale → rotation → translation
478            let mut transform = CATransform3D::new_scale(1.0, 1.0, 1.0); // identity
479
480            if let Some(s) = self.scale {
481                transform = CATransform3D::new_scale(s, s, 1.0);
482            }
483
484            if let Some(r) = self.rotation {
485                let rotation_transform = CATransform3D::new_rotation(r, 0.0, 0.0, 1.0);
486                transform = transform.concat(rotation_transform);
487            }
488
489            if let Some((dx, dy)) = self.translation {
490                let translation_transform = CATransform3D::new_translation(dx, dy, 0.0);
491                transform = transform.concat(translation_transform);
492            }
493
494            layer.setTransform(transform);
495        }
496
497        if let Some(hidden) = self.hidden {
498            layer.setHidden(hidden);
499        }
500        if let Some(opacity) = self.opacity {
501            layer.setOpacity(opacity);
502        }
503
504        // Apply shadow properties
505        if let Some(ref color) = self.shadow_color {
506            layer.setShadowColor(Some(&**color));
507        }
508        if let Some((dx, dy)) = self.shadow_offset {
509            layer.setShadowOffset(CGSize::new(dx, dy));
510        }
511        if let Some(radius) = self.shadow_radius {
512            layer.setShadowRadius(radius);
513        }
514        if let Some(opacity) = self.shadow_opacity {
515            layer.setShadowOpacity(opacity);
516        }
517
518        // Apply all pending animations
519        for pending in self.animations {
520            let key = NSString::from_str(&pending.name);
521            layer.addAnimation_forKey(&pending.animation, Some(&key));
522        }
523
524        layer
525    }
526}