From c10845f2c137fa342dd28447c5b5481e9e6638a4 Mon Sep 17 00:00:00 2001 From: Andrew Hamilton Date: Wed, 26 Aug 2026 18:19:59 +1000 Subject: [PATCH] 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. --- README.md | 11 +-- kick.c | 224 +++++++++++++++++++++++++++++++++++--------- meson.build | 7 ++ tests/test_smoke.py | 22 ++--- 4 files changed, 202 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index be1bb10..d9c2d7d 100644 --- a/README.md +++ b/README.md @@ -270,17 +270,16 @@ browser's `F11`). ## Fullscreen -Fullscreening the kick window and fullscreening a client are independent: +Fullscreen mirrors between the host window and the clients: - Fullscreen the kick window (your host compositor's fullscreen keybinding, or `Alt+F11`) and every window of every tab -- mains and stacked tab-group members alike -- is told it is fullscreen, so clients drop their decorations and fill the screen. -- A client that requests fullscreen itself (e.g. a video player) fills the - kick window's content only: the host window itself stays exactly where - and how it is. Use `Alt+F11` as well if you want the whole monitor. -- Leaving host fullscreen returns every window to maximized (X11 - tab-group members keep their own geometry). +- A client that requests fullscreen itself (e.g. a video player) also + fullscreens the kick host window: one action fills the whole monitor. +- Leaving fullscreen returns every window to maximized (X11 tab-group + members keep their own geometry). ## Standalone mode diff --git a/kick.c b/kick.c index ae9dce2..85d0ee8 100644 --- a/kick.c +++ b/kick.c @@ -37,6 +37,7 @@ #include #include "xdg-shell-client.h" +#include "xdg-decoration-client.h" #include "wlr-data-control-client.h" #include @@ -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 diff --git a/meson.build b/meson.build index 78018f4..45f533f 100644 --- a/meson.build +++ b/meson.build @@ -89,6 +89,13 @@ protocols = { wl_protocols_dir / 'stable/xdg-shell/xdg-shell.xml', 'xdg-shell', ], + # xdg-decoration is the unstable-v1 protocol but keeps the plain + # "xdg-decoration" base name because the host-window code includes + # those exact generated names (SSD-compositor direct path). + 'xdg-decoration': [ + wl_protocols_dir / 'unstable/xdg-decoration/xdg-decoration-unstable-v1.xml', + 'xdg-decoration', + ], 'wlr-data-control-unstable-v1': [ 'wlr-protocols/wlr-data-control-unstable-v1.xml', 'wlr-data-control', diff --git a/tests/test_smoke.py b/tests/test_smoke.py index e5bbb79..874eb1b 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1499,22 +1499,18 @@ def test_function_keys_pass_through(session): assert session.proc.poll() is None -def test_client_fullscreen_stays_inside_kick(session): - """A client's fullscreen request is honored within kick but never - mirrored onto the host window: wltest toggles xdg_toplevel fullscreen - on the 'F' key, so tap it and check only the client-side state moves.""" +def test_client_fullscreen_round_trip(session): + """A client's fullscreen request is honored and mirrored onto the host + window; the host configure then propagates fullscreen to every tab. + wltest toggles xdg_toplevel fullscreen on the 'F' key, so tap it and + watch both directions of the mirror.""" session.wait_any_active() session.tap(F) - session.wait_for("client fullscreen=1") - time.sleep(0.6) # give a wrong mirror time to show up in the log - assert "host fullscreen=" not in session.read_log(), \ - "client fullscreen leaked onto the host window" - assert "clients fullscreen=" not in session.read_log(), \ - "client fullscreen propagated to other tabs" + session.wait_for("host fullscreen=1") + session.wait_for("clients fullscreen=1") session.tap(F) - session.wait_for("client fullscreen=0") - time.sleep(0.4) - assert "host fullscreen=" not in session.read_log() + session.wait_for("host fullscreen=0") + session.wait_for("clients fullscreen=0") assert session.proc.poll() is None