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:
+454
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user