Skip to main content

rustmotion_components/
counter.rs

1use rustmotion_core::error::Result;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use skia_safe::{Canvas, Font, FontStyle, PaintStyle};
5
6use rustmotion_core::css::style::{
7    FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, TextAlign as CssTextAlign,
8};
9use rustmotion_core::css::CssStyle;
10use rustmotion_core::engine::animator::AnimatedProperties;
11use rustmotion_core::engine::layout_pass::BoxLayout;
12use rustmotion_core::engine::renderer::{
13    draw_text_with_fallback, emoji_typeface, format_counter_value, measure_text_with_fallback,
14    paint_from_hex, typeface_with_fallback,
15};
16use rustmotion_core::schema::{
17    EasingType, FontStyleType, FontWeight, Stroke, TextAlign, TextShadow, TimelineStep,
18};
19use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
20
21#[derive(Debug, Serialize, Deserialize, JsonSchema)]
22pub struct Counter {
23    pub from: f64,
24    pub to: f64,
25    #[serde(default)]
26    pub decimals: u8,
27    #[serde(default)]
28    pub separator: Option<String>,
29    #[serde(default)]
30    pub prefix: Option<String>,
31    #[serde(default)]
32    pub suffix: Option<String>,
33    #[serde(default)]
34    pub easing: EasingType,
35    /// Seconds the count takes to run, measured from `start_at`.
36    ///
37    /// Left unset the count stretches over whatever remains of the scene, so it
38    /// only reaches `to` on the very last frame and the viewer never gets to
39    /// read the figure they were counting towards. Setting this shorter than
40    /// the scene makes the count land early and hold.
41    #[serde(default)]
42    pub duration: Option<f64>,
43    #[serde(flatten)]
44    pub timing: TimingConfig,
45    #[serde(default)]
46    pub style: CssStyle,
47    #[serde(default)]
48    pub timeline: Vec<TimelineStep>,
49    #[serde(default)]
50    pub stagger: Option<f32>,
51    #[serde(default, rename = "text-shadow")]
52    pub text_shadow: Option<TextShadow>,
53    #[serde(default)]
54    pub stroke: Option<Stroke>,
55}
56
57rustmotion_core::impl_traits!(Counter {
58    Animatable => animation,
59    Timed => timing,
60    Styled => style,
61});
62
63impl Counter {
64    /// Where the count sits on its 0..1 ramp at `time`, before easing.
65    ///
66    /// The ramp starts at `start_at` and runs for `duration`, falling back to
67    /// the rest of the scene when no duration is given.
68    fn ramp_progress(&self, time: f64, scene_duration: f64) -> f64 {
69        let start = self.timing.start_at.unwrap_or(0.0);
70        let elapsed = (time - start).max(0.0);
71        let ramp = match self.duration {
72            Some(d) if d > 0.0 => d,
73            _ => scene_duration - start,
74        };
75        if ramp > 0.0 {
76            (elapsed / ramp).clamp(0.0, 1.0)
77        } else {
78            1.0
79        }
80    }
81
82    fn paint(
83        &self,
84        canvas: &Canvas,
85        layout_width: f32,
86        time: f64,
87        scene_duration: f64,
88        props: &AnimatedProperties,
89        ctx: &PaintCtx,
90    ) -> Result<()> {
91        use rustmotion_core::engine::animator::ease;
92
93        // Lot B (wave S): `font-size` now resolves through the same
94        // context-aware machinery `text-shadow` already used below (see
95        // `lctx`) — it used to stay on the context-free `font_size_px_or`,
96        // silently dropping `rem`/`vw`/`vh` font-size to 0px. `em`/`%` on
97        // `font-size` itself remain approximate — see
98        // `crate::intrinsic::font_size_ctx`'s doc comment.
99        let base_ctx = crate::intrinsic::font_size_ctx(
100            ctx.video_width as f32,
101            ctx.video_height as f32,
102            layout_width.max(0.0),
103        );
104        let font_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
105        // Animated color (timeline style-state transitions) overrides the
106        // static style color.
107        let color = props
108            .color
109            .as_deref()
110            .unwrap_or_else(|| self.style.color_str_or("#FFFFFF"));
111        let font_family = self.style.font_family_or("Inter");
112        let font_weight = match &self.style.font_weight {
113            Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
114                FontWeight::Bold
115            }
116            Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
117            Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
118            _ => FontWeight::Normal,
119        };
120        let font_style_type = match self.style.font_style {
121            Some(CssFontStyle::Italic) => FontStyleType::Italic,
122            Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
123            _ => FontStyleType::Normal,
124        };
125        let align = match self.style.text_align {
126            Some(CssTextAlign::Center) => TextAlign::Center,
127            Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
128            _ => TextAlign::Left,
129        };
130
131        let progress = ease(self.ramp_progress(time, scene_duration), &self.easing);
132        let value = self.from + (self.to - self.from) * progress;
133        let content = format_counter_value(
134            value,
135            self.decimals,
136            &self.separator,
137            &self.prefix,
138            &self.suffix,
139        );
140
141        let slant = match font_style_type {
142            FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
143            FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
144            FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
145        };
146        let weight = match font_weight {
147            FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
148            FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
149            FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
150        };
151        let skia_font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
152
153        let typeface = typeface_with_fallback(font_family, skia_font_style)?;
154
155        let font = Font::from_typeface(typeface, font_size);
156        let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
157        let mut paint = paint_from_hex(color);
158        paint.set_alpha_f(1.0);
159
160        // This element's own resolved font-size as the `em`/`%` base for
161        // `letter-spacing` and (below) `text-shadow` — same context reused
162        // for both instead of each rebuilding an equivalent one.
163        let own_ctx = rustmotion_core::css::units::LengthContext {
164            font_size,
165            ..base_ctx
166        };
167        let letter_spacing = self.style.letter_spacing_px_ctx(&own_ctx);
168
169        let advance_width =
170            measure_text_with_fallback(&content, &font, &emoji_font, letter_spacing);
171
172        // For center/right alignment, anchor positioning on the same `absmax`
173        // width that `measure()` reserved. This keeps the right edge (or
174        // bounding box midpoint) of the counter stable across frames instead
175        // of letting it shift sub-pixel as the digit count changes.
176        let stable_width = if matches!(align, TextAlign::Center | TextAlign::Right) {
177            let absmax = self.from.abs().max(self.to.abs());
178            let signed = if self.from < 0.0 || self.to < 0.0 {
179                -absmax
180            } else {
181                absmax
182            };
183            let display = format_counter_value(
184                signed,
185                self.decimals,
186                &self.separator,
187                &self.prefix,
188                &self.suffix,
189            );
190            measure_text_with_fallback(&display, &font, &emoji_font, letter_spacing)
191        } else {
192            advance_width
193        };
194
195        let raw_x = match align {
196            TextAlign::Left => 0.0,
197            TextAlign::Center => {
198                (layout_width - stable_width) / 2.0 + (stable_width - advance_width) / 2.0
199            }
200            TextAlign::Right => layout_width - advance_width,
201        };
202        // Snap to whole pixels to eliminate the sub-pixel jitter that the
203        // glyph rasterizer would otherwise introduce on a moving counter.
204        let x = raw_x.round();
205        let (_, metrics) = font.metrics();
206        let line_height = font_size * 1.3;
207        let ascent = -metrics.ascent;
208        let descent = metrics.descent;
209        let y = (line_height + ascent - descent) / 2.0;
210
211        // Draw shadows — component field wins, else the bridged CSS
212        // `style.text-shadow` list (reverse order: first shadow on top).
213        let shadows: Vec<rustmotion_core::schema::TextShadow> = if let Some(s) = &self.text_shadow {
214            vec![s.clone()]
215        } else if let Some(list) = &self.style.text_shadow {
216            list.iter().map(|s| s.to_schema(&own_ctx)).collect()
217        } else {
218            Vec::new()
219        };
220        for shadow in shadows.iter().rev() {
221            let mut sp = paint_from_hex(&shadow.color);
222            if shadow.blur > 0.01 {
223                if let Some(filter) = skia_safe::image_filters::blur(
224                    (shadow.blur, shadow.blur),
225                    skia_safe::TileMode::Clamp,
226                    None,
227                    None,
228                ) {
229                    sp.set_image_filter(filter);
230                }
231            }
232            draw_text_with_fallback(
233                canvas,
234                &content,
235                &font,
236                &emoji_font,
237                letter_spacing,
238                x + shadow.offset_x,
239                y + shadow.offset_y,
240                &sp,
241            );
242        }
243
244        // Draw stroke
245        if let Some(ref stroke) = self.stroke {
246            let mut sp = paint_from_hex(&stroke.color);
247            sp.set_style(PaintStyle::Stroke);
248            sp.set_stroke_width(stroke.width);
249            draw_text_with_fallback(
250                canvas,
251                &content,
252                &font,
253                &emoji_font,
254                letter_spacing,
255                x,
256                y,
257                &sp,
258            );
259        }
260
261        draw_text_with_fallback(
262            canvas,
263            &content,
264            &font,
265            &emoji_font,
266            letter_spacing,
267            x,
268            y,
269            &paint,
270        );
271
272        Ok(())
273    }
274}
275
276impl Painter for Counter {
277    fn paint_content(
278        &self,
279        canvas: &Canvas,
280        layout: &BoxLayout,
281        props: &AnimatedProperties,
282        ctx: &PaintCtx,
283    ) {
284        let _ = self.paint(
285            canvas,
286            layout.width,
287            ctx.time,
288            ctx.scene_duration,
289            props,
290            ctx,
291        );
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn counter(duration: Option<f64>, start_at: Option<f64>) -> Counter {
300        Counter {
301            from: 0.0,
302            to: 100.0,
303            decimals: 0,
304            separator: None,
305            prefix: None,
306            suffix: None,
307            easing: EasingType::default(),
308            duration,
309            timing: TimingConfig {
310                start_at,
311                end_at: None,
312            },
313            style: CssStyle::default(),
314            timeline: Vec::new(),
315            stagger: None,
316            text_shadow: None,
317            stroke: None,
318        }
319    }
320
321    #[test]
322    fn without_duration_the_count_only_lands_on_the_last_frame() {
323        // The behaviour that made counters unreadable: nothing settles early,
324        // so the figure is still moving when the scene cuts away.
325        let c = counter(None, None);
326        assert!(c.ramp_progress(3.9, 4.0) < 1.0);
327        assert_eq!(c.ramp_progress(4.0, 4.0), 1.0);
328    }
329
330    #[test]
331    fn duration_makes_the_count_land_early_and_hold() {
332        let c = counter(Some(1.5), None);
333        assert_eq!(c.ramp_progress(1.5, 4.0), 1.0);
334        // Held for the rest of the scene, which is the point.
335        assert_eq!(c.ramp_progress(3.0, 4.0), 1.0);
336        assert!((c.ramp_progress(0.75, 4.0) - 0.5).abs() < 1e-9);
337    }
338
339    #[test]
340    fn duration_is_measured_from_start_at() {
341        let c = counter(Some(2.0), Some(1.0));
342        assert_eq!(c.ramp_progress(1.0, 6.0), 0.0);
343        assert!((c.ramp_progress(2.0, 6.0) - 0.5).abs() < 1e-9);
344        assert_eq!(c.ramp_progress(3.0, 6.0), 1.0);
345    }
346
347    #[test]
348    fn a_duration_outlasting_the_scene_is_honoured_not_clamped() {
349        // Deliberate: the author asked for a slow count, and silently speeding
350        // it up would be a surprise. It simply never reaches `to`.
351        let c = counter(Some(10.0), None);
352        assert!(c.ramp_progress(4.0, 4.0) < 0.5);
353    }
354
355    #[test]
356    fn a_zero_or_negative_duration_falls_back_to_the_scene() {
357        let c = counter(Some(0.0), None);
358        assert!((c.ramp_progress(2.0, 4.0) - 0.5).abs() < 1e-9);
359    }
360
361    // ─── Lot B, wave S: relative `font-size` units ─────────────────────────
362
363    #[test]
364    fn rem_font_size_paints_visible_ink() {
365        // Reproduction: `font-size: "2rem"` used to resolve to 0px on the
366        // context-free `font_size_px_or` path.
367        let mut c = counter(None, None);
368        c.style.font_size = Some(rustmotion_core::css::Length::String("2rem".into()));
369        c.style.color = Some(rustmotion_core::css::style::Color::String("#FFFFFF".into()));
370
371        const W: i32 = 300;
372        const H: i32 = 150;
373        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
374        let ctx = PaintCtx {
375            time: 1.0,
376            scenario_time: 1.0,
377            scene_duration: 2.0,
378            frame_index: 30,
379            fps: 30,
380            video_width: 300,
381            video_height: 150,
382            stagger_offset: 0.0,
383        };
384        let props = AnimatedProperties::default();
385        {
386            let canvas = surface.canvas();
387            c.paint(canvas, 200.0, 1.0, 2.0, &props, &ctx)
388                .expect("paint succeeds");
389        }
390        let snapshot = surface.image_snapshot();
391        let info = skia_safe::ImageInfo::new(
392            (W, H),
393            skia_safe::ColorType::RGBA8888,
394            skia_safe::AlphaType::Premul,
395            None,
396        );
397        let mut buf = vec![0u8; (W * H * 4) as usize];
398        let ok = snapshot.read_pixels(
399            &info,
400            &mut buf,
401            (W * 4) as usize,
402            skia_safe::IPoint::new(0, 0),
403            skia_safe::image::CachingHint::Disallow,
404        );
405        assert!(ok, "pixel read should succeed");
406        let lit = buf.chunks_exact(4).filter(|p| p[3] > 0).count();
407        assert!(
408            lit > 20,
409            "counter at font-size: 2rem must paint visible ink, got {lit} lit pixels"
410        );
411    }
412}