1use rustmotion_core::css::CssStyle;
2use rustmotion_core::error::Result;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use skia_safe::{Canvas, PaintStyle, RRect, Rect};
6
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10 color4f_from_hex, draw_text_with_fallback, emoji_typeface, measure_text_with_fallback,
11 paint_from_hex, subpixel_font, typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16fn default_progress_width() -> f32 {
17 300.0
18}
19fn default_progress_height() -> f32 {
20 20.0
21}
22fn default_progress_bg() -> String {
23 "#333333".to_string()
24}
25fn default_progress_fill() -> String {
26 "#4CAF50".to_string()
27}
28fn default_track_width() -> f32 {
29 8.0
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
33#[serde(rename_all = "snake_case")]
34#[derive(Default)]
35pub enum ProgressVariant {
36 #[default]
37 Linear,
38 Circular,
39}
40
41#[derive(Debug, Serialize, Deserialize, JsonSchema)]
42#[deprecated(
43 since = "0.7.1",
44 note = "`progress` is a frozen composition (issue #333). Compose a track + fill from \
45 two `shape`s — a static track and a `rounded_rect` fill whose `style.width` \
46 is keyframed — instead. See \
47 crates/rustmotion/skills/rules/composition-recipes.md's progress-bar recipe \
48 and examples/composition-progress-bars.json. Kept for compatibility; \
49 scheduled for removal in a future major version via `rustmotion migrate` \
50 (#335)."
51)]
52pub struct Progress {
53 #[serde(default)]
54 pub progress: f64,
55 #[serde(default)]
56 pub variant: ProgressVariant,
57 #[serde(default = "default_progress_width")]
58 pub width: f32,
59 #[serde(default = "default_progress_height")]
60 pub height: f32,
61 #[serde(default = "default_progress_bg")]
62 pub background_color: String,
63 #[serde(default = "default_progress_fill")]
64 pub fill_color: String,
65 #[serde(default)]
66 pub border_radius: f32,
67 #[serde(default = "default_track_width")]
68 pub track_width: f32,
69 #[serde(default)]
70 pub show_value: bool,
71 #[serde(flatten)]
72 pub timing: TimingConfig,
73 #[serde(default)]
74 pub style: CssStyle,
75 #[serde(default)]
76 pub timeline: Vec<TimelineStep>,
77 #[serde(default)]
78 pub stagger: Option<f32>,
79}
80
81rustmotion_core::impl_traits!(Progress {
82 Animatable => animation,
83 Timed => timing,
84 Styled => style,
85});
86
87impl Progress {
88 fn paint(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> {
89 match self.variant {
90 ProgressVariant::Linear => self.render_linear(canvas, w, h),
91 ProgressVariant::Circular => self.render_circular(canvas, w, h),
92 }
93 }
94}
95
96impl Painter for Progress {
97 fn paint_content(
98 &self,
99 canvas: &Canvas,
100 layout: &BoxLayout,
101 _props: &AnimatedProperties,
102 _ctx: &PaintCtx,
103 ) {
104 let _ = self.paint(canvas, layout.width, layout.height);
105 }
106}
107
108impl Progress {
109 fn render_linear(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> {
110 let radius = self.border_radius;
111 let progress = self.progress.clamp(0.0, 1.0) as f32;
112
113 let mut bg_paint = skia_safe::Paint::new(color4f_from_hex(&self.background_color), None);
114 bg_paint.set_style(PaintStyle::Fill);
115 bg_paint.set_anti_alias(true);
116
117 let bg_rect = Rect::from_xywh(0.0, 0.0, w, h);
118 let bg_rrect = RRect::new_rect_xy(bg_rect, radius, radius);
119 canvas.draw_rrect(bg_rrect, &bg_paint);
120
121 if progress > 0.001 {
122 let mut fill_paint = skia_safe::Paint::new(color4f_from_hex(&self.fill_color), None);
123 fill_paint.set_style(PaintStyle::Fill);
124 fill_paint.set_anti_alias(true);
125
126 let fill_w = w * progress;
127 let fill_rect = Rect::from_xywh(0.0, 0.0, fill_w, h);
128
129 canvas.save();
130 canvas.clip_rrect(bg_rrect, skia_safe::ClipOp::Intersect, true);
131 canvas.draw_rect(fill_rect, &fill_paint);
132 canvas.restore();
133 }
134
135 Ok(())
136 }
137
138 fn render_circular(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> {
139 let progress = self.progress.clamp(0.0, 1.0) as f32;
140
141 let cx = w / 2.0;
142 let cy = h / 2.0;
143 let radius = (cx.min(cy) - self.track_width / 2.0 - 2.0).max(0.0);
144 let oval = Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0);
145
146 let mut track_paint = paint_from_hex(&self.background_color);
147 track_paint.set_style(PaintStyle::Stroke);
148 track_paint.set_stroke_width(self.track_width);
149 track_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
150 track_paint.set_anti_alias(true);
151 canvas.draw_arc(oval, 0.0, 360.0, false, &track_paint);
152
153 if progress > 0.001 {
154 let sweep = 360.0 * progress;
155 let mut fill_paint = paint_from_hex(&self.fill_color);
156 fill_paint.set_style(PaintStyle::Stroke);
157 fill_paint.set_stroke_width(self.track_width);
158 fill_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
159 fill_paint.set_anti_alias(true);
160 canvas.draw_arc(oval, -90.0, sweep, false, &fill_paint);
161 }
162
163 if self.show_value {
164 let text = format!("{}%", (progress * 100.0).round() as i32);
165 let font_size = (radius * 0.5).max(10.0);
166 let font_style = skia_safe::FontStyle::bold();
167 let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
168 return Ok(());
169 };
170 let font = subpixel_font(typeface, font_size);
171 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
172
173 let mut text_paint = paint_from_hex(&self.fill_color);
174 text_paint.set_anti_alias(true);
175
176 let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
177 let (_, metrics) = font.metrics();
178 let text_x = cx - text_w / 2.0;
179 let text_y = cy + (-metrics.ascent) / 2.0;
180 draw_text_with_fallback(
181 canvas,
182 &text,
183 &font,
184 &emoji_font,
185 0.0,
186 text_x,
187 text_y,
188 &text_paint,
189 );
190 }
191
192 Ok(())
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 fn base_progress(variant: ProgressVariant) -> Progress {
201 Progress {
202 progress: 0.5,
203 variant,
204 width: default_progress_width(),
205 height: default_progress_height(),
206 background_color: default_progress_bg(),
207 fill_color: default_progress_fill(),
208 border_radius: 0.0,
209 track_width: default_track_width(),
210 show_value: false,
211 timing: TimingConfig::default(),
212 style: CssStyle::default(),
213 timeline: Vec::new(),
214 stagger: None,
215 }
216 }
217
218 fn base_ctx() -> PaintCtx {
219 PaintCtx {
220 time: 0.0,
221 scenario_time: 0.0,
222 scene_duration: 2.0,
223 frame_index: 0,
224 fps: 30,
225 video_width: 900,
226 video_height: 200,
227 stagger_offset: 0.0,
228 }
229 }
230
231 fn ink_bounds(
232 surface: &mut skia_safe::Surface,
233 w: i32,
234 h: i32,
235 ) -> Option<(i32, i32, i32, i32)> {
236 let snapshot = surface.image_snapshot();
237 let info = skia_safe::ImageInfo::new(
238 (w, h),
239 skia_safe::ColorType::RGBA8888,
240 skia_safe::AlphaType::Premul,
241 None,
242 );
243 let mut buf = vec![0u8; (w * h * 4) as usize];
244 snapshot.read_pixels(
245 &info,
246 &mut buf,
247 (w * 4) as usize,
248 skia_safe::IPoint::new(0, 0),
249 skia_safe::image::CachingHint::Disallow,
250 );
251 let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
252 for y in 0..h {
253 for x in 0..w {
254 if buf[((y * w + x) * 4 + 3) as usize] > 0 {
255 minx = minx.min(x);
256 maxx = maxx.max(x);
257 miny = miny.min(y);
258 maxy = maxy.max(y);
259 }
260 }
261 }
262 (minx <= maxx).then_some((minx, maxx, miny, maxy))
263 }
264
265 #[test]
266 fn linear_progress_fills_the_layout_box_not_its_own_width_height() {
267 let progress = base_progress(ProgressVariant::Linear);
268 const BOX_W: f32 = 800.0;
269 const BOX_H: f32 = 24.0;
270 let layout = BoxLayout {
271 width: BOX_W,
272 height: BOX_H,
273 ..Default::default()
274 };
275 let ctx = base_ctx();
276 let props = AnimatedProperties::default();
277
278 let mut surface =
279 skia_safe::surfaces::raster_n32_premul((900, 200)).expect("raster surface");
280 {
281 let canvas = surface.canvas();
282 progress.paint_content(canvas, &layout, &props, &ctx);
283 }
284 let (_minx, maxx, _miny, _maxy) =
285 ink_bounds(&mut surface, 900, 200).expect("progress must paint something");
286 assert!(
287 maxx as f32 > BOX_W * 0.4,
288 "fill did not scale to the box's own width: max ink x = {maxx}, box width = {BOX_W}"
289 );
290 }
291
292 #[test]
293 fn circular_progress_fits_the_layout_box_not_its_own_width_height() {
294 let progress = base_progress(ProgressVariant::Circular);
295 const BOX_W: f32 = 60.0;
296 const BOX_H: f32 = 60.0;
297 let layout = BoxLayout {
298 width: BOX_W,
299 height: BOX_H,
300 ..Default::default()
301 };
302 let ctx = base_ctx();
303 let props = AnimatedProperties::default();
304
305 let mut surface =
306 skia_safe::surfaces::raster_n32_premul((300, 300)).expect("raster surface");
307 {
308 let canvas = surface.canvas();
309 progress.paint_content(canvas, &layout, &props, &ctx);
310 }
311 let (minx, maxx, miny, maxy) =
312 ink_bounds(&mut surface, 300, 300).expect("progress must paint something");
313 let center_x = (minx + maxx) as f32 / 2.0;
314 let center_y = (miny + maxy) as f32 / 2.0;
315 assert!(
316 (center_x - BOX_W / 2.0).abs() < 5.0 && (center_y - BOX_H / 2.0).abs() < 5.0,
317 "ring is not centered on the {BOX_W}x{BOX_H} box: center=({center_x}, {center_y})"
318 );
319 }
320}