rustmotion_components/
avatar_group.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::{
10 asset_cache, draw_text_with_fallback, emoji_typeface, measure_text_with_fallback,
11 paint_from_hex, typeface_with_fallback,
12};
13use rustmotion_core::error::RustmotionError;
14use rustmotion_core::schema::TimelineStep;
15use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
16
17fn default_avatar_size() -> f32 {
18 48.0
19}
20
21fn default_overlap() -> f32 {
22 16.0
23}
24
25fn default_border_width() -> f32 {
26 3.0
27}
28
29fn default_border_color() -> String {
30 "#0f172a".to_string()
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
34pub struct AvatarGroupItem {
35 pub src: String,
36}
37
38#[derive(Debug, Serialize, Deserialize, JsonSchema)]
39#[deprecated(
40 since = "0.7.1",
41 note = "`avatar_group` is a frozen composition (issue #333). Compose a stacked group \
42 from repeated avatar `shape`s/`image`s in a row with negative `margin-left` \
43 overlap instead — see crates/rustmotion/skills/rules/composition-recipes.md. \
44 Kept for compatibility; scheduled for removal in a future major version via \
45 `rustmotion migrate` (#335)."
46)]
47pub struct AvatarGroup {
48 pub avatars: Vec<AvatarGroupItem>,
49 #[serde(default)]
50 pub max_display: Option<usize>,
51 #[serde(default = "default_avatar_size")]
52 pub size: f32,
53 #[serde(default = "default_overlap")]
54 pub overlap: f32,
55 #[serde(default = "default_border_width")]
56 pub border_width: f32,
57 #[serde(default = "default_border_color")]
58 pub border_color: String,
59 #[serde(flatten)]
60 pub timing: TimingConfig,
61 #[serde(default)]
62 pub style: CssStyle,
63 #[serde(default)]
64 pub timeline: Vec<TimelineStep>,
65 #[serde(default)]
66 pub stagger: Option<f32>,
67}
68
69rustmotion_core::impl_traits!(AvatarGroup {
70 Animatable => animation,
71 Timed => timing,
72 Styled => style,
73});
74
75impl AvatarGroup {
76 pub fn visible_count(&self) -> usize {
77 match self.max_display {
78 Some(max) => max.min(self.avatars.len()),
79 None => self.avatars.len(),
80 }
81 }
82
83 pub fn overflow_count(&self) -> usize {
84 self.avatars.len().saturating_sub(self.visible_count())
85 }
86}
87
88impl AvatarGroup {
89 fn paint(&self, canvas: &Canvas) -> Result<()> {
90 let s = self.size;
91 let step = s - self.overlap;
92 let visible = self.visible_count();
93 let overflow = self.overflow_count();
94 let cache = asset_cache();
95
96 for rev_i in (0..visible).rev() {
97 let avatar = &self.avatars[rev_i];
98 let x = rev_i as f32 * step;
99
100 let mut border_paint = paint_from_hex(&self.border_color);
101 border_paint.set_style(PaintStyle::Fill);
102 border_paint.set_anti_alias(true);
103 canvas.draw_circle((x + s / 2.0, s / 2.0), s / 2.0, &border_paint);
104
105 let img = if let Some(cached) = cache.get(&avatar.src) {
106 cached.clone()
107 } else {
108 let data = std::fs::read(&avatar.src).map_err(|e| RustmotionError::ImageLoad {
109 path: avatar.src.clone(),
110 reason: e.to_string(),
111 })?;
112 let skia_data = skia_safe::Data::new_copy(&data);
113 let decoded = skia_safe::Image::from_encoded(skia_data).ok_or_else(|| {
114 RustmotionError::ImageDecode {
115 path: avatar.src.clone(),
116 }
117 })?;
118 cache.insert(avatar.src.clone(), decoded.clone());
119 decoded
120 };
121
122 let inset = self.border_width;
123 let inner_r = s / 2.0 - inset;
124 let cx = x + s / 2.0;
125 let cy = s / 2.0;
126
127 let clip_rect =
128 Rect::from_xywh(cx - inner_r, cy - inner_r, inner_r * 2.0, inner_r * 2.0);
129 let clip_rrect = RRect::new_oval(clip_rect);
130
131 canvas.save();
132 canvas.clip_rrect(clip_rrect, skia_safe::ClipOp::Intersect, true);
133
134 let img_w = img.width() as f32;
135 let img_h = img.height() as f32;
136 let d = inner_r * 2.0;
137 let scale = (d / img_w).max(d / img_h);
138 let draw_w = img_w * scale;
139 let draw_h = img_h * scale;
140 let offset_x = cx - inner_r + (d - draw_w) / 2.0;
141 let offset_y = cy - inner_r + (d - draw_h) / 2.0;
142
143 let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h);
144 canvas.draw_image_rect(img, None, dst, &Paint::default());
145 canvas.restore();
146 }
147
148 if overflow > 0 {
149 let x = visible as f32 * step;
150 let cx = x + s / 2.0;
151 let cy = s / 2.0;
152
153 let mut bg_paint = paint_from_hex("#374151");
154 bg_paint.set_style(PaintStyle::Fill);
155 bg_paint.set_anti_alias(true);
156 canvas.draw_circle((cx, cy), s / 2.0, &bg_paint);
157
158 let mut border_paint = paint_from_hex(&self.border_color);
159 border_paint.set_style(PaintStyle::Stroke);
160 border_paint.set_stroke_width(self.border_width);
161 border_paint.set_anti_alias(true);
162 canvas.draw_circle((cx, cy), s / 2.0 - self.border_width / 2.0, &border_paint);
163
164 let text = format!("+{}", overflow);
165 let font_size = s * 0.35;
166 let font_style = skia_safe::FontStyle::bold();
167 let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
168 return Ok(());
169 };
170 let font = skia_safe::Font::from_typeface(typeface, font_size);
171 let emoji_font =
172 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
173
174 let mut text_paint = paint_from_hex("#D1D5DB");
175 text_paint.set_anti_alias(true);
176
177 let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
178 let (_, metrics) = font.metrics();
179 let text_x = cx - text_w / 2.0;
180 let text_y = cy + (-metrics.ascent) / 2.0;
181
182 draw_text_with_fallback(
183 canvas,
184 &text,
185 &font,
186 &emoji_font,
187 0.0,
188 text_x,
189 text_y,
190 &text_paint,
191 );
192 }
193
194 Ok(())
195 }
196}
197
198impl Painter for AvatarGroup {
199 fn paint_content(
200 &self,
201 canvas: &Canvas,
202 _layout: &BoxLayout,
203 _props: &AnimatedProperties,
204 _ctx: &PaintCtx,
205 ) {
206 let _ = self.paint(canvas);
207 }
208}