sip 0.3.0

Interactive Wayland screen-region selector with slurp-compatible output
Documentation
use tiny_skia::{BlendMode, Paint, PathBuilder, PixmapMut, Rect as SkRect, Stroke, Transform};

use crate::color::Color;
use crate::geometry::Rect;

#[derive(Debug)]
pub(crate) struct FrozenFrame {
    pub width: u32,
    pub height: u32,
    pub rgba: Vec<u8>,
}

pub struct Scene<'a> {
    pub background: Color,
    pub border: Color,
    pub selection: Color,
    pub choice: Color,
    pub border_weight: f32,
    pub display_dimensions: bool,
    pub font: Option<&'a fontdue::Font>,
    pub logical: Rect,
    pub scale: f32,
    pub choice_boxes: &'a [Rect],
    pub selection_rect: Option<Rect>,
    pub crosshair: Option<(i32, i32)>,
    pub frozen: Option<&'a FrozenFrame>,
}

impl Scene<'_> {
    fn transform(&self) -> Transform {
        Transform::from_row(
            self.scale,
            0.0,
            0.0,
            self.scale,
            -self.scale * self.logical.x as f32,
            -self.scale * self.logical.y as f32,
        )
    }
}

fn solid(color: Color) -> Paint<'static> {
    let mut paint = Paint::default();
    paint.set_color(color.to_skia());
    paint.anti_alias = false;
    paint
}

fn clear() -> Paint<'static> {
    let mut paint = Paint::default();
    paint.blend_mode = BlendMode::Clear;
    paint.anti_alias = false;
    paint
}

fn sk_rect(r: Rect) -> Option<SkRect> {
    SkRect::from_xywh(r.x as f32, r.y as f32, r.width as f32, r.height as f32)
}

pub fn render(pixmap: &mut PixmapMut, scene: &Scene) {
    let transform = scene.transform();

    if let Some(frame) = scene.frozen {
        blit_frozen(pixmap, frame, None);
    }
    if let Some(r) = SkRect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32) {
        pixmap.fill_rect(r, &solid(scene.background), Transform::identity(), None);
    }

    let choice_paint = solid(scene.choice);
    for rect in scene.choice_boxes {
        if rect.intersects(&scene.logical) {
            if let Some(r) = sk_rect(*rect) {
                pixmap.fill_rect(r, &choice_paint, transform, None);
            }
        }
    }

    if let Some((cx, cy)) = scene.crosshair {
        if scene.logical.contains(cx, cy) {
            let paint = solid(scene.border);
            if let Some(h) = sk_rect(Rect::new(scene.logical.x, cy, scene.logical.width, 1)) {
                pixmap.fill_rect(h, &paint, transform, None);
            }
            if let Some(v) = sk_rect(Rect::new(cx, scene.logical.y, 1, scene.logical.height)) {
                pixmap.fill_rect(v, &paint, transform, None);
            }
        }
    }

    if let Some(sel) = scene.selection_rect {
        if sel.intersects(&scene.logical) {
            if let Some(r) = sk_rect(sel) {
                if let Some(frame) = scene.frozen {
                    let local = Rect::new(
                        ((sel.x - scene.logical.x) as f32 * scene.scale) as i32,
                        ((sel.y - scene.logical.y) as f32 * scene.scale) as i32,
                        (sel.width as f32 * scene.scale) as i32,
                        (sel.height as f32 * scene.scale) as i32,
                    );
                    blit_frozen(pixmap, frame, Some(local));
                } else {
                    pixmap.fill_rect(r, &clear(), transform, None);
                }
                pixmap.fill_rect(r, &solid(scene.selection), transform, None);

                let path = PathBuilder::from_rect(r);
                let stroke = Stroke {
                    width: scene.border_weight,
                    ..Stroke::default()
                };
                pixmap.stroke_path(&path, &solid(scene.border), &stroke, transform, None);
            }

            if scene.display_dimensions {
                if let Some(font) = scene.font {
                    let text = format!("{}x{}", sel.width, sel.height);
                    let anchor_x =
                        ((sel.x + sel.width + 10 - scene.logical.x) as f32) * scene.scale;
                    let baseline =
                        ((sel.y + sel.height + 20 - scene.logical.y) as f32) * scene.scale;
                    blit_text(
                        pixmap,
                        &text,
                        anchor_x,
                        baseline,
                        14.0 * scene.scale,
                        font,
                        scene.border,
                    );
                }
            }
        }
    }
}

