summaryrefslogtreecommitdiff
path: root/test/panes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'test/panes.zig')
-rw-r--r--test/panes.zig2743
1 files changed, 2743 insertions, 0 deletions
diff --git a/test/panes.zig b/test/panes.zig
new file mode 100644
index 00000000..7d7a9ab7
--- /dev/null
+++ b/test/panes.zig
@@ -0,0 +1,2743 @@
+const std = @import("std");
+const pardes = @import("pardes");
+const panes = pardes.panes;
+const layout = pardes.layout;
+const Pardes = pardes.Pardes;
+const Pane = pardes.Pane;
+const Key = pardes.Key;
+const config = pardes.config;
+const modal = pardes.modal;
+const dump = pardes.dump;
+const image = pardes.image;
+const syntax = pardes.syntax;
+const filesystem = pardes.filesystem;
+
+test "terminal overlay recoloring never materializes unrelated history" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 32, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ for (0..1000) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32mcaf\xc3\xa9 \xe7\x95\x8c\x1b[0m\r\n" } });
+ pane.mode = .normal;
+ pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "edited history") };
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ try testing.expect(p.shell_rows.pane == null);
+
+ pane.ovl.?.row = panes.Terminal.gridOffset(pane);
+ p.gpa.free(pane.ovl.?.text);
+ pane.ovl.?.text = try p.gpa.dupe(u8, "caf\xc3\xa9 \xe7\x95\x8c");
+ _ = frame.reset(.retain_capacity);
+ const surface = try p.render(frame.allocator());
+ try testing.expect(p.shell_rows.pane == null);
+ const rect = p.rects[0];
+ const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ const cell = surface.at(rect.x + config.GUTTER, body_y);
+ try testing.expectEqualStrings("c", cell.grapheme());
+ try testing.expectEqual(pardes.Color{ .index = 2 }, cell.style.fg);
+}
+
+test "terminal output keeps following new rows after scrollback eviction" {
+ if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.mode = .tty;
+ pane.tty_filter = false;
+ const pages = &pane.terminal.?.vt.screens.active.pages;
+ pages.explicit_max_size = 64 * 1024;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ var evictions: usize = 0;
+ for (0..3) |phase| {
+ if (phase > 0) {
+ pane.mode = .normal;
+ panes.Terminal.scrollGrid(pane, -30);
+ try std.testing.expect(pages.scrollbar().offset < pages.scrollbar().total - pane.rows);
+ panes.Terminal.enterTty(p, 0);
+ }
+ for (phase * 20_000..(phase + 1) * 20_000) |n| {
+ var buf: [64]u8 = undefined;
+ const bytes = try std.fmt.bufPrint(&buf, "\x1b[32mrow-{d:0>5}\x1b[0m\r\n", .{n});
+ const previous = pages.scrollbar().total;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ if (pages.scrollbar().total < previous) evictions += 1;
+ }
+ try std.testing.expect(evictions > phase);
+ try std.testing.expect(pages.page_size <= pages.maxSize());
+ const sb = pages.scrollbar();
+ try std.testing.expectEqual(sb.total - pane.rows, sb.offset);
+ const full = try panes.Terminal.screenTextAlloc(pane, gpa);
+ defer gpa.free(full);
+ var last_buf: [16]u8 = undefined;
+ const last = try std.fmt.bufPrint(&last_buf, "row-{d:0>5}", .{(phase + 1) * 20_000 - 1});
+ try std.testing.expect(std.mem.indexOf(u8, full, "row-00000") == null);
+ try std.testing.expect(std.mem.endsWith(u8, std.mem.trimEnd(u8, full, "\n"), last));
+ _ = frame.reset(.retain_capacity);
+ const surface = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER;
+ const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + pane.rows - 2;
+ for (last, 0..) |byte, col| {
+ const cell = surface.at(x + @as(u16, @intCast(col)), y);
+ try std.testing.expectEqualStrings(&.{byte}, cell.grapheme());
+ try std.testing.expectEqual(pardes.Color{ .index = 2 }, cell.style.fg);
+ }
+ while (p.nextEffect()) |_| {}
+ p.update(.{ .key = .{ .cp = 'f', .text = "f" } });
+ const effect = p.nextEffect() orelse return error.MissingTerminalInput;
+ try std.testing.expectEqual(.write, std.meta.activeTag(effect));
+ try std.testing.expectEqual(@as(u8, 0), effect.write.pane);
+ try std.testing.expectEqualStrings("f", effect.write.bytes.slice());
+ try std.testing.expect(p.nextEffect() == null);
+ }
+ const replay = &pane.terminal.?.replay;
+ try std.testing.expectEqual(replay.bytes.len, replay.len);
+ try std.testing.expect(replay.head > 0);
+}
+
+test "terminal edit stays on its surviving row when old scrollback is evicted" {
+ if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.mode = .tty;
+ const pages = &pane.terminal.?.vt.screens.active.pages;
+ pages.explicit_max_size = 64 * 1024;
+ var next: usize = 0;
+ while (next < 3000) {
+ var buf: [32]u8 = undefined;
+ const bytes = try std.fmt.bufPrint(&buf, "row-{d:0>5}\r\n", .{next});
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ next += 1;
+ if (pages.scrollbar().total > pages.pages.first.?.rows() + pane.rows + 10) break;
+ }
+ try std.testing.expect(next < 3000);
+ var last_buf: [16]u8 = undefined;
+ const last = try std.fmt.bufPrint(&last_buf, "row-{d:0>5}", .{next - 1});
+ pane.mode = .normal;
+ const original_row: i32 = @intCast(pages.scrollbar().total - 2);
+ pane.ovl = .{ .row = original_row, .rows = 1, .text = try gpa.dupe(u8, "my unsent edit") };
+ pane.cur_row = original_row;
+ pane.cur_col = 3;
+ pane.cur_pinned = true;
+ var removed: usize = 0;
+ for (next..next + 3000) |n| {
+ var buf: [32]u8 = undefined;
+ const bytes = try std.fmt.bufPrint(&buf, "row-{d:0>5}\r\n", .{n});
+ const previous = pages.scrollbar().total;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ if (pages.scrollbar().total < previous) {
+ removed = previous + 1 - pages.scrollbar().total;
+ break;
+ }
+ }
+ try std.testing.expect(removed > 0 and removed < original_row);
+ const full = try panes.Terminal.screenTextAlloc(pane, gpa);
+ defer gpa.free(full);
+ const row = original_row - @as(i32, @intCast(removed));
+ try std.testing.expectEqualStrings(last, modal.lineSlice(full, @intCast(row)));
+ try std.testing.expectEqual(row, pane.ovl.?.row);
+ try std.testing.expectEqual(row, pane.cur_row);
+ try std.testing.expectEqualStrings("my unsent edit", pane.ovl.?.text);
+}
+
+test "terminal edits and undo survive partial history clearing and whole-screen loss" {
+ if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const Action = enum { clear_history, partial_clear_history, reset_primary, reset_alternate, evict_all, hidden_undo };
+ for (std.enums.values(Action)) |action| {
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.mode = .tty;
+ if (action == .reset_alternate)
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049h" } });
+ p.update(.{ .output = .{ .pane = 0, .bytes = "old row\r\n" ** 40 } });
+ const pages = &pane.terminal.?.vt.screens.active.pages;
+ pages.explicit_max_size = 64 * 1024;
+ const row: i32 = @as(i32, @intCast(pages.total_rows - pane.rows)) +
+ @as(i32, if (action == .partial_clear_history) -1 else 2);
+ pane.mode = .normal;
+ pane.ovl = .{ .row = row, .rows = if (action == .partial_clear_history) 3 else 2, .text = try gpa.dupe(u8, "edit one\nedit two") };
+ pane.cur_row = row + 1;
+ pane.cur_col = 4;
+ pane.cur_pinned = true;
+ pane.vsel = .{ .active = true, .row = row, .col = 1 };
+ panes.Terminal.pushUndo(p, pane);
+ pane.ed_redo[0] = panes.Terminal.snap(p, pane) orelse return error.SnapshotFailed;
+ pane.ed_redo_len = 1;
+ if (action == .hidden_undo) {
+ gpa.free(pane.ovl.?.text);
+ pane.ovl = null;
+ panes.Terminal.enterTty(p, 0);
+ }
+ const bytes = switch (action) {
+ .clear_history, .partial_clear_history => "\x1b[3J",
+ .reset_primary, .reset_alternate => "\x1bc",
+ .evict_all, .hidden_undo => "new row\r\n" ** 4000,
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ const expected: i32 = if (action == .clear_history) 2 else 0;
+ if (pane.ovl) |edit| {
+ try std.testing.expectEqual(expected, edit.row);
+ try std.testing.expectEqual(@as(i32, if (action == .clear_history or action == .partial_clear_history) 2 else 1), edit.rows);
+ try std.testing.expectEqualStrings("edit one\nedit two", edit.text);
+ try std.testing.expectEqual(expected + 1, pane.cur_row);
+ try std.testing.expectEqual(expected, pane.vsel.row);
+ }
+ for ([_]panes.Terminal.Snapshot{ pane.ed_undo[0], pane.ed_redo[0] }) |snapshot| {
+ try std.testing.expectEqual(expected, snapshot.ovl.?.row);
+ try std.testing.expectEqual(expected + 1, snapshot.cur_row);
+ try std.testing.expectEqualStrings("edit one\nedit two", snapshot.ovl.?.text);
+ }
+ try std.testing.expectEqual(@as(usize, 2), pane.terminal.?.vt.screens.active.pages.countTrackedPins());
+ panes.Terminal.undo(p, pane);
+ try std.testing.expectEqualStrings("edit one\nedit two", pane.ovl.?.text);
+ try std.testing.expectEqual(expected, pane.ovl.?.row);
+ }
+}
+
+test "terminal lower scroll regions do not move edits above them" {
+ if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.mode = .normal;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "top\r\nsecond\r\nthird" } });
+ pane.ovl = .{ .row = 1, .rows = 1, .text = try gpa.dupe(u8, "unsent") };
+ pane.cur_row = 1;
+ pane.cur_pinned = true;
+ panes.Terminal.pushUndo(p, pane);
+ const total = panes.Terminal.scrollbar(pane).total;
+ var setup: [64]u8 = undefined;
+ const bytes = try std.fmt.bufPrint(&setup, "\x1b[4;{d}r\x1b[{d};1H", .{ pane.rows, pane.rows });
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ p.update(.{ .output = .{ .pane = 0, .bytes = "tail\r\n" ** 50 } });
+ try std.testing.expectEqual(total, panes.Terminal.scrollbar(pane).total);
+ try std.testing.expectEqual(@as(i32, 1), pane.ovl.?.row);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 1), pane.ed_undo[0].ovl.?.row);
+ const full = try panes.Terminal.screenTextAlloc(pane, gpa);
+ defer gpa.free(full);
+ try std.testing.expect(std.mem.startsWith(u8, full, "top\nsecond\nthird\n"));
+}
+
+test "terminal eviction and lower-region scrolling in one read preserve a surviving edit" {
+ if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.mode = .tty;
+ const pages = &pane.terminal.?.vt.screens.active.pages;
+ pages.explicit_max_size = 64 * 1024;
+ var next: usize = 0;
+ while (next < 3000) {
+ var buf: [32]u8 = undefined;
+ const bytes = try std.fmt.bufPrint(&buf, "row-{d:0>5}\r\n", .{next});
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ next += 1;
+ if (pages.pages.first != pages.pages.last and
+ pages.pages.last.?.rows() == pages.pages.last.?.capacity().rows) break;
+ }
+ try std.testing.expect(next < 3000);
+ const removed = pages.pages.first.?.rows();
+ const old_row: i32 = @intCast(removed + 2);
+ pane.mode = .normal;
+ pane.ovl = .{ .row = old_row, .rows = 1, .text = try gpa.dupe(u8, "kept edit") };
+ pane.cur_row = old_row;
+ pane.cur_pinned = true;
+ panes.Terminal.pushUndo(p, pane);
+ const old_total = pages.total_rows;
+ var buf: [128]u8 = undefined;
+ const bytes = try std.fmt.bufPrint(&buf, "new\r\n\x1b[4;{d}r\x1b[{d};1Hregion\r\n", .{ pane.rows, pane.rows });
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ try std.testing.expectEqual(old_total + 1 - removed, pages.total_rows);
+ try std.testing.expectEqual(@as(i32, 2), pane.ovl.?.row);
+ try std.testing.expectEqual(@as(i32, 2), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 2), pane.ed_undo[0].ovl.?.row);
+ try std.testing.expectEqualStrings("kept edit", pane.ovl.?.text);
+}
+
+test "tag prompt entry and cancellation preserve exact text and cursor state" {
+ const Case = struct { key: ?Key = null, save: bool = false, marker: []const u8 };
+ const cases = [_]Case{
+ .{ .marker = config.search_marker },
+ .{ .save = true, .marker = config.save_marker ++ "/dír space/" },
+ .{ .key = .{ .cp = '|' }, .marker = config.pipe_marker },
+ .{ .key = .{ .cp = '|', .alt = true }, .marker = config.pipe_marker_to },
+ .{ .key = .{ .cp = '!' }, .marker = config.pipe_marker_insert },
+ .{ .key = .{ .cp = '!', .alt = true }, .marker = config.pipe_marker_append },
+ };
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\ntwo\n");
+ try pane.setOwnedCwd("/dír space");
+ const tail = " keep";
+ for (cases) |case| {
+ @memcpy(pane.tag_tail[0..tail.len], tail);
+ pane.tag_tail_len = tail.len;
+ pane.tag_init = true;
+ pane.tag_sel = true;
+ pane.tag_col = 1;
+ pane.cur_row = 1;
+ pane.cur_col = 2;
+ pane.cur_pinned = true;
+ if (case.key) |key| {
+ p.update(.{ .key = key });
+ } else if (case.save) {
+ p.startPrompt(pane, .save);
+ } else {
+ p.startPrompt(pane, .{ .search = config.search_marker });
+ }
+ try std.testing.expect(pane.tag_edit);
+ try std.testing.expect(!pane.tag_sel);
+ try std.testing.expectEqual(.insert, pane.mode);
+ try std.testing.expectEqualDeep(modal.Normal.State{}, pane.normal);
+ try std.testing.expectEqualStrings(tail, pane.tag_tail[0..tail.len]);
+ try std.testing.expectEqualStrings(case.marker, pane.tag_tail[tail.len..pane.tag_tail_len]);
+ try std.testing.expectEqual(pane.file.?.path.len + pane.tag_tail_len, pane.tag_col);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(pane.prompt == .none);
+ try std.testing.expect(!pane.tag_edit);
+ try std.testing.expectEqual(.normal, pane.mode);
+ try std.testing.expectEqualStrings(tail, pane.tag_tail[0..pane.tag_tail_len]);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 2), pane.cur_col);
+ try std.testing.expectEqualStrings("one\ntwo\n", pane.file.?.content);
+ }
+}
+
+test "tag prompt capacity refusal leaves the existing tail and input state untouched" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\n");
+ @memset(&pane.tag_tail, 'x');
+ pane.tag_tail_len = pane.tag_tail.len - 1;
+ pane.tag_init = true;
+ pane.tag_col = 17;
+ pane.tag_sel = true;
+ for (0..3) |case| {
+ switch (case) {
+ 0 => p.startPrompt(pane, .{ .search = config.search_marker }),
+ 1 => p.startPrompt(pane, .save),
+ else => p.update(.{ .key = .{ .cp = '|' } }),
+ }
+ try std.testing.expect(pane.prompt == .none);
+ try std.testing.expect(!pane.tag_edit);
+ try std.testing.expect(pane.tag_sel);
+ try std.testing.expectEqual(.normal, pane.mode);
+ try std.testing.expectEqual(@as(u16, 17), pane.tag_col);
+ try std.testing.expectEqual(pane.tag_tail.len - 1, pane.tag_tail_len);
+ for (pane.tag_tail) |byte| try std.testing.expectEqual(@as(u8, 'x'), byte);
+ }
+}
+
+test "generated output refuses every pipe prompt before seeding the tag" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const content = try p.gpa.dupe(u8, "generated\n");
+ const pane = try panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", content);
+ p.active = 1;
+ for ([_]Key{ .{ .cp = '|' }, .{ .cp = '|', .alt = true }, .{ .cp = '!' }, .{ .cp = '!', .alt = true } }) |key| {
+ p.update(.{ .key = key });
+ try std.testing.expect(pane.prompt == .none);
+ try std.testing.expect(!pane.tag_init);
+ try std.testing.expect(!pane.tag_edit);
+ try std.testing.expectEqualStrings("generated\n", pane.file.?.content);
+ }
+}
+
+test "tag prompt owns the pipe behavior through cancellation and submission" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\ntwo\n");
+ pane.cur_row = 0;
+ pane.cur_col = 2;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+ for (std.enums.values(modal.Normal.PipeBehavior)) |how| {
+ p.startPrompt(pane, .{ .pipe = .append });
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ p.startPrompt(pane, .{ .pipe = how });
+ try std.testing.expectEqual(how, pane.prompt.pipe.how);
+ p.update(.{ .key = .{ .cp = 'c', .text = "cat" } });
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ const pending = p.pipe_wait orelse return error.MissingPipe;
+ try std.testing.expectEqual(how, pending.how);
+ try std.testing.expectEqualStrings("cat", pending.command);
+ try std.testing.expectEqual(@as(usize, 1), pending.inputs.len);
+ try std.testing.expectEqualStrings(if (how.pipes()) "one" else "", pending.inputs[0].bytes);
+ try std.testing.expect(pane.prompt == .none);
+ }
+}
+
+test "tag prompt cancellation restores the selection before regex previews" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("one two one");
+ const cases = [_]struct { key: u21, pattern: []const u8, secondary: u8 }{
+ .{ .key = 's', .pattern = "one", .secondary = 1 },
+ .{ .key = 'S', .pattern = " ", .secondary = 2 },
+ };
+ for (cases) |case| {
+ pane.cur_row = 0;
+ pane.cur_col = 10;
+ pane.cur_pinned = true;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+ pane.nsel = 0;
+ p.update(.{ .key = .{ .cp = case.key } });
+ try std.testing.expect(pane.prompt == .search);
+ p.update(.{ .key = .{ .cp = case.pattern[0], .text = case.pattern } });
+ try std.testing.expectEqual(case.secondary, pane.nsel);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expectEqual(@as(u8, 0), pane.nsel);
+ try std.testing.expectEqual(@as(u8, 0), pane.nsel_snap);
+ try std.testing.expectEqual(@as(i32, 10), pane.cur_col);
+ try std.testing.expectEqual(@as(i32, 0), pane.vsel.col);
+ try std.testing.expect(pane.vsel.active and pane.vsel.explicit);
+ try std.testing.expectEqualStrings("one two one", pane.file.?.content);
+ }
+}
+
+test "tag prompt prefix allocation failure preserves an already seeded prompt" {
+ const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?;
+ for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(failing.allocator(), .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\n");
+ const tail = " keep /prior";
+ @memcpy(pane.tag_tail[0..tail.len], tail);
+ pane.tag_tail_len = tail.len;
+ pane.tag_init = true;
+ pane.prompt = .{ .search = 5 };
+ pane.tag_edit = true;
+ pane.tag_sel = true;
+ pane.tag_col = 14;
+ pane.tag_anchor = 7;
+ pane.normal = .{ .count = 9, .prefix = .goto };
+ const previous_prompt = pane.prompt;
+ const previous_normal = pane.normal;
+ _ = p.scratch.reset(.free_all);
+ failing.fail_index = failing.alloc_index;
+ p.startPrompt(pane, request);
+ try std.testing.expect(failing.has_induced_failure);
+ try std.testing.expectEqualDeep(previous_prompt, pane.prompt);
+ try std.testing.expectEqualDeep(previous_normal, pane.normal);
+ try std.testing.expectEqualStrings(tail, pane.tag_tail[0..pane.tag_tail_len]);
+ try std.testing.expect(pane.tag_edit and pane.tag_sel);
+ try std.testing.expectEqual(.normal, pane.mode);
+ try std.testing.expectEqual(@as(u16, 14), pane.tag_col);
+ try std.testing.expectEqual(@as(u16, 7), pane.tag_anchor);
+ }
+}
+
+test "tag prompt Save requires room for its complete directory prefix" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\n");
+ for ([_][]const u8{ "/dír space", "/dír space/", "" }) |dir| {
+ try pane.setOwnedCwd(dir);
+ const slash: usize = @intFromBool(dir.len == 0 or dir[dir.len - 1] != '/');
+ const needed = config.save_marker.len + dir.len + slash;
+ for ([_]usize{ 1, 0 }) |shortfall| {
+ @memset(&pane.tag_tail, 'x');
+ pane.tag_tail_len = pane.tag_tail.len - needed + shortfall;
+ pane.tag_init = true;
+ pane.tag_edit = false;
+ pane.tag_sel = true;
+ pane.tag_col = 17;
+ const start = pane.tag_tail_len;
+ p.startPrompt(pane, .save);
+ if (shortfall != 0) {
+ try std.testing.expect(pane.prompt == .none);
+ try std.testing.expectEqual(start, pane.tag_tail_len);
+ try std.testing.expect(!pane.tag_edit and pane.tag_sel);
+ try std.testing.expectEqual(@as(u16, 17), pane.tag_col);
+ for (pane.tag_tail) |byte| try std.testing.expectEqual(@as(u8, 'x'), byte);
+ } else {
+ try std.testing.expect(pane.prompt == .save);
+ try std.testing.expectEqual(pane.tag_tail.len, pane.tag_tail_len);
+ try std.testing.expectEqualStrings(config.save_marker, pane.tag_tail[start..][0..config.save_marker.len]);
+ try std.testing.expectEqualStrings(dir, pane.tag_tail[start + config.save_marker.len ..][0..dir.len]);
+ try std.testing.expectEqual(@as(u8, '/'), pane.tag_tail[pane.tag_tail_len - 1]);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ }
+ }
+ }
+}
+
+test "tag prompt successful seeded entry needs one scratch allocation" {
+ const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?;
+ for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(failing.allocator(), .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\n");
+ pane.tag_tail_len = 0;
+ pane.tag_init = true;
+ _ = p.scratch.reset(.free_all);
+ const before = failing.alloc_index;
+ p.startPrompt(pane, request);
+ try std.testing.expectEqual(before + 1, failing.alloc_index);
+ try std.testing.expect(pane.tag_edit and pane.mode == .insert);
+ }
+}
+
+test "tag prompt failure may initialize only the default tail" {
+ const Request = @typeInfo(@TypeOf(Pardes.startPrompt)).@"fn".params[2].type.?;
+ for ([_]Request{ .{ .search = config.search_marker }, .save, .{ .pipe = .replace }, .{ .pipe = .ignore }, .{ .pipe = .insert }, .{ .pipe = .append } }) |request| {
+ var saw_seeded_failure = false;
+ var succeeded = false;
+ for (0..16) |failure| {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(failing.allocator(), .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\n");
+ const path = try p.gpa.dupe(u8, "/" ++ "a" ** 2048 ++ ".zig");
+ p.gpa.free(pane.file.?.path);
+ pane.file.?.path = path;
+ p.seedTail(pane);
+ try std.testing.expect(pane.tag_init);
+ const default_tail = pane.tag_tail;
+ const default_len = pane.tag_tail_len;
+ pane.tag_init = false;
+ pane.tag_tail_len = 0;
+ pane.tag_col = 17;
+ pane.tag_sel = true;
+ pane.normal = .{ .count = 9, .prefix = .goto };
+ const previous_normal = pane.normal;
+ _ = p.scratch.reset(.free_all);
+ failing.fail_index = failing.alloc_index + failure;
+ p.startPrompt(pane, request);
+ if (!failing.has_induced_failure) {
+ try std.testing.expect(pane.tag_edit and pane.mode == .insert);
+ succeeded = true;
+ break;
+ }
+ try std.testing.expect(pane.prompt == .none);
+ try std.testing.expect(!pane.tag_edit and pane.tag_sel);
+ try std.testing.expectEqual(.normal, pane.mode);
+ try std.testing.expectEqualDeep(previous_normal, pane.normal);
+ try std.testing.expectEqual(@as(u16, 17), pane.tag_col);
+ if (pane.tag_init) {
+ saw_seeded_failure = true;
+ try std.testing.expectEqualStrings(default_tail[0..default_len], pane.tag_tail[0..pane.tag_tail_len]);
+ } else try std.testing.expectEqual(@as(usize, 0), pane.tag_tail_len);
+ }
+ try std.testing.expect(succeeded and saw_seeded_failure);
+ }
+}
+
+test "file history construction failures preserve existing panes" {
+ const Case = enum { open, restore, output, tutor, replace };
+ for (std.enums.values(Case)) |case| {
+ var succeeded = false;
+ for (0..32) |failure| {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ const original = try p.setTestFile("original\n");
+ const old_history = original.file.?.history;
+ const serial = p.next_serial;
+ var path_buffer: [128]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{original.serial});
+ var incoming: ?[]u8 = if (case == .output) try p.gpa.dupe(u8, "generated\n") else null;
+ defer if (incoming) |bytes| p.gpa.free(bytes);
+ failing.fail_index = failing.alloc_index + failure;
+ const result = switch (case) {
+ .open => panes.File.open(p, 1, path, 0),
+ .restore => panes.File.restore(p, 1, .{
+ .kind = .file,
+ .tag = "",
+ .body = "",
+ .file = .{ .path = "/restored.zig", .content = "restored\n" },
+ }),
+ .output => panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", incoming.?),
+ .tutor => p.openTutorView(1),
+ .replace => p.setTestFile("replacement\n"),
+ };
+ if (result) |pane| {
+ if (case == .output) incoming = null;
+ try std.testing.expect(pane.file.?.history != old_history);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.redo_len);
+ succeeded = true;
+ break;
+ } else |err| {
+ try std.testing.expect(err == error.OutOfMemory or (case == .open and err == error.WriteFailed and failing.has_induced_failure));
+ try std.testing.expectEqual(original, p.panes[0].?);
+ try std.testing.expectEqual(old_history, original.file.?.history);
+ try std.testing.expectEqualStrings("original\n", original.file.?.content);
+ try std.testing.expectEqual(serial, p.next_serial);
+ try std.testing.expect(p.panes[1] == null);
+ try std.testing.expect(!p.reserved_slots[1]);
+ }
+ }
+ try std.testing.expect(succeeded);
+ }
+}
+
+test "file history owns bounded snapshot storage without per-edit bookkeeping allocations" {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("initial");
+ const file = &pane.file.?;
+ const history = file.history;
+ for (0..pardes.memory.limits.undo_max + 2) |index| {
+ const next = try std.fmt.allocPrint(p.gpa, "{d}", .{index});
+ const before = failing.alloc_index;
+ panes.File.pushUndo(p, pane);
+ try std.testing.expectEqual(before + 1, failing.alloc_index);
+ panes.File.setContent(p, file, next);
+ try std.testing.expectEqual(history, file.history);
+ }
+ try std.testing.expectEqual(pardes.memory.limits.undo_max, history.undo_len);
+ try std.testing.expectEqualStrings("1", history.undo[0].content);
+ const current = file.content;
+ failing.fail_index = failing.alloc_index;
+ panes.File.pushUndo(p, pane);
+ panes.File.undo(p, pane);
+ try std.testing.expectEqual(current.ptr, file.content.ptr);
+ try std.testing.expectEqual(pardes.memory.limits.undo_max, history.undo_len);
+ try std.testing.expectEqual(@as(usize, 0), history.redo_len);
+ failing.fail_index = std.math.maxInt(usize);
+ const before = failing.alloc_index;
+ panes.File.undo(p, pane);
+ try std.testing.expectEqual(before + 2, failing.alloc_index);
+ try std.testing.expectEqual(@as(usize, 1), file.line_starts.len);
+ try std.testing.expectEqual(@as(usize, 1), history.redo_len);
+ const undone = file.content;
+ failing.fail_index = failing.alloc_index;
+ panes.File.redo(p, pane);
+ try std.testing.expectEqual(undone.ptr, file.content.ptr);
+ try std.testing.expectEqual(@as(usize, 1), history.redo_len);
+ failing.fail_index = std.math.maxInt(usize);
+ const before_redo = failing.alloc_index;
+ panes.File.redo(p, pane);
+ try std.testing.expectEqual(before_redo + 2, failing.alloc_index);
+ try std.testing.expectEqual(pardes.memory.limits.undo_max, history.undo_len);
+ try std.testing.expectEqual(@as(usize, 0), history.redo_len);
+}
+
+test "file history allocation precedes a filesystem read with side effects" {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ const original = p.panes[0].?;
+ const serial = p.next_serial;
+ failing.fail_index = failing.alloc_index + 2;
+ try std.testing.expectError(error.OutOfMemory, panes.File.open(p, 1, "/n/self/new/ctl", 0));
+ try std.testing.expect(failing.has_induced_failure);
+ try std.testing.expectEqual(original, p.panes[0].?);
+ try std.testing.expectEqual(serial, p.next_serial);
+ for (p.panes[1..], p.reserved_slots[1..]) |pane, reserved| {
+ try std.testing.expect(pane == null);
+ try std.testing.expect(!reserved);
+ }
+}
+
+test "file history survives PDF fallback restoration and allocation failures" {
+ const saved =
+ \\.{
+ \\ .screen = .{ .cols = 40, .rows = 12 },
+ \\ .columns = .{.{ .panes = .{0} }},
+ \\ .panes = .{.{
+ \\ .kind = .image, .tag = "", .body = "",
+ \\ .image = .{ .path = "/virtual/missing-history.pdf", .bytes_b64 = "cmF3" },
+ \\ }},
+ \\}
+ ;
+ var succeeded = false;
+ for (0..256) |failure| {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = failure });
+ if (Pardes.initFromDump(failing.allocator(), .{ .tty_only = true }, saved)) |p| {
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ try std.testing.expect(pane.file != null);
+ try std.testing.expectEqualStrings("raw", pane.file.?.content);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len);
+ try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.redo_len);
+ if (!failing.has_induced_failure) {
+ succeeded = true;
+ break;
+ }
+ } else |err| {
+ try std.testing.expect(failing.has_induced_failure);
+ try std.testing.expect(err == error.OutOfMemory or err == error.WriteFailed);
+ }
+ }
+ try std.testing.expect(succeeded);
+}
+
+test "Save rearms renamed file watches only after a successful write" {
+ const Host = struct {
+ const Result = enum { success, failure, edited, replaced, closed };
+ core: *Pardes,
+ result: Result,
+ watches: usize = 0,
+ err: ?anyerror = null,
+
+ fn write(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void {
+ const h: *@This() = @ptrCast(@alignCast(ctx.?));
+ switch (h.result) {
+ .success => {},
+ .failure => h.core.saveFailed(id, "save", error.PermissionDenied),
+ .edited => h.core.panes[id].?.file.?.revision +%= 1,
+ .replaced => {
+ const replacement = h.core.setTestFile("replacement\n") catch |err| {
+ h.err = err;
+ return;
+ };
+ replacement.file.?.watch_after_save = true;
+ replacement.file.?.saved_revision -%= 1;
+ },
+ .closed => {
+ h.core.deinitPane(h.core.panes[id].?) catch |err| {
+ h.err = err;
+ return;
+ };
+ h.core.panes[id] = null;
+ },
+ }
+ }
+
+ fn watch(ctx: ?*anyopaque, _: u8, _: []const u8, on: bool, _: pardes.WatchMode) void {
+ const h: *@This() = @ptrCast(@alignCast(ctx.?));
+ if (on) h.watches += 1;
+ }
+ };
+ const Case = struct { result: Host.Result, path: []const u8 = "/renamed.zig", watches: usize = 0 };
+ for ([_]Case{
+ .{ .result = .success, .watches = 1 },
+ .{ .result = .success, .path = "/n/os/renamed.zig", .watches = 1 },
+ .{ .result = .success, .path = "/virtual/pane/0/body" },
+ .{ .result = .success, .path = "/n/remote/file.zig" },
+ .{ .result = .failure },
+ .{ .result = .edited },
+ .{ .result = .replaced },
+ .{ .result = .closed },
+ }) |case| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ const original = try p.setTestFile("edited\n");
+ p.gpa.free(original.file.?.path);
+ original.file.?.path = try p.gpa.dupe(u8, case.path);
+ original.file.?.saved_revision -%= 1;
+ original.file.?.watch_after_save = true;
+ while (p.nextEffect()) |_| {}
+ var host: Host = .{ .core = p, .result = case.result };
+ p.host = .{ .ctx = &host, .vtable = &.{ .write_file = Host.write, .watch_file = Host.watch } };
+ try std.testing.expect(p.executeBuiltinLine(0, "Save"));
+ while (p.nextEffect()) |effect| p.perform(effect);
+ if (host.err) |err| return err;
+ try std.testing.expectEqual(case.watches, host.watches);
+ if (p.panes[0]) |pane| {
+ const file = &pane.file.?;
+ try std.testing.expectEqual(case.result != .success, file.watch_after_save);
+ try std.testing.expectEqual(case.result == .success, file.revision == file.saved_revision);
+ } else try std.testing.expectEqual(.closed, case.result);
+ if (case.result == .failure) {
+ host.result = .success;
+ try std.testing.expect(p.executeBuiltinLine(0, "Save"));
+ while (p.nextEffect()) |effect| p.perform(effect);
+ try std.testing.expectEqual(@as(usize, 1), host.watches);
+ try std.testing.expect(!original.file.?.watch_after_save);
+ try std.testing.expectEqual(original.file.?.revision, original.file.?.saved_revision);
+ }
+ }
+}
+
+test "Save on a scratch does not watch a path the host could not create" {
+ const Refusing = struct {
+ fn write(ctx: ?*anyopaque, id: u8, _: []const u8, _: []const u8) void {
+ const p: *Pardes = @ptrCast(@alignCast(ctx.?));
+ p.saveFailed(id, "save", error.PermissionDenied);
+ }
+ };
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ try std.testing.expect(p.executeBuiltinLine(0, "New"));
+ const id = p.active;
+ while (p.nextEffect()) |_| {}
+ p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.write } };
+ try std.testing.expect(p.executeBuiltinLine(id, "Save /new-file.txt"));
+ while (p.nextEffect()) |effect| p.perform(effect);
+ const file = &p.panes[id].?.file.?;
+ try std.testing.expect(file.output == null);
+ try std.testing.expect(file.watch_after_save);
+ try std.testing.expect(file.revision != file.saved_revision);
+ try std.testing.expect(!p.fallback.watched[id]);
+ p.host = .{};
+ try std.testing.expect(p.executeBuiltinLine(id, "Save"));
+ while (p.nextEffect()) |effect| p.perform(effect);
+ try std.testing.expect(!file.watch_after_save);
+ try std.testing.expectEqual(file.revision, file.saved_revision);
+ try std.testing.expect(p.fallback.watched[id]);
+}
+
+test "unplaced terminal panes keep their allocated grid until placement" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ p.presentation.enabled = false;
+ while (p.nextEffect()) |_| {}
+ const id = p.freeSlot().?;
+ const pane = try p.newShell(id, "");
+ try std.testing.expectEqual(pardes.Rect{}, p.rects[id]);
+ p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "unplaced\r\n" } });
+ try std.testing.expectEqual(pardes.Rect{}, p.rects[id]);
+ try std.testing.expectEqual(@as(u16, 100), pane.cols);
+ try std.testing.expectEqual(@as(u16, 30), pane.rows);
+ try std.testing.expectEqual(pane.cols, pane.terminal.?.vt.cols);
+ try std.testing.expectEqual(pane.rows, pane.terminal.?.vt.rows);
+ while (p.nextEffect()) |effect| {
+ if (effect == .resize_pty and effect.resize_pty.pane == id) return error.ResizedUnplacedPane;
+ }
+ pardes.layout.insert(p, 0, 1, id);
+ p.update(.tick);
+ try std.testing.expect(p.rects[id].w > 0 and p.rects[id].h > pardes.BOX_H);
+ try std.testing.expectEqual(p.rects[id].w - config.GUTTER, pane.cols);
+ try std.testing.expectEqual(p.rects[id].h - pardes.BOX_H, pane.rows);
+}
+
+test "document panes ignore terminal output without allocating terminal state" {
+ var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("document body\n");
+ while (p.nextEffect()) |_| {}
+ const revision = pane.file.?.revision;
+ const allocations = allocator.alloc_index;
+ const resizes = allocator.resize_index;
+ allocator.fail_index = allocations;
+ allocator.resize_fail_index = resizes;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "unrelated PTY output\r\n" } });
+ try std.testing.expect(!allocator.has_induced_failure);
+ try std.testing.expectEqual(allocations, allocator.alloc_index);
+ try std.testing.expectEqual(resizes, allocator.resize_index);
+ try std.testing.expect(pane.terminal == null);
+ try std.testing.expectEqual(revision, pane.file.?.revision);
+ try std.testing.expectEqualStrings("document body\n", pane.file.?.content);
+}
+
+test "removed and recycled pane slots have no stale geometry" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ p.presentation.enabled = false;
+ const id = p.freeSlot().?;
+ const original = try p.newDocPane(id);
+ pardes.layout.insert(p, 0, 1, id);
+ layout.compute(p);
+ try std.testing.expect(p.rects[id].w > 0 and p.rects[id].h > 0);
+ layout.removePane(p, id);
+ layout.compute(p);
+ try std.testing.expectEqual(pardes.Rect{}, p.rects[id]);
+
+ try p.deinitPane(original);
+ p.panes[id] = null;
+ p.rects[id] = .{ .x = 10, .y = 5, .w = 20, .h = 10 };
+ const replacement = try p.newDocPane(id);
+ try std.testing.expect(replacement.serial != original.serial);
+ try std.testing.expectEqual(pardes.Rect{}, p.rects[id]);
+ pardes.layout.insert(p, 0, 1, id);
+
+ for ([_]u16{ 1, 2, 3, 10 }) |size| {
+ p.update(.{ .resize = .{ .cols = size, .rows = size } });
+ for (p.rects) |rect| {
+ try std.testing.expect(rect.x + rect.w <= size);
+ try std.testing.expect(rect.y + rect.h <= size);
+ }
+ }
+ p.ncol = 0;
+ layout.compute(p);
+ for (p.rects) |rect| try std.testing.expectEqual(pardes.Rect{}, rect);
+}
+
+const FileTests = struct {
+ const graphemeDisplayWidth = panes.File.graphemeDisplayWidth;
+ const displayWidth = panes.File.displayWidth;
+ const byteAtDisplay = panes.File.byteAtDisplay;
+ const rawDisplayCol = panes.File.rawDisplayCol;
+ const rawAtDisplay = panes.File.rawAtDisplay;
+ const VisualRow = panes.File.VisualRow;
+ const visualRow = panes.File.visualRow;
+
+ test "display columns map complete Unicode graphemes" {
+ const text = "é界e\u{301}x";
+ try std.testing.expectEqual(@as(usize, 5), displayWidth(text));
+ try std.testing.expectEqual(@as(usize, 0), byteAtDisplay(text, 0));
+ try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 1));
+ try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 2));
+ try std.testing.expectEqual(@as(usize, 5), byteAtDisplay(text, 3));
+ try std.testing.expectEqual(@as(usize, 8), byteAtDisplay(text, 4));
+ try std.testing.expectEqual(text.len, byteAtDisplay(text, 5));
+ try std.testing.expectEqual(@as(usize, 3), rawDisplayCol(text, 5));
+ try std.testing.expectEqual(@as(usize, 5), rawAtDisplay(text, 3));
+ try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("👩\u{200d}🚀"));
+ }
+
+ test "visual rows partition a line at the breaks the body renders" {
+ const line = "abcdefgh";
+ try std.testing.expectEqual(VisualRow{ .start = 0, .end = line.len }, visualRow(line, 5, 0));
+ try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 0, 3));
+ try std.testing.expectEqual(VisualRow{ .start = 0, .end = 3 }, visualRow(line, 2, 3));
+ try std.testing.expectEqual(VisualRow{ .start = 3, .end = 6 }, visualRow(line, 3, 3));
+ // Past the end (a cursor on the newline) names the LAST row, and a short
+ // line is one row however narrow the pane is.
+ try std.testing.expectEqual(VisualRow{ .start = 6, .end = 8 }, visualRow(line, line.len, 3));
+ try std.testing.expectEqual(VisualRow{ .start = 0, .end = 0 }, visualRow("", 0, 3));
+ // A grapheme wider than the row still occupies exactly one row.
+ try std.testing.expectEqual(VisualRow{ .start = 0, .end = 4 }, visualRow("👩x", 0, 1));
+ }
+};
+
+const OutputTests = struct {
+ const Origin = panes.Output.Origin;
+ const max_arg = panes.Output.max_arg;
+ const State = panes.Output.State;
+ const setArg = panes.Output.setArg;
+ const fromWord = panes.Output.fromWord;
+ const fillResults = panes.Output.fillResults;
+
+ test "replacing generated Help clears stale selection before executing a new row" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 });
+ defer p.deinit();
+ _ = try p.setTestFile("notes\n");
+ p.presentation.enabled = false;
+ try std.testing.expect(p.executeBuiltinLine(0, "Help"));
+ const id = p.active;
+ const help = p.panes[id].?;
+ for ("gevkk") |key| p.update(.{ .key = .{ .cp = key } });
+ try std.testing.expect(help.vsel.active);
+ try std.testing.expect(help.vsel.row > 5);
+ try std.testing.expect(help.cur_row != help.vsel.row);
+ for (" h?") |key| p.update(.{ .key = .{ .cp = key } });
+ try std.testing.expectEqual(id, p.active);
+ const content = help.file.?.content;
+ try std.testing.expect(std.mem.startsWith(u8, content, "pardes builtins under SPC h\n"));
+ const at = std.mem.indexOf(u8, content, "Tutor") orelse return error.MissingTutorRow;
+ const row = std.mem.count(u8, content[0..at], "\n");
+ const line_start = if (std.mem.lastIndexOfScalar(u8, content[0..at], '\n')) |nl| nl + 1 else 0;
+ var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[id];
+ const x = rect.x + config.GUTTER + panes.File.gutterWidth(help) + @as(u16, @intCast(at - line_start + 2));
+ const y = (if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H) + @as(u16, @intCast(row));
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = x, .row = y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = x, .row = y } });
+ try std.testing.expect(p.active != id);
+ const tutor = p.panes[p.active].?.file orelse return error.MissingTutor;
+ try std.testing.expect(std.mem.indexOf(u8, tutor.content, "PARDES TUTOR") != null);
+ }
+
+ test "generated result refills preserve unchanged selections and reset changed text" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("notes\n");
+ const content = "first\nsecond\nthird\n";
+ try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, content), null);
+ const id = for (p.panes, 0..) |slot, id| {
+ if (slot != null and slot.?.file != null and slot.?.file.?.output != null) break id;
+ } else return error.MissingResults;
+ const result = p.panes[id].?;
+ p.active = id;
+ for ("Cv3") |key| p.update(.{ .key = .{ .cp = key } });
+ try std.testing.expect(result.nsel > 0);
+ try std.testing.expect(result.select);
+ try std.testing.expect(result.vsel.active);
+ try std.testing.expect(result.normal.count > 0);
+ result.file.?.scroll = 1;
+ result.hscroll = 2;
+ const selected = result.vsel;
+ const selections = result.nsel;
+ const cursor_row = result.cur_row;
+ const cursor_col = result.cur_col;
+
+ try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, content), null);
+ try std.testing.expectEqual(selected, result.vsel);
+ try std.testing.expectEqual(selections, result.nsel);
+ try std.testing.expectEqual(cursor_row, result.cur_row);
+ try std.testing.expectEqual(cursor_col, result.cur_col);
+ try std.testing.expectEqual(@as(usize, 1), result.file.?.scroll);
+ try std.testing.expectEqual(@as(i32, 2), result.hscroll);
+ try std.testing.expect(result.normal.count > 0);
+
+ try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "short\n"), null);
+ try std.testing.expectEqualStrings("short\n", result.file.?.content);
+ try std.testing.expect(!result.vsel.active and !result.msel.active and !result.select);
+ try std.testing.expectEqual(@as(u8, 0), result.nsel);
+ try std.testing.expectEqual(@as(i32, 0), result.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), result.cur_col);
+ try std.testing.expectEqual(@as(usize, 0), result.file.?.scroll);
+ try std.testing.expectEqual(@as(i32, 0), result.hscroll);
+ try std.testing.expectEqual(modal.Normal.State{}, result.normal);
+ }
+
+ test "generated result replacement cancels an in-progress body selection" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("notes\n");
+ p.presentation.enabled = false;
+ try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "first\nsecond\nthird\n"), null);
+ const id = for (p.panes, 0..) |slot, id| {
+ if (slot != null and slot.?.file != null and slot.?.file.?.output != null) break id;
+ } else return error.MissingResults;
+ const result = p.panes[id].?;
+ p.update(.tick);
+ var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(&.{});
+ const rect = p.rects[id];
+ const x = rect.x + config.GUTTER + panes.File.gutterWidth(result);
+ const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y + 1 } });
+ try std.testing.expect(p.drag == .select);
+ try std.testing.expectEqual(.dragging, result.sel[@intFromEnum(config.select_button)].state);
+ try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "short\n"), null);
+ try std.testing.expect(p.drag == .none);
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y + 1 } });
+ for (result.sel) |selection| try std.testing.expectEqual(.none, selection.state);
+ try std.testing.expect(!result.vsel.active and !result.msel.active);
+ }
+
+ test "dump origins accept only builtins that actually own output panes" {
+ try std.testing.expectEqual(Origin{ .cmd = .Help }, fromWord("Help").?);
+ try std.testing.expect(fromWord("Kill") == null);
+ try std.testing.expect(fromWord("Theme") == null);
+ }
+
+ test "result refill identity retains the full bounded argument" {
+ const p = try Pardes.init(std.testing.allocator, .{
+ .tty_only = true,
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+
+ var first: [max_arg]u8 = @splat('a');
+ var second = first;
+ first[200] = 'x';
+ second[200] = 'y';
+
+ try fillResults(
+ p,
+ 0,
+ "/tmp",
+ .search,
+ &first,
+ try p.gpa.dupe(u8, "first\n"),
+ null,
+ );
+ const first_id = p.panes[0].?.search_pane orelse return error.MissingResults;
+ const next_slot = p.freeSlot();
+
+ try fillResults(
+ p,
+ 0,
+ "/tmp",
+ .search,
+ &first,
+ try p.gpa.dupe(u8, "refilled\n"),
+ null,
+ );
+ try std.testing.expectEqual(first_id, p.panes[0].?.search_pane.?);
+ try std.testing.expectEqual(next_slot, p.freeSlot());
+ try std.testing.expectEqualStrings("refilled\n", p.panes[first_id].?.file.?.content);
+
+ try fillResults(
+ p,
+ 0,
+ "/tmp",
+ .search,
+ &second,
+ try p.gpa.dupe(u8, "second\n"),
+ null,
+ );
+ try std.testing.expect(p.panes[0].?.search_pane.? != first_id);
+
+ var output: State = .{ .from = .search };
+ var oversized: [max_arg + 1]u8 = @splat('z');
+ try std.testing.expectError(error.ArgumentTooLong, setArg(&output, &oversized));
+ const slot_before_error = p.freeSlot();
+ try std.testing.expectError(
+ error.ArgumentTooLong,
+ fillResults(
+ p,
+ 0,
+ "/tmp",
+ .search,
+ &oversized,
+ try p.gpa.dupe(u8, "must be freed\n"),
+ null,
+ ),
+ );
+ try std.testing.expectEqual(slot_before_error, p.freeSlot());
+ }
+};
+
+const ImageTests = struct {
+ const State = panes.Image.State;
+ const restore = panes.Image.restore;
+ const toggleGlyphArt = panes.Image.toggleGlyphArt;
+ const togglePalette = panes.Image.togglePalette;
+ const toggleAscii = panes.Image.toggleAscii;
+ const tagPrefix = panes.Image.tagPrefix;
+ const legacySavedPrefix = panes.Image.legacySavedPrefix;
+
+ test "pane-local renderer choices are visible in the image tag" {
+ var state: State = .{ .path = @constCast("/tmp/picture.ppm") };
+ toggleGlyphArt(&state);
+ togglePalette(&state);
+ toggleAscii(&state);
+ const tag = try tagPrefix(std.testing.allocator, &state);
+ defer std.testing.allocator.free(tag);
+ try std.testing.expectEqualStrings(
+ config.tag_image ++ " petscii:on palette:terminal ascii:off /tmp/picture.ppm",
+ tag,
+ );
+ try std.testing.expectEqualStrings(
+ "img /tmp/picture.ppm",
+ legacySavedPrefix(&state, "img /tmp/picture.ppm Keep Del").?,
+ );
+ }
+
+ test "dump restore propagates malformed embedded image bytes" {
+ const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ try std.testing.expect(p.panes[1] == null);
+ try std.testing.expectError(error.InvalidCharacter, restore(p, 1, .{
+ .kind = .image,
+ .tag = "img /tmp/bad.ppm",
+ .body = "",
+ .image = .{ .path = "/tmp/bad.ppm", .bytes_b64 = "A..A" },
+ }));
+ try std.testing.expect(p.panes[1] == null);
+ }
+};
+
+const PdfTests = struct {
+ const enabled = panes.Pdf.enabled;
+ const pdf = panes.Pdf.pdf;
+ const Point = panes.Pdf.Point;
+ const Document = panes.Pdf.Document;
+ const isPath = panes.Pdf.isPath;
+ const legacySavedPrefix = panes.Pdf.legacySavedPrefix;
+ const State = panes.Pdf.State;
+ const probeWord = panes.Pdf.probeWord;
+ const pointAtGeometry = panes.Pdf.pointAtGeometry;
+ const SectionRows = panes.Pdf.SectionRows;
+
+ test "PDF dump paths are recognized independent of MuPDF support" {
+ try std.testing.expect(isPath("manual.pdf"));
+ try std.testing.expect(isPath("MANUAL.PDF"));
+ try std.testing.expect(!isPath("manual.pdf.txt"));
+ try std.testing.expect(!isPath("pdf"));
+ }
+
+ test "legacy PDF prefix is delimited by its stable marker and exact path" {
+ const path = "/tmp/a document.pdf";
+ const tag = "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path ++ " Keep Del";
+ try std.testing.expectEqualStrings(
+ "pdf 2/9 height PdfFit full PdfTint PdfSections " ++ path,
+ legacySavedPrefix(path, tag).?,
+ );
+ }
+
+ test "feature-off PDF state is zero-sized" {
+ if (!enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(State));
+ }
+
+ test "PDF section rows preserve DFS ordinals and sanitise hierarchy" {
+ if (!enabled) return;
+ const entries = [_]pdf.OutlineEntry{
+ .{ .depth = 0, .title = null, .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = " Child\n\tTitle\x01 Café \u{2028} Next ", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = 12, .y = 34 } } },
+ .{ .depth = 0, .title = "", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 0, .x = null, .y = null } } },
+ .{ .depth = 0, .title = "External", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/manual" } },
+ .{ .depth = 0, .title = "Dead branch", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 0, .title = "Unsafe", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:alert" } },
+ .{ .depth = 0, .title = "Linked branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = "Deep link", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/deep" } },
+ };
+ const rows = try SectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries);
+ defer std.testing.allocator.free(rows);
+ try std.testing.expectEqualStrings(
+ "manual.pdf:3:1 [untitled]\n" ++
+ "manual.pdf:3:2 Child Title Café Next\n" ++
+ "manual.pdf:1:3 [empty title]\n" ++
+ "https://example.com/manual External\n" ++
+ "https://example.com/deep Linked branch\n" ++
+ "https://example.com/deep Deep link\n",
+ rows,
+ );
+ try std.testing.expect(SectionRows.resolve(&entries, 4) == null);
+ try std.testing.expect(SectionRows.resolve(&entries, 5) == null);
+ const resolved = try SectionRows.resolveOrdinals(std.testing.allocator, &entries);
+ defer std.testing.allocator.free(resolved);
+ for (entries, 0..) |_, ordinal| {
+ const single = SectionRows.resolve(&entries, ordinal);
+ if (resolved[ordinal] == std.math.maxInt(usize)) {
+ try std.testing.expect(single == null);
+ } else {
+ const bulk = SectionRows.usableDestination(entries[resolved[ordinal]].destination) orelse
+ return error.MissingBulkPdfSectionDestination;
+ try std.testing.expect(single != null);
+ try std.testing.expect(std.meta.eql(single.?, bulk));
+ }
+ }
+
+ // Every allocation site in the ordinal table and output growth remains
+ // atomic: the testing allocator sees each induced failure cleaned up
+ // before the first index at which the whole render can succeed.
+ var rendered = false;
+ for (0..64) |fail_index| {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
+ .fail_index = fail_index,
+ });
+ const failure_gpa = failing.allocator();
+ const attempt = SectionRows.render(failure_gpa, "/tmp/manual.pdf", &entries) catch |err| {
+ try std.testing.expectEqual(error.OutOfMemory, err);
+ continue;
+ };
+ failure_gpa.free(attempt);
+ rendered = true;
+ break;
+ }
+ try std.testing.expect(rendered);
+ }
+
+ test "bulk PDF section resolution matches single Look policy across DFS boundaries" {
+ if (!enabled) return;
+ const entries = [_]pdf.OutlineEntry{
+ .{ .depth = 0, .title = "Resolved root", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = "Unsafe branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:unsafe" } },
+ .{ .depth = 2, .title = "Safe grandchild", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 1, .x = 4, .y = 8 } } },
+ .{ .depth = 1, .title = "Unresolved child", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = "Sibling", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = null, .y = null } } },
+ .{ .depth = 0, .title = "Unresolved root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 0, .title = "Next root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 3, .x = null, .y = null } } },
+ };
+ const bulk = try SectionRows.resolveOrdinals(std.testing.allocator, &entries);
+ defer std.testing.allocator.free(bulk);
+ const none = std.math.maxInt(usize);
+ try std.testing.expectEqualSlices(usize, &.{ 2, none, 2, none, 4, none, 6 }, bulk);
+ for (entries, 0..) |_, ordinal| {
+ const single = SectionRows.resolve(&entries, ordinal);
+ if (bulk[ordinal] == none) {
+ try std.testing.expect(single == null);
+ } else {
+ const destination = SectionRows.usableDestination(entries[bulk[ordinal]].destination) orelse
+ return error.MissingBoundaryPdfSectionDestination;
+ try std.testing.expect(single != null);
+ try std.testing.expect(std.meta.eql(single.?, destination));
+ }
+ }
+ }
+
+ test "PDF word probe owns quads and text without a selection object" {
+ if (comptime !enabled) return;
+ var document = try Document.open("docs/design.pdf");
+ defer document.deinit();
+ var found = try document.search(std.testing.allocator, 0, "Pardes");
+ defer found.deinit(std.testing.allocator);
+ const quad = found.quads[0].quad;
+ const point = Point{
+ .x = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4,
+ .y = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4,
+ };
+
+ // Exactly one shared probe exercises the primitive used by both UI paths;
+ // click-vs-hover equivalence belongs to production structure, not a second
+ // test that can only restate this MuPDF result.
+ var probe = (try probeWord(&document, std.testing.allocator, 0, point)) orelse
+ return error.MissingPdfWordProbe;
+ defer probe.deinit(std.testing.allocator);
+ try std.testing.expectEqual(@as(usize, 0), probe.page);
+ try std.testing.expect(probe.quads.len > 0);
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(probe.text, "Pardes") != null);
+ }
+
+ test "PDF point mapping restores band origin and fractional placement" {
+ if (comptime !enabled) return;
+ const point = pointAtGeometry(
+ .{
+ .src = .{ .x = 20, .y = 10, .w = 40, .h = 20 },
+ .dst = .{ .x = 100, .y = 30, .w = 80, .h = 20 },
+ },
+ -0.5,
+ 200,
+ 200,
+ 80,
+ 120,
+ 34,
+ false,
+ ) orelse return error.MissingPdfMappedPoint;
+ // x: src 30; y: band 80 + local src 14. The returned coordinates address
+ // pixel centers, matching MuPDF's normalized page-space contract.
+ try std.testing.expectApproxEqAbs(@as(f32, 30.5 / 200.0), point.x, 0.000_001);
+ try std.testing.expectApproxEqAbs(@as(f32, 94.5 / 200.0), point.y, 0.000_001);
+ }
+};
+
+const TerminalTests = struct {
+ const gridOffset = panes.Terminal.gridOffset;
+ const scrollGrid = panes.Terminal.scrollGrid;
+ const promptInputReady = panes.Terminal.promptInputReady;
+ const forwardKey = panes.Terminal.forwardKey;
+ const enterTty = panes.Terminal.enterTty;
+
+ // Align normal-mode glyphs with the VT grid and compare their styles.
+ fn modeStyleDiffs(p: *Pardes, pane: *Pane, gpa: std.mem.Allocator, note: []const u8) !usize {
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const rows: usize = r.h - pardes.BOX_H;
+ const cols: usize = r.w -| config.GUTTER;
+
+ const Snap = struct { text: [][7]u8, len: []u8, style: []pardes.CellStyle };
+ const glyphAt = struct {
+ fn f(sn: Snap, i: usize) []const u8 {
+ return sn.text[i][0..sn.len[i]];
+ }
+ }.f;
+ var shot: [2]Snap = undefined;
+ for (&shot) |*sn| {
+ sn.text = try gpa.alloc([7]u8, rows * cols);
+ sn.len = try gpa.alloc(u8, rows * cols);
+ sn.style = try gpa.alloc(pardes.CellStyle, rows * cols);
+ }
+ defer for (&shot) |*sn| {
+ gpa.free(sn.text);
+ gpa.free(sn.len);
+ gpa.free(sn.style);
+ };
+
+ for ([_]pardes.Pane.Mode{ .tty, .normal }, 0..) |mode, i| {
+ pane.mode = mode;
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const s = try p.render(frame.allocator());
+ for (0..rows) |row| for (0..cols) |col| {
+ const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row)));
+ shot[i].text[row * cols + col] = cell.text;
+ shot[i].len[row * cols + col] = cell.len;
+ shot[i].style[row * cols + col] = cell.style;
+ };
+ }
+
+ var diffs: usize = 0;
+ for (0..rows) |row| {
+ const base = row * cols;
+ // The glyph run normal mode shows, and where it ends.
+ var last: ?usize = null;
+ for (0..cols) |col| {
+ if (!std.mem.eql(u8, glyphAt(shot[1], base + col), " ")) last = col;
+ }
+ const end = last orelse continue; // blank row: nothing to align
+
+ // Recover the shift: the first offset at which tty mode spells the same
+ // run. Zero for every row no prompt was hugged out of.
+ const shift = shift: {
+ var s: usize = 0;
+ while (s + end < cols) : (s += 1) {
+ var all = true;
+ for (0..end + 1) |col| {
+ if (!std.mem.eql(u8, glyphAt(shot[1], base + col), glyphAt(shot[0], base + col + s))) {
+ all = false;
+ break;
+ }
+ }
+ if (all) break :shift s;
+ }
+ var tty_row: [256]u8 = undefined;
+ var nrm_row: [256]u8 = undefined;
+ var tn: usize = 0;
+ var nn: usize = 0;
+ for (0..cols) |col| {
+ const tg = glyphAt(shot[0], base + col);
+ const ng = glyphAt(shot[1], base + col);
+ if (tn + tg.len < tty_row.len) {
+ @memcpy(tty_row[tn..][0..tg.len], tg);
+ tn += tg.len;
+ }
+ if (nn + ng.len < nrm_row.len) {
+ @memcpy(nrm_row[nn..][0..ng.len], ng);
+ nn += ng.len;
+ }
+ }
+ std.debug.print("\n[{s}] row {d} unalignable\n tty: '{s}'\nnormal: '{s}'\n", .{ note, row, tty_row[0..tn], nrm_row[0..nn] });
+ diffs += 1;
+ break :shift null;
+ } orelse continue;
+
+ // Every column the shift can reach, not just the ones holding a glyph:
+ // a cell with a background and no text (`\x1b[41m\x1b[K`, a padded
+ // table cell) carries colour too, and is exactly what a shell paints
+ // most of.
+ for (0..cols - shift) |col| {
+ const want = shot[0].style[base + col + shift];
+ const got = shot[1].style[base + col];
+ if (std.meta.eql(want, got)) continue;
+ if (diffs < 6) std.debug.print(
+ "\n[{s}] row {d} col {d} (shift {d}) glyph '{s}': tty fg={any} bg={any} rev={} ul={any} | normal fg={any} bg={any} rev={} ul={any}",
+ .{ note, row, col, shift, glyphAt(shot[1], base + col), want.fg, want.bg, want.reverse, want.ul, got.fg, got.bg, got.reverse, got.ul },
+ );
+ diffs += 1;
+ }
+ }
+ if (diffs > 0) std.debug.print("\n[{s}] {d} style mismatches\n", .{ note, diffs });
+ return diffs;
+ }
+
+ test "fresh-shell greeting waits for OSC 133 B input phase" {
+ if (pardes.platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ while (p.nextEffect()) |_| {} // initial spawn
+
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.GreetedBeforeOutput,
+ else => {},
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.GreetedBeforePrompt,
+ else => {},
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } });
+ try std.testing.expect(!promptInputReady(pane));
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.GreetedDuringPrompt,
+ else => {},
+ };
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } });
+ try std.testing.expect(promptInputReady(pane));
+ var greeted = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"),
+ else => {},
+ };
+ try std.testing.expect(greeted);
+ try std.testing.expect(!pane.greet);
+ }
+
+ test "fresh-shell commands preserve order and wait for OSC 133 B" {
+ if (pardes.platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet
+
+ try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first"));
+ try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second"));
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.CommandEscapedBeforeFork,
+ else => {},
+ };
+
+ p.acknowledgeShell(0, "/bin/bash", true);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.CommandEscapedBeforePrompt,
+ else => {},
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.CommandEscapedDuringPrompt,
+ else => {},
+ };
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } });
+ var sent: [64]u8 = undefined;
+ var sent_len: usize = 0;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |write| {
+ const bytes = write.bytes.slice();
+ @memcpy(sent[sent_len..][0..bytes.len], bytes);
+ sent_len += bytes.len;
+ },
+ else => {},
+ };
+ try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]);
+ try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait);
+ }
+
+ test "unmarked fresh shells omit the automatic greeting" {
+ if (pardes.platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{});
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ while (p.nextEffect()) |_| {}
+ try std.testing.expect(pane.greet);
+
+ p.acknowledgeShell(0, "/bin/sh", false);
+ try std.testing.expect(!pane.greet);
+ try std.testing.expectEqual(.none, pane.pending_command.wait);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.UnmarkedGreetingEscaped,
+ else => {},
+ };
+ }
+
+ test "raw terminal keys encode text controls and special sequences" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const Case = struct { key: Key, expected: ?[]const u8 };
+ const cases = [_]Case{
+ .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" },
+ .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" },
+ .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" },
+ .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" },
+ .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" },
+ .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" },
+ .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" },
+ .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" },
+ .{ .key = .{ .cp = Key.home }, .expected = null },
+ };
+ for (cases) |case| {
+ forwardKey(p, 0, case.key);
+ const expected = case.expected orelse {
+ try std.testing.expect(p.nextEffect() == null);
+ continue;
+ };
+ const effect = p.nextEffect() orelse return error.MissingWriteEffect;
+ switch (effect) {
+ .write => |write| {
+ try std.testing.expectEqual(@as(u8, 0), write.pane);
+ try std.testing.expectEqualStrings(expected, write.bytes.slice());
+ },
+ else => return error.UnexpectedEffect,
+ }
+ try std.testing.expect(p.nextEffect() == null);
+ }
+ }
+
+ test "an edit buffer slides shell rows and their colors together" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+ const green: pardes.Color = .{ .index = 2 };
+ const blue: pardes.Color = .{ .index = 4 };
+
+ p.shell_rows.stale = true;
+ const before = try p.render(frame.allocator());
+ try testing.expectEqual(red, before.at(tx, body_y).style.fg);
+ try testing.expectEqual(green, before.at(tx, body_y + 1).style.fg);
+ try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg);
+
+ // Four lines of typed text standing in for the ONE shell row `AAA` was:
+ // every row below slides down by three, and `surfRow` is the arithmetic
+ // that says so. The colours have to take the same three rows, or `BBB`
+ // would be painted green three rows above where it is now drawn.
+ pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") };
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("B", after.at(tx, body_y + 4).grapheme());
+ try testing.expectEqualStrings("C", after.at(tx, body_y + 5).grapheme());
+ try testing.expectEqual(green, after.at(tx, body_y + 4).style.fg);
+ try testing.expectEqual(blue, after.at(tx, body_y + 5).style.fg);
+
+ // ...and the rows the user typed are the user's own text: no shell row
+ // sits under them, so nothing projects a colour onto them.
+ for (0..4) |i| {
+ const cell = after.at(tx, body_y + @as(u16, @intCast(i)));
+ try testing.expect(!std.meta.eql(red, cell.style.fg));
+ try testing.expect(!std.meta.eql(green, cell.style.fg));
+ try testing.expect(!std.meta.eql(blue, cell.style.fg));
+ }
+ }
+
+ test "a combining mark in the prompt keeps the command and its colors aligned" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ // A ONE-cell prompt carrying a combining mark — an NFD `e` — then `ABC`
+ // typed at it. The cell walk that finds the prompt's end must step ONE
+ // grapheme for that cell, not one per stored codepoint: stepping twice ate
+ // the `A`, and left every colour a cell to the left of its glyph with the
+ // last one stranded on a blank.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32me\u{301}\x1b]133;B\x1b\\\x1b[31mA\x1b[34mB\x1b[35mC" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+ try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme());
+ try testing.expectEqualStrings("B", s.at(tx + 1, body_y).grapheme());
+ try testing.expectEqualStrings("C", s.at(tx + 2, body_y).grapheme());
+ try testing.expectEqual(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx + 2, body_y).style.fg);
+ // ...and no colour past the end of what the row actually says
+ try testing.expect(!std.meta.eql(pardes.Color{ .index = 5 }, s.at(tx + 3, body_y).style.fg));
+ }
+
+ test "colors are never taken from shell rows below the viewport" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ // Raw palette, so a leaked background reads back as `.index` — the theme
+ // filter would repaint every blank cell and hide the evidence.
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ // Sixty rows, each a distinct background, so a leaked colour names the row
+ // it leaked from.
+ for (0..60) |i| {
+ var buf: [32]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[4{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ p.shell_rows.stale = true;
+ scrollGrid(pane, -20);
+
+ // ONE buffer line standing in for SIX shell rows: everything below slides
+ // UP five, so the last rows of the body resolve past the viewport's bottom
+ // edge. `PageList.pin` answers for those rows anyway — it walks down the
+ // pagelist, not the viewport — so without a bound of its own this pass
+ // painted the scrollback's colours onto rows the text pass left blank.
+ const anchor = gridOffset(pane);
+ pane.ovl = .{ .row = anchor, .rows = 6, .text = try p.gpa.dupe(u8, "one") };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_h = r.h - pardes.BOX_H;
+
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+ // A body row the text pass left blank has no shell row under it, so no
+ // ANSI background may have reached it. Every colour in the payload above is
+ // an indexed one, so a leak is exactly an `.index` background on a blank row.
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ var blank = true;
+ var c: u16 = 0;
+ while (c < r.w -| config.GUTTER) : (c += 1) {
+ if (!std.mem.eql(u8, " ", s.at(tx + c, body_y + vr).grapheme())) blank = false;
+ }
+ if (!blank) continue;
+ c = 0;
+ while (c < r.w -| config.GUTTER) : (c += 1) {
+ const bg = s.at(tx + c, body_y + vr).style.bg;
+ try testing.expect(std.meta.activeTag(bg) != .index);
+ }
+ }
+ }
+
+ test "a row the edit buffer only swallowed keeps its color" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+ const green: pardes.Color = .{ .index = 2 };
+ const blue: pardes.Color = .{ .index = 4 };
+
+ // The buffer only ever grows, so after a few edits it covers rows nobody
+ // touched. Here it spans all three and only the MIDDLE line differs: the
+ // first and last are still byte-identical to the shell rows they were
+ // seeded from, so they still stand over them and keep their colours.
+ pane.ovl = .{ .row = 0, .rows = 3, .text = try p.gpa.dupe(u8, "AAA\nXXX\nCCC") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("A", s.at(tx, body_y).grapheme());
+ try testing.expectEqualStrings("X", s.at(tx, body_y + 1).grapheme());
+ try testing.expectEqualStrings("C", s.at(tx, body_y + 2).grapheme());
+ try testing.expectEqual(red, s.at(tx, body_y).style.fg);
+ try testing.expectEqual(blue, s.at(tx, body_y + 2).style.fg);
+ // ...and the line that actually changed is the user's own text now
+ try testing.expect(!std.meta.eql(green, s.at(tx, body_y + 1).style.fg));
+ }
+
+ test "an edit buffer reaching past the dumped rows colors nothing from row zero" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .index = 1 };
+
+ // Covers far more rows than the grid was ever dumped for, so the anchor
+ // table cannot be built and answers "no shell row" for every line. The
+ // zeroed table must not read as "the last line sits on the buffer's first
+ // row", which claimed row zero's colour and underflowed on every line after.
+ pane.ovl = .{ .row = 1, .rows = 50, .text = try p.gpa.dupe(u8, "p\nq\nr") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("p", s.at(tx, body_y + 1).grapheme());
+ var i: u16 = 1;
+ while (i <= 3) : (i += 1) {
+ try testing.expect(!std.meta.eql(red, s.at(tx, body_y + i).style.fg));
+ }
+ }
+
+ test "a prompted session keeps every glyph's color in normal mode" {
+ const testing = std.testing;
+ const payload =
+ "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mls \x1b[33m-la\x1b[0m\r\n" ++
+ "\x1b[34mdir1\x1b[0m \x1b[32mexec\x1b[0m plain.txt\r\n" ++
+ "\x1b[31merror: nope\x1b[0m\r\n" ++
+ "\x1b]133;A\x1b\\\x1b[32muser\x1b[34m@host\x1b[35m ~/dir\x1b[0m$ \x1b]133;B\x1b\\\x1b[36mecho \x1b[1;37mhi\x1b[0m\r\n" ++
+ "\x1b[38;5;208mhi\x1b[0m\r\n";
+
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 44, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ p.update(.{ .output = .{ .pane = 0, .bytes = payload } });
+ const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "session filter=on" else "session filter=off");
+ try testing.expectEqual(@as(usize, 0), diffs);
+ }
+ }
+
+ test "an emoji prompt neither eats the command nor slides its colors" {
+ const testing = std.testing;
+ // ABSOLUTE assertions, not a tty/normal comparison: ghostty and this
+ // surface can BOTH be wrong about a cluster's width, and then a differential
+ // agrees with itself while the user sees the wrong thing. What is typed at
+ // the prompt is what must appear, each character wearing its own colour.
+ //
+ // Ghostty splits these clusters across cells and spells each one in the row
+ // dump, so the cell walk and the byte walk only agree if the byte walk is
+ // driven by what each CELL contributed. `👨‍💻` is two wide cells, `👨‍👩‍👧`
+ // three, `🇺🇸` two, `👍🏽` two, while all of them print as one glyph here.
+ const prompts = [_][]const u8{
+ "plain",
+ "\u{1F468}\u{200D}\u{1F4BB}", // technologist
+ "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", // family
+ "\u{1F1FA}\u{1F1F8}", // flag
+ "\u{1F44D}\u{1F3FD}", // thumbs up, skin tone
+ "\u{2764}\u{FE0F}", // heart, VS16
+ "\u{0031}\u{FE0F}\u{20E3}", // keycap
+ "\u{754C}", // CJK wide
+ "e\u{301}", // NFD
+ };
+ for (prompts) |prompt| {
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ pane.mode = .normal;
+
+ var buf: [256]u8 = undefined;
+ const bytes = try std.fmt.bufPrint(
+ &buf,
+ "\x1b]133;A\x1b\\\x1b[32m{s}$ \x1b]133;B\x1b\\\x1b[31mab\x1b[34mcd\x1b[0m",
+ .{prompt},
+ );
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ for ([_][]const u8{ "a", "b", "c", "d" }, 0..) |want, i| {
+ const cell = s.at(tx + @as(u16, @intCast(i)), body_y);
+ testing.expectEqualStrings(want, cell.grapheme()) catch |err| {
+ std.debug.print("\nprompt '{s}' filter={}: col {d}\n", .{ prompt, filter, i });
+ return err;
+ };
+ }
+ // `ab` was printed red and `cd` blue, so whatever the theme does
+ // with those two runs, the pair boundary has to fall between `b`
+ // and `c`. A prompt that cost the row a character shows up here as
+ // the boundary sliding onto the wrong glyph.
+ const fg = [_]pardes.Color{
+ s.at(tx, body_y).style.fg,
+ s.at(tx + 1, body_y).style.fg,
+ s.at(tx + 2, body_y).style.fg,
+ s.at(tx + 3, body_y).style.fg,
+ };
+ errdefer std.debug.print("\nprompt '{s}' filter={}: fg {any}\n", .{ prompt, filter, fg });
+ try testing.expect(std.meta.eql(fg[0], fg[1]));
+ try testing.expect(std.meta.eql(fg[2], fg[3]));
+ try testing.expect(!std.meta.eql(fg[1], fg[2]));
+ if (!filter) {
+ try testing.expectEqual(pardes.Color{ .index = 1 }, fg[0]);
+ try testing.expectEqual(pardes.Color{ .index = 4 }, fg[2]);
+ }
+ }
+ }
+ }
+
+ test "background-only cells keep their color through the prompt hug" {
+ const testing = std.testing;
+ // Backgrounds with no glyph under them are most of what a shell paints:
+ // erase-to-end-of-line after a colour is set, padded table cells, and a
+ // selected row. They have no text to align on, so they are the cells a
+ // column translation is most likely to lose.
+ const payload =
+ "\x1b]133;A\x1b\\\x1b[32mp\x1b[0m$ \x1b]133;B\x1b\\cmd\x1b[41m\x1b[K\r\n" ++
+ "\x1b[44mblue-bg\x1b[K\x1b[0m\r\n" ++
+ "a\x1b[42m \x1b[0mb\r\n" ++
+ "\x1b[100;97mbright-on-grey\x1b[0m\r\n" ++
+ "\x1b]133;A\x1b\\\x1b[35m>>\x1b[0m \x1b]133;B\x1b\\\x1b[48;5;19mrun\x1b[K\x1b[0m\r\n" ++
+ "\x1b[48;2;90;10;10mtruecolor-bg\x1b[K\x1b[0m\r\n";
+
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ p.update(.{ .output = .{ .pane = 0, .bytes = payload } });
+ const diffs = try modeStyleDiffs(p, pane, testing.allocator, if (filter) "bg filter=on" else "bg filter=off");
+ try testing.expectEqual(@as(usize, 0), diffs);
+ }
+ }
+
+ test "a leftover edit buffer does not move what tty mode shows" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .tty;
+
+ for (0..60) |i| {
+ var buf: [40]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[3{d}mL{d:0>2}\x1b[0m\r\n", .{ (i % 6) + 1, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const rows: usize = r.h - pardes.BOX_H;
+ const cols: usize = r.w -| config.GUTTER;
+
+ // What tty mode shows with nothing left behind: the reference.
+ p.shell_rows.stale = true;
+ const clean = try p.render(frame.allocator());
+ const want_text = try testing.allocator.alloc([7]u8, rows * cols);
+ defer testing.allocator.free(want_text);
+ const want_fg = try testing.allocator.alloc(pardes.Color, rows * cols);
+ defer testing.allocator.free(want_fg);
+ for (0..rows) |row| for (0..cols) |col| {
+ const cell = clean.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row)));
+ want_text[row * cols + col] = cell.text;
+ want_fg[row * cols + col] = cell.style.fg;
+ };
+
+ // `enterTty` clears every other modal remnant but leaves the edit buffer, so
+ // a buffer whose covered span STRADDLES the viewport top is an ordinary
+ // state. tty mode does not apply the buffer, so it must not be moved by one
+ // either — and `surfRow`/`gridRow` are not inverses across that span.
+ const anchor = gridOffset(pane);
+ pane.ovl = .{ .row = anchor - 1, .rows = 4, .text = try p.gpa.dupe(u8, "one\ntwo") };
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+
+ for (0..rows) |row| for (0..cols) |col| {
+ const cell = after.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(row)));
+ try testing.expectEqualStrings(
+ std.mem.sliceTo(&want_text[row * cols + col], 0),
+ std.mem.sliceTo(&cell.text, 0),
+ );
+ try testing.expectEqual(want_fg[row * cols + col], cell.style.fg);
+ };
+ }
+
+ test "a background after a row-final wide glyph lands on the right columns" {
+ const testing = std.testing;
+ // A CJK glyph then a coloured erase-to-end-of-line, with a second colour
+ // partway. The glyph's grid tail spells no bytes, so the pairing walk used
+ // to stop ON it and pair every later column with the cell before it: an
+ // unpainted hole beside the glyph and every boundary one column right.
+ //
+ // ABSOLUTE assertions: both modes were wrong identically here, so a
+ // tty/normal differential says nothing.
+ for ([_]bool{ false, true }) |filter| {
+ for ([_]pardes.Pane.Mode{ .tty, .normal }) |mode| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ pane.mode = mode;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[32m\u{754C}\x1b[41m\x1b[K\x1b[7G\x1b[44m\x1b[K\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("\u{754C}", s.at(tx, body_y).grapheme());
+ // The glyph covers columns 0-1; red runs from 2 up to the second
+ // erase at column 6 (1-based 7), blue from there to the edge.
+ const red = s.at(tx + 3, body_y).style.bg;
+ const blue = s.at(tx + 9, body_y).style.bg;
+ errdefer std.debug.print("\nmode={any} filter={}: red={any} blue={any} col2={any}\n", .{ mode, filter, red, blue, s.at(tx + 2, body_y).style.bg });
+ try testing.expect(!std.meta.eql(red, blue));
+ for (2..6) |c| try testing.expectEqual(red, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg);
+ for (6..10) |c| try testing.expectEqual(blue, s.at(tx + @as(u16, @intCast(c)), body_y).style.bg);
+ }
+ }
+ }
+
+ test "a colored row reaches its last column when a wide glyph did not fit" {
+ const testing = std.testing;
+ // Thirteen cells of red background, then a wide glyph with one column left:
+ // ghostty leaves a `spacer_head` in that last column, carrying the row's
+ // background, and wraps the glyph to the next row. A head OWNS its column,
+ // so skipping it the way a tail is skipped left the row's final column bare.
+ for ([_]pardes.Pane.Mode{ .tty, .normal }) |mode| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 16, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = mode;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[41mzzzzzzzzzzzzz\u{754C}\x1b[0m\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ const red: pardes.Color = .{ .index = 1 };
+ var c: u16 = 0;
+ while (c < r.w -| config.GUTTER) : (c += 1) {
+ errdefer std.debug.print("\nmode={any} col {d} bg={any}\n", .{ mode, c, s.at(tx + c, body_y).style.bg });
+ try testing.expectEqual(red, s.at(tx + c, body_y).style.bg);
+ }
+ }
+ }
+
+ test "tty colours survive a scrollback deeper than the pane" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .tty;
+ for (0..40) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mline-{d:0>2}\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const s = try p.render(frame.allocator());
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ var bad: usize = 0;
+ for (0..r.h -| pardes.BOX_H) |vr| {
+ var buf: [16]u8 = undefined;
+ var n: usize = 0;
+ for (0..10) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ if (g.len != 1) break;
+ buf[n] = g[0];
+ n += 1;
+ }
+ const txt = buf[0..n];
+ if (!std.mem.startsWith(u8, txt, "line-")) continue;
+ const num = std.fmt.parseInt(usize, std.mem.trim(u8, txt[5..], " "), 10) catch continue;
+ const want = pardes.Color{ .index = @intCast(20 + num) };
+ const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg;
+ if (!std.meta.eql(want, got)) {
+ bad += 1;
+ std.debug.print("row {d}: text {s} want {any} got {any}\n", .{ vr, txt, want, got });
+ }
+ }
+ try testing.expectEqual(@as(usize, 0), bad);
+ }
+
+ test "reverse video swaps the default colors with the filter off too" {
+ const testing = std.testing;
+ // DECSCNM is a property of the terminal, not of a cell's SGR, so it has to
+ // be honoured on BOTH colour paths. The theme filter folds it into its own
+ // palette; the raw path resolves a `.none` colour by role, and simply
+ // dropped reverse video altogether.
+ for ([_]bool{ false, true }) |filter| {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 20, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = filter;
+ pane.mode = .normal;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "plain text\r\n" } });
+ p.shell_rows.stale = true;
+ const before = try p.render(frame.allocator());
+ const plain = before.at(tx, body_y).style;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+ const reversed = after.at(tx, body_y).style;
+
+ errdefer std.debug.print("\nfilter={}: plain fg={any} bg={any} | reversed fg={any} bg={any}\n", .{ filter, plain.fg, plain.bg, reversed.fg, reversed.bg });
+ try testing.expectEqual(plain.fg, reversed.bg);
+ try testing.expectEqual(plain.bg, reversed.fg);
+ }
+ }
+
+ test "untouched lines between two edits keep their colors" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ for (0..6) |i| {
+ var buf: [40]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}\x1b[0m\r\n", .{ 16 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ // The state two ordinary edits reach: one at the bottom, one that split a
+ // line further up. The buffer now spans rows 2..6 and diverges at BOTH
+ // ends, with three untouched lines in the middle. Matching a leading and a
+ // trailing run stops at the first divergence and drains exactly those three;
+ // each line carries its own evidence, so each is anchored on its own.
+ pane.ovl = .{ .row = 2, .rows = 5, .text = try p.gpa.dupe(u8, "r\now-02\nrow-03\nrow-04\nrow-05\nZ") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ // body row 2+k shows buffer line k; lines 2..4 are `row-03`..`row-05`
+ for (0..3) |k| {
+ const vr = @as(u16, @intCast(4 + k));
+ var buf: [8]u8 = undefined;
+ const want_text = std.fmt.bufPrint(&buf, "row-{d:0>2}", .{3 + k}) catch unreachable;
+ const cell = s.at(tx, body_y + vr);
+ errdefer std.debug.print("\nbody row {d}: glyph '{s}' fg {any}\n", .{ vr, cell.grapheme(), cell.style.fg });
+ try testing.expectEqualStrings(want_text[0..1], cell.grapheme());
+ try testing.expectEqual(pardes.Color{ .index = @intCast(19 + k) }, cell.style.fg);
+ }
+ }
+
+ test "an emptied edit buffer does not shift the colors below it" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31m000\x1b[0m\r\n\x1b[32m111\x1b[0m\r\n\r\n\x1b[34m333\x1b[0m\r\n\x1b[35m444\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ // The state three keystrokes reach on any blank shell row: type a character
+ // and delete it, and the buffer holds NO text while still standing in for
+ // the row. `modal.lineCount("")` is 0 while `splitScalar("")` yields one
+ // line, so anything deriving the slide from the former puts every colour
+ // below here one row too far down — and drops the bottom row's entirely.
+ pane.ovl = .{ .row = 2, .rows = 1, .text = try p.gpa.dupe(u8, "") };
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings("3", s.at(tx, body_y + 3).grapheme());
+ try testing.expectEqualStrings("4", s.at(tx, body_y + 4).grapheme());
+ try testing.expectEqual(pardes.Color{ .index = 4 }, s.at(tx, body_y + 3).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 5 }, s.at(tx, body_y + 4).style.fg);
+ // ...and the user's own empty line takes no colour from the row beneath it
+ try testing.expect(!std.meta.eql(pardes.Color{ .index = 4 }, s.at(tx, body_y + 2).style.fg));
+ }
+
+ test "an edit overlay never changes tty-mode ansi colors" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .tty;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const blue: pardes.Color = .{ .index = 4 };
+
+ p.shell_rows.stale = true;
+ const before = try p.render(frame.allocator());
+ try testing.expectEqualStrings("C", before.at(tx, body_y + 2).grapheme());
+ try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg);
+
+ // A lingering multi-line edit overlay must not move any shell row's colour.
+ pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") };
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const after = try p.render(frame.allocator());
+ try testing.expectEqualStrings("C", after.at(tx, body_y + 2).grapheme());
+ try testing.expectEqual(blue, after.at(tx, body_y + 2).style.fg);
+ }
+
+ test "an edited row keeps the colours of the bytes the edit did not touch" {
+ const testing = std.testing;
+ // The loudest colour bug this editor had: one keystroke anywhere in a
+ // coloured row turned EVERY column of it grey, because an anchor was all or
+ // nothing. The row's own bytes survive at both ends of what was typed, and
+ // being the same bytes they keep the same colours; only the typed character
+ // has no cell under it and so takes none.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 30, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..6) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d}-abcdefgh\x1b[0m\r\n", .{ 30 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ // One `Z` typed into the middle of row 3's own text.
+ pane.ovl = .{ .row = 3, .rows = 1, .text = try p.gpa.dupe(u8, "row-3-abcZdefgh") };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const s = try p.render(frame.allocator());
+ const want = pardes.Color{ .index = 33 };
+ var seen = false;
+ for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| {
+ var buf: [15]u8 = undefined;
+ for (0..15) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ if (!std.mem.eql(u8, &buf, "row-3-abcZdefgh")) continue;
+ seen = true;
+ for (0..15) |c| {
+ const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nedited row col {d} ('{c}') fg={any}\n", .{ c, buf[c], got });
+ // Column 9 is the typed `Z`; every other column is row 3's own.
+ if (c == 9) try testing.expect(!std.meta.eql(want, got)) else try testing.expectEqual(want, got);
+ }
+ }
+ try testing.expect(seen);
+ }
+
+ test "joining two rows leaves the rows below them their colours" {
+ const testing = std.testing;
+ // A join removes a buffer line while the buffer's covered span GROWS, so the
+ // two counts cancel at `lines == covered`. Anchoring that only counts down
+ // from the buffer's top and up from its bottom then resolves both ways to
+ // the SAME row, one short of where the lines below live, and every untouched
+ // row under the join went plain. This is the state four keystrokes reach
+ // (Enter, then a backspace two rows up), taken from the fuzzer that found it.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..26) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ pane.ovl = .{
+ .row = 23,
+ .rows = 4,
+ .text = try p.gpa.dupe(u8, "row-23-xyzzyrow-24-xyzzy\nrow-25-xyzzy\n\n"),
+ };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const s = try p.render(frame.allocator());
+ var seen = false;
+ for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| {
+ var buf: [12]u8 = undefined;
+ for (0..12) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ if (!std.mem.eql(u8, &buf, "row-25-xyzzy")) continue;
+ seen = true;
+ // The join is above it and its own text is untouched, so every column
+ // still carries row 25's own colour.
+ for (0..12) |c| {
+ const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nrow-25 col {d} fg={any}\n", .{ c, got });
+ try testing.expectEqual(pardes.Color{ .index = 45 }, got);
+ }
+ }
+ try testing.expect(seen);
+ }
+
+ test "an untouched row always carries the colour its own text names" {
+ const testing = std.testing;
+ // Random editing, absolute oracle: every row's own text names the colour it
+ // must have, so no sequence of keystrokes may leave an UNTOUCHED row wearing
+ // anything else. This is what found the join above, and the empty line that
+ // claimed a blank row far below it and took every coloured row in between
+ // out of reach of the lines that owned them.
+ var seed: u64 = 0;
+ while (seed < 40) : (seed += 1) {
+ var prng = std.Random.DefaultPrng.init(seed);
+ const rand = prng.random();
+
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ // The raw palette, so a row's text names its exact colour instead of one
+ // this test would have to re-derive from the theme.
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..26) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_h = r.h -| pardes.BOX_H;
+
+ var step: usize = 0;
+ while (step < 12) : (step += 1) {
+ _ = frame.reset(.retain_capacity);
+ const s = try p.render(frame.allocator());
+ for (0..body_h) |vr| {
+ var buf: [24]u8 = undefined;
+ for (0..24) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ const txt = std.mem.trimEnd(u8, buf[0..24], " ");
+ if (txt.len != 12) continue;
+ if (!std.mem.startsWith(u8, txt, "row-") or !std.mem.endsWith(u8, txt, "-xyzzy")) continue;
+ const num = std.fmt.parseInt(usize, txt[4..6], 10) catch continue;
+ const want = pardes.Color{ .index = @intCast(20 + num) };
+ for (0..txt.len) |c| {
+ const got = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nseed {d} step {d}: untouched '{s}' col {d} fg={any}\n", .{ seed, step, txt, c, got });
+ try testing.expectEqual(want, got);
+ }
+ }
+
+ switch (rand.intRangeAtMost(u8, 0, 10)) {
+ 0 => p.update(.{ .key = .{ .cp = pardes.Key.up } }),
+ 1 => p.update(.{ .key = .{ .cp = pardes.Key.down } }),
+ 2 => p.update(.{ .key = .{ .cp = pardes.Key.left } }),
+ 3 => p.update(.{ .key = .{ .cp = pardes.Key.right } }),
+ 4 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = 'Q', .text = "Q" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 5 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.enter } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 6 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.backspace } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 7 => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = 'W', .text = "W" } });
+ p.update(.{ .key = .{ .cp = 'W', .text = "W" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ 8 => p.update(.{ .key = .{ .cp = pardes.Key.home } }),
+ 9 => p.update(.{ .key = .{ .cp = pardes.Key.end } }),
+ else => {
+ p.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ p.update(.{ .key = .{ .cp = pardes.Key.delete } });
+ p.update(.{ .key = .{ .cp = pardes.Key.escape } });
+ },
+ }
+ while (p.nextEffect()) |_| {}
+ }
+ }
+ }
+
+ test "a new empty line does not take the colours of the rows below it" {
+ const testing = std.testing;
+ // Splitting a row makes an EMPTY buffer line, and empty equals every blank
+ // row in the buffer's span - including the one under the last output. Left
+ // free to look ahead for a row spelling the same bytes, that line claimed
+ // the blank row far below and put every coloured row in between out of
+ // reach of the lines that owned them. Two keystrokes (Home, Enter) got here.
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 14 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ for (0..26) |i| {
+ var buf: [64]u8 = undefined;
+ const bytes = std.fmt.bufPrint(&buf, "\x1b[38;5;{d}mrow-{d:0>2}-xyzzy\x1b[0m\r\n", .{ 20 + i, i }) catch unreachable;
+ p.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ }
+ // A newline typed at column 0 of row 24, and `WW` typed on the blank row
+ // below the output: the span covers rows 24, 25 and that blank row.
+ pane.ovl = .{
+ .row = 24,
+ .rows = 3,
+ .text = try p.gpa.dupe(u8, "\nrow-24-xyzzy\nrow-25-xyzzy\nWW"),
+ };
+ p.shell_rows.stale = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const s = try p.render(frame.allocator());
+ var seen: usize = 0;
+ for (0..@as(usize, r.h -| pardes.BOX_H)) |vr| {
+ var buf: [12]u8 = undefined;
+ for (0..12) |c| {
+ const g = s.at(tx + @as(u16, @intCast(c)), body_y + @as(u16, @intCast(vr))).grapheme();
+ buf[c] = if (g.len == 1) g[0] else '?';
+ }
+ if (!std.mem.startsWith(u8, &buf, "row-2")) continue;
+ const num = std.fmt.parseInt(usize, buf[4..6], 10) catch continue;
+ if (num != 24 and num != 25) continue;
+ seen += 1;
+ const got = s.at(tx, body_y + @as(u16, @intCast(vr))).style.fg;
+ errdefer std.debug.print("\nrow-{d} fg={any}\n", .{ num, got });
+ try testing.expectEqual(pardes.Color{ .index = @intCast(20 + num) }, got);
+ }
+ try testing.expectEqual(@as(usize, 2), seen);
+ }
+};
+
+test {
+ _ = @import("output.zig");
+ _ = @import("pdf.zig");
+ _ = @import("fs_namespace.zig");
+ _ = @import("hxdiff.zig");
+ _ = FileTests;
+ _ = OutputTests;
+ _ = ImageTests;
+ _ = PdfTests;
+ _ = TerminalTests;
+}
+
+test "an empty counted paste leaves no count for the next motion" {
+ for ([_]bool{ false, true }) |empty_register| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("abcdefgh\n");
+ if (empty_register) p.yank = try p.gpa.dupe(u8, "");
+ for ("3p") |cp| p.update(.{ .key = .{ .cp = cp } });
+ try std.testing.expectEqual(modal.Normal.State{}, pane.normal);
+ p.update(.{ .key = .{ .cp = 'l' } });
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
+ try std.testing.expectEqualStrings("abcdefgh\n", pane.file.?.content);
+ }
+}
+
+test "cancelled normal gestures leave no count or subprefix after mouse and focus changes" {
+ const Gesture = enum { body, tag, focus, last };
+ for ([_][]const u8{ "3", "3g", "mr(" }) |keys| {
+ for ([_]Gesture{ .body, .tag, .focus, .last }) |gesture| {
+ errdefer std.debug.print("\nkeys={s} gesture={s}\n", .{ keys, @tagName(gesture) });
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 16 });
+ defer p.deinit();
+ const pane = try p.setTestFile("abcdefghij\n");
+ p.settings.colors = false;
+ p.presentation.enabled = false;
+ try std.testing.expect(p.executeBuiltinLine(0, "New"));
+ const other = p.active;
+ try std.testing.expect(layout.splitColumn(p, other, other, false));
+ p.active = 0;
+ p.update(.tick);
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ for (keys) |cp| p.update(.{ .key = .{ .cp = cp } });
+ try std.testing.expect(!std.meta.eql(modal.Normal.State{}, pane.normal));
+ switch (gesture) {
+ .body, .tag => {
+ const rect = p.rects[0];
+ const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y;
+ const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ const x = rect.x + config.GUTTER + @as(u16, @intCast(panes.File.gutterWidth(pane))) + 3;
+ const y = if (gesture == .tag) tag_y else body_y;
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x, .row = y } });
+ if (gesture == .tag) {
+ try std.testing.expect(pane.tag_edit);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(!pane.tag_edit);
+ }
+ },
+ .focus, .last => {
+ p.update(.{ .key = .{ .cp = 'w', .ctrl = true } });
+ p.update(.{ .key = .{ .cp = 'l' } });
+ try std.testing.expectEqual(other, p.active);
+ if (gesture == .last) {
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ } else {
+ p.update(.{ .key = .{ .cp = 'w', .ctrl = true } });
+ p.update(.{ .key = .{ .cp = 'h' } });
+ }
+ try std.testing.expectEqual(@as(usize, 0), p.active);
+ },
+ }
+ try std.testing.expectEqual(modal.Normal.State{}, pane.normal);
+ const col = pane.cur_col;
+ p.update(.{ .key = .{ .cp = 'l' } });
+ try std.testing.expectEqual(col + 1, pane.cur_col);
+ try std.testing.expectEqualStrings("abcdefghij\n", pane.file.?.content);
+ }
+ }
+}
+
+test "cancelled normal gestures leave no count or subprefix through raw tty mode" {
+ for ([_][]const u8{ "3", "3g", "mr(" }) |keys| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 12 });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ pane.mode = .normal;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "abcdefghij\r\n" } });
+ for (keys) |cp| p.update(.{ .key = .{ .cp = cp } });
+ try std.testing.expect(!std.meta.eql(modal.Normal.State{}, pane.normal));
+ p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } });
+ try std.testing.expectEqual(pardes.Pane.Mode.tty, pane.mode);
+ try std.testing.expectEqual(modal.Normal.State{}, pane.normal);
+ p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } });
+ try std.testing.expectEqual(pardes.Pane.Mode.normal, pane.mode);
+ try std.testing.expectEqual(modal.Normal.State{}, pane.normal);
+ }
+}
+
+test "Colors restores syntax after rendering disabled files and disabled edits" {
+ if (!syntax.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ for ([_]bool{ false, true }) |edit_while_disabled| {
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("pub fn first() void { if (true) {} }\n");
+ const file = &pane.file.?;
+ gpa.free(file.path);
+ file.path = try gpa.dupe(u8, "/colors.zig");
+ p.settings.colors = edit_while_disabled;
+ p.presentation.enabled = false;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ if (edit_while_disabled) {
+ try std.testing.expect(file.highlights.len > 0);
+ p.update(.{ .command = "Colors" });
+ panes.File.setContent(p, file, try gpa.dupe(u8, "pub fn edited() void { if (false) {} }\n"));
+ _ = frame.reset(.retain_capacity);
+ _ = try p.render(frame.allocator());
+ }
+ try std.testing.expect(!p.settings.colors);
+ try std.testing.expectEqual(@as(usize, 0), file.highlights.len);
+ try std.testing.expect(!file.syntax_dirty);
+ const rect = p.rects[0];
+ const cols = pane.cols;
+ const rows = pane.rows;
+ p.update(.{ .command = "Colors" });
+ try std.testing.expectEqual(rect, p.rects[0]);
+ try std.testing.expectEqual(cols, pane.cols);
+ try std.testing.expectEqual(rows, pane.rows);
+ _ = frame.reset(.retain_capacity);
+ const surface = try p.render(frame.allocator());
+ try std.testing.expect(p.settings.colors);
+ try std.testing.expect(file.highlights.len > 0);
+ try std.testing.expect(!file.syntax_dirty);
+ var seen: usize = 0;
+ for (surface.cells[0 .. surface.cells.len - 1], 0..) |*first, i| {
+ const second = &surface.cells[i + 1];
+ if (!(std.mem.eql(u8, first.grapheme(), "f") and std.mem.eql(u8, second.grapheme(), "n")) and
+ !(std.mem.eql(u8, first.grapheme(), "i") and std.mem.eql(u8, second.grapheme(), "f"))) continue;
+ seen += 1;
+ for ([_]*pardes.Cell{ first, second }) |cell| {
+ try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().kw }, cell.style.fg);
+ try std.testing.expect(cell.style.bold);
+ }
+ }
+ try std.testing.expectEqual(@as(usize, 2), seen);
+ }
+}
+
+test "growing a file pane colors every newly visible Zig keyword" {
+ if (!syntax.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 8 });
+ defer p.deinit();
+ const pane = try p.setTestFile("pub fn f() void { if (true) {} }\n" ** 100);
+ const file = &pane.file.?;
+ gpa.free(file.path);
+ file.path = try gpa.dupe(u8, "/resize.zig");
+ p.settings.colors = true;
+ p.presentation.enabled = false;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const initial_end = file.highlight_start + file.highlights.len;
+ for ([_]u16{ 50, 12, 70 }) |rows| {
+ p.update(.{ .resize = .{ .cols = 80, .rows = rows } });
+ _ = frame.reset(.retain_capacity);
+ const surface = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ var seen: usize = 0;
+ for (body_y..body_y + rect.h - pardes.BOX_H) |y| {
+ for (rect.x + config.GUTTER..rect.x + rect.w - 1) |x| {
+ const first = surface.at(@intCast(x), @intCast(y));
+ const second = surface.at(@intCast(x + 1), @intCast(y));
+ if (!(std.mem.eql(u8, first.grapheme(), "f") and std.mem.eql(u8, second.grapheme(), "n")) and
+ !(std.mem.eql(u8, first.grapheme(), "i") and std.mem.eql(u8, second.grapheme(), "f"))) continue;
+ seen += 1;
+ for ([_]*pardes.Cell{ first, second }) |cell| {
+ try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().kw }, cell.style.fg);
+ try std.testing.expect(cell.style.bold);
+ }
+ }
+ }
+ try std.testing.expect(seen >= 2 * (rows - 4));
+ try std.testing.expect(file.highlight_start + file.highlights.len > initial_end);
+ try std.testing.expect(!file.syntax_dirty);
+ }
+}
+
+test "Zig keywords keep every rendered byte colored across line-number widths" {
+ if (!syntax.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const samples = [_][]const u8{
+ "pub fn main() void { if (true) {} }\n",
+ "\tpub fn main() void { if (true) {} }\n",
+ "const text = \"λ界\"; pub fn main() void { if (true) {} }\n",
+ };
+ for ([_]usize{ 9999, 10000, 99999, 100000 }) |line| {
+ for (samples) |sample| {
+ for ([_]struct { cols: u16, wrap: bool, horizontal: i32 }{
+ .{ .cols = 96, .wrap = false, .horizontal = 0 },
+ .{ .cols = 96, .wrap = false, .horizontal = 2 },
+ .{ .cols = 38, .wrap = true, .horizontal = 0 },
+ }) |view| {
+ const source = try gpa.alloc(u8, line - 1 + sample.len);
+ defer gpa.free(source);
+ @memset(source[0 .. line - 1], '\n');
+ @memcpy(source[line - 1 ..], sample);
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = view.cols, .rows = 10 });
+ defer p.deinit();
+ const pane = try p.setTestFile(source);
+ const file = &pane.file.?;
+ gpa.free(file.path);
+ file.path = try gpa.dupe(u8, "/highlight.zig");
+ file.scroll = line - 1;
+ pane.cur_row = @intCast(line - 1);
+ pane.cur_col = @intCast(std.mem.indexOf(u8, sample, "fn").?);
+ pane.hscroll = view.horizontal;
+ p.settings.colors = true;
+ p.settings.wrap = view.wrap;
+ p.presentation.enabled = false;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = try p.render(frame.allocator());
+ var seen: usize = 0;
+ var if_position: ?struct { x: u16, y: u16 } = null;
+ const rect = p.rects[0];
+ const body_y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ for (body_y..body_y + rect.h - pardes.BOX_H) |y| {
+ for (rect.x + config.GUTTER..rect.x + rect.w - 1) |x| {
+ const first = surface.at(@intCast(x), @intCast(y));
+ const second = surface.at(@intCast(x + 1), @intCast(y));
+ const keyword = (std.mem.eql(u8, first.grapheme(), "f") and std.mem.eql(u8, second.grapheme(), "n")) or
+ (std.mem.eql(u8, first.grapheme(), "i") and std.mem.eql(u8, second.grapheme(), "f"));
+ if (!keyword) continue;
+ seen += 1;
+ if (std.mem.eql(u8, first.grapheme(), "f")) {
+ try std.testing.expectEqual(@as(u16, @intCast(x)), surface.cursor.?.x);
+ try std.testing.expectEqual(@as(u16, @intCast(y)), surface.cursor.?.y);
+ } else if_position = .{ .x = @intCast(x), .y = @intCast(y) };
+ for ([_]*pardes.Cell{ first, second }) |cell| {
+ const expected: pardes.Color = .{ .rgb = p.theme().kw };
+ if (!std.meta.eql(expected, cell.style.fg)) std.debug.print(
+ "\nline={d} wrap={} horizontal={d} sample={s} cell={s}\n",
+ .{ line, view.wrap, view.horizontal, sample, cell.grapheme() },
+ );
+ try std.testing.expectEqual(expected, cell.style.fg);
+ try std.testing.expect(cell.style.bold);
+ }
+ }
+ }
+ try std.testing.expectEqual(@as(usize, 2), seen);
+ const clicked = if_position.?;
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = clicked.x, .row = clicked.y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = clicked.x, .row = clicked.y } });
+ try std.testing.expectEqual(@as(i32, @intCast(line - 1)), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, @intCast(std.mem.indexOf(u8, sample, "if").?)), pane.cur_col);
+ }
+ }
+ }
+}
+
+test "terminal overlays preserve trailing blank styles without coloring inserted rows" {
+ if (comptime !panes.Terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "A\r\n\x1b[41m\x1b[2K\r\n\x1b[42m\x1b[2K\r\n\x1b[44m\x1b[2K\x1b[0m" } });
+ pane.mode = .normal;
+ pane.ovl = .{ .row = 0, .rows = 3, .text = try gpa.dupe(u8, "A\n\n") };
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER;
+ const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H;
+ const colors = [_]pardes.Color{ .{ .index = 1 }, .{ .index = 2 }, .{ .index = 4 } };
+ const original = try p.render(frame.allocator());
+ for (colors, 0..) |color, row| {
+ const cell = original.at(x, y + 1 + @as(u16, @intCast(row)));
+ try std.testing.expectEqualStrings(" ", cell.grapheme());
+ try std.testing.expectEqual(color, cell.style.bg);
+ }
+ try std.testing.expect(p.shell_rows.pane == null);
+
+ gpa.free(pane.ovl.?.text);
+ pane.ovl.?.text = try gpa.dupe(u8, "A\n\n\n");
+ _ = frame.reset(.retain_capacity);
+ const extended = try p.render(frame.allocator());
+ for (colors[0..2], 0..) |color, row|
+ try std.testing.expectEqual(color, extended.at(x, y + 1 + @as(u16, @intCast(row))).style.bg);
+ const inserted = extended.at(x, y + 3);
+ try std.testing.expectEqualStrings(" ", inserted.grapheme());
+ for (colors) |color| try std.testing.expect(!std.meta.eql(color, inserted.style.bg));
+ try std.testing.expectEqual(colors[2], extended.at(x, y + 4).style.bg);
+ try std.testing.expect(p.shell_rows.pane == null);
+}