Skip to main content

rustmotion_components/
timeline.rs

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/// A horizontal or vertical pipeline/timeline component.
16#[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    /// The steps in the pipeline.
27    pub steps: Vec<TimelineStep>,
28    /// Overall width of the timeline.
29    #[serde(default = "default_timeline_width")]
30    pub width: f32,
31    /// Direction: "horizontal" (default) or "vertical".
32    #[serde(default)]
33    pub direction: TimelineDirection,
34    /// Radius of the step circles.
35    #[serde(default = "default_node_radius")]
36    pub node_radius: f32,
37    /// Color of the connecting bar background.
38    #[serde(default = "default_bar_color")]
39    pub bar_color: String,
40    /// Color of the filled portion of the bar.
41    #[serde(default = "default_bar_fill_color")]
42    pub bar_fill_color: String,
43    /// Bar thickness.
44    #[serde(default = "default_bar_height")]
45    pub bar_height: f32,
46    /// Fill progress (0.0 to 1.0) — animates the bar from left to right.
47    #[serde(default = "default_fill_progress")]
48    pub fill_progress: f32,
49    /// Font size for labels.
50    #[serde(default = "default_label_font_size")]
51    pub font_size: f32,
52    /// Label color.
53    #[serde(default = "default_label_color")]
54    pub label_color: String,
55    /// Sublabel color.
56    #[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    /// Label text displayed below/beside the node.
71    pub label: String,
72    /// Optional sublabel (smaller text under the label).
73    #[serde(default)]
74    pub sublabel: Option<String>,
75    /// Node color (fill).
76    #[serde(default = "default_node_color")]
77    pub color: String,
78    /// Optional icon text inside the circle (emoji or single char).
79    #[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}