Skip to main content

rustmotion_components/
pointer.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, Paint, PaintStyle, Path, PathBuilder};
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, parse_hex_color};
9use rustmotion_core::schema::TimelineStep;
10use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
11
12use crate::cursor::{CursorPathEasing, CursorWaypoint};
13
14/// Colour scheme of the pointer, so a scene picks one word instead of two
15/// hex values that have to stay in contrast with each other.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum PointerTone {
19    /// White arrow, dark outline — for dark frames.
20    #[default]
21    Light,
22    /// Dark arrow, light outline — for light frames.
23    Dark,
24    /// Transparent fill, white outline — reads on top of any background,
25    /// dark or light, without a filled shape competing with what it points at.
26    Outline,
27}
28
29/// Which glyph the pointer draws. `click_glyph` can swap to a different one
30/// only for the duration of a click, then it reverts to this one.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "snake_case")]
33pub enum PointerGlyph {
34    /// The classic pointer arrow, tip at the hotspot.
35    #[default]
36    Arrow,
37    /// An open hand pointing with its index finger, fingertip at the hotspot.
38    Hand,
39    /// A closed fist, as if grabbing the point under the hotspot.
40    Grab,
41}
42
43/// How loud the click ring is.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "snake_case")]
46pub enum ClickRing {
47    /// A thin ring that stays close to the tip.
48    Subtle,
49    #[default]
50    Standard,
51    /// A thick ring travelling well past the tip.
52    Bold,
53    /// No ring — the arrow still nudges, nothing expands.
54    None,
55}
56
57impl ClickRing {
58    fn metrics(self) -> Option<(f32, f32)> {
59        match self {
60            Self::Subtle => Some((0.05, 0.55)),
61            Self::Standard => Some((0.09, 0.85)),
62            Self::Bold => Some((0.16, 1.25)),
63            Self::None => None,
64        }
65    }
66}
67
68fn default_pointer_size() -> f32 {
69    44.0
70}
71
72fn default_pointer_click_duration() -> f32 {
73    0.45
74}
75
76/// A mouse pointer that travels a waypoint path and clicks along the way.
77#[derive(Debug, Serialize, Deserialize, JsonSchema)]
78pub struct Pointer {
79    /// Height of the arrow in px. The click ring scales with it.
80    #[serde(default = "default_pointer_size")]
81    pub size: f32,
82    /// Which glyph is drawn. Defaults to the classic arrow.
83    #[serde(default)]
84    pub glyph: PointerGlyph,
85    /// Glyph shown for the duration of a click, then back to `glyph`. Absent
86    /// keeps `glyph` unchanged through the click — only the scale dip shows.
87    #[serde(default)]
88    pub click_glyph: Option<PointerGlyph>,
89    /// Colour scheme. Overridden by `color` / `outline_color` when set.
90    #[serde(default)]
91    pub tone: PointerTone,
92    /// Arrow fill (hex), overriding `tone`.
93    #[serde(default)]
94    pub color: Option<String>,
95    /// Arrow outline (hex), overriding `tone`.
96    #[serde(default)]
97    pub outline_color: Option<String>,
98    /// Click ring size. `none` removes it.
99    #[serde(default)]
100    pub click_ring: ClickRing,
101    /// Ring colour (hex). Defaults to the arrow's fill.
102    #[serde(default)]
103    pub ring_color: Option<String>,
104    /// Waypoints the pointer travels between, in scene-local seconds. Each
105    /// `x`/`y` is relative to the component's own origin — place the
106    /// component with `position: absolute` and read the waypoints as scene
107    /// coordinates. The pointer clicks on arrival at each one.
108    #[serde(default)]
109    pub path: Vec<CursorWaypoint>,
110    /// Extra click times (seconds), for a pointer that clicks without
111    /// travelling. Ignored when `path` is set — the waypoints carry their
112    /// own clicks.
113    #[serde(default)]
114    pub click_at: Vec<f64>,
115    /// How long one click animation runs (seconds). Also how long the
116    /// pointer pauses on a waypoint before setting off for the next.
117    #[serde(default = "default_pointer_click_duration")]
118    pub click_duration: f32,
119    /// Easing between waypoints.
120    #[serde(default)]
121    pub path_easing: CursorPathEasing,
122    #[serde(flatten)]
123    pub timing: TimingConfig,
124    #[serde(default)]
125    pub style: CssStyle,
126    #[serde(default)]
127    pub timeline: Vec<TimelineStep>,
128    #[serde(default)]
129    pub stagger: Option<f32>,
130}
131
132rustmotion_core::impl_traits!(Pointer {
133    Animatable => animation,
134    Timed => timing,
135    Styled => style,
136});
137
138impl Pointer {
139    fn click_times(&self) -> Vec<f64> {
140        if self.path.is_empty() {
141            self.click_at.clone()
142        } else {
143            self.path
144                .iter()
145                .filter(|w| w.click)
146                .map(|w| w.time)
147                .collect()
148        }
149    }
150
151    fn click_progress(&self, time: f64) -> Option<f32> {
152        if self.click_duration <= 0.0 {
153            return None;
154        }
155        self.click_times()
156            .into_iter()
157            .rfind(|&t| time >= t && time < t + self.click_duration as f64)
158            .map(|t| ((time - t) / self.click_duration as f64) as f32)
159    }
160
161    fn colors(&self) -> (String, String) {
162        let (fill, outline) = match self.tone {
163            PointerTone::Light => ("#FFFFFF", "#111827"),
164            PointerTone::Dark => ("#111827", "#FFFFFF"),
165            PointerTone::Outline => ("transparent", "#FFFFFF"),
166        };
167        (
168            self.color.clone().unwrap_or_else(|| fill.to_string()),
169            self.outline_color
170                .clone()
171                .unwrap_or_else(|| outline.to_string()),
172        )
173    }
174
175    fn ring_fallback_color<'a>(&self, fill: &'a str, outline: &'a str) -> &'a str {
176        match self.tone {
177            PointerTone::Outline => outline,
178            PointerTone::Light | PointerTone::Dark => fill,
179        }
180    }
181
182    fn add_contour(path: &mut PathBuilder, points: &[(f32, f32)], size: f32) {
183        for (i, (x, y)) in points.iter().enumerate() {
184            let p = (x * size, y * size);
185            if i == 0 {
186                path.move_to(p);
187            } else {
188                path.line_to(p);
189            }
190        }
191        path.close();
192    }
193
194    fn add_smooth_contour(path: &mut PathBuilder, points: &[(f32, f32)], size: f32) {
195        let n = points.len();
196        if n < 3 {
197            Self::add_contour(path, points, size);
198            return;
199        }
200        let at = |i: usize| (points[i % n].0 * size, points[i % n].1 * size);
201        let midpoint = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5);
202
203        path.move_to(midpoint(at(0), at(1)));
204        for i in 1..=n {
205            let control = at(i);
206            let end = midpoint(at(i), at(i + 1));
207            path.quad_to(control, end);
208        }
209        path.close();
210    }
211
212    fn arrow_path(size: f32) -> Path {
213        const OUTLINE: [(f32, f32); 7] = [
214            (0.0, 0.0),
215            (0.0, 0.72),
216            (0.19, 0.56),
217            (0.30, 0.84),
218            (0.43, 0.78),
219            (0.32, 0.51),
220            (0.54, 0.51),
221        ];
222        let mut path = PathBuilder::new();
223        Self::add_contour(&mut path, &OUTLINE, size);
224        path.detach()
225    }
226
227    const HAND_OUTLINE: [(f32, f32); 16] = [
228        (0.00, 0.00),
229        (0.22, 0.00),
230        (0.26, 0.34),
231        (0.38, 0.30),
232        (0.48, 0.36),
233        (0.58, 0.34),
234        (0.66, 0.46),
235        (0.68, 0.64),
236        (0.58, 0.86),
237        (0.36, 0.96),
238        (0.16, 0.90),
239        (0.06, 0.72),
240        (0.00, 0.58),
241        (0.03, 0.46),
242        (0.12, 0.42),
243        (0.04, 0.34),
244    ];
245
246    const FIST_OUTLINE: [(f32, f32); 15] = [
247        (0.18, 0.22),
248        (0.30, 0.12),
249        (0.40, 0.20),
250        (0.50, 0.14),
251        (0.60, 0.24),
252        (0.66, 0.38),
253        (0.66, 0.60),
254        (0.56, 0.80),
255        (0.38, 0.90),
256        (0.20, 0.84),
257        (0.10, 0.68),
258        (0.02, 0.58),
259        (0.04, 0.44),
260        (0.14, 0.40),
261        (0.12, 0.30),
262    ];
263
264    fn hand_path(size: f32) -> Path {
265        let mut path = PathBuilder::new();
266        Self::add_smooth_contour(&mut path, &Self::HAND_OUTLINE, size);
267        path.detach()
268    }
269
270    fn grab_path(size: f32) -> Path {
271        let mut path = PathBuilder::new();
272        Self::add_smooth_contour(&mut path, &Self::FIST_OUTLINE, size);
273        path.detach()
274    }
275
276    fn glyph_path(glyph: PointerGlyph, size: f32) -> Path {
277        match glyph {
278            PointerGlyph::Arrow => Self::arrow_path(size),
279            PointerGlyph::Hand => Self::hand_path(size),
280            PointerGlyph::Grab => Self::grab_path(size),
281        }
282    }
283
284    fn unfilled_contrast_color(&self, outline: &str) -> Option<&'static str> {
285        if self.tone != PointerTone::Outline || self.outline_color.is_some() {
286            return None;
287        }
288        let (_, _, _, alpha) = parse_hex_color(outline);
289        if alpha == 0 {
290            return None;
291        }
292        Some("#111827")
293    }
294
295    fn active_glyph(&self, click: Option<f32>) -> PointerGlyph {
296        match click {
297            Some(_) => self.click_glyph.unwrap_or(self.glyph),
298            None => self.glyph,
299        }
300    }
301}
302
303impl Painter for Pointer {
304    fn paint_content(
305        &self,
306        canvas: &Canvas,
307        _layout: &BoxLayout,
308        _props: &AnimatedProperties,
309        ctx: &PaintCtx,
310    ) {
311        let click = self.click_progress(ctx.time);
312        let (fill, outline) = self.colors();
313
314        canvas.save();
315
316        if let (Some(p), Some((stroke_f, travel_f))) = (click, self.click_ring.metrics()) {
317            let ring_fallback = self.ring_fallback_color(&fill, &outline);
318            let (r, g, b, _) = parse_hex_color(self.ring_color.as_deref().unwrap_or(ring_fallback));
319            let alpha = ((1.0 - p) * 200.0) as u8;
320            if alpha > 0 {
321                let mut ring = Paint::default();
322                ring.set_style(PaintStyle::Stroke);
323                ring.set_anti_alias(true);
324                ring.set_stroke_width(stroke_f * self.size);
325                ring.set_color(skia_safe::Color::from_argb(alpha, r, g, b));
326                canvas.draw_circle((0.0, 0.0), p * travel_f * self.size, &ring);
327            }
328        }
329
330        if let Some(p) = click {
331            let scale = if p < 0.35 {
332                1.0 - 0.12 * (p / 0.35)
333            } else {
334                0.88 + 0.12 * ((p - 0.35) / 0.65)
335            };
336            canvas.scale((scale, scale));
337        }
338
339        let path = Self::glyph_path(self.active_glyph(click), self.size);
340        let outline_width = (self.size * 0.07).max(1.0);
341        let mut outline_paint = paint_from_hex(&outline);
342        outline_paint.set_style(PaintStyle::Stroke);
343        outline_paint.set_stroke_width(outline_width);
344        outline_paint.set_stroke_join(skia_safe::PaintJoin::Round);
345        outline_paint.set_anti_alias(true);
346
347        let mut fill_paint = paint_from_hex(&fill);
348        fill_paint.set_style(PaintStyle::Fill);
349        fill_paint.set_anti_alias(true);
350
351        if let Some(contrast) = self.unfilled_contrast_color(&outline) {
352            let mut contrast_paint = paint_from_hex(contrast);
353            contrast_paint.set_style(PaintStyle::Stroke);
354            contrast_paint.set_stroke_width(outline_width * 1.5);
355            contrast_paint.set_stroke_join(skia_safe::PaintJoin::Round);
356            contrast_paint.set_anti_alias(true);
357            canvas.draw_path(&path, &contrast_paint);
358        }
359
360        canvas.draw_path(&path, &fill_paint);
361        canvas.draw_path(&path, &outline_paint);
362
363        canvas.restore();
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::cursor::waypoint_offset;
371
372    fn pointer(json: serde_json::Value) -> Pointer {
373        serde_json::from_value(json).expect("pointer fixture")
374    }
375
376    #[test]
377    fn a_pointer_with_no_path_sits_at_its_own_origin() {
378        let p = pointer(serde_json::json!({}));
379        assert!(p.path.is_empty());
380        assert_eq!(p.click_progress(0.0), None, "no clicks were asked for");
381    }
382
383    #[test]
384    fn clicks_come_from_the_waypoints_when_a_path_is_given() {
385        let p = pointer(serde_json::json!({
386            "click_at": [9.0],
387            "path": [
388                { "time": 0.0, "x": 0.0, "y": 0.0 },
389                { "time": 1.0, "x": 200.0, "y": 100.0 }
390            ]
391        }));
392        assert_eq!(p.click_times(), vec![0.0, 1.0]);
393        assert!(
394            p.click_progress(9.1).is_none(),
395            "a `click_at` entry must be ignored once the pointer has a path"
396        );
397    }
398
399    #[test]
400    fn a_click_runs_for_exactly_its_duration() {
401        let p = pointer(serde_json::json!({
402            "click_at": [1.0],
403            "click_duration": 0.5
404        }));
405        assert_eq!(p.click_progress(0.9), None, "before the click");
406        assert_eq!(p.click_progress(1.0), Some(0.0), "at the click");
407        assert!(
408            matches!(p.click_progress(1.25), Some(t) if (t - 0.5).abs() < 1e-5),
409            "halfway through"
410        );
411        assert_eq!(p.click_progress(1.5), None, "the instant it ends");
412    }
413
414    #[test]
415    fn overlapping_clicks_resolve_to_the_most_recent() {
416        let p = pointer(serde_json::json!({
417            "click_at": [1.0, 1.2],
418            "click_duration": 0.5
419        }));
420        let at = p.click_progress(1.3).expect("a click is running at 1.3");
421        assert!(
422            (at - 0.2).abs() < 1e-5,
423            "expected 0.1s into the second click (0.2 of its duration), got {at}"
424        );
425    }
426
427    #[test]
428    fn the_pointer_holds_its_first_waypoint_before_the_path_starts() {
429        let p = pointer(serde_json::json!({
430            "path": [
431                { "time": 1.0, "x": 100.0, "y": 50.0 },
432                { "time": 2.0, "x": 400.0, "y": 50.0 }
433            ]
434        }));
435        assert_eq!(
436            waypoint_offset(&p.path, 0.0, p.click_duration, p.path_easing),
437            (100.0, 50.0),
438            "before the first waypoint's time the pointer waits there, it does not fly in"
439        );
440    }
441
442    #[test]
443    fn none_removes_the_ring_without_removing_the_click() {
444        let p = pointer(serde_json::json!({
445            "click_at": [1.0],
446            "click_ring": "none"
447        }));
448        assert!(p.click_ring.metrics().is_none(), "no ring to draw");
449        assert!(
450            p.click_progress(1.1).is_some(),
451            "the click itself still runs — the arrow still dips"
452        );
453    }
454
455    fn paint_ctx(time: f64, video_width: u32, video_height: u32) -> PaintCtx {
456        PaintCtx {
457            time,
458            scenario_time: time,
459            scene_duration: 1.0,
460            frame_index: 0,
461            fps: 30,
462            video_width,
463            video_height,
464            stagger_offset: 0.0,
465        }
466    }
467
468    fn render(
469        p: &Pointer,
470        w: i32,
471        h: i32,
472        time: f64,
473        background: skia_safe::Color,
474    ) -> skia_safe::Surface {
475        let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface");
476        {
477            let canvas = surface.canvas();
478            canvas.clear(background);
479            p.paint_content(
480                canvas,
481                &BoxLayout::default(),
482                &AnimatedProperties::default(),
483                &paint_ctx(time, w as u32, h as u32),
484            );
485        }
486        surface
487    }
488
489    fn pixel(surface: &mut skia_safe::Surface, w: i32, h: i32, x: f32, y: f32) -> (u8, u8, u8, u8) {
490        let snapshot = surface.image_snapshot();
491        let info = skia_safe::ImageInfo::new(
492            (w, h),
493            skia_safe::ColorType::RGBA8888,
494            skia_safe::AlphaType::Unpremul,
495            None,
496        );
497        let mut buf = vec![0u8; (w * h * 4) as usize];
498        let ok = snapshot.read_pixels(
499            &info,
500            &mut buf,
501            (w * 4) as usize,
502            skia_safe::IPoint::new(0, 0),
503            skia_safe::image::CachingHint::Disallow,
504        );
505        assert!(ok, "pixel read should succeed");
506        let ix = x.round() as i32;
507        let iy = y.round() as i32;
508        let idx = ((iy * w + ix) * 4) as usize;
509        (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3])
510    }
511
512    const PROBE_SIZE: f32 = 200.0;
513
514    fn deep_interior_point() -> (f32, f32) {
515        (PROBE_SIZE * 0.1946, PROBE_SIZE * 0.4390)
516    }
517
518    fn left_edge_point() -> (f32, f32) {
519        let stroke_width = (PROBE_SIZE * 0.07f32).max(1.0);
520        (stroke_width * 0.3, PROBE_SIZE * 0.36)
521    }
522
523    #[test]
524    fn deep_interior_point_is_well_clear_of_the_outline_stroke() {
525        let path = Pointer::arrow_path(PROBE_SIZE);
526        let margin = (PROBE_SIZE * 0.07).max(1.0) + 3.0;
527        let (cx, cy) = deep_interior_point();
528        assert!(
529            path.contains((cx, cy)),
530            "probe point must be inside the arrow"
531        );
532        for (dx, dy) in [(-margin, 0.0), (margin, 0.0), (0.0, -margin), (0.0, margin)] {
533            assert!(
534                path.contains((cx + dx, cy + dy)),
535                "probe point at ({cx}, {cy}) is too close to an edge in direction ({dx}, {dy})"
536            );
537        }
538    }
539
540    #[test]
541    fn outline_tone_leaves_the_interior_transparent_and_paints_a_pointer_coloured_edge() {
542        let p = pointer(serde_json::json!({ "tone": "outline", "size": PROBE_SIZE }));
543        let background = skia_safe::Color::from_argb(255, 0, 128, 0);
544        const W: i32 = 300;
545        const H: i32 = 300;
546        let mut surface = render(&p, W, H, 0.0, background);
547
548        let (ix, iy) = deep_interior_point();
549        let interior = pixel(&mut surface, W, H, ix, iy);
550        assert_eq!(
551            interior,
552            (0, 128, 0, 255),
553            "an outline pointer must let the background show through its interior, got {interior:?}"
554        );
555
556        let (ex, ey) = left_edge_point();
557        let edge = pixel(&mut surface, W, H, ex, ey);
558        assert!(
559            edge.0 > 200 && edge.1 > 200 && edge.2 > 200 && edge.3 == 255,
560            "the outline itself must still paint a solid, pointer-coloured edge, got {edge:?}"
561        );
562    }
563
564    #[test]
565    fn filled_tones_still_paint_pointer_colour_in_both_interior_and_edge() {
566        let background = skia_safe::Color::from_argb(255, 0, 128, 0);
567        const W: i32 = 300;
568        const H: i32 = 300;
569        let (ix, iy) = deep_interior_point();
570        let (ex, ey) = left_edge_point();
571
572        let light = pointer(serde_json::json!({ "tone": "light", "size": PROBE_SIZE }));
573        let mut light_surface = render(&light, W, H, 0.0, background);
574        assert_eq!(
575            pixel(&mut light_surface, W, H, ix, iy),
576            (255, 255, 255, 255),
577            "light tone interior must stay pinned to its white fill"
578        );
579        assert_eq!(
580            pixel(&mut light_surface, W, H, ex, ey),
581            (0x11, 0x18, 0x27, 255),
582            "light tone edge must stay pinned to its dark outline"
583        );
584
585        let dark = pointer(serde_json::json!({ "tone": "dark", "size": PROBE_SIZE }));
586        let mut dark_surface = render(&dark, W, H, 0.0, background);
587        assert_eq!(
588            pixel(&mut dark_surface, W, H, ix, iy),
589            (0x11, 0x18, 0x27, 255),
590            "dark tone interior must stay pinned to its dark fill"
591        );
592        assert_eq!(
593            pixel(&mut dark_surface, W, H, ex, ey),
594            (255, 255, 255, 255),
595            "dark tone edge must stay pinned to its white outline"
596        );
597    }
598
599    #[test]
600    fn outline_tone_keeps_the_click_ring_visible_and_matched_to_the_white_outline() {
601        let p = pointer(serde_json::json!({
602            "tone": "outline",
603            "size": 120.0,
604            "click_ring": "bold",
605            "click_duration": 0.5,
606            "click_at": [0.0]
607        }));
608        const W: i32 = 300;
609        const H: i32 = 300;
610        let background = skia_safe::Color::from_argb(255, 20, 20, 20);
611        let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
612        {
613            let canvas = surface.canvas();
614            canvas.clear(background);
615            canvas.translate((150.0, 150.0));
616            p.paint_content(
617                canvas,
618                &BoxLayout::default(),
619                &AnimatedProperties::default(),
620                &paint_ctx(0.15, W as u32, H as u32),
621            );
622        }
623
624        let radius = 0.3 * 1.25 * 120.0;
625        let offset = radius * std::f32::consts::FRAC_1_SQRT_2;
626        let (rx, ry) = (150.0 - offset, 150.0 - offset);
627        let ring = pixel(&mut surface, W, H, rx, ry);
628        assert!(
629            ring.0 > 100 && ring.1 > 100 && ring.2 > 100,
630            "the click ring on an outline pointer must default to the outline's white, not a hard-coded or transparent-derived colour, got {ring:?}"
631        );
632    }
633
634    fn finger_probe(size: f32) -> (f32, f32) {
635        (0.08 * size, 0.05 * size)
636    }
637
638    #[test]
639    fn a_waypoint_can_be_passed_through_without_clicking() {
640        let p = pointer(serde_json::json!({
641            "size": 120,
642            "click_duration": 0.4,
643            "path": [
644                { "time": 0.0, "x": 0.0, "y": 0.0 },
645                { "time": 1.0, "x": 200.0, "y": 0.0, "click": false },
646                { "time": 2.0, "x": 200.0, "y": 200.0 }
647            ]
648        }));
649        assert_eq!(
650            p.click_times(),
651            vec![0.0, 2.0],
652            "a pointer must be able to travel through an intermediate point without clicking \
653             on arrival — it clicked at every waypoint, with no way to opt out"
654        );
655    }
656
657    #[test]
658    fn a_waypoint_clicks_by_default_so_an_existing_scenario_is_unchanged() {
659        let p = pointer(serde_json::json!({
660            "size": 120,
661            "path": [
662                { "time": 0.0, "x": 0.0, "y": 0.0 },
663                { "time": 1.0, "x": 200.0, "y": 0.0 }
664            ]
665        }));
666        assert_eq!(p.click_times(), vec![0.0, 1.0]);
667    }
668
669    #[test]
670    fn opting_every_waypoint_out_leaves_a_pointer_that_travels_and_never_clicks() {
671        let p = pointer(serde_json::json!({
672            "size": 120,
673            "click_at": [3.0],
674            "path": [
675                { "time": 0.0, "x": 0.0, "y": 0.0, "click": false },
676                { "time": 1.0, "x": 200.0, "y": 0.0, "click": false }
677            ]
678        }));
679        assert!(
680            p.click_times().is_empty(),
681            "click_at stays ignored while a path is present, which is what \
682             clicks_come_from_the_waypoints_when_a_path_is_given pins; a pointer that should \
683             click somewhere says so on the waypoint"
684        );
685    }
686
687    #[test]
688    fn an_outline_pointer_carries_a_dark_contour_so_it_reads_on_a_light_frame() {
689        let p = pointer(serde_json::json!({ "size": 200, "tone": "outline" }));
690        const W: i32 = 300;
691        const H: i32 = 300;
692        let mut frame = render(
693            &p,
694            W,
695            H,
696            0.0,
697            skia_safe::Color::from_argb(255, 255, 255, 255),
698        );
699
700        let mut darkest = 255u8;
701        for y in 0..H {
702            for x in 0..W {
703                let (r, g, b, a) = pixel(&mut frame, W, H, x as f32, y as f32);
704                if a > 200 {
705                    darkest = darkest.min(r.max(g).max(b));
706                }
707            }
708        }
709        assert!(
710            darkest < 100,
711            "tone: outline is a white stroke on a transparent fill, so on a white frame it was \
712             invisible while its own documentation promised it reads on any background; \
713             darkest painted channel was {darkest}"
714        );
715    }
716
717    #[test]
718    fn finger_probe_is_inside_the_open_hand_but_outside_the_closed_fist() {
719        const SIZE: f32 = 200.0;
720        let (fx, fy) = finger_probe(SIZE);
721        assert!(
722            Pointer::hand_path(SIZE).contains((fx, fy)),
723            "probe point must sit on the extended finger of the open hand"
724        );
725        assert!(
726            !Pointer::grab_path(SIZE).contains((fx, fy)),
727            "probe point must fall outside the fist alone, or it cannot prove the finger retracted"
728        );
729    }
730
731    #[test]
732    fn a_default_pointer_still_draws_the_classic_arrow() {
733        let p = pointer(serde_json::json!({}));
734        assert_eq!(
735            p.glyph,
736            PointerGlyph::Arrow,
737            "arrow stays the default glyph"
738        );
739        assert_eq!(
740            p.active_glyph(None),
741            PointerGlyph::Arrow,
742            "no glyph/click_glyph configured must resolve to the classic arrow"
743        );
744    }
745
746    #[test]
747    fn hand_glyph_closes_into_a_grab_during_a_click_and_reopens_after() {
748        let p = pointer(serde_json::json!({
749            "glyph": "hand",
750            "click_glyph": "grab",
751            "tone": "light",
752            "size": SIZE_FOR_HAND_TEST,
753            "click_at": [1.0],
754            "click_duration": 0.5
755        }));
756        const W: i32 = 300;
757        const H: i32 = 300;
758        let background = skia_safe::Color::from_argb(255, 0, 128, 0);
759        let (fx, fy) = finger_probe(SIZE_FOR_HAND_TEST);
760
761        let mut before_click = render(&p, W, H, 0.0, background);
762        let open = pixel(&mut before_click, W, H, fx, fy);
763        assert_eq!(
764            open,
765            (255, 255, 255, 255),
766            "before any click the open hand must paint its extended finger (white fill), got {open:?}"
767        );
768
769        let mut mid_click = render(&p, W, H, 1.0, background);
770        let closed = pixel(&mut mid_click, W, H, fx, fy);
771        assert_eq!(
772            closed,
773            (0, 128, 0, 255),
774            "at the moment of the click the finger must have retracted into the fist, \
775             leaving the background showing through at the same point, got {closed:?}"
776        );
777
778        let mut after_click = render(&p, W, H, 1.6, background);
779        let reopened = pixel(&mut after_click, W, H, fx, fy);
780        assert_eq!(
781            reopened, open,
782            "once the click finishes the hand must reopen to exactly its resting pose"
783        );
784    }
785
786    const SIZE_FOR_HAND_TEST: f32 = 200.0;
787
788    #[test]
789    fn the_open_hand_pose_is_stable_when_no_click_is_happening() {
790        let p = pointer(serde_json::json!({
791            "glyph": "hand",
792            "click_glyph": "grab",
793            "tone": "light",
794            "size": SIZE_FOR_HAND_TEST,
795            "click_at": [1.0],
796            "click_duration": 0.5
797        }));
798        const W: i32 = 300;
799        const H: i32 = 300;
800        let background = skia_safe::Color::from_argb(255, 0, 128, 0);
801        let (fx, fy) = finger_probe(SIZE_FOR_HAND_TEST);
802
803        let mut at_zero = render(&p, W, H, 0.0, background);
804        let mut long_before_the_click = render(&p, W, H, 0.4, background);
805        let mut long_after_the_click = render(&p, W, H, 3.0, background);
806
807        let a = pixel(&mut at_zero, W, H, fx, fy);
808        let b = pixel(&mut long_before_the_click, W, H, fx, fy);
809        let c = pixel(&mut long_after_the_click, W, H, fx, fy);
810        assert_eq!(
811            a, b,
812            "resting hand pose must not drift with time before a click"
813        );
814        assert_eq!(
815            a, c,
816            "resting hand pose must not drift with time after a click"
817        );
818    }
819}