kick/README.md

14 KiB

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, 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.

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 and connect to its private Wayland socket, so DISPLAY is unset for them and their WAYLAND_DISPLAY points at the kick compositor (clients are launched with the GDK and Qt Wayland backends forced).

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 on PATH before 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 sub-window 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.

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 tab (SIGTERM, SIGKILL fallback)
Alt+Shift+T Open a new terminal tab next to the current one

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's title with the active tab highlighted; the list updates live as you cycle with Up/Down 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 row also shows the tab's app icon, resolved from its Wayland app_id via the desktop files and icon themes in XDG_DATA_DIRS (PNG via libpng, SVG via librsvg); tabs without a resolvable icon get a generic placeholder.

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 sub-windows 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 tab. The 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 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 (use your host compositor's fullscreen keybinding, or an app's own F11) and every tab 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 tab to maximized.

Standalone mode (KICK_MODE=standalone)

By default kick runs nested: it opens a window on the host Wayland compositor and draws every tab inside that single window. Set KICK_MODE=standalone to instead become its own Wayland compositor, rendering directly onto a 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.

  • KICK_MODE=standalone WLR_BACKENDS=... selects the wlroots backends (wlroots' own WLR_BACKENDS variable is passed through). With no WLR_BACKENDS it defaults to drm,libinput.
  • WAYLAND_DISPLAY is ignored in standalone mode (it never connects to an outer compositor), except as part of the requested WLR_BACKENDS.
  • Real keyboard/pointer devices are attached to the kick seat directly (native input); the Alt+Shift+T and Alt-overlay hotkeys still work off those devices.
  • Ctrl-Alt-F1..F12 switch 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=headless runs 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.

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:

  • SIGUSR1 writes /tmp/scene_N.ppm — the composed scene (whatever kick rendered for the host).
  • SIGUSR1 writes /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 the Alt overlay).
  • N is an incrementing per-run counter; a single SIGUSR1 produces 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=2 the diagnostic that detects the client's own shm buffer additionally writes that buffer to /tmp/client_N.ppm.