Skip to main content

rustmotion_components/
progress.rs

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, 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 = skia_safe::Font::from_typeface(typeface, font_size);
171            let emoji_font =
172                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
173
174            let mut text_paint = paint_from_hex(&self.fill_color);
175            text_paint.set_anti_alias(true);
176
177            let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
178            let (_, metrics) = font.metrics();
179            let text_x = cx - text_w / 2.0;
180            let text_y = cy + (-metrics.ascent) / 2.0;
181            draw_text_with_fallback(
182                canvas,
183                &text,
184                &font,
185                &emoji_font,
186                0.0,
187                text_x,
188                text_y,
189                &text_paint,
190            );
191        }
192
193        Ok(())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn base_progress(variant: ProgressVariant) -> Progress {
202        Progress {
203            progress: 0.5,
204            variant,
205            width: default_progress_width(),
206            height: default_progress_height(),
207            background_color: default_progress_bg(),
208            fill_color: default_progress_fill(),
209            border_radius: 0.0,
210            track_width: default_track_width(),
211            show_value: false,
212            timing: TimingConfig::default(),
213            style: CssStyle::default(),
214            timeline: Vec::new(),
215            stagger: None,
216        }
217    }
218
219    fn base_ctx() -> PaintCtx {
220        PaintCtx {
221            time: 0.0,
222            scenario_time: 0.0,
223            scene_duration: 2.0,
224            frame_index: 0,
225            fps: 30,
226            video_width: 900,
227            video_height: 200,
228            stagger_offset: 0.0,
229        }
230    }
231
232    fn ink_bounds(
233        surface: &mut skia_safe::Surface,
234        w: i32,
235        h: i32,
236    ) -> Option<(i32, i32, i32, i32)> {
237        let snapshot = surface.image_snapshot();
238        let info = skia_safe::ImageInfo::new(
239            (w, h),
240            skia_safe::ColorType::RGBA8888,
241            skia_safe::AlphaType::Premul,
242            None,
243        );
244        let mut buf = vec![0u8; (w * h * 4) as usize];
245        snapshot.read_pixels(
246            &info,
247            &mut buf,
248            (w * 4) as usize,
249            skia_safe::IPoint::new(0, 0),
250            skia_safe::image::CachingHint::Disallow,
251        );
252        let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
253        for y in 0..h {
254            for x in 0..w {
255                if buf[((y * w + x) * 4 + 3) as usize] > 0 {
256                    minx = minx.min(x);
257                    maxx = maxx.max(x);
258                    miny = miny.min(y);
259                    maxy = maxy.max(y);
260                }
261            }
262        }
263        (minx <= maxx).then_some((minx, maxx, miny, maxy))
264    }
265
266    #[test]
267    fn linear_progress_fills_the_layout_box_not_its_own_width_height() {
268        let progress = base_progress(ProgressVariant::Linear);
269        const BOX_W: f32 = 800.0;
270        const BOX_H: f32 = 24.0;
271        let layout = BoxLayout {
272            width: BOX_W,
273            height: BOX_H,
274            ..Default::default()
275        };
276        let ctx = base_ctx();
277        let props = AnimatedProperties::default();
278
279        let mut surface =
280            skia_safe::surfaces::raster_n32_premul((900, 200)).expect("raster surface");
281        {
282            let canvas = surface.canvas();
283            progress.paint_content(canvas, &layout, &props, &ctx);
284        }
285        let (_minx, maxx, _miny, _maxy) =
286            ink_bounds(&mut surface, 900, 200).expect("progress must paint something");
287        assert!(
288            maxx as f32 > BOX_W * 0.4,
289            "fill did not scale to the box's own width: max ink x = {maxx}, box width = {BOX_W}"
290        );
291    }
292
293    #[test]
294    fn circular_progress_fits_the_layout_box_not_its_own_width_height() {
295        let progress = base_progress(ProgressVariant::Circular);
296        const BOX_W: f32 = 60.0;
297        const BOX_H: f32 = 60.0;
298        let layout = BoxLayout {
299            width: BOX_W,
300            height: BOX_H,
301            ..Default::default()
302        };
303        let ctx = base_ctx();
304        let props = AnimatedProperties::default();
305
306        let mut surface =
307            skia_safe::surfaces::raster_n32_premul((300, 300)).expect("raster surface");
308        {
309            let canvas = surface.canvas();
310            progress.paint_content(canvas, &layout, &props, &ctx);
311        }
312        let (minx, maxx, miny, maxy) =
313            ink_bounds(&mut surface, 300, 300).expect("progress must paint something");
314        let center_x = (minx + maxx) as f32 / 2.0;
315        let center_y = (miny + maxy) as f32 / 2.0;
316        assert!(
317            (center_x - BOX_W / 2.0).abs() < 5.0 && (center_y - BOX_H / 2.0).abs() < 5.0,
318            "ring is not centered on the {BOX_W}x{BOX_H} box: center=({center_x}, {center_y})"
319        );
320    }
321}