diff options
Diffstat (limited to 'crates/core/src')
| -rw-r--r-- | crates/core/src/manager/focus.rs | 87 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 112 |
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"); |