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
+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,
.{},
);
}
};