valo_dl/paint.rs
1use std::sync::Arc;
2
3use valo_geometry::{Color, Matrix, Point, Rect, Stroke};
4
5use crate::{Bounds, TileMode};
6
7/// `BlendMode` controls how source pixels combine with destination pixels.
8///
9/// [`BlendMode::SrcOver`] is the default. Advanced modes that read destination
10/// pixels may require an additional render-pass break and snapshot.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum BlendMode {
14 Clear,
15 Src,
16 Dst,
17 #[default]
18 SrcOver,
19 DstOver,
20 SrcIn,
21 DstIn,
22 SrcOut,
23 DstOut,
24 SrcAtop,
25 DstAtop,
26 Xor,
27 /// `Plus` adds source and destination, clamped at one: Core Graphics'
28 /// `kCGBlendModePlusLighter` and Core Animation's `plusL` filter.
29 ///
30 /// Over premultiplied colour it is `R = min(1, S + D)`, alpha included
31 /// (`CGContext.h`); Core Image's `_plusLighter` kernel is `src + dst`, so
32 /// the clamp is the target's own and a float target keeps the sum, as
33 /// the blend unit does here.
34 Plus,
35 Modulate,
36 Screen,
37 // Destination-reading advanced modes require a target snapshot.
38 Overlay,
39 Darken,
40 Lighten,
41 ColorDodge,
42 ColorBurn,
43 HardLight,
44 SoftLight,
45 Difference,
46 Exclusion,
47 Multiply,
48 Hue,
49 Saturation,
50 Color,
51 Luminosity,
52 /// `PlusDarker` adds source and destination toward black: Core
53 /// Graphics' `kCGBlendModePlusDarker`, Core Animation's `plusD` filter
54 /// and Core Image's `CIPlusDarkerCompositing`. It is not one of
55 /// Flutter's modes.
56 ///
57 /// Over premultiplied colour it is Core Image's `_plusDarker` kernel, as
58 /// CoreImage ships it in macOS 27: `Ra = min(1, Sa + Da)` and
59 /// `Rc = Ra − min(1, (Sa − Sc) + (Da − Dc))`, which for opaque colours
60 /// is `R = max(0, 1 − ((1 − D) + (1 − S)))` (the comment in
61 /// `CGContext.h` drops the outer `1 −`; Core Graphics draws the kernel's
62 /// values). A transparent source leaves the destination as it is, and
63 /// over a transparent destination the source shows unchanged. It reads
64 /// the destination, so like `Overlay` it breaks the render pass.
65 PlusDarker,
66}
67
68impl BlendMode {
69 /// `is_destructive` reports whether transparent source pixels can change
70 /// destination pixels outside the source ink.
71 pub fn is_destructive(self) -> bool {
72 matches!(
73 self,
74 BlendMode::Clear
75 | BlendMode::Src
76 | BlendMode::SrcIn
77 | BlendMode::DstIn
78 | BlendMode::SrcOut
79 | BlendMode::DstOut
80 | BlendMode::DstAtop
81 | BlendMode::Xor
82 | BlendMode::Modulate
83 )
84 }
85}
86
87/// `BlurStyle` controls where blurred coverage appears relative to a shape.
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90pub enum BlurStyle {
91 /// `Normal` blurs coverage inside and outside the shape.
92 #[default]
93 Normal,
94 /// `Solid` keeps a sharp interior and blurs outside.
95 Solid,
96 /// `Inner` blurs inside and leaves the exterior empty.
97 Inner,
98 /// `Outer` blurs outside and leaves the interior empty.
99 Outer,
100}
101
102/// `MaskBlur` applies a Gaussian blur to a draw's coverage mask.
103///
104/// Sigma is measured in local units and follows the draw's transform.
105#[derive(Clone, Copy, Debug, PartialEq)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize))]
107pub struct MaskBlur {
108 /// `sigma` is the nonnegative Gaussian standard deviation in local units.
109 pub sigma: f32,
110 /// `style` controls which side of the original coverage remains visible.
111 pub style: BlurStyle,
112}
113
114impl MaskBlur {
115 /// `new` creates a normal mask blur.
116 pub fn new(sigma: f32) -> Self {
117 Self::styled(sigma, BlurStyle::Normal)
118 }
119
120 /// `solid` creates a blur with a sharp interior.
121 pub fn solid(sigma: f32) -> Self {
122 Self::styled(sigma, BlurStyle::Solid)
123 }
124
125 /// `inner` creates a blur visible only inside the shape.
126 pub fn inner(sigma: f32) -> Self {
127 Self::styled(sigma, BlurStyle::Inner)
128 }
129
130 /// `outer` creates a blur visible only outside the shape.
131 pub fn outer(sigma: f32) -> Self {
132 Self::styled(sigma, BlurStyle::Outer)
133 }
134
135 /// `styled` clamps sigma to keep effect bounds from shrinking.
136 fn styled(sigma: f32, style: BlurStyle) -> Self {
137 Self {
138 sigma: sigma.max(0.0),
139 style,
140 }
141 }
142}
143
144/// `ColorFilter` transforms the pixels produced by a draw or layer.
145///
146/// Color filters run before mask blur, so the blur spreads the filtered result.
147#[derive(Clone, Copy, Debug, PartialEq)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize))]
149pub enum ColorFilter {
150 /// `Matrix` is a row-major 4×5 transform over straight color in 0..1.
151 ///
152 /// Each output
153 /// channel is `row · [r, g, b, a, 1]`, clamped. Skia's `SkColorMatrix`
154 /// convention.
155 ///
156 /// Flutter's `ColorFilter.matrix` hands the translation column in
157 /// unnormalized 0..255 space instead, so a Flutter matrix needs entries
158 /// 4, 9, 14 and 19 divided by 255 before it arrives here. Getting that
159 /// wrong still produces a plausible-looking image, which is why it is
160 /// called out rather than absorbed.
161 Matrix([f32; 20]),
162 /// `Blend` composites a constant source color over each produced pixel.
163 Blend(Color, BlendMode),
164 /// `LinearToSrgbGamma` encodes linear-light values with the sRGB transfer
165 /// curve.
166 ///
167 /// The curve runs on the red, green and blue values as they are stored,
168 /// on straight colour, and leaves alpha alone; no texture changes colour
169 /// space. Flutter's `ColorFilter.linearToSrgbGamma`.
170 LinearToSrgbGamma,
171 /// `SrgbToLinearGamma` decodes sRGB-encoded values to linear light, the
172 /// inverse of [`ColorFilter::LinearToSrgbGamma`].
173 ///
174 /// Decoded values stored in an 8-bit target keep little precision in the
175 /// shadows, so a decode, blur, encode chain bands in dark areas.
176 /// Flutter's `ColorFilter.srgbToLinearGamma`.
177 SrgbToLinearGamma,
178}
179
180impl ColorFilter {
181 /// `folded_into` applies this filter to one solid color on the CPU.
182 pub fn folded_into(&self, color: Color) -> Color {
183 crate::color_filter::apply(*self, color)
184 }
185
186 /// `modifies_transparent_black` reports whether this filter can create
187 /// visible output from a transparent input pixel.
188 pub fn modifies_transparent_black(&self) -> bool {
189 self.folded_into(Color::TRANSPARENT).a > 0.0
190 }
191}
192
193/// `ImageFilter` transforms a rasterized draw or layer.
194///
195/// In a composition, the inner filter runs first and feeds the outer filter.
196#[derive(Clone, Debug, PartialEq)]
197#[cfg_attr(feature = "serde", derive(serde::Serialize))]
198pub enum ImageFilter {
199 /// `Blur` applies a Gaussian blur in local x and y units.
200 Blur {
201 /// `sigma_x` is the horizontal standard deviation.
202 sigma_x: f32,
203 /// `sigma_y` is the vertical standard deviation.
204 sigma_y: f32,
205 /// `bounds` limits what the blur reads to a rectangle in local units,
206 /// placed by the transform where the filter applies, so a rotation
207 /// turns it with the content.
208 ///
209 /// Pixels outside it count as transparent, and each result is divided
210 /// by its own alpha: content near the edge stays opaque instead of
211 /// fading or taking in colour from beyond the edge, and translucent
212 /// content comes out opaque. The output is not clipped; it spreads
213 /// past the bounds as far as an unbounded blur's would, so pair it
214 /// with a clip. `None` blurs everything. Flutter's
215 /// `ImageFilter.blur(bounds:)`.
216 bounds: Option<Rect>,
217 /// `tile_mode` is how the blur reads past the edge of what it blurs:
218 /// transparent ([`TileMode::Decal`]), the edge pixels extended, or
219 /// the picture mirrored or repeated.
220 ///
221 /// `None` leaves it to where the filter is used, as dart:ui does for
222 /// an `ImageFilter.blur` without a `tileMode`: decal on a layer and
223 /// on most draws, clamp on an image draw and inside a composition,
224 /// mirror for a backdrop.
225 tile_mode: Option<TileMode>,
226 },
227 /// `Color` applies a color filter after rasterization.
228 Color(ColorFilter),
229 /// `DropShadow` composites the input over a blurred, colored copy of its alpha.
230 DropShadow {
231 /// `offset` moves the shadow in local coordinates.
232 offset: Point,
233 /// `sigma_x` is the horizontal standard deviation.
234 sigma_x: f32,
235 /// `sigma_y` is the vertical standard deviation.
236 sigma_y: f32,
237 /// `color` colors the shadow.
238 color: Color,
239 },
240 /// `Compose` applies `inner` and then `outer`.
241 Compose {
242 /// `outer` receives the filtered result of `inner`.
243 outer: Arc<ImageFilter>,
244 /// `inner` receives the original input.
245 inner: Arc<ImageFilter>,
246 },
247 /// `Runtime` shades what the input covers with a runtime filter's `shade`,
248 /// its `source` the input prepared as the effect's
249 /// [`FilterInputs`](crate::FilterInputs) ask. The effect is made by
250 /// [`RuntimeEffect::filter`](crate::RuntimeEffect::filter); see
251 /// [`ImageFilter::runtime`].
252 Runtime(crate::RuntimeShader),
253}
254
255impl ImageFilter {
256 /// `blur` creates a Gaussian image filter with nonnegative sigmas, its
257 /// tile mode left to where it is used.
258 pub fn blur(sigma_x: f32, sigma_y: f32) -> Self {
259 Self::Blur {
260 sigma_x: sigma_x.max(0.0),
261 sigma_y: sigma_y.max(0.0),
262 bounds: None,
263 tile_mode: None,
264 }
265 }
266
267 /// `bounded_blur` creates a Gaussian image filter that reads only inside
268 /// `bounds`, a rectangle in local units.
269 ///
270 /// It is the frosted-glass blur: colour from beyond the bounds does not
271 /// bleed in at the edges. See the `bounds` field of [`ImageFilter::Blur`].
272 pub fn bounded_blur(sigma_x: f32, sigma_y: f32, bounds: Rect) -> Self {
273 Self::Blur {
274 sigma_x: sigma_x.max(0.0),
275 sigma_y: sigma_y.max(0.0),
276 bounds: Some(bounds),
277 tile_mode: None,
278 }
279 }
280
281 /// `with_tile_mode` sets a blur's tile mode, the way it reads past the
282 /// edge of what it blurs; any other filter is returned as it is. See the
283 /// `tile_mode` field of [`ImageFilter::Blur`].
284 pub fn with_tile_mode(self, tile_mode: TileMode) -> Self {
285 match self {
286 Self::Blur {
287 sigma_x,
288 sigma_y,
289 bounds,
290 ..
291 } => Self::Blur {
292 sigma_x,
293 sigma_y,
294 bounds,
295 tile_mode: Some(tile_mode),
296 },
297 other => other,
298 }
299 }
300
301 /// `color` creates an image filter from a color filter.
302 pub fn color(filter: ColorFilter) -> Self {
303 Self::Color(filter)
304 }
305
306 /// `compose` applies `inner` first and `outer` second.
307 pub fn compose(outer: ImageFilter, inner: ImageFilter) -> Self {
308 Self::Compose {
309 outer: Arc::new(outer),
310 inner: Arc::new(inner),
311 }
312 }
313
314 /// `runtime` creates a filter from a runtime filter effect with one
315 /// draw's inputs; a paint shader's effect is refused.
316 pub fn runtime(shader: crate::RuntimeShader) -> Result<Self, crate::EffectError> {
317 match shader.effect.kind() {
318 crate::EffectKind::Filter(_) => Ok(Self::Runtime(shader)),
319 crate::EffectKind::Shader => Err(crate::EffectError::Contract(
320 "an image filter's effect is made by `RuntimeEffect::filter`".to_owned(),
321 )),
322 }
323 }
324
325 /// `drop_shadow` creates a shadow that retains the original input.
326 pub fn drop_shadow(offset: Point, sigma_x: f32, sigma_y: f32, color: Color) -> Self {
327 Self::DropShadow {
328 offset,
329 sigma_x: sigma_x.max(0.0),
330 sigma_y: sigma_y.max(0.0),
331 color,
332 }
333 }
334
335 /// `is_nop` reports whether the filter leaves every input pixel unchanged.
336 pub fn is_nop(&self) -> bool {
337 match self {
338 Self::Blur {
339 sigma_x, sigma_y, ..
340 } => *sigma_x <= 0.0 && *sigma_y <= 0.0,
341 Self::Color(_) => false,
342 // An invisible shadow leaves the input exactly as it found it.
343 Self::DropShadow { color, .. } => color.a <= 0.0,
344 Self::Compose { outer, inner } => outer.is_nop() && inner.is_nop(),
345 Self::Runtime(_) => false,
346 }
347 }
348
349 /// `padding` returns conservative local x and y expansion for this filter.
350 pub fn padding(&self) -> [f32; 2] {
351 match self {
352 // The bounds limit what the blur reads, not how far it spreads.
353 Self::Blur {
354 sigma_x, sigma_y, ..
355 } => [(sigma_x * 3.0).ceil(), (sigma_y * 3.0).ceil()],
356 Self::Color(_) => [0.0; 2],
357 // Padding is symmetric, so a one-sided offset has to be paid on
358 // both sides — the shadow is free to land on either.
359 Self::DropShadow {
360 offset,
361 sigma_x,
362 sigma_y,
363 ..
364 } => [
365 (sigma_x * 3.0).ceil() + offset.x.abs(),
366 (sigma_y * 3.0).ceil() + offset.y.abs(),
367 ],
368 Self::Compose { outer, inner } => {
369 let outer = outer.padding();
370 let inner = inner.padding();
371 [outer[0] + inner[0], outer[1] + inner[1]]
372 }
373 // It shades what its input covers; its margin is what it reads.
374 Self::Runtime(_) => [0.0; 2],
375 }
376 }
377
378 /// `modifies_transparent_black` reports whether this filter can create
379 /// visible output from a transparent input pixel.
380 pub fn modifies_transparent_black(&self) -> bool {
381 match self {
382 Self::Blur { .. } => false,
383 Self::Color(filter) => filter.modifies_transparent_black(),
384 // The shadow is the input's own alpha recoloured, so transparent
385 // input stays transparent however opaque the shadow colour is.
386 Self::DropShadow { .. } => false,
387 Self::Compose { outer, inner } => {
388 outer.modifies_transparent_black() || inner.modifies_transparent_black()
389 }
390 // Flutter's `DlRuntimeEffectImageFilter`: it shades only what its
391 // input covers.
392 Self::Runtime(_) => false,
393 }
394 }
395}
396
397/// `PaintStyle` selects filled geometry or a stroked outline.
398#[derive(Clone, Debug, Default, PartialEq)]
399#[cfg_attr(feature = "serde", derive(serde::Serialize))]
400pub enum PaintStyle {
401 /// `Fill` covers the geometry's interior.
402 #[default]
403 Fill,
404 /// `Stroke` draws the geometry's outline with the supplied stroke parameters.
405 Stroke(Stroke),
406}
407
408/// `Paint` describes how a drawing operation produces and composites pixels.
409///
410/// The default is an opaque black fill using [`BlendMode::SrcOver`].
411#[derive(Clone, Debug, PartialEq)]
412#[cfg_attr(feature = "serde", derive(serde::Serialize))]
413pub struct Paint {
414 /// `color` supplies solid-draw color and the alpha for shader or image draws.
415 ///
416 /// Shader and image draws ignore its RGB channels.
417 pub color: Color,
418 /// `blend_mode` controls compositing with destination pixels.
419 pub blend_mode: BlendMode,
420 /// `shader` replaces the solid color with a per-pixel source.
421 pub shader: Option<crate::Shader>,
422 /// `mask_blur` softens the draw's coverage. A save layer's paint takes
423 /// none: the layer is recorded without it.
424 pub mask_blur: Option<MaskBlur>,
425 /// `color_filter` transforms produced colors before mask blur.
426 pub color_filter: Option<ColorFilter>,
427 /// `image_filter` transforms the rasterized draw or layer.
428 pub image_filter: Option<ImageFilter>,
429 /// `style` selects fill or stroke rendering.
430 pub style: PaintStyle,
431}
432
433impl Default for Paint {
434 fn default() -> Self {
435 Self {
436 color: Color::BLACK,
437 blend_mode: BlendMode::SrcOver,
438 shader: None,
439 mask_blur: None,
440 color_filter: None,
441 image_filter: None,
442 style: PaintStyle::Fill,
443 }
444 }
445}
446
447impl Paint {
448 /// `from_color` creates a solid-color fill paint.
449 pub fn from_color(color: Color) -> Self {
450 Self {
451 color,
452 ..Default::default()
453 }
454 }
455
456 /// `from_shader` creates a fill paint using a per-pixel shader.
457 pub fn from_shader(shader: crate::Shader) -> Self {
458 Self {
459 color: Color::WHITE,
460 shader: Some(shader),
461 ..Default::default()
462 }
463 }
464
465 /// `is_nop` reports whether this paint can produce no visible change.
466 pub fn is_nop(&self) -> bool {
467 // Width ZERO is a hairline, not an empty stroke — Skia and Impeller
468 // both draw it one device pixel wide, and the renderer's hairline
469 // floor is what realises that. Only a negative width draws nothing.
470 let empty_stroke = matches!(&self.style, PaintStyle::Stroke(s) if s.width < 0.0);
471 self.is_invisible() || empty_stroke
472 }
473
474 /// `is_invisible` reports whether what this paint composites shows
475 /// nowhere, whatever it draws: a transparent colour blended `SrcOver`
476 /// with no filter to colour it. A save layer with such a paint records
477 /// nothing inside (Flutter's `PaintResult`).
478 pub(crate) fn is_invisible(&self) -> bool {
479 self.color.a <= 0.0 && self.blend_mode == BlendMode::SrcOver && !self.reveals_transparent()
480 }
481
482 /// `takes_group_opacity` reports whether an enclosing group's opacity
483 /// can be applied to what this paint draws instead of to the group. It
484 /// needs the paint to blend `SrcOver`, and no colour or image filter,
485 /// which would take the alpha in before it filters. Flutter's display
486 /// list asks the same of every op in a group before the group's opacity
487 /// may ride its children (`UpdateCurrentOpacityCompatibility`).
488 pub(crate) fn takes_group_opacity(&self) -> bool {
489 self.blend_mode == BlendMode::SrcOver
490 && self.color_filter.is_none()
491 && self.effective_image_filter().is_none()
492 }
493
494 /// `effective_image_filter` returns the image filter when it changes pixels.
495 pub fn effective_image_filter(&self) -> Option<&ImageFilter> {
496 self.image_filter.as_ref().filter(|f| !f.is_nop())
497 }
498
499 /// `mask_padding` returns conservative local padding for the mask blur.
500 pub fn mask_padding(&self) -> f32 {
501 self.mask_blur.map_or(0.0, |blur| (blur.sigma * 3.0).ceil())
502 }
503
504 /// `effect_padding_axes` returns local x and y padding for raster effects.
505 pub fn effect_padding_axes(&self) -> [f32; 2] {
506 let image = self
507 .image_filter
508 .as_ref()
509 .map_or([0.0; 2], ImageFilter::padding);
510 let mask = self.mask_padding();
511 [image[0] + mask, image[1] + mask]
512 }
513
514 /// `effect_padding` returns the largest local-axis padding for raster effects.
515 pub fn effect_padding(&self) -> f32 {
516 let axes = self.effect_padding_axes();
517 axes[0].max(axes[1])
518 }
519
520 /// `device_effect_padding` returns effect padding in device pixels.
521 ///
522 /// It maps both local padding axes through `transform`, preserving a
523 /// conservative bound under rotation and shear.
524 pub fn device_effect_padding(&self, transform: &Matrix) -> f32 {
525 let [x, y] = self.effect_padding_axes();
526 if x <= 0.0 && y <= 0.0 {
527 return 0.0;
528 }
529 // The half-extent of an axis-aligned box under a linear map is the
530 // component-wise absolute matrix applied to the half-extent.
531 let [a, b, c, d, ..] = transform.to_affine();
532 let device_x = (x * a).abs() + (y * c).abs();
533 let device_y = (x * b).abs() + (y * d).abs();
534 device_x.max(device_y)
535 }
536
537 /// `effect_bounds` returns where a draw of `bounds` with this paint may
538 /// show, in local coordinates, its effects included.
539 ///
540 /// A draw whose paint fills its clip ([`Paint::draw_fills_clip`]) is
541 /// unbounded: only its clip bounds it.
542 pub fn effect_bounds(&self, bounds: Rect) -> Bounds {
543 if self.draw_fills_clip() {
544 Bounds::Unbounded
545 } else {
546 Bounds::of(bounds.expand(self.effect_padding()))
547 }
548 }
549
550 /// `draw_fills_clip` reports whether a draw with this paint covers its
551 /// whole clip, whatever its shape: its image filter colours transparent
552 /// pixels, which then show everywhere the clip lets them, as Skia draws
553 /// it.
554 ///
555 /// A colour filter does not: it stays inside the shape it colours
556 /// (Flutter's `AdjustBoundsForPaint`). On a save layer either filter, or
557 /// a destructive blend, fills the clip instead (`Op::SaveLayer`'s
558 /// `floods_clip`).
559 pub fn draw_fills_clip(&self) -> bool {
560 self.image_filter
561 .as_ref()
562 .is_some_and(ImageFilter::modifies_transparent_black)
563 }
564
565 /// `reveals_transparent` reports whether the paint's colour or image
566 /// filter colours transparent pixels, so a transparent colour still
567 /// paints something.
568 pub(crate) fn reveals_transparent(&self) -> bool {
569 self.color_filter
570 .is_some_and(|filter| filter.modifies_transparent_black())
571 || self.draw_fills_clip()
572 }
573
574 /// `stroke_padding` returns conservative stroke expansion at unit scale.
575 pub fn stroke_padding(&self) -> f32 {
576 self.stroke_padding_at_scale(1.0)
577 }
578
579 /// `stroke_padding_at_scale` returns stroke expansion at a device scale.
580 ///
581 /// Hairlines and minified strokes retain at least one device pixel.
582 pub fn stroke_padding_at_scale(&self, scale: f32) -> f32 {
583 match &self.style {
584 PaintStyle::Fill => 0.0,
585 PaintStyle::Stroke(s) => {
586 let spike = match s.join {
587 valo_geometry::Join::Miter => s.miter_limit.max(1.5),
588 _ => 1.5,
589 };
590 let effective_width = s.width.max(1.0 / scale.max(1e-3));
591 effective_width * 0.5 * spike
592 }
593 }
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::{ColorFilter, ImageFilter, MaskBlur, Paint, PaintStyle};
600 use valo_geometry::Stroke;
601
602 #[test]
603 fn hairline_padding_stays_large_enough_when_minified() {
604 let paint = Paint {
605 style: PaintStyle::Stroke(Stroke::new(0.0)),
606 ..Paint::default()
607 };
608 let scale = 0.1;
609 let device_padding = paint.stroke_padding_at_scale(scale) * scale;
610 assert!(device_padding >= 0.5);
611 }
612
613 #[test]
614 fn composed_image_filters_accumulate_blur_coverage() {
615 let filter = ImageFilter::compose(
616 ImageFilter::blur(3.0, 4.0),
617 ImageFilter::compose(
618 ImageFilter::color(ColorFilter::Matrix([0.0; 20])),
619 ImageFilter::blur(2.0, 1.0),
620 ),
621 );
622 assert_eq!(filter.padding(), [15.0, 15.0]);
623 }
624
625 #[test]
626 fn drop_shadow_padding_covers_the_offset_on_both_sides() {
627 let filter = ImageFilter::drop_shadow(
628 valo_geometry::Point::new(4.0, -6.0),
629 2.0,
630 1.0,
631 valo_geometry::Color::BLACK,
632 );
633 assert_eq!(filter.padding(), [10.0, 9.0]);
634 }
635
636 // A rotation reaches further than any axis length reports: `max_scale`
637 // is 1 for a pure rotation, so scalar padding would clip this shadow.
638 #[test]
639 fn device_padding_bounds_a_rotated_effect() {
640 use valo_geometry::Matrix;
641 let paint = Paint {
642 image_filter: Some(ImageFilter::drop_shadow(
643 valo_geometry::Point::new(10.0, 10.0),
644 0.0,
645 0.0,
646 valo_geometry::Color::BLACK,
647 )),
648 ..Paint::default()
649 };
650 assert_eq!(paint.effect_padding(), 10.0);
651
652 let quarter_turn = Matrix::rotation(std::f32::consts::FRAC_PI_4);
653 let padding = paint.device_effect_padding(&quarter_turn);
654 assert!(
655 (padding - 14.142136).abs() < 1e-3,
656 "a 45° rotation maps the (10, 10) padding box to 14.14, got {padding}"
657 );
658 assert!(
659 padding > paint.effect_padding() * quarter_turn.max_scale(),
660 "the scalar bound is exactly what this has to beat"
661 );
662 }
663
664 #[test]
665 fn device_padding_matches_the_scalar_bound_under_a_plain_scale() {
666 use valo_geometry::Matrix;
667 let paint = Paint {
668 mask_blur: Some(MaskBlur::new(2.0)),
669 ..Paint::default()
670 };
671 let scale = Matrix::scale(3.0, 3.0);
672 assert_eq!(paint.effect_padding(), 6.0);
673 assert!((paint.device_effect_padding(&scale) - 18.0).abs() < 1e-4);
674 }
675
676 /// Flutter's rule: a group's opacity rides a `SrcOver` paint with no
677 /// colour filter. valo also keeps it off an image filter, whose colour
678 /// filter would take it in first; a shader or a mask blur takes it after.
679 #[test]
680 fn only_a_source_over_paint_without_filters_takes_group_opacity() {
681 use crate::BlendMode;
682 let takes = |paint: Paint| paint.takes_group_opacity();
683 assert!(takes(Paint::default()));
684 assert!(takes(Paint {
685 mask_blur: Some(MaskBlur::new(2.0)),
686 ..Paint::default()
687 }));
688 assert!(takes(Paint::from_shader(crate::Shader::linear(
689 valo_geometry::Point::new(0.0, 0.0),
690 valo_geometry::Point::new(1.0, 0.0),
691 valo_geometry::Color::BLACK,
692 valo_geometry::Color::WHITE,
693 ))));
694 assert!(!takes(Paint {
695 blend_mode: BlendMode::Plus,
696 ..Paint::default()
697 }));
698 assert!(!takes(Paint {
699 color_filter: Some(ColorFilter::Matrix([0.0; 20])),
700 ..Paint::default()
701 }));
702 assert!(!takes(Paint {
703 image_filter: Some(ImageFilter::blur(2.0, 2.0)),
704 ..Paint::default()
705 }));
706 }
707
708 /// A draw fills its clip only for an image filter that colours
709 /// transparent pixels; a colour filter that does still makes a
710 /// transparent paint paint something.
711 #[test]
712 fn a_draw_fills_its_clip_for_an_image_filter_that_colours_transparent_pixels() {
713 use crate::BlendMode;
714 let fill_red = ColorFilter::Blend(valo_geometry::Color::rgb(1.0, 0.0, 0.0), BlendMode::Src);
715 let transparent = Paint::from_color(valo_geometry::Color::TRANSPARENT);
716 let colour_filtered = Paint {
717 color_filter: Some(fill_red),
718 ..transparent.clone()
719 };
720 let image_filtered = Paint {
721 image_filter: Some(ImageFilter::color(fill_red)),
722 ..transparent.clone()
723 };
724 assert!(transparent.is_nop());
725 assert!(!colour_filtered.draw_fills_clip() && !colour_filtered.is_nop());
726 assert!(image_filtered.draw_fills_clip() && !image_filtered.is_nop());
727 let blurred = Paint {
728 image_filter: Some(ImageFilter::blur(2.0, 2.0)),
729 ..Paint::default()
730 };
731 assert!(!blurred.draw_fills_clip());
732 }
733
734 #[test]
735 fn an_invisible_drop_shadow_is_a_nop() {
736 let filter = ImageFilter::drop_shadow(
737 valo_geometry::Point::new(4.0, 4.0),
738 2.0,
739 2.0,
740 valo_geometry::Color::TRANSPARENT,
741 );
742 assert!(filter.is_nop());
743 assert!(!filter.modifies_transparent_black());
744 }
745}