diff --git a/compositor/src/state/floating.rs b/compositor/src/state/floating.rs index cf781d3..50d43cc 100644 --- a/compositor/src/state/floating.rs +++ b/compositor/src/state/floating.rs @@ -1,6 +1,57 @@ use super::*; impl Ferese { + pub(super) fn adjust_floating_direction(&mut self, id: WindowId, direction: Direction, resize: bool) { + const STEP: f64 = 32.0; + let workspace = self.workspaces.active(); + if self.session_lock.active() + || self.input_capture.captures(1) + || self.workspaces.workspace_for_window(id) != Some(workspace.id) + || workspace.fullscreen == Some(id) + || self.windows.record(id).is_some_and(|record| record.maximized) + { + return; + } + let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id) else { + return; + }; + let Some(work) = self.floating_bounds_for_window(id) else { + return; + }; + let mut destination = rect; + match (resize, direction) { + (false, Direction::Left) => destination.x -= STEP, + (false, Direction::Right) => destination.x += STEP, + (false, Direction::Up) => destination.y -= STEP, + (false, Direction::Down) => destination.y += STEP, + (true, Direction::Left) => destination.width -= STEP, + (true, Direction::Right) => destination.width += STEP, + (true, Direction::Up) => destination.height -= STEP, + (true, Direction::Down) => destination.height += STEP, + } + if resize { + destination = constrained_floating_rect( + destination, + self.window_constraints().get(&id).copied().unwrap_or_default(), + ); + } + destination = crate::floating::clamp_to_work(destination, work); + if destination == rect { + return; + } + if let Err(error) = self.workspaces.set_floating_rect(id, destination) { + tracing::error!(%error, ?id, "failed to adjust floating window geometry"); + return; + } + self.windows.update(id, |record| { + record.resize_anchor = resize.then_some((false, false, destination)) + }); + self.relayout_window(id); + if let Some(window) = self.windows.window(id).cloned() { + self.remember_floating(&window); + } + } + pub fn toggle_focused_floating(&mut self) { let Some(window) = self.focused_window else { return; diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index 48d0e58..ea3829b 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -197,6 +197,13 @@ impl Ferese { let Some(current) = self.focused_window else { return; }; + if matches!( + self.workspaces.placement(current), + Some(WindowPlacement::Floating { .. }) + ) { + self.adjust_floating_direction(current, direction, false); + return; + } let Some(bounds) = self.output_bounds() else { return; }; @@ -220,6 +227,14 @@ impl Ferese { return; }; + if matches!( + self.workspaces.placement(current), + Some(WindowPlacement::Floating { .. }) + ) { + self.adjust_floating_direction(current, direction, true); + return; + } + match self .workspaces .active_mut() diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index 6cb4481..dae713c 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -31,6 +31,188 @@ fn fixture() -> (smithay::reexports::calloop::EventLoop<'static, Ferese>, Ferese (events, state, output) } +#[test] +fn keyboard_floating_move_and_resize_work_in_both_layouts() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::keyboard_floating_move_and_resize_work_in_both_layouts", + ) { + return; + } + let (mut events, mut state, _) = fixture(); + let (window, _wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.toggle_focused_floating(); + let initial = Rect::new(120., 100., 160., 128.); + let workspace = state.workspaces.active_id(); + for mode in [LayoutMode::Scrolling, LayoutMode::Tree] { + state + .workspaces + .set_active_layout_mode(mode, state.output_bounds().unwrap()) + .unwrap(); + for (resize, direction, expected) in [ + (false, Direction::Left, Rect::new(88., 100., 160., 128.)), + (false, Direction::Right, Rect::new(152., 100., 160., 128.)), + (false, Direction::Up, Rect::new(120., 68., 160., 128.)), + (false, Direction::Down, Rect::new(120., 132., 160., 128.)), + (true, Direction::Left, Rect::new(120., 100., 128., 128.)), + (true, Direction::Right, Rect::new(120., 100., 192., 128.)), + (true, Direction::Up, Rect::new(120., 100., 160., 96.)), + (true, Direction::Down, Rect::new(120., 100., 160., 160.)), + ] { + state.workspaces.set_floating_rect(id, initial).unwrap(); + state.relayout_window(id); + if resize { + state.resize_direction(direction); + } else { + state.move_direction(direction); + } + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { rect: expected }) + ); + assert_eq!(state.windows.geometry(&id).unwrap().logical, expected); + assert_eq!(state.focused_window, Some(id)); + assert_eq!(state.workspaces.active_id(), workspace); + assert_eq!(state.workspaces.workspace_for_window(id), Some(workspace)); + } + } +} + +#[test] +fn keyboard_floating_geometry_stays_reachable_and_respects_client_limits() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::keyboard_floating_geometry_stays_reachable_and_respects_client_limits", + ) { + return; + } + let (mut events, mut state, _) = fixture(); + let (window, mut wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.toggle_focused_floating(); + request(&mut wire, 4, 8, &[100, 80], None); + request(&mut wire, 4, 7, &[200, 160], None); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + state + .workspaces + .set_floating_rect(id, Rect::new(630., 470., 160., 128.)) + .unwrap(); + state.relayout_window(id); + for _ in 0..5 { + state.move_direction(Direction::Right); + state.move_direction(Direction::Down); + state.resize_direction(Direction::Right); + state.resize_direction(Direction::Down); + } + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { + rect: Rect::new(600., 440., 200., 160.), + }) + ); + for _ in 0..30 { + state.move_direction(Direction::Left); + state.move_direction(Direction::Up); + state.resize_direction(Direction::Left); + state.resize_direction(Direction::Up); + } + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { + rect: Rect::new(0., 0., 100., 80.), + }) + ); + request(&mut wire, 4, 7, &[0, 0], None); + request(&mut wire, 4, 8, &[900, 700], None); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + state.resize_direction(Direction::Left); + state.resize_direction(Direction::Up); + state.move_direction(Direction::Right); + state.move_direction(Direction::Down); + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { + rect: Rect::new(0., 0., 900., 700.), + }) + ); +} + +#[test] +fn keyboard_floating_actions_preserve_maximized_and_fullscreen_restore_geometry() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::keyboard_floating_actions_preserve_maximized_and_fullscreen_restore_geometry", + ) { + return; + } + let (mut events, mut state, _) = fixture(); + let (window, _wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.toggle_focused_floating(); + let normal = state.workspaces.placement(id); + for fullscreen in [false, true] { + if fullscreen { + state.set_window_fullscreen(id, true); + } else { + state.set_window_maximized(id, true); + } + let geometry = state.windows.geometry(&id).unwrap().logical; + for direction in [Direction::Left, Direction::Right, Direction::Up, Direction::Down] { + state.move_direction(direction); + state.resize_direction(direction); + assert_eq!(state.workspaces.placement(id), normal); + assert_eq!(state.windows.geometry(&id).unwrap().logical, geometry); + } + if fullscreen { + state.set_window_fullscreen(id, false); + } else { + state.set_window_maximized(id, false); + } + let WindowPlacement::Floating { rect } = normal.unwrap() else { + panic!("floating window") + }; + assert_eq!(state.windows.geometry(&id).unwrap().logical, rect); + } + state.focused_window = None; + state.move_direction(Direction::Right); + state.resize_direction(Direction::Down); + assert_eq!(state.workspaces.placement(id), normal); +} + +#[test] +fn keyboard_floating_resize_reuses_the_client_barrier_and_move_keeps_it_pending() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::keyboard_floating_resize_reuses_the_client_barrier_and_move_keeps_it_pending", + ) { + return; + } + let (mut events, mut state, _) = fixture(); + let (window, _wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.toggle_focused_floating(); + state.set_floating_window_geometry(&window, (120, 100).into(), (64, 48).into()); + state.animations_enabled = true; + let before = state.windows.geometry(&id).unwrap().visual.current; + let client = state.windows.geometry(&id).unwrap().client.committed_size; + state.resize_direction(Direction::Right); + let transaction = *state.windows.transaction(&id).expect("existing resize barrier"); + assert_eq!(transaction.source_geometry(), Some(window.geometry())); + assert_eq!(state.windows.geometry(&id).unwrap().client.committed_size, client); + assert_eq!(state.windows.geometry(&id).unwrap().visual.current, before); + assert_eq!( + state.windows.geometry(&id).unwrap().logical, + Rect::new(120., 100., 96., 48.) + ); + state.move_direction(Direction::Right); + assert_eq!(state.windows.transaction(&id).unwrap().serial(), transaction.serial()); + assert_eq!(state.windows.geometry(&id).unwrap().client.committed_size, client); + assert_eq!(state.windows.geometry(&id).unwrap().visual.current, before); + assert_eq!( + state.windows.geometry(&id).unwrap().logical, + Rect::new(152., 100., 96., 48.) + ); +} + #[test] fn late_keymap_failure_preserves_capture_sessions_and_runtime_settings() { if !crate::startup_tests::private_runtime( diff --git a/docs/configuration.md b/docs/configuration.md index 4fad550..a007ce0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -551,7 +551,13 @@ selects an edge or corner using a 3×3 grid; the center selects the nearest corn The selected edges stay fixed for the whole drag. These work even when an app has no title bar or resize border. -Moving and resizing snap to work-area edges and nearby visible window edges at +For a floating window, Super+Shift+H/J/K/L moves left/down/up/right by 32 logical +pixels. Super+Ctrl+H/L shrinks/grows its width; Super+Ctrl+K/J shrinks/grows its +height by the same step. Keyboard adjustments respect client size limits and +keep the window reachable in its output's work area. Fullscreen and maximized +floating windows ignore these actions. + +Mouse moves and resizes snap to work-area edges and nearby visible window edges at 10 logical pixels, then resist movement until 20 pixels from the attached edge. Window edges are eligible only when the windows overlap along the other axis, with a 10-pixel allowance. Hold Shift during a drag to bypass snapping. diff --git a/docs/ipc.md b/docs/ipc.md index 0ab0e30..8465238 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -45,6 +45,12 @@ feresectl focus-floating tiled/floating layer on the active workspace. It does nothing when that layer has no candidate. `toggle-floating` changes placement; `focus-floating` changes focus. +`feresectl move left|right|up|down` and `feresectl resize left|right|up|down` +also handle floating windows. Moves use 32 logical pixels. Resizing shrinks +width/height with left/up and grows them with right/down by 32 logical pixels, +subject to client size limits. Fullscreen and maximized floating windows ignore +these actions. + ## Choose a session Every feresectl IPC command, including theme commands and subscriptions, resolves