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#[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
160fn 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 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 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)); 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 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();
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 let box_w = 42.0_f32; let gap = default_gap();
398 let inner_gap = gap * 0.3;
399 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 assert_eq!(countdown_total_width(42.0, 12.0, false, false, false), 0.0);
426 }
427}