Skip to main content

rustmotion_components/
stepper.rs

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/// Layout axis of the stepper. Closed set, matched exhaustively by the
24/// painter; serde snake_case keeps the JSON values identical
25/// ("horizontal"/"vertical") — an unknown value now fails the typed parse
26/// (blocking validate error, by design).
27#[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    /// The steps to display.
61    pub steps: Vec<StepItem>,
62    /// The currently active step (0-indexed).
63    #[serde(default = "default_active_step")]
64    pub active_step: u32,
65    /// Animate active step to this index.
66    #[serde(default)]
67    pub animate_to: Option<u32>,
68    /// Time at which the animation starts.
69    #[serde(default)]
70    pub animate_at: Option<f64>,
71    /// Duration of the step transition animation.
72    #[serde(default = "default_transition_duration")]
73    pub transition_duration: f64,
74    /// Layout direction: "horizontal" or "vertical".
75    #[serde(default)]
76    pub orientation: StepperOrientation,
77    /// Color of the active step node.
78    #[serde(default = "default_active_color")]
79    pub active_color: String,
80    /// Color of completed step nodes.
81    #[serde(default = "default_completed_color")]
82    pub completed_color: String,
83    /// Color of pending step nodes.
84    #[serde(default = "default_pending_color")]
85    pub pending_color: String,
86    /// Diameter of step nodes.
87    #[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            // Draw connector lines between nodes
170            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            // Draw nodes and labels
195            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                    // Completed
201                    (&self.completed_color, false)
202                } else if (step_f - current).abs() < 0.5 {
203                    // Active
204                    (&self.active_color, true)
205                } else {
206                    // Pending
207                    (&self.pending_color, false)
208                };
209
210                // Node circle
211                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                    // Filled circle for active
217                    canvas.draw_circle((cx, cy), r, &node_paint);
218                } else if step_f < current.floor() {
219                    // Filled circle for completed
220                    canvas.draw_circle((cx, cy), r, &node_paint);
221                } else {
222                    // Ring for pending
223                    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                // Step number inside circle
229                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                // Label below circle
250                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                // Description below label
270                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            // Vertical layout
292            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            // Draw connector lines
302            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            // Draw nodes and labels
327            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                // Step number
352                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                // Label to the right
373                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                // Description below label
390                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}