From f3f7082016859df4799b99ced6e8cdd6805053de Mon Sep 17 00:00:00 2001 From: elwrcl Date: Sat, 3 Oct 2026 02:32:31 +0300 Subject: [PATCH] feat: optional shadow around window shots --- src/config.rs | 3 ++ src/main.rs | 8 +-- src/shadow.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 src/shadow.rs diff --git a/src/config.rs b/src/config.rs index fde811d..112a547 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,8 @@ pub struct Save { #[serde(default)] pub struct Capture { pub show_cursor: bool, + /// A soft shadow around window shots, like macOS. + pub window_shadow: bool, } #[derive(Debug, Clone, PartialEq, Deserialize)] @@ -169,6 +171,7 @@ mod tests { assert!(!config.save.copy_to_clipboard); assert_eq!(config.save.directory, "~/Pictures/Screenshots"); assert!(!config.capture.show_cursor); + assert!(!config.capture.window_shadow); } #[test] diff --git a/src/main.rs b/src/main.rs index c1947f8..42db7f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod output; mod region; mod render; mod selection; +mod shadow; mod stack; mod thumbnail; mod toolbar; @@ -232,11 +233,11 @@ fn capture(mode: Mode, common: Common) -> Result<()> { } region::Choice::Window => { drop(frames); - window_shot(&outputs, cursor)? + window_shot(&outputs, cursor, config.capture.window_shadow)? } } } - Mode::Window => window_shot(&outputs, cursor)?, + Mode::Window => window_shot(&outputs, cursor, config.capture.window_shadow)?, Mode::Zoom => { let focused = focused_output(&outputs); let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0); @@ -278,8 +279,9 @@ fn capture(mode: Mode, common: Common) -> Result<()> { /// A window shot, shaped like the other modes' results. The preview goes to /// the window's output, or niri's focused one if that is unknown. -fn window_shot(outputs: &[wayland::Output], cursor: bool) -> Result { +fn window_shot(outputs: &[wayland::Output], cursor: bool, shadow: bool) -> Result { let (image, output) = window::capture(cursor)?; + let image = if shadow { shadow::add(&image) } else { image }; let source = output.unwrap_or_else(|| outputs[focused_output(outputs)].geom.name.clone()); Ok((vec![(image, None)], 0, source)) } diff --git a/src/shadow.rs b/src/shadow.rs new file mode 100644 index 0000000..1bfea3b --- /dev/null +++ b/src/shadow.rs @@ -0,0 +1,133 @@ +//! A macOS-style soft shadow around window shots. + +use image::{Rgba, RgbaImage}; + +pub const PAD: u32 = 40; +pub const OFFSET_Y: u32 = 12; +pub const BLUR: u32 = 20; +pub const OPACITY: f32 = 0.5; + +/// `image` on a transparent canvas `PAD` px larger on every side, over a +/// blurred, offset copy of its own alpha in black. +pub fn add(image: &RgbaImage) -> RgbaImage { + let (w, h) = image.dimensions(); + let (cw, ch) = (w + 2 * PAD, h + 2 * PAD); + let mut alpha = vec![0f32; (cw * ch) as usize]; + for (x, y, p) in image.enumerate_pixels() { + let (sx, sy) = (x + PAD, y + PAD + OFFSET_Y); + if sy < ch { + alpha[(sy * cw + sx) as usize] = p.0[3] as f32 / 255.0; + } + } + // Three box blurs approximate a Gaussian of about BLUR / 2 sigma. + let r = (BLUR / 3).max(1) as usize; + for _ in 0..3 { + alpha = box_blur(&alpha, cw as usize, ch as usize, r); + } + let mut out = RgbaImage::from_fn(cw, ch, |x, y| { + let a = (alpha[(y * cw + x) as usize] * OPACITY * 255.0).round() as u8; + Rgba([0, 0, 0, a]) + }); + for (x, y, p) in image.enumerate_pixels() { + let dst = out.get_pixel_mut(x + PAD, y + PAD); + *dst = over(*p, *dst); + } + out +} + +/// Straight-alpha `top` over `bottom`. +fn over(top: Rgba, bottom: Rgba) -> Rgba { + let ta = top.0[3] as f32 / 255.0; + let ba = bottom.0[3] as f32 / 255.0; + let a = ta + ba * (1.0 - ta); + if a <= 0.0 { + return Rgba([0, 0, 0, 0]); + } + let mix = |t: u8, b: u8| ((t as f32 * ta + b as f32 * ba * (1.0 - ta)) / a).round() as u8; + Rgba([ + mix(top.0[0], bottom.0[0]), + mix(top.0[1], bottom.0[1]), + mix(top.0[2], bottom.0[2]), + (a * 255.0).round() as u8, + ]) +} + +/// A separable box blur of radius `r` (horizontal then vertical). +fn box_blur(src: &[f32], w: usize, h: usize, r: usize) -> Vec { + let n = (2 * r + 1) as f32; + let mut tmp = vec![0f32; src.len()]; + for y in 0..h { + let row = &src[y * w..(y + 1) * w]; + let mut sum: f32 = (0..=r).map(|i| row.get(i).copied().unwrap_or(0.0)).sum(); + for x in 0..w { + tmp[y * w + x] = sum / n; + if let Some(v) = row.get(x + r + 1) { + sum += v; + } + if x >= r { + sum -= row[x - r]; + } + } + } + let mut out = vec![0f32; src.len()]; + for x in 0..w { + let at = |y: usize| tmp[y * w + x]; + let mut sum: f32 = (0..=r.min(h - 1)).map(at).sum(); + for y in 0..h { + out[y * w + x] = sum / n; + if y + r + 1 < h { + sum += at(y + r + 1); + } + if y >= r { + sum -= at(y - r); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + + fn window() -> RgbaImage { + RgbaImage::from_pixel(100, 60, Rgba([200, 100, 50, 255])) + } + + #[test] + fn the_image_grows_by_the_padding() { + let out = add(&window()); + assert_eq!(out.dimensions(), (100 + 2 * PAD, 60 + 2 * PAD)); + } + + #[test] + fn the_window_is_unchanged_in_the_middle() { + let out = add(&window()); + assert_eq!(out.get_pixel(PAD + 50, PAD + 30).0, [200, 100, 50, 255]); + } + + #[test] + fn the_shadow_is_below_and_fades_out() { + let out = add(&window()); + let below = out.get_pixel(PAD + 50, PAD + 60 + 8).0; + let above = out.get_pixel(PAD + 50, PAD - 8).0; + assert_eq!(&below[..3], &[0, 0, 0]); + assert!(below[3] > 40, "{below:?}"); + assert!(below[3] > above[3], "offset down: {below:?} vs {above:?}"); + assert_eq!(out.get_pixel(0, 0).0[3], 0, "far corner clear"); + } + + #[test] + fn rounded_corners_are_followed() { + let mut w = window(); + for (x, y) in [(0, 0), (1, 0), (0, 1)] { + w.put_pixel(x, y, Rgba([0, 0, 0, 0])); + } + let out = add(&w); + assert!( + out.get_pixel(PAD, PAD).0[3] < 255, + "the transparent corner stays see-through" + ); + } +}