use std::sync::Arc;
use valo_geometry::{Color, Matrix, Point, Rect, Stroke};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BlendMode {
Clear,
Src,
Dst,
#[default]
SrcOver,
DstOver,
SrcIn,
DstIn,
SrcOut,
DstOut,
SrcAtop,
DstAtop,
Xor,
Plus,
Modulate,
Screen,
Overlay,
Darken,
Lighten,
ColorDodge,
ColorBurn,
HardLight,
SoftLight,
Difference,
Exclusion,
Multiply,
Hue,
Saturation,
Color,
Luminosity,
}
#[cfg(test)]
mod tests {
use super::{ColorFilter, ImageFilter, MaskBlur, Paint, PaintStyle};
use valo_geometry::Stroke;
#[test]
fn hairline_padding_stays_large_enough_when_minified() {
let paint = Paint {
style: PaintStyle::Stroke(Stroke::new(0.0)),
..Paint::default()
};
let scale = 0.1;
let device_padding = paint.stroke_padding_at_scale(scale) * scale;
assert!(device_padding >= 0.5);
}
#[test]
fn composed_image_filters_accumulate_blur_coverage() {
let filter = ImageFilter::compose(
ImageFilter::blur(3.0, 4.0),
ImageFilter::compose(
ImageFilter::color(ColorFilter::Matrix([0.0; 20])),
ImageFilter::blur(2.0, 1.0),
),
);
assert_eq!(filter.padding(), [15.0, 15.0]);
}
#[test]
fn drop_shadow_padding_covers_the_offset_on_both_sides() {
let filter = ImageFilter::drop_shadow(
valo_geometry::Point::new(4.0, -6.0),
2.0,
1.0,
valo_geometry::Color::BLACK,
);
assert_eq!(filter.padding(), [10.0, 9.0]);
}
#[test]
fn device_padding_bounds_a_rotated_effect() {
use valo_geometry::Matrix;
let paint = Paint {
image_filter: Some(ImageFilter::drop_shadow(
valo_geometry::Point::new(10.0, 10.0),
0.0,
0.0,
valo_geometry::Color::BLACK,
)),
..Paint::default()
};
assert_eq!(paint.effect_padding(), 10.0);
let quarter_turn = Matrix::rotation(std::f32::consts::FRAC_PI_4);
let padding = paint.device_effect_padding(&quarter_turn);
assert!(
(padding - 14.142136).abs() < 1e-3,
"a 45° rotation maps the (10, 10) padding box to 14.14, got {padding}"
);
assert!(
padding > paint.effect_padding() * quarter_turn.max_scale(),
"the scalar bound is exactly what this has to beat"
);
}
#[test]
fn device_padding_matches_the_scalar_bound_under_a_plain_scale() {
use valo_geometry::Matrix;
let paint = Paint {
mask_blur: Some(MaskBlur::new(2.0)),
..Paint::default()
};
let scale = Matrix::scale(3.0, 3.0);
assert_eq!(paint.effect_padding(), 6.0);
assert!((paint.device_effect_padding(&scale) - 18.0).abs() < 1e-4);
}
#[test]
fn an_invisible_drop_shadow_is_a_nop() {
let filter = ImageFilter::drop_shadow(
valo_geometry::Point::new(4.0, 4.0),
2.0,
2.0,
valo_geometry::Color::TRANSPARENT,
);
assert!(filter.is_nop());
assert!(!filter.modifies_transparent_black());
}
}
impl BlendMode {
pub fn is_destructive(self) -> bool {
matches!(
self,
BlendMode::Clear
| BlendMode::Src
| BlendMode::SrcIn
| BlendMode::DstIn
| BlendMode::SrcOut
| BlendMode::DstOut
| BlendMode::DstAtop
| BlendMode::Xor
| BlendMode::Modulate
)
}
pub fn is_pipeline_blendable(self) -> bool {
!matches!(
self,
BlendMode::Overlay
| BlendMode::Darken
| BlendMode::Lighten
| BlendMode::ColorDodge
| BlendMode::ColorBurn
| BlendMode::HardLight
| BlendMode::SoftLight
| BlendMode::Difference
| BlendMode::Exclusion
| BlendMode::Multiply
| BlendMode::Hue
| BlendMode::Saturation
| BlendMode::Color
| BlendMode::Luminosity
)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BlurStyle {
#[default]
Normal,
Solid,
Inner,
Outer,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct MaskBlur {
pub sigma: f32,
pub style: BlurStyle,
}
impl MaskBlur {
pub fn new(sigma: f32) -> Self {
Self::styled(sigma, BlurStyle::Normal)
}
pub fn solid(sigma: f32) -> Self {
Self::styled(sigma, BlurStyle::Solid)
}
pub fn inner(sigma: f32) -> Self {
Self::styled(sigma, BlurStyle::Inner)
}
pub fn outer(sigma: f32) -> Self {
Self::styled(sigma, BlurStyle::Outer)
}
fn styled(sigma: f32, style: BlurStyle) -> Self {
Self {
sigma: sigma.max(0.0),
style,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum ColorFilter {
Matrix([f32; 20]),
Blend(Color, BlendMode),
}
impl ColorFilter {
pub fn folded_into(&self, color: Color) -> Option<Color> {
Some(crate::color_filter::apply(*self, color))
}
pub fn modifies_transparent_black(&self) -> bool {
self.folded_into(Color::TRANSPARENT)
.is_some_and(|color| color.a > 0.0)
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum ImageFilter {
Blur {
sigma_x: f32,
sigma_y: f32,
},
Color(ColorFilter),
DropShadow {
offset: Point,
sigma_x: f32,
sigma_y: f32,
color: Color,
},
Compose {
outer: Arc<ImageFilter>,
inner: Arc<ImageFilter>,
},
}
impl ImageFilter {
pub fn blur(sigma_x: f32, sigma_y: f32) -> Self {
Self::Blur {
sigma_x: sigma_x.max(0.0),
sigma_y: sigma_y.max(0.0),
}
}
pub fn color(filter: ColorFilter) -> Self {
Self::Color(filter)
}
pub fn compose(outer: ImageFilter, inner: ImageFilter) -> Self {
Self::Compose {
outer: Arc::new(outer),
inner: Arc::new(inner),
}
}
pub fn drop_shadow(offset: Point, sigma_x: f32, sigma_y: f32, color: Color) -> Self {
Self::DropShadow {
offset,
sigma_x: sigma_x.max(0.0),
sigma_y: sigma_y.max(0.0),
color,
}
}
pub fn is_nop(&self) -> bool {
match self {
Self::Blur { sigma_x, sigma_y } => *sigma_x <= 0.0 && *sigma_y <= 0.0,
Self::Color(_) => false,
Self::DropShadow { color, .. } => color.a <= 0.0,
Self::Compose { outer, inner } => outer.is_nop() && inner.is_nop(),
}
}
pub fn padding(&self) -> [f32; 2] {
match self {
Self::Blur { sigma_x, sigma_y } => [(sigma_x * 3.0).ceil(), (sigma_y * 3.0).ceil()],
Self::Color(_) => [0.0; 2],
Self::DropShadow {
offset,
sigma_x,
sigma_y,
..
} => [
(sigma_x * 3.0).ceil() + offset.x.abs(),
(sigma_y * 3.0).ceil() + offset.y.abs(),
],
Self::Compose { outer, inner } => {
let outer = outer.padding();
let inner = inner.padding();
[outer[0] + inner[0], outer[1] + inner[1]]
}
}
}
pub fn modifies_transparent_black(&self) -> bool {
match self {
Self::Blur { .. } => false,
Self::Color(filter) => filter.modifies_transparent_black(),
Self::DropShadow { .. } => false,
Self::Compose { outer, inner } => {
outer.modifies_transparent_black() || inner.modifies_transparent_black()
}
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum PaintStyle {
#[default]
Fill,
Stroke(Stroke),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Paint {
pub color: Color,
pub blend_mode: BlendMode,
pub shader: Option<crate::Shader>,
pub mask_blur: Option<MaskBlur>,
pub color_filter: Option<ColorFilter>,
pub image_filter: Option<ImageFilter>,
pub style: PaintStyle,
}
impl Default for Paint {
fn default() -> Self {
Self {
color: Color::BLACK,
blend_mode: BlendMode::SrcOver,
shader: None,
mask_blur: None,
color_filter: None,
image_filter: None,
style: PaintStyle::Fill,
}
}
}
impl Paint {
pub fn from_color(color: Color) -> Self {
Self {
color,
..Default::default()
}
}
pub fn from_shader(shader: crate::Shader) -> Self {
Self {
color: Color::WHITE,
shader: Some(shader),
..Default::default()
}
}
pub fn is_nop(&self) -> bool {
let filter_keeps_transparent = self
.color_filter
.is_none_or(|filter| !filter.modifies_transparent_black())
&& self
.image_filter
.as_ref()
.is_none_or(|filter| !filter.modifies_transparent_black());
let invisible = self.color.a <= 0.0
&& self.blend_mode == BlendMode::SrcOver
&& filter_keeps_transparent;
let empty_stroke = matches!(&self.style, PaintStyle::Stroke(s) if s.width < 0.0);
invisible || empty_stroke
}
pub fn is_opacity_only(&self) -> bool {
self.blend_mode == BlendMode::SrcOver
&& self.shader.is_none()
&& self.mask_blur.is_none()
&& self.color_filter.is_none()
&& self.effective_image_filter().is_none()
}
pub fn effective_image_filter(&self) -> Option<&ImageFilter> {
self.image_filter.as_ref().filter(|f| !f.is_nop())
}
pub fn mask_padding(&self) -> f32 {
self.mask_blur.map_or(0.0, |blur| (blur.sigma * 3.0).ceil())
}
pub fn effect_padding_axes(&self) -> [f32; 2] {
let image = self
.image_filter
.as_ref()
.map_or([0.0; 2], ImageFilter::padding);
let mask = self.mask_padding();
[image[0] + mask, image[1] + mask]
}
pub fn effect_padding(&self) -> f32 {
let axes = self.effect_padding_axes();
axes[0].max(axes[1])
}
pub fn device_effect_padding(&self, transform: &Matrix) -> f32 {
let [x, y] = self.effect_padding_axes();
if x <= 0.0 && y <= 0.0 {
return 0.0;
}
let [a, b, c, d, ..] = transform.to_affine();
let device_x = (x * a).abs() + (y * c).abs();
let device_y = (x * b).abs() + (y * d).abs();
device_x.max(device_y)
}
pub fn effect_bounds(&self, bounds: Rect) -> Rect {
let floods = self
.color_filter
.is_some_and(|filter| filter.modifies_transparent_black())
|| self
.image_filter
.as_ref()
.is_some_and(|filter| filter.modifies_transparent_black());
if floods {
Rect::EVERYTHING
} else {
bounds.expand(self.effect_padding())
}
}
pub fn stroke_padding(&self) -> f32 {
self.stroke_padding_at_scale(1.0)
}
pub fn stroke_padding_at_scale(&self, scale: f32) -> f32 {
match &self.style {
PaintStyle::Fill => 0.0,
PaintStyle::Stroke(s) => {
let spike = match s.join {
valo_geometry::Join::Miter => s.miter_limit.max(1.5),
_ => 1.5,
};
let effective_width = s.width.max(1.0 / scale.max(1e-3));
effective_width * 0.5 * spike
}
}
}
}