Skip to main content

teksilo_widgets/primitives/
image_mask.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Anti-aliased alpha masking for raster images — circle / rounded-square
5//! / square coverage applied in-place to RGBA8 pixel buffers.
6//!
7//! The retained renderer's `Canvas::set_clip` is rectangular-only, so to
8//! crop a photo into a circle (avatar, contact icon, channel thumbnail,
9//! etc.) we modulate the source image's alpha channel with a per-pixel
10//! coverage value computed analytically. 4×4 super-sampling (16
11//! sub-samples per pixel) gives a smooth edge at the small sizes these
12//! masks are typically used at (≤96 logical pixels).
13//!
14//! Used directly by [`ImageWidget::mask`](super::ImageWidget::mask) and
15//! by `Avatar`. Other widgets that want a non-rectangular image silhouette
16//! can call [`apply_alpha_mask`] and [`center_crop_square`] directly.
17//!
18//! ```rust
19//! # use teksilo_widgets::primitives::image_mask::{ImageMaskShape, apply_alpha_mask};
20//! let mut pixels = vec![255u8; 32 * 32 * 4]; // opaque white 32×32
21//! apply_alpha_mask(&mut pixels, 32, 32, ImageMaskShape::Circle);
22//! // Corner pixels are now transparent; the center is still opaque.
23//! assert_eq!(pixels[3], 0);         // top-left alpha
24//! assert_eq!(pixels[(16 * 32 + 16) * 4 + 3], 255); // center alpha
25//! ```
26
27/// Shape of the alpha mask applied to an image.
28///
29/// `RoundedSquare` carries the corner radius **as a fraction of the
30/// shorter side** (0.0 ⇒ square, 0.5 ⇒ circle), matching the convention
31/// `Avatar` and `ImageWidget::mask` accept on their public APIs. The
32/// `apply_alpha_mask` helper expects a radius in *pixels* — convert
33/// before calling.
34#[derive(Debug, Clone, Copy, PartialEq, Default)]
35pub enum ImageMaskShape {
36    /// No mask. The pixels pass through unchanged.
37    #[default]
38    None,
39    /// Inscribed circle in the image's bounding square (after a centred
40    /// crop to the shorter side).
41    Circle,
42    /// Rounded rectangle. The carried `f32` is the corner radius as a
43    /// fraction of `min(width, height)`, clamped to `0.0..=0.5`.
44    RoundedSquare(f32),
45}
46
47/// Internal mask shape used by `apply_alpha_mask` after the radius
48/// has been resolved to pixel space. Kept private so callers don't
49/// accidentally mix the ratio API and the absolute API.
50#[derive(Debug, Clone, Copy)]
51enum MaskShape {
52    Circle,
53    RoundedSquare(f32),
54    Square,
55}
56
57const SAMPLES_PER_AXIS: u32 = 4;
58
59/// Crop the source RGBA buffer to a centered square of edge `min(w, h)`.
60/// The returned buffer is `side * side * 4` bytes. If the input is
61/// already square, a copy of the original is returned.
62pub fn center_crop_square(pixels: &[u8], width: u32, height: u32) -> (Vec<u8>, u32) {
63    let side = width.min(height);
64    if width == side && height == side {
65        return (pixels.to_vec(), side);
66    }
67    debug_assert_eq!(
68        pixels.len(),
69        (width * height * 4) as usize,
70        "pixel buffer length must be width * height * 4"
71    );
72    let x_off = ((width - side) / 2) as usize;
73    let y_off = ((height - side) / 2) as usize;
74    let stride = (width * 4) as usize;
75    let row_bytes = (side * 4) as usize;
76    let mut out = Vec::with_capacity((side as usize) * row_bytes);
77    for j in 0..side as usize {
78        let row_start = (y_off + j) * stride + x_off * 4;
79        out.extend_from_slice(&pixels[row_start..row_start + row_bytes]);
80    }
81    (out, side)
82}
83
84/// Apply an alpha mask in-place to an RGBA8 buffer. RGB channels are
85/// preserved; only alpha is modulated by the coverage value, so a
86/// pre-multiplied source remains pre-multiplied (the alpha-channel-only
87/// transformation matches `RasterIcon::to_alpha_mask`).
88///
89/// The `shape` accepts the public [`ImageMaskShape`] surface; the
90/// `RoundedSquare` radius is interpreted as a **fraction** of
91/// `min(width, height)`, clamped to `0.0..=0.5`. `None` is a no-op.
92pub fn apply_alpha_mask(pixels: &mut [u8], width: u32, height: u32, shape: ImageMaskShape) {
93    debug_assert_eq!(pixels.len(), (width * height * 4) as usize);
94    let internal = match shape {
95        ImageMaskShape::None => return,
96        ImageMaskShape::Circle => MaskShape::Circle,
97        ImageMaskShape::RoundedSquare(ratio) => {
98            let r = ratio.clamp(0.0, 0.5) * (width.min(height) as f32);
99            if r <= 0.0 {
100                MaskShape::Square
101            } else {
102                MaskShape::RoundedSquare(r)
103            }
104        }
105    };
106    let radius = match internal {
107        MaskShape::Square => return,
108        MaskShape::Circle => (width.min(height) as f32) / 2.0,
109        MaskShape::RoundedSquare(r) => r,
110    };
111    apply_rounded(pixels, width, height, radius);
112}
113
114fn apply_rounded(pixels: &mut [u8], width: u32, height: u32, radius: f32) {
115    if width == 0 || height == 0 {
116        return;
117    }
118    let w = width as f32;
119    let h = height as f32;
120    let r = radius.clamp(0.0, (w.min(h)) / 2.0);
121    if r <= 0.0 {
122        // Square corner — every pixel fully covered, nothing to do.
123        return;
124    }
125
126    for j in 0..height {
127        for i in 0..width {
128            let coverage = pixel_coverage(i as f32, j as f32, w, h, r);
129            let idx = ((j * width + i) * 4 + 3) as usize;
130            let original = pixels[idx] as f32;
131            // Round-to-nearest, not truncate, so a fully-covered pixel
132            // stays at 255 instead of rounding down.
133            let masked = (original * coverage + 0.5).clamp(0.0, 255.0) as u8;
134            pixels[idx] = masked;
135        }
136    }
137}
138
139/// Coverage of one pixel by a rounded-rectangle of size `w` × `h` with
140/// corner radius `r`, super-sampled `SAMPLES_PER_AXIS²` times. The
141/// pixel's top-left integer coordinate is `(px, py)`.
142///
143/// Each sub-sample is at the center of its sub-pixel cell; coverage is
144/// `1.0` if the sub-sample is inside the rounded rectangle, `0.0`
145/// otherwise. The mean over all samples is the pixel's anti-aliased
146/// alpha multiplier.
147fn pixel_coverage(px: f32, py: f32, w: f32, h: f32, r: f32) -> f32 {
148    let mut hits: u32 = 0;
149    let total = SAMPLES_PER_AXIS * SAMPLES_PER_AXIS;
150    for sy in 0..SAMPLES_PER_AXIS {
151        for sx in 0..SAMPLES_PER_AXIS {
152            let sub_x = px + (sx as f32 + 0.5) / SAMPLES_PER_AXIS as f32;
153            let sub_y = py + (sy as f32 + 0.5) / SAMPLES_PER_AXIS as f32;
154            if inside_rounded_rect(sub_x, sub_y, w, h, r) {
155                hits += 1;
156            }
157        }
158    }
159    hits as f32 / total as f32
160}
161
162#[inline]
163fn inside_rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> bool {
164    if x < 0.0 || y < 0.0 || x > w || y > h {
165        return false;
166    }
167    // Closest point of the inner "rounded core" rectangle [r..w-r] × [r..h-r].
168    let cx = x.clamp(r, w - r);
169    let cy = y.clamp(r, h - r);
170    let dx = x - cx;
171    let dy = y - cy;
172    dx * dx + dy * dy <= r * r
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn solid(width: u32, height: u32) -> Vec<u8> {
180        // RGBA = (10, 20, 30, 200) so we can detect RGB preservation.
181        let mut v = Vec::with_capacity((width * height * 4) as usize);
182        for _ in 0..(width * height) {
183            v.extend_from_slice(&[10, 20, 30, 200]);
184        }
185        v
186    }
187
188    fn alpha_at(pixels: &[u8], width: u32, x: u32, y: u32) -> u8 {
189        pixels[((y * width + x) * 4 + 3) as usize]
190    }
191
192    #[test]
193    fn mask_circle_zeros_corners() {
194        let mut pixels = solid(32, 32);
195        apply_alpha_mask(&mut pixels, 32, 32, ImageMaskShape::Circle);
196        // All four corners are fully outside an inscribed circle.
197        assert_eq!(alpha_at(&pixels, 32, 0, 0), 0);
198        assert_eq!(alpha_at(&pixels, 32, 31, 0), 0);
199        assert_eq!(alpha_at(&pixels, 32, 0, 31), 0);
200        assert_eq!(alpha_at(&pixels, 32, 31, 31), 0);
201    }
202
203    #[test]
204    fn mask_circle_full_center() {
205        let mut pixels = solid(32, 32);
206        apply_alpha_mask(&mut pixels, 32, 32, ImageMaskShape::Circle);
207        // The center pixel sits well inside the circle and must
208        // preserve the source alpha exactly.
209        assert_eq!(alpha_at(&pixels, 32, 16, 16), 200);
210    }
211
212    #[test]
213    fn mask_circle_aa_at_boundary() {
214        // The 32×32 inscribed circle has radius 16 centred at (16, 16).
215        // At y = 4 the boundary x is 16 ± √(256 − 144) ≈ 16 ± 10.58, so
216        // pixel (5, 4) (centre 5.5, 4.5) straddles the curve — some
217        // sub-samples are inside the circle, some outside, so the
218        // resulting alpha must be strictly between 0 and 200.
219        let mut pixels = solid(32, 32);
220        apply_alpha_mask(&mut pixels, 32, 32, ImageMaskShape::Circle);
221        let edge = alpha_at(&pixels, 32, 5, 4);
222        assert!(
223            edge > 0 && edge < 200,
224            "expected partial coverage at the curve boundary, got {edge}"
225        );
226    }
227
228    #[test]
229    fn mask_rounded_square_radius_zero_is_passthrough() {
230        let mut pixels = solid(16, 16);
231        apply_alpha_mask(&mut pixels, 16, 16, ImageMaskShape::RoundedSquare(0.0));
232        // Every pixel keeps its original alpha.
233        for j in 0..16 {
234            for i in 0..16 {
235                assert_eq!(alpha_at(&pixels, 16, i, j), 200);
236            }
237        }
238    }
239
240    #[test]
241    fn mask_rounded_square_full_radius_equals_circle() {
242        let mut a = solid(24, 24);
243        let mut b = solid(24, 24);
244        apply_alpha_mask(&mut a, 24, 24, ImageMaskShape::Circle);
245        // ratio = 0.5 ⇒ radius = 0.5 × 24 = 12 ⇒ matches a circle.
246        apply_alpha_mask(&mut b, 24, 24, ImageMaskShape::RoundedSquare(0.5));
247        // Both formulas reduce to a circle when radius == size/2 of a
248        // square buffer. Allow a 1-LSB rounding tolerance.
249        for (av, bv) in a.iter().zip(b.iter()) {
250            assert!(
251                av.abs_diff(*bv) <= 1,
252                "circle and full-radius rounded-square should match within 1 alpha LSB"
253            );
254        }
255    }
256
257    #[test]
258    fn mask_preserves_rgb() {
259        let mut pixels = solid(16, 16);
260        apply_alpha_mask(&mut pixels, 16, 16, ImageMaskShape::Circle);
261        for i in (0..pixels.len()).step_by(4) {
262            assert_eq!(pixels[i], 10);
263            assert_eq!(pixels[i + 1], 20);
264            assert_eq!(pixels[i + 2], 30);
265        }
266    }
267
268    #[test]
269    fn mask_none_is_noop() {
270        let mut pixels = solid(8, 8);
271        apply_alpha_mask(&mut pixels, 8, 8, ImageMaskShape::None);
272        for j in 0..8 {
273            for i in 0..8 {
274                assert_eq!(alpha_at(&pixels, 8, i, j), 200);
275            }
276        }
277    }
278
279    #[test]
280    fn mask_handles_size_one_image() {
281        // A 1×1 image masked to a circle inscribed in 1×1: only the
282        // four sub-samples within √(0.5)−0.5 of the centre are inside
283        // the unit-diameter circle, so the result is partial coverage.
284        // The contract is no panic + alpha non-zero + alpha ≤ source.
285        let mut pixels = vec![10, 20, 30, 200];
286        apply_alpha_mask(&mut pixels, 1, 1, ImageMaskShape::Circle);
287        assert!(pixels[3] > 0, "1×1 alpha must remain non-zero");
288        assert!(pixels[3] <= 200, "1×1 alpha cannot exceed source");
289        // RGB still preserved.
290        assert_eq!(&pixels[..3], &[10, 20, 30]);
291    }
292
293    #[test]
294    fn mask_circle_alpha_multiplied_with_source() {
295        // A pixel-dim source (alpha = 100) in the circle's interior
296        // must keep its 100/255 alpha — not be promoted to 255.
297        let mut pixels = Vec::with_capacity(32 * 32 * 4);
298        for _ in 0..(32 * 32) {
299            pixels.extend_from_slice(&[10, 20, 30, 100]);
300        }
301        apply_alpha_mask(&mut pixels, 32, 32, ImageMaskShape::Circle);
302        assert_eq!(alpha_at(&pixels, 32, 16, 16), 100);
303        // Corner is still zero — coverage zero × source alpha = 0.
304        assert_eq!(alpha_at(&pixels, 32, 0, 0), 0);
305    }
306
307    #[test]
308    fn center_crop_square_is_identity_when_already_square() {
309        let p = solid(16, 16);
310        let (out, side) = center_crop_square(&p, 16, 16);
311        assert_eq!(side, 16);
312        assert_eq!(out, p);
313    }
314
315    #[test]
316    fn center_crop_square_landscape() {
317        // 8 wide × 4 tall: should crop the centered 4×4 square (cols 2..6).
318        let mut pixels = Vec::new();
319        for y in 0..4 {
320            for x in 0..8 {
321                pixels.extend_from_slice(&[x as u8, y as u8, 0, 255]);
322            }
323        }
324        let (out, side) = center_crop_square(&pixels, 8, 4);
325        assert_eq!(side, 4);
326        assert_eq!(out.len(), 4 * 4 * 4);
327        // Top-left of the crop is the column at x = 2 of the original.
328        assert_eq!(out[0], 2);
329        // Bottom-right of the crop is the column at x = 5 of the original.
330        let last = out.len() - 4;
331        assert_eq!(out[last], 5);
332    }
333
334    #[test]
335    fn center_crop_square_portrait() {
336        // 4 wide × 8 tall: should crop rows 2..6.
337        let mut pixels = Vec::new();
338        for y in 0..8 {
339            for x in 0..4 {
340                pixels.extend_from_slice(&[x as u8, y as u8, 0, 255]);
341            }
342        }
343        let (out, side) = center_crop_square(&pixels, 4, 8);
344        assert_eq!(side, 4);
345        assert_eq!(out[1], 2); // first pixel's y == 2
346    }
347}