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