srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-05-11 16:57:00 +0200
committersrdusr <[email protected]>2026-05-11 16:57:00 +0200
commit646b37e7e3aa5931079c6b9e804f420bb43c74d5 (patch)
tree159825fae0086c162b36edc6e78da07d975ab6b0
parentcaec1e7c355bbe6437afe87cd3dab6b64fb91e0a (diff)
downloadsrdwm-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.rs18
-rw-r--r--crates/config/src/engine/support.rs3
-rw-r--r--crates/core/src/lib.rs2
-rw-r--r--crates/core/src/manager/dragresize.rs7
-rw-r--r--crates/core/src/manager/mod.rs6
-rw-r--r--crates/core/src/manager/tests.rs115
-rw-r--r--crates/core/src/manager/windows.rs44
-rw-r--r--crates/core/src/manager/winops.rs4
-rw-r--r--crates/core/src/rules.rs5
-rw-r--r--crates/core/src/window.rs21
-rw-r--r--crates/srdwm/src/main.rs4
-rw-r--r--crates/wayland/src/input/layers.rs12
-rw-r--r--crates/wayland/src/state/lifecycle.rs20
-rw-r--r--crates/wayland/src/udev/platform.rs2
-rw-r--r--crates/wayland/src/udev/render.rs11
-rw-r--r--crates/wayland/src/winit/nested_platform.rs4
-rw-r--r--crates/wayland/src/winit/render.rs11
-rw-r--r--docs/DEFAULTS.md54
-rw-r--r--docs/TODO.md68
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