Skip to main content

rustmotion_components/
avatar.rs

1use rustmotion_core::css::CssStyle;
2use rustmotion_core::error::Result;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use skia_safe::{Canvas, Paint, PaintStyle, RRect, Rect};
6
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{asset_cache, paint_from_hex};
10use rustmotion_core::error::RustmotionError;
11use rustmotion_core::schema::TimelineStep;
12use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
13
14#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
15#[serde(rename_all = "snake_case")]
16#[derive(Default)]
17pub enum AvatarStatus {
18    Online,
19    Offline,
20    Away,
21    #[serde(rename = "none")]
22    #[default]
23    NoStatus,
24}
25
26impl AvatarStatus {
27    fn default_color(&self) -> &str {
28        match self {
29            AvatarStatus::Online => "#22C55E",
30            AvatarStatus::Offline => "#9CA3AF",
31            AvatarStatus::Away => "#F59E0B",
32            AvatarStatus::NoStatus => "",
33        }
34    }
35}
36
37#[derive(Debug, Serialize, Deserialize, JsonSchema)]
38pub struct Avatar {
39    pub src: String,
40    #[serde(default = "default_avatar_size")]
41    pub size: f32,
42    #[serde(default)]
43    pub border_color: Option<String>,
44    #[serde(default)]
45    pub border_width: Option<f32>,
46    #[serde(default)]
47    pub status: AvatarStatus,
48    #[serde(default)]
49    pub status_color: Option<String>,
50    #[serde(flatten)]
51    pub timing: TimingConfig,
52    #[serde(default)]
53    pub style: CssStyle,
54    #[serde(default)]
55    pub timeline: Vec<TimelineStep>,
56    #[serde(default)]
57    pub stagger: Option<f32>,
58}
59
60fn default_avatar_size() -> f32 {
61    64.0
62}
63
64rustmotion_core::impl_traits!(Avatar {
65    Animatable => animation,
66    Timed => timing,
67    Styled => style,
68});
69
70impl Avatar {
71    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32) -> Result<()> {
72        let w = layout_w;
73        let h = layout_h;
74
75        // Load image
76        let cache = asset_cache();
77        let img = if let Some(cached) = cache.get(&self.src) {
78            cached.clone()
79        } else {
80            let data = std::fs::read(&self.src).map_err(|e| RustmotionError::ImageLoad {
81                path: self.src.clone(),
82                reason: e.to_string(),
83            })?;
84            let skia_data = skia_safe::Data::new_copy(&data);
85            let decoded = skia_safe::Image::from_encoded(skia_data).ok_or_else(|| {
86                RustmotionError::ImageDecode {
87                    path: self.src.clone(),
88                }
89            })?;
90            cache.insert(self.src.clone(), decoded.clone());
91            decoded
92        };
93
94        // Circular clip + draw image with cover fit
95        let oval_rect = Rect::from_xywh(0.0, 0.0, w, h);
96        let oval_rrect = RRect::new_oval(oval_rect);
97
98        canvas.save();
99        canvas.clip_rrect(oval_rrect, skia_safe::ClipOp::Intersect, true);
100
101        let img_w = img.width() as f32;
102        let img_h = img.height() as f32;
103        let scale = (w / img_w).max(h / img_h);
104        let draw_w = img_w * scale;
105        let draw_h = img_h * scale;
106        let offset_x = (w - draw_w) / 2.0;
107        let offset_y = (h - draw_h) / 2.0;
108
109        let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h);
110        canvas.draw_image_rect(img, None, dst, &Paint::default());
111
112        canvas.restore();
113
114        // Border
115        let border_width = self.border_width.unwrap_or(0.0);
116        if border_width > 0.0 {
117            if let Some(border_color) = &self.border_color {
118                let mut border_paint = paint_from_hex(border_color);
119                border_paint.set_style(PaintStyle::Stroke);
120                border_paint.set_stroke_width(border_width);
121                border_paint.set_anti_alias(true);
122
123                let inset = border_width / 2.0;
124                let border_rect = Rect::from_xywh(inset, inset, w - border_width, h - border_width);
125                canvas.draw_oval(border_rect, &border_paint);
126            }
127        }
128
129        // Status indicator
130        if !matches!(self.status, AvatarStatus::NoStatus) {
131            let dot_radius = w * 0.15;
132            let dot_color = self
133                .status_color
134                .as_deref()
135                .unwrap_or_else(|| self.status.default_color());
136
137            let mut dot_paint = paint_from_hex(dot_color);
138            dot_paint.set_style(PaintStyle::Fill);
139            dot_paint.set_anti_alias(true);
140
141            let cx = w - dot_radius;
142            let cy = h - dot_radius;
143            canvas.draw_circle((cx, cy), dot_radius, &dot_paint);
144
145            // White border around dot
146            let mut border_paint = paint_from_hex("#FFFFFF");
147            border_paint.set_style(PaintStyle::Stroke);
148            border_paint.set_stroke_width(2.0);
149            border_paint.set_anti_alias(true);
150            canvas.draw_circle((cx, cy), dot_radius, &border_paint);
151        }
152
153        Ok(())
154    }
155}
156
157impl Painter for Avatar {
158    fn paint_content(
159        &self,
160        canvas: &Canvas,
161        layout: &BoxLayout,
162        _props: &AnimatedProperties,
163        _ctx: &PaintCtx,
164    ) {
165        let _ = self.paint(canvas, layout.width, layout.height);
166    }
167}