Files
playpen/README.md
T
2026-08-11 14:26:12 -04:00

17 KiB

Playpen

A proof-of-concept workspace built on libghostty-vt and GTK4/libadwaita: vertical tabs, each holding a split tree of terminal and web panes, with saved layouts that open a whole arrangement — directories, scripts and all — in one go.

The sidebar holds the window controls, a new-tab button, and one row per tab — the layout Zen Browser uses for vertical tabs — with the content inset to its right.

Quick start

nix develop          # Zig 0.16 + GTK4 + libadwaita, no host toolchain needed
zig build run

Everything is pinned by flake.nix; nothing needs to be installed on the host.

Installing

mise run install     # builds release, installs into ~/.local
mise run uninstall

That puts the binary in ~/.local/libexec/playpen, a launcher on ~/.local/bin, a desktop entry in ~/.local/share/applications, and the icon in the hicolor theme, then refreshes the desktop and icon caches. mise tasks lists the rest (build, run, fmt, screenshot).

Two things the installer has to handle that a normal install wouldn't:

  • A launcher, not a symlink. The binary links against GTK in the Nix store and has an RPATH, so it runs anywhere — but a handful of things are found by path at runtime rather than linked, and they have to be pointed at the Nix copies or the app misbehaves in ways that look nothing like a packaging problem. A desktop launcher starts the app with a minimal environment, so those paths are baked into the launcher script, read out of the dev shell at install time rather than hardcoded so they can't drift from the flake. See Running off-NixOS for what each one is.
  • Nix GC roots. The installed app depends on store paths that nix-collect-garbage would otherwise be free to delete, which would break it later with no obvious connection to the command that did it. Install pins every store path in the binary's RPATH, plus the by-path ones above, which never appear in an RPATH; uninstall releases them.

Running off-NixOS

Everything here is set up by the dev shell and baked into the installed launcher, so it should be invisible — but each one cost a debugging session, so it is written down.

  • GSettings schemas and icon themes (GSETTINGS_SCHEMA_DIR, XDG_DATA_DIRS). Without them GTK aborts on a missing schema, or renders broken-image icons.
  • A TLS backend (GIO_EXTRA_MODULESglib-networking). GIO has none built in; it loads one as a module. Without it every https:// page in a web pane fails with "TLS support is not available", while http:// still works.
  • One consistent mesa (__EGL_VENDOR_LIBRARY_DIRS, LIBGL_DRIVERS_PATH, GBM_BACKENDS_PATH). This is the subtle one. WebKit's web process is linked against Nix's libgbm, but libglvnd falls back to /usr/share/glvnd/egl_vendor.d and loads the host's mesa as the EGL vendor — and a GBM device created by one mesa is rejected by the other. Nix's libgbm also looks for its backend under /run/opengl-driver, which only exists on NixOS. Either one aborts the web process at startup with Could not create default EGL display: EGL_BAD_PARAMETER, so web panes open fine, begin loading, and then go permanently blank — the terminal side is completely unaffected, which makes it look like an app bug rather than a graphics one. Pointing all three at the Nix mesa fixes it; the real GPU is still used, via /dev/dri.

Which "libghostty" this uses, and why

Ghostty ships two different C APIs, and only one of them is usable here:

include/ghostty.h include/ghostty/vt.h
name "libghostty-internal" libghostty-vt
scope whole terminal incl. renderer VT core only
platforms GHOSTTY_PLATFORM_MACOS, GHOSTTY_PLATFORM_IOS any
intended for Ghostty's own macOS app external embedders

ghostty.h is the one that would hand you a ready-made terminal surface, but it has no Linux platform tag at all — its ghostty_platform_e enum only knows macOS and iOS, and its own header says it is "tailored to the needs of the macOS app". On Linux, Ghostty does not embed itself through that API; it builds its GTK apprt directly into the binary.

So this project uses libghostty-vt, the API Ghostty documents for external embedders. That means we get, for free and battle-tested:

  • escape sequence parsing and full terminal state
  • screen, scrollback, line wrapping, and reflow on resize
  • key and mouse event encoding (including the Kitty keyboard protocol)
  • paste safety checking and bracketed paste encoding

and we supply everything above it: process management, rendering, and the UI.

We consume it as a Zig module rather than through the C ABI. Ghostty's build.zig detects that it is being used as a dependency and, in that mode, builds only libghostty-vt — no GTK, no executable — so zig build pulls in just the VT core.

Architecture

main.zig       AdwApplication, CSS loading
Window.zig     sidebar + GtkStack of views, tab management, shortcuts
View.zig       one tab's content: its panes, their layout, and drag handling
Layout.zig     the split tree: nodes, rearranging, GtkPaned materialization
Pane.zig       content plus its header, drag source, and drop target
Terminal.zig   GtkDrawingArea: Cairo/Pango renderer + input handling
Browser.zig    WebKitWebView plus a back/forward/reload/address bar
webkit.zig     hand-written bindings for the WebKitGTK calls we make
Layouts.zig    saved tab templates: model, JSON on disk, {{substitution}}
OpenLayoutDialog.zig  prompts for a layout's parameters
SaveLayoutDialog.zig  turns the current tab into a saved layout
Session.zig    libghostty-vt Terminal + parser, fed by the PTY
Pty.zig        openpt/fork/exec, controlling terminal setup
key.zig        GDK keyval -> libghostty-vt key mapping
theme.zig      colors libghostty-vt has no opinion about

