No description
Find a file
Andrew Hamilton 72b95f1204 Support standalone (native-output) mode alongside nested hosting
TABBED_MODE=standalone (or a visible WLR_BACKENDS) makes tabbed-wlroots its
own Wayland compositor rendering onto the first native output instead of a
nested host window. DRM+libinput is the default backend set; a headless
backend (created directly, since wlr_backend_autocreate wraps backends in a
multi backend that wlr_headless_add_output rejects) keeps the mode testable
in CI over the existing stdin feed.

Standalone:
- server_init creates a headless or autocreated backend and attaches a
  new_input listener; skips seat bridging (no host client to mirror).
- handle_new_output keeps the first output only, drives it at its preferred
  mode, and sizes the swapchain to the output instead of a host window.
- Font/icon overlays build CPU wlr_textures (premultiplied ARGB) alongside
  the existing GL textures so the tab overlay can be drawn by a second
  wlr_renderer pass over the committed output buffer.
- draw_tab_overlay_pass mirrors draw_tab_overlay's geometry with pass
  rects/textures; render_standalone builds scene state, applies the overlay,
  commits the buffer, and sends frame done. SIGUSR1 now also readbacks the
  composited frame (GLES2 FBO, or CPU data-ptr fallback) as /tmp/host_N.ppm.
- Native keyboards/pointers are attached to the seat and share the existing
  process_key_event/process_modifiers hotkey handling and the main loop's
  standalone branch (blocking dispatch + render on frame/redraw).

The nested mode is unchanged. Adds standalone smoke tests (boot/cycle and
Alt overlay readback under WLR_BACKENDS=headless); full suite 21 passing.
README documents the new mode.
2026-08-15 18:06:55 +10:00
scripts Re-add labwc stress harness scripts 2026-08-14 08:12:05 +10:00
subprojects Build with meson; portable wlroots fallback (pinned wrap) + README 2026-08-09 12:46:14 +10:00
tests Support standalone (native-output) mode alongside nested hosting 2026-08-15 18:06:55 +10:00
wlr-protocols Add bidirectional clipboard bridge with text prefetch 2026-08-10 11:29:07 +10:00
.gitignore Fix Debian x64 build: drop nonexistent libwlroots-dev, build wlroots 0.19.1 without -Werror 2026-08-10 09:25:52 +10:00
build.sh Add build.sh wrapper for meson build 2026-08-09 21:07:51 +10:00
meson.build Show per-tab app icons in the Alt-hold overlay 2026-08-14 12:55:42 +10:00
README.md Support standalone (native-output) mode alongside nested hosting 2026-08-15 18:06:55 +10:00
run.sh Add run.sh wrapper that execs build/tabbed-wlroots 2026-08-14 07:59:13 +10:00
tabbed-wlroots.c Support standalone (native-output) mode alongside nested hosting 2026-08-15 18:06:55 +10:00
test.sh Add six smoke tests and [claim] client-PID log 2026-08-10 19:22:20 +10:00
wlr-data-control-client.h Add bidirectional clipboard bridge with text prefetch 2026-08-10 11:29:07 +10:00
wlr-data-control-protocol.c Add bidirectional clipboard bridge with text prefetch 2026-08-10 11:29:07 +10:00
xdg-decoration-client.h Request server-side decorations from the host compositor 2026-08-07 10:32:11 +10:00
xdg-decoration-protocol.c Request server-side decorations from the host compositor 2026-08-07 10:32:11 +10:00
xdg-shell-client.h Add tabbed-windows nested compositor with EGL/dmabuf client support 2026-08-06 11:19:35 +10:00
xdg-shell-protocol.c Tabby: C Wayland compositor with xdg-shell tabbed windows 2026-08-05 11:52:07 +10:00
xdg-shell-server.h Tabby: C Wayland compositor with xdg-shell tabbed windows 2026-08-05 11:52:07 +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 + 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/tabbed-wlroots.

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

tabbed-wlroots [prog...]

Each <prog> is one shell command; quote it when it takes arguments, e.g. 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.

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

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

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, tabbed-wlroots 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 tabbed-wlroots window, or sending SIGINT/SIGTERM, shuts the whole session down and kills all client children.

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)
Ctrl+Shift+T Open a new terminal tab next to the current one

Holding Alt 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 Alt release. It is drawn by tabbed itself, so it works on hosts with or without a title bar (the overlay text uses a system font 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.

Ctrl+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 — exactly as if its command had been given on the command line.

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.

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.

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

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.

Frame dumps (SIGUSR1 / TABBED_AUTODUMP)

The SIGUSR1 signal and TABBED_AUTODUMP write raw P6 portable-pixmap files to /tmp regardless of the log level:

  • SIGUSR1 writes /tmp/scene_N.ppm — the composed scene (whatever tabbed-wlroots 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 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.