Skip to main content

rustmotion_components/
counter.rs

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