srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src/manager
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-08-25 14:49:00 +0200
committersrdusr <[email protected]>2026-08-25 14:49:00 +0200
commit8b3ef3c7b28570122126a1c8b3d6695fc3647557 (patch)
tree5e37511996bbaa891476a37906d621ab1912853f /crates/core/src/manager
parent807d0ba1680cdc1322bfe9584781416339f6f76d (diff)
downloadsrdwm-8b3ef3c7b28570122126a1c8b3d6695fc3647557.tar.gz
srdwm-8b3ef3c7b28570122126a1c8b3d6695fc3647557.zip
Move a window in steps, and let the keyboard resize one at all
Two of the owner's reported gaps, both about moving a window without a mouse. Super+Shift+HJKL slammed the window to the far edge of the monitor in one press: "it should move in increments - one side, middle, other side - not just extreme left/up/right/down". It now steps an eighth of the monitor per press, which crosses the screen in eight, passes through the middle at the fourth, and stops flush against the edge rather than short of it. A fraction rather than a pixel count, so the same key feels the same on a laptop panel and a 4K display. Only outside a tiling layout. There a window does not own its geometry -- stepping it would be undone by the next arrange - so the neighbour swap stays, and three tests that assumed swapping on a dynamic workspace now say which layout they mean. Keyboard resize did not exist. Super+right-drag has resized with the mouse for a while, which is why this went unnoticed, but nothing resized a window without one. `srd.window.resize("right", "grow"|"shrink")` grows or shrinks from the far edge in that direction, leaving the top-left where it is, and is bounded by the same minimums a drag is and by the monitor's usable area - a window can be resized neither to nothing nor off the screen, and a test holds each key down forty times to prove it. Wired into the owner's config: Super+Ctrl+arrows resize (the arrow says which way the far edge moves, so Right always widens), and Super+Ctrl+L locks the screen. Hyprland only ever bound the lock to XF86ScreenSaver, which most keyboards do not have; Super+L is the convention everywhere else but is already focus-right here. Ctrl+arrows rather than Ctrl+HJKL because Super+Ctrl+K is already kill-process. Verified through the real path in a nested compositor running that config: 89 bindings, all 89 described, the five new ones registered.
Diffstat (limited to 'crates/core/src/manager')
-rw-r--r--crates/core/src/manager/focus.rs87
-rw-r--r--crates/core/src/manager/tests.rs112
2 files changed, 197 insertions, 2 deletions
diff --git a/crates/core/src/manager/focus.rs b/crates/core/src/manager/focus.rs
index f1473cf..6defab6 100644
--- a/crates/core/src/manager/focus.rs
+++ b/crates/core/src/manager/focus.rs
@@ -149,8 +149,29 @@ impl WindowManager {
/// predictable either way. With no neighbour in that direction the
/// window is pushed to the corresponding edge of its monitor instead, so
/// the key still does something sensible.
+ /// How far one press moves a window, as a fraction of the monitor.
+ ///
+ /// An eighth crosses the screen in eight presses, which is fine
+ /// control without being tedious, and passes through the middle at the
+ /// fourth - the owner asked for "increments, or one side, middle,
+ /// other side", and stepping gives both.
+ const MOVE_STEP_FRACTION: i32 = 8;
+
pub fn move_window_direction(&mut self, dir: Direction) -> Option<WindowId> {
let focused = self.focused_id()?;
+ // Only a tiling layout swaps with the neighbour. Everywhere else a
+ // window has its own geometry and "move it left" means move it, not
+ // trade places with whatever happens to be over there.
+ let tiling = self
+ .windows
+ .get(&focused)
+ .and_then(|w| self.workspace(w.workspace))
+ .map(|ws| ws.layout == "tiling")
+ .unwrap_or(false);
+ if !tiling {
+ self.nudge_window(focused, dir);
+ return None;
+ }
match self.neighbour_in(dir) {
Some(other) => {
let a = self.windows.get(&focused)?.geometry;
@@ -184,4 +205,70 @@ impl WindowManager {
}
}
+ /// Grows or shrinks the focused window one step along `dir`.
+ ///
+ /// The keyboard counterpart to dragging an edge. srdwm has had
+ /// Super+right-drag since before this, which covers the mouse, but
+ /// nothing resized a window without one - reported as a missing
+ /// keybinding alongside screen lock.
+ ///
+ /// `grow` decides which way: growing extends the window's far edge in
+ /// `dir`, shrinking pulls it back, so Right always affects the right
+ /// edge whichever way it moves. The window's top-left stays put, which
+ /// is what every keyboard resize does and what keeps repeated presses
+ /// predictable.
+ ///
+ /// Bounded by the same minimums a drag is, and by the monitor's usable
+ /// area, so a window can neither be resized to nothing nor grown off
+ /// the screen.
+ pub fn resize_window_direction(&mut self, dir: Direction, grow: bool) -> Option<WindowId> {
+ let focused = self.focused_id()?;
+ let area = self.windows.get(&focused).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.geometry)?;
+ let step_x = (area.width as i32 / Self::MOVE_STEP_FRACTION).max(1);
+ let step_y = (area.height as i32 / Self::MOVE_STEP_FRACTION).max(1);
+ let w = self.windows.get_mut(&focused)?;
+ let (min_w, min_h) = (w.min_size.0.max(crate::placement::MIN_WINDOW_WIDTH) as i32, w.min_size.1.max(crate::placement::MIN_WINDOW_HEIGHT) as i32);
+ let delta_x = if grow { step_x } else { -step_x };
+ let delta_y = if grow { step_y } else { -step_y };
+ match dir {
+ Direction::Left | Direction::Right => {
+ let room = area.right() - w.geometry.x;
+ w.geometry.width = (w.geometry.width as i32 + delta_x).clamp(min_w, room.max(min_w)) as u32;
+ }
+ Direction::Up | Direction::Down => {
+ let room = area.bottom() - w.geometry.y;
+ w.geometry.height = (w.geometry.height as i32 + delta_y).clamp(min_h, room.max(min_h)) as u32;
+ }
+ }
+ Some(focused)
+ }
+
+ /// Moves a window one step in `dir`, stopping flush against the edge of
+ /// its monitor's usable area.
+ ///
+ /// This is what a directional move does outside a tiling layout. It used
+ /// to slam the window all the way to the edge in one press, which the
+ /// owner reported as "it should move in increments, one side, middle,
+ /// other side - not just extreme left/up/right/down".
+ ///
+ /// The step is a fraction of the monitor rather than a pixel count, so
+ /// the same keypress feels the same on a laptop panel and on a 4K
+ /// display. Landing within one step of an edge snaps flush to it, so the
+ /// window can always be put exactly against the side rather than a few
+ /// pixels short of it.
+ fn nudge_window(&mut self, id: WindowId, dir: Direction) {
+ let Some(area) = self.windows.get(&id).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.geometry) else { return };
+ let step_x = (area.width as i32 / Self::MOVE_STEP_FRACTION).max(1);
+ let step_y = (area.height as i32 / Self::MOVE_STEP_FRACTION).max(1);
+ let Some(w) = self.windows.get_mut(&id) else { return };
+ let (width, height) = (w.geometry.width as i32, w.geometry.height as i32);
+ let (min_x, max_x) = (area.x, (area.right() - width).max(area.x));
+ let (min_y, max_y) = (area.y, (area.bottom() - height).max(area.y));
+ match dir {
+ Direction::Left => w.geometry.x = (w.geometry.x - step_x).max(min_x),
+ Direction::Right => w.geometry.x = (w.geometry.x + step_x).min(max_x),
+ Direction::Up => w.geometry.y = (w.geometry.y - step_y).max(min_y),
+ Direction::Down => w.geometry.y = (w.geometry.y + step_y).min(max_y),
+ }
+ }
}
diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs
index b5a54c0..0cb7782 100644
--- a/crates/core/src/manager/tests.rs
+++ b/crates/core/src/manager/tests.rs
@@ -116,6 +116,98 @@
assert!(!win.size_is_provisional, "a deliberately remembered size must never be second-guessed by the client's own default");
}
+ /// "Super+Shift+H should move the window in increments, one side,
+ /// middle, other side - not just to the extreme left."
+ #[test]
+ fn a_directional_move_steps_rather_than_slamming_to_the_edge() {
+ let mut wm = wm_with_monitor();
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "w");
+ w.geometry = Rect::new(800, 400, 400, 300);
+ wm.add_window(w);
+ wm.focus_window(id);
+ wm.window_mut(id).unwrap().geometry = Rect::new(800, 400, 400, 300);
+
+ wm.move_window_direction(Direction::Left);
+ let after_one = wm.window(id).unwrap().geometry.x;
+ assert!(after_one < 800, "did not move");
+ assert!(after_one > 0, "slammed to the edge in one press: {after_one}");
+
+ // Repeated presses keep walking it across, and it stops flush.
+ for _ in 0..20 {
+ wm.move_window_direction(Direction::Left);
+ }
+ assert_eq!(wm.window(id).unwrap().geometry.x, 0, "should end flush against the left edge");
+ }
+
+ /// Keyboard resize: the missing half of "we are missing keybinds like
+ /// screen lock, resize windows".
+ #[test]
+ fn keyboard_resize_grows_and_shrinks_from_the_far_edge() {
+ let mut wm = wm_with_monitor();
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "w"));
+ wm.focus_window(id);
+ wm.window_mut(id).unwrap().geometry = Rect::new(100, 100, 600, 400);
+
+ wm.resize_window_direction(Direction::Right, true);
+ let grown = wm.window(id).unwrap().geometry;
+ assert!(grown.width > 600, "did not grow: {grown:?}");
+ assert_eq!((grown.x, grown.y), (100, 100), "the top-left must not move");
+
+ wm.resize_window_direction(Direction::Right, false);
+ assert_eq!(wm.window(id).unwrap().geometry.width, 600, "shrink should undo the grow");
+ }
+
+ /// A window can be resized neither to nothing nor off the screen.
+ #[test]
+ fn keyboard_resize_stays_within_its_limits() {
+ let mut wm = wm_with_monitor();
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "w"));
+ wm.focus_window(id);
+ wm.window_mut(id).unwrap().geometry = Rect::new(100, 100, 600, 400);
+ let area = wm.monitors()[0].geometry;
+
+ for _ in 0..40 {
+ wm.resize_window_direction(Direction::Right, true);
+ wm.resize_window_direction(Direction::Down, true);
+ }
+ let big = wm.window(id).unwrap().geometry;
+ assert!(big.right() <= area.right(), "grew off the right: {big:?}");
+ assert!(big.bottom() <= area.bottom(), "grew off the bottom: {big:?}");
+
+ for _ in 0..40 {
+ wm.resize_window_direction(Direction::Right, false);
+ wm.resize_window_direction(Direction::Down, false);
+ }
+ let small = wm.window(id).unwrap().geometry;
+ assert!(small.width >= crate::placement::MIN_WINDOW_WIDTH, "shrank below the minimum width");
+ assert!(small.height >= crate::placement::MIN_WINDOW_HEIGHT, "shrank below the minimum height");
+ }
+
+ /// A tiling workspace still swaps with the neighbour: there a window
+ /// does not own its own geometry, and stepping it would be undone by
+ /// the next arrange.
+ #[test]
+ fn a_directional_move_still_swaps_on_a_tiling_workspace() {
+ let mut wm = wm_with_monitor();
+ let ws = wm.current_workspace();
+ wm.set_layout(ws, "tiling");
+ let left = wm.alloc_window_id();
+ wm.add_window(Window::new(left, "left"));
+ let right = wm.alloc_window_id();
+ wm.add_window(Window::new(right, "right"));
+ // Side by side, so "the window to the left" is unambiguous.
+ wm.window_mut(left).unwrap().geometry = Rect::new(0, 0, 900, 1000);
+ wm.window_mut(right).unwrap().geometry = Rect::new(960, 0, 900, 1000);
+ wm.focus_window(right);
+
+ assert_eq!(wm.move_window_direction(Direction::Left), Some(left), "should have swapped with its neighbour");
+ assert_eq!(wm.window(right).unwrap().geometry.x, 0, "the two should have traded places");
+ assert_eq!(wm.window(left).unwrap().geometry.x, 960);
+ }
+
/// A client that accepts the size placement assumed must not be moved
/// again: re-placing would consume another cascade step for nothing,
/// and the cascade wraps - measured, two windows landing on exactly
@@ -2343,6 +2435,11 @@
#[test]
fn moving_a_window_swaps_it_with_its_neighbour() {
let mut wm = wm_with_monitor();
+ // Swapping is tiling behaviour. On a dynamic workspace a window owns
+ // its own geometry and "move it right" moves it - see
+ // `a_directional_move_steps_rather_than_slamming_to_the_edge`.
+ let ws = wm.current_workspace();
+ wm.set_layout(ws, "tiling");
let left = wm.alloc_window_id();
let mut a = Window::new(left, "left");
a.geometry = Rect::new(0, 0, 400, 400);
@@ -2364,7 +2461,7 @@
}
#[test]
- fn moving_with_no_neighbour_pushes_to_the_monitor_edge() {
+ fn moving_with_no_neighbour_walks_to_the_monitor_edge() {
let mut wm = wm_with_monitor();
let id = wm.alloc_window_id();
let mut w = Window::new(id, "only");
@@ -2373,10 +2470,21 @@
wm.window_mut(id).unwrap().geometry = Rect::new(500, 300, 200, 150);
wm.focus_window(id);
+ // One press moves one step, not all the way: the owner asked for
+ // increments rather than "just extreme left/up/right/down".
assert_eq!(wm.move_window_direction(Direction::Left), None);
+ let stepped = wm.window(id).unwrap().geometry.x;
+ assert!(stepped < 500 && stepped > 0, "should have stepped, not slammed: {stepped}");
+
+ // Held down, it still ends flush against the edge.
+ for _ in 0..20 {
+ wm.move_window_direction(Direction::Left);
+ }
assert_eq!(wm.window(id).unwrap().geometry.x, 0, "should hug the left edge");
- wm.move_window_direction(Direction::Down);
+ for _ in 0..20 {
+ wm.move_window_direction(Direction::Down);
+ }
let g = wm.window(id).unwrap().geometry;
let mon = wm.primary_monitor().unwrap().geometry;
assert_eq!(g.bottom(), mon.bottom(), "should hug the bottom edge");