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 #[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 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 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 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 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 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 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 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 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 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 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 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 #[test]
364 fn rem_font_size_paints_visible_ink() {
365 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}