From edad6d960dc0fc1165cbb3b801e0538045b2247f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 10:09:54 -0300 Subject: A +Pager keeps its program's colours: the session parses what /pager is written after its directory with ghostty-vt, SGR becomes spans drawn over the plain text, every other escape is dropped, and PagerColor off pages it plain --- src/pager.zig | 373 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 src/pager.zig (limited to 'src/pager.zig') diff --git a/src/pager.zig b/src/pager.zig new file mode 100644 index 00000000..30418623 --- /dev/null +++ b/src/pager.zig @@ -0,0 +1,373 @@ +//! 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 } } }}); +} -- cgit v1.3