summaryrefslogtreecommitdiff
path: root/main.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-05 20:21:50 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitde4def3548a6729b0dfd2120495a61beef8c8c2c (patch)
tree12144f0f64dc96bb4741459c1f9db57f44349330 /main.zig
parent7988d9bc6e31210ff13994f18d5622dd57ba9341 (diff)
downloadpardes-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.zig3940
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, &current);
- 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;
-}