use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, Font, FontStyle, PaintStyle, Rect};
use crate::engine::renderer::{font_mgr, paint_from_hex, emoji_typeface, draw_text_with_fallback, measure_text_with_fallback};
use crate::layout::{Constraints, LayoutNode};
use crate::schema::LayerStyle;
use crate::traits::{RenderContext, TimingConfig, Widget};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct Timeline {
pub steps: Vec<TimelineStep>,
#[serde(default = "default_timeline_width")]
pub width: f32,
#[serde(default)]
pub direction: TimelineDirection,
#[serde(default = "default_node_radius")]
pub node_radius: f32,
#[serde(default = "default_bar_color")]
pub bar_color: String,
#[serde(default = "default_bar_fill_color")]
pub bar_fill_color: String,
#[serde(default = "default_bar_height")]
pub bar_height: f32,
#[serde(default = "default_fill_progress")]
pub fill_progress: f32,
#[serde(default = "default_label_font_size")]
pub font_size: f32,
#[serde(default = "default_label_color")]
pub label_color: String,
#[serde(default = "default_sublabel_color")]
pub sublabel_color: String,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
pub style: LayerStyle,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TimelineStep {
pub label: String,
#[serde(default)]
pub sublabel: Option<String>,
#[serde(default = "default_node_color")]
pub color: String,
#[serde(default)]
pub icon: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum TimelineDirection {
#[default]
Horizontal,
Vertical,
}
fn default_timeline_width() -> f32 { 800.0 }
fn default_node_radius() -> f32 { 24.0 }
fn default_bar_color() -> String { "#333333".to_string() }
fn default_bar_fill_color() -> String { "#58A6FF".to_string() }
fn default_bar_height() -> f32 { 4.0 }
fn default_fill_progress() -> f32 { 1.0 }
fn default_label_font_size() -> f32 { 16.0 }
fn default_label_color() -> String { "#FFFFFF".to_string() }
fn default_sublabel_color() -> String { "#8B949E".to_string() }
fn default_node_color() -> String { "#58A6FF".to_string() }
crate::impl_traits!(Timeline {
Animatable => style,
Timed => timing,
Styled => style,
});
impl Widget for Timeline {
fn render(
&self,
canvas: &Canvas,
_layout: &LayoutNode,
ctx: &RenderContext,
props: &crate::engine::animator::AnimatedProperties,
) -> Result<()> {
let n = self.steps.len();
if n == 0 { return Ok(()); }
let fm = font_mgr();
let typeface = fm.match_family_style("Inter", FontStyle::normal())
.or_else(|| fm.match_family_style("Helvetica", FontStyle::normal()))
.or_else(|| fm.match_family_style("Arial", FontStyle::normal()))
.unwrap_or_else(|| fm.match_family_style("sans-serif", FontStyle::normal()).unwrap());
let font = Font::from_typeface(&typeface, self.font_size);
let icon_font = Font::from_typeface(&typeface, self.node_radius * 0.8);
let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, self.node_radius * 0.8));
let sublabel_font = Font::from_typeface(&typeface, self.font_size * 0.8);
let (_, metrics) = font.metrics();
let ascent = -metrics.ascent;
let r = self.node_radius;
let fill_progress = if props.draw_progress >= 0.0 { props.draw_progress } else { self.fill_progress };
match self.direction {
TimelineDirection::Horizontal => {
self.render_horizontal(canvas, n, r, fill_progress, &font, &icon_font, &emoji_font, &sublabel_font, ascent, ctx);
}
TimelineDirection::Vertical => {
self.render_vertical(canvas, n, r, fill_progress, &font, &icon_font, &emoji_font, &sublabel_font, ascent, ctx);
}
}
Ok(())
}
fn measure(&self, _constraints: &Constraints) -> (f32, f32) {
match self.direction {
TimelineDirection::Horizontal => {
let h = self.node_radius * 2.0 + self.font_size * 2.5 + 20.0;
(self.width, h)
}
TimelineDirection::Vertical => {
let n = self.steps.len().max(1);
let spacing = 80.0;
let h = n as f32 * spacing;
(self.width, h)
}
}
}
}
impl Timeline {
fn render_horizontal(
&self,
canvas: &Canvas,
n: usize,
r: f32,
fill_progress: f32,
font: &Font,
icon_font: &Font,
emoji_font: &Option<Font>,
sublabel_font: &Font,
ascent: f32,
_ctx: &RenderContext,
) {
let total_w = self.width;
let bar_y = r; let spacing = if n > 1 { total_w / (n - 1) as f32 } else { 0.0 };
let bar_rect = Rect::from_xywh(
0.0, bar_y - self.bar_height / 2.0,
total_w, self.bar_height,
);
let mut bar_paint = paint_from_hex(&self.bar_color);
bar_paint.set_style(PaintStyle::Fill);
canvas.draw_round_rect(bar_rect, self.bar_height / 2.0, self.bar_height / 2.0, &bar_paint);
if fill_progress > 0.001 {
let fill_w = total_w * fill_progress.clamp(0.0, 1.0);
let fill_rect = Rect::from_xywh(
0.0, bar_y - self.bar_height / 2.0,
fill_w, self.bar_height,
);
let mut fill_paint = paint_from_hex(&self.bar_fill_color);
fill_paint.set_style(PaintStyle::Fill);
canvas.save();
canvas.clip_rect(Rect::from_xywh(0.0, bar_y - self.bar_height / 2.0, total_w, self.bar_height), skia_safe::ClipOp::Intersect, false);
canvas.draw_round_rect(fill_rect, self.bar_height / 2.0, self.bar_height / 2.0, &fill_paint);
canvas.restore();
}
for (i, step) in self.steps.iter().enumerate() {
let cx = if n > 1 { i as f32 * spacing } else { total_w / 2.0 };
let cy = bar_y;
let step_progress = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
let is_active = fill_progress >= step_progress;
let node_color = if is_active { &step.color } else { &self.bar_color };
let mut node_paint = paint_from_hex(node_color);
node_paint.set_style(PaintStyle::Fill);
node_paint.set_anti_alias(true);
canvas.draw_circle((cx, cy), r, &node_paint);
if let Some(ref icon) = step.icon {
let icon_w = measure_text_with_fallback(icon, icon_font, emoji_font, 0.0);
let (_, icon_metrics) = icon_font.metrics();
let icon_ascent = -icon_metrics.ascent;
let icon_descent = icon_metrics.descent;
let ix = cx - icon_w / 2.0;
let iy = cy + (icon_ascent - icon_descent) / 2.0;
let mut icon_paint = paint_from_hex("#FFFFFF");
icon_paint.set_anti_alias(true);
draw_text_with_fallback(canvas, icon, icon_font, emoji_font, 0.0, ix, iy, &icon_paint);
}
let label_w = measure_text_with_fallback(&step.label, font, &None, 0.0);
let lx = cx - label_w / 2.0;
let ly = cy + r + 8.0 + ascent;
let mut label_paint = paint_from_hex(&self.label_color);
label_paint.set_anti_alias(true);
draw_text_with_fallback(canvas, &step.label, font, &None, 0.0, lx, ly, &label_paint);
if let Some(ref sublabel) = step.sublabel {
let sub_w = measure_text_with_fallback(sublabel, sublabel_font, &None, 0.0);
let sx = cx - sub_w / 2.0;
let sy = ly + self.font_size * 1.2;
let mut sub_paint = paint_from_hex(&self.sublabel_color);
sub_paint.set_anti_alias(true);
draw_text_with_fallback(canvas, sublabel, sublabel_font, &None, 0.0, sx, sy, &sub_paint);
}
}
}
fn render_vertical(
&self,
canvas: &Canvas,
n: usize,
r: f32,
fill_progress: f32,
font: &Font,
icon_font: &Font,
emoji_font: &Option<Font>,
_sublabel_font: &Font,
_ascent: f32,
_ctx: &RenderContext,
) {
let spacing = 80.0;
let bar_x = r;
let total_h = if n > 1 { (n - 1) as f32 * spacing } else { 0.0 };
let bar_rect = Rect::from_xywh(
bar_x - self.bar_height / 2.0, 0.0,
self.bar_height, total_h,
);
let mut bar_paint = paint_from_hex(&self.bar_color);
bar_paint.set_style(PaintStyle::Fill);
canvas.draw_round_rect(bar_rect, self.bar_height / 2.0, self.bar_height / 2.0, &bar_paint);
if fill_progress > 0.001 {
let fill_h = total_h * fill_progress.clamp(0.0, 1.0);
let fill_rect = Rect::from_xywh(
bar_x - self.bar_height / 2.0, 0.0,
self.bar_height, fill_h,
);
let mut fill_paint = paint_from_hex(&self.bar_fill_color);
fill_paint.set_style(PaintStyle::Fill);
canvas.draw_round_rect(fill_rect, self.bar_height / 2.0, self.bar_height / 2.0, &fill_paint);
}
for (i, step) in self.steps.iter().enumerate() {
let cx = bar_x;
let cy = if n > 1 { i as f32 * spacing } else { 0.0 };
let step_progress = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
let is_active = fill_progress >= step_progress;
let node_color = if is_active { &step.color } else { &self.bar_color };
let mut node_paint = paint_from_hex(node_color);
node_paint.set_style(PaintStyle::Fill);
node_paint.set_anti_alias(true);
canvas.draw_circle((cx, cy), r, &node_paint);
if let Some(ref icon) = step.icon {
let icon_w = measure_text_with_fallback(icon, icon_font, emoji_font, 0.0);
let (_, icon_metrics) = icon_font.metrics();
let icon_ascent = -icon_metrics.ascent;
let icon_descent = icon_metrics.descent;
let ix = cx - icon_w / 2.0;
let iy = cy + (icon_ascent - icon_descent) / 2.0;
let mut icon_paint = paint_from_hex("#FFFFFF");
icon_paint.set_anti_alias(true);
draw_text_with_fallback(canvas, icon, icon_font, emoji_font, 0.0, ix, iy, &icon_paint);
}
let lx = cx + r + 12.0;
let (_, font_metrics) = font.metrics();
let ly = cy + (-font_metrics.ascent - font_metrics.descent) / 2.0;
let mut label_paint = paint_from_hex(&self.label_color);
label_paint.set_anti_alias(true);
draw_text_with_fallback(canvas, &step.label, font, &None, 0.0, lx, ly, &label_paint);
}
}
}