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)]
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 pub steps: Vec<StepItem>,
71 #[serde(default = "default_active_step")]
73 pub active_step: u32,
74 #[serde(default)]
76 pub animate_to: Option<u32>,
77 #[serde(default)]
79 pub animate_at: Option<f64>,
80 #[serde(default = "default_transition_duration")]
82 pub transition_duration: f64,
83 #[serde(default)]
85 pub orientation: StepperOrientation,
86 #[serde(default = "default_active_color")]
88 pub active_color: String,
89 #[serde(default = "default_completed_color")]
91 pub completed_color: String,
92 #[serde(default = "default_pending_color")]
94 pub pending_color: String,
95 #[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}