summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig90
-rw-r--r--main.zig532
-rw-r--r--smoke.zig136
-rw-r--r--tests.zig140
4 files changed, 779 insertions, 119 deletions
diff --git a/build.zig b/build.zig
index 9bc1d2b6..7d64f995 100644
--- a/build.zig
+++ b/build.zig
@@ -4,15 +4,6 @@ pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
const target = b.standardTargetOptions(.{});
- const exe = b.addExecutable(.{
- .name = "pardes",
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- .root_source_file = b.path("main.zig"),
- }),
- });
-
// ghostty-vt and vaxis both depend on uucode, but Zig 0.16 forbids the
// same source file from belonging to two modules, so they can't each have
// their own uucode instance. We build a single uucode module here (with our
@@ -42,30 +33,75 @@ pub fn build(b: *std.Build) void {
});
const vaxis_mod = vaxis_dep.module("vaxis");
- // You'll want to use a lazy dependency here so that ghostty is only
- // downloaded if you actually need it.
- if (b.lazyDependency("ghostty", .{
- // Setting simd to false will force a pure static build that
- // doesn't even require libc, but it has a significant performance
- // penalty. If your embedding app requires libc anyway, you should
- // always keep simd enabled.
+ // Accessing ghostty's modules runs its build(), which (because vaxis is a
+ // shared dependency, deduplicated to the same module) wires ghostty's own
+ // uucode into vaxis. We override both with ours below; order matters, so
+ // ghostty must be touched before the final vaxis uucode override.
+ const ghostty_vt: ?*std.Build.Module = if (b.lazyDependency("ghostty", .{
+ // Setting simd to false will force a pure static build that doesn't
+ // even require libc, but it has a significant performance penalty. If
+ // your embedding app requires libc anyway, you should always keep simd
+ // enabled. We need libc anyway (forkpty), so we keep it on.
// .simd = false,
- })) |dep| {
- // Accessing ghostty's modules runs its build(), which (because vaxis is
- // a shared dependency, deduplicated to the same module) wires ghostty's
- // own uucode into vaxis. We undo that below by overriding both imports
- // with ours — order matters, so this must happen after this point.
- const ghostty_vt = dep.module("ghostty-vt");
- ghostty_vt.addImport("uucode", uucode_mod);
- exe.root_module.addImport("ghostty-vt", ghostty_vt);
- }
+ })) |dep| blk: {
+ const mod = dep.module("ghostty-vt");
+ mod.addImport("uucode", uucode_mod);
+ break :blk mod;
+ } else null;
// Override uucode on vaxis last, so it wins over ghostty's build() above.
vaxis_mod.addImport("uucode", uucode_mod);
- exe.root_module.addImport("vaxis", vaxis_mod);
- const run_exe = b.addRunArtifact(exe);
+ // Wires the shared imports onto a root module. We link libc for `forkpty`
+ // (see pty.zig).
+ const wire = struct {
+ fn apply(
+ mod: *std.Build.Module,
+ vaxis_m: *std.Build.Module,
+ ghostty_m: ?*std.Build.Module,
+ ) void {
+ mod.link_libc = true;
+ mod.addImport("vaxis", vaxis_m);
+ if (ghostty_m) |g| mod.addImport("ghostty-vt", g);
+ }
+ }.apply;
+
+ // The app.
+ const exe_mod = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("main.zig"),
+ });
+ wire(exe_mod, vaxis_mod, ghostty_vt);
+ const exe = b.addExecutable(.{ .name = "pardes", .root_module = exe_mod });
+ b.installArtifact(exe);
+ const run_exe = b.addRunArtifact(exe);
const run = b.step("run", "descend to the pardes");
run.dependOn(&run_exe.step);
+
+ // Deterministic pipeline tests (pty -> ghostty-vt -> grid + input encode).
+ const test_mod = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tests.zig"),
+ });
+ wire(test_mod, vaxis_mod, ghostty_vt);
+ const tests = b.addTest(.{ .root_module = test_mod });
+ const run_tests = b.addRunArtifact(tests);
+ const test_step = b.step("test", "run the pipeline + smoke tests");
+ test_step.dependOn(&run_tests.step);
+
+ // End-to-end smoke: drives the real `pardes` binary under a pty. Only needs
+ // libc (forkpty); the pardes path is passed in as argv[1].
+ const smoke_mod = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("smoke.zig"),
+ });
+ smoke_mod.link_libc = true;
+ const smoke = b.addExecutable(.{ .name = "smoke", .root_module = smoke_mod });
+ const run_smoke = b.addRunArtifact(smoke);
+ run_smoke.addArtifactArg(exe); // builds pardes first, passes its path
+ test_step.dependOn(&run_smoke.step);
}
diff --git a/main.zig b/main.zig
index a55ad637..d43cb750 100644
--- a/main.zig
+++ b/main.zig
@@ -1,125 +1,473 @@
+//! Two shells, two ptys, side by side in one terminal. ghostty-vt parses each
+//! child's output into a grid, we paint both grids into bordered vaxis windows
+//! so it's obvious these are two ptys living *inside* one terminal, and key/mouse
+//! events go to the focused pane. 'o' switches focus, F12 quits.
+//!
+//! pty master ──read──▶ ghostty-vt Stream ──▶ RenderState ──▶ vaxis window
+//! ▲ │
+//! └──────── encodeKey / encodeMouse ◀── vaxis key/mouse events
+//!
+//! Prototype: flat globals, code written out twice on purpose (pane 0 / pane 1).
+//! ponytail: repetition over abstraction. Make it N panes the day there are N.
+
const std = @import("std");
const vaxis = @import("vaxis");
const ghostty_vt = @import("ghostty-vt");
+const posix = std.posix;
-// basic file navigation
-// basic shell io
-// basic layouting: for now following acme and ad
-// * /home/goblin/05-genizah/plan9port/src/cmd/acme
-// * /home/goblin/05-genizah/ad
-// * /usr/lib/zig
-// * ../03-plano
-// right click and enter on lines to open files at specified lines
-
-pub const Command = struct {
- prev: ?*Command = null,
- pub var value: union(enum) {
- close,
- ping,
- nop,
- quit,
- tick,
- key_press: vaxis.Key,
- winsize: vaxis.Winsize,
- mouse: vaxis.Mouse,
- } = .nop;
+const Event = union(enum) {
+ key_press: vaxis.Key,
+ winsize: vaxis.Winsize,
+ mouse: vaxis.Mouse,
+ pty_readable, // some child wrote output; repaint
+ child_exited, // a child died; quit
};
+const Loop = vaxis.Loop(Event);
+
+// libc bits std.posix doesn't wrap in Zig 0.16. ponytail: extern over a module.
+extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
+extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8;
+extern "c" fn write(fd: c_int, buf: [*]const u8, count: usize) isize;
+extern "c" fn close(fd: c_int) c_int;
+extern "c" fn waitpid(pid: c_int, status: ?*c_int, options: c_int) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+const GSLOT = 16; // per-cell utf8 storage: base codepoint + a few combining marks
+
+var ga: std.mem.Allocator = undefined;
+var gio: std.Io = undefined;
+var vx: vaxis.Vaxis = undefined;
+var tty: vaxis.Tty = undefined;
+var loop: Loop = undefined;
+
+// Per-pane state, indexed [0]/[1]. Flat parallel arrays, no struct.
+var term: [2]ghostty_vt.Terminal = undefined;
+var stream: [2]ghostty_vt.TerminalStream = undefined;
+var rs: [2]ghostty_vt.RenderState = undefined;
+var tmutex: [2]std.Io.Mutex = undefined; // each guards its term/stream
+var master: [2]posix.fd_t = undefined;
+var child: [2]posix.pid_t = undefined;
+var gbuf: [2][]u8 = undefined;
+var pcols: [2]u16 = .{ 0, 0 }; // size we last synced pty+model to
+var prows: [2]u16 = .{ 0, 0 };
+var focus: usize = 0;
+// Coalesce repaint wakeups so a burst of output can't flood the event queue.
+var pending: std.atomic.Value(bool) = .init(false);
+
+pub fn main(init: std.process.Init) !void {
+ ga = init.gpa;
+ gio = init.io;
-const Loop = vaxis.Loop(@TypeOf(Command.value));
-const MAX_FILES: usize = 0x200;
+ var tty_buf: [0x10000]u8 = undefined;
+ tty = try vaxis.Tty.init(gio, &tty_buf);
+ vx = try vaxis.init(gio, ga, init.environ_map, .{});
+ defer vx.deinit(ga, tty.writer());
+ try vx.enterAltScreen(tty.writer());
+ defer vx.exitAltScreen(tty.writer()) catch {};
+ try vx.setMouseMode(tty.writer(), true); // outer terminal -> us
+
+ loop = .init(gio, &tty, &vx);
+
+ _ = setenv("TERM", "xterm-256color", 1);
+ const sh = getenv("SHELL") orelse "/bin/bash";
+
+ // pane 0
+ term[0] = try ghostty_vt.Terminal.init(ga, .{ .cols = 80, .rows = 24 });
+ stream[0] = term[0].vtStream(); // captures &term[0], stable global
+ rs[0] = .empty;
+ tmutex[0] = .init;
+ gbuf[0] = try ga.alloc(u8, 80 * 24 * GSLOT);
+ {
+ const argv = [_:null]?[*:0]const u8{ sh, null };
+ const ws = posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master[0], null, null, &ws);
+ if (pid < 0) return error.Forkpty;
+ if (pid == 0) {
+ _ = execvp(argv[0].?, &argv);
+ _exit(127);
+ }
+ child[0] = pid;
+ }
+ // pane 1
+ term[1] = try ghostty_vt.Terminal.init(ga, .{ .cols = 80, .rows = 24 });
+ stream[1] = term[1].vtStream();
+ rs[1] = .empty;
+ tmutex[1] = .init;
+ gbuf[1] = try ga.alloc(u8, 80 * 24 * GSLOT);
+ {
+ const argv = [_:null]?[*:0]const u8{ sh, null };
+ const ws = posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master[1], null, null, &ws);
+ if (pid < 0) return error.Forkpty;
+ if (pid == 0) {
+ _ = execvp(argv[0].?, &argv);
+ _exit(127);
+ }
+ child[1] = pid;
+ }
+ defer {
+ stream[0].deinit();
+ term[0].deinit(ga);
+ rs[0].deinit(ga);
+ ga.free(gbuf[0]);
+ stream[1].deinit();
+ term[1].deinit(ga);
+ rs[1].deinit(ga);
+ ga.free(gbuf[1]);
+ }
+
+ try loop.start();
+ defer loop.stop();
+ var r0 = try gio.concurrent(ptyReader, .{@as(usize, 0)});
+ var r1 = try gio.concurrent(ptyReader, .{@as(usize, 1)});
+ defer {
+ r0.await(gio);
+ r1.await(gio);
+ }
+ // SIGHUP the children first so the readers' read() hit EOF and await returns.
+ defer {
+ posix.kill(child[0], posix.SIG.HUP) catch {};
+ _ = close(master[0]);
+ _ = waitpid(child[0], null, 0);
+ posix.kill(child[1], posix.SIG.HUP) catch {};
+ _ = close(master[1]);
+ _ = waitpid(child[1], null, 0);
+ }
-fn ticker(io: std.Io, loop: *Loop) void {
while (true) {
- io.sleep(.fromMilliseconds(2), .awake) catch return;
- _ = loop.tryPostEvent(.tick) catch return;
+ switch (try loop.nextEvent()) {
+ .child_exited => break,
+ .pty_readable => {
+ pending.store(false, .release);
+ try repaint();
+ },
+ .key_press => |k| {
+ if (k.codepoint == vaxis.Key.f12) break; // quit
+ // plain 'o' alternates focus; prototype, so it's never sent to a
+ // child (you can't type lowercase o into the shells, who cares).
+ if (k.codepoint == 'o' and !k.mods.ctrl and !k.mods.alt and !k.mods.super and !k.mods.shift) {
+ focus = 1 - focus;
+ try repaint();
+ } else sendKey(k);
+ },
+ .mouse => |m| sendMouse(m),
+ .winsize => |w| {
+ try vx.resize(ga, tty.writer(), w);
+ try repaint(); // repaint lays out + resizes both panes to fit
+ },
+ }
}
}
-pub fn main(init: std.process.Init) !void {
- std.debug.print("hello!\n", .{});
+/// Concurrent task: blocking-read pane[idx]'s child output, feed ghostty, wake
+/// the loop.
+fn ptyReader(idx: usize) void {
+ var buf: [4096]u8 = undefined;
+ while (true) {
+ const n = posix.read(master[idx], &buf) catch break;
+ if (n == 0) break; // EOF: child closed the pty
+ tmutex[idx].lockUncancelable(gio);
+ stream[idx].nextSlice(buf[0..n]);
+ tmutex[idx].unlock(gio);
+ if (pending.swap(true, .acq_rel) == false)
+ _ = loop.tryPostEvent(.pty_readable) catch {};
+ }
+ _ = loop.tryPostEvent(.child_exited) catch {};
+}
- const io = init.io;
- const gpa = init.gpa;
+/// Paint both panes side by side in bordered boxes, plus a status line so it's
+/// obvious this is a wrapper around two ptys. Written out twice on purpose.
+fn repaint() !void {
+ const root = vx.window();
+ root.clear();
+ if (root.width < 4 or root.height < 3) {
+ try vx.render(tty.writer());
+ return;
+ }
+ const lw = root.width / 2; // left width; right gets the remainder
+ const body_h = root.height - 1; // last row is the status line
- var t: ghostty_vt.Terminal = try .init(gpa, .{
- .cols = 6,
- .rows = 40,
- });
- defer t.deinit(gpa);
+ // ===== pane 0 (left) =====
+ {
+ const focused = focus == 0;
+ const st: vaxis.Style = .{ .fg = if (focused) .{ .index = 4 } else .default, .bold = focused, .dim = !focused };
+ const win = root.child(.{ .x_off = 0, .width = lw, .height = body_h, .border = .{ .where = .all, .style = st } });
+ _ = root.print(&.{.{ .text = " pty 0 ", .style = st }}, .{ .col_offset = 2 }); // title on top border
- try t.printString("hello there!\nhowdy!");
- const str = try t.plainString(gpa);
+ if (win.width != 0 and win.height != 0 and (win.width != pcols[0] or win.height != prows[0])) {
+ pcols[0] = win.width;
+ prows[0] = win.height;
+ const need = @as(usize, win.width) * win.height * GSLOT;
+ if (need > gbuf[0].len) gbuf[0] = ga.realloc(gbuf[0], need) catch gbuf[0];
+ tmutex[0].lockUncancelable(gio);
+ term[0].resize(ga, win.width, win.height) catch {};
+ tmutex[0].unlock(gio);
+ const wsz = posix.winsize{ .row = win.height, .col = win.width, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(master[0], posix.T.IOCSWINSZ, @intFromPtr(&wsz));
+ }
- var tty_buf: [0x10000]u8 = undefined;
- var lines: [0x1000][0x1000]u8 = undefined;
+ tmutex[0].lockUncancelable(gio);
+ rs[0].update(ga, &term[0]) catch {};
+ tmutex[0].unlock(gio);
- const dir = std.Io.Dir.cwd();
- const cwd = try std.Io.Dir.openDir(dir, io, ".", .{ .iterate = true });
- var walk = try cwd.walk(gpa);
- defer walk.deinit();
+ const cols: usize = rs[0].cols;
+ const cell_rows = rs[0].row_data.items(.cells);
+ var y: usize = 0;
+ while (y < rs[0].rows and y < win.height) : (y += 1) {
+ const row_cells = cell_rows[y];
+ var x: usize = 0;
+ while (x < cols and x < win.width) : (x += 1) {
+ const cell = row_cells.get(x);
+ if (cell.raw.wide == .spacer_tail) continue;
+ const slot = gbuf[0][(y * cols + x) * GSLOT ..][0..GSLOT];
+ const cp = cell.raw.codepoint();
+ var glen: usize = 1;
+ if (cp == 0) {
+ slot[0] = ' ';
+ } else {
+ glen = std.unicode.utf8Encode(cp, slot) catch e: {
+ slot[0] = ' ';
+ break :e 1;
+ };
+ if (cell.raw.hasGrapheme()) for (cell.grapheme) |g| {
+ if (glen + 4 > slot.len) break;
+ glen += std.unicode.utf8Encode(g, slot[glen..]) catch break;
+ };
+ }
+ const sty = if (cell.raw.hasStyling()) cell.style else ghostty_vt.Style{};
+ win.writeCell(@intCast(x), @intCast(y), .{
+ .char = .{ .grapheme = slot[0..glen], .width = if (cell.raw.wide == .wide) 2 else 1 },
+ .style = .{
+ .fg = vcol(sty.fg_color),
+ .bg = vcol(sty.bg_color),
+ .ul = vcol(sty.underline_color),
+ .ul_style = switch (sty.flags.underline) {
+ .none => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ .bold = sty.flags.bold,
+ .dim = sty.flags.faint,
+ .italic = sty.flags.italic,
+ .blink = sty.flags.blink,
+ .reverse = sty.flags.inverse,
+ .invisible = sty.flags.invisible,
+ .strikethrough = sty.flags.strikethrough,
+ },
+ });
+ }
+ }
+ if (focused) if (rs[0].cursor.visible) if (rs[0].cursor.viewport) |vp| {
+ if (vp.x < win.width and vp.y < win.height) win.showCursor(vp.x, vp.y);
+ };
+ }
+ // ===== pane 1 (right) =====
{
- var i: usize = 0;
+ const focused = focus == 1;
+ const st: vaxis.Style = .{ .fg = if (focused) .{ .index = 4 } else .default, .bold = focused, .dim = !focused };
+ const win = root.child(.{ .x_off = lw, .width = root.width - lw, .height = body_h, .border = .{ .where = .all, .style = st } });
+ _ = root.print(&.{.{ .text = " pty 1 ", .style = st }}, .{ .col_offset = lw + 2 });
- while (try walk.next(io)) |e| : (i += 1) {
- if (i > MAX_FILES) break;
+ if (win.width != 0 and win.height != 0 and (win.width != pcols[1] or win.height != prows[1])) {
+ pcols[1] = win.width;
+ prows[1] = win.height;
+ const need = @as(usize, win.width) * win.height * GSLOT;
+ if (need > gbuf[1].len) gbuf[1] = ga.realloc(gbuf[1], need) catch gbuf[1];
+ tmutex[1].lockUncancelable(gio);
+ term[1].resize(ga, win.width, win.height) catch {};
+ tmutex[1].unlock(gio);
+ const wsz = posix.winsize{ .row = win.height, .col = win.width, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(master[1], posix.T.IOCSWINSZ, @intFromPtr(&wsz));
+ }
- std.debug.print("path: {s}\n", .{e.path});
- @memcpy(lines[i][0..e.path.len], e.path);
- std.debug.dumpHex(lines[i][0..2]);
- lines[i][e.path.len] = 0;
+ tmutex[1].lockUncancelable(gio);
+ rs[1].update(ga, &term[1]) catch {};
+ tmutex[1].unlock(gio);
+
+ const cols: usize = rs[1].cols;
+ const cell_rows = rs[1].row_data.items(.cells);
+ var y: usize = 0;
+ while (y < rs[1].rows and y < win.height) : (y += 1) {
+ const row_cells = cell_rows[y];
+ var x: usize = 0;
+ while (x < cols and x < win.width) : (x += 1) {
+ const cell = row_cells.get(x);
+ if (cell.raw.wide == .spacer_tail) continue;
+ const slot = gbuf[1][(y * cols + x) * GSLOT ..][0..GSLOT];
+ const cp = cell.raw.codepoint();
+ var glen: usize = 1;
+ if (cp == 0) {
+ slot[0] = ' ';
+ } else {
+ glen = std.unicode.utf8Encode(cp, slot) catch e: {
+ slot[0] = ' ';
+ break :e 1;
+ };
+ if (cell.raw.hasGrapheme()) for (cell.grapheme) |g| {
+ if (glen + 4 > slot.len) break;
+ glen += std.unicode.utf8Encode(g, slot[glen..]) catch break;
+ };
+ }
+ const sty = if (cell.raw.hasStyling()) cell.style else ghostty_vt.Style{};
+ win.writeCell(@intCast(x), @intCast(y), .{
+ .char = .{ .grapheme = slot[0..glen], .width = if (cell.raw.wide == .wide) 2 else 1 },
+ .style = .{
+ .fg = vcol(sty.fg_color),
+ .bg = vcol(sty.bg_color),
+ .ul = vcol(sty.underline_color),
+ .ul_style = switch (sty.flags.underline) {
+ .none => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ .bold = sty.flags.bold,
+ .dim = sty.flags.faint,
+ .italic = sty.flags.italic,
+ .blink = sty.flags.blink,
+ .reverse = sty.flags.inverse,
+ .invisible = sty.flags.invisible,
+ .strikethrough = sty.flags.strikethrough,
+ },
+ });
+ }
}
+ if (focused) if (rs[1].cursor.visible) if (rs[1].cursor.viewport) |vp| {
+ if (vp.x < win.width and vp.y < win.height) win.showCursor(vp.x, vp.y);
+ };
}
- var tty = try vaxis.Tty.init(io, &tty_buf);
+ _ = root.print(
+ &.{.{ .text = " pardes · two ptys, one terminal · o: switch focus · F12: quit ", .style = .{ .dim = true } }},
+ .{ .row_offset = body_h, .col_offset = 1 },
+ );
+ try vx.render(tty.writer());
+}
+
+fn vcol(c: ghostty_vt.Style.Color) vaxis.Color {
+ return switch (c) {
+ .none => .default,
+ .palette => |i| .{ .index = i },
+ .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
+ };
+}
- var vx = try vaxis.init(io, gpa, init.environ_map, .{});
- defer vx.deinit(gpa, tty.writer());
+/// Encode a vaxis key with ghostty-vt and write it to the focused pane's child.
+fn sendKey(k: vaxis.Key) void {
+ tmutex[focus].lockUncancelable(gio);
+ const opts = ghostty_vt.input.KeyEncodeOptions.fromTerminal(&term[focus]);
+ tmutex[focus].unlock(gio);
- try vx.enterAltScreen(tty.writer());
- defer vx.exitAltScreen(tty.writer()) catch {};
+ const cp = k.codepoint;
+ var ev: ghostty_vt.input.KeyEvent = .{
+ .action = .press,
+ .mods = .{ .shift = k.mods.shift, .ctrl = k.mods.ctrl, .alt = k.mods.alt, .super = k.mods.super },
+ .unshifted_codepoint = cp,
+ .key = switch (cp) {
+ vaxis.Key.enter => .enter,
+ vaxis.Key.tab => .tab,
+ vaxis.Key.escape => .escape,
+ vaxis.Key.space => .space,
+ vaxis.Key.backspace => .backspace,
+ vaxis.Key.up => .arrow_up,
+ vaxis.Key.down => .arrow_down,
+ vaxis.Key.left => .arrow_left,
+ vaxis.Key.right => .arrow_right,
+ vaxis.Key.home => .home,
+ vaxis.Key.end => .end,
+ vaxis.Key.page_up => .page_up,
+ vaxis.Key.page_down => .page_down,
+ vaxis.Key.insert => .insert,
+ vaxis.Key.delete => .delete,
+ vaxis.Key.f1 => .f1,
+ vaxis.Key.f2 => .f2,
+ vaxis.Key.f3 => .f3,
+ vaxis.Key.f4 => .f4,
+ vaxis.Key.f5 => .f5,
+ vaxis.Key.f6 => .f6,
+ vaxis.Key.f7 => .f7,
+ vaxis.Key.f8 => .f8,
+ vaxis.Key.f9 => .f9,
+ vaxis.Key.f10 => .f10,
+ vaxis.Key.f11 => .f11,
+ vaxis.Key.f12 => .f12,
+ 'a'...'z' => @enumFromInt(@intFromEnum(ghostty_vt.input.Key.key_a) + (cp - 'a')),
+ '0'...'9' => @enumFromInt(@intFromEnum(ghostty_vt.input.Key.digit_0) + (cp - '0')),
+ else => .unidentified,
+ },
+ };
+ // Only forward real text; control/private-use keys encode from .key above.
+ if (k.text) |t| if (cp >= 0x20 and cp != 0x7f and cp < 0xE000) {
+ ev.utf8 = t;
+ };
- try vx.setMouseMode(tty.writer(), true);
+ var buf: [128]u8 = undefined;
+ var w = std.Io.Writer.fixed(&buf);
+ ghostty_vt.input.encodeKey(&w, ev, opts) catch return;
+ ptyWrite(master[focus], w.buffered());
+}
- var loop: Loop = .init(io, &tty, &vx);
- try loop.start();
- defer loop.stop();
+/// Encode a vaxis mouse event for the focused pane and write it to its child
+/// (only when that child turned mouse reporting on). Coords are translated from
+/// screen space into the pane's content area.
+fn sendMouse(m: vaxis.Mouse) void {
+ const root = vx.window();
+ const lw = root.width / 2;
+ const inner_x: i32 = if (focus == 0) 1 else @as(i32, lw) + 1; // border-inset origin
+ const lx = @as(i32, m.col) - inner_x;
+ const ly = @as(i32, m.row) - 1;
+ if (lx < 0 or ly < 0 or lx >= pcols[focus] or ly >= prows[focus]) return;
- while (true) {
- const event = try loop.nextEvent();
- const win = vx.window();
- const win_child = win.child(.{ .border = .{ .where = .all }, .height = 8, .width = 42 });
- _ = win_child.printSegment(.{ .text = "hey" }, .{});
+ const Size = @FieldType(ghostty_vt.input.MouseEncodeOptions, "size");
+ tmutex[focus].lockUncancelable(gio);
+ const mouse_on = term[focus].flags.mouse_event != .none;
+ var size: Size = std.mem.zeroes(Size); // 1px cells: col/row == "pixels"
+ size.screen = .{ .width = term[focus].cols, .height = term[focus].rows };
+ size.cell = .{ .width = 1, .height = 1 };
+ var opts = ghostty_vt.input.MouseEncodeOptions.fromTerminal(&term[focus], size);
+ tmutex[focus].unlock(gio);
+ if (!mouse_on) return;
- switch (event) {
- .key_press => |key| {
- if (key.matches('c', .{ .ctrl = true })) {
- break;
- }
- },
- .mouse => |m| {
- _ = win.printSegment(.{ .text = str }, .{ .col_offset = 1, .row_offset = 1 });
+ opts.any_button_pressed = m.type == .press or m.type == .drag;
+ const ev: ghostty_vt.input.MouseEncodeEvent = .{
+ .action = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion, .drag => .motion,
+ },
+ .button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .four,
+ .wheel_down => .five,
+ .wheel_right => .six,
+ .wheel_left => .seven,
+ else => null,
+ },
+ .mods = .{ .shift = m.mods.shift, .ctrl = m.mods.ctrl, .alt = m.mods.alt },
+ .pos = .{ .x = @floatFromInt(lx), .y = @floatFromInt(ly) },
+ };
- if (m.type == .press) {
- // const txt = try std.fmt.allocPrint(gpa, "col={} row={} xo={} yo={}", .{ m.col, m.row, m.xoffset, m.yoffset });
- // constlen = std.mem.findSentinel(u8, 0, @ptrCast(&lines[@intCast(m.row)]));
- // _ = win.printSegment(
- // .{
- // .text = lines[@intCast(m.row)][0..len],
- // .style = .{ .fg = .{ .rgb = .{ 0x00, 0xd0, 0xaa } } },
- // },
- // .{
- // .col_offset = @as(u16, @intCast(m.col)),
- // .row_offset = @as(u16, @intCast(m.row)),
- // .wrap = .none,
- // },
- // );
- }
- },
- .winsize => |ws| try vx.resize(gpa, tty.writer(), ws),
- else => {},
- }
+ var buf: [64]u8 = undefined;
+ var w = std.Io.Writer.fixed(&buf);
+ ghostty_vt.input.encodeMouse(&w, ev, opts) catch return;
+ ptyWrite(master[focus], w.buffered());
+}
- try vx.render(tty.writer());
+fn ptyWrite(fd: posix.fd_t, bytes: []const u8) void {
+ var i: usize = 0;
+ while (i < bytes.len) {
+ const n = write(fd, bytes.ptr + i, bytes.len - i);
+ if (n <= 0) return;
+ i += @intCast(n);
}
}
diff --git a/smoke.zig b/smoke.zig
new file mode 100644
index 00000000..6b2ae164
--- /dev/null
+++ b/smoke.zig
@@ -0,0 +1,136 @@
+//! End-to-end smoke test: drive the real `pardes` binary under a pty, type a
+//! command, resize, then exit, and verify it rendered and tore down cleanly
+//! (no hang, no panic). Path to the binary comes in as argv[1] (build wires it).
+//! Pure-Zig port of the former smoke.py.
+
+const std = @import("std");
+const posix = std.posix;
+
+// libc bits std.posix no longer exposes; same extern style as main.zig.
+extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
+extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn write(fd: c_int, buf: [*]const u8, count: usize) isize;
+extern "c" fn waitpid(pid: c_int, status: *c_int, options: c_int) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+// ponytail: global state, single-threaded driver. The 256K capture buffer holds
+// a few frames; on overflow we stop appending (search/len checks still hold).
+// Grow to an ArrayList only if that limit bites.
+var captured: [256 * 1024]u8 = undefined;
+var cap_len: usize = 0;
+var master: c_int = undefined;
+var io: std.Io = undefined;
+
+fn send(s: []const u8) void {
+ _ = write(master, s.ptr, s.len);
+}
+
+/// Pump output for `ms` milliseconds. Returns false on EOF (master closed).
+fn pump(ms: i64) bool {
+ const end = std.Io.Timestamp.now(io, .awake).toMilliseconds() + ms; // .awake == monotonic
+ var buf: [65536]u8 = undefined;
+ while (std.Io.Timestamp.now(io, .awake).toMilliseconds() < end) {
+ var fds = [_]posix.pollfd{.{ .fd = master, .events = posix.POLL.IN, .revents = 0 }};
+ const ready = posix.poll(&fds, 100) catch 0;
+ if (ready == 0 or fds[0].revents & posix.POLL.IN == 0) continue;
+ const n = posix.read(master, &buf) catch return false; // EIO == child gone
+ if (n == 0) return false;
+ const chunk = buf[0..n];
+ if (cap_len + n <= captured.len) {
+ @memcpy(captured[cap_len..][0..n], chunk);
+ cap_len += n;
+ }
+ // Answer the queries vaxis sends, like a real terminal would. The DSR
+ // one (\x1b[5n -> \x1b[0n) is what wakes loop.stop()'s reader on teardown.
+ if (std.mem.indexOf(u8, chunk, "\x1b[5n") != null) send("\x1b[0n");
+ if (std.mem.indexOf(u8, chunk, "\x1b[6n") != null) send("\x1b[1;1R"); // cursor pos -> 1,1
+ if (std.mem.indexOf(u8, chunk, "\x1b[c") != null) send("\x1b[?6c"); // device attrs
+ if (std.mem.indexOf(u8, chunk, "\x1b[?996n") != null) send("\x1b[?997;1n"); // scheme -> dark
+ }
+ return true;
+}
+
+pub fn main(init: std.process.Init) void {
+ io = init.io;
+ var args = init.minimal.args.iterate();
+ _ = args.skip(); // argv[0]
+ const app: [*:0]const u8 = (args.next() orelse {
+ std.debug.print("usage: smoke <path-to-pardes>\n", .{});
+ std.process.exit(2);
+ }).ptr;
+
+ const ws = posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master, null, null, &ws);
+ if (pid == 0) {
+ _ = setenv("TERM", "xterm-256color", 1);
+ const argv = [_:null]?[*:0]const u8{ app, null };
+ _ = execvp(app, &argv);
+ _exit(127);
+ }
+
+ _ = pump(800); // let vaxis init + shell prompt render
+ send("echo PARDES_OK\r");
+ _ = pump(800);
+
+ // Exercise the resize path: shrink the pty and signal the app.
+ const ws_new = posix.winsize{ .row = 30, .col = 100, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(master, posix.T.IOCSWINSZ, @intFromPtr(&ws_new));
+ posix.kill(pid, posix.SIG.WINCH) catch {};
+ _ = pump(500);
+ send("echo AFTER_RESIZE\r");
+ _ = pump(600);
+
+ send("exit\r"); // shell exits -> child EOF -> app tears down
+
+ // Wait for the app to exit, still draining output.
+ const deadline = std.Io.Timestamp.now(io, .awake).toMilliseconds() + 12_000;
+ var status: ?u32 = null;
+ while (std.Io.Timestamp.now(io, .awake).toMilliseconds() < deadline) {
+ _ = pump(300);
+ var st: c_int = 0;
+ if (waitpid(pid, &st, posix.W.NOHANG) == pid) {
+ status = @bitCast(st);
+ break;
+ }
+ }
+
+ const out = captured[0..cap_len];
+ std.debug.print("captured {d} bytes of rendered output\n", .{out.len});
+
+ var ok = true;
+ if (status) |st| {
+ if (posix.W.IFSIGNALED(st)) {
+ std.debug.print("FAIL: app killed by signal {d}\n", .{@intFromEnum(posix.W.TERMSIG(st))});
+ ok = false;
+ } else {
+ const code = posix.W.EXITSTATUS(st);
+ std.debug.print("app exited with code {d}\n", .{code});
+ if (code != 0) ok = false;
+ }
+ } else {
+ std.debug.print("FAIL: app did not exit within timeout (possible deadlock)\n", .{});
+ posix.kill(pid, posix.SIG.KILL) catch {};
+ var st: c_int = 0;
+ _ = waitpid(pid, &st, 0);
+ ok = false;
+ }
+
+ for ([_][]const u8{ "panic", "segmentation", "sigsegv", "unreachable" }) |needle| {
+ if (std.ascii.indexOfIgnoreCase(out, needle) != null) {
+ std.debug.print("FAIL: '{s}' found in output\n", .{needle});
+ ok = false;
+ }
+ }
+
+ // Alt-screen enter / clear / home means vaxis painted at least one frame.
+ const rendered = std.mem.indexOf(u8, out, "\x1b[?1049h") != null or
+ std.mem.indexOf(u8, out, "\x1b[2J") != null or
+ std.mem.indexOf(u8, out, "\x1b[H") != null or
+ out.len > 200;
+ std.debug.print("rendered something: {}\n", .{rendered});
+ if (!rendered) ok = false;
+
+ std.debug.print("RESULT: {s}\n", .{if (ok) "PASS" else "FAIL"});
+ std.process.exit(if (ok) 0 else 1);
+}
diff --git a/tests.zig b/tests.zig
new file mode 100644
index 00000000..6fa43f7a
--- /dev/null
+++ b/tests.zig
@@ -0,0 +1,140 @@
+//! Deterministic tests for the terminal-pane pipeline, exercised without a real
+//! tty/vaxis: pty -> ghostty-vt parsing -> RenderState grid, plus the input
+//! encoders used to send keys/mouse back to the child.
+
+const std = @import("std");
+const posix = std.posix;
+const testing = std.testing;
+const ghostty_vt = @import("ghostty-vt");
+
+extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
+extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+/// Read all of a cell row's codepoints into `out`, returning the slice used.
+fn rowText(rs: *const ghostty_vt.RenderState, y: usize, out: []u8) []const u8 {
+ const cells = rs.row_data.items(.cells)[y];
+ var n: usize = 0;
+ var x: usize = 0;
+ while (x < rs.cols and n < out.len) : (x += 1) {
+ const cp = cells.get(x).raw.codepoint();
+ out[n] = if (cp == 0 or cp > 0x7f) ' ' else @intCast(cp);
+ n += 1;
+ }
+ // trim trailing spaces for convenience
+ while (n > 0 and out[n - 1] == ' ') n -= 1;
+ return out[0..n];
+}
+
+test "pty -> ghostty: child output lands in the grid with color" {
+ const alloc = testing.allocator;
+
+ var t: ghostty_vt.Terminal = try .init(alloc, .{ .cols = 40, .rows = 5 });
+ defer t.deinit(alloc);
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ // A one-shot child that prints bold-green "GREEN" then a newline.
+ const argv = [_:null]?[*:0]const u8{
+ "/bin/sh", "-c", "printf '\\033[1;32mGREEN\\033[0m\\n'", null,
+ };
+ var master: c_int = undefined;
+ const ws = posix.winsize{ .row = 5, .col = 40, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master, null, null, &ws);
+ if (pid == 0) {
+ _ = execvp(argv[0].?, &argv);
+ _exit(127);
+ }
+ defer posix.kill(pid, posix.SIG.HUP) catch {};
+
+ // Drain the child until EOF, feeding bytes into ghostty as they arrive
+ // (exactly what the app's reader task does).
+ var buf: [1024]u8 = undefined;
+ while (true) {
+ const n = posix.read(master, &buf) catch break;
+ if (n == 0) break;
+ stream.nextSlice(buf[0..n]);
+ }
+
+ var rs: ghostty_vt.RenderState = .empty;
+ defer rs.deinit(alloc);
+ try rs.update(alloc, &t);
+
+ var text_buf: [40]u8 = undefined;
+ try testing.expectEqualStrings("GREEN", rowText(&rs, 0, &text_buf));
+
+ // The 'G' cell should be bold with a green (palette index 2) foreground.
+ const g = rs.row_data.items(.cells)[0].get(0);
+ try testing.expect(g.raw.hasStyling());
+ try testing.expect(g.style.flags.bold);
+ try testing.expectEqual(ghostty_vt.Style.Color{ .palette = 2 }, g.style.fg_color);
+}
+
+test "encodeKey: ctrl+c -> 0x03" {
+ // Mirrors how main.zig builds a KeyEvent from a vaxis ctrl+c.
+ const ev: ghostty_vt.input.KeyEvent = .{
+ .action = .press,
+ .key = .key_c,
+ .mods = .{ .ctrl = true },
+ .unshifted_codepoint = 'c',
+ };
+
+ var buf: [16]u8 = undefined;
+ var w = std.Io.Writer.fixed(&buf);
+ try ghostty_vt.input.encodeKey(&w, ev, .{});
+ try testing.expectEqualStrings("\x03", w.buffered());
+}
+
+test "encodeKey: plain 'a' -> a" {
+ const ev: ghostty_vt.input.KeyEvent = .{
+ .action = .press,
+ .key = .key_a,
+ .unshifted_codepoint = 'a',
+ .utf8 = "a",
+ };
+
+ var buf: [16]u8 = undefined;
+ var w = std.Io.Writer.fixed(&buf);
+ try ghostty_vt.input.encodeKey(&w, ev, .{});
+ try testing.expectEqualStrings("a", w.buffered());
+}
+
+test "encodeKey: up arrow -> CSI A" {
+ const ev: ghostty_vt.input.KeyEvent = .{ .action = .press, .key = .arrow_up };
+ var buf: [16]u8 = undefined;
+ var w = std.Io.Writer.fixed(&buf);
+ try ghostty_vt.input.encodeKey(&w, ev, .{});
+ try testing.expectEqualStrings("\x1b[A", w.buffered());
+}
+
+test "encodeMouse: SGR left press at cell (0,0)" {
+ const alloc = testing.allocator;
+ var t: ghostty_vt.Terminal = try .init(alloc, .{ .cols = 40, .rows = 5 });
+ defer t.deinit(alloc);
+
+ // Enable normal mouse tracking (1000) in SGR mode (1006), as a child app
+ // would, so fromTerminal picks up the right reporting mode/format.
+ var stream = t.vtStream();
+ defer stream.deinit();
+ stream.nextSlice("\x1b[?1000h\x1b[?1006h");
+
+ const Size = @FieldType(ghostty_vt.input.MouseEncodeOptions, "size");
+ var size: Size = std.mem.zeroes(Size);
+ size.screen = .{ .width = t.cols, .height = t.rows };
+ size.cell = .{ .width = 1, .height = 1 };
+
+ var opts = ghostty_vt.input.MouseEncodeOptions.fromTerminal(&t, size);
+ opts.any_button_pressed = true;
+
+ const ev: ghostty_vt.input.MouseEncodeEvent = .{
+ .action = .press,
+ .button = .left,
+ .pos = .{ .x = 0, .y = 0 },
+ };
+
+ var buf: [32]u8 = undefined;
+ var w = std.Io.Writer.fixed(&buf);
+ try ghostty_vt.input.encodeMouse(&w, ev, opts);
+ try testing.expectEqualStrings("\x1b[<0;1;1M", w.buffered());
+}