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 #[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}