vtabs: terminal with vertical tabs on libghostty-vt + GTK4

Uses libghostty-vt (the API Ghostty documents for external embedders) for
the terminal core. Ghostty's other C API, ghostty.h, exposes a full terminal
surface but only supports macOS and iOS platform tags, so it cannot be
embedded on Linux.

We supply the layers libghostty-vt deliberately leaves out: PTY and process
management, a Cairo/Pango cell renderer, and a GTK4/libadwaita UI with a
Zen-style vertical tab sidebar.

Nix pins the whole toolchain (Zig 0.16 via zig-overlay, GTK 4.22, libadwaita)
so no host setup is needed.
This commit is contained in:
Greyson Parrelli
2026-08-11 08:36:10 -04:00
commit 738cead680
16 changed files with 2359 additions and 0 deletions
+454
View File
@@ -0,0 +1,454 @@
//! The application window: a vertical tab strip down the left side and the
//! active terminal filling the rest.
//!
//! The layout follows Zen Browser's vertical tabs — a persistent sidebar
//! column holding the window controls, a "New Tab" affordance, and one row
//! per tab, with the content pane inset to its right.
const std = @import("std");
const adw = @import("adw");
const gdk = @import("gdk");
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const vt = @import("ghostty-vt");
const Terminal = @import("Terminal.zig");
const Window = @This();
const sidebar_width = 220;
alloc: std.mem.Allocator,
window: *adw.ApplicationWindow,
/// Holds one page per tab; the visible page is the active terminal.
stack: *gtk.Stack,
/// One row per tab, in the same order as `tabs`.
list: *gtk.ListBox,
tabs: std.ArrayListUnmanaged(*Tab) = .empty,
/// Monotonic counter so every tab gets a distinct GtkStack page name.
next_id: u32 = 0,
/// Set while we're programmatically changing the selection, so that the
/// resulting `row-selected` signal doesn't recurse.
updating: bool = false,
/// Set once teardown has begun, so that a session exiting mid-teardown
/// doesn't try to close a tab we're already destroying.
closing: bool = false,
/// A single tab: the terminal plus the sidebar row that selects it.
const Tab = struct {
window: *Window,
term: *Terminal,
row: *gtk.ListBoxRow,
label: *gtk.Label,
name: [16]u8,
name_len: usize,
fn pageName(self: *const Tab) [:0]const u8 {
return self.name[0..self.name_len :0];
}
};
pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
const self = try alloc.create(Window);
errdefer alloc.destroy(self);
const window = adw.ApplicationWindow.new(app.as(gtk.Application));
window.as(gtk.Window).setTitle("vtabs");
window.as(gtk.Window).setDefaultSize(1100, 720);
self.* = .{
.alloc = alloc,
.window = window,
.stack = gtk.Stack.new(),
.list = gtk.ListBox.new(),
};
window.as(gtk.Widget).addCssClass("vtabs-window");
// ---- sidebar -------------------------------------------------------
const sidebar = gtk.Box.new(.vertical, 0);
sidebar.as(gtk.Widget).addCssClass("vtabs-sidebar");
sidebar.as(gtk.Widget).setSizeRequest(sidebar_width, -1);
// The header bar lives inside the sidebar rather than spanning the
// window, which is what gives the Zen-style look. It also carries the
// window controls, which we still need since GTK draws its own
// decorations on Wayland.
const header = adw.HeaderBar.new();
header.setShowTitle(0);
header.as(gtk.Widget).addCssClass("flat");
const new_tab_button = gtk.Button.newFromIconName("tab-new-symbolic");
new_tab_button.as(gtk.Widget).setTooltipText("New tab (Ctrl+Shift+T)");
_ = gtk.Button.signals.clicked.connect(
new_tab_button,
*Window,
&onNewTabClicked,
self,
.{},
);
header.packEnd(new_tab_button.as(gtk.Widget));
sidebar.append(header.as(gtk.Widget));
self.list.setSelectionMode(.single);
self.list.as(gtk.Widget).addCssClass("navigation-sidebar");
self.list.as(gtk.Widget).addCssClass("vtabs-list");
_ = gtk.ListBox.signals.row_selected.connect(
self.list,
*Window,
&onRowSelected,
self,
.{},
);
const scroller = gtk.ScrolledWindow.new();
scroller.setPolicy(.never, .automatic);
scroller.as(gtk.Widget).setVexpand(1);
scroller.setChild(self.list.as(gtk.Widget));
sidebar.append(scroller.as(gtk.Widget));
// ---- content -------------------------------------------------------
self.stack.as(gtk.Widget).setHexpand(1);
self.stack.as(gtk.Widget).setVexpand(1);
self.stack.as(gtk.Widget).addCssClass("vtabs-content");
const content = gtk.Box.new(.horizontal, 0);
content.append(sidebar.as(gtk.Widget));
content.append(self.stack.as(gtk.Widget));
window.setContent(content.as(gtk.Widget));
// Window-level shortcuts run in the capture phase so they are handled
// before the focused terminal turns the key into a VT sequence.
const shortcuts = gtk.EventControllerKey.new();
shortcuts.as(gtk.EventController).setPropagationPhase(.capture);
_ = gtk.EventControllerKey.signals.key_pressed.connect(
shortcuts,
*Window,
&onShortcut,
self,
.{},
);
window.as(gtk.Widget).addController(shortcuts.as(gtk.EventController));
// Free our own state once GTK is done with the window. Doing this on
// `destroy` rather than `close-request` means no further events can
// arrive for widgets whose user data we're about to free.
_ = gtk.Widget.signals.destroy.connect(
window,
*Window,
&onDestroy,
self,
.{},
);
try self.newTab();
return self;
}
pub fn present(self: *Window) void {
self.window.as(gtk.Window).present();
// Focus has to be grabbed after the window is presented. Calling
// grabFocus during construction silently does nothing because the
// widget is not yet realized, which would send the first keystroke to
// the sidebar instead of the terminal.
if (self.activeTab()) |tab| tab.term.grabFocus();
}
/// Open a new tab and switch to it.
pub fn newTab(self: *Window) !void {
const tab = try self.alloc.create(Tab);
errdefer self.alloc.destroy(tab);
const term = try Terminal.create(self.alloc, .{
.on_title = &onTabTitle,
.on_exit = &onTabExit,
.ctx = tab,
});
errdefer term.destroy();
const id = self.next_id;
self.next_id += 1;
tab.* = .{
.window = self,
.term = term,
.row = gtk.ListBoxRow.new(),
.label = gtk.Label.new("shell"),
.name = undefined,
.name_len = 0,
};
const printed = std.fmt.bufPrintZ(&tab.name, "tab{d}", .{id}) catch unreachable;
tab.name_len = printed.len;
// ---- sidebar row ---------------------------------------------------
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));
tab.label.setXalign(0);
tab.label.setEllipsize(.end);
tab.label.as(gtk.Widget).setHexpand(1);
row_box.append(tab.label.as(gtk.Widget));
const close = gtk.Button.newFromIconName("window-close-symbolic");
close.as(gtk.Widget).addCssClass("flat");
close.as(gtk.Widget).addCssClass("vtabs-close");
_ = gtk.Button.signals.clicked.connect(close, *Tab, &onCloseClicked, tab, .{});
row_box.append(close.as(gtk.Widget));
tab.row.setChild(row_box.as(gtk.Widget));
self.list.append(tab.row.as(gtk.Widget));
_ = self.stack.addNamed(term.widget(), tab.pageName());
try self.tabs.append(self.alloc, tab);
self.select(tab);
}
/// Make `tab` the visible one.
fn select(self: *Window, tab: *Tab) void {
self.updating = true;
defer self.updating = false;
self.stack.setVisibleChildName(tab.pageName());
self.list.selectRow(tab.row);
tab.term.grabFocus();
}
fn indexOf(self: *Window, tab: *Tab) ?usize {
for (self.tabs.items, 0..) |t, i| if (t == tab) return i;
return null;
}
/// Close a tab, and the window along with it if it was the last one.
fn closeTab(self: *Window, tab: *Tab) void {
if (self.closing) return;
const index = self.indexOf(tab) orelse return;
self.stack.remove(tab.term.widget());
self.list.remove(tab.row.as(gtk.Widget));
_ = self.tabs.orderedRemove(index);
tab.term.destroy();
self.alloc.destroy(tab);
if (self.tabs.items.len == 0) {
// Teardown of our own state happens in onDestroy.
self.window.as(gtk.Window).close();
return;
}
// Prefer the tab that took the closed one's place, else the new last.
const next = @min(index, self.tabs.items.len - 1);
self.select(self.tabs.items[next]);
}
// -------------------------------------------------------------------------
// Signal handlers
fn onNewTabClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
self.newTab() catch |err| {
std.log.err("failed to open tab: {s}", .{@errorName(err)});
};
}
fn onCloseClicked(_: *gtk.Button, tab: *Tab) callconv(.c) void {
tab.window.closeTab(tab);
}
fn onRowSelected(_: *gtk.ListBox, row: ?*gtk.ListBoxRow, self: *Window) callconv(.c) void {
if (self.updating) return;
const selected = row orelse return;
for (self.tabs.items) |tab| {
if (tab.row == selected) {
self.select(tab);
return;
}
}
}
fn onTabTitle(ctx: ?*anyopaque, title: []const u8) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
// GTK needs a NUL-terminated string, and titles from the terminal are
// arbitrary length, so clamp to something a sidebar row can show.
var buf: [128]u8 = undefined;
const n = @min(title.len, buf.len - 1);
@memcpy(buf[0..n], title[0..n]);
buf[n] = 0;
tab.label.setText(buf[0..n :0]);
tab.label.as(gtk.Widget).setTooltipText(buf[0..n :0]);
}
fn onTabExit(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.window.closeTab(tab);
}
/// GTK has finished with the window: release everything we allocated.
fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
if (self.closing) return;
self.closing = true;
// Each terminal owns a session, which owns a PTY and its child process.
// Dropping them here reaps the children rather than orphaning them.
for (self.tabs.items) |tab| {
tab.term.destroy();
self.alloc.destroy(tab);
}
self.tabs.deinit(self.alloc);
self.alloc.destroy(self);
}
// -------------------------------------------------------------------------
// Shortcuts
/// The tab whose terminal is currently visible.
fn activeTab(self: *Window) ?*Tab {
const name = self.stack.getVisibleChildName() orelse return null;
const span = std.mem.span(name);
for (self.tabs.items) |tab| {
if (std.mem.eql(u8, tab.pageName(), span)) return tab;
}
return null;
}
fn selectIndex(self: *Window, index: usize) void {
if (index >= self.tabs.items.len) return;
self.select(self.tabs.items[index]);
}
/// Move the selection by `delta`, wrapping around the ends.
fn cycle(self: *Window, delta: isize) void {
if (self.tabs.items.len == 0) return;
const current = self.indexOf(self.activeTab() orelse return) orelse return;
const len: isize = @intCast(self.tabs.items.len);
const next = @mod(@as(isize, @intCast(current)) + delta + len, len);
self.selectIndex(@intCast(next));
}
fn onShortcut(
_: *gtk.EventControllerKey,
keyval: c_uint,
_: c_uint,
state: gdk.ModifierType,
self: *Window,
) callconv(.c) c_int {
const ctrl = state.control_mask;
const shift = state.shift_mask;
const alt = state.alt_mask;
if (ctrl and shift) {
switch (keyval) {
gdk.KEY_T, gdk.KEY_t => {
self.newTab() catch |err| {
std.log.err("failed to open tab: {s}", .{@errorName(err)});
};
return 1;
},
gdk.KEY_W, gdk.KEY_w => {
if (self.activeTab()) |tab| self.closeTab(tab);
return 1;
},
gdk.KEY_V, gdk.KEY_v => {
self.paste();
return 1;
},
else => {},
}
}
// Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals.
if (ctrl and !shift) {
switch (keyval) {
gdk.KEY_Page_Up => {
self.cycle(-1);
return 1;
},
gdk.KEY_Page_Down => {
self.cycle(1);
return 1;
},
else => {},
}
}
// Alt+1..9 jumps straight to a tab; Alt+9 is "last tab" by convention.
if (alt and !ctrl) {
if (keyval >= gdk.KEY_1 and keyval <= gdk.KEY_9) {
const n = keyval - gdk.KEY_1;
if (n == 8) {
self.selectIndex(self.tabs.items.len -| 1);
} else {
self.selectIndex(@intCast(n));
}
return 1;
}
}
return 0;
}
// -------------------------------------------------------------------------
// Paste
//
// GTK4's clipboard API is asynchronous, so the read completes on a later
// main loop turn. We resolve the destination tab at completion time rather
// than capturing it, so closing a tab mid-paste can't leave a dangling
// pointer.
fn paste(self: *Window) void {
const clipboard = self.window.as(gtk.Widget).getClipboard();
clipboard.readTextAsync(null, &onPasteReady, self);
}
fn onPasteReady(
source: ?*gobject.Object,
result: *gio.AsyncResult,
data: ?*anyopaque,
) callconv(.c) void {
const self: *Window = @ptrCast(@alignCast(data.?));
const clipboard: *gdk.Clipboard = @ptrCast(@alignCast(source.?));
var err: ?*glib.Error = null;
const text = clipboard.readTextFinish(result, &err) orelse {
if (err) |e| {
std.log.warn("paste failed: {s}", .{e.f_message orelse "unknown"});
e.free();
}
return;
};
defer glib.free(text);
const tab = self.activeTab() orelse return;
const session = tab.term.session;
// Coerce to a plain slice: encodePaste dispatches on the exact type.
const span: []const u8 = std.mem.span(text);
// Refuse pastes containing control characters that would execute on
// arrival (a newline in unbracketed mode runs the command immediately).
const opts: vt.input.PasteOptions = .fromTerminal(&session.term);
if (!vt.input.isSafePaste(span)) {
std.log.warn("refusing unsafe paste", .{});
return;
}
const parts = vt.input.encodePaste(span, opts) catch |e| {
std.log.warn("paste encode failed: {s}", .{@errorName(e)});
return;
};
for (parts) |part| session.write(part);
}