Skip to main content

rustmotion_components/
success_check.rs

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/// A checkmark that draws itself inside a halo.
33#[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    /// Diameter of the halo in px. The stroke scales with it.
45    #[serde(default = "default_check_size")]
46    pub size: f32,
47    /// Colour of the mark (hex).
48    #[serde(default = "default_check_tint")]
49    pub tint: String,
50    /// Halo opacity, 0..1. The halo takes `tint` unless `ring_color` says
51    /// otherwise, so the default reads as the mark's own colour behind it.
52    #[serde(default = "default_check_ring")]
53    pub ring: f32,
54    /// Halo colour (hex), overriding `tint`.
55    #[serde(default)]
56    pub ring_color: Option<String>,
57    /// Multiplier on the entrance rotation. `0` lands the mark square with
58    /// no swing; `2` gives it a pronounced one. The mark always finishes
59    /// upright whatever this is.
60    #[serde(default = "default_check_spin")]
61    pub spin: f32,
62    /// Stroke width of the mark in px. Defaults to 9% of `size`.
63    #[serde(default)]
64    pub stroke_width: Option<f32>,
65    /// Delay before the mark starts arriving (seconds).
66    #[serde(default)]
67    pub delay: f64,
68    /// How long the arrival takes (seconds).
69    #[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}