1use image::{Pixel, Rgba, RgbaImage};
9
10use crate::image_util::{screen_rect_to_image, ScreenshotMap};
11use crate::tree::{UiNode, CHILD_CAP, DEPTH_CAP, LINE_CAP};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct MarkBox {
20 pub rect: ImageRect,
22 pub label: String,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct ImageRect {
29 pub x: i32,
31 pub y: i32,
33 pub width: u32,
35 pub height: u32,
37}
38
39impl ImageRect {
40 fn right(&self) -> i32 {
41 self.x.saturating_add(self.width as i32)
42 }
43
44 fn bottom(&self) -> i32 {
45 self.y.saturating_add(self.height as i32)
46 }
47
48 fn is_empty(&self) -> bool {
49 self.width == 0 || self.height == 0
50 }
51}
52
53pub fn draw_marks(source: &RgbaImage, boxes: &[MarkBox]) -> RgbaImage {
58 let mut image = source.clone();
59 for mark in boxes {
60 if mark.rect.is_empty() {
61 continue;
62 }
63 draw_box_border(&mut image, mark.rect);
64 draw_label(&mut image, mark.rect, &mark.label);
65 }
66 image
67}
68
69#[must_use]
71pub fn tree_label(index: u32) -> String {
72 index.to_string()
73}
74
75#[must_use]
77pub fn ocr_label(index: u32) -> String {
78 format!("o{index}")
79}
80
81#[must_use]
92pub fn marks_for_nodes(root: &UiNode, map: &ScreenshotMap) -> Vec<MarkBox> {
93 let mut out = Vec::new();
94 let mut index = 0_u32;
95 collect_marks(root, map, 0, &mut index, &mut out);
96 out
97}
98
99fn collect_marks(
100 node: &UiNode,
101 map: &ScreenshotMap,
102 depth: usize,
103 index: &mut u32,
104 out: &mut Vec<MarkBox>,
105) {
106 let label = tree_label(*index);
107 *index = index.saturating_add(1);
108 if let Some(frame) = node.frame {
109 if let Some((x, y, width, height)) =
110 screen_rect_to_image(map, frame.x, frame.y, frame.width, frame.height)
111 {
112 out.push(MarkBox {
113 rect: ImageRect {
114 x,
115 y,
116 width,
117 height,
118 },
119 label,
120 });
121 }
122 }
123 let total = node.child_count.unwrap_or(node.children.len());
124 if depth >= DEPTH_CAP {
125 return;
126 }
127 let show = total.min(CHILD_CAP).min(node.children.len());
128 for child in node.children.iter().take(show) {
129 if usize::try_from(*index).unwrap_or(usize::MAX) >= LINE_CAP {
130 return;
131 }
132 collect_marks(child, map, depth + 1, index, out);
133 }
134}
135
136fn draw_box_border(image: &mut RgbaImage, rect: ImageRect) {
142 let color = MARK_BORDER;
143 let w = image.width() as i32;
144 let h = image.height() as i32;
145 let left = rect.x.clamp(0, w);
146 let top = rect.y.clamp(0, h);
147 let right = rect.right().clamp(0, w);
148 let bottom = rect.bottom().clamp(0, h);
149 if left >= right || top >= bottom {
150 return;
151 }
152 let stroke = 1_i32;
153 for stroke_y in 0..stroke {
155 for x in left..right {
156 put_pixel(image, x, top + stroke_y, color);
157 put_pixel(image, x, bottom - 1 - stroke_y, color);
158 }
159 }
160 for stroke_x in 0..stroke {
162 for y in top..bottom {
163 put_pixel(image, left + stroke_x, y, color);
164 put_pixel(image, right - 1 - stroke_x, y, color);
165 }
166 }
167 let halo = MARK_HALO;
169 for stroke_y in 0..stroke {
170 for x in left..right {
171 put_pixel(image, x, top.saturating_sub(1) + stroke_y, halo);
172 put_pixel(image, x, bottom + stroke_y, halo);
173 }
174 }
175 for stroke_x in 0..stroke {
176 for y in top..bottom {
177 put_pixel(image, left.saturating_sub(1) + stroke_x, y, halo);
178 put_pixel(image, right + stroke_x, y, halo);
179 }
180 }
181}
182
183fn draw_label(image: &mut RgbaImage, rect: ImageRect, label: &str) {
186 let pixels = label_to_pixels(label);
187 let rect_w = rect.width.max(1);
188 let rect_h = rect.height.max(1);
189 let cell = (rect_w / pixels.cols.max(1) as u32)
193 .min(rect_h / FONT_HEIGHT.max(1) as u32)
194 .min(MAX_LABEL_CELL);
195 if cell == 0 {
196 return;
197 }
198 let cell_i32 = cell as i32;
199 let origin_x = rect.x;
200 let origin_y = rect.y;
201 let w = image.width() as i32;
202 let h = image.height() as i32;
203 for (row, line) in pixels.rows.iter().enumerate() {
204 let y = origin_y + (row as i32) * cell_i32;
205 if y >= h {
206 break;
207 }
208 for (col, on) in line.iter().enumerate() {
209 if !*on {
210 continue;
211 }
212 let x = origin_x + (col as i32) * cell_i32;
213 if x >= w {
214 break;
215 }
216 for dy in 0..cell_i32 {
217 if y + dy < 0 || y + dy >= h {
218 continue;
219 }
220 for dx in 0..cell_i32 {
221 if x + dx < 0 || x + dx >= w {
222 continue;
223 }
224 put_pixel(image, x + dx, y + dy, MARK_TEXT);
225 }
226 }
227 }
228 }
229}
230
231fn put_pixel(image: &mut RgbaImage, x: i32, y: i32, color: Rgba<u8>) {
232 if x < 0 || y < 0 {
233 return;
234 }
235 let (xi, yi) = (x as u32, y as u32);
236 if xi >= image.width() || yi >= image.height() {
237 return;
238 }
239 let blended = blend(image.get_pixel(xi, yi).to_rgba(), color);
240 image.put_pixel(xi, yi, blended);
241}
242
243fn blend(base: Rgba<u8>, overlay: Rgba<u8>) -> Rgba<u8> {
246 let alpha = u32::from(overlay.0[3]);
247 if alpha == 0 {
248 return base;
249 }
250 let inv = 255 - alpha;
251 let mut out = [0_u8; 4];
252 for (i, slot) in out.iter_mut().enumerate().take(3) {
253 let mixed = (u32::from(base.0[i]) * inv + u32::from(overlay.0[i]) * alpha) / 255;
254 *slot = mixed.min(255) as u8;
255 }
256 out[3] = 255;
257 Rgba(out)
258}
259
260const MARK_BORDER: Rgba<u8> = Rgba([255, 64, 32, 255]);
262const MARK_TEXT: Rgba<u8> = Rgba([255, 255, 255, 255]);
264const MARK_HALO: Rgba<u8> = Rgba([255, 64, 32, 80]);
266
267const FONT_HEIGHT: usize = 6;
272const FONT_WIDTH: usize = 4;
273const FONT_GLYPHS: &[(&str, [u8; FONT_HEIGHT])] = &[
274 ("0", [0b0110, 0b1001, 0b1001, 0b1001, 0b1001, 0b0110]),
275 ("1", [0b0010, 0b0110, 0b0010, 0b0010, 0b0010, 0b0111]),
276 ("2", [0b0110, 0b1001, 0b0001, 0b0010, 0b0100, 0b1111]),
277 ("3", [0b1110, 0b0001, 0b0001, 0b0110, 0b0001, 0b1110]),
278 ("4", [0b0001, 0b0011, 0b0101, 0b1001, 0b1111, 0b0001]),
279 ("5", [0b1111, 0b1000, 0b1110, 0b0001, 0b0001, 0b1110]),
280 ("6", [0b0010, 0b0100, 0b1000, 0b1110, 0b1001, 0b0110]),
281 ("7", [0b1111, 0b0001, 0b0010, 0b0100, 0b0100, 0b0100]),
282 ("8", [0b0110, 0b1001, 0b1001, 0b0110, 0b1001, 0b0110]),
283 ("9", [0b0110, 0b1001, 0b0111, 0b0001, 0b0010, 0b0100]),
284 ("o", [0b0000, 0b0000, 0b0110, 0b1001, 0b1001, 0b0110]),
285 ("-", [0b0000, 0b0000, 0b0000, 0b0111, 0b0000, 0b0000]),
286];
287
288struct GlyphPixels {
289 rows: Vec<[bool; FONT_WIDTH * MAX_LABEL_CHARS]>,
290 cols: usize,
291}
292
293const MAX_LABEL_CHARS: usize = 4;
296
297const MAX_LABEL_CELL: u32 = 4;
302
303fn label_to_pixels(label: &str) -> GlyphPixels {
304 let mut rows = vec![[false; FONT_WIDTH * MAX_LABEL_CHARS]; FONT_HEIGHT];
310 let mut cols = 0_usize;
311 for (char_index, ch) in label.chars().enumerate() {
312 if char_index >= MAX_LABEL_CHARS {
313 break;
315 }
316 let glyph = glyph_for(ch);
317 if glyph.is_empty() {
318 fill_unknown(&mut rows, cols);
321 cols += FONT_WIDTH;
322 continue;
323 }
324 for (row, byte) in glyph.iter().enumerate() {
325 for col in 0..FONT_WIDTH {
326 let on = (byte >> (FONT_WIDTH - 1 - col)) & 1 == 1;
327 rows[row][cols + col] = on;
328 }
329 }
330 cols += FONT_WIDTH;
331 }
332 GlyphPixels { rows, cols }
333}
334
335fn glyph_for(ch: char) -> [u8; FONT_HEIGHT] {
336 let target: &str = match ch {
337 '0' => "0",
338 '1' => "1",
339 '2' => "2",
340 '3' => "3",
341 '4' => "4",
342 '5' => "5",
343 '6' => "6",
344 '7' => "7",
345 '8' => "8",
346 '9' => "9",
347 'o' | 'O' => "o",
348 '-' => "-",
349 _ => "",
350 };
351 FONT_GLYPHS
352 .iter()
353 .find(|(key, _)| *key == target)
354 .map(|(_, glyph)| *glyph)
355 .unwrap_or(EMPTY_GLYPH)
356}
357
358const EMPTY_GLYPH: [u8; FONT_HEIGHT] = [0; FONT_HEIGHT];
359
360fn fill_unknown(rows: &mut [[bool; FONT_WIDTH * MAX_LABEL_CHARS]], col_offset: usize) {
361 rows[2][col_offset + 1] = true;
363 rows[3][col_offset + 1] = true;
364}
365
366#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::image_util::{prepare_shot, ShotFit};
374 use crate::tree::NodeFrame;
375 use image::{Rgba, RgbaImage};
376
377 fn white_square(size: u32) -> RgbaImage {
378 RgbaImage::from_pixel(size, size, Rgba([255, 255, 255, 255]))
379 }
380
381 fn retina_window_map() -> ScreenshotMap {
386 let buffer = RgbaImage::from_pixel(1600, 1000, Rgba([120, 120, 120, 255]));
387 prepare_shot(
388 &buffer,
389 ShotFit {
390 scale: 2.0,
391 origin_x: 120,
392 origin_y: 40,
393 logical_width: 800,
394 logical_height: 500,
395 max_width: 1440,
396 canvas: None,
397 },
398 )
399 .expect("prepare_shot")
400 .map
401 }
402
403 fn framed(role: &str, frame: NodeFrame) -> UiNode {
404 let mut node = UiNode::bare(role);
405 node.frame = Some(frame);
406 node
407 }
408
409 fn rect(x: f64, y: f64, width: f64, height: f64) -> NodeFrame {
410 NodeFrame {
411 x,
412 y,
413 width,
414 height,
415 }
416 }
417
418 #[test]
419 fn screen_point_frames_at_a_nonzero_origin_become_in_canvas_marks() {
420 let map = retina_window_map();
425 assert_eq!(map.origin.x, 120);
426 assert_eq!(map.scale, 2.0);
427
428 let root = framed(
429 "AXWindow",
430 rect(120.0, 40.0, 800.0, 500.0), );
432 let mut inner = framed("AXTextArea", rect(140.0, 60.0, 760.0, 100.0));
433 inner.value = Some("hello".into());
434 let root = UiNode {
435 children: vec![inner],
436 ..root
437 };
438
439 let marks = marks_for_nodes(&root, &map);
440 assert_eq!(
441 marks.len(),
442 2,
443 "both the window and the text area are inside the capture"
444 );
445
446 assert_eq!(marks[0].label, "0");
449 assert_eq!(marks[1].label, "1");
450 assert_eq!(
451 marks[0].rect,
452 ImageRect {
453 x: 0,
454 y: 0,
455 width: 1440,
456 height: 900
457 }
458 );
459 assert_eq!(
460 marks[1].rect,
461 ImageRect {
462 x: 36,
463 y: 36,
464 width: 1368,
465 height: 180
466 }
467 );
468
469 for mark in &marks {
470 assert!(
471 mark.rect.x >= 0
472 && mark.rect.y >= 0
473 && mark.rect.x + mark.rect.width as i32 <= map.width as i32
474 && mark.rect.y + mark.rect.height as i32 <= map.height as i32,
475 "mark {:?} escaped the {}x{} canvas",
476 mark.rect,
477 map.width,
478 map.height
479 );
480 }
481 }
482
483 #[test]
484 fn mark_indices_match_the_numbers_the_tree_prints() {
485 let map = retina_window_map();
486 let root = UiNode {
487 children: vec![
488 framed("AXTextArea", rect(140.0, 60.0, 760.0, 100.0)),
489 framed("AXButton", rect(140.0, 200.0, 120.0, 40.0)),
490 ],
491 ..framed("AXWindow", rect(120.0, 40.0, 800.0, 500.0))
492 };
493 let rendered = crate::tree::render(&root);
494 let marks = marks_for_nodes(&root, &map);
495 assert_eq!(marks.len(), 3);
496 for mark in &marks {
497 assert!(
498 rendered.text.contains(&format!("[{}]", mark.label)),
499 "index {} is not in the rendered tree:\n{}",
500 mark.label,
501 rendered.text
502 );
503 }
504 }
505
506 #[test]
507 fn a_frameless_group_does_not_hide_its_subtree() {
508 let map = retina_window_map();
511 let mut group = UiNode::bare("AXGroup");
512 group.frame = None;
513 group.children = vec![framed("AXButton", rect(140.0, 200.0, 120.0, 40.0))];
514 let root = UiNode {
515 children: vec![group],
516 ..framed("AXWindow", rect(120.0, 40.0, 800.0, 500.0))
517 };
518
519 let marks = marks_for_nodes(&root, &map);
520 assert_eq!(
521 marks.len(),
522 2,
523 "the window and the button under the frameless group"
524 );
525 assert_eq!(marks[1].label, "2", "the group still consumed index 1");
526 }
527
528 #[test]
529 fn frames_outside_the_capture_produce_no_box() {
530 let map = retina_window_map();
531 let root = framed("AXWindow", rect(4_000.0, 4_000.0, 300.0, 200.0));
532 assert!(marks_for_nodes(&root, &map).is_empty());
533 }
534
535 #[test]
536 fn a_large_box_does_not_paint_a_giant_label() {
537 let image = RgbaImage::from_pixel(1440, 682, Rgba([20, 20, 20, 255]));
541 let marks = vec![MarkBox {
542 rect: ImageRect {
543 x: 0,
544 y: 0,
545 width: 1440,
546 height: 682,
547 },
548 label: tree_label(0),
549 }];
550 let marked = draw_marks(&image, &marks);
551
552 let badge_w = (FONT_WIDTH * MAX_LABEL_CELL as usize) as i32;
553 let badge_h = (FONT_HEIGHT * MAX_LABEL_CELL as usize) as i32;
554 let dark = Rgba([20, 20, 20, 255]);
555 let mut text_outside_badge = 0_u32;
556 for y in 0..marked.height() as i32 {
557 for x in 0..marked.width() as i32 {
558 if marked.get_pixel(x as u32, y as u32).to_rgba() == MARK_TEXT
561 && (x >= badge_w || y >= badge_h)
562 {
563 text_outside_badge += 1;
564 }
565 }
566 }
567
568 let total_changed = marked
569 .pixels()
570 .zip(image.pixels())
571 .filter(|(after, before)| **after != dark && **after != **before)
572 .count() as u32;
573
574 assert_eq!(
575 text_outside_badge, 0,
576 "the digit must stay inside its {badge_w}x{badge_h} badge"
577 );
578 assert!(
579 total_changed < 1440 * 682 / 4,
580 "the overlay rewrote {total_changed} of {} pixels",
581 1440 * 682
582 );
583 }
584
585 #[test]
586 fn marks_leave_a_non_white_pixel_inside_box_zero() {
587 let image = white_square(20);
591 let marks = vec![MarkBox {
592 rect: ImageRect {
593 x: 4,
594 y: 4,
595 width: 8,
596 height: 8,
597 },
598 label: tree_label(0),
599 }];
600 let marked = draw_marks(&image, &marks);
601 let mut saw_drawn_pixel = false;
602 for y in 4..12 {
606 for x in 4..12 {
607 if marked.get_pixel(x, y).to_rgba() != Rgba([255, 255, 255, 255]) {
608 saw_drawn_pixel = true;
609 break;
610 }
611 }
612 if saw_drawn_pixel {
613 break;
614 }
615 }
616 assert!(
617 saw_drawn_pixel,
618 "marks must leave a non-white pixel inside the labelled box"
619 );
620 }
621
622 #[test]
623 fn empty_boxes_are_skipped() {
624 let image = white_square(8);
625 let marks = vec![
626 MarkBox {
627 rect: ImageRect {
628 x: 0,
629 y: 0,
630 width: 0,
631 height: 0,
632 },
633 label: "?".to_string(),
634 },
635 MarkBox {
636 rect: ImageRect {
637 x: -10,
638 y: -10,
639 width: 4,
640 height: 4,
641 },
642 label: "?".to_string(),
643 },
644 ];
645 let marked = draw_marks(&image, &marks);
646 for y in 0..marked.height() {
648 for x in 0..marked.width() {
649 assert_eq!(*marked.get_pixel(x, y), Rgba([255, 255, 255, 255]));
650 }
651 }
652 }
653
654 #[test]
655 fn ocr_label_format() {
656 assert_eq!(ocr_label(0), "o0");
657 assert_eq!(ocr_label(12), "o12");
658 }
659
660 #[test]
661 fn tree_label_is_decimal() {
662 assert_eq!(tree_label(0), "0");
663 assert_eq!(tree_label(42), "42");
664 }
665
666 #[test]
667 fn multi_char_label_renders_without_panicking() {
668 let image = RgbaImage::from_pixel(40, 12, Rgba([255, 255, 255, 255]));
677 let marks = vec![MarkBox {
678 rect: ImageRect {
679 x: 24,
680 y: 0,
681 width: 16,
682 height: 12,
683 },
684 label: "o12".to_string(),
685 }];
686 let marked = draw_marks(&image, &marks);
687 let mut saw_drawn_pixel = false;
692 for y in 0..12 {
693 for x in 24..40 {
694 if marked.get_pixel(x, y).to_rgba() != Rgba([255, 255, 255, 255]) {
695 saw_drawn_pixel = true;
696 break;
697 }
698 }
699 if saw_drawn_pixel {
700 break;
701 }
702 }
703 assert!(
704 saw_drawn_pixel,
705 "marks must leave a non-white pixel inside the labelled box"
706 );
707 }
708
709 #[test]
710 fn multi_char_label_with_touching_right_edge_stays_inside() {
711 let image = RgbaImage::from_pixel(8, 8, Rgba([255, 255, 255, 255]));
720 let marks = vec![MarkBox {
721 rect: ImageRect {
722 x: 4,
723 y: 0,
724 width: 4,
725 height: 8,
726 },
727 label: "12".to_string(),
728 }];
729 let _marked = draw_marks(&image, &marks);
730 }
731}