srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-06-20 16:08:00 +0200
committersrdusr <[email protected]>2024-06-20 16:08:00 +0200
commitab0916391532031376817ed4ab6ab234428559a2 (patch)
tree169e7799fbb6dd5d225b01ed645b1fa5cbf179de
parent20cdd47b39f09aa19317d9d29fef6511b98c1233 (diff)
downloadsrdwm-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.
-rw-r--r--crates/config/src/lib.rs3
-rw-r--r--crates/core/src/manager.rs15
-rw-r--r--crates/core/src/window.rs58
-rw-r--r--crates/srdwm/src/main.rs4
-rw-r--r--crates/wayland/src/decoration.rs130
-rw-r--r--crates/wayland/src/state.rs30
-rw-r--r--crates/wayland/src/udev.rs20
-rw-r--r--crates/wayland/src/winit.rs12
-rw-r--r--docs/DEFAULTS.md2
9 files changed, 247 insertions, 27 deletions
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs
index 57fe582..12ba397 100644
--- a/crates/config/src/lib.rs
+++ b/crates/config/src/lib.rs
@@ -933,6 +933,7 @@ fn validate(s: &SharedState) -> Vec<String> {
check_range("general.border_width", 0.0, 20.0);
check_range("theme.decorations.border.width", 0.0, 20.0);
check_range("general.animation_duration", 0.0, 1000.0);
+ check_range("general.resize_margin", 1.0, 50.0);
check_range("performance.max_fps", 30.0, 240.0);
check_range("performance.window_cache_size", 10.0, 10000.0);
@@ -989,6 +990,8 @@ fn default_config() -> HashMap<String, ConfigValue> {
set("general.border_width", Number(2.0));
set("general.animations", Bool(true));
set("general.animation_duration", Number(200.0));
+ set("general.shadows", Bool(true));
+ set("general.resize_margin", Number(6.0));
set("general.focus_follows_mouse", Bool(false));
set("general.mouse_follows_focus", Bool(true));
set("general.auto_raise", Bool(false));
diff --git a/crates/core/src/manager.rs b/crates/core/src/manager.rs
index 8696508..2c92ac1 100644
--- a/crates/core/src/manager.rs
+++ b/crates/core/src/manager.rs
@@ -4,7 +4,7 @@ use crate::monitor::{Monitor, MonitorId};
use crate::placement::{PlacementConfig, SmartPlacement, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH};
use crate::rules::WindowRule;
use crate::theme::ThemeConfig;
-use crate::window::{ResizeEdge, TitlebarHit, Window, WindowId};
+use crate::window::{ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN};
use crate::workspace::{Workspace, WorkspaceId};
use std::collections::HashMap;
@@ -61,6 +61,15 @@ pub struct WindowManager {
pub animations_enabled: bool,
/// Tween duration in milliseconds, read from `general.animation_duration`.
pub animation_duration_ms: u32,
+ /// Whether windows get a drop shadow. Read from `general.shadows`. A
+ /// maximized or fullscreen window never gets one regardless of this --
+ /// see the Wayland backend's shadow render call site - so this only
+ /// ever turns it off entirely, not on for those.
+ pub shadows_enabled: bool,
+ /// Width, in pixels, of the resize grab band along a window's edges,
+ /// read from `general.resize_margin`. See [`crate::window::RESIZE_MARGIN`]'s
+ /// doc comment for the default and why it's what it is.
+ pub resize_margin: i32,
/// Default decoration colours and border width, read from `theme.colors.*`/
/// `theme.decorations.*`. See `ThemeConfig`'s own doc comment.
pub theme: ThemeConfig,
@@ -103,6 +112,8 @@ impl WindowManager {
placement: PlacementConfig::default(),
animations_enabled: true,
animation_duration_ms: 200,
+ shadows_enabled: true,
+ resize_margin: RESIZE_MARGIN,
theme: ThemeConfig::default(),
drag: None,
resize: None,
@@ -759,7 +770,7 @@ impl WindowManager {
if w.minimized {
continue;
}
- if let Some(hit) = ResizeEdge::hit_test(w.geometry, x, y, w.decorated, w.border_width) {
+ if let Some(hit) = ResizeEdge::hit_test(w.geometry, x, y, w.decorated, w.border_width, self.resize_margin) {
return Some((w.id, hit));
}
}
diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs
index 1a4cab5..e30f501 100644
--- a/crates/core/src/window.rs
+++ b/crates/core/src/window.rs
@@ -147,15 +147,25 @@ impl Window {
/// The height, in pixels, of the drawn title bar. Shared between backends so
/// hit-testing and rendering agree on the same band.
pub const TITLEBAR_HEIGHT: u32 = 30;
-/// Width of the resize grab band along each window edge.
+/// Default width, in pixels, of the resize grab band along each window
+/// edge - `WindowManager::resize_margin`'s starting value, read from
+/// `general.resize_margin`, and what every `hit_test` call in this file's
+/// own tests still passes directly.
///
-/// 10px rather than a hairline: this is grabbed with a mouse, and a border
-/// only a couple of pixels wide is genuinely hard to hit - which is why
-/// Hyprland ships `extend_border_grab_area` and why every desktop widens
-/// this beyond the visible border. The band is inside the window, so it
-/// costs a few pixels of client edge; that is the right trade for making
-/// resize reliably grabbable without a keyboard.
-pub const RESIZE_MARGIN: i32 = 10;
+/// Originally 10px on the reasoning that a border only a couple of pixels
+/// wide is genuinely hard to grab with a mouse - which is why Hyprland
+/// ships `extend_border_grab_area` and why every desktop widens this
+/// beyond the visible border. But the band is *inside* the window
+/// (`resize_edge_at` measures from `frame`, the client's own content rect,
+/// inward), and at 10px that traded too much: reported live as ordinary
+/// clicks near any edge - a link near a browser's edge, a button near a
+/// panel's edge - regularly landing as a resize-edge grab instead of
+/// reaching the client at all, not just an occasional near-miss. Halved to
+/// 6px, which is still comfortably grabbable (about the same as a native
+/// X11 border on a legacy WM) while giving content much more of its own
+/// edge back. Still configurable per the doc comment above if 6px turns
+/// out to be too little in the other direction for someone.
+pub const RESIZE_MARGIN: i32 = 6;
/// Top-edge resize margin for an *undecorated* window specifically --
/// narrower than [`RESIZE_MARGIN`] on purpose.
///
@@ -206,7 +216,7 @@ impl ResizeEdge {
/// client. Resize-from-edge still applies either way: an undecorated
/// window is still a window, and dragging its (invisible) edge to
/// resize is still expected to work.
- pub fn hit_test(frame: Rect, x: i32, y: i32, decorated: bool, border_width: u32) -> Option<TitlebarHit> {
+ pub fn hit_test(frame: Rect, x: i32, y: i32, decorated: bool, border_width: u32, resize_margin: i32) -> Option<TitlebarHit> {
// Border strips render *outside* `frame` (`decoration::
// border_strips`, `border_width` pixels past each edge) - without
// widening the containment check to match, those visible pixels
@@ -236,12 +246,12 @@ impl ResizeEdge {
}
return Some(TitlebarHit::Drag);
}
- let edge = Self::resize_edge_at(frame, x, y, decorated)?;
+ let edge = Self::resize_edge_at(frame, x, y, decorated, resize_margin)?;
Some(TitlebarHit::Resize(edge))
}
- fn resize_edge_at(frame: Rect, x: i32, y: i32, decorated: bool) -> Option<ResizeEdge> {
- let m = RESIZE_MARGIN;
+ fn resize_edge_at(frame: Rect, x: i32, y: i32, decorated: bool, resize_margin: i32) -> Option<ResizeEdge> {
+ let m = resize_margin;
let top_m = if decorated { m } else { UNDECORATED_TOP_RESIZE_MARGIN };
let near_left = x <= frame.x + m;
let near_right = x >= frame.right() - m;
@@ -318,14 +328,14 @@ mod tests {
#[test]
fn close_button_is_top_right_corner_of_titlebar() {
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5, true, 0);
+ let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5, true, 0, RESIZE_MARGIN);
assert_eq!(hit, Some(TitlebarHit::Close));
}
#[test]
fn maximize_is_left_of_close() {
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - TITLEBAR_HEIGHT as i32 - 5, f.y + 5, true, 0);
+ let hit = ResizeEdge::hit_test(f, f.right() - TITLEBAR_HEIGHT as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN);
assert_eq!(hit, Some(TitlebarHit::Maximize));
}
@@ -333,14 +343,14 @@ mod tests {
fn middle_of_titlebar_is_drag() {
let f = frame();
let (cx, _) = f.center();
- let hit = ResizeEdge::hit_test(f, cx, f.y + 5, true, 0);
+ let hit = ResizeEdge::hit_test(f, cx, f.y + 5, true, 0, RESIZE_MARGIN);
assert_eq!(hit, Some(TitlebarHit::Drag));
}
#[test]
fn bottom_right_corner_is_resize() {
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1, true, 0);
+ let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1, true, 0, RESIZE_MARGIN);
assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::BottomRight)));
}
@@ -357,7 +367,7 @@ mod tests {
// outside RESIZE_MARGIN, so a real resize edge can't also explain a
// `None` here - undecorated, this must not be treated as
// decoration (or a resize edge) at all, just plain content.
- let hit = ResizeEdge::hit_test(f, cx, f.y + 20, false, 0);
+ let hit = ResizeEdge::hit_test(f, cx, f.y + 20, false, 0, RESIZE_MARGIN);
assert_eq!(hit, None);
}
@@ -365,7 +375,7 @@ mod tests {
fn undecorated_window_still_resizes_from_every_edge_including_top() {
let f = frame();
let (cx, _) = f.center();
- let hit = ResizeEdge::hit_test(f, cx, f.y + 1, false, 0);
+ let hit = ResizeEdge::hit_test(f, cx, f.y + 1, false, 0, RESIZE_MARGIN);
assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::Top)));
}
@@ -381,9 +391,9 @@ mod tests {
// above where `RESIZE_MARGIN` even starts to matter.
let f = frame();
let (cx, _) = f.center();
- assert_eq!(ResizeEdge::hit_test(f, cx, f.y + 5, false, 0), None, "5px in: past the narrow undecorated band, must reach the client");
+ assert_eq!(ResizeEdge::hit_test(f, cx, f.y + 5, false, 0, RESIZE_MARGIN), None, "5px in: past the narrow undecorated band, must reach the client");
assert_eq!(
- ResizeEdge::hit_test(f, cx, f.y + 5, true, 0),
+ ResizeEdge::hit_test(f, cx, f.y + 5, true, 0, RESIZE_MARGIN),
Some(TitlebarHit::Drag),
"decorated: 5px in is still well inside the titlebar band, not a resize edge"
);
@@ -392,7 +402,7 @@ mod tests {
#[test]
fn outside_frame_is_none() {
let f = frame();
- assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0), None);
+ assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0, RESIZE_MARGIN), None);
}
#[test]
@@ -407,16 +417,16 @@ mod tests {
let border_width = 2;
// One pixel into the border strip, past the left edge.
let x = f.x - 1;
- assert_eq!(ResizeEdge::hit_test(f, x, cy, true, 0), None, "sanity check: with no border, this point really is outside the window");
+ assert_eq!(ResizeEdge::hit_test(f, x, cy, true, 0, RESIZE_MARGIN), None, "sanity check: with no border, this point really is outside the window");
assert_eq!(
- ResizeEdge::hit_test(f, x, cy, true, border_width),
+ ResizeEdge::hit_test(f, x, cy, true, border_width, RESIZE_MARGIN),
Some(TitlebarHit::Resize(ResizeEdge::Left)),
"one pixel into the actual drawn border must still register as the left edge"
);
// Just past the border entirely (border_width + 1 outside frame) is
// still nothing - the fix widens the dead zone's boundary, it
// doesn't remove it.
- assert_eq!(ResizeEdge::hit_test(f, f.x - border_width as i32 - 1, cy, true, border_width), None);
+ assert_eq!(ResizeEdge::hit_test(f, f.x - border_width as i32 - 1, cy, true, border_width, RESIZE_MARGIN), None);
}
#[test]
diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs
index 737737f..d334556 100644
--- a/crates/srdwm/src/main.rs
+++ b/crates/srdwm/src/main.rs
@@ -149,6 +149,8 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) {
let gap = engine.get_f64("general.window_gap", 8.0).max(0.0) as u32;
let animations = engine.get_bool("general.animations", true);
let duration = engine.get_f64("general.animation_duration", 200.0).max(0.0) as u32;
+ let shadows = engine.get_bool("general.shadows", true);
+ let resize_margin = engine.get_f64("general.resize_margin", srdwm_core::RESIZE_MARGIN as f64).max(1.0) as i32;
// Only the three `theme.*` keys with an unambiguous, already-rendered
// counterpart are wired - see `srdwm_core::ThemeConfig`'s doc comment.
@@ -176,6 +178,8 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) {
wm.tiling.gap_outer = gap;
wm.animations_enabled = animations;
wm.animation_duration_ms = duration;
+ wm.shadows_enabled = shadows;
+ wm.resize_margin = resize_margin;
wm.theme = theme;
wm.auto_back_and_forth = engine.get_bool("workspace.auto_back_and_forth", false);
}
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
diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md
index cbcbeda..7d13b95 100644
--- a/docs/DEFAULTS.md
+++ b/docs/DEFAULTS.md
@@ -13,6 +13,8 @@ srd.set("general.window_gap", 8) -- Default: 8
srd.set("general.border_width", 2) -- Default: 2
srd.set("general.animations", true) -- Default: true
srd.set("general.animation_duration", 200) -- Default: 200ms
+srd.set("general.shadows", true) -- Default: true
+srd.set("general.resize_margin", 6) -- Default: 6px
srd.set("general.focus_follows_mouse", false) -- Default: false
srd.set("general.mouse_follows_focus", true) -- Default: true
srd.set("general.auto_raise", false) -- Default: false