Skip to main content

ferroui_base/
rounded_rect.rs

1//! Represents a rectangle with (possibly elliptical) rounded corners.
2
3use crate::utilities::math_utilities;
4use crate::{CornerRadius, Point, Rect, Thickness, Vector};
5
6/// Represents a rectangle with per-corner radii.
7#[derive(Clone, Copy, Debug, PartialEq, Default)]
8pub struct RoundedRect {
9    pub rect: Rect,
10    pub radii_top_left: Vector,
11    pub radii_top_right: Vector,
12    pub radii_bottom_left: Vector,
13    pub radii_bottom_right: Vector,
14}
15
16impl RoundedRect {
17    /// Initializes a [`RoundedRect`] from elliptical corner radii, clockwise from the top left.
18    #[inline]
19    pub const fn new(
20        rect: Rect,
21        radii_top_left: Vector,
22        radii_top_right: Vector,
23        radii_bottom_right: Vector,
24        radii_bottom_left: Vector,
25    ) -> Self {
26        Self {
27            rect,
28            radii_top_left,
29            radii_top_right,
30            radii_bottom_left,
31            radii_bottom_right,
32        }
33    }
34
35    /// Initializes a [`RoundedRect`] from circular corner radii, clockwise from the top left.
36    #[inline]
37    pub const fn from_radii(
38        rect: Rect,
39        radius_top_left: f64,
40        radius_top_right: f64,
41        radius_bottom_right: f64,
42        radius_bottom_left: f64,
43    ) -> Self {
44        Self::new(
45            rect,
46            Vector::new(radius_top_left, radius_top_left),
47            Vector::new(radius_top_right, radius_top_right),
48            Vector::new(radius_bottom_right, radius_bottom_right),
49            Vector::new(radius_bottom_left, radius_bottom_left),
50        )
51    }
52
53    /// Initializes a [`RoundedRect`] with the same elliptical radii on every corner.
54    #[inline]
55    pub const fn from_uniform_radii(rect: Rect, radii: Vector) -> Self {
56        Self::new(rect, radii, radii, radii, radii)
57    }
58
59    /// Initializes a [`RoundedRect`] with the same X and Y radius on every corner.
60    #[inline]
61    pub const fn from_radius_xy(rect: Rect, radius_x: f64, radius_y: f64) -> Self {
62        Self::from_uniform_radii(rect, Vector::new(radius_x, radius_y))
63    }
64
65    /// Initializes a [`RoundedRect`] with the same circular radius on every corner.
66    #[inline]
67    pub const fn from_radius(rect: Rect, radius: f64) -> Self {
68        Self::from_radius_xy(rect, radius, radius)
69    }
70
71    /// Initializes a [`RoundedRect`] without rounded corners.
72    #[inline]
73    pub const fn from_rect(rect: Rect) -> Self {
74        Self::from_radius(rect, 0.0)
75    }
76
77    /// Initializes a [`RoundedRect`] from bounds and a [`CornerRadius`].
78    #[inline]
79    pub const fn from_corner_radius(bounds: Rect, radius: CornerRadius) -> Self {
80        Self::from_radii(
81            bounds,
82            radius.top_left,
83            radius.top_right,
84            radius.bottom_right,
85            radius.bottom_left,
86        )
87    }
88
89    #[inline]
90    pub fn equals(&self, other: RoundedRect) -> bool {
91        *self == other
92    }
93
94    /// Gets a value indicating whether any corner has a non-zero radius.
95    #[inline]
96    pub fn is_rounded(&self) -> bool {
97        self.radii_top_left != Vector::ZERO
98            || self.radii_top_right != Vector::ZERO
99            || self.radii_bottom_right != Vector::ZERO
100            || self.radii_bottom_left != Vector::ZERO
101    }
102
103    /// Gets a value indicating whether all corners have the same radii.
104    #[inline]
105    pub fn is_uniform(&self) -> bool {
106        self.radii_top_left.equals(self.radii_top_right)
107            && self.radii_top_left.equals(self.radii_bottom_right)
108            && self.radii_top_left.equals(self.radii_bottom_left)
109    }
110
111    #[inline]
112    pub fn inflate(&self, dx: f64, dy: f64) -> RoundedRect {
113        self.deflate(-dx, -dy)
114    }
115
116    pub fn deflate(&self, dx: f64, dy: f64) -> RoundedRect {
117        if !self.is_rounded() {
118            return RoundedRect::from_rect(
119                self.rect.deflate_thickness(Thickness::symmetric(dx, dy)),
120            );
121        }
122
123        // Same algorithm as Skia's SkRRect inset.
124        let mut left = self.rect.x + dx;
125        let mut top = self.rect.y + dy;
126        let mut right = left + self.rect.width - dx * 2.0;
127        let mut bottom = top + self.rect.height - dy * 2.0;
128        let mut radii = [
129            self.radii_top_left,
130            self.radii_top_right,
131            self.radii_bottom_right,
132            self.radii_bottom_left,
133        ];
134
135        let mut degenerate = false;
136        if right <= left {
137            degenerate = true;
138            left = (left + right) * 0.5;
139            right = left;
140        }
141        if bottom <= top {
142            degenerate = true;
143            top = (top + bottom) * 0.5;
144            bottom = top;
145        }
146        if degenerate {
147            return RoundedRect::from_rect(Rect::new(left, top, right - left, bottom - top));
148        }
149
150        for radius in &mut radii {
151            let rx = math_utilities::max(0.0, radius.x - dx);
152            let ry = math_utilities::max(0.0, radius.y - dy);
153            *radius = if rx == 0.0 || ry == 0.0 {
154                Vector::default()
155            } else {
156                Vector::new(rx, ry)
157            };
158        }
159
160        RoundedRect::new(
161            Rect::new(left, top, right - left, bottom - top),
162            radii[0],
163            radii[1],
164            radii[2],
165            radii[3],
166        )
167    }
168
169    /// This method should be used internally to check for the rect emptiness.
170    /// Once a dedicated "empty" value exists this is the single place to update.
171    #[inline]
172    pub fn is_empty(&self) -> bool {
173        *self == RoundedRect::default()
174    }
175
176    #[inline]
177    fn is_outside_corner(dx: f64, dy: f64, radius: f64) -> bool {
178        (dx < 0.0) && (dy < 0.0) && (dx * dx + dy * dy > radius * radius)
179    }
180
181    /// Determines whether a point is inside the rounded rectangle, exclusive of the
182    /// bottom/right edge. Elliptical corners are approximated by circles using the
183    /// smaller of the two radii.
184    pub fn contains_exclusive(&self, p: Point) -> bool {
185        // Do a simple rectangular bounds check first
186        if !self.rect.contains_exclusive(p) {
187            return false;
188        }
189
190        let rect = self.rect;
191
192        // If any radii totals exceed available bounds, determine a scale factor that needs to be applied
193        let mut scale_factor = 1.0;
194        if rect.width > 0.0 {
195            let radii_width = math_utilities::max(
196                self.radii_top_left.x + self.radii_top_right.x,
197                self.radii_bottom_left.x + self.radii_bottom_right.x,
198            );
199            if radii_width > rect.width {
200                scale_factor = math_utilities::min(scale_factor, rect.width / radii_width);
201            }
202        }
203        if rect.height > 0.0 {
204            let radii_height = math_utilities::max(
205                self.radii_top_left.y + self.radii_bottom_left.y,
206                self.radii_top_right.y + self.radii_bottom_right.y,
207            );
208            if radii_height > rect.height {
209                scale_factor = math_utilities::min(scale_factor, rect.height / radii_height);
210            }
211        }
212
213        // Before corner hit-testing, make the point relative to the bounds' upper-left
214        let p = Point::new(p.x - rect.x, p.y - rect.y);
215
216        // Top-left corner
217        let radius =
218            math_utilities::min(self.radii_top_left.x, self.radii_top_left.y) * scale_factor;
219        if Self::is_outside_corner(p.x - radius, p.y - radius, radius) {
220            return false;
221        }
222
223        // Top-right corner
224        let radius =
225            math_utilities::min(self.radii_top_right.x, self.radii_top_right.y) * scale_factor;
226        if Self::is_outside_corner(rect.width - radius - p.x, p.y - radius, radius) {
227            return false;
228        }
229
230        // Bottom-right corner
231        let radius = math_utilities::min(self.radii_bottom_right.x, self.radii_bottom_right.y)
232            * scale_factor;
233        if Self::is_outside_corner(
234            rect.width - radius - p.x,
235            rect.height - radius - p.y,
236            radius,
237        ) {
238            return false;
239        }
240
241        // Bottom-left corner
242        let radius =
243            math_utilities::min(self.radii_bottom_left.x, self.radii_bottom_left.y) * scale_factor;
244        if Self::is_outside_corner(p.x - radius, rect.height - radius - p.y, radius) {
245            return false;
246        }
247
248        true
249    }
250}
251
252impl From<Rect> for RoundedRect {
253    #[inline]
254    fn from(r: Rect) -> RoundedRect {
255        RoundedRect::from_rect(r)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn contains_exclusive_should_return_expected_result_for_point() {
265        let cases: [(f64, f64, bool); 13] = [
266            // Corners
267            (0.0, 0.0, false),
268            (100.0, 0.0, false),
269            (100.0, 100.0, false),
270            (0.0, 100.0, false),
271            // Indent 10px
272            (10.0, 10.0, false),
273            (90.0, 10.0, true),
274            (90.0, 90.0, false),
275            (10.0, 90.0, true),
276            // Indent 17px
277            (17.0, 17.0, false),
278            (83.0, 17.0, true),
279            (83.0, 83.0, true),
280            (17.0, 83.0, true),
281            // Center
282            (50.0, 50.0, true),
283        ];
284
285        for (x, y, expected_result) in cases {
286            let rrect = RoundedRect::from_corner_radius(
287                Rect::new(0.0, 0.0, 100.0, 100.0),
288                CornerRadius::new(60.0, 10.0, 50.0, 30.0),
289            );
290
291            assert_eq!(
292                expected_result,
293                rrect.contains_exclusive(Point::new(x, y)),
294                "point ({x}, {y})"
295            );
296        }
297    }
298
299    // Additional coverage (not part of the reference suite).
300
301    #[test]
302    fn rounded_uniform_and_deflate() {
303        let plain: RoundedRect = Rect::new(0.0, 0.0, 10.0, 10.0).into();
304        assert!(!plain.is_rounded());
305        assert!(plain.is_uniform());
306        assert!(RoundedRect::default().is_empty());
307        assert_eq!(
308            plain.deflate(1.0, 2.0),
309            RoundedRect::from_rect(Rect::new(1.0, 2.0, 8.0, 6.0))
310        );
311
312        let rr = RoundedRect::from_radius(Rect::new(0.0, 0.0, 100.0, 100.0), 10.0);
313        assert!(rr.is_rounded());
314        assert_eq!(
315            rr.deflate(4.0, 4.0),
316            RoundedRect::from_radius(Rect::new(4.0, 4.0, 92.0, 92.0), 6.0)
317        );
318        // Radii that shrink to zero collapse the corner.
319        assert_eq!(
320            rr.deflate(10.0, 10.0),
321            RoundedRect::from_rect(Rect::new(10.0, 10.0, 80.0, 80.0))
322        );
323        // Degenerate result collapses to the centre line.
324        assert_eq!(
325            rr.deflate(60.0, 10.0),
326            RoundedRect::from_rect(Rect::new(50.0, 10.0, 0.0, 80.0))
327        );
328        assert_eq!(
329            rr.inflate(5.0, 5.0),
330            RoundedRect::from_radius(Rect::new(-5.0, -5.0, 110.0, 110.0), 15.0)
331        );
332    }
333}