Replaces the flat pane list with a binary split tree, so a view can hold layouts a single shared orientation could not express. Three panes in a row with the rightmost dragged to the bottom now gives two on top and one spanning the full width beneath them. Each split node owns a GtkPaned, which brings draggable dividers. Positions are stored as ratios and re-applied whenever the available space changes, so proportions survive window resizes while user drags still update them. Drop placement depends on proximity to the view's own border: near an edge the pane is placed against the whole layout and spans it, anywhere else it splits only the pane under the pointer. Drags now rearrange live rather than on release, so the layout under the cursor is always the result. Cancelling restores the original arrangement, which works because only the dragged pane moves: the rest of the tree keeps its shape, so re-inserting beside the original sibling is enough. Fixes a latent ownership bug the tree exposed: panes did not hold a reference to their own widget, so detaching one during a rebuild dropped the last reference and finalized it, producing GTK_IS_WIDGET assertion failures. Panes and split nodes now each own a reference to their widget.
196 lines
9.1 KiB
Markdown
196 lines
9.1 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 it still needs to be pointed at
|
|
GTK's GSettings schemas and icon themes, or it aborts on a missing schema.
|
|
A desktop launcher starts the app with a minimal environment, so those paths
|
|
are baked into the launcher script. They are read out of the dev shell at
|
|
install time rather than hardcoded, so they can't drift from the flake.
|
|
- **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; uninstall releases them.
|
|
|
|
## 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 a terminal plus its header, drag source, and drop target
|
|
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
|
|
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 terminal **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.
|
|
|
|
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 terminals 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
|
|
|
|
### Shortcuts
|
|
|
|
| | |
|
|
|---|---|
|
|
| `Ctrl+Shift+T` | new tab |
|
|
| `Ctrl+Shift+E` | new terminal in the current tab |
|
|
| `Ctrl+Shift+W` | close the focused terminal (closes the tab with its last one) |
|
|
| `Ctrl+Shift+←/→/↑/↓` | move the focused terminal within its view |
|
|
| `Ctrl+Shift+V` | paste (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 terminal's own mouse handling.
|
|
|
|
## 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.
|
|
- **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.
|