Skip to main content

rustmotion_components/
pointer.rs

1//! A simulated mouse pointer — the arrow, plus the ring that pulses out of
2//! its tip when it clicks.
3//!
4//! Not to be confused with [`crate::cursor::Cursor`], which is a *text* caret
5//! (a blinking bar). Its `cursor_style: "pointer"` field has never drawn
6//! anything but that bar. Product walkthroughs and agent demos need the other
7//! thing: an arrow that travels to a control and visibly clicks it.
8//!
9//! Waypoint choreography — hold, glide, pause on the click — is shared with
10//! `cursor` via [`crate::cursor::waypoint_offset`], so the two stay in step.
11
12use 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/// Colour scheme of the pointer, so a scene picks one word instead of two
26/// hex values that have to stay in contrast with each other.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum PointerTone {
30    /// White arrow, dark outline — for dark frames.
31    #[default]
32    Light,
33    /// Dark arrow, light outline — for light frames.
34    Dark,
35}
36
37/// How loud the click ring is.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
39#[serde(rename_all = "snake_case")]
40pub enum ClickRing {
41    /// A thin ring that stays close to the tip.
42    Subtle,
43    #[default]
44    Standard,
45    /// A thick ring travelling well past the tip.
46    Bold,
47    /// No ring — the arrow still nudges, nothing expands.
48    None,
49}
50
51impl ClickRing {
52    /// `(stroke width, travel)` as multiples of the pointer's `size`.
53    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/// A mouse pointer that travels a waypoint path and clicks along the way.
72#[derive(Debug, Serialize, Deserialize, JsonSchema)]
73pub struct Pointer {
74    /// Height of the arrow in px. The click ring scales with it.
75    #[serde(default = "default_pointer_size")]
76    pub size: f32,
77    /// Colour scheme. Overridden by `color` / `outline_color` when set.
78    #[serde(default)]
79    pub tone: PointerTone,
80    /// Arrow fill (hex), overriding `tone`.
81    #[serde(default)]
82    pub color: Option<String>,
83    /// Arrow outline (hex), overriding `tone`.
84    #[serde(default)]
85    pub outline_color: Option<String>,
86    /// Click ring size. `none` removes it.
87    #[serde(default)]
88    pub click_ring: ClickRing,
89    /// Ring colour (hex). Defaults to the arrow's fill.
90    #[serde(default)]
91    pub ring_color: Option<String>,
92    /// Waypoints the pointer travels between, in scene-local seconds. Each
93    /// `x`/`y` is relative to the component's own origin — place the
94    /// component with `position: absolute` and read the waypoints as scene
95    /// coordinates. The pointer clicks on arrival at each one.
96    #[serde(default)]
97    pub path: Vec<CursorWaypoint>,
98    /// Extra click times (seconds), for a pointer that clicks without
99    /// travelling. Ignored when `path` is set — the waypoints carry their
100    /// own clicks.
101    #[serde(default)]
102    pub click_at: Vec<f64>,
103    /// How long one click animation runs (seconds). Also how long the
104    /// pointer pauses on a waypoint before setting off for the next.
105    #[serde(default = "default_pointer_click_duration")]
106    pub click_duration: f32,
107    /// Easing between waypoints.
108    #[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    /// The click running at `time`, as progress 0..1, if any.
136    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            // The *last* qualifying click, so overlapping clicks resolve to
143            // the most recent rather than to whichever happens to be first.
144            .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    /// The classic arrow — tip, left edge, tail notch, and back up the right
162    /// shoulder — drawn tip-first at the origin and scaled to `size`.
163    /// Coordinates are in units of the pointer's height, so the glyph keeps
164    /// its proportions at any size.
165    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        // The ring expands out of the tip and fades as it goes, so the eye
209        // reads the click as happening *at* the tip rather than around the
210        // whole pointer. Painted under the arrow so it never veils it.
211        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        // A small dip on the press, released as the click finishes — the
225        // arrow's own acknowledgement, independent of the ring (which
226        // `click_ring: "none"` can switch off).
227        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        // `click_at` is for a stationary pointer; a travelling one clicks on
272        // arrival, so listing both must not produce two overlapping sets.
273        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        // Two clicks closer together than `click_duration`: the second must
305        // restart the animation, not be swallowed by the first still running.
306        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}