//! 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; 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; 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); } while (true) { 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 }, } } } /// 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 {}; } /// 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 // ===== 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 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)); } tmutex[0].lockUncancelable(gio); rs[0].update(ga, &term[0]) catch {}; tmutex[0].unlock(gio); 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) ===== { 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 }); 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)); } 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); }; } _ = 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 } }, }; } /// 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); 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; }; 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()); } /// 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; 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; 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) }, }; 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()); } 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); } }