Support keyboard movement and resizing for floating windows

This commit is contained in:
Samuel Onoja committed 2026-10-07 08:19:33 +01:00
1 parent 76112b59ac
commit 4a9880eaad
5 files changed
+261 -1

No files matched your search

+51
View File
@@ -1,6 +1,57 @@
use super::*; use super::*;
impl Ferese { 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) { pub fn toggle_focused_floating(&mut self) {
let Some(window) = self.focused_window else { let Some(window) = self.focused_window else {
return; return;
+15
View File
@@ -197,6 +197,13 @@ impl Ferese {
let Some(current) = self.focused_window else { let Some(current) = self.focused_window else {
return; 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 { let Some(bounds) = self.output_bounds() else {
return; return;
}; };
@@ -220,6 +227,14 @@ impl Ferese {
return; return;
}; };
if matches!(
self.workspaces.placement(current),
Some(WindowPlacement::Floating { .. })
) {
self.adjust_floating_direction(current, direction, true);
return;
}
match self match self
.workspaces .workspaces
.active_mut() .active_mut()
@@ -31,6 +31,188 @@ fn fixture() -> (smithay::reexports::calloop::EventLoop<'static, Ferese>, Ferese
(events, state, output) (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] #[test]
fn late_keymap_failure_preserves_capture_sessions_and_runtime_settings() { fn late_keymap_failure_preserves_capture_sessions_and_runtime_settings() {
if !crate::startup_tests::private_runtime( if !crate::startup_tests::private_runtime(
+7 -1
View File
@@ -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 The selected edges stay fixed for the whole drag. These work even when an app has
no title bar or resize border. 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. 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, 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. with a 10-pixel allowance. Hold Shift during a drag to bypass snapping.
+6
View File
@@ -45,6 +45,12 @@ feresectl focus-floating
tiled/floating layer on the active workspace. It does nothing when that layer tiled/floating layer on the active workspace. It does nothing when that layer
has no candidate. `toggle-floating` changes placement; `focus-floating` changes focus. 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 ## Choose a session
Every feresectl IPC command, including theme commands and subscriptions, resolves Every feresectl IPC command, including theme commands and subscriptions, resolves