Add a web browser tab.

This commit is contained in:
Greyson Parrelli
2026-08-11 12:39:14 -04:00
parent 918ccec6e1
commit a7a9429d09
12 changed files with 868 additions and 93 deletions
+79 -15
View File
@@ -31,15 +31,43 @@ theme, then refreshes the desktop and icon caches. `mise tasks` lists the rest
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.
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; uninstall releases them.
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
@@ -80,20 +108,31 @@ 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
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 terminal **panes** arranged
in a **binary split tree**: every interior node is a split with an orientation,
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
@@ -141,9 +180,13 @@ in principle, but a terminal grid is small.
- 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
- 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
@@ -151,16 +194,20 @@ in principle, but a terminal grid is small.
|---|---|
| `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+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 terminal's own mouse handling.
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
@@ -174,6 +221,10 @@ This is a proof of concept, and the following are deliberately absent:
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
@@ -192,4 +243,17 @@ 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.
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).