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)]
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 for rev_i in (0..visible).rev() {
90 let avatar = &self.avatars[rev_i];
91 let x = rev_i as f32 * step;
92
93 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 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 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 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 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 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 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);
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}