Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion crates/rustmotion-components/src/badge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, ColorType, ImageInfo, Paint, PaintStyle, RRect, Rect};

use rustmotion_core::css::style::AlignSelf;
use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
Expand Down Expand Up @@ -65,14 +66,45 @@ pub struct Badge {
pub count: Option<u32>,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
/// `align-self` defaults to `flex-start` (not the flex container's
/// `stretch`) — a badge is an atomic icon+label chip that must keep its
/// natural intrinsic width even inside a `flex`/`card` column; without
/// this, the default cross-axis `stretch` wins over `BadgeIntrinsic`
/// and the chip becomes a full-width bar. An author-specified
/// `align-self` in JSON is always respected (this only fills the gap
/// when it's absent).
#[serde(
default = "default_badge_style",
deserialize_with = "deserialize_no_stretch_style"
)]
pub style: CssStyle,
#[serde(default)]
pub timeline: Vec<TimelineStep>,
#[serde(default)]
pub stagger: Option<f32>,
}

fn default_badge_style() -> CssStyle {
CssStyle {
align_self: Some(AlignSelf::FlexStart),
..CssStyle::default()
}
}

/// Deserializes `style` normally, then defaults `align-self` to
/// `flex-start` when the author didn't set it explicitly — see the doc
/// comment on [`Badge::style`].
fn deserialize_no_stretch_style<'de, D>(deserializer: D) -> Result<CssStyle, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut style = CssStyle::deserialize(deserializer)?;
if style.align_self.is_none() {
style.align_self = Some(AlignSelf::FlexStart);
}
Ok(style)
}

