Skip to main content

crcbl_shell/
geom.rs

1//! Sizes, points and rectangles, in the two coordinate spaces a window has.
2//!
3//! # Physical and logical are different types on purpose
4//!
5//! Every windowing bug that outlives a release is a scale-factor bug, and every
6//! scale-factor bug is a physical value used where a logical one was meant (or
7//! the reverse). So the two are separate types and the only way between them is
8//! an explicit conversion that takes the scale factor:
9//!
10//! * **Physical** ([`PhysicalSize`], [`PhysicalPoint`], [`PhysicalRect`]) —
11//!   device pixels. This is what a swapchain is sized in, what a viewport is
12//!   sized in, and what a pointer position is reported in. Never guess it.
13//! * **Logical** ([`LogicalSize`], [`LogicalPoint`]) — scale-independent units.
14//!   This is what a *request* is expressed in ("open a 1280×720 window"), what
15//!   min/max constraints are expressed in, and what the UI lays out in. It is
16//!   `f64` rather than integer because fractional scaling (Wayland's
17//!   `fractional-scale-v1` reports scale in 1/120ths) makes
18//!   `physical / scale` non-integral in general.
19//!
20//! The engine's rule: **events report physical, requests carry logical.** A
21//! window asks for a size in a unit the user recognizes; the renderer allocates
22//! in the unit the GPU recognizes; the shell is the only thing that converts.
23//!
24//! # Rounding
25//!
26//! [`LogicalSize::to_physical`] rounds to nearest and clamps to at least 1×1.
27//! Zero-sized swapchains are invalid in Vulkan, and a minimized window on Win32
28//! genuinely reports 0×0 — so the clamp is not paranoia, it is the difference
29//! between "the window is minimized" and a validation error.
30
31use core::fmt;
32
33/// A size in device pixels.
34///
35/// What the swapchain is created at. See the [module docs](self).
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
37pub struct PhysicalSize {
38    /// Width in device pixels.
39    pub width: u32,
40    /// Height in device pixels.
41    pub height: u32,
42}
43
44impl PhysicalSize {
45    /// A size of `width` × `height` device pixels.
46    #[inline]
47    #[must_use]
48    pub const fn new(width: u32, height: u32) -> Self {
49        Self { width, height }
50    }
51
52    /// Whether either dimension is zero — a minimized or not-yet-configured
53    /// window.
54    ///
55    /// The frame loop uses this to skip rendering rather than to create an
56    /// invalid swapchain.
57    #[inline]
58    #[must_use]
59    pub const fn is_empty(self) -> bool {
60        self.width == 0 || self.height == 0
61    }
62
63    /// Pixel count, as a `u64` so a 16K×16K target does not overflow.
64    #[inline]
65    #[must_use]
66    pub const fn area(self) -> u64 {
67        self.width as u64 * self.height as u64
68    }
69
70    /// This size in logical units at `scale_factor`.
71    #[inline]
72    #[must_use]
73    pub fn to_logical(self, scale_factor: f64) -> LogicalSize {
74        debug_assert!(scale_factor > 0.0, "scale factor must be positive");
75        LogicalSize {
76            width: f64::from(self.width) / scale_factor,
77            height: f64::from(self.height) / scale_factor,
78        }
79    }
80
81    /// The aspect ratio as a float, or `None` for an empty size.
82    #[must_use]
83    pub fn aspect(self) -> Option<f64> {
84        if self.is_empty() {
85            None
86        } else {
87            Some(f64::from(self.width) / f64::from(self.height))
88        }
89    }
90}
91
92impl fmt::Display for PhysicalSize {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(f, "{}x{}px", self.width, self.height)
95    }
96}
97
98/// A size in scale-independent units.
99///
100/// What a window *request* and a size constraint are expressed in. See the
101/// [module docs](self).
102#[derive(Clone, Copy, Debug, Default, PartialEq)]
103pub struct LogicalSize {
104    /// Width in logical units.
105    pub width: f64,
106    /// Height in logical units.
107    pub height: f64,
108}
109
110impl LogicalSize {
111    /// A size of `width` × `height` logical units.
112    #[inline]
113    #[must_use]
114    pub const fn new(width: f64, height: f64) -> Self {
115        Self { width, height }
116    }
117
118    /// This size in device pixels at `scale_factor`.
119    ///
120    /// Rounds to nearest and clamps to at least 1×1; see the [module
121    /// docs](self) for why the clamp exists.
122    #[must_use]
123    pub fn to_physical(self, scale_factor: f64) -> PhysicalSize {
124        debug_assert!(scale_factor > 0.0, "scale factor must be positive");
125        let scale = |value: f64| {
126            let scaled = (value * scale_factor).round();
127            // `clamp` handles infinities correctly (∞ saturates to `u32::MAX`)
128            // but propagates NaN, which has no sensible size — so NaN becomes
129            // the same 1 px a zero would.
130            if scaled.is_nan() {
131                1
132            } else {
133                scaled.clamp(1.0, f64::from(u32::MAX)) as u32
134            }
135        };
136        PhysicalSize {
137            width: scale(self.width),
138            height: scale(self.height),
139        }
140    }
141}
142
143impl fmt::Display for LogicalSize {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        write!(f, "{}x{}dp", self.width, self.height)
146    }
147}
148
149/// A point in device pixels, relative to a window's top-left corner.
150///
151/// `f64` because pointer positions are sub-pixel: Wayland delivers them as
152/// 24.8 fixed point and a 240 Hz mouse on a scaled output genuinely moves less
153/// than a pixel per event. Truncating here would quantize aim.
154#[derive(Clone, Copy, Debug, Default, PartialEq)]
155pub struct PhysicalPoint {
156    /// Distance from the left edge, in device pixels.
157    pub x: f64,
158    /// Distance from the top edge, in device pixels.
159    pub y: f64,
160}
161
162impl PhysicalPoint {
163    /// The window's top-left corner.
164    pub const ORIGIN: Self = Self { x: 0.0, y: 0.0 };
165
166    /// A point at `(x, y)` device pixels.
167    #[inline]
168    #[must_use]
169    pub const fn new(x: f64, y: f64) -> Self {
170        Self { x, y }
171    }
172
173    /// This point in logical units at `scale_factor`.
174    #[inline]
175    #[must_use]
176    pub fn to_logical(self, scale_factor: f64) -> LogicalPoint {
177        debug_assert!(scale_factor > 0.0, "scale factor must be positive");
178        LogicalPoint {
179            x: self.x / scale_factor,
180            y: self.y / scale_factor,
181        }
182    }
183
184    /// Whether the point lies inside a window of `size`.
185    #[must_use]
186    pub fn is_inside(self, size: PhysicalSize) -> bool {
187        self.x >= 0.0
188            && self.y >= 0.0
189            && self.x < f64::from(size.width)
190            && self.y < f64::from(size.height)
191    }
192}
193
194/// A point in scale-independent units, relative to a window's top-left corner.
195#[derive(Clone, Copy, Debug, Default, PartialEq)]
196pub struct LogicalPoint {
197    /// Distance from the left edge, in logical units.
198    pub x: f64,
199    /// Distance from the top edge, in logical units.
200    pub y: f64,
201}
202
203impl LogicalPoint {
204    /// A point at `(x, y)` logical units.
205    #[inline]
206    #[must_use]
207    pub const fn new(x: f64, y: f64) -> Self {
208        Self { x, y }
209    }
210}
211
212/// An axis-aligned rectangle in scale-independent units, relative to a
213/// window's top-left corner.
214#[derive(Clone, Copy, Debug, Default, PartialEq)]
215pub struct LogicalRect {
216    /// The top-left corner.
217    pub origin: LogicalPoint,
218    /// Width and height.
219    pub size: LogicalSize,
220}
221
222impl LogicalRect {
223    /// A rectangle at `origin` of `size`.
224    #[inline]
225    #[must_use]
226    pub const fn new(origin: LogicalPoint, size: LogicalSize) -> Self {
227        Self { origin, size }
228    }
229}
230
231/// An axis-aligned rectangle in device pixels.
232///
233/// Each use names its space. Monitor geometry is in the desktop's, where the
234/// origin is the desktop origin rather than a window corner and coordinates can
235/// be negative (a monitor placed left of the primary one);
236/// [`Shell::set_text_input_area`](crate::Shell::set_text_input_area) is in a
237/// window's client area.
238#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
239pub struct PhysicalRect {
240    /// Left edge, in device pixels of the space the use names.
241    pub x: i32,
242    /// Top edge, in device pixels of the space the use names.
243    pub y: i32,
244    /// Width in device pixels.
245    pub width: u32,
246    /// Height in device pixels.
247    pub height: u32,
248}
249
250impl PhysicalRect {
251    /// A rectangle at `(x, y)` of `width` × `height` device pixels.
252    #[inline]
253    #[must_use]
254    pub const fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
255        Self {
256            x,
257            y,
258            width,
259            height,
260        }
261    }
262
263    /// The rectangle's size.
264    #[inline]
265    #[must_use]
266    pub const fn size(self) -> PhysicalSize {
267        PhysicalSize::new(self.width, self.height)
268    }
269
270    /// Whether a desktop-space point lies inside.
271    ///
272    /// **Half-open**: the left and top edges belong to this rectangle, the
273    /// right and bottom ones do not. Monitors tile edge to edge — the standard
274    /// side-by-side layout is `(0,0,1920,1080)` next to `(1920,0,1920,1080)` —
275    /// so an inclusive far edge would put the first column of the second
276    /// monitor inside *both*, and the monitor lookup in the X11 backend returns
277    /// the first match. Half-open makes the tiling a partition.
278    ///
279    /// The subtraction is widened to `i64` *before* it happens, not after: a
280    /// rectangle at a negative origin and a point near [`i32::MAX`] overflow an
281    /// `i32` difference, which panics in debug and wraps to "inside" in
282    /// release.
283    #[must_use]
284    pub const fn contains(self, x: i32, y: i32) -> bool {
285        x >= self.x
286            && y >= self.y
287            && (x as i64 - self.x as i64) < self.width as i64
288            && (y as i64 - self.y as i64) < self.height as i64
289    }
290
291    /// This rectangle with its corners rounded to `radius` device pixels, as
292    /// the union of rectangles
293    /// [`Shell::set_input_region`](crate::Shell::set_input_region) takes.
294    ///
295    /// An **outer bound**: every pixel a corner's quarter circle covers at all
296    /// is inside, so a panel drawn with antialiased rounded corners keeps its
297    /// whole edge — which matters on Win32, where the region also clips what
298    /// the window draws. Neighbouring rows with the same inset are merged, so
299    /// the count grows with the radius rather than the height, and the
300    /// rectangles never overlap. The radius is clamped to half the shorter
301    /// side; an empty rectangle gives none.
302    #[must_use]
303    pub fn rounded(self, radius: u32) -> Vec<Self> {
304        if self.width == 0 || self.height == 0 {
305            return Vec::new();
306        }
307        let radius = radius.min(self.width / 2).min(self.height / 2);
308        let insets: Vec<u32> = (0..radius).map(|row| corner_inset(radius, row)).collect();
309        let mut rects: Vec<Self> = Vec::new();
310        let mut push = |top: u32, height: u32, inset: u32| {
311            if height == 0 {
312                return;
313            }
314            let row = Self::new(
315                self.x.saturating_add_unsigned(inset),
316                self.y.saturating_add_unsigned(top),
317                self.width - 2 * inset,
318                height,
319            );
320            match rects.last_mut() {
321                Some(last)
322                    if last.x == row.x
323                        && last.width == row.width
324                        && i64::from(last.y) + i64::from(last.height) == i64::from(row.y) =>
325                {
326                    last.height += height;
327                }
328                _ => rects.push(row),
329            }
330        };
331        for (row, &inset) in (0..radius).zip(&insets) {
332            push(row, 1, inset);
333        }
334        push(radius, self.height - 2 * radius, 0);
335        for (row, &inset) in (0..radius).zip(&insets).rev() {
336            push(self.height - 1 - row, 1, inset);
337        }
338        rects
339    }
340}
341
342/// How many columns of row `row` (counted from the edge) lie wholly outside a
343/// corner's quarter circle of `radius`: the columns the disc does not reach.
344///
345/// The circle's centre is `radius` in from both edges, and the row's nearest
346/// point to it is its inner edge, `radius - row - 1` away vertically, where
347/// the circle reaches `s = sqrt(radius² - d²)` from the centre. A column
348/// overlaps the disc exactly when its inner edge is past `radius - s`, so the
349/// inset is `floor(radius - s)`, which in integers is `radius - ceil(s)`.
350fn corner_inset(radius: u32, row: u32) -> u32 {
351    let (radius, d) = (u64::from(radius), u64::from(radius - row - 1));
352    let squared = radius * radius - d * d;
353    let root = squared.isqrt();
354    let ceil = if root * root == squared {
355        root
356    } else {
357        root + 1
358    };
359    u32::try_from(radius - ceil).expect("the inset is at most the radius, which is a u32")
360}
361
362impl fmt::Display for PhysicalRect {
363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364        write!(f, "{}x{}+{}+{}", self.width, self.height, self.x, self.y)
365    }
366}
367
368/// A width-to-height ratio, exact.
369///
370/// Stored as an integer fraction rather than an `f64` because 16:9 is not
371/// representable in binary floating point, and a window that must stay
372/// aspect-locked across thousands of resize events accumulates that error into
373/// a visible drift. Comparisons and the fit math below stay in integers where
374/// they can.
375#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
376pub struct AspectRatio {
377    numerator: u32,
378    denominator: u32,
379}
380
381impl AspectRatio {
382    /// 16:9.
383    pub const WIDESCREEN: Self = Self {
384        numerator: 16,
385        denominator: 9,
386    };
387    /// 4:3.
388    pub const CLASSIC: Self = Self {
389        numerator: 4,
390        denominator: 3,
391    };
392    /// 1:1.
393    pub const SQUARE: Self = Self {
394        numerator: 1,
395        denominator: 1,
396    };
397
398    /// A ratio of `width : height`.
399    ///
400    /// # Panics
401    ///
402    /// If either term is zero. A degenerate aspect ratio has no correct
403    /// behaviour and silently substituting one hides the caller's bug.
404    #[must_use]
405    pub const fn new(width: u32, height: u32) -> Self {
406        assert!(
407            width > 0 && height > 0,
408            "aspect ratio terms must be positive"
409        );
410        Self {
411            numerator: width,
412            denominator: height,
413        }
414    }
415
416    /// The width term.
417    #[inline]
418    #[must_use]
419    pub const fn numerator(self) -> u32 {
420        self.numerator
421    }
422
423    /// The height term.
424    #[inline]
425    #[must_use]
426    pub const fn denominator(self) -> u32 {
427        self.denominator
428    }
429
430    /// The ratio as a float, for shader constants and letterbox math.
431    #[inline]
432    #[must_use]
433    pub fn as_f64(self) -> f64 {
434        f64::from(self.numerator) / f64::from(self.denominator)
435    }
436
437    /// The largest size with this aspect ratio that fits inside `bounds`.
438    ///
439    /// This is the letterbox computation, and it lives here because
440    /// the windowing rules in `docs/notes/backends.md` make in-renderer letterboxing the
441    /// **universal fallback**: Wayland has no aspect hint at all, and a tiling
442    /// window manager can force any size on any backend, so every consumer that
443    /// aspect-locks needs this math and none of them should write it twice.
444    ///
445    /// Returns a size of at most `bounds` in both axes, and at least 1×1.
446    #[must_use]
447    pub fn fit(self, bounds: PhysicalSize) -> PhysicalSize {
448        if bounds.is_empty() {
449            return PhysicalSize::new(1, 1);
450        }
451        // Compare `bounds.width / bounds.height` against `n / d` without
452        // dividing: cross-multiply in u64, which cannot overflow for u32 terms.
453        let bounds_ratio = u64::from(bounds.width) * u64::from(self.denominator);
454        let target_ratio = u64::from(bounds.height) * u64::from(self.numerator);
455        if bounds_ratio > target_ratio {
456            // Too wide: height is the limit, pillarbox.
457            let width = target_ratio / u64::from(self.denominator);
458            PhysicalSize::new(width.max(1) as u32, bounds.height)
459        } else {
460            // Too tall (or exact): width is the limit, letterbox.
461            let height = bounds_ratio / u64::from(self.numerator);
462            PhysicalSize::new(bounds.width, height.max(1) as u32)
463        }
464    }
465}
466
467impl fmt::Display for AspectRatio {
468    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469        write!(f, "{}:{}", self.numerator, self.denominator)
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn scale_conversions_round_trip_at_integer_scales() {
479        let logical = LogicalSize::new(1280.0, 720.0);
480        assert_eq!(logical.to_physical(1.0), PhysicalSize::new(1280, 720));
481        assert_eq!(logical.to_physical(2.0), PhysicalSize::new(2560, 1440));
482        assert_eq!(
483            PhysicalSize::new(2560, 1440).to_logical(2.0),
484            LogicalSize::new(1280.0, 720.0)
485        );
486    }
487
488    #[test]
489    fn fractional_scale_rounds_and_never_produces_zero() {
490        // 125% is what `fractional-scale-v1` reports as 150/120.
491        assert_eq!(
492            LogicalSize::new(1280.0, 720.0).to_physical(1.25),
493            PhysicalSize::new(1600, 900)
494        );
495        // A window dragged to nothing still yields a legal swapchain size.
496        assert_eq!(
497            LogicalSize::new(0.0, 0.0).to_physical(1.0),
498            PhysicalSize::new(1, 1)
499        );
500        assert_eq!(
501            LogicalSize::new(f64::NAN, 4.0).to_physical(1.0),
502            PhysicalSize::new(1, 4)
503        );
504        // Overflow saturates rather than wrapping: `1e300 * 1e300` is infinite,
505        // and a wrapped `as u32` would have produced a plausible-looking small
506        // number.
507        assert_eq!(
508            LogicalSize::new(1e300, 4.0).to_physical(1e300),
509            PhysicalSize::new(u32::MAX, u32::MAX)
510        );
511    }
512
513    #[test]
514    fn empty_sizes_are_detectable_rather_than_clamped_away() {
515        // A *reported* size of 0 is real information (minimized), so
516        // `PhysicalSize` never clamps — only the logical→physical conversion
517        // does, because that one feeds a swapchain.
518        let minimized = PhysicalSize::new(0, 0);
519        assert!(minimized.is_empty());
520        assert_eq!(minimized.aspect(), None);
521        assert_eq!(minimized.area(), 0);
522        assert!(!PhysicalSize::new(1, 1).is_empty());
523        assert_eq!(PhysicalSize::new(3840, 2160).area(), 8_294_400);
524        assert_eq!(PhysicalSize::new(1920, 1080).to_string(), "1920x1080px");
525        assert_eq!(LogicalSize::new(16.0, 9.0).to_string(), "16x9dp");
526    }
527
528    #[test]
529    fn letterbox_fit_is_exact_for_the_common_ratios() {
530        // 16:9 content in a 16:10 window letterboxes vertically.
531        let fitted = AspectRatio::WIDESCREEN.fit(PhysicalSize::new(1920, 1200));
532        assert_eq!(fitted, PhysicalSize::new(1920, 1080));
533
534        // 16:9 content in a 21:9 window pillarboxes horizontally.
535        let fitted = AspectRatio::WIDESCREEN.fit(PhysicalSize::new(2560, 1080));
536        assert_eq!(fitted, PhysicalSize::new(1920, 1080));
537
538        // An exact match is unchanged — no drift, which is the reason the ratio
539        // is an integer fraction.
540        let exact = PhysicalSize::new(1280, 720);
541        let mut size = exact;
542        for _ in 0..1_000 {
543            size = AspectRatio::WIDESCREEN.fit(size);
544        }
545        assert_eq!(size, exact);
546
547        assert_eq!(
548            AspectRatio::CLASSIC.fit(PhysicalSize::new(1000, 1000)),
549            PhysicalSize::new(1000, 750)
550        );
551        assert_eq!(
552            AspectRatio::SQUARE.fit(PhysicalSize::new(1000, 400)),
553            PhysicalSize::new(400, 400)
554        );
555        assert_eq!(
556            AspectRatio::WIDESCREEN.fit(PhysicalSize::new(0, 0)),
557            PhysicalSize::new(1, 1)
558        );
559        // Degenerate but legal: never returns a zero dimension.
560        assert_eq!(
561            AspectRatio::new(1000, 1).fit(PhysicalSize::new(10, 10)),
562            PhysicalSize::new(10, 1)
563        );
564    }
565
566    #[test]
567    fn aspect_ratio_accessors_and_display() {
568        let ratio = AspectRatio::new(21, 9);
569        assert_eq!(ratio.numerator(), 21);
570        assert_eq!(ratio.denominator(), 9);
571        assert!((ratio.as_f64() - 21.0 / 9.0).abs() < 1e-12);
572        assert_eq!(ratio.to_string(), "21:9");
573        assert_eq!(AspectRatio::WIDESCREEN, AspectRatio::new(16, 9));
574    }
575
576    #[test]
577    #[should_panic(expected = "aspect ratio terms must be positive")]
578    fn a_degenerate_aspect_ratio_is_rejected() {
579        let _ = AspectRatio::new(16, 0);
580    }
581
582    #[test]
583    fn points_convert_and_hit_test() {
584        let point = PhysicalPoint::new(200.0, 100.0);
585        assert_eq!(point.to_logical(2.0), LogicalPoint::new(100.0, 50.0));
586        assert!(point.is_inside(PhysicalSize::new(640, 360)));
587        assert!(!point.is_inside(PhysicalSize::new(100, 100)));
588        assert!(!PhysicalPoint::new(-1.0, 0.0).is_inside(PhysicalSize::new(640, 360)));
589        assert_eq!(PhysicalPoint::ORIGIN, PhysicalPoint::default());
590        assert_eq!(LogicalPoint::new(0.0, 0.0), LogicalPoint::default());
591    }
592
593    #[test]
594    fn monitor_rectangles_handle_negative_origins() {
595        // A second monitor placed to the left of the primary one.
596        let left = PhysicalRect::new(-1920, 0, 1920, 1080);
597        assert!(left.contains(-1000, 500));
598        assert!(!left.contains(10, 500));
599        assert_eq!(left.size(), PhysicalSize::new(1920, 1080));
600        assert_eq!(left.to_string(), "1920x1080+-1920+0");
601    }
602
603    #[test]
604    fn tiled_monitors_partition_the_desktop() {
605        // The standard side-by-side layout. Every point belongs to exactly one
606        // of the two, which is what a monitor lookup that returns the first
607        // match needs to be correct.
608        let first = PhysicalRect::new(0, 0, 1920, 1080);
609        let second = PhysicalRect::new(1920, 0, 1920, 1080);
610        for (x, y) in [(0, 0), (1919, 0), (0, 1079), (1919, 1079)] {
611            assert!(first.contains(x, y), "({x},{y}) is on the first monitor");
612            assert!(!second.contains(x, y));
613        }
614        for (x, y) in [(1920, 0), (3839, 1079)] {
615            assert!(second.contains(x, y), "({x},{y}) is on the second monitor");
616            assert!(!first.contains(x, y), "the shared edge belongs to one");
617        }
618        // Past the far edge in either axis is outside.
619        assert!(!first.contains(0, 1080));
620        assert!(!second.contains(3840, 0));
621    }
622
623    #[test]
624    fn hit_testing_a_far_away_point_does_not_overflow() {
625        // `x - self.x` overflows an i32 here; the widening must happen first.
626        let negative_origin = PhysicalRect::new(-1, 0, 1920, 1080);
627        assert!(!negative_origin.contains(i32::MAX, 0));
628        assert!(!PhysicalRect::new(i32::MIN, 0, 1920, 1080).contains(i32::MAX, 0));
629        // The widest legal rectangle really does reach that far — and stops one
630        // pixel short of `i32::MIN + u32::MAX`, because the far edge is open.
631        let widest = PhysicalRect::new(i32::MIN, 0, u32::MAX, 1080);
632        assert!(widest.contains(i32::MAX - 1, 0));
633        assert!(!widest.contains(i32::MAX, 0));
634    }
635
636    /// Whether a disc of `radius` centred `radius` in from a corner overlaps
637    /// the pixel `(x, y)` counted from that corner: the pixel's nearest point
638    /// to the centre is its inner corner, and the overlap is strict, as an
639    /// antialiased edge with any coverage at all needs.
640    fn corner_covers(radius: i64, x: i64, y: i64) -> bool {
641        if x >= radius || y >= radius {
642            return true;
643        }
644        let (dx, dy) = (radius - x - 1, radius - y - 1);
645        dx * dx + dy * dy < radius * radius
646    }
647
648    #[test]
649    fn a_rounded_rectangle_is_exactly_the_pixels_its_corners_cover() {
650        for (rect, radius) in [
651            (PhysicalRect::new(0, 0, 40, 30), 10),
652            (PhysicalRect::new(-7, 13, 31, 64), 6),
653            (PhysicalRect::new(5, 5, 20, 20), 10),
654            // Clamped to half the shorter side.
655            (PhysicalRect::new(0, 0, 9, 50), 30),
656            (PhysicalRect::new(0, 0, 12, 8), 1),
657            (PhysicalRect::new(3, 4, 12, 8), 0),
658        ] {
659            let rects = rect.rounded(radius);
660            let r = i64::from(radius.min(rect.width / 2).min(rect.height / 2));
661            let (width, height) = (i64::from(rect.width), i64::from(rect.height));
662            for y in 0..height {
663                for x in 0..width {
664                    let covered = corner_covers(r, x.min(width - 1 - x), y.min(height - 1 - y));
665                    let px = i32::try_from(i64::from(rect.x) + x).expect("small");
666                    let py = i32::try_from(i64::from(rect.y) + y).expect("small");
667                    let hits = rects.iter().filter(|part| part.contains(px, py)).count();
668                    assert_eq!(
669                        hits,
670                        usize::from(covered),
671                        "{rect} r{radius}: pixel ({x},{y}) in {hits} of {rects:?}"
672                    );
673                }
674            }
675            // Nothing outside the rectangle.
676            for part in &rects {
677                assert!(part.x >= rect.x && part.y >= rect.y, "{part} in {rect}");
678                assert!(
679                    i64::from(part.x) + i64::from(part.width) <= i64::from(rect.x) + width
680                        && i64::from(part.y) + i64::from(part.height) <= i64::from(rect.y) + height,
681                    "{part} in {rect}"
682                );
683            }
684        }
685    }
686
687    #[test]
688    fn a_rounded_corner_insets_rows_by_the_known_amounts_and_merges_equal_ones() {
689        // Radius 10: the row nearest the edge reaches the circle at
690        // `10 - sqrt(19)`, so five whole columns are outside; then four, two,
691        // two, one, and nothing from row five on.
692        let rects = PhysicalRect::new(0, 0, 100, 60).rounded(10);
693        let insets: Vec<(i32, u32)> = rects.iter().map(|part| (part.x, part.height)).collect();
694        assert_eq!(
695            insets,
696            [
697                (5, 1),
698                (4, 1),
699                (2, 2),
700                (1, 1),
701                (0, 50),
702                (1, 1),
703                (2, 2),
704                (4, 1),
705                (5, 1)
706            ]
707        );
708        assert_eq!(rects[4], PhysicalRect::new(0, 5, 100, 50));
709        // No radius is the rectangle itself, and an empty one is nothing.
710        let square = PhysicalRect::new(3, 4, 12, 8);
711        assert_eq!(square.rounded(0), [square]);
712        assert!(PhysicalRect::new(0, 0, 0, 8).rounded(4).is_empty());
713    }
714}