@@ -26,6 +26,20 @@ pub enum PointerTone {
2626 Outline ,
2727}
2828
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" ) ]
33+ pub 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+
2943/// How loud the click ring is.
3044#[ derive( Debug , Clone , Copy , PartialEq , Eq , Default , Serialize , Deserialize , JsonSchema ) ]
3145#[ serde( rename_all = "snake_case" ) ]
@@ -65,6 +79,13 @@ pub struct Pointer {
6579 /// Height of the arrow in px. The click ring scales with it.
6680 #[ serde( default = "default_pointer_size" ) ]
6781 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 > ,
6889 /// Colour scheme. Overridden by `color` / `outline_color` when set.
6990 #[ serde( default ) ]
7091 pub tone : PointerTone ,
@@ -154,6 +175,18 @@ impl Pointer {
154175 }
155176 }
156177
178+ fn add_contour ( path : & mut PathBuilder , points : & [ ( f32 , f32 ) ] , size : f32 ) {
179+ for ( i, ( x, y) ) in points. iter ( ) . enumerate ( ) {
180+ let p = ( x * size, y * size) ;
181+ if i == 0 {
182+ path. move_to ( p) ;
183+ } else {
184+ path. line_to ( p) ;
185+ }
186+ }
187+ path. close ( ) ;
188+ }
189+
157190 fn arrow_path ( size : f32 ) -> Path {
158191 const OUTLINE : [ ( f32 , f32 ) ; 7 ] = [
159192 ( 0.0 , 0.0 ) ,
@@ -165,17 +198,49 @@ impl Pointer {
165198 ( 0.54 , 0.51 ) ,
166199 ] ;
167200 let mut path = PathBuilder :: new ( ) ;
168- for ( i, ( x, y) ) in OUTLINE . iter ( ) . enumerate ( ) {
169- let p = ( x * size, y * size) ;
170- if i == 0 {
171- path. move_to ( p) ;
172- } else {
173- path. line_to ( p) ;
174- }
175- }
176- path. close ( ) ;
201+ Self :: add_contour ( & mut path, & OUTLINE , size) ;
202+ path. detach ( )
203+ }
204+
205+ const HAND_FIST : [ ( f32 , f32 ) ; 8 ] = [
206+ ( 0.08 , 0.38 ) ,
207+ ( 0.30 , 0.30 ) ,
208+ ( 0.54 , 0.36 ) ,
209+ ( 0.62 , 0.58 ) ,
210+ ( 0.54 , 0.82 ) ,
211+ ( 0.30 , 0.92 ) ,
212+ ( 0.12 , 0.82 ) ,
213+ ( 0.04 , 0.58 ) ,
214+ ] ;
215+
216+ fn hand_path ( size : f32 ) -> Path {
217+ const FINGER : [ ( f32 , f32 ) ; 3 ] = [ ( 0.00 , 0.00 ) , ( 0.40 , 0.06 ) , ( 0.10 , 0.30 ) ] ;
218+ let mut path = PathBuilder :: new ( ) ;
219+ Self :: add_contour ( & mut path, & FINGER , size) ;
220+ Self :: add_contour ( & mut path, & Self :: HAND_FIST , size) ;
221+ path. detach ( )
222+ }
223+
224+ fn grab_path ( size : f32 ) -> Path {
225+ let mut path = PathBuilder :: new ( ) ;
226+ Self :: add_contour ( & mut path, & Self :: HAND_FIST , size) ;
177227 path. detach ( )
178228 }
229+
230+ fn glyph_path ( glyph : PointerGlyph , size : f32 ) -> Path {
231+ match glyph {
232+ PointerGlyph :: Arrow => Self :: arrow_path ( size) ,
233+ PointerGlyph :: Hand => Self :: hand_path ( size) ,
234+ PointerGlyph :: Grab => Self :: grab_path ( size) ,
235+ }
236+ }
237+
238+ fn active_glyph ( & self , click : Option < f32 > ) -> PointerGlyph {
239+ match click {
240+ Some ( _) => self . click_glyph . unwrap_or ( self . glyph ) ,
241+ None => self . glyph ,
242+ }
243+ }
179244}
180245
181246impl Painter for Pointer {
@@ -220,7 +285,7 @@ impl Painter for Pointer {
220285 canvas. scale ( ( scale, scale) ) ;
221286 }
222287
223- let path = Self :: arrow_path ( self . size ) ;
288+ let path = Self :: glyph_path ( self . active_glyph ( click ) , self . size ) ;
224289 let mut outline_paint = paint_from_hex ( & outline) ;
225290 outline_paint. set_style ( PaintStyle :: Stroke ) ;
226291 outline_paint. set_stroke_width ( ( self . size * 0.07 ) . max ( 1.0 ) ) ;
@@ -492,4 +557,111 @@ mod tests {
492557 "the click ring on an outline pointer must default to the outline's white, not a hard-coded or transparent-derived colour, got {ring:?}"
493558 ) ;
494559 }
560+
561+ fn finger_probe ( size : f32 ) -> ( f32 , f32 ) {
562+ ( 0.08 * size, 0.05 * size)
563+ }
564+
565+ #[ test]
566+ fn finger_probe_is_inside_the_open_hand_but_outside_the_closed_fist ( ) {
567+ const SIZE : f32 = 200.0 ;
568+ let ( fx, fy) = finger_probe ( SIZE ) ;
569+ assert ! (
570+ Pointer :: hand_path( SIZE ) . contains( ( fx, fy) ) ,
571+ "probe point must sit on the extended finger of the open hand"
572+ ) ;
573+ assert ! (
574+ !Pointer :: grab_path( SIZE ) . contains( ( fx, fy) ) ,
575+ "probe point must fall outside the fist alone, or it cannot prove the finger retracted"
576+ ) ;
577+ }
578+
579+ #[ test]
580+ fn a_default_pointer_still_draws_the_classic_arrow ( ) {
581+ let p = pointer ( serde_json:: json!( { } ) ) ;
582+ assert_eq ! (
583+ p. glyph,
584+ PointerGlyph :: Arrow ,
585+ "arrow stays the default glyph"
586+ ) ;
587+ assert_eq ! (
588+ p. active_glyph( None ) ,
589+ PointerGlyph :: Arrow ,
590+ "no glyph/click_glyph configured must resolve to the classic arrow"
591+ ) ;
592+ }
593+
594+ #[ test]
595+ fn hand_glyph_closes_into_a_grab_during_a_click_and_reopens_after ( ) {
596+ let p = pointer ( serde_json:: json!( {
597+ "glyph" : "hand" ,
598+ "click_glyph" : "grab" ,
599+ "tone" : "light" ,
600+ "size" : SIZE_FOR_HAND_TEST ,
601+ "click_at" : [ 1.0 ] ,
602+ "click_duration" : 0.5
603+ } ) ) ;
604+ const W : i32 = 300 ;
605+ const H : i32 = 300 ;
606+ let background = skia_safe:: Color :: from_argb ( 255 , 0 , 128 , 0 ) ;
607+ let ( fx, fy) = finger_probe ( SIZE_FOR_HAND_TEST ) ;
608+
609+ let mut before_click = render ( & p, W , H , 0.0 , background) ;
610+ let open = pixel ( & mut before_click, W , H , fx, fy) ;
611+ assert_eq ! (
612+ open,
613+ ( 255 , 255 , 255 , 255 ) ,
614+ "before any click the open hand must paint its extended finger (white fill), got {open:?}"
615+ ) ;
616+
617+ let mut mid_click = render ( & p, W , H , 1.0 , background) ;
618+ let closed = pixel ( & mut mid_click, W , H , fx, fy) ;
619+ assert_eq ! (
620+ closed,
621+ ( 0 , 128 , 0 , 255 ) ,
622+ "at the moment of the click the finger must have retracted into the fist, \
623+ leaving the background showing through at the same point, got {closed:?}"
624+ ) ;
625+
626+ let mut after_click = render ( & p, W , H , 1.6 , background) ;
627+ let reopened = pixel ( & mut after_click, W , H , fx, fy) ;
628+ assert_eq ! (
629+ reopened, open,
630+ "once the click finishes the hand must reopen to exactly its resting pose"
631+ ) ;
632+ }
633+
634+ const SIZE_FOR_HAND_TEST : f32 = 200.0 ;
635+
636+ #[ test]
637+ fn the_open_hand_pose_is_stable_when_no_click_is_happening ( ) {
638+ let p = pointer ( serde_json:: json!( {
639+ "glyph" : "hand" ,
640+ "click_glyph" : "grab" ,
641+ "tone" : "light" ,
642+ "size" : SIZE_FOR_HAND_TEST ,
643+ "click_at" : [ 1.0 ] ,
644+ "click_duration" : 0.5
645+ } ) ) ;
646+ const W : i32 = 300 ;
647+ const H : i32 = 300 ;
648+ let background = skia_safe:: Color :: from_argb ( 255 , 0 , 128 , 0 ) ;
649+ let ( fx, fy) = finger_probe ( SIZE_FOR_HAND_TEST ) ;
650+
651+ let mut at_zero = render ( & p, W , H , 0.0 , background) ;
652+ let mut long_before_the_click = render ( & p, W , H , 0.4 , background) ;
653+ let mut long_after_the_click = render ( & p, W , H , 3.0 , background) ;
654+
655+ let a = pixel ( & mut at_zero, W , H , fx, fy) ;
656+ let b = pixel ( & mut long_before_the_click, W , H , fx, fy) ;
657+ let c = pixel ( & mut long_after_the_click, W , H , fx, fy) ;
658+ assert_eq ! (
659+ a, b,
660+ "resting hand pose must not drift with time before a click"
661+ ) ;
662+ assert_eq ! (
663+ a, c,
664+ "resting hand pose must not drift with time after a click"
665+ ) ;
666+ }
495667}
0 commit comments