fn blit_frozen(pixmap: &mut PixmapMut, frame: &FrozenFrame, clip: Option<Rect>) {
    let width = pixmap.width();
    let height = pixmap.height();
    if width == 0 || height == 0 || frame.width == 0 || frame.height == 0 {
        return;
    }

    let clip = clip.unwrap_or(Rect::new(0, 0, width as i32, height as i32));
    let left = clip.x.max(0) as u32;
    let top = clip.y.max(0) as u32;
    let right = clip.x.saturating_add(clip.width).clamp(0, width as i32) as u32;
    let bottom = clip.y.saturating_add(clip.height).clamp(0, height as i32) as u32;
    if left >= right || top >= bottom {
        return;
    }

    let pixels = pixmap.data_mut();
    if width == frame.width && height == frame.height {
        for y in top..bottom {
            let start = ((y * width + left) * 4) as usize;
            let end = ((y * width + right) * 4) as usize;
            if let Some(source) = frame.rgba.get(start..end) {
                pixels[start..end].copy_from_slice(source);
            }
        }
        return;
    }

    for y in top..bottom {
        let source_y = y * frame.height / height;
        for x in left..right {
            let source_x = x * frame.width / width;
            let source = ((source_y * frame.width + source_x) * 4) as usize;
            let target = ((y * width + x) * 4) as usize;
            if let Some(pixel) = frame.rgba.get(source..source + 4) {
                pixels[target..target + 4].copy_from_slice(pixel);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn selection_clears_the_dimmed_background() {
        let mut pixmap = tiny_skia::Pixmap::new(3, 1).unwrap();
        let scene = Scene {
            background: Color::from_rgba_u32(0xFFFFFF40),
            border: Color::from_rgba_u32(0x00000000),
            selection: Color::from_rgba_u32(0x00000000),
            choice: Color::from_rgba_u32(0x00000000),
            border_weight: 0.0,
            display_dimensions: false,
            font: None,
            logical: Rect::new(0, 0, 3, 1),
            scale: 1.0,
            choice_boxes: &[],
            selection_rect: Some(Rect::new(1, 0, 1, 1)),
            crosshair: None,
            frozen: None,
        };

        render(&mut pixmap.as_mut(), &scene);

        assert_eq!(pixmap.pixel(0, 0).unwrap().alpha(), 64);
        assert_eq!(pixmap.pixel(1, 0).unwrap().alpha(), 0);
        assert_eq!(pixmap.pixel(2, 0).unwrap().alpha(), 64);
    }

    #[test]
    fn selection_restores_frozen_pixels() {
        let frame = FrozenFrame {
            width: 3,
            height: 1,
            rgba: vec![20, 40, 60, 255, 80, 100, 120, 255, 140, 160, 180, 255],
        };
        let mut pixmap = tiny_skia::Pixmap::new(3, 1).unwrap();
        let scene = Scene {
            background: Color::from_rgba_u32(0xFFFFFF40),
            border: Color::from_rgba_u32(0x00000000),
            selection: Color::from_rgba_u32(0x00000000),
            choice: Color::from_rgba_u32(0x00000000),
            border_weight: 0.0,
            display_dimensions: false,
            font: None,
            logical: Rect::new(0, 0, 3, 1),
            scale: 1.0,
            choice_boxes: &[],
            selection_rect: Some(Rect::new(1, 0, 1, 1)),
            crosshair: None,
            frozen: Some(&frame),
        };

        render(&mut pixmap.as_mut(), &scene);

        assert_ne!(&pixmap.data()[0..4], &frame.rgba[0..4]);
        assert_eq!(&pixmap.data()[4..8], &frame.rgba[4..8]);
        assert_ne!(&pixmap.data()[8..12], &frame.rgba[8..12]);
    }
}

fn blit_text(
    pixmap: &mut PixmapMut,
    text: &str,
    x0: f32,
    baseline: f32,
    px: f32,
    font: &fontdue::Font,
    color: Color,
) {
    let width = pixmap.width() as i32;
    let height = pixmap.height() as i32;
    let data = pixmap.data_mut();

    let mut pen_x = x0;
    for ch in text.chars() {
        let (metrics, coverage) = font.rasterize(ch, px);
        let gx = pen_x + metrics.xmin as f32;
        let gy = baseline - metrics.height as f32 - metrics.ymin as f32;

        for row in 0..metrics.height {
            for col in 0..metrics.width {
                let cov = coverage[row * metrics.width + col];
                if cov == 0 {
                    continue;
                }
                let px_x = (gx + col as f32) as i32;
                let px_y = (gy + row as f32) as i32;
                if px_x < 0 || px_y < 0 || px_x >= width || px_y >= height {
                    continue;
                }
                let idx = ((px_y * width + px_x) * 4) as usize;
                let alpha = (cov as u16 * color.a as u16 / 255) as u8;
                blend_over(&mut data[idx..idx + 4], color, alpha);
            }
        }

        pen_x += metrics.advance_width;
    }
}

fn blend_over(dst: &mut [u8], color: Color, alpha: u8) {
    let sa = alpha as u16;
    let inv = 255 - sa;
    let sr = color.r as u16 * sa / 255;
    let sg = color.g as u16 * sa / 255;
    let sb = color.b as u16 * sa / 255;
    dst[0] = (sr + dst[0] as u16 * inv / 255) as u8;
    dst[1] = (sg + dst[1] as u16 * inv / 255) as u8;
    dst[2] = (sb + dst[2] as u16 * inv / 255) as u8;
    dst[3] = (sa + dst[3] as u16 * inv / 255) as u8;
}