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:
commit
738cead680
16 files changed
+2359
No files matched your search
+205
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user