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