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        // (font_size, h_padding, v_padding, icon_size)
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)]
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    /// Show a small colored dot indicator.
56    #[serde(default)]
57    pub dot: bool,
58    /// Dot color (defaults to the badge background color).
59    #[serde(default)]
60    pub dot_color: Option<String>,
61    /// Animate the dot with a pulse effect.
62    #[serde(default)]
63    pub pulse: bool,
64    /// Show a numeric count badge (e.g. notification count).
65    #[serde(default)]
66    pub count: Option<u32>,
67    #[serde(flatten)]
68    pub timing: TimingConfig,
69    /// `align-self` defaults to `flex-start` (not the flex container's
70    /// `stretch`) — a badge is an atomic icon+label chip that must keep its
71    /// natural intrinsic width even inside a `flex`/`card` column; without
72    /// this, the default cross-axis `stretch` wins over `BadgeIntrinsic`
73    /// and the chip becomes a full-width bar. An author-specified
74    /// `align-self` in JSON is always respected (this only fills the gap
75    /// when it's absent).
76    #[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
94/// Deserializes `style` normally, then defaults `align-self` to
95/// `flex-start` when the author didn't set it explicitly — see the doc
96/// comment on [`Badge::style`].
97fn 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    /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/
116    /// `vh` now resolve instead of silently dropping to 0px — lot B, wave
117    /// S). `em`/`%` on `font-size` itself remain approximate — see
118    /// `crate::intrinsic::font_size_ctx`'s doc comment.
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    /// Returns (h_padding, v_padding, icon_size) scaled proportionally
127    /// to the resolved font size. If style.font_size overrides the default,
128    /// padding and icon scale with it.
129    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        // Background / outline
157        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        // Icon
176        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        // Text
239        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        // Use cap height for visual centering (excludes descenders like g, p, y)
255        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        // Dot indicator (top-right)
274        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            // Pulse ring animation
281            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        // Count badge (top-right, outside bounds)
299        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            // Red background pill
323            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            // Count text
331            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        // #127: `align-items: stretch` (the flex column default) was
373        // winning over `BadgeIntrinsic`, stretching every badge in a card
374        // to the container's full width. No `style` key at all in the
375        // JSON is the common case — this must still default away from
376        // stretch.
377        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        // Same fix, but exercised through the `deserialize_with` path
384        // (some `style` object present, just not `align-self`) rather
385        // than the field-level `default` path (`style` entirely absent).
386        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    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
398
399    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        // Reproduction: `font-size: "2rem"` used to resolve to 0px via the
415        // context-free `font_size_px_or`.
416        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        // Solid variant text is always white — probe for white ink
442        // specifically, since the pill background paints regardless.
443        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}