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, draw_photo, 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)]
38#[deprecated(
39    since = "0.7.1",
40    note = "`avatar` is a frozen composition (issue #333). Compose a circular avatar from a \
41            `shape` (`circle`) or a circle-clipped `image` instead — see \
42            crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
43            scheduled for removal in a future major version via `rustmotion migrate` (#335)."
44)]
45pub struct Avatar {
46    pub src: String,
47    #[serde(default = "default_avatar_size")]
48    pub size: f32,
49    #[serde(default)]
50    pub border_color: Option<String>,
51    #[serde(default)]
52    pub border_width: Option<f32>,
53    #[serde(default)]
54    pub status: AvatarStatus,
55    #[serde(default)]
56    pub status_color: Option<String>,
57    #[serde(flatten)]
58    pub timing: TimingConfig,
59    #[serde(default)]
60    pub style: CssStyle,
61    #[serde(default)]
62    pub timeline: Vec<TimelineStep>,
63    #[serde(default)]
64    pub stagger: Option<f32>,
65}
66
67fn default_avatar_size() -> f32 {
68    64.0
69}
70
71rustmotion_core::impl_traits!(Avatar {
72    Animatable => animation,
73    Timed => timing,
74    Styled => style,
75});
76
77impl Avatar {
78    fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32) -> Result<()> {
79        let w = layout_w;
80        let h = layout_h;
81
82        let cache = asset_cache();
83        let img = if let Some(cached) = cache.get(&self.src) {
84            cached.clone()
85        } else {
86            let data = std::fs::read(&self.src).map_err(|e| RustmotionError::ImageLoad {
87                path: self.src.clone(),
88                reason: e.to_string(),
89            })?;
90            let skia_data = skia_safe::Data::new_copy(&data);
91            let decoded = skia_safe::Image::from_encoded(skia_data).ok_or_else(|| {
92                RustmotionError::ImageDecode {
93                    path: self.src.clone(),
94                }
95            })?;
96            cache.insert(self.src.clone(), decoded.clone());
97            decoded
98        };
99
100        let oval_rect = Rect::from_xywh(0.0, 0.0, w, h);
101        let oval_rrect = RRect::new_oval(oval_rect);
102
103        canvas.save();
104        canvas.clip_rrect(oval_rrect, skia_safe::ClipOp::Intersect, true);
105
106        let img_w = img.width() as f32;
107        let img_h = img.height() as f32;
108        let scale = (w / img_w).max(h / img_h);
109        let draw_w = img_w * scale;
110        let draw_h = img_h * scale;
111        let offset_x = (w - draw_w) / 2.0;
112        let offset_y = (h - draw_h) / 2.0;
113
114        let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h);
115        draw_photo(canvas, img, dst, &Paint::default());
116
117        canvas.restore();
118
119        let border_width = self.border_width.unwrap_or(0.0);
120        if border_width > 0.0 {
121            if let Some(border_color) = &self.border_color {
122                let mut border_paint = paint_from_hex(border_color);
123                border_paint.set_style(PaintStyle::Stroke);
124                border_paint.set_stroke_width(border_width);
125                border_paint.set_anti_alias(true);
126
127                let inset = border_width / 2.0;
128                let border_rect = Rect::from_xywh(inset, inset, w - border_width, h - border_width);
129                canvas.draw_oval(border_rect, &border_paint);
130            }
131        }
132
133        if !matches!(self.status, AvatarStatus::NoStatus) {
134            let dot_radius = w * 0.15;
135            let dot_color = self
136                .status_color
137                .as_deref()
138                .unwrap_or_else(|| self.status.default_color());
139
140            let mut dot_paint = paint_from_hex(dot_color);
141            dot_paint.set_style(PaintStyle::Fill);
142            dot_paint.set_anti_alias(true);
143
144            let cx = w - dot_radius;
145            let cy = h - dot_radius;
146            canvas.draw_circle((cx, cy), dot_radius, &dot_paint);
147
148            let mut border_paint = paint_from_hex("#FFFFFF");
149            border_paint.set_style(PaintStyle::Stroke);
150            border_paint.set_stroke_width(2.0);
151            border_paint.set_anti_alias(true);
152            canvas.draw_circle((cx, cy), dot_radius, &border_paint);
153        }
154
155        Ok(())
156    }
157}
158
159impl Painter for Avatar {
160    fn paint_content(
161        &self,
162        canvas: &Canvas,
163        layout: &BoxLayout,
164        _props: &AnimatedProperties,
165        _ctx: &PaintCtx,
166    ) {
167        let _ = self.paint(canvas, layout.width, layout.height);
168    }
169}