Skip to main content

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}