summaryrefslogtreecommitdiff
path: root/src/pager.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pager.zig')
-rw-r--r--src/pager.zig373
1 files changed, 373 insertions, 0 deletions
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 <a@x>\n\n subject\n",
+ "commit 0123abc (HEAD -> main)\nAuthor: a <a@x>\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 } } }});
+}