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).
+5
View File
@@ -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);
+42
View File
@@ -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"
'';
};
});
+41 -16
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+44
View File
@@ -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
View File
@@ -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,
.{},
);
}
};