const std = @import("std"); const vaxis = @import("vaxis"); const ghostty_vt = @import("ghostty-vt"); const Term = @import("term.zig"); const modal = @import("modal.zig"); const image = @import("image.zig"); const ts = @import("tree-sitter"); const ts_queries = @import("ts_queries"); // grammars whose parser.c we link (see build.zig). The query strings ride along // in the ts_queries module. extern fn tree_sitter_c() callconv(.c) *const ts.Language; extern fn tree_sitter_rust() callconv(.c) *const ts.Language; extern fn tree_sitter_zig() callconv(.c) *const ts.Language; // A TUI can't let std.log hit stderr — it's the same tty as our alt-screen, so the // message lands on top of the UI (this is the stray text you saw when opening yazi: // ghostty-vt's parser warns via std.log about sequences it doesn't implement, e.g. // kitty text-sizing). Route every log line to /tmp/pardes.log instead: the display // stays clean and the messages are still there to inspect. pub const std_options: std.Options = .{ .logFn = fileLog }; var log_fd: i32 = -1; fn fileLog( comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype, ) void { if (log_fd < 0) { const rc = std.os.linux.open("/tmp/pardes.log", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true, .APPEND = true }, 0o644); const sfd: isize = @bitCast(rc); if (sfd < 0) return; // can't open the log: drop the message (never touch stderr) log_fd = @intCast(sfd); } var buf: [4096]u8 = undefined; const prefix = "[" ++ @tagName(level) ++ "] " ++ @tagName(scope) ++ ": "; const line = std.fmt.bufPrint(&buf, prefix ++ format ++ "\n", args) catch return; _ = std.os.linux.write(log_fd, line.ptr, line.len); } 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 TOPBAR_H: u16 = 1; // global tagbar row at the very top (full width) 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 (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: an indigo-purple accent (brighter when the window is focused), // dimming to a dark grey-blue when idle. const accent = [3]u8{ 0x6e, 0x4f, 0xd0 }; const accent_dim = [3]u8{ 0x38, 0x3c, 0x54 }; // 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 }; // Master switch for the per-cell recolor passes (tty ansi + file syntax). Off = // the plain-text render path, exactly as before colors existed; the recolor loops // are skipped so there's no per-frame cost. Toggled by the "Colors" tagbar command. var colors_on: bool = true; // Color themes. Chrome (scrollbar/box/selection/edit colors above) stays fixed; // only the page background, text, tag bar and syntax colors swap. `bg`/`fg` are // null in the dark theme so it keeps the terminal's own default colors (i.e. looks // exactly as it did before themes existed); the acme light theme paints an explicit // pale-yellow page with black text. Cycle with the "NextColor" tagbar command. const Theme = struct { name: []const u8, bg: ?[3]u8, // body background (null = terminal default, no paint) fg: ?[3]u8, // body text (null = terminal default) tag_bg: [3]u8, tag_fg: [3]u8, // syntax (keyword + comment render bold) kw: [3]u8, str: [3]u8, num: [3]u8, comment: [3]u8, lineno: [3]u8, // muted file-pane line-number gutter scroll_track: [3]u8, // scrollbar strip scroll_thumb: [3]u8, // scrollbar visible-portion indicator // the 16 ANSI colors for terminal-pane output; null = pass palette indices // through to the real terminal (dark keeps the terminal's own colors). palette: ?[16][3]u8, }; const themes = [_]Theme{ .{ .name = "dark", .bg = null, .fg = null, .tag_bg = .{ 0x2c, 0x2a, 0x3e }, // dim gray, slight indigo tint .tag_fg = .{ 0x96, 0x96, 0x96 }, .kw = .{ 0xcc, 0x70, 0xd8 }, // magenta .str = .{ 0x8a, 0xb8, 0x7a }, // green .num = .{ 0xd0, 0xc0, 0x70 }, // yellow .comment = .{ 0xf0, 0xf0, 0xf0 }, // bold white .lineno = .{ 0x5a, 0x5a, 0x5a }, // dim gray gutter .scroll_track = .{ 0x2a, 0x2a, 0x2a }, // gray.bar (unchanged dark scrollbar) .scroll_thumb = .{ 0x52, 0x52, 0x52 }, // gray.mid .palette = null, // terminal-native ANSI colors }, .{ .name = "acme", .bg = .{ 0xff, 0xff, 0xea }, // classic acme pale yellow .fg = .{ 0x00, 0x00, 0x00 }, .tag_bg = .{ 0xea, 0xff, 0xff }, // acme pale cyan .tag_fg = .{ 0x00, 0x00, 0x00 }, .kw = .{ 0x88, 0x22, 0x99 }, .str = .{ 0x00, 0x77, 0x33 }, .num = .{ 0xa0, 0x52, 0x00 }, .comment = .{ 0x55, 0x55, 0x55 }, // bold, darkened to read on yellow .lineno = .{ 0x99, 0x99, 0x80 }, // muted gutter, reads on the pale page .scroll_track = .{ 0x99, 0x99, 0x4c }, // acme olive strip (BORD) .scroll_thumb = .{ 0xff, 0xff, 0xea }, // acme pale-yellow scroller (BACK) // darkened ANSI palette so shell output (ls colors, prompts) reads on the // pale-yellow page; 7/15 (the "whites") map to gray/black, not invisible. .palette = .{ .{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 }, .{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 }, .{ 0x88, 0x88, 0x88 }, .{ 0xcc, 0x33, 0x33 }, .{ 0x33, 0xaa, 0x33 }, .{ 0xaa, 0x77, 0x00 }, .{ 0x33, 0x55, 0xdd }, .{ 0xbb, 0x44, 0xbb }, .{ 0x22, 0xaa, 0xaa }, .{ 0x00, 0x00, 0x00 }, }, }, }; var theme_idx: usize = 0; fn T() *const Theme { return &themes[theme_idx]; } fn rgbColor(c: [3]u8) vaxis.Color { return .{ .rgb = c }; } fn colorOpt(c: ?[3]u8) vaxis.Color { return if (c) |x| .{ .rgb = x } else .default; } // tree-sitter syntax classes; also the per-byte style id stored in File.highlights // (0 = none). synStyle maps each to the active theme's color (null for .none). const Syn = enum(u8) { none, keyword, string, number, comment }; fn synStyle(s: Syn) ?struct { fg: [3]u8, bold: bool } { return switch (s) { .none => null, .keyword => .{ .fg = T().kw, .bold = true }, .string => .{ .fg = T().str, .bold = false }, .number => .{ .fg = T().num, .bold = false }, .comment => .{ .fg = T().comment, .bold = true }, }; } // Builtins. Clicking the name runs the action. Window-specific ones (Del, Delcol) // live in each pane's tag; window-agnostic ones (Kill, Newcol, Tutor) live in the // single global tagbar at the top. onSelect matches against the whole list, so a // name works wherever it's shown. const SelectAction = enum { none, send, newcol, delcol, del, tutor, kill }; const Builtin = struct { name: []const u8, action: SelectAction }; const builtins = [_]Builtin{ .{ .name = "Kill", .action = .kill }, .{ .name = "Newcol", .action = .newcol }, .{ .name = "Tutor", .action = .tutor }, .{ .name = "Del", .action = .del }, .{ .name = "Delcol", .action = .delcol }, }; // per-pane tag commands (window-specific). const pane_builtins_str = "Del Delcol"; // the global tagbar: window-agnostic commands. Kill closes everything; Debug // toggles the stats overlay, Colors the ansi/syntax recolor, NextColor cycles the // theme (all handled in the tagbar click, not a SelectAction). const topbar_str = "Kill Newcol Tutor Debug Colors NextColor"; // 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.isTerminal() 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.isTerminal()) return "?"; // file/image 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 { // an image pane's tag: a label + path (+ builtins); display-only, no mode/cwd. if (t.image) |iv| { // toggles: Petscii (glyph art on/off), the color mode (C64 = commodore / // Term = terminal palette), Ascii (include ascii glyphs in the matcher). const mode = if (iv.pmode == .commodore) "C64" else "Term"; return std.fmt.allocPrint(alloc, "img {s} Petscii {s} Ascii {s}", .{ iv.path, mode, pane_builtins_str }); } // 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, pane_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, pane_builtins_str }); } // The whitespace-delimited word covering `col` in `str` (empty if on a gap / past // the end). Used to resolve which global-tagbar command was clicked. fn wordAtCol(str: []const u8, col: u16) []const u8 { if (col >= str.len or str[col] == ' ') return ""; var lo: usize = col; while (lo > 0 and str[lo - 1] != ' ') lo -= 1; var hi: usize = col; while (hi < str.len and str[hi] != ' ') hi += 1; return str[lo..hi]; } // 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); } // Map a tree-sitter capture name (e.g. "keyword.function", "string.special") to a // syntax color, by substring — the grammars' highlights.scm use many sub-captures // we don't care to distinguish. Returns .none for captures we don't color. fn synFor(name: []const u8) Syn { for ([_]struct { []const u8, Syn }{ .{ "comment", .comment }, .{ "string", .string }, .{ "character", .string }, .{ "number", .number }, .{ "float", .number }, .{ "boolean", .number }, .{ "keyword", .keyword }, .{ "include", .keyword }, .{ "operator", .keyword }, .{ "conditional", .keyword }, .{ "repeat", .keyword }, }) |m| { if (std.mem.indexOf(u8, name, m[0]) != null) return m[1]; } return .none; } // Syntax-highlight `content` with tree-sitter: returns a gpa-owned byte per content // byte (a `Syn` id; 0 = none), or empty when the extension has no grammar. Caller // frees (stored in File.highlights; see term.zig). Best-effort — any parse/query // failure just yields fewer styled bytes, never an error. fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 { const ext = std.fs.path.extension(path); var lang: *const ts.Language = undefined; var query_src: []const u8 = undefined; if (std.mem.eql(u8, ext, ".c") or std.mem.eql(u8, ext, ".h")) { lang = tree_sitter_c(); query_src = ts_queries.c_highlights; } else if (std.mem.eql(u8, ext, ".rs")) { lang = tree_sitter_rust(); query_src = ts_queries.rust_highlights; } else if (std.mem.eql(u8, ext, ".zig") or std.mem.eql(u8, ext, ".zon")) { lang = tree_sitter_zig(); query_src = ts_queries.zig_highlights; } else return &.{}; // note: no lang.destroy() — tree_sitter_*() return static singletons reused on // every open; deleting one would use-after-free the next file's parse. const styles = try gpa.alloc(u8, content.len); errdefer gpa.free(styles); @memset(styles, 0); const parser = ts.Parser.create(); defer parser.destroy(); parser.setLanguage(lang) catch return styles; const tree = parser.parseString(content, null) orelse return styles; defer tree.destroy(); var error_offset: u32 = 0; const query = ts.Query.create(lang, query_src, &error_offset) catch return styles; defer query.destroy(); const cursor = ts.QueryCursor.create(); defer cursor.destroy(); cursor.exec(query, tree.rootNode()); while (cursor.nextMatch()) |match| { for (match.captures) |cap| { const syn = synFor(query.captureNameForId(cap.index) orelse ""); if (syn == .none) continue; var b: usize = cap.node.startByte(); const end = @min(@as(usize, cap.node.endByte()), styles.len); while (b < end) : (b += 1) styles[b] = @intFromEnum(syn); } } return styles; } // Replace a file pane's content (freeing the old buffer) and re-derive syntax // highlighting to match. Every file edit funnels through here, so highlights stay // in sync with content (and can never index past it). The reparse is skipped when // colors are off, keeping the edit path exactly as fast as before colors existed. fn setFileContent(gpa: std.mem.Allocator, f: *Term.File, new: []u8) void { gpa.free(f.content); f.content = new; if (f.highlights.len > 0) gpa.free(f.highlights); f.highlights = if (colors_on) (highlightFile(gpa, f.path, new) catch &.{}) else &.{}; } // 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 highlights = try highlightFile(gpa, path, content); errdefer if (highlights.len > 0) gpa.free(highlights); 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, .highlights = highlights }, .cols = cols, .rows = rows, .cur_pinned = true, .cur_row = @intCast(scroll), }; t.stream = t.term.vtStream(); return t; } // Open an IMAGE pane (the kitty-graphics side quest): a Term with `image` set, // sharing the layout machinery. No pty (handle -1), no reader, a stub 1x1 emulator // (only its allocator-owned bits are touched). The render loop transmits + draws // the image (see image.zig). `rp` is the absolute path. Caller wires it in + frees. fn openImageView(gpa: std.mem.Allocator, rp: [*:0]const u8, cols: u16, rows: u16, petscii: bool) !*Term { const path = try gpa.dupe(u8, std.mem.span(rp)); 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 = {} }, .image = .{ .path = path, .petscii = petscii }, .cols = cols, .rows = rows, }; 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.isTerminal()) 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; // reserve the top row for the global tagbar: panes tile [TOPBAR_H, sh). const avail_h: u16 = (sh -| TOPBAR_H) -| sep_rows; var y: u16 = TOPBAR_H; 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; } } // A ghostty palette index → vaxis color. With a themed palette (light) the 16 // ANSI colors resolve to the theme's rgb so shell output matches the page; without // one (dark) they pass through as palette indices for the real terminal to render. fn palColor(idx: u8) vaxis.Color { if (T().palette) |p| if (idx < 16) return rgbColor(p[idx]); return .{ .index = idx }; } // Translate a ghostty cell color to vaxis. `default` (no explicit color) resolves // to the theme's page fg/bg — so on the acme theme the terminal's own default text // becomes black-on-yellow instead of the real terminal's colors. dark keeps null // (→ vaxis .default → terminal native, unchanged). fn ghostColor(color: ghostty_vt.Style.Color, is_bg: bool) vaxis.Color { return switch (color) { .none => colorOpt(if (is_bg) T().bg else T().fg), .palette => |idx| palColor(idx), .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }, }; } // The vaxis style for one emulator cell: fg/bg/attrs translated from ghostty. fn cellStyle(ci: ghostty_vt.PageList.Cell) vaxis.Style { const style = ci.style(); var vs: vaxis.Style = .{ .fg = ghostColor(style.fg_color, false), .bg = ghostColor(style.bg_color, true), .bold = style.flags.bold, .dim = style.flags.faint, .italic = style.flags.italic, .blink = style.flags.blink, .reverse = style.flags.inverse, .invisible = style.flags.invisible, .strikethrough = style.flags.strikethrough, .ul_style = switch (style.flags.underline) { .none => .off, .single => .single, .double => .double, .curly => .curly, .dotted => .dotted, .dashed => .dashed, }, }; // a cell can carry its bg directly (palette/rgb) rather than via its style. switch (ci.cell.content_tag) { .bg_color_palette => vs.bg = palColor(ci.cell.content.color_palette.data), .bg_color_rgb => { const rgb = ci.cell.content.color_rgb; vs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } }; }, else => {}, } return vs; } // `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(); // light themes paint the page; dark leaves it on the terminal's own background. if (T().bg) |bg| text.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(bg) } }); // 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 = rgbColor(T().tag_bg) } }); _ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none }); // image pane: the picture fills the body; draw the box + a plain (thumbless) // gutter so it reads like any other pane. kitty placements are re-issued each // frame, so this runs every render. Nothing else (text/cursor/selection) applies. if (t.image) |*iv| { if (r.h > BOX_H) image.draw(iv, text.child(.{ .y_off = BOX_H, .height = r.h - BOX_H }), r.w - GUTTER, r.h - BOX_H); const gut = root.child(.{ .x_off = r.x, .y_off = r.y, .width = GUTTER, .height = r.h }); const box_bg = if (active) accent else accent_dim; var gy: u16 = 0; while (gy < r.h) : (gy += 1) { const bg = if (gy < BOX_H) box_bg else T().scroll_track; var gx: u16 = 0; while (gx < GUTTER) : (gx += 1) gut.writeCell(gx, gy, .{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = bg } } }); } return; } // 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); const body_style: vaxis.Style = .{ .fg = colorOpt(T().fg), .bg = colorOpt(T().bg) }; var it = std.mem.splitAny(u8, body, "\n"); var i: u16 = 0; while (it.next()) |line| : (i += 1) { _ = text.printSegment(.{ .text = line, .style = body_style }, .{ .row_offset = BOX_H + i, .wrap = .none }); } const body_w = r.w - GUTTER; const body_h = if (r.h > BOX_H) r.h - BOX_H else 0; // mute the file-pane line-number gutter (the first PREFIX_W cols). Cheap chrome, // so it's not gated on colors_on; the selection/cursor passes below still win. if (t.file != null) { const ln_fg = rgbColor(T().lineno); var vr: u16 = 0; while (vr < body_h) : (vr += 1) { var c: u16 = 0; while (c < PREFIX_W and c < body_w) : (c += 1) { if (text.readCell(c, BOX_H + vr)) |cell| { var nc = cell; nc.default = false; nc.style.fg = ln_fg; text.writeCell(c, BOX_H + vr, nc); } } } } // syntax colors (file panes): recolor each content cell from its tree-sitter // style. Content starts after the PREFIX_W line-number gutter; highlights are // indexed by content byte, and a line slice's byte offset is its ptr - content. const pw: usize = PREFIX_W; const bw: usize = body_w; if (colors_on) { if (t.file) |f| { if (f.highlights.len > 0) { var lines = std.mem.splitScalar(u8, f.content, '\n'); var skip: usize = 0; while (skip < f.scroll) : (skip += 1) if (lines.next() == null) break; var vr: u16 = 0; while (vr < body_h) : (vr += 1) { const line = lines.next() orelse break; const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr); var c: usize = 0; while (c < line.len and pw + c < bw) : (c += 1) { const ss = synStyle(@enumFromInt(f.highlights[base + c])) orelse continue; const cx: u16 = @intCast(pw + c); if (text.readCell(cx, BOX_H + vr)) |cell| { var nc = cell; nc.default = false; nc.style.fg = rgbColor(ss.fg); nc.style.bold = ss.bold; text.writeCell(cx, BOX_H + vr, nc); } } } } } else if (t.isTerminal()) { // tty colors: recolor each body cell from the emulator's per-cell style, so // shell output (ls, prompts, TUIs) keeps its ansi colors. row i <-> viewport // row i, col c <-> viewport col c (1:1; edit runs are repainted just below). var vr: u16 = 0; while (vr < body_h and vr < t.rows) : (vr += 1) { var c: u16 = 0; while (c < body_w) : (c += 1) { const ci = t.term.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue; if (ci.cell.wide == .spacer_tail) continue; if (text.readCell(c, BOX_H + vr)) |cell| { var nc = cell; nc.default = false; nc.style = cellStyle(ci); text.writeCell(c, BOX_H + vr, nc); } } } } } // repaint the inserted cells with the plain body style so typed text reads // EXACTLY like normal text — not a distinct overlay. (The pass is still needed: // the tty recolor above paints cell (c,vr) from the emulator cell at column c, // but inserted runs shifted the row right, so those cells would otherwise pick // up the wrong emulator colors.) edit.row is absolute -> (row - off) + BOX_H. // Skipped in tty mode: the overlay is hidden there (bodyText doesn't splice it). if (t.mode != .tty) 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 = body_style; 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; // file panes: don't paint over the line-number gutter. var col: i32 = if (t.file != null) @max(c0, @as(i32, PREFIX_W)) else 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 = if (t.file != null) PREFIX_W else 0; // skip the line-number gutter 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); } } } } // modal char selection (helix `v`): stream-shaped highlight from the anchor to // the cursor (head). r0/r1 are absolute body rows; cols are content-relative on // file panes (offset by PREFIX_W) and visual on terminals. Normal mode only. if (t.mode == .normal and t.vsel.active) { const bnd = vselBounds(t); const pfx: i32 = if (t.file != null) PREFIX_W else 0; var ar: i32 = bnd.lo_row; while (ar <= bnd.hi_row) : (ar += 1) { const prow = ar - off + @as(i32, BOX_H); if (prow < 0 or prow >= @as(i32, r.h)) continue; // first row starts at the anchor col, last row ends at the head col // (inclusive); interior rows span the whole width. const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col + pfx else pfx; const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col + pfx else @as(i32, r.w - GUTTER) - 1; var col: i32 = @max(cstart, pfx); while (col <= cend and col < @as(i32, r.w - GUTTER)) : (col += 1) { if (text.readCell(@intCast(col), @intCast(prow))) |cell| { var nc = cell; nc.default = false; nc.style.bg = .{ .rgb = msel_bg }; nc.style.fg = .{ .rgb = msel_fg }; text.writeCell(@intCast(col), @intCast(prow), 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) accent else accent_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) T().scroll_thumb else T().scroll_track; 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 the cursor and keep it visible. A line selection // (`x`) collapses on motion; a char selection (`v`) is left active so the motion // extends it — the head is just the cursor, which fromModalCursor moved. 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); // snapshot once per insert session so `u` undoes the whole session, not each key. // (file: content; terminal: the char-mode runs — both via the unified pushUndo.) pushUndo(gpa, t); const pl = paneCursorLines(talloc, t) catch { t.mode = .insert; t.msel.active = false; t.vsel.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; setFileContent(gpa, f, 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; setFileContent(gpa, f, 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.vsel.active = false; t.pending = 0; ensureCursorVisible(t); } // the char selection [anchor, cursor] normalized to document order. Absolute body // rows; cols in the cursor's coords (content for files, visual for terminals). const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; fn vselBounds(t: *Term) Bounds { var ar = t.vsel.row; var ac = t.vsel.col; var br = t.cur_row; var bc = t.cur_col; if (br < ar or (br == ar and bc < ac)) { const sr = ar; const sc = ac; ar = br; ac = bc; br = sr; bc = sc; } return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc }; } // the selected text of the char selection. File: sliced from content (content // cols). Terminal: sliced from the rendered body (visual cols), so it copies tty // output and typed runs alike. Caller frees via `alloc`. fn vselText(alloc: std.mem.Allocator, t: *Term) []const u8 { const b = vselBounds(t); if (t.file) |f| { return modal.rangeText(alloc, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch ""; } 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 < b.lo_row or abs > b.hi_row) continue; const lo: usize = if (abs == b.lo_row) @intCast(@max(0, b.lo_col)) else 0; const hi_excl: usize = if (abs == b.hi_row) @as(usize, @intCast(@max(0, b.hi_col))) + 1 else ln.len; if (abs > b.lo_row) out.append(alloc, '\n') catch return ""; out.appendSlice(alloc, ln[@min(lo, ln.len)..@min(hi_excl, ln.len)]) catch return ""; } return out.toOwnedSlice(alloc) catch ""; } // The active modal selection (`v` char range or `x` line range) as text, or null // if nothing is selected. The normal-mode Enter/Tab chords use this to act on the // SELECTION instead of the word under the cursor. `alloc` owns the result. fn currentSelText(alloc: std.mem.Allocator, t: *Term) ?[]const u8 { if (t.vsel.active) return vselText(alloc, t); if (!t.msel.active) return null; 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; const lo: usize = @intCast(@max(0, r0)); const hi: usize = @intCast(@max(0, r1)); var r: usize = lo; while (r <= hi) : (r += 1) { if (r > lo) buf.append(alloc, '\n') catch break; buf.appendSlice(alloc, modal.lineSlice(f.content, r)) catch break; } return buf.toOwnedSlice(alloc) catch null; } return yankRows(alloc, t, r0, r1); } // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- // File panes snapshot the content bytes (File.undo/redo); terminal panes snapshot // the char-mode insertion runs (Term.ed_undo/redo). One `pushUndo(t)` handles both // so every edit site is type-agnostic. const UNDO_MAX = 256; const EDIT_UNDO_MAX = 64; // terminal snapshots are ~2KB each; cap tighter fn editSnapEq(a: Term.EditSnap, b: Term.EditSnap) bool { if (a.nedits != b.nedits) return false; for (a.edits[0..a.nedits], b.edits[0..b.nedits]) |x, y| { if (x.row != y.row or x.col != y.col or x.len != y.len) return false; if (!std.mem.eql(u8, x.text[0..x.len], y.text[0..y.len])) return false; } return true; } fn snapTerm(t: *Term) Term.EditSnap { return .{ .edits = t.edits, .nedits = t.nedits, .cur_row = t.cur_row, .cur_col = t.cur_col, .cur_off = t.cur_off }; } // snapshot the pane's edit state onto its undo stack before an edit, and clear // redo. Deduped against the stack top so repeated no-op ops don't pile up. fn pushUndo(gpa: std.mem.Allocator, t: *Term) void { if (t.file) |*f| { if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1], f.content)) return; const snap = gpa.dupe(u8, f.content) catch return; f.undo.append(gpa, snap) catch { gpa.free(snap); return; }; if (f.undo.items.len > UNDO_MAX) gpa.free(f.undo.orderedRemove(0)); for (f.redo.items) |s| gpa.free(s); f.redo.clearRetainingCapacity(); } else if (t.isTerminal()) { const snap = snapTerm(t); if (t.ed_undo.items.len > 0 and editSnapEq(t.ed_undo.items[t.ed_undo.items.len - 1], snap)) return; t.ed_undo.append(gpa, snap) catch return; if (t.ed_undo.items.len > EDIT_UNDO_MAX) _ = t.ed_undo.orderedRemove(0); t.ed_redo.clearRetainingCapacity(); } } fn doUndo(gpa: std.mem.Allocator, t: *Term) void { if (t.file) |*f| { if (f.undo.items.len == 0) return; const cur = gpa.dupe(u8, f.content) catch return; f.redo.append(gpa, cur) catch { gpa.free(cur); return; }; const prev = f.undo.pop() orelse return; setFileContent(gpa, f, prev); // transfers ownership of `prev` to f.content clampCursorToFile(t, f); return; } if (!t.isTerminal() or t.ed_undo.items.len == 0) return; t.ed_redo.append(gpa, snapTerm(t)) catch return; restoreTerm(t, t.ed_undo.pop() orelse return); } fn doRedo(gpa: std.mem.Allocator, t: *Term) void { if (t.file) |*f| { if (f.redo.items.len == 0) return; const cur = gpa.dupe(u8, f.content) catch return; f.undo.append(gpa, cur) catch { gpa.free(cur); return; }; const next = f.redo.pop() orelse return; setFileContent(gpa, f, next); clampCursorToFile(t, f); return; } if (!t.isTerminal() or t.ed_redo.items.len == 0) return; t.ed_undo.append(gpa, snapTerm(t)) catch return; restoreTerm(t, t.ed_redo.pop() orelse return); } fn restoreTerm(t: *Term, snap: Term.EditSnap) void { t.edits = snap.edits; t.nedits = snap.nedits; t.cur_row = snap.cur_row; t.cur_col = snap.cur_col; t.cur_off = snap.cur_off; t.cur_pinned = true; t.vsel.active = false; t.msel.active = false; ensureCursorVisible(t); } // clamp the cursor into the (possibly shrunken) content and collapse selections. fn clampCursorToFile(t: *Term, f: *Term.File) void { const n = modal.lineCount(f.content); const row: usize = @min(@as(usize, @intCast(@max(0, t.cur_row))), if (n == 0) 0 else n - 1); const llen = modal.lineSlice(f.content, row).len; t.cur_row = @intCast(row); t.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_col))), llen)); t.cur_off = 0; t.cur_pinned = true; t.vsel.active = false; t.msel.active = false; ensureCursorVisible(t); } // helix `v`: toggle char-range select. Starting it anchors at the cursor; while // active, motions move the head (the cursor) and the selection spans [anchor,head]. fn charSelect(t: *Term) void { if (t.vsel.active) { t.vsel.active = false; } else { t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col }; } t.msel.active = false; t.cur_pinned = true; t.pending = 0; } // 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.vsel.active = false; 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.vsel.active) { const b = vselBounds(t); setYank(gpa, vselText(talloc, t)); // read the range before the cursor/content move pushUndo(gpa, t); if (t.file) |*f| { const d = modal.deleteRange(gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch { t.vsel.active = false; return; }; setFileContent(gpa, f, d.content); gpa.free(d.deleted); t.cur_row = b.lo_row; t.cur_col = b.lo_col; clampCursorToFile(t, f); } else { // terminal: can't delete shell output — only drop typed runs in the span. clearEditsInRange(t, b.lo_row, b.hi_row); t.cur_row = b.lo_row; t.cur_col = b.lo_col; t.cur_off = 0; t.cur_pinned = true; t.vsel.active = false; ensureCursorVisible(t); } return; } if (t.msel.active) { const r0 = @min(t.msel.r0, t.msel.r1); const r1 = @max(t.msel.r0, t.msel.r1); pushUndo(gpa, t); 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; }; setFileContent(gpa, f, 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| { pushUndo(gpa, t); 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; setFileContent(gpa, f, new); } else { // terminal, no selection: can't delete shell output, but CAN delete a char // of your own inserted run sitting under the (visual) cursor. deleteRunCharAtCursor(gpa, t); } } // delete the inserted char under the normal-mode cursor (visual col) from its run, // if any. tty output is untouched. No-op when the cursor isn't over a typed run. fn deleteRunCharAtCursor(gpa: std.mem.Allocator, t: *Term) void { for (t.edits[0..t.nedits]) |*e| { if (e.row != t.cur_row or e.len == 0) continue; const vstart = renderedCol(t, e.row, e.col, 0); if (t.cur_col < vstart or t.cur_col >= vstart + @as(i32, e.len)) continue; pushUndo(gpa, t); var k: usize = @intCast(t.cur_col - vstart); while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1]; e.len -= 1; t.cur_pinned = true; return; } } fn normalYank(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void { if (t.vsel.active) { const b = vselBounds(t); setYank(gpa, vselText(talloc, t)); t.cur_row = b.lo_row; t.cur_col = b.lo_col; t.cur_off = 0; t.cur_pinned = true; t.vsel.active = false; ensureCursorVisible(t); return; } 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; pushUndo(gpa, t); if (t.file) |*f| { const new = modal.pasteLineBelow(gpa, f.content, @intCast(@max(0, t.cur_row)), y) catch return; setFileContent(gpa, f, 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.vsel.active) { const b = vselBounds(t); setYank(gpa, vselText(talloc, t)); pushUndo(gpa, t); if (t.file) |*f| { const d = modal.deleteRange(gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch { t.vsel.active = false; return; }; setFileContent(gpa, f, d.content); gpa.free(d.deleted); t.cur_row = b.lo_row; t.cur_col = b.lo_col; clampCursorToFile(t, f); } else { clearEditsInRange(t, b.lo_row, b.hi_row); t.cur_row = b.lo_row; t.cur_col = b.lo_col; t.cur_off = 0; t.cur_pinned = true; t.vsel.active = false; ensureCursorVisible(t); } t.mode = .insert; t.pending = 0; return; } if (t.msel.active) { const r0 = @min(t.msel.r0, t.msel.r1); const r1 = @max(t.msel.r0, t.msel.r1); pushUndo(gpa, t); 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; }; setFileContent(gpa, f, d.content); gpa.free(d.deleted); } const cl = modal.clearLine(gpa, f.content, @intCast(@max(0, r0))) catch { t.msel.active = false; return; }; setFileContent(gpa, f, 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| { pushUndo(gpa, t); 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; setFileContent(gpa, f, new); t.mode = .insert; t.cur_off = 0; t.pending = 0; } else { // terminal, no selection: just enter insert at the cursor (snapshot first so // the typing that follows is undoable as one group). pushUndo(gpa, t); 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 v (char range, motions extend) x (whole lines, extend down) // edit delete d change c yank y paste p undo u redo U // chord Enter -> acme look (open file/dir) Tab -> acme execute (run it) // (act on the selection if one is active, else the word under cursor) // mode Esc collapses the selection; insert Esc -> normal; Ctrl-b -> raw tty // 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) with whole-content undo/redo; // 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 selected rows + yank). // 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.vsel.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); // Esc collapses any selection / cancels a pending prefix (helix). It does NOT // drop terminals to tty — that's the deliberate Ctrl-b toggle. if (key.matches(vaxis.Key.escape, .{})) { t.vsel.active = false; t.msel.active = false; t.pending = 0; return; } // 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); // selection + edit (helix `v` char range / `x` lines, then d/c/y; p pastes) if (key.matches('v', .{})) return charSelect(t); 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); if (key.matches('u', .{})) return doUndo(gpa, t); if (key.matches('U', .{})) return doRedo(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; setFileContent(gpa, f, 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; setFileContent(gpa, f, 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; setFileContent(gpa, f, 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; setFileContent(gpa, f, 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. Editing only ever touches YOUR inserted runs // (the shell's output underneath is immutable), so backspace erases within the // run at the cursor and stops at its start. if (!t.cur_pinned) pinCursor(t); if (key.text) |text| { insertRunText(t, text); } else switch (key.codepoint) { vaxis.Key.backspace => { if (runAt(t, t.cur_row, t.cur_col)) |ei| { const e = &t.edits[ei]; if (t.cur_off > 0 and e.len > 0) { var k: usize = t.cur_off - 1; while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1]; e.len -= 1; t.cur_off -= 1; } } }, 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, images_ok: bool, button: Button, id: usize, txt: []const u8, ) bool { const t = terms[id] orelse return false; // image-pane tagline toggles (middle-click, acme execute): Petscii on/off, the // C64/Term color mode, and Ascii glyphs. Intercept before the builtin/run dispatch. if (button == .middle) if (t.image) |*iv| { const w = std.mem.trim(u8, txt, " \t\r\n"); if (std.mem.eql(u8, w, "Petscii")) { iv.petscii = !iv.petscii; return false; } if (std.mem.eql(u8, w, "C64") or std.mem.eql(u8, w, "Term")) { iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore; return false; } if (std.mem.eql(u8, w, "Ascii")) { iv.ascii = !iv.ascii; 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; nt.greet = true; } else |_| {} } } } else { const fp = std.mem.span(rp); // image paths open as IMAGE panes; they render via kitty when supported, // else via the PETSCII fallback (also toggleable on the pane's tagline). const as_image = image.isImagePath(fp); // focus an existing pane already showing this path (file or image) var found: ?usize = null; for (terms, 0..) |*slot, i| if (slot.*) |tt| { const tp: ?[]const u8 = if (tt.file) |ff| ff.path else if (tt.image) |iv| iv.path else null; if (tp) |p| if (std.mem.eql(u8, p, fp)) { found = i; break; }; }; if (found) |fid| { active.* = fid; if (!as_image and 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| { const opened: ?*Term = if (as_image) (openImageView(gpa, rp, sw, sh, !images_ok) catch null) // petscii by default when no kitty else (openFileView(gpa, rp, pl.line, sw, sh) catch null); if (opened) |nt| { terms[free] = nt; // files/images 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); } } } }; return false; } switch (onSelect(button, txt)) { .none => {}, .kill => return true, // close everything: signal the main loop to quit .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; nt.greet = true; } 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; // --tty: start tmux-style — a single shell pane already in tty mode — instead // of the default multi-pane editor layout. It only changes the startup layout // and mode; everything else behaves identically. const tty_only = blk: { const args = init.minimal.args.toSlice(init.arena.allocator()) catch break :blk false; for (args) |a| if (std.mem.eql(u8, a, "--tty")) break :blk true; break :blk false; }; var screen_w: u16 = 80; var screen_h: u16 = 24; var resize_count: usize = 0; // stb_image allocator for image panes (see image.zig). image.start(io, gpa); defer image.stop(); // 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. if (tty_only) { // tmux-style: a single shell, already in tty mode, filling one column. terms[0] = try forkTerm(gpa, screen_w, screen_h, null); terms[0].?.mode = .tty; layout.ncol = 1; layout.col_n[0] = 1; layout.col_terms[0][0] = 0; } else { // editor default: 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); // greet each fresh shell with `ls` once it's been sized (see the resize // loop): sending it now wraps the listing to the full-screen width. for (terms[0..3]) |slot| if (slot) |t| { t.greet = true; }; 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(); // Ask the terminal what it supports; the loop's reader thread fills vx.caps // from the replies. We only need kitty graphics (for image panes). Keep the // timeout tiny — a real terminal answers in well under a millisecond, and we // don't want to stall startup on one that never will. vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(20)) catch {}; // 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 var show_debug: bool = false; // the stats overlay; toggled by the Debug tagbar command 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 mrow < TOPBAR_H) { // global tagbar: MIDDLE-click runs the command under the click // (acme "execute"; left-click must NOT execute — a stray left-click // on "Kill" would quit). id=active is just a valid pane for the // action context; Kill/Newcol/Tutor ignore it. if (mouse.button == .middle) { const word = wordAtCol(topbar_str, mcol); if (std.mem.eql(u8, word, "Debug")) { show_debug = !show_debug; } else if (std.mem.eql(u8, word, "Colors")) { colors_on = !colors_on; } else if (std.mem.eql(u8, word, "NextColor")) { theme_idx = (theme_idx + 1) % themes.len; } else if (word.len > 0) { if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, .middle, active, word)) break; } } } 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. // In tty mode we DON'T do this: the mouse stays usable // for selecting/copying without yanking you out of the // shell — the selection is still recorded above. const sl = t.sel[@intFromEnum(Button.left)]; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and t.mode != .tty) { 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, vx.caps.kitty_graphics, 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; nt.greet = true; } 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.isTerminal()) { if (at.mode == .tty) { at.mode = .normal; at.pending = 0; } else enterTty(at); } break :kpress; } // Normal-mode Enter/Tab mirror the acme mouse chords: Enter = // right-click "look" (open the file/dir), Tab = middle-click // "execute" (run it / builtin). With an active selection (`v`/`x`) // they act on the SELECTED text; otherwise on the file-ish word // under the cursor. 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; if (currentSelText(frame_arena.allocator(), at)) |txt| { at.vsel.active = false; at.msel.active = false; if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt)) break; } else { 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, vx.caps.kitty_graphics, 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/image panes have no pty/emulator grid to reflow; just // record the size so bodyText renders the right number of rows. if (t.isTerminal()) { t.term.resize(gpa, cols, rows) catch {}; pty_set_size(t.pty, rows, cols); } t.cols = cols; t.rows = rows; } // greet a freshly forked shell with `ls` once it has its real size // (after the first winsize), so the listing wraps to the pane width // and not the placeholder full-screen size it was forked at. if (t.greet and t.isTerminal() and resize_count > 0) { pty_set_size(t.pty, t.rows, t.cols); termWrite(t, "ls\r"); t.greet = false; } } } // ---- render ---- const win = vx.window(); win.clear(); // paint the inter-pane gaps (and any uncovered area) with the scrollbar // track color so the gap reads as chrome, not raw black — most visible on // the light theme. Panes paint over their own rects on top of this. win.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().scroll_track) } }); for (&terms, 0..) |*slot, id| { if (slot.*) |t| { // image panes transmit lazily (needs the tty writer, which lives // here on the render thread); renderPane then re-places the picture. if (t.image) |*iv| { image.ensureLoaded(iv, &vx, gpa, tty.writer()); const r = rects[id]; const bc = if (r.w > GUTTER) r.w - GUTTER else 0; const br = if (r.h > BOX_H) r.h - BOX_H else 0; image.ensureGrid(iv, gpa, bc, br); } try renderPane(win, frame_arena.allocator(), t, rects[id], id == active); } } // global tagbar (full width, top row): app name + window-agnostic commands. const bar = win.child(.{ .x_off = 0, .y_off = 0, .width = screen_w, .height = TOPBAR_H }); bar.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = rgbColor(T().tag_bg) } }); _ = bar.printSegment(.{ .text = topbar_str, .style = .{ .fg = rgbColor(T().tag_fg), .bg = rgbColor(T().tag_bg) } }, .{ .wrap = .none }); // 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 = TOPBAR_H; 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: preview the resize border under the cursor with the // same dashed line the drag shows, drawn on top (no background tint), // so the invisible drag zones are discoverable on hover. var c: usize = 0; while (c + 1 < layout.ncol) : (c += 1) { if (hover_col != col_x[c] + col_w[c]) continue; var ry: u16 = TOPBAR_H; while (ry < screen_h) : (ry += 1) win.writeCell(hover_col, ry, .{ .char = .{ .grapheme = "╎" }, .style = .{ .fg = .{ .rgb = gray.lit } } }); } 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 = .{ .fg = .{ .rgb = gray.lit } } }); } } }, } // 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). // Off by default; the "Debug" tagbar command toggles it. if (show_debug) 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 = TOPBAR_H, .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 { // image pane: no text body (the picture is drawn over it). Return blank rows so // the tag stays selectable — selectionText/expandSel read this, and the stub // emulator's plainString must not be touched here (that path broke tag clicks). if (t.image != null) { const buf = try alloc.alloc(u8, t.rows -| 1); @memset(buf, '\n'); return buf; } // 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; // unsigned: Zig 0.16's {d} prints a leading '+' for signed ints. const lineno: usize = @intCast(abs + 1); const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) 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) // raw tty mode shows the REAL terminal: the char-mode insertion overlay is // hidden, so don't splice the runs — just emit the line as-is. if (t.mode == .tty) try out.appendSlice(alloc, line) else 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; }