From 572fa4a2bfe920daacdefc7e564b49115413306a Mon Sep 17 00:00:00 2001 From: Shawn Wallace Date: Fri, 23 May 2025 23:25:33 -0400 Subject: [PATCH] Add Xsettings support, for setting scaling related settings This allows for most GTK and Qt apps to be scaled properly. In the case of mixed DPI, it will default to using the smallest monitor scale. --- README.md | 11 ++- src/lib.rs | 4 + src/server/dispatch.rs | 1 + src/server/event.rs | 5 ++ src/server/mod.rs | 44 +++++++++ src/xstate/mod.rs | 16 ++++ src/xstate/selection.rs | 25 ++++-- src/xstate/settings.rs | 177 ++++++++++++++++++++++++++++++++++++ tests/integration.rs | 194 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 src/xstate/settings.rs diff --git a/README.md b/README.md index 87f5c55..e34d815 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,14 @@ It will be started when the `graphical-session.target` is reached, which is likely after your compositor is started if it supports systemd. ## Scaling/HiDPI -On HiDPI displays, xwayland-satellite windows may have small text. Because HiDPI on X11 is very application dependent and hard to solve, -xwayland-satellite doesn't make an attempt to do it for you. However, the same methods that would normally work on X11 should also work -with satellite. See [the Arch Wiki on HiDPI](https://wiki.archlinux.org/title/HiDPI) for a good place start. +For most GTK and Qt apps, xwayland-satellite should automatically scale them properly. Note that for mixed DPI monitor setups, satellite will choose +the smallest monitor's DPI, meaning apps may have small text on other monitors. + +Other miscellaneous apps (such as Wine apps) may have small text on HiDPI displays. It is application dependent on getting apps to scale properly with satellite, +so you will have to figure out what app specific config needs to be set. See [the Arch Wiki on HiDPI](https://wiki.archlinux.org/title/HiDPI) for a good place start. + +Satellite acts as an Xsettings manager for setting scaling related settings, but will get out of the way of other Xsettings managers. +To manually set these settings, try [xsettingsd](https://codeberg.org/derat/xsettingsd) or another Xsettings manager. ## Wayland protocols used The host compositor **must** implement the following protocols/interfaces for satellite to function: diff --git a/src/lib.rs b/src/lib.rs index d6eb979..0a08e09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -196,6 +196,10 @@ pub fn main(data: impl RunData) -> Option<()> { if let Some(sel) = server_state.new_selection() { xstate.set_clipboard(sel); } + + if let Some(scale) = server_state.new_global_scale() { + xstate.update_global_scale(scale); + } } } } diff --git a/src/server/dispatch.rs b/src/server/dispatch.rs index fcecfe0..8431cd0 100644 --- a/src/server/dispatch.rs +++ b/src/server/dispatch.rs @@ -1272,6 +1272,7 @@ impl GlobalDispatch for ServerState { Output::new(client, server).into() }); state.output_keys.insert(key, ()); + state.output_scales_updated = true; } } global_dispatch_with_events!(WlDrmServer, WlDrmClient); diff --git a/src/server/event.rs b/src/server/event.rs index 550d7ef..26fafc1 100644 --- a/src/server/event.rs +++ b/src/server/event.rs @@ -822,6 +822,10 @@ impl Output { scale: 1, } } + + pub(super) fn scale(&self) -> i32 { + self.scale + } } #[derive(Debug)] @@ -1027,6 +1031,7 @@ impl Output { self.server.scale(factor); } + state.output_scales_updated = true; } _ => simple_event_shunt! { self.server, event: Event => [ diff --git a/src/server/mod.rs b/src/server/mod.rs index 7e5faac..f695639 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -522,6 +522,8 @@ pub struct ServerState { activation_state: Option, global_output_offset: GlobalOutputOffset, global_offset_updated: bool, + output_scales_updated: bool, + new_scale: Option, decoration_manager: Option, } @@ -611,6 +613,8 @@ impl ServerState { }, }, global_offset_updated: false, + output_scales_updated: false, + new_scale: None, decoration_manager, } } @@ -1058,6 +1062,42 @@ impl ServerState { self.global_offset_updated = false; } + if self.output_scales_updated { + let mut mixed_scale = false; + let mut scale; + + 'b: { + let mut keys_iter = self.output_keys.iter(); + let (key, _) = keys_iter.next().unwrap(); + let Some::<&Output>(output) = &mut self.objects.get(key).map(AsRef::as_ref) else { + // This should never happen, but you never know... + break 'b; + }; + + scale = output.scale(); + + for (key, _) in keys_iter { + let Some::<&Output>(output) = self.objects.get(key).map(AsRef::as_ref) else { + continue; + }; + + if output.scale() != scale { + mixed_scale = true; + scale = scale.min(output.scale()); + } + } + + 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); + } + + self.output_scales_updated = false; + } + { if let Some(FocusData { window, @@ -1083,6 +1123,10 @@ impl ServerState { .expect("Failed flushing clientside events"); } + pub fn new_global_scale(&mut self) -> Option { + self.new_scale.take() + } + pub fn new_selection(&mut self) -> Option { self.clipboard_data.as_mut().and_then(|c| { c.source.take().and_then(|s| match s { diff --git a/src/xstate/mod.rs b/src/xstate/mod.rs index 6b1cab4..9bf72ae 100644 --- a/src/xstate/mod.rs +++ b/src/xstate/mod.rs @@ -1,3 +1,5 @@ +mod settings; +use settings::Settings; mod selection; use selection::{Selection, SelectionData}; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; @@ -111,6 +113,7 @@ pub struct XState { root: x::Window, wm_window: x::Window, selection_data: SelectionData, + settings: Settings, } impl XState { @@ -185,6 +188,14 @@ impl XState { | SelectionEventMask::SELECTION_CLIENT_CLOSE, }) .unwrap(); + connection + .send_and_check_request(&xcb::xfixes::SelectSelectionInput { + window: root, + selection: atoms.xsettings, + event_mask: SelectionEventMask::SELECTION_WINDOW_DESTROY + | SelectionEventMask::SELECTION_CLIENT_CLOSE, + }) + .unwrap(); { // Setup default cursor theme let ctx = CursorContext::new(&connection, screen).unwrap(); @@ -200,6 +211,7 @@ impl XState { let wm_window = connection.generate_id(); let selection_data = SelectionData::new(&connection, root); let window_atoms = WindowTypes::intern_all(&connection).unwrap(); + let settings = Settings::new(&connection, &atoms, root); let mut r = Self { connection, @@ -208,8 +220,10 @@ impl XState { atoms, window_atoms, selection_data, + settings, }; r.create_ewmh_window(); + r.set_xsettings_owner(); r } @@ -876,6 +890,8 @@ xcb::atoms_struct! { timestamp => b"TIMESTAMP" only_if_exists = false, selection_reply => b"_selection_reply" only_if_exists = false, incr => b"INCR" only_if_exists = false, + xsettings => b"_XSETTINGS_S0" only_if_exists = false, + xsettings_settings => b"_XSETTINGS_SETTINGS" only_if_exists = false, } } diff --git a/src/xstate/selection.rs b/src/xstate/selection.rs index 27a85d8..419b67c 100644 --- a/src/xstate/selection.rs +++ b/src/xstate/selection.rs @@ -281,15 +281,16 @@ impl XState { debug!("Clipboard set from Wayland"); } - pub(crate) fn handle_selection_event( + pub(super) fn handle_selection_event( &mut self, event: &xcb::Event, server_state: &mut RealServerState, ) -> bool { match event { - // Someone else took the clipboard owner xcb::Event::X(x::Event::SelectionClear(e)) => { - self.handle_new_selection_owner(e.owner(), e.time()); + if e.selection() == self.atoms.clipboard { + self.handle_new_selection_owner(e.owner(), e.time()); + } } xcb::Event::X(x::Event::SelectionNotify(e)) => { if e.property() == x::ATOM_NONE { @@ -414,9 +415,8 @@ impl XState { } } - xcb::Event::XFixes(xcb::xfixes::Event::SelectionNotify(e)) => { - assert_eq!(e.selection(), self.atoms.clipboard); - match e.subtype() { + xcb::Event::XFixes(xcb::xfixes::Event::SelectionNotify(e)) => match e.selection() { + x if x == self.atoms.clipboard => match e.subtype() { xcb::xfixes::SelectionEvent::SetSelectionOwner => { if e.owner() == self.wm_window { return true; @@ -429,8 +429,17 @@ impl XState { debug!("Selection owner destroyed, selection will be unset"); self.selection_data.current_selection = None; } - } - } + }, + x if x == self.atoms.xsettings => match e.subtype() { + xcb::xfixes::SelectionEvent::SelectionClientClose + | xcb::xfixes::SelectionEvent::SelectionWindowDestroy => { + debug!("Xsettings owner disappeared, reacquiring"); + self.set_xsettings_owner(); + } + _ => {} + }, + _ => {} + }, _ => return false, } diff --git a/src/xstate/settings.rs b/src/xstate/settings.rs new file mode 100644 index 0000000..4c0d65c --- /dev/null +++ b/src/xstate/settings.rs @@ -0,0 +1,177 @@ +use super::XState; +use log::warn; +use std::collections::HashMap; +use xcb::x; + +impl XState { + pub(crate) fn set_xsettings_owner(&self) { + self.connection + .send_and_check_request(&x::SetSelectionOwner { + owner: self.settings.window, + selection: self.atoms.xsettings, + time: x::CURRENT_TIME, + }) + .unwrap(); + let reply = self + .connection + .wait_for_reply(self.connection.send_request(&x::GetSelectionOwner { + selection: self.atoms.xsettings, + })) + .unwrap(); + + if reply.owner() != self.settings.window { + warn!( + "Could not get XSETTINGS selection (owned by {:?})", + reply.owner() + ); + } + } + + pub(crate) fn update_global_scale(&mut self, scale: i32) { + self.settings.set_scale(scale); + self.connection + .send_and_check_request(&x::ChangeProperty { + window: self.settings.window, + mode: x::PropMode::Replace, + property: self.atoms.xsettings_settings, + r#type: self.atoms.xsettings_settings, + data: &self.settings.as_data(), + }) + .unwrap(); + } +} + +/// The DPI consider 1x scale by X11. +const DEFAULT_DPI: i32 = 96; +/// I don't know why, but the DPI related xsettings seem to +/// divide the DPI by 1024. +const DPI_SCALE_FACTOR: i32 = 1024; + +const XFT_DPI: &str = "Xft/DPI"; +const GDK_WINDOW_SCALE: &str = "Gdk/WindowScalingFactor"; +const GDK_UNSCALED_DPI: &str = "Gdk/UnscaledDPI"; + +pub(super) struct Settings { + window: x::Window, + serial: u32, + settings: HashMap<&'static str, IntSetting>, +} + +struct IntSetting { + value: i32, + last_change_serial: u32, +} + +mod setting_type { + pub const INTEGER: u8 = 0; +} + +impl Settings { + pub(super) fn new(connection: &xcb::Connection, atoms: &super::Atoms, root: x::Window) -> Self { + let window = connection.generate_id(); + connection + .send_and_check_request(&x::CreateWindow { + wid: window, + width: 1, + height: 1, + depth: 0, + parent: root, + x: 0, + y: 0, + border_width: 0, + class: x::WindowClass::InputOnly, + visual: x::COPY_FROM_PARENT, + value_list: &[], + }) + .expect("Couldn't create window for settings"); + + let s = Settings { + window, + serial: 0, + settings: HashMap::from([ + ( + XFT_DPI, + IntSetting { + value: DEFAULT_DPI * DPI_SCALE_FACTOR, + last_change_serial: 0, + }, + ), + ( + GDK_WINDOW_SCALE, + IntSetting { + value: 1, + last_change_serial: 0, + }, + ), + ( + GDK_UNSCALED_DPI, + IntSetting { + value: DEFAULT_DPI * DPI_SCALE_FACTOR, + last_change_serial: 0, + }, + ), + ]), + }; + + connection + .send_and_check_request(&x::ChangeProperty { + window, + mode: x::PropMode::Replace, + property: atoms.xsettings_settings, + r#type: atoms.xsettings_settings, + data: &s.as_data(), + }) + .unwrap(); + + s + } + + fn as_data(&self) -> Vec { + // https://specifications.freedesktop.org/xsettings-spec/0.5/#format + + let mut data = vec![ + // GTK seems to use this value for byte order from the X.h header, + // so I assume I can use it too. + x::ImageOrder::LsbFirst as u8, + // unused + 0, + 0, + 0, + ]; + + data.extend_from_slice(&self.serial.to_le_bytes()); + data.extend_from_slice(&(self.settings.len() as u32).to_le_bytes()); + + fn insert_with_padding(data: &[u8], out: &mut Vec) { + out.extend_from_slice(data); + // See https://x.org/releases/X11R7.7/doc/xproto/x11protocol.html#Syntactic_Conventions_b + let num_padding_bytes = (4 - (data.len() % 4)) % 4; + out.extend(std::iter::repeat_n(0, num_padding_bytes)); + } + + for (name, setting) in &self.settings { + data.extend_from_slice(&[setting_type::INTEGER, 0]); + data.extend_from_slice(&(name.len() as u16).to_le_bytes()); + insert_with_padding(name.as_bytes(), &mut data); + data.extend_from_slice(&setting.last_change_serial.to_le_bytes()); + data.extend_from_slice(&setting.value.to_le_bytes()); + } + + data + } + + fn set_scale(&mut self, scale: i32) { + self.serial += 1; + + self.settings.entry(XFT_DPI).insert_entry(IntSetting { + value: scale * DEFAULT_DPI * DPI_SCALE_FACTOR, + last_change_serial: self.serial, + }); + self.settings + .entry(GDK_WINDOW_SCALE) + .insert_entry(IntSetting { + value: scale, + last_change_serial: self.serial, + }); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index 9ffe386..b9b3529 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,5 +1,6 @@ use rustix::event::{poll, PollFd, PollFlags}; use rustix::process::{Pid, Signal, WaitOptions}; +use std::collections::HashMap; use std::io::Write; use std::mem::ManuallyDrop; use std::os::fd::{AsRawFd, BorrowedFd}; @@ -178,6 +179,7 @@ impl Fixture { #[track_caller] fn wait_and_dispatch(&mut self) { let mut pollfd = [self.pollfd.clone()]; + self.testwl.dispatch(); assert!( poll(&mut pollfd, 50).unwrap() > 0, "Did not receive any events" @@ -315,6 +317,8 @@ xcb::atoms_struct! { mime1 => b"text/plain" only_if_exists = false, mime2 => b"blah/blah" only_if_exists = false, incr => b"INCR", + xsettings => b"_XSETTINGS_S0", + xsettings_setting => b"_XSETTINGS_SETTINGS", } } @@ -1698,3 +1702,193 @@ fn popup_heuristics() { ); f.map_as_toplevel(&mut connection, reaper_dialog); } + +#[test] +fn xsettings_scale() { + let mut f = Fixture::new_preset(|testwl| { + testwl.new_output(0, 0); // WL-1 + }); + let connection = Connection::new(&f.display); + f.testwl.enable_xdg_output_manager(); + + struct Settings { + serial: u32, + data: HashMap, + } + struct Setting { + value: i32, + last_change: u32, + } + + let owner = connection + .get_reply(&x::GetSelectionOwner { + selection: connection.atoms.xsettings, + }) + .owner(); + + let get_settings = || -> Settings { + let reply = connection.get_reply(&x::GetProperty { + delete: false, + window: owner, + property: connection.atoms.xsettings_setting, + r#type: connection.atoms.xsettings_setting, + long_offset: 0, + long_length: 60, + }); + assert_eq!(reply.r#type(), connection.atoms.xsettings_setting); + + let data = reply.value::(); + + let byte_order = data[0]; + assert_eq!(byte_order, 0); + let serial = u32::from_le_bytes(data[4..8].try_into().unwrap()); + let num_settings = u32::from_le_bytes(data[8..12].try_into().unwrap()); + + let mut current_idx = 12; + let mut settings = HashMap::new(); + for _ in 0..num_settings { + assert_eq!(&data[current_idx..current_idx + 2], &[0, 0]); + let name_len = + u16::from_le_bytes(data[current_idx + 2..current_idx + 4].try_into().unwrap()); + + let padding_start = current_idx + 4 + name_len as usize; + let name = String::from_utf8(data[current_idx + 4..padding_start].to_vec()).unwrap(); + let num_padding_bytes = (4 - (name_len as usize % 4)) % 4; + let data_start = padding_start + num_padding_bytes; + let last_change = + u32::from_le_bytes(data[data_start..data_start + 4].try_into().unwrap()); + let value = + i32::from_le_bytes(data[data_start + 4..data_start + 8].try_into().unwrap()); + + settings.insert(name, Setting { value, last_change }); + current_idx = data_start + 8; + } + + Settings { + serial, + data: settings, + } + }; + + let settings = get_settings(); + let settings_serial = settings.serial; + assert_eq!(settings.data["Xft/DPI"].value, 96 * 1024); + let dpi_serial = settings.data["Xft/DPI"].last_change; + assert_eq!(settings.data["Gdk/WindowScalingFactor"].value, 1); + let window_serial = settings.data["Gdk/WindowScalingFactor"].last_change; + assert_eq!(settings.data["Gdk/UnscaledDPI"].value, 96 * 1024); + let unscaled_serial = settings.data["Gdk/UnscaledDPI"].last_change; + + let output = f.testwl.get_output("WL-1").unwrap(); + output.scale(2); + output.done(); + f.wait_and_dispatch(); + + let settings = get_settings(); + assert!(settings.serial > settings_serial); + assert_eq!(settings.data["Xft/DPI"].value, 2 * 96 * 1024); + assert!(settings.data["Xft/DPI"].last_change > dpi_serial); + assert_eq!(settings.data["Gdk/WindowScalingFactor"].value, 2); + assert!(settings.data["Gdk/WindowScalingFactor"].last_change > window_serial); + assert_eq!(settings.data["Gdk/UnscaledDPI"].value, 96 * 1024); + assert_eq!( + settings.data["Gdk/UnscaledDPI"].last_change, + unscaled_serial + ); + + let output2 = f.create_output(0, 0); + let settings = get_settings(); + assert_eq!(settings.data["Xft/DPI"].value, 96 * 1024); + assert_eq!(settings.data["Gdk/WindowScalingFactor"].value, 1); + assert_eq!(settings.data["Gdk/UnscaledDPI"].value, 96 * 1024); + + output2.scale(2); + output2.done(); + f.testwl.dispatch(); + std::thread::sleep(Duration::from_millis(1)); + + let settings = get_settings(); + assert_eq!(settings.data["Xft/DPI"].value, 2 * 96 * 1024); + assert_eq!(settings.data["Gdk/WindowScalingFactor"].value, 2); + assert_eq!(settings.data["Gdk/UnscaledDPI"].value, 96 * 1024); +} + +#[test] +fn xsettings_switch_owner() { + let f = Fixture::new(); + let mut connection = Connection::new(&f.display); + + let owner = connection + .get_reply(&x::GetSelectionOwner { + selection: connection.atoms.xsettings, + }) + .owner(); + + let win = connection.generate_id(); + connection + .send_and_check_request(&x::CreateWindow { + wid: win, + x: 0, + y: 0, + parent: connection.root, + depth: 0, + width: 1, + height: 1, + border_width: 0, + class: x::WindowClass::InputOnly, + visual: x::COPY_FROM_PARENT, + value_list: &[], + }) + .unwrap(); + + connection + .send_and_check_request(&x::SetSelectionOwner { + owner: win, + selection: connection.atoms.xsettings, + time: x::CURRENT_TIME, + }) + .unwrap(); + + assert_eq!( + connection + .get_reply(&x::GetSelectionOwner { + selection: connection.atoms.xsettings, + }) + .owner(), + win + ); + + connection + .send_and_check_request(&xcb::xfixes::SelectSelectionInput { + window: connection.root, + selection: connection.atoms.xsettings, + event_mask: xcb::xfixes::SelectionEventMask::SET_SELECTION_OWNER + | xcb::xfixes::SelectionEventMask::SELECTION_WINDOW_DESTROY + | xcb::xfixes::SelectionEventMask::SELECTION_CLIENT_CLOSE, + }) + .unwrap(); + + connection + .send_and_check_request(&x::DestroyWindow { window: win }) + .unwrap(); + + match connection.await_event() { + xcb::Event::XFixes(xcb::xfixes::Event::SelectionNotify(x)) + if x.subtype() == xcb::xfixes::SelectionEvent::SelectionWindowDestroy => {} + other => panic!("unexpected event {other:?}"), + } + match connection.await_event() { + xcb::Event::XFixes(xcb::xfixes::Event::SelectionNotify(x)) + if x.subtype() == xcb::xfixes::SelectionEvent::SetSelectionOwner => {} + other => panic!("unexpected event {other:?}"), + } + + assert_eq!( + connection + .get_reply(&x::GetSelectionOwner { + selection: connection.atoms.xsettings, + }) + .owner(), + owner + ); +}