diff options
| -rw-r--r-- | build.zig | 90 | ||||
| -rw-r--r-- | main.zig | 532 | ||||
| -rw-r--r-- | smoke.zig | 136 | ||||
| -rw-r--r-- | tests.zig | 140 |
4 files changed, 779 insertions, 119 deletions
@@ -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); } @@ -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()); +} |
