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