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")]
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}