summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 06:54:27 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit599dd82f96b9d091aae78300aa6c3fbc81f9eb69 (patch)
tree532d29eb0d79896cd730fb324df4866056306506
parent628aa40f13e9bbd313b51ab625f193110aad8dd0 (diff)
downloadpardes-599dd82f96b9d091aae78300aa6c3fbc81f9eb69.tar.gz
pardes-599dd82f96b9d091aae78300aa6c3fbc81f9eb69.zip
review pass: fix the eaten Tab, drop the duplicated code, cover the gaps
-rw-r--r--src/gui/gui.zig80
-rw-r--r--src/lsp/lsp.zig4
-rw-r--r--src/lsp/lsp_zls.zig32
-rw-r--r--src/macos.zig18
-rw-r--r--src/nested.zig59
-rw-r--r--src/output_pane.zig69
-rw-r--r--src/pardes.zig307
-rw-r--r--src/tty/tty.zig11
-rw-r--r--src/web.zig56
-rw-r--r--test/snapshots/lspcomplete.golden2
-rw-r--r--test/snapshots/lspcomplete.snap49
-rw-r--r--test/snapshots/lspcompletemoved.golden36
-rw-r--r--test/snapshots/lspcompletemoved.snap33
-rw-r--r--test/snapshots/nested-optout.golden93
-rw-r--r--test/snapshots/nested-optout.snap52
-rw-r--r--test/snapshots/tagalign2col.golden93
-rw-r--r--test/snapshots/tagalign2col.snap45
-rw-r--r--test/snapshots/tagbottom.golden93
-rw-r--r--test/snapshots/tagbottom.snap14
-rw-r--r--test/snapshots/tagbottomimage.golden155
-rw-r--r--test/snapshots/tagbottomimage.snap52
21 files changed, 809 insertions, 544 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 0cc5f520..39d8c575 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -461,7 +461,7 @@ fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, wi
const point = TouchNormPoint{ .x = f.x, .y = f.y };
const col = normCell(point.x, win_w, cell_w);
const row = normCell(point.y, win_h, cell_h);
- const chrome = webChromeTarget(core, col, row);
+ const chrome = core.chromeTarget(col, row);
t.beginSingle(f, chrome != null);
if (chrome) |target|
core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } });
@@ -496,73 +496,6 @@ fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, wi
}
}
-const WebChromeTarget = struct { col: u16, row: u16 };
-
-/// Fat-finger hit test for web layout chrome. A gesture is classified once at
-/// finger-down, then never changes into scrolling. Handle coordinates are
-/// snapped onto the core's one-cell resize edge; body rows remain untouched.
-///
-/// The tag row and the stacked-pair seam come from the core's own rule:
-/// Tagbottom puts a pane's tag on its LAST row, which makes its FIRST row an
-/// ordinary scrollable body row and moves the h-handle down to the lower
-/// pane's first. Kept a line-for-line clone of web.zig's chromeTarget — the
-/// two must classify a finger identically.
-fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget {
- if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row };
-
- // The move box wins over a horizontal handle when a pane is tag-only,
- // matching the core's own mouse hit-test priority.
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = core.rects[id];
- const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
- return .{ .col = col, .row = tag };
- }
-
- for (0..core.ncol -| 1) |column| {
- const handle = core.col_x[column] + core.col_w[column] -| 1;
- if (col == handle) return .{ .col = handle, .row = row };
- }
- for (0..core.ncol) |column| {
- if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
- for (0..core.col_n[column] -| 1) |index| {
- const rect = core.rects[core.col_terms[column][index]];
- const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (row == handle) return .{ .col = col, .row = handle };
- }
- }
-
- // The remainder of each tag row is tag editing, not a scrollable body.
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = core.rects[id];
- const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w)
- return .{ .col = col, .row = tag };
- }
-
- // One-cell tolerance around thin separators, after exact tag rows have had
- // first refusal so a fat-finger tag edit never turns into a resize.
- for (0..core.ncol -| 1) |column| {
- const handle = core.col_x[column] + core.col_w[column] -| 1;
- if (cellDistance(col, handle) == 1) return .{ .col = handle, .row = row };
- }
- for (0..core.ncol) |column| {
- if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
- for (0..core.col_n[column] -| 1) |index| {
- const rect = core.rects[core.col_terms[column][index]];
- const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle };
- }
- }
- return null;
-}
-
-fn cellDistance(a: u16, b: u16) u16 {
- return if (a > b) a - b else b - a;
-}
-
fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
switch (f.kind) {
.down => {
@@ -1336,15 +1269,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// --nested opted out of the whole mechanism, including being an outer
// instance; so does any failure to bind, and then children simply open
// their own session.
- var sock_buf: [108]u8 = undefined;
- const sock_path: ?[:0]const u8 = if (opts.nested) null else nested.socketPath(&sock_buf, libc.getpid());
- const sock_fd: c_int = if (sock_path) |sp| nested.listenAt(sp) else -1;
- // only on the fd, so a bind that FAILED cannot unlink a path this process
- // never created
- defer if (sock_fd >= 0) {
- _ = libc.close(sock_fd);
- _ = libc.unlink(sock_path.?);
- };
+ const sock_fd: c_int = if (opts.nested) -1 else nested.listen();
+ defer nested.unlisten(sock_fd);
// initial spawns BEFORE any worker thread exists: forkpty from a
// multithreaded process can wedge the child before exec (see tty.zig).
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index 0ce70869..a4489552 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -184,9 +184,7 @@ pub const supports: std.EnumSet(Kind) = backend.supports;
/// diverts to `completion` after a `.`, so in a README — or in any pane the
/// backend would refuse — it has to indent instead. The core asks rather than
/// knowing, so the list of extensions stays the backend's business.
-pub fn speaks(path: []const u8) bool {
- return backend.speaks(path);
-}
+pub const speaks = backend.speaks;
/// Name shown by the harness and in `SPC ?`. Each implementation renames it.
pub const backend_name = "zls-inproc";
diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig
index eeacb5e3..dfc2db90 100644
--- a/src/lsp/lsp_zls.zig
+++ b/src/lsp/lsp_zls.zig
@@ -681,32 +681,12 @@ fn goto(
/// No spelling may contain a NEWLINE: the rows depend on line numbers being
/// identical between the repaired copy and the user's buffer (see `completion`).
///
-/// Picking between them takes TWO signals, and getting that wrong was the first
-/// version's bug twice over.
-///
-/// The parse-error count alone does not work. A repair that supplies the
-/// identifier without closing the outer construct merely TIES with the broken
-/// buffer — measured, `const z: E = .` is one error either way — so demanding
-/// a strict improvement threw the repair away in exactly the cases it was
-/// written for, and the feature only worked where the syntax was already
-/// finished. Accepting ties does not save it either: for an unclosed
-/// `switch (e) {` the bare identifier and the arrow BOTH tie at one error and
-/// only the arrow produces a switch node, so the count cannot say which helped.
-///
-/// Reachability alone does not work either. "Is the dot now an `enum_literal`
-/// the tree can be walked down to from the root" is exactly the question the
-/// repair asks, and an orphaned node answers it correctly — but several
-/// spellings can answer yes while leaving trees of very different quality, and
-/// taking the first loses candidates the cleanest spelling would have found.
-///
-/// So: reachability is a hard FILTER, the error count RANKS what survives it,
-/// the earlier spelling wins a tie, and a candidate that reaches ZERO errors
-/// ends the search because nothing can beat it. That last clause is why six
-/// spellings cost less than the three that came before them: a switch arm —
-/// the case this feature exists for — is answered by the first candidate, and
-/// only a buffer nothing repairs pays for all six. A buffer that already
-/// parses is left alone, and a dot no spelling can reach is the seam's normal
-/// "no result".
+/// Choosing between them: reachability (is the dot now an `enum_literal` the
+/// tree can be walked down to from the root) is a hard FILTER, the parse-error
+/// count RANKS what survives it, the earlier spelling wins a tie, and a
+/// candidate at ZERO errors ends the search. A buffer that already parses is
+/// left alone, and a dot no spelling can reach is the seam's normal "no
+/// result".
/// ponytail: one parse per spelling; caching the base parse is the obvious
/// next step if it ever shows up.
fn completionSource(arena: std.mem.Allocator, req: lsp.Req, tr: *Trace) [:0]const u8 {
diff --git a/src/macos.zig b/src/macos.zig
index 8eb6179a..2e628d54 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -495,12 +495,11 @@ export fn pardes_cursor_bar() bool {
/// Perform the IO the core queued. `threads_ok` is false for the one drain
/// inside pardes_init, which runs before any reader task exists.
///
-/// ponytail: the lsp, pipe and watch effects are answered with nothing. Each
-/// wants real machinery — a worker plus a snapshot of the pane's file for lsp
+/// ponytail: the lsp, pipe and watch effects do no work here. Each wants real
+/// machinery — a worker plus a snapshot of the pane's file for lsp
/// (src/tty/tty.zig:919), a job copy for pipe, and FSEvents for watch, since
-/// inotify is Linux-only. The core is built to tolerate an unanswered effect:
-/// the browser answers none of these either. Lift tty.zig's implementations
-/// when the app is past first light.
+/// inotify is Linux-only. Lift tty.zig's implementations when the app is past
+/// first light. Pipe and watch may simply be dropped; lsp may NOT, see below.
fn drainEffects(st: *State, threads_ok: bool) bool {
const core = st.core;
var did = false;
@@ -589,7 +588,14 @@ fn drainEffects(st: *State, threads_ok: bool) bool {
const y = core.yank orelse continue;
cb(st.runtime.userdata, y.ptr, y.len);
},
- .lsp, .pipe, .watch => {},
+ // An empty answer, immediately: the honest reply from a shell with
+ // no worker, and the only safe one. Tab after a `.` DIVERTS to the
+ // backend instead of indenting and indents late, when the answer
+ // comes back empty (lspResponse); drop the effect and lsp_wait
+ // stays armed, the retroactive indent never fires, and every
+ // dot-Tab for the rest of the session does nothing at all.
+ .lsp => |q| core.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }),
+ .pipe, .watch => {},
.quit => {},
}
}
diff --git a/src/nested.zig b/src/nested.zig
index 6e70bdc9..0c065d1d 100644
--- a/src/nested.zig
+++ b/src/nested.zig
@@ -17,15 +17,11 @@
//! socket sits at a path anyone can derive from a pid — `Exec …` arriving here
//! is not something this protocol is allowed to say.
//!
-//! Linux only. ponytail: darwin has no /proc, so the walk there is
-//! proc_pidinfo(PROC_PIDTBSDINFO) for `pbi_ppid` plus a `pbi_comm` compare —
-//! a 16-byte truncated name, which is a weaker identity than an exe path —
-//! and neither SOCK_CLOEXEC nor accept4 exists, so the socket half needs two
-//! extra fcntl(FD_CLOEXEC) calls. Its `sockaddr.un.path` is 104 bytes, not
-//! 108: the `[108]u8` buffers and the unguarded memcpys below are sized for
-//! linux and a port has to re-derive them from `@FieldType`. None of it is
-//! testable from here, so detection is simply off: a pardes inside a pardes on
-//! macOS opens a second session the way it always did.
+//! Linux only. ponytail: darwin has no /proc, no SOCK_CLOEXEC and no accept4,
+//! and its `sockaddr.un.path` is 104 bytes rather than the 108 every buffer
+//! and unguarded memcpy below assumes. None of that is testable from here, so
+//! detection is simply off: a pardes inside a pardes on macOS opens a second
+//! session the way it always did.
const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
@@ -69,7 +65,7 @@ pub fn socketPath(buf: *[108]u8, pid: libc.pid_t) ?[:0]const u8 {
/// link reads `/path/to/pardes (deleted)` while the freshly built child's
/// reads `/path/to/pardes`. Comparing them raw made every nested launch after
/// a rebuild open a second full-screen UI inside the pane.
-pub fn stripDeleted(link: []const u8) []const u8 {
+fn stripDeleted(link: []const u8) []const u8 {
const suffix = " (deleted)";
return if (std.mem.endsWith(u8, link, suffix)) link[0 .. link.len - suffix.len] else link;
}
@@ -78,7 +74,7 @@ pub fn stripDeleted(link: []const u8) []const u8 {
/// /proc/<pid>/stat: that field is positional after `comm`, and a comm may
/// contain spaces and parentheses — a process named `sh (a b)` shifts every
/// field after it and the parse silently reads the wrong number.
-pub fn parsePPid(status: []const u8) ?libc.pid_t {
+fn parsePPid(status: []const u8) ?libc.pid_t {
var lines = std.mem.splitScalar(u8, status, '\n');
while (lines.next()) |line| {
if (!std.mem.startsWith(u8, line, "PPid:")) continue;
@@ -90,7 +86,7 @@ pub fn parsePPid(status: []const u8) ?libc.pid_t {
/// The pid in a `pardes-<pid>.sock` filename, for the startup sweep. Strictly
/// digits: parseInt alone would take `pardes-+7.sock` and `pardes--7.sock`,
/// and the sweep unlinks what this answers about.
-pub fn sweepPid(name: []const u8) ?libc.pid_t {
+fn sweepPid(name: []const u8) ?libc.pid_t {
if (!std.mem.startsWith(u8, name, "pardes-") or !std.mem.endsWith(u8, name, ".sock")) return null;
const digits = name["pardes-".len .. name.len - ".sock".len];
if (digits.len == 0) return null;
@@ -218,17 +214,22 @@ fn sweep(dir: [:0]const u8) void {
/// Bind and listen so nested instances can find us; -1 if anything fails, and
/// a pardes without a socket is simply one whose children open their own UI.
+/// The path is always this process's own, so nobody outside holds a buffer of
+/// it — the shells each kept one and passed it back to be unlinked, which is a
+/// way for the two spellings to go out of step and for no other reason.
///
/// CLOEXEC matters more here than on any other fd in the program: pane shells
/// are forked with forkpty and inherit everything open, and an orphaned bash
/// holding this one would keep the socket bound long after we exit — the same
/// shape as the inherited lock fd that once held a flock forever.
-pub fn listenAt(path: [:0]const u8) c_int {
+pub fn listen() c_int {
if (comptime builtin.os.tag != .linux) return -1;
var dir_buf: [108:0]u8 = undefined;
const dir = socketDir(&dir_buf) orelse return -1;
if (!ensureSocketDir(dir)) return -1;
sweep(dir);
+ var path_buf: [108]u8 = undefined;
+ const path = socketPath(&path_buf, libc.getpid()) orelse return -1;
var addr: libc.sockaddr.un = .{ .path = @splat(0) };
if (path.len + 1 > addr.path.len) return -1;
@memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
@@ -249,6 +250,17 @@ pub fn listenAt(path: [:0]const u8) c_int {
return fd;
}
+/// Close the listener and take its file away. Guarded on the fd rather than on
+/// the path, so a bind that FAILED cannot unlink a path this process never
+/// created; anything else is a no-op, which is what --nested and every
+/// non-linux build hand it.
+pub fn unlisten(fd: c_int) void {
+ if (fd < 0) return;
+ _ = libc.close(fd);
+ var path_buf: [108]u8 = undefined;
+ if (socketPath(&path_buf, libc.getpid())) |path| _ = libc.unlink(path);
+}
+
/// Block until a nested instance sends a `Look` line, and return it inside
/// `buf`. Null only when the listening fd itself is gone — teardown closed it,
/// or it was never a socket — because anything else (EMFILE, ECONNABORTED)
@@ -282,11 +294,7 @@ pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 {
const tv: libc.timeval = .{ .sec = 1, .usec = 0 };
_ = libc.setsockopt(conn, libc.SOL.SOCKET, libc.SO.RCVTIMEO, &tv, @sizeOf(libc.timeval));
var len: usize = 0;
- // ...and a cap on the reads themselves, because the timeout is PER
- // read and a peer dribbling one byte under it would otherwise stretch
- // to buf.len seconds. One line is one or two reads.
- var reads: usize = 0;
- while (len < buf.len and reads < 64) : (reads += 1) {
+ while (len < buf.len) {
const n = libc.read(conn, buf.ptr + len, buf.len - len);
if (n < 0 and libc.errno(n) == .INTR) continue;
if (n <= 0) break; // EOF, or the receive timeout expired
@@ -307,6 +315,21 @@ pub fn acceptLine(fd: c_int, buf: []u8) ?[]const u8 {
test "socket path: XDG first, then a private dir under HOME, never /tmp" {
var buf: [108]u8 = undefined;
+ // The environment is process-wide and every test in this binary shares it.
+ // The last case below reaches the "no directory at all" branch by blanking
+ // both variables, and without this every later test ran without a HOME.
+ var xdg_buf: [4096:0]u8 = undefined;
+ var home_buf: [4096:0]u8 = undefined;
+ const xdg0 = if (libc.getenv("XDG_RUNTIME_DIR")) |v| std.fmt.bufPrintSentinel(&xdg_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null;
+ const home0 = if (libc.getenv("HOME")) |v| std.fmt.bufPrintSentinel(&home_buf, "{s}", .{std.mem.span(v)}, 0) catch null else null;
+ defer {
+ if (xdg0) |v| {
+ _ = setenv("XDG_RUNTIME_DIR", v, 1);
+ } else _ = unsetenv("XDG_RUNTIME_DIR");
+ if (home0) |v| {
+ _ = setenv("HOME", v, 1);
+ } else _ = unsetenv("HOME");
+ }
_ = setenv("XDG_RUNTIME_DIR", "/run/user/1000", 1);
try std.testing.expectEqualStrings("/run/user/1000/pardes-4242.sock", socketPath(&buf, 4242).?);
_ = unsetenv("XDG_RUNTIME_DIR");
diff --git a/src/output_pane.zig b/src/output_pane.zig
index bce3d503..1bf14b7c 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -236,6 +236,75 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u
return pane;
}
+/// Land a freshly produced list of rows in the buffer it belongs in — the one
+/// rule every results buffer follows, whichever side of the core made them.
+///
+/// The SAME command asked again REFILLS the list it already opened rather than
+/// stacking a byte-identical twin under the pane. That was runSearch's rule
+/// from the start (right-clicking a word in four places is one +Search walked
+/// four times) and language answers turned out to need it far more urgently:
+/// Tab after a dot makes a query an ordinary typing keystroke, and without the
+/// refill twenty of them fill every slot and the key is eaten for the rest of
+/// the session — see docs/lsp.md.
+///
+/// What "the same command" means comes off the ORIGIN. A search is identified
+/// by its PATTERN, so `foo`, `bar`, `foo` re-arms foo's own buffer and leaves
+/// bar's open; a language query is asked about a different symbol every time
+/// with the same (usually empty) arg, so the arg cannot tell two apart and the
+/// KIND is the natural unit — a second `gr` replaces the first list. Same
+/// directory only, because the rows are written relative to it, and never the
+/// asking pane itself (a `/` inside a +Search writes its own rows).
+///
+/// `content` is gpa-owned: adopted by the buffer, or freed here when there is
+/// nowhere to put it. `anchor` is the row n/N step from, null for the top.
+pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u8, content: []u8, anchor: ?usize) void {
+ const pane = p.panes[id] orelse {
+ p.gpa.free(content);
+ return;
+ };
+ const by_arg = std.meta.activeTag(from) != .query;
+ for (p.panes, 0..) |slot, i| {
+ if (i == id) continue;
+ const rp = slot orelse continue;
+ const rf = if (rp.file) |*f| f else continue;
+ const o = if (rf.output) |*x| x else continue;
+ if (!std.meta.eql(o.from, from)) continue;
+ if (by_arg and !std.mem.eql(u8, o.arg(), arg)) continue;
+ if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
+ setArg(o, arg);
+ // a refill that changes NOTHING keeps its place: a right click on an
+ // already-armed word is an `n`, and throwing the list back to the top
+ // only to scroll down to the stepped row is a jump with no information
+ // in it.
+ const same = std.mem.eql(u8, rf.content, content);
+ file_pane.setContent(p, rf, content);
+ if (!same) rf.scroll = 0;
+ p.active = id;
+ if (traits(from).steps) {
+ pane.search_pane = i;
+ pane.search_row = anchor;
+ }
+ return;
+ }
+ const free = p.freeSlot() orelse {
+ p.gpa.free(content);
+ return;
+ };
+ const np = open(p, free, dir, from, arg, content) catch {
+ p.gpa.free(content);
+ return;
+ };
+ p.placeDoc(id, free, np);
+ p.active = id;
+ // prose is not a list of locations: n/N over a hover blurb would step to
+ // nowhere, so only stepping buffers arm the stepper — and WHICH command
+ // filled it is the buffer's own record, not a field on the asking pane.
+ if (traits(from).steps) {
+ pane.search_pane = free;
+ pane.search_row = anchor;
+ }
+}
+
/// The Jumplist builtin: the focus history (Pardes.jumps) written out as text,
/// one row per location, oldest first — the same `location text` shape every
/// results buffer here has, which is what buys n/N stepping and Look-on-a-row
diff --git a/src/pardes.zig b/src/pardes.zig
index 0dba0abf..592dd059 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1919,75 +1919,6 @@ test "an untouched tagline ends where its layout column's widest one does" {
);
}
-test "tagPrefixLen agrees with tagPrefix for every pane kind" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
-
- const same = struct {
- fn f(pp: *Pardes, pane: *Pane) !void {
- const s = try pp.tagPrefix(pane);
- try std.testing.expectEqual(s.len, Pardes.tagPrefixLen(pane));
- }
- }.f;
-
- // terminal: whatever boot gave it, then empty, then a changed cwd
- const term = p.panes[0].?;
- try same(p, term);
- p.setCwd(0, "");
- try same(p, term);
- p.setCwd(0, "/some/where/deep/enough/to/matter");
- try same(p, term);
- p.setCwd(0, "/caf\u{e9}/\u{5b50}"); // multibyte: BYTES, both sides
- try same(p, term);
-
- // an output buffer (+Help) — a file pane whose path the table names
- const help_id = p.freeSlot().?;
- _ = try p.newDocPane(help_id);
- output_pane.openHelp(p, help_id, "");
- try same(p, p.panes[help_id].?);
-
- // an image
- const img_id = p.freeSlot().?;
- const img = try p.newDocPane(img_id);
- img.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-parity/pic.ppm") };
- try same(p, img);
-
- // an ordinary file pane (this one replaces pane 0, so it goes last)
- const file = try p.hxOpenFileContent("hello\n");
- try same(p, file);
-}
-
-test "tagPrefixLen agrees with tagPrefix for a pdf pane" {
- if (!pdf_enabled or platform == .web) return;
- const gpa = std.testing.allocator;
- var tmp = std.testing.tmpDir(.{});
- defer tmp.cleanup();
- const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
- defer gpa.free(fixture);
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
- var path_buf: [256]u8 = undefined;
- const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
-
- const p = try Pardes.init(gpa, .{ .file = path, .cols = 100, .rows = 30 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pdf_pane = p.panes[0].?;
- try std.testing.expect(pdf_pane.pdf != null);
- const s0 = try p.tagPrefix(pdf_pane);
- try std.testing.expectEqual(s0.len, Pardes.tagPrefixLen(pdf_pane));
- // ...and on a later page / another fit / another tint, where the digits
- // and the @tagName words change width
- pdf_pane.pdf.?.page = 9;
- pdf_pane.pdf.?.page_count = 100;
- const s1 = try p.tagPrefix(pdf_pane);
- try std.testing.expectEqual(s1.len, Pardes.tagPrefixLen(pdf_pane));
- pdf_pane.pdf.?.page = 99;
- const s2 = try p.tagPrefix(pdf_pane);
- try std.testing.expectEqual(s2.len, Pardes.tagPrefixLen(pdf_pane));
-}
-
test "legacy default tag tails upgrade while custom tails remain owned" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{});
@@ -3971,9 +3902,10 @@ test "a corner drag's two axes clamp independently" {
const ay: u16 = TOPBAR_H;
const ah: u16 = 15;
const bh: u16 = 14;
- // the walls, spelled out: the handle is the upper pane's LAST row, so the
- // upper pane bottoms out with its tag row alone at ay, and the lower pane
- // does the same one row above the pair's end
+ // the walls, spelled out for tags-on-top (the tag_bottom = false below),
+ // where the handle is the upper pane's LAST row: the upper pane bottoms out
+ // with its tag row alone at ay, and the lower pane does the same one row
+ // above the pair's end
const row_lo: u16 = ay + BOX_H - 1;
const row_hi: u16 = ay + ah + bh - (BOX_H + 1);
@@ -4662,9 +4594,6 @@ pub const Pardes = struct {
// ---- tag + selection text (chord sources) ----
- // shared by tagPrefix and tagPrefixLen below, which have to agree
- const pdf_tag_fmt = "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}";
-
/// the live tag prefix: the pane's cwd/path, and nothing else (an image
/// still names its kind — the renderer toggles it used to spell out are
/// builtins now, under SPC t). The mode used to lead this as a word; it is
@@ -4679,7 +4608,7 @@ pub const Pardes = struct {
const arena = p.scratch.allocator();
if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint(
arena,
- pdf_tag_fmt,
+ "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}",
.{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
);
if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
@@ -4687,20 +4616,6 @@ pub const Pardes = struct {
return arena.dupe(u8, pane.cwdSlice());
}
- /// the same prefix's LENGTH, without the allocation. tagGap measures every
- /// pane in a layout column, once per pane per frame, and has no arena —
- /// formatting a path just to ask how wide it is would put the whole column
- /// on the render hot path. Mirror any change to tagPrefix here.
- fn tagPrefixLen(pane: *const Pane) usize {
- if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.count(
- pdf_tag_fmt,
- .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
- );
- if (pane.image) |iv| return config.tag_image.len + 1 + iv.path.len;
- if (pane.file) |f| return f.path.len;
- return pane.cwdSlice().len;
- }
-
/// the editable tail: the user's edited buffer once touched, else defaults
/// (a buffer with nothing to Save gets the plain tail — the table decides)
fn curTail(pane: *Pane) []const u8 {
@@ -4766,7 +4681,14 @@ pub const Pardes = struct {
/// every keystroke. It still takes no gap of its own (above) — but it
/// has to keep voting, or clicking the widest tagline in a column would
/// snap every other one left, out from under the next click.
- fn tagGap(p: *const Pardes, pane: *const Pane, used: usize) usize {
+ ///
+ /// ponytail: every voter's prefix is FORMATTED to be measured, so a frame
+ /// costs up to MAX_PANES² path dupes — 256 bump allocations into the
+ /// scratch arena that renderPane resets anyway, at a realistic two to four
+ /// panes. The alternative was a second tagPrefix that only counted, and
+ /// keeping two spellings of one string in step by comment is the more
+ /// expensive kind of cost.
+ fn tagGap(p: *Pardes, pane: *const Pane, used: usize) usize {
if (pane.tag_init) return 0;
const id = p.paneIdOf(pane) orelse return 0;
const r = p.rects[id];
@@ -4785,7 +4707,7 @@ pub const Pardes = struct {
pane_tail;
const laid = if (q.tag_init) q.tag_tail.items else words;
const lead = laid.len - std.mem.trimStart(u8, laid, " ").len;
- const q_end = tagPrefixLen(q) + lead + std.mem.trimStart(u8, words, " ").len;
+ const q_end = (p.tagPrefix(q) catch continue).len + lead + std.mem.trimStart(u8, words, " ").len;
end = @max(end, @min(q_end, tw));
};
return end -| used;
@@ -7326,48 +7248,12 @@ pub const Pardes = struct {
.find => .{ .cmd = .Find },
.grep => .{ .cmd = .Grep },
};
- // The SAME search asked again REFILLS the list it already opened —
- // right-clicking a word in four places is one +Search walked four
- // times, not four +Searches over identical rows. A different pattern
- // still gets its own buffer, and that IS the old rule: two searches are
- // two lists, both stay open at their sizes, and the new one stacks
- // directly below this pane (placeDoc). Focus stays here either way.
- // ...and it is ANY open list this search already filled, not only the
- // one n/N are armed on: search `foo`, then `bar`, then `foo` again and
- // the third search re-arms foo's own buffer rather than opening its
- // identical twin below it. Same directory only — the rows are written
- // relative to it, so another dir's list is a different list. Never the
- // searching pane itself (a `/` inside a +Search writes its own rows).
- for (p.panes, 0..) |slot, i| {
- if (i == id) continue;
- const rp = slot orelse continue;
- const rf = if (rp.file) |*f| f else continue;
- const o = rf.output orelse continue;
- if (!std.meta.eql(o.from, from) or !std.mem.eql(u8, o.arg(), pat)) continue;
- if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
- // a refill that changes NOTHING keeps its place: a right click on
- // an already-armed word is an `n`, and throwing the list back to
- // the top only to scroll down to the stepped row is a jump with no
- // information in it.
- const same = std.mem.eql(u8, rf.content, content);
- file_pane.setContent(p, rf, content);
- if (!same) rf.scroll = 0;
- pane.search_pane = i;
- pane.search_row = anchor;
- return;
- }
- const free = p.freeSlot() orelse {
- p.gpa.free(content);
- return;
- };
- const np = output_pane.open(p, free, dir, from, pat, content) catch {
- p.gpa.free(content);
- return;
- };
- p.placeDoc(id, free, np);
- p.active = id;
- pane.search_pane = free;
- pane.search_row = anchor;
+ // A different pattern still gets its own buffer: two searches are two
+ // lists, both stay open at their sizes, and the new one stacks directly
+ // below this pane. Everything about landing the rows — which open
+ // buffer counts as this same search, keeping a refill's place, opening
+ // fresh when there is none — is output_pane.fillResults.
+ output_pane.fillResults(p, id, dir, from, pat, content, anchor);
}
/// n/N: step to the next/previous row of this pane's results buffer and
@@ -7509,61 +7395,10 @@ pub const Pardes = struct {
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const content = p.gpa.dupe(u8, rows) catch return;
- // The same KIND asked again REFILLS the buffer it already opened, the
- // rule runSearch has always had. It was missing here and that was
- // survivable while every language query was a deliberate press: `gr`
- // twice left two identical lists and you closed one. Tab after a dot
- // is an ordinary typing keystroke, which turns the same bug fatal —
- // measured, twenty Tabs stacked fifteen byte-identical `+Search` panes
- // under the file, crushed it to one visible line, and from the
- // sixteenth on freeSlot returned null and the key was eaten for the
- // rest of the session with nothing said. One code path, so every kind
- // that lands in a results buffer gets the fix.
- //
- // Unlike runSearch this does NOT key on the ARGUMENT. A search is
- // identified by its pattern; a language query is asked about a
- // different symbol every time with the same (usually empty) arg, so
- // the arg cannot tell two lists apart and the KIND is the natural
- // unit: a second `gr` replaces the first list rather than growing a
- // stack of them.
- for (p.panes, 0..) |slot, i| {
- if (i == w.pane) continue;
- const rp = slot orelse continue;
- const rf = if (rp.file) |*f| f else continue;
- const o = rf.output orelse continue;
- if (!std.meta.eql(o.from, from)) continue;
- if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue;
- output_pane.setArg(&rf.output.?, w.arg.slice());
- // a refill that changes nothing keeps its place (runSearch's rule,
- // and the same reason): re-asking about a symbol you are already
- // stepping must not throw the list back to the top
- const same = std.mem.eql(u8, rf.content, content);
- file_pane.setContent(p, rf, content);
- if (!same) rf.scroll = 0;
- p.active = w.pane;
- if (output_pane.traits(from).steps) {
- pane.search_pane = i;
- pane.search_row = null;
- }
- return;
- }
- const free = p.freeSlot() orelse {
- p.gpa.free(content);
- return;
- };
- const np = output_pane.open(p, free, dir, from, w.arg.slice(), content) catch {
- p.gpa.free(content);
- return;
- };
- p.placeDoc(w.pane, free, np);
- p.active = w.pane;
- // prose is not a list of locations: n/N over a hover blurb would step
- // to nowhere, so only stepping buffers arm the stepper — and WHICH
- // query filled it is now the buffer's own record, not a field here.
- if (output_pane.traits(from).steps) {
- pane.search_pane = free;
- pane.search_row = null;
- }
+ // Landing the rows is runSearch's path exactly, keyed on the KIND
+ // rather than the argument (fillResults reads that off the origin).
+ // Why the refill is not optional here: docs/lsp.md.
+ output_pane.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null);
}
/// n/N on a terminal pane: a MOTION over lookable tokens. Select the
@@ -8024,7 +7859,14 @@ pub const Pardes = struct {
if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: {
const f = pane.file orelse break :dot;
if (f.output != null or !lsp.speaks(f.path)) break :dot;
- return p.lspRequest(p.active, .completion, "");
+ // speaks() is the fast path only — lspRequest has four
+ // bails of its own (unsupported kind, dead pane, output
+ // buffer, multiOnce) and each one would eat the Tab. The
+ // seq bump is the one honest "the question went out", so
+ // ask and fall through to the indent if it did not.
+ const seq = p.lsp_seq;
+ p.lspRequest(p.active, .completion, "");
+ if (p.lsp_seq != seq) return;
}
p.insertTab(pane);
},
@@ -8033,11 +7875,8 @@ pub const Pardes = struct {
}
/// helix insert_tab with a Spaces indent style: spaces to the next tab
- /// stop (smart-tab machinery skipped). It is a function rather than the
- /// five lines it used to be inside the Tab prong because Tab after a `.`
- /// asks the language backend FIRST and indents only if the answer comes
- /// back empty — which happens on another turn of the loop entirely, so
- /// lspResponse needs to be able to press the same key.
+ /// stop (smart-tab machinery skipped). A function because lspResponse
+ /// presses the same key, a turn of the loop later.
fn insertTab(p: *Pardes, pane: *Pane) void {
const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return;
const c: modal.Cursor = .{
@@ -8045,8 +7884,7 @@ pub const Pardes = struct {
.col = @intCast(@max(0, pane.cur_col)),
};
const pad = modal.INDENT_W - (c.col % modal.INDENT_W);
- const spaces = " ";
- const new = modal.insertAt(p.gpa, eb.text, c, spaces[0..pad]) catch return;
+ const new = modal.insertAt(p.gpa, eb.text, c, " "[0..pad]) catch return;
p.setEditText(pane, new);
pane.cur_col += @intCast(pad);
pane.cur_pinned = true;
@@ -9039,6 +8877,69 @@ pub const Pardes = struct {
});
}
+ pub const ChromeTarget = struct { col: u16, row: u16 };
+
+ /// Is this cell layout CHROME, and if so which cell should the press be
+ /// delivered at? For the touch shells: a finger on a tag row or a resize
+ /// handle latches a left-mouse drag, everything else is body text and gets
+ /// one-finger scrolling and tap-as-look. A gesture is classified once, at
+ /// finger-down, and never turns into a scroll afterwards.
+ ///
+ /// It lives here because it is a MIRROR of handleMouse's own hit test
+ /// below, in both the geometry and the ORDER: the move box beats a
+ /// horizontal handle on a tag-only pane, the rest of the tag row beats the
+ /// fat-finger tolerance around a separator, and Tagbottom moves both the
+ /// tag row and the h-handle together (a pane's tag on its LAST row makes
+ /// its first an ordinary body row and puts the seam on the lower pane's
+ /// first). It was a line-for-line clone in web.zig and gui.zig, kept in
+ /// step by a comment in each saying it was a clone of the other; the two
+ /// conditionals Tagbottom added went into both copies four times.
+ ///
+ /// The one-cell tolerance is the only thing here that is not handleMouse's
+ /// rule: a mouse is exact, a finger is not.
+ pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget {
+ if (row < TOPBAR_H) return .{ .col = col, .row = row };
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = p.rects[id];
+ const tag = if (p.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
+ return .{ .col = col, .row = tag };
+ }
+ for (0..p.ncol -| 1) |column| {
+ const handle = p.col_x[column] + p.col_w[column] -| 1;
+ if (col == handle) return .{ .col = handle, .row = row };
+ }
+ for (0..p.ncol) |column| {
+ if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
+ for (0..p.col_n[column] -| 1) |index| {
+ const rect = p.rects[p.col_terms[column][index]];
+ const handle = if (p.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ if (row == handle) return .{ .col = col, .row = handle };
+ }
+ }
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = p.rects[id];
+ const tag = if (p.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ if (row == tag and col >= rect.x and col < rect.x + rect.w)
+ return .{ .col = col, .row = tag };
+ }
+ for (0..p.ncol -| 1) |column| {
+ const handle = p.col_x[column] + p.col_w[column] -| 1;
+ if (@max(col, handle) - @min(col, handle) == 1) return .{ .col = handle, .row = row };
+ }
+ for (0..p.ncol) |column| {
+ if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
+ for (0..p.col_n[column] -| 1) |index| {
+ const rect = p.rects[p.col_terms[column][index]];
+ const handle = if (p.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle };
+ }
+ }
+ return null;
+ }
+
fn handleMouse(p: *Pardes, m: Mouse) void {
const mcol = @min(m.col, p.screen_w -| 1);
const mrow = @min(m.row, p.screen_h -| 1);
@@ -9135,12 +9036,6 @@ pub const Pardes = struct {
// pair that is a BODY row — the upper pane's last, or with
// Tagbottom, where that one is the upper pane's tag, the
// lower pane's first).
- // Either way the handle EATS one body row of one of the two
- // panes — a press there resizes instead of placing a cursor
- // — and it always has. Tagbottom does not change how many
- // rows that costs, only WHICH pane pays: the lower pane's
- // first row rather than the upper pane's last. A no-drag
- // click on it is a no-op in both orientations.
// The v test still wins outright, but it now also asks
// whether this same cell is one of ITS OWN column's h
// handles — that cell is the corner where the two lines
@@ -9359,8 +9254,6 @@ pub const Pardes = struct {
// .tag drag) stays on the tag, which is one line anyway.
// The cost is that a body sweep can no longer be extended
// onto the bottom tagline to pick up the tag text.
- // With the tag on TOP, Sel order IS screen order and the
- // gesture is contiguous and correct, so nothing is clamped.
if (p.tag_bottom) {
const body_h = r.h -| BOX_H;
pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0)
@@ -12191,7 +12084,7 @@ pub const Pardes = struct {
try p.renderPane(arena, pane, p.rects[id], id == p.active);
}
- // ---- the transient message row: a pane's LAST row, left-aligned ----
+ // ---- the transient message row: the end away from the tag ----
//
// An OVERLAY, not geometry: no rect moves, no pane shrinks, and a pane
// with neither a message nor an armed prompt is not touched at all.
@@ -12422,7 +12315,7 @@ pub const Pardes = struct {
// body's first. The Tagbottom builtin swaps which end each is at and
// NOTHING else in here reads r.y — that is the whole of the feature on
// the render side. r.h == 0 returned above, so the bottom row exists.
- const tag_y = if (p.tag_bottom) r.y + r.h - 1 else r.y;
+ const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
// the pane's own background, for everything that has to read as "no
// chrome here": the body text, and the blank right half of the
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 91213d9f..ad6f04b0 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -457,15 +457,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// --nested opted out of the whole mechanism, including being an outer
// instance; so does any failure to bind, and then children simply open
// their own session.
- var sock_buf: [108]u8 = undefined;
- const sock_path: ?[:0]const u8 = if (opts.nested) null else nested.socketPath(&sock_buf, libc.getpid());
- const sock_fd: c_int = if (sock_path) |sp| nested.listenAt(sp) else -1;
- // only on the fd, so a bind that FAILED cannot unlink a path this process
- // never created
- defer if (sock_fd >= 0) {
- _ = libc.close(sock_fd);
- _ = libc.unlink(sock_path.?);
- };
+ const sock_fd: c_int = if (opts.nested) -1 else nested.listen();
+ defer nested.unlisten(sock_fd);
// Perform the initial spawns BEFORE any worker thread exists: forkpty from
// a multithreaded process can wedge the child before exec.
diff --git a/src/web.zig b/src/web.zig
index b8abf27e..43b6c96d 100644
--- a/src/web.zig
+++ b/src/web.zig
@@ -344,7 +344,7 @@ export fn pardes_chrome_target(col_arg: u32, row_arg: u32) u32 {
const s = &(state orelse return std.math.maxInt(u32));
const col: u16 = @intCast(@min(col_arg, std.math.maxInt(u16)));
const row: u16 = @intCast(@min(row_arg, std.math.maxInt(u16)));
- const target = chromeTarget(s.core, col, row) orelse return std.math.maxInt(u32);
+ const target = s.core.chromeTarget(col, row) orelse return std.math.maxInt(u32);
return (@as(u32, target.row) << 16) | target.col;
}
@@ -486,57 +486,3 @@ fn putU32(s: *State, value: u32) void {
std.mem.writeInt(u32, &bytes, value, .little);
appendEffect(s, &bytes);
}
-
-const Target = struct { col: u16, row: u16 };
-
-/// The tag row and the stacked-pair seam, the way the core's own hit test
-/// derives them: Tagbottom puts a pane's tag on its LAST row, which makes its
-/// FIRST row an ordinary scrollable body row and moves the h-handle down to
-/// the lower pane's first. Kept a line-for-line clone of gui.zig's
-/// webChromeTarget — the two must classify a finger identically.
-fn chromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?Target {
- if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row };
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = core.rects[id];
- const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.config.GUTTER)
- return .{ .col = col, .row = tag };
- }
- for (0..core.ncol -| 1) |column| {
- const handle = core.col_x[column] + core.col_w[column] -| 1;
- if (col == handle) return .{ .col = handle, .row = row };
- }
- for (0..core.ncol) |column| {
- if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
- for (0..core.col_n[column] -| 1) |index| {
- const rect = core.rects[core.col_terms[column][index]];
- const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (row == handle) return .{ .col = col, .row = handle };
- }
- }
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const rect = core.rects[id];
- const tag = if (core.tag_bottom) rect.y + rect.h -| pardes.BOX_H else rect.y;
- if (row == tag and col >= rect.x and col < rect.x + rect.w)
- return .{ .col = col, .row = tag };
- }
- for (0..core.ncol -| 1) |column| {
- const handle = core.col_x[column] + core.col_w[column] -| 1;
- if (distance(col, handle) == 1) return .{ .col = handle, .row = row };
- }
- for (0..core.ncol) |column| {
- if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
- for (0..core.col_n[column] -| 1) |index| {
- const rect = core.rects[core.col_terms[column][index]];
- const handle = if (core.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
- if (distance(row, handle) == 1) return .{ .col = col, .row = handle };
- }
- }
- return null;
-}
-
-fn distance(a: u16, b: u16) u16 {
- return if (a > b) a - b else b - a;
-}
diff --git a/test/snapshots/lspcomplete.golden b/test/snapshots/lspcomplete.golden
index 5c92b71a..51c11c66 100644
--- a/test/snapshots/lspcomplete.golden
+++ b/test/snapshots/lspcomplete.golden
@@ -203,7 +203,7 @@
| /tmp/pardes-snap/lspcomplete/cwd/+Search New Del
| 1 /tmp/pardes-snap/lspcomplete/cwd/a.zig:2:5-13 verdigris,
| 2 /tmp/pardes-snap/lspcomplete/cwd/a.zig:3:5-12 cinnabar,
-== snap twentytabs grid=120x40 cursor=16,9
+== snap refill grid=120x40 cursor=16,9
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
|^ /tmp/pardes-snap/lspcomplete/cwd/a.zig Save New Del
| 1 const Shade = enum {
diff --git a/test/snapshots/lspcomplete.snap b/test/snapshots/lspcomplete.snap
index 7393b209..8b7854cd 100644
--- a/test/snapshots/lspcomplete.snap
+++ b/test/snapshots/lspcomplete.snap
@@ -66,52 +66,21 @@ key tab
wait 15000 a.zig:2:
stable 700 15000
snap completion
-# Nineteen more Tabs. Every one of them answers, and every answer REFILLS the
-# one `+Search` rather than opening its twin: before that fix six presses left
-# six byte-identical panes, twenty left fifteen with the file crushed to a
-# single visible line, and from the sixteenth on freeSlot returned null and the
-# keystroke was eaten for the rest of the session with nothing said. The golden
-# below is the assertion — one file pane, one results pane, same as after the
-# first press.
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
-key tab
-settle 150
+# Two more Tabs. Every one of them answers, and every answer REFILLS the one
+# `+Search` rather than opening its twin. The SECOND press is already the whole
+# assertion: without the refill it leaves a second byte-identical `+Search` and
+# this golden diverges. What TWENTY do is only worse in degree — fifteen
+# stacked panes, the file crushed to one visible line, and from the sixteenth
+# on freeSlot returns null and the keystroke is eaten for the rest of the
+# session — and that story lives in docs/lsp.md rather than in three seconds of
+# every suite run. One file pane, one results pane, same as after the first.
key tab
settle 150
key tab
settle 150
key tab
stable 700 20000
-snap twentytabs
+snap refill
# The pane is still in insert mode, which is right — the query answered beside
# the cursor and typing goes on — so walking the answer means leaving it first,
# the same as for any other results buffer.
diff --git a/test/snapshots/lspcompletemoved.golden b/test/snapshots/lspcompletemoved.golden
new file mode 100644
index 00000000..bd23bfcd
--- /dev/null
+++ b/test/snapshots/lspcompletemoved.golden
@@ -0,0 +1,36 @@
+== snap typedthrough grid=120x20 cursor=16,13
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|^ /tmp/pardes-snap/lspcompletemoved/cwd/a.zig Save New Del
+| 1 const Shade = enum {
+| 2 verdigris,
+| 3 cinnabar,
+| 4 };
+| 5
+| 6 fn pick(s: Shade) u8 {
+| 7 return switch (s) {
+| 8
+| 9 };
+| 10 }
+| 11
+| 12 // note.x see below
+| 13
+|
+|
+|
+|
+|
+== file buffer
+const Shade = enum {
+ verdigris,
+ cinnabar,
+};
+
+fn pick(s: Shade) u8 {
+ return switch (s) {
+
+ };
+}
+
+// note.x see below
+
+== end file
diff --git a/test/snapshots/lspcompletemoved.snap b/test/snapshots/lspcompletemoved.snap
new file mode 100644
index 00000000..5f20b806
--- /dev/null
+++ b/test/snapshots/lspcompletemoved.snap
@@ -0,0 +1,33 @@
+# The guard that makes "an empty answer indents" safe to have at all.
+#
+# lspcomplete.snap pins the indent itself: Tab after a dot the backend cannot
+# answer diverts, the answer comes back empty, and four spaces go in LATE so
+# the keystroke is not eaten. That late write is the dangerous half — it lands
+# a few hundred milliseconds after the key, by which time the hands have moved
+# on — so lspResponse only performs it if nothing has: same pane, still insert,
+# one cursor, cursor still on the cell the Tab was pressed at. Nothing covered
+# the condition, only its happy side.
+#
+# Same file and same click as lspcomplete's `commentindent` arm — line 12 is a
+# comment, so no candidate can exist and the answer is certainly empty — but
+# here an `x` is typed straight after the Tab, in the same write, long before a
+# ZLS analysis can finish. The buffer below is the assertion: `// note.x`, with
+# neither `// note. x` (the indent landing under the typing) nor
+# `// note.x ` (the indent landing on top of it).
+file a.zig const Shade = enum {\n verdigris,\n cinnabar,\n};\n\nfn pick(s: Shade) u8 {\n return switch (s) {\n \n };\n}\n\n// note. see below\n
+start 20 120 a.zig
+wait 8000 verdigris
+stable 700 20000
+press left 16 14
+release left 16 14
+stable 400 5000
+key i
+key tab
+text x
+stable 700 20000
+snap typedthrough
+key esc
+settle 100
+key space f s
+stable 700 15000
+snapfile buffer /tmp/pardes-snap/lspcompletemoved/cwd/a.zig
diff --git a/test/snapshots/nested-optout.golden b/test/snapshots/nested-optout.golden
new file mode 100644
index 00000000..7fc443df
--- /dev/null
+++ b/test/snapshots/nested-optout.golden
@@ -0,0 +1,93 @@
+== snap optout-file grid=100x30 cursor=9,4
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|$ /tmp/pardes-snap/nested-optout/cwd New Del
+| New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/nested-optout/cwd/nested.txt Save New Del
+| 1 optout-marker
+| 2 second-optout-line
+| 3
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap inner-shell grid=100x30 cursor=6,4
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|$ /tmp/pardes-snap/nested-optout/cwd New Del
+| New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| $ /tmp/pardes-snap/nested-optout/cwd/innerdir New Del
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap inner-launch grid=100x30 cursor=11,6
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|$ /tmp/pardes-snap/nested-optout/cwd New Del
+| New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| $ /tmp/pardes-snap/nested-optout/cwd/innerdir New Del
+| New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/nested-optout/cwd/innerdir/deeper.txt Save New Del
+| 1 deepest-marker
+| 2
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/nested-optout.snap b/test/snapshots/nested-optout.snap
new file mode 100644
index 00000000..27e1ba3d
--- /dev/null
+++ b/test/snapshots/nested-optout.snap
@@ -0,0 +1,52 @@
+# `--nested` is the documented escape hatch out of the hand-the-file-outward
+# mechanism, and nested.snap covers only the mechanism. The flag says two
+# things at once and both are asserted here: an instance carrying it keeps its
+# own argument instead of forwarding it, AND it is not an outer instance for
+# anything launched inside IT (tty.zig binds no socket when opts.nested).
+#
+# The same trick nested.snap uses to get a genuine descendant: a pane shell's
+# parent IS the app under test, so `$(readlink /proc/$PPID/exe)` in a pane is
+# this exact binary. What discriminates is the GRID — a second topbar drawn
+# INSIDE the pane, with the outer instance still one shell wide.
+#
+# Long waits on purpose: each arm forks and execs whole further pardes.
+file nested.txt optout-marker\nsecond-optout-line\n
+dirmk innerdir
+file innerdir/deeper.txt deepest-marker\n
+# --- a file argument, WITH the flag: the inner keeps it ---
+# Without --nested this is nested.snap's first arm and the outer grows a
+# file pane beside the shell. Here the outer's layout never changes: one
+# shell, and a whole second pardes drawn inside it holding nested.txt.
+start 30 100
+wait 8000 $
+stable 700 20000
+text $(readlink /proc/$PPID/exe) --nested nested.txt
+key enter
+wait 20000 optout-marker
+stable 700 20000
+snap optout-file
+# --- ...and it is not an outer instance either ---
+# The subshell is what makes the wait unambiguous: the inner boots in
+# `innerdir` while the outer pane's own shell stays in cwd, so `cwd/innerdir`
+# on the grid can only be the inner instance's tagline. --nested with no
+# argument is still a bare launch, so the inner comes up in tty mode and
+# keystrokes travel through both layers to its shell.
+start 30 100
+wait 8000 $
+stable 700 20000
+text (cd innerdir && $(readlink /proc/$PPID/exe) --nested)
+key enter
+wait 20000 cwd/innerdir
+stable 700 20000
+snap inner-shell
+# A third pardes, launched from inside the inner one. Its nearest pardes
+# ancestor is the INNER instance, which bound no socket, so the send fails and
+# it opens its own session where it stands — three topbars. The assertion is
+# the row it did NOT appear on: the outer instance is still one shell pane, and
+# a walk that had skipped past the opted-out instance would have put
+# deeper.txt up there beside it.
+text $(readlink /proc/$PPID/exe) deeper.txt
+key enter
+wait 20000 deepest-marker
+stable 700 20000
+snap inner-launch
diff --git a/test/snapshots/tagalign2col.golden b/test/snapshots/tagalign2col.golden
new file mode 100644
index 00000000..d5d22935
--- /dev/null
+++ b/test/snapshots/tagalign2col.golden
@@ -0,0 +1,93 @@
+== snap onecol grid=160x30 cursor=7,2
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/tagalign2col/cwd/deep/proj/document.txt Save New Del /tmp/pardes-snap/tagalign2col/cwd New Del
+| 1 alpha
+| 2 beta deep
+| 3 gamma
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap aligned grid=160x30 cursor=4,19
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/tagalign2col/cwd/deep/proj/document.txt Save New Del /tmp/pardes-snap/tagalign2col/cwd New Del
+| 1 alpha
+| 2 beta deep
+| 3 gamma
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagalign2col/cwd New Del
+|
+| deep
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap widened grid=160x30 cursor=4,19
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/tagalign2col/cwd/deep/proj/document.txt Save New Del ╎ /tmp/pardes-snap/tagalign2col/cwd New Del
+| 1 alpha ╎
+| 2 beta ╎ deep
+| 3 gamma ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| /tmp/pardes-snap/tagalign2col/cwd New Del ╎
+| ╎
+| deep ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
diff --git a/test/snapshots/tagalign2col.snap b/test/snapshots/tagalign2col.snap
new file mode 100644
index 00000000..02add6fd
--- /dev/null
+++ b/test/snapshots/tagalign2col.snap
@@ -0,0 +1,45 @@
+# The shared tail-end is scoped to a LAYOUT COLUMN. tagalign.snap proves the
+# sharing but only ever has one column, so nothing there would notice a vote
+# that leaked sideways: every pane in the window would still line up, and the
+# feature would look like it worked right up until you opened a second column.
+#
+# Here the long path is in the LEFT column (that is which side it lands on:
+# `SPC c n` puts the NEW column on the right, so the boot file keeps the left)
+# and the right column is a plain shell. Both columns are 80 wide, so both
+# would end their words at tag_right_pad — 58 columns in — if nothing pushed.
+dirmk deep
+dirmk deep/proj
+file deep/proj/document.txt alpha\nbeta\ngamma
+start 30 160 deep/proj/document.txt
+wait 8000 New Newcol
+wait 8000 document.txt
+stable 700 20000
+# `SPC c n`: a shell in a column of its own on the right. Two columns, one pane
+# each. The file's own tag is 69 columns and so pushes past its column's pad;
+# the shell on the right sits AT its pad, 58 in. Read the two ends off this
+# snap — they are what the next one must not disturb.
+key space c n
+stable 700 15000
+key space w h
+stable 400 5000
+snap onecol
+# Alt-n: a second pane in the LEFT column. It is 41 columns of tag and would
+# right-align at 58 on its own; the file above it drags it out to 69 instead.
+# THE ASSERTION: the right column's `New Del` has not moved a cell — same
+# absolute column as in `onecol`, still at its own pad — while the left
+# column's two rows now agree with each other at 69.
+key a-n
+stable 700 15000
+snap aligned
+# ...and the ends are computed per column, not once for the window. Drag the
+# vertical border 20 columns right: the left column grows to 100 (tw-pad 78 is
+# now past the file's 69, so its pad binds again and both left rows move OUT to
+# 78) and the right column shrinks to 60 (tw-pad 38 is now short of the shell's
+# own 41, so its words fall back hard against the path at 41). Two different
+# rules firing in the same frame, one per column — a single window-wide end
+# could not produce this grid at all.
+press left 80 10
+drag left 100 10
+release left 100 10
+stable 700 15000
+snap widened
diff --git a/test/snapshots/tagbottom.golden b/test/snapshots/tagbottom.golden
index cc402fd1..27c6d42a 100644
--- a/test/snapshots/tagbottom.golden
+++ b/test/snapshots/tagbottom.golden
@@ -246,68 +246,6 @@
|
|
| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
-== snap wheel-body grid=100x30 cursor=9,1
-|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| 5 eee
-| 6 fff
-| 7 ggg
-| 8 hhh
-| 9 iii
-| 10 jjj
-| 11 kkk
-| 12 lll
-| 13 mmm
-| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
-| 1 ZZONE
-| 2 ZZTWO
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
-== snap wheel-gutter grid=100x30 cursor=9,2
-|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
-| 4 ddd
-| 5 eee
-| 6 fff
-| 7 ggg
-| 8 hhh
-| 9 iii
-| 10 jjj
-| 11 kkk
-| 12 lll
-| /tmp/pardes-snap/tagbottom/cwd/top.txt bottom.txt Save New Del
-| 1 ZZONE
-| 2 ZZTWO
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-|
-| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
== snap gutter-click grid=100x30 cursor=9,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| 2 bbb
@@ -401,37 +339,6 @@
|
|
| /tmp/pardes-snap/tagbottom/cwd/bottom.txt Save New Del
-== style sweep-down
-|0: 0-56 #949494,#121212, 57-99 d,#121212,
-|1: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-9 #bdbdbd,#080808, 10-99 d,#080808,
-|2: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-9 #bdbdbd,#080808, 10-99 d,#080808,
-|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
-|4: 0-0 d,#121212, 1-1 d,#080808, 2-6 #949494,#121212, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
-|5: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
-|6: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
-|7: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
-|8: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
-|9: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-7 #080808,#9399f9, 8-9 #bdbdbd,#080808, 10-99 d,#080808,
-|10: 0-1 #ffffff,#626262, 2-90 #949494,#121212, 91-99 d,#121212,
-|11: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-11 #bdbdbd,#080808, 12-99 d,#080808,
-|12: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-11 #bdbdbd,#080808, 12-99 d,#080808,
-|13: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|14: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|15: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|16: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|17: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|19: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|20: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|21: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|22: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|23: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|24: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|25: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|26: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|27: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|28: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808,
-|29: 0-1 #ffffff,#262626, 2-79 #949494,#121212, 80-99 d,#121212,
== snap cut-down grid=100x30 cursor=7,4
|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
| 1 aaa
diff --git a/test/snapshots/tagbottom.snap b/test/snapshots/tagbottom.snap
index 801df1a8..389e0c36 100644
--- a/test/snapshots/tagbottom.snap
+++ b/test/snapshots/tagbottom.snap
@@ -76,20 +76,17 @@ release left 40 12
motion 40 25
stable 500 8000
snap seam-dragged
-# the wheel over the BODY of the upper pane (nine body rows now): four rows
-# down, so the view starts at line 5 and the pinned cursor rides up to the
-# pane's first row
+# Scroll the upper pane's body down four rows and back up one, so the gutter
+# click below has a scrolled view to count from. Not captured: the wheel arm of
+# handleMouse is `hovered` plus scrollBy and contains no tag_bottom at all, and
+# the body rendering under it is already pinned by `boot`, `on` and
+# `body-cursor`. View ends at line 4.
wheel down 40 4
wheel down 40 4
wheel down 40 4
wheel down 40 4
-stable 400 8000
-snap wheel-body
-# ...and one row back up with the wheel over the GUTTER: the same pane, found
-# through its gutter column. View at line 4.
wheel up 1 4
stable 400 8000
-snap wheel-gutter
# a left click in the gutter scrolls UP to the clicked row, and the row it
# counts from is the BODY's first, not r.y + BOX_H. Row 4 is grid row 3, the
# third body row: two rows up, to line 2. Counted from the wrong end it would
@@ -129,7 +126,6 @@ stable 400 5000
press left 8 4
drag left 8 11
stable 400 5000
-snapstyle sweep-down
# ...and the proof is the BUFFER, not the highlight: 1-2 Cut the sweep, save,
# and read the file back. aaa/bbb/ccc survive and kkk..zzz survive; unclamped
# this file lost bbb/ccc/ddd instead and kept eee onward.
diff --git a/test/snapshots/tagbottomimage.golden b/test/snapshots/tagbottomimage.golden
new file mode 100644
index 00000000..5508d01a
--- /dev/null
+++ b/test/snapshots/tagbottomimage.golden
@@ -0,0 +1,155 @@
+== snap img-top grid=150x30 cursor=11,7
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| img /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm New Del /tmp/pardes-snap/tagbottomimage/cwd New Del
+| (f🬱]0V7R*%m🬶🬁G🬬tN#XR9q🬢tC#f/🬜_GR(🬞🬙tCRp$o*7^+[#H7s🬢YDZ🬝▟
+| iU)dSotT[r27T"\*Fh▐🬅g,🬰p🬛▙🬲&*3/9xy🬛▀0kCg▛🬱C,>C}PC06m🬝▟█ pic.ppm
+| "S▄;$gs,Xa▟T🬔s▖┼W▘?by🬋🬭uCR3/A3🬶lO▖▞🬃🬘╳🬟#=🬺🬕yoaJ@>4🬝🬵
+| ─m!fAAD🬒jW>7JkR▄▜🬫🬞🬢FWx╲▟2ZT🬥/▖jteR?q🬶^🬅▀_@N─u4🬹🬝🬹
+| E72z🬧🬒oy🬧e▌ijY╲&🬅mbh╲h$🬘🬴PS9y▙0H🬳╲🬂&🬣╲,🬃,R;█AZ🬝▟
+| <JBpn-S┼zq^🬄677grnk🬛bMd2|VB▙┼'🬎f/{X7🬮jO^]Y─🬄🬎🬻█ pic.ppm
+| 🬊3🬶🬛As0╲─Wd$╳🬤vRnfXIVH&)C+frE2FJZ🬃e🬊W🬔@▜@s🬝🬻█
+| ▛▛aRawF│▌o<╲🬪dGk/gJ🬴🬍🬧mYbR▀LZx🬒%t'sUX:p🬌🬝🬹
+| ─"n_&mht┼A0j]p┼🬲/B%c$1Ln;🬗s*▟j!🬥q2U#🬙&#~
+| 🬖🬈p"│vctj'&^X─C*🬍🬆@&F,7{h`🬷mg▜b╱e%@V🬝🬹█
+| J5▝$8),x&🬩i🬰~D🬚v(>kz5zXa92Q▖` i6Ax🬝🬵
+| z[I🬮╲🬸&j;▟▛eY🬉L&u?🬫gc▝🬍5🬉Q🬪s2V🬗🬡🬝🬹█
+| XUq🬨[╱🬒Zb<"#.$1Zby╳Fk:y🬠🬷z\txn🬝▟
+| r🬗│G"🬵8🬘j=F🬐Z🬫@u▛╲r🬖$🬥%┼6f&>🬝🬹█
+| ~{🬫$blt🬘=dxu",🬣t🬣=y🬌H3>\🬎A🬝🬻
+| k🬴5L🬹🬢u>-!🬊Ga▟╱🬈D🬉╳wEZ'g🬎▄
+| y'5K%p?:╳🬤▘/fZ╳>jwbv0M🬝▟
+| %C🬢▄🬍▗I🬬G▄🬡w~Hp!#"Q▝▛▄█
+| 🬯[▜?To5@q🬗gmJ.w🬉~R🬝▟█
+| y🬗🬞+*X7eJ🬮`X}ZO🬟🬝▟█
+| ╱^🬷pc[a]juQ[🬷$🬝🬻
+| g6▗>🬌7O▞3🬤b─🬝▟
+| YVQ8Q╳NF🬝▐🬝🬹█
+| 🬯│@/]4%^🬆~█
+| eP🬗<┼A🬝🬹█
+| ▀(u🬬🬝▟
+| fc🬎🬹█
+| 🬝🬹
+== snap img-bottom grid=150x30 cursor=11,7
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| (f🬱]0V7R*%m🬶🬁G🬬tN#XR9q🬢tC#f/🬜_GR(🬞🬙tCRp$o*7^+[#H7s🬢YDZ🬝▟
+| iU)dSotT[r27T"\*Fh▐🬅g,🬰p🬛▙🬲&*3/9xy🬛▀0kCg▛🬱C,>C}PC06m🬝▟█ pic.ppm
+| "S▄;$gs,Xa▟T🬔s▖┼W▘?by🬋🬭uCR3/A3🬶lO▖▞🬃🬘╳🬟#=🬺🬕yoaJ@>4🬝🬵
+| ─m!fAAD🬒jW>7JkR▄▜🬫🬞🬢FWx╲▟2ZT🬥/▖jteR?q🬶^🬅▀_@N─u4🬹🬝🬹
+| E72z🬧🬒oy🬧e▌ijY╲&🬅mbh╲h$🬘🬴PS9y▙0H🬳╲🬂&🬣╲,🬃,R;█AZ🬝▟
+| <JBpn-S┼zq^🬄677grnk🬛bMd2|VB▙┼'🬎f/{X7🬮jO^]Y─🬄🬎🬻█ pic.ppm
+| 🬊3🬶🬛As0╲─Wd$╳🬤vRnfXIVH&)C+frE2FJZ🬃e🬊W🬔@▜@s🬝🬻█
+| ▛▛aRawF│▌o<╲🬪dGk/gJ🬴🬍🬧mYbR▀LZx🬒%t'sUX:p🬌🬝🬹
+| ─"n_&mht┼A0j]p┼🬲/B%c$1Ln;🬗s*▟j!🬥q2U#🬙&#~
+| 🬖🬈p"│vctj'&^X─C*🬍🬆@&F,7{h`🬷mg▜b╱e%@V🬝🬹█
+| J5▝$8),x&🬩i🬰~D🬚v(>kz5zXa92Q▖` i6Ax🬝🬵
+| z[I🬮╲🬸&j;▟▛eY🬉L&u?🬫gc▝🬍5🬉Q🬪s2V🬗🬡🬝🬹█
+| XUq🬨[╱🬒Zb<"#.$1Zby╳Fk:y🬠🬷z\txn🬝▟
+| r🬗│G"🬵8🬘j=F🬐Z🬫@u▛╲r🬖$🬥%┼6f&>🬝🬹█
+| ~{🬫$blt🬘=dxu",🬣t🬣=y🬌H3>\🬎A🬝🬻
+| k🬴5L🬹🬢u>-!🬊Ga▟╱🬈D🬉╳wEZ'g🬎▄
+| y'5K%p?:╳🬤▘/fZ╳>jwbv0M🬝▟
+| %C🬢▄🬍▗I🬬G▄🬡w~Hp!#"Q▝▛▄█
+| 🬯[▜?To5@q🬗gmJ.w🬉~R🬝▟█
+| y🬗🬞+*X7eJ🬮`X}ZO🬟🬝▟█
+| ╱^🬷pc[a]juQ[🬷$🬝🬻
+| g6▗>🬌7O▞3🬤b─🬝▟
+| YVQ8Q╳NF🬝▐🬝🬹█
+| 🬯│@/]4%^🬆~█
+| eP🬗<┼A🬝🬹█
+| ▀(u🬬🬝▟
+| fc🬎🬹█
+| 🬝🬹
+| img /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm New Del /tmp/pardes-snap/tagbottomimage/cwd New Del
+== style img-bottom
+|0: 0-56 #949494,#121212, 57-149 d,#121212,
+|1: 0-0 d,#121212, 1-9 d,#080808, 10-10 #959595,#6c6c6c, 11-11 #6c6c6c,#959595, 12-12 #959595,#6c6c6c, 13-13 #6c6c6c,#959595, 14-24 #959595,#6c6c6c, 25-27 #6c6c6c,#959595, 28-28 #959595,#6c6c6c, 29-30 #6c6c6c,#959595, 31-32 #959595,#6c6c6c, 33-34 #6c6c6c,#959595, 35-35 #959595,#6c6c6c, 36-37 #6c6c6c,#959595, 38-39 #959595,#6c6c6c, 40-40 #6c6c6c,#959595, 41-41 #959595,#6c6c6c, 42-42 #6c6c6c,#959595, 43-44 #959595,#6c6c6c, 45-47 #6c6c6c,#959595, 48-50 #959595,#6c6c6c, 51-51 #6c6c6c,#959595, 52-52 #959595,#6c6c6c, 53-53 #6c6c6c,#959595, 54-63 #959595,#6c6c6c, 64-64 #959595,#352879, 65-65 #352879,#6c6c6c, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|2: 0-0 d,#121212, 1-9 d,#080808, 10-10 #959595,#6c6c6c, 11-12 #6c6c6c,#959595, 13-13 #959595,#6c6c6c, 14-15 #6c6c6c,#959595, 16-18 #959595,#6c6c6c, 19-19 #6c6c6c,#959595, 20-20 #959595,#6c6c6c, 21-21 #6c6c6c,#959595, 22-32 #959595,#6c6c6c, 33-33 #6c6c6c,#959595, 34-37 #959595,#6c6c6c, 38-39 #6c6c6c,#959595, 40-44 #959595,#6c6c6c, 45-45 #6c6c6c,#959595, 46-47 #959595,#6c6c6c, 48-48 #6c6c6c,#959595, 49-52 #959595,#6c6c6c, 53-53 #444444,#959595, 54-54 #959595,#6c6c6c, 55-55 #6c6c6c,#959595, 56-58 #959595,#6c6c6c, 59-59 #6c6c6c,#959595, 60-60 #959595,#6c6c6c, 61-61 #6c6c6c,#959595, 62-62 #959595,#352879, 63-63 #352879,#959595, 64-64 #352879,#6c5eb5, 65-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|3: 0-0 d,#121212, 1-9 d,#080808, 10-10 #6c6c6c,#959595, 11-13 #959595,#6c6c6c, 14-16 #6c6c6c,#959595, 17-17 #444444,#959595, 18-22 #959595,#6c6c6c, 23-23 #6c6c6c,#959595, 24-26 #959595,#6c6c6c, 27-27 #6c6c6c,#959595, 28-28 #959595,#6c6c6c, 29-30 #6c6c6c,#959595, 31-32 #959595,#6c6c6c, 33-33 #6c6c6c,#959595, 34-35 #959595,#6c6c6c, 36-36 #6c6c6c,#959595, 37-37 #444444,#959595, 38-38 #959595,#6c6c6c, 39-39 #6c6c6c,#959595, 40-43 #959595,#6c6c6c, 44-44 #6c6c6c,#959595, 45-48 #959595,#6c6c6c, 49-51 #6c6c6c,#959595, 52-52 #959595,#6c6c6c, 53-53 #6c6c6c,#959595, 54-55 #959595,#6c6c6c, 56-58 #6c6c6c,#959595, 59-59 #959595,#6c6c6c, 60-60 #6c6c6c,#352879, 61-61 #352879,#6c5eb5, 62-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|4: 0-0 d,#121212, 1-9 d,#080808, 10-10 #959595,#6c6c6c, 11-11 #6c6c6c,#959595, 12-17 #959595,#6c6c6c, 18-18 #6c6c6c,#959595, 19-19 #959595,#6c6c6c, 20-22 #6c6c6c,#959595, 23-26 #959595,#6c6c6c, 27-27 #6c6c6c,#959595, 28-29 #959595,#6c6c6c, 30-33 #6c6c6c,#959595, 34-35 #959595,#6c6c6c, 36-37 #6c6c6c,#959595, 38-40 #959595,#6c6c6c, 41-41 #6c6c6c,#959595, 42-42 #959595,#6c6c6c, 43-43 #6c6c6c,#959595, 44-44 #959595,#6c6c6c, 45-46 #6c6c6c,#959595, 47-47 #959595,#6c6c6c, 48-48 #6c6c6c,#959595, 49-49 #959595,#6c6c6c, 50-50 #6c6c6c,#959595, 51-51 #444444,#959595, 52-56 #959595,#6c6c6c, 57-57 #6c6c6c,#959595, 58-58 #959595,#352879, 59-59 #352879,#959595, 60-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|5: 0-0 d,#121212, 1-9 d,#080808, 10-11 #959595,#6c6c6c, 12-13 #6c6c6c,#959595, 14-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-19 #959595,#6c6c6c, 20-20 #6c6c6c,#959595, 21-21 #959595,#6c6c6c, 22-22 #6c6c6c,#959595, 23-24 #959595,#6c6c6c, 25-25 #6c6c6c,#959595, 26-27 #959595,#6c6c6c, 28-32 #6c6c6c,#959595, 33-40 #959595,#6c6c6c, 41-41 #6c6c6c,#959595, 42-53 #959595,#6c6c6c, 54-54 #6c6c6c,#959595, 55-55 #959595,#6c6c6c, 56-56 #959595,#352879, 57-57 #352879,#6c6c6c, 58-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|6: 0-0 d,#121212, 1-9 d,#080808, 10-13 #6c6c6c,#959595, 14-15 #959595,#6c6c6c, 16-16 #6c6c6c,#959595, 17-18 #959595,#6c6c6c, 19-19 #6c6c6c,#959595, 20-23 #959595,#6c6c6c, 24-24 #6c6c6c,#959595, 25-26 #959595,#6c6c6c, 27-28 #6c6c6c,#959595, 29-30 #959595,#6c6c6c, 31-31 #6c6c6c,#959595, 32-35 #959595,#6c6c6c, 36-38 #6c6c6c,#959595, 39-40 #959595,#6c6c6c, 41-41 #6c6c6c,#959595, 42-42 #959595,#6c6c6c, 43-43 #6c6c6c,#959595, 44-48 #959595,#6c6c6c, 49-49 #444444,#959595, 50-50 #959595,#6c6c6c, 51-51 #6c6c6c,#959595, 52-53 #959595,#6c6c6c, 54-54 #959595,#352879, 55-55 #352879,#444444, 56-56 #352879,#6c5eb5, 57-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-85 #bdbdbd,#080808, 86-149 d,#080808,
+|7: 0-0 d,#121212, 1-9 d,#080808, 10-15 #959595,#6c6c6c, 16-21 #6c6c6c,#959595, 22-25 #959595,#6c6c6c, 26-26 #6c6c6c,#959595, 27-29 #959595,#6c6c6c, 30-30 #6c6c6c,#959595, 31-31 #959595,#6c6c6c, 32-34 #6c6c6c,#959595, 35-38 #959595,#6c6c6c, 39-39 #6c6c6c,#959595, 40-45 #959595,#6c6c6c, 46-48 #6c6c6c,#959595, 49-49 #959595,#6c6c6c, 50-51 #6c6c6c,#959595, 52-52 #6c6c6c,#352879, 53-53 #352879,#6c6c6c, 54-54 #352879,#6c5eb5, 55-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|8: 0-0 d,#121212, 1-9 d,#080808, 10-10 #959595,#6c6c6c, 11-11 #6c6c6c,#959595, 12-13 #959595,#6c6c6c, 14-14 #6c6c6c,#959595, 15-15 #959595,#6c6c6c, 16-16 #6c6c6c,#959595, 17-17 #959595,#6c6c6c, 18-20 #6c6c6c,#959595, 21-24 #959595,#6c6c6c, 25-25 #6c6c6c,#959595, 26-26 #959595,#6c6c6c, 27-27 #6c6c6c,#959595, 28-28 #959595,#6c6c6c, 29-29 #6c6c6c,#959595, 30-34 #959595,#6c6c6c, 35-35 #6c6c6c,#959595, 36-36 #959595,#6c6c6c, 37-37 #6c6c6c,#959595, 38-40 #959595,#6c6c6c, 41-41 #6c6c6c,#959595, 42-42 #959595,#6c6c6c, 43-43 #444444,#959595, 44-44 #959595,#6c6c6c, 45-45 #6c6c6c,#959595, 46-46 #959595,#6c6c6c, 47-47 #444444,#959595, 48-48 #6c6c6c,#959595, 49-49 #959595,#6c6c6c, 50-50 #6c6c6c,#352879, 51-51 #352879,#6c5eb5, 52-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|9: 0-0 d,#121212, 1-9 d,#080808, 10-10 #6c6c6c,#959595, 11-13 #959595,#6c6c6c, 14-14 #6c6c6c,#959595, 15-15 #959595,#6c6c6c, 16-16 #6c6c6c,#959595, 17-18 #959595,#6c6c6c, 19-19 #6c6c6c,#959595, 20-20 #444444,#959595, 21-24 #959595,#6c6c6c, 25-25 #6c6c6c,#959595, 26-26 #959595,#6c6c6c, 27-30 #6c6c6c,#959595, 31-35 #959595,#6c6c6c, 36-37 #6c6c6c,#959595, 38-38 #959595,#6c6c6c, 39-40 #6c6c6c,#959595, 41-44 #959595,#6c6c6c, 45-45 #6c6c6c,#959595, 46-47 #959595,#6c6c6c, 48-49 #959595,#352879, 50-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|10: 0-0 d,#121212, 1-9 d,#080808, 10-13 #959595,#6c6c6c, 14-14 #6c6c6c,#959595, 15-17 #959595,#6c6c6c, 18-18 #6c6c6c,#959595, 19-20 #959595,#6c6c6c, 21-21 #6c6c6c,#959595, 22-23 #959595,#6c6c6c, 24-24 #6c6c6c,#959595, 25-30 #959595,#6c6c6c, 31-34 #6c6c6c,#959595, 35-36 #959595,#6c6c6c, 37-37 #6c6c6c,#959595, 38-38 #959595,#6c6c6c, 39-40 #6c6c6c,#959595, 41-41 #444444,#959595, 42-42 #959595,#6c6c6c, 43-45 #6c6c6c,#959595, 46-46 #6c6c6c,#352879, 47-47 #352879,#6c6c6c, 48-48 #352879,#6c5eb5, 49-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|11: 0-0 d,#121212, 1-9 d,#080808, 10-11 #6c6c6c,#959595, 12-13 #959595,#6c6c6c, 14-15 #6c6c6c,#959595, 16-16 #959595,#6c6c6c, 17-20 #6c6c6c,#959595, 21-21 #959595,#6c6c6c, 22-22 #6c6c6c,#959595, 23-23 #959595,#6c6c6c, 24-24 #6c6c6c,#959595, 25-25 #959595,#6c6c6c, 26-26 #6c6c6c,#959595, 27-28 #959595,#6c6c6c, 29-29 #6c6c6c,#959595, 30-31 #959595,#6c6c6c, 32-33 #6c6c6c,#959595, 34-35 #959595,#6c6c6c, 36-36 #6c6c6c,#959595, 37-38 #959595,#6c6c6c, 39-39 #444444,#6c6c6c, 40-40 #6c6c6c,#959595, 41-41 #959595,#6c6c6c, 42-42 #6c6c6c,#959595, 43-43 #959595,#6c6c6c, 44-44 #959595,#352879, 45-45 #352879,#6c5eb5, 46-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|12: 0-0 d,#121212, 1-9 d,#080808, 10-10 #6c6c6c,#959595, 11-13 #959595,#6c6c6c, 14-14 #444444,#6c6c6c, 15-24 #959595,#6c6c6c, 25-25 #6c6c6c,#959595, 26-31 #959595,#6c6c6c, 32-39 #6c6c6c,#959595, 40-41 #959595,#6c6c6c, 42-42 #6c6c6c,#352879, 43-43 #352879,#959595, 44-44 #352879,#6c5eb5, 45-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|13: 0-0 d,#121212, 1-9 d,#080808, 10-10 #959595,#6c6c6c, 11-11 #6c6c6c,#959595, 12-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-20 #959595,#6c6c6c, 21-21 #6c6c6c,#959595, 22-23 #959595,#6c6c6c, 24-27 #6c6c6c,#959595, 28-29 #959595,#6c6c6c, 30-30 #6c6c6c,#959595, 31-31 #959595,#6c6c6c, 32-32 #6c6c6c,#959595, 33-35 #959595,#6c6c6c, 36-38 #6c6c6c,#959595, 39-39 #959595,#6c6c6c, 40-40 #6c6c6c,#352879, 41-41 #352879,#6c6c6c, 42-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|14: 0-0 d,#121212, 1-9 d,#080808, 10-10 #6c6c6c,#959595, 11-15 #959595,#6c6c6c, 16-16 #6c6c6c,#959595, 17-21 #959595,#6c6c6c, 22-22 #6c6c6c,#959595, 23-23 #959595,#6c6c6c, 24-25 #6c6c6c,#959595, 26-26 #959595,#6c6c6c, 27-27 #444444,#959595, 28-29 #6c6c6c,#959595, 30-32 #959595,#6c6c6c, 33-33 #6c6c6c,#959595, 34-34 #959595,#6c6c6c, 35-35 #6c6c6c,#959595, 36-36 #959595,#6c6c6c, 37-37 #6c6c6c,#959595, 38-38 #6c6c6c,#352879, 39-39 #352879,#959595, 40-40 #352879,#6c5eb5, 41-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|15: 0-0 d,#121212, 1-9 d,#080808, 10-10 #6c6c6c,#959595, 11-13 #959595,#6c6c6c, 14-14 #6c6c6c,#959595, 15-20 #959595,#6c6c6c, 21-21 #6c6c6c,#959595, 22-24 #959595,#6c6c6c, 25-25 #6c6c6c,#959595, 26-27 #959595,#6c6c6c, 28-29 #6c6c6c,#959595, 30-31 #959595,#6c6c6c, 32-32 #6c6c6c,#959595, 33-33 #959595,#6c6c6c, 34-34 #6c6c6c,#959595, 35-35 #959595,#6c6c6c, 36-36 #6c6c6c,#352879, 37-37 #352879,#959595, 38-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|16: 0-0 d,#121212, 1-9 d,#080808, 10-11 #959595,#6c6c6c, 12-12 #6c6c6c,#959595, 13-15 #959595,#6c6c6c, 16-16 #6c6c6c,#959595, 17-17 #959595,#6c6c6c, 18-19 #6c6c6c,#959595, 20-21 #959595,#6c6c6c, 22-22 #6c6c6c,#959595, 23-29 #959595,#6c6c6c, 30-31 #6c6c6c,#959595, 32-32 #959595,#6c6c6c, 33-33 #6c6c6c,#959595, 34-34 #959595,#352879, 35-35 #352879,#6c5eb5, 36-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|17: 0-0 d,#121212, 1-9 d,#080808, 10-12 #959595,#6c6c6c, 13-15 #6c6c6c,#959595, 16-16 #959595,#6c6c6c, 17-17 #444444,#959595, 18-20 #959595,#6c6c6c, 21-21 #6c6c6c,#959595, 22-22 #959595,#6c6c6c, 23-23 #6c6c6c,#959595, 24-27 #959595,#6c6c6c, 28-28 #6c6c6c,#959595, 29-31 #959595,#6c6c6c, 32-32 #6c6c6c,#352879, 33-33 #352879,#6c6c6c, 34-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|18: 0-0 d,#121212, 1-9 d,#080808, 10-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-18 #959595,#6c6c6c, 19-19 #6c6c6c,#959595, 20-20 #959595,#6c6c6c, 21-21 #6c6c6c,#959595, 22-23 #959595,#6c6c6c, 24-26 #6c6c6c,#959595, 27-27 #959595,#6c6c6c, 28-28 #6c6c6c,#959595, 29-29 #959595,#6c6c6c, 30-30 #959595,#352879, 31-32 #352879,#6c5eb5, 33-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|19: 0-0 d,#121212, 1-9 d,#080808, 10-11 #959595,#6c6c6c, 12-12 #6c6c6c,#959595, 13-15 #959595,#6c6c6c, 16-17 #6c6c6c,#959595, 18-19 #959595,#6c6c6c, 20-21 #6c6c6c,#959595, 22-22 #959595,#6c6c6c, 23-24 #6c6c6c,#959595, 25-26 #959595,#6c6c6c, 27-27 #6c6c6c,#959595, 28-28 #6c6c6c,#352879, 29-29 #352879,#6c6c6c, 30-30 #352879,#6c5eb5, 31-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|20: 0-0 d,#121212, 1-9 d,#080808, 10-14 #959595,#6c6c6c, 15-15 #6c6c6c,#959595, 16-17 #959595,#6c6c6c, 18-18 #6c6c6c,#959595, 19-19 #959595,#6c6c6c, 20-20 #6c6c6c,#959595, 21-21 #959595,#6c6c6c, 22-22 #6c6c6c,#959595, 23-25 #959595,#6c6c6c, 26-26 #6c6c6c,#352879, 27-27 #352879,#6c6c6c, 28-28 #352879,#6c5eb5, 29-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|21: 0-0 d,#121212, 1-9 d,#080808, 10-12 #6c6c6c,#959595, 13-14 #959595,#6c6c6c, 15-16 #6c6c6c,#959595, 17-20 #959595,#6c6c6c, 21-21 #6c6c6c,#959595, 22-22 #959595,#6c6c6c, 23-23 #6c6c6c,#959595, 24-24 #6c6c6c,#352879, 25-25 #352879,#959595, 26-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|22: 0-0 d,#121212, 1-9 d,#080808, 10-10 #959595,#6c6c6c, 11-11 #6c6c6c,#959595, 12-21 #959595,#6c6c6c, 22-22 #6c6c6c,#352879, 23-23 #352879,#6c6c6c, 24-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|23: 0-0 d,#121212, 1-9 d,#080808, 10-11 #6c6c6c,#959595, 12-13 #959595,#6c6c6c, 14-14 #6c6c6c,#959595, 15-15 #959595,#6c6c6c, 16-17 #6c6c6c,#959595, 18-19 #959595,#6c6c6c, 20-20 #6c6c6c,#352879, 21-21 #352879,#959595, 22-22 #352879,#6c5eb5, 23-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|24: 0-0 d,#121212, 1-9 d,#080808, 10-11 #959595,#6c6c6c, 12-13 #6c6c6c,#959595, 14-16 #959595,#6c6c6c, 17-17 #6c6c6c,#959595, 18-19 #959595,#352879, 20-20 #352879,#6c5eb5, 21-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|25: 0-0 d,#121212, 1-9 d,#080808, 10-11 #6c6c6c,#959595, 12-15 #959595,#6c6c6c, 16-16 #959595,#352879, 17-17 #352879,#6c6c6c, 18-18 #352879,#6c5eb5, 19-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|26: 0-0 d,#121212, 1-9 d,#080808, 10-10 #6c6c6c,#959595, 11-12 #959595,#6c6c6c, 13-13 #959595,#444444, 14-14 #6c6c6c,#352879, 15-15 #352879,#959595, 16-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|27: 0-0 d,#121212, 1-9 d,#080808, 10-11 #6c6c6c,#959595, 12-12 #6c6c6c,#352879, 13-13 #352879,#6c6c6c, 14-14 #352879,#6c5eb5, 15-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|28: 0-0 d,#121212, 1-9 d,#080808, 10-10 #6c6c6c,#352879, 11-11 #352879,#6c6c6c, 12-65 #352879,#352879, 66-74 d,#080808, 75-75 d,#444444, 76-76 d,#080808, 77-149 #bdbdbd,#080808,
+|29: 0-1 #ffffff,#626262, 2-56 #949494,#121212, 57-74 d,#121212, 75-76 #ffffff,#262626, 77-129 #949494,#121212, 130-149 d,#121212,
+== snap search-prompt grid=150x30 cursor=6,1
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+| /ZZ
+| iU)dSotT[r27T"\*Fh▐🬅g,🬰p🬛▙🬲&*3/9xy🬛▀0kCg▛🬱C,>C}PC06m🬝▟█ pic.ppm
+| "S▄;$gs,Xa▟T🬔s▖┼W▘?by🬋🬭uCR3/A3🬶lO▖▞🬃🬘╳🬟#=🬺🬕yoaJ@>4🬝🬵
+| ─m!fAAD🬒jW>7JkR▄▜🬫🬞🬢FWx╲▟2ZT🬥/▖jteR?q🬶^🬅▀_@N─u4🬹🬝🬹
+| E72z🬧🬒oy🬧e▌ijY╲&🬅mbh╲h$🬘🬴PS9y▙0H🬳╲🬂&🬣╲,🬃,R;█AZ🬝▟
+| <JBpn-S┼zq^🬄677grnk🬛bMd2|VB▙┼'🬎f/{X7🬮jO^]Y─🬄🬎🬻█ pic.ppm
+| 🬊3🬶🬛As0╲─Wd$╳🬤vRnfXIVH&)C+frE2FJZ🬃e🬊W🬔@▜@s🬝🬻█
+| ▛▛aRawF│▌o<╲🬪dGk/gJ🬴🬍🬧mYbR▀LZx🬒%t'sUX:p🬌🬝🬹
+| ─"n_&mht┼A0j]p┼🬲/B%c$1Ln;🬗s*▟j!🬥q2U#🬙&#~
+| 🬖🬈p"│vctj'&^X─C*🬍🬆@&F,7{h`🬷mg▜b╱e%@V🬝🬹█
+| J5▝$8),x&🬩i🬰~D🬚v(>kz5zXa92Q▖` i6Ax🬝🬵
+| z[I🬮╲🬸&j;▟▛eY🬉L&u?🬫gc▝🬍5🬉Q🬪s2V🬗🬡🬝🬹█
+| XUq🬨[╱🬒Zb<"#.$1Zby╳Fk:y🬠🬷z\txn🬝▟
+| r🬗│G"🬵8🬘j=F🬐Z🬫@u▛╲r🬖$🬥%┼6f&>🬝🬹█
+| ~{🬫$blt🬘=dxu",🬣t🬣=y🬌H3>\🬎A🬝🬻
+| k🬴5L🬹🬢u>-!🬊Ga▟╱🬈D🬉╳wEZ'g🬎▄
+| y'5K%p?:╳🬤▘/fZ╳>jwbv0M🬝▟
+| %C🬢▄🬍▗I🬬G▄🬡w~Hp!#"Q▝▛▄█
+| 🬯[▜?To5@q🬗gmJ.w🬉~R🬝▟█
+| y🬗🬞+*X7eJ🬮`X}ZO🬟🬝▟█
+| ╱^🬷pc[a]juQ[🬷$🬝🬻
+| g6▗>🬌7O▞3🬤b─🬝▟
+| YVQ8Q╳NF🬝▐🬝🬹█
+| 🬯│@/]4%^🬆~█
+| eP🬗<┼A🬝🬹█
+| ▀(u🬬🬝▟
+| fc🬎🬹█
+| 🬝🬹
+| img /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm New Del /tmp/pardes-snap/tagbottomimage/cwd New Del
+== snap del-image grid=150x30 cursor=5,6
+|New Newcol Find Grep Help Tutor Dump NextColor Debug Kill
+|
+| pic.ppm
+|
+|
+|
+| pic.ppm
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/tagbottomimage/cwd New Del
diff --git a/test/snapshots/tagbottomimage.snap b/test/snapshots/tagbottomimage.snap
new file mode 100644
index 00000000..e46e19ba
--- /dev/null
+++ b/test/snapshots/tagbottomimage.snap
@@ -0,0 +1,52 @@
+# Tagbottom against the one pane kind whose renderPane RETURNS EARLY. An image
+# draws no body rows and no tagline of its own — the tag comes from the outer
+# loop and so does the transient message row — so it is exactly the pane where
+# "the tag moved" and "the message row moved with it" can come apart. Nothing
+# in tagbottom.snap touches one.
+ppmdiag pic.ppm
+start 30 150 -n 1
+wait 8000 New Newcol
+stable 700 20000
+# open it the way images.snap does: type the name into the shell pane's own
+# text and right-click it. The image lands in a column of its own on the left.
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text pic.ppm
+key esc
+settle 100
+press right 6 8
+release right 6 8
+wait 10000 img
+stable 700 20000
+snap img-top
+# SPC t b: the tag leaves row 1 for row 29 and the glyph art slides up into the
+# row it left. The style capture is the half a text grid cannot see — the
+# tagline's chrome colours have to travel with it, and a tag row drawn in the
+# palette of the cell art under it would read as part of the picture.
+key space t b
+stable 700 15000
+snap img-bottom
+snapstyle img-bottom
+# `/` on an image pane. There is no text to search, and that is the point: the
+# prompt is drawn by the message-row pass, which runs OUTSIDE renderPane
+# precisely so an early-returning pane still gets one. With Tagbottom it lands
+# on the pane's FIRST row (grid 1), the end away from the tag — over the top
+# row of the art, not over the tagline that is now beneath it.
+key /
+send ZZ
+stable 600 8000
+stable 400 5000
+snap search-prompt
+key esc
+settle 100
+stable 400 5000
+# ...and EXECUTE `Del` on that bottom tagline, which is the assertion the
+# pixels cannot make: the middle click has to map row 29 to the image pane's
+# TAG row rather than to a body row it has no body for. The image pane goes
+# away entirely and the shell takes the whole window back.
+press middle 56 30
+release middle 56 30
+stable 700 15000
+snap del-image