rustmotion_core::impl_traits!(Badge {
Animatable => animation,
Timed => timing,
Expand Down Expand Up @@ -319,3 +351,39 @@ impl Painter for Badge {
self.paint(canvas, layout.width, layout.height, ctx.time);
}
}

#[cfg(test)]
mod tests {
use super::*;

fn parse(json: &str) -> Badge {
serde_json::from_str(json).expect("badge should deserialize")
}

#[test]
fn style_defaults_to_flex_start_when_absent() {
// #127: `align-items: stretch` (the flex column default) was
// winning over `BadgeIntrinsic`, stretching every badge in a card
// to the container's full width. No `style` key at all in the
// JSON is the common case — this must still default away from
// stretch.
let badge = parse(r#"{"type":"badge","text":"v1"}"#);
assert_eq!(badge.style.align_self, Some(AlignSelf::FlexStart));
}

#[test]
fn style_defaults_to_flex_start_with_other_style_keys_present() {
// Same fix, but exercised through the `deserialize_with` path
// (some `style` object present, just not `align-self`) rather
// than the field-level `default` path (`style` entirely absent).
let badge = parse(r##"{"type":"badge","text":"v1","style":{"background":"#f00"}}"##);
assert_eq!(badge.style.align_self, Some(AlignSelf::FlexStart));
assert_eq!(badge.style.background_color_str(), Some("#f00"));
}

#[test]
fn explicit_align_self_is_respected() {
let badge = parse(r#"{"type":"badge","text":"v1","style":{"align-self":"center"}}"#);
assert_eq!(badge.style.align_self, Some(AlignSelf::Center));
}
}
82 changes: 78 additions & 4 deletions crates/rustmotion-components/src/caption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ rustmotion_core::impl_traits!(Caption {
});

impl Caption {
fn paint(&self, canvas: &Canvas, layout_width: f32, time: f64) {
fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, time: f64) {
let font_size = self.style.font_size_px_or(48.0);
let color = self.style.color_str_or("#FFFFFF");
let font_family = self.style.font_family_or("Inter");
Expand All @@ -55,6 +55,37 @@ impl Caption {
let font = Font::from_typeface(typeface, font_size);
let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size));

// Every branch below draws text with its *baseline* at local y=0 and
// (for the pill-background presets) a highlight box extending up to
// `font_size + padding/2` above that baseline. Treated as the box's
// own coordinate space (y=0 = box top, as every other painter
// assumes), that put glyphs — and pills further still — above the
// assigned box: `CaptionIntrinsic` sizes the box for one line's
// ascent+descent starting at y=0, not for a baseline sitting at 0
// with ascenders going negative. Shifting the whole paint down by a
// margin that covers the tallest pill (WordPop's `font_size*0.35`
// padding, ~1.175×font_size) puts the topmost ink at/after y=0
// without touching any of the per-preset layout math below.
let top_offset = font_size * 1.2;
canvas.save();
canvas.translate((0.0, top_offset));
// Safety net: even with the offset above, an unusually large pill
// padding combined with a short assigned box could still spill past
// the bottom edge. Clip vertically only (not horizontally) — a
// caption in `white-space: nowrap` mode is *meant* to bleed past
// its own width when `max_width` doesn't fit the line (see
// `box_builder.rs`'s nowrap comment and the geometry validator's
// `unwrappable_text_overflow`), so clipping width here would hide a
// condition the validator is supposed to catch instead.
if layout_height > 0.0 {
const HALF_PLANE: f32 = 1_000_000.0;
canvas.clip_rect(
Rect::from_xywh(-HALF_PLANE, -top_offset, HALF_PLANE * 2.0, layout_height),
skia_safe::ClipOp::Intersect,
true,
);
}

match self.mode {
CaptionStyle::WordByWord => {
for word in &self.words {
Expand Down Expand Up @@ -249,6 +280,7 @@ impl Caption {
}
}
}
canvas.restore();
}
}

Expand All @@ -269,7 +301,7 @@ impl Painter for Caption {
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
self.paint(canvas, layout.width, ctx.time);
self.paint(canvas, layout.width, layout.height, ctx.time);
}
}

Expand Down Expand Up @@ -364,6 +396,48 @@ mod tests {
(minx <= maxx).then_some((minx, maxx, miny, maxy))
}

#[test]
fn ink_never_starts_above_the_box_top() {
// #127: every branch drew its baseline at local y=0 (the box's own
// top edge) — ascenders, and pill backgrounds further still,
// painted *above* y=0, bleeding out of whatever box the layout
// gave this caption (measured: assigned box top at y=124 in a
// card starting at y=100, but ink started at y≈85 — above the
// card itself, not just the box).
let caption = make_caption("Hello world", None);
const W: i32 = 400;
const H: i32 = 200;
let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
{
let canvas = surface.canvas();
caption.paint(canvas, W as f32, H as f32, 0.5);
}
let (_minx, _maxx, miny, _maxy) =
ink_bounds(&mut surface, W, H).expect("caption must paint something");
assert!(miny >= 0, "ink starts above the box top at y={miny}");
}

#[test]
fn word_pop_pill_never_starts_above_the_box_top() {
// The pill-background presets pad further above the baseline than
// plain text (up to `font_size * 0.35` extra) — the worst case
// among the four rendering modes.
let mut caption = make_caption("Hello", None);
caption.mode = CaptionStyle::WordPop;
caption.words[0].start = 0.0;
caption.words[0].end = 10.0;
const W: i32 = 400;
const H: i32 = 200;
let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
{
let canvas = surface.canvas();
caption.paint(canvas, W as f32, H as f32, 0.1);
}
let (_minx, _maxx, miny, _maxy) =
ink_bounds(&mut surface, W, H).expect("word_pop caption must paint something");
assert!(miny >= 0, "pill starts above the box top at y={miny}");
}

#[test]
fn nowrap_paints_one_wide_line_instead_of_wrapping_at_max_width() {
// M1 render-level proof, caption (Highlight mode): `white-space:
Expand All @@ -379,7 +453,7 @@ mod tests {
{
let canvas = surface.canvas();
canvas.translate((800.0, 250.0));
caption.paint(canvas, 80.0, 0.5);
caption.paint(canvas, 80.0, H as f32, 0.5);
}
let (minx, maxx, miny, maxy) =
ink_bounds(&mut surface, W, H).expect("nowrap caption must paint something");
Expand All @@ -405,7 +479,7 @@ mod tests {
{
let canvas = surface.canvas();
canvas.translate((800.0, 250.0));
caption.paint(canvas, 80.0, 0.5);
caption.paint(canvas, 80.0, H as f32, 0.5);
}
let (minx, maxx, miny, maxy) =
ink_bounds(&mut surface, W, H).expect("wrapped caption must paint something");
Expand Down
Loading
Loading