Skip to main content

rustmotion_components/
countdown.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, RRect, Rect};
4
5use rustmotion_core::css::style::Size as CSize;
6use rustmotion_core::css::{CssStyle, LengthPercentage as CLP};
7use rustmotion_core::engine::animator::AnimatedProperties;
8use rustmotion_core::engine::layout_pass::BoxLayout;
9use rustmotion_core::engine::renderer::{
10    draw_text_with_fallback, emoji_typeface, measure_text_with_fallback, paint_from_hex,
11    parse_hex_color, typeface_with_fallback,
12};
13use rustmotion_core::schema::TimelineStep;
14use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
15
16fn default_seconds() -> f64 {
17    3600.0
18}
19
20fn default_true() -> bool {
21    true
22}
23
24fn default_digit_size() -> f32 {
25    64.0
26}
27
28fn default_digit_color() -> String {
29    "#FFFFFF".to_string()
30}
31
32fn default_digit_background() -> String {
33    "#1E293B".to_string()
34}
35
36fn default_separator_color() -> String {
37    "#6B7280".to_string()
38}
39
40fn default_gap() -> f32 {
41    12.0
42}
43
44fn default_border_radius() -> f32 {
45    12.0
46}
47
48/// `#[serde(from = "CountdownRaw")]`: `box_builder.rs`'s width formula for
49/// this component (`visible * 2*box_w + (visible-1)*gap`) doesn't include
50/// the small inner gap (`gap*0.3`) `paint()` actually inserts *within*
51/// each digit pair — only the gap *between* pairs (the separator). That
52/// mismatch compounds with every extra group and is exactly what #127
53/// measured (153×67 assigned vs. 187×68 painted). Since `paint()` draws
54/// from `self.digit_size`/`self.gap`, not `layout.width` (same pattern as
55/// `switch`/`slider`), the fix is to fold the *exact* formula `paint()`
56/// uses into `style.width` here, so `box_builder.rs`'s `if
57/// css.width.is_none()` never gets a chance to apply its slightly-off one.
58#[derive(Debug, Serialize, Deserialize, JsonSchema)]
59#[serde(from = "CountdownRaw")]
60pub struct Countdown {
61    #[serde(default = "default_seconds")]
62    pub seconds: f64,
63    #[serde(default = "default_true")]
64    pub show_hours: bool,
65    #[serde(default = "default_true")]
66    pub show_minutes: bool,
67    #[serde(default = "default_true")]
68    pub show_seconds: bool,
69    #[serde(default = "default_digit_size")]
70    pub digit_size: f32,
71    #[serde(default = "default_digit_color")]
72    pub digit_color: String,
73    #[serde(default = "default_digit_background")]
74    pub digit_background: String,
75    #[serde(default = "default_separator_color")]
76    pub separator_color: String,
77    #[serde(default = "default_gap")]
78    pub gap: f32,
79    #[serde(default = "default_border_radius")]
80    pub border_radius: f32,
81    #[serde(default)]
82    pub timing: TimingConfig,
83    #[serde(default)]
84    pub style: CssStyle,
85    #[serde(default)]
86    pub timeline: Vec<TimelineStep>,
87    #[serde(default)]
88    pub stagger: Option<f32>,
89}
90
91#[derive(Debug, Deserialize)]
92struct CountdownRaw {
93    #[serde(default = "default_seconds")]
94    seconds: f64,
95    #[serde(default = "default_true")]
96    show_hours: bool,
97    #[serde(default = "default_true")]
98    show_minutes: bool,
99    #[serde(default = "default_true")]
100    show_seconds: bool,
101    #[serde(default = "default_digit_size")]
102    digit_size: f32,
103    #[serde(default = "default_digit_color")]
104    digit_color: String,
105    #[serde(default = "default_digit_background")]
106    digit_background: String,
107    #[serde(default = "default_separator_color")]
108    separator_color: String,
109    #[serde(default = "default_gap")]
110    gap: f32,
111    #[serde(default = "default_border_radius")]
112    border_radius: f32,
113    #[serde(flatten)]
114    timing: TimingConfig,
115    #[serde(default)]
116    style: CssStyle,
117    #[serde(default)]
118    timeline: Vec<TimelineStep>,
119    #[serde(default)]
120    stagger: Option<f32>,
121}
122
123impl From<CountdownRaw> for Countdown {
124    fn from(raw: CountdownRaw) -> Self {
125        let mut style = raw.style;
126        let box_w = raw.digit_size * 0.75;
127        let box_h = raw.digit_size * 1.2;
128        if style.width.is_none() {
129            let w = countdown_total_width(
130                box_w,
131                raw.gap,
132                raw.show_hours,
133                raw.show_minutes,
134                raw.show_seconds,
135            );
136            style.width = Some(CSize::Length(CLP::Px(w)));
137        }
138        if style.height.is_none() {
139            style.height = Some(CSize::Length(CLP::Px(box_h)));
140        }
141        Countdown {
142            seconds: raw.seconds,
143            show_hours: raw.show_hours,
144            show_minutes: raw.show_minutes,
145            show_seconds: raw.show_seconds,
146            digit_size: raw.digit_size,
147            digit_color: raw.digit_color,
148            digit_background: raw.digit_background,
149            separator_color: raw.separator_color,
150            gap: raw.gap,
151            border_radius: raw.border_radius,
152            timing: raw.timing,
153            style,
154            timeline: raw.timeline,
155            stagger: raw.stagger,
156        }
157    }
158}
159
160/// Total ink width `paint()`'s cursor walk produces: each visible group is
161/// two digit boxes plus one inner gap (`gap*0.3`), and every group after
162/// the first is preceded by a separator that consumes a full `gap`. Shared
163/// by `From<CountdownRaw>` (to size the box) and `paint()`'s own `total_w`
164/// so the two can never drift apart again.
165fn countdown_total_width(
166    box_w: f32,
167    gap: f32,
168    show_hours: bool,
169    show_minutes: bool,
170    show_seconds: bool,
171) -> f32 {
172    let visible = [show_hours, show_minutes, show_seconds]
173        .iter()
174        .filter(|v| **v)
175        .count() as f32;
176    if visible <= 0.0 {
177        return 0.0;
178    }
179    let inner_gap = gap * 0.3;
180    let digit_pair_w = box_w * 2.0 + inner_gap;
181    let separators = (visible - 1.0).max(0.0);
182    digit_pair_w * visible + gap * separators
183}
184
185rustmotion_core::impl_traits!(Countdown {
186    Animatable => animation,
187    Timed => timing,
188    Styled => style,
189});
190
191impl Countdown {
192    fn digit_box_size(&self) -> (f32, f32) {
193        let box_w = self.digit_size * 0.75;
194        let box_h = self.digit_size * 1.2;
195        (box_w, box_h)
196    }
197
198    fn draw_digit_box(
199        &self,
200        canvas: &Canvas,
201        x: f32,
202        y: f32,
203        digit: char,
204        font: &skia_safe::Font,
205        emoji_font: &Option<skia_safe::Font>,
206    ) {
207        let (box_w, box_h) = self.digit_box_size();
208
209        // Background rounded rect
210        let mut bg_paint = paint_from_hex(&self.digit_background);
211        bg_paint.set_style(PaintStyle::Fill);
212        bg_paint.set_anti_alias(true);
213        let rect = Rect::from_xywh(x, y, box_w, box_h);
214        let rrect = RRect::new_rect_xy(rect, self.border_radius, self.border_radius);
215        canvas.draw_rrect(rrect, &bg_paint);
216
217        // Flip-clock horizontal line across the middle
218        let (r, g, b, _) = parse_hex_color(&self.digit_background);
219        let mut line_paint = skia_safe::Paint::default();
220        line_paint.set_style(PaintStyle::Stroke);
221        line_paint.set_stroke_width(1.0);
222        line_paint.set_anti_alias(true);
223        line_paint.set_color(skia_safe::Color::from_argb(76, r, g, b)); // alpha ~0.3
224        let mid_y = y + box_h / 2.0;
225        canvas.draw_line(
226            skia_safe::Point::new(x, mid_y),
227            skia_safe::Point::new(x + box_w, mid_y),
228            &line_paint,
229        );
230
231        // Digit text centered in box
232        let digit_str = digit.to_string();
233        let mut text_paint = paint_from_hex(&self.digit_color);
234        text_paint.set_anti_alias(true);
235
236        let text_w = measure_text_with_fallback(&digit_str, font, emoji_font, 0.0);
237        let (_, metrics) = font.metrics();
238        let text_x = x + (box_w - text_w) / 2.0;
239        let text_y = y + (box_h + (-metrics.ascent)) / 2.0;
240
241        draw_text_with_fallback(
242            canvas,
243            &digit_str,
244            font,
245            emoji_font,
246            0.0,
247            text_x,
248            text_y,
249            &text_paint,
250        );
251    }
252
253    fn draw_separator(
254        &self,
255        canvas: &Canvas,
256        x: f32,
257        y: f32,
258        font: &skia_safe::Font,
259        emoji_font: &Option<skia_safe::Font>,
260    ) -> f32 {
261        let (_, box_h) = self.digit_box_size();
262        let sep = ":";
263
264        let mut sep_paint = paint_from_hex(&self.separator_color);
265        sep_paint.set_anti_alias(true);
266
267        let sep_w = measure_text_with_fallback(sep, font, emoji_font, 0.0);
268        let (_, metrics) = font.metrics();
269        let sep_x = x + (self.gap - sep_w) / 2.0;
270        let sep_y = y + (box_h + (-metrics.ascent)) / 2.0;
271
272        draw_text_with_fallback(canvas, sep, font, emoji_font, 0.0, sep_x, sep_y, &sep_paint);
273
274        self.gap
275    }
276}
277
278impl Countdown {
279    fn paint(&self, canvas: &Canvas, layout: &BoxLayout, time: f64) {
280        let remaining = (self.seconds - time).max(0.0);
281        let total_secs = remaining as u64;
282        let hours = total_secs / 3600;
283        let minutes = (total_secs % 3600) / 60;
284        let secs = total_secs % 60;
285
286        let font_size = self.digit_size * 0.6;
287        let font_style = skia_safe::FontStyle::bold();
288        let Ok(typeface) = typeface_with_fallback("Inter", font_style) else {
289            return;
290        };
291        let font = skia_safe::Font::from_typeface(typeface, font_size);
292        let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size));
293
294        let (box_w, _box_h) = self.digit_box_size();
295
296        // Safety net: `style.width`/`style.height` on this struct are
297        // already sized (see `From<CountdownRaw>`) to match this exact
298        // cursor walk, so this shouldn't ever clip in practice — it's a
299        // backstop against drift if the two formulas are ever edited
300        // separately again.
301        canvas.save();
302        if layout.width > 0.0 && layout.height > 0.0 {
303            canvas.clip_rect(
304                Rect::from_xywh(0.0, 0.0, layout.width, layout.height),
305                skia_safe::ClipOp::Intersect,
306                true,
307            );
308        }
309
310        let mut cursor_x = 0.0_f32;
311        let cursor_y = 0.0_f32;
312
313        let inner_gap = self.gap * 0.3;
314        let mut need_separator = false;
315
316        if self.show_hours {
317            if need_separator {
318                let sep_w = self.draw_separator(canvas, cursor_x, cursor_y, &font, &emoji_font);
319                cursor_x += sep_w;
320            }
321            let h_str = format!("{:02}", hours);
322            let chars: Vec<char> = h_str.chars().collect();
323            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[0], &font, &emoji_font);
324            cursor_x += box_w + inner_gap;
325            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[1], &font, &emoji_font);
326            cursor_x += box_w;
327            need_separator = true;
328        }
329
330        if self.show_minutes {
331            if need_separator {
332                let sep_w = self.draw_separator(canvas, cursor_x, cursor_y, &font, &emoji_font);
333                cursor_x += sep_w;
334            }
335            let m_str = format!("{:02}", minutes);
336            let chars: Vec<char> = m_str.chars().collect();
337            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[0], &font, &emoji_font);
338            cursor_x += box_w + inner_gap;
339            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[1], &font, &emoji_font);
340            cursor_x += box_w;
341            need_separator = true;
342        }
343
344        if self.show_seconds {
345            if need_separator {
346                let sep_w = self.draw_separator(canvas, cursor_x, cursor_y, &font, &emoji_font);
347                cursor_x += sep_w;
348            }
349            let s_str = format!("{:02}", secs);
350            let chars: Vec<char> = s_str.chars().collect();
351            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[0], &font, &emoji_font);
352            cursor_x += box_w + inner_gap;
353            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[1], &font, &emoji_font);
354        }
355
356        canvas.restore();
357    }
358}
359
360impl Painter for Countdown {
361    fn paint_content(
362        &self,
363        canvas: &Canvas,
364        layout: &BoxLayout,
365        _props: &AnimatedProperties,
366        ctx: &PaintCtx,
367    ) {
368        self.paint(canvas, layout, ctx.time);
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn parse(json: &str) -> Countdown {
377        serde_json::from_str(json).expect("countdown should deserialize")
378    }
379
380    fn px_width(c: &Countdown) -> f32 {
381        let CSize::Length(CLP::Px(w)) = c.style.width.clone().expect("style.width should be set")
382        else {
383            panic!("expected an explicit px width");
384        };
385        w
386    }
387
388    #[test]
389    fn countdown_total_width_matches_paints_own_cursor_walk() {
390        // #127: box_builder.rs's width formula (`visible*2*box_w +
391        // (visible-1)*gap`) omits the small inner gap `paint()` inserts
392        // *within* each digit pair, so the box came out narrower than
393        // what actually got drawn (153×67 assigned vs. 187×68 painted).
394        // Recompute by hand here (independent of `countdown_total_width`
395        // itself) so this test would catch drift in either direction.
396        let box_w = 42.0_f32; // digit_size 56 * 0.75
397        let gap = default_gap();
398        let inner_gap = gap * 0.3;
399        // two visible groups (minutes, seconds): two pairs + one separator
400        let by_hand = (box_w * 2.0 + inner_gap) * 2.0 + gap;
401        let got = countdown_total_width(box_w, gap, false, true, true);
402        assert!(
403            (got - by_hand).abs() < 0.001,
404            "formula drift: got {got}, hand-computed {by_hand}"
405        );
406    }
407
408    #[test]
409    fn style_width_matches_the_shared_formula() {
410        let c = parse(r#"{"type":"countdown","show_hours":false,"digit_size":56}"#);
411        let expected = countdown_total_width(56.0 * 0.75, c.gap, false, true, true);
412        assert!((px_width(&c) - expected).abs() < 0.001);
413    }
414
415    #[test]
416    fn explicit_style_width_is_never_overridden() {
417        let c = parse(r#"{"type":"countdown","style":{"width":900}}"#);
418        assert_eq!(px_width(&c), 900.0);
419    }
420
421    #[test]
422    fn no_visible_groups_is_zero_not_negative() {
423        // Defensive: `(visible - 1.0)` must not go negative and blow up
424        // `separators` when nothing is shown.
425        assert_eq!(countdown_total_width(42.0, 12.0, false, false, false), 0.0);
426    }
427}