diff options
Diffstat (limited to 'test/panes.zig')
| -rw-r--r-- | test/panes.zig | 2743 |
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); +} |
