diff --git a/compositor/src/state/capture_privacy_tests.rs b/compositor/src/state/capture_privacy_tests.rs index a411621..c22a93e 100644 --- a/compositor/src/state/capture_privacy_tests.rs +++ b/compositor/src/state/capture_privacy_tests.rs @@ -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 { + ack_configure_with_size(wire, xdg, None).0 +} + +fn ack_configure_with_size( + wire: &mut UnixStream, + xdg: u32, + toplevel: Option, +) -> (u32, Option<(u32, u32)>) { wire.set_nonblocking(true).unwrap(); let mut bytes = Vec::new(); let _ = wire.read_to_end(&mut bytes); wire.set_nonblocking(false).unwrap(); let mut serial = None; + let mut size = None; let mut offset = 0; while offset < bytes.len() { 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()); + 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 { serial = Some(u32::from_ne_bytes( 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:?}"); request(wire, xdg, 4, &[serial.unwrap()], None); - serial.unwrap() + (serial.unwrap(), size) } pub(crate) fn window( state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, 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) { let (server, mut wire) = UnixStream::pair().unwrap(); let client = state @@ -125,23 +148,47 @@ pub(crate) fn window( request(&mut wire, compositor.id().protocol_id(), 0, &[2], None); request(&mut wire, shell.id().protocol_id(), 2, &[3, 2], 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); 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(); - file.write_all(&color.to_ne_bytes().repeat(64 * 48)) + file.write_all(&color.to_ne_bytes().repeat((width * height) as usize)) .unwrap(); request( &mut wire, shm.id().protocol_id(), 0, - &[5, 64 * 48 * 4], + &[5, buffer_size], Some(file.as_raw_fd()), ); - request(&mut wire, 5, 0, &[6, 0, 64, 48, 64 * 4, 0], None); - request(&mut wire, 3, 3, &[0, 0, 64, 48], None); + request(&mut wire, 5, 0, &[6, 0, width, height, width * 4, 0], None); + request(&mut wire, 3, 3, &[0, 0, width, height], 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); dispatch(events, state); let window = state diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index 8b631e9..4b688a4 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -359,6 +359,119 @@ fn floating_placement_reserves_the_full_opening_window_footprint() { 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] fn keyboard_floating_move_and_resize_work_in_both_layouts() { if !crate::startup_tests::private_runtime( diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index f5b3d0c..20bee34 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -58,13 +58,17 @@ impl Ferese { return; } + let bounds = self.output_bounds(); let outputs = self.floating_outputs(); let Some((remembered, work)) = app_id .as_deref() .and_then(|app| self.floating_memory.get(app)) .and_then(|entry| { 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)) }) else { @@ -106,10 +110,13 @@ impl Ferese { return; } - if let Some(pointer) = self.seat.get_pointer() { - self.focus_output_at(pointer.current_location()); - } + let Some(bounds) = self.output_bounds() else { + 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 .filter(|app| !crate::window_rules::is_native_dialog(Some(app))) .and_then(|app| self.floating_memory.get(app)) @@ -118,18 +125,9 @@ impl Ferese { entry.restore(&outputs)?; outputs .into_iter() - .find(|(name, _)| *name == entry.output) + .find(|(name, work)| *name == entry.output && *work == bounds) .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 size_rect = client_size(&window) .map(|size| natural_floating_rect(bounds, size)) @@ -434,6 +432,11 @@ impl Ferese { .0 .as_deref() .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())) .is_none() { diff --git a/docs/configuration/window-rules.md b/docs/configuration/window-rules.md index 0828699..d61791c 100644 --- a/docs/configuration/window-rules.md +++ b/docs/configuration/window-rules.md @@ -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 `$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 -placement survives resolution changes. Restores are skipped if the output is -missing or less than 25% of the window would be visible. A worker saves the -latest snapshot, combining pending updates. Completing a drag updates memory +placement survives resolution changes. New independent windows open on the +focused output; saved geometry applies only when it belongs to that output and +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. Transient dialogs and cancelled drags do not update this memory. Floating → tiled → floating also restores the window's last floating geometry. Nested previews