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 (and clicks).
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}
22
23/// A blinking cursor component (vertical bar) with optional motion path and click events.
24#[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    /// Blink interval in seconds (0 = no blink, always visible).
33    #[serde(default = "default_blink_interval")]
34    pub blink: f32,
35    #[serde(default = "default_cursor_radius")]
36    pub radius: f32,
37    /// Timestamps (seconds) at which the cursor "clicks" (scale bounce effect).
38    /// Used when no auto_path waypoints are provided.
39    #[serde(default)]
40    pub click_at: Vec<f64>,
41    /// Auto-path waypoints: cursor moves between these positions with smooth curves.
42    /// Each waypoint has a time and position. The cursor clicks at each waypoint.
43    #[serde(default)]
44    pub auto_path: Vec<CursorWaypoint>,
45    /// Click animation duration in seconds.
46    #[serde(default = "default_click_duration")]
47    pub click_duration: f32,
48    /// Visual cursor style: "default" (arrow) or "pointer" (hand).
49    /// Currently both render as a bar; this is metadata for future SVG cursors.
50    #[serde(default)]
51    pub cursor_style: CursorStyle,
52    /// Easing for movement between waypoints: "ease_in_out" (default), "linear", "ease_out".
53    #[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/// Visual cursor style. Closed documented set; JSON values unchanged.
90#[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/// Easing of the cursor's waypoint path. Closed set, now matched
99/// exhaustively; previously-silent unknown values fail the typed parse.
100#[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    /// No interpolation: hold each waypoint until the next one's time, then
108    /// jump. What a text caret does — it never slides between two positions —
109    /// and the only way to express that, since every other easing glides.
110    Step,
111}
112
113rustmotion_core::impl_traits!(Cursor {
114    Animatable => animation,
115    Timed => timing,
116    Styled => style,
117});
118
119impl Cursor {
120    /// Get all click times (from click_at or auto_path waypoints).
121    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    /// Compute cursor position offset from auto_path at the given time.
130    /// Returns (dx, dy) translation to apply.
131    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
136/// Where a waypoint path sits at `time`: `(dx, dy)` from the component's own
137/// origin.
138///
139/// Shared by `cursor` (a text caret) and `pointer` (a mouse pointer). They
140/// draw entirely different glyphs, but "hold at the first waypoint, glide to
141/// each next one on a Catmull-Rom curve, pause for the click before moving
142/// on" is the same walkthrough choreography in both, and it is worth having
143/// exactly one implementation of it.
144pub(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        // Before first waypoint: stay at first position
159        if time <= waypoints[0].time {
160            return (waypoints[0].x, waypoints[0].y);
161        }
162
163        // After last waypoint: stay at last position
164        if time >= waypoints[waypoints.len() - 1].time {
165            let last = &waypoints[waypoints.len() - 1];
166            return (last.x, last.y);
167        }
168
169        // Find which segment we're in
170        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        // Account for click pause: don't start moving until click animation finishes
186        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        // Apply easing
197        let t = match path_easing {
198            // Hold the departure point for the whole segment; the jump happens
199            // when `time` reaches the next waypoint and the segment changes.
200            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        // Catmull-Rom interpolation for smooth curves
213        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
231/// Catmull-Rom spline interpolation
232fn 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    /// A caret jumps between positions; it must never be caught between two
332    /// fields. Every other easing interpolates, so `step` is the only way to
333    /// say that.
334    #[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    /// The other easings keep gliding — `step` is additive, not a change of
349    /// default behaviour.
350    #[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}