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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum PointerTone {
19 #[default]
21 Light,
22 Dark,
24 Outline,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "snake_case")]
33pub enum PointerGlyph {
34 #[default]
36 Arrow,
37 Hand,
39 Grab,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "snake_case")]
46pub enum ClickRing {
47 Subtle,
49 #[default]
50 Standard,
51 Bold,
53 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
78pub struct Pointer {
79 #[serde(default = "default_pointer_size")]
81 pub size: f32,
82 #[serde(default)]
84 pub glyph: PointerGlyph,
85 #[serde(default)]
88 pub click_glyph: Option<PointerGlyph>,
89 #[serde(default)]
91 pub tone: PointerTone,
92 #[serde(default)]
94 pub color: Option<String>,
95 #[serde(default)]
97 pub outline_color: Option<String>,
98 #[serde(default)]
100 pub click_ring: ClickRing,
101 #[serde(default)]
103 pub ring_color: Option<String>,
104 #[serde(default)]
109 pub path: Vec<CursorWaypoint>,
110 #[serde(default)]
114 pub click_at: Vec<f64>,
115 #[serde(default = "default_pointer_click_duration")]
118 pub click_duration: f32,
119 #[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}