rustmotion_components/
avatar.rs1use 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}