//! A window's drop shadow, rasterized as its own BGRA8 bitmap - extent, //! sizing, and the corner-aware falloff distance function it needs to read //! as rounded next to a window with a real rounded corner, rather than a //! plain square-cornered glow sitting incongruously beside one. /// How far a window's drop shadow extends past its geometry on each side. /// /// `24`, not the original `12`: reported live as srdwm's own shadow /// reading as a thin, tight dark line rather than the soft, generously- /// sized glow real macOS windows have - doubled, and paired with /// `shadow_bitmap`'s own falloff moving from a plain linear ramp to an /// eased (`smoothstep`) one, which reads as noticeably softer at the same /// pixel budget even without a real blur primitive to work with. pub const SHADOW_SIZE: u32 = 24; /// The shadow's darkest alpha, right at the window's own edge - out of /// 255. Deliberately subtle (Nord/GNOME-default territory, not a heavy /// drop shadow): this compositor has no blur primitive to soften it with /// (see `shadow_bitmap`'s own doc comment), so a strong value would read as /// a hard dark ring rather than a shadow. This is the *focused*-window /// value - see `shadow_bitmap`'s own `max_alpha` parameter for why an /// unfocused window doesn't just reuse it unconditionally. pub(crate) const SHADOW_MAX_ALPHA: u8 = 90; /// `geometry` expanded by [`SHADOW_SIZE`] on every side - the full bounding /// box [`shadow_bitmap`] rasterises into, and where the caller positions it /// (top-left corner at `(geometry.x - SHADOW_SIZE, geometry.y - SHADOW_SIZE)`). pub fn shadow_rect(geometry: srdwm_core::Rect) -> srdwm_core::Rect { let s = SHADOW_SIZE as i32; srdwm_core::Rect::new(geometry.x - s, geometry.y - s, geometry.width + SHADOW_SIZE * 2, geometry.height + SHADOW_SIZE * 2) } /// [`shadow_rect`], clipped so a shadow can never land on a monitor the /// window itself does not occupy. /// /// Reported live as "windows show a bit in the other monitor" with a real /// second monitor connected: `srd clients` showed several windows sitting /// at exactly `x: 1920`, the seam between two 1920-wide outputs, and a /// window flush against that seam has nowhere to put its 24px shadow strip /// except the neighbouring screen. The earlier shadow work only ever /// considered a window's neighbouring *tile*; [`shadow_rect`] expands by /// [`SHADOW_SIZE`] on every side with no monitor-boundary awareness at /// all, so this survived it. /// /// `bounds` is every monitor's `full_geometry`. The clip box is the /// bounding box of the monitors the window's own geometry actually /// touches, not just of the one it is assigned to: a window straddling a /// seam genuinely occupies both screens, and clipping such a window's /// shadow at the seam would cut it off in the middle of its own visible /// body. A window touching no monitor at all (off-screen, or no monitors /// yet) is returned unclipped - there is nothing to clip against, and /// silently collapsing it to an empty rect would drop the shadow instead. pub fn shadow_rect_clipped(geometry: srdwm_core::Rect, bounds: &[srdwm_core::Rect]) -> srdwm_core::Rect { let rect = shadow_rect(geometry); let mut clip: Option = None; for m in bounds.iter().filter(|m| overlaps(**m, geometry)) { clip = Some(match clip { None => *m, Some(c) => union(c, *m), }); } match clip { Some(c) => intersect(rect, c), None => rect, } } fn overlaps(a: srdwm_core::Rect, b: srdwm_core::Rect) -> bool { a.x < b.x + b.width as i32 && b.x < a.x + a.width as i32 && a.y < b.y + b.height as i32 && b.y < a.y + a.height as i32 } fn union(a: srdwm_core::Rect, b: srdwm_core::Rect) -> srdwm_core::Rect { let x = a.x.min(b.x); let y = a.y.min(b.y); let right = (a.x + a.width as i32).max(b.x + b.width as i32); let bottom = (a.y + a.height as i32).max(b.y + b.height as i32); srdwm_core::Rect::new(x, y, (right - x).max(0) as u32, (bottom - y).max(0) as u32) } fn intersect(a: srdwm_core::Rect, b: srdwm_core::Rect) -> srdwm_core::Rect { let x = a.x.max(b.x); let y = a.y.max(b.y); let right = (a.x + a.width as i32).min(b.x + b.width as i32); let bottom = (a.y + a.height as i32).min(b.y + b.height as i32); srdwm_core::Rect::new(x, y, (right - x).max(0) as u32, (bottom - y).max(0) as u32) } /// Renders a window's drop shadow as a BGRA8 bitmap: black at an alpha that /// falls off linearly from [`SHADOW_MAX_ALPHA`] right at the window's own /// edge to fully transparent [`SHADOW_SIZE`] pixels out. `win_width`/ /// `win_height` are the window's own footprint (`geometry`, border strips /// included if any - whatever the caller already draws as opaque); the /// returned bitmap is `shadow_rect`'s size, `SHADOW_SIZE` larger on every /// side. /// /// Not a true Gaussian blur - no blur primitive is available without a GPU /// shader (the udev backend's `PixmanRenderer` is software-only) or a new /// image-processing dependency - so this is a stepless *linear* falloff /// using Chebyshev (square-ring) distance from the window's edge rather /// than a rounded/radial one, cheap enough to rebuild on every resize (see /// the caller for when that is) without a per-pixel sqrt. Reads as "soft /// enough" at the sizes a titlebar-height window actually uses, the same /// "approximate cutoff over true anti-aliasing" trade-off `corners::round_ /// top_corners` already makes for corners. /// /// The region directly under the window itself (`dist == 0` below) is left /// fully transparent rather than filled - harmless either way since the /// window's own border/titlebar/content always draws over it, but skipping /// it is one less branch of work for the common case (a window with no /// occluders in front of it, so most of the bitmap's interior never /// contributes a visible pixel). /// /// `max_alpha` - the shadow's own darkest value, right at the window's /// edge - is a parameter rather than always `SHADOW_MAX_ALPHA`, so a /// caller can dim an *unfocused* window's shadow the same way `theme. /// border.inactive_dim` already dims an unfocused window's border colour /// (see `effective_border_color`). Real desktop convention, not invented /// here: Hyprland's own `decoration:shadow` config exposes `color` and /// `color_inactive` as two separate values specifically for this, common /// user configs going as far as a fully transparent `color_inactive` (no /// shadow at all once a window loses focus) - confirmed via Hyprland's /// own wiki, not assumed. `redraw_decoration_buffer` reuses `theme. /// border_inactive_dim` for this rather than adding a second, separately /// configurable factor: both are "how much does losing focus fade this /// window's own chrome", and this codebase already has a user-tunable /// answer to that question. pub fn shadow_bitmap(win_width: u32, win_height: u32, radius: u32, max_alpha: u8) -> Vec { let (win_width, win_height) = (win_width.max(1), win_height.max(1)); let width = win_width + SHADOW_SIZE * 2; let height = win_height + SHADOW_SIZE * 2; // Clamped the same way `corners::round_top_corners`/`round_bottom_ // corners` clamp their own radius against the buffer they're cutting -- // a radius that would eat more than half of either the window's own // width or height isn't geometrically meaningful. let radius = radius.min(win_width / 2).min(win_height / 2); let mut buf = vec![0u8; (width * height * 4) as usize]; for y in 0..height { let dy = edge_distance(y, SHADOW_SIZE, win_height); // The widest a row can still possibly contribute a visible pixel: // a corner-quadrant pixel's distance is `sqrt(qx^2 + qy^2) - // radius` where `qy = dy - radius`, and that can still be `<= // SHADOW_SIZE` (this function's own cutoff below) even with // `qx == 0`, i.e. up to `dy == SHADOW_SIZE + 2 * radius` - not // just `SHADOW_SIZE + radius`, which would cut off real corner // pixels a few rows early. if dy > SHADOW_SIZE + 2 * radius { continue; } for x in 0..width { let dx = edge_distance(x, SHADOW_SIZE, win_width); let dist = rounded_edge_distance(dx, dy, radius); if dist == 0 || dist > SHADOW_SIZE { continue; } // Eased (`smoothstep`), not a plain linear ramp - the same // curve `apply_corner_mask`/`fill_button_dot` already use for // their own anti-aliased edges, applied here across the whole // shadow's width instead of a 2px antialiasing band. A linear // falloff reads as a visible ring with a hard-ish inner edge // even when fully transparent at both ends; easing both ends // of the same 0..=1 range softens the transition into and out // of the shadow without needing a real blur primitive. let t = dist as f32 / SHADOW_SIZE as f32; let eased = 1.0 - (t * t * (3.0 - 2.0 * t)); let alpha = (max_alpha as f32 * eased).round() as u8; if alpha == 0 { continue; } let i = ((y * width + x) * 4) as usize; // Premultiplied BGRA, but the colour is black (0, 0, 0) - a // premultiplied black pixel is just (0, 0, 0, alpha) at any // alpha, so there's no separate multiply step needed here. buf[i + 3] = alpha; } } buf } /// `edge_distance`'s corner-aware version: `dx`/`dy` are already `edge_ /// distance`'s own plain per-axis distances past the window's true edge /// (`0` on either axis means "not in a corner quadrant at all" - directly /// above/below/left/right of the window, or inside it) - this only /// changes what happens where *both* are positive, i.e. genuinely outside /// the window on both axes at once. Along a flat edge, a rounded rect's /// boundary is identical to a square one's (rounding only touches the /// corners), so the plain `max(dx, dy)` this replaces was already correct /// there and stays correct here too. /// /// Without this, the shadow was a plain square-cornered falloff (`shadow_ /// bitmap`'s own historical doc comment called this out as a deliberate /// Chebyshev-not-radial simplification - reasonable for a soft blur where /// nothing else in the frame gives the eye a hard edge to compare against, /// but wrong once the window it belongs to has a *visibly* rounded corner /// right next to it) - confirmed live via a real screenshot at actual /// render resolution: the shadow's own corner cut a hard diagonal well /// outside the window's own curve, plainly a different, unrelated shape /// sitting right beside it. /// /// The corner-quadrant formula: place the window's true (sharp) corner at /// the origin, with the window occupying the quadrant behind it - `dx`/ /// `dy` are how far past that origin the shadow pixel sits on each axis. A /// *rounded* corner's circle sits centred `radius` pixels in from that /// origin on both axes, i.e. at `(-radius, -radius)`. Straight-line /// distance from the pixel to that centre is `sqrt((dx + radius)^2 + (dy + /// radius)^2)`; subtracting `radius` converts "distance to the circle's /// centre" into "distance to the circle's own boundary", which is what a /// real rounded corner's curve actually traces. (At `dx = dy = 0` - the /// old sharp corner's own tip - this correctly comes out positive, not /// `0`: the rounded window's boundary has curved away from that point /// entirely, so it's already outside the window, not sitting right on its /// edge the way a real square corner's tip would be.) pub(super) fn rounded_edge_distance(dx: u32, dy: u32, radius: u32) -> u32 { // `radius == 0` (nothing to round - kept byte-identical to the // pre-existing Chebyshev-everywhere behaviour, not just "close // enough": true Euclidean distance to a sharp corner *point* differs // from Chebyshev distance to it even without any rounding, and this // function must not change a square window's own shadow shape) or a // genuine flat-edge point (`dx == 0` xor `dy == 0` - rounding never // touches these, only the four corners) both use plain Chebyshev // distance, unchanged from before this function existed. if radius == 0 || (dx == 0) != (dy == 0) { return dx.max(dy); } let (ex, ey) = (dx as f32 + radius as f32, dy as f32 + radius as f32); let corner_dist = (ex * ex + ey * ey).sqrt() - radius as f32; corner_dist.max(0.0).round() as u32 } /// How far outside `[margin, margin + extent)` - the window's own span /// along one axis, inside the shadow's `margin`-pixel border on each side /// - position `pos` sits, in pixels. `0` anywhere inside that span /// (including exactly on its edge). fn edge_distance(pos: u32, margin: u32, extent: u32) -> u32 { if pos < margin { margin - pos } else if pos >= margin + extent { pos - (margin + extent) + 1 } else { 0 } } #[cfg(test)] mod clip_tests { use super::shadow_rect_clipped; use srdwm_core::Rect; /// Two 1920x1080 outputs side by side, the exact arrangement the /// "windows show a bit in the other monitor" report was taken on. fn two_monitors() -> Vec { vec![Rect::new(0, 0, 1920, 1080), Rect::new(1920, 0, 1920, 1080)] } #[test] fn a_window_flush_against_the_seam_does_not_shadow_the_next_monitor() { // Right edge exactly on the seam at x=1920. let w = Rect::new(1120, 100, 800, 600); let r = shadow_rect_clipped(w, &two_monitors()); assert_eq!(r.x + r.width as i32, 1920, "shadow crossed the seam"); assert_eq!(r.x, 1120 - 24, "the left side should still get its full shadow"); } #[test] fn a_window_at_the_left_edge_of_the_second_monitor_does_not_shadow_the_first() { let w = Rect::new(1920, 100, 800, 600); let r = shadow_rect_clipped(w, &two_monitors()); assert_eq!(r.x, 1920, "shadow crossed the seam"); } #[test] fn a_window_in_the_middle_of_a_monitor_is_unclipped() { let w = Rect::new(500, 300, 400, 300); let r = shadow_rect_clipped(w, &two_monitors()); assert_eq!((r.x, r.y, r.width, r.height), (500 - 24, 300 - 24, 400 + 48, 300 + 48)); } #[test] fn a_window_straddling_the_seam_keeps_its_shadow_on_both_monitors() { let w = Rect::new(1720, 100, 400, 600); let r = shadow_rect_clipped(w, &two_monitors()); assert_eq!(r.x, 1720 - 24); assert_eq!(r.x + r.width as i32, 2120 + 24); } #[test] fn the_outer_edges_of_the_whole_desktop_still_clip() { // Nothing to bleed onto past x=0, but the clip must not invent // space that no monitor covers either. let w = Rect::new(0, 0, 400, 300); let r = shadow_rect_clipped(w, &two_monitors()); assert_eq!((r.x, r.y), (0, 0)); } #[test] fn no_monitors_leaves_the_rect_unclipped() { let w = Rect::new(10, 10, 100, 100); let r = shadow_rect_clipped(w, &[]); assert_eq!((r.x, r.y, r.width, r.height), (10 - 24, 10 - 24, 100 + 48, 100 + 48)); } }