Skip to main content

telar_renderer_core/style/
shape.rs

1use geometry_core::Rect;
2
3use crate::{BorderRadius, Color};
4
5use super::paint::{FillRule, Paint, Shadow, Stroke};
6
7pub trait ShapeStyle: Sized {
8    fn fill_mut(&mut self) -> &mut Option<Paint>;
9    fn stroke_mut(&mut self) -> &mut Option<Stroke>;
10    fn shadow_mut(&mut self) -> &mut Option<Shadow>;
11
12    fn with_fill(mut self, fill: impl Into<Paint>) -> Self {
13        *self.fill_mut() = Some(fill.into());
14        self
15    }
16    fn with_stroke(mut self, stroke: Stroke) -> Self {
17        *self.stroke_mut() = Some(stroke);
18        self
19    }
20    fn with_shadow(mut self, shadow: Shadow) -> Self {
21        *self.shadow_mut() = Some(shadow);
22        self
23    }
24}
25
26/// How thick a rect's border is on each side.
27///
28/// [`Uniform`](Self::Uniform) is everything a [`Stroke`] on its own can say, and all a path or a line ever
29/// means by width: one number, applied the whole way round. [`PerSide`](Self::PerSide) is the case a box has
30/// and a path does not — a rule under a header, a divider down one edge — where a side of `0.0` is simply not
31/// there. Which is why the four numbers live on the rect rather than on the stroke they share a colour with.
32#[derive(Debug, Clone, Copy, PartialEq, Default)]
33pub enum BorderWidths {
34    #[default]
35    Uniform,
36    PerSide {
37        top: f32,
38        right: f32,
39        bottom: f32,
40        left: f32,
41    },
42}
43
44impl BorderWidths {
45    pub fn per_side(top: f32, right: f32, bottom: f32, left: f32) -> Self {
46        Self::PerSide {
47            top,
48            right,
49            bottom,
50            left,
51        }
52    }
53
54    /// The four thicknesses in `[top, right, bottom, left]` order, with [`Uniform`](Self::Uniform) taking its
55    /// number from the stroke it belongs to.
56    pub fn resolve(self, stroke_width: f32) -> [f32; 4] {
57        match self {
58            Self::Uniform => [stroke_width; 4],
59            Self::PerSide {
60                top,
61                right,
62                bottom,
63                left,
64            } => [top, right, bottom, left],
65        }
66    }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Default)]
70pub struct RectStyle {
71    pub fill: Option<Paint>,
72    pub stroke: Option<Stroke>,
73    pub shadow: Option<Shadow>,
74    pub radius: BorderRadius,
75    /// Which sides of the border are drawn and how thick each is; see [`BorderWidths`]. Read it through
76    /// [`border`](Self::border) rather than directly, so the uniform case resolves against `stroke.width`.
77    pub border_widths: BorderWidths,
78}
79
80impl RectStyle {
81    pub fn filled(color: Color, radius: f32) -> Self {
82        Self {
83            fill: Some(Paint::Solid(color)),
84            radius: BorderRadius::all(radius),
85            ..Self::default()
86        }
87    }
88
89    pub fn with_radius(mut self, radius: BorderRadius) -> Self {
90        self.radius = radius;
91        self
92    }
93
94    /// Draws the stroke on the named sides only, at the named thicknesses. See [`BorderWidths`].
95    pub fn with_border_widths(mut self, widths: BorderWidths) -> Self {
96        self.border_widths = widths;
97        self
98    }
99
100    /// The border this style actually paints: its paint, and the four thicknesses in
101    /// `[top, right, bottom, left]` order. `None` when there is nothing to draw — no stroke at all, or every
102    /// side sitting at zero.
103    pub fn border(&self) -> Option<(Paint, [f32; 4])> {
104        let stroke = self.stroke?;
105        let widths = self.border_widths.resolve(stroke.width);
106        widths
107            .iter()
108            .any(|w| *w > 0.0)
109            .then_some((stroke.paint, widths))
110    }
111}
112
113/// The inner edge of a border: the box pulled in by each side's thickness, with the corners tightened to
114/// match.
115///
116/// Shared rather than derived twice, because the rasterizer and the GPU have to agree to the pixel on where a
117/// border stops — one builds a path from it and the other an SDF, and a rule under a header that lands a half
118/// pixel apart between backends is a bug nobody can see until they switch machines.
119///
120/// `None` when the border leaves no interior at all: the box is thinner than its own frame, and the frame
121/// swallows it whole.
122pub fn border_inner_shape(
123    rect: Rect,
124    radius: BorderRadius,
125    widths: [f32; 4],
126) -> Option<(Rect, BorderRadius)> {
127    let [top, right, bottom, left] = widths;
128    let width = rect.width - left - right;
129    let height = rect.height - top - bottom;
130    if !(width > 0.0 && height > 0.0) {
131        return None;
132    }
133    let max_r = width.min(height) * 0.5;
134    // A corner is pulled in by the thicker of the two sides meeting there. CSS uses an ellipse when they
135    // differ; `BorderRadius` holds one number per corner, and of the two the thicker side is the one that
136    // would otherwise cut across its own arc.
137    let tighten = |r: f32, a: f32, b: f32| (r - a.max(b)).clamp(0.0, max_r);
138    let inner_radius = BorderRadius {
139        top_left: tighten(radius.top_left, top, left),
140        top_right: tighten(radius.top_right, top, right),
141        bottom_right: tighten(radius.bottom_right, bottom, right),
142        bottom_left: tighten(radius.bottom_left, bottom, left),
143    };
144    Some((
145        Rect::new(rect.x + left, rect.y + top, width, height),
146        inner_radius,
147    ))
148}
149
150impl ShapeStyle for RectStyle {
151    fn fill_mut(&mut self) -> &mut Option<Paint> {
152        &mut self.fill
153    }
154    fn stroke_mut(&mut self) -> &mut Option<Stroke> {
155        &mut self.stroke
156    }
157    fn shadow_mut(&mut self) -> &mut Option<Shadow> {
158        &mut self.shadow
159    }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Default)]
163pub struct PathStyle {
164    pub fill: Option<Paint>,
165    pub stroke: Option<Stroke>,
166    pub shadow: Option<Shadow>,
167    pub fill_rule: FillRule,
168}
169
170impl PathStyle {
171    pub fn with_fill_rule(mut self, rule: FillRule) -> Self {
172        self.fill_rule = rule;
173        self
174    }
175}
176
177impl ShapeStyle for PathStyle {
178    fn fill_mut(&mut self) -> &mut Option<Paint> {
179        &mut self.fill
180    }
181    fn stroke_mut(&mut self) -> &mut Option<Stroke> {
182        &mut self.stroke
183    }
184    fn shadow_mut(&mut self) -> &mut Option<Shadow> {
185        &mut self.shadow
186    }
187}
188
189#[cfg(test)]
190mod border_tests {
191    use super::*;
192
193    fn box_100() -> Rect {
194        Rect::new(0.0, 0.0, 100.0, 100.0)
195    }
196
197    #[test]
198    fn a_plain_stroke_still_means_all_four_sides() {
199        let style = RectStyle::default().with_stroke(Stroke::new(Color::BLACK, 2.0));
200        assert_eq!(style.border(), Some((Paint::Solid(Color::BLACK), [2.0; 4])));
201    }
202
203    /// The whole point of the type: a colour with no side to paint it on draws nothing, rather than falling
204    /// back to the stroke's own width and framing the box.
205    #[test]
206    fn every_side_at_zero_is_no_border_at_all() {
207        let style = RectStyle::default()
208            .with_stroke(Stroke::new(Color::BLACK, 2.0))
209            .with_border_widths(BorderWidths::per_side(0.0, 0.0, 0.0, 0.0));
210        assert_eq!(style.border(), None);
211    }
212
213    #[test]
214    fn a_side_of_its_own_overrides_the_strokes_width() {
215        let style = RectStyle::default()
216            .with_stroke(Stroke::new(Color::BLACK, 2.0))
217            .with_border_widths(BorderWidths::per_side(0.0, 0.0, 1.0, 0.0));
218        assert_eq!(
219            style.border(),
220            Some((Paint::Solid(Color::BLACK), [0.0, 0.0, 1.0, 0.0]))
221        );
222    }
223
224    #[test]
225    fn a_colourless_box_has_no_border_however_thick_its_sides_are() {
226        let style =
227            RectStyle::default().with_border_widths(BorderWidths::per_side(4.0, 4.0, 4.0, 4.0));
228        assert_eq!(style.border(), None);
229    }
230
231    /// A side at zero leaves the inner edge flush with the outer one there, which is what makes the ring
232    /// cover nothing along it — the rasterizer's two boundaries coincide and the shader's two SDFs agree.
233    #[test]
234    fn a_side_at_zero_leaves_the_inner_edge_flush_with_the_outer() {
235        let (inner, _) =
236            border_inner_shape(box_100(), BorderRadius::zero(), [0.0, 0.0, 1.0, 0.0]).unwrap();
237        assert_eq!(inner, Rect::new(0.0, 0.0, 100.0, 99.0));
238    }
239
240    /// The uniform case has to come out exactly where the old single-stroke path put it: outer edge on the
241    /// box, inner edge one width in, corners `r - w`.
242    #[test]
243    fn a_uniform_border_insets_every_side_and_tightens_every_corner() {
244        let (inner, radius) =
245            border_inner_shape(box_100(), BorderRadius::all(8.0), [2.0; 4]).unwrap();
246        assert_eq!(inner, Rect::new(2.0, 2.0, 96.0, 96.0));
247        assert_eq!(radius, BorderRadius::all(6.0));
248    }
249
250    /// Two sides of different thickness meet at a corner, and only one number is available to describe the
251    /// arc between them.
252    #[test]
253    fn a_corner_is_tightened_by_the_thicker_of_the_two_sides_that_meet_there() {
254        let (_, radius) =
255            border_inner_shape(box_100(), BorderRadius::all(10.0), [1.0, 0.0, 0.0, 4.0]).unwrap();
256        assert_eq!(radius.top_left, 6.0, "top 1, left 4 — the left side wins");
257        assert_eq!(radius.top_right, 9.0, "top 1, right 0");
258        assert_eq!(radius.bottom_right, 10.0, "neither side is drawn");
259        assert_eq!(radius.bottom_left, 6.0, "bottom 0, left 4");
260    }
261
262    #[test]
263    fn a_corner_never_tightens_past_straight() {
264        let (_, radius) = border_inner_shape(box_100(), BorderRadius::all(2.0), [8.0; 4]).unwrap();
265        assert_eq!(radius, BorderRadius::zero());
266    }
267
268    /// The border is thicker than the box it frames, so there is no interior left to punch out and the
269    /// caller paints the box solid instead.
270    #[test]
271    fn a_border_thicker_than_its_box_leaves_no_interior() {
272        assert!(
273            border_inner_shape(box_100(), BorderRadius::zero(), [60.0, 0.0, 60.0, 0.0]).is_none()
274        );
275        assert!(
276            border_inner_shape(box_100(), BorderRadius::zero(), [0.0, 50.0, 0.0, 50.0]).is_none()
277        );
278    }
279}