1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, Font, FontStyle, PaintStyle, Rect};
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 typeface_with_fallback,
11};
12use rustmotion_core::schema::TimelineStep as AnimTimelineStep;
13use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
14
15#[derive(Debug, Serialize, Deserialize, JsonSchema)]
17#[deprecated(
18 since = "0.7.1",
19 note = "`timeline` is a frozen composition (issue #333), the same recipe as \
20 `stepper`: compose `card`/`shape` nodes and connectors instead — see \
21 crates/rustmotion/skills/rules/composition-recipes.md's step-flow recipe and \
22 examples/composition-step-flow.json. Kept for compatibility; scheduled for \
23 removal in a future major version via `rustmotion migrate` (#335)."
24)]
25pub struct Timeline {
26 pub steps: Vec<TimelineStep>,
28 #[serde(default = "default_timeline_width")]
30 pub width: f32,
31 #[serde(default)]
33 pub direction: TimelineDirection,
34 #[serde(default = "default_node_radius")]
36 pub node_radius: f32,
37 #[serde(default = "default_bar_color")]
39 pub bar_color: String,
40 #[serde(default = "default_bar_fill_color")]
42 pub bar_fill_color: String,
43 #[serde(default = "default_bar_height")]
45 pub bar_height: f32,
46 #[serde(default = "default_fill_progress")]
48 pub fill_progress: f32,
49 #[serde(default = "default_label_font_size")]
51 pub font_size: f32,
52 #[serde(default = "default_label_color")]
54 pub label_color: String,
55 #[serde(default = "default_sublabel_color")]
57 pub sublabel_color: String,
58 #[serde(flatten)]
59 pub timing: TimingConfig,
60 #[serde(default)]
61 pub style: CssStyle,
62 #[serde(default)]
63 pub timeline: Vec<AnimTimelineStep>,
64 #[serde(default)]
65 pub stagger: Option<f32>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub struct TimelineStep {
70 pub label: String,
72 #[serde(default)]
74 pub sublabel: Option<String>,
75 #[serde(default = "default_node_color")]
77 pub color: String,
78 #[serde(default)]
80 pub icon: Option<String>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
84#[serde(rename_all = "snake_case")]
85pub enum TimelineDirection {
86 #[default]
87 Horizontal,
88 Vertical,
89}
90
91fn default_timeline_width() -> f32 {
92 800.0
93}
94fn default_node_radius() -> f32 {
95 24.0
96}
97fn default_bar_color() -> String {
98 "#333333".to_string()
99}
100fn default_bar_fill_color() -> String {
101 "#58A6FF".to_string()
102}
103fn default_bar_height() -> f32 {
104 4.0
105}
106fn default_fill_progress() -> f32 {
107 1.0
108}
109fn default_label_font_size() -> f32 {
110 16.0
111}
112fn default_label_color() -> String {
113 "#FFFFFF".to_string()
114}
115fn default_sublabel_color() -> String {
116 "#8B949E".to_string()
117}
118fn default_node_color() -> String {
119 "#58A6FF".to_string()
120}
121
122rustmotion_core::impl_traits!(Timeline {
123 Animatable => animation,
124 Timed => timing,
125 Styled => style,
126});
127
128impl Timeline {
129 fn paint(&self, canvas: &Canvas, props: &AnimatedProperties) {
130 let n = self.steps.len();
131 if n == 0 {
132 return;
133 }
134
135 let Ok(typeface) = typeface_with_fallback("Inter", FontStyle::normal()) else {
136 return;
137 };
138 let font = Font::from_typeface(&typeface, self.font_size);
139 let icon_font = Font::from_typeface(&typeface, self.node_radius * 0.8);
140 let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, self.node_radius * 0.8));
141 let sublabel_font = Font::from_typeface(&typeface, self.font_size * 0.8);
142 let (_, metrics) = font.metrics();
143 let ascent = -metrics.ascent;
144
145 let r = self.node_radius;
146 let fill_progress = if props.draw_progress >= 0.0 {
147 props.draw_progress
148 } else {
149 self.fill_progress
150 };
151
152 match self.direction {
153 TimelineDirection::Horizontal => {
154 self.render_horizontal(
155 canvas,
156 n,
157 r,
158 fill_progress,
159 &font,
160 &icon_font,
161 &emoji_font,
162 &sublabel_font,
163 ascent,
164 );
165 }
166 TimelineDirection::Vertical => {
167 self.render_vertical(
168 canvas,
169 n,
170 r,
171 fill_progress,
172 &font,
173 &icon_font,
174 &emoji_font,
175 &sublabel_font,
176 ascent,
177 );
178 }
179 }
180 }
181}
182
183impl Timeline {
184 fn render_horizontal(
185 &self,
186 canvas: &Canvas,
187 n: usize,
188 r: f32,
189 fill_progress: f32,
190 font: &Font,
191 icon_font: &Font,
192 emoji_font: &Option<Font>,
193 sublabel_font: &Font,
194 ascent: f32,
195 ) {
196 let total_w = self.width;
197 let bar_y = r;
198
199 let max_label_half_w = self
200 .steps
201 .iter()
202 .map(|s| measure_text_with_fallback(&s.label, font, &None, 0.0) * 0.5)
203 .fold(0.0f32, f32::max);
204 let inset = r.max(max_label_half_w).min(total_w / 2.0);
205 let usable_w = (total_w - inset * 2.0).max(0.0);
206 let spacing = if n > 1 {
207 usable_w / (n - 1) as f32
208 } else {
209 0.0
210 };
211
212 let bar_rect =
213 Rect::from_xywh(0.0, bar_y - self.bar_height / 2.0, total_w, self.bar_height);
214 let mut bar_paint = paint_from_hex(&self.bar_color);
215 bar_paint.set_style(PaintStyle::Fill);
216 canvas.draw_round_rect(
217 bar_rect,
218 self.bar_height / 2.0,
219 self.bar_height / 2.0,
220 &bar_paint,
221 );
222
223 if fill_progress > 0.001 {
224 let fill_w = total_w * fill_progress.clamp(0.0, 1.0);
225 let fill_rect =
226 Rect::from_xywh(0.0, bar_y - self.bar_height / 2.0, fill_w, self.bar_height);
227 let mut fill_paint = paint_from_hex(&self.bar_fill_color);
228 fill_paint.set_style(PaintStyle::Fill);
229 canvas.save();
230 canvas.clip_rect(
231 Rect::from_xywh(0.0, bar_y - self.bar_height / 2.0, total_w, self.bar_height),
232 skia_safe::ClipOp::Intersect,
233 false,
234 );
235 canvas.draw_round_rect(
236 fill_rect,
237 self.bar_height / 2.0,
238 self.bar_height / 2.0,
239 &fill_paint,
240 );
241 canvas.restore();
242 }
243
244 for (i, step) in self.steps.iter().enumerate() {
245 let cx = if n > 1 {
246 inset + i as f32 * spacing
247 } else {
248 total_w / 2.0
249 };
250 let cy = bar_y;
251
252 let step_progress = if n > 1 {
253 i as f32 / (n - 1) as f32
254 } else {
255 0.0
256 };
257 let is_active = fill_progress >= step_progress;
258
259 let node_color = if is_active {
260 &step.color
261 } else {
262 &self.bar_color
263 };
264 let mut node_paint = paint_from_hex(node_color);
265 node_paint.set_style(PaintStyle::Fill);
266 node_paint.set_anti_alias(true);
267 canvas.draw_circle((cx, cy), r, &node_paint);
268
269 if let Some(ref icon) = step.icon {
270 let icon_w = measure_text_with_fallback(icon, icon_font, emoji_font, 0.0);
271 let (_, icon_metrics) = icon_font.metrics();
272 let icon_ascent = -icon_metrics.ascent;
273 let icon_descent = icon_metrics.descent;
274 let ix = cx - icon_w / 2.0;
275 let iy = cy + (icon_ascent - icon_descent) / 2.0;
276 let mut icon_paint = paint_from_hex("#FFFFFF");
277 icon_paint.set_anti_alias(true);
278 draw_text_with_fallback(
279 canvas,
280 icon,
281 icon_font,
282 emoji_font,
283 0.0,
284 ix,
285 iy,
286 &icon_paint,
287 );
288 }
289
290 let label_w = measure_text_with_fallback(&step.label, font, &None, 0.0);
291 let lx = cx - label_w / 2.0;
292 let ly = cy + r + 8.0 + ascent;
293 let mut label_paint = paint_from_hex(&self.label_color);
294 label_paint.set_anti_alias(true);
295 draw_text_with_fallback(canvas, &step.label, font, &None, 0.0, lx, ly, &label_paint);
296
297 if let Some(ref sublabel) = step.sublabel {
298 let sub_w = measure_text_with_fallback(sublabel, sublabel_font, &None, 0.0);
299 let sx = cx - sub_w / 2.0;
300 let sy = ly + self.font_size * 1.2;
301 let mut sub_paint = paint_from_hex(&self.sublabel_color);
302 sub_paint.set_anti_alias(true);
303 draw_text_with_fallback(
304 canvas,
305 sublabel,
306 sublabel_font,
307 &None,
308 0.0,
309 sx,
310 sy,
311 &sub_paint,
312 );
313 }
314 }
315 }
316
317 fn render_vertical(
318 &self,
319 canvas: &Canvas,
320 n: usize,
321 r: f32,
322 fill_progress: f32,
323 font: &Font,
324 icon_font: &Font,
325 emoji_font: &Option<Font>,
326 _sublabel_font: &Font,
327 _ascent: f32,
328 ) {
329 let spacing = 80.0;
330 let bar_x = r;
331 let total_h = if n > 1 { (n - 1) as f32 * spacing } else { 0.0 };
332 let top_inset = r;
333
334 let bar_rect = Rect::from_xywh(
335 bar_x - self.bar_height / 2.0,
336 top_inset,
337 self.bar_height,
338 total_h,
339 );
340 let mut bar_paint = paint_from_hex(&self.bar_color);
341 bar_paint.set_style(PaintStyle::Fill);
342 canvas.draw_round_rect(
343 bar_rect,
344 self.bar_height / 2.0,
345 self.bar_height / 2.0,
346 &bar_paint,
347 );
348
349 if fill_progress > 0.001 {
350 let fill_h = total_h * fill_progress.clamp(0.0, 1.0);
351 let fill_rect = Rect::from_xywh(
352 bar_x - self.bar_height / 2.0,
353 top_inset,
354 self.bar_height,
355 fill_h,
356 );
357 let mut fill_paint = paint_from_hex(&self.bar_fill_color);
358 fill_paint.set_style(PaintStyle::Fill);
359 canvas.draw_round_rect(
360 fill_rect,
361 self.bar_height / 2.0,
362 self.bar_height / 2.0,
363 &fill_paint,
364 );
365 }
366
367 for (i, step) in self.steps.iter().enumerate() {
368 let cx = bar_x;
369 let cy = top_inset + if n > 1 { i as f32 * spacing } else { 0.0 };
370
371 let step_progress = if n > 1 {
372 i as f32 / (n - 1) as f32
373 } else {
374 0.0
375 };
376 let is_active = fill_progress >= step_progress;
377
378 let node_color = if is_active {
379 &step.color
380 } else {
381 &self.bar_color
382 };
383 let mut node_paint = paint_from_hex(node_color);
384 node_paint.set_style(PaintStyle::Fill);
385 node_paint.set_anti_alias(true);
386 canvas.draw_circle((cx, cy), r, &node_paint);
387
388 if let Some(ref icon) = step.icon {
389 let icon_w = measure_text_with_fallback(icon, icon_font, emoji_font, 0.0);
390 let (_, icon_metrics) = icon_font.metrics();
391 let icon_ascent = -icon_metrics.ascent;
392 let icon_descent = icon_metrics.descent;
393 let ix = cx - icon_w / 2.0;
394 let iy = cy + (icon_ascent - icon_descent) / 2.0;
395 let mut icon_paint = paint_from_hex("#FFFFFF");
396 icon_paint.set_anti_alias(true);
397 draw_text_with_fallback(
398 canvas,
399 icon,
400 icon_font,
401 emoji_font,
402 0.0,
403 ix,
404 iy,
405 &icon_paint,
406 );
407 }
408
409 let lx = cx + r + 12.0;
410 let (_, font_metrics) = font.metrics();
411 let ly = cy + (-font_metrics.ascent - font_metrics.descent) / 2.0;
412 let mut label_paint = paint_from_hex(&self.label_color);
413 label_paint.set_anti_alias(true);
414 draw_text_with_fallback(canvas, &step.label, font, &None, 0.0, lx, ly, &label_paint);
415 }
416 }
417}
418
419impl Painter for Timeline {
420 fn paint_content(
421 &self,
422 canvas: &Canvas,
423 _layout: &BoxLayout,
424 props: &AnimatedProperties,
425 _ctx: &PaintCtx,
426 ) {
427 self.paint(canvas, props);
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 fn step(label: &str) -> TimelineStep {
436 TimelineStep {
437 label: label.to_string(),
438 sublabel: None,
439 color: default_node_color(),
440 icon: None,
441 }
442 }
443
444 fn ink_bounds(
445 surface: &mut skia_safe::Surface,
446 w: i32,
447 h: i32,
448 ) -> Option<(i32, i32, i32, i32)> {
449 let snapshot = surface.image_snapshot();
450 let info = skia_safe::ImageInfo::new(
451 (w, h),
452 skia_safe::ColorType::RGBA8888,
453 skia_safe::AlphaType::Premul,
454 None,
455 );
456 let mut buf = vec![0u8; (w * h * 4) as usize];
457 let ok = snapshot.read_pixels(
458 &info,
459 &mut buf,
460 (w * 4) as usize,
461 skia_safe::IPoint::new(0, 0),
462 skia_safe::image::CachingHint::Disallow,
463 );
464 assert!(ok, "pixel read should succeed");
465 let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
466 for y in 0..h {
467 for x in 0..w {
468 if buf[((y * w + x) * 4 + 3) as usize] > 0 {
469 minx = minx.min(x);
470 maxx = maxx.max(x);
471 miny = miny.min(y);
472 maxy = maxy.max(y);
473 }
474 }
475 }
476 (minx <= maxx).then_some((minx, maxx, miny, maxy))
477 }
478
479 #[test]
480 fn horizontal_nodes_and_labels_stay_within_the_declared_width() {
481 let tl = Timeline {
482 steps: vec![
483 step("Design phase kickoff"),
484 step("Build"),
485 step("Test"),
486 step("Ship it now"),
487 ],
488 width: 512.0,
489 direction: TimelineDirection::Horizontal,
490 node_radius: default_node_radius(),
491 bar_color: default_bar_color(),
492 bar_fill_color: default_bar_fill_color(),
493 bar_height: default_bar_height(),
494 fill_progress: default_fill_progress(),
495 font_size: default_label_font_size(),
496 label_color: default_label_color(),
497 sublabel_color: default_sublabel_color(),
498 timing: Default::default(),
499 style: CssStyle::default(),
500 timeline: Vec::new(),
501 stagger: None,
502 };
503 const W: i32 = 512;
504 const H: i32 = 100;
505 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
506 {
507 let canvas = surface.canvas();
508 let props = AnimatedProperties::default();
509 tl.paint(canvas, &props);
510 }
511 let (minx, maxx, _miny, _maxy) =
512 ink_bounds(&mut surface, W, H).expect("timeline must paint something");
513 assert!(minx >= 0, "ink starts left of the box at x={minx}");
514 assert!(
515 maxx < W,
516 "ink escapes the box on the right at x={maxx} (width={W})"
517 );
518 }
519
520 #[test]
521 fn single_step_stays_centered_within_the_box() {
522 let tl = Timeline {
523 steps: vec![step("Only step")],
524 width: 300.0,
525 direction: TimelineDirection::Horizontal,
526 node_radius: default_node_radius(),
527 bar_color: default_bar_color(),
528 bar_fill_color: default_bar_fill_color(),
529 bar_height: default_bar_height(),
530 fill_progress: default_fill_progress(),
531 font_size: default_label_font_size(),
532 label_color: default_label_color(),
533 sublabel_color: default_sublabel_color(),
534 timing: Default::default(),
535 style: CssStyle::default(),
536 timeline: Vec::new(),
537 stagger: None,
538 };
539 const W: i32 = 300;
540 const H: i32 = 100;
541 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
542 {
543 let canvas = surface.canvas();
544 let props = AnimatedProperties::default();
545 tl.paint(canvas, &props);
546 }
547 let (minx, maxx, _miny, _maxy) =
548 ink_bounds(&mut surface, W, H).expect("timeline must paint something");
549 assert!(minx >= 0 && maxx < W);
550 }
551}