1use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use skia_safe::{Canvas, Paint, PaintStyle, Path, PathBuilder};
15
16use rustmotion_core::css::CssStyle;
17use rustmotion_core::engine::animator::AnimatedProperties;
18use rustmotion_core::engine::layout_pass::BoxLayout;
19use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color};
20use rustmotion_core::schema::TimelineStep;
21use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
22
23use crate::cursor::{waypoint_offset, CursorPathEasing, CursorWaypoint};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum PointerTone {
30 #[default]
32 Light,
33 Dark,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
39#[serde(rename_all = "snake_case")]
40pub enum ClickRing {
41 Subtle,
43 #[default]
44 Standard,
45 Bold,
47 None,
49}
50
51impl ClickRing {
52 fn metrics(self) -> Option<(f32, f32)> {
54 match self {
55 Self::Subtle => Some((0.05, 0.55)),
56 Self::Standard => Some((0.09, 0.85)),
57 Self::Bold => Some((0.16, 1.25)),
58 Self::None => None,
59 }
60 }
61}
62
63fn default_pointer_size() -> f32 {
64 44.0
65}
66
67fn default_pointer_click_duration() -> f32 {
68 0.45
69}
70
71#[derive(Debug, Serialize, Deserialize, JsonSchema)]
73pub struct Pointer {
74 #[serde(default = "default_pointer_size")]
76 pub size: f32,
77 #[serde(default)]
79 pub tone: PointerTone,
80 #[serde(default)]
82 pub color: Option<String>,
83 #[serde(default)]
85 pub outline_color: Option<String>,
86 #[serde(default)]
88 pub click_ring: ClickRing,
89 #[serde(default)]
91 pub ring_color: Option<String>,
92 #[serde(default)]
97 pub path: Vec<CursorWaypoint>,
98 #[serde(default)]
102 pub click_at: Vec<f64>,
103 #[serde(default = "default_pointer_click_duration")]
106 pub click_duration: f32,
107 #[serde(default)]
109 pub path_easing: CursorPathEasing,
110 #[serde(flatten)]
111 pub timing: TimingConfig,
112 #[serde(default)]
113 pub style: CssStyle,
114 #[serde(default)]
115 pub timeline: Vec<TimelineStep>,
116 #[serde(default)]
117 pub stagger: Option<f32>,
118}
119
120rustmotion_core::impl_traits!(Pointer {
121 Animatable => animation,
122 Timed => timing,
123 Styled => style,
124});
125
126impl Pointer {
127 fn click_times(&self) -> Vec<f64> {
128 if self.path.is_empty() {
129 self.click_at.clone()
130 } else {
131 self.path.iter().map(|w| w.time).collect()
132 }
133 }
134
135 fn click_progress(&self, time: f64) -> Option<f32> {
137 if self.click_duration <= 0.0 {
138 return None;
139 }
140 self.click_times()
141 .into_iter()
142 .rfind(|&t| time >= t && time < t + self.click_duration as f64)
145 .map(|t| ((time - t) / self.click_duration as f64) as f32)
146 }
147
148 fn colors(&self) -> (String, String) {
149 let (fill, outline) = match self.tone {
150 PointerTone::Light => ("#FFFFFF", "#111827"),
151 PointerTone::Dark => ("#111827", "#FFFFFF"),
152 };
153 (
154 self.color.clone().unwrap_or_else(|| fill.to_string()),
155 self.outline_color
156 .clone()
157 .unwrap_or_else(|| outline.to_string()),
158 )
159 }
160
161 fn arrow_path(size: f32) -> Path {
166 const OUTLINE: [(f32, f32); 7] = [
167 (0.0, 0.0),
168 (0.0, 0.72),
169 (0.19, 0.56),
170 (0.30, 0.84),
171 (0.43, 0.78),
172 (0.32, 0.51),
173 (0.54, 0.51),
174 ];
175 let mut path = PathBuilder::new();
176 for (i, (x, y)) in OUTLINE.iter().enumerate() {
177 let p = (x * size, y * size);
178 if i == 0 {
179 path.move_to(p);
180 } else {
181 path.line_to(p);
182 }
183 }
184 path.close();
185 path.detach()
186 }
187}
188
189impl Painter for Pointer {
190 fn paint_content(
191 &self,
192 canvas: &Canvas,
193 _layout: &BoxLayout,
194 _props: &AnimatedProperties,
195 ctx: &PaintCtx,
196 ) {
197 let (dx, dy) = if self.path.is_empty() {
198 (0.0, 0.0)
199 } else {
200 waypoint_offset(&self.path, ctx.time, self.click_duration, self.path_easing)
201 };
202 let click = self.click_progress(ctx.time);
203 let (fill, outline) = self.colors();
204
205 canvas.save();
206 canvas.translate((dx, dy));
207
208 if let (Some(p), Some((stroke_f, travel_f))) = (click, self.click_ring.metrics()) {
212 let (r, g, b, _) = parse_hex_color(self.ring_color.as_deref().unwrap_or(&fill));
213 let alpha = ((1.0 - p) * 200.0) as u8;
214 if alpha > 0 {
215 let mut ring = Paint::default();
216 ring.set_style(PaintStyle::Stroke);
217 ring.set_anti_alias(true);
218 ring.set_stroke_width(stroke_f * self.size);
219 ring.set_color(skia_safe::Color::from_argb(alpha, r, g, b));
220 canvas.draw_circle((0.0, 0.0), p * travel_f * self.size, &ring);
221 }
222 }
223
224 if let Some(p) = click {
228 let scale = if p < 0.35 {
229 1.0 - 0.12 * (p / 0.35)
230 } else {
231 0.88 + 0.12 * ((p - 0.35) / 0.65)
232 };
233 canvas.scale((scale, scale));
234 }
235
236 let path = Self::arrow_path(self.size);
237 let mut outline_paint = paint_from_hex(&outline);
238 outline_paint.set_style(PaintStyle::Stroke);
239 outline_paint.set_stroke_width((self.size * 0.07).max(1.0));
240 outline_paint.set_stroke_join(skia_safe::PaintJoin::Round);
241 outline_paint.set_anti_alias(true);
242
243 let mut fill_paint = paint_from_hex(&fill);
244 fill_paint.set_style(PaintStyle::Fill);
245 fill_paint.set_anti_alias(true);
246
247 canvas.draw_path(&path, &fill_paint);
248 canvas.draw_path(&path, &outline_paint);
249
250 canvas.restore();
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn pointer(json: serde_json::Value) -> Pointer {
259 serde_json::from_value(json).expect("pointer fixture")
260 }
261
262 #[test]
263 fn a_pointer_with_no_path_sits_at_its_own_origin() {
264 let p = pointer(serde_json::json!({}));
265 assert!(p.path.is_empty());
266 assert_eq!(p.click_progress(0.0), None, "no clicks were asked for");
267 }
268
269 #[test]
270 fn clicks_come_from_the_waypoints_when_a_path_is_given() {
271 let p = pointer(serde_json::json!({
274 "click_at": [9.0],
275 "path": [
276 { "time": 0.0, "x": 0.0, "y": 0.0 },
277 { "time": 1.0, "x": 200.0, "y": 100.0 }
278 ]
279 }));
280 assert_eq!(p.click_times(), vec![0.0, 1.0]);
281 assert!(
282 p.click_progress(9.1).is_none(),
283 "a `click_at` entry must be ignored once the pointer has a path"
284 );
285 }
286
287 #[test]
288 fn a_click_runs_for_exactly_its_duration() {
289 let p = pointer(serde_json::json!({
290 "click_at": [1.0],
291 "click_duration": 0.5
292 }));
293 assert_eq!(p.click_progress(0.9), None, "before the click");
294 assert_eq!(p.click_progress(1.0), Some(0.0), "at the click");
295 assert!(
296 matches!(p.click_progress(1.25), Some(t) if (t - 0.5).abs() < 1e-5),
297 "halfway through"
298 );
299 assert_eq!(p.click_progress(1.5), None, "the instant it ends");
300 }
301
302 #[test]
303 fn overlapping_clicks_resolve_to_the_most_recent() {
304 let p = pointer(serde_json::json!({
307 "click_at": [1.0, 1.2],
308 "click_duration": 0.5
309 }));
310 let at = p.click_progress(1.3).expect("a click is running at 1.3");
311 assert!(
312 (at - 0.2).abs() < 1e-5,
313 "expected 0.1s into the second click (0.2 of its duration), got {at}"
314 );
315 }
316
317 #[test]
318 fn the_pointer_holds_its_first_waypoint_before_the_path_starts() {
319 let p = pointer(serde_json::json!({
320 "path": [
321 { "time": 1.0, "x": 100.0, "y": 50.0 },
322 { "time": 2.0, "x": 400.0, "y": 50.0 }
323 ]
324 }));
325 assert_eq!(
326 waypoint_offset(&p.path, 0.0, p.click_duration, p.path_easing),
327 (100.0, 50.0),
328 "before the first waypoint's time the pointer waits there, it does not fly in"
329 );
330 }
331
332 #[test]
333 fn none_removes_the_ring_without_removing_the_click() {
334 let p = pointer(serde_json::json!({
335 "click_at": [1.0],
336 "click_ring": "none"
337 }));
338 assert!(p.click_ring.metrics().is_none(), "no ring to draw");
339 assert!(
340 p.click_progress(1.1).is_some(),
341 "the click itself still runs — the arrow still dips"
342 );
343 }
344}