Skip to content

Commit 00ea2e3

Browse files
authored
feat(pointer,rich_text): a hand that closes on click, and pill spans (#397)
* feat(pointer,rich_text): hand glyph that closes on click, pill spans in rich_text #381 — pointer gains glyph/click_glyph (arrow default, hand, grab). The hotspot stays the tip/fingertip in local (0,0) regardless of glyph, so switching shape never moves the point a waypoint aims at. click_glyph reuses the existing click_progress window instead of a second clock: it only shows during click_duration, then reverts to glyph. Extends colors()'s one-match-arm shape rather than adding a parallel dispatch mechanism. #382 — rich_text spans gain background/padding/border-radius/rotation to paint a pill behind their own glyph run. Horizontal padding is folded into compute_layout's line-advance so RichTextIntrinsic (which just calls compute_layout) sizes correctly for free; vertical padding only grows the box, never line-height. Grouping is done per rendered line, so a pill that wraps gets its own padded box per line fragment (box-decoration-break: clone), not one box stretched across two lines. Rotation is paint-only, applied around the box's own centre, and never touches layout. box_builder.rs's two RichTextSpan test literals were extended with the four new fields — the only other construction site in the tree, needed to keep `cargo test --workspace` green. * docs(skills): link the rich_text pills rule into the index
1 parent 109868e commit 00ea2e3

7 files changed

Lines changed: 708 additions & 30 deletions

File tree

‎crates/rustmotion-components/src/box_builder.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2415,6 +2415,10 @@ mod tests {
24152415
font_family: None,
24162416
font_style: None,
24172417
letter_spacing: None,
2418+
background: None,
2419+
padding: None,
2420+
border_radius: None,
2421+
rotation: None,
24182422
},
24192423
RichTextSpan {
24202424
text: "40%".into(),
@@ -2424,6 +2428,10 @@ mod tests {
24242428
font_family: None,
24252429
font_style: None,
24262430
letter_spacing: None,
2431+
background: None,
2432+
padding: None,
2433+
border_radius: None,
2434+
rotation: None,
24272435
},
24282436
],
24292437
max_width: None,

‎crates/rustmotion-components/src/intrinsic.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,10 @@ mod tests {
932932
font_family: None,
933933
font_style: None,
934934
letter_spacing: None,
935+
background: None,
936+
padding: None,
937+
border_radius: None,
938+
rotation: None,
935939
}
936940
}
937941

‎crates/rustmotion-components/src/pointer.rs‎

Lines changed: 182 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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

181246
impl 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

Comments
 (0)