//! A program's output, paged into a +Pager (`pardes -`): the text it //! prints, and its SGR colours as spans over that text. ghostty-vt, which //! terminal panes already drive, parses every escape; this keeps what is //! printed and how SGR styles it, and drops every other control (cursor //! motion, OSC titles and hyperlinks, modes). The spans are display state //! of the pane only: no 9P file reads them (File.recolorSyntax draws them). const std = @import("std"); const pardes = @import("pardes.zig"); const CellStyle = @import("surface.zig").CellStyle; const ghostty_vt = if (pardes.terminal_panes) @import("ghostty-vt") else struct {}; /// Bytes `start..end` of the text, drawn in `style`. A palette colour stays /// `.index`, resolved through the theme as it is drawn (terminal.palColor), /// and `.default` is the pane's own. pub const Span = struct { start: u32, end: u32, style: CellStyle }; /// ponytail: past this many spans the rest of a page is plain, rather than /// ~100 MB of spans for a 256 MiB coloured log; a packed style if it matters. pub const max_spans = 4 << 20; pub const Page = struct { text: []u8, spans: []Span, pub fn deinit(page: Page, gpa: std.mem.Allocator) void { gpa.free(page.text); gpa.free(page.spans); } }; /// `raw`, a program's output for a terminal, as text and, `colour` on /// (PagerColor), its spans. Both are `gpa`'s. pub fn parse(gpa: std.mem.Allocator, raw: []const u8, colour: bool) !Page { // ponytail: no ghostty-vt on esp32p4 (no terminal panes, no PAGER): // the text is taken as sent. if (comptime !pardes.terminal_panes) return .{ .text = try gpa.dupe(u8, raw), .spans = &.{} }; var h: Handler = .{ .gpa = gpa, .colour = colour }; errdefer h.text.deinit(gpa); errdefer h.spans.deinit(gpa); try h.text.ensureTotalCapacity(gpa, raw.len); var stream: ghostty_vt.Stream(*Handler) = .init(&h); defer stream.deinit(); stream.nextSlice(raw); if (h.failed) return error.OutOfMemory; const text = try h.text.toOwnedSlice(gpa); errdefer gpa.free(text); return .{ .text = text, .spans = try h.spans.toOwnedSlice(gpa) }; } const Handler = struct { gpa: std.mem.Allocator, colour: bool, text: std.ArrayList(u8) = .empty, spans: std.ArrayList(Span) = .empty, /// What SGR has set so far. style: CellStyle = .{}, /// Where the line being printed starts, and its last character. line_start: usize = 0, last: usize = 0, /// A carriage return not yet followed by a newline: the line starts /// over at the next print, as on a terminal, so a progress line keeps /// its last state. cr: bool = false, /// A backspace: the next print strikes over the character before it. bs: bool = false, failed: bool = false, const Action = ghostty_vt.StreamAction; pub fn vt(h: *Handler, comptime action: Action.Tag, value: Action.Value(action)) void { switch (action) { .print => h.print(value.cp), .print_slice => h.printRun(value.cps), // CSI b repeats the last character, as a terminal draws it; // a terminal stops at its edge, and this at a line's worth. .print_repeat => if (h.text.items.len > h.last) { const cp = std.unicode.utf8Decode(h.text.items[h.last..]) catch return; for (0..@min(value, 1024)) |_| h.print(cp); }, .set_attribute => if (h.colour) h.attribute(value), .linefeed => { h.append("\n"); h.line_start = h.text.items.len; h.last = h.line_start; h.cr = false; h.bs = false; }, .carriage_return => h.cr = true, .backspace => h.bs = h.text.items.len > h.line_start, .horizontal_tab => for (0..@min(value, 64)) |_| h.print('\t'), else => {}, } } pub fn deinit(_: *Handler) void {} // ponytail: bytes that are not UTF-8 reach here as U+FFFD (ghostty's // decoder replaces them, as a terminal shows them); a raw-byte pass // through the stream if a +Pager must keep them. fn print(h: *Handler, cp: u21) void { // A control a terminal acts on and never draws: ghostty's fast path // hands those past 0x0f (CAN, DEL, the C1 set) on as printed text. // A tab is HT's, printed here. if ((cp < 0x20 and cp != '\t') or (cp >= 0x7f and cp < 0xa0)) return; if (h.cr) { h.text.shrinkRetainingCapacity(h.line_start); h.cut(h.line_start); h.last = h.line_start; h.cr = false; h.bs = false; } var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(cp, &buf) catch std.unicode.utf8Encode(0xfffd, &buf) catch unreachable; if (h.bs) { // A man page's overstrike: a character struck over itself is // bold, one struck with `_` (either way round) is underlined; // any other is replaced, as on a terminal. h.bs = false; const prev = h.text.items[h.last..]; var struck = h.style; if (std.mem.eql(u8, prev, buf[0..n])) { struck.bold = true; } else if (std.mem.eql(u8, prev, "_")) { h.text.shrinkRetainingCapacity(h.last); h.append(buf[0..n]); struck.ul = .single; } else if (cp == '_') { struck.ul = .single; } else { h.text.shrinkRetainingCapacity(h.last); h.cut(h.last); h.last = h.text.items.len; h.append(buf[0..n]); h.span(h.last, h.style); return; } h.cut(h.last); h.span(h.last, struck); return; } h.last = h.text.items.len; h.append(buf[0..n]); h.span(h.last, h.style); } /// A run of printed characters (most of a page): the first as `print` /// takes it, a pending return or backspace and all, the rest straight /// on, as one span. A 256 MiB page goes through here. fn printRun(h: *Handler, cps: []const u32) void { if (cps.len == 0) return; h.print(@intCast(cps[0])); const rest = cps[1..]; if (rest.len == 0) return; var need: usize = 0; for (rest) |cp| need += if (cp < 0x80) 1 else std.unicode.utf8CodepointSequenceLength(@intCast(cp)) catch 3; h.text.ensureUnusedCapacity(h.gpa, need) catch { h.failed = true; return; }; const from = h.text.items.len; for (rest) |cp| { if (cp < 0x20 or (cp >= 0x7f and cp < 0xa0)) continue; h.last = h.text.items.len; if (cp < 0x80) { h.text.appendAssumeCapacity(@intCast(cp)); continue; } var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(@intCast(cp), &buf) catch std.unicode.utf8Encode(0xfffd, &buf) catch unreachable; h.text.appendSliceAssumeCapacity(buf[0..n]); } h.span(from, h.style); } fn append(h: *Handler, bytes: []const u8) void { h.text.appendSlice(h.gpa, bytes) catch { h.failed = true; }; } /// `from` to the text's end drawn in `style`: the span before it goes /// on when it ends there in the same style. fn span(h: *Handler, from: usize, style: CellStyle) void { if (!h.colour or std.meta.eql(style, CellStyle{})) return; const end: u32 = @intCast(h.text.items.len); if (h.spans.items.len > 0) { const tail = &h.spans.items[h.spans.items.len - 1]; if (tail.end == from and std.meta.eql(tail.style, style)) { tail.end = end; return; } } if (h.spans.items.len >= max_spans) return; h.spans.append(h.gpa, .{ .start = @intCast(from), .end = end, .style = style }) catch { h.failed = true; }; } /// No span reaches past `at` any more: the text there is gone. fn cut(h: *Handler, at: usize) void { while (h.spans.items.len > 0) { const tail = &h.spans.items[h.spans.items.len - 1]; if (tail.end <= at) return; if (tail.start < at) { tail.end = @intCast(at); return; } _ = h.spans.pop(); } } fn attribute(h: *Handler, attr: ghostty_vt.Attribute) void { const s = &h.style; switch (attr) { .unset => s.* = .{}, .bold => s.bold = true, // SGR 22 ends faint as well as bold. .reset_bold => { s.bold = false; s.dim = false; }, .faint => s.dim = true, .italic => s.italic = true, .reset_italic => s.italic = false, .underline => |u| s.ul = switch (u) { .none => .off, .single => .single, .double => .double, .curly => .curly, .dotted => .dotted, .dashed => .dashed, }, .blink => s.blink = true, .reset_blink => s.blink = false, .inverse => s.reverse = true, .reset_inverse => s.reverse = false, .invisible => s.invisible = true, .reset_invisible => s.invisible = false, .strikethrough => s.strikethrough = true, .reset_strikethrough => s.strikethrough = false, .direct_color_fg => |c| s.fg = .{ .rgb = .{ c.r, c.g, c.b } }, .direct_color_bg => |c| s.bg = .{ .rgb = .{ c.r, c.g, c.b } }, .@"8_fg", .@"8_bright_fg" => |name| s.fg = .{ .index = @intFromEnum(name) }, .@"8_bg", .@"8_bright_bg" => |name| s.bg = .{ .index = @intFromEnum(name) }, .@"256_fg" => |i| s.fg = .{ .index = i }, .@"256_bg" => |i| s.bg = .{ .index = i }, .reset_fg => s.fg = .default, .reset_bg => s.bg = .default, // A cell has no overline and no underline colour to take. .overline, .reset_overline, .underline_color, .@"256_underline_color", .reset_underline_color, .unknown => {}, } } }; // ---- tests ---- const testing = std.testing; /// The text `raw` pages to, and its spans as `text[start..end]=style` lines. fn expectPage(raw: []const u8, text: []const u8, spans: []const struct { []const u8, CellStyle }) !void { if (comptime !pardes.terminal_panes) return error.SkipZigTest; const page = try parse(testing.allocator, raw, true); defer page.deinit(testing.allocator); try testing.expectEqualStrings(text, page.text); for (spans, 0..) |want, i| { if (i >= page.spans.len) { std.debug.print("missing span {d}: \"{s}\"\n", .{ i, want[0] }); return error.TestExpectedEqual; } const got = page.spans[i]; try testing.expectEqualStrings(want[0], page.text[got.start..got.end]); try testing.expectEqualDeep(want[1], got.style); } try testing.expectEqual(spans.len, page.spans.len); } test "git log --color: the commit line yellow, the rest plain" { try expectPage( "\x1b[33mcommit 0123abc\x1b[m\x1b[33m (\x1b[m\x1b[1;36mHEAD -> \x1b[m\x1b[1;32mmain\x1b[m\x1b[33m)\x1b[m\nAuthor: a \n\n subject\n", "commit 0123abc (HEAD -> main)\nAuthor: a \n\n subject\n", &.{ .{ "commit 0123abc (", .{ .fg = .{ .index = 3 } } }, .{ "HEAD -> ", .{ .fg = .{ .index = 6 }, .bold = true } }, .{ "main", .{ .fg = .{ .index = 2 }, .bold = true } }, .{ ")", .{ .fg = .{ .index = 3 } } }, }, ); } test "ls --color=always: 01;34 directories and 01;32 executables, reset by 0 and none" { try expectPage( "\x1b[0m\x1b[01;34mdir\x1b[0m\n\x1b[01;32mrun.sh\x1b[0m\nplain.txt\n", "dir\nrun.sh\nplain.txt\n", &.{ .{ "dir", .{ .fg = .{ .index = 4 }, .bold = true } }, .{ "run.sh", .{ .fg = .{ .index = 2 }, .bold = true } }, }, ); } test "grep --color=always: the match, the file name and the separator, and EL dropped" { try expectPage( "\x1b[35m\x1b[Ka.txt\x1b[m\x1b[K\x1b[36m\x1b[K:\x1b[m\x1b[Kone \x1b[01;31m\x1b[Kfoo\x1b[m\x1b[K two\n", "a.txt:one foo two\n", &.{ .{ "a.txt", .{ .fg = .{ .index = 5 } } }, .{ ":", .{ .fg = .{ .index = 6 } } }, .{ "foo", .{ .fg = .{ .index = 1 }, .bold = true } }, }, ); } test "a man page's overstrike: bold and underline, and a stray backspace" { try expectPage( "N\x08NA\x08AM\x08ME\x08E _\x08u_\x08s x\x08_ a\x08b\x08\n", "NAME us x b\n", &.{ .{ "NAME", .{ .bold = true } }, .{ "us", .{ .ul = .single } }, .{ "x", .{ .ul = .single } }, }, ); } test "truecolor, 256 colours, background, italic, faint, inverse and curly underline" { try expectPage( "\x1b[38;2;255;128;0mo\x1b[48;5;236mb\x1b[0;3;2mi\x1b[7mr\x1b[0;4:3mc\x1b[m.\n", "obirc.\n", &.{ .{ "o", .{ .fg = .{ .rgb = .{ 255, 128, 0 } } } }, .{ "b", .{ .fg = .{ .rgb = .{ 255, 128, 0 } }, .bg = .{ .index = 236 } } }, .{ "i", .{ .italic = true, .dim = true } }, .{ "r", .{ .italic = true, .dim = true, .reverse = true } }, .{ "c", .{ .ul = .curly } }, }, ); } test "every other escape is dropped, and a stray or broken one loses no text after it" { const cases = [_][2][]const u8{ .{ "a\x1b]8;;http://x\x1b\\link\x1b]8;;\x07b", "alinkb" }, .{ "\x1b]0;title\x07\x1b[2J\x1b[H\x1b[?1049hdone\n", "done\n" }, .{ "\x1b(Bdone", "done" }, .{ "plain\ttext\n", "plain\ttext\n" }, .{ "a\r\nb\r\n", "a\nb\n" }, .{ "10%\r50%\r\x1b[K100%\ndone\n", "100%\ndone\n" }, .{ "x\x00y\x07z\x0e\x0f\n", "xyz\n" }, // Controls ghostty's fast path prints: CAN, FS, DEL and a C1. .{ "x\x18y\x1cz\x7f!\u{85}?\n", "xyz!?\n" }, // A broken CSI: its parameter bytes are eaten up to a final byte, as // a terminal eats them; CAN abandons one, and the text goes on. .{ "a\x1b[31;\x18b\x1b[m\n", "ab\n" }, // An unknown SGR changes nothing; an ESC at the very end is dropped. .{ "\x1b[99mz\x1b", "z" }, }; for (cases) |c| try expectPage(c[0], c[1], &.{}); } test "bytes that are not UTF-8 page as U+FFFD, the text around them kept" { try expectPage("caf\xe9 \xff\xfe ok\n", "caf\u{fffd} \u{fffd}\u{fffd} ok\n", &.{}); } test "PagerColor off: the same text, no spans, overstrike included" { if (comptime !pardes.terminal_panes) return error.SkipZigTest; const page = try parse(testing.allocator, "\x1b[31mred\x1b[m N\x08N\n", false); defer page.deinit(testing.allocator); try testing.expectEqualStrings("red N\n", page.text); try testing.expectEqual(@as(usize, 0), page.spans.len); } test "a carriage return's rewrite takes the spans of what it rewrote with it" { try expectPage("\x1b[31m10%\r\x1b[32m100%\x1b[m\n", "100%\n", &.{.{ "100%", .{ .fg = .{ .index = 2 } } }}); }