Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat(xwayland): per-app exception to xwayland_hidpi for full-screen X11 games
Since da95c6f every X11 client sees a physical-pixel screen, and a game
that sizes itself to that screen renders scale² as many pixels as it did
before: Trackmania under Proton (borderless "windowedfull") went from a
1920x1200 swapchain to 3840x2400 on the scale-2 panel, with 4x MSAA on
top. The screen Xwayland shows is one thing for every client, so the
exception works on the compositor's side of the window instead:
`window_manager { xwayland_hidpi_except "Trackmania" }` names windows
whose configures go out in logical pixels and whose buffers draw at 1
— the pre-hidpi arrangement — so the fullscreen answer to "give me the
whole root" is the logical size and that is what the game renders.
`xwayland_window::x11_scale_for(server, xsurface)` resolves the factor
for one surface and every per-window caller now goes through it
(configure, request_configure, the saved-size restore, the dimensions
readback, `Window::x11_buffer_scale`, override-redirect placement and
per-frame scaling, and the pointer hit-test in `Scene::at_impl`); the
screen-wide `x11_scale` remains for what has no window (Xft.dpi). A
pattern is matched against WM_CLASS class, WM_CLASS instance and the
title, since every Proton window shares the class `steam_proton`.
Verified headless at scale 2 (Xwayland-enabled shadow, 640x360
logical): a fullscreen GTK4 X11 window titled Trackmania is configured
to 640x360 in X, its buffer draws 640x360 at 1:1 and a pointer move to
logical (300,200) reaches it as (300,200); a zenity window in the same
session stays physical (300x266 in X, 150x133 logical, drawn at 1/2).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/server/config.rs | 17 ++++++-
src/server/scene.rs | 8 +++-
src/server/window.rs | 12 +++--
src/server/window_manager.rs | 5 ++
src/server/xwayland_override_redirect.rs | 4 +-
src/server/xwayland_window.rs | 82 ++++++++++++++++++++++++++++++--
6 files changed, 115 insertions(+), 13 deletions(-)
diff --git a/src/server/config.rs b/src/server/config.rs
index 7d87fc6..df58989 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -399,6 +399,15 @@ pub struct WindowManagerConfig {
/// 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>,
+ /// Per-app exception to `xwayland_hidpi`: windows matching one of these
+ /// patterns are configured and drawn in the logical world (factor 1)
+ /// while every other X11 window stays physical. Meant for games and other
+ /// X11 clients that size themselves to the whole screen and would render
+ /// scale² times the pixels for nothing. A pattern is tried against the
+ /// window's WM_CLASS class, its WM_CLASS instance and its title, since
+ /// every Proton window shares the class `steam_proton`; `*` wildcards as
+ /// in `rounded_apps`. KDL: `xwayland_hidpi_except "Trackmania"`.
+ pub xwayland_hidpi_except: Option<Vec<String>>,
/// Apps whose windows turn trackpad input into a view drag (Space +
/// button) — see `cursor::ViewDrag`. KDL: `touchpad_view_apps "Houdini FX"`.
pub touchpad_view_apps: Option<Vec<String>>,
@@ -2280,11 +2289,12 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
let rounded_apps = get_child_args_string_vec_opt(node, "rounded_apps");
let bevel_apps = get_child_args_string_vec_opt(node, "bevel_apps");
let xwayland_hidpi = get_child_arg_bool_opt(node, "xwayland_hidpi");
+ let xwayland_hidpi_except = get_child_args_string_vec_opt(node, "xwayland_hidpi_except");
let touchpad_view_apps = get_child_args_string_vec_opt(node, "touchpad_view_apps");
let touchpad_view_swipe = get_child_arg_string_opt(node, "touchpad_view_swipe");
let touchpad_view_sensitivity = get_child_arg_f64_opt(node, "touchpad_view_sensitivity");
let touchpad_view_invert = get_child_arg_bool_opt(node, "touchpad_view_invert");
- 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, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, touchpad_view_invert });
+ 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, xwayland_hidpi_except, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, touchpad_view_invert });
}
Ok(Config {
@@ -2345,6 +2355,11 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
.as_ref()
.and_then(|wm| wm.xwayland_hidpi)
.unwrap_or(true);
+ state.xwayland_hidpi_except = config
+ .window_manager
+ .as_ref()
+ .and_then(|wm| wm.xwayland_hidpi_except.clone())
+ .unwrap_or_default();
{
let tv = config.window_manager.as_ref();
state.touchpad_view_apps = tv.and_then(|w| w.touchpad_view_apps.clone()).unwrap_or_default();
diff --git a/src/server/scene.rs b/src/server/scene.rs
index ab0e31d..54b450c 100644
--- a/src/server/scene.rs
+++ b/src/server/scene.rs
@@ -218,12 +218,16 @@ impl Scene {
if !window.is_null()
&& matches!((*window).impl_type, crate::window::WindowImpl::Xwayland(_)) =>
{
- let s = crate::xwayland_window::x11_scale((*window).server) as f64;
+ let xsurface = match (*window).impl_type {
+ crate::window::WindowImpl::Xwayland(xw) if !xw.is_null() => (*xw).xsurface as *const _,
+ _ => std::ptr::null(),
+ };
+ let s = crate::xwayland_window::x11_scale_for((*window).server, xsurface) as f64;
let zoom = if (*window).scale > 0.0 { (*window).scale } else { 1.0 };
(s != 1.0).then_some(s / zoom)
}
SceneNodeDataVal::OverrideRedirect(or) if !or.is_null() => {
- let s = crate::xwayland_window::x11_scale((*or).server) as f64;
+ let s = crate::xwayland_window::x11_scale_for((*or).server, (*or).xsurface) as f64;
(s != 1.0).then_some(s)
}
_ => None,
diff --git a/src/server/window.rs b/src/server/window.rs
index 1318f6f..8431756 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -934,10 +934,12 @@ 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.
+ /// `xwayland_window::x11_scale_for`); 1 for everything else, including
+ /// an X11 window named in `xwayland_hidpi_except`.
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
+ if let WindowImpl::Xwayland(xwindow) = self.impl_type {
+ let xsurface = if xwindow.is_null() { std::ptr::null() } else { (*xwindow).xsurface as *const _ };
+ 1.0 / crate::xwayland_window::x11_scale_for(self.server, xsurface) as f64
} else {
1.0
}
@@ -1120,7 +1122,7 @@ impl Window {
}
WindowImpl::Xwayland(xwindow) => {
if !xwindow.is_null() && !(*xwindow).xsurface.is_null() {
- let s = crate::xwayland_window::x11_scale(self.server);
+ let s = crate::xwayland_window::x11_scale_for(self.server, (*xwindow).xsurface);
(*(*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;
}
@@ -2613,7 +2615,7 @@ impl Window {
}
WindowImpl::Xwayland(xwindow) => {
if !xwindow.is_null() {
- let s = crate::xwayland_window::x11_scale(self.server);
+ let s = crate::xwayland_window::x11_scale_for(self.server, (*xwindow).xsurface);
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();
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 8f79015..4f2f714 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -117,6 +117,9 @@ pub struct WindowManager {
/// Xwayland sees a physical-pixel screen and X11 surfaces draw at
/// 1/scale (see `WindowManagerConfig::xwayland_hidpi`).
pub xwayland_hidpi: bool,
+ /// X11 windows kept in the logical world while `xwayland_hidpi` is on
+ /// (see `xwayland_window::x11_scale_for`).
+ pub xwayland_hidpi_except: Vec<String>,
/// Trackpad-to-view-drag emulation (see `cursor::ViewDrag`).
pub touchpad_view_apps: Vec<String>,
pub touchpad_view_swipe_tumble: bool,
@@ -358,6 +361,7 @@ impl WindowManager {
self.sent.output_config = std::ptr::null_mut();
self.output_scale = 1.0;
self.xwayland_hidpi = true;
+ self.xwayland_hidpi_except = Vec::new();
self.touchpad_view_apps = Vec::new();
self.touchpad_view_swipe_tumble = false;
self.touchpad_view_sensitivity = 1.0;
@@ -433,6 +437,7 @@ impl WindowManager {
self.layout = crate::config::Layout::default();
self.output_scale = 1.0;
self.xwayland_hidpi = true;
+ self.xwayland_hidpi_except = Vec::new();
self.touchpad_view_apps = Vec::new();
self.touchpad_view_swipe_tumble = false;
self.touchpad_view_sensitivity = 1.0;
diff --git a/src/server/xwayland_override_redirect.rs b/src/server/xwayland_override_redirect.rs
index b81f47c..3b4072c 100644
--- a/src/server/xwayland_override_redirect.rs
+++ b/src/server/xwayland_override_redirect.rs
@@ -100,7 +100,7 @@ impl XwaylandOverrideRedirect {
if self.surface_tree.is_null() {
return;
}
- let s = crate::xwayland_window::x11_scale(self.server);
+ let s = crate::xwayland_window::x11_scale_for(self.server, self.xsurface);
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),
@@ -115,7 +115,7 @@ impl XwaylandOverrideRedirect {
if self.surface_tree.is_null() {
return;
}
- let s = crate::xwayland_window::x11_scale(self.server) as f64;
+ let s = crate::xwayland_window::x11_scale_for(self.server, self.xsurface) as f64;
if s == 1.0 {
return;
}
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 1e37a58..518161d 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -67,7 +67,10 @@ pub const WINE_MARGIN: i32 = 16;
///
/// 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.
+/// express — the first output's scale stands for the screen. A single
+/// window can opt out through `xwayland_hidpi_except` — see `x11_scale_for`,
+/// which every per-window caller goes through; this screen-wide value is
+/// only for what has no window, like the Xft.dpi pushed at Xwayland-ready.
///
/// The factor must survive the output going away. A lid-close suspend
/// destroys the DRM output and re-creates it on resume, and X11 windows
@@ -143,6 +146,52 @@ pub fn in_output_change_grace() -> bool {
.is_some_and(|deadline| std::time::Instant::now() < deadline)
}
+/// `x11_scale` for one X11 surface: 1 when the window is named in
+/// `window_manager { xwayland_hidpi_except }`, else the screen's factor.
+///
+/// The screen Xwayland shows is one thing for every client, so an exempt
+/// window still SEES a physical-pixel root; what changes is what the
+/// compositor does with it. Its configures go out in logical pixels and its
+/// buffer is drawn at 1 (`Window::x11_buffer_scale`), the pre-`xwayland_hidpi`
+/// arrangement — so a borderless-fullscreen game that asks for the whole
+/// root is answered with the logical size and renders that many pixels,
+/// not scale² as many. Matched by WM_CLASS class, WM_CLASS instance or
+/// title (`hidpi_exempt`), re-evaluated on every use so a title that
+/// arrives after the first configure still takes effect.
+pub unsafe fn x11_scale_for(
+ server: *mut crate::server::Server,
+ xsurface: *const ffi::wlr_xwayland_surface,
+) -> f32 {
+ if server.is_null() || !(*server).wm.xwayland_hidpi {
+ return 1.0;
+ }
+ if !xsurface.is_null() && !(*server).wm.xwayland_hidpi_except.is_empty() {
+ let text = |p: *const libc::c_char| -> String {
+ if p.is_null() { String::new() } else { std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() }
+ };
+ let class = text((*xsurface).class);
+ let instance = text((*xsurface).instance);
+ let title = text((*xsurface).title);
+ if hidpi_exempt(&(*server).wm.xwayland_hidpi_except, &class, &instance, &title) {
+ return 1.0;
+ }
+ }
+ x11_scale(server)
+}
+
+/// Whether any of `patterns` names this window: each is tried against the
+/// WM_CLASS class, the WM_CLASS instance and the title with the
+/// `app_id_matches` rules (case-insensitive, `*` wildcards). Empty fields
+/// never match.
+pub fn hidpi_exempt(patterns: &[String], class: &str, instance: &str, title: &str) -> bool {
+ use crate::window_manager::app_id_matches;
+ patterns.iter().any(|p| {
+ [class, instance, title]
+ .iter()
+ .any(|field| !field.is_empty() && app_id_matches(p, field))
+ })
+}
+
pub fn to_x11(logical: i32, scale: f32) -> i32 {
(logical as f32 * scale).round() as i32
}
@@ -220,7 +269,7 @@ impl XwaylandWindow {
let window = self.window;
let scheduled = &mut (*window).configure_scheduled;
let sent = &mut (*window).configure_sent;
- let s = x11_scale((*window).server);
+ let s = x11_scale_for((*window).server, self.xsurface);
if scheduled.width == Some(0) {
scheduled.width = Some(from_x11((*self.xsurface).width as i32, s) as u32);
@@ -457,7 +506,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);
+ let s = x11_scale_for((*window).server, (*xwindow).xsurface);
log::info!(
"XWayland configure request: title='{}' class='{}' has_parent={} is_wine={} event=({}, {}, {}, {}) xsurface=({}, {}, {}, {})",
title,
@@ -684,6 +733,33 @@ mod tests {
assert_eq!(resolve_x11_scale(None, &last), 1.5);
}
+ fn pats(list: &[&str]) -> Vec<String> {
+ list.iter().map(|s| s.to_string()).collect()
+ }
+
+ #[test]
+ fn exempt_matches_class_instance_or_title() {
+ // Proton: every window is class steam_proton, so the game is told
+ // apart by its instance (the exe) or its title.
+ let p = pats(&["Trackmania"]);
+ assert!(hidpi_exempt(&p, "steam_proton", "trackmania.exe", "Trackmania"));
+ assert!(hidpi_exempt(&p, "steam_proton", "", "Trackmania"));
+ assert!(hidpi_exempt(&p, "Trackmania", "", ""));
+ assert!(!hidpi_exempt(&p, "steam_proton", "upc.exe", "Ubisoft Connect"));
+ // Wildcards and case follow app_id_matches.
+ let p = pats(&["trackmania*"]);
+ assert!(hidpi_exempt(&p, "steam_proton", "Trackmania.exe", ""));
+ assert!(!hidpi_exempt(&p, "steam_proton", "", "My Trackmania"));
+ }
+
+ #[test]
+ fn exempt_ignores_empty_fields_and_lists() {
+ assert!(!hidpi_exempt(&[], "steam_proton", "trackmania.exe", "Trackmania"));
+ // An empty field must not match a pattern that is itself empty-ish.
+ assert!(!hidpi_exempt(&pats(&["*"]), "", "", ""));
+ assert!(hidpi_exempt(&pats(&["*"]), "x", "", ""));
+ }
+
#[test]
fn scale_before_any_output_is_one() {
let last = AtomicU32::new(0);