Skip to main content

rustmotion_components/
gauge.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, Rect};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{
9    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
10    typeface_with_fallback,
11};
12use rustmotion_core::schema::TimelineStep;
13use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
14
15fn default_max() -> f64 {
16    100.0
17}
18
19fn default_track_color() -> String {
20    "#333333".to_string()
21}
22
23fn default_fill_color() -> String {
24    "#3B82F6".to_string()
25}
26
27fn default_track_width() -> f32 {
28    16.0
29}
30
31fn default_start_angle() -> f32 {
32    135.0
33}
34
35fn default_end_angle() -> f32 {
36    405.0
37}
38
39fn default_animated() -> bool {
40    true
41}
42
43fn default_animation_duration() -> f64 {
44    1.5
45}
46
47#[derive(Debug, Serialize, Deserialize, JsonSchema)]
48#[deprecated(
49    since = "0.7.1",
50    note = "`gauge` is a frozen composition (issue #333). The closest primitive recipe is \
51            an `svg` arc path animated with `draw_progress`; \
52            crates/rustmotion/skills/rules/composition-recipes.md flags this one (with \
53            `number_wheel`) as a reasonable exception to keep using directly, since the \
54            arc is mechanically harder to reproduce than a card/text/shape composition. \
55            Kept for compatibility; scheduled for removal in a future major version via \
56            `rustmotion migrate` (#335)."
57)]
58pub struct Gauge {
59    #[serde(default)]
60    pub value: f64,
61    #[serde(default)]
62    pub min: f64,
63    #[serde(default = "default_max")]
64    pub max: f64,
65    #[serde(default)]
66    pub label: Option<String>,
67    #[serde(default = "default_track_color")]
68    pub track_color: String,
69    #[serde(default = "default_fill_color")]
70    pub fill_color: String,
71    #[serde(default = "default_track_width")]
72    pub track_width: f32,
73    #[serde(default = "default_start_angle")]
74    pub start_angle: f32,
75    #[serde(default = "default_end_angle")]
76    pub end_angle: f32,
77    #[serde(default = "default_show_value")]
78    pub show_value: bool,
79    #[serde(default = "default_animated")]
80    pub animated: bool,
81    #[serde(default = "default_animation_duration")]
82    pub animation_duration: f64,
83    #[serde(flatten)]
84    pub timing: TimingConfig,
85    #[serde(default)]
86    pub style: CssStyle,
87    #[serde(default)]
88    pub timeline: Vec<TimelineStep>,
89    #[serde(default)]
90    pub stagger: Option<f32>,
91}
92
93fn default_show_value() -> bool {
94    true
95}
96
97rustmotion_core::impl_traits!(Gauge {
98    Animatable => animation,
99    Timed => timing,
100    Styled => style,
101});
102
103impl Gauge {
104    fn progress_at(&self, time: f64) -> f32 {
105        if !self.animated {
106            return 1.0;
107        }
108        let start = self.timing.start_at.unwrap_or(0.0);
109        let elapsed = (time - start).max(0.0);
110        let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
111        1.0 - (1.0 - p).powi(3)
112    }
113
114    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
115        let w = layout_w;
116        let h = layout_h;
117        let progress = self.progress_at(time);
118
119        let cx = w / 2.0;
120        let cy = h / 2.0;
121        let radius = cx.min(cy) - self.track_width / 2.0 - 4.0;
122
123        let oval = Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0);
124        let total_sweep = self.end_angle - self.start_angle;
125
126        let mut track_paint = paint_from_hex(&self.track_color);
127        track_paint.set_style(PaintStyle::Stroke);
128        track_paint.set_stroke_width(self.track_width);
129        track_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
130        track_paint.set_anti_alias(true);
131        canvas.draw_arc(oval, self.start_angle, total_sweep, false, &track_paint);
132
133        let range = (self.max - self.min).max(0.001);
134        let value_ratio = ((self.value - self.min) / range).clamp(0.0, 1.0) as f32;
135        let fill_sweep = total_sweep * value_ratio * progress;
136
137        let mut fill_paint = paint_from_hex(&self.fill_color);
138        fill_paint.set_style(PaintStyle::Stroke);
139        fill_paint.set_stroke_width(self.track_width);
140        fill_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
141        fill_paint.set_anti_alias(true);
142        canvas.draw_arc(oval, self.start_angle, fill_sweep, false, &fill_paint);
143
144        if self.show_value {
145            let display_val = self.min + (self.value - self.min) * progress as f64;
146            let text = format!("{}", display_val.round() as i64);
147
148            let font_size = (radius * 0.45).max(16.0);
149            let font_style = skia_safe::FontStyle::bold();
150            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
151                return;
152            };
153            let font = skia_safe::Font::from_typeface(typeface, font_size);
154            let emoji_font =
155                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
156
157            let mut text_paint = paint_from_hex(&self.fill_color);
158            text_paint.set_anti_alias(true);
159
160            let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
161            let (_, metrics) = font.metrics();
162            let text_x = cx - text_w / 2.0;
163            let text_y = cy + (-metrics.ascent) / 2.0;
164
165            draw_text_with_fallback(
166                canvas,
167                &text,
168                &font,
169                &emoji_font,
170                0.0,
171                text_x,
172                text_y,
173                &text_paint,
174            );
175        }
176
177        if let Some(label) = &self.label {
178            let font_size = (radius * 0.18).max(10.0);
179            let font_style = skia_safe::FontStyle::normal();
180            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
181                return;
182            };
183            let font = skia_safe::Font::from_typeface(typeface, font_size);
184            let emoji_font =
185                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
186
187            let mut label_paint = paint_from_hex("#888888");
188            label_paint.set_anti_alias(true);
189
190            let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
191            let label_x = cx - label_w / 2.0;
192            let label_y = cy + radius * 0.35;
193
194            draw_text_with_fallback(
195                canvas,
196                label,
197                &font,
198                &emoji_font,
199                0.0,
200                label_x,
201                label_y,
202                &label_paint,
203            );
204        }
205    }
206}
207
208impl Painter for Gauge {
209    fn paint_content(
210        &self,
211        canvas: &Canvas,
212        layout: &BoxLayout,
213        _props: &AnimatedProperties,
214        ctx: &PaintCtx,
215    ) {
216        self.paint(canvas, layout.width, layout.height, ctx.time);
217    }
218}