1use 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 #[must_use]
35 pub fn to_hex(self) -> String {
36 format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
37 }
38}
39
40#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
456fn 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
471pub 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#[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
501pub 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
538fn 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
549pub 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
563pub 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
575fn 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
591fn 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
607pub 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 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 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 let mut rgba = vec![0u8; 3 * 2 * 4];
675 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 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); assert_eq!(px(&buf, 15, 15), 0); assert_eq!(px(&buf, 9, 10), 0); }
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); assert_eq!(px(&buf, 59, 30), red); assert_eq!(px(&buf, 50, 30), 0); assert_eq!(px(&buf, 62, 30), 0); }
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); 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); assert_eq!(px(&buf, 50, 11), red); assert_eq!(px(&buf, 12, 12), 0); assert_eq!(px(&buf, 88, 12), 0); }
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); assert_eq!(px(&buf, 50, 35), 0); assert_eq!(px(&buf, 12, 12), 0); }
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 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 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 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 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 assert_eq!(buf[10 * 40 + 20], ink);
996 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 assert_eq!(capped[10 * 40 + 20], ink);
1025 assert_eq!(bare[10 * 40 + 20], ink);
1026 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 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); assert_eq!(px(&buf, 50, 55), red); assert_eq!(px(&buf, 75, 30), 0); 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)); 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 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 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 assert_eq!(pixel(10, 10), (200, 200));
1151 assert_eq!(pixel(40, 20), (200, 200));
1152 assert_eq!(pixel(25, 10), (200, 0));
1154 assert_eq!(pixel(0, 39), (200, 0));
1155 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 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 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 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 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 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); assert_eq!(alpha(5, 15), 0); }
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); assert_eq!(alpha(10, 2), 255); assert_eq!(alpha(0, 0), 0); assert_eq!(rgba[0], 255); }
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 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 for bbox in [
1342 Rect::new(0, 0, 20, 20), Rect::new(W - 10, 0, 20, 20), Rect::new(0, H - 10, 20, 20), Rect::new(W - 5, H - 5, 20, 20), ] {
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 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}