use image::{Rgba, RgbaImage};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OverlayQuad {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl OverlayQuad {
pub const EMPTY: OverlayQuad = OverlayQuad {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
};
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BoxQuads {
pub content: OverlayQuad,
pub padding: OverlayQuad,
pub border: OverlayQuad,
pub margin: OverlayQuad,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HighlightColor {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
impl HighlightColor {
pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
Self { r, g, b, a }
}
pub fn from_rgba8(r: u8, g: u8, b: u8, a: f32) -> Self {
Self {
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HighlightSpec {
pub content: HighlightColor,
pub padding: HighlightColor,
pub border: HighlightColor,
pub margin: HighlightColor,
}
impl Default for HighlightSpec {
fn default() -> Self {
Self {
content: HighlightColor::from_rgba8(111, 168, 220, 0.66),
padding: HighlightColor::from_rgba8(147, 196, 125, 0.55),
border: HighlightColor::from_rgba8(255, 229, 153, 0.66),
margin: HighlightColor::from_rgba8(246, 178, 107, 0.66),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum OverlayNodeRef {
NodeId(i64),
BackendNodeId(i64),
ObjectId(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct HighlightState {
pub node: OverlayNodeRef,
pub spec: HighlightSpec,
}
pub fn draw_highlight(image: &mut RgbaImage, quads: &BoxQuads, spec: &HighlightSpec) {
let layers = [
(quads.margin, spec.margin),
(quads.border, spec.border),
(quads.padding, spec.padding),
(quads.content, spec.content),
];
for (quad, color) in layers {
draw_layer(image, quad, color);
}
}
fn draw_layer(image: &mut RgbaImage, quad: OverlayQuad, color: HighlightColor) {
let fill = to_fill(color);
if fill[3] == 0 {
return;
}
let (x0, y0, x1, y1) = rounded_bounds(quad);
if x1 <= x0 || y1 <= y0 {
return;
}
let stroke = to_stroke(color);
let (w, h) = (image.width() as i64, image.height() as i64);
let (ix0, iy0) = (x0.max(0), y0.max(0));
let (ix1, iy1) = (x1.min(w), y1.min(h));
if ix1 <= ix0 || iy1 <= iy0 {
return;
}
for y in iy0..iy1 {
for x in ix0..ix1 {
let on_ring = x == ix0 || x == ix1 - 1 || y == iy0 || y == iy1 - 1;
if on_ring {
*image.get_pixel_mut(x as u32, y as u32) = stroke;
} else {
let px = image.get_pixel_mut(x as u32, y as u32);
*px = blend_over(*px, fill);
}
}
}
}
fn rounded_bounds(quad: OverlayQuad) -> (i64, i64, i64, i64) {
(
quad.x.round() as i64,
quad.y.round() as i64,
(quad.x + quad.w).round() as i64,
(quad.y + quad.h).round() as i64,
)
}
fn to_fill(color: HighlightColor) -> Rgba<u8> {
Rgba([
to_channel_u8(color.r),
to_channel_u8(color.g),
to_channel_u8(color.b),
to_channel_u8(color.a),
])
}
fn to_stroke(color: HighlightColor) -> Rgba<u8> {
Rgba([
to_channel_u8(color.r),
to_channel_u8(color.g),
to_channel_u8(color.b),
255,
])
}
fn to_channel_u8(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
fn blend_over(dst: Rgba<u8>, src: Rgba<u8>) -> Rgba<u8> {
let sa = src[3] as u32;
if sa == 0 {
return dst;
}
if sa == 255 {
return src;
}
let da = dst[3] as u32;
let inv = 255 - sa;
let alpha = sa + div_round(da * inv, 255);
let den = 255 * sa + da * inv;
let ch = |s: u8, d: u8| div_round(255 * s as u32 * sa + d as u32 * da * inv, den) as u8;
Rgba([
ch(src[0], dst[0]),
ch(src[1], dst[1]),
ch(src[2], dst[2]),
alpha as u8,
])
}
fn div_round(n: u32, d: u32) -> u32 {
(n + d / 2) / d
}
#[cfg(test)]
mod tests {
use super::*;
const WHITE: [u8; 4] = [255, 255, 255, 255];
fn solid(w: u32, h: u32, px: [u8; 4]) -> RgbaImage {
RgbaImage::from_pixel(w, h, Rgba(px))
}
fn q(x: f32, y: f32, w: f32, h: f32) -> OverlayQuad {
OverlayQuad { x, y, w, h }
}
fn quads(content: OverlayQuad) -> BoxQuads {
BoxQuads {
content,
padding: OverlayQuad::EMPTY,
border: OverlayQuad::EMPTY,
margin: OverlayQuad::EMPTY,
}
}
fn content_spec(color: HighlightColor) -> HighlightSpec {
let clear = HighlightColor::new(0.0, 0.0, 0.0, 0.0);
HighlightSpec {
content: color,
padding: clear,
border: clear,
margin: clear,
}
}
fn green_opaque() -> HighlightColor {
HighlightColor::new(0.0, 1.0, 0.0, 1.0)
}
#[test]
fn four_layers_stack_outside_in() {
let mut image = solid(40, 40, WHITE);
let spec = HighlightSpec {
content: HighlightColor::new(0.0, 0.0, 0.0, 1.0),
padding: HighlightColor::new(0.0, 0.0, 1.0, 1.0),
border: HighlightColor::new(0.0, 1.0, 0.0, 1.0),
margin: HighlightColor::new(1.0, 0.0, 0.0, 1.0),
};
let quads = BoxQuads {
margin: q(0.0, 0.0, 40.0, 40.0),
border: q(5.0, 5.0, 30.0, 30.0),
padding: q(10.0, 10.0, 20.0, 20.0),
content: q(15.0, 15.0, 10.0, 10.0),
};
draw_highlight(&mut image, &quads, &spec);
assert_eq!(image.get_pixel(2, 2), &Rgba([255, 0, 0, 255]));
assert_eq!(image.get_pixel(7, 7), &Rgba([0, 255, 0, 255]));
assert_eq!(image.get_pixel(12, 12), &Rgba([0, 0, 255, 255]));
assert_eq!(image.get_pixel(18, 18), &Rgba([0, 0, 0, 255]));
}
#[test]
fn translucent_layers_blend_with_exact_integer_math() {
let mut image = solid(4, 4, [10, 20, 30, 255]);
let spec = HighlightSpec {
content: HighlightColor::new(1.0, 0.0, 0.0, 0.5),
padding: HighlightColor::new(0.0, 0.0, 1.0, 0.5),
border: HighlightColor::new(0.0, 0.0, 0.0, 0.0),
margin: HighlightColor::new(0.0, 0.0, 0.0, 0.0),
};
let quads = BoxQuads {
margin: OverlayQuad::EMPTY,
border: OverlayQuad::EMPTY,
padding: q(0.0, 0.0, 4.0, 4.0),
content: q(0.0, 0.0, 4.0, 4.0),
};
draw_highlight(&mut image, &quads, &spec);
assert_eq!(image.get_pixel(1, 1), &Rgba([130, 5, 71, 255]));
assert_eq!(image.get_pixel(0, 0), &Rgba([255, 0, 0, 255]));
}
#[test]
fn quad_partially_out_of_bounds_clips_to_image() {
let mut image = solid(4, 4, WHITE);
draw_highlight(
&mut image,
&quads(q(2.0, 2.0, 10.0, 10.0)),
&content_spec(green_opaque()),
);
assert_eq!(image.get_pixel(3, 3), &Rgba([0, 255, 0, 255]));
assert_eq!(image.get_pixel(1, 1), &Rgba(WHITE));
}
#[test]
fn quad_fully_outside_image_draws_nothing() {
let mut image = solid(4, 4, WHITE);
draw_highlight(
&mut image,
&quads(q(-20.0, -20.0, 10.0, 10.0)),
&content_spec(green_opaque()),
);
assert!(image.pixels().all(|p| p == &Rgba(WHITE)));
}
#[test]
fn quad_crossing_image_origin_clips_to_visible_part() {
let mut image = solid(4, 4, WHITE);
draw_highlight(
&mut image,
&quads(q(-2.0, -2.0, 4.0, 4.0)),
&content_spec(green_opaque()),
);
assert_eq!(image.get_pixel(0, 0), &Rgba([0, 255, 0, 255]));
assert_eq!(image.get_pixel(2, 2), &Rgba(WHITE));
}
#[test]
fn oversized_quad_clips_to_whole_image() {
let mut image = solid(4, 4, WHITE);
draw_highlight(
&mut image,
&quads(q(0.0, 0.0, 100.0, 100.0)),
&content_spec(green_opaque()),
);
assert_eq!(image.get_pixel(0, 0), &Rgba([0, 255, 0, 255]));
assert_eq!(image.get_pixel(3, 3), &Rgba([0, 255, 0, 255]));
}
#[test]
fn zero_area_quad_variants_draw_nothing() {
let cases = [
("zero-area point", q(2.0, 2.0, 0.0, 0.0)),
("zero-width", q(2.0, 2.0, 0.0, 2.0)),
("zero-height", q(2.0, 2.0, 2.0, 0.0)),
("negative size", q(2.0, 2.0, -4.0, -4.0)),
];
for (name, quad) in cases {
let mut image = solid(4, 4, WHITE);
draw_highlight(&mut image, &quads(quad), &content_spec(green_opaque()));
assert!(
image.pixels().all(|p| p == &Rgba(WHITE)),
"{name} quad must draw nothing"
);
}
}
#[test]
fn fully_transparent_layer_draws_nothing() {
let mut image = solid(4, 4, WHITE);
draw_highlight(
&mut image,
&quads(q(0.0, 0.0, 4.0, 4.0)),
&content_spec(HighlightColor::new(1.0, 0.0, 0.0, 0.0)),
);
assert!(image.pixels().all(|p| p == &Rgba(WHITE)));
}
#[test]
fn default_spec_is_the_devtools_palette() {
let spec = HighlightSpec::default();
let assert_close = |c: HighlightColor, r: u8, g: u8, b: u8, a: f32| {
let close = |v: f32, want: f32| assert!((v - want).abs() < 1e-4, "{v} != {want}");
close(c.r, r as f32 / 255.0);
close(c.g, g as f32 / 255.0);
close(c.b, b as f32 / 255.0);
close(c.a, a);
};
assert_close(spec.content, 111, 168, 220, 0.66);
assert_close(spec.padding, 147, 196, 125, 0.55);
assert_close(spec.border, 255, 229, 153, 0.66);
assert_close(spec.margin, 246, 178, 107, 0.66);
}
#[test]
fn highlight_state_holds_node_ref_and_spec() {
let state = HighlightState {
node: OverlayNodeRef::NodeId(42),
spec: HighlightSpec::default(),
};
assert_eq!(state.node, OverlayNodeRef::NodeId(42));
assert_eq!(state.spec, HighlightSpec::default());
assert_ne!(state.node, OverlayNodeRef::BackendNodeId(42));
let object = OverlayNodeRef::ObjectId("1:3".to_string());
assert!(matches!(object, OverlayNodeRef::ObjectId(_)));
}
}