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
+205
View File
@@ -0,0 +1,205 @@
//! A pseudoterminal pair with a child process (the shell) running on the
//! slave side. libghostty-vt deliberately has no opinion about process
//! management, so this is ours to own.
//!
//! The libc calls are declared directly rather than going through std.posix.
//! PTY setup is inherently POSIX-specific, and std.posix has been churning,
//! so explicit externs keep this module readable and stable.
const std = @import("std");
const Pty = @This();
// Linux ioctl request numbers. This app is Linux-only (GTK4/Wayland), so
// inlining these is simpler than chasing them through std across versions.
const TIOCSCTTY = 0x540E;
const TIOCSWINSZ = 0x5414;
const fd_t = std.c.fd_t;
const pid_t = std.c.pid_t;
/// The libc surface this module needs, kept in its own namespace so the
/// names don't collide with our own methods.
const c = struct {
extern "c" fn posix_openpt(flags: c_int) c_int;
extern "c" fn grantpt(fd: c_int) c_int;
extern "c" fn unlockpt(fd: c_int) c_int;
extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int;
extern "c" fn setsid() pid_t;
extern "c" fn fork() pid_t;
extern "c" fn close(fd: fd_t) c_int;
extern "c" fn dup2(old: fd_t, new: fd_t) c_int;
extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int;
extern "c" fn ioctl(fd: fd_t, request: c_ulong, ...) c_int;
extern "c" fn write(fd: fd_t, buf: [*]const u8, count: usize) isize;
extern "c" fn read(fd: fd_t, buf: [*]u8, count: usize) isize;
extern "c" fn execvpe(
file: [*:0]const u8,
argv: [*:null]const ?[*:0]const u8,
envp: [*:null]const ?[*:0]const u8,
) c_int;
extern "c" fn _exit(code: c_int) noreturn;
extern "c" fn kill(pid: pid_t, sig: c_int) c_int;
extern "c" fn waitpid(pid: pid_t, status: ?*c_int, options: c_int) pid_t;
};
const O_RDWR = 0x0002;
const O_NOCTTY = 0x0100;
const SIGHUP = 1;
pub const Winsize = extern struct {
ws_row: u16,
ws_col: u16,
ws_xpixel: u16 = 0,
ws_ypixel: u16 = 0,
};
/// The master side of the pair. Read terminal output from it, write user
/// input to it.
master: fd_t,
/// PID of the child process on the slave side.
pid: pid_t,
pub const Error = error{
OpenPtFailed,
GrantPtFailed,
UnlockPtFailed,
PtsNameFailed,
ForkFailed,
};
/// Open a PTY pair and fork `argv` onto the slave side. The child gets its
/// own session with the slave as controlling terminal, so job control and
/// signals (Ctrl-C, SIGWINCH) work the way a shell expects.
pub fn create(
alloc: std.mem.Allocator,
argv: []const [:0]const u8,
size: Winsize,
) !Pty {
const master = c.posix_openpt(O_RDWR | O_NOCTTY);
if (master < 0) return Error.OpenPtFailed;
errdefer _ = c.close(master);
if (c.grantpt(master) != 0) return Error.GrantPtFailed;
if (c.unlockpt(master) != 0) return Error.UnlockPtFailed;
var name_buf: [128]u8 = undefined;
if (c.ptsname_r(master, &name_buf, name_buf.len) != 0) return Error.PtsNameFailed;
const slave_path = std.mem.sliceTo(&name_buf, 0);
_ = c.ioctl(master, TIOCSWINSZ, &size);
// Everything the child needs must be allocated before the fork. After
// forking, only async-signal-safe work is legal in the child.
const argv_z = try alloc.allocSentinel(?[*:0]const u8, argv.len, null);
defer alloc.free(argv_z);
for (argv, 0..) |arg, i| argv_z[i] = arg.ptr;
const envp_z = try buildEnv(alloc);
defer freeEnv(alloc, envp_z);
const slave_path_z = try alloc.dupeZ(u8, slave_path);
defer alloc.free(slave_path_z);
const pid = c.fork();
if (pid < 0) return Error.ForkFailed;
if (pid == 0) {
childExec(master, slave_path_z, argv_z, envp_z);
// childExec only returns on failure, and a forked child has no
// sensible way to report that back to us.
c._exit(127);
}
return .{ .master = master, .pid = pid };
}
/// The child half of the fork. Only returns if something failed.
fn childExec(
master: fd_t,
slave_path: [:0]const u8,
argv: [:null]const ?[*:0]const u8,
envp: [:null]const ?[*:0]const u8,
) void {
_ = c.close(master);
// A new session detaches us from the parent's controlling terminal so
// that we can claim the slave as our own below.
if (c.setsid() < 0) return;
const slave = c.open(slave_path.ptr, O_RDWR);
if (slave < 0) return;
// Claim the slave as this session's controlling terminal. Without this
// the shell has no way to deliver SIGINT to foreground jobs.
if (c.ioctl(slave, TIOCSCTTY, @as(c_int, 0)) < 0) return;
if (c.dup2(slave, 0) < 0) return;
if (c.dup2(slave, 1) < 0) return;
if (c.dup2(slave, 2) < 0) return;
if (slave > 2) _ = c.close(slave);
_ = c.execvpe(argv[0].?, argv.ptr, envp.ptr);
}
/// Copy the current environment, forcing the variables that describe what
/// kind of terminal we are. We advertise xterm-256color rather than
/// ghostty's own terminfo because we don't install a terminfo entry.
fn buildEnv(alloc: std.mem.Allocator) ![:null]?[*:0]const u8 {
var list: std.ArrayListUnmanaged([*:0]const u8) = .empty;
defer list.deinit(alloc);
errdefer for (list.items) |item| alloc.free(std.mem.span(item));
var i: usize = 0;
while (std.c.environ[i]) |entry| : (i += 1) {
const span = std.mem.span(entry);
// Drop the variables we're about to define ourselves.
if (std.mem.startsWith(u8, span, "TERM=")) continue;
if (std.mem.startsWith(u8, span, "COLORTERM=")) continue;
try list.append(alloc, (try alloc.dupeZ(u8, span)).ptr);
}
try list.append(alloc, (try alloc.dupeZ(u8, "TERM=xterm-256color")).ptr);
try list.append(alloc, (try alloc.dupeZ(u8, "COLORTERM=truecolor")).ptr);
const result = try alloc.allocSentinel(?[*:0]const u8, list.items.len, null);
for (list.items, 0..) |item, idx| result[idx] = item;
return result;
}
fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void {
for (envp) |entry| if (entry) |e| alloc.free(std.mem.span(e));
alloc.free(envp);
}
/// Tell the child its window changed size. This both updates the kernel's
/// idea of the terminal size and delivers SIGWINCH to the foreground group.
pub fn setSize(self: Pty, size: Winsize) void {
_ = c.ioctl(self.master, TIOCSWINSZ, &size);
}
/// Returns the number of bytes read, or null if the PTY hung up.
pub fn read(self: Pty, buf: []u8) ?usize {
const n = c.read(self.master, buf.ptr, buf.len);
if (n <= 0) return null;
return @intCast(n);
}
pub fn writeAll(self: Pty, bytes: []const u8) void {
var off: usize = 0;
while (off < bytes.len) {
const n = c.write(self.master, bytes.ptr + off, bytes.len - off);
// Best-effort: if the child has exited the write fails with EPIPE
// and there is nothing useful to do about it here.
if (n <= 0) return;
off += @intCast(n);
}
}
pub fn deinit(self: *Pty) void {
_ = c.close(self.master);
// Closing the master sends SIGHUP to the child's session. Reap it so we
// don't leave a zombie behind.
_ = c.kill(self.pid, SIGHUP);
_ = c.waitpid(self.pid, null, 0);
self.* = undefined;
}
+203
View File
@@ -0,0 +1,203 @@
//! One terminal session: a libghostty-vt terminal fed by a PTY.
//!
//! The PTY is read on the GLib main loop via a unix fd watch rather than a
//! dedicated IO thread. That keeps VT state single-threaded, so the renderer
//! can read the screen directly with no locking. A real terminal would want
//! Ghostty's threaded IO, but for a proof of concept this is much simpler and
//! is plenty fast for interactive use.
const std = @import("std");
const glib = @import("glib");
const glibunix = @import("glibunix");
const vt = @import("ghostty-vt");
const Pty = @import("Pty.zig");
const Session = @This();
/// Read buffer size. Large enough that a `cat` of a big file doesn't spend
/// all its time bouncing through the main loop.
const read_buf_size = 64 * 1024;
alloc: std.mem.Allocator,
/// The terminal emulator state. Must be the first field referenced by
/// `fromTerminal` below, which recovers the Session from stream callbacks.
term: vt.Terminal,
/// Persistent parser state. Escape sequences can and do get split across
/// read boundaries, so this must outlive individual reads.
stream: vt.TerminalStream,
pty: Pty,
/// GLib source ID for the PTY read watch, so we can cancel it on teardown.
watch: c_uint = 0,
/// True once the child process has exited and the PTY hung up.
exited: bool = false,
/// Called after terminal state changes, so the owner can queue a redraw.
on_damage: *const fn (ctx: ?*anyopaque) void,
/// Called when the terminal title changes (OSC 0/2).
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
/// Called when the child process exits.
on_exit: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
pub const Callbacks = struct {
on_damage: *const fn (ctx: ?*anyopaque) void,
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
on_exit: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque,
};
pub fn create(
alloc: std.mem.Allocator,
cols: u16,
rows: u16,
cbs: Callbacks,
) !*Session {
const self = try alloc.create(Session);
errdefer alloc.destroy(self);
const tio: vt.TinyIo = .init;
self.* = .{
.alloc = alloc,
.term = try .init(tio.io(), alloc, .{
.cols = cols,
.rows = rows,
.max_scrollback_bytes = 8 * 1024 * 1024,
}),
.stream = undefined,
.pty = undefined,
.on_damage = cbs.on_damage,
.on_title = cbs.on_title,
.on_exit = cbs.on_exit,
.ctx = cbs.ctx,
};
errdefer self.term.deinit(alloc);
self.stream = self.term.vtStream();
errdefer self.stream.deinit();
// Wire up the side effects we care about. `readonly` handles terminal
// state but silently drops anything that needs to talk back to the
// child; we need the writes so that queries (cursor position, device
// attributes, in-band resize) get answers.
var effects = vt.TerminalStream.Handler.Effects.readonly;
effects.write_pty = &effectWritePty;
effects.title_changed = &effectTitleChanged;
self.stream.handler.effects = effects;
const shell = try defaultShell(alloc);
defer alloc.free(shell);
// A leading '-' in argv[0] tells the shell to start as a login shell.
const argv0 = try std.fmt.allocPrintSentinel(alloc, "-{s}", .{
std.fs.path.basename(shell),
}, 0);
defer alloc.free(argv0);
self.pty = try .create(alloc, &.{ shell, argv0 }, .{
.ws_row = rows,
.ws_col = cols,
});
errdefer self.pty.deinit();
self.watch = glibunix.fdAdd(
self.pty.master,
.{ .in = true, .hup = true, .err = true },
&onReadable,
self,
);
return self;
}
pub fn destroy(self: *Session) void {
if (self.watch != 0) _ = glib.Source.remove(self.watch);
self.pty.deinit();
self.stream.deinit();
self.term.deinit(self.alloc);
self.alloc.destroy(self);
}
/// Resolve the user's shell, falling back to something that always exists.
fn defaultShell(alloc: std.mem.Allocator) ![:0]const u8 {
if (std.c.getenv("SHELL")) |sh| {
const span = std.mem.span(sh);
if (span.len > 0) return alloc.dupeZ(u8, span);
}
return alloc.dupeZ(u8, "/bin/sh");
}
/// Main loop callback: the PTY has data (or hung up).
fn onReadable(
fd: c_int,
condition: glib.IOCondition,
data: ?*anyopaque,
) callconv(.c) c_int {
const self: *Session = @ptrCast(@alignCast(data.?));
_ = fd;
if (condition.in) {
var buf: [read_buf_size]u8 = undefined;
if (self.pty.read(&buf)) |n| {
self.stream.nextSlice(buf[0..n]);
self.on_damage(self.ctx);
return 1;
}
}
// Zero-length read, HUP or error all mean the child is gone.
self.exited = true;
self.watch = 0;
self.on_exit(self.ctx);
return 0;
}
/// Effect callback: the terminal wants to send bytes back to the child.
fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
const self = fromHandler(handler);
self.pty.writeAll(data);
}
/// Effect callback: OSC 0/2 changed the window title.
fn effectTitleChanged(handler: *vt.TerminalStream.Handler) void {
const self = fromHandler(handler);
self.on_title(self.ctx, self.term.title.items);
}
/// Recover the owning Session from a stream callback. The handler holds a
/// pointer to our embedded `term` field, so we can walk back from it.
fn fromHandler(handler: *vt.TerminalStream.Handler) *Session {
return @fieldParentPtr("term", handler.terminal);
}
/// Send user input to the child.
pub fn write(self: *Session, bytes: []const u8) void {
self.pty.writeAll(bytes);
}
/// Resize the terminal grid and tell the child about it.
pub fn resize(self: *Session, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !void {
if (cols == self.term.cols and rows == self.term.rows) return;
try self.stream.handler.resize(.{
.cols = cols,
.rows = rows,
.cell_size_px = .{ .width = cell_w, .height = cell_h },
});
self.pty.setSize(.{
.ws_row = rows,
.ws_col = cols,
.ws_xpixel = @intCast(cell_w * cols),
.ws_ypixel = @intCast(cell_h * rows),
});
}
+634
View File
@@ -0,0 +1,634 @@
//! The terminal widget: draws a libghostty-vt screen with Cairo/Pango and
//! feeds user input back to the session.
//!
//! Rendering is deliberately simple. Ghostty itself uses a GPU renderer with
//! a glyph atlas; here we walk the visible rows every frame and hand runs of
//! same-styled text to Pango. That is far slower in principle, but a terminal
//! grid is small and this keeps the proof of concept readable.
const std = @import("std");
const cairo = @import("cairo");
const gdk = @import("gdk");
const gtk = @import("gtk");
const pango = @import("pango");
const pangocairo = @import("pangocairo");
const vt = @import("ghostty-vt");
const keymap = @import("key.zig");
const theme = @import("theme.zig");
const Session = @import("Session.zig");
const Terminal = @This();
/// Pango measures in 1/1024ths of a device unit.
const pango_scale: f64 = @floatFromInt(pango.SCALE);
/// The font we render with. Any monospace family the system provides.
const font_spec = "monospace 11";
/// Padding between the grid and the widget edge, in pixels.
const pad: f64 = 8;
/// Corner rounding of the terminal pane.
const corner_radius: f64 = 10;
alloc: std.mem.Allocator,
session: *Session,
area: *gtk.DrawingArea,
font: *pango.FontDescription,
/// Cell geometry derived from the font metrics.
cell_w: f64 = 8,
cell_h: f64 = 16,
ascent: f64 = 12,
/// Scratch buffer for building the UTF-8 of a single text run.
run_buf: std.ArrayListUnmanaged(u8) = .empty,
/// Called when the session's title changes, so the owner can retitle the tab.
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
on_exit: *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,
ctx: ?*anyopaque,
},
) !*Terminal {
const self = try alloc.create(Terminal);
errdefer alloc.destroy(self);
const area = gtk.DrawingArea.new();
const font = pango.FontDescription.fromString(font_spec);
self.* = .{
.alloc = alloc,
.session = undefined,
.area = area,
.font = font,
.on_title = cbs.on_title,
.on_exit = cbs.on_exit,
.ctx = cbs.ctx,
};
self.measureFont();
// The grid size follows the widget size, but we need a starting point
// for the session before the widget has ever been allocated.
self.session = try .create(alloc, 80, 24, .{
.on_damage = &onDamage,
.on_title = &onSessionTitle,
.on_exit = &onSessionExit,
.ctx = self,
});
errdefer self.session.destroy();
const w = area.as(gtk.Widget);
w.setHexpand(1);
w.setVexpand(1);
// Without this the drawing area can never hold keyboard focus, and all
// key events would go to the sidebar instead.
w.setFocusable(1);
w.setCanFocus(1);
area.setDrawFunc(&drawFunc, self, null);
_ = gtk.DrawingArea.signals.resize.connect(area, *Terminal, &onResize, self, .{});
const keys = gtk.EventControllerKey.new();
_ = gtk.EventControllerKey.signals.key_pressed.connect(
keys,
*Terminal,
&onKeyPressed,
self,
.{},
);
w.addController(keys.as(gtk.EventController));
const scroll = gtk.EventControllerScroll.new(.{ .vertical = true });
_ = gtk.EventControllerScroll.signals.scroll.connect(
scroll,
*Terminal,
&onScroll,
self,
.{},
);
w.addController(scroll.as(gtk.EventController));
// Clicking the terminal should focus it.
const click = gtk.GestureClick.new();
_ = gtk.GestureClick.signals.pressed.connect(
click,
*Terminal,
&onClick,
self,
.{},
);
w.addController(click.as(gtk.EventController));
return self;
}
pub fn destroy(self: *Terminal) void {
self.session.destroy();
self.run_buf.deinit(self.alloc);
self.font.free();
self.alloc.destroy(self);
}
pub fn widget(self: *Terminal) *gtk.Widget {
return self.area.as(gtk.Widget);
}
pub fn grabFocus(self: *Terminal) void {
_ = self.widget().grabFocus();
}
/// Derive cell geometry from the font. A monospace font's "approximate
/// character width" is its advance, which is exactly our cell width.
fn measureFont(self: *Terminal) void {
const ctx = self.area.as(gtk.Widget).createPangoContext();
defer ctx.unref();
const metrics = ctx.getMetrics(self.font, null);
defer metrics.unref();
const ascent = @as(f64, @floatFromInt(metrics.getAscent())) / pango_scale;
const descent = @as(f64, @floatFromInt(metrics.getDescent())) / pango_scale;
const char_w = @as(f64, @floatFromInt(metrics.getApproximateCharWidth())) / pango_scale;
self.cell_w = @max(1, @ceil(char_w));
self.cell_h = @max(1, @ceil(ascent + descent));
self.ascent = ascent;
}
fn onDamage(ctx: ?*anyopaque) void {
const self: *Terminal = @ptrCast(@alignCast(ctx.?));
self.area.as(gtk.Widget).queueDraw();
}
fn onSessionTitle(ctx: ?*anyopaque, title: []const u8) void {
const self: *Terminal = @ptrCast(@alignCast(ctx.?));
self.on_title(self.ctx, title);
}
fn onSessionExit(ctx: ?*anyopaque) void {
const self: *Terminal = @ptrCast(@alignCast(ctx.?));
self.on_exit(self.ctx);
}
/// The widget was resized: recompute the grid and tell the child process.
fn onResize(_: *gtk.DrawingArea, width: c_int, height: c_int, self: *Terminal) callconv(.c) void {
const usable_w = @max(0.0, @as(f64, @floatFromInt(width)) - pad * 2);
const usable_h = @max(0.0, @as(f64, @floatFromInt(height)) - pad * 2);
const cols: u16 = @intFromFloat(@max(1, @floor(usable_w / self.cell_w)));
const rows: u16 = @intFromFloat(@max(1, @floor(usable_h / self.cell_h)));
self.session.resize(
cols,
rows,
@intFromFloat(self.cell_w),
@intFromFloat(self.cell_h),
) catch |err| {
std.log.warn("resize failed: {s}", .{@errorName(err)});
};
}
// -------------------------------------------------------------------------
// Input
fn onClick(
_: *gtk.GestureClick,
_: c_int,
_: f64,
_: f64,
self: *Terminal,
) callconv(.c) void {
self.grabFocus();
}
fn onScroll(
_: *gtk.EventControllerScroll,
_: f64,
dy: f64,
self: *Terminal,
) callconv(.c) c_int {
// Three rows per notch matches the conventional feel.
const delta: isize = @intFromFloat(dy * 3);
if (delta == 0) return 0;
self.session.term.screens.active.pages.scroll(.{ .delta_row = delta });
self.area.as(gtk.Widget).queueDraw();
return 1;
}
fn onKeyPressed(
_: *gtk.EventControllerKey,
keyval: c_uint,
_: c_uint,
state: gdk.ModifierType,
self: *Terminal,
) callconv(.c) c_int {
const mods = keymap.translateMods(state);
var event: vt.input.KeyEvent = .{
.action = .press,
.key = keymap.keyFromKeyval(keyval) orelse .unidentified,
.mods = mods,
};
// GDK has already applied the keyboard layout and shift level, so the
// unicode value of the keyval is the text this key produces.
var utf8_buf: [8]u8 = undefined;
const codepoint = gdk.keyvalToUnicode(keyval);
if (codepoint > 0) {
if (std.unicode.utf8Encode(@intCast(codepoint), &utf8_buf)) |n| {
event.utf8 = utf8_buf[0..n];
// Shift is consumed producing the shifted character; ctrl/alt
// are not, and the encoder needs to know that to build e.g.
// ctrl sequences correctly.
event.consumed_mods = .{ .shift = mods.shift };
} else |_| {}
const lower = gdk.keyvalToLower(keyval);
const unshifted = gdk.keyvalToUnicode(lower);
if (unshifted > 0) event.unshifted_codepoint = @intCast(unshifted);
}
var out: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&out);
const opts: vt.input.KeyEncodeOptions = .fromTerminal(&self.session.term);
vt.input.encodeKey(&writer, event, opts) catch |err| {
std.log.warn("key encode failed: {s}", .{@errorName(err)});
return 0;
};
const encoded = writer.buffered();
// Keys with no terminal representation (bare modifiers, unmapped keys)
// encode to nothing. Let GTK keep processing them.
if (encoded.len == 0) return 0;
// Typing should always snap the view back to the prompt.
self.session.term.screens.active.pages.scroll(.active);
self.session.write(encoded);
self.area.as(gtk.Widget).queueDraw();
return 1;
}
// -------------------------------------------------------------------------
// Rendering
/// A cell's appearance after resolving palette indices and SGR attributes.
const Appearance = struct {
fg: theme.Rgb,
bg: ?theme.Rgb,
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
fn sameRun(a: Appearance, b: Appearance) bool {
return std.meta.eql(a.fg, b.fg) and
a.bold == b.bold and
a.italic == b.italic and
a.underline == b.underline and
a.strikethrough == b.strikethrough;
}
};
fn drawFunc(
_: *gtk.DrawingArea,
cr: *cairo.Context,
width: c_int,
height: c_int,
data: ?*anyopaque,
) callconv(.c) void {
const self: *Terminal = @ptrCast(@alignCast(data.?));
self.render(cr, width, height) catch |err| {
std.log.warn("render failed: {s}", .{@errorName(err)});
};
}
fn render(self: *Terminal, cr: *cairo.Context, width: c_int, height: c_int) !void {
const term = &self.session.term;
const screen = term.screens.active;
// Background. Clipped to a rounded rectangle so the terminal reads as an
// inset pane next to the sidebar, the way Zen insets web content.
const default_bg: theme.Rgb = if (term.colors.background.get()) |c|
.from(c)
else
theme.bg;
{
roundedRect(
cr,
0,
0,
@floatFromInt(width),
@floatFromInt(height),
corner_radius,
);
cr.clip();
const r, const g, const b = default_bg.cairoRgb();
cr.setSourceRgb(r, g, b);
cr.paint();
}
const default_fg: theme.Rgb = if (term.colors.foreground.get()) |c|
.from(c)
else
theme.fg;
const layout = pangocairo.createLayout(cr);
defer layout.unref();
layout.setFontDescription(self.font);
var y: u16 = 0;
while (y < term.rows) : (y += 1) {
const pin = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = y } }) orelse continue;
const cells = pin.cells(.all);
const row_top = pad + @as(f64, @floatFromInt(y)) * self.cell_h;
// Pass 1: backgrounds. Drawn as one rect per run so that a wide
// block of color doesn't turn into hundreds of tiny fills.
var x: usize = 0;
while (x < cells.len) {
const start_bg = appearance(pin, &cells[x], term, default_fg, default_bg).bg;
var end = x + 1;
while (end < cells.len) : (end += 1) {
const next = appearance(pin, &cells[end], term, default_fg, default_bg).bg;
if (!std.meta.eql(start_bg, next)) break;
}
if (start_bg) |color| {
const r, const g, const b = color.cairoRgb();
cr.setSourceRgb(r, g, b);
cr.rectangle(
pad + @as(f64, @floatFromInt(x)) * self.cell_w,
row_top,
@as(f64, @floatFromInt(end - x)) * self.cell_w,
self.cell_h,
);
cr.fill();
}
x = end;
}
// Pass 2: text runs.
x = 0;
while (x < cells.len) {
if (cells[x].wide == .spacer_tail) {
x += 1;
continue;
}
const look = appearance(pin, &cells[x], term, default_fg, default_bg);
self.run_buf.clearRetainingCapacity();
const run_start = x;
while (x < cells.len) : (x += 1) {
const cell = &cells[x];
if (cell.wide == .spacer_tail) continue;
const cell_look = appearance(pin, cell, term, default_fg, default_bg);
if (x != run_start and !look.sameRun(cell_look)) break;
try self.appendCell(pin, cell);
}
if (self.run_buf.items.len > 0) {
try self.drawRun(
cr,
layout,
look,
pad + @as(f64, @floatFromInt(run_start)) * self.cell_w,
row_top,
);
}
}
}
self.drawCursor(cr, layout, term, default_bg);
}
/// Append a cell's text to the current run.
fn appendCell(self: *Terminal, pin: vt.Pin, cell: *const vt.Cell) !void {
switch (cell.content_tag) {
.codepoint, .codepoint_grapheme => {
const cp = cell.content.codepoint.data;
// An empty cell still occupies a column, so emit a space to
// keep the run's characters aligned to the grid.
try self.appendCodepoint(if (cp == 0) ' ' else cp);
if (cell.content_tag == .codepoint_grapheme) {
if (pin.grapheme(cell)) |extra| {
for (extra) |cp2| try self.appendCodepoint(cp2);
}
}
},
// Background-only cells have no text.
.bg_color_palette, .bg_color_rgb => try self.appendCodepoint(' '),
}
}
fn appendCodepoint(self: *Terminal, cp: u21) !void {
var buf: [4]u8 = undefined;
const n = std.unicode.utf8Encode(cp, &buf) catch return;
try self.run_buf.appendSlice(self.alloc, buf[0..n]);
}
fn drawRun(
self: *Terminal,
cr: *cairo.Context,
layout: *pango.Layout,
look: Appearance,
x: f64,
y: f64,
) !void {
self.font.setWeight(if (look.bold) .bold else .normal);
self.font.setStyle(if (look.italic) .italic else .normal);
layout.setFontDescription(self.font);
// Pango wants a NUL-terminated pointer even though we pass the length.
try self.run_buf.append(self.alloc, 0);
const text = self.run_buf.items[0 .. self.run_buf.items.len - 1 :0];
layout.setText(text.ptr, @intCast(text.len));
const r, const g, const b = look.fg.cairoRgb();
cr.setSourceRgb(r, g, b);
cr.moveTo(x, y);
pangocairo.showLayout(cr, layout);
// Pango has no notion of our grid, so decorations are drawn by hand
// across the exact width of the run.
const run_w = runWidth(layout);
if (look.underline) {
cr.rectangle(x, y + self.ascent + 2, run_w, 1);
cr.fill();
}
if (look.strikethrough) {
cr.rectangle(x, y + self.ascent * 0.6, run_w, 1);
cr.fill();
}
}
fn runWidth(layout: *pango.Layout) f64 {
var w: c_int = 0;
var h: c_int = 0;
layout.getPixelSize(&w, &h);
return @floatFromInt(w);
}
fn drawCursor(
self: *Terminal,
cr: *cairo.Context,
layout: *pango.Layout,
term: *vt.Terminal,
default_bg: theme.Rgb,
) void {
// Only show the cursor when we're looking at the live screen; while
// scrolled back into history there is nothing meaningful to point at.
if (term.screens.active.pages.viewport != .active) return;
if (!term.modes.get(.cursor_visible)) return;
const cursor = term.screens.active.cursor;
if (cursor.x >= term.cols or cursor.y >= term.rows) return;
const x = pad + @as(f64, @floatFromInt(cursor.x)) * self.cell_w;
const y = pad + @as(f64, @floatFromInt(cursor.y)) * self.cell_h;
const color: theme.Rgb = if (term.colors.cursor.get()) |c| .from(c) else theme.cursor;
const r, const g, const b = color.cairoRgb();
cr.setSourceRgb(r, g, b);
switch (cursor.cursor_style) {
.block => {
cr.rectangle(x, y, self.cell_w, self.cell_h);
cr.fill();
// Redraw the covered character in the background color so it
// stays legible through the block.
const pin = term.screens.active.pages.pin(.{
.viewport = .{ .x = cursor.x, .y = cursor.y },
}) orelse return;
const cell = pin.rowAndCell().cell;
if (cell.content_tag != .codepoint and cell.content_tag != .codepoint_grapheme) return;
const cp = cell.content.codepoint.data;
if (cp == 0 or cp == ' ') return;
var buf: [5]u8 = @splat(0);
const n = std.unicode.utf8Encode(cp, buf[0..4]) catch return;
layout.setText(buf[0..n :0].ptr, @intCast(n));
const tr, const tg, const tb = default_bg.cairoRgb();
cr.setSourceRgb(tr, tg, tb);
cr.moveTo(x, y);
pangocairo.showLayout(cr, layout);
},
.bar => {
cr.rectangle(x, y, 2, self.cell_h);
cr.fill();
},
.underline => {
cr.rectangle(x, y + self.cell_h - 2, self.cell_w, 2);
cr.fill();
},
.block_hollow => {
cr.rectangle(x + 0.5, y + 0.5, self.cell_w - 1, self.cell_h - 1);
cr.setLineWidth(1);
cr.stroke();
},
}
}
/// Trace a rounded rectangle as the current path.
fn roundedRect(cr: *cairo.Context, x: f64, y: f64, w: f64, h: f64, r: f64) void {
const radius = @min(r, @min(w, h) / 2);
const pi = std.math.pi;
cr.newSubPath();
cr.arc(x + w - radius, y + radius, radius, -pi / 2.0, 0);
cr.arc(x + w - radius, y + h - radius, radius, 0, pi / 2.0);
cr.arc(x + radius, y + h - radius, radius, pi / 2.0, pi);
cr.arc(x + radius, y + radius, radius, pi, 3.0 * pi / 2.0);
cr.closePath();
}
/// Resolve a cell's style into concrete colors and attributes.
fn appearance(
pin: vt.Pin,
cell: *const vt.Cell,
term: *vt.Terminal,
default_fg: theme.Rgb,
default_bg: theme.Rgb,
) Appearance {
const palette = &term.colors.palette.current;
// Cells that carry only a background color have no style entry.
switch (cell.content_tag) {
.bg_color_palette => return .{
.fg = default_fg,
.bg = .from(palette[cell.content.color_palette.data]),
.bold = false,
.italic = false,
.underline = false,
.strikethrough = false,
},
.bg_color_rgb => {
const c = cell.content.color_rgb;
return .{
.fg = default_fg,
.bg = .{ .r = c.r, .g = c.g, .b = c.b },
.bold = false,
.italic = false,
.underline = false,
.strikethrough = false,
};
},
else => {},
}
const style = pin.style(cell);
var fg: theme.Rgb = switch (style.fg_color) {
.none => default_fg,
.palette => |i| brightIfBold(palette, i, style.flags.bold),
.rgb => |c| .from(c),
};
var bg: ?theme.Rgb = switch (style.bg_color) {
.none => null,
.palette => |i| .from(palette[i]),
.rgb => |c| .from(c),
};
if (style.flags.inverse) {
const new_fg = bg orelse default_bg;
const new_bg = fg;
fg = new_fg;
bg = new_bg;
}
if (style.flags.invisible) fg = bg orelse default_bg;
return .{
.fg = fg,
.bg = bg,
.bold = style.flags.bold,
.italic = style.flags.italic,
.underline = style.flags.underline != .none,
.strikethrough = style.flags.strikethrough,
};
}
/// Bold text using one of the low 8 palette colors conventionally renders
/// with the matching bright color.
fn brightIfBold(palette: *const [256]vt.color.RGB, index: u8, bold: bool) theme.Rgb {
const effective = if (bold and index < 8) index + 8 else index;
return .from(palette[effective]);
}
+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);
}
+177
View File
@@ -0,0 +1,177 @@
//! Translation from GDK key events to libghostty-vt key events.
//!
//! The keyval table is adapted from Ghostty's own GTK apprt
//! (src/apprt/gtk/key.zig), since it is the reference for how a GTK
//! application should map GDK keyvals onto libghostty's key enum.
const std = @import("std");
const gdk = @import("gdk");
const vt = @import("ghostty-vt");
const Key = vt.input.Key;
const Mods = vt.input.KeyMods;
/// Translate GDK modifier state into libghostty-vt modifiers.
pub fn translateMods(state: gdk.ModifierType) Mods {
return .{
.shift = state.shift_mask,
.ctrl = state.control_mask,
.alt = state.alt_mask,
.super = state.super_mask,
};
}
/// Returns the libghostty key for a GDK keyval, or null if unmapped.
pub fn keyFromKeyval(keyval: c_uint) ?Key {
for (keymap) |entry| {
if (entry[0] == keyval) return entry[1];
}
return null;
}
const RawEntry = struct { c_uint, Key };
const keymap: []const RawEntry = &.{
.{ gdk.KEY_a, .key_a },
.{ gdk.KEY_b, .key_b },
.{ gdk.KEY_c, .key_c },
.{ gdk.KEY_d, .key_d },
.{ gdk.KEY_e, .key_e },
.{ gdk.KEY_f, .key_f },
.{ gdk.KEY_g, .key_g },
.{ gdk.KEY_h, .key_h },
.{ gdk.KEY_i, .key_i },
.{ gdk.KEY_j, .key_j },
.{ gdk.KEY_k, .key_k },
.{ gdk.KEY_l, .key_l },
.{ gdk.KEY_m, .key_m },
.{ gdk.KEY_n, .key_n },
.{ gdk.KEY_o, .key_o },
.{ gdk.KEY_p, .key_p },
.{ gdk.KEY_q, .key_q },
.{ gdk.KEY_r, .key_r },
.{ gdk.KEY_s, .key_s },
.{ gdk.KEY_t, .key_t },
.{ gdk.KEY_u, .key_u },
.{ gdk.KEY_v, .key_v },
.{ gdk.KEY_w, .key_w },
.{ gdk.KEY_x, .key_x },
.{ gdk.KEY_y, .key_y },
.{ gdk.KEY_z, .key_z },
.{ gdk.KEY_0, .digit_0 },
.{ gdk.KEY_1, .digit_1 },
.{ gdk.KEY_2, .digit_2 },
.{ gdk.KEY_3, .digit_3 },
.{ gdk.KEY_4, .digit_4 },
.{ gdk.KEY_5, .digit_5 },
.{ gdk.KEY_6, .digit_6 },
.{ gdk.KEY_7, .digit_7 },
.{ gdk.KEY_8, .digit_8 },
.{ gdk.KEY_9, .digit_9 },
.{ gdk.KEY_semicolon, .semicolon },
.{ gdk.KEY_space, .space },
.{ gdk.KEY_apostrophe, .quote },
.{ gdk.KEY_comma, .comma },
.{ gdk.KEY_grave, .backquote },
.{ gdk.KEY_period, .period },
.{ gdk.KEY_slash, .slash },
.{ gdk.KEY_minus, .minus },
.{ gdk.KEY_equal, .equal },
.{ gdk.KEY_bracketleft, .bracket_left },
.{ gdk.KEY_bracketright, .bracket_right },
.{ gdk.KEY_backslash, .backslash },
.{ gdk.KEY_Up, .arrow_up },
.{ gdk.KEY_Down, .arrow_down },
.{ gdk.KEY_Right, .arrow_right },
.{ gdk.KEY_Left, .arrow_left },
.{ gdk.KEY_Home, .home },
.{ gdk.KEY_End, .end },
.{ gdk.KEY_Insert, .insert },
.{ gdk.KEY_Delete, .delete },
.{ gdk.KEY_Caps_Lock, .caps_lock },
.{ gdk.KEY_Scroll_Lock, .scroll_lock },
.{ gdk.KEY_Num_Lock, .num_lock },
.{ gdk.KEY_Page_Up, .page_up },
.{ gdk.KEY_Page_Down, .page_down },
.{ gdk.KEY_Escape, .escape },
.{ gdk.KEY_Return, .enter },
.{ gdk.KEY_Tab, .tab },
.{ gdk.KEY_BackSpace, .backspace },
.{ gdk.KEY_Print, .print_screen },
.{ gdk.KEY_Pause, .pause },
.{ gdk.KEY_F1, .f1 },
.{ gdk.KEY_F2, .f2 },
.{ gdk.KEY_F3, .f3 },
.{ gdk.KEY_F4, .f4 },
.{ gdk.KEY_F5, .f5 },
.{ gdk.KEY_F6, .f6 },
.{ gdk.KEY_F7, .f7 },
.{ gdk.KEY_F8, .f8 },
.{ gdk.KEY_F9, .f9 },
.{ gdk.KEY_F10, .f10 },
.{ gdk.KEY_F11, .f11 },
.{ gdk.KEY_F12, .f12 },
.{ gdk.KEY_F13, .f13 },
.{ gdk.KEY_F14, .f14 },
.{ gdk.KEY_F15, .f15 },
.{ gdk.KEY_F16, .f16 },
.{ gdk.KEY_F17, .f17 },
.{ gdk.KEY_F18, .f18 },
.{ gdk.KEY_F19, .f19 },
.{ gdk.KEY_F20, .f20 },
.{ gdk.KEY_F21, .f21 },
.{ gdk.KEY_F22, .f22 },
.{ gdk.KEY_F23, .f23 },
.{ gdk.KEY_F24, .f24 },
.{ gdk.KEY_F25, .f25 },
.{ gdk.KEY_KP_0, .numpad_0 },
.{ gdk.KEY_KP_1, .numpad_1 },
.{ gdk.KEY_KP_2, .numpad_2 },
.{ gdk.KEY_KP_3, .numpad_3 },
.{ gdk.KEY_KP_4, .numpad_4 },
.{ gdk.KEY_KP_5, .numpad_5 },
.{ gdk.KEY_KP_6, .numpad_6 },
.{ gdk.KEY_KP_7, .numpad_7 },
.{ gdk.KEY_KP_8, .numpad_8 },
.{ gdk.KEY_KP_9, .numpad_9 },
.{ gdk.KEY_KP_Decimal, .numpad_decimal },
.{ gdk.KEY_KP_Divide, .numpad_divide },
.{ gdk.KEY_KP_Multiply, .numpad_multiply },
.{ gdk.KEY_KP_Subtract, .numpad_subtract },
.{ gdk.KEY_KP_Add, .numpad_add },
.{ gdk.KEY_KP_Enter, .numpad_enter },
.{ gdk.KEY_KP_Equal, .numpad_equal },
.{ gdk.KEY_KP_Separator, .numpad_separator },
.{ gdk.KEY_KP_Left, .numpad_left },
.{ gdk.KEY_KP_Right, .numpad_right },
.{ gdk.KEY_KP_Up, .numpad_up },
.{ gdk.KEY_KP_Down, .numpad_down },
.{ gdk.KEY_KP_Page_Up, .numpad_page_up },
.{ gdk.KEY_KP_Page_Down, .numpad_page_down },
.{ gdk.KEY_KP_Home, .numpad_home },
.{ gdk.KEY_KP_End, .numpad_end },
.{ gdk.KEY_KP_Insert, .numpad_insert },
.{ gdk.KEY_KP_Delete, .numpad_delete },
.{ gdk.KEY_KP_Begin, .numpad_begin },
.{ gdk.KEY_Copy, .copy },
.{ gdk.KEY_Cut, .cut },
.{ gdk.KEY_Paste, .paste },
.{ gdk.KEY_Shift_L, .shift_left },
.{ gdk.KEY_Control_L, .control_left },
.{ gdk.KEY_Alt_L, .alt_left },
.{ gdk.KEY_Super_L, .meta_left },
.{ gdk.KEY_Shift_R, .shift_right },
.{ gdk.KEY_Control_R, .control_right },
.{ gdk.KEY_Alt_R, .alt_right },
.{ gdk.KEY_Super_R, .meta_right },
// TODO: media keys
};
+62
View File
@@ -0,0 +1,62 @@
//! vtabs: a terminal with vertical tabs, built on libghostty-vt.
//!
//! libghostty-vt supplies the terminal emulator core (escape sequence
//! parsing, screen and scrollback state, key/mouse encoding). Everything
//! else — process management, rendering, and the GTK4 UI — lives here.
const std = @import("std");
const adw = @import("adw");
const gdk = @import("gdk");
const gio = @import("gio");
const gtk = @import("gtk");
const Window = @import("Window.zig");
/// libghostty-vt logs unimplemented sequences at debug level, which is very
/// chatty against a real shell. Keep the app's own warnings and errors.
pub const std_options: std.Options = .{
.log_level = .info,
};
const css = @embedFile("style.css");
var gpa: std.heap.DebugAllocator(.{}) = .init;
pub fn main() u8 {
defer _ = gpa.deinit();
// Non-unique so every launch is its own process. The default GApplication
// behavior hands off to an already-running instance over D-Bus, which for
// a terminal means a second launch silently does nothing visible here and
// opens a window in whatever session owns the first one.
const app = adw.Application.new("dev.greyson.vtabs", .{ .non_unique = true });
defer app.unref();
_ = gio.Application.signals.activate.connect(app, ?*anyopaque, &onActivate, null, .{});
const status = gio.Application.run(app.as(gio.Application), 0, null);
return @intCast(status);
}
fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
loadCss();
const window = Window.create(gpa.allocator(), app) catch |err| {
std.log.err("failed to create window: {s}", .{@errorName(err)});
return;
};
window.present();
}
fn loadCss() void {
const display = gdk.Display.getDefault() orelse return;
const provider = gtk.CssProvider.new();
defer provider.unref();
provider.loadFromString(css);
gtk.StyleContext.addProviderForDisplay(
display,
provider.as(gtk.StyleProvider),
gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
+66
View File
@@ -0,0 +1,66 @@
/* Zen-style vertical tabs: a dark sidebar column with the terminal inset
to its right. */
.vtabs-window {
background-color: #0f0d14;
}
.vtabs-sidebar {
background-color: #1b1823;
border-right: 1px solid #2a2536;
}
.vtabs-sidebar headerbar {
background: none;
box-shadow: none;
min-height: 38px;
}
.vtabs-list {
background: none;
padding: 4px 6px;
}
.vtabs-list > row {
border-radius: 8px;
margin: 1px 0;
padding: 5px 8px;
color: #b6afc7;
transition: background-color 120ms ease;
}
.vtabs-list > row:hover {
background-color: #262133;
}
.vtabs-list > row:selected {
background-color: #342c4a;
color: #f0ecf8;
}
.vtabs-list > row:selected image {
color: #b29df5;
}
/* Keep the close button unobtrusive until the row is hovered or current. */
.vtabs-close {
opacity: 0;
min-width: 20px;
min-height: 20px;
padding: 0;
}
.vtabs-list > row:hover .vtabs-close,
.vtabs-list > row:selected .vtabs-close {
opacity: 0.65;
}
.vtabs-close:hover {
opacity: 1;
}
.vtabs-content {
background-color: #16141c;
border-radius: 10px;
margin: 6px 6px 6px 0;
}
+35
View File
@@ -0,0 +1,35 @@
//! Colors for the parts of the UI that libghostty-vt has no opinion about.
//!
//! The 256-color palette itself comes from the terminal's own color state
//! (`Terminal.colors.palette`), which libghostty-vt initializes to the
//! standard xterm palette and keeps updated as programs change it via OSC.
const vt = @import("ghostty-vt");
pub const Rgb = struct {
r: u8,
g: u8,
b: u8,
pub fn from(c: vt.color.RGB) Rgb {
return .{ .r = c.r, .g = c.g, .b = c.b };
}
/// Cairo takes color channels as 0..1 doubles.
pub fn cairoRgb(self: Rgb) struct { f64, f64, f64 } {
return .{
@as(f64, @floatFromInt(self.r)) / 255.0,
@as(f64, @floatFromInt(self.g)) / 255.0,
@as(f64, @floatFromInt(self.b)) / 255.0,
};
}
};
/// Terminal default background, used when the program hasn't set one.
pub const bg: Rgb = .{ .r = 0x16, .g = 0x14, .b = 0x1c };
/// Terminal default foreground.
pub const fg: Rgb = .{ .r = 0xe2, .g = 0xde, .b = 0xea };
/// Cursor block color.
pub const cursor: Rgb = .{ .r = 0xb2, .g = 0x9d, .b = 0xf5 };