Advertise zwlr_gamma_control_manager_v1 and commit client ramps to the single output (reset when the control goes away); other outputs and failed commits fail the control so clients retry. Outputs with no LUT (headless/nested) are failed by wlroots itself. wltest gains a --gamma mode reporting gamma_size/failed; smoke tests cover the global and the clean headless failure path.
415 lines
No EOL
21 KiB
Markdown
415 lines
No EOL
21 KiB
Markdown
# 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](https://mesonbuild.com/) + 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`).
|
||
|
||
```sh
|
||
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
|
||
|
||
```sh
|
||
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
|
||
|
||
```sh
|
||
sudo pacman -S base-devel meson ninja pkgconf wayland wayland-protocols \
|
||
wlroots xkbcommon mesa pixman
|
||
```
|
||
|
||
### Fedora
|
||
|
||
```sh
|
||
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=1` to disable the embedded X server entirely
|
||
(`DISPLAY` is 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 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 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
|
||
|
||
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) 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
|
||
|
||
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=standalone` forces standalone everywhere.
|
||
- `KICK_MODE=nested` pins nested even at a console.
|
||
- Any non-empty `WLR_BACKENDS` forces 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_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. 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..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.
|
||
|
||
`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 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` 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).
|
||
|
||
## Text input / input method (`text-input-v3`, `input-method-v2`)
|
||
|
||
Kick advertises `zwp_text_input_manager_v3` and `zwp_input_method_manager_v2`,
|
||
so in-session clients (toolkit text entries) and in-session input methods
|
||
(e.g. fcitx5, on-screen keyboards) work with each other in both nested and
|
||
standalone mode. The focused tab's field state (surrounding text, content
|
||
type) is relayed to the input method, and its preedit/commit strings back to
|
||
the field; focus follows tab switches, and the IME's candidate popup floats
|
||
above the tabs. While the IME holds its keyboard grab, keys go to it instead
|
||
of the focused client (the `Alt` tab-management chords still run first).
|
||
Relay activity is logged with the `[im]` prefix at `KICK_LOG=2`.
|
||
|
||
## Content type / single-pixel buffers
|
||
|
||
Kick advertises `wp_content_type_manager_v1`, so video players and games can
|
||
tag their surfaces (`photo`/`video`/`game`; the type is recorded in the
|
||
`[diag] map` line at `KICK_LOG=2`), and
|
||
`wp_single_pixel_buffer_manager_v1`, so clients can back a solid-color
|
||
surface with a 1×1 buffer (scaled up via `wp_viewporter`, which kick also
|
||
advertises) instead of a shared-memory pool.
|
||
|
||
## Gamma control (`wlr-gamma-control`)
|
||
|
||
Kick advertises `zwlr_gamma_control_manager_v1`, so night-light tools
|
||
(wlsunset, gammastep) can set the output's gamma ramp. Ramps are committed
|
||
to the single output via the standard wlroots apply path (a departing
|
||
client resets it); outputs with no gamma LUT report `failed` instead, so
|
||
on headless/nested outputs such tools learn the output is unsupported
|
||
rather than hanging. Like all hardware LUTs, this only takes visible
|
||
effect on DRM in standalone mode.
|
||
|
||
## 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`. |