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