summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 10:41:33 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a (patch)
tree91fce278abc85e4f7491de8b5f02184c8a2d5a56 /src/pardes.zig
parent2b1fac6fecbf7d7cc08f9dad24687b05e9968c9a (diff)
downloadpardes-9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a.tar.gz
pardes-9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a.zip
replace ArrayLists with bounded storage
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig1504
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);