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)]
22#[deprecated(
23    since = "0.7.1",
24    note = "`counter` is a frozen composition (issue #333). Compose a plain `text` node and \
25            drive its displayed value with an animation/expression on content instead — see \
26            crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
27            scheduled for removal in a future major version via `rustmotion migrate` (#335)."
28)]
29pub struct Counter {
30    pub from: f64,
31    pub to: f64,
32    #[serde(default)]
33    pub decimals: u8,
34    #[serde(default)]
35    pub separator: Option<String>,
36    #[serde(default)]
37    pub prefix: Option<String>,
38    #[serde(default)]
39    pub suffix: Option<String>,
40    #[serde(default)]
41    pub easing: EasingType,
42    /// Seconds the count takes to run, measured from `start_at`.
43    ///
44    /// Left unset the count stretches over whatever remains of the scene, so it
45    /// only reaches `to` on the very last frame and the viewer never gets to
46    /// read the figure they were counting towards. Setting this shorter than
47    /// the scene makes the count land early and hold.
48    #[serde(default)]
49    pub duration: Option<f64>,
50    #[serde(flatten)]
51    pub timing: TimingConfig,
52    #[serde(default)]
53    pub style: CssStyle,
54    #[serde(default)]
55    pub timeline: Vec<TimelineStep>,
56    #[serde(default)]
57    pub stagger: Option<f32>,
58    #[serde(default, rename = "text-shadow")]
59    pub text_shadow: Option<TextShadow>,
60    #[serde(default)]
61    pub stroke: Option<Stroke>,
62}
63
64rustmotion_core::impl_traits!(Counter {
65    Animatable => animation,
66    Timed => timing,
67    Styled => style,
68});
69
70impl Counter {
71    fn ramp_progress(&self, time: f64, scene_duration: f64) -> f64 {
72        let start = self.timing.start_at.unwrap_or(0.0);
73        let elapsed = (time - start).max(0.0);
74        let ramp = match self.duration {
75            Some(d) if d > 0.0 => d,
76            _ => scene_duration - start,
77        };
78        if ramp > 0.0 {
79            (elapsed / ramp).clamp(0.0, 1.0)
80        } else {
81            1.0
82        }
83    }
84
85    fn paint(
86        &self,
87        canvas: &Canvas,
88        layout_width: f32,
89        time: f64,
90        scene_duration: f64,
91        props: &AnimatedProperties,
92        ctx: &PaintCtx,
93    ) -> Result<()> {
94        use rustmotion_core::engine::animator::ease;
95
96        let base_ctx = crate::intrinsic::font_size_ctx(
97            ctx.video_width as f32,
98            ctx.video_height as f32,
99            layout_width.max(0.0),
100        );
101        let font_size = self.style.font_size_px_ctx(&base_ctx, 48.0);
102        let color = props
103            .color
104            .as_deref()
105            .unwrap_or_else(|| self.style.color_str_or("#FFFFFF"));
106        let font_family = self.style.font_family_or("Inter");
107        let font_weight = match &self.style.font_weight {
108            Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
109                FontWeight::Bold
110            }
111            Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
112            Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
113            _ => FontWeight::Normal,
114        };
115        let font_style_type = match self.style.font_style {
116            Some(CssFontStyle::Italic) => FontStyleType::Italic,
117            Some(CssFontStyle::Oblique) => FontStyleType::Oblique,
118            _ => FontStyleType::Normal,
119        };
120        let align = match self.style.text_align {
121            Some(CssTextAlign::Center) => TextAlign::Center,
122            Some(CssTextAlign::Right | CssTextAlign::End) => TextAlign::Right,
123            _ => TextAlign::Left,
124        };
125
126        let progress = ease(self.ramp_progress(time, scene_duration), &self.easing);
127        let value = self.from + (self.to - self.from) * progress;
128        let content = format_counter_value(
129            value,
130            self.decimals,
131            &self.separator,
132            &self.prefix,
133            &self.suffix,
134        );
135
136        let slant = match font_style_type {
137            FontStyleType::Normal => skia_safe::font_style::Slant::Upright,
138            FontStyleType::Italic => skia_safe::font_style::Slant::Italic,
139            FontStyleType::Oblique => skia_safe::font_style::Slant::Oblique,
140        };
141        let weight = match font_weight {
142            FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
143            FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
144            FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
145        };
146        let skia_font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant);
147
148        let typeface = typeface_with_fallback(font_family, skia_font_style)?;
149
150        let font = Font::from_typeface(typeface, font_size);
151        let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));
152        let mut paint = paint_from_hex(color);
153        paint.set_alpha_f(1.0);
154
155        let own_ctx = rustmotion_core::css::units::LengthContext {
156            font_size,
157            ..base_ctx
158        };
159        let letter_spacing = self.style.letter_spacing_px_ctx(&own_ctx);
160
161        let advance_width =
162            measure_text_with_fallback(&content, &font, &emoji_font, letter_spacing);
163
164        let stable_width = if matches!(align, TextAlign::Center | TextAlign::Right) {
165            let absmax = self.from.abs().max(self.to.abs());
166            let signed = if self.from < 0.0 || self.to < 0.0 {
167                -absmax
168            } else {
169                absmax
170            };
171            let display = format_counter_value(
172                signed,
173                self.decimals,
174                &self.separator,
175                &self.prefix,
176                &self.suffix,
177            );
178            measure_text_with_fallback(&display, &font, &emoji_font, letter_spacing)
179        } else {
180            advance_width
181        };
182
183        let raw_x = match align {
184            TextAlign::Left => 0.0,
185            TextAlign::Center => {
186                (layout_width - stable_width) / 2.0 + (stable_width - advance_width) / 2.0
187            }
188            TextAlign::Right => layout_width - advance_width,
189        };
190        let x = raw_x.round();
191        let (_, metrics) = font.metrics();
192        let line_height = font_size * 1.3;
193        let ascent = -metrics.ascent;
194        let descent = metrics.descent;
195        let y = (line_height + ascent - descent) / 2.0;
196
197        let shadows: Vec<rustmotion_core::schema::TextShadow> = if let Some(s) = &self.text_shadow {
198            vec![s.clone()]
199        } else if let Some(list) = &self.style.text_shadow {
200            list.iter().map(|s| s.to_schema(&own_ctx)).collect()
201        } else {
202            Vec::new()
203        };
204        for shadow in shadows.iter().rev() {
205            let mut sp = paint_from_hex(&shadow.color);
206            if shadow.blur > 0.01 {
207                if let Some(filter) = skia_safe::image_filters::blur(
208                    (shadow.blur, shadow.blur),
209                    skia_safe::TileMode::Clamp,
210                    None,
211                    None,
212                ) {
213                    sp.set_image_filter(filter);
214                }
215            }
216            draw_text_with_fallback(
217                canvas,
218                &content,
219                &font,
220                &emoji_font,
221                letter_spacing,
222                x + shadow.offset_x,
223                y + shadow.offset_y,
224                &sp,
225            );
226        }
227
228        if let Some(ref stroke) = self.stroke {
229            let mut sp = paint_from_hex(&stroke.color);
230            sp.set_style(PaintStyle::Stroke);
231            sp.set_stroke_width(stroke.width);
232            draw_text_with_fallback(
233                canvas,
234                &content,
235                &font,
236                &emoji_font,
237                letter_spacing,
238                x,
239                y,
240                &sp,
241            );
242        }
243
244        draw_text_with_fallback(
245            canvas,
246            &content,
247            &font,
248            &emoji_font,
249            letter_spacing,
250            x,
251            y,
252            &paint,
253        );
254
255        Ok(())
256    }
257}
258
259impl Painter for Counter {
260    fn paint_content(
261        &self,
262        canvas: &Canvas,
263        layout: &BoxLayout,
264        props: &AnimatedProperties,
265        ctx: &PaintCtx,
266    ) {
267        let _ = self.paint(
268            canvas,
269            layout.width,
270            ctx.time,
271            ctx.scene_duration,
272            props,
273            ctx,
274        );
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn counter(duration: Option<f64>, start_at: Option<f64>) -> Counter {
283        Counter {
284            from: 0.0,
285            to: 100.0,
286            decimals: 0,
287            separator: None,
288            prefix: None,
289            suffix: None,
290            easing: EasingType::default(),
291            duration,
292            timing: TimingConfig {
293                start_at,
294                end_at: None,
295            },
296            style: CssStyle::default(),
297            timeline: Vec::new(),
298            stagger: None,
299            text_shadow: None,
300            stroke: None,
301        }
302    }
303
304    #[test]
305    fn without_duration_the_count_only_lands_on_the_last_frame() {
306        let c = counter(None, None);
307        assert!(c.ramp_progress(3.9, 4.0) < 1.0);
308        assert_eq!(c.ramp_progress(4.0, 4.0), 1.0);
309    }
310
311    #[test]
312    fn duration_makes_the_count_land_early_and_hold() {
313        let c = counter(Some(1.5), None);
314        assert_eq!(c.ramp_progress(1.5, 4.0), 1.0);
315        assert_eq!(c.ramp_progress(3.0, 4.0), 1.0);
316        assert!((c.ramp_progress(0.75, 4.0) - 0.5).abs() < 1e-9);
317    }
318
319    #[test]
320    fn duration_is_measured_from_start_at() {
321        let c = counter(Some(2.0), Some(1.0));
322        assert_eq!(c.ramp_progress(1.0, 6.0), 0.0);
323        assert!((c.ramp_progress(2.0, 6.0) - 0.5).abs() < 1e-9);
324        assert_eq!(c.ramp_progress(3.0, 6.0), 1.0);
325    }
326
327    #[test]
328    fn a_duration_outlasting_the_scene_is_honoured_not_clamped() {
329        let c = counter(Some(10.0), None);
330        assert!(c.ramp_progress(4.0, 4.0) < 0.5);
331    }
332
333    #[test]
334    fn a_zero_or_negative_duration_falls_back_to_the_scene() {
335        let c = counter(Some(0.0), None);
336        assert!((c.ramp_progress(2.0, 4.0) - 0.5).abs() < 1e-9);
337    }
338
339    #[test]
340    fn rem_font_size_paints_visible_ink() {
341        let mut c = counter(None, None);
342        c.style.font_size = Some(rustmotion_core::css::Length::String("2rem".into()));
343        c.style.color = Some(rustmotion_core::css::style::Color::String("#FFFFFF".into()));
344
345        const W: i32 = 300;
346        const H: i32 = 150;
347        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
348        let ctx = PaintCtx {
349            time: 1.0,
350            scenario_time: 1.0,
351            scene_duration: 2.0,
352            frame_index: 30,
353            fps: 30,
354            video_width: 300,
355            video_height: 150,
356            stagger_offset: 0.0,
357        };
358        let props = AnimatedProperties::default();
359        {
360            let canvas = surface.canvas();
361            c.paint(canvas, 200.0, 1.0, 2.0, &props, &ctx)
362                .expect("paint succeeds");
363        }
364        let snapshot = surface.image_snapshot();
365        let info = skia_safe::ImageInfo::new(
366            (W, H),
367            skia_safe::ColorType::RGBA8888,
368            skia_safe::AlphaType::Premul,
369            None,
370        );
371        let mut buf = vec![0u8; (W * H * 4) as usize];
372        let ok = snapshot.read_pixels(
373            &info,
374            &mut buf,
375            (W * 4) as usize,
376            skia_safe::IPoint::new(0, 0),
377            skia_safe::image::CachingHint::Disallow,
378        );
379        assert!(ok, "pixel read should succeed");
380        let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count();
381        assert!(
382            lit > 20,
383            "counter at font-size: 2rem must paint visible ink, got {lit} lit pixels"
384        );
385    }
386}