diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-09 10:41:33 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a (patch) | |
| tree | 91fce278abc85e4f7491de8b5f02184c8a2d5a56 /src/pardes.zig | |
| parent | 2b1fac6fecbf7d7cc08f9dad24687b05e9968c9a (diff) | |
| download | pardes-9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a.tar.gz pardes-9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a.zip | |
replace ArrayLists with bounded storage
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1504 |
1 files changed, 871 insertions, 633 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index ef606f23..fd33cafd 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -26,7 +26,7 @@ const mvzr = @import("mvzr"); const modal = @import("modal.zig"); const normal_input = @import("normal_input.zig"); const look = @import("look.zig"); -const syntax = @import("syntax.zig"); +pub const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); const term_pane = @import("term_pane.zig"); const file_pane = @import("file_pane.zig"); @@ -35,7 +35,9 @@ const builtins = @import("builtins.zig"); const selection_pipe = @import("selection_pipe.zig"); pub const config = @import("config.zig"); pub const pdf_enabled = @import("pardes_config").mupdf; -const pdf_impl = if (pdf_enabled) @import("mupdf") else struct {}; +pub const pdf = if (pdf_enabled) @import("mupdf") else struct {}; +const pdf_impl = pdf; +pub const allocators = @import("allocators.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); @@ -359,7 +361,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll); const revision_before_search = pane.pdf.?.render_revision; - p.runSearch(0, "Pardes", .text, .top); + try p.runSearch(0, "Pardes", .text, .top); try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); try std.testing.expectEqual(revision_before_search, pane.pdf.?.render_revision); const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; @@ -374,7 +376,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { const long_query = "a query deliberately longer than any tag display budget: " ++ "012345678901234567890123456789012345678901234567890123456789" ++ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; - p.setPdfSearchQuery(&pane.pdf.?, long_query); + try p.setPdfSearchQuery(&pane.pdf.?, long_query); try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0; p.setPdfPage(pane, next_page); @@ -456,7 +458,7 @@ test "PDF normal adapter consumes unsupported actions and navigates page fallbac try std.testing.expectEqual(@as(u32, 0), pane.count); try std.testing.expectEqual(@as(u21, 0), pane.pending); try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0); - try std.testing.expect(!pane.pipe_edit and !pane.tag_edit); + try std.testing.expect(!pane.hasPipePrompt() and !pane.tag_edit); // Cross-pane BODY-NORMAL actions keep their established shared paths. p.update(.{ .key = .{ .cp = ':' } }); @@ -469,9 +471,9 @@ test "PDF normal adapter consumes unsupported actions and navigates page fallbac p.update(.{ .key = .{ .cp = '!' } }); try std.testing.expect(!p.leader_on); p.update(.{ .key = .{ .cp = '/' } }); - try std.testing.expect(pane.search_edit and pane.tag_edit); + try std.testing.expect(pane.hasSearchPrompt() and pane.tag_edit); p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expect(!pane.search_edit and !pane.tag_edit); + try std.testing.expect(!pane.hasSearchPrompt() and !pane.tag_edit); } test "PDF continuous strip renders every intersecting short page" { @@ -499,10 +501,11 @@ test "PDF continuous strip renders every intersecting short page" { const visible = Pardes.pdfVisiblePages(pv, viewport); try std.testing.expectEqual(pv.page_count, visible.len); const request = p.pdfRenderRequest(pane).?; + try std.testing.expect(pv.page_count <= pv.rasters.len); for (0..pv.page_count) |page| { const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4); @memset(rgba, @intCast(page)); - try pv.rasters.append(gpa, .{ + pv.rasters[pv.rasters_len] = .{ .valid = true, .page = page, .rgba = rgba, @@ -513,14 +516,15 @@ test "PDF continuous strip renders every intersecting short page" { .tried = true, .tint_key = p.pdfTintKey(pv), .revision = @intCast(page + 1), - }); + }; + pv.rasters_len += 1; } var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const surface = try p.render(frame.allocator()); try std.testing.expectEqual(pv.page_count, surface.nimages); - try std.testing.expectEqual(pv.page_count, pv.rasters.items.len); + try std.testing.expectEqual(pv.page_count, pv.rasters_len); for (surface.images[0..surface.nimages], 0..) |maybe, page| { const place = maybe orelse return error.MissingShortPdfPage; try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page); @@ -607,7 +611,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" { const default_surface = try p.render(frame.allocator()); try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); try std.testing.expectEqual(@as(usize, 2), default_surface.nimages); - try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len); + try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); const Snapshot = struct { page: u32, @@ -648,14 +652,14 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" { } }; const default_filtered = try Capture.get(default_surface); - for (pv.rasters.items) |raster| + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode); const dark_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "dark")) break i; } else return error.MissingDarkTheme; p.setThemeIndex(dark_index); - for (pv.rasters.items) |raster| try std.testing.expect(!raster.tried); + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried); _ = frame.reset(.retain_capacity); const themed_surface = try p.render(frame.allocator()); const themed = try Capture.get(themed_surface); @@ -690,7 +694,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" { for (themed, full) |old, new| { try std.testing.expect(new.revision > old.revision); } - for (pv.rasters.items) |raster| + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode); p.togglePdfTint(pane); @@ -707,11 +711,11 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" { const acme_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "acme")) break i; } else return error.MissingAcmeTheme; - for (pv.rasters.items) |raster| try std.testing.expect(raster.tried); + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); p.setThemeIndex(acme_index); // Disabled keys ignore theme colors, so neither explicit invalidation nor // the per-raster key comparison asks MuPDF for replacement pixels. - for (pv.rasters.items) |raster| try std.testing.expect(raster.tried); + for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried); _ = frame.reset(.retain_capacity); const unchanged_surface = try p.render(frame.allocator()); const unchanged = try Capture.get(unchanged_surface); @@ -720,7 +724,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" { try std.testing.expectEqual(old.revision, new.revision); try std.testing.expectEqual(old.checksum, new.checksum); } - try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len); + try std.testing.expectEqual(@as(usize, 2), pv.rasters_len); } test "PDF resize preserves page-relative document position" { @@ -843,7 +847,7 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { defer page_hits.deinit(gpa); try std.testing.expect(page_hits.hit_count >= 3); - p.runSearch(0, "Pardes", .text, .top); + try p.runSearch(0, "Pardes", .text, .top); const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; const rows = p.panes[results_id].?.file.?.content; try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null); @@ -1223,7 +1227,7 @@ const legacy_file_pane_tail = " Save Del"; // to keep in sync. It lands here rather than in builtins.zig because a // container cannot hold a decl folded out of its own decl list, and here it // sits with the other two comptime folds (builtin_rows, the topbar check). -const Builtin = builtins.Builtin(); +const Builtin = builtins.registry.Builtin(); test "PDF builtins and leader paths follow the MuPDF feature gate" { const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit"); @@ -1458,11 +1462,11 @@ test "PdfSections output, Look, and n/N share exact cached outline destinations" // The same path:page:col token from any non-PdfSections pane retains the // established PDF-search meaning for col; no outline reveal is installed. - p.setPdfSearchQuery(&pdf_pane.pdf.?, "ordinary search semantics"); + try p.setPdfSearchQuery(&pdf_pane.pdf.?, "ordinary search semantics"); p.lookAt(1, child_target); try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.search_hit); try std.testing.expect(pdf_pane.pdf.?.outline_reveal_pending == null); - p.setPdfSearchQuery(&pdf_pane.pdf.?, ""); + try p.setPdfSearchQuery(&pdf_pane.pdf.?, ""); // Step forward onto the external row: it uses the existing open_link // effect and searchStep restores focus to the owning PDF. @@ -1559,12 +1563,12 @@ test "selection pipe prompt submits exact request and Escape cancels" { pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(pane.tag_edit and pane.pipe_edit); - try std.testing.expect(std.mem.endsWith(u8, pane.tag_tail.items, config.pipe_marker)); + try std.testing.expect(pane.tag_edit and pane.hasPipePrompt()); + try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker)); try std.testing.expect(nextPipeEffect(p) == null); p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); p.update(.{ .key = .{ .cp = Key.enter } }); - try std.testing.expect(!pane.tag_edit and !pane.pipe_edit); + try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt()); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; try std.testing.expectEqualStrings("tr a-z A-Z", request.command); @@ -1576,7 +1580,7 @@ test "selection pipe prompt submits exact request and Escape cancels" { p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expect(!pane.tag_edit and !pane.pipe_edit); + try std.testing.expect(!pane.tag_edit and !pane.hasPipePrompt()); try std.testing.expectEqualSlices(u8, before, pane.file.?.content); try std.testing.expect(nextPipeEffect(p) == null); } @@ -1606,7 +1610,7 @@ test "selection pipe replaces all ranges atomically and undo restores them" { const outputs: []const []const u8 = &.{ "AA\n", "" }; p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); try std.testing.expectEqualSlices(u8, "AA\n bb \n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo.items.len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); try std.testing.expectEqual(@as(u8, 1), pane.nsel); p.update(.{ .key = .{ .cp = 'u' } }); @@ -1628,7 +1632,7 @@ test "selection pipe failure and stale completion never mutate the file" { const failed_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; p.update(.{ .pipe_resp = .{ .id = failed_id, .success = false, .outputs = &.{} } }); try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo.items.len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); @@ -1637,7 +1641,7 @@ test "selection pipe failure and stale completion never mutate the file" { file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n")); p.update(.{ .pipe_resp = .{ .id = stale_id, .success = true, .outputs = &.{"ABC"} } }); try std.testing.expectEqualSlices(u8, "changed\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo.items.len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); } test "selection pipe rejects a reused pane slot and a superseded request" { @@ -1656,7 +1660,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" { pane = try p.hxOpenFileContent("new\n"); // same slot, different serial p.update(.{ .pipe_resp = .{ .id = replaced_id, .success = true, .outputs = &.{"OLD"} } }); try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo.items.len); + try std.testing.expectEqual(@as(usize, 0), pane.file.?.undo_len); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; @@ -1674,7 +1678,7 @@ test "selection pipe rejects a reused pane slot and a superseded request" { try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); p.update(.{ .pipe_resp = .{ .id = latest_id, .success = true, .outputs = &.{"NEW"} } }); try std.testing.expectEqualSlices(u8, "NEW\n", pane.file.?.content); - try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo.items.len); + try std.testing.expectEqual(@as(usize, 1), pane.file.?.undo_len); } test "selection pipe binding is file-normal-only" { @@ -1686,11 +1690,11 @@ test "selection pipe binding is file-normal-only" { pane.file.?.output = .{ .from = .search }; p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(!pane.pipe_edit); + try std.testing.expect(!pane.hasPipePrompt()); pane.file.?.output = null; pane.mode = .insert; p.update(.{ .key = .{ .cp = '|', .text = "|" } }); - try std.testing.expect(!pane.pipe_edit); + try std.testing.expect(!pane.hasPipePrompt()); try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content); } @@ -1713,7 +1717,7 @@ test "startup config runs builtin lines in order and isolates bad lines" { try std.testing.expect(!p.themeAnimationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); try std.testing.expect(!p.quit); - for (p.effects.items) |effect| switch (effect) { + for (0..p.effects_len) |i| switch (p.effects[(p.effects_head + i) % p.effects.len]) { .write => |w| { try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "DefinitelyNotABuiltin") == null); try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "Kill") == null); @@ -1820,8 +1824,8 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. - pane.tag_tail.clearRetainingCapacity(); - try pane.tag_tail.appendSlice(gpa, " Theme dark"); + pane.tag_tail_len = 0; + try std.testing.expect(pane.appendTag(" Theme dark")); pane.tag_init = true; p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .drag, .col = tag_x + 10, .row = tag_y } }); @@ -1840,21 +1844,23 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { // A word absent from the builtin vocabulary takes the fallback, including // the terminating carriage return expected by the shell prompt. - pane.tag_tail.clearRetainingCapacity(); - try pane.tag_tail.appendSlice(gpa, " DefinitelyNotABuiltin"); + pane.tag_tail_len = 0; + try std.testing.expect(pane.appendTag(" DefinitelyNotABuiltin")); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } }); - var sent: std.ArrayList(u8) = .empty; - defer sent.deinit(gpa); + var sent: [256]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 sent.appendSlice(gpa, w.bytes.slice()); + 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; }, .new_file => return error.UnknownCommandRanNew, else => {}, }; - try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent.items); + try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]); } test "an untouched tagline ends where its layout column's widest one does" { @@ -1904,7 +1910,7 @@ test "an untouched tagline ends where its layout column's widest one does" { // builtins. (A plain click seeds the tail, so the alternative is every // other tagline in the column snapping left the moment you click one.) p.seedTail(below); - try below.tag_tail.appendSlice(gpa, " lots and lots of typing out here"); + try std.testing.expect(below.appendTag(" lots and lots of typing out here")); try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); // A pane squeezed off the bottom is not drawn, so it stops voting and the @@ -1968,20 +1974,23 @@ test "unknown Exec from an image writes to a terminal in the image directory" { 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; try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_pane)); try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback")); - var sent: std.ArrayList(u8) = .empty; - defer sent.deinit(gpa); + var sent: [256]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 sent.appendSlice(gpa, w.bytes.slice()); + 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.ImageExecSpawnedDespiteMatchingTerminal, else => {}, }; - try std.testing.expectEqualStrings("echo image-fallback\r", sent.items); + try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); } test "New completes as an empty watched file in the calling column and focuses it" { @@ -2118,6 +2127,11 @@ test "New requests repeat, stay anchored, and reject stale or full completions" else => {}, }; try std.testing.expect(!emitted_when_full); + // Builtins that need another pane surface capacity/allocation failures on + // the asking pane instead of silently doing nothing. + const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin; + p.runBuiltin(help, source, "", null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[source].?.msg[0..p.panes[source].?.msg_len], "help: NoPaneSlots") != null); } test "startup config overrides restored styling before initFromDump returns" { @@ -2169,10 +2183,28 @@ fn chordName(comptime c: config.Chord) []const u8 { /// with no word to index by. The day one of them becomes a builtin — a struct /// in builtins.zig and a `cmd` column beside its binding — it appears here for /// free, which is the upgrade path rather than a special case here. +fn shortcutCount(comptime b: Builtin) comptime_int { + var count = 0; + for (config.window_keys) |wk| if (wk.cmd == b) { + count += 2; + }; + for (config.jump_keys) |jk| if (jk.cmd == b) { + count += 1; + }; + if (config.look_cmd == b) count += config.look_key.len + 1; + if (config.exec_cmd == b) count += config.exec_key.len + 1; + var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); + while (it.next()) |w| if (std.mem.eql(u8, w, @tagName(b))) { + count += 1; + }; + return count; +} + fn shortcuts(comptime b: Builtin) []const u8 { comptime { @setEvalBranchQuota(20000); - var parts: []const []const u8 = &.{}; + var parts: [shortcutCount(b)][]const u8 = undefined; + var part: usize = 0; // both spellings of the four directional moves: the arrow is a real // key someone presses, and the table carries it precisely so it is // discoverable from the builtin as well as the other way round @@ -2180,29 +2212,72 @@ fn shortcuts(comptime b: Builtin) []const u8 { if (wk.cmd != b) continue; // the prefix has one spelling and this is it const pfx = chordName(config.window_prefix[0]) ++ " "; - parts = parts ++ &[_][]const u8{ pfx ++ chordName(wk.letter), pfx ++ chordName(wk.arrow) }; + parts[part] = pfx ++ chordName(wk.letter); + parts[part + 1] = pfx ++ chordName(wk.arrow); + part += 2; } for (config.jump_keys) |jk| { - if (jk.cmd == b) parts = parts ++ &[_][]const u8{chordName(jk.chord)}; + if (jk.cmd == b) { + parts[part] = chordName(jk.chord); + part += 1; + } } // the two acme verbs, a key and a mouse button each. Asked of the // BINDING (look_cmd/exec_cmd point at a builtin) rather than of Look // and Exec by name, so pointing look_cmd at Grep moves the row. if (config.look_cmd == b) { - for (config.look_key) |k| parts = parts ++ &[_][]const u8{chordName(k)}; - parts = parts ++ &[_][]const u8{@tagName(config.look_button) ++ "-click"}; + for (config.look_key) |k| { + parts[part] = chordName(k); + part += 1; + } + parts[part] = @tagName(config.look_button) ++ "-click"; + part += 1; } if (config.exec_cmd == b) { - for (config.exec_key) |k| parts = parts ++ &[_][]const u8{chordName(k)}; - parts = parts ++ &[_][]const u8{@tagName(config.exec_button) ++ "-click"}; + for (config.exec_key) |k| { + parts[part] = chordName(k); + part += 1; + } + parts[part] = @tagName(config.exec_button) ++ "-click"; + part += 1; } var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); while (it.next()) |w| { - if (std.mem.eql(u8, w, @tagName(b))) parts = parts ++ &[_][]const u8{"topbar"}; + if (std.mem.eql(u8, w, @tagName(b))) { + parts[part] = "topbar"; + part += 1; + } } - var out: []const u8 = ""; - for (parts, 0..) |s, i| out = out ++ (if (i > 0) ", " else "") ++ s; - return out; + var len: usize = if (parts.len == 0) 0 else 2 * (parts.len - 1); + for (parts) |text| len += text.len; + var out: [len]u8 = undefined; + var at: usize = 0; + for (parts, 0..) |text, i| { + if (i > 0) { + @memcpy(out[at..][0..2], ", "); + at += 2; + } + @memcpy(out[at..][0..text.len], text); + at += text.len; + } + return &out; + } +} + +fn leaderKeys(comptime path: []const u8) []const u8 { + comptime { + var out: [4 + 2 * path.len - 1]u8 = undefined; + @memcpy(out[0..4], "SPC "); + var at: usize = 4; + for (path, 0..) |c, i| { + if (i > 0) { + out[at] = ' '; + at += 1; + } + out[at] = c; + at += 1; + } + return &out; } } @@ -2236,14 +2311,7 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { namew = @max(namew, @tagName(b).len); } for (std.enums.values(Builtin), 0..) |b, i| { - var keys: []const u8 = ""; - if (config.leader_path.get(b)) |path| { - keys = "SPC "; - for (path, 0..) |c, k| { - if (k > 0) keys = keys ++ " "; - keys = keys ++ &[_]u8{c}; - } - } + const keys = if (config.leader_path.get(b)) |path| leaderKeys(path) else ""; // the name column is only padded when something follows it: a row // whose builtin has no other shortcut ends at the name, so the listing // carries no trailing whitespace @@ -2317,6 +2385,10 @@ 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 +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 /// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own /// default cell showing (the native-dark shape); `palette` null = let a child's @@ -2374,14 +2446,15 @@ const curated = struct { /// /// A FUNCTION and not a const for the same reason builtins.all is one: it is /// only ever a signature to whoever walks it, never a value in its own way. -fn fold(comptime C: type) []const Theme { +fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { comptime { - @setEvalBranchQuota(400000); // 228 themes x 15 fields, each field a @field lookup, and the array re-concatenated per theme - var out: []const Theme = &.{}; - for (@typeInfo(C).@"struct".decls) |d| { + @setEvalBranchQuota(400000); + var out: [@typeInfo(C).@"struct".decls.len]Theme = undefined; + for (@typeInfo(C).@"struct".decls, 0..) |d, i| { var t: Theme = undefined; - for (@typeInfo(Theme).@"struct".fields) |f| @field(t, f.name) = @field(@field(C, d.name).theme, f.name); - out = out ++ &[_]Theme{t}; + for (@typeInfo(Theme).@"struct".fields) |f| + @field(t, f.name) = @field(@field(C, d.name).theme, f.name); + out[i] = t; } return out; } @@ -2780,6 +2853,21 @@ const sel_slot = @intFromEnum(config.select_button); fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } +fn roleBindingName(comptime role: normal_input.Role) []const u8 { + return switch (role) { + .prefix_goto => "goto_prefix", + .prefix_view => "view_prefix", + .prefix_match => "match_prefix", + .prefix_find_fwd => "find_char_fwd", + .prefix_find_back => "find_char_back", + .prefix_till_fwd => "till_char_fwd", + .prefix_till_back => "till_char_back", + .prefix_replace => "replace_prefix", + .prefix_next => "next_prefix", + .prefix_prev => "prev_prefix", + else => @tagName(role), + }; +} /// The single physical-key -> BODY-NORMAL vocabulary seam. A key may carry /// several roles (`h` is both move-left and `gh`'s line-start); the pure @@ -2792,127 +2880,19 @@ fn normalInput(key: Key) normal_input.Input { .ctrl = key.ctrl, .alt = key.alt, }; - const roles = &out.roles; - if (hit(key, config.escape)) roles.insert(.escape); - - if (isPrefix(key, config.goto_prefix)) roles.insert(.prefix_goto); - if (isPrefix(key, config.view_prefix)) roles.insert(.prefix_view); - if (isPrefix(key, config.match_prefix)) roles.insert(.prefix_match); - if (isPrefix(key, config.find_char_fwd)) roles.insert(.prefix_find_fwd); - if (isPrefix(key, config.find_char_back)) roles.insert(.prefix_find_back); - if (isPrefix(key, config.till_char_fwd)) roles.insert(.prefix_till_fwd); - if (isPrefix(key, config.till_char_back)) roles.insert(.prefix_till_back); - if (isPrefix(key, config.replace_prefix)) roles.insert(.prefix_replace); - if (isPrefix(key, config.next_prefix)) roles.insert(.prefix_next); - if (isPrefix(key, config.prev_prefix)) roles.insert(.prefix_prev); - - if (hit(key, config.goto_file_start)) roles.insert(.goto_file_start); - if (hit(key, config.goto_last_line)) roles.insert(.goto_last_line); - if (hit(key, config.goto_line_start)) roles.insert(.goto_line_start); - if (hit(key, config.goto_line_end)) roles.insert(.goto_line_end); - if (hit(key, config.goto_first_nonws)) roles.insert(.goto_first_nonws); - if (hit(key, config.goto_line_down)) roles.insert(.goto_line_down); - if (hit(key, config.goto_line_up)) roles.insert(.goto_line_up); - if (hit(key, config.goto_column)) roles.insert(.goto_column); - if (hit(key, config.goto_view_top)) roles.insert(.goto_view_top); - if (hit(key, config.goto_view_center)) roles.insert(.goto_view_center); - if (hit(key, config.goto_view_bottom)) roles.insert(.goto_view_bottom); - if (hit(key, config.goto_definition)) roles.insert(.goto_definition); - if (hit(key, config.goto_declaration)) roles.insert(.goto_declaration); - if (hit(key, config.goto_type_definition)) roles.insert(.goto_type_definition); - if (hit(key, config.goto_implementation)) roles.insert(.goto_implementation); - if (hit(key, config.goto_references)) roles.insert(.goto_references); - - if (hit(key, config.view_top)) roles.insert(.view_top); - if (hit(key, config.view_center)) roles.insert(.view_center); - if (hit(key, config.view_bottom)) roles.insert(.view_bottom); - if (hit(key, config.view_scroll_down)) roles.insert(.view_scroll_down); - if (hit(key, config.view_scroll_up)) roles.insert(.view_scroll_up); - - if (isPrefix(key, config.match_inside)) roles.insert(.match_inside); - if (isPrefix(key, config.match_around)) roles.insert(.match_around); - if (isPrefix(key, config.surround_add)) roles.insert(.surround_add); - if (isPrefix(key, config.surround_replace)) roles.insert(.surround_replace); - if (isPrefix(key, config.surround_delete)) roles.insert(.surround_delete); - - if (hit(key, config.goto_paragraph)) roles.insert(.goto_paragraph); - if (hit(key, config.add_newline)) roles.insert(.add_newline); - if (hit(key, config.goto_diagnostic)) roles.insert(.goto_diagnostic); - if (hit(key, config.goto_diagnostic_end)) roles.insert(.goto_diagnostic_end); - - if (hit(key, config.move_left)) roles.insert(.move_left); - if (hit(key, config.move_right)) roles.insert(.move_right); - if (hit(key, config.move_down)) roles.insert(.move_down); - if (hit(key, config.move_up)) roles.insert(.move_up); - if (hit(key, config.next_word_start)) roles.insert(.next_word_start); - if (hit(key, config.prev_word_start)) roles.insert(.prev_word_start); - if (hit(key, config.next_word_end)) roles.insert(.next_word_end); - if (hit(key, config.next_long_word_start)) roles.insert(.next_long_word_start); - if (hit(key, config.prev_long_word_start)) roles.insert(.prev_long_word_start); - if (hit(key, config.next_long_word_end)) roles.insert(.next_long_word_end); - if (hit(key, config.repeat_find)) roles.insert(.repeat_find); - if (hit(key, config.line_start)) roles.insert(.line_start); - if (hit(key, config.line_end)) roles.insert(.line_end); - if (hit(key, config.line_first_nonws)) roles.insert(.line_first_nonws); - if (hit(key, config.goto_line)) roles.insert(.goto_line); - if (hit(key, config.half_page_down)) roles.insert(.half_page_down); - if (hit(key, config.half_page_up)) roles.insert(.half_page_up); - if (hit(key, config.page_down)) roles.insert(.page_down); - if (hit(key, config.page_up)) roles.insert(.page_up); - - if (hit(key, config.insert)) roles.insert(.insert); - if (hit(key, config.append)) roles.insert(.append); - if (hit(key, config.insert_line_start)) roles.insert(.insert_line_start); - if (hit(key, config.insert_line_end)) roles.insert(.insert_line_end); - if (hit(key, config.open_below)) roles.insert(.open_below); - if (hit(key, config.open_above)) roles.insert(.open_above); - - if (hit(key, config.select_mode)) roles.insert(.select_mode); - if (hit(key, config.select_line)) roles.insert(.select_line); - if (hit(key, config.select_line_bounds)) roles.insert(.select_line_bounds); - if (hit(key, config.shrink_to_line_bounds)) roles.insert(.shrink_to_line_bounds); - if (hit(key, config.collapse_selection)) roles.insert(.collapse_selection); - if (hit(key, config.flip_selection)) roles.insert(.flip_selection); - if (hit(key, config.select_all)) roles.insert(.select_all); - if (hit(key, config.copy_sel_below)) roles.insert(.copy_sel_below); - if (hit(key, config.copy_sel_above)) roles.insert(.copy_sel_above); - if (hit(key, config.keep_primary_sel)) roles.insert(.keep_primary_sel); - if (hit(key, config.remove_primary_sel)) roles.insert(.remove_primary_sel); - if (hit(key, config.rotate_sel_fwd)) roles.insert(.rotate_sel_fwd); - if (hit(key, config.rotate_sel_back)) roles.insert(.rotate_sel_back); - if (hit(key, config.split_sel_newline)) roles.insert(.split_sel_newline); - if (hit(key, config.merge_sels)) roles.insert(.merge_sels); - if (hit(key, config.merge_consecutive_sels)) roles.insert(.merge_consecutive_sels); - if (hit(key, config.trim_sels)) roles.insert(.trim_sels); - if (hit(key, config.select_regex)) roles.insert(.select_regex); - if (hit(key, config.split_regex)) roles.insert(.split_regex); - - if (hit(key, config.delete)) roles.insert(.delete); - if (hit(key, config.delete_noyank)) roles.insert(.delete_noyank); - if (hit(key, config.change)) roles.insert(.change); - if (hit(key, config.yank)) roles.insert(.yank); - if (hit(key, config.replace_with_yank)) roles.insert(.replace_with_yank); - if (hit(key, config.paste_after)) roles.insert(.paste_after); - if (hit(key, config.paste_before)) roles.insert(.paste_before); - if (hit(key, config.switch_case)) roles.insert(.switch_case); - if (hit(key, config.to_lowercase)) roles.insert(.to_lowercase); - if (hit(key, config.to_uppercase)) roles.insert(.to_uppercase); - if (hit(key, config.join_lines)) roles.insert(.join_lines); - if (hit(key, config.indent)) roles.insert(.indent); - if (hit(key, config.unindent)) roles.insert(.unindent); - if (hit(key, config.format)) roles.insert(.format); - if (hit(key, config.increment)) roles.insert(.increment); - if (hit(key, config.decrement)) roles.insert(.decrement); - if (hit(key, config.comment_toggle)) roles.insert(.comment_toggle); - if (hit(key, config.undo)) roles.insert(.undo); - if (hit(key, config.redo)) roles.insert(.redo); - - if (hit(key, config.leader)) roles.insert(.leader); - if (hit(key, config.command_line)) roles.insert(.command_line); - if (hit(key, config.pipe_selection)) roles.insert(.pipe_selection); - if (hit(key, config.search)) roles.insert(.search); - if (hit(key, config.search_next)) roles.insert(.search_next); - if (hit(key, config.search_prev)) roles.insert(.search_prev); + inline for (std.enums.values(normal_input.Role)) |role| { + const name = comptime roleBindingName(role); + if (!@hasDecl(config, name)) + @compileError("normal input role has no config binding: " ++ name); + const binding = @field(config, name); + const matched = if (@TypeOf(binding) == comptime_int) + isPrefix(key, binding) + else if (@TypeOf(binding) == []const config.Chord) + hit(key, binding) + else + @compileError("normal input binding has unsupported type: " ++ name); + if (matched) out.roles.insert(role); + } return out; } @@ -3076,8 +3056,7 @@ pub const CharSel = struct { /// an unbounded Vec; a fixed array keeps a Pane trivially copyable (the undo /// snapshots memcpy it) and costs nothing at one cursor. The ceiling only /// bites on `C`/`Alt-s` over a very long selection, where the extra ranges are -/// simply not created — raise the number, or swap in an ArrayList, if that -/// ever matters. +/// simply not created — raise the bound if that ever matters. pub const MAX_SELS = 64; /// One selection range in PANE coordinates: the block-cursor cell and the @@ -3141,12 +3120,14 @@ pub const File = struct { /// at least one line. Built and read only through file_pane.lineIndex / /// nlines / lineStart; see the invalidation contract on lineIndex. line_starts: []usize = &.{}, - /// one syntax.Syn byte per content byte in the highlighted window + /// one tree_sitter_gpa-owned syntax.Syn byte per content byte in the highlighted window highlights: []u8 = &.{}, highlight_start: usize = 0, syntax_dirty: bool = true, - undo: std.ArrayList(FileSnap) = .empty, - redo: std.ArrayList(FileSnap) = .empty, + undo: [UNDO_MAX]FileSnap = undefined, + undo_len: usize = 0, + redo: [UNDO_MAX]FileSnap = undefined, + redo_len: usize = 0, }; /// An image pane's backing: the decoded+downscaled RGBA (kept so the petscii @@ -3160,7 +3141,7 @@ pub const Image = struct { rgba: []u8 = &.{}, iw: usize = 0, ih: usize = 0, - /// dump-loaded image bytes (gpa-owned), decoded lazily like a file read + /// dump-loaded image bytes (image_gpa-owned), decoded lazily like a file read raw: []u8 = &.{}, grid: []image.petscii.Cell = &.{}, gw: usize = 0, @@ -3219,10 +3200,11 @@ const PdfView = if (pdf_enabled) struct { /// fractional wheel distance while page starts/heights remain integral. document_scroll_y: f64 = 0, scroll_to_page_pending: bool = true, - /// Exactly the visible pages. Entries are removed (and RGBA freed) the - /// frame a page leaves the viewport, so pixel memory is viewport-bounded - /// even for documents with pathological page dimensions. - rasters: std.ArrayList(PdfRaster) = .empty, + /// At most PDF_RASTER_MAX visible pages are retained. If a viewport can + /// show more pathological tiny pages, later pages are rendered as space + /// rather than letting cache memory grow with the document. + rasters: [PDF_RASTER_MAX]PdfRaster = undefined, + rasters_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. @@ -3364,15 +3346,12 @@ pub const PdfSectionRows = if (pdf_enabled) struct { return ordinals; } - fn appendTitle( - out: *std.ArrayList(u8), - gpa: std.mem.Allocator, - title: ?[]const u8, - ) !void { + fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize { const raw = title orelse { - try out.appendSlice(gpa, "[untitled]"); - return; + if (out) |buf| @memcpy(buf[0..10], "[untitled]"); + return 10; }; + var at: usize = 0; var i: usize = 0; var wrote = false; var pending_space = false; @@ -3396,54 +3375,68 @@ pub const PdfSectionRows = if (pdf_enabled) struct { if (whitespace_or_control) { pending_space = wrote; } else { - if (pending_space) try out.append(gpa, ' '); - try out.appendSlice(gpa, raw[i .. i + n]); + if (pending_space) { + if (out) |buf| buf[at] = ' '; + at += 1; + } + if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]); + at += n; wrote = true; pending_space = false; } i += n; } - if (!wrote) try out.appendSlice(gpa, "[empty title]"); + if (!wrote) { + if (out) |buf| @memcpy(buf[0..13], "[empty title]"); + return 13; + } + return at; } - pub fn render( - gpa: std.mem.Allocator, - path: []const u8, - entries: []const pdf_impl.OutlineEntry, - ) ![]u8 { - // The output pane's directory is the PDF's directory, so a basename - // resolves to the same document while leaving room for the actual - // chapter title. An absolute prefix can consume the entire picker. + pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf_impl.OutlineEntry) ![]u8 { const target = std.fs.path.basename(path); const ordinals = try resolveOrdinals(gpa, entries); defer gpa.free(ordinals); - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(gpa); + var total: usize = 0; for (entries, 0..) |entry, ordinal| { const resolved = ordinals[ordinal]; if (resolved == std.math.maxInt(usize)) continue; const destination = usableDestination(entries[resolved].destination) orelse unreachable; - switch (destination) { - .internal => |internal| try out.print(gpa, "{s}:{d}:{d} ", .{ - target, - internal.page + 1, - ordinal + 1, - }), - .external => |uri| try out.print(gpa, "{s} ", .{uri}), + total += switch (destination) { + .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), + .external => |uri| std.fmt.count("{s} ", .{uri}), .none => unreachable, - } - try out.appendNTimes(gpa, ' ', @as(usize, entry.depth) * 2); - try appendTitle(&out, gpa, entry.title); - try out.append(gpa, '\n'); + }; + total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1; } - return out.toOwnedSlice(gpa); + const out = try gpa.alloc(u8, total); + errdefer gpa.free(out); + var at: usize = 0; + for (entries, 0..) |entry, ordinal| { + const resolved = ordinals[ordinal]; + if (resolved == std.math.maxInt(usize)) continue; + const destination = usableDestination(entries[resolved].destination) orelse unreachable; + const prefix = switch (destination) { + .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), + .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}), + .none => unreachable, + }; + at += prefix.len; + const indent = @as(usize, entry.depth) * 2; + @memset(out[at..][0..indent], ' '); + at += indent; + at += cleanTitle(out[at..], entry.title); + out[at] = '\n'; + at += 1; + } + return out; } } else struct {}; const PdfSlot = if (pdf_enabled) ?PdfView else void; fn hasPdf(pane: *const Pane) bool { - return if (comptime pdf_enabled) pane.pdf != null else false; + return pdf_enabled and pane.kind == .pdf; } fn hasPdfSelection(pane: *const Pane) bool { @@ -3456,6 +3449,12 @@ fn hasPdfSelection(pane: *const Pane) bool { test "feature-off PDF pane state is zero-sized" { if (!pdf_enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfSlot)); } +pub const PaneKind = enum { terminal, file, image, pdf }; +const Prompt = union(enum) { + none, + search: u16, + pipe: u16, +}; pub const Pane = struct { vt: ghostty_vt.Terminal, @@ -3466,6 +3465,9 @@ pub const Pane = struct { /// ensureCursorVisible, lastNavRow — can build it. The alternative was /// threading an allocator through ensureCursorVisible's 33 call sites. gpa: std.mem.Allocator, + /// Active payload discriminator. Constructors set this with the matching + /// file/image/PDF payload; terminal panes carry none of those payloads. + kind: PaneKind = .terminal, /// WHICH pane this is, for anything that outlives the pane: slots are /// REUSED (freeSlot hands back the lowest free one), so a remembered id /// alone can silently come to mean an unrelated pane. Handed out by @@ -3514,21 +3516,9 @@ pub const Pane = struct { find_ch: u21 = 0, /// Compact storage for normal_input.State's typed prefix. pending: u21 = 0, - /// `/` search and the Find builtin: the pattern is typed into the tag tail - /// (no popup); search_at is the tail length before the marker was appended - /// (submit/cancel restore it, and the marker itself says WHICH search this - /// is). Enter fills the output buffer in pane search_pane with one result - /// per row; n/N walk it, search_row being the row they last landed on - /// (null = fresh results, nothing stepped to yet). WHICH command filled - /// that buffer is the BUFFER's own record, never a second field here — - /// output_pane.resultsFrom is how `]d` asks. - search_edit: bool = false, - search_at: u16 = 0, - /// `|` reuses the visible tag-tail input but submits a shell-filter - /// request rather than a search. pipe_at is a tail byte offset exactly - /// like search_at; Enter/Esc restore the tail to it. - pipe_edit: bool = false, - pipe_at: u16 = 0, + /// Tag-tail input state. The tag text is presentation; this tag carries + /// which operation owns it and the tail offset restored on submit/cancel. + prompt: Prompt = .none, search_pane: ?usize = null, search_row: ?usize = null, /// The selection an `s`/`S` input was armed on, as gap offsets over the @@ -3538,7 +3528,7 @@ pub const Pane = struct { /// a pattern one character at a time land on the same answer as pasting it /// whole, and what makes Esc a plain restore with nothing else to undo. /// `nsel_snap == 0` means no such input is armed; exitTagEdit, the one - /// place search_edit is ever cleared, clears it too. + /// place the prompt is cleared, clears it too. sel_snap: [MAX_SELS]modal.HxRange = undefined, nsel_snap: u8 = 0, sel_snap_pri: u8 = 0, @@ -3546,9 +3536,10 @@ pub const Pane = struct { /// (you picked those matches), but restoring must not silently promote /// motion residue into something the acme chords will act on sel_snap_expl: bool = false, - /// the editable tag tail: seeded with the default builtins on first touch; - /// edited in place by a modal one-line editor sharing the pane's mode - tag_tail: std.ArrayList(u8) = .empty, + /// the editable tag tail: a bounded one-line command buffer. Input that + /// does not fit is refused atomically. + tag_tail: [TAG_TAIL_CAP]u8 = undefined, + tag_tail_len: usize = 0, tag_init: bool = false, tag_edit: bool = false, tag_sel: bool = false, @@ -3563,8 +3554,10 @@ pub const Pane = struct { /// does nothing left of it. tag_col: u16 = 0, tag_anchor: u16 = 0, - ed_undo: std.ArrayList(EditSnap) = .empty, - ed_redo: std.ArrayList(EditSnap) = .empty, + ed_undo: [EDIT_UNDO_MAX]EditSnap = undefined, + ed_undo_len: usize = 0, + ed_redo: [EDIT_UNDO_MAX]EditSnap = undefined, + ed_redo_len: usize = 0, /// live working directory, shell-reported (setCwd); shown in the tag and /// the base for look-resolution of relative paths. cwd: [1024]u8 = undefined, @@ -3606,8 +3599,12 @@ pub const Pane = struct { sel: [3]Sel = @splat(.{}), /// terminals only: the typed-text buffer standing in for shell rows ovl: ?Ovl = null, - /// every raw pty byte, in order — the dump/replay record. - tty_stream: std.ArrayList(u8) = .empty, + /// every raw pty byte, in order — a bounded dump/replay ring. Once full, + /// new output evicts the oldest bytes while the live terminal still sees + /// every byte. + tty_stream: [TTY_REPLAY_CAP]u8 = undefined, + tty_stream_head: usize = 0, + tty_stream_len: usize = 0, /// query replies ghostty computed (DSR, DA, kitty); the stream handler has /// no path to the effect queue, so they land here and sync() drains them /// into write effects. Bounded: replies are tiny escape sequences. @@ -3622,12 +3619,95 @@ pub const Pane = struct { msg: [256]u8 = undefined, msg_len: u16 = 0, + fn tagSlice(p: *const Pane) []const u8 { + return p.tag_tail[0..p.tag_tail_len]; + } + fn promptAt(p: *const Pane) ?u16 { + return switch (p.prompt) { + .none => null, + .search, .pipe => |at| at, + }; + } + + fn hasSearchPrompt(p: *const Pane) bool { + return switch (p.prompt) { + .search => true, + else => false, + }; + } + + fn hasPipePrompt(p: *const Pane) bool { + return switch (p.prompt) { + .pipe => true, + else => false, + }; + } + + fn appendTag(p: *Pane, text: []const u8) bool { + if (text.len > p.tag_tail.len - p.tag_tail_len) return false; + @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); + p.tag_tail_len += text.len; + return true; + } + + fn insertTagByte(p: *Pane, at: usize, byte: u8) bool { + if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false; + std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]); + p.tag_tail[at] = byte; + p.tag_tail_len += 1; + return true; + } + + fn removeTagByte(p: *Pane, at: usize) void { + if (at >= p.tag_tail_len) return; + std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]); + p.tag_tail_len -= 1; + } + + fn appendTtyReplay(p: *Pane, bytes: []const u8) void { + if (bytes.len >= p.tty_stream.len) { + const kept = bytes[bytes.len - p.tty_stream.len ..]; + @memcpy(p.tty_stream[0..], kept); + p.tty_stream_head = 0; + p.tty_stream_len = p.tty_stream.len; + return; + } + const overflow = bytes.len -| (p.tty_stream.len - p.tty_stream_len); + p.tty_stream_head = (p.tty_stream_head + overflow) % p.tty_stream.len; + p.tty_stream_len -= overflow; + const tail = (p.tty_stream_head + p.tty_stream_len) % p.tty_stream.len; + const first = @min(bytes.len, p.tty_stream.len - tail); + @memcpy(p.tty_stream[tail..][0..first], bytes[0..first]); + @memcpy(p.tty_stream[0 .. bytes.len - first], bytes[first..]); + p.tty_stream_len += bytes.len; + } + + fn ttyReplayBytes(p: *const Pane, allocator: std.mem.Allocator) ![]const u8 { + if (p.tty_stream_len == 0) return &.{}; + if (p.tty_stream_head + p.tty_stream_len <= p.tty_stream.len) + return p.tty_stream[p.tty_stream_head..][0..p.tty_stream_len]; + const out = try allocator.alloc(u8, p.tty_stream_len); + const first = p.tty_stream.len - p.tty_stream_head; + @memcpy(out[0..first], p.tty_stream[p.tty_stream_head..]); + @memcpy(out[first..], p.tty_stream[0 .. p.tty_stream_len - first]); + return out; + } + pub fn cwdSlice(p: *const Pane) []const u8 { return p.cwd[0..p.cwd_len]; } + fn payloadValid(p: *const Pane) bool { + const no_pdf = if (comptime pdf_enabled) p.pdf == null else true; + return switch (p.kind) { + .terminal => p.file == null and p.image == null and no_pdf, + .file => p.file != null and p.image == null and no_pdf, + .image => p.file == null and p.image != null and no_pdf, + .pdf => if (comptime pdf_enabled) p.file == null and p.image == null and p.pdf != null else false, + }; + } pub fn isTerminal(pane: *const Pane) bool { - return pane.file == null and pane.image == null and !hasPdf(pane); + return pane.kind == .terminal; } pub fn pdfPath(pane: *const Pane) ?[]const u8 { @@ -3965,6 +4045,9 @@ pub const Options = struct { /// deterministic; when present, each line is dispatched as a builtin /// before init returns and therefore before any frontend can render. startup_config: ?[]const u8 = null, + image_allocator: ?std.mem.Allocator = null, + pdf_allocator: ?std.mem.Allocator = null, + tree_sitter_allocator: ?std.mem.Allocator = null, /// Initial grid. Shell contract: for LIVE sessions leave these at the /// defaults and deliver the real size as the first resize EVENT — the core /// defers each shell's greeting until after a resize (so `ls` wraps to the @@ -4007,6 +4090,9 @@ const PendingPipe = struct { pub const Pardes = struct { gpa: std.mem.Allocator, + image_gpa: std.mem.Allocator, + pdf_gpa: std.mem.Allocator, + tree_sitter_gpa: std.mem.Allocator, opts: Options, screen_w: u16, screen_h: u16, @@ -4141,11 +4227,12 @@ pub const Pardes = struct { pipe_seq: u32 = 0, pipe_wait: ?PendingPipe = null, - /// pending effects, drained by the shell after each update. Growable: one - /// update can emit unbounded writes (a big selection executed is chunked - /// into 64-byte write effects) — a fixed ring overflows and panics. - effects: std.ArrayList(Effect) = .empty, + /// Pending effects, drained by the shell after each update. The bounded + /// ring preserves byte order; once full, later effects are refused so no + /// already-queued write can be reordered or silently evicted. + effects: [EFFECT_CAP]Effect = undefined, effects_head: usize = 0, + effects_len: usize = 0, /// modal yank register (gpa-owned); a yank also mirrors out to the system /// clipboard via the set_clipboard effect. @@ -4165,8 +4252,21 @@ pub const Pardes = struct { scratch: std.heap.ArenaAllocator, pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { + const image_gpa = opts.image_allocator orelse gpa; + const pdf_gpa = opts.pdf_allocator orelse gpa; + const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; const p = try gpa.create(Pardes); - p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) }; + p.* = .{ + .gpa = gpa, + .image_gpa = image_gpa, + .pdf_gpa = pdf_gpa, + .tree_sitter_gpa = tree_sitter_gpa, + .opts = opts, + .screen_w = opts.cols, + .screen_h = opts.rows, + .scratch = .init(gpa), + }; + errdefer p.deinit(); if (opts.file) |path| { // FILE argv boot: the doc alone, filling the window. Naming a file // is asking to READ it, not to be handed a shell you did not ask @@ -4226,7 +4326,6 @@ pub const Pardes = struct { if (p.last_dump) |d| gpa.free(d); if (p.chord_arg) |a| gpa.free(a); if (p.pipe_wait) |*wait| wait.deinit(gpa); - p.effects.deinit(gpa); p.scratch.deinit(); gpa.free(p.surface.cells); if (p.surface.images.len > 0) gpa.free(p.surface.images); @@ -4257,29 +4356,29 @@ pub const Pardes = struct { } pub fn deinitPane(p: *Pardes, pane: *Pane) void { + std.debug.assert(pane.payloadValid()); if (pane.image) |*iv| { - p.gpa.free(iv.path); - if (iv.rgba.len > 0) p.gpa.free(iv.rgba); - if (iv.raw.len > 0) p.gpa.free(iv.raw); - if (iv.grid.len > 0) p.gpa.free(iv.grid); + p.image_gpa.free(iv.path); + if (iv.rgba.len > 0) p.image_gpa.free(iv.rgba); + if (iv.raw.len > 0) p.image_gpa.free(iv.raw); + if (iv.grid.len > 0) p.image_gpa.free(iv.grid); } if (comptime pdf_enabled) { if (pane.pdf) |*pv| { - p.gpa.free(pv.path); - for (pv.rasters.items) |raster| if (raster.rgba.len > 0) - p.gpa.free(raster.rgba); - pv.rasters.deinit(p.gpa); - p.gpa.free(pv.page_sizes); - p.gpa.free(pv.page_starts); - p.gpa.free(pv.page_heights); + 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); + p.pdf_gpa.free(pv.page_sizes); + p.pdf_gpa.free(pv.page_starts); + p.pdf_gpa.free(pv.page_heights); // `rgba` aliases the active raster for legacy geometry state; // its owner was released above. - if (pv.text.len > 0) p.gpa.free(pv.text); - if (pv.search_query.len > 0) p.gpa.free(pv.search_query); - if (pv.search_results) |*results| results.deinit(p.gpa); - if (pv.selection) |*selection| selection.deinit(p.gpa); - if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text); - if (pv.outline) |*outline| outline.deinit(p.gpa); + if (pv.text.len > 0) p.pdf_gpa.free(pv.text); + if (pv.search_query.len > 0) p.pdf_gpa.free(pv.search_query); + if (pv.search_results) |*results| results.deinit(p.pdf_gpa); + if (pv.selection) |*selection| selection.deinit(p.pdf_gpa); + if (pv.selection_text.len > 0) p.pdf_gpa.free(pv.selection_text); + if (pv.outline) |*outline| outline.deinit(p.pdf_gpa); pv.document.deinit(); } } @@ -4295,21 +4394,15 @@ pub const Pardes = struct { p.gpa.free(f.path); p.gpa.free(f.content); if (f.line_starts.len > 0) p.gpa.free(f.line_starts); - if (f.highlights.len > 0) p.gpa.free(f.highlights); - for (f.undo.items) |s| p.gpa.free(s.content); - f.undo.deinit(p.gpa); - for (f.redo.items) |s| p.gpa.free(s.content); - f.redo.deinit(p.gpa); + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + for (f.undo[0..f.undo_len]) |s| p.gpa.free(s.content); + for (f.redo[0..f.redo_len]) |s| p.gpa.free(s.content); } if (pane.ovl) |o| p.gpa.free(o.text); - pane.tag_tail.deinit(p.gpa); - for (pane.ed_undo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); - pane.ed_undo.deinit(p.gpa); - for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); - pane.ed_redo.deinit(p.gpa); + for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); + for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.stream.deinit(); pane.vt.deinit(p.gpa); - pane.tty_stream.deinit(p.gpa); p.gpa.destroy(pane); } @@ -4324,6 +4417,7 @@ pub const Pardes = struct { std.debug.assert(p.panes[id] == null); if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256) const pane = try p.gpa.create(Pane); + errdefer p.gpa.destroy(pane); pane.* = .{ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = p.screen_w, @@ -4352,6 +4446,7 @@ pub const Pardes = struct { pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); const pane = try p.gpa.create(Pane); + errdefer p.gpa.destroy(pane); pane.* = .{ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = 1, .rows = 1 }), .stream = undefined, @@ -4415,6 +4510,7 @@ pub const Pardes = struct { return false; }; pane.file = .{ .path = path_copy, .content = content }; + pane.kind = .file; pane.cur_pinned = true; // sf was captured from SOURCE, not active. Nothing above this point @@ -4450,19 +4546,27 @@ pub const Pardes = struct { pane.msg_len = @intCast(@min(text.len, pane.msg.len)); @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); } + pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void { + var buf: [256]u8 = undefined; + const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation; + p.setMessage(id, text); + } pub fn emit(p: *Pardes, e: Effect) void { - p.effects.append(p.gpa, e) catch {}; + if (p.effects_len == p.effects.len) return; + const tail = (p.effects_head + p.effects_len) % p.effects.len; + p.effects[tail] = e; + p.effects_len += 1; } pub fn nextEffect(p: *Pardes) ?Effect { - if (p.effects_head == p.effects.items.len) { - p.effects.clearRetainingCapacity(); + if (p.effects_len == 0) { p.effects_head = 0; return null; } - const e = p.effects.items[p.effects_head]; - p.effects_head += 1; + const e = p.effects[p.effects_head]; + p.effects_head = (p.effects_head + 1) % p.effects.len; + p.effects_len -= 1; return e; } @@ -4519,7 +4623,7 @@ pub const Pardes = struct { }, .output => |o| { const pane = p.panes[o.pane] orelse return; - pane.tty_stream.appendSlice(p.gpa, o.bytes) catch {}; + pane.appendTtyReplay(o.bytes); pane.stream.nextSlice(o.bytes); // follow new output to the bottom only in tty mode; in // normal/insert the user is reading scrollback and the view @@ -4619,7 +4723,7 @@ pub const Pardes = struct { /// the editable tail: the user's edited buffer once touched, else defaults /// (a buffer with nothing to Save gets the plain tail — the table decides) fn curTail(pane: *Pane) []const u8 { - if (pane.tag_init) return pane.tag_tail.items; + if (pane.tag_init) return pane.tagSlice(); if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return file_pane_tail; return pane_tail; } @@ -4705,7 +4809,7 @@ pub const Pardes = struct { (if (output_pane.fileTraits(f.output).saves) file_pane_tail else pane_tail) else pane_tail; - const laid = if (q.tag_init) q.tag_tail.items else words; + const laid = if (q.tag_init) q.tagSlice() else words; const lead = laid.len - std.mem.trimStart(u8, laid, " ").len; const q_end = (p.tagPrefix(q) catch continue).len + lead + std.mem.trimStart(u8, words, " ").len; end = @max(end, @min(q_end, tw)); @@ -4734,8 +4838,11 @@ pub const Pardes = struct { if (pane.tag_init) return; const tail = curTail(pane); const prefix_len = (p.tagPrefix(pane) catch return).len; - pane.tag_tail.appendNTimes(p.gpa, ' ', p.tagGap(pane, prefix_len + tail.len)) catch return; - pane.tag_tail.appendSlice(p.gpa, tail) catch return; + const gap = p.tagGap(pane, prefix_len + tail.len); + if (gap + tail.len > pane.tag_tail.len) return; + @memset(pane.tag_tail[0..gap], ' '); + @memcpy(pane.tag_tail[gap..][0..tail.len], tail); + pane.tag_tail_len = gap + tail.len; pane.tag_init = true; } @@ -4747,20 +4854,21 @@ pub const Pardes = struct { fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len); p.seedTail(pane); + if (!pane.tag_init) return; if (!pane.tag_edit) pane.tag_mode = pane.mode; pane.tag_edit = true; pane.tag_sel = false; // a one-line tag has no use for normal mode: always insert pane.mode = .insert; pane.pending = 0; - const end = edit0 + @as(i32, @intCast(pane.tag_tail.items.len)); + const end = edit0 + @as(i32, @intCast(pane.tag_tail_len)); // Past the gap that right-aligns the builtins, not at the first // editable column: `:` promises the tail's START, and the start of a // run of layout spaces is not it. Landing there would cost `:w` its // second keystroke — w would select the whitespace and execute nothing // — and `:w` being the same two keys every time is the whole point of // the door. The spaces stay editable; h and Left still walk into them. - const tail = pane.tag_tail.items; + const tail = pane.tagSlice(); const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len); pane.tag_col = @intCast(if (col < 0) @min(edit0 + lead, end) else std.math.clamp(col, 0, end)); } @@ -4774,8 +4882,7 @@ pub const Pardes = struct { } else pane.mode = .normal; pane.tag_edit = false; pane.tag_sel = false; - pane.search_edit = false; // an abandoned search input stays as tag text - pane.pipe_edit = false; + pane.prompt = .none; // an abandoned prompt stays as tag text pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed } @@ -4819,19 +4926,22 @@ pub const Pardes = struct { // (that is how the path selects), but every edit below is measured from // the first EDITABLE column and simply does nothing to the left of it. const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len); - const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail.items.len)); + const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail_len)); if (key.text.len > 0) { if (pane.tag_col < edit0) return; // typing inside the path: inert + var insert_len: usize = 0; + for (key.text) |ch| insert_len += @intFromBool(ch != '\n' and ch != '\r'); + if (insert_len > pane.tag_tail.len - pane.tag_tail_len) return; for (key.text) |ch| { if (ch == '\n' or ch == '\r') continue; // one-line tag - pane.tag_tail.insert(p.gpa, pane.tag_col - edit0, ch) catch return; + if (!pane.insertTagByte(pane.tag_col - edit0, ch)) return; pane.tag_col += 1; } return; } switch (key.cp) { Key.backspace => if (pane.tag_col > edit0) { - _ = pane.tag_tail.orderedRemove(pane.tag_col - edit0 - 1); + pane.removeTagByte(pane.tag_col - edit0 - 1); pane.tag_col -= 1; }, Key.left => if (pane.tag_col > 0) { @@ -5055,19 +5165,35 @@ pub const Pardes = struct { const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1))); const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1))); const text = try p.paneText(pane); - var out: std.ArrayList(u8) = .empty; + var total: usize = 0; + var selected: usize = 0; + var count_it = std.mem.splitAny(u8, text, "\n"); + var count_row: i32 = 0; + while (count_it.next()) |line| : (count_row += 1) { + if (count_row < r0 or count_row > r1) continue; + const b0 = @min(c0, line.len); + const b1 = @min(c1 + 1, line.len); + total += b1 - b0 + @intFromBool(selected > 0); + selected += 1; + } + const out = try arena.alloc(u8, total); + var at: usize = 0; var it = std.mem.splitAny(u8, text, "\n"); var v: i32 = 0; var first = true; while (it.next()) |line| : (v += 1) { if (v < r0 or v > r1) continue; - if (!first) try out.append(arena, '\n'); + if (!first) { + out[at] = '\n'; + at += 1; + } first = false; const b0 = @min(c0, line.len); const b1 = @min(c1 + 1, line.len); - try out.appendSlice(arena, line[b0..b1]); + @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]); + at += b1 - b0; } - return out.items; + return out; } /// Is (r,c) inside the span (ar,ac)..(br,bc), in reading order? Either end @@ -5246,9 +5372,10 @@ pub const Pardes = struct { // `|` owns the same visible one-line tag input as search, but Enter // snapshots an asynchronous shell filter. Escape is a pure cancel: // restore the old tail and never emit a request. - if (pane.pipe_edit and (hit(key, config.search_submit) or hit(key, config.escape))) { + if (pane.hasPipePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { + const prompt_at = pane.promptAt().?; if (hit(key, config.search_submit)) p.submitPipe(p.active); - pane.tag_tail.shrinkRetainingCapacity(@min(pane.pipe_at, pane.tag_tail.items.len)); + pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); exitTagEdit(pane); pane.mode = .normal; pane.pending = 0; @@ -5256,15 +5383,13 @@ pub const Pardes = struct { } // a search input in flight (`/` or Find): Enter searches, Esc abandons; // both restore the tag tail and hand focus back to the body. - if (pane.search_edit and (hit(key, config.search_submit) or hit(key, config.escape))) { + if (pane.hasSearchPrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) { + const prompt_at = pane.promptAt().?; if (hit(key, config.search_submit)) p.submitSearch(p.active) else if (selRegexArmed(pane)) |_| - // Esc on an `s`/`S` preview: back to the selection it opened - // on. The empty pattern applies nothing, so this IS the - // snapshot — one path, not a second restore. p.applySelRegex(pane, "", false); - pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); + pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len); exitTagEdit(pane); pane.mode = .normal; pane.pending = 0; @@ -5377,7 +5502,11 @@ pub const Pardes = struct { p.leader_on = false; // only "still a prefix" below re-arms it if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return; const c: u8 = @intCast(key.cp); - if (c == config.leader_help) return output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]); + if (c == config.leader_help) { + output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]) catch |err| + p.reportError(p.active, "help", err); + return; + } if (p.leader_n >= p.leader_keys.len) return; p.leader_keys[p.leader_n] = c; p.leader_n += 1; @@ -5600,7 +5729,7 @@ pub const Pardes = struct { const pv = &(pane.pdf orelse return error.NotPdf); if (!pv.text_tried) { pv.text_tried = true; - pv.text = pv.document.pageText(p.gpa, pv.page) catch &.{}; + pv.text = pv.document.pageText(p.pdf_gpa, pv.page) catch &.{}; } return pv.text; } @@ -5614,10 +5743,9 @@ pub const Pardes = struct { pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines { const arena = p.scratch.allocator(); if (pane.file) |*f| { - // straight off the line index: exact size (no ArrayList regrowth - // copying a 300k-entry table forward) and no second scan for the - // newlines the index already found. Identical to splitting the - // content — a file's line count is its newline count plus one. + // Straight off the line index: exact size, no repeated growth or + // second scan for newlines the index already found. A file's line + // count is its newline count plus one. const idx = try file_pane.lineIndex(pane.gpa, f); const ls = try arena.alloc([]const u8, idx.len); for (idx, 0..) |s, i| { @@ -5628,28 +5756,37 @@ pub const Pardes = struct { } if (hasPdf(pane)) { const text = try p.ensurePdfText(pane); - var ls: std.ArrayList([]const u8) = .empty; + const count = std.mem.count(u8, text, "\n") + 1; + const ls = try arena.alloc([]const u8, count); var it = std.mem.splitScalar(u8, text, '\n'); - while (it.next()) |line| try ls.append(arena, line); - return .{ .lines = ls.items, .row0 = 0 }; + var n: usize = 0; + while (it.next()) |line| : (n += 1) ls[n] = line; + return .{ .lines = ls, .row0 = 0 }; } const rows = try term_pane.shellRows(p, pane); // the buffer may sit below the dump's last row (the grid's trailing // blanks are trimmed off), so run the loop to whichever ends later var last = rows.len; if (pane.ovl) |o| last = @max(last, @as(usize, @intCast(@max(0, o.row + o.rows)))); - var ls: std.ArrayList([]const u8) = .empty; + var count = last; + if (pane.ovl) |o| { + if (o.row >= 0 and @as(usize, @intCast(o.row)) < last) + count = count - @min(@as(usize, @intCast(o.rows)), last - @as(usize, @intCast(o.row))) + modal.lineCount(o.text); + } + const ls = try arena.alloc([]const u8, count); + var n: usize = 0; var g: usize = 0; while (g < last) : (g += 1) { if (pane.ovl) |o| if (o.row >= 0 and g == @as(usize, @intCast(o.row))) { var bit = std.mem.splitScalar(u8, o.text, '\n'); - while (bit.next()) |ln| try ls.append(arena, ln); + while (bit.next()) |ln| : (n += 1) ls[n] = ln; g += @intCast(o.rows - 1); continue; }; - try ls.append(arena, if (g < rows.len) rows[g] else ""); + ls[n] = if (g < rows.len) rows[g] else ""; + n += 1; } - return .{ .lines = ls.items, .row0 = 0 }; + return .{ .lines = ls[0..n], .row0 = 0 }; } fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor { @@ -5677,12 +5814,19 @@ pub const Pardes = struct { if (pane.file) |f| return f.content; if (hasPdf(pane)) return p.ensurePdfText(pane); const arena = p.scratch.allocator(); - var buf: std.ArrayList(u8) = .empty; + var total: usize = if (pl.lines.len > 0) pl.lines.len - 1 else 0; + for (pl.lines) |ln| total += ln.len; + const buf = try arena.alloc(u8, total); + var at: usize = 0; for (pl.lines, 0..) |ln, i| { - if (i > 0) try buf.append(arena, '\n'); - try buf.appendSlice(arena, ln); + if (i > 0) { + buf[at] = '\n'; + at += 1; + } + @memcpy(buf[at..][0..ln.len], ln); + at += ln.len; } - return buf.items; + return buf; } /// modal.hxOff and modal.hxPos, answered from the file's LINE INDEX when @@ -6095,9 +6239,13 @@ pub const Pardes = struct { /// it is — read back off the MARKER, exactly the way submitSearch decides /// which search is running. Null for `/`, Find, Grep and Rename. fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { - if (!pane.search_edit or pane.nsel_snap == 0) return null; - const tail = pane.tag_tail.items; - const armed = tail[@min(pane.search_at, tail.len)..]; + const prompt_at = switch (pane.prompt) { + .search => |at| at, + else => return null, + }; + if (pane.nsel_snap == 0) return null; + const tail = pane.tagSlice(); + const armed = tail[@min(prompt_at, tail.len)..]; const split = std.mem.startsWith(u8, armed, config.split_marker); if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null; @@ -6890,14 +7038,15 @@ pub const Pardes = struct { const f = pane.file orelse return; if (!output_pane.fileTraits(f.output).saves) return; p.seedTail(pane); - pane.pipe_at = @intCast(pane.tag_tail.items.len); - pane.tag_tail.appendSlice(p.gpa, config.pipe_marker) catch return; - pane.pipe_edit = true; + if (!pane.tag_init) return; + const prompt_at: u16 = @intCast(pane.tag_tail_len); + if (!pane.appendTag(config.pipe_marker)) return; + pane.prompt = .{ .pipe = prompt_at }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; pane.pending = 0; - pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len); + pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); } /// Snapshot command/cwd/ranges/selection bytes before emitting the id-only @@ -6907,8 +7056,8 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; const f = pane.file orelse return; if (!output_pane.fileTraits(f.output).saves) return; - const tail = pane.tag_tail.items; - const armed = tail[@min(pane.pipe_at, tail.len)..]; + const tail = pane.tagSlice(); + const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; if (!std.mem.startsWith(u8, armed, config.pipe_marker)) return; const command = armed[config.pipe_marker.len..]; if (command.len == 0) return; @@ -7039,7 +7188,7 @@ pub const Pardes = struct { return pane.cwdSlice(); } - const PdfSearchResult = struct { rows: usize = 0, anchor: ?usize = null }; + const PdfSearchResult = struct { bytes: usize = 0, rows: usize = 0, anchor: ?usize = null }; /// Ask MuPDF for each page's logical hits, so the result list and the /// quads used to highlight/reveal can never disagree. Locations spell @@ -7052,11 +7201,11 @@ pub const Pardes = struct { pane: *Pane, pat: []const u8, start: SearchStart, - out: *std.ArrayList(u8), - ) PdfSearchResult { + out: []u8, + ) !PdfSearchResult { if (comptime !pdf_enabled) return .{}; const pv = &(pane.pdf orelse return .{}); - p.setPdfSearchQuery(pv, pat); + try p.setPdfSearchQuery(pv, pat); const arena = p.scratch.allocator(); const shown = std.fs.path.basename(pv.path); var result: PdfSearchResult = .{}; @@ -7067,8 +7216,8 @@ pub const Pardes = struct { const snippet = pat[0..snippet_len]; for (0..pv.page_count) |page| { if (result.rows >= max_hits) break; - var found = pv.document.search(p.gpa, page, pat) catch continue; - defer found.deinit(p.gpa); + var found = try pv.document.search(p.pdf_gpa, page, pat); + defer found.deinit(p.pdf_gpa); // A native right-click selection identifies the occurrence the // user is already on. Compare hit centers against its oriented @@ -7088,13 +7237,15 @@ pub const Pardes = struct { for (0..found.hit_count) |hit_index| { if (result.rows >= max_hits) break; + const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ + shown, page + 1, hit_index + 1, snippet, + }); + if (row.len > out.len - result.bytes) return result; if (start == .cursor and (page < pv.page or (page == pv.page and cursor_hit != null and hit_index <= cursor_hit.?))) result.anchor = result.rows; - const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ - shown, page + 1, hit_index + 1, snippet, - }) catch return result; - out.appendSlice(arena, row) catch return result; + @memcpy(out[result.bytes..][0..row.len], row); + result.bytes += row.len; result.rows += 1; } } @@ -7106,16 +7257,16 @@ pub const Pardes = struct { /// typing, nothing that disappears. Enter/Esc are intercepted in handleKey. pub fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void { p.seedTail(pane); - pane.search_at = @intCast(pane.tag_tail.items.len); - pane.tag_tail.appendSlice(p.gpa, marker) catch return; - pane.search_edit = true; + if (!pane.tag_init) return; + const prompt_at: u16 = @intCast(pane.tag_tail_len); + if (!pane.appendTag(marker)) return; + pane.prompt = .{ .search = prompt_at }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; pane.pending = 0; - // tag_col is a RENDERED-tag column, so the end of the tail is past the - // prefix (search_at stays a tail offset — it slices tag_tail) - pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len); + // tag_col is a rendered-tag column; the prompt offset slices tag_tail. + pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); } /// Enter on an armed input: the pattern is everything past the marker's @@ -7129,8 +7280,8 @@ pub const Pardes = struct { // Enter re-runs the final pattern so a submit is one code path with // the preview and cannot disagree with what is on screen. if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); - const tail = pane.tag_tail.items; - const armed = tail[@min(pane.search_at, tail.len)..]; + const tail = pane.tagSlice(); + const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]); @@ -7140,7 +7291,7 @@ pub const Pardes = struct { .grep else .text; - p.runSearch(id, armed[slash + 1 ..], kind, .top); + p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| p.reportError(id, "search", err); } /// Fill this pane's results buffer with everything matching `pat_raw` @@ -7167,7 +7318,7 @@ pub const Pardes = struct { /// there and the first step goes to the next one (acme's button-3 walking a /// word). `/`, Find and Grep point at nothing, so their list is walked from /// the top, which is also the only place a list of OTHER files could start. - pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) void { + pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; @@ -7175,7 +7326,8 @@ pub const Pardes = struct { // where the pane lives: a file's directory, a shell's cwd — the walk // root, and the directory the results buffer is named in. const dir = paneDir(pane); - var out: std.ArrayList(u8) = .empty; + const out = try arena.alloc(u8, look.search_max_output_bytes); + var out_len: usize = 0; var nrows: usize = 0; var anchor: ?usize = null; if (kind == .grep) { @@ -7207,13 +7359,17 @@ pub const Pardes = struct { roots[nroots] = d; nroots += 1; } - for (roots[0..nroots]) |r| look.grep(arena, p.gpa, r, dir, pat, &out); - } else if (kind == .find) look.find(arena, dir, pat, &out) else if (hasPdf(pane)) { - const found = p.searchPdf(pane, pat, start, &out); + for (roots[0..nroots]) |r| + out_len += try look.grep(arena, p.gpa, r, dir, pat, out[out_len..]); + } else if (kind == .find) { + out_len = try look.find(arena, dir, pat, out); + } else if (hasPdf(pane)) { + const found = try p.searchPdf(pane, pat, start, out); + out_len = found.bytes; nrows = found.rows; anchor = found.anchor; } else { - const pl = p.paneCursorLines(pane) catch return; + const pl = try p.paneCursorLines(pane); // a real file names itself; a terminal or an output buffer has no path const has_path = if (pane.file) |f| f.output == null else false; var idbuf: [16]u8 = undefined; @@ -7222,7 +7378,7 @@ pub const Pardes = struct { const loc: []const u8 = if (has_path) std.fs.path.basename(pane.file.?.path) else - std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return; + std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong; // the hit at or before the cursor is the one you are ON, so arming // there makes the first step land on the NEXT one: a click on the // second `foo` goes to the third, not back to the first. @@ -7230,17 +7386,17 @@ pub const Pardes = struct { const cc: usize = @intCast(@max(0, pane.cur_col)); for (pl.lines, 0..) |ln, i| { const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; + const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{ + loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"), + }); + if (row.len > out.len - out_len) break; if (start == .cursor and (i < cl or (i == cl and at <= cc))) anchor = nrows; + @memcpy(out[out_len..][0..row.len], row); + out_len += row.len; nrows += 1; - // the row names the MATCH's span, not just its first cell, so - // n/N land ON the hit with it selected (config.range_sep) - const row = std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{ - loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"), - }) catch return; - out.appendSlice(arena, row) catch return; } } - const content = p.gpa.dupe(u8, out.items) catch return; + const content = try p.gpa.dupe(u8, out[0..out_len]); // the buffer records WHICH search filled it, pattern and all: Find and // Grep are builtins (words you can execute), the bare `/` is a key const from: output_pane.Origin = switch (kind) { @@ -7253,7 +7409,7 @@ pub const Pardes = struct { // below this pane. Everything about landing the rows — which open // buffer counts as this same search, keeping a refill's place, opening // fresh when there is none — is output_pane.fillResults. - output_pane.fillResults(p, id, dir, from, pat, content, anchor); + try output_pane.fillResults(p, id, dir, from, pat, content, anchor); } /// n/N: step to the next/previous row of this pane's results buffer and @@ -7398,7 +7554,8 @@ pub const Pardes = struct { // Landing the rows is runSearch's path exactly, keyed on the KIND // rather than the argument (fillResults reads that off the origin). // Why the refill is not optional here: docs/lsp.md. - output_pane.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null); + output_pane.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null) catch |err| + p.reportError(w.pane, "language response", err); } /// n/N on a terminal pane: a MOTION over lookable tokens. Select the @@ -7458,17 +7615,7 @@ pub const Pardes = struct { const EditText = struct { text: []u8, row0: i32 }; - /// The pane's editable text and the absolute surface row its first line - /// sits at: a file's whole content at row 0, or a terminal's edit buffer - /// at its anchor. Terminals materialize the buffer here on first use and - /// grow it whenever an edit reaches past an end, seeding from — and from - /// then on standing in for — the shell rows it swallows. - /// `col` is the caller's cursor column: row `lo` is padded out to it with - /// spaces, because a terminal cursor may sit past the end of a row (acme: - /// you click anywhere and type there, which is what the insertion runs - /// did by padding at render time). Insert-mode callers pass their column; - /// everything else passes -1 and leaves the text alone. - /// Null if the pane holds no editable text (an image or PDF projection). + /// Materialize or extend a terminal edit buffer with one exact allocation. fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; if (pane.image != null or hasPdf(pane)) return null; @@ -7477,29 +7624,49 @@ pub const Pardes = struct { const fresh = pane.ovl == null; const old: Ovl = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); - const up = old.row - want_lo; // shell rows to prepend - const down = want_hi - (old.row + lines - 1); // surface rows past its end + const up = old.row - want_lo; + const down = want_hi - (old.row + lines - 1); if (fresh or up > 0 or down > 0) { const rows = term_pane.shellRows(p, pane) catch return null; - var buf: std.ArrayList(u8) = .empty; - defer buf.deinit(p.gpa); const row0 = old.row - @max(0, up); + var total: usize = 0; var g: i32 = row0; - while (g < old.row) : (g += 1) { - buf.appendSlice(p.gpa, if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else "") catch return null; - buf.append(p.gpa, '\n') catch return null; - } - if (fresh) - buf.appendSlice(p.gpa, if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "") catch return null + while (g < old.row) : (g += 1) + total += (if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)].len else 0) + 1; + total += if (fresh) + (if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)].len else 0) else - buf.appendSlice(p.gpa, old.text) catch return null; + old.text.len; var k: i32 = 0; while (k < down) : (k += 1) { const src = old.row + old.rows + k; - buf.append(p.gpa, '\n') catch return null; - buf.appendSlice(p.gpa, if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)] else "") catch return null; + total += 1 + if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)].len else 0; + } + const text = p.gpa.alloc(u8, total) catch return null; + var at: usize = 0; + g = row0; + while (g < old.row) : (g += 1) { + const src = if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else ""; + @memcpy(text[at..][0..src.len], src); + at += src.len; + text[at] = '\n'; + at += 1; + } + const middle = if (fresh) + (if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "") + else + old.text; + @memcpy(text[at..][0..middle.len], middle); + at += middle.len; + k = 0; + while (k < down) : (k += 1) { + const src_row = old.row + old.rows + k; + const src = if (@as(usize, @intCast(src_row)) < rows.len) rows[@intCast(src_row)] else ""; + text[at] = '\n'; + at += 1; + @memcpy(text[at..][0..src.len], src); + at += src.len; } - const text = buf.toOwnedSlice(p.gpa) catch return null; if (pane.ovl) |o| p.gpa.free(o.text); pane.ovl = .{ .row = row0, .rows = old.rows + @max(0, up) + @max(0, down), .text = text }; } @@ -7596,39 +7763,35 @@ pub const Pardes = struct { .open_below, .open_above => { pane.vsel.active = false; const below = where == .open_below; - // helix `open`: the new line copies the current line's full - // indent LEVELS; <n>o opens n lines, cursor on the first const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else pane.cur_row; const eb = p.editText(pane, abs, abs, -1) orelse return; const row: usize = @intCast(@max(0, abs - eb.row0)); const ind = modal.hxIndentString(modal.lineSlice(eb.text, row)); const arena = p.scratch.allocator(); - var block: std.ArrayList(u8) = .empty; + const block_len = std.math.mul(usize, cnt, ind.len + 1) catch return; + const block = arena.alloc(u8, block_len) catch return; + var block_at: usize = 0; for (0..cnt) |_| { if (below) { - block.append(arena, '\n') catch return; - block.appendSlice(arena, ind) catch return; - } else { - block.appendSlice(arena, ind) catch return; - block.append(arena, '\n') catch return; + block[block_at] = '\n'; + block_at += 1; + } + @memcpy(block[block_at..][0..ind.len], ind); + block_at += ind.len; + if (!below) { + block[block_at] = '\n'; + block_at += 1; } } const at: modal.Cursor = if (below) .{ .row = row, .col = modal.lineSlice(eb.text, row).len } else .{ .row = row, .col = 0 }; - const new = modal.insertAt(p.gpa, eb.text, at, block.items) catch return; + const new = modal.insertAt(p.gpa, eb.text, at, block) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0; pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; - // helix `open` with a COUNT leaves one cursor per line it - // opened — `3o` then typing fills all three. The lines are - // consecutive and start at the cursor, so this is just cnt - // points a row apart, the first one primary. - // (Inside a replay pass the extra ranges cannot be created: - // a pass produces exactly one range. `3o` at several cursors - // therefore opens the lines but keeps the cursors it had.) if (cnt > 1 and !p.multi_on) opened: { const pl2 = p.paneCursorLines(pane) catch break :opened; const t2 = p.flatSurface(pane, pl2) catch break :opened; @@ -7904,7 +8067,6 @@ pub const Pardes = struct { fn vselBounds(pane: *Pane) Bounds { return cellBounds(.{ .row = pane.cur_row, .col = pane.cur_col, .arow = pane.vsel.row, .acol = pane.vsel.col }); } - /// the char selection as text. Read off the pane's SURFACE (file content / /// terminal shell rows + edit buffer), not the rendered body: a yank of a /// whole line has to carry its newline, the way a file's does, or p/P @@ -7920,14 +8082,28 @@ pub const Pardes = struct { fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 { const arena = p.scratch.allocator(); const pl = p.paneCursorLines(pane) catch return ""; - var out: std.ArrayList(u8) = .empty; + const rows_count: usize = @intCast(@max(0, r1 - r0 + 1)); + var total: usize = rows_count -| 1; var i = r0; while (i <= r1) : (i += 1) { - if (i > r0) out.append(arena, '\n') catch return ""; if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) - out.appendSlice(arena, pl.lines[@intCast(i)]) catch return ""; + total += pl.lines[@intCast(i)].len; + } + const out = arena.alloc(u8, total) catch return ""; + var at: usize = 0; + i = r0; + while (i <= r1) : (i += 1) { + if (i > r0) { + out[at] = '\n'; + at += 1; + } + if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) { + const line = pl.lines[@intCast(i)]; + @memcpy(out[at..][0..line.len], line); + at += line.len; + } } - return out.items; + return out; } /// the whole pane surface as flat text, absolute rows from 0 (read-only: @@ -8003,18 +8179,14 @@ pub const Pardes = struct { } fn normalYank(p: *Pardes, pane: *Pane) void { - pane.select = false; // helix yank exits select mode, selection KEPT + pane.select = false; if (pane.vsel.active) { - // helix: yank moves neither the cursor nor the selection p.setYank(p.vselText(pane)); return; } if (pane.msel.active) { p.setYank(p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1))); } else { - // helix: a bare cursor IS a 1-wide selection — yank the char - // under it, file and terminal alike (the line-yank vim-ism died - // with the motion model). vselText already speaks both. const save = pane.vsel; pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = false }; p.setYank(p.vselText(pane)); @@ -8038,9 +8210,10 @@ pub const Pardes = struct { const arena = p.scratch.allocator(); var y: []const u8 = y0; if (cnt > 1) { - var buf: std.ArrayList(u8) = .empty; - for (0..cnt) |_| buf.appendSlice(arena, y0) catch return; - y = buf.items; + const total = std.math.mul(usize, cnt, y0.len) catch return; + const buf = arena.alloc(u8, total) catch return; + for (0..cnt) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0); + y = buf; } const b: Bounds = if (pane.vsel.active) vselBounds(pane) @@ -8102,9 +8275,6 @@ pub const Pardes = struct { if (pane.vsel.active) { const b = vselBounds(pane); const eb = p.editTextEol(pane, b) orelse return; - // helix: a LINEWISE selection (whole lines incl. their final - // newline) deletes the lines and opens a fresh one above, - // copying the indent of the line that moves up const hi_len = modal.lineSlice(eb.text, @intCast(@max(0, b.hi_row - eb.row0))).len; if (b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len) { p.setYank(p.vselText(pane)); @@ -8149,17 +8319,23 @@ pub const Pardes = struct { const r1 = @max(pane.msel.r0, pane.msel.r1); p.pushUndo(pane); const eb = p.editText(pane, r0, r1 + 1, -1) orelse return; - // keep one empty line at r0, drop the rest; yank the removed - var buf: std.ArrayList(u8) = .empty; const arena = p.scratch.allocator(); const lo: usize = @intCast(@max(0, r0 - eb.row0)); const hi: usize = @intCast(@max(0, r1 - eb.row0)); - var r: usize = lo; - while (r <= hi) : (r += 1) { - if (r > lo) buf.append(arena, '\n') catch break; - buf.appendSlice(arena, modal.lineSlice(eb.text, r)) catch break; + var total: usize = hi - lo; + for (lo..hi + 1) |r| total += modal.lineSlice(eb.text, r).len; + const buf = arena.alloc(u8, total) catch return; + var at: usize = 0; + for (lo..hi + 1) |r| { + if (r > lo) { + buf[at] = '\n'; + at += 1; + } + const line = modal.lineSlice(eb.text, r); + @memcpy(buf[at..][0..line.len], line); + at += line.len; } - p.setYank(buf.items); + p.setYank(buf); var body: []u8 = eb.text; if (hi > lo) { const d = modal.deleteLines(p.gpa, body, lo + 1, hi) catch { @@ -8310,16 +8486,22 @@ pub const Pardes = struct { /// apply a change list. gpa-owned result. fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 { - var out: std.ArrayList(u8) = .empty; - errdefer out.deinit(p.gpa); + var total = text.len; + for (chs) |ch| total = total - (ch.to - ch.from) + ch.ins.len; + const out = try p.gpa.alloc(u8, total); + errdefer p.gpa.free(out); var copied: usize = 0; + var at: usize = 0; for (chs) |ch| { - try out.appendSlice(p.gpa, text[copied..ch.from]); - try out.appendSlice(p.gpa, ch.ins); + const unchanged = text[copied..ch.from]; + @memcpy(out[at..][0..unchanged.len], unchanged); + at += unchanged.len; + @memcpy(out[at..][0..ch.ins.len], ch.ins); + at += ch.ins.len; copied = ch.to; } - try out.appendSlice(p.gpa, text[copied..]); - return out.toOwnedSlice(p.gpa); + @memcpy(out[at..], text[copied..]); + return out; } /// `J`: helix join_selections — join the selection's line span (a bare @@ -8340,25 +8522,27 @@ pub const Pardes = struct { if (span.start == end) end = @min(end + 1, nlines - 1); if (end <= span.start) return; const arena = p.scratch.allocator(); - var chs: std.ArrayList(TextChange) = .empty; + const chs = arena.alloc(TextChange, end - span.start) catch return; + var chs_len: usize = 0; var l = span.start; while (l < end) : (l += 1) { const from = modal.hxLineEndIdx(text, l); var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; const sep: []const u8 = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) "" else " "; - chs.append(arena, .{ .from = from, .to = to, .ins = sep }) catch return; + chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; + chs_len += 1; } - if (chs.items.len == 0) return; + if (chs_len == 0) return; p.pushUndo(pane); const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; - const new = p.applyChanges(text, chs.items) catch return; - const nc = mapThroughChanges(chs.items, cur_off); - const na = mapThroughChanges(chs.items, anc_off); + const new = p.applyChanges(text, chs[0..chs_len]) catch return; + const nc = mapThroughChanges(chs[0..chs_len], cur_off); + const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); const cc = modal.hxPos(new, nc); const ac = modal.hxPos(new, na); @@ -8390,7 +8574,8 @@ pub const Pardes = struct { // one run of spaces, sliced per line: `>` never inserts more than this const pad = arena.alloc(u8, modal.INDENT_W * cnt) catch return; @memset(pad, ' '); - var chs: std.ArrayList(TextChange) = .empty; + const chs = arena.alloc(TextChange, span.end - span.start + 1) catch return; + var chs_len: usize = 0; var l = span.start; while (l <= span.end) : (l += 1) { const ls = modal.lineStartOffset(text, l); @@ -8400,7 +8585,8 @@ pub const Pardes = struct { if (nw == line.len) continue; // blank lines stay blank (helix) if (add) { const ins = modal.INDENT_W * cnt - (nw % modal.INDENT_W); - chs.append(arena, .{ .from = ls, .to = ls, .ins = pad[0..ins] }) catch return; + chs[chs_len] = .{ .from = ls, .to = ls, .ins = pad[0..ins] }; + chs_len += 1; } else { const want = modal.INDENT_W * cnt; var w: usize = 0; @@ -8414,19 +8600,22 @@ pub const Pardes = struct { pos += 1; if (w >= want) break; } - if (pos > 0) chs.append(arena, .{ .from = ls, .to = ls + pos, .ins = "" }) catch return; + if (pos > 0) { + chs[chs_len] = .{ .from = ls, .to = ls + pos, .ins = "" }; + chs_len += 1; + } } } - if (chs.items.len == 0) return; + if (chs_len == 0) return; p.pushUndo(pane); const cur_off = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; - const new = p.applyChanges(text, chs.items) catch return; - const nc = mapThroughChanges(chs.items, cur_off); - const na = mapThroughChanges(chs.items, anc_off); + const new = p.applyChanges(text, chs[0..chs_len]) catch return; + const nc = mapThroughChanges(chs[0..chs_len], cur_off); + const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); const cc = modal.hxPos(new, nc); const ac = modal.hxPos(new, na); @@ -8480,13 +8669,17 @@ pub const Pardes = struct { const arena = p.scratch.allocator(); // the lines the ranges cover, each ONE ONCE and in order (helix's // min_next_line: two cursors on one line comment it once) - var lines: std.ArrayList(usize) = .empty; + const lines = arena.alloc(usize, nlines) catch return; + var lines_len: usize = 0; var next: usize = 0; for (rs[0..got.n]) |r| { const span = rangeLineSpan(text, r); var l = @max(span.start, next); const end = @min(span.end + 1, nlines); - while (l < end) : (l += 1) lines.append(arena, l) catch return; + while (l < end) : (l += 1) { + lines[lines_len] = l; + lines_len += 1; + } next = @max(next, end); } // which token: the file's EXTENSION, which is the same thing @@ -8505,7 +8698,7 @@ pub const Pardes = struct { var indent: usize = std.math.maxInt(usize); var margin: usize = 1; var live: usize = 0; - for (lines.items) |l| { + for (lines[0..lines_len]) |l| { const line = text[modal.lineStartOffset(text, l)..modal.hxLineEndIdx(text, l)]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; @@ -8516,8 +8709,9 @@ pub const Pardes = struct { } if (live == 0) return; // nothing but blank lines const ins = std.fmt.allocPrint(arena, "{s} ", .{token}) catch return; - var chs: std.ArrayList(TextChange) = .empty; - for (lines.items) |l| { + const chs = arena.alloc(TextChange, lines_len) catch return; + var chs_len: usize = 0; + for (lines[0..lines_len]) |l| { const ls = modal.lineStartOffset(text, l); const le = modal.hxLineEndIdx(text, l); const line = text[ls..le]; @@ -8525,20 +8719,21 @@ pub const Pardes = struct { const at = ls + indent; // the @min can never bind: margin is 1 only when every line has a // space after its own token, which is a byte past `at + token.len` - chs.append(arena, if (commented) + chs[chs_len] = if (commented) .{ .from = at, .to = @min(at + token.len + margin, le), .ins = "" } else - .{ .from = at, .to = at, .ins = ins }) catch return; + .{ .from = at, .to = at, .ins = ins }; + chs_len += 1; } p.pushUndo(pane); // one edit, and the WHOLE selection rides through it (helix maps the // selection with the transaction) var cells: [MAX_SELS]SelRange = undefined; - const new = p.applyChanges(text, chs.items) catch return; + const new = p.applyChanges(text, chs[0..chs_len]) catch return; for (rs[0..got.n], 0..) |r, i| { const c = rangeCells(new, .{ - .anchor = mapThroughChanges(chs.items, r.anchor), - .head = mapThroughChanges(chs.items, r.head), + .anchor = mapThroughChanges(chs[0..chs_len], r.anchor), + .head = mapThroughChanges(chs[0..chs_len], r.head), }); const cc = modal.hxPos(new, c.cur); const ac = modal.hxPos(new, c.anc); @@ -8750,99 +8945,99 @@ pub const Pardes = struct { pane.ensureCursorVisible(); } + fn pushFileHistory(p: *Pardes, slots: *[UNDO_MAX]FileSnap, len: *usize, snap: FileSnap) void { + if (len.* == slots.len) { + p.gpa.free(slots[0].content); + std.mem.copyForwards(FileSnap, slots[0 .. slots.len - 1], slots[1..]); + len.* -= 1; + } + slots[len.*] = snap; + len.* += 1; + } + + fn pushEditHistory(p: *Pardes, slots: *[EDIT_UNDO_MAX]EditSnap, len: *usize, snap: EditSnap) void { + if (len.* == slots.len) { + if (slots[0].ovl) |o| p.gpa.free(o.text); + std.mem.copyForwards(EditSnap, slots[0 .. slots.len - 1], slots[1..]); + len.* -= 1; + } + slots[len.*] = snap; + len.* += 1; + } + fn pushUndo(p: *Pardes, pane: *Pane) void { // one keystroke, one undo step — even when it edited at ten cursors. - // (The content-equality guard below cannot do this on its own: by the - // second pass the buffer HAS changed, so it would push again.) if (p.multi_on and !p.multi_first) return; if (pane.file) |*f| { - if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1].content, f.content)) return; + if (f.undo_len > 0 and std.mem.eql(u8, f.undo[f.undo_len - 1].content, f.content)) return; const snap: FileSnap = .{ .content = p.gpa.dupe(u8, f.content) catch return, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }; - f.undo.append(p.gpa, snap) catch { - p.gpa.free(snap.content); - return; - }; - if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0).content); - for (f.redo.items) |s| p.gpa.free(s.content); - f.redo.clearRetainingCapacity(); + p.pushFileHistory(&f.undo, &f.undo_len, snap); + for (f.redo[0..f.redo_len]) |s| p.gpa.free(s.content); + f.redo_len = 0; } else { const cur = pane.ovl orelse Ovl{ .rows = 0 }; - if (pane.ed_undo.getLastOrNull()) |top| { + if (pane.ed_undo_len > 0) { + const top = pane.ed_undo[pane.ed_undo_len - 1]; const same = if (top.ovl) |o| pane.ovl != null and o.row == cur.row and o.rows == cur.rows and std.mem.eql(u8, o.text, cur.text) else pane.ovl == null; if (same) return; } const snap = term_pane.snap(p, pane) orelse return; - pane.ed_undo.append(p.gpa, snap) catch { - if (snap.ovl) |o| p.gpa.free(o.text); - return; - }; - if (pane.ed_undo.items.len > EDIT_UNDO_MAX) { - const drop = pane.ed_undo.orderedRemove(0); - if (drop.ovl) |o| p.gpa.free(o.text); - } - for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); - pane.ed_redo.clearRetainingCapacity(); + p.pushEditHistory(&pane.ed_undo, &pane.ed_undo_len, snap); + for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); + pane.ed_redo_len = 0; } } fn doUndo(p: *Pardes, pane: *Pane) void { if (pane.file) |*f| { - if (f.undo.items.len == 0) return; + if (f.undo_len == 0) return; const cur: FileSnap = .{ .content = p.gpa.dupe(u8, f.content) catch return, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }; - f.redo.append(p.gpa, cur) catch { - p.gpa.free(cur.content); - return; - }; - const prev = f.undo.pop() orelse return; + p.pushFileHistory(&f.redo, &f.redo_len, cur); + f.undo_len -= 1; + const prev = f.undo[f.undo_len]; file_pane.setContent(p, f, prev.content); file_pane.restoreSnap(pane, f, prev); return; } - if (pane.ed_undo.items.len == 0) return; + if (pane.ed_undo_len == 0) return; const cur = term_pane.snap(p, pane) orelse return; - pane.ed_redo.append(p.gpa, cur) catch { - if (cur.ovl) |o| p.gpa.free(o.text); - return; - }; - term_pane.restoreSnap(p, pane, pane.ed_undo.pop() orelse return); + p.pushEditHistory(&pane.ed_redo, &pane.ed_redo_len, cur); + pane.ed_undo_len -= 1; + term_pane.restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]); } fn doRedo(p: *Pardes, pane: *Pane) void { if (pane.file) |*f| { - if (f.redo.items.len == 0) return; + if (f.redo_len == 0) return; const cur: FileSnap = .{ .content = p.gpa.dupe(u8, f.content) catch return, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }; - f.undo.append(p.gpa, cur) catch { - p.gpa.free(cur.content); - return; - }; - const next = f.redo.pop() orelse return; + p.pushFileHistory(&f.undo, &f.undo_len, cur); + f.redo_len -= 1; + const next = f.redo[f.redo_len]; file_pane.setContent(p, f, next.content); file_pane.restoreSnap(pane, f, next); return; } - if (pane.ed_redo.items.len == 0) return; + if (pane.ed_redo_len == 0) return; const cur = term_pane.snap(p, pane) orelse return; - pane.ed_undo.append(p.gpa, cur) catch { - if (cur.ovl) |o| p.gpa.free(o.text); - return; - }; - term_pane.restoreSnap(p, pane, pane.ed_redo.pop() orelse return); + p.pushEditHistory(&pane.ed_undo, &pane.ed_undo_len, cur); + pane.ed_redo_len -= 1; + term_pane.restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]); } /// Something outside pardes rewrote a file we have open. Commit the buffer @@ -9274,7 +9469,7 @@ pub const Pardes = struct { // row is ignored (a tag is one line) and the anchor stays where // the press put it, so this is the mouse's `v` const pane = p.panes[d.id] orelse return; - const end: i32 = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len); + const end: i32 = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len); const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); pane.tag_col = @intCast(std.math.clamp(c, 0, end)); pane.tag_sel = pane.tag_col != pane.tag_anchor; @@ -9854,6 +10049,7 @@ pub const Pardes = struct { errdefer p.gpa.free(path); const pane = try p.newDocPane(id); pane.file = .{ .path = path, .content = content }; + pane.kind = .file; pane.cur_pinned = true; return pane; } @@ -9871,6 +10067,7 @@ pub const Pardes = struct { p.panes[0] = null; const pane = try p.newDocPane(0); pane.file = .{ .path = path, .content = copy }; + pane.kind = .file; pane.cur_pinned = true; p.active = 0; p.sync(); @@ -9878,14 +10075,15 @@ pub const Pardes = struct { } fn openImageView(p: *Pardes, id: usize, path: []const u8) !*Pane { - const path_copy = try p.gpa.dupe(u8, path); - errdefer p.gpa.free(path_copy); + const path_copy = try p.image_gpa.dupe(u8, path); + errdefer p.image_gpa.free(path_copy); const pane = try p.newDocPane(id); // Native pixels are the preference, not a capability observed at open // time. drawImage dynamically falls back while a shell has no native // image support; this matters because kitty detection finishes after // argv images and restored panes have already been constructed. pane.image = .{ .path = path_copy }; + pane.kind = .image; return pane; } @@ -9893,16 +10091,16 @@ pub const Pardes = struct { if (comptime !pdf_enabled) return error.PdfDisabled; var document = try pdf_impl.Document.open(path); errdefer document.deinit(); - const page_sizes = try p.gpa.alloc(pdf_impl.PageSize, document.pages); - errdefer p.gpa.free(page_sizes); + const page_sizes = try p.pdf_gpa.alloc(pdf_impl.PageSize, document.pages); + errdefer p.pdf_gpa.free(page_sizes); for (page_sizes, 0..) |*size, page_number| size.* = try document.pageSize(page_number); - const page_starts = try p.gpa.alloc(u64, document.pages); - errdefer p.gpa.free(page_starts); - const page_heights = try p.gpa.alloc(u32, document.pages); - errdefer p.gpa.free(page_heights); - const path_copy = try p.gpa.dupe(u8, path); - errdefer p.gpa.free(path_copy); + const page_starts = try p.pdf_gpa.alloc(u64, document.pages); + errdefer p.pdf_gpa.free(page_starts); + const page_heights = try p.pdf_gpa.alloc(u32, document.pages); + errdefer p.pdf_gpa.free(page_heights); + const path_copy = try p.pdf_gpa.dupe(u8, path); + errdefer p.pdf_gpa.free(path_copy); const pane = try p.newDocPane(id); const page = if (page_one_based > 0) @min(page_one_based - 1, document.pages - 1) @@ -9917,6 +10115,7 @@ pub const Pardes = struct { .page_starts = page_starts, .page_heights = page_heights, }; + pane.kind = .pdf; pane.cur_pinned = true; return pane; } @@ -9929,7 +10128,7 @@ pub const Pardes = struct { if (comptime !pdf_enabled) return; _ = p; pv.render_tried = false; - for (pv.rasters.items) |*raster| if (raster.valid and raster.page == pv.page) { + for (pv.rasters[0..pv.rasters_len]) |*raster| if (raster.valid and raster.page == pv.page) { raster.tried = false; break; }; @@ -9942,21 +10141,21 @@ pub const Pardes = struct { if (comptime !pdf_enabled) return; _ = p; pv.render_tried = false; - for (pv.rasters.items) |*raster| { + for (pv.rasters[0..pv.rasters_len]) |*raster| { if (raster.valid) raster.tried = false; } } fn releasePdfRaster(p: *Pardes, raster: *PdfRaster) void { if (comptime !pdf_enabled) return; - if (raster.rgba.len > 0) p.gpa.free(raster.rgba); + if (raster.rgba.len > 0) p.pdf_gpa.free(raster.rgba); raster.* = .{}; } fn dropPdfRaster(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; - for (pv.rasters.items) |*raster| p.releasePdfRaster(raster); - pv.rasters.clearRetainingCapacity(); + for (pv.rasters[0..pv.rasters_len]) |*raster| p.releasePdfRaster(raster); + pv.rasters_len = 0; pv.rgba = &.{}; pv.iw = 0; pv.ih = 0; @@ -9966,15 +10165,15 @@ pub const Pardes = struct { fn dropPdfSearchResults(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; - if (pv.search_results) |*results| results.deinit(p.gpa); + if (pv.search_results) |*results| results.deinit(p.pdf_gpa); pv.search_results = null; } fn dropPdfSelection(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; - if (pv.selection) |*selection| selection.deinit(p.gpa); + if (pv.selection) |*selection| selection.deinit(p.pdf_gpa); pv.selection = null; - if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text); + if (pv.selection_text.len > 0) p.pdf_gpa.free(pv.selection_text); pv.selection_text = &.{}; pv.selection_anchor = null; pv.selection_head = null; @@ -10001,7 +10200,7 @@ pub const Pardes = struct { p.dropPdfSearchResults(pv); p.dropPdfSelection(pv); clearPdfDrag(pv); - if (pv.text.len > 0) p.gpa.free(pv.text); + if (pv.text.len > 0) p.pdf_gpa.free(pv.text); pv.text = &.{}; pv.text_tried = false; pv.text_scroll = 0; @@ -10012,11 +10211,11 @@ pub const Pardes = struct { /// Replace the full PDF query and discard only query-derived page state. /// Resolution is lazy in drawPdf; fit and pan remain placement-only state. - fn setPdfSearchQuery(p: *Pardes, pv: *PdfView, query: []const u8) void { + fn setPdfSearchQuery(p: *Pardes, pv: *PdfView, query: []const u8) !void { if (comptime !pdf_enabled) return; if (std.mem.eql(u8, pv.search_query, query)) return; - const owned = p.gpa.dupe(u8, query) catch return; - if (pv.search_query.len > 0) p.gpa.free(pv.search_query); + const owned = try p.pdf_gpa.dupe(u8, query); + if (pv.search_query.len > 0) p.pdf_gpa.free(pv.search_query); pv.search_query = owned; pv.search_hit = 0; p.dropPdfSearchResults(pv); @@ -10031,7 +10230,7 @@ pub const Pardes = struct { pv.highlights_dirty = false; p.dropPdfSearchResults(pv); if (pv.search_query.len == 0) return; - const results = pv.document.search(p.gpa, pv.page, pv.search_query) catch return; + const results = pv.document.search(p.pdf_gpa, pv.page, pv.search_query) catch return; pv.search_hit = if (results.hit_count == 0) 0 else @@ -10170,7 +10369,7 @@ pub const Pardes = struct { if (comptime !pdf_enabled) return null; if (!pv.outline_tried) { pv.outline_tried = true; - pv.outline = pv.document.outline(p.gpa) catch null; + pv.outline = pv.document.outline(p.pdf_gpa) catch null; } return if (pv.outline) |*outline| outline else null; } @@ -10472,7 +10671,7 @@ pub const Pardes = struct { fn pdfRasterForPage(pv: *PdfView, page: usize) ?*PdfRaster { if (comptime !pdf_enabled) return null; - for (pv.rasters.items) |*raster| + for (pv.rasters[0..pv.rasters_len]) |*raster| if (raster.valid and raster.page == page) return raster; return null; } @@ -10650,14 +10849,14 @@ pub const Pardes = struct { pv.selection_anchor != null and pv.selection_head != null and pv.selection_anchor.?.x == start.x and pv.selection_anchor.?.y == start.y and pv.selection_head.?.x == end.x and pv.selection_head.?.y == end.y) return .unchanged; - var selection = pv.document.select(p.gpa, pv.page, start, end) catch return .failed; + var selection = pv.document.select(p.pdf_gpa, pv.page, start, end) catch return .failed; const text = pv.document.copySelection( - p.gpa, + p.pdf_gpa, pv.page, selection.start, selection.end, ) catch { - selection.deinit(p.gpa); + selection.deinit(p.pdf_gpa); return .failed; }; @@ -10729,31 +10928,25 @@ pub const Pardes = struct { fn pdfHighlights(p: *Pardes, pv: *PdfView) ![]const PdfHighlight { if (comptime !pdf_enabled) return &.{}; - var highlights: std.ArrayList(PdfHighlight) = .empty; const arena = p.scratch.allocator(); + const search_len = if (pv.search_results) |results| results.quads.len else 0; + const selection_len = if (pv.selection) |selection| selection.quads.len else 0; + const highlights = try arena.alloc(PdfHighlight, search_len + selection_len); + var n: usize = 0; if (pv.search_results) |results| { - try highlights.ensureUnusedCapacity(arena, results.quads.len); - for (results.quads) |item| highlights.appendAssumeCapacity(pdf_impl.Highlight.init( - item.quad, - .{ 0xff, 0xd5, 0x4f, 0x70 }, - .search, - )); + for (results.quads) |item| { + highlights[n] = pdf_impl.Highlight.init(item.quad, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search); + n += 1; + } } if (pv.selection) |selection| { - try highlights.ensureUnusedCapacity(arena, selection.quads.len); - // a page is not a grid of cells, so this is the one selection that - // paints as a translucent wash over rendered pixels rather than as - // a cell's background — but it is the same selection, so it is the - // same colour. The alpha stays hardcoded: it is what makes the - // glyphs underneath readable, and no theme has an opinion on it. const sel = p.theme().sel_bg; - for (selection.quads) |quad| highlights.appendAssumeCapacity(pdf_impl.Highlight.init( - quad, - .{ sel[0], sel[1], sel[2], 0x78 }, - .selection, - )); + for (selection.quads) |quad| { + highlights[n] = pdf_impl.Highlight.init(quad, .{ sel[0], sel[1], sel[2], 0x78 }, .selection); + n += 1; + } } - return highlights.items; + return highlights; } /// Move one normalized pan axis by a distance measured in DISPLAY pixels. @@ -11227,7 +11420,10 @@ pub const Pardes = struct { // where the click put the cursor. Asking again is free: the // same pattern refills its own list rather than opening a // second, so clicking a word repeatedly walks its hits. - p.runSearch(id, trimmed, .text, .cursor); + p.runSearch(id, trimmed, .text, .cursor) catch |err| { + p.reportError(id, "search", err); + return; + }; const at = pane.search_row; _ = p.searchStep(id, 1); // past the last hit, back to the first: acme's search is a @@ -11387,17 +11583,7 @@ pub const Pardes = struct { /// ran as a command nobody typed. A selection is only an argument to /// something that has somewhere to put it. fn takesArg(b: Builtin) bool { - switch (b) { - .Restore, .Find, .Grep, .Rename, .WsSymbols, .Theme, .Shell, .Look, .Exec => return true, - // Font is a BRANCH and not another name in that prong for the - // reason output_pane.traits has the same shape: it is not in the - // enum at all off the gui, and a comptime-false `if` is the one - // form that is not analysed there. - else => { - if (platform == .gui) if (b == .Font) return true; - return false; - }, - } + return builtins.registry.takesArg(b); } fn applyStartupConfig(p: *Pardes) void { @@ -11420,7 +11606,7 @@ pub const Pardes = struct { if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse return; const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; - inline for (comptime builtins.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c); + inline for (comptime builtins.registry.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c); } /// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a @@ -11509,10 +11695,11 @@ pub const Pardes = struct { pub fn dumpState(p: *Pardes) !void { const arena = p.scratch.allocator(); var slot_to_pane: [MAX_PANES]?usize = @splat(null); - var panes: std.ArrayList(dump.Pane) = .empty; + var panes: [MAX_PANES]dump.Pane = undefined; + var panes_len: usize = 0; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; - slot_to_pane[id] = panes.items.len; + slot_to_pane[id] = panes_len; const tag = try p.tagText(arena, pane); const body = try p.bodyText(arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); @@ -11536,7 +11723,7 @@ pub const Pardes = struct { }, } else if (hasPdf(pane)) blk: { if (comptime !pdf_enabled) unreachable; - const pv = pane.pdf.?; + const pv = &pane.pdf.?; const bytes = look.readFile(arena, pv.path) catch ""; // Keep the dump schema backwards-compatible: a raster-backed // document rides the existing binary image record, while its @@ -11574,7 +11761,9 @@ pub const Pardes = struct { // the stream text: whole history, prompts hidden outside tty // mode, the edit buffer standing in for the rows it covers const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); - var stream_text: std.ArrayList(u8) = .empty; + const extra = if (pane.ovl) |o| o.text.len else 0; + const stream_buf = try arena.alloc(u8, full.len + extra); + var stream_len: usize = 0; var lines = std.mem.splitAny(u8, full, "\n"); var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); var row: i32 = 0; @@ -11588,13 +11777,19 @@ pub const Pardes = struct { skip -= 1; continue; } - if (row > 0) try stream_text.append(arena, '\n'); + if (row > 0) { + stream_buf[stream_len] = '\n'; + stream_len += 1; + } if (pane.mode != .tty) if (pane.ovl) |o| if (row == o.row) { - try stream_text.appendSlice(arena, o.text); + @memcpy(stream_buf[stream_len..][0..o.text.len], o.text); + stream_len += o.text.len; skip = o.rows - 1; continue; }; - try stream_text.appendSlice(arena, if (is_prompt) "" else raw); + const shown = if (is_prompt) "" else raw; + @memcpy(stream_buf[stream_len..][0..shown.len], shown); + stream_len += shown.len; } break :blk .{ .kind = .terminal, @@ -11606,8 +11801,8 @@ pub const Pardes = struct { .vweight = pane.vweight, .terminal = .{ .cwd = try arena.dupe(u8, pane.cwdSlice()), - .stream = stream_text.items, - .stream_b64 = try dump.encodeBytes(arena, pane.tty_stream.items), + .stream = stream_buf[0..stream_len], + .stream_b64 = try dump.encodeBytes(arena, try pane.ttyReplayBytes(arena)), .cursor = .{ .col = pane.vt.screens.active.cursor.x, .row = pane.vt.screens.active.cursor.y, @@ -11615,17 +11810,23 @@ pub const Pardes = struct { }, }; }; - try panes.append(arena, dp); + panes[panes_len] = dp; + panes_len += 1; } - var columns: std.ArrayList(dump.Column) = .empty; + var columns: [MAX_COLS]dump.Column = undefined; + var columns_len: usize = 0; + var column_ids: [MAX_COLS][MAX_PANES]usize = undefined; for (0..p.ncol) |c| { - var ids: std.ArrayList(usize) = .empty; - for (0..p.col_n[c]) |k| { - if (slot_to_pane[p.col_terms[c][k]]) |compact| try ids.append(arena, compact); + var ids_len: usize = 0; + for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_terms[c][k]]) |compact| { + column_ids[c][ids_len] = compact; + ids_len += 1; + }; + if (ids_len > 0) { + columns[columns_len] = .{ .weight = p.col_weight[c], .panes = column_ids[c][0..ids_len] }; + columns_len += 1; } - if (ids.items.len > 0) - try columns.append(arena, .{ .weight = p.col_weight[c], .panes = ids.items }); } const state: dump.State = .{ @@ -11633,8 +11834,8 @@ pub const Pardes = struct { .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, .theme = p.theme().name, - .columns = columns.items, - .panes = panes.items, + .columns = columns[0..columns_len], + .panes = panes[0..panes_len], }; try dump.validate(state); var out: std.Io.Writer.Allocating = .init(p.gpa); @@ -11649,8 +11850,20 @@ pub const Pardes = struct { /// by replaying their raw VT streams into fresh emulators), no spawns — /// loaded terminals are dead replays, scrollable and selectable. pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { + const image_gpa = opts.image_allocator orelse gpa; + const pdf_gpa = opts.pdf_allocator orelse gpa; + const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; const p = try gpa.create(Pardes); - p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) }; + p.* = .{ + .gpa = gpa, + .image_gpa = image_gpa, + .pdf_gpa = pdf_gpa, + .tree_sitter_gpa = tree_sitter_gpa, + .opts = opts, + .screen_w = opts.cols, + .screen_h = opts.rows, + .scratch = .init(gpa), + }; errdefer p.deinit(); const st = try dump.readZon(gpa, zon_bytes, "load"); defer dump.free(gpa, st); @@ -11681,7 +11894,7 @@ pub const Pardes = struct { p.panes[i] = pane; if (t.stream_b64.len > 0) { const bytes = try dump.decodeBytes(p.scratch.allocator(), t.stream_b64); - pane.tty_stream.appendSlice(gpa, bytes) catch {}; + pane.appendTtyReplay(bytes); pane.stream.nextSlice(bytes); pane.vt.screens.active.scroll(.active); if (src.scroll > 0) pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) }); @@ -11708,6 +11921,7 @@ pub const Pardes = struct { break :blk ov; } else null; pane.file = .{ .path = path, .content = content, .output = out, .scroll = src.scroll }; + pane.kind = .file; pane.cur_pinned = true; pane.cur_row = @intCast(src.scroll); pane.cols = @max(1, src.cols); @@ -11723,13 +11937,16 @@ pub const Pardes = struct { }, .image => restore_image: { const im = src.image.?; - const raw: []u8 = if (im.bytes_b64.len > 0) - dump.decodeBytes(gpa, im.bytes_b64) catch &.{} + const raw_gpa = if (pdf_enabled and look.isPdfPath(im.path)) gpa else p.image_gpa; + var raw: []u8 = if (im.bytes_b64.len > 0) + dump.decodeBytes(raw_gpa, im.bytes_b64) catch &.{} else &.{}; + errdefer if (raw.len > 0) raw_gpa.free(raw); if (comptime pdf_enabled) if (look.isPdfPath(im.path)) { if (p.openPdfView(i, im.path, src.scroll + 1) catch null) |pane| { if (raw.len > 0) gpa.free(raw); + raw = &.{}; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); break :restore_image; @@ -11739,19 +11956,23 @@ pub const Pardes = struct { const path = try gpa.dupe(u8, im.path); errdefer gpa.free(path); const content = if (raw.len > 0) raw else try gpa.dupe(u8, ""); + raw = &.{}; errdefer if (content.len > 0) gpa.free(content); const pane = try p.newDocPane(i); pane.file = .{ .path = path, .content = content }; + pane.kind = .file; pane.cur_pinned = true; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); break :restore_image; }; - const path = try gpa.dupe(u8, im.path); - errdefer gpa.free(path); + const path = try p.image_gpa.dupe(u8, im.path); + errdefer p.image_gpa.free(path); const pane = try p.newDocPane(i); pane.image = .{ .path = path, .raw = raw }; + raw = &.{}; + pane.kind = .image; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); }, @@ -11792,7 +12013,7 @@ pub const Pardes = struct { const said = std.mem.trimStart(u8, rest, " "); if (std.mem.eql(u8, said, std.mem.trimStart(u8, curTail(pane), " ")) or std.mem.eql(u8, said, std.mem.trimStart(u8, legacy, " "))) return; - pane.tag_tail.appendSlice(p.gpa, rest) catch return; + if (!pane.appendTag(rest)) return; pane.tag_init = true; } @@ -11971,7 +12192,7 @@ pub const Pardes = struct { // produced output — bash sourced the rc and readline owns echo, so // the greeting echoes on an OSC 133-marked row and hides cleanly; // earlier, the kernel would echo it onto an unmarked row). - if (pane.greet and pane.isTerminal() and p.resize_count > 0 and pane.tty_stream.items.len > 0) { + if (pane.greet and pane.isTerminal() and p.resize_count > 0 and pane.tty_stream_len > 0) { p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } }); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } }); pane.greet = false; @@ -12121,9 +12342,9 @@ pub const Pardes = struct { // the same one input model renderPane cut off the tagline; only one // of the two can ever be armed (a body key arms one, exitTagEdit // clears both) - const prompt_at: ?u16 = if (pane.search_edit) pane.search_at else if (pane.pipe_edit) pane.pipe_at else null; + const prompt_at = pane.promptAt(); const text = if (prompt_at) |at| - pane.tag_tail.items[@min(at, pane.tag_tail.items.len)..] + pane.tagSlice()[@min(at, pane.tag_tail_len)..] else pane.msg[0..pane.msg_len]; // The pending SPC path is the third thing that wants this row, and @@ -12389,13 +12610,11 @@ pub const Pardes = struct { // readable while you type instead of being pushed off the right edge by // a long pattern. // - // search_at/pipe_at are TAIL offsets and tag_col is a rendered-tag - // column, so the cut is the offset plus the prefix the tail is missing - // — which is what tag.len - tail.len is, without formatting the prefix - // a second time. - const prompt_at: ?u16 = if (pane.search_edit) pane.search_at else if (pane.pipe_edit) pane.pipe_at else null; + // The prompt offset is in the tail while tag_col is in the rendered + // tag, so add the live prefix length when clipping the editable span. + const prompt_at = pane.promptAt(); const tag_cut = if (prompt_at) |at| - @min(tag.len, tag.len - @min(tag.len, pane.tag_tail.items.len) + at) + @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at) else tag.len; _ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style); @@ -12758,13 +12977,14 @@ pub const Pardes = struct { // Remove first: owned RGBA never accumulates with document length, // and backends see stale keys disappear in this same frame. var index: usize = 0; - while (index < pv.rasters.items.len) { - if (pdfVisibleContains(visible, pv.rasters.items[index].page)) { + while (index < pv.rasters_len) { + if (pdfVisibleContains(visible, pv.rasters[index].page)) { index += 1; continue; } - p.releasePdfRaster(&pv.rasters.items[index]); - _ = pv.rasters.swapRemove(index); + p.releasePdfRaster(&pv.rasters[index]); + pv.rasters_len -= 1; + if (index != pv.rasters_len) pv.rasters[index] = pv.rasters[pv.rasters_len]; } var page = visible.first; @@ -12772,8 +12992,10 @@ pub const Pardes = struct { while (page < end) : (page += 1) { var raster = pdfRasterForPage(pv, page); if (raster == null) { - pv.rasters.append(p.gpa, .{ .valid = true, .page = page }) catch continue; - raster = &pv.rasters.items[pv.rasters.items.len - 1]; + if (pv.rasters_len == pv.rasters.len) continue; + pv.rasters[pv.rasters_len] = .{ .valid = true, .page = page }; + pv.rasters_len += 1; + raster = &pv.rasters[pv.rasters_len - 1]; } const slot = raster.?; const decorated = page == pv.page and highlights.len > 0; @@ -12786,17 +13008,17 @@ pub const Pardes = struct { slot.request_valid = true; const rendered = render: { const fresh = if (decorated) - pv.document.renderWithHighlightsAt(p.gpa, page, request, highlights) catch break :render null + pv.document.renderWithHighlightsAt(p.pdf_gpa, page, request, highlights) catch break :render null else - pv.document.renderAt(p.gpa, page, request) catch break :render null; + 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.gpa.free(fresh.rgba); + p.pdf_gpa.free(fresh.rgba); continue; }; - if (slot.rgba.len > 0) p.gpa.free(slot.rgba); + if (slot.rgba.len > 0) p.pdf_gpa.free(slot.rgba); slot.rgba = fresh.rgba; slot.iw = fresh.width; slot.ih = fresh.height; @@ -12877,7 +13099,7 @@ pub const Pardes = struct { if (!iv.tried) { iv.tried = true; const bytes: []const u8 = if (iv.raw.len > 0) iv.raw else (look.readFile(p.scratch.allocator(), iv.path) catch ""); - if (image.decode(p.gpa, bytes)) |d| { + if (image.decode(p.image_gpa, bytes)) |d| { iv.rgba = d.rgba; iv.iw = d.w; iv.ih = d.h; @@ -12908,12 +13130,12 @@ pub const Pardes = struct { if (iv.grid.len == 0 or iv.grid_w != body_cols or iv.grid_h != body_rows or iv.grid_mode != iv.pmode or iv.grid_ascii != iv.ascii) { - if (iv.grid.len > 0) p.gpa.free(iv.grid); + if (iv.grid.len > 0) p.image_gpa.free(iv.grid); const pal = switch (iv.pmode) { .commodore => image.petscii.commodore, .terminal => image.ansiPalette(), }; - const g = image.petscii.render(p.gpa, iv.rgba, iv.iw, iv.ih, body_cols, body_rows, pal, iv.ascii) catch image.petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 }; + const g = image.petscii.render(p.image_gpa, iv.rgba, iv.iw, iv.ih, body_cols, body_rows, pal, iv.ascii) catch image.petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 }; iv.grid = g.cells; iv.gw = g.gw; iv.gh = g.gh; @@ -12961,18 +13183,34 @@ pub const Pardes = struct { if (hasPdf(pane)) { const text = try p.ensurePdfText(pane); const scroll = if (comptime pdf_enabled) pane.pdf.?.text_scroll else 0; - var out: std.ArrayList(u8) = .empty; + const max_rows = pane.rows -| BOX_H; + var total: usize = 0; + var visible: usize = 0; + var count_lines = std.mem.splitScalar(u8, text, '\n'); + var count_row: usize = 0; + while (count_lines.next()) |line| : (count_row += 1) { + if (count_row < scroll) continue; + if (visible >= max_rows) break; + total += line.len + @intFromBool(visible > 0); + visible += 1; + } + const out = try arena.alloc(u8, total); + var at: usize = 0; var lines = std.mem.splitScalar(u8, text, '\n'); var row: usize = 0; var wrote: usize = 0; while (lines.next()) |line| : (row += 1) { if (row < scroll) continue; - if (wrote >= pane.rows -| BOX_H) break; - if (wrote > 0) try out.append(arena, '\n'); - try out.appendSlice(arena, line); + if (wrote >= max_rows) break; + if (wrote > 0) { + out[at] = '\n'; + at += 1; + } + @memcpy(out[at..][0..line.len], line); + at += line.len; wrote += 1; } - return out.items; + return out; } if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.wrap_on); return term_pane.bodyText(arena, pane); |
