From 7c4646c2608c5d77d3242d6cde1edbe30838c6ff Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 21:09:32 +0200 Subject: [PATCH 1/2] feat(pointer,rich_text): hand glyph that closes on click, pill spans in rich_text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- .../rustmotion-components/src/box_builder.rs | 8 + crates/rustmotion-components/src/intrinsic.rs | 4 + crates/rustmotion-components/src/pointer.rs | 192 +++++++- crates/rustmotion-components/src/rich_text.rs | 461 +++++++++++++++++- .../skills/rules/pointer-walkthrough.md | 24 + .../skills/rules/rich-text-pills.md | 48 ++ 6 files changed, 707 insertions(+), 30 deletions(-) create mode 100644 crates/rustmotion/skills/rules/rich-text-pills.md diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 37bb403..5f331cf 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -2415,6 +2415,10 @@ mod tests { font_family: None, font_style: None, letter_spacing: None, + background: None, + padding: None, + border_radius: None, + rotation: None, }, RichTextSpan { text: "40%".into(), @@ -2424,6 +2428,10 @@ mod tests { font_family: None, font_style: None, letter_spacing: None, + background: None, + padding: None, + border_radius: None, + rotation: None, }, ], max_width: None, diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 3193f24..d7968b1 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -932,6 +932,10 @@ mod tests { font_family: None, font_style: None, letter_spacing: None, + background: None, + padding: None, + border_radius: None, + rotation: None, } } diff --git a/crates/rustmotion-components/src/pointer.rs b/crates/rustmotion-components/src/pointer.rs index c1d663e..2e57cd4 100644 --- a/crates/rustmotion-components/src/pointer.rs +++ b/crates/rustmotion-components/src/pointer.rs @@ -26,6 +26,20 @@ pub enum PointerTone { Outline, } +/// Which glyph the pointer draws. `click_glyph` can swap to a different one +/// only for the duration of a click, then it reverts to this one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PointerGlyph { + /// The classic pointer arrow, tip at the hotspot. + #[default] + Arrow, + /// An open hand pointing with its index finger, fingertip at the hotspot. + Hand, + /// A closed fist, as if grabbing the point under the hotspot. + Grab, +} + /// How loud the click ring is. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -65,6 +79,13 @@ pub struct Pointer { /// Height of the arrow in px. The click ring scales with it. #[serde(default = "default_pointer_size")] pub size: f32, + /// Which glyph is drawn. Defaults to the classic arrow. + #[serde(default)] + pub glyph: PointerGlyph, + /// Glyph shown for the duration of a click, then back to `glyph`. Absent + /// keeps `glyph` unchanged through the click — only the scale dip shows. + #[serde(default)] + pub click_glyph: Option, /// Colour scheme. Overridden by `color` / `outline_color` when set. #[serde(default)] pub tone: PointerTone, @@ -154,6 +175,18 @@ impl Pointer { } } + fn add_contour(path: &mut PathBuilder, points: &[(f32, f32)], size: f32) { + for (i, (x, y)) in points.iter().enumerate() { + let p = (x * size, y * size); + if i == 0 { + path.move_to(p); + } else { + path.line_to(p); + } + } + path.close(); + } + fn arrow_path(size: f32) -> Path { const OUTLINE: [(f32, f32); 7] = [ (0.0, 0.0), @@ -165,17 +198,49 @@ impl Pointer { (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(); + Self::add_contour(&mut path, &OUTLINE, size); + path.detach() + } + + const HAND_FIST: [(f32, f32); 8] = [ + (0.08, 0.38), + (0.30, 0.30), + (0.54, 0.36), + (0.62, 0.58), + (0.54, 0.82), + (0.30, 0.92), + (0.12, 0.82), + (0.04, 0.58), + ]; + + fn hand_path(size: f32) -> Path { + const FINGER: [(f32, f32); 3] = [(0.00, 0.00), (0.40, 0.06), (0.10, 0.30)]; + let mut path = PathBuilder::new(); + Self::add_contour(&mut path, &FINGER, size); + Self::add_contour(&mut path, &Self::HAND_FIST, size); + path.detach() + } + + fn grab_path(size: f32) -> Path { + let mut path = PathBuilder::new(); + Self::add_contour(&mut path, &Self::HAND_FIST, size); path.detach() } + + fn glyph_path(glyph: PointerGlyph, size: f32) -> Path { + match glyph { + PointerGlyph::Arrow => Self::arrow_path(size), + PointerGlyph::Hand => Self::hand_path(size), + PointerGlyph::Grab => Self::grab_path(size), + } + } + + fn active_glyph(&self, click: Option) -> PointerGlyph { + match click { + Some(_) => self.click_glyph.unwrap_or(self.glyph), + None => self.glyph, + } + } } impl Painter for Pointer { @@ -220,7 +285,7 @@ impl Painter for Pointer { canvas.scale((scale, scale)); } - let path = Self::arrow_path(self.size); + let path = Self::glyph_path(self.active_glyph(click), 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)); @@ -492,4 +557,111 @@ mod tests { "the click ring on an outline pointer must default to the outline's white, not a hard-coded or transparent-derived colour, got {ring:?}" ); } + + fn finger_probe(size: f32) -> (f32, f32) { + (0.08 * size, 0.05 * size) + } + + #[test] + fn finger_probe_is_inside_the_open_hand_but_outside_the_closed_fist() { + const SIZE: f32 = 200.0; + let (fx, fy) = finger_probe(SIZE); + assert!( + Pointer::hand_path(SIZE).contains((fx, fy)), + "probe point must sit on the extended finger of the open hand" + ); + assert!( + !Pointer::grab_path(SIZE).contains((fx, fy)), + "probe point must fall outside the fist alone, or it cannot prove the finger retracted" + ); + } + + #[test] + fn a_default_pointer_still_draws_the_classic_arrow() { + let p = pointer(serde_json::json!({})); + assert_eq!( + p.glyph, + PointerGlyph::Arrow, + "arrow stays the default glyph" + ); + assert_eq!( + p.active_glyph(None), + PointerGlyph::Arrow, + "no glyph/click_glyph configured must resolve to the classic arrow" + ); + } + + #[test] + fn hand_glyph_closes_into_a_grab_during_a_click_and_reopens_after() { + let p = pointer(serde_json::json!({ + "glyph": "hand", + "click_glyph": "grab", + "tone": "light", + "size": SIZE_FOR_HAND_TEST, + "click_at": [1.0], + "click_duration": 0.5 + })); + const W: i32 = 300; + const H: i32 = 300; + let background = skia_safe::Color::from_argb(255, 0, 128, 0); + let (fx, fy) = finger_probe(SIZE_FOR_HAND_TEST); + + let mut before_click = render(&p, W, H, 0.0, background); + let open = pixel(&mut before_click, W, H, fx, fy); + assert_eq!( + open, + (255, 255, 255, 255), + "before any click the open hand must paint its extended finger (white fill), got {open:?}" + ); + + let mut mid_click = render(&p, W, H, 1.0, background); + let closed = pixel(&mut mid_click, W, H, fx, fy); + assert_eq!( + closed, + (0, 128, 0, 255), + "at the moment of the click the finger must have retracted into the fist, \ + leaving the background showing through at the same point, got {closed:?}" + ); + + let mut after_click = render(&p, W, H, 1.6, background); + let reopened = pixel(&mut after_click, W, H, fx, fy); + assert_eq!( + reopened, open, + "once the click finishes the hand must reopen to exactly its resting pose" + ); + } + + const SIZE_FOR_HAND_TEST: f32 = 200.0; + + #[test] + fn the_open_hand_pose_is_stable_when_no_click_is_happening() { + let p = pointer(serde_json::json!({ + "glyph": "hand", + "click_glyph": "grab", + "tone": "light", + "size": SIZE_FOR_HAND_TEST, + "click_at": [1.0], + "click_duration": 0.5 + })); + const W: i32 = 300; + const H: i32 = 300; + let background = skia_safe::Color::from_argb(255, 0, 128, 0); + let (fx, fy) = finger_probe(SIZE_FOR_HAND_TEST); + + let mut at_zero = render(&p, W, H, 0.0, background); + let mut long_before_the_click = render(&p, W, H, 0.4, background); + let mut long_after_the_click = render(&p, W, H, 3.0, background); + + let a = pixel(&mut at_zero, W, H, fx, fy); + let b = pixel(&mut long_before_the_click, W, H, fx, fy); + let c = pixel(&mut long_after_the_click, W, H, fx, fy); + assert_eq!( + a, b, + "resting hand pose must not drift with time before a click" + ); + assert_eq!( + a, c, + "resting hand pose must not drift with time after a click" + ); + } } diff --git a/crates/rustmotion-components/src/rich_text.rs b/crates/rustmotion-components/src/rich_text.rs index 6782eaa..14cb979 100644 --- a/crates/rustmotion-components/src/rich_text.rs +++ b/crates/rustmotion-components/src/rich_text.rs @@ -1,6 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use skia_safe::{Canvas, Font, FontStyle}; +use skia_safe::{Canvas, Font, FontStyle, Point, RRect, Rect, Typeface}; use rustmotion_core::css::style::{ FontStyle as CssFontStyle, FontWeight as CssFontWeight, FontWeightKw, @@ -16,6 +16,26 @@ use rustmotion_core::engine::renderer::{ use rustmotion_core::schema::{FontStyleType, FontWeight, TextAlign, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; +/// Padding around a pill span's background box, in px on each side. `left` +/// and `right` grow the token's advance width, so following spans shift +/// over instead of overlapping the pill. `top` and `bottom` only grow the +/// box — they never change the line's height. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] +pub struct RichTextSpanPadding { + /// Padding above the glyph run, in px. + #[serde(default)] + pub top: f32, + /// Padding to the right of the glyph run, in px. Added to the line's advance. + #[serde(default)] + pub right: f32, + /// Padding below the glyph run, in px. + #[serde(default)] + pub bottom: f32, + /// Padding to the left of the glyph run, in px. Added to the line's advance. + #[serde(default)] + pub left: f32, +} + /// A single styled span within a rich_text component. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct RichTextSpan { @@ -32,6 +52,28 @@ pub struct RichTextSpan { pub font_style: Option, #[serde(default, rename = "letter-spacing")] pub letter_spacing: Option, + /// Background colour (hex) painted behind this span's glyph run, as a + /// pill. Absent means no box — the span paints glyphs only, and + /// `padding`/`border-radius`/`rotation` are then inert. + #[serde(default)] + pub background: Option, + /// Padding around the pill box. Ignored when `background` is absent. + #[serde(default)] + pub padding: Option, + /// Corner radius of the pill box, in px. Ignored when `background` is absent. + #[serde(default, rename = "border-radius")] + pub border_radius: Option, + /// Rotation of the pill box and its glyphs together, in degrees, about + /// the box's own centre. Does not affect layout. Ignored when + /// `background` is absent. + #[serde(default)] + pub rotation: Option, +} + +impl RichTextSpan { + fn pill_padding(&self) -> RichTextSpanPadding { + self.padding.unwrap_or_default() + } } /// Rich text component: renders multiple styled spans on the same line(s). @@ -282,6 +324,23 @@ impl RichText { } } + for line in &mut lines { + let mut extra = 0.0f32; + let n = line.tokens.len(); + for i in 0..n { + let span_idx = line.tokens[i].span_idx; + let is_pill = spans.get(span_idx).is_some_and(|s| s.background.is_some()); + if is_pill && (i == 0 || line.tokens[i - 1].span_idx != span_idx) { + extra += spans[span_idx].pill_padding().left; + } + line.tokens[i].x += extra; + if is_pill && (i + 1 == n || line.tokens[i + 1].span_idx != span_idx) { + extra += spans[span_idx].pill_padding().right; + } + } + line.width += extra; + } + let max_width = lines.iter().map(|l| l.width).fold(0.0f32, f32::max); let max_ascent = span_fonts .iter() @@ -368,28 +427,113 @@ impl RichText { }; let y = line_idx as f32 * layout.line_height + baseline_offset; - for tok in &line.tokens { - let sf = span_fonts[tok.span_idx] - .as_ref() - .expect("font presence matches compute_layout's tokenization"); - let paint = paint_from_hex(&sf.color); - let emoji_font = emoji_tf - .as_ref() - .map(|tf| Font::from_typeface(tf.clone(), sf.font.size())); - - draw_text_with_fallback( - canvas, - &tok.text, - &sf.font, - &emoji_font, - sf.letter_spacing, - line_x_offset + tok.x, - y, - &paint, - ); + let mut i = 0; + while i < line.tokens.len() { + let span_idx = line.tokens[i].span_idx; + let mut j = i + 1; + while j < line.tokens.len() && line.tokens[j].span_idx == span_idx { + j += 1; + } + let run = &line.tokens[i..j]; + + if self.spans[span_idx].background.is_some() { + self.paint_pill_run( + canvas, + &self.spans[span_idx], + run, + &span_fonts, + &emoji_tf, + line_x_offset, + y, + ); + } else { + for tok in run { + Self::paint_token(canvas, tok, &span_fonts, &emoji_tf, line_x_offset, y); + } + } + i = j; } } } + + fn paint_token( + canvas: &Canvas, + tok: &RichTextToken, + span_fonts: &[Option], + emoji_tf: &Option, + line_x_offset: f32, + y: f32, + ) { + let sf = span_fonts[tok.span_idx] + .as_ref() + .expect("font presence matches compute_layout's tokenization"); + let paint = paint_from_hex(&sf.color); + let emoji_font = emoji_tf + .as_ref() + .map(|tf| Font::from_typeface(tf.clone(), sf.font.size())); + + draw_text_with_fallback( + canvas, + &tok.text, + &sf.font, + &emoji_font, + sf.letter_spacing, + line_x_offset + tok.x, + y, + &paint, + ); + } + + #[allow(clippy::too_many_arguments)] + fn paint_pill_run( + &self, + canvas: &Canvas, + span: &RichTextSpan, + run: &[RichTextToken], + span_fonts: &[Option], + emoji_tf: &Option, + line_x_offset: f32, + y: f32, + ) { + let (Some(first), Some(last)) = (run.first(), run.last()) else { + return; + }; + let sf = span_fonts[first.span_idx] + .as_ref() + .expect("font presence matches compute_layout's tokenization"); + let (_, metrics) = sf.font.metrics(); + let ascent = -metrics.ascent; + let descent = metrics.descent; + let padding = span.pill_padding(); + + let x0 = line_x_offset + first.x - padding.left; + let x1 = line_x_offset + last.x + last.width + padding.right; + let y0 = y - ascent - padding.top; + let y1 = y + descent + padding.bottom; + let rect = Rect::from_ltrb(x0, y0, x1, y1); + let radius = span.border_radius.unwrap_or(0.0).max(0.0); + let rrect = RRect::new_rect_xy(rect, radius, radius); + let center = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let rotation = span.rotation.unwrap_or(0.0); + + canvas.save(); + if rotation != 0.0 { + canvas.rotate(rotation, Some(center)); + } + + let background = span + .background + .as_deref() + .expect("paint_pill_run is only called for spans with a background"); + let bg_paint = paint_from_hex(background); + canvas.draw_rrect(rrect, &bg_paint); + + for tok in run { + Self::paint_token(canvas, tok, span_fonts, emoji_tf, line_x_offset, y); + } + + canvas.restore(); + } } impl Painter for RichText { @@ -418,6 +562,18 @@ mod tests { font_family: None, font_style: None, letter_spacing: None, + background: None, + padding: None, + border_radius: None, + rotation: None, + } + } + + fn pill_span(text: &str, background: &str, padding: RichTextSpanPadding) -> RichTextSpan { + RichTextSpan { + background: Some(background.into()), + padding: Some(padding), + ..span(text) } } @@ -691,4 +847,269 @@ mod tests { (formula gave baseline {expected_baseline})" ); } + + fn pixel_rgba( + surface: &mut skia_safe::Surface, + w: i32, + h: i32, + x: f32, + y: f32, + ) -> (u8, u8, u8, u8) { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (w, h), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (w * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + let ix = x.round() as i32; + let iy = y.round() as i32; + let idx = ((iy * w + ix) * 4) as usize; + (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]) + } + + fn any_pixel_near_white(surface: &mut skia_safe::Surface, w: i32, h: i32) -> bool { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (w, h), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (w * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + buf.as_chunks::<4>() + .0 + .iter() + .any(|p| p[0] > 200 && p[1] > 200 && p[2] > 200 && p[3] > 200) + } + + #[test] + fn pill_padding_grows_the_line_advance_and_shifts_the_following_span() { + let s = style(40.0); + let padding = RichTextSpanPadding { + top: 4.0, + right: 16.0, + bottom: 6.0, + left: 16.0, + }; + + let plain = vec![span("tag"), span(" rest")]; + let with_pill = vec![pill_span("tag", "#1F6FEB", padding), span(" rest")]; + + let plain_layout = RichText::compute_layout(&plain, &s, 1920.0, 1080.0, None, -1.0); + let pill_layout = RichText::compute_layout(&with_pill, &s, 1920.0, 1080.0, None, -1.0); + + let plain_rest_x = plain_layout.lines[0].tokens[1].x; + let pill_rest_x = pill_layout.lines[0].tokens[1].x; + let expected_shift = padding.left + padding.right; + + assert!( + (pill_rest_x - plain_rest_x - expected_shift).abs() < 0.5, + "a pill's horizontal padding must be added to the line's advance, moving the \ + following span over by left+right padding: plain x={plain_rest_x}, pill \ + x={pill_rest_x}, expected shift {expected_shift}" + ); + } + + #[test] + fn a_pill_that_wraps_gets_its_own_padding_per_line_fragment() { + let s = style(40.0); + let padding = RichTextSpanPadding { + top: 4.0, + right: 16.0, + bottom: 4.0, + left: 16.0, + }; + const WRAP: f32 = 100.0; + + let plain = vec![span("aaaa bbbb")]; + let plain_layout = RichText::compute_layout(&plain, &s, 1920.0, 1080.0, Some(WRAP), -1.0); + assert_eq!( + plain_layout.lines.len(), + 2, + "the reference text must wrap into two lines for this test to be meaningful" + ); + + let pill = vec![pill_span("aaaa bbbb", "#1F6FEB", padding)]; + let pill_layout = RichText::compute_layout(&pill, &s, 1920.0, 1080.0, Some(WRAP), -1.0); + assert_eq!( + pill_layout.lines.len(), + 2, + "padding must not change where the raw text wraps" + ); + + assert!( + (pill_layout.lines[0].tokens[0].x - padding.left).abs() < 0.5, + "the first line's fragment must get its own left padding, got x={}", + pill_layout.lines[0].tokens[0].x + ); + assert!( + (pill_layout.lines[1].tokens[0].x - padding.left).abs() < 0.5, + "the second line's fragment must ALSO get its own left padding — \ + box-decoration-break: clone — got x={}", + pill_layout.lines[1].tokens[0].x + ); + + let expected_extra = padding.left + padding.right; + assert!( + (pill_layout.lines[0].width - plain_layout.lines[0].width - expected_extra).abs() < 0.5, + "line 1's fragment must grow by its own left+right padding" + ); + assert!( + (pill_layout.lines[1].width - plain_layout.lines[1].width - expected_extra).abs() < 0.5, + "line 2's fragment must ALSO grow by its own left+right padding" + ); + } + + #[test] + fn pill_background_paints_behind_the_glyphs_not_in_front() { + let padding = RichTextSpanPadding { + top: 4.0, + right: 16.0, + bottom: 4.0, + left: 16.0, + }; + let s = CssStyle { + color: Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())), + ..style(60.0) + }; + let spans = vec![pill_span("Tag", "#000000", padding)]; + let rt = RichText { + spans: spans.clone(), + max_width: None, + timing: Default::default(), + style: s.clone(), + timeline: Vec::new(), + stagger: None, + }; + + const W: i32 = 400; + const H: i32 = 200; + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + + let layout = RichText::compute_layout( + &spans, + &s, + ctx.video_width as f32, + ctx.video_height as f32, + Some(W as f32), + -1.0, + ); + let baseline_offset = (layout.line_height + layout.max_ascent - layout.max_descent) / 2.0; + let token = &layout.lines[0].tokens[0]; + let box_x0 = token.x - padding.left; + let box_y0 = baseline_offset - layout.max_ascent - padding.top; + + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::from_argb(255, 0, 255, 0)); + rt.paint(canvas, W as f32, &props, &ctx); + } + + let inside_padding = pixel_rgba(&mut surface, W, H, box_x0 + 4.0, box_y0 + 4.0); + assert_eq!( + inside_padding, + (0, 0, 0, 255), + "just inside the pill's padding, clear of any glyph, must show the pill's own \ + background colour, got {inside_padding:?}" + ); + + assert!( + any_pixel_near_white(&mut surface, W, H), + "the glyphs must still paint their own (white) colour on top of the pill's black \ + background — a background painted in front of the text would leave no white pixel" + ); + } + + #[test] + fn pill_rotation_turns_the_box_and_text_without_touching_layout() { + let s = style(50.0); + let padding = RichTextSpanPadding { + top: 6.0, + right: 10.0, + bottom: 6.0, + left: 10.0, + }; + let flat = vec![pill_span("Hi", "#1F6FEB", padding)]; + let mut rotated_spans = flat.clone(); + rotated_spans[0].rotation = Some(45.0); + + let flat_layout = RichText::compute_layout(&flat, &s, 1920.0, 1080.0, None, -1.0); + let rotated_layout = + RichText::compute_layout(&rotated_spans, &s, 1920.0, 1080.0, None, -1.0); + assert_eq!( + flat_layout.lines[0].tokens[0].x, rotated_layout.lines[0].tokens[0].x, + "rotation must not move the token — it is a paint-only transform" + ); + assert_eq!( + flat_layout.lines[0].width, rotated_layout.lines[0].width, + "rotation must not change the line's advance" + ); + + const W: i32 = 300; + const H: i32 = 200; + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + let baseline_offset = + (flat_layout.line_height + flat_layout.max_ascent - flat_layout.max_descent) / 2.0; + let token = &flat_layout.lines[0].tokens[0]; + let box_x1 = token.x + token.width + padding.right; + let box_y1 = baseline_offset + flat_layout.max_descent + padding.bottom; + + let render_at = |rotation: Option| { + let mut spans = flat.clone(); + spans[0].rotation = rotation; + let rt = RichText { + spans, + max_width: None, + timing: Default::default(), + style: s.clone(), + timeline: Vec::new(), + stagger: None, + }; + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::from_argb(255, 0, 255, 0)); + rt.paint(canvas, W as f32, &props, &ctx); + } + surface + }; + + let mut unrotated = render_at(None); + let corner_flat = pixel_rgba(&mut unrotated, W, H, box_x1 - 2.0, box_y1 - 2.0); + assert_eq!( + corner_flat, + (0x1F, 0x6F, 0xEB, 255), + "unrotated: the bottom-right corner of the pill box must be filled, got {corner_flat:?}" + ); + + let mut rotated = render_at(Some(45.0)); + let corner_rotated = pixel_rgba(&mut rotated, W, H, box_x1 - 2.0, box_y1 - 2.0); + assert_ne!( + corner_rotated, corner_flat, + "rotating the pill 45° about its own centre must move its corner away from the \ + unrotated position, got the same pixel {corner_rotated:?} at both" + ); + } } diff --git a/crates/rustmotion/skills/rules/pointer-walkthrough.md b/crates/rustmotion/skills/rules/pointer-walkthrough.md index cbda382..9fd8834 100644 --- a/crates/rustmotion/skills/rules/pointer-walkthrough.md +++ b/crates/rustmotion/skills/rules/pointer-walkthrough.md @@ -26,6 +26,8 @@ For a product demo or an agent walkthrough — the arrow that moves to a control | Field | Role | |---|---| | `size` | Height of the arrow in px. The click ring scales with it. | +| `glyph` | `arrow` (default), `hand`, or `grab` — which shape is drawn | +| `click_glyph` | Glyph shown for the duration of a click, then back to `glyph` | | `tone` | `light` (white arrow, dark outline), `dark`, or `outline` (transparent fill, white outline) | | `color` / `outline_color` | Override `tone` | | `click_ring` | `subtle` / `standard` / `bold` / `none` | @@ -53,3 +55,25 @@ Between two waypoints, the pointer doesn't set off again until the click animati `light` and `dark` are both filled arrows — a solid shape that sits on top of whatever's underneath. `outline` is a third tone: a transparent fill with a white outline, so the arrow reads as a mark rather than a shape competing for attention with the control it's pointing at. Reach for it over a busy screenshot or a mockup where a filled arrow would cover detail you want to keep visible. The click ring follows the same rule as `color`/`outline_color`: it defaults to whichever colour is actually visible for the tone in use — the fill for `light`/`dark`, the outline for `outline` — rather than a colour hard-coded independently of `tone`. `ring_color` still overrides it directly, same as on the filled tones. + +## `glyph` / `click_glyph`: a hand that closes on click + +The default `glyph` is the classic arrow. `hand` draws an open hand pointing with its index finger, fingertip at the hotspot — the same point `path`/`click_at` coordinates always referred to. `grab` draws the same hand with the finger retracted into the fist, as if it had just closed around that point. + +```json +{ + "type": "pointer", + "glyph": "hand", + "click_glyph": "grab", + "tone": "outline", + "path": [ + { "time": 0.0, "x": 1500, "y": 900 }, + { "time": 0.6, "x": 960, "y": 540 } + ], + "click_at": [0.7] +} +``` + +`click_glyph` only applies **for the duration of the click** — the same window `click_progress` already computes from `path`'s arrival times or `click_at`. There is no second clock: a hand with no `click_glyph` set still dips on click (the existing scale animation), it just never swaps shape. `grab` with no `glyph: "hand"` is legal but unusual — nothing ever shows it, since the resting glyph never becomes it outside a click. + +Because the hotspot is defined per glyph rather than being the geometric centre of its bounding box, switching `glyph` (or swapping to `click_glyph` mid-click) never moves the point a waypoint is aimed at — only the drawn shape around that point changes. diff --git a/crates/rustmotion/skills/rules/rich-text-pills.md b/crates/rustmotion/skills/rules/rich-text-pills.md new file mode 100644 index 0000000..7fb3ada --- /dev/null +++ b/crates/rustmotion/skills/rules/rich-text-pills.md @@ -0,0 +1,48 @@ +# Rule: `rich_text` — spans en pilule (fond, padding, radius, rotation) + +Plusieurs références mettent en valeur un mot au milieu d'une phrase avec une petite étiquette arrondie, parfois légèrement tournée. Un span de `rich_text` peut porter sa propre boîte peinte derrière ses glyphes — l'étiquette suit alors le wrap et la ligne de base de la phrase, plutôt que d'être un `div` posé à côté qui casse dès que le texte change. + +```json +{ "type": "rich_text", "style": { "font-size": 64 }, "spans": [ + { "text": "Automatisez vos " }, + { "text": "relances", "color": "#FFFFFF", + "background": "#1F6FEB", "padding": { "top": 4, "right": 16, "bottom": 6, "left": 16 }, + "border-radius": 999, "rotation": -3 }, + { "text": " en un clic." } ] } +``` + +## Champs + +| Champ | Rôle | +|---|---| +| `background` | Couleur (hex) du fond peint derrière le run de glyphes du span. Absent = pas de boîte ; `padding`, `border-radius` et `rotation` sont alors inertes, comme `text-autofit` sur un composant qui ne l'implémente pas. | +| `padding` | `{ top, right, bottom, left }` en px, chaque champ optionnel (défaut `0`). | +| `border-radius` | Rayon des coins de la boîte, en px. | +| `rotation` | Degrés, autour du **centre de la boîte**. N'affecte jamais la mise en page. | + +## Le padding horizontal grossit l'avance de la ligne + +`padding.left` et `padding.right` s'ajoutent à la largeur occupée par le span dans le flux — pas seulement à ce qui est peint. Sans ça, le span suivant recouvrirait la pilule au lieu de se décaler après elle. `padding.top`/`padding.bottom` ne touchent en revanche jamais `line-height` : ils grossissent la boîte verticalement, jamais l'interligne — deux pilules de tailles de police différentes sur la même ligne ne poussent donc pas les lignes voisines. + +**GOOD** — le texte qui suit se décale après la pilule, il ne la recouvre pas : +```json +{ "type": "rich_text", "spans": [ + { "text": "tag", "background": "#1F6FEB", "padding": { "left": 16, "right": 16 } }, + { "text": " suite" } ] } +``` + +## Un span qui wrap redonne son padding à chaque fragment (`box-decoration-break: clone`) + +Si le span-pilule contient plusieurs mots et que la ligne wrap au milieu, chaque fragment — un par ligne — reçoit sa **propre** boîte avec son propre padding gauche/droite, exactement le modèle CSS `box-decoration-break: clone`. Ce n'est pas une seule boîte étirée entre deux lignes. + +Le point de wrap lui-même reste décidé sur la largeur brute du texte, sans compter le padding : un padding généreux sur une pilule proche de la largeur du conteneur peut donc légèrement déborder plutôt que de déclencher un retour à la ligne plus tôt qu'un span normal de même texte. + +## `rotation` tourne la boîte et le texte ensemble, jamais la mise en page + +`rotation` fait pivoter la pilule (fond + glyphes) comme un seul bloc rigide autour du centre de sa propre boîte. La position et la largeur que ce span réserve dans le flux du texte restent celles calculées sans rotation — un span à `rotation: -3` ne pousse pas ses voisins différemment d'un span identique à `rotation: 0`. C'est une transformation de peinture, pas de layout. + +## Le fond est peint derrière les glyphes, jamais devant + +La boîte est peinte en premier, puis les glyphes du run par-dessus — même ordre que `text-background` sur `text` (voir [rules/text-background.md](text-background.md)). Un span-pilule avec un `color` clair sur un `background` sombre reste donc lisible. + +`text-background` couvre tout le composant `text` ; le fond de pilule ici ne couvre que le run d'un span à l'intérieur d'un `rich_text` — c'est la brique à choisir quand un seul mot au milieu d'une phrase doit être mis en évidence, pas toute la ligne. Voir aussi [rules/text-component-parity.md](text-component-parity.md) pour ce qui aligne déjà `text` et `rich_text` (espaces, ligne de base). From 3a160cab24ebfdfc5e169799f3912ed7885c754d Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 21:59:33 +0200 Subject: [PATCH 2/2] docs(skills): link the rich_text pills rule into the index --- crates/rustmotion/skills/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 7827d7a..870c2a7 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -244,6 +244,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/draw-progress-stroke.md](rules/draw-progress-stroke.md) - `draw_progress` at 0 paints nothing, and an `svg` draw-on matches the finished mark's stroke width, cap and join - [rules/svg-text-fonts.md](rules/svg-text-fonts.md) - Why `` inside an `svg` needs a resolvable font face, and what happens when the host has none - [rules/vhs-tear.md](rules/vhs-tear.md) - The `vhs` scene effect: bands slid sideways, noise, scanlines and a travelling tracking line — bounded by `at`/`duration` because it is a beat, not a filter +- [rules/rich-text-pills.md](rules/rich-text-pills.md) - Pill spans in `rich_text`: padding that moves the following span, `box-decoration-break: clone` on a wrap, and rotation that turns the box without touching layout - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet - [rules/overlapping-scenes.md](rules/overlapping-scenes.md) - Make an element outlive a cut: overlapping `at` windows composite instead of replacing, who supplies the background, and why `snap` never creates an overlap