x11_pending_teardown only removed the pending's associate listener when the surface had been hooked (associated). the listener is attached unconditionally at new_surface, so an X11 window destroyed before ever associating -- VLC's short-lived probe windows do exactly that -- left it linked on events.associate, and wlroots' xwayland_surface_destroy asserted on the non-empty list, killing kick. |
||
|---|---|---|
| scripts | ||
| subprojects | ||
| tests | ||
| wlr-protocols | ||
| .gitignore | ||
| build.sh | ||
| kick.c | ||
| meson.build | ||
| pytest.ini | ||
| README.md | ||
| run.sh | ||
| test-headless.sh | ||
| test.sh | ||
kick
A nested Wayland compositor (built on wlroots) that presents several client apps as tabs in a single window on the host Wayland compositor. Each command-line argument launches one tab.
Building
The project builds with Meson + Ninja. On most
distributions the non-wlroots dependencies are available in the standard
repositories; wlroots is used from the system where one is installed, and
otherwise is fetched and built automatically from a pinned source tarball
(subprojects/wlroots.wrap).
meson setup build
meson compile -C build
The resulting binary is build/kick.
The Wayland protocol stubs (xdg-shell, xdg-decoration,
cursor-shape-v1 and tablet-v2, wlr-data-control-unstable-v1) are not
committed; they are generated into the build directory by wayland-scanner
from the system wayland-protocols (and the bundled
wlr-data-control-unstable-v1.xml), so they always match the installed
protocol headers. This adds no extra dependency beyond what the wlroots
fallback already requires. (tablet-v2 is generated only because
cursor-shape's implementation references wl_tablet_tool_v2_interface.)
Debian / Ubuntu
sudo apt install build-essential meson ninja-build pkg-config \
libwayland-dev libwayland-bin wayland-protocols \
libxkbcommon-dev libpixman-1-dev \
libegl1-mesa-dev libgles2-mesa-dev
No wlroots package is needed: this project targets wlroots 0.19, while
Debian/Ubuntu ship another version as a versioned package (e.g.
libwlroots-0.20-dev on Debian >= trixie, pkg-config name wlroots-0.20),
so Meson builds the bundled 0.19.1 instead (see below).
Arch
sudo pacman -S base-devel meson ninja pkgconf wayland wayland-protocols \
wlroots xkbcommon mesa pixman
Fedora
sudo dnf install gcc meson ninja-build pkgconf-pkg-config \
wayland-devel wayland-protocols-devel wlroots-devel \
libxkbcommon-devel mesa-libEGL-devel mesa-libGLES-devel pixman-devel
When the system wlroots dep is missing / the fallback can't find one
The Meson setup searches for wlroots-0.19 then wlroots; if neither is
installed it builds the pinned 0.19.1 from subprojects/wlroots.wrap, which
only needs the wayland, wayland-protocols, xkbcommon and EGL/GLES2 dev
packages listed above. On newer Debian/Ubuntu the packaged wlroots is a
versioned library (e.g. libwlroots-0.20-dev, pkg-config wlroots-0.20)
whose version falls outside this project's >=0.19,<0.20 pin, so the
fallback build is used automatically and no wlroots package needs to be
installed. Optional wlroots features (Vulkan, X11, libinput, etc.) are
auto-detected and skipped when their build deps aren't present, so a minimal
install works too.
Usage
kick [--start-cmd-index N] [prog...]
Each <prog> is one shell command; quote it when it takes arguments, e.g.
kick "foot -e htop" "firefox -no-remote". With no arguments, a
single terminal tab is opened (see $TERMINAL below). --start-cmd-index N starts focused on command N (1-based; the last command by default).
kick -h prints this usage.
Every argument starts a new tab. There is no tab-count limit. Client apps run
as children of kick with WAYLAND_DISPLAY pointed at the kick compositor and
XAUTHORITY cleared; toolkits then self-select — Wayland-capable apps
connect to kick directly, X11-only ones (emacs-gtk, xmessage) use DISPLAY
at the embedded Xwayland when XWayland support is available (see below).
kick never forces GDK_BACKEND/QT_QPA_PLATFORM: an explicit backend value
removes a toolkit's fallback and breaks X11-only builds. If a particular app
misbehaves under native Wayland, wrap its command, e.g.
"GDK_BACKEND=x11 myapp".
XWayland
X11 applications work as tabs alongside Wayland clients: kick xmessage hi
opens xmessage in a tab like any other command. kick embeds an Xwayland
server (started lazily on first use) and claims each X11 window by the
process group of its _NET_WM_PID, using the same pgid matching as Wayland
clients — so an X11 program's windows land in the tab reserved for its
command-line argument.
- Managed X11 windows are resized to fill the output and follow every tab rule (focus, cycling, Alt+Delete close via WM_DELETE_WINDOW with the usual SIGTERM/SIGKILL escalation).
- Override-redirect windows (menus, tooltips, popups) float above the tabs at their requested position; they take keyboard focus only when they ask for it.
- Transient/dialog X11 windows follow the same rules as Wayland subsurface
dialogs (
--tab-groups on|off). - Set
KICK_NO_XWAYLAND=1to disable the embedded X server entirely (DISPLAYis then unset again).
Requirements: wlroots built with XWayland support plus xcb, xcb-icccm
and xcb-ewmh dev packages; if any are missing, kick builds without X11
support and behaves exactly as before. Known limitation: X11 cursor themes
are not applied inside X11 windows (kick does not bridge the legacy
wl_pointer.set_cursor path).
Alt+Shift+T opens the terminal named by the $TERMINAL environment variable
when it is set (see Key bindings); otherwise it falls back to alacritty. The
value is used verbatim, so it may carry arguments, e.g.
TERMINAL="foot -e htop" kick ....
Notes:
- An argument written bare (starting with
-) is taken as a tab launcher too (unless quoted); a warning is printed when it looks like a stray option. - Each command (an argument or the default
$TERMINAL) is resolved onPATHbefore the session starts: if its program isn't found, kick prints an error and exits non-zero without opening any window. - The client applications run inside the session, so closing their windows only closes their tab; closing the host kick window, or sending SIGINT/SIGTERM, shuts the whole session down and kills all client children.
Startup order
The command-line commands are all launched at the same time, in the order
you wrote them. Each command runs in its own process group, and when its
client connects it claims its own slot by that group — so every tab keeps
its command-line position even with wrapped commands like sh -c "foot -e htop", no matter which program takes longest to open its window.
Until a client connects, its tab is black and listed in the overlay under
its command line instead of a window title. From the very first frame
the last command's tab is the active one, so you wait only on that
single tab: when it maps, the window reveals it and nothing steals focus
before that, so you don't flicker through every intermediate tab. This
"last command wins" rule also means the tab you see on startup is always
the one from the last argument. Pass --start-cmd-index N to start focused
on a different command instead: N is 1-based (the first command is 1),
and that command's tab is black-and-active from the first frame just like
the default last one.
If a command never connects — e.g. sleep 999 — the rest of the command
line still starts (they all launched together) and the window shows the
last tab as soon as it is ready. A command whose whole process group exits
without ever connecting (e.g. kick /bin/false) drops its tab
entirely instead of leaving a permanent black hole.
Closing a tab takes it out of the list for good: its slot is removed and the remaining tabs reflow into place — there are no holes for later clients to fall into, so externally started windows are always appended, not absorbed into a dead tab's position.
Later tab openings follow the same no-steal rule: a brand-new tab never yanks
the active surface on its own. Alt+Shift+T still focuses its fresh
terminal, and a tab-group member of the tab you are currently looking at
becomes active; every other new window or tab just appears in the list, hidden, until
you cycle to it.
Tab groups: --tab-groups on|off
A window is a tab-group member of an existing tab when its client process is
a (direct or indirect) child of that tab's client process — kick walks the
parent-PID chain (/proc) up to a few levels to find the owning tab. So when a
terminal tab's shell launches gnome-calculator, or an app opens a dialog or
palette, those windows are "related" to the tab they came from (the whole
forked process tree, not just a single process). --tab-groups picks how they
appear:
on(default): related windows never get a tab. They render stacked above their parent tab's surface, share the tab's visibility, and hold the keyboard while that tab is shown. They are invisible to cycling and to the Alt-held overlay, and closing the tab closes them with it (same process tree). When a stacked window closes, focus falls back to the tab's own surface.off: each related window gets its own tab glued immediately to the right of its parent tab. It activates only if the parent tab is the one being shown; otherwise it waits hidden until you cycle to it.
--tab-groups on groups a tab with its related windows the way a browser groups
tabs: the dialogs and auxiliaries feel like part of the one window. off keeps
every surface individually reachable, which is kick's original model.
Key bindings
| Key | Action |
|---|---|
Alt (hold) |
Show the tab-title overlay (centered) |
Alt+Up |
Previous tab (hold to keep cycling) |
Alt+Down |
Next tab (hold to keep cycling) |
Alt+Shift+Up |
Move the active tab earlier in the list |
Alt+Shift+Down |
Move the active tab later in the list |
Alt+Delete |
Close the active window (or the tab when it's the only window) |
Alt+Shift+T |
Open a new terminal tab next to the current one |
Alt+Left / Alt+Right |
Cycle the active tab's tab-group windows (Left = previous, Right = next; hold to keep cycling) |
Alt+F11 |
Toggle the kick window's fullscreen state in the host (no-op standalone) |
The tab-management prefix is Alt by default. Pass --modifier super to
use Super (Windows/Meta key, Mod4) instead: Super hold shows the
overlay, Super+Up/Down cycle, Super+Shift+Up/Down reorder,
Super+Delete closes, and Super+Shift+T opens a new terminal -- the
new-tab chord follows the prefix like every other binding.
The Super variant is meant for standalone/DRM and labwc-style hosts
(including the Raspberry Pi, where nothing reserves it); under GNOME the
host grabs most Super chords before the nested compositor ever sees them
(e.g. Super+Up maximizes the kick window), so keep the default Alt
there. labwc's own defaults also leave Super+Shift+arrow and
Super+Delete free even when kick is nested inside labwc.
Holding the tab-management modifier (Alt, or Super with --modifier super) shows a passive overlay listing every tab; the list updates live as
you move the focus and disappears on release. It is drawn by kick itself, so
it works on hosts with or without a title bar (the overlay text uses a system
font via FreeType; set KICK_FONT to pick one). Each tab row lists that
tab's windows -- its main surface plus any tab-group members -- each shown
with its own icon (resolved from the window's app_id via the desktop files
and icon themes in XDG_DATA_DIRS, PNG via libpng, SVG via librsvg; a window
without a resolvable icon gets a generic placeholder). The focused window is
highlighted like a cursor: Alt+Up/Alt+Down switch tabs and
Alt+Left/Alt+Right move between windows within the active tab, so the
highlight walks through every window in the session.
Alt+Shift+T launches a fresh terminal ($TERMINAL, default
alacritty) as a new tab placed immediately to the right of the current tab,
and it becomes the active tab once it maps (the one exception to the
no-map-steal rule at startup, along with tab-group members of the shown tab).
The tab-cycling hotkey only emits a physical press for the first tab switch;
repeats are synthesized, so holding Alt+Up/Down keeps stepping through
tabs while any other key stops it.
Alt+Shift+Up/Down reorder the overlay list instead of switching: the
active tab stays active, it just moves one position earlier or later in the
order. Moving off either end wraps around and the whole list shifts — a
rightmost tab moved past the end lands first, with every other tab pushed
one position, not merely swapped with the first. Holding the chord keeps
moving it (same synthesized repeat as cycling). An Alt+Shift+T inside the
moved tab opens exactly where the reordered list says it should — immediately
right of the tab's new position.
Both Alt+Up/Down and Alt+Shift+Up/Down are grabbed: kick consumes them
so the focused client never receives the chord (e.g. a terminal can't act on
Shift+arrow while you reorder).
Alt+Delete closes the active window. When the tab holds more than one window
(a tab group), only the focused window closes and the tab stays up; when it is
the tab's only window, the whole tab disappears immediately (the tab to its
right becomes active, or the left neighbor if it was the rightmost); the
client's process group gets a SIGTERM, escalated to SIGKILL after a short
grace if it ignores the signal. A window or tab that closes on its own (its
program exits) instead hands active off to the left neighbor.
Function keys are not grabbed: F9/F10/F11 (and anything else) are
forwarded to the focused client, so apps keep their own bindings (e.g. a
browser's F11).
Fullscreen
Fullscreen mirrors between the host window and the clients:
- Fullscreen the kick window (your host compositor's fullscreen keybinding,
an app's own
F11, orAlt+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) also fullscreens the kick host window.
- Leaving fullscreen returns every window to maximized (X11 tab-group members keep their own geometry).
Standalone mode
At a Linux virtual console, plain kick automatically runs standalone:
it becomes its own Wayland compositor, rendering directly onto the native
display (DRM via wlroots) — the first output only; extra monitors are
ignored. In this mode kick takes over the screen, and the tab overlay is
composited onto the output in a second render pass instead of being drawn
into a host window.
The auto-detection fires only when none of KICK_MODE, WLR_BACKENDS,
WAYLAND_DISPLAY or DISPLAY is set and kick's stdin is a real VT
(/dev/tty<digits>): there must be nothing to nest into, and SSH sessions,
terminal emulators and pipes never match the VT shape test. The chosen mode
is logged at startup as [mode] standalone|nested (reason). Escape hatches,
in precedence order:
KICK_MODE=standaloneforces standalone everywhere.KICK_MODE=nestedpins nested even at a console.- Any non-empty
WLR_BACKENDSforces standalone and selects the backends.
Otherwise kick runs nested by default: it opens a window on the host Wayland compositor and draws every tab inside that single window.
WAYLAND_DISPLAYis ignored in standalone mode (it never connects to an outer compositor), except as part of the requestedWLR_BACKENDS.- Real keyboard/pointer devices are attached to the kick seat directly
(native input); the
Alt+Shift+TandAlt-overlay hotkeys still work off those devices. Touchscreens work the same way: standalone mode delivers native touch events to the focused client, and nested mode forwards touch from the host seat. Multi-touch slots are preserved; touches do not move the mouse pointer. Ctrl-Alt-F1..F12switch virtual terminals while running on the DRM backend (via libseat/logind); the terminal sees nothing of the chord.- The stdin event feed used by the test harness works identically in both modes, so the same synthesized-input tests cover nested and standalone runs.
- Headless testing:
KICK_MODE=standalone WLR_BACKENDS=headlessruns standalone on wlroots' headless backend (no DRM needed), useful in CI or when no TTY is available.
Running the nested tests without a visible window
./test.sh runs the nested suite against the desktop, so each test briefly
opens the compositor's host window. ./test-headless.sh re-hosts those
windows inside an invisible compositor: it boots a second, standalone
instance of the same binary (KICK_MODE=standalone WLR_BACKENDS=headless,
output shown nowhere) and points WAYLAND_DISPLAY at it for the pytest run,
so nothing flashes on the desktop. No extra dependencies.
The headless host exposes wlr-data-control, so the clipboard tests run
through the (preferred) data-control bridge instead of the focus-based one
that the desktop session uses. Standalone mode needs that data-control
set_selection request handled too, which is the case regardless of which
runner you use.
test_nested_host_primary_to_kick exercises the host->nested primary
selection path, which only exists over the data-control v2 bridge; it skips
on hosts without it (e.g. GNOME), so use ./test-headless.sh or a
data-control host (labwc, sway) for it.
Known-flaky tests are marked @pytest.mark.flaky. pytest.ini defaults to
-m "not flaky", so they are excluded from every plain run — ./test.sh,
./test-headless.sh, or direct pytest. Run them on demand with
./test.sh -m flaky.
Cursor shapes (cursor-shape-v1)
Kick advertises the cursor-shape-v1 protocol (as wp_cursor_shape_manager_v1,
version 1). Nested clients that switch the pointer's cursor shape are honoured:
- Standalone mode applies the requested shape to the compositor's own
cursor, looked up in the current
XCURSOR_THEME(so e.g. GTK's text cursor works over a galculator entry). - Nested mode has no cursor of its own: the shape is forwarded to the host
compositor's pointer via
wp_cursor_shape_device_v1.set_shape, and the host (which draws the cursor over our window) applies it.
The shape is remembered per tab and the cursor is reset to default when you
switch to a tab whose client last asked for nothing (or asked for default),
so a text cursor never leaks from one app into the next.
Legacy wl_pointer.set_cursor surfaces are not bridged: a nested client
that still uses the old protocol (some older toolkits) keeps no visible cursor
over the window, and a host without cursor-shape-v1 simply draws its default
cursor (logged as [cursor] host shape n/a at debug level).
Logging (KICK_LOG)
Quiet by default: a plain launch prints nothing to the launching terminal
except fatal errors. Set KICK_LOG before starting:
| Value | Meaning |
|---|---|
unset/0 |
Quiet. Only fatal errors are shown. |
1 |
Info. Status lines, dump summaries, host notes (e.g. socket). |
2 |
Debug. Adds a per-frame [diag] trace and EGL/render/import messages, and client stdout/stderr flows through. |
At debug level (2) client programs also inherit the launching terminal's
stdout/stderr instead of being silenced, which makes client errors visible.
Frame dumps (SIGUSR1 / KICK_AUTODUMP)
The SIGUSR1 signal and KICK_AUTODUMP write raw P6 portable-pixmap
files to /tmp regardless of the log level:
SIGUSR1writes/tmp/scene_N.ppm— the composed scene (whatever kick rendered for the host).SIGUSR1writes/tmp/host_N.ppm— the host compositor's back buffer, so you can see what the host actually displayed. In standalone mode this is the composited frame being presented to the output (scene plus theAltoverlay).Nis an incrementing per-run counter; a singleSIGUSR1produces a matching scene/host pair.- With
KICK_AUTODUMP(any value, e.g.KICK_AUTODUMP=1), a scene and host dump are taken automatically once the first frame in which a client committed a CPU-readable (shared-memory) buffer — handy for capturing exactly what a newly launched client drew. - At
KICK_LOG=2the diagnostic that detects the client's own shm buffer additionally writes that buffer to/tmp/client_N.ppm.