use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, Paint, PaintStyle, Path, PathBuilder};
use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color};
use rustmotion_core::schema::TimelineStep;
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
use crate::cursor::{waypoint_offset, CursorPathEasing, CursorWaypoint};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PointerTone {
#[default]
Light,
Dark,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ClickRing {
Subtle,
#[default]
Standard,
Bold,
None,
}
impl ClickRing {
fn metrics(self) -> Option<(f32, f32)> {
match self {
Self::Subtle => Some((0.05, 0.55)),
Self::Standard => Some((0.09, 0.85)),
Self::Bold => Some((0.16, 1.25)),
Self::None => None,
}
}
}
fn default_pointer_size() -> f32 {
44.0
}
fn default_pointer_click_duration() -> f32 {
0.45
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct Pointer {
#[serde(default = "default_pointer_size")]
pub size: f32,
#[serde(default)]
pub tone: PointerTone,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub outline_color: Option<String>,
#[serde(default)]
pub click_ring: ClickRing,
#[serde(default)]
pub ring_color: Option<String>,
#[serde(default)]
pub path: Vec<CursorWaypoint>,
#[serde(default)]
pub click_at: Vec<f64>,
#[serde(default = "default_pointer_click_duration")]
pub click_duration: f32,
#[serde(default)]
pub path_easing: CursorPathEasing,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
pub style: CssStyle,
#[serde(default)]
pub timeline: Vec<TimelineStep>,
#[serde(default)]
pub stagger: Option<f32>,
}
rustmotion_core::impl_traits!(Pointer {
Animatable => animation,
Timed => timing,
Styled => style,
});
impl Pointer {
fn click_times(&self) -> Vec<f64> {
if self.path.is_empty() {
self.click_at.clone()
} else {
self.path.iter().map(|w| w.time).collect()
}
}
fn click_progress(&self, time: f64) -> Option<f32> {
if self.click_duration <= 0.0 {
return None;
}
self.click_times()
.into_iter()
.rfind(|&t| time >= t && time < t + self.click_duration as f64)
.map(|t| ((time - t) / self.click_duration as f64) as f32)
}
fn colors(&self) -> (String, String) {
let (fill, outline) = match self.tone {
PointerTone::Light => ("#FFFFFF", "#111827"),
PointerTone::Dark => ("#111827", "#FFFFFF"),
};
(
self.color.clone().unwrap_or_else(|| fill.to_string()),
self.outline_color
.clone()
.unwrap_or_else(|| outline.to_string()),
)
}
fn arrow_path(size: f32) -> Path {
const OUTLINE: [(f32, f32); 7] = [
(0.0, 0.0),
(0.0, 0.72),
(0.19, 0.56),
(0.30, 0.84),
(0.43, 0.78),
(0.32, 0.51),
(0.54, 0.51),
];
let mut path = PathBuilder::new();
for (i, (x, y)) in OUTLINE.iter().enumerate() {
let p = (x * size, y * size);
if i == 0 {
path.move_to(p);
} else {
path.line_to(p);
}
}
path.close();
path.detach()
}
}
impl Painter for Pointer {
fn paint_content(
&self,
canvas: &Canvas,
_layout: &BoxLayout,
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
let (dx, dy) = if self.path.is_empty() {
(0.0, 0.0)
} else {
waypoint_offset(&self.path, ctx.time, self.click_duration, self.path_easing)
};
let click = self.click_progress(ctx.time);
let (fill, outline) = self.colors();
canvas.save();
canvas.translate((dx, dy));
if let (Some(p), Some((stroke_f, travel_f))) = (click, self.click_ring.metrics()) {
let (r, g, b, _) = parse_hex_color(self.ring_color.as_deref().unwrap_or(&fill));
let alpha = ((1.0 - p) * 200.0) as u8;
if alpha > 0 {
let mut ring = Paint::default();
ring.set_style(PaintStyle::Stroke);
ring.set_anti_alias(true);
ring.set_stroke_width(stroke_f * self.size);
ring.set_color(skia_safe::Color::from_argb(alpha, r, g, b));
canvas.draw_circle((0.0, 0.0), p * travel_f * self.size, &ring);
}
}
if let Some(p) = click {
let scale = if p < 0.35 {
1.0 - 0.12 * (p / 0.35)
} else {
0.88 + 0.12 * ((p - 0.35) / 0.65)
};
canvas.scale((scale, scale));
}
let path = Self::arrow_path(self.size);
let mut outline_paint = paint_from_hex(&outline);
outline_paint.set_style(PaintStyle::Stroke);
outline_paint.set_stroke_width((self.size * 0.07).max(1.0));
outline_paint.set_stroke_join(skia_safe::PaintJoin::Round);
outline_paint.set_anti_alias(true);
let mut fill_paint = paint_from_hex(&fill);
fill_paint.set_style(PaintStyle::Fill);
fill_paint.set_anti_alias(true);
canvas.draw_path(&path, &fill_paint);
canvas.draw_path(&path, &outline_paint);
canvas.restore();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pointer(json: serde_json::Value) -> Pointer {
serde_json::from_value(json).expect("pointer fixture")
}
#[test]
fn a_pointer_with_no_path_sits_at_its_own_origin() {
let p = pointer(serde_json::json!({}));
assert!(p.path.is_empty());
assert_eq!(p.click_progress(0.0), None, "no clicks were asked for");
}
#[test]
fn clicks_come_from_the_waypoints_when_a_path_is_given() {
let p = pointer(serde_json::json!({
"click_at": [9.0],
"path": [
{ "time": 0.0, "x": 0.0, "y": 0.0 },
{ "time": 1.0, "x": 200.0, "y": 100.0 }
]
}));
assert_eq!(p.click_times(), vec![0.0, 1.0]);
assert!(
p.click_progress(9.1).is_none(),
"a `click_at` entry must be ignored once the pointer has a path"
);
}
#[test]
fn a_click_runs_for_exactly_its_duration() {
let p = pointer(serde_json::json!({
"click_at": [1.0],
"click_duration": 0.5
}));
assert_eq!(p.click_progress(0.9), None, "before the click");
assert_eq!(p.click_progress(1.0), Some(0.0), "at the click");
assert!(
matches!(p.click_progress(1.25), Some(t) if (t - 0.5).abs() < 1e-5),
"halfway through"
);
assert_eq!(p.click_progress(1.5), None, "the instant it ends");
}
#[test]
fn overlapping_clicks_resolve_to_the_most_recent() {
let p = pointer(serde_json::json!({
"click_at": [1.0, 1.2],
"click_duration": 0.5
}));
let at = p.click_progress(1.3).expect("a click is running at 1.3");
assert!(
(at - 0.2).abs() < 1e-5,
"expected 0.1s into the second click (0.2 of its duration), got {at}"
);
}
#[test]
fn the_pointer_holds_its_first_waypoint_before_the_path_starts() {
let p = pointer(serde_json::json!({
"path": [
{ "time": 1.0, "x": 100.0, "y": 50.0 },
{ "time": 2.0, "x": 400.0, "y": 50.0 }
]
}));
assert_eq!(
waypoint_offset(&p.path, 0.0, p.click_duration, p.path_easing),
(100.0, 50.0),
"before the first waypoint's time the pointer waits there, it does not fly in"
);
}
#[test]
fn none_removes_the_ring_without_removing_the_click() {
let p = pointer(serde_json::json!({
"click_at": [1.0],
"click_ring": "none"
}));
assert!(p.click_ring.metrics().is_none(), "no ring to draw");
assert!(
p.click_progress(1.1).is_some(),
"the click itself still runs — the arrow still dips"
);
}
}