1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle, Path, PathBuilder};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::{ease, AnimatedProperties};
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color};
9use rustmotion_core::schema::{EasingType, TimelineStep};
10use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
11
12fn default_check_size() -> f32 {
13 82.0
14}
15
16fn default_check_tint() -> String {
17 "#22C55E".to_string()
18}
19
20fn default_check_ring() -> f32 {
21 0.28
22}
23
24fn default_check_spin() -> f32 {
25 1.0
26}
27
28fn default_check_duration() -> f64 {
29 0.7
30}
31
32#[derive(Debug, Serialize, Deserialize, JsonSchema)]
34#[deprecated(
35 since = "0.7.1",
36 note = "`success_check` is a frozen composition (issue #333). Compose an `svg` \
37 checkmark (`reveal: \"stroke\"` draw-on) inside a `shape` circle halo with a \
38 pop-in scale instead — see \
39 crates/rustmotion/skills/rules/composition-recipes.md. Kept for compatibility; \
40 scheduled for removal in a future major version via `rustmotion migrate` \
41 (#335)."
42)]
43pub struct SuccessCheck {
44 #[serde(default = "default_check_size")]
46 pub size: f32,
47 #[serde(default = "default_check_tint")]
49 pub tint: String,
50 #[serde(default = "default_check_ring")]
53 pub ring: f32,
54 #[serde(default)]
56 pub ring_color: Option<String>,
57 #[serde(default = "default_check_spin")]
61 pub spin: f32,
62 #[serde(default)]
64 pub stroke_width: Option<f32>,
65 #[serde(default)]
67 pub delay: f64,
68 #[serde(default = "default_check_duration")]
70 pub duration: f64,
71 #[serde(flatten)]
72 pub timing: TimingConfig,
73 #[serde(default)]
74 pub style: CssStyle,
75 #[serde(default)]
76 pub timeline: Vec<TimelineStep>,
77 #[serde(default)]
78 pub stagger: Option<f32>,
79}
80
81rustmotion_core::impl_traits!(SuccessCheck {
82 Animatable => animation,
83 Timed => timing,
84 Styled => style,
85});
86
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub(crate) struct CheckPhase {
89 pub arrival: f32,
90 pub stroke: f32,
91}
92
93impl SuccessCheck {
94 const STROKE_START: f64 = 0.35;
95
96 pub(crate) fn phase_at(&self, time: f64) -> CheckPhase {
97 if self.duration <= 0.0 {
98 return CheckPhase {
99 arrival: 1.0,
100 stroke: 1.0,
101 };
102 }
103 let raw = ((time - self.delay) / self.duration).clamp(0.0, 1.0);
104 if raw <= 0.0 {
105 return CheckPhase {
106 arrival: 0.0,
107 stroke: 0.0,
108 };
109 }
110 let stroke_raw = ((raw - Self::STROKE_START) / (1.0 - Self::STROKE_START)).clamp(0.0, 1.0);
111 CheckPhase {
112 arrival: ease(raw, &EasingType::EaseOutBack) as f32,
113 stroke: ease(stroke_raw, &EasingType::EaseOutQuad) as f32,
114 }
115 }
116
117 pub(crate) fn check_path(size: f32) -> Path {
118 let mut path = PathBuilder::new();
119 path.move_to((0.28 * size, 0.52 * size));
120 path.line_to((0.44 * size, 0.69 * size));
121 path.line_to((0.73 * size, 0.33 * size));
122 path.detach()
123 }
124}
125
126impl Painter for SuccessCheck {
127 fn paint_content(
128 &self,
129 canvas: &Canvas,
130 _layout: &BoxLayout,
131 _props: &AnimatedProperties,
132 ctx: &PaintCtx,
133 ) {
134 let phase = self.phase_at(ctx.time);
135 if phase.arrival <= 0.0 {
136 return;
137 }
138 let size = self.size;
139 let centre = size / 2.0;
140
141 let scale = 0.72 + 0.28 * phase.arrival;
142 let angle = -18.0 * self.spin * (1.0 - phase.arrival);
143
144 canvas.save();
145 canvas.translate((centre, centre));
146 canvas.scale((scale, scale));
147 canvas.rotate(angle, None);
148 canvas.translate((-centre, -centre));
149
150 if self.ring > 0.0 {
151 let hex = self.ring_color.as_deref().unwrap_or(&self.tint);
152 let (r, g, b, _) = parse_hex_color(hex);
153 let alpha = (self.ring.clamp(0.0, 1.0) * phase.arrival.clamp(0.0, 1.0) * 255.0) as u8;
154 let mut halo = skia_safe::Paint::default();
155 halo.set_style(PaintStyle::Fill);
156 halo.set_anti_alias(true);
157 halo.set_color(skia_safe::Color::from_argb(alpha, r, g, b));
158 canvas.draw_circle((centre, centre), size * 0.5, &halo);
159 }
160
161 let path = Self::check_path(size);
162 let mut stroke = paint_from_hex(&self.tint);
163 stroke.set_style(PaintStyle::Stroke);
164 stroke.set_anti_alias(true);
165 stroke.set_stroke_width(self.stroke_width.unwrap_or(size * 0.09));
166 stroke.set_stroke_cap(skia_safe::PaintCap::Round);
167 stroke.set_stroke_join(skia_safe::PaintJoin::Round);
168
169 if phase.stroke <= 0.0 {
170 canvas.restore();
171 return;
172 }
173 if phase.stroke < 1.0 {
174 let mut measure = skia_safe::PathMeasure::new(&path, false, None);
175 let len = measure.length();
176 let drawn = len * phase.stroke;
177 if let Some(dash) = skia_safe::PathEffect::dash(&[drawn, len - drawn + 1.0], 0.0) {
178 stroke.set_path_effect(dash);
179 }
180 }
181 canvas.draw_path(&path, &stroke);
182
183 canvas.restore();
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn check(json: serde_json::Value) -> SuccessCheck {
192 serde_json::from_value(json).expect("success_check fixture")
193 }
194
195 #[test]
196 fn nothing_is_drawn_before_the_delay() {
197 let c = check(serde_json::json!({ "delay": 1.0 }));
198 let p = c.phase_at(0.5);
199 assert_eq!(p.arrival, 0.0, "the mark has not started arriving");
200 assert_eq!(p.stroke, 0.0, "and nothing of it is drawn");
201 }
202
203 #[test]
204 fn the_stroke_starts_after_the_halo_has_landed() {
205 let c = check(serde_json::json!({ "duration": 1.0 }));
206 let early = c.phase_at(0.2);
207 assert!(
208 early.arrival > 0.0,
209 "the halo is already arriving at 20% of the window"
210 );
211 assert_eq!(
212 early.stroke, 0.0,
213 "but the stroke has not begun — it waits for the landing"
214 );
215 assert!(
216 c.phase_at(0.6).stroke > 0.0,
217 "by 60% the stroke is under way"
218 );
219 }
220
221 #[test]
222 fn both_phases_are_complete_once_the_window_has_passed() {
223 let c = check(serde_json::json!({ "duration": 0.5, "delay": 0.25 }));
224 let done = c.phase_at(5.0);
225 assert!((done.arrival - 1.0).abs() < 1e-5);
226 assert!((done.stroke - 1.0).abs() < 1e-5);
227 }
228
229 #[test]
230 fn a_zero_duration_lands_immediately_instead_of_dividing_by_zero() {
231 let c = check(serde_json::json!({ "duration": 0.0 }));
232 let p = c.phase_at(0.0);
233 assert_eq!((p.arrival, p.stroke), (1.0, 1.0));
234 }
235
236 #[test]
237 fn the_mark_finishes_upright_whatever_the_spin() {
238 for spin in [0.0, 1.0, 2.0, 5.0] {
239 let c = check(serde_json::json!({ "spin": spin, "duration": 0.5 }));
240 let settled = c.phase_at(2.0).arrival;
241 let angle = -18.0 * spin * (1.0 - settled);
242 assert!(
243 angle.abs() < 1e-4,
244 "spin={spin} left the settled mark rotated by {angle}°"
245 );
246 }
247 }
248}