Skip to main content

denise_render/
rounded.rs

1//! Anti-aliased rounded rectangles.
2//!
3//! The shape is evaluated analytically per scanline rather than by rendering a
4//! path. For each row the corner arcs give an exact horizontal inset, and the only
5//! approximation is that the arc is sampled at [`SUBSAMPLES`] sub-rows instead of
6//! integrated exactly. That is enough at UI radii and costs a handful of integer
7//! square roots per row — no path building, no allocation, no floating point.
8
9use denise::Rect;
10
11use crate::blend::{Paint, blend_span};
12use crate::canvas::Canvas;
13pub(crate) use denise::angle::{COORD_LIMIT, ONE, to_fx};
14
15/// Sub-rows sampled per scanline. Four is the point where the near-horizontal top
16/// of a corner stops looking stepped; eight is not visibly better.
17pub(crate) const SUBSAMPLES: usize = 4;
18
19/// Vertical distance between sub-rows.
20pub(crate) const SUB_STEP: i32 = ONE / SUBSAMPLES as i32;
21
22#[inline]
23pub(crate) fn floor_px(v: i32) -> i32 {
24    v.div_euclid(ONE)
25}
26
27#[inline]
28pub(crate) fn ceil_px(v: i32) -> i32 {
29    (v + ONE - 1).div_euclid(ONE)
30}
31
32/// `sqrt(radius² - dy²)`, all in fixed point.
33#[inline]
34fn arc_half_width(radius: i32, dy: i32) -> i32 {
35    let r2 = (radius as i64 * radius as i64) as u64;
36    let d2 = (dy as i64 * dy as i64) as u64;
37    r2.saturating_sub(d2).isqrt() as i32
38}
39
40/// Where a rounded rectangle starts and ends on one scanline, sampled at several
41/// sub-rows and kept sub-pixel.
42#[derive(Clone, Copy, Debug)]
43pub(crate) struct Scan {
44    pub(crate) left: [i32; SUBSAMPLES],
45    pub(crate) right: [i32; SUBSAMPLES],
46}
47
48impl Scan {
49    /// Computes the extent of `rect` with corner `radius` on row `y`.
50    pub(crate) fn new(rect: Rect, radius: i32, y: i32) -> Self {
51        let rad = to_fx(radius);
52        let top = to_fx(rect.y);
53        let bottom = to_fx(rect.bottom());
54        let left_edge = to_fx(rect.x);
55        let right_edge = to_fx(rect.right());
56
57        let mut left = [0; SUBSAMPLES];
58        let mut right = [0; SUBSAMPLES];
59
60        for k in 0..SUBSAMPLES {
61            let sy = to_fx(y) + k as i32 * SUB_STEP + SUB_STEP / 2;
62
63            // Distance into whichever corner band this sub-row falls in. Outside
64            // both bands the inset is zero and the row is a plain rectangle, which
65            // is also what a zero radius gives everywhere.
66            let from_top = sy - top;
67            let from_bottom = bottom - sy;
68            let dy = if from_top < rad {
69                rad - from_top
70            } else if from_bottom < rad {
71                rad - from_bottom
72            } else {
73                0
74            };
75
76            let inset = if dy > 0 {
77                rad - arc_half_width(rad, dy)
78            } else {
79                0
80            };
81
82            left[k] = left_edge + inset;
83            right[k] = right_edge - inset;
84        }
85
86        Scan { left, right }
87    }
88
89    /// Coverage of pixel column `x`, `0..=255`.
90    pub(crate) fn coverage(&self, x: i32) -> u32 {
91        let px0 = to_fx(x);
92        let px1 = px0 + ONE;
93        let mut covered: i32 = 0;
94        for k in 0..SUBSAMPLES {
95            let l = self.left[k].max(px0);
96            let r = self.right[k].min(px1);
97            covered += (r - l).max(0);
98        }
99        // Rounded, not truncated. Truncation leaves a pixel that is 99.9% covered
100        // reading as 254, which shows up as a hairline seam wherever a fill meets
101        // the solid span next to it.
102        let total = ONE as u32 * SUBSAMPLES as u32;
103        ((covered as u32 * 255 + total / 2) / total).min(255)
104    }
105
106    #[inline]
107    pub(crate) fn min_left(&self) -> i32 {
108        *self.left.iter().min().expect("SUBSAMPLES > 0")
109    }
110
111    #[inline]
112    pub(crate) fn max_left(&self) -> i32 {
113        *self.left.iter().max().expect("SUBSAMPLES > 0")
114    }
115
116    #[inline]
117    pub(crate) fn min_right(&self) -> i32 {
118        *self.right.iter().min().expect("SUBSAMPLES > 0")
119    }
120
121    #[inline]
122    pub(crate) fn max_right(&self) -> i32 {
123        *self.right.iter().max().expect("SUBSAMPLES > 0")
124    }
125}
126
127/// Coverage of a filled shape, or of the gap between two — a stroke.
128struct Coverage<'a> {
129    outer: &'a Scan,
130    inner: Option<&'a Scan>,
131}
132
133impl Coverage<'_> {
134    #[inline]
135    fn at(&self, x: i32) -> u32 {
136        let outer = self.outer.coverage(x);
137        match self.inner {
138            None => outer,
139            Some(inner) => {
140                let hole = inner.coverage(x);
141                if hole == 0 {
142                    outer
143                } else {
144                    // Correct rounding matters here: an off-by-one on a 1px stroke
145                    // is a visible seam between the band and the solid interior.
146                    (outer * (255 - hole) + 127) / 255
147                }
148            }
149        }
150    }
151}
152
153impl Canvas<'_> {
154    /// Fills a rectangle with rounded corners, anti-aliased.
155    ///
156    /// `radius` is clamped to half the shorter side; a radius of zero is exactly
157    /// [`Canvas::fill_rect`].
158    pub fn fill_rounded_rect(&mut self, rect: Rect, radius: i32, color: impl Into<Paint>) {
159        let paint = color.into();
160        if paint.is_invisible() || rect.is_empty() {
161            return;
162        }
163        let radius = radius.clamp(0, rect.width.min(rect.height) / 2);
164        if radius == 0 {
165            self.fill_rect(rect, paint);
166            return;
167        }
168        let Some(vis) = self.visible(rect) else {
169            return;
170        };
171
172        for y in vis.y..vis.bottom() {
173            let outer = Scan::new(rect, radius, y);
174            let cov = Coverage {
175                outer: &outer,
176                inner: None,
177            };
178            self.emit_run(
179                y,
180                floor_px(outer.min_left()),
181                ceil_px(outer.max_right()),
182                Some((ceil_px(outer.max_left()), floor_px(outer.min_right()))),
183                &cov,
184                paint,
185            );
186        }
187    }
188
189    /// Draws a rounded border of `thickness` pixels inside `rect`, anti-aliased.
190    ///
191    /// The inner radius follows the outer one so the band keeps a constant width
192    /// around the corner.
193    pub fn stroke_rounded_rect(
194        &mut self,
195        rect: Rect,
196        radius: i32,
197        thickness: i32,
198        color: impl Into<Paint>,
199    ) {
200        let paint = color.into();
201        let t = thickness.max(0);
202        if t == 0 || rect.is_empty() || paint.is_invisible() {
203            return;
204        }
205        let radius = radius.clamp(0, rect.width.min(rect.height) / 2);
206        if t * 2 >= rect.width.min(rect.height) {
207            self.fill_rounded_rect(rect, radius, paint);
208            return;
209        }
210
211        let inner = Rect::new(
212            rect.x + t,
213            rect.y + t,
214            rect.width - 2 * t,
215            rect.height - 2 * t,
216        );
217        let inner_radius = (radius - t).max(0);
218
219        let Some(vis) = self.visible(rect) else {
220            return;
221        };
222
223        for y in vis.y..vis.bottom() {
224            let outer = Scan::new(rect, radius, y);
225
226            // Above and below the inner rectangle the whole row is stroke.
227            if y < inner.y || y >= inner.bottom() {
228                let cov = Coverage {
229                    outer: &outer,
230                    inner: None,
231                };
232                self.emit_run(
233                    y,
234                    floor_px(outer.min_left()),
235                    ceil_px(outer.max_right()),
236                    Some((ceil_px(outer.max_left()), floor_px(outer.min_right()))),
237                    &cov,
238                    paint,
239                );
240                continue;
241            }
242
243            let inner_scan = Scan::new(inner, inner_radius, y);
244            let cov = Coverage {
245                outer: &outer,
246                inner: Some(&inner_scan),
247            };
248
249            // Two bands, skipping the interior entirely rather than blending it at
250            // zero coverage. On a 1080p dialog that is the difference between
251            // touching the border and touching the whole rectangle.
252            self.emit_run(
253                y,
254                floor_px(outer.min_left()),
255                ceil_px(inner_scan.max_left()),
256                Some((ceil_px(outer.max_left()), floor_px(inner_scan.min_left()))),
257                &cov,
258                paint,
259            );
260            self.emit_run(
261                y,
262                floor_px(inner_scan.min_right()),
263                ceil_px(outer.max_right()),
264                Some((ceil_px(inner_scan.max_right()), floor_px(outer.min_right()))),
265                &cov,
266                paint,
267            );
268        }
269    }
270
271    /// Emits one horizontal run: anti-aliased fringe, solid span, anti-aliased
272    /// fringe. `solid` is the half-open range that is known to be fully covered.
273    fn emit_run(
274        &mut self,
275        y: i32,
276        from: i32,
277        to: i32,
278        solid: Option<(i32, i32)>,
279        cov: &Coverage<'_>,
280        paint: Paint,
281    ) {
282        let clip = self.clip();
283        let from = from.max(clip.x);
284        let to = to.min(clip.right());
285        if from >= to {
286            return;
287        }
288
289        let (s0, s1) = match solid {
290            Some((s0, s1)) if s0 < s1 => (s0.clamp(from, to), s1.clamp(from, to)),
291            _ => (to, to),
292        };
293
294        for x in from..s0 {
295            self.blend_at(x, y, paint, cov.at(x));
296        }
297        if s0 < s1
298            && let Some(span) = self.row_span(y, s0, s1)
299        {
300            blend_span(span, paint);
301        }
302        for x in s1..to {
303            self.blend_at(x, y, paint, cov.at(x));
304        }
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::testing::TestCanvas;
312    use denise::Color;
313
314    fn alpha_of(px: u32) -> u32 {
315        // Opaque white on a black canvas, so any channel reads back as coverage.
316        px & 0xFF
317    }
318
319    #[test]
320    fn zero_radius_is_exactly_a_fill() {
321        let mut rounded = TestCanvas::new(16, 16);
322        rounded
323            .canvas()
324            .fill_rounded_rect(Rect::new(2, 2, 12, 12), 0, Color::WHITE);
325
326        let mut square = TestCanvas::new(16, 16);
327        square
328            .canvas()
329            .fill_rect(Rect::new(2, 2, 12, 12), Color::WHITE);
330
331        assert_eq!(rounded.pixels(), square.pixels());
332    }
333
334    #[test]
335    fn corners_are_cut_and_the_middle_is_not() {
336        let mut t = TestCanvas::new(32, 32);
337        t.canvas()
338            .fill_rounded_rect(Rect::new(0, 0, 32, 32), 8, Color::WHITE);
339
340        assert_eq!(alpha_of(t.at(0, 0)), 0, "corner must be empty");
341        assert_eq!(alpha_of(t.at(16, 0)), 255, "top edge must be solid");
342        assert_eq!(alpha_of(t.at(0, 16)), 255, "left edge must be solid");
343        assert_eq!(alpha_of(t.at(16, 16)), 255, "centre must be solid");
344        assert_eq!(alpha_of(t.at(31, 31)), 0, "corner must be empty");
345    }
346
347    #[test]
348    fn corners_are_antialiased_not_stepped() {
349        let radius = 10;
350        let mut t = TestCanvas::new(32, 32);
351        t.canvas()
352            .fill_rounded_rect(Rect::new(0, 0, 32, 32), radius, Color::WHITE);
353
354        // Not along the 45° diagonal: there the arc runs perpendicular to the walk
355        // and genuinely does step from 0 to 255 in one pixel. The anti-aliasing
356        // lives on the near-horizontal and near-vertical stretches of the arc, so
357        // count partial pixels over the whole corner block instead.
358        let partial = (0..radius)
359            .flat_map(|y| (0..radius).map(move |x| (x, y)))
360            .filter(|&(x, y)| (1..255).contains(&alpha_of(t.at(x, y))))
361            .count();
362
363        // An arc of radius 10 crosses roughly 2r pixel boundaries; well under half
364        // that means the edge is being quantised, not anti-aliased.
365        assert!(
366            partial >= radius as usize,
367            "only {partial} partially covered pixels in a radius-{radius} corner"
368        );
369    }
370
371    #[test]
372    fn coverage_reaches_both_extremes() {
373        // Rounding must still let a fully covered pixel read as opaque and a fully
374        // empty one as clear, rather than parking everything in between.
375        let mut t = TestCanvas::new(32, 32);
376        t.canvas()
377            .fill_rounded_rect(Rect::new(0, 0, 32, 32), 10, Color::WHITE);
378        assert_eq!(alpha_of(t.at(0, 0)), 0);
379        assert_eq!(alpha_of(t.at(16, 16)), 255);
380    }
381
382    #[test]
383    fn shape_is_symmetric() {
384        let mut t = TestCanvas::new(32, 24);
385        t.canvas()
386            .fill_rounded_rect(Rect::new(0, 0, 32, 24), 7, Color::WHITE);
387
388        for y in 0..24 {
389            for x in 0..16 {
390                assert_eq!(t.at(x, y), t.at(31 - x, y), "mirror at {x},{y}");
391            }
392        }
393        for y in 0..12 {
394            for x in 0..32 {
395                assert_eq!(t.at(x, y), t.at(x, 23 - y), "flip at {x},{y}");
396            }
397        }
398    }
399
400    #[test]
401    fn radius_is_clamped_to_half_the_shorter_side() {
402        // A radius past the clamp is a stadium, not an error, and must stay inside.
403        let mut t = TestCanvas::new(40, 20);
404        t.canvas()
405            .fill_rounded_rect(Rect::new(0, 0, 40, 20), 999, Color::WHITE);
406        assert_eq!(alpha_of(t.at(20, 10)), 255);
407        assert_eq!(alpha_of(t.at(0, 0)), 0);
408        assert_eq!(alpha_of(t.at(20, 0)), 255);
409    }
410
411    #[test]
412    fn fill_stays_inside_its_bounds() {
413        let mut t = TestCanvas::new(32, 32);
414        t.canvas()
415            .fill_rounded_rect(Rect::new(8, 8, 16, 16), 4, Color::WHITE);
416        for y in 0..32 {
417            for x in 0..32 {
418                let inside = (8..24).contains(&x) && (8..24).contains(&y);
419                if !inside {
420                    assert_eq!(t.at(x, y), 0, "spilled at {x},{y}");
421                }
422            }
423        }
424    }
425
426    #[test]
427    fn clipping_a_rounded_fill_matches_the_unclipped_result() {
428        let region = Rect::new(4, 4, 10, 10);
429
430        let mut full = TestCanvas::new(32, 32);
431        full.canvas()
432            .fill_rounded_rect(Rect::new(2, 2, 24, 24), 6, Color::WHITE);
433
434        let mut clipped = TestCanvas::new(32, 32);
435        {
436            let mut c = clipped.canvas();
437            c.clip_to(region);
438            c.fill_rounded_rect(Rect::new(2, 2, 24, 24), 6, Color::WHITE);
439        }
440
441        for y in 0..32 {
442            for x in 0..32 {
443                let expected = if region.contains(denise::Point::new(x, y)) {
444                    full.at(x, y)
445                } else {
446                    0
447                };
448                assert_eq!(clipped.at(x, y), expected, "at {x},{y}");
449            }
450        }
451    }
452
453    #[test]
454    fn stroke_leaves_the_interior_alone() {
455        let mut t = TestCanvas::new(32, 32);
456        t.canvas()
457            .stroke_rounded_rect(Rect::new(2, 2, 28, 28), 8, 3, Color::WHITE);
458        assert_eq!(alpha_of(t.at(16, 16)), 0, "interior must be untouched");
459        assert_eq!(alpha_of(t.at(16, 2)), 255, "top band must be solid");
460        assert_eq!(alpha_of(t.at(2, 16)), 255, "left band must be solid");
461        assert_eq!(alpha_of(t.at(16, 6)), 0, "just inside the band");
462    }
463
464    #[test]
465    fn stroke_covers_the_band_without_seams() {
466        // Every pixel across the band, at the mid-height where the stroke is
467        // vertical, must be fully covered. A gap here is the classic
468        // outer-minus-inner rounding seam.
469        let mut t = TestCanvas::new(40, 40);
470        t.canvas()
471            .stroke_rounded_rect(Rect::new(4, 4, 32, 32), 10, 4, Color::WHITE);
472        for x in 4..8 {
473            assert_eq!(alpha_of(t.at(x, 20)), 255, "seam at x={x}");
474        }
475    }
476
477    #[test]
478    fn stroke_thicker_than_the_shape_is_a_fill() {
479        let mut t = TestCanvas::new(32, 32);
480        t.canvas()
481            .stroke_rounded_rect(Rect::new(4, 4, 16, 16), 4, 99, Color::WHITE);
482        assert_eq!(alpha_of(t.at(12, 12)), 255);
483        assert_eq!(alpha_of(t.at(0, 0)), 0);
484    }
485
486    #[test]
487    fn stroke_alpha_does_not_double_up_anywhere() {
488        // Two bands meeting must never composite the same pixel twice.
489        let mut t = TestCanvas::new(40, 40);
490        t.canvas().stroke_rounded_rect(
491            Rect::new(4, 4, 32, 32),
492            10,
493            3,
494            Color::rgba(255, 255, 255, 128),
495        );
496        let single = alpha_of(t.at(20, 4));
497        for y in 0..40 {
498            for x in 0..40 {
499                assert!(
500                    alpha_of(t.at(x, y)) <= single,
501                    "double-composited at {x},{y}"
502                );
503            }
504        }
505    }
506
507    #[test]
508    fn degenerate_rects_do_not_panic() {
509        let mut t = TestCanvas::new(16, 16);
510        let mut c = t.canvas();
511        c.fill_rounded_rect(Rect::new(0, 0, 1, 1), 4, Color::WHITE);
512        c.fill_rounded_rect(Rect::new(0, 0, 0, 10), 4, Color::WHITE);
513        c.fill_rounded_rect(Rect::new(-100, -100, 8, 8), 3, Color::WHITE);
514        c.stroke_rounded_rect(Rect::new(0, 0, 2, 2), 1, 1, Color::WHITE);
515        c.stroke_rounded_rect(Rect::new(1_000_000, 0, 8, 8), 3, 1, Color::WHITE);
516    }
517}