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 subpixel_font, 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 = subpixel_font(typeface, font_size);
154 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
155
156 let mut text_paint = paint_from_hex(&self.fill_color);
157 text_paint.set_anti_alias(true);
158
159 let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
160 let (_, metrics) = font.metrics();
161 let text_x = cx - text_w / 2.0;
162 let text_y = cy + (-metrics.ascent) / 2.0;
163
164 draw_text_with_fallback(
165 canvas,
166 &text,
167 &font,
168 &emoji_font,
169 0.0,
170 text_x,
171 text_y,
172 &text_paint,
173 );
174 }
175
176 if let Some(label) = &self.label {
177 let font_size = (radius * 0.18).max(10.0);
178 let font_style = skia_safe::FontStyle::normal();
179 let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
180 return;
181 };
182 let font = subpixel_font(typeface, font_size);
183 let emoji_font = emoji_typeface().map(|tf| subpixel_font(tf, font_size));
184
185 let mut label_paint = paint_from_hex("#888888");
186 label_paint.set_anti_alias(true);
187
188 let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0);
189 let label_x = cx - label_w / 2.0;
190 let label_y = cy + radius * 0.35;
191
192 draw_text_with_fallback(
193 canvas,
194 label,
195 &font,
196 &emoji_font,
197 0.0,
198 label_x,
199 label_y,
200 &label_paint,
201 );
202 }
203 }
204}
205
206impl Painter for Gauge {
207 fn paint_content(
208 &self,
209 canvas: &Canvas,
210 layout: &BoxLayout,
211 _props: &AnimatedProperties,
212 ctx: &PaintCtx,
213 ) {
214 self.paint(canvas, layout.width, layout.height, ctx.time);
215 }
216}