const std = @import("std"); const vaxis = @import("vaxis"); const ghostty_vt = @import("ghostty-vt"); const Term = @import("term.zig"); const modal = @import("modal.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; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; // 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; }; // Modal yank register: gpa-owned, freed on overwrite / app exit. Helix-style // `y` yanks the (line) selection into here; `p` pastes it after the cursor. var yank_reg: ?[]u8 = null; fn freeYank(gpa: std.mem.Allocator) void { if (yank_reg) |y| gpa.free(y); yank_reg = null; } fn setYank(gpa: std.mem.Allocator, text: []const u8) void { freeYank(gpa); yank_reg = gpa.dupe(u8, text) catch null; } 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 PREFIX_W: u16 = 5; // file-pane line-number prefix width: "%4d " 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 }; // resize-border hover tint: a teal bar makes the (invisible) drag zones discoverable. const hover_bg = [3]u8{ 0x3a, 0x70, 0x70 }; // 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: distinct (muted) colors so left/middle/right // selections are easy to tell apart against the otherwise-monochrome chrome. const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } }; const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 }; // modal line-selection (helix `x`) highlight: a warm tan so it reads as // keyboard-driven, distinct from the mouse selections above. const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e }; const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 }; // 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, tutor }; const Builtin = struct { name: []const u8, action: SelectAction }; const builtins = [_]Builtin{ .{ .name = "Newcol", .action = .newcol }, .{ .name = "Delcol", .action = .delcol }, .{ .name = "Del", .action = .del }, .{ .name = "Tutor", .action = .tutor }, }; const builtins_str = blk: { var s: []const u8 = ""; for (builtins, 0..) |b, i| s = s ++ (if (i == 0) "" else " ") ++ b.name; break :blk s; }; // Path of the generated bash rc that adds OSC 133 semantic prompts (shell // integration). bash --rcfile reads it for interactive shells; it sources the // user's ~/.bashrc first, then wraps PS1 so each prompt emits 133;A..B. That // marks the prompt rows so we can hide them in text mode. The A marker also // carries cl=line, opting in to ghostty's promptClickMove (arrow-key // repositioning of the shell cursor within the typed input) so enterTty can // land the shell cursor where the modal cursor navigated (start vs end). const rc_path = "/tmp/pardes-osc133.bash"; const bash_rc = \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" \\trap 'printf "\e]133;C\a"' DEBUG \\ ; fn writeFile(path: [*:0]const u8, data: []const u8) void { const l = std.os.linux; const rc = l.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); const sfd: isize = @bitCast(rc); if (sfd < 0) return; const fd: i32 = @intCast(sfd); defer _ = l.close(fd); var off: usize = 0; while (off < data.len) { const w = l.write(fd, data[off..].ptr, data.len - off); const sw: isize = @bitCast(w); if (sw <= 0) break; off += @intCast(sw); } } fn fork_pty(argv: []const ?[*:0]const u8, cwd: ?[*: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) { if (cwd) |c| _ = chdir(c); // start the shell in this directory var child_argv: [8]?[*:0]const u8 = @splat(null); for (argv, 0..) |a, i| child_argv[i] = a; _ = execv(argv[0].?, @ptrCast(&child_argv)); _exit(127); } return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; } fn termWrite(t: *Term, bytes: []const u8) void { if (t.pty.handle < 0) return; // file pane: no pty var off: usize = 0; while (off < bytes.len) { const rc = std.os.linux.write(t.pty.handle, bytes[off..].ptr, bytes.len - off); // raw syscall: -errno, not the libc -1 sentinel. Check the sign. const n: isize = @bitCast(rc); if (n < 0) { if (n == -@as(isize, @intFromEnum(std.os.linux.E.INTR))) continue; break; } off += @intCast(n); } } fn fileLineCount(content: []const u8) usize { return std.mem.count(u8, content, "\n") + 1; } // the pane's current scroll offset (top visible row, in body coordinates). For a // file it's `scroll`; for a terminal it's the emulator's scrollback offset. fn paneScroll(t: *Term) i32 { if (t.file) |f| return @intCast(f.scroll); return @intCast(t.term.screens.active.pages.scrollbar().offset); } fn paneScrollBy(t: *Term, delta: i32) void { if (t.file) |*f| { const max: i64 = @intCast(fileLineCount(f.content) -| 1); const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); f.scroll = @intCast(n); } else { t.term.screens.active.scroll(.{ .delta_row = @intCast(delta) }); } } // Before running a composed command in a terminal, push the shell down so its // output lands BELOW the last row the user typed overlay text on. The shell owns // its cursor, so we move it the only way we can: emit newlines to the pty until // the prompt sits at max(last-edit row, the shell's current prompt row). The empty // prompts this creates are hidden in text mode (promptFlags), so the gap reads as // blank padding and the output flows right under the composed text. No-op unless // the shell is at a prompt (don't inject newlines into a running program). // ponytail: pad caps at the grid height — you can't click/type past the pane. fn padOutputBelowEdits(t: *Term) void { if (t.file != null or t.nedits == 0) return; if (!t.term.cursorIsAtPrompt()) return; var max_row: i32 = std.math.minInt(i32); for (t.edits[0..t.nedits]) |e| { if (e.len > 0 and e.row > max_row) max_row = e.row; } if (max_row == std.math.minInt(i32)) return; const cursor_abs: i32 = paneScroll(t) + @as(i32, @intCast(t.term.screens.active.cursor.y)); const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, t.rows)); var i: i32 = 0; while (i < pad) : (i += 1) termWrite(t, "\r"); } // The shell's current working directory (via /proc//cwd). `buf` holds it. // A raw linux syscall returns -errno; check the sign directly. (std.posix.errno // is the LIBC convention here — `rc == -1` — and we link libc, so it misreads a // raw -2/ENOENT as success and we'd slice buf[0..~2^64]. file panes (pid 0) and // exited shells both hit this.) fn termCwd(t: *Term, buf: []u8) []const u8 { if (t.file != null) return "?"; // file pane: no shell, no cwd var pathbuf: [64]u8 = undefined; const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch return "?"; const rc = std.os.linux.readlinkat(std.os.linux.AT.FDCWD, path, buf.ptr, buf.len); const n: isize = @bitCast(rc); if (n < 0) return "?"; return buf[0..@intCast(n)]; } // True if `path` exists and is a directory. open(O_DIRECTORY) fails with ENOTDIR // for anything else, so no stat struct needed. fn isDir(path: [*:0]const u8) bool { const l = std.os.linux; const rc = l.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0); const sfd: isize = @bitCast(rc); if (sfd < 0) return false; _ = l.close(@intCast(sfd)); return true; } // 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 { // a file pane's tag: mode + path (+ builtins); no cwd. Files never reach tty. if (t.file) |f| { const fmode = if (t.mode == .insert) "in" else "nm"; return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ fmode, f.path, builtins_str }); } var link: [1024]u8 = undefined; const cwd = termCwd(t, &link); // leading 2-char mode indicator: "nm" (normal), "in" (insert) or "sy" (sym/tty). const mode = switch (t.mode) { .normal => "nm", .insert => "in", .tty => "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, cwd: ?[*:0]const u8) !*Term { const argv = [_]?[*:0]const u8{ "/usr/bin/bash", "--rcfile", rc_path }; const child = fork_pty(&argv, cwd, .{ .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, .max_scrollback = 16 * 1024 * 1024 }), .stream = undefined, .reader = .{ .any_future = null, .result = {} }, // no-op until startReader .cols = init_cols, .rows = init_rows, }; t.stream = t.term.vtStream(); return t; } // peel a trailing :NNN (or :NNN:) line suffix: main.zig:100 -> {main.zig, 100}. fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } { var sep: usize = 0; while (sep < tok.len) : (sep += 1) { if (tok[sep] != ':') continue; var j = sep + 1; var line: usize = 0; while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0'); if (j == sep + 1) continue; // no digits after ':' if (j < tok.len and tok[j] != ':') continue; // junk after the number return .{ .path = tok[0..sep], .line = line }; } return .{ .path = tok, .line = 0 }; } // slurp a whole file via linux syscalls (kept flat; no std.fs). Caller frees. fn readFileAlloc(gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 { const l = std.os.linux; const rc = l.open(path, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0); const sfd: isize = @bitCast(rc); if (sfd < 0) return error.OpenFailed; const fd: i32 = @intCast(sfd); defer _ = l.close(fd); var buf: std.ArrayList(u8) = .empty; errdefer buf.deinit(gpa); var chunk: [64 * 1024]u8 = undefined; while (true) { const rrc = l.read(fd, &chunk, chunk.len); // raw syscall: -errno, not the libc -1 sentinel. Check the sign. const n: isize = @bitCast(rrc); if (n < 0) { if (n == -@as(isize, @intFromEnum(l.E.INTR))) continue; return error.ReadFailed; } if (n == 0) break; try buf.appendSlice(gpa, chunk[0..@intCast(n)]); } return buf.toOwnedSlice(gpa); } // Open a read/editable FILE pane: a Term with `file` set, sharing all the // layout/selection/edit machinery. No pty (handle -1), no reader, a stub 1x1 // emulator (only its allocator-owned bits are touched). `line` (1-based, 0=none) // scrolls the target near the top. Caller wires it into the layout + frees later. fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u16, rows: u16) !*Term { const content = try readFileAlloc(gpa, rp); errdefer gpa.free(content); const path = try gpa.dupe(u8, std.mem.span(rp)); errdefer gpa.free(path); const t = try gpa.create(Term); errdefer gpa.destroy(t); const total = fileLineCount(content); const scroll: usize = if (line > 0 and line <= total) line - 1 else 0; t.* = .{ .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } }, .pid = 0, .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }), .stream = undefined, .reader = .{ .any_future = null, .result = {} }, .file = .{ .path = path, .content = content, .scroll = scroll }, .cols = cols, .rows = rows, .cur_pinned = true, .cur_row = @intCast(scroll), }; t.stream = t.term.vtStream(); return t; } // Open the built-in Tutor as a read/editable FILE pane: same machinery as // openFileView, but the content is the tutor text embedded at comptime (so the // binary is self-contained — no external file needed) and the path is a fixed // label. Middle-clicking "Tutor" in any tag spawns this. Caller wires it into // the layout + frees later. const tutor_text = @embedFile("tutor.txt"); fn openTutorView(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Term { const content = try gpa.dupe(u8, tutor_text); errdefer gpa.free(content); const path = try gpa.dupe(u8, "/Tutor"); errdefer gpa.free(path); const t = try gpa.create(Term); errdefer gpa.destroy(t); t.* = .{ .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } }, .pid = 0, .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }), .stream = undefined, .reader = .{ .any_future = null, .result = {} }, .file = .{ .path = path, .content = content, .scroll = 0 }, .cols = cols, .rows = rows, .cur_pinned = true, .cur_row = 0, }; 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; } // Before deleting `id`, hand its vertical weight to a sibling (the pane above, or // below if it's the first) so the freed space goes to ONE neighbor and the rest of // the column keeps its sizes — the deletion mirror of splitBelow. Call BEFORE // layoutRemove/deinit, while `id` is still in the layout and not yet freed. No-op // when `id` is alone in its column (the column just closes). fn absorbVWeight(l: *const Layout, terms: *const [MAX_TERMS]?*Term, id: usize) void { const f = layoutFindTerm(l, id) orelse return; if (l.col_n[f.col] <= 1) return; const gone = terms[id] orelse return; const sib = if (f.idx > 0) l.col_terms[f.col][f.idx - 1] else l.col_terms[f.col][f.idx + 1]; if (terms[sib]) |s| s.vweight += gone.vweight; } 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; } // Open a window BELOW `src` (acme-style) without rebalancing the whole column: // shrink ONLY `src` to the height of its content so far — and hand the freed // rows to the new window `nw`. `src`+`nw` together fill `src`'s old slot, so the // other panes in the column keep their proportions. `src_h` is `src`'s height. // // `keep` INCLUDES the cursor row (`cursor.y + 1`): shrinking to exactly the // content height means ghostty only drops blank rows *below* the cursor, so the // source's visible text doesn't scroll. Cutting at `cursor.y` would evict the // cursor's row and force a 1-row scroll to keep it visible. fn splitBelow(src: *Term, nw: *Term, src_h: u16) void { const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.term.screens.active.cursor.y)) + 1; const keep = std.math.clamp(cur, 1, @max(1, body -| 1)); // both panes get >=1 row const w = src.vweight; src.vweight = w * @as(f32, @floatFromInt(BOX_H + keep)) / @as(f32, @floatFromInt(src_h)); nw.vweight = w - src.vweight; } // A pristine terminal: a shell nobody has touched — no char-mode typing, the // cursor still on its first prompt line, and no scrollback. Used to recognize a // throwaway placeholder shell that a file should replace. (A file pane is never // pristine — it isn't a terminal.) fn isPristineTty(t: *Term) bool { if (t.file != null) return false; if (t.nedits != 0) return false; if (t.term.screens.active.cursor.y != 0) return false; return t.term.screens.active.pages.scrollbar().total <= t.rows; } // When a file lands in `col` (the files column), a leftover UNUSED shell there is // just clutter: if the column holds exactly one terminal and it's pristine, delete // it. Its weight flows to a neighbor via absorbVWeight (typically the file), so the // file takes its place. `keep_id` is the file, used to refocus if the shell was active. fn evictLonePristineTty(l: *Layout, terms: *[MAX_TERMS]?*Term, io: std.Io, gpa: std.mem.Allocator, col: usize, keep_id: usize, active: *usize) void { var n_tty: usize = 0; var tty_id: usize = 0; var k: usize = 0; while (k < l.col_n[col]) : (k += 1) { const cid = l.col_terms[col][k]; if (terms[cid]) |ct| if (ct.file == null) { n_tty += 1; tty_id = cid; }; } if (n_tty != 1) return; const tt = terms[tty_id] orelse return; if (!isPristineTty(tt)) return; absorbVWeight(l, terms, tty_id); layoutRemove(l, tty_id); tt.deinit(io, gpa); gpa.destroy(tt); terms[tty_id] = null; if (active.* == tty_id) active.* = keep_id; } // Find a terminal whose shell cwd is `dir`, else fork a fresh shell there and // stack it at the bottom of the rightmost column. Returns the terminal to drive, // or null if no slot is free / the fork failed. Backs middle-click "send" from a // file pane: the selected line runs in a shell in the file's dir. Does NOT change // focus — middle/execute keeps you where you were editing (right/look focuses). // ponytail: rightmost column; with a single column the tty stacks under the file. fn ttyForDir( io: std.Io, gpa: std.mem.Allocator, loop: *Loop, terms: *[MAX_TERMS]?*Term, layout: *Layout, sw: u16, sh: u16, dir: []const u8, ) ?*Term { for (terms.*) |slot| if (slot) |tt| { if (tt.file != null) continue; var cb: [1024]u8 = undefined; if (std.mem.eql(u8, termCwd(tt, &cb), dir)) return tt; }; const free: usize = for (terms.*, 0..) |slot, i| { if (slot == null) break i; } else return null; var dirbuf: [1024]u8 = undefined; const dirz = std.fmt.bufPrintZ(&dirbuf, "{s}", .{dir}) catch return null; const nt = forkTerm(gpa, sw, sh, dirz.ptr) catch return null; terms[free] = nt; startReader(io, gpa, loop, nt, free) catch {}; const rc = if (layout.ncol > 0) layout.ncol - 1 else 0; layoutInsert(layout, rc, layout.col_n[rc], free); return nt; } // 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 }); // body = tty rows with insertion runs spliced in (text shifts, not overdrawn). // In text mode bodyText also hides OSC 133 prompt rows (see promptFlags); the // shells emit OSC 133 via the rc we drop at startup (writeFile/bash_rc). const off: i32 = paneScroll(t); 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); } } } } // modal line selection (helix `x`): highlight the selected body rows across // the text width. Only in normal mode; r0/r1 are absolute body rows. if (t.mode == .normal and t.msel.active) { const lo = @min(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H); const hi = @max(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H); var row: u16 = 0; while (row < r.h) : (row += 1) { if (@as(i32, row) < lo or @as(i32, row) > hi) continue; var col: u16 = 0; while (col < r.w - GUTTER) : (col += 1) { if (text.readCell(col, row)) |cell| { var nc = cell; nc.default = false; nc.style.bg = .{ .rgb = msel_bg }; nc.style.fg = .{ .rgb = msel_fg }; text.writeCell(col, row, nc); } } } } if (active) { if (t.mode != .tty) { // text mode (normal/insert): cursor at the (scroll-adjusted) edit // position. until the user pins it (click/type) it tracks the shell cursor. const crow = if (t.cur_pinned) t.cur_row else @as(i32, @intCast(t.term.screens.active.cursor.y)) + off; const ccol = if (t.cur_pinned) t.cur_col else @as(i32, @intCast(t.term.screens.active.cursor.x)); const coff = if (t.cur_pinned) t.cur_off else 0; const prow = crow - off + @as(i32, BOX_H); // insert mode uses the edit-run coordinate -> visual mapping; normal // mode keeps cur_col already-visual; file panes add the line-number prefix. const cx = if (t.file != null) @as(i32, PREFIX_W) + ccol + @as(i32, coff) else if (t.mode == .insert) renderedCol(t, crow, ccol, coff) else ccol; 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 track_h: usize = if (r.h > BOX_H) r.h - BOX_H else 0; if (track_h > 0) { // scrollbar thumb: from file lines for a file pane, else the emulator's. var sb_total: usize = undefined; var sb_off: usize = undefined; var sb_len: usize = undefined; if (t.file) |f| { sb_total = fileLineCount(f.content); sb_off = f.scroll; sb_len = t.rows; } else { const sb = t.term.screens.active.pages.scrollbar(); sb_total = sb.total; sb_off = sb.offset; sb_len = sb.len; } const total: usize = if (sb_total == 0) 1 else sb_total; const pos = (track_h * sb_off) / 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 } } }); } } } // ---- modal editing helpers (wired onto Term by the key handler) ---- const PaneLines = struct { lines: []const []const u8, row0: i32, // absolute row of lines[0]; cursor lines-row = cur_row - row0 body: ?[]u8, // terminal only: bodyText temp; arena-owned, freed by caller }; // The lines the cursor moves over. File: all content lines (row0 = 0). Terminal: // the viewport rows (row0 = scroll offset). `alloc` is per-frame scratch. fn paneCursorLines(alloc: std.mem.Allocator, t: *Term) !PaneLines { if (t.file) |f| { var ls: std.ArrayList([]const u8) = .empty; var it = std.mem.splitScalar(u8, f.content, '\n'); while (it.next()) |ln| try ls.append(alloc, ln); return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = 0, .body = null }; } const body = try bodyText(alloc, t); var ls: std.ArrayList([]const u8) = .empty; var it = std.mem.splitAny(u8, body, "\n"); while (it.next()) |ln| try ls.append(alloc, ln); return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = paneScroll(t), .body = body }; } fn freePaneLines(alloc: std.mem.Allocator, pl: PaneLines) void { alloc.free(pl.lines); if (pl.body) |b| alloc.free(b); } fn toModalCursor(t: *Term, pl: PaneLines) modal.Cursor { const r: i32 = t.cur_row - pl.row0; return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, t.cur_col)) }; } // write a modal Cursor back to the Term (absolute row, pin, clear run offset) fn fromModalCursor(t: *Term, pl: PaneLines, c: modal.Cursor) void { t.cur_row = @as(i32, @intCast(c.row)) + pl.row0; t.cur_col = @intCast(c.col); t.cur_off = 0; t.cur_pinned = true; } // apply a motion result: move cursor, collapse any line selection, keep it visible fn applyMove(t: *Term, pl: PaneLines, c: modal.Cursor) void { fromModalCursor(t, pl, c); t.msel.active = false; t.pending = 0; ensureCursorVisible(t); } fn ensureCursorVisible(t: *Term) void { const off = paneScroll(t); const last = off + @as(i32, t.rows) - 1; if (t.cur_row < off) paneScrollBy(t, t.cur_row - off) else if (t.cur_row > last) paneScrollBy(t, t.cur_row - last); } fn alignViewportTop(t: *Term) void { paneScrollBy(t, t.cur_row - paneScroll(t)); } fn alignViewportBottom(t: *Term) void { paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows) - 1)); } fn alignViewportCenter(t: *Term) void { paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows / 2))); } // pin the cursor to the shell's current cursor (terminal) or the scroll top // (file), the first time the user interacts in normal/insert mode. fn pinCursor(t: *Term) void { if (t.cur_pinned) return; const off: i32 = paneScroll(t); if (t.file != null) { t.cur_row = off; t.cur_col = 0; } else { t.cur_row = @as(i32, @intCast(t.term.screens.active.cursor.y)) + off; t.cur_col = @intCast(t.term.screens.active.cursor.x); } t.cur_off = 0; t.cur_pinned = true; } // the last navigable row: file -> last content line; terminal -> viewport bottom. fn lastNavRow(t: *Term) i32 { if (t.file) |f| return @intCast(modal.lineCount(f.content) -| 1); return paneScroll(t) + @as(i32, t.rows) - 1; } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; // enter insert mode, positioning the cursor per `where`. For file panes, // open_below/open_above splice a newline into the content; terminals just move. fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, where: InsertAt) void { pinCursor(t); const pl = paneCursorLines(talloc, t) catch { t.mode = .insert; t.msel.active = false; t.pending = 0; return; }; defer freePaneLines(talloc, pl); const cur = toModalCursor(t, pl); const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0; switch (where) { .at => {}, .append => fromModalCursor(t, pl, modal.charRight(pl.lines, cur)), .line_start => fromModalCursor(t, pl, modal.firstNonWsOf(pl.lines, cur)), .line_end => { t.cur_col = @intCast(llen); t.cur_pinned = true; }, .open_below => { if (t.file) |*f| { const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return; gpa.free(f.content); f.content = new; t.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0; t.cur_col = 0; } else { const bottom = paneScroll(t) + @as(i32, t.rows) - 1; t.cur_row = @min(bottom, t.cur_row + 1); t.cur_col = 0; } t.cur_off = 0; t.cur_pinned = true; }, .open_above => { if (t.file) |*f| { const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return; gpa.free(f.content); f.content = new; // current line shifts down; the new empty line is at cur.row t.cur_col = 0; } else { const top = paneScroll(t); t.cur_row = @max(top, t.cur_row - 1); t.cur_col = 0; } t.cur_off = 0; t.cur_pinned = true; }, } // run-offset for the terminal insert model: `i` inserts before the run at the // cursor (off 0); the others append after any run already sitting there. t.cur_off = if (where == .at) 0 else if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; t.mode = .insert; t.msel.active = false; t.pending = 0; ensureCursorVisible(t); } // helix `x`: extend the line selection down by one (or start it at the cursor). fn lineSelect(t: *Term) void { if (!t.msel.active) { t.msel = .{ .active = true, .r0 = t.cur_row, .r1 = t.cur_row }; } else { const last = lastNavRow(t); t.msel.r1 = @min(t.msel.r1 + 1, last); t.cur_row = t.msel.r1; } t.cur_col = 0; t.cur_off = 0; t.cur_pinned = true; t.pending = 0; } // drop all insertion runs whose row is in [r0, r1] (terminal delete/change). fn clearEditsInRange(t: *Term, r0: i32, r1: i32) void { var w: usize = 0; for (t.edits[0..t.nedits]) |e| { if (e.row >= r0 and e.row <= r1) continue; t.edits[w] = e; w += 1; } t.nedits = w; } // join the rendered body rows [r0, r1] (absolute) with '\n'. `alloc` is scratch; // caller (setYank) copies it into the gpa-owned register before the frame ends. fn yankRows(alloc: std.mem.Allocator, t: *Term, r0: i32, r1: i32) []const u8 { const body = bodyText(alloc, t) catch return ""; defer alloc.free(body); const off = paneScroll(t); var out: std.ArrayList(u8) = .empty; var it = std.mem.splitAny(u8, body, "\n"); var i: i32 = 0; while (it.next()) |ln| : (i += 1) { const abs = i + off; if (abs < r0 or abs > r1) continue; if (abs > r0) out.append(alloc, '\n') catch return ""; out.appendSlice(alloc, ln) catch return ""; } return out.toOwnedSlice(alloc) catch ""; } fn normalDelete(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void { if (t.msel.active) { const r0 = @min(t.msel.r0, t.msel.r1); const r1 = @max(t.msel.r0, t.msel.r1); if (t.file) |*f| { const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch { t.msel.active = false; return; }; gpa.free(f.content); f.content = d.content; setYank(gpa, d.deleted); gpa.free(d.deleted); const n = modal.lineCount(f.content); t.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1)); t.cur_col = 0; } else { clearEditsInRange(t, r0, r1); setYank(gpa, yankRows(talloc, t, r0, r1)); } t.msel.active = false; t.cur_off = 0; t.cur_pinned = true; ensureCursorVisible(t); } else if (t.file) |*f| { const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }; const new = modal.deleteChar(gpa, f.content, c) catch return; gpa.free(f.content); f.content = new; } // terminal, no selection: can't delete shell output -> no-op } fn normalYank(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void { if (t.msel.active) { const r0 = @min(t.msel.r0, t.msel.r1); const r1 = @max(t.msel.r0, t.msel.r1); if (t.file) |*f| { var buf: std.ArrayList(u8) = .empty; var r: usize = @intCast(@max(0, r0)); const hi: usize = @intCast(@max(0, r1)); const lo: usize = @intCast(@max(0, r0)); while (r <= hi) : (r += 1) { if (r > lo) buf.append(gpa, '\n') catch break; buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break; } setYank(gpa, buf.items); buf.deinit(gpa); } else { setYank(gpa, yankRows(talloc, t, r0, r1)); } t.cur_row = r0; t.cur_col = 0; t.msel.active = false; } else if (t.file) |f| { setYank(gpa, modal.lineSlice(f.content, @intCast(@max(0, t.cur_row)))); } else { setYank(gpa, yankRows(talloc, t, t.cur_row, t.cur_row)); } } fn normalPaste(gpa: std.mem.Allocator, t: *Term) void { const y = yank_reg orelse return; if (t.file) |*f| { const new = modal.pasteLineBelow(gpa, f.content, @intCast(@max(0, t.cur_row)), y) catch return; gpa.free(f.content); f.content = new; t.cur_row += 1; t.cur_col = 0; } else { // terminal: spill the yanked text into an insertion run at the cursor insertRunText(t, y); } t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; t.cur_pinned = true; ensureCursorVisible(t); } fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void { if (t.msel.active) { const r0 = @min(t.msel.r0, t.msel.r1); const r1 = @max(t.msel.r0, t.msel.r1); if (t.file) |*f| { // keep one empty line at r0, drop the rest; yank the removed text var buf: std.ArrayList(u8) = .empty; var r: usize = @intCast(@max(0, r0)); const hi: usize = @intCast(@max(0, r1)); const lo: usize = @intCast(@max(0, r0)); while (r <= hi) : (r += 1) { if (r > lo) buf.append(gpa, '\n') catch break; buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break; } setYank(gpa, buf.items); buf.deinit(gpa); if (r1 > r0) { const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch { t.msel.active = false; return; }; gpa.free(f.content); f.content = d.content; gpa.free(d.deleted); } const cl = modal.clearLine(gpa, f.content, @intCast(@max(0, r0))) catch { t.msel.active = false; return; }; gpa.free(f.content); f.content = cl; t.cur_row = r0; t.cur_col = 0; } else { clearEditsInRange(t, r0, r1); setYank(gpa, yankRows(talloc, t, r0, r1)); t.cur_row = r0; t.cur_col = 0; } t.msel.active = false; t.cur_off = 0; t.cur_pinned = true; t.mode = .insert; t.pending = 0; ensureCursorVisible(t); } else if (t.file) |*f| { const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }; const new = modal.deleteChar(gpa, f.content, c) catch return; gpa.free(f.content); f.content = new; t.mode = .insert; t.cur_off = 0; t.pending = 0; } else { // terminal, no selection: just enter insert at the cursor t.mode = .insert; t.cur_off = 0; t.pending = 0; } } // insert `text` into the terminal run model at the cursor (the existing char-mode // splice). Factored out so `p`aste reuses it. fn insertRunText(t: *Term, text: []const u8) void { var idx: ?usize = runAt(t, t.cur_row, t.cur_col); if (idx == null and t.nedits < Term.MAX_EDITS) { t.edits[t.nedits] = .{ .row = t.cur_row, .col = t.cur_col }; t.nedits += 1; idx = t.nedits - 1; } if (idx) |ei| { const e = &t.edits[ei]; for (text) |ch| { if (e.len >= Term.EDIT_CAP) break; if (t.cur_off > e.len) t.cur_off = e.len; var k: usize = e.len; while (k > t.cur_off) : (k -= 1) e.text[k] = e.text[k - 1]; e.text[t.cur_off] = ch; e.len += 1; t.cur_off += 1; } } } // normal-mode (helix-style) key dispatch on a pane. The cursor is a block that // moves over the rendered text (file content lines, or the terminal viewport); // paneCursorLines builds that view per keystroke. Bindings (helix defaults, // adapted to the acme text model): // move h j k l (or arrows) word w b e / W B E (long) // line 0 $ ^ (or home/end) goto g g (top) G (last) g e g h g l // page Ctrl-d/u (half) Ctrl-f/b (full) PgUp/PgDn // viewport z t / z z / z b (top/center/bottom) // insert i a I A o O (at/append/line-start/line-end/below/above) // select line x (extend down) delete d change c yank y paste p // chord Enter -> acme look (open file/dir) Tab -> acme execute (run word) // mode Esc -> insert exits to normal; Ctrl-b -> raw tty (terminals) // window Ctrl-w h/j/k/l focus neighbor pane (Alt-n new term, Alt-c new col) // File panes mutate their content (real edits); terminals yank rendered text and // paste it as an insertion run (shell output can't be deleted, so d/c there only // clear pasted runs in the selection). Select mode (`v`, char ranges) is not yet // wired; use `x` for line-oriented selection. // Enter raw tty mode (the Ctrl-b toggle; terminals only). tty-native: if the shell is at a // prompt and the modal cursor sits on the current input line, move the shell's // REAL cursor there via ghostty's promptClickMove (sends left/right arrows the // shell understands) — so dropping into tty lands where you navigated: at the // prompt start when the input is empty, or at/after typed text at the end. Then // hand the keyboard to the pty. If the shell isn't at a prompt (outputting) or // the cursor is above the prompt, just switch — the shell cursor stays put. // Requires OSC 133 with cl=line (our bash_rc emits it). fn enterTty(t: *Term) void { const screen = t.term.screens.active; if (t.term.cursorIsAtPrompt()) { const off = paneScroll(t); const vp_row: i32 = t.cur_row - off; if (vp_row >= 0) { if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, t.cur_col)), .y = @intCast(vp_row) } })) |click_pin| { const cursor_pin = screen.cursor.page_pin.*; var pit = cursor_pin.promptIterator(.left_up, null); if (pit.next()) |prompt_pin| { if (!click_pin.before(prompt_pin)) { const m = screen.promptClickMove(click_pin); var i: usize = 0; while (i < m.left) : (i += 1) termWrite(t, "\x1b[D"); i = 0; while (i < m.right) : (i += 1) termWrite(t, "\x1b[C"); } } } } } t.mode = .tty; t.msel.active = false; t.pending = 0; } fn handleNormal(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void { pinCursor(t); const pl = paneCursorLines(talloc, t) catch return; defer freePaneLines(talloc, pl); const cur = toModalCursor(t, pl); const lines = pl.lines; const half: usize = @max(1, t.rows / 2); // prefix continuations if (t.pending == 'g') { t.pending = 0; if (key.matches('g', .{})) return applyMove(t, pl, modal.gotoFirst()); if (key.matches('e', .{})) return applyMove(t, pl, modal.gotoLast(lines)); if (key.matches('h', .{})) return applyMove(t, pl, modal.lineStart(cur)); if (key.matches('l', .{})) return applyMove(t, pl, modal.lineEnd(lines, cur)); return; } if (t.pending == 'z') { t.pending = 0; if (key.matches('t', .{})) return alignViewportTop(t); if (key.matches('z', .{})) return alignViewportCenter(t); if (key.matches('b', .{})) return alignViewportBottom(t); return; } if (key.matches('g', .{})) { t.pending = 'g'; return; } if (key.matches('z', .{})) { t.pending = 'z'; return; } // movement if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) return applyMove(t, pl, modal.charLeft(cur)); if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) return applyMove(t, pl, modal.charRight(lines, cur)); if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) return applyMove(t, pl, modal.lineDown(lines, cur)); if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) return applyMove(t, pl, modal.lineUp(lines, cur)); if (key.matches('w', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, false)); if (key.matches('b', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, false)); if (key.matches('e', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, false)); if (key.matches('W', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, true)); if (key.matches('B', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, true)); if (key.matches('E', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, true)); if (key.matches('0', .{}) or key.matches(vaxis.Key.home, .{})) return applyMove(t, pl, modal.lineStart(cur)); if (key.matches('$', .{}) or key.matches(vaxis.Key.end, .{})) return applyMove(t, pl, modal.lineEnd(lines, cur)); if (key.matches('^', .{})) return applyMove(t, pl, modal.firstNonWsOf(lines, cur)); if (key.matches('G', .{})) return applyMove(t, pl, modal.gotoLast(lines)); if (key.matches('d', .{ .ctrl = true }) or key.matches(vaxis.Key.page_down, .{})) return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, half))); if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.page_up, .{})) return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, half))); if (key.matches('f', .{ .ctrl = true })) return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, t.rows)))); if (key.matches('b', .{ .ctrl = true })) return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, t.rows)))); // insert entry if (key.matches('i', .{})) return enterInsert(gpa, talloc, t, .at); if (key.matches('a', .{})) return enterInsert(gpa, talloc, t, .append); if (key.matches('I', .{})) return enterInsert(gpa, talloc, t, .line_start); if (key.matches('A', .{})) return enterInsert(gpa, talloc, t, .line_end); if (key.matches('o', .{})) return enterInsert(gpa, talloc, t, .open_below); if (key.matches('O', .{})) return enterInsert(gpa, talloc, t, .open_above); // line selection + edit (helix `x` then d/c/y; p pastes) if (key.matches('x', .{})) return lineSelect(t); if (key.matches('d', .{})) return normalDelete(gpa, talloc, t); if (key.matches('c', .{})) return normalChange(gpa, talloc, t); if (key.matches('y', .{})) return normalYank(gpa, talloc, t); if (key.matches('p', .{})) return normalPaste(gpa, t); } // insert-mode key dispatch on a pane. File: real content editing; terminal: the // char-mode run splice (text shifts the underlying tty row right). fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void { if (t.file) |*f| { const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }; if (key.text) |text| { const new = modal.insertAt(gpa, f.content, c, text) catch return; gpa.free(f.content); f.content = new; for (text) |ch| { if (ch == '\n') { t.cur_row += 1; t.cur_col = 0; } else t.cur_col += 1; } t.cur_pinned = true; ensureCursorVisible(t); return; } switch (key.codepoint) { vaxis.Key.enter => { const new = modal.insertAt(gpa, f.content, c, "\n") catch return; gpa.free(f.content); f.content = new; t.cur_row += 1; t.cur_col = 0; t.cur_pinned = true; ensureCursorVisible(t); }, vaxis.Key.backspace => { if (t.cur_col > 0) { const new = modal.deleteChar(gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return; gpa.free(f.content); f.content = new; t.cur_col -= 1; } else if (t.cur_row > 0) { const prevlen = modal.lineSlice(f.content, c.row - 1).len; const new = modal.deleteChar(gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return; gpa.free(f.content); f.content = new; t.cur_row -= 1; t.cur_col = @intCast(prevlen); } t.cur_pinned = true; ensureCursorVisible(t); }, vaxis.Key.left, vaxis.Key.right, vaxis.Key.up, vaxis.Key.down => { const pl = paneCursorLines(talloc, t) catch return; defer freePaneLines(talloc, pl); const cur0 = toModalCursor(t, pl); const nc = switch (key.codepoint) { vaxis.Key.left => modal.charLeft(cur0), vaxis.Key.right => modal.charRight(pl.lines, cur0), vaxis.Key.up => modal.lineUp(pl.lines, cur0), vaxis.Key.down => modal.lineDown(pl.lines, cur0), else => cur0, }; fromModalCursor(t, pl, nc); ensureCursorVisible(t); }, else => {}, } return; } // terminal: char-mode run insert (unchanged from the original editor) if (!t.cur_pinned) pinCursor(t); if (key.text) |text| { insertRunText(t, text); } else switch (key.codepoint) { vaxis.Key.left => { if (t.cur_off > 0) t.cur_off -= 1 else { t.cur_col = @max(0, t.cur_col - 1); t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; } }, vaxis.Key.right => { const rl: u16 = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; if (t.cur_off < rl) t.cur_off += 1 else { t.cur_col += 1; t.cur_off = 0; } }, vaxis.Key.up => { const off = paneScroll(t); t.cur_row = @max(off, t.cur_row - 1); t.cur_off = 0; }, vaxis.Key.down => { const off = paneScroll(t); t.cur_row = @min(off + @as(i32, t.rows) - 1, t.cur_row + 1); t.cur_off = 0; }, else => {}, } } // tty mode: forward the key to the pty as terminal input. Ctrl+letter is sent as // the raw control byte (so the shell sees Ctrl-D EOF, Ctrl-L clear, ...). fn forwardToPty(t: *Term, key: vaxis.Key) void { var cbuf: [1]u8 = undefined; const bytes: ?[]const u8 = blk: { if (key.mods.ctrl) { const cp = key.codepoint; if (cp >= 'a' and cp <= 'z') { cbuf[0] = @intCast(cp - 0x60); break :blk cbuf[0..1]; } if (cp == '@') { cbuf[0] = 0; break :blk cbuf[0..1]; } if (cp >= '[' and cp <= '_') { cbuf[0] = @intCast(cp - 0x40); break :blk cbuf[0..1]; } if (cp >= 'A' and cp <= 'Z') { cbuf[0] = @intCast(cp - 0x40); break :blk cbuf[0..1]; } } if (key.text) |text| break :blk text; break :blk 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(t, b); } // move focus to the neighbor pane in `dir`, if any. Rects are the current tiling. fn focusDir(active: *usize, terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_TERMS]Rect, dir: enum { left, right, up, down }) void { const a = rects[active.*]; var best: ?usize = null; var best_d: i32 = 0; for (terms, 0..) |slot, i| { if (slot == null or i == active.*) continue; const r = rects[i]; const vov = a.y < r.y + r.h and r.y < a.y + a.h; // vertical overlap const hov = a.x < r.x + r.w and r.x < a.x + a.w; // horizontal overlap const ok = switch (dir) { .left => r.x + r.w <= a.x and vov, .right => r.x >= a.x + a.w and vov, .up => r.y + r.h <= a.y and hov, .down => r.y >= a.y + a.h and hov, }; if (!ok) continue; const d: i32 = switch (dir) { .left => a.x - (r.x + r.w), .right => r.x - (a.x + a.w), .up => a.y - (r.y + r.h), .down => r.y - (a.y + a.h), }; if (best == null or d < best_d) { best = i; best_d = d; } } if (best) |b| active.* = b; } // Perform an acme button-2/3 chord with `txt` as the text, from pane `id`: // .right = "look" (open the file/dir under the word, peeling a :line suffix), // .middle = "execute" (a builtin, else run the text in a shell). Shared by the // mouse release and the normal-mode Enter (look) / Tab (execute) keys. Returns // true if the app should quit (the last pane was closed). fn actOnSelection( io: std.Io, gpa: std.mem.Allocator, loop: *Loop, terms: *[MAX_TERMS]?*Term, layout: *Layout, rects: *const [MAX_TERMS]Rect, active: *usize, sw: u16, sh: u16, button: Button, id: usize, txt: []const u8, ) bool { const t = terms[id] orelse return false; if (button == .right) { // acme "look": resolve the selected word (peeling a :line suffix) relative // to the window's dir. A directory opens/focuses a terminal there + ls; a // file opens/focuses a file pane scrolled to :line. const word_raw = std.mem.trim(u8, txt, " \t\r\n"); const pl = parsePathLine(word_raw); const word = pl.path; var cwdbuf: [1024]u8 = undefined; var joinbuf: [2048]u8 = undefined; var realbuf: [4096]u8 = undefined; const cwd = if (t.file) |f| (std.fs.path.dirname(f.path) orelse "/") else termCwd(t, &cwdbuf); const joined: ?[:0]u8 = if (word.len == 0) null else if (word[0] == '/') (std.fmt.bufPrintZ(&joinbuf, "{s}", .{word}) catch null) else (std.fmt.bufPrintZ(&joinbuf, "{s}/{s}", .{ cwd, word }) catch null); if (joined) |jz| if (realpath(jz.ptr, &realbuf)) |rp| if (isDir(rp)) { const dir = std.mem.span(rp); // focus an existing terminal on this dir var found: ?usize = null; for (terms, 0..) |*slot, i| if (slot.*) |tt| { var cb: [1024]u8 = undefined; if (std.mem.eql(u8, termCwd(tt, &cb), dir)) { found = i; break; } }; if (found) |fid| { active.* = fid; if (terms[fid]) |tt| termWrite(tt, "ls\r"); } else { const slot: ?usize = for (terms.*, 0..) |sl, i| { if (sl == null) break i; } else null; if (slot) |free| { if (forkTerm(gpa, sw, sh, rp)) |nt| { terms[free] = nt; startReader(io, gpa, loop, nt, free) catch {}; const f = layoutFindTerm(layout, id).?; layoutInsert(layout, f.col, f.idx + 1, free); splitBelow(t, nt, rects[id].h); active.* = free; termWrite(nt, "ls\r"); } else |_| {} } } } else { const fp = std.mem.span(rp); // focus an existing file pane for this path var found: ?usize = null; for (terms, 0..) |*slot, i| if (slot.*) |tt| { if (tt.file) |ff| if (std.mem.eql(u8, ff.path, fp)) { found = i; break; }; }; if (found) |fid| { active.* = fid; if (pl.line > 0) if (terms[fid]) |tt| if (tt.file) |*ff| if (pl.line <= fileLineCount(ff.content)) { ff.scroll = pl.line - 1; }; } else { const slot: ?usize = for (terms.*, 0..) |sl, i| { if (sl == null) break i; } else null; if (slot) |free| { if (openFileView(gpa, rp, pl.line, sw, sh)) |nt| { terms[free] = nt; // files live in the LEFT column (acme convention). Stack at its // top with a fair share of the column height, then drop a // leftover unused shell. const lc = 0; const ln = layout.col_n[lc]; if (ln > 0) { var vsum: f32 = 0; for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| { vsum += p.vweight; }; nt.vweight = vsum / @as(f32, @floatFromInt(ln)); } layoutInsert(layout, lc, 0, free); active.* = free; evictLonePristineTty(layout, terms, io, gpa, lc, free, active); } else |_| {} } } }; return false; } switch (onSelect(button, txt)) { .none => {}, .send => { // terminal: run in itself. file pane: run in a shell sitting in the // file's directory (found by cwd, else a fresh tty in the right column). const cmd = std.mem.trim(u8, txt, " \t\r\n"); const dst: ?*Term = if (t.file) |f| ttyForDir(io, gpa, loop, terms, layout, sw, sh, std.fs.path.dirname(f.path) orelse "/") else t; if (dst) |dt| { padOutputBelowEdits(dt); termWrite(dt, cmd); termWrite(dt, "\r"); } }, .newcol => { // new terminal in a fresh column; ls its directory. 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, sw, sh, null)) |nt| { terms[free] = nt; startReader(io, gpa, loop, nt, free) catch {}; layoutAppendColumn(layout, free); active.* = free; termWrite(nt, "ls\r"); } else |_| {} }; }, .del => { // close just this window absorbVWeight(layout, terms, id); layoutRemove(layout, id); t.deinit(io, gpa); gpa.destroy(t); terms[id] = null; if (active.* == id) active.* = for (terms.*, 0..) |sl, i| { if (sl != null) break i; } else return true; }, .delcol => { // close every terminal in this one's column if (layoutFindTerm(layout, 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 return true; } }, .tutor => { // spawn the Tutor as a file pane in the LEFT column (acme convention for // files), stacked at the top with a fair share of the column. const slot: ?usize = for (terms.*, 0..) |sl, i| { if (sl == null) break i; } else null; if (slot) |free| { if (openTutorView(gpa, sw, sh)) |nt| { terms[free] = nt; const lc = 0; const ln = layout.col_n[lc]; if (ln > 0) { var vsum: f32 = 0; for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| { vsum += p.vweight; }; nt.vweight = vsum / @as(f32, @floatFromInt(ln)); } layoutInsert(layout, lc, 0, free); active.* = free; evictLonePristineTty(layout, terms, io, gpa, lc, free, active); } else |_| {} } }, } return false; } // The word under the modal cursor as a pane-local selection in paneText coords // (row 0 is the tag; file panes carry the line-number prefix). Feed it through // expandSel + selectionText to get the acme chord text for the Enter/Tab keys. fn cursorWordSel(t: *Term) Term.Sel { const vrow = (t.cur_row - paneScroll(t)) + @as(i32, BOX_H); const vcol = if (t.file != null) t.cur_col + @as(i32, PREFIX_W) else t.cur_col; return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; } 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(); defer freeYank(gpa); 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), }; // drop the bash shell-integration rc so spawned shells emit OSC 133. writeFile(rc_path, bash_rc); // 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, null); terms[1] = try forkTerm(gpa, screen_w, screen_h, null); terms[2] = try forkTerm(gpa, screen_w, screen_h, null); // a fresh shell greets you with its directory listing (the readers below pick // up the output once they start). for (terms[0..3]) |slot| if (slot) |t| termWrite(t, "ls\r"); 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; var hover_col: u16 = 0; // last mouse position; used to tint the resize border under it var hover_row: u16 = 0; var ctrl_w_pending: bool = false; // Ctrl-w prefix for helix-style pane focus 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); hover_col = mcol; hover_row = mrow; // 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| paneScrollBy(terms[id].?, 1); } else if (mouse.button == .wheel_up) { if (hovered) |id| paneScrollBy(terms[id].?, -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: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H); paneScrollBy(t, -local); // left: scroll up to here } } 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. // Right focuses the pane (acme "look" lands you there); middle // (execute) does NOT — you stay where you were editing. const b: Button = if (mouse.button == .middle) .middle else .right; if (hovered) |id| { const r = rects[id]; if (mcol < r.x + GUTTER and mrow >= r.y + BOX_H) { // gutter (scrollbar): right scrolls down, mirroring left's up if (b == .right) active = id; if (terms[id]) |t| { const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H); paneScrollBy(t, if (b == .right) local else -local); } } else if (mcol >= r.x + GUTTER) { if (b == .right) 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); // a file moved into the left column evicts a lone unused shell there if (terms[d.id]) |mt| if (mt.file != null) { if (layoutFindTerm(&layout, d.id)) |f| if (f.col == 0) evictLonePristineTty(&layout, &terms, io, gpa, 0, d.id, &active); }; }, .select => |s| { if (terms[s.id]) |t| { t.sel[@intFromEnum(s.button)].state = .done; if (s.button == .left) { // left-click in the body enters NORMAL mode and // anchors the cursor at the selection end. The row is // stored absolute (visual + scroll off) so it rides // the scrollback. tag clicks (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 = paneScroll(t); t.cur_row = body_vis + off; // file panes render a line-number prefix; cur_col // is content-relative, so peel PREFIX_W off the click. t.cur_col = if (t.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) else sl.c1; t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; t.cur_pinned = true; t.mode = .normal; t.msel.active = false; t.pending = 0; } } else { // middle/right: acme execute/look. A no-drag click // expands to the file-ish word under the cursor first. expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]); if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| { defer gpa.free(txt); if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, s.button, s.id, txt)) break; } else |_| {} } } }, .none => {}, } drag = .none; } }, .key_press => |key| kpress: { if (key.matches('c', .{ .ctrl = true })) break; if (terms[active]) |at| { // Ctrl-w prefix: helix-style pane focus (Ctrl-w h/j/k/l). if (ctrl_w_pending) { ctrl_w_pending = false; if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) focusDir(&active, &terms, &rects, .left) else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) focusDir(&active, &terms, &rects, .right) else if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) focusDir(&active, &terms, &rects, .up) else if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) focusDir(&active, &terms, &rects, .down); if (terms[active]) |nt| nt.pending = 0; break :kpress; } if (key.matches('w', .{ .ctrl = true })) { ctrl_w_pending = true; break :kpress; } // global window ops (any mode): Alt-n new terminal below, // Alt-c move active terminal into a fresh column. 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, null)) |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; termWrite(nt, "ls\r"); } else |_| {} } break :kpress; } 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); } break :kpress; } // Ctrl-b: hidden toggle in/out of raw tty mode (terminals only). // tty forwards every key straight to the pty, so it's deliberately // off the normal editing path — this toggle is the only way in/out. if (key.matches('b', .{ .ctrl = true })) { if (at.file == null) { if (at.mode == .tty) { at.mode = .normal; at.pending = 0; } else enterTty(at); } break :kpress; } // Normal-mode Enter/Tab mirror the acme mouse chords on the word // under the cursor: Enter = right-click "look" (open the file/dir), // Tab = middle-click "execute" (run it / builtin). if (at.mode == .normal and (key.matches(vaxis.Key.enter, .{}) or key.matches(vaxis.Key.tab, .{}))) { pinCursor(at); const btn: Button = if (key.matches(vaxis.Key.tab, .{})) .middle else .right; var sel = cursorWordSel(at); expandSel(gpa, at, &sel); if (selectionText(gpa, at, sel)) |word| { defer gpa.free(word); if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, btn, active, word)) break; } else |_| {} break :kpress; } // per-mode dispatch. Esc: insert -> normal (normal Esc is a // no-op, like helix). Ctrl-b toggles raw tty on terminals (above). // tty forwards every key to the pty. switch (at.mode) { .normal => handleNormal(gpa, frame_arena.allocator(), at, key), .insert => { if (key.matches(vaxis.Key.escape, .{})) { at.mode = .normal; at.msel.active = false; at.pending = 0; } else handleInsert(gpa, frame_arena.allocator(), at, key); }, .tty => forwardToPty(at, key), } } }, .pty_read => |pr| { defer gpa.free(pr.bytes); if (terms[pr.id]) |t| { t.stream.nextSlice(pr.bytes); // follow new output to the bottom, so a command's result shows // even in a pane you didn't focus. Exception: the pane you're // actively navigating in a text mode — don't yank the cursor out // from under you (it still tracks the bottom on its own there). if (!(pr.id == active and t.mode != .tty)) t.term.screens.active.scroll(.active); } }, .pty_eof => |id| { if (terms[id]) |t| { absorbVWeight(&layout, &terms, id); 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) { // file panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows. if (t.file == null) { 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); } // No static separators: each pane's tagline (top) and scrollbar gutter // (left) already read as borders. The 1-cell gap between panes stays as // an (invisible) drag zone; the dashed preview below shows it on drag. // 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 => { // not dragging: tint the resize border under the cursor (matching // the press-handler hit tests) so the invisible drag zones show. var c: usize = 0; while (c + 1 < layout.ncol) : (c += 1) { if (hover_col != col_x[c] + col_w[c]) continue; var ry: u16 = 0; while (ry < screen_h) : (ry += 1) win.writeCell(hover_col, ry, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = hover_bg } } }); } var cc: usize = 0; while (cc < layout.ncol) : (cc += 1) { if (hover_col < col_x[cc] or hover_col >= col_x[cc] + col_w[cc]) continue; var k: usize = 0; while (k + 1 < layout.col_n[cc]) : (k += 1) { const r = rects[layout.col_terms[cc][k]]; if (hover_row != r.y + r.h) continue; var rx: u16 = col_x[cc]; while (rx < col_x[cc] + col_w[cc]) : (rx += 1) win.writeCell(rx, hover_row, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = hover_bg } } }); } } }, } // 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; } var sb_off: usize = undefined; var sb_total: usize = undefined; if (at.file) |f| { sb_off = f.scroll; sb_total = fileLineCount(f.content); } else { const sb = at.term.screens.active.pages.scrollbar(); sb_off = sb.offset; sb_total = sb.total; } 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_off, 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. // Per visible row, whether it's a shell prompt/input row (OSC 133). Used to hide // prompts in text mode. Rows beyond the iterator (blank tail) stay false. fn promptFlags(t: *Term, flags: []bool) void { @memset(flags, false); var it = t.term.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); var i: usize = 0; while (it.next()) |pin| : (i += 1) { if (i >= flags.len) break; flags[i] = pin.rowAndCell().row.semantic_prompt != .none; } } fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 { // file pane: line-numbered content from f.scroll, edits spliced like the tty. if (t.file) |f| { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(alloc); var lines = std.mem.splitScalar(u8, f.content, '\n'); var skipped: usize = 0; while (skipped < f.scroll) : (skipped += 1) { if (lines.next() == null) break; } var i: usize = 0; while (i < t.rows) : (i += 1) { if (i > 0) try out.append(alloc, '\n'); const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i)); if (lines.next()) |text| { var lbuf: [16]u8 = undefined; const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{abs + 1}) catch " "; var base: std.ArrayList(u8) = .empty; defer base.deinit(alloc); try base.appendSlice(alloc, prefix); try base.appendSlice(alloc, text); try spliceRow(alloc, &out, t, abs, base.items); } else try spliceRow(alloc, &out, t, abs, ""); } return out.toOwnedSlice(alloc); } const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset); const body = try t.term.plainString(alloc); defer alloc.free(body); // text mode (normal AND insert) hides shell prompt rows: a clean acme-style // page to navigate and type on. Only raw tty mode (the Ctrl-b toggle) shows // the real terminal with its prompts + typed input. var flagbuf: [512]bool = undefined; const nrows: usize = @min(t.rows, flagbuf.len); const flags = flagbuf[0..nrows]; if (t.mode != .tty) promptFlags(t, flags) else @memset(flags, false); 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"); var i: usize = 0; while (i < nrows) : (i += 1) { if (i > 0) try out.append(alloc, '\n'); const raw = it.next() orelse ""; const line = if (flags[i]) "" else raw; // hidden prompt -> blank (edits still splice) try spliceRow(alloc, &out, t, @as(i32, @intCast(i)) + off, line); } return out.toOwnedSlice(alloc); } // The full rendered pane: tag at visual row 0, spliced body below. Caller frees. fn paneText(gpa: std.mem.Allocator, t: *Term) ![]u8 { const tag = try tagLine(gpa, t); defer gpa.free(tag); const body = try bodyText(gpa, t); defer gpa.free(body); return std.fmt.allocPrint(gpa, "{s}\n{s}", .{ tag, body }); } // Block-selected text, newline-joined per row; caller frees. Reads the rendered // screen, 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 pane = try paneText(gpa, t); 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); } // file-ish word chars (acme isfilec): alnum + . - + / : @ _ ~ fn isFileChar(c: u8) bool { return std.ascii.isAlphanumeric(c) or switch (c) { '.', '-', '+', '/', ':', '@', '_', '~' => true, else => false, }; } // acme-style: a no-drag middle/right click expands to the file-ish word under the // cursor, in place. A real (dragged) selection is left untouched. fn expandSel(gpa: std.mem.Allocator, t: *Term, sl: *Term.Sel) void { if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return; const pane = paneText(gpa, t) catch return; defer gpa.free(pane); var it = std.mem.splitAny(u8, pane, "\n"); var v: i32 = 0; while (it.next()) |line| : (v += 1) { if (v != sl.r0) continue; const col: usize = @intCast(@max(0, sl.c0)); if (col > line.len) return; var hi = col; while (hi < line.len and isFileChar(line[hi])) hi += 1; var lo = col; while (lo > 0 and isFileChar(line[lo - 1])) lo -= 1; if (hi > lo) { sl.c0 = @intCast(lo); sl.c1 = @intCast(hi - 1); } return; } } // 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; }