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#[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 = 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}