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}