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