summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-14 13:40:29 -0300
committerGabriel Schneider <[email protected]>2026-08-15 11:57:13 -0300
commitf67fec978a9296c651ec06bd2f43686d34ff86ee (patch)
treecbf5568883e5888398e0887093fc5afc524fd54d /src/pardes.zig
parent9280c597b000eed661fd98793e182fdcb640f6cd (diff)
downloadpardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.tar.gz
pardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.zip
look: richer path/range parsing, pdf rendering, corner-drag and stepgrain snapshots
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig890
1 files changed, 723 insertions, 167 deletions
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" {