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::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}