From ac8d4e1b72f7c0f64f1bea1b83f0ee0bc9878f09 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Tue, 29 Sep 2026 23:26:49 +0300 Subject: [PATCH] feat: selection geometry and overlay compositing --- src/main.rs | 2 + src/render.rs | 99 ++++++++++++++++++++++ src/selection.rs | 212 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 src/render.rs create mode 100644 src/selection.rs diff --git a/src/main.rs b/src/main.rs index e44d1bc..2b9ebf5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,8 @@ mod error; mod frame; mod lock; mod log; +mod render; +mod selection; use clap::Parser; diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..111bb50 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,99 @@ +use crate::frame::PixelRect; + +/// Overlay brightness outside the selection (60%, i.e. darkened by 40%). +pub const DIM: u8 = 153; + +/// B, G, R, X pixels with every byte scaled by about `brightness / 255`. +/// +/// This runs on every output before the overlay can appear, so it is one +/// branch-free pass the compiler can vectorise. Scaling X too is harmless: +/// the overlay buffer is XRGB8888, which ignores it. +pub fn dim(pixels: &[u8], brightness: u8) -> Vec { + let factor = brightness as u16 + 1; + pixels + .iter() + .map(|&b| ((b as u16 * factor) >> 8) as u8) + .collect() +} + +/// Draws one overlay frame into `dst`: `dark` everywhere, `bright` inside +/// `sel`, and a 1 px white outline on the edge of `sel`. +pub fn compose(dst: &mut [u8], dark: &[u8], bright: &[u8], width: u32, sel: Option) { + dst.copy_from_slice(dark); + let Some(r) = sel else { return }; + let stride = width as usize * 4; + let (x0, x1) = (r.x as usize * 4, (r.x + r.width) as usize * 4); + + for y in r.y..r.y + r.height { + let row = y as usize * stride; + dst[row + x0..row + x1].copy_from_slice(&bright[row + x0..row + x1]); + if y == r.y || y == r.y + r.height - 1 { + dst[row + x0..row + x1].fill(255); + } else { + dst[row + x0..row + x0 + 4].fill(255); + dst[row + x1 - 4..row + x1].fill(255); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dims_every_byte() { + assert_eq!(dim(&[200, 100, 50, 0], 255), vec![200, 100, 50, 0]); + assert_eq!(dim(&[200, 100, 50, 255], DIM), vec![120, 60, 30, 153]); + assert_eq!(dim(&[255, 0, 1, 2], 0), vec![0, 0, 0, 0]); + } + + fn px(buf: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { + let i = ((y * width + x) * 4) as usize; + buf[i..i + 4].try_into().unwrap() + } + + #[test] + fn compose_outline_and_inside() { + let (w, h) = (6, 6); + let dark = vec![1u8; (w * h * 4) as usize]; + let bright = vec![2u8; (w * h * 4) as usize]; + let mut dst = vec![0u8; dark.len()]; + let sel = PixelRect { + x: 1, + y: 1, + width: 4, + height: 4, + }; + + compose(&mut dst, &dark, &bright, w, Some(sel)); + + assert_eq!(px(&dst, w, 0, 0), [1; 4], "outside"); + assert_eq!(px(&dst, w, 1, 1), [255; 4], "corner"); + assert_eq!(px(&dst, w, 4, 2), [255; 4], "right edge"); + assert_eq!(px(&dst, w, 2, 4), [255; 4], "bottom edge"); + assert_eq!(px(&dst, w, 2, 2), [2; 4], "inside"); + assert_eq!(px(&dst, w, 5, 5), [1; 4], "outside"); + } + + #[test] + fn compose_without_selection_is_all_dark() { + let dark = vec![1u8; 16]; + let mut dst = vec![0u8; 16]; + compose(&mut dst, &dark, &[2u8; 16], 2, None); + assert_eq!(dst, dark); + } + + #[test] + fn compose_one_pixel_selection() { + let dark = vec![1u8; 16]; + let mut dst = vec![0u8; 16]; + let sel = PixelRect { + x: 1, + y: 1, + width: 1, + height: 1, + }; + compose(&mut dst, &dark, &[2u8; 16], 2, Some(sel)); + assert_eq!(px(&dst, 2, 1, 1), [255; 4]); + } +} diff --git a/src/selection.rs b/src/selection.rs new file mode 100644 index 0000000..ca329f9 --- /dev/null +++ b/src/selection.rs @@ -0,0 +1,212 @@ +use crate::frame::{OutputGeom, PixelRect}; + +/// Selections smaller than this (in physical pixels) count as a click. +pub const MIN_PIXELS: u32 = 2; + +/// A point in global logical coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub enum Selection { + #[default] + Idle, + Dragging { + start: Point, + end: Point, + }, +} + +impl Selection { + pub fn press(&mut self, p: Point) { + *self = Selection::Dragging { start: p, end: p }; + } + + pub fn motion(&mut self, p: Point) { + if let Selection::Dragging { end, .. } = self { + *end = p; + } + } + + /// Ends the drag and returns its start and end. Back to `Idle` either way. + pub fn release(&mut self, p: Point) -> Option<(Point, Point)> { + match std::mem::take(self) { + Selection::Dragging { start, .. } => Some((start, p)), + Selection::Idle => None, + } + } +} + +/// The part of the drag from `a` to `b` that falls on `out`, in the physical +/// pixels of a `frame_w` x `frame_h` frame. `None` if nothing is left. +/// +/// Edges round outwards so the rectangle covers every pixel the user touched. +pub fn clip(a: Point, b: Point, out: &OutputGeom, frame_w: u32, frame_h: u32) -> Option { + let sx = frame_w as f64 / out.width as f64; + let sy = frame_h as f64 / out.height as f64; + let to_px = |v: f64, origin: i32, s: f64, max: u32, round: fn(f64) -> f64| { + round((v - origin as f64) * s).clamp(0.0, max as f64) as u32 + }; + let x0 = to_px(a.x.min(b.x), out.x, sx, frame_w, f64::floor); + let x1 = to_px(a.x.max(b.x), out.x, sx, frame_w, f64::ceil); + let y0 = to_px(a.y.min(b.y), out.y, sy, frame_h, f64::floor); + let y1 = to_px(a.y.max(b.y), out.y, sy, frame_h, f64::ceil); + (x1 > x0 && y1 > y0).then(|| PixelRect { + x: x0, + y: y0, + width: x1 - x0, + height: y1 - y0, + }) +} + +/// Like [`clip`], but treats anything under [`MIN_PIXELS`] as a click. +pub fn resolve( + a: Point, + b: Point, + out: &OutputGeom, + frame_w: u32, + frame_h: u32, +) -> Option { + clip(a, b, out, frame_w, frame_h).filter(|r| r.width >= MIN_PIXELS && r.height >= MIN_PIXELS) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn out(name: &str, x: i32, y: i32, width: i32, height: i32) -> OutputGeom { + OutputGeom { + name: name.into(), + x, + y, + width, + height, + } + } + + fn p(x: f64, y: f64) -> Point { + Point { x, y } + } + + fn rect(x: u32, y: u32, width: u32, height: u32) -> PixelRect { + PixelRect { + x, + y, + width, + height, + } + } + + #[test] + fn scale_one() { + let o = out("A", 0, 0, 1920, 1080); + assert_eq!( + clip(p(10.0, 20.0), p(110.0, 70.0), &o, 1920, 1080), + Some(rect(10, 20, 100, 50)) + ); + } + + #[test] + fn drag_direction_does_not_matter() { + let o = out("A", 0, 0, 1920, 1080); + assert_eq!( + clip(p(110.0, 70.0), p(10.0, 20.0), &o, 1920, 1080), + clip(p(10.0, 20.0), p(110.0, 70.0), &o, 1920, 1080) + ); + } + + #[test] + fn fractional_scale_rounds_outwards() { + // 1920x1080 at 1.25 is 1536x864 logical. + let o = out("A", 0, 0, 1536, 864); + // 10.5 * 1.25 = 13.125 -> 13; 20.3 * 1.25 = 25.375 -> 26. + assert_eq!( + clip(p(10.5, 0.0), p(20.3, 8.0), &o, 1920, 1080), + Some(rect(13, 0, 13, 10)) + ); + } + + #[test] + fn scale_one_and_a_half() { + let o = out("A", 0, 0, 1280, 720); + assert_eq!( + clip(p(100.0, 100.0), p(200.0, 150.0), &o, 1920, 1080), + Some(rect(150, 150, 150, 75)) + ); + } + + #[test] + fn output_offsets_including_negative() { + let left = out("L", -1366, 0, 1366, 768); + let right = out("R", 0, 0, 1920, 1080); + assert_eq!( + clip(p(-1356.0, 10.0), p(-1256.0, 60.0), &left, 1366, 768), + Some(rect(10, 10, 100, 50)) + ); + assert_eq!( + clip( + p(1930.0, 10.0), + p(2030.0, 60.0), + &out("R2", 1920, 0, 800, 600), + 800, + 600 + ), + Some(rect(10, 10, 100, 50)) + ); + assert_eq!( + clip(p(-10.0, 10.0), p(10.0, 20.0), &right, 1920, 1080), + Some(rect(0, 10, 10, 10)) + ); + } + + #[test] + fn drag_across_outputs_is_clipped_to_the_output() { + let left = out("L", 0, 0, 1366, 768); + // From (1300, 100) on the left output to (1500, 900) on the right one. + assert_eq!( + clip(p(1300.0, 100.0), p(1500.0, 900.0), &left, 1366, 768), + Some(rect(1300, 100, 66, 668)) + ); + } + + #[test] + fn nothing_left_on_other_output() { + let o = out("A", 0, 0, 100, 100); + assert_eq!(clip(p(200.0, 200.0), p(300.0, 300.0), &o, 100, 100), None); + } + + #[test] + fn click_is_not_a_selection() { + let o = out("A", 0, 0, 1920, 1080); + assert_eq!(resolve(p(10.0, 10.0), p(10.0, 10.0), &o, 1920, 1080), None); + assert_eq!(resolve(p(10.0, 10.0), p(11.0, 30.0), &o, 1920, 1080), None); + assert_eq!( + resolve(p(10.0, 10.0), p(12.0, 12.0), &o, 1920, 1080), + Some(rect(10, 10, 2, 2)) + ); + } + + #[test] + fn state_machine() { + let mut s = Selection::default(); + assert_eq!(s.release(p(1.0, 1.0)), None); + + s.press(p(1.0, 2.0)); + s.motion(p(5.0, 6.0)); + assert_eq!( + s, + Selection::Dragging { + start: p(1.0, 2.0), + end: p(5.0, 6.0) + } + ); + assert_eq!(s.release(p(7.0, 8.0)), Some((p(1.0, 2.0), p(7.0, 8.0)))); + assert_eq!(s, Selection::Idle); + + s.motion(p(9.0, 9.0)); + assert_eq!(s, Selection::Idle); + } +}