diff options
| -rw-r--r-- | crates/core/src/placement.rs | 105 | ||||
| -rw-r--r-- | crates/wayland/src/state/mod.rs | 6 | ||||
| -rw-r--r-- | crates/wayland/src/udev/platform.rs | 1 | ||||
| -rw-r--r-- | crates/wayland/src/udev/render.rs | 11 | ||||
| -rw-r--r-- | crates/wayland/src/winit/capture.rs | 27 | ||||
| -rw-r--r-- | crates/wayland/src/winit/connect.rs | 1 | ||||
| -rw-r--r-- | docs/TODO.md | 58 |
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 |