const std = @import("std"); const vaxis = @import("vaxis"); const ghostty_vt = @import("ghostty-vt"); const Term = @import("term.zig"); extern "c" fn forkpty( amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const std.posix.winsize, ) c_int; // execv, not execvp: no PATH search means no malloc in the child, so it's // async-signal-safe and survives being forked from a multithreaded process. extern "c" fn execv( path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, ) c_int; extern "c" fn _exit(status: c_int) noreturn; // basic file navigation // basic shell io // acme-style layout: columns split horizontally, windows stacked in each column. // * /home/goblin/05-genizah/plan9port/src/cmd/acme // right click and enter on lines to open files at specified lines const PtyRead = struct { id: usize, bytes: []u8 }; pub const Command = struct { prev: ?*Command = null, pub var value: union(enum) { close, ping, nop, quit, tick, key_press: vaxis.Key, pty_read: PtyRead, pty_eof: usize, winsize: vaxis.Winsize, mouse: vaxis.Mouse, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); // layout constants const GUTTER: u16 = 2; // left gutter: box (top) + scrollbar (below) per pane const BOX_H: u16 = 1; // rows of the drag/rearrange box; scrollbar starts below it const MINW: u16 = 10; // min pane width (cells) const MINH: u16 = 3; // min pane height (cells) const MAX_TERMS: usize = 16; const MAX_COLS: usize = 6; const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; // acme model: columns own X (col_weight), windows own Y (Term.vweight). The // per-column ordered list `col_terms[c][0..col_n[c]]` is the source of truth. const Layout = struct { ncol: usize, col_weight: [MAX_COLS]f32, col_terms: [MAX_COLS][MAX_TERMS]usize, col_n: [MAX_COLS]usize, }; const Drag = union(enum) { none, border_v: struct { left_col: usize, cur_x: u16 }, border_h: struct { col: usize, top_idx: usize, cur_y: u16 }, move: struct { id: usize, cur_x: u16, cur_y: u16 }, select: struct { id: usize, button: Button }, }; // mouse button driving a selection; also indexes Term.sel. const Button = enum { left, middle, right }; // monochrome, low-contrast palette (rgb grays). const gray = struct { const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (tag, scrollbar track, box idle) const dim = [3]u8{ 0x3c, 0x3c, 0x3c }; // separators const mid = [3]u8{ 0x52, 0x52, 0x52 }; // box active, scrollbar thumb, move preview const lit = [3]u8{ 0x6e, 0x6e, 0x6e }; // dashed resize preview const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text }; // the drag button: a distinctive red (brighter when the window is focused). const red = [3]u8{ 0xcc, 0x33, 0x33 }; const red_dim = [3]u8{ 0x66, 0x2a, 0x2a }; // char-mode inserted text (bright on a faint box so it reads as an overlay). const edit_fg = [3]u8{ 0xea, 0xea, 0xea }; const edit_bg = [3]u8{ 0x3a, 0x3a, 0x3a }; // per-button selection highlight: three gray levels, muted text. const SEL_BG = [3][3]u8{ .{ 0x38, 0x38, 0x38 }, .{ 0x4c, 0x4c, 0x4c }, .{ 0x60, 0x60, 0x60 } }; const sel_fg = [3]u8{ 0xc0, 0xc0, 0xc0 }; // Tag builtins. Middle-clicking the name in a window's tag runs the action; // `builtins_str` is the space-joined name list, produced at comptime. const SelectAction = enum { none, send, newcol, delcol, del }; const Builtin = struct { name: []const u8, action: SelectAction }; const builtins = [_]Builtin{ .{ .name = "Newcol", .action = .newcol }, .{ .name = "Delcol", .action = .delcol }, .{ .name = "Del", .action = .del }, }; const builtins_str = blk: { var s: []const u8 = ""; for (builtins, 0..) |b, i| s = s ++ (if (i == 0) "" else " ") ++ b.name; break :blk s; }; fn fork_pty(argv: [:0]const u8, ws: std.posix.winsize) struct { file: std.Io.File, pid: std.posix.pid_t } { var master: c_int = undefined; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { const child_argv = [_:null]?[*:0]const u8{ argv.ptr, null }; _ = execv(argv.ptr, &child_argv); _exit(127); } return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; } fn termWrite(t: *Term, bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { const rc = std.os.linux.write(t.pty.handle, bytes[off..].ptr, bytes.len - off); switch (std.posix.errno(rc)) { .SUCCESS => off += @intCast(rc), .INTR => {}, else => break, } } } // The window tag: the shell's cwd followed by the builtin command names. Plain, // selectable text (acme-style). Caller frees with `alloc`. fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 { var link: [1024]u8 = undefined; var pathbuf: [64]u8 = undefined; const cwd: []const u8 = cwd: { const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch break :cwd "?"; const rc = std.os.linux.readlinkat(std.os.linux.AT.FDCWD, path, &link, link.len); break :cwd switch (std.posix.errno(rc)) { .SUCCESS => link[0..rc], else => "?", }; }; // leading 2-char mode indicator: "ch" (char/edit) or "sy" (sym/terminal). const mode = if (t.editing) "ch" else "sy"; return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, builtins_str }); } // TIOCSWINSZ: set the pty size; the kernel raises SIGWINCH in the child so it // (and anything running in it) reflows to the new grid. fn pty_set_size(pty: std.Io.File, rows: u16, cols: u16) void { const ws: std.posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; _ = std.posix.system.ioctl(pty.handle, std.posix.T.IOCSWINSZ, @intFromPtr(&ws)); } // One reader task per terminal. Tags each chunk with the terminal's slot id so // the main loop knows which emulator to feed; posts pty_eof when the shell exits. fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop: *Loop) anyerror!void { var read_buf: [0x1000]u8 = undefined; var reader = pty.readerStreaming(io, &read_buf); while (true) { var buf: [0x1000]u8 = undefined; var vec = [_][]u8{&buf}; const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; const bytes = try gpa.dupe(u8, buf[0..n]); loop.postEvent(.{ .pty_read = .{ .id = id, .bytes = bytes } }) catch { gpa.free(bytes); break; }; } loop.postEvent(.{ .pty_eof = id }) catch {}; } // fork the pty + boot the emulator. Forking is the dangerous part in a // multithreaded process, so this does NOT start the reader task (no new // threads) — call startReader once the term is wired in. fn forkTerm(gpa: std.mem.Allocator, init_cols: u16, init_rows: u16) !*Term { const child = fork_pty("/usr/bin/bash", .{ .row = init_rows, .col = init_cols, .xpixel = 0, .ypixel = 0 }); const t = try gpa.create(Term); errdefer gpa.destroy(t); t.* = .{ .pty = child.file, .pid = child.pid, .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = init_cols, .rows = init_rows }), .stream = undefined, .reader = .{ .any_future = null, .result = {} }, // no-op until startReader .cols = init_cols, .rows = init_rows, }; t.stream = t.term.vtStream(); return t; } fn startReader(io: std.Io, gpa: std.mem.Allocator, loop: *Loop, t: *Term, id: usize) !void { t.reader = try io.concurrent(readPty, .{ io, gpa, t.pty, id, loop }); } // ---- layout surgery over the per-column ordered lists ---- fn layoutFindTerm(l: *const Layout, id: usize) ?struct { col: usize, idx: usize } { var c: usize = 0; while (c < l.ncol) : (c += 1) { var k: usize = 0; while (k < l.col_n[c]) : (k += 1) { if (l.col_terms[c][k] == id) return .{ .col = c, .idx = k }; } } return null; } fn layoutRemove(l: *Layout, id: usize) void { const f = layoutFindTerm(l, id) orelse return; const c = f.col; var k = f.idx; while (k + 1 < l.col_n[c]) : (k += 1) l.col_terms[c][k] = l.col_terms[c][k + 1]; l.col_n[c] -= 1; if (l.col_n[c] == 0) { var j = c; while (j + 1 < l.ncol) : (j += 1) { l.col_terms[j] = l.col_terms[j + 1]; l.col_n[j] = l.col_n[j + 1]; l.col_weight[j] = l.col_weight[j + 1]; } l.ncol -= 1; } } fn layoutInsert(l: *Layout, c: usize, idx: usize, id: usize) void { var k = l.col_n[c]; while (k > idx) : (k -= 1) l.col_terms[c][k] = l.col_terms[c][k - 1]; l.col_terms[c][idx] = id; l.col_n[c] += 1; } fn layoutAppendColumn(l: *Layout, id: usize) void { if (l.ncol >= MAX_COLS) return; const c = l.ncol; l.col_weight[c] = 1; l.col_terms[c][0] = id; l.col_n[c] = 1; l.ncol += 1; } // Tile the screen: columns by col_weight, then each column's windows by vweight. // Last column/window absorbs rounding so the tiling always fills the screen. // `out_*` written for every live term and live column. fn computeGeom( l: *const Layout, terms: *const [MAX_TERMS]?*Term, sw: u16, sh: u16, rects: *[MAX_TERMS]Rect, col_x: *[MAX_COLS]u16, col_w: *[MAX_COLS]u16, ) void { if (l.ncol == 0) return; var wsum: f32 = 0; for (0..l.ncol) |c| wsum += l.col_weight[c]; if (wsum <= 0) wsum = 1; const sep_cols: u16 = @intCast(l.ncol - 1); const avail_w: u16 = sw -| sep_cols; var x: u16 = 0; for (0..l.ncol) |c| { const last = (c + 1 == l.ncol); const cw: u16 = if (last) (sw -| x) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_w)) * l.col_weight[c] / wsum)))); col_x[c] = x; col_w[c] = cw; const n = l.col_n[c]; var vsum: f32 = 0; for (0..n) |k| { if (terms[l.col_terms[c][k]]) |t| vsum += t.vweight; } if (vsum <= 0) vsum = 1; const sep_rows: u16 = if (n > 0) @intCast(n - 1) else 0; const avail_h: u16 = sh -| sep_rows; var y: u16 = 0; for (0..n) |k| { const id = l.col_terms[c][k]; const t = terms[id] orelse continue; const lastk = (k + 1 == n); const ch: u16 = if (lastk) (sh -| y) else @max(1, @as(u16, @intFromFloat(@round(@as(f32, @floatFromInt(avail_h)) * t.vweight / vsum)))); rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; y +|= ch + 1; } x +|= cw + 1; } } // `alloc` must be a per-frame arena, NOT freed before vx.render(): vaxis is a // retained renderer and stores each cell's grapheme as a *slice* into the text // we hand it, so plainString has to outlive the render call. fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, active: bool) !void { if (r.w <= GUTTER or r.h == 0) return; // text area: tag line on row 0 (same row as the box), terminal body below. const text = root.child(.{ .x_off = r.x + GUTTER, .y_off = r.y, .width = r.w - GUTTER, .height = r.h }); text.clear(); // tag: cwd + builtins, drawn as a dim bar; plain selectable text. const tag = try tagLine(alloc, t); const tagw = text.child(.{ .height = BOX_H }); tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = gray.bar } } }); _ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = .{ .rgb = gray.txt }, .bg = .{ .rgb = gray.bar } } }, .{ .wrap = .none }); // FUTURE: hide shell prompts, render only command output. ghostty-vt tracks // OSC 133 semantic prompts and stores the state we'd need: // - per cell: page.Cell.semantic_content -> .output | .input | .prompt // - per row: page.Row.semantic_prompt -> .none | .prompt | .prompt_continuation // - cursor drives it via OSC 133 A/B/C/D (stream_terminal -> Terminal.semanticPrompt). // Ready-made helpers (all public through ghostty_vt): Screen.selectOutput(pin), // PageList.highlightSemanticContent(pin, .output), screen.pages.rowIterator(...), // pin.rowAndCell().cell.semantic_content, pin.promptIterator/cellIterator. // To do it: swap plainString for a row walk that skips rows where // row.semantic_prompt != .none (coarse) or non-.output cells (fine): // var it = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); // while (it.next()) |pin| { if (pin.rowAndCell().row.semantic_prompt != .none) continue; ... } // CATCH: only populated if the shell emits OSC 133 (shell integration). bash // doesn't by default — every cell stays the default .output, so nothing would // be hidden until we source ghostty's integration script or wrap PS1/ // PROMPT_COMMAND with the 133;A/B/C/D markers in each spawned pty. Best as a // toggle (a Hide/Show tag builtin) so it degrades gracefully without integration. // body = tty rows with insertion runs spliced in (text shifts, not overdrawn). const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset); const body = try bodyText(alloc, t); var it = std.mem.splitAny(u8, body, "\n"); var i: u16 = 0; while (it.next()) |line| : (i += 1) { _ = text.printSegment(.{ .text = line }, .{ .row_offset = BOX_H + i, .wrap = .none }); } // recolor the inserted cells so typed text reads distinctly from tty output. // edit.row is absolute; its visual pane row is (row - off) + BOX_H. for (t.edits[0..t.nedits]) |*e| { if (e.len == 0) continue; const prow = e.row - off + @as(i32, BOX_H); if (prow < 0 or prow >= @as(i32, r.h)) continue; const start = renderedCol(t, e.row, e.col, 0); var k: u16 = 0; while (k < e.len) : (k += 1) { const cc = start + @as(i32, k); if (cc < 0 or cc >= @as(i32, r.w - GUTTER)) continue; if (text.readCell(@intCast(cc), @intCast(prow))) |cell| { var nc = cell; nc.default = false; nc.style.fg = .{ .rgb = edit_fg }; nc.style.bg = .{ .rgb = edit_bg }; text.writeCell(@intCast(cc), @intCast(prow), nc); } } } // recolor selected cells (pane-local visual coords), one pass per button — // later buttons win on overlap; only while actively dragging. for (t.sel, 0..) |sl, b| { if (sl.state != .dragging) continue; const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0 = @max(0, @min(sl.c0, sl.c1)); const c1 = @max(0, @max(sl.c0, sl.c1)); var row: u16 = 0; while (row < r.h) : (row += 1) { if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; var col: i32 = c0; while (col <= c1 and col < @as(i32, r.w - GUTTER)) : (col += 1) { const cc: u16 = @intCast(col); if (text.readCell(cc, row)) |cell| { var nc = cell; nc.default = false; // else vaxis ignores the style on cleared cells nc.style.bg = .{ .rgb = SEL_BG[b] }; nc.style.fg = .{ .rgb = sel_fg }; text.writeCell(cc, row, nc); } } } } if (active) { if (t.editing) { // char mode: cursor at the rendered cursor position (scroll-adjusted). const prow = t.cur_row - off + @as(i32, BOX_H); const cx = renderedCol(t, t.cur_row, t.cur_col, t.cur_off); if (prow >= 0 and cx >= 0 and prow < @as(i32, r.h) and cx < @as(i32, r.w - GUTTER)) text.showCursor(@intCast(cx), @intCast(prow)); } else { text.showCursor(t.term.screens.active.cursor.x, t.term.screens.active.cursor.y + BOX_H); } } // gutter: box (the drag/rearrange handle) on top, scrollbar below const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h }); const box_bg = if (active) red else red_dim; var bx: u16 = 0; while (bx < GUTTER) : (bx += 1) { var by: u16 = 0; while (by < BOX_H) : (by += 1) gut.writeCell(bx, by, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = box_bg } } }); } const sb = t.term.screens.active.pages.scrollbar(); const track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0; if (track_h > 0) { const total: usize = if (sb.total == 0) 1 else sb.total; const pos = (track_h * sb.offset) / total; const len = @max(1, (track_h * sb.len) / total); var gy: usize = 0; while (gy < track_h) : (gy += 1) { const thumb = gy >= pos and gy < pos + len; const st_bg = if (thumb) gray.mid else gray.bar; var cx: u16 = 0; while (cx < GUTTER) : (cx += 1) gut.writeCell(cx, @intCast(BOX_H + gy), .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = st_bg } } }); } } } pub fn main(init: std.process.Init) !void { const io = init.io; const gpa = init.gpa; var screen_w: u16 = 80; var screen_h: u16 = 24; var resize_count: usize = 0; // per-frame scratch for plainString; reset (not freed) after each render so // the retained vaxis screen can keep referencing it through vx.render(). var frame_arena: std.heap.ArenaAllocator = .init(gpa); defer frame_arena.deinit(); var ov_buf: [128]u8 = undefined; // overlay text; must outlive each vx.render() var tty_buf: [0x10000]u8 = undefined; var tty = try vaxis.Tty.init(io, &tty_buf); var vx = try vaxis.init(io, gpa, init.environ_map, .{}); defer vx.deinit(gpa, tty.writer()); try vx.setMouseMode(tty.writer(), true); try vx.enterAltScreen(tty.writer()); defer vx.exitAltScreen(tty.writer()) catch {}; // ---- terminals + layout ---- var terms: [MAX_TERMS]?*Term = @splat(null); defer for (&terms) |*slot| { if (slot.*) |t| { t.deinit(io, gpa); gpa.destroy(t); slot.* = null; } }; var layout: Layout = .{ .ncol = 0, .col_weight = @splat(1), .col_terms = undefined, .col_n = @splat(0), }; // Fork the initial terminals BEFORE starting any io worker thread. forkpty // from a multithreaded process can leave the child wedged before exec; doing // it while still single-threaded avoids that. (initial: two columns, the // left one split into two windows.) terms[0] = try forkTerm(gpa, screen_w, screen_h); terms[1] = try forkTerm(gpa, screen_w, screen_h); terms[2] = try forkTerm(gpa, screen_w, screen_h); layout.ncol = 2; layout.col_n[0] = 2; layout.col_terms[0][0] = 0; layout.col_terms[0][1] = 1; layout.col_n[1] = 1; layout.col_terms[1][0] = 2; var active: usize = 0; var loop: Loop = .init(io, &tty, &vx); try loop.start(); defer loop.stop(); try loop.installResizeHandler(); // now safe to spawn the per-terminal reader tasks (no forking here) for (&terms, 0..) |*slot, id| { if (slot.*) |t| try startReader(io, gpa, &loop, t, id); } var rects: [MAX_TERMS]Rect = undefined; var col_x: [MAX_COLS]u16 = undefined; var col_w: [MAX_COLS]u16 = undefined; computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w); var drag: Drag = .none; while (true) { const event = try loop.nextEvent(); switch (event) { .mouse => |mouse| { const mcol: u16 = if (mouse.col < 0) 0 else @min(@as(u16, @intCast(mouse.col)), screen_w -| 1); const mrow: u16 = if (mouse.row < 0) 0 else @min(@as(u16, @intCast(mouse.row)), screen_h -| 1); // which pane is under the cursor (if any) const hovered: ?usize = for (terms, 0..) |slot, i| { if (slot == null) continue; const r = rects[i]; if (mcol >= r.x and mcol < r.x + r.w and mrow >= r.y and mrow < r.y + r.h) break i; } else null; if (mouse.button == .wheel_down) { if (hovered) |id| terms[id].?.term.screens.active.scroll(.{ .delta_row = 1 }); } else if (mouse.button == .wheel_up) { if (hovered) |id| terms[id].?.term.screens.active.scroll(.{ .delta_row = -1 }); } else if (mouse.type == .press and mouse.button == .left) { var handled = false; // vertical separator (between columns)? var c: usize = 0; while (c + 1 < layout.ncol and !handled) : (c += 1) { if (mcol == col_x[c] + col_w[c]) { drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } }; handled = true; } } // horizontal separator (between stacked windows)? if (!handled) { var cc: usize = 0; outer: while (cc < layout.ncol) : (cc += 1) { if (mcol < col_x[cc] or mcol >= col_x[cc] + col_w[cc]) continue; var k: usize = 0; while (k + 1 < layout.col_n[cc]) : (k += 1) { const id = layout.col_terms[cc][k]; const r = rects[id]; if (mrow == r.y + r.h) { drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } }; handled = true; break :outer; } } } } // inside a pane? if (!handled) { if (hovered) |id| { const r = rects[id]; const in_gutter = mcol < r.x + GUTTER; if (in_gutter and mrow < r.y + BOX_H) { drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } }; } else if (in_gutter) { active = id; if (terms[id]) |t| { const local: i16 = @intCast(@as(i32, mrow) - @as(i32, r.y + BOX_H)); t.term.screens.active.scroll(.{ .delta_row = -local }); } } else { active = id; if (terms[id]) |t| { const sc: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); t.sel[@intFromEnum(Button.left)] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; drag = .{ .select = .{ .id = id, .button = .left } }; } } } } } else if (mouse.type == .press and (mouse.button == .middle or mouse.button == .right)) { // middle/right select text too (own state + color), text area only const b: Button = if (mouse.button == .middle) .middle else .right; if (hovered) |id| { const r = rects[id]; if (mcol >= r.x + GUTTER) { active = id; if (terms[id]) |t| { const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); t.sel[@intFromEnum(b)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }; drag = .{ .select = .{ .id = id, .button = b } }; } } } } else if (mouse.type == .drag) { switch (drag) { .border_v => |*d| { const c = d.left_col; if (c + 1 < layout.ncol) { const lo = col_x[c] + MINW; const hi = col_x[c] +| col_w[c] +| col_w[c + 1] -| MINW; d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol; } else d.cur_x = mcol; }, .border_h => |*d| { const cc = d.col; const k = d.top_idx; if (k + 1 < layout.col_n[cc]) { const a = layout.col_terms[cc][k]; const b = layout.col_terms[cc][k + 1]; const lo = rects[a].y + MINH; const hi = rects[a].y +| rects[a].h +| rects[b].h -| MINH; d.cur_y = if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow; } else d.cur_y = mrow; }, .move => |*d| { d.cur_x = mcol; d.cur_y = mrow; }, .select => |s| { if (terms[s.id]) |t| { const r = rects[s.id]; t.sel[@intFromEnum(s.button)].c1 = @as(i32, mcol) - @as(i32, r.x + GUTTER); t.sel[@intFromEnum(s.button)].r1 = @as(i32, mrow) - @as(i32, r.y); } }, .none => {}, } } else if (mouse.type == .release) { switch (drag) { .border_v => |d| { const c = d.left_col; if (c + 1 < layout.ncol) { const combined: f32 = @floatFromInt(col_w[c] + col_w[c + 1]); var nl: f32 = @floatFromInt(d.cur_x -| col_x[c]); const minw: f32 = MINW; nl = std.math.clamp(nl, minw, @max(minw, combined - minw)); const pair = layout.col_weight[c] + layout.col_weight[c + 1]; layout.col_weight[c] = pair * (nl / combined); layout.col_weight[c + 1] = pair - layout.col_weight[c]; } }, .border_h => |d| { const c = d.col; const k = d.top_idx; if (k + 1 < layout.col_n[c]) { const a = layout.col_terms[c][k]; const b = layout.col_terms[c][k + 1]; if (terms[a]) |ta| if (terms[b]) |tb| { const combined: f32 = @floatFromInt(rects[a].h + rects[b].h); var nt: f32 = @floatFromInt(d.cur_y -| rects[a].y); const minh: f32 = MINH; nt = std.math.clamp(nt, minh, @max(minh, combined - minh)); const pair = ta.vweight + tb.vweight; ta.vweight = pair * (nt / combined); tb.vweight = pair - ta.vweight; }; } }, .move => |d| moveTerm(&layout, &rects, &col_x, &col_w, d.id, d.cur_x, d.cur_y), .select => |s| { if (terms[s.id]) |t| { t.sel[@intFromEnum(s.button)].state = .done; if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| { defer gpa.free(txt); switch (onSelect(s.button, txt)) { .none => {}, .send => { // run the selection in the terminal it came from termWrite(t, std.mem.trim(u8, txt, " \t\r\n")); termWrite(t, "\r"); }, .newcol => { // new terminal in a fresh column const slot: ?usize = for (terms, 0..) |sl, i| { if (sl == null) break i; } else null; if (slot) |free| if (layout.ncol < MAX_COLS) { if (forkTerm(gpa, screen_w, screen_h)) |nt| { terms[free] = nt; startReader(io, gpa, &loop, nt, free) catch {}; layoutAppendColumn(&layout, free); active = free; } else |_| {} }; }, .del => { // close just this window layoutRemove(&layout, s.id); t.deinit(io, gpa); gpa.destroy(t); terms[s.id] = null; if (active == s.id) active = for (terms, 0..) |sl, i| { if (sl != null) break i; } else break; }, .delcol => { // close every terminal in this one's column if (layoutFindTerm(&layout, s.id)) |f| { var ids: [MAX_TERMS]usize = undefined; const nids = layout.col_n[f.col]; for (0..nids) |k| ids[k] = layout.col_terms[f.col][k]; for (ids[0..nids]) |tid| { if (terms[tid]) |tt| { layoutRemove(&layout, tid); tt.deinit(io, gpa); gpa.destroy(tt); terms[tid] = null; } } if (terms[active] == null) active = for (terms, 0..) |sl, i| { if (sl != null) break i; } else break; } }, } } else |_| {} if (s.button == .left) { // left-click in the body enters char mode and // anchors the cursor at the selection end. the // row is stored absolute (visual + scroll off) so // it rides the scrollback. clicks on the tag (row // < BOX_H) don't edit. const sl = t.sel[@intFromEnum(Button.left)]; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0) { const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset); t.cur_row = body_vis + off; t.cur_col = sl.c1; t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; t.editing = true; } } } }, .none => {}, } drag = .none; } }, .key_press => |key| { if (key.matches('c', .{ .ctrl = true })) break; if (terms[active]) |at| { if (key.matches('n', .{ .alt = true })) { // new terminal below the active one, in its column. // ponytail: forks from the now-multithreaded process; // execv (not execvp) keeps the child async-signal-safe. const free_slot: ?usize = for (terms, 0..) |s, i| { if (s == null) break i; } else null; if (free_slot) |slot| { if (forkTerm(gpa, screen_w, screen_h)) |nt| { terms[slot] = nt; startReader(io, gpa, &loop, nt, slot) catch {}; const f = layoutFindTerm(&layout, active).?; layoutInsert(&layout, f.col, f.idx + 1, slot); active = slot; } else |_| {} } } else if (key.matches('c', .{ .alt = true })) { // move active terminal into a fresh column const f = layoutFindTerm(&layout, active).?; if (layout.ncol < MAX_COLS and layout.col_n[f.col] > 1) { layoutRemove(&layout, active); layoutAppendColumn(&layout, active); } } else if (key.matches('d', .{ .ctrl = true })) { at.term.screens.active.scroll(.{ .delta_row = 15 }); } else if (key.matches('u', .{ .ctrl = true })) { at.term.screens.active.scroll(.{ .delta_row = -15 }); } else if (at.editing and key.matches(vaxis.Key.escape, .{})) { at.editing = false; // leave char mode (don't forward Esc) } else if (at.editing) { // char mode: printable text inserts at the cursor (shifting // the current run); arrows move the cursor; backspace/etc. // are no-ops for now (deletion comes later). if (key.text) |text| { // find or create the run at the cursor var idx: ?usize = runAt(at, at.cur_row, at.cur_col); if (idx == null and at.nedits < Term.MAX_EDITS) { at.edits[at.nedits] = .{ .row = at.cur_row, .col = at.cur_col }; at.nedits += 1; idx = at.nedits - 1; } if (idx) |ei| { const e = &at.edits[ei]; for (text) |ch| { if (e.len >= Term.EDIT_CAP) break; if (at.cur_off > e.len) at.cur_off = e.len; var k: usize = e.len; while (k > at.cur_off) : (k -= 1) e.text[k] = e.text[k - 1]; e.text[at.cur_off] = ch; e.len += 1; at.cur_off += 1; } } } else switch (key.codepoint) { vaxis.Key.left => { if (at.cur_off > 0) at.cur_off -= 1 else { at.cur_col = @max(0, at.cur_col - 1); at.cur_off = if (runAt(at, at.cur_row, at.cur_col)) |i| at.edits[i].len else 0; } }, vaxis.Key.right => { const rl: u16 = if (runAt(at, at.cur_row, at.cur_col)) |i| at.edits[i].len else 0; if (at.cur_off < rl) at.cur_off += 1 else { at.cur_col += 1; at.cur_off = 0; } }, vaxis.Key.up => { const off: i32 = @intCast(at.term.screens.active.pages.scrollbar().offset); at.cur_row = @max(off, at.cur_row - 1); at.cur_off = 0; }, vaxis.Key.down => { const off: i32 = @intCast(at.term.screens.active.pages.scrollbar().offset); at.cur_row = @min(off + @as(i32, at.rows) - 1, at.cur_row + 1); at.cur_off = 0; }, else => {}, } } else { // sym mode: forward to the pty as terminal input. const bytes: ?[]const u8 = if (key.text) |text| text else switch (key.codepoint) { vaxis.Key.enter => "\r", vaxis.Key.backspace => "\x7f", vaxis.Key.tab => "\t", vaxis.Key.escape => "\x1b", vaxis.Key.up => "\x1b[A", vaxis.Key.down => "\x1b[B", vaxis.Key.right => "\x1b[C", vaxis.Key.left => "\x1b[D", else => null, }; if (bytes) |b| termWrite(at, b); } } }, .pty_read => |pr| { defer gpa.free(pr.bytes); if (terms[pr.id]) |t| t.stream.nextSlice(pr.bytes); }, .pty_eof => |id| { if (terms[id]) |t| { layoutRemove(&layout, id); t.deinit(io, gpa); gpa.destroy(t); terms[id] = null; // refocus the first surviving terminal; quit if none remain. if (active == id) active = for (terms, 0..) |s, i| { if (s != null) break i; } else break; } }, .winsize => |ws| { resize_count += 1; screen_w = ws.cols; screen_h = ws.rows; try vx.resize(gpa, tty.writer(), ws); }, .quit => break, else => {}, } // recompute geometry from the (possibly mutated) layout, then push any // grid-size change to each emulator + pty. Weights only change on a drag // *release*, so mid-drag the rects are unchanged and nothing resizes. computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w); for (&terms, 0..) |*slot, id| { if (slot.*) |t| { const r = rects[id]; const cols = @max(1, r.w -| GUTTER); const rows = @max(1, r.h -| BOX_H); // the tag steals the top row if (cols != t.cols or rows != t.rows) { t.term.resize(gpa, cols, rows) catch {}; pty_set_size(t.pty, rows, cols); t.cols = cols; t.rows = rows; } } } // ---- render ---- const win = vx.window(); win.clear(); for (&terms, 0..) |*slot, id| { if (slot.*) |t| try renderPane(win, frame_arena.allocator(), t, rects[id], id == active); } // separators (the grips signal that borders are draggable) for (0..layout.ncol) |c| { if (c + 1 < layout.ncol) { const sx = col_x[c] + col_w[c]; const mid = screen_h / 2; var ry: u16 = 0; while (ry < screen_h) : (ry += 1) { const g: []const u8 = if (ry == mid) "⇔" else "│"; win.writeCell(sx, ry, .{ .char = .{ .grapheme = g }, .style = .{ .fg = .{ .rgb = gray.dim } } }); } } const n = layout.col_n[c]; var k: usize = 0; while (k + 1 < n) : (k += 1) { const r = rects[layout.col_terms[c][k]]; const sy = r.y + r.h; const midx = col_x[c] + col_w[c] / 2; var rx: u16 = col_x[c]; while (rx < col_x[c] + col_w[c]) : (rx += 1) { const g: []const u8 = if (rx == midx) "⇕" else "─"; win.writeCell(rx, sy, .{ .char = .{ .grapheme = g }, .style = .{ .fg = .{ .rgb = gray.dim } } }); } } } // dashed preview while dragging; commit happens on release. switch (drag) { .border_v => |d| { var ry: u16 = 0; while (ry < screen_h) : (ry += 1) win.writeCell(d.cur_x, ry, .{ .char = .{ .grapheme = "╎" }, .style = .{ .fg = .{ .rgb = gray.lit } } }); }, .border_h => |d| { const c = d.col; var rx: u16 = col_x[c]; while (rx < col_x[c] + col_w[c]) : (rx += 1) win.writeCell(rx, d.cur_y, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = gray.lit } } }); }, .move => |d| { // insertion indicator: dashed line at cur_y across the target column var tc: usize = if (layout.ncol > 0) layout.ncol - 1 else 0; for (0..layout.ncol) |c| { if (d.cur_x >= col_x[c] and d.cur_x < col_x[c] + col_w[c]) { tc = c; break; } } var rx: u16 = col_x[tc]; while (rx < col_x[tc] + col_w[tc]) : (rx += 1) win.writeCell(rx, d.cur_y, .{ .char = .{ .grapheme = "╌" }, .style = .{ .fg = .{ .rgb = gray.mid } } }); win.writeCell(d.cur_x, d.cur_y, .{ .char = .{ .grapheme = "▌" }, .style = .{ .fg = .{ .rgb = gray.mid } } }); }, else => {}, } // debug overlay: a small virtual-text box drawn last, on top of everything. // ov_buf lives across vx.render() — vaxis keeps slices into it (retained). if (terms[active]) |at| { var nlive: usize = 0; for (terms) |slot| { if (slot != null) nlive += 1; } const sb = at.term.screens.active.pages.scrollbar(); const s = std.fmt.bufPrint( &ov_buf, "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}", .{ resize_count, nlive, layout.ncol, active, at.cols, at.rows, sb.offset, sb.total }, ) catch ""; const ow: u16 = 26; const oh: u16 = 6; const ox: u16 = if (screen_w > ow) screen_w - ow else 0; const ov = win.child(.{ .x_off = ox, .y_off = 0, .width = ow, .height = oh, .border = .{ .where = .all } }); ov.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = gray.bar } } }); _ = ov.printSegment(.{ .text = s, .style = .{ .fg = .{ .rgb = gray.txt } } }, .{}); } try vx.render(tty.writer()); _ = frame_arena.reset(.retain_capacity); } } // On box-drag release: drop the window into the column under cur_x at the // vertical slot picked by cur_y (acme's coldragwin, minus the live retile). fn moveTerm( l: *Layout, rects: *const [MAX_TERMS]Rect, col_x: *const [MAX_COLS]u16, col_w: *const [MAX_COLS]u16, id: usize, cur_x: u16, cur_y: u16, ) void { const src = layoutFindTerm(l, id) orelse return; // target column under cur_x (last column absorbs anything past the right edge) var tc: usize = if (l.ncol > 0) l.ncol - 1 else 0; for (0..l.ncol) |c| { if (cur_x >= col_x[c] and cur_x < col_x[c] + col_w[c]) { tc = c; break; } } // insertion index in the target column, ignoring the moving window itself var ti: usize = 0; var k: usize = 0; while (k < l.col_n[tc]) : (k += 1) { const cid = l.col_terms[tc][k]; if (cid == id) continue; const rr = rects[cid]; if (cur_y < rr.y + rr.h / 2) break; ti += 1; } const src_solo = l.col_n[src.col] == 1; if (tc == src.col and src_solo) return; // dragging the only window of its column: nothing to do layoutRemove(l, id); if (src_solo and src.col < tc) tc -= 1; // removing src dropped a column to our left if (tc >= l.ncol) tc = l.ncol - 1; if (ti > l.col_n[tc]) ti = l.col_n[tc]; layoutInsert(l, tc, ti, id); } // index of the run anchored exactly at (row, col), if any. fn runAt(t: *Term, row: i32, col: i32) ?usize { for (t.edits[0..t.nedits], 0..) |e, i| if (e.row == row and e.col == col) return i; return null; } // rendered column of a position: original col + earlier-on-row insertions + the // offset into the run at this col. (horizontal only; scrolling doesn't matter.) fn renderedCol(t: *Term, row: i32, col: i32, off: u16) i32 { var rc: i32 = col + @as(i32, off); for (t.edits[0..t.nedits]) |e| if (e.row == row and e.col < col) { rc += @as(i32, e.len); }; return rc; } // Splice this row's insertion runs into `line` (the raw tty text for absolute // row `abs_row`), shifting the underlying text right rather than overwriting it. fn spliceRow(alloc: std.mem.Allocator, out: *std.ArrayList(u8), t: *Term, abs_row: i32, line: []const u8) !void { var idxs: [Term.MAX_EDITS]usize = undefined; var n: usize = 0; for (t.edits[0..t.nedits], 0..) |e, i| if (e.row == abs_row and e.len > 0) { idxs[n] = i; n += 1; }; // insertion-sort the runs by column (n is tiny) var a: usize = 1; while (a < n) : (a += 1) { const key = idxs[a]; var b = a; while (b > 0 and t.edits[idxs[b - 1]].col > t.edits[key].col) : (b -= 1) idxs[b] = idxs[b - 1]; idxs[b] = key; } var vpos: usize = 0; // original-line columns consumed so far for (idxs[0..n]) |i| { const e = &t.edits[i]; const c: usize = @intCast(@max(0, e.col)); if (c <= line.len) { if (c > vpos) try out.appendSlice(alloc, line[vpos..c]); vpos = c; } else { // run anchored past the text: emit the rest, then pad with spaces if (vpos < line.len) { try out.appendSlice(alloc, line[vpos..]); vpos = line.len; } while (vpos < c) : (vpos += 1) try out.append(alloc, ' '); } try out.appendSlice(alloc, e.text[0..e.len]); } if (vpos < line.len) try out.appendSlice(alloc, line[vpos..]); } // The terminal body with all insertion runs spliced in, newline-joined. This is // the actual on-screen text: render and selection both read it, so there's no // tty-vs-typed distinction. Caller frees. fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 { const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset); const body = try t.term.plainString(alloc); defer alloc.free(body); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(alloc); // pad to the full grid height: plainString trims trailing blank rows, but we // must still be able to type (and splice) onto those blank rows. var it = std.mem.splitAny(u8, body, "\n"); const nrows: i32 = @intCast(t.rows); var i: i32 = 0; while (i < nrows) : (i += 1) { if (i > 0) try out.append(alloc, '\n'); const line = it.next() orelse ""; try spliceRow(alloc, &out, t, i + off, line); // line i shows absolute row i+off } return out.toOwnedSlice(alloc); } // Block-selected text, newline-joined per row; caller frees. Reads the rendered // screen (tag at visual row 0, spliced body below), so selecting typed text and // tty text works identically. fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 { const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1))); const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1))); const tag = try tagLine(gpa, t); defer gpa.free(tag); const body = try bodyText(gpa, t); defer gpa.free(body); const pane = try std.fmt.allocPrint(gpa, "{s}\n{s}", .{ tag, body }); defer gpa.free(pane); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); var it = std.mem.splitAny(u8, pane, "\n"); var v: i32 = 0; var first = true; while (it.next()) |line| : (v += 1) { if (v < r0 or v > r1) continue; if (!first) try out.append(gpa, '\n'); first = false; const b0 = @min(c0, line.len); const b1 = @min(c1 + 1, line.len); try out.appendSlice(gpa, line[b0..b1]); } return out.toOwnedSlice(gpa); } // Decide what a selection means. Middle button: a builtin name (comptime-matched // against `builtins`) runs that builtin; anything else is sent to the terminal. fn onSelect(button: Button, text: []const u8) SelectAction { if (button != .middle) return .none; const cmd = std.mem.trim(u8, text, " \t\r\n"); if (cmd.len == 0) return .none; inline for (builtins) |b| { if (std.mem.eql(u8, cmd, b.name)) return b.action; } return .send; }