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; const TagNavigationTests = struct { fn key(p: *Pardes, cp: u21) void { p.update(.{ .key = .{ .cp = cp } }); } fn enterNormal(p: *Pardes) *Pane { key(p, ':'); return p.panes[p.active].?; } test "tag navigation retains each pane cursor and mouse placement overrides memory" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 }); defer p.deinit(); const first = try p.setTestFile("body\n"); p.presentation.enabled = false; try std.testing.expect(p.executeBuiltinLine(0, "New")); const second_id = p.active; const second = p.panes[second_id].?; try std.testing.expect(layout.splitColumn(p, second_id, second_id, false)); p.update(.tick); p.active = 0; key(p, ':'); key(p, Key.home); for (0..3) |_| key(p, Key.right); const first_col = first.tag_col; key(p, Key.escape); p.active = second_id; key(p, ':'); key(p, Key.home); for (0..5) |_| key(p, Key.right); const second_col = second.tag_col; key(p, Key.escape); p.active = 0; key(p, ':'); try std.testing.expectEqual(first_col, first.tag_col); key(p, Key.escape); p.active = second_id; key(p, ':'); try std.testing.expectEqual(second_col, second.tag_col); key(p, Key.escape); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[0]; const y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; ClickCursorTests.click(p, .left, rect.x + config.GUTTER + 6, y); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqual(@as(u16, 6), first.tag_col); key(p, Key.escape); p.startPrompt(first, .{ .search = config.search_marker }); key(p, Key.escape); key(p, ':'); try std.testing.expectEqual(@as(u16, 6), first.tag_col); key(p, Key.escape); try std.testing.expectEqualStrings("body\n", first.file.?.content); } test "tag navigation clamps remembered columns to renamed graphemes and shorter tags" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 14 }); defer p.deinit(); const pane = try p.setTestFile("body\n"); key(p, ':'); key(p, Key.home); for (0..3) |_| key(p, Key.right); key(p, Key.escape); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/界.zig"); key(p, ':'); try std.testing.expectEqual(@as(u16, 1), pane.tag_col); key(p, Key.end); key(p, Key.escape); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/é"); pane.tag_tail_len = 0; key(p, ':'); try std.testing.expectEqual(@as(u16, 3), pane.tag_col); try std.testing.expectEqualStrings("body\n", pane.file.?.content); } test "tag navigation J and K restore cursors in stacked panes" { for ([_]bool{ false, true }) |bottom| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); const first = try p.setTestFile("body\n"); p.presentation.enabled = false; p.settings.tag_bottom = bottom; try std.testing.expect(p.executeBuiltinLine(0, "New")); const second_id = p.active; const second = p.panes[second_id].?; p.update(.tick); p.active = 0; _ = enterNormal(p); key(p, Key.home); key(p, 'l'); key(p, 'l'); key(p, 'J'); try std.testing.expectEqual(second_id, p.active); try std.testing.expect(!first.tag_edit and second.tag_edit); key(p, Key.home); for (0..4) |_| key(p, 'l'); key(p, 'K'); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqual(@as(u16, 2), first.tag_col); key(p, 'J'); try std.testing.expectEqual(second_id, p.active); try std.testing.expectEqual(@as(u16, 4), second.tag_col); } } test "tag navigation uses uppercase pane and header keys while lowercase stays local" { for ([_]bool{ false, true }) |bottom| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; p.settings.tag_bottom = bottom; p.settings.column_tags = true; try std.testing.expect(p.executeBuiltinLine(0, "New")); const right = p.active; try std.testing.expect(layout.splitColumn(p, right, right, false)); p.update(.tick); p.active = 0; const pane = enterNormal(p); key(p, Key.home); key(p, 'l'); try std.testing.expectEqual(@as(u16, 1), pane.tag_col); key(p, 'h'); key(p, 'k'); try std.testing.expectEqual(@as(u16, 0), pane.tag_col); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expect(pane.tag_edit and p.topbar_col == null); key(p, 'L'); try std.testing.expectEqual(right, p.active); try std.testing.expect(!pane.tag_edit and p.panes[right].?.tag_edit); key(p, 'H'); try std.testing.expectEqual(@as(usize, 0), p.active); key(p, 'K'); try std.testing.expect(p.topbar_col != null and p.header_column != null); try std.testing.expect(!pane.tag_edit); key(p, 'l'); const column_cursor = p.topbar_col.?; key(p, 'K'); try std.testing.expect(p.topbar_col != null and p.header_column == null); key(p, 'l'); key(p, 'l'); const workspace_cursor = p.topbar_col.?; key(p, 'J'); try std.testing.expect(p.topbar_col != null and p.header_column != null); try std.testing.expectEqual(column_cursor, p.topbar_col.?); key(p, 'K'); try std.testing.expectEqual(workspace_cursor, p.topbar_col.?); key(p, 'J'); key(p, 'J'); try std.testing.expect(p.topbar_col == null and pane.tag_edit); key(p, 'j'); try std.testing.expect(!pane.tag_edit and p.topbar_col == null); try std.testing.expectEqual(@as(usize, 0), p.active); _ = enterNormal(p); key(p, 'K'); key(p, 'j'); try std.testing.expect(p.topbar_col == null and !pane.tag_edit); key(p, ':'); key(p, 'i'); key(p, Key.end); const before = pane.tag_tail_len; try std.testing.expect(pane.tag_edit and pane.mode == .insert); for ("hjklHJKL") |letter| { const text = [_]u8{letter}; p.update(.{ .key = .{ .cp = letter, .text = &text } }); try std.testing.expect(pane.tag_edit and pane.mode == .insert); } try std.testing.expectEqualStrings("hjklHJKL", pane.tag_tail[before..pane.tag_tail_len]); try std.testing.expect(pane.tag_edit and pane.mode == .insert and p.topbar_col == null); try std.testing.expectEqual(@as(usize, 0), p.active); } } }; const BodyHitTests = struct { fn mouse(layer: *const pardes.BodyLayer, row: u16, col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { const width: f32 = if (row < layer.context_rows) 8 else 10; const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(col)) + 0.5) * width; const y = layer.rowTop(row, 20, 10) + layer.rowHeight(row, 20, 10) / 2; return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .body_hit = layer.hitAt(x, y, 10, 20, 8, 10).? }; } test "compact TreeContext mouse follows source rows in reclaimed space" { if (!syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 64, .rows = 18 }); defer p.deinit(); p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 8, .tagline_h = 10 }; const pane = try TreeContextTests.setZigFile(p, "const Box = struct {\n pub fn work(\n value: usize,\n ) void {\n" ++ " // body\n" ** 60 ++ " }\n};\n"); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); pane.scrollBy(10); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const layer = &p.surface.body_layers[0]; try std.testing.expect(layer.context_rows >= 4); try std.testing.expect(layer.rows > pane.rows); const first = mouse(layer, 0, 2, .press); const second = mouse(layer, 1, 2, .press); try std.testing.expectEqual(first.row, second.row); const top = pane.scroll(); for ([_]u16{ 0, 1 }) |row| { const expected = pane.wrapAt(row).line; p.update(.{ .mouse = mouse(layer, row, 2, .press) }); p.update(.{ .mouse = mouse(layer, row, 2, .release) }); try std.testing.expectEqual(expected, pane.cur_row); try std.testing.expectEqual(top, pane.scroll()); } const last = pane.body_visible_rows - 1; try std.testing.expect(last >= pane.rows); const expected = pane.wrapAt(last).line; const prefix = panes.File.gutterWidth(pane); p.update(.{ .mouse = mouse(layer, last - 1, prefix + 11, .press) }); // Release at a different source row without an intermediate drag event. p.update(.{ .mouse = mouse(layer, last, prefix + 14, .release) }); try std.testing.expectEqualStrings("body\nbody", pardes.test_api.heldSelection(p, 0).?); p.update(.{ .mouse = mouse(layer, last, 10, .press) }); p.update(.{ .mouse = mouse(layer, last, 10, .release) }); try std.testing.expectEqual(expected, pane.cur_row); var stale = mouse(layer, 0, 2, .press); stale.body_hit.?.serial +%= 1; p.update(.{ .mouse = stale }); try std.testing.expectEqual(expected, pane.cur_row); p.update(.{ .key = .{ .cp = 'i' } }); p.update(.{ .key = .{ .cp = 'X', .text = "X" } }); try std.testing.expect(std.mem.indexOfScalar(u8, modal.lineSlice(pane.file.?.content, @intCast(expected)), 'X') != null); } }; const TreeContextTests = struct { const source = "const Box = struct {\n" ++ " pub fn work(\n" ++ " value: usize,\n" ++ " ) void {\n" ++ " _ = value;\n" ++ " // one\n" ++ " // two\n" ++ " // three\n" ++ " // four\n" ++ " // five\n" ++ " // six\n" ++ " }\n" ++ "};\n"; fn setZigFile(p: *Pardes, content: []const u8) !*Pane { const pane = try p.setTestFile(content); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/tree-context.zig"); p.presentation.enabled = false; return pane; } test "TreeContext Look to an open file discards the old viewport extent" { for ([_]bool{ false, true }) |context| { if (context and !syntax.enabled) continue; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 64, .rows = 18 }); defer p.deinit(); const pane = try setZigFile(p, "pub fn work() void {\n" ++ " // body\n" ** 100 ++ "}\n"); if (context) try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(i32, 0), pane.scroll()); p.lookAt(0, "/tree-context.zig:60"); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqual(@as(i32, 59), pane.cur_row); const surface = try p.render(frame.allocator()); const visible = pane.wrapRow(pane.cur_row, pane.cur_col); try std.testing.expect(visible.row >= pane.context_rows); try std.testing.expect(visible.row < pane.rows); try std.testing.expectEqual(@as(i32, 59), pane.wrapAt(visible.row).line); const cursor = surface.cursor orelse return error.MissingLookCursor; const rect = p.rects[0]; const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; try std.testing.expectEqual(y + @as(u16, @intCast(visible.row)), cursor.y); } } test "TreeContext sticky declarations preserve the body while clicked and edited" { if (!syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 64, .rows = 18 }); defer p.deinit(); const pane = try setZigFile(p, source); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); pane.scrollBy(7); _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 0), pane.context_rows); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 4), pane.context_rows); for (0..4) |row| try std.testing.expectEqual(@as(i32, @intCast(row)), pane.wrapAt(@intCast(row)).line); try std.testing.expectEqual(@as(i32, 7), pane.wrapAt(pane.context_rows).line); const rect = p.rects[0]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; ClickCursorTests.click(p, .left, x + 6, y); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); try std.testing.expectEqual(@as(i32, 6), pane.cur_col); try std.testing.expectEqual(@as(i32, 7), pane.scroll()); const cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingContextCursor; try std.testing.expectEqual(x + 6, cursor.x); try std.testing.expectEqual(y, cursor.y); p.update(.{ .key = .{ .cp = 'i' } }); p.update(.{ .key = .{ .cp = 'Z', .text = "Z" } }); try std.testing.expect(std.mem.startsWith(u8, pane.file.?.content, "const ZBox = struct {")); try std.testing.expectEqual(@as(i32, 7), pane.scroll()); _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 4), pane.context_rows); p.update(.{ .key = .{ .cp = Key.escape } }); var previous: u16 = pane.context_rows; while (pane.scroll() > 0) { pane.scrollBy(-1); _ = try p.render(frame.allocator()); try std.testing.expect(pane.context_rows <= previous); previous = pane.context_rows; try std.testing.expectEqual(pane.scroll(), pane.wrapAt(pane.context_rows).line); } try std.testing.expectEqual(@as(u16, 0), pane.context_rows); try std.testing.expectEqual(@as(i32, 0), pane.wrapRow(0, pane.cur_col).row); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); pane.scrollBy(7); _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 0), pane.context_rows); } test "TreeContext unwrapped headers map Unicode and survive source replacement" { if (!syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 48, .rows = 14 }); defer p.deinit(); const pane = try setZigFile(p, "pub fn @\"界name\"(\n\tvalue: usize,\n) void {\n" ++ " _ = value;\n" ** 12 ++ "}\n"); p.settings.wrap = false; p.settings.colors = false; try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); pane.scrollBy(8); pane.hscroll = 4; var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); const surface = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 3), pane.context_rows); const rect = p.rects[0]; const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; var glyph_x: ?u16 = null; for (0..surface.cols) |col| { if (std.mem.eql(u8, surface.at(@intCast(col), y).grapheme(), "界")) glyph_x = @intCast(col); } const x = glyph_x orelse return error.MissingContextUnicode; ClickCursorTests.click(p, .left, x + 1, y); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); try std.testing.expectEqual(@as(i32, 9), pane.cur_col); try std.testing.expectEqual(@as(i32, 8), pane.scroll()); const cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingContextCursor; try std.testing.expectEqual(x, cursor.x); try std.testing.expectEqual(y, cursor.y); panes.File.setContent(p, &pane.file.?, try p.gpa.dupe(u8, "// no enclosing declaration\n" ** 18)); _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 0), pane.context_rows); try std.testing.expectEqual(pane.scroll(), pane.wrapAt(0).line); } test "TreeContext clipped headers leave wrapped body cursor coordinates intact" { if (!syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 12 }); defer p.deinit(); const body = " // 012345678901234567890123456789界tail"; const pane = try setZigFile(p, "pub fn aLongDeclarationNameThatCannotFit() void {\n // before\n" ++ body ++ "\n}\n"); p.settings.wrap = true; try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); pane.scrollBy(2); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); const surface = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 1), pane.context_rows); try std.testing.expectEqual(@as(i32, 2), pane.wrapAt(1).line); try std.testing.expectEqual(@as(i32, 2), pane.wrapAt(2).line); var target: ?struct { x: u16, y: u16 } = null; for (0..surface.rows) |row| for (0..surface.cols) |col| { if (std.mem.eql(u8, surface.at(@intCast(col), @intCast(row)).grapheme(), "界")) target = .{ .x = @intCast(col), .y = @intCast(row) }; }; const glyph = target orelse return error.MissingWrappedContextBody; ClickCursorTests.click(p, .left, glyph.x + 1, glyph.y); try std.testing.expectEqual(@as(i32, 2), pane.cur_row); try std.testing.expectEqual(@as(i32, @intCast(std.mem.indexOf(u8, body, "界").?)), pane.cur_col); const cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingContextBodyCursor; try std.testing.expectEqual(glyph.x, cursor.x); try std.testing.expectEqual(glyph.y, cursor.y); } }; const TtySelectionTests = struct { fn noChildInput(p: *Pardes) !void { while (p.nextEffect()) |effect| { if (effect == .write) return error.UnexpectedChildInput; if (effect == .set_clipboard) p.perform(effect); } } test "tty selection yields to a new modal cursor selection" { if (comptime !panes.Terminal.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); p.presentation.enabled = false; const pane = p.panes[0].?; pane.tty_filter = false; while (p.nextEffect()) |_| {} p.update(.{ .output = .{ .pane = 0, .bytes = "alpha" } }); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = 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; JumpSelectionTests.drag(p, x + 1, y, x + 3, y); try std.testing.expectEqualStrings("lph", p.yank orelse return error.MissingAutomaticTtyYank); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); p.update(.{ .key = .{ .cp = 'l' } }); try std.testing.expectEqual(@as(i32, 4), pane.cur_col); p.update(.{ .key = .{ .cp = 'y' } }); try std.testing.expectEqualStrings("a", p.yank.?); try noChildInput(p); } test "tty selection preserves exact rectangle through yank clipboard and mode changes" { if (comptime !panes.Terminal.enabled) return error.SkipZigTest; for ([_]bool{ false, true }) |prompt| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); const pane = p.panes[0].?; pane.mode = .tty; pane.tty_filter = false; p.presentation.enabled = false; while (p.nextEffect()) |_| {} p.update(.{ .output = .{ .pane = 0, .bytes = if (prompt) "\x1b]133;A\x07$ \x1b]133;B\x07abc" else "alpha\r\nbravo" } }); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = 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; JumpSelectionTests.drag(p, x + @as(u16, if (prompt) 0 else 1), y, x + 3, y + @as(u16, if (prompt) 0 else 1)); const expected = if (prompt) "$ ab" else "lph\nrav"; try std.testing.expectEqual(Pane.Mode.tty, pane.mode); try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?); try std.testing.expectEqualStrings(expected, p.yank orelse return error.MissingAutomaticTtyYank); try noChildInput(p); try std.testing.expect(p.executeBuiltinLine(0, "ClipYank")); try noChildInput(p); try std.testing.expectEqualStrings(expected, p.fallback.clipboard.items); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Pane.Mode.normal, pane.mode); const normal = try p.render(frame.allocator()); if (!prompt) { const selection_bg: pardes.Color = .{ .rgb = p.theme().sel_bg }; try std.testing.expect(!std.meta.eql(selection_bg, normal.at(x + 4, y).style.bg)); try std.testing.expect(!std.meta.eql(selection_bg, normal.at(x, y + 1).style.bg)); } try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?); p.update(.{ .key = .{ .cp = 'y' } }); try std.testing.expectEqualStrings(expected, p.yank.?); try noChildInput(p); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); try noChildInput(p); _ = try p.render(frame.allocator()); try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;C\x07" ++ "\r\nnew output" ** 30 } }); _ = try p.render(frame.allocator()); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?); try std.testing.expectEqualStrings(expected, p.yank.?); try noChildInput(p); } } test "tty selection pastes across panes once with existing bracket and mouse protocols" { if (comptime !panes.Terminal.enabled) return error.SkipZigTest; for ([_]bool{ false, true }) |bracketed| { for ([_]bool{ false, true }) |reports_mouse| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 24 }); defer p.deinit(); p.presentation.enabled = false; p.spawnTty(0); for (p.panes[0..2]) |slot| { slot.?.mode = .tty; slot.?.tty_filter = false; slot.?.greet = false; slot.?.vweight = 1; } p.update(.tick); while (p.nextEffect()) |_| {} p.update(.{ .output = .{ .pane = 0, .bytes = "alpha\r\nbravo" } }); p.update(.{ .output = .{ .pane = 1, .bytes = "target> " } }); if (bracketed) p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004h" } }); if (reports_mouse) p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?1000h\x1b[?1006h" } }); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const source = p.rects[0]; const sx = source.x + config.GUTTER; const sy = if (p.settings.tag_bottom) source.y else source.y + pardes.BOX_H; JumpSelectionTests.drag(p, sx + 1, sy, sx + 3, sy + 1); try std.testing.expectEqualStrings("lph\nrav", p.yank orelse return error.MissingAutomaticTtyYank); try noChildInput(p); const target = p.rects[1]; const tx = target.x + config.GUTTER + 2; const ty = if (p.settings.tag_bottom) target.y else target.y + pardes.BOX_H; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = tx, .row = ty } }); p.update(.{ .mouse = .{ .button = .right, .kind = .press, .col = tx, .row = ty } }); p.update(.{ .mouse = .{ .button = .right, .kind = .release, .col = tx, .row = ty } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = tx, .row = ty } }); var written: std.Io.Writer.Allocating = .init(p.gpa); defer written.deinit(); while (p.nextEffect()) |effect| { if (effect == .write) { try std.testing.expectEqual(@as(u8, 1), effect.write.pane); try written.writer.writeAll(effect.write.bytes.slice()); } } const expected = try std.fmt.allocPrint(p.gpa, "{s}{s}", .{ if (reports_mouse) "\x1b[<0;3;1M\x1b[<0;3;1m" else "", if (bracketed) "\x1b[200~lph\nrav\x1b[201~" else "lph\rrav", }); defer p.gpa.free(expected); try std.testing.expectEqualStrings(expected, written.written()); try std.testing.expectEqual(Pane.Mode.tty, p.panes[0].?.mode); try std.testing.expectEqual(Pane.Mode.tty, p.panes[1].?.mode); try std.testing.expectEqualStrings("lph\nrav", pardes.test_api.heldSelection(p, 0).?); } } } }; const ClickCursorTests = struct { fn click(p: *Pardes, button: pardes.Mouse.Button, x: u16, y: u16) void { p.update(.{ .mouse = .{ .button = button, .kind = .press, .col = x, .row = y } }); p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = x, .row = y } }); } test "click cursor and insertion agree beyond line and file ends" { for ([_]bool{ false, true }) |already_insert| { for ([_]bool{ false, true }) |bottom_tag| { for ([_]struct { text: []const u8, click_row: u16, row: i32, col: i32, result: []const u8 }{ .{ .text = "abc\nxy", .click_row = 0, .row = 0, .col = 3, .result = "abcZ\nxy" }, .{ .text = "abc\nxy", .click_row = 5, .row = 1, .col = 2, .result = "abc\nxyZ" }, .{ .text = "abc\n", .click_row = 5, .row = 1, .col = 0, .result = "abc\nZ" }, .{ .text = "", .click_row = 5, .row = 0, .col = 0, .result = "Z" }, }) |case| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); p.settings.tag_bottom = bottom_tag; const pane = try p.setTestFile(case.text); p.presentation.enabled = false; if (already_insert) p.update(.{ .key = .{ .cp = 'i' } }); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[0]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); const y = if (bottom_tag) rect.y else rect.y + pardes.BOX_H; click(p, .left, x + 15, y + case.click_row); try std.testing.expectEqual(case.row, pane.cur_row); try std.testing.expectEqual(case.col, pane.cur_col); const clicked = (try p.render(frame.allocator())).cursor orelse return error.MissingClickCursor; try std.testing.expectEqual(x + @as(u16, @intCast(case.col)), clicked.x); try std.testing.expectEqual(y + @as(u16, @intCast(case.row)), clicked.y); if (!already_insert) p.update(.{ .key = .{ .cp = 'i' } }); try std.testing.expectEqual(Pane.Mode.insert, pane.mode); const insert = (try p.render(frame.allocator())).cursor orelse return error.MissingInsertCursor; try std.testing.expectEqual(clicked.x, insert.x); try std.testing.expectEqual(clicked.y, insert.y); p.update(.{ .key = .{ .cp = 'Z', .text = "Z" } }); try std.testing.expectEqualStrings(case.result, pane.file.?.content); } } } } test "click cursor snaps tab and wrapped wide glyph cells before insertion" { for ([_]bool{ false, true }) |already_insert| { for ([_]bool{ false, true }) |wrap| { const source = if (wrap) "012345678901234567890123456789界tail\n" else "\t界tail\n"; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 24, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile(source); p.settings.wrap = wrap; p.presentation.enabled = false; if (already_insert) p.update(.{ .key = .{ .cp = 'i' } }); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); const surface = try p.render(frame.allocator()); var target: ?struct { x: u16, y: u16 } = null; for (0..surface.rows) |row| for (0..surface.cols) |col| { if (std.mem.eql(u8, surface.at(@intCast(col), @intCast(row)).grapheme(), "界")) target = .{ .x = @intCast(col), .y = @intCast(row) }; }; const glyph = target orelse return error.MissingWideGlyph; if (wrap) try std.testing.expect(glyph.y > p.rects[0].y + pardes.BOX_H); click(p, .left, glyph.x + 1, glyph.y); const byte = std.mem.indexOf(u8, source, "界").?; try std.testing.expectEqual(@as(i32, @intCast(byte)), pane.cur_col); const clicked = (try p.render(frame.allocator())).cursor orelse return error.MissingClickCursor; try std.testing.expectEqual(glyph.x, clicked.x); try std.testing.expectEqual(glyph.y, clicked.y); if (!already_insert) p.update(.{ .key = .{ .cp = 'i' } }); try std.testing.expectEqual(Pane.Mode.insert, pane.mode); const insert = (try p.render(frame.allocator())).cursor orelse return error.MissingInsertCursor; try std.testing.expectEqual(clicked.x, insert.x); try std.testing.expectEqual(clicked.y, insert.y); p.update(.{ .key = .{ .cp = 'Z', .text = "Z" } }); const expected = try std.fmt.allocPrint(p.gpa, "{s}Z{s}", .{ source[0..byte], source[byte..] }); defer p.gpa.free(expected); try std.testing.expectEqualStrings(expected, pane.file.?.content); } } } test "click operands and hover follow the scrolled source selection" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 14 }); defer p.deinit(); const pane = try p.setTestFile("zero\none\ntwo\nthree\nalpha beta\nfive\ngamma delta\nseven\n" ++ "tail\n" ** 20); p.presentation.enabled = false; p.settings.wrap = false; var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[0]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; JumpSelectionTests.drag(p, x + 1, y + 4, x + 3, y + 4); try std.testing.expectEqualStrings("lph", pardes.test_api.heldSelection(p, 0).?); for (0..2) |_| p.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = x + 2, .row = y + 4 } }); try std.testing.expectEqual(@as(usize, 2), pane.file.?.scroll); _ = try p.render(frame.allocator()); const event_node = filesystem.Node.of(pane.serial, .event); const opened = filesystem.handle(p, .{ .tag = 0, .op = .open, .node = event_node }); try std.testing.expectEqual(.ok, opened.status); defer _ = filesystem.handle(p, .{ .tag = 0, .op = .release, .node = event_node, .handle = opened.handle }); for ([_]pardes.Mouse.Button{ .middle, .right }) |button| { const events = &p.fs.panes[0].events; while (!events.empty()) events.pop(); click(p, button, x + 2, y + 2); const selected = events.peek() orelse return error.MissingSelectionGesture; try std.testing.expect(std.mem.endsWith(u8, selected, " lph\n")); try std.testing.expect(std.mem.startsWith(u8, selected[2..], "20 23 ")); while (!events.empty()) events.pop(); click(p, button, x + 2, y + 4); const unselected = events.peek() orelse return error.MissingWordGesture; try std.testing.expect(std.mem.endsWith(u8, unselected, " gamma\n")); } const delay = config.look_preview_delay_frames orelse return; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = x + 2, .row = y + 2 } }); for (0..delay) |_| p.update(.tick); const selected_preview = p.look_hover_preview orelse return error.MissingSelectionHover; try std.testing.expect(selected_preview.sel != null); try std.testing.expect(pane.pointerSelection(selected_preview.sel.?) != null); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = x + 2, .row = y + 4 } }); for (0..delay) |_| p.update(.tick); const word_preview = p.look_hover_preview orelse return error.MissingWordHover; try std.testing.expect(word_preview.file_word != null); try std.testing.expectEqual(@as(i32, 6), word_preview.file_word.?.row); } }; const JumpSelectionTests = struct { fn drag(p: *Pardes, col0: u16, row0: u16, col1: u16, row1: u16) void { p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = col0, .row = row0 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = col1, .row = row1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = col1, .row = row1 } }); } fn exercise(prefix: []const u8, wrap: bool, reverse: bool, first_col: u16, last_col: u16, expected: ?[]const u8) !void { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 34, .rows = 10 }); defer p.deinit(); p.settings.wrap = wrap; const text = try std.fmt.allocPrint(p.gpa, "{s}{s}", .{ prefix, "unrelated destination text\n" ** 80 }); defer p.gpa.free(text); const pane = try p.setTestFile(text); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); if (wrap) try std.testing.expectEqual(pane.wrap_line[0], pane.wrap_line[1]); const rect = p.rects[0]; const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane); const y = if (p.settings.tag_bottom) rect.y else rect.y + pardes.BOX_H; if (reverse) drag(p, x + last_col, y + 1, x + first_col, y) else drag(p, x + first_col, y, x + last_col, y + 1); const selected = try p.gpa.dupe(u8, pardes.test_api.heldSelection(p, 0) orelse return error.MissingMouseSelection); defer p.gpa.free(selected); if (expected) |want| try std.testing.expectEqualStrings(want, selected); try std.testing.expect(std.mem.indexOfScalar(u8, selected, '\n') != null); if (wrap) try std.testing.expect(std.mem.indexOf(u8, selected, "界") != null); // Seed two actual locations after the drag, so both history directions // must scroll while retaining the rectangle's original source text. p.njumps = 2; p.jcur = 1; p.jumps[0] = .{ .pane = 0, .serial = pane.serial, .line = 60, .col = 1 }; p.jumps[1] = .{ .pane = 0, .serial = pane.serial, .line = 1, .col = 1 }; p.update(.{ .key = .{ .cp = 'o', .ctrl = true } }); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), p.jcur); try std.testing.expect(pane.file.?.scroll > 20); try std.testing.expectEqualStrings(selected, pardes.test_api.heldSelection(p, 0) orelse return error.LostMouseSelection); p.update(.{ .key = .{ .cp = 'i', .ctrl = true } }); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 1), p.jcur); try std.testing.expectEqual(@as(usize, 0), pane.file.?.scroll); try std.testing.expectEqualStrings(selected, pardes.test_api.heldSelection(p, 0) orelse return error.LostMouseSelection); } test "jump selection retains copied rectangle text after scrolling away and back" { for ([_]bool{ false, true }) |reverse| try exercise("alpha\nbravo\n", false, reverse, 1, 3, "lph\nrav"); } test "jump selection retains wrapped Unicode and tab rectangle text" { for ([_]bool{ false, true }) |reverse| try exercise("界\tabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n", true, reverse, 0, 8, null); } }; const TagNameTintTests = struct { const name_fg = [3]u8{ 231, 137, 43 }; const tag_fg = [3]u8{ 111, 122, 133 }; fn configure(p: *Pardes) void { var theme = p.theme().*; theme.tag_name_fg = name_fg; theme.tag_fg = tag_fg; p.custom_theme = theme; p.chrome_animation.snap(pardes.ChromeTheme.fromTheme(p.theme())); p.settings.focus_tint = false; p.presentation.enabled = false; } fn check(p: *Pardes, id: usize, name: []const u8) !usize { const pane = p.panes[id].?; var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); const surface = try p.render(arena.allocator()); const text = try p.tagText(arena.allocator(), pane); const base = std.mem.lastIndexOfScalar(u8, name, '/') orelse return error.MissingDirectory; const rect = p.rects[id]; const y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; const left = rect.x + config.GUTTER; var visible_name: usize = 0; var at: usize = 0; while (at < text.len) : (at = modal.nextGrapheme(text, at)) { const start: i32 = @as(i32, @intCast(panes.File.rawDisplayCol(text, at))) - pane.tag_scroll; const end = start + @as(i32, @intCast(panes.File.displayWidth(text[at..modal.nextGrapheme(text, at)]))); var col = @max(0, start); while (col < end and col < rect.w - config.GUTTER) : (col += 1) { const in_name = at > base and at < name.len; const fg = if (in_name) name_fg else tag_fg; try std.testing.expectEqual(pardes.Color{ .rgb = fg }, surface.at(left + @as(u16, @intCast(col)), y).style.fg); if (in_name) visible_name += 1; } } return visible_name; } test "tag filename tint excludes directories dirty marker and output commands" { for ([_]bool{ false, true }) |bottom| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 24 }); defer p.deinit(); const pane = try p.setTestFile("body\n"); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/project/界file.zig"); pane.file.?.revision += 1; configure(p); defer p.custom_theme = null; p.settings.tag_bottom = bottom; try std.testing.expect((try check(p, 0, pane.file.?.path)) > 0); try panes.Output.fillResults(p, 0, "/project", .search, "tint", try p.gpa.dupe(u8, "no matches\n"), null); const id = pane.search_pane.?; p.update(.tick); try std.testing.expect((try check(p, id, p.panes[id].?.file.?.path)) > 0); } } test "tag filename tint follows scrolled Unicode staged names and yields to selection" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 38, .rows = 14 }); defer p.deinit(); const pane = try p.setTestFile("body\n"); const old = pane.file.?.path.len; configure(p); defer p.custom_theme = null; p.update(.{ .key = .{ .cp = ':' } }); p.update(.{ .key = .{ .cp = 'i' } }); pane.tag_anchor = 0; pane.tag_col = @intCast(old - 1); pane.tag_sel = true; const name = "/a/very/long/directory/that/extends/past/the/tag/界e\u{301}name-with-a-long-tail.zig"; p.update(.{ .paste = name }); try std.testing.expectEqualStrings(name, pane.tag_name[0..pane.tag_name_len.?]); pane.tag_col = @intCast(std.mem.indexOf(u8, name, "界").?); pane.tag_sel = false; try std.testing.expect((try check(p, 0, name)) > 0); try std.testing.expect(pane.tag_scroll > 0); pane.tag_anchor = pane.tag_col; pane.tag_sel = true; pane.tag_scroll += 4; // bring both cells of the selected wide glyph into view var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); const surface = try p.render(arena.allocator()); const x = p.rects[0].x + config.GUTTER + @as(u16, @intCast(panes.File.rawDisplayCol(name, pane.tag_col) - pane.tag_scroll)); const y = if (p.settings.tag_bottom) p.rects[0].y + p.rects[0].h - pardes.BOX_H else p.rects[0].y; try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().sel_fg }, surface.at(x, y).style.fg); try std.testing.expectEqual(pardes.Color{ .rgb = p.theme().sel_fg }, surface.at(x + 1, y).style.fg); } test "tag filename tint legacy themes retain distinct active foreground" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 70, .rows = 14 }); defer p.deinit(); const pane = try p.setTestFile("body\n"); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/dir/file.zig"); configure(p); defer p.custom_theme = null; p.custom_theme.?.tag_name_fg = null; const active_fg = [3]u8{ 175, 183, 194 }; p.custom_theme.?.tag_active_fg = active_fg; p.chrome_animation.snap(pardes.ChromeTheme.fromTheme(p.theme())); var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); for ([_]bool{ false, true }) |active| { p.settings.focus_tint = active; const surface = try p.render(arena.allocator()); const x = p.rects[0].x + config.GUTTER; const y = if (p.settings.tag_bottom) p.rects[0].y + p.rects[0].h - pardes.BOX_H else p.rects[0].y; const fg = if (active) active_fg else tag_fg; try std.testing.expectEqual(pardes.Color{ .rgb = fg }, surface.at(x + 1, y).style.fg); try std.testing.expectEqual(pardes.Color{ .rgb = fg }, surface.at(x + 5, y).style.fg); } } }; test "pane filename edits stage commit and cancel without changing body or command tail" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 70, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("original body\n"); const original = try p.gpa.dupe(u8, pane.file.?.path); defer p.gpa.free(original); p.seedTail(pane); try std.testing.expect(pane.appendTag(" SyntaxBold")); const tail = try p.gpa.dupe(u8, pane.tagSlice()); defer p.gpa.free(tail); for ([_]bool{ false, true }) |commit| { p.update(.{ .key = .{ .cp = ':' } }); p.update(.{ .key = .{ .cp = 'i' } }); pane.tag_anchor = 0; pane.tag_col = @intCast(original.len - 1); pane.tag_sel = true; p.update(.{ .paste = "/new name.zig" }); try std.testing.expectEqualStrings(original, pane.file.?.path); try std.testing.expectEqualStrings("/new name.zig", pane.tag_name[0..pane.tag_name_len.?]); try std.testing.expectEqualStrings(tail, pane.tagSlice()); try std.testing.expectEqualStrings("original body\n", pane.file.?.content); p.update(.{ .key = .{ .cp = if (commit) Key.enter else Key.escape } }); try std.testing.expect(!pane.tag_edit); try std.testing.expect(pane.tag_name_len == null); try std.testing.expectEqualStrings(if (commit) "/new name.zig" else original, pane.file.?.path); try std.testing.expectEqualStrings(tail, pane.tagSlice()); while (p.nextEffect()) |effect| try std.testing.expect(std.meta.activeTag(effect) != .save_file); } try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); } test "pane filename grapheme edits and invalid empty commits preserve the buffer" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("untouched\n"); p.update(.{ .key = .{ .cp = ':' } }); p.update(.{ .key = .{ .cp = 'i' } }); pane.tag_anchor = 0; pane.tag_col = @intCast(pane.file.?.path.len - 1); pane.tag_sel = true; p.update(.{ .paste = "/café界" }); p.update(.{ .key = .{ .cp = Key.backspace } }); try std.testing.expectEqualStrings("/café", pane.tag_name[0..pane.tag_name_len.?]); p.update(.{ .key = .{ .cp = Key.backspace } }); try std.testing.expectEqualStrings("/caf", pane.tag_name[0..pane.tag_name_len.?]); pane.tag_anchor = 0; pane.tag_col = 3; pane.tag_sel = true; p.update(.{ .key = .{ .cp = Key.delete } }); try std.testing.expectEqual(@as(u16, 0), pane.tag_name_len.?); p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expect(pane.tag_edit); try std.testing.expectEqualStrings("/test.txt", pane.file.?.path); try std.testing.expectEqualStrings("untouched\n", pane.file.?.content); p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expect(pane.tag_name_len == null); } test "leaving a pane or entering a header cancels an uncommitted filename" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("untouched\n"); for ([_]bool{ false, true }) |header| { p.active = 0; p.topbar_col = null; p.update(.{ .key = .{ .cp = ':' } }); p.update(.{ .key = .{ .cp = 'i' } }); p.update(.{ .key = .{ .cp = Key.home } }); p.update(.{ .paste = "/draft" }); try std.testing.expect(pane.tag_name_len != null); if (header) { p.topbar_col = 0; p.update(.tick); } else { try std.testing.expect(p.executeBuiltinLine(0, "New")); p.update(.tick); } try std.testing.expect(pane.tag_name_len == null); try std.testing.expect(!pane.tag_edit); try std.testing.expectEqualStrings("/test.txt", pane.file.?.path); } } test "middle clicking Save commits the staged filename before issuing a save" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 70, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("untouched\n"); p.update(.{ .key = .{ .cp = ':' } }); p.update(.{ .key = .{ .cp = 'i' } }); pane.tag_anchor = 0; pane.tag_col = @intCast(pane.file.?.path.len - 1); pane.tag_sel = true; p.update(.{ .paste = "/renamed.txt" }); var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); _ = try p.render(arena.allocator()); const text = try p.tagText(arena.allocator(), pane); const save_at = std.mem.indexOf(u8, text, "Save").?; const x: u16 = @intCast(p.rects[0].x + config.GUTTER + save_at - pane.tag_scroll); const y = p.rects[0].y; while (p.nextEffect()) |_| {} p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .press } }); p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .release } }); try std.testing.expectEqualStrings("/renamed.txt", pane.file.?.path); try std.testing.expect(pane.tag_name_len == null); try std.testing.expectEqualStrings("untouched\n", pane.file.?.content); var saves: usize = 0; while (p.nextEffect()) |effect| if (effect == .save_file) { try std.testing.expectEqual(@as(u8, 0), effect.save_file.pane); saves += 1; }; try std.testing.expectEqual(@as(usize, 1), saves); } test "oversized filename paste refuses atomically across a selected tag boundary" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("untouched\n"); p.update(.{ .key = .{ .cp = ':' } }); p.update(.{ .key = .{ .cp = 'i' } }); const before = try p.tagText(p.gpa, pane); defer p.gpa.free(before); pane.tag_anchor = 0; pane.tag_col = @intCast(before.len - 1); pane.tag_sel = true; const excess = try p.gpa.alloc(u8, pane.tag_name.len + 1); defer p.gpa.free(excess); @memset(excess, 'x'); p.update(.{ .paste = excess }); try std.testing.expect(pane.tag_name_len == null); try std.testing.expect(pane.tag_sel); try std.testing.expectEqualStrings(before, try p.tagText(p.scratch.allocator(), pane)); try std.testing.expectEqualStrings("untouched\n", pane.file.?.content); } test "paste into pane commands and prompts never inserts into the body" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("one two one\n"); p.update(.{ .key = .{ .cp = ':' } }); p.update(.{ .key = .{ .cp = 'A' } }); p.update(.{ .paste = " Custom\r\nCommand" }); try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), " CustomCommand")); p.update(.{ .key = .{ .cp = Key.escape } }); p.startPrompt(pane, .{ .search = config.search_marker }); p.update(.{ .paste = "two" }); try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), "two")); try std.testing.expectEqualStrings("one two one\n", pane.file.?.content); p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), " CustomCommand")); } test "long pane tags reveal commands and map visible clicks to whole tag addresses" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 50, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("body\n"); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/an/especially/deep/project/path/with/a/long/component/filename.zig"); p.seedTail(pane); try std.testing.expect(pane.appendTag(" SyntaxBold")); p.update(.{ .key = .{ .cp = ':' } }); p.update(.{ .key = .{ .cp = 'A' } }); var arena = std.heap.ArenaAllocator.init(p.gpa); defer arena.deinit(); const surface = try p.render(arena.allocator()); try std.testing.expect(pane.tag_scroll > 0); try std.testing.expect(surface.cursor.?.x < p.rects[0].x + p.rects[0].w); const text = try p.tagText(p.gpa, pane); defer p.gpa.free(text); const offset = std.mem.indexOf(u8, text, "SyntaxBold").?; const x: u16 = @intCast(p.rects[0].x + config.GUTTER + panes.File.rawDisplayCol(text, offset) - pane.tag_scroll); const y = p.rects[0].y; const before = p.settings.syntax_bold; p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .press } }); p.update(.{ .mouse = .{ .col = x, .row = y, .button = config.exec_button, .kind = .release } }); try std.testing.expectEqual(!before, p.settings.syntax_bold); try std.testing.expectEqualStrings(text, try p.tagText(p.scratch.allocator(), pane)); } 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{ .rgb = p.theme().palette.?[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{ .rgb = p.theme().palette.?[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 "Locations align Unicode paths by display width and preserve source indentation" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("notes\n"); try fillResults(p, 0, "/", .search, "alignment", try p.gpa.dupe(u8, "/界.zig:2:1 first\n/longer-name.zig:12:3 \tsecond\n"), null); const result = p.panes[p.panes[0].?.search_pane.?].?; const file = &result.file.?; try std.testing.expectEqual(@as(usize, 2), file.location_rows.len); var column: ?usize = null; for (file.location_rows, 0..) |row, i| { const line = modal.lineSlice(file.content, i); try std.testing.expectEqual(.match, row.kind); const current = panes.File.displayWidth(line[0..row.code_start]); if (column) |previous| try std.testing.expectEqual(previous, current); column = current; const expected = if (std.mem.eql(u8, row.path, "/界.zig")) " first" else "\tsecond"; try std.testing.expectEqualStrings(expected, line[row.code_start..]); } } test "Locations context uses unsaved sources and navigation visits only matches" { if (!syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); const source = "const Box = struct {\n" ++ " pub fn run() void {\n" ++ " // unsaved before\n" ++ " first();\n" ++ " // shared context\n" ++ " second();\n" ++ " // unsaved after\n" ++ " }\n};\n"; const pane = try TreeContextTests.setZigFile(p, source); p.locations_config.context = 1; p.locations_config.tscontext = true; p.locations_config.tslocations = false; const path = pane.file.?.path; const input = try std.fmt.allocPrint(p.gpa, "{s}:6:9 second();\n{s}:4:9 first();\n", .{ path, path }); // Original first result is the later source match: sorting and inserted // context must both preserve its anchor. try fillResults(p, 0, "/", .search, "context", input, 0); const result = p.panes[pane.search_pane.?].?; const file = &result.file.?; const expected_lines = [_]usize{ 1, 2, 3, 4, 5, 6, 7 }; try std.testing.expectEqual(expected_lines.len, file.location_rows.len); var matches: [2]usize = undefined; var count: usize = 0; for (file.location_rows, expected_lines, 0..) |row, expected_line, i| { try std.testing.expectEqual(expected_line, row.at.line); try std.testing.expectEqualStrings(path, row.path); const line = modal.lineSlice(file.content, i); if (row.kind == .match) { matches[count] = i; count += 1; } else { try std.testing.expect(std.mem.startsWith(u8, line, "| ")); try std.testing.expect(!panes.Output.isResultRow(file, i)); try std.testing.expectEqualStrings(modal.lineSlice(source, expected_line - 1), line[row.code_start..]); } if (expected_line <= 2) try std.testing.expectEqual(@as(usize, 0), row.location_end); } try std.testing.expectEqual(@as(usize, 2), count); try std.testing.expectEqual(matches[1], pane.search_row.?); try std.testing.expect(pardes.test_api.searchStep(p, 0, -1)); try std.testing.expectEqual(matches[0], pane.search_row.?); try std.testing.expectEqual(@as(i32, 3), pane.cur_row); try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); try std.testing.expectEqual(matches[1], pane.search_row.?); try std.testing.expectEqual(@as(i32, 5), pane.cur_row); try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); try std.testing.expectEqual(matches[1], pane.search_row.?); pane.search_row = null; try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); try std.testing.expectEqual(matches[0], pane.search_row.?); var frame = std.heap.ArenaAllocator.init(p.gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const declaration = file.location_rows[0]; const keyword = declaration.code_start + std.mem.indexOf(u8, modal.lineSlice(source, 0), "struct").?; const styles = file.highlights[keyword..][0.."struct".len]; var colored = false; for (styles) |style| colored = colored or style != 0; try std.testing.expect(colored); try p.dumpState(); const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); const restored_source = restored.panes[0].?; const restored_id = pane.search_pane.?; const restored_result = restored.panes[restored_id].?; const restored_file = &restored_result.file.?; try std.testing.expectEqualStrings(file.content, restored_file.content); try std.testing.expectEqual(file.location_rows.len, restored_file.location_rows.len); for (file.location_rows, restored_file.location_rows) |before, after| { try std.testing.expectEqual(before.kind, after.kind); try std.testing.expectEqualStrings(before.path, after.path); try std.testing.expectEqual(before.at, after.at); try std.testing.expectEqual(before.location_end, after.location_end); try std.testing.expectEqual(before.code_start, after.code_start); try std.testing.expectEqualSlices(u8, before.colors, after.colors); } _ = try restored.render(frame.allocator()); try std.testing.expectEqualSlices(u8, styles, restored_file.highlights[keyword..][0.."struct".len]); // Search links are transient UI state; the restored output metadata // still governs stepping when that output is attached to a source. restored_source.search_pane = restored_id; restored_source.search_row = null; try std.testing.expect(pardes.test_api.searchStep(restored, 0, 1)); try std.testing.expectEqual(matches[0], restored_source.search_row.?); try std.testing.expect(pardes.test_api.searchStep(restored, 0, 1)); try std.testing.expectEqual(matches[1], restored_source.search_row.?); try std.testing.expect(pardes.test_api.searchStep(restored, 0, -1)); try std.testing.expectEqual(matches[0], restored_source.search_row.?); // Once an edit invalidates source metadata, the explicit marker still // prevents context declarations from becoming search destinations. panes.File.setContent(p, file, try p.gpa.dupe(u8, file.content)); try std.testing.expectEqual(@as(usize, 0), file.location_rows.len); pane.search_row = null; try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); try std.testing.expectEqual(matches[0], pane.search_row.?); try std.testing.expect(pardes.test_api.searchStep(p, 0, 1)); try std.testing.expectEqual(matches[1], pane.search_row.?); } 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 "compact PDF saved prefix separates page status from editable commands" { const path = "/tmp/a document.pdf"; try std.testing.expectEqualStrings(path ++ " [2/9]", legacySavedPrefix(path, path ++ " [2/9] Keep Del").?); try std.testing.expect(legacySavedPrefix(path, path ++ " [oops] Keep Del") == null); try std.testing.expect(legacySavedPrefix(path, path ++ " [2/9/4] Keep Del") == null); } 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 { // Tests asserting raw ANSI indices explicitly select helix, whose palette // is inherited. Native-palette coverage lives in the overlay/eviction and // trailing-blank tests and checks their concrete RGB values instead. 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[1;4A" }, .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" }, .{ .key = .{ .cp = Key.home }, .expected = "\x1b[H" }, }; 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, .{ .startup_config = "Theme helix\n", .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, .{ .startup_config = "Theme helix\n", .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, .{ .startup_config = "Theme helix\n", .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, .{ .startup_config = "Theme helix\n", .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, .{ .startup_config = "Theme helix\n", .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, .{ .startup_config = "Theme helix\n", .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, .{ .startup_config = "Theme helix\n", .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, .{ .startup_config = "Theme helix\n", .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, .{ .startup_config = "Theme helix\n", .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 { _ = TagNavigationTests; _ = TagNameTintTests; _ = TreeContextTests; _ = BodyHitTests; _ = TtySelectionTests; _ = ClickCursorTests; _ = JumpSelectionTests; _ = @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.update(.{ .command = "SyntaxBold" }); try std.testing.expect(p.settings.syntax_bold); 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 palette = p.theme().palette.?; const colors = [_]pardes.Color{ .{ .rgb = palette[1] }, .{ .rgb = palette[2] }, .{ .rgb = palette[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); }