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)]
39pub struct AvatarGroup {
40    pub avatars: Vec<AvatarGroupItem>,
41    #[serde(default)]
42    pub max_display: Option<usize>,
43    #[serde(default = "default_avatar_size")]
44    pub size: f32,
45    #[serde(default = "default_overlap")]
46    pub overlap: f32,
47    #[serde(default = "default_border_width")]
48    pub border_width: f32,
49    #[serde(default = "default_border_color")]
50    pub border_color: String,
51    #[serde(flatten)]
52    pub timing: TimingConfig,
53    #[serde(default)]
54    pub style: CssStyle,
55    #[serde(default)]
56    pub timeline: Vec<TimelineStep>,
57    #[serde(default)]
58    pub stagger: Option<f32>,
59}
60
61rustmotion_core::impl_traits!(AvatarGroup {
62    Animatable => animation,
63    Timed => timing,
64    Styled => style,
65});
66
67impl AvatarGroup {
68    pub fn visible_count(&self) -> usize {
69        match self.max_display {
70            Some(max) => max.min(self.avatars.len()),
71            None => self.avatars.len(),
72        }
73    }
74
75    pub fn overflow_count(&self) -> usize {
76        self.avatars.len().saturating_sub(self.visible_count())
77    }
78}
79
80impl AvatarGroup {
81    fn paint(&self, canvas: &Canvas) -> Result<()> {
82        let s = self.size;
83        let step = s - self.overlap;
84        let visible = self.visible_count();
85        let overflow = self.overflow_count();
86        let cache = asset_cache();
87
88        // Draw avatars in reverse order so first avatar is on top
89        for rev_i in (0..visible).rev() {
90            let avatar = &self.avatars[rev_i];
91            let x = rev_i as f32 * step;
92
93            // Border circle (background ring)
94            let mut border_paint = paint_from_hex(&self.border_color);
95            border_paint.set_style(PaintStyle::Fill);
96            border_paint.set_anti_alias(true);
97            canvas.draw_circle((x + s / 2.0, s / 2.0), s / 2.0, &border_paint);
98
99            // Load image
100            let img = if let Some(cached) = cache.get(&avatar.src) {
101                cached.clone()
102            } else {
103                let data = std::fs::read(&avatar.src).map_err(|e| RustmotionError::ImageLoad {
104                    path: avatar.src.clone(),
105                    reason: e.to_string(),
106                })?;
107                let skia_data = skia_safe::Data::new_copy(&data);
108                let decoded = skia_safe::Image::from_encoded(skia_data).ok_or_else(|| {
109                    RustmotionError::ImageDecode {
110                        path: avatar.src.clone(),
111                    }
112                })?;
113                cache.insert(avatar.src.clone(), decoded.clone());
114                decoded
115            };
116
117            // Clip to circle inset by border_width
118            let inset = self.border_width;
119            let inner_r = s / 2.0 - inset;
120            let cx = x + s / 2.0;
121            let cy = s / 2.0;
122
123            let clip_rect =
124                Rect::from_xywh(cx - inner_r, cy - inner_r, inner_r * 2.0, inner_r * 2.0);
125            let clip_rrect = RRect::new_oval(clip_rect);
126
127            canvas.save();
128            canvas.clip_rrect(clip_rrect, skia_safe::ClipOp::Intersect, true);
129
130            // Draw image with cover fit
131            let img_w = img.width() as f32;
132            let img_h = img.height() as f32;
133            let d = inner_r * 2.0;
134            let scale = (d / img_w).max(d / img_h);
135            let draw_w = img_w * scale;
136            let draw_h = img_h * scale;
137            let offset_x = cx - inner_r + (d - draw_w) / 2.0;
138            let offset_y = cy - inner_r + (d - draw_h) / 2.0;
139
140            let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h);
141            canvas.draw_image_rect(img, None, dst, &Paint::default());
142            canvas.restore();
143        }
144
145        // "+N" overflow badge
146        if overflow > 0 {
147            let x = visible as f32 * step;
148            let cx = x + s / 2.0;
149            let cy = s / 2.0;
150
151            // Background circle
152            let mut bg_paint = paint_from_hex("#374151");
153            bg_paint.set_style(PaintStyle::Fill);
154            bg_paint.set_anti_alias(true);
155            canvas.draw_circle((cx, cy), s / 2.0, &bg_paint);
156
157            // Border
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            // Text
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 = skia_safe::Font::from_typeface(typeface, font_size);
172            let emoji_font =
173                emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
174
175            let mut text_paint = paint_from_hex("#D1D5DB");
176            text_paint.set_anti_alias(true);
177
178            let text_w = measure_text_with_fallback(&text, &font, &emoji_font, 0.0);
179            let (_, metrics) = font.metrics();
180            let text_x = cx - text_w / 2.0;
181            let text_y = cy + (-metrics.ascent) / 2.0;
182
183            draw_text_with_fallback(
184                canvas,
185                &text,
186                &font,
187                &emoji_font,
188                0.0,
189                text_x,
190                text_y,
191                &text_paint,
192            );
193        }
194
195        Ok(())
196    }
197}
198
199impl Painter for AvatarGroup {
200    fn paint_content(
201        &self,
202        canvas: &Canvas,
203        _layout: &BoxLayout,
204        _props: &AnimatedProperties,
205        _ctx: &PaintCtx,
206    ) {
207        let _ = self.paint(canvas);
208    }
209}