Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat(xwayland): HiDPI X11 — a physical-pixel screen for Xwayland, drawn at 1/scale
On the scale-2 panel every X11 app was blurry: Xwayland sized its screen
from xdg-output's LOGICAL size, so Houdini rendered at 1920x1200 and the
compositor upscaled the buffer 2x. This is the standard Xwayland HiDPI
trade, done the way Hyprland's force_zero_scaling does it.
With `window_manager { xwayland_hidpi }` on (the default) the xdg-output
global is hidden from the Xwayland client through a wl_display global
filter, so Xwayland falls back to the wl_output MODE and X11 becomes a
physical-pixel world. Every position and size crossing into or out of X11
now converts through `xwayland_window::to_x11` / `from_x11` with the first
output's scale (`x11_scale`): the toplevel configure paths, the restored
saved size, the size read back for the dimensions report, the wine frame
margin, and override-redirect placement. X11 surface buffers draw at
1/scale on top of the overview zoom (`Window::x11_buffer_scale`, folded
into every ScaleData site) and override-redirect trees get the same
treatment from a per-frame pass over a new registry, since the scene's
commit listener resets a committed buffer's dest size. The Xwayland-ready
hook pushes Xft.dpi = 96×scale (and Xcursor.size) with xrdb so Qt 6 —
Houdini — and Xft toolkits scale themselves to match; GTK on X11 still
wants GDK_SCALE in its own environment.
`xwayland_hidpi (bool)false` restores the previous logical-X11 behaviour,
where the factor is 1 and the ff65b0b coordinate fix still holds.
Verified headless at output scale 2 with GTK4 under Xwayland (GDK_SCALE=2):
X reports the screen at 1280x720 and the window at 800x600 while the
compositor keeps it 400x300 logical; Xft.dpi reads 192; text renders
sharp; a menu popover's 260x348 buffer draws at 130x174 under its button;
clicks land on the button and on a popover item; a transient dialog keeps
its logical 300x200.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/server/config.rs | 14 +++-
src/server/output.rs | 7 +-
src/server/server.rs | 69 ++++++++++++++++++
src/server/window.rs | 47 +++++++++----
src/server/window_manager.rs | 10 +++
src/server/xwayland_override_redirect.rs | 71 +++++++++++++++----
src/server/xwayland_window.rs | 116 ++++++++++++++++++++-----------
7 files changed, 265 insertions(+), 69 deletions(-)
diff --git a/src/server/config.rs b/src/server/config.rs
index 3c6d740..9265b4b 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -393,6 +393,12 @@ pub struct WindowManagerConfig {
/// KDL: `bevel_apps "*claude*"`. Globbed like `rounded_apps`; reported by
/// `ccectl windows` as `beveled=`.
pub bevel_apps: Option<Vec<String>>,
+ /// Present Xwayland with a PHYSICAL-pixel screen (the xdg-output global is
+ /// hidden from it, so it sizes its root from the wl_output mode) and draw
+ /// X11 surfaces at 1/scale, so HiDPI-aware X11 apps render sharp instead
+ /// of being upscaled from logical size. Default on. KDL:
+ /// `xwayland_hidpi (bool)false` to get the old blurry-but-1:1 behaviour.
+ pub xwayland_hidpi: Option<bool>,
}
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
@@ -2263,7 +2269,8 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
let corner_shape = get_child_arg_f64_opt(node, "corner_shape");
let rounded_apps = get_child_args_string_vec_opt(node, "rounded_apps");
let bevel_apps = get_child_args_string_vec_opt(node, "bevel_apps");
- window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps });
+ let xwayland_hidpi = get_child_arg_bool_opt(node, "xwayland_hidpi");
+ window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi });
}
Ok(Config {
@@ -2319,6 +2326,11 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
}
state.output_scale = 1.0f32;
+ state.xwayland_hidpi = config
+ .window_manager
+ .as_ref()
+ .and_then(|wm| wm.xwayland_hidpi)
+ .unwrap_or(true);
state.display = config.display.clone();
state.on_app_exit = config
.window_manager
diff --git a/src/server/output.rs b/src/server/output.rs
index 1066430..0f5cda2 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -607,10 +607,15 @@ impl Output {
// Re-apply scale to all windows whose scale is not 1.0 right before rendering
let wm = &(*self.server).wm;
for &window in wm.windows.iter() {
- if !window.is_null() && (*window).scale != 1.0 {
+ if !window.is_null() && ((*window).scale != 1.0 || (*window).x11_buffer_scale() != 1.0) {
(*window).scale_only_render_finish();
}
}
+ for &or in wm.override_redirects.iter() {
+ if !or.is_null() {
+ (*or).apply_x11_scale();
+ }
+ }
// Overview-delay debugging: while /tmp/cce-ovdbg exists (contents =
// comma-separated app_id substrings), dump the scene-side truth for
diff --git a/src/server/server.rs b/src/server/server.rs
index 55149b0..6a1a646 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -502,6 +502,40 @@ unsafe extern "C" fn handle_new_xwayland_surface(listener: *mut ffi::wl_listener
}
}
+/// The first field of `struct wl_interface`; bindgen leaves the type opaque.
+#[repr(C)]
+struct WlInterfaceHead {
+ name: *const std::os::raw::c_char,
+}
+
+/// Hide the xdg-output global from the Xwayland client while
+/// `xwayland_hidpi` is on. Xwayland sizes its root window from xdg-output's
+/// LOGICAL size when it can see one (1920x1200 on the scale-2 panel), and
+/// then every X11 app draws at logical resolution and is upscaled — blurry.
+/// Without xdg-output it falls back to the wl_output mode, the physical
+/// pixel grid, and a DPI-aware X11 app renders sharp; the compositor draws
+/// X11 surfaces at 1/scale and converts X11 geometry to match
+/// (`xwayland_window.rs`). Every other client keeps seeing xdg-output.
+unsafe extern "C" fn xwayland_global_filter(
+ client: *const ffi::wl_client,
+ global: *const ffi::wl_global,
+ data: *mut std::ffi::c_void,
+) -> bool {
+ let server = data as *mut Server;
+ if server.is_null() || (*server).xwayland.is_null() || !(*server).wm.xwayland_hidpi {
+ return true;
+ }
+ let xserver = (*((*server).xwayland as *mut WlrXwayland)).server as *mut ffi::wlr_xwayland_server;
+ if xserver.is_null() || (*xserver).client.is_null() || (*xserver).client as *const ffi::wl_client != client {
+ return true;
+ }
+ let iface = ffi::wl_global_get_interface(global) as *const WlInterfaceHead;
+ if iface.is_null() || (*iface).name.is_null() {
+ return true;
+ }
+ std::ffi::CStr::from_ptr((*iface).name).to_bytes() != b"zxdg_output_manager_v1"
+}
+
unsafe extern "C" fn handle_xwayland_ready(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
let server = container_of!(listener, Server, xwayland_ready);
let xwayland_cast = (*server).xwayland as *mut WlrXwayland;
@@ -511,6 +545,35 @@ unsafe extern "C" fn handle_xwayland_ready(listener: *mut ffi::wl_listener, _dat
.into_owned();
log::info!("Xwayland is ready on display {}", display_name);
std::env::set_var("DISPLAY", &display_name);
+
+ // Under `xwayland_hidpi` X11 is a physical-pixel world (see
+ // `xwayland_window::x11_scale`), so tell X11 clients the DPI that goes
+ // with it: Qt 6 (Houdini) and Xft-based toolkits read Xft.dpi and scale
+ // themselves to match. GTK on X11 wants GDK_SCALE in its own environment
+ // on top of this; that is the app launcher's to provide.
+ let s = crate::xwayland_window::x11_scale(server);
+ if s != 1.0 {
+ let dpi = (96.0 * s).round() as i32;
+ let cursor = (24.0 * s).round() as i32;
+ match std::process::Command::new("xrdb")
+ .args(["-merge", "-"])
+ .env("DISPLAY", &display_name)
+ .stdin(std::process::Stdio::piped())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn()
+ {
+ Ok(mut child) => {
+ use std::io::Write;
+ if let Some(mut stdin) = child.stdin.take() {
+ let _ = write!(stdin, "Xft.dpi: {}\nXcursor.size: {}\n", dpi, cursor);
+ }
+ std::thread::spawn(move || { let _ = child.wait(); });
+ log::info!("Xwayland HiDPI: X11 scale {} — set Xft.dpi {} via xrdb", s, dpi);
+ }
+ Err(e) => log::warn!("Xwayland HiDPI: could not run xrdb to set Xft.dpi: {}", e),
+ }
+ }
}
}
@@ -727,6 +790,12 @@ impl Server {
return Err("Failed to create xwayland server");
}
self.xwayland = xwayland;
+ // See `xwayland_global_filter`.
+ ffi::wl_display_set_global_filter(
+ wl_server,
+ Some(xwayland_global_filter),
+ self as *mut Server as *mut std::ffi::c_void,
+ );
}
// Setup linux dmabuf if supported
diff --git a/src/server/window.rs b/src/server/window.rs
index e2d0e2f..f0fea0c 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -931,6 +931,18 @@ impl Window {
}
}
+ /// Dest-size factor for this window's surface buffers on top of the
+ /// overview zoom: 1/output-scale for an X11 window under
+ /// `xwayland_hidpi`, whose buffer is physical pixels (see
+ /// `xwayland_window::x11_scale`); 1 for everything else.
+ pub unsafe fn x11_buffer_scale(&self) -> f64 {
+ if matches!(self.impl_type, WindowImpl::Xwayland(_)) {
+ 1.0 / crate::xwayland_window::x11_scale(self.server) as f64
+ } else {
+ 1.0
+ }
+ }
+
pub unsafe fn get_parent(&self) -> *mut Window {
match self.impl_type {
WindowImpl::Toplevel(toplevel) => {
@@ -1108,8 +1120,9 @@ impl Window {
}
WindowImpl::Xwayland(xwindow) => {
if !xwindow.is_null() && !(*xwindow).xsurface.is_null() {
- (*(*xwindow).xsurface).width = saved.width as u16;
- (*(*xwindow).xsurface).height = saved.height as u16;
+ let s = crate::xwayland_window::x11_scale(self.server);
+ (*(*xwindow).xsurface).width = crate::xwayland_window::to_x11(saved.width as i32, s) as u16;
+ (*(*xwindow).xsurface).height = crate::xwayland_window::to_x11(saved.height as i32, s) as u16;
}
}
_ => {}
@@ -2600,12 +2613,13 @@ impl Window {
}
WindowImpl::Xwayland(xwindow) => {
if !xwindow.is_null() {
- let mut w = (*(*xwindow).xsurface).width as u32;
- let mut h = (*(*xwindow).xsurface).height as u32;
+ let s = crate::xwayland_window::x11_scale(self.server);
+ let mut w = crate::xwayland_window::from_x11((*(*xwindow).xsurface).width as i32, s) as u32;
+ let mut h = crate::xwayland_window::from_x11((*(*xwindow).xsurface).height as i32, s) as u32;
let has_parent = !(*(*xwindow).xsurface).parent.is_null();
if self.is_wine() && !has_parent && !self.is_fullscreen() {
- w = w.saturating_sub(32);
- h = h.saturating_sub(32);
+ w = w.saturating_sub((crate::xwayland_window::WINE_MARGIN * 2) as u32);
+ h = h.saturating_sub((crate::xwayland_window::WINE_MARGIN * 2) as u32);
}
self.rendering_scheduled.width = w;
self.rendering_scheduled.height = h;
@@ -3089,7 +3103,10 @@ impl Window {
if self.fs_anim.is_some() {
return;
}
- if self.scale == 1.0 {
+ // The zoom the overview asks for, times 1/output-scale for an X11
+ // surface whose buffer is physical pixels (`x11_buffer_scale`).
+ let eff_scale = self.scale * self.x11_buffer_scale();
+ if eff_scale == 1.0 {
self.last_applied_scale = 1.0;
return;
}
@@ -3150,7 +3167,7 @@ impl Window {
// leaves it briefly at the old zoom, which restore corrects.
}
- let scale_data_surfaces = ScaleData { scale: self.scale, ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
+ let scale_data_surfaces = ScaleData { scale: eff_scale, ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
ffi::wlr_scene_node_for_each_buffer(
self.surfaces.tree as *mut ffi::wlr_scene_node,
Some(set_overview_scale_iterator),
@@ -3158,7 +3175,7 @@ impl Window {
);
if self.surfaces.saved {
- let scale_data_saved = ScaleData { scale: self.scale, ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
+ let scale_data_saved = ScaleData { scale: eff_scale, ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
ffi::wlr_scene_node_for_each_buffer(
self.surfaces.saved_tree as *mut ffi::wlr_scene_node,
Some(set_overview_scale_iterator),
@@ -3166,7 +3183,7 @@ impl Window {
);
}
- let scale_data_popup = ScaleData { scale: self.scale, ancestor: self.popup_tree as *mut ffi::wlr_scene_node };
+ let scale_data_popup = ScaleData { scale: eff_scale, ancestor: self.popup_tree as *mut ffi::wlr_scene_node };
ffi::wlr_scene_node_for_each_buffer(
self.popup_tree as *mut ffi::wlr_scene_node,
Some(set_overview_scale_iterator),
@@ -4954,7 +4971,7 @@ impl Decoration {
// leaves it briefly at the old zoom, which restore corrects.
}
- let scale_data = ScaleData { scale, ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
+ let scale_data = ScaleData { scale: scale * (*self.window).x11_buffer_scale(), ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
ffi::wlr_scene_node_for_each_buffer(
self.surfaces.tree as *mut ffi::wlr_scene_node,
Some(set_overview_scale_iterator),
@@ -4962,7 +4979,7 @@ impl Decoration {
);
if self.surfaces.saved {
- let scale_data_saved = ScaleData { scale, ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
+ let scale_data_saved = ScaleData { scale: scale * (*self.window).x11_buffer_scale(), ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
ffi::wlr_scene_node_for_each_buffer(
self.surfaces.saved_tree as *mut ffi::wlr_scene_node,
Some(set_overview_scale_iterator),
@@ -4978,7 +4995,7 @@ impl Decoration {
pub unsafe fn scale_only_render_finish(&mut self) {
let scale = (*self.window).scale;
- if scale == 1.0 {
+ if scale * (*self.window).x11_buffer_scale() == 1.0 {
return;
}
@@ -5029,7 +5046,7 @@ impl Decoration {
// leaves it briefly at the old zoom, which restore corrects.
}
- let scale_data = ScaleData { scale, ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
+ let scale_data = ScaleData { scale: scale * (*self.window).x11_buffer_scale(), ancestor: self.surfaces.tree as *mut ffi::wlr_scene_node };
ffi::wlr_scene_node_for_each_buffer(
self.surfaces.tree as *mut ffi::wlr_scene_node,
Some(set_overview_scale_iterator),
@@ -5037,7 +5054,7 @@ impl Decoration {
);
if self.surfaces.saved {
- let scale_data_saved = ScaleData { scale, ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
+ let scale_data_saved = ScaleData { scale: scale * (*self.window).x11_buffer_scale(), ancestor: self.surfaces.saved_tree as *mut ffi::wlr_scene_node };
ffi::wlr_scene_node_for_each_buffer(
self.surfaces.saved_tree as *mut ffi::wlr_scene_node,
Some(set_overview_scale_iterator),
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 0937564..b2c7de5 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -114,6 +114,13 @@ pub struct WindowManager {
pub startup_pids: Vec<(crate::config::StartupConfig, nix::unistd::Pid)>,
pub status_sender: Option<crate::status_server::StatusSender>,
pub output_scale: f32,
+ /// Xwayland sees a physical-pixel screen and X11 surfaces draw at
+ /// 1/scale (see `WindowManagerConfig::xwayland_hidpi`).
+ pub xwayland_hidpi: bool,
+ /// Live override-redirect X11 surfaces (menus, tooltips, combo lists),
+ /// so the per-frame pass can re-apply their 1/scale dest size — the
+ /// scene's own commit listener resets it on every commit.
+ pub override_redirects: Vec<*mut XwaylandOverrideRedirect>,
pub display: std::collections::HashMap<String, f64>,
pub input_rules: Vec<crate::config::InputDeviceConfigRule>,
pub input_config: crate::config::InputConfig,
@@ -345,6 +352,7 @@ impl WindowManager {
self.scheduled.output_config = std::ptr::null_mut();
self.sent.output_config = std::ptr::null_mut();
self.output_scale = 1.0;
+ self.xwayland_hidpi = true;
self.display = std::collections::HashMap::new();
self.input_rules = Vec::new();
self.input_config = crate::config::InputConfig::default();
@@ -408,12 +416,14 @@ impl WindowManager {
self.last_window_states = Vec::new();
self.exit_orphans = Vec::new();
self.last_saved_windows = Vec::new();
+ self.override_redirects = Vec::new();
self.pending_placements = Vec::new();
self.rounded_apps = Vec::new();
self.bevel_apps = Vec::new();
self.shutting_down = false;
self.layout = crate::config::Layout::default();
self.output_scale = 1.0;
+ self.xwayland_hidpi = true;
self.display = std::collections::HashMap::new();
self.has_restored_focused_window = false;
self.restored_focused_window_mapped = false;
diff --git a/src/server/xwayland_override_redirect.rs b/src/server/xwayland_override_redirect.rs
index 1f611a4..0889e48 100644
--- a/src/server/xwayland_override_redirect.rs
+++ b/src/server/xwayland_override_redirect.rs
@@ -71,6 +71,7 @@ impl XwaylandOverrideRedirect {
});
let raw = Box::into_raw(override_redirect);
+ (*server).wm.override_redirects.push(raw);
connect_listener(&mut (*xsurface).events.request_configure, &mut (*raw).request_configure, handle_request_configure);
connect_listener(&mut (*xsurface).events.destroy, &mut (*raw).destroy, handle_destroy);
@@ -88,6 +89,60 @@ impl XwaylandOverrideRedirect {
Ok(())
}
+ /// Put the surface tree where X11 says the window is, in logical
+ /// pixels (`xwayland_window::x11_scale`).
+ pub unsafe fn place(&mut self) {
+ if self.surface_tree.is_null() {
+ return;
+ }
+ let s = crate::xwayland_window::x11_scale(self.server);
+ ffi::wlr_scene_node_set_position(
+ self.surface_tree as *mut ffi::wlr_scene_node,
+ crate::xwayland_window::from_x11((*self.xsurface).x as i32, s),
+ crate::xwayland_window::from_x11((*self.xsurface).y as i32, s),
+ );
+ }
+
+ /// Draw the physical-pixel X11 buffer at 1/scale. Runs from the
+ /// per-frame pass (output.rs) because the scene's commit listener
+ /// resets a committed buffer's dest size; every setter is change-checked.
+ pub unsafe fn apply_x11_scale(&mut self) {
+ if self.surface_tree.is_null() {
+ return;
+ }
+ let s = crate::xwayland_window::x11_scale(self.server) as f64;
+ if s == 1.0 {
+ return;
+ }
+ unsafe extern "C" fn iter(
+ buffer: *mut ffi::wlr_scene_buffer,
+ _sx: i32,
+ _sy: i32,
+ user_data: *mut std::ffi::c_void,
+ ) {
+ let inv = *(user_data as *const f64);
+ let node = buffer as *mut ffi::wlr_scene_node;
+ let surface = ffi::river_scene_node_get_surface(node);
+ if surface.is_null() {
+ return;
+ }
+ let w = ffi::river_wlr_surface_get_width(surface);
+ let h = ffi::river_wlr_surface_get_height(surface);
+ ffi::river_scene_buffer_set_dest_size_if_changed(
+ buffer,
+ (w as f64 * inv).round() as i32,
+ (h as f64 * inv).round() as i32,
+ );
+ ffi::river_scene_buffer_set_scaled_opaque_region(buffer, surface, inv);
+ }
+ let inv = 1.0 / s;
+ ffi::wlr_scene_node_for_each_buffer(
+ self.surface_tree as *mut ffi::wlr_scene_node,
+ Some(iter),
+ &inv as *const f64 as *mut std::ffi::c_void,
+ );
+ }
+
pub unsafe fn focus_if_desired(&self) {
if (*self.server).lock_manager.state != crate::lock_manager::LockState::Unlocked {
return;
@@ -123,6 +178,7 @@ unsafe extern "C" fn handle_request_configure(_listener: *mut ffi::wl_listener,
unsafe extern "C" fn handle_destroy(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
let or = crate::container_of!(listener, XwaylandOverrideRedirect, destroy);
+ (*(*or).server).wm.override_redirects.retain(|&p| p != or);
wl_listener_remove_safe(&mut (*or).request_configure);
wl_listener_remove_safe(&mut (*or).destroy);
@@ -186,11 +242,8 @@ unsafe fn handle_map_impl(or: *mut XwaylandOverrideRedirect) {
ffi::river_wlr_surface_set_data(surface, surface_tree as *mut ffi::wlr_scene_node as *mut _);
- ffi::wlr_scene_node_set_position(
- surface_tree as *mut ffi::wlr_scene_node,
- (*(*or).xsurface).x as i32,
- (*(*or).xsurface).y as i32,
- );
+ (*or).place();
+ (*or).apply_x11_scale();
connect_listener(&mut (*(*or).xsurface).events.set_geometry, &mut (*or).set_geometry, handle_set_geometry);
@@ -234,13 +287,7 @@ unsafe extern "C" fn handle_unmap(listener: *mut ffi::wl_listener, _data: *mut s
unsafe extern "C" fn handle_set_geometry(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
let or = crate::container_of!(listener, XwaylandOverrideRedirect, set_geometry);
- if !(*or).surface_tree.is_null() {
- ffi::wlr_scene_node_set_position(
- (*or).surface_tree as *mut ffi::wlr_scene_node,
- (*(*or).xsurface).x as i32,
- (*(*or).xsurface).y as i32,
- );
- }
+ (*or).place();
}
unsafe extern "C" fn handle_set_override_redirect(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index dfd0107..535888e 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -50,6 +50,47 @@ unsafe fn wl_listener_remove_safe(listener: *mut ffi::wl_listener) {
}
}
+/// Wine draws its own frame in a margin around the window; the compositor
+/// hides it by oversizing and offsetting the X window. Logical pixels.
+pub const WINE_MARGIN: i32 = 16;
+
+/// The factor between X11 root coordinates and the logical layout.
+///
+/// With `xwayland_hidpi` on (the default) the xdg-output global is hidden
+/// from Xwayland (`server.rs`), so it sizes its screen from the wl_output
+/// MODE — the physical pixel grid — and X11 is a physical-pixel world: a
+/// HiDPI-aware X11 app (Houdini, any Qt 6 app reading Xft.dpi) renders at
+/// full resolution and its surfaces are drawn at 1/scale
+/// (`Window::x11_buffer_scale`), sharp instead of upscaled from logical size.
+/// Every position and size crossing into or out of X11 converts through
+/// `to_x11` / `from_x11`; the window's own geometry stays logical.
+///
+/// Off, X11 is the logical layout (Xwayland reads xdg-output) and the
+/// factor is 1. Multi-output with differing scales is not a case X11 can
+/// express — the first output's scale stands for the screen.
+pub unsafe fn x11_scale(server: *mut crate::server::Server) -> f32 {
+ if server.is_null() || !(*server).wm.xwayland_hidpi {
+ return 1.0;
+ }
+ let link = (*server).om.outputs.next;
+ if link != &mut (*server).om.outputs as *mut ffi::wl_list {
+ let output = crate::container_of!(link, crate::output::Output, link);
+ let scale = (*output).current.scale;
+ if scale > 0.0 {
+ return scale;
+ }
+ }
+ 1.0
+}
+
+pub fn to_x11(logical: i32, scale: f32) -> i32 {
+ (logical as f32 * scale).round() as i32
+}
+
+pub fn from_x11(x11: i32, scale: f32) -> i32 {
+ (x11 as f32 / scale).round() as i32
+}
+
impl XwaylandWindow {
pub unsafe fn create(
xsurface: *mut ffi::wlr_xwayland_surface,
@@ -119,49 +160,44 @@ impl XwaylandWindow {
let window = self.window;
let scheduled = &mut (*window).configure_scheduled;
let sent = &mut (*window).configure_sent;
+ let s = x11_scale((*window).server);
if scheduled.width == Some(0) {
- scheduled.width = Some((*self.xsurface).width as u32);
+ scheduled.width = Some(from_x11((*self.xsurface).width as i32, s) as u32);
}
if scheduled.height == Some(0) {
- scheduled.height = Some((*self.xsurface).height as u32);
+ scheduled.height = Some(from_x11((*self.xsurface).height as i32, s) as u32);
}
let mut phys_width = if let Some(w) = scheduled.width {
- w as u16
+ to_x11(w as i32, s) as u16
} else {
(*self.xsurface).width
};
let mut phys_height = if let Some(h) = scheduled.height {
- h as u16
+ to_x11(h as i32, s) as u16
} else {
(*self.xsurface).height
};
- // X11 root coordinates are the LOGICAL layout: Xwayland sizes its
- // screen from the wl_output's logical size (1920x1200 on the scale-2
- // panel), and a window's X size already goes over 1:1. Positions used
- // to be multiplied by the output scale here, which told X a window at
- // logical (120, 30) sat at (240, 60) — and every override-redirect
- // popup the client placed relative to that origin (Houdini's menus,
- // Qt combo lists) landed displaced by the window's own on-screen
- // position, down and to the right, since the override-redirect layer
- // draws them at their raw X coordinates.
- let mut phys_x = (*window).box_geom.x as i16;
- let mut phys_y = (*window).box_geom.y as i16;
+ // X11 root coordinates: see `x11_scale`. Everything sent to X goes
+ // through `to_x11`, everything read back through `from_x11`; the
+ // window's own geometry stays logical.
+ let mut phys_x = to_x11((*window).box_geom.x, s) as i16;
+ let mut phys_y = to_x11((*window).box_geom.y, s) as i16;
let has_parent = !(*self.xsurface).parent.is_null();
if (*window).is_wine() && !has_parent && !(*window).is_fullscreen() {
if scheduled.width.is_some() {
- phys_width += 32;
+ phys_width += to_x11(WINE_MARGIN * 2, s) as u16;
}
if scheduled.height.is_some() {
- phys_height += 32;
+ phys_height += to_x11(WINE_MARGIN * 2, s) as u16;
}
- phys_x -= 16;
- phys_y -= 16;
+ phys_x -= to_x11(WINE_MARGIN, s) as i16;
+ phys_y -= to_x11(WINE_MARGIN, s) as i16;
}
if phys_x != (*self.xsurface).x
@@ -188,15 +224,15 @@ impl XwaylandWindow {
ffi::wlr_xwayland_surface_set_fullscreen(self.xsurface, scheduled.inform_fullscreen);
}
- let mut width = scheduled.width.unwrap_or((*self.xsurface).width as u32);
- let mut height = scheduled.height.unwrap_or((*self.xsurface).height as u32);
+ let mut width = scheduled.width.unwrap_or(from_x11((*self.xsurface).width as i32, s) as u32);
+ let mut height = scheduled.height.unwrap_or(from_x11((*self.xsurface).height as i32, s) as u32);
if (*window).is_wine() && !has_parent && !(*window).is_fullscreen() {
if scheduled.width.is_none() {
- width = width.saturating_sub(32);
+ width = width.saturating_sub((WINE_MARGIN * 2) as u32);
}
if scheduled.height.is_none() {
- height = height.saturating_sub(32);
+ height = height.saturating_sub((WINE_MARGIN * 2) as u32);
}
}
@@ -297,7 +333,7 @@ unsafe fn handle_map_impl(xwindow: *mut XwaylandWindow) {
let has_parent = !(*(*xwindow).xsurface).parent.is_null();
if (*(*xwindow).window).is_wine() && !has_parent && !(*(*xwindow).window).is_fullscreen() {
- ffi::wlr_scene_node_set_position(surface_tree as *mut ffi::wlr_scene_node, -16, -16);
+ ffi::wlr_scene_node_set_position(surface_tree as *mut ffi::wlr_scene_node, -WINE_MARGIN, -WINE_MARGIN);
}
ffi::river_wlr_surface_set_data(surface, &mut (*(*xwindow).window).node as *mut crate::wm_node::WmNode as *mut _);
@@ -361,6 +397,7 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
let is_wine = (*window).is_wine();
let has_parent = !(*(*xwindow).xsurface).parent.is_null();
+ let s = x11_scale((*window).server);
log::info!(
"XWayland configure request: title='{}' class='{}' has_parent={} is_wine={} event=({}, {}, {}, {}) xsurface=({}, {}, {}, {})",
title,
@@ -379,11 +416,10 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
(*event).width,
(*event).height,
);
- // Logical already — see `configure`.
- let log_x = (*event).x as i32;
- let log_y = (*event).y as i32;
- let log_width = (*event).width as u32;
- let log_height = (*event).height as u32;
+ let log_x = from_x11((*event).x as i32, s);
+ let log_y = from_x11((*event).y as i32, s);
+ let log_width = from_x11((*event).width as i32, s) as u32;
+ let log_height = from_x11((*event).height as i32, s) as u32;
(*window).box_geom.x = log_x;
(*window).box_geom.y = log_y;
@@ -410,10 +446,10 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
let mut w = log_w;
let mut h = log_h;
if is_wine && !has_parent && !is_fullscreen {
- w += 32;
- h += 32;
+ w += (WINE_MARGIN * 2) as u32;
+ h += (WINE_MARGIN * 2) as u32;
}
- (w as u16, h as u16)
+ (to_x11(w as i32, s) as u16, to_x11(h as i32, s) as u16)
} else {
((*event).width, (*event).height)
}
@@ -421,12 +457,12 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
((*event).width, (*event).height)
};
- let mut phys_x = (*window).box_geom.x as i16;
- let mut phys_y = (*window).box_geom.y as i16;
+ let mut phys_x = to_x11((*window).box_geom.x, s) as i16;
+ let mut phys_y = to_x11((*window).box_geom.y, s) as i16;
if is_wine && !has_parent && !is_fullscreen {
- phys_x -= 16;
- phys_y -= 16;
+ phys_x -= to_x11(WINE_MARGIN, s) as i16;
+ phys_y -= to_x11(WINE_MARGIN, s) as i16;
}
ffi::wlr_xwayland_surface_configure(
@@ -436,11 +472,11 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
phys_width,
phys_height,
);
- let mut log_width = phys_width as u32;
- let mut log_height = phys_height as u32;
+ let mut log_width = from_x11(phys_width as i32, s) as u32;
+ let mut log_height = from_x11(phys_height as i32, s) as u32;
if is_wine && !has_parent && !is_fullscreen {
- log_width = log_width.saturating_sub(32);
- log_height = log_height.saturating_sub(32);
+ log_width = log_width.saturating_sub((WINE_MARGIN * 2) as u32);
+ log_height = log_height.saturating_sub((WINE_MARGIN * 2) as u32);
}
(*window).set_dimensions(log_width, log_height);
}