route the host window through xdg-shell directly on SSD compositors

libdecor sat on the critical path of every host-window transition even
where it drew nothing: on compositors honoring server-side decorations
(labwc/sway/KDE) it negotiated SSD, then still injected permanent
set_window_geometry hints and batched acks inside its own commit call.
labwc 0.9.8 expires a 100 ms configure deadline under load and answers
"move but keep old size" -- anchoring fullscreen views to stale geometry
forever.

the host window now selects its backend at startup: compositors
advertising zxdg_decoration_manager_v1 get kick-owned xdg_shell objects
with immediate configure acks, an explicit server-side decoration request,
and no geometry hints at all (KICK_LIBDECOR=1 forces the libdecor path);
hosts without the manager -- Mutter/GNOME forces CSD -- keep libdecor
unchanged.  this also restores mirrored fullscreen semantics: a client's
fullscreen request again fullscreens the host window in one step.
This commit is contained in:
Andrew Hamilton 2026-08-26 18:19:59 +10:00
parent 74a2ac72a8
commit c10845f2c1
4 changed files with 202 additions and 62 deletions

224
kick.c
View file

@ -37,6 +37,7 @@
#include <libdecor.h>
#include "xdg-shell-client.h"
#include "xdg-decoration-client.h"
#include "wlr-data-control-client.h"
#include <EGL/egl.h>
@ -421,12 +422,22 @@ struct kick_host {
struct wl_compositor *compositor;
struct wl_surface *surface;
struct xdg_wm_base *wm_base;
/* The nested window is a libdecor frame around our wl_surface: libdecor
owns the xdg_surface/xdg_toplevel plus the window decorations,
negotiating server-side decoration when the host offers it and drawing
its own titlebar (with move/resize/close) otherwise (GNOME). */
/* The nested host window has two backends selected at startup:
- direct xdg-shell (default on compositors advertising
zxdg_decoration_manager_v1 -- labwc/sway/KDE): kick owns the
xdg_surface/xdg_toplevel, requests server-side decorations and
acks configure events immediately. No set_window_geometry hints,
so no stale-view anchoring on fullscreen (labwc 0.9.8 bug).
- libdecor (hosts forcing CSD, e.g. Mutter/GNOME): libdecor owns the
shell objects and draws the titlebar.
KICK_LIBDECOR=1 forces the libdecor path for testing. */
bool use_libdecor;
struct libdecor *libdecor;
struct libdecor_frame *frame;
struct xdg_surface *xdg_surface;
struct xdg_toplevel *toplevel;
struct zxdg_decoration_manager_v1 *decoration;
struct zxdg_toplevel_decoration_v1 *decoration_obj;
struct wl_seat *seat;
struct wl_keyboard *keyboard;
struct wl_pointer *pointer;
@ -468,7 +479,7 @@ struct kick_host {
bool present_pending;
bool back_dirty;
/* Host-window fullscreen request waiting for the present pipeline to
drain (see host_queue_fullscreen). */
drain (see host_apply_fullscreen). */
bool fs_queued;
bool fs_target;
/* Keybind repeat for the captured tab-switch binding. The host
@ -2622,7 +2633,20 @@ static void handle_request_maximize(struct wl_listener *listener, void *data) {
window was", margins top/left, cropped bottom/right). Waiting for the
frame callback proves the last swap reached the screen, so nothing stale
can follow the ack. Rapid requests collapse to the latest target. */
static void host_queue_fullscreen(bool fs) {
/* Apply a fullscreen change to the kick window on the active backend:
direct path talks xdg-shell immediately (its ack precedes every commit
deterministically, so labwc's deadline race cannot anchor stale state);
libdecor path queues until the present pipeline drains, for the same
reason. */
static void host_apply_fullscreen(bool fs) {
if (!host.use_libdecor) {
if (!host.toplevel) return; /* standalone has no host window */
if (fs)
xdg_toplevel_set_fullscreen(host.toplevel, NULL);
else
xdg_toplevel_unset_fullscreen(host.toplevel);
return;
}
if (!host.frame) return; /* standalone has no host window */
if (host.present_pending) {
host.fs_queued = true;
@ -2642,13 +2666,12 @@ static void handle_request_fullscreen(struct wl_listener *listener, void *data)
if (!toplevel->base->initialized) return;
/* wlroots 0.19 emits request_fullscreen with NULL data; the direction
lives in toplevel->requested.fullscreen. Honor the client's request
within kick only: the window fills our output while the host window
itself is left alone. Fullscreening the host window is a separate,
explicit act (prefix+F11 or the compositor), which propagates back
down through clients_set_fullscreen(). */
and mirror it onto the host window: one action fullscreens the whole
monitor. */
bool fs = toplevel->requested.fullscreen;
wlr_xdg_toplevel_set_fullscreen(toplevel, fs);
kick_log(KICK_LOG_INFO, "client fullscreen=%d\n", fs);
host_apply_fullscreen(fs);
}
/* Propagate the host window's fullscreen state onto every client -- each
@ -2684,11 +2707,11 @@ static void clients_set_fullscreen(bool fs) {
}
/* Toggle the host window's fullscreen state. The request is queued until
the present pipeline drains (see host_queue_fullscreen); the actual flip
the present pipeline drains (see host_apply_fullscreen); the actual flip
happens asynchronously in host_toplevel_configure, where the resulting
configure updates host.fullscreen and propagates to every client. */
static void host_toggle_fullscreen(void) {
host_queue_fullscreen(!host.fullscreen);
host_apply_fullscreen(!host.fullscreen);
}
static void handle_request_minimize(struct wl_listener *listener, void *data) {
@ -3645,10 +3668,11 @@ static void handle_xs_request_fullscreen(struct wl_listener *listener,
struct kick_app *app = wl_container_of(listener, app, request_fullscreen);
struct wlr_xwayland_surface *xs = app->xsurface;
if (!xs) return;
/* Same containment as the xdg path: fullscreen stays inside kick. */
/* Mirror onto the host window like the xdg path. */
bool fs = xs->fullscreen;
wlr_xwayland_surface_set_fullscreen(xs, fs);
kick_log(KICK_LOG_INFO, "client fullscreen=%d\n", fs);
host_apply_fullscreen(fs);
}
static void handle_xs_request_configure(struct wl_listener *listener,
@ -5651,6 +5675,13 @@ static void host_registry_global(void *data, struct wl_registry *registry,
} else if (strcmp(interface, xdg_wm_base_interface.name) == 0) {
host.wm_base = wl_registry_bind(registry, name,
&xdg_wm_base_interface, version < 1 ? version : 1);
} else if (strcmp(interface,
zxdg_decoration_manager_v1_interface.name) == 0) {
/* Advertised by compositors that honor server-side decorations
(labwc/sway/KDE): marks the direct-xdg host-window path. */
host.decoration = wl_registry_bind(registry, name,
&zxdg_decoration_manager_v1_interface, 1);
} else if (strcmp(interface, wl_seat_interface.name) == 0) {
/* Bind the seat and rely on the capabilities event (which the
compositor always sends after a bind) to (re)create the keyboard
@ -5753,9 +5784,20 @@ static void host_resize(int width, int height) {
subsurfaces) and the content surface. Only called from the exit paths, so
no ack/commit dance is needed. */
static void host_teardown_window(void) {
if (host.frame) {
libdecor_frame_unref(host.frame);
host.frame = NULL;
if (host.use_libdecor) {
if (host.frame) {
libdecor_frame_unref(host.frame);
host.frame = NULL;
}
} else {
if (host.toplevel) {
xdg_toplevel_destroy(host.toplevel);
host.toplevel = NULL;
}
if (host.xdg_surface) {
xdg_surface_destroy(host.xdg_surface);
host.xdg_surface = NULL;
}
}
if (host.surface) {
wl_surface_destroy(host.surface);
@ -5763,6 +5805,72 @@ static void host_teardown_window(void) {
}
}
/* ---- Direct-xdg host-window path (SSD compositors: labwc/sway/KDE) ----
Kick owns the shell objects and acks configure events immediately -- the
arrangement that predates libdecor, where fullscreen state application
cannot be anchored to stale geometry because no set_window_geometry hint
is ever sent and the ack precedes every commit. */
static void host_xdg_surface_configure(void *data, struct xdg_surface *s,
uint32_t serial)
{
(void)data;
xdg_surface_ack_configure(s, serial);
}
static const struct xdg_surface_listener host_xdg_surface_listener = {
.configure = host_xdg_surface_configure,
};
static void host_xdg_toplevel_configure(void *data, struct xdg_toplevel *t,
int32_t width, int32_t height,
struct wl_array *states)
{
(void)data; (void)t;
host.fullscreen = false;
uint32_t *state;
wl_array_for_each(state, states)
if (*state == XDG_TOPLEVEL_STATE_FULLSCREEN)
host.fullscreen = true;
/* Mirror the host window's state onto the clients: fullscreen begets
fullscreen, anything else begets maximized. */
if (host.fullscreen != server.clients_fullscreen) {
kick_log(KICK_LOG_INFO, "host fullscreen=%d\n", host.fullscreen);
clients_set_fullscreen(host.fullscreen);
}
if (width > 0 && height > 0)
host_resize(width, height);
host.configured = true;
}
static void host_xdg_toplevel_close(void *data, struct xdg_toplevel *t) {
(void)data; (void)t;
kick_log(KICK_LOG_INFO, "kick: window closed\n");
shutdown_cleanup();
exit(0);
}
static const struct xdg_toplevel_listener host_xdg_toplevel_listener = {
.configure = host_xdg_toplevel_configure,
.close = host_xdg_toplevel_close,
};
static void host_decoration_configure(void *data,
struct zxdg_toplevel_decoration_v1 *dec,
uint32_t mode)
{
(void)data; (void)dec;
kick_log(KICK_LOG_INFO, "kick: host decoration mode=%u (%s)\n",
mode,
mode == ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE
? "server-side" : "client-side");
}
static const struct zxdg_toplevel_decoration_v1_listener
host_decoration_listener = {
.configure = host_decoration_configure,
};
static void host_frame_configure(struct libdecor_frame *frame,
struct libdecor_configuration *config,
void *data)
@ -7431,31 +7539,59 @@ static bool host_init(void) {
}
host.surface = wl_compositor_create_surface(host.compositor);
host.libdecor = libdecor_new(host.display, &host_libdecor_iface);
if (!host.libdecor) {
fprintf(stderr, "kick: libdecor failed to initialise\n");
return false;
}
host.frame = libdecor_decorate(host.libdecor, host.surface,
&host_frame_iface, NULL);
if (!host.frame) {
fprintf(stderr, "kick: libdecor_decorate failed\n");
return false;
}
libdecor_frame_set_title(host.frame, "kick");
libdecor_frame_set_app_id(host.frame, "kick");
/* Ask libdecor for a frame around our content. It negotiates xdg
decorations with the host: a server-side title bar when the host offers
one (labwc, sway), its own client-side title bar (drawing in a
subsurface above our content) otherwise -- GNOME/Mutter forces CSD, so
the window becomes draggable and resizable through that bar. */
libdecor_frame_map(host.frame);
/* libdecor pongs pings on its own wm_base binding; ours is separate, so
keep ponging the ones addressed to it or the host declares us hung. */
/* Backend selection: compositors advertising zxdg_decoration_manager_v1
honor server-side decorations, so kick owns the shell objects and
talks xdg-shell directly -- no set_window_geometry hints, immediate
acks, and fullscreen state application that cannot anchor to stale
geometry (the labwc 0.9.8 misplacement). Hosts without it force
CSD (Mutter/GNOME): hand the window to libdecor, which draws its own
titlebar. KICK_LIBDECOR=1 forces the libdecor path for testing. */
host.use_libdecor = getenv("KICK_LIBDECOR") != NULL || !host.decoration;
xdg_wm_base_add_listener(host.wm_base, &host_xdg_wm_listener, NULL);
wl_surface_commit(host.surface);
wl_display_roundtrip(host.display);
libdecor_dispatch(host.libdecor, 0);
if (host.use_libdecor) {
host.libdecor = libdecor_new(host.display, &host_libdecor_iface);
if (!host.libdecor) {
fprintf(stderr, "kick: libdecor failed to initialise\n");
return false;
}
host.frame = libdecor_decorate(host.libdecor, host.surface,
&host_frame_iface, NULL);
if (!host.frame) {
fprintf(stderr, "kick: libdecor_decorate failed\n");
return false;
}
libdecor_frame_set_title(host.frame, "kick");
libdecor_frame_set_app_id(host.frame, "kick");
/* libdecor negotiates xdg decorations with the host: a server-side
title bar when offered, its own client-side titlebar otherwise. */
libdecor_frame_map(host.frame);
wl_surface_commit(host.surface);
wl_display_roundtrip(host.display);
if (host.use_libdecor)
libdecor_dispatch(host.libdecor, 0);
} else {
kick_log(KICK_LOG_INFO,
"host window: direct xdg-shell with server-side decorations\n");
host.xdg_surface = xdg_wm_base_get_xdg_surface(host.wm_base,
host.surface);
host.toplevel = xdg_surface_get_toplevel(host.xdg_surface);
xdg_toplevel_set_title(host.toplevel, "kick");
xdg_toplevel_set_app_id(host.toplevel, "kick");
host.decoration_obj = zxdg_decoration_manager_v1_get_toplevel_decoration(
host.decoration, host.toplevel);
zxdg_toplevel_decoration_v1_add_listener(host.decoration_obj,
&host_decoration_listener, NULL);
zxdg_toplevel_decoration_v1_set_mode(host.decoration_obj,
ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE);
xdg_surface_add_listener(host.xdg_surface,
&host_xdg_surface_listener, NULL);
xdg_toplevel_add_listener(host.toplevel,
&host_xdg_toplevel_listener, NULL);
wl_surface_commit(host.surface);
wl_display_roundtrip(host.display);
}
/* Host keyboard/pointer are created by the seat capabilities handler
during the registry round-trip above; nothing more to fetch here. */
@ -7654,7 +7790,7 @@ static void host_frame_done(void *data, struct wl_callback *callback,
ack will carry the freshly sized buffer. */
if (host.fs_queued) {
host.fs_queued = false;
host_queue_fullscreen(host.fs_target);
host_apply_fullscreen(host.fs_target);
}
if (host.back_dirty) {
@ -8973,7 +9109,8 @@ int main(int argc, char *argv[]) {
for (;;) {
wl_display_dispatch_pending(host.display);
libdecor_dispatch(host.libdecor, 0);
if (host.use_libdecor)
libdecor_dispatch(host.libdecor, 0);
/* Flush queued nested-client messages before blocking: a data-control
`send` queued while dispatching the host side would otherwise sit
in the client connection until some later event wakes the poll
@ -9009,7 +9146,8 @@ int main(int argc, char *argv[]) {
wl_display_flush_clients(server.wl_display);
sweep_dead_startup();
wl_display_dispatch_pending(host.display);
libdecor_dispatch(host.libdecor, 0);
if (host.use_libdecor)
libdecor_dispatch(host.libdecor, 0);
/* Render only when something changed (kick's own redraws or scene
damage) and no page flip is in flight; an idle screen never