Skip to main content

rustmotion_components/
avatar_group.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::{
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}