Skip to main content

rustmotion_components/
shape.rs

1use rustmotion_core::error::Result;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use skia_safe::{Canvas, Paint, PaintStyle, Point};
5
6use rustmotion_core::css::CssStyle;
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10    build_shape_path, color4f_from_hex, draw_shape_path, draw_text_with_fallback, emoji_typeface,
11    measure_text_with_fallback, paint_from_hex, typeface_with_fallback, wrap_text_with_tracking,
12};
13use rustmotion_core::schema::{
14    Fill, FontWeight, GradientType, ShapeText, ShapeType, Stroke, TextAlign, TimelineStep,
15};
16use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
17
18#[derive(Debug, Serialize, Deserialize, JsonSchema)]
19pub struct Shape {
20    pub shape: ShapeType,
21    #[serde(default)]
22    pub text: Option<ShapeText>,
23    #[serde(flatten)]
24    pub timing: TimingConfig,
25    #[serde(default)]
26    pub style: CssStyle,
27    #[serde(default)]
28    pub timeline: Vec<TimelineStep>,
29    #[serde(default)]
30    pub stagger: Option<f32>,
31    #[serde(default)]
32    pub fill: Option<Fill>,
33    #[serde(default)]
34    pub stroke: Option<Stroke>,
35}
36
37rustmotion_core::impl_traits!(Shape {
38    Animatable => animation,
39    Timed => timing,
40    Styled => style,
41});
42
43impl Painter for Shape {
44    fn paint_content(
45        &self,
46        canvas: &Canvas,
47        layout: &BoxLayout,
48        props: &AnimatedProperties,
49        _ctx: &PaintCtx,
50    ) {
51        let w = layout.width;
52        let h = layout.height;
53        let corner_radius = self.style.border_radius_px();
54
55        if let Some(fill) = &self.fill {
56            let mut paint = match fill {
57                Fill::Solid(color) => paint_from_hex(color),
58                Fill::Gradient(gradient) => {
59                    let colors: Vec<skia_safe::Color4f> = gradient
60                        .colors
61                        .iter()
62                        .map(|c| color4f_from_hex(c))
63                        .collect();
64                    // skia asserts `pos.len() == colors.len()` inside the gradient
65                    // shader — a mismatch aborts the process instead of erroring.
66                    // Nothing upstream enforces the pairing, so drop stops we
67                    // cannot honour and let skia distribute the colours evenly.
68                    let stops: Option<Vec<f32>> = gradient
69                        .stops
70                        .as_ref()
71                        .filter(|s| s.len() == colors.len())
72                        .cloned();
73                    let mut paint = Paint::default();
74                    paint.set_anti_alias(true);
75
76                    let shader = match gradient.gradient_type {
77                        GradientType::Linear => {
78                            let angle = gradient.angle.unwrap_or(0.0);
79                            let rad = angle.to_radians();
80                            let cx = w / 2.0;
81                            let cy = h / 2.0;
82                            let dx = (w / 2.0) * rad.cos();
83                            let dy = (h / 2.0) * rad.sin();
84                            let start = Point::new(cx - dx, cy - dy);
85                            let end = Point::new(cx + dx, cy + dy);
86                            let gradient_colors = skia_safe::gradient::Colors::new(
87                                &colors,
88                                stops.as_deref(),
89                                skia_safe::TileMode::Clamp,
90                                Some(skia_safe::ColorSpace::new_srgb()),
91                            );
92                            let g = skia_safe::gradient::Gradient::new(
93                                gradient_colors,
94                                skia_safe::gradient::Interpolation::default(),
95                            );
96                            skia_safe::gradient::shaders::linear_gradient((start, end), &g, None)
97                        }
98                        GradientType::Radial => {
99                            let center = Point::new(w / 2.0, h / 2.0);
100                            let radius = w.max(h) / 2.0;
101                            let gradient_colors = skia_safe::gradient::Colors::new(
102                                &colors,
103                                stops.as_deref(),
104                                skia_safe::TileMode::Clamp,
105                                Some(skia_safe::ColorSpace::new_srgb()),
106                            );
107                            let g = skia_safe::gradient::Gradient::new(
108                                gradient_colors,
109                                skia_safe::gradient::Interpolation::default(),
110                            );
111                            skia_safe::gradient::shaders::radial_gradient(
112                                (center, radius),
113                                &g,
114                                None,
115                            )
116                        }
117                    };
118                    if let Some(shader) = shader {
119                        paint.set_shader(shader);
120                        paint.set_dither(true);
121                    }
122                    paint
123                }
124            };
125            paint.set_style(PaintStyle::Fill);
126            draw_shape_path(canvas, &self.shape, 0.0, 0.0, w, h, corner_radius, &paint);
127        }
128
129        if let Some(stroke) = &self.stroke {
130            let mut paint = paint_from_hex(&stroke.color);
131            paint.set_style(PaintStyle::Stroke);
132            let stroke_w = if props.stroke_width >= 0.0 {
133                props.stroke_width
134            } else {
135                stroke.width
136            };
137            paint.set_stroke_width(stroke_w);
138
139            if props.draw_progress >= 0.0 && props.draw_progress < 1.0 {
140                if let Some(path) = build_shape_path(&self.shape, 0.0, 0.0, w, h, corner_radius) {
141                    let mut measure = skia_safe::PathMeasure::new(&path, false, None);
142                    let path_len = measure.length();
143                    if path_len > 0.0 {
144                        let draw_len = path_len * props.draw_progress.clamp(0.0, 1.0);
145                        let intervals = [draw_len, path_len - draw_len + 0.01];
146                        if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) {
147                            paint.set_path_effect(dash);
148                        }
149                    }
150                }
151            }
152
153            draw_shape_path(canvas, &self.shape, 0.0, 0.0, w, h, corner_radius, &paint);
154        }
155
156        if let Some(text) = &self.text {
157            let _ = render_shape_text(canvas, text, 0.0, 0.0, w, h);
158        }
159    }
160}
161
162fn render_shape_text(
163    canvas: &Canvas,
164    text: &ShapeText,
165    shape_x: f32,
166    shape_y: f32,
167    shape_w: f32,
168    shape_h: f32,
169) -> Result<()> {
170    use rustmotion_core::schema::VerticalAlign;
171
172    let pad = text.padding.unwrap_or(0.0);
173    let area_x = shape_x + pad;
174    let area_y = shape_y + pad;
175    let area_w = shape_w - 2.0 * pad;
176    let area_h = shape_h - 2.0 * pad;
177
178    let font_style = match text.font_weight {
179        FontWeight::Bold => skia_safe::FontStyle::bold(),
180        FontWeight::Normal => skia_safe::FontStyle::normal(),
181        FontWeight::Weight(w) => skia_safe::FontStyle::new(
182            skia_safe::font_style::Weight::from(w as i32),
183            skia_safe::font_style::Width::NORMAL,
184            skia_safe::font_style::Slant::Upright,
185        ),
186    };
187
188    let typeface = typeface_with_fallback(&text.font_family, font_style)?;
189
190    let font = skia_safe::Font::from_typeface(typeface, text.font_size);
191    let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, text.font_size));
192    let (_strike_width, metrics) = font.metrics();
193    let ascent = -metrics.ascent;
194    let line_height = match text.line_height {
195        Some(v) if v <= 10.0 => text.font_size * v,
196        Some(v) => v,
197        None => text.font_size * 1.3,
198    };
199    let letter_spacing = text.letter_spacing.unwrap_or(0.0);
200
201    // Tracking-aware wrap (issue #125 §1): the fit test measures with the
202    // same `letter_spacing` used below for `line_width`/`draw_text_with_
203    // fallback`, so the wrap decision agrees with what's actually painted.
204    // `ShapeText`'s `font_size`/`letter_spacing`/`line_height` are plain
205    // `f32` (not `CssStyle`/`Length`), so issue #125 §2's relative-unit gap
206    // doesn't apply here — there is no unit string to resolve.
207    let lines = wrap_text_with_tracking(
208        &text.content,
209        &font,
210        &emoji_font,
211        Some(area_w),
212        letter_spacing,
213    );
214    let descent = metrics.descent;
215    let total_h = if lines.len() > 1 {
216        (lines.len() - 1) as f32 * line_height + ascent + descent
217    } else {
218        ascent + descent
219    };
220
221    let y_start = match text.vertical_align {
222        VerticalAlign::Top => area_y + ascent,
223        VerticalAlign::Middle => area_y + (area_h - total_h) / 2.0 + ascent,
224        VerticalAlign::Bottom => area_y + area_h - total_h + ascent,
225    };
226
227    let mut paint = paint_from_hex(&text.color);
228    paint.set_alpha_f(1.0);
229
230    for (i, line) in lines.iter().enumerate() {
231        if line.is_empty() {
232            continue;
233        }
234
235        let line_width = measure_text_with_fallback(line, &font, &emoji_font, letter_spacing);
236
237        let x = match text.align {
238            TextAlign::Left => area_x,
239            TextAlign::Center => area_x + (area_w - line_width) / 2.0,
240            TextAlign::Right => area_x + area_w - line_width,
241        };
242        let y = y_start + i as f32 * line_height;
243        draw_text_with_fallback(
244            canvas,
245            line,
246            &font,
247            &emoji_font,
248            letter_spacing,
249            x,
250            y,
251            &paint,
252        );
253    }
254
255    Ok(())
256}