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)]
42pub struct Progress {
43    #[serde(default)]
44    pub progress: f64,
45    #[serde(default)]
46    pub variant: ProgressVariant,
47    #[serde(default = "default_progress_width")]
48    pub width: f32,
49    #[serde(default = "default_progress_height")]
50    pub height: f32,
51    #[serde(default = "default_progress_bg")]
52    pub background_color: String,
53    #[serde(default = "default_progress_fill")]
54    pub fill_color: String,
55    #[serde(default)]
56    pub border_radius: f32,
57    #[serde(default = "default_track_width")]
58    pub track_width: f32,
59    #[serde(default)]
60    pub show_value: bool,
61    #[serde(flatten)]
62    pub timing: TimingConfig,
63    #[serde(default)]
64    pub style: CssStyle,
65    #[serde(default)]
66    pub timeline: Vec<TimelineStep>,
67    #[serde(default)]
68    pub stagger: Option<f32>,
69}
70
71rustmotion_core::impl_traits!(Progress {
72    Animatable => animation,
73    Timed => timing,
74    Styled => style,
75});
76
77impl Progress {
78    fn paint(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> {
79        match self.variant {
80            ProgressVariant::Linear => self.render_linear(canvas, w, h),
81            ProgressVariant::Circular => self.render_circular(canvas, w, h),
82        }
83    }
84}
85
86impl Painter for Progress {
87    fn paint_content(
88        &self,
89        canvas: &Canvas,
90        layout: &BoxLayout,
91        _props: &AnimatedProperties,
92        _ctx: &PaintCtx,
93    ) {
94        // `self.width`/`self.height` only seed the *intrinsic* size in
95        // `box_builder` (promoted to CSS when `style.width`/`style.height`
96        // are absent) — the box taffy actually assigns can differ whenever
97        // an author sets `style.width`/`style.height` or a flex-grow
98        // idiom directly, which `html-css-mental-model.md` recommends.
99        // Painting at `self.width`/`self.height` regardless left the fill
100        // sized to whichever one happened to be smaller, filling only part
101        // of its own box (or overflowing it) instead of the box.
102        let _ = self.paint(canvas, layout.width, layout.height);
103    }
104}
105
106impl Progress {
107    fn render_linear(&self, canvas: &Canvas, w: f32, h: f32) -> Result<()> {
108        let radius = self.border_radius;
109        let progress = self.progress.clamp(0.0, 1.0) as f32;
110
111        // Background
112        let mut bg_paint = skia_safe::Paint::new(color4f_from_hex(&self.background_color), None);
113        bg_paint.set_style(PaintStyle::Fill);
114        bg_paint.set_anti_alias(true);
115
116        let bg_rect = Rect::from_xywh(0.0, 0.0, w, h);
117        let bg_rrect = RRect::new_rect_xy(bg_rect, radius, radius);
118        canvas.draw_rrect(bg_rrect, &bg_paint);
119
120        // Fill (progress)
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        // Track
147        let mut track_paint = paint_from_hex(&self.background_color);
148        track_paint.set_style(PaintStyle::Stroke);
149        track_paint.set_stroke_width(self.track_width);
150        track_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
151        track_paint.set_anti_alias(true);
152        canvas.draw_arc(oval, 0.0, 360.0, false, &track_paint);
153
154        // Fill arc
155        if progress > 0.001 {
156            let sweep = 360.0 * progress;
157            let mut fill_paint = paint_from_hex(&self.fill_color);
158            fill_paint.set_style(PaintStyle::Stroke);
159            fill_paint.set_stroke_width(self.track_width);
160            fill_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
161            fill_paint.set_anti_alias(true);
162            canvas.draw_arc(oval, -90.0, sweep, false, &fill_paint);
163        }
164
165        // Value text
166        if self.show_value {
167            let text = format!("{}%", (progress * 100.0).round() as i32);
168            let font_size = (radius * 0.5).max(10.0);
169            let font_style = skia_safe::FontStyle::bold();
170            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
171                return Ok(());
172            };
173            let font = skia_safe::Font::from_typeface(typeface, font_size);
174            let emoji_font =
175                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
176
177            let mut text_paint = paint_from_hex(&self.fill_color);
178            text_paint.set_anti_alias(true);
179
180            let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
181            let (_, metrics) = font.metrics();
182            let text_x = cx - text_w / 2.0;
183            let text_y = cy + (-metrics.ascent) / 2.0;
184            draw_text_with_fallback(
185                canvas,
186                &text,
187                &font,
188                &emoji_font,
189                0.0,
190                text_x,
191                text_y,
192                &text_paint,
193            );
194        }
195
196        Ok(())
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn base_progress(variant: ProgressVariant) -> Progress {
205        Progress {
206            progress: 0.5,
207            variant,
208            width: default_progress_width(),
209            height: default_progress_height(),
210            background_color: default_progress_bg(),
211            fill_color: default_progress_fill(),
212            border_radius: 0.0,
213            track_width: default_track_width(),
214            show_value: false,
215            timing: TimingConfig::default(),
216            style: CssStyle::default(),
217            timeline: Vec::new(),
218            stagger: None,
219        }
220    }
221
222    fn base_ctx() -> PaintCtx {
223        PaintCtx {
224            time: 0.0,
225            scenario_time: 0.0,
226            scene_duration: 2.0,
227            frame_index: 0,
228            fps: 30,
229            video_width: 900,
230            video_height: 200,
231            stagger_offset: 0.0,
232        }
233    }
234
235    fn ink_bounds(
236        surface: &mut skia_safe::Surface,
237        w: i32,
238        h: i32,
239    ) -> Option<(i32, i32, i32, i32)> {
240        let snapshot = surface.image_snapshot();
241        let info = skia_safe::ImageInfo::new(
242            (w, h),
243            skia_safe::ColorType::RGBA8888,
244            skia_safe::AlphaType::Premul,
245            None,
246        );
247        let mut buf = vec![0u8; (w * h * 4) as usize];
248        snapshot.read_pixels(
249            &info,
250            &mut buf,
251            (w * 4) as usize,
252            skia_safe::IPoint::new(0, 0),
253            skia_safe::image::CachingHint::Disallow,
254        );
255        let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
256        for y in 0..h {
257            for x in 0..w {
258                if buf[((y * w + x) * 4 + 3) as usize] > 0 {
259                    minx = minx.min(x);
260                    maxx = maxx.max(x);
261                    miny = miny.min(y);
262                    maxy = maxy.max(y);
263                }
264            }
265        }
266        (minx <= maxx).then_some((minx, maxx, miny, maxy))
267    }
268
269    #[test]
270    fn linear_progress_fills_the_layout_box_not_its_own_width_height() {
271        // #4's exact repro: `render_linear` always drew at `self.width` x
272        // `self.height` (defaults 300x20) regardless of the box taffy
273        // actually assigned it. `box_builder` only promotes `c.width`/
274        // `c.height` to CSS when `style.width`/`style.height` are absent —
275        // so a `progress` sized via `style.width: 800` (the project's
276        // CSS-first idiom) painted a 300px-wide bar sitting inside an
277        // 800px-wide box, filling only 37% of it at `progress: 0.5`.
278        let progress = base_progress(ProgressVariant::Linear);
279        const BOX_W: f32 = 800.0;
280        const BOX_H: f32 = 24.0;
281        let layout = BoxLayout {
282            width: BOX_W,
283            height: BOX_H,
284            ..Default::default()
285        };
286        let ctx = base_ctx();
287        let props = AnimatedProperties::default();
288
289        let mut surface =
290            skia_safe::surfaces::raster_n32_premul((900, 200)).expect("raster surface");
291        {
292            let canvas = surface.canvas();
293            progress.paint_content(canvas, &layout, &props, &ctx);
294        }
295        let (_minx, maxx, _miny, _maxy) =
296            ink_bounds(&mut surface, 900, 200).expect("progress must paint something");
297        // At progress 0.5 the fill should reach roughly the middle of the
298        // 800px box (~400px), not the middle of the component's own
299        // `width` field (300px -> 150px).
300        assert!(
301            maxx as f32 > BOX_W * 0.4,
302            "fill did not scale to the box's own width: max ink x = {maxx}, box width = {BOX_W}"
303        );
304    }
305
306    #[test]
307    fn circular_progress_fits_the_layout_box_not_its_own_width_height() {
308        let progress = base_progress(ProgressVariant::Circular);
309        const BOX_W: f32 = 60.0;
310        const BOX_H: f32 = 60.0;
311        let layout = BoxLayout {
312            width: BOX_W,
313            height: BOX_H,
314            ..Default::default()
315        };
316        let ctx = base_ctx();
317        let props = AnimatedProperties::default();
318
319        let mut surface =
320            skia_safe::surfaces::raster_n32_premul((300, 300)).expect("raster surface");
321        {
322            let canvas = surface.canvas();
323            progress.paint_content(canvas, &layout, &props, &ctx);
324        }
325        let (minx, maxx, miny, maxy) =
326            ink_bounds(&mut surface, 300, 300).expect("progress must paint something");
327        // The ring must be centered on the 60x60 box's own center (30, 30),
328        // not on the component's own `width`/`height` fields' center
329        // (150, 10 for the 300x20 defaults) — the un-fixed painter puts the
330        // whole ring outside a small box entirely.
331        let center_x = (minx + maxx) as f32 / 2.0;
332        let center_y = (miny + maxy) as f32 / 2.0;
333        assert!(
334            (center_x - BOX_W / 2.0).abs() < 5.0 && (center_y - BOX_H / 2.0).abs() < 5.0,
335            "ring is not centered on the {BOX_W}x{BOX_H} box: center=({center_x}, {center_y})"
336        );
337    }
338}