1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::Canvas;
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::paint_from_hex;
9use rustmotion_core::schema::TimelineStep;
10use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
11
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
14pub struct CursorWaypoint {
15 pub time: f64,
17 pub x: f32,
19 pub y: f32,
21}
22
23#[derive(Debug, Serialize, Deserialize, JsonSchema)]
25pub struct Cursor {
26 #[serde(default = "default_cursor_width")]
27 pub width: f32,
28 #[serde(default = "default_cursor_height")]
29 pub height: f32,
30 #[serde(default = "default_cursor_color")]
31 pub color: String,
32 #[serde(default = "default_blink_interval")]
34 pub blink: f32,
35 #[serde(default = "default_cursor_radius")]
36 pub radius: f32,
37 #[serde(default)]
40 pub click_at: Vec<f64>,
41 #[serde(default)]
44 pub auto_path: Vec<CursorWaypoint>,
45 #[serde(default = "default_click_duration")]
47 pub click_duration: f32,
48 #[serde(default)]
51 pub cursor_style: CursorStyle,
52 #[serde(default)]
54 pub path_easing: CursorPathEasing,
55 #[serde(flatten)]
56 pub timing: TimingConfig,
57 #[serde(default)]
58 pub style: CssStyle,
59 #[serde(default)]
60 pub timeline: Vec<TimelineStep>,
61 #[serde(default)]
62 pub stagger: Option<f32>,
63}
64
65fn default_cursor_width() -> f32 {
66 3.0
67}
68
69fn default_cursor_height() -> f32 {
70 40.0
71}
72
73fn default_cursor_color() -> String {
74 "#FFFFFF".to_string()
75}
76
77fn default_blink_interval() -> f32 {
78 0.5
79}
80
81fn default_cursor_radius() -> f32 {
82 1.5
83}
84
85fn default_click_duration() -> f32 {
86 0.3
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
91#[serde(rename_all = "snake_case")]
92pub enum CursorStyle {
93 #[default]
94 Default,
95 Pointer,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
101#[serde(rename_all = "snake_case")]
102pub enum CursorPathEasing {
103 Linear,
104 EaseOut,
105 #[default]
106 EaseInOut,
107 Step,
111}
112
113rustmotion_core::impl_traits!(Cursor {
114 Animatable => animation,
115 Timed => timing,
116 Styled => style,
117});
118
119impl Cursor {
120 fn click_times(&self) -> Vec<f64> {
122 if !self.auto_path.is_empty() {
123 self.auto_path.iter().map(|w| w.time).collect()
124 } else {
125 self.click_at.clone()
126 }
127 }
128
129 fn auto_path_offset(&self, time: f64) -> (f32, f32) {
132 waypoint_offset(&self.auto_path, time, self.click_duration, self.path_easing)
133 }
134}
135
136pub(crate) fn waypoint_offset(
145 waypoints: &[CursorWaypoint],
146 time: f64,
147 click_duration: f32,
148 path_easing: CursorPathEasing,
149) -> (f32, f32) {
150 {
151 if waypoints.len() < 2 {
152 if let Some(wp) = waypoints.first() {
153 return (wp.x, wp.y);
154 }
155 return (0.0, 0.0);
156 }
157
158 if time <= waypoints[0].time {
160 return (waypoints[0].x, waypoints[0].y);
161 }
162
163 if time >= waypoints[waypoints.len() - 1].time {
165 let last = &waypoints[waypoints.len() - 1];
166 return (last.x, last.y);
167 }
168
169 let mut seg_idx = 0;
171 for i in 0..waypoints.len() - 1 {
172 if time >= waypoints[i].time && time < waypoints[i + 1].time {
173 seg_idx = i;
174 break;
175 }
176 }
177
178 let wp0 = &waypoints[seg_idx];
179 let wp1 = &waypoints[seg_idx + 1];
180 let seg_duration = wp1.time - wp0.time;
181 if seg_duration <= 0.0 {
182 return (wp1.x, wp1.y);
183 }
184
185 let click_end = wp0.time + click_duration as f64;
187 let move_start = if seg_idx > 0 { click_end } else { wp0.time };
188 let move_duration = wp1.time - move_start;
189
190 if time < move_start || move_duration <= 0.0 {
191 return (wp0.x, wp0.y);
192 }
193
194 let raw_t = ((time - move_start) / move_duration).clamp(0.0, 1.0);
195
196 let t = match path_easing {
198 CursorPathEasing::Step => 0.0,
201 CursorPathEasing::Linear => raw_t,
202 CursorPathEasing::EaseOut => 1.0 - (1.0 - raw_t).powi(3),
203 CursorPathEasing::EaseInOut => {
204 if raw_t < 0.5 {
205 4.0 * raw_t * raw_t * raw_t
206 } else {
207 1.0 - (-2.0 * raw_t + 2.0).powi(3) / 2.0
208 }
209 }
210 } as f32;
211
212 let p_prev = if seg_idx > 0 {
214 &waypoints[seg_idx - 1]
215 } else {
216 wp0
217 };
218 let p_next = if seg_idx + 2 < waypoints.len() {
219 &waypoints[seg_idx + 2]
220 } else {
221 wp1
222 };
223
224 let x = catmull_rom(t, p_prev.x, wp0.x, wp1.x, p_next.x);
225 let y = catmull_rom(t, p_prev.y, wp0.y, wp1.y, p_next.y);
226
227 (x, y)
228 }
229}
230
231fn catmull_rom(t: f32, p0: f32, p1: f32, p2: f32, p3: f32) -> f32 {
233 let t2 = t * t;
234 let t3 = t2 * t;
235 0.5 * ((2.0 * p1)
236 + (-p0 + p2) * t
237 + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
238 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3)
239}
240
241impl Painter for Cursor {
242 fn paint_content(
243 &self,
244 canvas: &Canvas,
245 _layout: &BoxLayout,
246 _props: &AnimatedProperties,
247 ctx: &PaintCtx,
248 ) {
249 let click_times = self.click_times();
250
251 let in_click = click_times.iter().any(|&t| {
252 let dt = ctx.time - t;
253 dt >= 0.0 && dt < self.click_duration as f64
254 });
255
256 if self.blink > 0.0 && !in_click {
257 let cycle = (ctx.time as f32 % (self.blink * 2.0)) / self.blink;
258 if cycle >= 1.0 {
259 return;
260 }
261 }
262
263 let (path_dx, path_dy) = if !self.auto_path.is_empty() {
264 self.auto_path_offset(ctx.time)
265 } else {
266 (0.0, 0.0)
267 };
268
269 let click_scale = if in_click {
270 let closest_click = click_times
271 .iter()
272 .filter(|&&t| ctx.time >= t && ctx.time < t + self.click_duration as f64)
273 .copied()
274 .last()
275 .unwrap_or(0.0);
276 let progress = ((ctx.time - closest_click) / self.click_duration as f64) as f32;
277 if progress < 0.3 {
278 1.0 + 0.5 * (progress / 0.3)
279 } else {
280 1.5 - 0.5 * ((progress - 0.3) / 0.7)
281 }
282 } else {
283 1.0
284 };
285
286 if path_dx.abs() > 0.001 || path_dy.abs() > 0.001 {
287 canvas.save();
288 canvas.translate((path_dx, path_dy));
289 }
290
291 if (click_scale - 1.0).abs() > 0.001 {
292 let cx = self.width / 2.0;
293 let cy = self.height / 2.0;
294 canvas.save();
295 canvas.translate((cx, cy));
296 canvas.scale((click_scale, click_scale));
297 canvas.translate((-cx, -cy));
298 }
299
300 let paint = paint_from_hex(&self.color);
301 let rect = skia_safe::Rect::from_xywh(0.0, 0.0, self.width, self.height);
302 let rrect = skia_safe::RRect::new_rect_xy(rect, self.radius, self.radius);
303 canvas.draw_rrect(rrect, &paint);
304
305 if (click_scale - 1.0).abs() > 0.001 {
306 canvas.restore();
307 }
308
309 if path_dx.abs() > 0.001 || path_dy.abs() > 0.001 {
310 canvas.restore();
311 }
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 fn caret(easing: CursorPathEasing) -> Cursor {
320 serde_json::from_value(serde_json::json!({
321 "path_easing": easing,
322 "click_duration": 0.0,
323 "auto_path": [
324 {"time": 1.0, "x": 100.0, "y": 0.0},
325 {"time": 2.0, "x": 500.0, "y": 0.0},
326 ],
327 }))
328 .expect("cursor fixture")
329 }
330
331 #[test]
335 fn step_easing_holds_the_departure_point_until_the_next_waypoint() {
336 let c = caret(CursorPathEasing::Step);
337 for t in [1.0, 1.25, 1.5, 1.75, 1.99] {
338 assert_eq!(
339 c.auto_path_offset(t),
340 (100.0, 0.0),
341 "step must hold the first waypoint at t={t}"
342 );
343 }
344 assert_eq!(c.auto_path_offset(2.0), (500.0, 0.0));
345 assert_eq!(c.auto_path_offset(9.0), (500.0, 0.0));
346 }
347
348 #[test]
351 fn linear_easing_still_interpolates() {
352 let (x, _) = caret(CursorPathEasing::Linear).auto_path_offset(1.5);
353 assert!(
354 (x - 300.0).abs() < 0.5,
355 "linear should be halfway at t=1.5, got {x}"
356 );
357 }
358
359 #[test]
360 fn step_is_spelled_snake_case_in_json() {
361 let c: Cursor = serde_json::from_value(serde_json::json!({"path_easing": "step"}))
362 .expect("`step` must parse");
363 assert_eq!(c.path_easing, CursorPathEasing::Step);
364 }
365}