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