260 lines
13 KiB
Markdown
260 lines
13 KiB
Markdown
# vtabs
|
|
|
|
A proof-of-concept terminal emulator with **vertical tabs**, built on
|
|
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita.
|
|
|
|
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 terminal inset to its
|
|
right.
|
|
|
|
## Quick start
|
|
|
|
```sh
|
|
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
|
|
|
|
```sh
|
|
mise run install # builds release, installs into ~/.local
|
|
mise run uninstall
|
|
```
|
|
|
|
That puts the binary in `~/.local/libexec/vtabs`, 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](#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_MODULES` → `glib-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
|
|
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.
|
|
|
|
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
|
|
|
|
### 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.
|
|
- **Saved layouts.** A view's arrangement lives only as long as the tab.
|
|
- **Kitty graphics, hyperlinks, tab reordering, config file.**
|
|
- **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](#running-off-nixos).
|