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 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 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 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 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 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}