Skip to main content

frus_core/
shape.rs

1//! **The outline of a box**: [`ShapeBorder`] and [`BorderSide`] (`shape_border.dart`).
2//!
3//! Until this module a box here had a `radius: f32` and, at best, a [`BorderRadius`]. The
4//! reference has a whole family, and it is not decoration: `shape` is the property that
5//! decides a chip is a stadium, a floating button is a circle, and a navigation rail's
6//! selection indicator is a pill rather than a rounded box. A framework with no way to
7//! say *what shape this is* cannot accept the property at all, which is why
8//! `indicatorShape`, `Card.shape`, `Chip.shape` and the rest have been recorded as
9//! blocked since milestone 437.
10//!
11//! # Two ways out of one type
12//!
13//! Three of the four shapes here **are** rounded rectangles once the box is known — a
14//! stadium is one whose radius is half the short side, a circle is one drawn in a square
15//! taken out of the middle. [`ShapeBorder::as_rounded`] says so, and hands back the box
16//! and the radii, so those three go down the renderer's existing fast path and cost
17//! nothing new.
18//!
19//! [`ShapeBorder::outline`] answers for **all** of them, as a path. That is what a
20//! bevelled corner needs, and what clipping to a shape will need when it arrives.
21
22use crate::decoration::BorderRadius;
23use crate::geometry::{Point, Rect};
24use crate::path::Path;
25use crate::Color;
26
27/// **One edge of a shape**: what colour it is drawn in and how thick it is
28/// (`border_side.dart`).
29#[derive(Copy, Clone, Debug, PartialEq)]
30pub struct BorderSide {
31    /// The edge's colour.
32    pub color: Color,
33    /// The edge's width, in logical pixels.
34    pub width: f32,
35}
36
37impl BorderSide {
38    /// No edge at all — the reference's `BorderStyle.none`, said as a width rather than
39    /// as a third state, since a zero-width edge and an absent one paint identically and
40    /// nothing here has to tell them apart.
41    pub const NONE: Self = Self {
42        color: Color::TRANSPARENT,
43        width: 0.0,
44    };
45
46    /// An edge of the given colour and width.
47    pub const fn new(color: Color, width: f32) -> Self {
48        Self { color, width }
49    }
50
51    /// Is there an edge to draw?
52    pub fn is_drawn(&self) -> bool {
53        self.width > 0.0 && self.color.a > 0.0
54    }
55}
56
57impl Default for BorderSide {
58    fn default() -> Self {
59        Self::NONE
60    }
61}
62
63/// **What shape a box is** (`shape_border.dart`), and the edge around it.
64///
65/// `Copy`, deliberately: this is meant to sit in a theme, and the theme's per-widget
66/// structs are copied wholesale. A shape with a variable number of corners — the
67/// reference's `StarBorder`, `LinearBorder` — would end that, and is not here.
68#[derive(Copy, Clone, Debug, PartialEq)]
69pub enum ShapeBorder {
70    /// Corners rounded by a radius each (`rounded_rectangle_border.dart`).
71    RoundedRectangle {
72        /// The corner radii.
73        radius: BorderRadius,
74        /// The edge drawn around the shape.
75        side: BorderSide,
76    },
77    /// **A pill**: the ends are semicircles, whatever the box's proportions
78    /// (`stadium_border.dart:95`). What a chip is, and what a navigation indicator is.
79    Stadium {
80        /// The edge drawn around the shape.
81        side: BorderSide,
82    },
83    /// **A circle** inscribed in the box, or an ellipse on the way to filling it
84    /// (`circle_border.dart:126`).
85    Circle {
86        /// `0.0` — a true circle, in a square taken out of the middle of the box. `1.0` —
87        /// an ellipse filling the box entirely. Between them, the circle grows towards
88        /// the ellipse along the box's longer axis.
89        eccentricity: f32,
90        /// The edge drawn around the shape.
91        side: BorderSide,
92    },
93    /// Corners **cut off straight** rather than rounded
94    /// (`beveled_rectangle_border.dart`). The one shape here that is not a rounded
95    /// rectangle in disguise.
96    Beveled {
97        /// The corner radii, before bevelling.
98        radius: BorderRadius,
99        /// The edge drawn around the shape.
100        side: BorderSide,
101    },
102}
103
104impl Default for ShapeBorder {
105    fn default() -> Self {
106        Self::RoundedRectangle {
107            radius: BorderRadius::ZERO,
108            side: BorderSide::NONE,
109        }
110    }
111}
112
113impl ShapeBorder {
114    /// A rectangle with these corners and no edge.
115    pub fn rounded(radius: impl Into<BorderRadius>) -> Self {
116        Self::RoundedRectangle {
117            radius: radius.into(),
118            side: BorderSide::NONE,
119        }
120    }
121
122    /// A pill with no edge.
123    pub const fn stadium() -> Self {
124        Self::Stadium {
125            side: BorderSide::NONE,
126        }
127    }
128
129    /// A circle with no edge.
130    pub const fn circle() -> Self {
131        Self::Circle {
132            eccentricity: 0.0,
133            side: BorderSide::NONE,
134        }
135    }
136
137    /// A rectangle with these corners cut off straight, and no edge.
138    pub fn beveled(radius: impl Into<BorderRadius>) -> Self {
139        Self::Beveled {
140            radius: radius.into(),
141            side: BorderSide::NONE,
142        }
143    }
144
145    /// The same shape with an edge around it (the reference's `copyWith(side:)`).
146    #[must_use]
147    pub fn with_side(self, side: BorderSide) -> Self {
148        match self {
149            Self::RoundedRectangle { radius, .. } => Self::RoundedRectangle { radius, side },
150            Self::Stadium { .. } => Self::Stadium { side },
151            Self::Circle { eccentricity, .. } => Self::Circle { eccentricity, side },
152            Self::Beveled { radius, .. } => Self::Beveled { radius, side },
153        }
154    }
155
156    /// The edge around it.
157    pub fn side(&self) -> BorderSide {
158        match *self {
159            Self::RoundedRectangle { side, .. }
160            | Self::Stadium { side }
161            | Self::Circle { side, .. }
162            | Self::Beveled { side, .. } => side,
163        }
164    }
165
166    /// **The rounded rectangle this shape is** inside `rect`: the box to draw and the
167    /// radii to draw it with — or `None` when the outline is not one and wants
168    /// [`outline`](Self::outline).
169    ///
170    /// A stadium's radius is half its short side (`stadium_border.dart:95`); a circle's
171    /// box is the one `_adjustRect` takes out of the middle (`circle_border.dart:126`),
172    /// with half *its* short side. Saying it this way is what keeps three shapes out of
173    /// the path renderer.
174    pub fn as_rounded(&self, rect: Rect) -> Option<(Rect, BorderRadius)> {
175        match *self {
176            Self::RoundedRectangle { radius, .. } => Some((rect, radius)),
177            Self::Stadium { .. } => Some((rect, BorderRadius::uniform(shortest(rect) * 0.5))),
178            Self::Circle { eccentricity, .. } => {
179                let box_ = circle_box(rect, eccentricity);
180                Some((box_, BorderRadius::uniform(shortest(box_) * 0.5)))
181            }
182            Self::Beveled { .. } => None,
183        }
184    }
185
186    /// **The outline**, as a path — for every shape, including the three that did not
187    /// need one.
188    pub fn outline(&self, rect: Rect) -> Path {
189        match *self {
190            Self::Beveled { radius, .. } => beveled_path(rect, radius),
191            _ => {
192                let (box_, radius) = self
193                    .as_rounded(rect)
194                    .expect("every shape but a bevel is a rounded rectangle");
195                rounded_path(box_, radius)
196            }
197        }
198    }
199}
200
201/// The shorter of a box's two sides.
202fn shortest(rect: Rect) -> f32 {
203    rect.width.min(rect.height).max(0.0)
204}
205
206/// The box a circle of this eccentricity occupies inside `rect`
207/// (`circle_border.dart:126`): a square out of the middle at `0.0`, the whole box at
208/// `1.0`, and the difference shared equally between the two ends in between.
209fn circle_box(rect: Rect, eccentricity: f32) -> Rect {
210    let e = eccentricity.clamp(0.0, 1.0);
211    if rect.width == rect.height {
212        return rect;
213    }
214    if rect.width < rect.height {
215        let delta = (1.0 - e) * (rect.height - rect.width) * 0.5;
216        Rect::new(
217            rect.x,
218            rect.y + delta,
219            rect.width,
220            rect.height - delta * 2.0,
221        )
222    } else {
223        let delta = (1.0 - e) * (rect.width - rect.height) * 0.5;
224        Rect::new(
225            rect.x + delta,
226            rect.y,
227            rect.width - delta * 2.0,
228            rect.height,
229        )
230    }
231}
232
233/// Each corner's radius, never more than the box can give it.
234fn fitted(rect: Rect, radius: BorderRadius) -> [f32; 4] {
235    let cap = shortest(rect) * 0.5;
236    [
237        radius.top_left.clamp(0.0, cap),
238        radius.top_right.clamp(0.0, cap),
239        radius.bottom_right.clamp(0.0, cap),
240        radius.bottom_left.clamp(0.0, cap),
241    ]
242}
243
244/// A rounded rectangle as a path, its corners drawn as quarter arcs.
245fn rounded_path(rect: Rect, radius: BorderRadius) -> Path {
246    let [tl, tr, br, bl] = fitted(rect, radius);
247    let (l, t) = (rect.x, rect.y);
248    let (r, b) = (rect.x + rect.width, rect.y + rect.height);
249    const HALF_PI: f32 = std::f32::consts::FRAC_PI_2;
250    Path::new()
251        .move_to(Point::new(l + tl, t))
252        .line_to(Point::new(r - tr, t))
253        .arc_to(Point::new(r - tr, t + tr), tr, -HALF_PI, 0.0)
254        .line_to(Point::new(r, b - br))
255        .arc_to(Point::new(r - br, b - br), br, 0.0, HALF_PI)
256        .line_to(Point::new(l + bl, b))
257        .arc_to(
258            Point::new(l + bl, b - bl),
259            bl,
260            HALF_PI,
261            std::f32::consts::PI,
262        )
263        .line_to(Point::new(l, t + tl))
264        .arc_to(
265            Point::new(l + tl, t + tl),
266            tl,
267            std::f32::consts::PI,
268            std::f32::consts::PI + HALF_PI,
269        )
270        .close()
271}
272
273/// The same rectangle with its corners **cut off straight**.
274fn beveled_path(rect: Rect, radius: BorderRadius) -> Path {
275    let [tl, tr, br, bl] = fitted(rect, radius);
276    let (l, t) = (rect.x, rect.y);
277    let (r, b) = (rect.x + rect.width, rect.y + rect.height);
278    Path::new()
279        .move_to(Point::new(l + tl, t))
280        .line_to(Point::new(r - tr, t))
281        .line_to(Point::new(r, t + tr))
282        .line_to(Point::new(r, b - br))
283        .line_to(Point::new(r - br, b))
284        .line_to(Point::new(l + bl, b))
285        .line_to(Point::new(l, b - bl))
286        .line_to(Point::new(l, t + tl))
287        .close()
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    /// **A stadium's radius is half its short side** (`stadium_border.dart:95`), which is
295    /// what makes its ends semicircles whatever the box's proportions — and not a corner
296    /// radius someone has to keep in step with the height.
297    #[test]
298    fn a_stadium_is_a_pill_at_any_size() {
299        let wide = Rect::new(0.0, 0.0, 200.0, 32.0);
300        let (box_, radius) = ShapeBorder::stadium().as_rounded(wide).unwrap();
301        assert_eq!(box_, wide, "it fills the box it is given");
302        assert_eq!(radius, BorderRadius::uniform(16.0));
303
304        let tall = Rect::new(0.0, 0.0, 32.0, 200.0);
305        let (_, radius) = ShapeBorder::stadium().as_rounded(tall).unwrap();
306        assert_eq!(
307            radius,
308            BorderRadius::uniform(16.0),
309            "the *short* side, either way"
310        );
311    }
312
313    /// **A circle sits in a square taken out of the middle** of the box
314    /// (`circle_border.dart:127`), and eccentricity walks it out to an ellipse filling
315    /// the box.
316    #[test]
317    fn a_circle_takes_a_square_out_of_the_middle() {
318        let wide = Rect::new(10.0, 0.0, 100.0, 40.0);
319        let (box_, radius) = ShapeBorder::circle().as_rounded(wide).unwrap();
320        assert_eq!(box_, Rect::new(40.0, 0.0, 40.0, 40.0), "centred and square");
321        assert_eq!(radius, BorderRadius::uniform(20.0));
322
323        let full = ShapeBorder::Circle {
324            eccentricity: 1.0,
325            side: BorderSide::NONE,
326        };
327        assert_eq!(
328            full.as_rounded(wide).unwrap().0,
329            wide,
330            "an eccentricity of one fills the box"
331        );
332
333        let half = ShapeBorder::Circle {
334            eccentricity: 0.5,
335            side: BorderSide::NONE,
336        };
337        assert_eq!(
338            half.as_rounded(wide).unwrap().0,
339            Rect::new(25.0, 0.0, 70.0, 40.0),
340            "and half way is half way"
341        );
342    }
343
344    /// **A bevel is the one shape that is not a rounded rectangle in disguise**, so it is
345    /// the one that has to go through the path renderer — eight straight segments where
346    /// the rounded one has four arcs.
347    #[test]
348    fn a_bevel_is_the_one_that_needs_a_path() {
349        let rect = Rect::new(0.0, 0.0, 100.0, 40.0);
350        assert!(ShapeBorder::beveled(8.0).as_rounded(rect).is_none());
351        assert!(ShapeBorder::rounded(8.0).as_rounded(rect).is_some());
352        assert!(ShapeBorder::stadium().as_rounded(rect).is_some());
353
354        // And every shape still answers with an outline.
355        for shape in [
356            ShapeBorder::beveled(8.0),
357            ShapeBorder::rounded(8.0),
358            ShapeBorder::stadium(),
359            ShapeBorder::circle(),
360        ] {
361            assert!(
362                !shape.outline(rect).verbs().is_empty(),
363                "{shape:?} has no outline"
364            );
365        }
366    }
367
368    /// A corner never asks for more than the box can give it — the reference clamps too,
369    /// and without it a radius larger than the box turns the outline inside out.
370    #[test]
371    fn a_corner_is_capped_by_the_box() {
372        let rect = Rect::new(0.0, 0.0, 20.0, 10.0);
373        assert_eq!(fitted(rect, BorderRadius::uniform(100.0)), [5.0; 4]);
374        assert_eq!(fitted(rect, BorderRadius::uniform(-4.0)), [0.0; 4]);
375    }
376
377    /// An edge is a width and a colour, and `NONE` is the absence of one.
378    #[test]
379    fn an_edge_is_drawn_only_when_there_is_one() {
380        assert!(!BorderSide::NONE.is_drawn());
381        assert!(!BorderSide::new(Color::rgb8(255, 0, 0), 0.0).is_drawn());
382        assert!(!BorderSide::new(Color::TRANSPARENT, 2.0).is_drawn());
383        assert!(BorderSide::new(Color::rgb8(255, 0, 0), 1.0).is_drawn());
384        assert_eq!(
385            ShapeBorder::stadium()
386                .with_side(BorderSide::new(Color::rgb8(255, 0, 0), 2.0))
387                .side()
388                .width,
389            2.0
390        );
391    }
392}