2026-08-09 12:46:14 +10:00
|
|
|
# tabbed-wlroots
|
|
|
|
|
|
|
|
|
|
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/tabbed-wlroots`.
|
|
|
|
|
|
|
|
|
|
### Debian / Ubuntu
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
sudo apt install build-essential meson ninja-build pkg-config \
|
|
|
|
|
libwayland-dev libwayland-bin wayland-protocols \
|
2026-08-10 09:25:52 +10:00
|
|
|
libxkbcommon-dev libpixman-1-dev \
|
|
|
|
|
libegl1-mesa-dev libgles2-mesa-dev
|
2026-08-09 12:46:14 +10:00
|
|
|
```
|
|
|
|
|
|
2026-08-10 09:25:52 +10:00
|
|
|
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).
|
2026-08-09 12:46:14 +10:00
|
|
|
|
|
|
|
|
### 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
|
2026-08-10 09:25:52 +10:00
|
|
|
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.
|
2026-08-09 12:46:14 +10:00
|
|
|
|
|
|
|
|
## Usage
|
|
|
|
|
|
|
|
|
|
```
|
2026-08-15 11:29:53 +10:00
|
|
|
tabbed-wlroots [prog...]
|
2026-08-09 12:46:14 +10:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Each `<prog>` is one shell command; quote it when it takes arguments, e.g.
|
2026-08-15 11:29:53 +10:00
|
|
|
`tabbed-wlroots "foot -e htop" "firefox -no-remote"`. With no arguments, a
|
|
|
|
|
single terminal tab is opened (see `$TERMINAL` below). `tabbed-wlroots -h`
|
|
|
|
|
prints this usage.
|
2026-08-09 13:02:46 +10:00
|
|
|
|
|
|
|
|
Every argument starts a new tab. There is no tab-count limit. Client apps run
|
|
|
|
|
as children of tabbed-wlroots and connect to its private Wayland socket, so
|
|
|
|
|
`DISPLAY` is unset for them and their `WAYLAND_DISPLAY` points at the tabbed
|
|
|
|
|
compositor (clients are launched with the GDK and Qt Wayland backends forced).
|
|
|
|
|
|
2026-08-15 09:16:09 +10:00
|
|
|
`Ctrl+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" tabbed-wlroots ...`.
|
|
|
|
|
|
2026-08-09 13:02:46 +10:00
|
|
|
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.
|
2026-08-15 11:29:53 +10:00
|
|
|
- Each command (an argument or the default `$TERMINAL`) is resolved on `PATH`
|
|
|
|
|
before the session starts: if its program isn't found, tabbed-wlroots prints
|
|
|
|
|
an error and exits non-zero without opening any window.
|
2026-08-09 13:02:46 +10:00
|
|
|
- The client applications run inside the session, so closing their windows
|
|
|
|
|
only closes their tab; closing the host tabbed-wlroots window, or sending
|
|
|
|
|
SIGINT/SIGTERM, shuts the whole session down and kills all client children.
|
|
|
|
|
|
2026-08-18 11:20:29 +10:00
|
|
|
### Startup order
|
|
|
|
|
|
|
|
|
|
The command-line commands are launched one at a time — each starts only after
|
|
|
|
|
the previous one's client has connected to the socket — so the tabs appear in
|
|
|
|
|
exactly the order you wrote them on the command line, no matter which program
|
|
|
|
|
takes longest to open its window.
|
|
|
|
|
|
|
|
|
|
The window stays black while they load (tabs map hidden). When the **last**
|
|
|
|
|
command's tab is ready it becomes the active tab and the window reveals it;
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
If a command never connects — e.g. `sleep 999` — the rest of the command line
|
|
|
|
|
never starts and the window stays black; the compositor itself keeps running
|
|
|
|
|
normally (`Ctrl+Shift+T` still works) until you close it.
|
|
|
|
|
|
|
|
|
|
Later tab openings follow the same no-steal rule: a brand-new tab never yanks
|
|
|
|
|
the active surface on its own. `Ctrl+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.
|
|
|
|
|
|
2026-08-09 13:02:46 +10:00
|
|
|
## Key bindings
|
|
|
|
|
|
|
|
|
|
| Key | Action |
|
|
|
|
|
|----------------------------|---------------------------------------------------|
|
2026-08-15 09:16:09 +10:00
|
|
|
| `Alt` (hold) | Show the tab-title overlay (centered) |
|
2026-08-14 08:48:28 +10:00
|
|
|
| `Alt+Up` | Previous tab (hold to keep cycling) |
|
|
|
|
|
| `Alt+Down` | Next tab (hold to keep cycling) |
|
2026-08-17 10:45:21 +10:00
|
|
|
| `Alt+Shift+Up` | Move the active tab earlier in the list |
|
|
|
|
|
| `Alt+Shift+Down` | Move the active tab later in the list |
|
2026-08-17 10:26:48 +10:00
|
|
|
| `Alt+Delete` | Close the active tab (SIGTERM, SIGKILL fallback) |
|
2026-08-15 09:16:09 +10:00
|
|
|
| `Ctrl+Shift+T` | Open a new terminal tab next to the current one |
|
2026-08-09 13:02:46 +10:00
|
|
|
|
2026-08-17 11:44:27 +10:00
|
|
|
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, and
|
|
|
|
|
`Super+Delete` closes. `Ctrl+Shift+T` always uses Ctrl and is unchanged.
|
|
|
|
|
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 tabbed window), so keep the default `Alt`
|
|
|
|
|
there. labwc's own defaults also leave `Super+Shift+arrow` and
|
|
|
|
|
`Super+Delete` free even when tabbed 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
|
2026-08-14 12:39:51 +10:00
|
|
|
active tab highlighted; the list updates live as you cycle with `Up`/`Down`
|
2026-08-17 11:44:27 +10:00
|
|
|
and disappears on release. It is drawn by tabbed itself, so it works
|
2026-08-14 12:39:51 +10:00
|
|
|
on hosts with or without a title bar (the overlay text uses a system font
|
2026-08-14 12:55:42 +10:00
|
|
|
via FreeType; set `TABBED_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.
|
2026-08-14 12:39:51 +10:00
|
|
|
|
2026-08-15 09:16:09 +10:00
|
|
|
`Ctrl+Shift+T` launches a fresh terminal (`$TERMINAL`, default
|
|
|
|
|
`alacritty`) as a new tab placed immediately to the right of the current tab,
|
2026-08-18 11:20:29 +10:00
|
|
|
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).
|
2026-08-15 09:16:09 +10:00
|
|
|
|
2026-08-09 13:02:46 +10:00
|
|
|
The tab-cycling hotkey only emits a physical press for the first tab switch;
|
2026-08-14 08:48:28 +10:00
|
|
|
repeats are synthesized, so holding `Alt+Up/Down` keeps stepping through
|
|
|
|
|
tabs while any other key stops it.
|
2026-08-09 13:02:46 +10:00
|
|
|
|
2026-08-17 10:45:21 +10:00
|
|
|
`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
|
Grab Alt+arrows and rotate tabs when reorder wraps past an end
A focused terminal acted on Alt+Shift+Up/Down (selection/typing) because
the reorder chord was forwarded like plain Alt+Up/Down cycling was. Both
are now grabbed: tabbed consumes them so the focused client never sees
them.
Reordering off an end previously swapped the moved tab with the far-end
tab, leaving the middle of the list in place ([A,B,C,D] moving D off the
end gave [D,B,C,A]). reorder_active_tab now shifts every slot between the
two positions, so the whole list rotates: [D,A,B,C]. Adjacent moves are
unchanged (rotation is a swap there).
The reorder test now runs three tabs to distinguish swap from rotation
([R,G,B] moving B off the end must land [B,R,G]) and keeps the
Ctrl+Shift+T anchor-slot check. The clipboard tests got more patience
(retry on stalled wl-paste; longer socket-start wait) since a stalled host
GNOME display was flaking them.
Full suite 5/5 green.
2026-08-17 11:12:28 +10:00
|
|
|
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). A `Ctrl+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: tabbed consumes them
|
|
|
|
|
so the focused client never receives the chord (e.g. a terminal can't act on
|
|
|
|
|
`Shift`+arrow while you reorder).
|
2026-08-17 10:45:21 +10:00
|
|
|
|
2026-08-17 10:26:48 +10:00
|
|
|
`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.
|
|
|
|
|
|
2026-08-14 11:31:32 +10:00
|
|
|
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 tabbed 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 tabbed host window.
|
|
|
|
|
- Leaving fullscreen returns every tab to maximized.
|
|
|
|
|
|
2026-08-15 18:06:55 +10:00
|
|
|
## Standalone mode (`TABBED_MODE=standalone`)
|
|
|
|
|
|
|
|
|
|
By default tabbed-wlroots runs **nested**: it opens a window on the host
|
|
|
|
|
Wayland compositor and draws every tab inside that single window. Set
|
|
|
|
|
`TABBED_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 tabbed-wlroots 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.
|
|
|
|
|
|
|
|
|
|
- `TABBED_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 tabbed seat directly
|
|
|
|
|
(native input); the `Ctrl-Shift-T` and `Alt`-overlay hotkeys still work off
|
|
|
|
|
those devices.
|
2026-08-16 07:39:37 +10:00
|
|
|
- `Ctrl-Alt-F1..F12` switch virtual terminals while running on the DRM
|
|
|
|
|
backend (via libseat/logind); the terminal sees nothing of the chord.
|
2026-08-15 18:06:55 +10:00
|
|
|
- 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: `TABBED_MODE=standalone WLR_BACKENDS=headless` runs
|
|
|
|
|
standalone on wlroots' headless backend (no DRM needed), useful in CI or
|
|
|
|
|
when no TTY is available.
|
|
|
|
|
|
2026-08-09 13:02:46 +10:00
|
|
|
## Logging (`TABBED_LOG`)
|
|
|
|
|
|
|
|
|
|
Quiet by default: a plain launch prints nothing to the launching terminal
|
|
|
|
|
except fatal errors. Set `TABBED_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.
|
|
|
|
|
|
2026-08-14 11:31:32 +10:00
|
|
|
## Frame dumps (SIGUSR1 / TABBED_AUTODUMP)
|
2026-08-09 13:02:46 +10:00
|
|
|
|
2026-08-14 11:31:32 +10:00
|
|
|
The `SIGUSR1` signal and `TABBED_AUTODUMP` write raw `P6` portable-pixmap
|
|
|
|
|
files to `/tmp` regardless of the log level:
|
2026-08-09 13:02:46 +10:00
|
|
|
|
2026-08-14 11:31:32 +10:00
|
|
|
- `SIGUSR1` writes `/tmp/scene_N.ppm` — the composed scene (whatever
|
2026-08-09 13:02:46 +10:00
|
|
|
tabbed-wlroots rendered for the host).
|
2026-08-15 18:06:55 +10:00
|
|
|
- `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).
|
2026-08-09 13:02:46 +10:00
|
|
|
- `N` is an incrementing per-run counter; a single `SIGUSR1` produces a
|
|
|
|
|
matching scene/host pair.
|
|
|
|
|
- With `TABBED_AUTODUMP` (any value, e.g. `TABBED_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 `TABBED_LOG=2` the diagnostic that detects the client's own shm buffer
|
|
|
|
|
additionally writes that buffer to `/tmp/client_N.ppm`.
|