1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, ClipOp, Font, FontStyle, Rect};
4
5use rustmotion_core::css::style::{FontStyle as CssFontStyle, TextAlign as CssTextAlign};
6use rustmotion_core::css::CssStyle;
7use rustmotion_core::engine::animator::{ease, AnimatedProperties};
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10 css_font_weight, draw_text_with_fallback, measure_text_with_fallback, paint_from_hex,
11 subpixel_font, typeface_with_fallback,
12};
13use rustmotion_core::schema::{EasingType, FontStyleType, TimelineStep};
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum WheelSpin {
23 #[default]
24 Single,
25 Double,
26 Triple,
27}
28
29impl WheelSpin {
30 fn revolutions(self) -> f32 {
31 match self {
32 Self::Single => 1.0,
33 Self::Double => 2.0,
34 Self::Triple => 3.0,
35 }
36 }
37}
38
39fn default_wheel_duration() -> f64 {
40 1.2
41}
42
43fn default_wheel_stagger() -> f64 {
44 0.08
45}
46
47fn default_wheel_easing() -> EasingType {
48 EasingType::EaseOutCubic
49}
50
51#[derive(Debug, Serialize, Deserialize, JsonSchema)]
53#[deprecated(
54 since = "0.7.1",
55 note = "`number_wheel` is in the frozen-composition set (issue #333), but \
56 crates/rustmotion/skills/rules/composition-recipes.md flags it (with `gauge`) \
57 as a reasonable exception to keep using directly: reproducing genuine \
58 per-digit scroll physics from card/text/shape primitives is mechanically \
59 harder than the odometer effect itself. Kept for compatibility; scheduled for \
60 removal in a future major version via `rustmotion migrate` (#335)."
61)]
62pub struct NumberWheel {
63 pub value: String,
67 #[serde(default)]
69 pub spin: WheelSpin,
70 #[serde(default = "default_wheel_duration")]
72 pub duration: f64,
73 #[serde(default)]
75 pub delay: f64,
76 #[serde(default = "default_wheel_stagger")]
80 pub stagger_per_column: f64,
81 #[serde(default = "default_wheel_easing")]
84 pub easing: EasingType,
85 #[serde(flatten)]
86 pub timing: TimingConfig,
87 #[serde(default)]
88 pub style: CssStyle,
89 #[serde(default)]
90 pub timeline: Vec<TimelineStep>,
91 #[serde(default)]
92 pub stagger: Option<f32>,
93}
94
95rustmotion_core::impl_traits!(NumberWheel {
96 Animatable => animation,
97 Timed => timing,
98 Styled => style,
99});
100
101pub(crate) enum Cell {
102 Digit(u32),
103 Fixed(char),
104}
105
106impl NumberWheel {
107 pub(crate) fn cells(value: &str) -> Vec<Cell> {
108 value
109 .chars()
110 .map(|c| match c.to_digit(10) {
111 Some(d) => Cell::Digit(d),
112 None => Cell::Fixed(c),
113 })
114 .collect()
115 }
116
117 pub(crate) fn reel_position(&self, column: usize, target: u32, time: f64) -> f32 {
118 let start = self.delay + column as f64 * self.stagger_per_column;
119 let raw = if self.duration <= 0.0 {
120 1.0
121 } else {
122 ((time - start) / self.duration).clamp(0.0, 1.0)
123 };
124 let p = ease(raw, &self.easing) as f32;
125 let travel = self.spin.revolutions() * 10.0 + target as f32;
126 travel * p
127 }
128
129 pub(crate) fn advance(
130 cells: &[Cell],
131 digit_w: f32,
132 font: &Font,
133 emoji: &Option<Font>,
134 letter_spacing: f32,
135 ) -> f32 {
136 cells
137 .iter()
138 .map(|cell| match cell {
139 Cell::Digit(_) => digit_w,
140 Cell::Fixed(c) => {
141 measure_text_with_fallback(&c.to_string(), font, emoji, letter_spacing)
142 }
143 })
144 .sum()
145 }
146
147 pub(crate) fn align_offset(align: CssTextAlign, box_width: f32, advance: f32) -> f32 {
148 if !box_width.is_finite() || box_width <= 0.0 {
149 return 0.0;
150 }
151 match align {
152 CssTextAlign::Center => (box_width - advance) / 2.0,
153 CssTextAlign::Right | CssTextAlign::End => box_width - advance,
154 _ => 0.0,
155 }
156 }
157
158 fn digit_advance(font: &Font, emoji: &Option<Font>, letter_spacing: f32) -> f32 {
159 (0..10)
160 .map(|d| measure_text_with_fallback(&d.to_string(), font, emoji, letter_spacing))
161 .fold(0.0f32, f32::max)
162 }
163
164 pub(crate) fn build_font(&self, font_size: f32) -> Option<Font> {
165 let font_family = self.style.font_family_or("Inter");
166 let slant = match self.style.font_style {
167 Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
168 Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
169 _ => skia_safe::font_style::Slant::Upright,
170 };
171 let weight = css_font_weight(self.style.font_weight.as_ref());
172 let _ = FontStyleType::Normal;
173 let typeface = typeface_with_fallback(
174 font_family,
175 FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant),
176 )
177 .ok()?;
178 Some(subpixel_font(typeface, font_size))
179 }
180}
181
182impl Painter for NumberWheel {
183 fn paint_content(
184 &self,
185 canvas: &Canvas,
186 layout: &BoxLayout,
187 props: &AnimatedProperties,
188 ctx: &PaintCtx,
189 ) {
190 let base_ctx = crate::intrinsic::font_size_ctx(
191 ctx.video_width as f32,
192 ctx.video_height as f32,
193 layout.width.max(0.0),
194 );
195 let font_size = self.style.font_size_px_ctx(&base_ctx, 72.0);
196 let Some(font) = self.build_font(font_size) else {
197 return;
198 };
199 let emoji_font = rustmotion_core::engine::renderer::emoji_typeface()
200 .map(|tf| subpixel_font(tf, font_size));
201 let own_ctx = rustmotion_core::css::units::LengthContext {
202 font_size,
203 ..base_ctx
204 };
205 let letter_spacing = self.style.letter_spacing_px_ctx(&own_ctx);
206
207 let color = props
208 .color
209 .as_deref()
210 .unwrap_or_else(|| self.style.color_str_or("#FFFFFF"));
211 let paint = paint_from_hex(color);
212
213 let (_, metrics) = font.metrics();
214 let ascent = -metrics.ascent;
215 let descent = metrics.descent;
216 let cell_h = ascent + descent;
217 let baseline = ascent;
218
219 let digit_w = Self::digit_advance(&font, &emoji_font, letter_spacing);
220 let cells = Self::cells(&self.value);
221 let advance = Self::advance(&cells, digit_w, &font, &emoji_font, letter_spacing);
222
223 let mut x = Self::align_offset(
224 self.style.text_align.unwrap_or(CssTextAlign::Left),
225 layout.width,
226 advance,
227 );
228 let mut column = 0usize;
229 for cell in &cells {
230 match cell {
231 Cell::Fixed(c) => {
232 let s = c.to_string();
233 let w = measure_text_with_fallback(&s, &font, &emoji_font, letter_spacing);
234 draw_text_with_fallback(
235 canvas,
236 &s,
237 &font,
238 &emoji_font,
239 letter_spacing,
240 x,
241 baseline,
242 &paint,
243 );
244 x += w;
245 }
246 Cell::Digit(target) => {
247 let pos = self.reel_position(column, *target, ctx.time);
248 let whole = pos.floor();
249 let frac = pos - whole;
250
251 canvas.save();
252 canvas.clip_rect(
253 Rect::from_xywh(x, 0.0, digit_w, cell_h),
254 ClipOp::Intersect,
255 false,
256 );
257
258 for (step, offset) in [(0.0f32, -frac * cell_h), (1.0, (1.0 - frac) * cell_h)] {
259 let digit = ((whole + step) as i64).rem_euclid(10);
260 let s = digit.to_string();
261 let w = measure_text_with_fallback(&s, &font, &emoji_font, letter_spacing);
262 draw_text_with_fallback(
263 canvas,
264 &s,
265 &font,
266 &emoji_font,
267 letter_spacing,
268 x + (digit_w - w) / 2.0,
269 baseline + offset,
270 &paint,
271 );
272 }
273 canvas.restore();
274
275 x += digit_w;
276 column += 1;
277 }
278 }
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn wheel(json: serde_json::Value) -> NumberWheel {
288 serde_json::from_value(json).expect("number_wheel fixture")
289 }
290
291 fn test_ctx() -> PaintCtx {
292 PaintCtx {
293 time: 5.0,
294 scenario_time: 5.0,
295 scene_duration: 6.0,
296 frame_index: 150,
297 fps: 30,
298 video_width: 1920,
299 video_height: 1080,
300 stagger_offset: 0.0,
301 }
302 }
303
304 fn box_layout(width: f32) -> BoxLayout {
305 BoxLayout {
306 x: 0.0,
307 y: 0.0,
308 width,
309 height: 200.0,
310 border: Default::default(),
311 padding: Default::default(),
312 }
313 }
314
315 fn ink_x_span(w: &NumberWheel, box_width: f32, surface_width: i32) -> (i32, i32) {
316 let height = 200;
317 let mut surface = skia_safe::surfaces::raster_n32_premul((surface_width, height))
318 .expect("raster surface");
319 w.paint_content(
320 surface.canvas(),
321 &box_layout(box_width),
322 &AnimatedProperties::default(),
323 &test_ctx(),
324 );
325 let image = surface.image_snapshot();
326 let info = skia_safe::ImageInfo::new(
327 (surface_width, height),
328 skia_safe::ColorType::RGBA8888,
329 skia_safe::AlphaType::Unpremul,
330 None,
331 );
332 let mut buf = vec![0u8; (surface_width * height * 4) as usize];
333 image.read_pixels(
334 &info,
335 &mut buf,
336 (surface_width * 4) as usize,
337 (0, 0),
338 skia_safe::image::CachingHint::Allow,
339 );
340 let mut lo = i32::MAX;
341 let mut hi = i32::MIN;
342 for y in 0..height {
343 for x in 0..surface_width {
344 if buf[((y * surface_width + x) * 4 + 3) as usize] > 0 {
345 lo = lo.min(x);
346 hi = hi.max(x);
347 }
348 }
349 }
350 assert!(lo <= hi, "the wheel painted nothing at all");
351 (lo, hi)
352 }
353
354 fn landed_wheel(align: Option<CssTextAlign>) -> NumberWheel {
355 let mut w = wheel(serde_json::json!({
356 "value": "27",
357 "duration": 1.0,
358 "stagger_per_column": 0.0,
359 "style": { "font-size": 120 }
360 }));
361 w.style.text_align = align;
362 w
363 }
364
365 #[test]
366 fn text_align_center_centres_the_reels_in_the_box() {
367 const BOX_W: f32 = 1000.0;
368 let (lo, hi) = ink_x_span(&landed_wheel(Some(CssTextAlign::Center)), BOX_W, 1100);
369 let centre = (lo + hi) as f32 / 2.0;
370 assert!(
371 (centre - BOX_W / 2.0).abs() <= 6.0,
372 "a centred number_wheel must sit on the box centre {}, painted [{lo}, {hi}] \
373 with centre {centre}",
374 BOX_W / 2.0
375 );
376 assert!(
377 lo > 100,
378 "a centred number_wheel must leave a left margin, first ink at {lo}"
379 );
380 }
381
382 #[test]
383 fn text_align_right_ends_the_reels_on_the_box_edge() {
384 const BOX_W: f32 = 1000.0;
385 let (lo, hi) = ink_x_span(&landed_wheel(Some(CssTextAlign::Right)), BOX_W, 1100);
386 assert!(
387 (BOX_W - hi as f32) <= 8.0,
388 "a right-aligned number_wheel must end on the box edge {BOX_W}, last ink at {hi}"
389 );
390 assert!(
391 lo > 700,
392 "a right-aligned number_wheel starts far from the left edge, first ink at {lo}"
393 );
394 }
395
396 #[test]
397 fn no_text_align_still_starts_at_the_box_left_edge() {
398 let (lo, _) = ink_x_span(&landed_wheel(None), 1000.0, 1100);
399 assert!(
400 lo < 12,
401 "without text_align the wheel keeps starting at the box's left edge, first ink at {lo}"
402 );
403 }
404
405 #[test]
406 fn a_box_with_no_usable_width_falls_back_to_the_left_edge() {
407 for width in [0.0_f32, -5.0, f32::INFINITY, f32::NAN] {
408 let advance = 240.0;
409 assert_eq!(
410 NumberWheel::align_offset(CssTextAlign::Center, width, advance),
411 0.0,
412 "a box width of {width} cannot centre anything — paint from the left \
413 rather than off-screen"
414 );
415 }
416 }
417
418 #[test]
419 fn only_digits_become_reels() {
420 let cells = NumberWheel::cells("1,204.5%");
421 let digits = cells.iter().filter(|c| matches!(c, Cell::Digit(_))).count();
422 assert_eq!(
423 digits, 5,
424 "1 2 0 4 5 roll; the comma, dot and percent do not"
425 );
426 }
427
428 #[test]
429 fn a_reel_lands_exactly_on_its_target_digit() {
430 let w =
431 wheel(serde_json::json!({ "value": "7", "duration": 1.0, "stagger_per_column": 0.0 }));
432 let landed = w.reel_position(0, 7, 5.0);
433 assert!(
434 (landed % 10.0 - 7.0).abs() < 1e-4,
435 "the reel should rest on 7, got cell {landed}"
436 );
437 }
438
439 #[test]
440 fn a_reel_starts_on_zero_before_it_moves() {
441 let w = wheel(serde_json::json!({ "value": "42", "delay": 0.5 }));
442 assert_eq!(
443 w.reel_position(0, 4, 0.0),
444 0.0,
445 "before its delay a reel shows 0, it does not preview the answer"
446 );
447 }
448
449 #[test]
450 fn spin_changes_the_distance_not_the_landing() {
451 let single = wheel(serde_json::json!({ "value": "3", "spin": "single" }));
452 let triple = wheel(serde_json::json!({ "value": "3", "spin": "triple" }));
453
454 assert!(
455 triple.reel_position(0, 3, 0.4) > single.reel_position(0, 3, 0.4),
456 "a triple spin covers more ground in the same time"
457 );
458 assert!(
459 (single.reel_position(0, 3, 9.0) % 10.0 - 3.0).abs() < 1e-4
460 && (triple.reel_position(0, 3, 9.0) % 10.0 - 3.0).abs() < 1e-4,
461 "both must land on 3"
462 );
463 }
464
465 #[test]
466 fn columns_land_left_to_right() {
467 let w = wheel(serde_json::json!({
468 "value": "99", "duration": 0.5, "stagger_per_column": 0.25
469 }));
470 let first = w.reel_position(0, 9, 0.5);
471 let second = w.reel_position(1, 9, 0.5);
472 assert!(
473 (first % 10.0 - 9.0).abs() < 1e-4,
474 "the leftmost reel should have landed by t=0.5"
475 );
476 assert!(
477 second < first,
478 "the next reel should still be travelling (first={first}, second={second})"
479 );
480 }
481}