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)]
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 = skia_safe::Font::from_typeface(&bold_typeface, number_font_size);
153        let emoji_number_font =
154            emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, number_font_size));
155
156        let label_font_size = 14.0;
157        let label_font = skia_safe::Font::from_typeface(&typeface, label_font_size);
158        let emoji_label_font =
159            emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size));
160
161        let desc_font_size = 11.0;
162        let desc_font = skia_safe::Font::from_typeface(&typeface, desc_font_size);
163        let emoji_desc_font =
164            emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, desc_font_size));
165
166        let is_horizontal = self.orientation == StepperOrientation::Horizontal;
167
168        if is_horizontal {
169            let padding = r + 8.0;
170            let available = w - padding * 2.0;
171            let spacing = if n > 1 {
172                available / (n - 1) as f32
173            } else {
174                0.0
175            };
176            let cy = r + 4.0;
177
178            for i in 0..(n - 1) {
179                let x1 = padding + i as f32 * spacing + r;
180                let x2 = padding + (i + 1) as f32 * spacing - r;
181
182                let step_f = i as f32;
183                let is_completed = current > step_f + 0.5;
184
185                let (cr, cg, cb, _) = if is_completed {
186                    parse_hex_color(&self.completed_color)
187                } else {
188                    parse_hex_color(&self.pending_color)
189                };
190
191                let alpha = if is_completed { 255u8 } else { 80u8 };
192                let color = skia_safe::Color::from_argb(alpha, cr, cg, cb);
193                let mut line_paint = skia_safe::Paint::default();
194                line_paint.set_color(color);
195                line_paint.set_style(PaintStyle::Stroke);
196                line_paint.set_stroke_width(2.0);
197                line_paint.set_anti_alias(true);
198
199                canvas.draw_line((x1, cy), (x2, cy), &line_paint);
200            }
201
202            for (i, step) in self.steps.iter().enumerate() {
203                let cx = padding + i as f32 * spacing;
204                let step_f = i as f32;
205
206                let (node_color, is_active) = if step_f < current.floor() {
207                    (&self.completed_color, false)
208                } else if (step_f - current).abs() < 0.5 {
209                    (&self.active_color, true)
210                } else {
211                    (&self.pending_color, false)
212                };
213
214                let mut node_paint = paint_from_hex(node_color);
215                node_paint.set_style(PaintStyle::Fill);
216                node_paint.set_anti_alias(true);
217
218                if is_active || step_f < current.floor() {
219                    canvas.draw_circle((cx, cy), r, &node_paint);
220                } else {
221                    node_paint.set_style(PaintStyle::Stroke);
222                    node_paint.set_stroke_width(2.0);
223                    canvas.draw_circle((cx, cy), r - 1.0, &node_paint);
224                }
225
226                let num_text = format!("{}", i + 1);
227                let num_w =
228                    measure_text_with_fallback(&num_text, &number_font, &emoji_number_font, 0.0);
229                let (_, num_metrics) = number_font.metrics();
230                let num_x = cx - num_w / 2.0;
231                let num_y = cy + (-num_metrics.ascent - num_metrics.descent) / 2.0;
232
233                let mut num_paint = paint_from_hex("#FFFFFF");
234                num_paint.set_anti_alias(true);
235                draw_text_with_fallback(
236                    canvas,
237                    &num_text,
238                    &number_font,
239                    &emoji_number_font,
240                    0.0,
241                    num_x,
242                    num_y,
243                    &num_paint,
244                );
245
246                let label_w =
247                    measure_text_with_fallback(&step.label, &label_font, &emoji_label_font, 0.0);
248                let label_x = cx - label_w / 2.0;
249                let (_, label_metrics) = label_font.metrics();
250                let label_y = cy + r + 12.0 + (-label_metrics.ascent);
251
252                let mut label_paint = paint_from_hex("#FFFFFF");
253                label_paint.set_anti_alias(true);
254                draw_text_with_fallback(
255                    canvas,
256                    &step.label,
257                    &label_font,
258                    &emoji_label_font,
259                    0.0,
260                    label_x,
261                    label_y,
262                    &label_paint,
263                );
264
265                if let Some(desc) = &step.description {
266                    let desc_w =
267                        measure_text_with_fallback(desc, &desc_font, &emoji_desc_font, 0.0);
268                    let desc_x = cx - desc_w / 2.0;
269                    let desc_y = label_y + label_font_size + 4.0;
270
271                    let mut desc_paint = paint_from_hex("#8B949E");
272                    desc_paint.set_anti_alias(true);
273                    draw_text_with_fallback(
274                        canvas,
275                        desc,
276                        &desc_font,
277                        &emoji_desc_font,
278                        0.0,
279                        desc_x,
280                        desc_y,
281                        &desc_paint,
282                    );
283                }
284            }
285        } else {
286            let padding = r + 8.0;
287            let available = h - padding * 2.0;
288            let spacing = if n > 1 {
289                available / (n - 1) as f32
290            } else {
291                0.0
292            };
293            let cx = r + 4.0;
294
295            for i in 0..(n - 1) {
296                let y1 = padding + i as f32 * spacing + r;
297                let y2 = padding + (i + 1) as f32 * spacing - r;
298
299                let step_f = i as f32;
300                let is_completed = current > step_f + 0.5;
301
302                let (cr, cg, cb, _) = if is_completed {
303                    parse_hex_color(&self.completed_color)
304                } else {
305                    parse_hex_color(&self.pending_color)
306                };
307
308                let alpha = if is_completed { 255u8 } else { 80u8 };
309                let color = skia_safe::Color::from_argb(alpha, cr, cg, cb);
310                let mut line_paint = skia_safe::Paint::default();
311                line_paint.set_color(color);
312                line_paint.set_style(PaintStyle::Stroke);
313                line_paint.set_stroke_width(2.0);
314                line_paint.set_anti_alias(true);
315
316                canvas.draw_line((cx, y1), (cx, y2), &line_paint);
317            }
318
319            for (i, step) in self.steps.iter().enumerate() {
320                let cy = padding + i as f32 * spacing;
321                let step_f = i as f32;
322
323                let (node_color, is_active) = if step_f < current.floor() {
324                    (&self.completed_color, false)
325                } else if (step_f - current).abs() < 0.5 {
326                    (&self.active_color, true)
327                } else {
328                    (&self.pending_color, false)
329                };
330
331                let mut node_paint = paint_from_hex(node_color);
332                node_paint.set_style(PaintStyle::Fill);
333                node_paint.set_anti_alias(true);
334
335                if is_active || step_f < current.floor() {
336                    canvas.draw_circle((cx, cy), r, &node_paint);
337                } else {
338                    node_paint.set_style(PaintStyle::Stroke);
339                    node_paint.set_stroke_width(2.0);
340                    canvas.draw_circle((cx, cy), r - 1.0, &node_paint);
341                }
342
343                let num_text = format!("{}", i + 1);
344                let num_w =
345                    measure_text_with_fallback(&num_text, &number_font, &emoji_number_font, 0.0);
346                let (_, num_metrics) = number_font.metrics();
347                let num_x = cx - num_w / 2.0;
348                let num_y = cy + (-num_metrics.ascent - num_metrics.descent) / 2.0;
349
350                let mut num_paint = paint_from_hex("#FFFFFF");
351                num_paint.set_anti_alias(true);
352                draw_text_with_fallback(
353                    canvas,
354                    &num_text,
355                    &number_font,
356                    &emoji_number_font,
357                    0.0,
358                    num_x,
359                    num_y,
360                    &num_paint,
361                );
362
363                let lx = cx + r + 12.0;
364                let (_, lm) = label_font.metrics();
365                let ly = cy + (-lm.ascent - lm.descent) / 2.0;
366                let mut label_paint = paint_from_hex("#FFFFFF");
367                label_paint.set_anti_alias(true);
368                draw_text_with_fallback(
369                    canvas,
370                    &step.label,
371                    &label_font,
372                    &emoji_label_font,
373                    0.0,
374                    lx,
375                    ly,
376                    &label_paint,
377                );
378
379                if let Some(desc) = &step.description {
380                    let desc_y = ly + label_font_size + 2.0;
381                    let mut desc_paint = paint_from_hex("#8B949E");
382                    desc_paint.set_anti_alias(true);
383                    draw_text_with_fallback(
384                        canvas,
385                        desc,
386                        &desc_font,
387                        &emoji_desc_font,
388                        0.0,
389                        lx,
390                        desc_y,
391                        &desc_paint,
392                    );
393                }
394            }
395        }
396    }
397}
398
399impl Painter for Stepper {
400    fn paint_content(
401        &self,
402        canvas: &Canvas,
403        layout: &BoxLayout,
404        _props: &AnimatedProperties,
405        ctx: &PaintCtx,
406    ) {
407        self.paint(canvas, layout.width, layout.height, ctx.time);
408    }
409}