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#[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#[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 pub value: String,
68 #[serde(default)]
70 pub spin: WheelSpin,
71 #[serde(default = "default_wheel_duration")]
73 pub duration: f64,
74 #[serde(default)]
76 pub delay: f64,
77 #[serde(default = "default_wheel_stagger")]
81 pub stagger_per_column: f64,
82 #[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}