srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-15 19:05:00 +0200
committersrdusr <[email protected]>2026-05-15 19:05:00 +0200
commit27daed83230eaefd0f41461cad20bbed7d1ad575 (patch)
treec725244151b2128748e6c6ab07ff0578c6da200c
parent8174ced0d9d42343b18072c64491ccb06632a75f (diff)
downloadsrdwm-27daed83230eaefd0f41461cad20bbed7d1ad575.tar.gz
srdwm-27daed83230eaefd0f41461cad20bbed7d1ad575.zip
Fix the maximize border on the path that actually runs, and three spawn faults
The maximize border was still drawn because the earlier fix landed on the wrong branch. udev/render.rs has three border blocks: the SRDWM_GPU=1 path at the top and two Pixman ones below. The patch replaced the first match in the file, which is the GPU branch a real DRM session never runs. All four sites across both backends are now gated on !maximized. The verification had failed twice for a separate reason: winit/capture.rs did not draw border strips at all, so a screenshot could never answer "is there a border here" and the control passed for the wrong reason. Border strips are now drawn into that pass as solid fills - corner rounding is not reproduced, so a capture is not pixel-exact at the corners, but presence, position, thickness and colour are. With that closed the test has a real control: unmaximized gives 6 accent pixels at x=800..805, exactly the configured border_width, and maximized gives none at the right edge or along the top row. That proves the winit path; the Pixman path is the same change at two more sites and is not separately confirmed on screen. Windows spawning as squares, partly off-screen, and always on the left were all SmartPlacement::grid. It returned size.min(cell), shrinking every window to its grid cell whatever size it asked for; it scanned cells in reading order and took the first free one, which is the leftmost; and nothing clamped the result, so a window larger than its cell could hang off the edge with its border out of view. The cell now decides only where a window goes, the scan starts from a rotating cell, and both grid and cascade clamp into the usable area. Four tests, one per reported symptom. 525 tests pass, clippy clean.
-rw-r--r--crates/core/src/placement.rs105
-rw-r--r--crates/wayland/src/state/mod.rs6
-rw-r--r--crates/wayland/src/udev/platform.rs1
-rw-r--r--crates/wayland/src/udev/render.rs11
-rw-r--r--crates/wayland/src/winit/capture.rs27
-rw-r--r--crates/wayland/src/winit/connect.rs1
-rw-r--r--docs/TODO.md58
7 files changed, 194 insertions, 15 deletions
diff --git a/crates/core/src/placement.rs b/crates/core/src/placement.rs
index 91c317c..cd3c29c 100644
--- a/crates/core/src/placement.rs
+++ b/crates/core/src/placement.rs
@@ -142,6 +142,20 @@ pub fn centered_in(area: Rect, width: u32, height: u32) -> Rect {
Rect::new(x.max(area.x), y.max(area.y), width, height)
}
+/// Moves `rect` so it sits inside `area` where it can, shrinking it only if
+/// it is genuinely larger than `area`.
+///
+/// Position is corrected before size on purpose: a window that merely
+/// overhangs an edge should slide back on screen at the size it asked for,
+/// not be cut down to fit where it happened to land.
+pub fn clamp_into(rect: Rect, area: Rect) -> Rect {
+ let width = rect.width.min(area.width);
+ let height = rect.height.min(area.height);
+ let x = rect.x.clamp(area.x, (area.right() - width as i32).max(area.x));
+ let y = rect.y.clamp(area.y, (area.bottom() - height as i32).max(area.y));
+ Rect::new(x, y, width, height)
+}
+
pub struct SmartPlacement;
impl SmartPlacement {
@@ -166,10 +180,10 @@ impl SmartPlacement {
if existing.is_empty() {
return Self::cascade(monitor, size, cfg, cascade_step);
}
- Self::grid(monitor, existing, size, cfg).unwrap_or_else(|| Self::cascade(monitor, size, cfg, cascade_step))
+ Self::grid(monitor, existing, size, cfg, cascade_step).unwrap_or_else(|| Self::cascade(monitor, size, cfg, cascade_step))
}
- fn grid(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig) -> Option<Rect> {
+ fn grid(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig, cascade_step: u32) -> Option<Rect> {
let count = existing.len() + 1;
let grid_size = (count as f64).sqrt().ceil() as u32;
let grid_size = grid_size.clamp(1, cfg.max_grid);
@@ -185,14 +199,33 @@ impl SmartPlacement {
return None;
}
- for gy in 0..grid_size {
- for gx in 0..grid_size {
- let x = area.x + cfg.grid_margin as i32 + (gx * (cell_w + cfg.grid_margin)) as i32;
- let y = area.y + cfg.grid_margin as i32 + (gy * (cell_h + cfg.grid_margin)) as i32;
- let candidate = Rect::new(x, y, cell_w, cell_h);
- if !existing.iter().any(|w| w.overlaps(&candidate)) {
- return Some(Rect::new(x, y, size.0.min(cell_w), size.1.min(cell_h)));
- }
+ // Cells are visited starting from a different one each time rather
+ // than always from the top-left, so consecutive windows do not all
+ // pile into the same corner. Reported as windows spawning
+ // "predominantly left side": the scan returned the first free cell
+ // in reading order, which is the leftmost one that happens to be
+ // free, over and over.
+ let cells = grid_size * grid_size;
+ let start = cascade_step % cells.max(1);
+ for offset in 0..cells {
+ let cell = (start + offset) % cells;
+ let (gx, gy) = (cell % grid_size, cell / grid_size);
+ let x = area.x + cfg.grid_margin as i32 + (gx * (cell_w + cfg.grid_margin)) as i32;
+ let y = area.y + cfg.grid_margin as i32 + (gy * (cell_h + cfg.grid_margin)) as i32;
+ let candidate = Rect::new(x, y, cell_w, cell_h);
+ if !existing.iter().any(|w| w.overlaps(&candidate)) {
+ // The window keeps the size it actually asked for. Shrinking
+ // it to the cell is what made every window come out the same
+ // boxy shape regardless of what it wanted - reported as
+ // windows spawning "as squares". The cell decides *where* a
+ // window goes, not how big it is.
+ //
+ // Clamped into the usable area afterwards so a window bigger
+ // than its cell still lands fully on screen rather than
+ // hanging off the edge with its border out of view --
+ // reported in the same breath as spawning "a little bit out
+ // of view, ie i can't see a border".
+ return Some(clamp_into(Rect::new(x, y, size.0, size.1), area));
}
}
None
@@ -228,9 +261,9 @@ impl SmartPlacement {
let max_steps = max_steps_x.min(max_steps_y).max(1);
let step = (cascade_step as i32) % max_steps;
- let x = (area.x + cfg.cascade_offset + step * cfg.cascade_offset).min(area.right() - width as i32).max(area.x);
- let y = (area.y + cfg.cascade_offset + step * cfg.cascade_offset).min(area.bottom() - height as i32).max(area.y);
- Rect::new(x, y, width, height)
+ let x = area.x + cfg.cascade_offset + step * cfg.cascade_offset;
+ let y = area.y + cfg.cascade_offset + step * cfg.cascade_offset;
+ clamp_into(Rect::new(x, y, width, height), area)
}
/// Given a window being dragged (its live geometry) and the monitor it's
@@ -372,6 +405,52 @@ mod tests {
}
#[test]
+ fn grid_placement_keeps_the_size_the_window_asked_for() {
+ // The reported "windows spawn as squares": every window used to be
+ // shrunk to its grid cell, so they all came out the same shape no
+ // matter what size they wanted.
+ let cfg = PlacementConfig::default();
+ let existing = [Rect::new(0, 0, 100, 100)];
+ let r = SmartPlacement::place(&monitor(), &existing, (1200, 400), &cfg, 0);
+ assert_eq!((r.width, r.height), (1200, 400), "the requested size must survive placement");
+ }
+
+ #[test]
+ fn a_window_never_lands_partly_off_screen() {
+ // "sometimes a little bit out of view, ie i can't see a border".
+ let cfg = PlacementConfig::default();
+ let area = monitor().geometry;
+ for step in 0..40u32 {
+ for size in [(400, 300), (1600, 900), (1900, 1000)] {
+ let r = SmartPlacement::place(&monitor(), &[Rect::new(0, 0, 50, 50)], size, &cfg, step);
+ assert!(
+ r.x >= area.x && r.y >= area.y && r.right() <= area.right() && r.bottom() <= area.bottom(),
+ "step {step} size {size:?} landed at {r:?}, outside {area:?}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn consecutive_windows_do_not_all_pile_into_the_same_corner() {
+ // "windows predominately spawn left side": the grid scan always
+ // returned the first free cell in reading order.
+ let cfg = PlacementConfig::default();
+ let existing = [Rect::new(900, 500, 80, 80)];
+ let xs: Vec<i32> = (0..4).map(|step| SmartPlacement::place(&monitor(), &existing, (300, 200), &cfg, step).x).collect();
+ assert!(xs.iter().any(|&x| x != xs[0]), "every placement started at the same x: {xs:?}");
+ }
+
+ #[test]
+ fn a_window_larger_than_the_whole_screen_is_cut_down_to_it() {
+ let cfg = PlacementConfig::default();
+ let r = SmartPlacement::place(&monitor(), &[Rect::new(0, 0, 50, 50)], (4000, 3000), &cfg, 0);
+ let area = monitor().geometry;
+ assert_eq!((r.width, r.height), (area.width, area.height));
+ assert_eq!((r.x, r.y), (area.x, area.y));
+ }
+
+ #[test]
fn snap_zone_kind_halves_split_the_area_down_the_middle() {
let area = monitor().geometry;
assert_eq!(SnapZoneKind::LeftHalf.rect(area), Rect::new(0, 0, 960, 1080));
diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs
index fee7af3..5c1b02e 100644
--- a/crates/wayland/src/state/mod.rs
+++ b/crates/wayland/src/state/mod.rs
@@ -589,6 +589,12 @@ pub(crate) struct CompState {
/// one drag is in progress at a time. See
/// `elements::snap_preview_elements`.
pub(crate) snap_preview_buffers: Vec<SolidColorBuffer>,
+ /// Persistent solid-colour buffers for the border strips drawn into the
+ /// winit backend's screencopy pass - same stable-`Id` reasoning as
+ /// `border_side_buffers`. One shared pool rather than one per window:
+ /// a capture pass runs to completion in a single frame, so nothing
+ /// needs to persist per window between windows.
+ pub(crate) capture_border_buffers: Vec<SolidColorBuffer>,
/// Persistent solid-colour buffer backing the whole-output night-light/
/// reading-mode overlay, one per output name - same "reuse the buffer
/// so its `Id` stays stable across frames" reasoning as `border_side_
diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs
index ea2260e..8dad7c3 100644
--- a/crates/wayland/src/udev/platform.rs
+++ b/crates/wayland/src/udev/platform.rs
@@ -268,6 +268,7 @@ impl UdevPlatform {
rounded_content_buffers: HashMap::new(),
border_side_buffers: HashMap::new(),
snap_preview_buffers: Vec::new(),
+ capture_border_buffers: Vec::new(),
color_filter_buffers: HashMap::new(),
last_synced_size: HashMap::new(),
provisional_size: HashSet::new(),
diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs
index 3bd9a52..9992b2c 100644
--- a/crates/wayland/src/udev/render.rs
+++ b/crates/wayland/src/udev/render.rs
@@ -668,7 +668,14 @@ impl CompState {
// instead, since `custom_elements` composites earlier-
// pushed entries over later ones - exactly backwards
// from what this overlap needs.
- if w.border_width > 0 {
+ // No border on a maximized window - see the matching
+ // gate on the GPU path above. This is the Pixman path,
+ // the one a real DRM session actually runs: the earlier
+ // fix landed only on the GPU branch, so the border kept
+ // being drawn on real hardware and was reported again as
+ // "i still see the border or at least the top border
+ // when maximized".
+ if w.border_width > 0 && !w.maximized {
let strips = decoration::border_strips(frame, w.border_width);
// Strip 0 (top) rounded on its own two corners - see
// `render_border_top`'s own doc comment - so it's a
@@ -773,7 +780,7 @@ impl CompState {
// (unlike the bottom strip) *do* need cropping against
// this same top/bottom-strip overlap, a real bug this
// comment used to claim didn't exist here at all.
- if w.border_width > 0 {
+ if w.border_width > 0 && !w.maximized {
let color = crate::state::effective_border_color(w.border_color, focused == Some(id), self.wm.borrow().theme.border_inactive_dim);
let strips = decoration::border_strips(frame, w.border_width);
// Strip 1 (bottom), the top strip's own mirror --
diff --git a/crates/wayland/src/winit/capture.rs b/crates/wayland/src/winit/capture.rs
index 095b56b..3813559 100644
--- a/crates/wayland/src/winit/capture.rs
+++ b/crates/wayland/src/winit/capture.rs
@@ -96,6 +96,7 @@ impl WaylandPlatform {
// as both on-screen render loops do it.
let monitor_bounds: Vec<srdwm_core::Rect> = self.wm.borrow().monitors().iter().map(|m| m.full_geometry).collect();
let mut occluders: Vec<srdwm_core::Rect> = Vec::new();
+ let focused = self.wm.borrow().focused_id();
for id in self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect::<Vec<_>>() {
let Some(w) = self.wm.borrow().window(id).cloned() else { continue };
if let Some(deco) = self.state.decorations.get(&id) {
@@ -158,6 +159,32 @@ impl WaylandPlatform {
}
}
}
+ // Border strips, as plain solid fills.
+ //
+ // The on-screen loop draws the top and bottom strips from
+ // cached bitmaps so their corners round into the titlebar's
+ // curve, and only the sides as fills. This pass approximates
+ // all four with fills: corner rounding is not reproduced, so a
+ // capture is not pixel-exact at the four corners. Everything
+ // that matters for checking a border - whether one is drawn at
+ // all, where, how thick, and in what colour - is faithful.
+ //
+ // Without this a screenshot could not answer "is there a border
+ // here", which is exactly the question a border bug asks. That
+ // gap was documented and then walked into twice: a maximize
+ // border fix was checked against a capture that never draws
+ // borders, and the control silently passed for the wrong
+ // reason both times.
+ if w.border_width > 0 && !w.maximized {
+ let color = crate::state::effective_border_color(w.border_color, focused == Some(id), self.wm.borrow().theme.border_inactive_dim);
+ let frame = self.state.effective_frame(id, w.geometry);
+ for (index, strip) in crate::decoration::border_strips(frame, w.border_width).into_iter().enumerate() {
+ for fragment in crate::elements::visible_border_fragments(strip, &occluders) {
+ let buf = crate::elements::border_fragment_buffer(&mut self.state.capture_border_buffers, index);
+ custom_elements.push(crate::elements::OverlayElement::Solid(crate::elements::border_side_render_element(buf, fragment, color, (0, 0))));
+ }
+ }
+ }
occluders.push(w.geometry);
}
custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Background | Layer::Bottom)));
diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs
index 1648826..aa59d9d 100644
--- a/crates/wayland/src/winit/connect.rs
+++ b/crates/wayland/src/winit/connect.rs
@@ -180,6 +180,7 @@ impl WaylandPlatform {
rounded_content_buffers: HashMap::new(),
border_side_buffers: HashMap::new(),
snap_preview_buffers: Vec::new(),
+ capture_border_buffers: Vec::new(),
color_filter_buffers: HashMap::new(),
last_synced_size: HashMap::new(),
provisional_size: HashSet::new(),
diff --git a/docs/TODO.md b/docs/TODO.md
index d93f0d2..d6e0cad 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,5 +1,63 @@
# TODO / planned features - master checklist
+## Five reports after the second restart: a fix that landed on the wrong branch, and a config that fought itself (2026-08-28)
+
+**The maximize border was still there because the fix landed on the wrong
+code path.** `udev/render.rs` has three border blocks, not one: the GPU
+(`SRDWM_GPU=1`) path at the top, and two Pixman ones below it. The earlier
+patch replaced the first match in the file, which is the GPU branch - the
+one a real DRM session never runs. Both Pixman blocks are now gated too, and
+so is the winit one. Four sites, checked by grep rather than assumed.
+
+The verification failed twice before this for a separate reason worth
+recording: `winit/capture.rs` did not draw border strips at all, a gap this
+file had already documented, so a screenshot could never answer "is there a
+border here" - and the control silently passed for the wrong reason both
+times. Border strips are now drawn into that pass as solid fills (corner
+rounding is not reproduced, so a capture is not pixel-exact at the four
+corners; presence, position, thickness and colour are). With that closed the
+test finally has a real control:
+
+ unmaximized 6 accent pixels at x=800..805, exactly border_width = 6
+ maximized 0 accent pixels at the right edge, 0 along the top row
+
+That proves the winit path. The user runs the Pixman path, which is the same
+change at two more sites and is not separately confirmed on screen.
+
+**Windows spawning as squares, off-screen, and always on the left - all
+three were `SmartPlacement::grid`.** It returned `size.min(cell)`, shrinking
+every window to its grid cell, so windows came out the same boxy shape
+whatever size they asked for. It scanned cells in reading order and returned
+the first free one, which is the leftmost. And nothing clamped the result, so
+a window bigger than its cell could hang off the edge with its border out of
+view. The cell now decides only *where* a window goes; the window keeps its
+requested size, the scan starts from a rotating cell so consecutive windows
+do not pile into one corner, and both grid and cascade clamp into the usable
+area. Four tests, one per reported symptom.
+
+**"Setting to right side decorations doesn't survive reboot" - it never
+survived the config load.** `init.lua:86` sets
+`theme.decorations.title_bar.button_side = "right"`, and `init.lua:141` then
+`srd.load("themes")`, whose active Catppuccin preset set `button_side =
+"left"`. Later write wins, so the explicit setting was silently overridden
+every start. Fixed in the user's own config by dropping that key from the
+preset, with a comment saying why, so the explicit choice in `init.lua`
+stands. Backup at `~/.config/srd/themes.lua.bak-20260828-195412`. Verified by
+loading their real config in a nested instance with `srd.load("startup")`
+stripped: `button_side` now reports `right`.
+
+**"Some windows are still using traffic lights" - those are not srdwm's
+buttons.** `rules.lua` sets `decorated = false` for firefox and nemo, and
+`likely_draws_own_titlebar` matches `org.gnome.*`/`org.pwmt.*`, so srdwm
+draws no titlebar for any of them - deliberately, since that is the
+double-titlebar fix. What shows is each app's own GTK header, and on the
+WhiteSur-Dark theme those buttons are macOS-style dots. `button_style` only
+controls srdwm's own titlebar and cannot restyle another toolkit's. Changing
+them means changing the GTK theme, or removing `decorated = false` for that
+app and accepting srdwm's titlebar stacked on the app's own.
+
+525 tests pass, clippy clean.
+
## Spawn placement, per-window minimum sizes, and how maximize looks (2026-08-28)
Four reports after the owner restarted into the day's build, with a