use image::{Pixel, Rgba, RgbaImage};
use crate::image_util::{screen_rect_to_image, ScreenshotMap};
use crate::tree::{UiNode, CHILD_CAP, DEPTH_CAP, LINE_CAP};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkBox {
pub rect: ImageRect,
pub label: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageRect {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
}
impl ImageRect {
fn right(&self) -> i32 {
self.x.saturating_add(self.width as i32)
}
fn bottom(&self) -> i32 {
self.y.saturating_add(self.height as i32)
}
fn is_empty(&self) -> bool {
self.width == 0 || self.height == 0
}
}
pub fn draw_marks(source: &RgbaImage, boxes: &[MarkBox]) -> RgbaImage {
let mut image = source.clone();
for mark in boxes {
if mark.rect.is_empty() {
continue;
}
draw_box_border(&mut image, mark.rect);
draw_label(&mut image, mark.rect, &mark.label);
}
image
}
#[must_use]
pub fn tree_label(index: u32) -> String {
index.to_string()
}
#[must_use]
pub fn ocr_label(index: u32) -> String {
format!("o{index}")
}
#[must_use]
pub fn marks_for_nodes(root: &UiNode, map: &ScreenshotMap) -> Vec<MarkBox> {
let mut out = Vec::new();
let mut index = 0_u32;
collect_marks(root, map, 0, &mut index, &mut out);
out
}
fn collect_marks(
node: &UiNode,
map: &ScreenshotMap,
depth: usize,
index: &mut u32,
out: &mut Vec<MarkBox>,
) {
let label = tree_label(*index);
*index = index.saturating_add(1);
if let Some(frame) = node.frame {
if let Some((x, y, width, height)) =
screen_rect_to_image(map, frame.x, frame.y, frame.width, frame.height)
{
out.push(MarkBox {
rect: ImageRect {
x,
y,
width,
height,
},
label,
});
}
}
let total = node.child_count.unwrap_or(node.children.len());
if depth >= DEPTH_CAP {
return;
}
let show = total.min(CHILD_CAP).min(node.children.len());
for child in node.children.iter().take(show) {
if usize::try_from(*index).unwrap_or(usize::MAX) >= LINE_CAP {
return;
}
collect_marks(child, map, depth + 1, index, out);
}
}
fn draw_box_border(image: &mut RgbaImage, rect: ImageRect) {
let color = MARK_BORDER;
let w = image.width() as i32;
let h = image.height() as i32;
let left = rect.x.clamp(0, w);
let top = rect.y.clamp(0, h);
let right = rect.right().clamp(0, w);
let bottom = rect.bottom().clamp(0, h);
if left >= right || top >= bottom {
return;
}
let stroke = 1_i32;
for stroke_y in 0..stroke {
for x in left..right {
put_pixel(image, x, top + stroke_y, color);
put_pixel(image, x, bottom - 1 - stroke_y, color);
}
}
for stroke_x in 0..stroke {
for y in top..bottom {
put_pixel(image, left + stroke_x, y, color);
put_pixel(image, right - 1 - stroke_x, y, color);
}
}
let halo = MARK_HALO;
for stroke_y in 0..stroke {
for x in left..right {
put_pixel(image, x, top.saturating_sub(1) + stroke_y, halo);
put_pixel(image, x, bottom + stroke_y, halo);
}
}
for stroke_x in 0..stroke {
for y in top..bottom {
put_pixel(image, left.saturating_sub(1) + stroke_x, y, halo);
put_pixel(image, right + stroke_x, y, halo);
}
}
}
fn draw_label(image: &mut RgbaImage, rect: ImageRect, label: &str) {
let pixels = label_to_pixels(label);
let rect_w = rect.width.max(1);
let rect_h = rect.height.max(1);
let cell = (rect_w / pixels.cols.max(1) as u32)
.min(rect_h / FONT_HEIGHT.max(1) as u32)
.min(MAX_LABEL_CELL);
if cell == 0 {
return;
}
let cell_i32 = cell as i32;
let origin_x = rect.x;
let origin_y = rect.y;
let w = image.width() as i32;
let h = image.height() as i32;
for (row, line) in pixels.rows.iter().enumerate() {
let y = origin_y + (row as i32) * cell_i32;
if y >= h {
break;
}
for (col, on) in line.iter().enumerate() {
if !*on {
continue;
}
let x = origin_x + (col as i32) * cell_i32;
if x >= w {
break;
}
for dy in 0..cell_i32 {
if y + dy < 0 || y + dy >= h {
continue;
}
for dx in 0..cell_i32 {
if x + dx < 0 || x + dx >= w {
continue;
}
put_pixel(image, x + dx, y + dy, MARK_TEXT);
}
}
}
}
}
fn put_pixel(image: &mut RgbaImage, x: i32, y: i32, color: Rgba<u8>) {
if x < 0 || y < 0 {
return;
}
let (xi, yi) = (x as u32, y as u32);
if xi >= image.width() || yi >= image.height() {
return;
}
let blended = blend(image.get_pixel(xi, yi).to_rgba(), color);
image.put_pixel(xi, yi, blended);
}
fn blend(base: Rgba<u8>, overlay: Rgba<u8>) -> Rgba<u8> {
let alpha = u32::from(overlay.0[3]);
if alpha == 0 {
return base;
}
let inv = 255 - alpha;
let mut out = [0_u8; 4];
for (i, slot) in out.iter_mut().enumerate().take(3) {
let mixed = (u32::from(base.0[i]) * inv + u32::from(overlay.0[i]) * alpha) / 255;
*slot = mixed.min(255) as u8;
}
out[3] = 255;
Rgba(out)
}
const MARK_BORDER: Rgba<u8> = Rgba([255, 64, 32, 255]);
const MARK_TEXT: Rgba<u8> = Rgba([255, 255, 255, 255]);
const MARK_HALO: Rgba<u8> = Rgba([255, 64, 32, 80]);
const FONT_HEIGHT: usize = 6;
const FONT_WIDTH: usize = 4;
const FONT_GLYPHS: &[(&str, [u8; FONT_HEIGHT])] = &[
("0", [0b0110, 0b1001, 0b1001, 0b1001, 0b1001, 0b0110]),
("1", [0b0010, 0b0110, 0b0010, 0b0010, 0b0010, 0b0111]),
("2", [0b0110, 0b1001, 0b0001, 0b0010, 0b0100, 0b1111]),
("3", [0b1110, 0b0001, 0b0001, 0b0110, 0b0001, 0b1110]),
("4", [0b0001, 0b0011, 0b0101, 0b1001, 0b1111, 0b0001]),
("5", [0b1111, 0b1000, 0b1110, 0b0001, 0b0001, 0b1110]),
("6", [0b0010, 0b0100, 0b1000, 0b1110, 0b1001, 0b0110]),
("7", [0b1111, 0b0001, 0b0010, 0b0100, 0b0100, 0b0100]),
("8", [0b0110, 0b1001, 0b1001, 0b0110, 0b1001, 0b0110]),
("9", [0b0110, 0b1001, 0b0111, 0b0001, 0b0010, 0b0100]),
("o", [0b0000, 0b0000, 0b0110, 0b1001, 0b1001, 0b0110]),
("-", [0b0000, 0b0000, 0b0000, 0b0111, 0b0000, 0b0000]),
];
struct GlyphPixels {
rows: Vec<[bool; FONT_WIDTH * MAX_LABEL_CHARS]>,
cols: usize,
}
const MAX_LABEL_CHARS: usize = 4;
const MAX_LABEL_CELL: u32 = 4;
fn label_to_pixels(label: &str) -> GlyphPixels {
let mut rows = vec![[false; FONT_WIDTH * MAX_LABEL_CHARS]; FONT_HEIGHT];
let mut cols = 0_usize;
for (char_index, ch) in label.chars().enumerate() {
if char_index >= MAX_LABEL_CHARS {
break;
}
let glyph = glyph_for(ch);
if glyph.is_empty() {
fill_unknown(&mut rows, cols);
cols += FONT_WIDTH;
continue;
}
for (row, byte) in glyph.iter().enumerate() {
for col in 0..FONT_WIDTH {
let on = (byte >> (FONT_WIDTH - 1 - col)) & 1 == 1;
rows[row][cols + col] = on;
}
}
cols += FONT_WIDTH;
}
GlyphPixels { rows, cols }
}
fn glyph_for(ch: char) -> [u8; FONT_HEIGHT] {
let target: &str = match ch {
'0' => "0",
'1' => "1",
'2' => "2",
'3' => "3",
'4' => "4",
'5' => "5",
'6' => "6",
'7' => "7",
'8' => "8",
'9' => "9",
'o' | 'O' => "o",
'-' => "-",
_ => "",
};
FONT_GLYPHS
.iter()
.find(|(key, _)| *key == target)
.map(|(_, glyph)| *glyph)
.unwrap_or(EMPTY_GLYPH)
}
const EMPTY_GLYPH: [u8; FONT_HEIGHT] = [0; FONT_HEIGHT];
fn fill_unknown(rows: &mut [[bool; FONT_WIDTH * MAX_LABEL_CHARS]], col_offset: usize) {
rows[2][col_offset + 1] = true;
rows[3][col_offset + 1] = true;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image_util::{prepare_shot, ShotFit};
use crate::tree::NodeFrame;
use image::{Rgba, RgbaImage};
fn white_square(size: u32) -> RgbaImage {
RgbaImage::from_pixel(size, size, Rgba([255, 255, 255, 255]))
}
fn retina_window_map() -> ScreenshotMap {
let buffer = RgbaImage::from_pixel(1600, 1000, Rgba([120, 120, 120, 255]));
prepare_shot(
&buffer,
ShotFit {
scale: 2.0,
origin_x: 120,
origin_y: 40,
logical_width: 800,
logical_height: 500,
max_width: 1440,
canvas: None,
},
)
.expect("prepare_shot")
.map
}
fn framed(role: &str, frame: NodeFrame) -> UiNode {
let mut node = UiNode::bare(role);
node.frame = Some(frame);
node
}
fn rect(x: f64, y: f64, width: f64, height: f64) -> NodeFrame {
NodeFrame {
x,
y,
width,
height,
}
}
#[test]
fn screen_point_frames_at_a_nonzero_origin_become_in_canvas_marks() {
let map = retina_window_map();
assert_eq!(map.origin.x, 120);
assert_eq!(map.scale, 2.0);
let root = framed(
"AXWindow",
rect(120.0, 40.0, 800.0, 500.0), );
let mut inner = framed("AXTextArea", rect(140.0, 60.0, 760.0, 100.0));
inner.value = Some("hello".into());
let root = UiNode {
children: vec![inner],
..root
};
let marks = marks_for_nodes(&root, &map);
assert_eq!(
marks.len(),
2,
"both the window and the text area are inside the capture"
);
assert_eq!(marks[0].label, "0");
assert_eq!(marks[1].label, "1");
assert_eq!(
marks[0].rect,
ImageRect {
x: 0,
y: 0,
width: 1440,
height: 900
}
);
assert_eq!(
marks[1].rect,
ImageRect {
x: 36,
y: 36,
width: 1368,
height: 180
}
);
for mark in &marks {
assert!(
mark.rect.x >= 0
&& mark.rect.y >= 0
&& mark.rect.x + mark.rect.width as i32 <= map.width as i32
&& mark.rect.y + mark.rect.height as i32 <= map.height as i32,
"mark {:?} escaped the {}x{} canvas",
mark.rect,
map.width,
map.height
);
}
}
#[test]
fn mark_indices_match_the_numbers_the_tree_prints() {
let map = retina_window_map();
let root = UiNode {
children: vec![
framed("AXTextArea", rect(140.0, 60.0, 760.0, 100.0)),
framed("AXButton", rect(140.0, 200.0, 120.0, 40.0)),
],
..framed("AXWindow", rect(120.0, 40.0, 800.0, 500.0))
};
let rendered = crate::tree::render(&root);
let marks = marks_for_nodes(&root, &map);
assert_eq!(marks.len(), 3);
for mark in &marks {
assert!(
rendered.text.contains(&format!("[{}]", mark.label)),
"index {} is not in the rendered tree:\n{}",
mark.label,
rendered.text
);
}
}
#[test]
fn a_frameless_group_does_not_hide_its_subtree() {
let map = retina_window_map();
let mut group = UiNode::bare("AXGroup");
group.frame = None;
group.children = vec![framed("AXButton", rect(140.0, 200.0, 120.0, 40.0))];
let root = UiNode {
children: vec![group],
..framed("AXWindow", rect(120.0, 40.0, 800.0, 500.0))
};
let marks = marks_for_nodes(&root, &map);
assert_eq!(
marks.len(),
2,
"the window and the button under the frameless group"
);
assert_eq!(marks[1].label, "2", "the group still consumed index 1");
}
#[test]
fn frames_outside_the_capture_produce_no_box() {
let map = retina_window_map();
let root = framed("AXWindow", rect(4_000.0, 4_000.0, 300.0, 200.0));
assert!(marks_for_nodes(&root, &map).is_empty());
}
#[test]
fn a_large_box_does_not_paint_a_giant_label() {
let image = RgbaImage::from_pixel(1440, 682, Rgba([20, 20, 20, 255]));
let marks = vec![MarkBox {
rect: ImageRect {
x: 0,
y: 0,
width: 1440,
height: 682,
},
label: tree_label(0),
}];
let marked = draw_marks(&image, &marks);
let badge_w = (FONT_WIDTH * MAX_LABEL_CELL as usize) as i32;
let badge_h = (FONT_HEIGHT * MAX_LABEL_CELL as usize) as i32;
let dark = Rgba([20, 20, 20, 255]);
let mut text_outside_badge = 0_u32;
for y in 0..marked.height() as i32 {
for x in 0..marked.width() as i32 {
if marked.get_pixel(x as u32, y as u32).to_rgba() == MARK_TEXT
&& (x >= badge_w || y >= badge_h)
{
text_outside_badge += 1;
}
}
}
let total_changed = marked
.pixels()
.zip(image.pixels())
.filter(|(after, before)| **after != dark && **after != **before)
.count() as u32;
assert_eq!(
text_outside_badge, 0,
"the digit must stay inside its {badge_w}x{badge_h} badge"
);
assert!(
total_changed < 1440 * 682 / 4,
"the overlay rewrote {total_changed} of {} pixels",
1440 * 682
);
}
#[test]
fn marks_leave_a_non_white_pixel_inside_box_zero() {
let image = white_square(20);
let marks = vec![MarkBox {
rect: ImageRect {
x: 4,
y: 4,
width: 8,
height: 8,
},
label: tree_label(0),
}];
let marked = draw_marks(&image, &marks);
let mut saw_drawn_pixel = false;
for y in 4..12 {
for x in 4..12 {
if marked.get_pixel(x, y).to_rgba() != Rgba([255, 255, 255, 255]) {
saw_drawn_pixel = true;
break;
}
}
if saw_drawn_pixel {
break;
}
}
assert!(
saw_drawn_pixel,
"marks must leave a non-white pixel inside the labelled box"
);
}
#[test]
fn empty_boxes_are_skipped() {
let image = white_square(8);
let marks = vec![
MarkBox {
rect: ImageRect {
x: 0,
y: 0,
width: 0,
height: 0,
},
label: "?".to_string(),
},
MarkBox {
rect: ImageRect {
x: -10,
y: -10,
width: 4,
height: 4,
},
label: "?".to_string(),
},
];
let marked = draw_marks(&image, &marks);
for y in 0..marked.height() {
for x in 0..marked.width() {
assert_eq!(*marked.get_pixel(x, y), Rgba([255, 255, 255, 255]));
}
}
}
#[test]
fn ocr_label_format() {
assert_eq!(ocr_label(0), "o0");
assert_eq!(ocr_label(12), "o12");
}
#[test]
fn tree_label_is_decimal() {
assert_eq!(tree_label(0), "0");
assert_eq!(tree_label(42), "42");
}
#[test]
fn multi_char_label_renders_without_panicking() {
let image = RgbaImage::from_pixel(40, 12, Rgba([255, 255, 255, 255]));
let marks = vec![MarkBox {
rect: ImageRect {
x: 24,
y: 0,
width: 16,
height: 12,
},
label: "o12".to_string(),
}];
let marked = draw_marks(&image, &marks);
let mut saw_drawn_pixel = false;
for y in 0..12 {
for x in 24..40 {
if marked.get_pixel(x, y).to_rgba() != Rgba([255, 255, 255, 255]) {
saw_drawn_pixel = true;
break;
}
}
if saw_drawn_pixel {
break;
}
}
assert!(
saw_drawn_pixel,
"marks must leave a non-white pixel inside the labelled box"
);
}
#[test]
fn multi_char_label_with_touching_right_edge_stays_inside() {
let image = RgbaImage::from_pixel(8, 8, Rgba([255, 255, 255, 255]));
let marks = vec![MarkBox {
rect: ImageRect {
x: 4,
y: 0,
width: 4,
height: 8,
},
label: "12".to_string(),
}];
let _marked = draw_marks(&image, &marks);
}
}