use kurbo::{
Affine, BezPath, ParamCurve, ParamCurveArclen, Point, Rect, RoundedRect, RoundedRectRadii,
Shape, Stroke, Vec2,
};
use peniko::{Color, ColorStop, ColorStops, Fill, Gradient};
use vello::Scene;
use crate::element::PathData;
use crate::style::{CornerRadius, Paint, Shadow, Style};
use crate::tokens::FOCUS_RING;
const BLUR_TO_STD_DEV: f32 = 0.5;
pub(crate) fn rounded_rect(rect: Rect, corners: CornerRadius) -> RoundedRect {
let clamp = |r: f32| -> f64 {
let max = 0.5 * rect.width().min(rect.height());
f64::from(r).clamp(0.0, max.max(0.0))
};
RoundedRect::from_rect(
rect,
RoundedRectRadii::new(
clamp(corners.tl),
clamp(corners.tr),
clamp(corners.br),
clamp(corners.bl),
),
)
}
const N_MAX: f64 = 5.0;
const SQUIRCLE_SEGMENTS: usize = 24;
pub(crate) enum BoxPath {
Arc(RoundedRect),
Squircle(BezPath),
}
impl Shape for BoxPath {
type PathElementsIter<'i> = Box<dyn Iterator<Item = kurbo::PathEl> + 'i>;
fn path_elements(&self, tol: f64) -> Self::PathElementsIter<'_> {
match self {
BoxPath::Arc(r) => Box::new(r.path_elements(tol)),
BoxPath::Squircle(p) => Box::new(p.path_elements(tol)),
}
}
fn area(&self) -> f64 {
match self {
BoxPath::Arc(r) => r.area(),
BoxPath::Squircle(p) => p.area(),
}
}
fn perimeter(&self, accuracy: f64) -> f64 {
match self {
BoxPath::Arc(r) => r.perimeter(accuracy),
BoxPath::Squircle(p) => p.perimeter(accuracy),
}
}
fn winding(&self, pt: Point) -> i32 {
match self {
BoxPath::Arc(r) => r.winding(pt),
BoxPath::Squircle(p) => p.winding(pt),
}
}
fn bounding_box(&self) -> Rect {
match self {
BoxPath::Arc(r) => r.bounding_box(),
BoxPath::Squircle(p) => p.bounding_box(),
}
}
}
pub(crate) fn corner_path(rect: Rect, corners: CornerRadius, smoothing: f32) -> BoxPath {
if smoothing <= 0.0 {
BoxPath::Arc(rounded_rect(rect, corners))
} else {
BoxPath::Squircle(build_squircle(rect, corners, smoothing))
}
}
fn squircle_exponent(smoothing: f32) -> f64 {
let s = f64::from(smoothing.clamp(0.0, 1.0));
2.0 + s * (N_MAX - 2.0)
}
fn superellipse_point(center: Point, u: Vec2, v: Vec2, r: f64, n: f64, theta: f64) -> Point {
let cx = theta.cos().max(0.0).powf(2.0 / n);
let sy = theta.sin().max(0.0).powf(2.0 / n);
center + u * (r * cx) + v * (r * sy)
}
fn build_squircle(rect: Rect, corners: CornerRadius, smoothing: f32) -> BezPath {
let n = squircle_exponent(smoothing);
let max = (0.5 * rect.width().min(rect.height())).max(0.0);
let clamp = |r: f32| f64::from(r).clamp(0.0, max);
let (tl, tr, br, bl) = (
clamp(corners.tl),
clamp(corners.tr),
clamp(corners.br),
clamp(corners.bl),
);
let (x0, y0, x1, y1) = (rect.x0, rect.y0, rect.x1, rect.y1);
let corner = |center: Point, u: Vec2, v: Vec2, r: f64| -> Vec<Point> {
(0..=SQUIRCLE_SEGMENTS)
.map(|i| {
#[expect(clippy::cast_precision_loss, reason = "segment counts are tiny")]
let theta = (i as f64 / SQUIRCLE_SEGMENTS as f64) * std::f64::consts::FRAC_PI_2;
superellipse_point(center, u, v, r, n, theta)
})
.collect()
};
let tr_pts = corner(
Point::new(x1 - tr, y0 + tr),
Vec2::new(0.0, -1.0),
Vec2::new(1.0, 0.0),
tr,
);
let br_pts = corner(
Point::new(x1 - br, y1 - br),
Vec2::new(1.0, 0.0),
Vec2::new(0.0, 1.0),
br,
);
let bl_pts = corner(
Point::new(x0 + bl, y1 - bl),
Vec2::new(0.0, 1.0),
Vec2::new(-1.0, 0.0),
bl,
);
let tl_pts = corner(
Point::new(x0 + tl, y0 + tl),
Vec2::new(-1.0, 0.0),
Vec2::new(0.0, -1.0),
tl,
);
let mut path = BezPath::new();
path.move_to(tr_pts[0]);
for p in &tr_pts[1..] {
path.line_to(*p);
}
path.line_to(br_pts[0]);
for p in &br_pts[1..] {
path.line_to(*p);
}
path.line_to(bl_pts[0]);
for p in &bl_pts[1..] {
path.line_to(*p);
}
path.line_to(tl_pts[0]);
for p in &tl_pts[1..] {
path.line_to(*p);
}
path.line_to(tr_pts[0]);
path.close_path();
path
}
fn uniform_radius(rect: Rect, corners: CornerRadius) -> f64 {
let max = (0.5 * rect.width().min(rect.height())).max(0.0);
let c = |r: f32| f64::from(r).clamp(0.0, max);
0.25 * (c(corners.tl) + c(corners.tr) + c(corners.br) + c(corners.bl))
}
fn shadow_layer(scene: &mut Scene, rect: Rect, corners: CornerRadius, shadow: &Shadow) {
if shadow.color.components[3] <= 0.0 {
return;
}
let spread = f64::from(shadow.spread);
let shadow_rect = rect.inflate(spread, spread).with_origin(Point::new(
rect.x0 - spread + f64::from(shadow.dx),
rect.y0 - spread + f64::from(shadow.dy),
));
let radius = (uniform_radius(rect, corners) + spread).max(0.0);
let std_dev = f64::from(shadow.blur * BLUR_TO_STD_DEV);
if std_dev <= 0.0 {
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
shadow.color,
None,
&RoundedRect::from_rect(shadow_rect, radius),
);
} else {
scene.draw_blurred_rounded_rect(
Affine::IDENTITY,
shadow_rect,
shadow.color,
radius,
std_dev,
);
}
}
fn brush_for(paint: &Paint, rect: Rect) -> peniko::Brush {
match paint {
Paint::Solid(color) => (*color).into(),
Paint::LinearGradient { angle_deg, stops } => {
let theta = f64::from(*angle_deg).to_radians();
let (sin, cos) = theta.sin_cos();
let half_len = 0.5 * (rect.width() * sin.abs() + rect.height() * cos.abs());
let center = rect.center();
let dir = Vec2::new(sin, -cos);
Gradient::new_linear(center - dir * half_len, center + dir * half_len)
.with_stops(color_stops(stops))
.into()
}
Paint::RadialGradient {
center,
radius,
stops,
} => {
let c = Point::new(
rect.x0 + f64::from(center.0) * rect.width(),
rect.y0 + f64::from(center.1) * rect.height(),
);
let r = f64::from(*radius) * 0.5 * rect.width().max(rect.height());
Gradient::new_radial(c, r as f32)
.with_stops(color_stops(stops))
.into()
}
}
}
fn color_stops(stops: &[crate::style::GradientStop]) -> ColorStops {
ColorStops(
stops
.iter()
.map(|s| ColorStop::from((s.offset, s.color)))
.collect(),
)
}
fn snap(v: f64, scale: f64) -> f64 {
(v * scale).round() / scale
}
fn snap_hairline_rect(rect: Rect, scale: f64) -> Rect {
let mut r = rect;
if rect.height() * scale < 1.75 {
let h = (rect.height() * scale).round().max(1.0) / scale;
r.y0 = snap(rect.y0, scale);
r.y1 = r.y0 + h;
}
if rect.width() * scale < 1.75 {
let w = (rect.width() * scale).round().max(1.0) / scale;
r.x0 = snap(rect.x0, scale);
r.x1 = r.x0 + w;
}
r
}
pub(crate) fn fill_rounded(scene: &mut Scene, rect: Rect, radius: f32, color: peniko::Color) {
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
color,
None,
&rounded_rect(rect, CornerRadius::all(radius)),
);
}
pub(crate) fn push_box(
scene: &mut Scene,
style: &Style,
rect: Rect,
canvas: Rect,
scale: f64,
) -> usize {
let mut layers = 0;
if style.opacity < 1.0 {
scene.push_layer(
Fill::NonZero,
peniko::Mix::Normal,
style.opacity.clamp(0.0, 1.0),
Affine::IDENTITY,
&rounded_rect(canvas, CornerRadius::default()),
);
layers += 1;
}
for shadow in &style.shadows {
shadow_layer(scene, rect, style.corner_radius, shadow);
}
let path = corner_path(rect, style.corner_radius, style.corner_smoothing);
if let Some(paint) = &style.fill {
let fill_rect = snap_hairline_rect(rect, scale);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
&brush_for(paint, fill_rect),
None,
&corner_path(fill_rect, style.corner_radius, style.corner_smoothing),
);
}
if let Some(border) = style.border
&& border.width > 0.0
{
let width = (f64::from(border.width) * scale).round().max(1.0) / scale;
let half = width * 0.5;
let snapped = Rect::new(
snap(rect.x0, scale),
snap(rect.y0, scale),
snap(rect.x1, scale),
snap(rect.y1, scale),
);
let inset_rect = snapped.inset(-half);
let mut corners = style.corner_radius;
for r in [
&mut corners.tl,
&mut corners.tr,
&mut corners.br,
&mut corners.bl,
] {
#[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
{
*r = (*r - half as f32).max(0.0);
}
}
scene.stroke(
&Stroke::new(width),
Affine::IDENTITY,
border.color,
None,
&corner_path(inset_rect, corners, style.corner_smoothing),
);
}
if let Some(highlight) = style.highlight_top
&& highlight.components[3] > 0.0
{
let h = 1.0 / scale;
let top = snap(rect.y0, scale);
let bar = Rect::new(rect.x0, top, rect.x1, top + h);
scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &path);
scene.fill(Fill::NonZero, Affine::IDENTITY, highlight, None, &bar);
scene.pop_layer();
}
if style.clip {
scene.push_clip_layer(Fill::NonZero, Affine::IDENTITY, &path);
layers += 1;
}
layers
}
pub(crate) fn pop_box(scene: &mut Scene, layers: usize) {
for _ in 0..layers {
scene.pop_layer();
}
}
pub(crate) fn draw_image(
scene: &mut Scene,
image: &peniko::ImageData,
rect: Rect,
corners: CornerRadius,
) {
if image.width == 0 || image.height == 0 || rect.width() <= 0.0 || rect.height() <= 0.0 {
return;
}
let transform = Affine::translate((rect.x0, rect.y0))
* Affine::scale_non_uniform(
rect.width() / f64::from(image.width),
rect.height() / f64::from(image.height),
);
scene.push_clip_layer(
Fill::NonZero,
Affine::IDENTITY,
&rounded_rect(rect, corners),
);
scene.draw_image(image, transform);
scene.pop_layer();
}
pub(crate) fn focus_ring(scene: &mut Scene, rect: Rect, corners: CornerRadius, color: Color) {
let offset = f64::from(FOCUS_RING.offset) + f64::from(FOCUS_RING.width) * 0.5;
let ring_rect = rect.inflate(offset, offset);
let mut ring_corners = corners;
for r in [
&mut ring_corners.tl,
&mut ring_corners.tr,
&mut ring_corners.br,
&mut ring_corners.bl,
] {
*r += FOCUS_RING.offset;
}
scene.stroke(
&Stroke::new(f64::from(FOCUS_RING.width)),
Affine::IDENTITY,
color,
None,
&rounded_rect(ring_rect, ring_corners),
);
}
pub(crate) fn draw_path_rotated(
scene: &mut Scene,
data: &PathData,
trim: f32,
color: Color,
rect: Rect,
rotation: f64,
) {
if trim <= 0.0 {
return;
}
let sx = rect.width() / data.viewbox.0.max(1e-6);
let sy = rect.height() / data.viewbox.1.max(1e-6);
let rotate = if rotation == 0.0 {
Affine::IDENTITY
} else {
Affine::rotate_about(rotation, rect.center())
};
let transform =
rotate * Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(sx, sy);
let trimmed;
let path: &BezPath = if trim >= 1.0 {
&data.path
} else {
trimmed = trim_path(&data.path, f64::from(trim));
&trimmed
};
match data.stroke {
Some(width) => {
let stroke = Stroke::new(width)
.with_caps(kurbo::Cap::Round)
.with_join(kurbo::Join::Round);
scene.stroke(&stroke, transform, color, None, path);
}
None => scene.fill(Fill::NonZero, transform, color, None, path),
}
}
fn trim_path(path: &BezPath, t: f64) -> BezPath {
const ACCURACY: f64 = 0.1;
let segments: Vec<kurbo::PathSeg> = path.segments().collect();
let total: f64 = segments.iter().map(|s| s.arclen(ACCURACY)).sum();
let mut budget = total * t.clamp(0.0, 1.0);
let mut out = BezPath::new();
for seg in segments {
let len = seg.arclen(ACCURACY);
if budget <= 0.0 {
break;
}
let piece = if len <= budget {
seg
} else {
seg.subsegment(0.0..(budget / len))
};
let needs_move =
out.elements().is_empty() || piece.start().distance(last_point(&out)) > 1e-6;
if needs_move {
out.move_to(piece.start());
}
match piece {
kurbo::PathSeg::Line(l) => out.line_to(l.p1),
kurbo::PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
}
budget -= len;
}
out
}
fn last_point(path: &BezPath) -> Point {
match path.elements().last() {
Some(kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p)) => *p,
Some(kurbo::PathEl::QuadTo(_, p) | kurbo::PathEl::CurveTo(_, _, p)) => *p,
_ => Point::ORIGIN,
}
}
#[cfg(test)]
mod squircle_tests {
use super::*;
#[test]
fn squircle_exponent_zero_is_circle() {
assert_eq!(squircle_exponent(0.0), 2.0);
assert_eq!(squircle_exponent(1.0), N_MAX);
let mut prev = squircle_exponent(0.0);
for s in [0.1_f32, 0.25, 0.5, 0.75, 1.0] {
let n = squircle_exponent(s);
assert!(
n > prev,
"exponent must increase with smoothing: {prev} -> {n}"
);
prev = n;
}
}
#[test]
fn superellipse_reduces_to_circle_at_n2() {
let c = Point::new(10.0, 10.0);
let (u, v) = (Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0));
let r = 8.0;
for i in 0..=16 {
let theta = (f64::from(i) / 16.0) * std::f64::consts::FRAC_PI_2;
let p = superellipse_point(c, u, v, r, 2.0, theta);
let circle = c + u * (r * theta.cos()) + v * (r * theta.sin());
assert!(
(p - circle).hypot() < 1e-9,
"n=2 must trace the circle at theta={theta}"
);
}
}
#[test]
fn corner_path_zero_smoothing_is_exact_arc() {
let rect = Rect::new(0.0, 0.0, 100.0, 60.0);
let corners = CornerRadius {
tl: 4.0,
tr: 8.0,
br: 12.0,
bl: 16.0,
};
match corner_path(rect, corners, 0.0) {
BoxPath::Arc(r) => assert_eq!(r, rounded_rect(rect, corners)),
BoxPath::Squircle(_) => panic!("zero smoothing must take the exact arc path"),
}
}
#[test]
fn squircle_corner_is_fuller_than_circle() {
let c = Point::ORIGIN;
let (u, v) = (Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0));
let r = 10.0;
let theta = std::f64::consts::FRAC_PI_4;
let circle = superellipse_point(c, u, v, r, squircle_exponent(0.0), theta);
assert!(
((circle - c).hypot() - r).abs() < 1e-9,
"circle bisector sits exactly r from center"
);
let squircle = superellipse_point(c, u, v, r, squircle_exponent(0.6), theta);
assert!(
(squircle - c).hypot() > r + 1e-6,
"squircle bisector must push past r toward the geometric corner"
);
}
}