Add a web browser tab.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -52,6 +52,11 @@ pub fn build(b: *std.Build) void {
|
||||
exe.root_module.linkSystemLibrary("gtk4", .{});
|
||||
exe.root_module.linkSystemLibrary("libadwaita-1", .{});
|
||||
|
||||
// WebKitGTK backs the web panes. There are no generated bindings for it in
|
||||
// the zig-gobject set we use, so `src/webkit.zig` declares the handful of
|
||||
// C entry points we need by hand and this links them.
|
||||
exe.root_module.linkSystemLibrary("webkitgtk-6.0", .{});
|
||||
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
|
||||
@@ -61,6 +61,19 @@
|
||||
# GTK4 + libadwaita, the same native UI stack Ghostty uses on Linux.
|
||||
gtk4
|
||||
libadwaita
|
||||
|
||||
# Powers the web panes. The 6.0 ABI is the GTK4 build of WebKitGTK;
|
||||
# the 4.x ABIs are GTK3 and would pull in a second, conflicting GTK.
|
||||
webkitgtk_6_0
|
||||
|
||||
# TLS for the web panes. GIO has no TLS backend built in — it loads
|
||||
# one as a module — so without this every https:// page fails with
|
||||
# "TLS support is not available" while http:// still works.
|
||||
glib-networking
|
||||
|
||||
# The GPU stack WebKit's web process renders through. It has to be
|
||||
# this mesa and not the host's — see the shellHook below.
|
||||
mesa
|
||||
glib
|
||||
gobject-introspection
|
||||
pango
|
||||
@@ -96,8 +109,37 @@
|
||||
export VTABS_GSETTINGS_SCHEMA_DIR="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}/glib-2.0/schemas"
|
||||
export VTABS_RUNTIME_DATA_DIRS="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}:${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share"
|
||||
|
||||
# The GIO module holding the TLS backend. Same story as the schemas:
|
||||
# it is found by path at runtime, so the installer bakes it in too.
|
||||
export VTABS_GIO_MODULE_DIR="${pkgs.glib-networking}/lib/gio/modules"
|
||||
|
||||
export GSETTINGS_SCHEMA_DIR="$VTABS_GSETTINGS_SCHEMA_DIR"
|
||||
export XDG_DATA_DIRS="$VTABS_RUNTIME_DATA_DIRS:$XDG_DATA_DIRS"
|
||||
export GIO_EXTRA_MODULES="$VTABS_GIO_MODULE_DIR''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
|
||||
|
||||
# WebKit's web process renders through mesa, and every piece of that
|
||||
# path has to come from the *same* mesa. Two things go wrong on a
|
||||
# non-NixOS host if we leave it alone, and both kill the web process
|
||||
# at startup with "Could not create default EGL display:
|
||||
# EGL_BAD_PARAMETER", leaving web panes permanently blank:
|
||||
#
|
||||
# - libglvnd falls back to /usr/share/glvnd/egl_vendor.d and loads
|
||||
# the *host's* mesa as the EGL vendor, while WebKit is linked
|
||||
# against Nix's libgbm. A GBM device made by one mesa is rejected
|
||||
# by the other.
|
||||
# - Nix's libgbm looks for its backend in /run/opengl-driver/lib/gbm,
|
||||
# a path that only exists on NixOS.
|
||||
#
|
||||
# Pointing all three at this mesa keeps the stack self-consistent.
|
||||
# It still uses the real GPU through /dev/dri; this is not a
|
||||
# software-rendering fallback.
|
||||
export VTABS_EGL_VENDOR_DIR="${pkgs.mesa}/share/glvnd/egl_vendor.d"
|
||||
export VTABS_DRI_DRIVERS_PATH="${pkgs.mesa}/lib/dri"
|
||||
export VTABS_GBM_BACKENDS_PATH="${pkgs.mesa}/lib/gbm"
|
||||
|
||||
export __EGL_VENDOR_LIBRARY_DIRS="$VTABS_EGL_VENDOR_DIR"
|
||||
export LIBGL_DRIVERS_PATH="$VTABS_DRI_DRIVERS_PATH"
|
||||
export GBM_BACKENDS_PATH="$VTABS_GBM_BACKENDS_PATH"
|
||||
'';
|
||||
};
|
||||
});
|
||||
|
||||
@@ -38,10 +38,16 @@ gcroots="$data/vtabs/gcroots"
|
||||
|
||||
# The binary carries an RPATH into the Nix store, so it runs anywhere, but it
|
||||
# still has to be told where GTK's GSettings schemas and icon themes live or
|
||||
# it aborts on a missing schema. Read those paths back out of the dev shell
|
||||
# that built it rather than hardcoding store hashes.
|
||||
# it aborts on a missing schema, where GIO's TLS module lives or every
|
||||
# https:// page in a web pane fails, and which mesa to render web panes
|
||||
# through or WebKit's web process dies on startup. Read those paths back out
|
||||
# of the dev shell that built it rather than hardcoding store hashes.
|
||||
schemas="$(nix develop --command printenv VTABS_GSETTINGS_SCHEMA_DIR)"
|
||||
datadirs="$(nix develop --command printenv VTABS_RUNTIME_DATA_DIRS)"
|
||||
giomodules="$(nix develop --command printenv VTABS_GIO_MODULE_DIR)"
|
||||
eglvendor="$(nix develop --command printenv VTABS_EGL_VENDOR_DIR)"
|
||||
dridrivers="$(nix develop --command printenv VTABS_DRI_DRIVERS_PATH)"
|
||||
gbmbackends="$(nix develop --command printenv VTABS_GBM_BACKENDS_PATH)"
|
||||
|
||||
mkdir -p "$bindir" "$libexec" "$apps" "$icons" "$gcroots"
|
||||
|
||||
@@ -55,6 +61,15 @@ cat > "$bindir/vtabs" <<EOF
|
||||
# Generated by \`mise run install\` — re-run that instead of editing.
|
||||
export GSETTINGS_SCHEMA_DIR="$schemas\${GSETTINGS_SCHEMA_DIR:+:\$GSETTINGS_SCHEMA_DIR}"
|
||||
export XDG_DATA_DIRS="$datadirs:\${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
|
||||
export GIO_EXTRA_MODULES="$giomodules\${GIO_EXTRA_MODULES:+:\$GIO_EXTRA_MODULES}"
|
||||
|
||||
# Web panes render through the mesa this was built against. Mixing it with
|
||||
# the host's mesa aborts WebKit's web process, so these are set outright
|
||||
# rather than appended to whatever the session already had.
|
||||
export __EGL_VENDOR_LIBRARY_DIRS="$eglvendor"
|
||||
export LIBGL_DRIVERS_PATH="$dridrivers"
|
||||
export GBM_BACKENDS_PATH="$gbmbackends"
|
||||
|
||||
exec "$libexec/vtabs" "\$@"
|
||||
EOF
|
||||
chmod 755 "$bindir/vtabs"
|
||||
@@ -68,20 +83,30 @@ install -m644 dist/dev.greyson.vtabs.svg "$icons/dev.greyson.vtabs.svg"
|
||||
# starting, with no obvious connection to the command that broke it.
|
||||
# Nix roots are transitive, so rooting each RPATH entry covers its closure.
|
||||
rm -f "$gcroots"/*
|
||||
if command -v readelf >/dev/null; then
|
||||
readelf -d "$libexec/vtabs" \
|
||||
| sed -n 's/.*R\(UN\)\?PATH.*\[\(.*\)\]/\2/p' \
|
||||
| tr ':' '\n' \
|
||||
| sed -n 's|^\(/nix/store/[^/]*\).*|\1|p' \
|
||||
| sort -u \
|
||||
| while read -r path; do
|
||||
nix-store --realise --add-root "$gcroots/$(basename "$path")" \
|
||||
--indirect "$path" >/dev/null 2>&1 || true
|
||||
done
|
||||
echo "pinned $(find "$gcroots" -maxdepth 1 -type l | wc -l) store paths against garbage collection"
|
||||
else
|
||||
echo "warning: readelf not found; store paths left unpinned (nix-collect-garbage may break the install)" >&2
|
||||
fi
|
||||
{
|
||||
# What the dynamic linker needs, read straight out of the binary.
|
||||
if command -v readelf >/dev/null; then
|
||||
readelf -d "$libexec/vtabs" \
|
||||
| sed -n 's/.*R\(UN\)\?PATH.*\[\(.*\)\]/\2/p' \
|
||||
| tr ':' '\n'
|
||||
else
|
||||
echo "warning: readelf not found; only the launcher's own paths are pinned" >&2
|
||||
fi
|
||||
|
||||
# What the launcher points at. These are opened by name at runtime rather
|
||||
# than linked, so they never show up in the RPATH and would otherwise be
|
||||
# left for the collector — losing the TLS module breaks https in web panes
|
||||
# long after the command that deleted it.
|
||||
printf '%s\n' "$schemas" "$datadirs" "$giomodules" \
|
||||
"$eglvendor" "$dridrivers" "$gbmbackends" | tr ':' '\n'
|
||||
} \
|
||||
| sed -n 's|^\(/nix/store/[^/]*\).*|\1|p' \
|
||||
| sort -u \
|
||||
| while read -r path; do
|
||||
nix-store --realise --add-root "$gcroots/$(basename "$path")" \
|
||||
--indirect "$path" >/dev/null 2>&1 || true
|
||||
done
|
||||
echo "pinned $(find "$gcroots" -maxdepth 1 -type l | wc -l) store paths against garbage collection"
|
||||
|
||||
# Let the desktop notice the new entry and icon right away.
|
||||
command -v update-desktop-database >/dev/null && update-desktop-database -q "$apps" || true
|
||||
|
||||
@@ -39,6 +39,11 @@ export WLR_LIBINPUT_NO_DEVICES=1
|
||||
export WLR_RENDERER=pixman
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
export GSK_RENDERER=cairo
|
||||
|
||||
# WebKit's web process renders through mesa, which the dev shell has already
|
||||
# pointed at its own copy; here we only force that mesa down to its software
|
||||
# rasterizer, so a web pane paints the same way with or without a GPU.
|
||||
export GALLIUM_DRIVER=llvmpipe
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
export SWAYSOCK="$RUNDIR/sway.sock"
|
||||
# Don't let the app fall back to the real session.
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
//! The other thing a pane can hold: a WebKit web view with a small navigation
|
||||
//! bar above it.
|
||||
//!
|
||||
//! The nav bar lives here rather than in the pane header because the header is
|
||||
//! the pane's drag handle — a text entry there would swallow the drags that
|
||||
//! rearrange the view. Keeping it inside the content also means `Pane` needs to
|
||||
//! know nothing about browsing beyond which kind of content it holds.
|
||||
//!
|
||||
//! The callback shape matches `Terminal`'s exactly, so a pane can drive either
|
||||
//! one through the same three signals.
|
||||
|
||||
const std = @import("std");
|
||||
const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
const Pane = @import("Pane.zig");
|
||||
const webkit = @import("webkit.zig");
|
||||
|
||||
const Browser = @This();
|
||||
|
||||
/// Where a bare search term goes. Anything that parses as a host is loaded
|
||||
/// directly instead, so this only catches input that could not be an address.
|
||||
const search_prefix = "https://duckduckgo.com/?q=";
|
||||
|
||||
/// Upper bound on a resolved address. Long enough for real URLs, and for a
|
||||
/// search query that triples in length under percent-encoding.
|
||||
const url_max = 4096;
|
||||
|
||||
alloc: std.mem.Allocator,
|
||||
|
||||
/// Vertical box: nav bar on top, web view filling the rest.
|
||||
box: *gtk.Box,
|
||||
|
||||
view: *webkit.WebView,
|
||||
|
||||
entry: *gtk.Entry,
|
||||
back: *gtk.Button,
|
||||
forward: *gtk.Button,
|
||||
|
||||
/// Toggles between reload and stop depending on whether a load is running.
|
||||
reload: *gtk.Button,
|
||||
|
||||
/// Scratch for building the URI handed to WebKit. Held here rather than on the
|
||||
/// stack because it is larger than a callback frame wants to carry.
|
||||
url_buf: [url_max]u8 = undefined,
|
||||
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Browser {
|
||||
const self = try alloc.create(Browser);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.alloc = alloc,
|
||||
.box = gtk.Box.new(.vertical, 0),
|
||||
.view = .new(),
|
||||
.entry = gtk.Entry.new(),
|
||||
.back = gtk.Button.newFromIconName("go-previous-symbolic"),
|
||||
.forward = gtk.Button.newFromIconName("go-next-symbolic"),
|
||||
.reload = gtk.Button.newFromIconName("view-refresh-symbolic"),
|
||||
.on_title = cbs.on_title,
|
||||
.on_exit = cbs.on_exit,
|
||||
.on_focus = cbs.on_focus,
|
||||
.ctx = cbs.ctx,
|
||||
};
|
||||
|
||||
self.box.append(self.buildNav());
|
||||
|
||||
const view_widget = self.view.as(gtk.Widget);
|
||||
view_widget.setHexpand(1);
|
||||
view_widget.setVexpand(1);
|
||||
self.box.append(view_widget);
|
||||
|
||||
// Everything interesting about a web view arrives as a property change:
|
||||
// the title for the tab label, the URI for the address bar, and the load
|
||||
// state for the progress indicator and the reload/stop button.
|
||||
self.watch("title", &onNotifyTitle);
|
||||
self.watch("uri", &onNotifyUri);
|
||||
self.watch("estimated-load-progress", &onNotifyProgress);
|
||||
self.watch("is-loading", &onNotifyLoading);
|
||||
|
||||
// A page calling window.close() should close its pane, the same way a
|
||||
// shell exiting closes a terminal's.
|
||||
self.view.connectSignal("close", *Browser, &onClose, self);
|
||||
|
||||
// Fires for focus landing anywhere inside, so clicking the address bar
|
||||
// marks the pane active just as clicking the page does.
|
||||
const focus = gtk.EventControllerFocus.new();
|
||||
_ = gtk.EventControllerFocus.signals.enter.connect(
|
||||
focus,
|
||||
*Browser,
|
||||
&onFocusEnter,
|
||||
self,
|
||||
.{},
|
||||
);
|
||||
self.box.as(gtk.Widget).addController(focus.as(gtk.EventController));
|
||||
|
||||
self.syncNav();
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Browser) void {
|
||||
// The widget tree outlives this struct by a moment: the pane frees its
|
||||
// content first and drops the widgets' last reference afterwards. Tearing
|
||||
// down a page makes WebKit emit property changes on the way out, so every
|
||||
// handler bound to `self` has to go before `self` does, or those changes
|
||||
// land on freed memory.
|
||||
_ = gobject.signalHandlersDisconnectMatched(
|
||||
self.view.as(gobject.Object),
|
||||
.{ .data = true },
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
self,
|
||||
);
|
||||
|
||||
self.alloc.destroy(self);
|
||||
}
|
||||
|
||||
pub fn widget(self: *Browser) *gtk.Widget {
|
||||
return self.box.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Focus the address bar until there is a page to interact with, then the
|
||||
/// page itself. A fresh web pane opens with nowhere to go, so the useful
|
||||
/// thing to type into is the address bar.
|
||||
pub fn grabFocus(self: *Browser) void {
|
||||
const target = if (self.view.getUri() == null)
|
||||
self.entry.as(gtk.Widget)
|
||||
else
|
||||
self.view.as(gtk.Widget);
|
||||
_ = target.grabFocus();
|
||||
}
|
||||
|
||||
/// Current page title for the pane header, falling back to the address while
|
||||
/// a page is still loading and to a placeholder before anything is loaded.
|
||||
pub fn title(self: *Browser) []const u8 {
|
||||
if (self.view.getTitle()) |t| {
|
||||
const span = std.mem.span(t);
|
||||
if (span.len > 0) return span;
|
||||
}
|
||||
if (self.view.getUri()) |u| return std.mem.span(u);
|
||||
return "web";
|
||||
}
|
||||
|
||||
/// Load an address, applying the same interpretation the address bar does.
|
||||
pub fn navigate(self: *Browser, input: []const u8) void {
|
||||
const uri = self.resolve(input) orelse return;
|
||||
self.view.loadUri(uri);
|
||||
}
|
||||
|
||||
fn buildNav(self: *Browser) *gtk.Widget {
|
||||
const nav = gtk.Box.new(.horizontal, 2);
|
||||
nav.as(gtk.Widget).addCssClass("vtabs-nav");
|
||||
|
||||
for ([_]*gtk.Button{ self.back, self.forward, self.reload }) |button| {
|
||||
button.as(gtk.Widget).addCssClass("flat");
|
||||
button.as(gtk.Widget).addCssClass("vtabs-nav-button");
|
||||
nav.append(button.as(gtk.Widget));
|
||||
}
|
||||
|
||||
_ = gtk.Button.signals.clicked.connect(self.back, *Browser, &onBack, self, .{});
|
||||
_ = gtk.Button.signals.clicked.connect(self.forward, *Browser, &onForward, self, .{});
|
||||
_ = gtk.Button.signals.clicked.connect(self.reload, *Browser, &onReload, self, .{});
|
||||
|
||||
self.entry.setPlaceholderText("Enter address or search");
|
||||
self.entry.as(gtk.Widget).addCssClass("vtabs-nav-entry");
|
||||
self.entry.as(gtk.Widget).setHexpand(1);
|
||||
_ = gtk.Entry.signals.activate.connect(self.entry, *Browser, &onEntryActivate, self, .{});
|
||||
nav.append(self.entry.as(gtk.Widget));
|
||||
|
||||
return nav.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Subscribe to one of the web view's properties.
|
||||
fn watch(
|
||||
self: *Browser,
|
||||
comptime property: [:0]const u8,
|
||||
handler: *const fn (*gobject.Object, *gobject.ParamSpec, *Browser) callconv(.c) void,
|
||||
) void {
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
self.view.as(gobject.Object),
|
||||
*Browser,
|
||||
handler,
|
||||
self,
|
||||
.{ .detail = property },
|
||||
);
|
||||
}
|
||||
|
||||
/// Bring the nav bar in line with the web view's current state.
|
||||
fn syncNav(self: *Browser) void {
|
||||
self.back.as(gtk.Widget).setSensitive(@intFromBool(self.view.canGoBack()));
|
||||
self.forward.as(gtk.Widget).setSensitive(@intFromBool(self.view.canGoForward()));
|
||||
|
||||
const loading = self.view.isLoading();
|
||||
self.reload.setIconName(if (loading) "process-stop-symbolic" else "view-refresh-symbolic");
|
||||
self.reload.as(gtk.Widget).setTooltipText(if (loading) "Stop" else "Reload");
|
||||
|
||||
// The entry's own progress bar doubles as the loading indicator, so the
|
||||
// nav bar doesn't need a separate widget that is empty most of the time.
|
||||
self.entry.setProgressFraction(if (loading)
|
||||
self.view.getEstimatedLoadProgress()
|
||||
else
|
||||
0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Address interpretation
|
||||
//
|
||||
// Three cases, in order: something that already names a scheme is an address
|
||||
// as it stands; a bare word with a dot in it (or `localhost`) is a host we can
|
||||
// put a scheme on; anything else is what the user meant to search for.
|
||||
|
||||
fn resolve(self: *Browser, raw: []const u8) ?[:0]const u8 {
|
||||
const input = std.mem.trim(u8, raw, &std.ascii.whitespace);
|
||||
if (input.len == 0) return null;
|
||||
|
||||
if (hasScheme(input)) {
|
||||
return copyZ(&self.url_buf, input);
|
||||
}
|
||||
if (looksLikeHost(input)) {
|
||||
return std.fmt.bufPrintZ(&self.url_buf, "https://{s}", .{input}) catch null;
|
||||
}
|
||||
return searchUrl(&self.url_buf, input);
|
||||
}
|
||||
|
||||
/// True if the input already names a scheme. Checking for a scheme rather than
|
||||
/// for `://` is what keeps `data:`, `about:` and `mailto:` working, since none
|
||||
/// of those has an authority.
|
||||
fn hasScheme(input: []const u8) bool {
|
||||
// Whitespace rules the whole thing out: `note: buy milk` is a search, not
|
||||
// a `note` URI.
|
||||
if (std.mem.indexOfAny(u8, input, " \t") != null) return false;
|
||||
|
||||
const colon = std.mem.indexOfScalar(u8, input, ':') orelse return false;
|
||||
if (colon == 0 or !std.ascii.isAlphabetic(input[0])) return false;
|
||||
for (input[1..colon]) |c| {
|
||||
const valid = std.ascii.isAlphanumeric(c) or c == '+' or c == '-' or c == '.';
|
||||
if (!valid) return false;
|
||||
}
|
||||
|
||||
// Digits all the way to the next separator make this a port rather than a
|
||||
// scheme, so `localhost:8080` still gets `https://` put on the front.
|
||||
const rest = input[colon + 1 ..];
|
||||
const end = std.mem.indexOfAny(u8, rest, "/?#") orelse rest.len;
|
||||
if (end == 0) return true;
|
||||
for (rest[0..end]) |c| {
|
||||
if (!std.ascii.isDigit(c)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn looksLikeHost(input: []const u8) bool {
|
||||
if (std.mem.indexOfAny(u8, input, " \t") != null) return false;
|
||||
|
||||
// The host stops at the first path, query or fragment separator, so
|
||||
// `example.com/a.b` isn't judged by its path.
|
||||
const host = input[0 .. std.mem.indexOfAny(u8, input, "/?#") orelse input.len];
|
||||
if (std.mem.eql(u8, host, "localhost")) return true;
|
||||
if (std.mem.startsWith(u8, host, "localhost:")) return true;
|
||||
|
||||
// A dot with something either side is the cheapest thing that separates
|
||||
// `example.com` from a one-word search.
|
||||
const dot = std.mem.lastIndexOfScalar(u8, host, '.') orelse return false;
|
||||
return dot > 0 and dot < host.len - 1;
|
||||
}
|
||||
|
||||
fn copyZ(buf: []u8, text: []const u8) ?[:0]const u8 {
|
||||
if (text.len >= buf.len) return null;
|
||||
@memcpy(buf[0..text.len], text);
|
||||
buf[text.len] = 0;
|
||||
return buf[0..text.len :0];
|
||||
}
|
||||
|
||||
/// Percent-encode `query` into a search URL. Only unreserved characters
|
||||
/// survive as themselves, which is more conservative than necessary but
|
||||
/// cannot produce an invalid URL.
|
||||
fn searchUrl(buf: []u8, query: []const u8) ?[:0]const u8 {
|
||||
const hex = "0123456789ABCDEF";
|
||||
var w: usize = 0;
|
||||
|
||||
if (search_prefix.len >= buf.len) return null;
|
||||
@memcpy(buf[0..search_prefix.len], search_prefix);
|
||||
w = search_prefix.len;
|
||||
|
||||
for (query) |c| {
|
||||
const unreserved = std.ascii.isAlphanumeric(c) or
|
||||
c == '-' or c == '_' or c == '.' or c == '~';
|
||||
if (unreserved) {
|
||||
if (w + 1 >= buf.len) return null;
|
||||
buf[w] = c;
|
||||
w += 1;
|
||||
} else {
|
||||
if (w + 3 >= buf.len) return null;
|
||||
buf[w] = '%';
|
||||
buf[w + 1] = hex[c >> 4];
|
||||
buf[w + 2] = hex[c & 0x0f];
|
||||
w += 3;
|
||||
}
|
||||
}
|
||||
|
||||
buf[w] = 0;
|
||||
return buf[0..w :0];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Callbacks
|
||||
|
||||
fn onEntryActivate(entry: *gtk.Entry, self: *Browser) callconv(.c) void {
|
||||
const text = entry.as(gtk.Editable).getText();
|
||||
self.navigate(std.mem.span(text));
|
||||
// Hand focus to the page so the address bar isn't still capturing keys
|
||||
// once the load starts.
|
||||
_ = self.view.as(gtk.Widget).grabFocus();
|
||||
}
|
||||
|
||||
fn onBack(_: *gtk.Button, self: *Browser) callconv(.c) void {
|
||||
self.view.goBack();
|
||||
}
|
||||
|
||||
fn onForward(_: *gtk.Button, self: *Browser) callconv(.c) void {
|
||||
self.view.goForward();
|
||||
}
|
||||
|
||||
fn onReload(_: *gtk.Button, self: *Browser) callconv(.c) void {
|
||||
if (self.view.isLoading()) self.view.stopLoading() else self.view.reload();
|
||||
}
|
||||
|
||||
fn onNotifyTitle(_: *gobject.Object, _: *gobject.ParamSpec, self: *Browser) callconv(.c) void {
|
||||
self.on_title(self.ctx, self.title());
|
||||
}
|
||||
|
||||
fn onNotifyUri(_: *gobject.Object, _: *gobject.ParamSpec, self: *Browser) callconv(.c) void {
|
||||
if (self.view.getUri()) |uri| self.entry.as(gtk.Editable).setText(uri);
|
||||
self.syncNav();
|
||||
|
||||
// A redirect can change the address without ever changing the title, and
|
||||
// the title falls back to the address, so the pane header needs telling.
|
||||
self.on_title(self.ctx, self.title());
|
||||
}
|
||||
|
||||
fn onNotifyProgress(_: *gobject.Object, _: *gobject.ParamSpec, self: *Browser) callconv(.c) void {
|
||||
self.syncNav();
|
||||
}
|
||||
|
||||
fn onNotifyLoading(_: *gobject.Object, _: *gobject.ParamSpec, self: *Browser) callconv(.c) void {
|
||||
self.syncNav();
|
||||
}
|
||||
|
||||
fn onClose(_: *webkit.WebView, self: *Browser) callconv(.c) void {
|
||||
self.on_exit(self.ctx);
|
||||
}
|
||||
|
||||
fn onFocusEnter(_: *gtk.EventControllerFocus, self: *Browser) callconv(.c) void {
|
||||
self.on_focus(self.ctx);
|
||||
}
|
||||
+133
-30
@@ -1,18 +1,23 @@
|
||||
//! One terminal pane inside a view.
|
||||
//!
|
||||
//! A pane is a terminal plus the chrome needed to tell panes apart and
|
||||
//! rearrange them: a header strip showing the terminal's title, which doubles
|
||||
//! as the drag handle, and a drop target covering the whole pane.
|
||||
//! One pane inside a view: either a terminal or a web view, plus the chrome
|
||||
//! needed to tell panes apart and rearrange them — a header strip showing the
|
||||
//! content's title, which doubles as the drag handle, and a drop target
|
||||
//! covering the whole pane.
|
||||
//!
|
||||
//! The header exists mainly so dragging a pane never competes with the
|
||||
//! terminal's own mouse handling. Grabbing anywhere in the terminal body
|
||||
//! would collide with text selection as soon as that lands.
|
||||
//! content's own mouse handling. Grabbing anywhere in the terminal body would
|
||||
//! collide with text selection as soon as that lands, and anywhere in a web
|
||||
//! page would collide with the page itself.
|
||||
//!
|
||||
//! Everything below the header is behind `Content`, so the layout, drag and
|
||||
//! drop, and focus tracking are all written once and neither kind of content
|
||||
//! is special-cased.
|
||||
|
||||
const std = @import("std");
|
||||
const gdk = @import("gdk");
|
||||
const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
const Browser = @import("Browser.zig");
|
||||
const Layout = @import("Layout.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const View = @import("View.zig");
|
||||
@@ -21,6 +26,64 @@ const Pane = @This();
|
||||
|
||||
const Side = Layout.Side;
|
||||
|
||||
/// What a pane can hold. Chosen when the pane is created and fixed for its
|
||||
/// lifetime; changing kinds means closing the pane and opening another.
|
||||
pub const Kind = enum {
|
||||
terminal,
|
||||
web,
|
||||
|
||||
/// Icon standing in for this kind in the pane header and the tab row.
|
||||
pub fn iconName(self: Kind) [:0]const u8 {
|
||||
return switch (self) {
|
||||
.terminal => "utilities-terminal-symbolic",
|
||||
.web => "web-browser-symbolic",
|
||||
};
|
||||
}
|
||||
|
||||
/// Title a pane of this kind starts with, before its content reports one.
|
||||
pub fn initialTitle(self: Kind) []const u8 {
|
||||
return switch (self) {
|
||||
.terminal => "shell",
|
||||
.web => "web",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// What a content kind reports back to its pane. Shared by both kinds so the
|
||||
/// pane can wire either one up with the same handlers.
|
||||
pub const Callbacks = struct {
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
|
||||
/// The two things a pane can hold. Both expose the same three operations and
|
||||
/// report back through the same three callbacks, which is the whole reason a
|
||||
/// pane can stay ignorant of which one it has.
|
||||
pub const Content = union(Kind) {
|
||||
terminal: *Terminal,
|
||||
web: *Browser,
|
||||
|
||||
pub fn widget(self: Content) *gtk.Widget {
|
||||
return switch (self) {
|
||||
inline else => |c| c.widget(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn grabFocus(self: Content) void {
|
||||
switch (self) {
|
||||
inline else => |c| c.grabFocus(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroy(self: Content) void {
|
||||
switch (self) {
|
||||
inline else => |c| c.destroy(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const all_sides = [_]Side{ .left, .right, .top, .bottom };
|
||||
|
||||
/// How close to the view's outer border a drop must be, in pixels, to place
|
||||
@@ -31,39 +94,48 @@ const span_margin: f64 = 48;
|
||||
|
||||
alloc: std.mem.Allocator,
|
||||
view: *View,
|
||||
term: *Terminal,
|
||||
|
||||
/// Vertical box: header strip on top, terminal filling the rest.
|
||||
kind: Kind,
|
||||
content: Content,
|
||||
|
||||
/// Vertical box: header strip on top, content filling the rest.
|
||||
box: *gtk.Box,
|
||||
|
||||
/// The header doubles as the drag handle, so it's kept for the drag source.
|
||||
header: *gtk.Box,
|
||||
label: *gtk.Label,
|
||||
|
||||
/// Latest terminal title, kept NUL-terminated for GTK.
|
||||
/// Latest title reported by the content, kept NUL-terminated for GTK.
|
||||
title: [128:0]u8 = @splat(0),
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
|
||||
pub fn create(alloc: std.mem.Allocator, view: *View, kind: Kind) !*Pane {
|
||||
const self = try alloc.create(Pane);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.alloc = alloc,
|
||||
.view = view,
|
||||
.term = undefined,
|
||||
.kind = kind,
|
||||
.content = undefined,
|
||||
.box = gtk.Box.new(.vertical, 0),
|
||||
.header = gtk.Box.new(.horizontal, 4),
|
||||
.label = gtk.Label.new("shell"),
|
||||
.label = gtk.Label.new(""),
|
||||
};
|
||||
setTitle(self, "shell");
|
||||
setTitle(self, kind.initialTitle());
|
||||
|
||||
self.term = try .create(alloc, .{
|
||||
.on_title = &onTermTitle,
|
||||
.on_exit = &onTermExit,
|
||||
.on_focus = &onTermFocus,
|
||||
// Both constructors take the same callbacks, so the only thing that
|
||||
// varies between kinds is which one is called.
|
||||
const callbacks: Callbacks = .{
|
||||
.on_title = &onContentTitle,
|
||||
.on_exit = &onContentExit,
|
||||
.on_focus = &onContentFocus,
|
||||
.ctx = self,
|
||||
});
|
||||
errdefer self.term.destroy();
|
||||
};
|
||||
self.content = switch (kind) {
|
||||
.terminal => .{ .terminal = try .create(alloc, callbacks) },
|
||||
.web => .{ .web = try .create(alloc, callbacks) },
|
||||
};
|
||||
errdefer self.content.destroy();
|
||||
|
||||
// Own a strong reference to our own widget. Rearranging the view detaches
|
||||
// panes from their parent before reattaching them elsewhere, and without a
|
||||
@@ -75,13 +147,14 @@ pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
|
||||
box_widget.addCssClass("vtabs-pane");
|
||||
box_widget.setHexpand(1);
|
||||
box_widget.setVexpand(1);
|
||||
// Clip the terminal to the pane's rounded corners. The terminal paints a
|
||||
// plain rectangle and has no idea it's inside a rounded frame.
|
||||
// Clip the content to the pane's rounded corners. A terminal paints a
|
||||
// plain rectangle and a web page paints whatever it likes; neither has any
|
||||
// idea it's inside a rounded frame.
|
||||
box_widget.setOverflow(.hidden);
|
||||
|
||||
self.buildHeader();
|
||||
self.box.append(self.header.as(gtk.Widget));
|
||||
self.box.append(self.term.widget());
|
||||
self.box.append(self.content.widget());
|
||||
|
||||
self.installDragSource();
|
||||
self.installDropTarget();
|
||||
@@ -90,7 +163,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Pane) void {
|
||||
self.term.destroy();
|
||||
self.content.destroy();
|
||||
// Releases the reference taken in `create`, finalizing the widget tree.
|
||||
self.box.as(gobject.Object).unref();
|
||||
self.alloc.destroy(self);
|
||||
@@ -101,7 +174,17 @@ pub fn widget(self: *Pane) *gtk.Widget {
|
||||
}
|
||||
|
||||
pub fn grabFocus(self: *Pane) void {
|
||||
self.term.grabFocus();
|
||||
self.content.grabFocus();
|
||||
}
|
||||
|
||||
/// The terminal this pane holds, or null if it holds something else. Callers
|
||||
/// that only make sense for a terminal — pasting a VT sequence, say — use this
|
||||
/// to opt out on a web pane.
|
||||
pub fn terminal(self: *Pane) ?*Terminal {
|
||||
return switch (self.content) {
|
||||
.terminal => |t| t,
|
||||
.web => null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn titleSlice(self: *const Pane) [:0]const u8 {
|
||||
@@ -123,6 +206,12 @@ fn buildHeader(self: *Pane) void {
|
||||
// The whole strip is the drag handle, so advertise that with the cursor.
|
||||
header.as(gtk.Widget).setCursorFromName("grab");
|
||||
|
||||
// With two kinds of pane in a view, the title alone no longer says what
|
||||
// you're looking at, so the header leads with the kind.
|
||||
const icon = gtk.Image.newFromIconName(self.kind.iconName());
|
||||
icon.as(gtk.Widget).addCssClass("vtabs-pane-icon");
|
||||
header.append(icon.as(gtk.Widget));
|
||||
|
||||
self.label.setXalign(0);
|
||||
self.label.setEllipsize(.end);
|
||||
self.label.as(gtk.Widget).setHexpand(1);
|
||||
@@ -136,10 +225,17 @@ fn buildHeader(self: *Pane) void {
|
||||
_ = gtk.Button.signals.clicked.connect(split, *Pane, &onSplitClicked, self, .{});
|
||||
header.append(split.as(gtk.Widget));
|
||||
|
||||
const web = gtk.Button.newFromIconName("web-browser-symbolic");
|
||||
web.as(gtk.Widget).addCssClass("flat");
|
||||
web.as(gtk.Widget).addCssClass("vtabs-pane-button");
|
||||
web.as(gtk.Widget).setTooltipText("New web view in this view (Ctrl+Shift+B)");
|
||||
_ = gtk.Button.signals.clicked.connect(web, *Pane, &onWebClicked, self, .{});
|
||||
header.append(web.as(gtk.Widget));
|
||||
|
||||
const close = gtk.Button.newFromIconName("window-close-symbolic");
|
||||
close.as(gtk.Widget).addCssClass("flat");
|
||||
close.as(gtk.Widget).addCssClass("vtabs-pane-button");
|
||||
close.as(gtk.Widget).setTooltipText("Close terminal (Ctrl+Shift+W)");
|
||||
close.as(gtk.Widget).setTooltipText("Close pane (Ctrl+Shift+W)");
|
||||
_ = gtk.Button.signals.clicked.connect(close, *Pane, &onCloseClicked, self, .{});
|
||||
header.append(close.as(gtk.Widget));
|
||||
}
|
||||
@@ -282,28 +378,35 @@ fn setTitle(self: *Pane, text: []const u8) void {
|
||||
self.label.as(gtk.Widget).setTooltipText(self.titleSlice());
|
||||
}
|
||||
|
||||
fn onTermTitle(ctx: ?*anyopaque, title: []const u8) void {
|
||||
fn onContentTitle(ctx: ?*anyopaque, title: []const u8) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
self.setTitle(title);
|
||||
self.view.paneTitleChanged(self);
|
||||
}
|
||||
|
||||
fn onTermExit(ctx: ?*anyopaque) void {
|
||||
/// The shell exited, or a page called window.close().
|
||||
fn onContentExit(ctx: ?*anyopaque) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
self.view.closePane(self);
|
||||
}
|
||||
|
||||
fn onTermFocus(ctx: ?*anyopaque) void {
|
||||
fn onContentFocus(ctx: ?*anyopaque) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
self.view.setFocused(self);
|
||||
}
|
||||
|
||||
fn onSplitClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.addPane() catch |err| {
|
||||
self.view.addPane(.terminal) catch |err| {
|
||||
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.addPane(.web) catch |err| {
|
||||
std.log.err("failed to open web view: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
fn onCloseClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.closePane(self);
|
||||
}
|
||||
|
||||
+2
-9
@@ -17,6 +17,7 @@ const vt = @import("ghostty-vt");
|
||||
|
||||
const keymap = @import("key.zig");
|
||||
const theme = @import("theme.zig");
|
||||
const Pane = @import("Pane.zig");
|
||||
const Session = @import("Session.zig");
|
||||
|
||||
const Terminal = @This();
|
||||
@@ -53,15 +54,7 @@ on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub fn create(
|
||||
alloc: std.mem.Allocator,
|
||||
cbs: struct {
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
},
|
||||
) !*Terminal {
|
||||
pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Terminal {
|
||||
const self = try alloc.create(Terminal);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
|
||||
+18
-9
@@ -1,5 +1,5 @@
|
||||
//! A view: the content of one tab, holding one or more terminal panes
|
||||
//! arranged in a split tree.
|
||||
//! A view: the content of one tab, holding one or more panes — terminals, web
|
||||
//! views, or a mix — arranged in a split tree.
|
||||
//!
|
||||
//! Dragging a pane rearranges the view live rather than on release. Each time
|
||||
//! the drop target changes, the move is applied for real, so what you see
|
||||
@@ -18,6 +18,7 @@ const Terminal = @import("Terminal.zig");
|
||||
const View = @This();
|
||||
|
||||
pub const Side = Layout.Side;
|
||||
pub const Kind = Pane.Kind;
|
||||
|
||||
/// Where a dragged pane would land.
|
||||
pub const Target = struct {
|
||||
@@ -113,8 +114,8 @@ pub fn widget(self: *View) *gtk.Widget {
|
||||
return self.box.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Text for the tab label: the focused terminal's title, prefixed with the
|
||||
/// pane count once there is more than one.
|
||||
/// Text for the tab label: the focused pane's title, prefixed with the pane
|
||||
/// count once there is more than one.
|
||||
pub fn label(self: *View, buf: []u8) []const u8 {
|
||||
const pane = self.focusedPane() orelse return "";
|
||||
const title = pane.titleSlice();
|
||||
@@ -132,19 +133,27 @@ pub fn focusedPane(self: *View) ?*Pane {
|
||||
return self.focused orelse (if (self.panes.items.len > 0) self.panes.items[0] else null);
|
||||
}
|
||||
|
||||
/// The focused pane's terminal, or null when the focused pane holds a web
|
||||
/// view instead.
|
||||
pub fn focusedTerminal(self: *View) ?*Terminal {
|
||||
const pane = self.focusedPane() orelse return null;
|
||||
return pane.term;
|
||||
return pane.terminal();
|
||||
}
|
||||
|
||||
/// Icon for the tab row: whatever the focused pane is showing.
|
||||
pub fn iconName(self: *View) [:0]const u8 {
|
||||
const pane = self.focusedPane() orelse return Kind.terminal.iconName();
|
||||
return pane.kind.iconName();
|
||||
}
|
||||
|
||||
pub fn focus(self: *View) void {
|
||||
if (self.focusedPane()) |pane| pane.grabFocus();
|
||||
}
|
||||
|
||||
/// Add a terminal, splitting the focused pane so the new one appears beside
|
||||
/// the terminal you were working in.
|
||||
pub fn addPane(self: *View) !void {
|
||||
const pane = try Pane.create(self.alloc, self);
|
||||
/// Add a pane, splitting the focused one so the new pane appears beside
|
||||
/// whatever you were working in.
|
||||
pub fn addPane(self: *View, kind: Kind) !void {
|
||||
const pane = try Pane.create(self.alloc, self, kind);
|
||||
errdefer pane.destroy();
|
||||
|
||||
const node = try self.layout.newLeaf(pane);
|
||||
|
||||
+38
-14
@@ -14,6 +14,7 @@ const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
const vt = @import("ghostty-vt");
|
||||
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const View = @import("View.zig");
|
||||
|
||||
const Window = @This();
|
||||
@@ -42,13 +43,18 @@ updating: bool = false,
|
||||
/// doesn't try to close a tab we're already destroying.
|
||||
closing: bool = false,
|
||||
|
||||
/// A single tab: a view of one or more terminals, plus the sidebar row that
|
||||
/// A single tab: a view of one or more panes, plus the sidebar row that
|
||||
/// selects it.
|
||||
const Tab = struct {
|
||||
window: *Window,
|
||||
view: *View,
|
||||
row: *gtk.ListBoxRow,
|
||||
label: *gtk.Label,
|
||||
|
||||
/// Shows what the tab's focused pane is, so a web view is recognisable in
|
||||
/// the sidebar without reading the title.
|
||||
icon: *gtk.Image,
|
||||
|
||||
name: [16]u8,
|
||||
name_len: usize,
|
||||
|
||||
@@ -185,6 +191,7 @@ pub fn newTab(self: *Window) !void {
|
||||
.view = view,
|
||||
.row = gtk.ListBoxRow.new(),
|
||||
.label = gtk.Label.new("shell"),
|
||||
.icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"),
|
||||
.name = undefined,
|
||||
.name_len = 0,
|
||||
};
|
||||
@@ -195,8 +202,7 @@ pub fn newTab(self: *Window) !void {
|
||||
const row_box = gtk.Box.new(.horizontal, 6);
|
||||
row_box.as(gtk.Widget).addCssClass("vtabs-row");
|
||||
|
||||
const icon = gtk.Image.newFromIconName("utilities-terminal-symbolic");
|
||||
row_box.append(icon.as(gtk.Widget));
|
||||
row_box.append(tab.icon.as(gtk.Widget));
|
||||
|
||||
tab.label.setXalign(0);
|
||||
tab.label.setEllipsize(.end);
|
||||
@@ -217,8 +223,8 @@ pub fn newTab(self: *Window) !void {
|
||||
try self.tabs.append(self.alloc, tab);
|
||||
|
||||
// Only now is `tab` complete enough for the view's callbacks to use, so
|
||||
// this is the first safe moment to give the view its terminal.
|
||||
try view.addPane();
|
||||
// this is the first safe moment to give the view its first pane.
|
||||
try view.addPane(.terminal);
|
||||
|
||||
self.refreshLabel(tab);
|
||||
self.select(tab);
|
||||
@@ -301,6 +307,7 @@ fn refreshLabel(self: *Window, tab: *Tab) void {
|
||||
|
||||
tab.label.setText(buf[0..text.len :0]);
|
||||
tab.label.as(gtk.Widget).setTooltipText(buf[0..text.len :0]);
|
||||
tab.icon.setFromIconName(tab.view.iconName());
|
||||
}
|
||||
|
||||
fn onViewTitle(ctx: ?*anyopaque) void {
|
||||
@@ -342,6 +349,20 @@ fn activeTab(self: *Window) ?*Tab {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The focused terminal of the visible tab, or null when a web pane has focus.
|
||||
fn focusedTerminal(self: *Window) ?*Terminal {
|
||||
const tab = self.activeTab() orelse return null;
|
||||
return tab.view.focusedTerminal();
|
||||
}
|
||||
|
||||
/// Split the visible tab's focused pane, adding a pane of the given kind.
|
||||
fn addPane(self: *Window, kind: View.Kind) void {
|
||||
const tab = self.activeTab() orelse return;
|
||||
tab.view.addPane(kind) catch |err| {
|
||||
std.log.err("failed to open {s} pane: {s}", .{ @tagName(kind), @errorName(err) });
|
||||
};
|
||||
}
|
||||
|
||||
fn selectIndex(self: *Window, index: usize) void {
|
||||
if (index >= self.tabs.items.len) return;
|
||||
self.select(self.tabs.items[index]);
|
||||
@@ -376,22 +397,26 @@ fn onShortcut(
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_W, gdk.KEY_w => {
|
||||
// Closes the focused terminal. The view raises on_empty when
|
||||
// its last pane goes, which is what closes the tab.
|
||||
// Closes the focused pane. The view raises on_empty when its
|
||||
// last pane goes, which is what closes the tab.
|
||||
if (self.activeTab()) |tab| {
|
||||
if (tab.view.focusedPane()) |pane| tab.view.closePane(pane);
|
||||
}
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_E, gdk.KEY_e => {
|
||||
if (self.activeTab()) |tab| {
|
||||
tab.view.addPane() catch |err| {
|
||||
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
self.addPane(.terminal);
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_B, gdk.KEY_b => {
|
||||
self.addPane(.web);
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_V, gdk.KEY_v => {
|
||||
// Only a terminal needs us to encode a paste for it. A web
|
||||
// pane has its own clipboard handling, so the key is left
|
||||
// alone rather than swallowed here.
|
||||
if (self.focusedTerminal() == null) return 0;
|
||||
self.paste();
|
||||
return 1;
|
||||
},
|
||||
@@ -478,8 +503,7 @@ fn onPasteReady(
|
||||
};
|
||||
defer glib.free(text);
|
||||
|
||||
const tab = self.activeTab() orelse return;
|
||||
const terminal = tab.view.focusedTerminal() orelse return;
|
||||
const terminal = self.focusedTerminal() orelse return;
|
||||
const session = terminal.session;
|
||||
// Coerce to a plain slice: encodePaste dispatches on the exact type.
|
||||
const span: []const u8 = std.mem.span(text);
|
||||
|
||||
@@ -120,6 +120,50 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vtabs-pane-icon {
|
||||
color: #6f6784;
|
||||
-gtk-icon-size: 14px;
|
||||
}
|
||||
|
||||
.vtabs-pane.active .vtabs-pane-icon {
|
||||
color: #b29df5;
|
||||
}
|
||||
|
||||
/* A web pane's navigation bar, below the pane header. Kept visually quieter
|
||||
than the header so the two rows don't compete. */
|
||||
.vtabs-nav {
|
||||
padding: 4px 6px;
|
||||
background-color: #16141c;
|
||||
border-bottom: 1px solid #262133;
|
||||
}
|
||||
|
||||
.vtabs-nav-button {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
color: #b6afc7;
|
||||
}
|
||||
|
||||
.vtabs-nav-button:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.vtabs-nav-entry {
|
||||
min-height: 24px;
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85em;
|
||||
background-color: #0f0d14;
|
||||
color: #ded7ef;
|
||||
border: 1px solid #2a2536;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vtabs-nav-entry:focus-within {
|
||||
border-color: #5b4d80;
|
||||
}
|
||||
|
||||
/* The pane being dragged. There is no separate drop indicator: the layout
|
||||
rearranges live during the drag, so the view itself is the preview. */
|
||||
.vtabs-pane.dragging {
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
//! Hand-written bindings for the slice of WebKitGTK 6.0 the web panes use.
|
||||
//!
|
||||
//! The zig-gobject binding set we share with Ghostty is generated from a fixed
|
||||
//! list of GIR files that doesn't include WebKit, and adding a second
|
||||
//! generated set would bring a second, incompatible `gtk.Widget` type with it —
|
||||
//! every widget handed across the boundary would need laundering. Declaring
|
||||
//! the dozen C entry points we actually call is smaller than that problem.
|
||||
//!
|
||||
//! `WebKitWebView` is an ordinary `GtkWidget` subclass, so a pointer cast is
|
||||
//! all it takes to hand one to GTK or to connect to its GObject signals; the
|
||||
//! bindings only have to cover the WebKit-specific calls.
|
||||
|
||||
const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
/// Boolean returns are C `gboolean`, which is an int, not a `bool`.
|
||||
fn truthy(v: c_int) bool {
|
||||
return v != 0;
|
||||
}
|
||||
|
||||
pub const WebView = opaque {
|
||||
extern fn webkit_web_view_new() *WebView;
|
||||
/// Returns a floating reference, like every other GTK widget constructor,
|
||||
/// so appending it to a container is what claims ownership.
|
||||
pub const new = webkit_web_view_new;
|
||||
|
||||
/// Upcast to the GTK/GObject types this really is underneath. Restricted
|
||||
/// to the two ancestors we need, since nothing here checks the hierarchy
|
||||
/// the way the generated bindings' `as` does.
|
||||
pub fn as(self: *WebView, comptime T: type) *T {
|
||||
comptime if (T != gtk.Widget and T != gobject.Object) @compileError(
|
||||
"WebKitWebView can only be cast to gtk.Widget or gobject.Object",
|
||||
);
|
||||
return @ptrCast(@alignCast(self));
|
||||
}
|
||||
|
||||
extern fn webkit_web_view_load_uri(*WebView, [*:0]const u8) void;
|
||||
pub const loadUri = webkit_web_view_load_uri;
|
||||
|
||||
/// The page's title, or null before one is known. Owned by WebKit.
|
||||
extern fn webkit_web_view_get_title(*WebView) ?[*:0]const u8;
|
||||
pub const getTitle = webkit_web_view_get_title;
|
||||
|
||||
/// The current URI, or null if nothing has been loaded. Owned by WebKit.
|
||||
extern fn webkit_web_view_get_uri(*WebView) ?[*:0]const u8;
|
||||
pub const getUri = webkit_web_view_get_uri;
|
||||
|
||||
extern fn webkit_web_view_can_go_back(*WebView) c_int;
|
||||
pub fn canGoBack(self: *WebView) bool {
|
||||
return truthy(webkit_web_view_can_go_back(self));
|
||||
}
|
||||
|
||||
extern fn webkit_web_view_can_go_forward(*WebView) c_int;
|
||||
pub fn canGoForward(self: *WebView) bool {
|
||||
return truthy(webkit_web_view_can_go_forward(self));
|
||||
}
|
||||
|
||||
extern fn webkit_web_view_go_back(*WebView) void;
|
||||
pub const goBack = webkit_web_view_go_back;
|
||||
|
||||
extern fn webkit_web_view_go_forward(*WebView) void;
|
||||
pub const goForward = webkit_web_view_go_forward;
|
||||
|
||||
extern fn webkit_web_view_reload(*WebView) void;
|
||||
pub const reload = webkit_web_view_reload;
|
||||
|
||||
extern fn webkit_web_view_stop_loading(*WebView) void;
|
||||
pub const stopLoading = webkit_web_view_stop_loading;
|
||||
|
||||
extern fn webkit_web_view_is_loading(*WebView) c_int;
|
||||
pub fn isLoading(self: *WebView) bool {
|
||||
return truthy(webkit_web_view_is_loading(self));
|
||||
}
|
||||
|
||||
/// 0..1, and meaningful only while `isLoading` is true.
|
||||
extern fn webkit_web_view_get_estimated_load_progress(*WebView) f64;
|
||||
pub const getEstimatedLoadProgress = webkit_web_view_get_estimated_load_progress;
|
||||
|
||||
/// Connect to a WebKit-specific signal.
|
||||
///
|
||||
/// The generated bindings' typed `connect` helpers only exist for types
|
||||
/// they know about, so WebKit's own signals go through the untyped
|
||||
/// GObject entry point. Property changes don't need this: `notify` is
|
||||
/// declared on `gobject.Object`, which this can be cast to.
|
||||
pub fn connectSignal(
|
||||
self: *WebView,
|
||||
comptime signal: [:0]const u8,
|
||||
comptime Data: type,
|
||||
handler: *const fn (*WebView, Data) callconv(.c) void,
|
||||
data: Data,
|
||||
) void {
|
||||
_ = gobject.signalConnectData(
|
||||
self.as(gobject.Object),
|
||||
signal,
|
||||
@ptrCast(handler),
|
||||
@ptrCast(data),
|
||||
null,
|
||||
.{},
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user