diff options
| author | srdusr <[email protected]> | 2026-05-11 16:57:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-05-11 16:57:00 +0200 |
| commit | 646b37e7e3aa5931079c6b9e804f420bb43c74d5 (patch) | |
| tree | 159825fae0086c162b36edc6e78da07d975ab6b0 | |
| parent | caec1e7c355bbe6437afe87cd3dab6b64fb91e0a (diff) | |
| download | srdwm-646b37e7e3aa5931079c6b9e804f420bb43c74d5.tar.gz srdwm-646b37e7e3aa5931079c6b9e804f420bb43c74d5.zip | |
Fix spawn placement under the top bar, add per-window minimum sizes, and clean up maximize
Four reports after restarting into today's build, with a screenshot. The
screenshot was measured, not eyeballed: top bar y=0..29, a 4px accent border
at y=30..33, titlebar from y=34, bottom border at y=1027..1030, and 49px of
bare desktop below it.
Windows spawning too close to the top bar. A remembered position was
validated only by asking whether it landed on some monitor's full_geometry,
which includes the strip a top bar reserves, so an app whose remembered y was
small reopened with its titlebar under the bar. That is why it was
"sometimes": it depended on the stored value, and the live store holds
wezterm at y=44 and firefox at y=69 against a 30px bar. Remembered positions
are now clamped into the monitor's usable area.
Placement not surviving a logout. Window memory does persist, but five of the
eleven entries in the live store were saved with a second monitor attached,
at x >= 2000. Those points match no current monitor and were discarded
outright, falling back to a fresh cascade, so those apps appeared to remember
nothing. Such a position is now clamped onto a monitor that exists instead.
Per-window minimum sizes. One global floor is wrong in both directions.
Three sources now, in increasing precedence: the global floor, the client's
own declared minimum (xdg_toplevel.set_min_size or ICCCM hints), and a
min_width/min_height window rule overriding both. A rule wins permanently --
the backend refreshes the client's declared minimum on every decoration
redraw and must not undo a deliberate override.
Maximize, three faults in one report. A maximized window now draws no
border: its edges are the screen's edges, and the only place maximize stops
short is the bar strip, which is exactly where the measured line was.
maximize_geometry_for no longer subtracts a bottom-anchored dock's exclusive
zone, so maximize runs to the bottom of the screen and the dock floats over
it; top, left and right are still honoured.
general.maximize_covers_dock = false restores the old behaviour. With the
border gone the window sits flush under the bar instead of with an accent
line crowding it.
Verified: seven new tests on the real numbers from the live store, and
maximize geometry measured live in a nested instance (a window maximized on a
split half reports exactly that half's rect). NOT confirmed on screen: the
border removal and the dock behaviour - the nested backend has no bar or
dock to reserve a zone, and an attempt to check the border produced a failing
control, since srd set border_width only affects windows created after it.
515 tests pass, clippy clean.
| -rw-r--r-- | crates/config/src/engine/general.rs | 18 | ||||
| -rw-r--r-- | crates/config/src/engine/support.rs | 3 | ||||
| -rw-r--r-- | crates/core/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/core/src/manager/dragresize.rs | 7 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 6 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 115 | ||||
| -rw-r--r-- | crates/core/src/manager/windows.rs | 44 | ||||
| -rw-r--r-- | crates/core/src/manager/winops.rs | 4 | ||||
| -rw-r--r-- | crates/core/src/rules.rs | 5 | ||||
| -rw-r--r-- | crates/core/src/window.rs | 21 | ||||
| -rw-r--r-- | crates/srdwm/src/main.rs | 4 | ||||
| -rw-r--r-- | crates/wayland/src/input/layers.rs | 12 | ||||
| -rw-r--r-- | crates/wayland/src/state/lifecycle.rs | 20 | ||||
| -rw-r--r-- | crates/wayland/src/udev/platform.rs | 2 | ||||
| -rw-r--r-- | crates/wayland/src/udev/render.rs | 11 | ||||
| -rw-r--r-- | crates/wayland/src/winit/nested_platform.rs | 4 | ||||
| -rw-r--r-- | crates/wayland/src/winit/render.rs | 11 | ||||
| -rw-r--r-- | docs/DEFAULTS.md | 54 | ||||
| -rw-r--r-- | docs/TODO.md | 68 |
19 files changed, 394 insertions, 17 deletions
diff --git a/crates/config/src/engine/general.rs b/crates/config/src/engine/general.rs index c10ecf0..c8a136f 100644 --- a/crates/config/src/engine/general.rs +++ b/crates/config/src/engine/general.rs @@ -172,6 +172,23 @@ impl Engine { _ => None, } }; + // `min_width`/`min_height` - the smallest this window may be + // resized to, overriding whatever the client itself declared. + // Two plain numbers rather than a `"WxH"` string: unlike an + // aspect ratio, a size is not conventionally written as one + // token, and `width`/`height` above already set the precedent + // for separate keys. + let min_size: Option<(u32, u32)> = { + let min_width: Option<u32> = actions.get("min_width")?; + let min_height: Option<u32> = actions.get("min_height")?; + match (min_width, min_height) { + (None, None) => None, + // One alone is meaningful - a terminal that needs + // width but no particular height, say. The unset axis + // keeps the global floor. + (w, h) => Some((w.unwrap_or(srdwm_core::MIN_WINDOW_WIDTH), h.unwrap_or(srdwm_core::MIN_WINDOW_HEIGHT))), + } + }; // `aspect_ratio = "9:16"` - the "phone monitor / special // workspace" ask's own real, scoped answer (see `Window:: // aspect_ratio`'s own doc comment in `crates/core`): a rule @@ -210,6 +227,7 @@ impl Engine { opacity: actions.get("opacity")?, resize_margin: actions.get("resize_margin")?, aspect_ratio, + min_size, }, }; state.borrow().wm.borrow_mut().add_rule(rule); diff --git a/crates/config/src/engine/support.rs b/crates/config/src/engine/support.rs index a2423d2..76bcf5e 100644 --- a/crates/config/src/engine/support.rs +++ b/crates/config/src/engine/support.rs @@ -182,6 +182,9 @@ pub(super) fn default_config() -> HashMap<String, ConfigValue> { // Re-read `init.lua` when it changes on disk, no reload key needed. // See `main.rs`'s `config_mtime`/`CONFIG_POLL_INTERVAL`. set("general.config_reload_on_write", Bool(true)); + // Maximize runs to the bottom of the screen, under a dock, rather than + // stopping above it. A top bar is still always honoured. + set("general.maximize_covers_dock", Bool(true)); set("general.smart_placement", Bool(true)); set("general.window_gap", Number(8.0)); set("general.animations", Bool(true)); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 30fc020..e9d31a5 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,7 +19,7 @@ pub use layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig}; pub use lock_config::LockConfig; pub use manager::{CaptureRequest, ColorFilter, Direction, WindowManager}; pub use monitor::{Monitor, MonitorId}; -pub use placement::{centered_in, PlacementConfig, SmartPlacement, SnapZoneKind, SNAP_FLYOUT_EDGE}; +pub use placement::{centered_in, PlacementConfig, SmartPlacement, SnapZoneKind, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SNAP_FLYOUT_EDGE}; pub use regex::Regex; pub use rules::{WindowMatch, WindowRule, WindowRuleActions}; pub use theme::{format_hex_color, parse_hex_color, ThemeConfig}; diff --git a/crates/core/src/manager/dragresize.rs b/crates/core/src/manager/dragresize.rs index 2efca7c..906e5db 100644 --- a/crates/core/src/manager/dragresize.rs +++ b/crates/core/src/manager/dragresize.rs @@ -211,13 +211,16 @@ impl WindowManager { self.adjust_master_ratio_for_drag(window, &ids, dx, orig_master_ratio); return; } - let mut new_geom = edge.apply_delta(orig, dx, dy, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT); + // This window's own minimum, not the one global floor - see + // `Window::min_size`. + let (min_w, min_h) = self.windows.get(&window).map(|w| w.min_size).unwrap_or((MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)); + let mut new_geom = edge.apply_delta(orig, dx, dy, min_w, min_h); // `Window::aspect_ratio`'s own doc comment: a locked-ratio window // (the "phone monitor" case, concretely) re-derives one dimension // from the other here, on top of the ordinary delta above, rather // than needing a second, separate resize code path. if let Some(ratio) = self.windows.get(&window).and_then(|w| w.aspect_ratio) { - new_geom = edge.apply_aspect_ratio(new_geom, ratio, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT); + new_geom = edge.apply_aspect_ratio(new_geom, ratio, min_w, min_h); } // Same live `w.monitor` correction as `update_drag`'s own doc // comment explains - a resize can cross a monitor boundary at diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs index e778a32..2ca2bf9 100644 --- a/crates/core/src/manager/mod.rs +++ b/crates/core/src/manager/mod.rs @@ -434,6 +434,11 @@ pub struct WindowManager { /// Read from `theme.lock.*`. See `LockConfig`'s own doc comment for /// why this isn't just folded into `theme` above. pub lock: LockConfig, + /// `general.maximize_covers_dock`: whether maximize runs to the bottom + /// of the screen, under a bottom-anchored dock, rather than stopping + /// above it. A top bar's own reservation is always honoured either + /// way. Default `true` - see `input::layers::maximize_geometry_for`. + pub maximize_covers_dock: bool, /// Set by `request_refresh`, drained by the main loop. Same /// cross-boundary queued-request shape as `lock_requested`. refresh_requested: bool, @@ -623,6 +628,7 @@ impl WindowManager { auto_raise: false, theme: ThemeConfig::default(), lock: LockConfig::default(), + maximize_covers_dock: true, refresh_requested: false, live_settings: std::collections::BTreeMap::new(), drag: None, diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs index fade3aa..152c71d 100644 --- a/crates/core/src/manager/tests.rs +++ b/crates/core/src/manager/tests.rs @@ -799,6 +799,121 @@ assert!(wm.drag_top_edge_monitor().is_none()); } + /// A monitor with a 30px top bar reserved, like the real session. + fn wm_with_top_bar() -> WindowManager { + let mut wm = WindowManager::new(); + wm.set_monitors(vec![{ + let mut m = Monitor::new(0, "primary", Rect::new(0, 30, 1920, 1050)); + m.full_geometry = Rect::new(0, 0, 1920, 1080); + m.primary = true; + m + }]); + wm + } + + #[test] + fn a_remembered_position_under_the_top_bar_is_pushed_clear_of_it() { + // The reported bug: an app whose remembered y predates the bar (or + // was saved on a monitor without one) reopened with its titlebar + // tucked underneath, unreachable. + let mut wm = wm_with_top_bar(); + wm.set_remembered_geometry("someapp".to_string(), (100, 5, 800, 600)); + let a = wm.alloc_window_id(); + let mut w = Window::new(a, "w"); + w.app_id = "someapp".into(); + wm.add_window(w); + let g = wm.window(a).unwrap().geometry; + assert_eq!(g.x, 100, "x was fine and must be left alone"); + assert_eq!(g.y, 30, "pushed down to the usable area's own top edge"); + } + + #[test] + fn a_remembered_position_that_already_clears_the_bar_is_untouched() { + let mut wm = wm_with_top_bar(); + wm.set_remembered_geometry("someapp".to_string(), (100, 200, 800, 600)); + let a = wm.alloc_window_id(); + let mut w = Window::new(a, "w"); + w.app_id = "someapp".into(); + wm.add_window(w); + assert_eq!((wm.window(a).unwrap().geometry.x, wm.window(a).unwrap().geometry.y), (100, 200)); + } + + #[test] + fn a_position_remembered_on_a_now_disconnected_monitor_is_pulled_back_on_screen() { + // Five of the eleven entries in the real store were saved with a + // second monitor attached. They used to be discarded outright, so + // those apps stopped remembering anything at all. + let mut wm = wm_with_top_bar(); + wm.set_remembered_geometry("someapp".to_string(), (2400, 300, 800, 600)); + let a = wm.alloc_window_id(); + let mut w = Window::new(a, "w"); + w.app_id = "someapp".into(); + wm.add_window(w); + let g = wm.window(a).unwrap().geometry; + assert_eq!(g.x, 1120, "clamped so its right edge sits on the monitor's own right edge"); + assert_eq!(g.y, 300, "y was already valid and is kept"); + assert!(g.x >= 0 && g.x + g.width as i32 <= 1920, "fully on screen: {g:?}"); + } + + #[test] + fn a_remembered_window_larger_than_the_screen_still_starts_at_the_usable_origin() { + let mut wm = wm_with_top_bar(); + wm.set_remembered_geometry("someapp".to_string(), (500, 500, 4000, 3000)); + let a = wm.alloc_window_id(); + let mut w = Window::new(a, "w"); + w.app_id = "someapp".into(); + wm.add_window(w); + let g = wm.window(a).unwrap().geometry; + assert_eq!((g.x, g.y), (0, 30), "clamped to the usable origin, not left off-screen"); + } + + #[test] + fn a_min_size_rule_raises_the_floor_an_interactive_resize_stops_at() { + let mut wm = wm_with_monitor(); + wm.add_rule(WindowRule { + matcher: WindowMatch { class: Some("bigapp".into()), ..Default::default() }, + actions: WindowRuleActions { min_size: Some((600, 400)), ..Default::default() }, + }); + let a = wm.alloc_window_id(); + let mut w = Window::new(a, "w"); + w.app_id = "bigapp".into(); + w.geometry = Rect::new(100, 100, 900, 700); + wm.add_window(w); + assert_eq!(wm.window(a).unwrap().min_size, (600, 400)); + + // Drag the bottom-right corner far past the minimum. + wm.start_resize(a, ResizeEdge::BottomRight, 1000, 800); + wm.update_resize(-5000, -5000); + let g = wm.window(a).unwrap().geometry; + assert_eq!((g.width, g.height), (600, 400), "must stop at the rule's own minimum"); + } + + #[test] + fn without_a_rule_a_window_keeps_the_global_minimum() { + let mut wm = wm_with_monitor(); + let a = wm.alloc_window_id(); + wm.add_window(Window::new(a, "w")); + assert_eq!(wm.window(a).unwrap().min_size, (MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)); + } + + #[test] + fn a_min_size_rule_also_floors_a_remembered_size() { + // A size remembered from before the rule existed must not reopen + // the window below its new minimum. + let mut wm = wm_with_monitor(); + wm.add_rule(WindowRule { + matcher: WindowMatch { class: Some("bigapp".into()), ..Default::default() }, + actions: WindowRuleActions { min_size: Some((700, 500)), ..Default::default() }, + }); + wm.set_remembered_geometry("bigapp".to_string(), (100, 100, 300, 200)); + let a = wm.alloc_window_id(); + let mut w = Window::new(a, "w"); + w.app_id = "bigapp".into(); + wm.add_window(w); + let g = wm.window(a).unwrap().geometry; + assert_eq!((g.width, g.height), (700, 500)); + } + #[test] fn a_dialog_opens_centered_not_cascaded_into_the_corner() { let mut wm = wm_with_monitor(); diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs index bd13b0c..0186679 100644 --- a/crates/core/src/manager/windows.rs +++ b/crates/core/src/manager/windows.rs @@ -60,6 +60,10 @@ impl WindowManager { if let Some(margin) = a.resize_margin { window.resize_margin = Some(margin); } + if let Some(min) = a.min_size { + window.min_size = min; + window.min_size_from_rule = true; + } if let Some(ratio) = a.aspect_ratio { window.aspect_ratio = Some(ratio); } @@ -87,8 +91,8 @@ impl WindowManager { let mut remembered_position: Option<(i32, i32)> = None; if !window.app_id.is_empty() && !window.is_dialog { if let Some((x, y, w, h)) = self.remembered_geometry.get(&window.app_id).copied() { - window.geometry.width = w.max(MIN_WINDOW_WIDTH); - window.geometry.height = h.max(MIN_WINDOW_HEIGHT); + window.geometry.width = w.max(window.min_size.0); + window.geometry.height = h.max(window.min_size.1); remembered_position = Some((x, y)); } } @@ -137,7 +141,16 @@ impl WindowManager { // current monitor's own *full* geometry (not the exclusive-zone- // shrunk usable one): a remembered position under where a bar now // sits is still "a real monitor, just partly covered", not invalid. - let remembered_monitor = remembered_position.and_then(|(x, y)| self.monitors.iter().find(|m| m.full_geometry.contains_point(x, y))); + // Whichever monitor a remembered position lands on, else the one + // the window would have been placed on anyway - a position saved + // while a second monitor was connected must not be thrown away + // just because that monitor is gone. Clamped onto the surviving + // monitor below instead, which keeps "reopen where I left it" + // meaningful across a docking change rather than silently falling + // back to a fresh cascade. + let remembered_monitor = remembered_position + .and_then(|(x, y)| self.monitors.iter().find(|m| m.full_geometry.contains_point(x, y))) + .or(target_monitor); // A dialog is centred, and neither cascaded nor restored to a // remembered spot. Reported as "even dialog/starter windows spawn // that side, most times should be centered". @@ -160,8 +173,25 @@ impl WindowManager { } } else if let (Some((x, y)), Some(monitor)) = (remembered_position, remembered_monitor) { window.monitor = monitor.id; - window.geometry.x = x; - window.geometry.y = y; + // Clamped into the monitor's *usable* area, not its full one. + // + // The check above only asked whether the remembered point was + // on some monitor at all, and `full_geometry` includes the + // strip a top bar reserves - so an app whose remembered `y` + // was small reopened with its titlebar tucked under the bar, + // unreachable. Reported as windows spawning "so close to top + // bar", and "sometimes" precisely because it depended on the + // remembered value: this store currently holds a window at + // y=44 with a 30px bar above it. + // + // The same clamp is what makes a position from a + // now-disconnected monitor usable rather than discarded: it is + // pulled back onto a monitor that exists, keeping as much of + // the remembered placement as still fits. + let area = monitor.geometry; + let (w, h) = (window.geometry.width as i32, window.geometry.height as i32); + window.geometry.x = x.clamp(area.x, (area.right() - w).max(area.x)); + window.geometry.y = y.clamp(area.y, (area.bottom() - h).max(area.y)); } else if let Some(monitor) = target_monitor { window.monitor = monitor.id; let layout_name = self.workspace(workspace).map(|w| w.layout.clone()).unwrap_or_default(); @@ -280,6 +310,10 @@ impl WindowManager { if let Some(margin) = actions.resize_margin { window.resize_margin = Some(margin); } + if let Some(min) = actions.min_size { + window.min_size = min; + window.min_size_from_rule = true; + } if let Some(ratio) = actions.aspect_ratio { window.aspect_ratio = Some(ratio); } diff --git a/crates/core/src/manager/winops.rs b/crates/core/src/manager/winops.rs index 1deed3e..d145e97 100644 --- a/crates/core/src/manager/winops.rs +++ b/crates/core/src/manager/winops.rs @@ -258,8 +258,8 @@ impl WindowManager { pub fn resize_window(&mut self, id: WindowId, width: u32, height: u32) { if let Some(w) = self.windows.get_mut(&id) { - w.geometry.width = width.max(MIN_WINDOW_WIDTH); - w.geometry.height = height.max(MIN_WINDOW_HEIGHT); + w.geometry.width = width.max(w.min_size.0); + w.geometry.height = height.max(w.min_size.1); } } diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs index ea83501..d44f5a4 100644 --- a/crates/core/src/rules.rs +++ b/crates/core/src/rules.rs @@ -100,6 +100,11 @@ pub struct WindowRuleActions { /// binding (`crates/config`), not here - this crate has no config /// engine of its own to own that parsing. pub aspect_ratio: Option<(u32, u32)>, + /// `(width, height)` this window may never be resized below, in + /// physical pixels - see `Window::min_size`. Overrides whatever the + /// client itself declared, so a rule can both raise a minimum the app + /// set too low and lower one it set too high. + pub min_size: Option<(u32, u32)>, } #[derive(Debug, Clone, Default)] diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs index ee9fd23..272d772 100644 --- a/crates/core/src/window.rs +++ b/crates/core/src/window.rs @@ -197,6 +197,25 @@ pub struct Window { /// (GNOME, KDE, Windows) hides or disables it in exactly this case. /// Asked for as titlebars with "buttons of the program/dynamic". pub resizable: bool, + /// This window's own minimum size, in physical pixels. Defaults to the + /// global [`crate::placement::MIN_WINDOW_WIDTH`]/`MIN_WINDOW_HEIGHT`. + /// + /// Two sources, in order of precedence: a `min_size` window rule, and + /// the client's own declared minimum (`xdg_toplevel.set_min_size`, or + /// an XWayland window's ICCCM size hints), which the backend fills in + /// the same way it fills `resizable`. + /// + /// One global minimum for every window is wrong in both directions: it + /// is far too small for an application that needs room to lay out at + /// all, and too large for a small utility or palette window. Asked for + /// as "different windows should have minimum sizes depending on what + /// window is". + pub min_size: (u32, u32), + /// Set when `min_size` came from a `min_size` window rule rather than + /// from the client or the default. The backend refreshes a client's + /// declared minimum on every decoration redraw, and must not overwrite + /// a deliberate rule with it. + pub min_size_from_rule: bool, /// `decorated`'s value from just before entering fullscreen, restored /// on exit - see `WindowManager::toggle_fullscreen`'s doc comment on /// why this can't just hardcode `true` back. @@ -302,6 +321,8 @@ impl Window { decorated: true, is_dialog: false, resizable: true, + min_size: (crate::placement::MIN_WINDOW_WIDTH, crate::placement::MIN_WINDOW_HEIGHT), + min_size_from_rule: false, restore_decorated: None, floating: false, minimized: false, diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs index 1cad90f..5de7ace 100644 --- a/crates/srdwm/src/main.rs +++ b/crates/srdwm/src/main.rs @@ -228,6 +228,9 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) { let rounded_corners = engine.get("general.rounded_corners").and_then(|v| v.as_bool()); let gpu = engine.get_bool("general.gpu", false); let phone_mode = engine.get_bool("general.phone_mode", false); + // Maximize runs under a bottom dock by default - see + // `WindowManager::maximize_covers_dock`. + let maximize_covers_dock = engine.get_bool("general.maximize_covers_dock", true); let multi_cursor = engine.get_bool("general.multi_cursor", false); let desktop_icons = engine.get_bool("general.desktop_icons", true); let desktop_icons_all_monitors = engine.get_bool("general.desktop_icons_all_monitors", true); @@ -374,6 +377,7 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) { wm.rounded_corners_enabled = rounded_corners; wm.gpu_enabled = gpu; wm.phone_mode = phone_mode; + wm.maximize_covers_dock = maximize_covers_dock; wm.multi_cursor_enabled = multi_cursor; wm.desktop_icons_enabled = desktop_icons; wm.desktop_icons_all_monitors = desktop_icons_all_monitors; diff --git a/crates/wayland/src/input/layers.rs b/crates/wayland/src/input/layers.rs index b429fbb..4450013 100644 --- a/crates/wayland/src/input/layers.rs +++ b/crates/wayland/src/input/layers.rs @@ -142,7 +142,7 @@ pub(super) fn background_layer_surface_under(state: &CompState, pos: Point<f64, /// respecting every edge here is what every mainstream desktop's own /// maximize convention already does. Fullscreen is unaffected - it never /// called this function, and still doesn't. -pub(crate) fn maximize_geometry_for(output: &Output, full: srdwm_core::Rect) -> srdwm_core::Rect { +pub(crate) fn maximize_geometry_for(output: &Output, full: srdwm_core::Rect, covers_dock: bool) -> srdwm_core::Rect { let mut rect = full; // `exclusive_zone`/`margin` are logical (a layer-shell client reports // its own reservation the same way every other layer-shell geometry @@ -162,7 +162,15 @@ pub(crate) fn maximize_geometry_for(output: &Output, full: srdwm_core::Rect) -> rect.y += shrink; rect.height = rect.height.saturating_sub(shrink as u32); } - if data.anchor.contains(Anchor::BOTTOM) && !data.anchor.contains(Anchor::TOP) { + // A bottom-anchored zone (a dock) is deliberately NOT subtracted + // when `covers_dock` is set: maximize runs to the bottom of the + // screen and the dock floats over it. Asked for directly - "not + // sure why it doesn't go all the way down past where dock would + // be" - and it matches the common dock convention (macOS, and any + // auto-hiding dock), where the dock overlays a maximized window + // rather than permanently shortening it. Top/left/right zones are + // still honoured, so a maximized window never hides the bar. + if data.anchor.contains(Anchor::BOTTOM) && !data.anchor.contains(Anchor::TOP) && !covers_dock { let shrink = scaled(data.margin.bottom); rect.height = rect.height.saturating_sub(shrink as u32); } diff --git a/crates/wayland/src/state/lifecycle.rs b/crates/wayland/src/state/lifecycle.rs index 92f7d9d..163064e 100644 --- a/crates/wayland/src/state/lifecycle.rs +++ b/crates/wayland/src/state/lifecycle.rs @@ -131,9 +131,29 @@ impl CompState { } true }); + // The client's own declared minimum, read from the same place + // `resizable` above comes from. A rule's `min_size` still wins -- + // it is applied at map time and this must not undo it - so this + // only ever raises the floor above the global default, never + // overwrites a deliberate override. + let declared_min = self.id_to_window.get(&id).and_then(|dw| { + if let Some(toplevel) = dw.toplevel() { + let min = smithay::wayland::compositor::with_states(toplevel.wl_surface(), |states| { + let mut cached = states.cached_state.get::<smithay::wayland::shell::xdg::SurfaceCachedState>(); + cached.current().min_size + }); + return (min.w > 0 && min.h > 0).then_some((min.w as u32, min.h as u32)); + } + dw.x11_surface().and_then(|x| x.min_size()).and_then(|m| (m.w > 0 && m.h > 0).then_some((m.w as u32, m.h as u32))) + }); if let Some(win) = self.wm.borrow_mut().window_mut(id) { win.is_dialog = is_dialog; win.resizable = resizable; + if let Some(min) = declared_min { + if !win.min_size_from_rule { + win.min_size = min; + } + } } let show_maximize = { let wm = self.wm.borrow(); diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs index 0ae65f7..ea2260e 100644 --- a/crates/wayland/src/udev/platform.rs +++ b/crates/wayland/src/udev/platform.rs @@ -830,7 +830,7 @@ impl Platform for UdevPlatform { // deliberately *not* defaulted from `usable` the way `Monitor:: // new` alone would (see the fullscreen note below). let full = srdwm_core::Rect::new(head.location.x, head.location.y, head.size.0 as u32, head.size.1 as u32); - let maximize = crate::input::maximize_geometry_for(&head.output, full); + let maximize = crate::input::maximize_geometry_for(&head.output, full, wm.maximize_covers_dock); let name = head.output.name(); let split = wm.monitor_split(&name); let parts = split.map(|s| s.parts).unwrap_or(1).max(1); diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs index aa05529..3bd9a52 100644 --- a/crates/wayland/src/udev/render.rs +++ b/crates/wayland/src/udev/render.rs @@ -334,7 +334,16 @@ impl CompState { // be_masked` degenerates to just `w.decorated` when // masking can never succeed). let border_curve_is_safe = w.decorated; - if w.border_width > 0 { + // No border on a maximized window. A maximized window's own + // edges are the screen's edges, so a border has nothing to + // separate it from - and where maximize *does* stop short (the + // strip a top bar reserves) the border lands in that gap, drawn + // as a hard line right against the bar. Reported live with a + // screenshot: a 4px accent line between the bar and the window, + // and none anywhere else, because the left/right/bottom strips + // fall off-screen. Fullscreen is already borderless for the + // same reason, via `decorated` being cleared. + if w.border_width > 0 && !w.maximized { let strips = decoration::border_strips(geom, w.border_width); if let Some(buffer) = self.border_top_decorations.get(&id) { let (row0, rows, shift) = decoration::border_top_visible_rows(border_curve_is_safe, w.border_width, w.corner_radius); diff --git a/crates/wayland/src/winit/nested_platform.rs b/crates/wayland/src/winit/nested_platform.rs index 3b762ab..b0fa529 100644 --- a/crates/wayland/src/winit/nested_platform.rs +++ b/crates/wayland/src/winit/nested_platform.rs @@ -117,7 +117,7 @@ impl Platform for WaylandPlatform { let usable = srdwm_core::Rect::new(zone.loc.x, zone.loc.y, zone.size.w as u32, zone.size.h as u32); let full_size = self.backend.window_size(); let full = srdwm_core::Rect::new(0, 0, full_size.w as u32, full_size.h as u32); - let maximize = crate::input::maximize_geometry_for(&self.output, full); + let maximize = crate::input::maximize_geometry_for(&self.output, full, self.wm.borrow().maximize_covers_dock); // Expanded into one `Monitor` per split part, exactly as // `udev/platform.rs`'s own `monitors()` does - see the split drain // in `poll` above for why this backend supports it at all. @@ -149,7 +149,7 @@ impl Platform for WaylandPlatform { // "usable, shrunk rect" `toggle_maximize` targets. let full = self.backend.window_size(); m.full_geometry = srdwm_core::Rect::new(0, 0, full.w as u32, full.h as u32); - m.maximize_geometry = crate::input::maximize_geometry_for(&self.output, m.full_geometry); + m.maximize_geometry = crate::input::maximize_geometry_for(&self.output, m.full_geometry, self.wm.borrow().maximize_covers_dock); m.primary = true; m }]) diff --git a/crates/wayland/src/winit/render.rs b/crates/wayland/src/winit/render.rs index ccc64a2..432ebba 100644 --- a/crates/wayland/src/winit/render.rs +++ b/crates/wayland/src/winit/render.rs @@ -313,7 +313,16 @@ impl WaylandPlatform { // doc comment further down; that's an overlap between two // pieces of this window's own decoration, not with its content, // so it doesn't contradict this paragraph.) - if w.border_width > 0 { + // No border on a maximized window. A maximized window's own + // edges are the screen's edges, so a border has nothing to + // separate it from - and where maximize *does* stop short (the + // strip a top bar reserves) the border lands in that gap, drawn + // as a hard line right against the bar. Reported live with a + // screenshot: a 4px accent line between the bar and the window, + // and none anywhere else, because the left/right/bottom strips + // fall off-screen. Fullscreen is already borderless for the + // same reason, via `decorated` being cleared. + 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); // Strips 0/1 (top/bottom) are rounded on their own two diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md index 3497410..2cc1614 100644 --- a/docs/DEFAULTS.md +++ b/docs/DEFAULTS.md @@ -20,6 +20,7 @@ srd.set("general.gpu", false) -- Default: false - udev srd.set("general.desktop_icons", true) -- Default: true - see "Desktop icons" below srd.set("general.desktop_icons_all_monitors", true) -- Default: true - mirror icons onto every monitor, not just primary srd.set("general.config_reload_on_write", true) -- Default: true - re-read init.lua when it changes on disk +srd.set("general.maximize_covers_dock", true) -- Default: true - maximize runs under a bottom dock, not above it srd.set("general.reserve_top", 0) -- Default: 0 - static space reserved before any bar/dock connects srd.set("general.reserve_bottom", 0) -- Default: 0 - see "Startup space reservation" below srd.set("general.reserve_left", 0) -- Default: 0 @@ -881,3 +882,56 @@ One limit to know: a reload does not re-register key *grabs* with the backend, so a brand new key combination needs a restart before the compositor sees that key at all. An existing combination picks up its new action immediately. + +## Window minimum sizes + +Every window has a minimum size it cannot be resized below. Three sources, +in increasing precedence: + +1. A global floor, used when nothing else applies. +2. The client's own declared minimum - `xdg_toplevel.set_min_size` for a + native Wayland window, the ICCCM size hints for an XWayland one. Read + from the client and refreshed as it changes. +3. A `min_width`/`min_height` window rule, which overrides both. Either may + be given alone; the other axis keeps the global floor. + +```lua +srd.rule({ class = "Alacritty" }, { min_width = 480, min_height = 320 }) +``` + +A rule wins permanently: the client's own declared minimum is never allowed +to overwrite it, so an application that reports a minimum too large for a +small screen can be overridden. The minimum applies to interactive resizing, +to a size restored from window memory, and to any programmatic resize. + +## Maximize + +Maximize targets the monitor's full rectangle minus the space reserved by +top, left and right layer-shell surfaces. A bottom-anchored dock is +deliberately not subtracted, so a maximized window runs to the bottom of the +screen and the dock floats over it - the usual behaviour for an auto-hiding +dock. Set `general.maximize_covers_dock = false` to have maximize stop above +the dock instead. + +A maximized window draws no border. Its edges are the screen's edges, so a +border has nothing to separate it from, and where maximize does stop short +(the strip a top bar reserves) a border would otherwise draw as a hard line +directly against the bar. + +## Where a window opens + +In order: a `geometry` window rule wins outright; otherwise a position and +size remembered for that `app_id` are restored; otherwise smart placement +picks a free grid cell, falling back to a cascade. + +A remembered position is clamped into the target monitor's *usable* area -- +the region left after bars and docks reserve their space. Without that +clamp, an app whose remembered `y` was smaller than the top bar's height +reopened with its titlebar underneath the bar, where it could not be +grabbed. A position remembered on a monitor that is no longer connected is +clamped onto a current monitor rather than discarded, so undocking does not +make an app forget where it lives. + +Dialogs are centred instead, and take neither a remembered position nor a +remembered size - window memory is keyed by `app_id`, which a dialog shares +with the window that spawned it. diff --git a/docs/TODO.md b/docs/TODO.md index 04ccbdb..d93f0d2 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,73 @@ # TODO / planned features - master checklist +## 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 +screenshot. The screenshot was measured rather than eyeballed: at 1920x1080, +the top bar occupies y=0..29, a 4px accent border sits at y=30..33, the +window's titlebar starts at y=34, the bottom border is at y=1027..1030, and +49px of bare desktop sits below it. + +**Windows spawning too close to the top bar - a real bug, and the reason it +was "sometimes".** A remembered position was validated only by asking whether +it landed on some monitor's `full_geometry`, which *includes* the strip a top +bar reserves. So an app whose remembered `y` was small reopened with its +titlebar underneath the bar, unreachable. It depended entirely on the stored +value, which is why it happened for some apps and not others: the live store +holds `org.wezfurlong.wezterm` at y=44 and `firefox` at y=69 against a 30px +bar. Remembered positions are now clamped into the monitor's *usable* area. + +**"Doesn't remember placement after logout" - half true, and the half that +failed is now fixed.** Window memory does persist (`~/.local/state/srd/ +window-memory.json`, written on close, drag-end and resize-end, loaded at +startup by both backends). But five of the eleven entries in the live store +were saved while a second monitor was connected, at x >= 2000. Those points +are on no current monitor, and the old code discarded them outright and fell +back to a fresh cascade - so those apps appeared to remember nothing at all. +A remembered position with no matching monitor is now clamped onto the +monitor the window would have been placed on anyway, keeping as much of the +remembered placement as still fits. + +**Per-window minimum sizes.** One global floor for every window is wrong in +both directions. Three sources now, in increasing precedence: the global +floor, the client's own declared minimum (`xdg_toplevel.set_min_size`, or +ICCCM size hints for XWayland), and a `min_width`/`min_height` window rule +that overrides both. A rule wins permanently - the backend refreshes the +client's declared minimum on every decoration redraw and must not quietly +undo a deliberate override, which is what `Window::min_size_from_rule` +guards. The minimum applies to interactive resize, to a size restored from +window memory, and to programmatic resizes. + +**Maximize.** Three separate faults in one report: + +- *Borders.* A maximized window now draws none. Its edges are the screen's + edges, so a border has nothing to separate it from - and the only place + maximize stops short is the bar strip, which is exactly where the measured + 4px line was. The left/right/bottom strips were falling off-screen, which + is why the screenshot showed a line under the bar and nowhere else. +- *Stopping above the dock.* `maximize_geometry_for` subtracted every + exclusive zone including a bottom-anchored dock's, which is the 49px band + in the screenshot. Bottom zones are now skipped, so maximize runs to the + bottom of the screen and the dock floats over it. Top/left/right are still + honoured, so a maximized window never hides the bar. + `general.maximize_covers_dock = false` restores the old behaviour. +- *Sitting too close to the bar.* With the border gone the window sits flush + under the bar rather than with an accent line crowding it. + +**Verification, stated plainly.** The placement and minimum-size work is +covered by seven new tests (269 core tests total) built on the real numbers +from the live store and a 30px bar. Maximize geometry was measured live in a +nested instance: a window maximized on a split half reports exactly that +half's rect. The border removal and the dock behaviour are **not** confirmed +on screen - the nested backend has no bar or dock to reserve a zone, so +there is nothing there to test against, and an attempt to check the border +produced a control that failed (`srd set border_width` only affects windows +created after the call, so neither the maximized nor the unmaximized window +had one). Confirming those two needs either the owner's own session or a +purpose-built layer-shell client for the nested one. + +515 tests pass, clippy clean. + ## A wrong "blocked" of my own, corrected: monitor split works in a nested compositor (2026-08-28) Written earlier the same day, as the reason the monitor-seam shadow fix could |