From d39ac6ebc9042e9ba5b56c4f88d54a8d2654ec6f Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 11:21:58 +0200 Subject: [PATCH] fix(number_wheel): resolve text-align instead of always painting from the left paint_content started its draw loop at a literal origin and never read style.text_align or the layout width, so a number_wheel in a full-width box with text-align: center landed at x = 3 while the text label under it centred correctly. This is the same defect #337 fixed in gradient_text's draw loop. Every other text-like leaf -- text, rich_text, counter, gradient_text -- resolves alignment against its box; number_wheel was the one that was missed. It stayed invisible because NumberWheelIntrinsic shrinks the box to the digits, so it only shows once the box is wider than the strip, which is exactly when someone asks for centring. The strip's total advance is fixed-glyph widths plus digit_w per reel. A non-finite or non-positive width falls back to the left edge rather than painting off-screen, which is the case the intrinsic measure and a degenerate flex slot both produce. --- .../rustmotion-components/src/number_wheel.rs | 165 +++++++++++++++++- 1 file changed, 163 insertions(+), 2 deletions(-) diff --git a/crates/rustmotion-components/src/number_wheel.rs b/crates/rustmotion-components/src/number_wheel.rs index 16e6565..0e37a30 100644 --- a/crates/rustmotion-components/src/number_wheel.rs +++ b/crates/rustmotion-components/src/number_wheel.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, ClipOp, Font, FontStyle, Rect}; use rustmotion_core::css::style::{ - FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, + FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, TextAlign as CssTextAlign, }; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::{ease, AnimatedProperties}; @@ -127,6 +127,35 @@ impl NumberWheel { travel * p } + pub(crate) fn advance( + cells: &[Cell], + digit_w: f32, + font: &Font, + emoji: &Option, + letter_spacing: f32, + ) -> f32 { + cells + .iter() + .map(|cell| match cell { + Cell::Digit(_) => digit_w, + Cell::Fixed(c) => { + measure_text_with_fallback(&c.to_string(), font, emoji, letter_spacing) + } + }) + .sum() + } + + pub(crate) fn align_offset(align: CssTextAlign, box_width: f32, advance: f32) -> f32 { + if !box_width.is_finite() || box_width <= 0.0 { + return 0.0; + } + match align { + CssTextAlign::Center => (box_width - advance) / 2.0, + CssTextAlign::Right | CssTextAlign::End => box_width - advance, + _ => 0.0, + } + } + fn digit_advance(font: &Font, emoji: &Option, letter_spacing: f32) -> f32 { (0..10) .map(|d| measure_text_with_fallback(&d.to_string(), font, emoji, letter_spacing)) @@ -202,8 +231,13 @@ impl Painter for NumberWheel { let digit_w = Self::digit_advance(&font, &emoji_font, letter_spacing); let cells = Self::cells(&self.value); + let advance = Self::advance(&cells, digit_w, &font, &emoji_font, letter_spacing); - let mut x = 0.0f32; + let mut x = Self::align_offset( + self.style.text_align.unwrap_or(CssTextAlign::Left), + layout.width, + advance, + ); let mut column = 0usize; for cell in &cells { match cell { @@ -267,6 +301,133 @@ mod tests { serde_json::from_value(json).expect("number_wheel fixture") } + fn test_ctx() -> PaintCtx { + PaintCtx { + time: 5.0, + scenario_time: 5.0, + scene_duration: 6.0, + frame_index: 150, + fps: 30, + video_width: 1920, + video_height: 1080, + stagger_offset: 0.0, + } + } + + fn box_layout(width: f32) -> BoxLayout { + BoxLayout { + x: 0.0, + y: 0.0, + width, + height: 200.0, + border: Default::default(), + padding: Default::default(), + } + } + + fn ink_x_span(w: &NumberWheel, box_width: f32, surface_width: i32) -> (i32, i32) { + let height = 200; + let mut surface = skia_safe::surfaces::raster_n32_premul((surface_width, height)) + .expect("raster surface"); + w.paint_content( + surface.canvas(), + &box_layout(box_width), + &AnimatedProperties::default(), + &test_ctx(), + ); + let image = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (surface_width, height), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (surface_width * height * 4) as usize]; + image.read_pixels( + &info, + &mut buf, + (surface_width * 4) as usize, + (0, 0), + skia_safe::image::CachingHint::Allow, + ); + let mut lo = i32::MAX; + let mut hi = i32::MIN; + for y in 0..height { + for x in 0..surface_width { + if buf[((y * surface_width + x) * 4 + 3) as usize] > 0 { + lo = lo.min(x); + hi = hi.max(x); + } + } + } + assert!(lo <= hi, "the wheel painted nothing at all"); + (lo, hi) + } + + fn landed_wheel(align: Option) -> NumberWheel { + let mut w = wheel(serde_json::json!({ + "value": "27", + "duration": 1.0, + "stagger_per_column": 0.0, + "style": { "font-size": 120 } + })); + w.style.text_align = align; + w + } + + #[test] + fn text_align_center_centres_the_reels_in_the_box() { + const BOX_W: f32 = 1000.0; + let (lo, hi) = ink_x_span(&landed_wheel(Some(CssTextAlign::Center)), BOX_W, 1100); + let centre = (lo + hi) as f32 / 2.0; + assert!( + (centre - BOX_W / 2.0).abs() <= 6.0, + "a centred number_wheel must sit on the box centre {}, painted [{lo}, {hi}] \ + with centre {centre}", + BOX_W / 2.0 + ); + assert!( + lo > 100, + "a centred number_wheel must leave a left margin, first ink at {lo}" + ); + } + + #[test] + fn text_align_right_ends_the_reels_on_the_box_edge() { + const BOX_W: f32 = 1000.0; + let (lo, hi) = ink_x_span(&landed_wheel(Some(CssTextAlign::Right)), BOX_W, 1100); + assert!( + (BOX_W - hi as f32) <= 8.0, + "a right-aligned number_wheel must end on the box edge {BOX_W}, last ink at {hi}" + ); + assert!( + lo > 700, + "a right-aligned number_wheel starts far from the left edge, first ink at {lo}" + ); + } + + #[test] + fn no_text_align_still_starts_at_the_box_left_edge() { + let (lo, _) = ink_x_span(&landed_wheel(None), 1000.0, 1100); + assert!( + lo < 12, + "without text_align the wheel keeps starting at the box's left edge, first ink at {lo}" + ); + } + + #[test] + fn a_box_with_no_usable_width_falls_back_to_the_left_edge() { + for width in [0.0_f32, -5.0, f32::INFINITY, f32::NAN] { + let advance = 240.0; + assert_eq!( + NumberWheel::align_offset(CssTextAlign::Center, width, advance), + 0.0, + "a box width of {width} cannot centre anything — paint from the left \ + rather than off-screen" + ); + } + } + #[test] fn only_digits_become_reels() { let cells = NumberWheel::cells("1,204.5%");