srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/config/src/engine/register.rs1
-rw-r--r--crates/config/src/engine/window.rs20
-rw-r--r--crates/core/src/manager/focus.rs87
-rw-r--r--crates/core/src/manager/tests.rs112
-rw-r--r--crates/platform/src/ipc/tests.rs8
5 files changed, 226 insertions, 2 deletions
diff --git a/crates/config/src/engine/register.rs b/crates/config/src/engine/register.rs
index 77aede9..1bbe706 100644
--- a/crates/config/src/engine/register.rs
+++ b/crates/config/src/engine/register.rs
@@ -41,6 +41,7 @@ impl Engine {
window.set("toggle_pin", self.fn_window_action(WindowAction::TogglePin)?)?;
window.set("focus", self.fn_window_focus_direction()?)?;
window.set("move", self.fn_window_move_direction()?)?;
+ window.set("resize", self.fn_window_resize_direction()?)?;
window.set("next", self.fn_window_cycle(true)?)?;
window.set("prev", self.fn_window_cycle(false)?)?;
window.set("set_decorations", self.fn_window_set_decorations()?)?;
diff --git a/crates/config/src/engine/window.rs b/crates/config/src/engine/window.rs
index 61e7b89..7aa2085 100644
--- a/crates/config/src/engine/window.rs
+++ b/crates/config/src/engine/window.rs
@@ -66,6 +66,26 @@ impl Engine {
})?)
}
+ /// `srd.window.resize("right", "grow")` - grow or shrink the focused
+ /// window one step along that axis. The second argument defaults to
+ /// growing, so `srd.window.resize("right")` reads naturally.
+ pub(super) fn fn_window_resize_direction(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, (direction, mode): (String, Option<String>)| {
+ let dir = parse_direction(&direction, "srd.window.resize")?;
+ let grow = match mode.as_deref() {
+ None | Some("grow") => true,
+ Some("shrink") => false,
+ Some(other) => {
+ return Err(mlua::Error::RuntimeError(format!("srd.window.resize: expected \"grow\" or \"shrink\", got {other:?}")))
+ }
+ };
+ let wm = state.borrow().wm.clone();
+ wm.borrow_mut().resize_window_direction(dir, grow);
+ Ok(())
+ })?)
+ }
+
/// `srd.window.next()` / `srd.window.prev()` - cycle focus through the
/// windows on the current workspace (Hyprland's `cyclenext`).
pub(super) fn fn_window_cycle(&self, forward: bool) -> Result<mlua::Function<'_>> {
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");
diff --git a/crates/platform/src/ipc/tests.rs b/crates/platform/src/ipc/tests.rs
index 2e61761..d73cbc5 100644
--- a/crates/platform/src/ipc/tests.rs
+++ b/crates/platform/src/ipc/tests.rs
@@ -684,6 +684,14 @@ fn move_window_dispatch_swaps_with_the_neighbour_and_focuses_the_target_first()
let mut server = IpcServer::bind_in(dir.path(), "test").unwrap();
let wm = Rc::new(RefCell::new(WindowManager::new()));
wm.borrow_mut().set_monitors(vec![srdwm_core::Monitor::new(0, "primary", srdwm_core::Rect::new(0, 0, 1920, 1080))]);
+ // Swapping with the neighbour is tiling behaviour. On a dynamic
+ // workspace a directional move steps the window itself - see
+ // `WindowManager::nudge_window`.
+ {
+ let mut wm = wm.borrow_mut();
+ let ws = wm.current_workspace();
+ wm.set_layout(ws, "tiling");
+ }
let (a, b) = {
let mut wm = wm.borrow_mut();
let a = wm.alloc_window_id();