Skip to main content

rustmotion_components/
cursor.rs

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/// A cursor waypoint with position and time.
13#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
14pub struct CursorWaypoint {
15    /// Time in seconds when the cursor reaches this point.
16    pub time: f64,
17    /// X position relative to the cursor's origin.
18    pub x: f32,
19    /// Y position relative to the cursor's origin.
20    pub y: f32,
21    /// Whether arriving here is a click. Defaults to `true`, which is what
22    /// every waypoint did before this field existed. Set it to `false` for a
23    /// point the pointer only travels through.
24    #[serde(default = "default_waypoint_click")]
25    pub click: bool,
26}
27
28fn default_waypoint_click() -> bool {
29    true
30}
31
32/// A blinking cursor component (vertical bar) with optional motion path and click events.
33#[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    /// Blink interval in seconds (0 = no blink, always visible).
42    #[serde(default = "default_blink_interval")]
43    pub blink: f32,
44    #[serde(default = "default_cursor_radius")]
45    pub radius: f32,
46    /// Timestamps (seconds) at which the cursor "clicks" (scale bounce effect).
47    /// Used when no auto_path waypoints are provided.
48    #[serde(default)]
49    pub click_at: Vec<f64>,
50    /// Auto-path waypoints: cursor moves between these positions with smooth curves.
51    /// Each waypoint has a time and position. The cursor clicks at each waypoint.
52    #[serde(default)]
53    pub auto_path: Vec<CursorWaypoint>,
54    /// Click animation duration in seconds.
55    #[serde(default = "default_click_duration")]
56    pub click_duration: f32,
57    /// Visual cursor style: "default" (arrow) or "pointer" (hand).
58    /// Currently both render as a bar; this is metadata for future SVG cursors.
59    #[serde(default)]
60    pub cursor_style: CursorStyle,
61    /// Easing for movement between waypoints: "ease_in_out" (default), "linear", "ease_out".
62    #[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/// Visual cursor style. Closed documented set; JSON values unchanged.
99#[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/// Easing of the cursor's waypoint path. Closed set, now matched
108/// exhaustively; previously-silent unknown values fail the typed parse.
109#[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    /// No interpolation: hold each waypoint until the next one's time, then
117    /// jump. What a text caret does — it never slides between two positions —
118    /// and the only way to express that, since every other easing glides.
119    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}