diff --git a/README.md b/README.md index fa87d24..5020156 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/build.zig b/build.zig index 8f8f4e7..0913e8c 100644 --- a/build.zig +++ b/build.zig @@ -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); diff --git a/flake.nix b/flake.nix index 9f40d1d..393bf21 100644 --- a/flake.nix +++ b/flake.nix @@ -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" ''; }; }); diff --git a/mise.toml b/mise.toml index 79f8b04..9c2a3fc 100644 --- a/mise.toml +++ b/mise.toml @@ -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" </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 diff --git a/shot.sh b/shot.sh index ef1caee..1bde936 100755 --- a/shot.sh +++ b/shot.sh @@ -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. diff --git a/src/Browser.zig b/src/Browser.zig new file mode 100644 index 0000000..f28fac1 --- /dev/null +++ b/src/Browser.zig @@ -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); +} diff --git a/src/Pane.zig b/src/Pane.zig index 695ff59..92fa61e 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -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); } diff --git a/src/Terminal.zig b/src/Terminal.zig index 6b7fdf8..1160baf 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -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); diff --git a/src/View.zig b/src/View.zig index a96c13b..9b97589 100644 --- a/src/View.zig +++ b/src/View.zig @@ -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); diff --git a/src/Window.zig b/src/Window.zig index 950e301..1e721fa 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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); diff --git a/src/style.css b/src/style.css index adba517..c932f30 100644 --- a/src/style.css +++ b/src/style.css @@ -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 { diff --git a/src/webkit.zig b/src/webkit.zig new file mode 100644 index 0000000..a2e77a0 --- /dev/null +++ b/src/webkit.zig @@ -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, + .{}, + ); + } +};