Skip to main content

rustmotion_components/
success_check.rs

1//! The confirmation mark: a checkmark drawing itself inside a pale halo,
2//! arriving with a small pop and a rotation it resolves as it lands.
3//!
4//! Assembling this out of a `shape` circle, an `svg` with `draw_in` and a
5//! `scale_in` is possible, and was the only way before this existed — but the
6//! three have to be kept in time with each other by hand, and the mark is the
7//! single most repeated beat in a product video. One component, one timeline.
8
9use 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/// A checkmark that draws itself inside a halo.
41#[derive(Debug, Serialize, Deserialize, JsonSchema)]
42pub struct SuccessCheck {
43    /// Diameter of the halo in px. The stroke scales with it.
44    #[serde(default = "default_check_size")]
45    pub size: f32,
46    /// Colour of the mark (hex).
47    #[serde(default = "default_check_tint")]
48    pub tint: String,
49    /// Halo opacity, 0..1. The halo takes `tint` unless `ring_color` says
50    /// otherwise, so the default reads as the mark's own colour behind it.
51    #[serde(default = "default_check_ring")]
52    pub ring: f32,
53    /// Halo colour (hex), overriding `tint`.
54    #[serde(default)]
55    pub ring_color: Option<String>,
56    /// Multiplier on the entrance rotation. `0` lands the mark square with
57    /// no swing; `2` gives it a pronounced one. The mark always finishes
58    /// upright whatever this is.
59    #[serde(default = "default_check_spin")]
60    pub spin: f32,
61    /// Stroke width of the mark in px. Defaults to 9% of `size`.
62    #[serde(default)]
63    pub stroke_width: Option<f32>,
64    /// Delay before the mark starts arriving (seconds).
65    #[serde(default)]
66    pub delay: f64,
67    /// How long the arrival takes (seconds).
68    #[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/// Where the mark is in its arrival at a given instant.
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub(crate) struct CheckPhase {
89    /// Eased 0..1 over the whole arrival — drives scale, rotation, opacity.
90    pub arrival: f32,
91    /// Eased 0..1 over the stroke's own, later window.
92    pub stroke: f32,
93}
94
95impl SuccessCheck {
96    /// The stroke starts once the halo has mostly landed: a mark that draws
97    /// itself while still flying in reads as two unrelated animations.
98    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            // `ease_out_back(0)` is 0 only up to float residue (~2e-16), and
110            // "has it started?" is a question this type should answer
111            // exactly rather than approximately.
112            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    /// The checkmark itself, in units of `size`.
125    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        // Scale up from 72% and unwind the entrance rotation. `ease_out_back`
150        // already overshoots past 1, so the mark settles by springing back
151        // rather than by decelerating into place.
152        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        // Halo
162        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        // The mark, drawn on rather than faded in: a dash whose gap shrinks
174        // to nothing is what makes it read as being written.
175        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        // A mark that writes itself while still flying in reads as two
220        // animations fighting rather than as one gesture.
221        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        // `spin` scales the swing on the way in; it must never leave the
255        // finished mark tilted, or a still frame of the end state is wrong.
256        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}