diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-14 13:40:29 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-15 11:57:13 -0300 |
| commit | f67fec978a9296c651ec06bd2f43686d34ff86ee (patch) | |
| tree | cbf5568883e5888398e0887093fc5afc524fd54d /src | |
| parent | 9280c597b000eed661fd98793e182fdcb640f6cd (diff) | |
| download | pardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.tar.gz pardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.zip | |
look: richer path/range parsing, pdf rendering, corner-drag and stepgrain snapshots
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui/gui.zig | 19 | ||||
| -rw-r--r-- | src/look.zig | 818 | ||||
| -rw-r--r-- | src/macos.zig | 20 | ||||
| -rw-r--r-- | src/output_pane.zig | 35 | ||||
| -rw-r--r-- | src/pardes.zig | 890 | ||||
| -rw-r--r-- | src/pdf.zig | 303 | ||||
| -rw-r--r-- | src/tty/tty.zig | 26 |
7 files changed, 1862 insertions, 249 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 82866a30..f36de939 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1512,6 +1512,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { defer for (&ptys) |*slot| if (slot.*) |pt| { _ = libc.close(pt.fd); }; + // the core's one way to ask about those ptys, pulled at the Exec that cares + core.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt }; var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, @@ -2062,6 +2064,7 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { defer for (&ptys) |*slot| if (slot.*) |pt| { _ = libc.close(pt.fd); }; + core.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt }; var lsp_workers: LspWorkers = .{}; var queue: Queue = .{ .gpa = gpa, @@ -2908,7 +2911,8 @@ fn drainEffects( } const pt = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w); ptys[sp.pane] = pt; - // report the pane's starting directory back to the core (tags) + // report the pane's starting directory back to the core (tags); the + // slot needs no occupancy reset, nothing about it is remembered var lbuf: [1024]u8 = undefined; if (look.shellCwd(pt.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); if (threads_ok) spawnReader(gpa, pt, sp.pane, gens[sp.pane], queue); @@ -3014,6 +3018,9 @@ fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { return .{ .fd = master, .pid = pid }; } +/// Live cwd for tags/look: a cheap per-pane process lookup, polled every frame +/// because a tagline draws it. Whether a pane's tty still belongs to the prompt +/// pardes forked is deliberately NOT polled with it — see `ttyTakenAt`. fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { for (ptys, 0..) |slot, id| if (slot) |pt| { var lbuf: [1024]u8 = undefined; @@ -3021,6 +3028,16 @@ fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { }; } +/// The core's `tty_query`: is a program (vim, a pager, an agent) holding this +/// pane's tty instead of the shell we forked? Asked by the core only where it is +/// about to type a command line, which is why the /proc walk behind it is not in +/// pollCwds above: nothing draws this answer, and an Exec is a rare frame. +fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool { + const table: *const [pardes.MAX_PANES]?Pty = @ptrCast(@alignCast(ctx orelse return false)); + const pt = table[pane] orelse return false; + return look.ttyTaken(pt.pid, pt.fd); +} + /// web: no ptys, no fs — panes replay from the embedded dump. Only the /// clipboard and open_link effects have a browser meaning. fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { diff --git a/src/look.zig b/src/look.zig index 36390ec2..49be1646 100644 --- a/src/look.zig +++ b/src/look.zig @@ -93,7 +93,13 @@ fn num(tok: []const u8, i: usize) struct { v: usize, end: usize } { /// that keeps the dash safe: `a-b`, `build-2:3` and `x:1-y` are all paths (the /// last one goes back to hunting for a later ':' and finds none), because a /// range needs a number on both sides of its dash. -pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot } { +/// +/// `end` is how far into `tok` the form actually REACHED. The read is lenient +/// by design — `main.zig:100:7x` is the file at line 100 and the mangled `:7x` +/// is simply dropped — so `end == tok.len` is the separate question "is the +/// whole token this target and nothing else", which is what a row-grained step +/// must ask before it selects a run of a line (lookableLineSpan). +pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: usize } { var sep: usize = 0; while (sep < tok.len) : (sep += 1) { if (tok[sep] != config.line_col_sep) continue; @@ -106,37 +112,41 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot } { const e = num(tok, i + 1); if (e.end == i + 1) continue; // a dash with no number is not a range if (e.end < tok.len and tok[e.end] != config.line_col_sep) continue; // junk after it - return .{ .path = path, .at = .{ .line = l.v, .end_line = e.v } }; + return .{ .path = path, .at = .{ .line = l.v, .end_line = e.v }, .end = e.end }; } if (i < tok.len and tok[i] != config.line_col_sep) continue; // junk after the number var at: Spot = .{ .line = l.v }; - if (i == tok.len) return .{ .path = path, .at = at }; + if (i == tok.len) return .{ .path = path, .at = at, .end = i }; // `:COL`. A column that does not parse is dropped and the LINE still - // stands, which is how this has always read a half-mangled suffix. + // stands, which is how this has always read a half-mangled suffix — and + // `end` stops at the last character that DID read, so the caller that + // cares can tell the two apart. const c = num(tok, i + 1); - if (c.end == i + 1) return .{ .path = path, .at = at }; + if (c.end == i + 1) return .{ .path = path, .at = at, .end = i }; if (c.end < tok.len and tok[c.end] != config.line_col_sep and tok[c.end] != config.range_sep) - return .{ .path = path, .at = at }; + return .{ .path = path, .at = at, .end = i }; at.col = c.v; i = c.end; - if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at }; + if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at, .end = i }; // `-ENDCOL` on this same line, unless a `:ENDCOL` follows — then that // first number was the end LINE all along. One lookahead, and it is // what lets the two-number and four-number forms share a spelling. const e = num(tok, i + 1); - if (e.end == i + 1) return .{ .path = path, .at = at }; + if (e.end == i + 1) return .{ .path = path, .at = at, .end = i }; at.end_line = at.line; at.end_col = e.v; + var end = e.end; if (e.end < tok.len and tok[e.end] == config.line_col_sep) { const e2 = num(tok, e.end + 1); if (e2.end > e.end + 1) { at.end_line = at.end_col; at.end_col = e2.v; + end = e2.end; } } - return .{ .path = path, .at = at }; + return .{ .path = path, .at = at, .end = end }; } - return .{ .path = tok, .at = .{} }; + return .{ .path = tok, .at = .{}, .end = tok.len }; } test "parsePathLine: spots, ranges, and the paths that merely look like them" { @@ -165,6 +175,34 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" { } } +test "parsePathLine: `end` separates a whole-token target from a lenient read" { + // the whole token IS the target: every spelling the doc above lists + for ([_][]const u8{ + "main.zig", "main.zig:100", "main.zig:100:7", + "main.zig:100-104", "main.zig:100:7-21", "main.zig:100:7-104:3", + "@p3:10:5", "x:1-y", + }) |tok| try std.testing.expectEqual(tok.len, parsePathLine(tok).end); + // ...and the reads that DROP a tail: a result row with its matched text + // still attached, which is exactly what a row-grained step must not select + // whole (lookableLineSpan). Note where each one STOPS — a spot is only + // taken once its whole form has read, so the `:7` of a `:100:7 text` row + // is dropped along with the text and `end` says so. + const partial = [_]struct { tok: []const u8, end: usize }{ + .{ .tok = "main.zig:100:", .end = "main.zig:100".len }, // trailing ':' is peeled, not parsed + .{ .tok = "main.zig:100:7x", .end = "main.zig:100".len }, + .{ .tok = "main.zig:100:7 fn main() void {", .end = "main.zig:100".len }, + .{ .tok = "main.zig:100:7-21 const x = 1;", .end = "main.zig:100:7-21".len }, + .{ .tok = "@p3:10:5 /home/goblin", .end = "@p3:10".len }, + }; + for (partial) |c| try std.testing.expectEqual(c.end, parsePathLine(c.tok).end); + // A form that breaks off mid-range is not a lenient read at all: the scan + // goes back for a later ':', finds none, and the token is a plain PATH + // whole — which resolves or does not on its own merits. + const whole = "main.zig:100-104 whole lines"; + try std.testing.expectEqual(whole.len, parsePathLine(whole).end); + try std.testing.expectEqualStrings(whole, parsePathLine(whole).path); +} + /// A file-like Look target has a rendering kind only in MuPDF builds. The /// feature-off enum has no `pdf` tag at all, so `.pdf` is indistinguishable /// from any other ordinary file before it reaches the core. @@ -236,8 +274,11 @@ const lead_trim = "([{<\"'`*"; const trail_trim = ")]}>\"'`*,;:.!?"; /// The largest look-able span inside one whitespace-delimited `word`, or null -/// when nothing in it resolves. This is the whole heuristic behind n/N: split -/// on whitespace, and take the biggest piece of each run that Look can act on. +/// when nothing in it resolves. This is the WORD grain of n/N — split a row on +/// whitespace and take the biggest piece of each run Look can act on — which +/// is what a terminal, a file and a PDF step, because their lines are free +/// text and a line may hold several places (an `ls` row hops file to file). +/// A results buffer steps ROWS instead: lookableLineSpan. /// /// TWO resolve attempts at most, which is what keeps a motion across a /// screenful of prose from being a hundred realpaths: the run with every @@ -301,6 +342,119 @@ test "lookableSpan peels prose punctuation off a path, largest first" { try std.testing.expectEqual(@as(?Span, null), lookableSpan("((()))", ".", &realbuf)); } +/// The largest look-able span ANCHORED at the start of `line`'s text, or null +/// when the row names no place at all. This is the ROW grain of n/N, and what +/// a results buffer steps: a row there IS one location — `path:LINE:COL text` +/// — and the words after the location are the MATCH, not a second place to +/// step to. One stop per row, always its head. +/// +/// LARGEST, so the candidates are the run from the first non-blank cell out to +/// each whitespace boundary, tried LONGEST first: a path with a blank in it +/// (`old notes/plan.txt`) beats the word hiding inside it, which is the case +/// the word grain cannot express at all. +/// +/// A candidate only counts when it is the target EXACTLY — parsePathLine +/// consuming every byte of it, after the same wrapper peel lookableSpan does. +/// That gate is what keeps longest-first from swallowing the whole row: +/// `resolve` is lenient by design and answers `src/x.zig:12:5 const y` with +/// the FILE, so without it every result row would select out to its right +/// margin and throw the `:5` away along with the text. A url is lenient the +/// same way in the other direction — it is recognised by its PREFIX, so a +/// longer run is not a longer link — and only the filesystem can vouch for a +/// span with a blank inside it, so only the filesystem is allowed to. +/// +/// Cost is the word grain's: the exactness gate is pure parsing, so a row +/// spends at most one resolve per whitespace boundary and the ordinary result +/// row — whose head is its whole location — spends two. +pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span { + var lo: usize = 0; + while (lo < line.len and (line[lo] == ' ' or line[lo] == '\t')) lo += 1; + var hi = std.mem.trimEnd(u8, line, " \t\r").len; + while (hi > lo) { + var a = lo; + var b = hi; + while (a < b and std.mem.indexOfScalar(u8, lead_trim, line[a]) != null) a += 1; + while (b > a and std.mem.indexOfScalar(u8, trail_trim, line[b - 1]) != null) b -= 1; + const cand = line[a..b]; + if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(cand, cwd, realbuf)) { + .dir, .file, .image => return .{ .start = a, .end = b }, + .url, .pane => if (std.mem.indexOfAny(u8, cand, " \t") == null) + return .{ .start = a, .end = b }, + .none => {}, + }; + // ...else the same run one word shorter + while (hi > lo and line[hi - 1] != ' ' and line[hi - 1] != '\t') hi -= 1; + while (hi > lo and (line[hi - 1] == ' ' or line[hi - 1] == '\t')) hi -= 1; + } + return null; +} + +test "lookableLineSpan takes the row's location and stops before its text" { + if (!platform_has_fs) return; + var realbuf: [4096]u8 = undefined; + // a grep row: the location, and NOT the matched code after it — which + // `resolve` would happily answer for, minus the column + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5-9".len }), + lookableLineSpan("src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf), + ); + // an lsp/jumplist row, whose column is followed by a blank rather than a + // ':' — the form a lenient read drops on the floor + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 0, .end = "src/look.zig:12:5".len }), + lookableLineSpan("src/look.zig:12:5 pub fn resolve", ".", &realbuf), + ); + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 0, .end = "@p3:10:5".len }), + lookableLineSpan("@p3:10:5 /home/goblin", ".", &realbuf), + ); + // a bare path row, wrappers peeled and blank indent skipped like anywhere + // else — the anchor is the row's first non-blank cell, not column zero + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 3, .end = 3 + "src/look.zig".len }), + lookableLineSpan(" (src/look.zig)", ".", &realbuf), + ); + // a link row keeps its link and leaves the title alone: a longer run is + // not a longer url + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 0, .end = "https://pardes.dev/a".len }), + lookableLineSpan("https://pardes.dev/a Chapter One", ".", &realbuf), + ); + // ANCHORED: a place mentioned mid-row is not a stop, and a row with no + // place at its head is no stop at all + try std.testing.expectEqual( + @as(?Span, null), + lookableLineSpan("see also src/look.zig", ".", &realbuf), + ); + try std.testing.expectEqual(@as(?Span, null), lookableLineSpan(" ", ".", &realbuf)); + try std.testing.expectEqual(@as(?Span, null), lookableLineSpan("", ".", &realbuf)); +} + +test "lookableLineSpan prefers the longest run, so a blank inside a path is one span" { + if (!platform_has_fs) return; + var realbuf: [4096]u8 = undefined; + // A real path with a blank in it, under a directory whose own name is the + // first word of the row: the word grain can only ever see `tmp`, and the + // row grain sees the file, because it asks about the longest run first. + const io = std.Io.Threaded.global_single_threaded.io(); + var tmp = try std.Io.Dir.cwd().openDir(io, "/tmp", .{}); + defer tmp.close(io); + const name = "pardes look span.txt"; + try tmp.writeFile(io, .{ .sub_path = name, .data = "" }); + defer tmp.deleteFile(io, name) catch {}; + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 0, .end = ("tmp/" ++ name).len }), + lookableLineSpan("tmp/" ++ name, "/", &realbuf), + ); + // ...and the shrink still finds the shorter run when the long one is + // prose. Candidates END at a blank, so the runs tried are whole words: + // there is no hunt for a path hiding inside one (lookableSpan's rule). + try std.testing.expectEqualDeep( + @as(?Span, .{ .start = 0, .end = "tmp".len }), + lookableLineSpan("tmp holds pardes look span.txt", "/", &realbuf), + ); +} + /// Resolve a looked-at word against the pane's directory. `realbuf` must /// outlive the returned Target (native paths point into it; web paths are /// process-lifetime slices in the embedded source archive). @@ -758,3 +912,643 @@ pub fn shellCwd(pid: libc.pid_t, buf: *[1024]u8) ?[]const u8 { else => return null, } } + +// ---- tty occupancy: is a pane's terminal still the prompt pardes forked? ---- + +// Linux answers TIOCGPGRP asked of the pty MASTER with the SLAVE side's +// foreground process group — the number the kernel would deliver ^C to. Not in +// std.c, and the master is the only end pardes holds. +extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t; + +/// How far the descendant walk goes before it stops trusting itself. A shell +/// sitting at its prompt has no descendants at all and a foreground job is one +/// hop, so these are not a budget, they are a fuse: the walk is driven by +/// numbers read out of the kernel and must not be able to spin on a surprising +/// one (the same reason nested.outer() caps its hops). Hitting either bound +/// answers OCCUPIED — a tree we did not finish reading may hide the foreground +/// job, and typing a command line into vim is worse than declining to type it +/// into a shell that really was idle under 32 background jobs. +const occ_max_depth: u8 = 8; +const occ_max_visited: usize = 32; + +/// Scratch for one `ttyTaken` answer: the walk's helpers share it rather than +/// each declaring its own copy of a path buffer. Lives in the probe's own +/// frame — there is no polling loop to hoist it out of any more, because the +/// core asks this question only where it is about to type a command line. +const TtyProbe = struct { + /// the forked shell's own executable, read once per probe + self_exe: [std.fs.max_path_bytes]u8 = undefined, + /// ...and one descendant's, to compare against it + exe: [std.fs.max_path_bytes]u8 = undefined, + /// one small /proc text at a time: a children list, a stat line, a status + /// blob. Each is consumed (parsed to numbers) before the next read. + blob: [4096]u8 = undefined, + /// the DFS worklist, bounded by the same fuse as the visit count + pending: [occ_max_visited]Node = undefined, + + const Node = struct { pid: libc.pid_t, depth: u8 }; +}; + +/// Is something OTHER than the shell prompt pardes forked sitting on this +/// pane's tty — vim, less, an agent, a build? An Exec must never type a command +/// line into such a program (it would land as vim keystrokes), so a taken +/// terminal is treated exactly like no terminal at all: the core routes the +/// command to another shell. +/// +/// The predicate, and the false answer each clause exists to prevent: +/// +/// fg = tcgetpgrp(master) the tty's foreground pgrp, from the kernel +/// fg < 0 -> free no answer at all (not a tty, a host that +/// does not allow the ioctl): behave as before +/// self = exe(shell_pid) the binary of the terminal we spawned, +/// straight out of /proc, so no spawn path has +/// to be plumbed through three frontends' Pty +/// structs and kept in step with shell_bin +/// self == null -> free the shell is gone; the pane's EOF is about +/// to remove it anyway +/// walk descendants of shell_pid: +/// exe unreadable -> occupied, unless the child is a zombie (or has +/// already vanished), which is provably not on +/// the tty. Unreadable-but-alive is a setuid +/// program — `sudo` waiting for a password is +/// the case that must NOT be typed into. +/// exe != self -> occupied iff its pgrp is fg. The pgrp filter is +/// what keeps `sleep 30 &` from looking +/// occupied: a background job is a child of an +/// idle prompt, and its pgrp is not the tty's. +/// exe == self -> recurse. A nested shell prompt is still a usable +/// prompt, so `bash` inside `bash` stays +/// Exec-able; and the leaf is the answer, which +/// is what catches `bash -c 'sleep 30'` — there +/// the foreground pgrp LEADER's exe is our own +/// shell binary while the tty really belongs to +/// `sleep`. +/// no visited process in pgrp fg, and fg != shell_pid +/// -> occupied the tty belongs to a group we could not +/// attribute to anything we forked (a +/// foreground leader that died or re-parented); +/// never type into it. +/// otherwise -> free +pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool { + switch (builtin.os.tag) { + .linux => { + var probe: TtyProbe = undefined; + const fg = tcgetpgrp(master_fd); + if (fg < 0) return false; + const self_exe = procExe(shell_pid, &probe.self_exe) orelse return false; + + // The shell's own pgrp is normally the tty's when it is at its + // prompt (forkpty made it the session and group leader), so the + // idle answer is reached without reading its stat at all — the + // whole fast path is tcgetpgrp, one readlink, and an empty + // children file. + var saw_fg = fg == shell_pid; + var pending: usize = 0; + var visited: usize = 0; + switch (pushChildren(&probe, &pending, shell_pid, 1)) { + .pushed => {}, + // No children file: a kernel without CONFIG_PROC_CHILDREN + // cannot answer this question at all, so answer free and leave + // behaviour exactly as it was before this probe existed. + .unreadable => return false, + .full => return true, + } + + while (pending > 0) { + pending -= 1; + const node = probe.pending[pending]; + visited += 1; + if (visited > occ_max_visited) return true; + + // One stat read carries the group; note it before anything can + // return, because the final clause is about every process we + // looked at, not only the ones that decided the answer. + const pgrp = procPgrp(node.pid, &probe.blob); + if (pgrp) |g| { + if (g == fg) saw_fg = true; + } + + const exe = procExe(node.pid, &probe.exe) orelse { + if (offTty(node.pid, &probe.blob)) continue; + return true; + }; + if (!std.mem.eql(u8, exe, self_exe)) { + if (pgrp) |g| if (g == fg) return true; + continue; + } + if (node.depth >= occ_max_depth) return true; + switch (pushChildren(&probe, &pending, node.pid, node.depth + 1)) { + .pushed => {}, + // This one exited while we walked (or the kernel stopped + // answering for it); its own pgrp was already counted and + // there is nothing below it to learn. + .unreadable => {}, + .full => return true, + } + } + return !saw_fg; + }, + // A darwin implementation is tcgetpgrp (which xnu also allows on the + // master) plus a descendant walk built from proc_listchildpids, with + // proc_pidpath for the exe and proc_bsdinfo's pbi_pgid for the group — + // there is no /proc to read. Until then macOS behaves as it did before + // this probe existed: every terminal is a prompt. + else => return false, + } +} + +/// Read a small /proc text in one go. These files are generated on read and +/// answer completely in a single call at these sizes; a short read would only +/// truncate a field, which every parser below treats as "no answer". +fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return null; + defer _ = libc.close(fd); + const got = libc.read(fd, buf.ptr, buf.len); + if (got <= 0) return null; + return buf[0..@intCast(got)]; +} + +/// The binary behind a pid, as the kernel spells it. Fails for a zombie (no mm +/// to point at) and for a process we may not inspect — the two cases `ttyTaken` +/// has to tell apart. +fn procExe(pid: libc.pid_t, buf: *[std.fs.max_path_bytes]u8) ?[]const u8 { + var name: [64:0]u8 = undefined; + const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null; + const n = libc.readlink(link, buf, buf.len); + if (n <= 0) return null; + return buf[0..@intCast(n)]; +} + +/// A pid's process group. +fn procPgrp(pid: libc.pid_t, buf: *[4096]u8) ?libc.pid_t { + var name: [64:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/stat", .{@as(u32, @intCast(pid))}, 0) catch return null; + return parsePgrp(readProc(path, buf) orelse return null); +} + +/// Field 5 of /proc/<pid>/stat, found by scanning back from the LAST ')' +/// rather than counting fields from the start: field 2 is `comm` in +/// parentheses, and a comm may contain spaces AND parentheses, so a process +/// named `sh (a b)` shifts everything after it and a positional parse silently +/// reads some other number as the group. Same trap nested.parsePPid documents; +/// the kernel puts comm's closing paren last precisely so this scan works. +fn parsePgrp(stat: []const u8) ?libc.pid_t { + const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null; + var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n"); + _ = fields.next() orelse return null; // 3: state + _ = fields.next() orelse return null; // 4: ppid + const pgrp = fields.next() orelse return null; // 5: pgrp + return std.fmt.parseInt(libc.pid_t, pgrp, 10) catch null; +} + +/// Is this pid provably NOT holding the tty even though its exe is unreadable: +/// a zombie (dead, waiting to be reaped) or already gone. Everything else that +/// hides its exe — a setuid program — is alive and on the terminal. +fn offTty(pid: libc.pid_t, buf: *[4096]u8) bool { + var name: [64:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return false; + // No status at all: the pid died between the children read and here. A + // process that no longer exists cannot be typed into. + const status = readProc(path, buf) orelse return true; + return parseZombie(status); +} + +/// The `State:` field of a /proc/<pid>/status blob, and only Z. Line-anchored, +/// so a comm that spells `State: Z` inside the `Name:` line cannot answer. +fn parseZombie(status: []const u8) bool { + var lines = std.mem.splitScalar(u8, status, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "State:")) continue; + const state = std.mem.trim(u8, line["State:".len..], " \t\r"); + return state.len > 0 and state[0] == 'Z'; + } + return false; +} + +const Pushed = enum { pushed, unreadable, full }; + +/// Put a pid's direct children on the worklist. The children file is the whole +/// reason this walk is cheap: an idle shell's is empty, so the fast path reads +/// one empty file instead of scanning /proc. +/// +/// Spelled out rather than routed through `readProc` precisely because of that +/// empty file: readProc treats a zero-byte answer as no answer, which is right +/// for a stat line and exactly wrong here — "this process has no children" is +/// the most informative reply the walk ever gets, and calling it unreadable +/// would make the whole probe give up on every idle shell. +fn pushChildren(probe: *TtyProbe, pending: *usize, pid: libc.pid_t, depth: u8) Pushed { + var name: [96:0]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/task/{d}/children", .{ + @as(u32, @intCast(pid)), @as(u32, @intCast(pid)), + }, 0) catch return .unreadable; + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return .unreadable; + defer _ = libc.close(fd); + const got = libc.read(fd, &probe.blob, probe.blob.len); + if (got < 0) return .unreadable; + + var kids: [occ_max_visited]libc.pid_t = undefined; + const total = parseChildren(probe.blob[0..@intCast(got)], &kids); + if (total > kids.len or pending.* + total > probe.pending.len) return .full; + for (kids[0..total]) |kid| { + probe.pending[pending.*] = .{ .pid = kid, .depth = depth }; + pending.* += 1; + } + return .pushed; +} + +/// The pids in a /proc/<pid>/task/<tid>/children blob: space separated, with a +/// trailing space, and empty for the overwhelmingly common idle shell. Returns +/// how many valid pids the blob HAS, having written the first `out.len` of them +/// — a total past `out.len` is the caller's overflow signal. A token that is +/// not strictly digits is skipped rather than answered wrong: this drives who +/// gets walked, and parseInt alone would take `-1` and `+7`. +fn parseChildren(text: []const u8, out: []libc.pid_t) usize { + var total: usize = 0; + var it = std.mem.tokenizeAny(u8, text, " \t\n\r"); + while (it.next()) |tok| { + if (std.mem.indexOfNone(u8, tok, "0123456789") != null) continue; + const kid = std.fmt.parseInt(libc.pid_t, tok, 10) catch continue; + if (total < out.len) out[total] = kid; + total += 1; + } + return total; +} + +test "the children blob parses to pids, and a garbage token never becomes one" { + var out: [8]libc.pid_t = undefined; + // the idle shell, which is the case the whole fast path is shaped around + try std.testing.expectEqual(@as(usize, 0), parseChildren("", &out)); + try std.testing.expectEqual(@as(usize, 0), parseChildren(" ", &out)); + // one child — the kernel writes a TRAILING space and no newline + try std.testing.expectEqual(@as(usize, 1), parseChildren("991 ", &out)); + try std.testing.expectEqual(@as(libc.pid_t, 991), out[0]); + // several, with and without the trailing separator + try std.testing.expectEqual(@as(usize, 3), parseChildren("7 8 9 ", &out)); + try std.testing.expectEqualSlices(libc.pid_t, &.{ 7, 8, 9 }, out[0..3]); + try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12", &out)); + try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12\n", &out)); + // garbage: this list decides whose /proc entries get read, and parseInt + // alone would take every one of these. The pids AROUND the junk still + // answer — dropping the tree because one token was odd would silently turn + // a busy terminal into a free one. + try std.testing.expectEqual(@as(usize, 2), parseChildren("5 -1 +7 0x3 abc 6 ", &out)); + try std.testing.expectEqualSlices(libc.pid_t, &.{ 5, 6 }, out[0..2]); + // overflow is REPORTED, not silently truncated: the total is what the blob + // HAS, so the caller can answer "occupied" instead of walking a tree it + // only partly read + var two: [2]libc.pid_t = undefined; + try std.testing.expectEqual(@as(usize, 4), parseChildren("1 2 3 4 ", &two)); + try std.testing.expectEqualSlices(libc.pid_t, &.{ 1, 2 }, two[0..2]); +} + +test "the process group comes off the last ')', not a comm-shifted stat field" { + // the comm here contains a space AND parentheses — the exact shape that + // breaks `field 5 of /proc/<pid>/stat` (see nested.parsePPid). Counting + // from the left answers `b))` for the state and `S` for the group. + const shifted = "1234 (sh (a b)) S 991 992 993 34816 992 4194560 " ++ + "1729 0 0 0 1 0 0 0 20 0 1 0 8244630 9887744 1131"; + try std.testing.expectEqual(@as(libc.pid_t, 992), parsePgrp(shifted).?); + // ...and the ordinary shape still reads the same field + try std.testing.expectEqual(@as(libc.pid_t, 7), parsePgrp("42 (bash) S 1 7 7 34816 7 4194304").?); + // a group of its own, which is what a background job has + try std.testing.expectEqual(@as(libc.pid_t, 42), parsePgrp("42 (sleep) S 7 42 7 0 -1").?); + // a truncated read must not answer from a half line, and a blob that is + // not a stat line at all must not answer at all + try std.testing.expect(parsePgrp("") == null); + try std.testing.expect(parsePgrp("1234 (bash) S 991") == null); + try std.testing.expect(parsePgrp("1234 (bash) S 991 notanumber") == null); + try std.testing.expect(parsePgrp("no parens here at all") == null); +} + +test "the zombie state comes off its own status line" { + // a reaped-but-not-yet-collected child: no exe to read, and provably not + // holding the tty, so the walk must skip it instead of answering occupied + try std.testing.expect(parseZombie("Name:\tsh (a b)\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t1234\n")); + try std.testing.expect(parseZombie("State:\tZ (zombie)\n")); + // every other state is a live process, and an unreadable exe then means + // setuid (sudo asking for a password) — the one thing never to type into + try std.testing.expect(!parseZombie("Name:\tsh\nState:\tS (sleeping)\n")); + try std.testing.expect(!parseZombie("Name:\tvim\nState:\tR (running)\n")); + try std.testing.expect(!parseZombie("Name:\tvim\nState:\tT (stopped)\n")); + // a comm that spells the field cannot answer for it: the scan is anchored + // to the start of a line, and `Name:` is where a comm lives + try std.testing.expect(!parseZombie("Name:\tsh (State: Z)\nState:\tS (sleeping)\n")); + // a truncated read is not a zombie (and so stays conservative) + try std.testing.expect(!parseZombie("Name:\tsh\nSta")); + try std.testing.expect(!parseZombie("State:\t")); +} + +// ---- tests: the predicate against real processes on a real pty ---- +// +// The parsers above cannot see any of what follows: whether Linux answers +// TIOCGPGRP on the MASTER at all, whether bash really puts a background job in +// its own group, and whether `bash -c` leaves our own binary as the foreground +// leader are all facts about the system, and every one of them decides an +// answer. So these fork a real bash on a real pty — the way +// test/e2e_harness.zig forks the whole app — and drive it. +extern "c" fn forkpty( + amaster: *c_int, + name: ?[*:0]u8, + termp: ?*const anyopaque, + winp: ?*const std.posix.winsize, +) c_int; + +const test_shell = "/bin/bash"; +const test_prompt = "PZX> "; + +/// A real interactive bash on a pty of our own, plus the polling the cases need. +/// Nothing here sleeps for a fixed time waiting for the shell: every step polls +/// to a deadline, and every poll DRAINS the master — a shell whose output is +/// never read blocks on a full pty buffer and then nothing else happens either. +const TestShell = struct { + master: c_int, + pid: libc.pid_t, + /// a rolling window of what the shell has written, so a case can wait for + /// the prompt (or a job-control notice) instead of guessing a duration + tail: [8192]u8 = undefined, + tail_len: usize = 0, + + fn start() ?TestShell { + if (!haveFile(test_shell)) return null; + var master: c_int = undefined; + const ws = std.posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 }; + const pid = forkpty(&master, null, null, &ws); + if (pid < 0) return null; + if (pid == 0) { + // --norc: the developer's own bashrc must not decide what these + // tests see. -i: job control, which is what puts a background job + // in a group of its own and is half of what is under test. + const argv: [3:null]?[*:0]const u8 = .{ test_shell, "--norc", "-i" }; + _ = execv(test_shell, &argv); + _exit(127); + } + var sh: TestShell = .{ .master = master, .pid = pid }; + // A prompt of our own — EXPORTED, so a nested bash shows the same one — + // spelled with a '' seam, so the echo of the command that sets it + // cannot be mistaken for the prompt it produces. + sh.send("export PS1='PZ''X> '\n"); + if (!sh.waitText(test_prompt, 10_000)) { + sh.stop(); + return null; + } + sh.forget(); + return sh; + } + + fn send(sh: *TestShell, bytes: []const u8) void { + _ = libc.write(sh.master, bytes.ptr, bytes.len); + } + + fn forget(sh: *TestShell) void { + sh.tail_len = 0; + } + + /// Read everything the shell has produced so far, without blocking. + fn drain(sh: *TestShell) void { + while (true) { + var fds = [1]libc.pollfd{.{ .fd = sh.master, .events = libc.POLL.IN, .revents = 0 }}; + if (libc.poll(&fds, 1, 0) <= 0) return; + if (fds[0].revents & libc.POLL.IN == 0) return; + var chunk: [4096]u8 = undefined; + const n = libc.read(sh.master, &chunk, chunk.len); + if (n <= 0) return; + sh.append(chunk[0..@intCast(n)]); + } + } + + fn append(sh: *TestShell, bytes: []const u8) void { + if (bytes.len >= sh.tail.len) { + @memcpy(&sh.tail, bytes[bytes.len - sh.tail.len ..]); + sh.tail_len = sh.tail.len; + return; + } + const room = sh.tail.len - sh.tail_len; + if (bytes.len > room) { + const drop = bytes.len - room; + std.mem.copyForwards(u8, sh.tail[0 .. sh.tail_len - drop], sh.tail[drop..sh.tail_len]); + sh.tail_len -= drop; + } + @memcpy(sh.tail[sh.tail_len..][0..bytes.len], bytes); + sh.tail_len += bytes.len; + } + + fn waitText(sh: *TestShell, needle: []const u8, ms: i64) bool { + const deadline = nowMs() + ms; + while (true) { + sh.drain(); + if (std.mem.indexOf(u8, sh.tail[0..sh.tail_len], needle) != null) return true; + if (nowMs() >= deadline) return false; + sleepMs(5); + } + } + + fn taken(sh: *TestShell) bool { + sh.drain(); + return ttyTaken(sh.pid, sh.master); + } + + /// Poll until the verdict is `want` — the answer changes when the SHELL + /// gets around to forking or reaping, not when we sent the line. + fn waitTaken(sh: *TestShell, want: bool, ms: i64) bool { + const deadline = nowMs() + ms; + while (true) { + if (sh.taken() == want) return true; + if (nowMs() >= deadline) return false; + sleepMs(5); + } + } + + /// ...and the other direction: the verdict STAYS `want` for a window. What + /// a false positive looks like is a probe that flickers to occupied while + /// the shell sits at its prompt with a background job, and a single sample + /// can miss it. + fn holdsTaken(sh: *TestShell, want: bool, ms: i64) bool { + const deadline = nowMs() + ms; + while (nowMs() < deadline) { + if (sh.taken() != want) return false; + sleepMs(5); + } + return true; + } + + /// Kill the shell AND everything under it, then reap and close. The tree + /// has to be collected BEFORE the shell dies: a foreground job lives in its + /// own process group, so killing bash alone leaves `sleep 30` running, + /// re-parented to init — a stray that outlives the test binary. + fn stop(sh: *TestShell) void { + var probe: TtyProbe = undefined; + var pending: usize = 0; + var doomed: [occ_max_visited]libc.pid_t = undefined; + var n: usize = 0; + _ = pushChildren(&probe, &pending, sh.pid, 1); + while (pending > 0) { + pending -= 1; + const node = probe.pending[pending]; + if (n == doomed.len) break; + doomed[n] = node.pid; + n += 1; + if (node.depth < occ_max_depth) _ = pushChildren(&probe, &pending, node.pid, node.depth + 1); + } + _ = libc.kill(sh.pid, libc.SIG.KILL); + for (doomed[0..n]) |kid| { + _ = libc.kill(kid, libc.SIG.KILL); + // ...and its group, for a program that forked helpers of its own + _ = libc.kill(-kid, libc.SIG.KILL); + } + _ = libc.waitpid(sh.pid, null, 0); + _ = libc.close(sh.master); + } +}; + +fn haveFile(path: [*:0]const u8) bool { + const fd = libc.open(path, .{ .ACCMODE = .RDONLY }); + if (fd < 0) return false; + _ = libc.close(fd); + return true; +} + +fn nowMs() i64 { + var ts: libc.timespec = undefined; + _ = libc.clock_gettime(.MONOTONIC, &ts); + return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); +} + +fn sleepMs(ms: i64) void { + const ts = libc.timespec{ + .sec = @intCast(@divFloor(ms, 1000)), + .nsec = @intCast(@mod(ms, 1000) * 1_000_000), + }; + _ = libc.nanosleep(&ts, null); +} + +test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands it back" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + // The whole point of the default: a shell sitting at its prompt is usable, + // and stays usable across samples. + try std.testing.expect(sh.holdsTaken(false, 200)); + + // A foreground job IS the terminal now — this is the answer an Exec needs, + // and typing a command line here would be typing it at `sleep`. + sh.send("sleep 30\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + + // ^C, and the tty is the prompt's again. Nothing is cached: the next poll + // simply finds no children, which is why recovery needs no event. + sh.forget(); + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); +} + +test "a background job is not the tty's owner" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + // The false positive the pgrp filter exists for. Waiting for the job + // notice first matters: the verdict has to be taken while the child is + // genuinely alive, or this test would pass with no probe at all. + sh.send("sleep 30 &\n"); + try std.testing.expect(sh.waitText("[1]", 10_000)); + try std.testing.expect(sh.holdsTaken(false, 300)); + + // ...and it is still free once the job is gone, which also means the + // zombie between `kill` and bash's reap is not read as an occupant. + sh.send("kill %1\n"); + try std.testing.expect(sh.holdsTaken(false, 300)); +} + +test "a nested interactive shell is still a prompt" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + // `bash` inside `bash`: the leaf matches the binary we spawned, so it is a + // prompt like any other and Exec must keep working. This is the case the + // recursion is FOR, and the reason "any child at all" would be wrong. + sh.forget(); + sh.send("bash --norc -i\n"); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + try std.testing.expect(sh.holdsTaken(false, 300)); + + // ...and one level deeper still + sh.forget(); + sh.send("bash --norc -i\n"); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + try std.testing.expect(sh.holdsTaken(false, 300)); + + // a job inside the INNER shell is still the tty's owner + sh.send("sleep 30\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); +} + +test "the walk reaches the leaf: bash -c 'sleep 30' takes the tty" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + sh.send("bash --norc -c 'sleep 30'\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); + + // The same shape where bash provably CANNOT exec the command in place (two + // commands, so the wrapper has to stay around and fork): the foreground + // group's leader is then our own shell binary while the tty really belongs + // to `sleep`. A predicate that stopped at the leader would call this free. + sh.send("bash --norc -c 'sleep 30; :'\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + + // ...and that is the shape asserted, not assumed: the shell's only child + // runs the same binary the shell does. + var probe: TtyProbe = undefined; + var pending: usize = 0; + try std.testing.expectEqual(Pushed.pushed, pushChildren(&probe, &pending, sh.pid, 1)); + try std.testing.expectEqual(@as(usize, 1), pending); + var wrapper_buf: [std.fs.max_path_bytes]u8 = undefined; + var shell_buf: [std.fs.max_path_bytes]u8 = undefined; + try std.testing.expectEqualStrings( + procExe(sh.pid, &shell_buf).?, + procExe(probe.pending[0].pid, &wrapper_buf).?, + ); + + sh.send("\x03"); + try std.testing.expect(sh.waitTaken(false, 10_000)); +} + +test "a full-screen program takes the tty until it quits" { + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; + // The two shapes a human actually loses a terminal to: an editor that takes + // the alternate screen, and a pager that does not. Both are skipped rather + // than failed where they are not installed. + const cases = [_]struct { bin: [*:0]const u8, run: []const u8, quit: []const u8 }{ + // -u NONE -i NONE: no vimrc, no viminfo — this must not touch the + // developer's own files, and an rc that starts a plugin would change + // the process tree under test. + .{ .bin = "/usr/bin/vim", .run = "vim -u NONE -i NONE\n", .quit = "\x1b:q!\r" }, + // LESS= so a developer's own -F (quit if one screen) cannot make the + // pager exit before it is asked to + .{ .bin = "/usr/bin/less", .run = "env LESS= less /etc/hosts\n", .quit = "q" }, + }; + var ran: usize = 0; + for (cases) |c| { + if (!haveFile(c.bin)) continue; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + sh.send(c.run); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.forget(); + sh.send(c.quit); + try std.testing.expect(sh.waitTaken(false, 10_000)); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + ran += 1; + } + if (ran == 0) return error.SkipZigTest; +} diff --git a/src/macos.zig b/src/macos.zig index dce136f4..100019ef 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -415,6 +415,9 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { .runtime = if (runtime) |r| r.* else .{}, }; const st = &state.?; + // the core's one way to ask about this host's ptys, pulled at the Exec that + // cares rather than pushed with the cwd above + core.tty_query = .{ .ctx = st, .taken = &ttyTakenAt }; // The real grid, delivered as an EVENT and not as Options.cols/rows: the // core defers each shell's greeting until it has seen a resize, and the @@ -546,6 +549,9 @@ export fn pardes_theme_bg() u32 { /// can have changed one — a `cd` is a command, and a shell that ran a command /// writes at least its next prompt. So an idle session costs nothing at all, /// and a busy one costs one libproc call per pane per burst. +/// +/// Whether a shell's tty is still that shell is NOT refreshed here: nothing +/// draws it, so the core pulls it instead (see `ttyTakenAt`). fn refreshCwds(st: *State) void { for (&st.cwd_stale, 0..) |*stale, id| { if (!stale.*) continue; @@ -556,6 +562,17 @@ fn refreshCwds(st: *State) void { } } +/// The core's `tty_query`, asked only where a command line is about to be typed: +/// is a program holding this pane's tty instead of the prompt we forked? +/// `look.ttyTaken` answers `false` on darwin until it grows a libproc +/// implementation, so this host behaves exactly as it did — the wiring is here +/// so it cannot rot, and it costs nothing until then. +fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool { + const st: *const State = @ptrCast(@alignCast(ctx orelse return false)); + const pt = st.ptys[pane] orelse return false; + return look.ttyTaken(pt.pid, pt.file.handle); +} + /// Drain what the reader tasks collected into the core, then perform whatever /// the core queued in response. Returns whether this tick did any IO. /// @@ -1074,7 +1091,8 @@ fn drainEffects(st: *State, threads_ok: bool) bool { .gen = gen, .reader = .{ .any_future = null, .result = {} }, }; - // Report the pane's starting directory back to the core (tags). + // Report the pane's starting directory back to the core (tags); + // the slot needs no occupancy reset, nothing is remembered. var lbuf: [1024]u8 = undefined; if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); if (threads_ok) if (st.ptys[sp.pane]) |*pt| startReader(st, pt, sp.pane); diff --git a/src/output_pane.zig b/src/output_pane.zig index 37ec6033..f14d9a51 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -82,6 +82,11 @@ pub const Traits = struct { /// n/N walk the rows: each is a `path:LINE:COL text` location the ordinary /// look path resolves, so the buffer IS helix's picker. Prose (a hover /// blurb, a formatting diff) has nowhere to step to. + /// + /// It is also what makes a step ROW-GRAINED (Grain below): one stop per + /// row, on the location at its head. A pane whose lines are free text — + /// a terminal, a file, a PDF, and the prose buffers here — steps every + /// look-able word instead, several to a line. steps: bool = false, /// ...and WHAT THE ROWS ARE. Off, each is a LOCATION with a look-able /// `path:LINE:COL` word inside it. On, each is a COMMAND LINE — the whole @@ -89,9 +94,9 @@ pub const Traits = struct { /// with no path in it to pick out. /// /// TWO readers, one fact, which is why the column is named for the fact: - /// n/N (lookWalk) select the look-able span inside a location row, and - /// THE WHOLE LINE of a command row, since the line is - /// the unit there. Either way they only select; Enter + /// n/N (lookWalk) select the location at the head of a location row, + /// and THE WHOLE LINE of a command row, since the line + /// is the unit there. Either way they only select; Enter /// looks what they left, Tab runs it. /// searchStep Looks a location row's leading word and Execs a /// command row whole — still how `]d`/`[d` and acme's @@ -172,6 +177,30 @@ pub fn fileTraits(out: ?Output) Traits { return traits((out orelse return file_row).from); } +/// How much of a row ONE n/N step selects (Pardes.lookSpanIn). Derived from +/// the two columns above rather than a third one, because it is not a fact +/// about a buffer — it is what those facts MEAN to the walk. +pub const Grain = enum { + /// every look-able word, several to a line, in document order. Free text: + /// a terminal's scrollback, a file, a PDF, and an output buffer of PROSE, + /// where the place you want may be mid-sentence. + word, + /// the location at the head of the row, and one stop per row. A results + /// buffer is a LIST: the words after a row's location are the matched + /// text, and stepping onto them was stepping onto the same hit twice. + line, + /// the whole row: a command list, where the line is the word. + whole, +}; + +/// The grain of a pane's rows, off its `output` field — null (a real file) +/// included, which is why a file pane is unaffected by any of this. +pub fn grain(out: ?Output) Grain { + const tr = fileTraits(out); + if (tr.commands) return .whole; + return if (tr.steps) .line else .word; +} + /// How the dump spells an origin. A WORD, never an integer, for the reason the /// dump already stores tag words: reordering builtins.zig stays free. Nothing /// collides — a builtin is CamelCase, an lsp.Kind is snake_case, and `/` is diff --git a/src/pardes.zig b/src/pardes.zig index 3db27b86..af12cb96 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -303,14 +303,26 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y); // Once page zero is wholly outside the viewport, both its owned RGBA and - // backend placement disappear; returning later must allocate a new raster. + // backend placement disappear; returning later renders a new raster. What + // does NOT go back is the memory: the departing page's bytes are parked in + // the relay and the arriving page of the same size takes them, so a fling + // never asks the allocator (or the kernel's fault handler) for megabytes it + // just gave up. p.setPdfPage(pane, 1); _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); + const away = try p.render(frame.allocator()); + try std.testing.expect(away.nimages > 0); + for (away.images[0..away.nimages]) |maybe| if (maybe) |place| + try std.testing.expect(place.native.page != 0); try std.testing.expect(Pardes.pdfRasterForPage(&pane.pdf.?, 0) == null); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len); + const retired = pane.pdf.?.spare[0]; p.setPdfPage(pane, 0); _ = frame.reset(.retain_capacity); - _ = try p.render(frame.allocator()); + const returned = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(u32, 0), returned.images[0].?.native.page); + try std.testing.expectEqual(retired.ptr, returned.images[0].?.rgba.ptr); + try std.testing.expect(returned.images[0].?.native.revision != page0_revision); // PdfFit exists as a real builtin in this build. It resets placement but // preserves the current page pixels; fit-height j/k remains continuous in @@ -2087,6 +2099,217 @@ test "unknown Exec from an image writes to a terminal in the image directory" { try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); } +/// The host's tty query, as a test double: which panes a program is holding, +/// and how many times the core actually bothered to ask. The count is the +/// laziness contract — nothing but a command line about to be typed may ask. +const FakeTtyQuery = struct { + taken: [MAX_PANES]bool = @splat(false), + asked: usize = 0, + + fn install(f: *FakeTtyQuery, p: *Pardes) void { + p.tty_query = .{ .ctx = f, .taken = &answer }; + } + + fn answer(ctx: ?*anyopaque, pane: usize) bool { + const f: *FakeTtyQuery = @ptrCast(@alignCast(ctx.?)); + f.asked += 1; + return f.taken[pane]; + } +}; + +test "Exec in a terminal whose tty is taken spawns a shell instead of typing at the program" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{}); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + p.setCwd(0, "/tmp/pardes-taken"); + // vim, a pager, an agent: the host answers that this pane's tty is no + // longer the prompt pardes forked + var host: FakeTtyQuery = .{}; + host.install(p); + host.taken[0] = true; + const dst = p.execute(0, "echo taken-fallback") orelse return error.ExecFoundNowhereToRun; + // somewhere ELSE — and still without moving focus, which is execute's + // contract and the whole difference between it and a look + try std.testing.expect(dst != 0); + try std.testing.expectEqual(@as(usize, 0), p.active); + + var spawned: ?Effect = null; + var sent: [256]u8 = undefined; + var sent_len: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |w| { + if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; + try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); + try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); + @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); + sent_len += w.bytes.slice().len; + }, + .spawn => spawned = effect, + else => {}, + }; + const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; + try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane); + // ...in the directory the command was about, which is the taken pane's own + try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); + try std.testing.expectEqualStrings("echo taken-fallback\r", sent[0..sent_len]); +} + +test "Exec from a document pane skips an occupied terminal in its directory and spawns" { + // The test above this pair ("unknown Exec from an image...") is the same + // setup with the terminal at its prompt, and it reuses pane 0. The single + // difference here is the verdict. + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{}); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + p.setCwd(0, "/tmp/pardes-image-dir"); + const image_pane = try p.newDocPane(1); + image_pane.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; + image_pane.kind = .image; + var host: FakeTtyQuery = .{}; + host.install(p); + host.taken[0] = true; + + const dst = p.execute(1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; + try std.testing.expect(dst != 0 and dst != 1); + + var spawned: ?Effect = null; + var sent: [256]u8 = undefined; + var sent_len: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |w| { + if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; + try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); + try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); + @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); + sent_len += w.bytes.slice().len; + }, + .spawn => spawned = effect, + else => {}, + }; + const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; + try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice()); + try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); +} + +test "a Look on a directory does not type ls into an occupied terminal" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + // /tmp rather than a made-up name: the look resolves against the real + // filesystem, so the directory has to exist for this arm to be reached + p.setCwd(0, "/tmp"); + var host: FakeTtyQuery = .{}; + host.install(p); + host.taken[0] = true; + p.lookAt(0, "/tmp"); + + var spawned = false; + while (p.nextEffect()) |effect| switch (effect) { + .write => |w| if (w.pane == 0) return error.TypedLsIntoTheProgramOnTheTty, + .spawn => spawned = true, + else => {}, + }; + try std.testing.expect(spawned); + + // ...and the same look reuses that very pane once its program is gone: the + // core keeps no state of its own about it, so recovery needs nothing reset + host.taken[0] = false; + p.lookAt(0, "/tmp"); + var sent: [64]u8 = undefined; + var sent_len: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |w| { + try std.testing.expectEqual(@as(u8, 0), w.pane); + try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); + @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); + sent_len += w.bytes.slice().len; + }, + .spawn => return error.SpawnedDespiteAFreeTerminalOnTheDirectory, + else => {}, + }; + try std.testing.expectEqualStrings("ls\r", sent[0..sent_len]); + try std.testing.expectEqual(@as(usize, 0), p.active); +} + +test "a terminal its program gave back takes command lines again" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{}); + defer p.deinit(); + while (p.nextEffect()) |_| {} + + p.setCwd(0, "/tmp/pardes-taken-flip"); + var host: FakeTtyQuery = .{}; + host.install(p); + host.taken[0] = true; + try std.testing.expect(p.execute(0, "echo while-taken") != 0); + while (p.nextEffect()) |_| {} + + // the program exited, so the host's answer changes — and the core asks + // again, because it never cached the old one + host.taken[0] = false; + try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo after")); + var sent: [64]u8 = undefined; + var sent_len: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |w| { + try std.testing.expectEqual(@as(u8, 0), w.pane); + try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); + @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); + sent_len += w.bytes.slice().len; + }, + .spawn => return error.SpawnedDespiteAPromptOfItsOwn, + else => {}, + }; + try std.testing.expectEqualStrings("echo after\r", sent[0..sent_len]); +} + +test "the host is asked about a tty only where a command line is about to go" { + const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + + var host: FakeTtyQuery = .{}; + host.install(p); + p.setCwd(0, "/tmp/pardes-lazy-a"); + p.setCwd(1, "/tmp/pardes-lazy-b"); + p.setCwd(2, "/tmp/pardes-lazy-c"); + + // A frame is a frame: rendering, typing, moving the mouse and resizing ask + // nobody anything. This is the whole point of the query being a pull — the + // probe it runs walks /proc, and it used to run for every pane of every + // frame to answer a question only Exec and Look ever ask. + _ = try p.render(frame.allocator()); + p.update(.{ .key = .{ .cp = 'x' } }); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 4, .row = 4 } }); + p.update(.{ .resize = .{ .cols = 90, .rows = 28 } }); + while (p.nextEffect()) |_| {} + _ = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), host.asked); + + // The pane an Exec is typed into is one question, asked once... + _ = p.execute(0, "echo lazy"); + while (p.nextEffect()) |_| {} + try std.testing.expectEqual(@as(usize, 1), host.asked); + + // ...and the fallback scan asks only about the panes that could possibly + // answer yes: the cwd comparison is free and comes first, so the two shells + // sitting in other directories cost nothing. Pane 0 is asked a second time + // because it IS on the directory the command was about. + host.taken[0] = true; + host.asked = 0; + _ = p.execute(0, "echo lazy-again"); + while (p.nextEffect()) |_| {} + try std.testing.expectEqual(@as(usize, 2), host.asked); +} + test "New completes as an empty watched file in the calling column and focuses it" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); @@ -2545,6 +2768,11 @@ fn mix(a: [3]u8, b: [3]u8) [3]u8 { const UNDO_MAX = 256; const EDIT_UNDO_MAX = 64; // terminal snapshots copy the edit buffer; cap tighter const PDF_RASTER_MAX = 256; // enough for every visible page in a pathological tiny-page viewport +// How many retired page buffers one PDF pane may hold at once while a frame +// swaps its visible set. Four covers a viewport straddling a page boundary +// with slack; beyond that the bytes go back to the allocator. See +// PdfView.spare — this is a within-frame relay, not a cache. +const PDF_RASTER_SPARE = 4; const TAG_TAIL_CAP = 4096; // one editable command line; extra input is refused const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record const EFFECT_CAP = 4096; // one update may queue 256 KiB of ordered 64-byte writes @@ -3221,6 +3449,22 @@ pub const Event = union(enum) { tick, }; +/// The one thing the core PULLS from the host instead of being pushed or +/// emitting an effect: is a pane's terminal still the prompt the host forked, +/// or has a program (vim, a pager, an agent) taken its tty? +/// +/// An effect cannot answer it — `execute` has to choose a destination inside +/// the update that asked, and an effect is drained after. A pushed fact could, +/// and did, but only by having every host probe every pane's processes on every +/// frame to answer a question that is asked when a human middle-clicks a word. +/// So the host leaves a way to be asked, and the core asks where it decides +/// (see `Pardes.takesCommandLine`). `ctx` is the host's own pty table; the +/// answer must not re-enter the core. +pub const TtyQuery = struct { + ctx: ?*anyopaque, + taken: *const fn (ctx: ?*anyopaque, pane: usize) bool, +}; + /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. pub const Effect = union(enum) { @@ -3510,6 +3754,20 @@ const PdfView = if (pdf_enabled) struct { /// rather than letting cache memory grow with the document. rasters: [PDF_RASTER_MAX]PdfRaster = undefined, rasters_len: usize = 0, + /// Retired page buffers, waiting to become the raster of a page arriving at + /// the same byte length. Every frame of a fling replaces the visible set + /// wholesale at IDENTICAL page dimensions, so returning megabytes to the + /// allocator only to ask for the same bytes back costs an unmap, a map, and + /// a fresh kernel fault on every 4 KiB of every page flown past — for + /// nothing. The relay spans one reconcile, where the departures happen + /// before the arrivals; `trimPdfSpares` then drops it back to a single + /// buffer, so this is a frame-length relay and not a second cache. + /// + /// Retired rather than rendered into in place: a raster is still swapped + /// only after MuPDF and the tint have both succeeded, so a failed rerender + /// can never blank a page that was already visible. + spare: [PDF_RASTER_SPARE][]u8 = @splat(&.{}), + spare_len: usize = 0, /// A physical viewport change rebuilds page heights. Preserve the same /// page-relative reading position instead of reinterpreting old pixels or /// snapping to the active page's top. @@ -4233,25 +4491,26 @@ const PdfDrag = if (pdf_enabled) struct { const Drag = union(enum) { none, - /// `corner_idx` is what makes this a CORNER grab: the press landed on a - /// cell that is both this v-border and one of left_col's own h-borders, - /// and then the one drag moves both boundaries — cur_x the column pair, - /// cur_y left_col's pane pair at index corner_idx. null is a plain edge - /// drag and cur_y is only carried along for the preview. + /// `corner` is what makes this a CORNER grab: the press landed on a cell + /// that is both this v-border and one of the two adjoining columns' own + /// h-borders, and then the one drag moves both boundaries — cur_x the + /// column pair, cur_y `corner.col`'s pane pair at index `corner.idx`. + /// null is a plain edge drag and cur_y is only carried along for the + /// preview. The drag is a `border_v` on left_col either way; only the row + /// half changes which column it belongs to. /// - /// It is the LEFT column's h-splits a corner honours, and only those. The - /// v handle IS left_col's last cell, so left_col's horizontal hint is - /// drawn straight THROUGH it while the right column's spans start one cell - /// further right — the corners this finds are exactly the cells where the - /// user can see the two lines cross. Honouring the right column too would - /// scatter corners across rows with no visible crossing. + /// Both adjoining columns count, left_col FIRST. The v handle IS left_col's + /// last cell, so left_col's horizontal hint is drawn straight THROUGH it + /// and its crossing reads as a full cross; the right column's spans start + /// one cell further right, so its crossing reads as a T butting into the + /// junction. Either way the two lines meet AT the handle cell, which is + /// what makes both grabbable. /// - /// ponytail: so a corner moves exactly TWO boundaries, never three — a - /// right-column h-split that happens to sit at the same row is left alone, - /// even though the eye may read the whole row as one line. Add a second - /// optional index (the right column's k) here and a second applyRowSplit - /// call if that ever reads as a bug rather than as restraint. - border_v: struct { left_col: usize, cur_x: u16, corner_idx: ?usize = null, cur_y: u16 = 0 }, + /// ponytail: a corner still moves exactly TWO boundaries, never three, so + /// when BOTH columns happen to be split at the grabbed row the LEFT one + /// wins and the right column's seam is left alone — the gesture that + /// existed before is bit-for-bit unchanged. + border_v: struct { left_col: usize, cur_x: u16, corner: ?struct { col: usize, idx: usize } = null, cur_y: u16 = 0 }, border_h: struct { col: usize, top_idx: usize, cur_y: u16 }, move: struct { id: usize, cur_x: u16, cur_y: u16 }, /// a left sweep along a pane's TAG row: it drives the tag's own cursor and @@ -4352,6 +4611,147 @@ test "a corner drag's two axes clamp independently" { try std.testing.expectEqual(@as(u16, 9), clampBorderRow(TOPBAR_H, 1, 0, 9, false)); } +/// The screen row that is the handle between column `c`'s pane pair `k` and +/// `k+1`: the upper pane's LAST body row, or with Tagbottom — where that row is +/// the upper pane's tag — the lower pane's FIRST. The one place this rule +/// lives; the h hit test, the corner search and the hover hint all read it here. +fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 { + const r = p.rects[p.col_terms[c][k]]; + return if (p.tag_bottom) r.y +| r.h else r.y + r.h -| 1; +} + +/// The corner fixture: the classic two-column boot with a SECOND pane added to +/// the RIGHT column, so both columns have a seam of their own and the v handle +/// between them can find either. +fn cornerFixture(gpa: std.mem.Allocator) !*Pardes { + const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30, .shells = 3 }); + p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); + p.active = p.col_terms[1][0]; + p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell below it, same column + p.sync(); + return p; +} + +/// Moves column 1's own seam off column 0's, by the ordinary h-border gesture, +/// and answers the row it landed on. Both columns split at the SAME row is the +/// tie case, which is a different test. +fn nudgeRightSeam(p: *Pardes, delta: i32) u16 { + const from = seamRowOf(p, 1, 0); + const inside = p.col_x[1] + p.col_w[1] / 2; + const to: u16 = @intCast(@as(i32, from) + delta); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to } }); + p.sync(); + return seamRowOf(p, 1, 0); +} + +test "a v-handle press at the RIGHT column's seam drags both boundaries" { + if (platform == .web) return; + const p = try cornerFixture(std.testing.allocator); + defer p.deinit(); + try std.testing.expectEqual(@as(usize, 2), p.col_n[1]); + + const handle = p.col_x[0] + p.col_w[0] - 1; + const right_seam = nudgeRightSeam(p, 3); + try std.testing.expect(right_seam != seamRowOf(p, 0, 0)); + + const w0 = p.col_weight[0]; + const v_left = p.panes[p.col_terms[0][0]].?.vweight; + const v_right = p.panes[p.col_terms[1][0]].?.vweight; + + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } }); + try std.testing.expect(p.drag == .border_v); + const corner = p.drag.border_v.corner orelse return error.NoCorner; + try std.testing.expectEqual(@as(usize, 1), corner.col); + try std.testing.expectEqual(@as(usize, 0), corner.idx); + + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = right_seam - 4 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = right_seam - 4 } }); + p.sync(); + + // both halves committed: the column pair widened, and the RIGHT column's + // pane pair reweighted — while the left column's panes stayed put + try std.testing.expect(p.col_weight[0] > w0); + try std.testing.expect(p.panes[p.col_terms[1][0]].?.vweight != v_right); + try std.testing.expectEqual(v_left, p.panes[p.col_terms[0][0]].?.vweight); +} + +test "a tie row still moves the LEFT column's pane pair only" { + if (platform == .web) return; + const p = try cornerFixture(std.testing.allocator); + defer p.deinit(); + + // put column 1's seam exactly on column 0's, the row the eye reads as one + // line across the whole window + const left_seam = seamRowOf(p, 0, 0); + _ = nudgeRightSeam(p, @as(i32, left_seam) - @as(i32, seamRowOf(p, 1, 0))); + try std.testing.expectEqual(left_seam, seamRowOf(p, 1, 0)); + + const handle = p.col_x[0] + p.col_w[0] - 1; + const v_left = p.panes[p.col_terms[0][0]].?.vweight; + const v_right = p.panes[p.col_terms[1][0]].?.vweight; + + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = left_seam } }); + const corner = p.drag.border_v.corner orelse return error.NoCorner; + try std.testing.expectEqual(@as(usize, 0), corner.col); + + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = left_seam - 4 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = left_seam - 4 } }); + p.sync(); + try std.testing.expect(p.panes[p.col_terms[0][0]].?.vweight != v_left); + try std.testing.expectEqual(v_right, p.panes[p.col_terms[1][0]].?.vweight); +} + +test "a v-handle press at nobody's seam is still a plain edge drag" { + if (platform == .web) return; + const p = try cornerFixture(std.testing.allocator); + defer p.deinit(); + const right_seam = nudgeRightSeam(p, 3); + const left_seam = seamRowOf(p, 0, 0); + + const handle = p.col_x[0] + p.col_w[0] - 1; + var row: u16 = TOPBAR_H + 1; + while (row == left_seam or row == right_seam) row += 1; + + const w0 = p.col_weight[0]; + const v_left = p.panes[p.col_terms[0][0]].?.vweight; + const v_right = p.panes[p.col_terms[1][0]].?.vweight; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = row } }); + try std.testing.expect(p.drag == .border_v); + try std.testing.expect(p.drag.border_v.corner == null); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = row + 5 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = row + 5 } }); + p.sync(); + // exactly ONE boundary moved + try std.testing.expect(p.col_weight[0] > w0); + try std.testing.expectEqual(v_left, p.panes[p.col_terms[0][0]].?.vweight); + try std.testing.expectEqual(v_right, p.panes[p.col_terms[1][0]].?.vweight); +} + +test "a RIGHT-column corner's two axes clamp independently" { + if (platform == .web) return; + const p = try cornerFixture(std.testing.allocator); + defer p.deinit(); + const right_seam = nudgeRightSeam(p, 3); + const handle = p.col_x[0] + p.col_w[0] - 1; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } }); + try std.testing.expectEqual(@as(usize, 1), (p.drag.border_v.corner orelse return error.NoCorner).col); + + // off the right edge at mid-height: x parks at its MINW wall, y is the mouse + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 999, .row = right_seam } }); + try std.testing.expectEqual(@as(u16, 100 - config.MINW), p.drag.border_v.cur_x); + try std.testing.expectEqual(right_seam, p.drag.border_v.cur_y); + + // and the mirror: below the bottom at mid-width. y parks, x tracks again + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = 999 } }); + try std.testing.expectEqual(handle, p.drag.border_v.cur_x); + const a = p.rects[p.col_terms[1][0]]; + const b = p.rects[p.col_terms[1][1]]; + try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.tag_bottom), p.drag.border_v.cur_y); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } }); +} + pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; pub const Options = struct { @@ -4523,6 +4923,11 @@ pub const Pardes = struct { /// terminal, GPU textures in SDL). Image panes dynamically fall back to /// the PETSCII matcher without it. native_images: bool = false, + /// How the core asks the host the one question about a pane it cannot + /// answer itself — see `takesCommandLine`, the only caller. Installed once + /// by the native hosts (and again after a dump replay builds a second + /// core); left null by the web shell, which has no processes. + tty_query: ?TtyQuery = null, quit: bool = false, /// The Look or Exec that has happened and not yet been felt, taken by the /// shell once per pump (takeHaptic). A pulse, not a queue: five Execs @@ -4755,6 +5160,7 @@ pub const Pardes = struct { p.pdf_gpa.free(pv.path); for (pv.rasters[0..pv.rasters_len]) |raster| if (raster.rgba.len > 0) p.pdf_gpa.free(raster.rgba); + for (pv.spare[0..pv.spare_len]) |retired| p.pdf_gpa.free(retired); p.pdf_gpa.free(pv.page_sizes); p.pdf_gpa.free(pv.page_starts); p.pdf_gpa.free(pv.page_heights); @@ -4904,6 +5310,32 @@ pub const Pardes = struct { pane.cwd_len = @intCast(n); } + /// Can a command line be typed into this pane RIGHT NOW: a terminal whose + /// tty still belongs to the prompt the host forked. A terminal running vim + /// answers false and is then treated exactly like a document pane — the + /// command goes to some other shell (ttyForDir), because keystrokes are all + /// a full-screen program would make of it. + /// + /// The occupancy half of that question is the host's to answer (look.ttyTaken + /// walks the processes under the pane's shell pid against the tty's + /// foreground process group) and it is asked HERE, lazily: only for a pane a + /// command line is about to go to, and only at the moment it is about to go + /// there. It used to be pushed in by every host on every frame for every + /// pane, which bought nothing — nothing else in the core has ever wanted the + /// answer, and a verdict one frame old is a worse one than a verdict taken + /// now. The cheap half is tested first, so a pane in the wrong directory + /// costs no syscalls at all. + /// + /// No query (web has no processes, a dump replay has no shells yet, and the + /// core's own tests install their own) means every terminal is a prompt, + /// which is exactly how pardes behaved before the probe existed. + fn takesCommandLine(p: *const Pardes, id: usize) bool { + const pane = p.panes[id] orelse return false; + if (!pane.isTerminal()) return false; + const q = p.tty_query orelse return true; + return !q.taken(q.ctx, id); + } + /// Complete a `new_file` effect without doing IO in the core. The shell /// has already created `path` as an empty file; we model those known empty /// bytes directly, insert immediately below the still-live calling pane in @@ -8391,29 +8823,57 @@ pub const Pardes = struct { return out[0..n]; } + /// Is a span starting at `col0` PAST `from` in the direction of travel? + /// Only the row a walk STARTED on is filtered — every span on a row it + /// arrived at is ahead of it — and the comparison is against `col0` rather + /// than the whitespace run's start. Those are different columns the moment + /// a wrapper is peeled: `(mise.toml)` is a run starting at 0 and a span + /// starting at 1, and a backward step filtered on the run would find the + /// span it is standing on still ahead of it and never leave the row. + fn lookPast(col0: i32, from: LookFrom, on_start_row: bool, delta: i32) bool { + if (!on_start_row) return true; + const c = from.col orelse return true; + if (delta > 0) return if (from.strict) col0 > c else col0 >= c; + return if (from.strict) col0 < c else col0 <= c; + } + + /// The whole row as one span, first non-blank cell to last — the `.whole` + /// grain. The trailing trim keeps a padded row selecting the command and + /// not the padding. + fn wholeRowSpan(ln: []const u8) ?look.Span { + var lo: usize = 0; + while (lo < ln.len and (ln[lo] == ' ' or ln[lo] == '\t')) lo += 1; + const hi = std.mem.trimEnd(u8, ln, " \t\r").len; + return if (hi > lo) .{ .start = lo, .end = hi } else null; + } + /// The next STEPPABLE span in `pane` from `from`, in `delta`'s direction, /// or null when the pane has none left that way. `budget` is the caller's /// remaining row allowance and is spent here; a null return with a budget /// of zero means GAVE UP, not exhausted (see max_look_rows). /// - /// One flag decides what a span IS. In an ordinary pane it is the largest - /// look-able run on the row (look.lookableSpan) and a row may hold several - /// — an `ls` line hops big.txt -> plain.txt -> sub. In a buffer whose rows - /// are COMMANDS (output_pane.Traits.commands: ThemeSel, FontSel) it is the - /// WHOLE LINE, because `Theme gruvbox` has no path inside it to pick out - /// and the line is the unit you would run. Same motion, same selection, - /// same Enter/Tab afterwards; only the grain differs. + /// WHAT A SPAN IS comes from the pane's grain (output_pane.Grain) and is + /// the one thing about this motion a buffer gets to change: + /// .word free text — a terminal, a file, a PDF — where a row may hold + /// several places and every look-able run is a stop: an `ls` + /// line hops big.txt -> plain.txt -> sub (look.lookableSpan). + /// .line a results buffer, where a row IS one location: one stop per + /// row, on the largest run its head resolves as, and the matched + /// text after it is not a second stop (look.lookableLineSpan). + /// .whole a command list (ThemeSel, FontSel), where the line is the + /// word: `Theme gruvbox` has no path inside it to pick out. + /// Same motion, same selection, same Enter/Tab afterwards. /// - /// Symmetric by construction either way, and that is the whole point: both - /// directions ask the same question about the same rows, and both compare - /// against `col0` — the column the walk parks the cursor on. So a step - /// forward off a span and a step back onto it are the same two positions - /// read in the two orders. + /// Symmetric by construction in all three, and that is the whole point: + /// both directions ask the same question about the same rows, and both + /// compare against `col0` — the column the walk parks the cursor on. So a + /// step forward off a span and a step back onto it are the same two + /// positions read in the two orders. fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?LookSpot { const pl = p.paneCursorLines(pane) catch return null; const nrows: i32 = @intCast(pl.lines.len); if (nrows == 0) return null; - const whole_row = if (pane.file) |*f| output_pane.fileTraits(f.output).commands else false; + const grain: output_pane.Grain = if (pane.file) |*f| output_pane.grain(f.output) else .word; const dir = paneDir(pane); var realbuf: [4096]u8 = undefined; const start = std.math.clamp(from.row, 0, nrows - 1); @@ -8422,45 +8882,36 @@ pub const Pardes = struct { if (budget.* == 0) return null; budget.* -= 1; const ln = pl.lines[@intCast(r)]; - var best: ?LookSpot = null; - var i: usize = 0; - while (i < ln.len) { - while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1; - const t0 = i; - var spot: LookSpot = undefined; - if (whole_row) { - // one span per row, from its first non-blank cell to its - // last: the trailing trim keeps a padded row selecting the - // command and not the padding - const end = std.mem.trimEnd(u8, ln, " \t\r").len; - if (end <= t0) break; - i = end; - spot = .{ .row = r, .col0 = @intCast(t0), .col1 = @intCast(end - 1) }; - } else { - while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1; - if (i == t0) break; - const sp = look.lookableSpan(ln[t0..i], dir, &realbuf) orelse continue; - spot = .{ - .row = r, - .col0 = @intCast(t0 + sp.start), - .col1 = @intCast(t0 + sp.end - 1), - }; - } - // AFTER the span, never before it, and against `col0` rather - // than the whitespace run's start. Those are different columns - // the moment a wrapper is peeled: `(mise.toml)` is a run - // starting at 0 and a span starting at 1, and a backward step - // filtered on the run would find the span it is standing on - // still ahead of it and never leave the row. Only the start row - // is filtered at all, so the resolves this costs are one row's. - if (r == start) if (from.col) |c| { - if (delta > 0 and (if (from.strict) spot.col0 <= c else spot.col0 < c)) continue; - if (delta < 0 and (if (from.strict) spot.col0 >= c else spot.col0 > c)) continue; - }; - best = spot; - if (delta > 0) break; // first one forward; keep the last one back + const on_start = r == start; + switch (grain) { + .word => { + var best: ?LookSpot = null; + var i: usize = 0; + while (i < ln.len) { + while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1; + const t0 = i; + while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1; + if (i == t0) break; + const sp = look.lookableSpan(ln[t0..i], dir, &realbuf) orelse continue; + const col0: i32 = @intCast(t0 + sp.start); + if (!lookPast(col0, from, on_start, delta)) continue; + best = .{ .row = r, .col0 = col0, .col1 = @intCast(t0 + sp.end - 1) }; + if (delta > 0) break; // first one forward; keep the last one back + } + if (best) |b| return b; + }, + // one span per row, so there is nothing to keep and nothing to + // scan past: the row either offers it or it does not + .line, .whole => { + const sp = (if (grain == .line) + look.lookableLineSpan(ln, dir, &realbuf) + else + wholeRowSpan(ln)) orelse continue; + const col0: i32 = @intCast(sp.start); + if (lookPast(col0, from, on_start, delta)) + return .{ .row = r, .col0 = col0, .col1 = @intCast(sp.end - 1) }; + }, } - if (best) |b| return b; } return null; } @@ -8498,9 +8949,12 @@ pub const Pardes = struct { /// them trustworthy. A PDF used to step its results buffer and jump; it /// steps the same ring now, which IS that buffer, and Enter does the /// jumping. The single thing any buffer gets to change is the GRAIN of - /// what a step selects, and it changes it with one flag rather than a - /// branch here: `Traits.commands` makes a row select WHOLE, because a - /// ThemeSel line is a word to run and not a place to go (lookSpanIn). + /// what a step selects, and it changes it by BEING a kind of buffer rather + /// than by a branch here (output_pane.Grain, read in lookSpanIn): free + /// text steps every look-able word, a results list steps one ROW at a time + /// — its head is the location and the rest is the match — and a command + /// list steps the whole line, because a ThemeSel row is a word to run and + /// not a place to go. /// /// `]d`/`[d` are not n/N. They are helix's diagnostic motions, their job /// is to ARRIVE at the next diagnostic, and they still reach searchStep. @@ -10180,34 +10634,28 @@ pub const Pardes = struct { // Tagbottom, where that one is the upper pane's tag, the // lower pane's first). // The v test still wins outright, but it now also asks - // whether this same cell is one of ITS OWN column's h + // whether this same cell is one of the adjoining columns' h // handles — that cell is the corner where the two lines - // cross, and grabbing it drags both boundaries at once - // (see Drag.border_v for why only the left column counts). + // meet, and grabbing it drags both boundaries at once. Its + // OWN column is asked first, so a row where both are split + // is the gesture it always was (see Drag.border_v). for (0..p.ncol -| 1) |c| { if (mcol == p.col_x[c] + p.col_w[c] -| 1) { - var corner: ?usize = null; - for (0..p.col_n[c] -| 1) |k| { - const r = p.rects[p.col_terms[c][k]]; - const seam = if (p.tag_bottom) r.y +| r.h else r.y + r.h -| 1; - if (mrow == seam) { - corner = k; - break; - } - } - p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol, .corner_idx = corner, .cur_y = mrow } }; + const corner: @FieldType(@FieldType(Drag, "border_v"), "corner") = if (p.seamIdxAt(c, mrow)) |k| + .{ .col = c, .idx = k } + else if (p.seamIdxAt(c + 1, mrow)) |k| + .{ .col = c + 1, .idx = k } + else + null; + p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol, .corner = corner, .cur_y = mrow } }; return; } } for (0..p.ncol) |cc| { if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue; - for (0..p.col_n[cc] -| 1) |k| { - const r = p.rects[p.col_terms[cc][k]]; - const seam = if (p.tag_bottom) r.y +| r.h else r.y + r.h -| 1; - if (mrow == seam) { - p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } }; - return; - } + if (p.seamIdxAt(cc, mrow)) |k| { + p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } }; + return; } } const id = hovered orelse return; @@ -10344,6 +10792,14 @@ pub const Pardes = struct { } } + /// Index of the pane pair in column `c` whose seam (see seamRowOf) is screen + /// row `mrow`. Out-of-range columns simply have no seam. + fn seamIdxAt(p: *const Pardes, c: usize, mrow: u16) ?usize { + if (c >= p.ncol) return null; + for (0..p.col_n[c] -| 1) |k| if (mrow == seamRowOf(p, c, k)) return k; + return null; + } + fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16) void { switch (p.drag) { .border_v => |*d| { @@ -10352,14 +10808,14 @@ pub const Pardes = struct { clampBorderCol(p.col_x[c], p.col_w[c], p.col_w[c + 1], mcol) else mcol; - // a corner also drives left_col's pane pair, off the SAME mouse - // position but through its own clamp — the geometry a clamp - // reads (widths for x, heights for y) is frozen for the whole - // drag and never crosses axes, so one edge parked at its stop - // leaves the other tracking the mouse - if (d.corner_idx) |k| if (k + 1 < p.col_n[c]) { - const a = p.rects[p.col_terms[c][k]]; - const b = p.rects[p.col_terms[c][k + 1]]; + // a corner also drives its column's pane pair, off the SAME + // mouse position but through its own clamp — the geometry a + // clamp reads (widths for x, heights for y) is frozen for the + // whole drag and never crosses axes, so one edge parked at its + // stop leaves the other tracking the mouse + if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) { + const a = p.rects[p.col_terms[k.col][k.idx]]; + const b = p.rects[p.col_terms[k.col][k.idx + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.tag_bottom); } else { d.cur_y = mrow; @@ -10492,7 +10948,7 @@ pub const Pardes = struct { // — column weights are widths, pane vweights are heights, and // neither reads the other — so the order here does not matter // and a failed one cannot lose the other. - if (d.corner_idx) |k| p.applyRowSplit(c, k, d.cur_y); + if (d.corner) |k| p.applyRowSplit(k.col, k.idx, d.cur_y); }, .border_h => |d| p.applyRowSplit(d.col, d.top_idx, d.cur_y), .move => |d| { @@ -10970,13 +11426,21 @@ pub const Pardes = struct { if (p.active == tty_id) p.active = keep_id; } - /// a terminal already in `dir`, else a fresh shell there at the bottom of - /// the rightmost column. Backs middle-click send from a file pane. Does NOT - /// focus (execute keeps you where you were; look focuses). + /// a terminal already in `dir` and still at its prompt, else a fresh shell + /// there at the bottom of the rightmost column. Backs middle-click send + /// from a file pane. Does NOT focus (execute keeps you where you were; look + /// focuses). + /// + /// A terminal whose tty is TAKEN (vim, a pager, an agent) is not a match for + /// its own cwd: it cannot run a command line, so the scan keeps going and + /// spawns rather than pretending it found somewhere to type. The directory + /// is compared FIRST because that comparison is free and the occupancy + /// question costs a walk through /proc — a window full of shells in other + /// directories is not worth one syscall. fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { for (p.panes, 0..) |slot, i| if (slot) |tt| { - if (!tt.isTerminal()) continue; - if (std.mem.eql(u8, tt.cwdSlice(), dir)) return i; + if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue; + if (p.takesCommandLine(i)) return i; }; const free = p.freeSlot() orelse return null; const nt = p.newShell(free, dir) catch return null; @@ -11103,6 +11567,54 @@ pub const Pardes = struct { raster.* = .{}; } + /// Park one page buffer in the relay instead of handing it to the + /// allocator. A full relay means this frame retired more pages than any + /// arrival can want, so those bytes do go back. + fn retirePdfRgba(p: *Pardes, pv: *PdfView, rgba: []u8) void { + if (comptime !pdf_enabled) return; + if (rgba.len == 0) return; + if (pv.spare_len == pv.spare.len) { + p.pdf_gpa.free(rgba); + return; + } + pv.spare[pv.spare_len] = rgba; + pv.spare_len += 1; + } + + /// Eviction path: the slot's pixels are no longer reachable, but its bytes + /// are exactly what the page replacing it needs. + fn retirePdfRaster(p: *Pardes, pv: *PdfView, raster: *PdfRaster) void { + if (comptime !pdf_enabled) return; + p.retirePdfRgba(pv, raster.rgba); + raster.* = .{}; + } + + /// A buffer of exactly `bytes`, out of the relay when one fits, so a steady + /// fling stops touching the allocator at all. Uniform page sizes are the + /// overwhelming case, and an inexact match is worth nothing: the render + /// needs this length precisely. + fn claimPdfRgba(p: *Pardes, pv: *PdfView, bytes: usize) ?[]u8 { + if (comptime !pdf_enabled) return null; + for (pv.spare[0..pv.spare_len], 0..) |candidate, index| { + if (candidate.len != bytes) continue; + pv.spare_len -= 1; + pv.spare[index] = pv.spare[pv.spare_len]; + return candidate; + } + return p.pdf_gpa.alloc(u8, bytes) catch null; + } + + /// Close the relay at the end of a reconcile. One buffer survives, because + /// the next frame of a fling opens by retiring one and asking for one; more + /// than that would be idle megabytes pretending to be a cache. + fn trimPdfSpares(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + while (pv.spare_len > 1) { + pv.spare_len -= 1; + p.pdf_gpa.free(pv.spare[pv.spare_len]); + } + } + fn dropPdfRaster(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; for (pv.rasters[0..pv.rasters_len]) |*raster| p.releasePdfRaster(raster); @@ -12401,9 +12913,11 @@ pub const Pardes = struct { .pane => |t| p.focusPaneLine(t.id, t.at), .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { - // focus an existing terminal on this dir, else fork one below + // focus an existing terminal on this dir, else fork one below. + // A terminal whose tty is taken is not that terminal: `ls\r` + // typed into vim is `ls\r` typed into vim. for (p.panes, 0..) |slot, i| { - if (slot) |tt| if (tt.isTerminal() and std.mem.eql(u8, tt.cwdSlice(), dir)) { + if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) { p.active = i; p.emitWrite(i, "ls\r"); return; @@ -12487,11 +13001,13 @@ pub const Pardes = struct { // Anything not in the builtin vocabulary is a command line typed at // a shell. Startup config calls executeBuiltinLine directly and never // reaches this fallback. - // A terminal runs in itself. Every document kind — real/output file, - // image, or PDF — runs in a terminal for its directory (an existing - // one when possible, otherwise a freshly forked shell). In particular, - // never emit a PTY write addressed to an image's no-PTY pane slot. - const dst = (if (pane.isTerminal()) id else p.ttyForDir(paneDir(pane))) orelse return null; + // A terminal runs in itself — as long as its tty is still the prompt we + // forked. Every document kind — real/output file, image, or PDF — and a + // terminal currently held by a full-screen program run in a terminal for + // their directory (an existing prompt when possible, otherwise a freshly + // forked shell). In particular, never emit a PTY write addressed to an + // image's no-PTY pane slot, and never type a command line at vim. + const dst = (if (p.takesCommandLine(id)) id else p.ttyForDir(paneDir(pane))) orelse return null; term_pane.padOutputBelowEdits(p, dst); p.emitWrite(dst, cmd); p.emitWrite(dst, "\r"); @@ -13418,14 +13934,17 @@ pub const Pardes = struct { while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); } // a corner lights BOTH splits, which is the whole tell that you - // grabbed the crossing and not an edge. The horizontal half - // stops at the vertical preview rather than at the column's - // current right edge, so the two dashes stay joined at the cell - // under the mouse and together show the shape being committed. - if (d.corner_idx != null and d.cur_y < s.rows) { - var col = p.col_x[d.left_col]; - while (col <= d.cur_x and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); - } + // grabbed the crossing and not an edge. The horizontal half runs + // across ITS OWN column and stops at the vertical preview rather + // than at that column's current edge, so the two dashes stay + // joined at the cell under the mouse: through the handle for a + // left-column corner, butting into it from the right neighbour + // for a right-column one. + if (d.corner) |k| if (d.cur_y < s.rows) { + var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; + const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; + while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + }; }, .border_h => |d| if (d.cur_y < s.rows) { var col = p.col_x[d.col]; @@ -13451,16 +13970,25 @@ pub const Pardes = struct { } for (0..p.ncol) |cc| { if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue; - for (0..p.col_n[cc] -| 1) |k| { - const r = p.rects[p.col_terms[cc][k]]; - // the same seam the h-handle hit test picks (see there) - const seam = if (p.tag_bottom) r.y +| r.h else r.y + r.h -| 1; - if (p.hover_row == seam) { - var col = p.col_x[cc]; - while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); - } + // the same seam the h-handle hit test picks (see there) + if (p.seamIdxAt(cc, p.hover_row) != null) { + var col = p.col_x[cc]; + while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } } + // the containment test above can only ever light the column the + // hovered cell is IN, and a v handle is its LEFT column's cell. + // So when that column has no seam here but its right neighbour + // does, light the neighbour's: that is the corner a press would + // take (Drag.border_v), and a grabbable crossing has to be + // visible before it is grabbed. + for (0..p.ncol -| 1) |cn| { + if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; + if (p.seamIdxAt(cn, p.hover_row) != null) continue; + if (p.seamIdxAt(cn + 1, p.hover_row) == null) continue; + var col = p.col_x[cn + 1]; + while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); + } }, } @@ -13978,15 +14506,22 @@ pub const Pardes = struct { if (comptime !pdf_enabled) return; const tint_key = p.pdfTintKey(pv); - // Remove first: owned RGBA never accumulates with document length, - // and backends see stale keys disappear in this same frame. + // Remove first, and remove EVERYTHING outside the visible set: owned + // RGBA never accumulates with document length, and backends see stale + // keys disappear in this same frame. Removing before rendering is also + // what fills the buffer relay, so the pages arriving below take the + // departing pages' bytes rather than the allocator's. + // + // Only the end-of-frame visible set can ever be SEEN: a page the + // viewport merely swept across inside one event batch is never drawn, + // so a fling rasterizes what it lands on, not what it passed through. var index: usize = 0; while (index < pv.rasters_len) { if (pdfVisibleContains(visible, pv.rasters[index].page)) { index += 1; continue; } - p.releasePdfRaster(&pv.rasters[index]); + p.retirePdfRaster(pv, &pv.rasters[index]); pv.rasters_len -= 1; if (index != pv.rasters_len) pv.rasters[index] = pv.rasters[pv.rasters_len]; } @@ -14010,31 +14545,43 @@ pub const Pardes = struct { slot.tried = true; slot.request = request; slot.request_valid = true; - const rendered = render: { - const fresh = if (decorated) - pv.document.renderWithHighlightsAt(p.pdf_gpa, page, request, highlights) catch break :render null - else - pv.document.renderAt(p.pdf_gpa, page, request) catch break :render null; - break :render fresh; - }; - if (rendered) |fresh| { - pdf_impl.tintRgba(fresh.rgba, tint_key.mode, tint_key.colors) catch { - p.pdf_gpa.free(fresh.rgba); - continue; - }; - if (slot.rgba.len > 0) p.pdf_gpa.free(slot.rgba); - slot.rgba = fresh.rgba; - slot.iw = fresh.width; - slot.ih = fresh.height; - slot.decorated = decorated; - slot.tint_key = tint_key; - pv.next_raster_revision +%= 1; - if (pv.next_raster_revision == 0) pv.next_raster_revision = 1; - slot.revision = pv.next_raster_revision; + // Measure first so a retired buffer of exactly this size can + // be reclaimed. Render into that separate buffer and swap + // only on complete success; the old pixels stay presentable + // until then and then become the next page's buffer. + if (pv.document.measureRenderAt(page, request) catch null) |shape| { + if (p.claimPdfRgba(pv, shape.len)) |fresh| { + const filled = filled: { + pv.document.renderIntoAt( + page, + request, + shape, + if (decorated) highlights else &.{}, + fresh, + ) catch break :filled false; + pdf_impl.tintRgba(fresh, tint_key.mode, tint_key.colors) catch + break :filled false; + break :filled true; + }; + if (!filled) { + p.retirePdfRgba(pv, fresh); + continue; + } + p.retirePdfRgba(pv, slot.rgba); + slot.rgba = fresh; + slot.iw = shape.width; + slot.ih = shape.height; + slot.decorated = decorated; + slot.tint_key = tint_key; + pv.next_raster_revision +%= 1; + if (pv.next_raster_revision == 0) pv.next_raster_revision = 1; + slot.revision = pv.next_raster_revision; + } } } if (page == pv.page and slot.rgba.len > 0) syncPdfRasterAliases(pv, slot); } + p.trimPdfSpares(pv); } /// Attach every page intersecting the document viewport. Raster entries @@ -14378,10 +14925,12 @@ test "hopping between two panes does not grow the jump stack" { try std.testing.expectEqual(depth, p.njumps); } -/// A results buffer with rows we control: three look-able locations and one -/// row with nothing look-able on it at all. Returns its slot. `pat` is the -/// recorded pattern and is what keeps two of these APART — fillResults refills -/// a buffer whose origin, argument and directory all match. +/// A results buffer with rows we control: three rows whose HEAD is a look-able +/// location, one of them carrying a second location further along (which a +/// row-grained walk must not stop on), and one row with nothing look-able on +/// it at all. Returns its slot. `pat` is the recorded pattern and is what +/// keeps two of these APART — fillResults refills a buffer whose origin, +/// argument and directory all match. fn walkFixture(p: *Pardes, id: usize, cwd: []const u8, pat: []const u8) !usize { const rows = try std.fmt.allocPrint(p.gpa, \\build.zig:1:1 first @@ -14410,8 +14959,8 @@ test "n/N select look-able text and open nothing" { const panes_before = p.freeSlot(); // Nothing has looked yet, so the walk's list is the one unvisited output - // buffer. The first n lands on row 0's leading token, FOCUSES that buffer, - // and opens nothing whatsoever. + // buffer. The first n lands on row 0's location, FOCUSES that buffer, and + // opens nothing whatsoever. p.update(.{ .key = .{ .cp = 'n' } }); try std.testing.expectEqual(rid, p.active); try std.testing.expectEqual(panes_before, p.freeSlot()); @@ -14421,16 +14970,16 @@ test "n/N select look-able text and open nothing" { try std.testing.expect(rp.vsel.active and rp.vsel.explicit); try std.testing.expectEqualStrings("build.zig:1:1", p.currentSelText(rp) orelse ""); - // Row 1 holds TWO: a parenthesised path, whose wrappers are peeled off the - // selection, and a `path:LINE:COL-END` whose position tail is kept. + // ONE STOP PER ROW, at its head: this is a results LIST (Grain.line), so + // row 1 gives its parenthesised leading path — wrappers peeled off the + // selection — and the `build.zig.zon:3:2-9` further along it is part of + // the same hit, not a second place to stand. + try std.testing.expectEqual(output_pane.Grain.line, output_pane.grain(rp.file.?.output)); p.update(.{ .key = .{ .cp = 'n' } }); try std.testing.expectEqual(@as(i32, 1), rp.cur_row); try std.testing.expectEqualStrings("mise.toml", p.currentSelText(rp) orelse ""); - p.update(.{ .key = .{ .cp = 'n' } }); - try std.testing.expectEqual(@as(i32, 1), rp.cur_row); - try std.testing.expectEqualStrings("build.zig.zon:3:2-9", p.currentSelText(rp) orelse ""); - // Row 2 has nothing to step to and is skipped entirely. + // Row 2 has nothing look-able at all and is skipped entirely. p.update(.{ .key = .{ .cp = 'n' } }); try std.testing.expectEqual(@as(i32, 3), rp.cur_row); try std.testing.expectEqualStrings("uucode_config.zig:7:1", p.currentSelText(rp) orelse ""); @@ -14443,6 +14992,13 @@ test "n/N select look-able text and open nothing" { p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expect(p.freeSlot() != panes_before); try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/build.zig")); + + // The FILE that opened is free text, not a list, so it keeps the word + // grain — the breaking change is the results buffer's alone. + try std.testing.expectEqual( + output_pane.Grain.word, + output_pane.grain(p.panes[p.active].?.file.?.output), + ); } test "N is the exact inverse of n, across panes and the ring's seam" { diff --git a/src/pdf.zig b/src/pdf.zig index 2a674172..c1faafc5 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -268,36 +268,111 @@ pub fn tintRgba(rgba: []u8, mode: TintMode, colors: TintColors) !void { const pixels = std.mem.bytesAsSlice([4]u8, rgba); switch (mode) { .disabled => unreachable, + .full => tintPixels(pixels, .full, &targets), + .filtered => tintPixels(pixels, .filtered, &targets), + } +} + +/// The whole rule for ONE pixel, and the reason the memo below is exact: the +/// transform reads nothing but this packed word, and alpha rides through +/// untouched, so the word is a complete cache key. +inline fn tintPixel(word: u32, comptime mode: TintMode, targets: *const [256][3]u8) u32 { + var pixel: [4]u8 = @bitCast(word); + const red = pixel[0]; + const green = pixel[1]; + const blue = pixel[2]; + const luminance: u8 = @intCast((@as(u32, red) * 77 + + @as(u32, green) * 150 + + @as(u32, blue) * 29 + 128) >> 8); + const target = targets[luminance]; + switch (mode) { + .disabled => comptime unreachable, .full => { - for (pixels) |*pixel| { - const red = pixel[0]; - const green = pixel[1]; - const blue = pixel[2]; - const luminance: u8 = @intCast((@as(u32, red) * 77 + - @as(u32, green) * 150 + - @as(u32, blue) * 29 + 128) >> 8); - const target = targets[luminance]; - pixel[0] = target[0]; - pixel[1] = target[1]; - pixel[2] = target[2]; - } + pixel[0] = target[0]; + pixel[1] = target[1]; + pixel[2] = target[2]; }, .filtered => { - for (pixels) |*pixel| { - const red = pixel[0]; - const green = pixel[1]; - const blue = pixel[2]; - const luminance: u8 = @intCast((@as(u32, red) * 77 + - @as(u32, green) * 150 + - @as(u32, blue) * 29 + 128) >> 8); - const target = targets[luminance]; - const residual_base: i16 = 255 - @as(i16, luminance); - pixel[0] = filteredTintChannel(target[0], red, residual_base); - pixel[1] = filteredTintChannel(target[1], green, residual_base); - pixel[2] = filteredTintChannel(target[2], blue, residual_base); - } + const residual_base: i16 = 255 - @as(i16, luminance); + pixel[0] = filteredTintChannel(target[0], red, residual_base); + pixel[1] = filteredTintChannel(target[1], green, residual_base); + pixel[2] = filteredTintChannel(target[2], blue, residual_base); }, } + return @bitCast(pixel); +} + +/// Alpha-less packed tint for every grayscale level. See tintWord: this one +/// table serves BOTH modes, which is why it takes no mode. +fn grayTintTable(targets: *const [256][3]u8) [256]u32 { + var table: [256]u32 = undefined; + for (&table, targets) |*word, target| + word.* = @bitCast([4]u8{ target[0], target[1], target[2], 0 }); + return table; +} + +const tint_alpha_mask: u32 = @bitCast([4]u8{ 0, 0, 0, 0xff }); + +/// One pixel, with the two shortcuts that make a full-page tint affordable. +/// +/// Rec. 601's integer weights sum to exactly 256, so for r == g == b the +/// luminance dot product is the channel value itself, and `filtered`'s chroma +/// residual is then exactly zero — both modes collapse to the themed target +/// for that level. A text page's raster is overwhelmingly grayscale, so this +/// arm carries almost every pixel at one table load. Colour falls through to +/// the general rule, unchanged. +inline fn tintWord( + word: u32, + comptime mode: TintMode, + targets: *const [256][3]u8, + gray: *const [256]u32, +) u32 { + const pixel: [4]u8 = @bitCast(word); + if (pixel[0] == pixel[1] and pixel[1] == pixel[2]) + return gray[pixel[0]] | (word & tint_alpha_mask); + return tintPixel(word, mode, targets); +} + +/// Pixels per run test. Sixteen is four SSE2 registers: one branch says +/// "this whole span repeats the previous pixel", which is the shape of the +/// paper margins and of any flat fill. +const tint_run_pixels = 16; + +/// A rasterized page is mostly flat — paper, then runs of one ink value — and +/// the transform reads nothing but the packed word, so a one-entry memo tested +/// a span at a time turns those runs into one comparison and one store. That, +/// plus the grayscale collapse above, is what lets a fling afford to tint a +/// whole freshly rasterized page inside a single frame. Colour-photographic +/// content pays the span comparison and nothing else; the arithmetic is +/// byte-for-byte the scalar rule in every arm. +fn tintPixels(pixels: [][4]u8, comptime mode: TintMode, targets: *const [256][3]u8) void { + const gray = grayTintTable(targets); + const Run = @Vector(tint_run_pixels, u32); + var memo_key: u32 = undefined; + var memo_value: u32 = undefined; + var memo_valid = false; + var at: usize = 0; + while (at + tint_run_pixels <= pixels.len) : (at += tint_run_pixels) { + const span = pixels[at..][0..tint_run_pixels]; + if (memo_valid) { + const words: Run = @bitCast(span.*); + if (@reduce(.And, words == @as(Run, @splat(memo_key)))) { + span.* = @bitCast(@as(Run, @splat(memo_value))); + continue; + } + } + for (span) |*pixel| { + memo_key = @bitCast(pixel.*); + memo_value = tintWord(memo_key, mode, targets, &gray); + pixel.* = @bitCast(memo_value); + } + memo_valid = true; + } + while (at < pixels.len) : (at += 1) { + const pixel = &pixels[at]; + const word: u32 = @bitCast(pixel.*); + pixel.* = @bitCast(tintWord(word, mode, targets, &gray)); + } } fn tintRgbaReference(rgba: []u8, mode: TintMode, colors: TintColors) !void { @@ -403,6 +478,60 @@ test "optimized PDF tint matches scalar rule across deterministic broad samples" } } +// The memo, its tint_run_pixels span test, and the grayscale collapse only +// engage on repeated or achromatic pixels, and the span loop leaves a tail on +// any pixel count that is not a multiple of tint_run_pixels. None of that is +// visible to the random corpus above, so drive flat runs, run boundaries, and +// every remainder against the reference rule directly. +test "optimized PDF tint matches scalar rule across runs, boundaries, and every tail" { + const modes = [_]TintMode{ .full, .filtered }; + const colors: TintColors = .{ + .background = .{ 0x08, 0x12, 0x2a }, + .foreground = .{ 0xbd, 0xa4, 0x71 }, + }; + // Paper, ink, antialiased edge, saturated colour, and a transparent pixel + // that shares its RGB with an opaque one — the memo key must include alpha + // or that pair would tint from one cached word. + const palette = [_][4]u8{ + .{ 0xff, 0xff, 0xff, 0xff }, + .{ 0x00, 0x00, 0x00, 0xff }, + .{ 0x7f, 0x80, 0x81, 0xff }, + .{ 0xff, 0x00, 0x00, 0xff }, + .{ 0xff, 0xff, 0xff, 0x00 }, + }; + var state: u64 = 0x7061_7264_6573_5254; + var pixels: [37][4]u8 = undefined; + for (0..96) |round| { + // Alternate long flat runs against pixel-by-pixel churn so the block + // fast path, the per-pixel memo, and the miss path all run. + var run_left: usize = 0; + var current: [4]u8 = palette[round % palette.len]; + for (&pixels) |*pixel| { + if (run_left == 0) { + run_left = 1 + (tintTestByte(&state) % 9); + current = if (tintTestByte(&state) & 3 == 0) + .{ tintTestByte(&state), tintTestByte(&state), tintTestByte(&state), tintTestByte(&state) } + else + palette[tintTestByte(&state) % palette.len]; + } + run_left -= 1; + pixel.* = current; + } + for (0..pixels.len + 1) |count| { + const source = std.mem.sliceAsBytes(pixels[0..count]); + for (modes) |mode| { + var expected: [pixels.len * 4]u8 = undefined; + var actual: [pixels.len * 4]u8 = undefined; + @memcpy(expected[0..source.len], source); + @memcpy(actual[0..source.len], source); + try tintRgbaReference(expected[0..source.len], mode, colors); + try tintRgba(actual[0..source.len], mode, colors); + try std.testing.expectEqualSlices(u8, expected[0..source.len], actual[0..source.len]); + } + } + } +} + test "PDF tint mode cycle is exact" { try std.testing.expectEqual(TintMode.filtered, TintMode.disabled.next()); try std.testing.expectEqual(TintMode.full, TintMode.filtered.next()); @@ -740,62 +869,107 @@ pub const Document = struct { return document.renderInternal(gpa, page, request, highlights); } - fn renderInternal( + /// Exactly the raster this request will produce, without producing it. + /// Split out of the render so a caller holding a retired buffer of the + /// same length can hand it straight back instead of making the allocator + /// fetch (and later return) fresh pages for every page it flies past. + pub fn measureRenderAt( document: *Document, - gpa: std.mem.Allocator, page: usize, request: RenderRequest, - highlights: ?[]const Highlight, - ) !Render { - if (page >= document.pages or page > std.math.maxInt(c_int)) - return error.PageOutOfRange; - if (request.dpi == 0 or request.max_dimension == 0 or - request.max_dimension > absolute_max_render_dimension) - return error.InvalidRenderRequest; - const minimum_width: c_int = @intCast(@min( - request.minimum_width, - @as(u32, @intCast(std.math.maxInt(c_int))), - )); - const minimum_height: c_int = @intCast(@min( - request.minimum_height, - @as(u32, @intCast(std.math.maxInt(c_int))), - )); - const items: []const Highlight = highlights orelse &.{}; - if (items.len > c.PARDES_PDF_MAX_RESULT_QUADS) - return error.RenderFailed; + ) !Raster { + const bounded = try boundedRequest(document, page, request); var raw_layout: c.pardes_pdf_raster_layout = std.mem.zeroes(c.pardes_pdf_raster_layout); if (c.pardes_pdf_measure_render( document.handle, @intCast(page), - request.dpi, - minimum_width, - minimum_height, - request.max_dimension, + bounded.dpi, + bounded.minimum_width, + bounded.minimum_height, + bounded.max_dimension, &raw_layout, ) != c.PARDES_PDF_OK) return error.RenderFailed; const layout = try checkedRasterLayout(raw_layout); if (layout.len > max_owned_raster_bytes) return error.PixmapTooLarge; + return layout; + } - const rgba = try gpa.alloc(u8, layout.len); - errdefer gpa.free(rgba); + /// Rasterize into `rgba`, which MUST be exactly `raster.len` bytes from a + /// matching `measureRenderAt` with the same request. On any failure the + /// buffer's contents are unspecified and it still belongs to the caller. + pub fn renderIntoAt( + document: *Document, + page: usize, + request: RenderRequest, + raster: Raster, + highlights: []const Highlight, + rgba: []u8, + ) !void { + if (rgba.len != raster.len) return error.BadPixmap; + const bounded = try boundedRequest(document, page, request); + if (highlights.len > c.PARDES_PDF_MAX_RESULT_QUADS) return error.RenderFailed; const highlight_ptr: ?[*]const c.pardes_pdf_highlight = - if (items.len == 0) null else @ptrCast(items.ptr); + if (highlights.len == 0) null else @ptrCast(highlights.ptr); if (c.pardes_pdf_render_into( document.handle, @intCast(page), - request.dpi, - minimum_width, - minimum_height, - request.max_dimension, + bounded.dpi, + bounded.minimum_width, + bounded.minimum_height, + bounded.max_dimension, highlight_ptr, - items.len, + highlights.len, rgba.ptr, rgba.len, - @intCast(layout.width), - @intCast(layout.height), - @intCast(layout.stride), + @intCast(raster.width), + @intCast(raster.height), + @intCast(raster.stride), ) != c.PARDES_PDF_OK) return error.RenderFailed; - return .{ .rgba = rgba, .width = layout.width, .height = layout.height }; + } + + const BoundedRequest = struct { + dpi: c_int, + minimum_width: c_int, + minimum_height: c_int, + max_dimension: c_int, + }; + + fn boundedRequest( + document: *Document, + page: usize, + request: RenderRequest, + ) !BoundedRequest { + if (page >= document.pages or page > std.math.maxInt(c_int)) + return error.PageOutOfRange; + if (request.dpi == 0 or request.max_dimension == 0 or + request.max_dimension > absolute_max_render_dimension) + return error.InvalidRenderRequest; + return .{ + .dpi = request.dpi, + .minimum_width = @intCast(@min( + request.minimum_width, + @as(u32, @intCast(std.math.maxInt(c_int))), + )), + .minimum_height = @intCast(@min( + request.minimum_height, + @as(u32, @intCast(std.math.maxInt(c_int))), + )), + .max_dimension = request.max_dimension, + }; + } + + fn renderInternal( + document: *Document, + gpa: std.mem.Allocator, + page: usize, + request: RenderRequest, + highlights: ?[]const Highlight, + ) !Render { + const raster = try document.measureRenderAt(page, request); + const rgba = try gpa.alloc(u8, raster.len); + errdefer gpa.free(rgba); + try document.renderIntoAt(page, request, raster, highlights orelse &.{}, rgba); + return .{ .rgba = rgba, .width = raster.width, .height = raster.height }; } /// Plain UTF-8-ish text projection for one zero-based page. MuPDF owns the @@ -955,14 +1129,17 @@ fn validQuad(quad: Quad) bool { validPoint(quad.ll) and validPoint(quad.lr); } -const RasterLayout = struct { +/// The exact shape of the raster a render request produces. `len` is the +/// packed RGBA byte count `renderIntoAt` demands, so a caller can match a +/// retired buffer against it before deciding to allocate. +pub const Raster = struct { width: usize, height: usize, stride: usize, len: usize, }; -fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !RasterLayout { +fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !Raster { if (raw.width < 1 or raw.height < 1 or raw.stride < 1) return error.BadPixmap; const width: usize = @intCast(raw.width); diff --git a/src/tty/tty.zig b/src/tty/tty.zig index eadc3a9d..f6d095e5 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -456,6 +456,11 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // per-slot spawn generation: a reused pane id ignores the old shell's // late pty_eof (which would otherwise close the NEW pty on that slot) var gens: [pardes.MAX_PANES]u32 = @splat(0); + // ...and the core's one way to ask about those ptys: is a pane's tty still + // the prompt we forked? Installed here rather than polled per frame (see + // the cwd loop) and re-installed on a replay core, which owns a fresh set + // of panes over this same table. + core.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt }; // the single in-flight language query (see the .lsp effect) var lsp_task: ?std.Io.Future(anyerror!void) = null; // Selection filters may overlap: a second submit supersedes the first in @@ -811,6 +816,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); kitty_handles.clearRetainingCapacity(); nc.native_images = vx.caps.kitty_graphics; + nc.tty_query = .{ .ctx = &ptys, .taken = &ttyTakenAt }; core.deinit(); core = nc; if (comptime pardes.pdf_enabled) { @@ -822,7 +828,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } } - // live cwd for tags/look: cheap per-pane lookup, per frame + // live cwd for tags/look: cheap per-pane lookup, per frame. Whether the + // pane's tty still belongs to the prompt we forked is NOT polled here — + // it is a walk through /proc and nothing draws it, so the core pulls it + // through ttyQuery below, at the Exec that cares. for (&ptys, 0..) |*slot, id| if (slot.*) |pt| { var lbuf: [1024]u8 = undefined; if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); @@ -989,7 +998,9 @@ fn drainEffects( } const child = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w); ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; - // report the pane's starting directory back to the core (tags) + // report the pane's starting directory back to the core (tags). The + // slot needs no occupancy reset: nothing is remembered, and the next + // Exec asks about the shell that is there now. var lbuf: [1024]u8 = undefined; if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); if (threads_ok) { @@ -1275,6 +1286,17 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } +/// The core's `tty_query`: answer for one pane out of this host's pty table. +/// Lazy by construction — it runs only where the core is about to type a +/// command line, so the /proc walk costs nothing on an ordinary frame. A pane +/// with no pty of ours (a document, a slot whose shell already died) is not a +/// terminal a program can be holding. +fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool { + const table: *const [pardes.MAX_PANES]?Pty = @ptrCast(@alignCast(ctx orelse return false)); + const pt = table[pane] orelse return false; + return look.ttyTaken(pt.pid, pt.file.handle); +} + fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } { var master: c_int = undefined; // resolved BEFORE the fork, into this frame, which the child inherits: |
