1use 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#[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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
64pub struct NumberWheel {
65 pub value: String,
69 #[serde(default)]
71 pub spin: WheelSpin,
72 #[serde(default = "default_wheel_duration")]
74 pub duration: f64,
75 #[serde(default)]
77 pub delay: f64,
78 #[serde(default = "default_wheel_stagger")]
82 pub stagger_per_column: f64,
83 #[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
103pub(crate) enum Cell {
105 Digit(u32),
107 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 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 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; 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 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 canvas.clip_rect(
247 Rect::from_xywh(x, 0.0, digit_w, cell_h),
248 ClipOp::Intersect,
249 false,
250 );
251
252 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 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 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 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 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 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}