Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
fix(cursor): draw an X11 client's cursor at 1/scale, like its window
Under `xwayland_hidpi` X11 is a physical-pixel world and an X11 window's
buffer is drawn at 1/scale (`Window::x11_buffer_scale`) -- but its cursor
was not. Xwayland commits the cursor surface at buffer scale 1, and
wlr_cursor sizes the pointer from the surface's LOGICAL size, so an
N-pixel X cursor came out N logical pixels: Houdini's 48x48 crosshair
rendered 96 physical px against the desktop's 48.
A commit listener on the cursor surface, registered ahead of wlroots' own
so the size is right when wlroots reads it, divides the committed size by
the scale of the X11 window under the pointer; the hotspot goes with it.
A window named in `xwayland_hidpi_except` is drawn in the logical world
and keeps a logical cursor.
The size X11 clients ask the theme for has to follow. `Xcursor.size` is
now published even at scale 1, because libXcursor otherwise guesses from
the screen height, and the session stops exporting XCURSOR_SIZE: it beats
that resource in both libXcursor and libxcb-cursor, so every X11 app was
asking for 24 physical pixels and would now get a half-size cursor.
Wayland toolkits default to 24 logical on their own (sctk, GTK, Chromium).
Verified in a shadow at output scale 2: Houdini's crosshair 96 -> 48
physical px, an X11 theme cursor 48 px, Wayland cursors unchanged, and no
crash across hover on/off and closing a window under the pointer. Tablet
tools drive their own wlr_cursor through the same unscaled path and are
left alone.
Co-Authored-By: Claude Opus 5 <[email protected]>
scripts/startcce | 8 ++-
src/server/seat.rs | 126 ++++++++++++++++++++++++++++++++++++++-
src/server/server.rs | 48 +++++++++------
src/server/wlroots_log_wrapper.c | 24 ++++++++
src/server/xwayland_window.rs | 25 ++++++++
wrapper.h | 2 +
6 files changed, 212 insertions(+), 21 deletions(-)
diff --git a/scripts/startcce b/scripts/startcce
index 91dcd31..211e575 100755
--- a/scripts/startcce
+++ b/scripts/startcce
@@ -16,7 +16,13 @@ export XDG_CURRENT_DESKTOP=cce
export XDG_SESSION_TYPE=wayland
export PATH="$HOME/.local/bin:$PATH"
export XCURSOR_THEME="cce"
-export XCURSOR_SIZE=24
+# No XCURSOR_SIZE. Wayland toolkits already default to 24 logical pixels (sctk,
+# GTK and Chromium all do), and the two worlds want DIFFERENT numbers: under
+# `xwayland_hidpi` X11 is a physical-pixel world, so an X11 cursor has to be
+# 24*scale, which the compositor publishes as the `Xcursor.size` X resource
+# when Xwayland comes up (server.rs). XCURSOR_SIZE beats that resource in both
+# libXcursor and libxcb-cursor, so exporting it here made every X11 app ask for
+# a 24-pixel cursor and get one drawn at half size.
export XCURSOR_PATH="${XDG_DATA_HOME:-$HOME/.local/share}/icons:$HOME/.icons:/usr/share/icons"
export WLR_NO_HARDWARE_CURSORS=1
export WLR_DRM_DEVICES=/dev/dri/card1
diff --git a/src/server/seat.rs b/src/server/seat.rs
index d84dd4e..cf433ef 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -114,6 +114,13 @@ pub struct Seat {
pub wm_sent_y: i32,
pub request_set_cursor: ffi::wl_listener,
+ /// The Xwayland cursor surface currently being shown at 1/`x11_cursor_scale`,
+ /// null when the pointer image is anyone else's. See
+ /// `handle_x11_cursor_commit` for why an X11 cursor needs shrinking at all.
+ pub x11_cursor_surface: *mut ffi::wlr_surface,
+ pub x11_cursor_scale: f32,
+ pub x11_cursor_commit: ffi::wl_listener,
+ pub x11_cursor_destroy: ffi::wl_listener,
pub request_set_selection: ffi::wl_listener,
pub request_start_drag: ffi::wl_listener,
pub start_drag: ffi::wl_listener,
@@ -157,6 +164,10 @@ impl Seat {
wm_sent_x: 0,
wm_sent_y: 0,
request_set_cursor: std::mem::zeroed(),
+ x11_cursor_surface: std::ptr::null_mut(),
+ x11_cursor_scale: 1.0,
+ x11_cursor_commit: std::mem::zeroed(),
+ x11_cursor_destroy: std::mem::zeroed(),
request_set_selection: std::mem::zeroed(),
request_start_drag: std::mem::zeroed(),
start_drag: std::mem::zeroed(),
@@ -268,6 +279,7 @@ impl Seat {
crate::server::wl_list_remove(&mut (*seat).link as *mut ffi::wl_list as *mut crate::server::WlList);
crate::server::wl_list_remove(&mut (*seat).link_sent as *mut ffi::wl_list as *mut crate::server::WlList);
+ (*seat).unwatch_x11_cursor();
wl_listener_remove(&mut (*seat).request_set_cursor);
wl_listener_remove(&mut (*seat).request_set_selection);
wl_listener_remove(&mut (*seat).request_start_drag);
@@ -282,6 +294,56 @@ impl Seat {
let _boxed = Box::from_raw(seat);
}
+ /// Start (or stop) shrinking an X11 client's cursor surface to 1/`scale`.
+ ///
+ /// `scale` of 1 — a Wayland client's cursor, or an X11 window exempted
+ /// from `xwayland_hidpi` — just drops any surface being watched. The
+ /// commit listener is added BEFORE the caller hands the surface to
+ /// `wlr_cursor_set_surface`, so it sits ahead of wlroots' own commit
+ /// listener in the signal and the size is already right when wlroots
+ /// reads it.
+ unsafe fn watch_x11_cursor(&mut self, surface: *mut ffi::wlr_surface, scale: f32) {
+ if scale == 1.0 || surface.is_null() {
+ self.unwatch_x11_cursor();
+ return;
+ }
+ if self.x11_cursor_surface == surface {
+ self.x11_cursor_scale = scale;
+ return;
+ }
+ self.unwatch_x11_cursor();
+ self.x11_cursor_surface = surface;
+ self.x11_cursor_scale = scale;
+
+ let commit = &mut self.x11_cursor_commit as *mut ffi::wl_listener as *mut WlListener;
+ (*commit).notify = Some(handle_x11_cursor_commit);
+ wl_signal_add(
+ ffi::river_wlr_surface_get_commit_signal(surface),
+ &mut self.x11_cursor_commit,
+ );
+
+ let destroy = &mut self.x11_cursor_destroy as *mut ffi::wl_listener as *mut WlListener;
+ (*destroy).notify = Some(handle_x11_cursor_destroy);
+ wl_signal_add(
+ ffi::river_wlr_surface_get_destroy_signal(surface),
+ &mut self.x11_cursor_destroy,
+ );
+
+ // Whatever is already committed on the surface is what wlroots reads
+ // first; the fresh buffer only arrives on the commit that follows.
+ ffi::river_wlr_surface_scale_logical_size(surface, scale);
+ }
+
+ unsafe fn unwatch_x11_cursor(&mut self) {
+ if self.x11_cursor_surface.is_null() {
+ return;
+ }
+ self.x11_cursor_surface = std::ptr::null_mut();
+ self.x11_cursor_scale = 1.0;
+ wl_listener_remove(&mut self.x11_cursor_commit);
+ wl_listener_remove(&mut self.x11_cursor_destroy);
+ }
+
pub unsafe fn attach_device(&mut self, device: *mut crate::input_device::InputDevice) {
(*device).seat = self;
let dev_type = ffi::river_wlr_input_device_get_type((*device).wlr_device);
@@ -1708,15 +1770,75 @@ unsafe extern "C" fn handle_request_set_cursor(
// The client owns the cursor image from here; a compositor-driven
// xcursor animation would paint over it on its next tick.
seat.cursor.stop_xcursor_animation();
+ // An X11 client's cursor is a physical-pixel bitmap like the rest of
+ // its drawing, but Xwayland commits it at buffer scale 1, so wlroots
+ // would show it at that many LOGICAL pixels — Houdini's 48px
+ // crosshair came out 96 physical px against the desktop's 48. Show it
+ // at 1/scale, the way the window's own buffer already is
+ // (`Window::x11_buffer_scale`), and put the hotspot in the same units.
+ let scale = if is_xwayland_client(seat.server, event_client) {
+ crate::xwayland_window::x11_scale_for_surface(
+ seat.server,
+ ffi::river_wlr_seat_get_pointer_focused_surface(seat.wlr_seat),
+ )
+ } else {
+ 1.0
+ };
+ seat.watch_x11_cursor((*event).surface, scale);
+ let (hotspot_x, hotspot_y) = if scale != 1.0 {
+ (
+ ((*event).hotspot_x as f32 / scale).round() as i32,
+ ((*event).hotspot_y as f32 / scale).round() as i32,
+ )
+ } else {
+ ((*event).hotspot_x, (*event).hotspot_y)
+ };
ffi::wlr_cursor_set_surface(
seat.cursor.wlr_cursor,
(*event).surface,
- (*event).hotspot_x,
- (*event).hotspot_y,
+ hotspot_x,
+ hotspot_y,
);
}
}
+/// Is this the Xwayland client itself? Every X11 window's requests arrive as
+/// that one client, which is what separates an X11 cursor from a Wayland one.
+unsafe fn is_xwayland_client(
+ server: *mut crate::server::Server,
+ client: *mut ffi::wl_client,
+) -> bool {
+ if server.is_null() || (*server).xwayland.is_null() || client.is_null() {
+ return false;
+ }
+ let xwayland = (*server).xwayland as *mut crate::server::WlrXwayland;
+ let xserver = (*xwayland).server as *mut ffi::wlr_xwayland_server;
+ if xserver.is_null() {
+ return false;
+ }
+ !(*xserver).client.is_null() && (*xserver).client == client
+}
+
+/// Keep an X11 cursor surface shown at 1/scale for as long as it is the
+/// pointer image: `wlr_cursor` re-reads the surface's logical size on every
+/// commit, so the shrink has to be re-applied there — before wlroots reads it,
+/// which is why this listener is added ahead of `wlr_cursor_set_surface`.
+unsafe extern "C" fn handle_x11_cursor_commit(
+ listener: *mut ffi::wl_listener,
+ _data: *mut std::ffi::c_void,
+) {
+ let seat = &mut *crate::container_of!(listener, Seat, x11_cursor_commit);
+ ffi::river_wlr_surface_scale_logical_size(seat.x11_cursor_surface, seat.x11_cursor_scale);
+}
+
+unsafe extern "C" fn handle_x11_cursor_destroy(
+ listener: *mut ffi::wl_listener,
+ _data: *mut std::ffi::c_void,
+) {
+ let seat = &mut *crate::container_of!(listener, Seat, x11_cursor_destroy);
+ seat.unwatch_x11_cursor();
+}
+
unsafe extern "C" fn handle_request_set_selection(
listener: *mut ffi::wl_listener,
data: *mut std::ffi::c_void,
diff --git a/src/server/server.rs b/src/server/server.rs
index 6a1a646..a95d9f1 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -551,28 +551,40 @@ unsafe extern "C" fn handle_xwayland_ready(listener: *mut ffi::wl_listener, _dat
// 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.
+ //
+ // `Xcursor.size` goes with them, and is where X11 clients get their
+ // cursor size from — the session deliberately exports no XCURSOR_SIZE,
+ // which would beat this resource (see `scripts/startcce`). It is the
+ // physical size for the same reason: an X11 cursor bitmap is drawn at
+ // 1/scale like the rest of the client's drawing (`seat.rs`,
+ // `handle_request_set_cursor`), so 24 logical pixels is 24*scale of
+ // them. Sent even at scale 1, because otherwise libXcursor guesses
+ // from the screen height and lands somewhere else entirely.
let s = crate::xwayland_window::x11_scale(server);
+ let dpi = (96.0 * s).round() as i32;
+ let cursor = (24.0 * s).round() as i32;
+ let mut resources = format!("Xcursor.size: {}\n", cursor);
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);
+ resources.push_str(&format!("Xft.dpi: {}\n", dpi));
+ }
+ 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, "{}", resources);
}
- Err(e) => log::warn!("Xwayland HiDPI: could not run xrdb to set Xft.dpi: {}", e),
+ std::thread::spawn(move || { let _ = child.wait(); });
+ log::info!("Xwayland: X11 scale {} — set Xcursor.size {}{} via xrdb", s, cursor,
+ if s != 1.0 { format!(", Xft.dpi {}", dpi) } else { String::new() });
}
+ Err(e) => log::warn!("Xwayland: could not run xrdb to set X resources: {}", e),
}
}
}
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index 5132429..10582fb 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -3,6 +3,7 @@
#define _POSIX_C_SOURCE 199309L
#include <assert.h>
+#include <math.h>
#include <stdarg.h>
#include <stdlib.h>
#include <stdio.h>
@@ -215,6 +216,29 @@ struct wl_signal *river_wlr_surface_get_commit_signal(struct wlr_surface *surfac
return &surface->events.commit;
}
+struct wl_signal *river_wlr_surface_get_destroy_signal(struct wlr_surface *surface) {
+ return &surface->events.destroy;
+}
+
+// Show a surface at 1/scale of the size it committed itself at.
+//
+// For the cursor surface of an X11 client. Xwayland always commits its cursor
+// at buffer scale 1, and wlr_cursor sizes the pointer from `current.width` --
+// the surface's LOGICAL size -- so an N-pixel X cursor comes out N logical
+// pixels, twice as big as everything else on a scale-2 panel. Under
+// `xwayland_hidpi` X11 is a physical-pixel world and an X11 window's own
+// buffer is already drawn at 1/scale (Window::x11_buffer_scale); this puts its
+// cursor in the same world. Derived from the buffer, so applying it twice to
+// one committed state is a no-op.
+void river_wlr_surface_scale_logical_size(struct wlr_surface *surface, float scale) {
+ if (surface == NULL || scale <= 0.0f || scale == 1.0f) {
+ return;
+ }
+ float total = (float)surface->current.scale * scale;
+ surface->current.width = (int)roundf((float)surface->current.buffer_width / total);
+ surface->current.height = (int)roundf((float)surface->current.buffer_height / total);
+}
+
enum wlr_input_device_type river_wlr_input_device_get_type(struct wlr_input_device *dev) {
return dev->type;
}
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 9fe29d9..3016a7b 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -213,6 +213,31 @@ pub unsafe fn x11_scale_for(
x11_scale(server)
}
+/// `x11_scale_for` for a surface instead of an xsurface: the factor of the X11
+/// window that surface belongs to, and 1 for anything that is not X11.
+///
+/// What a cursor request needs. The scale has to come from the window under
+/// the pointer rather than the screen, because a window named in
+/// `xwayland_hidpi_except` is drawn in the logical world and its cursor
+/// belongs there with it.
+pub unsafe fn x11_scale_for_surface(
+ server: *mut crate::server::Server,
+ surface: *mut ffi::wlr_surface,
+) -> f32 {
+ if surface.is_null() {
+ return 1.0;
+ }
+ let root = ffi::wlr_surface_get_root_surface(surface);
+ if root.is_null() {
+ return 1.0;
+ }
+ let xsurface = ffi::wlr_xwayland_surface_try_from_wlr_surface(root);
+ if xsurface.is_null() {
+ return 1.0;
+ }
+ x11_scale_for(server, xsurface as *const _)
+}
+
/// 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
diff --git a/wrapper.h b/wrapper.h
index 00eae6b..57d4a61 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -138,6 +138,8 @@ struct wl_global *river_wlr_output_get_global(struct wlr_output *output);
void *river_wlr_surface_get_data(struct wlr_surface *surface);
void river_wlr_surface_set_data(struct wlr_surface *surface, void *data);
struct wl_signal *river_wlr_surface_get_commit_signal(struct wlr_surface *surface);
+struct wl_signal *river_wlr_surface_get_destroy_signal(struct wlr_surface *surface);
+void river_wlr_surface_scale_logical_size(struct wlr_surface *surface, float scale);
struct wl_signal *river_wlr_surface_get_map_signal(struct wlr_surface *surface);
struct wl_signal *river_wlr_surface_get_unmap_signal(struct wlr_surface *surface);
struct wl_resource *river_wlr_surface_get_resource(struct wlr_surface *surface);