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, subpixel_font, 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)]
59#[deprecated(
60    since = "0.7.1",
61    note = "`stepper` is a frozen composition (issue #333). Compose `card` circles \
62            (nodes) + `text` labels + `shape` connectors instead, one `for-each` item \
63            emitting a node and its trailing connector as sibling output — see \
64            crates/rustmotion/skills/rules/composition-recipes.md's step-flow recipe and \
65            examples/composition-step-flow.json. Kept for compatibility; scheduled for \
66            removal in a future major version via `rustmotion migrate` (#335)."
67)]
68pub struct Stepper {
69    /// The steps to display.
70    pub steps: Vec<StepItem>,
71    /// The currently active step (0-indexed).
72    #[serde(default = "default_active_step")]
73    pub active_step: u32,
74    /// Animate active step to this index.
75    #[serde(default)]
76    pub animate_to: Option<u32>,
77    /// Time at which the animation starts.
78    #[serde(default)]
79    pub animate_at: Option<f64>,
80    /// Duration of the step transition animation.
81    #[serde(default = "default_transition_duration")]
82    pub transition_duration: f64,
83    /// Layout direction: "horizontal" or "vertical".
84    #[serde(default)]
85    pub orientation: StepperOrientation,
86    /// Color of the active step node.
87    #[serde(default = "default_active_color")]
88    pub active_color: String,
89    /// Color of completed step nodes.
90    #[serde(default = "default_completed_color")]
91    pub completed_color: String,
92    /// Color of pending step nodes.
93    #[serde(default = "default_pending_color")]
94    pub pending_color: String,
95    /// Diameter of step nodes.
96    #[serde(default = "default_node_size")]
97    pub node_size: f32,
98    #[serde(flatten)]
99    pub timing: TimingConfig,
100    #[serde(default)]
101    pub style: CssStyle,
102    #[serde(default)]
103    pub timeline: Vec<TimelineStep>,
104    #[serde(default)]
105    pub stagger: Option<f32>,
106}
107
108rustmotion_core::impl_traits!(Stepper {
109    Animatable => animation,
110    Timed => timing,
111    Styled => style,
112});
113
114impl Stepper {
115    fn current_step_at(&self, time: f64) -> f32 {
116        if let (Some(target), Some(start_at)) = (self.animate_to, self.animate_at) {
117            let elapsed = (time - start_at).max(0.0);
118            let p = (elapsed / self.transition_duration).clamp(0.0, 1.0) as f32;
119            let eased = 1.0 - (1.0 - p).powi(3);
120            let from = self.active_step as f32;
121            let to = target as f32;
122            from + (to - from) * eased
123        } else {
124            self.active_step as f32
125        }
126    }
127}
128
129impl Stepper {
130    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) {
131        let w = layout_w;
132        let h = layout_h;
133        let n = self.steps.len();
134        if n == 0 {
135            return;
136        }
137
138        let current = self.current_step_at(time);
139        let r = self.node_size / 2.0;
140
141        let font_style = skia_safe::FontStyle::normal();
142        let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
143            return;
144        };
145
146        let bold_style = skia_safe::FontStyle::bold();
147        let Ok(bold_typeface) = typeface_with_fallback("Inter", bold_style) else {
148            return;
149        };
150
151        let number_font_size = r * 0.9;
152        let number_font = subpixel_font(&bold_typeface, number_font_size);
153        let emoji_number_font = emoji_typeface().map(|tf| subpixel_font(tf, number_font_size));
154
155        let label_font_size = 14.0;
156        let label_font = subpixel_font(&typeface, label_font_size);
157        let emoji_label_font = emoji_typeface().map(|tf| subpixel_font(tf, label_font_size));
158
159        let desc_font_size = 11.0;
160        let desc_font = subpixel_font(&typeface, desc_font_size);
161        let emoji_desc_font = emoji_typeface().map(|tf| subpixel_font(tf, desc_font_size));
162
163        let is_horizontal = self.orientation == StepperOrientation::Horizontal;
164
165        if is_horizontal {
166            let padding = r + 8.0;
167            let available = w - padding * 2.0;
168            let spacing = if n > 1 {
169                available / (n - 1) as f32
170            } else {
171                0.0
172            };
173            let cy = r + 4.0;
174
175            for i in 0..(n - 1) {
176                let x1 = padding + i as f32 * spacing + r;
177                let x2 = padding + (i + 1) as f32 * spacing - r;
178
179                let step_f = i as f32;
180                let is_completed = current > step_f + 0.5;
181
182                let (cr, cg, cb, _) = if is_completed {
183                    parse_hex_color(&self.completed_color)
184                } else {
185                    parse_hex_color(&self.pending_color)
186                };
187
188                let alpha = if is_completed { 255u8 } else { 80u8 };
189                let color = skia_safe::Color::from_argb(alpha, cr, cg, cb);
190                let mut line_paint = skia_safe::Paint::default();
191                line_paint.set_color(color);
192                line_paint.set_style(PaintStyle::Stroke);
193                line_paint.set_stroke_width(2.0);
194                line_paint.set_anti_alias(true);
195
196                canvas.draw_line((x1, cy), (x2, cy), &line_paint);
197            }
198
199            for (i, step) in self.steps.iter().enumerate() {
200                let cx = padding + i as f32 * spacing;
201                let step_f = i as f32;
202
203                let (node_color, is_active) = if step_f < current.floor() {
204                    (&self.completed_color, false)
205                } else if (step_f - current).abs() < 0.5 {
206                    (&self.active_color, true)
207                } else {
208                    (&self.pending_color, false)
209                };
210
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 || step_f < current.floor() {
216                    canvas.draw_circle((cx, cy), r, &node_paint);
217                } else {
218                    node_paint.set_style(PaintStyle::Stroke);
219                    node_paint.set_stroke_width(2.0);
220                    canvas.draw_circle((cx, cy), r - 1.0, &node_paint);
221                }
222
223                let num_text = format!("{}", i + 1);
224                let num_w =
225                    measure_text_with_fallback(&num_text, &number_font, &emoji_number_font, 0.0);
226                let (_, num_metrics) = number_font.metrics();
227                let num_x = cx - num_w / 2.0;
228                let num_y = cy + (-num_metrics.ascent - num_metrics.descent) / 2.0;
229
230                let mut num_paint = paint_from_hex("#FFFFFF");
231                num_paint.set_anti_alias(true);
232                draw_text_with_fallback(
233                    canvas,
234                    &num_text,
235                    &number_font,
236                    &emoji_number_font,
237                    0.0,
238                    num_x,
239                    num_y,
240                    &num_paint,
241                );
242
243                let label_w =
244                    measure_text_with_fallback(&step.label, &label_font, &emoji_label_font, 0.0);
245                let label_x = cx - label_w / 2.0;
246                let (_, label_metrics) = label_font.metrics();
247                let label_y = cy + r + 12.0 + (-label_metrics.ascent);
248
249                let mut label_paint = paint_from_hex("#FFFFFF");
250                label_paint.set_anti_alias(true);
251                draw_text_with_fallback(
252                    canvas,
253                    &step.label,
254                    &label_font,
255                    &emoji_label_font,
256                    0.0,
257                    label_x,
258                    label_y,
259                    &label_paint,
260                );
261
262                if let Some(desc) = &step.description {
263                    let desc_w =
264                        measure_text_with_fallback(desc, &desc_font, &emoji_desc_font, 0.0);
265                    let desc_x = cx - desc_w / 2.0;
266                    let desc_y = label_y + label_font_size + 4.0;
267
268                    let mut desc_paint = paint_from_hex("#8B949E");
269                    desc_paint.set_anti_alias(true);
270                    draw_text_with_fallback(
271                        canvas,
272                        desc,
273                        &desc_font,
274                        &emoji_desc_font,
275                        0.0,
276                        desc_x,
277                        desc_y,
278                        &desc_paint,
279                    );
280                }
281            }
282        } else {
283            let padding = r + 8.0;
284            let available = h - padding * 2.0;
285            let spacing = if n > 1 {
286                available / (n - 1) as f32
287            } else {
288                0.0
289            };
290            let cx = r + 4.0;
291
292            for i in 0..(n - 1) {
293                let y1 = padding + i as f32 * spacing + r;
294                let y2 = padding + (i + 1) as f32 * spacing - r;
295
296                let step_f = i as f32;
297                let is_completed = current > step_f + 0.5;
298
299                let (cr, cg, cb, _) = if is_completed {
300                    parse_hex_color(&self.completed_color)
301                } else {
302                    parse_hex_color(&self.pending_color)
303                };
304
305                let alpha = if is_completed { 255u8 } else { 80u8 };
306                let color = skia_safe::Color::from_argb(alpha, cr, cg, cb);
307                let mut line_paint = skia_safe::Paint::default();
308                line_paint.set_color(color);
309                line_paint.set_style(PaintStyle::Stroke);
310                line_paint.set_stroke_width(2.0);
311                line_paint.set_anti_alias(true);
312
313                canvas.draw_line((cx, y1), (cx, y2), &line_paint);
314            }
315
316            for (i, step) in self.steps.iter().enumerate() {
317                let cy = padding + i as f32 * spacing;
318                let step_f = i as f32;
319
320                let (node_color, is_active) = if step_f < current.floor() {
321                    (&self.completed_color, false)
322                } else if (step_f - current).abs() < 0.5 {
323                    (&self.active_color, true)
324                } else {
325                    (&self.pending_color, false)
326                };
327
328                let mut node_paint = paint_from_hex(node_color);
329                node_paint.set_style(PaintStyle::Fill);
330                node_paint.set_anti_alias(true);
331
332                if is_active || step_f < current.floor() {
333                    canvas.draw_circle((cx, cy), r, &node_paint);
334                } else {
335                    node_paint.set_style(PaintStyle::Stroke);
336                    node_paint.set_stroke_width(2.0);
337                    canvas.draw_circle((cx, cy), r - 1.0, &node_paint);
338                }
339
340                let num_text = format!("{}", i + 1);
341                let num_w =
342                    measure_text_with_fallback(&num_text, &number_font, &emoji_number_font, 0.0);
343                let (_, num_metrics) = number_font.metrics();
344                let num_x = cx - num_w / 2.0;
345                let num_y = cy + (-num_metrics.ascent - num_metrics.descent) / 2.0;
346
347                let mut num_paint = paint_from_hex("#FFFFFF");
348                num_paint.set_anti_alias(true);
349                draw_text_with_fallback(
350                    canvas,
351                    &num_text,
352                    &number_font,
353                    &emoji_number_font,
354                    0.0,
355                    num_x,
356                    num_y,
357                    &num_paint,
358                );
359
360                let lx = cx + r + 12.0;
361                let (_, lm) = label_font.metrics();
362                let ly = cy + (-lm.ascent - lm.descent) / 2.0;
363                let mut label_paint = paint_from_hex("#FFFFFF");
364                label_paint.set_anti_alias(true);
365                draw_text_with_fallback(
366                    canvas,
367                    &step.label,
368                    &label_font,
369                    &emoji_label_font,
370                    0.0,
371                    lx,
372                    ly,
373                    &label_paint,
374                );
375
376                if let Some(desc) = &step.description {
377                    let desc_y = ly + label_font_size + 2.0;
378                    let mut desc_paint = paint_from_hex("#8B949E");
379                    desc_paint.set_anti_alias(true);
380                    draw_text_with_fallback(
381                        canvas,
382                        desc,
383                        &desc_font,
384                        &emoji_desc_font,
385                        0.0,
386                        lx,
387                        desc_y,
388                        &desc_paint,
389                    );
390                }
391            }
392        }
393    }
394}
395
396impl Painter for Stepper {
397    fn paint_content(
398        &self,
399        canvas: &Canvas,
400        layout: &BoxLayout,
401        _props: &AnimatedProperties,
402        ctx: &PaintCtx,
403    ) {
404        self.paint(canvas, layout.width, layout.height, ctx.time);
405    }
406}