Skip to main content

rustmotion_components/
number_wheel.rs

1//! A rolling-digit counter: each digit column spins through 0-9 like a
2//! mechanical odometer reel before landing on its target.
3//!
4//! Distinct from [`crate::counter::Counter`], which interpolates a *value*
5//! and re-renders the number each frame — its digits change by arithmetic,
6//! and the glyphs jump. Here the digits are physically on a strip that
7//! travels: what lands is the number you asked for, and what you watch is the
8//! travel. Reels land left-to-right, which is what makes the final digit read
9//! as the one that settles the figure.
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use skia_safe::{Canvas, ClipOp, Font, FontStyle, Rect};
14
15use rustmotion_core::css::style::{
16    FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw,
17};
18use rustmotion_core::css::CssStyle;
19use rustmotion_core::engine::animator::{ease, AnimatedProperties};
20use rustmotion_core::engine::layout_pass::BoxLayout;
21use rustmotion_core::engine::renderer::{
22    draw_text_with_fallback, measure_text_with_fallback, paint_from_hex, typeface_with_fallback,
23};
24use rustmotion_core::schema::{EasingType, FontStyleType, FontWeight, TimelineStep};
25use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
26
27/// How many full 0-9 revolutions a reel makes before landing.
28///
29/// The reel covers the same *time* whichever this is, so a higher setting is
30/// a faster spin, not a longer one.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "snake_case")]
33pub enum WheelSpin {
34    #[default]
35    Single,
36    Double,
37    Triple,
38}
39
40impl WheelSpin {
41    fn revolutions(self) -> f32 {
42        match self {
43            Self::Single => 1.0,
44            Self::Double => 2.0,
45            Self::Triple => 3.0,
46        }
47    }
48}
49
50fn default_wheel_duration() -> f64 {
51    1.2
52}
53
54fn default_wheel_stagger() -> f64 {
55    0.08
56}
57
58fn default_wheel_easing() -> EasingType {
59    EasingType::EaseOutCubic
60}
61
62/// An odometer-style number where each digit rolls into place.
63#[derive(Debug, Serialize, Deserialize, JsonSchema)]
64pub struct NumberWheel {
65    /// The figure to land on, as written — `"30,222"`, `"5.7"`, `"98%"`.
66    /// Digits roll; every other character (separators, signs, units) is
67    /// painted where it stands.
68    pub value: String,
69    /// How far each reel travels before landing.
70    #[serde(default)]
71    pub spin: WheelSpin,
72    /// How long one reel takes to land (seconds).
73    #[serde(default = "default_wheel_duration")]
74    pub duration: f64,
75    /// Delay before the first reel starts (seconds).
76    #[serde(default)]
77    pub delay: f64,
78    /// Extra delay per digit column, left to right (seconds). `0` lands
79    /// every reel at once, which reads as a single flip rather than as a
80    /// counter settling.
81    #[serde(default = "default_wheel_stagger")]
82    pub stagger_per_column: f64,
83    /// Easing of a reel's travel. The default decelerates into the landing,
84    /// which is what makes it read as mechanical rather than as a fade.
85    #[serde(default = "default_wheel_easing")]
86    pub easing: EasingType,
87    #[serde(flatten)]
88    pub timing: TimingConfig,
89    #[serde(default)]
90    pub style: CssStyle,
91    #[serde(default)]
92    pub timeline: Vec<TimelineStep>,
93    #[serde(default)]
94    pub stagger: Option<f32>,
95}
96
97rustmotion_core::impl_traits!(NumberWheel {
98    Animatable => animation,
99    Timed => timing,
100    Styled => style,
101});
102
103/// One character of the figure: a rolling reel, or a fixed glyph.
104pub(crate) enum Cell {
105    /// A digit reel landing on this value.
106    Digit(u32),
107    /// A separator, sign or unit, painted as-is.
108    Fixed(char),
109}
110
111impl NumberWheel {
112    pub(crate) fn cells(value: &str) -> Vec<Cell> {
113        value
114            .chars()
115            .map(|c| match c.to_digit(10) {
116                Some(d) => Cell::Digit(d),
117                None => Cell::Fixed(c),
118            })
119            .collect()
120    }
121
122    /// How far reel `column` has travelled at `time`, in cells.
123    ///
124    /// Cell 0 is the digit `0`; the reel counts upward through 0-9, wrapping,
125    /// and stops exactly on `revolutions * 10 + target` so that the landing
126    /// is on the requested digit rather than near it.
127    pub(crate) fn reel_position(&self, column: usize, target: u32, time: f64) -> f32 {
128        let start = self.delay + column as f64 * self.stagger_per_column;
129        let raw = if self.duration <= 0.0 {
130            1.0
131        } else {
132            ((time - start) / self.duration).clamp(0.0, 1.0)
133        };
134        let p = ease(raw, &self.easing) as f32;
135        let travel = self.spin.revolutions() * 10.0 + target as f32;
136        travel * p
137    }
138
139    /// The digit-column width: the widest digit's advance, so the reels line
140    /// up in a column instead of jittering as a 1 rolls past an 8.
141    fn digit_advance(font: &Font, emoji: &Option<Font>, letter_spacing: f32) -> f32 {
142        (0..10)
143            .map(|d| measure_text_with_fallback(&d.to_string(), font, emoji, letter_spacing))
144            .fold(0.0f32, f32::max)
145    }
146
147    pub(crate) fn build_font(&self, font_size: f32) -> Option<Font> {
148        let font_family = self.style.font_family_or("Inter");
149        let weight = match &self.style.font_weight {
150            Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => {
151                FontWeight::Bold
152            }
153            Some(CssFontWeight::Number(n)) if *n >= 600 => FontWeight::Bold,
154            Some(CssFontWeight::Number(n)) => FontWeight::Weight(*n),
155            _ => FontWeight::Normal,
156        };
157        let slant = match self.style.font_style {
158            Some(CssFontStyle::Italic) => skia_safe::font_style::Slant::Italic,
159            Some(CssFontStyle::Oblique) => skia_safe::font_style::Slant::Oblique,
160            _ => skia_safe::font_style::Slant::Upright,
161        };
162        let weight = match weight {
163            FontWeight::Bold => skia_safe::font_style::Weight::BOLD,
164            FontWeight::Normal => skia_safe::font_style::Weight::NORMAL,
165            FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32),
166        };
167        let _ = FontStyleType::Normal; // keep the schema import meaningful
168        let typeface = typeface_with_fallback(
169            font_family,
170            FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant),
171        )
172        .ok()?;
173        Some(Font::from_typeface(typeface, font_size))
174    }
175}
176
177impl Painter for NumberWheel {
178    fn paint_content(
179        &self,
180        canvas: &Canvas,
181        layout: &BoxLayout,
182        props: &AnimatedProperties,
183        ctx: &PaintCtx,
184    ) {
185        let base_ctx = crate::intrinsic::font_size_ctx(
186            ctx.video_width as f32,
187            ctx.video_height as f32,
188            layout.width.max(0.0),
189        );
190        let font_size = self.style.font_size_px_ctx(&base_ctx, 72.0);
191        let Some(font) = self.build_font(font_size) else {
192            return;
193        };
194        let emoji_font = rustmotion_core::engine::renderer::emoji_typeface()
195            .map(|tf| Font::from_typeface(tf, font_size));
196        let own_ctx = rustmotion_core::css::units::LengthContext {
197            font_size,
198            ..base_ctx
199        };
200        let letter_spacing = self.style.letter_spacing_px_ctx(&own_ctx);
201
202        let color = props
203            .color
204            .as_deref()
205            .unwrap_or_else(|| self.style.color_str_or("#FFFFFF"));
206        let paint = paint_from_hex(color);
207
208        let (_, metrics) = font.metrics();
209        let ascent = -metrics.ascent;
210        let descent = metrics.descent;
211        // One cell is a full line box: the strip advances by exactly this, so
212        // consecutive digits never overlap inside the clip.
213        let cell_h = ascent + descent;
214        let baseline = ascent;
215
216        let digit_w = Self::digit_advance(&font, &emoji_font, letter_spacing);
217        let cells = Self::cells(&self.value);
218
219        let mut x = 0.0f32;
220        let mut column = 0usize;
221        for cell in &cells {
222            match cell {
223                Cell::Fixed(c) => {
224                    let s = c.to_string();
225                    let w = measure_text_with_fallback(&s, &font, &emoji_font, letter_spacing);
226                    draw_text_with_fallback(
227                        canvas,
228                        &s,
229                        &font,
230                        &emoji_font,
231                        letter_spacing,
232                        x,
233                        baseline,
234                        &paint,
235                    );
236                    x += w;
237                }
238                Cell::Digit(target) => {
239                    let pos = self.reel_position(column, *target, ctx.time);
240                    let whole = pos.floor();
241                    let frac = pos - whole;
242
243                    canvas.save();
244                    // The clip is the window in the odometer's housing: it is
245                    // what turns a long strip of digits into one visible one.
246                    canvas.clip_rect(
247                        Rect::from_xywh(x, 0.0, digit_w, cell_h),
248                        ClipOp::Intersect,
249                        false,
250                    );
251
252                    // Outgoing digit sliding up and out, incoming one rising
253                    // into its place from below.
254                    for (step, offset) in [(0.0f32, -frac * cell_h), (1.0, (1.0 - frac) * cell_h)] {
255                        let digit = ((whole + step) as i64).rem_euclid(10);
256                        let s = digit.to_string();
257                        let w = measure_text_with_fallback(&s, &font, &emoji_font, letter_spacing);
258                        draw_text_with_fallback(
259                            canvas,
260                            &s,
261                            &font,
262                            &emoji_font,
263                            letter_spacing,
264                            // Digits are centred in the column so a 1 does not
265                            // sit off to one side of the reel it shares with an 8.
266                            x + (digit_w - w) / 2.0,
267                            baseline + offset,
268                            &paint,
269                        );
270                    }
271                    canvas.restore();
272
273                    x += digit_w;
274                    column += 1;
275                }
276            }
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn wheel(json: serde_json::Value) -> NumberWheel {
286        serde_json::from_value(json).expect("number_wheel fixture")
287    }
288
289    #[test]
290    fn only_digits_become_reels() {
291        let cells = NumberWheel::cells("1,204.5%");
292        let digits = cells.iter().filter(|c| matches!(c, Cell::Digit(_))).count();
293        assert_eq!(
294            digits, 5,
295            "1 2 0 4 5 roll; the comma, dot and percent do not"
296        );
297    }
298
299    #[test]
300    fn a_reel_lands_exactly_on_its_target_digit() {
301        // Landing "near" the digit is the failure mode worth pinning: the
302        // whole point of a reel over a fade is that it stops on the figure.
303        let w =
304            wheel(serde_json::json!({ "value": "7", "duration": 1.0, "stagger_per_column": 0.0 }));
305        let landed = w.reel_position(0, 7, 5.0);
306        assert!(
307            (landed % 10.0 - 7.0).abs() < 1e-4,
308            "the reel should rest on 7, got cell {landed}"
309        );
310    }
311
312    #[test]
313    fn a_reel_starts_on_zero_before_it_moves() {
314        let w = wheel(serde_json::json!({ "value": "42", "delay": 0.5 }));
315        assert_eq!(
316            w.reel_position(0, 4, 0.0),
317            0.0,
318            "before its delay a reel shows 0, it does not preview the answer"
319        );
320    }
321
322    #[test]
323    fn spin_changes_the_distance_not_the_landing() {
324        let single = wheel(serde_json::json!({ "value": "3", "spin": "single" }));
325        let triple = wheel(serde_json::json!({ "value": "3", "spin": "triple" }));
326
327        // Mid-travel the triple is further along...
328        assert!(
329            triple.reel_position(0, 3, 0.4) > single.reel_position(0, 3, 0.4),
330            "a triple spin covers more ground in the same time"
331        );
332        // ...but both come to rest on the same digit.
333        assert!(
334            (single.reel_position(0, 3, 9.0) % 10.0 - 3.0).abs() < 1e-4
335                && (triple.reel_position(0, 3, 9.0) % 10.0 - 3.0).abs() < 1e-4,
336            "both must land on 3"
337        );
338    }
339
340    #[test]
341    fn columns_land_left_to_right() {
342        let w = wheel(serde_json::json!({
343            "value": "99", "duration": 0.5, "stagger_per_column": 0.25
344        }));
345        // At the moment the first reel has landed, the second is still moving.
346        let first = w.reel_position(0, 9, 0.5);
347        let second = w.reel_position(1, 9, 0.5);
348        assert!(
349            (first % 10.0 - 9.0).abs() < 1e-4,
350            "the leftmost reel should have landed by t=0.5"
351        );
352        assert!(
353            second < first,
354            "the next reel should still be travelling (first={first}, second={second})"
355        );
356    }
357}