Open floating windows on the focused output

This commit is contained in:
Samuel Onoja committed 2026-10-23 01:17:20 +01:00
1 parent 1b51ba83b4
commit 1a484adead
4 files changed
+188 -24

No files matched your search

+54 -7
View File
@@ -70,15 +70,29 @@ pub(crate) fn dispatch(events: &mut EventLoop<'static, Ferese>, state: &mut Fere
} }
pub(crate) fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 { pub(crate) fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 {
ack_configure_with_size(wire, xdg, None).0
}
fn ack_configure_with_size(
wire: &mut UnixStream,
xdg: u32,
toplevel: Option<u32>,
) -> (u32, Option<(u32, u32)>) {
wire.set_nonblocking(true).unwrap(); wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new(); let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes); let _ = wire.read_to_end(&mut bytes);
wire.set_nonblocking(false).unwrap(); wire.set_nonblocking(false).unwrap();
let mut serial = None; let mut serial = None;
let mut size = None;
let mut offset = 0; let mut offset = 0;
while offset < bytes.len() { while offset < bytes.len() {
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap()); let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()); let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
if Some(object) == toplevel && header & 0xffff == 0 {
let width = u32::from_ne_bytes(bytes[offset + 8..offset + 12].try_into().unwrap());
let height = u32::from_ne_bytes(bytes[offset + 12..offset + 16].try_into().unwrap());
size = Some((width, height));
}
if object == xdg && header & 0xffff == 0 { if object == xdg && header & 0xffff == 0 {
serial = Some(u32::from_ne_bytes( serial = Some(u32::from_ne_bytes(
bytes[offset + 8..offset + 12].try_into().unwrap(), bytes[offset + 8..offset + 12].try_into().unwrap(),
@@ -88,13 +102,22 @@ pub(crate) fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 {
} }
assert!(serial.is_some(), "missing configure for {xdg}: {bytes:?}"); assert!(serial.is_some(), "missing configure for {xdg}: {bytes:?}");
request(wire, xdg, 4, &[serial.unwrap()], None); request(wire, xdg, 4, &[serial.unwrap()], None);
serial.unwrap() (serial.unwrap(), size)
} }
pub(crate) fn window( pub(crate) fn window(
state: &mut Ferese, state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>, events: &mut EventLoop<'static, Ferese>,
color: u32, color: u32,
) -> (Window, UnixStream) {
window_with_app_id(state, events, color, "")
}
pub(crate) fn window_with_app_id(
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
color: u32,
app_id: &str,
) -> (Window, UnixStream) { ) -> (Window, UnixStream) {
let (server, mut wire) = UnixStream::pair().unwrap(); let (server, mut wire) = UnixStream::pair().unwrap();
let client = state let client = state
@@ -125,23 +148,47 @@ pub(crate) fn window(
request(&mut wire, compositor.id().protocol_id(), 0, &[2], None); request(&mut wire, compositor.id().protocol_id(), 0, &[2], None);
request(&mut wire, shell.id().protocol_id(), 2, &[3, 2], None); request(&mut wire, shell.id().protocol_id(), 2, &[3, 2], None);
request(&mut wire, 3, 1, &[4], None); request(&mut wire, 3, 1, &[4], None);
if !app_id.is_empty() {
let mut bytes = app_id.as_bytes().to_vec();
bytes.push(0);
let mut args = vec![bytes.len() as u32];
bytes.resize(bytes.len().next_multiple_of(4), 0);
args.extend(
bytes
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
);
request(&mut wire, 4, 3, &args, None);
}
request(&mut wire, 2, 6, &[], None); request(&mut wire, 2, 6, &[], None);
dispatch(events, state); dispatch(events, state);
ack_configure(&mut wire, 3); let (_, configured) = ack_configure_with_size(&mut wire, 3, Some(4));
// Application fixtures honor the first configured size, as Settings does.
// Existing capture fixtures keep their fixed 64x48 buffer.
let (width, height) = if app_id.is_empty() {
(64, 48)
} else {
let (width, height) = configured.expect("initial toplevel configure");
(
if width > 0 { width } else { 64 },
if height > 0 { height } else { 48 },
)
};
let buffer_size = width * height * 4;
let mut file = tempfile::tempfile().unwrap(); let mut file = tempfile::tempfile().unwrap();
file.write_all(&color.to_ne_bytes().repeat(64 * 48)) file.write_all(&color.to_ne_bytes().repeat((width * height) as usize))
.unwrap(); .unwrap();
request( request(
&mut wire, &mut wire,
shm.id().protocol_id(), shm.id().protocol_id(),
0, 0,
&[5, 64 * 48 * 4], &[5, buffer_size],
Some(file.as_raw_fd()), Some(file.as_raw_fd()),
); );
request(&mut wire, 5, 0, &[6, 0, 64, 48, 64 * 4, 0], None); request(&mut wire, 5, 0, &[6, 0, width, height, width * 4, 0], None);
request(&mut wire, 3, 3, &[0, 0, 64, 48], None); request(&mut wire, 3, 3, &[0, 0, width, height], None);
request(&mut wire, 2, 1, &[6, 0, 0], None); request(&mut wire, 2, 1, &[6, 0, 0], None);
request(&mut wire, 2, 2, &[0, 0, 64, 48], None); request(&mut wire, 2, 2, &[0, 0, width, height], None);
request(&mut wire, 2, 6, &[], None); request(&mut wire, 2, 6, &[], None);
dispatch(events, state); dispatch(events, state);
let window = state let window = state
@@ -359,6 +359,119 @@ fn floating_placement_reserves_the_full_opening_window_footprint() {
assert!(crate::floating::intersection(placed, logical).is_none()); assert!(crate::floating::intersection(placed, logical).is_none());
} }
fn floating_launch_fixture() -> (
smithay::reexports::calloop::EventLoop<'static, Ferese>,
Ferese,
Output,
Output,
) {
let (events, mut state, first) = fixture();
let second = Output::new("second".into(), first.physical_properties());
second.change_current_state(first.current_mode(), None, None, None);
state.space.map_output(&second, (800, 0));
state.register_output(&second, "second".into());
state.window_rules = crate::config::Config::parse_source(
"window-rule app-id=dev.ferese.Settings floating=#true\n",
)
.unwrap()
.window_rules()
.unwrap();
(events, state, first, second)
}
#[test]
fn floating_launch_keeps_keyboard_focused_output_when_pointer_is_elsewhere() {
if !crate::startup_tests::private_runtime(
"state::window_lifecycle_tests::floating_launch_keeps_keyboard_focused_output_when_pointer_is_elsewhere",
) {
return;
}
let (mut events, mut state, first, second) = floating_launch_fixture();
let pointer = state.seat.get_pointer().unwrap();
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (100., 100.).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(1),
},
);
let output_id = state.output_ids[&second];
let destination = state.output_workspaces.active_workspace(output_id).unwrap();
state.activate_output_workspace(output_id, destination);
assert_eq!(pointer.current_location(), (100., 100.).into());
assert_ne!(state.output_ids[&first], output_id);
let (window, _wire) = super::capture_privacy::tests::window_with_app_id(
&mut state,
&mut events,
0xff112233,
"dev.ferese.Settings",
);
let id = state.windows.ids()[&window];
assert_eq!(state.output_workspaces.focused_output(), Some(output_id));
assert_eq!(state.workspaces.workspace_for_window(id), Some(destination));
let bounds = state.output_bounds_for(&second).unwrap();
let rect = state.windows.geometry(&id).unwrap().logical;
assert_eq!(crate::floating::intersection(rect, bounds), Some(rect));
}
#[test]
fn floating_launch_restores_geometry_only_on_the_focused_output() {
if !crate::startup_tests::private_runtime(
"state::window_lifecycle_tests::floating_launch_restores_geometry_only_on_the_focused_output",
) {
return;
}
for restore_on_focused_output in [false, true] {
let (mut events, mut state, first, second) = floating_launch_fixture();
let saved_output = if restore_on_focused_output {
&second
} else {
&first
};
let saved_work = state.output_bounds_for(saved_output).unwrap();
let saved = Rect::new(saved_work.x + 100., saved_work.y + 80., 240., 160.);
state.floating_memory.save(
"dev.ferese.Settings".into(),
crate::floating::Remembered::new(saved_output.name(), saved, saved_work),
);
// Keep the pointer on the launch output to isolate saved-placement
// redirection from the separate keyboard-versus-pointer regression.
let pointer = state.seat.get_pointer().unwrap();
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (900., 100.).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(1),
},
);
state.focus_output_at(pointer.current_location());
let output_id = state.output_ids[&second];
let destination = state.output_workspaces.active_workspace(output_id).unwrap();
let (window, _wire) = super::capture_privacy::tests::window_with_app_id(
&mut state,
&mut events,
0xff112233,
"dev.ferese.Settings",
);
let id = state.windows.ids()[&window];
assert_eq!(state.output_workspaces.focused_output(), Some(output_id));
assert_eq!(state.workspaces.workspace_for_window(id), Some(destination));
let rect = state.windows.geometry(&id).unwrap().logical;
let bounds = state.output_bounds_for(&second).unwrap();
assert_eq!(crate::floating::intersection(rect, bounds), Some(rect));
if restore_on_focused_output {
assert_eq!(rect, saved);
} else {
assert_eq!((rect.width, rect.height), (64., 48.));
}
}
}
#[test] #[test]
fn keyboard_floating_move_and_resize_work_in_both_layouts() { fn keyboard_floating_move_and_resize_work_in_both_layouts() {
if !crate::startup_tests::private_runtime( if !crate::startup_tests::private_runtime(
+17 -14
View File
@@ -58,13 +58,17 @@ impl Ferese {
return; return;
} }
let bounds = self.output_bounds();
let outputs = self.floating_outputs(); let outputs = self.floating_outputs();
let Some((remembered, work)) = app_id let Some((remembered, work)) = app_id
.as_deref() .as_deref()
.and_then(|app| self.floating_memory.get(app)) .and_then(|app| self.floating_memory.get(app))
.and_then(|entry| { .and_then(|entry| {
let rect = entry.restore(&outputs)?; let rect = entry.restore(&outputs)?;
let work = outputs.iter().find(|(name, _)| *name == entry.output)?.1; let work = outputs
.iter()
.find(|(name, work)| *name == entry.output && Some(*work) == bounds)?
.1;
Some((rect, work)) Some((rect, work))
}) })
else { else {
@@ -106,10 +110,13 @@ impl Ferese {
return; return;
} }
if let Some(pointer) = self.seat.get_pointer() { let Some(bounds) = self.output_bounds() else {
self.focus_output_at(pointer.current_location()); self.add_tiled_window(window);
} return;
};
// Saved geometry may restore a position on this output, but must not
// redirect a new window away from the output the user focused.
let remembered_work = app_id let remembered_work = app_id
.filter(|app| !crate::window_rules::is_native_dialog(Some(app))) .filter(|app| !crate::window_rules::is_native_dialog(Some(app)))
.and_then(|app| self.floating_memory.get(app)) .and_then(|app| self.floating_memory.get(app))
@@ -118,18 +125,9 @@ impl Ferese {
entry.restore(&outputs)?; entry.restore(&outputs)?;
outputs outputs
.into_iter() .into_iter()
.find(|(name, _)| *name == entry.output) .find(|(name, work)| *name == entry.output && *work == bounds)
.map(|(_, work)| work) .map(|(_, work)| work)
}); });
if let Some(work) = remembered_work {
self.focus_output_at((work.x + work.width / 2.0, work.y + work.height / 2.0).into());
}
let Some(bounds) = self.output_bounds() else {
self.add_tiled_window(window);
return;
};
let id = self.windows.allocate_id(); let id = self.windows.allocate_id();
let size_rect = client_size(&window) let size_rect = client_size(&window)
.map(|size| natural_floating_rect(bounds, size)) .map(|size| natural_floating_rect(bounds, size))
@@ -434,6 +432,11 @@ impl Ferese {
.0 .0
.as_deref() .as_deref()
.and_then(|app| self.floating_memory.get(app)) .and_then(|app| self.floating_memory.get(app))
.filter(|entry| {
self.floating_outputs()
.iter()
.any(|(name, work)| *name == entry.output && *work == bounds)
})
.and_then(|entry| entry.restore(&self.floating_outputs())) .and_then(|entry| entry.restore(&self.floating_outputs()))
.is_none() .is_none()
{ {
+4 -3
View File
@@ -52,9 +52,10 @@ an oversized window keeps its size with its top-left corner reachable.
Successful move/resize completion saves ordinary floating geometry by app ID in Successful move/resize completion saves ordinary floating geometry by app ID in
`$XDG_STATE_HOME/ferese/floating.json` (normally `~/.local/state/ferese/floating.json`). `$XDG_STATE_HOME/ferese/floating.json` (normally `~/.local/state/ferese/floating.json`).
Geometry is stored with the output name and as fractions of its work area, so Geometry is stored with the output name and as fractions of its work area, so
placement survives resolution changes. Restores are skipped if the output is placement survives resolution changes. New independent windows open on the
missing or less than 25% of the window would be visible. A worker saves the focused output; saved geometry applies only when it belongs to that output and
latest snapshot, combining pending updates. Completing a drag updates memory at least 25% of the window would be visible. A worker saves the latest snapshot,
combining pending updates. Completing a drag updates memory
immediately without waiting for storage. immediately without waiting for storage.
Transient dialogs and cancelled drags do not update this memory. Floating → tiled Transient dialogs and cancelled drags do not update this memory. Floating → tiled
→ floating also restores the window's last floating geometry. Nested previews → floating also restores the window's last floating geometry. Nested previews