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::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    /// Show a small colored dot indicator.
64    #[serde(default)]
65    pub dot: bool,
66    /// Dot color (defaults to the badge background color).
67    #[serde(default)]
68    pub dot_color: Option<String>,
69    /// Animate the dot with a pulse effect.
70    #[serde(default)]
71    pub pulse: bool,
72    /// Show a numeric count badge (e.g. notification count).
73    #[serde(default)]
74    pub count: Option<u32>,
75    #[serde(flatten)]
76    pub timing: TimingConfig,
77    /// `align-self` defaults to `flex-start` (not the flex container's
78    /// `stretch`) — a badge is an atomic icon+label chip that must keep its
79    /// natural intrinsic width even inside a `flex`/`card` column; without
80    /// this, the default cross-axis `stretch` wins over `BadgeIntrinsic`
81    /// and the chip becomes a full-width bar. An author-specified
82    /// `align-self` in JSON is always respected (this only fills the gap
83    /// when it's absent).
84    #[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}