1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, ColorType, ImageInfo, Paint, PaintStyle, RRect, Rect};
4
5use rustmotion_core::css::style::AlignSelf;
6use rustmotion_core::css::CssStyle;
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, fetch_icon_svg,
11 measure_text_with_fallback, paint_from_hex, typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18#[derive(Default)]
19pub enum BadgeVariant {
20 #[default]
21 Solid,
22 Outline,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27#[derive(Default)]
28pub enum BadgeSize {
29 Sm,
30 #[default]
31 Md,
32 Lg,
33}
34
35impl BadgeSize {
36 fn params(&self) -> (f32, f32, f32, f32) {
37 match self {
39 BadgeSize::Sm => (12.0, 8.0, 4.0, 14.0),
40 BadgeSize::Md => (14.0, 12.0, 6.0, 18.0),
41 BadgeSize::Lg => (18.0, 16.0, 8.0, 22.0),
42 }
43 }
44}
45
46#[derive(Debug, Serialize, Deserialize, JsonSchema)]
47pub struct Badge {
48 pub text: String,
49 #[serde(default)]
50 pub icon: Option<String>,
51 #[serde(default)]
52 pub variant: BadgeVariant,
53 #[serde(default)]
54 pub badge_size: BadgeSize,
55 #[serde(default)]
57 pub dot: bool,
58 #[serde(default)]
60 pub dot_color: Option<String>,
61 #[serde(default)]
63 pub pulse: bool,
64 #[serde(default)]
66 pub count: Option<u32>,
67 #[serde(flatten)]
68 pub timing: TimingConfig,
69 #[serde(
77 default = "default_badge_style",
78 deserialize_with = "deserialize_no_stretch_style"
79 )]
80 pub style: CssStyle,
81 #[serde(default)]
82 pub timeline: Vec<TimelineStep>,
83 #[serde(default)]
84 pub stagger: Option<f32>,
85}
86
87fn default_badge_style() -> CssStyle {
88 CssStyle {
89 align_self: Some(AlignSelf::FlexStart),
90 ..CssStyle::default()
91 }
92}
93
94fn deserialize_no_stretch_style<'de, D>(deserializer: D) -> Result<CssStyle, D::Error>
98where
99 D: serde::Deserializer<'de>,
100{
101 let mut style = CssStyle::deserialize(deserializer)?;
102 if style.align_self.is_none() {
103 style.align_self = Some(AlignSelf::FlexStart);
104 }
105 Ok(style)
106}
107
108rustmotion_core::impl_traits!(Badge {
109 Animatable => animation,
110 Timed => timing,
111 Styled => style,
112});
113
114impl Badge {
115 fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 {
120 self.style.font_size_px_ctx(
121 &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0),
122 self.badge_size.params().0,
123 )
124 }
125
126 fn resolved_params(&self, ctx: &PaintCtx) -> (f32, f32, f32) {
130 let (default_fs, h_pad, v_pad, icon_size) = self.badge_size.params();
131 let actual_fs = self.resolved_font_size(ctx);
132 let ratio = actual_fs / default_fs;
133 (h_pad * ratio, v_pad * ratio, icon_size * ratio)
134 }
135
136 fn make_font(&self, ctx: &PaintCtx) -> Option<skia_safe::Font> {
137 let font_style = skia_safe::FontStyle::normal();
138 let family = self.style.font_family.as_deref().unwrap_or("Inter");
139 let typeface = typeface_with_fallback(family, font_style).ok()?;
140 Some(skia_safe::Font::from_typeface(
141 typeface,
142 self.resolved_font_size(ctx),
143 ))
144 }
145}
146
147impl Badge {
148 fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64, ctx: &PaintCtx) {
149 let color = self.style.background_color_str().unwrap_or("#3B82F6");
150 let (h_pad, _v_pad, icon_size) = self.resolved_params(ctx);
151
152 let w = layout_w;
153 let h = layout_h;
154 let radius = h / 2.0;
155
156 let rect = Rect::from_xywh(0.0, 0.0, w, h);
158 let rrect = RRect::new_rect_xy(rect, radius, radius);
159
160 let mut bg_paint = paint_from_hex(color);
161 bg_paint.set_anti_alias(true);
162
163 match self.variant {
164 BadgeVariant::Solid => {
165 bg_paint.set_style(PaintStyle::Fill);
166 canvas.draw_rrect(rrect, &bg_paint);
167 }
168 BadgeVariant::Outline => {
169 bg_paint.set_style(PaintStyle::Stroke);
170 bg_paint.set_stroke_width(1.5);
171 canvas.draw_rrect(rrect, &bg_paint);
172 }
173 }
174
175 let mut x_offset = h_pad;
177 if let Some(icon_id) = &self.icon {
178 let icon_color = if matches!(self.variant, BadgeVariant::Solid) {
179 "#FFFFFF"
180 } else {
181 color
182 };
183
184 let icon_w = icon_size.round() as u32;
185 let icon_h = icon_size.round() as u32;
186 let cache_key = format!("icon:{}:{}:{}x{}", icon_id, icon_color, icon_w, icon_h);
187
188 let cache = asset_cache();
189 let img = if let Some(cached) = cache.get(&cache_key) {
190 cached.clone()
191 } else if let Ok(svg_data) = fetch_icon_svg(icon_id, icon_color, icon_w, icon_h) {
192 let opt = usvg::Options::default();
193 if let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) {
194 let svg_size = tree.size();
195 if let Some(mut pixmap) = tiny_skia::Pixmap::new(icon_w, icon_h) {
196 let sx = icon_w as f32 / svg_size.width();
197 let sy = icon_h as f32 / svg_size.height();
198 resvg::render(
199 &tree,
200 tiny_skia::Transform::from_scale(sx, sy),
201 &mut pixmap.as_mut(),
202 );
203 let img_data = skia_safe::Data::new_copy(pixmap.data());
204 let info = ImageInfo::new(
205 (icon_w as i32, icon_h as i32),
206 ColorType::RGBA8888,
207 skia_safe::AlphaType::Premul,
208 None,
209 );
210 if let Some(decoded) = skia_safe::images::raster_from_data(
211 &info,
212 img_data,
213 icon_w as usize * 4,
214 ) {
215 cache.insert(cache_key, decoded.clone());
216 decoded
217 } else {
218 return;
219 }
220 } else {
221 return;
222 }
223 } else {
224 return;
225 }
226 } else {
227 return;
228 };
229
230 let icon_y = (h - icon_size) / 2.0;
231 let dst = Rect::from_xywh(x_offset, icon_y, icon_size, icon_size);
232 canvas.draw_image_rect(img, None, dst, &Paint::default());
233
234 let ratio = self.resolved_font_size(ctx) / self.badge_size.params().0;
235 x_offset += icon_size + 6.0 * ratio;
236 }
237
238 let text_color = if matches!(self.variant, BadgeVariant::Solid) {
240 "#FFFFFF"
241 } else {
242 color
243 };
244 let Some(font) = self.make_font(ctx) else {
245 return;
246 };
247 let font_size = self.resolved_font_size(ctx);
248 let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
249 let mut text_paint = paint_from_hex(text_color);
250 text_paint.set_anti_alias(true);
251
252 let (_, metrics) = font.metrics();
253 let ascent = -metrics.ascent;
254 let cap_h = if metrics.cap_height > 0.0 {
256 metrics.cap_height
257 } else {
258 ascent * 0.7
259 };
260 let text_y = (h - cap_h) / 2.0 + cap_h;
261
262 draw_text_with_fallback(
263 canvas,
264 &self.text,
265 &font,
266 &emoji_font,
267 0.0,
268 x_offset,
269 text_y,
270 &text_paint,
271 );
272
273 if self.dot {
275 let dot_r = font_size * 0.3;
276 let dot_cx = w - dot_r * 0.5;
277 let dot_cy = dot_r * 0.5;
278 let dot_color = self.dot_color.as_deref().unwrap_or(color);
279
280 if self.pulse {
282 let phase = (time * 2.0).fract() as f32;
283 let pulse_r = dot_r * (1.0 + phase * 1.5);
284 let pulse_alpha = (1.0 - phase).max(0.0) * 0.5;
285 let mut pulse_paint = paint_from_hex(dot_color);
286 pulse_paint.set_style(PaintStyle::Fill);
287 pulse_paint.set_anti_alias(true);
288 pulse_paint.set_alpha_f(pulse_alpha);
289 canvas.draw_circle((dot_cx, dot_cy), pulse_r, &pulse_paint);
290 }
291
292 let mut dot_paint = paint_from_hex(dot_color);
293 dot_paint.set_style(PaintStyle::Fill);
294 dot_paint.set_anti_alias(true);
295 canvas.draw_circle((dot_cx, dot_cy), dot_r, &dot_paint);
296 }
297
298 if let Some(count) = self.count {
300 let count_text = if count > 99 {
301 "99+".to_string()
302 } else {
303 count.to_string()
304 };
305
306 let count_fs = font_size * 0.65;
307 let Ok(count_typeface) = typeface_with_fallback("Inter", skia_safe::FontStyle::bold())
308 else {
309 return;
310 };
311 let count_font = skia_safe::Font::from_typeface(count_typeface, count_fs);
312 let count_emoji =
313 emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, count_fs));
314
315 let count_w = measure_text_with_fallback(&count_text, &count_font, &count_emoji, 0.0);
316 let badge_pad = count_fs * 0.4;
317 let badge_w = (count_w + badge_pad * 2.0).max(count_fs * 1.3);
318 let badge_h = count_fs * 1.4;
319 let badge_x = w - badge_w * 0.5;
320 let badge_y = -badge_h * 0.3;
321
322 let badge_rect = Rect::from_xywh(badge_x, badge_y, badge_w, badge_h);
324 let badge_rrect = RRect::new_rect_xy(badge_rect, badge_h / 2.0, badge_h / 2.0);
325 let mut count_bg = paint_from_hex("#EF4444");
326 count_bg.set_style(PaintStyle::Fill);
327 count_bg.set_anti_alias(true);
328 canvas.draw_rrect(badge_rrect, &count_bg);
329
330 let mut count_paint = paint_from_hex("#FFFFFF");
332 count_paint.set_anti_alias(true);
333 let (_, count_metrics) = count_font.metrics();
334 let cx = badge_x + (badge_w - count_w) / 2.0;
335 let cy = badge_y + (badge_h + (-count_metrics.ascent)) / 2.0;
336 draw_text_with_fallback(
337 canvas,
338 &count_text,
339 &count_font,
340 &count_emoji,
341 0.0,
342 cx,
343 cy,
344 &count_paint,
345 );
346 }
347 }
348}
349
350impl Painter for Badge {
351 fn paint_content(
352 &self,
353 canvas: &Canvas,
354 layout: &BoxLayout,
355 _props: &AnimatedProperties,
356 ctx: &PaintCtx,
357 ) {
358 self.paint(canvas, layout.width, layout.height, ctx.time, ctx);
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 fn parse(json: &str) -> Badge {
367 serde_json::from_str(json).expect("badge should deserialize")
368 }
369
370 #[test]
371 fn style_defaults_to_flex_start_when_absent() {
372 let badge = parse(r#"{"type":"badge","text":"v1"}"#);
378 assert_eq!(badge.style.align_self, Some(AlignSelf::FlexStart));
379 }
380
381 #[test]
382 fn style_defaults_to_flex_start_with_other_style_keys_present() {
383 let badge = parse(r##"{"type":"badge","text":"v1","style":{"background":"#f00"}}"##);
387 assert_eq!(badge.style.align_self, Some(AlignSelf::FlexStart));
388 assert_eq!(badge.style.background_color_str(), Some("#f00"));
389 }
390
391 #[test]
392 fn explicit_align_self_is_respected() {
393 let badge = parse(r#"{"type":"badge","text":"v1","style":{"align-self":"center"}}"#);
394 assert_eq!(badge.style.align_self, Some(AlignSelf::Center));
395 }
396
397 fn test_ctx() -> PaintCtx {
400 PaintCtx {
401 time: 0.0,
402 scenario_time: 0.0,
403 scene_duration: 1.0,
404 frame_index: 0,
405 fps: 30,
406 video_width: 400,
407 video_height: 200,
408 stagger_offset: 0.0,
409 }
410 }
411
412 #[test]
413 fn rem_font_size_paints_visible_ink() {
414 let mut badge = parse(r#"{"type":"badge","text":"v1"}"#);
417 badge.style.font_size = Some(rustmotion_core::css::Length::String("2rem".into()));
418 const W: i32 = 200;
419 const H: i32 = 100;
420 let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
421 {
422 let canvas = surface.canvas();
423 badge.paint(canvas, 150.0, 60.0, 0.0, &test_ctx());
424 }
425 let snapshot = surface.image_snapshot();
426 let info = skia_safe::ImageInfo::new(
427 (W, H),
428 skia_safe::ColorType::RGBA8888,
429 skia_safe::AlphaType::Premul,
430 None,
431 );
432 let mut buf = vec![0u8; (W * H * 4) as usize];
433 let ok = snapshot.read_pixels(
434 &info,
435 &mut buf,
436 (W * 4) as usize,
437 skia_safe::IPoint::new(0, 0),
438 skia_safe::image::CachingHint::Disallow,
439 );
440 assert!(ok, "pixel read should succeed");
441 let text_ink = buf
444 .chunks_exact(4)
445 .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200)
446 .count();
447 assert!(
448 text_ink > 5,
449 "badge at font-size: 2rem must paint visible text, got {text_ink} pixels"
450 );
451 }
452}