diff --git a/src/config/io.zig b/src/config/io.zig index 056dc87446d..c984b81034f 100644 --- a/src/config/io.zig +++ b/src/config/io.zig @@ -150,6 +150,30 @@ pub const ReadableIO = union(enum) { try v.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); try std.testing.expectEqualSlices(u8, "a = raw:foo\n", buf.written()); } + + // The embedded apprt stores initial input as a Zig-escaped raw + // value, so parsing it must give back the exact input bytes. + test "cloneParsed restores Zig-escaped bytes" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const inputs = [_][]const u8{ + "printf '\x1b]0;라마바OSC테스트\x07'\r", + "日本語のタイトル", + "\u{1F680} \u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", + "e\u{301}cole \u{1112}\u{1161}\u{11AB}", + "invalid \xff\xfe\x80 bytes", + }; + for (inputs) |input| { + var escaped: std.Io.Writer.Allocating = .init(alloc); + try std.zig.stringEscape(input, &escaped.writer); + const v: Self = .{ .raw = try escaped.toOwnedSliceSentinel(0) }; + const parsed = try v.cloneParsed(alloc); + try testing.expectEqualSlices(u8, input, parsed.raw); + } + } }; pub const RepeatableReadableIO = struct { diff --git a/src/config/string.zig b/src/config/string.zig index 45079937321..8d4fc7b4551 100644 --- a/src/config/string.zig +++ b/src/config/string.zig @@ -20,16 +20,23 @@ pub fn parse(out: []u8, bytes: []const u8) ![]u8 { continue; } + // Zig's \xNN syntax represents a single byte, while the other + // escape sequences represent Unicode codepoints. + const is_byte_escape = src_i + 1 < bytes.len and + bytes[src_i + 1] == 'x'; + // Parse the escape sequence switch (std.zig.string_literal.parseEscapeSequence( bytes, &src_i, )) { .failure => return error.InvalidString, - .success => |cp| dst_i += try std.unicode.utf8Encode( - cp, - out[dst_i..], - ), + .success => |cp| if (is_byte_escape) { + out[dst_i] = @intCast(cp); + dst_i += 1; + } else { + dst_i += try std.unicode.utf8Encode(cp, out[dst_i..]); + }, } } @@ -100,6 +107,15 @@ test "parse: escapes" { } } +test "parse: hex escapes are bytes" { + var buf: [128]u8 = undefined; + const result = try parse( + &buf, + "\\xe6\\x97\\xa5\\xe6\\x9c\\xac\\xe8\\xaa\\x9e", + ); + try std.testing.expectEqualStrings("日本語", result); +} + test "codepointIterator: empty" { var it = codepointIterator(""); try std.testing.expectEqual(null, try it.next()); diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index e2332113773..8b87f2b50a7 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1143,9 +1143,15 @@ pub const PageFormatter = struct { }; // If this row is blank, accumulate to avoid a bunch of extra - // work later. If it isn't blank, make sure we dump all our - // blanks. - if (!Cell.hasTextAny(cells_subset)) { + // work later. A styled blank row is content for styled output: + // dropping it loses full-width background bands during replay. + const has_styled_content = if (formatStyled(self.opts.emit)) styled: { + for (cells_subset) |cell| { + if (!cell.isEmpty() or cell.hasStyling()) break :styled true; + } + break :styled false; + } else false; + if (!Cell.hasTextAny(cells_subset) and !has_styled_content) { blank_rows += 1; continue; } @@ -6718,6 +6724,43 @@ test "Page VT background color on trailing blank cells" { try testing.expect(has_red_bg_line1); } +test "Page VT preserves a fully styled blank row" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(io, alloc, .{ + .cols = 20, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // Erase the complete first row with a background color and then put text + // on the next row. The first row has no text cells, but every cell carries + // the background style that a replay must preserve. + s.nextSlice("\x1b[41m\x1b[2K\x1b[0m\r\nline2"); + + const page = t.screens.active.pages.pages.last.?.page(); + var opts: Options = .vt; + opts.trim = false; + var formatter: PageFormatter = .init(page, opts); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const first_break = std.mem.indexOf(u8, output, "\r\n") orelse { + return error.TestUnexpectedResult; + }; + // SGR 41 is stored as palette background 1, which the VT formatter + // writes in its indexed form. + try testing.expect(std.mem.indexOf(u8, output[0..first_break], "\x1b[48;5;1m") != null); + try testing.expectEqual(@as(usize, 20), std.mem.count(u8, output[0..first_break], " ")); +} + test "Page HTML with hyperlinks" { const testing = std.testing; const alloc = testing.allocator;