1use crate::material::{BlurStrength, CornerRadius, Opacity, Tint};
8
9#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct Scale(f32);
18
19impl Scale {
20 pub fn new(factor: f32) -> Self {
22 Self(factor.max(f32::MIN_POSITIVE))
23 }
24
25 pub fn factor(self) -> f32 {
27 self.0
28 }
29}
30
31impl Default for Scale {
32 fn default() -> Self {
34 Self(1.0)
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct Region {
43 pub origin: [u32; 2],
45 pub size: [u32; 2],
47 pub scale: Scale,
49}
50
51impl Region {
52 pub fn clip_to(&self, source_extent: [u32; 2]) -> Option<Region> {
62 let [ox, oy] = self.origin;
63 let [w, h] = self.size;
64 let [ex, ey] = source_extent;
65
66 let x0 = ox.min(ex);
67 let y0 = oy.min(ey);
68 let x1 = ox.saturating_add(w).min(ex);
69 let y1 = oy.saturating_add(h).min(ey);
70
71 let clipped_w = x1.saturating_sub(x0);
72 let clipped_h = y1.saturating_sub(y0);
73
74 if clipped_w == 0 || clipped_h == 0 {
75 None
76 } else {
77 Some(Region {
78 origin: [x0, y0],
79 size: [clipped_w, clipped_h],
80 scale: self.scale,
81 })
82 }
83 }
84}
85
86impl std::fmt::Display for Region {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 let [x, y] = self.origin;
89 let [w, h] = self.size;
90 let scale = self.scale.factor();
91 write!(f, "origin ({x}, {y}), size {w}×{h}, scale {scale}")
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct ResolvedMask {
102 pub half_extents: [f32; 2],
104 pub corner_radius_px: f32,
106}
107
108impl ResolvedMask {
109 pub fn from_target(target: &Region, corner_radius: CornerRadius) -> Self {
114 let half_extents = [target.size[0] as f32 / 2.0, target.size[1] as f32 / 2.0];
115 let max_radius = half_extents[0].min(half_extents[1]);
116 let corner_radius_px =
117 (corner_radius.points() * target.scale.factor()).clamp(0.0, max_radius);
118 Self {
119 half_extents,
120 corner_radius_px,
121 }
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq)]
129pub struct BlurRequest {
130 pub source_region: Region,
132 pub target_rect: Region,
134 pub strength: BlurStrength,
136 pub tint: Tint,
138 pub corner_radius: CornerRadius,
140 pub opacity: Opacity,
142}
143
144impl BlurRequest {
145 pub fn physical_blur_radius(&self) -> f32 {
153 self.strength.to_physical_radius(self.source_region.scale)
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::material::LinearRgba;
161
162 fn close(a: f32, b: f32) -> bool {
163 (a - b).abs() < 1e-4
164 }
165
166 fn region(origin: [u32; 2], size: [u32; 2], scale: f32) -> Region {
167 Region {
168 origin,
169 size,
170 scale: Scale::new(scale),
171 }
172 }
173
174 #[test]
175 fn scale_new_floors_nonpositive_to_positive() {
176 assert!(Scale::new(0.0).factor() > 0.0);
177 assert!(Scale::new(-2.0).factor() > 0.0);
178 assert!(close(Scale::new(2.5).factor(), 2.5));
179 }
180
181 #[test]
182 fn scale_default_is_one() {
183 assert!(close(Scale::default().factor(), 1.0));
184 }
185
186 #[test]
187 fn clip_to_leaves_an_in_bounds_region_unchanged() {
188 let r = region([10, 10], [20, 20], 1.0);
189 assert_eq!(r.clip_to([100, 100]), Some(r));
190 }
191
192 #[test]
193 fn clip_to_clamps_a_partially_offscreen_region() {
194 let r = region([90, 90], [20, 20], 2.0);
196 let clipped = r
197 .clip_to([100, 100])
198 .expect("partial overlap is not a no-op");
199 assert_eq!(clipped.origin, [90, 90]);
200 assert_eq!(clipped.size, [10, 10]);
201 assert!(close(clipped.scale.factor(), 2.0));
203 }
204
205 #[test]
206 fn clip_to_is_none_when_origin_past_extent() {
207 let r = region([100, 0], [10, 10], 1.0);
208 assert_eq!(r.clip_to([100, 100]), None);
209 }
210
211 #[test]
212 fn clip_to_is_none_for_zero_area() {
213 let r = region([0, 0], [0, 10], 1.0);
214 assert_eq!(r.clip_to([100, 100]), None);
215 }
216
217 #[test]
218 fn clip_to_saturates_instead_of_overflowing() {
219 let r = region([u32::MAX - 1, 0], [10, 10], 1.0);
221 assert_eq!(r.clip_to([100, 100]), None);
222 }
223
224 #[test]
225 fn region_display_reads_as_a_sentence_fragment() {
226 let r = region([4, 8], [100, 60], 2.0);
227 assert_eq!(r.to_string(), "origin (4, 8), size 100×60, scale 2");
228 }
229
230 #[test]
231 fn resolved_mask_half_extents_are_half_the_size() {
232 let mask =
233 ResolvedMask::from_target(®ion([0, 0], [80, 40], 1.0), CornerRadius::new(8.0));
234 assert!(close(mask.half_extents[0], 40.0));
235 assert!(close(mask.half_extents[1], 20.0));
236 assert!(close(mask.corner_radius_px, 8.0));
237 }
238
239 #[test]
240 fn resolved_mask_scales_corner_radius_to_physical() {
241 let mask =
243 ResolvedMask::from_target(®ion([0, 0], [200, 200], 2.0), CornerRadius::new(8.0));
244 assert!(close(mask.corner_radius_px, 16.0));
245 }
246
247 #[test]
248 fn resolved_mask_clamps_radius_to_min_half_extent() {
249 let mask =
251 ResolvedMask::from_target(®ion([0, 0], [40, 100], 1.0), CornerRadius::new(999.0));
252 assert!(close(mask.corner_radius_px, 20.0));
253 }
254
255 #[test]
256 fn physical_blur_radius_resolves_against_the_source_scale() {
257 let request = BlurRequest {
259 source_region: region([0, 0], [100, 100], 2.0),
260 target_rect: region([0, 0], [80, 60], 1.0),
261 strength: BlurStrength::new(8.0),
262 tint: Tint::new(LinearRgba::new(0.1, 0.1, 0.12, 0.7)),
263 corner_radius: CornerRadius::new(10.0),
264 opacity: Opacity::default(),
265 };
266 assert!(close(request.physical_blur_radius(), 16.0));
267 }
268
269 #[test]
270 fn blur_request_constructs_by_named_fields() {
271 let r = region([0, 0], [100, 60], 1.0);
273 let request = BlurRequest {
274 source_region: r,
275 target_rect: r,
276 strength: BlurStrength::new(12.0),
277 tint: Tint::new(LinearRgba::new(0.1, 0.1, 0.12, 0.7)),
278 corner_radius: CornerRadius::new(10.0),
279 opacity: Opacity::default(),
280 };
281 assert_eq!(request.strength.points(), 12.0);
282 assert!(close(request.tint.color().a(), 0.7));
283 }
284}