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