diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-17 21:40:03 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-17 23:44:24 -0300 |
| commit | 15b9bef58c80e918352567e909a88084ca3134ef (patch) | |
| tree | b3d791abf5888dc94a6f4ba280119b7d92a9afd2 /main.zig | |
| parent | ffed14b9857b53a2aae4a0c39dee47949f1331f2 (diff) | |
| download | pardes-vibes-ghostty.tar.gz pardes-vibes-ghostty.zip | |
vibe fast vibes-ghostty
Diffstat (limited to 'main.zig')
| -rw-r--r-- | main.zig | 532 |
1 files changed, 440 insertions, 92 deletions
@@ -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); } } |
