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)]
17pub struct Timeline {
18    /// The steps in the pipeline.
19    pub steps: Vec<TimelineStep>,
20    /// Overall width of the timeline.
21    #[serde(default = "default_timeline_width")]
22    pub width: f32,
23    /// Direction: "horizontal" (default) or "vertical".
24    #[serde(default)]
25    pub direction: TimelineDirection,
26    /// Radius of the step circles.
27    #[serde(default = "default_node_radius")]
28    pub node_radius: f32,
29    /// Color of the connecting bar background.
30    #[serde(default = "default_bar_color")]
31    pub bar_color: String,
32    /// Color of the filled portion of the bar.
33    #[serde(default = "default_bar_fill_color")]
34    pub bar_fill_color: String,
35    /// Bar thickness.
36    #[serde(default = "default_bar_height")]
37    pub bar_height: f32,
38    /// Fill progress (0.0 to 1.0) — animates the bar from left to right.
39    #[serde(default = "default_fill_progress")]
40    pub fill_progress: f32,
41    /// Font size for labels.
42    #[serde(default = "default_label_font_size")]
43    pub font_size: f32,
44    /// Label color.
45    #[serde(default = "default_label_color")]
46    pub label_color: String,
47    /// Sublabel color.
48    #[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    /// Label text displayed below/beside the node.
63    pub label: String,
64    /// Optional sublabel (smaller text under the label).
65    #[serde(default)]
66    pub sublabel: Option<String>,
67    /// Node color (fill).
68    #[serde(default = "default_node_color")]
69    pub color: String,
70    /// Optional icon text inside the circle (emoji or single char).
71    #[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; // Center of nodes
190
191        // Node 0 used to sit at local x=0 and node n-1 at x=total_w, so
192        // their circles (radius `r`) bled `r` px past both edges of the
193        // assigned box — and further still wherever a step's label was
194        // wider than the node itself (issue #127 measured ink starting
195        // 33px left of the box, 9px outside the card). Insetting every
196        // node by `max(r, widest_label / 2)` keeps both the circles and
197        // their centered labels inside `[0, total_w]` regardless of which
198        // step has the longest label.
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        // Draw background bar
213        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        // Draw filled bar
225        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        // Draw nodes and labels
247        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            // Determine if this step is "active" (filled bar has reached it)
256            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            // Node circle
264            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            // Icon/text inside node
275            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            // Label below
297            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            // Sublabel
305            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        // Node 0 used to sit at local y=0, so its circle (radius `r`) bled
341        // `r` px above the assigned box's top edge — same class of bug as
342        // the horizontal direction's left/right overflow. Insetting every
343        // node's `cy` by `r` keeps the whole column inside `[0, box height]`
344        // (box_builder.rs already reserves `r*2 + 64` px per step, well
345        // past what this needs).
346        let top_inset = r;
347
348        // Background bar
349        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        // Filled bar
365        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            // Label to the right
426            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        // #127: node 0's circle used to be centered at local x=0 (and node
499        // n-1 at x=width), so the circle — and a wide first/last label —
500        // bled `node_radius` (or more) past both edges of the box the
501        // layout gave this component. A long first label and a long last
502        // label both push on the insets from either side.
503        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}