Skip to main content

pixelcoords_core/
draw.rs

1//! CPU rasterizer: shapes, text, and label placement into a `u32` pixel
2//! buffer (`0x00RRGGBB`, softbuffer's format), plus the alpha mask applied
3//! to circle crops. Everything clips to the buffer — drawing partially or
4//! fully off-buffer is safe and silent.
5
6use crate::font;
7use crate::geometry::{Line, Point, Rect, Shape, Size, scanline_spans};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct Color {
11    pub r: u8,
12    pub g: u8,
13    pub b: u8,
14}
15
16impl Color {
17    pub const WHITE: Self = Self {
18        r: 255,
19        g: 255,
20        b: 255,
21    };
22
23    pub const fn to_0rgb(self) -> u32 {
24        ((self.r as u32) << 16) | ((self.g as u32) << 8) | (self.b as u32)
25    }
26
27    /// Uppercase `#RRGGBB`, the inverse of
28    /// [`crate::config::parse_hex_color`].
29    ///
30    /// No alpha: screens are opaque, and a captured pixel's fourth byte
31    /// says nothing about what was on screen. No color-space conversion
32    /// either — this is the captured PNG's sRGB byte triple and nothing
33    /// more, which is what the docs promise.
34    #[must_use]
35    pub fn to_hex(self) -> String {
36        format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
37    }
38}
39
40/// The color of one pixel of an RGBA8 buffer, or `None` when the point
41/// lies outside it.
42///
43/// Sampled from the frozen capture rather than anything the overlay drew
44/// on top: the chrome is painted into a separate presentation buffer, so
45/// a readout taken here reports the screen, never the crosshair sitting
46/// over it.
47#[must_use]
48pub fn sample_rgba(rgba: &[u8], w: i32, h: i32, at: Point) -> Option<Color> {
49    if at.x < 0 || at.y < 0 || at.x >= w || at.y >= h {
50        return None;
51    }
52    let index = (at.y as usize * w as usize + at.x as usize) * 4;
53    let px = rgba.get(index..index + 3)?;
54    Some(Color {
55        r: px[0],
56        g: px[1],
57        b: px[2],
58    })
59}
60
61pub struct Canvas<'a> {
62    px: &'a mut [u32],
63    pub w: i32,
64    pub h: i32,
65}
66
67impl<'a> Canvas<'a> {
68    /// Wrap a pixel buffer. `px.len()` must equal `w * h`.
69    pub fn new(px: &'a mut [u32], w: i32, h: i32) -> Self {
70        assert!(w > 0 && h > 0, "canvas dimensions must be positive");
71        assert_eq!(
72            px.len(),
73            (w as usize) * (h as usize),
74            "buffer size mismatch"
75        );
76        Self { px, w, h }
77    }
78
79    fn set(&mut self, x: i32, y: i32, color: u32) {
80        if x >= 0 && y >= 0 && x < self.w && y < self.h {
81            self.px[(y as usize) * (self.w as usize) + (x as usize)] = color;
82        }
83    }
84
85    pub fn fill_rect(&mut self, rect: Rect, color: Color) {
86        let c = color.to_0rgb();
87        let x0 = rect.x.max(0);
88        let y0 = rect.y.max(0);
89        let x1 = (rect.x + rect.w).min(self.w);
90        let y1 = (rect.y + rect.h).min(self.h);
91        if x1 <= x0 || y1 <= y0 {
92            return;
93        }
94        for y in y0..y1 {
95            let row = (y as usize) * (self.w as usize);
96            self.px[row + x0 as usize..row + x1 as usize].fill(c);
97        }
98    }
99
100    /// Darken already-drawn pixels inside `rect` to `strength`/256 of
101    /// their brightness — a backdrop panel without an alpha channel. 0
102    /// blacks out, 256 leaves the pixels untouched.
103    pub fn dim_rect(&mut self, rect: Rect, strength: u32) {
104        let x0 = rect.x.max(0);
105        let y0 = rect.y.max(0);
106        let x1 = (rect.x + rect.w).min(self.w);
107        let y1 = (rect.y + rect.h).min(self.h);
108        for y in y0..y1 {
109            let row = (y as usize) * (self.w as usize);
110            for px in &mut self.px[row + x0 as usize..row + x1.max(x0) as usize] {
111                let r = (((*px >> 16) & 0xFF) * strength) >> 8;
112                let g = (((*px >> 8) & 0xFF) * strength) >> 8;
113                let b = ((*px & 0xFF) * strength) >> 8;
114                *px = (r << 16) | (g << 8) | b;
115            }
116        }
117    }
118
119    pub fn draw_rect_outline(&mut self, rect: Rect, color: Color, thickness: i32) {
120        if thickness <= 0 {
121            return;
122        }
123        let t = thickness.min(rect.w).min(rect.h);
124        self.fill_rect(Rect::new(rect.x, rect.y, rect.w, t), color);
125        self.fill_rect(Rect::new(rect.x, rect.y + rect.h - t, rect.w, t), color);
126        self.fill_rect(Rect::new(rect.x, rect.y, t, rect.h), color);
127        self.fill_rect(Rect::new(rect.x + rect.w - t, rect.y, t, rect.h), color);
128    }
129
130    pub fn fill_circle(&mut self, cx: i32, cy: i32, r: i32, color: Color) {
131        self.circle_band(cx, cy, 0, r, color);
132    }
133
134    pub fn draw_circle_outline(&mut self, cx: i32, cy: i32, r: i32, color: Color, thickness: i32) {
135        if thickness <= 0 {
136            return;
137        }
138        self.circle_band(cx, cy, (r - thickness).max(0), r, color);
139    }
140
141    /// Fill pixels whose distance from the center lies in (`inner`, `outer`]
142    /// — `inner = 0` fills the disc.
143    fn circle_band(&mut self, cx: i32, cy: i32, inner: i32, outer: i32, color: Color) {
144        if outer <= 0 {
145            return;
146        }
147        let c = color.to_0rgb();
148        let inner2 = i64::from(inner) * i64::from(inner);
149        let outer2 = i64::from(outer) * i64::from(outer);
150        for y in (cy - outer).max(0)..=(cy + outer).min(self.h - 1) {
151            for x in (cx - outer).max(0)..=(cx + outer).min(self.w - 1) {
152                let dx = i64::from(x - cx);
153                let dy = i64::from(y - cy);
154                let d2 = dx * dx + dy * dy;
155                if d2 <= outer2 && (inner == 0 || d2 > inner2) {
156                    self.set(x, y, c);
157                }
158            }
159        }
160    }
161
162    /// Draw `text` with the embedded font, top-left of the line box at
163    /// (`x`, `y`). `scale` multiplies the base text size — pass the
164    /// monitor's DPI scale so text is the same visual size on any display.
165    pub fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color, scale: i32) {
166        let advance = font::advance(scale);
167        let baseline = y + font::ascent(scale);
168        let mut pen_x = x;
169        for ch in text.chars() {
170            self.blend_glyph(pen_x, baseline, ch, color, scale);
171            pen_x += advance;
172        }
173    }
174
175    /// One antialiased glyph, pen at (`pen_x`, `baseline`).
176    fn blend_glyph(&mut self, pen_x: i32, baseline: i32, ch: char, color: Color, scale: i32) {
177        let (metrics, coverage) = font::rasterize(ch, scale);
178        let x0 = pen_x + metrics.xmin;
179        let y0 = baseline - metrics.height as i32 - metrics.ymin;
180        for row in 0..metrics.height {
181            for col in 0..metrics.width {
182                let alpha = coverage[row * metrics.width + col];
183                if alpha != 0 {
184                    self.blend(x0 + col as i32, y0 + row as i32, color, alpha);
185                }
186            }
187        }
188    }
189
190    /// Source-over blend of `color` at coverage `alpha` onto one pixel.
191    fn blend(&mut self, col: i32, row: i32, color: Color, alpha: u8) {
192        if col < 0 || row < 0 || col >= self.w || row >= self.h {
193            return;
194        }
195        let index = (row as usize) * (self.w as usize) + (col as usize);
196        let dst = self.px[index];
197        let cover = u32::from(alpha);
198        let keep = 255 - cover;
199        let red = (u32::from(color.r) * cover + ((dst >> 16) & 0xFF) * keep) / 255;
200        let green = (u32::from(color.g) * cover + ((dst >> 8) & 0xFF) * keep) / 255;
201        let blue = (u32::from(color.b) * cover + (dst & 0xFF) * keep) / 255;
202        self.px[index] = (red << 16) | (green << 8) | blue;
203    }
204
205    /// Whether offset (`dx`, `dy`) lies inside the origin-centered
206    /// ellipse with radii (`rx`, `ry`); non-positive radii cover nothing.
207    fn draw_ellipse_rotated(&mut self, shape: &Shape, deg: i32, color: Color, band: Option<i32>) {
208        let Shape::Ellipse { cx, cy, rx, ry } = *shape else {
209            return;
210        };
211        let (thickness, fill) = band.map_or((0, true), |t| (t, false));
212        let bb = shape.rotated_bbox(deg);
213        let c = color.to_0rgb();
214        let (irx, iry) = if fill {
215            (-1, -1)
216        } else {
217            ((rx - thickness).max(0), (ry - thickness).max(0))
218        };
219        for y in bb.y.max(0)..=bb.y.saturating_add(bb.h).min(self.h - 1) {
220            for x in bb.x.max(0)..=bb.x.saturating_add(bb.w).min(self.w - 1) {
221                let local =
222                    crate::geometry::rotate_point_about(Point::new(x, y), Point::new(cx, cy), -deg);
223                let (dx, dy) = (local.x - cx, local.y - cy);
224                if ellipse_covers(dx, dy, rx, ry) && !ellipse_covers(dx, dy, irx, iry) {
225                    self.set(x, y, c);
226                }
227            }
228        }
229    }
230
231    /// Draw a shape rotated `deg` about its bbox center. Unrotated shapes
232    /// and circles take the fast paths; triangles rotate their vertices and
233    /// reuse the triangle raster; rotated rects raster by inverse-rotating
234    /// each candidate pixel into the rect's local space.
235    pub fn draw_shape_rotated(
236        &mut self,
237        shape: &Shape,
238        deg: i32,
239        color: Color,
240        thickness: i32,
241        fill: bool,
242    ) {
243        let deg = crate::geometry::normalize_deg(deg);
244        if deg == 0 || matches!(shape, Shape::Circle { .. }) {
245            return self.draw_shape(shape, color, thickness, fill);
246        }
247        if matches!(shape, Shape::Triangle { .. } | Shape::Poly { .. }) {
248            return self.draw_shape(&shape.with_rotation_baked(deg), color, thickness, fill);
249        }
250        if matches!(shape, Shape::Ellipse { .. }) {
251            let band = (!fill).then_some(thickness);
252            return self.draw_ellipse_rotated(shape, deg, color, band);
253        }
254        let Shape::Rect(rect) = *shape else { return };
255        if !fill && thickness <= 0 {
256            return;
257        }
258        let c = color.to_0rgb();
259        let bb = shape.rotated_bbox(deg);
260        let band = f64::from(thickness);
261        let (x0, y0) = (f64::from(rect.x), f64::from(rect.y));
262        let (x1, y1) = (f64::from(rect.x + rect.w), f64::from(rect.y + rect.h));
263        // Sample pixel CENTERS against half-open local edges, pivoting on
264        // the exact f64 box center: a 180-degree rect then covers the same
265        // pixels as an unrotated one. Saturating loop bounds tolerate
266        // absurd deserialized coordinates.
267        let pivot_x = f64::from(rect.x) + f64::from(rect.w) / 2.0;
268        let pivot_y = f64::from(rect.y) + f64::from(rect.h) / 2.0;
269        let rad = f64::from(-deg).to_radians();
270        let (sin, cos) = rad.sin_cos();
271        for y in bb.y.max(0)..=bb.y.saturating_add(bb.h).min(self.h - 1) {
272            for x in bb.x.max(0)..=bb.x.saturating_add(bb.w).min(self.w - 1) {
273                let dx = f64::from(x) + 0.5 - pivot_x;
274                let dy = f64::from(y) + 0.5 - pivot_y;
275                let lx = pivot_x + dx * cos - dy * sin;
276                let ly = pivot_y + dx * sin + dy * cos;
277                if lx < x0 || lx >= x1 || ly < y0 || ly >= y1 {
278                    continue;
279                }
280                if fill {
281                    self.set(x, y, c);
282                    continue;
283                }
284                let edge_dist = (lx - x0).min(x1 - lx).min(ly - y0).min(y1 - ly);
285                if edge_dist < band {
286                    self.set(x, y, c);
287                }
288            }
289        }
290    }
291
292    /// Draw a selection shape as outline or fill.
293    pub fn draw_shape(&mut self, shape: &Shape, color: Color, thickness: i32, fill: bool) {
294        match *shape {
295            Shape::Rect(r) if fill => self.fill_rect(r, color),
296            Shape::Rect(r) => self.draw_rect_outline(r, color, thickness),
297            Shape::Circle { cx, cy, r } if fill => self.fill_circle(cx, cy, r, color),
298            Shape::Circle { cx, cy, r } => self.draw_circle_outline(cx, cy, r, color, thickness),
299            Shape::Ellipse { cx, cy, rx, ry } if fill => {
300                self.ellipse_band(cx, cy, rx, ry, 0, color);
301            }
302            Shape::Ellipse { cx, cy, rx, ry } => {
303                self.ellipse_band(cx, cy, rx, ry, thickness, color);
304            }
305            Shape::Triangle { .. } => self.draw_triangle(shape, color, thickness, fill),
306            Shape::Poly { ref points } if fill => self.fill_poly(points, color),
307            Shape::Poly { ref points } => self.draw_poly_outline(points, color, thickness),
308        }
309    }
310
311    /// A bare segment, no end decoration — for lines nobody grabs, like
312    /// a snap guide. Caps on a short guide read as a box rather than a
313    /// line, which is the opposite of pointing at something.
314    pub fn draw_segment(&mut self, line: Line, color: Color, thickness: i32) {
315        if thickness <= 0 {
316            return;
317        }
318        self.stamp_segment(line.a, line.b, color, thickness);
319    }
320
321    /// A measure ruler: the segment plus square end caps, so the two
322    /// grabbable endpoints are visible against any background.
323    pub fn draw_line(&mut self, line: Line, color: Color, thickness: i32) {
324        if thickness <= 0 {
325            return;
326        }
327        self.stamp_segment(line.a, line.b, color, thickness);
328        let cap = (thickness * 3).max(3);
329        let half = cap / 2;
330        for end in [line.a, line.b] {
331            self.draw_rect_outline(
332                Rect::new(end.x - half, end.y - half, cap, cap),
333                color,
334                thickness,
335            );
336        }
337    }
338
339    /// Even-odd scanline fill — correct for concave and self-touching
340    /// freehand outlines, not just convex N-gons.
341    fn fill_poly(&mut self, points: &[Point], color: Color) {
342        if points.len() < 3 {
343            return;
344        }
345        let c = color.to_0rgb();
346        let y0 = points.iter().map(|p| p.y).min().unwrap_or(0).max(0);
347        let y1 = points
348            .iter()
349            .map(|p| p.y)
350            .max()
351            .unwrap_or(0)
352            .min(self.h - 1);
353        for y in y0..=y1 {
354            let spans = scanline_spans(points, y);
355            for span in spans.chunks(2) {
356                let [l, r] = span else { continue };
357                let xa = (l.ceil() as i32).max(0);
358                let xb = (r.floor() as i32).min(self.w - 1);
359                for x in xa..=xb {
360                    self.set(x, y, c);
361                }
362            }
363        }
364    }
365
366    /// Outline as stamped edge segments: cheap enough to redraw per mouse
367    /// move mid-drag, unlike a per-pixel distance test over the bbox.
368    fn draw_poly_outline(&mut self, points: &[Point], color: Color, thickness: i32) {
369        if points.len() < 2 || thickness <= 0 {
370            return;
371        }
372        let n = points.len();
373        for i in 0..n {
374            self.stamp_segment(points[i], points[(i + 1) % n], color, thickness);
375        }
376    }
377
378    /// A thick line as a run of small filled squares along the segment.
379    fn stamp_segment(&mut self, a: Point, b: Point, color: Color, thickness: i32) {
380        let steps = (b.x - a.x).abs().max((b.y - a.y).abs()).max(1);
381        let half = thickness / 2;
382        for i in 0..=steps {
383            let x = a.x + ((b.x - a.x) * i) / steps;
384            let y = a.y + ((b.y - a.y) * i) / steps;
385            self.fill_rect(
386                Rect::new(x - half, y - half, thickness.max(1), thickness.max(1)),
387                color,
388            );
389        }
390    }
391
392    /// Fill the region between the ellipse and, for `thickness > 0`, the
393    /// concentric ellipse `thickness` smaller on each radius — thickness 0
394    /// fills the whole interior. Normalized-distance test per pixel.
395    fn ellipse_band(&mut self, cx: i32, cy: i32, rx: i32, ry: i32, thickness: i32, color: Color) {
396        if rx <= 0 || ry <= 0 {
397            return;
398        }
399        let c = color.to_0rgb();
400        let (irx, iry) = if thickness <= 0 {
401            (-1, -1)
402        } else {
403            ((rx - thickness).max(0), (ry - thickness).max(0))
404        };
405        for y in (cy - ry).max(0)..=(cy + ry).min(self.h - 1) {
406            for x in (cx - rx).max(0)..=(cx + rx).min(self.w - 1) {
407                if ellipse_covers(x - cx, y - cy, rx, ry)
408                    && !ellipse_covers(x - cx, y - cy, irx, iry)
409                {
410                    self.set(x, y, c);
411                }
412            }
413        }
414    }
415
416    /// Triangle raster: fill is coverage; outline is the inward band of
417    /// covered pixels within `thickness` of the nearest edge.
418    fn draw_triangle(&mut self, shape: &Shape, color: Color, thickness: i32, fill: bool) {
419        let Shape::Triangle {
420            ax,
421            ay,
422            bx,
423            by,
424            cx,
425            cy,
426        } = *shape
427        else {
428            return;
429        };
430        if !fill && thickness <= 0 {
431            return;
432        }
433        let c = color.to_0rgb();
434        let bb = shape.bbox();
435        let band = f64::from(thickness);
436        for y in bb.y.max(0)..=bb.y.saturating_add(bb.h).min(self.h - 1) {
437            for x in bb.x.max(0)..=bb.x.saturating_add(bb.w).min(self.w - 1) {
438                if !shape.covers(x, y) {
439                    continue;
440                }
441                if fill {
442                    self.set(x, y, c);
443                    continue;
444                }
445                let d = seg_dist(x, y, ax, ay, bx, by)
446                    .min(seg_dist(x, y, bx, by, cx, cy))
447                    .min(seg_dist(x, y, cx, cy, ax, ay));
448                if d < band {
449                    self.set(x, y, c);
450                }
451            }
452        }
453    }
454}
455
456/// Squared-distance-free point-to-segment distance, for outline bands.
457fn seg_dist(px: i32, py: i32, x1: i32, y1: i32, x2: i32, y2: i32) -> f64 {
458    let (px, py) = (f64::from(px), f64::from(py));
459    let (x1, y1) = (f64::from(x1), f64::from(y1));
460    let (x2, y2) = (f64::from(x2), f64::from(y2));
461    let (dx, dy) = (x2 - x1, y2 - y1);
462    let len2 = dx * dx + dy * dy;
463    let t = if len2 == 0.0 {
464        0.0
465    } else {
466        (((px - x1) * dx + (py - y1) * dy) / len2).clamp(0.0, 1.0)
467    };
468    (px - (x1 + t * dx)).hypot(py - (y1 + t * dy))
469}
470
471/// The coordinate caption for a shape, e.g. `(120, 448) 300x88` or
472/// `(900, 300) r=64`. Triangles caption their bounding box.
473pub fn coord_text(shape: &Shape) -> String {
474    match *shape {
475        Shape::Rect(ref r) => format!("({}, {}) {}x{}", r.x, r.y, r.w, r.h),
476        Shape::Circle { cx, cy, r } => format!("({cx}, {cy}) r={r}"),
477        Shape::Ellipse { cx, cy, rx, ry } => format!("({cx}, {cy}) {rx}x{ry}"),
478        Shape::Triangle { .. } | Shape::Poly { .. } => {
479            let b = shape.bbox();
480            format!("({}, {}) {}x{}", b.x, b.y, b.w, b.h)
481        }
482    }
483}
484
485/// The caption for a measure, e.g. `\u{394}120,-40 \u{b7} 126px \u{b7} 341\u{b0}`.
486///
487/// The deltas are signed and in screen space (Y grows down), the length is
488/// rounded to whole pixels, and the angle is clockwise from +X — the same
489/// convention `Line::angle_deg` documents, so the overlay and the saved
490/// session never disagree.
491#[must_use]
492pub fn measure_text(line: Line) -> String {
493    let (dx, dy) = line.delta();
494    format!(
495        "\u{394}{dx},{dy} \u{b7} {:.0}px \u{b7} {:.0}\u{b0}",
496        line.length(),
497        line.angle_deg()
498    )
499}
500
501/// Where to place a caption of `text_len` glyphs (drawn at `scale`) near
502/// `bbox` so it stays readable at screen edges: above by default, flipping
503/// left/right/below as needed. Ported from the predecessor's smart
504/// placement.
505pub fn smart_text_position(bbox: Rect, bounds: Size, text_len: usize, scale: i32) -> Point {
506    let scale = scale.max(1);
507    let padding = 4 * scale;
508    let text_w = font::text_width(text_len, scale);
509    let text_h = font::line_height(scale);
510    let mut x = bbox.x;
511    let mut y = bbox.y - text_h - padding;
512    if x + text_w > bounds.w {
513        x = bbox.x - text_w - padding;
514    }
515    if x < 0 {
516        x = bbox.x + bbox.w + padding;
517    }
518    if y < 0 {
519        y = bbox.y + bbox.h + padding;
520    }
521    if y + text_h > bounds.h {
522        y = bbox.y - text_h - padding;
523        if y < 0 {
524            y = bbox.y + padding;
525        }
526    }
527    x = x.max(0);
528    y = y.max(0);
529    if x + text_w > bounds.w {
530        x = bounds.w - text_w;
531    }
532    if y + text_h > bounds.h {
533        y = bounds.h - text_h;
534    }
535    Point::new(x, y)
536}
537
538/// Whether offset (`dx`, `dy`) lies inside the origin-centered ellipse
539/// with radii (`rx`, `ry`); non-positive radii cover nothing.
540fn ellipse_covers(dx: i32, dy: i32, rx: i32, ry: i32) -> bool {
541    if rx <= 0 || ry <= 0 {
542        return false;
543    }
544    let (dx, dy) = (i128::from(dx), i128::from(dy));
545    let (rx, ry) = (i128::from(rx), i128::from(ry));
546    dx * dx * ry * ry + dy * dy * rx * rx <= rx * rx * ry * ry
547}
548
549/// Zero the alpha of every RGBA pixel that no shape covers — the primary
550/// cutout: selections stay visible in place, everything else goes
551/// transparent. Each `(shape, deg)` pairs a shape with its rotation.
552/// Coverage is rasterized per shape over its rotated bbox, so cost scales
553/// with selection area rather than selections × frame.
554pub fn apply_cutout_mask(rgba: &mut [u8], w: i32, h: i32, shapes: &[(Shape, i32)]) {
555    let covered = coverage(rgba.len(), w, h, shapes);
556    for (i, inside) in covered.iter().enumerate() {
557        if !inside {
558            rgba[i * 4 + 3] = 0;
559        }
560    }
561}
562
563/// The exact complement: zero the alpha of every pixel a shape covers —
564/// the inverse cutout punches the selections out and keeps the rest, so
565/// the pair reassembles the frame with no pixel in both.
566pub fn apply_inverse_cutout_mask(rgba: &mut [u8], w: i32, h: i32, shapes: &[(Shape, i32)]) {
567    let covered = coverage(rgba.len(), w, h, shapes);
568    for (i, inside) in covered.iter().enumerate() {
569        if *inside {
570            rgba[i * 4 + 3] = 0;
571        }
572    }
573}
574
575/// Which pixels any shape covers, rasterized per shape over its rotated
576/// bbox. `rgba_len` is asserted against the dimensions once, here, for
577/// both cutout appliers.
578fn coverage(rgba_len: usize, w: i32, h: i32, shapes: &[(Shape, i32)]) -> Vec<bool> {
579    assert_eq!(
580        rgba_len,
581        (w as usize) * (h as usize) * 4,
582        "RGBA buffer size mismatch"
583    );
584    let mut covered = vec![false; (w as usize) * (h as usize)];
585    for (shape, deg) in shapes {
586        mark_covered(&mut covered, w, h, shape, *deg);
587    }
588    covered
589}
590
591/// Mark the pixels `shape` (rotated `deg`) covers, clipped to the canvas.
592fn mark_covered(covered: &mut [bool], w: i32, h: i32, shape: &Shape, deg: i32) {
593    let bbox = shape.rotated_bbox(deg);
594    let x0 = bbox.x.max(0);
595    let y0 = bbox.y.max(0);
596    let x1 = bbox.x.saturating_add(bbox.w).min(w);
597    let y1 = bbox.y.saturating_add(bbox.h).min(h);
598    for y in y0..y1 {
599        for x in x0..x1 {
600            if shape.hit_test_rotated(deg, crate::geometry::Point::new(x, y)) {
601                covered[(y as usize) * (w as usize) + (x as usize)] = true;
602            }
603        }
604    }
605}
606
607/// Zero the alpha of every RGBA pixel the shape (rotated `deg` about its
608/// bbox center) does not cover. Used on non-rect crops so the outside of
609/// the shape is transparent; `shape` is in the crop image's own coordinate
610/// space.
611pub fn apply_alpha_mask_outside(rgba: &mut [u8], w: i32, h: i32, shape: &Shape, deg: i32) {
612    assert_eq!(
613        rgba.len(),
614        (w as usize) * (h as usize) * 4,
615        "RGBA buffer size mismatch"
616    );
617    for y in 0..h {
618        for x in 0..w {
619            if !shape.hit_test_rotated(deg, crate::geometry::Point::new(x, y)) {
620                rgba[((y as usize) * (w as usize) + (x as usize)) * 4 + 3] = 0;
621            }
622        }
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    const W: i32 = 100;
631    const H: i32 = 60;
632    const RED: Color = Color { r: 255, g: 0, b: 0 };
633
634    #[test]
635    fn hex_is_uppercase_and_always_six_digits() {
636        assert_eq!(RED.to_hex(), "#FF0000");
637        assert_eq!(Color { r: 0, g: 0, b: 0 }.to_hex(), "#000000");
638        assert_eq!(Color::WHITE.to_hex(), "#FFFFFF");
639        // The zero-padding is the point: #3A7BD5 must not come out as
640        // #3A7BD5 for one value and #3A7BD for another.
641        assert_eq!(
642            Color {
643                r: 0x3A,
644                g: 0x07,
645                b: 0xD5
646            }
647            .to_hex(),
648            "#3A07D5"
649        );
650    }
651
652    #[test]
653    fn hex_round_trips_through_the_config_parser() {
654        // The two directions have to agree, or a color read back out of a
655        // session would not be the one written in.
656        for color in [
657            RED,
658            Color::WHITE,
659            Color { r: 0, g: 0, b: 0 },
660            Color {
661                r: 18,
662                g: 200,
663                b: 7,
664            },
665        ] {
666            let parsed = crate::config::parse_hex_color(&color.to_hex()).unwrap();
667            assert_eq!(parsed, color, "{}", color.to_hex());
668        }
669    }
670
671    #[test]
672    fn sampling_reads_the_pixel_under_the_point() {
673        // A 3x2 RGBA buffer with a known value at (2, 1).
674        let mut rgba = vec![0u8; 3 * 2 * 4];
675        // Row 1, column 2, of a 3-wide buffer.
676        let index = (3 + 2) * 4;
677        rgba[index..index + 4].copy_from_slice(&[10, 20, 30, 255]);
678        assert_eq!(
679            sample_rgba(&rgba, 3, 2, Point::new(2, 1)),
680            Some(Color {
681                r: 10,
682                g: 20,
683                b: 30
684            })
685        );
686        assert_eq!(
687            sample_rgba(&rgba, 3, 2, Point::new(0, 0)),
688            Some(Color { r: 0, g: 0, b: 0 })
689        );
690    }
691
692    #[test]
693    fn sampling_outside_the_frame_is_none_not_a_panic() {
694        let rgba = vec![7u8; 2 * 2 * 4];
695        for outside in [
696            Point::new(-1, 0),
697            Point::new(0, -1),
698            Point::new(2, 0),
699            Point::new(0, 2),
700        ] {
701            assert_eq!(sample_rgba(&rgba, 2, 2, outside), None, "{outside:?}");
702        }
703    }
704
705    #[test]
706    fn sampling_ignores_alpha() {
707        // A crop's alpha carries its shape mask; a frame's is opaque. A
708        // color readout is about what was on screen either way.
709        let rgba = [1u8, 2, 3, 0];
710        assert_eq!(
711            sample_rgba(&rgba, 1, 1, Point::new(0, 0)),
712            Some(Color { r: 1, g: 2, b: 3 })
713        );
714    }
715
716    fn canvas_buf() -> Vec<u32> {
717        vec![0u32; (W * H) as usize]
718    }
719
720    fn px(buf: &[u32], x: i32, y: i32) -> u32 {
721        buf[(y * W + x) as usize]
722    }
723
724    #[test]
725    fn rect_outline_sets_border_not_interior() {
726        let mut buf = canvas_buf();
727        let mut c = Canvas::new(&mut buf, W, H);
728        c.draw_rect_outline(Rect::new(10, 10, 20, 20), RED, 2);
729        let red = RED.to_0rgb();
730        assert_eq!(px(&buf, 10, 10), red);
731        assert_eq!(px(&buf, 29, 29), red);
732        assert_eq!(px(&buf, 11, 15), red); // inside 2px border
733        assert_eq!(px(&buf, 15, 15), 0); // interior untouched
734        assert_eq!(px(&buf, 9, 10), 0); // outside untouched
735    }
736
737    #[test]
738    fn fill_rect_clips_to_canvas() {
739        let mut buf = canvas_buf();
740        let mut c = Canvas::new(&mut buf, W, H);
741        c.fill_rect(Rect::new(-10, -10, 30, 30), RED);
742        assert_eq!(px(&buf, 0, 0), RED.to_0rgb());
743        assert_eq!(px(&buf, 19, 19), RED.to_0rgb());
744        assert_eq!(px(&buf, 20, 20), 0);
745    }
746
747    #[test]
748    fn fill_rect_fully_off_canvas_is_a_noop() {
749        let mut buf = canvas_buf();
750        let mut c = Canvas::new(&mut buf, W, H);
751        c.fill_rect(Rect::new(W + 10, 10, 20, 20), RED);
752        c.fill_rect(Rect::new(-50, -50, 20, 20), RED);
753        c.fill_rect(Rect::new(10, H + 5, 20, 20), RED);
754        assert!(buf.iter().all(|&p| p == 0));
755    }
756
757    #[test]
758    fn zero_thickness_draws_nothing() {
759        let mut buf = canvas_buf();
760        let mut c = Canvas::new(&mut buf, W, H);
761        c.draw_rect_outline(Rect::new(10, 10, 20, 20), RED, 0);
762        c.draw_circle_outline(50, 30, 10, RED, 0);
763        assert!(buf.iter().all(|&p| p == 0));
764    }
765
766    #[test]
767    fn ellipse_outline_is_a_band_and_fill_covers_the_interior() {
768        let mut buf = canvas_buf();
769        let mut c = Canvas::new(&mut buf, W, H);
770        let e = Shape::Ellipse {
771            cx: 50,
772            cy: 30,
773            rx: 30,
774            ry: 15,
775        };
776        c.draw_shape(&e, RED, 3, false);
777        let red = RED.to_0rgb();
778        assert_eq!(px(&buf, 79, 30), red, "on the rim");
779        assert_eq!(px(&buf, 50, 30), 0, "outline leaves the center empty");
780        assert_eq!(px(&buf, 79, 15), 0, "bbox corner outside");
781
782        let mut buf = canvas_buf();
783        let mut c = Canvas::new(&mut buf, W, H);
784        c.draw_shape(&e, RED, 3, true);
785        assert_eq!(px(&buf, 50, 30), red, "fill covers the center");
786    }
787
788    #[test]
789    fn rotated_ellipse_raster_follows_the_turn() {
790        let mut buf = canvas_buf();
791        let mut c = Canvas::new(&mut buf, W, H);
792        let e = Shape::Ellipse {
793            cx: 50,
794            cy: 30,
795            rx: 25,
796            ry: 6,
797        };
798        c.draw_shape_rotated(&e, 90, RED, 2, true);
799        let red = RED.to_0rgb();
800        assert_eq!(px(&buf, 50, 50), red, "stands tall after the turn");
801        assert_eq!(px(&buf, 70, 30), 0, "no longer lies flat");
802    }
803
804    #[test]
805    fn circle_outline_is_an_annulus() {
806        let mut buf = canvas_buf();
807        let mut c = Canvas::new(&mut buf, W, H);
808        c.draw_circle_outline(50, 30, 10, RED, 2);
809        let red = RED.to_0rgb();
810        assert_eq!(px(&buf, 60, 30), red); // on the radius
811        assert_eq!(px(&buf, 59, 30), red); // within the band
812        assert_eq!(px(&buf, 50, 30), 0); // center empty
813        assert_eq!(px(&buf, 62, 30), 0); // outside
814    }
815
816    #[test]
817    fn fill_circle_covers_center_and_clips() {
818        let mut buf = canvas_buf();
819        let mut c = Canvas::new(&mut buf, W, H);
820        c.fill_circle(0, 0, 10, RED); // 3/4 off-canvas
821        assert_eq!(px(&buf, 0, 0), RED.to_0rgb());
822        assert_eq!(px(&buf, 7, 7), RED.to_0rgb());
823        assert_eq!(px(&buf, 8, 8), 0);
824    }
825
826    #[test]
827    fn triangle_fill_covers_interior_not_bbox_corners() {
828        let mut buf = canvas_buf();
829        let mut c = Canvas::new(&mut buf, W, H);
830        let tri = Shape::Triangle {
831            ax: 50,
832            ay: 10,
833            bx: 10,
834            by: 50,
835            cx: 90,
836            cy: 50,
837        };
838        c.draw_shape(&tri, RED, 2, true);
839        let red = RED.to_0rgb();
840        assert_eq!(px(&buf, 50, 40), red); // interior
841        assert_eq!(px(&buf, 50, 11), red); // just below apex
842        assert_eq!(px(&buf, 12, 12), 0); // bbox top-left, outside triangle
843        assert_eq!(px(&buf, 88, 12), 0); // bbox top-right, outside triangle
844    }
845
846    #[test]
847    fn triangle_outline_is_a_band_not_a_fill() {
848        let mut buf = canvas_buf();
849        let mut c = Canvas::new(&mut buf, W, H);
850        let tri = Shape::Triangle {
851            ax: 50,
852            ay: 10,
853            bx: 10,
854            by: 50,
855            cx: 90,
856            cy: 50,
857        };
858        c.draw_shape(&tri, RED, 2, false);
859        let red = RED.to_0rgb();
860        assert_eq!(px(&buf, 50, 49), red); // on the base edge
861        assert_eq!(px(&buf, 50, 35), 0); // interior stays empty
862        assert_eq!(px(&buf, 12, 12), 0); // outside stays empty
863    }
864
865    #[test]
866    fn text_inks_inside_its_line_box_and_nowhere_else() {
867        let mut buf = canvas_buf();
868        let mut c = Canvas::new(&mut buf, W, H);
869        c.draw_text(10, 10, "I", RED, 1);
870        // Ink lands somewhere inside the one-glyph line box; nothing
871        // changes outside it (one pixel of slack for hinting offsets).
872        let box_x = 10 - 1..10 + font::advance(1) + 1;
873        let box_y = 10 - 1..10 + font::line_height(1) + 1;
874        let mut inked = false;
875        for y in 0..H {
876            for x in 0..W {
877                let p = px(&buf, x, y);
878                if box_x.contains(&x) && box_y.contains(&y) {
879                    inked |= p != 0;
880                    continue;
881                }
882                assert_eq!(p, 0, "stray ink at ({x},{y})");
883            }
884        }
885        assert!(inked, "the glyph drew nothing");
886    }
887
888    #[test]
889    fn text_off_canvas_is_safe() {
890        let mut buf = canvas_buf();
891        let mut c = Canvas::new(&mut buf, W, H);
892        c.draw_text(-5, -5, "EDGE", RED, 1);
893        c.draw_text(W - 3, H - 3, "EDGE", RED, 1);
894    }
895
896    #[test]
897    fn smart_position_defaults_above() {
898        let p = smart_text_position(Rect::new(200, 200, 100, 50), Size::new(1920, 1080), 10, 1);
899        assert_eq!(p, Point::new(200, 200 - font::line_height(1) - 4));
900    }
901
902    #[test]
903    fn smart_position_flips_below_at_top_edge() {
904        let p = smart_text_position(Rect::new(200, 2, 100, 50), Size::new(1920, 1080), 10, 1);
905        assert_eq!(p, Point::new(200, 2 + 50 + 4));
906    }
907
908    #[test]
909    fn smart_position_flips_left_at_right_edge() {
910        let bbox = Rect::new(1900, 200, 15, 50);
911        let p = smart_text_position(bbox, Size::new(1920, 1080), 10, 1);
912        let text_w = font::text_width(10, 1);
913        assert_eq!(p.x, 1900 - text_w - 4);
914    }
915
916    #[test]
917    fn smart_position_stays_on_screen_in_corners() {
918        let bounds = Size::new(1920, 1080);
919        let text_len = 20;
920        for bbox in [
921            Rect::new(0, 0, 50, 50),
922            Rect::new(1870, 0, 50, 50),
923            Rect::new(0, 1030, 50, 50),
924            Rect::new(1870, 1030, 50, 50),
925        ] {
926            let p = smart_text_position(bbox, bounds, text_len, 1);
927            assert!(p.x >= 0 && p.y >= 0, "{bbox:?} gave {p:?}");
928            assert!(
929                p.x + font::text_width(text_len, 1) <= bounds.w
930                    && p.y + font::line_height(1) <= bounds.h,
931                "{bbox:?} gave {p:?}"
932            );
933        }
934    }
935
936    #[test]
937    fn scaled_text_is_larger_and_reaches_full_ink() {
938        let mut buf = canvas_buf();
939        let mut c = Canvas::new(&mut buf, W, H);
940        c.draw_text(10, 10, "M", RED, 2);
941        // At scale 2 a dense glyph has fully-covered interior pixels, so
942        // the exact color appears; everything stays inside the line box.
943        let red = RED.to_0rgb();
944        let box_x = 10 - 1..10 + font::advance(2) + 1;
945        let box_y = 10 - 1..10 + font::line_height(2) + 1;
946        let mut solid = false;
947        for y in 0..H {
948            for x in 0..W {
949                let p = px(&buf, x, y);
950                if box_x.contains(&x) && box_y.contains(&y) {
951                    solid |= p == red;
952                    continue;
953                }
954                assert_eq!(p, 0, "stray ink at ({x},{y})");
955            }
956        }
957        assert!(solid, "no fully-covered pixel in 'M' at scale 2");
958    }
959
960    #[test]
961    fn scaled_smart_position_scales_offsets() {
962        let p = smart_text_position(Rect::new(200, 200, 100, 50), Size::new(1920, 1080), 10, 2);
963        assert_eq!(p, Point::new(200, 200 - font::line_height(2) - 8));
964    }
965
966    #[test]
967    fn measure_text_reports_deltas_length_and_clockwise_angle() {
968        // 3-4-5 triangle pointing down-right: screen Y grows down, so the
969        // angle is measured clockwise from +X.
970        let line = Line::new(Point::new(0, 0), Point::new(30, 40));
971        assert_eq!(
972            measure_text(line),
973            "\u{394}30,40 \u{b7} 50px \u{b7} 53\u{b0}"
974        );
975        // Negative deltas keep their sign; straight up is 270.
976        let up = Line::new(Point::new(10, 100), Point::new(10, 40));
977        assert_eq!(
978            measure_text(up),
979            "\u{394}0,-60 \u{b7} 60px \u{b7} 270\u{b0}"
980        );
981    }
982
983    #[test]
984    fn draw_line_marks_both_endpoints() {
985        let mut buf = vec![0u32; 40 * 40];
986        let mut canvas = Canvas::new(&mut buf, 40, 40);
987        let color = Color {
988            r: 0xFF,
989            g: 0,
990            b: 0,
991        };
992        canvas.draw_line(Line::new(Point::new(10, 10), Point::new(30, 10)), color, 1);
993        let ink = color.to_0rgb();
994        // The segment itself.
995        assert_eq!(buf[10 * 40 + 20], ink);
996        // Caps: the endpoints get a box, so pixels off the segment axis
997        // are inked at each end but not in the middle.
998        assert_eq!(buf[9 * 40 + 9], ink);
999        assert_eq!(buf[9 * 40 + 31], ink);
1000        assert_ne!(buf[9 * 40 + 20], ink);
1001    }
1002
1003    #[test]
1004    fn a_segment_has_no_caps_where_a_line_does() {
1005        let ink = Color {
1006            r: 0xFF,
1007            g: 0,
1008            b: 0,
1009        }
1010        .to_0rgb();
1011        let line = Line::new(Point::new(10, 10), Point::new(30, 10));
1012        let color = Color {
1013            r: 0xFF,
1014            g: 0,
1015            b: 0,
1016        };
1017
1018        let mut capped = vec![0u32; 40 * 40];
1019        Canvas::new(&mut capped, 40, 40).draw_line(line, color, 1);
1020        let mut bare = vec![0u32; 40 * 40];
1021        Canvas::new(&mut bare, 40, 40).draw_segment(line, color, 1);
1022
1023        // Both draw the segment itself.
1024        assert_eq!(capped[10 * 40 + 20], ink);
1025        assert_eq!(bare[10 * 40 + 20], ink);
1026        // Only the ruler decorates its ends.
1027        assert_eq!(capped[9 * 40 + 9], ink);
1028        assert_ne!(bare[9 * 40 + 9], ink);
1029        assert!(
1030            bare.iter().filter(|&&p| p == ink).count()
1031                < capped.iter().filter(|&&p| p == ink).count()
1032        );
1033    }
1034
1035    #[test]
1036    fn draw_segment_ignores_nonpositive_thickness() {
1037        let mut buf = vec![0u32; 20 * 20];
1038        Canvas::new(&mut buf, 20, 20).draw_segment(
1039            Line::new(Point::new(2, 2), Point::new(18, 2)),
1040            Color { r: 9, g: 9, b: 9 },
1041            0,
1042        );
1043        assert!(buf.iter().all(|&p| p == 0));
1044    }
1045
1046    #[test]
1047    fn draw_line_ignores_nonpositive_thickness() {
1048        let mut buf = vec![0u32; 20 * 20];
1049        let mut canvas = Canvas::new(&mut buf, 20, 20);
1050        canvas.draw_line(
1051            Line::new(Point::new(2, 2), Point::new(18, 2)),
1052            Color { r: 9, g: 9, b: 9 },
1053            0,
1054        );
1055        assert!(buf.iter().all(|&p| p == 0));
1056    }
1057
1058    #[test]
1059    fn coord_text_formats() {
1060        assert_eq!(
1061            coord_text(&Shape::Rect(Rect::new(1, 2, 3, 4))),
1062            "(1, 2) 3x4"
1063        );
1064        assert_eq!(
1065            coord_text(&Shape::Circle { cx: 9, cy: 8, r: 7 }),
1066            "(9, 8) r=7"
1067        );
1068    }
1069
1070    #[test]
1071    fn rotated_rect_raster_follows_the_turn() {
1072        let mut buf = canvas_buf();
1073        let mut c = Canvas::new(&mut buf, W, H);
1074        // Wide flat rect centered at (50, 30), turned 90 deg: it becomes
1075        // tall and thin.
1076        let s = Shape::Rect(Rect::new(20, 25, 60, 10));
1077        c.draw_shape_rotated(&s, 90, RED, 2, true);
1078        let red = RED.to_0rgb();
1079        assert_eq!(px(&buf, 50, 5), red); // above center: inside turned
1080        assert_eq!(px(&buf, 50, 55), red); // below center: inside turned
1081        assert_eq!(px(&buf, 75, 30), 0); // right of center: outside now
1082        // Rotation 0 matches plain drawing.
1083        let mut buf2 = canvas_buf();
1084        let mut c2 = Canvas::new(&mut buf2, W, H);
1085        c2.draw_shape_rotated(&s, 0, RED, 2, true);
1086        let mut buf3 = canvas_buf();
1087        let mut c3 = Canvas::new(&mut buf3, W, H);
1088        c3.draw_shape(&s, RED, 2, true);
1089        assert_eq!(buf2, buf3);
1090    }
1091
1092    #[test]
1093    fn rect_covers_same_pixel_count_at_0_and_180_degrees() {
1094        let s = Shape::Rect(Rect::new(20, 20, 11, 7)); // odd dims on purpose
1095        let mut plain = canvas_buf();
1096        Canvas::new(&mut plain, W, H).draw_shape(&s, RED, 2, true);
1097        let mut turned = canvas_buf();
1098        Canvas::new(&mut turned, W, H).draw_shape_rotated(&s, 180, RED, 2, true);
1099        let count = |buf: &[u32]| buf.iter().filter(|&&p| p != 0).count();
1100        assert_eq!(count(&plain), 77);
1101        assert_eq!(count(&turned), 77, "180-degree raster must match 0-degree");
1102    }
1103
1104    #[test]
1105    fn dim_rect_scales_brightness_and_clips_to_the_canvas() {
1106        let mut buf = vec![0x00FF_8040u32; (W * H) as usize];
1107        let mut c = Canvas::new(&mut buf, W, H);
1108        // Half brightness inside; a rect hanging off the canvas clips.
1109        c.dim_rect(Rect::new(-10, -10, 20, 20), 128);
1110        assert_eq!(buf[0], 0x007F_4020, "channels each halve");
1111        assert_eq!(
1112            buf[(10 * W + 10) as usize],
1113            0x00FF_8040,
1114            "outside the rect untouched"
1115        );
1116
1117        let mut buf = vec![0x00FF_FFFFu32; (W * H) as usize];
1118        Canvas::new(&mut buf, W, H).dim_rect(Rect::new(0, 0, 2, 1), 0);
1119        assert_eq!(buf[0], 0, "strength 0 blacks out");
1120
1121        let mut buf = vec![0x0012_3456u32; (W * H) as usize];
1122        Canvas::new(&mut buf, W, H).dim_rect(Rect::new(0, 0, 1, 1), 256);
1123        assert_eq!(buf[0], 0x0012_3456, "strength 256 leaves pixels alone");
1124        // A fully off-canvas rect is a no-op, not a panic.
1125        Canvas::new(&mut buf, W, H).dim_rect(Rect::new(-50, -50, 10, 10), 64);
1126    }
1127
1128    #[test]
1129    fn cutout_keeps_every_shape_in_place_and_clears_the_rest() {
1130        let w = 60;
1131        let h = 40;
1132        let mut rgba = vec![200u8; (w * h * 4) as usize];
1133        let shapes = [
1134            (Shape::Rect(Rect::new(5, 5, 10, 10)), 0),
1135            (
1136                Shape::Circle {
1137                    cx: 40,
1138                    cy: 20,
1139                    r: 6,
1140                },
1141                0,
1142            ),
1143        ];
1144        apply_cutout_mask(&mut rgba, w, h, &shapes);
1145        let pixel = |x: i32, y: i32| {
1146            let i = ((y * w + x) * 4) as usize;
1147            (rgba[i], rgba[i + 3])
1148        };
1149        // Inside either shape: color and alpha untouched.
1150        assert_eq!(pixel(10, 10), (200, 200));
1151        assert_eq!(pixel(40, 20), (200, 200));
1152        // Between and outside: transparent, color bytes untouched.
1153        assert_eq!(pixel(25, 10), (200, 0));
1154        assert_eq!(pixel(0, 39), (200, 0));
1155        // The circle's bbox corner is outside the circle itself.
1156        assert_eq!(pixel(35, 15), (200, 0));
1157    }
1158
1159    #[test]
1160    fn cutout_of_a_rotated_rect_follows_the_turn() {
1161        let w = 30;
1162        let h = 30;
1163        let mut rgba = vec![255u8; (w * h * 4) as usize];
1164        // Flat rect centered at (15, 15), turned 90: vertical strip kept.
1165        apply_cutout_mask(
1166            &mut rgba,
1167            w,
1168            h,
1169            &[(Shape::Rect(Rect::new(3, 12, 24, 6)), 90)],
1170        );
1171        let alpha = |x: i32, y: i32| rgba[((y * w + x) * 4 + 3) as usize];
1172        assert_eq!(alpha(15, 5), 255);
1173        assert_eq!(alpha(5, 15), 0);
1174    }
1175
1176    #[test]
1177    fn cutout_clips_offscreen_shapes_instead_of_panicking() {
1178        let w = 20;
1179        let h = 20;
1180        let mut rgba = vec![255u8; (w * h * 4) as usize];
1181        // Hangs off the top-left; only the on-canvas part is kept.
1182        apply_cutout_mask(
1183            &mut rgba,
1184            w,
1185            h,
1186            &[(Shape::Rect(Rect::new(-10, -10, 15, 15)), 0)],
1187        );
1188        let alpha = |x: i32, y: i32| rgba[((y * w + x) * 4 + 3) as usize];
1189        assert_eq!(alpha(2, 2), 255);
1190        assert_eq!(alpha(10, 10), 0);
1191    }
1192
1193    #[test]
1194    fn cutout_with_no_shapes_clears_everything() {
1195        let mut rgba = vec![255u8; 4 * 4];
1196        apply_cutout_mask(&mut rgba, 2, 2, &[]);
1197        assert!(rgba.chunks(4).all(|p| p[3] == 0));
1198    }
1199
1200    #[test]
1201    fn inverse_cutout_is_the_exact_complement() {
1202        let w = 60;
1203        let h = 40;
1204        let shapes = [
1205            (Shape::Rect(Rect::new(5, 5, 10, 10)), 0),
1206            (
1207                Shape::Circle {
1208                    cx: 40,
1209                    cy: 20,
1210                    r: 6,
1211                },
1212                45,
1213            ),
1214        ];
1215        let mut primary = vec![255u8; (w * h * 4) as usize];
1216        let mut inverse = vec![255u8; (w * h * 4) as usize];
1217        apply_cutout_mask(&mut primary, w, h, &shapes);
1218        apply_inverse_cutout_mask(&mut inverse, w, h, &shapes);
1219        // Every pixel is transparent in exactly one of the pair — together
1220        // they reassemble the frame.
1221        for i in 0..(w * h) as usize {
1222            let (p, v) = (primary[i * 4 + 3], inverse[i * 4 + 3]);
1223            assert_eq!(p ^ v, 255, "pixel {i}: primary {p}, inverse {v}");
1224        }
1225        // Spot-check orientation: inside a shape the inverse is the
1226        // transparent one.
1227        let idx = ((10 * w + 10) * 4 + 3) as usize;
1228        assert_eq!(primary[idx], 255);
1229        assert_eq!(inverse[idx], 0);
1230    }
1231
1232    #[test]
1233    fn rotated_alpha_mask_follows_the_turn() {
1234        let w = 30;
1235        let h = 30;
1236        let mut rgba = vec![255u8; (w * h * 4) as usize];
1237        // Flat rect centered at (15,15), turned 90: vertical strip opaque.
1238        let s = Shape::Rect(Rect::new(3, 12, 24, 6));
1239        apply_alpha_mask_outside(&mut rgba, w, h, &s, 90);
1240        let alpha = |x: i32, y: i32| rgba[((y * w + x) * 4 + 3) as usize];
1241        assert_eq!(alpha(15, 5), 255); // vertical strip kept
1242        assert_eq!(alpha(5, 15), 0); // horizontal extent cleared
1243    }
1244
1245    #[test]
1246    fn alpha_mask_zeroes_outside_circle_only() {
1247        let w = 20;
1248        let h = 20;
1249        let mut rgba = vec![255u8; (w * h * 4) as usize];
1250        apply_alpha_mask_outside(
1251            &mut rgba,
1252            w,
1253            h,
1254            &Shape::Circle {
1255                cx: 10,
1256                cy: 10,
1257                r: 8,
1258            },
1259            0,
1260        );
1261        let alpha = |x: i32, y: i32| rgba[((y * w + x) * 4 + 3) as usize];
1262        assert_eq!(alpha(10, 10), 255); // center kept
1263        assert_eq!(alpha(10, 2), 255); // on the radius kept
1264        assert_eq!(alpha(0, 0), 0); // corner cleared
1265        assert_eq!(rgba[0], 255); // color channels untouched
1266    }
1267
1268    #[test]
1269    fn a_rotated_rect_fills_its_interior() {
1270        let mut buf = canvas_buf();
1271        let mut c = Canvas::new(&mut buf, W, H);
1272        c.draw_shape_rotated(&Shape::Rect(Rect::new(30, 15, 40, 30)), 30, RED, 2, true);
1273        // The centre of a rotated rect is inside it at any angle.
1274        assert_eq!(px(&buf, 50, 30), RED.to_0rgb());
1275    }
1276
1277    #[test]
1278    fn a_rotated_rect_outline_is_a_band_not_a_fill() {
1279        let mut buf = canvas_buf();
1280        let mut c = Canvas::new(&mut buf, W, H);
1281        c.draw_shape_rotated(&Shape::Rect(Rect::new(30, 15, 40, 30)), 30, RED, 2, false);
1282        assert_eq!(px(&buf, 50, 30), 0, "centre stays empty for an outline");
1283        assert!(
1284            buf.iter().any(|&p| p == RED.to_0rgb()),
1285            "something was drawn"
1286        );
1287    }
1288
1289    #[test]
1290    fn a_rotated_triangle_bakes_its_rotation() {
1291        let mut buf = canvas_buf();
1292        let mut c = Canvas::new(&mut buf, W, H);
1293        let tri = Shape::Triangle {
1294            ax: 50,
1295            ay: 10,
1296            bx: 20,
1297            by: 50,
1298            cx: 80,
1299            cy: 50,
1300        };
1301        c.draw_shape_rotated(&tri, 90, RED, 1, true);
1302        assert!(buf.iter().any(|&p| p == RED.to_0rgb()));
1303    }
1304
1305    #[test]
1306    fn draw_shape_fills_or_outlines_each_kind() {
1307        for fill in [true, false] {
1308            for shape in [
1309                Shape::Rect(Rect::new(10, 10, 20, 20)),
1310                Shape::Circle {
1311                    cx: 50,
1312                    cy: 30,
1313                    r: 12,
1314                },
1315                Shape::Triangle {
1316                    ax: 60,
1317                    ay: 10,
1318                    bx: 45,
1319                    by: 40,
1320                    cx: 75,
1321                    cy: 40,
1322                },
1323            ] {
1324                let mut buf = canvas_buf();
1325                let mut c = Canvas::new(&mut buf, W, H);
1326                c.draw_shape(&shape, RED, 2, fill);
1327                assert!(
1328                    buf.iter().any(|&p| p == RED.to_0rgb()),
1329                    "{shape:?} fill={fill} drew nothing"
1330                );
1331            }
1332        }
1333    }
1334
1335    #[test]
1336    fn a_caption_flips_inside_the_canvas_at_every_edge() {
1337        let bounds = Size::new(W, H);
1338        let len = 6;
1339        // Each corner drives a different branch of the placement cascade;
1340        // whichever it picks must land inside the canvas.
1341        for bbox in [
1342            Rect::new(0, 0, 20, 20),         // no room above
1343            Rect::new(W - 10, 0, 20, 20),    // no room right
1344            Rect::new(0, H - 10, 20, 20),    // no room below
1345            Rect::new(W - 5, H - 5, 20, 20), // no room anywhere
1346        ] {
1347            let p = smart_text_position(bbox, bounds, len, 1);
1348            assert!(p.x >= 0 && p.y >= 0, "{bbox:?} -> {p:?}");
1349            assert!(p.x + font::text_width(len, 1) <= W, "{bbox:?} -> {p:?}");
1350            assert!(p.y + font::line_height(1) <= H, "{bbox:?} -> {p:?}");
1351        }
1352    }
1353
1354    #[test]
1355    fn a_caption_wider_than_the_canvas_starts_off_the_left_edge() {
1356        // Documented, not desired: nothing can fit, so the placement pins
1357        // the right edge and the head of the text clips away. Drawing
1358        // clips per pixel, so this is safe rather than fatal.
1359        let len = 40;
1360        assert!(font::text_width(len, 1) > W);
1361        let p = smart_text_position(Rect::new(0, 0, 20, 20), Size::new(W, H), len, 1);
1362        assert!(p.x < 0, "{p:?}");
1363    }
1364}