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