Skip to main content

rustmotion_components/
number_wheel.rs

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/// How many full 0-9 revolutions a reel makes before landing.
17///
18/// The reel covers the same *time* whichever this is, so a higher setting is
19/// a faster spin, not a longer one.
20#[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/// An odometer-style number where each digit rolls into place.
52#[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    /// The figure to land on, as written — `"30,222"`, `"5.7"`, `"98%"`.
64    /// Digits roll; every other character (separators, signs, units) is
65    /// painted where it stands.
66    pub value: String,
67    /// How far each reel travels before landing.
68    #[serde(default)]
69    pub spin: WheelSpin,
70    /// How long one reel takes to land (seconds).
71    #[serde(default = "default_wheel_duration")]
72    pub duration: f64,
73    /// Delay before the first reel starts (seconds).
74    #[serde(default)]
75    pub delay: f64,
76    /// Extra delay per digit column, left to right (seconds). `0` lands
77    /// every reel at once, which reads as a single flip rather than as a
78    /// counter settling.
79    #[serde(default = "default_wheel_stagger")]
80    pub stagger_per_column: f64,
81    /// Easing of a reel's travel. The default decelerates into the landing,
82    /// which is what makes it read as mechanical rather than as a fade.
83    #[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}