1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle};
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 parse_hex_color, typeface_with_fallback,
11};
12use rustmotion_core::schema::TimelineStep;
13use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
14
15fn default_active_step() -> u32 {
16 0
17}
18
19fn default_transition_duration() -> f64 {
20 0.5
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum StepperOrientation {
30 #[default]
31 Horizontal,
32 Vertical,
33}
34
35fn default_active_color() -> String {
36 "#3B82F6".to_string()
37}
38
39fn default_completed_color() -> String {
40 "#22C55E".to_string()
41}
42
43fn default_pending_color() -> String {
44 "#6B7280".to_string()
45}
46
47fn default_node_size() -> f32 {
48 32.0
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
52pub struct StepItem {
53 pub label: String,
54 #[serde(default)]
55 pub description: Option<String>,
56}
57
58#[derive(Debug, Serialize, Deserialize, JsonSchema)]
59pub struct Stepper {
60 pub steps: Vec<StepItem>,
62 #[serde(default = "default_active_step")]
64 pub active_step: u32,
65 #[serde(default)]
67 pub animate_to: Option<u32>,
68 #[serde(default)]
70 pub animate_at: Option<f64>,
71 #[serde(default = "default_transition_duration")]
73 pub transition_duration: f64,
74 #[serde(default)]
76 pub orientation: StepperOrientation,
77 #[serde(default = "default_active_color")]
79 pub active_color: String,
80 #[serde(default = "default_completed_color")]
82 pub completed_color: String,
83 #[serde(default = "default_pending_color")]
85 pub pending_color: String,
86 #[serde(default = "default_node_size")]
88 pub node_size: f32,
89 #[serde(flatten)]
90 pub timing: TimingConfig,
91 #[serde(default)]
92 pub style: CssStyle,
93 #[serde(default)]
94 pub timeline: Vec<TimelineStep>,
95 #[serde(default)]
96 pub stagger: Option<f32>,
97}
98
99rustmotion_core::impl_traits!(Stepper {
100 Animatable => animation,
101 Timed => timing,
102 Styled => style,
103});
104
105impl Stepper {
106 fn current_step_at(&self, time: f64) -> f32 {
107 if let (Some(target), Some(start_at)) = (self.animate_to, self.animate_at) {
108 let elapsed = (time - start_at).max(0.0);
109 let p = (elapsed / self.transition_duration).clamp(0.0, 1.0) as f32;
110 let eased = 1.0 - (1.0 - p).powi(3);
111 let from = self.active_step as f32;
112 let to = target as f32;
113 from + (to - from) * eased
114 } else {
115 self.active_step as f32
116 }
117 }
118}
119
120impl Stepper {
121 fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
122 let w = layout_w;
123 let h = layout_h;
124 let n = self.steps.len();
125 if n == 0 {
126 return;
127 }
128
129 let current = self.current_step_at(time);
130 let r = self.node_size / 2.0;
131
132 let font_style = skia_safe::FontStyle::normal();
133 let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
134 return;
135 };
136
137 let bold_style = skia_safe::FontStyle::bold();
138 let Ok(bold_typeface) = typeface_with_fallback("Inter", bold_style) else {
139 return;
140 };
141
142 let number_font_size = r * 0.9;
143 let number_font = skia_safe::Font::from_typeface(&bold_typeface, number_font_size);
144 let emoji_number_font =
145 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, number_font_size));
146
147 let label_font_size = 14.0;
148 let label_font = skia_safe::Font::from_typeface(&typeface, label_font_size);
149 let emoji_label_font =
150 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size));
151
152 let desc_font_size = 11.0;
153 let desc_font = skia_safe::Font::from_typeface(&typeface, desc_font_size);
154 let emoji_desc_font =
155 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, desc_font_size));
156
157 let is_horizontal = self.orientation == StepperOrientation::Horizontal;
158
159 if is_horizontal {
160 let padding = r + 8.0;
161 let available = w - padding * 2.0;
162 let spacing = if n > 1 {
163 available / (n - 1) as f32
164 } else {
165 0.0
166 };
167 let cy = r + 4.0;
168
169 for i in 0..(n - 1) {
171 let x1 = padding + i as f32 * spacing + r;
172 let x2 = padding + (i + 1) as f32 * spacing - r;
173
174 let step_f = i as f32;
175 let is_completed = current > step_f + 0.5;
176
177 let (cr, cg, cb, _) = if is_completed {
178 parse_hex_color(&self.completed_color)
179 } else {
180 parse_hex_color(&self.pending_color)
181 };
182
183 let alpha = if is_completed { 255u8 } else { 80u8 };
184 let color = skia_safe::Color::from_argb(alpha, cr, cg, cb);
185 let mut line_paint = skia_safe::Paint::default();
186 line_paint.set_color(color);
187 line_paint.set_style(PaintStyle::Stroke);
188 line_paint.set_stroke_width(2.0);
189 line_paint.set_anti_alias(true);
190
191 canvas.draw_line((x1, cy), (x2, cy), &line_paint);
192 }
193
194 for (i, step) in self.steps.iter().enumerate() {
196 let cx = padding + i as f32 * spacing;
197 let step_f = i as f32;
198
199 let (node_color, is_active) = if step_f < current.floor() {
200 (&self.completed_color, false)
202 } else if (step_f - current).abs() < 0.5 {
203 (&self.active_color, true)
205 } else {
206 (&self.pending_color, false)
208 };
209
210 let mut node_paint = paint_from_hex(node_color);
212 node_paint.set_style(PaintStyle::Fill);
213 node_paint.set_anti_alias(true);
214
215 if is_active {
216 canvas.draw_circle((cx, cy), r, &node_paint);
218 } else if step_f < current.floor() {
219 canvas.draw_circle((cx, cy), r, &node_paint);
221 } else {
222 node_paint.set_style(PaintStyle::Stroke);
224 node_paint.set_stroke_width(2.0);
225 canvas.draw_circle((cx, cy), r - 1.0, &node_paint);
226 }
227
228 let num_text = format!("{}", i + 1);
230 let num_w =
231 measure_text_with_fallback(&num_text, &number_font, &emoji_number_font, 0.0);
232 let (_, num_metrics) = number_font.metrics();
233 let num_x = cx - num_w / 2.0;
234 let num_y = cy + (-num_metrics.ascent - num_metrics.descent) / 2.0;
235
236 let mut num_paint = paint_from_hex("#FFFFFF");
237 num_paint.set_anti_alias(true);
238 draw_text_with_fallback(
239 canvas,
240 &num_text,
241 &number_font,
242 &emoji_number_font,
243 0.0,
244 num_x,
245 num_y,
246 &num_paint,
247 );
248
249 let label_w =
251 measure_text_with_fallback(&step.label, &label_font, &emoji_label_font, 0.0);
252 let label_x = cx - label_w / 2.0;
253 let (_, label_metrics) = label_font.metrics();
254 let label_y = cy + r + 12.0 + (-label_metrics.ascent);
255
256 let mut label_paint = paint_from_hex("#FFFFFF");
257 label_paint.set_anti_alias(true);
258 draw_text_with_fallback(
259 canvas,
260 &step.label,
261 &label_font,
262 &emoji_label_font,
263 0.0,
264 label_x,
265 label_y,
266 &label_paint,
267 );
268
269 if let Some(desc) = &step.description {
271 let desc_w =
272 measure_text_with_fallback(desc, &desc_font, &emoji_desc_font, 0.0);
273 let desc_x = cx - desc_w / 2.0;
274 let desc_y = label_y + label_font_size + 4.0;
275
276 let mut desc_paint = paint_from_hex("#8B949E");
277 desc_paint.set_anti_alias(true);
278 draw_text_with_fallback(
279 canvas,
280 desc,
281 &desc_font,
282 &emoji_desc_font,
283 0.0,
284 desc_x,
285 desc_y,
286 &desc_paint,
287 );
288 }
289 }
290 } else {
291 let padding = r + 8.0;
293 let available = h - padding * 2.0;
294 let spacing = if n > 1 {
295 available / (n - 1) as f32
296 } else {
297 0.0
298 };
299 let cx = r + 4.0;
300
301 for i in 0..(n - 1) {
303 let y1 = padding + i as f32 * spacing + r;
304 let y2 = padding + (i + 1) as f32 * spacing - r;
305
306 let step_f = i as f32;
307 let is_completed = current > step_f + 0.5;
308
309 let (cr, cg, cb, _) = if is_completed {
310 parse_hex_color(&self.completed_color)
311 } else {
312 parse_hex_color(&self.pending_color)
313 };
314
315 let alpha = if is_completed { 255u8 } else { 80u8 };
316 let color = skia_safe::Color::from_argb(alpha, cr, cg, cb);
317 let mut line_paint = skia_safe::Paint::default();
318 line_paint.set_color(color);
319 line_paint.set_style(PaintStyle::Stroke);
320 line_paint.set_stroke_width(2.0);
321 line_paint.set_anti_alias(true);
322
323 canvas.draw_line((cx, y1), (cx, y2), &line_paint);
324 }
325
326 for (i, step) in self.steps.iter().enumerate() {
328 let cy = padding + i as f32 * spacing;
329 let step_f = i as f32;
330
331 let (node_color, is_active) = if step_f < current.floor() {
332 (&self.completed_color, false)
333 } else if (step_f - current).abs() < 0.5 {
334 (&self.active_color, true)
335 } else {
336 (&self.pending_color, false)
337 };
338
339 let mut node_paint = paint_from_hex(node_color);
340 node_paint.set_style(PaintStyle::Fill);
341 node_paint.set_anti_alias(true);
342
343 if is_active || step_f < current.floor() {
344 canvas.draw_circle((cx, cy), r, &node_paint);
345 } else {
346 node_paint.set_style(PaintStyle::Stroke);
347 node_paint.set_stroke_width(2.0);
348 canvas.draw_circle((cx, cy), r - 1.0, &node_paint);
349 }
350
351 let num_text = format!("{}", i + 1);
353 let num_w =
354 measure_text_with_fallback(&num_text, &number_font, &emoji_number_font, 0.0);
355 let (_, num_metrics) = number_font.metrics();
356 let num_x = cx - num_w / 2.0;
357 let num_y = cy + (-num_metrics.ascent - num_metrics.descent) / 2.0;
358
359 let mut num_paint = paint_from_hex("#FFFFFF");
360 num_paint.set_anti_alias(true);
361 draw_text_with_fallback(
362 canvas,
363 &num_text,
364 &number_font,
365 &emoji_number_font,
366 0.0,
367 num_x,
368 num_y,
369 &num_paint,
370 );
371
372 let lx = cx + r + 12.0;
374 let (_, lm) = label_font.metrics();
375 let ly = cy + (-lm.ascent - lm.descent) / 2.0;
376 let mut label_paint = paint_from_hex("#FFFFFF");
377 label_paint.set_anti_alias(true);
378 draw_text_with_fallback(
379 canvas,
380 &step.label,
381 &label_font,
382 &emoji_label_font,
383 0.0,
384 lx,
385 ly,
386 &label_paint,
387 );
388
389 if let Some(desc) = &step.description {
391 let desc_y = ly + label_font_size + 2.0;
392 let mut desc_paint = paint_from_hex("#8B949E");
393 desc_paint.set_anti_alias(true);
394 draw_text_with_fallback(
395 canvas,
396 desc,
397 &desc_font,
398 &emoji_desc_font,
399 0.0,
400 lx,
401 desc_y,
402 &desc_paint,
403 );
404 }
405 }
406 }
407 }
408}
409
410impl Painter for Stepper {
411 fn paint_content(
412 &self,
413 canvas: &Canvas,
414 layout: &BoxLayout,
415 _props: &AnimatedProperties,
416 ctx: &PaintCtx,
417 ) {
418 self.paint(canvas, layout.width, layout.height, ctx.time);
419 }
420}