Show a real cursor in standalone mode (wlr_cursor, software-composited)

Standalone has no host compositor to draw a pointer, so give the output a
real wlr_cursor driven by XCURSOR_THEME/XCURSOR_SIZE.  Force software
cursors on every standalone output (the headless backend's set_cursor hook
would trick wlroots into skipping the scene pass while drawing nothing, and
a hardware plane would float the cursor outside the composited buffer).

Refuse to run when no 'default' sprite resolves: wlroots 0.19 substitutes
a builtin cursor silently, so check the theme dirs ourselves along the
loader's XCURSOR_PATH search and hard-fail instead of presenting an
invisible pointer.

Whole-output damage on every pointer move/warp, because the scene renderer
only composites the software cursor inside the damaged region and raw
cursor motion damages only the output.  The stdin feed gains CTL_CMD_WARP
so tests can place the pointer; the visible-cursor test probes through the
GL dump's bottom-up flip, and a missing-theme regression test asserts rc 1.
This commit is contained in:
Andrew Hamilton 2026-08-16 12:20:08 +10:00
parent c4a4b73393
commit 9db00591ca
2 changed files with 254 additions and 0 deletions

View file

@ -66,17 +66,20 @@
#include <wlr/render/wlr_texture.h>
#include <wlr/types/wlr_buffer.h>
#include <wlr/types/wlr_compositor.h>
#include <wlr/types/wlr_cursor.h>
#include <wlr/types/wlr_data_control_v1.h>
#include <wlr/types/wlr_data_device.h>
#include <wlr/types/wlr_input_device.h>
#include <wlr/types/wlr_keyboard.h>
#include <wlr/types/wlr_linux_dmabuf_v1.h>
#include <wlr/types/wlr_output.h>
#include <wlr/types/wlr_output_layout.h>
#include <wlr/types/wlr_pointer.h>
#include <wlr/types/wlr_scene.h>
#include <wlr/types/wlr_seat.h>
#include <wlr/types/wlr_shm.h>
#include <wlr/types/wlr_subcompositor.h>
#include <wlr/types/wlr_xcursor_manager.h>
#include <wlr/types/wlr_xdg_shell.h>
#include <wlr/util/log.h>
@ -126,6 +129,11 @@ struct tabbed_server {
/* Transparent full-output sentinel used to force whole-output damage on
overlay transitions; see standalone_overlay_damage(). */
struct wlr_scene_rect *overlay_damage_rect;
/* Standalone-only cursor: a real wlr_cursor so the pointer is visible
(hardware cursor plane on DRM, scene-composited on headless). */
struct wlr_cursor *cursor;
struct wlr_xcursor_manager *xcursor_mgr;
struct wlr_output_layout *output_layout;
struct wlr_seat *seat;
struct wlr_xdg_shell *xdg_shell;
struct wlr_keyboard keyboard;
@ -1627,6 +1635,20 @@ server.output = output;
server.scene_output = wlr_scene_output_create(server.scene, output);
wlr_scene_output_set_position(server.scene_output, 0, 0);
/* Always render the pointer into our own frame buffer (software cursor)
instead of a hardware cursor plane: the headless backend's set_cursor
hook makes wlroots believe it has a plane (so the scene software pass
is skipped) while actually drawing nothing, and a hardware plane would
float the cursor outside the buffer we composite/dump. */
if (server.standalone)
wlr_output_lock_software_cursors(output, true);
/* Give the standalone cursor a place to live. Idempotent across VT
round-trips (wlr_output_layout_add reuses the existing entry), and the
cursor attaches its output cursor via the layout's add signal. */
if (server.cursor)
wlr_output_layout_add(server.output_layout, output, 0, 0);
/* Render whenever the output is ready for a frame (refresh-rate driven),
like tinywl; this is what drives client frame callbacks / surface
enter events even before any damage has accumulated. */
@ -3250,6 +3272,9 @@ static void dump_standalone_buffer(struct wlr_buffer *buf) {
fclose(f);
tabbed_log(TABBED_LOG_INFO, "[dump] host back buffer -> %s (%dx%d)\n",
path, w, h);
if (server.cursor)
tabbed_log(TABBED_LOG_INFO,
"[dump] cursor at (%f,%f)\n", server.cursor->x, server.cursor->y);
}
free(px);
dump_seq++;
@ -3325,6 +3350,21 @@ static void update_overlay_visible(bool alt) {
needs_redraw = true;
}
/* handle_new_output() forces software cursors on every standalone output, so
the pointer is always composited into our frame by the scene renderer --
which only repaints where the frame was damaged. Cursor motion damages the
*output*, not the scene, so force whole-output damage on every move or the
pointer would never be painted. */
static void standalone_cursor_motion(struct wl_listener *listener, void *data) {
(void)listener; (void)data;
if (server.standalone)
standalone_overlay_damage();
}
static struct wl_listener standalone_cursor_motion_listener = {
.notify = standalone_cursor_motion,
};
/* Shared key handling for both the host keyboard and native input devices:
tab-switch bindings + overlay visibility, with the caller forwarding the
key to the seat afterwards. `mods_depressed` uses the wl_keyboard modifier
@ -3618,6 +3658,8 @@ static void handle_new_input(struct wl_listener *listener, void *data) {
}
case WLR_INPUT_DEVICE_POINTER: {
struct wlr_pointer *ptr = wlr_pointer_from_input_device(device);
if (server.cursor)
wlr_cursor_attach_input_device(server.cursor, device);
struct standalone_ptrdev *pd = calloc(1, sizeof(*pd));
if (!pd) break;
pd->pointer = ptr;
@ -3652,6 +3694,7 @@ static void handle_new_input(struct wl_listener *listener, void *data) {
*/
#define CTL_CMD_KEY 1
#define CTL_CMD_MODS 2
#define CTL_CMD_WARP 3
#define CTL_MAX_MSG 20 /* 1 + 4 + 4 (or 1 + 16) after padding */
#define CTL_BUF_SIZE 512
@ -3676,6 +3719,17 @@ static void ctl_dispatch(const uint8_t *msg, size_t len) {
host_kbd_modifiers(NULL, NULL, ++ctl_serial, u32[0], u32[1],
u32[2], u32[3]);
break;
case CTL_CMD_WARP:
if (len < 9) return;
memcpy(u32, msg + 1, 8);
ctl_time += 1;
/* Absolute standalone-output-pixel position, so tests can place the
visible cursor (wlr_cursor_warp with a NULL device). Warp doesn't
emit a motion event, so damage the scene here like motion would. */
wlr_cursor_warp(server.cursor, NULL, (double)u32[0], (double)u32[1]);
if (server.standalone)
standalone_overlay_damage();
break;
}
}
@ -4435,6 +4489,54 @@ static bool backends_include(const char *env, const char *name) {
return false;
}
/* True if `theme` resolves to a real cursors file on disk. Mirrors the
loader's search: XCURSOR_PATH verbatim, else $XDG_DATA_HOME/icons (or
~/.local/share/icons) plus the compiled-in paths, each expected to contain
<dir>/<theme>/cursors/. wlroots 0.19 silently substitutes a builtin cursor
when none is found, so a missing theme must be detected here for the
hard-fail path. */
static bool standalone_cursor_theme_resolvable(const char *theme) {
const char *env = getenv("XCURSOR_PATH");
char *path = NULL;
if (env && *env) {
path = strdup(env);
} else {
const char *xdg = getenv("XDG_DATA_HOME");
const char *udata = (xdg && xdg[0] == '/') ? xdg : "~/.local/share";
size_t n = strlen(udata) + strlen(
"/icons:/usr/share/icons:/usr/share/pixmaps:"
"~/.cursors:/usr/share/cursors/xorg-x11:"
"/usr/X11R6/lib/X11/icons") + 1;
path = malloc(n);
if (path)
snprintf(path, n, "%s%s", udata,
"/icons:/usr/share/icons:/usr/share/pixmaps:"
"~/.cursors:/usr/share/cursors/xorg-x11:"
"/usr/X11R6/lib/X11/icons");
}
if (!path)
return false;
bool found = false;
char candid[1024];
for (char *dir = strtok(path, ":"); dir; dir = strtok(NULL, ":")) {
if (dir[0] == '~') {
const char *home = getenv("HOME");
if (!home) continue;
snprintf(candid, sizeof(candid), "%s%s", home, dir + 1);
dir = candid;
}
size_t n = snprintf(candid, sizeof(candid), "%s/%s/cursors/default",
dir, theme);
if (n < sizeof(candid) && access(candid, F_OK) == 0) {
found = true;
break;
}
}
free(path);
return found;
}
/* ===================== Main ===================== */
int main(int argc, char *argv[]) {
@ -4625,6 +4727,59 @@ int main(int argc, char *argv[]) {
if (!server.standalone)
bridge_attach_seat();
/* Standalone has no host compositor to draw a pointer, so give the
output a real wlr_cursor. Theme/size follow the XDG cursor convention:
XCURSOR_THEME (default "default") and XCURSOR_SIZE (default 24px).
wlroots itself never derives these, so read them here -- and refuse to
run if the resolved theme has no usable cursor, rather than silently
presenting an invisible pointer. */
if (server.standalone) {
server.output_layout = wlr_output_layout_create(server.wl_display);
server.cursor = wlr_cursor_create();
if (!server.output_layout || !server.cursor) {
fprintf(stderr,
"tabbed-wlroots: failed to create output layout/cursor\n");
return 1;
}
wlr_cursor_attach_output_layout(server.cursor, server.output_layout);
const char *theme_env = getenv("XCURSOR_THEME");
const char *theme = theme_env && *theme_env ? theme_env : "default";
uint32_t size = 24;
const char *size_env = getenv("XCURSOR_SIZE");
if (size_env) {
char *end = NULL;
unsigned long v = strtoul(size_env, &end, 10);
if (end != size_env && v > 0)
size = (uint32_t)v;
}
/* wlroots 0.19 silently substitutes a builtin cursor when the theme
directory holds nothing, so a missing theme can't be detected from
the manager afterwards. Check the theme dirs ourselves, following
the same search path the loader uses (XCURSOR_PATH, else the
compiled default), and require a real 'default' sprite. */
if (!standalone_cursor_theme_resolvable(theme)) {
fprintf(stderr,
"tabbed-wlroots: no cursor theme '%s' is installed (need a "
"'default' sprite in <icon-dir>/%s/cursors). Install a cursor "
"theme or set XCURSOR_THEME/XCURSOR_PATH\n", theme, theme);
return 1;
}
server.xcursor_mgr = wlr_xcursor_manager_create(theme, size);
if (server.xcursor_mgr)
wlr_xcursor_manager_load(server.xcursor_mgr, 1);
if (!server.xcursor_mgr ||
!wlr_xcursor_manager_get_xcursor(
server.xcursor_mgr, "default", 1)) {
fprintf(stderr,
"tabbed-wlroots: failed to load cursor theme '%s'\n", theme);
return 1;
}
wlr_cursor_set_xcursor(server.cursor, server.xcursor_mgr, "default");
wl_signal_add(&server.cursor->events.motion,
&standalone_cursor_motion_listener);
}
/* virtual keyboard fed from the host keyboard */
static const struct wlr_keyboard_impl keyboard_impl = {
.name = "tabbed-keyboard",