A tab is a view, and a view holds one or more panes arranged in a binary split tree: every interior node is a split with an orientation, every leaf is a pane. That is what allows the layouts a single shared orientation can't express — three panes in a row, drag the rightmost to the bottom, and you get two on top with one spanning the full width beneath them.

A pane holds either a terminal or a web view, behind a Pane.Content union. Both kinds expose the same three operations (widget, focus, destroy) and report back through the same three callbacks (title, exit, focus), so the split tree, drag and drop, and focus tracking are written once and never branch on which kind of pane they are moving. A web pane carries its own navigation bar rather than putting an address entry in the pane header, because the header is the drag handle and a text entry there would swallow the drags that rearrange the view.

Each split node owns a GtkPaned, so dividers are draggable and every split remembers its position as a ratio rather than a pixel count. The ratio is the source of truth: it is re-applied whenever the available space changes, so proportions survive window resizes, and only user drags update it.

Where a drop lands depends on how close it is to the view's own border. Near an edge of the window the pane is placed against the whole layout and spans it; anywhere else it splits just the pane under the pointer. That single rule gives both "put this across the bottom" and "split this one in half".

Panes and split nodes each hold a strong reference to their own widget, so detaching and reattaching during a rearrangement never finalizes anything. Running terminals keep their scrollback and processes straight through a move.

Drags rearrange live. Each time the drop target changes, the move is applied for real, so the layout under the cursor is always the layout you will get — there is no separate drop indicator because the view itself is the preview. Cancelling a drag puts the pane back: only the dragged pane ever moves, so the rest of the tree is unchanged and re-inserting it beside its original sibling restores the original shape.

Layouts

A layout is a saved tab: an arrangement of panes, each with a directory and a script, that opens in one go. Layouts take parameters, so one layout serves any number of projects.

The layout button in the sidebar lists them. Picking one asks for its parameters — prefilled with their defaults — and opens a new tab. A layout with no parameters skips the prompt. The plain new-tab button is untouched: it still opens one shell, immediately.

You author layouts by arranging a tab. Split it, drag panes around, drag the dividers, then Save tab as layout…. That captures the tree, the split orientations and the ratios exactly as they are on screen, and asks only for what it can't infer: a name, the parameters, and each pane's script. There is deliberately no separate layout builder — the split tree already is one.

The save dialog prefills what it can read off the live tab: each terminal's current directory, straight out of /proc/<pid>/cwd, and each web pane's current page. So the usual flow is to get a tab set up the way you like, save it, and replace the literal paths with {{parameters}}.

Editing a saved layout opens the same dialog on the stored one, so its name, parameters and per-pane scripts can be changed without opening it. Renaming moves the layout rather than copying it, and renaming onto a name another layout already has is refused instead of quietly replacing it. Nothing is written until you confirm, so cancelling leaves the layout untouched. To change the shape of a layout, open it, rearrange the tab, and save over it under the same name — the same tools you used to build it in the first place.

Every cwd, command and url goes through {{name}} substitution, and a leading ~ is expanded afterwards. An unknown {{name}} is left as written rather than blanked, so a typo shows up in the pane instead of silently producing an empty path.

Scripts are typed into the shell, not run instead of it. A pane starts your login shell as usual, and the script is fed to it once it is ready. The shell is still there when the script finishes, with your environment loaded and the command in history. The wait matters: writing at spawn time loses the input, because shells that set up line editing discard whatever was buffered while they were initializing. The shell's first output is the signal that it is reading, so that is when the script goes in.

Layouts live in ~/.config/playpen/layouts.json (or $XDG_CONFIG_HOME), and the file is meant to be edited by hand as well — Reload from disk picks up changes. It is JSON because the app writes it too, and a format that round-trips without a hand-written emitter is worth more here than a prettier one.

{
  "version": 1,
  "layouts": [
    {
      "name": "Project",
      "parameters": [
        { "name": "path", "description": "Project directory", "default": "~" }
      ],
      "root": {
        "split": "horizontal",
        "ratio": 0.55,
        "first": {
          "kind": "terminal",
          "cwd": "{{path}}",
          "command": "git status"
        },
        "second": {
          "split": "vertical",
          "ratio": 0.5,
          "first": { "kind": "terminal", "cwd": "{{path}}", "command": "nvim ." },
          "second": { "kind": "web", "url": "https://github.com" }
        }
      }
    }
  ]
}

