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")]
60#[deprecated(
61    since = "0.7.1",
62    note = "`countdown` is a frozen composition (issue #333). Compose digit tiles from a \
63            `card` + `text` per unit instead of the dedicated flip-clock — see \
64            crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
65            scheduled for removal in a future major version via `rustmotion migrate` \
66            (#335)."
67)]
68pub struct Countdown {
69    #[serde(default = "default_seconds")]
70    pub seconds: f64,
71    #[serde(default = "default_true")]
72    pub show_hours: bool,
73    #[serde(default = "default_true")]
74    pub show_minutes: bool,
75    #[serde(default = "default_true")]
76    pub show_seconds: bool,
77    #[serde(default = "default_digit_size")]
78    pub digit_size: f32,
79    #[serde(default = "default_digit_color")]
80    pub digit_color: String,
81    #[serde(default = "default_digit_background")]
82    pub digit_background: String,
83    #[serde(default = "default_separator_color")]
84    pub separator_color: String,
85    #[serde(default = "default_gap")]
86    pub gap: f32,
87    #[serde(default = "default_border_radius")]
88    pub border_radius: f32,
89    #[serde(default)]
90    pub timing: TimingConfig,
91    #[serde(default)]
92    pub style: CssStyle,
93    #[serde(default)]
94    pub timeline: Vec<TimelineStep>,
95    #[serde(default)]
96    pub stagger: Option<f32>,
97}
98
99#[derive(Debug, Deserialize)]
100struct CountdownRaw {
101    #[serde(default = "default_seconds")]
102    seconds: f64,
103    #[serde(default = "default_true")]
104    show_hours: bool,
105    #[serde(default = "default_true")]
106    show_minutes: bool,
107    #[serde(default = "default_true")]
108    show_seconds: bool,
109    #[serde(default = "default_digit_size")]
110    digit_size: f32,
111    #[serde(default = "default_digit_color")]
112    digit_color: String,
113    #[serde(default = "default_digit_background")]
114    digit_background: String,
115    #[serde(default = "default_separator_color")]
116    separator_color: String,
117    #[serde(default = "default_gap")]
118    gap: f32,
119    #[serde(default = "default_border_radius")]
120    border_radius: f32,
121    #[serde(flatten)]
122    timing: TimingConfig,
123    #[serde(default)]
124    style: CssStyle,
125    #[serde(default)]
126    timeline: Vec<TimelineStep>,
127    #[serde(default)]
128    stagger: Option<f32>,
129}
130
131impl From<CountdownRaw> for Countdown {
132    fn from(raw: CountdownRaw) -> Self {
133        let mut style = raw.style;
134        let box_w = raw.digit_size * 0.75;
135        let box_h = raw.digit_size * 1.2;
136        if style.width.is_none() {
137            let w = countdown_total_width(
138                box_w,
139                raw.gap,
140                raw.show_hours,
141                raw.show_minutes,
142                raw.show_seconds,
143            );
144            style.width = Some(CSize::Length(CLP::Px(w)));
145        }
146        if style.height.is_none() {
147            style.height = Some(CSize::Length(CLP::Px(box_h)));
148        }
149        Countdown {
150            seconds: raw.seconds,
151            show_hours: raw.show_hours,
152            show_minutes: raw.show_minutes,
153            show_seconds: raw.show_seconds,
154            digit_size: raw.digit_size,
155            digit_color: raw.digit_color,
156            digit_background: raw.digit_background,
157            separator_color: raw.separator_color,
158            gap: raw.gap,
159            border_radius: raw.border_radius,
160            timing: raw.timing,
161            style,
162            timeline: raw.timeline,
163            stagger: raw.stagger,
164        }
165    }
166}
167
168fn countdown_total_width(
169    box_w: f32,
170    gap: f32,
171    show_hours: bool,
172    show_minutes: bool,
173    show_seconds: bool,
174) -> f32 {
175    let visible = [show_hours, show_minutes, show_seconds]
176        .iter()
177        .filter(|v| **v)
178        .count() as f32;
179    if visible <= 0.0 {
180        return 0.0;
181    }
182    let inner_gap = gap * 0.3;
183    let digit_pair_w = box_w * 2.0 + inner_gap;
184    let separators = (visible - 1.0).max(0.0);
185    digit_pair_w * visible + gap * separators
186}
187
188rustmotion_core::impl_traits!(Countdown {
189    Animatable => animation,
190    Timed => timing,
191    Styled => style,
192});
193
194impl Countdown {
195    fn digit_box_size(&self) -> (f32, f32) {
196        let box_w = self.digit_size * 0.75;
197        let box_h = self.digit_size * 1.2;
198        (box_w, box_h)
199    }
200
201    fn draw_digit_box(
202        &self,
203        canvas: &Canvas,
204        x: f32,
205        y: f32,
206        digit: char,
207        font: &skia_safe::Font,
208        emoji_font: &Option<skia_safe::Font>,
209    ) {
210        let (box_w, box_h) = self.digit_box_size();
211
212        let mut bg_paint = paint_from_hex(&self.digit_background);
213        bg_paint.set_style(PaintStyle::Fill);
214        bg_paint.set_anti_alias(true);
215        let rect = Rect::from_xywh(x, y, box_w, box_h);
216        let rrect = RRect::new_rect_xy(rect, self.border_radius, self.border_radius);
217        canvas.draw_rrect(rrect, &bg_paint);
218
219        let (r, g, b, _) = parse_hex_color(&self.digit_background);
220        let mut line_paint = skia_safe::Paint::default();
221        line_paint.set_style(PaintStyle::Stroke);
222        line_paint.set_stroke_width(1.0);
223        line_paint.set_anti_alias(true);
224        line_paint.set_color(skia_safe::Color::from_argb(76, r, g, b));
225        let mid_y = y + box_h / 2.0;
226        canvas.draw_line(
227            skia_safe::Point::new(x, mid_y),
228            skia_safe::Point::new(x + box_w, mid_y),
229            &line_paint,
230        );
231
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        canvas.save();
297        if layout.width > 0.0 && layout.height > 0.0 {
298            canvas.clip_rect(
299                Rect::from_xywh(0.0, 0.0, layout.width, layout.height),
300                skia_safe::ClipOp::Intersect,
301                true,
302            );
303        }
304
305        let mut cursor_x = 0.0_f32;
306        let cursor_y = 0.0_f32;
307
308        let inner_gap = self.gap * 0.3;
309        let mut need_separator = false;
310
311        if self.show_hours {
312            if need_separator {
313                let sep_w = self.draw_separator(canvas, cursor_x, cursor_y, &font, &emoji_font);
314                cursor_x += sep_w;
315            }
316            let h_str = format!("{:02}", hours);
317            let chars: Vec<char> = h_str.chars().collect();
318            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[0], &font, &emoji_font);
319            cursor_x += box_w + inner_gap;
320            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[1], &font, &emoji_font);
321            cursor_x += box_w;
322            need_separator = true;
323        }
324
325        if self.show_minutes {
326            if need_separator {
327                let sep_w = self.draw_separator(canvas, cursor_x, cursor_y, &font, &emoji_font);
328                cursor_x += sep_w;
329            }
330            let m_str = format!("{:02}", minutes);
331            let chars: Vec<char> = m_str.chars().collect();
332            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[0], &font, &emoji_font);
333            cursor_x += box_w + inner_gap;
334            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[1], &font, &emoji_font);
335            cursor_x += box_w;
336            need_separator = true;
337        }
338
339        if self.show_seconds {
340            if need_separator {
341                let sep_w = self.draw_separator(canvas, cursor_x, cursor_y, &font, &emoji_font);
342                cursor_x += sep_w;
343            }
344            let s_str = format!("{:02}", secs);
345            let chars: Vec<char> = s_str.chars().collect();
346            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[0], &font, &emoji_font);
347            cursor_x += box_w + inner_gap;
348            self.draw_digit_box(canvas, cursor_x, cursor_y, chars[1], &font, &emoji_font);
349        }
350
351        canvas.restore();
352    }
353}
354
355impl Painter for Countdown {
356    fn paint_content(
357        &self,
358        canvas: &Canvas,
359        layout: &BoxLayout,
360        _props: &AnimatedProperties,
361        ctx: &PaintCtx,
362    ) {
363        self.paint(canvas, layout, ctx.time);
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn parse(json: &str) -> Countdown {
372        serde_json::from_str(json).expect("countdown should deserialize")
373    }
374
375    fn px_width(c: &Countdown) -> f32 {
376        let CSize::Length(CLP::Px(w)) = c.style.width.clone().expect("style.width should be set")
377        else {
378            panic!("expected an explicit px width");
379        };
380        w
381    }
382
383    #[test]
384    fn countdown_total_width_matches_paints_own_cursor_walk() {
385        let box_w = 42.0_f32;
386        let gap = default_gap();
387        let inner_gap = gap * 0.3;
388        let by_hand = (box_w * 2.0 + inner_gap) * 2.0 + gap;
389        let got = countdown_total_width(box_w, gap, false, true, true);
390        assert!(
391            (got - by_hand).abs() < 0.001,
392            "formula drift: got {got}, hand-computed {by_hand}"
393        );
394    }
395
396    #[test]
397    fn style_width_matches_the_shared_formula() {
398        let c = parse(r#"{"type":"countdown","show_hours":false,"digit_size":56}"#);
399        let expected = countdown_total_width(56.0 * 0.75, c.gap, false, true, true);
400        assert!((px_width(&c) - expected).abs() < 0.001);
401    }
402
403    #[test]
404    fn explicit_style_width_is_never_overridden() {
405        let c = parse(r#"{"type":"countdown","style":{"width":900}}"#);
406        assert_eq!(px_width(&c), 900.0);
407    }
408
409    #[test]
410    fn no_visible_groups_is_zero_not_negative() {
411        assert_eq!(countdown_total_width(42.0, 12.0, false, false, false), 0.0);
412    }
413}