From f67fec978a9296c651ec06bd2f43686d34ff86ee Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 14 Aug 2026 13:40:29 -0300 Subject: look: richer path/range parsing, pdf rendering, corner-drag and stepgrain snapshots --- src/pardes.zig | 890 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 723 insertions(+), 167 deletions(-) (limited to 'src/pardes.zig') 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" { -- cgit v1.3