A node is a split if it has a split key and a leaf otherwise. Saves are atomic — written to a temporary and renamed — so an interrupted write leaves the previous layouts intact rather than a file that won't parse.

Two design choices worth calling out:

No IO thread. The PTY is read on the GLib main loop through a unix fd watch (g_unix_fd_add). Terminal state is therefore only ever touched from the main thread, so the renderer reads the screen with no locking. Ghostty itself uses a dedicated IO thread; that is the right answer for a real terminal, but this is dramatically simpler and is not a bottleneck at interactive speeds.

No GPU renderer. Ghostty rasterizes glyphs into an atlas and draws on the GPU. Here, each frame walks the visible rows, groups cells into runs of identical style, and hands each run to Pango. That is far more work per frame in principle, but a terminal grid is small.

What works

  • Your login shell (from the passwd database, not $SHELL) on a real PTY, started as a login shell, with a controlling terminal so job control, Ctrl-C and SIGWINCH behave
  • Full SGR rendering: 16/256/true color, bold, italic, underline, strikethrough, inverse, and the bright-on-bold convention
  • Block / bar / underline / hollow cursor styles
  • Scrollback via mouse wheel; typing snaps back to the prompt
  • Resize reflows the grid and notifies the child
  • Window title (OSC 0/2) becomes the pane header and tab label; the tab shows its pane count once a view holds more than one
  • Tabs: create, close, switch; closing the last one closes the window
  • Multiple panes per tab in an arbitrary split tree, rearranged by keyboard or by dragging a pane's header, with draggable dividers between them; closing the last pane in a view closes its tab
  • Web panes, on WebKitGTK, sitting in the split tree beside terminals and dragging around exactly like they do: back/forward/reload, an address bar that takes a URL or falls back to a search, a load-progress indicator in the entry, and the page title feeding the pane header and tab label
  • Saved layouts: whole tabs — panes, splits, ratios, per-pane directories and scripts — opened in one go, parameterised by {{name}}, authored by arranging a tab and saving it. See Layouts

Shortcuts

Ctrl+Shift+T new tab
Ctrl+Shift+E new terminal in the current tab
Ctrl+Shift+B new web view in the current tab
Ctrl+Shift+W close the focused pane (closes the tab with its last one)
Ctrl+Shift+←/→/↑/↓ move the focused pane within its view
Ctrl+Shift+V paste into a terminal (bracketed-paste aware, refuses unsafe pastes)
Ctrl+PageUp/PageDown previous / next tab
Alt+1..Alt+8 jump to tab N, Alt+9 jumps to the last

Dragging a pane by its header does the same thing as the arrow shortcuts: the edge you drop against decides both the order and the view's orientation. The header is the drag handle rather than the whole pane so that dragging never competes with the content's own mouse handling.

Both add-pane shortcuts are also buttons in every pane header, which is how you open a web view without remembering the chord.

Not implemented

This is a proof of concept, and the following are deliberately absent:

  • Mouse selection and copy. libghostty-vt provides the selection and mouse-encoding primitives, but nothing here is wired to them yet, so there is no way to copy text. Paste works.
  • Ligatures and complex shaping. Each run is drawn independently at a fixed grid offset, so text that needs shaping across cell boundaries won't look right.
  • Moving panes between tabs. Panes can only be rearranged within their own view.
  • Browser furniture. Web panes get navigation and an address bar and nothing else: no bookmarks, history, downloads, devtools, or find-in-page, and each pane uses WebKit's default context, so nothing is persisted between runs. Links that ask for a new window are ignored rather than opening a pane.
  • Restoring a session. Layouts save arrangements you open deliberately; nothing restores the tabs you happened to have open when the app closed.
  • Kitty graphics, hyperlinks, tab reordering.
  • Custom terminfo. TERM is reported as xterm-256color rather than ghostty, since we don't install a terminfo entry.

Development

./b.sh wraps nix develop --command zig build and strips the enormous command line Zig prints on failure.

mise run screenshot out.png "text to type" (or ./shot.sh directly) runs the app inside a throwaway headless Sway and screenshots it. This keeps UI checks entirely out of your real Wayland session — nothing appears on screen, and it works while the session is locked.

One caveat when driving it: wtype loses the first keystroke of every invocation while the compositor adopts its freshly uploaded keymap, so scripted input should begin with a throwaway key. That is a quirk of the injection tool, not of the terminal. Note that this applies per invocation, so a trailing wtype -k Return in its own call is swallowed entirely — pass it as part of the same wtype command as the text it submits.

Only plain text injection reaches the app. Modifier chords (wtype -M ctrl) and synthetic clicks (swaymsg seat - cursor) are both accepted by the compositor and never delivered to the client, so shortcuts and buttons can't be exercised this way; to screenshot a state that a chord would reach, open it from code instead.

Web panes need an EGL display and the headless compositor has no GPU, so shot.sh forces the dev shell's mesa down to its software rasterizer. The mesa paths themselves come from the dev shell and are needed on a real session too — see Running off-NixOS.