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}