diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-05 20:21:50 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | de4def3548a6729b0dfd2120495a61beef8c8c2c (patch) | |
| tree | 12144f0f64dc96bb4741459c1f9db57f44349330 /main.zig | |
| parent | 7988d9bc6e31210ff13994f18d5622dd57ba9341 (diff) | |
| download | pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.tar.gz pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.zip | |
pardes v2: the rewrite, complete and organized. src/ (core + three shells), test/ (snapshot parity harness + 18 frozen goldens). One sans-IO core, vaxis tty + SDL3 GPU native + wasm web shells, 18/18 parity with the purged prototype, 7.6k lines vs 12.1k. Fix: gui shell pre-sized the core at init so the greet-releasing resize never fired (blank panes until first interaction); live sessions now init at defaults and get the real grid as a resize event (the shell contract, documented on Options).
Diffstat (limited to 'main.zig')
| -rw-r--r-- | main.zig | 3940 |
1 files changed, 0 insertions, 3940 deletions
diff --git a/main.zig b/main.zig deleted file mode 100644 index f787f7f5..00000000 --- a/main.zig +++ /dev/null @@ -1,3940 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const vaxis = @import("ui"); -const ghostty_vt = @import("ghostty-vt"); -const Term = @import("term.zig"); -const modal = @import("modal.zig"); -const image = @import("image.zig"); -const dump = @import("dump.zig"); -const paint = @import("paint.zig"); -const pane_ui = @import("pane_ui.zig"); - -const os_tag = builtin.os.tag; -const posix = std.posix; -const linux = std.os.linux; -const shell_reports_cwd = os_tag == .macos; - -// 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: posix.fd_t = -1; -fn fileLog( - comptime level: std.log.Level, - comptime scope: @EnumLiteral(), - comptime format: []const u8, - args: anytype, -) void { - if (log_fd < 0) { - log_fd = openCreateTrunc("/tmp/pardes.log", true) orelse return; - } - var buf: [4096]u8 = undefined; - const prefix = "[" ++ @tagName(level) ++ "] " ++ @tagName(scope) ++ ": "; - const line = std.fmt.bufPrint(&buf, prefix ++ format ++ "\n", args) catch return; - writeFdBestEffort(log_fd, line); -} - -fn nowNs() u64 { - switch (os_tag) { - .linux => { - var ts: linux.timespec = undefined; - _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts); - return @intCast(ts.sec * 1_000_000_000 + ts.nsec); - }, - else => { - var ts: posix.timespec = undefined; - switch (posix.errno(posix.system.clock_gettime(posix.CLOCK.MONOTONIC, &ts))) { - .SUCCESS => return @intCast(ts.sec * 1_000_000_000 + ts.nsec), - else => return 0, - } - }, - } -} - -fn openCreateTrunc(path: [*:0]const u8, append: bool) ?posix.fd_t { - switch (os_tag) { - .linux => { - const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true, .APPEND = append }, 0o644); - const sfd: isize = @bitCast(rc); - return if (sfd < 0) null else @intCast(sfd); - }, - else => return posix.openatZ(posix.AT.FDCWD, path, .{ - .ACCMODE = .WRONLY, - .CREAT = true, - .TRUNC = true, - .APPEND = append, - }, 0o644) catch null, - } -} - -fn closeFdBestEffort(fd: posix.fd_t) void { - switch (os_tag) { - .linux => _ = linux.close(fd), - else => switch (posix.errno(posix.system.close(fd))) { - .SUCCESS, .INTR => {}, - else => {}, - }, - } -} - -fn writeFdBestEffort(fd: posix.fd_t, bytes: []const u8) void { - var off: usize = 0; - while (off < bytes.len) { - switch (os_tag) { - .linux => { - const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off); - const n: isize = @bitCast(rc); - if (n < 0) { - if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; - return; - } - if (n == 0) return; - off += @intCast(n); - }, - else => { - const rc = posix.system.write(fd, bytes[off..].ptr, bytes.len - off); - switch (posix.errno(rc)) { - .SUCCESS => { - const n: usize = @intCast(rc); - if (n == 0) return; - off += n; - }, - .INTR => continue, - else => return, - } - }, - } - } -} - -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. -// * see plan9port's acme: 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, - paste: []const u8, // OSC 52 system-clipboard read reply (the loop decodes it) - pinch: f32, - touch_scroll_delta: f32, - } = .nop; -}; - -// Modal yank register: gpa-owned, freed on overwrite / app exit. Helix-style -// `y` yanks the (line) selection into here; `p` pastes the system clipboard. -// A yank also mirrors out to the system clipboard (OSC 52): setYank raises -// yank_dirty and the main loop emits the copy where the tty writer is in scope. -var yank_reg: ?[]u8 = null; -var yank_dirty: bool = false; -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; - yank_dirty = true; -} - -const default_tty_toggle: u21 = 'b'; - -const Cli = struct { - tty_only: bool = false, - tty_toggle: u21 = default_tty_toggle, - help: bool = false, - perf_startup: bool = false, - debug_overlay: bool = false, -}; - -const help_text = - \\Usage: pardes [options] - \\ - \\Options: - \\ --tty start as one shell pane already in tty mode - \\ --tty-toggle <key> use Ctrl-<key> to enter/leave tty mode - \\ --tty-toggle=<key> same as above; accepts b, Ctrl-b, or ^b - \\ --debug-overlay start with debug overlay visible - \\ -h, --help show this help and exit - \\\; -; - -fn printHelp(io: std.Io) !void { - try std.Io.File.stdout().writeStreamingAll(io, help_text); -} - -fn parseCtrlKey(raw: []const u8) ?u21 { - var value = std.mem.trim(u8, raw, " \t\r\n"); - if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl-")) { - value = value[5..]; - } else if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl+")) { - value = value[5..]; - } else if (value.len == 2 and value[0] == '^') { - value = value[1..]; - } - if (value.len != 1) return null; - const c = std.ascii.toLower(value[0]); - if (c < 'a' or c > 'z' or c == 'c') return null; - return c; -} - -fn parseCli(args: []const [:0]const u8) !Cli { - var cli: Cli = .{}; - var i: usize = 1; - while (i < args.len) : (i += 1) { - const a = args[i]; - if (std.mem.eql(u8, a, "--tty")) { - cli.tty_only = true; - } else if (std.mem.eql(u8, a, "--perf-startup")) { - cli.perf_startup = true; - } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { - cli.help = true; - } else if (std.mem.startsWith(u8, a, "--tty-toggle=")) { - cli.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse return error.BadArgs; - } else if (std.mem.eql(u8, a, "--tty-toggle")) { - i += 1; - if (i >= args.len) return error.BadArgs; - cli.tty_toggle = parseCtrlKey(args[i]) orelse return error.BadArgs; - } else if (std.mem.eql(u8, a, "--debug-overlay")) { - cli.debug_overlay = true; - } else { - return error.BadArgs; - } - } - return cli; -} - -const Loop = vaxis.Loop(@TypeOf(Command.value)); - -// layout constants -const TOPBAR_H: u16 = pane_ui.TOPBAR_H; // global tagbar row at the very top (full width) -const GUTTER: u16 = pane_ui.GUTTER; // left gutter: box (top) + scrollbar (below) per pane -const BOX_H: u16 = pane_ui.BOX_H; // rows of the drag/rearrange box; scrollbar starts below it -const MINW: u16 = pane_ui.MINW; // min pane width (cells) -const MINH: u16 = pane_ui.MINH; // min pane height (cells) -const MAX_TERMS: usize = 16; -const MAX_COLS: usize = 6; -const MAX_PTY_DRAIN_EVENTS: usize = 128; -const MAX_KEY_DRAIN_EVENTS: usize = 512; -const MAX_SCROLL_DRAIN_EVENTS: usize = 4096; -const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0; -const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; -const PREFIX_W: u16 = pane_ui.PREFIX_W; // file-pane line-number prefix width: "%4d " - -const Rect = pane_ui.Rect; - -// 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 = pane_ui.Layout(MAX_COLS, MAX_TERMS); -const layoutFindTerm = pane_ui.layoutFindTerm; -const layoutRemove = pane_ui.layoutRemove; -const layoutInsert = pane_ui.layoutInsert; -const layoutAppendColumn = pane_ui.layoutAppendColumn; -const moveTerm = pane_ui.moveTerm; - -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 -}; -const accent = paint.accent; -const accent_dim = paint.accent_dim; -// 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 }; - -const T = paint.T; -const rgbColor = paint.rgbColor; -const colorOpt = paint.colorOpt; -const synStyle = paint.synStyle; -const highlightFileRange = paint.highlightFileRange; -const cellStyle = paint.cellStyle; - -// 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; - -// The stats overlay, toggled by the "Debug" builtin. Global (like colors_on) so the -// central command dispatcher can flip it; the render reads it. -var show_debug: bool = false; -var ov_pinch_scale: f32 = 1.0; -var ov_touch_scroll_delta: f32 = 0.0; - -// Builtins. Executing the name (middle-click / Tab) runs the action through the ONE -// dispatcher (onSelect + actOnSelection), no matter where the name is shown — a pane -// body, a file, the tagbar. onSelect matches against the whole list, so a name works -// wherever it appears. Window-specific ones (Save, Del, Delcol) live in each -// pane's tag or can be typed/executed; window-agnostic ones (Kill, Newcol, Tutor, -// Debug, Colors, NextColor) in the tagbar. -const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, dump }; -const Builtin = struct { name: []const u8, action: SelectAction }; -const builtins = [_]Builtin{ - .{ .name = "Kill", .action = .kill }, - .{ .name = "Newcol", .action = .newcol }, - .{ .name = "Tutor", .action = .tutor }, - .{ .name = "Debug", .action = .debug }, - .{ .name = "Colors", .action = .colors }, - .{ .name = "NextColor", .action = .nextcolor }, - .{ .name = "Dump", .action = .dump }, - .{ .name = "Save", .action = .save }, - .{ .name = "Del", .action = .del }, - .{ .name = "Delcol", .action = .delcol }, -}; -// Default per-pane tag commands. Delcol remains a builtin command, but it is no -// longer advertised in the default tag text. -const pane_builtins_str = "Del"; -const file_pane_builtins_str = "Save Del"; -// default editable tails (builtins with the leading gap after the cwd/path). -const pane_tail = " " ++ pane_builtins_str; -const file_pane_tail = " " ++ file_pane_builtins_str; -// the global tagbar: window-agnostic commands. Kill closes everything; Debug toggles -// the stats overlay, Colors the ansi/syntax recolor, NextColor cycles the theme. -const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump"; - -fn writeStdoutBestEffort(bytes: []const u8) void { - writeFdBestEffort(1, bytes); -} - -fn paintStartupTopbar() void { - writeStdoutBestEffort("\x1b[?1049h\x1b[H" ++ topbar_str); -} - -fn writeStartupPerfMarker(start_ns: u64) void { - const elapsed_us = (nowNs() - start_ns) / 1000; - var buf: [64]u8 = undefined; - const marker = std.fmt.bufPrint(&buf, "\x1b]1337;PARDES_STARTUP_US={d}\x07", .{elapsed_us}) catch return; - writeStdoutBestEffort(marker); -} - -// 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 = if (shell_reports_cwd) - \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" - \\PS1='\[\e]7;file://localhost$PWD\a\]\[\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 - \\ -else - \\[ -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 fd = openCreateTrunc(path, false) orelse return; - defer closeFdBestEffort(fd); - writeFdBestEffort(fd, data); -} - -fn saveFile(io: std.Io, f: *Term.File) !void { - var file = try std.Io.Dir.createFileAbsolute(io, f.path, .{}); - defer file.close(io); - try file.writeStreamingAll(io, f.content); -} - -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 - writeFdBestEffort(t.pty.handle, bytes); -} - -fn fileLineCount(content: []const u8) usize { - return std.mem.count(u8, content, "\n") + 1; -} - -fn lineStartByte(content: []const u8, row: usize) usize { - var off: usize = 0; - var r: usize = 0; - while (r < row) : (r += 1) { - const rel = std.mem.indexOfScalarPos(u8, content, off, '\n') orelse return content.len; - off = rel + 1; - } - return off; -} - -fn visibleByteRange(content: []const u8, row: usize, rows: usize) struct { start: usize, end: usize } { - const start = lineStartByte(content, row); - const end = lineStartByte(content, row + rows); - return .{ .start = start, .end = @max(start, end) }; -} - -// 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); - const next: usize = @intCast(n); - if (next != f.scroll) { - f.scroll = next; - f.syntax_dirty = true; - } - } else { - t.term.screens.active.scroll(.{ .delta_row = @intCast(delta) }); - } -} - -fn hoveredPane(terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_TERMS]Rect, mcol: u16, mrow: u16) ?usize { - return 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; -} - -fn handleWheelMouse( - terms: *[MAX_TERMS]?*Term, - rects: *const [MAX_TERMS]Rect, - screen_w: u16, - screen_h: u16, - hover_col: *u16, - hover_row: *u16, - mouse: vaxis.Mouse, -) bool { - if (mouse.button != .wheel_down and mouse.button != .wheel_up) return false; - const mpos = pane_ui.clampMousePos(screen_w, screen_h, mouse); - const mcol = mpos.col; - const mrow = mpos.row; - hover_col.* = mcol; - hover_row.* = mrow; - if (hoveredPane(terms, rects, mcol, mrow)) |id| { - if (terms[id]) |t| paneScrollBy(t, if (mouse.button == .wheel_down) 1 else -1); - } - return true; -} - -// 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. Linux keeps the strongest existing -// behavior by reading /proc/<pid>/cwd, which follows external `cd` commands -// without shell cooperation. macOS uses OSC 7 emitted by our bash rc. BSDs use -// the spawn cwd cached on Term until a native live-cwd implementation is added. -fn termCwd(t: *Term, buf: []u8) []const u8 { - if (!t.isTerminal()) return "?"; // file/image pane: no shell, no cwd - switch (os_tag) { - .linux => { - var pathbuf: [64]u8 = undefined; - const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch return "?"; - const rc = linux.readlinkat(linux.AT.FDCWD, path, buf.ptr, buf.len); - const n: isize = @bitCast(rc); - if (n < 0) return "?"; - return buf[0..@intCast(n)]; - }, - .macos, .freebsd, .openbsd, .netbsd, .dragonfly => return t.cwd orelse "?", - else => return t.cwd orelse "?", - } -} - -// 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 { - switch (os_tag) { - .linux => { - const rc = linux.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0); - const sfd: isize = @bitCast(rc); - if (sfd < 0) return false; - _ = linux.close(@intCast(sfd)); - return true; - }, - else => { - const fd = posix.openatZ(posix.AT.FDCWD, path, .{ - .ACCMODE = .RDONLY, - .DIRECTORY = true, - .CLOEXEC = true, - }, 0) catch return false; - closeFdBestEffort(fd); - return true; - }, - } -} - -// The LIVE prefix of a window tag: mode indicator + cwd/path. Regenerated every -// frame; the editable tail (builtins onward) lives in t.tag_tail. Caller frees. -// Image panes have no editable tail, so their whole tag (toggles + builtins) is -// returned here and stays live/non-editable. Caller frees with `alloc`. -fn tagPrefix(alloc: std.mem.Allocator, t: *Term) ![]u8 { - 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; 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}", .{ fmode, f.path }); - } - 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}", .{ mode, cwd }); -} - -// The editable tail: the user's edited buffer once touched, else the default -// builtins. Empty for image panes (whole tag is live). Borrowed, not owned. -fn defaultTail(t: *Term) []const u8 { - return if (t.file != null) file_pane_tail else pane_tail; -} - -fn curTail(t: *Term) []const u8 { - if (t.image != null) return ""; - return if (t.tag_init) t.tag_tail.items else defaultTail(t); -} - -// prefix + tail: the full rendered/selectable tag line. Caller frees with `alloc`. -fn fullTag(alloc: std.mem.Allocator, t: *Term) ![]u8 { - const pfx = try tagPrefix(alloc, t); - const tail = curTail(t); - if (tail.len == 0) return pfx; - defer alloc.free(pfx); - return std.fmt.allocPrint(alloc, "{s}{s}", .{ pfx, tail }); -} - -// Focus the tag for editing, seeding the tail with the default builtins on first -// touch and placing the cursor under `click_col` (a column within the text area). -// Enters NORMAL mode (like a body left-click), so the tag then follows the pane's -// modal keys — i/a to insert, hjkl/w/b/e to move, v to select, Enter/Tab chords. -fn enterTagEdit(gpa: std.mem.Allocator, alloc: std.mem.Allocator, t: *Term, click_col: i32) void { - if (t.image != null) return; // images keep a live toggle tag, no editable tail - const pfx = tagPrefix(alloc, t) catch return; - if (!t.tag_init) { - t.tag_tail.appendSlice(gpa, defaultTail(t)) catch return; - t.tag_init = true; - } - t.tag_edit = true; - t.tag_sel = false; - t.mode = .normal; - t.pending = 0; - const cc = std.math.clamp(click_col - @as(i32, @intCast(pfx.len)), 0, @as(i32, @intCast(t.tag_tail.items.len))); - t.tag_col = @intCast(cc); -} - -// leave tag editing (used when a body click / edit steals focus). -fn exitTagEdit(t: *Term) void { - t.tag_edit = false; - t.tag_sel = false; -} - -// inclusive char-select bounds within the tail: [lo, hi] (hi < len). -fn tagSelBounds(t: *Term) struct { lo: usize, hi: usize } { - const a: usize = t.tag_anchor; - const c: usize = t.tag_col; - return .{ .lo = @min(a, c), .hi = @max(a, c) }; -} - -// The text a tag Enter/Tab chord acts on: the char-selection if active, else the -// file-ish word under the cursor (acme isfilec). Borrowed from `alloc`; null if -// there's nothing under the cursor. -fn tagChordText(alloc: std.mem.Allocator, t: *Term) ?[]const u8 { - const tail = t.tag_tail.items; - if (t.tag_sel) { - const b = tagSelBounds(t); - const hi = @min(b.hi + 1, tail.len); - if (hi > b.lo) return alloc.dupe(u8, tail[b.lo..hi]) catch null; - return null; - } - const col: usize = @min(@as(usize, t.tag_col), tail.len); - var lo = col; - while (lo > 0 and isFileChar(tail[lo - 1])) lo -= 1; - var hi = col; - while (hi < tail.len and isFileChar(tail[hi])) hi += 1; - if (hi > lo) return alloc.dupe(u8, tail[lo..hi]) catch null; - return null; -} - -// A single-line view of the tail for the modal motion helpers (cursor row is -// always 0). The tail never contains a newline (inserts strip them). -fn tagLines(t: *Term) [1][]const u8 { - return .{t.tag_tail.items}; -} - -// tag-edit key dispatch. The tag shares the pane's `mode`: normal keys navigate/ -// select/edit; insert keys type. Newlines are always dropped (one-line tag). -fn handleTagKey(gpa: std.mem.Allocator, t: *Term, key: vaxis.Key) void { - if (t.mode == .insert) return tagInsertKey(gpa, t, key); - - const lines = tagLines(t); - const cur: modal.Cursor = .{ .row = 0, .col = t.tag_col }; - - // `g`-prefixed motions (gg/ge/gh/gl), matching the body. - if (t.pending == 'g') { - t.pending = 0; - const m: modal.Cursor = if (key.matches('g', .{})) modal.gotoFirst() else if (key.matches('e', .{})) modal.gotoLast(&lines) else if (key.matches('h', .{})) modal.lineStart(cur) else if (key.matches('l', .{})) modal.lineEnd(&lines, cur) else cur; - t.tag_col = @intCast(m.col); - return; - } - if (key.matches('g', .{})) { - t.pending = 'g'; - return; - } - - // motions: move the cursor (head); a live selection keeps its anchor. - const moved: ?modal.Cursor = - if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) modal.charLeft(cur) else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) modal.charRight(&lines, cur) else if (key.matches('w', .{})) modal.nextWordStart(&lines, cur, false) else if (key.matches('W', .{})) modal.nextWordStart(&lines, cur, true) else if (key.matches('b', .{})) modal.prevWordStart(&lines, cur, false) else if (key.matches('B', .{})) modal.prevWordStart(&lines, cur, true) else if (key.matches('e', .{})) modal.nextWordEnd(&lines, cur, false) else if (key.matches('E', .{})) modal.nextWordEnd(&lines, cur, true) else if (key.matches('0', .{}) or key.matches(vaxis.Key.home, .{})) modal.lineStart(cur) else if (key.matches('$', .{}) or key.matches(vaxis.Key.end, .{})) modal.lineEnd(&lines, cur) else if (key.matches('^', .{})) modal.firstNonWsOf(&lines, cur) else null; - if (moved) |m| { - t.tag_col = @intCast(m.col); - return; - } - - // insert entry (no o/O — those open a new line) - if (key.matches('i', .{})) return tagEnterInsert(t, t.tag_col); - if (key.matches('a', .{})) return tagEnterInsert(t, @intCast(modal.charRight(&lines, cur).col)); - if (key.matches('I', .{})) return tagEnterInsert(t, @intCast(modal.firstNonWsOf(&lines, cur).col)); - if (key.matches('A', .{})) return tagEnterInsert(t, @intCast(t.tag_tail.items.len)); - - // select / edit - if (key.matches('v', .{})) { - t.tag_sel = !t.tag_sel; - if (t.tag_sel) t.tag_anchor = t.tag_col; - return; - } - if (key.matches('x', .{})) { // select the whole one-line tail - const len = t.tag_tail.items.len; - if (len > 0) { - t.tag_sel = true; - t.tag_anchor = 0; - t.tag_col = @intCast(len - 1); - } - return; - } - if (key.matches('d', .{})) return tagDelete(gpa, t, false); - if (key.matches('c', .{})) return tagDelete(gpa, t, true); - if (key.matches('y', .{})) return tagYank(gpa, t); - if (key.matches('p', .{})) return tagPaste(gpa, t); - // Esc escalates: clear the selection, else hand focus back to the body. - if (key.matches(vaxis.Key.escape, .{})) { - if (t.tag_sel) t.tag_sel = false else exitTagEdit(t); - return; - } -} - -// begin insert mode at `col` within the tail. -fn tagEnterInsert(t: *Term, col: u16) void { - t.tag_col = @min(col, @as(u16, @intCast(t.tag_tail.items.len))); - t.tag_sel = false; - t.mode = .insert; - t.pending = 0; -} - -fn tagInsertKey(gpa: std.mem.Allocator, t: *Term, key: vaxis.Key) void { - if (key.matches(vaxis.Key.escape, .{})) { - t.mode = .normal; - return; - } - if (key.text) |text| { - for (text) |ch| { - if (ch == '\n' or ch == '\r') continue; // one-line tag - t.tag_tail.insert(gpa, t.tag_col, ch) catch return; - t.tag_col += 1; - } - return; - } - switch (key.codepoint) { - vaxis.Key.enter => {}, // no newline - vaxis.Key.backspace => if (t.tag_col > 0) { - _ = t.tag_tail.orderedRemove(t.tag_col - 1); - t.tag_col -= 1; - }, - vaxis.Key.left => if (t.tag_col > 0) { - t.tag_col -= 1; - }, - vaxis.Key.right => if (t.tag_col < t.tag_tail.items.len) { - t.tag_col += 1; - }, - vaxis.Key.home => t.tag_col = 0, - vaxis.Key.end => t.tag_col = @intCast(t.tag_tail.items.len), - else => {}, - } -} - -// `d`/`c`: delete the selection (or the char under the cursor), yanking it; `c` -// then enters insert. Cursor lands at the start of the removed span. -fn tagDelete(gpa: std.mem.Allocator, t: *Term, change: bool) void { - const tail = t.tag_tail.items; - var lo: usize = undefined; - var count: usize = undefined; - if (t.tag_sel) { - const b = tagSelBounds(t); - lo = b.lo; - count = @min(b.hi + 1, tail.len) - b.lo; - } else { - lo = @min(@as(usize, t.tag_col), tail.len); - count = if (lo < tail.len) 1 else 0; - } - if (count > 0) { - setYank(gpa, tail[lo .. lo + count]); - t.tag_tail.replaceRange(gpa, lo, count, "") catch return; - } - t.tag_col = @intCast(lo); - t.tag_sel = false; - if (change) t.mode = .insert; -} - -fn tagYank(gpa: std.mem.Allocator, t: *Term) void { - const tail = t.tag_tail.items; - if (t.tag_sel) { - const b = tagSelBounds(t); - const hi = @min(b.hi + 1, tail.len); - setYank(gpa, tail[b.lo..hi]); - t.tag_col = @intCast(b.lo); - t.tag_sel = false; - } else setYank(gpa, tail); // whole tail, like a line yank -} - -fn tagPaste(gpa: std.mem.Allocator, t: *Term) void { - const y = yank_reg orelse return; - const col: usize = @min(@as(usize, t.tag_col), t.tag_tail.items.len); - var at = col; - for (y) |ch| { - if (ch == '\n' or ch == '\r') continue; // one-line tag - t.tag_tail.insert(gpa, at, ch) catch break; - at += 1; - } - t.tag_col = @intCast(at); -} - -// 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: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; - _ = posix.system.ioctl(pty.handle, tiocswinszReq(), @intFromPtr(&ws)); -} - -fn ioctlReq(bits: u32) c_int { - return @bitCast(bits); -} - -fn tiocswinszReq() c_int { - return switch (os_tag) { - .linux => @intCast(posix.T.IOCSWINSZ), - .macos, .freebsd, .openbsd, .netbsd, .dragonfly => ioctlReq(0x80087467), - else => @intCast(posix.T.IOCSWINSZ), - }; -} - -// 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: [0x10000]u8 = undefined; - var reader = pty.readerStreaming(io, &read_buf); - while (true) { - var buf: [0x10000]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 {}; -} - -fn feedPtyRead(gpa: std.mem.Allocator, terms: *[MAX_TERMS]?*Term, active: usize, pr: PtyRead) void { - defer gpa.free(pr.bytes); - if (pr.id >= MAX_TERMS) return; - if (terms[pr.id]) |t| { - if (shell_reports_cwd) updateCwdFromOsc7(gpa, t, pr.bytes); - t.tty_stream.appendSlice(gpa, pr.bytes) catch {}; - 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. - if (!(pr.id == active and t.mode != .tty)) - t.term.screens.active.scroll(.active); - } -} - -fn updateCwdFromOsc7(gpa: std.mem.Allocator, t: *Term, bytes: []const u8) void { - if (!t.isTerminal()) return; - for (bytes) |b| switch (t.cwd_osc_state) { - .normal => { - if (b == 0x1b) t.cwd_osc_state = .esc; - }, - .esc => { - t.cwd_osc_state = if (b == ']') .osc else if (b == 0x1b) .esc else .normal; - }, - .osc => { - t.cwd_osc_state = if (b == '7') .osc7 else if (b == 0x1b) .esc else .normal; - }, - .osc7 => { - if (b == ';') { - t.cwd_osc.clearRetainingCapacity(); - t.cwd_osc_state = .collect; - } else { - t.cwd_osc_state = if (b == 0x1b) .esc else .normal; - } - }, - .collect => switch (b) { - 0x07 => { - finishCwdOsc(gpa, t); - }, - 0x1b => { - t.cwd_osc_state = .st_esc; - }, - else => { - if (t.cwd_osc.items.len < std.fs.max_path_bytes) { - t.cwd_osc.append(gpa, b) catch { - t.cwd_osc_state = .normal; - }; - } else { - t.cwd_osc.clearRetainingCapacity(); - t.cwd_osc_state = .normal; - } - }, - }, - .st_esc => { - if (b == '\\') finishCwdOsc(gpa, t) else t.cwd_osc_state = .normal; - }, - }; -} - -fn finishCwdOsc(gpa: std.mem.Allocator, t: *Term) void { - defer { - t.cwd_osc.clearRetainingCapacity(); - t.cwd_osc_state = .normal; - } - const path = osc7Path(t.cwd_osc.items) orelse return; - if (path.len == 0 or path[0] != '/') return; - const next = gpa.dupe(u8, path) catch return; - if (t.cwd) |old| gpa.free(old); - t.cwd = next; -} - -fn osc7Path(raw: []const u8) ?[]const u8 { - if (std.mem.startsWith(u8, raw, "file://")) { - const rest = raw["file://".len..]; - const slash = std.mem.indexOfScalar(u8, rest, '/') orelse return null; - return rest[slash..]; - } - if (std.mem.startsWith(u8, raw, "file:")) return raw["file:".len..]; - return raw; -} - -// 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(io: std.Io, 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 cwd_copy = if (cwd) |c| try gpa.dupe(u8, std.mem.span(c)) else blk: { - var current: [std.fs.max_path_bytes]u8 = undefined; - const n = try std.process.currentPath(io, ¤t); - break :blk try gpa.dupe(u8, current[0..n]); - }; - errdefer gpa.free(cwd_copy); - 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 - .cwd = cwd_copy, - .cols = init_cols, - .rows = init_rows, - }; - t.stream = t.term.vtStream(); - // Answer terminal QUERIES back into the pty. Apps (nushell/reedline, helix, fzf - // — anything on crossterm) query the terminal at startup and BLOCK on the reply. - // ghostty computes the reply but drops it unless these effects are wired, so the - // app spins forever. write_pty is the sink for DSR cursor-position (ESC[6n) and - // the kitty-keyboard report (ESC[?u); device_attributes answers primary DA - // (ESC[c), which crossterm sends as `ESC[?u ESC[c` and waits on the DA as the - // TERMINATOR — without it helix hangs after entering its alt screen (a blank - // pane). Only real terminals need this (file/image stubs read no pty). - t.stream.handler.effects.write_pty = ptyReport; - t.stream.handler.effects.device_attributes = ptyDeviceAttrs; - return t; -} - -// ghostty calls this with a reply (cursor-position report, kitty-keyboard flags, -// operating status, an encoded DA, ...) to send back to the child. The handler's -// `terminal` is our Term.term field, so recover the owning Term and echo the bytes -// into its pty as if the child typed them. termWrite no-ops on a closed pty, so a -// late report after EOF is harmless. -fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { - const t: *Term = @fieldParentPtr("term", handler.terminal); - termWrite(t, data); -} - -// device_attributes.Attributes isn't re-exported by the ghostty-vt module, so name -// its type by reflection off the effects field. The default (VT220 + ansi_color) is -// a fine DA reply — its only job here is to TERMINATE crossterm's capability probe. -const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( - @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"), -).optional.child).pointer.child).@"fn".return_type.?; -fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { - return .{}; -} - -// 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 }; -} - -fn readFileAlloc(io: std.Io, gpa: std.mem.Allocator, path: [*:0]const u8) ![]u8 { - return std.Io.Dir.cwd().readFileAlloc(io, std.mem.span(path), gpa, .limited(std.math.maxInt(usize))); -} - -fn refreshFileHighlightsVisible(gpa: std.mem.Allocator, f: *Term.File, rows: usize) void { - const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS; - const range = visibleByteRange(f.content, start_row, SYNTAX_CONTEXT_BEFORE_ROWS + rows + SYNTAX_CONTEXT_AFTER_ROWS); - if (!colors_on) { - if (f.highlights.len > 0) gpa.free(f.highlights); - f.highlights = &.{}; - f.highlight_start = 0; - f.syntax_dirty = false; - return; - } - const new_highlights = highlightFileRange(gpa, f.path, f.content, range.start, range.end) catch { - f.syntax_dirty = false; - return; - }; - if (f.highlights.len > 0) gpa.free(f.highlights); - f.highlights = new_highlights; - f.highlight_start = if (f.highlights.len > 0) range.start else 0; - f.syntax_dirty = false; -} - -fn refreshDirtyFileHighlightsVisible(gpa: std.mem.Allocator, terms: *[MAX_TERMS]?*Term) bool { - var refreshed = false; - for (terms) |*slot| { - if (slot.*) |t| { - if (t.file) |*f| { - if (f.syntax_dirty) { - refreshFileHighlightsVisible(gpa, f, t.rows); - refreshed = true; - } - } - } - } - return refreshed; -} - -// Replace a file pane's content without synchronously reparsing the whole file. -// The old style map remains in use until a bounded visible-range refresh catches -// up, so edits do not flash to unhighlighted text. -fn setFileContent(gpa: std.mem.Allocator, f: *Term.File, new: []u8) void { - const had_syntax = f.syntax_dirty or f.highlights.len > 0; - gpa.free(f.content); - f.content = new; - if (had_syntax and colors_on) { - // Keep the old style map while dirty. This preserves most visible color - // state during input batching; the synchronous visible-range refresh below - // swaps in a correctly offset map before the next frame is rendered. - f.syntax_dirty = true; - } else { - if (f.highlights.len > 0) gpa.free(f.highlights); - f.highlights = &.{}; - f.highlight_start = 0; - f.syntax_dirty = false; - } -} - -fn setFileContentStaleSyntax(gpa: std.mem.Allocator, f: *Term.File, new: []u8) void { - setFileContent(gpa, f, new); -} - -// 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(io: std.Io, gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u16, rows: u16) !*Term { - const content = try readFileAlloc(io, gpa, rp); - errdefer gpa.free(content); - const path = try gpa.dupe(u8, std.mem.span(rp)); - errdefer gpa.free(path); - const t = try gpa.create(Term); - errdefer gpa.destroy(t); - const total = fileLineCount(content); - const scroll: usize = if (line > 0 and line <= total) line - 1 else 0; - t.* = .{ - .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } }, - .pid = 0, - .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }), - .stream = undefined, - .reader = .{ .any_future = null, .result = {} }, - .file = .{ .path = path, .content = content, .scroll = scroll, .highlights = &.{}, .syntax_dirty = true }, - .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 }); -} - -// 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; -} - -// 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(io, 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; -} - -fn termVWeight(terms: *const [MAX_TERMS]?*Term, id: usize) ?f32 { - return if (terms[id]) |t| t.vweight else null; -} - -fn setTermVWeight(terms: *[MAX_TERMS]?*Term, id: usize, weight: f32) void { - if (terms[id]) |t| t.vweight = weight; -} - -// `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: live prefix (mode + cwd/path) + editable tail (builtins onward), drawn - // as a dim bar; plain selectable text, tail editable in place (handleTagKey). - const tag_pfx = try tagPrefix(alloc, t); - const tail = curTail(t); - const tag = if (tail.len == 0) tag_pfx else try std.fmt.allocPrint(alloc, "{s}{s}", .{ tag_pfx, tail }); - 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 }); - // char-selection highlight on the tail (helix `v`/`x`), inclusive [lo, hi]. - if (t.tag_edit and t.tag_sel) { - const b = tagSelBounds(t); - var col: usize = tag_pfx.len + b.lo; - const end: usize = tag_pfx.len + b.hi; - while (col <= end and col < r.w - GUTTER) : (col += 1) { - if (text.readCell(@intCast(col), 0)) |cell| { - var nc = cell; - nc.default = false; - nc.style.bg = .{ .rgb = msel_bg }; - nc.style.fg = .{ .rgb = msel_fg }; - text.writeCell(@intCast(col), 0, nc); - } - } - } - // cursor while editing the tail: prefix length + column within the tail. - if (active and t.tag_edit) { - const cx: usize = tag_pfx.len + t.tag_col; - if (cx < r.w - GUTTER) text.showCursor(@intCast(cx), 0); - } - - // 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); - pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, null); - 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 idx = base + c; - if (idx < f.highlight_start) continue; - const hidx = idx - f.highlight_start; - if (hidx >= f.highlights.len) continue; - const ss = synStyle(@enumFromInt(f.highlights[hidx])) 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. .dragging = a live drag (any button); a left - // .done is a FINISHED selection that is kept highlighted after release (it - // clears on the next left click or a new left drag, and feeds the middle+left - // chord as the argument). middle/right .done are transient (they fire their - // action on release) so they are not kept visible. - for (t.sel, 0..) |sl, b| { - if (sl.state == .none) continue; - if (sl.state == .done and b != @intFromEnum(Button.left)) 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 and !t.tag_edit) { - 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); - } - } - - const scrollbar: pane_ui.Scrollbar = if (t.file) |f| .{ - .total = fileLineCount(f.content), - .offset = f.scroll, - .len = t.rows, - } else blk: { - const sb = t.term.screens.active.pages.scrollbar(); - break :blk .{ .total = sb.total, .offset = sb.offset, .len = sb.len }; - }; - pane_ui.drawGutter(root, r, active, accent, accent_dim, T().scroll_track, T().scroll_thumb, scrollbar); -} - -// ---- 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, always with row0 = 0 (absolute rows). File: all -// content lines. Terminal: the WHOLE history+active grid, not just the viewport, so -// h/j/k/l/w/b/e/gg/G and the page motions ride the scrollback and ensureCursorVisible -// scrolls the viewport to follow — exactly like a file pane. `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 }; - } - // Same prompt-hide + edit-splice transform as bodyText, but over every screen row - // (dump .screen, not .viewport) so the visible window still lines up with what's - // rendered while off-screen scrollback becomes navigable. paneCursorLines only runs - // in normal/insert (never tty), so prompt rows are always hidden here. - // ponytail: O(scrollback) dump+scan per keystroke — fine for normal sessions; window - // it around the viewport if a multi-MB scrollback ever makes navigation lag. - const full = try t.term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(full); - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(alloc); - var it = std.mem.splitAny(u8, full, "\n"); - var pit = t.term.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var i: usize = 0; - while (it.next()) |raw| : (i += 1) { - if (i > 0) try out.append(alloc, '\n'); - const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false; - try spliceRow(alloc, &out, t, @intCast(i), if (is_prompt) "" else raw); - } - const body = try out.toOwnedSlice(alloc); - var ls: std.ArrayList([]const u8) = .empty; - var lit = std.mem.splitScalar(u8, body, '\n'); - while (lit.next()) |ln| try ls.append(alloc, ln); - return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = 0, .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; - setFileContentStaleSyntax(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; - setFileContentStaleSyntax(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; configured Ctrl key -> 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 configured Ctrl-key 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 configured Ctrl-key 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); - // `p` is intercepted in the main loop: it pastes the SYSTEM clipboard, which - // requires an OSC 52 round-trip (async .paste event), so it can't be done here. - 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; - setFileContentStaleSyntax(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; - setFileContentStaleSyntax(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; - setFileContentStaleSyntax(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; - setFileContentStaleSyntax(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 => {}, - } -} - -fn isBatchableInsertKey(key: vaxis.Key) bool { - if (key.mods.ctrl or key.mods.alt) return false; - if (key.text != null) return true; - return key.matches(vaxis.Key.backspace, .{}); -} - -fn isBatchableNormalMoveKey(key: vaxis.Key) bool { - if (key.mods.alt) return false; - if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) return true; - if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) return true; - if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) return true; - if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) return true; - if (key.matches('d', .{ .ctrl = true }) or key.matches(vaxis.Key.page_down, .{})) return true; - if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.page_up, .{})) return true; - if (key.matches('f', .{ .ctrl = true }) or key.matches('b', .{ .ctrl = true })) return true; - return false; -} - -// 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). -// -// `arg` is the middle+left chord argument: a kept left selection captured by a -// left press during the middle drag. For a CLI (`.send`) it is appended as a -// trailing argument; each builtin arm decides for itself whether to use it. -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, - arg: ?[]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(io, 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)) { - const next = pl.line - 1; - if (next != ff.scroll) { - ff.scroll = next; - ff.syntax_dirty = true; - } - }; - } 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(io, gpa, rp, pl.line, sw, sh) catch null); - if (opened) |nt| { - terms[free] = nt; - // If a doc pane (file/image) already exists, SPLIT it - // (acme-style, minimal reflow): drop the new pane directly - // below the source and shrink ONLY it — the rest of the - // column keeps its sizes. Same rule as the tty right-click - // split. Prefer the pane the look came from when it's a doc; - // else the first doc pane (docs live in the LEFT column). - var src_id: ?usize = null; - if (t.file != null or t.image != null) src_id = id; - if (src_id == null) for (terms.*, 0..) |sl, i| { - if (sl) |p| if ((p.file != null or p.image != null) and i != free) { - src_id = i; - break; - }; - }; - if (src_id) |sid| { - const sf = layoutFindTerm(layout, sid).?; - layoutInsert(layout, sf.col, sf.idx + 1, free); - splitBelow(terms[sid].?, nt, rects[sid].h); - active.* = free; - } else { - // No doc pane yet: stack at the top of the LEFT column - // with a fair share, 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 => {}, - .debug => show_debug = !show_debug, - .colors => colors_on = !colors_on, - .nextcolor => paint.nextTheme(), - .dump => dumpSnapshot(io, gpa, terms, layout, active.*, sw, sh) catch |err| { - std.log.warn("dump {s}: {}", .{ dump.default_path, err }); - }, - .kill => return true, // close everything: signal the main loop to quit - .save => { - if (t.file) |*f| saveFile(io, f) catch |err| { - std.log.warn("save {s}: {}", .{ f.path, err }); - }; - }, - .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); - // a chord argument (a kept left selection) is passed to the CLI as a - // trailing argument; builtins above decide for themselves (most ignore). - if (arg) |a| { - const at = std.mem.trim(u8, a, " \t\r\n"); - if (at.len > 0) { - termWrite(dt, " "); - termWrite(dt, at); - } - } - 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(io, 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 startup_main_t0 = nowNs(); - const io = init.io; - const gpa = init.gpa; - - const args = try init.minimal.args.toSlice(init.arena.allocator()); - const cli = parseCli(args) catch |err| { - try printHelp(io); - return err; - }; - if (cli.help) { - try printHelp(io); - return; - } - const tty_only = cli.tty_only; - const tty_toggle = cli.tty_toggle; - const perf_startup = cli.perf_startup; - if (cli.debug_overlay) show_debug = true; - - var screen_w: u16 = 80; - var screen_h: u16 = 24; - var resize_count: usize = 0; - - var startup_alt_screen = false; - if (vaxis.supports_terminal_prepaint) { - paintStartupTopbar(); - if (perf_startup) writeStartupPerfMarker(startup_main_t0); - startup_alt_screen = true; - } - errdefer if (startup_alt_screen) - writeStdoutBestEffort("\x1b[?1049l"); - - var tty_buf: [0x10000]u8 = undefined; - var tty = try vaxis.Tty.init(io, &tty_buf); - - // 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: [192]u8 = undefined; // overlay text; must outlive each vx.render() - - // system_clipboard_allocator lets the loop's reader thread decode OSC 52 paste - // replies (delivered as .paste events). gpa is already used cross-thread by the - // pty readers, so it's safe here too. - var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa }); - defer vx.deinit(gpa, tty.writer()); - if (startup_alt_screen) { - vaxis.adoptAltScreen(&vx); - } else { - try vx.enterAltScreen(tty.writer()); - } - defer vx.exitAltScreen(tty.writer()) catch {}; - startup_alt_screen = false; - - try vx.setMouseMode(tty.writer(), true); - - // ---- 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(io, 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(io, gpa, screen_w, screen_h, null); - terms[1] = try forkTerm(io, gpa, screen_w, screen_h, null); - terms[2] = try forkTerm(io, 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(2)) 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; - pane_ui.computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w, termVWeight); - - 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 - // middle+left chord argument: a kept left selection captured by a left press - // during an active middle select-drag. gpa-owned; consumed (and freed) on the - // middle release, freed on overwrite / app exit. - var chord_arg: ?[]u8 = null; - defer if (chord_arg) |a| gpa.free(a); - // Paint the chrome once immediately. Without this, startup waits for the - // first winsize/pty event before the user sees a frame. - var pending_event: ?@TypeOf(Command.value) = .tick; - - while (true) { - const event = if (pending_event) |ev| blk: { - pending_event = null; - break :blk ev; - } else try loop.nextEvent(); - var drain_pty_after = false; - var drain_insert_keys = false; - var drain_scroll_events = false; - var drain_normal_moves = false; - switch (event) { - .mouse => |mouse| { - const mpos = pane_ui.clampMousePos(screen_w, screen_h, mouse); - const mcol = mpos.col; - const mrow = mpos.row; - 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; - - // Any press that is NOT the middle+left chord (a left press while a - // middle select-drag is active) abandons a pending chord argument; - // free it so it can't leak or feed a later, unrelated release. - if (mouse.type == .press and - !(mouse.button == .left and drag == .select and drag.select.button == .middle)) - { - if (chord_arg) |a| { - gpa.free(a); - chord_arg = null; - } - } - - if (mouse.button == .wheel_down) { - if (hovered) |id| paneScrollBy(terms[id].?, 1); - drain_scroll_events = true; - } else if (mouse.button == .wheel_up) { - if (hovered) |id| paneScrollBy(terms[id].?, -1); - drain_scroll_events = true; - } 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 (word.len > 0) - if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, .middle, active, word, null)) break; - } - } else if (mouse.type == .press and mouse.button == .left) { - var handled = false; - - // middle+left chord: a left press while a middle select-drag is - // active captures the KEPT left selection as the argument for the - // middle execute (fired on the middle release below). Don't start - // a new left selection — the middle drag runs until its own - // release, which dispatches the command with chord_arg. - if (drag == .select and drag.select.button == .middle) { - handled = true; - if (terms[drag.select.id]) |t| { - const sl = t.sel[@intFromEnum(Button.left)]; - if (sl.state == .done) { - if (chord_arg) |old| gpa.free(old); - chord_arg = selectionText(gpa, t, sl) catch null; - } - } - } - - // vertical resize handle = the LEFT pane's last column (its own - // content, not the right pane's gutter). The border check runs before - // the pane hit test, so a press there resizes instead of clicking. - var c: usize = 0; - while (c + 1 < layout.ncol and !handled) : (c += 1) { - if (mcol == col_x[c] + col_w[c] -| 1) { - drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } }; - handled = true; - } - } - - // horizontal resize handle = the UPPER pane's last row (a's own last - // line, sitting right above b's tag) — not b's tag row, so the tag + - // its box-drag stay clean. - 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 -| 1) { - 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]; - if (pane_ui.inMoveBox(r, mcol, mrow)) { - drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } }; - } else if (pane_ui.scrollbarClickDelta(r, .left, mcol, mrow)) |delta| { - active = id; - if (terms[id]) |t| { - paneScrollBy(t, delta); // left: scroll up to here - } - } else if (mrow < r.y + BOX_H and terms[id] != null and terms[id].?.image == null) { - // left-click on the tag row (non-image): edit the tail. - active = id; - enterTagEdit(gpa, frame_arena.allocator(), terms[id].?, @as(i32, mcol) - @as(i32, r.x + GUTTER)); - } else { - active = id; - if (terms[id]) |t| { - exitTagEdit(t); // clicking the body leaves tag editing - 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]; - const sb_button: pane_ui.ScrollbarButton = if (b == .right) .right else .middle; - if (pane_ui.scrollbarClickDelta(r, sb_button, mcol, mrow)) |delta| { - // gutter (scrollbar): right scrolls down, mirroring left's up - if (b == .right) active = id; - if (terms[id]) |t| { - paneScrollBy(t, delta); - } - } 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) { - var end_drag = true; - 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]); - // handle sits on the left pane's LAST column (inclusive), - // so the new left width includes it: cur_x - col_x + 1. - // A click with no drag then leaves the width unchanged. - var nl: f32 = @floatFromInt((d.cur_x + 1) -| 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); - // handle is the upper pane's LAST row (inclusive) -> - // new upper height includes it: cur_y - y + 1. - var nt: f32 = @floatFromInt((d.cur_y + 1) -| 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, &terms, &rects, &col_x, &col_w, d.id, d.cur_x, d.cur_y, setTermVWeight); - // 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| { - // A release of a button OTHER than the one driving this - // drag is the middle+left chord's left release: ignore it - // so the middle drag keeps going until its own release - // runs the command (with chord_arg). end_drag stays false. - const released: ?Button = switch (mouse.button) { - .left => .left, - .middle => .middle, - .right => .right, - else => null, - }; - if (released == null or released.? != s.button) { - end_drag = false; - } else if (terms[s.id]) |t| { - t.sel[@intFromEnum(s.button)].state = .done; - if (s.button == .left) { - // left release: keep the selection highlighted if a - // real drag selected a range (it stays until the next - // left click / drag); a plain click (no movement) - // clears it — the selection "goes away" on a left - // click. Either way pin the cursor + enter normal - // mode on text panes (tty keeps the mouse usable). - const sl = t.sel[@intFromEnum(Button.left)]; - const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; - if (!dragged) t.sel[@intFromEnum(Button.left)].state = .none; - 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. - // chord_arg (a kept left selection captured by a left - // press during this drag) is passed as the argument to - // the command; builtins decide what to do with it. - expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]); - const maybe_txt = selectionText(gpa, t, t.sel[@intFromEnum(s.button)]) catch null; - const arg = chord_arg; - chord_arg = null; - defer if (arg) |a| gpa.free(a); - if (maybe_txt) |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, arg)) break; - } - } - } - }, - .none => {}, - } - if (end_drag) drag = .none; - } - }, - .key_press => |key| kpress: { - 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(io, 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; - } - - // Configured Ctrl key: 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 (at.isTerminal() and key.matches(tty_toggle, .{ .ctrl = true })) { - 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). They act on the active selection, - // else the file-ish word under the cursor — on the tag tail when - // it's focused for editing, else the body. - if (at.mode == .normal and (key.matches(vaxis.Key.enter, .{}) or key.matches(vaxis.Key.tab, .{}))) { - const btn: Button = if (key.matches(vaxis.Key.tab, .{})) .middle else .right; - if (at.tag_edit) { - if (tagChordText(frame_arena.allocator(), at)) |txt| { - at.tag_sel = false; - if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, vx.caps.kitty_graphics, btn, active, txt, null)) break; - } - break :kpress; - } - pinCursor(at); - 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, null)) 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, null)) break; - } else |_| {} - } - break :kpress; - } - - // tag editing intercepts every other key: a modal one-line editor - // over the tail, sharing the pane's mode (handleTagKey). - if (at.tag_edit) { - handleTagKey(gpa, at, key); - break :kpress; - } - - // `p` (normal mode) pastes the SYSTEM clipboard: request it now; - // the host replies with an OSC 52 sequence the loop surfaces as a - // .paste event, which inserts the text at the cursor. - if (at.mode == .normal and key.matches('p', .{})) { - vx.requestSystemClipboard(tty.writer()) catch {}; - break :kpress; - } - - // per-mode dispatch. Esc: insert -> normal (normal Esc is a - // no-op, like helix). The configured Ctrl key toggles raw tty - // on terminals (above). - // tty forwards every key to the pty. - switch (at.mode) { - .normal => { - drain_normal_moves = isBatchableNormalMoveKey(key); - 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 { - drain_insert_keys = isBatchableInsertKey(key); - handleInsert(gpa, frame_arena.allocator(), at, key); - } - }, - .tty => forwardToPty(at, key), - } - } - }, - .pty_read => |pr| { - feedPtyRead(gpa, &terms, active, pr); - drain_pty_after = true; - }, - .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); - }, - .paste => |text| { - // OSC 52 clipboard reply to our `p` request: load it into the yank - // register and paste it at the active pane's cursor, reusing the - // normal paste path. The register came FROM the clipboard, so don't - // echo it straight back out. - defer gpa.free(text); - if (terms[active]) |t| { - setYank(gpa, text); - yank_dirty = false; - normalPaste(gpa, t); - } - }, - .pinch => |scale| { - ov_pinch_scale = scale; - }, - .touch_scroll_delta => |delta| { - ov_touch_scroll_delta = delta; - }, - .quit => break, - else => {}, - } - - if (drain_normal_moves and pending_event == null) { - var drained: usize = 0; - while (drained < MAX_KEY_DRAIN_EVENTS) : (drained += 1) { - const next = try loop.tryEvent() orelse break; - var handled = false; - switch (next) { - .key_press => |next_key| { - if (isBatchableNormalMoveKey(next_key)) { - if (terms[active]) |at| { - if (!at.tag_edit and at.mode == .normal) { - handleNormal(gpa, frame_arena.allocator(), at, next_key); - handled = true; - } - } - } - }, - else => {}, - } - if (!handled) { - pending_event = next; - break; - } - } - } - - if (drain_scroll_events and pending_event == null) { - var drained: usize = 0; - while (drained < MAX_SCROLL_DRAIN_EVENTS) : (drained += 1) { - const next = try loop.tryEvent() orelse break; - var handled = false; - switch (next) { - .mouse => |next_mouse| { - handled = handleWheelMouse(&terms, &rects, screen_w, screen_h, &hover_col, &hover_row, next_mouse); - }, - .touch_scroll_delta => |delta| { - ov_touch_scroll_delta = delta; - handled = true; - }, - else => {}, - } - if (!handled) { - pending_event = next; - break; - } - } - } - - if (drain_insert_keys and pending_event == null) { - var drained: usize = 0; - while (drained < MAX_KEY_DRAIN_EVENTS) : (drained += 1) { - const next = try loop.tryEvent() orelse break; - var handled = false; - switch (next) { - .key_press => |next_key| { - if (isBatchableInsertKey(next_key)) { - if (terms[active]) |at| { - if (!at.tag_edit and at.mode == .insert) { - handleInsert(gpa, frame_arena.allocator(), at, next_key); - handled = true; - } - } - } - }, - else => {}, - } - if (!handled) { - pending_event = next; - break; - } - } - } - - if (drain_pty_after) { - var drained: usize = 0; - while (drained < MAX_PTY_DRAIN_EVENTS) : (drained += 1) { - const next = try loop.tryEvent() orelse break; - switch (next) { - .pty_read => |pr| feedPtyRead(gpa, &terms, active, pr), - else => { - pending_event = next; - break; - }, - } - } - } - - // a fresh yank (y/d/c) mirrors out to the system clipboard via OSC 52. Emit - // here where the tty writer + vx are in scope; the vx.render() below flushes. - // ponytail: OSC 52 has a per-terminal size cap, so a huge yank may be - // truncated by the host — the in-app register still holds the full text. - if (yank_dirty) { - yank_dirty = false; - if (yank_reg) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {}; - } - - // 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. - pane_ui.computeGeom(&layout, &terms, screen_w, screen_h, &rects, &col_x, &col_w, termVWeight); - 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; - } - } - } - - _ = refreshDirtyFileHighlightsVisible(gpa, &terms); - - // ---- 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 and no reserved gap: each pane's tagline (top) and - // scrollbar gutter (left) read as the borders, and adjacent panes abut. The - // resize handle is a pane's own trailing edge (left pane's last col / upper - // pane's last row) — hinted by a dash overlay that keeps the cell's colors. - - // resize-handle hint: overlay a dash on the boundary cells, keeping their - // colors (only the glyph changes). Shown while dragging a border and, in the - // else branch, on hover. - pane_ui.drawInteractionPreview(win, &layout, &rects, &col_x, &col_w, screen_h, drag, hover_col, hover_row, gray.mid); - - // 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}\npinch {d:6.4}\ntscroll {d:7.4}", - .{ resize_count, nlive, layout.ncol, active, at.cols, at.rows, sb_off, sb_total, ov_pinch_scale, ov_touch_scroll_delta }, - ) catch ""; - const ow: u16 = 30; - const oh: u16 = 8; - 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); - } -} - -// 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 configured Ctrl-key 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); -} - -fn terminalReplayText(alloc: std.mem.Allocator, t: *Term) ![]u8 { - const full = try t.term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(full); - - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(alloc); - var lines = std.mem.splitAny(u8, full, "\n"); - var prompts = t.term.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); - var row: usize = 0; - while (lines.next()) |raw| : (row += 1) { - if (row > 0) try out.append(alloc, '\n'); - const is_prompt = if (t.mode != .tty) - if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false - else - false; - const line = if (is_prompt) "" else raw; - if (t.mode == .tty) { - try out.appendSlice(alloc, line); - } else { - try spliceRow(alloc, &out, t, @intCast(row), line); - } - } - return out.toOwnedSlice(alloc); -} - -fn dumpSnapshot( - io: std.Io, - gpa: std.mem.Allocator, - terms: *[MAX_TERMS]?*Term, - layout: *const Layout, - active: usize, - sw: u16, - sh: u16, -) !void { - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - const alloc = arena.allocator(); - - var slot_to_pane: [MAX_TERMS]?usize = @splat(null); - var panes: std.ArrayList(dump.Pane) = .empty; - for (terms.*, 0..) |slot, id| { - const t = slot orelse continue; - const compact = panes.items.len; - slot_to_pane[id] = compact; - - const tag = try fullTag(alloc, t); - const body = try bodyText(alloc, t); - const scroll: usize = @intCast(@max(0, paneScroll(t))); - const pane: dump.Pane = if (t.file) |f| .{ - .kind = .file, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = t.cols, - .rows = t.rows, - .vweight = t.vweight, - .file = .{ - .path = f.path, - .content = f.content, - .content_b64 = try dump.encodeBytes(alloc, f.content), - }, - } else if (t.image) |iv| blk: { - const pz = try std.fmt.allocPrintSentinel(alloc, "{s}", .{iv.path}, 0); - const bytes = readFileAlloc(io, alloc, pz) catch ""; - break :blk .{ - .kind = .image, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = t.cols, - .rows = t.rows, - .vweight = t.vweight, - .image = .{ - .path = iv.path, - .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(alloc, bytes) else "", - }, - }; - } else blk: { - var cwdbuf: [1024]u8 = undefined; - const cwd = try alloc.dupe(u8, termCwd(t, &cwdbuf)); - const stream = try terminalReplayText(alloc, t); - break :blk .{ - .kind = .terminal, - .tag = tag, - .body = body, - .scroll = scroll, - .cols = t.cols, - .rows = t.rows, - .vweight = t.vweight, - .terminal = .{ - .cwd = cwd, - .stream = stream, - .stream_b64 = try dump.encodeBytes(alloc, t.tty_stream.items), - .cursor = .{ - .col = t.term.screens.active.cursor.x, - .row = t.term.screens.active.cursor.y, - }, - }, - }; - }; - try panes.append(alloc, pane); - } - - var columns: std.ArrayList(dump.Column) = .empty; - var c: usize = 0; - while (c < layout.ncol) : (c += 1) { - var ids: std.ArrayList(usize) = .empty; - var k: usize = 0; - while (k < layout.col_n[c]) : (k += 1) { - const old_id = layout.col_terms[c][k]; - if (slot_to_pane[old_id]) |compact| try ids.append(alloc, compact); - } - if (ids.items.len > 0) - try columns.append(alloc, .{ .weight = layout.col_weight[c], .panes = try ids.toOwnedSlice(alloc) }); - } - - const state: dump.State = .{ - .screen = .{ .cols = sw, .rows = sh }, - .active = slot_to_pane[active] orelse 0, - .topbar = topbar_str, - .theme = paint.currentThemeName(), - .columns = try columns.toOwnedSlice(alloc), - .panes = try panes.toOwnedSlice(alloc), - }; - try dump.writeFile(io, gpa, dump.default_path, state); -} - -// 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 fullTag(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; -} |
