Skip to main content

rustmotion_components/
badge.rs

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