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 + ); +}