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)]
48pub struct Gauge {
49    #[serde(default)]
50    pub value: f64,
51    #[serde(default)]
52    pub min: f64,
53    #[serde(default = "default_max")]
54    pub max: f64,
55    #[serde(default)]
56    pub label: Option<String>,
57    #[serde(default = "default_track_color")]
58    pub track_color: String,
59    #[serde(default = "default_fill_color")]
60    pub fill_color: String,
61    #[serde(default = "default_track_width")]
62    pub track_width: f32,
63    #[serde(default = "default_start_angle")]
64    pub start_angle: f32,
65    #[serde(default = "default_end_angle")]
66    pub end_angle: f32,
67    #[serde(default = "default_show_value")]
68    pub show_value: bool,
69    #[serde(default = "default_animated")]
70    pub animated: bool,
71    #[serde(default = "default_animation_duration")]
72    pub animation_duration: f64,
73    #[serde(flatten)]
74    pub timing: TimingConfig,
75    #[serde(default)]
76    pub style: CssStyle,
77    #[serde(default)]
78    pub timeline: Vec<TimelineStep>,
79    #[serde(default)]
80    pub stagger: Option<f32>,
81}
82
83fn default_show_value() -> bool {
84    true
85}
86
87rustmotion_core::impl_traits!(Gauge {
88    Animatable => animation,
89    Timed => timing,
90    Styled => style,
91});
92
93impl Gauge {
94    fn progress_at(&self, time: f64) -> f32 {
95        if !self.animated {
96            return 1.0;
97        }
98        // Measure from `start_at`, like every other animated component: driving
99        // the ramp off raw scene time makes a delayed gauge arrive already full.
100        let start = self.timing.start_at.unwrap_or(0.0);
101        let elapsed = (time - start).max(0.0);
102        let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32;
103        1.0 - (1.0 - p).powi(3)
104    }
105
106    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
107        let w = layout_w;
108        let h = layout_h;
109        let progress = self.progress_at(time);
110
111        let cx = w / 2.0;
112        let cy = h / 2.0;
113        let radius = cx.min(cy) - self.track_width / 2.0 - 4.0;
114
115        let oval = Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0);
116        let total_sweep = self.end_angle - self.start_angle;
117
118        // Track arc
119        let mut track_paint = paint_from_hex(&self.track_color);
120        track_paint.set_style(PaintStyle::Stroke);
121        track_paint.set_stroke_width(self.track_width);
122        track_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
123        track_paint.set_anti_alias(true);
124        canvas.draw_arc(oval, self.start_angle, total_sweep, false, &track_paint);
125
126        // Fill arc
127        let range = (self.max - self.min).max(0.001);
128        let value_ratio = ((self.value - self.min) / range).clamp(0.0, 1.0) as f32;
129        let fill_sweep = total_sweep * value_ratio * progress;
130
131        let mut fill_paint = paint_from_hex(&self.fill_color);
132        fill_paint.set_style(PaintStyle::Stroke);
133        fill_paint.set_stroke_width(self.track_width);
134        fill_paint.set_stroke_cap(skia_safe::paint::Cap::Round);
135        fill_paint.set_anti_alias(true);
136        canvas.draw_arc(oval, self.start_angle, fill_sweep, false, &fill_paint);
137
138        // Value text
139        if self.show_value {
140            let display_val = self.min + (self.value - self.min) * progress as f64;
141            let text = format!("{}", display_val.round() as i64);
142
143            let font_size = (radius * 0.45).max(16.0);
144            let font_style = skia_safe::FontStyle::bold();
145            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
146                return;
147            };
148            let font = skia_safe::Font::from_typeface(typeface, font_size);
149            let emoji_font =
150                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
151
152            let mut text_paint = paint_from_hex(&self.fill_color);
153            text_paint.set_anti_alias(true);
154
155            let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
156            let (_, metrics) = font.metrics();
157            let text_x = cx - text_w / 2.0;
158            let text_y = cy + (-metrics.ascent) / 2.0;
159
160            draw_text_with_fallback(
161                canvas,
162                &text,
163                &font,
164                &emoji_font,
165                0.0,
166                text_x,
167                text_y,
168                &text_paint,
169            );
170        }
171
172        // Label text
173        if let Some(label) = &self.label {
174            let font_size = (radius * 0.18).max(10.0);
175            let font_style = skia_safe::FontStyle::normal();
176            let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
177                return;
178            };
179            let font = skia_safe::Font::from_typeface(typeface, font_size);
180            let emoji_font =
181                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
182
183            let mut label_paint = paint_from_hex("#888888");
184            label_paint.set_anti_alias(true);
185
186            let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
187            let label_x = cx - label_w / 2.0;
188            let label_y = cy + radius * 0.35;
189
190            draw_text_with_fallback(
191                canvas,
192                label,
193                &font,
194                &emoji_font,
195                0.0,
196                label_x,
197                label_y,
198                &label_paint,
199            );
200        }
201    }
202}
203
204impl Painter for Gauge {
205    fn paint_content(
206        &self,
207        canvas: &Canvas,
208        layout: &BoxLayout,
209        _props: &AnimatedProperties,
210        ctx: &PaintCtx,
211    ) {
212        self.paint(canvas, layout.width, layout.height, ctx.time);
213    }
214}