From 0947c4685f6237d4f8045482ce0c62feab40b6c4 Mon Sep 17 00:00:00 2001 From: En-En <39373446+En-En-Code@users.noreply.github.com> Date: Wed, 4 Feb 2026 01:19:49 +0000 Subject: [PATCH 01/10] feat: handle global removals, recalc output scale (#367) All `GlobalRemove` events sent from the server are now handled by recording them in a new clientside `vec` and passing the identifier returned by `create_global` (now stored by a map in the state) to `disable_global`. `handle_globals` (the top-level function) and `handle_new_globals` (the `InnerServerState` member function) have swapped names to better represent their new purposes. This enables action to be taken when globals are removed. In this case, the desired action is to forward output removal, so that the scaling calculation does not account for disconnected monitors in its logic. Resolves #351. --- src/server/clientside.rs | 27 +++++++++++------- src/server/dispatch.rs | 1 + src/server/mod.rs | 52 ++++++++++++++++++++++++++++------ src/server/tests.rs | 60 +++++++++++++++++++++++++++++++++++++++- tests/integration.rs | 4 +-- testwl/src/lib.rs | 42 ++++++++++++++++++++-------- 6 files changed, 153 insertions(+), 33 deletions(-) diff --git a/src/server/clientside.rs b/src/server/clientside.rs index 05c8cbe..664c909 100644 --- a/src/server/clientside.rs +++ b/src/server/clientside.rs @@ -1,6 +1,6 @@ use super::decoration::DecorationMarker; -use super::ObjectEvent; +use super::{GlobalName, ObjectEvent}; use hecs::{Entity, World}; use smithay_client_toolkit::{ activation::{ActivationHandler, RequestData, RequestDataExt}, @@ -115,6 +115,7 @@ pub(super) struct MyWorld { pub world: World, pub global_list: GlobalList, pub new_globals: Vec, + pub removed_globals: Vec, events: Vec<(Entity, ObjectEvent)>, queued_events: Vec>, pub clipboard: SelectionEvents, @@ -128,6 +129,7 @@ impl MyWorld { world: World::new(), global_list, new_globals: Vec::new(), + removed_globals: Vec::new(), events: Vec::new(), queued_events: Vec::new(), clipboard: Default::default(), @@ -204,18 +206,23 @@ impl Dispatch for MyWorld { _: &wayland_client::Connection, _: &wayland_client::QueueHandle, ) { - if let Event::::Global { - name, - interface, - version, - } = event - { - state.new_globals.push(Global { + match event { + Event::::Global { name, interface, version, - }); - }; + } => { + state.new_globals.push(Global { + name, + interface, + version, + }); + } + Event::::GlobalRemove { name } => { + state.removed_globals.push(GlobalName(name)); + } + _ => {} + } } } diff --git a/src/server/dispatch.rs b/src/server/dispatch.rs index 21acf44..9704f25 100644 --- a/src/server/dispatch.rs +++ b/src/server/dispatch.rs @@ -1479,6 +1479,7 @@ impl GlobalDispatch for InnerServerState { client, event::OutputScaleFactor::Output(1), event::OutputDimensions::default(), + GlobalName(data.name), ), ); state.updated_outputs.push(entity); diff --git a/src/server/mod.rs b/src/server/mod.rs index a110fab..662d1a9 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -65,6 +65,7 @@ use wayland_protocols::{ use wayland_server::protocol::wl_seat::WlSeat; use wayland_server::{ Client, DisplayHandle, Resource, WEnum, + backend::GlobalId, protocol::{ wl_callback::WlCallback, wl_compositor::WlCompositor, wl_output::WlOutput, wl_shm::WlShm, wl_surface::WlSurface, @@ -342,7 +343,8 @@ enum ObjectEvent { } } -fn handle_globals<'a, S: X11Selection + 'static>( +fn handle_new_globals<'a, S: X11Selection + 'static>( + globals_map: &mut HashMap, dh: &DisplayHandle, globals: impl IntoIterator, ) { @@ -353,7 +355,8 @@ fn handle_globals<'a, S: X11Selection + 'static>( $( ref x if x == <$global>::interface().name => { let version = u32::min(global.version, <$global>::interface().version); - dh.create_global::, $global, Global>(version, global.clone()); + let global_id = dh.create_global::, $global, Global>(version, global.clone()); + globals_map.insert(GlobalName(global.name), (global.clone(), global_id)); } )+ _ => {} @@ -377,6 +380,9 @@ fn handle_globals<'a, S: X11Selection + 'static>( } } +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub(super) struct GlobalName(pub u32); + struct FocusData { window: x::Window, output_name: Option, @@ -446,6 +452,7 @@ pub struct InnerServerState { world: MyWorld, queue: EventQueue, qh: QueueHandle, + globals_map: HashMap, client: Client, to_focus: Option, unfocus: bool, @@ -532,9 +539,10 @@ impl ServerState> { dh.create_global::, XwaylandShellV1, _>(1, ()); + let mut globals_map = HashMap::new(); global_list .contents() - .with_list(|globals| handle_globals::(&dh, globals)); + .with_list(|globals| handle_new_globals::(&mut globals_map, &dh, globals)); let world = MyWorld::new(global_list); let client = dh.insert_client(client, std::sync::Arc::new(())).unwrap(); @@ -545,6 +553,7 @@ impl ServerState> { client, queue, qh, + globals_map, dh, to_focus: None, unfocus: false, @@ -613,7 +622,7 @@ impl ServerState { } pub fn handle_clientside_events(&mut self) { - self.handle_new_globals(); + self.handle_globals(); for (target, event) in self.world.read_events() { if !self.world.contains(target) { @@ -659,8 +668,10 @@ impl ServerState { } if !self.updated_outputs.is_empty() { - for output in self.updated_outputs.iter() { - let output_scale = self.world.get::<&OutputScaleFactor>(*output).unwrap(); + for output in std::mem::take(&mut self.updated_outputs).iter() { + let Ok(output_scale) = self.world.get::<&OutputScaleFactor>(*output) else { + continue; + }; if matches!(*output_scale, OutputScaleFactor::Output(..)) { let mut surface_query = self .world @@ -684,7 +695,6 @@ impl ServerState { } } } - self.updated_outputs.clear(); let mut mixed_scale = false; let mut scale; @@ -780,9 +790,33 @@ impl InnerServerState { self.queue.as_fd() } - fn handle_new_globals(&mut self) { + fn handle_globals(&mut self) { let globals = std::mem::take(&mut self.world.new_globals); - handle_globals::(&self.dh, globals.iter()); + handle_new_globals::(&mut self.globals_map, &self.dh, &globals); + + let globals = std::mem::take(&mut self.world.removed_globals); + if globals.is_empty() { + return; + } + let query = self + .world + .query_mut::<(&WlOutput, &GlobalName)>() + .into_iter() + .map(|(e, (_, name))| (e, *name)) + .collect::>(); + for global in globals { + let (global_struct, global_id) = self.globals_map.remove(&global).unwrap(); + self.dh.disable_global::>(global_id); + if global_struct.interface == ::interface().name { + for (entity, name) in query.iter() { + if *name == global { + self.updated_outputs.push(*entity); + self.world.despawn(*entity).unwrap(); + break; + } + } + } + } } pub fn new_window( diff --git a/src/server/tests.rs b/src/server/tests.rs index da176cb..8f398e6 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -530,7 +530,19 @@ impl TestFixture { ); self.run(); self.run(); - (output, self.testwl.last_created_output()) + (output, self.testwl.finalize_output()) + } + + fn remove_output(&mut self, output_s: wayland_server::protocol::wl_output::WlOutput) { + self.testwl.remove_output(output_s); + self.run(); + self.run(); + let mut events = std::mem::take(&mut *self.registry.data.events.lock().unwrap()); + assert_eq!(events.len(), 1); + let event = events.pop().unwrap(); + let Ev::::GlobalRemove { .. } = event else { + panic!("Unexpected event: {event:?}"); + }; } } @@ -2725,6 +2737,52 @@ fn scaled_pointer_lock_position_hint() { ); } +#[test] +fn disconnected_output_rescaling() { + let mut f = TestFixture::new_pre_connect(|testwl| { + testwl.enable_fractional_scale(); + }); + let comp = f.compositor(); + let (_, output_main) = f.new_output(0, 0); + let (_, output_ext) = f.new_output(1000, 0); + + let window = Window::new(1); + let (_, id) = f.create_toplevel(&comp, window); + + let surface_data = f.testwl.get_surface_data(id).expect("No surface data"); + let fractional = surface_data + .fractional + .as_ref() + .expect("No fractional scale for surface"); + fractional.preferred_scale(240); // 2.0 scale + f.testwl.move_surface_to_output(id, &output_main); + f.run(); + + let surface_data = f.testwl.get_surface_data(id).expect("No surface data"); + let fractional = surface_data + .fractional + .as_ref() + .expect("No fractional scale for surface"); + fractional.preferred_scale(180); // 1.5 scale + f.testwl.move_surface_to_output(id, &output_ext); + f.run(); + // Multiple monitors with different scaling will select the lowest scale across monitors + assert_eq!(f.satellite.inner.new_scale, Some(1.5)); + + f.remove_output(output_ext); + f.testwl.move_surface_to_output(id, &output_main); + let surface_data = f.testwl.get_surface_data(id).expect("No surface data"); + let fractional = surface_data + .fractional + .as_ref() + .expect("No fractional scale for surface"); + fractional.preferred_scale(240); // 2.0 scale + f.run(); + f.run(); + // Afteer the output is disconnected, only the 2x scale output remains, so use that scale + assert_eq!(f.satellite.inner.new_scale, Some(2.0)); +} + #[test] fn client_side_decorations() { let (mut f, compositor) = TestFixture::new_with_compositor(); diff --git a/tests/integration.rs b/tests/integration.rs index 06615bd..f7bbdf1 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -319,7 +319,7 @@ impl Fixture { fn create_output(&mut self, x: i32, y: i32) -> wayland_server::protocol::wl_output::WlOutput { self.testwl.new_output(x, y); self.wait_and_dispatch(); - self.testwl.last_created_output() + self.testwl.finalize_output() } } @@ -2233,7 +2233,7 @@ fn xsettings_fractional_scale() { let mut connection = Connection::new(&f.display); f.testwl.enable_xdg_output_manager(); - let output = f.testwl.last_created_output(); + let output = f.testwl.finalize_output(); let window = connection.new_window(connection.root, 0, 0, 20, 20, false); let surface = f.map_as_toplevel(&mut connection, window); diff --git a/testwl/src/lib.rs b/testwl/src/lib.rs index 2304415..f8d850f 100644 --- a/testwl/src/lib.rs +++ b/testwl/src/lib.rs @@ -233,6 +233,7 @@ struct DataSourceData { struct Output { name: String, xdg: Option, + global_id: Option, } struct KeyboardState { @@ -266,6 +267,8 @@ struct State { last_surface_id: Option, created_surfaces: Vec, last_output: Option, + last_output_global: Option, + output_counter: u32, callbacks: Vec, seat: Option, pointer: Option, @@ -296,6 +299,8 @@ impl Default for State { begin: Instant::now(), last_surface_id: None, last_output: None, + last_output_global: None, + output_counter: 0, callbacks: Vec::new(), seat: None, pointer: None, @@ -572,13 +577,15 @@ impl Server { &self.state.created_surfaces } + /// Finish the initialization of an output created by `new_output`. + /// This function must be called after the globals have been dispatched in order to use the + /// output on the server side created by `new_output` (this function's return value). #[track_caller] - pub fn last_created_output(&self) -> WlOutput { - self.state - .last_output - .as_ref() - .expect("No outputs created!") - .clone() + pub fn finalize_output(&mut self) -> WlOutput { + let output_s = self.state.last_output.take().expect("No new outputs"); + let output_data = self.state.outputs.get_mut(&output_s).unwrap(); + output_data.global_id = self.state.last_output_global.take(); + output_s } pub fn get_object( @@ -845,7 +852,8 @@ impl Server { } pub fn new_output(&mut self, x: i32, y: i32) { - self.dh.create_global::(4, (x, y)); + self.state.last_output_global = + Some(self.dh.create_global::(4, (x, y))); self.display.flush_clients().unwrap(); } @@ -877,6 +885,12 @@ impl Server { self.display.flush_clients().unwrap(); } + pub fn remove_output(&mut self, output: WlOutput) { + let output = self.state.outputs.remove(&output).unwrap(); + self.dh.remove_global::(output.global_id.unwrap()); + self.display.flush_clients().unwrap(); + } + pub fn enable_xdg_output_manager(&mut self) { self.dh .create_global::(3, ()); @@ -1126,13 +1140,19 @@ impl GlobalDispatch for State { "fake monitor".to_string(), wl_output::Transform::Normal, ); - let name = format!("WL-{}", state.outputs.len() + 1); + state.output_counter += 1; + let name = format!("WL-{}", state.output_counter); output.name(name.clone()); output.mode(wl_output::Mode::Current, 1000, 1000, 0); output.done(); - state - .outputs - .insert(output.clone(), Output { name, xdg: None }); + state.outputs.insert( + output.clone(), + Output { + name, + xdg: None, + global_id: None, + }, + ); state.last_output = Some(output); } } From e6dd3c05c098aef4e6a31fa95c04f129e9b52618 Mon Sep 17 00:00:00 2001 From: En-En <39373446+En-En-Code@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:22:57 +0000 Subject: [PATCH 02/10] fix: panics on output removal Despawning the output when its global was removed proved to be overzealous, as the Dispatch of `zxdg_output_v1` still needed it for cleanup. Instead, only the `OutputScaleFactor` element is removed, and every surface entity with an `OnOutput` referencing the removed output, so scaling events sent to surfaces on non-existent outputs did not panic or use the provided scale as if an output still existed for it. --- src/server/event.rs | 2 +- src/server/mod.rs | 34 +++++++++++++++++++++++----------- src/server/tests.rs | 13 ++++++++++++- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/server/event.rs b/src/server/event.rs index 2e2afc1..ea2277c 100644 --- a/src/server/event.rs +++ b/src/server/event.rs @@ -983,7 +983,7 @@ impl Event for client::wl_touch::Event { } } -#[derive(Copy, Clone)] +#[derive(Copy, Clone, PartialEq, Eq)] pub(super) struct OnOutput(pub Entity); struct OutputName(String); fn get_output_name(output: Option<&OnOutput>, world: &World) -> Option { diff --git a/src/server/mod.rs b/src/server/mod.rs index 662d1a9..0496628 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -795,26 +795,38 @@ impl InnerServerState { handle_new_globals::(&mut self.globals_map, &self.dh, &globals); let globals = std::mem::take(&mut self.world.removed_globals); - if globals.is_empty() { - return; + for global in globals { + let (global_struct, global_id) = self.globals_map.remove(&global).unwrap(); + self.dh.disable_global::>(global_id); + if global_struct.interface == ::interface().name { + self.remove_output(global); + } } + } + + fn remove_output(&mut self, global: GlobalName) { let query = self .world .query_mut::<(&WlOutput, &GlobalName)>() .into_iter() .map(|(e, (_, name))| (e, *name)) .collect::>(); - for global in globals { - let (global_struct, global_id) = self.globals_map.remove(&global).unwrap(); - self.dh.disable_global::>(global_id); - if global_struct.interface == ::interface().name { - for (entity, name) in query.iter() { - if *name == global { - self.updated_outputs.push(*entity); - self.world.despawn(*entity).unwrap(); - break; + for (entity, name) in query.iter() { + if *name == global { + self.updated_outputs.push(*entity); + self.world.remove_one::(*entity).unwrap(); + let query = self + .world + .query_mut::<&OnOutput>() + .into_iter() + .map(|(e, on_out)| (e, *on_out)) + .collect::>(); + for (e, on_out) in query.iter() { + if *on_out == OnOutput(*entity) { + self.world.remove_one::(*e).unwrap(); } } + break; } } } diff --git a/src/server/tests.rs b/src/server/tests.rs index 8f398e6..68495d0 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -2770,6 +2770,17 @@ fn disconnected_output_rescaling() { assert_eq!(f.satellite.inner.new_scale, Some(1.5)); f.remove_output(output_ext); + let surface_data = f.testwl.get_surface_data(id).expect("No surface data"); + let fractional = surface_data + .fractional + .as_ref() + .expect("No fractional scale for surface"); + fractional.preferred_scale(120); // 1.0 scale + f.run(); + f.run(); + // An fractional scale change done while the surface is on a removed output is ignored + assert_eq!(f.satellite.inner.new_scale, Some(1.5)); + f.testwl.move_surface_to_output(id, &output_main); let surface_data = f.testwl.get_surface_data(id).expect("No surface data"); let fractional = surface_data @@ -2779,7 +2790,7 @@ fn disconnected_output_rescaling() { fractional.preferred_scale(240); // 2.0 scale f.run(); f.run(); - // Afteer the output is disconnected, only the 2x scale output remains, so use that scale + // After the output is disconnected, only the 2x scale output remains, so use that scale assert_eq!(f.satellite.inner.new_scale, Some(2.0)); } From 86f5bd5d867ad6e120935dfe825f6b903ebbeddd Mon Sep 17 00:00:00 2001 From: GoranKovac Date: Sun, 8 Feb 2026 21:41:11 +0100 Subject: [PATCH 03/10] Motif popup fix (#370) Add resize check also since that is not something that popup should have. Closes #365 --- src/xstate/mod.rs | 9 ++++++--- tests/integration.rs | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/xstate/mod.rs b/src/xstate/mod.rs index cb6f9b0..e9425f0 100644 --- a/src/xstate/mod.rs +++ b/src/xstate/mod.rs @@ -723,9 +723,12 @@ impl XState { wmhint_popup = motif_popup && wm_hints.is_some_and(|h| !h.acquire_input_via_wm) && !hints.functions.as_ref().is_some_and(|f| { - f.contains(motif::Functions::Minimize) - || f.contains(motif::Functions::Maximize) - || f.contains(motif::Functions::All) + f.intersects( + motif::Functions::Minimize + | motif::Functions::Maximize + | motif::Functions::Resize + | motif::Functions::All, + ) }); // If the motif hints indicate the user shouldn't be able to do anything // to the window at all, it stands to reason it's probably a popup. diff --git a/tests/integration.rs b/tests/integration.rs index f7bbdf1..c49b5cb 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2174,6 +2174,27 @@ fn popup_heuristics() { &[0x1_u32, 0, 0, 0, 0, 0, 0, 0, 0], ); f.map_as_toplevel(&mut connection, battle_net); + + let wallpaper_engine = connection.new_window(connection.root, 10, 10, 50, 50, false); + connection.set_property( + wallpaper_engine, + x::ATOM_ATOM, + connection.atoms.win_type, + &[connection.atoms.win_type_normal], + ); + connection.set_property( + wallpaper_engine, + connection.atoms.motif_wm_hints, + connection.atoms.motif_wm_hints, + &[0x3_u32, 0x6, 0x0, 0x0, 0x0], + ); + connection.set_property( + wallpaper_engine, + connection.atoms.wm_hints, + connection.atoms.wm_hints, + &[0x1_u32, 0, 0, 0, 0, 0, 0, 0, 0], + ); + f.map_as_toplevel(&mut connection, wallpaper_engine); } #[test] From 536bd32efc935bf876d6de385ec18a1b715c9358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A3=81=E9=93=81=E5=BC=80=E5=8F=91=E9=83=A8=EF=BC=88MDD?= =?UTF-8?q?=EF=BC=89?= <128666602+MSCMDD@users.noreply.github.com> Date: Mon, 16 Feb 2026 06:52:49 +0800 Subject: [PATCH 04/10] fix: Round up the width under HIDPI (#374) --- src/server/event.rs | 4 ++-- src/server/tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/event.rs b/src/server/event.rs index ea2277c..ba31797 100644 --- a/src/server/event.rs +++ b/src/server/event.rs @@ -472,8 +472,8 @@ pub(super) fn update_surface_viewport( let dims = &window_data.attrs.dims; let size_hints = &window_data.attrs.size_hints; - let width = (dims.width as f64 / scale_factor.0) as i32; - let height = (dims.height as f64 / scale_factor.0) as i32; + let width = (dims.width as f64 / scale_factor.0).ceil() as i32; + let height = (dims.height as f64 / scale_factor.0).ceil() as i32; if width > 0 && height > 0 { viewport.set_destination(width, height); } diff --git a/src/server/tests.rs b/src/server/tests.rs index 68495d0..13492cd 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -2300,8 +2300,8 @@ fn fractional_scale_small_popup() { { let data = f.testwl.get_surface_data(toplevel_id).unwrap(); let viewport = data.viewport.as_ref().expect("Missing viewport"); - assert_eq!(viewport.width, 66); - assert_eq!(viewport.height, 66); + assert_eq!(viewport.width, 67); + assert_eq!(viewport.height, 67); } let popup = Window::new(2); From 33c344fee50504089a447a8fef5878cf4f6215fc Mon Sep 17 00:00:00 2001 From: En-En <39373446+En-En-Code@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:30:17 +0000 Subject: [PATCH 05/10] fix: align global output offset with screen edges Previously, global output offset only moved outputs if they were in negative coordinate space. Doing so guaranteed an output on the top-most and left-most border of the screen. This commit offsets outputs to achieve the same goal on outputs in positive coordinate space, which matches the output placement of `xrandr`, a placement assumption made by Krita's tablet tool logic. The new definition of global output offset can be thought of as drawing the smallest possible rectangle with contains every monitor, and moving that bounding box to (0, 0). --- ARCHITECTURE.md | 5 +- src/server/event.rs | 85 +++++--- src/server/mod.rs | 31 ++- src/server/tests.rs | 522 ++++++++++++++++++++++++++------------------ 4 files changed, 384 insertions(+), 259 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c9932a1..df71d94 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,13 +34,16 @@ The code for the X11 portion of satellite lives in `src/xstate`. Satellite must as any other standard X11 window manager. This includes: - Setting SubstructureRedirect and SubstructureNotify on the root window, to get notifications for when new windows are being created -- Follwing (most of) the [ICCCM](https://www.x.org/releases/X11R7.6/doc/xorg-docs/specs/ICCCM/icccm.html) and [EWMH](https://specifications.freedesktop.org/wm-spec/latest/) specs +- Following (most of) the [ICCCM](https://www.x.org/releases/X11R7.6/doc/xorg-docs/specs/ICCCM/icccm.html) and [EWMH](https://specifications.freedesktop.org/wm-spec/latest/) specs In addition, satellite must do some other things that a normal X11 window manager wouldn't - but a compositor integrating Xwayland would - such as synchronize X11 and Wayland selections. This is explained further in the Wayland server section. The way that satellite manages windows from the X11 point of view is as follows: +- All monitors maintain their relative positions to one another. Their absolute position is such that + the top-most monitor's top edge is on the X-axis and the left-most monitor's left edge is on the Y-axis. + - All monitors are on non-negative coordinates with no gaps between the screen and any monitor, matching what `xrandr` does. - All toplevels on a monitor are positioned at 0x0 on that monitor. So if you have one monitor at 0x0, all the windows are located at 0x0. If you have a monitor at 300x600, all the windows on that monitor are at 300x600. - This offset is needed because all monitors rest in the same coordinate plane in X11, so missing this offset would diff --git a/src/server/event.rs b/src/server/event.rs index ba31797..3731103 100644 --- a/src/server/event.rs +++ b/src/server/event.rs @@ -212,7 +212,9 @@ impl SurfaceEvents { let mut query = data.query::<(&x::Window, &mut WindowData)>(); if let Some((window, win_data)) = query.get() { - let dimensions = output_data.get::<&OutputDimensions>().unwrap(); + let Some(dimensions) = output_data.get::<&OutputDimensions>() else { + return; + }; win_data.update_output_offset( *window, WindowOutputOffset { @@ -1077,7 +1079,9 @@ fn update_output_offset( let connection = &mut state.connection; let state = &mut state.inner; { - let mut dimensions = state.world.get::<&mut OutputDimensions>(output).unwrap(); + let Ok(mut dimensions) = state.world.get::<&mut OutputDimensions>(output) else { + return; + }; if matches!(source, OutputDimensionsSource::Wl { .. }) && matches!(dimensions.source, OutputDimensionsSource::Xdg) { @@ -1093,7 +1097,8 @@ fn update_output_offset( }; state.global_offset_updated = true; } else if dim.owner == Some(output) && value > dim.value { - *dim = Default::default(); + // Another output's position could be less than the new value, so recalculate + dim.owner = None; state.global_offset_updated = true; } }; @@ -1124,7 +1129,9 @@ fn update_window_output_offsets( world: &World, connection: &mut impl XConnection, ) { - let dimensions = world.get::<&OutputDimensions>(output).unwrap(); + let Ok(dimensions) = world.get::<&OutputDimensions>(output) else { + return; + }; let mut query = world.query::<(&x::Window, &mut WindowData, &OnOutput)>(); for (_, (window, data, _)) in query @@ -1150,7 +1157,9 @@ pub(super) fn update_global_output_offset( ) { let entity = world.entity(output).unwrap(); let mut query = entity.query::<(&OutputDimensions, &WlOutput)>(); - let (dimensions, server) = query.get().unwrap(); + let Some((dimensions, server)) = query.get() else { + return; + }; let x = dimensions.x - global_output_offset.x.value; let y = dimensions.y - global_output_offset.y.value; @@ -1249,24 +1258,28 @@ impl OutputEvent { state, ); let global_output_offset = state.global_output_offset; + let global_offset_updated = state.global_offset_updated; - let (output, dimensions, xdg) = state - .world - .query_one_mut::<(&WlOutput, &mut OutputDimensions, Option<&XdgOutputServer>)>( - target, - ) - .unwrap(); + let Ok((output, dimensions, xdg)) = state.world.query_one_mut::<( + &WlOutput, + &mut OutputDimensions, + Option<&XdgOutputServer>, + )>(target) else { + return; + }; - output.geometry( - x - global_output_offset.x.value, - y - global_output_offset.y.value, - physical_width, - physical_height, - convert_wenum(subpixel), - make, - model, - convert_wenum(transform), - ); + if !global_offset_updated { + output.geometry( + x - global_output_offset.x.value, + y - global_output_offset.y.value, + physical_width, + physical_height, + convert_wenum(subpixel), + make, + model, + convert_wenum(transform), + ); + } dimensions.rotated_90 = transform.into_result().is_ok_and(|t| { matches!( t, @@ -1290,10 +1303,12 @@ impl OutputEvent { height, refresh, } => { - let (output, dimensions) = state + let Ok((output, dimensions)) = state .world .query_one_mut::<(&WlOutput, &mut OutputDimensions)>(target) - .unwrap(); + else { + return; + }; if flags .into_result() @@ -1348,20 +1363,24 @@ impl OutputEvent { match event { Event::LogicalPosition { x, y } => { update_output_offset(target, OutputDimensionsSource::Xdg, x, y, state); - state - .world - .get::<&XdgOutputServer>(target) - .unwrap() - .logical_position( - x - state.global_output_offset.x.value, - y - state.global_output_offset.y.value, - ); + if !state.global_offset_updated { + state + .world + .get::<&XdgOutputServer>(target) + .unwrap() + .logical_position( + x - state.global_output_offset.x.value, + y - state.global_output_offset.y.value, + ); + } } Event::LogicalSize { .. } => { - let (xdg, dimensions) = state + let Ok((xdg, dimensions)) = state .world .query_one_mut::<(&XdgOutputServer, &OutputDimensions)>(target) - .unwrap(); + else { + return; + }; if dimensions.rotated_90 { xdg.logical_size(dimensions.height, dimensions.width); } else { diff --git a/src/server/mod.rs b/src/server/mod.rs index 0496628..2c42887 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -143,7 +143,7 @@ impl WindowData { offset: WindowOutputOffset, connection: &mut C, ) { - log::trace!("offset: {offset:?}"); + log::trace!(target: "output_offset", "offset: {offset:?}"); if offset == self.output_offset { return; } @@ -162,7 +162,7 @@ impl WindowData { height: self.attrs.dims.height as _, }, ) { - debug!("set {:?} offset to {:?}", window, self.output_offset); + debug!(target: "output_offset", "set {:?} offset to {:?}", window, self.output_offset); } } } @@ -644,14 +644,15 @@ impl ServerState { .unwrap(); } + if self.global_output_offset.x.owner.is_none() + || self.global_output_offset.y.owner.is_none() + { + self.calc_global_output_offset(); + self.global_offset_updated = true; + } if self.global_offset_updated { - if self.global_output_offset.x.owner.is_none() - || self.global_output_offset.y.owner.is_none() - { - self.calc_global_output_offset(); - } - debug!( + target: "output_offset", "updated global output offset: {}x{}", self.global_output_offset.x.value, self.global_output_offset.y.value ); @@ -814,7 +815,9 @@ impl InnerServerState { for (entity, name) in query.iter() { if *name == global { self.updated_outputs.push(*entity); - self.world.remove_one::(*entity).unwrap(); + self.world + .remove::<(OutputScaleFactor, OutputDimensions)>(*entity) + .unwrap(); let query = self .world .query_mut::<&OnOutput>() @@ -826,6 +829,14 @@ impl InnerServerState { self.world.remove_one::(*e).unwrap(); } } + if self.global_output_offset.x.owner == Some(*entity) { + self.global_offset_updated = true; + self.global_output_offset.x.owner = None; + } + if self.global_output_offset.y.owner == Some(*entity) { + self.global_offset_updated = true; + self.global_output_offset.y.owner = None; + } break; } } @@ -1323,6 +1334,8 @@ impl InnerServerState { } fn calc_global_output_offset(&mut self) { + self.global_output_offset.x.value = i32::MAX; + self.global_output_offset.y.value = i32::MAX; for (entity, dimensions) in self.world.query_mut::<&OutputDimensions>() { if dimensions.x < self.global_output_offset.x.value { self.global_output_offset.x = GlobalOutputOffsetDimension { diff --git a/src/server/tests.rs b/src/server/tests.rs index 13492cd..317b4b4 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -864,13 +864,6 @@ impl TestFixture { let data = self.testwl.get_surface_data(surface_id).unwrap(); match data.role { Some(SurfaceRole::Popup(_)) => { - assert_eq!( - data.popup().positioner_state.offset, - testwl::Vec2 { - x: dims.x as _, - y: dims.y as _ - } - ); assert_eq!( data.popup().positioner_state.size, Some(testwl::Vec2 { @@ -1620,86 +1613,246 @@ fn override_redirect_choose_hover_window() { assert_eq!(&popup_data.popup().parent, win1_xdg); } -#[test] -fn output_offset() { - let (mut f, comp) = TestFixture::new_with_compositor(); - let (output_obj, output) = f.new_output(0, 0); - let man = f.enable_xdg_output(); - f.create_xdg_output(&man, output_obj.obj); - f.testwl.move_xdg_output(&output, 500, 100); - f.run(); - let window = Window::new(1); - - { - let (surface, surface_id) = f.create_toplevel(&comp, window); - f.testwl.move_surface_to_output(surface_id, &output); - f.run(); - let data = &f.connection().windows[&window]; - assert_eq!(data.dims.x, 500); - assert_eq!(data.dims.y, 100); - - f.satellite.unmap_window(window); - surface.obj.destroy(); - f.run(); +#[track_caller] +fn check_output_position_event(output: &TestObject, pos: (i32, i32)) { + let mut geo = None; + let events = std::mem::take(&mut *output.data.events.lock().unwrap()); + log::debug!("events: {events:?}"); + for event in events { + match event { + wl_output::Event::Geometry { x, y, .. } => { + geo = Some((x, y)); + } + wl_output::Event::Done => { + if let Some(geo) = geo { + assert_eq!(geo, pos); + return; + } + } + _ => {} + } } - - let (t_buffer, t_surface) = comp.create_surface(); - f.map_window(&comp, window, &t_surface.obj, &t_buffer); - f.run(); - let t_id = f.testwl.last_created_surface_id().unwrap(); - f.testwl.move_surface_to_output(t_id, &output); - f.run(); - { - let data = f.testwl.get_surface_data(t_id).unwrap(); - assert!( - matches!(data.role, Some(testwl::SurfaceRole::Toplevel(_))), - "surface role: {:?}", - data.role - ); + if geo.is_none() { + panic!("Did not receive any geometry events"); + } else { + panic!("Did not receive a done event"); } - f.testwl.configure_toplevel(t_id, 100, 100, vec![]); - f.testwl.focus_toplevel(t_id); - f.run(); +} - { - let data = &f.connection().windows[&window]; - assert_eq!(data.dims.x, 500); - assert_eq!(data.dims.y, 100); +#[track_caller] +fn check_output_position_event_xdg( + xdg_out: &TestObject, + out: &TestObject, + pos: (i32, i32), + goo_updated: bool, +) { + let mut done = false; + let events = std::mem::take(&mut *xdg_out.data.events.lock().unwrap()) + .into_iter() + .rev(); + for event in events { + if let zxdg_output_v1::Event::LogicalPosition { x, y } = event { + assert_eq!(pos, (x, y)); + done = true; + break; + } } - - let popup = Window::new(2); - let (p_surface, p_id) = - f.create_popup(&comp, PopupBuilder::new(popup, window, t_id).x(510).y(110)); - f.testwl.move_surface_to_output(p_id, &output); - f.run(); - let data = f.testwl.get_surface_data(p_id).unwrap(); + assert!(done, "Did not get zxdg_output_v1 logical_position"); + let events = std::mem::take(&mut *out.data.events.lock().unwrap()); assert_eq!( - data.popup().positioner_state.offset, - testwl::Vec2 { x: 10, y: 10 } - ); - - f.satellite.unmap_window(popup); - p_surface.obj.destroy(); - f.run(); - - let (buffer, surface) = comp.create_surface(); - f.map_window(&comp, popup, &surface.obj, &buffer); - f.run(); - let p_id = f.testwl.last_created_surface_id().unwrap(); - f.testwl.move_surface_to_output(p_id, &output); - f.testwl.configure_popup(p_id); - f.run(); - let data = f.testwl.get_surface_data(p_id).unwrap(); - assert_eq!( - data.popup().positioner_state.offset, - testwl::Vec2 { x: 10, y: 10 } + events + .into_iter() + .filter(|e| matches!(*e, wl_output::Event::Done)) + .count(), + goo_updated as usize, + "Did not get expected wl_output done event" ); } #[test] -fn output_offset_change() { +fn output_offset_one_output() { + // If there is only one output, that output is always positioned at 0x0 + let (mut f, _) = TestFixture::new_with_compositor(); + let (output_obj, output) = f.new_output(0, 0); + f.run(); + f.run(); + check_output_position_event(&output_obj, (0, 0)); + + f.testwl.move_output(&output, 500, 100); + f.run(); + f.run(); + check_output_position_event(&output_obj, (0, 0)); + + f.testwl.move_output(&output, -500, -100); + f.run(); + f.run(); + check_output_position_event(&output_obj, (0, 0)); +} + +#[test] +fn output_offset_multi_output() { + // With multiple outputs, the top-most output is on the X-axis, the left-most output is on the + // Y-axis, and they always maintain relative positioning. + let (mut f, _) = TestFixture::new_with_compositor(); + + let (output_obj_1, output_1) = f.new_output(1000, 0); + f.run(); + check_output_position_event(&output_obj_1, (0, 0)); + + let (output_obj_2, _) = f.new_output(0, 1000); + f.run(); + check_output_position_event(&output_obj_1, (1000, 0)); + check_output_position_event(&output_obj_2, (0, 1000)); + + f.testwl.move_output(&output_1, 1000, 2000); + f.run(); + f.run(); + check_output_position_event(&output_obj_1, (1000, 1000)); + check_output_position_event(&output_obj_2, (0, 0)); + + // Global output offset does not change + f.testwl.move_output(&output_1, 1000, 1000); + f.run(); + f.run(); + check_output_position_event(&output_obj_1, (1000, 0)); + assert!(&output_obj_2.data.events.lock().unwrap().is_empty()); +} + +#[test] +fn output_offset_multi_output_xdg() { + let (mut f, _) = TestFixture::new_with_compositor(); + let man = f.enable_xdg_output(); + + let (output_obj_1, output_1) = f.new_output(0, 0); + f.run(); + std::mem::take(&mut *output_obj_1.data.events.lock().unwrap()); + let output_xdg_1 = f.create_xdg_output(&man, output_obj_1.obj.clone()); + f.testwl.move_xdg_output(&output_1, 1000, 0); + f.run(); + f.run(); + check_output_position_event_xdg(&output_xdg_1, &output_obj_1, (0, 0), true); + + let (output_obj_2, output_2) = f.new_output(1000, 1000); + f.run(); + std::mem::take(&mut *output_obj_2.data.events.lock().unwrap()); + let output_xdg_2 = f.create_xdg_output(&man, output_obj_2.obj.clone()); + f.testwl.move_xdg_output(&output_2, 0, 1000); + f.run(); + f.run(); + check_output_position_event_xdg(&output_xdg_1, &output_obj_1, (1000, 0), true); + check_output_position_event_xdg(&output_xdg_2, &output_obj_2, (0, 1000), true); + + f.testwl.move_xdg_output(&output_1, 1000, 2000); + f.run(); + f.run(); + check_output_position_event_xdg(&output_xdg_1, &output_obj_1, (1000, 1000), true); + check_output_position_event_xdg(&output_xdg_2, &output_obj_2, (0, 0), true); + + f.testwl.move_xdg_output(&output_1, 1000, 1000); + f.run(); + f.run(); + check_output_position_event_xdg(&output_xdg_1, &output_obj_1, (1000, 0), false); + assert!(output_xdg_2.data.events.lock().unwrap().is_empty()); + assert!(output_obj_2.data.events.lock().unwrap().is_empty()); +} + +#[test] +fn output_offset_remove_output() { + let (mut f, _) = TestFixture::new_with_compositor(); + + let (output_ext_c, output_ext) = f.new_output(0, 0); + let (output_main_c, _) = f.new_output(1000, 500); + f.run(); + + check_output_position_event(&output_ext_c, (0, 0)); + check_output_position_event(&output_main_c, (1000, 500)); + + f.remove_output(output_ext); + f.run(); + f.run(); + check_output_position_event(&output_main_c, (0, 0)); +} + +#[test] +fn output_offset_surface_positioning() { let (mut f, comp) = TestFixture::new_with_compositor(); + f.new_output(0, 0); + let (_, output) = f.new_output(500, 100); + f.run(); + + let window = Window::new(1); + let (_, toplevel_id) = f.create_toplevel(&comp, window); + f.testwl.move_surface_to_output(toplevel_id, &output); + f.run(); + + let mut toplevel_pos = WindowDims { + x: 500, + y: 100, + width: 100, + height: 100, + }; + f.assert_window_dimensions(window, toplevel_id, toplevel_pos); + + let popup = Window::new(2); + let (_, p_id) = f.create_popup( + &comp, + PopupBuilder::new(popup, window, toplevel_id).x(510).y(110), + ); + let mut popup_dims = WindowDims { + x: 510, + y: 110, + width: 50, + height: 50, + }; + f.testwl.move_surface_to_output(p_id, &output); + f.run(); + let data = f.testwl.get_surface_data(p_id).unwrap(); + assert_eq!( + data.popup().positioner_state.offset, + testwl::Vec2 { x: 10, y: 10 } + ); + f.assert_window_dimensions(popup, p_id, popup_dims); + + f.testwl.move_output(&output, 600, 200); + f.run(); + f.run(); + + toplevel_pos.x = 600; + toplevel_pos.y = 200; + f.assert_window_dimensions(window, toplevel_id, toplevel_pos); + let data = f.testwl.get_surface_data(p_id).unwrap(); + assert_eq!( + data.popup().positioner_state.offset, + testwl::Vec2 { x: 10, y: 10 } + ); + popup_dims.x = 610; + popup_dims.y = 210; + f.assert_window_dimensions(popup, p_id, popup_dims); + + f.testwl.move_output(&output, -100, -200); + f.run(); + f.run(); + + toplevel_pos.x = 0; + toplevel_pos.y = 0; + f.assert_window_dimensions(window, toplevel_id, toplevel_pos); + let data = f.testwl.get_surface_data(p_id).unwrap(); + assert_eq!( + data.popup().positioner_state.offset, + testwl::Vec2 { x: 10, y: 10 } + ); + popup_dims.x = 10; + popup_dims.y = 10; + f.assert_window_dimensions(popup, p_id, popup_dims); +} + +#[test] +fn output_offset_xdg_override() { + let (mut f, comp) = TestFixture::new_with_compositor(); + f.new_output(0, 0); + f.run(); + let (output_obj, output) = f.new_output(500, 100); let window = Window::new(1); let (_, id) = f.create_toplevel(&comp, window); @@ -1713,13 +1866,8 @@ fn output_offset_change() { }; test_position(&f, 500, 100); - f.testwl.move_output(&output, 600, 200); - f.run(); - f.run(); - test_position(&f, 600, 200); - let man = f.enable_xdg_output(); - f.create_xdg_output(&man, output_obj.obj); + f.create_xdg_output(&man, output_obj.obj.clone()); // testwl inits xdg output position to 0, and it should take priority over wl_output position test_position(&f, 0, 0); @@ -1733,6 +1881,82 @@ fn output_offset_change() { test_position(&f, 1000, 22); } +#[test] +fn output_offset_negative_position() { + let mut f = TestFixture::new(); + std::mem::take(&mut *f.registry.data.events.lock().unwrap()); + let (output, _) = f.new_output(-500, -500); + f.run(); + f.run(); + check_output_position_event(&output, (0, 0)); + + let (output2, _) = f.new_output(0, 0); + f.run(); + f.run(); + check_output_position_event(&output2, (500, 500)); + assert!(output.data.events.lock().unwrap().is_empty()); + + let (output3, _) = f.new_output(500, 500); + f.run(); + f.run(); + check_output_position_event(&output3, (1000, 1000)); + assert!(output.data.events.lock().unwrap().is_empty()); + assert!(output2.data.events.lock().unwrap().is_empty()); +} + +#[test] +fn output_offset_negative_position_update() { + let mut f = TestFixture::new(); + std::mem::take(&mut *f.registry.data.events.lock().unwrap()); + + let (output, _) = f.new_output(-500, -500); + f.run(); + f.run(); + check_output_position_event(&output, (0, 0)); + + let (output2, _) = f.new_output(0, -1000); + f.run(); + f.run(); + check_output_position_event(&output, (0, 500)); + check_output_position_event(&output2, (500, 0)); + + let (output3, o3) = f.new_output(-1000, 0); + f.run(); + f.run(); + check_output_position_event(&output, (500, 500)); + check_output_position_event(&output2, (1000, 0)); + check_output_position_event(&output3, (0, 1000)); + + f.testwl.move_output(&o3, 0, 0); + f.run(); + f.run(); + check_output_position_event(&output, (0, 500)); + check_output_position_event(&output2, (500, 0)); + check_output_position_event(&output3, (500, 1000)); +} + +#[test] +fn output_offset_negative_position_update_xdg() { + let mut f = TestFixture::new(); + std::mem::take(&mut *f.registry.data.events.lock().unwrap()); + let xdg = f.enable_xdg_output(); + + let (output, _) = f.new_output(-500, -500); + f.run(); + f.run(); + check_output_position_event(&output, (0, 0)); + + let (output2, output_s) = f.new_output(0, 0); + f.run(); + std::mem::take(&mut *output2.data.events.lock().unwrap()); + let xdg_output = f.create_xdg_output(&xdg, output2.obj.clone()); + f.testwl.move_xdg_output(&output_s, 0, -1000); + f.run(); + f.run(); + check_output_position_event(&output, (0, 500)); + check_output_position_event_xdg(&xdg_output, &output2, (500, 0), true); +} + #[test] fn reconfigure_popup() { let (mut f, comp) = TestFixture::new_with_compositor(); @@ -2037,140 +2261,6 @@ fn fullscreen_heuristic() { check_fullscreen(3, true); } -#[track_caller] -fn check_output_position_event(output: &TestObject, x: i32, y: i32) { - let events = std::mem::take(&mut *output.data.events.lock().unwrap()); - assert!(!events.is_empty()); - let mut done = false; - let mut geo = false; - for event in events { - match event { - wl_output::Event::Geometry { - x: geo_x, y: geo_y, .. - } => { - assert_eq!(geo_x, x); - assert_eq!(geo_y, y); - geo = true; - } - wl_output::Event::Done => { - done = true; - } - _ => {} - } - } - - assert!(geo, "Didn't get geometry event"); - assert!(done, "Didn't get done event"); -} - -#[test] -fn negative_output_position() { - let mut f = TestFixture::new(); - std::mem::take(&mut *f.registry.data.events.lock().unwrap()); - let (output, _) = f.new_output(-500, -500); - f.run(); - f.run(); - check_output_position_event(&output, 0, 0); - - let (output2, _) = f.new_output(0, 0); - f.run(); - f.run(); - check_output_position_event(&output2, 500, 500); - assert!(output.data.events.lock().unwrap().is_empty()); - - let (output3, _) = f.new_output(500, 500); - f.run(); - f.run(); - check_output_position_event(&output3, 1000, 1000); - assert!(output.data.events.lock().unwrap().is_empty()); - assert!(output2.data.events.lock().unwrap().is_empty()); -} - -#[test] -fn negative_output_position_update_offset() { - let mut f = TestFixture::new(); - std::mem::take(&mut *f.registry.data.events.lock().unwrap()); - - let (output, _) = f.new_output(-500, -500); - f.run(); - f.run(); - check_output_position_event(&output, 0, 0); - - let (output2, _) = f.new_output(0, -1000); - f.run(); - f.run(); - check_output_position_event(&output, 0, 500); - check_output_position_event(&output2, 500, 0); - - let (output3, _) = f.new_output(-1000, 0); - f.run(); - f.run(); - check_output_position_event(&output, 500, 500); - check_output_position_event(&output2, 1000, 0); - check_output_position_event(&output3, 0, 1000); -} - -#[test] -fn negative_output_xdg_position_update_offset() { - let mut f = TestFixture::new(); - std::mem::take(&mut *f.registry.data.events.lock().unwrap()); - let xdg = f.enable_xdg_output(); - - let (output, _) = f.new_output(-500, -500); - f.run(); - f.run(); - check_output_position_event(&output, 0, 0); - - let (output2, output_s) = f.new_output(0, 0); - let xdg_output = f.create_xdg_output(&xdg, output2.obj); - f.testwl.move_xdg_output(&output_s, 0, -1000); - f.run(); - f.run(); - check_output_position_event(&output, 0, 500); - - let mut found = false; - let mut first = false; - for event in std::mem::take(&mut *xdg_output.data.events.lock().unwrap()) { - if let zxdg_output_v1::Event::LogicalPosition { x, y } = event { - // Testwl sends a logical position event when the output is first created - // We are interested in the second one generated by satellite - if !first { - first = true; - continue; - } - assert_eq!(x, 500); - assert_eq!(y, 0); - found = true; - break; - } - } - assert!(found, "Did not get xdg output logical position"); - found = false; - for event in std::mem::take(&mut *output2.data.events.lock().unwrap()) { - if let wl_output::Event::Done = event { - found = true; - break; - } - } - assert!(found, "Did not get done event"); -} - -#[test] -fn negative_output_position_remove_offset() { - let mut f = TestFixture::new(); - std::mem::take(&mut *f.registry.data.events.lock().unwrap()); - - let (c_output, s_output) = f.new_output(-500, -500); - f.run(); - f.run(); - check_output_position_event(&c_output, 0, 0); - - f.testwl.move_output(&s_output, 500, 500); - f.run(); - f.run(); - check_output_position_event(&c_output, 500, 500); -} - #[test] fn scaled_output_popup() { let (mut f, comp) = TestFixture::new_with_compositor(); From ecde06ed3a27c119c643a8e637a8a40cf194ada1 Mon Sep 17 00:00:00 2001 From: En-En <39373446+En-En-Code@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:04:24 +0000 Subject: [PATCH 06/10] fix: take ownership of `_NET_WM_CM_S0` EWMH section 8.1 requires that compositing window managers take ownership of `_NET_WM_CM_Sn` for each screen `n` they manage (in the case of `satellite`, this is just screen 0). The Avalonia UI toolkit needs this atom to be owned in order to provide transparent window support. Resolves #376. --- src/xstate/mod.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/xstate/mod.rs b/src/xstate/mod.rs index e9425f0..0fd91d1 100644 --- a/src/xstate/mod.rs +++ b/src/xstate/mod.rs @@ -333,6 +333,14 @@ impl XState { time: x::CURRENT_TIME, }) .unwrap(); + + self.connection + .send_and_check_request(&x::SetSelectionOwner { + owner: self.wm_window, + selection: self.atoms.net_wm_cm_s0, + time: x::CURRENT_TIME, + }) + .unwrap(); } pub fn handle_events(&mut self, server_state: &mut super::RealServerState) { @@ -1034,6 +1042,7 @@ xcb::atoms_struct! { wm_transient_for => b"WM_TRANSIENT_FOR" only_if_exists = false, wm_state => b"WM_STATE" only_if_exists = false, wm_s0 => b"WM_S0" only_if_exists = false, + net_wm_cm_s0 => b"_NET_WM_CM_S0" only_if_exists = false, wm_check => b"_NET_SUPPORTING_WM_CHECK" only_if_exists = false, net_wm_name => b"_NET_WM_NAME" only_if_exists = false, wm_pid => b"_NET_WM_PID" only_if_exists = false, From 10f985b84cdbcc3bbf35b3e7e43d1b2a84fa9ce2 Mon Sep 17 00:00:00 2001 From: awsms <48278661+awsms@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:20:40 +0100 Subject: [PATCH 07/10] fix: avoid panic when no outputs are present Replace `unwrap()` on the output scale query with conditional handling. This prevents a panic when the last output is removed and the query temporarily returns no results + add test to cover removing all outputs. --- src/server/mod.rs | 30 +++++++++++++++--------------- src/server/tests.rs | 11 +++++++++++ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/server/mod.rs b/src/server/mod.rs index 2c42887..ab6f7a4 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -701,25 +701,25 @@ impl ServerState { let mut scale; let mut outputs = self.world.query_mut::<&OutputScaleFactor>().into_iter(); - let (_, output_scale) = outputs.next().unwrap(); + if let Some((_, output_scale)) = outputs.next() { + scale = output_scale.get(); - scale = output_scale.get(); - - for (_, output_scale) in outputs { - if output_scale.get() != scale { - mixed_scale = true; - scale = scale.min(output_scale.get()); + for (_, output_scale) in outputs { + if output_scale.get() != scale { + mixed_scale = true; + scale = scale.min(output_scale.get()); + } } - } - if mixed_scale { - warn!( - "Mixed output scales detected, choosing to give apps the smallest detected scale ({scale}x)" - ); - } + if mixed_scale { + warn!( + "Mixed output scales detected, choosing to give apps the smallest detected scale ({scale}x)" + ); + } - debug!("Using new scale {scale}"); - self.new_scale = Some(scale); + debug!("Using new scale {scale}"); + self.new_scale = Some(scale); + } } { diff --git a/src/server/tests.rs b/src/server/tests.rs index 317b4b4..25db5b7 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -1773,6 +1773,17 @@ fn output_offset_remove_output() { check_output_position_event(&output_main_c, (0, 0)); } +#[test] +fn remove_all_outputs() { + let (mut f, _) = TestFixture::new_with_compositor(); + + let (_, output) = f.new_output(0, 0); + f.run(); + + f.remove_output(output); + f.run(); +} + #[test] fn output_offset_surface_positioning() { let (mut f, comp) = TestFixture::new_with_compositor(); From 309d8e2a29953f7465dc14c939e2afe4682c0aa9 Mon Sep 17 00:00:00 2001 From: Shawn Wallace Date: Sat, 14 Mar 2026 10:37:21 -0400 Subject: [PATCH 08/10] Bump version to 0.8.1 Missed from the last release. Closes #384 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60e76c2..32f850e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1241,7 +1241,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xwayland-satellite" -version = "0.8.0" +version = "0.8.1" dependencies = [ "ab_glyph", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index ece7236..400c210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ edition = "2024" [package] name = "xwayland-satellite" -version = "0.8.0" +version = "0.8.1" authors = ["Shawn Wallace"] license = "MPL-2.0" description = "xwayland-satellite grants rootless Xwayland integration to any Wayland compositor implementing xdg_wm_base and viewporter. This is particularly useful for compositors that (understandably) do not want to go through implementing support for rootless Xwayland themselves." From a879e5e0896a326adc79c474bf457b8b99011027 Mon Sep 17 00:00:00 2001 From: En-En <39373446+En-En-Code@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:51:05 +0000 Subject: [PATCH 09/10] fix: cast ExitStatus to correct type, remove Boxes (#399) * fix: cast ExitStatus to correct type, remove Boxes Absolutely no clue why Past Me thought `usize` was the correct type to convert `ExitStatus` from. Rust docs make it really clear it is a `c_int` (i32) on Unix. Past Me also decided both leaking memory with `Box::into_raw` then unsoundly calling `Box::from_raw` on a pointer to a stack-allocated array was smart. Dunno why. Loser did not even leave a safety comment smh. * fix: use `mem::size_of` to get size of `i32` --- src/lib.rs | 19 +++++++++---------- tests/integration.rs | 3 +-- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 611c959..8c27f2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ use server::selection::{Clipboard, Primary}; use smithay_client_toolkit::data_device_manager::WritePipe; use std::io::{BufRead, BufReader, Read, Write}; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; -use std::os::unix::net::UnixStream; +use std::os::unix::{net::UnixStream, process::ExitStatusExt}; use std::process::{Command, ExitStatus, Stdio}; use wayland_server::{Display, ListeningSocket}; use xcb::x; @@ -121,9 +121,9 @@ pub fn main(mut data: impl RunData) -> Option<()> { let line = line.unwrap(); info!(target: "xwayland_process", "{line}"); } - let status = Box::new(xwayland.wait().unwrap()); - let status = Box::into_raw(status) as usize; - finish_tx.write_all(&status.to_ne_bytes()).unwrap(); + let status = xwayland.wait().unwrap().into_raw(); + // On a successful integration test, the rx will be dropped, so keep logs/GDB clean + let _ = finish_tx.write_all(&status.to_ne_bytes()); }); let mut ready_fds = [ @@ -131,11 +131,10 @@ pub fn main(mut data: impl RunData) -> Option<()> { PollFd::new(&finish_rx, PollFlags::IN), ]; - fn xwayland_exit_code(rx: &mut UnixStream) -> Box { - let mut data = [0; (usize::BITS / 8) as usize]; + fn xwayland_exit_code(rx: &mut UnixStream) -> ExitStatus { + let mut data = [0; std::mem::size_of::()]; rx.read_exact(&mut data).unwrap(); - let data = usize::from_ne_bytes(data); - unsafe { Box::from_raw(data as *mut _) } + ExitStatus::from_raw(i32::from_ne_bytes(data)) } let connection = match poll(&mut ready_fds, None) { @@ -179,7 +178,7 @@ pub fn main(mut data: impl RunData) -> Option<()> { Ok(_) => { if !fds[3].revents().is_empty() { let status = xwayland_exit_code(&mut quit_rx); - if *status != ExitStatus::default() { + if status != ExitStatus::default() { error!("Xwayland exited early with {status}"); } return None; @@ -246,7 +245,7 @@ pub fn main(mut data: impl RunData) -> Option<()> { Ok(_) => { if !fds[3].revents().is_empty() { let status = xwayland_exit_code(&mut quit_rx); - if *status != ExitStatus::default() { + if status != ExitStatus::default() { error!("Xwayland exited early with {status}"); } return None; diff --git a/tests/integration.rs b/tests/integration.rs index c49b5cb..df08a07 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -102,12 +102,11 @@ impl Drop for Fixture { let thread = unsafe { ManuallyDrop::take(&mut self.thread) }; // Sending anything to the quit receiver to stop the main loop. Then we guarantee a main // thread does not use file descriptors which outlive the Fixture's BorrowedFd - let return_ptr = Box::into_raw(Box::new(0_usize)) as usize; // If the receiver end of the pipe closed, the main thread dropped it, which means that // thread already terminated if self .quit_tx - .write_all(&return_ptr.to_ne_bytes()) + .write_all(&0_i32.to_ne_bytes()) .is_err_and(|e| e.kind() != std::io::ErrorKind::BrokenPipe) { panic!("could not message the main thread to terminate"); From 725720f1cb438ff916f4ec2ae9684e59b327d3a2 Mon Sep 17 00:00:00 2001 From: Brian Hrebec Date: Tue, 31 Mar 2026 11:39:44 -0500 Subject: [PATCH 10/10] tweaked deco colors --- src/server/decoration.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/server/decoration.rs b/src/server/decoration.rs index db37dc2..0df8abb 100644 --- a/src/server/decoration.rs +++ b/src/server/decoration.rs @@ -173,7 +173,7 @@ impl DecorationsDataSatellite { // Draw the bar and its components let mut bar = Pixmap::new(drawn_width as u32, drawn_height as u32).unwrap(); - bar.fill(Color::WHITE); + bar.fill(Color::from_rgba(0.2, 0.2, 0.2, 1.0).unwrap()); if let Some(title) = title { bar.draw_pixmap( @@ -350,9 +350,9 @@ fn draw_pixmap_to_buffer(pixmap: &Pixmap, buffer: &mut [u8]) { fn x_pixmap(bar_height: u32, scale: f32, hovered: bool) -> Pixmap { let mut x = Pixmap::new(bar_height, bar_height).unwrap(); if hovered { - x.fill(Color::from_rgba(1.0, 0.0, 0.0, 0.8).unwrap()); + x.fill(Color::from_rgba(1.0, 0.2, 0.2, 0.8).unwrap()); } else { - x.fill(Color::WHITE); + x.fill(Color::from_rgba(0.2, 0.2, 0.2, 1.0).unwrap()); } let size = x.width() as f32; let margin = 8.4 * scale; @@ -363,9 +363,11 @@ fn x_pixmap(bar_height: u32, scale: f32, hovered: bool) -> Pixmap { line.move_to(size - margin, margin); line.line_to(margin, size - margin); let line = line.finish().unwrap(); + let mut paint = Paint::default(); + paint.set_color(Color::WHITE); x.stroke_path( &line, - &Default::default(), + &paint, &Stroke { width: scale + 0.5, ..Default::default() @@ -400,7 +402,7 @@ fn title_pixmap(title: &str, max_width: u32, height: u32, scale: f32) -> Option< ((bounds.min.x as u32 + x) + (bounds.min.y as u32 + y) * width) as usize; data[pixel_idx] = - ColorU8::from_rgba(0, 0, 0, (coverage * 255.0) as u8).premultiply(); + ColorU8::from_rgba(255, 255, 255, (coverage * 255.0) as u8).premultiply(); }); } }