diff options
| author | srdusr <[email protected]> | 2024-06-20 16:08:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-06-20 16:08:00 +0200 |
| commit | ab0916391532031376817ed4ab6ab234428559a2 (patch) | |
| tree | 169e7799fbb6dd5d225b01ed645b1fa5cbf179de /crates/wayland/src | |
| parent | 20cdd47b39f09aa19317d9d29fef6511b98c1233 (diff) | |
| download | srdwm-ab0916391532031376817ed4ab6ab234428559a2.tar.gz srdwm-ab0916391532031376817ed4ab6ab234428559a2.zip | |
Add drop shadows and shrink the resize grab margin that was eating content clicks
Two independent daily-driving gaps closed in one pass, both from MISSING.md
and live user feedback:
Drop shadows (general.shadows, default true). Reuses the exact "bitmap
drawn outside geometry, cached like the border" technique border_strips/
render_border_top already established - decoration::shadow_bitmap rasterizes
a linear alpha falloff (Chebyshev/square-ring distance, not a true blur --
no blur primitive exists without a GPU shader, and the udev backend's
PixmanRenderer is software-only) from SHADOW_MAX_ALPHA (90/255, deliberately
subtle) at the window's own edge down to fully transparent SHADOW_SIZE (12px)
out. Cached in CompState::shadow_buffers, rebuilt at the same trigger points
as border_top_decorations (redraw_decoration_buffer), for the identical
damage-tracking reason: a fresh Id every frame means OutputDamageTracker
never finds a previous-frame match. No shadow for a maximized or fullscreen
window, matching the Hyprland/GNOME convention MISSING.md measures against.
Resize grab margin: 10px -> 6px (general.resize_margin, now configurable,
same call-site-count-preserving change as threading a new parameter through
one indirection point: ResizeEdge::hit_test's only production caller is
WindowManager::hit_test, so this didn't need touching every backend despite
hit_test being shared verbatim across X11/Wayland/Windows/macOS). Reported
live: ordinary clicks near any window edge - a link near a browser's edge,
a button near a panel's edge - regularly registered as a resize-edge grab
instead of reaching the client, not just an occasional near-miss, because
the 10px band was measured inward from the client's own content rect. 6px
stays comfortably grabbable while giving content back most of its edge.
Verified: cargo build --workspace (all 9 crates including the windows/macos
stub backends), cargo clippy --workspace (0 new warnings), cargo test across
core/wayland/config/x11 (188 tests, 0 failed). Shadow rendering verified at
the render-element level live in a nested session (correct geometry, alpha,
buffer contents) - grim/screencopy itself turned out to route through
winit.rs's separate capture_offscreen path, which only ever drew
`decorations` (titlebars), never borders or shadows, so screenshots taken
this way have never shown either; a real gap, not fixed in this pass.
Diffstat (limited to 'crates/wayland/src')
| -rw-r--r-- | crates/wayland/src/decoration.rs | 130 | ||||
| -rw-r--r-- | crates/wayland/src/state.rs | 30 | ||||
| -rw-r--r-- | crates/wayland/src/udev.rs | 20 | ||||
| -rw-r--r-- | crates/wayland/src/winit.rs | 12 |
4 files changed, 191 insertions, 1 deletions
diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs index 8e897ef..8d300aa 100644 --- a/crates/wayland/src/decoration.rs +++ b/crates/wayland/src/decoration.rs @@ -182,6 +182,92 @@ pub fn border_strips(geometry: srdwm_core::Rect, width: u32) -> [srdwm_core::Rec ] } +/// How far a window's drop shadow extends past its geometry on each side. +pub const SHADOW_SIZE: u32 = 12; + +/// 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. +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) +} + +/// 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 `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). +pub fn shadow_bitmap(win_width: u32, win_height: u32) -> Vec<u8> { + 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; + let mut buf = vec![0u8; (width * height * 4) as usize]; + for y in 0..height { + let dy = edge_distance(y, SHADOW_SIZE, win_height); + if dy > SHADOW_SIZE { + continue; + } + for x in 0..width { + let dx = edge_distance(x, SHADOW_SIZE, win_width); + let dist = dx.max(dy); + if dist == 0 || dist > SHADOW_SIZE { + continue; + } + let alpha = (SHADOW_MAX_ALPHA as u32 * (SHADOW_SIZE - dist) / SHADOW_SIZE) 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 +} + +/// 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 + } +} + /// Renders the top border strip (`border_strips`'s first rect) as a BGRA8 /// bitmap instead of a plain solid fill, with its own outer top corners cut /// the same way `render_titlebar`'s `round_corners` cuts the titlebar's -- @@ -430,6 +516,44 @@ mod tests { } #[test] + fn shadow_rect_expands_geometry_by_shadow_size_on_every_side() { + let geom = srdwm_core::Rect::new(100, 100, 200, 150); + let s = shadow_rect(geom); + assert_eq!(s, srdwm_core::Rect::new(100 - SHADOW_SIZE as i32, 100 - SHADOW_SIZE as i32, 200 + SHADOW_SIZE * 2, 150 + SHADOW_SIZE * 2)); + } + + #[test] + fn shadow_bitmap_is_the_expected_size_and_transparent_under_the_window() { + let buf = shadow_bitmap(40, 20); + let width = 40 + SHADOW_SIZE * 2; + let height = 20 + SHADOW_SIZE * 2; + assert_eq!(buf.len(), (width * height * 4) as usize); + // Dead center is inside the window's own footprint - must stay + // fully transparent, since the window's own content draws over it. + let mid = ((height / 2) * width + width / 2) * 4; + assert_eq!(buf[mid as usize + 3], 0); + } + + #[test] + fn shadow_bitmap_is_darkest_right_at_the_window_edge_and_fades_outward() { + let buf = shadow_bitmap(40, 20); + let width = (40 + SHADOW_SIZE * 2) as usize; + // Walking straight up from the window's horizontal center, from one + // pixel above its top edge (row SHADOW_SIZE - 1) out to the shadow's + // own outer edge (row 0): alpha must start near SHADOW_MAX_ALPHA and + // strictly decrease to 0. + let x = width / 2; + let mut last_alpha = 255u8; + for row in (0..SHADOW_SIZE as usize).rev() { + let i = (row * width + x) * 4; + let alpha = buf[i + 3]; + assert!(alpha <= last_alpha, "alpha rose from {last_alpha} to {alpha} moving outward at row {row}"); + last_alpha = alpha; + } + assert_eq!(last_alpha, 0, "outermost row must be fully transparent"); + } + + #[test] fn fills_background_when_no_text() { let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true); assert_eq!(buf.len(), 40 * 20 * 4); @@ -465,7 +589,11 @@ mod tests { assert!(drawn, "expected some drawn icon pixel inside the right_offset={right_offset} square"); let cx = (x0 + x1) / 2; let cy = (y0 + y1) / 2; - assert_eq!(srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0), Some(expected), "icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}"); + assert_eq!( + srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN), + Some(expected), + "icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}" + ); } } diff --git a/crates/wayland/src/state.rs b/crates/wayland/src/state.rs index 1964d99..8d04d20 100644 --- a/crates/wayland/src/state.rs +++ b/crates/wayland/src/state.rs @@ -236,6 +236,18 @@ pub(crate) struct CompState { /// `elements::border_side_render_element`'s doc comment for the damage- /// tracking reason a per-frame rebuild was wrong in the first place. pub(crate) border_top_decorations: HashMap<WindowId, MemoryRenderBuffer>, + /// A window's drop-shadow bitmap (`decoration::shadow_bitmap`), cached + /// the same way and at the same trigger points as `border_top_decorations` + /// - rebuilt only on creation or a real size change, not per frame, for + /// the identical damage-tracking reason (a fresh `Id` every frame means + /// `OutputDamageTracker` never finds a previous-frame match, so the + /// shadow - like the border strips before this caching existed - would + /// mark itself fully damaged forever, keeping the output page-flipping + /// on an otherwise fully static screen). `None` for a maximized or + /// fullscreen window, or with `general.shadows` off - see the render + /// call site for why those don't get a shadow at all rather than a + /// zero-alpha one. + pub(crate) shadow_buffers: HashMap<WindowId, MemoryRenderBuffer>, /// Persistent solid-colour buffers backing a window's other three /// border strips (bottom, left, right - `decoration::border_strips`' /// order past index 0), reused by position every frame rather than @@ -610,6 +622,23 @@ impl CompState { } else { self.border_top_decorations.remove(&id); } + // No shadow for a maximized/fullscreen window: it already reaches + // (or, for fullscreen, exceeds) the monitor's own edge, so there is + // nowhere for `SHADOW_SIZE` pixels of shadow to actually fall, and + // a shadow drawn there would either be clipped to nothing useful or + // - for a maximized window short of the true monitor edge - read + // as a shadow the window doesn't visually need. Matches the + // Hyprland/GNOME convention `MISSING.md` measures this compositor + // against. + let shadows_enabled = self.wm.borrow().shadows_enabled; + if shadows_enabled && !w.maximized && !w.fullscreen { + let data = decoration::shadow_bitmap(w.geometry.width, w.geometry.height); + let rect = decoration::shadow_rect(w.geometry); + let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (rect.width as i32, rect.height as i32), 1, Transform::Normal, None); + self.shadow_buffers.insert(id, buffer); + } else { + self.shadow_buffers.remove(&id); + } } pub(crate) fn remove_window(&mut self, surface: &WlSurface) { @@ -619,6 +648,7 @@ impl CompState { } self.decorations.remove(&id); self.border_top_decorations.remove(&id); + self.shadow_buffers.remove(&id); self.border_side_buffers.remove(&id); self.last_synced_size.remove(&id); // A window closing (crash, kill, or its own menu's "Close" action diff --git a/crates/wayland/src/udev.rs b/crates/wayland/src/udev.rs index 96d57d5..7e7a69f 100644 --- a/crates/wayland/src/udev.rs +++ b/crates/wayland/src/udev.rs @@ -312,6 +312,25 @@ impl CompState { // windows) has to agree with what `sync_geometry` mapped // the content to, or they drift apart again. let geom = self.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry); + // Drawn first among this window's own decoration, and + // positioned from the same animated `geom` as everything + // else here - not `w.geometry` - for the identical + // reason: a shadow that stayed at the pre-tween rect + // while the window slid past it would look exactly as + // detached as the border did before that fix. Not + // fragment-clipped against `occluders` like the titlebar/ + // border below: at `SHADOW_MAX_ALPHA`'s low opacity, a + // shadow bleeding slightly onto a window stacked in front + // of this one reads as a soft edge, not the hard-line + // bleed-through that made the titlebar/border need it. + if let Some(shadow) = self.shadow_buffers.get(&id) { + let rect = decoration::shadow_rect(geom); + let pos = ((rect.x - origin.x) as f64, (rect.y - origin.y) as f64); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, shadow, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("udev: failed to import shadow buffer: {e}"), + } + } if let Some(deco) = self.decorations.get(&id) { // Fragment-clipped, same as the three solid border // strips below - an *all-or-nothing* version of @@ -996,6 +1015,7 @@ impl UdevPlatform { dead_layer_surfaces: HashSet::new(), decorations: HashMap::new(), border_top_decorations: HashMap::new(), + shadow_buffers: HashMap::new(), border_side_buffers: HashMap::new(), last_synced_size: HashMap::new(), pending: pending.clone(), diff --git a/crates/wayland/src/winit.rs b/crates/wayland/src/winit.rs index 733f4b0..11c2d1e 100644 --- a/crates/wayland/src/winit.rs +++ b/crates/wayland/src/winit.rs @@ -228,6 +228,7 @@ impl WaylandPlatform { dead_layer_surfaces: HashSet::new(), decorations: HashMap::new(), border_top_decorations: HashMap::new(), + shadow_buffers: HashMap::new(), border_side_buffers: HashMap::new(), last_synced_size: HashMap::new(), pending: pending.clone(), @@ -397,6 +398,17 @@ impl WaylandPlatform { // (reported live as the border "not flush" with the window // during an animated maximize/fullscreen/open-slide transition). let geom = self.state.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry); + // Same reasoning as udev.rs's matching push: positioned from + // `geom`, not `w.geometry`, and not fragment-clipped against + // `occluders` - see that comment. + if let Some(shadow) = self.state.shadow_buffers.get(&id) { + let rect = decoration::shadow_rect(geom); + let pos = (rect.x as f64, rect.y as f64); + match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, shadow, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("failed to import shadow buffer for window {id}: {e}"), + } + } if let Some(deco) = self.state.decorations.get(&id) { // Fragment-clipped, same as udev.rs's matching titlebar // push - see that comment for why all-or-nothing (skip |